.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.
Ensure XAMPP/MariaDB is running.
Check credentials in gpt.html (User: {$config['db_user']}).
Error: " . htmlspecialchars($e->getMessage()) . "
");
}
$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']) ?>
= htmlspecialchars($config['app_name']) ?>
Professional GoldSrc server watch and RCON control panel
= htmlspecialchars($config['app_name']) ?>
GoldSrc v1.0 Server Watch Panel.
User:
= htmlspecialchars($username) ?>
= htmlspecialchars($role) ?>
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 artworkNo 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.
Servers
No servers loaded yet.
Add Server
Required for RCON commands, players, and bans.
Used to remove unbanned IDs directly from `banned.cfg`.
RCON Console
> Hybrid GoldSrc RCON ready. Select a server and execute commands.
Quick commands
Recent
commands
No command history.
Global server control for Players, Ban Management, and Live Monitoring.
Player management
Uses global server control
Selected server is shared for Players, Ban Management, and Live Monitoring.
Player
Steam/NonSteam ID
IP
Score
Time
Select a server to view players.
Ban management
0 active bans
Target
Reason
Server
Expires
Action
No bans found.
Uses selected server from Player management
Live monitoring
Start/Stop live from top-right global control
Live diagnosticsReceiver: -Flow: -
Status
OFFLINE
Players
0 / 0
Map
-
Ping
-
Address
-
Engine
-
Updated
-
HLDS Console Logs
> Live monitor stopped.
Panel / RCON / Ban Events
> Waiting for events...
Server logs
Time
Level
Category
Message
Select a server to view logs.
Notifications
Time
Server
Type
Subject
No notifications yet.
Users
ID
Username
Role
Servers
Email
Last login
Created
Actions
No users loaded.
Add user
Panel activity log
Time
User
Action
Details
No logs yet.
Account Center
Security and profile access for this control panel.
Username= htmlspecialchars($username) ?>
Role= htmlspecialchars($role) ?>
Use a unique password with at least 12 characters. Avoid reusing server RCON or email
passwords.
Password Security
Update your panel password to protect server access and admin actions.
Edit Server Profile
Keep this updated to use RCON actions without errors.
Optional: remove entries from `banned.cfg` on unban.
Diagnose Server
Server
OFFLINE
Host
-
Port
-
Map
-
Players
-
Ping
-
Response Packet
-
Likes Total
0
Likes Users
0
Likes Visitors
0
Probe Reason
-
Player Moderation
Name-
IdentityUnknown
Score0
Time00:00
SteamID/NonSteam ID and IP are fetched from live metadata when available (no RCON required). GoldSrc public player query returns only name/score/time, so some servers may not expose ID/IP without plugin or status source.