diff --git a/inc/QrUtil.php b/inc/QrUtil.php index ecfeced..5515000 100644 --- a/inc/QrUtil.php +++ b/inc/QrUtil.php @@ -108,7 +108,7 @@ class QrUtil * Header (8 bytes): version (4) + length (4) + config text * No compression, no JSON wrapper */ - private static function encodeSimpleConf(string $confText): string + public static function encodeSimpleConf(string $confText): string { $version = 0x07C00200; // Amnezia magic version number (updated for newer app compatibility) $length = strlen($confText); @@ -119,21 +119,38 @@ class QrUtil /** * 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 + * Format: vpn:// + * + * Structure: + * - 4 bytes: uint32 BE — length of JSON after decompression + * - N bytes: zlib-compressed JSON (level 9, magic 0x78 0xDA) + * - Entire block encoded in Base64url without padding */ - private static function encodeVpnUrlConf(string $confText): string + public static function encodeVpnUrlConf(string $confText, string $protocolSlug = ''): string { - // Based on real Amnezia app format - no compression, just header + config - $version = 0x07C00200; // Amnezia magic version number - $length = strlen($confText); + // Build JSON envelope like the real Amnezia app + $envelope = self::buildOldEnvelopeFromConf($confText, $protocolSlug); + $jsonBytes = json_encode($envelope, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + if ($jsonBytes === false) { + throw new RuntimeException('json_encode failed'); + } - // Header: version (4 bytes big-endian) + length (4 bytes big-endian) - $header = pack('N2', $version, $length); - $payload = $header . $confText; + $jsonBytes = (string) $jsonBytes; + $uncompressedLength = strlen($jsonBytes); - // Return just the base64url encoded payload (vpn:// prefix added by caller if needed) + // Compress with zlib level 9 (produces 0x78 0xDA header) + $compressed = gzcompress($jsonBytes, 9); + if ($compressed === false) { + throw new RuntimeException('gzcompress failed'); + } + + // Header: uint32 BE with uncompressed length + $header = pack('N', $uncompressedLength); + + // Payload: header + compressed data + $payload = $header . $compressed; + + // Base64url encode without padding return self::urlsafe_b64_encode($payload); } @@ -149,6 +166,29 @@ class QrUtil return self::encodeOldPayloadFromJson($jsonPayload); } + /** + * Encode config in Amnezia app format for vpn:// URL + * Format: 3-byte length (big-endian) + zlib compressed JSON data + * This matches the real Amnezia app vpn:// URL format + */ + public static function encodeAmneziaVpnUrl(string $confText, string $protocolSlug = ''): string + { + // Build JSON envelope like the real Amnezia app + $payload = self::buildOldEnvelopeFromConf($confText, $protocolSlug); + $jsonPayload = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + + // Compress the JSON + $compressed = gzcompress($jsonPayload, 9); + if ($compressed === false) { + throw new RuntimeException('gzcompress failed'); + } + $length = strlen($compressed); + // 3-byte length in big-endian + $lengthBytes = pack('N', $length); + $header = substr($lengthBytes, 1); // Take last 3 bytes + return self::urlsafe_b64_encode($header . $compressed); + } + private static function resolveServerDescription(?string $endpointHost): string { $desc = (string) ($endpointHost ?? ''); diff --git a/inc/VpnClient.php b/inc/VpnClient.php index d0375ce..1bc8752 100644 --- a/inc/VpnClient.php +++ b/inc/VpnClient.php @@ -1269,15 +1269,8 @@ class VpnClient 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); + // For AWG2 and other WireGuard/AWG, use vpn:// URL format with JSON + zlib + $payloadVpn = QrUtil::encodeVpnUrlConf($config, $protocolSlug); $dataUri = QrUtil::pngBase64($payloadVpn); return $dataUri; } catch (Throwable $e) { diff --git a/public/index.php b/public/index.php index ff9578d..b5f8b75 100644 --- a/public/index.php +++ b/public/index.php @@ -1172,9 +1172,9 @@ Router::get('/clients/{id}', function ($params) { try { $qrCodeVpnUrl = VpnClient::generateQRCodeVpnUrl($clientData['config'], 'awg2'); - // Generate vpn:// URL string (add vpn:// prefix) + // Generate vpn:// URL string using vpn:// format (JSON + zlib) require_once __DIR__ . '/../inc/QrUtil.php'; - $vpnUrlConfig = 'vpn://' . QrUtil::encodeVpnUrlPayload($clientData['config'], 'awg2'); + $vpnUrlConfig = 'vpn://' . QrUtil::encodeVpnUrlConf($clientData['config'], 'awg2'); } catch (Exception $e) { // Ignore errors, just don't show the second QR }