.log) * Server Logs page (per server) * Notification system (email when server goes offline) * Notifications page * User management (Super Admin) * Activity log * Simple GeoIP lookup (if PHP geoip extension is present) Requirements: - PHP 7.4+ with PDO MySQL - Web server reachable from your CS 1.6 servers - mail() configured (for notifications) or use your own mail transport NOTE: - GoldSrcRcon class is untouched. - Existing API endpoints are kept; only new ones are added. ================================================================================ */ // ========================= CONFIG ========================= $config = [ 'app_name' => 'LongHorn CStrike Watch', 'version' => '1.0', 'session_timeout' => 3600, // Optional Cloudflare Turnstile (read from environment) 'turnstile_site_key' => getenv('TURNSTILE_SITE_KEY') ?: '', 'turnstile_secret_key' => getenv('TURNSTILE_SECRET_KEY') ?: '', // Auth anti-flood / anti-bruteforce 'auth_rate_window_seconds' => 300, 'auth_rate_max_requests' => 30, 'auth_rate_max_failures' => 8, 'auth_rate_lock_seconds' => 900, // Email for notifications (change this!) 'email_from' => 'no-reply@example.com', // Health-check token (used by cron or external pinger) // Change this to a strong random string. 'health_token' => 'CHANGE_ME_HEALTH_TOKEN', // Log directory 'log_dir' => __DIR__ . '/logs', // Local storage for auth rate-limit files 'rate_limit_dir' => __DIR__ . '/rate_limits', // HLDS log receiver (for real-time console log streaming) // Keep empty to auto-detect a reachable host from current request/server. // Set this in config.php to your public panel IP/domain when game servers are remote. 'hlds_log_receiver_host' => '', 'hlds_log_receiver_port' => 27150, // Database Config (MariaDB / MySQL) 'db_host' => '127.0.0.1', 'db_name' => 'gameserver_manager', 'db_user' => 'root', 'db_pass' => '', ]; // Load external config if available (for Live Server deployment) if (file_exists(__DIR__ . '/config.php')) { $localConfig = require __DIR__ . '/config.php'; if (is_array($localConfig)) { $config = array_merge($config, $localConfig); } } // ========================= UTIL / INIT ========================= function ensureLogsDir() { global $config; if (!is_dir($config['log_dir'])) { @mkdir($config['log_dir'], 0775, true); } } function ensureRateLimitDir() { global $config; if (!is_dir($config['rate_limit_dir'])) { @mkdir($config['rate_limit_dir'], 0775, true); } } // Optional live UDP probe helpers $__lhLiveUdpPath = __DIR__ . '/lib/live_udp.php'; if (file_exists($__lhLiveUdpPath)) { require_once $__lhLiveUdpPath; } // Optional MaxMind GeoIP helpers (shared with server_info.php) $__lhGeoIpMaxMindPath = __DIR__ . '/lib/geoip_maxmind.php'; if (file_exists($__lhGeoIpMaxMindPath)) { require_once $__lhGeoIpMaxMindPath; } function panelFlagFromCountryCode(string $code): string { $code = strtoupper(trim($code)); if ($code === '' || strlen($code) !== 2) { return ''; } $a = ord($code[0]); $b = ord($code[1]); if ($a < 65 || $a > 90 || $b < 65 || $b > 90) { return ''; } if (function_exists('mb_chr')) { return mb_chr(127397 + $a, 'UTF-8') . mb_chr(127397 + $b, 'UTF-8'); } return ''; } function panelGeoDefaults(): array { return [ 'country' => 'Unknown', 'country_code' => '', 'country_flag' => '', 'region' => 'Unknown', 'city' => 'Unknown', 'continent' => 'Unknown', 'timezone' => 'Unknown', 'isp' => 'Unknown', 'org' => 'Unknown', 'asn' => 'Unknown', ]; } function panelLookupGeoByIp(string $ip): array { $geo = panelGeoDefaults(); $ip = trim($ip); if ($ip === '' || !filter_var($ip, FILTER_VALIDATE_IP)) { return $geo; } static $memo = []; if (isset($memo[$ip])) { return $memo[$ip]; } if (function_exists('lh_geoip_lookup_maxmind')) { $maxMind = lh_geoip_lookup_maxmind($ip); if (is_array($maxMind)) { $countryCode = strtoupper(trim((string)($maxMind['country_code'] ?? ''))); $countryName = trim((string)($maxMind['country_name'] ?? '')); $city = trim((string)($maxMind['city'] ?? '')); $region = trim((string)($maxMind['region'] ?? '')); $asn = trim((string)($maxMind['asn'] ?? '')); $asOrg = trim((string)($maxMind['as_org'] ?? '')); if ($countryCode !== '' || $countryName !== '' || $city !== '' || $region !== '' || $asn !== '' || $asOrg !== '') { $geo['country_code'] = $countryCode; $geo['country'] = $countryName !== '' ? $countryName : 'Unknown'; $geo['country_flag'] = panelFlagFromCountryCode($countryCode); $geo['city'] = $city !== '' ? $city : 'Unknown'; $geo['region'] = $region !== '' ? $region : 'Unknown'; if ($asn !== '') { $geo['asn'] = $asn; } if ($asOrg !== '') { $geo['org'] = $asOrg; } $memo[$ip] = $geo; return $geo; } } } $allowRemote = true; if (function_exists('lh_geoip_config')) { $geoCfg = lh_geoip_config(); if (is_array($geoCfg) && array_key_exists('fallback_remote', $geoCfg)) { $allowRemote = (bool)$geoCfg['fallback_remote']; } } if (!$allowRemote) { $memo[$ip] = $geo; return $geo; } $ctx = stream_context_create([ 'http' => [ 'timeout' => 2.5, 'ignore_errors' => true, 'header' => "User-Agent: LongHorn-HLSW/1.0\r\nAccept: application/json\r\n", ], ]); $raw = @file_get_contents('https://ipwho.is/' . rawurlencode($ip), false, $ctx); if (!is_string($raw) || $raw === '') { $memo[$ip] = $geo; return $geo; } $data = json_decode($raw, true); if (!is_array($data) || empty($data['success'])) { $memo[$ip] = $geo; return $geo; } $countryCode = strtoupper(trim((string)($data['country_code'] ?? ''))); $country = trim((string)($data['country'] ?? '')); $region = trim((string)($data['region'] ?? '')); $city = trim((string)($data['city'] ?? '')); $continent = trim((string)($data['continent'] ?? '')); $tz = trim((string)($data['timezone']['id'] ?? ($data['timezone'] ?? ''))); $isp = trim((string)($data['connection']['isp'] ?? '')); $org = trim((string)($data['connection']['org'] ?? '')); $asnRaw = trim((string)($data['connection']['asn'] ?? '')); $asn = strtoupper($asnRaw); $geo['country_code'] = $countryCode; $geo['country'] = $country !== '' ? $country : 'Unknown'; $geo['country_flag'] = panelFlagFromCountryCode($countryCode); $geo['region'] = $region !== '' ? $region : 'Unknown'; $geo['city'] = $city !== '' ? $city : 'Unknown'; $geo['continent'] = $continent !== '' ? $continent : 'Unknown'; $geo['timezone'] = $tz !== '' ? $tz : 'Unknown'; $geo['isp'] = $isp !== '' ? $isp : 'Unknown'; $geo['org'] = $org !== '' ? $org : 'Unknown'; $geo['asn'] = $asn !== '' ? $asn : 'Unknown'; $memo[$ip] = $geo; return $geo; } function getDB(): PDO { global $config; static $db = null; if ($db === null) { try { $db = new PDO("mysql:host={$config['db_host']};dbname={$config['db_name']};charset=utf8", $config['db_user'], $config['db_pass']); $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); } catch (PDOException $e) { die("Database connection failed: " . $e->getMessage()); } } return $db; } function initDatabase() { global $config; try { // Connect without DB name first to create it if it doesn't exist $tempDb = new PDO("mysql:host={$config['db_host']};charset=utf8", $config['db_user'], $config['db_pass']); $tempDb->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $tempDb->exec("CREATE DATABASE IF NOT EXISTS `{$config['db_name']}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); $tempDb = null; } catch (PDOException $e) { die("

Database Error

Could not connect to MariaDB/MySQL.

"); } $db = getDB(); // Servers table $db->exec('CREATE TABLE IF NOT EXISTS servers ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, ip VARCHAR(255) NOT NULL, port INT DEFAULT 27015, rcon_password VARCHAR(255), banned_cfg_path VARCHAR(1024) NULL, game_type VARCHAR(50) DEFAULT "CS 1.6", status VARCHAR(50) DEFAULT "unknown", status_checked_at DATETIME NULL, user_id INT DEFAULT 1, max_players INT DEFAULT 32, community_name VARCHAR(255) NULL, website_url VARCHAR(255) NULL, discord_url VARCHAR(255) NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )'); // Users table $db->exec('CREATE TABLE IF NOT EXISTS users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(255) UNIQUE NOT NULL, password VARCHAR(255) NOT NULL, role VARCHAR(50) DEFAULT "User", email VARCHAR(255), last_login DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )'); // Activity log (panel actions) $db->exec('CREATE TABLE IF NOT EXISTS activity_log ( id INT AUTO_INCREMENT PRIMARY KEY, user VARCHAR(255), action VARCHAR(255), details TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )'); // Per-server logs (panel-side events, RCON, status changes) $db->exec('CREATE TABLE IF NOT EXISTS server_logs ( id INT AUTO_INCREMENT PRIMARY KEY, server_id INT NOT NULL, level VARCHAR(50) DEFAULT "INFO", category VARCHAR(50), message TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )'); // Notifications (server down/up, etc.) $db->exec('CREATE TABLE IF NOT EXISTS notifications ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT, server_id INT, type VARCHAR(50), subject VARCHAR(255), body TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, sent_at DATETIME )'); // Snapshot of currently online players from "status" output $db->exec('CREATE TABLE IF NOT EXISTS players ( id INT AUTO_INCREMENT PRIMARY KEY, server_id INT NOT NULL, name VARCHAR(255) NOT NULL, steam_id VARCHAR(64), ip_address VARCHAR(64), ping INT DEFAULT 0, time_played VARCHAR(32), first_seen DATETIME DEFAULT CURRENT_TIMESTAMP, last_seen DATETIME DEFAULT CURRENT_TIMESTAMP )'); // Ban records (panel + optional RCON execution) $db->exec('CREATE TABLE IF NOT EXISTS bans ( id INT AUTO_INCREMENT PRIMARY KEY, server_id INT NOT NULL, target VARCHAR(255) NOT NULL, reason VARCHAR(255), duration_minutes INT DEFAULT 0, expires_at DATETIME NULL, created_by INT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, is_active TINYINT(1) DEFAULT 1 )'); // RCON command history $db->exec('CREATE TABLE IF NOT EXISTS rcon_history ( id INT AUTO_INCREMENT PRIMARY KEY, server_id INT NOT NULL, user_id INT NULL, command TEXT NOT NULL, response MEDIUMTEXT, executed_at DATETIME DEFAULT CURRENT_TIMESTAMP )'); // Real-time HLDS log receiver storage $db->exec('CREATE TABLE IF NOT EXISTS hlds_live_logs ( id BIGINT AUTO_INCREMENT PRIMARY KEY, server_id INT NULL, source_ip VARCHAR(45) NOT NULL, source_port INT DEFAULT 0, message TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_hlds_server_id (server_id, id), KEY idx_hlds_created (created_at) )'); // Server likes / favorites from users + visitors $db->exec('CREATE TABLE IF NOT EXISTS server_reactions ( server_id INT NOT NULL, actor_key VARCHAR(190) NOT NULL, voted TINYINT(1) DEFAULT 0, favorited TINYINT(1) DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (server_id, actor_key), KEY idx_sr_votes (server_id, voted), KEY idx_sr_favs (server_id, favorited) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'); // Check if admin exists $stmt = $db->query("SELECT id FROM users WHERE username = 'admin'"); if (!$stmt->fetch()) { $password_hash = password_hash('admin123', PASSWORD_DEFAULT); $stmtIns = $db->prepare("INSERT INTO users (username, password, role, email) VALUES (?, ?, ?, ?)"); $stmtIns->bindValue(1, 'admin', PDO::PARAM_STR); $stmtIns->bindValue(2, $password_hash, PDO::PARAM_STR); $stmtIns->bindValue(3, 'Super Admin', PDO::PARAM_STR); $stmtIns->bindValue(4, 'admin@example.com', PDO::PARAM_STR); $stmtIns->execute(); } // Ensure user_id column exists (for older setups) $colRes = $db->query("SHOW COLUMNS FROM servers LIKE 'user_id'"); if (!$colRes->fetch()) { $db->exec("ALTER TABLE servers ADD COLUMN user_id INT DEFAULT 1"); } // Ensure max_players exists (for old installs) $colRes = $db->query("SHOW COLUMNS FROM servers LIKE 'max_players'"); if (!$colRes->fetch()) { $db->exec("ALTER TABLE servers ADD COLUMN max_players INT DEFAULT 32"); } // Ensure status_checked_at exists (for old installs) $colRes = $db->query("SHOW COLUMNS FROM servers LIKE 'status_checked_at'"); if (!$colRes->fetch()) { $db->exec("ALTER TABLE servers ADD COLUMN status_checked_at DATETIME NULL"); } // Ensure customizable profile fields exist (for old installs) $colRes = $db->query("SHOW COLUMNS FROM servers LIKE 'community_name'"); if (!$colRes->fetch()) { $db->exec("ALTER TABLE servers ADD COLUMN community_name VARCHAR(255) NULL"); } $colRes = $db->query("SHOW COLUMNS FROM servers LIKE 'website_url'"); if (!$colRes->fetch()) { $db->exec("ALTER TABLE servers ADD COLUMN website_url VARCHAR(255) NULL"); } $colRes = $db->query("SHOW COLUMNS FROM servers LIKE 'discord_url'"); if (!$colRes->fetch()) { $db->exec("ALTER TABLE servers ADD COLUMN discord_url VARCHAR(255) NULL"); } $colRes = $db->query("SHOW COLUMNS FROM servers LIKE 'banned_cfg_path'"); if (!$colRes->fetch()) { $db->exec("ALTER TABLE servers ADD COLUMN banned_cfg_path VARCHAR(1024) NULL"); } } initDatabase(); ensureLogsDir(); ensureRateLimitDir(); // ========================= SESSION ========================= session_start(); function isLoggedIn(): bool { global $config; if (!isset($_SESSION['user_id'])) { return false; } if (isset($_SESSION['last_activity']) && (time() - $_SESSION['last_activity'] > $config['session_timeout'])) { session_destroy(); return false; } $_SESSION['last_activity'] = time(); return true; } function requireLoginJSON() { if (!isLoggedIn()) { echo json_encode(['success' => false, 'error' => 'Authentication required']); exit; } } function isSuperAdmin(): bool { if (!isLoggedIn()) { return false; } $role = strtolower(trim((string)($_SESSION['role'] ?? ''))); return $role === 'super admin'; } function canViewAllServers(): bool { if (!isLoggedIn()) { return false; } $role = strtolower(trim((string)($_SESSION['role'] ?? ''))); return in_array($role, ['super admin', 'admin'], true); } function requireSuperAdminJSON() { if (!isSuperAdmin()) { echo json_encode(['success' => false, 'error' => 'Super Admin only']); exit; } } function logActivity(PDO $db, string $user, string $action, string $details) { $stmt = $db->prepare("INSERT INTO activity_log (user, action, details) VALUES (?, ?, ?)"); $stmt->bindValue(1, $user, PDO::PARAM_STR); $stmt->bindValue(2, $action, PDO::PARAM_STR); $stmt->bindValue(3, $details, PDO::PARAM_STR); $stmt->execute(); } function getAccessibleServer(PDO $db, int $serverId): ?array { if ($serverId <= 0) { return null; } $stmt = $db->prepare("SELECT * FROM servers WHERE id = ?"); $stmt->bindValue(1, $serverId, PDO::PARAM_INT); $stmt->execute(); $server = $stmt->fetch(PDO::FETCH_ASSOC); if (!$server) { return null; } if (!isSuperAdmin() && (int)$server['user_id'] !== (int)($_SESSION['user_id'] ?? 0)) { return null; } return $server; } function getServerReactionStats(PDO $db, int $serverId): array { if ($serverId <= 0) { return [ 'likes_total' => 0, 'likes_users' => 0, 'likes_visitors' => 0, 'favorites_total' => 0, ]; } try { $stmt = $db->prepare("SELECT COALESCE(SUM(CASE WHEN voted = 1 THEN 1 ELSE 0 END), 0) AS likes_total, COALESCE(SUM(CASE WHEN voted = 1 AND actor_key LIKE 'u:%' THEN 1 ELSE 0 END), 0) AS likes_users, COALESCE(SUM(CASE WHEN voted = 1 AND actor_key LIKE 'g:%' THEN 1 ELSE 0 END), 0) AS likes_visitors, COALESCE(SUM(CASE WHEN favorited = 1 THEN 1 ELSE 0 END), 0) AS favorites_total FROM server_reactions WHERE server_id = ?"); $stmt->bindValue(1, $serverId, PDO::PARAM_INT); $stmt->execute(); $row = $stmt->fetch(PDO::FETCH_ASSOC) ?: []; } catch (Throwable $_) { $row = []; } return [ 'likes_total' => (int)($row['likes_total'] ?? 0), 'likes_users' => (int)($row['likes_users'] ?? 0), 'likes_visitors' => (int)($row['likes_visitors'] ?? 0), 'favorites_total' => (int)($row['favorites_total'] ?? 0), ]; } function parseStatusPlayersOutput(string $output): array { $players = []; $lines = preg_split('/\r\n|\r|\n/', $output) ?: []; foreach ($lines as $line) { $line = trim((string)$line); if ($line === '') { continue; } // GoldSrc "status" player format: // # 1 "nick" 1 STEAM_0:1:12345 10 12:34 40 0 1.2.3.4:27005 if (!preg_match('/^\#?\s*(\d+)\s+"([^"]+)"\s+\d+\s+(\S+)\s+(-?\d+)\s+(\S+)\s+(\d+)\s+\d+\s+([0-9\.]+):\d+/', $line, $m)) { continue; } $players[] = [ 'user_id' => (int)$m[1], 'name' => $m[2], 'steam_id' => $m[3], 'frags' => (int)$m[4], 'time' => $m[5], 'ping' => (int)$m[6], 'ip' => $m[7], ]; } return $players; } function normalizePlayerNameForLookup(string $name): string { $name = strtolower(trim($name)); $name = preg_replace('/\s+/u', ' ', $name) ?? $name; return $name; } function normalizePlayerNameLooseForLookup(string $name): string { $name = normalizePlayerNameForLookup($name); $name = preg_replace('/\^\d/u', '', $name) ?? $name; $name = preg_replace('/[^a-z0-9]+/u', '', $name) ?? $name; return trim($name); } function quoteRconArg(string $value): string { $value = str_replace(["\\", "\"", "\r", "\n"], ["\\\\", "\\\"", ' ', ' '], trim($value)); return '"' . $value . '"'; } function isLikelyRconCommandError(string $response): bool { $text = strtolower(trim($response)); if ($text === '') { return false; } $needles = [ 'unknown command', 'usage:', 'bad rcon_password', 'invalid command', 'command not found', ]; foreach ($needles as $needle) { if (strpos($text, $needle) !== false) { return true; } } return false; } function isLoopbackHost(string $host): bool { $h = strtolower(trim($host)); if ($h === '') return false; return in_array($h, ['127.0.0.1', '::1', 'localhost'], true); } function sanitizeReceiverHost(string $host): string { $host = trim($host); if ($host === '') { return ''; } // Strip scheme/path if accidentally provided. $host = preg_replace('#^[a-z]+://#i', '', $host) ?? $host; $host = preg_replace('#/.*$#', '', $host) ?? $host; if (preg_match('/^\[([0-9a-fA-F:]+)\](?::\d+)?$/', $host, $m)) { return trim($m[1]); } // Remove ":port" for normal hostnames/IPv4. if (preg_match('/^([^:]+):\d+$/', $host, $m)) { return trim($m[1]); } return trim($host); } function resolveHostCandidateToIp(string $host): string { $host = sanitizeReceiverHost($host); if ($host === '') { return ''; } if (filter_var($host, FILTER_VALIDATE_IP)) { return $host; } $resolved = @gethostbyname($host); if (is_string($resolved) && $resolved !== '' && $resolved !== $host && filter_var($resolved, FILTER_VALIDATE_IP)) { return $resolved; } return ''; } function detectHldsLogReceiverHost(): string { $candidates = []; $forwardedHost = trim((string)($_SERVER['HTTP_X_FORWARDED_HOST'] ?? '')); if ($forwardedHost !== '') { $parts = explode(',', $forwardedHost); if (!empty($parts)) { $candidates[] = trim((string)$parts[0]); } } $httpHost = trim((string)($_SERVER['HTTP_HOST'] ?? '')); if ($httpHost !== '') { $candidates[] = $httpHost; } $serverName = trim((string)($_SERVER['SERVER_NAME'] ?? '')); if ($serverName !== '') { $candidates[] = $serverName; } $serverAddr = trim((string)($_SERVER['SERVER_ADDR'] ?? '')); if ($serverAddr !== '') { $candidates[] = $serverAddr; } $hostName = trim((string)@gethostname()); if ($hostName !== '') { $candidates[] = $hostName; } foreach ($candidates as $candidate) { $host = sanitizeReceiverHost($candidate); if ($host === '' || isLoopbackHost($host)) { continue; } if (filter_var($host, FILTER_VALIDATE_IP)) { return $host; } $resolved = resolveHostCandidateToIp($host); if ($resolved !== '' && !isLoopbackHost($resolved)) { return $resolved; } } // Last-resort local dev fallback. return '127.0.0.1'; } function getHldsLogReceiverHost(): string { global $config; $raw = sanitizeReceiverHost((string)($config['hlds_log_receiver_host'] ?? '')); if ($raw !== '' && strtolower($raw) !== 'auto') { if (filter_var($raw, FILTER_VALIDATE_IP)) { return $raw; } $resolved = resolveHostCandidateToIp($raw); return $resolved !== '' ? $resolved : $raw; } return detectHldsLogReceiverHost(); } function getHldsLogReceiverPort(): int { global $config; $port = (int)($config['hlds_log_receiver_port'] ?? 27150); if ($port < 1 || $port > 65535) { $port = 27150; } return $port; } function normalizePlayerIpForModeration(string $input): string { $ip = trim($input); if ($ip === '') { return ''; } if (preg_match('/^\[([0-9a-fA-F:]+)\](?::\d{1,5})?$/', $ip, $m)) { $ip = $m[1]; } elseif (preg_match('/^([0-9]{1,3}(?:\.[0-9]{1,3}){3}):\d{1,5}$/', $ip, $m)) { $ip = $m[1]; } return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : ''; } function normalizeBannedCfgPath(string $input): string { $path = trim(str_replace("\0", '', $input)); if ($path === '') { return ''; } $path = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path); $path = rtrim($path); if ($path === '') { return ''; } // If a directory is provided, default to banned.cfg inside it. if (!preg_match('/\.cfg$/i', $path)) { $path = rtrim($path, "\\/") . DIRECTORY_SEPARATOR . 'banned.cfg'; } $baseName = strtolower(pathinfo($path, PATHINFO_BASENAME)); if ($baseName !== 'banned.cfg') { return ''; } return $path; } function removeBanTargetsFromBannedCfg(string $cfgPath, array $targets): array { $path = normalizeBannedCfgPath($cfgPath); if ($path === '') { return ['success' => false, 'error' => 'Invalid banned.cfg path']; } if (!is_file($path)) { return ['success' => false, 'error' => 'banned.cfg not found']; } if (!is_readable($path) || !is_writable($path)) { return ['success' => false, 'error' => 'banned.cfg is not readable/writable']; } $needles = []; foreach ($targets as $target) { $value = trim((string)$target); if ($value === '') { continue; } if (normalizePlayerIpForModeration($value) !== '') { continue; } $needles[strtoupper(trim($value, "\"' \t"))] = true; } if (empty($needles)) { return ['success' => true, 'removed' => 0, 'path' => $path]; } $lines = @file($path, FILE_IGNORE_NEW_LINES); if ($lines === false) { return ['success' => false, 'error' => 'Failed to read banned.cfg']; } $removed = 0; $kept = []; foreach ($lines as $line) { $trimmed = ltrim((string)$line); if ($trimmed === '' || stripos($trimmed, 'banid') !== 0) { $kept[] = $line; continue; } $lineUpper = strtoupper($line); $matched = false; foreach ($needles as $needle => $_true) { $pattern = '/(^|[\\s\\t"\\\'])' . preg_quote($needle, '/') . '($|[\\s\\t"\\\';])/'; if (preg_match($pattern, $lineUpper)) { $matched = true; break; } } if ($matched) { $removed++; continue; } $kept[] = $line; } if ($removed > 0) { $content = implode(PHP_EOL, $kept); if ($content !== '') { $content .= PHP_EOL; } $written = @file_put_contents($path, $content, LOCK_EX); if ($written === false) { return ['success' => false, 'error' => 'Failed to write banned.cfg', 'removed' => 0, 'path' => $path]; } } return ['success' => true, 'removed' => $removed, 'path' => $path]; } function buildIdentityTargetsForRcon(string $target): array { $raw = trim($target); if ($raw === '') { return []; } $candidates = []; $seen = []; $add = static function (string $value) use (&$candidates, &$seen): void { $v = trim($value); if ($v === '') { return; } $k = strtolower($v); if (isset($seen[$k])) { return; } $seen[$k] = true; $candidates[] = $v; }; $upper = strtoupper($raw); $add($raw); $add($upper); // If target contains extra text (e.g. "LongHorn VALVE_1:0:123"), extract real identity/IP tokens. if (preg_match('/\b((?:STEAM|VALVE|HLTV|BOT)_[0-5]:[01]:\d+|STEAM_ID_LAN)\b/i', $raw, $mAuth)) { $auth = strtoupper(trim($mAuth[1])); $add($auth); if (preg_match('/^[A-Z]+_([0-5]:[01]:\d+)$/', $auth, $mTailFromAuth)) { // Reunion note: banid/removeid may use ID tail without STEAM_/VALVE_ prefix. $add($mTailFromAuth[1]); } } if (preg_match('/\b\d{17}\b/', $raw, $m64)) { $add(trim($m64[0])); } if (preg_match('/\b(?:\d{1,3}\.){3}\d{1,3}\b/', $raw, $mIp4)) { $add(trim($mIp4[0])); } // Support both Steam and Valve identity prefixes for no-steam / steam servers. if (strpos($upper, 'STEAM_') === 0) { $add('VALVE_' . substr($upper, 6)); } elseif (strpos($upper, 'VALVE_') === 0) { $add('STEAM_' . substr($upper, 6)); } elseif (preg_match('/^[0-5]:[01]:\d+$/', $upper)) { // Bare Reunion-style tail ID: try both namespaces too. $add('STEAM_' . $upper); $add('VALVE_' . $upper); } // Also cross-map extracted auth IDs. if (preg_match('/\bSTEAM_([0-5]:[01]:\d+)\b/i', $raw, $mSteamTail)) { $add('VALVE_' . $mSteamTail[1]); $add($mSteamTail[1]); } if (preg_match('/\bVALVE_([0-5]:[01]:\d+)\b/i', $raw, $mValveTail)) { $add('STEAM_' . $mValveTail[1]); $add($mValveTail[1]); } return $candidates; } function collectKnownIpsForIdentity(PDO $db, int $serverId, array $idTargets): array { if ($serverId <= 0 || empty($idTargets)) { return []; } $ids = []; $tails = []; foreach ($idTargets as $candidate) { $v = trim((string)$candidate); if ($v === '') { continue; } if (normalizePlayerIpForModeration($v) !== '') { continue; } $ids[strtolower($v)] = true; if (preg_match('/(?:^|_)([0-5]:[01]:\d+)$/i', $v, $mTail)) { $tails[$mTail[1]] = true; } } if (empty($ids) && empty($tails)) { return []; } $where = []; $params = []; foreach (array_keys($ids) as $idKey) { $where[] = 'LOWER(steam_id) = ?'; $params[] = $idKey; } foreach (array_keys($tails) as $tail) { $where[] = 'LOWER(steam_id) LIKE ?'; $params[] = '%' . strtolower($tail); } if (empty($where)) { return []; } $sql = "SELECT DISTINCT ip_address FROM players WHERE server_id = ? AND ip_address IS NOT NULL AND ip_address <> '' AND (" . implode(' OR ', $where) . ") ORDER BY id DESC LIMIT 25"; $stmt = $db->prepare($sql); $stmt->bindValue(1, $serverId, PDO::PARAM_INT); foreach ($params as $idx => $value) { $stmt->bindValue($idx + 2, $value, PDO::PARAM_STR); } $stmt->execute(); $ips = []; while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $ip = normalizePlayerIpForModeration((string)($row['ip_address'] ?? '')); if ($ip !== '') { $ips[$ip] = true; } } return array_keys($ips); } function selectModerationTarget(string $targetPref, string $steamId, string $ipAddress, string $fallbackTarget): array { $pref = strtolower(trim($targetPref)); $steam = trim($steamId); if (in_array(strtoupper($steam), ['N/A', 'UNKNOWN', '-'], true)) { $steam = ''; } $ip = normalizePlayerIpForModeration($ipAddress); $fallback = trim($fallbackTarget); if (in_array(strtoupper($fallback), ['N/A', 'UNKNOWN', '-'], true)) { $fallback = ''; } $fallbackIp = normalizePlayerIpForModeration($fallback); $isFallbackIp = $fallbackIp !== ''; if ($isFallbackIp) { $fallback = $fallbackIp; } if ($pref === 'ip') { if ($ip !== '') return ['target' => $ip, 'type' => 'ip']; if ($isFallbackIp) return ['target' => $fallback, 'type' => 'ip']; return ['target' => '', 'type' => 'ip']; } if ($pref === 'steam') { if ($steam !== '') return ['target' => $steam, 'type' => 'steam']; if (!$isFallbackIp && $fallback !== '') return ['target' => $fallback, 'type' => 'steam']; return ['target' => '', 'type' => 'steam']; } if ($steam !== '') return ['target' => $steam, 'type' => 'steam']; if ($ip !== '') return ['target' => $ip, 'type' => 'ip']; if ($fallback !== '') return ['target' => $fallback, 'type' => $isFallbackIp ? 'ip' : 'steam']; return ['target' => '', 'type' => 'unknown']; } function refreshServerStatusesForRows(PDO $db, array $rows, int $timeoutSeconds = 1, int $minIntervalSeconds = 20): array { if (empty($rows)) { return $rows; } $now = time(); $updateStmt = $db->prepare("UPDATE servers SET status = ?, status_checked_at = CURRENT_TIMESTAMP WHERE id = ?"); foreach ($rows as &$row) { $serverId = (int)($row['id'] ?? 0); if ($serverId <= 0) { continue; } $lastCheck = isset($row['status_checked_at']) ? strtotime((string)$row['status_checked_at']) : false; if ($lastCheck !== false && ($now - $lastCheck) < $minIntervalSeconds) { continue; } $probe = queryServerProbe( (string)($row['ip'] ?? ''), (int)($row['port'] ?? 27015), $timeoutSeconds, (string)($row['game_type'] ?? 'CS 1.6') ); $status = !empty($probe['online']) ? 'online' : 'offline'; $row['status'] = $status; $row['status_checked_at'] = date('Y-m-d H:i:s', $now); $row['players'] = $probe['players'] ?? null; $row['max_players'] = $probe['max_players'] ?? null; $row['map'] = $probe['map'] ?? null; $updateStmt->bindValue(1, $status, PDO::PARAM_STR); $updateStmt->bindValue(2, $serverId, PDO::PARAM_INT); $updateStmt->execute(); } unset($row); return $rows; } function isTurnstileEnabled(): bool { global $config; return trim((string)($config['turnstile_site_key'] ?? '')) !== '' && trim((string)($config['turnstile_secret_key'] ?? '')) !== ''; } function getClientIpAddress(): string { $ip = $_SERVER['HTTP_CF_CONNECTING_IP'] ?? ''; if ($ip !== '' && filter_var($ip, FILTER_VALIDATE_IP)) { return $ip; } $xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''; if ($xff !== '') { $parts = explode(',', $xff); foreach ($parts as $part) { $candidate = trim($part); if ($candidate !== '' && filter_var($candidate, FILTER_VALIDATE_IP)) { return $candidate; } } } $remote = $_SERVER['REMOTE_ADDR'] ?? ''; return ($remote !== '' && filter_var($remote, FILTER_VALIDATE_IP)) ? $remote : '0.0.0.0'; } function authRateLimitFile(string $scope, string $ip): string { global $config; $hash = hash('sha256', strtolower($scope) . '|' . $ip); return rtrim($config['rate_limit_dir'], '/\\') . DIRECTORY_SEPARATOR . $hash . '.json'; } function authRateLimitDefaults(): array { return [ 'window_start' => time(), 'request_count' => 0, 'fail_count' => 0, 'lock_until' => 0, ]; } function authRateLimitConfig(): array { global $config; return [ 'window_seconds' => max(30, (int)($config['auth_rate_window_seconds'] ?? 300)), 'max_requests' => max(5, (int)($config['auth_rate_max_requests'] ?? 30)), 'max_failures' => max(3, (int)($config['auth_rate_max_failures'] ?? 8)), 'lock_seconds' => max(60, (int)($config['auth_rate_lock_seconds'] ?? 900)), ]; } function authRateLimitLoad(string $scope, string $ip): array { $state = authRateLimitDefaults(); $file = authRateLimitFile($scope, $ip); if (!is_file($file)) { return $state; } $raw = @file_get_contents($file); if (!is_string($raw) || $raw === '') { return $state; } $json = json_decode($raw, true); if (!is_array($json)) { return $state; } foreach (['window_start', 'request_count', 'fail_count', 'lock_until'] as $k) { if (isset($json[$k]) && is_numeric($json[$k])) { $state[$k] = (int)$json[$k]; } } return $state; } function authRateLimitSave(string $scope, string $ip, array $state): void { ensureRateLimitDir(); $file = authRateLimitFile($scope, $ip); @file_put_contents($file, json_encode($state, JSON_UNESCAPED_SLASHES), LOCK_EX); } function authRateLimitCheck(string $scope = 'auth'): array { $cfg = authRateLimitConfig(); $ip = getClientIpAddress(); $now = time(); $state = authRateLimitLoad($scope, $ip); if ((int)$state['lock_until'] > $now) { return [ 'ok' => false, 'ip' => $ip, 'retry_after' => (int)$state['lock_until'] - $now, ]; } if ($now - (int)$state['window_start'] > $cfg['window_seconds']) { $state['window_start'] = $now; $state['request_count'] = 0; $state['fail_count'] = 0; $state['lock_until'] = 0; } $state['request_count'] = (int)$state['request_count'] + 1; if ((int)$state['request_count'] > $cfg['max_requests']) { $state['lock_until'] = $now + $cfg['lock_seconds']; authRateLimitSave($scope, $ip, $state); return [ 'ok' => false, 'ip' => $ip, 'retry_after' => $cfg['lock_seconds'], ]; } authRateLimitSave($scope, $ip, $state); return [ 'ok' => true, 'ip' => $ip, 'retry_after' => 0, ]; } function authRateLimitMarkFailure(string $scope, string $ip): void { $cfg = authRateLimitConfig(); $now = time(); $state = authRateLimitLoad($scope, $ip); if ($now - (int)$state['window_start'] > $cfg['window_seconds']) { $state['window_start'] = $now; $state['request_count'] = 0; $state['fail_count'] = 0; $state['lock_until'] = 0; } $state['fail_count'] = (int)$state['fail_count'] + 1; if ((int)$state['fail_count'] >= $cfg['max_failures']) { $state['lock_until'] = $now + $cfg['lock_seconds']; } authRateLimitSave($scope, $ip, $state); } function authRateLimitMarkSuccess(string $scope, string $ip): void { $state = authRateLimitLoad($scope, $ip); $state['fail_count'] = 0; authRateLimitSave($scope, $ip, $state); } function verifyTurnstileToken(?string $token): array { global $config; if (!isTurnstileEnabled()) { return ['ok' => true]; } $token = trim((string)$token); if ($token === '') { return ['ok' => false, 'error' => 'Security challenge is required']; } $payload = http_build_query([ 'secret' => (string)$config['turnstile_secret_key'], 'response' => $token, 'remoteip' => getClientIpAddress(), ]); $url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; $respRaw = false; if (function_exists('curl_init')) { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 8, CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'], ]); $respRaw = curl_exec($ch); curl_close($ch); } if (!is_string($respRaw) || $respRaw === '') { $ctx = stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => "Content-Type: application/x-www-form-urlencoded\r\n", 'content' => $payload, 'timeout' => 8, ], ]); $respRaw = @file_get_contents($url, false, $ctx); } if (!is_string($respRaw) || $respRaw === '') { return ['ok' => false, 'error' => 'Unable to verify security challenge']; } $decoded = json_decode($respRaw, true); if (!is_array($decoded) || empty($decoded['success'])) { $codes = ''; if (isset($decoded['error-codes']) && is_array($decoded['error-codes'])) { $codes = implode(', ', $decoded['error-codes']); } $message = 'Security challenge failed'; if ($codes !== '') { $message .= ' (' . $codes . ')'; } return ['ok' => false, 'error' => $message]; } return ['ok' => true]; } // ========================= SERVER LOGGING & NOTIFICATIONS ========================= function logServerEvent(PDO $db, int $serverId, string $level, string $category, string $message): void { global $config; // Save to DB $stmt = $db->prepare("INSERT INTO server_logs (server_id, level, category, message) VALUES (?, ?, ?, ?)"); $stmt->bindValue(1, $serverId, PDO::PARAM_INT); $stmt->bindValue(2, $level, PDO::PARAM_STR); $stmt->bindValue(3, $category, PDO::PARAM_STR); $stmt->bindValue(4, $message, PDO::PARAM_STR); $stmt->execute(); // Append to file $file = rtrim($config['log_dir'], '/\\') . '/server_' . $serverId . '.log'; $line = sprintf( "[%s] [%s] [%s] %s\n", date('Y-m-d H:i:s'), $level, $category, $message ); @file_put_contents($file, $line, FILE_APPEND | LOCK_EX); } function sendNotificationEmail(string $to, string $subject, string $body): bool { global $config; $headers = []; $headers[] = 'From: ' . $config['email_from']; $headers[] = 'MIME-Version: 1.0'; $headers[] = 'Content-Type: text/plain; charset=UTF-8'; return @mail($to, $subject, $body, implode("\r\n", $headers)); } function createNotification(PDO $db, int $serverId, string $type, string $subject, string $body): void { $serverOwnerId = (int)$db->query("SELECT user_id FROM servers WHERE id = $serverId")->fetchColumn(); // Send one notification per eligible user with email configured $usersRes = $db->query("SELECT id, email FROM users WHERE (role = 'Super Admin' OR id = $serverOwnerId) AND email IS NOT NULL AND TRIM(email) <> ''"); $now = date('Y-m-d H:i:s'); while ($u = $usersRes->fetch(PDO::FETCH_ASSOC)) { $stmt = $db->prepare("INSERT INTO notifications (user_id, server_id, type, subject, body, created_at, sent_at) VALUES (?, ?, ?, ?, ?, ?, ?)"); $stmt->bindValue(1, (int)$u['id'], PDO::PARAM_INT); $stmt->bindValue(2, $serverId, PDO::PARAM_INT); $stmt->bindValue(3, $type, PDO::PARAM_STR); $stmt->bindValue(4, $subject, PDO::PARAM_STR); $stmt->bindValue(5, $body, PDO::PARAM_STR); $stmt->bindValue(6, $now, PDO::PARAM_STR); $sentAt = null; if ($u['email']) { $ok = sendNotificationEmail($u['email'], $subject, $body); $sentAt = $ok ? $now : null; } $stmt->bindValue(7, $sentAt, PDO::PARAM_STR); $stmt->execute(); } } // ========================= GOLDSRC RCON (CS 1.6) ========================= // (UNCHANGED) class GoldSrcRcon { private string $host; private int $port; private string $password; private int $timeout; private $socket = null; public function __construct(string $host, int $port, string $password, int $timeout = 3) { $this->host = $host; $this->port = $port; $this->password = $password; $this->timeout = $timeout; } private function openSocket(): void { $errno = 0; $errstr = ''; $this->socket = @fsockopen("udp://{$this->host}", $this->port, $errno, $errstr, $this->timeout); if (!$this->socket) { throw new Exception("Cannot open UDP socket: {$errstr} ({$errno})"); } stream_set_timeout($this->socket, $this->timeout); } private function sendPacket(string $payload): string { if (!$this->socket) { $this->openSocket(); } $packet = "\xFF\xFF\xFF\xFF" . $payload; $bytes = fwrite($this->socket, $packet); if ($bytes === false || $bytes <= 0) { throw new Exception("Failed to write to UDP socket"); } $response = ''; $start = microtime(true); // Read until timeout or no more data while ((microtime(true) - $start) < $this->timeout) { $chunk = fread($this->socket, 4096); if ($chunk === false || $chunk === '') { break; } $response .= $chunk; // UDP answers are typically short if (strlen($chunk) < 4096) { break; } } if ($response === '') { throw new Exception("No response from server (UDP)"); } // Strip leading 0xFFFFFFFF if present if (substr($response, 0, 4) === "\xFF\xFF\xFF\xFF") { $response = substr($response, 4); } return trim($response); } private function getChallenge(): ?string { $resp = $this->sendPacket("challenge rcon\n"); // Example: "challenge rcon 123456789" if (preg_match('/challenge\s+rcon\s+(-?\d+)/i', $resp, $m)) { return $m[1]; } // Some HLDS builds might not require explicit challenge return null; } public function execute(string $command): string { $this->openSocket(); $challenge = $this->getChallenge(); if ($challenge !== null) { $payload = sprintf( "rcon %s \"%s\" %s\n", $challenge, $this->password, $command ); } else { // Fallback - some builds accept this format $payload = sprintf( "rcon \"%s\" %s\n", $this->password, $command ); } $resp = $this->sendPacket($payload); $this->disconnect(); return $resp; } public function disconnect(): void { if ($this->socket) { fclose($this->socket); $this->socket = null; } } } // ========================= SIMPLE A2S INFO (ONLINE CHECK) ========================= function normalizeServerAddress(string $hostInput, int $portInput = 27015): array { $host = trim($hostInput); $port = $portInput > 0 ? $portInput : 27015; // Common paste formats: steam://connect/ip:port , udp://ip:port $host = preg_replace('#^(steam://connect/|udp://)#i', '', $host) ?? $host; // Strip path/query fragments if present $host = preg_replace('~[/?#].*$~', '', $host) ?? $host; // [IPv6]:port if (preg_match('/^\[([0-9a-fA-F:]+)\]:(\d{1,5})$/', $host, $m)) { $host = $m[1]; $port = (int)$m[2]; } else { // host:port (single-colon forms only, avoids raw IPv6 ambiguity) $colonCount = substr_count($host, ':'); if ($colonCount === 1 && preg_match('/^([^:]+):(\d{1,5})$/', $host, $m)) { $host = trim($m[1]); $port = (int)$m[2]; } } if ($port < 1 || $port > 65535) { $port = 27015; } return [$host, $port]; } function normalizeOptionalHttpUrl(?string $url): string { $url = trim((string)$url); if ($url === '') { return ''; } if (!preg_match('~^https?://~i', $url)) { $url = 'https://' . $url; } if (!filter_var($url, FILTER_VALIDATE_URL)) { return ''; } return $url; } function formatUdpHost(string $host): string { if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { return '[' . $host . ']'; } return $host; } function readUdpPacket($sock): array { $resp = @fread($sock, 4096); $meta = @stream_get_meta_data($sock); return [$resp, is_array($meta) ? $meta : []]; } function classifyA2sPacket(string $resp): string { if (strlen($resp) < 5) { return 'short_response'; } if (substr($resp, 0, 4) === "\xFF\xFF\xFF\xFF") { $type = ord($resp[4]); if ($type === 0x49) return 'source_info'; if ($type === 0x6D) return 'goldsrc_info'; if ($type === 0x41) return 'challenge'; if ($type === 0x45) return 'error'; return 'ff_packet_' . strtoupper(str_pad(dechex($type), 2, '0', STR_PAD_LEFT)); } if (substr($resp, 0, 4) === "\xFE\xFF\xFF\xFF") { return 'split_packet'; } return 'unknown'; } function queryServerProbe(string $ip, int $port, int $timeout = 2, string $gameType = 'CS 1.6'): array { [$ip, $port] = normalizeServerAddress($ip, $port); if ($ip === '') { return [ 'online' => false, 'host' => $ip, 'port' => $port, 'reason' => 'empty_host', ]; } // Prefer the same probe engine used by live_servers.php when available. // Do not short-circuit to offline on helper errors; always keep raw A2S fallback below. $liveProbeError = null; if (function_exists('lh_probe_server_live')) { $legacy = lh_probe_server_live([ 'id' => 0, 'name' => 'probe', 'ip' => $ip, 'port' => $port, 'game_type' => $gameType, ], false, false); if (is_array($legacy)) { if (($legacy['status'] ?? 'offline') === 'online') { return [ 'online' => true, 'host' => $ip, 'port' => $port, 'reason' => 'live_udp_ok', 'response_type' => 'live_udp', 'bytes' => 0, 'ping_ms' => $legacy['ping'] ?? null, 'players' => $legacy['players_current'] ?? null, 'max_players' => $legacy['players_max'] ?? null, 'map' => $legacy['map'] ?? null, 'name' => $legacy['name'] ?? null, ]; } if (!empty($legacy['error'])) { $liveProbeError = (string)$legacy['error']; } } } $connectHost = formatUdpHost($ip); $sock = @fsockopen("udp://{$connectHost}", $port, $errno, $errstr, $timeout); if (!$sock) { return [ 'online' => false, 'host' => $ip, 'port' => $port, 'reason' => 'socket_open_failed', 'error' => trim(($errstr ?? '') . ' (' . (int)$errno . ')'), ]; } stream_set_timeout($sock, $timeout); $sourceQuery = "\xFF\xFF\xFF\xFFTSource Engine Query\x00"; $legacyQuery = "\xFF\xFF\xFF\xFFdetails\x00"; @fwrite($sock, $sourceQuery); [$resp, $meta] = readUdpPacket($sock); if (is_string($resp) && $resp !== '') { $type = classifyA2sPacket($resp); if ($type === 'challenge' && strlen($resp) >= 9) { $challenge = substr($resp, 5, 4); @fwrite($sock, $sourceQuery . $challenge); [$resp2, $meta2] = readUdpPacket($sock); if (is_string($resp2) && $resp2 !== '') { $type2 = classifyA2sPacket($resp2); fclose($sock); if ($type2 !== 'error' && $type2 !== 'unknown' && $type2 !== 'short_response') { return [ 'online' => true, 'host' => $ip, 'port' => $port, 'reason' => 'source_challenge_ok', 'response_type' => $type2, 'bytes' => strlen($resp2), ]; } return [ 'online' => false, 'host' => $ip, 'port' => $port, 'reason' => 'source_challenge_bad_response', 'response_type' => $type2, 'bytes' => strlen($resp2), ]; } fclose($sock); return [ 'online' => false, 'host' => $ip, 'port' => $port, 'reason' => !empty($meta2['timed_out']) ? 'source_challenge_timeout' : 'source_challenge_no_data', ]; } fclose($sock); if ($type !== 'error' && $type !== 'unknown' && $type !== 'short_response') { return [ 'online' => true, 'host' => $ip, 'port' => $port, 'reason' => 'source_query_ok', 'response_type' => $type, 'bytes' => strlen($resp), ]; } return [ 'online' => false, 'host' => $ip, 'port' => $port, 'reason' => 'source_bad_response', 'response_type' => $type, 'bytes' => strlen($resp), ]; } // Fallback for some legacy GoldSrc builds @fwrite($sock, $legacyQuery); [$legacyResp, $legacyMeta] = readUdpPacket($sock); fclose($sock); if (is_string($legacyResp) && $legacyResp !== '') { $legacyType = classifyA2sPacket($legacyResp); if ($legacyType !== 'error' && $legacyType !== 'unknown' && $legacyType !== 'short_response') { return [ 'online' => true, 'host' => $ip, 'port' => $port, 'reason' => 'legacy_query_ok', 'response_type' => $legacyType, 'bytes' => strlen($legacyResp), ]; } return [ 'online' => false, 'host' => $ip, 'port' => $port, 'reason' => 'legacy_bad_response', 'response_type' => $legacyType, 'bytes' => strlen($legacyResp), ]; } return [ 'online' => false, 'host' => $ip, 'port' => $port, 'reason' => !empty($legacyMeta['timed_out']) || !empty($meta['timed_out']) ? 'timeout_no_a2s_reply' : 'no_a2s_reply', 'live_probe_error' => $liveProbeError, ]; } function queryServerOnline(string $ip, int $port, int $timeout = 2): bool { $probe = queryServerProbe($ip, $port, $timeout, 'CS 1.6'); return (bool)($probe['online'] ?? false); } // ========================= API HANDLER ========================= // NOTE: For cron-style health check without web server, we allow CLI: // php panel.php check_servers=1 token=... if (PHP_SAPI === 'cli') { parse_str(implode('&', array_slice($argv, 1)), $cliParams); if (isset($cliParams['check_servers'])) { $_GET['action'] = 'check_servers'; if (isset($cliParams['token'])) { $_GET['token'] = $cliParams['token']; } } } if (isset($_GET['action'])) { header('Content-Type: application/json; charset=utf-8'); $action = $_GET['action']; $db = getDB(); try { switch ($action) { // ---------- AUTH ---------- case 'login': if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success' => false, 'error' => 'Invalid method']); break; } $data = json_decode(file_get_contents('php://input'), true); if (!is_array($data)) { $data = []; } $authRate = authRateLimitCheck('auth'); if (!$authRate['ok']) { echo json_encode([ 'success' => false, 'error' => 'Too many requests. Try again in ' . (int)$authRate['retry_after'] . 's', ]); break; } $turnstileCheck = verifyTurnstileToken($data['turnstile_token'] ?? ''); if (!$turnstileCheck['ok']) { authRateLimitMarkFailure('auth', $authRate['ip']); echo json_encode(['success' => false, 'error' => $turnstileCheck['error']]); break; } $username = trim($data['username'] ?? ''); $password = $data['password'] ?? ''; $stmt = $db->prepare("SELECT * FROM users WHERE username = ? LIMIT 1"); $stmt->bindValue(1, $username, PDO::PARAM_STR); $stmt->execute(); $user = $stmt->fetch(PDO::FETCH_ASSOC); if ($user && password_verify($password, $user['password'])) { $_SESSION['user_id'] = $user['id']; $_SESSION['username'] = $user['username']; $_SESSION['role'] = $user['role']; $_SESSION['last_activity'] = time(); authRateLimitMarkSuccess('auth', $authRate['ip']); $stmt2 = $db->prepare("UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?"); $stmt2->bindValue(1, $user['id'], PDO::PARAM_INT); $stmt2->execute(); logActivity($db, $user['username'], 'login', 'User logged in'); echo json_encode([ 'success' => true, 'user_id' => (int)$user['id'], 'role' => $user['role'], 'username' => $user['username'], ]); } else { authRateLimitMarkFailure('auth', $authRate['ip']); echo json_encode(['success' => false, 'error' => 'Invalid credentials']); } break; case 'register': if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success' => false, 'error' => 'Invalid method']); break; } $data = json_decode(file_get_contents('php://input'), true); if (!is_array($data)) { $data = []; } $authRate = authRateLimitCheck('auth'); if (!$authRate['ok']) { echo json_encode([ 'success' => false, 'error' => 'Too many requests. Try again in ' . (int)$authRate['retry_after'] . 's', ]); break; } $turnstileCheck = verifyTurnstileToken($data['turnstile_token'] ?? ''); if (!$turnstileCheck['ok']) { authRateLimitMarkFailure('auth', $authRate['ip']); echo json_encode(['success' => false, 'error' => $turnstileCheck['error']]); break; } $username = trim($data['username'] ?? ''); $password = (string)($data['password'] ?? ''); $email = trim($data['email'] ?? ''); if ($username === '' || $password === '') { authRateLimitMarkFailure('auth', $authRate['ip']); echo json_encode(['success' => false, 'error' => 'Username and password are required']); break; } if (!preg_match('/^[A-Za-z0-9_.-]{3,32}$/', $username)) { authRateLimitMarkFailure('auth', $authRate['ip']); echo json_encode(['success' => false, 'error' => 'Username must be 3-32 chars: letters, numbers, _, ., -']); break; } if (strlen($password) < 6) { authRateLimitMarkFailure('auth', $authRate['ip']); echo json_encode(['success' => false, 'error' => 'Password must be at least 6 characters']); break; } if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) { authRateLimitMarkFailure('auth', $authRate['ip']); echo json_encode(['success' => false, 'error' => 'Invalid email']); break; } $check = $db->prepare('SELECT id FROM users WHERE username = ? LIMIT 1'); $check->bindValue(1, $username, PDO::PARAM_STR); $check->execute(); if ($check->fetch(PDO::FETCH_ASSOC)) { authRateLimitMarkFailure('auth', $authRate['ip']); echo json_encode(['success' => false, 'error' => 'Username already exists']); break; } $hash = password_hash($password, PASSWORD_DEFAULT); $stmt = $db->prepare('INSERT INTO users (username, password, role, email) VALUES (?, ?, ?, ?)'); $stmt->bindValue(1, $username, PDO::PARAM_STR); $stmt->bindValue(2, $hash, PDO::PARAM_STR); $stmt->bindValue(3, 'User', PDO::PARAM_STR); $stmt->bindValue(4, $email, PDO::PARAM_STR); try { $stmt->execute(); authRateLimitMarkSuccess('auth', $authRate['ip']); logActivity($db, $username, 'register', 'User self-registered'); echo json_encode(['success' => true, 'message' => 'Account created. You can now login.']); } catch (Throwable $e) { authRateLimitMarkFailure('auth', $authRate['ip']); echo json_encode(['success' => false, 'error' => 'Registration failed']); } break; case 'logout': if (isLoggedIn()) { logActivity($db, $_SESSION['username'] ?? 'unknown', 'logout', 'User logged out'); } session_destroy(); echo json_encode(['success' => true]); break; // ---------- STATS ---------- case 'get_stats': requireLoginJSON(); $isSuperAdmin = isSuperAdmin(); $canViewAllServers = canViewAllServers(); $usrId = (int)$_SESSION['user_id']; // Keep DB status fresh using UDP/A2S probe only (no RCON required) $scopeQuery = $canViewAllServers ? "SELECT id, ip, port, game_type, status_checked_at FROM servers" : "SELECT id, ip, port, game_type, status_checked_at FROM servers WHERE user_id = $usrId"; $scopeRows = []; $scopeRes = $db->query($scopeQuery); while ($scopeRes && ($scopeRow = $scopeRes->fetch(PDO::FETCH_ASSOC))) { $scopeRows[] = $scopeRow; } refreshServerStatusesForRows($db, $scopeRows, 1, 20); $totalServersQ = $canViewAllServers ? "SELECT COUNT(*) FROM servers" : "SELECT COUNT(*) FROM servers WHERE user_id = $usrId"; $onlineServersQ = $canViewAllServers ? "SELECT COUNT(*) FROM servers WHERE status = 'online'" : "SELECT COUNT(*) FROM servers WHERE status = 'online' AND user_id = $usrId"; $stats = [ 'total_servers' => (int)$db->query($totalServersQ)->fetchColumn(), 'online_servers' => (int)$db->query($onlineServersQ)->fetchColumn(), 'total_users' => $isSuperAdmin ? (int)$db->query("SELECT COUNT(*) FROM users")->fetchColumn() : 1, 'rcon_status' => 'ready', ]; echo json_encode(['success' => true, 'data' => $stats]); break; // ---------- SERVERS ---------- case 'get_servers': requireLoginJSON(); $isSuperAdmin = isSuperAdmin(); $canViewAllServers = canViewAllServers(); $usrId = (int)$_SESSION['user_id']; $where = $canViewAllServers ? "" : " WHERE servers.user_id = $usrId"; $reactionJoin = " LEFT JOIN ( SELECT server_id, COALESCE(SUM(CASE WHEN voted = 1 THEN 1 ELSE 0 END), 0) AS likes_total, COALESCE(SUM(CASE WHEN voted = 1 AND actor_key LIKE 'u:%' THEN 1 ELSE 0 END), 0) AS likes_users, COALESCE(SUM(CASE WHEN voted = 1 AND actor_key LIKE 'g:%' THEN 1 ELSE 0 END), 0) AS likes_visitors, COALESCE(SUM(CASE WHEN favorited = 1 THEN 1 ELSE 0 END), 0) AS favorites_total FROM server_reactions GROUP BY server_id ) rx ON rx.server_id = servers.id "; $reactionSelect = ", COALESCE(rx.likes_total, 0) AS likes_total, COALESCE(rx.likes_users, 0) AS likes_users, COALESCE(rx.likes_visitors, 0) AS likes_visitors, COALESCE(rx.favorites_total, 0) AS favorites_total"; // If 'all' param is present, return everything (for dropdowns) if (isset($_GET['all'])) { $result = $db->query("SELECT servers.*, users.username AS owner_name $reactionSelect FROM servers LEFT JOIN users ON servers.user_id = users.id $reactionJoin $where ORDER BY servers.id DESC"); $servers = []; while ($row = $result->fetch(PDO::FETCH_ASSOC)) { $servers[] = $row; } echo json_encode(['success' => true, 'data' => $servers]); break; } // Pagination logic for the list $page = isset($_GET['page']) ? (int)$_GET['page'] : 1; if ($page < 1) $page = 1; $limit = 12; $offset = ($page - 1) * $limit; $total = (int)$db->query("SELECT COUNT(*) FROM servers$where")->fetchColumn(); $totalPages = ceil($total / $limit); $stmt = $db->prepare("SELECT servers.*, users.username AS owner_name $reactionSelect FROM servers LEFT JOIN users ON servers.user_id = users.id $reactionJoin $where ORDER BY servers.id DESC LIMIT ? OFFSET ?"); $stmt->bindValue(1, $limit, PDO::PARAM_INT); $stmt->bindValue(2, $offset, PDO::PARAM_INT); $stmt->execute(); $servers = []; while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $servers[] = $row; } // Refresh list status from A2S probe (RCON not needed) // Removed synchronous refresh to massively accelerate panel page load speeds // UI now relies on asynchronous live_servers.php polling instead. // $servers = refreshServerStatusesForRows($db, $servers, 1, 20); echo json_encode([ 'success' => true, 'data' => $servers, 'pagination' => [ 'current' => $page, 'total_pages' => $totalPages, 'total_items' => $total ] ]); break; case 'add_server': requireLoginJSON(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success' => false, 'error' => 'Invalid method']); break; } $data = json_decode(file_get_contents('php://input'), true); $name = trim($data['name'] ?? ''); $ipRaw = (string)($data['ip'] ?? ''); $portRaw = (int)($data['port'] ?? 27015); [$ipNorm, $portNorm] = normalizeServerAddress($ipRaw, $portRaw); $ip = $ipNorm; $port = $portNorm; $rcon = trim($data['rcon_password'] ?? ''); $game = trim($data['game_type'] ?? 'CS 1.6'); $communityName = trim((string)($data['community_name'] ?? '')); $websiteRaw = trim((string)($data['website_url'] ?? '')); $discordRaw = trim((string)($data['discord_url'] ?? '')); $bannedCfgRaw = trim((string)($data['banned_cfg_path'] ?? '')); $websiteUrl = normalizeOptionalHttpUrl($websiteRaw); $discordUrl = normalizeOptionalHttpUrl($discordRaw); $bannedCfgPath = normalizeBannedCfgPath($bannedCfgRaw); $maxPlayers = (int)($data['max_players'] ?? 32); if ($maxPlayers <= 0 || $maxPlayers > 256) { $maxPlayers = 32; } if (strlen($communityName) > 255) { $communityName = substr($communityName, 0, 255); } if (strlen($bannedCfgRaw) > 1024) { echo json_encode(['success' => false, 'error' => 'banned.cfg path is too long']); break; } if ($websiteRaw !== '' && $websiteUrl === '') { echo json_encode(['success' => false, 'error' => 'Invalid website URL']); break; } if ($discordRaw !== '' && $discordUrl === '') { echo json_encode(['success' => false, 'error' => 'Invalid Discord URL']); break; } if ($bannedCfgRaw !== '' && $bannedCfgPath === '') { echo json_encode(['success' => false, 'error' => 'Invalid banned.cfg path']); break; } if ($ip === '' || $port <= 0) { echo json_encode(['success' => false, 'error' => 'IP and port are required']); break; } $probe = queryServerProbe($ip, $port, 2, $game); $online = !empty($probe['online']) ? 'online' : 'offline'; if ($name === '') { $name = !empty($probe['name']) ? $probe['name'] : 'Unknown Server'; } if ($maxPlayers == 32 && !empty($probe['max_players'])) { $maxPlayers = (int)$probe['max_players']; } $stmt = $db->prepare("INSERT INTO servers (name, ip, port, rcon_password, banned_cfg_path, game_type, status, status_checked_at, user_id, max_players, community_name, website_url, discord_url) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?, ?, ?, ?, ?)"); $stmt->bindValue(1, $name, PDO::PARAM_STR); $stmt->bindValue(2, $ip, PDO::PARAM_STR); $stmt->bindValue(3, $port, PDO::PARAM_INT); $stmt->bindValue(4, $rcon, PDO::PARAM_STR); if ($bannedCfgPath !== '') { $stmt->bindValue(5, $bannedCfgPath, PDO::PARAM_STR); } else { $stmt->bindValue(5, null, PDO::PARAM_NULL); } $stmt->bindValue(6, $game, PDO::PARAM_STR); $stmt->bindValue(7, $online, PDO::PARAM_STR); $stmt->bindValue(8, (int)($_SESSION['user_id'] ?? 1), PDO::PARAM_INT); $stmt->bindValue(9, $maxPlayers, PDO::PARAM_INT); if ($communityName !== '') { $stmt->bindValue(10, $communityName, PDO::PARAM_STR); } else { $stmt->bindValue(10, null, PDO::PARAM_NULL); } if ($websiteUrl !== '') { $stmt->bindValue(11, $websiteUrl, PDO::PARAM_STR); } else { $stmt->bindValue(11, null, PDO::PARAM_NULL); } if ($discordUrl !== '') { $stmt->bindValue(12, $discordUrl, PDO::PARAM_STR); } else { $stmt->bindValue(12, null, PDO::PARAM_NULL); } $stmt->execute(); $serverId = (int)$db->lastInsertId(); logActivity($db, $_SESSION['username'] ?? 'unknown', 'add_server', "{$name} ({$ip}:{$port})"); logServerEvent($db, $serverId, 'INFO', 'SERVER', "Server added by " . ($_SESSION['username'] ?? 'unknown')); echo json_encode(['success' => true, 'message' => 'Server added', 'status' => $online, 'probe' => $probe]); break; case 'update_server_profile': requireLoginJSON(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success' => false, 'error' => 'Invalid method']); break; } $data = json_decode(file_get_contents('php://input'), true); if (!is_array($data)) { $data = []; } $id = (int)($data['id'] ?? 0); if ($id <= 0) { echo json_encode(['success' => false, 'error' => 'Invalid server id']); break; } $server = getAccessibleServer($db, $id); if (!$server) { echo json_encode(['success' => false, 'error' => 'Server not found or access denied']); break; } $name = trim((string)($data['name'] ?? ($server['name'] ?? ''))); $communityName = trim((string)($data['community_name'] ?? '')); $websiteRaw = trim((string)($data['website_url'] ?? '')); $discordRaw = trim((string)($data['discord_url'] ?? '')); $bannedCfgRaw = trim((string)($data['banned_cfg_path'] ?? (string)($server['banned_cfg_path'] ?? ''))); $rconPassword = trim((string)($data['rcon_password'] ?? (string)($server['rcon_password'] ?? ''))); $websiteUrl = normalizeOptionalHttpUrl($websiteRaw); $discordUrl = normalizeOptionalHttpUrl($discordRaw); $bannedCfgPath = normalizeBannedCfgPath($bannedCfgRaw); if ($name === '') { echo json_encode(['success' => false, 'error' => 'Server name is required']); break; } if (strlen($name) > 255) { $name = substr($name, 0, 255); } if (strlen($communityName) > 255) { $communityName = substr($communityName, 0, 255); } if (strlen($rconPassword) > 255) { $rconPassword = substr($rconPassword, 0, 255); } if (strlen($bannedCfgRaw) > 1024) { echo json_encode(['success' => false, 'error' => 'banned.cfg path is too long']); break; } if ($websiteRaw !== '' && $websiteUrl === '') { echo json_encode(['success' => false, 'error' => 'Invalid website URL']); break; } if ($discordRaw !== '' && $discordUrl === '') { echo json_encode(['success' => false, 'error' => 'Invalid Discord URL']); break; } if ($bannedCfgRaw !== '' && $bannedCfgPath === '') { echo json_encode(['success' => false, 'error' => 'Invalid banned.cfg path']); break; } $stmt = $db->prepare("UPDATE servers SET name = ?, community_name = ?, website_url = ?, discord_url = ?, rcon_password = ?, banned_cfg_path = ? WHERE id = ?"); $stmt->bindValue(1, $name, PDO::PARAM_STR); if ($communityName !== '') { $stmt->bindValue(2, $communityName, PDO::PARAM_STR); } else { $stmt->bindValue(2, null, PDO::PARAM_NULL); } if ($websiteUrl !== '') { $stmt->bindValue(3, $websiteUrl, PDO::PARAM_STR); } else { $stmt->bindValue(3, null, PDO::PARAM_NULL); } if ($discordUrl !== '') { $stmt->bindValue(4, $discordUrl, PDO::PARAM_STR); } else { $stmt->bindValue(4, null, PDO::PARAM_NULL); } $stmt->bindValue(5, $rconPassword, PDO::PARAM_STR); if ($bannedCfgPath !== '') { $stmt->bindValue(6, $bannedCfgPath, PDO::PARAM_STR); } else { $stmt->bindValue(6, null, PDO::PARAM_NULL); } $stmt->bindValue(7, $id, PDO::PARAM_INT); $stmt->execute(); logActivity($db, $_SESSION['username'] ?? 'unknown', 'update_server_profile', "Server {$id} profile updated"); logServerEvent($db, $id, 'INFO', 'SERVER', "Profile updated by " . ($_SESSION['username'] ?? 'unknown')); echo json_encode(['success' => true, 'message' => 'Server profile updated']); break; case 'delete_server': requireLoginJSON(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success' => false, 'error' => 'Invalid method']); break; } $data = json_decode(file_get_contents('php://input'), true); $id = (int)($data['id'] ?? 0); if ($id <= 0) { echo json_encode(['success' => false, 'error' => 'Invalid server id']); break; } $stmtName = $db->prepare("SELECT name, ip, port, user_id FROM servers WHERE id = ?"); $stmtName->bindValue(1, $id, PDO::PARAM_INT); $stmtName->execute(); $rowName = $stmtName->fetch(PDO::FETCH_ASSOC); if (!$rowName) { echo json_encode(['success' => false, 'error' => 'Server not found']); break; } if (!isSuperAdmin() && (int)$rowName['user_id'] !== (int)$_SESSION['user_id']) { echo json_encode(['success' => false, 'error' => 'Access denied']); break; } $stmt = $db->prepare("DELETE FROM servers WHERE id = ?"); $stmt->bindValue(1, $id, PDO::PARAM_INT); $stmt->execute(); logActivity($db, $_SESSION['username'] ?? 'unknown', 'delete_server', "Server ID {$id}"); logServerEvent($db, $id, 'WARN', 'SERVER', "Server deleted by " . ($_SESSION['username'] ?? 'unknown') . ' (' . ($rowName['name'] ?? 'unknown') . ')'); echo json_encode(['success' => true, 'message' => 'Server deleted']); break; case 'refresh_server_status': requireLoginJSON(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success' => false, 'error' => 'Invalid method']); break; } $data = json_decode(file_get_contents('php://input'), true); $id = (int)($data['id'] ?? 0); $stmt = $db->prepare("SELECT * FROM servers WHERE id = ?"); $stmt->bindValue(1, $id, PDO::PARAM_INT); $stmt->execute(); $server = $stmt->fetch(PDO::FETCH_ASSOC); if (!$server || (!isSuperAdmin() && (int)$server['user_id'] !== (int)$_SESSION['user_id'])) { echo json_encode(['success' => false, 'error' => 'Server not found or access denied']); break; } if (!$server) { echo json_encode(['success' => false, 'error' => 'Server not found']); break; } $previousStatus = $server['status']; $probe = queryServerProbe((string)$server['ip'], (int)$server['port'], 2, (string)($server['game_type'] ?? 'CS 1.6')); $online = !empty($probe['online']) ? 'online' : 'offline'; $stmt2 = $db->prepare("UPDATE servers SET status = ?, status_checked_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmt2->bindValue(1, $online, PDO::PARAM_STR); $stmt2->bindValue(2, $id, PDO::PARAM_INT); $stmt2->execute(); if ($online !== $previousStatus) { logServerEvent($db, $id, 'INFO', 'STATUS', "Status changed {$previousStatus} -> {$online} by " . ($_SESSION['username'] ?? 'unknown')); } echo json_encode(['success' => true, 'status' => $online, 'probe' => $probe]); break; case 'diagnose_server_status': requireLoginJSON(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success' => false, 'error' => 'Invalid method']); break; } $data = json_decode(file_get_contents('php://input'), true); if (!is_array($data)) { $data = []; } $id = (int)($data['id'] ?? 0); if ($id <= 0) { echo json_encode(['success' => false, 'error' => 'Invalid server id']); break; } $stmt = $db->prepare("SELECT * FROM servers WHERE id = ?"); $stmt->bindValue(1, $id, PDO::PARAM_INT); $stmt->execute(); $server = $stmt->fetch(PDO::FETCH_ASSOC); if (!$server || (!isSuperAdmin() && (int)$server['user_id'] !== (int)($_SESSION['user_id'] ?? 0))) { echo json_encode(['success' => false, 'error' => 'Server not found or access denied']); break; } $probe = queryServerProbe((string)$server['ip'], (int)$server['port'], 2, (string)($server['game_type'] ?? 'CS 1.6')); $reactionStats = getServerReactionStats($db, $id); echo json_encode([ 'success' => true, 'status' => (!empty($probe['online']) ? 'online' : 'offline'), 'probe' => $probe, 'reactions' => $reactionStats, ]); break; // ---------- RCON ---------- case 'execute_rcon': requireLoginJSON(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success' => false, 'error' => 'Invalid method']); break; } $data = json_decode(file_get_contents('php://input'), true); $server_id = (int)($data['server_id'] ?? 0); $command = trim($data['command'] ?? ''); $skipHistory = !empty($data['skip_history']); if ($server_id <= 0 || $command === '') { echo json_encode(['success' => false, 'error' => 'Server and command required']); break; } $server = getAccessibleServer($db, $server_id); if (!$server) { echo json_encode(['success' => false, 'error' => 'Server not found or access denied']); break; } if (empty($server['rcon_password'])) { echo json_encode(['success' => false, 'error' => 'RCON password not configured for this server']); break; } try { [$rconHost, $rconPort] = normalizeServerAddress((string)$server['ip'], (int)$server['port']); $rcon = new GoldSrcRcon( $rconHost, $rconPort, $server['rcon_password'], 3 ); $output = $rcon->execute($command); $userName = $_SESSION['username'] ?? 'unknown'; logActivity( $db, $userName, 'rcon', "{$server['name']} ({$server['ip']}:{$server['port']}): {$command}" ); $shortOut = function_exists('mb_substr') ? mb_substr($output, 0, 2000) : substr($output, 0, 2000); // Internal/fallback calls (skip_history=1) should not spam event stream. if (!$skipHistory) { logServerEvent( $db, $server_id, 'INFO', 'RCON', "{$userName} executed: {$command}\nOutput: {$shortOut}" ); } if (!$skipHistory) { $stmtHistory = $db->prepare("INSERT INTO rcon_history (server_id, user_id, command, response) VALUES (?, ?, ?, ?)"); $stmtHistory->bindValue(1, $server_id, PDO::PARAM_INT); $stmtHistory->bindValue(2, (int)($_SESSION['user_id'] ?? 0), PDO::PARAM_INT); $stmtHistory->bindValue(3, $command, PDO::PARAM_STR); $stmtHistory->bindValue(4, $shortOut, PDO::PARAM_STR); $stmtHistory->execute(); } echo json_encode([ 'success' => true, 'output' => $output, 'timestamp' => date('H:i:s') ]); } catch (Exception $e) { if (!$skipHistory) { logServerEvent( $db, $server_id, 'ERROR', 'RCON', "RCON error by " . ($_SESSION['username'] ?? 'unknown') . " on command '{$command}': " . $e->getMessage() ); } echo json_encode([ 'success' => false, 'error' => 'RCON error: ' . $e->getMessage() ]); } break; case 'get_rcon_history': requireLoginJSON(); $serverId = (int)($_GET['server_id'] ?? 0); if ($serverId <= 0) { echo json_encode(['success' => false, 'error' => 'Server ID required']); break; } $server = getAccessibleServer($db, $serverId); if (!$server) { echo json_encode(['success' => false, 'error' => 'Server not found or access denied']); break; } $stmt = $db->prepare("SELECT rh.*, u.username FROM rcon_history rh LEFT JOIN users u ON u.id = rh.user_id WHERE rh.server_id = ? ORDER BY rh.id DESC LIMIT 50"); $stmt->bindValue(1, $serverId, PDO::PARAM_INT); $stmt->execute(); $rows = []; while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $rows[] = $row; } echo json_encode(['success' => true, 'data' => $rows]); break; // ---------- PLAYERS ---------- case 'get_players': requireLoginJSON(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success' => false, 'error' => 'Invalid method']); break; } $data = json_decode(file_get_contents('php://input'), true); $serverId = (int)($data['server_id'] ?? 0); if ($serverId <= 0) { echo json_encode(['success' => false, 'error' => 'Server ID required']); break; } $server = getAccessibleServer($db, $serverId); if (!$server) { echo json_encode(['success' => false, 'error' => 'Server not found or access denied']); break; } if (empty($server['rcon_password'])) { echo json_encode(['success' => false, 'error' => 'RCON password not configured for this server']); break; } try { [$rconHost, $rconPort] = normalizeServerAddress((string)$server['ip'], (int)$server['port']); $rcon = new GoldSrcRcon($rconHost, $rconPort, (string)$server['rcon_password'], 3); $statusOutput = $rcon->execute('status'); $players = parseStatusPlayersOutput($statusOutput); $db->beginTransaction(); $stmtDel = $db->prepare("DELETE FROM players WHERE server_id = ?"); $stmtDel->bindValue(1, $serverId, PDO::PARAM_INT); $stmtDel->execute(); if (!empty($players)) { $stmtIns = $db->prepare("INSERT INTO players (server_id, name, steam_id, ip_address, ping, time_played, last_seen) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)"); foreach ($players as $p) { $stmtIns->bindValue(1, $serverId, PDO::PARAM_INT); $stmtIns->bindValue(2, $p['name'], PDO::PARAM_STR); $stmtIns->bindValue(3, $p['steam_id'], PDO::PARAM_STR); $stmtIns->bindValue(4, $p['ip'], PDO::PARAM_STR); $stmtIns->bindValue(5, (int)$p['ping'], PDO::PARAM_INT); $stmtIns->bindValue(6, $p['time'], PDO::PARAM_STR); $stmtIns->execute(); } } $db->commit(); echo json_encode(['success' => true, 'players' => $players]); } catch (Throwable $e) { if ($db->inTransaction()) { $db->rollBack(); } echo json_encode(['success' => false, 'error' => 'Failed to fetch players: ' . $e->getMessage()]); } break; case 'get_player_identity_cache': requireLoginJSON(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success' => false, 'error' => 'Invalid method']); break; } $data = json_decode(file_get_contents('php://input'), true); $serverId = (int)($data['server_id'] ?? 0); if ($serverId <= 0) { echo json_encode(['success' => false, 'error' => 'Server ID required']); break; } $server = getAccessibleServer($db, $serverId); if (!$server) { echo json_encode(['success' => false, 'error' => 'Server not found or access denied']); break; } $rawNames = $data['names'] ?? []; $allowedStrict = []; $allowedLoose = []; if (is_array($rawNames)) { foreach ($rawNames as $n) { $name = trim((string)$n); if ($name === '') continue; $strict = normalizePlayerNameForLookup($name); $loose = normalizePlayerNameLooseForLookup($name); if ($strict !== '') $allowedStrict[$strict] = true; if ($loose !== '') $allowedLoose[$loose] = true; } } $stmt = $db->prepare("SELECT name, steam_id, ip_address, ping, time_played, last_seen FROM players WHERE server_id = ? ORDER BY last_seen DESC, id DESC LIMIT 2000"); $stmt->bindValue(1, $serverId, PDO::PARAM_INT); $stmt->execute(); $rows = []; $seen = []; while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $nameRaw = trim((string)($row['name'] ?? '')); if ($nameRaw === '') continue; $strict = normalizePlayerNameForLookup($nameRaw); $loose = normalizePlayerNameLooseForLookup($nameRaw); if (!empty($allowedStrict) || !empty($allowedLoose)) { $ok = false; if ($strict !== '' && isset($allowedStrict[$strict])) $ok = true; if (!$ok && $loose !== '' && isset($allowedLoose[$loose])) $ok = true; if (!$ok) continue; } $steamId = trim((string)($row['steam_id'] ?? '')); $ip = trim((string)($row['ip_address'] ?? '')); $ping = (int)($row['ping'] ?? 0); if ($steamId === '' && $ip === '') { continue; } $seenKey = $strict . '|' . $loose; if ($seenKey !== '|' && isset($seen[$seenKey])) { continue; } if ($seenKey !== '|') { $seen[$seenKey] = true; } $rows[] = [ 'name' => $nameRaw, 'strict_key' => $strict, 'loose_key' => $loose, 'steam_id' => $steamId, 'ip_address' => $ip, 'ping' => $ping, 'time_played' => (string)($row['time_played'] ?? ''), 'last_seen' => (string)($row['last_seen'] ?? ''), ]; } echo json_encode(['success' => true, 'data' => $rows]); break; // ---------- BANS ---------- case 'get_bans': requireLoginJSON(); if (isSuperAdmin()) { $res = $db->query("SELECT b.*, s.name AS server_name FROM bans b LEFT JOIN servers s ON s.id = b.server_id WHERE b.is_active = 1 ORDER BY b.id DESC"); } else { $userId = (int)($_SESSION['user_id'] ?? 0); $stmt = $db->prepare("SELECT b.*, s.name AS server_name FROM bans b INNER JOIN servers s ON s.id = b.server_id WHERE b.is_active = 1 AND s.user_id = ? ORDER BY b.id DESC"); $stmt->bindValue(1, $userId, PDO::PARAM_INT); $stmt->execute(); $res = $stmt; } $rows = []; while ($row = $res->fetch(PDO::FETCH_ASSOC)) { $rows[] = $row; } echo json_encode(['success' => true, 'data' => $rows]); break; case 'add_ban': requireLoginJSON(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success' => false, 'error' => 'Invalid method']); break; } $data = json_decode(file_get_contents('php://input'), true); $serverId = (int)($data['server_id'] ?? 0); $target = trim((string)($data['target'] ?? '')); $reason = trim((string)($data['reason'] ?? '')); $duration = max(0, (int)($data['duration'] ?? 0)); if ($serverId <= 0 || $target === '') { echo json_encode(['success' => false, 'error' => 'Server and target are required']); break; } $server = getAccessibleServer($db, $serverId); if (!$server) { echo json_encode(['success' => false, 'error' => 'Server not found or access denied']); break; } if (empty($server['rcon_password'])) { echo json_encode(['success' => false, 'error' => 'RCON password not configured for this server']); break; } try { [$rconHost, $rconPort] = normalizeServerAddress((string)$server['ip'], (int)$server['port']); $rcon = new GoldSrcRcon($rconHost, $rconPort, (string)$server['rcon_password'], 3); $kickName = ''; $banApplied = false; $writeIdNeeded = false; $banTargetStored = $target; $banDebugErrors = []; $statusPlayers = []; $matchedPlayer = null; $isIpTarget = normalizePlayerIpForModeration($target) !== ''; try { $statusOutput = $rcon->execute('status'); $statusPlayers = parseStatusPlayersOutput($statusOutput); } catch (Throwable $_statusErr) { $statusPlayers = []; } if (!empty($statusPlayers)) { if ($isIpTarget) { $targetIp = normalizePlayerIpForModeration($target); foreach ($statusPlayers as $sp) { $spIp = normalizePlayerIpForModeration((string)($sp['ip'] ?? '')); if ($targetIp !== '' && $spIp !== '' && $spIp === $targetIp) { $matchedPlayer = $sp; break; } } } else { $targetIds = buildIdentityTargetsForRcon($target); $idSet = []; foreach ($targetIds as $idItem) { $idUpper = strtoupper(trim((string)$idItem)); if ($idUpper !== '') { $idSet[$idUpper] = true; } } foreach ($statusPlayers as $sp) { $spId = strtoupper(trim((string)($sp['steam_id'] ?? ''))); if ($spId !== '' && isset($idSet[$spId])) { $matchedPlayer = $sp; break; } } } } if ($isIpTarget) { $targetIp = normalizePlayerIpForModeration($target); $minutes = $duration > 0 ? $duration : 0; $respAddIp = $rcon->execute("addip {$minutes} {$targetIp}"); if (isLikelyRconCommandError($respAddIp)) { $banDebugErrors[] = 'addip: ' . trim($respAddIp); } else { $banApplied = true; $banTargetStored = $targetIp; } $rcon->execute("writeip"); // Plugin fallback (best-effort): amx_addban "" "" if (!$banApplied) { $amxIpCmd = 'amx_addban ' . quoteRconArg($targetIp) . ' ' . (int)$minutes; if ($reason !== '') { $amxIpCmd .= ' ' . quoteRconArg($reason); } $respAmxIp = $rcon->execute($amxIpCmd); if (!isLikelyRconCommandError($respAmxIp)) { $banApplied = true; $banTargetStored = $targetIp; } else { $banDebugErrors[] = 'amx_addban(ip): ' . trim($respAmxIp); } } } else { $idTargets = buildIdentityTargetsForRcon($target); if (empty($idTargets)) { throw new RuntimeException('Invalid identity target'); } // Requested order: first AMXX authid ban with quoted target. foreach ($idTargets as $idTarget) { if ($banApplied) { break; } $idCandidate = trim((string)$idTarget); if ($idCandidate === '') { continue; } $amxCommands = []; if ($reason !== '') { $amxCommands[] = 'amx_addban ' . quoteRconArg($idCandidate) . ' ' . (int)$duration . ' ' . quoteRconArg($reason); } $amxCommands[] = 'amx_addban ' . quoteRconArg($idCandidate) . ' ' . (int)$duration; $amxCommands[] = 'amx_addban ' . quoteRconArg($idCandidate); foreach ($amxCommands as $amxCmd) { $respAmx = $rcon->execute($amxCmd); if (!isLikelyRconCommandError($respAmx)) { $banApplied = true; $banTargetStored = $idCandidate; break; } $banDebugErrors[] = $amxCmd . ' => ' . trim($respAmx); } } if (!$banApplied) { $matchedUserId = (int)($matchedPlayer['user_id'] ?? 0); if ($matchedUserId > 0) { $cmdByUserId = 'banid ' . (int)$duration . ' #' . $matchedUserId . ' kick'; $respByUserId = $rcon->execute($cmdByUserId); if (!isLikelyRconCommandError($respByUserId)) { $banApplied = true; $writeIdNeeded = true; $matchedId = trim((string)($matchedPlayer['steam_id'] ?? '')); if ($matchedId !== '') { $banTargetStored = $matchedId; } $kickName = trim((string)($matchedPlayer['name'] ?? '')); } else { $banDebugErrors[] = $cmdByUserId . ' => ' . trim($respByUserId); } } } foreach ($idTargets as $idTarget) { if ($banApplied) { break; } $idCandidate = trim((string)$idTarget); if ($idCandidate === '') { continue; } $commands = [ 'banid ' . (int)$duration . ' ' . quoteRconArg($idCandidate) . ' kick', 'banid ' . (int)$duration . ' ' . $idCandidate . ' kick', 'addid ' . (int)$duration . ' ' . quoteRconArg($idCandidate), 'addid ' . (int)$duration . ' ' . $idCandidate, 'banid ' . (int)$duration . ' ' . quoteRconArg($idCandidate), 'banid ' . (int)$duration . ' ' . $idCandidate, ]; foreach ($commands as $cmd) { $resp = $rcon->execute($cmd); if (!isLikelyRconCommandError($resp)) { $banApplied = true; $writeIdNeeded = true; $banTargetStored = $idCandidate; break; } $banDebugErrors[] = $cmd . ' => ' . trim($resp); } if ($banApplied) { break; } // Plugin fallback for servers using AMXX ban systems. $amxCmd = 'amx_addban ' . quoteRconArg($idCandidate) . ' ' . (int)$duration; if ($reason !== '') { $amxCmd .= ' ' . quoteRconArg($reason); } $respAmx = $rcon->execute($amxCmd); if (!isLikelyRconCommandError($respAmx)) { $banApplied = true; $banTargetStored = $idCandidate; break; } $banDebugErrors[] = $amxCmd . ' => ' . trim($respAmx); } if ($writeIdNeeded) { $rcon->execute("writeid"); } } if (!$banApplied) { $dbg = ''; if (!empty($banDebugErrors)) { $dbg = ' [' . implode(' | ', array_slice($banDebugErrors, 0, 3)) . ']'; } throw new RuntimeException('Ban command was not accepted by server' . $dbg); } // Ban Management requirement: kick player after ban if currently connected. try { if ($isIpTarget) { $targetIp = normalizePlayerIpForModeration($banTargetStored); foreach ($statusPlayers as $sp) { $spIp = normalizePlayerIpForModeration((string)($sp['ip'] ?? '')); if ($targetIp !== '' && $spIp !== '' && $spIp === $targetIp) { $kickName = trim((string)($sp['name'] ?? '')); break; } } } else { $targetIds = buildIdentityTargetsForRcon($banTargetStored); $idSet = []; foreach ($targetIds as $idItem) { $idUpper = strtoupper(trim((string)$idItem)); if ($idUpper !== '') { $idSet[$idUpper] = true; } } foreach ($statusPlayers as $sp) { $spId = strtoupper(trim((string)($sp['steam_id'] ?? ''))); if ($spId !== '' && isset($idSet[$spId])) { $kickName = trim((string)($sp['name'] ?? '')); break; } } } if ($kickName !== '') { try { $rcon->execute('amx_kick ' . quoteRconArg($kickName)); } catch (Throwable $_amxKickErr) { $rcon->execute('kick ' . quoteRconArg($kickName)); } } elseif (!$isIpTarget && $banTargetStored !== '') { // Last-resort attempt if only ID is known. try { $rcon->execute('amx_kick ' . quoteRconArg($banTargetStored)); } catch (Throwable $_amxKickIdErr) { // ignore } } } catch (Throwable $_kickErr) { // Non-fatal: banning still succeeded even if kick resolution fails. } $expiresAt = $duration > 0 ? date('Y-m-d H:i:s', time() + ($duration * 60)) : null; $stmt = $db->prepare("INSERT INTO bans (server_id, target, reason, duration_minutes, expires_at, created_by, is_active) VALUES (?, ?, ?, ?, ?, ?, 1)"); $stmt->bindValue(1, $serverId, PDO::PARAM_INT); $stmt->bindValue(2, $banTargetStored, PDO::PARAM_STR); $stmt->bindValue(3, $reason, PDO::PARAM_STR); $stmt->bindValue(4, $duration, PDO::PARAM_INT); if ($expiresAt === null) { $stmt->bindValue(5, null, PDO::PARAM_NULL); } else { $stmt->bindValue(5, $expiresAt, PDO::PARAM_STR); } $stmt->bindValue(6, (int)($_SESSION['user_id'] ?? 0), PDO::PARAM_INT); $stmt->execute(); $actor = $_SESSION['username'] ?? 'unknown'; logActivity($db, $actor, 'add_ban', "{$banTargetStored} on {$server['name']}"); $banLogMessage = "{$actor} banned {$banTargetStored} ({$reason})"; if ($kickName !== '') { $banLogMessage .= " and kicked {$kickName}"; } logServerEvent($db, $serverId, 'WARN', 'BAN', $banLogMessage); $responseMessage = 'Ban added'; if ($kickName !== '') { $responseMessage .= " and player kicked ({$kickName})"; } echo json_encode(['success' => true, 'message' => $responseMessage]); } catch (Throwable $e) { echo json_encode(['success' => false, 'error' => 'Failed to add ban: ' . $e->getMessage()]); } break; case 'moderate_player': requireLoginJSON(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success' => false, 'error' => 'Invalid method']); break; } $data = json_decode(file_get_contents('php://input'), true); $serverId = (int)($data['server_id'] ?? 0); $mode = strtolower(trim((string)($data['mode'] ?? ''))); $playerName = trim((string)($data['player_name'] ?? '')); $reason = trim((string)($data['reason'] ?? '')); $duration = max(0, (int)($data['duration'] ?? 0)); $targetPref = (string)($data['target_pref'] ?? 'auto'); $steamId = trim((string)($data['steam_id'] ?? '')); $ipAddress = trim((string)($data['ip_address'] ?? '')); $fallbackTarget = trim((string)($data['target'] ?? '')); if ($serverId <= 0) { echo json_encode(['success' => false, 'error' => 'Server is required']); break; } if (!in_array($mode, ['kick', 'ban', 'unban'], true)) { echo json_encode(['success' => false, 'error' => 'Invalid moderation mode']); break; } $server = getAccessibleServer($db, $serverId); if (!$server) { echo json_encode(['success' => false, 'error' => 'Server not found or access denied']); break; } if (empty($server['rcon_password'])) { echo json_encode(['success' => false, 'error' => 'RCON password not configured for this server']); break; } if ($mode === 'kick' && $playerName === '') { echo json_encode(['success' => false, 'error' => 'Player name is required for kick']); break; } $targetInfo = selectModerationTarget($targetPref, $steamId, $ipAddress, $fallbackTarget); $target = (string)($targetInfo['target'] ?? ''); $targetType = (string)($targetInfo['type'] ?? 'unknown'); if (($mode === 'ban' || $mode === 'unban') && $target === '') { echo json_encode(['success' => false, 'error' => 'Player SteamID/NonSteam ID or IP not available']); break; } try { [$rconHost, $rconPort] = normalizeServerAddress((string)$server['ip'], (int)$server['port']); $rcon = new GoldSrcRcon($rconHost, $rconPort, (string)$server['rcon_password'], 3); $actor = (string)($_SESSION['username'] ?? 'unknown'); if ($mode === 'kick') { $rcon->execute('kick ' . quoteRconArg($playerName)); logActivity($db, $actor, 'kick_player', "{$playerName} on {$server['name']}"); logServerEvent($db, $serverId, 'WARN', 'KICK', "{$actor} kicked {$playerName}"); echo json_encode(['success' => true, 'message' => 'Player kicked']); break; } if ($mode === 'ban') { if ($targetType === 'ip') { $minutes = $duration > 0 ? $duration : 0; $rcon->execute("addip {$minutes} {$target}"); $rcon->execute('writeip'); } else { $rcon->execute('banid ' . (int)$duration . ' ' . quoteRconArg($target)); $rcon->execute('writeid'); } if ($playerName !== '') { try { $rcon->execute('kick ' . quoteRconArg($playerName)); } catch (Throwable $_kickErr) { // Non-fatal: player may already be disconnected. } } $expiresAt = $duration > 0 ? date('Y-m-d H:i:s', time() + ($duration * 60)) : null; $stmt = $db->prepare("INSERT INTO bans (server_id, target, reason, duration_minutes, expires_at, created_by, is_active) VALUES (?, ?, ?, ?, ?, ?, 1)"); $stmt->bindValue(1, $serverId, PDO::PARAM_INT); $stmt->bindValue(2, $target, PDO::PARAM_STR); $stmt->bindValue(3, $reason, PDO::PARAM_STR); $stmt->bindValue(4, $duration, PDO::PARAM_INT); if ($expiresAt === null) { $stmt->bindValue(5, null, PDO::PARAM_NULL); } else { $stmt->bindValue(5, $expiresAt, PDO::PARAM_STR); } $stmt->bindValue(6, (int)($_SESSION['user_id'] ?? 0), PDO::PARAM_INT); $stmt->execute(); logActivity($db, $actor, 'add_ban', "{$target} on {$server['name']}"); $banReason = $reason !== '' ? $reason : 'No reason'; logServerEvent($db, $serverId, 'WARN', 'BAN', "{$actor} banned {$target} ({$banReason})"); echo json_encode(['success' => true, 'message' => 'Player banned', 'target' => $target, 'target_type' => $targetType]); break; } $cfgCleanup = null; if ($targetType === 'ip') { $rcon->execute("removeip {$target}"); $rcon->execute('writeip'); } else { $idTargets = buildIdentityTargetsForRcon($target); $amxUnbanSent = false; $amxUnbanErrors = []; // Try AMXX plugin unban with quoted target, e.g. amx_unban "STEAM_0:0:12345" foreach ($idTargets as $idTarget) { try { $rcon->execute('amx_unban ' . quoteRconArg($idTarget)); $amxUnbanSent = true; } catch (Throwable $eAmxUnban) { $amxUnbanErrors[] = $eAmxUnban->getMessage(); } } $removeIdWorked = false; $lastError = null; foreach ($idTargets as $idTarget) { try { $rcon->execute('removeid ' . quoteRconArg($idTarget)); $removeIdWorked = true; break; } catch (Throwable $eQuoted) { $lastError = $eQuoted; } try { $rcon->execute('removeid ' . $idTarget); $removeIdWorked = true; break; } catch (Throwable $eRaw) { $lastError = $eRaw; } } if (!$removeIdWorked) { if (!$amxUnbanSent) { $errMsg = $lastError ? $lastError->getMessage() : 'Unknown target format'; if (!empty($amxUnbanErrors)) { $errMsg .= ' | amx_unban: ' . implode(' ; ', array_slice($amxUnbanErrors, 0, 2)); } throw new RuntimeException('Unable to unban identity target: ' . $errMsg); } } if ($removeIdWorked) { $rcon->execute('writeid'); } // Reunion/non-steam setups may also have an IP-level ban for the same player. $knownIps = collectKnownIpsForIdentity($db, $serverId, $idTargets); if (!empty($knownIps)) { foreach ($knownIps as $knownIp) { try { $rcon->execute("removeip {$knownIp}"); } catch (Throwable $_removeIpErr) { // Best-effort cleanup only. } } try { $rcon->execute('writeip'); } catch (Throwable $_writeIpErr) { // Best-effort cleanup only. } } $cfgPath = normalizeBannedCfgPath((string)($server['banned_cfg_path'] ?? '')); if ($cfgPath !== '') { $cfgCleanup = removeBanTargetsFromBannedCfg($cfgPath, $idTargets); } } $stmt = $db->prepare("UPDATE bans SET is_active = 0 WHERE server_id = ? AND target = ? AND is_active = 1"); $stmt->bindValue(1, $serverId, PDO::PARAM_INT); $stmt->bindValue(2, $target, PDO::PARAM_STR); $stmt->execute(); logActivity($db, $actor, 'remove_ban', "{$target} on {$server['name']}"); logServerEvent($db, $serverId, 'INFO', 'UNBAN', "{$actor} unbanned {$target}"); $message = 'Player unbanned'; if (is_array($cfgCleanup)) { if (!empty($cfgCleanup['success'])) { $removedCount = (int)($cfgCleanup['removed'] ?? 0); $message .= $removedCount > 0 ? " and removed {$removedCount} entr" . ($removedCount === 1 ? 'y' : 'ies') . " from banned.cfg" : ' (banned.cfg checked, no matching entries)'; } else { $cfgErr = trim((string)($cfgCleanup['error'] ?? 'Unknown error')); if ($cfgErr !== '') { $message .= ' (banned.cfg warning: ' . $cfgErr . ')'; } } } echo json_encode(['success' => true, 'message' => $message, 'target' => $target, 'target_type' => $targetType]); } catch (Throwable $e) { echo json_encode(['success' => false, 'error' => 'Moderation failed: ' . $e->getMessage()]); } break; // ---------- USERS (SUPER ADMIN) ---------- case 'get_users': requireLoginJSON(); requireSuperAdminJSON(); $result = $db->query('SELECT u.id, u.username, u.role, u.email, u.last_login, u.created_at, (SELECT COUNT(*) FROM servers s WHERE s.user_id = u.id) AS servers_count FROM users u ORDER BY u.id ASC'); $users = []; while ($row = $result->fetch(PDO::FETCH_ASSOC)) { $users[] = $row; } echo json_encode(['success' => true, 'data' => $users]); break; case 'create_user': requireLoginJSON(); requireSuperAdminJSON(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success' => false, 'error' => 'Invalid method']); break; } $data = json_decode(file_get_contents('php://input'), true); $username = trim($data['username'] ?? ''); $password = $data['password'] ?? ''; $role = trim($data['role'] ?? 'Admin'); $email = trim($data['email'] ?? ''); if ($username === '' || $password === '') { echo json_encode(['success' => false, 'error' => 'Username and password required']); break; } if (!in_array($role, ['User', 'Admin', 'Super Admin'], true)) { echo json_encode(['success' => false, 'error' => 'Invalid role']); break; } if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) { echo json_encode(['success' => false, 'error' => 'Invalid email']); break; } $hash = password_hash($password, PASSWORD_DEFAULT); $stmt = $db->prepare("INSERT INTO users (username, password, role, email) VALUES (?, ?, ?, ?)"); $stmt->bindValue(1, $username, PDO::PARAM_STR); $stmt->bindValue(2, $hash, PDO::PARAM_STR); $stmt->bindValue(3, $role, PDO::PARAM_STR); $stmt->bindValue(4, $email, PDO::PARAM_STR); try { $stmt->execute(); logActivity($db, $_SESSION['username'] ?? 'unknown', 'create_user', $username); echo json_encode(['success' => true]); } catch (Throwable $e) { echo json_encode(['success' => false, 'error' => 'Failed: ' . $e->getMessage()]); } break; case 'update_user': requireLoginJSON(); requireSuperAdminJSON(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success' => false, 'error' => 'Invalid method']); break; } $data = json_decode(file_get_contents('php://input'), true); $id = (int)($data['id'] ?? 0); $username = trim((string)($data['username'] ?? '')); $role = trim($data['role'] ?? ''); $email = trim($data['email'] ?? ''); $password = $data['password'] ?? ''; if ($id <= 0) { echo json_encode(['success' => false, 'error' => 'Invalid user id']); break; } $stmt = $db->prepare("SELECT * FROM users WHERE id = ?"); $stmt->bindValue(1, $id, PDO::PARAM_INT); $stmt->execute(); $user = $stmt->fetch(PDO::FETCH_ASSOC); if (!$user) { echo json_encode(['success' => false, 'error' => 'User not found']); break; } $effectiveUsername = $username !== '' ? $username : (string)$user['username']; if ($effectiveUsername === '') { echo json_encode(['success' => false, 'error' => 'Username is required']); break; } if (strlen($effectiveUsername) < 3 || strlen($effectiveUsername) > 64) { echo json_encode(['success' => false, 'error' => 'Username must be between 3 and 64 characters']); break; } if (strcasecmp($effectiveUsername, (string)$user['username']) !== 0) { $stmtUser = $db->prepare("SELECT id FROM users WHERE username = ? AND id <> ? LIMIT 1"); $stmtUser->bindValue(1, $effectiveUsername, PDO::PARAM_STR); $stmtUser->bindValue(2, $id, PDO::PARAM_INT); $stmtUser->execute(); if ($stmtUser->fetch(PDO::FETCH_ASSOC)) { echo json_encode(['success' => false, 'error' => 'Username already exists']); break; } } if ($role !== '' && !in_array($role, ['User', 'Admin', 'Super Admin'], true)) { echo json_encode(['success' => false, 'error' => 'Invalid role']); break; } if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) { echo json_encode(['success' => false, 'error' => 'Invalid email']); break; } if ($password !== '' && strlen((string)$password) < 6) { echo json_encode(['success' => false, 'error' => 'Password too short (min 6 chars)']); break; } $effectiveRole = $role !== '' ? $role : $user['role']; if ((string)$user['role'] === 'Super Admin' && $effectiveRole !== 'Super Admin') { $superCount = (int)$db->query("SELECT COUNT(*) FROM users WHERE role = 'Super Admin'")->fetchColumn(); if ($superCount <= 1) { echo json_encode(['success' => false, 'error' => 'At least one Super Admin account must remain']); break; } } $sql = "UPDATE users SET username = ?, role = ?, email = ?"; $params = [$effectiveUsername, $effectiveRole, $email]; if ($password !== '') { $sql .= ", password = ?"; $params[] = password_hash($password, PASSWORD_DEFAULT); } $sql .= " WHERE id = ?"; $params[] = $id; $stmt2 = $db->prepare($sql); $stmt2->execute($params); $details = (string)$user['username'] . ' -> ' . $effectiveUsername . ' (' . $user['role'] . ' -> ' . $effectiveRole . ')'; logActivity($db, $_SESSION['username'] ?? 'unknown', 'update_user', $details); echo json_encode(['success' => true]); break; case 'delete_user': requireLoginJSON(); requireSuperAdminJSON(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success' => false, 'error' => 'Invalid method']); break; } $data = json_decode(file_get_contents('php://input'), true); $id = (int)($data['id'] ?? 0); if ($id <= 0) { echo json_encode(['success' => false, 'error' => 'Invalid user id']); break; } if ($id === ($_SESSION['user_id'] ?? 0)) { echo json_encode(['success' => false, 'error' => 'You cannot delete yourself']); break; } $stmt = $db->prepare("SELECT username, role FROM users WHERE id = ?"); $stmt->bindValue(1, $id, PDO::PARAM_INT); $stmt->execute(); $user = $stmt->fetch(PDO::FETCH_ASSOC); if (!$user) { echo json_encode(['success' => false, 'error' => 'User not found']); break; } if ((string)($user['role'] ?? '') === 'Super Admin') { $superCount = (int)$db->query("SELECT COUNT(*) FROM users WHERE role = 'Super Admin'")->fetchColumn(); if ($superCount <= 1) { echo json_encode(['success' => false, 'error' => 'Cannot delete the last Super Admin']); break; } } $stmt2 = $db->prepare("DELETE FROM users WHERE id = ?"); $stmt2->bindValue(1, $id, PDO::PARAM_INT); $stmt2->execute(); logActivity($db, $_SESSION['username'] ?? 'unknown', 'delete_user', $user['username'] ?? ('ID '.$id)); echo json_encode(['success' => true]); break; // ---------- ACCOUNT (SELF PASSWORD CHANGE) ---------- case 'change_password': requireLoginJSON(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success' => false, 'error' => 'Invalid method']); break; } $data = json_decode(file_get_contents('php://input'), true); $old = $data['old_password'] ?? ''; $new = $data['new_password'] ?? ''; if ($new === '' || strlen($new) < 6) { echo json_encode(['success' => false, 'error' => 'New password too short (min 6 chars)']); break; } $id = (int)($_SESSION['user_id'] ?? 0); $stmt = $db->prepare("SELECT * FROM users WHERE id = ?"); $stmt->bindValue(1, $id, PDO::PARAM_INT); $stmt->execute(); $user = $stmt->fetch(PDO::FETCH_ASSOC); if (!$user || !password_verify($old, $user['password'])) { echo json_encode(['success' => false, 'error' => 'Old password incorrect']); break; } $hash = password_hash($new, PASSWORD_DEFAULT); $stmt2 = $db->prepare("UPDATE users SET password = ? WHERE id = ?"); $stmt2->bindValue(1, $hash, PDO::PARAM_STR); $stmt2->bindValue(2, $id, PDO::PARAM_INT); $stmt2->execute(); logActivity($db, $user['username'] ?? 'unknown', 'change_password', 'User changed own password'); echo json_encode(['success' => true]); break; // ---------- ACTIVITY LOG ---------- case 'get_activity_log': requireLoginJSON(); $res = $db->query("SELECT * FROM activity_log ORDER BY id DESC LIMIT 200"); $rows = []; while ($row = $res->fetch(PDO::FETCH_ASSOC)) { $rows[] = $row; } echo json_encode(['success' => true, 'data' => $rows]); break; // ---------- SERVER GEO (SERVER_INFO-STYLE) ---------- case 'get_server_geo': requireLoginJSON(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success' => false, 'error' => 'Invalid method']); break; } $data = json_decode(file_get_contents('php://input'), true); $serverId = (int)($data['server_id'] ?? 0); if ($serverId <= 0) { echo json_encode(['success' => false, 'error' => 'Invalid server id']); break; } $stmt = $db->prepare("SELECT id, name, ip, port, user_id FROM servers WHERE id = ? LIMIT 1"); $stmt->bindValue(1, $serverId, PDO::PARAM_INT); $stmt->execute(); $server = $stmt->fetch(PDO::FETCH_ASSOC); if (!$server) { echo json_encode(['success' => false, 'error' => 'Server not found']); break; } if (!isSuperAdmin() && (int)($server['user_id'] ?? 0) !== (int)($_SESSION['user_id'] ?? 0)) { echo json_encode(['success' => false, 'error' => 'Access denied']); break; } $ip = (string)($server['ip'] ?? ''); $geo = panelLookupGeoByIp($ip); echo json_encode([ 'success' => true, 'data' => [ 'server_id' => (int)($server['id'] ?? 0), 'name' => (string)($server['name'] ?? ''), 'address' => $ip . ':' . (int)($server['port'] ?? 0), 'country' => (string)($geo['country'] ?? 'Unknown'), 'country_code' => (string)($geo['country_code'] ?? ''), 'country_flag' => (string)($geo['country_flag'] ?? ''), 'region' => (string)($geo['region'] ?? 'Unknown'), 'city' => (string)($geo['city'] ?? 'Unknown'), 'continent' => (string)($geo['continent'] ?? 'Unknown'), 'timezone' => (string)($geo['timezone'] ?? 'Unknown'), 'isp' => (string)($geo['isp'] ?? 'Unknown'), 'org' => (string)($geo['org'] ?? 'Unknown'), 'asn' => (string)($geo['asn'] ?? 'Unknown'), ], ]); break; // ---------- GEO IP LOOKUP ---------- case 'geoip_lookup': requireLoginJSON(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success' => false, 'error' => 'Invalid method']); break; } $data = json_decode(file_get_contents('php://input'), true); $ip = trim($data['ip'] ?? ''); $country = 'Unknown'; if (filter_var($ip, FILTER_VALIDATE_IP)) { if (function_exists('geoip_country_name_by_name')) { $countryName = @geoip_country_name_by_name($ip); if ($countryName) { $country = $countryName; } } } echo json_encode(['success' => true, 'data' => ['country' => $country]]); break; // ---------- SERVER LOGS (NEW) ---------- case 'get_server_logs': requireLoginJSON(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success' => false, 'error' => 'Invalid method']); break; } $data = json_decode(file_get_contents('php://input'), true); $serverId = (int)($data['server_id'] ?? 0); $sinceId = max(0, (int)($data['since_id'] ?? 0)); $limit = (int)($data['limit'] ?? 200); if ($limit <= 0 || $limit > 1000) $limit = 200; if ($serverId <= 0) { echo json_encode(['success' => false, 'error' => 'Invalid server id']); break; } $srv = $db->query("SELECT user_id FROM servers WHERE id = $serverId")->fetchColumn(); if ($srv === false) { echo json_encode(['success' => false, 'error' => 'Server not found']); break; } if (!isSuperAdmin() && (int)$srv !== (int)($_SESSION['user_id'] ?? 0)) { echo json_encode(['success' => false, 'error' => 'Access denied']); break; } if ($sinceId > 0) { $stmt = $db->prepare("SELECT * FROM server_logs WHERE server_id = ? AND id > ? ORDER BY id ASC LIMIT ?"); $stmt->bindValue(1, $serverId, PDO::PARAM_INT); $stmt->bindValue(2, $sinceId, PDO::PARAM_INT); $stmt->bindValue(3, $limit, PDO::PARAM_INT); $stmt->execute(); $logs = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; } else { $stmt = $db->prepare("SELECT * FROM server_logs WHERE server_id = ? ORDER BY id DESC LIMIT ?"); $stmt->bindValue(1, $serverId, PDO::PARAM_INT); $stmt->bindValue(2, $limit, PDO::PARAM_INT); $stmt->execute(); $logs = []; while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $logs[] = $row; } } echo json_encode(['success' => true, 'data' => $logs]); break; // ---------- HLDS LIVE LOG STREAM ---------- case 'setup_hlds_log_stream': requireLoginJSON(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success' => false, 'error' => 'Invalid method']); break; } $data = json_decode(file_get_contents('php://input'), true); $serverId = (int)($data['server_id'] ?? 0); if ($serverId <= 0) { echo json_encode(['success' => false, 'error' => 'Invalid server id']); break; } $server = getAccessibleServer($db, $serverId); if (!$server) { echo json_encode(['success' => false, 'error' => 'Server not found or access denied']); break; } if (empty($server['rcon_password'])) { echo json_encode(['success' => false, 'error' => 'RCON password not configured for this server']); break; } try { [$rconHost, $rconPort] = normalizeServerAddress((string)$server['ip'], (int)$server['port']); $rcon = new GoldSrcRcon($rconHost, $rconPort, (string)$server['rcon_password'], 3); $receiverHost = getHldsLogReceiverHost(); $receiverPort = getHldsLogReceiverPort(); $receiverAddr = $receiverHost . ':' . $receiverPort; $receiverLoopback = isLoopbackHost($receiverHost); $rconLoopback = isLoopbackHost($rconHost); $responses = []; try { $responses[] = ['cmd' => 'log on', 'out' => (string)$rcon->execute('log on')]; } catch (Throwable $eLogOn) { $responses[] = ['cmd' => 'log on', 'out' => 'ERR: ' . $eLogOn->getMessage()]; } try { $responses[] = ['cmd' => 'mp_logmessages 1', 'out' => (string)$rcon->execute('mp_logmessages 1')]; } catch (Throwable $_eLogMsg) { // non-fatal } try { $responses[] = ['cmd' => 'logaddress_delall', 'out' => (string)$rcon->execute('logaddress_delall')]; } catch (Throwable $_eDelAll) { // non-fatal } $addCommands = [ 'logaddress_add ' . quoteRconArg($receiverAddr), 'logaddress_add ' . $receiverAddr, 'logaddress_add ' . quoteRconArg($receiverHost . ' ' . $receiverPort), 'logaddress_add ' . $receiverHost . ' ' . $receiverPort, ]; $added = false; foreach ($addCommands as $cmd) { try { $out = (string)$rcon->execute($cmd); $responses[] = ['cmd' => $cmd, 'out' => $out]; if (!isLikelyRconCommandError($out)) { $added = true; break; } } catch (Throwable $eAdd) { $responses[] = ['cmd' => $cmd, 'out' => 'ERR: ' . $eAdd->getMessage()]; } } if (!$added) { $errPreview = []; foreach (array_slice($responses, -3) as $r) { $errPreview[] = ($r['cmd'] ?? '?') . ' => ' . trim((string)($r['out'] ?? '')); } echo json_encode(['success' => false, 'error' => 'Failed to configure logaddress_add', 'debug' => $errPreview]); break; } logServerEvent($db, $serverId, 'INFO', 'HLDS', 'Live log stream configured to ' . $receiverAddr . ' by ' . ($_SESSION['username'] ?? 'unknown')); $warnings = []; if ($receiverLoopback && !$rconLoopback) { $warnings[] = 'Receiver host is loopback (127.0.0.1). Remote game server may send logs to itself. Set hlds_log_receiver_host in config.php to your panel public IP/domain.'; } echo json_encode([ 'success' => true, 'message' => 'HLDS log stream configured', 'receiver' => $receiverAddr, 'warnings' => $warnings, ]); } catch (Throwable $e) { echo json_encode(['success' => false, 'error' => 'Failed to setup live log stream: ' . $e->getMessage()]); } break; case 'get_hlds_live_logs': requireLoginJSON(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success' => false, 'error' => 'Invalid method']); break; } $data = json_decode(file_get_contents('php://input'), true); $serverId = (int)($data['server_id'] ?? 0); $sinceId = max(0, (int)($data['since_id'] ?? 0)); $limit = (int)($data['limit'] ?? 200); if ($limit <= 0 || $limit > 500) $limit = 200; if ($serverId <= 0) { echo json_encode(['success' => false, 'error' => 'Invalid server id']); break; } $server = getAccessibleServer($db, $serverId); if (!$server) { echo json_encode(['success' => false, 'error' => 'Server not found or access denied']); break; } try { [$hostNorm, ] = normalizeServerAddress((string)($server['ip'] ?? ''), (int)($server['port'] ?? 27015)); $serverIpFallback = ''; if (filter_var($hostNorm, FILTER_VALIDATE_IP)) { $serverIpFallback = $hostNorm; } else { $resolved = @gethostbyname($hostNorm); if (is_string($resolved) && $resolved !== '' && $resolved !== $hostNorm && filter_var($resolved, FILTER_VALIDATE_IP)) { $serverIpFallback = $resolved; } } if ($sinceId > 0) { $stmt = $db->prepare("SELECT id, server_id, source_ip, source_port, message, created_at FROM hlds_live_logs WHERE (server_id = ? OR (server_id IS NULL AND source_ip = ?)) AND id > ? ORDER BY id ASC LIMIT ?"); $stmt->bindValue(1, $serverId, PDO::PARAM_INT); $stmt->bindValue(2, $serverIpFallback, PDO::PARAM_STR); $stmt->bindValue(3, $sinceId, PDO::PARAM_INT); $stmt->bindValue(4, $limit, PDO::PARAM_INT); $stmt->execute(); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; } else { $stmt = $db->prepare("SELECT id, server_id, source_ip, source_port, message, created_at FROM hlds_live_logs WHERE (server_id = ? OR (server_id IS NULL AND source_ip = ?)) ORDER BY id DESC LIMIT ?"); $stmt->bindValue(1, $serverId, PDO::PARAM_INT); $stmt->bindValue(2, $serverIpFallback, PDO::PARAM_STR); $stmt->bindValue(3, min($limit, 120), PDO::PARAM_INT); $stmt->execute(); $rows = array_reverse($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []); } $nextId = $sinceId; foreach ($rows as $r) { $rid = (int)($r['id'] ?? 0); if ($rid > $nextId) { $nextId = $rid; } } echo json_encode([ 'success' => true, 'data' => $rows, 'next_id' => $nextId, ]); } catch (Throwable $e) { echo json_encode(['success' => false, 'error' => 'Failed to fetch live logs: ' . $e->getMessage()]); } break; case 'get_live_monitor_diagnostics': requireLoginJSON(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success' => false, 'error' => 'Invalid method']); break; } $data = json_decode(file_get_contents('php://input'), true); $serverId = (int)($data['server_id'] ?? 0); if ($serverId <= 0) { echo json_encode(['success' => false, 'error' => 'Invalid server id']); break; } $server = getAccessibleServer($db, $serverId); if (!$server) { echo json_encode(['success' => false, 'error' => 'Server not found or access denied']); break; } $receiverHost = getHldsLogReceiverHost(); $receiverPort = getHldsLogReceiverPort(); $receiver = $receiverHost . ':' . $receiverPort; [$rconHost, ] = normalizeServerAddress((string)$server['ip'], (int)$server['port']); $receiverLoopback = isLoopbackHost($receiverHost); $rconLoopback = isLoopbackHost($rconHost); [$hostNorm, ] = normalizeServerAddress((string)($server['ip'] ?? ''), (int)($server['port'] ?? 27015)); $serverIpFallback = ''; if (filter_var($hostNorm, FILTER_VALIDATE_IP)) { $serverIpFallback = $hostNorm; } else { $resolvedHost = @gethostbyname($hostNorm); if (is_string($resolvedHost) && $resolvedHost !== '' && $resolvedHost !== $hostNorm && filter_var($resolvedHost, FILTER_VALIDATE_IP)) { $serverIpFallback = $resolvedHost; } } try { $stmtHlds = $db->prepare("SELECT COUNT(*) FROM hlds_live_logs WHERE (server_id = ? OR (server_id IS NULL AND source_ip = ?)) AND created_at >= DATE_SUB(NOW(), INTERVAL 5 MINUTE)"); $stmtHlds->execute([$serverId, $serverIpFallback]); $hldsCount = (int)$stmtHlds->fetchColumn(); $stmtEvt = $db->prepare("SELECT COUNT(*) FROM server_logs WHERE server_id = ? AND created_at >= DATE_SUB(NOW(), INTERVAL 5 MINUTE)"); $stmtEvt->execute([$serverId]); $eventCount = (int)$stmtEvt->fetchColumn(); $warnings = []; if ($receiverLoopback && !$rconLoopback) { $warnings[] = 'Receiver is loopback; set hlds_log_receiver_host in config.php to a reachable panel IP/domain.'; } if (empty($server['rcon_password'])) { $warnings[] = 'RCON password is not configured on this server.'; } echo json_encode([ 'success' => true, 'data' => [ 'receiver' => $receiver, 'hlds_logs_last_5m' => $hldsCount, 'events_last_5m' => $eventCount, 'warnings' => $warnings, ], ]); } catch (Throwable $e) { echo json_encode(['success' => false, 'error' => 'Diagnostics failed: ' . $e->getMessage()]); } break; // ---------- NOTIFICATIONS (VIEW) ---------- case 'get_notifications': requireLoginJSON(); $userId = (int)($_SESSION['user_id'] ?? 0); $stmt = $db->prepare("SELECT n.*, s.name AS server_name, s.ip AS server_ip, s.port AS server_port FROM notifications n LEFT JOIN servers s ON s.id = n.server_id WHERE n.user_id = ? ORDER BY n.id DESC LIMIT 100"); $stmt->bindValue(1, $userId, PDO::PARAM_INT); $stmt->execute(); $rows = []; while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $rows[] = $row; } echo json_encode(['success' => true, 'data' => $rows]); break; // ---------- SERVER HEALTH CHECK & ALERTS (CRON) ---------- case 'check_servers': global $config; $token = $_GET['token'] ?? ''; if ($token !== $config['health_token']) { // allow Super Admin to trigger manually from UI if (!isSuperAdmin()) { echo json_encode(['success' => false, 'error' => 'Invalid token or not authorized']); break; } } $res = $db->query("SELECT * FROM servers"); $total = 0; $changed = 0; $alerts = 0; while ($srv = $res->fetch(PDO::FETCH_ASSOC)) { $total++; $id = (int)$srv['id']; $ip = $srv['ip']; $port = (int)$srv['port']; $old = $srv['status']; $probe = queryServerProbe((string)$ip, (int)$port, 2, (string)($srv['game_type'] ?? 'CS 1.6')); $online = !empty($probe['online']); $newStatus = $online ? 'online' : 'offline'; // Always persist last checked time, even if status did not change $stmtUp = $db->prepare("UPDATE servers SET status = ?, status_checked_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmtUp->bindValue(1, $newStatus, PDO::PARAM_STR); $stmtUp->bindValue(2, $id, PDO::PARAM_INT); $stmtUp->execute(); if ($newStatus !== $old) { $changed++; logServerEvent($db, $id, 'INFO', 'STATUS', "Background health check: {$old} -> {$newStatus}"); if ($newStatus === 'offline') { $alerts++; $subject = "[HLDSWatch] Server OFFLINE: {$srv['name']} ({$ip}:{$port})"; $body = "Server appears to be OFFLINE.\n\n" . "Name: {$srv['name']}\n" . "IP: {$ip}\n" . "Port: {$port}\n" . "Previous status: {$old}\n" . "Current status: {$newStatus}\n" . "Time: " . date('Y-m-d H:i:s') . "\n\n" . "This alert was generated by LongHorn CStrike Watch."; createNotification($db, $id, 'server_offline', $subject, $body); } } } echo json_encode([ 'success' => true, 'total' => $total, 'changed' => $changed, 'alerts' => $alerts, 'time' => date('Y-m-d H:i:s') ]); break; default: echo json_encode(['success' => false, 'error' => 'Unknown action']); } } catch (Throwable $e) { echo json_encode(['success' => false, 'error' => 'Server error: ' . $e->getMessage()]); } $db = null; exit; } // ========================= HTML UI ========================= $isLoggedIn = isLoggedIn(); $username = $_SESSION['username'] ?? 'Guest'; $role = $_SESSION['role'] ?? 'Guest'; $turnstileEnabled = isTurnstileEnabled(); ?> <?= htmlspecialchars($config['app_name']) ?>

Professional GoldSrc server watch and RCON control panel

GoldSrc v1.0 Server Watch Panel.
User:
Total servers
0
Online servers
0
RCON status
Ready
Total users
0

Server overview

OFFLINE
Hostname -
Address -
Map -
Players
0 / 0
Server ping
-
Engine
-
Countries
-
No country data
Map preview
Map artwork No map loaded

Online players

Name Score Time
Select a server to see players.

Quick console

Uses selected server from "Server overview"
> Select a server and send a command.

Recent activity

No log entries yet.