feat: Add AIVPN support and enhance client statistics tracking

- Introduced AIVPN server detection and statistics fetching in ServerMonitoring.
- Implemented AIVPN client statistics handling in VpnClient, including raw and offset counters for traffic.
- Enhanced AWG parameters to include S3 and S4.
- Updated database schema to accommodate new AIVPN statistics fields.
- Added a script for remote reset and reinstallation of protocols.
- Improved client view template to ensure proper display of connection instructions.
- Added translations for connection instructions in multiple languages.
- Ensured host-level NAT for AWG subnet in VpnServer.
This commit is contained in:
infosave2007
2026-04-04 15:27:40 +03:00
parent 0bc23e11db
commit 1c4b080ee5
8 changed files with 741 additions and 29 deletions
+226 -6
View File
@@ -16,6 +16,8 @@ class ServerMonitoring
private array $serverData;
private array $xrayStatsCache = [];
private bool $xrayStatsFetched = false;
private array $aivpnStatsCache = ['by_name' => [], 'by_id' => [], 'by_ip' => []];
private bool $aivpnStatsFetched = false;
/**
* Fetch all X-ray user stats in one batch
@@ -113,10 +115,20 @@ class ServerMonitoring
}
}
// Pre-fetch X-ray stats
if (!$this->fetchXrayStats()) {
error_log("Failed to fetch X-ray stats, preventing DB overwrite");
return []; // Abort if stats collection failed
// Pre-fetch X-ray stats only for Xray servers.
// Otherwise we block AWG/WireGuard stats collection with irrelevant Xray errors.
if ($this->isXrayServer()) {
if (!$this->fetchXrayStats()) {
error_log("Failed to fetch X-ray stats, preventing DB overwrite");
return []; // Abort only for Xray servers
}
}
// For AIVPN we best-effort fetch client stats once per cycle.
if ($this->isAivpnServer()) {
if (!$this->fetchAivpnStats()) {
error_log("Failed to fetch AIVPN stats, using DB fallback values");
}
}
$clients = VpnClient::listByServer($this->serverData['id']);
@@ -271,12 +283,16 @@ class ServerMonitoring
// Determine if this client is XRay based on protocol_id
$isXrayClient = false;
$protocolSlug = '';
if (!empty($client['protocol_id'])) {
$stmtProto = $db->prepare('SELECT slug FROM protocols WHERE id = ?');
$stmtProto->execute([$client['protocol_id']]);
$protoData = $stmtProto->fetch();
if ($protoData && stripos($protoData['slug'], 'xray') !== false) {
$isXrayClient = true;
if ($protoData) {
$protocolSlug = (string) ($protoData['slug'] ?? '');
if (stripos($protocolSlug, 'xray') !== false) {
$isXrayClient = true;
}
}
}
@@ -285,6 +301,11 @@ class ServerMonitoring
$isXrayClient = true;
}
$isAivpnClient = (
stripos($protocolSlug, 'aivpn') !== false ||
(!empty($client['config']) && strpos((string) $client['config'], 'aivpn://') === 0)
);
if ($isXrayClient) {
// Retrieve DELTA from cache
if ($this->xrayStatsFetched) {
@@ -330,6 +351,70 @@ class ServerMonitoring
} else {
// WireGuard Logic - get bytes and handshake timestamp
$publicKey = $client['public_key'];
$isWireguardClient = (
stripos($protocolSlug, 'awg') !== false ||
stripos($protocolSlug, 'wireguard') !== false
);
if ($isAivpnClient) {
$aivpn = $this->getAivpnClientStats($client);
if (is_array($aivpn)) {
$stmt = $db->prepare("SELECT bytes_sent, bytes_received, aivpn_raw_bytes_in, aivpn_raw_bytes_out, aivpn_offset_bytes_in, aivpn_offset_bytes_out FROM vpn_clients WHERE id = ?");
$stmt->execute([$client['id']]);
$currentDbStats = $stmt->fetch(PDO::FETCH_ASSOC);
$prevSent = (int) ($currentDbStats['bytes_sent'] ?? 0);
$prevReceived = (int) ($currentDbStats['bytes_received'] ?? 0);
$rawInPrev = (int) ($currentDbStats['aivpn_raw_bytes_in'] ?? 0);
$rawOutPrev = (int) ($currentDbStats['aivpn_raw_bytes_out'] ?? 0);
$offsetIn = (int) ($currentDbStats['aivpn_offset_bytes_in'] ?? 0);
$offsetOut = (int) ($currentDbStats['aivpn_offset_bytes_out'] ?? 0);
$rawInNow = (int) ($aivpn['bytes_in'] ?? 0);
$rawOutNow = (int) ($aivpn['bytes_out'] ?? 0);
// Detect counter rollover/reset in AIVPN source and preserve cumulative totals.
if ($rawInNow < $rawInPrev) {
$offsetIn = max($offsetIn + $rawInPrev, $prevSent);
}
if ($rawOutNow < $rawOutPrev) {
$offsetOut = max($offsetOut + $rawOutPrev, $prevReceived);
}
$candidateSent = $offsetIn + $rawInNow;
$candidateReceived = $offsetOut + $rawOutNow;
// AIVPN stores per-client counters as bytes_in/bytes_out.
// Map to panel semantics: sent=client upload, received=client download.
$bytesSent = max($prevSent, $candidateSent);
$bytesReceived = max($prevReceived, $candidateReceived);
$stmtAivpn = $db->prepare("UPDATE vpn_clients SET aivpn_raw_bytes_in = ?, aivpn_raw_bytes_out = ?, aivpn_offset_bytes_in = ?, aivpn_offset_bytes_out = ? WHERE id = ?");
$stmtAivpn->execute([$rawInNow, $rawOutNow, $offsetIn, $offsetOut, $client['id']]);
$lastHandshake = $aivpn['last_handshake'] ?? null;
if (is_string($lastHandshake) && $lastHandshake !== '') {
$ts = strtotime($lastHandshake);
if ($ts) {
$stmtHs = $db->prepare("UPDATE vpn_clients SET last_handshake = ? WHERE id = ?");
$stmtHs->execute([date('Y-m-d H:i:s', $ts), $client['id']]);
}
}
} else {
$stmt = $db->prepare("SELECT bytes_sent, bytes_received FROM vpn_clients WHERE id = ?");
$stmt->execute([$client['id']]);
$currentDbStats = $stmt->fetch(PDO::FETCH_ASSOC);
$bytesSent = (int) ($currentDbStats['bytes_sent'] ?? 0);
$bytesReceived = (int) ($currentDbStats['bytes_received'] ?? 0);
}
} elseif (empty($publicKey) || !$isWireguardClient) {
// Non-WireGuard protocols without dedicated collectors keep DB values.
$stmt = $db->prepare("SELECT bytes_sent, bytes_received FROM vpn_clients WHERE id = ?");
$stmt->execute([$client['id']]);
$currentDbStats = $stmt->fetch(PDO::FETCH_ASSOC);
$bytesSent = (int) ($currentDbStats['bytes_sent'] ?? 0);
$bytesReceived = (int) ($currentDbStats['bytes_received'] ?? 0);
} else {
// wg show all dump format (tab-separated):
// $1=interface $2=pubkey $3=psk $4=endpoint $5=allowed-ips $6=latest-handshake $7=rx-bytes $8=tx-bytes $9=keepalive
// rx-bytes = bytes received by server = client's upload (bytes_sent)
@@ -352,6 +437,7 @@ class ServerMonitoring
}
}
}
}
}
// If we couldn't get stats (and they are 0), check if we have previous stats to avoid zeroing out if API fails?
@@ -590,6 +676,140 @@ class ServerMonitoring
return $this->getXrayContainerName() !== null;
}
/**
* Check if this server is an AIVPN server.
*/
private function isAivpnServer(): bool
{
$containerName = (string) ($this->serverData['container_name'] ?? '');
$protocol = (string) ($this->serverData['install_protocol'] ?? '');
return stripos($containerName, 'aivpn') !== false || stripos($protocol, 'aivpn') !== false;
}
/**
* Fetch AIVPN clients and their stats once per collection cycle.
*/
private function fetchAivpnStats(): bool
{
if ($this->aivpnStatsFetched) {
return true;
}
$this->aivpnStatsFetched = true;
$this->aivpnStatsCache = ['by_name' => [], 'by_id' => [], 'by_ip' => []];
$containerName = trim((string) ($this->serverData['container_name'] ?? ''));
if ($containerName === '' || stripos($containerName, 'aivpn') === false) {
$containerName = 'aivpn-server';
}
$jsonRaw = $this->execSSH(
'docker exec -i ' . escapeshellarg($containerName) . ' cat /etc/aivpn/clients.json 2>/dev/null'
);
if (!$jsonRaw || trim($jsonRaw) === '') {
return false;
}
$data = json_decode($jsonRaw, true);
if (!is_array($data) || !isset($data['clients']) || !is_array($data['clients'])) {
return false;
}
foreach ($data['clients'] as $entry) {
if (!is_array($entry)) {
continue;
}
$stats = is_array($entry['stats'] ?? null) ? $entry['stats'] : [];
$record = [
'id' => (string) ($entry['id'] ?? ''),
'name' => (string) ($entry['name'] ?? ''),
'vpn_ip' => (string) ($entry['vpn_ip'] ?? ''),
'bytes_in' => (int) ($stats['bytes_in'] ?? 0),
'bytes_out' => (int) ($stats['bytes_out'] ?? 0),
'last_handshake' => isset($stats['last_handshake']) ? (string) $stats['last_handshake'] : null,
];
if ($record['name'] !== '') {
$this->aivpnStatsCache['by_name'][strtolower($record['name'])] = $record;
}
if ($record['id'] !== '') {
$this->aivpnStatsCache['by_id'][$record['id']] = $record;
}
if ($record['vpn_ip'] !== '') {
$this->aivpnStatsCache['by_ip'][$record['vpn_ip']] = $record;
}
}
return true;
}
private function getAivpnClientStats(array $client): ?array
{
if (!$this->aivpnStatsFetched && !$this->fetchAivpnStats()) {
return null;
}
$name = trim((string) ($client['name'] ?? ''));
if ($name !== '') {
$nameKey = strtolower($name);
if (isset($this->aivpnStatsCache['by_name'][$nameKey])) {
return $this->aivpnStatsCache['by_name'][$nameKey];
}
}
$clientIp = trim((string) ($client['client_ip'] ?? ''));
if ($clientIp !== '' && isset($this->aivpnStatsCache['by_ip'][$clientIp])) {
return $this->aivpnStatsCache['by_ip'][$clientIp];
}
$cfgIp = $this->extractAivpnIpFromConfig((string) ($client['config'] ?? ''));
if ($cfgIp !== '' && isset($this->aivpnStatsCache['by_ip'][$cfgIp])) {
return $this->aivpnStatsCache['by_ip'][$cfgIp];
}
return null;
}
private function extractAivpnIpFromConfig(string $config): string
{
if (stripos($config, 'aivpn://') !== 0) {
return '';
}
$payload = substr($config, strlen('aivpn://'));
if ($payload === '') {
return '';
}
$decoded = base64_decode(strtr($payload, '-_', '+/'), true);
if ($decoded === false) {
$padLen = strlen($payload) % 4;
$normalized = $payload;
if ($padLen > 0) {
$normalized .= str_repeat('=', 4 - $padLen);
}
$decoded = base64_decode(strtr($normalized, '-_', '+/'), true);
}
if ($decoded === false) {
return '';
}
$data = json_decode($decoded, true);
if (!is_array($data)) {
return '';
}
$ip = trim((string) ($data['i'] ?? ''));
if ($ip !== '' && preg_match('/^\d{1,3}(?:\.\d{1,3}){3}$/', $ip)) {
return $ip;
}
return '';
}
/**
* Enforce single IP per user for Xray connections
* If a user is connected from multiple IPs, block all but the first one