From c29162ddb94fadcd8c34b73b6d8fd5f77885778a Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Mon, 20 Apr 2026 19:52:33 +0300 Subject: [PATCH] feat: add support for AWG2 QR code generation and vpn:// URL configuration --- inc/InstallProtocolManager.php | 98 +++++++++++++++++++++++++++++++--- inc/QrUtil.php | 55 +++++++++++++++++-- inc/VpnClient.php | 37 +++++++++++-- public/index.php | 28 +++++++++- templates/clients/view.twig | 20 ++++++- 5 files changed, 221 insertions(+), 17 deletions(-) diff --git a/inc/InstallProtocolManager.php b/inc/InstallProtocolManager.php index aef481e..5cd8388 100644 --- a/inc/InstallProtocolManager.php +++ b/inc/InstallProtocolManager.php @@ -586,6 +586,8 @@ class InstallProtocolManager } $restored = 0; $pid = self::resolveProtocolId($protocol); + $needsServerConfigUpdate = false; + $keyUpdates = []; // Array of ['old' => $oldPub, 'new' => $newPub] Logger::appendInstall($serverId, "Restore: protocol_id={$pid}, wgConfig empty=" . (trim($wgConfig) === '' ? 'yes' : 'no')); if (trim($wgConfig) !== '') { $pattern = '/\[Peer\][^\[]*?PublicKey\s*=\s*(.+?)\s*[\r\n]+[\s\S]*?AllowedIPs\s*=\s*(.+?)(?:\r?\n|$)/'; @@ -611,29 +613,113 @@ class InstallProtocolManager continue; } $name = $nameByPub[$pub] ?? ('import-' . str_replace('.', '_', $clientIp)); - $ins = $pdo->prepare('INSERT INTO vpn_clients (server_id, user_id, name, client_ip, public_key, private_key, preshared_key, config, protocol_id, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())'); + + // Try to find existing client in database with this public key + $stmt = $pdo->prepare('SELECT id, private_key, config, qr_code FROM vpn_clients WHERE server_id = ? AND public_key = ? LIMIT 1'); + $stmt->execute([$server->getId(), $pub]); + $existingClient = $stmt->fetch(); + + $privateKey = $existingClient['private_key'] ?? ''; + $config = $existingClient['config'] ?? ''; + $qrCode = $existingClient['qr_code'] ?? ''; + $newPublicKey = $pub; // By default use existing public key + + // If client exists in DB with private key, use existing config + // Otherwise generate new key pair and update server config + if (!empty($privateKey) && $existingClient) { + // Use existing keys and config + } else { + // Generate new key pair for this client + $newPrivateKey = trim($server->executeCommand("docker exec -i {$containerArg} /usr/bin/awg genkey 2>/dev/null", true)); + $newPublicKey = trim($server->executeCommand("docker exec -i {$containerArg} sh -c 'echo \"'\"$newPrivateKey\"'\" | /usr/bin/awg pubkey' 2>/dev/null", true)); + + if (!empty($newPrivateKey) && !empty($newPublicKey)) { + $privateKey = $newPrivateKey; + $protocolSlug = $protocol['slug'] ?? ''; + $config = VpnClient::buildClientConfig( + $privateKey, + $clientIp, + $details['server_public_key'] ?? '', + $details['preshared_key'] ?? '', + $serverData['ip_address'] ?? $serverData['hostname'] ?? '', + $details['vpn_port'] ?? 51820, + $details['awg_params'] ?? [], + $protocolSlug + ); + $qrCode = VpnClient::generateQRCode($config, $protocolSlug); + + // Mark that we need to update server config with new public key + $needsServerConfigUpdate = true; + $keyUpdates[] = ['old' => $pub, 'new' => $newPublicKey]; + } + } + + $ins = $pdo->prepare('INSERT INTO vpn_clients (server_id, user_id, name, client_ip, public_key, private_key, preshared_key, config, qr_code, protocol_id, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())'); $ins->execute([ $server->getId(), $serverData['user_id'] ?? null, $name, $clientIp, - $pub, - '', + $newPublicKey, + $privateKey, $details['preshared_key'] ?? null, - '', + $config, + $qrCode, $pid ?: null, - 'active' // Import as active since they already work on the server + 'active' ]); $restored++; } } } + // Update server config if any keys were regenerated + if ($needsServerConfigUpdate && !empty($keyUpdates)) { + Logger::appendInstall($serverId, "Restore: updating server config with " . count($keyUpdates) . " new public keys"); + + // Update awg0.conf - replace old public keys with new ones + $updatedConfig = $wgConfig; + foreach ($keyUpdates as $update) { + // Escape special characters for regex + $oldEscaped = preg_quote($update['old'], '/'); + $updatedConfig = preg_replace( + '/(PublicKey\s*=\s*)' . $oldEscaped . '/', + '${1}' . $update['new'], + $updatedConfig + ); + } + + // Write updated config back to container + $escapedConfig = addslashes($updatedConfig); + $server->executeCommand("docker exec -i {$containerArg} sh -c 'echo \"$escapedConfig\" > {$configDir}/{$configFile}'", true); + + // Update clientsTable with new public keys + $updatedTable = $clientsTable; + if (is_array($updatedTable)) { + foreach ($keyUpdates as $update) { + foreach ($updatedTable as &$entry) { + if (($entry['clientId'] ?? '') === $update['old']) { + $entry['clientId'] = $update['new']; + break; + } + } + } + } + $tableJson = addslashes(json_encode($updatedTable, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + $server->executeCommand("docker exec -i {$containerArg} sh -c 'echo \"$tableJson\" > {$configDir}/clientsTable'", true); + + // Restart WireGuard interface to apply changes + $server->executeCommand("docker exec -i {$containerArg} wg-quick down {$configDir}/{$configFile} 2>/dev/null || true", true); + $server->executeCommand("docker exec -i {$containerArg} wg-quick up {$configDir}/{$configFile} 2>/dev/null || true", true); + + Logger::appendInstall($serverId, "Restore: server config updated, WireGuard restarted"); + } + Logger::appendInstall($serverId, "Restore: finished, restored={$restored}"); return [ 'success' => true, 'mode' => 'restore', - 'message' => 'Существующая конфигурация восстановлена', + 'message' => 'Существующая конфигурация восстановлена' . ($needsServerConfigUpdate ? ' (ключи клиентов обновлены)' : ''), 'vpn_port' => $details['vpn_port'] ?? null, 'clients_count' => $details['clients_count'] ?? null, 'restored_clients' => $restored diff --git a/inc/QrUtil.php b/inc/QrUtil.php index c720ec3..ecfeced 100644 --- a/inc/QrUtil.php +++ b/inc/QrUtil.php @@ -69,7 +69,7 @@ class QrUtil } $uncompressedLen = strlen($json); $compressedLen = strlen($compressed) + 4; // +4 for the uncompressed length field - $version = 0x07C00100; // Amnezia magic version number + $version = 0x07C00100; // Amnezia magic version number for compressed format $header = pack('N3', $version, $compressedLen, $uncompressedLen); return self::urlsafe_b64_encode($header . $compressed); } @@ -87,23 +87,68 @@ class QrUtil return self::encodeOldPayloadFromJson(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); } + /** + * Generate QR code payload in vpn:// URL format for AWG2 + * Returns vpn:// + */ + public static function encodeVpnUrlPayload(string $confText, string $protocolSlug = ''): string + { + if ($protocolSlug === 'awg2') { + return self::encodeVpnUrlConf($confText); + } + + // For other protocols, use old format with vpn:// prefix + $payload = self::buildOldEnvelopeFromConf($confText, $protocolSlug); + $jsonPayload = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + return 'vpn://' . self::encodeOldPayloadFromJson($jsonPayload); + } + /** * Encode config in simple format used by real Amnezia app for AWG2: * Header (8 bytes): version (4) + length (4) + config text * No compression, no JSON wrapper - * - * Note: In real app, bytes 8-11 contain first 4 bytes of config text, - * not a separate field. So header is only 8 bytes. */ private static function encodeSimpleConf(string $confText): string { - $version = 0x07C00100; // Amnezia magic version number + $version = 0x07C00200; // Amnezia magic version number (updated for newer app compatibility) $length = strlen($confText); $header = pack('N2', $version, $length); return self::urlsafe_b64_encode($header . $confText); } + /** + * Encode config in vpn:// URL format used by newer Amnezia app + * Format: vpn:// + * Header: version (4 bytes) + uncompressed length (4 bytes) + * Payload: gzipped config text + */ + private static function encodeVpnUrlConf(string $confText): string + { + // Based on real Amnezia app format - no compression, just header + config + $version = 0x07C00200; // Amnezia magic version number + $length = strlen($confText); + + // Header: version (4 bytes big-endian) + length (4 bytes big-endian) + $header = pack('N2', $version, $length); + $payload = $header . $confText; + + // Return just the base64url encoded payload (vpn:// prefix added by caller if needed) + return self::urlsafe_b64_encode($payload); + } + + /** + * Encode config in old compressed format (for second QR code) + * Uses JSON envelope with gzip compression + * Header: version (4) + compressedLen+4 (4) + uncompressedLen (4) + compressed data + */ + public static function encodeCompressedConf(string $confText, string $protocolSlug = ''): string + { + $payload = self::buildOldEnvelopeFromConf($confText, $protocolSlug); + $jsonPayload = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + return self::encodeOldPayloadFromJson($jsonPayload); + } + private static function resolveServerDescription(?string $endpointHost): string { $desc = (string) ($endpointHost ?? ''); diff --git a/inc/VpnClient.php b/inc/VpnClient.php index 7d8f416..d0375ce 100644 --- a/inc/VpnClient.php +++ b/inc/VpnClient.php @@ -997,7 +997,7 @@ class VpnClient /** * Build client configuration file */ - private static function buildClientConfig( + public static function buildClientConfig( string $privateKey, string $clientIP, string $serverPublicKey, @@ -1066,7 +1066,7 @@ class VpnClient $config .= "PresharedKey = {$presharedKey}\n"; $config .= "Endpoint = {$serverHost}:{$serverPort}\n"; $config .= "AllowedIPs = 0.0.0.0/0, ::/0\n"; - $config .= "PersistentKeepalive = 25\n"; + $config .= "PersistentKeepalive = 25\n\n"; return $config; } @@ -1209,7 +1209,7 @@ class VpnClient * Generate QR code for configuration using Amnezia format * Uses working QrUtil from /Users/oleg/Documents/amnezia */ - private static function generateQRCode(string $config, string $protocolSlug = ''): string + public static function generateQRCode(string $config, string $protocolSlug = ''): string { require_once __DIR__ . '/QrUtil.php'; @@ -1255,6 +1255,37 @@ class VpnClient } } + /** + * Generate second QR code in vpn:// URL format + * Used for newer Amnezia app versions that support vpn:// scheme + */ + public static function generateQRCodeVpnUrl(string $config, string $protocolSlug = ''): string + { + require_once __DIR__ . '/QrUtil.php'; + + try { + // For X-Ray VLESS, use same format as regular QR + if (strpos($config, 'vless://') === 0) { + return self::generateQRCode($config, $protocolSlug); + } + + // For AWG2, use compressed format (second QR code format) + if ($protocolSlug === 'awg2') { + $payloadCompressed = QrUtil::encodeCompressedConf($config, $protocolSlug); + $dataUri = QrUtil::pngBase64($payloadCompressed); + return $dataUri; + } + + // For other WireGuard/AWG, use vpn:// URL format + $payloadVpn = QrUtil::encodeVpnUrlPayload($config, $protocolSlug); + $dataUri = QrUtil::pngBase64($payloadVpn); + return $dataUri; + } catch (Throwable $e) { + error_log('Failed to generate vpn:// QR code: ' . $e->getMessage()); + return ''; + } + } + /** * Get all clients for a server */ diff --git a/public/index.php b/public/index.php index 1faeb88..ff9578d 100644 --- a/public/index.php +++ b/public/index.php @@ -1134,6 +1134,9 @@ Router::get('/clients/{id}', function ($params) { $server = new VpnServer((int) $clientData['server_id']); $serverData = $server->getData(); $protocolOutput = ''; + $qrCodeVpnUrl = ''; + $vpnUrlConfig = ''; + $isAwg2 = false; try { $pdo = DB::conn(); $protocol = null; @@ -1149,22 +1152,43 @@ Router::get('/clients/{id}', function ($params) { if ($protocol) { $clientData['show_text_content'] = !empty($protocol['show_text_content']); + $protocolSlug = $protocol['slug'] ?? ''; + $isAwg2 = ($protocolSlug === 'awg2'); } if ($protocol && ($protocol['output_template'] ?? '') !== '') { $slug = $protocol['slug'] ?? ''; $isWireguard = in_array($slug, ['amnezia-wg-advanced', 'wireguard-standard', 'amnezia-wg', 'awg2'], true); if ($isWireguard) { - // For WG, we don’t render protocol_output; config is downloadable + // For WG, we don't render protocol_output; config is downloadable $protocolOutput = ''; } else { // For non-WG protocols, reuse stored generated output in config $protocolOutput = $clientData['config'] ?? ''; } } + + // Generate second QR code and vpn:// config for AWG2 + if ($isAwg2 && !empty($clientData['config'])) { + try { + $qrCodeVpnUrl = VpnClient::generateQRCodeVpnUrl($clientData['config'], 'awg2'); + + // Generate vpn:// URL string (add vpn:// prefix) + require_once __DIR__ . '/../inc/QrUtil.php'; + $vpnUrlConfig = 'vpn://' . QrUtil::encodeVpnUrlPayload($clientData['config'], 'awg2'); + } catch (Exception $e) { + // Ignore errors, just don't show the second QR + } + } } catch (Exception $e) { $protocolOutput = ''; } - View::render('clients/view.twig', ['client' => $clientData, 'protocol_output' => $protocolOutput]); + View::render('clients/view.twig', [ + 'client' => $clientData, + 'protocol_output' => $protocolOutput, + 'qr_code_vpn_url' => $qrCodeVpnUrl, + 'vpn_url_config' => $vpnUrlConfig, + 'is_awg2' => $isAwg2 + ]); } catch (Exception $e) { http_response_code(404); echo 'Client not found'; diff --git a/templates/clients/view.twig b/templates/clients/view.twig index 3f1edc9..a46a2a6 100644 --- a/templates/clients/view.twig +++ b/templates/clients/view.twig @@ -146,12 +146,30 @@ {% if client.qr_code %}
-

QR Code

+

QR Code (Simple)

QR Code

Scan with Amnezia VPN app

{% endif %} + {% if is_awg2 and qr_code_vpn_url %} +
+

QR Code (vpn:// URL)

+ QR Code VPN URL +

Scan with Amnezia VPN app

+
+ {% endif %} + + {% if is_awg2 and vpn_url_config %} +
+

VPN URL Configuration

+
+ {{ vpn_url_config }} +
+

Copy this vpn:// URL to import configuration

+
+ {% endif %} + {% if protocol_output and client.show_text_content %}

{{ t('clients.connection_instructions') }}