diff --git a/inc/InstallProtocolManager.php b/inc/InstallProtocolManager.php index 3de0488..09c6e4c 100644 --- a/inc/InstallProtocolManager.php +++ b/inc/InstallProtocolManager.php @@ -1222,15 +1222,49 @@ class InstallProtocolManager $config['policy']['levels']['0'] = []; } - // Enforce limitIp: 1 for user level 0 + // Enforce stats and online tracking for user level 0 $config['policy']['levels']['0']['handshake'] = 4; $config['policy']['levels']['0']['connIdle'] = 300; $config['policy']['levels']['0']['uplinkOnly'] = 2; $config['policy']['levels']['0']['downlinkOnly'] = 5; $config['policy']['levels']['0']['statsUserUplink'] = true; $config['policy']['levels']['0']['statsUserDownlink'] = true; + $config['policy']['levels']['0']['statsUserOnline'] = true; // Enable online tracking $config['policy']['levels']['0']['bufferSize'] = 4; - $config['policy']['levels']['0']['limitIp'] = 1; // Enforce 1 IP per user + + // Ensure API services include StatsService and RoutingService + if (!isset($config['api'])) { + $config['api'] = ['tag' => 'api', 'services' => []]; + } + if (!isset($config['api']['services'])) { + $config['api']['services'] = []; + } + if (!in_array('StatsService', $config['api']['services'])) { + $config['api']['services'][] = 'StatsService'; + } + if (!in_array('RoutingService', $config['api']['services'])) { + $config['api']['services'][] = 'RoutingService'; + } + + // Ensure blocked outbound exists for IP blocking + if (!isset($config['outbounds'])) { + $config['outbounds'] = []; + } + $hasBlocked = false; + foreach ($config['outbounds'] as $ob) { + if (($ob['tag'] ?? '') === 'blocked') { + $hasBlocked = true; + break; + } + } + if (!$hasBlocked) { + $config['outbounds'][] = ['protocol' => 'blackhole', 'tag' => 'blocked']; + } + + // Ensure main inbound has a tag for routing rules + if (!isset($config['inbounds'][0]['tag'])) { + $config['inbounds'][0]['tag'] = 'vless-in'; + } // Assuming VLESS structure: inbounds[0] -> settings -> clients @@ -1309,7 +1343,7 @@ class InstallProtocolManager } } - // Enforce Level 0 Policy with limitIp + // Enforce Level 0 Policy with online tracking if (!isset($config['policy']['levels']->{'0'})) { $config['policy']['levels']->{'0'} = new stdClass(); } @@ -1320,14 +1354,14 @@ class InstallProtocolManager $config['policy']['levels']->{'0'} = $level0; } - // Set restriction parameters - $level0->limitIp = 1; + // Set restriction parameters (statsUserOnline enables connection counting) $level0->handshake = 4; $level0->connIdle = 300; $level0->uplinkOnly = 2; $level0->downlinkOnly = 5; $level0->statsUserUplink = true; $level0->statsUserDownlink = true; + $level0->statsUserOnline = true; // Enable online tracking for enforcement $level0->bufferSize = 4; // It's an assoc array, duplicate it to stdClass to ensure object encoding $config['policy']['levels'] = (object) $config['policy']['levels']; diff --git a/inc/ServerMonitoring.php b/inc/ServerMonitoring.php index b30a644..bed5000 100644 --- a/inc/ServerMonitoring.php +++ b/inc/ServerMonitoring.php @@ -34,9 +34,12 @@ class ServerMonitoring } // Use --reset=true to get delta since last check and prevent counter reset on restart - // Note: The container name is hardcoded to 'amnezia-xray' in the provided snippet. - // Assuming this is intentional or will be corrected by the user later. - $cmd = "docker exec amnezia-xray xray api statsquery --pattern 'user>>>' --reset=true --server=127.0.0.1:10085"; + $xrayContainer = $this->getXrayContainerName(); + if (!$xrayContainer) { + $this->xrayStatsFetched = true; + return true; // Not an Xray server + } + $cmd = "docker exec $xrayContainer xray api statsquery --pattern 'user>>>' --reset=true --server=127.0.0.1:10085"; $json = $this->execSSH($cmd); if (!$json || trim($json) === '') { @@ -109,6 +112,15 @@ class ServerMonitoring */ public function collectClientMetrics(): array { + // Enforce single IP per user for Xray before collecting stats + if ($this->isXrayServer()) { + try { + $this->enforceXraySingleIpPerUser(); + } catch (Throwable $e) { + error_log("Xray enforcement error: " . $e->getMessage()); + } + } + // Pre-fetch X-ray stats if (!$this->fetchXrayStats()) { error_log("Failed to fetch X-ray stats, preventing DB overwrite"); @@ -530,4 +542,98 @@ class ServerMonitoring return $output ?: null; } + + /** + * Get Xray container name for this server + * @return string|null Container name or null if not an Xray server + */ + private function getXrayContainerName(): ?string + { + $containerName = $this->serverData['container_name'] ?? ''; + // Check if this is an Xray server + if (stripos($containerName, 'xray') !== false) { + return $containerName; + } + // Also check slug + $slug = $this->serverData['slug'] ?? ''; + if (stripos($slug, 'xray') !== false || stripos($slug, 'vless') !== false) { + return $containerName ?: 'amnezia-xray'; + } + return null; + } + + /** + * Check if this server is an Xray server + */ + private function isXrayServer(): bool + { + return $this->getXrayContainerName() !== null; + } + + /** + * Enforce single IP per user for Xray connections + * If a user is connected from multiple IPs, block all but the first one + */ + public function enforceXraySingleIpPerUser(): void + { + $xrayContainer = $this->getXrayContainerName(); + if (!$xrayContainer) { + return; // Not an Xray server + } + + // Get all online users + $cmd = "docker exec $xrayContainer xray api statsgetallonlineusers --server=127.0.0.1:10085"; + $result = $this->execSSH($cmd); + if (!$result) { + return; + } + + $data = json_decode($result, true); + if (!isset($data['users']) || !is_array($data['users'])) { + return; + } + + $ipsToBlock = []; + + foreach ($data['users'] as $user) { + $email = $user['email'] ?? null; + if (!$email) { + continue; + } + + // Get IP list for this user + $ipCmd = "docker exec $xrayContainer xray api statsonlineiplist --server=127.0.0.1:10085 --email=" . escapeshellarg($email); + $ipResult = $this->execSSH($ipCmd); + if (!$ipResult) { + continue; + } + + $ipData = json_decode($ipResult, true); + if (!isset($ipData['ips']) || !is_array($ipData['ips'])) { + continue; + } + + // If more than 1 IP, block all but the first (oldest by timestamp) + if (count($ipData['ips']) > 1) { + // Sort by timestamp (value) ascending + asort($ipData['ips']); + $first = true; + foreach ($ipData['ips'] as $ip => $timestamp) { + if ($first) { + $first = false; + continue; // Keep first IP + } + $ipsToBlock[] = $ip; + } + } + } + + // Block collected IPs + if (!empty($ipsToBlock)) { + $ipList = implode(' ', array_unique($ipsToBlock)); + $blockCmd = "docker exec $xrayContainer xray api sib --server=127.0.0.1:10085 -outbound=blocked -inbound=vless-in -reset $ipList"; + $this->execSSH($blockCmd); + error_log("[Xray Enforcement] Blocked IPs: $ipList"); + } + } } diff --git a/migrations/054_xray_single_ip_enforcement.sql b/migrations/054_xray_single_ip_enforcement.sql new file mode 100644 index 0000000..7240ccb --- /dev/null +++ b/migrations/054_xray_single_ip_enforcement.sql @@ -0,0 +1,131 @@ +-- Enable single IP enforcement for XRay VLESS protocol +-- Adds: +-- 1. statsUserOnline for tracking online connections +-- 2. RoutingService for dynamic IP blocking +-- 3. blocked outbound (blackhole) for dropping unwanted traffic +-- 4. vless-in tag on main inbound for targeting rules + +UPDATE protocols SET install_script = '#!/bin/bash +set -eu + +CONTAINER_NAME="${CONTAINER_NAME:-amnezia-xray}" +XRAY_PORT=${SERVER_PORT:-443} + +docker pull teddysun/xray >/dev/null 2>&1 || true + +# Use existing keys if provided, otherwise generate new ones +if [ -z "${PRIVATE_KEY:-}" ]; then + GEN=$(docker run --rm --entrypoint /usr/bin/xray teddysun/xray x25519 2>/dev/null || true) + PRIVATE_KEY=$(printf "%s\\n" "$GEN" | sed -n -E "s/^[Pp]rivate[Kk]ey:[[:space:]]*(.*)$/\\1/p" | tr -d " \\t\\r\\n") + if [ -z "$PRIVATE_KEY" ]; then + PRIVATE_KEY=$(printf "%s\\n" "$GEN" | grep -i "private" | head -1 | sed "s/.*:[[:space:]]*//" | tr -d " \\t\\r\\n") + fi +fi + +# Derive public key from private key +PUBLIC_KEY=$(docker run --rm --entrypoint /usr/bin/xray teddysun/xray x25519 -i "$PRIVATE_KEY" 2>/dev/null | sed -n -E "s/^[Pp]ublic[[:space:]]*[Kk]ey:[[:space:]]*(.*)$/\\1/p" | tr -d " \\t\\r\\n" || true) +if [ -z "$PUBLIC_KEY" ]; then + PUBLIC_KEY=$(docker run --rm --entrypoint /usr/bin/xray teddysun/xray x25519 -i "$PRIVATE_KEY" 2>/dev/null | sed -n -E "s/^[Pp]assword:[[:space:]]*(.*)$/\\1/p" | tr -d " \\t\\r\\n" || true) +fi + +# Use existing short_id or generate new one +if [ -z "${SHORT_ID:-}" ]; then + SHORT_ID=$(od -An -tx1 -N8 /dev/urandom | tr -d " \\n") +fi + +# Use existing client_id or generate new one +if [ -z "${CLIENT_ID:-}" ]; then + CLIENT_ID=$(cat /proc/sys/kernel/random/uuid) +fi + +SERVER_NAME="${SERVER_NAME:-www.googletagmanager.com}" +FINGERPRINT="${FINGERPRINT:-chrome}" +SPIDER_X="${SPIDER_X:-/}" + +docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true +mkdir -p /opt/amnezia/xray + +cat > /opt/amnezia/xray/server.json <