From ea82b78a7db0ee6ec6eb25afc22a4cd40c6766d6 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Fri, 23 Jan 2026 17:55:40 +0300 Subject: [PATCH] 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