feat: Implement single IP enforcement for Xray VLESS protocol with online tracking
This commit is contained in:
@@ -1222,15 +1222,49 @@ class InstallProtocolManager
|
|||||||
$config['policy']['levels']['0'] = [];
|
$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']['handshake'] = 4;
|
||||||
$config['policy']['levels']['0']['connIdle'] = 300;
|
$config['policy']['levels']['0']['connIdle'] = 300;
|
||||||
$config['policy']['levels']['0']['uplinkOnly'] = 2;
|
$config['policy']['levels']['0']['uplinkOnly'] = 2;
|
||||||
$config['policy']['levels']['0']['downlinkOnly'] = 5;
|
$config['policy']['levels']['0']['downlinkOnly'] = 5;
|
||||||
$config['policy']['levels']['0']['statsUserUplink'] = true;
|
$config['policy']['levels']['0']['statsUserUplink'] = true;
|
||||||
$config['policy']['levels']['0']['statsUserDownlink'] = 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']['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
|
// 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'})) {
|
if (!isset($config['policy']['levels']->{'0'})) {
|
||||||
$config['policy']['levels']->{'0'} = new stdClass();
|
$config['policy']['levels']->{'0'} = new stdClass();
|
||||||
}
|
}
|
||||||
@@ -1320,14 +1354,14 @@ class InstallProtocolManager
|
|||||||
$config['policy']['levels']->{'0'} = $level0;
|
$config['policy']['levels']->{'0'} = $level0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set restriction parameters
|
// Set restriction parameters (statsUserOnline enables connection counting)
|
||||||
$level0->limitIp = 1;
|
|
||||||
$level0->handshake = 4;
|
$level0->handshake = 4;
|
||||||
$level0->connIdle = 300;
|
$level0->connIdle = 300;
|
||||||
$level0->uplinkOnly = 2;
|
$level0->uplinkOnly = 2;
|
||||||
$level0->downlinkOnly = 5;
|
$level0->downlinkOnly = 5;
|
||||||
$level0->statsUserUplink = true;
|
$level0->statsUserUplink = true;
|
||||||
$level0->statsUserDownlink = true;
|
$level0->statsUserDownlink = true;
|
||||||
|
$level0->statsUserOnline = true; // Enable online tracking for enforcement
|
||||||
$level0->bufferSize = 4;
|
$level0->bufferSize = 4;
|
||||||
// It's an assoc array, duplicate it to stdClass to ensure object encoding
|
// It's an assoc array, duplicate it to stdClass to ensure object encoding
|
||||||
$config['policy']['levels'] = (object) $config['policy']['levels'];
|
$config['policy']['levels'] = (object) $config['policy']['levels'];
|
||||||
|
|||||||
+109
-3
@@ -34,9 +34,12 @@ class ServerMonitoring
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use --reset=true to get delta since last check and prevent counter reset on restart
|
// 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.
|
$xrayContainer = $this->getXrayContainerName();
|
||||||
// Assuming this is intentional or will be corrected by the user later.
|
if (!$xrayContainer) {
|
||||||
$cmd = "docker exec amnezia-xray xray api statsquery --pattern 'user>>>' --reset=true --server=127.0.0.1:10085";
|
$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);
|
$json = $this->execSSH($cmd);
|
||||||
|
|
||||||
if (!$json || trim($json) === '') {
|
if (!$json || trim($json) === '') {
|
||||||
@@ -109,6 +112,15 @@ class ServerMonitoring
|
|||||||
*/
|
*/
|
||||||
public function collectClientMetrics(): array
|
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
|
// Pre-fetch X-ray stats
|
||||||
if (!$this->fetchXrayStats()) {
|
if (!$this->fetchXrayStats()) {
|
||||||
error_log("Failed to fetch X-ray stats, preventing DB overwrite");
|
error_log("Failed to fetch X-ray stats, preventing DB overwrite");
|
||||||
@@ -530,4 +542,98 @@ class ServerMonitoring
|
|||||||
|
|
||||||
return $output ?: null;
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <<EOJSON
|
||||||
|
{
|
||||||
|
"log": { "loglevel": "warning" },
|
||||||
|
"stats": {},
|
||||||
|
"api": {
|
||||||
|
"tag": "api",
|
||||||
|
"services": [ "StatsService", "RoutingService" ]
|
||||||
|
},
|
||||||
|
"policy": {
|
||||||
|
"levels": {
|
||||||
|
"0": {
|
||||||
|
"statsUserUplink": true,
|
||||||
|
"statsUserDownlink": true,
|
||||||
|
"statsUserOnline": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"system": {
|
||||||
|
"statsInboundUplink": true,
|
||||||
|
"statsInboundDownlink": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inbounds": [{
|
||||||
|
"listen": "0.0.0.0",
|
||||||
|
"port": ${XRAY_PORT},
|
||||||
|
"protocol": "vless",
|
||||||
|
"tag": "vless-in",
|
||||||
|
"settings": {
|
||||||
|
"clients": [{ "id": "${CLIENT_ID}", "flow": "xtls-rprx-vision", "email": "${CLIENT_ID}", "level": 0 }],
|
||||||
|
"decryption": "none"
|
||||||
|
},
|
||||||
|
"streamSettings": {
|
||||||
|
"network": "tcp",
|
||||||
|
"security": "reality",
|
||||||
|
"realitySettings": {
|
||||||
|
"show": false,
|
||||||
|
"dest": "${SERVER_NAME}:443",
|
||||||
|
"xver": 0,
|
||||||
|
"serverNames": ["${SERVER_NAME}"],
|
||||||
|
"privateKey": "${PRIVATE_KEY}",
|
||||||
|
"shortIds": ["${SHORT_ID}"],
|
||||||
|
"fingerprint": "${FINGERPRINT}",
|
||||||
|
"spiderX": "${SPIDER_X}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"listen": "127.0.0.1",
|
||||||
|
"port": 10085,
|
||||||
|
"protocol": "dokodemo-door",
|
||||||
|
"tag": "api",
|
||||||
|
"settings": {
|
||||||
|
"address": "127.0.0.1"
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
"outbounds": [
|
||||||
|
{ "protocol": "freedom", "tag": "direct" },
|
||||||
|
{ "protocol": "blackhole", "tag": "blocked" }
|
||||||
|
],
|
||||||
|
"routing": {
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"inboundTag": [ "api" ],
|
||||||
|
"outboundTag": "api",
|
||||||
|
"type": "field"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOJSON
|
||||||
|
|
||||||
|
docker run -d --name "$CONTAINER_NAME" --restart always -p "${XRAY_PORT}:${XRAY_PORT}" -v /opt/amnezia/xray:/opt/amnezia/xray teddysun/xray xray run -c /opt/amnezia/xray/server.json
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
echo "XrayPort: ${XRAY_PORT}"
|
||||||
|
echo "Port: ${XRAY_PORT}"
|
||||||
|
echo "ClientID: ${CLIENT_ID}"
|
||||||
|
echo "PublicKey: ${PUBLIC_KEY}"
|
||||||
|
echo "PrivateKey: ${PRIVATE_KEY}"
|
||||||
|
echo "ShortID: ${SHORT_ID}"
|
||||||
|
echo "ServerName: ${SERVER_NAME}"
|
||||||
|
echo "ContainerName: ${CONTAINER_NAME}"
|
||||||
|
'
|
||||||
|
WHERE slug = 'xray-vless';
|
||||||
Reference in New Issue
Block a user