Files
amneziavpnphp/public/index.php
T
2026-02-25 06:57:04 +03:00

4039 lines
136 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Amnezia VPN Web Panel
* 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();
// Load dependencies
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../inc/Config.php';
require_once __DIR__ . '/../inc/DB.php';
require_once __DIR__ . '/../inc/Auth.php';
require_once __DIR__ . '/../inc/Router.php';
require_once __DIR__ . '/../inc/View.php';
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';
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');
// Test database connection
try {
DB::conn();
} catch (Throwable $e) {
die('Database connection error: ' . $e->getMessage());
}
// Seed admin user if not exists
try {
$adminEmail = Config::get('ADMIN_EMAIL');
$adminPass = Config::get('ADMIN_PASSWORD');
if ($adminEmail && $adminPass) {
Auth::seedAdmin($adminEmail, $adminPass);
}
} catch (Throwable $e) {
// Ignore errors
}
// Initialize translator
Translator::init();
InstallProtocolManager::ensureDefaults();
// Initialize template engine
$user = Auth::user();
$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
{
// Check JWT token in Authorization header
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if ($authHeader && preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) {
$token = $matches[1];
$user = JWT::verify($token);
if ($user) {
return $user;
}
}
// Fallback to session
if (isset($_SESSION['user_id'])) {
return Auth::user();
}
return null;
}
View::init(__DIR__ . '/../templates', [
'app_name' => $appName,
'user' => $user,
'current_language' => Translator::getCurrentLanguage(),
'languages' => Translator::getSupportedLanguages(),
'current_uri' => $_SERVER['REQUEST_URI'] ?? '/dashboard',
't' => function ($key, $params = []) {
return Translator::t($key, $params);
}
]);
// Helper function for redirects
function redirect(string $to): void
{
header('Location: ' . $to);
exit;
}
// Helper function to require authentication
function requireAuth(): void
{
if (!Auth::check()) {
redirect('/login');
}
}
// Helper function to require admin
function requireAdmin(): void
{
requireAuth();
if (!Auth::isAdmin()) {
http_response_code(403);
echo 'Forbidden: Admin access required';
exit;
}
}
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
{
// Try JWT first
$token = JWT::getTokenFromHeader();
if ($token !== null) {
$user = JWT::verify($token);
if ($user !== null) {
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
{
$user = getAuthUser();
if ($user === null) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'Authentication required']);
return null;
}
return $user;
}
/**
* PUBLIC ROUTES
*/
// Home page
Router::get('/', function () {
if (!Auth::check()) {
redirect('/login');
}
redirect('/dashboard');
});
// Login page
Router::get('/login', function () {
if (Auth::check()) {
redirect('/dashboard');
}
View::render('login.twig');
});
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']);
});
// Register page
Router::get('/register', function () {
if (Auth::check()) {
redirect('/dashboard');
}
View::render('register.twig');
});
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) {
Auth::login($email, $password);
redirect('/dashboard');
}
} catch (Throwable $e) {
// Email already exists or other error
}
View::render('register.twig', ['error' => 'Registration failed. Email may already be in use.']);
});
// Logout
Router::get('/logout', function () {
Auth::logout();
redirect('/login');
});
/**
* AUTHENTICATED ROUTES
*/
// Dashboard
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']);
// Get real-time online clients count from Xray API
$onlineData = ServerMonitoring::countOnlineClients();
// Also count clients with recent handshake (within 5 minutes) for WireGuard/AWG
$pdo = DB::conn();
$stmt = $pdo->query("
SELECT COUNT(*) as cnt FROM vpn_clients
WHERE last_handshake IS NOT NULL
AND last_handshake > DATE_SUB(NOW(), INTERVAL 5 MINUTE)
AND status = 'active'
");
$recentHandshakeCount = (int) $stmt->fetchColumn();
// Combine both counts (XRay online + recent handshake), avoiding duplicates
$totalOnline = max($onlineData['total'], $recentHandshakeCount);
View::render('dashboard.twig', [
'servers' => $servers,
'clients' => $clients,
'online_count' => $totalOnline,
'online_users' => $onlineData['users'],
]);
});
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()
: VpnServer::listByUser($user['id']);
View::render('servers/index.twig', ['servers' => $servers]);
});
// Create server page
Router::get('/servers/create', function () {
requireAuth();
$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();
$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'],
'name' => $name,
'host' => $host,
'port' => $port,
'username' => $username,
'password' => $password,
'ssh_key' => $sshKey,
'container_name' => $containerName,
'vpn_subnet' => $vpnSubnet,
'install_protocol' => $protocolSlug,
'install_options' => $installOptions,
]);
redirect('/servers/' . $serverId . '/deploy');
} catch (Exception $e) {
View::render('servers/create.twig', [
'error' => $e->getMessage(),
'selected_mode' => $creationMode,
'form_data' => $formData,
'protocols' => $protocols,
'default_protocol' => $defaultProtocol
]);
}
});
// Delete server action
Router::post('/servers/{id}/delete', function ($params) {
requireAuth();
$user = Auth::user();
$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');
} catch (Exception $e) {
$_SESSION['error_message'] = $e->getMessage();
redirect('/servers');
}
});
// Deploy server page
Router::get('/servers/{id}/deploy', 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()) {
http_response_code(403);
echo 'Forbidden';
return;
}
View::render('servers/deploy.twig', ['server' => $serverData]);
} catch (Exception $e) {
http_response_code(404);
echo 'Server not found';
}
});
// Deploy server action (AJAX)
Router::post('/servers/{id}/deploy', function ($params) {
requireAuth();
header('Content-Type: application/json');
$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()) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden']);
return;
}
$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()]);
}
});
// Activate protocol on server (AJAX)
Router::post('/servers/{id}/protocols/activate', function ($params) {
requireAuth();
// 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()) {
http_response_code(403);
echo 'Forbidden';
return;
}
$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',
'text' => 'Import failed: ' . $e->getMessage()
];
unset($_SESSION['pending_import']);
}
// Refresh clients list after import
$clients = VpnClient::listByServer($serverId);
}
}
// Get online clients for this server (Xray)
$onlineLogins = ServerMonitoring::getOnlineClientsForServer($serverData);
View::render('servers/view.twig', [
'server' => $serverData,
'clients' => $clients,
'import_message' => $importMessage,
'server_protocols' => $serverProtocols,
'selected_protocol_id' => $selectedProtocolId,
'available_protocols' => $availableProtocols,
'online_logins' => $onlineLogins,
]);
} catch (Exception $e) {
error_log('Server view error: ' . $e->getMessage() . ' at ' . $e->getFile() . ':' . $e->getLine());
http_response_code(404);
echo 'Server not found: ' . htmlspecialchars($e->getMessage());
}
});
// Server monitoring page
Router::get('/servers/{id}/monitoring', 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()) {
http_response_code(403);
echo 'Forbidden';
return;
}
// Get clients for this server
$clients = VpnClient::listByServer($serverId);
View::render('servers/monitoring.twig', [
'server' => $serverData,
'clients' => $clients,
]);
} catch (Exception $e) {
http_response_code(404);
echo 'Server not found';
}
});
// Import server configuration from uploaded backup
Router::post('/servers/{id}/config/import', function ($params) {
requireAuth();
$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()) {
http_response_code(403);
echo 'Forbidden';
return;
}
$server->delete();
redirect('/servers');
} catch (Exception $e) {
redirect('/servers');
}
});
// Create client for server
Router::post('/servers/{id}/clients/create', function ($params) {
requireAuth();
$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);
} elseif (!empty($_POST['expires_in_days']) && $_POST['expires_in_days'] !== 'custom') {
$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);
} elseif (!empty($_POST['traffic_limit_gb']) && $_POST['traffic_limit_gb'] !== 'custom') {
// Convert GB to bytes
$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()) {
http_response_code(403);
echo 'Forbidden';
return;
}
$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()));
}
});
// View client
Router::get('/clients/{id}', function ($params) {
requireAuth();
$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()) {
http_response_code(403);
echo 'Forbidden';
return;
}
$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) {
$clientData['show_text_content'] = !empty($protocol['show_text_content']);
}
if ($protocol && ($protocol['output_template'] ?? '') !== '') {
$slug = $protocol['slug'] ?? '';
$isWireguard = in_array($slug, ['amnezia-wg-advanced', 'wireguard-standard', 'amnezia-wg'], true);
if ($isWireguard) {
// For WG, we dont 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';
}
});
// Download client config
Router::get('/clients/{id}/download', function ($params) {
requireAuth();
// 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()) {
http_response_code(403);
echo 'Forbidden';
return;
}
// 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());
}
$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;
} catch (Exception $e) {
http_response_code(404);
echo 'Client not found';
}
});
// 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'];
try {
$client = new VpnClient($clientId);
$clientData = $client->getData();
// Check ownership
$user = Auth::user();
if ($clientData['user_id'] != $user['id'] && !Auth::isAdmin()) {
http_response_code(403);
echo 'Forbidden';
return;
}
if ($client->revoke()) {
redirect('/servers/' . $clientData['server_id'] . '?success=Client+revoked');
} else {
redirect('/servers/' . $clientData['server_id'] . '?error=Failed+to+revoke+client');
}
} catch (Exception $e) {
redirect('/dashboard?error=' . urlencode($e->getMessage()));
}
});
// Restore client access
Router::post('/clients/{id}/restore', function ($params) {
requireAuth();
$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()) {
http_response_code(403);
echo 'Forbidden';
return;
}
if ($client->restore()) {
redirect('/servers/' . $clientData['server_id'] . '?success=Client+restored');
} else {
redirect('/servers/' . $clientData['server_id'] . '?error=Failed+to+restore+client');
}
} catch (Exception $e) {
redirect('/dashboard?error=' . urlencode($e->getMessage()));
}
});
// Delete client
Router::post('/clients/{id}/delete', function ($params) {
requireAuth();
$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()) {
http_response_code(403);
echo 'Forbidden';
return;
}
$serverId = $clientData['server_id'];
if ($client->delete()) {
redirect('/servers/' . $serverId . '?success=Client+deleted');
} else {
redirect('/servers/' . $serverId . '?error=Failed+to+delete+client');
}
} catch (Exception $e) {
redirect('/dashboard?error=' . urlencode($e->getMessage()));
}
});
// Sync client stats
Router::post('/clients/{id}/sync-stats', function ($params) {
requireAuth();
$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()) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden']);
return;
}
if ($client->syncStats()) {
// Reload client data
$client = new VpnClient($clientId);
$stats = $client->getFormattedStats();
echo json_encode(['success' => true, 'stats' => $stats]);
} else {
echo json_encode(['success' => false, 'error' => 'Failed to sync stats']);
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
});
// Sync all stats for server
Router::post('/servers/{id}/sync-stats', function ($params) {
requireAuth();
$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()) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden']);
return;
}
$synced = VpnClient::syncAllStatsForServer($serverId);
echo json_encode(['success' => true, 'synced' => $synced]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
});
/**
* API ROUTES (for Telegram bot integration)
*/
// 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([
'success' => true,
'token' => $token,
'type' => 'Bearer',
'expires_in' => 30 * 24 * 3600 // 30 days
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => 'Token generation failed']);
}
});
// API: Create persistent API token
Router::post('/api/tokens', function () {
header('Content-Type: application/json');
$user = JWT::requireAuth();
if (!$user)
return;
$name = $_POST['name'] ?? 'API Token';
$expiresIn = isset($_POST['expires_in']) ? (int) $_POST['expires_in'] : 2592000; // 30 days default
try {
$tokenData = JWT::createApiToken($user['id'], $name, $expiresIn);
echo json_encode([
'success' => true,
'token' => $tokenData
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
});
// API: List user's API tokens
Router::get('/api/tokens', function () {
header('Content-Type: application/json');
$user = JWT::requireAuth();
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
ORDER BY created_at DESC
");
$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;
try {
JWT::revokeApiToken($params['id'], $user['id']);
echo json_encode(['success' => true]);
} catch (Exception $e) {
http_response_code(404);
echo json_encode(['error' => $e->getMessage()]);
}
});
// API: List servers
Router::get('/api/servers', function () {
header('Content-Type: application/json');
$user = JWT::requireAuth();
if (!$user)
return;
$servers = VpnServer::listByUser($user['id']);
// Enrich with installed protocols
$pdo = DB::conn();
foreach ($servers as &$server) {
$stmt = $pdo->prepare('SELECT p.id, p.slug, p.name FROM server_protocols sp JOIN protocols p ON p.id = sp.protocol_id WHERE sp.server_id = ?');
$stmt->execute([$server['id']]);
$server['protocols'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
}
unset($server);
echo json_encode(['servers' => $servers]);
});
// API: Create server
Router::post('/api/servers/create', function () {
header('Content-Type: application/json');
$user = JWT::requireAuth();
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);
$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'],
'name' => $name,
'host' => $host,
'port' => $port,
'username' => $username,
'password' => $password,
]);
http_response_code(201);
echo json_encode([
'success' => true,
'server_id' => $serverId,
'message' => 'Server created successfully'
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
});
// 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'];
// Validate server ownership
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([
'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
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
]);
});
// 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'];
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
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
});
// 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'];
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,
'count' => count($backups)
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
});
// 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'];
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
$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) {
error_log('Restore backup exception: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['error' => $e->getMessage(), 'success' => false]);
}
});
// API: Delete backup
Router::delete('/api/backups/{id}', function ($params) {
header('Content-Type: application/json');
$user = requireApiAuth();
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'
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
});
// API: List clients
Router::get('/api/clients', function () {
header('Content-Type: application/json');
$user = JWT::requireAuth();
if (!$user)
return;
$clients = VpnClient::listByUser($user['id']);
echo json_encode(['clients' => $clients]);
});
// 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'];
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' => [
'id' => $clientData['id'],
'name' => $clientData['name'],
'server_id' => $clientData['server_id'],
'client_ip' => $clientData['client_ip'],
'status' => $clientData['status'],
'created_at' => $clientData['created_at'],
'stats' => $stats,
'bytes_sent' => $clientData['bytes_sent'],
'bytes_received' => $clientData['bytes_received'],
'last_handshake' => $clientData['last_handshake'],
'config' => $clientData['config'],
'qr_code' => $clientData['qr_code'],
]
]);
} catch (Exception $e) {
http_response_code(404);
echo json_encode(['error' => 'Client not found']);
}
});
// 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'];
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'],
'client_name' => $clientData['name']
]);
} catch (Exception $e) {
http_response_code(404);
echo json_encode(['error' => 'Client not found']);
}
});
// 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'];
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 {
http_response_code(500);
echo json_encode(['error' => 'Failed to revoke client']);
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
});
// 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'];
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 {
http_response_code(500);
echo json_encode(['error' => 'Failed to restore client']);
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
});
// API: Delete client
Router::delete('/api/clients/{id}/delete', function ($params) {
header('Content-Type: application/json');
$user = JWT::requireAuth();
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;
}
if ($client->delete()) {
echo json_encode(['success' => true, 'message' => 'Client deleted']);
} else {
http_response_code(500);
echo json_encode(['error' => 'Failed to delete client']);
}
} catch (Exception $e) {
http_response_code(404);
echo json_encode(['error' => 'Client not found']);
}
});
// 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];
$user = JWT::verify($token);
} else if (isset($_SESSION['user_id'])) {
// 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;
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);
echo json_encode(['error' => $e->getMessage()]);
}
});
// 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];
$user = JWT::verify($token);
} else if (isset($_SESSION['user_id'])) {
// 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;
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);
echo json_encode(['error' => $e->getMessage()]);
}
});
// 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'];
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'],
'client_ip' => $clientData['client_ip'],
'status' => $clientData['status'],
'created_at' => $clientData['created_at'],
'stats' => $stats,
'bytes_sent' => $clientData['bytes_sent'],
'bytes_received' => $clientData['bytes_received'],
'last_handshake' => $clientData['last_handshake'],
];
}
echo json_encode(['success' => true, 'clients' => $clientsData]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
});
// API: Get online clients for a server (real-time)
Router::get('/api/servers/{id}/online', 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'];
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;
}
require_once __DIR__ . '/../inc/ServerMonitoring.php';
$onlineLogins = ServerMonitoring::getOnlineClientsForServer($serverData);
echo json_encode(['success' => true, 'online' => $onlineLogins]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
});
// 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;
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
$serverId = (int) ($data['server_id'] ?? 0);
$name = trim($data['name'] ?? '');
$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 {
$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,
'client' => [
'id' => $clientData['id'],
'name' => $clientData['name'],
'server_id' => $clientData['server_id'],
'client_ip' => $clientData['client_ip'],
'status' => $clientData['status'],
'expires_at' => $clientData['expires_at'],
'created_at' => $clientData['created_at'],
'config' => $clientData['config'],
'qr_code' => $clientData['qr_code'],
]
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
});
// 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'] ?? 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
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
});
// 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'];
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
$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'],
'extended_days' => $days
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
});
// 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);
try {
$clients = VpnClient::getExpiringClients($days);
// Filter by user if not admin
if ($user['role'] !== 'admin') {
$clients = array_filter($clients, function ($c) use ($user) {
return $c['user_id'] == $user['id'];
});
}
echo json_encode([
'success' => true,
'clients' => array_values($clients),
'count' => count($clients)
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
});
// 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'];
$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;
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,
'limit_gb' => $limitBytes ? round($limitBytes / 1073741824, 2) : null
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
});
// 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'];
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
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
});
// Get clients over traffic limit
Router::get('/api/clients/overlimit', function () {
header('Content-Type: application/json');
$user = JWT::requireAuth();
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) {
return $c['user_id'] == $user['id'];
});
}
echo json_encode([
'success' => true,
'clients' => array_values($clients),
'count' => count($clients)
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
});
/**
* SETTINGS ROUTES
*/
// 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();
});
// 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();
});
// Delete user
Router::post('/settings/delete-user/{id}', function ($params) {
requireAdmin();
require_once __DIR__ . '/../controllers/SettingsController.php';
$controller = new SettingsController();
$controller->deleteUser($params['id']);
});
// LDAP settings page
Router::get('/settings/ldap', function () {
requireAdmin();
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();
$controller->saveLdapSettings();
});
// 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();
$controller->testLdapConnection();
});
/**
* LANGUAGE ROUTES
*/
// 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);
});
Router::get('/language/change', function () {
redirect('/dashboard');
});
// API: Get translation statistics
Router::get('/api/translations/stats', function () {
header('Content-Type: application/json');
$user = JWT::requireAuth();
if (!$user)
return;
$stats = Translator::getStatistics();
echo json_encode(['stats' => $stats]);
});
// API: Auto-translate missing keys
Router::post('/api/translations/auto-translate', function () {
header('Content-Type: application/json');
$user = JWT::requireAuth();
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([
'success' => true,
'stats' => $stats
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
});
// API: Export translations
Router::get('/api/translations/export/{lang}', function ($params) {
header('Content-Type: application/json');
$user = JWT::requireAuth();
if (!$user)
return;
$lang = $params['lang'];
try {
$json = Translator::exportToJson($lang);
header('Content-Disposition: attachment; filename="translations_' . $lang . '.json"');
echo $json;
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
});
// ===== 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']);