diff --git a/README.md b/README.md index 8e8ea99..6711677 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Web-based management panel for Amnezia AWG (WireGuard) VPN servers. ## Features - VPN server deployment via SSH +- **Import from existing VPN panels** (wg-easy, 3x-ui) - Client configuration management with **expiration dates** - **Traffic limits** for clients with automatic enforcement - **Server backup and restore** functionality @@ -57,8 +58,13 @@ JWT_SECRET=your-secret-key-change-this 1. Servers → Add Server 2. Enter: name, host IP, SSH port, username, password -3. Click Deploy Server -4. Wait for deployment +3. **(Optional) Enable import from existing panel:** + - Check "Import from existing panel" + - Select panel type (wg-easy or 3x-ui) + - Upload backup file (JSON) +4. Click "Create Server" +5. Wait for deployment +6. Clients will be imported automatically if import was enabled ### Create Client @@ -221,6 +227,13 @@ POST /api/servers/{id}/restore - Restore from backup DELETE /api/backups/{id} - Delete backup ``` +### Panel Import +``` +POST /api/servers/{id}/import - Import clients from existing panel + Parameters: panel_type (wg-easy|3x-ui), backup_file (multipart/form-data) +GET /api/servers/{id}/imports - Get import history for server +``` + ## Translation Add OpenRouter API key in Settings, then run: @@ -244,6 +257,7 @@ inc/ - Core classes Translator.php - Multi-language JWT.php - Token auth QrUtil.php - QR code generation + PanelImporter.php - Import from wg-easy/3x-ui templates/ - Twig templates migrations/ - SQL migrations (executed in alphabetical order) ``` diff --git a/inc/PanelImporter.php b/inc/PanelImporter.php new file mode 100644 index 0000000..558460b --- /dev/null +++ b/inc/PanelImporter.php @@ -0,0 +1,354 @@ +serverId = $serverId; + $this->userId = $userId; + $this->panelType = $panelType; + } + + /** + * Parse and validate backup file + */ + public function parseBackupFile(string $jsonContent): bool + { + $data = json_decode($jsonContent, true); + + if (!$data) { + throw new Exception('Invalid JSON format'); + } + + $this->importData = $data; + + // Validate based on panel type + switch ($this->panelType) { + case 'wg-easy': + return $this->validateWgEasy($data); + case '3x-ui': + return $this->validate3xUi($data); + default: + throw new Exception('Unsupported panel type'); + } + } + + /** + * Validate wg-easy backup format + */ + private function validateWgEasy(array $data): bool + { + if (!isset($data['clients']) || !is_array($data['clients'])) { + throw new Exception('Invalid wg-easy format: missing clients array'); + } + + return true; + } + + /** + * Validate 3x-ui export format + */ + private function validate3xUi(array $data): bool + { + if (!isset($data['clients']) || !is_array($data['clients'])) { + throw new Exception('Invalid 3x-ui format: missing clients array'); + } + + return true; + } + + /** + * Import clients from parsed data + */ + public function import(): array + { + $importId = $this->createImportRecord(); + + try { + $this->updateImportStatus($importId, 'processing'); + + $clients = $this->extractClients(); + $imported = []; + $errors = []; + + foreach ($clients as $clientData) { + try { + $clientId = $this->createClient($clientData); + $imported[] = [ + 'id' => $clientId, + 'name' => $clientData['name'] + ]; + } catch (Exception $e) { + $errors[] = [ + 'name' => $clientData['name'], + 'error' => $e->getMessage() + ]; + } + } + + $this->updateImportRecord($importId, count($imported)); + $this->updateImportStatus($importId, 'completed'); + + return [ + 'success' => true, + 'import_id' => $importId, + 'imported' => $imported, + 'errors' => $errors, + 'total' => count($clients), + 'imported_count' => count($imported), + 'error_count' => count($errors) + ]; + + } catch (Exception $e) { + $this->updateImportStatus($importId, 'failed', $e->getMessage()); + throw $e; + } + } + + /** + * Extract client data based on panel type + */ + private function extractClients(): array + { + switch ($this->panelType) { + case 'wg-easy': + return $this->extractWgEasyClients(); + case '3x-ui': + return $this->extract3xUiClients(); + default: + return []; + } + } + + /** + * Extract clients from wg-easy backup + */ + private function extractWgEasyClients(): array + { + $clients = []; + + foreach ($this->importData['clients'] as $client) { + $clients[] = [ + 'name' => $client['name'] ?? 'imported_client_' . uniqid(), + 'public_key' => $client['publicKey'] ?? null, + 'private_key' => $client['privateKey'] ?? null, + 'preshared_key' => $client['preSharedKey'] ?? null, + 'address' => $client['address'] ?? null, + 'enabled' => $client['enabled'] ?? true, + 'created_at' => $client['createdAt'] ?? null + ]; + } + + return $clients; + } + + /** + * Extract clients from 3x-ui export + */ + private function extract3xUiClients(): array + { + $clients = []; + + foreach ($this->importData['clients'] as $client) { + // 3x-ui uses email as client name + $name = $client['email'] ?? 'imported_client_' . uniqid(); + + $clients[] = [ + 'name' => $name, + 'public_key' => null, // Will be generated + 'private_key' => null, // Will be generated + 'preshared_key' => null, + 'address' => null, // Will be assigned from pool + 'enabled' => $client['enable'] ?? true, + 'expiry_time' => $client['expiryTime'] ?? 0, + 'total_gb' => $client['totalGB'] ?? 0 + ]; + } + + return $clients; + } + + /** + * Create client from imported data + */ + private function createClient(array $clientData): int + { + // If we have keys and address from wg-easy, use them + if (!empty($clientData['public_key']) && !empty($clientData['address'])) { + return $this->createClientWithKeys($clientData); + } + + // Otherwise, create new client with generated keys + return $this->createNewClient($clientData); + } + + /** + * Create client with existing keys (wg-easy import) + */ + private function createClientWithKeys(array $clientData): int + { + $db = DB::conn(); + + $stmt = $db->prepare(" + INSERT INTO vpn_clients ( + server_id, user_id, name, client_ip, + public_key, private_key, preshared_key, + status, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW()) + "); + + $status = ($clientData['enabled'] ?? true) ? 'active' : 'disabled'; + $privateKey = $clientData['private_key'] ?? ''; + $presharedKey = $clientData['preshared_key'] ?? ''; + + $stmt->execute([ + $this->serverId, + $this->userId, + $clientData['name'], + $clientData['address'], + $clientData['public_key'], + $privateKey, + $presharedKey, + $status + ]); + + $clientId = (int)$db->lastInsertId(); + + // Generate config and QR code + $client = new VpnClient($clientId); + $client->generateConfig(); + + return $clientId; + } + + /** + * Create new client with generated keys (3x-ui import) + */ + private function createNewClient(array $clientData): int + { + // Calculate expiration if provided + $expiresInDays = null; + if (!empty($clientData['expiry_time']) && $clientData['expiry_time'] > 0) { + $expiryTimestamp = $clientData['expiry_time'] / 1000; // Convert from ms + $daysUntilExpiry = ceil(($expiryTimestamp - time()) / 86400); + if ($daysUntilExpiry > 0) { + $expiresInDays = (int)$daysUntilExpiry; + } + } + + // Create client using standard method + $clientId = VpnClient::create( + $this->serverId, + $this->userId, + $clientData['name'], + $expiresInDays + ); + + // Set traffic limit if provided + if (!empty($clientData['total_gb']) && $clientData['total_gb'] > 0) { + $client = new VpnClient($clientId); + $limitBytes = $clientData['total_gb'] * 1073741824; // GB to bytes + $client->setTrafficLimit($limitBytes); + } + + // Set status + if (isset($clientData['enabled']) && !$clientData['enabled']) { + $db = DB::conn(); + $stmt = $db->prepare("UPDATE vpn_clients SET status = 'disabled' WHERE id = ?"); + $stmt->execute([$clientId]); + } + + return $clientId; + } + + /** + * Create import record in database + */ + private function createImportRecord(): int + { + $db = DB::conn(); + + $stmt = $db->prepare(" + INSERT INTO panel_imports ( + server_id, panel_type, import_file_name, + import_data, status, created_by + ) VALUES (?, ?, ?, ?, 'pending', ?) + "); + + $stmt->execute([ + $this->serverId, + $this->panelType, + 'backup_' . date('Y-m-d_H-i-s') . '.json', + json_encode($this->importData), + $this->userId + ]); + + return (int)$db->lastInsertId(); + } + + /** + * Update import record with results + */ + private function updateImportRecord(int $importId, int $clientsImported): void + { + $db = DB::conn(); + + $stmt = $db->prepare(" + UPDATE panel_imports + SET clients_imported = ? + WHERE id = ? + "); + + $stmt->execute([$clientsImported, $importId]); + } + + /** + * Update import status + */ + private function updateImportStatus(int $importId, string $status, ?string $error = null): void + { + $db = DB::conn(); + + $stmt = $db->prepare(" + UPDATE panel_imports + SET status = ?, error_message = ? + WHERE id = ? + "); + + $stmt->execute([$status, $error, $importId]); + } + + /** + * Get import history for server + */ + public static function getImportHistory(int $serverId): array + { + $db = DB::conn(); + + $stmt = $db->prepare(" + SELECT + pi.*, + u.name as created_by_name + FROM panel_imports pi + LEFT JOIN users u ON pi.created_by = u.id + WHERE pi.server_id = ? + ORDER BY pi.created_at DESC + "); + + $stmt->execute([$serverId]); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } +} diff --git a/migrations/001_init.sql b/migrations/001_init.sql index a584315..bc453a3 100644 --- a/migrations/001_init.sql +++ b/migrations/001_init.sql @@ -307,5 +307,15 @@ INSERT INTO translations (language_code, translation_key, translation_value) VAL ('en', 'users.role_user', 'User'), ('en', 'settings.api_key_configured', 'API Key Configured'), ('en', 'settings.no_api_key', 'No API key configured. Auto-translation will not work.'), -('en', 'settings.skip_validation', 'Skip validation (save without testing)') +('en', 'settings.skip_validation', 'Skip validation (save without testing)'), +('en', 'servers.import_from_panel', 'Import from existing panel'), +('en', 'servers.select_panel_type', 'Select panel type'), +('en', 'servers.panel_type_wgeasy', 'wg-easy'), +('en', 'servers.panel_type_3xui', '3x-ui'), +('en', 'servers.upload_backup_file', 'Upload backup file (JSON)'), +('en', 'servers.import_in_progress', 'Import in progress...'), +('en', 'servers.import_success', 'Successfully imported {0} clients'), +('en', 'servers.import_failed', 'Import failed'), +('en', 'servers.import_partial', 'Imported {0} of {1} clients'), +('en', 'servers.import_history', 'Import History') ON DUPLICATE KEY UPDATE translation_value=VALUES(translation_value); diff --git a/migrations/002_translations_ru.sql b/migrations/002_translations_ru.sql index 8f57c3e..3733ae4 100644 --- a/migrations/002_translations_ru.sql +++ b/migrations/002_translations_ru.sql @@ -134,5 +134,15 @@ INSERT INTO translations (language_code, translation_key, translation_value) VAL ('ru', 'users.delete_confirm', 'Удалить {0}?'), ('ru', 'users.role', 'Роль'), ('ru', 'users.role_admin', 'Администратор'), -('ru', 'users.role_user', 'Пользователь') +('ru', 'users.role_user', 'Пользователь'), +('ru', 'servers.import_from_panel', 'Импорт из другой панели'), +('ru', 'servers.select_panel_type', 'Выберите тип панели'), +('ru', 'servers.panel_type_wgeasy', 'wg-easy'), +('ru', 'servers.panel_type_3xui', '3x-ui'), +('ru', 'servers.upload_backup_file', 'Загрузите файл резервной копии (JSON)'), +('ru', 'servers.import_in_progress', 'Импорт выполняется...'), +('ru', 'servers.import_success', 'Успешно импортировано клиентов: {0}'), +('ru', 'servers.import_failed', 'Ошибка импорта'), +('ru', 'servers.import_partial', 'Импортировано {0} из {1} клиентов'), +('ru', 'servers.import_history', 'История импорта') ON DUPLICATE KEY UPDATE translation_value=VALUES(translation_value); diff --git a/migrations/003_translations_es.sql b/migrations/003_translations_es.sql index 43ae907..163520d 100644 --- a/migrations/003_translations_es.sql +++ b/migrations/003_translations_es.sql @@ -134,5 +134,15 @@ INSERT INTO translations (language_code, translation_key, translation_value) VAL ('es', 'users.delete_confirm', '¿Eliminar {0}?'), ('es', 'users.role', 'Rol'), ('es', 'users.role_admin', 'Administrador'), -('es', 'users.role_user', 'Usuario') +('es', 'users.role_user', 'Usuario'), +('es', 'servers.import_from_panel', 'Importar desde panel existente'), +('es', 'servers.select_panel_type', 'Seleccione tipo de panel'), +('es', 'servers.panel_type_wgeasy', 'wg-easy'), +('es', 'servers.panel_type_3xui', '3x-ui'), +('es', 'servers.upload_backup_file', 'Subir archivo de respaldo (JSON)'), +('es', 'servers.import_in_progress', 'Importación en progreso...'), +('es', 'servers.import_success', 'Se importaron {0} clientes correctamente'), +('es', 'servers.import_failed', 'Error de importación'), +('es', 'servers.import_partial', 'Importados {0} de {1} clientes'), +('es', 'servers.import_history', 'Historial de importación') ON DUPLICATE KEY UPDATE translation_value=VALUES(translation_value); diff --git a/migrations/004_translations_de.sql b/migrations/004_translations_de.sql index 6d9a0b7..b9dccde 100644 --- a/migrations/004_translations_de.sql +++ b/migrations/004_translations_de.sql @@ -134,5 +134,15 @@ INSERT INTO translations (language_code, translation_key, translation_value) VAL ('de', 'users.delete_confirm', '{0} löschen?'), ('de', 'users.role', 'Rolle'), ('de', 'users.role_admin', 'Admin'), -('de', 'users.role_user', 'Benutzer') +('de', 'users.role_user', 'Benutzer'), +('de', 'servers.import_from_panel', 'Import aus bestehendem Panel'), +('de', 'servers.select_panel_type', 'Panel-Typ auswählen'), +('de', 'servers.panel_type_wgeasy', 'wg-easy'), +('de', 'servers.panel_type_3xui', '3x-ui'), +('de', 'servers.upload_backup_file', 'Backup-Datei hochladen (JSON)'), +('de', 'servers.import_in_progress', 'Import läuft...'), +('de', 'servers.import_success', '{0} Clients erfolgreich importiert'), +('de', 'servers.import_failed', 'Import fehlgeschlagen'), +('de', 'servers.import_partial', '{0} von {1} Clients importiert'), +('de', 'servers.import_history', 'Import-Historie') ON DUPLICATE KEY UPDATE translation_value=VALUES(translation_value); diff --git a/migrations/005_translations_fr.sql b/migrations/005_translations_fr.sql index c0968e6..cda6410 100644 --- a/migrations/005_translations_fr.sql +++ b/migrations/005_translations_fr.sql @@ -134,5 +134,15 @@ INSERT INTO translations (language_code, translation_key, translation_value) VAL ('fr', 'users.delete_confirm', 'Supprimer {0} ?'), ('fr', 'users.role', 'Rôle'), ('fr', 'users.role_admin', 'Administrateur'), -('fr', 'users.role_user', 'Utilisateur') +('fr', 'users.role_user', 'Utilisateur'), +('fr', 'servers.import_from_panel', 'Importer depuis un panel existant'), +('fr', 'servers.select_panel_type', 'Sélectionnez le type de panel'), +('fr', 'servers.panel_type_wgeasy', 'wg-easy'), +('fr', 'servers.panel_type_3xui', '3x-ui'), +('fr', 'servers.upload_backup_file', 'Télécharger le fichier de sauvegarde (JSON)'), +('fr', 'servers.import_in_progress', 'Importation en cours...'), +('fr', 'servers.import_success', '{0} clients importés avec succès'), +('fr', 'servers.import_failed', 'Échec de l''importation'), +('fr', 'servers.import_partial', '{0} clients importés sur {1}'), +('fr', 'servers.import_history', 'Historique d''importation') ON DUPLICATE KEY UPDATE translation_value=VALUES(translation_value); diff --git a/migrations/006_translations_zh.sql b/migrations/006_translations_zh.sql index c675f7f..da1ef68 100644 --- a/migrations/006_translations_zh.sql +++ b/migrations/006_translations_zh.sql @@ -134,5 +134,15 @@ INSERT INTO translations (language_code, translation_key, translation_value) VAL ('zh', 'users.delete_confirm', '删除 {0}?'), ('zh', 'users.role', '角色'), ('zh', 'users.role_admin', '管理员'), -('zh', 'users.role_user', '用户') +('zh', 'users.role_user', '用户'), +('zh', 'servers.import_from_panel', '从现有面板导入'), +('zh', 'servers.select_panel_type', '选择面板类型'), +('zh', 'servers.panel_type_wgeasy', 'wg-easy'), +('zh', 'servers.panel_type_3xui', '3x-ui'), +('zh', 'servers.upload_backup_file', '上传备份文件 (JSON)'), +('zh', 'servers.import_in_progress', '导入进行中...'), +('zh', 'servers.import_success', '成功导入 {0} 个客户端'), +('zh', 'servers.import_failed', '导入失败'), +('zh', 'servers.import_partial', '已导入 {0}/{1} 个客户端'), +('zh', 'servers.import_history', '导入历史') ON DUPLICATE KEY UPDATE translation_value=VALUES(translation_value); diff --git a/migrations/008_add_panel_imports.sql b/migrations/008_add_panel_imports.sql new file mode 100644 index 0000000..a111167 --- /dev/null +++ b/migrations/008_add_panel_imports.sql @@ -0,0 +1,20 @@ +-- Add panel imports tracking table +-- This migration adds functionality to track imports from other VPN panels + +CREATE TABLE IF NOT EXISTS panel_imports ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + server_id INT UNSIGNED NOT NULL, + panel_type ENUM('wg-easy', '3x-ui') NOT NULL, + import_file_name VARCHAR(255) NOT NULL, + clients_imported INT UNSIGNED DEFAULT 0, + import_data JSON NULL COMMENT 'Original import data for reference', + status ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending', + error_message TEXT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by INT UNSIGNED NULL, + INDEX idx_server_id (server_id), + INDEX idx_panel_type (panel_type), + INDEX idx_status (status), + FOREIGN KEY (server_id) REFERENCES vpn_servers(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/public/index.php b/public/index.php index c78dbca..4adc895 100644 --- a/public/index.php +++ b/public/index.php @@ -18,6 +18,7 @@ require_once __DIR__ . '/../inc/VpnServer.php'; require_once __DIR__ . '/../inc/VpnClient.php'; require_once __DIR__ . '/../inc/Translator.php'; require_once __DIR__ . '/../inc/JWT.php'; +require_once __DIR__ . '/../inc/PanelImporter.php'; // Load environment configuration Config::load(__DIR__ . '/../.env'); @@ -252,6 +253,21 @@ Router::post('/servers/create', function () { 'password' => $password, ]); + // 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()]); @@ -355,9 +371,48 @@ Router::get('/servers/{id}', function ($params) { // 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) { + $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', + 'text' => 'Import failed: ' . $e->getMessage() + ]; + 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, ]); } catch (Exception $e) { error_log('Server view error: ' . $e->getMessage() . ' at ' . $e->getFile() . ':' . $e->getLine()); @@ -802,6 +857,87 @@ Router::delete('/api/servers/{id}/delete', function ($params) { header('Content-Type: application/json'); $user = JWT::requireAuth(); + +// 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']; + + // Validate server ownership + $server = VpnServer::getById($serverId); + if (!$server || $server['user_id'] != $user['id']) { + http_response_code(404); + echo json_encode(['error' => 'Server not found']); + 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([ + 'success' => false, + 'error' => $e->getMessage() + ]); + } +}); + +// 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']; + + // Validate server ownership + $server = VpnServer::getById($serverId); + if (!$server || $server['user_id'] != $user['id']) { + http_response_code(404); + echo json_encode(['error' => 'Server not found']); + return; + } + + $imports = PanelImporter::getImportHistory($serverId); + + echo json_encode([ + 'success' => true, + 'imports' => $imports + ]); +}); if (!$user) return; $serverId = (int)$params['id']; diff --git a/templates/servers/create.twig b/templates/servers/create.twig index 90b26c0..90b6085 100644 --- a/templates/servers/create.twig +++ b/templates/servers/create.twig @@ -4,13 +4,54 @@
{{ server.host }}