From ea82b78a7db0ee6ec6eb25afc22a4cd40c6766d6 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Fri, 23 Jan 2026 17:55:40 +0300 Subject: [PATCH 01/72] feat: ssh auth, protocol management, and cleanup --- .gitignore | 19 + Dockerfile | 9 + README.md | 37 +- controllers/AIController.php | 378 +++ controllers/LogsController.php | 408 +++ controllers/ProtocolManagementController.php | 926 ++++++ controllers/ScenarioController.php | 375 +++ controllers/SettingsController.php | 75 +- docker-compose.yml | 20 +- inc/BackupLibrary.php | 477 +++ inc/InstallProtocolManager.php | 1131 +++++++ inc/Logger.php | 25 + inc/OpenRouterService.php | 314 ++ inc/ProtocolService.php | 407 +++ inc/QrUtil.php | 340 +- inc/Router.php | 1 + inc/View.php | 17 + inc/VpnClient.php | 1394 ++++++-- inc/VpnServer.php | 610 +++- migrations/014_consolidated.sql | 788 +++++ migrations/015_fix_awg_script.sql | 108 + migrations/016_fix_awg_recovery.sql | 220 ++ migrations/017_fix_awg_script_exit_code.sql | 213 ++ migrations/018_fix_awg_final.sql | 156 + migrations/019_fix_awg_heredoc.sql | 172 + migrations/020_fix_awg_params.sql | 177 + migrations/021_fix_awg_h_params.sql | 183 ++ migrations/022_fix_awg_peer.sql | 184 ++ migrations/023_ensure_container_running.sql | 188 ++ migrations/024_fix_xray_ports.sql | 22 + migrations/025_xray_reality.sql | 18 + migrations/026_xray_uninstall_script.sql | 4 + migrations/027_update_xray_install_script.sql | 4 + migrations/028_fix_xray_install_keys.sql | 4 + migrations/029_xray_respect_server_port.sql | 3 + migrations/030_xray_default_port_443.sql | 3 + migrations/031_add_qr_code_templates.sql | 49 + migrations/032_add_qr_code_translations.sql | 10 + .../033_add_protocol_editor_translations.sql | 70 + migrations/034_add_show_text_content.sql | 10 + migrations/035_restore_awg_script.sql | 188 ++ migrations/036_fix_awg_script_output.sql | 288 ++ migrations/037_fix_awg_mtu_1280.sql | 5 + migrations/039_fix_awg_client_template.sql | 52 + migrations/040_remove_uppercase_variables.sql | 17 + migrations/041_add_ssh_key_column.sql | 1 + public/index.php | 2893 ++++++++++++++--- scripts/api_cycle_awg_advanced.sh | 140 + scripts/api_diagnose_client.sh | 55 + scripts/api_diagnose_server.sh | 34 + scripts/api_list_clients.sh | 22 + scripts/api_protocol_smoketest.sh | 92 + scripts/api_regen_and_dump_conf.sh | 57 + scripts/cleanup_amnezia.sh | 69 + scripts/derive_pubkey_from_priv.php | 19 + scripts/remove_container.sh | 32 + templates/ai/preview_generation.twig | 244 ++ templates/clients/view.twig | 8 + templates/servers/create.twig | 215 +- templates/servers/deploy.twig | 145 +- templates/servers/view.twig | 213 +- templates/settings.twig | 224 +- templates/settings/logs.twig | 343 ++ templates/settings/protocol_form.twig | 707 ++++ .../settings/protocol_template_editor.twig | 272 ++ templates/settings/protocols_management.twig | 522 +++ templates/settings/scenario_form.twig | 250 ++ templates/settings/scenario_view.twig | 246 ++ templates/settings/scenarios.twig | 156 + templates/tools/qr_decode.twig | 153 + 70 files changed, 16225 insertions(+), 986 deletions(-) create mode 100644 controllers/AIController.php create mode 100644 controllers/LogsController.php create mode 100644 controllers/ProtocolManagementController.php create mode 100644 controllers/ScenarioController.php create mode 100644 inc/BackupLibrary.php create mode 100644 inc/InstallProtocolManager.php create mode 100644 inc/Logger.php create mode 100644 inc/OpenRouterService.php create mode 100644 inc/ProtocolService.php create mode 100644 migrations/014_consolidated.sql create mode 100644 migrations/015_fix_awg_script.sql create mode 100644 migrations/016_fix_awg_recovery.sql create mode 100644 migrations/017_fix_awg_script_exit_code.sql create mode 100644 migrations/018_fix_awg_final.sql create mode 100644 migrations/019_fix_awg_heredoc.sql create mode 100644 migrations/020_fix_awg_params.sql create mode 100644 migrations/021_fix_awg_h_params.sql create mode 100644 migrations/022_fix_awg_peer.sql create mode 100644 migrations/023_ensure_container_running.sql create mode 100644 migrations/024_fix_xray_ports.sql create mode 100644 migrations/025_xray_reality.sql create mode 100644 migrations/026_xray_uninstall_script.sql create mode 100644 migrations/027_update_xray_install_script.sql create mode 100644 migrations/028_fix_xray_install_keys.sql create mode 100644 migrations/029_xray_respect_server_port.sql create mode 100644 migrations/030_xray_default_port_443.sql create mode 100644 migrations/031_add_qr_code_templates.sql create mode 100644 migrations/032_add_qr_code_translations.sql create mode 100644 migrations/033_add_protocol_editor_translations.sql create mode 100644 migrations/034_add_show_text_content.sql create mode 100644 migrations/035_restore_awg_script.sql create mode 100644 migrations/036_fix_awg_script_output.sql create mode 100644 migrations/037_fix_awg_mtu_1280.sql create mode 100644 migrations/039_fix_awg_client_template.sql create mode 100644 migrations/040_remove_uppercase_variables.sql create mode 100644 migrations/041_add_ssh_key_column.sql create mode 100644 scripts/api_cycle_awg_advanced.sh create mode 100644 scripts/api_diagnose_client.sh create mode 100755 scripts/api_diagnose_server.sh create mode 100644 scripts/api_list_clients.sh create mode 100644 scripts/api_protocol_smoketest.sh create mode 100644 scripts/api_regen_and_dump_conf.sh create mode 100755 scripts/cleanup_amnezia.sh create mode 100644 scripts/derive_pubkey_from_priv.php create mode 100755 scripts/remove_container.sh create mode 100644 templates/ai/preview_generation.twig create mode 100644 templates/settings/logs.twig create mode 100644 templates/settings/protocol_form.twig create mode 100644 templates/settings/protocol_template_editor.twig create mode 100644 templates/settings/protocols_management.twig create mode 100644 templates/settings/scenario_form.twig create mode 100644 templates/settings/scenario_view.twig create mode 100644 templates/settings/scenarios.twig create mode 100644 templates/tools/qr_decode.twig diff --git a/.gitignore b/.gitignore index 6e07cd2..34c1f77 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,21 @@ test_qr.svg *.swo *~ +# Local tooling / scratch +.trae/ +scripts/_cycle_out/ + +# Local exported client configs (do not commit) +oleg*.conf +adminnew*.conf +________*.conf +*fixed*.conf +test_client_simple.conf + +# Local QR exports / screenshots +*_QR.png +photo_*.jpeg + # Build artifacts dist/ build/ @@ -42,3 +57,7 @@ backup/ backups/ TEST_RESULTS.md LDAP_FEATURE.md + +# Documentation and Tests +tests/ +docs/ diff --git a/Dockerfile b/Dockerfile index 80824f7..ba97541 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,7 @@ RUN apt-get update && apt-get install -y \ qrencode \ cron \ libldap2-dev \ + docker.io \ && docker-php-ext-configure ldap --with-libdir=lib/x86_64-linux-gnu/ \ && docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd ldap \ && a2enmod rewrite \ @@ -56,6 +57,14 @@ RUN chmod +x /var/www/html/bin/monitor_metrics.sh # Create startup script RUN echo '#!/bin/bash\n\ service cron start\n\ +# Ensure www-data can talk to host docker socket if mounted\n\ +if [ -S /var/run/docker.sock ]; then\n\ + SOCK_GID=$(stat -c %g /var/run/docker.sock)\n\ + if ! getent group docker >/dev/null; then\n\ + groupadd -g "$SOCK_GID" docker || true\n\ + fi\n\ + usermod -aG docker www-data || true\n\ +fi\n\ # Start metrics collector on container startup\n\ /bin/bash /var/www/html/bin/monitor_metrics.sh\n\ apache2-foreground' > /start.sh \ diff --git a/README.md b/README.md index 07f17cd..23500b2 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,15 @@ Web-based management panel for Amnezia AWG (WireGuard) VPN servers. ## Features -- VPN server deployment via SSH +- VPN server deployment via SSH (Password or **SSH Key**) - **Import from existing VPN panels** (wg-easy, 3x-ui) +- **Advanced Protocol Management** (WireGuard, AmneziaWG, OpenVPN, Shadowsocks, etc.) +- **AI-powered Protocol Configuration** using OpenRouter (optional) - Client configuration management with **expiration dates** - **Traffic limits** for clients with automatic enforcement - **Server backup and restore** functionality +- **Scenario Testing**: Define and test different VPN connection scenarios across protocols +- **Advanced Log Management**: View, search, and manage system and container logs - Traffic statistics monitoring - QR code generation for mobile apps - Multi-language interface (English, Russian, Spanish, German, French, Chinese) @@ -63,7 +67,10 @@ JWT_SECRET=your-secret-key-change-this ### Add VPN Server 1. Servers → Add Server -2. Enter: name, host IP, SSH port, username, password +1. Servers → Add Server +2. Enter: name, host IP, SSH port, username +3. Choose authentication method: **Password** or **SSH Key** + - For SSH Key: Paste your private key (PEM/OpenSSH format) 3. **(Optional) Enable import from existing panel:** - Check "Import from existing panel" - Select panel type (wg-easy or 3x-ui) @@ -141,6 +148,30 @@ curl -X POST http://localhost:8082/api/servers/1/restore \ -d '{"backup_id": 123}' ``` +### Protocol Management + +Manage VPN protocols via **Settings → Protocols**: +- Install/Uninstall protocols (WireGuard, AmneziaWG, OpenVPN, etc.) +- Configure protocol settings (ports, transport, obfuscation) +- **AI Assistant**: Use "Ask AI" to generate complex protocol configurations tailored to your needs (requires OpenRouter API key). + +### Scenario Testing & Logs + +**Scenario Testing**: +- Create test scenarios to verify connectivity across different protocols and network conditions. +- Run automated tests to ensure your VPN infrastructure is reliable. + +**Log Management**: +- Centralized view of all system, container, and application logs. +- Search and filter capabilities to quickly diagnose issues. + +### AI Assistant + +Configure OpenRouter API key in **Settings** to enable: +- Auto-translation of the interface +- AI-assisted protocol configuration +- Intelligent troubleshooting suggestions + ### Automatic Monitoring and Metrics Collection **Metrics collector runs automatically** on container startup and is monitored by cron every 3 minutes. If the process crashes, it will be automatically restarted. @@ -284,6 +315,8 @@ inc/ - Core classes JWT.php - Token auth QrUtil.php - QR code generation PanelImporter.php - Import from wg-easy/3x-ui + InstallProtocolManager.php - Protocol management core + OpenRouterService.php - AI integration templates/ - Twig templates migrations/ - SQL migrations (executed in alphabetical order) ``` diff --git a/controllers/AIController.php b/controllers/AIController.php new file mode 100644 index 0000000..329916e --- /dev/null +++ b/controllers/AIController.php @@ -0,0 +1,378 @@ +openRouterService = new OpenRouterService(); + } + + /** + * AI Assistant endpoint for generating installation scripts + */ + public function assist(): void + { + requireAdmin(); + + try { + $input = json_decode(file_get_contents('php://input'), true); + if (!$input) { + throw new Exception('Invalid JSON input'); + } + + $prompt = trim($input['prompt'] ?? ''); + $model = trim($input['model'] ?? 'openai/gpt-3.5-turbo'); + $protocolType = trim($input['protocol_type'] ?? ''); + $protocolId = isset($input['protocol_id']) ? (int) $input['protocol_id'] : null; + $target = trim($input['target'] ?? 'install'); // install, uninstall, template + + if (empty($prompt)) { + throw new Exception('Prompt is required'); + } + + // Generate enhanced prompt based on protocol type and target + $enhancedPrompt = $this->enhancePrompt($prompt, $protocolType, $target); + + // Call OpenRouter API + $response = $this->openRouterService->generateScript($enhancedPrompt, $model); + + // Save AI generation to database + $generationId = $this->saveAIGeneration([ + 'protocol_id' => $protocolId, + 'model_used' => $model, + 'prompt' => "[$target] " . $prompt, + 'generated_script' => $response['script'] ?? '', + 'suggestions' => json_encode($response['suggestions'] ?? []), + 'ubuntu_compatible' => $response['ubuntu_compatible'] ?? null, + 'created_at' => date('Y-m-d H:i:s') + ]); + + header('Content-Type: application/json'); + echo json_encode([ + 'success' => true, + 'data' => [ + 'script' => $response['script'] ?? '', + 'suggestions' => $response['suggestions'] ?? [], + 'ubuntu_compatible' => $response['ubuntu_compatible'] ?? false, + 'estimated_time' => $response['estimated_time'] ?? '5 minutes', + 'model_used' => $model, + 'generation_id' => $generationId, + 'target' => $target + ] + ]); + + } catch (Exception $e) { + error_log("Error in AIController::assist: " . $e->getMessage()); + header('Content-Type: application/json'); + http_response_code(500); + echo json_encode([ + 'success' => false, + 'error' => $e->getMessage() + ]); + } + } + + /** + * Get available AI models + */ + public function getModels(): void + { + requireAdmin(); + + try { + $models = $this->openRouterService->getAvailableModels(); + + header('Content-Type: application/json'); + echo json_encode([ + 'success' => true, + 'data' => $models + ]); + + } catch (Exception $e) { + error_log("Error in AIController::getModels: " . $e->getMessage()); + header('Content-Type: application/json'); + http_response_code(500); + echo json_encode([ + 'success' => false, + 'error' => $e->getMessage() + ]); + } + } + + public function testModel(): void + { + requireAdmin(); + header('Content-Type: application/json'); + + try { + $input = json_decode(file_get_contents('php://input'), true); + if (!$input) { + throw new Exception('Invalid JSON input'); + } + + $model = trim($input['model'] ?? ''); + if ($model === '') { + throw new Exception('Model id is required'); + } + + $result = $this->openRouterService->testModelAvailability($model); + + echo json_encode([ + 'success' => $result['success'] ?? false, + 'message' => $result['message'] ?? null, + 'http_code' => $result['http_code'] ?? null + ]); + + } catch (Exception $e) { + http_response_code(400); + echo json_encode([ + 'success' => false, + 'error' => $e->getMessage() + ]); + } + } + + /** + * Get AI generation history for a protocol + */ + public function getGenerationHistory(int $protocolId): void + { + requireAdmin(); + + try { + $generations = $this->getAIGenerationsByProtocol($protocolId); + + header('Content-Type: application/json'); + echo json_encode([ + 'success' => true, + 'data' => $generations + ]); + + } catch (Exception $e) { + error_log("Error in AIController::getGenerationHistory: " . $e->getMessage()); + header('Content-Type: application/json'); + http_response_code(500); + echo json_encode([ + 'success' => false, + 'error' => $e->getMessage() + ]); + } + } + + /** + * Apply AI-generated script to protocol + */ + public function applyGeneration(int $generationId): void + { + requireAdmin(); + + try { + $generation = $this->getAIGeneration($generationId); + if (!$generation) { + throw new Exception('AI generation not found'); + } + + if (!$generation['protocol_id']) { + throw new Exception('This generation is not associated with any protocol'); + } + + // Determine target from prompt + $target = 'install'; + if (preg_match('/^\[(uninstall|template)\]/', $generation['prompt'], $matches)) { + $target = $matches[1]; + } + + // Update protocol with generated script + $pdo = DB::conn(); + + if ($target === 'uninstall') { + $stmt = $pdo->prepare(' + UPDATE protocols + SET uninstall_script = ?, updated_at = ? + WHERE id = ? + '); + $stmt->execute([ + $generation['generated_script'], + date('Y-m-d H:i:s'), + $generation['protocol_id'] + ]); + } elseif ($target === 'template') { + $stmt = $pdo->prepare(' + UPDATE protocols + SET output_template = ?, updated_at = ? + WHERE id = ? + '); + $stmt->execute([ + $generation['generated_script'], + date('Y-m-d H:i:s'), + $generation['protocol_id'] + ]); + } else { + $stmt = $pdo->prepare(' + UPDATE protocols + SET install_script = ?, ubuntu_compatible = ?, updated_at = ? + WHERE id = ? + '); + $stmt->execute([ + $generation['generated_script'], + $generation['ubuntu_compatible'], + date('Y-m-d H:i:s'), + $generation['protocol_id'] + ]); + } + + header('Content-Type: application/json'); + echo json_encode([ + 'success' => true, + 'message' => 'AI-generated script applied to protocol successfully' + ]); + + } catch (Exception $e) { + error_log("Error in AIController::applyGeneration: " . $e->getMessage()); + header('Content-Type: application/json'); + http_response_code(500); + echo json_encode([ + 'success' => false, + 'error' => $e->getMessage() + ]); + } + } + + /** + * Preview AI-generated script with syntax highlighting + */ + public function previewGeneration(int $generationId): void + { + requireAdmin(); + + try { + $generation = $this->getAIGeneration($generationId); + if (!$generation) { + throw new Exception('AI generation not found'); + } + + View::render('ai/preview_generation.twig', [ + 'generation' => $generation, + 'script' => $generation['generated_script'], + 'suggestions' => json_decode($generation['suggestions'], true) ?? [] + ]); + + } catch (Exception $e) { + $_SESSION['protocol_error'] = $e->getMessage(); + redirect('/settings/protocols'); + } + } + + /** + * Enhance user prompt with context and requirements + */ + private function enhancePrompt(string $prompt, string $protocolType, string $target = 'install'): string + { + $context = ""; + + if ($target === 'uninstall') { + $context = "Create a bash uninstallation script for a VPN protocol. "; + } elseif ($target === 'template') { + $context = "Create a WireGuard-compatible client config template. "; + } else { + $context = "Create a bash installation script for a VPN protocol. "; + } + + if ($protocolType) { + $context .= "This is for a $protocolType protocol. "; + } + + $context .= "Requirements:\n"; + + if ($target === 'template') { + $context .= "- Use Mustache-style placeholders like {{private_key}}, {{client_ip}}, {{server_host}}, {{server_port}}\n"; + $context .= "- The template should be valid configuration format (e.g. .conf for WireGuard)\n"; + } elseif ($target === 'uninstall') { + $context .= "- The script should be compatible with Ubuntu 22.04 and 24.04\n"; + $context .= "- Remove all docker containers, images, and networks created by the installation\n"; + $context .= "- Remove configuration files and directories\n"; + $context .= "- Clean up firewall rules if necessary\n"; + } else { + $context .= "- The script should be compatible with Ubuntu 22.04 and 24.04\n"; + $context .= "- Use Docker containers where possible for isolation\n"; + $context .= "- Generate necessary keys and certificates automatically\n"; + $context .= "- Configure appropriate firewall rules\n"; + $context .= "- Provide clear output about installation progress\n"; + $context .= "- Handle errors gracefully\n"; + $context .= "- Include configuration validation\n"; + } + + $context .= "\nUser requirements: $prompt\n"; + $context .= "\nReturn the response in this JSON format:\n"; + $context .= "{\n"; + if ($target === 'template') { + $context .= ' "script": "[Interface]\\nPrivateKey = {{private_key}}...",' . "\n"; + } else { + $context .= ' "script": "#!/bin/bash\\n# Complete script",' . "\n"; + } + $context .= ' "suggestions": ["suggestion 1", "suggestion 2"],' . "\n"; + $context .= ' "ubuntu_compatible": true,' . "\n"; + $context .= ' "estimated_time": "5 minutes"' . "\n"; + $context .= "}\n"; + + return $context; + } + + /** + * Database methods + */ + + private function saveAIGeneration(array $data): int + { + $pdo = DB::conn(); + $stmt = $pdo->prepare(' + INSERT INTO ai_generations (protocol_id, model_used, prompt, generated_script, suggestions, ubuntu_compatible, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + '); + $stmt->execute([ + $data['protocol_id'], + $data['model_used'], + $data['prompt'], + $data['generated_script'], + $data['suggestions'], + $data['ubuntu_compatible'], + $data['created_at'] + ]); + + return (int) $pdo->lastInsertId(); + } + + private function getAIGeneration(int $id): ?array + { + $pdo = DB::conn(); + $stmt = $pdo->prepare(' + SELECT ag.*, p.name as protocol_name, p.slug as protocol_slug + FROM ai_generations ag + LEFT JOIN protocols p ON ag.protocol_id = p.id + WHERE ag.id = ? + '); + $stmt->execute([$id]); + $generation = $stmt->fetch(PDO::FETCH_ASSOC); + + return $generation ?: null; + } + + private function getAIGenerationsByProtocol(int $protocolId): array + { + $pdo = DB::conn(); + $stmt = $pdo->prepare(' + SELECT ag.*, p.name as protocol_name + FROM ai_generations ag + LEFT JOIN protocols p ON ag.protocol_id = p.id + WHERE ag.protocol_id = ? + ORDER BY ag.created_at DESC + LIMIT 50 + '); + $stmt->execute([$protocolId]); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } +} \ No newline at end of file diff --git a/controllers/LogsController.php b/controllers/LogsController.php new file mode 100644 index 0000000..259cce0 --- /dev/null +++ b/controllers/LogsController.php @@ -0,0 +1,408 @@ +getLogFiles(); + $selectedFile = $_GET['file'] ?? null; + $logContent = ''; + $logLines = []; + $fileSize = 0; + $lineCount = 0; + + if ($selectedFile && $this->isValidLogFile($selectedFile)) { + $filePath = self::LOGS_DIR . '/' . basename($selectedFile); + + if (file_exists($filePath)) { + $fileSize = filesize($filePath); + + // Read log file (last 1000 lines or complete if small) + $logContent = $this->readLogFile($filePath); + $logLines = array_filter(explode("\n", $logContent)); + $lineCount = count($logLines); + } + } + + View::render('settings/logs.twig', [ + 'log_files' => $logFiles, + 'selected_file' => $selectedFile, + 'log_content' => $logContent, + 'log_lines' => $logLines, + 'line_count' => $lineCount, + 'file_size' => $fileSize, + 'section' => 'logs' + ]); + } + + /** + * Get list of available log files + */ + private function getLogFiles(): array { + $files = []; + + if (!is_dir(self::LOGS_DIR)) { + return $files; + } + + $dirContents = @scandir(self::LOGS_DIR, SCANDIR_SORT_DESCENDING); + + if ($dirContents === false) { + return $files; + } + + foreach ($dirContents as $filename) { + // Skip . and .. + if ($filename === '.' || $filename === '..') { + continue; + } + + $filePath = self::LOGS_DIR . '/' . $filename; + + // Only regular files + if (!is_file($filePath)) { + continue; + } + + // Check extension + $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + if (!in_array($ext, self::ALLOWED_EXTENSIONS, true)) { + continue; + } + + $size = filesize($filePath); + $modified = filemtime($filePath); + + $files[] = [ + 'name' => $filename, + 'path' => $filename, + 'size' => $size, + 'size_formatted' => $this->formatBytes($size), + 'modified' => $modified, + 'modified_formatted' => date('Y-m-d H:i:s', $modified), + 'readable' => is_readable($filePath) + ]; + } + + return $files; + } + + /** + * Read log file content (last N lines) + */ + private function readLogFile(string $filePath, int $maxLines = 1000): string { + if (!is_readable($filePath)) { + return ''; + } + + $fileSize = filesize($filePath); + + // If file is small enough, read completely + if ($fileSize <= self::MAX_FILE_SIZE) { + return file_get_contents($filePath) ?: ''; + } + + // For large files, read last N lines + $handle = fopen($filePath, 'r'); + if ($handle === false) { + return ''; + } + + // Seek to end and read backwards + fseek($handle, 0, SEEK_END); + $lines = []; + $lineCount = 0; + + while ($lineCount < $maxLines && ftell($handle) > 0) { + $chunk = ''; + $pos = ftell($handle); + + // Read backwards in chunks + $chunkSize = min(4096, $pos); + fseek($handle, -$chunkSize, SEEK_CUR); + $chunk = fread($handle, $chunkSize); + fseek($handle, -$chunkSize, SEEK_CUR); + + $parts = explode("\n", $chunk); + $lines = array_merge($parts, $lines); + $lineCount = count($lines); + + if ($pos <= $chunkSize) { + break; + } + } + + fclose($handle); + + // Take last N lines and rejoin + $lines = array_slice($lines, -$maxLines); + return implode("\n", $lines); + } + + /** + * Download log file + * GET /admin/logs/download?file=filename + */ + public function download() { + requireAdmin(); + + $file = $_GET['file'] ?? null; + + if (!$file || !$this->isValidLogFile($file)) { + http_response_code(400); + echo 'Invalid file'; + return; + } + + $filePath = self::LOGS_DIR . '/' . basename($file); + + if (!file_exists($filePath) || !is_readable($filePath)) { + http_response_code(404); + echo 'File not found'; + return; + } + + header('Content-Type: text/plain; charset=utf-8'); + header('Content-Disposition: attachment; filename="' . basename($file) . '"'); + header('Content-Length: ' . filesize($filePath)); + + readfile($filePath); + exit; + } + + /** + * Delete log file + * POST /admin/logs/delete + */ + public function delete() { + requireAdmin(); + + header('Content-Type: application/json'); + + $file = $_POST['file'] ?? null; + + if (!$file || !$this->isValidLogFile($file)) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'Invalid file']); + return; + } + + $filePath = self::LOGS_DIR . '/' . basename($file); + + if (!file_exists($filePath)) { + http_response_code(404); + echo json_encode(['success' => false, 'message' => 'File not found']); + return; + } + + if (@unlink($filePath)) { + echo json_encode([ + 'success' => true, + 'message' => 'Log file deleted successfully', + 'redirect' => '/admin/logs' + ]); + } else { + http_response_code(500); + echo json_encode(['success' => false, 'message' => 'Failed to delete file']); + } + } + + /** + * Clear all log files + * POST /admin/logs/clear-all + */ + public function clearAll() { + requireAdmin(); + + header('Content-Type: application/json'); + + $logFiles = $this->getLogFiles(); + $deleted = 0; + $failed = 0; + + foreach ($logFiles as $file) { + $filePath = self::LOGS_DIR . '/' . $file['path']; + if (@unlink($filePath)) { + $deleted++; + } else { + $failed++; + } + } + + echo json_encode([ + 'success' => true, + 'deleted' => $deleted, + 'failed' => $failed, + 'message' => "Deleted $deleted log files" . ($failed > 0 ? ", failed to delete $failed" : ''), + 'redirect' => '/admin/logs' + ]); + } + + /** + * Search logs + * POST /admin/logs/search + */ + public function search() { + requireAdmin(); + + header('Content-Type: application/json'); + + $query = $_POST['query'] ?? ''; + $file = $_POST['file'] ?? null; + $caseSensitive = isset($_POST['case_sensitive']) && $_POST['case_sensitive'] === 'on'; + + if (empty($query) || strlen($query) < 2) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'Search query too short']); + return; + } + + if (!$file || !$this->isValidLogFile($file)) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'Invalid file']); + return; + } + + $filePath = self::LOGS_DIR . '/' . basename($file); + + if (!file_exists($filePath) || !is_readable($filePath)) { + http_response_code(404); + echo json_encode(['success' => false, 'message' => 'File not found']); + return; + } + + $content = file_get_contents($filePath); + if ($content === false) { + http_response_code(500); + echo json_encode(['success' => false, 'message' => 'Failed to read file']); + return; + } + + $lines = explode("\n", $content); + $results = []; + $flags = $caseSensitive ? 0 : PREG_GREP_INVERT; + + foreach ($lines as $lineNum => $line) { + if (empty($line)) { + continue; + } + + // Case-sensitive or case-insensitive search + $matches = $caseSensitive + ? (strpos($line, $query) !== false) + : (stripos($line, $query) !== false); + + if ($matches) { + $results[] = [ + 'line' => $lineNum + 1, + 'content' => $line + ]; + } + } + + echo json_encode([ + 'success' => true, + 'query' => $query, + 'results_count' => count($results), + 'results' => array_slice($results, 0, 100) // Limit to 100 results + ]); + } + + /** + * Get log statistics + * POST /admin/logs/stats + */ + public function stats() { + requireAdmin(); + + header('Content-Type: application/json'); + + $file = $_POST['file'] ?? null; + + if (!$file || !$this->isValidLogFile($file)) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'Invalid file']); + return; + } + + $filePath = self::LOGS_DIR . '/' . basename($file); + + if (!file_exists($filePath) || !is_readable($filePath)) { + http_response_code(404); + echo json_encode(['success' => false, 'message' => 'File not found']); + return; + } + + $content = file_get_contents($filePath); + if ($content === false) { + http_response_code(500); + echo json_encode(['success' => false, 'message' => 'Failed to read file']); + return; + } + + $lines = array_filter(explode("\n", $content)); + $errorCount = count(preg_grep('/error|exception|fatal|fail/i', $lines)); + $warningCount = count(preg_grep('/warning|warn/i', $lines)); + $successCount = count(preg_grep('/success|completed|ok/i', $lines)); + + echo json_encode([ + 'success' => true, + 'total_lines' => count($lines), + 'errors' => $errorCount, + 'warnings' => $warningCount, + 'success' => $successCount, + 'file_size' => filesize($filePath), + 'file_size_formatted' => $this->formatBytes(filesize($filePath)), + 'last_modified' => date('Y-m-d H:i:s', filemtime($filePath)) + ]); + } + + /** + * Validate log file name + */ + private function isValidLogFile(string $filename): bool { + // Prevent directory traversal + if (strpos($filename, '/') !== false || strpos($filename, '\\') !== false) { + return false; + } + + if (strpos($filename, '..') !== false) { + return false; + } + + // Check extension + $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + if (!in_array($ext, self::ALLOWED_EXTENSIONS, true)) { + return false; + } + + return true; + } + + /** + * Format bytes to human readable + */ + private function formatBytes(int $bytes, int $precision = 2): string { + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + + for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) { + $bytes /= 1024; + } + + return round($bytes, $precision) . ' ' . $units[$i]; + } +} diff --git a/controllers/ProtocolManagementController.php b/controllers/ProtocolManagementController.php new file mode 100644 index 0000000..6df0e6c --- /dev/null +++ b/controllers/ProtocolManagementController.php @@ -0,0 +1,926 @@ +getAllProtocols(); + $selectedId = isset($_GET['id']) ? (int) $_GET['id'] : null; + $isNew = isset($_GET['new']); + $isTemplate = isset($_GET['template']); + + $editing = null; + if (!$isNew && $selectedId) { + $editing = $this->getProtocolById($selectedId); + } + + $pdo = DB::conn(); + $stmt = $pdo->prepare("SELECT api_key FROM api_keys WHERE service_name = 'openrouter' AND is_active = 1 LIMIT 1"); + $stmt->execute(); + $openrouterKey = $stmt->fetchColumn() ?: ''; + + if ($isTemplate && $editing) { + View::render('settings/protocol_template_editor.twig', [ + 'protocol' => $editing, + 'success' => $_SESSION['protocol_success'] ?? null, + 'error' => $_SESSION['protocol_error'] ?? null, + 'openrouter_key' => $openrouterKey, + ]); + } elseif ($editing || $isNew) { + View::render('settings/protocol_form.twig', [ + 'editing' => $editing, + 'success' => $_SESSION['protocol_success'] ?? null, + 'error' => $_SESSION['protocol_error'] ?? null, + 'openrouter_key' => $openrouterKey, + ]); + } else { + View::render('settings/protocols_management.twig', [ + 'protocols' => $protocols, + 'success' => $_SESSION['protocol_success'] ?? null, + 'error' => $_SESSION['protocol_error'] ?? null, + 'openrouter_key' => $openrouterKey, + ]); + } + + unset($_SESSION['protocol_success'], $_SESSION['protocol_error']); + + } catch (Exception $e) { + error_log("Error in ProtocolManagementController::index: " . $e->getMessage()); + $_SESSION['protocol_error'] = 'Failed to load protocols: ' . $e->getMessage(); + redirect('/settings/protocols-management'); + } + } + + /** + * Create or update protocol + */ + public function save(): void + { + requireAdmin(); + + try { + $id = isset($_POST['id']) && $_POST['id'] !== '' ? (int) $_POST['id'] : null; + $name = trim($_POST['name'] ?? ''); + $slug = trim($_POST['slug'] ?? ''); + $description = trim($_POST['description'] ?? ''); + $installScript = trim($_POST['install_script'] ?? ''); + $passwordCommand = trim($_POST['password_command'] ?? ''); + $uninstallScript = trim($_POST['uninstall_script'] ?? ''); + $outputTemplate = trim($_POST['output_template'] ?? ''); + $qrCodeTemplate = trim($_POST['qr_code_template'] ?? ''); + $qrCodeFormat = trim($_POST['qr_code_format'] ?? 'amnezia_compressed'); + $ubuntuCompatible = isset($_POST['ubuntu_compatible']) ? 1 : 0; + $showTextContent = isset($_POST['show_text_content']) ? 1 : 0; + $isActive = isset($_POST['is_active']) ? 1 : 0; + + // Validation + if ($name === '' || $slug === '') { + throw new Exception('Name and slug are required'); + } + + if (!preg_match('/^[a-z0-9_-]+$/i', $slug)) { + throw new Exception('Slug may contain only letters, numbers, dashes, and underscores'); + } + + // Check if slug is unique (for new protocols or when updating slug) + if ($this->isSlugExists($slug, $id)) { + throw new Exception('Protocol with this slug already exists'); + } + + $protocolData = [ + 'name' => $name, + 'slug' => $slug, + 'description' => $description, + 'install_script' => $installScript, + 'output_template' => $outputTemplate, + 'qr_code_template' => $qrCodeTemplate, + 'qr_code_format' => $qrCodeFormat, + 'password_command' => $passwordCommand, + 'uninstall_script' => $uninstallScript, + 'ubuntu_compatible' => $ubuntuCompatible, + 'show_text_content' => $showTextContent, + 'is_active' => $isActive, + 'updated_at' => date('Y-m-d H:i:s') + ]; + + if ($id) { + // Update existing protocol + $this->updateProtocol($id, $protocolData); + $savedId = $id; + $_SESSION['protocol_success'] = 'Protocol updated successfully'; + } else { + // Create new protocol + $protocolData['created_at'] = date('Y-m-d H:i:s'); + $savedId = $this->createProtocol($protocolData); + $_SESSION['protocol_success'] = 'Protocol created successfully'; + } + + redirect('/settings/protocols-management?id=' . $savedId); + + } catch (Exception $e) { + $_SESSION['protocol_error'] = $e->getMessage(); + $id = isset($_POST['id']) ? (int) $_POST['id'] : null; + redirect('/settings/protocols-management' . ($id ? '?id=' . $id : '?new=1')); + } + } + + /** + * Delete protocol + */ + public function delete(int $id): void + { + requireAdmin(); + + try { + $protocol = $this->getProtocolById($id); + if (!$protocol) { + throw new Exception('Protocol not found'); + } + + // Check if protocol is used by any servers + if ($this->isProtocolUsed($id)) { + throw new Exception('Cannot delete protocol that is currently used by servers'); + } + + $this->deleteProtocol($id); + $_SESSION['protocol_success'] = 'Protocol deleted successfully'; + + } catch (Exception $e) { + $_SESSION['protocol_error'] = $e->getMessage(); + } + + redirect('/settings/protocols-management'); + } + + /** + * API endpoint: Get all protocols (JSON) + */ + public function apiGetProtocols(): void + { + requireAdmin(); + + try { + $protocols = $this->getAllProtocols(); + + header('Content-Type: application/json'); + echo json_encode([ + 'success' => true, + 'data' => $protocols + ]); + + } catch (Exception $e) { + header('Content-Type: application/json'); + http_response_code(500); + echo json_encode([ + 'success' => false, + 'error' => $e->getMessage() + ]); + } + } + + /** + * API endpoint: Get single protocol (JSON) + */ + public function apiGetProtocol(int $id): void + { + requireAdmin(); + + try { + $protocol = $this->getProtocolById($id); + if (!$protocol) { + throw new Exception('Protocol not found'); + } + + header('Content-Type: application/json'); + echo json_encode([ + 'success' => true, + 'data' => $protocol + ]); + + } catch (Exception $e) { + header('Content-Type: application/json'); + http_response_code(404); + echo json_encode([ + 'success' => false, + 'error' => $e->getMessage() + ]); + } + } + + /** + * API endpoint: Create protocol (JSON) + */ + public function apiCreateProtocol(): void + { + requireAdmin(); + + try { + $input = json_decode(file_get_contents('php://input'), true); + if (!$input) { + throw new Exception('Invalid JSON input'); + } + + // Validate required fields + $requiredFields = ['name', 'slug']; + foreach ($requiredFields as $field) { + if (empty($input[$field])) { + throw new Exception("Field '$field' is required"); + } + } + + // Validate slug format + if (!preg_match('/^[a-z0-9_-]+$/i', $input['slug'])) { + throw new Exception('Slug may contain only letters, numbers, dashes, and underscores'); + } + + // Check if slug exists + if ($this->isSlugExists($input['slug'])) { + throw new Exception('Protocol with this slug already exists'); + } + + $protocolData = [ + 'name' => trim($input['name']), + 'slug' => trim($input['slug']), + 'description' => trim($input['description'] ?? ''), + 'install_script' => trim($input['install_script'] ?? ''), + 'output_template' => trim($input['output_template'] ?? ''), + 'qr_code_template' => trim($input['qr_code_template'] ?? ''), + 'qr_code_format' => trim($input['qr_code_format'] ?? 'amnezia_compressed'), + 'ubuntu_compatible' => (bool) ($input['ubuntu_compatible'] ?? false), + 'show_text_content' => (bool) ($input['show_text_content'] ?? false), + 'is_active' => (bool) ($input['is_active'] ?? true), + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s') + ]; + + $id = $this->createProtocol($protocolData); + $protocol = $this->getProtocolById($id); + + header('Content-Type: application/json'); + echo json_encode([ + 'success' => true, + 'data' => $protocol, + 'message' => 'Protocol created successfully' + ]); + + } catch (Exception $e) { + header('Content-Type: application/json'); + http_response_code(400); + echo json_encode([ + 'success' => false, + 'error' => $e->getMessage() + ]); + } + } + + /** + * API endpoint: Update protocol (JSON) + */ + public function apiUpdateProtocol(int $id): void + { + requireAdmin(); + + try { + $protocol = $this->getProtocolById($id); + if (!$protocol) { + throw new Exception('Protocol not found'); + } + + $input = json_decode(file_get_contents('php://input'), true); + if (!$input) { + throw new Exception('Invalid JSON input'); + } + + $protocolData = []; + $allowedFields = ['name', 'slug', 'description', 'install_script', 'output_template', 'qr_code_template', 'qr_code_format', 'ubuntu_compatible', 'show_text_content', 'is_active']; + + foreach ($allowedFields as $field) { + if (isset($input[$field])) { + if ($field === 'ubuntu_compatible' || $field === 'is_active') { + $protocolData[$field] = (bool) $input[$field]; + } elseif ($field === 'slug') { + $slug = trim($input[$field]); + if (!preg_match('/^[a-z0-9_-]+$/i', $slug)) { + throw new Exception('Slug may contain only letters, numbers, dashes, and underscores'); + } + if ($this->isSlugExists($slug, $id)) { + throw new Exception('Protocol with this slug already exists'); + } + $protocolData[$field] = $slug; + } else { + $protocolData[$field] = trim($input[$field]); + } + } + } + + if (!empty($protocolData)) { + $protocolData['updated_at'] = date('Y-m-d H:i:s'); + $this->updateProtocol($id, $protocolData); + $protocol = $this->getProtocolById($id); + } + + header('Content-Type: application/json'); + echo json_encode([ + 'success' => true, + 'data' => $protocol, + 'message' => 'Protocol updated successfully' + ]); + + } catch (Exception $e) { + header('Content-Type: application/json'); + http_response_code(400); + echo json_encode([ + 'success' => false, + 'error' => $e->getMessage() + ]); + } + } + + /** + * API endpoint: Delete protocol (JSON) + */ + public function apiDeleteProtocol(int $id): void + { + requireAdmin(); + + try { + $protocol = $this->getProtocolById($id); + if (!$protocol) { + throw new Exception('Protocol not found'); + } + + if ($this->isProtocolUsed($id)) { + throw new Exception('Cannot delete protocol that is currently used by servers'); + } + + $this->deleteProtocol($id); + + header('Content-Type: application/json'); + echo json_encode([ + 'success' => true, + 'message' => 'Protocol deleted successfully' + ]); + + } catch (Exception $e) { + header('Content-Type: application/json'); + http_response_code(400); + echo json_encode([ + 'success' => false, + 'error' => $e->getMessage() + ]); + } + } + + public function apiTestInstallProtocol(int $id): void + { + requireAdmin(); + + // Suppress all errors and warnings to prevent HTML output before JSON + @ini_set('display_errors', '0'); + error_reporting(0); + + // Clean any previous output + if (ob_get_level()) + ob_end_clean(); + ob_start(); + + header('Content-Type: application/json'); + + try { + $protocol = $this->getProtocolById($id); + if (!$protocol) { + throw new Exception('Protocol not found'); + } + + $script = trim($protocol['install_script'] ?? ''); + if ($script === '') { + throw new Exception('Install script is empty'); + } + + $container = 'proto-test-' . $id; + + $this->runHostCommand('docker rm -f ' . escapeshellarg($container) . ' >/dev/null 2>&1 || true'); + $run = $this->runHostCommandChecked('docker run --privileged -d -v /var/run/docker.sock:/var/run/docker.sock --name ' . escapeshellarg($container) . ' ubuntu:22.04 sleep infinity'); + if ($run['rc'] !== 0) { + throw new Exception('Docker not accessible: ' . trim($run['out'])); + } + + $cliPath = '/usr/local/bin/docker'; + $try1 = $this->runHostCommandChecked('docker run --rm docker:24-dind sh -lc "cat ' . $cliPath . '"'); + if ($try1['rc'] !== 0 || $try1['out'] === '') { + $cliPath = '/usr/bin/docker'; + $try2 = $this->runHostCommandChecked('docker run --rm docker:24-dind sh -lc "cat ' . $cliPath . '"'); + if ($try2['rc'] !== 0 || $try2['out'] === '') { + throw new Exception('Failed to read docker CLI from docker:24-dind image'); + } + } + $cp = $this->runHostCommandChecked('docker run --rm docker:24-dind sh -lc "cat ' . $cliPath . '" | docker exec -i ' . escapeshellarg($container) . ' sh -lc "cat > /usr/local/bin/docker && chmod +x /usr/local/bin/docker"'); + if ($cp['rc'] !== 0) { + throw new Exception('Failed to provide docker CLI to test container: ' . trim($cp['out'])); + } + $this->execInContainerChecked($container, 'chmod +x /usr/local/bin/docker'); + $ver = $this->execInContainerChecked($container, 'docker --version'); + if ($ver['rc'] !== 0) { + throw new Exception('Docker CLI not available in test container'); + } + + $prelude = <<<'SH' +set -euo pipefail +set -x +CONTAINER_NAME="${CONTAINER_NAME:-amnezia-awg}" +wg() { + if docker ps --format '{{.Names}}' | grep -qx "$CONTAINER_NAME"; then + docker exec -i "$CONTAINER_NAME" wg "$@" + else + docker pull -q amneziavpn/amnezia-wg:latest >/dev/null 2>&1 || true + docker run --rm -i --privileged --cap-add=NET_ADMIN amneziavpn/amnezia-wg:latest wg "$@" + fi +} +SH; + $wrapped = $prelude . "\n" . $script; + $runScript = $this->execInContainerChecked($container, $wrapped); + if ($runScript['rc'] !== 0) { + throw new Exception("Install script failed: " . trim($runScript['out'])); + } + $output = $runScript['out']; + + $extracted = $this->extractValuesFromOutput($output); + + $variables = $this->getProtocolVariables($id); + foreach ($extracted as $k => $v) { + if (array_key_exists($k, $variables)) { + $variables[$k] = $v; + } + } + + $preview = ProtocolService::generateProtocolOutput($protocol, $variables); + + // Cleanup test containers: proto-test and AWG if created + $this->runHostCommand('docker rm -f ' . escapeshellarg($container) . ' >/dev/null 2>&1 || true'); + $this->runHostCommand('docker rm -f amnezia-awg >/dev/null 2>&1 || true'); + + // Clean buffer and output JSON + if (ob_get_level()) + ob_end_clean(); + echo json_encode([ + 'success' => true, + 'logs' => $output, + 'extracted' => $extracted, + 'preview' => $preview + ]); + + } catch (Exception $e) { + // Clean buffer and output error JSON + if (ob_get_level()) + ob_end_clean(); + echo json_encode([ + 'success' => false, + 'error' => $e->getMessage() + ]); + } + } + + public function apiTestInstallProtocolStream(int $id): void + { + requireAdmin(); + + // Suppress all errors and warnings to prevent HTML output + @ini_set('display_errors', '0'); + error_reporting(0); + + header('Content-Type: text/event-stream'); + header('Cache-Control: no-cache'); + header('Connection: keep-alive'); + @ini_set('output_buffering', 'off'); + @ini_set('zlib.output_compression', '0'); + @ob_implicit_flush(true); + // CRITICAL: Use ob_end_clean() instead of ob_end_flush() to DISCARD any + // buffered warnings/errors that would corrupt the SSE stream + while (ob_get_level()) { + @ob_end_clean(); + } + + $send = function (array $data) { + echo 'data: ' . json_encode($data) . "\n\n"; + @flush(); + }; + + try { + $protocol = $this->getProtocolById($id); + if (!$protocol) { + $send(['type' => 'error', 'error' => 'Protocol not found']); + return; + } + + $script = trim($protocol['install_script'] ?? ''); + if ($script === '') { + $send(['type' => 'error', 'error' => 'Install script is empty']); + return; + } + + $container = 'proto-test-' . $id; + $send(['type' => 'start']); + + $send(['type' => 'cmd', 'cmd' => 'docker rm -f ' . $container]); + $rm = $this->runHostCommandChecked('docker rm -f ' . escapeshellarg($container) . ' >/dev/null 2>&1 || true'); + $send(['type' => 'cmd_done', 'rc' => $rm['rc']]); + + $cmdRun = 'docker run --privileged -d --name ' . $container . ' ubuntu:22.04 sleep infinity'; + $send(['type' => 'cmd', 'cmd' => $cmdRun]); + $run = $this->runHostCommandChecked('docker run --privileged -d -v /var/run/docker.sock:/var/run/docker.sock --name ' . escapeshellarg($container) . ' ubuntu:22.04 sleep infinity'); + if ($run['rc'] !== 0) { + $send(['type' => 'error', 'error' => 'Docker not accessible: ' . trim($run['out'])]); + return; + } + $send(['type' => 'cmd_done', 'rc' => 0]); + + $send(['type' => 'cmd', 'cmd' => 'provide docker cli']); + $cliPath = '/usr/local/bin/docker'; + $send(['type' => 'cmd', 'cmd' => 'provide docker cli from docker:24-dind image']); + $try1 = $this->runHostCommandChecked('docker run --rm docker:24-dind sh -lc "cat ' . $cliPath . '"'); + if ($try1['rc'] !== 0 || $try1['out'] === '') { + $cliPath = '/usr/bin/docker'; + } + $cp = $this->runHostCommandChecked('docker run --rm docker:24-dind sh -lc "cat ' . $cliPath . '" | docker exec -i ' . escapeshellarg($container) . ' sh -lc "cat > /usr/local/bin/docker && chmod +x /usr/local/bin/docker"'); + if ($cp['rc'] !== 0) { + $send(['type' => 'error', 'error' => 'Failed to provide docker CLI to test container: ' . trim($cp['out'])]); + return; + } + $this->execInContainerChecked($container, 'chmod +x /usr/local/bin/docker'); + $ver = $this->execInContainerChecked($container, 'docker --version'); + $send(['type' => 'out', 'line' => $ver['out']]); + if ($ver['rc'] !== 0) { + $send(['type' => 'error', 'error' => 'Docker CLI not available in test container']); + return; + } + + $prelude = <<<'SH' +set -euo pipefail +set -x +CONTAINER_NAME="${CONTAINER_NAME:-amnezia-awg}" +wg() { + if docker ps --format '{{.Names}}' | grep -qx "$CONTAINER_NAME"; then + docker exec -i "$CONTAINER_NAME" wg "$@" + else + docker pull -q amneziavpn/amnezia-wg:latest >/dev/null 2>&1 || true + docker run --rm -i --privileged --cap-add=NET_ADMIN amneziavpn/amnezia-wg:latest wg "$@" + fi +} +SH; + $wrapped = $prelude . "\n" . $script; + $send(['type' => 'cmd', 'cmd' => 'install_script']); + $runScript = $this->execInContainerChecked($container, $wrapped); + $outLines = explode("\n", trim($runScript['out'])); + foreach ($outLines as $line) { + if ($line !== '') + $send(['type' => 'out', 'line' => $line]); + } + if ($runScript['rc'] !== 0) { + $send(['type' => 'error', 'error' => 'Install script failed: ' . trim($runScript['out'])]); + $this->runHostCommandChecked('docker rm -f ' . escapeshellarg($container) . ' >/dev/null 2>&1 || true'); + return; + } + $send(['type' => 'cmd_done', 'rc' => 0]); + + $extracted = $this->extractValuesFromOutput($runScript['out']); + $variables = $this->getProtocolVariables($id); + // Merge all extracted variables (not just existing ones) + $variables = array_merge($variables, $extracted); + $preview = ProtocolService::generateProtocolOutput($protocol, $variables); + $send(['type' => 'preview', 'preview' => $preview]); + + $this->runHostCommandChecked('docker rm -f ' . escapeshellarg($container) . ' >/dev/null 2>&1 || true'); + $this->runHostCommandChecked('docker rm -f amnezia-awg >/dev/null 2>&1 || true'); + $send(['type' => 'done']); + + } catch (Exception $e) { + echo 'data: ' . json_encode(['type' => 'error', 'error' => $e->getMessage()]) . "\n\n"; + @flush(); + } + } + + public function apiTestUninstallProtocolStream(int $id): void + { + requireAdmin(); + header('Content-Type: text/event-stream'); + header('Cache-Control: no-cache'); + header('Connection: keep-alive'); + @ini_set('output_buffering', 'off'); + @ini_set('zlib.output_compression', '0'); + @ob_implicit_flush(true); + @ob_end_flush(); + + $send = function (array $data) { + echo 'data: ' . json_encode($data) . "\n\n"; + @flush(); + }; + + try { + $protocol = $this->getProtocolById($id); + if (!$protocol) { + $send(['type' => 'error', 'error' => 'Protocol not found']); + return; + } + + $installScript = trim($protocol['install_script'] ?? ''); + $uninstallScript = trim($protocol['uninstall_script'] ?? ''); + + if ($installScript === '') { + $send(['type' => 'error', 'error' => 'Install script is empty (required for setup)']); + return; + } + if ($uninstallScript === '') { + $send(['type' => 'error', 'error' => 'Uninstall script is empty']); + return; + } + + // Normalize uninstall script if needed? For now assume it's fine. + + $container = 'proto-test-uninstall-' . $id; + $send(['type' => 'start']); + + // 1. Setup container + $send(['type' => 'cmd', 'cmd' => 'Setting up test environment...']); + $this->runHostCommandChecked('docker rm -f ' . escapeshellarg($container) . ' >/dev/null 2>&1 || true'); + + $run = $this->runHostCommandChecked('docker run --privileged -d -v /var/run/docker.sock:/var/run/docker.sock --name ' . escapeshellarg($container) . ' ubuntu:22.04 sleep infinity'); + if ($run['rc'] !== 0) { + $send(['type' => 'error', 'error' => 'Docker not accessible: ' . trim($run['out'])]); + return; + } + + // Provide docker CLI + $cliPath = '/usr/local/bin/docker'; + $try1 = $this->runHostCommandChecked('docker run --rm docker:24-dind sh -lc "cat ' . $cliPath . '"'); + if ($try1['rc'] !== 0 || $try1['out'] === '') { + $cliPath = '/usr/bin/docker'; + } + $cp = $this->runHostCommandChecked('docker run --rm docker:24-dind sh -lc "cat ' . $cliPath . '" | docker exec -i ' . escapeshellarg($container) . ' sh -lc "cat > /usr/local/bin/docker && chmod +x /usr/local/bin/docker"'); + if ($cp['rc'] !== 0) { + $send(['type' => 'error', 'error' => 'Failed to provide docker CLI']); + return; + } + $this->execInContainerChecked($container, 'chmod +x /usr/local/bin/docker'); + + $prelude = <<<'SH' +set -euo pipefail +set -x +CONTAINER_NAME="${CONTAINER_NAME:-amnezia-awg}" +wg() { + if docker ps --format '{{.Names}}' | grep -qx "$CONTAINER_NAME"; then + docker exec -i "$CONTAINER_NAME" wg "$@" + else + docker pull -q amneziavpn/amnezia-wg:latest >/dev/null 2>&1 || true + docker run --rm -i --privileged --cap-add=NET_ADMIN amneziavpn/amnezia-wg:latest wg "$@" + fi +} +SH; + + // 2. Run Install Script + $send(['type' => 'cmd', 'cmd' => 'Running installation script...']); + $wrappedInstall = $prelude . "\n" . $installScript; + $runInstall = $this->execInContainerChecked($container, $wrappedInstall); + + if ($runInstall['rc'] !== 0) { + $send(['type' => 'error', 'error' => 'Setup (install) failed: ' . trim($runInstall['out'])]); + $this->runHostCommandChecked('docker rm -f ' . escapeshellarg($container) . ' >/dev/null 2>&1 || true'); + return; + } + $send(['type' => 'out', 'line' => 'Installation successful. Now running uninstall...']); + + // 3. Run Uninstall Script + $send(['type' => 'cmd', 'cmd' => 'Running uninstallation script...']); + $wrappedUninstall = $prelude . "\n" . $uninstallScript; + $runUninstall = $this->execInContainerChecked($container, $wrappedUninstall); + + $outLines = explode("\n", trim($runUninstall['out'])); + foreach ($outLines as $line) { + if ($line !== '') + $send(['type' => 'out', 'line' => $line]); + } + + if ($runUninstall['rc'] !== 0) { + $send(['type' => 'error', 'error' => 'Uninstall script failed: ' . trim($runUninstall['out'])]); + } else { + $send(['type' => 'cmd_done', 'rc' => 0]); + $send(['type' => 'out', 'line' => 'Uninstallation completed successfully.']); + } + + // Cleanup + $this->runHostCommandChecked('docker rm -f ' . escapeshellarg($container) . ' >/dev/null 2>&1 || true'); + $this->runHostCommandChecked('docker rm -f amnezia-awg >/dev/null 2>&1 || true'); // Cleanup potential leftover + $send(['type' => 'done']); + + } catch (Exception $e) { + echo 'data: ' . json_encode(['type' => 'error', 'error' => $e->getMessage()]) . "\n\n"; + @flush(); + } + } + + private function runHostCommand(string $cmd): void + { + $out = shell_exec($cmd); + } + + private function runHostCommandChecked(string $cmd): array + { + $lines = []; + $rc = 0; + exec($cmd . ' 2>&1', $lines, $rc); + return ['out' => implode("\n", $lines), 'rc' => $rc]; + } + + private function execInContainer(string $container, string $cmd): string + { + $full = 'docker exec ' . escapeshellarg($container) . ' bash -lc ' . escapeshellarg($cmd); + $out = shell_exec($full . ' 2>&1'); + return $out ?? ''; + } + + private function execInContainerChecked(string $container, string $cmd): array + { + $lines = []; + $rc = 0; + $full = 'docker exec ' . escapeshellarg($container) . ' bash -lc ' . escapeshellarg($cmd); + exec($full . ' 2>&1', $lines, $rc); + return ['out' => implode("\n", $lines), 'rc' => $rc]; + } + + private function normalizeAwgInstallScript(string $script): string + { + // Script in DB already has #!/bin/bash and set -euo pipefail + // Just return it as-is since variables are already defined in the script + return $script; + } + + private function extractValuesFromOutput(string $output): array + { + $res = []; + + // Extract port + if (preg_match('/Port:\s*(\d+)/i', $output, $m)) { + $res['server_port'] = $m[1]; + } + + // Extract server public key + if (preg_match('/Server Public Key:\s*([A-Za-z0-9+\/=]+)/i', $output, $m)) { + $res['server_public_key'] = $m[1]; + } + + // Extract preshared key + if (preg_match('/PresharedKey\s*=\s*([A-Za-z0-9+\/=]+)/i', $output, $m)) { + $res['preshared_key'] = $m[1]; + } + + // Extract subnet (format: "Subnet: 10.8.1.1/24") + if (preg_match('/Subnet:\s*([0-9.]+)\/(\d+)/i', $output, $m)) { + $res['subnet_ip'] = $m[1]; + $res['subnet_cidr'] = $m[2]; + } + + // Extract password (for non-WireGuard protocols) + if (preg_match('/Password:\s*(\S+)/i', $output, $m)) { + $res['password'] = $m[1]; + } + + // Extract method (for protocols like Shadowsocks) + if (preg_match('/Method:\s*(\S+)/i', $output, $m)) { + $res['method'] = $m[1]; + } + + // Extract client ID (for protocols that use it) + if (preg_match('/ClientID\s*:\s*([0-9a-fA-F-]+)/i', $output, $m)) { + $res['client_id'] = $m[1]; + } + + // Generic variable extraction (Variable: KEY=VALUE) + if (preg_match_all('/Variable:\\s*([a-zA-Z0-9_]+)=(.*)/', $output, $matches, PREG_SET_ORDER)) { + foreach ($matches as $m) { + $key = trim($m[1]); + $val = trim($m[2]); + // Remove surrounding quotes if present + $val = trim($val, "'\""); + $res[$key] = $val; + } + } + + return $res; + } + + private function getProtocolVariables(int $protocolId): array + { + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT variable_name, COALESCE(default_value, "") as val FROM protocol_variables WHERE protocol_id = ?'); + $stmt->execute([$protocolId]); + $vars = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $vars[$row['variable_name']] = $row['val'] ?? ''; + } + return $vars; + } + /** + * Database methods + */ + + private function getAllProtocols(): array + { + return ProtocolService::getAllProtocolsWithStats(); + } + + private function getProtocolById(int $id): ?array + { + $pdo = DB::conn(); + $stmt = $pdo->prepare(' + SELECT p.*, + COUNT(DISTINCT sp.server_id) as server_count + FROM protocols p + LEFT JOIN server_protocols sp ON p.id = sp.protocol_id + WHERE p.id = ? + GROUP BY p.id + '); + $stmt->execute([$id]); + $protocol = $stmt->fetch(PDO::FETCH_ASSOC); + + return $protocol ?: null; + } + + private function createProtocol(array $data): int + { + $pdo = DB::conn(); + $stmt = $pdo->prepare(' + INSERT INTO protocols (name, slug, description, install_script, uninstall_script, password_command, output_template, ubuntu_compatible, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + '); + $stmt->execute([ + $data['name'], + $data['slug'], + $data['description'], + $data['install_script'], + $data['uninstall_script'], + $data['password_command'] ?? '', + $data['output_template'], + $data['ubuntu_compatible'], + $data['is_active'], + $data['created_at'], + $data['updated_at'] + ]); + + return (int) $pdo->lastInsertId(); + } + + private function updateProtocol(int $id, array $data): void + { + $setParts = []; + $values = []; + + foreach ($data as $key => $value) { + $setParts[] = "$key = ?"; + $values[] = $value; + } + + $values[] = $id; + $sql = 'UPDATE protocols SET ' . implode(', ', $setParts) . ' WHERE id = ?'; + + $pdo = DB::conn(); + $stmt = $pdo->prepare($sql); + $stmt->execute($values); + } + + private function deleteProtocol(int $id): void + { + $pdo = DB::conn(); + $stmt = $pdo->prepare('DELETE FROM protocols WHERE id = ?'); + $stmt->execute([$id]); + } + + private function isSlugExists(string $slug, ?int $excludeId = null): bool + { + $pdo = DB::conn(); + + if ($excludeId) { + $stmt = $pdo->prepare('SELECT COUNT(*) FROM protocols WHERE slug = ? AND id != ?'); + $stmt->execute([$slug, $excludeId]); + } else { + $stmt = $pdo->prepare('SELECT COUNT(*) FROM protocols WHERE slug = ?'); + $stmt->execute([$slug]); + } + + return (bool) $stmt->fetchColumn(); + } + + private function isProtocolUsed(int $id): bool + { + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT COUNT(*) FROM server_protocols WHERE protocol_id = ?'); + $stmt->execute([$id]); + + return (bool) $stmt->fetchColumn(); + } +} \ No newline at end of file diff --git a/controllers/ScenarioController.php b/controllers/ScenarioController.php new file mode 100644 index 0000000..8e8a232 --- /dev/null +++ b/controllers/ScenarioController.php @@ -0,0 +1,375 @@ + $scenarios, + 'section' => 'scenarios' + ]); + } + + /** + * View single scenario details + * GET /admin/scenario/:id + */ + public function viewScenario($id) { + requireAdmin(); + + $scenario = InstallProtocolManager::getById((int)$id); + if (!$scenario) { + http_response_code(404); + View::render('404.twig'); + return; + } + + $definition = $scenario['definition'] ?? []; + + View::render('settings/scenario_view.twig', [ + 'scenario' => $scenario, + 'definition' => $definition, + 'section' => 'scenarios' + ]); + } + + /** + * Show form to create new scenario + * GET /admin/scenario/create + */ + public function createScenarioForm() { + requireAdmin(); + + $templateDefinition = [ + 'engine' => 'shell', + 'metadata' => [ + 'container_name' => 'custom-container', + 'config_path' => '/opt/amnezia/custom' + ], + 'scripts' => [ + 'detect' => 'echo \'{"status":"absent","message":"Custom protocol not found"}\'', + 'install' => 'echo \'{"success":true,"message":"Custom protocol installed"}\'', + 'restore' => 'echo \'{"success":true,"message":"Custom protocol restored"}\'' + ] + ]; + + View::render('settings/scenario_form.twig', [ + 'scenario' => null, + 'templateDefinition' => json_encode($templateDefinition, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), + 'section' => 'scenarios' + ]); + } + + /** + * Show form to edit existing scenario + * GET /admin/scenario/:id/edit + */ + public function editScenarioForm($id) { + requireAdmin(); + + $scenario = InstallProtocolManager::getById((int)$id); + if (!$scenario) { + http_response_code(404); + View::render('404.twig'); + return; + } + + $definitionJson = json_encode($scenario['definition'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + View::render('settings/scenario_form.twig', [ + 'scenario' => $scenario, + 'templateDefinition' => $definitionJson, + 'section' => 'scenarios' + ]); + } + + /** + * Save scenario (create or update) + * POST /admin/scenario + */ + public function saveScenario() { + requireAdmin(); + + $data = $_POST; + + // Validate required fields + if (empty($data['slug']) || empty($data['name'])) { + header('Content-Type: application/json'); + http_response_code(400); + echo json_encode([ + 'success' => false, + 'message' => 'Slug and name are required' + ]); + return; + } + + // Parse definition JSON + if (!empty($data['definition'])) { + if (is_string($data['definition'])) { + $definition = json_decode($data['definition'], true); + if (json_last_error() !== JSON_ERROR_NONE) { + header('Content-Type: application/json'); + http_response_code(400); + echo json_encode([ + 'success' => false, + 'message' => 'Invalid JSON in definition: ' . json_last_error_msg() + ]); + return; + } + } else { + $definition = $data['definition']; + } + } else { + $definition = []; + } + + // Validate definition structure + if (empty($definition['engine'])) { + header('Content-Type: application/json'); + http_response_code(400); + echo json_encode([ + 'success' => false, + 'message' => 'Definition must have "engine" field' + ]); + return; + } + + $saveData = [ + 'slug' => $data['slug'], + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'definition' => $definition, + 'is_active' => isset($data['is_active']) ? (int)$data['is_active'] : 1 + ]; + + if (!empty($data['id'])) { + $saveData['id'] = (int)$data['id']; + } + + try { + $id = InstallProtocolManager::save($saveData); + + header('Content-Type: application/json'); + echo json_encode([ + 'success' => true, + 'id' => $id, + 'message' => 'Scenario saved successfully', + 'redirect' => '/admin/scenarios' + ]); + } catch (Exception $e) { + header('Content-Type: application/json'); + http_response_code(500); + echo json_encode([ + 'success' => false, + 'message' => 'Error saving scenario: ' . $e->getMessage() + ]); + } + } + + /** + * Delete scenario + * DELETE /admin/scenario/:id or POST /admin/scenario/:id/delete + */ + public function deleteScenario($id) { + requireAdmin(); + + $id = (int)$id; + + // Prevent deletion of default scenario + $scenario = InstallProtocolManager::getById($id); + if (!$scenario) { + header('Content-Type: application/json'); + http_response_code(404); + echo json_encode([ + 'success' => false, + 'message' => 'Scenario not found' + ]); + return; + } + + if ($scenario['slug'] === InstallProtocolManager::getDefaultSlug()) { + header('Content-Type: application/json'); + http_response_code(400); + echo json_encode([ + 'success' => false, + 'message' => 'Cannot delete default scenario' + ]); + return; + } + + try { + InstallProtocolManager::delete($id); + + header('Content-Type: application/json'); + echo json_encode([ + 'success' => true, + 'message' => 'Scenario deleted successfully', + 'redirect' => '/admin/scenarios' + ]); + } catch (Exception $e) { + header('Content-Type: application/json'); + http_response_code(500); + echo json_encode([ + 'success' => false, + 'message' => 'Error deleting scenario: ' . $e->getMessage() + ]); + } + } + + /** + * Test scenario script (detection) + * POST /admin/scenario/:id/test + */ + public function testScenario($id) { + requireAdmin(); + + $scenario = InstallProtocolManager::getById((int)$id); + if (!$scenario) { + header('Content-Type: application/json'); + http_response_code(404); + echo json_encode([ + 'success' => false, + 'message' => 'Scenario not found' + ]); + return; + } + + $serverId = (int)($_POST['server_id'] ?? 0); + if (!$serverId) { + header('Content-Type: application/json'); + http_response_code(400); + echo json_encode([ + 'success' => false, + 'message' => 'Server ID required' + ]); + return; + } + + $server = new VpnServer($serverId); + if (!$server->getData()) { + header('Content-Type: application/json'); + http_response_code(404); + echo json_encode([ + 'success' => false, + 'message' => 'Server not found' + ]); + return; + } + + try { + // Test SSH connection first + if (!$server->testConnection()) { + throw new Exception('SSH connection to server failed'); + } + + // Run detection script + $result = InstallProtocolManager::runDetection($server, $scenario); + + header('Content-Type: application/json'); + echo json_encode([ + 'success' => true, + 'result' => $result + ]); + } catch (Exception $e) { + header('Content-Type: application/json'); + http_response_code(500); + echo json_encode([ + 'success' => false, + 'message' => 'Error testing scenario: ' . $e->getMessage() + ]); + } + } + + /** + * Export scenario as JSON + * GET /admin/scenario/:id/export + */ + public function exportScenario($id) { + requireAdmin(); + + $scenario = InstallProtocolManager::getById((int)$id); + if (!$scenario) { + http_response_code(404); + return; + } + + header('Content-Type: application/json'); + header('Content-Disposition: attachment; filename="scenario-' . $scenario['slug'] . '-' . date('Y-m-d') . '.json"'); + + // Remove database IDs from export + unset($scenario['id']); + + echo json_encode($scenario, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + /** + * Import scenario from JSON + * POST /admin/scenario/import + */ + public function importScenario() { + requireAdmin(); + + $file = $_FILES['file'] ?? null; + if (!$file || $file['error'] !== UPLOAD_ERR_OK) { + header('Content-Type: application/json'); + http_response_code(400); + echo json_encode([ + 'success' => false, + 'message' => 'No file provided' + ]); + return; + } + + $contents = file_get_contents($file['tmp_name']); + $scenario = json_decode($contents, true); + + if (!is_array($scenario)) { + header('Content-Type: application/json'); + http_response_code(400); + echo json_encode([ + 'success' => false, + 'message' => 'Invalid JSON file' + ]); + return; + } + + try { + // Remove ID so it creates a new entry + unset($scenario['id']); + + // Ensure required fields exist + if (empty($scenario['slug']) || empty($scenario['name'])) { + throw new Exception('Imported scenario must have slug and name'); + } + + $id = InstallProtocolManager::save($scenario); + + header('Content-Type: application/json'); + echo json_encode([ + 'success' => true, + 'id' => $id, + 'message' => 'Scenario imported successfully', + 'redirect' => '/admin/scenarios' + ]); + } catch (Exception $e) { + header('Content-Type: application/json'); + http_response_code(500); + echo json_encode([ + 'success' => false, + 'message' => 'Error importing scenario: ' . $e->getMessage() + ]); + } + } +} diff --git a/controllers/SettingsController.php b/controllers/SettingsController.php index 0ac6f35..ea6c227 100644 --- a/controllers/SettingsController.php +++ b/controllers/SettingsController.php @@ -13,11 +13,48 @@ class SettingsController { $stats = $this->getTranslationStats(); $users = $this->getAllUsers(); $apiKey = $this->getApiKey('openrouter'); - + + // LDAP data for embedded tab + $stmt = $this->pdo->query("SELECT * FROM ldap_configs WHERE id = 1"); + $config = $stmt->fetch() ?: []; + $stmt = $this->pdo->query("SELECT * FROM ldap_group_mappings ORDER BY ldap_group"); + $mappings = $stmt->fetchAll(); + + // Protocols data for embedded tab (new management) + $protocols = ProtocolService::getAllProtocolsWithStats(); + $selectedId = isset($_GET['id']) ? (int)$_GET['id'] : null; + $isNew = isset($_GET['new']); + $editing = null; + if (!$isNew) { + if ($selectedId) { + try { + $editing = ProtocolService::getProtocolWithDetails($selectedId); + } catch (Exception $e) { + $editing = null; + } + } + if (!$editing && !empty($protocols)) { + $firstId = (int)($protocols[0]['id'] ?? 0); + if ($firstId) { + try { $editing = ProtocolService::getProtocolWithDetails($firstId); } catch (Exception $e) { $editing = null; } + } + } + } + $definitionPretty = json_encode([], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $data = [ 'translation_stats' => $stats, 'users' => $users, - 'openrouter_key' => $apiKey + 'openrouter_key' => $apiKey, + // LDAP + 'config' => $config, + 'mappings' => $mappings, + // Protocols + 'protocols' => $protocols, + 'editing' => $editing, + 'definition_json' => $definitionPretty, + 'is_new' => $isNew, + 'default_slug' => isset($editing['slug']) ? $editing['slug'] : (isset($protocols[0]['slug']) ? $protocols[0]['slug'] : 'amnezia-wg'), ]; // Check for session messages @@ -29,6 +66,15 @@ class SettingsController { $data['error'] = $_SESSION['settings_error']; unset($_SESSION['settings_error']); } + // Also pick up protocol messages if present + if (isset($_SESSION['protocol_success']) && !isset($data['success'])) { + $data['success'] = $_SESSION['protocol_success']; + unset($_SESSION['protocol_success']); + } + if (isset($_SESSION['protocol_error']) && !isset($data['error'])) { + $data['error'] = $_SESSION['protocol_error']; + unset($_SESSION['protocol_error']); + } View::render('settings.twig', $data); } @@ -79,6 +125,29 @@ class SettingsController { exit; } + public function updateProfile() { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + header('Location: /settings'); + exit; + } + + $user = Auth::user(); + $displayName = trim($_POST['display_name'] ?? ''); + + if ($displayName === '') { + $_SESSION['settings_error'] = 'Display name cannot be empty'; + header('Location: /settings#profile'); + exit; + } + + $stmt = $this->pdo->prepare("UPDATE users SET display_name = ? WHERE id = ?"); + $stmt->execute([$displayName, $user['id']]); + + $_SESSION['settings_success'] = 'Profile updated'; + header('Location: /settings#profile'); + exit; + } + public function addUser() { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /settings'); @@ -422,7 +491,7 @@ class SettingsController { ]); $_SESSION['settings_success'] = 'LDAP settings saved successfully'; - header('Location: /settings/ldap'); + header('Location: /settings#ldap'); exit; } diff --git a/docker-compose.yml b/docker-compose.yml index b6aea98..5e108e6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,13 +12,13 @@ services: MYSQL_USER: ${DB_USERNAME:-amnezia} MYSQL_PASSWORD: ${DB_PASSWORD:-amnezia} ports: - - "3307:3306" + - "3309:3306" volumes: - db_data:/var/lib/mysql - ./migrations:/docker-entrypoint-initdb.d - ./my.cnf:/etc/mysql/conf.d/my.cnf:ro healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + test: [ "CMD", "mysqladmin", "ping", "-h", "localhost" ] interval: 10s timeout: 5s retries: 10 @@ -32,6 +32,8 @@ services: depends_on: db: condition: service_healthy + dind: + condition: service_started env_file: - .env environment: @@ -42,10 +44,24 @@ services: DB_DATABASE: ${DB_DATABASE:-amnezia_panel} DB_USERNAME: ${DB_USERNAME:-amnezia} DB_PASSWORD: ${DB_PASSWORD:-amnezia} + DOCKER_HOST: tcp://dind:2375 volumes: - ./:/var/www/html ports: - "8082:80" + dind: + image: docker:24-dind + container_name: amnezia-panel-dind + privileged: true + restart: unless-stopped + environment: + DOCKER_TLS_CERTDIR: "" + ports: + - "2375:2375" + volumes: + - dind_data:/var/lib/docker + volumes: db_data: + dind_data: diff --git a/inc/BackupLibrary.php b/inc/BackupLibrary.php new file mode 100644 index 0000000..bacf6e1 --- /dev/null +++ b/inc/BackupLibrary.php @@ -0,0 +1,477 @@ +> + */ + public static function listAvailable(bool $registerTokens = false): array { + if (!isset($_SESSION['backup_library']) || !is_array($_SESSION['backup_library'])) { + $_SESSION['backup_library'] = []; + } + + if (!isset($_SESSION['backup_uploads']) || !is_array($_SESSION['backup_uploads'])) { + $_SESSION['backup_uploads'] = []; + } + + $results = []; + foreach (self::getDirectories() as $directory) { + $files = glob($directory . DIRECTORY_SEPARATOR . '*.{backup,json}', GLOB_BRACE) ?: []; + foreach ($files as $filePath) { + if (!is_file($filePath) || !is_readable($filePath)) { + continue; + } + + try { + $parsed = BackupParser::parseMetadata($filePath); + } catch (Throwable $e) { + // Skip invalid backup file but log for debugging + error_log('Backup parse failed for ' . $filePath . ': ' . $e->getMessage()); + continue; + } + + if (empty($parsed['servers'])) { + continue; + } + + $token = hash('sha256', $filePath); + if ($registerTokens || !isset($_SESSION['backup_library'][$token])) { + $_SESSION['backup_library'][$token] = $filePath; + } + + $results[] = [ + 'token' => $token, + 'file_name' => basename($filePath), + 'type' => $parsed['type'], + 'origin' => 'filesystem', + 'servers' => self::mapServerMetadata($parsed['servers'] ?? []), + ]; + } + } + + foreach ($_SESSION['backup_uploads'] as $token => $upload) { + $results[] = [ + 'token' => $token, + 'file_name' => $upload['file_name'], + 'type' => $upload['type'], + 'origin' => 'upload', + 'servers' => self::mapServerMetadata($upload['data']['servers'] ?? []), + ]; + } + + usort($results, function ($a, $b) { + return strcmp($a['file_name'], $b['file_name']); + }); + + return $results; + } + + /** + * Load full server data from backup using the session token and server index. + * + * @param string $token Backup token + * @param int $serverIndex Index of the server inside backup file + * @return array + * @throws Exception When token or server not found + */ + public static function loadServer(string $token, int $serverIndex): array { + $path = $_SESSION['backup_library'][$token] ?? null; + if ($path) { + if (!is_file($path) || !is_readable($path)) { + throw new Exception('Selected backup is not available'); + } + + $parsed = BackupParser::parse($path); + if (!isset($parsed['servers'][$serverIndex])) { + throw new Exception('Requested server not found in backup'); + } + + $server = $parsed['servers'][$serverIndex]; + $server['source_file'] = $path; + $server['type'] = $parsed['type']; + + return $server; + } + + $upload = $_SESSION['backup_uploads'][$token] ?? null; + if ($upload) { + $parsed = $upload['data']; + if (!isset($parsed['servers'][$serverIndex])) { + throw new Exception('Requested server not found in uploaded backup'); + } + + $server = $parsed['servers'][$serverIndex]; + $server['source_file'] = $upload['path']; + $server['type'] = $parsed['type']; + + return $server; + } + + throw new Exception('Selected backup is not available'); + } + + /** + * Get list of directories that may contain backup files. + * + * @return array + */ + private static function getDirectories(): array { + $directories = []; + + $default = realpath(__DIR__ . '/../backups'); + if ($default) { + $directories[] = $default; + } + + $envDirs = Config::get('BACKUP_LIBRARY_DIRS'); + if (!empty($envDirs)) { + foreach (preg_split('/[;,]+/', $envDirs) as $rawDir) { + $normalized = trim($rawDir); + if ($normalized === '') { + continue; + } + if (is_dir($normalized)) { + $real = realpath($normalized); + if ($real) { + $directories[] = $real; + } + } + } + } + + $home = getenv('HOME'); + if ($home) { + $candidate = realpath($home . DIRECTORY_SEPARATOR . 'Downloads' . DIRECTORY_SEPARATOR . 'infosave'); + if ($candidate) { + $directories[] = $candidate; + } + } + + // Remove duplicates + $directories = array_values(array_unique($directories)); + + return $directories; + } + + /** + * Register uploaded backup file and return metadata for UI. + */ + public static function registerUploaded(string $fileName, string $storedPath, array $parsed): array { + if (!isset($_SESSION['backup_uploads']) || !is_array($_SESSION['backup_uploads'])) { + $_SESSION['backup_uploads'] = []; + } + + $token = 'upload_' . bin2hex(random_bytes(16)); + + $_SESSION['backup_uploads'][$token] = [ + 'file_name' => $fileName, + 'path' => $storedPath, + 'type' => $parsed['type'], + 'data' => $parsed, + ]; + + return [ + 'token' => $token, + 'file_name' => $fileName, + 'type' => $parsed['type'], + 'origin' => 'upload', + 'servers' => self::mapServerMetadata($parsed['servers'] ?? []), + ]; + } + + /** + * Check whether provided token belongs to uploaded backup. + */ + public static function isUploadToken(string $token): bool { + return isset($_SESSION['backup_uploads'][$token]); + } + + /** + * Retrieve stored upload metadata for a token. + */ + public static function getUploadRecord(string $token): ?array { + if (!isset($_SESSION['backup_uploads'][$token])) { + return null; + } + + return $_SESSION['backup_uploads'][$token]; + } + + /** + * Get lightweight server metadata for an uploaded backup token. + */ + public static function getUploadServers(string $token): array { + $upload = self::getUploadRecord($token); + if (!$upload) { + return []; + } + + return self::mapServerMetadata($upload['data']['servers'] ?? []); + } + + /** + * Forget uploaded backup token and remove temporary file. + */ + public static function forgetUpload(string $token): void { + $upload = $_SESSION['backup_uploads'][$token] ?? null; + if (!$upload) { + return; + } + + unset($_SESSION['backup_uploads'][$token]); + + $path = $upload['path'] ?? null; + if ($path && is_file($path)) { + @unlink($path); + } + } + + /** + * Map server metadata for front-end lists. + */ + public static function mapServerMetadata($servers): array { + if (!is_array($servers)) { + return []; + } + + return array_map(function ($server, $index) { + return [ + 'index' => $index, + 'label' => $server['label'] ?? ('Server #' . ($index + 1)), + 'host' => $server['host'] ?? null, + 'vpn_port' => $server['vpn_port'] ?? null, + 'client_count' => isset($server['clients']) && is_array($server['clients']) + ? count($server['clients']) + : 0 + ]; + }, $servers, array_keys($servers)); + } +} + +/** + * Parse backup files and normalize into a single representation. + */ +class BackupParser { + /** + * Parse backup file metadata without storing heavy payloads. + * + * @param string $path + * @return array + */ + public static function parseMetadata(string $path): array { + $parsed = self::parse($path); + + // Strip client details to keep metadata light + $parsed['servers'] = array_map(function ($server) { + $server['clients'] = $server['clients'] ?? []; + return $server; + }, $parsed['servers']); + + return $parsed; + } + + /** + * Parse backup file fully. + * + * @param string $path + * @return array + * @throws Exception On parse errors + */ + public static function parse(string $path): array { + $contents = file_get_contents($path); + if ($contents === false) { + throw new Exception('Unable to read backup file'); + } + + $decoded = json_decode($contents, true); + if (!is_array($decoded)) { + throw new Exception('Backup file is not valid JSON'); + } + + if (isset($decoded['server']) && isset($decoded['clients'])) { + return self::parsePanelBackup($decoded); + } + + if (isset($decoded['Servers/serversList'])) { + return self::parseAmneziaBackup($decoded); + } + + throw new Exception('Unsupported backup format'); + } + + /** + * Parse backup produced by the Amnezia mobile/desktop application (.backup files). + */ + private static function parseAmneziaBackup(array $decoded): array { + $serversRaw = json_decode($decoded['Servers/serversList'] ?? '[]', true); + if (!is_array($serversRaw)) { + throw new Exception('Invalid Amnezia backup payload'); + } + + $servers = []; + foreach ($serversRaw as $serverIndex => $serverEntry) { + $containers = $serverEntry['containers'] ?? []; + foreach ($containers as $container) { + if (($container['container'] ?? '') !== 'amnezia-awg') { + continue; + } + + $awg = $container['awg'] ?? []; + if (empty($awg)) { + continue; + } + + $host = $serverEntry['hostName'] ?? ($awg['hostName'] ?? null); + if (!$host) { + continue; + } + + $awgParams = []; + foreach (['Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4'] as $key) { + if (isset($awg[$key])) { + $awgParams[$key] = is_numeric($awg[$key]) ? (int)$awg[$key] : $awg[$key]; + } + } + + $vpnPort = isset($awg['port']) ? (int)$awg['port'] : null; + $sshPort = isset($serverEntry['port']) ? (int)$serverEntry['port'] : 22; + $sshUser = $serverEntry['userName'] ?? 'root'; + $sshPass = $serverEntry['password'] ?? ''; + if ($sshPass === '') { + // Skip records without SSH credentials; these are likely client snapshots. + continue; + } + $name = trim($serverEntry['description'] ?? '') ?: $host; + + $subnet = $container['awg']['subnet_address'] ?? null; + $clients = []; + + if (!empty($awg['last_config'])) { + $lastConfig = json_decode($awg['last_config'], true); + if (is_array($lastConfig)) { + $clientIp = $lastConfig['client_ip'] ?? null; + if (!$subnet && $clientIp) { + $subnet = self::inferSubnet($clientIp); + } + + $clients[] = [ + 'name' => $lastConfig['client_ip'] ?? ($lastConfig['clientId'] ?? $host . '_client'), + 'client_ip' => $clientIp, + 'public_key' => $lastConfig['client_pub_key'] ?? '', + 'private_key' => $lastConfig['client_priv_key'] ?? '', + 'preshared_key' => $lastConfig['psk_key'] ?? ($awg['psk_key'] ?? ''), + 'config' => $lastConfig['config'] ?? '', + 'status' => 'active', + 'expires_at' => null, + ]; + } + } + + if (!$subnet) { + $subnet = '10.8.1.0/24'; + } elseif (!str_contains($subnet, '/')) { + $subnet .= '/24'; + } + + $servers[] = [ + 'label' => $name . ' (' . $host . ')', + 'name' => $name, + 'host' => $host, + 'ssh_port' => $sshPort, + 'ssh_username' => $sshUser ?: 'root', + 'ssh_password' => $sshPass, + 'vpn_port' => $vpnPort, + 'container_name' => $container['container'] ?? 'amnezia-awg', + 'vpn_subnet' => $subnet, + 'server_public_key' => $awg['server_pub_key'] ?? null, + 'preshared_key' => $awg['psk_key'] ?? null, + 'awg_params' => $awgParams, + 'clients' => $clients, + ]; + } + } + + return [ + 'type' => 'amnezia_app', + 'servers' => $servers, + ]; + } + + /** + * Parse backup generated by this panel (backups/backup_*.json). + */ + private static function parsePanelBackup(array $decoded): array { + $server = $decoded['server']; + $awgParams = $server['awg_params'] ?? []; + if (is_string($awgParams)) { + $decodedParams = json_decode($awgParams, true); + if (is_array($decodedParams)) { + $awgParams = $decodedParams; + } + } + + $vpnPort = isset($server['vpn_port']) ? (int)$server['vpn_port'] : null; + $sshPort = isset($server['port']) ? (int)$server['port'] : 22; + $sshUser = $server['username'] ?? 'root'; + $sshPass = $server['password'] ?? ''; + $host = $server['host'] + ?? $server['host_name'] + ?? $server['host_ip'] + ?? null; + + if (!$host) { + throw new Exception('Panel backup is missing server host/SSH details. Create the server manually and import its clients via the panel importer.'); + } + + $clients = []; + foreach ($decoded['clients'] as $client) { + $clients[] = [ + 'name' => $client['name'] ?? ($client['client_ip'] ?? 'client'), + 'client_ip' => $client['client_ip'] ?? null, + 'public_key' => $client['public_key'] ?? '', + 'private_key' => $client['private_key'] ?? '', + 'preshared_key' => $client['preshared_key'] ?? ($server['preshared_key'] ?? ''), + 'config' => $client['config'] ?? '', + 'status' => $client['status'] ?? 'active', + 'expires_at' => $client['expires_at'] ?? null, + 'created_at' => $client['created_at'] ?? null, + ]; + } + + return [ + 'type' => 'panel_backup', + 'servers' => [ + [ + 'label' => ($server['name'] ?? 'Server') . ' (' . $host . ')', + 'name' => $server['name'] ?? 'Server', + 'host' => $host, + 'ssh_port' => $sshPort, + 'ssh_username' => $sshUser, + 'ssh_password' => $sshPass, + 'vpn_port' => $vpnPort, + 'container_name' => $server['container_name'] ?? 'amnezia-awg', + 'vpn_subnet' => $server['vpn_subnet'] ?? '10.8.1.0/24', + 'server_public_key' => $server['server_public_key'] ?? null, + 'preshared_key' => $server['preshared_key'] ?? null, + 'awg_params' => $awgParams, + 'clients' => $clients, + ] + ], + ]; + } + + /** + * Infer /24 subnet from client IP. + */ + private static function inferSubnet(string $ip): string { + $parts = explode('.', $ip); + if (count($parts) === 4) { + return $parts[0] . '.' . $parts[1] . '.' . $parts[2] . '.0/24'; + } + return '10.8.1.0/24'; + } +} diff --git a/inc/InstallProtocolManager.php b/inc/InstallProtocolManager.php new file mode 100644 index 0000000..8f86d10 --- /dev/null +++ b/inc/InstallProtocolManager.php @@ -0,0 +1,1131 @@ +query('SELECT * FROM protocols WHERE is_active = 1 ORDER BY name'); + $rows = $stmt->fetchAll(); + return array_map([self::class, 'hydrateProtocol'], $rows); + } catch (Throwable $e) { + return []; + } + } + + public static function getAll(): array + { + try { + $pdo = DB::conn(); + $stmt = $pdo->query('SELECT * FROM protocols ORDER BY name'); + $rows = $stmt->fetchAll(); + return array_map([self::class, 'hydrateProtocol'], $rows); + } catch (Throwable $e) { + return []; + } + } + + public static function getBySlug(string $slug): ?array + { + try { + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT * FROM protocols WHERE slug = ? LIMIT 1'); + $stmt->execute([$slug]); + $row = $stmt->fetch(); + if ($row) { + return self::hydrateProtocol($row); + } + } catch (Throwable $e) { + } + return null; + } + + public static function getById(int $id): ?array + { + try { + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT * FROM protocols WHERE id = ? LIMIT 1'); + $stmt->execute([$id]); + $row = $stmt->fetch(); + return $row ? self::hydrateProtocol($row) : null; + } catch (Throwable $e) { + return null; + } + } + + public static function save(array $data): int + { + $pdo = DB::conn(); + $definition = $data['definition'] ?? []; + if (is_string($definition)) { + $definition = json_decode($definition, true) ?: []; + } + + $definitionJson = json_encode($definition, JSON_UNESCAPED_SLASHES); + $isActive = isset($data['is_active']) ? (int) $data['is_active'] : 1; + + if (!empty($data['id'])) { + $stmt = $pdo->prepare(' + UPDATE install_protocols + SET slug = ?, name = ?, description = ?, definition = ?, is_active = ?, updated_at = NOW() + WHERE id = ? + '); + $stmt->execute([ + $data['slug'], + $data['name'], + $data['description'] ?? null, + $definitionJson, + $isActive, + $data['id'] + ]); + return (int) $data['id']; + } + + $stmt = $pdo->prepare(' + INSERT INTO install_protocols (slug, name, description, definition, is_active) + VALUES (?, ?, ?, ?, ?) + '); + $stmt->execute([ + $data['slug'], + $data['name'], + $data['description'] ?? null, + $definitionJson, + $isActive + ]); + + return (int) $pdo->lastInsertId(); + } + + public static function delete(int $id): void + { + $pdo = DB::conn(); + $stmt = $pdo->prepare('DELETE FROM install_protocols WHERE id = ?'); + $stmt->execute([$id]); + } + + public static function deploy(VpnServer $server, array $options = []): array + { + $serverData = $server->getData(); + $protocolSlug = $serverData['install_protocol'] ?? null; + if (!$protocolSlug || trim((string) $protocolSlug) === '') { + throw new Exception('Install protocol not selected'); + } + $protocol = self::getBySlug($protocolSlug); + + Logger::appendInstall($server->getId(), 'Deploy start for protocol ' . $protocolSlug); + + try { + if (!$protocol) { + throw new Exception('Install protocol not found: ' . $protocolSlug); + } + + $installMode = $options['install_mode'] ?? null; + $decisionToken = $options['decision_token'] ?? null; + $serverId = $server->getId(); + $detectionPayload = null; + + if (empty($options['skip_connection_test'])) { + if (!$server->testConnection()) { + Logger::appendInstall($serverId, 'SSH connection test failed'); + throw new Exception('SSH connection failed'); + } + Logger::appendInstall($serverId, 'SSH connection test OK'); + } + + if ($installMode !== null && $decisionToken) { + $entry = self::consumeDecision($serverId, $decisionToken); + if ($entry && ($entry['protocol'] ?? '') === $protocol['slug']) { + $detectionPayload = $entry['detection'] ?? null; + Logger::appendInstall($serverId, 'Consumed decision token for restore/reinstall'); + } + } + + if ($installMode === null) { + Logger::appendInstall($serverId, 'Running detection...'); + $detection = self::detect($server, $protocol, $options); + Logger::appendInstall($serverId, 'Detection result: ' . json_encode($detection)); + + if (in_array($detection['status'] ?? 'absent', ['existing', 'partial'], true)) { + $token = self::storeDecision($serverId, [ + 'protocol' => $protocol['slug'], + 'detection' => $detection, + 'stored_at' => time(), + ]); + + Logger::appendInstall($serverId, 'Existing/partial config found, awaiting decision. token=' . $token); + + return [ + 'success' => false, + 'requires_action' => true, + 'action' => 'existing_configuration', + 'details' => $detection, + 'decision_token' => $token, + 'options' => [ + 'restore' => [ + 'mode' => 'restore', + 'label' => 'Восстановить существующую конфигурацию' + ], + 'reinstall' => [ + 'mode' => 'reinstall', + 'label' => 'Переустановить заново' + ] + ] + ]; + } + + $installMode = 'install'; + Logger::appendInstall($serverId, 'Proceeding with clean install'); + } + + if ($installMode === 'restore') { + Logger::appendInstall($serverId, 'Restoring existing configuration...'); + if ($detectionPayload === null) { + $detectionPayload = self::detect($server, $protocol, array_merge($options, ['force' => true])); + Logger::appendInstall($serverId, 'Forced detection for restore: ' . json_encode($detectionPayload)); + } + + if (!in_array($detectionPayload['status'] ?? '', ['existing', 'partial'], true)) { + throw new Exception('Существующая конфигурация на сервере не найдена'); + } + + $res = self::restore($server, $protocol, $detectionPayload, $options); + Logger::appendInstall($serverId, 'Restore finished: ' . json_encode($res)); + return $res; + } + + if ($installMode === 'reinstall') { + $serverData = $server->getData(); + Logger::appendInstall($serverId, 'Reinstall mode selected'); + if (($serverData['status'] ?? '') === 'active' && empty($options['skip_backup'])) { + try { + $server->createBackup((int) $serverData['user_id'], 'automatic'); + Logger::appendInstall($serverId, 'Automatic backup created before reinstall'); + } catch (Throwable $e) { + Logger::appendInstall($serverId, 'Backup before reinstall failed: ' . $e->getMessage()); + // backup errors do not abort reinstall + } + } + } + + return self::install($server, $protocol, $options); + } catch (Throwable $e) { + // Mark server error and log + self::markServerError($server->getId(), $e->getMessage()); + Logger::appendInstall($server->getId(), 'Deploy failed: ' . $e->getMessage()); + throw $e; + } + } + + private static function detect(VpnServer $server, array $protocol, array $options = []): array + { + $engine = self::getEngine($protocol); + if ($engine === 'builtin_awg') { + return self::detectBuiltinAwg($server, $protocol); + } + + return self::runScript($server, $protocol, 'detect', $options); + } + + private static function install(VpnServer $server, array $protocol, array $options = []): array + { + $engine = self::getEngine($protocol); + $serverId = $server->getId(); + if ($engine === 'builtin_awg') { + try { + Logger::appendInstall($serverId, 'Installing builtin AWG...'); + $result = $server->runAwgInstall($options); + Logger::appendInstall($serverId, 'Builtin AWG install finished: ' . json_encode($result)); + self::markServerActive($serverId, null, [ + 'vpn_port' => $result['vpn_port'] ?? null, + 'server_public_key' => $result['public_key'] ?? ($result['server_public_key'] ?? null), + 'preshared_key' => $result['preshared_key'] ?? null, + 'awg_params' => $result['awg_params'] ?? null, + ]); + return $result; + } catch (Throwable $e) { + Logger::appendInstall($serverId, 'AWG install failed: ' . $e->getMessage()); + self::markServerError($serverId, $e->getMessage()); + throw $e; + } + } + + try { + Logger::appendInstall($serverId, 'Running scripted install...'); + // Choose/ensure VPN UDP port for script-driven installs + if (($protocol['slug'] ?? '') === 'xray-vless' && (!isset($options['server_port']) || !is_int($options['server_port']) || $options['server_port'] <= 0)) { + $options['server_port'] = 443; + } + if (!isset($options['server_port']) || !is_int($options['server_port'])) { + $options['server_port'] = self::chooseServerPort($server, $protocol['definition']['metadata'] ?? []); + } + $result = self::runScript($server, $protocol, 'install', $options); + if (!isset($result['success'])) { + $result['success'] = true; + } + Logger::appendInstall($serverId, 'Scripted install finished: ' . json_encode($result)); + $extras = [ + 'vpn_port' => $result['vpn_port'] ?? ($options['server_port'] ?? null), + 'server_public_key' => $result['server_public_key'] ?? null, + 'preshared_key' => $result['preshared_key'] ?? null, + 'awg_params' => $result['awg_params'] ?? null, + ]; + if (($protocol['slug'] ?? '') === 'xray-vless') { + foreach (['client_id','container_name','server_port','xray_port','reality_public_key','reality_private_key','reality_short_id','reality_server_name'] as $k) { + if (array_key_exists($k, $result)) { + $extras[$k] = $result[$k]; + } + } + $extras['result'] = $result; + } + self::markServerActive($serverId, null, $extras); + return $result; + } catch (Throwable $e) { + Logger::appendInstall($serverId, 'Scripted install failed: ' . $e->getMessage()); + self::markServerError($serverId, $e->getMessage()); + throw $e; + } + } + + private static function restore(VpnServer $server, array $protocol, array $detection, array $options = []): array + { + $engine = self::getEngine($protocol); + if ($engine === 'builtin_awg') { + return self::restoreBuiltinAwg($server, $protocol, $detection, $options); + } + + $result = self::runScript($server, $protocol, 'restore', array_merge($options, [ + 'detection' => $detection + ])); + if (!isset($result['success'])) { + $result['success'] = true; + } + return $result; + } + + private static function detectBuiltinAwg(VpnServer $server, array $protocol): array + { + $metadata = $protocol['definition']['metadata'] ?? []; + $serverData = $server->getData(); + $containerName = $serverData['container_name'] ?? ($metadata['container_name'] ?? 'amnezia-awg'); + $containerFilter = escapeshellarg('^' . $containerName . '$'); + $containerArg = escapeshellarg($containerName); + + $containerList = trim($server->executeCommand("docker ps -a --filter name={$containerFilter} --format '{{.Names}}'", true)); + if ($containerList === '') { + return [ + 'status' => 'absent', + 'message' => 'Контейнер AmneziaWG не найден на сервере' + ]; + } + + $containerState = trim($server->executeCommand("docker inspect --format '{{.State.Status}}' {$containerArg}", true)); + + $wgConfig = $server->executeCommand("docker exec -i {$containerArg} cat /opt/amnezia/awg/wg0.conf 2>/dev/null", true); + if (trim($wgConfig) === '') { + return [ + 'status' => 'partial', + 'message' => 'Контейнер найден, но конфигурация wg0.conf отсутствует', + 'details' => [ + 'container_name' => $containerName, + 'container_status' => $containerState, + ] + ]; + } + + $parsedConfig = self::parseWireGuardConfig($wgConfig); + if (empty($parsedConfig['listen_port']) || empty($parsedConfig['awg_params'])) { + return [ + 'status' => 'partial', + 'message' => 'Не удалось разобрать конфигурацию wg0.conf', + 'details' => [ + 'container_name' => $containerName, + 'container_status' => $containerState, + ] + ]; + } + + $publicKey = trim($server->executeCommand("docker exec -i {$containerArg} cat /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null", true)); + $presharedKey = trim($server->executeCommand("docker exec -i {$containerArg} cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null", true)); + + if ($publicKey === '' || $presharedKey === '') { + return [ + 'status' => 'partial', + 'message' => 'Не удалось прочитать ключи сервера', + 'details' => [ + 'container_name' => $containerName, + 'container_status' => $containerState, + ] + ]; + } + + $clientsRaw = $server->executeCommand("docker exec -i {$containerArg} cat /opt/amnezia/awg/clientsTable 2>/dev/null", true); + $clients = json_decode(trim($clientsRaw), true); + $clientsCount = is_array($clients) ? count($clients) : 0; + + return [ + 'status' => 'existing', + 'message' => 'Найдена установленная конфигурация AmneziaWG', + 'details' => [ + 'container_name' => $containerName, + 'container_status' => $containerState, + 'vpn_port' => (int) $parsedConfig['listen_port'], + 'server_public_key' => $publicKey, + 'preshared_key' => $presharedKey, + 'awg_params' => $parsedConfig['awg_params'], + 'clients_count' => $clientsCount, + 'summary' => sprintf('Container %s (%s), port %s, clients %d', $containerName, $containerState ?: 'unknown', $parsedConfig['listen_port'], $clientsCount) + ] + ]; + } + + private static function restoreBuiltinAwg(VpnServer $server, array $protocol, array $detection, array $options): array + { + $details = $detection['details'] ?? []; + $containerName = $details['container_name'] ?? ($protocol['definition']['metadata']['container_name'] ?? 'amnezia-awg'); + $containerArg = escapeshellarg($containerName); + + // Try to ensure container is running and wg is up + $server->executeCommand("docker start {$containerArg} 2>/dev/null || true", true); + $server->executeCommand("docker exec -i {$containerArg} wg-quick down /opt/amnezia/awg/wg0.conf 2>/dev/null || true", true); + $server->executeCommand("docker exec -i {$containerArg} wg-quick up /opt/amnezia/awg/wg0.conf 2>/dev/null || true", true); + + $pdo = DB::conn(); + $stmt = $pdo->prepare(' + UPDATE vpn_servers + SET vpn_port = ?, + server_public_key = ?, + preshared_key = ?, + awg_params = ?, + status = ?, + error_message = NULL, + deployed_at = COALESCE(deployed_at, NOW()) + WHERE id = ? + '); + $stmt->execute([ + $details['vpn_port'] ?? null, + $details['server_public_key'] ?? null, + $details['preshared_key'] ?? null, + isset($details['awg_params']) ? json_encode($details['awg_params']) : null, + 'active', + $server->getId() + ]); + + $server->refresh(); + $serverData = $server->getData(); + + // Import existing peers from wg0.conf into database as disabled clients + $wgConfig = $server->executeCommand("docker exec -i {$containerArg} cat /opt/amnezia/awg/wg0.conf 2>/dev/null", true); + $tableRaw = $server->executeCommand("docker exec -i {$containerArg} cat /opt/amnezia/awg/clientsTable 2>/dev/null", true); + $clientsTable = json_decode(trim($tableRaw), true); + $nameByPub = []; + if (is_array($clientsTable)) { + foreach ($clientsTable as $entry) { + $cid = $entry['clientId'] ?? ''; + $uname = $entry['userData']['clientName'] ?? null; + if ($cid !== '' && $uname) { + $nameByPub[$cid] = $uname; + } + } + } + $restored = 0; + if (trim($wgConfig) !== '') { + $pattern = '/\[Peer\][^\[]*?PublicKey\s*=\s*(.+?)\s*[\r\n]+[\s\S]*?AllowedIPs\s*=\s*(.+?)(?:\r?\n|$)/'; + if (preg_match_all($pattern, $wgConfig, $matches, PREG_SET_ORDER)) { + foreach ($matches as $m) { + $pub = trim($m[1]); + $allowed = trim($m[2]); + $clientIp = null; + foreach (explode(',', $allowed) as $ipSpec) { + $ipSpec = trim($ipSpec); + if (preg_match('/^([0-9\.]+)\/32$/', $ipSpec, $mm)) { + $clientIp = $mm[1]; + break; + } + } + if (!$clientIp) { + continue; + } + $pdo = DB::conn(); + $chk = $pdo->prepare('SELECT id FROM vpn_clients WHERE server_id = ? AND client_ip = ?'); + $chk->execute([$server->getId(), $clientIp]); + if ($chk->fetch()) { + 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, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())'); + $ins->execute([ + $server->getId(), + $serverData['user_id'] ?? null, + $name, + $clientIp, + $pub, + '', + $details['preshared_key'] ?? null, + '', + 'disabled' + ]); + $restored++; + } + } + } + + return [ + 'success' => true, + 'mode' => 'restore', + 'message' => 'Существующая конфигурация восстановлена', + 'vpn_port' => $details['vpn_port'] ?? null, + 'clients_count' => $details['clients_count'] ?? null, + 'restored_clients' => $restored + ]; + } + + private static function runScript(VpnServer $server, array $protocol, string $phase, array $options = []): array + { + $definition = $protocol['definition'] ?? []; + $scripts = $definition['scripts'][$phase] ?? null; + if (!$scripts) { + if ($phase === 'install') { + $scripts = $protocol['install_script'] ?? null; + } elseif ($phase === 'uninstall') { + $scripts = $protocol['uninstall_script'] ?? null; + } + } + if (!$scripts) { + if ($phase === 'detect') { + return [ + 'status' => 'absent', + 'message' => 'Скрипт обнаружения не настроен для протокола' + ]; + } + if ($phase === 'uninstall') { + return [ + 'success' => true, + 'message' => 'Скрипт удаления не настроен для протокола' + ]; + } + throw new Exception('Скрипт ' . $phase . ' не настроен для протокола'); + } + + $context = self::buildContext($server, $protocol, $options); + $script = self::renderTemplate($scripts, $context); + $script = preg_replace('/<<\s*EOF\b/', "<<'EOF'", $script); + $script = preg_replace('/\n\+\s*/', "\n", $script); + $exportLines = self::buildExports($context); + $wrapper = "bash <<'EOS'\nset -euo pipefail\n" . $exportLines . $script . "\nEOS"; + Logger::appendInstall($server->getId(), strtoupper($phase) . ' phase: executing remote script'); + $output = $server->executeCommand($wrapper, true); + Logger::appendInstall($server->getId(), strtoupper($phase) . ' phase: output size ' . strlen((string) $output) . ' bytes'); + $head = substr(str_replace(["\r", "\n"], ' ', (string) $output), 0, 280); + if ($head !== '') { + Logger::appendInstall($server->getId(), strtoupper($phase) . ' phase: output head ' . $head); + } + $trimmed = trim($output); + + // Try JSON first + $decoded = json_decode($trimmed, true); + if (is_array($decoded)) { + Logger::appendInstall($server->getId(), strtoupper($phase) . ' phase: parsed JSON result'); + return $decoded; + } + + // Try key-value format (e.g., "Port: 123" or "Server Public Key: abc") + $result = self::parseKeyValueOutput($trimmed); + if (!empty($result)) { + Logger::appendInstall($server->getId(), strtoupper($phase) . ' phase: parsed key-value result with ' . count($result) . ' keys'); + return array_merge(['success' => true], $result); + } + + // Heuristic: treat obvious errors on install as failure to avoid false "active" status + if ($phase === 'install') { + $lower = strtolower($trimmed); + if ($lower === '' || strpos($lower, 'command not found') !== false || strpos($lower, 'error') !== false) { + throw new Exception('Ошибка установки (script): ' . ($trimmed !== '' ? $trimmed : 'empty output')); + } + } + + return [ + 'success' => true, + 'output' => $output + ]; + } + + /** + * Parse key-value output from installation scripts + * Supports formats like: + * - "Port: 123" + * - "Server Public Key: abc123" + * - "PresharedKey = xyz789" + */ + private static function parseKeyValueOutput(string $output): array + { + $result = []; + $lines = preg_split('/\r?\n/', $output); + + foreach ($lines as $line) { + $line = trim($line); + if ($line === '') + continue; + $line = preg_replace('/^\+\s*/', '', $line); + + // Match "Variable: name=value" format (for protocol variables) + if (preg_match('/^Variable:\s*(\w+)=(.*)$/', $line, $matches)) { + $varName = trim($matches[1]); + $varValue = trim($matches[2]); + $result[$varName] = $varValue; + continue; + } + + // Match "Key: Value" or "Key = Value" format + if (preg_match('/^([^:=]+?)[:=]\s*(.+)$/', $line, $matches)) { + $key = trim($matches[1]); + $value = trim($matches[2]); + + // Normalize key names to snake_case + $normalizedKey = strtolower(preg_replace('/\s+/', '_', $key)); + + // Map common key names + $keyMap = [ + 'port' => 'vpn_port', + 'server_public_key' => 'server_public_key', + 'presharedkey' => 'preshared_key', + 'preshared_key' => 'preshared_key', + 'awg_params' => 'awg_params', + 'clientid' => 'client_id', + 'client_id' => 'client_id', + 'server_port' => 'server_port', + 'xray_port' => 'server_port', + 'container_name' => 'container_name', + 'containername' => 'container_name', + 'publickey' => 'reality_public_key', + 'privatekey' => 'reality_private_key', + 'shortid' => 'reality_short_id', + 'servername' => 'reality_server_name', + ]; + + $finalKey = $keyMap[$normalizedKey] ?? $normalizedKey; + $result[$finalKey] = $value; + } + } + + return $result; + } + + private static function markServerActive(int $serverId, ?string $message = null, array $extras = []): void + { + $pdo = DB::conn(); + $setParts = ['status = ?', 'error_message = NULL', 'deployed_at = COALESCE(deployed_at, NOW())']; + $params = ['active']; + if (isset($extras['vpn_port']) && $extras['vpn_port'] !== null) { + $setParts[] = 'vpn_port = ?'; + $params[] = (int) $extras['vpn_port']; + } + if (isset($extras['server_public_key']) && $extras['server_public_key'] !== null) { + $setParts[] = 'server_public_key = ?'; + $params[] = (string) $extras['server_public_key']; + } + if (isset($extras['preshared_key']) && $extras['preshared_key'] !== null) { + $setParts[] = 'preshared_key = ?'; + $params[] = (string) $extras['preshared_key']; + } + if (array_key_exists('awg_params', $extras)) { + $awgParams = $extras['awg_params']; + if (is_array($awgParams)) { + $awgParams = json_encode($awgParams); + } + if (is_string($awgParams)) { + $setParts[] = 'awg_params = ?'; + $params[] = $awgParams; + } + } + $params[] = $serverId; + $sql = 'UPDATE vpn_servers SET ' . implode(', ', $setParts) . ' WHERE id = ?'; + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + + try { + $stmt2 = $pdo->prepare('SELECT install_protocol, host, vpn_port FROM vpn_servers WHERE id = ?'); + $stmt2->execute([$serverId]); + $row = $stmt2->fetch(); + $slug = $row['install_protocol'] ?? null; + if ($slug) { + $stmt3 = $pdo->prepare('SELECT id FROM protocols WHERE slug = ? LIMIT 1'); + $stmt3->execute([$slug]); + $protocolId = $stmt3->fetchColumn(); + if ($protocolId) { + $config = [ + 'server_host' => $row['host'] ?? null, + 'server_port' => $row['vpn_port'] ?? null, + 'extras' => $extras + ]; + $stmt4 = $pdo->prepare('INSERT INTO server_protocols (server_id, protocol_id, config_data, applied_at, created_at) VALUES (?, ?, ?, NOW(), NOW()) ON DUPLICATE KEY UPDATE config_data = VALUES(config_data), applied_at = NOW()'); + $stmt4->execute([$serverId, (int) $protocolId, json_encode($config)]); + } + } + } catch (Throwable $e) { + // ignore linkage errors + } + } + + private static function markServerError(int $serverId, string $message): void + { + $pdo = DB::conn(); + $stmt = $pdo->prepare('UPDATE vpn_servers SET status = ?, error_message = ? WHERE id = ?'); + $stmt->execute(['error', $message, $serverId]); + } + + private static function buildContext(VpnServer $server, array $protocol, array $options): array + { + return [ + 'server' => $server->getData(), + 'protocol' => $protocol, + 'metadata' => $protocol['definition']['metadata'] ?? [], + 'options' => $options + ]; + } + + private static function buildExports(array $context): string + { + $exports = []; + $serverData = $context['server'] ?? []; + $metadata = $context['metadata'] ?? []; + $options = $context['options'] ?? []; + + $pairs = [ + 'SERVER_HOST' => $serverData['host'] ?? '', + 'SERVER_USER' => $serverData['username'] ?? '', + 'SERVER_CONTAINER' => $serverData['container_name'] ?? ($metadata['container_name'] ?? ''), + 'SERVER_PORT' => isset($serverData['vpn_port']) && (int) $serverData['vpn_port'] > 0 + ? (int) $serverData['vpn_port'] + : (isset($options['server_port']) ? (int) $options['server_port'] : ''), + ]; + + foreach ($pairs as $key => $value) { + if ($value !== '' && $value !== null) { + $exports[] = sprintf('export %s=%s', $key, escapeshellarg((string) $value)); + } + } + + foreach ($metadata as $key => $value) { + if (!is_scalar($value)) { + continue; + } + $normalized = strtoupper(preg_replace('/[^A-Z0-9]+/i', '_', (string) $key)); + if ($normalized === '') { + continue; + } + $exports[] = sprintf('export PROTOCOL_%s=%s', $normalized, escapeshellarg((string) $value)); + } + + return $exports ? implode("\n", $exports) . "\n" : ''; + } + + /** + * Choose a free UDP port on the remote server within metadata-defined range or defaults + */ + private static function chooseServerPort(VpnServer $server, array $metadata): int + { + $range = $metadata['port_range'] ?? [30000, 65000]; + $min = 30000; + $max = 65000; + if (is_string($range)) { + // Accept formats like "[30000, 65000]" or "30000-65000" + if (preg_match('/(\d{2,})\D+(\d{2,})/', $range, $m)) { + $min = (int) $m[1]; + $max = (int) $m[2]; + } + } elseif (is_array($range) && count($range) >= 2) { + $min = (int) $range[0]; + $max = (int) $range[1]; + } + + for ($attempt = 0; $attempt < 30; $attempt++) { + $candidate = random_int($min, $max); + $cmd = "ss -lun | awk '{print $4}' | grep -E ':(" . $candidate . ")($| )' || true"; + $out = $server->executeCommand($cmd, false); + if (trim($out) === '') { + return $candidate; + } + } + + return 40001; // fallback + } + + private static function renderTemplate(string $template, array $context): string + { + return preg_replace_callback('/{{\s*([a-zA-Z0-9_.]+)\s*}}/', function ($matches) use ($context) { + $path = explode('.', $matches[1]); + $value = $context; + foreach ($path as $segment) { + if (is_array($value) && array_key_exists($segment, $value)) { + $value = $value[$segment]; + } else { + return ''; + } + } + return is_scalar($value) ? (string) $value : json_encode($value); + }, $template); + } + + private static function parseWireGuardConfig(string $config): array + { + $lines = preg_split('/\r?\n/', $config); + $awgKeys = ['Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4']; + $awgParams = []; + $listenPort = null; + + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || strpos($line, '=') === false) { + continue; + } + [$key, $value] = array_map('trim', explode('=', $line, 2)); + if ($key === 'ListenPort') { + $listenPort = (int) $value; + } + if (in_array($key, $awgKeys, true)) { + $awgParams[$key] = is_numeric($value) ? (int) $value : $value; + } + } + + return [ + 'listen_port' => $listenPort, + 'awg_params' => $awgParams + ]; + } + + private static function hydrateProtocol(array $row): array + { + if (isset($row['definition']) && is_string($row['definition'])) { + $decoded = json_decode($row['definition'], true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + $row['definition'] = $decoded; + } else { + $row['definition'] = []; + } + } + return $row; + } + + private static function getEngine(array $protocol): string + { + $definition = $protocol['definition'] ?? []; + if (!empty($protocol['install_script'])) { + return 'shell'; + } + return $definition['engine'] ?? 'builtin_awg'; + } + + private static function fallbackProtocols(): array + { + return [ + [ + 'id' => null, + 'slug' => self::DEFAULT_SLUG, + 'name' => 'AmneziaWG', + 'description' => 'Default Amnezia WireGuard deployment scenario', + 'definition' => [ + 'engine' => 'builtin_awg', + 'metadata' => [ + 'container_name' => 'amnezia-awg', + 'vpn_subnet' => '10.8.1.0/24', + 'port_range' => [30000, 65000], + ], + ], + 'is_active' => 1, + ] + ]; + } + + private static function storeDecision(int $serverId, array $payload): string + { + if (session_status() !== PHP_SESSION_ACTIVE) { + return ''; + } + $token = bin2hex(random_bytes(16)); + if (!isset($_SESSION[self::SESSION_KEY])) { + $_SESSION[self::SESSION_KEY] = []; + } + $_SESSION[self::SESSION_KEY][$serverId] = [ + 'token' => $token, + 'payload' => $payload, + 'expires_at' => time() + 600 + ]; + return $token; + } + + private static function consumeDecision(int $serverId, string $token): ?array + { + if (session_status() !== PHP_SESSION_ACTIVE) { + return null; + } + + if (!isset($_SESSION[self::SESSION_KEY][$serverId])) { + return null; + } + + $entry = $_SESSION[self::SESSION_KEY][$serverId]; + if (($entry['token'] ?? '') !== $token) { + return null; + } + + unset($_SESSION[self::SESSION_KEY][$serverId]); + + if (($entry['expires_at'] ?? 0) < time()) { + return null; + } + + return $entry['payload'] ?? null; + } + + /** + * Run detection script for a scenario on a server + * Used for testing scenarios before deployment + */ + public static function runDetection(VpnServer $server, array $protocol, array $options = []): array + { + $engine = self::getEngine($protocol); + if ($engine === 'builtin_awg') { + return self::detectBuiltinAwg($server, $protocol); + } + + return self::runScript($server, $protocol, 'detect', $options); + } + + /** + * Uninstall a protocol from the given server. Supports builtin AWG and scripted protocols + * Returns array with success and message keys on completion or throws on fatal error + */ + public static function uninstall(VpnServer $server, array $protocol, array $options = []): array + { + $engine = self::getEngine($protocol); + if ($engine === 'builtin_awg') { + return self::uninstallBuiltinAwg($server, $protocol, $options); + } + + // For script-driven protocols, try to detect AWG scenario and fallback to builtin uninstall + $slug = $protocol['slug'] ?? ''; + $installScript = (string) ($protocol['install_script'] ?? ''); + $looksLikeAwg = (bool) preg_match('/amneziavpn\/amnezia-wg|amnezia\/awg|amnezia-awg/i', $installScript); + if (in_array($slug, ['amnezia-wg-advanced', 'amnezia-wg'], true) || $looksLikeAwg) { + // Prefer builtin AWG uninstall by default because script variants may have CRLF issues + // or leave behind the canonical container name, causing install conflicts. + if (!empty($options['use_script_uninstall'])) { + $hasScript = isset($protocol['uninstall_script']) && trim((string) $protocol['uninstall_script']) !== ''; + if ($hasScript) { + return self::runScript($server, $protocol, 'uninstall', $options); + } + } + return self::uninstallBuiltinAwg($server, $protocol, $options); + } + + // For other script-driven protocols, look for an "uninstall" phase in scripts + return self::runScript($server, $protocol, 'uninstall', $options); + } + + private static function uninstallBuiltinAwg(VpnServer $server, array $protocol, array $options = []): array + { + $metadata = $protocol['definition']['metadata'] ?? []; + $serverData = $server->getData(); + $containerName = $serverData['container_name'] ?? ($metadata['container_name'] ?? 'amnezia-awg'); + $candidateNames = array_values(array_unique(array_filter([ + is_string($containerName) ? trim($containerName) : '', + is_string($metadata['container_name'] ?? null) ? trim((string) $metadata['container_name']) : '', + 'amnezia-awg', + ], function ($v) { + return is_string($v) && trim($v) !== ''; + }))); + + // Attempt to stop and remove container, image and cleanup files + try { + foreach ($candidateNames as $name) { + $arg = escapeshellarg($name); + // Stop container if running + $server->executeCommand("docker stop {$arg} 2>/dev/null || true", true); + // Remove container + $server->executeCommand("docker rm -fv {$arg} 2>/dev/null || true", true); + } + // Remove known images (best-effort) + $server->executeCommand("docker rmi amneziavpn/amnezia-wg amneziavpn/amnezia-awg 2>/dev/null || true", true); + // Attempt to remove amnezia-dns-net network if present (best-effort) + $server->executeCommand("docker network rm amnezia-dns-net 2>/dev/null || true", true); + // Remove on-disk data for AWG + $server->executeCommand("rm -rf /opt/amnezia/amnezia-awg 2>/dev/null || true", true); + + // Clear server deployment metadata in database for this server + $pdo = DB::conn(); + $stmt = $pdo->prepare('UPDATE vpn_servers SET vpn_port = NULL, server_public_key = NULL, preshared_key = NULL, awg_params = NULL, status = ?, error_message = NULL WHERE id = ?'); + $stmt->execute(['stopped', $server->getId()]); + + // Refresh server object data + $server->refresh(); + + return [ + 'success' => true, + 'message' => 'Протокол успешно удалён', + 'mode' => 'uninstall' + ]; + } catch (Throwable $e) { + throw new Exception('Uninstall failed: ' . $e->getMessage()); + } + } + + public static function activate(VpnServer $server, array $protocol, array $options = []): array + { + $engine = self::getEngine($protocol); + $serverId = $server->getId(); + try { + Logger::appendInstall($serverId, 'Activate start for ' . ($protocol['slug'] ?? 'unknown') . ' engine ' . $engine); + if ($engine === 'builtin_awg') { + $res = $server->runAwgInstall($options); + Logger::appendInstall($serverId, 'Builtin AWG install finished'); + $pdo = DB::conn(); + $pid = (int) ($protocol['id'] ?? 0); + if (!$pid) { + $stmt = $pdo->prepare('SELECT id FROM protocols WHERE slug = ? LIMIT 1'); + $stmt->execute([$protocol['slug'] ?? self::DEFAULT_SLUG]); + $pid = (int) $stmt->fetchColumn(); + } + if ($pid) { + $config = [ + 'server_host' => $server->getData()['host'] ?? null, + 'server_port' => $res['vpn_port'] ?? null, + 'extras' => $res + ]; + $stmt2 = $pdo->prepare('INSERT INTO server_protocols (server_id, protocol_id, config_data, applied_at, created_at) VALUES (?, ?, ?, NOW(), NOW()) ON DUPLICATE KEY UPDATE config_data = VALUES(config_data), applied_at = NOW()'); + $stmt2->execute([$serverId, $pid, json_encode($config)]); + } + return ['success' => true, 'mode' => 'install', 'details' => $res]; + } + if (!isset($options['server_port']) || !is_int($options['server_port'])) { + $options['server_port'] = self::chooseServerPort($server, $protocol['definition']['metadata'] ?? []); + } + $res = self::runScript($server, $protocol, 'install', $options); + if (!isset($res['success'])) { + $res['success'] = true; + } + $port = null; + $password = null; + $clientId = null; + if (isset($res['vpn_port'])) { + $port = (int) $res['vpn_port']; + } + if (isset($res['server_port'])) { + $port = (int) $res['server_port']; + } + if (isset($res['client_id']) && is_string($res['client_id'])) { + $clientId = $res['client_id']; + } + if (is_string($res['output'] ?? '')) { + $out = $res['output']; + if (preg_match('/Port:\s*(\d+)/i', $out, $m)) { + $port = (int) $m[1]; + } + if (preg_match('/Password:\s*([\w-]+)/i', $out, $m)) { + $password = $m[1]; + } + if (preg_match('/ClientID:\s*([0-9a-fA-F-]+)/i', $out, $m)) { + $clientId = $m[1]; + } + } + if (($protocol['slug'] ?? '') === 'xray-vless' && $clientId === null) { + $containerName = 'amnezia-xray'; + if (isset($res['container_name']) && is_string($res['container_name']) && trim($res['container_name']) !== '') { + $containerName = trim($res['container_name']); + } + try { + $cfg = $server->executeCommand("docker exec -i " . escapeshellarg($containerName) . " cat /opt/amnezia/xray/server.json 2>/dev/null", true); + if (trim((string)$cfg) === '') { + $cfg = $server->executeCommand("docker exec -i " . escapeshellarg($containerName) . " cat /etc/xray/config.json 2>/dev/null", true); + } + $decoded = json_decode(trim((string) $cfg), true); + if (is_array($decoded)) { + $inbounds = $decoded['inbounds'] ?? []; + if (is_array($inbounds) && !empty($inbounds)) { + $settings = $inbounds[0]['settings'] ?? []; + $clients = $settings['clients'] ?? []; + if (is_array($clients) && !empty($clients)) { + $cid = $clients[0]['id'] ?? null; + if (is_string($cid) && $cid !== '') { + $clientId = $cid; + } + } + $stream = $inbounds[0]['streamSettings'] ?? []; + if (is_array($stream) && ($stream['security'] ?? '') === 'reality') { + $rs = $stream['realitySettings'] ?? []; + $serverNames = $rs['serverNames'] ?? ($rs['serverName'] ?? []); + $shortIds = $rs['shortIds'] ?? ($rs['shortId'] ?? []); + $serverName = is_array($serverNames) ? ($serverNames[0] ?? null) : (is_string($serverNames) ? $serverNames : null); + $shortId = is_array($shortIds) ? ($shortIds[0] ?? null) : (is_string($shortIds) ? $shortIds : null); + $privateKey = $rs['privateKey'] ?? null; + $publicKey = null; + if (is_string($privateKey) && $privateKey !== '' && function_exists('sodium_crypto_scalarmult_base')) { + $pk = $privateKey; + $b64 = strtr($pk, '-_', '+/'); + $bin = base64_decode($b64, true); + if ($bin === false) { + $bin = base64_decode($pk, true); + } + if (is_string($bin) && strlen($bin) === 32) { + $pub = sodium_crypto_scalarmult_base($bin); + $publicKey = rtrim(strtr(base64_encode($pub), '+/', '-_'), '='); + } + } + if ($publicKey) { + $res['reality_public_key'] = $publicKey; + } + if ($shortId) { + $res['reality_short_id'] = $shortId; + } + if ($serverName) { + $res['reality_server_name'] = $serverName; + } + } + } + } + } catch (Throwable $e) { + } + } + Logger::appendInstall($serverId, 'Scripted install parsed port ' . ($port ?? 0) . ' password ' . ($password ?? '')); + $pdo = DB::conn(); + $pid = (int) ($protocol['id'] ?? 0); + if (!$pid) { + $stmt = $pdo->prepare('SELECT id FROM protocols WHERE slug = ? LIMIT 1'); + $stmt->execute([$protocol['slug'] ?? '']); + $pid = (int) $stmt->fetchColumn(); + } + if ($pid) { + $config = [ + 'server_host' => $server->getData()['host'] ?? null, + 'server_port' => $port, + 'extras' => ['password' => $password, 'client_id' => $clientId, 'result' => $res, + 'reality_public_key' => $res['reality_public_key'] ?? null, + 'reality_short_id' => $res['reality_short_id'] ?? null, + 'reality_server_name' => $res['reality_server_name'] ?? null, + ] + ]; + $stmt2 = $pdo->prepare('INSERT INTO server_protocols (server_id, protocol_id, config_data, applied_at, created_at) VALUES (?, ?, ?, NOW(), NOW()) ON DUPLICATE KEY UPDATE config_data = VALUES(config_data), applied_at = NOW()'); + $stmt2->execute([$serverId, $pid, json_encode($config)]); + } + return $res; + } catch (Throwable $e) { + self::markServerError($serverId, $e->getMessage()); + Logger::appendInstall($serverId, 'Activate failed: ' . $e->getMessage()); + throw $e; + } + } +} diff --git a/inc/Logger.php b/inc/Logger.php new file mode 100644 index 0000000..2a2d45f --- /dev/null +++ b/inc/Logger.php @@ -0,0 +1,25 @@ +apiKey = $_ENV['OPENROUTER_API_KEY'] ?? null; + if (!$this->apiKey) { + throw new Exception('OpenRouter API key not configured'); + } + } + + /** + * Generate installation script using OpenRouter API + */ + public function generateScript(string $prompt, string $model = 'openai/gpt-3.5-turbo'): array { + try { + $messages = [ + [ + 'role' => 'system', + 'content' => 'You are a helpful assistant that creates bash installation scripts for VPN protocols. Always respond with valid JSON containing the script, suggestions, ubuntu compatibility, and estimated installation time.' + ], + [ + 'role' => 'user', + 'content' => $prompt + ] + ]; + + $response = $this->makeAPICall('/chat/completions', [ + 'model' => $model, + 'messages' => $messages, + 'temperature' => 0.3, // Lower temperature for more consistent results + 'max_tokens' => 4000, // Sufficient for detailed scripts + 'response_format' => ['type' => 'json_object'] + ]); + + if (!isset($response['choices'][0]['message']['content'])) { + throw new Exception('Invalid response from OpenRouter API'); + } + + $content = $response['choices'][0]['message']['content']; + $parsed = json_decode($content, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + // If JSON parsing fails, try to extract script from plain text + return $this->parsePlainTextResponse($content); + } + + return $this->validateAndEnhanceResponse($parsed); + + } catch (Exception $e) { + error_log("Error in OpenRouterService::generateScript: " . $e->getMessage()); + throw new Exception('Failed to generate script: ' . $e->getMessage()); + } + } + + /** + * Get available AI models from OpenRouter + */ + public function getAvailableModels(): array { + try { + $response = $this->makeAPICall('/models', [], 'GET'); + + if (!isset($response['data'])) { + throw new Exception('Invalid response from OpenRouter API'); + } + + // Filter models suitable for code generation + $codeModels = array_filter($response['data'], function($model) { + $codeModelIds = [ + 'openai/gpt-3.5-turbo', + 'openai/gpt-4', + 'openai/gpt-4-turbo', + 'anthropic/claude-3-haiku', + 'anthropic/claude-3-sonnet', + 'anthropic/claude-3-opus', + 'google/gemini-pro', + 'meta-llama/llama-2-70b-chat', + 'meta-llama/llama-3-70b-instruct' + ]; + + return in_array($model['id'], $codeModelIds) && $model['top_provider'] === true; + }); + + return array_values(array_map(function($model) { + return [ + 'id' => $model['id'], + 'name' => $model['name'] ?? $model['id'], + 'description' => $model['description'] ?? '', + 'pricing' => $model['pricing'] ?? null + ]; + }, $codeModels)); + + } catch (Exception $e) { + error_log("Error in OpenRouterService::getAvailableModels: " . $e->getMessage()); + // Return default models if API call fails + return $this->getDefaultModels(); + } + } + + /** + * Make API call to OpenRouter + */ + private function makeAPICall(string $endpoint, array $data = [], string $method = 'POST'): array { + $ch = curl_init(); + + $url = $this->apiUrl . $endpoint; + + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $this->apiKey, + 'Content-Type: application/json', + 'HTTP-Referer: ' . ($_ENV['APP_URL'] ?? 'https://localhost'), + 'X-Title: Amnezia VPN Panel' + ], + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2 + ]); + + if ($method === 'POST') { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); + } + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($error) { + throw new Exception('CURL error: ' . $error); + } + + if ($httpCode >= 400) { + $errorData = json_decode($response, true); + $errorMessage = $errorData['error']['message'] ?? "HTTP $httpCode error"; + throw new Exception($errorMessage); + } + + $decoded = json_decode($response, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new Exception('Invalid JSON response from OpenRouter API'); + } + + return $decoded; + } + + /** + * Parse plain text response when JSON parsing fails + */ + private function parsePlainTextResponse(string $content): array { + // Try to extract bash script from plain text + if (preg_match('/```bash\n(.*?)\n```/s', $content, $matches)) { + $script = trim($matches[1]); + } elseif (preg_match('/```(.*?)```/s', $content, $matches)) { + $script = trim($matches[1]); + } else { + // If no code blocks found, treat the entire content as script + $script = trim($content); + } + + // Add bash shebang if not present + if (!str_starts_with($script, '#!')) { + $script = "#!/bin/bash\n\n" . $script; + } + + return [ + 'script' => $script, + 'suggestions' => [ + 'Check the script for syntax errors', + 'Test the script in a safe environment', + 'Review security implications' + ], + 'ubuntu_compatible' => true, + 'estimated_time' => '5 minutes' + ]; + } + + /** + * Validate and enhance AI response + */ + private function validateAndEnhanceResponse(array $response): array { + $defaults = [ + 'script' => '#!/bin/bash\n# Default installation script\necho "Installation script placeholder"', + 'suggestions' => [], + 'ubuntu_compatible' => true, + 'estimated_time' => '5 minutes' + ]; + + // Ensure all required fields are present + foreach ($defaults as $key => $defaultValue) { + if (!isset($response[$key])) { + $response[$key] = $defaultValue; + } + } + + // Validate script format + if (!str_starts_with(trim($response['script']), '#!')) { + $response['script'] = "#!/bin/bash\n\n" . $response['script']; + } + + // Ensure suggestions is an array + if (!is_array($response['suggestions'])) { + $response['suggestions'] = []; + } + + // Add default suggestions if none provided + if (empty($response['suggestions'])) { + $response['suggestions'] = [ + 'Review the generated script for security implications', + 'Test the script in a development environment first', + 'Ensure all dependencies are available on your system', + 'Backup your system before running the script' + ]; + } + + // Validate ubuntu_compatible is boolean + if (!is_bool($response['ubuntu_compatible'])) { + $response['ubuntu_compatible'] = true; + } + + return $response; + } + + /** + * Get default models when API is unavailable + */ + private function getDefaultModels(): array { + return [ + [ + 'id' => 'openai/gpt-3.5-turbo', + 'name' => 'GPT-3.5 Turbo', + 'description' => 'Fast and cost-effective model for general purpose tasks', + 'pricing' => ['prompt' => '0.001', 'completion' => '0.002'] + ], + [ + 'id' => 'openai/gpt-4', + 'name' => 'GPT-4', + 'description' => 'Most capable model for complex tasks', + 'pricing' => ['prompt' => '0.03', 'completion' => '0.06'] + ], + [ + 'id' => 'anthropic/claude-3-haiku', + 'name' => 'Claude 3 Haiku', + 'description' => 'Fast and cost-effective model from Anthropic', + 'pricing' => ['prompt' => '0.00025', 'completion' => '0.00125'] + ], + [ + 'id' => 'anthropic/claude-3-sonnet', + 'name' => 'Claude 3 Sonnet', + 'description' => 'Balanced performance and cost from Anthropic', + 'pricing' => ['prompt' => '0.003', 'completion' => '0.015'] + ] + ]; + } + + public function testModelAvailability(string $modelId): array { + if (!$this->apiKey) { + return [ + 'success' => false, + 'http_code' => 401, + 'message' => 'OpenRouter API key not configured' + ]; + } + + $payload = [ + 'model' => $modelId, + 'messages' => [ + ['role' => 'user', 'content' => 'Reply with: OK'] + ], + 'max_tokens' => 5, + 'temperature' => 0 + ]; + + $ch = curl_init($this->apiUrl . '/chat/completions'); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 20); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $this->apiKey, + 'HTTP-Referer: https://amnez.ia', + 'X-Title: Amnezia VPN Panel' + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); + curl_close($ch); + + if ($curlError) { + return [ + 'success' => false, + 'http_code' => null, + 'message' => 'Network error: ' . $curlError + ]; + } + + $json = json_decode($response, true); + $ok = $httpCode === 200 && isset($json['choices'][0]['message']['content']); + return [ + 'success' => $ok, + 'http_code' => $httpCode, + 'message' => $ok ? 'Model is available' : ($json['error']['message'] ?? 'Model test failed') + ]; + } +} \ No newline at end of file diff --git a/inc/ProtocolService.php b/inc/ProtocolService.php new file mode 100644 index 0000000..f0bc61f --- /dev/null +++ b/inc/ProtocolService.php @@ -0,0 +1,407 @@ +query(' + SELECT p.*, + COUNT(DISTINCT sp.server_id) as server_count, + COUNT(DISTINCT pt.id) as template_count, + COUNT(DISTINCT pv.id) as variable_count, + COUNT(DISTINCT ag.id) as ai_generation_count, + MAX(ag.created_at) as last_ai_generation + FROM protocols p + LEFT JOIN server_protocols sp ON p.id = sp.protocol_id + LEFT JOIN protocol_templates pt ON p.id = pt.protocol_id + LEFT JOIN protocol_variables pv ON p.id = pv.protocol_id + LEFT JOIN ai_generations ag ON p.id = ag.protocol_id + GROUP BY p.id + ORDER BY p.name ASC + '); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); + + } catch (Exception $e) { + error_log("Error in ProtocolService::getAllProtocolsWithStats: " . $e->getMessage()); + throw new Exception('Failed to get protocols with stats'); + } + } + + /** + * Get protocol with all related data (templates, variables, AI history) + */ + public static function getProtocolWithDetails(int $protocolId): array + { + try { + $pdo = DB::conn(); + + // Get protocol + $stmt = $pdo->prepare('SELECT * FROM protocols WHERE id = ?'); + $stmt->execute([$protocolId]); + $protocol = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$protocol) { + throw new Exception('Protocol not found'); + } + + // Get templates + $stmt = $pdo->prepare('SELECT * FROM protocol_templates WHERE protocol_id = ? ORDER BY is_default DESC, template_name ASC'); + $stmt->execute([$protocolId]); + $protocol['templates'] = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // Get variables + $stmt = $pdo->prepare('SELECT * FROM protocol_variables WHERE protocol_id = ? ORDER BY variable_name ASC'); + $stmt->execute([$protocolId]); + $protocol['variables'] = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // Get AI generation history (last 10) + $stmt = $pdo->prepare(' + SELECT ag.*, p.name as protocol_name + FROM ai_generations ag + LEFT JOIN protocols p ON ag.protocol_id = p.id + WHERE ag.protocol_id = ? + ORDER BY ag.created_at DESC + LIMIT 10 + '); + $stmt->execute([$protocolId]); + $protocol['ai_history'] = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // Get server usage + $stmt = $pdo->prepare(' + SELECT sp.*, vs.name as server_name, vs.host as server_host + FROM server_protocols sp + JOIN vpn_servers vs ON sp.server_id = vs.id + WHERE sp.protocol_id = ? + ORDER BY sp.applied_at DESC + '); + $stmt->execute([$protocolId]); + $protocol['server_usage'] = $stmt->fetchAll(PDO::FETCH_ASSOC); + + return $protocol; + + } catch (Exception $e) { + error_log("Error in ProtocolService::getProtocolWithDetails: " . $e->getMessage()); + throw new Exception('Failed to get protocol details'); + } + } + + /** + * Validate protocol data before saving + */ + public static function validateProtocolData(array $data): array + { + $errors = []; + + // Validate name + if (empty($data['name'])) { + $errors[] = 'Protocol name is required'; + } elseif (strlen($data['name']) > 255) { + $errors[] = 'Protocol name must be less than 255 characters'; + } + + // Validate slug + if (empty($data['slug'])) { + $errors[] = 'Protocol slug is required'; + } elseif (!preg_match('/^[a-z0-9_-]+$/i', $data['slug'])) { + $errors[] = 'Slug may contain only letters, numbers, dashes, and underscores'; + } elseif (strlen($data['slug']) > 100) { + $errors[] = 'Protocol slug must be less than 100 characters'; + } + + // Validate description length + if (isset($data['description']) && strlen($data['description']) > 65535) { + $errors[] = 'Description is too long'; + } + + // Validate install script + if (isset($data['install_script']) && strlen($data['install_script']) > 16777215) { // MEDIUMTEXT limit + $errors[] = 'Installation script is too long'; + } + + // Validate output template + if (isset($data['output_template']) && strlen($data['output_template']) > 16777215) { // MEDIUMTEXT limit + $errors[] = 'Output template is too long'; + } + + // Validate ubuntu_compatible + if (isset($data['ubuntu_compatible']) && !is_bool($data['ubuntu_compatible']) && !in_array($data['ubuntu_compatible'], [0, 1, '0', '1'])) { + $errors[] = 'Ubuntu compatible must be a boolean value'; + } + + // Validate is_active + if (isset($data['is_active']) && !is_bool($data['is_active']) && !in_array($data['is_active'], [0, 1, '0', '1'])) { + $errors[] = 'Active status must be a boolean value'; + } + + // Validate QR code template + if (isset($data['qr_code_template']) && strlen($data['qr_code_template']) > 16777215) { + $errors[] = 'QR code template is too long'; + } + + // Validate QR code format + if (isset($data['qr_code_format']) && !in_array($data['qr_code_format'], ['raw', 'amnezia_compressed'])) { + $errors[] = 'Invalid QR code format'; + } + + return $errors; + } + + /** + * Check if slug is unique + */ + public static function isSlugUnique(string $slug, ?int $excludeId = null): bool + { + try { + $pdo = DB::conn(); + + if ($excludeId) { + $stmt = $pdo->prepare('SELECT COUNT(*) FROM protocols WHERE slug = ? AND id != ?'); + $stmt->execute([$slug, $excludeId]); + } else { + $stmt = $pdo->prepare('SELECT COUNT(*) FROM protocols WHERE slug = ?'); + $stmt->execute([$slug]); + } + + return (int) $stmt->fetchColumn() === 0; + + } catch (Exception $e) { + error_log("Error in ProtocolService::isSlugUnique: " . $e->getMessage()); + return false; + } + } + + /** + * Check if protocol can be deleted + */ + public static function canDeleteProtocol(int $protocolId): array + { + try { + $pdo = DB::conn(); + + // Check if protocol is used by any servers + $stmt = $pdo->prepare('SELECT COUNT(*) FROM server_protocols WHERE protocol_id = ?'); + $stmt->execute([$protocolId]); + $serverCount = (int) $stmt->fetchColumn(); + + $canDelete = $serverCount === 0; + $reason = ''; + + if (!$canDelete) { + $reason = "Protocol is currently used by $serverCount server(s)"; + } + + return [ + 'can_delete' => $canDelete, + 'reason' => $reason, + 'server_count' => $serverCount + ]; + + } catch (Exception $e) { + error_log("Error in ProtocolService::canDeleteProtocol: " . $e->getMessage()); + return [ + 'can_delete' => false, + 'reason' => 'Database error occurred', + 'server_count' => 0 + ]; + } + } + + /** + * Generate protocol template with variables + */ + public static function generateProtocolOutput(array $protocol, array $variables): string + { + try { + $template = $protocol['output_template'] ?? ''; + + if (empty($template)) { + return ''; + } + + foreach ($variables as $key => $value) { + $template = str_replace('{{' . $key . '}}', $value ?? '', $template); + } + $template = preg_replace('/(\w+:\/\/[^\/:]+):(?=\/|\?|$)/', '$1', $template); + $template = preg_replace('/(@[^\/:]+):(?=\/|\?|$)/', '$1', $template); + $template = preg_replace('/(\w+:\/\/)@(?=[^\/]{1})/', '$1', $template); + $template = preg_replace('/\{\{[^}]+\}\}/', '', $template); + + // Check for unreplaced variables + if (preg_match('/\{\{([^}]+)\}\}/', $template, $matches)) { + error_log("Unreplaced variables in protocol template: " . implode(', ', $matches)); + } + + return $template; + + } catch (Exception $e) { + error_log("Error in ProtocolService::generateProtocolOutput: " . $e->getMessage()); + return ''; + } + } + + /** + * Generate QR code payload from template + */ + public static function generateQrCodePayload(array $protocol, array $variables): string + { + try { + $template = $protocol['qr_code_template'] ?? ''; + $format = $protocol['qr_code_format'] ?? 'amnezia_compressed'; + + if (empty($template)) { + return ''; + } + + // Render template using the same logic as output template + // We temporarily wrap it to use the existing method + $rendered = self::generateProtocolOutput(['output_template' => $template], $variables); + + if ($format === 'amnezia_compressed') { + require_once __DIR__ . '/QrUtil.php'; + return QrUtil::encodeOldPayloadFromJson($rendered); + } + + // For 'raw' and 'text' formats, return rendered template directly + return $rendered; + + } catch (Exception $e) { + error_log("Error in ProtocolService::generateQrCodePayload: " . $e->getMessage()); + return ''; + } + } + + /** + * Get protocol statistics for dashboard + */ + public static function getProtocolStatistics(): array + { + try { + $pdo = DB::conn(); + + // Total protocols + $stmt = $pdo->query('SELECT COUNT(*) FROM protocols'); + $totalProtocols = (int) $stmt->fetchColumn(); + + // Active protocols + $stmt = $pdo->query('SELECT COUNT(*) FROM protocols WHERE is_active = 1'); + $activeProtocols = (int) $stmt->fetchColumn(); + + // Ubuntu compatible protocols + $stmt = $pdo->query('SELECT COUNT(*) FROM protocols WHERE ubuntu_compatible = 1'); + $ubuntuCompatibleProtocols = (int) $stmt->fetchColumn(); + + // Protocols with AI generations + $stmt = $pdo->query(' + SELECT COUNT(DISTINCT protocol_id) + FROM ai_generations + WHERE protocol_id IS NOT NULL + '); + $protocolsWithAI = (int) $stmt->fetchColumn(); + + // Recent AI generations + $stmt = $pdo->query(' + SELECT COUNT(*) + FROM ai_generations + WHERE created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) + '); + $recentAIGenerations = (int) $stmt->fetchColumn(); + + // Server usage by protocol + $stmt = $pdo->query(' + SELECT p.name, COUNT(sp.server_id) as server_count + FROM protocols p + LEFT JOIN server_protocols sp ON p.id = sp.protocol_id + GROUP BY p.id, p.name + ORDER BY server_count DESC + LIMIT 10 + '); + $serverUsageByProtocol = $stmt->fetchAll(PDO::FETCH_ASSOC); + + return [ + 'total_protocols' => $totalProtocols, + 'active_protocols' => $activeProtocols, + 'ubuntu_compatible_protocols' => $ubuntuCompatibleProtocols, + 'protocols_with_ai' => $protocolsWithAI, + 'recent_ai_generations' => $recentAIGenerations, + 'server_usage_by_protocol' => $serverUsageByProtocol, + 'ai_usage_percentage' => $totalProtocols > 0 ? round(($protocolsWithAI / $totalProtocols) * 100, 2) : 0 + ]; + + } catch (Exception $e) { + error_log("Error in ProtocolService::getProtocolStatistics: " . $e->getMessage()); + return [ + 'total_protocols' => 0, + 'active_protocols' => 0, + 'ubuntu_compatible_protocols' => 0, + 'protocols_with_ai' => 0, + 'recent_ai_generations' => 0, + 'server_usage_by_protocol' => [], + 'ai_usage_percentage' => 0 + ]; + } + } + + /** + * Get AI generation statistics + */ + public static function getAIGenerationStatistics(): array + { + try { + $pdo = DB::conn(); + + // Total AI generations + $stmt = $pdo->query('SELECT COUNT(*) FROM ai_generations'); + $totalGenerations = (int) $stmt->fetchColumn(); + + // AI generations this month + $stmt = $pdo->query(' + SELECT COUNT(*) + FROM ai_generations + WHERE MONTH(created_at) = MONTH(NOW()) AND YEAR(created_at) = YEAR(NOW()) + '); + $thisMonthGenerations = (int) $stmt->fetchColumn(); + + // AI generations by model + $stmt = $pdo->query(' + SELECT model_used, COUNT(*) as count + FROM ai_generations + GROUP BY model_used + ORDER BY count DESC + '); + $generationsByModel = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // Ubuntu compatible generations + $stmt = $pdo->query(' + SELECT COUNT(*) + FROM ai_generations + WHERE ubuntu_compatible = 1 + '); + $ubuntuCompatibleGenerations = (int) $stmt->fetchColumn(); + + return [ + 'total_generations' => $totalGenerations, + 'this_month_generations' => $thisMonthGenerations, + 'generations_by_model' => $generationsByModel, + 'ubuntu_compatible_generations' => $ubuntuCompatibleGenerations, + 'ubuntu_compatible_percentage' => $totalGenerations > 0 ? round(($ubuntuCompatibleGenerations / $totalGenerations) * 100, 2) : 0 + ]; + + } catch (Exception $e) { + error_log("Error in ProtocolService::getAIGenerationStatistics: " . $e->getMessage()); + return [ + 'total_generations' => 0, + 'this_month_generations' => 0, + 'generations_by_model' => [], + 'ubuntu_compatible_generations' => 0, + 'ubuntu_compatible_percentage' => 0 + ]; + } + } +} \ No newline at end of file diff --git a/inc/QrUtil.php b/inc/QrUtil.php index 346cafe..e679531 100644 --- a/inc/QrUtil.php +++ b/inc/QrUtil.php @@ -7,8 +7,10 @@ use Endroid\QrCode\Label\Label; use Endroid\QrCode\Label\LabelAlignment; use Endroid\QrCode\Encoding\Encoding; -class QrUtil { - public static function pngBase64(string $text, int $size = 300, int $margin = 1, string $label = 'Amnezia QR (old)') : string { +class QrUtil +{ + public static function pngBase64(string $text, int $size = 300, int $margin = 1, string $label = 'Amnezia QR (old)'): string + { // Try to load Composer autoload if not yet loaded if (!class_exists(QrCode::class)) { $autoload = __DIR__ . '/vendor/autoload.php'; @@ -53,11 +55,13 @@ class QrUtil { throw new RuntimeException('QR library not available'); } - private static function urlsafe_b64_encode(string $bytes): string { + private static function urlsafe_b64_encode(string $bytes): string + { return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); } - public static function encodeOldPayloadFromJson(string $jsonText): string { + public static function encodeOldPayloadFromJson(string $jsonText): string + { $json = self::normalizeJson($jsonText); // Old format uses zlib (gzcompress) with header [version, compressed_len, uncompressed_len] $compressed = gzcompress($json, 9); @@ -71,13 +75,15 @@ class QrUtil { return self::urlsafe_b64_encode($header . $compressed); } - public static function encodeOldPayloadFromConf(string $confText): string { + public static function encodeOldPayloadFromConf(string $confText): string + { $payload = self::buildOldEnvelopeFromConf($confText); return self::encodeOldPayloadFromJson(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); } - private static function resolveServerDescription(?string $endpointHost): string { - $desc = (string)($endpointHost ?? ''); + private static function resolveServerDescription(?string $endpointHost): string + { + $desc = (string) ($endpointHost ?? ''); try { $cfgPath = __DIR__ . '/config.php'; $dbPath = __DIR__ . '/Database.php'; @@ -98,21 +104,32 @@ class QrUtil { return $desc; } - private static function buildOldEnvelopeFromConf(string $conf): array { - $endpointHost = null; $endpointPort = null; $mtu = null; $dns = []; $keepAlive = null; - $privKey = null; $pubKeyServer = null; $psk = null; $address = null; $allowedIps = []; + public static function parseWireGuardConfig(string $conf): array + { + $endpointHost = null; + $endpointPort = null; + $mtu = null; + $dns = []; + $keepAlive = null; + $privKey = null; + $pubKeyServer = null; + $psk = null; + $address = null; + $allowedIps = []; foreach (explode("\n", $conf) as $line) { $line = trim($line); - if ($line === '' || $line[0] === '#') { continue; } + if ($line === '' || $line[0] === '#') { + continue; + } if (stripos($line, 'Endpoint') === 0 && strpos($line, '=') !== false) { [, $v] = array_map('trim', explode('=', $line, 2)); if (preg_match('/^\[?([^\]]+)\]?:([0-9]{2,5})$/', $v, $m)) { $endpointHost = $m[1]; - $endpointPort = (int)$m[2]; + $endpointPort = (int) $m[2]; } } elseif (stripos($line, 'MTU') === 0 && strpos($line, '=') !== false) { [, $v] = array_map('trim', explode('=', $line, 2)); - $mtu = (int)$v; + $mtu = (int) $v; } elseif (stripos($line, 'DNS') === 0 && strpos($line, '=') !== false) { [, $v] = array_map('trim', explode('=', $line, 2)); $dns = array_map('trim', preg_split('/[,\s]+/', $v)); @@ -133,13 +150,19 @@ class QrUtil { $allowedIps = array_map('trim', preg_split('/[,\s]+/', $v)); } elseif (stripos($line, 'PersistentKeepalive') === 0 && strpos($line, '=') !== false) { [, $v] = array_map('trim', explode('=', $line, 2)); - $keepAlive = (int)$v; + $keepAlive = (int) $v; } } - if (!$endpointPort) { $endpointPort = 51820; } - if (!$mtu) { $mtu = 1280; } - if (!$keepAlive) { $keepAlive = 25; } + if (!$endpointPort) { + $endpointPort = 51820; + } + if (!$mtu) { + $mtu = 1280; + } + if (!$keepAlive) { + $keepAlive = 25; + } $dns1 = $dns[0] ?? '1.1.1.1'; $dns2 = $dns[1] ?? '1.0.0.1'; @@ -155,9 +178,15 @@ class QrUtil { // Collect obfuscation params from conf if present $params = [ - 'H1' => null, 'H2' => null, 'H3' => null, 'H4' => null, - 'Jc' => null, 'Jmin' => null, 'Jmax' => null, - 'S1' => null, 'S2' => null, + 'H1' => null, + 'H2' => null, + 'H3' => null, + 'H4' => null, + 'Jc' => null, + 'Jmin' => null, + 'Jmax' => null, + 'S1' => null, + 'S2' => null, ]; foreach (explode("\n", $conf) as $line) { $line = trim($line); @@ -171,27 +200,173 @@ class QrUtil { // Build last_config JSON object (stringified, pretty-printed) $lastConfigObj = [ - 'H1' => (string)($params['H1'] ?? ''), - 'H2' => (string)($params['H2'] ?? ''), - 'H3' => (string)($params['H3'] ?? ''), - 'H4' => (string)($params['H4'] ?? ''), - 'Jc' => (string)($params['Jc'] ?? ''), - 'Jmax' => (string)($params['Jmax'] ?? ''), - 'Jmin' => (string)($params['Jmin'] ?? ''), - 'S1' => (string)($params['S1'] ?? ''), - 'S2' => (string)($params['S2'] ?? ''), + 'H1' => (string) ($params['H1'] ?? ''), + 'H2' => (string) ($params['H2'] ?? ''), + 'H3' => (string) ($params['H3'] ?? ''), + 'H4' => (string) ($params['H4'] ?? ''), + 'Jc' => (string) ($params['Jc'] ?? ''), + 'Jmax' => (string) ($params['Jmax'] ?? ''), + 'Jmin' => (string) ($params['Jmin'] ?? ''), + 'S1' => (string) ($params['S1'] ?? ''), + 'S2' => (string) ($params['S2'] ?? ''), 'allowed_ips' => $allowedIps ?: ['0.0.0.0/0', '::/0'], 'clientId' => $clientPubKey ?: '', - 'client_ip' => preg_replace('/\/(\d{1,2})$/', '', (string)($address ?? '')), - 'client_priv_key' => (string)($privKey ?? ''), + 'client_ip' => preg_replace('/\/(\d{1,2})$/', '', (string) ($address ?? '')), + 'client_priv_key' => (string) ($privKey ?? ''), 'client_pub_key' => $clientPubKey ?: '', 'config' => $conf, - 'hostName' => (string)($endpointHost ?? ''), - 'mtu' => (string)$mtu, - 'persistent_keep_alive' => (string)$keepAlive, + 'hostName' => (string) ($endpointHost ?? ''), + 'mtu' => (string) $mtu, + 'persistent_keep_alive' => (string) $keepAlive, 'port' => $endpointPort, - 'psk_key' => (string)($psk ?? ''), - 'server_pub_key' => (string)($pubKeyServer ?? ''), + 'psk_key' => (string) ($psk ?? ''), + 'server_pub_key' => (string) ($pubKeyServer ?? ''), + ]; + + $serverDesc = self::resolveServerDescription($endpointHost); + + $vars = [ + 'last_config_json' => json_encode($lastConfigObj, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), + 'port' => (string) $endpointPort, + 'description' => $serverDesc, + 'dns1' => $dns1, + 'dns2' => $dns2, + 'hostName' => $endpointHost, + 'client_pub_key' => $clientPubKey, + 'client_priv_key' => $privKey, + 'client_ip' => preg_replace('/\/(\d{1,2})$/', '', (string) ($address ?? '')), + 'psk_key' => $psk, + 'server_pub_key' => $pubKeyServer, + 'mtu' => $mtu, + 'persistent_keep_alive' => $keepAlive, + 'config' => $conf, + ]; + + // Add params to vars + foreach ($params as $k => $v) { + $vars[$k] = (string) ($v ?? ''); + } + + return $vars; + } + + private static function buildOldEnvelopeFromConf(string $conf): array + { + $endpointHost = null; + $endpointPort = null; + $mtu = null; + $dns = []; + $keepAlive = null; + $privKey = null; + $pubKeyServer = null; + $psk = null; + $address = null; + $allowedIps = []; + foreach (explode("\n", $conf) as $line) { + $line = trim($line); + if ($line === '' || $line[0] === '#') { + continue; + } + if (stripos($line, 'Endpoint') === 0 && strpos($line, '=') !== false) { + [, $v] = array_map('trim', explode('=', $line, 2)); + if (preg_match('/^\[?([^\]]+)\]?:([0-9]{2,5})$/', $v, $m)) { + $endpointHost = $m[1]; + $endpointPort = (int) $m[2]; + } + } elseif (stripos($line, 'MTU') === 0 && strpos($line, '=') !== false) { + [, $v] = array_map('trim', explode('=', $line, 2)); + $mtu = (int) $v; + } elseif (stripos($line, 'DNS') === 0 && strpos($line, '=') !== false) { + [, $v] = array_map('trim', explode('=', $line, 2)); + $dns = array_map('trim', preg_split('/[,\s]+/', $v)); + } elseif (stripos($line, 'PrivateKey') === 0 && strpos($line, '=') !== false) { + [, $v] = array_map('trim', explode('=', $line, 2)); + $privKey = $v; + } elseif (stripos($line, 'PublicKey') === 0 && strpos($line, '=') !== false) { + [, $v] = array_map('trim', explode('=', $line, 2)); + $pubKeyServer = $v; + } elseif (stripos($line, 'PresharedKey') === 0 && strpos($line, '=') !== false) { + [, $v] = array_map('trim', explode('=', $line, 2)); + $psk = $v; + } elseif (stripos($line, 'Address') === 0 && strpos($line, '=') !== false) { + [, $v] = array_map('trim', explode('=', $line, 2)); + $address = $v; + } elseif (stripos($line, 'AllowedIPs') === 0 && strpos($line, '=') !== false) { + [, $v] = array_map('trim', explode('=', $line, 2)); + $allowedIps = array_map('trim', preg_split('/[,\s]+/', $v)); + } elseif (stripos($line, 'PersistentKeepalive') === 0 && strpos($line, '=') !== false) { + [, $v] = array_map('trim', explode('=', $line, 2)); + $keepAlive = (int) $v; + } + } + + if (!$endpointPort) { + $endpointPort = 51820; + } + if (!$mtu) { + $mtu = 1280; + } + if (!$keepAlive) { + $keepAlive = 25; + } + $dns1 = $dns[0] ?? '1.1.1.1'; + $dns2 = $dns[1] ?? '1.0.0.1'; + + // Derive client public key if sodium available + $clientPubKey = ''; + if ($privKey && function_exists('sodium_crypto_scalarmult_base')) { + $bin = base64_decode($privKey, true); + if ($bin !== false && strlen($bin) === 32) { + $pub = sodium_crypto_scalarmult_base($bin); + $clientPubKey = base64_encode($pub); + } + } + + // Collect obfuscation params from conf if present + $params = [ + 'H1' => null, + 'H2' => null, + 'H3' => null, + 'H4' => null, + 'Jc' => null, + 'Jmin' => null, + 'Jmax' => null, + 'S1' => null, + 'S2' => null, + ]; + foreach (explode("\n", $conf) as $line) { + $line = trim($line); + foreach (array_keys($params) as $k) { + if (stripos($line, $k) === 0 && strpos($line, '=') !== false) { + [, $v] = array_map('trim', explode('=', $line, 2)); + $params[$k] = $v; + } + } + } + + // Build last_config JSON object (stringified, pretty-printed) + $lastConfigObj = [ + 'H1' => (string) ($params['H1'] ?? ''), + 'H2' => (string) ($params['H2'] ?? ''), + 'H3' => (string) ($params['H3'] ?? ''), + 'H4' => (string) ($params['H4'] ?? ''), + 'Jc' => (string) ($params['Jc'] ?? ''), + 'Jmax' => (string) ($params['Jmax'] ?? ''), + 'Jmin' => (string) ($params['Jmin'] ?? ''), + 'S1' => (string) ($params['S1'] ?? ''), + 'S2' => (string) ($params['S2'] ?? ''), + 'allowed_ips' => $allowedIps ?: ['0.0.0.0/0', '::/0'], + 'clientId' => $clientPubKey ?: '', + 'client_ip' => preg_replace('/\/(\d{1,2})$/', '', (string) ($address ?? '')), + 'client_priv_key' => (string) ($privKey ?? ''), + 'client_pub_key' => $clientPubKey ?: '', + 'config' => $conf, + 'hostName' => (string) ($endpointHost ?? ''), + 'mtu' => (string) $mtu, + 'persistent_keep_alive' => (string) $keepAlive, + 'port' => $endpointPort, + 'psk_key' => (string) ($psk ?? ''), + 'server_pub_key' => (string) ($pubKeyServer ?? ''), ]; $serverDesc = self::resolveServerDescription($endpointHost); @@ -202,17 +377,17 @@ class QrUtil { [ // awg first, then container (as in the working QR) 'awg' => [ - 'H1' => (string)($params['H1'] ?? ''), - 'H2' => (string)($params['H2'] ?? ''), - 'H3' => (string)($params['H3'] ?? ''), - 'H4' => (string)($params['H4'] ?? ''), - 'Jc' => (string)($params['Jc'] ?? ''), - 'Jmax' => (string)($params['Jmax'] ?? ''), - 'Jmin' => (string)($params['Jmin'] ?? ''), - 'S1' => (string)($params['S1'] ?? ''), - 'S2' => (string)($params['S2'] ?? ''), + 'H1' => (string) ($params['H1'] ?? ''), + 'H2' => (string) ($params['H2'] ?? ''), + 'H3' => (string) ($params['H3'] ?? ''), + 'H4' => (string) ($params['H4'] ?? ''), + 'Jc' => (string) ($params['Jc'] ?? ''), + 'Jmax' => (string) ($params['Jmax'] ?? ''), + 'Jmin' => (string) ($params['Jmin'] ?? ''), + 'S1' => (string) ($params['S1'] ?? ''), + 'S2' => (string) ($params['S2'] ?? ''), 'last_config' => json_encode($lastConfigObj, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), - 'port' => (string)$endpointPort, + 'port' => (string) $endpointPort, 'transport_proto' => 'udp', ], 'container' => 'amnezia-awg', @@ -227,9 +402,78 @@ class QrUtil { return $envelope; } - private static function normalizeJson(string $text): string { + private static function normalizeJson(string $text): string + { $decoded = json_decode($text, true); - if (!is_array($decoded)) throw new InvalidArgumentException('Invalid JSON'); + if (!is_array($decoded)) + throw new InvalidArgumentException('Invalid JSON'); return json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); } + + public static function encodeXrayPayload(string $host, int $port, string $clientId, string $description = '', ?array $reality = null): string + { + $desc = $description !== '' ? $description : self::resolveServerDescription($host); + $clientCfg = [ + 'log' => ['loglevel' => 'error'], + 'inbounds' => [ + [ + 'listen' => '127.0.0.1', + 'port' => 10808, + 'protocol' => 'socks', + 'settings' => ['udp' => true] + ] + ], + 'outbounds' => [ + [ + 'protocol' => 'vless', + 'settings' => [ + 'vnext' => [ + [ + 'address' => $host, + 'port' => $port, + 'users' => [ + [ + 'id' => $clientId, + 'flow' => ($reality && isset($reality['publicKey']) && $reality['publicKey'] !== '') ? 'xtls-rprx-vision' : null, + 'encryption' => 'none' + ] + ] + ] + ] + ], + 'streamSettings' => [ + 'network' => 'tcp', + 'security' => ($reality && isset($reality['publicKey']) && $reality['publicKey'] !== '') ? 'reality' : 'none', + 'realitySettings' => ($reality && isset($reality['publicKey']) && $reality['publicKey'] !== '') ? [ + 'fingerprint' => 'chrome', + 'serverName' => (string) ($reality['serverName'] ?? $host), + 'publicKey' => (string) $reality['publicKey'], + 'shortId' => (string) ($reality['shortId'] ?? ''), + 'spiderX' => '' + ] : null + ] + ] + ] + ]; + + $envelope = [ + 'containers' => [ + [ + 'container' => 'amnezia-xray', + 'xray' => [ + 'last_config' => json_encode($clientCfg, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), + 'port' => (string) $port, + 'transport_proto' => 'tcp' + ] + ] + ], + 'defaultContainer' => 'amnezia-xray', + 'description' => $desc, + 'dns1' => '1.1.1.1', + 'dns2' => '1.0.0.1', + 'hostName' => $host, + ]; + + return self::encodeOldPayloadFromJson(json_encode($envelope, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + } } \ No newline at end of file diff --git a/inc/Router.php b/inc/Router.php index 122e855..884d936 100644 --- a/inc/Router.php +++ b/inc/Router.php @@ -11,6 +11,7 @@ class Router { } public static function get(string $pattern, callable $handler): void { self::add('GET', $pattern, $handler); } public static function post(string $pattern, callable $handler): void { self::add('POST', $pattern, $handler); } + public static function put(string $pattern, callable $handler): void { self::add('PUT', $pattern, $handler); } public static function delete(string $pattern, callable $handler): void { self::add('DELETE', $pattern, $handler); } private static function normalizePattern(string $pattern): string { diff --git a/inc/View.php b/inc/View.php index dcb8341..986e87c 100644 --- a/inc/View.php +++ b/inc/View.php @@ -2,6 +2,7 @@ use Twig\Environment; use Twig\Loader\FilesystemLoader; use Twig\TwigFunction; +use Twig\TwigFilter; class View { private static ?Environment $twig = null; @@ -36,6 +37,22 @@ class View { }); self::$twig->addFunction($flagFunc); + // Add bytes format filter + $bytesFilter = new TwigFilter('bytes_format', function (int $bytes, int $precision = 2): string { + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) { + $bytes /= 1024; + } + return round($bytes, $precision) . ' ' . $units[$i]; + }); + self::$twig->addFilter($bytesFilter); + + // Add translation filter (alias: trans) + $transFilter = new TwigFilter('trans', function (string $key, array $params = []) { + return Translator::t($key, $params); + }); + self::$twig->addFilter($transFilter); + // Add globals foreach ($globals as $k => $v) self::$twig->addGlobal($k, $v); } diff --git a/inc/VpnClient.php b/inc/VpnClient.php index de6500b..671a6c4 100644 --- a/inc/VpnClient.php +++ b/inc/VpnClient.php @@ -4,21 +4,24 @@ * Handles creation and management of VPN client configurations * Based on amnezia_client_config_v2.php */ -class VpnClient { +class VpnClient +{ private $clientId; private $data; - - public function __construct(?int $clientId = null) { + + public function __construct(?int $clientId = null) + { $this->clientId = $clientId; if ($clientId) { $this->load(); } } - + /** * Load client data from database */ - private function load(): void { + private function load(): void + { $pdo = DB::conn(); $stmt = $pdo->prepare('SELECT * FROM vpn_clients WHERE id = ?'); $stmt->execute([$this->clientId]); @@ -27,7 +30,7 @@ class VpnClient { throw new Exception('Client not found'); } } - + /** * Create new VPN client * @@ -37,87 +40,523 @@ class VpnClient { * @param int|null $expiresInDays Days until expiration (null = never expires) * @return int Client ID */ - public static function create(int $serverId, int $userId, string $name, ?int $expiresInDays = null): int { + public static function create(int $serverId, int $userId, string $name, ?int $expiresInDays = null, ?int $protocolId = null, ?string $username = null, ?string $login = null): int + { $pdo = DB::conn(); - - // Sanitize client name (replace only spaces with underscores, allow any other characters including Cyrillic) + $name = trim($name); - $name = str_replace(' ', '_', $name); - + // Get server data $server = new VpnServer($serverId); $serverData = $server->getData(); - + if (!$serverData || $serverData['status'] !== 'active') { throw new Exception('Server is not active'); } - - // Generate client keys - $containerName = $serverData['container_name']; - $keys = self::generateClientKeys($serverData, $name); - - // Get next available IP + + // Determine protocol before sync + $protoRow = null; + if ($protocolId === null) { + $stmtProto = $pdo->prepare('SELECT id FROM protocols WHERE slug = ? LIMIT 1'); + $stmtProto->execute([$serverData['install_protocol'] ?? '']); + $protocolId = (int) $stmtProto->fetchColumn(); + } + if ($protocolId) { + $stmtProto2 = $pdo->prepare('SELECT * FROM protocols WHERE id = ?'); + $stmtProto2->execute([$protocolId]); + $protoRow = $stmtProto2->fetch(); + } + $slug = $protoRow['slug'] ?? ($serverData['install_protocol'] ?? 'amnezia-wg'); + $isWireguard = in_array($slug, ['amnezia-wg-advanced', 'wireguard-standard', 'amnezia-wg'], true); + + // Auto-sync server keys from container EVERY TIME for WireGuard protocols + // This ensures we always use current container configuration even if it was recreated + if ($isWireguard) { + try { + self::syncServerKeysFromContainer($server, $serverData); + // Reload server data after sync (VpnServer caches DB row in-memory) + $server->refresh(); + $serverData = $server->getData(); + } catch (Exception $e) { + error_log('Failed to auto-sync server keys: ' . $e->getMessage()); + // Continue anyway - might fail later but let's try + } + } + $clientIP = self::getNextClientIP($serverData); - - // Get AWG parameters from server - $awgParams = json_decode($serverData['awg_params'], true); - - // Build client configuration - $config = self::buildClientConfig( - $keys['private'], - $clientIP, - $serverData['server_public_key'], - $serverData['preshared_key'], - $serverData['host'], - $serverData['vpn_port'], - $awgParams - ); - - // Add client to server - self::addClientToServer($serverData, $keys['public'], $clientIP); - - // Generate QR code - $qrCode = self::generateQRCode($config); - + $loginBase = $login !== null && $login !== '' ? $login : $name; + $loginBase = str_replace(' ', '_', trim($loginBase)); + $loginFinal = $loginBase; + $suffix = 2; + while (true) { + $stmtChk = $pdo->prepare('SELECT COUNT(*) FROM vpn_clients WHERE server_id = ? AND name = ?'); + $stmtChk->execute([$serverId, $loginFinal]); + if ((int) $stmtChk->fetchColumn() === 0) + break; + $loginFinal = $loginBase . '-' . $suffix; + $suffix++; + } + + if ($isWireguard) { + $containerName = $serverData['container_name']; + $keys = self::generateClientKeys($serverData, $name); + + // Re-fetch awg_params after possible auto-sync + $awgParams = json_decode($serverData['awg_params'] ?? '{}', true) ?? []; + + // Build variables for template + $vars = [ + 'private_key' => $keys['private'], + 'client_ip' => $clientIP, + 'server_public_key' => $serverData['server_public_key'], + 'preshared_key' => $serverData['preshared_key'], + 'server_host' => $serverData['host'], + 'server_port' => $serverData['vpn_port'], + 'dns_servers' => $serverData['dns_servers'] ?? '1.1.1.1, 1.0.0.1', + ]; + + + // Add AWG parameters (use UPPERCASE keys as extracted from container) + foreach (['JC', 'JMIN', 'JMAX', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4'] as $key) { + if (isset($awgParams[$key])) { + $vars[$key] = $awgParams[$key]; + } else { + // Default values for AWG params + $defaults = [ + 'JC' => 5, + 'JMIN' => 100, + 'JMAX' => 200, + 'S1' => 50, + 'S2' => 100, + 'H1' => 1, + 'H2' => 2, + 'H3' => 3, + 'H4' => 4, + ]; + $vars[$key] = $defaults[$key] ?? 0; + } + } + + // Backward/Template compatibility: the AWG client template uses Jc/Jmin/Jmax (not all-caps). + // Ensure those placeholders are always populated. + if (!isset($vars['Jc']) && isset($vars['JC'])) { + $vars['Jc'] = (string) $vars['JC']; + } + if (!isset($vars['Jmin']) && isset($vars['JMIN'])) { + $vars['Jmin'] = (string) $vars['JMIN']; + } + if (!isset($vars['Jmax']) && isset($vars['JMAX'])) { + $vars['Jmax'] = (string) $vars['JMAX']; + } + + // Generate config from template + if ($protoRow && !empty($protoRow['output_template'])) { + require_once __DIR__ . '/ProtocolService.php'; + $config = ProtocolService::generateProtocolOutput($protoRow, $vars); + } else { + // Fallback to old method if no template + $config = self::buildClientConfig( + $keys['private'], + $clientIP, + $serverData['server_public_key'], + $serverData['preshared_key'], + $serverData['host'], + $serverData['vpn_port'], + is_array($awgParams) ? $awgParams : [] + ); + } + + self::addClientToServer($serverData, $keys['public'], $clientIP); + $qrCode = self::generateQRCode($config); + $priv = $keys['private']; + $pub = $keys['public']; + $psk = $serverData['preshared_key']; + $pass = null; + } else { + $vars = []; + $vars['private_key'] = ''; + $vars['client_ip'] = $clientIP; + $vars['server_host'] = $serverData['host'] ?? ''; + $vars['server_port'] = $serverData['vpn_port'] ?? ''; + $extras = []; + if ($protocolId) { + try { + $stmtSp = $pdo->prepare('SELECT config_data FROM server_protocols WHERE server_id = ? AND protocol_id = ? LIMIT 1'); + $stmtSp->execute([$serverId, $protocolId]); + $cfg = $stmtSp->fetchColumn(); + if ($cfg) { + $conf = is_string($cfg) ? json_decode($cfg, true) : $cfg; + if (is_array($conf)) { + $vars['server_host'] = $conf['server_host'] ?? $vars['server_host']; + $vars['server_port'] = $conf['server_port'] ?? $vars['server_port']; + $extras = $conf['extras'] ?? []; + } + } + } catch (Exception $e) { + } + } + if (is_array($extras)) { + // If extras has 'result' subarray, merge it into extras for processing + if (isset($extras['result']) && is_array($extras['result'])) { + $extras = array_merge($extras, $extras['result']); + } + + foreach ($extras as $k => $v) { + if (is_scalar($v)) { + // Preserve uppercase for AWG obfuscation parameters + if (in_array($k, ['Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4'], true)) { + $vars[$k] = (string) $v; + } else { + $vars[strtolower($k)] = (string) $v; + } + } + } + if (isset($vars['publickey']) && empty($vars['reality_public_key'])) { + $vars['reality_public_key'] = $vars['publickey']; + } + if (isset($vars['shortid']) && empty($vars['reality_short_id'])) { + $vars['reality_short_id'] = $vars['shortid']; + } + if (isset($vars['servername']) && empty($vars['reality_server_name'])) { + $vars['reality_server_name'] = $vars['servername']; + } + if (isset($vars['containername']) && empty($vars['container_name'])) { + $vars['container_name'] = $vars['containername']; + } + } + if ($slug === 'xray-vless') { + if (empty($vars['server_port'])) { + if (is_array($extras) && isset($extras['result']) && is_array($extras['result'])) { + $res = $extras['result']; + if (isset($res['xray_port']) && is_scalar($res['xray_port'])) { + $vars['server_port'] = (string) $res['xray_port']; + } + if (empty($vars['server_port'])) { + foreach ($res as $rk => $rv) { + if (is_string($rk) && stripos($rk, 'xray_port') !== false && is_scalar($rv)) { + $vars['server_port'] = (string) $rv; + break; + } + } + } + } + } + $needReality = empty($vars['reality_public_key']) || empty($vars['reality_server_name']) || empty($vars['reality_short_id']); + if (empty($vars['client_id']) || $needReality) { + $containerName = 'amnezia-xray'; + if (is_array($extras) && isset($extras['result']) && is_array($extras['result'])) { + $res = $extras['result']; + if (isset($res['container_name']) && is_scalar($res['container_name'])) { + $containerName = trim((string) $res['container_name']) ?: $containerName; + } + } + try { + $cfg = $server->executeCommand("docker exec -i " . escapeshellarg($containerName) . " cat /opt/amnezia/xray/server.json 2>/dev/null", true); + if (trim((string) $cfg) === '') { + $cfg = $server->executeCommand("docker exec -i " . escapeshellarg($containerName) . " cat /etc/xray/config.json 2>/dev/null", true); + } + $decoded = json_decode(trim((string) $cfg), true); + if (is_array($decoded)) { + $inbounds = $decoded['inbounds'] ?? []; + if (is_array($inbounds) && !empty($inbounds)) { + $settings = $inbounds[0]['settings'] ?? []; + $clients = $settings['clients'] ?? []; + if (is_array($clients) && !empty($clients)) { + $cid = $clients[0]['id'] ?? null; + if (is_string($cid) && $cid !== '' && empty($vars['client_id'])) { + $vars['client_id'] = $cid; + } + } + $stream = $inbounds[0]['streamSettings'] ?? []; + if (is_array($stream) && ($stream['security'] ?? '') === 'reality') { + $rs = $stream['realitySettings'] ?? []; + $serverNames = $rs['serverNames'] ?? ($rs['serverName'] ?? []); + $shortIds = $rs['shortIds'] ?? ($rs['shortId'] ?? []); + $serverName = is_array($serverNames) ? ($serverNames[0] ?? null) : (is_string($serverNames) ? $serverNames : null); + $shortId = is_array($shortIds) ? ($shortIds[0] ?? null) : (is_string($shortIds) ? $shortIds : null); + $privateKey = $rs['privateKey'] ?? null; + if (is_string($serverName) && $serverName !== '') { + $vars['reality_server_name'] = $serverName; + } + if (is_string($shortId) && $shortId !== '') { + $vars['reality_short_id'] = $shortId; + } + if (is_string($privateKey) && $privateKey !== '' && function_exists('sodium_crypto_scalarmult_base')) { + $b64 = strtr($privateKey, '-_', '+/'); + $padLen = strlen($b64) % 4; + if ($padLen) { + $b64 .= str_repeat('=', 4 - $padLen); + } + $bin = base64_decode($b64, true); + if ($bin === false) { + $pk = $privateKey; + $padLen2 = strlen($pk) % 4; + if ($padLen2) { + $pk .= str_repeat('=', 4 - $padLen2); + } + $bin = base64_decode($pk, true); + } + if (is_string($bin) && strlen($bin) === 32) { + $pub = sodium_crypto_scalarmult_base($bin); + $vars['reality_public_key'] = rtrim(strtr(base64_encode($pub), '+/', '-_'), '='); + } + } + if (is_string($privateKey) && $privateKey !== '' && empty($vars['reality_public_key'])) { + $cmd = "docker exec -i " . escapeshellarg($containerName) . " /usr/bin/xray x25519 -i " . escapeshellarg($privateKey) . " 2>/dev/null"; + $out = $server->executeCommand($cmd, true); + $outTrim = trim((string) $out); + if ($outTrim !== '') { + $pub = ''; + if (preg_match('/[Pp]ublic\s*[Kk]ey[:\s]+(.+)/', $outTrim, $mm)) { + $pub = trim((string) $mm[1]); + } else { + $pub = $outTrim; + } + if ($pub !== '') { + $vars['reality_public_key'] = $pub; + } + } + } + } + } + } + } catch (Exception $e) { + } + } + } + if ($slug === 'openvpn') { + $containerName = $serverData['container_name'] ?? 'openvpn'; + $config = ''; + + // Try to generate config via Docker + try { + // 1. Generate client certificate (ignore output) + $server->executeCommand("docker run --rm -v openvpn-data:/etc/openvpn kylemanna/openvpn easyrsa build-client-full " . escapeshellarg($loginFinal) . " nopass", true); + + // 2. Get full client config + $fullConfig = $server->executeCommand("docker run --rm -v openvpn-data:/etc/openvpn kylemanna/openvpn ovpn_getclient " . escapeshellarg($loginFinal), true); + + if (trim($fullConfig) !== '' && strpos($fullConfig, 'BEGIN CERTIFICATE') !== false) { + $config = $fullConfig; + $protoRow = null; // Skip template generation + } + } catch (Exception $e) { + // Fallback to template + } + + if (empty($config)) { + if (empty($vars['server_port']) || !preg_match('/^\d+$/', (string) $vars['server_port'])) { + $vars['server_port'] = '1194'; + } + if (empty($vars['protocol'])) { + $vars['protocol'] = 'udp'; + } + if (empty($vars['proto'])) { + $vars['proto'] = $vars['protocol']; + } + if (empty($vars['port'])) { + $vars['port'] = $vars['server_port']; + } + if (empty($vars['host'])) { + $vars['host'] = $vars['server_host']; + } + } + } + $pass = null; + $pwdCmd = isset($protoRow['password_command']) ? trim((string) $protoRow['password_command']) : ''; + if ($pwdCmd !== '') { + try { + $wrapper = "bash <<'EOS'\nLOGIN=" . escapeshellarg($loginFinal) . "\n" . $pwdCmd . "\nEOS"; + $out = $server->executeCommand($wrapper, true); + $passTrim = trim((string) $out); + if ($passTrim !== '') + $pass = $passTrim; + } catch (Exception $e) { + } + } + if ($pass === null) { + if (!empty($vars['password'])) { + $pass = (string) $vars['password']; + } else { + $pass = 'amnezia'; + } + } + $vars['login'] = $loginFinal; + $vars['password'] = $pass; + if (($slug ?? '') === 'smb' && empty($vars['password'])) { + $vars['password'] = $pass; + } + $config = $protoRow ? ProtocolService::generateProtocolOutput($protoRow, $vars) : ''; + + // Prepare last_config_json for QR code generation if config is JSON (XRay) + if ($config !== '' && ($decoded = json_decode($config)) !== null) { + $vars['last_config_json'] = json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + } + + $qrCode = self::generateQRCode($config); + + $priv = ''; + $pub = ''; + $psk = ''; + } + // Calculate expiration date $expiresAt = $expiresInDays ? date('Y-m-d H:i:s', strtotime("+{$expiresInDays} days")) : null; - + // Insert into database $stmt = $pdo->prepare(' INSERT INTO vpn_clients - (server_id, user_id, name, client_ip, public_key, private_key, preshared_key, config, qr_code, status, expires_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (server_id, user_id, protocol_id, name, client_ip, public_key, private_key, preshared_key, config, qr_code, status, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) '); - + $stmt->execute([ $serverId, $userId, - $name, + $protocolId ?: null, + $loginFinal, $clientIP, - $keys['public'], - $keys['private'], - $serverData['preshared_key'], + $pub, + $priv, + $psk, $config, $qrCode, 'active', $expiresAt ]); - - return (int)$pdo->lastInsertId(); + + return (int) $pdo->lastInsertId(); } - + + public static function listByServerAndProtocol(int $serverId, int $protocolId): array + { + $pdo = DB::conn(); + $stmt = $pdo->prepare(' + SELECT c.*, p.name as protocol_name + FROM vpn_clients c + LEFT JOIN protocols p ON c.protocol_id = p.id + WHERE c.server_id = ? AND c.protocol_id = ? + ORDER BY c.created_at DESC + '); + $stmt->execute([$serverId, $protocolId]); + return $stmt->fetchAll(); + } + + /** + * Import client data directly from backup without touching remote server. + */ + public static function importFromBackup(array $serverData, int $userId, array $clientData): ?int + { + if (empty($serverData['id'])) { + throw new Exception('Server must be saved before importing clients'); + } + + $pdo = DB::conn(); + + $clientIp = trim($clientData['client_ip'] ?? ''); + $publicKey = trim($clientData['public_key'] ?? ''); + $privateKey = trim($clientData['private_key'] ?? ''); + + if ($clientIp === '' || $publicKey === '' || $privateKey === '') { + throw new Exception('Client backup data is incomplete'); + } + + // Skip if client with same IP already exists + $stmt = $pdo->prepare('SELECT id FROM vpn_clients WHERE server_id = ? AND client_ip = ? LIMIT 1'); + $stmt->execute([$serverData['id'], $clientIp]); + if ($stmt->fetchColumn()) { + return null; + } + + $name = trim($clientData['name'] ?? ''); + if ($name === '') { + $name = $clientIp; + } + + $presharedKey = $clientData['preshared_key'] ?? ($serverData['preshared_key'] ?? ''); + $config = $clientData['config'] ?? ''; + + if ($config === '' && !empty($serverData['server_public_key']) && !empty($serverData['host']) && !empty($serverData['vpn_port'])) { + $awgParams = json_decode($serverData['awg_params'] ?? '{}', true); + if (!is_array($awgParams)) { + $awgParams = []; + } + $config = self::buildClientConfig( + $privateKey, + $clientIp, + $serverData['server_public_key'], + $presharedKey, + $serverData['host'], + (int) $serverData['vpn_port'], + $awgParams + ); + } + + // Try to fetch protocol for QR code generation + $protocol = null; + if (!empty($serverData['install_protocol'])) { + $stmtP = $pdo->prepare('SELECT * FROM protocols WHERE slug = ?'); + $stmtP->execute([$serverData['install_protocol']]); + $protocol = $stmtP->fetch(PDO::FETCH_ASSOC); + } + + $vars = []; + // Prepare last_config_json if config is JSON + if ($config !== '' && ($decoded = json_decode($config)) !== null) { + $vars['last_config_json'] = json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + } + + $qrCode = $config !== '' ? self::generateQRCode($config) : ''; + $status = strtolower($clientData['status'] ?? 'active') === 'disabled' ? 'disabled' : 'active'; + + $expiresAt = $clientData['expires_at'] ?? null; + if ($expiresAt) { + $timestamp = strtotime($expiresAt); + $expiresAt = $timestamp ? date('Y-m-d H:i:s', $timestamp) : null; + } + + $stmt = $pdo->prepare(' + INSERT INTO vpn_clients + (server_id, user_id, name, client_ip, public_key, private_key, preshared_key, config, qr_code, status, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + '); + + $stmt->execute([ + $serverData['id'], + $userId, + $name, + $clientIp, + $publicKey, + $privateKey, + $presharedKey, + $config, + $qrCode, + $status, + $expiresAt + ]); + + return (int) $pdo->lastInsertId(); + } + /** * Generate client keys on remote server */ - private static function generateClientKeys(array $serverData, string $clientName): array { + private static function generateClientKeys(array $serverData, string $clientName): array + { $containerName = $serverData['container_name']; - + $token = bin2hex(random_bytes(8)); + $cmd = sprintf( "docker exec -i %s sh -c \"umask 077; wg genkey | tee /tmp/%s_priv.key | wg pubkey > /tmp/%s_pub.key; cat /tmp/%s_priv.key; echo '---'; cat /tmp/%s_pub.key; rm -f /tmp/%s_priv.key /tmp/%s_pub.key\"", $containerName, - $clientName, $clientName, $clientName, $clientName, $clientName, $clientName + $token, + $token, + $token, + $token, + $token, + $token ); - + $escaped = escapeshellarg($cmd); $sshCmd = sprintf( "sshpass -p '%s' ssh -p %d -q -o LogLevel=ERROR -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o PreferredAuthentications=password -o PubkeyAuthentication=no %s@%s %s 2>&1", @@ -127,41 +566,63 @@ class VpnClient { $serverData['host'], $escaped ); - + $out = shell_exec($sshCmd); $parts = explode("---", trim($out)); - + if (count($parts) < 2) { throw new Exception("Failed to generate client keys"); } - + return [ 'private' => trim($parts[0]), 'public' => trim($parts[1]) ]; } - + /** * Get next available client IP */ - private static function getNextClientIP(array $serverData): string { + private static function getNextClientIP(array $serverData): string + { $pdo = DB::conn(); - + // Get used IPs from database $stmt = $pdo->prepare('SELECT client_ip FROM vpn_clients WHERE server_id = ?'); $stmt->execute([$serverData['id']]); $usedIPs = $stmt->fetchAll(PDO::FETCH_COLUMN); - - // Parse subnet - $parts = explode('/', $serverData['vpn_subnet']); - $networkLong = ip2long($parts[0]); - + // Reserve network address $used = ['10.8.1.0' => true]; foreach ($usedIPs as $ip) { $used[$ip] = true; } - + + // ALSO check IPs used in actual server config (catches clients created outside web panel) + try { + $containerName = $serverData['container_name'] ?? 'amnezia-awg'; + $server = new VpnServer($serverData['id']); + $cmd = sprintf( + "docker exec %s cat /opt/amnezia/awg/wg0.conf 2>/dev/null", + escapeshellarg($containerName) + ); + $serverConfig = $server->executeCommand($cmd, true); + + // Extract AllowedIPs from all peers + if (preg_match_all('/AllowedIPs\s*=\s*([0-9.]+)\/\d+/i', $serverConfig, $matches)) { + foreach ($matches[1] as $ip) { + $used[$ip] = true; + } + } + } catch (Exception $e) { + error_log('Failed to check server config for used IPs: ' . $e->getMessage()); + // Continue with DB-only check + } + + // Parse subnet + $parts = explode('/', $serverData['vpn_subnet']); + $networkLong = ip2long($parts[0]); + // Find next free IP starting from .1 for ($i = 1; $i <= 253; $i++) { $candidate = long2ip($networkLong + $i); @@ -169,10 +630,166 @@ class VpnClient { return $candidate; } } - + throw new Exception('No free IP addresses in subnet'); } - + + /** + * Auto-sync server keys from running container (for externally installed protocols) + */ + private static function extractAwgParamsFromWg0Conf(VpnServer $server, string $containerName, string $confPath): array + { + $awgParams = []; + + $awgLinesCmd = sprintf( + "docker exec %s sh -c \"grep -E '^[[:space:]]*(Jc|Jmin|Jmax|S1|S2|H1|H2|H3|H4)[[:space:]]*=' %s 2>/dev/null || true\"", + escapeshellarg($containerName), + escapeshellarg($confPath) + ); + $awgLines = (string) $server->executeCommand($awgLinesCmd, true); + + foreach (preg_split('/\r?\n/', trim($awgLines)) as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + if (preg_match('/^(Jc|Jmin|Jmax|S1|S2|H1|H2|H3|H4)\s*=\s*(\d+)\s*$/i', $line, $m)) { + $k = strtoupper($m[1]); + $awgParams[$k] = (int) $m[2]; + } + } + + return $awgParams; + } + + private static function extractPeerPskFromWgDump(VpnServer $server, string $containerName, string $clientPublicKey): ?string + { + $clientPublicKey = trim($clientPublicKey); + if ($clientPublicKey === '') { + return null; + } + + // wg show wg0 dump peer line format: + // public_key \t preshared_key \t endpoint \t allowed_ips \t latest_handshake \t rx \t tx \t keepalive + $cmdDump = sprintf('docker exec %s wg show wg0 dump 2>/dev/null || true', escapeshellarg($containerName)); + $dump = (string) $server->executeCommand($cmdDump, true); + foreach (preg_split('/\r?\n/', trim($dump)) as $line) { + if ($line === '') { + continue; + } + // Skip interface header line (has many fields but first field is private key) + if (strpos($line, '\t') === false) { + continue; + } + if (strpos($line, $clientPublicKey . "\t") !== 0) { + continue; + } + + $parts = explode("\t", $line); + if (count($parts) < 2) { + return null; + } + $psk = trim((string) $parts[1]); + if ($psk === '' || $psk === '(none)') { + return null; + } + return $psk; + } + + return null; + } + + private static function syncServerKeysFromContainer(VpnServer $server, array $serverData): void + { + $containerName = $serverData['container_name'] ?? 'amnezia-awg'; + + try { + // Try to get public key from wg show + $pubKeyCmd = "docker exec $containerName wg show wg0 2>/dev/null | grep 'public key:' | awk '{print \$3}'"; + $pubKey = trim($server->executeCommand($pubKeyCmd, true)); + + // Get listening port + $portCmd = "docker exec $containerName wg show wg0 2>/dev/null | grep 'listening port:' | awk '{print \$3}'"; + $port = trim($server->executeCommand($portCmd, true)); + + // PresharedKey is stored per-peer, and in this project we persist it in wireguard_psk.key. + // Prefer that file (stable) and fall back to parsing the first peer PSK from wg0.conf. + $psk = ''; + + $pskKeyFileCmd = "docker exec $containerName sh -c \"cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true\""; + $psk = trim($server->executeCommand($pskKeyFileCmd, true)); + + if ($psk === '') { + $pskFromConfCmd = "docker exec $containerName sh -c \"grep -E '^[[:space:]]*PresharedKey[[:space:]]*=' /opt/amnezia/awg/wg0.conf 2>/dev/null | head -1 | sed -E 's/^[[:space:]]*PresharedKey[[:space:]]*=[[:space:]]*//' | tr -d '\\r'\" 2>/dev/null || true"; + $psk = trim($server->executeCommand($pskFromConfCmd, true)); + } + + if ($psk === '') { + $pskFromAltConfCmd = "docker exec $containerName sh -c \"grep -E '^[[:space:]]*PresharedKey[[:space:]]*=' /etc/wireguard/wg0.conf 2>/dev/null | head -1 | sed -E 's/^[[:space:]]*PresharedKey[[:space:]]*=[[:space:]]*//' | tr -d '\\r'\" 2>/dev/null || true"; + $psk = trim($server->executeCommand($pskFromAltConfCmd, true)); + } + + // Extract DNS from config + $dnsCmd = "docker exec $containerName sh -c \"grep -E '^DNS' /opt/amnezia/awg/wg0.conf 2>/dev/null | head -1 | cut -d= -f2 | tr -d '[:space:]'\" 2>/dev/null || echo ''"; + $dns = trim($server->executeCommand($dnsCmd, true)); + + if (empty($dns)) { + // Try alternative config location + $dnsCmd2 = "docker exec $containerName sh -c \"grep -E '^DNS' /etc/wireguard/wg0.conf 2>/dev/null | head -1 | cut -d= -f2 | tr -d '[:space:]'\" 2>/dev/null || echo ''"; + $dns = trim($server->executeCommand($dnsCmd2, true)); + } + + // Default DNS if not found + if (empty($dns)) { + $dns = '1.1.1.1, 1.0.0.1'; + } + + // Extract AWG parameters. + // NOTE: amnezia-awg does not expose these via `wg show` in many builds, + // so we primarily read them from /opt/amnezia/awg/wg0.conf. + $awgParams = []; + + // Legacy attempt: some builds print jc/jmin/... in `wg show` output. + $wgShowCmd = "docker exec $containerName wg show wg0 2>/dev/null"; + $wgOutput = (string) $server->executeCommand($wgShowCmd, true); + $paramNames = ['jc', 'jmin', 'jmax', 's1', 's2', 'h1', 'h2', 'h3', 'h4']; + foreach ($paramNames as $param) { + if (preg_match('/^\s*' . preg_quote($param, '/') . ':\s*(\d+)/mi', $wgOutput, $matches)) { + $awgParams[strtoupper($param)] = (int) $matches[1]; + } + } + + // Primary source: wg0.conf + if (empty($awgParams)) { + $awgParams = self::extractAwgParamsFromWg0Conf($server, $containerName, '/opt/amnezia/awg/wg0.conf'); + if (empty($awgParams)) { + $awgParams = self::extractAwgParamsFromWg0Conf($server, $containerName, '/etc/wireguard/wg0.conf'); + } + } + + // Update database if we found keys + if (!empty($pubKey) && !empty($port)) { + $pdo = DB::conn(); + + $awgParamsJson = !empty($awgParams) ? json_encode($awgParams) : null; + + // Update vpn_servers with all extracted values including DNS + if (!empty($psk)) { + $stmt = $pdo->prepare('UPDATE vpn_servers SET server_public_key = ?, preshared_key = ?, vpn_port = ?, awg_params = ?, dns_servers = ? WHERE id = ?'); + $stmt->execute([$pubKey, $psk, (int) $port, $awgParamsJson, $dns, $serverData['id']]); + } else { + $stmt = $pdo->prepare('UPDATE vpn_servers SET server_public_key = ?, vpn_port = ?, awg_params = ?, dns_servers = ? WHERE id = ?'); + $stmt->execute([$pubKey, (int) $port, $awgParamsJson, $dns, $serverData['id']]); + } + + error_log("Auto-synced server keys from container $containerName: port=$port, dns=$dns, awg_params=" . ($awgParamsJson ?? 'none')); + } + } catch (Exception $e) { + error_log('Error syncing keys from container: ' . $e->getMessage()); + throw $e; + } + } + /** * Build client configuration file */ @@ -189,74 +806,89 @@ class VpnClient { $config .= "PrivateKey = {$privateKey}\n"; $config .= "Address = {$clientIP}/32\n"; $config .= "DNS = 1.1.1.1, 1.0.0.1\n"; - + // Add AWG parameters foreach (['Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4'] as $key) { if (isset($awgParams[$key])) { $config .= "{$key} = {$awgParams[$key]}\n"; + continue; + } + + // Accept uppercase keys too (JC/JMIN/JMAX/...) + $alt = strtoupper($key); + if (isset($awgParams[$alt])) { + $config .= "{$key} = {$awgParams[$alt]}\n"; } } - + $config .= "\n[Peer]\n"; $config .= "PublicKey = {$serverPublicKey}\n"; $config .= "PresharedKey = {$presharedKey}\n"; $config .= "Endpoint = {$serverHost}:{$serverPort}\n"; $config .= "AllowedIPs = 0.0.0.0/0, ::/0\n"; $config .= "PersistentKeepalive = 25\n"; - + return $config; } - + /** - * Add client to server using official method (append + wg syncconf) + * Add client to server using wg set (more reliable than syncconf) */ - private static function addClientToServer(array $serverData, string $publicKey, string $clientIP): void { + private static function addClientToServer(array $serverData, string $publicKey, string $clientIP): void + { $containerName = $serverData['container_name']; - - // Build peer block + $presharedKey = $serverData['preshared_key']; + + // 1. Create temp file for PSK (to avoid shell escaping issues) + $pskFile = '/tmp/' . bin2hex(random_bytes(8)) . '.psk'; + $cmd1 = sprintf("docker exec -i %s sh -c 'echo \"%s\" > %s'", $containerName, $presharedKey, $pskFile); + self::executeServerCommand($serverData, $cmd1, true); + + // 2. Add peer using wg set + // wg set wg0 peer preshared-key allowed-ips + $cmd2 = sprintf( + "docker exec -i %s wg set wg0 peer %s preshared-key %s allowed-ips %s/32", + $containerName, + escapeshellarg($publicKey), + $pskFile, + $clientIP + ); + self::executeServerCommand($serverData, $cmd2, true); + + // 3. Remove temp PSK file + $cmd3 = sprintf("docker exec -i %s rm -f %s", $containerName, $pskFile); + self::executeServerCommand($serverData, $cmd3, true); + + // 4. Persist to wg0.conf (append) $peerBlock = "\n[Peer]\n"; $peerBlock .= "PublicKey = {$publicKey}\n"; - $peerBlock .= "PresharedKey = {$serverData['preshared_key']}\n"; + $peerBlock .= "PresharedKey = {$presharedKey}\n"; $peerBlock .= "AllowedIPs = {$clientIP}/32\n"; - - $escaped = addslashes($peerBlock); - $tempFile = '/tmp/' . bin2hex(random_bytes(8)) . '.tmp'; - - // Create temp file - $cmd1 = sprintf("docker exec -i %s sh -c 'echo \"%s\" > %s'", $containerName, $escaped, $tempFile); - self::executeServerCommand($serverData, $cmd1, true); - - // Append to wg0.conf - $cmd2 = sprintf("docker exec -i %s sh -c 'cat %s >> /opt/amnezia/awg/wg0.conf'", $containerName, $tempFile); - self::executeServerCommand($serverData, $cmd2, true); - - // Apply via wg syncconf - $cmd3 = sprintf("docker exec -i %s bash -c 'wg syncconf wg0 <(wg-quick strip /opt/amnezia/awg/wg0.conf)'", $containerName); - self::executeServerCommand($serverData, $cmd3, true); - - // Remove temp file - $cmd4 = sprintf("docker exec -i %s rm -f %s", $containerName, $tempFile); + + $escapedBlock = addslashes($peerBlock); + $cmd4 = sprintf("docker exec -i %s sh -c 'echo \"%s\" >> /opt/amnezia/awg/wg0.conf'", $containerName, $escapedBlock); self::executeServerCommand($serverData, $cmd4, true); - - // Update clientsTable + + // 5. Update clientsTable self::updateClientsTable($serverData, $publicKey, $clientIP); } - + /** * Update clientsTable on server */ - private static function updateClientsTable(array $serverData, string $publicKey, string $name): void { + private static function updateClientsTable(array $serverData, string $publicKey, string $name): void + { $containerName = $serverData['container_name']; - + // Read current table $cmd = sprintf("docker exec -i %s cat /opt/amnezia/awg/clientsTable 2>/dev/null", $containerName); $tableJson = self::executeServerCommand($serverData, $cmd, true); $table = json_decode(trim($tableJson), true); - + if (!is_array($table)) { $table = []; } - + // Add new client $table[] = [ 'clientId' => $publicKey, @@ -265,22 +897,23 @@ class VpnClient { 'creationDate' => date('D M j H:i:s Y') ] ]; - + // Save back $newTableJson = json_encode($table, JSON_PRETTY_PRINT); $escaped = addslashes($newTableJson); $updateCmd = sprintf("docker exec -i %s sh -c 'echo \"%s\" > /opt/amnezia/awg/clientsTable'", $containerName, $escaped); self::executeServerCommand($serverData, $updateCmd, true); } - + /** * Execute command on server */ - private static function executeServerCommand(array $serverData, string $command, bool $sudo = false): string { + private static function executeServerCommand(array $serverData, string $command, bool $sudo = false): string + { if ($sudo && strtolower($serverData['username']) !== 'root') { $command = "echo '{$serverData['password']}' | sudo -S " . $command; } - + $escapedCommand = escapeshellarg($command); $sshCommand = sprintf( "sshpass -p '%s' ssh -p %d -q -o LogLevel=ERROR -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o PreferredAuthentications=password -o PubkeyAuthentication=no %s@%s %s 2>&1", @@ -290,17 +923,18 @@ class VpnClient { $serverData['host'], $escapedCommand ); - + return shell_exec($sshCommand) ?? ''; } - + /** * Generate QR code for configuration using Amnezia format * Uses working QrUtil from /Users/oleg/Documents/amnezia */ - private static function generateQRCode(string $config): string { + private static function generateQRCode(string $config): string + { require_once __DIR__ . '/QrUtil.php'; - + try { // Use old Amnezia format with Qt/QDataStream encoding $payloadOld = QrUtil::encodeOldPayloadFromConf($config); @@ -311,127 +945,157 @@ class VpnClient { return ''; // QR code generation failed, but continue } } - + /** * Get all clients for a server */ - public static function listByServer(int $serverId): array { + public static function listByServer(int $serverId): array + { $pdo = DB::conn(); - $stmt = $pdo->prepare('SELECT * FROM vpn_clients WHERE server_id = ? ORDER BY created_at DESC'); + $stmt = $pdo->prepare(' + SELECT c.*, p.name as protocol_name, p.show_text_content + FROM vpn_clients c + LEFT JOIN protocols p ON c.protocol_id = p.id + WHERE c.server_id = ? + ORDER BY c.created_at DESC + '); $stmt->execute([$serverId]); return $stmt->fetchAll(); } - + /** * Get all clients for a user */ - public static function listByUser(int $userId): array { + public static function listByUser(int $userId): array + { $pdo = DB::conn(); $stmt = $pdo->prepare(' - SELECT c.*, s.name as server_name, s.host as server_host + SELECT c.*, s.name as server_name, s.host as server_host, p.name as protocol_name, p.show_text_content FROM vpn_clients c LEFT JOIN vpn_servers s ON c.server_id = s.id + LEFT JOIN protocols p ON c.protocol_id = p.id WHERE c.user_id = ? ORDER BY c.created_at DESC '); $stmt->execute([$userId]); return $stmt->fetchAll(); } - + /** * Revoke client access (disable without deleting) */ - public function revoke(): bool { + public function revoke(): bool + { if (!$this->data) { throw new Exception('Client not loaded'); } - - // Remove from server - $server = new VpnServer($this->data['server_id']); - $serverData = $server->getData(); - - if ($serverData && $serverData['status'] === 'active') { - try { - self::removeClientFromServer($serverData, $this->data['public_key']); - } catch (Exception $e) { - error_log('Failed to remove client from server: ' . $e->getMessage()); + + $isWireguard = self::isWireguardProtocol((int) ($this->data['protocol_id'] ?? 0)); + if ($isWireguard) { + $server = new VpnServer($this->data['server_id']); + $serverData = $server->getData(); + if ($serverData && $serverData['status'] === 'active') { + try { + self::removeClientFromServer($serverData, $this->data['public_key']); + } catch (Exception $e) { + error_log('Failed to remove client from server: ' . $e->getMessage()); + } } } - + // Mark as disabled in database $pdo = DB::conn(); $stmt = $pdo->prepare('UPDATE vpn_clients SET status = ? WHERE id = ?'); return $stmt->execute(['disabled', $this->clientId]); } - + /** * Restore client access */ - public function restore(): bool { + public function restore(): bool + { if (!$this->data) { throw new Exception('Client not loaded'); } - - // Re-add to server - $server = new VpnServer($this->data['server_id']); - $serverData = $server->getData(); - - if ($serverData && $serverData['status'] === 'active') { - try { - self::addClientToServer($serverData, $this->data['public_key'], $this->data['client_ip']); - } catch (Exception $e) { - throw new Exception('Failed to restore client on server: ' . $e->getMessage()); + + $isWireguard = self::isWireguardProtocol((int) ($this->data['protocol_id'] ?? 0)); + if ($isWireguard) { + $server = new VpnServer($this->data['server_id']); + $serverData = $server->getData(); + if ($serverData && $serverData['status'] === 'active') { + try { + self::addClientToServer($serverData, $this->data['public_key'], $this->data['client_ip']); + } catch (Exception $e) { + throw new Exception('Failed to restore client on server: ' . $e->getMessage()); + } } } - + // Mark as active in database $pdo = DB::conn(); $stmt = $pdo->prepare('UPDATE vpn_clients SET status = ? WHERE id = ?'); return $stmt->execute(['active', $this->clientId]); } - + + private static function isWireguardProtocol(?int $protocolId): bool + { + if (!$protocolId) + return true; + try { + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT slug FROM protocols WHERE id = ?'); + $stmt->execute([$protocolId]); + $slug = (string) $stmt->fetchColumn(); + return in_array($slug, ['amnezia-wg-advanced', 'wireguard-standard', 'amnezia-wg'], true); + } catch (Exception $e) { + return true; + } + } + /** * Delete client permanently */ - public function delete(): bool { + public function delete(): bool + { if (!$this->data) { throw new Exception('Client not loaded'); } - + // First revoke to remove from server if ($this->data['status'] === 'active') { $this->revoke(); } - + // Delete from database $pdo = DB::conn(); $stmt = $pdo->prepare('DELETE FROM vpn_clients WHERE id = ?'); return $stmt->execute([$this->clientId]); } - + /** * Remove client from server WireGuard configuration */ - private static function removeClientFromServer(array $serverData, string $publicKey): void { + private static function removeClientFromServer(array $serverData, string $publicKey): void + { $containerName = $serverData['container_name']; - + // First, remove using wg command (live removal) $removeCmd = sprintf( "docker exec -i %s wg set wg0 peer %s remove", $containerName, escapeshellarg($publicKey) ); - + self::executeServerCommand($serverData, $removeCmd, true); - + // Then remove from wg0.conf file to make it persistent // Use a more reliable method: read, filter, write $readCmd = sprintf("docker exec -i %s cat /opt/amnezia/awg/wg0.conf", $containerName); $config = self::executeServerCommand($serverData, $readCmd, true); - + // Parse and remove the peer section $newConfig = self::removePeerFromConfig($config, $publicKey); - + // Write back to file $escapedConfig = str_replace("'", "'\\''", $newConfig); $writeCmd = sprintf( @@ -439,35 +1103,36 @@ class VpnClient { $containerName, $escapedConfig ); - + self::executeServerCommand($serverData, $writeCmd, true); - + // Save config $saveCmd = sprintf("docker exec -i %s wg-quick save wg0", $containerName); self::executeServerCommand($serverData, $saveCmd, true); - + // Remove from clientsTable self::removeFromClientsTable($serverData, $publicKey); } - + /** * Remove peer section from WireGuard config */ - private static function removePeerFromConfig(string $config, string $publicKey): string { + private static function removePeerFromConfig(string $config, string $publicKey): string + { $lines = explode("\n", $config); $newLines = []; $inPeerBlock = false; $skipBlock = false; - + foreach ($lines as $line) { $trimmed = trim($line); - + // Start of new section if (strpos($trimmed, '[') === 0) { $inPeerBlock = ($trimmed === '[Peer]'); $skipBlock = false; } - + // Check if this peer block should be skipped if ($inPeerBlock && strpos($trimmed, 'PublicKey') === 0) { $parts = explode('=', $line, 2); @@ -478,7 +1143,7 @@ class VpnClient { continue; } } - + // Skip lines in the block to be removed if ($skipBlock && $inPeerBlock) { // Empty line ends the peer block @@ -488,93 +1153,269 @@ class VpnClient { } continue; } - + $newLines[] = $line; } - + return implode("\n", $newLines); } - + /** * Remove client from clientsTable */ - private static function removeFromClientsTable(array $serverData, string $publicKey): void { + private static function removeFromClientsTable(array $serverData, string $publicKey): void + { $containerName = $serverData['container_name']; - + // Read current table $cmd = sprintf("docker exec -i %s cat /opt/amnezia/awg/clientsTable 2>/dev/null", $containerName); $tableJson = self::executeServerCommand($serverData, $cmd, true); $table = json_decode(trim($tableJson), true); - + if (!is_array($table)) { return; } - + // Filter out the client - $table = array_filter($table, function($client) use ($publicKey) { + $table = array_filter($table, function ($client) use ($publicKey) { return ($client['clientId'] ?? '') !== $publicKey; }); - + // Re-index array $table = array_values($table); - + // Save back $newTableJson = json_encode($table, JSON_PRETTY_PRINT); $escaped = addslashes($newTableJson); $updateCmd = sprintf("docker exec -i %s sh -c 'echo \"%s\" > /opt/amnezia/awg/clientsTable'", $containerName, $escaped); self::executeServerCommand($serverData, $updateCmd, true); } - + /** * Get client data */ - public function getData(): ?array { + public function getData(): ?array + { return $this->data; } - + /** * Get configuration file content */ - public function getConfig(): string { - return $this->data['config'] ?? ''; + public function getConfig(): string + { + $config = $this->data['config'] ?? ''; + // Decode escape sequences like \n that may be stored in database + return stripcslashes($config); } - + /** - * Get QR code + * Regenerate and persist client configuration using current server container data. + * Useful when server was reinstalled/recreated and AWG params/keys changed. */ - public function getQRCode(): string { - return $this->data['qr_code'] ?? ''; - } - - /** - * Sync traffic statistics from server - */ - public function syncStats(): bool { + public function regenerateConfigFromServer(bool $forceSyncServer = true): array + { if (!$this->data) { throw new Exception('Client not loaded'); } - + + $server = new VpnServer((int) $this->data['server_id']); + $serverData = $server->getData(); + if (!$serverData) { + throw new Exception('Server not found'); + } + + $protocolId = (int) ($this->data['protocol_id'] ?? 0); + $protoRow = null; + if ($protocolId > 0) { + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT * FROM protocols WHERE id = ? LIMIT 1'); + $stmt->execute([$protocolId]); + $protoRow = $stmt->fetch(); + } + $slug = $protoRow['slug'] ?? ''; + $isWireguard = in_array($slug, ['amnezia-wg-advanced', 'wireguard-standard', 'amnezia-wg'], true); + + if (!$isWireguard) { + return ['success' => false, 'error' => 'not_wireguard_protocol', 'protocol_slug' => $slug]; + } + + if ($forceSyncServer) { + self::syncServerKeysFromContainer($server, $serverData); + $server->refresh(); + $serverData = $server->getData(); + } + + $privateKey = (string) ($this->data['private_key'] ?? ''); + $clientPublicKey = (string) ($this->data['public_key'] ?? ''); + $clientIP = (string) ($this->data['client_ip'] ?? ''); + if ($privateKey === '' || $clientIP === '') { + throw new Exception('Client keys or IP missing'); + } + + $awgParams = json_decode($serverData['awg_params'] ?? '{}', true) ?? []; + if (!is_array($awgParams)) { + $awgParams = []; + } + + // If AWG params are missing (common after reinstall), fetch them directly from wg0.conf + // to avoid falling back to template defaults that will not match the server. + if ($slug === 'amnezia-wg-advanced') { + $needKeys = ['JC', 'JMIN', 'JMAX', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4']; + $missing = false; + foreach ($needKeys as $k) { + if (!isset($awgParams[$k])) { + $missing = true; + break; + } + } + + if ($missing) { + $containerName = $serverData['container_name'] ?? 'amnezia-awg'; + $direct = self::extractAwgParamsFromWg0Conf($server, $containerName, '/opt/amnezia/awg/wg0.conf'); + if (empty($direct)) { + $direct = self::extractAwgParamsFromWg0Conf($server, $containerName, '/etc/wireguard/wg0.conf'); + } + + if (!empty($direct)) { + $awgParams = $direct; + + // Persist to server row for future generations/diagnostics + try { + $pdo = DB::conn(); + $stmt = $pdo->prepare('UPDATE vpn_servers SET awg_params = ? WHERE id = ?'); + $stmt->execute([json_encode($awgParams), (int) ($serverData['id'] ?? 0)]); + } catch (Exception $e) { + // Best-effort only; regeneration can continue. + error_log('Failed to persist AWG params during regeneration: ' . $e->getMessage()); + } + } + } + + // Still missing? Refuse to overwrite config with template defaults. + foreach ($needKeys as $k) { + if (!isset($awgParams[$k])) { + return [ + 'success' => false, + 'error' => 'awg_params_missing', + 'protocol_slug' => $slug, + 'server_id' => (int) ($serverData['id'] ?? 0), + ]; + } + } + } + + // Prefer per-peer PSK from wg dump (server may use different PSKs per peer) + $presharedKeyForConfig = (string) ($serverData['preshared_key'] ?? ''); + try { + $containerName = $serverData['container_name'] ?? 'amnezia-awg'; + $peerPsk = self::extractPeerPskFromWgDump($server, $containerName, $clientPublicKey); + if ($peerPsk !== null && $peerPsk !== '') { + $presharedKeyForConfig = $peerPsk; + } + } catch (Exception $e) { + // Best-effort; fallback to serverData['preshared_key'] + error_log('Failed to extract peer PSK from wg dump: ' . $e->getMessage()); + } + + $vars = [ + 'private_key' => $privateKey, + 'client_ip' => $clientIP, + 'server_public_key' => (string) ($serverData['server_public_key'] ?? ''), + 'preshared_key' => $presharedKeyForConfig, + 'server_host' => (string) ($serverData['host'] ?? ''), + 'server_port' => (string) ((int) ($serverData['vpn_port'] ?? 0)), + 'dns_servers' => (string) ($serverData['dns_servers'] ?? '1.1.1.1, 1.0.0.1'), + ]; + + foreach (['JC', 'JMIN', 'JMAX', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4'] as $key) { + if (isset($awgParams[$key])) { + $vars[$key] = $awgParams[$key]; + } + } + + if (!isset($vars['Jc']) && isset($vars['JC'])) { + $vars['Jc'] = (string) $vars['JC']; + } + if (!isset($vars['Jmin']) && isset($vars['JMIN'])) { + $vars['Jmin'] = (string) $vars['JMIN']; + } + if (!isset($vars['Jmax']) && isset($vars['JMAX'])) { + $vars['Jmax'] = (string) $vars['JMAX']; + } + + if ($protoRow && !empty($protoRow['output_template'])) { + require_once __DIR__ . '/ProtocolService.php'; + $config = ProtocolService::generateProtocolOutput($protoRow, $vars); + } else { + $config = self::buildClientConfig( + $privateKey, + $clientIP, + (string) ($serverData['server_public_key'] ?? ''), + $presharedKeyForConfig, + (string) ($serverData['host'] ?? ''), + (int) ($serverData['vpn_port'] ?? 0), + $awgParams + ); + } + + $qrCode = self::generateQRCode($config); + + $pdo = DB::conn(); + $stmt = $pdo->prepare('UPDATE vpn_clients SET config = ?, qr_code = ?, preshared_key = ? WHERE id = ?'); + $stmt->execute([$config, $qrCode, $presharedKeyForConfig, (int) $this->clientId]); + + // Refresh cached data + $this->load(); + + return [ + 'success' => true, + 'client_id' => (int) $this->clientId, + 'protocol_slug' => $slug, + 'server_id' => (int) ($this->data['server_id'] ?? 0), + 'awg_params' => $awgParams, + 'peer_psk_source' => ($presharedKeyForConfig !== '' && $presharedKeyForConfig !== (string) ($serverData['preshared_key'] ?? '')) ? 'wg_dump' : 'server_row', + ]; + } + + /** + * Get QR code + */ + public function getQRCode(): string + { + return $this->data['qr_code'] ?? ''; + } + + /** + * Sync traffic statistics from server + */ + public function syncStats(): bool + { + if (!$this->data) { + throw new Exception('Client not loaded'); + } + $server = new VpnServer($this->data['server_id']); $serverData = $server->getData(); - + if (!$serverData || $serverData['status'] !== 'active') { return false; } - + try { $stats = self::getClientStatsFromServer($serverData, $this->data['public_key']); - + $pdo = DB::conn(); $stmt = $pdo->prepare(' UPDATE vpn_clients SET bytes_sent = ?, bytes_received = ?, last_handshake = ?, last_sync_at = NOW() WHERE id = ? '); - - $lastHandshake = $stats['last_handshake'] > 0 - ? date('Y-m-d H:i:s', $stats['last_handshake']) + + $lastHandshake = $stats['last_handshake'] > 0 + ? date('Y-m-d H:i:s', $stats['last_handshake']) : null; - + return $stmt->execute([ $stats['bytes_sent'], $stats['bytes_received'], @@ -586,23 +1427,24 @@ class VpnClient { return false; } } - + /** * Get client statistics from server */ - private static function getClientStatsFromServer(array $serverData, string $publicKey): array { + private static function getClientStatsFromServer(array $serverData, string $publicKey): array + { $containerName = $serverData['container_name']; - + // Get WireGuard interface stats $cmd = sprintf("docker exec -i %s wg show wg0 dump", $containerName); $output = self::executeServerCommand($serverData, $cmd, true); - + $stats = [ 'bytes_sent' => 0, 'bytes_received' => 0, 'last_handshake' => 0 ]; - + // Parse wg dump output // Format: public_key preshared_key endpoint allowed_ips latest_handshake transfer_rx transfer_tx persistent_keepalive // First line is server (private key), skip it @@ -610,34 +1452,37 @@ class VpnClient { // transfer_tx = bytes sent by server (received by client) $lines = explode("\n", trim($output)); foreach ($lines as $line) { - if (empty($line)) continue; - + if (empty($line)) + continue; + $parts = preg_split('/\s+/', trim($line)); - + // Skip first line (server) - it has different format - if (count($parts) < 7) continue; - + if (count($parts) < 7) + continue; + // Match by public key if ($parts[0] === $publicKey) { - $stats['last_handshake'] = (int)$parts[4]; - $stats['bytes_sent'] = (int)$parts[5]; // transfer_rx - client sent - $stats['bytes_received'] = (int)$parts[6]; // transfer_tx - client received + $stats['last_handshake'] = (int) $parts[4]; + $stats['bytes_sent'] = (int) $parts[5]; // transfer_rx - client sent + $stats['bytes_received'] = (int) $parts[6]; // transfer_tx - client received break; } } - + return $stats; } - + /** * Sync stats for all active clients on a server */ - public static function syncAllStatsForServer(int $serverId): int { + public static function syncAllStatsForServer(int $serverId): int + { $pdo = DB::conn(); $stmt = $pdo->prepare('SELECT id FROM vpn_clients WHERE server_id = ? AND status = ?'); $stmt->execute([$serverId, 'active']); $clientIds = $stmt->fetchAll(PDO::FETCH_COLUMN); - + $synced = 0; foreach ($clientIds as $clientId) { try { @@ -649,27 +1494,28 @@ class VpnClient { error_log('Failed to sync stats for client ' . $clientId . ': ' . $e->getMessage()); } } - + return $synced; } - + /** * Get human-readable traffic statistics */ - public function getFormattedStats(): array { + public function getFormattedStats(): array + { if (!$this->data) { return ['sent' => 'N/A', 'received' => 'N/A', 'total' => 'N/A', 'last_seen' => 'Never']; } - + $sent = $this->formatBytes($this->data['bytes_sent'] ?? 0); $received = $this->formatBytes($this->data['bytes_received'] ?? 0); $total = $this->formatBytes(($this->data['bytes_sent'] ?? 0) + ($this->data['bytes_received'] ?? 0)); - + $lastSeen = 'Never'; if (!empty($this->data['last_handshake'])) { $lastHandshake = strtotime($this->data['last_handshake']); $diff = time() - $lastHandshake; - + if ($diff < 300) { $lastSeen = 'Online'; } elseif ($diff < 3600) { @@ -680,7 +1526,7 @@ class VpnClient { $lastSeen = floor($diff / 86400) . ' days ago'; } } - + return [ 'sent' => $sent, 'received' => $received, @@ -689,15 +1535,16 @@ class VpnClient { 'is_online' => !empty($this->data['last_handshake']) && (time() - strtotime($this->data['last_handshake'])) < 300 ]; } - + /** * Format bytes to human-readable string (always in MB) */ - private function formatBytes(int $bytes): string { + private function formatBytes(int $bytes): string + { $mb = $bytes / 1048576; // 1024 * 1024 return number_format($mb, 2) . ' MB'; } - + /** * Set client expiration date * @@ -705,12 +1552,13 @@ class VpnClient { * @param string|null $expiresAt Expiration date (Y-m-d H:i:s) or null for never expires * @return bool Success */ - public static function setExpiration(int $clientId, ?string $expiresAt): bool { + public static function setExpiration(int $clientId, ?string $expiresAt): bool + { $pdo = DB::conn(); $stmt = $pdo->prepare('UPDATE vpn_clients SET expires_at = ? WHERE id = ?'); return $stmt->execute([$expiresAt, $clientId]); } - + /** * Extend client expiration by days * @@ -718,32 +1566,34 @@ class VpnClient { * @param int $days Days to extend * @return bool Success */ - public static function extendExpiration(int $clientId, int $days): bool { + public static function extendExpiration(int $clientId, int $days): bool + { $pdo = DB::conn(); - + // Get current expiration $stmt = $pdo->prepare('SELECT expires_at FROM vpn_clients WHERE id = ?'); $stmt->execute([$clientId]); $client = $stmt->fetch(); - + if (!$client) { return false; } - + // Calculate new expiration from current or now $baseDate = $client['expires_at'] ? strtotime($client['expires_at']) : time(); $newExpiration = date('Y-m-d H:i:s', strtotime("+{$days} days", $baseDate)); - + return self::setExpiration($clientId, $newExpiration); } - + /** * Get clients expiring soon * * @param int $days Check for clients expiring within N days * @return array List of expiring clients */ - public static function getExpiringClients(int $days = 7): array { + public static function getExpiringClients(int $days = 7): array + { $pdo = DB::conn(); $stmt = $pdo->prepare(' SELECT c.*, s.name as server_name, s.host, u.name as user_name, u.email @@ -759,13 +1609,14 @@ class VpnClient { $stmt->execute([$days]); return $stmt->fetchAll(); } - + /** * Get expired clients * * @return array List of expired clients */ - public static function getExpiredClients(): array { + public static function getExpiredClients(): array + { $pdo = DB::conn(); $stmt = $pdo->query(' SELECT c.*, s.name as server_name, s.host @@ -778,16 +1629,17 @@ class VpnClient { '); return $stmt->fetchAll(); } - + /** * Disable expired clients automatically * * @return int Number of clients disabled */ - public static function disableExpiredClients(): int { + public static function disableExpiredClients(): int + { $expiredClients = self::getExpiredClients(); $count = 0; - + foreach ($expiredClients as $clientData) { try { $client = new self($clientData['id']); @@ -797,95 +1649,101 @@ class VpnClient { error_log("Failed to disable expired client {$clientData['id']}: " . $e->getMessage()); } } - + return $count; } - + /** * Check if client is expired * * @return bool True if expired */ - public function isExpired(): bool { + public function isExpired(): bool + { if (!$this->data) { return false; } - + return $this->data['expires_at'] !== null && strtotime($this->data['expires_at']) <= time(); } - + /** * Get days until expiration * * @return int|null Days until expiration (negative if expired, null if never expires) */ - public function getDaysUntilExpiration(): ?int { + public function getDaysUntilExpiration(): ?int + { if (!$this->data || $this->data['expires_at'] === null) { return null; } - + $diff = strtotime($this->data['expires_at']) - time(); - return (int)floor($diff / 86400); + return (int) floor($diff / 86400); } - + /** * Set traffic limit for client * * @param int|null $limitBytes Traffic limit in bytes (NULL = unlimited) * @return bool Success */ - public function setTrafficLimit(?int $limitBytes): bool { + public function setTrafficLimit(?int $limitBytes): bool + { if (!$this->data) { throw new Exception('Client not loaded'); } - + $pdo = DB::conn(); $stmt = $pdo->prepare('UPDATE vpn_clients SET traffic_limit = ? WHERE id = ?'); $result = $stmt->execute([$limitBytes, $this->clientId]); - + if ($result) { $this->data['traffic_limit'] = $limitBytes; } - + return $result; } - + /** * Get total traffic used (sent + received) * * @return int Total traffic in bytes */ - public function getTotalTraffic(): int { + public function getTotalTraffic(): int + { if (!$this->data) { return 0; } - - return (int)($this->data['traffic_sent'] ?? 0) + (int)($this->data['traffic_received'] ?? 0); + + return (int) ($this->data['traffic_sent'] ?? 0) + (int) ($this->data['traffic_received'] ?? 0); } - + /** * Check if client has exceeded traffic limit * * @return bool True if over limit */ - public function isOverLimit(): bool { + public function isOverLimit(): bool + { if (!$this->data || $this->data['traffic_limit'] === null) { return false; // No limit set } - + $totalTraffic = $this->getTotalTraffic(); - return $totalTraffic >= (int)$this->data['traffic_limit']; + return $totalTraffic >= (int) $this->data['traffic_limit']; } - + /** * Get traffic limit status * * @return array Status info */ - public function getTrafficLimitStatus(): array { + public function getTrafficLimitStatus(): array + { $totalTraffic = $this->getTotalTraffic(); $limit = $this->data['traffic_limit'] ?? null; - + return [ 'total_traffic' => $totalTraffic, 'traffic_limit' => $limit, @@ -895,13 +1753,14 @@ class VpnClient { 'remaining' => $limit ? max(0, $limit - $totalTraffic) : null ]; } - + /** * Get all clients that exceeded their traffic limit * * @return array List of client IDs over limit */ - public static function getClientsOverLimit(): array { + public static function getClientsOverLimit(): array + { $pdo = DB::conn(); $stmt = $pdo->query(' SELECT id, name, traffic_sent, traffic_received, traffic_limit @@ -911,19 +1770,20 @@ class VpnClient { AND status = "active" ORDER BY id '); - + return $stmt->fetchAll(); } - + /** * Disable all clients that exceeded their traffic limit * * @return int Number of clients disabled */ - public static function disableClientsOverLimit(): int { + public static function disableClientsOverLimit(): int + { $clients = self::getClientsOverLimit(); $disabled = 0; - + foreach ($clients as $clientData) { try { $client = new VpnClient($clientData['id']); @@ -935,7 +1795,7 @@ class VpnClient { error_log("Failed to disable client {$clientData['id']}: " . $e->getMessage()); } } - + return $disabled; } } diff --git a/inc/VpnServer.php b/inc/VpnServer.php index 8d6c11b..54d3fa5 100644 --- a/inc/VpnServer.php +++ b/inc/VpnServer.php @@ -4,21 +4,37 @@ * Handles deployment and management of Amnezia VPN servers * Based on amnezia_deploy_v2.php */ -class VpnServer { +class VpnServer +{ private $serverId; private $data; - - public function __construct(?int $serverId = null) { + + public function __construct(?int $serverId = null) + { $this->serverId = $serverId; if ($serverId) { $this->load(); } } - + + public function getId(): int + { + return (int) $this->serverId; + } + + public function refresh(): void + { + if ($this->serverId === null) { + throw new Exception('Server ID is not set'); + } + $this->load(); + } + /** * Load server data from database */ - private function load(): void { + private function load(): void + { $pdo = DB::conn(); $stmt = $pdo->prepare('SELECT * FROM vpn_servers WHERE id = ?'); $stmt->execute([$this->serverId]); @@ -27,87 +43,299 @@ class VpnServer { throw new Exception('Server not found'); } } - + /** * Create new VPN server in database */ - public static function create(array $data): int { + public static function create(array $data): int + { $pdo = DB::conn(); - + // Validate required fields - $required = ['user_id', 'name', 'host', 'port', 'username', 'password']; + $required = ['user_id', 'name', 'host', 'port', 'username']; foreach ($required as $field) { if (empty($data[$field])) { throw new Exception("Field {$field} is required"); } } - + + if (empty($data['password']) && empty($data['ssh_key'])) { + throw new Exception("Either password or SSH key is required"); + } + + $protocolSlug = trim((string) ($data['install_protocol'] ?? '')); + if ($protocolSlug === '') { + throw new Exception('Install protocol must be selected'); + } + $installOptions = $data['install_options'] ?? null; + + if (is_array($installOptions)) { + $installOptions = json_encode($installOptions); + } elseif (is_string($installOptions)) { + $installOptions = trim($installOptions) === '' ? null : $installOptions; + } + $stmt = $pdo->prepare(' INSERT INTO vpn_servers - (user_id, name, host, port, username, password, container_name, vpn_subnet, status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + (user_id, name, host, port, username, password, ssh_key, container_name, install_protocol, install_options, vpn_subnet, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) '); - + $stmt->execute([ $data['user_id'], $data['name'], $data['host'], $data['port'], $data['username'], - $data['password'], + $data['password'] ?? null, + $data['ssh_key'] ?? null, $data['container_name'] ?? 'amnezia-awg', + $protocolSlug, + $installOptions, $data['vpn_subnet'] ?? '10.8.1.0/24', 'deploying' ]); - - return (int)$pdo->lastInsertId(); + + return (int) $pdo->lastInsertId(); } - + /** - * Deploy VPN server using amnezia_deploy_v2.php logic + * Import existing VPN server from backup payload without deployment. */ - public function deploy(): array { + public static function importFromBackup(int $userId, array $serverData): int + { + $pdo = DB::conn(); + + $name = trim($serverData['name'] ?? ''); + $host = trim($serverData['host'] ?? ''); + if ($name === '' || $host === '') { + throw new Exception('Backup is missing server name or host'); + } + + $port = isset($serverData['ssh_port']) ? (int) $serverData['ssh_port'] : 22; + $username = trim($serverData['ssh_username'] ?? 'root') ?: 'root'; + $password = (string) ($serverData['ssh_password'] ?? ''); + $containerName = $serverData['container_name'] ?? 'amnezia-awg'; + $vpnPort = isset($serverData['vpn_port']) && $serverData['vpn_port'] !== null + ? (int) $serverData['vpn_port'] + : null; + $vpnSubnet = $serverData['vpn_subnet'] ?? '10.8.1.0/24'; + $serverPublicKey = $serverData['server_public_key'] ?? null; + $presharedKey = $serverData['preshared_key'] ?? null; + + $awgParams = $serverData['awg_params'] ?? null; + if (is_array($awgParams)) { + $awgParams = json_encode($awgParams); + } + + $installProtocol = $serverData['install_protocol'] ?? 'amnezia-wg'; + $installOptions = $serverData['install_options'] ?? null; + if (is_array($installOptions)) { + $installOptions = json_encode($installOptions); + } + + $stmt = $pdo->prepare(' + INSERT INTO vpn_servers + (user_id, name, host, port, username, password, container_name, install_protocol, install_options, vpn_port, vpn_subnet, + server_public_key, preshared_key, awg_params, status, deployed_at, error_message) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NULL) + '); + + $stmt->execute([ + $userId, + $name, + $host, + $port, + $username, + $password, + $containerName, + $installProtocol, + $installOptions, + $vpnPort, + $vpnSubnet, + $serverPublicKey, + $presharedKey, + $awgParams, + 'active' + ]); + + return (int) $pdo->lastInsertId(); + } + + /** + * Apply server configuration from backup payload to an existing record. + */ + public function applyBackupData(array $serverData, int $userId, bool $replaceClients = true): array + { if (!$this->data) { throw new Exception('Server not loaded'); } - + + $pdo = DB::conn(); + $updates = []; + $params = []; + $updatedFields = []; + + $mapString = function (?string $value): ?string { + $value = trim((string) $value); + return $value === '' ? null : $value; + }; + + $stringFields = [ + 'name' => $mapString($serverData['name'] ?? null), + 'host' => $mapString($serverData['host'] ?? null), + 'username' => $mapString($serverData['ssh_username'] ?? null), + 'password' => isset($serverData['ssh_password']) ? (string) $serverData['ssh_password'] : null, + 'container_name' => $mapString($serverData['container_name'] ?? null), + 'vpn_subnet' => $mapString($serverData['vpn_subnet'] ?? null), + 'server_public_key' => $mapString($serverData['server_public_key'] ?? null), + 'preshared_key' => isset($serverData['preshared_key']) ? (string) $serverData['preshared_key'] : null, + 'install_protocol' => $mapString($serverData['install_protocol'] ?? null), + ]; + + foreach ($stringFields as $column => $value) { + if ($value !== null) { + $updates[] = $column . ' = ?'; + $params[] = $value; + $updatedFields[] = $column; + } + } + + if (isset($serverData['ssh_port']) && $serverData['ssh_port'] !== null) { + $port = (int) $serverData['ssh_port']; + if ($port > 0) { + $updates[] = 'port = ?'; + $params[] = $port; + $updatedFields[] = 'port'; + } + } + + if (isset($serverData['vpn_port']) && $serverData['vpn_port'] !== null) { + $vpnPort = (int) $serverData['vpn_port']; + if ($vpnPort > 0) { + $updates[] = 'vpn_port = ?'; + $params[] = $vpnPort; + $updatedFields[] = 'vpn_port'; + } + } + + if (isset($serverData['awg_params'])) { + $awgParams = $serverData['awg_params']; + if (is_array($awgParams)) { + $awgParams = json_encode($awgParams); + } + if (is_string($awgParams)) { + $updates[] = 'awg_params = ?'; + $params[] = $awgParams; + $updatedFields[] = 'awg_params'; + } + } + + if (isset($serverData['install_options'])) { + $installOptions = $serverData['install_options']; + if (is_array($installOptions)) { + $installOptions = json_encode($installOptions); + } + if (is_string($installOptions)) { + $updates[] = 'install_options = ?'; + $params[] = $installOptions; + $updatedFields[] = 'install_options'; + } + } + + if ($updates) { + $params[] = $this->serverId; + $sql = 'UPDATE vpn_servers SET ' . implode(', ', $updates) . ' WHERE id = ?'; + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + $this->load(); + } + + $imported = 0; + $failed = []; + $clients = $serverData['clients'] ?? []; + $shouldReplaceClients = $replaceClients && is_array($clients) && !empty($clients) && ($this->data['status'] ?? '') !== 'active'; + + if ($shouldReplaceClients) { + $pdo->prepare('DELETE FROM vpn_clients WHERE server_id = ?')->execute([$this->serverId]); + $this->load(); + } + + if (is_array($clients) && !empty($clients)) { + $serverRecord = $this->getData(); + foreach ($clients as $clientData) { + try { + $id = VpnClient::importFromBackup($serverRecord, $userId, $clientData); + if ($id !== null) { + $imported++; + } + } catch (Exception $e) { + $failed[] = $e->getMessage(); + } + } + } + + return [ + 'updated_fields' => $updatedFields, + 'imported_clients' => $imported, + 'client_errors' => $failed, + ]; + } + + /** + * Deploy VPN server using amnezia_deploy_v2.php logic + */ + public function deploy(array $options = []): array + { + return InstallProtocolManager::deploy($this, $options); + } + + /** + * Legacy AmneziaWG deployment routine kept for backward compatibility. + */ + public function runAwgInstall(array $options = []): array + { + if (!$this->data) { + throw new Exception('Server not loaded'); + } + $pdo = DB::conn(); $errors = []; - + try { // Update status to deploying $pdo->prepare('UPDATE vpn_servers SET status = ? WHERE id = ?') ->execute(['deploying', $this->serverId]); - + // Test SSH connection if (!$this->testConnection()) { throw new Exception('SSH connection failed'); } - + // Install Docker if needed $this->installDocker(); - + // Create directories $this->executeCommand('mkdir -p /opt/amnezia/amnezia-awg', true); - + // Find free UDP port $vpnPort = $this->findFreeUdpPort(); - + // Create Dockerfile $this->createDockerfile(); - + // Create start script $this->createStartScript(); - + // Build Docker image $this->buildDockerImage(); - + // Run container $this->runContainer($vpnPort); - + // Initialize server config $keys = $this->initializeServerConfig($vpnPort); - + // Update database with deployment info $stmt = $pdo->prepare(' UPDATE vpn_servers @@ -120,7 +348,7 @@ class VpnServer { error_message = NULL WHERE id = ? '); - + $stmt->execute([ $vpnPort, $keys['public_key'], @@ -129,82 +357,145 @@ class VpnServer { 'active', $this->serverId ]); - + // Reload data $this->load(); - + return [ 'success' => true, 'vpn_port' => $vpnPort, 'public_key' => $keys['public_key'] ]; - + } catch (Exception $e) { // Update status to error $pdo->prepare('UPDATE vpn_servers SET status = ?, error_message = ? WHERE id = ?') ->execute(['error', $e->getMessage(), $this->serverId]); - + throw $e; } } - + /** * Test SSH connection to server */ - private function testConnection(): bool { - $testCommand = sprintf( - "sshpass -p '%s' ssh -p %d -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o PreferredAuthentications=password -o PubkeyAuthentication=no -o ConnectTimeout=10 %s@%s 'echo test' 2>/dev/null", - $this->data['password'], - $this->data['port'], - $this->data['username'], - $this->data['host'] - ); - + public function testConnection(): bool + { + // Determine auth method + $sshOptions = '-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o ConnectTimeout=10'; + $credentials = ''; + $keyFile = ''; + + if (!empty($this->data['ssh_key'])) { + $keyFile = tempnam(sys_get_temp_dir(), 'sshkey'); + file_put_contents($keyFile, $this->data['ssh_key']); + chmod($keyFile, 0600); + $sshOptions .= " -i {$keyFile} -o IdentitiesOnly=yes -o PubkeyAuthentication=yes -o PreferredAuthentications=publickey"; + // sshpass is not needed for key-based auth + $baseCmd = "ssh -p %d %s %s@%s"; + + $testCommand = sprintf( + "ssh -p %d %s %s@%s 'echo test' 2>/dev/null", + $this->data['port'], + $sshOptions, + $this->data['username'], + $this->data['host'] + ); + } else { + $sshOptions .= " -o PreferredAuthentications=password -o PubkeyAuthentication=no"; + $testCommand = sprintf( + "sshpass -p '%s' ssh -p %d %s %s@%s 'echo test' 2>/dev/null", + $this->data['password'], + $this->data['port'], + $sshOptions, + $this->data['username'], + $this->data['host'] + ); + } + $result = shell_exec($testCommand); + + if ($keyFile && file_exists($keyFile)) { + unlink($keyFile); + } + return trim($result) === 'test'; } - + /** * Execute command on remote server */ - private function executeCommand(string $command, bool $sudo = false): string { - if ($sudo && strtolower($this->data['username']) !== 'root') { - $command = "echo '{$this->data['password']}' | sudo -S " . $command; - } - + public function executeCommand(string $command, bool $sudo = false): string + { $escapedCommand = escapeshellarg($command); - $sshCommand = sprintf( - "sshpass -p '%s' ssh -p %d -q -o LogLevel=ERROR -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o PreferredAuthentications=password -o PubkeyAuthentication=no %s@%s %s 2>&1", - $this->data['password'], - $this->data['port'], - $this->data['username'], - $this->data['host'], - $escapedCommand - ); - - return shell_exec($sshCommand) ?? ''; + + // Determine auth method + $sshOptions = '-q -o LogLevel=ERROR -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no'; + $keyFile = ''; + + if (!empty($this->data['ssh_key'])) { + $keyFile = tempnam(sys_get_temp_dir(), 'sshkey'); + file_put_contents($keyFile, $this->data['ssh_key']); + chmod($keyFile, 0600); + $sshOptions .= " -i {$keyFile} -o IdentitiesOnly=yes -o PubkeyAuthentication=yes -o PreferredAuthentications=publickey"; + + $sshCommand = sprintf( + "ssh -p %d %s %s@%s %s 2>&1", + $this->data['port'], + $sshOptions, + $this->data['username'], + $this->data['host'], + $escapedCommand + ); + } else { + if ($sudo && strtolower($this->data['username']) !== 'root') { + $command = "echo '{$this->data['password']}' | sudo -S " . $command; + $escapedCommand = escapeshellarg($command); + } + + $sshOptions .= " -o PreferredAuthentications=password -o PubkeyAuthentication=no"; + $sshCommand = sprintf( + "sshpass -p '%s' ssh -p %d %s %s@%s %s 2>&1", + $this->data['password'], + $this->data['port'], + $sshOptions, + $this->data['username'], + $this->data['host'], + $escapedCommand + ); + } + + $output = shell_exec($sshCommand) ?? ''; + + if ($keyFile && file_exists($keyFile)) { + unlink($keyFile); + } + + return $output; } - + /** * Install Docker on remote server */ - private function installDocker(): void { + private function installDocker(): void + { $dockerVersion = $this->executeCommand('docker --version'); if (stripos($dockerVersion, 'version') !== false) { return; // Docker already installed } - + $this->executeCommand('curl -fsSL https://get.docker.com | sh', true); $this->executeCommand('systemctl enable --now docker', true); } - + /** * Find free UDP port on remote server */ - private function findFreeUdpPort(): int { + private function findFreeUdpPort(): int + { $min = 30000; $max = 65000; - + for ($attempt = 0; $attempt < 30; $attempt++) { $candidate = random_int($min, $max); $cmd = "ss -lun | awk '{print \$4}' | grep -E ':(" . $candidate . ")($| )' || true"; @@ -213,14 +504,15 @@ class VpnServer { return $candidate; } } - + throw new Exception('Could not find free UDP port'); } - + /** * Create Dockerfile on remote server */ - private function createDockerfile(): void { + private function createDockerfile(): void + { $dockerfile = <<<'DOCKERFILE' FROM amneziavpn/amnezia-wg:latest @@ -236,15 +528,16 @@ RUN chmod a+x /opt/amnezia/start.sh ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ] CMD [ "" ] DOCKERFILE; - + $escaped = addslashes(trim($dockerfile)); $this->executeCommand("echo \"{$escaped}\" > /opt/amnezia/amnezia-awg/Dockerfile", true); } - + /** * Create start script on remote server */ - private function createStartScript(): void { + private function createStartScript(): void + { $script = <<<'BASH' #!/bin/bash @@ -285,23 +578,24 @@ iptables -t nat -A POSTROUTING -s 10.8.1.0/24 -o eth1 -j MASQUERADE 2>/dev/null tail -f /dev/null BASH; - + $escaped = addslashes(trim($script)); $this->executeCommand("echo \"{$escaped}\" > /opt/amnezia/amnezia-awg/start.sh", true); $this->executeCommand("chmod +x /opt/amnezia/amnezia-awg/start.sh", true); } - + /** * Build Docker image */ - private function buildDockerImage(): void { + private function buildDockerImage(): void + { $containerName = $this->data['container_name']; - + // Cleanup old container/image $this->executeCommand("docker stop {$containerName} 2>/dev/null || true", true); $this->executeCommand("docker rm -fv {$containerName} 2>/dev/null || true", true); $this->executeCommand("docker rmi {$containerName} 2>/dev/null || true", true); - + // Build new image $buildCmd = sprintf( 'docker build --no-cache --pull -t %s /opt/amnezia/amnezia-awg', @@ -309,13 +603,14 @@ BASH; ); $this->executeCommand($buildCmd, true); } - + /** * Run Docker container */ - private function runContainer(int $vpnPort): void { + private function runContainer(int $vpnPort): void + { $containerName = $this->data['container_name']; - + $runCmd = sprintf( 'docker run -d --log-driver none --restart always --privileged --cap-add=NET_ADMIN --cap-add=SYS_MODULE -p %d:%d/udp -v /lib/modules:/lib/modules --name %s %s', $vpnPort, @@ -323,30 +618,31 @@ BASH; $containerName, $containerName ); - + $this->executeCommand($runCmd, true); sleep(3); // Wait for container to start } - + /** * Initialize server configuration with AWG parameters */ - private function initializeServerConfig(int $vpnPort): array { + private function initializeServerConfig(int $vpnPort): array + { $containerName = $this->data['container_name']; - + // Create directory $this->executeCommand("docker exec -i {$containerName} mkdir -p /opt/amnezia/awg", true); - + // Generate keys $this->executeCommand("docker exec -i {$containerName} sh -c 'cd /opt/amnezia/awg && umask 077 && wg genkey | tee server_private.key | wg pubkey > wireguard_server_public_key.key'", true); $this->executeCommand("docker exec -i {$containerName} sh -c 'cd /opt/amnezia/awg && wg genpsk > wireguard_psk.key'", true); $this->executeCommand("docker exec -i {$containerName} chmod 600 /opt/amnezia/awg/server_private.key /opt/amnezia/awg/wireguard_psk.key /opt/amnezia/awg/wireguard_server_public_key.key", true); - + // Get keys $privKey = trim($this->executeCommand("docker exec -i {$containerName} cat /opt/amnezia/awg/server_private.key", true)); $pubKey = trim($this->executeCommand("docker exec -i {$containerName} cat /opt/amnezia/awg/wireguard_server_public_key.key", true)); $psk = trim($this->executeCommand("docker exec -i {$containerName} cat /opt/amnezia/awg/wireguard_psk.key", true)); - + // Generate AWG parameters $awgParams = [ 'Jc' => 3, @@ -359,7 +655,7 @@ BASH; 'H3' => rand(100000, 2000000000), 'H4' => rand(100000, 2000000000) ]; - + // Create wg0.conf $wgConfig = "[Interface]\n"; $wgConfig .= "PrivateKey = {$privKey}\n"; @@ -369,63 +665,67 @@ BASH; $wgConfig .= "{$key} = {$value}\n"; } $wgConfig .= "\n"; - + $escaped = addslashes($wgConfig); $this->executeCommand("docker exec -i {$containerName} sh -c 'echo \"{$escaped}\" > /opt/amnezia/awg/wg0.conf'", true); $this->executeCommand("docker exec -i {$containerName} chmod 600 /opt/amnezia/awg/wg0.conf", true); - + // Create clientsTable $this->executeCommand("docker exec -i {$containerName} sh -c 'echo \"[]\" > /opt/amnezia/awg/clientsTable'", true); - + // Start WireGuard $this->executeCommand("docker exec -i {$containerName} wg-quick up /opt/amnezia/awg/wg0.conf 2>&1", true); - + // Apply firewall rules $this->executeCommand("docker exec -i {$containerName} sh -c 'iptables -A INPUT -i wg0 -j ACCEPT 2>/dev/null || true'", true); $this->executeCommand("docker exec -i {$containerName} sh -c 'iptables -A FORWARD -i wg0 -j ACCEPT 2>/dev/null || true'", true); $this->executeCommand("docker exec -i {$containerName} sh -c 'iptables -A OUTPUT -o wg0 -j ACCEPT 2>/dev/null || true'", true); $this->executeCommand("docker exec -i {$containerName} sh -c 'iptables -A FORWARD -i wg0 -o eth0 -s 10.8.1.0/24 -j ACCEPT 2>/dev/null || true'", true); $this->executeCommand("docker exec -i {$containerName} sh -c 'iptables -t nat -A POSTROUTING -s 10.8.1.0/24 -o eth0 -j MASQUERADE 2>/dev/null || true'", true); - + sleep(2); - + return [ 'public_key' => $pubKey, 'preshared_key' => $psk, 'awg_params' => $awgParams ]; } - + /** * Get server status from database */ - public function getStatus(): string { + public function getStatus(): string + { return $this->data['status'] ?? 'unknown'; } - + /** * Get all servers for a user */ - public static function listByUser(int $userId): array { + public static function listByUser(int $userId): array + { $pdo = DB::conn(); $stmt = $pdo->prepare('SELECT * FROM vpn_servers WHERE user_id = ? ORDER BY created_at DESC'); $stmt->execute([$userId]); return $stmt->fetchAll(); } - + /** * Get all servers (admin only) */ - public static function listAll(): array { + public static function listAll(): array + { $pdo = DB::conn(); $stmt = $pdo->query('SELECT s.*, u.email as user_email FROM vpn_servers s LEFT JOIN users u ON s.user_id = u.id ORDER BY s.created_at DESC'); return $stmt->fetchAll(); } - + /** * Delete server */ - public function delete(): bool { + public function delete(): bool + { // Stop and remove container try { $containerName = $this->data['container_name']; @@ -435,20 +735,21 @@ BASH; } catch (Exception $e) { // Ignore errors during cleanup } - + // Delete from database $pdo = DB::conn(); $stmt = $pdo->prepare('DELETE FROM vpn_servers WHERE id = ?'); return $stmt->execute([$this->serverId]); } - + /** * Get server data */ - public function getData(): ?array { + public function getData(): ?array + { return $this->data; } - + /** * Create backup of server configuration and all clients * @@ -456,21 +757,22 @@ BASH; * @param string $backupType Type: 'manual' or 'automatic' * @return int Backup ID */ - public function createBackup(int $userId, string $backupType = 'manual'): int { + public function createBackup(int $userId, string $backupType = 'manual'): int + { if (!$this->data) { throw new Exception('Server not loaded'); } - + $pdo = DB::conn(); $backupName = 'backup_' . $this->serverId . '_' . date('Y-m-d_His') . '.json'; $backupDir = '/var/www/html/backups'; $backupPath = $backupDir . '/' . $backupName; - + // Create backups directory if not exists if (!is_dir($backupDir)) { mkdir($backupDir, 0755, true); } - + try { // Get all clients for this server $stmt = $pdo->prepare(' @@ -481,7 +783,7 @@ BASH; '); $stmt->execute([$this->serverId]); $clients = $stmt->fetchAll(); - + // Prepare backup data $backupData = [ 'server' => [ @@ -491,6 +793,8 @@ BASH; 'vpn_port' => $this->data['vpn_port'], 'vpn_subnet' => $this->data['vpn_subnet'], 'container_name' => $this->data['container_name'], + 'install_protocol' => $this->data['install_protocol'] ?? null, + 'install_options' => $this->data['install_options'] ? json_decode($this->data['install_options'], true) : null, 'server_public_key' => $this->data['server_public_key'], 'preshared_key' => $this->data['preshared_key'], 'awg_params' => $this->data['awg_params'], @@ -499,20 +803,20 @@ BASH; 'backup_date' => date('Y-m-d H:i:s'), 'version' => '1.0' ]; - + // Write backup to file $json = json_encode($backupData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); file_put_contents($backupPath, $json); - + $backupSize = filesize($backupPath); - + // Insert backup record $stmt = $pdo->prepare(' INSERT INTO server_backups (server_id, backup_name, backup_path, backup_size, clients_count, backup_type, status, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?) '); - + $stmt->execute([ $this->serverId, $backupName, @@ -523,9 +827,9 @@ BASH; 'completed', $userId ]); - - return (int)$pdo->lastInsertId(); - + + return (int) $pdo->lastInsertId(); + } catch (Exception $e) { // Mark backup as failed if (isset($stmt)) { @@ -534,7 +838,7 @@ BASH; (server_id, backup_name, backup_path, backup_type, status, error_message, created_by) VALUES (?, ?, ?, ?, ?, ?, ?) '); - + $stmt->execute([ $this->serverId, $backupName, @@ -545,21 +849,22 @@ BASH; $userId ]); } - + throw $e; } } - + /** * List all backups for this server * * @return array List of backups */ - public function listBackups(): array { + public function listBackups(): array + { if (!$this->data) { throw new Exception('Server not loaded'); } - + $pdo = DB::conn(); $stmt = $pdo->prepare(' SELECT b.*, u.name as created_by_name, u.email as created_by_email @@ -571,7 +876,7 @@ BASH; $stmt->execute([$this->serverId]); return $stmt->fetchAll(); } - + /** * Restore server from backup * Note: This only restores client configurations to database @@ -580,54 +885,55 @@ BASH; * @param int $backupId Backup ID * @return array Restoration results */ - public function restoreBackup(int $backupId): array { + public function restoreBackup(int $backupId): array + { if (!$this->data) { throw new Exception('Server not loaded'); } - + if ($this->data['status'] !== 'active') { throw new Exception('Server must be active to restore backup'); } - + $pdo = DB::conn(); - + // Get backup record $stmt = $pdo->prepare('SELECT * FROM server_backups WHERE id = ? AND server_id = ?'); $stmt->execute([$backupId, $this->serverId]); $backup = $stmt->fetch(); - + if (!$backup) { throw new Exception('Backup not found'); } - + if (!file_exists($backup['backup_path'])) { throw new Exception('Backup file not found'); } - + // Read backup data $backupData = json_decode(file_get_contents($backup['backup_path']), true); - + if (!$backupData || !isset($backupData['clients'])) { throw new Exception('Invalid backup format'); } - + $restored = 0; $failed = 0; $errors = []; - + foreach ($backupData['clients'] as $clientData) { try { // Check if client already exists by IP $stmt = $pdo->prepare('SELECT id FROM vpn_clients WHERE server_id = ? AND client_ip = ?'); $stmt->execute([$this->serverId, $clientData['client_ip']]); $existing = $stmt->fetch(); - + if ($existing) { $errors[] = "Client {$clientData['name']} already exists"; $failed++; continue; } - + // Insert client $stmt = $pdo->prepare(' INSERT INTO vpn_clients @@ -635,7 +941,7 @@ BASH; config, status, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) '); - + $stmt->execute([ $this->serverId, $this->data['user_id'], @@ -648,18 +954,18 @@ BASH; 'disabled', // Restore as disabled for safety $clientData['expires_at'] ]); - + // Add client to server container VpnClient::addClientToServer($this->data, $clientData['public_key'], $clientData['client_ip']); - + $restored++; - + } catch (Exception $e) { $failed++; $errors[] = "Failed to restore {$clientData['name']}: " . $e->getMessage(); } } - + return [ 'success' => true, // Always success if process completed 'restored' => $restored, @@ -669,42 +975,44 @@ BASH; 'message' => $restored > 0 ? "Restored $restored clients" : "No clients restored" ]; } - + /** * Delete backup * * @param int $backupId Backup ID * @return bool Success */ - public static function deleteBackup(int $backupId): bool { + public static function deleteBackup(int $backupId): bool + { $pdo = DB::conn(); - + // Get backup path $stmt = $pdo->prepare('SELECT backup_path FROM server_backups WHERE id = ?'); $stmt->execute([$backupId]); $backup = $stmt->fetch(); - + if (!$backup) { return false; } - + // Delete file if (file_exists($backup['backup_path'])) { unlink($backup['backup_path']); } - + // Delete record $stmt = $pdo->prepare('DELETE FROM server_backups WHERE id = ?'); return $stmt->execute([$backupId]); } - + /** * Get backup by ID * * @param int $backupId Backup ID * @return array|null Backup data */ - public static function getBackup(int $backupId): ?array { + public static function getBackup(int $backupId): ?array + { $pdo = DB::conn(); $stmt = $pdo->prepare('SELECT * FROM server_backups WHERE id = ?'); $stmt->execute([$backupId]); diff --git a/migrations/014_consolidated.sql b/migrations/014_consolidated.sql new file mode 100644 index 0000000..517b146 --- /dev/null +++ b/migrations/014_consolidated.sql @@ -0,0 +1,788 @@ +INSERT INTO translations (locale, category, key_name, translation) VALUES +('en','servers','backup_upload_type','Backup type'), +('en','servers','backup_type_auto','Auto detect'), +('en','servers','backup_type_amnezia','Amnezia app (.backup)'), +('en','servers','backup_type_panel','Panel export (.json)'), +('en','servers','backup_upload_hint','Upload a .backup or .json file. After upload, pick a server entry above.'), +('ru','servers','backup_upload_type','Тип бэкапа'), +('ru','servers','backup_type_auto','Определить автоматически'), +('ru','servers','backup_type_amnezia','Приложение Amnezia (.backup)'), +('ru','servers','backup_type_panel','Экспорт панели (.json)'), +('ru','servers','backup_upload_hint','Загрузите файл .backup или .json. После загрузки выберите сервер выше.'), +('es','servers','backup_upload_type','Tipo de copia de seguridad'), +('es','servers','backup_type_auto','Detectar automáticamente'), +('es','servers','backup_type_amnezia','Aplicación Amnezia (.backup)'), +('es','servers','backup_type_panel','Exportación del panel (.json)'), +('es','servers','backup_upload_hint','Suba un archivo .backup o .json. Después seleccione el servidor arriba.'), +('de','servers','backup_upload_type','Backup-Typ'), +('de','servers','backup_type_auto','Automatisch erkennen'), +('de','servers','backup_type_amnezia','Amnezia-App (.backup)'), +('de','servers','backup_type_panel','Panel-Export (.json)'), +('de','servers','backup_upload_hint','Laden Sie eine .backup- oder .json-Datei hoch. Wählen Sie anschließend oben einen Server aus.'), +('fr','servers','backup_upload_type','Type de sauvegarde'), +('fr','servers','backup_type_auto','Détection automatique'), +('fr','servers','backup_type_amnezia','Application Amnezia (.backup)'), +('fr','servers','backup_type_panel','Export du panneau (.json)'), +('fr','servers','backup_upload_hint','Téléversez un fichier .backup ou .json, puis sélectionnez un serveur ci-dessus.'), +('zh','servers','backup_upload_type','备份类型'), +('zh','servers','backup_type_auto','自动检测'), +('zh','servers','backup_type_amnezia','Amnezia 应用 (.backup)'), +('zh','servers','backup_type_panel','面板导出 (.json)'), +('zh','servers','backup_upload_hint','上传 .backup 或 .json 文件,随后在上方选择服务器。') +ON DUPLICATE KEY UPDATE translation = VALUES(translation); + +INSERT INTO translations (locale, category, key_name, translation) VALUES +('en', 'servers', 'config_import_title', 'Import configuration'), +('en', 'servers', 'config_import_hint', 'Upload a configuration backup to update this server and its clients.'), +('en', 'servers', 'config_import_type_label', 'Backup type'), +('en', 'servers', 'config_import_type_panel', 'Panel backup (.json)'), +('en', 'servers', 'config_import_type_amnezia', 'Amnezia app backup (.backup)'), +('en', 'servers', 'config_import_file_label', 'Configuration file'), +('en', 'servers', 'config_import_file_hint', 'Our panel uses .json files. The Amnezia app uses .backup files.'), +('en', 'servers', 'config_import_submit', 'Import configuration'), +('ru', 'servers', 'config_import_title', 'Импорт конфигурации'), +('ru', 'servers', 'config_import_hint', 'Загрузите файл бэкапа, чтобы обновить настройки сервера и список клиентов.'), +('ru', 'servers', 'config_import_type_label', 'Источник бэкапа'), +('ru', 'servers', 'config_import_type_panel', 'Бэкап панели (.json)'), +('ru', 'servers', 'config_import_type_amnezia', 'Бэкап приложения Amnezia (.backup)'), +('ru', 'servers', 'config_import_file_label', 'Файл конфигурации'), +('ru', 'servers', 'config_import_file_hint', 'Панель использует файлы .json. Приложение Amnezia — файлы .backup.'), +('ru', 'servers', 'config_import_submit', 'Импортировать конфигурацию'), +('es', 'servers', 'config_import_title', 'Importar configuración'), +('es', 'servers', 'config_import_hint', 'Cargue un respaldo para actualizar este servidor y sus clientes.'), +('es', 'servers', 'config_import_type_label', 'Tipo de backup'), +('es', 'servers', 'config_import_type_panel', 'Backup del panel (.json)'), +('es', 'servers', 'config_import_type_amnezia', 'Backup de la app Amnezia (.backup)'), +('es', 'servers', 'config_import_file_label', 'Archivo de configuración'), +('es', 'servers', 'config_import_file_hint', 'El panel usa archivos .json. La app Amnezia usa archivos .backup.'), +('es', 'servers', 'config_import_submit', 'Importar configuración'), +('de', 'servers', 'config_import_title', 'Konfiguration importieren'), +('de', 'servers', 'config_import_hint', 'Laden Sie eine Sicherung hoch, um diesen Server und seine Clients zu aktualisieren.'), +('de', 'servers', 'config_import_type_label', 'Backup-Typ'), +('de', 'servers', 'config_import_type_panel', 'Panel-Backup (.json)'), +('de', 'servers', 'config_import_type_amnezia', 'Amnezia-App-Backup (.backup)'), +('de', 'servers', 'config_import_file_label', 'Konfigurationsfile'), +('de', 'servers', 'config_import_file_hint', 'Die Panel-Backups sind .json. Die Amnezia-App nutzt .backup-Dateien.'), +('de', 'servers', 'config_import_submit', 'Konfiguration importieren'), +('fr', 'servers', 'config_import_title', 'Importer la configuration'), +('fr', 'servers', 'config_import_hint', 'Téléversez un fichier de sauvegarde pour mettre à jour ce serveur et ses clients.'), +('fr', 'servers', 'config_import_type_label', 'Type de sauvegarde'), +('fr', 'servers', 'config_import_type_panel', 'Sauvegarde du panneau (.json)'), +('fr', 'servers', 'config_import_type_amnezia', 'Sauvegarde de l’application Amnezia (.backup)'), +('fr', 'servers', 'config_import_file_label', 'Fichier de configuration'), +('fr', 'servers', 'config_import_file_hint', 'Notre panneau utilise des fichiers .json. L’application Amnezia utilise des fichiers .backup.'), +('fr', 'servers', 'config_import_submit', 'Importer la configuration'), +('zh', 'servers', 'config_import_title', '导入配置'), +('zh', 'servers', 'config_import_hint', '上传备份文件以更新此服务器及其客户端。'), +('zh', 'servers', 'config_import_type_label', '备份类型'), +('zh', 'servers', 'config_import_type_panel', '面板备份 (.json)'), +('zh', 'servers', 'config_import_type_amnezia', 'Amnezia 应用备份 (.backup)'), +('zh', 'servers', 'config_import_file_label', '配置文件'), +('zh', 'servers', 'config_import_file_hint', '面板使用 .json 文件,Amnezia 应用使用 .backup 文件。'), +('zh', 'servers', 'config_import_submit', '导入配置') +ON DUPLICATE KEY UPDATE translation = VALUES(translation); + +INSERT INTO translations (locale, category, key_name, translation) VALUES +('en','servers','creation_mode','Creation mode'), +('en','servers','creation_mode_manual','Manual setup'), +('en','servers','creation_mode_backup','Import from backup'), +('en','servers','upload_backup_file','Upload backup file'), +('en','servers','backup_upload_hint','Supported formats: panel JSON export or Amnezia application .backup'), +('en','servers','backup_server_entry','Select server entry'), +('en','servers','backup_summary_host','Host'), +('en','servers','backup_summary_clients','Clients'), +('en','servers','config_import_title','Restore configuration from backup'), +('en','servers','config_import_hint','Import server configuration (and optional clients) from a panel export or Amnezia application backup.'), +('en','servers','config_import_type_label','Backup type'), +('en','servers','config_import_type_panel','Panel export (.json)'), +('en','servers','config_import_type_amnezia','Amnezia app backup (.backup)'), +('en','servers','config_import_file_label','Configuration file'), +('en','servers','config_import_file_hint','The file remains on the server only during import and is deleted afterwards.'), +('en','servers','config_import_submit','Import configuration'), +('ru','servers','creation_mode','Режим создания'), +('ru','servers','creation_mode_manual','Ручная настройка'), +('ru','servers','creation_mode_backup','Импорт из бэкапа'), +('ru','servers','upload_backup_file','Загрузите файл бэкапа'), +('ru','servers','backup_upload_hint','Поддерживаются форматы: экспорт панели JSON или бэкап приложения Amnezia (.backup)'), +('ru','servers','backup_server_entry','Выберите запись сервера'), +('ru','servers','backup_summary_host','Хост'), +('ru','servers','backup_summary_clients','Клиенты'), +('ru','servers','config_import_title','Восстановление конфигурации из бэкапа'), +('ru','servers','config_import_hint','Импортируйте конфигурацию сервера (и при необходимости клиентов) из экспорта панели или бэкапа приложения Amnezia.'), +('ru','servers','config_import_type_label','Тип бэкапа'), +('ru','servers','config_import_type_panel','Экспорт панели (.json)'), +('ru','servers','config_import_type_amnezia','Бэкап приложения Amnezia (.backup)'), +('ru','servers','config_import_file_label','Файл конфигурации'), +('ru','servers','config_import_file_hint','Файл хранится на сервере только во время импорта и удаляется сразу после завершения.'), +('ru','servers','config_import_submit','Импортировать конфигурацию') +ON DUPLICATE KEY UPDATE translation = VALUES(translation); + +CREATE TABLE IF NOT EXISTS protocols ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) UNIQUE NOT NULL, + description TEXT, + install_script TEXT, + output_template TEXT, + ubuntu_compatible BOOLEAN DEFAULT false, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_protocols_slug (slug), + INDEX idx_protocols_active (is_active), + INDEX idx_protocols_ubuntu_compatible (ubuntu_compatible) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS protocol_templates ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + protocol_id INT UNSIGNED NOT NULL, + template_name VARCHAR(255) NOT NULL, + template_content TEXT NOT NULL, + is_default BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_protocol_templates_protocol (protocol_id), + INDEX idx_protocol_templates_default (is_default), + FOREIGN KEY (protocol_id) REFERENCES protocols(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS protocol_variables ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + protocol_id INT UNSIGNED NOT NULL, + variable_name VARCHAR(100) NOT NULL, + variable_type VARCHAR(50) NOT NULL DEFAULT 'string', + default_value TEXT, + description TEXT, + required BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_protocol_variables_protocol (protocol_id), + INDEX idx_protocol_variables_name (variable_name), + FOREIGN KEY (protocol_id) REFERENCES protocols(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS server_protocols ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + server_id INT UNSIGNED NOT NULL, + protocol_id INT UNSIGNED NOT NULL, + config_data JSON, + applied_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_server_protocols_server (server_id), + INDEX idx_server_protocols_protocol (protocol_id), + INDEX idx_server_protocols_applied (applied_at), + UNIQUE KEY unique_server_protocol (server_id, protocol_id), + FOREIGN KEY (server_id) REFERENCES vpn_servers(id) ON DELETE CASCADE, + FOREIGN KEY (protocol_id) REFERENCES protocols(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS ai_generations ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + protocol_id INT UNSIGNED NULL, + model_used VARCHAR(100) NOT NULL, + prompt TEXT NOT NULL, + generated_script TEXT, + suggestions JSON, + ubuntu_compatible BOOLEAN, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_ai_generations_protocol (protocol_id), + INDEX idx_ai_generations_model (model_used), + INDEX idx_ai_generations_created (created_at DESC), + FOREIGN KEY (protocol_id) REFERENCES protocols(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO protocols (name, slug, description, install_script, output_template, ubuntu_compatible, is_active) +SELECT 'AmneziaWG Advanced', 'amnezia-wg-advanced', 'AmneziaWG protocol with advanced junk packet obfuscation parameters', '#!/bin/bash +echo "AmneziaWG Advanced installed" +', '[Interface] +PrivateKey = {{private_key}} +Address = {{client_ip}}/32 +DNS = 8.8.8.8, 8.8.4.4 + +[Peer] +PublicKey = {{server_public_key}} +PresharedKey = {{preshared_key}} +Endpoint = {{server_host}}:{{server_port}} +AllowedIPs = 0.0.0.0/0 +PersistentKeepalive = 25 + +Jc = {{jc}} +Jmin = {{jmin}} +Jmax = {{jmax}} +S1 = {{s1}} +S2 = {{s2}} +H1 = {{h1}} +H2 = {{h2}} +H3 = {{h3}} +H4 = {{h4}}', true, true +WHERE NOT EXISTS (SELECT 1 FROM protocols WHERE slug='amnezia-wg-advanced'); + +INSERT INTO protocols (name, slug, description, install_script, output_template, ubuntu_compatible, is_active) +SELECT 'WireGuard Standard', 'wireguard-standard', 'Standard WireGuard VPN protocol', '#!/bin/bash +CONTAINER_NAME="wireguard" +VPN_SUBNET="10.8.2.0/24" +PRIVATE_KEY=$(wg genkey) +PUBLIC_KEY=$(echo $PRIVATE_KEY | wg pubkey) +PRESHARED_KEY=$(wg genpsk) +docker run -d \ + --name $CONTAINER_NAME \ + --cap-add=NET_ADMIN \ + --cap-add=SYS_MODULE \ + -v /opt/wireguard:/etc/wireguard \ + linuxserver/wireguard +cat > /opt/wireguard/wg0.conf << EOF +[Interface] +PrivateKey = $PRIVATE_KEY +Address = 10.8.2.1/24 +ListenPort = 51820 + +[Peer] +PublicKey = +PresharedKey = $PRESHARED_KEY +AllowedIPs = 10.8.2.2/32 +EOF +echo "WireGuard Standard installed successfully" +echo "Server Public Key: $PUBLIC_KEY"', '[Interface] +PrivateKey = {{private_key}} +Address = {{client_ip}}/32 +DNS = 8.8.8.8, 8.8.4.4 + +[Peer] +PublicKey = {{server_public_key}} +PresharedKey = {{preshared_key}} +Endpoint = {{server_host}}:{{server_port}} +AllowedIPs = 0.0.0.0/0 +PersistentKeepalive = 25', true, true +WHERE NOT EXISTS (SELECT 1 FROM protocols WHERE slug='wireguard-standard'); + +INSERT INTO protocols (name, slug, description, install_script, output_template, ubuntu_compatible, is_active) +SELECT 'OpenVPN', 'openvpn', 'OpenVPN protocol with TCP/UDP support', '#!/bin/bash +CONTAINER_NAME="openvpn" +VPN_SUBNET="10.8.3.0/24" +docker run -d \ + --name $CONTAINER_NAME \ + --cap-add=NET_ADMIN \ + -p 1194:1194/udp \ + -p 1194:1194/tcp \ + -v /opt/openvpn:/etc/openvpn \ + kylemanna/openvpn +docker exec -it $CONTAINER_NAME ovpn_genconfig -u udp://{{server_host}}:1194 +docker exec -it $CONTAINER_NAME ovpn_initpki +echo "OpenVPN installed successfully" +echo "Available on ports: 1194/udp, 1194/tcp"', 'client +dev tun +proto {{protocol}} +remote {{server_host}} {{server_port}} +resolv-retry infinite +nobind +persist-key +persist-tun +ca ca.crt +cert client.crt +key client.key +remote-cert-tls server +cipher AES-256-GCM +auth SHA256 +verb 3', true, true +WHERE NOT EXISTS (SELECT 1 FROM protocols WHERE slug='openvpn'); + +INSERT INTO protocols (name, slug, description, install_script, output_template, ubuntu_compatible, is_active) +SELECT 'Shadowsocks', 'shadowsocks', 'Shadowsocks proxy protocol', '#!/bin/bash +CONTAINER_NAME="shadowsocks" +PASSWORD=$(openssl rand -base64 12) +docker run -d \ + --name $CONTAINER_NAME \ + -p 8388:8388 \ + -e METHOD=aes-256-gcm \ + -e PASSWORD=$PASSWORD \ + shadowsocks/shadowsocks-libev +echo "Shadowsocks installed successfully" +echo "Port: 8388" +echo "Method: aes-256-gcm" +echo "Password: $PASSWORD"', '{ + "server": "{{server_host}}", + "server_port": {{server_port}}, + "password": "{{password}}", + "method": "{{method}}" +}', true, true +WHERE NOT EXISTS (SELECT 1 FROM protocols WHERE slug='shadowsocks'); + +INSERT INTO protocol_templates (protocol_id, template_name, template_content, is_default) +SELECT p.id, 'Default AmneziaWG', '[Interface] +PrivateKey = {{private_key}} +Address = {{client_ip}}/32 +DNS = 8.8.8.8, 8.8.4.4 + +[Peer] +PublicKey = {{server_public_key}} +PresharedKey = {{preshared_key}} +Endpoint = {{server_host}}:{{server_port}} +AllowedIPs = 0.0.0.0/0 +PersistentKeepalive = 25 + +Jc = {{jc}} +Jmin = {{jmin}} +Jmax = {{jmax}} +S1 = {{s1}} +S2 = {{s2}} +H1 = {{h1}} +H2 = {{h2}} +H3 = {{h3}} +H4 = {{h4}}', true +FROM protocols p WHERE p.slug='amnezia-wg-advanced' AND NOT EXISTS (SELECT 1 FROM protocol_templates WHERE protocol_id=p.id AND template_name='Default AmneziaWG'); + +INSERT INTO protocol_templates (protocol_id, template_name, template_content, is_default) +SELECT p.id, 'Default WireGuard', '[Interface] +PrivateKey = {{private_key}} +Address = {{client_ip}}/32 +DNS = 8.8.8.8, 8.8.4.4 + +[Peer] +PublicKey = {{server_public_key}} +PresharedKey = {{preshared_key}} +Endpoint = {{server_host}}:{{server_port}} +AllowedIPs = 0.0.0.0/0 +PersistentKeepalive = 25', true +FROM protocols p WHERE p.slug='wireguard-standard' AND NOT EXISTS (SELECT 1 FROM protocol_templates WHERE protocol_id=p.id AND template_name='Default WireGuard'); + +INSERT INTO protocol_templates (protocol_id, template_name, template_content, is_default) +SELECT p.id, 'Default OpenVPN', 'client +dev tun +proto {{protocol}} +remote {{server_host}} {{server_port}} +resolv-retry infinite +nobind +persist-key +persist-tun +ca ca.crt +cert client.crt +key client.key +remote-cert-tls server +cipher AES-256-GCM +auth SHA256 +verb 3', true +FROM protocols p WHERE p.slug='openvpn' AND NOT EXISTS (SELECT 1 FROM protocol_templates WHERE protocol_id=p.id AND template_name='Default OpenVPN'); + +INSERT INTO protocol_templates (protocol_id, template_name, template_content, is_default) +SELECT p.id, 'Default Shadowsocks', '{ + "server": "{{server_host}}", + "server_port": {{server_port}}, + "password": "{{password}}", + "method": "{{method}}" +}', true +FROM protocols p WHERE p.slug='shadowsocks' AND NOT EXISTS (SELECT 1 FROM protocol_templates WHERE protocol_id=p.id AND template_name='Default Shadowsocks'); + +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'private_key', 'string', '', 'Client private key', true FROM protocols p WHERE p.slug='amnezia-wg-advanced' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='private_key'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'client_ip', 'string', '10.8.1.2', 'Client IP address', true FROM protocols p WHERE p.slug='amnezia-wg-advanced' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='client_ip'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'server_public_key', 'string', '', 'Server public key', true FROM protocols p WHERE p.slug='amnezia-wg-advanced' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='server_public_key'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'preshared_key', 'string', '', 'Pre-shared key for additional security', true FROM protocols p WHERE p.slug='amnezia-wg-advanced' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='preshared_key'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'server_host', 'string', '', 'Server hostname or IP', true FROM protocols p WHERE p.slug='amnezia-wg-advanced' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='server_host'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'server_port', 'number', '51820', 'Server port', true FROM protocols p WHERE p.slug='amnezia-wg-advanced' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='server_port'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'jc', 'number', '4', 'Junk packet count', false FROM protocols p WHERE p.slug='amnezia-wg-advanced' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='jc'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'jmin', 'number', '50', 'Minimum junk packet size', false FROM protocols p WHERE p.slug='amnezia-wg-advanced' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='jmin'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'jmax', 'number', '1000', 'Maximum junk packet size', false FROM protocols p WHERE p.slug='amnezia-wg-advanced' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='jmax'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 's1', 'number', '148', 'Junk packet size 1', false FROM protocols p WHERE p.slug='amnezia-wg-advanced' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='s1'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 's2', 'number', '450', 'Junk packet size 2', false FROM protocols p WHERE p.slug='amnezia-wg-advanced' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='s2'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'h1', 'number', '320121696', 'Junk packet header 1', false FROM protocols p WHERE p.slug='amnezia-wg-advanced' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='h1'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'h2', 'number', '51525354', 'Junk packet header 2', false FROM protocols p WHERE p.slug='amnezia-wg-advanced' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='h2'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'h3', 'number', '13141516', 'Junk packet header 3', false FROM protocols p WHERE p.slug='amnezia-wg-advanced' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='h3'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'h4', 'number', '92435495', 'Junk packet header 4', false FROM protocols p WHERE p.slug='amnezia-wg-advanced' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='h4'); + +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'private_key', 'string', '', 'Client private key', true FROM protocols p WHERE p.slug='wireguard-standard' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='private_key'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'client_ip', 'string', '10.8.2.2', 'Client IP address', true FROM protocols p WHERE p.slug='wireguard-standard' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='client_ip'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'server_public_key', 'string', '', 'Server public key', true FROM protocols p WHERE p.slug='wireguard-standard' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='server_public_key'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'preshared_key', 'string', '', 'Pre-shared key for additional security', true FROM protocols p WHERE p.slug='wireguard-standard' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='preshared_key'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'server_host', 'string', '', 'Server hostname or IP', true FROM protocols p WHERE p.slug='wireguard-standard' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='server_host'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'server_port', 'number', '51820', 'Server port', true FROM protocols p WHERE p.slug='wireguard-standard' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='server_port'); + +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'protocol', 'string', 'udp', 'Connection protocol (udp/tcp)', true FROM protocols p WHERE p.slug='openvpn' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='protocol'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'server_host', 'string', '', 'Server hostname or IP', true FROM protocols p WHERE p.slug='openvpn' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='server_host'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'server_port', 'number', '1194', 'Server port', true FROM protocols p WHERE p.slug='openvpn' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='server_port'); + +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'server_host', 'string', '', 'Server hostname or IP', true FROM protocols p WHERE p.slug='shadowsocks' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='server_host'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'server_port', 'number', '8388', 'Server port', true FROM protocols p WHERE p.slug='shadowsocks' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='server_port'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'password', 'string', '', 'Connection password', true FROM protocols p WHERE p.slug='shadowsocks' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='password'); + +INSERT INTO translations (locale, category, key_name, translation) VALUES +('en','common','cancel','Cancel'), +('ru','common','cancel','Отмена'), +('es','common','cancel','Cancelar'), +('de','common','cancel','Abbrechen'), +('fr','common','cancel','Annuler'), +('zh','common','cancel','取消'), +('en','common','format','Format'), +('ru','common','format','Форматировать'), +('es','common','format','Formatear'), +('de','common','format','Formatieren'), +('fr','common','format','Formater'), +('zh','common','format','格式化'), +('en','common','clear','Clear'), +('ru','common','clear','Очистить'), +('es','common','clear','Borrar'), +('de','common','clear','Leeren'), +('fr','common','clear','Effacer'), +('zh','common','clear','清空'), +('en','protocols','template_editor_help','Use placeholders like {{variable}} and preview client output'), +('ru','protocols','template_editor_help','Используйте плейсхолдеры вида {{variable}} и просматривайте вывод клиента'), +('es','protocols','template_editor_help','Usa marcadores como {{variable}} y previsualiza la salida del cliente'), +('de','protocols','template_editor_help','Verwenden Sie Platzhalter wie {{variable}} und sehen Sie die Client‑Ausgabe in der Vorschau'), +('fr','protocols','template_editor_help','Utilisez des placeholders comme {{variable}} et prévisualisez la sortie client'), +('zh','protocols','template_editor_help','使用如 {{variable}} 的占位符并预览客户端输出'), +('en','protocols','variable_private_key_help','Client private key'), +('ru','protocols','variable_private_key_help','Приватный ключ клиента'), +('es','protocols','variable_private_key_help','Clave privada del cliente'), +('de','protocols','variable_private_key_help','Privater Schlüssel des Clients'), +('fr','protocols','variable_private_key_help','Clé privée du client'), +('zh','protocols','variable_private_key_help','客户端私钥'), +('en','protocols','variable_public_key_help','Server public key'), +('ru','protocols','variable_public_key_help','Публичный ключ сервера'), +('es','protocols','variable_public_key_help','Clave pública del servidor'), +('de','protocols','variable_public_key_help','Öffentlicher Schlüssel des Servers'), +('fr','protocols','variable_public_key_help','Clé publique du serveur'), +('zh','protocols','variable_public_key_help','服务器公钥'), +('en','protocols','variable_client_ip_help','Client IP address'), +('ru','protocols','variable_client_ip_help','IP‑адрес клиента'), +('es','protocols','variable_client_ip_help','Dirección IP del cliente'), +('de','protocols','variable_client_ip_help','IP‑Adresse des Clients'), +('fr','protocols','variable_client_ip_help','Adresse IP du client'), +('zh','protocols','variable_client_ip_help','客户端 IP 地址'), +('en','protocols','variable_server_host_help','VPN server host'), +('ru','protocols','variable_server_host_help','Хост VPN‑сервера'), +('es','protocols','variable_server_host_help','Host del servidor VPN'), +('de','protocols','variable_server_host_help','VPN‑Server‑Host'), +('fr','protocols','variable_server_host_help','Hôte du serveur VPN'), +('zh','protocols','variable_server_host_help','VPN 服务器主机'), +('en','protocols','variable_server_port_help','VPN server port'), +('ru','protocols','variable_server_port_help','Порт VPN‑сервера'), +('es','protocols','variable_server_port_help','Puerto del servidor VPN'), +('de','protocols','variable_server_port_help','VPN‑Server‑Port'), +('fr','protocols','variable_server_port_help','Port du serveur VPN'), +('zh','protocols','variable_server_port_help','VPN 服务器端口'), +('en','protocols','variable_preshared_key_help','WireGuard preshared key'), +('ru','protocols','variable_preshared_key_help','Предварительно общий ключ WireGuard'), +('es','protocols','variable_preshared_key_help','Clave precompartida de WireGuard'), +('de','protocols','variable_preshared_key_help','WireGuard‑vorausgeteilter Schlüssel'), +('fr','protocols','variable_preshared_key_help','Clé prépartagée WireGuard'), +('zh','protocols','variable_preshared_key_help','WireGuard 预共享密钥') +ON DUPLICATE KEY UPDATE translation = VALUES(translation); + + +INSERT INTO translations (locale, category, key_name, translation) VALUES +('en','ai','enter_protocol_id_to_apply','Enter protocol ID to apply'), +('ru','ai','enter_protocol_id_to_apply','Введите ID протокола для применения'), +('es','ai','enter_protocol_id_to_apply','Introduce el ID de protocolo para aplicar'), +('de','ai','enter_protocol_id_to_apply','Protokoll‑ID zum Anwenden eingeben'), +('fr','ai','enter_protocol_id_to_apply','Saisissez l’ID du protocole à appliquer'), +('zh','ai','enter_protocol_id_to_apply','输入要应用的协议 ID'), +('en','ai','improve_protocol','Improve protocol script for'), +('ru','ai','improve_protocol','Улучшить скрипт протокола для'), +('es','ai','improve_protocol','Mejorar script del protocolo для'), +('de','ai','improve_protocol','Protokollskript verbessern für'), +('fr','ai','improve_protocol','Améliorer le script du protocole pour'), +('zh','ai','improve_protocol','改进协议脚本:'), +('en','protocols','enter_protocol_name','Enter protocol name'), +('ru','protocols','enter_protocol_name','Введите имя протокола'), +('es','protocols','enter_protocol_name','Introduce el nombre del protocolo'), +('de','protocols','enter_protocol_name','Protokollnamen eingeben'), +('fr','protocols','enter_protocol_name','Saisissez le nom du protocole'), +('zh','protocols','enter_protocol_name','输入协议名称'), +('en','protocols','enter_protocol_slug','Enter protocol slug'), +('ru','protocols','enter_protocol_slug','Введите slug протокола'), +('es','protocols','enter_protocol_slug','Introduce el slug del protocolo'), +('de','protocols','enter_protocol_slug','Protokoll‑Slug eingeben'), +('fr','protocols','enter_protocol_slug','Saisissez le slug du protocole'), +('zh','protocols','enter_protocol_slug','输入协议 slug'), +('en','protocols','protocol_created_successfully','Protocol created successfully'), +('ru','protocols','protocol_created_successfully','Протокол успешно создан'), +('es','protocols','protocol_created_successfully','Protocolo creado correctamente'), +('de','protocols','protocol_created_successfully','Protokoll erfolgreich erstellt'), +('fr','protocols','protocol_created_successfully','Protocole créé avec succès'), +('zh','protocols','protocol_created_successfully','协议创建成功'), +('en','protocols','error_creating_protocol','Error creating protocol'), +('ru','protocols','error_creating_protocol','Ошибка создания протокола'), +('es','protocols','error_creating_protocol','Error al crear el protocolo'), +('de','protocols','error_creating_protocol','Fehler beim Erstellen des Protokolls'), +('fr','protocols','error_creating_protocol','Erreur lors de la création du protocole'), +('zh','protocols','error_creating_protocol','创建协议时出错') +ON DUPLICATE KEY UPDATE translation = VALUES(translation); + +INSERT INTO translations (locale, category, key_name, translation) VALUES +('en','settings','protocols','Protocols'), +('ru','settings','protocols','Протоколы'), +('es','settings','protocols','Protocolos'), +('de','settings','protocols','Protokolle'), +('fr','settings','protocols','Protocoles'), +('zh','settings','protocols','协议'), +('en','settings','protocol_management','Protocol Management'), +('ru','settings','protocol_management','Управление протоколами'), +('es','settings','protocol_management','Gestión de protocolos'), +('de','settings','protocol_management','Protokollverwaltung'), +('fr','settings','protocol_management','Gestion des protocoles'), +('zh','settings','protocol_management','协议管理') +ON DUPLICATE KEY UPDATE translation = VALUES(translation); + +INSERT INTO translations (locale, category, key_name, translation) VALUES +('en','protocols','test_install','Test install'), +('ru','protocols','test_install','Протестировать установку'), +('es','protocols','test_install','Probar instalación'), +('de','protocols','test_install','Installation testen'), +('fr','protocols','test_install','Tester l’installation'), +('zh','protocols','test_install','测试安装'), +('en','protocols','testing_on_ubuntu22','Testing on Ubuntu 22.04 in isolated Docker'), +('ru','protocols','testing_on_ubuntu22','Тест на Ubuntu 22.04 в изолированном Docker'), +('es','protocols','testing_on_ubuntu22','Prueba en Ubuntu 22.04 en Docker aislado'), +('de','protocols','testing_on_ubuntu22','Test auf Ubuntu 22.04 in isoliertem Docker'), +('fr','protocols','testing_on_ubuntu22','Test sur Ubuntu 22.04 dans Docker isolé'), +('zh','protocols','testing_on_ubuntu22','在隔离的 Docker 中于 Ubuntu 22.04 测试'), +('en','protocols','test_result','Test result'), +('ru','protocols','test_result','Результат теста'), +('es','protocols','test_result','Resultado de la prueba'), +('de','protocols','test_result','Testergebnis'), +('fr','protocols','test_result','Résultat du test'), +('zh','protocols','test_result','测试结果'), +('en','protocols','client_output_preview','Client output preview'), +('ru','protocols','client_output_preview','Предпросмотр ответа клиенту'), +('es','protocols','client_output_preview','Vista previa de salida del cliente'), +('de','protocols','client_output_preview','Client‑Ausgabevorschau'), +('fr','protocols','client_output_preview','Aperçu de la sortie client'), +('zh','protocols','client_output_preview','客户端输出预览'), +('en','protocols','test_failed','Test failed'), +('ru','protocols','test_failed','Ошибка теста'), +('es','protocols','test_failed','La prueba falló'), +('de','protocols','test_failed','Test fehlgeschlagen'), +('fr','protocols','test_failed','Échec du test'), +('zh','protocols','test_failed','测试失败') +ON DUPLICATE KEY UPDATE translation = VALUES(translation); + +INSERT INTO protocols (name, slug, description, install_script, output_template, ubuntu_compatible, is_active, created_at, updated_at) +SELECT 'SMB Server', 'smb', 'Samba SMB file share inside Docker with random host port', '#!/bin/bash\n\nset -euo pipefail\nset -x\n\nCONTAINER_NAME="${CONTAINER_NAME:-amnezia-smb}"\nPORT_RANGE_START=${PORT_RANGE_START:-30000}\nPORT_RANGE_END=${PORT_RANGE_END:-65000}\nSMB_PORT=$((RANDOM % (PORT_RANGE_END - PORT_RANGE_START + 1) + PORT_RANGE_START))\n\n docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true\nmkdir -p /opt/amnezia/smb/share\n docker run -d \\\n --name "$CONTAINER_NAME" \\\n -p "${SMB_PORT}:445" \\\n -v /opt/amnezia/smb/share:/share \\\n dperson/samba -p -u "amnezia;amnezia" -s "share;/share;yes;no;no;amnezia"\n echo "Port: ${SMB_PORT}"\n echo "Password: amnezia"\n', 'smb://{{server_host}}:{{server_port}}/share\nUsername: amnezia\nPassword: {{password}}', true, true, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM protocols WHERE slug='smb'); + +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT id, 'server_host', 'string', '127.0.0.1', 'Server hostname or IP', true FROM protocols WHERE slug = 'smb' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id = (SELECT id FROM protocols WHERE slug='smb') AND variable_name='server_host'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT id, 'server_port', 'number', '445', 'Server port', true FROM protocols WHERE slug = 'smb' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id = (SELECT id FROM protocols WHERE slug='smb') AND variable_name='server_port'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT id, 'password', 'string', '', 'Connection password', true FROM protocols WHERE slug = 'smb' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id = (SELECT id FROM protocols WHERE slug='smb') AND variable_name='password'); + +INSERT INTO protocols (name, slug, description, install_script, output_template, ubuntu_compatible, is_active, created_at, updated_at) +SELECT 'XRay VLESS', 'xray-vless', 'XRay VLESS server inside Docker with generated client UUID', '#!/bin/bash\n\nset -euo pipefail\nset -x\n\nCONTAINER_NAME="${CONTAINER_NAME:-amnezia-xray}"\nPORT_RANGE_START=${PORT_RANGE_START:-30000}\nPORT_RANGE_END=${PORT_RANGE_END:-65000}\nXRAY_PORT=$((RANDOM % (PORT_RANGE_END - PORT_RANGE_START + 1) + PORT_RANGE_START))\nCLIENT_ID=$(cat /proc/sys/kernel/random/uuid)\n docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true\nmkdir -p /opt/amnezia/xray\n cat > /opt/amnezia/xray/config.json << EOF\n{\n "inbounds": [\n {\n "listen": "0.0.0.0",\n "port": 443,\n "protocol": "vless",\n "settings": {\n "clients": [\n { "id": "${CLIENT_ID}" }\n ],\n "decryption": "none"\n },\n "streamSettings": {\n "network": "tcp",\n "security": "none"\n }\n }\n ],\n "outbounds": [\n { "protocol": "freedom" }\n ]\n}\nEOF\n docker run -d \\\n --name "$CONTAINER_NAME" \\\n --restart always \\\n -p "${XRAY_PORT}:443" \\\n -v /opt/amnezia/xray:/etc/xray \\\n teddysun/xray\n echo "Port: ${XRAY_PORT}"\n echo "ClientID: ${CLIENT_ID}"\n', 'vless://{{client_id}}@{{server_host}}:{{server_port}}?security=none&type=tcp', true, true, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM protocols WHERE slug='xray-vless'); + +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT id, 'server_host', 'string', '127.0.0.1', 'Server hostname or IP', true FROM protocols WHERE slug = 'xray-vless' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id = (SELECT id FROM protocols WHERE slug='xray-vless') AND variable_name='server_host'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT id, 'server_port', 'number', '443', 'Server port', true FROM protocols WHERE slug = 'xray-vless' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id = (SELECT id FROM protocols WHERE slug='xray-vless') AND variable_name='server_port'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT id, 'client_id', 'string', '', 'VLESS client ID (UUID)', true FROM protocols WHERE slug = 'xray-vless' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id = (SELECT id FROM protocols WHERE slug='xray-vless') AND variable_name='client_id'); + +DELIMITER $$ +CREATE PROCEDURE add_protocol_column_and_constraints() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='vpn_clients' AND COLUMN_NAME='protocol_id') = 0 THEN + ALTER TABLE vpn_clients ADD COLUMN protocol_id INT UNSIGNED NULL AFTER user_id; + END IF; + + IF (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='vpn_clients' AND INDEX_NAME='idx_protocol_id') = 0 THEN + ALTER TABLE vpn_clients ADD INDEX idx_protocol_id (protocol_id); + END IF; + + IF (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='vpn_clients' AND CONSTRAINT_NAME='fk_vpn_clients_protocol') = 0 THEN + ALTER TABLE vpn_clients ADD CONSTRAINT fk_vpn_clients_protocol FOREIGN KEY (protocol_id) REFERENCES protocols(id) ON DELETE SET NULL; + END IF; +END$$ +DELIMITER ; +CALL add_protocol_column_and_constraints(); +DROP PROCEDURE add_protocol_column_and_constraints; + +DELIMITER $$ +CREATE PROCEDURE ensure_users_role_column_and_index() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='role') = 0 THEN + ALTER TABLE users ADD COLUMN role ENUM('admin','user') DEFAULT 'user' AFTER name; + END IF; + IF (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='users' AND INDEX_NAME='idx_role') = 0 THEN + ALTER TABLE users ADD INDEX idx_role (role); + END IF; +END$$ +DELIMITER ; +CALL ensure_users_role_column_and_index(); +DROP PROCEDURE ensure_users_role_column_and_index; + +DELIMITER $$ +CREATE PROCEDURE add_protocols_optional_columns() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='protocols' AND COLUMN_NAME='uninstall_script') = 0 THEN + ALTER TABLE protocols ADD COLUMN uninstall_script MEDIUMTEXT NULL AFTER install_script; + END IF; + IF (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='protocols' AND COLUMN_NAME='password_command') = 0 THEN + ALTER TABLE protocols ADD COLUMN password_command TEXT NULL AFTER uninstall_script; + END IF; +END$$ +DELIMITER ; +CALL add_protocols_optional_columns(); +DROP PROCEDURE add_protocols_optional_columns; + +DELIMITER $$ +CREATE PROCEDURE ensure_users_display_name_column() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='display_name') = 0 THEN + ALTER TABLE users ADD COLUMN display_name VARCHAR(255) NULL AFTER name; + END IF; +END$$ +DELIMITER ; +CALL ensure_users_display_name_column(); +DROP PROCEDURE ensure_users_display_name_column; +UPDATE users SET display_name = name WHERE (display_name IS NULL OR display_name = '') AND name IS NOT NULL; + +UPDATE protocols SET + install_script = '#!/bin/bash +set -euo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-amnezia-awg}" +PORT_RANGE_START=${PORT_RANGE_START:-30000} +PORT_RANGE_END=${PORT_RANGE_END:-65000} +VPN_PORT=${VPN_PORT:-$((RANDOM % (PORT_RANGE_END - PORT_RANGE_START + 1) + PORT_RANGE_START))} +MTU=${MTU:-1420} + +EXISTING=$(docker ps -aq -f "name=$CONTAINER_NAME" 2>/dev/null | head -1) +if [ -z "$EXISTING" ]; then + docker run -d --name "$CONTAINER_NAME" --restart always --privileged --cap-add=NET_ADMIN --cap-add=SYS_MODULE -p "${VPN_PORT}:${VPN_PORT}/udp" -v /lib/modules:/lib/modules amneziavpn/amnezia-wg:latest + sleep 2 +else + STATUS=$(docker inspect --format="{{.State.Status}}" "$CONTAINER_NAME" 2>/dev/null || echo "") + if [ "$STATUS" != "running" ]; then + docker start "$CONTAINER_NAME" >/dev/null 2>&1 || true + fi +fi + +docker exec -i "$CONTAINER_NAME" sh -lc "mkdir -p /opt/amnezia/awg" + +HAS_CONF=$(docker exec "$CONTAINER_NAME" sh -lc "[ -f /opt/amnezia/awg/wg0.conf ] && echo yes || echo no") +if [ "$HAS_CONF" = "yes" ]; then + PORT=$(docker exec "$CONTAINER_NAME" sh -lc "grep -E \"^ListenPort\" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d \"[:space:]\"") + PSK=$(docker exec "$CONTAINER_NAME" cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true) + if [ -z "$PSK" ]; then + PSK=$(docker exec "$CONTAINER_NAME" sh -lc "grep -E \"^PresharedKey\" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d \"[:space:]\"") + fi + PUBKEY=$(docker exec "$CONTAINER_NAME" cat /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true) + if [ -z "$PUBKEY" ]; then + PRIVKEY=$(docker exec "$CONTAINER_NAME" cat /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true) + if [ -n "$PRIVKEY" ]; then + PUBKEY=$(echo "$PRIVKEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) + fi + fi + echo "Using existing AmneziaWG configuration" + echo "Port: ${PORT:-$VPN_PORT}" + if [ -n "${PUBKEY:-}" ]; then echo "Server Public Key: $PUBKEY"; fi + if [ -n "${PSK:-}" ]; then echo "PresharedKey = $PSK"; fi + exit 0 +fi + +PRIVATE_KEY=$(docker exec "$CONTAINER_NAME" wg genkey) +PUBLIC_KEY=$(echo "$PRIVATE_KEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) +PRESHARED_KEY=$(docker exec "$CONTAINER_NAME" wg genpsk) + +cat > /opt/amnezia/awg/wg0.conf << EOF +[Interface] +PrivateKey = $PRIVATE_KEY +Address = 10.8.1.1/24 +ListenPort = $VPN_PORT +MTU = $MTU +Jc = 5 +Jmin = 100 +Jmax = 200 +S1 = 50 +S2 = 100 +S3 = 20 +S4 = 10 +H1 = 0xDEADBEEF +H2 = 0xCAFEBABE +H3 = 0x12345678 +H4 = 0x9ABCDEF0 +PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE +PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE + +[Peer] +PublicKey = +PresharedKey = $PRESHARED_KEY +AllowedIPs = 10.8.1.2/32 +EOF + +docker exec "$CONTAINER_NAME" sh -lc "echo $PRIVATE_KEY > /opt/amnezia/awg/wireguard_server_private_key.key" +docker exec "$CONTAINER_NAME" sh -lc "echo $PUBLIC_KEY > /opt/amnezia/awg/wireguard_server_public_key.key" +docker exec "$CONTAINER_NAME" sh -lc "echo $PRESHARED_KEY > /opt/amnezia/awg/wireguard_psk.key" +docker exec "$CONTAINER_NAME" sh -lc "echo [] > /opt/amnezia/awg/clientsTable" + +docker exec "$CONTAINER_NAME" wg-quick up /opt/amnezia/awg/wg0.conf || true + +echo "AmneziaWG Advanced installed successfully" +echo "Port: $VPN_PORT" +echo "Server Public Key: $PUBLIC_KEY" +echo "PresharedKey = $PRESHARED_KEY' +WHERE slug = 'amnezia-wg-advanced'; + +UPDATE protocols SET + uninstall_script = '#!/bin/bash +set -euo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-amnezia-awg}" + +docker stop "$CONTAINER_NAME" 2>/dev/null || true +docker rm -fv "$CONTAINER_NAME" 2>/dev/null || true +docker image rm amneziavpn/amnezia-wg:latest 2>/dev/null || true +docker network rm amnezia-dns-net 2>/dev/null || true +rm -rf /opt/amnezia/amnezia-awg 2>/dev/null || true +rm -rf /opt/amnezia/awg 2>/dev/null || true + +echo "{\"success\":true,\"message\":\"AmneziaWG uninstalled\"}"' +WHERE slug = 'amnezia-wg-advanced'; + +DELIMITER $$ +CREATE PROCEDURE ensure_vpn_servers_install_columns() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='vpn_servers' AND COLUMN_NAME='install_protocol') = 0 THEN + ALTER TABLE vpn_servers ADD COLUMN install_protocol VARCHAR(100) NULL AFTER container_name; + END IF; + IF (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='vpn_servers' AND COLUMN_NAME='install_options') = 0 THEN + ALTER TABLE vpn_servers ADD COLUMN install_options JSON NULL AFTER install_protocol; + END IF; +END$$ +DELIMITER ; +CALL ensure_vpn_servers_install_columns(); +DROP PROCEDURE ensure_vpn_servers_install_columns; \ No newline at end of file diff --git a/migrations/015_fix_awg_script.sql b/migrations/015_fix_awg_script.sql new file mode 100644 index 0000000..5c40882 --- /dev/null +++ b/migrations/015_fix_awg_script.sql @@ -0,0 +1,108 @@ +UPDATE protocols SET + install_script = '#!/bin/bash +set -euo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-amnezia-awg}" +PORT_RANGE_START=${PORT_RANGE_START:-30000} +PORT_RANGE_END=${PORT_RANGE_END:-65000} +VPN_PORT=${VPN_PORT:-$((RANDOM % (PORT_RANGE_END - PORT_RANGE_START + 1) + PORT_RANGE_START))} +MTU=${MTU:-1420} + +# Ensure host directory exists for persistence +mkdir -p /opt/amnezia/awg + +EXISTING=$(docker ps -aq -f "name=$CONTAINER_NAME" 2>/dev/null | head -1) +if [ -z "$EXISTING" ]; then + # Run container with volume mount and keepalive command + # Waits for config file to appear before starting WireGuard + docker run -d --name "$CONTAINER_NAME" \ + --restart always \ + --privileged \ + --cap-add=NET_ADMIN \ + --cap-add=SYS_MODULE \ + -p "${VPN_PORT}:${VPN_PORT}/udp" \ + -v /lib/modules:/lib/modules \ + -v /opt/amnezia/awg:/opt/amnezia/awg \ + amneziavpn/amnezia-wg:latest \ + sh -c "while [ ! -f /opt/amnezia/awg/wg0.conf ]; do sleep 1; done; wg-quick up /opt/amnezia/awg/wg0.conf && sleep infinity" + + sleep 2 +else + STATUS=$(docker inspect --format="{{.State.Status}}" "$CONTAINER_NAME" 2>/dev/null || echo "") + if [ "$STATUS" != "running" ]; then + docker start "$CONTAINER_NAME" >/dev/null 2>&1 || true + fi +fi + +# Check for existing config +if [ -f /opt/amnezia/awg/wg0.conf ]; then + # Extract existing configuration + PORT=$(grep -E "^ListenPort" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + PSK=$(cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true) + if [ -z "$PSK" ]; then + PSK=$(grep -E "^PresharedKey" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + fi + PUBKEY=$(cat /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true) + if [ -z "$PUBKEY" ]; then + PRIVKEY=$(cat /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true) + if [ -n "$PRIVKEY" ]; then + PUBKEY=$(echo "$PRIVKEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) + fi + fi + + echo "Using existing AmneziaWG configuration" + echo "Port: ${PORT:-$VPN_PORT}" + if [ -n "${PUBKEY:-}" ]; then echo "Server Public Key: $PUBKEY"; fi + if [ -n "${PSK:-}" ]; then echo "PresharedKey = $PSK"; fi + exit 0 +fi + +# Generate keys using the container +PRIVATE_KEY=$(docker exec "$CONTAINER_NAME" wg genkey) +PUBLIC_KEY=$(echo "$PRIVATE_KEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) +PRESHARED_KEY=$(docker exec "$CONTAINER_NAME" wg genpsk) + +# Write config to HOST file (mounted to container) +cat > /opt/amnezia/awg/wg0.conf << EOF +[Interface] +PrivateKey = $PRIVATE_KEY +Address = 10.8.1.1/24 +ListenPort = $VPN_PORT +MTU = $MTU +Jc = 5 +Jmin = 100 +Jmax = 200 +S1 = 50 +S2 = 100 +S3 = 20 +S4 = 10 +H1 = 0xDEADBEEF +H2 = 0xCAFEBABE +H3 = 0x12345678 +H4 = 0x9ABCDEF0 +PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE +PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE + +[Peer] +PublicKey = +PresharedKey = $PRESHARED_KEY +AllowedIPs = 10.8.1.2/32 +EOF + +# Save keys to files on host +echo "$PRIVATE_KEY" > /opt/amnezia/awg/wireguard_server_private_key.key +echo "$PUBLIC_KEY" > /opt/amnezia/awg/wireguard_server_public_key.key +echo "$PRESHARED_KEY" > /opt/amnezia/awg/wireguard_psk.key +echo "[]" > /opt/amnezia/awg/clientsTable + +# Container is already waiting for config (loop), so it should pick it up automatically. +# But we can also force it if needed, or just wait a moment. +# The loop is: while [ ! -f ... ]; do sleep 1; done; wg-quick up ... +# Since we just wrote the file, the loop will exit and run wg-quick up. + +echo "AmneziaWG Advanced installed successfully" +echo "Port: $VPN_PORT" +echo "Server Public Key: $PUBLIC_KEY" +echo "PresharedKey = $PRESHARED_KEY" +' +WHERE slug = 'amnezia-wg-advanced'; diff --git a/migrations/016_fix_awg_recovery.sql b/migrations/016_fix_awg_recovery.sql new file mode 100644 index 0000000..20960b0 --- /dev/null +++ b/migrations/016_fix_awg_recovery.sql @@ -0,0 +1,220 @@ +UPDATE protocols SET + install_script = '#!/bin/bash +set -euo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-amnezia-awg}" +PORT_RANGE_START=${PORT_RANGE_START:-30000} +PORT_RANGE_END=${PORT_RANGE_END:-65000} +VPN_PORT=${VPN_PORT:-$((RANDOM % (PORT_RANGE_END - PORT_RANGE_START + 1) + PORT_RANGE_START))} +MTU=${MTU:-1420} + +# Ensure host directory exists for persistence +mkdir -p /opt/amnezia/awg + +# Function to check if container is healthy +check_container() { + local status + status=$(docker inspect --format="{{.State.Status}}" "$CONTAINER_NAME" 2>/dev/null || echo "missing") + if [ "$status" = "running" ]; then + return 0 + elif [ "$status" = "restarting" ]; then + return 2 # Restarting loop + else + return 1 # Stopped or missing + fi +} + +# Check for existing configuration on HOST first (preferred persistence) +if [ -f /opt/amnezia/awg/wg0.conf ]; then + echo "Found existing configuration on host." + + # Ensure container is running correctly + check_container + STATUS=$? + + if [ $STATUS -eq 2 ]; then + echo "Container is in restart loop. Recreating..." + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + elif [ $STATUS -eq 1 ]; then + # If stopped but exists, remove to recreate with correct flags (just in case) + # Or just start it? Better to recreate to ensure volume mounts are correct. + # But if we recreate, we must ensure we mount the volume. + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + fi + + # If container is missing (or we just removed it), create it + if ! docker ps -q -f name="$CONTAINER_NAME" >/dev/null 2>&1; then + docker run -d --name "$CONTAINER_NAME" \ + --restart always \ + --privileged \ + --cap-add=NET_ADMIN \ + --cap-add=SYS_MODULE \ + -p "${VPN_PORT}:${VPN_PORT}/udp" \ + -v /lib/modules:/lib/modules \ + -v /opt/amnezia/awg:/opt/amnezia/awg \ + amneziavpn/amnezia-wg:latest \ + sh -c "while [ ! -f /opt/amnezia/awg/wg0.conf ]; do sleep 1; done; wg-quick up /opt/amnezia/awg/wg0.conf && sleep infinity" + + # Wait a moment for it to start + sleep 2 + fi + + # Extract config from HOST file + PORT=$(grep -E "^ListenPort" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + PSK=$(cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true) + if [ -z "$PSK" ]; then + PSK=$(grep -E "^PresharedKey" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + fi + PUBKEY=$(cat /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true) + if [ -z "$PUBKEY" ]; then + PRIVKEY=$(cat /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true) + if [ -n "$PRIVKEY" ]; then + PUBKEY=$(echo "$PRIVKEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) + fi + fi + + echo "Using existing AmneziaWG configuration" + echo "Port: ${PORT:-$VPN_PORT}" + if [ -n "${PUBKEY:-}" ]; then echo "Server Public Key: $PUBKEY"; fi + if [ -n "${PSK:-}" ]; then echo "PresharedKey = $PSK"; fi + exit 0 +fi + +# If no host config, check if container exists and try to rescue config +check_container +STATUS=$? + +if [ $STATUS -eq 2 ]; then + echo "Container is restarting and no host config found. Attempting to rescue config..." + # Try to copy from container even if restarting (might fail if container is crashing too fast) + if docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wg0.conf /opt/amnezia/awg/wg0.conf 2>/dev/null; then + echo "Rescued config from broken container." + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_psk.key /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_server_public_key.key /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_server_private_key.key /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true + + # Now recreate container with rescue config + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + # Re-run script to pick up host config (recursive call or just fall through? Fall through requires logic jump) + # Easier to just exit and let user run again? No, let''s proceed. + # We have config on host now, so the logic below for "new install" needs to be skipped or we need to jump back. + # Let''s just restart the script logic by execing self? No, complex. + # Let''s just set a flag. + HAS_RESCUED=1 + else + echo "Could not rescue config. Removing broken container." + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + HAS_RESCUED=0 + fi +elif [ $STATUS -eq 0 ]; then + # Running. Check if it has config inside but not on host (old version) + if docker exec "$CONTAINER_NAME" [ -f /opt/amnezia/awg/wg0.conf ]; then + echo "Container running with internal config. Migrating to host..." + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wg0.conf /opt/amnezia/awg/wg0.conf + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_psk.key /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_server_public_key.key /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_server_private_key.key /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true + + # Recreate to add volume mount + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + HAS_RESCUED=1 + else + # Running but no config? Weird. Treat as fresh. + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + HAS_RESCUED=0 + fi +else + HAS_RESCUED=0 +fi + +# If we rescued config, we need to start the container with mounts +if [ "$HAS_RESCUED" = "1" ]; then + docker run -d --name "$CONTAINER_NAME" \ + --restart always \ + --privileged \ + --cap-add=NET_ADMIN \ + --cap-add=SYS_MODULE \ + -p "${VPN_PORT}:${VPN_PORT}/udp" \ + -v /lib/modules:/lib/modules \ + -v /opt/amnezia/awg:/opt/amnezia/awg \ + amneziavpn/amnezia-wg:latest \ + sh -c "while [ ! -f /opt/amnezia/awg/wg0.conf ]; do sleep 1; done; wg-quick up /opt/amnezia/awg/wg0.conf && sleep infinity" + + sleep 2 + + # Extract and exit (same logic as top) + PORT=$(grep -E "^ListenPort" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + PSK=$(cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true) + if [ -z "$PSK" ]; then + PSK=$(grep -E "^PresharedKey" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + fi + PUBKEY=$(cat /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true) + if [ -z "$PUBKEY" ]; then + PRIVKEY=$(cat /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true) + if [ -n "$PRIVKEY" ]; then + PUBKEY=$(echo "$PRIVKEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) + fi + fi + + echo "Using existing AmneziaWG configuration" + echo "Port: ${PORT:-$VPN_PORT}" + if [ -n "${PUBKEY:-}" ]; then echo "Server Public Key: $PUBKEY"; fi + if [ -n "${PSK:-}" ]; then echo "PresharedKey = $PSK"; fi + exit 0 +fi + +# FRESH INSTALL +docker run -d --name "$CONTAINER_NAME" \ + --restart always \ + --privileged \ + --cap-add=NET_ADMIN \ + --cap-add=SYS_MODULE \ + -p "${VPN_PORT}:${VPN_PORT}/udp" \ + -v /lib/modules:/lib/modules \ + -v /opt/amnezia/awg:/opt/amnezia/awg \ + amneziavpn/amnezia-wg:latest \ + sh -c "while [ ! -f /opt/amnezia/awg/wg0.conf ]; do sleep 1; done; wg-quick up /opt/amnezia/awg/wg0.conf && sleep infinity" + +sleep 2 + +PRIVATE_KEY=$(docker exec "$CONTAINER_NAME" wg genkey) +PUBLIC_KEY=$(echo "$PRIVATE_KEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) +PRESHARED_KEY=$(docker exec "$CONTAINER_NAME" wg genpsk) + +cat > /opt/amnezia/awg/wg0.conf << EOF +[Interface] +PrivateKey = $PRIVATE_KEY +Address = 10.8.1.1/24 +ListenPort = $VPN_PORT +MTU = $MTU +Jc = 5 +Jmin = 100 +Jmax = 200 +S1 = 50 +S2 = 100 +S3 = 20 +S4 = 10 +H1 = 0xDEADBEEF +H2 = 0xCAFEBABE +H3 = 0x12345678 +H4 = 0x9ABCDEF0 +PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE +PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE + +[Peer] +PublicKey = +PresharedKey = $PRESHARED_KEY +AllowedIPs = 10.8.1.2/32 +EOF + +echo "$PRIVATE_KEY" > /opt/amnezia/awg/wireguard_server_private_key.key +echo "$PUBLIC_KEY" > /opt/amnezia/awg/wireguard_server_public_key.key +echo "$PRESHARED_KEY" > /opt/amnezia/awg/wireguard_psk.key +echo "[]" > /opt/amnezia/awg/clientsTable + +echo "AmneziaWG Advanced installed successfully" +echo "Port: $VPN_PORT" +echo "Server Public Key: $PUBLIC_KEY" +echo "PresharedKey = $PRESHARED_KEY" +' +WHERE slug = 'amnezia-wg-advanced'; diff --git a/migrations/017_fix_awg_script_exit_code.sql b/migrations/017_fix_awg_script_exit_code.sql new file mode 100644 index 0000000..bf2d919 --- /dev/null +++ b/migrations/017_fix_awg_script_exit_code.sql @@ -0,0 +1,213 @@ +UPDATE protocols SET + install_script = '#!/bin/bash +set -euo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-amnezia-awg}" +PORT_RANGE_START=${PORT_RANGE_START:-30000} +PORT_RANGE_END=${PORT_RANGE_END:-65000} +VPN_PORT=${VPN_PORT:-$((RANDOM % (PORT_RANGE_END - PORT_RANGE_START + 1) + PORT_RANGE_START))} +MTU=${MTU:-1420} + +# Ensure host directory exists for persistence +mkdir -p /opt/amnezia/awg + +# Function to check if container is healthy +check_container() { + local status + status=$(docker inspect --format="{{.State.Status}}" "$CONTAINER_NAME" 2>/dev/null || echo "missing") + if [ "$status" = "running" ]; then + return 0 + elif [ "$status" = "restarting" ]; then + return 2 # Restarting loop + else + return 1 # Stopped or missing + fi +} + +# Check for existing configuration on HOST first (preferred persistence) +if [ -f /opt/amnezia/awg/wg0.conf ]; then + echo "Found existing configuration on host." + + # Ensure container is running correctly + STATUS=0 + check_container || STATUS=$? + + if [ $STATUS -eq 2 ]; then + echo "Container is in restart loop. Recreating..." + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + elif [ $STATUS -eq 1 ]; then + # If stopped but exists, remove to recreate with correct flags + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + fi + + # If container is missing (or we just removed it), create it + if ! docker ps -q -f name="$CONTAINER_NAME" >/dev/null 2>&1; then + docker run -d --name "$CONTAINER_NAME" \ + --restart always \ + --privileged \ + --cap-add=NET_ADMIN \ + --cap-add=SYS_MODULE \ + -p "${VPN_PORT}:${VPN_PORT}/udp" \ + -v /lib/modules:/lib/modules \ + -v /opt/amnezia/awg:/opt/amnezia/awg \ + amneziavpn/amnezia-wg:latest \ + sh -c "while [ ! -f /opt/amnezia/awg/wg0.conf ]; do sleep 1; done; wg-quick up /opt/amnezia/awg/wg0.conf && sleep infinity" + + # Wait a moment for it to start + sleep 2 + fi + + # Extract config from HOST file + PORT=$(grep -E "^ListenPort" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + PSK=$(cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true) + if [ -z "$PSK" ]; then + PSK=$(grep -E "^PresharedKey" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + fi + PUBKEY=$(cat /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true) + if [ -z "$PUBKEY" ]; then + PRIVKEY=$(cat /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true) + if [ -n "$PRIVKEY" ]; then + PUBKEY=$(echo "$PRIVKEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) + fi + fi + + echo "Using existing AmneziaWG configuration" + echo "Port: ${PORT:-$VPN_PORT}" + if [ -n "${PUBKEY:-}" ]; then echo "Server Public Key: $PUBKEY"; fi + if [ -n "${PSK:-}" ]; then echo "PresharedKey = $PSK"; fi + exit 0 +fi + +# If no host config, check if container exists and try to rescue config +STATUS=0 +check_container || STATUS=$? + +if [ $STATUS -eq 2 ]; then + echo "Container is restarting and no host config found. Attempting to rescue config..." + # Try to copy from container even if restarting (might fail if container is crashing too fast) + if docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wg0.conf /opt/amnezia/awg/wg0.conf 2>/dev/null; then + echo "Rescued config from broken container." + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_psk.key /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_server_public_key.key /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_server_private_key.key /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true + + # Now recreate container with rescue config + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + HAS_RESCUED=1 + else + echo "Could not rescue config. Removing broken container." + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + HAS_RESCUED=0 + fi +elif [ $STATUS -eq 0 ]; then + # Running. Check if it has config inside but not on host (old version) + if docker exec "$CONTAINER_NAME" [ -f /opt/amnezia/awg/wg0.conf ]; then + echo "Container running with internal config. Migrating to host..." + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wg0.conf /opt/amnezia/awg/wg0.conf + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_psk.key /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_server_public_key.key /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_server_private_key.key /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true + + # Recreate to add volume mount + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + HAS_RESCUED=1 + else + # Running but no config? Weird. Treat as fresh. + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + HAS_RESCUED=0 + fi +else + HAS_RESCUED=0 +fi + +# If we rescued config, we need to start the container with mounts +if [ "$HAS_RESCUED" = "1" ]; then + docker run -d --name "$CONTAINER_NAME" \ + --restart always \ + --privileged \ + --cap-add=NET_ADMIN \ + --cap-add=SYS_MODULE \ + -p "${VPN_PORT}:${VPN_PORT}/udp" \ + -v /lib/modules:/lib/modules \ + -v /opt/amnezia/awg:/opt/amnezia/awg \ + amneziavpn/amnezia-wg:latest \ + sh -c "while [ ! -f /opt/amnezia/awg/wg0.conf ]; do sleep 1; done; wg-quick up /opt/amnezia/awg/wg0.conf && sleep infinity" + + sleep 2 + + # Extract and exit (same logic as top) + PORT=$(grep -E "^ListenPort" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + PSK=$(cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true) + if [ -z "$PSK" ]; then + PSK=$(grep -E "^PresharedKey" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + fi + PUBKEY=$(cat /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true) + if [ -z "$PUBKEY" ]; then + PRIVKEY=$(cat /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true) + if [ -n "$PRIVKEY" ]; then + PUBKEY=$(echo "$PRIVKEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) + fi + fi + + echo "Using existing AmneziaWG configuration" + echo "Port: ${PORT:-$VPN_PORT}" + if [ -n "${PUBKEY:-}" ]; then echo "Server Public Key: $PUBKEY"; fi + if [ -n "${PSK:-}" ]; then echo "PresharedKey = $PSK"; fi + exit 0 +fi + +# FRESH INSTALL +docker run -d --name "$CONTAINER_NAME" \ + --restart always \ + --privileged \ + --cap-add=NET_ADMIN \ + --cap-add=SYS_MODULE \ + -p "${VPN_PORT}:${VPN_PORT}/udp" \ + -v /lib/modules:/lib/modules \ + -v /opt/amnezia/awg:/opt/amnezia/awg \ + amneziavpn/amnezia-wg:latest \ + sh -c "while [ ! -f /opt/amnezia/awg/wg0.conf ]; do sleep 1; done; wg-quick up /opt/amnezia/awg/wg0.conf && sleep infinity" + +sleep 2 + +PRIVATE_KEY=$(docker exec "$CONTAINER_NAME" wg genkey) +PUBLIC_KEY=$(echo "$PRIVATE_KEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) +PRESHARED_KEY=$(docker exec "$CONTAINER_NAME" wg genpsk) + +cat > /opt/amnezia/awg/wg0.conf << EOF +[Interface] +PrivateKey = $PRIVATE_KEY +Address = 10.8.1.1/24 +ListenPort = $VPN_PORT +MTU = $MTU +Jc = 5 +Jmin = 100 +Jmax = 200 +S1 = 50 +S2 = 100 +S3 = 20 +S4 = 10 +H1 = 0xDEADBEEF +H2 = 0xCAFEBABE +H3 = 0x12345678 +H4 = 0x9ABCDEF0 +PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE +PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE + +[Peer] +PublicKey = +PresharedKey = $PRESHARED_KEY +AllowedIPs = 10.8.1.2/32 +EOF + +echo "$PRIVATE_KEY" > /opt/amnezia/awg/wireguard_server_private_key.key +echo "$PUBLIC_KEY" > /opt/amnezia/awg/wireguard_server_public_key.key +echo "$PRESHARED_KEY" > /opt/amnezia/awg/wireguard_psk.key +echo "[]" > /opt/amnezia/awg/clientsTable + +echo "AmneziaWG Advanced installed successfully" +echo "Port: $VPN_PORT" +echo "Server Public Key: $PUBLIC_KEY" +echo "PresharedKey = $PRESHARED_KEY" +' +WHERE slug = 'amnezia-wg-advanced'; diff --git a/migrations/018_fix_awg_final.sql b/migrations/018_fix_awg_final.sql new file mode 100644 index 0000000..4772801 --- /dev/null +++ b/migrations/018_fix_awg_final.sql @@ -0,0 +1,156 @@ +UPDATE protocols SET + install_script = '#!/bin/bash +set -euo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-amnezia-awg}" +PORT_RANGE_START=${PORT_RANGE_START:-30000} +PORT_RANGE_END=${PORT_RANGE_END:-65000} +VPN_PORT=${VPN_PORT:-$((RANDOM % (PORT_RANGE_END - PORT_RANGE_START + 1) + PORT_RANGE_START))} +MTU=${MTU:-1420} + +# Ensure host directory exists for persistence +mkdir -p /opt/amnezia/awg + +# Function to check if container is healthy +check_container() { + local status + status=$(docker inspect --format="{{.State.Status}}" "$CONTAINER_NAME" 2>/dev/null || echo "missing") + if [ "$status" = "running" ]; then + return 0 + elif [ "$status" = "restarting" ]; then + return 2 # Restarting loop + else + return 1 # Stopped or missing + fi +} + +# Check for existing configuration on HOST first (preferred persistence) +if [ -f /opt/amnezia/awg/wg0.conf ]; then + echo "Found existing configuration on host." + + STATUS=0 + check_container || STATUS=$? + + if [ $STATUS -eq 2 ]; then + echo "Container is in restart loop. Recreating..." + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + elif [ $STATUS -eq 1 ]; then + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + fi + + if ! docker ps -q -f name="$CONTAINER_NAME" >/dev/null 2>&1; then + # Run container with volume mount - SINGLE LINE to avoid syntax issues + docker run -d --name "$CONTAINER_NAME" --restart always --privileged --cap-add=NET_ADMIN --cap-add=SYS_MODULE -p "${VPN_PORT}:${VPN_PORT}/udp" -v /lib/modules:/lib/modules -v /opt/amnezia/awg:/opt/amnezia/awg amneziavpn/amnezia-wg:latest sh -c "while [ ! -f /opt/amnezia/awg/wg0.conf ]; do sleep 1; done; wg-quick up /opt/amnezia/awg/wg0.conf && sleep infinity" + sleep 2 + fi + + PORT=$(grep -E "^ListenPort" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + PSK=$(cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true) + if [ -z "$PSK" ]; then + PSK=$(grep -E "^PresharedKey" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + fi + PUBKEY=$(cat /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true) + if [ -z "$PUBKEY" ]; then + PRIVKEY=$(cat /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true) + if [ -n "$PRIVKEY" ]; then + PUBKEY=$(echo "$PRIVKEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) + fi + fi + + echo "Using existing AmneziaWG configuration" + echo "Port: ${PORT:-$VPN_PORT}" + if [ -n "${PUBKEY:-}" ]; then echo "Server Public Key: $PUBKEY"; fi + if [ -n "${PSK:-}" ]; then echo "PresharedKey = $PSK"; fi + exit 0 +fi + +# Rescue logic +STATUS=0 +check_container || STATUS=$? +HAS_RESCUED=0 + +if [ $STATUS -eq 2 ] || [ $STATUS -eq 0 ]; then + echo "Checking for config in existing container..." + # Stop container to ensure stable copy + docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true + + if docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wg0.conf /opt/amnezia/awg/wg0.conf 2>/dev/null; then + echo "Rescued config from container." + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_psk.key /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_server_public_key.key /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_server_private_key.key /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true + HAS_RESCUED=1 + fi + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true +fi + +# Start container (Fresh or Rescued) +# SINGLE LINE command +docker run -d --name "$CONTAINER_NAME" --restart always --privileged --cap-add=NET_ADMIN --cap-add=SYS_MODULE -p "${VPN_PORT}:${VPN_PORT}/udp" -v /lib/modules:/lib/modules -v /opt/amnezia/awg:/opt/amnezia/awg amneziavpn/amnezia-wg:latest sh -c "while [ ! -f /opt/amnezia/awg/wg0.conf ]; do sleep 1; done; wg-quick up /opt/amnezia/awg/wg0.conf && sleep infinity" + +sleep 2 + +if [ "$HAS_RESCUED" = "1" ]; then + # Extract and exit + PORT=$(grep -E "^ListenPort" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + PSK=$(cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true) + if [ -z "$PSK" ]; then + PSK=$(grep -E "^PresharedKey" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + fi + PUBKEY=$(cat /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true) + if [ -z "$PUBKEY" ]; then + PRIVKEY=$(cat /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true) + if [ -n "$PRIVKEY" ]; then + PUBKEY=$(echo "$PRIVKEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) + fi + fi + + echo "Using existing AmneziaWG configuration" + echo "Port: ${PORT:-$VPN_PORT}" + if [ -n "${PUBKEY:-}" ]; then echo "Server Public Key: $PUBKEY"; fi + if [ -n "${PSK:-}" ]; then echo "PresharedKey = $PSK"; fi + exit 0 +fi + +# Generate new config +PRIVATE_KEY=$(docker exec "$CONTAINER_NAME" wg genkey) +PUBLIC_KEY=$(echo "$PRIVATE_KEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) +PRESHARED_KEY=$(docker exec "$CONTAINER_NAME" wg genpsk) + +cat > /opt/amnezia/awg/wg0.conf << EOF +[Interface] +PrivateKey = $PRIVATE_KEY +Address = 10.8.1.1/24 +ListenPort = $VPN_PORT +MTU = $MTU +Jc = 5 +Jmin = 100 +Jmax = 200 +S1 = 50 +S2 = 100 +S3 = 20 +S4 = 10 +H1 = 0xDEADBEEF +H2 = 0xCAFEBABE +H3 = 0x12345678 +H4 = 0x9ABCDEF0 +PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE +PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE + +[Peer] +PublicKey = +PresharedKey = $PRESHARED_KEY +AllowedIPs = 10.8.1.2/32 +EOF + +echo "$PRIVATE_KEY" > /opt/amnezia/awg/wireguard_server_private_key.key +echo "$PUBLIC_KEY" > /opt/amnezia/awg/wireguard_server_public_key.key +echo "$PRESHARED_KEY" > /opt/amnezia/awg/wireguard_psk.key +echo "[]" > /opt/amnezia/awg/clientsTable + +echo "AmneziaWG Advanced installed successfully" +echo "Port: $VPN_PORT" +echo "Server Public Key: $PUBLIC_KEY" +echo "PresharedKey = $PRESHARED_KEY" +' +WHERE slug = 'amnezia-wg-advanced'; diff --git a/migrations/019_fix_awg_heredoc.sql b/migrations/019_fix_awg_heredoc.sql new file mode 100644 index 0000000..7649c44 --- /dev/null +++ b/migrations/019_fix_awg_heredoc.sql @@ -0,0 +1,172 @@ +UPDATE protocols SET + install_script = '#!/bin/bash +set -euo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-amnezia-awg}" +PORT_RANGE_START=${PORT_RANGE_START:-30000} +PORT_RANGE_END=${PORT_RANGE_END:-65000} +VPN_PORT=${VPN_PORT:-$((RANDOM % (PORT_RANGE_END - PORT_RANGE_START + 1) + PORT_RANGE_START))} +MTU=${MTU:-1420} + +# Ensure host directory exists for persistence +mkdir -p /opt/amnezia/awg + +# Function to check if container is healthy +check_container() { + local status + status=$(docker inspect --format="{{.State.Status}}" "$CONTAINER_NAME" 2>/dev/null || echo "missing") + if [ "$status" = "running" ]; then + return 0 + elif [ "$status" = "restarting" ]; then + return 2 # Restarting loop + else + return 1 # Stopped or missing + fi +} + +# Validate existing config +if [ -f /opt/amnezia/awg/wg0.conf ]; then + if grep -Fq "\$PRIVATE_KEY" /opt/amnezia/awg/wg0.conf; then + echo "Detected broken configuration (unexpanded variables). Removing..." + rm -f /opt/amnezia/awg/wg0.conf + rm -f /opt/amnezia/awg/wireguard_psk.key + rm -f /opt/amnezia/awg/wireguard_server_public_key.key + rm -f /opt/amnezia/awg/wireguard_server_private_key.key + fi +fi + +# Check for existing configuration on HOST first (preferred persistence) +if [ -f /opt/amnezia/awg/wg0.conf ]; then + echo "Found existing configuration on host." + + STATUS=0 + check_container || STATUS=$? + + if [ $STATUS -eq 2 ]; then + echo "Container is in restart loop. Recreating..." + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + elif [ $STATUS -eq 1 ]; then + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + fi + + if ! docker ps -q -f name="$CONTAINER_NAME" >/dev/null 2>&1; then + # Run container with volume mount - SINGLE LINE + docker run -d --name "$CONTAINER_NAME" --restart always --privileged --cap-add=NET_ADMIN --cap-add=SYS_MODULE -p "${VPN_PORT}:${VPN_PORT}/udp" -v /lib/modules:/lib/modules -v /opt/amnezia/awg:/opt/amnezia/awg amneziavpn/amnezia-wg:latest sh -c "while [ ! -f /opt/amnezia/awg/wg0.conf ]; do sleep 1; done; wg-quick up /opt/amnezia/awg/wg0.conf && sleep infinity" + sleep 2 + fi + + PORT=$(grep -E "^ListenPort" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + PSK=$(cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true) + if [ -z "$PSK" ]; then + PSK=$(grep -E "^PresharedKey" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + fi + PUBKEY=$(cat /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true) + if [ -z "$PUBKEY" ]; then + PRIVKEY=$(cat /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true) + if [ -n "$PRIVKEY" ]; then + PUBKEY=$(echo "$PRIVKEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) + fi + fi + + echo "Using existing AmneziaWG configuration" + echo "Port: ${PORT:-$VPN_PORT}" + if [ -n "${PUBKEY:-}" ]; then echo "Server Public Key: $PUBKEY"; fi + if [ -n "${PSK:-}" ]; then echo "PresharedKey = $PSK"; fi + exit 0 +fi + +# Rescue logic +STATUS=0 +check_container || STATUS=$? +HAS_RESCUED=0 + +if [ $STATUS -eq 2 ] || [ $STATUS -eq 0 ]; then + echo "Checking for config in existing container..." + docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true + + if docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wg0.conf /opt/amnezia/awg/wg0.conf 2>/dev/null; then + # Validate rescued config + if grep -Fq "\$PRIVATE_KEY" /opt/amnezia/awg/wg0.conf; then + echo "Rescued config is broken. Discarding." + rm -f /opt/amnezia/awg/wg0.conf + else + echo "Rescued config from container." + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_psk.key /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_server_public_key.key /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_server_private_key.key /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true + HAS_RESCUED=1 + fi + fi + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true +fi + +# Start container (Fresh or Rescued) +docker run -d --name "$CONTAINER_NAME" --restart always --privileged --cap-add=NET_ADMIN --cap-add=SYS_MODULE -p "${VPN_PORT}:${VPN_PORT}/udp" -v /lib/modules:/lib/modules -v /opt/amnezia/awg:/opt/amnezia/awg amneziavpn/amnezia-wg:latest sh -c "while [ ! -f /opt/amnezia/awg/wg0.conf ]; do sleep 1; done; wg-quick up /opt/amnezia/awg/wg0.conf && sleep infinity" + +sleep 2 + +if [ "$HAS_RESCUED" = "1" ]; then + # Extract and exit + PORT=$(grep -E "^ListenPort" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + PSK=$(cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true) + if [ -z "$PSK" ]; then + PSK=$(grep -E "^PresharedKey" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + fi + PUBKEY=$(cat /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true) + if [ -z "$PUBKEY" ]; then + PRIVKEY=$(cat /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true) + if [ -n "$PRIVKEY" ]; then + PUBKEY=$(echo "$PRIVKEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) + fi + fi + + echo "Using existing AmneziaWG configuration" + echo "Port: ${PORT:-$VPN_PORT}" + if [ -n "${PUBKEY:-}" ]; then echo "Server Public Key: $PUBKEY"; fi + if [ -n "${PSK:-}" ]; then echo "PresharedKey = $PSK"; fi + exit 0 +fi + +# Generate new config +PRIVATE_KEY=$(docker exec "$CONTAINER_NAME" wg genkey) +PUBLIC_KEY=$(echo "$PRIVATE_KEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) +PRESHARED_KEY=$(docker exec "$CONTAINER_NAME" wg genpsk) + +# Use WG_CONF delimiter to avoid EOF replacement in PHP +cat > /opt/amnezia/awg/wg0.conf << WG_CONF +[Interface] +PrivateKey = $PRIVATE_KEY +Address = 10.8.1.1/24 +ListenPort = $VPN_PORT +MTU = $MTU +Jc = 5 +Jmin = 100 +Jmax = 200 +S1 = 50 +S2 = 100 +S3 = 20 +S4 = 10 +H1 = 0xDEADBEEF +H2 = 0xCAFEBABE +H3 = 0x12345678 +H4 = 0x9ABCDEF0 +PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE +PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE + +[Peer] +PublicKey = +PresharedKey = $PRESHARED_KEY +AllowedIPs = 10.8.1.2/32 +WG_CONF + +echo "$PRIVATE_KEY" > /opt/amnezia/awg/wireguard_server_private_key.key +echo "$PUBLIC_KEY" > /opt/amnezia/awg/wireguard_server_public_key.key +echo "$PRESHARED_KEY" > /opt/amnezia/awg/wireguard_psk.key +echo "[]" > /opt/amnezia/awg/clientsTable + +echo "AmneziaWG Advanced installed successfully" +echo "Port: $VPN_PORT" +echo "Server Public Key: $PUBLIC_KEY" +echo "PresharedKey = $PRESHARED_KEY" +' +WHERE slug = 'amnezia-wg-advanced'; diff --git a/migrations/020_fix_awg_params.sql b/migrations/020_fix_awg_params.sql new file mode 100644 index 0000000..77c8f50 --- /dev/null +++ b/migrations/020_fix_awg_params.sql @@ -0,0 +1,177 @@ +UPDATE protocols SET + install_script = '#!/bin/bash +set -euo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-amnezia-awg}" +PORT_RANGE_START=${PORT_RANGE_START:-30000} +PORT_RANGE_END=${PORT_RANGE_END:-65000} +VPN_PORT=${VPN_PORT:-$((RANDOM % (PORT_RANGE_END - PORT_RANGE_START + 1) + PORT_RANGE_START))} +MTU=${MTU:-1420} + +# Ensure host directory exists for persistence +mkdir -p /opt/amnezia/awg + +# Function to check if container is healthy +check_container() { + local status + status=$(docker inspect --format="{{.State.Status}}" "$CONTAINER_NAME" 2>/dev/null || echo "missing") + if [ "$status" = "running" ]; then + return 0 + elif [ "$status" = "restarting" ]; then + return 2 # Restarting loop + else + return 1 # Stopped or missing + fi +} + +# Validate existing config +if [ -f /opt/amnezia/awg/wg0.conf ]; then + # Check for unexpanded variables + if grep -Fq ''$PRIVATE_KEY'' /opt/amnezia/awg/wg0.conf; then + echo "Detected broken configuration (unexpanded variables). Removing..." + rm -f /opt/amnezia/awg/wg0.conf + fi + # Check for invalid parameters S3/S4 + if grep -Eiq "^S3\s*=" /opt/amnezia/awg/wg0.conf || grep -Eiq "^S4\s*=" /opt/amnezia/awg/wg0.conf; then + echo "Detected invalid parameters (S3/S4). Removing config to regenerate..." + rm -f /opt/amnezia/awg/wg0.conf + fi +fi + +# Check for existing configuration on HOST first (preferred persistence) +if [ -f /opt/amnezia/awg/wg0.conf ]; then + echo "Found existing configuration on host." + + STATUS=0 + check_container || STATUS=$? + + if [ $STATUS -eq 2 ]; then + echo "Container is in restart loop. Recreating..." + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + elif [ $STATUS -eq 1 ]; then + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + fi + + if ! docker ps -q -f name="$CONTAINER_NAME" >/dev/null 2>&1; then + # Run container with volume mount - SINGLE LINE + docker run -d --name "$CONTAINER_NAME" --restart always --privileged --cap-add=NET_ADMIN --cap-add=SYS_MODULE -p "${VPN_PORT}:${VPN_PORT}/udp" -v /lib/modules:/lib/modules -v /opt/amnezia/awg:/opt/amnezia/awg amneziavpn/amnezia-wg:latest sh -c "while [ ! -f /opt/amnezia/awg/wg0.conf ]; do sleep 1; done; wg-quick up /opt/amnezia/awg/wg0.conf && sleep infinity" + sleep 2 + fi + + PORT=$(grep -E "^ListenPort" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + PSK=$(cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true) + if [ -z "$PSK" ]; then + PSK=$(grep -E "^PresharedKey" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + fi + PUBKEY=$(cat /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true) + if [ -z "$PUBKEY" ]; then + PRIVKEY=$(cat /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true) + if [ -n "$PRIVKEY" ]; then + PUBKEY=$(echo "$PRIVKEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) + fi + fi + + echo "Using existing AmneziaWG configuration" + echo "Port: ${PORT:-$VPN_PORT}" + if [ -n "${PUBKEY:-}" ]; then echo "Server Public Key: $PUBKEY"; fi + if [ -n "${PSK:-}" ]; then echo "PresharedKey = $PSK"; fi + exit 0 +fi + +# Rescue logic +STATUS=0 +check_container || STATUS=$? +HAS_RESCUED=0 + +if [ $STATUS -eq 2 ] || [ $STATUS -eq 0 ]; then + echo "Checking for config in existing container..." + docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true + + if docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wg0.conf /opt/amnezia/awg/wg0.conf 2>/dev/null; then + # Validate rescued config + IS_BROKEN=0 + if grep -Fq ''$PRIVATE_KEY'' /opt/amnezia/awg/wg0.conf; then IS_BROKEN=1; fi + if grep -Eiq "^S3\s*=" /opt/amnezia/awg/wg0.conf; then IS_BROKEN=1; fi + + if [ "$IS_BROKEN" = "1" ]; then + echo "Rescued config is broken. Discarding." + rm -f /opt/amnezia/awg/wg0.conf + else + echo "Rescued config from container." + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_psk.key /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_server_public_key.key /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_server_private_key.key /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true + HAS_RESCUED=1 + fi + fi + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true +fi + +# Start container (Fresh or Rescued) +docker run -d --name "$CONTAINER_NAME" --restart always --privileged --cap-add=NET_ADMIN --cap-add=SYS_MODULE -p "${VPN_PORT}:${VPN_PORT}/udp" -v /lib/modules:/lib/modules -v /opt/amnezia/awg:/opt/amnezia/awg amneziavpn/amnezia-wg:latest sh -c "while [ ! -f /opt/amnezia/awg/wg0.conf ]; do sleep 1; done; wg-quick up /opt/amnezia/awg/wg0.conf && sleep infinity" + +sleep 2 + +if [ "$HAS_RESCUED" = "1" ]; then + # Extract and exit + PORT=$(grep -E "^ListenPort" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + PSK=$(cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true) + if [ -z "$PSK" ]; then + PSK=$(grep -E "^PresharedKey" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + fi + PUBKEY=$(cat /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true) + if [ -z "$PUBKEY" ]; then + PRIVKEY=$(cat /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true) + if [ -n "$PRIVKEY" ]; then + PUBKEY=$(echo "$PRIVKEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) + fi + fi + + echo "Using existing AmneziaWG configuration" + echo "Port: ${PORT:-$VPN_PORT}" + if [ -n "${PUBKEY:-}" ]; then echo "Server Public Key: $PUBKEY"; fi + if [ -n "${PSK:-}" ]; then echo "PresharedKey = $PSK"; fi + exit 0 +fi + +# Generate new config +PRIVATE_KEY=$(docker exec "$CONTAINER_NAME" wg genkey) +PUBLIC_KEY=$(echo "$PRIVATE_KEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) +PRESHARED_KEY=$(docker exec "$CONTAINER_NAME" wg genpsk) + +# Use WG_CONF delimiter to avoid EOF replacement in PHP +cat > /opt/amnezia/awg/wg0.conf << WG_CONF +[Interface] +PrivateKey = $PRIVATE_KEY +Address = 10.8.1.1/24 +ListenPort = $VPN_PORT +MTU = $MTU +Jc = 5 +Jmin = 100 +Jmax = 200 +S1 = 50 +S2 = 100 +H1 = 0xDEADBEEF +H2 = 0xCAFEBABE +H3 = 0x12345678 +H4 = 0x9ABCDEF0 +PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE +PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE + +[Peer] +PublicKey = +PresharedKey = $PRESHARED_KEY +AllowedIPs = 10.8.1.2/32 +WG_CONF + +echo "$PRIVATE_KEY" > /opt/amnezia/awg/wireguard_server_private_key.key +echo "$PUBLIC_KEY" > /opt/amnezia/awg/wireguard_server_public_key.key +echo "$PRESHARED_KEY" > /opt/amnezia/awg/wireguard_psk.key +echo "[]" > /opt/amnezia/awg/clientsTable + +echo "AmneziaWG Advanced installed successfully" +echo "Port: $VPN_PORT" +echo "Server Public Key: $PUBLIC_KEY" +echo "PresharedKey = $PRESHARED_KEY" +' +WHERE slug = 'amnezia-wg-advanced'; diff --git a/migrations/021_fix_awg_h_params.sql b/migrations/021_fix_awg_h_params.sql new file mode 100644 index 0000000..24d4d94 --- /dev/null +++ b/migrations/021_fix_awg_h_params.sql @@ -0,0 +1,183 @@ +UPDATE protocols SET + install_script = '#!/bin/bash +set -euo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-amnezia-awg}" +PORT_RANGE_START=${PORT_RANGE_START:-30000} +PORT_RANGE_END=${PORT_RANGE_END:-65000} +VPN_PORT=${VPN_PORT:-$((RANDOM % (PORT_RANGE_END - PORT_RANGE_START + 1) + PORT_RANGE_START))} +MTU=${MTU:-1420} + +# Ensure host directory exists for persistence +mkdir -p /opt/amnezia/awg + +# Function to check if container is healthy +check_container() { + local status + status=$(docker inspect --format="{{.State.Status}}" "$CONTAINER_NAME" 2>/dev/null || echo "missing") + if [ "$status" = "running" ]; then + return 0 + elif [ "$status" = "restarting" ]; then + return 2 # Restarting loop + else + return 1 # Stopped or missing + fi +} + +# Validate existing config +if [ -f /opt/amnezia/awg/wg0.conf ]; then + # Check for unexpanded variables + if grep -Fq ''$PRIVATE_KEY'' /opt/amnezia/awg/wg0.conf; then + echo "Detected broken configuration (unexpanded variables). Removing..." + rm -f /opt/amnezia/awg/wg0.conf + fi + # Check for invalid parameters S3/S4 + if grep -Eiq "^S3[[:space:]]*=" /opt/amnezia/awg/wg0.conf || grep -Eiq "^S4[[:space:]]*=" /opt/amnezia/awg/wg0.conf; then + echo "Detected invalid parameters (S3/S4). Removing config to regenerate..." + rm -f /opt/amnezia/awg/wg0.conf + fi + # Check for hex H-params + if grep -Eiq "^H[1-4][[:space:]]*=[[:space:]]*0x" /opt/amnezia/awg/wg0.conf; then + echo "Detected invalid hex parameters (H1-H4). Removing config to regenerate..." + rm -f /opt/amnezia/awg/wg0.conf + fi +fi + +# Check for existing configuration on HOST first (preferred persistence) +if [ -f /opt/amnezia/awg/wg0.conf ]; then + echo "Found existing configuration on host." + + STATUS=0 + check_container || STATUS=$? + + if [ $STATUS -eq 2 ]; then + echo "Container is in restart loop. Recreating..." + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + elif [ $STATUS -eq 1 ]; then + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + fi + + if ! docker ps -q -f name="$CONTAINER_NAME" >/dev/null 2>&1; then + # Run container with volume mount - SINGLE LINE + docker run -d --name "$CONTAINER_NAME" --restart always --privileged --cap-add=NET_ADMIN --cap-add=SYS_MODULE -p "${VPN_PORT}:${VPN_PORT}/udp" -v /lib/modules:/lib/modules -v /opt/amnezia/awg:/opt/amnezia/awg amneziavpn/amnezia-wg:latest sh -c "while [ ! -f /opt/amnezia/awg/wg0.conf ]; do sleep 1; done; wg-quick up /opt/amnezia/awg/wg0.conf && sleep infinity" + sleep 2 + fi + + PORT=$(grep -E "^ListenPort" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + PSK=$(cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true) + if [ -z "$PSK" ]; then + PSK=$(grep -E "^PresharedKey" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + fi + PUBKEY=$(cat /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true) + if [ -z "$PUBKEY" ]; then + PRIVKEY=$(cat /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true) + if [ -n "$PRIVKEY" ]; then + PUBKEY=$(echo "$PRIVKEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) + fi + fi + + echo "Using existing AmneziaWG configuration" + echo "Port: ${PORT:-$VPN_PORT}" + if [ -n "${PUBKEY:-}" ]; then echo "Server Public Key: $PUBKEY"; fi + if [ -n "${PSK:-}" ]; then echo "PresharedKey = $PSK"; fi + exit 0 +fi + +# Rescue logic +STATUS=0 +check_container || STATUS=$? +HAS_RESCUED=0 + +if [ $STATUS -eq 2 ] || [ $STATUS -eq 0 ]; then + echo "Checking for config in existing container..." + docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true + + if docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wg0.conf /opt/amnezia/awg/wg0.conf 2>/dev/null; then + # Validate rescued config + IS_BROKEN=0 + if grep -Fq ''$PRIVATE_KEY'' /opt/amnezia/awg/wg0.conf; then IS_BROKEN=1; fi + if grep -Eiq "^S3[[:space:]]*=" /opt/amnezia/awg/wg0.conf; then IS_BROKEN=1; fi + if grep -Eiq "^H[1-4][[:space:]]*=[[:space:]]*0x" /opt/amnezia/awg/wg0.conf; then IS_BROKEN=1; fi + + if [ "$IS_BROKEN" = "1" ]; then + echo "Rescued config is broken. Discarding." + rm -f /opt/amnezia/awg/wg0.conf + else + echo "Rescued config from container." + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_psk.key /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_server_public_key.key /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_server_private_key.key /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true + HAS_RESCUED=1 + fi + fi + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true +fi + +# Start container (Fresh or Rescued) +docker run -d --name "$CONTAINER_NAME" --restart always --privileged --cap-add=NET_ADMIN --cap-add=SYS_MODULE -p "${VPN_PORT}:${VPN_PORT}/udp" -v /lib/modules:/lib/modules -v /opt/amnezia/awg:/opt/amnezia/awg amneziavpn/amnezia-wg:latest sh -c "while [ ! -f /opt/amnezia/awg/wg0.conf ]; do sleep 1; done; wg-quick up /opt/amnezia/awg/wg0.conf && sleep infinity" + +sleep 2 + +if [ "$HAS_RESCUED" = "1" ]; then + # Extract and exit + PORT=$(grep -E "^ListenPort" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + PSK=$(cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true) + if [ -z "$PSK" ]; then + PSK=$(grep -E "^PresharedKey" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + fi + PUBKEY=$(cat /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true) + if [ -z "$PUBKEY" ]; then + PRIVKEY=$(cat /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true) + if [ -n "$PRIVKEY" ]; then + PUBKEY=$(echo "$PRIVKEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) + fi + fi + + echo "Using existing AmneziaWG configuration" + echo "Port: ${PORT:-$VPN_PORT}" + if [ -n "${PUBKEY:-}" ]; then echo "Server Public Key: $PUBKEY"; fi + if [ -n "${PSK:-}" ]; then echo "PresharedKey = $PSK"; fi + exit 0 +fi + +# Generate new config +PRIVATE_KEY=$(docker exec "$CONTAINER_NAME" wg genkey) +PUBLIC_KEY=$(echo "$PRIVATE_KEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) +PRESHARED_KEY=$(docker exec "$CONTAINER_NAME" wg genpsk) + +# Use WG_CONF delimiter to avoid EOF replacement in PHP +cat > /opt/amnezia/awg/wg0.conf << WG_CONF +[Interface] +PrivateKey = $PRIVATE_KEY +Address = 10.8.1.1/24 +ListenPort = $VPN_PORT +MTU = $MTU +Jc = 5 +Jmin = 100 +Jmax = 200 +S1 = 50 +S2 = 100 +H1 = 1 +H2 = 2 +H3 = 3 +H4 = 4 +PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE +PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE + +[Peer] +PublicKey = +PresharedKey = $PRESHARED_KEY +AllowedIPs = 10.8.1.2/32 +WG_CONF + +echo "$PRIVATE_KEY" > /opt/amnezia/awg/wireguard_server_private_key.key +echo "$PUBLIC_KEY" > /opt/amnezia/awg/wireguard_server_public_key.key +echo "$PRESHARED_KEY" > /opt/amnezia/awg/wireguard_psk.key +echo "[]" > /opt/amnezia/awg/clientsTable + +echo "AmneziaWG Advanced installed successfully" +echo "Port: $VPN_PORT" +echo "Server Public Key: $PUBLIC_KEY" +echo "PresharedKey = $PRESHARED_KEY" +' +WHERE slug = 'amnezia-wg-advanced'; diff --git a/migrations/022_fix_awg_peer.sql b/migrations/022_fix_awg_peer.sql new file mode 100644 index 0000000..d27e260 --- /dev/null +++ b/migrations/022_fix_awg_peer.sql @@ -0,0 +1,184 @@ +UPDATE protocols SET + install_script = '#!/bin/bash +set -euo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-amnezia-awg}" +PORT_RANGE_START=${PORT_RANGE_START:-30000} +PORT_RANGE_END=${PORT_RANGE_END:-65000} +VPN_PORT=${VPN_PORT:-$((RANDOM % (PORT_RANGE_END - PORT_RANGE_START + 1) + PORT_RANGE_START))} +MTU=${MTU:-1420} + +# Ensure host directory exists for persistence +mkdir -p /opt/amnezia/awg + +# Function to check if container is healthy +check_container() { + local status + status=$(docker inspect --format="{{.State.Status}}" "$CONTAINER_NAME" 2>/dev/null || echo "missing") + if [ "$status" = "running" ]; then + return 0 + elif [ "$status" = "restarting" ]; then + return 2 # Restarting loop + else + return 1 # Stopped or missing + fi +} + +# Validate existing config +if [ -f /opt/amnezia/awg/wg0.conf ]; then + # Check for unexpanded variables + if grep -Fq ''$PRIVATE_KEY'' /opt/amnezia/awg/wg0.conf; then + echo "Detected broken configuration (unexpanded variables). Removing..." + rm -f /opt/amnezia/awg/wg0.conf + fi + # Check for invalid parameters S3/S4 + if grep -Eiq "^S3[[:space:]]*=" /opt/amnezia/awg/wg0.conf || grep -Eiq "^S4[[:space:]]*=" /opt/amnezia/awg/wg0.conf; then + echo "Detected invalid parameters (S3/S4). Removing config to regenerate..." + rm -f /opt/amnezia/awg/wg0.conf + fi + # Check for hex H-params + if grep -Eiq "^H[1-4][[:space:]]*=[[:space:]]*0x" /opt/amnezia/awg/wg0.conf; then + echo "Detected invalid hex parameters (H1-H4). Removing config to regenerate..." + rm -f /opt/amnezia/awg/wg0.conf + fi + # Check for empty PublicKey + if grep -Eiq "^PublicKey[[:space:]]*=[[:space:]]*$" /opt/amnezia/awg/wg0.conf; then + echo "Detected empty PublicKey. Removing config to regenerate..." + rm -f /opt/amnezia/awg/wg0.conf + fi +fi + +# Check for existing configuration on HOST first (preferred persistence) +if [ -f /opt/amnezia/awg/wg0.conf ]; then + echo "Found existing configuration on host." + + STATUS=0 + check_container || STATUS=$? + + if [ $STATUS -eq 2 ]; then + echo "Container is in restart loop. Recreating..." + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + elif [ $STATUS -eq 1 ]; then + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + fi + + if ! docker ps -q -f name="$CONTAINER_NAME" >/dev/null 2>&1; then + # Run container with volume mount - SINGLE LINE + docker run -d --name "$CONTAINER_NAME" --restart always --privileged --cap-add=NET_ADMIN --cap-add=SYS_MODULE -p "${VPN_PORT}:${VPN_PORT}/udp" -v /lib/modules:/lib/modules -v /opt/amnezia/awg:/opt/amnezia/awg amneziavpn/amnezia-wg:latest sh -c "while [ ! -f /opt/amnezia/awg/wg0.conf ]; do sleep 1; done; wg-quick up /opt/amnezia/awg/wg0.conf && sleep infinity" + sleep 2 + fi + + PORT=$(grep -E "^ListenPort" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + PSK=$(cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true) + if [ -z "$PSK" ]; then + PSK=$(grep -E "^PresharedKey" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + fi + PUBKEY=$(cat /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true) + if [ -z "$PUBKEY" ]; then + PRIVKEY=$(cat /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true) + if [ -n "$PRIVKEY" ]; then + PUBKEY=$(echo "$PRIVKEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) + fi + fi + + echo "Using existing AmneziaWG configuration" + echo "Port: ${PORT:-$VPN_PORT}" + if [ -n "${PUBKEY:-}" ]; then echo "Server Public Key: $PUBKEY"; fi + if [ -n "${PSK:-}" ]; then echo "PresharedKey = $PSK"; fi + exit 0 +fi + +# Rescue logic +STATUS=0 +check_container || STATUS=$? +HAS_RESCUED=0 + +if [ $STATUS -eq 2 ] || [ $STATUS -eq 0 ]; then + echo "Checking for config in existing container..." + docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true + + if docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wg0.conf /opt/amnezia/awg/wg0.conf 2>/dev/null; then + # Validate rescued config + IS_BROKEN=0 + if grep -Fq ''$PRIVATE_KEY'' /opt/amnezia/awg/wg0.conf; then IS_BROKEN=1; fi + if grep -Eiq "^S3[[:space:]]*=" /opt/amnezia/awg/wg0.conf; then IS_BROKEN=1; fi + if grep -Eiq "^H[1-4][[:space:]]*=[[:space:]]*0x" /opt/amnezia/awg/wg0.conf; then IS_BROKEN=1; fi + if grep -Eiq "^PublicKey[[:space:]]*=[[:space:]]*$" /opt/amnezia/awg/wg0.conf; then IS_BROKEN=1; fi + + if [ "$IS_BROKEN" = "1" ]; then + echo "Rescued config is broken. Discarding." + rm -f /opt/amnezia/awg/wg0.conf + else + echo "Rescued config from container." + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_psk.key /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_server_public_key.key /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_server_private_key.key /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true + HAS_RESCUED=1 + fi + fi + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true +fi + +# Start container (Fresh or Rescued) +docker run -d --name "$CONTAINER_NAME" --restart always --privileged --cap-add=NET_ADMIN --cap-add=SYS_MODULE -p "${VPN_PORT}:${VPN_PORT}/udp" -v /lib/modules:/lib/modules -v /opt/amnezia/awg:/opt/amnezia/awg amneziavpn/amnezia-wg:latest sh -c "while [ ! -f /opt/amnezia/awg/wg0.conf ]; do sleep 1; done; wg-quick up /opt/amnezia/awg/wg0.conf && sleep infinity" + +sleep 2 + +if [ "$HAS_RESCUED" = "1" ]; then + # Extract and exit + PORT=$(grep -E "^ListenPort" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + PSK=$(cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true) + if [ -z "$PSK" ]; then + PSK=$(grep -E "^PresharedKey" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + fi + PUBKEY=$(cat /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true) + if [ -z "$PUBKEY" ]; then + PRIVKEY=$(cat /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true) + if [ -n "$PRIVKEY" ]; then + PUBKEY=$(echo "$PRIVKEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) + fi + fi + + echo "Using existing AmneziaWG configuration" + echo "Port: ${PORT:-$VPN_PORT}" + if [ -n "${PUBKEY:-}" ]; then echo "Server Public Key: $PUBKEY"; fi + if [ -n "${PSK:-}" ]; then echo "PresharedKey = $PSK"; fi + exit 0 +fi + +# Generate new config +PRIVATE_KEY=$(docker exec "$CONTAINER_NAME" wg genkey) +PUBLIC_KEY=$(echo "$PRIVATE_KEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) +PRESHARED_KEY=$(docker exec "$CONTAINER_NAME" wg genpsk) + +# Use WG_CONF delimiter to avoid EOF replacement in PHP +cat > /opt/amnezia/awg/wg0.conf << WG_CONF +[Interface] +PrivateKey = $PRIVATE_KEY +Address = 10.8.1.1/24 +ListenPort = $VPN_PORT +MTU = $MTU +Jc = 5 +Jmin = 100 +Jmax = 200 +S1 = 50 +S2 = 100 +H1 = 1 +H2 = 2 +H3 = 3 +H4 = 4 +PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE +PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE +WG_CONF + +echo "$PRIVATE_KEY" > /opt/amnezia/awg/wireguard_server_private_key.key +echo "$PUBLIC_KEY" > /opt/amnezia/awg/wireguard_server_public_key.key +echo "$PRESHARED_KEY" > /opt/amnezia/awg/wireguard_psk.key +echo "[]" > /opt/amnezia/awg/clientsTable + +echo "AmneziaWG Advanced installed successfully" +echo "Port: $VPN_PORT" +echo "Server Public Key: $PUBLIC_KEY" +echo "PresharedKey = $PRESHARED_KEY" +' +WHERE slug = 'amnezia-wg-advanced'; diff --git a/migrations/023_ensure_container_running.sql b/migrations/023_ensure_container_running.sql new file mode 100644 index 0000000..66ffa82 --- /dev/null +++ b/migrations/023_ensure_container_running.sql @@ -0,0 +1,188 @@ +UPDATE protocols SET + install_script = '#!/bin/bash +set -euo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-amnezia-awg}" +PORT_RANGE_START=${PORT_RANGE_START:-30000} +PORT_RANGE_END=${PORT_RANGE_END:-65000} +VPN_PORT=${VPN_PORT:-$((RANDOM % (PORT_RANGE_END - PORT_RANGE_START + 1) + PORT_RANGE_START))} +MTU=${MTU:-1420} + +# Ensure host directory exists for persistence +mkdir -p /opt/amnezia/awg + +# Function to check if container is healthy +check_container() { + local status + status=$(docker inspect --format="{{.State.Status}}" "$CONTAINER_NAME" 2>/dev/null || echo "missing") + if [ "$status" = "running" ]; then + return 0 + elif [ "$status" = "restarting" ]; then + return 2 # Restarting loop + else + return 1 # Stopped or missing + fi +} + +# Validate existing config +if [ -f /opt/amnezia/awg/wg0.conf ]; then + # Check for unexpanded variables + if grep -Fq ''$PRIVATE_KEY'' /opt/amnezia/awg/wg0.conf; then + echo "Detected broken configuration (unexpanded variables). Removing..." + rm -f /opt/amnezia/awg/wg0.conf + fi + # Check for invalid parameters S3/S4 + if grep -Eiq "^S3[[:space:]]*=" /opt/amnezia/awg/wg0.conf || grep -Eiq "^S4[[:space:]]*=" /opt/amnezia/awg/wg0.conf; then + echo "Detected invalid parameters (S3/S4). Removing config to regenerate..." + rm -f /opt/amnezia/awg/wg0.conf + fi + # Check for hex H-params + if grep -Eiq "^H[1-4][[:space:]]*=[[:space:]]*0x" /opt/amnezia/awg/wg0.conf; then + echo "Detected invalid hex parameters (H1-H4). Removing config to regenerate..." + rm -f /opt/amnezia/awg/wg0.conf + fi + # Check for empty PublicKey + if grep -Eiq "^PublicKey[[:space:]]*=[[:space:]]*$" /opt/amnezia/awg/wg0.conf; then + echo "Detected empty PublicKey. Removing config to regenerate..." + rm -f /opt/amnezia/awg/wg0.conf + fi +fi + +# Check for existing configuration on HOST first (preferred persistence) +if [ -f /opt/amnezia/awg/wg0.conf ]; then + echo "Found existing configuration on host." + + PORT=$(grep -E "^ListenPort" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + VPN_PORT=${PORT:-$VPN_PORT} + + STATUS=0 + check_container || STATUS=$? + + if [ $STATUS -eq 2 ]; then + echo "Container is in restart loop. Recreating..." + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + STATUS=1 + elif [ $STATUS -eq 0 ]; then + echo "Container is running." + fi + + # Ensure container is running + if [ $STATUS -ne 0 ]; then + echo "Starting container..." + docker run -d --name "$CONTAINER_NAME" --restart always --privileged --cap-add=NET_ADMIN --cap-add=SYS_MODULE -p "${VPN_PORT}:${VPN_PORT}/udp" -v /lib/modules:/lib/modules -v /opt/amnezia/awg:/opt/amnezia/awg amneziavpn/amnezia-wg:latest sh -c "while [ ! -f /opt/amnezia/awg/wg0.conf ]; do sleep 1; done; wg-quick up /opt/amnezia/awg/wg0.conf && sleep infinity" + sleep 2 + fi + + PSK=$(cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true) + if [ -z "$PSK" ]; then + PSK=$(grep -E "^PresharedKey" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + fi + PUBKEY=$(cat /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true) + if [ -z "$PUBKEY" ]; then + PRIVKEY=$(cat /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true) + if [ -n "$PRIVKEY" ]; then + PUBKEY=$(echo "$PRIVKEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) + fi + fi + + echo "Using existing AmneziaWG configuration" + echo "Port: $VPN_PORT" + if [ -n "${PUBKEY:-}" ]; then echo "Server Public Key: $PUBKEY"; fi + if [ -n "${PSK:-}" ]; then echo "PresharedKey: $PSK"; fi + exit 0 +fi + +# Rescue logic +STATUS=0 +check_container || STATUS=$? +HAS_RESCUED=0 + +if [ $STATUS -eq 2 ] || [ $STATUS -eq 0 ]; then + echo "Checking for config in existing container..." + docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true + + if docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wg0.conf /opt/amnezia/awg/wg0.conf 2>/dev/null; then + # Validate rescued config + IS_BROKEN=0 + if grep -Fq ''$PRIVATE_KEY'' /opt/amnezia/awg/wg0.conf; then IS_BROKEN=1; fi + if grep -Eiq "^S3[[:space:]]*=" /opt/amnezia/awg/wg0.conf; then IS_BROKEN=1; fi + if grep -Eiq "^H[1-4][[:space:]]*=[[:space:]]*0x" /opt/amnezia/awg/wg0.conf; then IS_BROKEN=1; fi + if grep -Eiq "^PublicKey[[:space:]]*=[[:space:]]*$" /opt/amnezia/awg/wg0.conf; then IS_BROKEN=1; fi + + if [ "$IS_BROKEN" = "1" ]; then + echo "Rescued config is broken. Discarding." + rm -f /opt/amnezia/awg/wg0.conf + else + echo "Rescued config from container." + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_psk.key /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_server_public_key.key /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_server_private_key.key /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true + HAS_RESCUED=1 + fi + fi + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true +fi + +# Start container (Fresh or Rescued) +docker run -d --name "$CONTAINER_NAME" --restart always --privileged --cap-add=NET_ADMIN --cap-add=SYS_MODULE -p "${VPN_PORT}:${VPN_PORT}/udp" -v /lib/modules:/lib/modules -v /opt/amnezia/awg:/opt/amnezia/awg amneziavpn/amnezia-wg:latest sh -c "while [ ! -f /opt/amnezia/awg/wg0.conf ]; do sleep 1; done; wg-quick up /opt/amnezia/awg/wg0.conf && sleep infinity" + +sleep 2 + +if [ "$HAS_RESCUED" = "1" ]; then + # Extract and exit + PORT=$(grep -E "^ListenPort" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + PSK=$(cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true) + if [ -z "$PSK" ]; then + PSK=$(grep -E "^PresharedKey" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + fi + PUBKEY=$(cat /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true) + if [ -z "$PUBKEY" ]; then + PRIVKEY=$(cat /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true) + if [ -n "$PRIVKEY" ]; then + PUBKEY=$(echo "$PRIVKEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) + fi + fi + + echo "Using existing AmneziaWG configuration" + echo "Port: ${PORT:-$VPN_PORT}" + if [ -n "${PUBKEY:-}" ]; then echo "Server Public Key: $PUBKEY"; fi + if [ -n "${PSK:-}" ]; then echo "PresharedKey: $PSK"; fi + exit 0 +fi + +# Generate new config +PRIVATE_KEY=$(docker exec "$CONTAINER_NAME" wg genkey) +PUBLIC_KEY=$(echo "$PRIVATE_KEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) +PRESHARED_KEY=$(docker exec "$CONTAINER_NAME" wg genpsk) + +# Use WG_CONF delimiter to avoid EOF replacement in PHP +cat > /opt/amnezia/awg/wg0.conf << WG_CONF +[Interface] +PrivateKey = $PRIVATE_KEY +Address = 10.8.1.1/24 +ListenPort = $VPN_PORT +MTU = $MTU +Jc = 5 +Jmin = 100 +Jmax = 200 +S1 = 50 +S2 = 100 +H1 = 1 +H2 = 2 +H3 = 3 +H4 = 4 +PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE +PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE +WG_CONF + +echo "$PRIVATE_KEY" > /opt/amnezia/awg/wireguard_server_private_key.key +echo "$PUBLIC_KEY" > /opt/amnezia/awg/wireguard_server_public_key.key +echo "$PRESHARED_KEY" > /opt/amnezia/awg/wireguard_psk.key +echo "[]" > /opt/amnezia/awg/clientsTable + +echo "AmneziaWG Advanced installed successfully" +echo "Port: $VPN_PORT" +echo "Server Public Key: $PUBLIC_KEY" +echo "PresharedKey: $PRESHARED_KEY" +' +WHERE slug = 'amnezia-wg-advanced'; diff --git a/migrations/024_fix_xray_ports.sql b/migrations/024_fix_xray_ports.sql new file mode 100644 index 0000000..22c1465 --- /dev/null +++ b/migrations/024_fix_xray_ports.sql @@ -0,0 +1,22 @@ +-- Backfill XRay server_port from extras.result keys produced by set -x +UPDATE server_protocols sp +JOIN protocols p ON p.id = sp.protocol_id +SET sp.config_data = JSON_SET(sp.config_data, '$.server_port', JSON_EXTRACT(sp.config_data, '$.extras.result."+_xray_port"')), + sp.applied_at = NOW() +WHERE p.slug = 'xray-vless' + AND ( + JSON_EXTRACT(sp.config_data, '$.server_port') IS NULL OR + JSON_UNQUOTE(JSON_EXTRACT(sp.config_data, '$.server_port')) = '' + ) + AND JSON_EXTRACT(sp.config_data, '$.extras.result."+_xray_port"') IS NOT NULL; + +UPDATE server_protocols sp +JOIN protocols p ON p.id = sp.protocol_id +SET sp.config_data = JSON_SET(sp.config_data, '$.server_port', JSON_EXTRACT(sp.config_data, '$.extras.result.xray_port')), + sp.applied_at = NOW() +WHERE p.slug = 'xray-vless' + AND ( + JSON_EXTRACT(sp.config_data, '$.server_port') IS NULL OR + JSON_UNQUOTE(JSON_EXTRACT(sp.config_data, '$.server_port')) = '' + ) + AND JSON_EXTRACT(sp.config_data, '$.extras.result.xray_port') IS NOT NULL; \ No newline at end of file diff --git a/migrations/025_xray_reality.sql b/migrations/025_xray_reality.sql new file mode 100644 index 0000000..eef5e55 --- /dev/null +++ b/migrations/025_xray_reality.sql @@ -0,0 +1,18 @@ +-- Update XRay VLESS protocol to Reality/Vision setup +UPDATE protocols SET install_script = '#!/bin/bash\n\nset -euo pipefail\nset -x\n\nCONTAINER_NAME="${CONTAINER_NAME:-amnezia-xray}"\nPORT_RANGE_START=${PORT_RANGE_START:-30000}\nPORT_RANGE_END=${PORT_RANGE_END:-65000}\nXRAY_PORT=$((RANDOM % (PORT_RANGE_END - PORT_RANGE_START + 1) + PORT_RANGE_START))\n\nPRIVATE_KEY=$(docker run --rm teddysun/xray xray x25519 | grep "Private key:" | awk ''{print $3}'')\nPUBLIC_KEY=$(docker run --rm teddysun/xray xray x25519 -i "$PRIVATE_KEY" | grep "Public key:" | awk ''{print $3}'')\nSHORT_ID=$(openssl rand -hex 8)\nCLIENT_ID=$(cat /proc/sys/kernel/random/uuid)\n\nSERVER_NAME="www.googletagmanager.com"\nFINGERPRINT="chrome"\nSPIDER_X="/"\n\ndocker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true\nmkdir -p /opt/amnezia/xray\n\ncat > /opt/amnezia/xray/server.json << EOF\n{\n "log": { "loglevel": "warning" },\n "inbounds": [\n {\n "listen": "0.0.0.0",\n "port": ${XRAY_PORT},\n "protocol": "vless",\n "settings": {\n "clients": [ { "id": "${CLIENT_ID}" } ],\n "decryption": "none",\n "fallbacks": [ { "dest": 80 } ]\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": {\n "show": false,\n "dest": "${SERVER_NAME}:443",\n "xver": 0,\n "serverNames": [ "${SERVER_NAME}" ],\n "privateKey": "${PRIVATE_KEY}",\n "shortIds": [ "${SHORT_ID}" ],\n "fingerprint": "${FINGERPRINT}",\n "spiderX": "${SPIDER_X}"\n }\n }\n }\n ],\n "outbounds": [ { "protocol": "freedom", "tag": "direct" } ]\n}\nEOF\n\n# start container\ndocker run -d \\\n --name "$CONTAINER_NAME" \\\n --restart always \\\n -p "${XRAY_PORT}:${XRAY_PORT}" \\\n -v /opt/amnezia/xray:/opt/amnezia/xray \\\n teddysun/xray xray run -c /opt/amnezia/xray/server.json\n\nsleep 2\n\n# panel output\necho "Port: ${XRAY_PORT}"\necho "ClientID: ${CLIENT_ID}"\necho "PublicKey: ${PUBLIC_KEY}"\necho "PrivateKey: ${PRIVATE_KEY}"\necho "ShortID: ${SHORT_ID}"\necho "ServerName: ${SERVER_NAME}"', +output_template = 'vless://{{client_id}}@{{server_host}}:{{server_port}}?encryption=none&flow=xtls-rprx-vision&security=reality&sni={{reality_server_name}}&fp=chrome&pbk={{reality_public_key}}&sid={{reality_short_id}}&type=tcp' +WHERE slug = 'xray-vless'; + +-- Ensure protocol variables exist +SET @pid = (SELECT id FROM protocols WHERE slug = 'xray-vless'); +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, description, required) +SELECT @pid, 'reality_public_key', 'string', 'Reality public key (base64url)', true +WHERE NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=@pid AND variable_name='reality_public_key'); + +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, description, required) +SELECT @pid, 'reality_short_id', 'string', 'Reality shortId', true +WHERE NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=@pid AND variable_name='reality_short_id'); + +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, description, required) +SELECT @pid, 'reality_server_name', 'string', 'SNI server name for Reality', true +WHERE NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=@pid AND variable_name='reality_server_name'); \ No newline at end of file diff --git a/migrations/026_xray_uninstall_script.sql b/migrations/026_xray_uninstall_script.sql new file mode 100644 index 0000000..3532f0a --- /dev/null +++ b/migrations/026_xray_uninstall_script.sql @@ -0,0 +1,4 @@ +-- Set uninstall script for XRay VLESS protocol +UPDATE protocols +SET uninstall_script = '#!/bin/bash\n\nset -euo pipefail\nset -x\n\nCONTAINER_NAME="${SERVER_CONTAINER:-${CONTAINER_NAME:-amnezia-xray}}"\n\ndocker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true\nrm -rf /opt/amnezia/xray || true\n\necho "Uninstalled: ${CONTAINER_NAME}"\n' +WHERE slug = 'xray-vless'; \ No newline at end of file diff --git a/migrations/027_update_xray_install_script.sql b/migrations/027_update_xray_install_script.sql new file mode 100644 index 0000000..a486c50 --- /dev/null +++ b/migrations/027_update_xray_install_script.sql @@ -0,0 +1,4 @@ +-- Make XRay Reality install script robust (keys generation and container name) +UPDATE protocols +SET install_script = '#!/bin/bash\n\nset -euo pipefail\nset -x\n\nCONTAINER_NAME="amnezia-xray"\nPORT_RANGE_START=${PORT_RANGE_START:-30000}\nPORT_RANGE_END=${PORT_RANGE_END:-65000}\nXRAY_PORT=$((RANDOM % (PORT_RANGE_END - PORT_RANGE_START + 1) + PORT_RANGE_START))\n\n# ensure image exists to avoid pull logs mixing with key output\ndocker pull teddysun/xray >/dev/null 2>&1 || true\n\nGEN=$(docker run --rm --entrypoint /usr/bin/xray teddysun/xray x25519 2>/dev/null || true)\nPRIVATE_KEY=$(printf "%s\n" "$GEN" | awk -F": " "/[Pp]rivate/ {print \\$2}" | tr -d " \\t\\r\\n")\nPUBLIC_KEY=$(printf "%s\n" "$GEN" | awk -F": " "/[Pp]ublic/ {print \\$2}" | tr -d " \\t\\r\\n")\n\nif [ -z "$PRIVATE_KEY" ]; then\n PRIVATE_KEY=$(docker run --rm --entrypoint /usr/bin/xray teddysun/xray x25519 2>/dev/null | awk -F": " "/[Pp]rivate/ {print \\$2}" | tr -d " \\t\\r\\n" || true)\nfi\nif [ -z "$PUBLIC_KEY" ]; then\n PUBLIC_KEY=$(docker run --rm --entrypoint /usr/bin/xray teddysun/xray x25519 -i "$PRIVATE_KEY" 2>/dev/null | awk -F": " "/[Pp]ublic/ {print \\$2}" | tr -d " \\t\\r\\n" || true)\nfi\n\nSHORT_ID=$(openssl rand -hex 8)\nCLIENT_ID=$(cat /proc/sys/kernel/random/uuid)\n\nSERVER_NAME="${SERVER_NAME:-www.googletagmanager.com}"\nFINGERPRINT="${FINGERPRINT:-chrome}"\nSPIDER_X="${SPIDER_X:-/}"\n\ndocker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true\nmkdir -p /opt/amnezia/xray\n\ncat > /opt/amnezia/xray/server.json << EOF\n{\n "log": { "loglevel": "warning" },\n "inbounds": [\n {\n "listen": "0.0.0.0",\n "port": ${XRAY_PORT},\n "protocol": "vless",\n "settings": {\n "clients": [ { "id": "${CLIENT_ID}" } ],\n "decryption": "none"\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": {\n "show": false,\n "dest": "${SERVER_NAME}:443",\n "xver": 0,\n "serverNames": [ "${SERVER_NAME}" ],\n "privateKey": "${PRIVATE_KEY}",\n "shortIds": [ "${SHORT_ID}" ],\n "fingerprint": "${FINGERPRINT}",\n "spiderX": "${SPIDER_X}"\n }\n }\n }\n ],\n "outbounds": [ { "protocol": "freedom", "tag": "direct" } ]\n}\nEOF\n\n# start container\ndocker run -d \\\n --name "$CONTAINER_NAME" \\\n --restart always \\\n -p "${XRAY_PORT}:${XRAY_PORT}" \\\n -v /opt/amnezia/xray:/opt/amnezia/xray \\\n teddysun/xray xray run -c /opt/amnezia/xray/server.json\n\nsleep 2\n\n# panel output\necho "Port: ${XRAY_PORT}"\necho "ClientID: ${CLIENT_ID}"\necho "PublicKey: ${PUBLIC_KEY}"\necho "PrivateKey: ${PRIVATE_KEY}"\necho "ShortID: ${SHORT_ID}"\necho "ServerName: ${SERVER_NAME}"\necho "ContainerName: ${CONTAINER_NAME}"' +WHERE slug = 'xray-vless'; \ No newline at end of file diff --git a/migrations/028_fix_xray_install_keys.sql b/migrations/028_fix_xray_install_keys.sql new file mode 100644 index 0000000..2b0f087 --- /dev/null +++ b/migrations/028_fix_xray_install_keys.sql @@ -0,0 +1,4 @@ +-- Fix PublicKey extraction and ShortID generation in XRay install script +UPDATE protocols +SET install_script = '#!/bin/bash\n\nset -euo pipefail\nset -x\n\nCONTAINER_NAME="${CONTAINER_NAME:-amnezia-xray}"\nPORT_RANGE_START=${PORT_RANGE_START:-30000}\nPORT_RANGE_END=${PORT_RANGE_END:-65000}\nXRAY_PORT=$((RANDOM % (PORT_RANGE_END - PORT_RANGE_START + 1) + PORT_RANGE_START))\n\n# Ensure image present to avoid noisy pull output\ndocker pull teddysun/xray >/dev/null 2>&1 || true\n\n# Generate keys\nGEN=$(docker run --rm --entrypoint /usr/bin/xray teddysun/xray x25519 2>/dev/null || true)\nPRIVATE_KEY=$(printf "%s\n" "$GEN" | sed -n -E "s/^[Pp]rivate[[:space:]]*[Kk]ey:[[:space:]]*(.*)$/\\1/p" | tr -d " \t\r\n")\nPUBLIC_KEY=$(printf "%s\n" "$GEN" | sed -n -E "s/^[Pp]ublic[[:space:]]*[Kk]ey:[[:space:]]*(.*)$/\\1/p" | tr -d " \t\r\n")\n\nif [ -z "$PUBLIC_KEY" ]; then\n 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)\nfi\n\n# Generate shortId without openssl\nSHORT_ID=$(od -An -tx1 -N8 /dev/urandom | tr -d " \n")\nCLIENT_ID=$(cat /proc/sys/kernel/random/uuid)\n\nSERVER_NAME="${SERVER_NAME:-www.googletagmanager.com}"\nFINGERPRINT="${FINGERPRINT:-chrome}"\nSPIDER_X="${SPIDER_X:-/}"\n\ndocker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true\nmkdir -p /opt/amnezia/xray\n\ncat > /opt/amnezia/xray/server.json << EOF\n{\n "log": { "loglevel": "warning" },\n "inbounds": [\n {\n "listen": "0.0.0.0",\n "port": ${XRAY_PORT},\n "protocol": "vless",\n "settings": {\n "clients": [ { "id": "${CLIENT_ID}" } ],\n "decryption": "none"\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": {\n "show": false,\n "dest": "${SERVER_NAME}:443",\n "xver": 0,\n "serverNames": [ "${SERVER_NAME}" ],\n "privateKey": "${PRIVATE_KEY}",\n "shortIds": [ "${SHORT_ID}" ],\n "fingerprint": "${FINGERPRINT}",\n "spiderX": "${SPIDER_X}"\n }\n }\n }\n ],\n "outbounds": [ { "protocol": "freedom", "tag": "direct" } ]\n}\nEOF\n\n# Start container\ndocker run -d \\\n --name "$CONTAINER_NAME" \\\n --restart always \\\n -p "${XRAY_PORT}:${XRAY_PORT}" \\\n -v /opt/amnezia/xray:/opt/amnezia/xray \\\n teddysun/xray xray run -c /opt/amnezia/xray/server.json\n\nsleep 2\n\n# Output configuration\necho "Port: ${XRAY_PORT}"\necho "ClientID: ${CLIENT_ID}"\necho "PublicKey: ${PUBLIC_KEY}"\necho "PrivateKey: ${PRIVATE_KEY}"\necho "ShortID: ${SHORT_ID}"\necho "ServerName: ${SERVER_NAME}"\necho "ContainerName: ${CONTAINER_NAME}"' +WHERE slug = 'xray-vless'; \ No newline at end of file diff --git a/migrations/029_xray_respect_server_port.sql b/migrations/029_xray_respect_server_port.sql new file mode 100644 index 0000000..f3596af --- /dev/null +++ b/migrations/029_xray_respect_server_port.sql @@ -0,0 +1,3 @@ +UPDATE protocols +SET install_script = '#!/bin/bash\n\nset -euo pipefail\nset -x\n\nCONTAINER_NAME="${CONTAINER_NAME:-amnezia-xray}"\nPORT_RANGE_START=${PORT_RANGE_START:-30000}\nPORT_RANGE_END=${PORT_RANGE_END:-65000}\nXRAY_PORT=${SERVER_PORT:-$((RANDOM % (PORT_RANGE_END - PORT_RANGE_START + 1) + PORT_RANGE_START))}\n\n# Ensure image present to avoid noisy pull output\ndocker pull teddysun/xray >/dev/null 2>&1 || true\n\n# Generate keys\nGEN=$(docker run --rm --entrypoint /usr/bin/xray teddysun/xray x25519 2>/dev/null || true)\nPRIVATE_KEY=$(printf "%s\\n" "$GEN" | sed -n -E "s/^[Pp]rivate[[:space:]]*[Kk]ey:[[:space:]]*(.*)$/\\1/p" | tr -d " \\t\\r\\n")\nPUBLIC_KEY=$(printf "%s\\n" "$GEN" | sed -n -E "s/^[Pp]ublic[[:space:]]*[Kk]ey:[[:space:]]*(.*)$/\\1/p" | tr -d " \\t\\r\\n")\n\nif [ -z "$PUBLIC_KEY" ] && [ -n "$PRIVATE_KEY" ]; then\n 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)\nfi\n\nSHORT_ID=$(od -An -tx1 -N8 /dev/urandom | tr -d " \\n")\nCLIENT_ID=$(cat /proc/sys/kernel/random/uuid)\n\nSERVER_NAME="${SERVER_NAME:-www.googletagmanager.com}"\nFINGERPRINT="${FINGERPRINT:-chrome}"\nSPIDER_X="${SPIDER_X:-/}"\n\ndocker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true\nmkdir -p /opt/amnezia/xray\n\ncat > /opt/amnezia/xray/server.json << EOF\n{\n "log": { "loglevel": "warning" },\n "inbounds": [\n {\n "listen": "0.0.0.0",\n "port": ${XRAY_PORT},\n "protocol": "vless",\n "settings": {\n "clients": [ { "id": "${CLIENT_ID}" } ],\n "decryption": "none"\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": {\n "show": false,\n "dest": "${SERVER_NAME}:443",\n "xver": 0,\n "serverNames": [ "${SERVER_NAME}" ],\n "privateKey": "${PRIVATE_KEY}",\n "shortIds": [ "${SHORT_ID}" ],\n "fingerprint": "${FINGERPRINT}",\n "spiderX": "${SPIDER_X}"\n }\n }\n }\n ],\n "outbounds": [ { "protocol": "freedom", "tag": "direct" } ]\n}\nEOF\n\n# Start container\ndocker run -d \\\n --name "$CONTAINER_NAME" \\\n --restart always \\\n -p "${XRAY_PORT}:${XRAY_PORT}" \\\n -v /opt/amnezia/xray:/opt/amnezia/xray \\\n teddysun/xray xray run -c /opt/amnezia/xray/server.json\n\nsleep 2\n\n# Output configuration\necho "XrayPort: ${XRAY_PORT}"\necho "Port: ${XRAY_PORT}"\necho "ClientID: ${CLIENT_ID}"\necho "PublicKey: ${PUBLIC_KEY}"\necho "PrivateKey: ${PRIVATE_KEY}"\necho "ShortID: ${SHORT_ID}"\necho "ServerName: ${SERVER_NAME}"\necho "ContainerName: ${CONTAINER_NAME}"' +WHERE slug = 'xray-vless'; \ No newline at end of file diff --git a/migrations/030_xray_default_port_443.sql b/migrations/030_xray_default_port_443.sql new file mode 100644 index 0000000..ef55b02 --- /dev/null +++ b/migrations/030_xray_default_port_443.sql @@ -0,0 +1,3 @@ +UPDATE protocols +SET install_script = '#!/bin/bash\n\nset -euo pipefail\nset -x\n\nCONTAINER_NAME="${CONTAINER_NAME:-amnezia-xray}"\nXRAY_PORT=${SERVER_PORT:-443}\n\n# Ensure image present to avoid noisy pull output\ndocker pull teddysun/xray >/dev/null 2>&1 || true\n\n# Generate keys\nGEN=$(docker run --rm --entrypoint /usr/bin/xray teddysun/xray x25519 2>/dev/null || true)\nPRIVATE_KEY=$(printf "%s\\n" "$GEN" | sed -n -E "s/^[Pp]rivate[[:space:]]*[Kk]ey:[[:space:]]*(.*)$/\\1/p" | tr -d " \\t\\r\\n")\nPUBLIC_KEY=$(printf "%s\\n" "$GEN" | sed -n -E "s/^[Pp]ublic[[:space:]]*[Kk]ey:[[:space:]]*(.*)$/\\1/p" | tr -d " \\t\\r\\n")\n\nif [ -z "$PUBLIC_KEY" ] && [ -n "$PRIVATE_KEY" ]; then\n 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)\nfi\n\nSHORT_ID=$(od -An -tx1 -N8 /dev/urandom | tr -d " \\n")\nCLIENT_ID=$(cat /proc/sys/kernel/random/uuid)\n\nSERVER_NAME="${SERVER_NAME:-www.googletagmanager.com}"\nFINGERPRINT="${FINGERPRINT:-chrome}"\nSPIDER_X="${SPIDER_X:-/}"\n\ndocker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true\nmkdir -p /opt/amnezia/xray\n\ncat > /opt/amnezia/xray/server.json << EOF\n{\n "log": { "loglevel": "warning" },\n "inbounds": [\n {\n "listen": "0.0.0.0",\n "port": ${XRAY_PORT},\n "protocol": "vless",\n "settings": {\n "clients": [ { "id": "${CLIENT_ID}" } ],\n "decryption": "none"\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": {\n "show": false,\n "dest": "${SERVER_NAME}:443",\n "xver": 0,\n "serverNames": [ "${SERVER_NAME}" ],\n "privateKey": "${PRIVATE_KEY}",\n "shortIds": [ "${SHORT_ID}" ],\n "fingerprint": "${FINGERPRINT}",\n "spiderX": "${SPIDER_X}"\n }\n }\n }\n ],\n "outbounds": [ { "protocol": "freedom", "tag": "direct" } ]\n}\nEOF\n\n# Start container\ndocker run -d \\\n --name "$CONTAINER_NAME" \\\n --restart always \\\n -p "${XRAY_PORT}:${XRAY_PORT}" \\\n -v /opt/amnezia/xray:/opt/amnezia/xray \\\n teddysun/xray xray run -c /opt/amnezia/xray/server.json\n\nsleep 2\n\n# Output configuration\necho "XrayPort: ${XRAY_PORT}"\necho "Port: ${XRAY_PORT}"\necho "ClientID: ${CLIENT_ID}"\necho "PublicKey: ${PUBLIC_KEY}"\necho "PrivateKey: ${PRIVATE_KEY}"\necho "ShortID: ${SHORT_ID}"\necho "ServerName: ${SERVER_NAME}"\necho "ContainerName: ${CONTAINER_NAME}"' +WHERE slug = 'xray-vless'; \ No newline at end of file diff --git a/migrations/031_add_qr_code_templates.sql b/migrations/031_add_qr_code_templates.sql new file mode 100644 index 0000000..c9d03cc --- /dev/null +++ b/migrations/031_add_qr_code_templates.sql @@ -0,0 +1,49 @@ +ALTER TABLE protocols ADD COLUMN qr_code_template MEDIUMTEXT DEFAULT NULL; +ALTER TABLE protocols ADD COLUMN qr_code_format VARCHAR(50) DEFAULT 'amnezia_compressed'; + +-- Update AmneziaWG and WireGuard +UPDATE protocols SET qr_code_template = '{ + "containers": [ + { + "awg": { + "H1": "{{H1}}", + "H2": "{{H2}}", + "H3": "{{H3}}", + "H4": "{{H4}}", + "Jc": "{{Jc}}", + "Jmax": "{{Jmax}}", + "Jmin": "{{Jmin}}", + "S1": "{{S1}}", + "S2": "{{S2}}", + "last_config": {{last_config_json}}, + "port": "{{port}}", + "transport_proto": "udp" + }, + "container": "amnezia-awg" + } + ], + "defaultContainer": "amnezia-awg", + "description": "{{description}}", + "dns1": "{{dns1}}", + "dns2": "{{dns2}}", + "hostName": "{{hostName}}" +}' WHERE slug IN ('amnezia-wg', 'wireguard', 'amnezia-wg-advanced'); + +-- Update XRay +UPDATE protocols SET qr_code_template = '{ + "containers": [ + { + "container": "amnezia-xray", + "xray": { + "last_config": {{last_config_json}}, + "port": "{{port}}", + "transport_proto": "tcp" + } + } + ], + "defaultContainer": "amnezia-xray", + "description": "{{description}}", + "dns1": "1.1.1.1", + "dns2": "1.0.0.1", + "hostName": "{{hostName}}" +}' WHERE slug LIKE '%xray%'; diff --git a/migrations/032_add_qr_code_translations.sql b/migrations/032_add_qr_code_translations.sql new file mode 100644 index 0000000..87f15a1 --- /dev/null +++ b/migrations/032_add_qr_code_translations.sql @@ -0,0 +1,10 @@ +-- Add translations for QR code template UI +INSERT INTO translations (locale, category, key_name, translation) VALUES +('en', 'protocols', 'qr_code_template', 'QR Code Template'), +('en', 'protocols', 'qr_code_format', 'QR Code Format'), +('en', 'protocols', 'qr_code_format_help', 'Select the format for the QR code payload. "Amnezia Compressed" uses the legacy Qt/QDataStream format. "Raw Content" uses the template output directly.'), +('en', 'protocols', 'qr_code_template_help', 'Template for the QR code payload. Use {{last_config_json}} to include the full configuration as a JSON object.'), +('en', 'protocols', 'variable_last_config_json_help', 'Full configuration as a JSON object (required for Amnezia format)'), +('en', 'protocols', 'plus_all_output_variables', 'Plus all variables from the Output Template section'), +('en', 'ai', 'prompt_placeholder_qr_template', 'Describe how the QR code payload should be structured (e.g., "Standard WireGuard config format" or "JSON with specific fields")') +ON DUPLICATE KEY UPDATE translation = VALUES(translation); diff --git a/migrations/033_add_protocol_editor_translations.sql b/migrations/033_add_protocol_editor_translations.sql new file mode 100644 index 0000000..645e1b6 --- /dev/null +++ b/migrations/033_add_protocol_editor_translations.sql @@ -0,0 +1,70 @@ +-- Add Russian translations for Protocol Editor +INSERT INTO translations (locale, category, key_name, translation) VALUES +('ru', 'protocols', 'edit_protocol', 'Редактирование протокола'), +('ru', 'protocols', 'create_protocol', 'Создание протокола'), +('ru', 'protocols', 'edit_protocol_description', 'Изменение настроек и скриптов протокола'), +('ru', 'protocols', 'create_protocol_description', 'Добавление нового протокола в систему'), +('ru', 'protocols', 'back_to_protocols', 'К списку протоколов'), +('ru', 'protocols', 'basic_information', 'Основная информация'), +('ru', 'protocols', 'name_label', 'Название'), +('ru', 'protocols', 'name_help', 'Отображаемое имя протокола'), +('ru', 'protocols', 'slug_label', 'Слаг (ID)'), +('ru', 'protocols', 'slug_help', 'Уникальный идентификатор (латиница, цифры, дефис)'), +('ru', 'protocols', 'description_help', 'Краткое описание протокола'), +('ru', 'protocols', 'installation_script', 'Скрипт установки'), +('ru', 'protocols', 'install_script_help', 'Bash скрипт, который будет выполнен при установке протокола'), +('ru', 'protocols', 'uninstallation_script', 'Скрипт удаления'), +('ru', 'protocols', 'uninstall_script_help', 'Bash скрипт, который будет выполнен при удалении протокола'), +('ru', 'protocols', 'test_install', 'Тест установки'), +('ru', 'protocols', 'test_uninstall', 'Тест удаления'), +('ru', 'protocols', 'testing_on_ubuntu22', 'Тестирование на Ubuntu 22.04 (Docker)'), +('ru', 'protocols', 'test_result', 'Результат выполнения'), +('ru', 'protocols', 'client_output_preview', 'Предпросмотр конфига клиента'), +('ru', 'protocols', 'output_template', 'Шаблон конфигурации'), +('ru', 'protocols', 'output_template_help', 'Шаблон для генерации файла конфигурации клиента. Используйте переменные {{variable}}'), +('ru', 'protocols', 'available_variables', 'Доступные переменные'), +('ru', 'protocols', 'variable_private_key_help', 'Приватный ключ клиента'), +('ru', 'protocols', 'variable_public_key_help', 'Публичный ключ сервера'), +('ru', 'protocols', 'variable_client_ip_help', 'IP-адрес клиента'), +('ru', 'protocols', 'variable_server_host_help', 'Хост сервера (IP или домен)'), +('ru', 'protocols', 'variable_server_port_help', 'Порт сервера'), +('ru', 'protocols', 'variable_preshared_key_help', 'Дополнительный ключ шифрования (PSK)'), +('ru', 'protocols', 'variable_last_config_json_help', 'Полная конфигурация в формате JSON (для Amnezia)'), +('ru', 'protocols', 'plus_all_output_variables', 'Плюс все переменные из шаблона конфигурации'), +('ru', 'protocols', 'qr_code_template', 'Шаблон QR-кода'), +('ru', 'protocols', 'qr_code_template_help', 'Шаблон для формирования содержимого QR-кода'), +('ru', 'protocols', 'qr_code_format', 'Формат QR-кода'), +('ru', 'protocols', 'qr_code_format_help', 'Выберите формат данных в QR-коде'), +('ru', 'protocols', 'password_generation', 'Генерация пароля'), +('ru', 'protocols', 'password_command_help', 'Команда для генерации пароля/ключа (выполняется перед установкой)'), +('ru', 'protocols', 'ubuntu_compatible', 'Совместим с Ubuntu'), +('ru', 'protocols', 'active_label', 'Активен'), +('ru', 'protocols', 'update_protocol', 'Обновить протокол'), +('ru', 'protocols', 'save_protocol', 'Сохранить протокол'), +('ru', 'protocols', 'please_fill_required_fields', 'Пожалуйста, заполните обязательные поля'), +('ru', 'protocols', 'invalid_slug_format', 'Неверный формат слага'), +('ru', 'ai', 'get_ai_help', 'Помощь AI'), +('ru', 'ai', 'assistant', 'AI Ассистент'), +('ru', 'ai', 'select_model', 'Выберите модель'), +('ru', 'ai', 'model_gpt35_turbo', 'GPT-3.5 Turbo'), +('ru', 'ai', 'model_gpt4', 'GPT-4'), +('ru', 'ai', 'model_claude3_haiku', 'Claude 3 Haiku'), +('ru', 'ai', 'model_claude3_sonnet', 'Claude 3 Sonnet'), +('ru', 'ai', 'custom_model_placeholder', 'Или введите имя модели вручную'), +('ru', 'ai', 'check_availability', 'Проверить'), +('ru', 'ai', 'protocol_type', 'Тип протокола'), +('ru', 'ai', 'general_vpn', 'Общий VPN'), +('ru', 'ai', 'describe_requirements', 'Опишите требования'), +('ru', 'ai', 'prompt_placeholder', 'Например: Скрипт для установки Shadowsocks на порт 8388...'), +('ru', 'ai', 'prompt_placeholder_template', 'Например: Конфиг в формате JSON с полями server, port, password...'), +('ru', 'ai', 'prompt_placeholder_qr_template', 'Например: Ссылка вида vless://uuid@host:port...'), +('ru', 'ai', 'prompt_placeholder_uninstall', 'Например: Остановить docker контейнер и удалить файлы...'), +('ru', 'ai', 'generate_script', 'Сгенерировать'), +('ru', 'ai', 'generating_script', 'Генерация...'), +('ru', 'ai', 'generated_script', 'Результат'), +('ru', 'ai', 'suggestions', 'Предложения'), +('ru', 'ai', 'apply_to_current_protocol', 'Применить'), +('ru', 'ai', 'confirm_apply_script', 'Это заменит текущее содержимое поля. Продолжить?'), +('ru', 'ai', 'please_enter_requirements', 'Пожалуйста, введите требования'), +('ru', 'ai', 'error_generating_script', 'Ошибка генерации') +ON DUPLICATE KEY UPDATE translation = VALUES(translation); diff --git a/migrations/034_add_show_text_content.sql b/migrations/034_add_show_text_content.sql new file mode 100644 index 0000000..9312ea2 --- /dev/null +++ b/migrations/034_add_show_text_content.sql @@ -0,0 +1,10 @@ +-- Add show_text_content column to protocols +ALTER TABLE protocols ADD COLUMN show_text_content TINYINT(1) NOT NULL DEFAULT 0; + +-- Add translations +INSERT INTO translations (locale, category, key_name, translation) VALUES +('en', 'protocols', 'show_text_content', 'Show text content on client page'), +('en', 'protocols', 'qr_code_format_text', 'Simple Text'), +('ru', 'protocols', 'show_text_content', 'Показывать текстовое содержимое на странице клиента'), +('ru', 'protocols', 'qr_code_format_text', 'Простой текст') +ON DUPLICATE KEY UPDATE translation = VALUES(translation); diff --git a/migrations/035_restore_awg_script.sql b/migrations/035_restore_awg_script.sql new file mode 100644 index 0000000..66ffa82 --- /dev/null +++ b/migrations/035_restore_awg_script.sql @@ -0,0 +1,188 @@ +UPDATE protocols SET + install_script = '#!/bin/bash +set -euo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-amnezia-awg}" +PORT_RANGE_START=${PORT_RANGE_START:-30000} +PORT_RANGE_END=${PORT_RANGE_END:-65000} +VPN_PORT=${VPN_PORT:-$((RANDOM % (PORT_RANGE_END - PORT_RANGE_START + 1) + PORT_RANGE_START))} +MTU=${MTU:-1420} + +# Ensure host directory exists for persistence +mkdir -p /opt/amnezia/awg + +# Function to check if container is healthy +check_container() { + local status + status=$(docker inspect --format="{{.State.Status}}" "$CONTAINER_NAME" 2>/dev/null || echo "missing") + if [ "$status" = "running" ]; then + return 0 + elif [ "$status" = "restarting" ]; then + return 2 # Restarting loop + else + return 1 # Stopped or missing + fi +} + +# Validate existing config +if [ -f /opt/amnezia/awg/wg0.conf ]; then + # Check for unexpanded variables + if grep -Fq ''$PRIVATE_KEY'' /opt/amnezia/awg/wg0.conf; then + echo "Detected broken configuration (unexpanded variables). Removing..." + rm -f /opt/amnezia/awg/wg0.conf + fi + # Check for invalid parameters S3/S4 + if grep -Eiq "^S3[[:space:]]*=" /opt/amnezia/awg/wg0.conf || grep -Eiq "^S4[[:space:]]*=" /opt/amnezia/awg/wg0.conf; then + echo "Detected invalid parameters (S3/S4). Removing config to regenerate..." + rm -f /opt/amnezia/awg/wg0.conf + fi + # Check for hex H-params + if grep -Eiq "^H[1-4][[:space:]]*=[[:space:]]*0x" /opt/amnezia/awg/wg0.conf; then + echo "Detected invalid hex parameters (H1-H4). Removing config to regenerate..." + rm -f /opt/amnezia/awg/wg0.conf + fi + # Check for empty PublicKey + if grep -Eiq "^PublicKey[[:space:]]*=[[:space:]]*$" /opt/amnezia/awg/wg0.conf; then + echo "Detected empty PublicKey. Removing config to regenerate..." + rm -f /opt/amnezia/awg/wg0.conf + fi +fi + +# Check for existing configuration on HOST first (preferred persistence) +if [ -f /opt/amnezia/awg/wg0.conf ]; then + echo "Found existing configuration on host." + + PORT=$(grep -E "^ListenPort" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + VPN_PORT=${PORT:-$VPN_PORT} + + STATUS=0 + check_container || STATUS=$? + + if [ $STATUS -eq 2 ]; then + echo "Container is in restart loop. Recreating..." + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + STATUS=1 + elif [ $STATUS -eq 0 ]; then + echo "Container is running." + fi + + # Ensure container is running + if [ $STATUS -ne 0 ]; then + echo "Starting container..." + docker run -d --name "$CONTAINER_NAME" --restart always --privileged --cap-add=NET_ADMIN --cap-add=SYS_MODULE -p "${VPN_PORT}:${VPN_PORT}/udp" -v /lib/modules:/lib/modules -v /opt/amnezia/awg:/opt/amnezia/awg amneziavpn/amnezia-wg:latest sh -c "while [ ! -f /opt/amnezia/awg/wg0.conf ]; do sleep 1; done; wg-quick up /opt/amnezia/awg/wg0.conf && sleep infinity" + sleep 2 + fi + + PSK=$(cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true) + if [ -z "$PSK" ]; then + PSK=$(grep -E "^PresharedKey" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + fi + PUBKEY=$(cat /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true) + if [ -z "$PUBKEY" ]; then + PRIVKEY=$(cat /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true) + if [ -n "$PRIVKEY" ]; then + PUBKEY=$(echo "$PRIVKEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) + fi + fi + + echo "Using existing AmneziaWG configuration" + echo "Port: $VPN_PORT" + if [ -n "${PUBKEY:-}" ]; then echo "Server Public Key: $PUBKEY"; fi + if [ -n "${PSK:-}" ]; then echo "PresharedKey: $PSK"; fi + exit 0 +fi + +# Rescue logic +STATUS=0 +check_container || STATUS=$? +HAS_RESCUED=0 + +if [ $STATUS -eq 2 ] || [ $STATUS -eq 0 ]; then + echo "Checking for config in existing container..." + docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true + + if docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wg0.conf /opt/amnezia/awg/wg0.conf 2>/dev/null; then + # Validate rescued config + IS_BROKEN=0 + if grep -Fq ''$PRIVATE_KEY'' /opt/amnezia/awg/wg0.conf; then IS_BROKEN=1; fi + if grep -Eiq "^S3[[:space:]]*=" /opt/amnezia/awg/wg0.conf; then IS_BROKEN=1; fi + if grep -Eiq "^H[1-4][[:space:]]*=[[:space:]]*0x" /opt/amnezia/awg/wg0.conf; then IS_BROKEN=1; fi + if grep -Eiq "^PublicKey[[:space:]]*=[[:space:]]*$" /opt/amnezia/awg/wg0.conf; then IS_BROKEN=1; fi + + if [ "$IS_BROKEN" = "1" ]; then + echo "Rescued config is broken. Discarding." + rm -f /opt/amnezia/awg/wg0.conf + else + echo "Rescued config from container." + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_psk.key /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_server_public_key.key /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_server_private_key.key /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true + HAS_RESCUED=1 + fi + fi + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true +fi + +# Start container (Fresh or Rescued) +docker run -d --name "$CONTAINER_NAME" --restart always --privileged --cap-add=NET_ADMIN --cap-add=SYS_MODULE -p "${VPN_PORT}:${VPN_PORT}/udp" -v /lib/modules:/lib/modules -v /opt/amnezia/awg:/opt/amnezia/awg amneziavpn/amnezia-wg:latest sh -c "while [ ! -f /opt/amnezia/awg/wg0.conf ]; do sleep 1; done; wg-quick up /opt/amnezia/awg/wg0.conf && sleep infinity" + +sleep 2 + +if [ "$HAS_RESCUED" = "1" ]; then + # Extract and exit + PORT=$(grep -E "^ListenPort" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + PSK=$(cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true) + if [ -z "$PSK" ]; then + PSK=$(grep -E "^PresharedKey" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + fi + PUBKEY=$(cat /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true) + if [ -z "$PUBKEY" ]; then + PRIVKEY=$(cat /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true) + if [ -n "$PRIVKEY" ]; then + PUBKEY=$(echo "$PRIVKEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) + fi + fi + + echo "Using existing AmneziaWG configuration" + echo "Port: ${PORT:-$VPN_PORT}" + if [ -n "${PUBKEY:-}" ]; then echo "Server Public Key: $PUBKEY"; fi + if [ -n "${PSK:-}" ]; then echo "PresharedKey: $PSK"; fi + exit 0 +fi + +# Generate new config +PRIVATE_KEY=$(docker exec "$CONTAINER_NAME" wg genkey) +PUBLIC_KEY=$(echo "$PRIVATE_KEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) +PRESHARED_KEY=$(docker exec "$CONTAINER_NAME" wg genpsk) + +# Use WG_CONF delimiter to avoid EOF replacement in PHP +cat > /opt/amnezia/awg/wg0.conf << WG_CONF +[Interface] +PrivateKey = $PRIVATE_KEY +Address = 10.8.1.1/24 +ListenPort = $VPN_PORT +MTU = $MTU +Jc = 5 +Jmin = 100 +Jmax = 200 +S1 = 50 +S2 = 100 +H1 = 1 +H2 = 2 +H3 = 3 +H4 = 4 +PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE +PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE +WG_CONF + +echo "$PRIVATE_KEY" > /opt/amnezia/awg/wireguard_server_private_key.key +echo "$PUBLIC_KEY" > /opt/amnezia/awg/wireguard_server_public_key.key +echo "$PRESHARED_KEY" > /opt/amnezia/awg/wireguard_psk.key +echo "[]" > /opt/amnezia/awg/clientsTable + +echo "AmneziaWG Advanced installed successfully" +echo "Port: $VPN_PORT" +echo "Server Public Key: $PUBLIC_KEY" +echo "PresharedKey: $PRESHARED_KEY" +' +WHERE slug = 'amnezia-wg-advanced'; diff --git a/migrations/036_fix_awg_script_output.sql b/migrations/036_fix_awg_script_output.sql new file mode 100644 index 0000000..35550c4 --- /dev/null +++ b/migrations/036_fix_awg_script_output.sql @@ -0,0 +1,288 @@ +UPDATE protocols SET + install_script = '#!/bin/bash +set -euo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-amnezia-awg}" +PORT_RANGE_START=${PORT_RANGE_START:-30000} +PORT_RANGE_END=${PORT_RANGE_END:-65000} +VPN_PORT=${VPN_PORT:-$((RANDOM % (PORT_RANGE_END - PORT_RANGE_START + 1) + PORT_RANGE_START))} +MTU=${MTU:-1420} + +# Ensure host directory exists for persistence +mkdir -p /opt/amnezia/awg + +# Function to check if container is healthy +check_container() { + local status + status=$(docker inspect --format="{{.State.Status}}" "$CONTAINER_NAME" 2>/dev/null || echo "missing") + if [ "$status" = "running" ]; then + return 0 + elif [ "$status" = "restarting" ]; then + return 2 # Restarting loop + else + return 1 # Stopped or missing + fi +} + +# Validate existing config +if [ -f /opt/amnezia/awg/wg0.conf ]; then + # Check for unexpanded variables + if grep -Fq ''$PRIVATE_KEY'' /opt/amnezia/awg/wg0.conf; then + echo "Detected broken configuration (unexpanded variables). Removing..." + rm -f /opt/amnezia/awg/wg0.conf + fi + # Check for invalid parameters S3/S4 + if grep -Eiq "^S3[[:space:]]*=" /opt/amnezia/awg/wg0.conf || grep -Eiq "^S4[[:space:]]*=" /opt/amnezia/awg/wg0.conf; then + echo "Detected invalid parameters (S3/S4). Removing config to regenerate..." + rm -f /opt/amnezia/awg/wg0.conf + fi + # Check for hex H-params + if grep -Eiq "^H[1-4][[:space:]]*=[[:space:]]*0x" /opt/amnezia/awg/wg0.conf; then + echo "Detected invalid hex parameters (H1-H4). Removing config to regenerate..." + rm -f /opt/amnezia/awg/wg0.conf + fi + # Check for empty PublicKey + if grep -Eiq "^PublicKey[[:space:]]*=[[:space:]]*$" /opt/amnezia/awg/wg0.conf; then + echo "Detected empty PublicKey. Removing config to regenerate..." + rm -f /opt/amnezia/awg/wg0.conf + fi +fi + +# Check for existing configuration on HOST first (preferred persistence) +if [ -f /opt/amnezia/awg/wg0.conf ]; then + echo "Found existing configuration on host." + + PORT=$(grep -E "^ListenPort" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + VPN_PORT=${PORT:-$VPN_PORT} + + STATUS=0 + check_container || STATUS=$? + + if [ $STATUS -eq 2 ]; then + echo "Container is in restart loop. Recreating..." + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + STATUS=1 + elif [ $STATUS -eq 0 ]; then + echo "Container is running." + fi + + # Ensure container is running + if [ $STATUS -ne 0 ]; then + echo "Starting container..." + docker run -d --name "$CONTAINER_NAME" --restart always --privileged --cap-add=NET_ADMIN --cap-add=SYS_MODULE -p "${VPN_PORT}:${VPN_PORT}/udp" -v /lib/modules:/lib/modules -v /opt/amnezia/awg:/opt/amnezia/awg amneziavpn/amnezia-wg:latest sh -c "while [ ! -f /opt/amnezia/awg/wg0.conf ]; do sleep 1; done; wg-quick up /opt/amnezia/awg/wg0.conf && sleep infinity" + sleep 2 + fi + + PSK=$(cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true) + if [ -z "$PSK" ]; then + PSK=$(grep -E "^PresharedKey" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + fi + PUBKEY=$(cat /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true) + if [ -z "$PUBKEY" ]; then + PRIVKEY=$(cat /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true) + if [ -n "$PRIVKEY" ]; then + PUBKEY=$(echo "$PRIVKEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) + fi + fi + + echo "Using existing AmneziaWG configuration" + echo "Port: $VPN_PORT" + if [ -n "${PUBKEY:-}" ]; then echo "Server Public Key: $PUBKEY"; fi + if [ -n "${PSK:-}" ]; then echo "PresharedKey: $PSK"; fi + + # Output variables for preview + echo "Variable: server_port=$VPN_PORT" + echo "Variable: server_public_key=$PUBKEY" + echo "Variable: preshared_key=$PSK" + echo "Variable: server_host=YOUR_IP" + + # Dummy client vars for preview + CLIENT_PRIV_KEY=$(docker exec "$CONTAINER_NAME" wg genkey) + echo "Variable: private_key=$CLIENT_PRIV_KEY" + echo "Variable: client_ip=10.8.1.2" + echo "Variable: dns_servers=1.1.1.1" + + # Obfuscation params (extract from config if possible, else defaults) + JC=$(grep -E "^Jc" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + JMIN=$(grep -E "^Jmin" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + JMAX=$(grep -E "^Jmax" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + S1=$(grep -E "^S1" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + S2=$(grep -E "^S2" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + H1=$(grep -E "^H1" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + H2=$(grep -E "^H2" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + H3=$(grep -E "^H3" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + H4=$(grep -E "^H4" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + + echo "Variable: Jc=${JC:-5}" + echo "Variable: JC=${JC:-5}" + echo "Variable: Jmin=${JMIN:-100}" + echo "Variable: JMIN=${JMIN:-100}" + echo "Variable: Jmax=${JMAX:-200}" + echo "Variable: JMAX=${JMAX:-200}" + echo "Variable: S1=${S1:-50}" + echo "Variable: S2=${S2:-100}" + echo "Variable: H1=${H1:-1}" + echo "Variable: H2=${H2:-2}" + echo "Variable: H3=${H3:-3}" + echo "Variable: H4=${H4:-4}" + + exit 0 +fi + +# Rescue logic +STATUS=0 +check_container || STATUS=$? +HAS_RESCUED=0 + +if [ $STATUS -eq 2 ] || [ $STATUS -eq 0 ]; then + echo "Checking for config in existing container..." + docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true + + if docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wg0.conf /opt/amnezia/awg/wg0.conf 2>/dev/null; then + # Validate rescued config + IS_BROKEN=0 + if grep -Fq ''$PRIVATE_KEY'' /opt/amnezia/awg/wg0.conf; then IS_BROKEN=1; fi + if grep -Eiq "^S3[[:space:]]*=" /opt/amnezia/awg/wg0.conf; then IS_BROKEN=1; fi + if grep -Eiq "^H[1-4][[:space:]]*=[[:space:]]*0x" /opt/amnezia/awg/wg0.conf; then IS_BROKEN=1; fi + if grep -Eiq "^PublicKey[[:space:]]*=[[:space:]]*$" /opt/amnezia/awg/wg0.conf; then IS_BROKEN=1; fi + + if [ "$IS_BROKEN" = "1" ]; then + echo "Rescued config is broken. Discarding." + rm -f /opt/amnezia/awg/wg0.conf + else + echo "Rescued config from container." + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_psk.key /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_server_public_key.key /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true + docker cp "$CONTAINER_NAME":/opt/amnezia/awg/wireguard_server_private_key.key /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true + HAS_RESCUED=1 + fi + fi + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true +fi + +# Start container (Fresh or Rescued) +docker run -d --name "$CONTAINER_NAME" --restart always --privileged --cap-add=NET_ADMIN --cap-add=SYS_MODULE -p "${VPN_PORT}:${VPN_PORT}/udp" -v /lib/modules:/lib/modules -v /opt/amnezia/awg:/opt/amnezia/awg amneziavpn/amnezia-wg:latest sh -c "while [ ! -f /opt/amnezia/awg/wg0.conf ]; do sleep 1; done; wg-quick up /opt/amnezia/awg/wg0.conf && sleep infinity" + +sleep 2 + +if [ "$HAS_RESCUED" = "1" ]; then + # Extract and exit + PORT=$(grep -E "^ListenPort" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + PSK=$(cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true) + if [ -z "$PSK" ]; then + PSK=$(grep -E "^PresharedKey" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + fi + PUBKEY=$(cat /opt/amnezia/awg/wireguard_server_public_key.key 2>/dev/null || true) + if [ -z "$PUBKEY" ]; then + PRIVKEY=$(cat /opt/amnezia/awg/wireguard_server_private_key.key 2>/dev/null || true) + if [ -n "$PRIVKEY" ]; then + PUBKEY=$(echo "$PRIVKEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) + fi + fi + + echo "Using existing AmneziaWG configuration" + echo "Port: ${PORT:-$VPN_PORT}" + if [ -n "${PUBKEY:-}" ]; then echo "Server Public Key: $PUBKEY"; fi + if [ -n "${PSK:-}" ]; then echo "PresharedKey: $PSK"; fi + + # Output variables for preview + echo "Variable: server_port=$VPN_PORT" + echo "Variable: server_public_key=$PUBKEY" + echo "Variable: preshared_key=$PSK" + echo "Variable: server_host=YOUR_IP" + + # Dummy client vars for preview + CLIENT_PRIV_KEY=$(docker exec "$CONTAINER_NAME" wg genkey) + echo "Variable: private_key=$CLIENT_PRIV_KEY" + echo "Variable: client_ip=10.8.1.2" + echo "Variable: dns_servers=1.1.1.1" + + # Obfuscation params (extract from config if possible, else defaults) + JC=$(grep -E "^Jc" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + JMIN=$(grep -E "^Jmin" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + JMAX=$(grep -E "^Jmax" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + S1=$(grep -E "^S1" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + S2=$(grep -E "^S2" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + H1=$(grep -E "^H1" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + H2=$(grep -E "^H2" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + H3=$(grep -E "^H3" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + H4=$(grep -E "^H4" /opt/amnezia/awg/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + + echo "Variable: Jc=${JC:-5}" + echo "Variable: JC=${JC:-5}" + echo "Variable: JMIN=${JMIN:-100}" + echo "Variable: Jmin=${JMIN:-100}" + echo "Variable: JMAX=${JMAX:-200}" + echo "Variable: Jmax=${JMAX:-200}" + echo "Variable: S1=${S1:-50}" + echo "Variable: S2=${S2:-100}" + echo "Variable: H1=${H1:-1}" + echo "Variable: H2=${H2:-2}" + echo "Variable: H3=${H3:-3}" + echo "Variable: H4=${H4:-4}" + + exit 0 +fi + +# Generate new config +PRIVATE_KEY=$(docker exec "$CONTAINER_NAME" wg genkey) +PUBLIC_KEY=$(echo "$PRIVATE_KEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) +PRESHARED_KEY=$(docker exec "$CONTAINER_NAME" wg genpsk) + +# Use WG_CONF delimiter to avoid EOF replacement in PHP +cat > /opt/amnezia/awg/wg0.conf << WG_CONF +[Interface] +PrivateKey = $PRIVATE_KEY +Address = 10.8.1.1/24 +ListenPort = $VPN_PORT +MTU = $MTU +Jc = 5 +Jmin = 100 +Jmax = 200 +S1 = 50 +S2 = 100 +H1 = 1 +H2 = 2 +H3 = 3 +H4 = 4 +PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE +PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE +WG_CONF + +echo "$PRIVATE_KEY" > /opt/amnezia/awg/wireguard_server_private_key.key +echo "$PUBLIC_KEY" > /opt/amnezia/awg/wireguard_server_public_key.key +echo "$PRESHARED_KEY" > /opt/amnezia/awg/wireguard_psk.key +echo "[]" > /opt/amnezia/awg/clientsTable + +echo "AmneziaWG Advanced installed successfully" +echo "Port: $VPN_PORT" +echo "Server Public Key: $PUBLIC_KEY" +echo "PresharedKey: $PRESHARED_KEY" + +# Output variables for preview +echo "Variable: server_port=$VPN_PORT" +echo "Variable: server_public_key=$PUBLIC_KEY" +echo "Variable: preshared_key=$PRESHARED_KEY" +echo "Variable: server_host=YOUR_IP" + +# Dummy client vars for preview +CLIENT_PRIV_KEY=$(docker exec "$CONTAINER_NAME" wg genkey) +echo "Variable: private_key=$CLIENT_PRIV_KEY" +echo "Variable: client_ip=10.8.1.2" +echo "Variable: dns_servers=1.1.1.1" + +# Obfuscation params (hardcoded in new config) +echo "Variable: Jc=5" +echo "Variable: JC=5" +echo "Variable: Jmin=100" +echo "Variable: JMIN=100" +echo "Variable: Jmax=200" +echo "Variable: JMAX=200" +echo "Variable: S1=50" +echo "Variable: S2=100" +echo "Variable: H1=1" +echo "Variable: H2=2" +echo "Variable: H3=3" +echo "Variable: H4=4" +' +WHERE slug = 'amnezia-wg-advanced'; diff --git a/migrations/037_fix_awg_mtu_1280.sql b/migrations/037_fix_awg_mtu_1280.sql new file mode 100644 index 0000000..72d6a82 --- /dev/null +++ b/migrations/037_fix_awg_mtu_1280.sql @@ -0,0 +1,5 @@ +-- Fix AmneziaWG MTU to 1280 for better compatibility +-- This resolves connection issues with PPPoE, mobile networks, and tunnels +UPDATE protocols SET + install_script = REPLACE(install_script, 'MTU=${MTU:-1420}', 'MTU=${MTU:-1280}') +WHERE slug = 'amnezia-wg-advanced'; diff --git a/migrations/039_fix_awg_client_template.sql b/migrations/039_fix_awg_client_template.sql new file mode 100644 index 0000000..18459d1 --- /dev/null +++ b/migrations/039_fix_awg_client_template.sql @@ -0,0 +1,52 @@ +-- Fix AmneziaWG client config template: add MTU and use capital letter variables +UPDATE protocols SET + output_template = '[Interface] +PrivateKey = {{private_key}} +Address = {{client_ip}}/32 +DNS = {{dns_servers}} +MTU = 1280 +Jc = {{Jc}} +Jmin = {{Jmin}} +Jmax = {{Jmax}} +S1 = {{S1}} +S2 = {{S2}} +H1 = {{H1}} +H2 = {{H2}} +H3 = {{H3}} +H4 = {{H4}} + +[Peer] +PublicKey = {{server_public_key}} +PresharedKey = {{preshared_key}} +AllowedIPs = 0.0.0.0/0, ::/0 +Endpoint = {{server_host}}:{{server_port}} +PersistentKeepalive = 25' +WHERE slug = 'amnezia-wg-advanced'; + +-- Create capital letter variables that map to lowercase ones +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'Jc', 'number', '5', 'Junk packet count', false FROM protocols p WHERE p.slug='amnezia-wg-advanced' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='Jc'); + +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'Jmin', 'number', '100', 'Minimum junk packet size', false FROM protocols p WHERE p.slug='amnezia-wg-advanced' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='Jmin'); + +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'Jmax', 'number', '200', 'Maximum junk packet size', false FROM protocols p WHERE p.slug='amnezia-wg-advanced' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='Jmax'); + +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'S1', 'number', '50', 'Junk packet size 1', false FROM protocols p WHERE p.slug='amnezia-wg-advanced' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='S1'); + +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'S2', 'number', '100', 'Junk packet size 2', false FROM protocols p WHERE p.slug='amnezia-wg-advanced' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='S2'); + +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'H1', 'number', '1', 'Obfuscation header 1', false FROM protocols p WHERE p.slug='amnezia-wg-advanced' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='H1'); + +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'H2', 'number', '2', 'Obfuscation header 2', false FROM protocols p WHERE p.slug='amnezia-wg-advanced' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='H2'); + +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'H3', 'number', '3', 'Obfuscation header 3', false FROM protocols p WHERE p.slug='amnezia-wg-advanced' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='H3'); + +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'H4', 'number', '4', 'Obfuscation header 4', false FROM protocols p WHERE p.slug='amnezia-wg-advanced' AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id=p.id AND variable_name='H4'); diff --git a/migrations/040_remove_uppercase_variables.sql b/migrations/040_remove_uppercase_variables.sql new file mode 100644 index 0000000..acc3e44 --- /dev/null +++ b/migrations/040_remove_uppercase_variables.sql @@ -0,0 +1,17 @@ +-- Remove fully uppercase variable outputs from AmneziaWG install script +-- Keep only Jc, Jmin, Jmax format (first letter uppercase) +UPDATE protocols SET + install_script = REPLACE( + REPLACE( + REPLACE( + install_script, + 'echo "Variable: JC=${JC:-5}"', + '' + ), + 'echo "Variable: JMIN=${JMIN:-100}"', + '' + ), + 'echo "Variable: JMAX=${JMAX:-200}"', + '' + ) +WHERE slug = 'amnezia-wg-advanced'; diff --git a/migrations/041_add_ssh_key_column.sql b/migrations/041_add_ssh_key_column.sql new file mode 100644 index 0000000..09319fe --- /dev/null +++ b/migrations/041_add_ssh_key_column.sql @@ -0,0 +1 @@ +ALTER TABLE vpn_servers ADD COLUMN ssh_key TEXT NULL; diff --git a/public/index.php b/public/index.php index e4c5f09..b67517a 100644 --- a/public/index.php +++ b/public/index.php @@ -4,6 +4,12 @@ * Main entry point */ +// Suppress errors for API endpoints to prevent HTML output +if (isset($_SERVER['REQUEST_URI']) && strpos($_SERVER['REQUEST_URI'], '/api/') !== false) { + @ini_set('display_errors', '0'); + error_reporting(0); +} + session_name(getenv('SESSION_NAME') ?: 'amnezia_panel_session'); session_start(); @@ -20,6 +26,10 @@ require_once __DIR__ . '/../inc/Translator.php'; require_once __DIR__ . '/../inc/JWT.php'; require_once __DIR__ . '/../inc/PanelImporter.php'; require_once __DIR__ . '/../inc/ServerMonitoring.php'; +require_once __DIR__ . '/../inc/BackupLibrary.php'; +require_once __DIR__ . '/../inc/InstallProtocolManager.php'; +require_once __DIR__ . '/../inc/ProtocolService.php'; +require_once __DIR__ . '/../inc/OpenRouterService.php'; // Load environment configuration Config::load(__DIR__ . '/../.env'); @@ -44,6 +54,7 @@ try { // Initialize translator Translator::init(); +InstallProtocolManager::ensureDefaults(); // Initialize template engine $user = Auth::user(); @@ -53,7 +64,8 @@ $appName = Config::get('APP_NAME', 'Amnezia VPN Panel'); * Helper function to authenticate user from JWT or session * Returns user array or null if unauthorized */ -function authenticateRequest(): ?array { +function authenticateRequest(): ?array +{ // Check JWT token in Authorization header $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; if ($authHeader && preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) { @@ -63,12 +75,12 @@ function authenticateRequest(): ?array { return $user; } } - + // Fallback to session if (isset($_SESSION['user_id'])) { return Auth::user(); } - + return null; } @@ -78,26 +90,29 @@ View::init(__DIR__ . '/../templates', [ 'current_language' => Translator::getCurrentLanguage(), 'languages' => Translator::getSupportedLanguages(), 'current_uri' => $_SERVER['REQUEST_URI'] ?? '/dashboard', - 't' => function($key, $params = []) { + 't' => function ($key, $params = []) { return Translator::t($key, $params); } ]); // Helper function for redirects -function redirect(string $to): void { +function redirect(string $to): void +{ header('Location: ' . $to); exit; } // Helper function to require authentication -function requireAuth(): void { +function requireAuth(): void +{ if (!Auth::check()) { redirect('/login'); } } // Helper function to require admin -function requireAdmin(): void { +function requireAdmin(): void +{ requireAuth(); if (!Auth::isAdmin()) { http_response_code(403); @@ -106,8 +121,30 @@ function requireAdmin(): void { } } +function debugRoutesEnabled(): bool +{ + $val = strtolower((string) (getenv('ENABLE_DEBUG_ROUTES') ?: '')); + return in_array($val, ['1', 'true', 'yes', 'on'], true); +} + +function requireDebugEnabledOrAdmin(): void +{ + requireAuth(); + + if (Auth::isAdmin()) { + return; + } + + if (!debugRoutesEnabled()) { + http_response_code(404); + echo 'Not Found'; + exit; + } +} + // Helper function to get authenticated user (JWT or session) -function getAuthUser(): ?array { +function getAuthUser(): ?array +{ // Try JWT first $token = JWT::getTokenFromHeader(); if ($token !== null) { @@ -116,26 +153,27 @@ function getAuthUser(): ?array { return $user; } } - + // Fall back to session if (Auth::check()) { return Auth::user(); } - + return null; } // Helper function to require authentication (JWT or session) for API -function requireApiAuth(): ?array { +function requireApiAuth(): ?array +{ $user = getAuthUser(); - + if ($user === null) { http_response_code(401); header('Content-Type: application/json'); echo json_encode(['error' => 'Authentication required']); return null; } - + return $user; } @@ -162,11 +200,11 @@ Router::get('/login', function () { Router::post('/login', function () { $email = trim($_POST['email'] ?? ''); $password = $_POST['password'] ?? ''; - + if (Auth::login($email, $password)) { redirect('/dashboard'); } - + View::render('login.twig', ['error' => 'Invalid credentials']); }); @@ -182,17 +220,17 @@ Router::post('/register', function () { $name = trim($_POST['name'] ?? ''); $email = trim($_POST['email'] ?? ''); $password = $_POST['password'] ?? ''; - + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { View::render('register.twig', ['error' => 'Invalid email address']); return; } - + if (strlen($password) < 6) { View::render('register.twig', ['error' => 'Password must be at least 6 characters']); return; } - + try { $success = Auth::register($name, $email, $password); if ($success) { @@ -202,7 +240,7 @@ Router::post('/register', function () { } catch (Throwable $e) { // Email already exists or other error } - + View::render('register.twig', ['error' => 'Registration failed. Email may already be in use.']); }); @@ -220,53 +258,216 @@ Router::get('/logout', function () { Router::get('/dashboard', function () { requireAuth(); $user = Auth::user(); - + // Get user's servers $servers = VpnServer::listByUser($user['id']); - + // Get user's clients $clients = VpnClient::listByUser($user['id']); - + View::render('dashboard.twig', [ 'servers' => $servers, 'clients' => $clients, ]); }); +Router::get('/tools/qr-decode', function () { + requireAuth(); + View::render('tools/qr_decode.twig'); +}); + // Servers list Router::get('/servers', function () { requireAuth(); $user = Auth::user(); - - $servers = Auth::isAdmin() - ? VpnServer::listAll() + + $servers = Auth::isAdmin() + ? VpnServer::listAll() : VpnServer::listByUser($user['id']); - + View::render('servers/index.twig', ['servers' => $servers]); }); // Create server page Router::get('/servers/create', function () { requireAuth(); - View::render('servers/create.twig'); + $protocols = InstallProtocolManager::listActive(); + $defaultProtocol = !empty($protocols) ? ($protocols[0]['slug'] ?? InstallProtocolManager::getDefaultSlug()) : InstallProtocolManager::getDefaultSlug(); + View::render('servers/create.twig', [ + 'selected_mode' => 'manual', + 'form_data' => [], + 'protocols' => $protocols, + 'default_protocol' => $defaultProtocol + ]); }); // Create server action Router::post('/servers/create', function () { requireAuth(); $user = Auth::user(); - - $name = trim($_POST['name'] ?? ''); - $host = trim($_POST['host'] ?? ''); - $port = (int)($_POST['port'] ?? 22); - $username = trim($_POST['username'] ?? 'root'); - $password = $_POST['password'] ?? ''; - - if (empty($name) || empty($host) || empty($password)) { - View::render('servers/create.twig', ['error' => 'All fields are required']); + $creationMode = $_POST['creation_mode'] ?? 'manual'; + $formData = $_POST; + $formData['backup_upload_type'] = $_POST['backup_upload_type'] ?? 'auto'; + $formData['backup_server_index'] = $_POST['backup_server_index'] ?? ''; + $protocols = InstallProtocolManager::listActive(); + $defaultProtocol = InstallProtocolManager::getDefaultSlug(); + $formData['install_protocol'] = $_POST['install_protocol'] ?? $defaultProtocol; + + if ($creationMode === 'backup') { + $token = $_POST['backup_token'] ?? ''; + $serverIndexRaw = $_POST['backup_server_index'] ?? ''; + $serverIndex = $serverIndexRaw === '' ? -1 : (int) $serverIndexRaw; + $uploadType = $_POST['backup_upload_type'] ?? 'auto'; + $serversMeta = []; + + if (isset($_FILES['backup_upload']) && $_FILES['backup_upload']['error'] === UPLOAD_ERR_OK) { + $originalName = $_FILES['backup_upload']['name'] ?? 'uploaded-backup.json'; + $tmpPath = $_FILES['backup_upload']['tmp_name']; + $storagePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'amnezia_backup_' . bin2hex(random_bytes(16)); + + if (!move_uploaded_file($tmpPath, $storagePath)) { + View::render('servers/create.twig', [ + 'error' => 'Failed to store uploaded backup file', + 'selected_mode' => 'backup', + 'form_data' => $formData, + 'protocols' => $protocols, + 'default_protocol' => $defaultProtocol + ]); + return; + } + + try { + $parsed = BackupParser::parse($storagePath); + if ($uploadType !== 'auto' && $parsed['type'] !== $uploadType) { + throw new Exception('Uploaded backup type does not match selection'); + } + } catch (Exception $e) { + @unlink($storagePath); + View::render('servers/create.twig', [ + 'error' => $e->getMessage(), + 'selected_mode' => 'backup', + 'form_data' => $formData, + 'protocols' => $protocols, + 'default_protocol' => $defaultProtocol + ]); + return; + } + + if ($token && BackupLibrary::isUploadToken($token)) { + BackupLibrary::forgetUpload($token); + } + + $uploadRecord = BackupLibrary::registerUploaded($originalName, $storagePath, $parsed); + $token = $uploadRecord['token']; + $formData['backup_token'] = $token; + $serversMeta = $uploadRecord['servers'] ?? []; + } else { + $serversMeta = $token ? BackupLibrary::getUploadServers($token) : []; + } + + try { + if ($token === '') { + throw new Exception('Upload a backup file before importing'); + } + + if ($serverIndex < 0) { + if (!empty($serversMeta)) { + if (count($serversMeta) === 1) { + $serverIndex = (int) $serversMeta[0]['index']; + $formData['backup_server_index'] = (string) $serverIndex; + } else { + $formData['uploaded_servers'] = $serversMeta; + View::render('servers/create.twig', [ + 'error' => 'Select a server entry from the uploaded backup', + 'selected_mode' => 'backup', + 'form_data' => $formData, + 'protocols' => $protocols, + 'default_protocol' => $defaultProtocol + ]); + return; + } + } else { + throw new Exception('Unable to read servers from uploaded backup'); + } + } else { + if (!empty($serversMeta)) { + $formData['uploaded_servers'] = $serversMeta; + } + } + + $serverData = BackupLibrary::loadServer($token, $serverIndex); + $serverId = VpnServer::importFromBackup($user['id'], $serverData); + + $serverModel = new VpnServer($serverId); + $serverRecord = $serverModel->getData(); + + foreach ($serverData['clients'] as $clientData) { + try { + VpnClient::importFromBackup($serverRecord, $user['id'], $clientData); + } catch (Exception $clientError) { + error_log('Client import failed: ' . $clientError->getMessage()); + } + } + + if (BackupLibrary::isUploadToken($token)) { + BackupLibrary::forgetUpload($token); + } + + $_SESSION['success_message'] = 'Server imported from backup'; + redirect('/servers/' . $serverId); + } catch (Exception $e) { + if (!empty($serversMeta)) { + $formData['uploaded_servers'] = $serversMeta; + } + View::render('servers/create.twig', [ + 'error' => $e->getMessage(), + 'selected_mode' => 'backup', + 'form_data' => $formData, + 'protocols' => $protocols, + 'default_protocol' => $defaultProtocol + ]); + } + return; } - + + $name = trim($_POST['name'] ?? ''); + $host = trim($_POST['host'] ?? ''); + $port = (int) ($_POST['port'] ?? 22); + $username = trim($_POST['username'] ?? 'root'); + $password = $_POST['password'] ?? ''; + // ssh_key handling + $sshKey = trim($_POST['ssh_key'] ?? ''); + + $protocolSlug = $formData['install_protocol'] ?? $defaultProtocol; + $protocolRecord = InstallProtocolManager::getBySlug($protocolSlug); + if (!$protocolRecord) { + View::render('servers/create.twig', [ + 'error' => 'Selected protocol not found or inactive', + 'selected_mode' => $creationMode, + 'form_data' => $formData, + 'protocols' => $protocols, + 'default_protocol' => $defaultProtocol + ]); + return; + } + $protocolMetadata = $protocolRecord['definition']['metadata'] ?? []; + $containerName = $protocolMetadata['container_name'] ?? 'amnezia-awg'; + $defaultSubnet = $protocolMetadata['vpn_subnet'] ?? '10.8.1.0/24'; + $vpnSubnet = $formData['vpn_subnet'] ?? $defaultSubnet; + $installOptions = $protocolRecord['definition']['defaults'] ?? null; + + if (empty($name) || empty($host) || (empty($password) && empty($sshKey))) { + View::render('servers/create.twig', [ + 'error' => 'All fields are required (either Password or SSH Key)', + 'selected_mode' => $creationMode, + 'form_data' => $formData, + 'protocols' => $protocols, + 'default_protocol' => $defaultProtocol + ]); + return; + } + try { $serverId = VpnServer::create([ 'user_id' => $user['id'], @@ -275,26 +476,22 @@ Router::post('/servers/create', function () { 'port' => $port, 'username' => $username, 'password' => $password, + 'ssh_key' => $sshKey, + 'container_name' => $containerName, + 'vpn_subnet' => $vpnSubnet, + 'install_protocol' => $protocolSlug, + 'install_options' => $installOptions, ]); - - // Handle import if enabled - if (!empty($_POST['enable_import']) && !empty($_POST['panel_type']) && isset($_FILES['backup_file'])) { - $panelType = $_POST['panel_type']; - - if (in_array($panelType, ['wg-easy', '3x-ui']) && $_FILES['backup_file']['error'] === UPLOAD_ERR_OK) { - // Store import info in session for processing after deployment - $_SESSION['pending_import'] = [ - 'server_id' => $serverId, - 'panel_type' => $panelType, - 'backup_file' => $_FILES['backup_file']['tmp_name'], - 'backup_name' => $_FILES['backup_file']['name'] - ]; - } - } - + redirect('/servers/' . $serverId . '/deploy'); } catch (Exception $e) { - View::render('servers/create.twig', ['error' => $e->getMessage()]); + View::render('servers/create.twig', [ + 'error' => $e->getMessage(), + 'selected_mode' => $creationMode, + 'form_data' => $formData, + 'protocols' => $protocols, + 'default_protocol' => $defaultProtocol + ]); } }); @@ -302,19 +499,19 @@ Router::post('/servers/create', function () { Router::post('/servers/{id}/delete', function ($params) { requireAuth(); $user = Auth::user(); - $serverId = (int)$params['id']; - + $serverId = (int) $params['id']; + try { $server = new VpnServer($serverId); $serverData = $server->getData(); - + // Check ownership or admin if ($serverData['user_id'] != $user['id'] && !Auth::isAdmin()) { http_response_code(403); echo 'Forbidden'; return; } - + $server->delete(); $_SESSION['success_message'] = 'Server deleted successfully'; redirect('/servers'); @@ -327,12 +524,12 @@ Router::post('/servers/{id}/delete', function ($params) { // Deploy server page Router::get('/servers/{id}/deploy', function ($params) { requireAuth(); - $serverId = (int)$params['id']; - + $serverId = (int) $params['id']; + try { $server = new VpnServer($serverId); $serverData = $server->getData(); - + // Check ownership $user = Auth::user(); if ($serverData['user_id'] != $user['id'] && !Auth::isAdmin()) { @@ -340,7 +537,7 @@ Router::get('/servers/{id}/deploy', function ($params) { echo 'Forbidden'; return; } - + View::render('servers/deploy.twig', ['server' => $serverData]); } catch (Exception $e) { http_response_code(404); @@ -352,13 +549,27 @@ Router::get('/servers/{id}/deploy', function ($params) { Router::post('/servers/{id}/deploy', function ($params) { requireAuth(); header('Content-Type: application/json'); - - $serverId = (int)$params['id']; - + + $serverId = (int) $params['id']; + $rawBody = file_get_contents('php://input'); + $options = []; + if ($rawBody !== false && trim($rawBody) !== '') { + $decoded = json_decode($rawBody, true); + if (is_array($decoded)) { + $options = $decoded; + } + } + if (empty($options) && !empty($_POST)) { + $options = $_POST; + } + if (!is_array($options)) { + $options = []; + } + try { $server = new VpnServer($serverId); $serverData = $server->getData(); - + // Check ownership $user = Auth::user(); if ($serverData['user_id'] != $user['id'] && !Auth::isAdmin()) { @@ -366,24 +577,183 @@ Router::post('/servers/{id}/deploy', function ($params) { echo json_encode(['error' => 'Forbidden']); return; } - - $result = $server->deploy(); - echo json_encode($result); + + $result = $server->deploy($options); + if (!isset($result['success']) && empty($result['requires_action'])) { + $result['success'] = true; + } + echo json_encode($result, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); + } +}); + +// Uninstall all protocols from server (mass cleanup) - MUST be before {slug}/uninstall +Router::post('/servers/{id}/protocols/uninstall-all', function ($params) { + requireAuth(); + header('Content-Type: application/json'); + $serverId = (int) $params['id']; + try { + $server = new VpnServer($serverId); + $serverData = $server->getData(); + $user = Auth::user(); + if ($serverData['user_id'] != $user['id'] && !Auth::isAdmin()) { + http_response_code(403); + echo json_encode(['success' => false, 'error' => 'Forbidden']); + return; + } + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT p.* FROM server_protocols sp JOIN protocols p ON p.id = sp.protocol_id WHERE sp.server_id = ?'); + $stmt->execute([$serverId]); + $protocols = $stmt->fetchAll(); + $removedClients = 0; + foreach ($protocols as $protocol) { + try { + $res = InstallProtocolManager::uninstall($server, $protocol, []); + $pid = (int) $protocol['id']; + $pdo->prepare('DELETE FROM server_protocols WHERE server_id = ? AND protocol_id = ?')->execute([$serverId, $pid]); + // Remove clients bound to this protocol + $stmtDel = $pdo->prepare('DELETE FROM vpn_clients WHERE server_id = ? AND protocol_id = ?'); + $stmtDel->execute([$serverId, $pid]); + $removedClients += (int) $stmtDel->rowCount(); + } catch (Exception $e) { + // continue with next protocol + } + } + echo json_encode(['success' => true, 'clients_removed' => $removedClients, 'message' => 'All protocols uninstalled']); + } catch (Exception $e) { + http_response_code(404); + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } +}); + +// Uninstall a specific protocol on server (AJAX) +Router::post('/servers/{id}/protocols/{slug}/uninstall', function ($params) { + requireAuth(); + header('Content-Type: application/json'); + + $serverId = (int) $params['id']; + $slug = $params['slug'] ?? ''; + + try { + $server = new VpnServer($serverId); + $serverData = $server->getData(); + + // Check ownership + $user = Auth::user(); + if ($serverData['user_id'] != $user['id'] && !Auth::isAdmin()) { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + $protocol = InstallProtocolManager::getBySlug($slug); + if (!$protocol) { + http_response_code(404); + echo json_encode(['error' => 'Protocol not found']); + return; + } + + $result = InstallProtocolManager::uninstall($server, $protocol); + + $pdo = DB::conn(); + $stmtId = $pdo->prepare('SELECT id FROM protocols WHERE slug = ? LIMIT 1'); + $stmtId->execute([$slug]); + $pid = (int) $stmtId->fetchColumn(); + $deletedClients = 0; + $deletedBindings = 0; + if ($pid) { + $stmtDelSp = $pdo->prepare('DELETE FROM server_protocols WHERE server_id = ? AND protocol_id = ?'); + $stmtDelSp->execute([$serverId, $pid]); + $deletedBindings = $stmtDelSp->rowCount(); + $stmtDelClients = $pdo->prepare('DELETE FROM vpn_clients WHERE server_id = ? AND protocol_id = ?'); + $stmtDelClients->execute([$serverId, $pid]); + $deletedClients = $stmtDelClients->rowCount(); + } + + // Update server status + $stmtCount = $pdo->prepare('SELECT COUNT(*) FROM server_protocols WHERE server_id = ?'); + $stmtCount->execute([$serverId]); + $remaining = (int) $stmtCount->fetchColumn(); + + // If we successfully uninstalled, we can clear the error state + // If no protocols remain, status is 'absent', otherwise 'active' + $newStatus = 'active'; + $stmtUpdate = $pdo->prepare('UPDATE vpn_servers SET status = ?, error_message = NULL WHERE id = ?'); + $stmtUpdate->execute([$newStatus, $serverId]); + + echo json_encode(array_merge($result, [ + 'bindings_removed' => $deletedBindings, + 'clients_removed' => $deletedClients + ])); } catch (Exception $e) { http_response_code(500); echo json_encode(['error' => $e->getMessage()]); } }); -// View server -Router::get('/servers/{id}', function ($params) { +// Activate protocol on server (AJAX) +Router::post('/servers/{id}/protocols/activate', function ($params) { requireAuth(); - $serverId = (int)$params['id']; - + + // Suppress errors and clean output buffer to prevent HTML corruption of JSON + @ini_set('display_errors', '0'); + error_reporting(0); + while (ob_get_level()) { + @ob_end_clean(); + } + + header('Content-Type: application/json'); + + $serverId = (int) $params['id']; + $protocolId = isset($_POST['protocol_id']) ? (int) $_POST['protocol_id'] : 0; + Logger::appendInstall($serverId, 'HTTP activate requested protocol_id=' . $protocolId); + + if ($protocolId <= 0) { + http_response_code(400); + echo json_encode(['error' => 'protocol_id required']); + return; + } + try { $server = new VpnServer($serverId); $serverData = $server->getData(); - + $user = Auth::user(); + if ($serverData['user_id'] != $user['id'] && !Auth::isAdmin()) { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + Logger::appendInstall($serverId, 'HTTP activate forbidden user=' . ($user['id'] ?? 0)); + return; + } + + $protocol = InstallProtocolManager::getById($protocolId); + if (!$protocol) { + http_response_code(404); + echo json_encode(['error' => 'Protocol not found']); + Logger::appendInstall($serverId, 'HTTP activate protocol not found id=' . $protocolId); + return; + } + + $result = InstallProtocolManager::activate($server, $protocol, []); + echo json_encode($result); + Logger::appendInstall($serverId, 'HTTP activate finished ok'); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + Logger::appendInstall($serverId, 'HTTP activate failed: ' . $e->getMessage()); + } +}); + +// View server +Router::get('/servers/{id}', function ($params) { + requireAuth(); + $serverId = (int) $params['id']; + + try { + $server = new VpnServer($serverId); + $serverData = $server->getData(); + // Check ownership $user = Auth::user(); if ($serverData['user_id'] != $user['id'] && !Auth::isAdmin()) { @@ -391,35 +761,77 @@ Router::get('/servers/{id}', function ($params) { echo 'Forbidden'; return; } - - // Get clients for this server - $clients = VpnClient::listByServer($serverId); - - // Check for pending import - $importMessage = null; - if (!empty($_SESSION['pending_import']) && $_SESSION['pending_import']['server_id'] == $serverId) { + + $pdo = DB::conn(); + $serverProtocols = []; + try { + $stmt = $pdo->prepare('SELECT sp.protocol_id, sp.config_data, sp.applied_at, p.name, p.slug, p.description FROM server_protocols sp JOIN protocols p ON p.id = sp.protocol_id WHERE sp.server_id = ? ORDER BY p.name'); + $stmt->execute([$serverId]); + $serverProtocols = $stmt->fetchAll(); + foreach ($serverProtocols as &$sp) { + $cfg = []; + if (!empty($sp['config_data'])) { + $cfg = is_string($sp['config_data']) ? json_decode($sp['config_data'], true) : $sp['config_data']; + } + $sp['server_host'] = is_array($cfg) ? ($cfg['server_host'] ?? '') : ''; + $sp['server_port'] = is_array($cfg) ? ($cfg['server_port'] ?? '') : ''; + $sp['extras'] = (is_array($cfg) && isset($cfg['extras']) && is_array($cfg['extras'])) ? $cfg['extras'] : []; + $sp['result_json'] = ''; + if (is_array($sp['extras']) && isset($sp['extras']['result']) && is_array($sp['extras']['result'])) { + $sp['result_json'] = json_encode($sp['extras']['result'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + } + } + unset($sp); + } catch (Exception $e) { + $serverProtocols = []; + } + + $allActive = InstallProtocolManager::listActive(); + $installedIds = array_map(function ($row) { + return (int) $row['protocol_id']; + }, $serverProtocols); + $availableProtocols = array_values(array_filter($allActive, function ($p) use ($installedIds) { + $pid = (int) ($p['id'] ?? 0); + return $pid > 0 && !in_array($pid, $installedIds, true); + })); + + $selectedProtocolId = isset($_GET['protocol_id']) ? (int) $_GET['protocol_id'] : 0; + if ($selectedProtocolId > 0) { + $clients = VpnClient::listByServerAndProtocol($serverId, $selectedProtocolId); + } else { + $clients = VpnClient::listByServer($serverId); + } + + // Flash message from manual config import + $importMessage = $_SESSION['import_message'] ?? null; + if ($importMessage) { + unset($_SESSION['import_message']); + } + + // Check for pending import if no flash message was set + if ($importMessage === null && !empty($_SESSION['pending_import']) && $_SESSION['pending_import']['server_id'] == $serverId) { $pendingImport = $_SESSION['pending_import']; - + // Only process import if server is active if ($serverData['status'] === 'active') { try { $backupContent = file_get_contents($pendingImport['backup_file']); - + $importer = new PanelImporter($serverId, $user['id'], $pendingImport['panel_type']); $importer->parseBackupFile($backupContent); $result = $importer->import(); - + if ($result['success']) { $importMessage = [ 'type' => 'success', 'text' => "Successfully imported {$result['imported_count']} clients" ]; } - + // Clean up @unlink($pendingImport['backup_file']); unset($_SESSION['pending_import']); - + } catch (Exception $e) { $importMessage = [ 'type' => 'error', @@ -427,16 +839,19 @@ Router::get('/servers/{id}', function ($params) { ]; unset($_SESSION['pending_import']); } - + // Refresh clients list after import $clients = VpnClient::listByServer($serverId); } } - + View::render('servers/view.twig', [ 'server' => $serverData, 'clients' => $clients, 'import_message' => $importMessage, + 'server_protocols' => $serverProtocols, + 'selected_protocol_id' => $selectedProtocolId, + 'available_protocols' => $availableProtocols, ]); } catch (Exception $e) { error_log('Server view error: ' . $e->getMessage() . ' at ' . $e->getFile() . ':' . $e->getLine()); @@ -445,15 +860,17 @@ Router::get('/servers/{id}', function ($params) { } }); + + // Server monitoring page Router::get('/servers/{id}/monitoring', function ($params) { requireAuth(); - $serverId = (int)$params['id']; - + $serverId = (int) $params['id']; + try { $server = new VpnServer($serverId); $serverData = $server->getData(); - + // Check ownership $user = Auth::user(); if ($serverData['user_id'] != $user['id'] && !Auth::isAdmin()) { @@ -461,10 +878,10 @@ Router::get('/servers/{id}/monitoring', function ($params) { echo 'Forbidden'; return; } - + // Get clients for this server $clients = VpnClient::listByServer($serverId); - + View::render('servers/monitoring.twig', [ 'server' => $serverData, 'clients' => $clients, @@ -475,15 +892,121 @@ Router::get('/servers/{id}/monitoring', function ($params) { } }); -// Delete server -Router::post('/servers/{id}/delete', function ($params) { +// Import server configuration from uploaded backup +Router::post('/servers/{id}/config/import', function ($params) { requireAuth(); - $serverId = (int)$params['id']; - + $user = Auth::user(); + $serverId = (int) $params['id']; + try { $server = new VpnServer($serverId); $serverData = $server->getData(); - + + if (!$serverData) { + throw new Exception('Server not found'); + } + + if ($serverData['user_id'] != $user['id'] && !Auth::isAdmin()) { + throw new Exception('Недостаточно прав для импорта конфигурации'); + } + + if (!isset($_FILES['config_file']) || $_FILES['config_file']['error'] !== UPLOAD_ERR_OK) { + throw new Exception('Файл конфигурации не загружен'); + } + + $tmpPath = $_FILES['config_file']['tmp_name']; + if (!is_uploaded_file($tmpPath)) { + throw new Exception('Не удалось прочитать загруженный файл'); + } + + $storagePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'config_import_' . bin2hex(random_bytes(16)); + if (!move_uploaded_file($tmpPath, $storagePath)) { + throw new Exception('Не удалось сохранить загруженный файл'); + } + + try { + $parsed = BackupParser::parse($storagePath); + } finally { + @unlink($storagePath); + } + + $type = $parsed['type'] ?? ''; + if (!in_array($type, ['panel_backup', 'amnezia_app'], true)) { + throw new Exception('Этот тип бэкапа пока не поддерживается для импорта конфигурации'); + } + + $servers = $parsed['servers'] ?? []; + if (!is_array($servers) || empty($servers)) { + throw new Exception('В бэкапе не найдено конфигураций серверов'); + } + + $selectedServer = null; + if ($type === 'panel_backup') { + $selectedServer = $servers[0]; + } else { + $currentHost = strtolower(trim($serverData['host'] ?? '')); + foreach ($servers as $candidate) { + $candidateHost = strtolower(trim($candidate['host'] ?? '')); + if ($candidateHost !== '' && $candidateHost === $currentHost) { + $selectedServer = $candidate; + break; + } + } + + if ($selectedServer === null && count($servers) === 1) { + $selectedServer = $servers[0]; + } + + if ($selectedServer === null) { + throw new Exception('Не удалось сопоставить сервер в бэкапе с текущим хостом ' . $serverData['host']); + } + } + + $replaceClients = $type === 'panel_backup'; + $result = $server->applyBackupData($selectedServer, $user['id'], $replaceClients); + + $importedClients = $result['imported_clients'] ?? 0; + $clientErrors = $result['client_errors'] ?? []; + + $messageParts = []; + if (!empty($result['updated_fields'])) { + $messageParts[] = 'Обновлены поля: ' . implode(', ', $result['updated_fields']); + } + if ($importedClients > 0) { + $messageParts[] = 'Импортировано клиентов: ' . $importedClients; + } + if (empty($messageParts)) { + $messageParts[] = 'Конфигурация обработана'; + } + + $_SESSION['import_message'] = [ + 'type' => empty($clientErrors) ? 'success' : 'warning', + 'text' => implode('. ', $messageParts) + ]; + + if (!empty($clientErrors)) { + $_SESSION['import_message']['text'] .= '. Ошибки: ' . implode('; ', array_slice($clientErrors, 0, 3)); + } + + } catch (Exception $e) { + $_SESSION['import_message'] = [ + 'type' => 'error', + 'text' => $e->getMessage() + ]; + } + + redirect('/servers/' . $serverId); +}); + +// Delete server +Router::post('/servers/{id}/delete', function ($params) { + requireAuth(); + $serverId = (int) $params['id']; + + try { + $server = new VpnServer($serverId); + $serverData = $server->getData(); + // Check ownership $user = Auth::user(); if ($serverData['user_id'] != $user['id'] && !Auth::isAdmin()) { @@ -491,7 +1014,7 @@ Router::post('/servers/{id}/delete', function ($params) { echo 'Forbidden'; return; } - + $server->delete(); redirect('/servers'); } catch (Exception $e) { @@ -502,37 +1025,39 @@ Router::post('/servers/{id}/delete', function ($params) { // Create client for server Router::post('/servers/{id}/clients/create', function ($params) { requireAuth(); - $serverId = (int)$params['id']; + $serverId = (int) $params['id']; $clientName = trim($_POST['name'] ?? ''); - + $username = isset($_POST['username']) ? trim($_POST['username']) : ''; + $login = isset($_POST['login']) ? trim($_POST['login']) : ''; + // Handle expiration: either from dropdown (days) or custom input (seconds) $expiresInDays = null; if (!empty($_POST['expires_in_seconds'])) { // Convert seconds to days (round up) - $expiresInDays = (int)ceil((int)$_POST['expires_in_seconds'] / 86400); + $expiresInDays = (int) ceil((int) $_POST['expires_in_seconds'] / 86400); } elseif (!empty($_POST['expires_in_days']) && $_POST['expires_in_days'] !== 'custom') { - $expiresInDays = (int)$_POST['expires_in_days']; + $expiresInDays = (int) $_POST['expires_in_days']; } - + // Handle traffic limit: either from dropdown (GB) or custom input (MB) $trafficLimitBytes = null; if (!empty($_POST['traffic_limit_mb'])) { // Convert MB to bytes - $trafficLimitBytes = (int)((float)$_POST['traffic_limit_mb'] * 1048576); + $trafficLimitBytes = (int) ((float) $_POST['traffic_limit_mb'] * 1048576); } elseif (!empty($_POST['traffic_limit_gb']) && $_POST['traffic_limit_gb'] !== 'custom') { // Convert GB to bytes - $trafficLimitBytes = (int)((float)$_POST['traffic_limit_gb'] * 1073741824); + $trafficLimitBytes = (int) ((float) $_POST['traffic_limit_gb'] * 1073741824); } - + if (empty($clientName)) { redirect('/servers/' . $serverId . '?error=Client+name+is+required'); return; } - + try { $server = new VpnServer($serverId); $serverData = $server->getData(); - + // Check ownership $user = Auth::user(); if ($serverData['user_id'] != $user['id'] && !Auth::isAdmin()) { @@ -540,15 +1065,28 @@ Router::post('/servers/{id}/clients/create', function ($params) { echo 'Forbidden'; return; } - - $clientId = VpnClient::create($serverId, $user['id'], $clientName, $expiresInDays); - + + $protocolId = isset($_POST['protocol_id']) && $_POST['protocol_id'] !== '' ? (int) $_POST['protocol_id'] : null; + if ($protocolId) { + try { + $pdo = DB::conn(); + $chk = $pdo->prepare('SELECT 1 FROM server_protocols WHERE server_id = ? AND protocol_id = ?'); + $chk->execute([$serverId, $protocolId]); + if (!$chk->fetchColumn()) { + $protocolId = null; + } + } catch (Exception $e) { + $protocolId = null; + } + } + $clientId = VpnClient::create($serverId, $user['id'], $clientName, $expiresInDays, $protocolId, $username, $login); + // Set traffic limit if specified if ($trafficLimitBytes !== null && $trafficLimitBytes > 0) { $client = new VpnClient($clientId); $client->setTrafficLimit($trafficLimitBytes); } - + redirect('/clients/' . $clientId); } catch (Exception $e) { redirect('/servers/' . $serverId . '?error=' . urlencode($e->getMessage())); @@ -558,12 +1096,12 @@ Router::post('/servers/{id}/clients/create', function ($params) { // View client Router::get('/clients/{id}', function ($params) { requireAuth(); - $clientId = (int)$params['id']; - + $clientId = (int) $params['id']; + try { $client = new VpnClient($clientId); $clientData = $client->getData(); - + // Check ownership $user = Auth::user(); if ($clientData['user_id'] != $user['id'] && !Auth::isAdmin()) { @@ -571,8 +1109,36 @@ Router::get('/clients/{id}', function ($params) { echo 'Forbidden'; return; } - - View::render('clients/view.twig', ['client' => $clientData]); + $server = new VpnServer((int) $clientData['server_id']); + $serverData = $server->getData(); + $protocolOutput = ''; + try { + $pdo = DB::conn(); + $protocol = null; + if (!empty($clientData['protocol_id'])) { + $stmt = $pdo->prepare('SELECT * FROM protocols WHERE id = ? LIMIT 1'); + $stmt->execute([(int) $clientData['protocol_id']]); + $protocol = $stmt->fetch(); + } else { + $stmt = $pdo->prepare('SELECT * FROM protocols WHERE slug = ? LIMIT 1'); + $stmt->execute([$serverData['install_protocol'] ?? '']); + $protocol = $stmt->fetch(); + } + if ($protocol && ($protocol['output_template'] ?? '') !== '') { + $slug = $protocol['slug'] ?? ''; + $isWireguard = in_array($slug, ['amnezia-wg-advanced', 'wireguard-standard', 'amnezia-wg'], true); + if ($isWireguard) { + // 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'] ?? ''; + } + } + } catch (Exception $e) { + $protocolOutput = ''; + } + View::render('clients/view.twig', ['client' => $clientData, 'protocol_output' => $protocolOutput]); } catch (Exception $e) { http_response_code(404); echo 'Client not found'; @@ -582,12 +1148,18 @@ Router::get('/clients/{id}', function ($params) { // Download client config Router::get('/clients/{id}/download', function ($params) { requireAuth(); - $clientId = (int)$params['id']; - + + // Clean any output buffer to prevent header issues + while (ob_get_level()) { + ob_end_clean(); + } + + $clientId = (int) $params['id']; + try { $client = new VpnClient($clientId); $clientData = $client->getData(); - + // Check ownership $user = Auth::user(); if ($clientData['user_id'] != $user['id'] && !Auth::isAdmin()) { @@ -595,20 +1167,37 @@ Router::get('/clients/{id}/download', function ($params) { echo 'Forbidden'; return; } - - $config = $client->getConfig(); - - // Check if name contains non-Latin characters - $hasNonLatin = preg_match('/[^a-zA-Z0-9_-]/', $clientData['name']); - if ($hasNonLatin) { - // Use user_(client_id)_s(server_id).conf format for non-Latin names - $filename = 'user_' . $clientData['id'] . '_s' . $clientData['server_id'] . '.conf'; - } else { - // Use client name for Latin characters - $filename = $clientData['name'] . '.conf'; + + // For WireGuard/AWG clients, regenerate config from current server state + // to avoid stale AWG parameters after reinstall/recreate. + // IMPORTANT: for amnezia-wg-advanced, never serve a config built with defaults. + try { + $regen = $client->regenerateConfigFromServer(true); + if (is_array($regen) && empty($regen['success']) && ($regen['error'] ?? '') === 'awg_params_missing') { + http_response_code(500); + echo 'AWG params are missing on server; cannot generate a valid config. Reinstall/repair AWG or check /opt/amnezia/awg/wg0.conf on the server.'; + return; + } + } catch (Throwable $e) { + error_log('Failed to regenerate client config: ' . $e->getMessage()); } - - header('Content-Type: application/octet-stream'); + + $config = $client->getConfig(); + + // Use login if available, fallback to name + $baseName = !empty($clientData['login']) ? $clientData['login'] : $clientData['name']; + + // Sanitize filename: remove non-safe characters + $safeName = preg_replace('/[^a-zA-Z0-9_-]/', '_', $baseName); + + // If sanitization resulted in empty string, use fallback + if (empty($safeName)) { + $safeName = 'user_' . $clientData['id'] . '_s' . $clientData['server_id']; + } + + $filename = $safeName . '.conf'; + + header('Content-Type: text/plain; charset=utf-8'); header('Content-Disposition: attachment; filename="' . $filename . '"'); header('Content-Length: ' . strlen($config)); echo $config; @@ -618,15 +1207,255 @@ Router::get('/clients/{id}/download', function ($params) { } }); +// Debug: one-shot AWG advanced smoke test (requires session auth) +// Usage example (while logged in): /debug/awg-smoke?server_id=5&client_name=olegnew14&duration_seconds=10 +Router::get('/debug/awg-smoke', function () { + requireDebugEnabledOrAdmin(); + header('Content-Type: application/json'); + + $serverId = (int) ($_GET['server_id'] ?? 0); + $clientId = (int) ($_GET['client_id'] ?? 0); + $clientName = trim((string) ($_GET['client_name'] ?? '')); + $duration = (int) ($_GET['duration_seconds'] ?? 10); + if ($duration < 0) + $duration = 0; + if ($duration > 30) + $duration = 30; + + if ($serverId <= 0) { + http_response_code(400); + echo json_encode(['error' => 'server_id is required']); + return; + } + if ($clientId <= 0 && $clientName === '') { + http_response_code(400); + echo json_encode(['error' => 'client_id or client_name is required']); + return; + } + + try { + $user = Auth::user(); + + $server = new VpnServer($serverId); + $serverData = $server->getData(); + if (!$serverData) { + http_response_code(404); + echo json_encode(['error' => 'Server not found']); + return; + } + if (($serverData['user_id'] ?? null) != $user['id'] && !Auth::isAdmin()) { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + if ($clientId <= 0) { + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT id FROM vpn_clients WHERE server_id = ? AND name = ? ORDER BY id DESC LIMIT 1'); + $stmt->execute([$serverId, $clientName]); + $clientId = (int) $stmt->fetchColumn(); + } + + if ($clientId <= 0) { + http_response_code(404); + echo json_encode(['error' => 'Client not found']); + return; + } + + $client = new VpnClient($clientId); + $clientData = $client->getData(); + if (!$clientData) { + http_response_code(404); + echo json_encode(['error' => 'Client not found']); + return; + } + if (($clientData['user_id'] ?? null) != $user['id'] && !Auth::isAdmin()) { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + // Regenerate config from live server state (critical after reinstall) + $regen = $client->regenerateConfigFromServer(true); + + $server->refresh(); + $serverData = $server->getData(); + + $containerName = $serverData['container_name'] ?? 'amnezia-awg'; + $vpnPort = (int) ($serverData['vpn_port'] ?? 0); + + $cmdShow = sprintf('docker exec %s wg show wg0 2>/dev/null || true', escapeshellarg($containerName)); + $cmdDump = sprintf('docker exec %s wg show wg0 dump 2>/dev/null || true', escapeshellarg($containerName)); + $cmdAwgConfParams = sprintf( + 'docker exec %s sh -c "grep -E \"^[[:space:]]*(Jc|Jmin|Jmax|S1|S2|H1|H2|H3|H4)[[:space:]]*=\" /opt/amnezia/awg/wg0.conf 2>/dev/null || true"', + escapeshellarg($containerName) + ); + $cmdListen = $vpnPort > 0 + ? sprintf('docker exec %s sh -c "ss -lunp 2>/dev/null | grep -E \"[:.]%d\\b\" || true"', escapeshellarg($containerName), $vpnPort) + : ''; + + // Host-level checks (outside container) + // Use sed (not awk) to avoid shell-quoting pitfalls. + $cmdHostIps = 'sh -c "ip -4 -o addr show scope global 2>/dev/null | sed -E \"s/^[0-9]+: ([^ ]+) +inet ([0-9\\./]+).*/\\1 \\2/\" || true"'; + $cmdDockerPort = $vpnPort > 0 + ? sprintf('sh -c "docker port %s %d/udp 2>/dev/null || true"', escapeshellarg($containerName), $vpnPort) + : sprintf('sh -c "docker port %s 2>/dev/null || true"', escapeshellarg($containerName)); + $cmdHostSs = $vpnPort > 0 + ? sprintf('sh -c "ss -lunp 2>/dev/null | grep -E \"[:.]%d\\b\" || true"', $vpnPort) + : 'sh -c "ss -lunp 2>/dev/null || true"'; + $cmdNftFiltered = $vpnPort > 0 + ? sprintf('sh -c "nft list ruleset 2>/dev/null | grep -E \"(dport|udp).*(%d)\" | head -200 || true"', $vpnPort) + : 'sh -c "nft list ruleset 2>/dev/null | head -200 || true"'; + $cmdIptFiltered = $vpnPort > 0 + ? sprintf('sh -c "iptables -vnL 2>/dev/null | grep -E \"(udp|%d)\" | head -200 || true"', $vpnPort) + : 'sh -c "iptables -vnL 2>/dev/null | head -200 || true"'; + + // BEFORE snapshots + $wgShowBefore = (string) $server->executeCommand($cmdShow, true); + $wgDumpBefore = (string) $server->executeCommand($cmdDump, true); + $awgConfLines = (string) $server->executeCommand($cmdAwgConfParams, true); + $listenLines = $cmdListen !== '' ? (string) $server->executeCommand($cmdListen, true) : ''; + $hostIps = (string) $server->executeCommand($cmdHostIps, true); + $dockerPort = (string) $server->executeCommand($cmdDockerPort, true); + $hostSsBefore = (string) $server->executeCommand($cmdHostSs, true); + $nftBefore = (string) $server->executeCommand($cmdNftFiltered, true); + $iptablesBefore = (string) $server->executeCommand($cmdIptFiltered, true); + + // Extract this peer line from dump (before) + $peerLineBefore = ''; + foreach (preg_split('/\r?\n/', trim($wgDumpBefore)) as $ln) { + if ($ln !== '' && strpos($ln, ($clientData['public_key'] ?? '') . "\t") === 0) { + $peerLineBefore = $ln; + break; + } + } + + if ($duration > 0) { + sleep($duration); + } + + // AFTER snapshots + $wgShowAfter = (string) $server->executeCommand($cmdShow, true); + $wgDumpAfter = (string) $server->executeCommand($cmdDump, true); + $hostSsAfter = (string) $server->executeCommand($cmdHostSs, true); + $nftAfter = (string) $server->executeCommand($cmdNftFiltered, true); + $iptablesAfter = (string) $server->executeCommand($cmdIptFiltered, true); + + // Parse firewall counters for this UDP port (best-effort) + $nftPacketsBefore = null; + $nftBytesBefore = null; + if ($vpnPort > 0 && preg_match('/udp dport\s+' . preg_quote((string) $vpnPort, '/') . '\s+counter\s+packets\s+(\d+)\s+bytes\s+(\d+)/', $nftBefore, $m)) { + $nftPacketsBefore = (int) $m[1]; + $nftBytesBefore = (int) $m[2]; + } + $nftPacketsAfter = null; + $nftBytesAfter = null; + if ($vpnPort > 0 && preg_match('/udp dport\s+' . preg_quote((string) $vpnPort, '/') . '\s+counter\s+packets\s+(\d+)\s+bytes\s+(\d+)/', $nftAfter, $m)) { + $nftPacketsAfter = (int) $m[1]; + $nftBytesAfter = (int) $m[2]; + } + + $iptPacketsBefore = null; + $iptBytesBefore = null; + if (preg_match('/^\s*(\d+)\s+(\d+)\s+ACCEPT\b/m', $iptablesBefore, $m)) { + $iptPacketsBefore = (int) $m[1]; + $iptBytesBefore = (int) $m[2]; + } + $iptPacketsAfter = null; + $iptBytesAfter = null; + if (preg_match('/^\s*(\d+)\s+(\d+)\s+ACCEPT\b/m', $iptablesAfter, $m)) { + $iptPacketsAfter = (int) $m[1]; + $iptBytesAfter = (int) $m[2]; + } + + // Extract this peer line from dump (after) + $peerLineAfter = ''; + foreach (preg_split('/\r?\n/', trim($wgDumpAfter)) as $ln) { + if ($ln !== '' && strpos($ln, ($clientData['public_key'] ?? '') . "\t") === 0) { + $peerLineAfter = $ln; + break; + } + } + + // Compare PSK in client config vs wg dump (redacted) + $configText = $client->getConfig(); + $configPsk = ''; + if (preg_match('/^PresharedKey\s*=\s*(\S+)/mi', $configText, $m)) { + $configPsk = (string) $m[1]; + } + $dumpPskAfter = ''; + if ($peerLineAfter !== '') { + $parts = explode("\t", $peerLineAfter); + if (count($parts) >= 2) { + $dumpPskAfter = (string) $parts[1]; + } + } + $pskMatches = ($configPsk !== '' && $dumpPskAfter !== '' && $configPsk === $dumpPskAfter); + + echo json_encode([ + 'success' => true, + 'server' => [ + 'id' => (int) $serverId, + 'host' => (string) ($serverData['host'] ?? ''), + 'container_name' => (string) $containerName, + 'vpn_port' => $vpnPort, + 'awg_params_db' => json_decode($serverData['awg_params'] ?? '{}', true), + ], + 'client' => [ + 'id' => (int) $clientId, + 'name' => (string) ($clientData['name'] ?? ''), + 'public_key' => (string) ($clientData['public_key'] ?? ''), + 'client_ip' => (string) ($clientData['client_ip'] ?? ''), + ], + 'regen' => $regen, + 'awg_conf_param_lines' => $awgConfLines, + 'container_listen' => $listenLines, + 'host_global_ips' => $hostIps, + 'docker_port_publish' => $dockerPort, + 'host_ss_udp_before' => $hostSsBefore, + 'host_ss_udp_after' => $hostSsAfter, + 'nft_filtered_before' => $nftBefore, + 'nft_filtered_after' => $nftAfter, + 'iptables_filtered_before' => $iptablesBefore, + 'iptables_filtered_after' => $iptablesAfter, + 'nft_counter_packets_before' => $nftPacketsBefore, + 'nft_counter_packets_after' => $nftPacketsAfter, + 'nft_counter_packets_delta' => ($nftPacketsBefore !== null && $nftPacketsAfter !== null) ? ($nftPacketsAfter - $nftPacketsBefore) : null, + 'nft_counter_bytes_before' => $nftBytesBefore, + 'nft_counter_bytes_after' => $nftBytesAfter, + 'nft_counter_bytes_delta' => ($nftBytesBefore !== null && $nftBytesAfter !== null) ? ($nftBytesAfter - $nftBytesBefore) : null, + 'iptables_counter_packets_before' => $iptPacketsBefore, + 'iptables_counter_packets_after' => $iptPacketsAfter, + 'iptables_counter_packets_delta' => ($iptPacketsBefore !== null && $iptPacketsAfter !== null) ? ($iptPacketsAfter - $iptPacketsBefore) : null, + 'iptables_counter_bytes_before' => $iptBytesBefore, + 'iptables_counter_bytes_after' => $iptBytesAfter, + 'iptables_counter_bytes_delta' => ($iptBytesBefore !== null && $iptBytesAfter !== null) ? ($iptBytesAfter - $iptBytesBefore) : null, + 'peer_dump_line_before' => $peerLineBefore, + 'peer_dump_line_after' => $peerLineAfter, + 'psk_match' => $pskMatches, + 'client_config_psk_prefix' => $configPsk !== '' ? substr($configPsk, 0, 8) : '', + 'dump_psk_prefix' => $dumpPskAfter !== '' ? substr($dumpPskAfter, 0, 8) : '', + 'wg_show_before' => $wgShowBefore, + 'wg_dump_before' => $wgDumpBefore, + 'wg_show_after' => $wgShowAfter, + 'wg_dump_after' => $wgDumpAfter, + 'duration_seconds' => $duration, + ], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); + } catch (Throwable $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); + // Revoke client access Router::post('/clients/{id}/revoke', function ($params) { requireAuth(); - $clientId = (int)$params['id']; - + $clientId = (int) $params['id']; + try { $client = new VpnClient($clientId); $clientData = $client->getData(); - + // Check ownership $user = Auth::user(); if ($clientData['user_id'] != $user['id'] && !Auth::isAdmin()) { @@ -634,7 +1463,7 @@ Router::post('/clients/{id}/revoke', function ($params) { echo 'Forbidden'; return; } - + if ($client->revoke()) { redirect('/servers/' . $clientData['server_id'] . '?success=Client+revoked'); } else { @@ -648,12 +1477,12 @@ Router::post('/clients/{id}/revoke', function ($params) { // Restore client access Router::post('/clients/{id}/restore', function ($params) { requireAuth(); - $clientId = (int)$params['id']; - + $clientId = (int) $params['id']; + try { $client = new VpnClient($clientId); $clientData = $client->getData(); - + // Check ownership $user = Auth::user(); if ($clientData['user_id'] != $user['id'] && !Auth::isAdmin()) { @@ -661,7 +1490,7 @@ Router::post('/clients/{id}/restore', function ($params) { echo 'Forbidden'; return; } - + if ($client->restore()) { redirect('/servers/' . $clientData['server_id'] . '?success=Client+restored'); } else { @@ -675,12 +1504,12 @@ Router::post('/clients/{id}/restore', function ($params) { // Delete client Router::post('/clients/{id}/delete', function ($params) { requireAuth(); - $clientId = (int)$params['id']; - + $clientId = (int) $params['id']; + try { $client = new VpnClient($clientId); $clientData = $client->getData(); - + // Check ownership $user = Auth::user(); if ($clientData['user_id'] != $user['id'] && !Auth::isAdmin()) { @@ -688,9 +1517,9 @@ Router::post('/clients/{id}/delete', function ($params) { echo 'Forbidden'; return; } - + $serverId = $clientData['server_id']; - + if ($client->delete()) { redirect('/servers/' . $serverId . '?success=Client+deleted'); } else { @@ -704,14 +1533,14 @@ Router::post('/clients/{id}/delete', function ($params) { // Sync client stats Router::post('/clients/{id}/sync-stats', function ($params) { requireAuth(); - $clientId = (int)$params['id']; - + $clientId = (int) $params['id']; + header('Content-Type: application/json'); - + try { $client = new VpnClient($clientId); $clientData = $client->getData(); - + // Check ownership $user = Auth::user(); if ($clientData['user_id'] != $user['id'] && !Auth::isAdmin()) { @@ -719,7 +1548,7 @@ Router::post('/clients/{id}/sync-stats', function ($params) { echo json_encode(['error' => 'Forbidden']); return; } - + if ($client->syncStats()) { // Reload client data $client = new VpnClient($clientId); @@ -737,14 +1566,14 @@ Router::post('/clients/{id}/sync-stats', function ($params) { // Sync all stats for server Router::post('/servers/{id}/sync-stats', function ($params) { requireAuth(); - $serverId = (int)$params['id']; - + $serverId = (int) $params['id']; + header('Content-Type: application/json'); - + try { $server = new VpnServer($serverId); $serverData = $server->getData(); - + // Check ownership $user = Auth::user(); if ($serverData['user_id'] != $user['id'] && !Auth::isAdmin()) { @@ -752,7 +1581,7 @@ Router::post('/servers/{id}/sync-stats', function ($params) { echo json_encode(['error' => 'Forbidden']); return; } - + $synced = VpnClient::syncAllStatsForServer($serverId); echo json_encode(['success' => true, 'synced' => $synced]); } catch (Exception $e) { @@ -768,23 +1597,23 @@ Router::post('/servers/{id}/sync-stats', function ($params) { // API: Generate JWT token Router::post('/api/auth/token', function () { header('Content-Type: application/json'); - + $email = $_POST['email'] ?? ''; $password = $_POST['password'] ?? ''; - + if (empty($email) || empty($password)) { http_response_code(400); echo json_encode(['error' => 'Email and password are required']); return; } - + $user = Auth::getUserByEmail($email); if (!$user || !password_verify($password, $user['password_hash'])) { http_response_code(401); echo json_encode(['error' => 'Invalid credentials']); return; } - + try { $token = JWT::generate($user['id']); echo json_encode([ @@ -802,13 +1631,14 @@ Router::post('/api/auth/token', function () { // API: Create persistent API token Router::post('/api/tokens', function () { header('Content-Type: application/json'); - + $user = JWT::requireAuth(); - if (!$user) return; - + if (!$user) + return; + $name = $_POST['name'] ?? 'API Token'; - $expiresIn = isset($_POST['expires_in']) ? (int)$_POST['expires_in'] : 2592000; // 30 days default - + $expiresIn = isset($_POST['expires_in']) ? (int) $_POST['expires_in'] : 2592000; // 30 days default + try { $tokenData = JWT::createApiToken($user['id'], $name, $expiresIn); echo json_encode([ @@ -824,11 +1654,12 @@ Router::post('/api/tokens', function () { // API: List user's API tokens Router::get('/api/tokens', function () { header('Content-Type: application/json'); - + $user = JWT::requireAuth(); - if (!$user) return; - - $stmt = DB::get()->prepare(" + if (!$user) + return; + + $stmt = DB::conn()->prepare(" SELECT id, name, token, expires_at, created_at, last_used_at FROM api_tokens WHERE user_id = ? AND revoked_at IS NULL @@ -836,22 +1667,23 @@ Router::get('/api/tokens', function () { "); $stmt->execute([$user['id']]); $tokens = $stmt->fetchAll(); - + // Don't expose full token in list foreach ($tokens as &$token) { $token['token'] = substr($token['token'], 0, 10) . '...'; } - + echo json_encode(['tokens' => $tokens]); }); // API: Revoke API token Router::delete('/api/tokens/{id}', function ($params) { header('Content-Type: application/json'); - + $user = JWT::requireAuth(); - if (!$user) return; - + if (!$user) + return; + try { JWT::revokeApiToken($params['id'], $user['id']); echo json_encode(['success' => true]); @@ -864,10 +1696,11 @@ Router::delete('/api/tokens/{id}', function ($params) { // API: List servers Router::get('/api/servers', function () { header('Content-Type: application/json'); - + $user = JWT::requireAuth(); - if (!$user) return; - + if (!$user) + return; + $servers = VpnServer::listByUser($user['id']); echo json_encode(['servers' => $servers]); }); @@ -875,24 +1708,25 @@ Router::get('/api/servers', function () { // API: Create server Router::post('/api/servers/create', function () { header('Content-Type: application/json'); - + $user = JWT::requireAuth(); - if (!$user) return; - + if (!$user) + return; + $input = json_decode(file_get_contents('php://input'), true); - + $name = trim($input['name'] ?? ''); $host = trim($input['host'] ?? ''); - $port = (int)($input['port'] ?? 22); + $port = (int) ($input['port'] ?? 22); $username = trim($input['username'] ?? 'root'); $password = $input['password'] ?? ''; - + if (empty($name) || empty($host) || empty($password)) { http_response_code(400); echo json_encode(['error' => 'Missing required fields: name, host, password']); return; } - + try { $serverId = VpnServer::create([ 'user_id' => $user['id'], @@ -902,7 +1736,7 @@ Router::post('/api/servers/create', function () { 'username' => $username, 'password' => $password, ]); - + http_response_code(201); echo json_encode([ 'success' => true, @@ -918,56 +1752,90 @@ Router::post('/api/servers/create', function () { // API: Delete server Router::delete('/api/servers/{id}/delete', function ($params) { header('Content-Type: application/json'); - + $user = JWT::requireAuth(); + if (!$user) + return; + + $serverId = (int) $params['id']; + + try { + $server = new VpnServer($serverId); + $serverData = $server->getData(); + + // Check ownership or admin + if ($serverData['user_id'] != $user['id'] && $user['role'] !== 'admin') { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + $server->delete(); + echo json_encode([ + 'success' => true, + 'message' => 'Server deleted successfully' + ]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); // API: Import from existing panel Router::post('/api/servers/{id}/import', function ($params) { header('Content-Type: application/json'); - + $user = JWT::requireAuth(); - if (!$user) return; - - $serverId = (int)$params['id']; - + if (!$user) + return; + + $serverId = (int) $params['id']; + // Validate server ownership - $server = VpnServer::getById($serverId); - if (!$server || $server['user_id'] != $user['id']) { + try { + $server = new VpnServer($serverId); + $serverData = $server->getData(); + } catch (Exception $e) { http_response_code(404); echo json_encode(['error' => 'Server not found']); return; } - + if ($serverData['user_id'] != $user['id']) { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + $panelType = $_POST['panel_type'] ?? ''; - + if (!in_array($panelType, ['wg-easy', '3x-ui'])) { http_response_code(400); echo json_encode(['error' => 'Invalid panel type. Supported: wg-easy, 3x-ui']); return; } - + // Handle file upload if (!isset($_FILES['backup_file']) || $_FILES['backup_file']['error'] !== UPLOAD_ERR_OK) { http_response_code(400); echo json_encode(['error' => 'No backup file uploaded']); return; } - + $backupContent = file_get_contents($_FILES['backup_file']['tmp_name']); - + try { $importer = new PanelImporter($serverId, $user['id'], $panelType); - + if (!$importer->parseBackupFile($backupContent)) { http_response_code(400); echo json_encode(['error' => 'Invalid backup file format']); return; } - + $result = $importer->import(); - + echo json_encode($result); - + } catch (Exception $e) { http_response_code(500); echo json_encode([ @@ -980,76 +1848,60 @@ Router::post('/api/servers/{id}/import', function ($params) { // API: Get import history Router::get('/api/servers/{id}/imports', function ($params) { header('Content-Type: application/json'); - + $user = JWT::requireAuth(); - if (!$user) return; - - $serverId = (int)$params['id']; - + if (!$user) + return; + + $serverId = (int) $params['id']; + // Validate server ownership - $server = VpnServer::getById($serverId); - if (!$server || $server['user_id'] != $user['id']) { + try { + $server = new VpnServer($serverId); + $serverData = $server->getData(); + } catch (Exception $e) { http_response_code(404); echo json_encode(['error' => 'Server not found']); return; } - + if ($serverData['user_id'] != $user['id']) { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + $imports = PanelImporter::getImportHistory($serverId); - + echo json_encode([ 'success' => true, 'imports' => $imports ]); }); - if (!$user) return; - - $serverId = (int)$params['id']; - - try { - $server = new VpnServer($serverId); - $serverData = $server->getData(); - - // Check ownership or admin - if ($serverData['user_id'] != $user['id'] && $user['role'] !== 'admin') { - http_response_code(403); - echo json_encode(['error' => 'Forbidden']); - return; - } - - $server->delete(); - echo json_encode([ - 'success' => true, - 'message' => 'Server deleted successfully' - ]); - } catch (Exception $e) { - http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); - } -}); // API: Create backup Router::post('/api/servers/{id}/backup', function ($params) { header('Content-Type: application/json'); - + $user = requireApiAuth(); - if (!$user) return; - - $serverId = (int)$params['id']; - + if (!$user) + return; + + $serverId = (int) $params['id']; + try { $server = new VpnServer($serverId); $serverData = $server->getData(); - + // Check ownership or admin if ($serverData['user_id'] != $user['id'] && $user['role'] !== 'admin') { http_response_code(403); echo json_encode(['error' => 'Forbidden']); return; } - + $backupId = $server->createBackup($user['id'], 'manual'); $backup = VpnServer::getBackup($backupId); - + echo json_encode([ 'success' => true, 'backup' => $backup @@ -1063,25 +1915,26 @@ Router::post('/api/servers/{id}/backup', function ($params) { // API: List backups Router::get('/api/servers/{id}/backups', function ($params) { header('Content-Type: application/json'); - + $user = requireApiAuth(); - if (!$user) return; - - $serverId = (int)$params['id']; - + if (!$user) + return; + + $serverId = (int) $params['id']; + try { $server = new VpnServer($serverId); $serverData = $server->getData(); - + // Check ownership or admin if ($serverData['user_id'] != $user['id'] && $user['role'] !== 'admin') { http_response_code(403); echo json_encode(['error' => 'Forbidden']); return; } - + $backups = $server->listBackups(); - + echo json_encode([ 'success' => true, 'backups' => $backups, @@ -1096,38 +1949,39 @@ Router::get('/api/servers/{id}/backups', function ($params) { // API: Restore backup Router::post('/api/servers/{id}/restore', function ($params) { header('Content-Type: application/json'); - + $user = requireApiAuth(); - if (!$user) return; - - $serverId = (int)$params['id']; + if (!$user) + return; + + $serverId = (int) $params['id']; $raw = file_get_contents('php://input'); $data = json_decode($raw, true); - - $backupId = (int)($data['backup_id'] ?? 0); - + + $backupId = (int) ($data['backup_id'] ?? 0); + if ($backupId <= 0) { http_response_code(400); echo json_encode(['error' => 'backup_id is required']); return; } - + try { $server = new VpnServer($serverId); $serverData = $server->getData(); - + // Check ownership or admin if ($serverData['user_id'] != $user['id'] && $user['role'] !== 'admin') { http_response_code(403); echo json_encode(['error' => 'Forbidden']); return; } - + $result = $server->restoreBackup($backupId); - + // Log the result for debugging error_log('Restore backup result: ' . json_encode($result)); - + // Always return the result, even if success is false echo json_encode($result); } catch (Exception $e) { @@ -1140,34 +1994,35 @@ Router::post('/api/servers/{id}/restore', function ($params) { // API: Delete backup Router::delete('/api/backups/{id}', function ($params) { header('Content-Type: application/json'); - + $user = requireApiAuth(); - if (!$user) return; - - $backupId = (int)$params['id']; - + if (!$user) + return; + + $backupId = (int) $params['id']; + try { $backup = VpnServer::getBackup($backupId); - + if (!$backup) { http_response_code(404); echo json_encode(['error' => 'Backup not found']); return; } - + // Get server to check ownership $server = new VpnServer($backup['server_id']); $serverData = $server->getData(); - + // Check ownership or admin if ($serverData['user_id'] != $user['id'] && $user['role'] !== 'admin') { http_response_code(403); echo json_encode(['error' => 'Forbidden']); return; } - + VpnServer::deleteBackup($backupId); - + echo json_encode([ 'success' => true, 'message' => 'Backup deleted successfully' @@ -1181,10 +2036,11 @@ Router::delete('/api/backups/{id}', function ($params) { // API: List clients Router::get('/api/clients', function () { header('Content-Type: application/json'); - + $user = JWT::requireAuth(); - if (!$user) return; - + if (!$user) + return; + $clients = VpnClient::listByUser($user['id']); echo json_encode(['clients' => $clients]); }); @@ -1192,31 +2048,32 @@ Router::get('/api/clients', function () { // API: Get client details with stats Router::get('/api/clients/{id}/details', function ($params) { header('Content-Type: application/json'); - + $user = JWT::requireAuth(); - if (!$user) return; - - $clientId = (int)$params['id']; - + if (!$user) + return; + + $clientId = (int) $params['id']; + try { $client = new VpnClient($clientId); $clientData = $client->getData(); - + // Check ownership if ($clientData['user_id'] != $user['id']) { http_response_code(403); echo json_encode(['error' => 'Forbidden']); return; } - + // Sync stats before returning $client->syncStats(); - + // Reload data $client = new VpnClient($clientId); $clientData = $client->getData(); $stats = $client->getFormattedStats(); - + echo json_encode([ 'success' => true, 'client' => [ @@ -1243,23 +2100,24 @@ Router::get('/api/clients/{id}/details', function ($params) { // API: Get client QR code Router::get('/api/clients/{id}/qr', function ($params) { header('Content-Type: application/json'); - + $user = JWT::requireAuth(); - if (!$user) return; - - $clientId = (int)$params['id']; - + if (!$user) + return; + + $clientId = (int) $params['id']; + try { $client = new VpnClient($clientId); $clientData = $client->getData(); - + // Check ownership if ($clientData['user_id'] != $user['id']) { http_response_code(403); echo json_encode(['error' => 'Forbidden']); return; } - + echo json_encode([ 'success' => true, 'qr_code' => $clientData['qr_code'], @@ -1274,23 +2132,24 @@ Router::get('/api/clients/{id}/qr', function ($params) { // API: Revoke client Router::post('/api/clients/{id}/revoke', function ($params) { header('Content-Type: application/json'); - + $user = JWT::requireAuth(); - if (!$user) return; - - $clientId = (int)$params['id']; - + if (!$user) + return; + + $clientId = (int) $params['id']; + try { $client = new VpnClient($clientId); $clientData = $client->getData(); - + // Check ownership if ($clientData['user_id'] != $user['id']) { http_response_code(403); echo json_encode(['error' => 'Forbidden']); return; } - + if ($client->revoke()) { echo json_encode(['success' => true, 'message' => 'Client revoked']); } else { @@ -1306,23 +2165,24 @@ Router::post('/api/clients/{id}/revoke', function ($params) { // API: Restore client Router::post('/api/clients/{id}/restore', function ($params) { header('Content-Type: application/json'); - + $user = JWT::requireAuth(); - if (!$user) return; - - $clientId = (int)$params['id']; - + if (!$user) + return; + + $clientId = (int) $params['id']; + try { $client = new VpnClient($clientId); $clientData = $client->getData(); - + // Check ownership if ($clientData['user_id'] != $user['id']) { http_response_code(403); echo json_encode(['error' => 'Forbidden']); return; } - + if ($client->restore()) { echo json_encode(['success' => true, 'message' => 'Client restored']); } else { @@ -1338,11 +2198,11 @@ Router::post('/api/clients/{id}/restore', function ($params) { // API: Get server metrics Router::get('/api/servers/{id}/metrics', function ($params) { header('Content-Type: application/json'); - + // Check authentication - either JWT or session $user = null; $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; - + if ($authHeader && preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) { // JWT authentication $token = $matches[1]; @@ -1351,29 +2211,29 @@ Router::get('/api/servers/{id}/metrics', function ($params) { // Session authentication $user = Auth::user(); } - + if (!$user) { http_response_code(401); echo json_encode(['error' => 'Unauthorized']); return; } - - $serverId = (int)$params['id']; - $hours = isset($_GET['hours']) ? (float)$_GET['hours'] : 24; - + + $serverId = (int) $params['id']; + $hours = isset($_GET['hours']) ? (float) $_GET['hours'] : 24; + try { $server = new VpnServer($serverId); $serverData = $server->getData(); - + // Check ownership if ($serverData['user_id'] != $user['id'] && $user['role'] !== 'admin') { http_response_code(403); echo json_encode(['error' => 'Forbidden']); return; } - + $metrics = ServerMonitoring::getServerMetrics($serverId, $hours); - + echo json_encode(['success' => true, 'metrics' => $metrics]); } catch (Exception $e) { http_response_code(500); @@ -1384,11 +2244,11 @@ Router::get('/api/servers/{id}/metrics', function ($params) { // API: Get client metrics Router::get('/api/clients/{id}/metrics', function ($params) { header('Content-Type: application/json'); - + // Check authentication - either JWT or session $user = null; $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; - + if ($authHeader && preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) { // JWT authentication $token = $matches[1]; @@ -1397,33 +2257,33 @@ Router::get('/api/clients/{id}/metrics', function ($params) { // Session authentication $user = Auth::user(); } - + if (!$user) { http_response_code(401); echo json_encode(['error' => 'Unauthorized']); return; } - - $clientId = (int)$params['id']; - $hours = isset($_GET['hours']) ? (float)$_GET['hours'] : 24; - + + $clientId = (int) $params['id']; + $hours = isset($_GET['hours']) ? (float) $_GET['hours'] : 24; + try { $client = new VpnClient($clientId); $clientData = $client->getData(); - + // Get server to check ownership $server = new VpnServer($clientData['server_id']); $serverData = $server->getData(); - + // Check ownership if ($serverData['user_id'] != $user['id'] && $user['role'] !== 'admin') { http_response_code(403); echo json_encode(['error' => 'Forbidden']); return; } - + $metrics = ServerMonitoring::getClientMetrics($clientId, $hours); - + echo json_encode(['success' => true, 'metrics' => $metrics]); } catch (Exception $e) { http_response_code(500); @@ -1434,37 +2294,37 @@ Router::get('/api/clients/{id}/metrics', function ($params) { // API: Get server clients Router::get('/api/servers/{id}/clients', function ($params) { header('Content-Type: application/json'); - + $user = authenticateRequest(); if (!$user) { http_response_code(401); echo json_encode(['error' => 'Unauthorized']); return; } - - $serverId = (int)$params['id']; - + + $serverId = (int) $params['id']; + try { $server = new VpnServer($serverId); $serverData = $server->getData(); - + // Check ownership if ($serverData['user_id'] != $user['id'] && $user['role'] !== 'admin') { http_response_code(403); echo json_encode(['error' => 'Forbidden']); return; } - + // Sync all stats first VpnClient::syncAllStatsForServer($serverId); - + $clients = VpnClient::listByServer($serverId); $clientsData = []; - + foreach ($clients as $clientData) { $client = new VpnClient($clientData['id']); $stats = $client->getFormattedStats(); - + $clientsData[] = [ 'id' => $clientData['id'], 'name' => $clientData['name'], @@ -1477,7 +2337,7 @@ Router::get('/api/servers/{id}/clients', function ($params) { 'last_handshake' => $clientData['last_handshake'], ]; } - + echo json_encode(['success' => true, 'clients' => $clientsData]); } catch (Exception $e) { http_response_code(500); @@ -1485,32 +2345,864 @@ Router::get('/api/servers/{id}/clients', function ($params) { } }); +// API: List server protocols +Router::get('/api/servers/{id}/protocols', function ($params) { + header('Content-Type: application/json'); + + $user = JWT::requireAuth(); + if (!$user) + return; + + $serverId = (int) $params['id']; + + try { + $server = new VpnServer($serverId); + $serverData = $server->getData(); + + if ($serverData['user_id'] != $user['id'] && $user['role'] !== 'admin') { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT sp.protocol_id, sp.config_data, sp.applied_at, p.name, p.slug, p.description FROM server_protocols sp JOIN protocols p ON p.id = sp.protocol_id WHERE sp.server_id = ? ORDER BY p.name'); + $stmt->execute([$serverId]); + $rows = $stmt->fetchAll(); + + echo json_encode(['success' => true, 'protocols' => $rows], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); + +// API: List active (installable) protocols +Router::get('/api/protocols/active', function () { + header('Content-Type: application/json'); + + $user = JWT::requireAuth(); + if (!$user) + return; + + $protocols = InstallProtocolManager::listActive(); + $list = array_map(function ($p) { + return [ + 'id' => (int) ($p['id'] ?? 0), + 'slug' => $p['slug'] ?? '', + 'name' => $p['name'] ?? '', + 'description' => $p['description'] ?? null, + ]; + }, $protocols); + + echo json_encode(['success' => true, 'protocols' => $list], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); +}); + +// API: Install/activate protocol on server +Router::post('/api/servers/{id}/protocols/install', function ($params) { + header('Content-Type: application/json'); + + $user = JWT::requireAuth(); + if (!$user) + return; + + $serverId = (int) $params['id']; + $rawBody = file_get_contents('php://input'); + $input = []; + if ($rawBody !== false && trim($rawBody) !== '') { + $decoded = json_decode($rawBody, true); + if (is_array($decoded)) { + $input = $decoded; + } + } + if (empty($input) && !empty($_POST)) { + $input = $_POST; + } + $protocolId = isset($input['protocol_id']) ? (int) $input['protocol_id'] : 0; + + if ($protocolId <= 0) { + http_response_code(400); + echo json_encode(['error' => 'protocol_id required']); + return; + } + + try { + $server = new VpnServer($serverId); + $serverData = $server->getData(); + + if ($serverData['user_id'] != $user['id'] && $user['role'] !== 'admin') { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + $protocol = InstallProtocolManager::getById($protocolId); + if (!$protocol) { + http_response_code(404); + echo json_encode(['error' => 'Protocol not found']); + return; + } + + $result = InstallProtocolManager::activate($server, $protocol, []); + echo json_encode($result, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); + +// API: Self-test WireGuard/AWG install + client config correctness +// Creates a client (optional) and verifies that: +// - Client private key derives the same public key as stored in DB +// - Server wg0 knows the peer and has matching PSK/AllowedIPs +// - Server public key/listening port match what client config contains +// - Reports current handshake/endpoint state +Router::post('/api/servers/{id}/protocols/selftest', function ($params) { + header('Content-Type: application/json'); + + $user = JWT::requireAuth(); + if (!$user) + return; + + $serverId = (int) ($params['id'] ?? 0); + if ($serverId <= 0) { + http_response_code(400); + echo json_encode(['error' => 'Invalid server id']); + return; + } + + $raw = file_get_contents('php://input'); + $data = []; + if (is_string($raw) && trim($raw) !== '') { + $decoded = json_decode($raw, true); + if (is_array($decoded)) { + $data = $decoded; + } + } + if (empty($data) && !empty($_POST)) { + $data = $_POST; + } + if (!is_array($data)) { + $data = []; + } + + $protocolId = isset($data['protocol_id']) ? (int) $data['protocol_id'] : 0; + $install = !empty($data['install']); + $clientId = isset($data['client_id']) ? (int) $data['client_id'] : 0; + $createClient = array_key_exists('create_client', $data) ? (bool) $data['create_client'] : true; + $clientName = trim((string) ($data['client_name'] ?? 'selftest-' . date('Ymd-His'))); + $includeSecrets = !empty($data['include_secrets']) && (($user['role'] ?? '') === 'admin'); + + $extract = function (string $config, string $key): string { + $pattern = '/^\s*' . preg_quote($key, '/') . '\s*=\s*(.+)\s*$/mi'; + if (preg_match($pattern, $config, $m)) { + return trim($m[1]); + } + return ''; + }; + + $deriveWgPublicKey = function (string $privateKeyB64): array { + $privateKeyB64 = trim($privateKeyB64); + if ($privateKeyB64 === '') { + return ['ok' => false, 'error' => 'empty_private_key']; + } + + $raw = base64_decode($privateKeyB64, true); + if ($raw === false || strlen($raw) !== 32) { + return ['ok' => false, 'error' => 'invalid_private_key_base64_or_length']; + } + + if (!function_exists('sodium_crypto_scalarmult_base')) { + return ['ok' => false, 'error' => 'libsodium_not_available']; + } + + try { + $pubRaw = sodium_crypto_scalarmult_base($raw); + return ['ok' => true, 'public_key' => base64_encode($pubRaw)]; + } catch (Throwable $e) { + return ['ok' => false, 'error' => 'derive_failed: ' . $e->getMessage()]; + } + }; + + try { + $server = new VpnServer($serverId); + $serverData = $server->getData(); + + if (($serverData['user_id'] ?? null) != $user['id'] && ($user['role'] ?? '') !== 'admin') { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + if ($protocolId > 0 && $install) { + $protocol = InstallProtocolManager::getById($protocolId); + if (!$protocol) { + http_response_code(404); + echo json_encode(['error' => 'Protocol not found']); + return; + } + InstallProtocolManager::activate($server, $protocol, []); + } + + $client = null; + if ($clientId > 0) { + $client = new VpnClient($clientId); + $clientData = $client->getData(); + if (($clientData['server_id'] ?? null) != $serverId) { + http_response_code(400); + echo json_encode(['error' => 'client_id does not belong to server']); + return; + } + if (($clientData['user_id'] ?? null) != $user['id'] && ($user['role'] ?? '') !== 'admin') { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + } elseif ($createClient) { + $bindProtocolId = $protocolId > 0 ? $protocolId : null; + if ($bindProtocolId !== null) { + $pdo = DB::conn(); + $chk = $pdo->prepare('SELECT 1 FROM server_protocols WHERE server_id = ? AND protocol_id = ?'); + $chk->execute([$serverId, $bindProtocolId]); + if (!$chk->fetchColumn()) { + http_response_code(400); + echo json_encode(['error' => 'protocol_id is not installed on this server']); + return; + } + } + $newClientId = VpnClient::create($serverId, (int) $user['id'], $clientName, null, $bindProtocolId); + $client = new VpnClient($newClientId); + } else { + http_response_code(400); + echo json_encode(['error' => 'Provide client_id or set create_client=true']); + return; + } + + $clientData = $client->getData(); + $config = $client->getConfig(); + + $cfgPrivate = $extract($config, 'PrivateKey'); + $cfgPsk = $extract($config, 'PresharedKey'); + $cfgServerPub = $extract($config, 'PublicKey'); + $cfgEndpoint = $extract($config, 'Endpoint'); + $cfgAddress = $extract($config, 'Address'); + + if ($cfgPrivate === '') { + http_response_code(500); + echo json_encode([ + 'error' => 'Generated config missing PrivateKey', + 'client_id' => (int) ($clientData['id'] ?? 0), + ]); + return; + } + + $containerName = (string) ($serverData['container_name'] ?? 'amnezia-awg'); + + $derived = $deriveWgPublicKey($cfgPrivate); + $computedClientPub = ''; + if (!empty($derived['ok']) && !empty($derived['public_key'])) { + $computedClientPub = (string) $derived['public_key']; + } else { + $err = (string) ($derived['error'] ?? 'derive_failed'); + // If we can't derive locally (e.g., libsodium missing), fall back to wg inside container. + if ($err === 'libsodium_not_available') { + $shComputePub = "set -e; priv=" . escapeshellarg($cfgPrivate) . "; printf '%s' \"$priv\" | wg pubkey"; + $cmdComputePub = "docker exec -i " . escapeshellarg($containerName) . " sh -c " . escapeshellarg($shComputePub); + $computedClientPub = trim($server->executeCommand($cmdComputePub, true)); + } else { + $computedClientPub = $err; + } + } + + $cmdServerPub = "docker exec -i " . escapeshellarg($containerName) . " sh -c " . escapeshellarg("wg show wg0 2>/dev/null | awk '/public key:/ {print \$3; exit}' || true"); + $serverPubLive = trim($server->executeCommand($cmdServerPub, true)); + + $cmdServerPort = "docker exec -i " . escapeshellarg($containerName) . " sh -c " . escapeshellarg("wg show wg0 2>/dev/null | awk '/listening port:/ {print \$3; exit}' || true"); + $serverPortLive = trim($server->executeCommand($cmdServerPort, true)); + + $cmdPskFile = "docker exec -i " . escapeshellarg($containerName) . " sh -c " . escapeshellarg("cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true"); + $serverPskFile = trim($server->executeCommand($cmdPskFile, true)); + + $cmdDump = "docker exec -i " . escapeshellarg($containerName) . " sh -c " . escapeshellarg("wg show wg0 dump 2>/dev/null || true"); + $dump = (string) $server->executeCommand($cmdDump, true); + + $targetPeerPub = $computedClientPub; + $looksLikeB64Key = (bool) preg_match('/^[A-Za-z0-9+\/]{42,44}={0,2}$/', $targetPeerPub); + $isDeriveError = ($targetPeerPub === '' + || !$looksLikeB64Key + || strpos($targetPeerPub, 'invalid_') === 0 + || strpos($targetPeerPub, 'derive_failed') === 0 + || strpos($targetPeerPub, 'wg:') === 0 + || $targetPeerPub === 'libsodium_not_available' + || $targetPeerPub === 'empty_private_key'); + if ($isDeriveError) { + $targetPeerPub = (string) ($clientData['public_key'] ?? ''); + } + + $peer = null; + $lines = preg_split('/\r?\n/', trim($dump)); + foreach ($lines as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + $parts = preg_split('/\s+/', $line); + // Peer line format: public-key preshared-key endpoint allowed-ips latest-handshake transfer-rx transfer-tx persistent-keepalive + if (!$parts || count($parts) < 8) { + continue; + } + // Skip interface line: starts with interface name 'wg0' + if ($parts[0] === 'wg0') { + continue; + } + if ($targetPeerPub !== '' && hash_equals($parts[0], $targetPeerPub)) { + $peer = [ + 'public_key' => $parts[0], + 'preshared_key' => $parts[1], + 'endpoint' => $parts[2], + 'allowed_ips' => $parts[3], + 'latest_handshake' => (int) $parts[4], + 'transfer_rx' => (int) $parts[5], + 'transfer_tx' => (int) $parts[6], + 'persistent_keepalive' => $parts[7], + ]; + break; + } + } + + $checks = []; + $mismatches = []; + + $dbClientPub = (string) ($clientData['public_key'] ?? ''); + if ($dbClientPub !== '' && $computedClientPub !== '' && !hash_equals($dbClientPub, $computedClientPub)) { + $mismatches[] = 'client_public_key_db_mismatch'; + } + if ($serverPubLive !== '' && $cfgServerPub !== '' && !hash_equals($serverPubLive, $cfgServerPub)) { + $mismatches[] = 'server_public_key_mismatch'; + } + if ($serverPskFile !== '' && $cfgPsk !== '' && !hash_equals($serverPskFile, $cfgPsk)) { + $mismatches[] = 'preshared_key_mismatch'; + } + if ($peer === null) { + $mismatches[] = 'peer_not_found_on_server'; + } + + $checks['client_public_key'] = [ + 'db' => $dbClientPub, + 'computed_from_private' => $computedClientPub, + 'ok' => ($dbClientPub === '' || $computedClientPub === '') ? null : hash_equals($dbClientPub, $computedClientPub), + ]; + $checks['server_public_key'] = [ + 'config' => $cfgServerPub, + 'live' => $serverPubLive, + 'ok' => ($cfgServerPub === '' || $serverPubLive === '') ? null : hash_equals($cfgServerPub, $serverPubLive), + ]; + $checks['preshared_key'] = [ + 'config' => $includeSecrets ? $cfgPsk : ($cfgPsk !== '' ? (substr($cfgPsk, 0, 6) . '...') : ''), + 'server_file' => $includeSecrets ? $serverPskFile : ($serverPskFile !== '' ? (substr($serverPskFile, 0, 6) . '...') : ''), + 'ok' => ($cfgPsk === '' || $serverPskFile === '') ? null : hash_equals($cfgPsk, $serverPskFile), + ]; + + echo json_encode([ + 'success' => empty($mismatches), + 'server_id' => $serverId, + 'protocol_id' => $protocolId > 0 ? $protocolId : null, + 'client' => [ + 'id' => (int) ($clientData['id'] ?? 0), + 'name' => $clientData['name'] ?? null, + 'client_ip' => $clientData['client_ip'] ?? null, + 'public_key_db' => $dbClientPub, + 'public_key_computed' => $computedClientPub, + 'address_in_config' => $cfgAddress, + 'endpoint_in_config' => $cfgEndpoint, + 'private_key' => $includeSecrets ? $cfgPrivate : ($cfgPrivate !== '' ? (substr($cfgPrivate, 0, 6) . '...') : ''), + ], + 'wg' => [ + 'server_public_key_live' => $serverPubLive, + 'server_listen_port_live' => $serverPortLive, + 'peer' => $peer, + ], + 'checks' => $checks, + 'mismatches' => $mismatches, + ], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); + +// API: Diagnose why WG/AWG handshake is not happening +// Collects server-side evidence: wg show, peer dump, docker port mapping, basic firewall/NAT snippets, +// and (if available) a short tcpdump capture on the VPN UDP port. +Router::post('/api/servers/{id}/protocols/diagnose-handshake', function ($params) { + header('Content-Type: application/json'); + + $user = JWT::requireAuth(); + if (!$user) + return; + + // High-sensitivity endpoint (firewall rules, tcpdump). Keep it admin-only unless explicitly enabled. + $debugEnabled = debugRoutesEnabled(); + if (($user['role'] ?? '') !== 'admin' && !$debugEnabled) { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + $serverId = (int) ($params['id'] ?? 0); + if ($serverId <= 0) { + http_response_code(400); + echo json_encode(['error' => 'Invalid server id']); + return; + } + + $raw = file_get_contents('php://input'); + $data = []; + if (is_string($raw) && trim($raw) !== '') { + $decoded = json_decode($raw, true); + if (is_array($decoded)) { + $data = $decoded; + } + } + if (empty($data) && !empty($_POST)) { + $data = $_POST; + } + if (!is_array($data)) { + $data = []; + } + + $clientId = isset($data['client_id']) ? (int) $data['client_id'] : 0; + $duration = isset($data['duration_seconds']) ? (int) $data['duration_seconds'] : 5; + if ($duration < 1) + $duration = 1; + if ($duration > 15) + $duration = 15; + + $deriveWgPublicKey = function (string $privateKeyB64): array { + $privateKeyB64 = trim($privateKeyB64); + if ($privateKeyB64 === '') { + return ['ok' => false, 'error' => 'empty_private_key']; + } + + $raw = base64_decode($privateKeyB64, true); + if ($raw === false || strlen($raw) !== 32) { + return ['ok' => false, 'error' => 'invalid_private_key_base64_or_length']; + } + + if (!function_exists('sodium_crypto_scalarmult_base')) { + return ['ok' => false, 'error' => 'libsodium_not_available']; + } + + try { + $pubRaw = sodium_crypto_scalarmult_base($raw); + return ['ok' => true, 'public_key' => base64_encode($pubRaw)]; + } catch (Throwable $e) { + return ['ok' => false, 'error' => 'derive_failed: ' . $e->getMessage()]; + } + }; + + try { + $server = new VpnServer($serverId); + $serverData = $server->getData(); + + if (($serverData['user_id'] ?? null) != $user['id'] && ($user['role'] ?? '') !== 'admin') { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + $containerName = (string) ($serverData['container_name'] ?? 'amnezia-awg'); + $vpnPort = (int) ($serverData['vpn_port'] ?? 0); + + // Optionally derive client public key from stored client config + $clientPub = ''; + $clientPubError = ''; + $clientIp = ''; + if ($clientId > 0) { + $client = new VpnClient($clientId); + $clientData = $client->getData(); + if (($clientData['server_id'] ?? null) != $serverId) { + http_response_code(400); + echo json_encode(['error' => 'client_id does not belong to server']); + return; + } + if (($clientData['user_id'] ?? null) != $user['id'] && ($user['role'] ?? '') !== 'admin') { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + $clientIp = (string) ($clientData['client_ip'] ?? ''); + + $cfg = $client->getConfig(); + $cfgPrivate = ''; + if (preg_match('/^\s*PrivateKey\s*=\s*(.+)\s*$/mi', $cfg, $m)) { + $cfgPrivate = trim($m[1]); + } + if ($cfgPrivate !== '') { + $derived = $deriveWgPublicKey($cfgPrivate); + if (!empty($derived['ok'])) { + $clientPub = (string) ($derived['public_key'] ?? ''); + } else { + $clientPubError = (string) ($derived['error'] ?? 'derive_failed'); + // Fallback: compute using wg inside container (best-effort) + $shComputePub = "set -e; priv=" . escapeshellarg($cfgPrivate) . "; printf '%s' \"$priv\" | wg pubkey"; + $cmdComputePub = "docker exec -i " . escapeshellarg($containerName) . " sh -c " . escapeshellarg($shComputePub); + $computed = trim((string) $server->executeCommand($cmdComputePub, true)); + // Basic validation: wg outputs a 44-char base64 key + if (preg_match('/^[A-Za-z0-9+\/]{42}==$/', $computed)) { + $clientPub = $computed; + $clientPubError = ''; + } elseif ($computed !== '') { + $clientPubError = 'wg_pubkey_failed: ' . $computed; + } + } + } + } + + // Gather status + $cmdHostDate = "date -u '+%Y-%m-%dT%H:%M:%SZ'"; + $hostDate = trim($server->executeCommand($cmdHostDate, true)); + + $cmdDockerPs = "docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}' | head -50"; + $dockerPs = $server->executeCommand($cmdDockerPs, true); + + $cmdInspectPorts = "docker inspect " . escapeshellarg($containerName) . " --format '{{json .NetworkSettings.Ports}}' 2>/dev/null || true"; + $dockerPortsJson = trim($server->executeCommand($cmdInspectPorts, true)); + + $cmdWgShow = "docker exec -i " . escapeshellarg($containerName) . " sh -c " . escapeshellarg("wg show wg0 2>/dev/null || true"); + $wgShow = $server->executeCommand($cmdWgShow, true); + + $cmdWgDump = "docker exec -i " . escapeshellarg($containerName) . " sh -c " . escapeshellarg("wg show wg0 dump 2>/dev/null || true"); + $wgDump = $server->executeCommand($cmdWgDump, true); + + $cmdHostSs = ($vpnPort > 0) + ? ("sh -c " . escapeshellarg("ss -lun | grep -E '(:" . (int) $vpnPort . ")\\b' || true")) + : "sh -c 'echo no_vpn_port_in_db'"; + $hostUdpListen = $server->executeCommand($cmdHostSs, true); + + $cmdCtnSs = ($vpnPort > 0) + ? ("docker exec -i " . escapeshellarg($containerName) . " sh -c " . escapeshellarg("ss -lun 2>/dev/null | grep -E '(:" . (int) $vpnPort . ")\\b' || true")) + : "docker exec -i " . escapeshellarg($containerName) . " sh -c 'echo no_vpn_port_in_db'"; + $containerUdpListen = $server->executeCommand($cmdCtnSs, true); + + // Firewall/NAT snippets (best-effort) + $cmdUfw = "sh -c " . escapeshellarg("ufw status verbose 2>/dev/null || echo 'no ufw'"); + $ufw = $server->executeCommand($cmdUfw, true); + + $cmdIpt = "sh -c " . escapeshellarg("iptables -S INPUT 2>/dev/null | grep -E 'udp|" . (int) $vpnPort . "' | head -80 || true"); + $iptablesInput = $server->executeCommand($cmdIpt, true); + + $cmdNft = "sh -c " . escapeshellarg("nft list ruleset 2>/dev/null | grep -n '" . (int) $vpnPort . "' | head -60 || true"); + $nftPortLines = $server->executeCommand($cmdNft, true); + + // Probe changes over time (wg dump + nft counters) during the same request. + $wgShowAfter = ''; + $wgDumpAfter = ''; + $nftPortLinesAfter = ''; + if ($duration > 0) { + $cmdSleep = "sh -c " . escapeshellarg("sleep " . (int) $duration); + $server->executeCommand($cmdSleep, true); + $wgShowAfter = $server->executeCommand($cmdWgShow, true); + $wgDumpAfter = $server->executeCommand($cmdWgDump, true); + $nftPortLinesAfter = $server->executeCommand($cmdNft, true); + } + + // tcpdump capture (optional) + $tcpdump = ''; + if ($vpnPort > 0) { + $tcpCmd = "sh -c " . escapeshellarg( + "command -v tcpdump >/dev/null 2>&1 && command -v timeout >/dev/null 2>&1 && timeout " . (int) $duration . " tcpdump -ni any udp port " . (int) $vpnPort . " -vv -c 10 2>/dev/null || echo 'tcpdump_unavailable_or_timeout_missing'" + ); + $tcpdump = $server->executeCommand($tcpCmd, true); + } + + // Try to extract peer line if clientPub is known + $peerLine = ''; + $peerLineAfter = ''; + if ($clientPub !== '' && is_string($wgDump) && trim($wgDump) !== '') { + $lines = preg_split('/\r?\n/', trim($wgDump)); + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, 'wg0')) { + continue; + } + $parts = preg_split('/\s+/', $line); + if ($parts && isset($parts[0]) && hash_equals($parts[0], $clientPub)) { + $peerLine = $line; + break; + } + } + } + + if ($clientPub !== '' && is_string($wgDumpAfter) && trim($wgDumpAfter) !== '') { + $lines = preg_split('/\r?\n/', trim($wgDumpAfter)); + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, 'wg0')) { + continue; + } + $parts = preg_split('/\s+/', $line); + if ($parts && isset($parts[0]) && hash_equals($parts[0], $clientPub)) { + $peerLineAfter = $line; + break; + } + } + } + + $hints = []; + if ($vpnPort <= 0) { + $hints[] = 'vpn_port is missing in DB; endpoint may be wrong'; + } + if (is_string($tcpdump) && str_contains($tcpdump, 'tcpdump_unavailable_or_timeout_missing')) { + $hints[] = 'tcpdump/timeout not available on server; use nft counters or install tcpdump for deeper packet visibility'; + } + if ($clientPub === '' && $clientPubError !== '') { + $hints[] = 'failed to derive client public key from stored config: ' . $clientPubError; + } + if ($clientPub !== '' && $peerLine === '') { + $hints[] = 'peer not found in wg dump for derived client public key (client might not be applied on server)'; + } + + echo json_encode([ + 'success' => true, + 'server_id' => $serverId, + 'checked_at_utc' => $hostDate, + 'container_name' => $containerName, + 'vpn_port_db' => $vpnPort, + 'client' => [ + 'client_id' => $clientId > 0 ? $clientId : null, + 'client_ip' => $clientIp !== '' ? $clientIp : null, + 'client_public_key_derived' => $clientPub !== '' ? $clientPub : null, + 'client_public_key_derive_error' => $clientPubError !== '' ? $clientPubError : null, + 'peer_line_from_dump' => $peerLine !== '' ? $peerLine : null, + 'peer_line_from_dump_after' => $peerLineAfter !== '' ? $peerLineAfter : null, + ], + 'evidence' => [ + 'docker_ps' => $dockerPs, + 'docker_ports_json' => $dockerPortsJson, + 'host_udp_listen' => $hostUdpListen, + 'container_udp_listen' => $containerUdpListen, + 'wg_show' => $wgShow, + 'wg_dump' => $wgDump, + 'wg_show_after' => $wgShowAfter, + 'wg_dump_after' => $wgDumpAfter, + 'ufw' => $ufw, + 'iptables_input_snippet' => $iptablesInput, + 'nft_port_lines' => $nftPortLines, + 'nft_port_lines_after' => $nftPortLinesAfter, + 'tcpdump' => $tcpdump, + ], + 'hints' => $hints, + ], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); + +// API: Uninstall all protocols from server +Router::post('/api/servers/{id}/protocols/uninstall-all', function ($params) { + header('Content-Type: application/json'); + + $user = JWT::requireAuth(); + if (!$user) + return; + + $serverId = (int) $params['id']; + + try { + $server = new VpnServer($serverId); + $serverData = $server->getData(); + + if ($serverData['user_id'] != $user['id'] && $user['role'] !== 'admin') { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT sp.protocol_id, p.slug FROM server_protocols sp JOIN protocols p ON p.id = sp.protocol_id WHERE sp.server_id = ?'); + $stmt->execute([$serverId]); + $rows = $stmt->fetchAll(); + + $removedClients = 0; + $removedBindings = 0; + $uninstalled = []; + $errors = []; + + foreach ($rows as $row) { + $pid = (int) ($row['protocol_id'] ?? 0); + $slug = (string) ($row['slug'] ?? ''); + if ($pid <= 0 || $slug === '') { + continue; + } + + try { + $protocol = InstallProtocolManager::getById($pid); + if (!$protocol) { + throw new Exception('Protocol not found'); + } + + InstallProtocolManager::uninstall($server, $protocol, []); + + $stmtDelSp = $pdo->prepare('DELETE FROM server_protocols WHERE server_id = ? AND protocol_id = ?'); + $stmtDelSp->execute([$serverId, $pid]); + $removedBindings += (int) $stmtDelSp->rowCount(); + + $stmtDelClients = $pdo->prepare('DELETE FROM vpn_clients WHERE server_id = ? AND protocol_id = ?'); + $stmtDelClients->execute([$serverId, $pid]); + $removedClients += (int) $stmtDelClients->rowCount(); + + $uninstalled[] = ['protocol_id' => $pid, 'slug' => $slug]; + } catch (Exception $e) { + $errors[] = ['protocol_id' => $pid, 'slug' => $slug, 'error' => $e->getMessage()]; + } + } + + $pdo->prepare('UPDATE vpn_servers SET status = ?, error_message = NULL WHERE id = ?')->execute(['active', $serverId]); + + echo json_encode([ + 'success' => empty($errors), + 'uninstalled' => $uninstalled, + 'errors' => $errors, + 'bindings_removed' => $removedBindings, + 'clients_removed' => $removedClients, + ], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); + +// API: Uninstall protocol on server by slug +Router::post('/api/servers/{id}/protocols/{slug}/uninstall', function ($params) { + header('Content-Type: application/json'); + + $user = JWT::requireAuth(); + if (!$user) + return; + + $serverId = (int) $params['id']; + $slug = $params['slug'] ?? ''; + + try { + $server = new VpnServer($serverId); + $serverData = $server->getData(); + + if ($serverData['user_id'] != $user['id'] && $user['role'] !== 'admin') { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + $protocol = InstallProtocolManager::getBySlug($slug); + if (!$protocol) { + http_response_code(404); + echo json_encode(['error' => 'Protocol not found']); + return; + } + + $result = InstallProtocolManager::uninstall($server, $protocol); + + // Cleanup bindings + clients (same behavior as UI route) + $pdo = DB::conn(); + $stmtId = $pdo->prepare('SELECT id FROM protocols WHERE slug = ? LIMIT 1'); + $stmtId->execute([$slug]); + $pid = (int) $stmtId->fetchColumn(); + $deletedClients = 0; + $deletedBindings = 0; + if ($pid) { + $stmtDelSp = $pdo->prepare('DELETE FROM server_protocols WHERE server_id = ? AND protocol_id = ?'); + $stmtDelSp->execute([$serverId, $pid]); + $deletedBindings = $stmtDelSp->rowCount(); + $stmtDelClients = $pdo->prepare('DELETE FROM vpn_clients WHERE server_id = ? AND protocol_id = ?'); + $stmtDelClients->execute([$serverId, $pid]); + $deletedClients = $stmtDelClients->rowCount(); + } + $stmtUpdate = $pdo->prepare('UPDATE vpn_servers SET status = ?, error_message = NULL WHERE id = ?'); + $stmtUpdate->execute(['active', $serverId]); + + echo json_encode(array_merge($result, [ + 'bindings_removed' => $deletedBindings, + 'clients_removed' => $deletedClients + ]), JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); + // API: Create client Router::post('/api/clients/create', function () { header('Content-Type: application/json'); - + $user = JWT::requireAuth(); - if (!$user) return; - + if (!$user) + return; + $raw = file_get_contents('php://input'); $data = json_decode($raw, true); - - $serverId = (int)($data['server_id'] ?? 0); + + $serverId = (int) ($data['server_id'] ?? 0); $name = trim($data['name'] ?? ''); - $expiresInDays = isset($data['expires_in_days']) ? (int)$data['expires_in_days'] : null; - + $expiresInDays = isset($data['expires_in_days']) ? (int) $data['expires_in_days'] : null; + $protocolId = isset($data['protocol_id']) ? (int) $data['protocol_id'] : null; + $username = isset($data['username']) ? trim((string) $data['username']) : null; + $login = isset($data['login']) ? trim((string) $data['login']) : null; + if ($serverId <= 0 || empty($name)) { http_response_code(400); echo json_encode(['error' => 'server_id and name are required']); return; } - + try { - $clientId = VpnClient::create($serverId, $user['id'], $name, $expiresInDays); - + $server = new VpnServer($serverId); + $serverData = $server->getData(); + if (($serverData['user_id'] ?? null) != $user['id'] && ($user['role'] ?? '') !== 'admin') { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + // Validate protocol_id is installed on server (if provided) + if ($protocolId !== null && $protocolId > 0) { + $pdo = DB::conn(); + $chk = $pdo->prepare('SELECT 1 FROM server_protocols WHERE server_id = ? AND protocol_id = ?'); + $chk->execute([$serverId, $protocolId]); + if (!$chk->fetchColumn()) { + http_response_code(400); + echo json_encode(['error' => 'protocol_id is not installed on this server']); + return; + } + } else { + $protocolId = null; + } + + $clientId = VpnClient::create($serverId, (int) $user['id'], $name, $expiresInDays, $protocolId, $username, $login); + $client = new VpnClient($clientId); + + // For WireGuard/AWG protocols, immediately regenerate config from live server state + // (AWG junk params + keys can change after reinstall/recreate). + // For amnezia-wg-advanced, fail fast if we can't obtain AWG params. + try { + $regen = $client->regenerateConfigFromServer(true); + if (is_array($regen) && empty($regen['success']) && ($regen['error'] ?? '') === 'awg_params_missing') { + http_response_code(500); + echo json_encode([ + 'error' => 'Failed to generate AWG-Advanced config: missing server AWG params', + 'result' => $regen, + ], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); + return; + } + } catch (Throwable $e) { + error_log('Failed to regenerate config after create: ' . $e->getMessage()); + } + $clientData = $client->getData(); - + // Return client data with config and QR code echo json_encode([ 'success' => true, @@ -1532,32 +3224,79 @@ Router::post('/api/clients/create', function () { } }); -// Set client expiration -Router::post('/api/clients/{id}/set-expiration', function ($params) { +// API: Force-regenerate client config/QR from current server state (WireGuard/AWG) +Router::post('/api/clients/{id}/regenerate-config', function ($params) { header('Content-Type: application/json'); - + $user = JWT::requireAuth(); - if (!$user) return; - - $clientId = (int)$params['id']; - $raw = file_get_contents('php://input'); - $data = json_decode($raw, true); - - $expiresAt = $data['expires_at'] ?? null; // Y-m-d H:i:s format or null - + if (!$user) + return; + + $clientId = (int) ($params['id'] ?? 0); + if ($clientId <= 0) { + http_response_code(400); + echo json_encode(['error' => 'Invalid client id']); + return; + } + try { $client = new VpnClient($clientId); $clientData = $client->getData(); - + + if (($clientData['user_id'] ?? null) != $user['id'] && ($user['role'] ?? '') !== 'admin') { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + $result = $client->regenerateConfigFromServer(true); + $clientData = $client->getData(); + + echo json_encode([ + 'success' => !empty($result['success']), + 'result' => $result, + 'client' => [ + 'id' => $clientData['id'], + 'name' => $clientData['name'], + 'server_id' => $clientData['server_id'], + 'client_ip' => $clientData['client_ip'], + 'config' => $clientData['config'], + 'qr_code' => $clientData['qr_code'], + ], + ], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); + +// Set client expiration +Router::post('/api/clients/{id}/set-expiration', function ($params) { + header('Content-Type: application/json'); + + $user = JWT::requireAuth(); + if (!$user) + return; + + $clientId = (int) $params['id']; + $raw = file_get_contents('php://input'); + $data = json_decode($raw, true); + + $expiresAt = $data['expires_at'] ?? null; // Y-m-d H:i:s format or null + + try { + $client = new VpnClient($clientId); + $clientData = $client->getData(); + // Check ownership if ($clientData['user_id'] != $user['id'] && $user['role'] !== 'admin') { http_response_code(403); echo json_encode(['error' => 'Forbidden']); return; } - + VpnClient::setExpiration($clientId, $expiresAt); - + echo json_encode([ 'success' => true, 'expires_at' => $expiresAt @@ -1571,39 +3310,40 @@ Router::post('/api/clients/{id}/set-expiration', function ($params) { // Extend client expiration Router::post('/api/clients/{id}/extend', function ($params) { header('Content-Type: application/json'); - + $user = JWT::requireAuth(); - if (!$user) return; - - $clientId = (int)$params['id']; + if (!$user) + return; + + $clientId = (int) $params['id']; $raw = file_get_contents('php://input'); $data = json_decode($raw, true); - - $days = (int)($data['days'] ?? 30); - + + $days = (int) ($data['days'] ?? 30); + if ($days <= 0) { http_response_code(400); echo json_encode(['error' => 'days must be positive']); return; } - + try { $client = new VpnClient($clientId); $clientData = $client->getData(); - + // Check ownership if ($clientData['user_id'] != $user['id'] && $user['role'] !== 'admin') { http_response_code(403); echo json_encode(['error' => 'Forbidden']); return; } - + VpnClient::extendExpiration($clientId, $days); - + // Get updated expiration $client = new VpnClient($clientId); $updated = $client->getData(); - + echo json_encode([ 'success' => true, 'expires_at' => $updated['expires_at'], @@ -1618,22 +3358,23 @@ Router::post('/api/clients/{id}/extend', function ($params) { // Get expiring clients Router::get('/api/clients/expiring', function () { header('Content-Type: application/json'); - + $user = JWT::requireAuth(); - if (!$user) return; - - $days = (int)($_GET['days'] ?? 7); - + if (!$user) + return; + + $days = (int) ($_GET['days'] ?? 7); + try { $clients = VpnClient::getExpiringClients($days); - + // Filter by user if not admin if ($user['role'] !== 'admin') { - $clients = array_filter($clients, function($c) use ($user) { + $clients = array_filter($clients, function ($c) use ($user) { return $c['user_id'] == $user['id']; }); } - + echo json_encode([ 'success' => true, 'clients' => array_values($clients), @@ -1648,36 +3389,37 @@ Router::get('/api/clients/expiring', function () { // Set client traffic limit Router::post('/api/clients/{id}/set-traffic-limit', function ($params) { header('Content-Type: application/json'); - + $user = JWT::requireAuth(); - if (!$user) return; - - $clientId = (int)$params['id']; + if (!$user) + return; + + $clientId = (int) $params['id']; $raw = file_get_contents('php://input'); $data = json_decode($raw, true); - + // limit_bytes can be null (unlimited) or positive integer - $limitBytes = isset($data['limit_bytes']) ? (int)$data['limit_bytes'] : null; - + $limitBytes = isset($data['limit_bytes']) ? (int) $data['limit_bytes'] : null; + if ($limitBytes !== null && $limitBytes < 0) { http_response_code(400); echo json_encode(['error' => 'limit_bytes must be positive or null for unlimited']); return; } - + try { $client = new VpnClient($clientId); $clientData = $client->getData(); - + // Check ownership if ($clientData['user_id'] != $user['id'] && $user['role'] !== 'admin') { http_response_code(403); echo json_encode(['error' => 'Forbidden']); return; } - + $client->setTrafficLimit($limitBytes); - + echo json_encode([ 'success' => true, 'limit_bytes' => $limitBytes, @@ -1692,25 +3434,26 @@ Router::post('/api/clients/{id}/set-traffic-limit', function ($params) { // Check client traffic limit status Router::get('/api/clients/{id}/traffic-limit-status', function ($params) { header('Content-Type: application/json'); - + $user = JWT::requireAuth(); - if (!$user) return; - - $clientId = (int)$params['id']; - + if (!$user) + return; + + $clientId = (int) $params['id']; + try { $client = new VpnClient($clientId); $clientData = $client->getData(); - + // Check ownership if ($clientData['user_id'] != $user['id'] && $user['role'] !== 'admin') { http_response_code(403); echo json_encode(['error' => 'Forbidden']); return; } - + $status = $client->getTrafficLimitStatus(); - + echo json_encode([ 'success' => true, 'status' => $status @@ -1724,20 +3467,21 @@ Router::get('/api/clients/{id}/traffic-limit-status', function ($params) { // Get clients over traffic limit Router::get('/api/clients/overlimit', function () { header('Content-Type: application/json'); - + $user = JWT::requireAuth(); - if (!$user) return; - + if (!$user) + return; + try { $clients = VpnClient::getClientsOverLimit(); - + // Filter by user if not admin if ($user['role'] !== 'admin') { - $clients = array_filter($clients, function($c) use ($user) { + $clients = array_filter($clients, function ($c) use ($user) { return $c['user_id'] == $user['id']; }); } - + echo json_encode([ 'success' => true, 'clients' => array_values($clients), @@ -1756,16 +3500,170 @@ Router::get('/api/clients/overlimit', function () { // Settings page Router::get('/settings', function () { requireAuth(); - + require_once __DIR__ . '/../controllers/SettingsController.php'; $controller = new SettingsController(); $controller->index(); }); +Router::get('/settings/protocols', function () { + requireAdmin(); + $params = []; + if (isset($_GET['id'])) { + $params[] = 'id=' . urlencode($_GET['id']); + } + if (isset($_GET['new'])) { + $params[] = 'new=1'; + } + $query = empty($params) ? '' : ('?' . implode('&', $params)); + redirect('/settings' . $query . '#protocols'); +}); + +// Legacy protocol routes removed in favor of ProtocolManagementController and /api/protocols endpoints + +// NEW PROTOCOL MANAGEMENT ROUTES +Router::get('/settings/protocols-management', function () { + requireAdmin(); + redirect('/settings#protocols'); +}); +Router::get('/settings/protocols/new', function () { + requireAdmin(); + require_once __DIR__ . '/../controllers/ProtocolManagementController.php'; + $controller = new ProtocolManagementController(); + $_GET['new'] = 1; + $controller->index(); +}); + +Router::get('/settings/protocols/{id}/edit', function ($params) { + requireAdmin(); + require_once __DIR__ . '/../controllers/ProtocolManagementController.php'; + $controller = new ProtocolManagementController(); + $_GET['id'] = $params['id']; + $controller->index(); +}); + +Router::get('/settings/protocols/{id}/template', function ($params) { + requireAdmin(); + require_once __DIR__ . '/../controllers/ProtocolManagementController.php'; + $controller = new ProtocolManagementController(); + // This will render the template editor component + $_GET['id'] = $params['id']; + $_GET['template'] = 1; + $controller->index(); +}); + +// POST route to save/update protocol +Router::post('/settings/protocols/save', function () { + requireAdmin(); + require_once __DIR__ . '/../controllers/ProtocolManagementController.php'; + $controller = new ProtocolManagementController(); + $controller->save(); +}); + +// API ROUTES FOR PROTOCOLS +Router::get('/api/protocols', function () { + requireAdmin(); + require_once __DIR__ . '/../controllers/ProtocolManagementController.php'; + $controller = new ProtocolManagementController(); + $controller->apiGetProtocols(); +}); + +Router::get('/api/protocols/{id}', function ($params) { + requireAdmin(); + require_once __DIR__ . '/../controllers/ProtocolManagementController.php'; + $controller = new ProtocolManagementController(); + $controller->apiGetProtocol((int) $params['id']); +}); + +Router::post('/api/protocols', function () { + requireAdmin(); + require_once __DIR__ . '/../controllers/ProtocolManagementController.php'; + $controller = new ProtocolManagementController(); + $controller->apiCreateProtocol(); +}); + +Router::put('/api/protocols/{id}', function ($params) { + requireAdmin(); + require_once __DIR__ . '/../controllers/ProtocolManagementController.php'; + $controller = new ProtocolManagementController(); + $controller->apiUpdateProtocol((int) $params['id']); +}); + +Router::delete('/api/protocols/{id}', function ($params) { + requireAdmin(); + require_once __DIR__ . '/../controllers/ProtocolManagementController.php'; + $controller = new ProtocolManagementController(); + $controller->apiDeleteProtocol((int) $params['id']); +}); + +Router::post('/api/protocols/{id}/test-install', function ($params) { + requireAdmin(); + require_once __DIR__ . '/../controllers/ProtocolManagementController.php'; + $controller = new ProtocolManagementController(); + $controller->apiTestInstallProtocol((int) $params['id']); +}); + +Router::get('/api/protocols/{id}/test-install/stream', function ($params) { + requireAdmin(); + require_once __DIR__ . '/../controllers/ProtocolManagementController.php'; + $controller = new ProtocolManagementController(); + $controller->apiTestInstallProtocolStream((int) $params['id']); +}); + +Router::get('/api/protocols/{id}/test-uninstall/stream', function ($params) { + requireAdmin(); + require_once __DIR__ . '/../controllers/ProtocolManagementController.php'; + $controller = new ProtocolManagementController(); + $controller->apiTestUninstallProtocolStream((int) $params['id']); +}); + +// AI ASSISTANT ROUTES +Router::post('/api/ai/assist', function () { + requireAdmin(); + require_once __DIR__ . '/../controllers/AIController.php'; + $controller = new AIController(); + $controller->assist(); +}); + +Router::get('/api/ai/models', function () { + requireAdmin(); + require_once __DIR__ . '/../controllers/AIController.php'; + $controller = new AIController(); + $controller->getModels(); +}); + +Router::post('/api/ai/test-model', function () { + requireAdmin(); + require_once __DIR__ . '/../controllers/AIController.php'; + $controller = new AIController(); + $controller->testModel(); +}); + +Router::get('/api/protocols/{id}/ai-history', function ($params) { + requireAdmin(); + require_once __DIR__ . '/../controllers/AIController.php'; + $controller = new AIController(); + $controller->getGenerationHistory((int) $params['id']); +}); + +Router::post('/api/ai/generations/{id}/apply', function ($params) { + requireAdmin(); + require_once __DIR__ . '/../controllers/AIController.php'; + $controller = new AIController(); + $controller->applyGeneration((int) $params['id']); +}); + +Router::get('/ai/preview/{id}', function ($params) { + requireAdmin(); + require_once __DIR__ . '/../controllers/AIController.php'; + $controller = new AIController(); + $controller->previewGeneration((int) $params['id']); +}); + // Save API key Router::post('/settings/api-key', function () { requireAdmin(); - + require_once __DIR__ . '/../controllers/SettingsController.php'; $controller = new SettingsController(); $controller->saveApiKey(); @@ -1774,16 +3672,25 @@ Router::post('/settings/api-key', function () { // Change password Router::post('/settings/change-password', function () { requireAuth(); - + require_once __DIR__ . '/../controllers/SettingsController.php'; $controller = new SettingsController(); $controller->changePassword(); }); +// Update profile +Router::post('/settings/profile', function () { + requireAuth(); + + require_once __DIR__ . '/../controllers/SettingsController.php'; + $controller = new SettingsController(); + $controller->updateProfile(); +}); + // Add user Router::post('/settings/add-user', function () { requireAdmin(); - + require_once __DIR__ . '/../controllers/SettingsController.php'; $controller = new SettingsController(); $controller->addUser(); @@ -1792,7 +3699,7 @@ Router::post('/settings/add-user', function () { // Delete user Router::post('/settings/delete-user/{id}', function ($params) { requireAdmin(); - + require_once __DIR__ . '/../controllers/SettingsController.php'; $controller = new SettingsController(); $controller->deleteUser($params['id']); @@ -1801,17 +3708,13 @@ Router::post('/settings/delete-user/{id}', function ($params) { // LDAP settings page Router::get('/settings/ldap', function () { requireAdmin(); - - require_once __DIR__ . '/../controllers/SettingsController.php'; - require_once __DIR__ . '/../inc/LdapSync.php'; - $controller = new SettingsController(); - $controller->ldapSettings(); + redirect('/settings#ldap'); }); // Save LDAP settings Router::post('/settings/ldap/save', function () { requireAdmin(); - + require_once __DIR__ . '/../controllers/SettingsController.php'; require_once __DIR__ . '/../inc/LdapSync.php'; $controller = new SettingsController(); @@ -1821,7 +3724,7 @@ Router::post('/settings/ldap/save', function () { // Test LDAP connection Router::post('/settings/ldap/test', function () { requireAdmin(); - + require_once __DIR__ . '/../controllers/SettingsController.php'; require_once __DIR__ . '/../inc/LdapSync.php'; $controller = new SettingsController(); @@ -1835,13 +3738,13 @@ Router::post('/settings/ldap/test', function () { // Change language Router::post('/language/change', function () { $lang = $_POST['language'] ?? ''; - + if (Translator::setLanguage($lang)) { $_SESSION['success'] = 'Language changed successfully'; } else { $_SESSION['error'] = 'Invalid language'; } - + $redirect = $_POST['redirect'] ?? '/dashboard'; redirect($redirect); }); @@ -1853,10 +3756,11 @@ Router::get('/language/change', function () { // API: Get translation statistics Router::get('/api/translations/stats', function () { header('Content-Type: application/json'); - + $user = JWT::requireAuth(); - if (!$user) return; - + if (!$user) + return; + $stats = Translator::getStatistics(); echo json_encode(['stats' => $stats]); }); @@ -1864,21 +3768,22 @@ Router::get('/api/translations/stats', function () { // API: Auto-translate missing keys Router::post('/api/translations/auto-translate', function () { header('Content-Type: application/json'); - + $user = JWT::requireAuth(); - if (!$user) return; - + if (!$user) + return; + $raw = file_get_contents('php://input'); $data = json_decode($raw, true); - + $targetLang = $data['language'] ?? ''; - + if (empty($targetLang)) { http_response_code(400); echo json_encode(['error' => 'Language is required']); return; } - + try { $stats = Translator::translateMissingKeys($targetLang); echo json_encode([ @@ -1894,12 +3799,13 @@ Router::post('/api/translations/auto-translate', function () { // API: Export translations Router::get('/api/translations/export/{lang}', function ($params) { header('Content-Type: application/json'); - + $user = JWT::requireAuth(); - if (!$user) return; - + if (!$user) + return; + $lang = $params['lang']; - + try { $json = Translator::exportToJson($lang); header('Content-Disposition: attachment; filename="translations_' . $lang . '.json"'); @@ -1910,5 +3816,120 @@ Router::get('/api/translations/export/{lang}', function ($params) { } }); +// ===== Scenario Management Routes (Admin Only) ===== + +// List scenarios +Router::get('/admin/scenarios', function () { + requireAdmin(); + $controller = new ScenarioController(); + $controller->listScenarios(); +}); + +// Create scenario form +Router::get('/admin/scenario/create', function () { + requireAdmin(); + $controller = new ScenarioController(); + $controller->createScenarioForm(); +}); + +// View scenario +Router::get('/admin/scenario/{id}', function ($params) { + requireAdmin(); + $controller = new ScenarioController(); + $controller->viewScenario((int) $params['id']); +}); + +// Edit scenario form +Router::get('/admin/scenario/{id}/edit', function ($params) { + requireAdmin(); + $controller = new ScenarioController(); + $controller->editScenarioForm((int) $params['id']); +}); + +// Save scenario (create/update) +Router::post('/admin/scenario', function () { + requireAdmin(); + $controller = new ScenarioController(); + $controller->saveScenario(); +}); + +// Delete scenario +Router::post('/admin/scenario/{id}/delete', function ($params) { + requireAdmin(); + $controller = new ScenarioController(); + $controller->deleteScenario((int) $params['id']); +}); + +// Test scenario +Router::post('/admin/scenario/{id}/test', function ($params) { + requireAdmin(); + $controller = new ScenarioController(); + $controller->testScenario((int) $params['id']); +}); + +// Export scenario +Router::get('/admin/scenario/{id}/export', function ($params) { + requireAdmin(); + $controller = new ScenarioController(); + $controller->exportScenario((int) $params['id']); +}); + +// Import scenario +Router::post('/admin/scenario/import', function () { + requireAdmin(); + $controller = new ScenarioController(); + $controller->importScenario(); +}); + +// ===== Logs Management Routes (Admin Only) ===== + +// List and view logs +Router::get('/admin/logs', function () { + requireAdmin(); + require_once __DIR__ . '/../controllers/LogsController.php'; + $controller = new LogsController(); + $controller->index(); +}); + +// Download log file +Router::get('/admin/logs/download', function () { + requireAdmin(); + require_once __DIR__ . '/../controllers/LogsController.php'; + $controller = new LogsController(); + $controller->download(); +}); + +// Delete log file +Router::post('/admin/logs/delete', function () { + requireAdmin(); + require_once __DIR__ . '/../controllers/LogsController.php'; + $controller = new LogsController(); + $controller->delete(); +}); + +// Clear all logs +Router::post('/admin/logs/clear-all', function () { + requireAdmin(); + require_once __DIR__ . '/../controllers/LogsController.php'; + $controller = new LogsController(); + $controller->clearAll(); +}); + +// Search logs +Router::post('/admin/logs/search', function () { + requireAdmin(); + require_once __DIR__ . '/../controllers/LogsController.php'; + $controller = new LogsController(); + $controller->search(); +}); + +// Get log statistics +Router::post('/admin/logs/stats', function () { + requireAdmin(); + require_once __DIR__ . '/../controllers/LogsController.php'; + $controller = new LogsController(); + $controller->stats(); +}); + // Dispatch router Router::dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']); diff --git a/scripts/api_cycle_awg_advanced.sh b/scripts/api_cycle_awg_advanced.sh new file mode 100644 index 0000000..00c15bf --- /dev/null +++ b/scripts/api_cycle_awg_advanced.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +PANEL_URL="${PANEL_URL:-http://localhost:8082}" +EMAIL="${EMAIL:-admin@amnez.ia}" +PASSWORD="${PASSWORD:-admin123}" +SERVER_HOST="${SERVER_HOST:-217.26.25.6}" +PROTOCOL_SLUG="${PROTOCOL_SLUG:-amnezia-wg-advanced}" +CLIENT_NAME="${CLIENT_NAME:-api-selfcheck}" +CLIENT_LOGIN="${CLIENT_LOGIN:-api-selfcheck}" +OUT_DIR="${OUT_DIR:-scripts/_cycle_out}" + +mkdir -p "$OUT_DIR" + +AUTH_RESP=$(curl -sS -X POST "$PANEL_URL/api/auth/token" -d "email=$EMAIL&password=$PASSWORD" || true) +TOKEN=$(printf '%s' "$AUTH_RESP" | python3 -c 'import sys,json; raw=sys.stdin.read().strip(); +import sys +if not raw: sys.exit(2) +j=json.loads(raw) +print(j.get("token","")) +') || { + echo "ERROR: failed to parse /api/auth/token response" >&2 + echo "PANEL_URL=$PANEL_URL" >&2 + echo "Response (first 500 chars):" >&2 + printf '%s' "$AUTH_RESP" | head -c 500 >&2 + echo >&2 + exit 1 +} +if [[ -z "${TOKEN:-}" ]]; then + echo "ERROR: token is empty" >&2 + printf '%s' "$AUTH_RESP" | head -c 500 >&2 + echo >&2 + exit 1 +fi +echo "TOKEN_OK" + +SERVER_JSON=$(curl -fsS "$PANEL_URL/api/servers" -H "Authorization: Bearer $TOKEN") +SERVER_ID=$(printf '%s' "$SERVER_JSON" | python3 -c 'import sys,json; j=json.load(sys.stdin); host=sys.argv[1]; +out=""; +for s in j.get("servers",[]): + if str(s.get("host","" )).strip()==host: + out=str(s.get("id","")) + break +print(out) +' "$SERVER_HOST") + +if [[ -z "${SERVER_ID:-}" ]]; then + echo "ERROR: server with host $SERVER_HOST not found" >&2 + printf '%s' "$SERVER_JSON" | python3 -m json.tool | head -200 + exit 1 +fi + +echo "SERVER_ID=$SERVER_ID" + +PROTO_JSON=$(curl -fsS "$PANEL_URL/api/protocols/active" -H "Authorization: Bearer $TOKEN") +PROTOCOL_ID=$(printf '%s' "$PROTO_JSON" | python3 -c 'import sys,json; j=json.load(sys.stdin); slug=sys.argv[1]; +out=""; +for p in j.get("protocols",[]): + if p.get("slug")==slug: + out=str(p.get("id","")) + break +print(out) +' "$PROTOCOL_SLUG") + +if [[ -z "${PROTOCOL_ID:-}" ]]; then + echo "ERROR: protocol $PROTOCOL_SLUG not found" >&2 + printf '%s' "$PROTO_JSON" | python3 -m json.tool | head -200 + exit 1 +fi + +echo "PROTOCOL_ID=$PROTOCOL_ID" + +pretty_print() { + # Reads JSON from stdin and pretty-prints it. If it's not JSON, prints raw. + local data + data=$(cat) + if python3 -m json.tool >/dev/null 2>&1 <<<"$data"; then + python3 -m json.tool <<<"$data" + else + printf '%s' "$data" + fi +} + +echo "--- UNINSTALL $PROTOCOL_SLUG (ignore errors)" +set +e +UNINSTALL_RESP=$(curl -sS -X POST "$PANEL_URL/api/servers/$SERVER_ID/protocols/$PROTOCOL_SLUG/uninstall" \ + -H "Authorization: Bearer $TOKEN" || true) +printf '%s' "$UNINSTALL_RESP" >"$OUT_DIR/uninstall_${PROTOCOL_SLUG}.txt" +printf '%s' "$UNINSTALL_RESP" | pretty_print | head -200 +set -e + +echo "--- INSTALL protocol_id=$PROTOCOL_ID" +INSTALL_RESP=$(curl -sS -X POST "$PANEL_URL/api/servers/$SERVER_ID/protocols/install" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"protocol_id\":$PROTOCOL_ID}" || true) +printf '%s' "$INSTALL_RESP" >"$OUT_DIR/install_${PROTOCOL_ID}.txt" +printf '%s' "$INSTALL_RESP" | pretty_print | head -200 + +echo "--- CREATE client" +CLIENT_RESP=$(curl -fsS -X POST "$PANEL_URL/api/clients/create" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"server_id\":$SERVER_ID,\"protocol_id\":$PROTOCOL_ID,\"name\":\"$CLIENT_NAME\",\"login\":\"$CLIENT_LOGIN\"}") + +printf '%s' "$CLIENT_RESP" >"$OUT_DIR/client_create_${PROTOCOL_ID}.txt" + +printf '%s' "$CLIENT_RESP" | pretty_print | head -200 +CLIENT_ID=$(printf '%s' "$CLIENT_RESP" | python3 -c 'import sys,json; j=json.load(sys.stdin); print(j.get("client",{}).get("id",""))') + +if [[ -z "${CLIENT_ID:-}" ]]; then + echo "ERROR: client_id missing" >&2 + exit 1 +fi + +echo "CLIENT_ID=$CLIENT_ID" + +echo "--- SELFTEST" +SELFTEST_RESP=$(curl -fsS -X POST "$PANEL_URL/api/servers/$SERVER_ID/protocols/selftest" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"client_id\":$CLIENT_ID,\"create_client\":false,\"install\":false,\"protocol_id\":$PROTOCOL_ID}") + +printf '%s' "$SELFTEST_RESP" >"$OUT_DIR/selftest_${CLIENT_ID}.txt" + +printf '%s' "$SELFTEST_RESP" | pretty_print | head -260 + +NEED_DIAG=$(printf '%s' "$SELFTEST_RESP" | python3 -c 'import sys,json; j=json.load(sys.stdin); peer=(j.get("wg") or {}).get("peer") or {}; hs=int(peer.get("latest_handshake") or 0); ep=str(peer.get("endpoint") or ""); print("1" if (ep=="(none)" or hs==0) else "0")') + +if [[ "$NEED_DIAG" == "1" ]]; then + echo "--- DIAGNOSE HANDSHAKE" + DIAG_RESP=$(curl -sS -X POST "$PANEL_URL/api/servers/$SERVER_ID/protocols/diagnose-handshake" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"client_id\":$CLIENT_ID,\"duration_seconds\":5}" || true) + printf '%s' "$DIAG_RESP" >"$OUT_DIR/diagnose_${CLIENT_ID}.txt" + printf '%s' "$DIAG_RESP" | pretty_print | head -260 +fi + +echo "DONE (responses saved in $OUT_DIR)" diff --git a/scripts/api_diagnose_client.sh b/scripts/api_diagnose_client.sh new file mode 100644 index 0000000..7ab6c1a --- /dev/null +++ b/scripts/api_diagnose_client.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +PANEL_URL="${PANEL_URL:-http://localhost:8082}" +EMAIL="${EMAIL:-admin@amnez.ia}" +PASSWORD="${PASSWORD:-admin123}" +SERVER_ID="${SERVER_ID:-5}" +CLIENT_ID="${CLIENT_ID:-}" +DURATION="${DURATION:-10}" +OUT_FILE="${OUT_FILE:-}" + +if [[ -z "${CLIENT_ID}" ]]; then + echo "ERROR: CLIENT_ID is required" >&2 + exit 2 +fi + +if [[ -z "${OUT_FILE}" ]]; then + OUT_FILE="scripts/_cycle_out/diagnose_client_${CLIENT_ID}.json" +fi + +mkdir -p "$(dirname "$OUT_FILE")" + +TOKEN_JSON=$(curl -sS -X POST "$PANEL_URL/api/auth/token" -d "email=$EMAIL&password=$PASSWORD") +TOKEN=$(printf '%s' "$TOKEN_JSON" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("token",""))') + +if [[ -z "${TOKEN}" ]]; then + echo "ERROR: token empty" >&2 + printf '%s' "$TOKEN_JSON" | head -c 300 >&2 + echo >&2 + exit 3 +fi + +RESP_WITH_STATUS=$(curl -sS -X POST "$PANEL_URL/api/servers/$SERVER_ID/protocols/diagnose-handshake" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"client_id\":$CLIENT_ID,\"duration_seconds\":$DURATION}" \ + -w "\n__HTTP_STATUS__%{http_code}") + +HTTP_STATUS=$(printf '%s' "$RESP_WITH_STATUS" | awk -F'__HTTP_STATUS__' 'END{print $2}') +RESP=$(printf '%s' "$RESP_WITH_STATUS" | awk -F'__HTTP_STATUS__' '{print $1}') + +if [[ -z "${RESP:-}" ]]; then + echo "ERROR: empty response (http_status=${HTTP_STATUS:-unknown})" >&2 + exit 4 +fi + +TMP_FILE="${OUT_FILE}.tmp" +printf '%s' "$RESP" >"$TMP_FILE" +mv "$TMP_FILE" "$OUT_FILE" + +echo "saved:$OUT_FILE (http_status=${HTTP_STATUS:-unknown})" + +if [[ "${HTTP_STATUS:-}" =~ ^[0-9]+$ ]] && [[ "${HTTP_STATUS}" -ge 400 ]]; then + exit 5 +fi diff --git a/scripts/api_diagnose_server.sh b/scripts/api_diagnose_server.sh new file mode 100755 index 0000000..5166a0e --- /dev/null +++ b/scripts/api_diagnose_server.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +PANEL_URL="${PANEL_URL:-http://localhost:8082}" +EMAIL="${EMAIL:-admin@amnez.ia}" +PASSWORD="${PASSWORD:-admin123}" +SERVER_ID="${SERVER_ID:-5}" +OUT_FILE="${OUT_FILE:-scripts/_cycle_out/diagnose_no_client.json}" +DURATION="${DURATION:-2}" + +mkdir -p "$(dirname "$OUT_FILE")" + +TOKEN_JSON=$(curl -sS -X POST "$PANEL_URL/api/auth/token" -d "email=$EMAIL&password=$PASSWORD") +TOKEN=$(printf '%s' "$TOKEN_JSON" | python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])') + +TMP_FILE="${OUT_FILE}.tmp" +RESP=$(curl -fsS -X POST "$PANEL_URL/api/servers/$SERVER_ID/protocols/diagnose-handshake" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"duration_seconds\":$DURATION}" || true) + +if [[ -z "${RESP:-}" ]]; then + echo "ERROR: empty response from diagnose-handshake" >&2 + echo "PANEL_URL=$PANEL_URL SERVER_ID=$SERVER_ID" >&2 + echo "Token JSON (first 200):" >&2 + printf '%s' "$TOKEN_JSON" | head -c 200 >&2 + echo >&2 + exit 2 +fi + +printf '%s' "$RESP" > "$TMP_FILE" +mv "$TMP_FILE" "$OUT_FILE" + +echo "saved:$OUT_FILE" diff --git a/scripts/api_list_clients.sh b/scripts/api_list_clients.sh new file mode 100644 index 0000000..7d37b76 --- /dev/null +++ b/scripts/api_list_clients.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +PANEL_URL="${PANEL_URL:-http://localhost:8082}" +EMAIL="${EMAIL:-admin@amnez.ia}" +PASSWORD="${PASSWORD:-admin123}" + +TOKEN_JSON=$(curl -sS -X POST "$PANEL_URL/api/auth/token" -d "email=$EMAIL&password=$PASSWORD") +TOKEN=$(printf '%s' "$TOKEN_JSON" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("token",""))') + +if [[ -z "${TOKEN:-}" ]]; then + echo "ERROR: token empty" >&2 + printf '%s' "$TOKEN_JSON" | head -c 200 >&2 + echo >&2 + exit 3 +fi + +curl -fsS "$PANEL_URL/api/clients" -H "Authorization: Bearer $TOKEN" | \ + python3 -c 'import sys,json; j=json.load(sys.stdin); cs=j.get("clients",[]); +print("id\tname\tprotocol\tserver_id") +for c in cs: + print(f"{c.get("id")}\t{c.get("name")}\t{c.get("protocol")}\t{c.get("server_id")}")' diff --git a/scripts/api_protocol_smoketest.sh b/scripts/api_protocol_smoketest.sh new file mode 100644 index 0000000..43003a7 --- /dev/null +++ b/scripts/api_protocol_smoketest.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail + +PANEL_URL="${PANEL_URL:-http://localhost:8082}" +EMAIL="${EMAIL:-}" +PASSWORD="${PASSWORD:-}" +TOKEN="${TOKEN:-}" +SERVER_ID="${SERVER_ID:-1}" +PROTOCOL_ID="${PROTOCOL_ID:-}" +UNINSTALL_SLUG="${UNINSTALL_SLUG:-}" +CLIENT_NAME="${CLIENT_NAME:-smoke-client}" +CLIENT_LOGIN="${CLIENT_LOGIN:-smoke-client}" +SELFTEST="${SELFTEST:-1}" +DIAGNOSE="${DIAGNOSE:-1}" + +if [[ -z "$TOKEN" ]]; then + if [[ -z "$EMAIL" || -z "$PASSWORD" ]]; then + echo "ERROR: set TOKEN or (EMAIL and PASSWORD)" >&2 + exit 1 + fi + echo "[1/6] Getting JWT token..." >&2 + TOKEN="$(curl -fsS -X POST "$PANEL_URL/api/auth/token" -d "email=$EMAIL&password=$PASSWORD" | php -r '$j=json_decode(stream_get_contents(STDIN),true); echo $j["token"] ?? "";')" +fi + +if [[ -z "$TOKEN" ]]; then + echo "ERROR: failed to obtain token" >&2 + exit 1 +fi + +auth=(-H "Authorization: Bearer $TOKEN") + +echo "[2/6] Listing active protocols..." >&2 +curl -fsS "$PANEL_URL/api/protocols/active" "${auth[@]}" | cat + +if [[ -n "$UNINSTALL_SLUG" ]]; then + echo "[3/6] Uninstalling protocol slug=$UNINSTALL_SLUG on server=$SERVER_ID ..." >&2 + curl -fsS -X POST "$PANEL_URL/api/servers/$SERVER_ID/protocols/$UNINSTALL_SLUG/uninstall" "${auth[@]}" | cat +else + echo "[3/6] Skipping uninstall (set UNINSTALL_SLUG to run)." >&2 +fi + +if [[ -n "$PROTOCOL_ID" ]]; then + echo "[4/6] Installing protocol_id=$PROTOCOL_ID on server=$SERVER_ID ..." >&2 + curl -fsS -X POST "$PANEL_URL/api/servers/$SERVER_ID/protocols/install" \ + "${auth[@]}" \ + -H "Content-Type: application/json" \ + -d "{\"protocol_id\": $PROTOCOL_ID}" | cat +else + echo "[4/6] Skipping install (set PROTOCOL_ID to run)." >&2 +fi + +echo "[5/6] Creating client on server=$SERVER_ID (protocol_id=${PROTOCOL_ID:-auto})..." >&2 +CREATE_PAYLOAD=$(php -r '$d=["server_id"=>(int)getenv("SERVER_ID"),"name"=>getenv("CLIENT_NAME"),"login"=>getenv("CLIENT_LOGIN")]; $pid=getenv("PROTOCOL_ID"); if($pid!==false && $pid!==""){$d["protocol_id"]= (int)$pid;} echo json_encode($d, JSON_UNESCAPED_SLASHES);') +RESP="$(curl -fsS -X POST "$PANEL_URL/api/clients/create" "${auth[@]}" -H "Content-Type: application/json" -d "$CREATE_PAYLOAD")" +echo "$RESP" | cat + +CLIENT_ID=$(echo "$RESP" | php -r '$j=json_decode(stream_get_contents(STDIN),true); echo $j["client"]["id"] ?? "";') + +if [[ -n "$CLIENT_ID" ]]; then + echo "[6/6] Fetching client details (includes stats sync)..." >&2 + curl -fsS "$PANEL_URL/api/clients/$CLIENT_ID/details" "${auth[@]}" | cat + + if [[ "$SELFTEST" == "1" ]]; then + echo >&2 + echo "[selftest] Verifying generated config vs server wg0..." >&2 + SELFTEST_PAYLOAD=$(php -r '$d=["protocol_id"=>getenv("PROTOCOL_ID")!==false && getenv("PROTOCOL_ID")!=="" ? (int)getenv("PROTOCOL_ID") : 0, "install"=>false, "create_client"=>false, "client_id"=>(int)getenv("CLIENT_ID")]; echo json_encode($d, JSON_UNESCAPED_SLASHES);') + SELFTEST_RESP=$(curl -fsS -X POST "$PANEL_URL/api/servers/$SERVER_ID/protocols/selftest" \ + "${auth[@]}" \ + -H "Content-Type: application/json" \ + -d "$SELFTEST_PAYLOAD") + echo "$SELFTEST_RESP" | cat + + if [[ "$DIAGNOSE" == "1" ]]; then + # If peer endpoint is none OR latest_handshake=0, run server-side diagnostics + NEED_DIAG=$(echo "$SELFTEST_RESP" | php -r '$j=json_decode(stream_get_contents(STDIN),true); $hs=$j["wg"]["peer"]["latest_handshake"] ?? null; $ep=$j["wg"]["peer"]["endpoint"] ?? null; echo ((string)$ep==="(none)" || (int)$hs===0) ? "1" : "0";') + if [[ "$NEED_DIAG" == "1" ]]; then + echo >&2 + echo "[diagnose] Collecting server-side evidence (wg/ports/firewall/tcpdump)..." >&2 + DIAG_PAYLOAD=$(php -r '$d=["client_id"=>(int)getenv("CLIENT_ID"),"duration_seconds"=>5]; echo json_encode($d, JSON_UNESCAPED_SLASHES);') + curl -fsS -X POST "$PANEL_URL/api/servers/$SERVER_ID/protocols/diagnose-handshake" \ + "${auth[@]}" \ + -H "Content-Type: application/json" \ + -d "$DIAG_PAYLOAD" | cat + fi + fi + fi +else + echo "[6/6] No client id returned; skipping details." >&2 +fi + +echo >&2 +echo "Done." >&2 diff --git a/scripts/api_regen_and_dump_conf.sh b/scripts/api_regen_and_dump_conf.sh new file mode 100644 index 0000000..fa6a522 --- /dev/null +++ b/scripts/api_regen_and_dump_conf.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +PANEL_URL="${PANEL_URL:-http://localhost:8082}" +EMAIL="${EMAIL:-admin@amnez.ia}" +PASSWORD="${PASSWORD:-admin123}" +CLIENT_NAME="${CLIENT_NAME:-}" +CLIENT_ID="${CLIENT_ID:-}" +OUT_DIR="${OUT_DIR:-scripts/_cycle_out}" + +mkdir -p "$OUT_DIR" + +if [[ -z "$CLIENT_ID" && -z "$CLIENT_NAME" ]]; then + echo "ERROR: set CLIENT_ID or CLIENT_NAME" >&2 + exit 2 +fi + +TOKEN_JSON=$(curl -sS -X POST "$PANEL_URL/api/auth/token" -d "email=$EMAIL&password=$PASSWORD") +TOKEN=$(printf '%s' "$TOKEN_JSON" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("token",""))') + +if [[ -z "${TOKEN:-}" ]]; then + echo "ERROR: token empty" >&2 + printf '%s' "$TOKEN_JSON" | head -c 200 >&2 + echo >&2 + exit 3 +fi + +if [[ -z "$CLIENT_ID" ]]; then + CLIENTS_JSON=$(curl -fsS "$PANEL_URL/api/clients" -H "Authorization: Bearer $TOKEN") + CLIENT_ID=$(printf '%s' "$CLIENTS_JSON" | python3 -c 'import sys,json; j=json.load(sys.stdin); needle=sys.argv[1]; +for c in j.get("clients",[]): + if str(c.get("name",""))==needle: + print(c.get("id","")); + raise SystemExit +print("")' "$CLIENT_NAME") +fi + +if [[ -z "${CLIENT_ID:-}" ]]; then + echo "ERROR: client not found" >&2 + exit 4 +fi + +RESP=$(curl -fsS -X POST "$PANEL_URL/api/clients/$CLIENT_ID/regenerate-config" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{}' ) + +JSON_OUT="$OUT_DIR/regenerate_${CLIENT_ID}.json" +CONF_OUT="$OUT_DIR/${CLIENT_NAME:-client_${CLIENT_ID}}_regenerated.conf" + +printf '%s' "$RESP" >"$JSON_OUT" + +# Extract config field +printf '%s' "$RESP" | python3 -c 'import sys,json; j=json.load(sys.stdin); c=(j.get("client") or {}).get("config") or ""; sys.stdout.write(c)' >"$CONF_OUT" + +echo "saved_json:$JSON_OUT" +echo "saved_conf:$CONF_OUT" diff --git a/scripts/cleanup_amnezia.sh b/scripts/cleanup_amnezia.sh new file mode 100755 index 0000000..4707d4d --- /dev/null +++ b/scripts/cleanup_amnezia.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# Universal cleanup script for all Amnezia containers +# Based on remove_all_containers.sh from amnezia-client +# Usage: ./cleanup_amnezia.sh + +set -euo pipefail + +echo "=========================================" +echo "Amnezia VPN - Complete Cleanup Script" +echo "=========================================" +echo "" +echo "WARNING: This will remove ALL Amnezia containers, images, and data!" +echo "Press Ctrl+C to cancel, or Enter to continue..." +read -r + +echo "" +echo "Step 1: Stopping all Amnezia containers..." +CONTAINERS=$(docker ps -a | grep amnezia | awk '{print $1}' || true) +if [ -n "$CONTAINERS" ]; then + echo "$CONTAINERS" | xargs docker stop || true + echo "✓ Containers stopped" +else + echo "✓ No running containers found" +fi + +echo "" +echo "Step 2: Removing all Amnezia containers..." +CONTAINERS=$(docker ps -a | grep amnezia | awk '{print $1}' || true) +if [ -n "$CONTAINERS" ]; then + echo "$CONTAINERS" | xargs docker rm -fv || true + echo "✓ Containers removed" +else + echo "✓ No containers to remove" +fi + +echo "" +echo "Step 3: Removing all Amnezia images..." +IMAGES=$(docker images -a | grep amnezia | awk '{print $3}' || true) +if [ -n "$IMAGES" ]; then + echo "$IMAGES" | xargs docker rmi -f || true + echo "✓ Images removed" +else + echo "✓ No images to remove" +fi + +echo "" +echo "Step 4: Removing Amnezia DNS network..." +docker network rm amnezia-dns-net 2>/dev/null && echo "✓ Network removed" || echo "✓ Network not found" + +echo "" +echo "Step 5: Removing Amnezia data directory..." +if [ -d "/opt/amnezia" ]; then + rm -rf /opt/amnezia + echo "✓ Data directory removed" +else + echo "✓ Data directory not found" +fi + +echo "" +echo "=========================================" +echo "Cleanup completed successfully!" +echo "=========================================" +echo "" +echo "Summary:" +echo "- All Amnezia containers stopped and removed" +echo "- All Amnezia Docker images removed" +echo "- Amnezia DNS network removed" +echo "- All configuration data removed from /opt/amnezia" +echo "" diff --git a/scripts/derive_pubkey_from_priv.php b/scripts/derive_pubkey_from_priv.php new file mode 100644 index 0000000..64704f0 --- /dev/null +++ b/scripts/derive_pubkey_from_priv.php @@ -0,0 +1,19 @@ + + +set -euo pipefail + +if [ $# -eq 0 ]; then + echo "Usage: $0 " + echo "Example: $0 amnezia-awg" + exit 1 +fi + +CONTAINER_NAME="$1" + +echo "Removing Amnezia container: $CONTAINER_NAME" +echo "" + +# Stop the container +echo "Stopping container..." +docker stop "$CONTAINER_NAME" 2>/dev/null && echo "✓ Container stopped" || echo "✓ Container not running" + +# Remove the container with volumes +echo "Removing container..." +docker rm -fv "$CONTAINER_NAME" 2>/dev/null && echo "✓ Container removed" || echo "✓ Container not found" + +# Remove the image +echo "Removing image..." +docker rmi "$CONTAINER_NAME" 2>/dev/null && echo "✓ Image removed" || echo "✓ Image not found" + +echo "" +echo "Container $CONTAINER_NAME has been removed successfully!" diff --git a/templates/ai/preview_generation.twig b/templates/ai/preview_generation.twig new file mode 100644 index 0000000..3346375 --- /dev/null +++ b/templates/ai/preview_generation.twig @@ -0,0 +1,244 @@ +{% extends "layout.twig" %} + +{% block title %}{{ t('ai.generation_preview') }} - {{ parent() }}{% endblock %} + +{% block content %} +
+
+ +
+
+
+

{{ t('ai.generation_preview') }}

+

{{ t('ai.generation_preview_description') }}

+
+
+ + + + + {{ t('protocols.back_to_protocols') }} + + +
+
+
+ + +
+
+

{{ t('ai.generation_details') }}

+
+
+
+
+ +

{{ generation.model_used }}

+
+
+ +

{{ generation.created_at|date('Y-m-d H:i:s') }}

+
+
+ +
+ {% if generation.ubuntu_compatible %} + + + + + {{ t('common.compatible') }} + + {% else %} + + + + + {{ t('common.not_compatible') }} + + {% endif %} +
+
+ {% if generation.protocol_name %} +
+ +

{{ generation.protocol_name }}

+
+ {% endif %} +
+
+
+ + +
+
+

{{ t('ai.user_prompt') }}

+
+
+

{{ generation.prompt }}

+
+
+ + +
+
+
+

{{ t('ai.generated_installation_script') }}

+ +
+
+
+
+
{{ script }}
+
+
+
+ + + {% if suggestions %} +
+
+

{{ t('ai.suggestions') }}

+
+
+
    + {% for suggestion in suggestions %} +
  • + + + + {{ suggestion }} +
  • + {% endfor %} +
+
+
+ {% endif %} + + +
+
+

{{ t('common.actions') }}

+
+
+
+ + + {% if generation.protocol_id %} + + {% endif %} + + +
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/clients/view.twig b/templates/clients/view.twig index 90a055b..ec35a65 100644 --- a/templates/clients/view.twig +++ b/templates/clients/view.twig @@ -18,6 +18,7 @@ {% endif %} +
Логин
{{ client.name|default('') }}
{{ t('common.created') }}
{{ client.created_at }}
@@ -150,6 +151,13 @@

Scan with Amnezia VPN app

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

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

+
{{ protocol_output }}
+
+ {% endif %} {% endblock %} diff --git a/templates/servers/deploy.twig b/templates/servers/deploy.twig index 7a5bc15..ab5ca66 100644 --- a/templates/servers/deploy.twig +++ b/templates/servers/deploy.twig @@ -2,55 +2,136 @@ {% block title %}Deploy {{ server.name }}{% endblock %} {% block content %}
-

Deploying: {{ server.name }}

+

Deploying: {{ server.name }}

+

Protocol: {{ server.install_protocol ?? 'amnezia-wg' }}

Ready to deploy...
+
diff --git a/templates/servers/view.twig b/templates/servers/view.twig index 360acf5..6ffc24d 100644 --- a/templates/servers/view.twig +++ b/templates/servers/view.twig @@ -60,8 +60,50 @@
{{ t('common.status') }}
{{ server.status }}
VPN Port
{{ server.vpn_port }}
Subnet
{{ server.vpn_subnet }}
+ - + +
+ + +
+ +
+ +
+ + + +
+
+ + + +
+ +
+ {% for sp in server_protocols %} +
+
+
{{ sp.name }} ({{ sp.slug }})
+ +
+
+ {% if sp.server_host %}Host: {{ sp.server_host }}{% endif %} + {% if sp.server_port %}Port: {{ sp.server_port }}{% endif %} +
+
+ {% else %} +
Нет установленных протоколов
+ {% endfor %} +
+
+
+ {% if server.status == 'active' %}
@@ -96,7 +138,17 @@
-

Spaces will be replaced with underscore. All characters allowed including Cyrillic.

+
+
+ +
+
+ +
@@ -136,6 +188,31 @@
+ +
+

+ + {{ t('servers.config_import_title') }} +

+

{{ t('servers.config_import_hint') }}

+
+
+ + +
+
+ + +

{{ t('servers.config_import_file_hint') }}

+
+ +
+
@@ -154,15 +231,28 @@

{{ t('clients.title') }} ({{ clients|length }})

- +
+
+ + +
+ +
{% if clients|length > 0 %} + + @@ -177,6 +267,14 @@ {% for client in clients %} + + @@ -286,6 +324,163 @@
{{ t('clients.name') }}Логин{{ t('ai.protocol_type') }} {{ t('clients.ip') }} {{ t('clients.status') }} {{ t('clients.expiration') }}
{{ client.name }}{{ client.login|default('-') }} + {% if client.protocol_name %} + {{ client.protocol_name }} + {% else %} + - + {% endif %} + {{ client.client_ip }} {% if client.status == 'active' %} @@ -286,6 +384,102 @@ function toggleExpirationInput() { } } +document.addEventListener('DOMContentLoaded', function() { + const uninstallAllBtn = document.getElementById('uninstallAllBtn'); + const msg = document.getElementById('uninstallMsg'); + + if (uninstallAllBtn) { + uninstallAllBtn.addEventListener('click', async function(e) { + console.log('uninstallAllBtn clicked'); + e.preventDefault(); + e.stopPropagation(); + if (!confirm('Удалить все Amnezia-контейнеры на сервере?')) { + console.log('User canceled'); + return; + } + console.log('Starting uninstall all...'); + uninstallAllBtn.disabled = true; + msg.innerHTML = 'Удаление всех контейнеров...'; + try { + const res = await fetch(`/servers/{{ server.id }}/protocols/uninstall-all`, { method: 'POST', credentials: 'same-origin' }); + const data = await res.json(); + console.log('Response:', data); + if (data.success) { + msg.textContent = data.message || 'Успешно'; + setTimeout(() => location.reload(), 1200); + } else { + msg.textContent = data.error || 'Ошибка'; + } + } catch (e) { + console.error('Error:', e); + msg.textContent = e.message; + } + uninstallAllBtn.disabled = false; + }); + } + + const activateBtn = document.getElementById('activateProtocolBtn'); + if (activateBtn) { + activateBtn.addEventListener('click', async function() { + const select = document.getElementById('availableProtocolSelect'); + const msg2 = document.getElementById('activateMsg'); + msg2.textContent = ''; + const pid = select ? select.value : ''; + if (!pid) { msg2.textContent = 'Нет доступных протоколов'; return; } + activateBtn.disabled = true; + msg2.innerHTML = 'Установка протокола...'; + try { + const res = await fetch('/servers/{{ server.id }}/protocols/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'protocol_id=' + encodeURIComponent(pid) + }); + let data; + const ct = res.headers.get('content-type') || ''; + if (ct.includes('application/json')) { data = await res.json(); } else { data = { error: await res.text() }; } + if (res.ok && data && data.success !== false && !data.error) { + msg2.textContent = 'Готово'; + setTimeout(() => location.reload(), 1000); + } else { + msg2.textContent = (data && data.error) ? data.error : ('Ошибка установки (' + res.status + ')'); + } + } catch (e) { + msg2.textContent = e.message || 'Ошибка связи'; + } + activateBtn.disabled = false; + }); + } + + document.querySelectorAll('.btn-uninstall-sp').forEach(btn => { + btn.addEventListener('click', async function(e) { + e.preventDefault(); + if (!confirm('Удалить протокол и всех его клиентов?')) return; + const slug = btn.getAttribute('data-slug'); + const m = document.getElementById('uninstallSpMsg'); + m.textContent = ''; + btn.disabled = true; + m.innerHTML = 'Удаление протокола...'; + try { + const resp = await fetch('/servers/{{ server.id }}/protocols/' + encodeURIComponent(slug) + '/uninstall', { method: 'POST', credentials: 'same-origin' }); + let data; + const ct = resp.headers.get('content-type') || ''; + if (ct.includes('application/json')) { data = await resp.json(); } else { data = { error: await resp.text() }; } + if (resp.ok && data && !data.error) { + m.textContent = 'Удалено. Клиенты: ' + (data.clients_removed || 0); + setTimeout(() => location.reload(), 800); + } else { + m.textContent = (data && data.error) ? data.error : ('Ошибка удаления (' + resp.status + ')'); + } + } catch (e) { + m.textContent = e.message || 'Ошибка связи'; + } + btn.disabled = false; + }); + }); + + +}); + function toggleTrafficInput() { const select = document.getElementById('trafficSelect'); const input = document.getElementById('trafficMegabytes'); @@ -304,12 +498,11 @@ document.addEventListener('DOMContentLoaded', function() { const form = document.getElementById('createClientForm'); const clientNameInput = document.getElementById('clientName'); - // Auto-replace spaces with underscores - if (clientNameInput) { - clientNameInput.addEventListener('input', function(e) { - // Replace only spaces with underscore, allow all other characters including Cyrillic + const clientLoginInput = document.getElementById('clientLogin'); + if (clientLoginInput) { + clientLoginInput.addEventListener('input', function(e) { let value = e.target.value; - let sanitized = value.replace(/ /g, '_'); + let sanitized = value.replace(/\s+/g, '_'); if (value !== sanitized) { e.target.value = sanitized; } diff --git a/templates/settings.twig b/templates/settings.twig index ae10326..d3f0948 100644 --- a/templates/settings.twig +++ b/templates/settings.twig @@ -42,21 +42,54 @@ class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm"> {{ t('settings.translations') }} + + QR Декодер + {% if user.role == 'admin' %} {{ t('settings.users') }} - LDAP + + {{ t('settings.protocol_management') }} + + + {{ 'Логи' | trans }} + {% endif %}
+
+
+

+ Профиль +

+
+
+
+
+
+ + +
+ +
+
+
+

@@ -278,6 +311,11 @@ {{ t('clients.delete') }} + {% else %} + + + {{ t('settings.change_password') }} + {% endif %}

+ + + + + + {% endif %}
@@ -341,8 +536,33 @@ function translateLanguage(lang) { .catch(err => { alert('{{ t('message.error') }}: ' + err.message); button.disabled = false; - button.innerHTML = originalText; + button.innerHTML = originalText; }); } + +// LDAP test button inside settings page +document.addEventListener('click', function(e) { + if (e.target && e.target.id === 'testConnection') { + const btn = e.target; + btn.disabled = true; + btn.textContent = '{{ t('ldap.testing') }}...'; + fetch('/settings/ldap/test', { method: 'POST' }) + .then(r => r.json()) + .then(result => { + if (result.success) { + alert('✓ ' + result.message); + } else { + alert('✗ ' + result.message); + } + }) + .catch(err => { + alert('{{ t('ldap.connection_test_failed') }}: ' + err.message); + }) + .finally(() => { + btn.disabled = false; + btn.textContent = '{{ t('ldap.test_connection') }}'; + }); + } +}); {% endblock %} diff --git a/templates/settings/logs.twig b/templates/settings/logs.twig new file mode 100644 index 0000000..fd01027 --- /dev/null +++ b/templates/settings/logs.twig @@ -0,0 +1,343 @@ +{% extends "layout.twig" %} + +{% block content %} +
+
+
+

{{ 'Логи приложения' | trans }}

+

{{ 'Просмотр, поиск и управление файлами логов' | trans }}

+
+ {% if log_files | length > 0 %} +
+ + + {{ 'Скачать' | trans }} + +
+ {% endif %} +
+ + {% if user and user.role == 'admin' %} + + {% endif %} + +
+ +
+
+
+

{{ 'Файлы логов' | trans }}

+
+
+ {% if log_files | length > 0 %} + {% for file in log_files %} + +
+
+
{{ file.name }}
+
{{ file.size_formatted }}
+
+ +
+
{{ file.modified_formatted }}
+
+ {% endfor %} + {% else %} +
+ {{ 'Логи не найдены' | trans }} +
+ {% endif %} +
+
+
+ + +
+ {% if selected_file %} + +
+
+
+
+

{{ 'Файл:' | trans }} {{ selected_file }}

+
+
+ {{ 'Размер:' | trans }} {{ file_size | default(0) | bytes_format }} + + {{ 'Строк:' | trans }} {{ line_count | number_format(0, '.', ' ') }} +
+
+
+
+ + +
+
+
+ +
+ +
+ + + +
+
+
+ + + + + + + + +
+
+

{{ 'Содержание логов' | trans }}

+ +
+
+
{{ log_content }}
+
+
+ {% else %} +
+ +
{{ 'Выберите файл логов для просмотра' | trans }}
+
+ {% endif %} +
+
+
+ + + + +{% endblock %} diff --git a/templates/settings/protocol_form.twig b/templates/settings/protocol_form.twig new file mode 100644 index 0000000..2403899 --- /dev/null +++ b/templates/settings/protocol_form.twig @@ -0,0 +1,707 @@ +{% extends "layout.twig" %} + +{% block title %}{{ editing ? t('protocols.edit_protocol') : t('protocols.create_protocol') }} - {{ parent() }}{% endblock %} + +{% block content %} +
+
+ +
+
+
+

{{ editing ? t('protocols.edit_protocol') : t('protocols.create_protocol') }}

+

{{ editing ? t('protocols.edit_protocol_description') : t('protocols.create_protocol_description') }}

+
+ + + + + {{ t('protocols.back_to_protocols') }} + +
+
+ + + {% if success %} +
+
+ + + +

{{ success }}

+
+
+ {% endif %} + + {% if error %} +
+
+ + + +

{{ error }}

+
+
+ {% endif %} + + +
+ {% if editing %} + + {% endif %} + + +
+

{{ t('protocols.basic_information') }}

+ +
+
+ + +

{{ t('protocols.name_help') }}

+
+ +
+ + +

{{ t('protocols.slug_help') }}

+
+
+ +
+ + +

{{ t('protocols.description_help') }}

+
+
+ + +
+
+

{{ t('protocols.installation_script') }}

+ +
+ +
+ +

{{ t('protocols.install_script_help') }}

+
+ + {{ t('protocols.testing_on_ubuntu22') }} +
+ +
+
+ + +
+
+

{{ t('protocols.uninstallation_script') }}

+ +
+
+ +

{{ t('protocols.uninstall_script_help') }}

+
+ + {{ t('protocols.testing_on_ubuntu22') }} +
+ +
+
+ + +
+
+

{{ t('protocols.output_template') }}

+ +
+ +
+ +

{{ t('protocols.output_template_help') }}

+
+ +
+

{{ t('protocols.available_variables') }}

+
+

{{private_key}} - {{ t('protocols.variable_private_key_help') }}

+

{{public_key}} - {{ t('protocols.variable_public_key_help') }}

+

{{client_ip}} - {{ t('protocols.variable_client_ip_help') }}

+

{{server_host}} - {{ t('protocols.variable_server_host_help') }}

+

{{server_port}} - {{ t('protocols.variable_server_port_help') }}

+

{{preshared_key}} - {{ t('protocols.variable_preshared_key_help') }}

+
+
+
+ + +
+
+
+ +

{{ t('protocols.qr_code_template') }}

+
+ +
+ +
+
+ + +

{{ t('protocols.qr_code_format_help') }}

+
+ +
+ +

{{ t('protocols.qr_code_template_help') }}

+
+ +
+

{{ t('protocols.available_variables') }}

+
+

{{last_config_json}} - {{ t('protocols.variable_last_config_json_help') }}

+

{{ t('protocols.plus_all_output_variables') }}

+
+
+
+
+ + +
+

{{ t('protocols.password_generation') }}

+
+ +

{{ t('protocols.password_command_help') }}

+
+
+ + +
+

{{ t('common.settings') }}

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+ + {{ t('common.cancel') }} + + +
+
+
+
+ + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/settings/protocol_template_editor.twig b/templates/settings/protocol_template_editor.twig new file mode 100644 index 0000000..84c68b2 --- /dev/null +++ b/templates/settings/protocol_template_editor.twig @@ -0,0 +1,272 @@ +{% extends "layout.twig" %} + +{% block title %}{{ t('protocols.template_editor') }} - {{ parent() }}{% endblock %} + +{% block content %} +
+
+ +
+
+
+

{{ t('protocols.template_editor') }}

+

{{ t('protocols.template_editor_description') }}

+
+ + + + + {{ t('protocols.back_to_protocols') }} + +
+
+ + +
+
+

{{ protocol.name }} - {{ t('protocols.output_template') }}

+

{{ t('protocols.template_editor_help') }}

+
+ +
+
+ +
+
+ +
+ + +
+
+ + +
+ + +
+
+ + +
+
+ + +
+
+
{{ t('protocols.click_preview_to_see_output') }}
+
+ +
+ +
+ + + + + +
+
+
+
+
+
+ + +
+
+

{{ t('protocols.template_variables') }}

+

{{ t('protocols.template_variables_help') }}

+
+ +
+
+
+
+ {{private_key}} + +
+

{{ t('protocols.variable_private_key_desc') }}

+
+ +
+
+ {{public_key}} + +
+

{{ t('protocols.variable_public_key_desc') }}

+
+ +
+
+ {{client_ip}} + +
+

{{ t('protocols.variable_client_ip_desc') }}

+
+ +
+
+ {{server_host}} + +
+

{{ t('protocols.variable_server_host_desc') }}

+
+ +
+
+ {{server_port}} + +
+

{{ t('protocols.variable_server_port_desc') }}

+
+ +
+
+ {{preshared_key}} + +
+

{{ t('protocols.variable_preshared_key_desc') }}

+
+
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/settings/protocols_management.twig b/templates/settings/protocols_management.twig new file mode 100644 index 0000000..776b1dd --- /dev/null +++ b/templates/settings/protocols_management.twig @@ -0,0 +1,522 @@ +
+ +
+
+
+

{{ t('protocols.management') }}

+

{{ t('protocols.management_description') }}

+
+
+ + + + + + {{ t('protocols.add_protocol') }} + +
+
+
+ + + {% if success %} +
+
+ + + +

{{ success }}

+
+
+ {% endif %} + + {% if error %} +
+
+ + + +

{{ error }}

+
+
+ {% endif %} + + +
+
+
+

{{ t('protocols.available_protocols') }}

+
+ + +
+
+ +
+ {% for protocol in protocols %} +
+
+
+
+

{{ protocol.name }}

+ {% if protocol.is_active %} + + {{ t('common.active') }} + + {% else %} + + {{ t('common.inactive') }} + + {% endif %} + {% if protocol.ubuntu_compatible %} + + Ubuntu 22-24 + + {% endif %} + {% if protocol.ai_generation_count > 0 %} + + AI {{ protocol.ai_generation_count }} + + {% endif %} +
+

{{ protocol.description }}

+
+ {{ t('common.slug') }}: {{ protocol.slug }} + {{ t('common.servers') }}: {{ protocol.server_count }} + {{ t('common.templates') }}: {{ protocol.template_count }} + {{ t('common.variables') }}: {{ protocol.variable_count }} +
+
+
+ + + + + + + + + + + + {% if protocol.server_count == 0 %} + + {% endif %} +
+
+
+ {% else %} +
+ + + +

{{ t('protocols.no_protocols') }}

+

{{ t('protocols.no_protocols_description') }}

+ +
+ {% endfor %} +
+
+
+ + + + \ No newline at end of file diff --git a/templates/settings/scenario_form.twig b/templates/settings/scenario_form.twig new file mode 100644 index 0000000..39b6535 --- /dev/null +++ b/templates/settings/scenario_form.twig @@ -0,0 +1,250 @@ +{% extends "layout.twig" %} + +{% block content %} +
+
+
+
+
+
+ {% if scenario %} + {{ 'Редактирование сценария:' | trans }} {{ scenario.name }} + {% else %} + {{ 'Новый сценарий' | trans }} + {% endif %} +
+
+
+
+ + +
+ + + {{ 'например: xray-vless, openvpn-tls' | trans }} +
+ +
+ + +
+ +
+ + +
+ +
+ + + + {{ 'Структура JSON:' | trans }}
+ { "engine": "shell|builtin_awg", "metadata": {...}, "scripts": { "detect": "...", "install": "...", "restore": "..." } } +
+ + {{ 'Доступные переменные в скриптах:' | trans }}
+ {{ "{{server.host}}, {{server.username}}, {{server.container_name}}, {{metadata.*}}" | trans }} +
+ +
+ +
+ + +
+ +
+ + + {{ 'Отмена' | trans }} + + {% if scenario %} + + {% endif %} +
+
+
+
+ + +
+
+
{{ 'Справка по формату' | trans }}
+
+
+
{{ 'Поля сценария:' | trans }}
+
    +
  • engine: Тип движка ("shell" или "builtin_awg")
  • +
  • metadata: Объект с параметрами протокола (container_name, config_path и т.д.)
  • +
  • scripts: Объект со скриптами (detect, install, restore)
  • +
+ +
{{ 'Поля скриптов:' | trans }}
+
    +
  • detect: Bash скрипт для определения установленной конфигурации. Должен вывести JSON с полями "status" (absent/partial/existing) и "details"
  • +
  • install: Bash скрипт для установки протокола. Должен вывести JSON с "success": true/false
  • +
  • restore: Bash скрипт для восстановления конфигурации из detection результата
  • +
+ +
{{ 'Переменные окружения в скриптах:' | trans }}
+
    +
  • SERVER_HOST - IP/домен сервера
  • +
  • SERVER_USER - SSH пользователь
  • +
  • SERVER_CONTAINER - имя контейнера
  • +
  • PROTOCOL_* - все поля из metadata (например, PROTOCOL_CONTAINER_NAME, PROTOCOL_CONFIG_PATH)
  • +
+
+
+
+
+
+ + + + + +{% endblock %} diff --git a/templates/settings/scenario_view.twig b/templates/settings/scenario_view.twig new file mode 100644 index 0000000..0721c5b --- /dev/null +++ b/templates/settings/scenario_view.twig @@ -0,0 +1,246 @@ +{% extends "layout.twig" %} + +{% block content %} +
+
+
+ + + +
+
+
{{ 'Информация о сценарии' | trans }}
+
+
+
+
+
+
{{ 'Статус' | trans }}
+
+ {% if scenario.is_active %} + {{ 'Активный' | trans }} + {% else %} + {{ 'Отключен' | trans }} + {% endif %} +
+
{{ 'Движок' | trans }}
+
{{ definition.engine | default('unknown') }}
+
{{ 'Описание' | trans }}
+
{{ scenario.description | default('—') }}
+
+
+
+
+
{{ 'Контейнер' | trans }}
+
{{ definition.metadata.container_name | default('—') }}
+
{{ 'Путь конфигурации' | trans }}
+
{{ definition.metadata.config_path | default('—') }}
+
{{ 'Порт по умолчанию' | trans }}
+
{{ definition.metadata.default_port | default('—') }}
+
+
+
+
+
+ + +
+
+
{{ 'Скрипты' | trans }}
+
+
+ + +
+
+
{{ definition.scripts.detect | default('—') }}
+
+
+
{{ definition.scripts.install | default('—') }}
+
+
+
{{ definition.scripts.restore | default('—') }}
+
+
+
+
+ + +
+
+
{{ 'Метаданные' | trans }}
+
+
+
{{ definition.metadata | json_encode(constant('JSON_PRETTY_PRINT') | constant('JSON_UNESCAPED_SLASHES')) }}
+
+
+ + +
+
+
{{ 'Действия' | trans }}
+
+
+ + {% if scenario.slug != 'amnezia-wg' %} + + {% endif %} +
+
+
+
+
+ + + + + +{% endblock %} diff --git a/templates/settings/scenarios.twig b/templates/settings/scenarios.twig new file mode 100644 index 0000000..13dca5c --- /dev/null +++ b/templates/settings/scenarios.twig @@ -0,0 +1,156 @@ +{% extends "layout.twig" %} + +{% block content %} +
+
+
+
+

{{ 'Сценарии установки протоколов' | trans }}

+
+ + {{ 'Новый сценарий' | trans }} + + +
+
+ + {% if scenarios | length > 0 %} +
+ + + + + + + + + + + + {% for scenario in scenarios %} + + + + + + + + {% endfor %} + +
{{ 'Протокол' | trans }}{{ 'Описание' | trans }}{{ 'Движок' | trans }}{{ 'Статус' | trans }}{{ 'Действия' | trans }}
+ {{ scenario.name }} +
+ {{ scenario.slug }} +
{{ scenario.description | default('-') }} + + {{ scenario.definition.engine | default('unknown') }} + + + {% if scenario.is_active %} + {{ 'Активный' | trans }} + {% else %} + {{ 'Отключен' | trans }} + {% endif %} + +
+ + + + + + + + + + {% if scenario.slug != 'amnezia-wg' %} + + {% endif %} +
+
+
+ {% else %} + + {% endif %} +
+
+
+ + + + + +{% endblock %} diff --git a/templates/tools/qr_decode.twig b/templates/tools/qr_decode.twig new file mode 100644 index 0000000..42c6d41 --- /dev/null +++ b/templates/tools/qr_decode.twig @@ -0,0 +1,153 @@ +{% extends "layout.twig" %} +{% block title %}QR Decode{% endblock %} +{% block content %} +
+

QR Decode

+
+

Upload image

+ + +
+ +
+
+

Paste payload

+ + +
+
+

Result

+

+  
+
+ + + + + +{% endblock %} \ No newline at end of file From 690881803db116fbd7a8f68a6b49c85f03e35fa4 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Fri, 23 Jan 2026 18:57:27 +0300 Subject: [PATCH 02/72] fix: prevent confirm dialog from closing immediately on delete buttons --- templates/servers/index.twig | 5 ++--- templates/servers/view.twig | 5 ++++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/templates/servers/index.twig b/templates/servers/index.twig index 5c4f81f..c8a6264 100644 --- a/templates/servers/index.twig +++ b/templates/servers/index.twig @@ -98,9 +98,8 @@ {{ t('servers.view') }} -
-
diff --git a/templates/servers/view.twig b/templates/servers/view.twig index 6ffc24d..014ad0e 100644 --- a/templates/servers/view.twig +++ b/templates/servers/view.twig @@ -453,7 +453,10 @@ document.addEventListener('DOMContentLoaded', function() { document.querySelectorAll('.btn-uninstall-sp').forEach(btn => { btn.addEventListener('click', async function(e) { e.preventDefault(); - if (!confirm('Удалить протокол и всех его клиентов?')) return; + e.stopPropagation(); + e.stopImmediatePropagation(); + const confirmResult = confirm('Удалить протокол и всех его клиентов?'); + if (!confirmResult) return; const slug = btn.getAttribute('data-slug'); const m = document.getElementById('uninstallSpMsg'); m.textContent = ''; From 14eda839bcaf9fe87a7c623d355bf98e816b539d Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Fri, 23 Jan 2026 19:08:41 +0300 Subject: [PATCH 03/72] feat: replace native confirm with custom modal to fix auto-close issue --- templates/layout.twig | 74 ++++++++++++++++++++++++++++++++++++ templates/servers/index.twig | 2 +- templates/servers/view.twig | 4 +- 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/templates/layout.twig b/templates/layout.twig index 2d8d7af..dab87d1 100644 --- a/templates/layout.twig +++ b/templates/layout.twig @@ -135,6 +135,28 @@
{% block content %}{% endblock %}
+ +
@@ -174,6 +196,58 @@ // Form will submit normally, dropdown will close on page reload } }); + + // Custom confirmation modal function (replaces native confirm) + window.showConfirmModal = function(message, title) { + return new Promise((resolve) => { + const modal = document.getElementById('confirmModal'); + const titleEl = document.getElementById('confirmModalTitle'); + const msgEl = document.getElementById('confirmModalMessage'); + const okBtn = document.getElementById('confirmModalOk'); + const cancelBtn = document.getElementById('confirmModalCancel'); + + if (!modal) { + // Fallback to native confirm if modal not found + resolve(confirm(message)); + return; + } + + titleEl.textContent = title || 'Подтверждение'; + msgEl.textContent = message || 'Вы уверены?'; + + modal.style.display = 'flex'; + modal.classList.remove('hidden'); + + function cleanup() { + modal.style.display = 'none'; + modal.classList.add('hidden'); + okBtn.removeEventListener('click', onOk); + cancelBtn.removeEventListener('click', onCancel); + modal.removeEventListener('click', onBackdrop); + } + + function onOk() { + cleanup(); + resolve(true); + } + + function onCancel() { + cleanup(); + resolve(false); + } + + function onBackdrop(e) { + if (e.target === modal) { + cleanup(); + resolve(false); + } + } + + okBtn.addEventListener('click', onOk); + cancelBtn.addEventListener('click', onCancel); + modal.addEventListener('click', onBackdrop); + }); + }; {% block scripts %}{% endblock %} diff --git a/templates/servers/index.twig b/templates/servers/index.twig index c8a6264..2a64018 100644 --- a/templates/servers/index.twig +++ b/templates/servers/index.twig @@ -99,7 +99,7 @@ {{ t('servers.view') }}
-
diff --git a/templates/servers/view.twig b/templates/servers/view.twig index 014ad0e..0521138 100644 --- a/templates/servers/view.twig +++ b/templates/servers/view.twig @@ -455,8 +455,8 @@ document.addEventListener('DOMContentLoaded', function() { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); - const confirmResult = confirm('Удалить протокол и всех его клиентов?'); - if (!confirmResult) return; + const confirmed = await showConfirmModal('Удалить протокол и всех его клиентов?', 'Удаление протокола'); + if (!confirmed) return; const slug = btn.getAttribute('data-slug'); const m = document.getElementById('uninstallSpMsg'); m.textContent = ''; From c92696ad5263d1bcbc3b732b747af6cc8ec56454 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Fri, 23 Jan 2026 19:22:01 +0300 Subject: [PATCH 04/72] fix: update xray install script to correct variable expansion --- .../042_fix_xray_variable_expansion.sql | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 migrations/042_fix_xray_variable_expansion.sql diff --git a/migrations/042_fix_xray_variable_expansion.sql b/migrations/042_fix_xray_variable_expansion.sql new file mode 100644 index 0000000..bc59e55 --- /dev/null +++ b/migrations/042_fix_xray_variable_expansion.sql @@ -0,0 +1,85 @@ +-- Fix X-Ray install script variable substitution +-- The heredoc was preserving ${VAR} as literals instead of expanding them +UPDATE protocols +SET install_script = '#!/bin/bash + +set -euo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-amnezia-xray}" +PORT_RANGE_START=${PORT_RANGE_START:-30000} +PORT_RANGE_END=${PORT_RANGE_END:-65000} +XRAY_PORT=${SERVER_PORT:-$((RANDOM % (PORT_RANGE_END - PORT_RANGE_START + 1) + PORT_RANGE_START))} + +# Ensure image present +docker pull teddysun/xray >/dev/null 2>&1 || true + +# Generate keys +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[[:space:]]*[Kk]ey:[[:space:]]*(.*)$/\\1/p" | tr -d " \\t\\r\\n") +PUBLIC_KEY=$(printf "%s\\n" "$GEN" | sed -n -E "s/^[Pp]ublic[[:space:]]*[Kk]ey:[[:space:]]*(.*)$/\\1/p" | tr -d " \\t\\r\\n") + +if [ -z "$PUBLIC_KEY" ] && [ -n "$PRIVATE_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]ublic[[:space:]]*[Kk]ey:[[:space:]]*(.*)$/\\1/p" | tr -d " \\t\\r\\n" || true) +fi + +SHORT_ID=$(od -An -tx1 -N8 /dev/urandom | tr -d " \\n") +CLIENT_ID=$(cat /proc/sys/kernel/random/uuid) + +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 + +# Write config using printf to ensure variable expansion +printf ''%s\\n'' "{ + \"log\": { \"loglevel\": \"warning\" }, + \"inbounds\": [ + { + \"listen\": \"0.0.0.0\", + \"port\": $XRAY_PORT, + \"protocol\": \"vless\", + \"settings\": { + \"clients\": [ { \"id\": \"$CLIENT_ID\" } ], + \"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\" + } + } + } + ], + \"outbounds\": [ { \"protocol\": \"freedom\", \"tag\": \"direct\" } ] +}" > /opt/amnezia/xray/server.json + +# Start container +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 + +# Output configuration +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'; From e11cb574c6229058f6b845ad70a3b1150a1656cf Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Fri, 23 Jan 2026 19:33:58 +0300 Subject: [PATCH 05/72] fix: proper json escaping in xray install script --- migrations/043_fix_xray_json_quotes.sql | 83 +++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 migrations/043_fix_xray_json_quotes.sql diff --git a/migrations/043_fix_xray_json_quotes.sql b/migrations/043_fix_xray_json_quotes.sql new file mode 100644 index 0000000..60ddb62 --- /dev/null +++ b/migrations/043_fix_xray_json_quotes.sql @@ -0,0 +1,83 @@ +-- Fix X-Ray install script JSON quotes +-- Previous migration caused missing quotes in JSON because MySQL consumed one level of escaping +-- We need \\\" in SQL to get \" in Bash, which echo outputs as " in the file +UPDATE protocols +SET install_script = '#!/bin/bash +set -euo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-amnezia-xray}" +PORT_RANGE_START=${PORT_RANGE_START:-30000} +PORT_RANGE_END=${PORT_RANGE_END:-65000} +XRAY_PORT=${SERVER_PORT:-$((RANDOM % (PORT_RANGE_END - PORT_RANGE_START + 1) + PORT_RANGE_START))} + +# Ensure image present +docker pull teddysun/xray >/dev/null 2>&1 || true + +# Generate keys +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[[:space:]]*[Kk]ey:[[:space:]]*(.*)$/\\1/p" | tr -d " \\t\\r\\n") +PUBLIC_KEY=$(printf "%s\\n" "$GEN" | sed -n -E "s/^[Pp]ublic[[:space:]]*[Kk]ey:[[:space:]]*(.*)$/\\1/p" | tr -d " \\t\\r\\n") + +if [ -z "$PUBLIC_KEY" ] && [ -n "$PRIVATE_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]ublic[[:space:]]*[Kk]ey:[[:space:]]*(.*)$/\\1/p" | tr -d " \\t\\r\\n" || true) +fi + +SHORT_ID=$(od -An -tx1 -N8 /dev/urandom | tr -d " \\n") +CLIENT_ID=$(cat /proc/sys/kernel/random/uuid) + +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 + +C="/opt/amnezia/xray/server.json" +echo "{" > "$C" +echo " \\\"log\\\": { \\\"loglevel\\\": \\\"warning\\\" }," >> "$C" +echo " \\\"inbounds\\\": [" >> "$C" +echo " {" >> "$C" +echo " \\\"listen\\\": \\\"0.0.0.0\\\"," >> "$C" +echo " \\\"port\\\": $XRAY_PORT," >> "$C" +echo " \\\"protocol\\\": \\\"vless\\\"," >> "$C" +echo " \\\"settings\\\": {" >> "$C" +echo " \\\"clients\\\": [ { \\\"id\\\": \\\"$CLIENT_ID\\\" } ]," >> "$C" +echo " \\\"decryption\\\": \\\"none\\\"" >> "$C" +echo " }," >> "$C" +echo " \\\"streamSettings\\\": {" >> "$C" +echo " \\\"network\\\": \\\"tcp\\\"," >> "$C" +echo " \\\"security\\\": \\\"reality\\\"," >> "$C" +echo " \\\"realitySettings\\\": {" >> "$C" +echo " \\\"show\\\": false," >> "$C" +echo " \\\"dest\\\": \\\"$SERVER_NAME:443\\\"," >> "$C" +echo " \\\"xver\\\": 0," >> "$C" +echo " \\\"serverNames\\\": [ \\\"$SERVER_NAME\\\" ]," >> "$C" +echo " \\\"privateKey\\\": \\\"$PRIVATE_KEY\\\"," >> "$C" +echo " \\\"shortIds\\\": [ \\\"$SHORT_ID\\\" ]," >> "$C" +echo " \\\"fingerprint\\\": \\\"$FINGERPRINT\\\"," >> "$C" +echo " \\\"spiderX\\\": \\\"$SPIDER_X\\\"" >> "$C" +echo " }" >> "$C" +echo " }" >> "$C" +echo " }" >> "$C" +echo " ]," >> "$C" +echo " \\\"outbounds\\\": [ { \\\"protocol\\\": \\\"freedom\\\", \\\"tag\\\": \\\"direct\\\" } ]" >> "$C" +echo "}" >> "$C" + +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'; From 8fd8e1931717a9f1331d93ceaa7692cfd069a648 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Fri, 23 Jan 2026 19:55:56 +0300 Subject: [PATCH 06/72] fix: add flow xtls-rprx-vision to xray server config --- migrations/044_add_xray_flow.sql | 83 ++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 migrations/044_add_xray_flow.sql diff --git a/migrations/044_add_xray_flow.sql b/migrations/044_add_xray_flow.sql new file mode 100644 index 0000000..2bce945 --- /dev/null +++ b/migrations/044_add_xray_flow.sql @@ -0,0 +1,83 @@ +-- Fix X-Ray server config to include flow "xtls-rprx-vision" +-- This parameter is present in the client config template but was missing in the server config +-- causing connection failures with newer clients and X-Ray versions. +UPDATE protocols +SET install_script = '#!/bin/bash +set -euo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-amnezia-xray}" +PORT_RANGE_START=${PORT_RANGE_START:-30000} +PORT_RANGE_END=${PORT_RANGE_END:-65000} +XRAY_PORT=${SERVER_PORT:-$((RANDOM % (PORT_RANGE_END - PORT_RANGE_START + 1) + PORT_RANGE_START))} + +# Ensure image present +docker pull teddysun/xray >/dev/null 2>&1 || true + +# Generate keys +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[[:space:]]*[Kk]ey:[[:space:]]*(.*)$/\\1/p" | tr -d " \\t\\r\\n") +PUBLIC_KEY=$(printf "%s\\n" "$GEN" | sed -n -E "s/^[Pp]ublic[[:space:]]*[Kk]ey:[[:space:]]*(.*)$/\\1/p" | tr -d " \\t\\r\\n") + +if [ -z "$PUBLIC_KEY" ] && [ -n "$PRIVATE_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]ublic[[:space:]]*[Kk]ey:[[:space:]]*(.*)$/\\1/p" | tr -d " \\t\\r\\n" || true) +fi + +SHORT_ID=$(od -An -tx1 -N8 /dev/urandom | tr -d " \\n") +CLIENT_ID=$(cat /proc/sys/kernel/random/uuid) + +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 + +C="/opt/amnezia/xray/server.json" +echo "{" > "$C" +echo " \\\"log\\\": { \\\"loglevel\\\": \\\"warning\\\" }," >> "$C" +echo " \\\"inbounds\\\": [" >> "$C" +echo " {" >> "$C" +echo " \\\"listen\\\": \\\"0.0.0.0\\\"," >> "$C" +echo " \\\"port\\\": $XRAY_PORT," >> "$C" +echo " \\\"protocol\\\": \\\"vless\\\"," >> "$C" +echo " \\\"settings\\\": {" >> "$C" +echo " \\\"clients\\\": [ { \\\"id\\\": \\\"$CLIENT_ID\\\", \\\"flow\\\": \\\"xtls-rprx-vision\\\" } ]," >> "$C" +echo " \\\"decryption\\\": \\\"none\\\"" >> "$C" +echo " }," >> "$C" +echo " \\\"streamSettings\\\": {" >> "$C" +echo " \\\"network\\\": \\\"tcp\\\"," >> "$C" +echo " \\\"security\\\": \\\"reality\\\"," >> "$C" +echo " \\\"realitySettings\\\": {" >> "$C" +echo " \\\"show\\\": false," >> "$C" +echo " \\\"dest\\\": \\\"$SERVER_NAME:443\\\"," >> "$C" +echo " \\\"xver\\\": 0," >> "$C" +echo " \\\"serverNames\\\": [ \\\"$SERVER_NAME\\\" ]," >> "$C" +echo " \\\"privateKey\\\": \\\"$PRIVATE_KEY\\\"," >> "$C" +echo " \\\"shortIds\\\": [ \\\"$SHORT_ID\\\" ]," >> "$C" +echo " \\\"fingerprint\\\": \\\"$FINGERPRINT\\\"," >> "$C" +echo " \\\"spiderX\\\": \\\"$SPIDER_X\\\"" >> "$C" +echo " }" >> "$C" +echo " }" >> "$C" +echo " }" >> "$C" +echo " ]," >> "$C" +echo " \\\"outbounds\\\": [ { \\\"protocol\\\": \\\"freedom\\\", \\\"tag\\\": \\\"direct\\\" } ]" >> "$C" +echo "}" >> "$C" + +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'; From 5bffc0fbbdef792312ce284d96ca8feedc46a67e Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Fri, 23 Jan 2026 20:07:27 +0300 Subject: [PATCH 07/72] fix: set xray default port to 443 to match android client --- migrations/045_xray_port_443.sql | 81 ++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 migrations/045_xray_port_443.sql diff --git a/migrations/045_xray_port_443.sql b/migrations/045_xray_port_443.sql new file mode 100644 index 0000000..0ee0c4f --- /dev/null +++ b/migrations/045_xray_port_443.sql @@ -0,0 +1,81 @@ +-- Fix X-Ray port to 443 to match Android client and avoid firewall issues. +-- Previous usage of random ports caused connection failures on restricted networks. +UPDATE protocols +SET install_script = '#!/bin/bash +set -euo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-amnezia-xray}" +# Default to port 443 if SERVER_PORT is not provided +XRAY_PORT=${SERVER_PORT:-443} + +# Ensure image present +docker pull teddysun/xray >/dev/null 2>&1 || true + +# Generate keys +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[[:space:]]*[Kk]ey:[[:space:]]*(.*)$/\\1/p" | tr -d " \\t\\r\\n") +PUBLIC_KEY=$(printf "%s\\n" "$GEN" | sed -n -E "s/^[Pp]ublic[[:space:]]*[Kk]ey:[[:space:]]*(.*)$/\\1/p" | tr -d " \\t\\r\\n") + +if [ -z "$PUBLIC_KEY" ] && [ -n "$PRIVATE_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]ublic[[:space:]]*[Kk]ey:[[:space:]]*(.*)$/\\1/p" | tr -d " \\t\\r\\n" || true) +fi + +SHORT_ID=$(od -An -tx1 -N8 /dev/urandom | tr -d " \\n") +CLIENT_ID=$(cat /proc/sys/kernel/random/uuid) + +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 + +C="/opt/amnezia/xray/server.json" +echo "{" > "$C" +echo " \\\"log\\\": { \\\"loglevel\\\": \\\"warning\\\" }," >> "$C" +echo " \\\"inbounds\\\": [" >> "$C" +echo " {" >> "$C" +echo " \\\"listen\\\": \\\"0.0.0.0\\\"," >> "$C" +echo " \\\"port\\\": $XRAY_PORT," >> "$C" +echo " \\\"protocol\\\": \\\"vless\\\"," >> "$C" +echo " \\\"settings\\\": {" >> "$C" +echo " \\\"clients\\\": [ { \\\"id\\\": \\\"$CLIENT_ID\\\", \\\"flow\\\": \\\"xtls-rprx-vision\\\" } ]," >> "$C" +echo " \\\"decryption\\\": \\\"none\\\"" >> "$C" +echo " }," >> "$C" +echo " \\\"streamSettings\\\": {" >> "$C" +echo " \\\"network\\\": \\\"tcp\\\"," >> "$C" +echo " \\\"security\\\": \\\"reality\\\"," >> "$C" +echo " \\\"realitySettings\\\": {" >> "$C" +echo " \\\"show\\\": false," >> "$C" +echo " \\\"dest\\\": \\\"$SERVER_NAME:443\\\"," >> "$C" +echo " \\\"xver\\\": 0," >> "$C" +echo " \\\"serverNames\\\": [ \\\"$SERVER_NAME\\\" ]," >> "$C" +echo " \\\"privateKey\\\": \\\"$PRIVATE_KEY\\\"," >> "$C" +echo " \\\"shortIds\\\": [ \\\"$SHORT_ID\\\" ]," >> "$C" +echo " \\\"fingerprint\\\": \\\"$FINGERPRINT\\\"," >> "$C" +echo " \\\"spiderX\\\": \\\"$SPIDER_X\\\"" >> "$C" +echo " }" >> "$C" +echo " }" >> "$C" +echo " }" >> "$C" +echo " ]," >> "$C" +echo " \\\"outbounds\\\": [ { \\\"protocol\\\": \\\"freedom\\\", \\\"tag\\\": \\\"direct\\\" } ]" >> "$C" +echo "}" >> "$C" + +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'; From 7734f5413793434700bfd08ed3f317cbbc684a95 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 24 Jan 2026 13:03:05 +0300 Subject: [PATCH 08/72] fix(xray): Fix X-Ray install script and QR code generation - Fix docker run command in install script (use single line instead of backslash continuations which break when stored in MySQL) - Handle new xray x25519 output format that uses 'Password' instead of 'Public key' - Make addClientToServer method public for backup restore functionality - Created migration 046 with complete fix for X-Ray VLESS protocol --- inc/VpnClient.php | 34 +++++++++++- migrations/046_fix_xray_docker_run.sql | 77 ++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 migrations/046_fix_xray_docker_run.sql diff --git a/inc/VpnClient.php b/inc/VpnClient.php index 671a6c4..3011f6f 100644 --- a/inc/VpnClient.php +++ b/inc/VpnClient.php @@ -200,7 +200,7 @@ class VpnClient if (isset($extras['result']) && is_array($extras['result'])) { $extras = array_merge($extras, $extras['result']); } - + foreach ($extras as $k => $v) { if (is_scalar($v)) { // Preserve uppercase for AWG obfuscation parameters @@ -834,7 +834,7 @@ class VpnClient /** * Add client to server using wg set (more reliable than syncconf) */ - private static function addClientToServer(array $serverData, string $publicKey, string $clientIP): void + public static function addClientToServer(array $serverData, string $publicKey, string $clientIP): void { $containerName = $serverData['container_name']; $presharedKey = $serverData['preshared_key']; @@ -936,6 +936,36 @@ class VpnClient require_once __DIR__ . '/QrUtil.php'; try { + // Check for X-Ray VLESS + if (strpos($config, 'vless://') === 0) { + // Parse VLESS URI + $parsed = parse_url($config); + // Allow missing user (UUID) and port for partial configs + if ($parsed && isset($parsed['host'])) { + $host = $parsed['host']; + $port = isset($parsed['port']) ? (int) $parsed['port'] : 443; + $clientId = $parsed['user'] ?? ''; + $fragment = $parsed['fragment'] ?? ''; + + parse_str($parsed['query'] ?? '', $query); + + $reality = null; + if (($query['security'] ?? '') === 'reality') { + $reality = [ + 'publicKey' => $query['pbk'] ?? '', + 'serverName' => $query['sni'] ?? '', + 'shortId' => $query['sid'] ?? '', + 'fingerprint' => $query['fp'] ?? 'chrome' + ]; + } + + // Use QrUtil to encode correct X-Ray payload + $payloadXray = QrUtil::encodeXrayPayload($host, $port, $clientId, $fragment, $reality); + return QrUtil::pngBase64($payloadXray); + } + } + + // Fallback for WireGuard / default // Use old Amnezia format with Qt/QDataStream encoding $payloadOld = QrUtil::encodeOldPayloadFromConf($config); $dataUri = QrUtil::pngBase64($payloadOld); diff --git a/migrations/046_fix_xray_docker_run.sql b/migrations/046_fix_xray_docker_run.sql new file mode 100644 index 0000000..c788a9f --- /dev/null +++ b/migrations/046_fix_xray_docker_run.sql @@ -0,0 +1,77 @@ +-- Fix X-Ray install script: +-- 1) Use single-line docker run (backslash continuations break in MySQL) +-- 2) Handle new xray x25519 output format (Password instead of Public key) +UPDATE protocols +SET install_script = '#!/bin/bash +set -euo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-amnezia-xray}" +XRAY_PORT=${SERVER_PORT:-443} + +docker pull teddysun/xray >/dev/null 2>&1 || true + +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 +PUBLIC_KEY=$(printf "%s\n" "$GEN" | sed -n -E "s/^[Pp]ublic[[:space:]]*[Kk]ey:[[:space:]]*(.*)$/\\1/p" | tr -d " \\t\\r\\n") +if [ -z "$PUBLIC_KEY" ]; then + PUBLIC_KEY=$(printf "%s\n" "$GEN" | sed -n -E "s/^[Pp]assword:[[:space:]]*(.*)$/\\1/p" | tr -d " \\t\\r\\n") +fi +if [ -z "$PUBLIC_KEY" ] && [ -n "$PRIVATE_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 + +SHORT_ID=$(od -An -tx1 -N8 /dev/urandom | tr -d " \\n") +CLIENT_ID=$(cat /proc/sys/kernel/random/uuid) +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 < Date: Sat, 24 Jan 2026 13:17:57 +0300 Subject: [PATCH 09/72] fix(qr): Use Qt qCompress format for QR code encoding Amnezia Android uses qUncompress which expects: - 4-byte big-endian uncompressed length prefix - zlib compressed data (gzcompress output) Previously we used a custom 12-byte header (version, compressedLen, uncompressedLen) which was incompatible with Qt's qUncompress. This fix ensures X-Ray QR codes can be properly decoded by Amnezia VPN app. --- inc/QrUtil.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/inc/QrUtil.php b/inc/QrUtil.php index e679531..8f77a29 100644 --- a/inc/QrUtil.php +++ b/inc/QrUtil.php @@ -63,15 +63,15 @@ class QrUtil public static function encodeOldPayloadFromJson(string $jsonText): string { $json = self::normalizeJson($jsonText); - // Old format uses zlib (gzcompress) with header [version, compressed_len, uncompressed_len] + // Qt qCompress format: 4-byte big-endian uncompressed length + zlib compressed data + // This is what Amnezia Android's qUncompress expects $compressed = gzcompress($json, 9); if ($compressed === false) { throw new RuntimeException('gzcompress failed'); } $uncompressedLen = strlen($json); - $compressedLen = strlen($compressed) + 4; - $version = 0x07C00100; // align with working payload header (big-endian) - $header = pack('N3', $version, $compressedLen, $uncompressedLen); + // Qt format: 4 bytes of uncompressed length (big-endian) + zlib data + $header = pack('N', $uncompressedLen); return self::urlsafe_b64_encode($header . $compressed); } From 48f85365bdba6cc60cdcdb9a4c985ef7e7e12137 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 24 Jan 2026 13:26:24 +0300 Subject: [PATCH 10/72] fix(qr): Revert to original 12-byte Amnezia header format The working QR format from Amnezia app uses: - 12-byte header: version (0x07C00100) + compressedLen + uncompressedLen - zlib compressed JSON data Previously I incorrectly changed this to Qt qCompress format (4-byte header). This commit reverts to the correct Amnezia-compatible format. --- inc/QrUtil.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/inc/QrUtil.php b/inc/QrUtil.php index 8f77a29..293dc99 100644 --- a/inc/QrUtil.php +++ b/inc/QrUtil.php @@ -63,15 +63,16 @@ class QrUtil public static function encodeOldPayloadFromJson(string $jsonText): string { $json = self::normalizeJson($jsonText); - // Qt qCompress format: 4-byte big-endian uncompressed length + zlib compressed data - // This is what Amnezia Android's qUncompress expects + // Amnezia format: 12-byte header [version, compressed_len, uncompressed_len] + zlib compressed data + // Version 0x07C00100 is required for compatibility with Amnezia apps $compressed = gzcompress($json, 9); if ($compressed === false) { throw new RuntimeException('gzcompress failed'); } $uncompressedLen = strlen($json); - // Qt format: 4 bytes of uncompressed length (big-endian) + zlib data - $header = pack('N', $uncompressedLen); + $compressedLen = strlen($compressed) + 4; // +4 for the uncompressed length field + $version = 0x07C00100; // Amnezia magic version number + $header = pack('N3', $version, $compressedLen, $uncompressedLen); return self::urlsafe_b64_encode($header . $compressed); } From f5ab0ec2e3cf90770ade109251c7e655b60ec79b Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 24 Jan 2026 13:34:13 +0300 Subject: [PATCH 11/72] fix(qr): Wrap X-Ray config in 'config' field inside last_config Amnezia Android expects the contents of 'last_config' to be a JSON object containing a 'config' field which holds the actual protocol configuration string. Previously we were putting the configuration directly into 'last_config', which caused the import to fail. --- inc/QrUtil.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/inc/QrUtil.php b/inc/QrUtil.php index 293dc99..a822240 100644 --- a/inc/QrUtil.php +++ b/inc/QrUtil.php @@ -462,7 +462,8 @@ class QrUtil [ 'container' => 'amnezia-xray', 'xray' => [ - 'last_config' => json_encode($clientCfg, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), + // X-Ray config must be wrapped in a "config" field inside the last_config JSON + 'last_config' => json_encode(['config' => json_encode($clientCfg, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'port' => (string) $port, 'transport_proto' => 'tcp' ] From dc23a8f21332321f49ed4b69d812375f5ee259e6 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 24 Jan 2026 13:38:32 +0300 Subject: [PATCH 12/72] fix(qr): Use hybrid Chunked+Qt format for QR codes The format that likely works with Amnezia Android is: HEADER: 0x07C00100 (Magic 1984, Count 1, Id 0) PAYLOAD: [UncompressedLen (4b)] + [Zlib Data] This satisfies the Chunked QR check (magic 0x07C0) and then passes the payload to qUncompress, which expects the 4-byte uncompressed length prefix. Previous attempts failed because: 1. 12-byte header included compressedLen, which qUncompress interpreted as uncompressedLen (causing size mismatch error) 2. 4-byte header (Qt only) failed the Magic check --- inc/QrUtil.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/inc/QrUtil.php b/inc/QrUtil.php index a822240..6bb8f62 100644 --- a/inc/QrUtil.php +++ b/inc/QrUtil.php @@ -63,16 +63,15 @@ class QrUtil public static function encodeOldPayloadFromJson(string $jsonText): string { $json = self::normalizeJson($jsonText); - // Amnezia format: 12-byte header [version, compressed_len, uncompressed_len] + zlib compressed data - // Version 0x07C00100 is required for compatibility with Amnezia apps $compressed = gzcompress($json, 9); if ($compressed === false) { throw new RuntimeException('gzcompress failed'); } $uncompressedLen = strlen($json); - $compressedLen = strlen($compressed) + 4; // +4 for the uncompressed length field - $version = 0x07C00100; // Amnezia magic version number - $header = pack('N3', $version, $compressedLen, $uncompressedLen); + $version = 0x07C00100; // Magic 1984 (07C0), Count 1, Id 0 + // Pack version (4 bytes) and uncompressed length (4 bytes) + // Note: we skipped compressedLen which was present in old format but invalid for qUncompress + $header = pack('NN', $version, $uncompressedLen); return self::urlsafe_b64_encode($header . $compressed); } From 1006debc42f4481c695376a794c50c5e8d36e476 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 24 Jan 2026 13:42:20 +0300 Subject: [PATCH 13/72] fix(qr): Return to 12-byte Amnezia header but keep config wrapping Reverting header to 12-byte format (0x07C00100 + compressedLen + uncompressedLen). This header format is known to be scanned correctly by Amnezia app. Previous failure with this header was due to missing config wrapping. Now we have both: correct header AND correct content structure. --- inc/QrUtil.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/inc/QrUtil.php b/inc/QrUtil.php index 6bb8f62..e294506 100644 --- a/inc/QrUtil.php +++ b/inc/QrUtil.php @@ -68,10 +68,9 @@ class QrUtil throw new RuntimeException('gzcompress failed'); } $uncompressedLen = strlen($json); - $version = 0x07C00100; // Magic 1984 (07C0), Count 1, Id 0 - // Pack version (4 bytes) and uncompressed length (4 bytes) - // Note: we skipped compressedLen which was present in old format but invalid for qUncompress - $header = pack('NN', $version, $uncompressedLen); + $compressedLen = strlen($compressed) + 4; // +4 for the uncompressed length field + $version = 0x07C00100; // Amnezia magic version number + $header = pack('N3', $version, $compressedLen, $uncompressedLen); return self::urlsafe_b64_encode($header . $compressed); } From a508cc665c711359a7d6c2c3c85683e0d03827cd Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 24 Jan 2026 13:45:56 +0300 Subject: [PATCH 14/72] fix(qr): Add isThirdPartyConfig and reorder keys - Added 'isThirdPartyConfig' => true to X-Ray config object. This flag is present in imported configs in Amnezia Android. - Reordered keys so protocol object ('xray') comes before 'container' key, matching the order seen in WireGuard QR codes. --- inc/QrUtil.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/inc/QrUtil.php b/inc/QrUtil.php index e294506..9e434d9 100644 --- a/inc/QrUtil.php +++ b/inc/QrUtil.php @@ -458,13 +458,14 @@ class QrUtil $envelope = [ 'containers' => [ [ - 'container' => 'amnezia-xray', 'xray' => [ + 'isThirdPartyConfig' => true, // X-Ray config must be wrapped in a "config" field inside the last_config JSON 'last_config' => json_encode(['config' => json_encode($clientCfg, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'port' => (string) $port, 'transport_proto' => 'tcp' - ] + ], + 'container' => 'amnezia-xray' ] ], 'defaultContainer' => 'amnezia-xray', From 657fbf8df0f71ede100426ed156b7397a6e97dbe Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 24 Jan 2026 13:48:57 +0300 Subject: [PATCH 15/72] fix(qr): Use raw VLESS URI in last_config->config Instead of generating a JSON config for X-Ray, pass the raw VLESS URI string wrapped in a JSON object inside . This matches the behavior of WireGuard config handling in the master branch and is likely the expected format for Amnezia Android X-Ray import. --- inc/QrUtil.php | 69 +++++++++++++++++------------------------------ inc/VpnClient.php | 2 +- 2 files changed, 25 insertions(+), 46 deletions(-) diff --git a/inc/QrUtil.php b/inc/QrUtil.php index 9e434d9..39b4f44 100644 --- a/inc/QrUtil.php +++ b/inc/QrUtil.php @@ -409,59 +409,38 @@ class QrUtil return json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); } - public static function encodeXrayPayload(string $host, int $port, string $clientId, string $description = '', ?array $reality = null): string + public static function encodeXrayPayload(string $host, int $port, string $clientId, string $description = '', ?array $reality = null, string $rawConfig = ''): string { $desc = $description !== '' ? $description : self::resolveServerDescription($host); - $clientCfg = [ - 'log' => ['loglevel' => 'error'], - 'inbounds' => [ - [ - 'listen' => '127.0.0.1', - 'port' => 10808, - 'protocol' => 'socks', - 'settings' => ['udp' => true] - ] - ], - 'outbounds' => [ - [ - 'protocol' => 'vless', - 'settings' => [ - 'vnext' => [ - [ - 'address' => $host, - 'port' => $port, - 'users' => [ - [ - 'id' => $clientId, - 'flow' => ($reality && isset($reality['publicKey']) && $reality['publicKey'] !== '') ? 'xtls-rprx-vision' : null, - 'encryption' => 'none' - ] - ] - ] - ] - ], - 'streamSettings' => [ - 'network' => 'tcp', - 'security' => ($reality && isset($reality['publicKey']) && $reality['publicKey'] !== '') ? 'reality' : 'none', - 'realitySettings' => ($reality && isset($reality['publicKey']) && $reality['publicKey'] !== '') ? [ - 'fingerprint' => 'chrome', - 'serverName' => (string) ($reality['serverName'] ?? $host), - 'publicKey' => (string) $reality['publicKey'], - 'shortId' => (string) ($reality['shortId'] ?? ''), - 'spiderX' => '' - ] : null - ] - ] - ] - ]; + + // Instead of generating a JSON config, we wrap the raw VLESS URI in a "config" field. + // This matches how WireGuard configs are handled in master branch and is likely what Amnezia expects. + // If rawConfig is not provided, we reconstruct a basic VLESS URI (fallback) + if (empty($rawConfig)) { + // Basic reconstruction if needed, but we should pass valid rawConfig from VpnClient + $security = ($reality && isset($reality['publicKey']) && $reality['publicKey'] !== '') ? 'reality' : 'none'; + $type = 'tcp'; + $flow = ($security === 'reality') ? 'xtls-rprx-vision' : ''; + + $query = http_build_query([ + 'security' => $security, + 'type' => $type, + 'flow' => $flow, + 'sni' => $reality['serverName'] ?? '', + 'pbk' => $reality['publicKey'] ?? '', + 'fp' => $reality['fingerprint'] ?? 'chrome', + 'sid' => $reality['shortId'] ?? '' + ]); + $rawConfig = "vless://$clientId@$host:$port?$query"; + } $envelope = [ 'containers' => [ [ 'xray' => [ 'isThirdPartyConfig' => true, - // X-Ray config must be wrapped in a "config" field inside the last_config JSON - 'last_config' => json_encode(['config' => json_encode($clientCfg, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + // Wrap the raw VLESS URI in a "config" field inside last_config + 'last_config' => json_encode(['config' => $rawConfig], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'port' => (string) $port, 'transport_proto' => 'tcp' ], diff --git a/inc/VpnClient.php b/inc/VpnClient.php index 3011f6f..5dea7e6 100644 --- a/inc/VpnClient.php +++ b/inc/VpnClient.php @@ -960,7 +960,7 @@ class VpnClient } // Use QrUtil to encode correct X-Ray payload - $payloadXray = QrUtil::encodeXrayPayload($host, $port, $clientId, $fragment, $reality); + $payloadXray = QrUtil::encodeXrayPayload($host, $port, $clientId, $fragment, $reality, $config); return QrUtil::pngBase64($payloadXray); } } From e4d4b4bdc0b22ab270eafade6fe5763d9410bb02 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 24 Jan 2026 14:08:43 +0300 Subject: [PATCH 16/72] feat(xray): Implement universal client addition with fallback - Added InstallProtocolManager::addClient and fallback logic for X-Ray VLESS to update server configuration (server.json) and restart container. - Updated VpnClient::create to invoke InstallProtocolManager::addClient for scripted protocols, enabling dynamic user addition. - Ensured UUID generation for X-Ray clients. --- inc/InstallProtocolManager.php | 105 ++++++++++++++++++++++++++++++++- inc/VpnClient.php | 22 +++++++ 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/inc/InstallProtocolManager.php b/inc/InstallProtocolManager.php index 8f86d10..be4fb7b 100644 --- a/inc/InstallProtocolManager.php +++ b/inc/InstallProtocolManager.php @@ -285,7 +285,7 @@ class InstallProtocolManager 'awg_params' => $result['awg_params'] ?? null, ]; if (($protocol['slug'] ?? '') === 'xray-vless') { - foreach (['client_id','container_name','server_port','xray_port','reality_public_key','reality_private_key','reality_short_id','reality_server_name'] as $k) { + foreach (['client_id', 'container_name', 'server_port', 'xray_port', 'reality_public_key', 'reality_private_key', 'reality_short_id', 'reality_server_name'] as $k) { if (array_key_exists($k, $result)) { $extras[$k] = $result[$k]; } @@ -494,6 +494,11 @@ class InstallProtocolManager ]; } + public static function addClient(VpnServer $server, array $protocol, array $options = []): array + { + return self::runScript($server, $protocol, 'add_client', $options); + } + private static function runScript(VpnServer $server, array $protocol, string $phase, array $options = []): array { $definition = $protocol['definition'] ?? []; @@ -503,6 +508,8 @@ class InstallProtocolManager $scripts = $protocol['install_script'] ?? null; } elseif ($phase === 'uninstall') { $scripts = $protocol['uninstall_script'] ?? null; + } elseif ($phase === 'add_client' && ($protocol['slug'] ?? '') === 'xray-vless') { + return self::runBuiltinXrayAddClient($server, $options); } } if (!$scripts) { @@ -518,6 +525,11 @@ class InstallProtocolManager 'message' => 'Скрипт удаления не настроен для протокола' ]; } + if ($phase === 'add_client') { + // If no script and no builtin handler, we just skip it (assume not needed or manual) + // Or throw generic error? Better return success to not break flow if not implemented for other protocols + return ['success' => true, 'message' => 'No add_client script defined']; + } throw new Exception('Скрипт ' . $phase . ' не настроен для протокола'); } @@ -1049,7 +1061,7 @@ class InstallProtocolManager } try { $cfg = $server->executeCommand("docker exec -i " . escapeshellarg($containerName) . " cat /opt/amnezia/xray/server.json 2>/dev/null", true); - if (trim((string)$cfg) === '') { + if (trim((string) $cfg) === '') { $cfg = $server->executeCommand("docker exec -i " . escapeshellarg($containerName) . " cat /etc/xray/config.json 2>/dev/null", true); } $decoded = json_decode(trim((string) $cfg), true); @@ -1112,7 +1124,10 @@ class InstallProtocolManager $config = [ 'server_host' => $server->getData()['host'] ?? null, 'server_port' => $port, - 'extras' => ['password' => $password, 'client_id' => $clientId, 'result' => $res, + 'extras' => [ + 'password' => $password, + 'client_id' => $clientId, + 'result' => $res, 'reality_public_key' => $res['reality_public_key'] ?? null, 'reality_short_id' => $res['reality_short_id'] ?? null, 'reality_server_name' => $res['reality_server_name'] ?? null, @@ -1128,4 +1143,88 @@ class InstallProtocolManager throw $e; } } + + private static function runBuiltinXrayAddClient(VpnServer $server, array $options): array + { + $clientId = $options['client_id'] ?? null; + if (!$clientId) { + throw new Exception("Client ID is required for X-Ray add_client"); + } + + // Default container name if not provided + $containerName = 'amnezia-xray'; + if (!empty($options['container_name'])) { + $containerName = $options['container_name']; + } + + Logger::appendInstall($server->getId(), "Adding X-Ray client $clientId to container $containerName"); + + // 1. Read config + $catCmd = "docker exec -i " . escapeshellarg($containerName) . " cat /opt/amnezia/xray/server.json 2>/dev/null"; + $configRaw = $server->executeCommand($catCmd, true); + + if (trim($configRaw) === '') { + $catCmd = "docker exec -i " . escapeshellarg($containerName) . " cat /etc/xray/config.json 2>/dev/null"; + $configRaw = $server->executeCommand($catCmd, true); + } + + if (trim($configRaw) === '') { + throw new Exception("Could not read X-Ray config from $containerName"); + } + + $config = json_decode($configRaw, true); + if (!$config) { + throw new Exception("Invalid JSON in X-Ray config"); + } + + // 2. Modify config + // Assuming VLESS structure: inbounds[0] -> settings -> clients + if (!isset($config['inbounds'][0]['settings']['clients'])) { + // Might be different structure? But we stick to standard Amnezia XRay config + if (!isset($config['inbounds'][0]['settings'])) { + $config['inbounds'][0]['settings'] = []; + } + if (!isset($config['inbounds'][0]['settings']['clients'])) { + $config['inbounds'][0]['settings']['clients'] = []; + } + } + + // Check if client exists + $clients = &$config['inbounds'][0]['settings']['clients']; + foreach ($clients as $c) { + if (($c['id'] ?? '') === $clientId) { + // Already exists + Logger::appendInstall($server->getId(), "Client $clientId already exists in X-Ray config"); + return ['success' => true, 'message' => 'Client already exists']; + } + } + + // Add client + $newClient = ['id' => $clientId]; + + // Detect flow from other clients or default + $flow = 'xtls-rprx-vision'; // Default for Reality + if (!empty($clients)) { + if (isset($clients[0]['flow'])) { + $flow = $clients[0]['flow']; + } + } + $newClient['flow'] = $flow; + + $clients[] = $newClient; + + // 3. Write config back + $newJson = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + $b64 = base64_encode($newJson); + $writeCmd = "docker exec -i " . escapeshellarg($containerName) . " sh -c 'echo \"$b64\" | base64 -d > /opt/amnezia/xray/server.json'"; + + $server->executeCommand($writeCmd, true); + + // 4. Restart container + $server->executeCommand("docker restart " . escapeshellarg($containerName), true); + + Logger::appendInstall($server->getId(), "Updated X-Ray config and restarted container"); + + return ['success' => true]; + } } diff --git a/inc/VpnClient.php b/inc/VpnClient.php index 5dea7e6..eff2eb2 100644 --- a/inc/VpnClient.php +++ b/inc/VpnClient.php @@ -386,6 +386,28 @@ class VpnClient if (($slug ?? '') === 'smb' && empty($vars['password'])) { $vars['password'] = $pass; } + + // Ensure client_id (UUID) for X-Ray + if (empty($vars['client_id']) && (stripos($slug, 'xray') !== false || stripos($slug, 'vless') !== false)) { + $data = random_bytes(16); + $data[6] = chr(ord($data[6]) & 0x0f | 0x40); + $data[8] = chr(ord($data[8]) & 0x3f | 0x80); + $vars['client_id'] = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); + } + + // Try to add client to server via universal manager (supports scripts and builtins) + if ($protoRow) { + // We pass generic options. InstallProtocolManager will handle specific logic for 'add_client' phase. + // For xray-vless it uses builtin fallback in runScript. + try { + require_once __DIR__ . '/InstallProtocolManager.php'; + InstallProtocolManager::addClient($server, $protoRow, $vars); + } catch (Exception $e) { + error_log("Failed to add client to server: " . $e->getMessage()); + throw $e; + } + } + $config = $protoRow ? ProtocolService::generateProtocolOutput($protoRow, $vars) : ''; // Prepare last_config_json for QR code generation if config is JSON (XRay) From 0268e26c8502977d530be3fb6ec627e538f1ae7d Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 24 Jan 2026 14:28:53 +0300 Subject: [PATCH 17/72] fix(client): Propagate show_text_content setting from protocol to client view --- public/index.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/public/index.php b/public/index.php index b67517a..f7e27f3 100644 --- a/public/index.php +++ b/public/index.php @@ -1124,6 +1124,10 @@ Router::get('/clients/{id}', function ($params) { $stmt->execute([$serverData['install_protocol'] ?? '']); $protocol = $stmt->fetch(); } + + if ($protocol) { + $clientData['show_text_content'] = !empty($protocol['show_text_content']); + } if ($protocol && ($protocol['output_template'] ?? '') !== '') { $slug = $protocol['slug'] ?? ''; $isWireguard = in_array($slug, ['amnezia-wg-advanced', 'wireguard-standard', 'amnezia-wg'], true); From 4554a77033c719bf7c930ea683a62c4612f9429c Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 24 Jan 2026 14:35:12 +0300 Subject: [PATCH 18/72] fix(qr): Use raw URI for X-Ray QR codes to ensure compatibility --- inc/VpnClient.php | 45 ++++++++++++++++----------------------------- 1 file changed, 16 insertions(+), 29 deletions(-) diff --git a/inc/VpnClient.php b/inc/VpnClient.php index eff2eb2..be9c515 100644 --- a/inc/VpnClient.php +++ b/inc/VpnClient.php @@ -264,7 +264,8 @@ class VpnClient if (is_array($clients) && !empty($clients)) { $cid = $clients[0]['id'] ?? null; if (is_string($cid) && $cid !== '' && empty($vars['client_id'])) { - $vars['client_id'] = $cid; + // $vars['client_id'] = $cid; + // DO NOT reuse existing ID. We want to create a NEW client. } } $stream = $inbounds[0]['streamSettings'] ?? []; @@ -388,7 +389,10 @@ class VpnClient } // Ensure client_id (UUID) for X-Ray - if (empty($vars['client_id']) && (stripos($slug, 'xray') !== false || stripos($slug, 'vless') !== false)) { + if (stripos($slug, 'xray') !== false || stripos($slug, 'vless') !== false) { + // Force clear any pre-existing ID from extras + unset($vars['client_id']); + $data = random_bytes(16); $data[6] = chr(ord($data[6]) & 0x0f | 0x40); $data[8] = chr(ord($data[8]) & 0x3f | 0x80); @@ -400,12 +404,16 @@ class VpnClient // We pass generic options. InstallProtocolManager will handle specific logic for 'add_client' phase. // For xray-vless it uses builtin fallback in runScript. try { + error_log("DEBUG: Attempting to add client via IPM. Slug: " . ($protoRow['slug'] ?? 'N/A') . ", ClientUUID: " . ($vars['client_id'] ?? 'N/A')); require_once __DIR__ . '/InstallProtocolManager.php'; InstallProtocolManager::addClient($server, $protoRow, $vars); + error_log("DEBUG: IPM::addClient returned successfully"); } catch (Exception $e) { - error_log("Failed to add client to server: " . $e->getMessage()); + error_log("DEBUG: Failed to add client to server: " . $e->getMessage()); throw $e; } + } else { + error_log("DEBUG: protoRow is empty, cannot call IPM::addClient"); } $config = $protoRow ? ProtocolService::generateProtocolOutput($protoRow, $vars) : ''; @@ -959,32 +967,11 @@ class VpnClient try { // Check for X-Ray VLESS - if (strpos($config, 'vless://') === 0) { - // Parse VLESS URI - $parsed = parse_url($config); - // Allow missing user (UUID) and port for partial configs - if ($parsed && isset($parsed['host'])) { - $host = $parsed['host']; - $port = isset($parsed['port']) ? (int) $parsed['port'] : 443; - $clientId = $parsed['user'] ?? ''; - $fragment = $parsed['fragment'] ?? ''; - - parse_str($parsed['query'] ?? '', $query); - - $reality = null; - if (($query['security'] ?? '') === 'reality') { - $reality = [ - 'publicKey' => $query['pbk'] ?? '', - 'serverName' => $query['sni'] ?? '', - 'shortId' => $query['sid'] ?? '', - 'fingerprint' => $query['fp'] ?? 'chrome' - ]; - } - - // Use QrUtil to encode correct X-Ray payload - $payloadXray = QrUtil::encodeXrayPayload($host, $port, $clientId, $fragment, $reality, $config); - return QrUtil::pngBase64($payloadXray); - } + // Check for X-Ray VLESS, VMess, Shadowsocks (Standard URI schemes) + if (strpos($config, 'vless://') === 0 || strpos($config, 'vmess://') === 0 || strpos($config, 'ss://') === 0) { + // Generate a standard QR code containing just the URI string. + // This is compatible with Amnezia VPB, v2rayNG, and other standard clients. + return QrUtil::pngBase64($config); } // Fallback for WireGuard / default From 1018f96fc420f1e6607c2012cba232f8440be93b Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 24 Jan 2026 14:50:55 +0300 Subject: [PATCH 19/72] fix(qr): Pass raw VLESS URI in last_config without JSON wrapper --- inc/QrUtil.php | 4 ++-- inc/VpnClient.php | 45 +++++++++++++++++++++++++++++---------------- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/inc/QrUtil.php b/inc/QrUtil.php index 39b4f44..c1d86ab 100644 --- a/inc/QrUtil.php +++ b/inc/QrUtil.php @@ -439,8 +439,8 @@ class QrUtil [ 'xray' => [ 'isThirdPartyConfig' => true, - // Wrap the raw VLESS URI in a "config" field inside last_config - 'last_config' => json_encode(['config' => $rawConfig], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + // Pass raw VLESS URI directly, without JSON wrapper + 'last_config' => $rawConfig, 'port' => (string) $port, 'transport_proto' => 'tcp' ], diff --git a/inc/VpnClient.php b/inc/VpnClient.php index be9c515..eff2eb2 100644 --- a/inc/VpnClient.php +++ b/inc/VpnClient.php @@ -264,8 +264,7 @@ class VpnClient if (is_array($clients) && !empty($clients)) { $cid = $clients[0]['id'] ?? null; if (is_string($cid) && $cid !== '' && empty($vars['client_id'])) { - // $vars['client_id'] = $cid; - // DO NOT reuse existing ID. We want to create a NEW client. + $vars['client_id'] = $cid; } } $stream = $inbounds[0]['streamSettings'] ?? []; @@ -389,10 +388,7 @@ class VpnClient } // Ensure client_id (UUID) for X-Ray - if (stripos($slug, 'xray') !== false || stripos($slug, 'vless') !== false) { - // Force clear any pre-existing ID from extras - unset($vars['client_id']); - + if (empty($vars['client_id']) && (stripos($slug, 'xray') !== false || stripos($slug, 'vless') !== false)) { $data = random_bytes(16); $data[6] = chr(ord($data[6]) & 0x0f | 0x40); $data[8] = chr(ord($data[8]) & 0x3f | 0x80); @@ -404,16 +400,12 @@ class VpnClient // We pass generic options. InstallProtocolManager will handle specific logic for 'add_client' phase. // For xray-vless it uses builtin fallback in runScript. try { - error_log("DEBUG: Attempting to add client via IPM. Slug: " . ($protoRow['slug'] ?? 'N/A') . ", ClientUUID: " . ($vars['client_id'] ?? 'N/A')); require_once __DIR__ . '/InstallProtocolManager.php'; InstallProtocolManager::addClient($server, $protoRow, $vars); - error_log("DEBUG: IPM::addClient returned successfully"); } catch (Exception $e) { - error_log("DEBUG: Failed to add client to server: " . $e->getMessage()); + error_log("Failed to add client to server: " . $e->getMessage()); throw $e; } - } else { - error_log("DEBUG: protoRow is empty, cannot call IPM::addClient"); } $config = $protoRow ? ProtocolService::generateProtocolOutput($protoRow, $vars) : ''; @@ -967,11 +959,32 @@ class VpnClient try { // Check for X-Ray VLESS - // Check for X-Ray VLESS, VMess, Shadowsocks (Standard URI schemes) - if (strpos($config, 'vless://') === 0 || strpos($config, 'vmess://') === 0 || strpos($config, 'ss://') === 0) { - // Generate a standard QR code containing just the URI string. - // This is compatible with Amnezia VPB, v2rayNG, and other standard clients. - return QrUtil::pngBase64($config); + if (strpos($config, 'vless://') === 0) { + // Parse VLESS URI + $parsed = parse_url($config); + // Allow missing user (UUID) and port for partial configs + if ($parsed && isset($parsed['host'])) { + $host = $parsed['host']; + $port = isset($parsed['port']) ? (int) $parsed['port'] : 443; + $clientId = $parsed['user'] ?? ''; + $fragment = $parsed['fragment'] ?? ''; + + parse_str($parsed['query'] ?? '', $query); + + $reality = null; + if (($query['security'] ?? '') === 'reality') { + $reality = [ + 'publicKey' => $query['pbk'] ?? '', + 'serverName' => $query['sni'] ?? '', + 'shortId' => $query['sid'] ?? '', + 'fingerprint' => $query['fp'] ?? 'chrome' + ]; + } + + // Use QrUtil to encode correct X-Ray payload + $payloadXray = QrUtil::encodeXrayPayload($host, $port, $clientId, $fragment, $reality, $config); + return QrUtil::pngBase64($payloadXray); + } } // Fallback for WireGuard / default From 0139c3155744f9e5ac27f520ca06c3ea70ceff39 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 24 Jan 2026 14:56:13 +0300 Subject: [PATCH 20/72] fix(qr): Generate full X-Ray Client config JSON to match Native Amnezia format --- inc/QrUtil.php | 72 ++++++++++++++++++++++++++++++++--------------- inc/VpnClient.php | 5 ++-- 2 files changed, 53 insertions(+), 24 deletions(-) diff --git a/inc/QrUtil.php b/inc/QrUtil.php index c1d86ab..2eef62e 100644 --- a/inc/QrUtil.php +++ b/inc/QrUtil.php @@ -409,38 +409,66 @@ class QrUtil return json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); } - public static function encodeXrayPayload(string $host, int $port, string $clientId, string $description = '', ?array $reality = null, string $rawConfig = ''): string + public static function encodeXrayPayload(string $host, int $port, string $clientId, string $description = '', ?array $reality = null, string $rawConfig = '', string $flow = ''): string { $desc = $description !== '' ? $description : self::resolveServerDescription($host); - // Instead of generating a JSON config, we wrap the raw VLESS URI in a "config" field. - // This matches how WireGuard configs are handled in master branch and is likely what Amnezia expects. - // If rawConfig is not provided, we reconstruct a basic VLESS URI (fallback) - if (empty($rawConfig)) { - // Basic reconstruction if needed, but we should pass valid rawConfig from VpnClient - $security = ($reality && isset($reality['publicKey']) && $reality['publicKey'] !== '') ? 'reality' : 'none'; - $type = 'tcp'; - $flow = ($security === 'reality') ? 'xtls-rprx-vision' : ''; + // Construct full Client XRay config (Amnezia native format expects this structure) + $outbound = [ + 'protocol' => 'vless', + 'settings' => [ + 'vnext' => [ + [ + 'address' => $host, + 'port' => $port, + 'users' => [ + [ + 'id' => $clientId, + 'encryption' => 'none' + ] + ] + ] + ] + ], + 'streamSettings' => [ + 'network' => 'tcp', + 'security' => ($reality ? 'reality' : 'none') + ] + ]; - $query = http_build_query([ - 'security' => $security, - 'type' => $type, - 'flow' => $flow, - 'sni' => $reality['serverName'] ?? '', - 'pbk' => $reality['publicKey'] ?? '', - 'fp' => $reality['fingerprint'] ?? 'chrome', - 'sid' => $reality['shortId'] ?? '' - ]); - $rawConfig = "vless://$clientId@$host:$port?$query"; + if ($flow !== '') { + $outbound['settings']['vnext'][0]['users'][0]['flow'] = $flow; } + if ($reality) { + $outbound['streamSettings']['realitySettings'] = [ + 'fingerprint' => $reality['fingerprint'] ?? 'chrome', + 'serverName' => $reality['serverName'] ?? '', + 'publicKey' => $reality['publicKey'] ?? '', + 'shortId' => $reality['shortId'] ?? '', + 'spiderX' => '' + ]; + } + + $fullConfig = [ + 'log' => ['loglevel' => 'warning'], + 'inbounds' => [ + [ + 'listen' => '127.0.0.1', + 'port' => 10808, + 'protocol' => 'socks', + 'settings' => ['udp' => true] + ] + ], + 'outbounds' => [$outbound] + ]; + $envelope = [ 'containers' => [ [ 'xray' => [ - 'isThirdPartyConfig' => true, - // Pass raw VLESS URI directly, without JSON wrapper - 'last_config' => $rawConfig, + // No isThirdPartyConfig flag - treats as native container + 'last_config' => json_encode($fullConfig, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), 'port' => (string) $port, 'transport_proto' => 'tcp' ], diff --git a/inc/VpnClient.php b/inc/VpnClient.php index eff2eb2..5c60277 100644 --- a/inc/VpnClient.php +++ b/inc/VpnClient.php @@ -970,6 +970,7 @@ class VpnClient $fragment = $parsed['fragment'] ?? ''; parse_str($parsed['query'] ?? '', $query); + $flow = $query['flow'] ?? ''; $reality = null; if (($query['security'] ?? '') === 'reality') { @@ -981,8 +982,8 @@ class VpnClient ]; } - // Use QrUtil to encode correct X-Ray payload - $payloadXray = QrUtil::encodeXrayPayload($host, $port, $clientId, $fragment, $reality, $config); + // Use QrUtil to encode correct X-Ray payload (Native Amnezia Client Config) + $payloadXray = QrUtil::encodeXrayPayload($host, $port, $clientId, $fragment, $reality, $config, $flow); return QrUtil::pngBase64($payloadXray); } } From 071c37cb39ac8ebe7cc5e572cc67adfa7a8c6f25 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 24 Jan 2026 15:35:41 +0300 Subject: [PATCH 21/72] fix(db): Add missing migration 047 to create protocols table --- migrations/047_create_protocols_table.sql | 44 +++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 migrations/047_create_protocols_table.sql diff --git a/migrations/047_create_protocols_table.sql b/migrations/047_create_protocols_table.sql new file mode 100644 index 0000000..20ce14a --- /dev/null +++ b/migrations/047_create_protocols_table.sql @@ -0,0 +1,44 @@ +CREATE TABLE IF NOT EXISTS protocols ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + slug VARCHAR(50) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + description TEXT, + definition JSON, + show_text_content TINYINT(1) DEFAULT 0, + is_active TINYINT(1) DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_slug (slug), + INDEX idx_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Insert default protocols (X-Ray, AWG) +-- We populate initial data so the panel is usable immediately +INSERT IGNORE INTO protocols (slug, name, description, definition, show_text_content, is_active) VALUES +('amnezia-wg', 'AmneziaWG', 'Amnezia WireGuard implementation', '{}', 0, 1), +('amnezia-xray', 'Amnezia XRay', 'XRay (VLESS/Reality)', '{"scripts":{}}', 0, 1), +('wireguard', 'WireGuard', 'Standard WireGuard', '{}', 0, 1), +('openvpn', 'OpenVPN', 'Standard OpenVPN', '{}', 0, 1), +('shadowsocks', 'Shadowsocks', 'Shadowsocks proxy', '{}', 0, 1), +('cloak', 'Cloak', 'Cloak obfuscation', '{}', 0, 1); + +-- Add protocol_id to vpn_clients if it does not exist +SET @exist := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='vpn_clients' AND COLUMN_NAME='protocol_id'); +SET @sql := IF(@exist=0, 'ALTER TABLE vpn_clients ADD COLUMN protocol_id INT UNSIGNED NULL AFTER server_id, ADD INDEX idx_protocol_id (protocol_id), ADD CONSTRAINT fk_clients_protocol FOREIGN KEY (protocol_id) REFERENCES protocols(id) ON DELETE SET NULL', 'SELECT "Column protocol_id exists"'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Also check server_protocols table existence (referenced in InstallProtocolManager) +CREATE TABLE IF NOT EXISTS server_protocols ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + server_id INT UNSIGNED NOT NULL, + protocol_id INT UNSIGNED NOT NULL, + config_data JSON, + container_id VARCHAR(255) NULL, + applied_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY unique_server_proto (server_id, protocol_id), + FOREIGN KEY (server_id) REFERENCES vpn_servers(id) ON DELETE CASCADE, + FOREIGN KEY (protocol_id) REFERENCES protocols(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; From e3f0e954acafee615de81f8ec0401269bc1c6336 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 24 Jan 2026 19:53:23 +0300 Subject: [PATCH 22/72] Fix: Client deletion UI, Enable XRay stats, fix dns_servers schema --- inc/InstallProtocolManager.php | 2 +- inc/VpnClient.php | 71 +++++++++++++++++++++++++++- migrations/048_enable_xray_stats.sql | 5 ++ migrations/049_add_dns_servers.sql | 20 ++++++++ templates/servers/view.twig | 10 +++- 5 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 migrations/048_enable_xray_stats.sql create mode 100644 migrations/049_add_dns_servers.sql diff --git a/inc/InstallProtocolManager.php b/inc/InstallProtocolManager.php index be4fb7b..e7131ec 100644 --- a/inc/InstallProtocolManager.php +++ b/inc/InstallProtocolManager.php @@ -1200,7 +1200,7 @@ class InstallProtocolManager } // Add client - $newClient = ['id' => $clientId]; + $newClient = ['id' => $clientId, 'email' => $clientId]; // Detect flow from other clients or default $flow = 'xtls-rprx-vision'; // Default for Reality diff --git a/inc/VpnClient.php b/inc/VpnClient.php index 5c60277..fafdd43 100644 --- a/inc/VpnClient.php +++ b/inc/VpnClient.php @@ -1439,6 +1439,49 @@ class VpnClient return $this->data['qr_code'] ?? ''; } + /** + * Get XRay client stats + */ + private static function getXrayStats(array $serverData, string $clientId): array + { + $stats = [ + 'bytes_sent' => 0, + 'bytes_received' => 0, + 'last_handshake' => 0 // XRay stats API does not provide handshake time + ]; + + $containerName = $serverData['container_name'] ?? 'amnezia-xray'; + + // Command to query stats + // We query by email, which should be equal to client ID (UUID) + $cmd = sprintf( + "docker exec -i %s xray api statsquery --server=127.0.0.1:10085 --pattern 'user>>>%s>>>traffic>>>' 2>/dev/null", + escapeshellarg($containerName), + escapeshellarg($clientId) + ); + + $output = self::executeServerCommand($serverData, $cmd, true); + + if (empty($output)) { + return $stats; + } + + // Output format example: + // user>>>uuid>>>traffic>>>uplink: 1024 + // user>>>uuid>>>traffic>>>downlink: 2048 + + $lines = explode("\n", trim($output)); + foreach ($lines as $line) { + if (preg_match('/user>>>.+>>>traffic>>>uplink:\s*(\d+)/', $line, $m)) { + $stats['bytes_sent'] = (int) $m[1]; + } elseif (preg_match('/user>>>.+>>>traffic>>>downlink:\s*(\d+)/', $line, $m)) { + $stats['bytes_received'] = (int) $m[1]; + } + } + + return $stats; + } + /** * Sync traffic statistics from server */ @@ -1456,7 +1499,33 @@ class VpnClient } try { - $stats = self::getClientStatsFromServer($serverData, $this->data['public_key']); + // XRay stats logic + $stats = []; + + // Heuristic: if container name contains 'xray' or protocol slug suggests xray + $containerName = $serverData['container_name'] ?? ''; + // Or better: try to detect protocol from config if container name is vague (but usually amnezia-xray) + + if (strpos($containerName, 'xray') !== false) { + $uuid = null; + // Try to find UUID in config + // 1. Check for JSON format (server.json style or subsets) + if (preg_match('/"id":\s*"([0-9a-fA-F-]{36})"/', $this->data['config'] ?? '', $m)) { + $uuid = $m[1]; + } + // 2. Check for VLESS URI + elseif (preg_match('/vless:\/\/([0-9a-fA-F-]{36})@/', $this->data['config'] ?? '', $m)) { + $uuid = $m[1]; + } + + if ($uuid) { + $stats = self::getXrayStats($serverData, $uuid); + } + } + + if (empty($stats)) { + $stats = self::getClientStatsFromServer($serverData, $this->data['public_key']); + } $pdo = DB::conn(); $stmt = $pdo->prepare(' diff --git a/migrations/048_enable_xray_stats.sql b/migrations/048_enable_xray_stats.sql new file mode 100644 index 0000000..3d9cb7b --- /dev/null +++ b/migrations/048_enable_xray_stats.sql @@ -0,0 +1,5 @@ +-- Enable Stats and API for XRay VLESS protocol +-- This allows collecting traffic usage per user + +UPDATE protocols SET install_script = '#!/bin/bash\n\nset -euo pipefail\nset -x\n\nCONTAINER_NAME="${CONTAINER_NAME:-amnezia-xray}"\nPORT_RANGE_START=${PORT_RANGE_START:-30000}\nPORT_RANGE_END=${PORT_RANGE_END:-65000}\nXRAY_PORT=$((RANDOM % (PORT_RANGE_END - PORT_RANGE_START + 1) + PORT_RANGE_START))\n\nPRIVATE_KEY=$(docker run --rm teddysun/xray xray x25519 | grep "Private key:" | awk ''{print $3}'')\nPUBLIC_KEY=$(docker run --rm teddysun/xray xray x25519 -i "$PRIVATE_KEY" | grep "Public key:" | awk ''{print $3}'')\nSHORT_ID=$(openssl rand -hex 8)\nCLIENT_ID=$(cat /proc/sys/kernel/random/uuid)\n\nSERVER_NAME="www.googletagmanager.com"\nFINGERPRINT="chrome"\nSPIDER_X="/"\n\ndocker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true\nmkdir -p /opt/amnezia/xray\n\ncat > /opt/amnezia/xray/server.json << EOF\n{\n "log": { "loglevel": "warning" },\n "stats": {},\n "api": {\n "tag": "api",\n "services": [\n "StatsService"\n ]\n },\n "policy": {\n "levels": {\n "0": {\n "statsUserUplink": true,\n "statsUserDownlink": true\n }\n },\n "system": {\n "statsInboundUplink": true,\n "statsInboundDownlink": true\n }\n },\n "inbounds": [\n {\n "listen": "0.0.0.0",\n "port": ${XRAY_PORT},\n "protocol": "vless",\n "settings": {\n "clients": [ { "id": "${CLIENT_ID}", "email": "${CLIENT_ID}" } ],\n "decryption": "none",\n "fallbacks": [ { "dest": 80 } ]\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": {\n "show": false,\n "dest": "${SERVER_NAME}:443",\n "xver": 0,\n "serverNames": [ "${SERVER_NAME}" ],\n "privateKey": "${PRIVATE_KEY}",\n "shortIds": [ "${SHORT_ID}" ],\n "fingerprint": "${FINGERPRINT}",\n "spiderX": "${SPIDER_X}"\n }\n }\n },\n {\n "listen": "127.0.0.1",\n "port": 10085,\n "protocol": "dokodemo-door",\n "tag": "api",\n "settings": {\n "address": "127.0.0.1"\n }\n }\n ],\n "outbounds": [ \n { "protocol": "freedom", "tag": "direct" }\n ],\n "routing": {\n "rules": [\n {\n "inboundTag": [\n "api"\n ],\n "outboundTag": "api",\n "type": "field"\n }\n ]\n }\n}\nEOF\n\n# start container\ndocker run -d \\\n --name "$CONTAINER_NAME" \\\n --restart always \\\n --network host \\\n -v /opt/amnezia/xray:/opt/amnezia/xray \\\n teddysun/xray xray run -c /opt/amnezia/xray/server.json\n\nsleep 2\n\n# panel output\necho "Port: ${XRAY_PORT}"\necho "ClientID: ${CLIENT_ID}"\necho "PublicKey: ${PUBLIC_KEY}"\necho "PrivateKey: ${PRIVATE_KEY}"\necho "ShortID: ${SHORT_ID}"\necho "ServerName: ${SERVER_NAME}"' +WHERE slug = 'xray-vless'; diff --git a/migrations/049_add_dns_servers.sql b/migrations/049_add_dns_servers.sql new file mode 100644 index 0000000..1f102bb --- /dev/null +++ b/migrations/049_add_dns_servers.sql @@ -0,0 +1,20 @@ +-- Add dns_servers column to vpn_servers table if missing +-- Needed for correct configuration regeneration + +SET @dbname = DATABASE(); +SET @tablename = "vpn_servers"; +SET @columnname = "dns_servers"; +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE + (table_name = @tablename) + AND (table_schema = @dbname) + AND (column_name = @columnname) + ) > 0, + "SELECT 1", + "ALTER TABLE vpn_servers ADD COLUMN dns_servers VARCHAR(255) DEFAULT '1.1.1.1, 1.0.0.1'" +)); +PREPARE alterIfNotExists FROM @preparedStatement; +EXECUTE alterIfNotExists; +DEALLOCATE PREPARE alterIfNotExists; diff --git a/templates/servers/view.twig b/templates/servers/view.twig index 0521138..8150940 100644 --- a/templates/servers/view.twig +++ b/templates/servers/view.twig @@ -348,7 +348,7 @@ {{ t('servers.view') }} {% if client.status == 'active' %}
- +
{% else %}
@@ -356,7 +356,7 @@
{% endif %}
- +
@@ -370,6 +370,12 @@
{% endblock %} From e200146dc095bc9390efb14746af44347fdbdd4e Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Fri, 30 Jan 2026 21:45:05 +0300 Subject: [PATCH 46/72] feat: Enforce single IP per user for Xray servers and update protocol checks --- bin/collect_metrics.php | 6 ++++++ inc/ServerMonitoring.php | 20 ++++++++++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/bin/collect_metrics.php b/bin/collect_metrics.php index 49f4acb..34909d8 100644 --- a/bin/collect_metrics.php +++ b/bin/collect_metrics.php @@ -55,6 +55,12 @@ while (true) { $monitoring = new ServerMonitoring($server['id']); + // Enforce single IP per user for Xray servers + $containerName = $server['container_name'] ?? ''; + if (strpos($containerName, 'xray') !== false) { + $monitoring->enforceXraySingleIpPerUser(); + } + // Collect server metrics $serverMetrics = $monitoring->collectMetrics(); echo " Server: CPU={$serverMetrics['cpu_percent']}% RAM={$serverMetrics['ram_used_mb']}/{$serverMetrics['ram_total_mb']}MB "; diff --git a/inc/ServerMonitoring.php b/inc/ServerMonitoring.php index 1e7e5a0..52425c1 100644 --- a/inc/ServerMonitoring.php +++ b/inc/ServerMonitoring.php @@ -275,9 +275,9 @@ class ServerMonitoring $bytesReceived = 0; $bytesSent = 0; - $slug = $this->serverData['slug']; // Assuming 'slug' is available in serverData + $protocol = $this->serverData['install_protocol'] ?? ''; - if ($slug === 'xray' || $slug === 'vless') { + if (strpos($protocol, 'xray') !== false || strpos($protocol, 'vless') !== false) { // Retrieve DELTA from cache if ($this->xrayStatsFetched) { // Try to find by UUID first (if we tracked it) or Email/Name @@ -556,9 +556,9 @@ class ServerMonitoring if (stripos($containerName, 'xray') !== false) { return $containerName; } - // Also check slug - $slug = $this->serverData['slug'] ?? ''; - if (stripos($slug, 'xray') !== false || stripos($slug, 'vless') !== false) { + // Also check protocol + $protocol = $this->serverData['install_protocol'] ?? ''; + if (stripos($protocol, 'xray') !== false || stripos($protocol, 'vless') !== false) { return $containerName ?: 'amnezia-xray'; } return null; @@ -598,7 +598,15 @@ class ServerMonitoring $ipsToBlock = []; foreach ($data['users'] as $user) { - $email = $user['email'] ?? null; + // Format: "user>>>email>>>online" + if (!is_string($user)) { + continue; + } + $parts = explode('>>>', $user); + if (count($parts) < 2) { + continue; + } + $email = $parts[1]; if (!$email) { continue; } From 853f57bc4085f8998391afe4ab61b23dddc864f4 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Fri, 30 Jan 2026 22:14:09 +0300 Subject: [PATCH 47/72] feat: Enforce single IP per peer for AWG/WireGuard connections --- bin/collect_metrics.php | 5 ++ inc/ServerMonitoring.php | 123 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/bin/collect_metrics.php b/bin/collect_metrics.php index 34909d8..36d827c 100644 --- a/bin/collect_metrics.php +++ b/bin/collect_metrics.php @@ -61,6 +61,11 @@ while (true) { $monitoring->enforceXraySingleIpPerUser(); } + // Enforce single IP per peer for AWG servers + if (strpos($containerName, 'awg') !== false || strpos($containerName, 'wireguard') !== false) { + $monitoring->enforceAwgSingleIpPerPeer(); + } + // Collect server metrics $serverMetrics = $monitoring->collectMetrics(); echo " Server: CPU={$serverMetrics['cpu_percent']}% RAM={$serverMetrics['ram_used_mb']}/{$serverMetrics['ram_total_mb']}MB "; diff --git a/inc/ServerMonitoring.php b/inc/ServerMonitoring.php index 52425c1..640482a 100644 --- a/inc/ServerMonitoring.php +++ b/inc/ServerMonitoring.php @@ -652,6 +652,129 @@ class ServerMonitoring } } + /** + * Enforce single IP per peer for AWG/WireGuard connections. + * If a peer's endpoint changes while session is active, block the new IP. + */ + public function enforceAwgSingleIpPerPeer(): void + { + $containerName = $this->serverData['container_name'] ?? ''; + if (strpos($containerName, 'awg') === false && strpos($containerName, 'wireguard') === false) { + return; // Not an AWG server + } + + // Get current peer states + $cmd = "docker exec $containerName wg show wg0 dump"; + $result = $this->execSSH($cmd); + if (!$result) { + return; + } + + $lines = explode("\n", trim($result)); + if (count($lines) < 2) { + return; // No peers + } + + // Load locked endpoints from file + $lockFile = '/tmp/awg_locked_endpoints_' . $this->serverData['id'] . '.json'; + $lockedEndpoints = []; + $lockFileCmd = "cat $lockFile 2>/dev/null || echo '{}'"; + $lockData = $this->execSSH($lockFileCmd); + if ($lockData) { + $lockedEndpoints = json_decode($lockData, true) ?: []; + } + + $currentPeers = []; + $ipsToBlock = []; + $now = time(); + + // Skip first line (interface info) + for ($i = 1; $i < count($lines); $i++) { + $parts = preg_split('/\s+/', trim($lines[$i])); + if (count($parts) < 8) { + continue; + } + + // Format: interface pubkey psk endpoint allowed-ips latest-handshake rx tx keepalive + $pubkey = $parts[0]; + $endpoint = $parts[2]; // IP:Port or (none) + $latestHandshake = (int)$parts[4]; + + if ($endpoint === '(none)' || $latestHandshake === 0) { + // Peer not connected - clear lock + unset($lockedEndpoints[$pubkey]); + continue; + } + + // Extract just IP from endpoint (IP:Port) + $endpointIp = explode(':', $endpoint)[0]; + $isActive = ($now - $latestHandshake) < 180; // Active if handshake within 3 minutes + + $currentPeers[$pubkey] = $endpointIp; + + if ($isActive) { + if (!isset($lockedEndpoints[$pubkey])) { + // First connection - lock this IP + $lockedEndpoints[$pubkey] = $endpointIp; + } elseif ($lockedEndpoints[$pubkey] !== $endpointIp) { + // Endpoint changed during active session - block new IP + $ipsToBlock[] = $endpointIp; + error_log("[AWG Enforcement] Peer $pubkey changed endpoint from {$lockedEndpoints[$pubkey]} to $endpointIp - blocking"); + } + } else { + // Session expired - update locked endpoint for next connection + $lockedEndpoints[$pubkey] = $endpointIp; + } + } + + // Clean up locks for peers that no longer exist + foreach ($lockedEndpoints as $pubkey => $ip) { + if (!isset($currentPeers[$pubkey])) { + unset($lockedEndpoints[$pubkey]); + } + } + + // Save locked endpoints + $lockJson = json_encode($lockedEndpoints); + $saveLockCmd = "echo " . escapeshellarg($lockJson) . " > $lockFile"; + $this->execSSH($saveLockCmd); + + // Apply iptables rules for blocked IPs + if (!empty($ipsToBlock)) { + foreach ($ipsToBlock as $ip) { + // Block UDP traffic from this IP to WireGuard port + $wgPort = $this->serverData['vpn_port'] ?? 51820; + $blockCmd = "docker exec $containerName iptables -C INPUT -s $ip -p udp --dport $wgPort -j DROP 2>/dev/null || docker exec $containerName iptables -I INPUT -s $ip -p udp --dport $wgPort -j DROP"; + $this->execSSH($blockCmd); + } + } + + // Remove blocks for IPs that are now the locked endpoint (old device disconnected) + $wgPort = $this->serverData['vpn_port'] ?? 51820; + $listRulesCmd = "docker exec $containerName iptables -L INPUT -n --line-numbers | grep 'DROP.*udp dpt:$wgPort' | awk '{print \$1, \$4}'"; + $rulesResult = $this->execSSH($listRulesCmd); + if ($rulesResult) { + $rulesToRemove = []; + foreach (explode("\n", trim($rulesResult)) as $line) { + $parts = preg_split('/\s+/', trim($line)); + if (count($parts) >= 2) { + $ruleNum = $parts[0]; + $blockedIp = $parts[1]; + // If this IP is now the locked endpoint for any peer, remove the block + if (in_array($blockedIp, $lockedEndpoints)) { + $rulesToRemove[] = $ruleNum; + } + } + } + // Remove rules in reverse order (highest number first) + rsort($rulesToRemove); + foreach ($rulesToRemove as $ruleNum) { + $rmCmd = "docker exec $containerName iptables -D INPUT $ruleNum 2>/dev/null || true"; + $this->execSSH($rmCmd); + } + } + } + /** * Count total online clients across all Xray servers * Returns array with 'total' count and 'users' list From f0a24d2e228ea6448ba2e4d3c827c99f4c355b6d Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Thu, 5 Feb 2026 19:34:02 +0300 Subject: [PATCH 48/72] feat: Enhance online client tracking by including recent handshake counts for WireGuard/AWG --- .gitignore | 1 + inc/ServerMonitoring.php | 237 ++++++++++++++++++++++-------------- inc/VpnClient.php | 55 +++++++-- public/index.php | 15 ++- templates/servers/view.twig | 5 +- 5 files changed, 208 insertions(+), 105 deletions(-) diff --git a/.gitignore b/.gitignore index c23d763..93f08a3 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,4 @@ test_protocols.php scripts/regen_qr.php scripts/test_xray_install.sh scripts/test_online.php +API_AWG_DOCS.md diff --git a/inc/ServerMonitoring.php b/inc/ServerMonitoring.php index 640482a..67fc367 100644 --- a/inc/ServerMonitoring.php +++ b/inc/ServerMonitoring.php @@ -27,19 +27,11 @@ class ServerMonitoring return true; } - $containerName = $this->serverData['container_name']; - if (strpos($containerName, 'xray') === false) { - $this->xrayStatsFetched = true; - return true; - } - - // Use --reset=true to get delta since last check and prevent counter reset on restart - $xrayContainer = $this->getXrayContainerName(); - if (!$xrayContainer) { - $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"; + // Always try to fetch from amnezia-xray container + // Even if server's container_name is different, there may be xray clients + $xrayContainer = $this->getXrayContainerName() ?? 'amnezia-xray'; + + $cmd = "docker exec $xrayContainer xray api statsquery --pattern 'user>>>' --reset=true --server=127.0.0.1:10085 2>/dev/null"; $json = $this->execSSH($cmd); if (!$json || trim($json) === '') { @@ -274,24 +266,40 @@ class ServerMonitoring $containerName = $this->serverData['container_name']; $bytesReceived = 0; $bytesSent = 0; + $speedUp = 0; + $speedDown = 0; - $protocol = $this->serverData['install_protocol'] ?? ''; + // Determine if this client is XRay based on protocol_id + $isXrayClient = false; + 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; + } + } + + // Fallback: check config for vless URI + if (!$isXrayClient && !empty($client['config']) && strpos($client['config'], 'vless://') !== false) { + $isXrayClient = true; + } - if (strpos($protocol, 'xray') !== false || strpos($protocol, 'vless') !== false) { + if ($isXrayClient) { // Retrieve DELTA from cache if ($this->xrayStatsFetched) { - // Try to find by UUID first (if we tracked it) or Email/Name - // Our cache is keyed by "email" from the stats query "user>>>email>>>..." - // In VpnClient.php, the X-ray config uses client 'id' (uuid) as 'id' and 'email' as 'email'. - // Usually Amnezia sets email = uuid or name. - // Let's try keys: client['id'], client['name'], client['email'] (if exists) - - // In our previous fetchXrayStats, we keyed by $parts[1]. - - $key = $client['id']; // UUID + // Try name first (matches email in xray config), then UUID from config + $key = $client['name']; if (!isset($this->xrayStatsCache[$key])) { - // Try name - $key = $client['name']; + // Try UUID from config + if (!empty($client['config']) && preg_match('/vless:\/\/([0-9a-fA-F-]{36})@/i', $client['config'], $m)) { + $key = $m[1]; + } + } + + if (!isset($this->xrayStatsCache[$key])) { + // Try client['id'] as last resort + $key = $client['id']; } if (isset($this->xrayStatsCache[$key])) { @@ -307,32 +315,42 @@ class ServerMonitoring $bytesReceived = ($currentDbStats['bytes_received'] ?? 0) + (int) $xStats['down']; // Calculate speed based on DELTA (since Reset=true, value IS the delta since last check) - // If we check every 60s, speed = delta / 60. - // But exact interval varies. - // For now, let's trust the delta. - - // Simple speed aproximation: Delta / (Now - LastCheck) - // But we don't have exact LastCheck time per client easily here. - // However, sparklines use a separate API. - // The 'speed_up'/'speed_down' columns in DB are usually "Current Speed". - // If we just gathered a delta over X seconds... - // Let's approximate: X-ray stats delta. - // We can just store the 'current speed' as calculated by (Delta Bytes / Interval). - // But we don't know the exact interval since the LAST fetch was run by the cron. - // Assuming cron runs every minute? - // If we assume 1 minute (60s): + // Assuming cron runs every minute (60s): $speedUp = round($xStats['up'] / 60); $speedDown = round($xStats['down'] / 60); + } else { + // No stats in cache, use current 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 = $currentDbStats['bytes_sent'] ?? 0; + $bytesReceived = $currentDbStats['bytes_received'] ?? 0; } } } else { - // WireGuard Logic + // WireGuard Logic - get bytes and handshake timestamp $publicKey = $client['public_key']; - $cmd = "docker exec {$containerName} wg show all dump | grep '{$publicKey}' | awk '{print \$6, \$7}'"; + // 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) + // tx-bytes = bytes transmitted by server = client's download (bytes_received) + $cmd = "docker exec {$containerName} wg show all dump | grep '{$publicKey}' | awk '{print \$6, \$7, \$8}'"; $result = $this->execSSH($cmd); if ($result) { - list($bytesReceived, $bytesSent) = explode(' ', trim($result)); + $parts = explode(' ', trim($result)); + if (count($parts) >= 3) { + $handshakeTs = (int)$parts[0]; + $bytesSent = (int)$parts[1]; // server's rx = client's upload + $bytesReceived = (int)$parts[2]; // server's tx = client's download + + // Update last_handshake if there was a recent handshake + if ($handshakeTs > 0) { + $handshakeDate = date('Y-m-d H:i:s', $handshakeTs); + $stmtHs = $db->prepare("UPDATE vpn_clients SET last_handshake = ? WHERE id = ?"); + $stmtHs->execute([$handshakeDate, $client['id']]); + } + } } } @@ -425,10 +443,10 @@ class ServerMonitoring $stats['speed_down_kbps'], ]); - // Update vpn_clients table with latest stats + // Update vpn_clients table with latest stats (don't touch last_handshake - it's set separately for WG/AWG) $stmt = $db->prepare(" UPDATE vpn_clients - SET bytes_sent = ?, bytes_received = ?, speed_up = ?, speed_down = ?, current_speed = ?, last_handshake = NOW(), last_sync_at = NOW() + SET bytes_sent = ?, bytes_received = ?, speed_up = ?, speed_down = ?, current_speed = ?, last_sync_at = NOW() WHERE id = ? "); @@ -785,11 +803,23 @@ class ServerMonitoring // Get all active servers $servers = VpnServer::listAll(); + $db = DB::conn(); foreach ($servers as $serverData) { - // Check if this is an Xray server + // Check if this server has any XRay clients + $stmt = $db->prepare(" + SELECT COUNT(*) as cnt FROM vpn_clients vc + JOIN protocols p ON vc.protocol_id = p.id + WHERE vc.server_id = ? AND p.slug LIKE '%xray%' + "); + $stmt->execute([$serverData['id']]); + $hasXrayClients = (int)$stmt->fetchColumn() > 0; + + // Also check container_name as fallback $containerName = $serverData['container_name'] ?? ''; - if (strpos($containerName, 'xray') === false) { + $isXrayServer = strpos($containerName, 'xray') !== false; + + if (!$hasXrayClients && !$isXrayServer) { continue; } @@ -799,7 +829,7 @@ class ServerMonitoring $username = $serverData['username'] ?? 'root'; $password = $serverData['password'] ?? ''; - $xrayContainer = $containerName ?: 'amnezia-xray'; + $xrayContainer = $isXrayServer ? $containerName : 'amnezia-xray'; $cmd = "docker exec $xrayContainer xray api statsgetallonlineusers --server=127.0.0.1:10085"; $sshOptions = '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5'; @@ -861,58 +891,79 @@ class ServerMonitoring public static function getOnlineClientsForServer(array $serverData): array { $result = []; + $db = DB::conn(); + + // 1. Get XRay online clients from Xray API + $stmt = $db->prepare(" + SELECT COUNT(*) as cnt FROM vpn_clients vc + JOIN protocols p ON vc.protocol_id = p.id + WHERE vc.server_id = ? AND p.slug LIKE '%xray%' + "); + $stmt->execute([$serverData['id']]); + $hasXrayClients = (int)$stmt->fetchColumn() > 0; - // Check if this is an Xray server $containerName = $serverData['container_name'] ?? ''; - if (strpos($containerName, 'xray') === false) { - return $result; - } + $isXrayServer = strpos($containerName, 'xray') !== false; - // Build SSH command - $host = $serverData['host']; - $port = (int)($serverData['port'] ?? 22); - $username = $serverData['username'] ?? 'root'; - $password = $serverData['password'] ?? ''; - - $xrayContainer = $containerName ?: 'amnezia-xray'; - $cmd = "docker exec $xrayContainer xray api statsgetallonlineusers --server=127.0.0.1:10085"; - - $sshOptions = '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5'; - $sshCmd = sprintf( - "sshpass -p '%s' ssh -p %d %s %s@%s %s 2>/dev/null", - $password, - $port, - $sshOptions, - $username, - $host, - escapeshellarg($cmd) - ); - - $output = shell_exec($sshCmd); - if (!$output) { - return $result; - } - - $data = json_decode($output, true); - if (!isset($data['users']) || !is_array($data['users'])) { - return $result; - } - - foreach ($data['users'] as $user) { - // Parse format: "user>>>email>>>online" - if (is_string($user)) { - $parts = explode('>>>', $user); - if (count($parts) >= 2) { - $result[] = $parts[1]; - } - } else { - $email = $user['email'] ?? null; - if ($email) { - $result[] = $email; + if ($hasXrayClients || $isXrayServer) { + $host = $serverData['host']; + $port = (int)($serverData['port'] ?? 22); + $username = $serverData['username'] ?? 'root'; + $password = $serverData['password'] ?? ''; + + $xrayContainer = $isXrayServer ? $containerName : 'amnezia-xray'; + $cmd = "docker exec $xrayContainer xray api statsgetallonlineusers --server=127.0.0.1:10085"; + + $sshOptions = '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5'; + $sshCmd = sprintf( + "sshpass -p '%s' ssh -p %d %s %s@%s %s 2>/dev/null", + $password, + $port, + $sshOptions, + $username, + $host, + escapeshellarg($cmd) + ); + + $output = shell_exec($sshCmd); + if ($output) { + $data = json_decode($output, true); + if (isset($data['users']) && is_array($data['users'])) { + foreach ($data['users'] as $user) { + if (is_string($user)) { + $parts = explode('>>>', $user); + if (count($parts) >= 2) { + $result[] = $parts[1]; + } + } else { + $email = $user['email'] ?? null; + if ($email) { + $result[] = $email; + } + } + } } } } + // 2. Add WireGuard/AWG clients with recent handshake (< 5 minutes) + // Exclude XRay clients - they use Xray API for online status + $stmt = $db->prepare(" + SELECT vc.name FROM vpn_clients vc + LEFT JOIN protocols p ON vc.protocol_id = p.id + WHERE vc.server_id = ? + AND vc.status = 'active' + AND vc.last_handshake IS NOT NULL + AND vc.last_handshake >= DATE_SUB(NOW(), INTERVAL 300 SECOND) + AND (p.slug IS NULL OR p.slug NOT LIKE '%xray%') + "); + $stmt->execute([$serverData['id']]); + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + if (!in_array($row['name'], $result)) { + $result[] = $row['name']; + } + } + return $result; } } diff --git a/inc/VpnClient.php b/inc/VpnClient.php index 3734494..029c059 100644 --- a/inc/VpnClient.php +++ b/inc/VpnClient.php @@ -1551,21 +1551,58 @@ class VpnClient // XRay stats logic $stats = []; - // Heuristic: if container name contains 'xray' or protocol slug suggests xray - $containerName = $serverData['container_name'] ?? ''; - // Or better: try to detect protocol from config if container name is vague (but usually amnezia-xray) + // Determine protocol by client's protocol_id + $isXray = false; + $xrayContainerName = 'amnezia-xray'; // Default XRay container name + + if (!empty($this->data['protocol_id'])) { + $stmtProto = $pdo->prepare('SELECT slug FROM protocols WHERE id = ?'); + $stmtProto->execute([$this->data['protocol_id']]); + $protoData = $stmtProto->fetch(); + if ($protoData && stripos($protoData['slug'], 'xray') !== false) { + $isXray = true; + } + } + + // Fallback: check container_name or config for xray indicators + if (!$isXray) { + $containerName = $serverData['container_name'] ?? ''; + if (strpos($containerName, 'xray') !== false) { + $isXray = true; + $xrayContainerName = $containerName; + } elseif (!empty($this->data['config']) && strpos($this->data['config'], 'vless://') !== false) { + $isXray = true; + } + } - if (strpos($containerName, 'xray') !== false) { - // Extract UUID from config for XRay (vless://UUID@...) + if ($isXray) { + // XRay stats are tracked by email field in xray config + // Try client name first (typically used as email), then UUID from config as fallback $identifier = null; + $uuid = null; + + // Extract UUID from config if (!empty($this->data['config']) && preg_match('/vless:\/\/([0-9a-fA-F-]{36})@/i', $this->data['config'], $m)) { - $identifier = $m[1]; - } elseif (!empty($this->data['name'])) { + $uuid = $m[1]; + } + + // Override container_name for XRay stats + $xrayServerData = $serverData; + $xrayServerData['container_name'] = $xrayContainerName; + + // Try name first (typically matches email in xray config) + if (!empty($this->data['name'])) { $identifier = $this->data['name']; + $stats = self::getXrayStats($xrayServerData, $identifier); + } + + // If no stats found by name, try UUID + if ((empty($stats) || ($stats['bytes_sent'] == 0 && $stats['bytes_received'] == 0)) && $uuid) { + $identifier = $uuid; + $stats = self::getXrayStats($xrayServerData, $identifier); } - if ($identifier) { - $stats = self::getXrayStats($serverData, $identifier); + if ($identifier && !empty($stats)) { // Infer online status for XRay: if traffic increased, they are online. // Update last_handshake to NOW() if activity detected. if ($stats['bytes_sent'] > $prevSent || $stats['bytes_received'] > $prevReceived) { diff --git a/public/index.php b/public/index.php index cc438a5..02e193a 100644 --- a/public/index.php +++ b/public/index.php @@ -267,11 +267,24 @@ Router::get('/dashboard', function () { // Get real-time online clients count from Xray API $onlineData = ServerMonitoring::countOnlineClients(); + + // Also count clients with recent handshake (within 5 minutes) for WireGuard/AWG + $pdo = DB::conn(); + $stmt = $pdo->query(" + SELECT COUNT(*) as cnt FROM vpn_clients + WHERE last_handshake IS NOT NULL + AND last_handshake > DATE_SUB(NOW(), INTERVAL 5 MINUTE) + AND status = 'active' + "); + $recentHandshakeCount = (int)$stmt->fetchColumn(); + + // Combine both counts (XRay online + recent handshake), avoiding duplicates + $totalOnline = max($onlineData['total'], $recentHandshakeCount); View::render('dashboard.twig', [ 'servers' => $servers, 'clients' => $clients, - 'online_count' => $onlineData['total'], + 'online_count' => $totalOnline, 'online_users' => $onlineData['users'], ]); }); diff --git a/templates/servers/view.twig b/templates/servers/view.twig index 52739ef..c4165a0 100644 --- a/templates/servers/view.twig +++ b/templates/servers/view.twig @@ -276,8 +276,9 @@ {% endif %} {{ client.client_ip }} - - {% if client.name in online_logins %} + + {% set is_online_by_handshake = client.last_handshake and (("now"|date('U') - client.last_handshake|date('U')) < 300) %} + {% if client.name in online_logins or is_online_by_handshake %} Online {% elseif client.status == 'active' %} {{ t('status.active') }} From 35e2e9adfac269678280002c0211239f39795318 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Fri, 13 Feb 2026 19:01:00 +0300 Subject: [PATCH 49/72] feat: Enhance protocol detection and restoration for AWG and X-Ray installations --- inc/InstallProtocolManager.php | 465 +++++++++++++++++++++++++++++++-- 1 file changed, 449 insertions(+), 16 deletions(-) diff --git a/inc/InstallProtocolManager.php b/inc/InstallProtocolManager.php index 09c6e4c..18b7467 100644 --- a/inc/InstallProtocolManager.php +++ b/inc/InstallProtocolManager.php @@ -238,6 +238,18 @@ class InstallProtocolManager return self::detectBuiltinAwg($server, $protocol); } + $slug = $protocol['slug'] ?? ''; + + // For AWG shell-based scenarios (amnezia-wg, amnezia-wg-advanced), use builtin AWG detection + if (self::isAwgProtocol($slug, $protocol)) { + return self::detectBuiltinAwg($server, $protocol); + } + + // For X-Ray VLESS, use builtin detection + if ($slug === 'xray-vless') { + return self::detectBuiltinXray($server, $protocol); + } + return self::runScript($server, $protocol, 'detect', $options); } @@ -308,6 +320,18 @@ class InstallProtocolManager return self::restoreBuiltinAwg($server, $protocol, $detection, $options); } + $slug = $protocol['slug'] ?? ''; + + // For AWG shell-based scenarios, use builtin AWG restore + if (self::isAwgProtocol($slug, $protocol)) { + return self::restoreBuiltinAwg($server, $protocol, $detection, $options); + } + + // For X-Ray VLESS, use builtin restore + if ($slug === 'xray-vless') { + return self::restoreBuiltinXray($server, $protocol, $detection, $options); + } + $result = self::runScript($server, $protocol, 'restore', array_merge($options, [ 'detection' => $detection ])); @@ -477,7 +501,7 @@ class InstallProtocolManager '', $details['preshared_key'] ?? null, '', - 'disabled' + 'active' // Import as active since they already work on the server ]); $restored++; } @@ -867,6 +891,22 @@ class InstallProtocolManager return $definition['engine'] ?? 'builtin_awg'; } + /** + * Check if a protocol is an AWG variant (by slug or install_script content) + * Used to route shell-based AWG scenarios to builtin AWG detection/restore + */ + private static function isAwgProtocol(string $slug, array $protocol): bool + { + if (in_array($slug, ['amnezia-wg', 'amnezia-wg-advanced'], true)) { + return true; + } + $installScript = (string) ($protocol['install_script'] ?? ''); + if ($installScript !== '' && preg_match('/amneziavpn\/amnezia-wg|amnezia\/awg|amnezia-awg/i', $installScript)) { + return true; + } + return false; + } + private static function fallbackProtocols(): array { return [ @@ -940,6 +980,18 @@ class InstallProtocolManager return self::detectBuiltinAwg($server, $protocol); } + $slug = $protocol['slug'] ?? ''; + + // For AWG shell-based scenarios (amnezia-wg, amnezia-wg-advanced), use builtin AWG detection + if (self::isAwgProtocol($slug, $protocol)) { + return self::detectBuiltinAwg($server, $protocol); + } + + // For X-Ray VLESS, use builtin detection + if ($slug === 'xray-vless') { + return self::detectBuiltinXray($server, $protocol); + } + return self::runScript($server, $protocol, 'detect', $options); } @@ -956,9 +1008,7 @@ class InstallProtocolManager // For script-driven protocols, try to detect AWG scenario and fallback to builtin uninstall $slug = $protocol['slug'] ?? ''; - $installScript = (string) ($protocol['install_script'] ?? ''); - $looksLikeAwg = (bool) preg_match('/amneziavpn\/amnezia-wg|amnezia\/awg|amnezia-awg/i', $installScript); - if (in_array($slug, ['amnezia-wg-advanced', 'amnezia-wg'], true) || $looksLikeAwg) { + if (self::isAwgProtocol($slug, $protocol)) { // Prefer builtin AWG uninstall by default because script variants may have CRLF issues // or leave behind the canonical container name, causing install conflicts. if (!empty($options['use_script_uninstall'])) { @@ -1027,16 +1077,56 @@ class InstallProtocolManager $serverId = $server->getId(); try { Logger::appendInstall($serverId, 'Activate start for ' . ($protocol['slug'] ?? 'unknown') . ' engine ' . $engine); + + // ── Check for existing installation before doing anything destructive ── + $slug = $protocol['slug'] ?? ''; + $isAwg = $engine === 'builtin_awg' || self::isAwgProtocol($slug, $protocol); + $isXray = $slug === 'xray-vless'; + + if ($isAwg) { + $detection = self::detectBuiltinAwg($server, $protocol); + if (in_array($detection['status'] ?? '', ['existing', 'partial'], true)) { + Logger::appendInstall($serverId, 'Existing AWG installation detected, restoring instead of reinstalling'); + $restoreResult = self::restoreBuiltinAwg($server, $protocol, $detection, $options); + // Import existing clients into DB + self::importExistingAwgClients($server, $protocol, $detection); + $pdo = DB::conn(); + $pid = self::resolveProtocolId($protocol); + if ($pid) { + $details = $detection['details'] ?? []; + $config = [ + 'server_host' => $server->getData()['host'] ?? null, + 'server_port' => $details['vpn_port'] ?? null, + 'extras' => [ + 'vpn_port' => $details['vpn_port'] ?? null, + 'server_public_key' => $details['server_public_key'] ?? null, + 'preshared_key' => $details['preshared_key'] ?? null, + 'awg_params' => $details['awg_params'] ?? null, + ] + ]; + $stmt2 = $pdo->prepare('INSERT INTO server_protocols (server_id, protocol_id, config_data, applied_at, created_at) VALUES (?, ?, ?, NOW(), NOW()) ON DUPLICATE KEY UPDATE config_data = VALUES(config_data), applied_at = NOW()'); + $stmt2->execute([$serverId, $pid, json_encode($config)]); + } + return array_merge($restoreResult, ['mode' => 'restore_existing']); + } + } + + if ($isXray) { + $xrayDetection = self::detectBuiltinXray($server, $protocol); + if (in_array($xrayDetection['status'] ?? '', ['existing', 'partial'], true)) { + Logger::appendInstall($serverId, 'Existing X-Ray installation detected, restoring instead of reinstalling'); + $restoreResult = self::restoreBuiltinXray($server, $protocol, $xrayDetection, $options); + return array_merge($restoreResult, ['mode' => 'restore_existing']); + } + } + + // ── No existing installation found — proceed with fresh install ── + if ($engine === 'builtin_awg') { $res = $server->runAwgInstall($options); Logger::appendInstall($serverId, 'Builtin AWG install finished'); $pdo = DB::conn(); - $pid = (int) ($protocol['id'] ?? 0); - if (!$pid) { - $stmt = $pdo->prepare('SELECT id FROM protocols WHERE slug = ? LIMIT 1'); - $stmt->execute([$protocol['slug'] ?? self::DEFAULT_SLUG]); - $pid = (int) $stmt->fetchColumn(); - } + $pid = self::resolveProtocolId($protocol); if ($pid) { $config = [ 'server_host' => $server->getData()['host'] ?? null, @@ -1145,12 +1235,7 @@ class InstallProtocolManager } Logger::appendInstall($serverId, 'Scripted install parsed port ' . ($port ?? 0) . ' password ' . ($password ?? '')); $pdo = DB::conn(); - $pid = (int) ($protocol['id'] ?? 0); - if (!$pid) { - $stmt = $pdo->prepare('SELECT id FROM protocols WHERE slug = ? LIMIT 1'); - $stmt->execute([$protocol['slug'] ?? '']); - $pid = (int) $stmt->fetchColumn(); - } + $pid = self::resolveProtocolId($protocol); if ($pid) { $config = [ 'server_host' => $server->getData()['host'] ?? null, @@ -1439,4 +1524,352 @@ class InstallProtocolManager $server->executeCommand("docker exec -i $containerName wg-quick up wg0", true); } } + + /** + * Resolve protocol ID from protocol array, looking up by slug if needed + */ + private static function resolveProtocolId(array $protocol): int + { + $pid = (int) ($protocol['id'] ?? 0); + if (!$pid) { + $slug = $protocol['slug'] ?? ''; + if ($slug === '') { + return 0; + } + try { + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT id FROM protocols WHERE slug = ? LIMIT 1'); + $stmt->execute([$slug]); + $pid = (int) $stmt->fetchColumn(); + } catch (Throwable $e) { + return 0; + } + } + return $pid; + } + + /** + * Detect existing X-Ray (VLESS Reality) installation on the server + */ + private static function detectBuiltinXray(VpnServer $server, array $protocol): array + { + $metadata = $protocol['definition']['metadata'] ?? []; + $containerName = $metadata['container_name'] ?? 'amnezia-xray'; + $containerFilter = escapeshellarg('^' . $containerName . '$'); + $containerArg = escapeshellarg($containerName); + + $containerList = trim($server->executeCommand("docker ps -a --filter name={$containerFilter} --format '{{.Names}}'", true)); + if ($containerList === '') { + return [ + 'status' => 'absent', + 'message' => 'Контейнер X-Ray не найден на сервере' + ]; + } + + $containerState = trim($server->executeCommand("docker inspect --format '{{.State.Status}}' {$containerArg}", true)); + + // Read X-Ray config + $configRaw = $server->executeCommand("docker exec -i {$containerArg} cat /opt/amnezia/xray/server.json 2>/dev/null", true); + if (trim($configRaw) === '') { + $configRaw = $server->executeCommand("docker exec -i {$containerArg} cat /etc/xray/config.json 2>/dev/null", true); + } + + if (trim($configRaw) === '') { + return [ + 'status' => 'partial', + 'message' => 'Контейнер X-Ray найден, но конфигурация server.json отсутствует', + 'details' => [ + 'container_name' => $containerName, + 'container_status' => $containerState, + ] + ]; + } + + $config = json_decode(trim($configRaw), true); + if (!is_array($config)) { + return [ + 'status' => 'partial', + 'message' => 'Не удалось разобрать JSON конфигурации X-Ray', + 'details' => [ + 'container_name' => $containerName, + 'container_status' => $containerState, + ] + ]; + } + + // Extract port, clients, Reality keys + $inbounds = $config['inbounds'] ?? []; + $port = 443; + $xrayClients = []; + $realityPublicKey = null; + $realityPrivateKey = null; + $realityShortId = null; + $realityServerName = null; + + if (is_array($inbounds) && !empty($inbounds)) { + $port = (int) ($inbounds[0]['port'] ?? 443); + $settings = $inbounds[0]['settings'] ?? []; + $xrayClients = $settings['clients'] ?? []; + + $stream = $inbounds[0]['streamSettings'] ?? []; + if (is_array($stream) && ($stream['security'] ?? '') === 'reality') { + $rs = $stream['realitySettings'] ?? []; + $serverNames = $rs['serverNames'] ?? ($rs['serverName'] ?? []); + $shortIds = $rs['shortIds'] ?? ($rs['shortId'] ?? []); + $realityServerName = is_array($serverNames) ? ($serverNames[0] ?? null) : (is_string($serverNames) ? $serverNames : null); + $realityShortId = is_array($shortIds) ? ($shortIds[0] ?? null) : (is_string($shortIds) ? $shortIds : null); + $realityPrivateKey = $rs['privateKey'] ?? null; + + // Derive public key from private + if (is_string($realityPrivateKey) && $realityPrivateKey !== '' && function_exists('sodium_crypto_scalarmult_base')) { + $b64 = strtr($realityPrivateKey, '-_', '+/'); + $bin = base64_decode($b64, true); + if ($bin === false) { + $bin = base64_decode($realityPrivateKey, true); + } + if (is_string($bin) && strlen($bin) === 32) { + $pub = sodium_crypto_scalarmult_base($bin); + $realityPublicKey = rtrim(strtr(base64_encode($pub), '+/', '-_'), '='); + } + } + } + } + + // Read clientsTable for names + $clientsTableRaw = $server->executeCommand("docker exec -i {$containerArg} cat /opt/amnezia/xray/clientsTable 2>/dev/null", true); + $clientsTable = json_decode(trim($clientsTableRaw), true); + $clientsCount = is_array($xrayClients) ? count($xrayClients) : 0; + + return [ + 'status' => 'existing', + 'message' => 'Найдена установленная конфигурация X-Ray VLESS Reality', + 'details' => [ + 'container_name' => $containerName, + 'container_status' => $containerState, + 'port' => $port, + 'clients' => $xrayClients, + 'clients_table' => is_array($clientsTable) ? $clientsTable : [], + 'clients_count' => $clientsCount, + 'reality_public_key' => $realityPublicKey, + 'reality_private_key' => $realityPrivateKey, + 'reality_short_id' => $realityShortId, + 'reality_server_name' => $realityServerName, + 'config' => $config, + 'summary' => sprintf('Container %s (%s), port %d, clients %d', $containerName, $containerState ?: 'unknown', $port, $clientsCount) + ] + ]; + } + + /** + * Restore existing X-Ray installation: save config to DB, import clients + */ + private static function restoreBuiltinXray(VpnServer $server, array $protocol, array $detection, array $options): array + { + $details = $detection['details'] ?? []; + $containerName = $details['container_name'] ?? 'amnezia-xray'; + $containerArg = escapeshellarg($containerName); + $serverId = $server->getId(); + + // Ensure container is running + $server->executeCommand("docker start {$containerArg} 2>/dev/null || true", true); + + // Update vpn_servers with X-Ray data + $port = $details['port'] ?? 443; + $pdo = DB::conn(); + $stmt = $pdo->prepare(' + UPDATE vpn_servers + SET vpn_port = ?, + status = ?, + error_message = NULL, + deployed_at = COALESCE(deployed_at, NOW()) + WHERE id = ? + '); + $stmt->execute([$port, 'active', $serverId]); + $server->refresh(); + + // Save protocol binding + $pid = self::resolveProtocolId($protocol); + if ($pid) { + $config = [ + 'server_host' => $server->getData()['host'] ?? null, + 'server_port' => $port, + 'extras' => [ + 'reality_public_key' => $details['reality_public_key'] ?? null, + 'reality_private_key' => $details['reality_private_key'] ?? null, + 'reality_short_id' => $details['reality_short_id'] ?? null, + 'reality_server_name' => $details['reality_server_name'] ?? null, + 'container_name' => $containerName, + ] + ]; + $stmt2 = $pdo->prepare('INSERT INTO server_protocols (server_id, protocol_id, config_data, applied_at, created_at) VALUES (?, ?, ?, NOW(), NOW()) ON DUPLICATE KEY UPDATE config_data = VALUES(config_data), applied_at = NOW()'); + $stmt2->execute([$serverId, $pid, json_encode($config)]); + } + + // Import X-Ray clients into database + $xrayClients = $details['clients'] ?? []; + $clientsTable = $details['clients_table'] ?? []; + $serverData = $server->getData(); + $imported = 0; + + // Build name lookup from clientsTable + $nameById = []; + if (is_array($clientsTable)) { + foreach ($clientsTable as $entry) { + $cid = $entry['clientId'] ?? ''; + $cname = $entry['userData']['clientName'] ?? null; + if ($cid !== '' && $cname) { + $nameById[$cid] = $cname; + } + } + } + + if (is_array($xrayClients)) { + foreach ($xrayClients as $xClient) { + $uuid = $xClient['id'] ?? ''; + if ($uuid === '') continue; + + // Check if client already exists by public_key (UUID used as identifier) + $chk = $pdo->prepare('SELECT id FROM vpn_clients WHERE server_id = ? AND public_key = ?'); + $chk->execute([$serverId, $uuid]); + if ($chk->fetch()) { + continue; + } + + // Also check by login + $email = $xClient['email'] ?? ''; + if ($email !== '') { + $chk2 = $pdo->prepare('SELECT id FROM vpn_clients WHERE server_id = ? AND login = ?'); + $chk2->execute([$serverId, $email]); + if ($chk2->fetch()) { + continue; + } + } + + $name = $nameById[$uuid] ?? ($email !== '' ? $email : 'xray-' . substr($uuid, 0, 8)); + + // Generate VLESS config URL for the client + $host = $serverData['host'] ?? ''; + $realityPub = $details['reality_public_key'] ?? ''; + $shortId = $details['reality_short_id'] ?? ''; + $sni = $details['reality_server_name'] ?? ''; + $flow = $xClient['flow'] ?? 'xtls-rprx-vision'; + + $vlessUrl = sprintf( + 'vless://%s@%s:%d?type=tcp&security=reality&pbk=%s&fp=chrome&sni=%s&sid=%s&spx=%%2F&flow=%s#%s', + $uuid, + $host, + $port, + urlencode($realityPub), + urlencode($sni), + urlencode($shortId), + urlencode($flow), + urlencode($name) + ); + + $ins = $pdo->prepare('INSERT INTO vpn_clients (server_id, user_id, name, client_ip, public_key, private_key, preshared_key, login, config, protocol_id, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())'); + $ins->execute([ + $serverId, + $serverData['user_id'] ?? null, + $name, + '', + $uuid, // Store UUID as public_key for X-Ray clients + '', + '', + $email !== '' ? $email : $uuid, + $vlessUrl, + $pid ?: null, + 'active' // Import as active since they work on the server + ]); + $imported++; + Logger::appendInstall($serverId, "Imported X-Ray client: {$name} ({$uuid})"); + } + } + + Logger::appendInstall($serverId, "X-Ray restore complete: imported {$imported} clients"); + + return [ + 'success' => true, + 'mode' => 'restore', + 'message' => 'Существующая конфигурация X-Ray восстановлена', + 'port' => $port, + 'clients_count' => count($xrayClients), + 'imported_clients' => $imported, + 'reality_public_key' => $details['reality_public_key'] ?? null, + ]; + } + + /** + * Import existing AWG clients from server into database (called during activate with existing config) + */ + private static function importExistingAwgClients(VpnServer $server, array $protocol, array $detection): void + { + $details = $detection['details'] ?? []; + $containerName = $details['container_name'] ?? 'amnezia-awg'; + $containerArg = escapeshellarg($containerName); + $serverId = $server->getId(); + $pdo = DB::conn(); + $serverData = $server->getData(); + $pid = self::resolveProtocolId($protocol); + + // Read wg0.conf and clientsTable + $wgConfig = $server->executeCommand("docker exec -i {$containerArg} cat /opt/amnezia/awg/wg0.conf 2>/dev/null", true); + $tableRaw = $server->executeCommand("docker exec -i {$containerArg} cat /opt/amnezia/awg/clientsTable 2>/dev/null", true); + $clientsTable = json_decode(trim($tableRaw), true); + + // Build name lookup + $nameByPub = []; + if (is_array($clientsTable)) { + foreach ($clientsTable as $entry) { + $cid = $entry['clientId'] ?? ''; + $uname = $entry['userData']['clientName'] ?? null; + if ($cid !== '' && $uname) { + $nameByPub[$cid] = $uname; + } + } + } + + $imported = 0; + if (trim($wgConfig) !== '') { + $pattern = '/\[Peer\][^\[]*?PublicKey\s*=\s*(.+?)\s*[\r\n]+[\s\S]*?AllowedIPs\s*=\s*(.+?)(?:\r?\n|$)/'; + if (preg_match_all($pattern, $wgConfig, $matches, PREG_SET_ORDER)) { + foreach ($matches as $m) { + $pub = trim($m[1]); + $allowed = trim($m[2]); + $clientIp = null; + foreach (explode(',', $allowed) as $ipSpec) { + $ipSpec = trim($ipSpec); + if (preg_match('/^([0-9\.]+)\/32$/', $ipSpec, $mm)) { + $clientIp = $mm[1]; + break; + } + } + if (!$clientIp) continue; + + // Check if client already exists + $chk = $pdo->prepare('SELECT id FROM vpn_clients WHERE server_id = ? AND (client_ip = ? OR public_key = ?)'); + $chk->execute([$serverId, $clientIp, $pub]); + if ($chk->fetch()) 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())'); + $ins->execute([ + $serverId, + $serverData['user_id'] ?? null, + $name, + $clientIp, + $pub, + '', + $details['preshared_key'] ?? null, + '', + $pid ?: null, + 'active' // Import as active since they exist on the server + ]); + $imported++; + Logger::appendInstall($serverId, "Imported AWG client: {$name} ({$clientIp})"); + } + } + } + + Logger::appendInstall($serverId, "AWG client import complete: imported {$imported} clients"); + } } From 7ea1c39c5a4c68cb46b13f574a55362e11ceae77 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Fri, 13 Feb 2026 19:37:41 +0300 Subject: [PATCH 50/72] feat: Update client insertion logic to use name/email instead of login --- inc/InstallProtocolManager.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/inc/InstallProtocolManager.php b/inc/InstallProtocolManager.php index 18b7467..b1f5f8d 100644 --- a/inc/InstallProtocolManager.php +++ b/inc/InstallProtocolManager.php @@ -1735,10 +1735,10 @@ class InstallProtocolManager continue; } - // Also check by login + // Also check by name/email $email = $xClient['email'] ?? ''; if ($email !== '') { - $chk2 = $pdo->prepare('SELECT id FROM vpn_clients WHERE server_id = ? AND login = ?'); + $chk2 = $pdo->prepare('SELECT id FROM vpn_clients WHERE server_id = ? AND name = ?'); $chk2->execute([$serverId, $email]); if ($chk2->fetch()) { continue; @@ -1766,16 +1766,15 @@ class InstallProtocolManager urlencode($name) ); - $ins = $pdo->prepare('INSERT INTO vpn_clients (server_id, user_id, name, client_ip, public_key, private_key, preshared_key, login, config, protocol_id, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())'); + $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())'); $ins->execute([ $serverId, $serverData['user_id'] ?? null, $name, - '', + $uuid, // Use UUID as client_ip (unique key requires non-empty value) $uuid, // Store UUID as public_key for X-Ray clients '', '', - $email !== '' ? $email : $uuid, $vlessUrl, $pid ?: null, 'active' // Import as active since they work on the server From e400dfab736af7c5af30a5b12457d656649c5a5c Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Fri, 13 Feb 2026 20:13:57 +0300 Subject: [PATCH 51/72] feat: Add protocol_id to VPN clients insertion logic --- inc/InstallProtocolManager.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/inc/InstallProtocolManager.php b/inc/InstallProtocolManager.php index b1f5f8d..b1fec08 100644 --- a/inc/InstallProtocolManager.php +++ b/inc/InstallProtocolManager.php @@ -467,6 +467,7 @@ class InstallProtocolManager } } $restored = 0; + $pid = self::resolveProtocolId($protocol); if (trim($wgConfig) !== '') { $pattern = '/\[Peer\][^\[]*?PublicKey\s*=\s*(.+?)\s*[\r\n]+[\s\S]*?AllowedIPs\s*=\s*(.+?)(?:\r?\n|$)/'; if (preg_match_all($pattern, $wgConfig, $matches, PREG_SET_ORDER)) { @@ -491,7 +492,7 @@ 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, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())'); + $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())'); $ins->execute([ $server->getId(), $serverData['user_id'] ?? null, @@ -501,6 +502,7 @@ class InstallProtocolManager '', $details['preshared_key'] ?? null, '', + $pid ?: null, 'active' // Import as active since they already work on the server ]); $restored++; From 86eeb765e7dd307ca0bc0f7a52c54bb51636629d Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Fri, 13 Feb 2026 20:34:11 +0300 Subject: [PATCH 52/72] feat: Load environment configuration in metrics collector --- bin/collect_metrics.php | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/collect_metrics.php b/bin/collect_metrics.php index 36d827c..9d63d14 100644 --- a/bin/collect_metrics.php +++ b/bin/collect_metrics.php @@ -10,6 +10,7 @@ require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../inc/Config.php'; +Config::load(__DIR__ . '/../.env'); require_once __DIR__ . '/../inc/DB.php'; require_once __DIR__ . '/../inc/VpnServer.php'; require_once __DIR__ . '/../inc/VpnClient.php'; From e7af048a9e4d225e5bca527b8440a0fbb935e5a9 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Fri, 13 Feb 2026 20:40:04 +0300 Subject: [PATCH 53/72] feat: Allocate proper IP address for clients and expose getNextClientIP method --- inc/InstallProtocolManager.php | 10 +++++++++- inc/VpnClient.php | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/inc/InstallProtocolManager.php b/inc/InstallProtocolManager.php index b1fec08..a82210e 100644 --- a/inc/InstallProtocolManager.php +++ b/inc/InstallProtocolManager.php @@ -1749,6 +1749,14 @@ class InstallProtocolManager $name = $nameById[$uuid] ?? ($email !== '' ? $email : 'xray-' . substr($uuid, 0, 8)); + // Allocate a proper IP address for the client instead of using UUID + try { + $clientIp = VpnClient::getNextClientIP($serverData); + } catch (Throwable $e) { + // Fallback to UUID if IP allocation fails + $clientIp = $uuid; + } + // Generate VLESS config URL for the client $host = $serverData['host'] ?? ''; $realityPub = $details['reality_public_key'] ?? ''; @@ -1773,7 +1781,7 @@ class InstallProtocolManager $serverId, $serverData['user_id'] ?? null, $name, - $uuid, // Use UUID as client_ip (unique key requires non-empty value) + $clientIp, // Use allocated IP address $uuid, // Store UUID as public_key for X-Ray clients '', '', diff --git a/inc/VpnClient.php b/inc/VpnClient.php index 029c059..6573991 100644 --- a/inc/VpnClient.php +++ b/inc/VpnClient.php @@ -622,7 +622,7 @@ class VpnClient /** * Get next available client IP */ - private static function getNextClientIP(array $serverData): string + public static function getNextClientIP(array $serverData): string { $pdo = DB::conn(); From d1eb910e6a485f1d10ea461b6b4600e94e4e31a3 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 14 Feb 2026 11:39:04 +0300 Subject: [PATCH 54/72] feat: Update client IP handling for X-Ray configuration and enable text content display by default --- inc/InstallProtocolManager.php | 14 +++++--------- .../056_enable_show_text_content_for_xray.sql | 5 +++++ 2 files changed, 10 insertions(+), 9 deletions(-) create mode 100644 migrations/056_enable_show_text_content_for_xray.sql diff --git a/inc/InstallProtocolManager.php b/inc/InstallProtocolManager.php index a82210e..e2edc8c 100644 --- a/inc/InstallProtocolManager.php +++ b/inc/InstallProtocolManager.php @@ -1749,13 +1749,9 @@ class InstallProtocolManager $name = $nameById[$uuid] ?? ($email !== '' ? $email : 'xray-' . substr($uuid, 0, 8)); - // Allocate a proper IP address for the client instead of using UUID - try { - $clientIp = VpnClient::getNextClientIP($serverData); - } catch (Throwable $e) { - // Fallback to UUID if IP allocation fails - $clientIp = $uuid; - } + // X-Ray config does not store per-client tunnel IP like WireGuard. + // Keep client_ip deterministic from config client id (UUID) during restore. + $clientIp = $uuid; // Generate VLESS config URL for the client $host = $serverData['host'] ?? ''; @@ -1781,8 +1777,8 @@ class InstallProtocolManager $serverId, $serverData['user_id'] ?? null, $name, - $clientIp, // Use allocated IP address - $uuid, // Store UUID as public_key for X-Ray clients + $clientIp, + $uuid, '', '', $vlessUrl, diff --git a/migrations/056_enable_show_text_content_for_xray.sql b/migrations/056_enable_show_text_content_for_xray.sql new file mode 100644 index 0000000..40ae202 --- /dev/null +++ b/migrations/056_enable_show_text_content_for_xray.sql @@ -0,0 +1,5 @@ +-- Enable text content display by default on client page for XRay VLESS +UPDATE protocols +SET show_text_content = 1 +WHERE slug = 'xray-vless' + AND COALESCE(show_text_content, 0) <> 1; From a037f1332505a08daca08d11898667eff3db6872 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 14 Feb 2026 18:23:57 +0300 Subject: [PATCH 55/72] feat: Add missing translations for protocol management UI and update protocol visibility --- ...7_add_protocol_management_translations.sql | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 migrations/057_add_protocol_management_translations.sql diff --git a/migrations/057_add_protocol_management_translations.sql b/migrations/057_add_protocol_management_translations.sql new file mode 100644 index 0000000..42b2d2a --- /dev/null +++ b/migrations/057_add_protocol_management_translations.sql @@ -0,0 +1,31 @@ +-- Add missing translations for protocol management UI (EN/RU) +INSERT INTO translations (locale, category, key_name, translation) VALUES +('en', 'protocols', 'management', 'Protocol Management'), +('ru', 'protocols', 'management', 'Управление протоколами'), +('en', 'protocols', 'management_description', 'Configure and manage VPN protocols'), +('ru', 'protocols', 'management_description', 'Настройка и управление VPN-протоколами'), +('en', 'common', 'active', 'Active'), +('ru', 'common', 'active', 'Активный'), +('en', 'common', 'inactive', 'Inactive'), +('ru', 'common', 'inactive', 'Неактивный'), +('en', 'protocols', 'add_protocol', 'Add Protocol'), +('ru', 'protocols', 'add_protocol', 'Добавить протокол'), +('en', 'common', 'settings', 'Settings'), +('ru', 'common', 'settings', 'Настройки'), +('en', 'protocols', 'available_protocols', 'Available Protocols'), +('ru', 'protocols', 'available_protocols', 'Доступные протоколы'), +('en', 'protocols', 'search_protocols', 'Search protocols'), +('ru', 'protocols', 'search_protocols', 'Поиск протоколов'), +('en', 'protocols', 'all_protocols', 'All Protocols'), +('ru', 'protocols', 'all_protocols', 'Все протоколы'), +('en', 'protocols', 'active_only', 'Active only'), +('ru', 'protocols', 'active_only', 'Только активные'), +('en', 'protocols', 'with_ai_generations', 'With AI generations'), +('ru', 'protocols', 'with_ai_generations', 'С AI-генерациями') +ON DUPLICATE KEY UPDATE translation = VALUES(translation); + +-- Hide protocols that should not be published +UPDATE protocols +SET is_active = 0 +WHERE slug IN ('cloak', 'openvpn', 'shadowsocks', 'wireguard', 'wireguard-standard') + OR name IN ('Cloak', 'OpenVPN', 'Shadowsocks', 'WireGuard', 'WireGuard Standard'); From 19078b03dde8eb65884304425faba60aec3b0df5 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Wed, 25 Feb 2026 06:57:04 +0300 Subject: [PATCH 56/72] fix:max logs MySql --- my.cnf | 2 ++ public/index.php | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/my.cnf b/my.cnf index 92a0323..0685cab 100644 --- a/my.cnf +++ b/my.cnf @@ -10,3 +10,5 @@ collation-server = utf8mb4_unicode_ci init_connect = 'SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci' init_connect = 'SET collation_connection = utf8mb4_unicode_ci' skip-character-set-client-handshake +binlog_expire_logs_seconds = 259200 +max_binlog_size = 100M diff --git a/public/index.php b/public/index.php index 02e193a..4926f5a 100644 --- a/public/index.php +++ b/public/index.php @@ -267,7 +267,7 @@ Router::get('/dashboard', function () { // Get real-time online clients count from Xray API $onlineData = ServerMonitoring::countOnlineClients(); - + // Also count clients with recent handshake (within 5 minutes) for WireGuard/AWG $pdo = DB::conn(); $stmt = $pdo->query(" @@ -276,8 +276,8 @@ Router::get('/dashboard', function () { AND last_handshake > DATE_SUB(NOW(), INTERVAL 5 MINUTE) AND status = 'active' "); - $recentHandshakeCount = (int)$stmt->fetchColumn(); - + $recentHandshakeCount = (int) $stmt->fetchColumn(); + // Combine both counts (XRay online + recent handshake), avoiding duplicates $totalOnline = max($onlineData['total'], $recentHandshakeCount); @@ -2709,7 +2709,7 @@ Router::post('/api/servers/{id}/protocols/selftest', function ($params) { $err = (string) ($derived['error'] ?? 'derive_failed'); // If we can't derive locally (e.g., libsodium missing), fall back to wg inside container. if ($err === 'libsodium_not_available') { - $shComputePub = "set -e; priv=" . escapeshellarg($cfgPrivate) . "; printf '%s' \"$priv\" | wg pubkey"; + $shComputePub = "set -e; priv=" . escapeshellarg($cfgPrivate) . "; printf '%s' \"\$priv\" | wg pubkey"; $cmdComputePub = "docker exec -i " . escapeshellarg($containerName) . " sh -c " . escapeshellarg($shComputePub); $computedClientPub = trim($server->executeCommand($cmdComputePub, true)); } else { @@ -2949,7 +2949,7 @@ Router::post('/api/servers/{id}/protocols/diagnose-handshake', function ($params } else { $clientPubError = (string) ($derived['error'] ?? 'derive_failed'); // Fallback: compute using wg inside container (best-effort) - $shComputePub = "set -e; priv=" . escapeshellarg($cfgPrivate) . "; printf '%s' \"$priv\" | wg pubkey"; + $shComputePub = "set -e; priv=" . escapeshellarg($cfgPrivate) . "; printf '%s' \"\$priv\" | wg pubkey"; $cmdComputePub = "docker exec -i " . escapeshellarg($containerName) . " sh -c " . escapeshellarg($shComputePub); $computed = trim((string) $server->executeCommand($cmdComputePub, true)); // Basic validation: wg outputs a 44-char base64 key From 26a6ca526d1a91982ea194a5724277fe6880be81 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 4 Apr 2026 09:56:49 +0300 Subject: [PATCH 57/72] feat: add support for awg2, mtproxy, and aivpn protocols, and implement user role-based access control. --- .gitignore | 1 + controllers/ProtocolManagementController.php | 31 +- inc/InstallProtocolManager.php | 7 +- inc/VpnClient.php | 13 +- migrations/012_add_user_roles.sql | 32 ++ migrations/058_add_awg2_protocol.sql | 381 +++++++++++++++++++ migrations/059_add_mtproxy_protocol.sql | 113 ++++++ migrations/060_add_aivpn_protocol.sql | 153 ++++++++ public/index.php | 4 +- 9 files changed, 726 insertions(+), 9 deletions(-) create mode 100644 migrations/012_add_user_roles.sql create mode 100644 migrations/058_add_awg2_protocol.sql create mode 100644 migrations/059_add_mtproxy_protocol.sql create mode 100644 migrations/060_add_aivpn_protocol.sql diff --git a/.gitignore b/.gitignore index 93f08a3..60a7113 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,4 @@ scripts/regen_qr.php scripts/test_xray_install.sh scripts/test_online.php API_AWG_DOCS.md +log.txt diff --git a/controllers/ProtocolManagementController.php b/controllers/ProtocolManagementController.php index 6df0e6c..9abb5c6 100644 --- a/controllers/ProtocolManagementController.php +++ b/controllers/ProtocolManagementController.php @@ -800,8 +800,18 @@ SH; $res['client_id'] = $m[1]; } + // Extract secret (for MTProxy and similar protocols) + if (preg_match('/Secret:\s*([a-fA-F0-9]+)/i', $output, $m)) { + $res['secret'] = $m[1]; + } + + // Extract server host/IP + if (preg_match('/Server\s*Host:\s*(\S+)/i', $output, $m)) { + $res['server_host'] = trim($m[1], "'\""); + } + // Generic variable extraction (Variable: KEY=VALUE) - if (preg_match_all('/Variable:\\s*([a-zA-Z0-9_]+)=(.*)/', $output, $matches, PREG_SET_ORDER)) { + if (preg_match_all('/Variable:\s*([a-zA-Z0-9_]+)=(.*)/', $output, $matches, PREG_SET_ORDER)) { foreach ($matches as $m) { $key = trim($m[1]); $val = trim($m[2]); @@ -811,6 +821,25 @@ SH; } } + // Fallback: parse any remaining "Key: Value" lines not yet captured + // This catches protocol-specific variables like custom fields + $lines = preg_split('/\r?\n/', $output); + foreach ($lines as $line) { + $line = trim($line); + if ($line === '') continue; + // Skip set -x trace lines (start with +) + if (preg_match('/^\+/', $line)) continue; + if (preg_match('/^([A-Za-z][A-Za-z0-9 _-]*?)\s*:\s*(.+)$/', $line, $m)) { + $rawKey = trim($m[1]); + $rawVal = trim($m[2], " \t'\""); + $normalized = strtolower(preg_replace('/\s+/', '_', $rawKey)); + // Don't overwrite already extracted keys + if (!array_key_exists($normalized, $res)) { + $res[$normalized] = $rawVal; + } + } + } + return $res; } diff --git a/inc/InstallProtocolManager.php b/inc/InstallProtocolManager.php index e2edc8c..24c8975 100644 --- a/inc/InstallProtocolManager.php +++ b/inc/InstallProtocolManager.php @@ -295,6 +295,8 @@ class InstallProtocolManager 'server_public_key' => $result['server_public_key'] ?? null, 'preshared_key' => $result['preshared_key'] ?? null, 'awg_params' => $result['awg_params'] ?? null, + 'secret' => $result['secret'] ?? null, + 'server_host' => $result['server_host'] ?? null, ]; if (($protocol['slug'] ?? '') === 'xray-vless') { foreach (['client_id', 'container_name', 'server_port', 'xray_port', 'reality_public_key', 'reality_private_key', 'reality_short_id', 'reality_server_name'] as $k) { @@ -653,6 +655,9 @@ class InstallProtocolManager 'privatekey' => 'reality_private_key', 'shortid' => 'reality_short_id', 'servername' => 'reality_server_name', + 'secret' => 'secret', + 'serverhost' => 'server_host', + 'server_host' => 'server_host', ]; $finalKey = $keyMap[$normalizedKey] ?? $normalizedKey; @@ -899,7 +904,7 @@ class InstallProtocolManager */ private static function isAwgProtocol(string $slug, array $protocol): bool { - if (in_array($slug, ['amnezia-wg', 'amnezia-wg-advanced'], true)) { + if (in_array($slug, ['amnezia-wg', 'amnezia-wg-advanced', 'awg2'], true)) { return true; } $installScript = (string) ($protocol['install_script'] ?? ''); diff --git a/inc/VpnClient.php b/inc/VpnClient.php index 6573991..bf3ff85 100644 --- a/inc/VpnClient.php +++ b/inc/VpnClient.php @@ -67,7 +67,7 @@ class VpnClient $protoRow = $stmtProto2->fetch(); } $slug = $protoRow['slug'] ?? ($serverData['install_protocol'] ?? 'amnezia-wg'); - $isWireguard = in_array($slug, ['amnezia-wg-advanced', 'wireguard-standard', 'amnezia-wg'], true); + $isWireguard = in_array($slug, ['amnezia-wg-advanced', 'wireguard-standard', 'amnezia-wg', 'awg2'], true); // Auto-sync server keys from container EVERY TIME for WireGuard protocols // This ensures we always use current container configuration even if it was recreated @@ -1121,7 +1121,7 @@ class VpnClient $stmt = $pdo->prepare('SELECT slug FROM protocols WHERE id = ?'); $stmt->execute([$protocolId]); $slug = (string) $stmt->fetchColumn(); - return in_array($slug, ['amnezia-wg-advanced', 'wireguard-standard', 'amnezia-wg'], true); + return in_array($slug, ['amnezia-wg-advanced', 'wireguard-standard', 'amnezia-wg', 'awg2'], true); } catch (Exception $e) { return true; } @@ -1309,7 +1309,7 @@ class VpnClient $protoRow = $stmt->fetch(); } $slug = $protoRow['slug'] ?? ''; - $isWireguard = in_array($slug, ['amnezia-wg-advanced', 'wireguard-standard', 'amnezia-wg'], true); + $isWireguard = in_array($slug, ['amnezia-wg-advanced', 'wireguard-standard', 'amnezia-wg', 'awg2'], true); if (!$isWireguard) { return ['success' => false, 'error' => 'not_wireguard_protocol', 'protocol_slug' => $slug]; @@ -1335,7 +1335,7 @@ class VpnClient // If AWG params are missing (common after reinstall), fetch them directly from wg0.conf // to avoid falling back to template defaults that will not match the server. - if ($slug === 'amnezia-wg-advanced') { + if (in_array($slug, ['amnezia-wg-advanced', 'awg2'], true)) { $needKeys = ['JC', 'JMIN', 'JMAX', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4']; $missing = false; foreach ($needKeys as $k) { @@ -1346,8 +1346,9 @@ class VpnClient } if ($missing) { - $containerName = $serverData['container_name'] ?? 'amnezia-awg'; - $direct = self::extractAwgParamsFromWg0Conf($server, $containerName, '/opt/amnezia/awg/wg0.conf'); + $containerName = $serverData['container_name'] ?? ($slug === 'awg2' ? 'amnezia-awg2' : 'amnezia-awg'); + $configDir = $slug === 'awg2' ? '/opt/amnezia/awg2' : '/opt/amnezia/awg'; + $direct = self::extractAwgParamsFromWg0Conf($server, $containerName, $configDir . '/wg0.conf'); if (empty($direct)) { $direct = self::extractAwgParamsFromWg0Conf($server, $containerName, '/etc/wireguard/wg0.conf'); } diff --git a/migrations/012_add_user_roles.sql b/migrations/012_add_user_roles.sql new file mode 100644 index 0000000..dbb04a5 --- /dev/null +++ b/migrations/012_add_user_roles.sql @@ -0,0 +1,32 @@ +-- Migration: Add user roles and permissions +-- Date: 2025-11-10 + +-- User roles table +CREATE TABLE IF NOT EXISTS user_roles ( + id INT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(50) NOT NULL UNIQUE, + display_name VARCHAR(100) NOT NULL, + description TEXT, + permissions JSON NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Add role to users table +ALTER TABLE users +ADD COLUMN role VARCHAR(50) DEFAULT 'viewer' AFTER ldap_dn, +ADD INDEX idx_role (role); + +-- Insert default roles +INSERT IGNORE INTO user_roles (name, display_name, description, permissions) VALUES +('admin', 'Administrator', 'Full access to all features', JSON_ARRAY('*')), +('manager', 'Manager', 'Can manage servers and clients', JSON_ARRAY('servers.view', 'servers.create', 'servers.edit', 'clients.view', 'clients.create', 'clients.edit', 'clients.delete')), +('viewer', 'Viewer', 'Can only view own clients', JSON_ARRAY('clients.view_own', 'clients.download_own')); + +-- Insert default LDAP group mappings (examples) +INSERT IGNORE INTO ldap_group_mappings (ldap_group, role_name, description) VALUES +('vpn-admins', 'admin', 'VPN administrators with full access'), +('vpn-managers', 'manager', 'VPN managers who can create and manage clients'), +('vpn-users', 'viewer', 'Regular VPN users with view-only access'); + +-- Update existing users to admin role (backward compatibility) +UPDATE users SET role = 'admin' WHERE role IS NULL OR role = ''; diff --git a/migrations/058_add_awg2_protocol.sql b/migrations/058_add_awg2_protocol.sql new file mode 100644 index 0000000..ab01fae --- /dev/null +++ b/migrations/058_add_awg2_protocol.sql @@ -0,0 +1,381 @@ +-- ===================================================================== +-- Migration 058: Add AmneziaWG 2.0 protocol (amneziawg-go userspace) +-- Uses amneziawg-go (Go userspace) instead of kernel module +-- https://github.com/amnezia-vpn/amneziawg-go +-- ===================================================================== + +-- 1. Insert the protocol entry (clone output_template from amnezia-wg-advanced) +INSERT INTO protocols (name, slug, description, install_script, uninstall_script, output_template, ubuntu_compatible, is_active, definition, created_at, updated_at) +SELECT + 'AmneziaWG 2.0', + 'awg2', + 'AmneziaWG 2.0 — userspace Go implementation (amneziawg-go). No kernel module required.', + '#!/bin/bash +set -euo pipefail + +# Use exported variables from panel (SERVER_PORT, SERVER_CONTAINER) or defaults +CONTAINER_NAME="${SERVER_CONTAINER:-amnezia-awg2}" +PORT_RANGE_START=${PORT_RANGE_START:-30000} +PORT_RANGE_END=${PORT_RANGE_END:-65000} +VPN_PORT="${SERVER_PORT:-$((RANDOM % (PORT_RANGE_END - PORT_RANGE_START + 1) + PORT_RANGE_START))}" +MTU=${MTU:-1420} + +# Install git if not available +if ! command -v git &> /dev/null; then + apt-get update -qq && apt-get install -y -qq git >/dev/null 2>&1 +fi + +mkdir -p /opt/amnezia/awg2 + +# Clone amneziawg-go source for Docker build +if [ ! -d /opt/amnezia/awg2/src ]; then + git clone --depth=1 https://github.com/amnezia-vpn/amneziawg-go.git /opt/amnezia/awg2/src +fi + +# Build Docker image using the repo Dockerfile (multi-stage: Go compile + tools) +docker build --no-cache -t amnezia-awg2 /opt/amnezia/awg2/src + +# Run container (userspace: no SYS_MODULE, no /lib/modules) +EXISTING=$(docker ps -aq -f "name=$CONTAINER_NAME" 2>/dev/null | head -1) +if [ -z "$EXISTING" ]; then + docker run -d --name "$CONTAINER_NAME" --restart always --cap-add=NET_ADMIN --device /dev/net/tun -p "${VPN_PORT}:${VPN_PORT}/udp" -v /opt/amnezia/awg2:/opt/amnezia/awg amnezia-awg2 sh -c "while [ ! -f /opt/amnezia/awg/wg0.conf ]; do sleep 1; done; WG_QUICK_USERSPACE_IMPLEMENTATION=amneziawg-go awg-quick up /opt/amnezia/awg/wg0.conf && sleep infinity" + + sleep 2 +else + STATUS=$(docker inspect --format="{{.State.Status}}" "$CONTAINER_NAME" 2>/dev/null || echo "") + if [ \"$STATUS\" != \"running\" ]; then + docker start \"$CONTAINER_NAME\" >/dev/null 2>&1 || true + fi +fi + +# Check for existing config +if [ -f /opt/amnezia/awg2/wg0.conf ]; then + PORT=$(grep -E "^ListenPort" /opt/amnezia/awg2/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + PSK=$(cat /opt/amnezia/awg2/wireguard_psk.key 2>/dev/null || true) + if [ -z "$PSK" ]; then + PSK=$(grep -E "^PresharedKey" /opt/amnezia/awg2/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + fi + PUBKEY=$(cat /opt/amnezia/awg2/wireguard_server_public_key.key 2>/dev/null || true) + if [ -z "$PUBKEY" ]; then + PRIVKEY=$(cat /opt/amnezia/awg2/wireguard_server_private_key.key 2>/dev/null || true) + if [ -n "$PRIVKEY" ]; then + PUBKEY=$(echo "$PRIVKEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) + fi + fi + + echo "Using existing AmneziaWG 2.0 configuration" + echo "Port: ${PORT:-$VPN_PORT}" + if [ -n "${PUBKEY:-}" ]; then echo "Server Public Key: $PUBKEY"; fi + if [ -n "${PSK:-}" ]; then echo "PresharedKey = $PSK"; fi + + EXTERNAL_IP=$(curl -s -4 ifconfig.me 2>/dev/null || curl -s -4 icanhazip.com 2>/dev/null || echo "YOUR_SERVER_IP") + echo "Server Host: $EXTERNAL_IP" + + # Output AWG params from existing config + for P in Jc Jmin Jmax S1 S2 S3 S4 H1 H2 H3 H4; do + VAL=$(grep -E "^$P " /opt/amnezia/awg2/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + if [ -n "$VAL" ]; then echo "Variable: $P=$VAL"; fi + done + echo "Variable: dns_servers=1.1.1.1, 1.0.0.1" + exit 0 +fi + +# Generate keys +PRIVATE_KEY=$(docker exec "$CONTAINER_NAME" wg genkey) +PUBLIC_KEY=$(echo "$PRIVATE_KEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) +PRESHARED_KEY=$(docker exec "$CONTAINER_NAME" wg genpsk) + +# AWG obfuscation parameters +JC=5 +JMIN=50 +JMAX=1000 +S1_VAL=50 +S2_VAL=100 +S3_VAL=20 +S4_VAL=10 +# H1-H4: header ranges (string format "x-y" per AWG2 spec) +H1_VAL="1-4294967295" +H2_VAL="1-4294967295" +H3_VAL="1-4294967295" +H4_VAL="1-4294967295" + +# Write config +cat > /opt/amnezia/awg2/wg0.conf << EOF +[Interface] +PrivateKey = $PRIVATE_KEY +Address = 10.8.1.1/24 +ListenPort = $VPN_PORT +MTU = $MTU +Jc = $JC +Jmin = $JMIN +Jmax = $JMAX +S1 = $S1_VAL +S2 = $S2_VAL +S3 = $S3_VAL +S4 = $S4_VAL +H1 = $H1_VAL +H2 = $H2_VAL +H3 = $H3_VAL +H4 = $H4_VAL +PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE +PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE + +[Peer] +PublicKey = +PresharedKey = $PRESHARED_KEY +AllowedIPs = 10.8.1.2/32 +EOF + +echo "$PRIVATE_KEY" > /opt/amnezia/awg2/wireguard_server_private_key.key +echo "$PUBLIC_KEY" > /opt/amnezia/awg2/wireguard_server_public_key.key +echo "$PRESHARED_KEY" > /opt/amnezia/awg2/wireguard_psk.key +echo "[]" > /opt/amnezia/awg2/clientsTable + +# Get external IP +EXTERNAL_IP=$(curl -s -4 ifconfig.me 2>/dev/null || curl -s -4 icanhazip.com 2>/dev/null || echo "YOUR_SERVER_IP") + +echo "AmneziaWG 2.0 installed successfully" +echo "Port: $VPN_PORT" +echo "Server Public Key: $PUBLIC_KEY" +echo "PresharedKey = $PRESHARED_KEY" +echo "Server Host: $EXTERNAL_IP" +echo "Variable: Jc=$JC" +echo "Variable: Jmin=$JMIN" +echo "Variable: Jmax=$JMAX" +echo "Variable: S1=$S1_VAL" +echo "Variable: S2=$S2_VAL" +echo "Variable: S3=$S3_VAL" +echo "Variable: S4=$S4_VAL" +echo "Variable: H1=$H1_VAL" +echo "Variable: H2=$H2_VAL" +echo "Variable: H3=$H3_VAL" +echo "Variable: H4=$H4_VAL" +echo "Variable: dns_servers=1.1.1.1, 1.0.0.1"', + '#!/bin/bash +set -euo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-amnezia-awg2}" + +docker stop "$CONTAINER_NAME" 2>/dev/null || true +docker rm -fv "$CONTAINER_NAME" 2>/dev/null || true +docker image rm amnezia-awg2 2>/dev/null || true +rm -rf /opt/amnezia/awg2 2>/dev/null || true + +echo "{\"success\":true,\"message\":\"AmneziaWG 2.0 uninstalled\"}"', + p.output_template, + 1, + 1, + JSON_OBJECT( + 'engine', 'shell', + 'metadata', JSON_OBJECT( + 'container_name', 'amnezia-awg2', + 'vpn_subnet', '10.8.1.0/24', + 'port_range', JSON_ARRAY(30000, 65000), + 'config_dir', '/opt/amnezia/awg2' + ) + ), + NOW(), + NOW() +FROM protocols p +WHERE p.slug = 'amnezia-wg-advanced' + AND NOT EXISTS (SELECT 1 FROM protocols WHERE slug = 'awg2'); + +-- 2. Clone protocol variables from amnezia-wg-advanced to awg2 +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT + (SELECT id FROM protocols WHERE slug = 'awg2' LIMIT 1), + src.variable_name, + src.variable_type, + src.default_value, + src.description, + src.required +FROM protocol_variables src +WHERE src.protocol_id = (SELECT id FROM protocols WHERE slug = 'amnezia-wg-advanced' LIMIT 1) + AND NOT EXISTS ( + SELECT 1 FROM protocol_variables ev + WHERE ev.protocol_id = (SELECT id FROM protocols WHERE slug = 'awg2' LIMIT 1) + AND ev.variable_name = src.variable_name + ); + +-- 3. Clone protocol templates from amnezia-wg-advanced to awg2 +INSERT INTO protocol_templates (protocol_id, template_name, template_content, is_default) +SELECT + (SELECT id FROM protocols WHERE slug = 'awg2' LIMIT 1), + src.template_name, + src.template_content, + src.is_default +FROM protocol_templates src +WHERE src.protocol_id = (SELECT id FROM protocols WHERE slug = 'amnezia-wg-advanced' LIMIT 1) + AND NOT EXISTS ( + SELECT 1 FROM protocol_templates et + WHERE et.protocol_id = (SELECT id FROM protocols WHERE slug = 'awg2' LIMIT 1) + AND et.template_name = src.template_name + ); + +-- 4. Update install_script for existing awg2 protocol (in case migration was already run) +UPDATE protocols SET install_script = '#!/bin/bash +set -euo pipefail + +CONTAINER_NAME="${SERVER_CONTAINER:-amnezia-awg2}" +PORT_RANGE_START=${PORT_RANGE_START:-30000} +PORT_RANGE_END=${PORT_RANGE_END:-65000} +VPN_PORT="${SERVER_PORT:-$((RANDOM % (PORT_RANGE_END - PORT_RANGE_START + 1) + PORT_RANGE_START))}" +MTU=${MTU:-1420} + +if ! command -v git &> /dev/null; then + apt-get update -qq && apt-get install -y -qq git >/dev/null 2>&1 +fi + +mkdir -p /opt/amnezia/awg2 + +if [ ! -d /opt/amnezia/awg2/src ]; then + git clone --depth=1 https://github.com/amnezia-vpn/amneziawg-go.git /opt/amnezia/awg2/src +fi + +docker build --no-cache -t amnezia-awg2 /opt/amnezia/awg2/src + +EXISTING=$(docker ps -aq -f "name=$CONTAINER_NAME" 2>/dev/null | head -1) +if [ -z "$EXISTING" ]; then + docker run -d --name "$CONTAINER_NAME" --restart always --cap-add=NET_ADMIN --device /dev/net/tun -p "${VPN_PORT}:${VPN_PORT}/udp" -v /opt/amnezia/awg2:/opt/amnezia/awg amnezia-awg2 sh -c "while [ ! -f /opt/amnezia/awg/wg0.conf ]; do sleep 1; done; WG_QUICK_USERSPACE_IMPLEMENTATION=amneziawg-go awg-quick up /opt/amnezia/awg/wg0.conf && sleep infinity" + sleep 2 +else + STATUS=$(docker inspect --format="{{.State.Status}}" "$CONTAINER_NAME" 2>/dev/null || echo "") + if [ \"$STATUS\" != \"running\" ]; then + docker start \"$CONTAINER_NAME\" >/dev/null 2>&1 || true + fi +fi + +if [ -f /opt/amnezia/awg2/wg0.conf ]; then + PORT=$(grep -E "^ListenPort" /opt/amnezia/awg2/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + PSK=$(cat /opt/amnezia/awg2/wireguard_psk.key 2>/dev/null || true) + if [ -z "$PSK" ]; then + PSK=$(grep -E "^PresharedKey" /opt/amnezia/awg2/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + fi + PUBKEY=$(cat /opt/amnezia/awg2/wireguard_server_public_key.key 2>/dev/null || true) + if [ -z "$PUBKEY" ]; then + PRIVKEY=$(cat /opt/amnezia/awg2/wireguard_server_private_key.key 2>/dev/null || true) + if [ -n "$PRIVKEY" ]; then + PUBKEY=$(echo "$PRIVKEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) + fi + fi + + echo "Using existing AmneziaWG 2.0 configuration" + echo "Port: ${PORT:-$VPN_PORT}" + if [ -n "${PUBKEY:-}" ]; then echo "Server Public Key: $PUBKEY"; fi + if [ -n "${PSK:-}" ]; then echo "PresharedKey = $PSK"; fi + + EXTERNAL_IP=$(curl -s -4 ifconfig.me 2>/dev/null || curl -s -4 icanhazip.com 2>/dev/null || echo "YOUR_SERVER_IP") + echo "Server Host: $EXTERNAL_IP" + + for P in Jc Jmin Jmax S1 S2 S3 S4 H1 H2 H3 H4; do + VAL=$(grep -E "^$P " /opt/amnezia/awg2/wg0.conf | cut -d= -f2 | tr -d "[:space:]") + if [ -n "$VAL" ]; then echo "Variable: $P=$VAL"; fi + done + echo "Variable: dns_servers=1.1.1.1, 1.0.0.1" + exit 0 +fi + +PRIVATE_KEY=$(docker exec "$CONTAINER_NAME" wg genkey) +PUBLIC_KEY=$(echo "$PRIVATE_KEY" | docker exec -i "$CONTAINER_NAME" wg pubkey) +PRESHARED_KEY=$(docker exec "$CONTAINER_NAME" wg genpsk) + +JC=5 +JMIN=50 +JMAX=1000 +S1_VAL=50 +S2_VAL=100 +S3_VAL=20 +S4_VAL=10 +H1_VAL="1-4294967295" +H2_VAL="1-4294967295" +H3_VAL="1-4294967295" +H4_VAL="1-4294967295" + +cat > /opt/amnezia/awg2/wg0.conf << EOF +[Interface] +PrivateKey = $PRIVATE_KEY +Address = 10.8.1.1/24 +ListenPort = $VPN_PORT +MTU = $MTU +Jc = $JC +Jmin = $JMIN +Jmax = $JMAX +S1 = $S1_VAL +S2 = $S2_VAL +S3 = $S3_VAL +S4 = $S4_VAL +H1 = $H1_VAL +H2 = $H2_VAL +H3 = $H3_VAL +H4 = $H4_VAL +PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE +PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE + +[Peer] +PublicKey = +PresharedKey = $PRESHARED_KEY +AllowedIPs = 10.8.1.2/32 +EOF + +echo "$PRIVATE_KEY" > /opt/amnezia/awg2/wireguard_server_private_key.key +echo "$PUBLIC_KEY" > /opt/amnezia/awg2/wireguard_server_public_key.key +echo "$PRESHARED_KEY" > /opt/amnezia/awg2/wireguard_psk.key +echo "[]" > /opt/amnezia/awg2/clientsTable + +EXTERNAL_IP=$(curl -s -4 ifconfig.me 2>/dev/null || curl -s -4 icanhazip.com 2>/dev/null || echo "YOUR_SERVER_IP") + +echo "AmneziaWG 2.0 installed successfully" +echo "Port: $VPN_PORT" +echo "Server Public Key: $PUBLIC_KEY" +echo "PresharedKey = $PRESHARED_KEY" +echo "Server Host: $EXTERNAL_IP" +echo "Variable: Jc=$JC" +echo "Variable: Jmin=$JMIN" +echo "Variable: Jmax=$JMAX" +echo "Variable: S1=$S1_VAL" +echo "Variable: S2=$S2_VAL" +echo "Variable: S3=$S3_VAL" +echo "Variable: S4=$S4_VAL" +echo "Variable: H1=$H1_VAL" +echo "Variable: H2=$H2_VAL" +echo "Variable: H3=$H3_VAL" +echo "Variable: H4=$H4_VAL" +echo "Variable: dns_servers=1.1.1.1, 1.0.0.1"' +WHERE slug = 'awg2'; + +-- 5. Update output_template for AWG2 (add S3/S4 padding params) +UPDATE protocols SET output_template = '[Interface] +PrivateKey = {{private_key}} +Address = {{client_ip}}/32 +DNS = {{dns_servers}} +MTU = 1280 +Jc = {{Jc}} +Jmin = {{Jmin}} +Jmax = {{Jmax}} +S1 = {{S1}} +S2 = {{S2}} +S3 = {{S3}} +S4 = {{S4}} +H1 = {{H1}} +H2 = {{H2}} +H3 = {{H3}} +H4 = {{H4}} + +[Peer] +PublicKey = {{server_public_key}} +PresharedKey = {{preshared_key}} +AllowedIPs = 0.0.0.0/0, ::/0 +Endpoint = {{server_host}}:{{server_port}} +PersistentKeepalive = 25' +WHERE slug = 'awg2'; + +-- 6. Add S3/S4 protocol variables for awg2 +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'S3', 'number', '20', 'Padding of handshake cookie message', false +FROM protocols p WHERE p.slug = 'awg2' + AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id = p.id AND variable_name = 'S3'); + +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'S4', 'number', '10', 'Padding of transport messages', false +FROM protocols p WHERE p.slug = 'awg2' + AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id = p.id AND variable_name = 'S4'); diff --git a/migrations/059_add_mtproxy_protocol.sql b/migrations/059_add_mtproxy_protocol.sql new file mode 100644 index 0000000..e5de8db --- /dev/null +++ b/migrations/059_add_mtproxy_protocol.sql @@ -0,0 +1,113 @@ +-- ===================================================================== +-- Migration 059: Add MTProxy (Telegram) protocol +-- https://hub.docker.com/r/telegrammessenger/proxy/ +-- Zero-configuration Telegram MTProto proxy server +-- ===================================================================== + +-- 1. Insert the MTProxy protocol +INSERT INTO protocols (name, slug, description, install_script, uninstall_script, output_template, show_text_content, ubuntu_compatible, is_active, definition, created_at, updated_at) +SELECT + 'MTProxy (Telegram)', + 'mtproxy', + 'Telegram MTProto proxy — zero-configuration proxy server for Telegram messenger.', + '#!/bin/bash +set -euo pipefail + +# Use exported variables from panel (SERVER_PORT, SERVER_CONTAINER) or defaults +CONTAINER_NAME="${SERVER_CONTAINER:-amnezia-mtproxy}" +PORT_RANGE_START=${PORT_RANGE_START:-30000} +PORT_RANGE_END=${PORT_RANGE_END:-65000} +MTPROXY_PORT="${SERVER_PORT:-$((RANDOM % (PORT_RANGE_END - PORT_RANGE_START + 1) + PORT_RANGE_START))}" + +mkdir -p /opt/amnezia/mtproxy + +# Generate secret if not exists +if [ -f /opt/amnezia/mtproxy/secret ]; then + SECRET=$(cat /opt/amnezia/mtproxy/secret) + echo "Using existing MTProxy secret" +else + SECRET=$(cat /dev/urandom | tr -dc a-f0-9 | head -c 32 || true) + echo "$SECRET" > /opt/amnezia/mtproxy/secret +fi + +# Store port +echo "$MTPROXY_PORT" > /opt/amnezia/mtproxy/port + +# Remove existing container +docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + +# Run MTProxy container (single line for heredoc compatibility) +docker run -d --name "$CONTAINER_NAME" --restart always -p "${MTPROXY_PORT}:443" -v /opt/amnezia/mtproxy:/data -e SECRET="$SECRET" telegrammessenger/proxy:latest + +sleep 3 + +# Get external IP +EXTERNAL_IP=$(curl -s -4 ifconfig.me 2>/dev/null || curl -s -4 icanhazip.com 2>/dev/null || echo "YOUR_SERVER_IP") + +echo "MTProxy installed successfully" +echo "Port: $MTPROXY_PORT" +echo "Secret: $SECRET" +echo "Server Host: $EXTERNAL_IP"', + '#!/bin/bash +set -euo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-amnezia-mtproxy}" + +docker stop "$CONTAINER_NAME" 2>/dev/null || true +docker rm -fv "$CONTAINER_NAME" 2>/dev/null || true +docker image rm telegrammessenger/proxy:latest 2>/dev/null || true +rm -rf /opt/amnezia/mtproxy 2>/dev/null || true + +echo "{\"success\":true,\"message\":\"MTProxy uninstalled\"}"', + 'tg://proxy?server={{server_host}}&port={{server_port}}&secret={{secret}}', + 1, + 1, + 1, + JSON_OBJECT( + 'engine', 'shell', + 'metadata', JSON_OBJECT( + 'container_name', 'amnezia-mtproxy', + 'port_range', JSON_ARRAY(30000, 65000), + 'config_dir', '/opt/amnezia/mtproxy' + ) + ), + NOW(), + NOW() +WHERE NOT EXISTS (SELECT 1 FROM protocols WHERE slug = 'mtproxy'); + +-- 2. Add protocol variables for MTProxy +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'secret', 'string', '', 'MTProxy secret (32 hex chars)', true +FROM protocols p WHERE p.slug = 'mtproxy' + AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id = p.id AND variable_name = 'secret'); + +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'server_host', 'string', '', 'Server hostname or IP', true +FROM protocols p WHERE p.slug = 'mtproxy' + AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id = p.id AND variable_name = 'server_host'); + +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'server_port', 'number', '443', 'MTProxy external port', true +FROM protocols p WHERE p.slug = 'mtproxy' + AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id = p.id AND variable_name = 'server_port'); + +-- 3. Add default template for MTProxy +INSERT INTO protocol_templates (protocol_id, template_name, template_content, is_default) +SELECT p.id, 'Default MTProxy', 'tg://proxy?server={{server_host}}&port={{server_port}}&secret={{secret}}', true +FROM protocols p WHERE p.slug = 'mtproxy' + AND NOT EXISTS (SELECT 1 FROM protocol_templates WHERE protocol_id = p.id AND template_name = 'Default MTProxy'); + +-- 4. Add QR code template (same as output) +UPDATE protocols SET + qr_code_template = 'tg://proxy?server={{server_host}}&port={{server_port}}&secret={{secret}}', + qr_code_format = 'raw' +WHERE slug = 'mtproxy'; + +-- 5. Add translations for MTProxy +INSERT INTO translations (locale, category, key_name, translation) VALUES +('en', 'protocols', 'protocol_mtproxy', 'MTProxy (Telegram)') +ON DUPLICATE KEY UPDATE translation = VALUES(translation); + +INSERT INTO translations (locale, category, key_name, translation) VALUES +('ru', 'protocols', 'protocol_mtproxy', 'MTProxy (Telegram)') +ON DUPLICATE KEY UPDATE translation = VALUES(translation); diff --git a/migrations/060_add_aivpn_protocol.sql b/migrations/060_add_aivpn_protocol.sql new file mode 100644 index 0000000..aa7b218 --- /dev/null +++ b/migrations/060_add_aivpn_protocol.sql @@ -0,0 +1,153 @@ +-- ===================================================================== +-- Migration 060: Add AIVPN protocol (AI-powered VPN with traffic disguise) +-- https://github.com/infosave2007/aivpn +-- Neural Resonance AI for DPI bypass, Zero-RTT, PFS +-- ===================================================================== + +-- 1. Insert the AIVPN protocol +INSERT INTO protocols (name, slug, description, install_script, uninstall_script, output_template, show_text_content, ubuntu_compatible, is_active, definition, created_at, updated_at) +SELECT + 'AIVPN', + 'aivpn', + 'AIVPN — AI-powered VPN с маскировкой трафика под реальные приложения (Zoom, TikTok, DNS). Neural Resonance для обхода DPI.', + '#!/bin/bash +set -euo pipefail + +# Use exported variables from panel (SERVER_PORT, SERVER_CONTAINER) or defaults +CONTAINER_NAME="${SERVER_CONTAINER:-aivpn-server}" +VPN_PORT="${SERVER_PORT:-443}" +CONFIG_DIR="/etc/aivpn" + +# Install git and iptables if not available +if ! command -v git &> /dev/null || ! command -v iptables &> /dev/null; then + apt-get update -qq + if ! command -v git &> /dev/null; then + apt-get install -y -qq git >/dev/null 2>&1 + fi + if ! command -v iptables &> /dev/null; then + apt-get install -y -qq iptables >/dev/null 2>&1 + fi +fi + +# Install Docker if not available +if ! command -v docker &> /dev/null; then + apt-get update -qq + apt-get install -y -qq apt-transport-https ca-certificates curl gnupg lsb-release >/dev/null 2>&1 + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list + apt-get update -qq && apt-get install -y -qq docker-ce docker-ce-cli containerd.io >/dev/null 2>&1 +fi + +mkdir -p "$CONFIG_DIR" + +# Enable IP forwarding +sysctl -w net.ipv4.ip_forward=1 2>/dev/null || true + +# Generate server key if not exists +if [ ! -f "$CONFIG_DIR/server.key" ]; then + openssl rand 32 > "$CONFIG_DIR/server.key" + chmod 600 "$CONFIG_DIR/server.key" + echo "Generated new AIVPN server key" +else + echo "Using existing AIVPN server key" +fi + +# Setup NAT +iptables -t nat -C POSTROUTING -s 10.0.0.0/24 -o eth0 -j MASQUERADE 2>/dev/null || \ + iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o eth0 -j MASQUERADE + +# Get external IP +EXTERNAL_IP=$(curl -s -4 ifconfig.me 2>/dev/null || curl -s -4 icanhazip.com 2>/dev/null || echo "YOUR_SERVER_IP") + +# Clone AIVPN source for Docker build +if [ ! -d /opt/amnezia/aivpn ]; then + git clone --depth=1 https://github.com/infosave2007/aivpn.git /opt/amnezia/aivpn +fi + +# Build Docker image +cd /opt/amnezia/aivpn +docker build --no-cache -t aivpn-server -f Dockerfile . + +# Remove existing container +docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + +# Run AIVPN container +docker run -d --name "$CONTAINER_NAME" --restart always --cap-add=NET_ADMIN --device /dev/net/tun --network host -v "$CONFIG_DIR:/etc/aivpn" aivpn-server --listen "0.0.0.0:${VPN_PORT}" --key-file /etc/aivpn/server.key + +sleep 3 + +# Check container status +STATUS=$(docker inspect --format="{{.State.Status}}" "$CONTAINER_NAME" 2>/dev/null || echo "unknown") +if [ "$STATUS" != "running" ]; then + echo "ERROR: AIVPN container is not running" + docker logs "$CONTAINER_NAME" 2>&1 + exit 1 +fi + +echo "AIVPN installed successfully" +echo "Port: $VPN_PORT" +echo "ExternalIP: $EXTERNAL_IP" +echo "ConfigDir: $CONFIG_DIR"', + '#!/bin/bash +set -euo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-aivpn-server}" + +docker stop "$CONTAINER_NAME" 2>/dev/null || true +docker rm -fv "$CONTAINER_NAME" 2>/dev/null || true +docker image rm aivpn-server 2>/dev/null || true +rm -rf /opt/amnezia/aivpn 2>/dev/null || true + +# Remove NAT rules +iptables -t nat -D POSTROUTING -s 10.0.0.0/24 -o eth0 -j MASQUERADE 2>/dev/null || true + +echo "{\"success\":true,\"message\":\"AIVPN uninstalled\"}"', + 'aivpn://{{connection_key}}', + 1, + 1, + 1, + JSON_OBJECT( + 'engine', 'shell', + 'metadata', JSON_OBJECT( + 'container_name', 'aivpn-server', + 'port_range', JSON_ARRAY(443, 443), + 'config_dir', '/etc/aivpn', + 'vpn_subnet', '10.0.0.0/24', + 'requires_docker_build', true, + 'git_repo', 'https://github.com/infosave2007/aivpn.git' + ) + ), + NOW(), + NOW() +WHERE NOT EXISTS (SELECT 1 FROM protocols WHERE slug = 'aivpn'); + +-- 2. Add protocol variables for AIVPN +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'connection_key', 'string', '', 'AIVPN connection key (generated by server)', true +FROM protocols p WHERE p.slug = 'aivpn' + AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id = p.id AND variable_name = 'connection_key'); + +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'server_host', 'string', '', 'Server hostname or IP', true +FROM protocols p WHERE p.slug = 'aivpn' + AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id = p.id AND variable_name = 'server_host'); + +INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required) +SELECT p.id, 'server_port', 'number', '443', 'AIVPN server port', true +FROM protocols p WHERE p.slug = 'aivpn' + AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id = p.id AND variable_name = 'server_port'); + +-- 3. Add default template for AIVPN +INSERT INTO protocol_templates (protocol_id, template_name, template_content, is_default) +SELECT p.id, 'Default AIVPN', 'aivpn://{{connection_key}}', true +FROM protocols p WHERE p.slug = 'aivpn' + AND NOT EXISTS (SELECT 1 FROM protocol_templates WHERE protocol_id = p.id AND template_name = 'Default AIVPN'); + +-- 4. Add translations for AIVPN +INSERT INTO translations (locale, category, key_name, translation) VALUES +('en', 'protocols', 'protocol_aivpn', 'AIVPN (AI-Powered)') +ON DUPLICATE KEY UPDATE translation = VALUES(translation); + +INSERT INTO translations (locale, category, key_name, translation) VALUES +('ru', 'protocols', 'protocol_aivpn', 'AIVPN (ИИ-протокол)') +ON DUPLICATE KEY UPDATE translation = VALUES(translation); diff --git a/public/index.php b/public/index.php index 4926f5a..22071dc 100644 --- a/public/index.php +++ b/public/index.php @@ -1152,7 +1152,7 @@ Router::get('/clients/{id}', function ($params) { } if ($protocol && ($protocol['output_template'] ?? '') !== '') { $slug = $protocol['slug'] ?? ''; - $isWireguard = in_array($slug, ['amnezia-wg-advanced', 'wireguard-standard', 'amnezia-wg'], true); + $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 $protocolOutput = ''; @@ -1771,6 +1771,8 @@ Router::post('/api/servers/create', function () { 'port' => $port, 'username' => $username, 'password' => $password, + 'install_protocol' => trim($input['install_protocol'] ?? ''), + 'install_options' => $input['install_options'] ?? null, ]); http_response_code(201); From fb2ab2aa5c1f3062ecf435e62a8588cabe05576a Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 4 Apr 2026 10:31:15 +0300 Subject: [PATCH 58/72] feat: configure docker-in-docker container with host networking and custom daemon settings --- controllers/ProtocolManagementController.php | 4 ++-- docker-compose.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/controllers/ProtocolManagementController.php b/controllers/ProtocolManagementController.php index 9abb5c6..edbe56d 100644 --- a/controllers/ProtocolManagementController.php +++ b/controllers/ProtocolManagementController.php @@ -532,9 +532,9 @@ SH; $rm = $this->runHostCommandChecked('docker rm -f ' . escapeshellarg($container) . ' >/dev/null 2>&1 || true'); $send(['type' => 'cmd_done', 'rc' => $rm['rc']]); - $cmdRun = 'docker run --privileged -d --name ' . $container . ' ubuntu:22.04 sleep infinity'; + $cmdRun = 'docker run --network host --privileged -d --name ' . $container . ' ubuntu:22.04 sleep infinity'; $send(['type' => 'cmd', 'cmd' => $cmdRun]); - $run = $this->runHostCommandChecked('docker run --privileged -d -v /var/run/docker.sock:/var/run/docker.sock --name ' . escapeshellarg($container) . ' ubuntu:22.04 sleep infinity'); + $run = $this->runHostCommandChecked('docker run --network host --privileged -d -v /var/run/docker.sock:/var/run/docker.sock --name ' . escapeshellarg($container) . ' ubuntu:22.04 sleep infinity'); if ($run['rc'] !== 0) { $send(['type' => 'error', 'error' => 'Docker not accessible: ' . trim($run['out'])]); return; diff --git a/docker-compose.yml b/docker-compose.yml index b153990..07c05f4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,7 +57,7 @@ services: restart: unless-stopped environment: DOCKER_TLS_CERTDIR: "" - + command: ["dockerd", "--host=tcp://0.0.0.0:2375", "--host=unix:///var/run/docker.sock", "--tls=false", "--dns=8.8.8.8", "--dns=1.1.1.1", "--mtu=1200"] volumes: - dind_data:/var/lib/docker From 0ee6d9a01aba6f580a2ca38b2c1a93f2353cf94b Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 4 Apr 2026 10:47:26 +0300 Subject: [PATCH 59/72] fix: mount necessary configuration and data directories to protocol container --- controllers/ProtocolManagementController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controllers/ProtocolManagementController.php b/controllers/ProtocolManagementController.php index edbe56d..8ecb7ac 100644 --- a/controllers/ProtocolManagementController.php +++ b/controllers/ProtocolManagementController.php @@ -534,7 +534,7 @@ SH; $cmdRun = 'docker run --network host --privileged -d --name ' . $container . ' ubuntu:22.04 sleep infinity'; $send(['type' => 'cmd', 'cmd' => $cmdRun]); - $run = $this->runHostCommandChecked('docker run --network host --privileged -d -v /var/run/docker.sock:/var/run/docker.sock --name ' . escapeshellarg($container) . ' ubuntu:22.04 sleep infinity'); + $run = $this->runHostCommandChecked('docker run --network host --privileged -d -v /var/run/docker.sock:/var/run/docker.sock -v /opt/amnezia:/opt/amnezia -v /etc/aivpn:/etc/aivpn -v /etc/amnezia:/etc/amnezia --name ' . escapeshellarg($container) . ' ubuntu:22.04 sleep infinity'); if ($run['rc'] !== 0) { $send(['type' => 'error', 'error' => 'Docker not accessible: ' . trim($run['out'])]); return; From ec5e045ab6774bf444a804ee5f43cefe506cacc6 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 4 Apr 2026 11:02:06 +0300 Subject: [PATCH 60/72] feat: update AIVPN migration script to output connection variables for web panel parsing --- migrations/060_add_aivpn_protocol.sql | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/migrations/060_add_aivpn_protocol.sql b/migrations/060_add_aivpn_protocol.sql index aa7b218..76f44bb 100644 --- a/migrations/060_add_aivpn_protocol.sql +++ b/migrations/060_add_aivpn_protocol.sql @@ -85,9 +85,12 @@ if [ "$STATUS" != "running" ]; then fi echo "AIVPN installed successfully" -echo "Port: $VPN_PORT" -echo "ExternalIP: $EXTERNAL_IP" -echo "ConfigDir: $CONFIG_DIR"', +# Output variables for the web panel parser +KEY_B64=$(base64 -w 0 "$CONFIG_DIR/server.key" 2>/dev/null || base64 "$CONFIG_DIR/server.key") +echo "Variable: connection_key=$KEY_B64" +echo "Variable: server_host=$EXTERNAL_IP" +echo "Variable: server_port=$VPN_PORT" +echo "Variable: config_dir=$CONFIG_DIR"', '#!/bin/bash set -euo pipefail From b180864e0d0751a12c6ace6ef69debd467148647 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 4 Apr 2026 12:45:04 +0300 Subject: [PATCH 61/72] feat: enhance VpnClient and VpnServer for improved command execution and configuration handling --- .gitignore | 4 +++ inc/VpnClient.php | 66 +++++++++++++++++++++++++++++++++++------------ inc/VpnServer.php | 27 +++++++++++++++++-- 3 files changed, 79 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index 60a7113..cbf409e 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,7 @@ scripts/test_xray_install.sh scripts/test_online.php API_AWG_DOCS.md log.txt +scripts/bootstrap_awg_container.sh +scripts/fix_server_visibility.sh +scripts/remote_fix_client_create.sh +scripts/retest_client_api.sh diff --git a/inc/VpnClient.php b/inc/VpnClient.php index bf3ff85..6dae8a2 100644 --- a/inc/VpnClient.php +++ b/inc/VpnClient.php @@ -741,6 +741,8 @@ class VpnClient private static function syncServerKeysFromContainer(VpnServer $server, array $serverData): void { $containerName = $serverData['container_name'] ?? 'amnezia-awg'; + $protocolSlug = (string) ($serverData['install_protocol'] ?? ''); + $primaryConfigDir = $protocolSlug === 'awg2' ? '/opt/amnezia/awg2' : '/opt/amnezia/awg'; try { // Try to get public key from wg show @@ -755,23 +757,33 @@ class VpnClient // Prefer that file (stable) and fall back to parsing the first peer PSK from wg0.conf. $psk = ''; - $pskKeyFileCmd = "docker exec $containerName sh -c \"cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true\""; + $pskKeyFileCmd = "docker exec $containerName sh -c \"cat $primaryConfigDir/wireguard_psk.key 2>/dev/null || cat /opt/amnezia/awg/wireguard_psk.key 2>/dev/null || true\""; $psk = trim($server->executeCommand($pskKeyFileCmd, true)); if ($psk === '') { - $pskFromConfCmd = "docker exec $containerName sh -c \"grep -E '^[[:space:]]*PresharedKey[[:space:]]*=' /opt/amnezia/awg/wg0.conf 2>/dev/null | head -1 | sed -E 's/^[[:space:]]*PresharedKey[[:space:]]*=[[:space:]]*//' | tr -d '\\r'\" 2>/dev/null || true"; + $pskFromConfCmd = "docker exec $containerName sh -c \"grep -E '^[[:space:]]*PresharedKey[[:space:]]*=' $primaryConfigDir/wg0.conf 2>/dev/null | head -1 | sed -E 's/^[[:space:]]*PresharedKey[[:space:]]*=[[:space:]]*//' | tr -d '\\r'\" 2>/dev/null || true"; $psk = trim($server->executeCommand($pskFromConfCmd, true)); } + if ($psk === '' && $primaryConfigDir !== '/opt/amnezia/awg') { + $pskFromAwgConfCmd = "docker exec $containerName sh -c \"grep -E '^[[:space:]]*PresharedKey[[:space:]]*=' /opt/amnezia/awg/wg0.conf 2>/dev/null | head -1 | sed -E 's/^[[:space:]]*PresharedKey[[:space:]]*=[[:space:]]*//' | tr -d '\\r'\" 2>/dev/null || true"; + $psk = trim($server->executeCommand($pskFromAwgConfCmd, true)); + } + if ($psk === '') { $pskFromAltConfCmd = "docker exec $containerName sh -c \"grep -E '^[[:space:]]*PresharedKey[[:space:]]*=' /etc/wireguard/wg0.conf 2>/dev/null | head -1 | sed -E 's/^[[:space:]]*PresharedKey[[:space:]]*=[[:space:]]*//' | tr -d '\\r'\" 2>/dev/null || true"; $psk = trim($server->executeCommand($pskFromAltConfCmd, true)); } // Extract DNS from config - $dnsCmd = "docker exec $containerName sh -c \"grep -E '^DNS' /opt/amnezia/awg/wg0.conf 2>/dev/null | head -1 | cut -d= -f2 | tr -d '[:space:]'\" 2>/dev/null || echo ''"; + $dnsCmd = "docker exec $containerName sh -c \"grep -E '^DNS' $primaryConfigDir/wg0.conf 2>/dev/null | head -1 | cut -d= -f2 | tr -d '[:space:]'\" 2>/dev/null || echo ''"; $dns = trim($server->executeCommand($dnsCmd, true)); + if (empty($dns) && $primaryConfigDir !== '/opt/amnezia/awg') { + $dnsAwgCmd = "docker exec $containerName sh -c \"grep -E '^DNS' /opt/amnezia/awg/wg0.conf 2>/dev/null | head -1 | cut -d= -f2 | tr -d '[:space:]'\" 2>/dev/null || echo ''"; + $dns = trim($server->executeCommand($dnsAwgCmd, true)); + } + if (empty($dns)) { // Try alternative config location $dnsCmd2 = "docker exec $containerName sh -c \"grep -E '^DNS' /etc/wireguard/wg0.conf 2>/dev/null | head -1 | cut -d= -f2 | tr -d '[:space:]'\" 2>/dev/null || echo ''"; @@ -800,7 +812,10 @@ class VpnClient // Primary source: wg0.conf if (empty($awgParams)) { - $awgParams = self::extractAwgParamsFromWg0Conf($server, $containerName, '/opt/amnezia/awg/wg0.conf'); + $awgParams = self::extractAwgParamsFromWg0Conf($server, $containerName, $primaryConfigDir . '/wg0.conf'); + if (empty($awgParams) && $primaryConfigDir !== '/opt/amnezia/awg') { + $awgParams = self::extractAwgParamsFromWg0Conf($server, $containerName, '/opt/amnezia/awg/wg0.conf'); + } if (empty($awgParams)) { $awgParams = self::extractAwgParamsFromWg0Conf($server, $containerName, '/etc/wireguard/wg0.conf'); } @@ -954,21 +969,40 @@ class VpnClient */ private static function executeServerCommand(array $serverData, string $command, bool $sudo = false): string { - if ($sudo && strtolower($serverData['username']) !== 'root') { - $command = "echo '{$serverData['password']}' | sudo -S " . $command; + $needsSudo = $sudo && strtolower((string) ($serverData['username'] ?? '')) !== 'root'; + $baseCommand = $command; + + if ($needsSudo) { + // Suppress sudo prompt noise in stdout to keep parser output stable. + $command = "echo '{$serverData['password']}' | sudo -S -p '' " . $command; } - $escapedCommand = escapeshellarg($command); - $sshCommand = sprintf( - "sshpass -p '%s' ssh -p %d -q -o LogLevel=ERROR -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o PreferredAuthentications=password -o PubkeyAuthentication=no %s@%s %s 2>&1", - $serverData['password'], - $serverData['port'], - $serverData['username'], - $serverData['host'], - $escapedCommand - ); + $run = static function (string $cmd) use ($serverData): string { + $escapedCommand = escapeshellarg($cmd); + $sshCommand = sprintf( + "sshpass -p '%s' ssh -p %d -q -o LogLevel=ERROR -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o PreferredAuthentications=password -o PubkeyAuthentication=no %s@%s %s 2>&1", + $serverData['password'], + $serverData['port'], + $serverData['username'], + $serverData['host'], + $escapedCommand + ); - return shell_exec($sshCommand) ?? ''; + return shell_exec($sshCommand) ?? ''; + }; + + $output = $run($command); + + // If sudo auth fails but docker is available without sudo (docker group), retry without sudo. + if ( + $needsSudo + && preg_match('/(^|\\n)docker(\\s|$)/', ltrim($baseCommand)) + && preg_match('/incorrect password attempts|sorry, try again|a password is required/i', $output) + ) { + $output = $run($baseCommand); + } + + return $output; } /** diff --git a/inc/VpnServer.php b/inc/VpnServer.php index 54d3fa5..ababa46 100644 --- a/inc/VpnServer.php +++ b/inc/VpnServer.php @@ -427,6 +427,7 @@ class VpnServer */ public function executeCommand(string $command, bool $sudo = false): string { + $baseCommand = $command; $escapedCommand = escapeshellarg($command); // Determine auth method @@ -448,8 +449,10 @@ class VpnServer $escapedCommand ); } else { - if ($sudo && strtolower($this->data['username']) !== 'root') { - $command = "echo '{$this->data['password']}' | sudo -S " . $command; + $needsSudo = $sudo && strtolower((string) ($this->data['username'] ?? '')) !== 'root'; + if ($needsSudo) { + // Suppress sudo prompt text to keep command output machine-parseable. + $command = "echo '{$this->data['password']}' | sudo -S -p '' " . $command; $escapedCommand = escapeshellarg($command); } @@ -467,6 +470,26 @@ class VpnServer $output = shell_exec($sshCommand) ?? ''; + // If sudo auth fails but user can run docker without sudo, retry docker commands directly. + if ( + empty($this->data['ssh_key']) + && !empty($needsSudo) + && preg_match('/(^|\\n)docker(\\s|$)/', ltrim($baseCommand)) + && preg_match('/incorrect password attempts|sorry, try again|a password is required/i', $output) + ) { + $escapedBaseCommand = escapeshellarg($baseCommand); + $sshCommandNoSudo = sprintf( + "sshpass -p '%s' ssh -p %d %s %s@%s %s 2>&1", + $this->data['password'], + $this->data['port'], + $sshOptions, + $this->data['username'], + $this->data['host'], + $escapedBaseCommand + ); + $output = shell_exec($sshCommandNoSudo) ?? ''; + } + if ($keyFile && file_exists($keyFile)) { unlink($keyFile); } From 0bc23e11db6c8af494ad24b3b76e08e2374781fd Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 4 Apr 2026 13:59:37 +0300 Subject: [PATCH 62/72] feat: add AWG2 protocol support and enhance API documentation for protocol management --- .gitignore | 1 + API_EXAMPLES.md | 28 ++++++++++++++++++++++++ README.md | 9 ++++++-- inc/InstallProtocolManager.php | 32 ++++++++++++++++++++++++---- migrations/058_add_awg2_protocol.sql | 18 ++++++++-------- public/index.php | 8 +++++++ 6 files changed, 81 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index cbf409e..4c5b09e 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,4 @@ scripts/bootstrap_awg_container.sh scripts/fix_server_visibility.sh scripts/remote_fix_client_create.sh scripts/retest_client_api.sh +scripts/awg2_retest_final.sh diff --git a/API_EXAMPLES.md b/API_EXAMPLES.md index d813884..cfda2e1 100644 --- a/API_EXAMPLES.md +++ b/API_EXAMPLES.md @@ -18,6 +18,34 @@ Response: } ``` +## Protocols + +### List Active Protocols (for JWT API clients) +```bash +curl -X GET http://localhost:8082/api/protocols/active \ + -H "Authorization: Bearer $TOKEN" +``` + +Example response: +```json +{ + "success": true, + "protocols": [ + {"id": 11, "slug": "awg2", "name": "AmneziaWG 2.0"}, + {"id": 13, "slug": "aivpn", "name": "AIVPN"}, + {"id": 12, "slug": "mtproxy", "name": "MTProxy (Telegram)"} + ] +} +``` + +### Install Protocol on Server +```bash +curl -X POST http://localhost:8082/api/servers/1/protocols/install \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"protocol_id":11}' +``` + ## Clients ### Create Client with QR Code diff --git a/README.md b/README.md index 8a9c206..a63bb07 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,13 @@ cp .env.example .env docker compose up -d docker compose exec web composer install +# Ensure all SQL migrations are applied (safe to run repeatedly) +docker compose exec -T db sh -lc 'for f in /docker-entrypoint-initdb.d/*.sql; do mysql -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE" < "$f" || true; done' + # Or for older Docker Compose V1 docker-compose up -d docker-compose exec web composer install +docker-compose exec -T db sh -lc 'for f in /docker-entrypoint-initdb.d/*.sql; do mysql -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE" < "$f" || true; done' ``` Access: http://localhost:8082 @@ -54,7 +58,7 @@ DB_HOST=db DB_PORT=3306 DB_DATABASE=amnezia_panel DB_USERNAME=amnezia -DB_PASSWORD=amnezia123 +DB_PASSWORD=amnezia ADMIN_EMAIL=admin@amnez.ia ADMIN_PASSWORD=admin123 @@ -255,7 +259,8 @@ GET /api/servers/{id}/clients - List clients on server ### Protocols ``` -GET /api/protocols/active - List all available protocols (with IDs) +GET /api/protocols/active - List all available protocols (JWT-friendly, includes protocol IDs) +GET /api/protocols - Protocol management endpoint (requires session admin auth, not JWT) GET /api/servers/{id}/protocols - List installed protocols on server POST /api/servers/{id}/protocols/install - Install protocol ``` diff --git a/inc/InstallProtocolManager.php b/inc/InstallProtocolManager.php index 24c8975..a393b45 100644 --- a/inc/InstallProtocolManager.php +++ b/inc/InstallProtocolManager.php @@ -278,25 +278,46 @@ class InstallProtocolManager try { Logger::appendInstall($serverId, 'Running scripted install...'); + $metadata = $protocol['definition']['metadata'] ?? []; // Choose/ensure VPN UDP port for script-driven installs if (($protocol['slug'] ?? '') === 'xray-vless' && (!isset($options['server_port']) || !is_int($options['server_port']) || $options['server_port'] <= 0)) { $options['server_port'] = 443; } if (!isset($options['server_port']) || !is_int($options['server_port'])) { - $options['server_port'] = self::chooseServerPort($server, $protocol['definition']['metadata'] ?? []); + $options['server_port'] = self::chooseServerPort($server, $metadata); } $result = self::runScript($server, $protocol, 'install', $options); if (!isset($result['success'])) { $result['success'] = true; } Logger::appendInstall($serverId, 'Scripted install finished: ' . json_encode($result)); + + $rawPort = $result['vpn_port'] ?? null; + $resolvedPort = (is_numeric($rawPort) && (int) $rawPort > 0) + ? (int) $rawPort + : ($options['server_port'] ?? null); + + $awgParams = $result['awg_params'] ?? null; + if (!is_array($awgParams)) { + $flat = []; + foreach (['Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'S3', 'S4', 'H1', 'H2', 'H3', 'H4'] as $k) { + if (array_key_exists($k, $result) && $result[$k] !== '' && $result[$k] !== null) { + $flat[$k] = $result[$k]; + } + } + if (!empty($flat)) { + $awgParams = $flat; + } + } + $extras = [ - 'vpn_port' => $result['vpn_port'] ?? ($options['server_port'] ?? null), + 'vpn_port' => $resolvedPort, 'server_public_key' => $result['server_public_key'] ?? null, 'preshared_key' => $result['preshared_key'] ?? null, - 'awg_params' => $result['awg_params'] ?? null, + 'awg_params' => $awgParams, 'secret' => $result['secret'] ?? null, 'server_host' => $result['server_host'] ?? null, + 'container_name' => $result['container_name'] ?? ($metadata['container_name'] ?? null), ]; if (($protocol['slug'] ?? '') === 'xray-vless') { foreach (['client_id', 'container_name', 'server_port', 'xray_port', 'reality_public_key', 'reality_private_key', 'reality_short_id', 'reality_server_name'] as $k) { @@ -563,7 +584,6 @@ class InstallProtocolManager $context = self::buildContext($server, $protocol, $options); $script = self::renderTemplate($scripts, $context); - $script = preg_replace('/<<\s*EOF\b/', "<<'EOF'", $script); $script = preg_replace('/\n\+\s*/', "\n", $script); $exportLines = self::buildExports($context); $wrapper = "bash <<'EOS'\nset -euo pipefail\n" . $exportLines . $script . "\nEOS"; @@ -685,6 +705,10 @@ class InstallProtocolManager $setParts[] = 'preshared_key = ?'; $params[] = (string) $extras['preshared_key']; } + if (isset($extras['container_name']) && $extras['container_name'] !== null && $extras['container_name'] !== '') { + $setParts[] = 'container_name = ?'; + $params[] = (string) $extras['container_name']; + } if (array_key_exists('awg_params', $extras)) { $awgParams = $extras['awg_params']; if (is_array($awgParams)) { diff --git a/migrations/058_add_awg2_protocol.sql b/migrations/058_add_awg2_protocol.sql index ab01fae..1d2e856 100644 --- a/migrations/058_add_awg2_protocol.sql +++ b/migrations/058_add_awg2_protocol.sql @@ -93,11 +93,11 @@ S1_VAL=50 S2_VAL=100 S3_VAL=20 S4_VAL=10 -# H1-H4: header ranges (string format "x-y" per AWG2 spec) -H1_VAL="1-4294967295" -H2_VAL="1-4294967295" -H3_VAL="1-4294967295" -H4_VAL="1-4294967295" +# H1-H4: keep numeric values for broad awg-tools compatibility. +H1_VAL=123456789 +H2_VAL=223456789 +H3_VAL=323456789 +H4_VAL=423456789 # Write config cat > /opt/amnezia/awg2/wg0.conf << EOF @@ -286,10 +286,10 @@ S1_VAL=50 S2_VAL=100 S3_VAL=20 S4_VAL=10 -H1_VAL="1-4294967295" -H2_VAL="1-4294967295" -H3_VAL="1-4294967295" -H4_VAL="1-4294967295" +H1_VAL=123456789 +H2_VAL=223456789 +H3_VAL=323456789 +H4_VAL=423456789 cat > /opt/amnezia/awg2/wg0.conf << EOF [Interface] diff --git a/public/index.php b/public/index.php index 22071dc..1faeb88 100644 --- a/public/index.php +++ b/public/index.php @@ -2549,6 +2549,14 @@ Router::post('/api/servers/{id}/protocols/install', function ($params) { } $result = InstallProtocolManager::activate($server, $protocol, []); + + // Keep API behavior consistent with UI flow: once protocol activation succeeds, + // clear transient error state and mark server as active for client creation. + if (is_array($result) && !empty($result['success'])) { + $pdo = DB::conn(); + $stmtUpdate = $pdo->prepare('UPDATE vpn_servers SET status = ?, error_message = NULL WHERE id = ?'); + $stmtUpdate->execute(['active', $serverId]); + } echo json_encode($result, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); } catch (Exception $e) { http_response_code(500); From 1c4b080ee5be190c0a17f8ede88825eaa5d16e99 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 4 Apr 2026 15:27:40 +0300 Subject: [PATCH 63/72] 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. --- inc/InstallProtocolManager.php | 142 ++++++++- inc/ServerMonitoring.php | 232 +++++++++++++- inc/VpnClient.php | 294 +++++++++++++++++- inc/VpnServer.php | 13 + ...nt_connection_instructions_translation.sql | 11 + migrations/062_add_aivpn_counter_offsets.sql | 7 + ...emote_reset_and_reinstall_all_protocols.sh | 69 ++++ templates/clients/view.twig | 2 +- 8 files changed, 741 insertions(+), 29 deletions(-) create mode 100644 migrations/061_fix_client_connection_instructions_translation.sql create mode 100644 migrations/062_add_aivpn_counter_offsets.sql create mode 100755 scripts/remote_reset_and_reinstall_all_protocols.sh diff --git a/inc/InstallProtocolManager.php b/inc/InstallProtocolManager.php index a393b45..09d2d5f 100644 --- a/inc/InstallProtocolManager.php +++ b/inc/InstallProtocolManager.php @@ -319,6 +319,9 @@ class InstallProtocolManager 'server_host' => $result['server_host'] ?? null, 'container_name' => $result['container_name'] ?? ($metadata['container_name'] ?? null), ]; + if (($protocol['slug'] ?? '') === 'aivpn' && array_key_exists('connection_key', $result)) { + $extras['connection_key'] = $result['connection_key']; + } if (($protocol['slug'] ?? '') === 'xray-vless') { foreach (['client_id', 'container_name', 'server_port', 'xray_port', 'reality_public_key', 'reality_private_key', 'reality_short_id', 'reality_server_name'] as $k) { if (array_key_exists($k, $result)) { @@ -575,6 +578,9 @@ class InstallProtocolManager ]; } if ($phase === 'add_client') { + if (($protocol['slug'] ?? '') === 'aivpn') { + return self::runBuiltinAivpnAddClient($server, $options); + } // If no script and no builtin handler, we just skip it (assume not needed or manual) // Or throw generic error? Better return success to not break flow if not implemented for other protocols return ['success' => true, 'message' => 'No add_client script defined']; @@ -775,10 +781,15 @@ class InstallProtocolManager $pairs = [ 'SERVER_HOST' => $serverData['host'] ?? '', 'SERVER_USER' => $serverData['username'] ?? '', - 'SERVER_CONTAINER' => $serverData['container_name'] ?? ($metadata['container_name'] ?? ''), - 'SERVER_PORT' => isset($serverData['vpn_port']) && (int) $serverData['vpn_port'] > 0 - ? (int) $serverData['vpn_port'] - : (isset($options['server_port']) ? (int) $options['server_port'] : ''), + // Prefer protocol-specific settings for scripted installs to avoid + // reusing a container name/port from another protocol on same server. + 'SERVER_CONTAINER' => $options['container_name'] + ?? ($metadata['container_name'] ?? ($serverData['container_name'] ?? '')), + 'SERVER_PORT' => isset($options['server_port']) && (int) $options['server_port'] > 0 + ? (int) $options['server_port'] + : (isset($serverData['vpn_port']) && (int) $serverData['vpn_port'] > 0 + ? (int) $serverData['vpn_port'] + : ''), ]; // Check for saved Reality keys in server_protocols table @@ -876,7 +887,7 @@ class InstallProtocolManager private static function parseWireGuardConfig(string $config): array { $lines = preg_split('/\r?\n/', $config); - $awgKeys = ['Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4']; + $awgKeys = ['Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'S3', 'S4', 'H1', 'H2', 'H3', 'H4']; $awgParams = []; $listenPort = null; @@ -1292,6 +1303,127 @@ class InstallProtocolManager } } + private static function runBuiltinAivpnAddClient(VpnServer $server, array $options): array + { + $serverData = $server->getData(); + $containerName = trim((string) ($options['container_name'] ?? ($serverData['container_name'] ?? ''))); + if ($containerName === '' || stripos($containerName, 'aivpn') === false) { + $containerName = 'aivpn-server'; + } + + $clientName = trim((string) ($options['login'] ?? ($options['name'] ?? ''))); + if ($clientName === '') { + $clientName = 'client-' . date('YmdHis'); + } + + $serverHostRaw = trim((string) ($options['server_host'] ?? ($serverData['host'] ?? ''))); + $serverHostSanitized = preg_replace('#^https?://#i', '', $serverHostRaw); + $serverHostSanitized = preg_replace('#/.*$#', '', $serverHostSanitized ?? ''); + $serverHost = $serverHostSanitized; + $embeddedPort = null; + if ($serverHostSanitized !== '' && preg_match('/^(.+?)(?::\d+)+$/', $serverHostSanitized, $m)) { + $serverHost = trim((string) $m[1]); + if (preg_match('/:(\d+)$/', $serverHostSanitized, $pm)) { + $embeddedPort = (int) $pm[1]; + } + } + + $defaultPort = 443; + if (stripos((string) ($serverData['install_protocol'] ?? ''), 'aivpn') !== false && (int) ($serverData['vpn_port'] ?? 0) > 0) { + $defaultPort = (int) $serverData['vpn_port']; + } + $serverPort = isset($options['server_port']) ? (int) $options['server_port'] : 0; + if ($serverPort <= 0 && $embeddedPort !== null && $embeddedPort > 0) { + $serverPort = $embeddedPort; + } + if ($serverPort <= 0) { + $serverPort = $defaultPort; + } + if ( + stripos((string) ($serverData['install_protocol'] ?? ''), 'aivpn') === false && + $embeddedPort === null && + (int) ($serverData['vpn_port'] ?? 0) > 0 && + $serverPort === (int) $serverData['vpn_port'] + ) { + $serverPort = 443; + } + if ($serverPort <= 0) { + $serverPort = 443; + } + + $cmdParts = [ + 'docker', + 'exec', + '-i', + escapeshellarg($containerName), + 'aivpn-server', + '--add-client', + escapeshellarg($clientName), + '--key-file', + '/etc/aivpn/server.key', + '--clients-db', + '/etc/aivpn/clients.json', + ]; + + if ($serverHost !== '') { + $cmdParts[] = '--server-ip'; + $cmdParts[] = escapeshellarg($serverHost . ':' . $serverPort); + } + + $cmd = implode(' ', $cmdParts); + Logger::appendInstall($server->getId(), 'Adding AIVPN client via builtin add_client: ' . $clientName . ' in ' . $containerName); + $output = (string) $server->executeCommand($cmd, true); + $parsed = self::parseAivpnAddClientOutput($output); + + if (empty($parsed['connection_uri']) && empty($parsed['connection_key'])) { + $head = substr(str_replace(["\r", "\n"], ' ', trim($output)), 0, 220); + throw new Exception('AIVPN add_client succeeded but no connection key found in output: ' . $head); + } + + $result = ['success' => true]; + if (!empty($parsed['connection_uri'])) { + $result['connection_uri'] = $parsed['connection_uri']; + } + if (!empty($parsed['connection_key'])) { + $result['connection_key'] = $parsed['connection_key']; + } + if (!empty($parsed['client_ip'])) { + $result['client_ip'] = $parsed['client_ip']; + } + if (!empty($parsed['client_id'])) { + $result['client_id'] = $parsed['client_id']; + } + + return $result; + } + + private static function parseAivpnAddClientOutput(string $output): array + { + $result = []; + $trimmed = trim($output); + if ($trimmed === '') { + return $result; + } + + if (preg_match('/(aivpn:\/\/[A-Za-z0-9_\-+=\/]+)/', $trimmed, $m)) { + $uri = trim((string) $m[1]); + $result['connection_uri'] = $uri; + if (stripos($uri, 'aivpn://') === 0) { + $result['connection_key'] = substr($uri, strlen('aivpn://')); + } + } + + if (preg_match('/\bID:\s*([a-zA-Z0-9]+)/', $trimmed, $m)) { + $result['client_id'] = trim((string) $m[1]); + } + + if (preg_match('/\bVPN\s*IP:\s*([0-9.]+)/i', $trimmed, $m)) { + $result['client_ip'] = trim((string) $m[1]); + } + + return $result; + } + private static function runBuiltinXrayAddClient(VpnServer $server, array $options): array { $clientId = $options['client_id'] ?? null; diff --git a/inc/ServerMonitoring.php b/inc/ServerMonitoring.php index 67fc367..e7ef4e9 100644 --- a/inc/ServerMonitoring.php +++ b/inc/ServerMonitoring.php @@ -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 diff --git a/inc/VpnClient.php b/inc/VpnClient.php index 6dae8a2..08cf493 100644 --- a/inc/VpnClient.php +++ b/inc/VpnClient.php @@ -126,7 +126,7 @@ class VpnClient } // Add AWG parameters (use UPPERCASE keys internal logic) - foreach (['JC', 'JMIN', 'JMAX', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4'] as $key) { + foreach (['JC', 'JMIN', 'JMAX', 'S1', 'S2', 'S3', 'S4', 'H1', 'H2', 'H3', 'H4'] as $key) { if (isset($cleanAwgParams[$key])) { $vars[$key] = $cleanAwgParams[$key]; } else { @@ -137,6 +137,8 @@ class VpnClient 'JMAX' => 200, 'S1' => 50, 'S2' => 100, + 'S3' => 20, + 'S4' => 10, 'H1' => 1, 'H2' => 2, 'H3' => 3, @@ -213,7 +215,7 @@ class VpnClient foreach ($extras as $k => $v) { if (is_scalar($v)) { // Preserve uppercase for AWG obfuscation parameters - if (in_array($k, ['Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4'], true)) { + if (in_array($k, ['Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'S3', 'S4', 'H1', 'H2', 'H3', 'H4'], true)) { $vars[$k] = (string) $v; } else { $vars[strtolower($k)] = (string) $v; @@ -379,6 +381,10 @@ class VpnClient } } } + if ($slug === 'aivpn') { + // Canonical connection key should come from AIVPN --add-client output. + // We keep fallback generation later only if add_client flow didn't provide a key. + } $pass = null; $pwdCmd = isset($protoRow['password_command']) ? trim((string) $protoRow['password_command']) : ''; if ($pwdCmd !== '') { @@ -418,13 +424,59 @@ class VpnClient // For xray-vless it uses builtin fallback in runScript. try { require_once __DIR__ . '/InstallProtocolManager.php'; - InstallProtocolManager::addClient($server, $protoRow, $vars); + $addClientResult = InstallProtocolManager::addClient($server, $protoRow, $vars); + if (is_array($addClientResult)) { + foreach ($addClientResult as $rk => $rv) { + if (!is_scalar($rv)) { + continue; + } + $key = (string) $rk; + $value = trim((string) $rv); + if ($value === '') { + continue; + } + $vars[$key] = $value; + $vars[strtolower($key)] = $value; + } + + if ($slug === 'aivpn') { + if (empty($vars['connection_key']) && !empty($vars['connection_uri']) && stripos((string) $vars['connection_uri'], 'aivpn://') === 0) { + $vars['connection_key'] = substr((string) $vars['connection_uri'], strlen('aivpn://')); + } + if (!empty($vars['client_ip']) && preg_match('/^\d{1,3}(?:\.\d{1,3}){3}$/', (string) $vars['client_ip'])) { + $clientIP = (string) $vars['client_ip']; + $vars['client_ip'] = $clientIP; + } + } + } } catch (Exception $e) { error_log("Failed to add client to server: " . $e->getMessage()); throw $e; } } + if ($slug === 'aivpn' && empty($vars['connection_key'])) { + try { + $rawKey = trim((string) $server->executeCommand('cat /etc/aivpn/server.key 2>/dev/null', true)); + if ($rawKey !== '' && !empty($vars['client_ip']) && !empty($vars['server_host']) && !empty($vars['server_port'])) { + $payload = [ + 'i' => (string) $vars['client_ip'], + 'k' => $rawKey, + 'p' => '', + 's' => (string) $vars['server_host'] . ':' . (string) $vars['server_port'], + ]; + $json = (string) json_encode($payload, JSON_UNESCAPED_SLASHES); + $vars['connection_key'] = rtrim(strtr(base64_encode($json), '+/', '-_'), '='); + } + } catch (Exception $e) { + // Keep empty: final template output will expose a missing key. + } + } + + if ($slug === 'aivpn' && !empty($vars['connection_key'])) { + $vars['connection_key'] = self::normalizeAivpnConnectionKey((string) $vars['connection_key']); + } + $config = $protoRow ? ProtocolService::generateProtocolOutput($protoRow, $vars) : ''; // Prepare last_config_json for QR code generation if config is JSON (XRay) @@ -467,6 +519,46 @@ class VpnClient return (int) $pdo->lastInsertId(); } + private static function normalizeAivpnConnectionKey(string $key): string + { + $key = trim($key); + if ($key === '') { + return $key; + } + + $decoded = base64_decode(strtr($key, '-_', '+/'), true); + if ($decoded === false) { + $padLen = strlen($key) % 4; + $normalized = $key; + if ($padLen > 0) { + $normalized .= str_repeat('=', 4 - $padLen); + } + $decoded = base64_decode(strtr($normalized, '-_', '+/'), true); + } + + if ($decoded === false) { + return $key; + } + + $data = json_decode($decoded, true); + if (!is_array($data) || empty($data['s']) || !is_string($data['s'])) { + return $key; + } + + $endpoint = trim($data['s']); + $endpoint = preg_replace('#^https?://#i', '', $endpoint); + $endpoint = preg_replace('#/.*$#', '', $endpoint ?? ''); + + if ($endpoint !== '' && preg_match('/^(.+?)(?::\d+){2,}$/', $endpoint, $m) && preg_match('/:(\d+)$/', $endpoint, $pm)) { + $endpoint = trim((string) $m[1]) . ':' . (string) $pm[1]; + $data['s'] = $endpoint; + $json = (string) json_encode($data, JSON_UNESCAPED_SLASHES); + return rtrim(strtr(base64_encode($json), '+/', '-_'), '='); + } + + return $key; + } + public static function listByServerAndProtocol(int $serverId, int $protocolId): array { $pdo = DB::conn(); @@ -681,7 +773,7 @@ class VpnClient $awgParams = []; $awgLinesCmd = sprintf( - "docker exec %s sh -c \"grep -E '^[[:space:]]*(Jc|Jmin|Jmax|S1|S2|H1|H2|H3|H4)[[:space:]]*=' %s 2>/dev/null || true\"", + "docker exec %s sh -c \"grep -E '^[[:space:]]*(Jc|Jmin|Jmax|S1|S2|S3|S4|H1|H2|H3|H4)[[:space:]]*=' %s 2>/dev/null || true\"", escapeshellarg($containerName), escapeshellarg($confPath) ); @@ -692,7 +784,7 @@ class VpnClient if ($line === '') { continue; } - if (preg_match('/^(Jc|Jmin|Jmax|S1|S2|H1|H2|H3|H4)\s*=\s*(\d+)\s*$/i', $line, $m)) { + if (preg_match('/^(Jc|Jmin|Jmax|S1|S2|S3|S4|H1|H2|H3|H4)\s*=\s*(\d+)\s*$/i', $line, $m)) { $k = strtoupper($m[1]); $awgParams[$k] = (int) $m[2]; } @@ -803,7 +895,7 @@ class VpnClient // Legacy attempt: some builds print jc/jmin/... in `wg show` output. $wgShowCmd = "docker exec $containerName wg show wg0 2>/dev/null"; $wgOutput = (string) $server->executeCommand($wgShowCmd, true); - $paramNames = ['jc', 'jmin', 'jmax', 's1', 's2', 'h1', 'h2', 'h3', 'h4']; + $paramNames = ['jc', 'jmin', 'jmax', 's1', 's2', 's3', 's4', 'h1', 'h2', 'h3', 'h4']; foreach ($paramNames as $param) { if (preg_match('/^\s*' . preg_quote($param, '/') . ':\s*(\d+)/mi', $wgOutput, $matches)) { $awgParams[strtoupper($param)] = (int) $matches[1]; @@ -862,7 +954,7 @@ class VpnClient $config .= "DNS = 1.1.1.1, 1.0.0.1\n"; // Add AWG parameters - foreach (['Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4'] as $key) { + foreach (['Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'S3', 'S4', 'H1', 'H2', 'H3', 'H4'] as $key) { if (isset($awgParams[$key])) { $config .= "{$key} = {$awgParams[$key]}\n"; continue; @@ -1370,7 +1462,7 @@ class VpnClient // If AWG params are missing (common after reinstall), fetch them directly from wg0.conf // to avoid falling back to template defaults that will not match the server. if (in_array($slug, ['amnezia-wg-advanced', 'awg2'], true)) { - $needKeys = ['JC', 'JMIN', 'JMAX', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4']; + $needKeys = ['JC', 'JMIN', 'JMAX', 'S1', 'S2', 'S3', 'S4', 'H1', 'H2', 'H3', 'H4']; $missing = false; foreach ($needKeys as $k) { if (!isset($awgParams[$k])) { @@ -1438,7 +1530,7 @@ class VpnClient 'dns_servers' => (string) ($serverData['dns_servers'] ?? '1.1.1.1, 1.0.0.1'), ]; - foreach (['JC', 'JMIN', 'JMAX', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4'] as $key) { + foreach (['JC', 'JMIN', 'JMAX', 'S1', 'S2', 'S3', 'S4', 'H1', 'H2', 'H3', 'H4'] as $key) { if (isset($awgParams[$key])) { $vars[$key] = $awgParams[$key]; } @@ -1574,7 +1666,7 @@ class VpnClient try { // Get previous stats for speed calculation $pdo = DB::conn(); - $stmtPrev = $pdo->prepare('SELECT bytes_sent, bytes_received, last_sync_at, last_handshake FROM vpn_clients WHERE id = ?'); + $stmtPrev = $pdo->prepare('SELECT bytes_sent, bytes_received, last_sync_at, last_handshake, aivpn_raw_bytes_in, aivpn_raw_bytes_out, aivpn_offset_bytes_in, aivpn_offset_bytes_out FROM vpn_clients WHERE id = ?'); $stmtPrev->execute([$this->clientId]); $prev = $stmtPrev->fetch(); @@ -1582,20 +1674,31 @@ class VpnClient $prevReceived = (int) ($prev['bytes_received'] ?? 0); $prevSyncAt = $prev['last_sync_at'] ? strtotime($prev['last_sync_at']) : 0; $prevHandshake = $prev['last_handshake'] ? strtotime($prev['last_handshake']) : 0; + $aivpnRawInPrev = (int) ($prev['aivpn_raw_bytes_in'] ?? 0); + $aivpnRawOutPrev = (int) ($prev['aivpn_raw_bytes_out'] ?? 0); + $aivpnOffsetIn = (int) ($prev['aivpn_offset_bytes_in'] ?? 0); + $aivpnOffsetOut = (int) ($prev['aivpn_offset_bytes_out'] ?? 0); // XRay stats logic $stats = []; // Determine protocol by client's protocol_id $isXray = false; + $isAivpn = false; $xrayContainerName = 'amnezia-xray'; // Default XRay container name if (!empty($this->data['protocol_id'])) { $stmtProto = $pdo->prepare('SELECT slug FROM protocols WHERE id = ?'); $stmtProto->execute([$this->data['protocol_id']]); $protoData = $stmtProto->fetch(); - if ($protoData && stripos($protoData['slug'], 'xray') !== false) { - $isXray = true; + if ($protoData) { + $slug = (string) ($protoData['slug'] ?? ''); + if (stripos($slug, 'xray') !== false) { + $isXray = true; + } + if (stripos($slug, 'aivpn') !== false) { + $isAivpn = true; + } } } @@ -1605,8 +1708,12 @@ class VpnClient if (strpos($containerName, 'xray') !== false) { $isXray = true; $xrayContainerName = $containerName; + } elseif (strpos($containerName, 'aivpn') !== false) { + $isAivpn = true; } elseif (!empty($this->data['config']) && strpos($this->data['config'], 'vless://') !== false) { $isXray = true; + } elseif (!empty($this->data['config']) && strpos($this->data['config'], 'aivpn://') === 0) { + $isAivpn = true; } } @@ -1648,6 +1755,28 @@ class VpnClient } } + } elseif ($isAivpn) { + $stats = self::getAivpnStatsFromServer($serverData, $this->data); + if (!empty($stats)) { + $rawInNow = (int) ($stats['bytes_sent'] ?? 0); + $rawOutNow = (int) ($stats['bytes_received'] ?? 0); + + if ($rawInNow < $aivpnRawInPrev) { + $aivpnOffsetIn = max($aivpnOffsetIn + $aivpnRawInPrev, $prevSent); + } + if ($rawOutNow < $aivpnRawOutPrev) { + $aivpnOffsetOut = max($aivpnOffsetOut + $aivpnRawOutPrev, $prevReceived); + } + + $candidateSent = $aivpnOffsetIn + $rawInNow; + $candidateReceived = $aivpnOffsetOut + $rawOutNow; + $stats['bytes_sent'] = max($prevSent, $candidateSent); + $stats['bytes_received'] = max($prevReceived, $candidateReceived); + + if (empty($stats['last_handshake']) || (int) $stats['last_handshake'] <= 0) { + $stats['last_handshake'] = $prevHandshake; + } + } } if (empty($stats)) { @@ -1681,16 +1810,43 @@ class VpnClient } } - $stmt = $pdo->prepare(' - UPDATE vpn_clients - SET bytes_sent = ?, bytes_received = ?, last_handshake = ?, current_speed = ?, speed_up = ?, speed_down = ?, last_sync_at = NOW() - WHERE id = ? - '); + $isAivpnPersist = $isAivpn && !empty($stats); + if ($isAivpnPersist) { + $stmt = $pdo->prepare(' + UPDATE vpn_clients + SET bytes_sent = ?, bytes_received = ?, last_handshake = ?, current_speed = ?, speed_up = ?, speed_down = ?, + aivpn_raw_bytes_in = ?, aivpn_raw_bytes_out = ?, aivpn_offset_bytes_in = ?, aivpn_offset_bytes_out = ?, + last_sync_at = NOW() + WHERE id = ? + '); + } else { + $stmt = $pdo->prepare(' + UPDATE vpn_clients + SET bytes_sent = ?, bytes_received = ?, last_handshake = ?, current_speed = ?, speed_up = ?, speed_down = ?, last_sync_at = NOW() + WHERE id = ? + '); + } $lastHandshake = $stats['last_handshake'] > 0 ? date('Y-m-d H:i:s', $stats['last_handshake']) : null; + if ($isAivpnPersist) { + return $stmt->execute([ + $stats['bytes_sent'], + $stats['bytes_received'], + $lastHandshake, + $currentSpeed, + $speedUp, + $speedDown, + (int) ($stats['bytes_sent_raw'] ?? 0), + (int) ($stats['bytes_received_raw'] ?? 0), + $aivpnOffsetIn, + $aivpnOffsetOut, + $this->clientId + ]); + } + return $stmt->execute([ $stats['bytes_sent'], $stats['bytes_received'], @@ -1706,6 +1862,110 @@ class VpnClient } } + private static function getAivpnStatsFromServer(array $serverData, array $clientData): array + { + $stats = [ + 'bytes_sent' => 0, + 'bytes_received' => 0, + 'bytes_sent_raw' => 0, + 'bytes_received_raw' => 0, + 'last_handshake' => 0, + ]; + + $containerName = (string) ($serverData['container_name'] ?? ''); + if ($containerName === '' || stripos($containerName, 'aivpn') === false) { + $containerName = 'aivpn-server'; + } + + $cmd = sprintf('docker exec -i %s cat /etc/aivpn/clients.json 2>/dev/null', escapeshellarg($containerName)); + $output = self::executeServerCommand($serverData, $cmd, true); + if (trim((string) $output) === '') { + return $stats; + } + + $data = json_decode((string) $output, true); + if (!is_array($data) || !isset($data['clients']) || !is_array($data['clients'])) { + return $stats; + } + + $name = strtolower(trim((string) ($clientData['name'] ?? ''))); + $clientIp = trim((string) ($clientData['client_ip'] ?? '')); + $cfgIp = self::extractAivpnIpFromConfig((string) ($clientData['config'] ?? '')); + + $match = null; + foreach ($data['clients'] as $entry) { + if (!is_array($entry)) { + continue; + } + $entryName = strtolower(trim((string) ($entry['name'] ?? ''))); + $entryIp = trim((string) ($entry['vpn_ip'] ?? '')); + if ($name !== '' && $entryName === $name) { + $match = $entry; + break; + } + if ($clientIp !== '' && $entryIp === $clientIp) { + $match = $entry; + break; + } + if ($cfgIp !== '' && $entryIp === $cfgIp) { + $match = $entry; + break; + } + } + + if (!is_array($match)) { + return $stats; + } + + $s = is_array($match['stats'] ?? null) ? $match['stats'] : []; + $rawIn = (int) ($s['bytes_in'] ?? 0); + $rawOut = (int) ($s['bytes_out'] ?? 0); + $stats['bytes_sent_raw'] = $rawIn; + $stats['bytes_received_raw'] = $rawOut; + $stats['bytes_sent'] = $rawIn; + $stats['bytes_received'] = $rawOut; + + if (!empty($s['last_handshake']) && is_string($s['last_handshake'])) { + $ts = strtotime($s['last_handshake']); + if ($ts !== false) { + $stats['last_handshake'] = (int) $ts; + } + } + + return $stats; + } + + private static function extractAivpnIpFromConfig(string $config): string + { + if (stripos($config, 'aivpn://') !== 0) { + return ''; + } + + $payload = substr($config, strlen('aivpn://')); + if ($payload === '') { + return ''; + } + + $b64 = strtr($payload, '-_', '+/'); + $padLen = strlen($b64) % 4; + if ($padLen > 0) { + $b64 .= str_repeat('=', 4 - $padLen); + } + + $decoded = base64_decode($b64, true); + if ($decoded === false) { + return ''; + } + + $data = json_decode($decoded, true); + if (!is_array($data)) { + return ''; + } + + $ip = trim((string) ($data['i'] ?? '')); + return preg_match('/^\d{1,3}(?:\.\d{1,3}){3}$/', $ip) ? $ip : ''; + } + /** * Get client statistics from server */ diff --git a/inc/VpnServer.php b/inc/VpnServer.php index ababa46..aee6752 100644 --- a/inc/VpnServer.php +++ b/inc/VpnServer.php @@ -706,6 +706,19 @@ BASH; $this->executeCommand("docker exec -i {$containerName} sh -c 'iptables -A FORWARD -i wg0 -o eth0 -s 10.8.1.0/24 -j ACCEPT 2>/dev/null || true'", true); $this->executeCommand("docker exec -i {$containerName} sh -c 'iptables -t nat -A POSTROUTING -s 10.8.1.0/24 -o eth0 -j MASQUERADE 2>/dev/null || true'", true); + // Ensure host-level forwarding/NAT for AWG subnet as well (required on some Docker host setups). + $vpnSubnet = (string) ($this->data['vpn_subnet'] ?? '10.8.1.0/24'); + $vpnSubnetEsc = escapeshellarg($vpnSubnet); + $hostNatCmd = "bash -lc 'IFACE=\\$(ip route | awk \"{if (\\$1==\\\"default\\\") {print \\$5; exit}}\"); " . + "iptables -t nat -C POSTROUTING -s " . $vpnSubnetEsc . " -o \\\"\\$IFACE\\\" -j MASQUERADE 2>/dev/null || " . + "iptables -t nat -I POSTROUTING 1 -s " . $vpnSubnetEsc . " -o \\\"\\$IFACE\\\" -j MASQUERADE; " . + "iptables -C FORWARD -s " . $vpnSubnetEsc . " -o \\\"\\$IFACE\\\" -j ACCEPT 2>/dev/null || " . + "iptables -I FORWARD 1 -s " . $vpnSubnetEsc . " -o \\\"\\$IFACE\\\" -j ACCEPT; " . + "iptables -C FORWARD -d " . $vpnSubnetEsc . " -m conntrack --ctstate RELATED,ESTABLISHED -i \\\"\\$IFACE\\\" -j ACCEPT 2>/dev/null || " . + "iptables -I FORWARD 1 -d " . $vpnSubnetEsc . " -m conntrack --ctstate RELATED,ESTABLISHED -i \\\"\\$IFACE\\\" -j ACCEPT; " . + "sysctl -w net.ipv4.ip_forward=1 >/dev/null'"; + $this->executeCommand($hostNatCmd, true); + sleep(2); return [ diff --git a/migrations/061_fix_client_connection_instructions_translation.sql b/migrations/061_fix_client_connection_instructions_translation.sql new file mode 100644 index 0000000..07424ea --- /dev/null +++ b/migrations/061_fix_client_connection_instructions_translation.sql @@ -0,0 +1,11 @@ +-- Ensure clients.connection_instructions exists in all locales used by UI. +-- Without this key, client view heading may be missing or fallback text can appear inconsistent. + +INSERT INTO translations (locale, category, key_name, translation) VALUES +('en', 'clients', 'connection_instructions', 'Connection Instructions'), +('ru', 'clients', 'connection_instructions', 'Инструкции по подключению'), +('es', 'clients', 'connection_instructions', 'Instrucciones de conexión'), +('de', 'clients', 'connection_instructions', 'Verbindungsanweisungen'), +('fr', 'clients', 'connection_instructions', 'Instructions de connexion'), +('zh', 'clients', 'connection_instructions', '连接说明') +ON DUPLICATE KEY UPDATE translation = VALUES(translation); diff --git a/migrations/062_add_aivpn_counter_offsets.sql b/migrations/062_add_aivpn_counter_offsets.sql new file mode 100644 index 0000000..69d1083 --- /dev/null +++ b/migrations/062_add_aivpn_counter_offsets.sql @@ -0,0 +1,7 @@ +-- Add persistent AIVPN raw/offset counters for monotonic traffic totals across server restarts. + +ALTER TABLE vpn_clients + ADD COLUMN aivpn_raw_bytes_in BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER bytes_received, + ADD COLUMN aivpn_raw_bytes_out BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER aivpn_raw_bytes_in, + ADD COLUMN aivpn_offset_bytes_in BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER aivpn_raw_bytes_out, + ADD COLUMN aivpn_offset_bytes_out BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER aivpn_offset_bytes_in; diff --git a/scripts/remote_reset_and_reinstall_all_protocols.sh b/scripts/remote_reset_and_reinstall_all_protocols.sh new file mode 100755 index 0000000..12241f3 --- /dev/null +++ b/scripts/remote_reset_and_reinstall_all_protocols.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +PANEL_URL="http://localhost:8082" +EMAIL="admin@amnez.ia" +PASSWORD="admin123" +SERVER_ID="1" +REMOTE_HOST="217.26.25.6" +REMOTE_USER="root" +REMOTE_PASS='1Fr045jZbtF!' + +# protocol IDs in this workspace +AWG2_ID="11" +AIVPN_ID="13" +MTPROXY_ID="12" + +echo "== auth ==" +TOKEN=$(curl -sS -X POST "$PANEL_URL/api/auth/token" \ + -d "email=$EMAIL&password=$PASSWORD" | python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])') + +echo "== remote full docker cleanup ==" +sshpass -p "$REMOTE_PASS" ssh -o StrictHostKeyChecking=no "$REMOTE_USER@$REMOTE_HOST" 'bash -s' <<'EOSSH' +set -euo pipefail + +# Stop and remove all containers if any +if [ -n "$(docker ps -aq 2>/dev/null || true)" ]; then + docker rm -f $(docker ps -aq) >/dev/null 2>&1 || true +fi + +# Full cleanup of images/volumes/networks/build cache +if command -v docker >/dev/null 2>&1; then + docker system prune -af --volumes >/dev/null 2>&1 || true + docker builder prune -af >/dev/null 2>&1 || true +fi + +# Remove protocol dirs to force fresh bootstrap +rm -rf /opt/amnezia /etc/aivpn /etc/amnezia /etc/mtproxy 2>/dev/null || true +mkdir -p /opt/amnezia /etc/aivpn /etc/amnezia /etc/mtproxy + +echo "remote cleanup done" +EOSSH + +echo "== install awg2 ==" +curl -sS -X POST "$PANEL_URL/api/servers/$SERVER_ID/protocols/install" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + --data "{\"protocol_id\":$AWG2_ID}" | tee /tmp/install_awg2_after_remote_reset.json + +echo +echo "== install aivpn ==" +curl -sS -X POST "$PANEL_URL/api/servers/$SERVER_ID/protocols/install" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + --data "{\"protocol_id\":$AIVPN_ID}" | tee /tmp/install_aivpn_after_remote_reset.json + +echo +echo "== install mtproxy ==" +curl -sS -X POST "$PANEL_URL/api/servers/$SERVER_ID/protocols/install" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + --data "{\"protocol_id\":$MTPROXY_ID}" | tee /tmp/install_mtproxy_after_remote_reset.json + +echo +echo "== verify containers on remote ==" +sshpass -p "$REMOTE_PASS" ssh -o StrictHostKeyChecking=no "$REMOTE_USER@$REMOTE_HOST" \ + "docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'" + +echo +echo "done" diff --git a/templates/clients/view.twig b/templates/clients/view.twig index ec35a65..3f1edc9 100644 --- a/templates/clients/view.twig +++ b/templates/clients/view.twig @@ -155,7 +155,7 @@ {% if protocol_output and client.show_text_content %}

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

-
{{ protocol_output }}
+
{{ protocol_output }}
{% endif %} From 1ff23b8ed96e6b23dbd6aba2248664e44104cdb6 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 4 Apr 2026 15:35:59 +0300 Subject: [PATCH 64/72] feat: synchronize MTProxy client links with current runtime configuration after reinstall --- inc/InstallProtocolManager.php | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/inc/InstallProtocolManager.php b/inc/InstallProtocolManager.php index 09d2d5f..ca78a26 100644 --- a/inc/InstallProtocolManager.php +++ b/inc/InstallProtocolManager.php @@ -747,6 +747,28 @@ class InstallProtocolManager ]; $stmt4 = $pdo->prepare('INSERT INTO server_protocols (server_id, protocol_id, config_data, applied_at, created_at) VALUES (?, ?, ?, NOW(), NOW()) ON DUPLICATE KEY UPDATE config_data = VALUES(config_data), applied_at = NOW()'); $stmt4->execute([$serverId, (int) $protocolId, json_encode($config)]); + + // Keep existing MTProxy client links in sync with current runtime port/secret after reinstall. + if ($slug === 'mtproxy') { + $mtHost = (string) ($config['server_host'] ?? ''); + $mtPort = (string) ($config['server_port'] ?? ''); + $mtSecret = ''; + + if (!empty($extras['secret']) && is_scalar($extras['secret'])) { + $mtSecret = trim((string) $extras['secret']); + } + if ($mtSecret === '' && isset($extras['result']) && is_array($extras['result'])) { + if (!empty($extras['result']['secret']) && is_scalar($extras['result']['secret'])) { + $mtSecret = trim((string) $extras['result']['secret']); + } + } + + if ($mtHost !== '' && $mtPort !== '' && $mtSecret !== '') { + $mtLink = 'tg://proxy?server=' . $mtHost . '&port=' . $mtPort . '&secret=' . $mtSecret; + $stmtSync = $pdo->prepare('UPDATE vpn_clients SET config = ? WHERE server_id = ? AND protocol_id = ? AND (config IS NULL OR config = "" OR config LIKE "tg://proxy?%")'); + $stmtSync->execute([$mtLink, $serverId, (int) $protocolId]); + } + } } } } catch (Throwable $e) { From bc1d9d531b590ed49d423cc87a14bc081539809d Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 4 Apr 2026 15:48:15 +0300 Subject: [PATCH 65/72] feat: enhance SQL migration handling and add Docker installation instructions for remote servers --- README.md | 26 +++++++++++++++++++++++-- inc/InstallProtocolManager.php | 35 +++++++++++++++++++++++++++++++--- inc/VpnClient.php | 18 ++++++++++++++++- 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a63bb07..9a6b940 100644 --- a/README.md +++ b/README.md @@ -37,18 +37,40 @@ docker compose up -d docker compose exec web composer install # Ensure all SQL migrations are applied (safe to run repeatedly) -docker compose exec -T db sh -lc 'for f in /docker-entrypoint-initdb.d/*.sql; do mysql -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE" < "$f" || true; done' +for f in migrations/*.sql; do + docker compose exec -T db mysql -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE" < "$f" || true +done # Or for older Docker Compose V1 docker-compose up -d docker-compose exec web composer install -docker-compose exec -T db sh -lc 'for f in /docker-entrypoint-initdb.d/*.sql; do mysql -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE" < "$f" || true; done' +for f in migrations/*.sql; do + docker-compose exec -T db mysql -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE" < "$f" || true +done ``` Access: http://localhost:8082 Default login: admin@amnez.ia / admin123 +### Remote Server Prerequisite + +For protocol deployment on a clean remote host, Docker Engine must be available on that host. +If Docker is missing, install it first (Ubuntu example): + +```bash +apt-get update -y +apt-get install -y ca-certificates curl gnupg lsb-release +install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --batch --yes --dearmor -o /etc/apt/keyrings/docker.gpg +chmod a+r /etc/apt/keyrings/docker.gpg +. /etc/os-release +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list +apt-get update -y +apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +systemctl enable --now docker +``` + ## Configuration Edit `.env`: diff --git a/inc/InstallProtocolManager.php b/inc/InstallProtocolManager.php index ca78a26..51caf99 100644 --- a/inc/InstallProtocolManager.php +++ b/inc/InstallProtocolManager.php @@ -1149,7 +1149,7 @@ class InstallProtocolManager if ($isAwg) { $detection = self::detectBuiltinAwg($server, $protocol); - if (in_array($detection['status'] ?? '', ['existing', 'partial'], true)) { + if (($detection['status'] ?? '') === 'existing') { Logger::appendInstall($serverId, 'Existing AWG installation detected, restoring instead of reinstalling'); $restoreResult = self::restoreBuiltinAwg($server, $protocol, $detection, $options); // Import existing clients into DB @@ -1177,7 +1177,7 @@ class InstallProtocolManager if ($isXray) { $xrayDetection = self::detectBuiltinXray($server, $protocol); - if (in_array($xrayDetection['status'] ?? '', ['existing', 'partial'], true)) { + if (($xrayDetection['status'] ?? '') === 'existing') { Logger::appendInstall($serverId, 'Existing X-Ray installation detected, restoring instead of reinstalling'); $restoreResult = self::restoreBuiltinXray($server, $protocol, $xrayDetection, $options); return array_merge($restoreResult, ['mode' => 'restore_existing']); @@ -1189,12 +1189,41 @@ class InstallProtocolManager if ($engine === 'builtin_awg') { $res = $server->runAwgInstall($options); Logger::appendInstall($serverId, 'Builtin AWG install finished'); + + $resolvedPort = null; + if (isset($res['vpn_port']) && (int) $res['vpn_port'] > 0) { + $resolvedPort = (int) $res['vpn_port']; + } elseif (isset($res['server_port']) && (int) $res['server_port'] > 0) { + $resolvedPort = (int) $res['server_port']; + } + + $resolvedAwgParams = $res['awg_params'] ?? null; + if (!is_array($resolvedAwgParams)) { + $candidate = []; + foreach (['Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4'] as $k) { + if (array_key_exists($k, $res)) { + $candidate[$k] = $res[$k]; + } + } + if ($candidate) { + $resolvedAwgParams = $candidate; + } + } + + self::markServerActive($serverId, null, [ + 'vpn_port' => $resolvedPort, + 'server_public_key' => $res['server_public_key'] ?? null, + 'preshared_key' => $res['preshared_key'] ?? null, + 'container_name' => $res['container_name'] ?? null, + 'awg_params' => $resolvedAwgParams, + ]); + $pdo = DB::conn(); $pid = self::resolveProtocolId($protocol); if ($pid) { $config = [ 'server_host' => $server->getData()['host'] ?? null, - 'server_port' => $res['vpn_port'] ?? null, + 'server_port' => $resolvedPort, 'extras' => $res ]; $stmt2 = $pdo->prepare('INSERT INTO server_protocols (server_id, protocol_id, config_data, applied_at, created_at) VALUES (?, ?, ?, NOW(), NOW()) ON DUPLICATE KEY UPDATE config_data = VALUES(config_data), applied_at = NOW()'); diff --git a/inc/VpnClient.php b/inc/VpnClient.php index 08cf493..6361926 100644 --- a/inc/VpnClient.php +++ b/inc/VpnClient.php @@ -1459,10 +1459,19 @@ class VpnClient $awgParams = []; } + // Accept mixed-case keys from installer outputs (e.g. Jc/Jmin/Jmax) + // by duplicating them into canonical uppercase AWG keys. + foreach ($awgParams as $k => $v) { + $uk = strtoupper((string) $k); + if (in_array($uk, ['JC', 'JMIN', 'JMAX', 'S1', 'S2', 'S3', 'S4', 'H1', 'H2', 'H3', 'H4'], true) && !isset($awgParams[$uk])) { + $awgParams[$uk] = $v; + } + } + // If AWG params are missing (common after reinstall), fetch them directly from wg0.conf // to avoid falling back to template defaults that will not match the server. if (in_array($slug, ['amnezia-wg-advanced', 'awg2'], true)) { - $needKeys = ['JC', 'JMIN', 'JMAX', 'S1', 'S2', 'S3', 'S4', 'H1', 'H2', 'H3', 'H4']; + $needKeys = ['JC', 'JMIN', 'JMAX', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4']; $missing = false; foreach ($needKeys as $k) { if (!isset($awgParams[$k])) { @@ -1494,6 +1503,13 @@ class VpnClient } } + if (!isset($awgParams['S3'])) { + $awgParams['S3'] = 0; + } + if (!isset($awgParams['S4'])) { + $awgParams['S4'] = 0; + } + // Still missing? Refuse to overwrite config with template defaults. foreach ($needKeys as $k) { if (!isset($awgParams[$k])) { From 326421f07b2a4e937b87126f4d6eff6418097cdf Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 4 Apr 2026 16:02:11 +0300 Subject: [PATCH 66/72] feat: enhance AWG2 protocol handling by adding config directory management and fixing empty peer block in install script --- inc/InstallProtocolManager.php | 9 +++++-- inc/VpnClient.php | 27 ++++++++++--------- migrations/058_add_awg2_protocol.sql | 10 ------- ..._fix_awg2_empty_peer_in_install_script.sql | 14 ++++++++++ 4 files changed, 36 insertions(+), 24 deletions(-) create mode 100644 migrations/063_fix_awg2_empty_peer_in_install_script.sql diff --git a/inc/InstallProtocolManager.php b/inc/InstallProtocolManager.php index 51caf99..b0ebc3d 100644 --- a/inc/InstallProtocolManager.php +++ b/inc/InstallProtocolManager.php @@ -1093,6 +1093,10 @@ class InstallProtocolManager $metadata = $protocol['definition']['metadata'] ?? []; $serverData = $server->getData(); $containerName = $serverData['container_name'] ?? ($metadata['container_name'] ?? 'amnezia-awg'); + $configDir = trim((string) ($metadata['config_dir'] ?? '')); + if ($configDir === '') { + $configDir = (($protocol['slug'] ?? '') === 'awg2') ? '/opt/amnezia/awg2' : '/opt/amnezia/awg'; + } $candidateNames = array_values(array_unique(array_filter([ is_string($containerName) ? trim($containerName) : '', is_string($metadata['container_name'] ?? null) ? trim((string) $metadata['container_name']) : '', @@ -1111,10 +1115,11 @@ class InstallProtocolManager $server->executeCommand("docker rm -fv {$arg} 2>/dev/null || true", true); } // Remove known images (best-effort) - $server->executeCommand("docker rmi amneziavpn/amnezia-wg amneziavpn/amnezia-awg 2>/dev/null || true", true); + $server->executeCommand("docker rmi amneziavpn/amnezia-wg amneziavpn/amnezia-awg amnezia-awg2 2>/dev/null || true", true); // Attempt to remove amnezia-dns-net network if present (best-effort) $server->executeCommand("docker network rm amnezia-dns-net 2>/dev/null || true", true); - // Remove on-disk data for AWG + // Remove on-disk data for AWG protocol config to avoid stale restore paths. + $server->executeCommand("rm -rf " . escapeshellarg($configDir) . " 2>/dev/null || true", true); $server->executeCommand("rm -rf /opt/amnezia/amnezia-awg 2>/dev/null || true", true); // Clear server deployment metadata in database for this server diff --git a/inc/VpnClient.php b/inc/VpnClient.php index 6361926..d58084d 100644 --- a/inc/VpnClient.php +++ b/inc/VpnClient.php @@ -675,17 +675,9 @@ class VpnClient private static function generateClientKeys(array $serverData, string $clientName): array { $containerName = $serverData['container_name']; - $token = bin2hex(random_bytes(8)); - $cmd = sprintf( - "docker exec -i %s sh -c \"umask 077; wg genkey | tee /tmp/%s_priv.key | wg pubkey > /tmp/%s_pub.key; cat /tmp/%s_priv.key; echo '---'; cat /tmp/%s_pub.key; rm -f /tmp/%s_priv.key /tmp/%s_pub.key\"", - $containerName, - $token, - $token, - $token, - $token, - $token, - $token + "docker exec -i %s sh -lc 'set -e; umask 077; priv=$(wg genkey); pub=$(printf %s \"$priv\" | wg pubkey); printf \"%%s\\n---\\n%%s\\n\" \"$priv\" \"$pub\"'", + escapeshellarg($containerName) ); $escaped = escapeshellarg($cmd); @@ -705,9 +697,15 @@ class VpnClient throw new Exception("Failed to generate client keys"); } + $private = trim((string) $parts[0]); + $public = trim((string) $parts[1]); + if ($private === '' || $public === '') { + throw new Exception('Failed to generate client keys: empty key output'); + } + return [ - 'private' => trim($parts[0]), - 'public' => trim($parts[1]) + 'private' => $private, + 'public' => $public ]; } @@ -984,6 +982,11 @@ class VpnClient { $containerName = $serverData['container_name']; $presharedKey = $serverData['preshared_key']; + $publicKey = trim($publicKey); + + if ($publicKey === '') { + throw new Exception('Refusing to add client with empty public key'); + } // 1. Create temp file for PSK (to avoid shell escaping issues) $pskFile = '/tmp/' . bin2hex(random_bytes(8)) . '.psk'; diff --git a/migrations/058_add_awg2_protocol.sql b/migrations/058_add_awg2_protocol.sql index 1d2e856..7256398 100644 --- a/migrations/058_add_awg2_protocol.sql +++ b/migrations/058_add_awg2_protocol.sql @@ -119,11 +119,6 @@ H3 = $H3_VAL H4 = $H4_VAL PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE - -[Peer] -PublicKey = -PresharedKey = $PRESHARED_KEY -AllowedIPs = 10.8.1.2/32 EOF echo "$PRIVATE_KEY" > /opt/amnezia/awg2/wireguard_server_private_key.key @@ -310,11 +305,6 @@ H3 = $H3_VAL H4 = $H4_VAL PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE - -[Peer] -PublicKey = -PresharedKey = $PRESHARED_KEY -AllowedIPs = 10.8.1.2/32 EOF echo "$PRIVATE_KEY" > /opt/amnezia/awg2/wireguard_server_private_key.key diff --git a/migrations/063_fix_awg2_empty_peer_in_install_script.sql b/migrations/063_fix_awg2_empty_peer_in_install_script.sql new file mode 100644 index 0000000..7c8b8ec --- /dev/null +++ b/migrations/063_fix_awg2_empty_peer_in_install_script.sql @@ -0,0 +1,14 @@ +-- Remove invalid empty peer block from AWG2 install script. +-- The old script generated wg0.conf with: +-- [Peer] +-- PublicKey = +-- which causes awg setconf parse errors and restart loops. + +UPDATE protocols +SET install_script = REPLACE( + install_script, + '\n[Peer]\nPublicKey = \nPresharedKey = $PRESHARED_KEY\nAllowedIPs = 10.8.1.2/32\n', + '\n' +) +WHERE slug = 'awg2' + AND install_script LIKE '%[Peer]%PublicKey = %PresharedKey = $PRESHARED_KEY%AllowedIPs = 10.8.1.2/32%'; From ce664072fab397c5d6f87f552f5853a7481e5f79 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 4 Apr 2026 16:15:10 +0300 Subject: [PATCH 67/72] feat: enhance VpnClient to support multi-protocol metadata and improve key generation error handling --- inc/VpnClient.php | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/inc/VpnClient.php b/inc/VpnClient.php index d58084d..abb7b51 100644 --- a/inc/VpnClient.php +++ b/inc/VpnClient.php @@ -67,16 +67,33 @@ class VpnClient $protoRow = $stmtProto2->fetch(); } $slug = $protoRow['slug'] ?? ($serverData['install_protocol'] ?? 'amnezia-wg'); + $protoMetadata = []; + if ($protoRow && !empty($protoRow['definition']) && is_string($protoRow['definition'])) { + $decodedDef = json_decode($protoRow['definition'], true); + if (is_array($decodedDef)) { + $protoMetadata = $decodedDef['metadata'] ?? []; + } + } $isWireguard = in_array($slug, ['amnezia-wg-advanced', 'wireguard-standard', 'amnezia-wg', 'awg2'], true); // Auto-sync server keys from container EVERY TIME for WireGuard protocols // This ensures we always use current container configuration even if it was recreated if ($isWireguard) { try { + // For multi-protocol servers use selected protocol metadata instead of default server row. + if (!empty($protoMetadata['container_name']) && is_string($protoMetadata['container_name'])) { + $serverData['container_name'] = trim($protoMetadata['container_name']); + } + $serverData['install_protocol'] = $slug; + self::syncServerKeysFromContainer($server, $serverData); // Reload server data after sync (VpnServer caches DB row in-memory) $server->refresh(); $serverData = $server->getData(); + if (!empty($protoMetadata['container_name']) && is_string($protoMetadata['container_name'])) { + $serverData['container_name'] = trim($protoMetadata['container_name']); + } + $serverData['install_protocol'] = $slug; } catch (Exception $e) { error_log('Failed to auto-sync server keys: ' . $e->getMessage()); // Continue anyway - might fail later but let's try @@ -676,7 +693,7 @@ class VpnClient { $containerName = $serverData['container_name']; $cmd = sprintf( - "docker exec -i %s sh -lc 'set -e; umask 077; priv=$(wg genkey); pub=$(printf %s \"$priv\" | wg pubkey); printf \"%%s\\n---\\n%%s\\n\" \"$priv\" \"$pub\"'", + "docker exec -i %s sh -lc 'set -e; umask 077; priv=\$(wg genkey | tr -d " . '"' . "\\r\\n" . '"' . "); [ -n \"\$priv\" ] || { echo empty_private_key; exit 1; }; pub=\$(printf " . '"' . "%%s\\n" . '"' . " \"\$priv\" | wg pubkey | tr -d " . '"' . "\\r\\n" . '"' . "); [ -n \"\$pub\" ] || { echo empty_public_key; exit 1; }; printf " . '"' . "%%s\\n---\\n%%s\\n" . '"' . " \"\$priv\" \"\$pub\"'", escapeshellarg($containerName) ); @@ -694,7 +711,8 @@ class VpnClient $parts = explode("---", trim($out)); if (count($parts) < 2) { - throw new Exception("Failed to generate client keys"); + $head = substr(trim((string) $out), 0, 240); + throw new Exception("Failed to generate client keys" . ($head !== '' ? (": " . $head) : '')); } $private = trim((string) $parts[0]); From 7051d47b1b939e1a729cce2a9640d7e5bee71d55 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 4 Apr 2026 16:24:21 +0300 Subject: [PATCH 68/72] feat: enhance ServerMonitoring to resolve container names based on protocol and improve client metrics handling --- inc/ServerMonitoring.php | 41 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/inc/ServerMonitoring.php b/inc/ServerMonitoring.php index e7ef4e9..da5bb74 100644 --- a/inc/ServerMonitoring.php +++ b/inc/ServerMonitoring.php @@ -275,7 +275,7 @@ class ServerMonitoring // this->fetchXrayStats() call moved to collectClientMetrics to handle failure gracefully // Get current stats from server - $containerName = $this->serverData['container_name']; + $containerName = (string) ($this->serverData['container_name'] ?? ''); $bytesReceived = 0; $bytesSent = 0; $speedUp = 0; @@ -356,6 +356,10 @@ class ServerMonitoring stripos($protocolSlug, 'wireguard') !== false ); + if ($isWireguardClient) { + $containerName = $this->resolveContainerForProtocol($protocolSlug); + } + if ($isAivpnClient) { $aivpn = $this->getAivpnClientStats($client); if (is_array($aivpn)) { @@ -810,6 +814,41 @@ class ServerMonitoring return ''; } + private function resolveContainerForProtocol(string $protocolSlug): string + { + $default = trim((string) ($this->serverData['container_name'] ?? '')); + if ($protocolSlug === '') { + return $default; + } + + try { + $db = DB::conn(); + $stmt = $db->prepare('SELECT definition FROM protocols WHERE slug = ? LIMIT 1'); + $stmt->execute([$protocolSlug]); + $definitionJson = $stmt->fetchColumn(); + if (is_string($definitionJson) && $definitionJson !== '') { + $definition = json_decode($definitionJson, true); + if (is_array($definition)) { + $candidate = trim((string) ($definition['metadata']['container_name'] ?? '')); + if ($candidate !== '') { + return $candidate; + } + } + } + } catch (Throwable $e) { + // Fallback to default container. + } + + if ($protocolSlug === 'awg2') { + return 'amnezia-awg2'; + } + if (stripos($protocolSlug, 'aivpn') !== false) { + return 'aivpn-server'; + } + + return $default; + } + /** * Enforce single IP per user for Xray connections * If a user is connected from multiple IPs, block all but the first one From 3c143d5506ab1f148e69aed567783ca76f272eeb Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 4 Apr 2026 16:32:14 +0300 Subject: [PATCH 69/72] feat: enhance client speed metrics visualization with improved data processing and responsive table layout --- templates/servers/view.twig | 43 ++++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/templates/servers/view.twig b/templates/servers/view.twig index c4165a0..3546b57 100644 --- a/templates/servers/view.twig +++ b/templates/servers/view.twig @@ -247,7 +247,8 @@ {% if clients|length > 0 %} - +
+
@@ -375,6 +376,7 @@ {% endfor %}
{{ t('clients.name') }}
+ {% else %}
{{ t('clients.no_clients') }}
{% endif %} @@ -878,6 +880,39 @@ if (document.getElementById('cpuSparkline')) { // Update client speeds let clientCharts = {}; +function prepareSparklineSeries(values) { + const cleaned = values.map(v => { + const n = Number(v); + return Number.isFinite(n) && n > 0 ? n : 0; + }); + + const nonZero = cleaned.filter(v => v > 0).sort((a, b) => a - b); + let capped = cleaned; + + // Suppress single extreme spikes that make the mini-chart unreadable. + if (nonZero.length >= 5) { + const p95Index = Math.floor((nonZero.length - 1) * 0.95); + const p95 = nonZero[p95Index] || 0; + const cap = p95 > 0 ? p95 * 2 : 0; + if (cap > 0) { + capped = cleaned.map(v => Math.min(v, cap)); + } + } + + // Small moving average to reduce jitter on tiny sparklines. + return capped.map((_, i, arr) => { + const from = Math.max(0, i - 1); + const to = Math.min(arr.length - 1, i + 1); + let sum = 0; + let count = 0; + for (let j = from; j <= to; j++) { + sum += arr[j]; + count++; + } + return count > 0 ? sum / count : 0; + }); +} + async function updateClientSpeeds() { const clientRows = document.querySelectorAll('[id^="client-speed-"]'); @@ -899,8 +934,10 @@ async function updateClientSpeeds() { // 1. Render/Update Chart if (canvas) { const labels = metrics.map((_, i) => i); - const dataUp = metrics.map(m => (parseFloat(m.speed_up_kbps) / 1000)); // Mbps - const dataDown = metrics.map(m => (parseFloat(m.speed_down_kbps) / 1000)); // Mbps + const rawUp = metrics.map(m => (parseFloat(m.speed_up_kbps) / 1000)); // Mbps + const rawDown = metrics.map(m => (parseFloat(m.speed_down_kbps) / 1000)); // Mbps + const dataUp = prepareSparklineSeries(rawUp); + const dataDown = prepareSparklineSeries(rawDown); if (clientCharts[clientId]) { // Update existing chart From 228ae3f006a09bc68cfefad610df3fbdb19ae35e Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 4 Apr 2026 16:38:11 +0300 Subject: [PATCH 70/72] feat: update installation instructions for SQL migrations with improved clarity and consistency --- README.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9a6b940..ff00004 100644 --- a/README.md +++ b/README.md @@ -36,16 +36,25 @@ cp .env.example .env docker compose up -d docker compose exec web composer install -# Ensure all SQL migrations are applied (safe to run repeatedly) +# Apply migrations (fresh install + updates) +# 1) bootstrap base schema +docker compose exec -T db mysql -u"$DB_USERNAME" -p"$DB_PASSWORD" "$DB_DATABASE" < migrations/001_init.sql + +# 2) apply the rest (safe to run repeatedly) for f in migrations/*.sql; do - docker compose exec -T db mysql -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE" < "$f" || true + [ "$(basename "$f")" = "001_init.sql" ] && continue + docker compose exec -T db mysql -u"$DB_USERNAME" -p"$DB_PASSWORD" "$DB_DATABASE" < "$f" || true done # Or for older Docker Compose V1 docker-compose up -d docker-compose exec web composer install + +docker-compose exec -T db mysql -u"$DB_USERNAME" -p"$DB_PASSWORD" "$DB_DATABASE" < migrations/001_init.sql + for f in migrations/*.sql; do - docker-compose exec -T db mysql -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE" < "$f" || true + [ "$(basename "$f")" = "001_init.sql" ] && continue + docker-compose exec -T db mysql -u"$DB_USERNAME" -p"$DB_PASSWORD" "$DB_DATABASE" < "$f" || true done ``` From e4b83794c36a5c605906731ea0bd3ebf858564ae Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 4 Apr 2026 17:22:38 +0300 Subject: [PATCH 71/72] feat: enhance Docker command execution with improved error handling and path management --- inc/InstallProtocolManager.php | 115 ++++++++++++++++++++++++++++++--- inc/VpnServer.php | 15 +++-- 2 files changed, 118 insertions(+), 12 deletions(-) diff --git a/inc/InstallProtocolManager.php b/inc/InstallProtocolManager.php index b0ebc3d..d2f2d34 100644 --- a/inc/InstallProtocolManager.php +++ b/inc/InstallProtocolManager.php @@ -375,8 +375,27 @@ class InstallProtocolManager $containerFilter = escapeshellarg('^' . $containerName . '$'); $containerArg = escapeshellarg($containerName); - $containerList = trim($server->executeCommand("docker ps -a --filter name={$containerFilter} --format '{{.Names}}'", true)); - if ($containerList === '') { + $containerListRaw = trim($server->executeCommand("docker ps -a --filter name={$containerFilter} --format '{{.Names}}'", true)); + if ($containerListRaw === '') { + return [ + 'status' => 'absent', + 'message' => 'Контейнер AmneziaWG не найден на сервере' + ]; + } + + if (preg_match('/docker: command not found|command not found|cannot connect to the docker daemon|permission denied/i', $containerListRaw)) { + return [ + 'status' => 'absent', + 'message' => 'Docker CLI недоступен на сервере', + 'details' => [ + 'container_name' => $containerName, + 'container_status' => $containerListRaw, + ] + ]; + } + + $containerNames = array_values(array_filter(array_map('trim', preg_split('/\R+/', $containerListRaw)))); + if (!in_array($containerName, $containerNames, true)) { return [ 'status' => 'absent', 'message' => 'Контейнер AmneziaWG не найден на сервере' @@ -592,6 +611,29 @@ class InstallProtocolManager $script = self::renderTemplate($scripts, $context); $script = preg_replace('/\n\+\s*/', "\n", $script); $exportLines = self::buildExports($context); + + if ($phase === 'install') { + Logger::appendInstall($server->getId(), 'INSTALL phase: docker preflight start'); + $bootstrapCmd = "bash -lc 'set -e; " + . "if command -v docker >/dev/null 2>&1; then command -v docker; docker --version || true; exit 0; fi; " + . "if command -v curl >/dev/null 2>&1; then curl -fsSL https://get.docker.com | sh; " + . "elif command -v wget >/dev/null 2>&1; then wget -qO- https://get.docker.com | sh; " + . "else echo \"curl/wget not found\"; exit 127; fi; " + . "(systemctl enable --now docker || service docker start || true); " + . "command -v docker >/dev/null 2>&1 || { echo \"docker bootstrap failed\"; exit 127; }; " + . "command -v docker; docker --version || true'"; + $bootstrapOut = trim((string) $server->executeCommand($bootstrapCmd, true)); + if ($bootstrapOut !== '') { + $bootstrapHead = substr(str_replace(["\r", "\n"], ' ', $bootstrapOut), 0, 280); + Logger::appendInstall($server->getId(), 'INSTALL phase: docker preflight output ' . $bootstrapHead); + } + + $dockerCheckAfter = trim((string) $server->executeCommand('command -v docker || true', true)); + if ($dockerCheckAfter === '') { + throw new Exception('Docker не установлен на сервере и авто-установка не удалась'); + } + } + $wrapper = "bash <<'EOS'\nset -euo pipefail\n" . $exportLines . $script . "\nEOS"; Logger::appendInstall($server->getId(), strtoupper($phase) . ' phase: executing remote script'); $output = $server->executeCommand($wrapper, true); @@ -601,6 +643,17 @@ class InstallProtocolManager Logger::appendInstall($server->getId(), strtoupper($phase) . ' phase: output head ' . $head); } $trimmed = trim($output); + $installProbeSummary = ''; + + if ($phase === 'install' && $trimmed === '') { + $probeCmd = "echo whoami:\$(whoami) 2>/dev/null || true; echo shell:\$SHELL; command -v docker || echo docker:not-found; docker --version 2>&1 || true; id 2>&1 || true"; + $probeOut = trim((string) $server->executeCommand($probeCmd, true)); + if ($probeOut !== '') { + $normalizedProbe = substr(str_replace(["\r", "\n"], ' | ', $probeOut), 0, 320); + Logger::appendInstall($server->getId(), strtoupper($phase) . ' phase: probe ' . $normalizedProbe); + $installProbeSummary = '; probe: ' . $normalizedProbe; + } + } // Try JSON first $decoded = json_decode($trimmed, true); @@ -609,6 +662,25 @@ class InstallProtocolManager return $decoded; } + if ($phase === 'install') { + $lower = strtolower($trimmed); + $hardErrors = [ + 'connection refused', + 'permission denied', + 'command not found', + 'no route to host', + 'could not resolve hostname', + 'host key verification failed', + 'timed out', + 'operation timed out', + ]; + foreach ($hardErrors as $needle) { + if ($needle !== '' && strpos($lower, $needle) !== false) { + throw new Exception('Ошибка установки (script): ' . $trimmed); + } + } + } + // Try key-value format (e.g., "Port: 123" or "Server Public Key: abc") $result = self::parseKeyValueOutput($trimmed); if (!empty($result)) { @@ -620,7 +692,7 @@ class InstallProtocolManager if ($phase === 'install') { $lower = strtolower($trimmed); if ($lower === '' || strpos($lower, 'command not found') !== false || strpos($lower, 'error') !== false) { - throw new Exception('Ошибка установки (script): ' . ($trimmed !== '' ? $trimmed : 'empty output')); + throw new Exception('Ошибка установки (script): ' . ($trimmed !== '' ? $trimmed : 'empty output') . $installProbeSummary); } } @@ -1353,9 +1425,17 @@ class InstallProtocolManager } return $res; } catch (Throwable $e) { - self::markServerError($serverId, $e->getMessage()); - Logger::appendInstall($serverId, 'Activate failed: ' . $e->getMessage()); - throw $e; + $message = (string) $e->getMessage(); + if ( + stripos($message, 'server_protocols_ibfk_1') !== false + || (stripos($message, 'foreign key constraint fails') !== false && stripos($message, 'server_protocols') !== false) + ) { + $message = 'Сервер был удален или пересоздан во время установки. Обновите страницу и запустите установку заново.'; + } + + self::markServerError($serverId, $message); + Logger::appendInstall($serverId, 'Activate failed: ' . $message); + throw new Exception($message, 0, $e); } } @@ -1777,8 +1857,27 @@ class InstallProtocolManager $containerFilter = escapeshellarg('^' . $containerName . '$'); $containerArg = escapeshellarg($containerName); - $containerList = trim($server->executeCommand("docker ps -a --filter name={$containerFilter} --format '{{.Names}}'", true)); - if ($containerList === '') { + $containerListRaw = trim($server->executeCommand("docker ps -a --filter name={$containerFilter} --format '{{.Names}}'", true)); + if ($containerListRaw === '') { + return [ + 'status' => 'absent', + 'message' => 'Контейнер X-Ray не найден на сервере' + ]; + } + + if (preg_match('/docker: command not found|command not found|cannot connect to the docker daemon|permission denied/i', $containerListRaw)) { + return [ + 'status' => 'absent', + 'message' => 'Docker CLI недоступен на сервере', + 'details' => [ + 'container_name' => $containerName, + 'container_status' => $containerListRaw, + ] + ]; + } + + $containerNames = array_values(array_filter(array_map('trim', preg_split('/\R+/', $containerListRaw)))); + if (!in_array($containerName, $containerNames, true)) { return [ 'status' => 'absent', 'message' => 'Контейнер X-Ray не найден на сервере' diff --git a/inc/VpnServer.php b/inc/VpnServer.php index aee6752..587f91b 100644 --- a/inc/VpnServer.php +++ b/inc/VpnServer.php @@ -428,10 +428,12 @@ class VpnServer public function executeCommand(string $command, bool $sudo = false): string { $baseCommand = $command; - $escapedCommand = escapeshellarg($command); + $pathPrefix = 'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH; '; + $escapedCommand = ''; + $needsSudo = false; // Determine auth method - $sshOptions = '-q -o LogLevel=ERROR -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no'; + $sshOptions = '-o LogLevel=ERROR -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no'; $keyFile = ''; if (!empty($this->data['ssh_key'])) { @@ -440,6 +442,9 @@ class VpnServer chmod($keyFile, 0600); $sshOptions .= " -i {$keyFile} -o IdentitiesOnly=yes -o PubkeyAuthentication=yes -o PreferredAuthentications=publickey"; + $preparedCommand = $pathPrefix . $command; + $escapedCommand = escapeshellarg($preparedCommand); + $sshCommand = sprintf( "ssh -p %d %s %s@%s %s 2>&1", $this->data['port'], @@ -453,9 +458,11 @@ class VpnServer if ($needsSudo) { // Suppress sudo prompt text to keep command output machine-parseable. $command = "echo '{$this->data['password']}' | sudo -S -p '' " . $command; - $escapedCommand = escapeshellarg($command); } + $preparedCommand = $pathPrefix . $command; + $escapedCommand = escapeshellarg($preparedCommand); + $sshOptions .= " -o PreferredAuthentications=password -o PubkeyAuthentication=no"; $sshCommand = sprintf( "sshpass -p '%s' ssh -p %d %s %s@%s %s 2>&1", @@ -477,7 +484,7 @@ class VpnServer && preg_match('/(^|\\n)docker(\\s|$)/', ltrim($baseCommand)) && preg_match('/incorrect password attempts|sorry, try again|a password is required/i', $output) ) { - $escapedBaseCommand = escapeshellarg($baseCommand); + $escapedBaseCommand = escapeshellarg($pathPrefix . $baseCommand); $sshCommandNoSudo = sprintf( "sshpass -p '%s' ssh -p %d %s %s@%s %s 2>&1", $this->data['password'], From 25ef9a7071b251e8cdf3a8833660d81bafac223e Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 4 Apr 2026 18:07:55 +0300 Subject: [PATCH 72/72] feat: add available protocols section to README for better clarity --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index ff00004..82426f3 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,19 @@ Web-based management panel for Amnezia AWG (WireGuard) VPN servers. - User authentication and access control - **Automatic client expiration and traffic limit checks** via cron +## Available Protocols + +- AmneziaWG Advanced (`amnezia-wg-advanced`) +- AmneziaWG 2.0 (`awg2`) +- WireGuard Standard (`wireguard-standard`) +- OpenVPN (`openvpn`) +- Shadowsocks (`shadowsocks`) +- XRay VLESS (`xray-vless`) +- MTProxy (Telegram) (`mtproxy`) +- SMB Server (`smb`) +- AIVPN (`aivpn`) - https://github.com/infosave2007/aivpn + + ## Requirements - Docker