feat: add support for AWG2 QR code generation and vpn:// URL configuration
This commit is contained in:
@@ -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
|
||||
|
||||
+50
-5
@@ -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://<base64url(header + compressed config)>
|
||||
*/
|
||||
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://<base64url(header + compressed config)>
|
||||
* 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 ?? '');
|
||||
|
||||
+34
-3
@@ -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
|
||||
*/
|
||||
|
||||
+26
-2
@@ -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';
|
||||
|
||||
@@ -146,12 +146,30 @@
|
||||
|
||||
{% if client.qr_code %}
|
||||
<div class="bg-white rounded shadow p-6 text-center">
|
||||
<h3 class="font-bold mb-4">QR Code</h3>
|
||||
<h3 class="font-bold mb-4">QR Code (Simple)</h3>
|
||||
<img src="{{ client.qr_code }}" alt="QR Code" class="mx-auto" style="max-width: 300px; width: 100%; height: auto;">
|
||||
<p class="text-sm text-gray-600 mt-2">Scan with Amnezia VPN app</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_awg2 and qr_code_vpn_url %}
|
||||
<div class="bg-white rounded shadow p-6 text-center mt-6">
|
||||
<h3 class="font-bold mb-4">QR Code (vpn:// URL)</h3>
|
||||
<img src="{{ qr_code_vpn_url }}" alt="QR Code VPN URL" class="mx-auto" style="max-width: 300px; width: 100%; height: auto;">
|
||||
<p class="text-sm text-gray-600 mt-2">Scan with Amnezia VPN app</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_awg2 and vpn_url_config %}
|
||||
<div class="bg-white rounded shadow p-6 mt-6">
|
||||
<h3 class="font-bold mb-4">VPN URL Configuration</h3>
|
||||
<div class="bg-gray-100 p-4 rounded text-left overflow-x-auto">
|
||||
<code class="text-sm break-all">{{ vpn_url_config }}</code>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mt-2">Copy this vpn:// URL to import configuration</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if protocol_output and client.show_text_content %}
|
||||
<div class="bg-white rounded shadow p-6 mt-6">
|
||||
<h3 class="font-bold mb-4">{{ t('clients.connection_instructions') }}</h3>
|
||||
|
||||
Reference in New Issue
Block a user