feat: ssh auth, protocol management, and cleanup

This commit is contained in:
infosave2007
2026-01-23 17:55:40 +03:00
parent 4995147bad
commit ea82b78a7d
70 changed files with 16225 additions and 986 deletions
+378
View File
@@ -0,0 +1,378 @@
<?php
class AIController
{
private $openRouterService;
public function __construct()
{
$this->openRouterService = new OpenRouterService();
}
/**
* AI Assistant endpoint for generating installation scripts
*/
public function assist(): void
{
requireAdmin();
try {
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
throw new Exception('Invalid JSON input');
}
$prompt = trim($input['prompt'] ?? '');
$model = trim($input['model'] ?? 'openai/gpt-3.5-turbo');
$protocolType = trim($input['protocol_type'] ?? '');
$protocolId = isset($input['protocol_id']) ? (int) $input['protocol_id'] : null;
$target = trim($input['target'] ?? 'install'); // install, uninstall, template
if (empty($prompt)) {
throw new Exception('Prompt is required');
}
// Generate enhanced prompt based on protocol type and target
$enhancedPrompt = $this->enhancePrompt($prompt, $protocolType, $target);
// Call OpenRouter API
$response = $this->openRouterService->generateScript($enhancedPrompt, $model);
// Save AI generation to database
$generationId = $this->saveAIGeneration([
'protocol_id' => $protocolId,
'model_used' => $model,
'prompt' => "[$target] " . $prompt,
'generated_script' => $response['script'] ?? '',
'suggestions' => json_encode($response['suggestions'] ?? []),
'ubuntu_compatible' => $response['ubuntu_compatible'] ?? null,
'created_at' => date('Y-m-d H:i:s')
]);
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'data' => [
'script' => $response['script'] ?? '',
'suggestions' => $response['suggestions'] ?? [],
'ubuntu_compatible' => $response['ubuntu_compatible'] ?? false,
'estimated_time' => $response['estimated_time'] ?? '5 minutes',
'model_used' => $model,
'generation_id' => $generationId,
'target' => $target
]
]);
} catch (Exception $e) {
error_log("Error in AIController::assist: " . $e->getMessage());
header('Content-Type: application/json');
http_response_code(500);
echo json_encode([
'success' => false,
'error' => $e->getMessage()
]);
}
}
/**
* Get available AI models
*/
public function getModels(): void
{
requireAdmin();
try {
$models = $this->openRouterService->getAvailableModels();
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'data' => $models
]);
} catch (Exception $e) {
error_log("Error in AIController::getModels: " . $e->getMessage());
header('Content-Type: application/json');
http_response_code(500);
echo json_encode([
'success' => false,
'error' => $e->getMessage()
]);
}
}
public function testModel(): void
{
requireAdmin();
header('Content-Type: application/json');
try {
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
throw new Exception('Invalid JSON input');
}
$model = trim($input['model'] ?? '');
if ($model === '') {
throw new Exception('Model id is required');
}
$result = $this->openRouterService->testModelAvailability($model);
echo json_encode([
'success' => $result['success'] ?? false,
'message' => $result['message'] ?? null,
'http_code' => $result['http_code'] ?? null
]);
} catch (Exception $e) {
http_response_code(400);
echo json_encode([
'success' => false,
'error' => $e->getMessage()
]);
}
}
/**
* Get AI generation history for a protocol
*/
public function getGenerationHistory(int $protocolId): void
{
requireAdmin();
try {
$generations = $this->getAIGenerationsByProtocol($protocolId);
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'data' => $generations
]);
} catch (Exception $e) {
error_log("Error in AIController::getGenerationHistory: " . $e->getMessage());
header('Content-Type: application/json');
http_response_code(500);
echo json_encode([
'success' => false,
'error' => $e->getMessage()
]);
}
}
/**
* Apply AI-generated script to protocol
*/
public function applyGeneration(int $generationId): void
{
requireAdmin();
try {
$generation = $this->getAIGeneration($generationId);
if (!$generation) {
throw new Exception('AI generation not found');
}
if (!$generation['protocol_id']) {
throw new Exception('This generation is not associated with any protocol');
}
// Determine target from prompt
$target = 'install';
if (preg_match('/^\[(uninstall|template)\]/', $generation['prompt'], $matches)) {
$target = $matches[1];
}
// Update protocol with generated script
$pdo = DB::conn();
if ($target === 'uninstall') {
$stmt = $pdo->prepare('
UPDATE protocols
SET uninstall_script = ?, updated_at = ?
WHERE id = ?
');
$stmt->execute([
$generation['generated_script'],
date('Y-m-d H:i:s'),
$generation['protocol_id']
]);
} elseif ($target === 'template') {
$stmt = $pdo->prepare('
UPDATE protocols
SET output_template = ?, updated_at = ?
WHERE id = ?
');
$stmt->execute([
$generation['generated_script'],
date('Y-m-d H:i:s'),
$generation['protocol_id']
]);
} else {
$stmt = $pdo->prepare('
UPDATE protocols
SET install_script = ?, ubuntu_compatible = ?, updated_at = ?
WHERE id = ?
');
$stmt->execute([
$generation['generated_script'],
$generation['ubuntu_compatible'],
date('Y-m-d H:i:s'),
$generation['protocol_id']
]);
}
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'message' => 'AI-generated script applied to protocol successfully'
]);
} catch (Exception $e) {
error_log("Error in AIController::applyGeneration: " . $e->getMessage());
header('Content-Type: application/json');
http_response_code(500);
echo json_encode([
'success' => false,
'error' => $e->getMessage()
]);
}
}
/**
* Preview AI-generated script with syntax highlighting
*/
public function previewGeneration(int $generationId): void
{
requireAdmin();
try {
$generation = $this->getAIGeneration($generationId);
if (!$generation) {
throw new Exception('AI generation not found');
}
View::render('ai/preview_generation.twig', [
'generation' => $generation,
'script' => $generation['generated_script'],
'suggestions' => json_decode($generation['suggestions'], true) ?? []
]);
} catch (Exception $e) {
$_SESSION['protocol_error'] = $e->getMessage();
redirect('/settings/protocols');
}
}
/**
* Enhance user prompt with context and requirements
*/
private function enhancePrompt(string $prompt, string $protocolType, string $target = 'install'): string
{
$context = "";
if ($target === 'uninstall') {
$context = "Create a bash uninstallation script for a VPN protocol. ";
} elseif ($target === 'template') {
$context = "Create a WireGuard-compatible client config template. ";
} else {
$context = "Create a bash installation script for a VPN protocol. ";
}
if ($protocolType) {
$context .= "This is for a $protocolType protocol. ";
}
$context .= "Requirements:\n";
if ($target === 'template') {
$context .= "- Use Mustache-style placeholders like {{private_key}}, {{client_ip}}, {{server_host}}, {{server_port}}\n";
$context .= "- The template should be valid configuration format (e.g. .conf for WireGuard)\n";
} elseif ($target === 'uninstall') {
$context .= "- The script should be compatible with Ubuntu 22.04 and 24.04\n";
$context .= "- Remove all docker containers, images, and networks created by the installation\n";
$context .= "- Remove configuration files and directories\n";
$context .= "- Clean up firewall rules if necessary\n";
} else {
$context .= "- The script should be compatible with Ubuntu 22.04 and 24.04\n";
$context .= "- Use Docker containers where possible for isolation\n";
$context .= "- Generate necessary keys and certificates automatically\n";
$context .= "- Configure appropriate firewall rules\n";
$context .= "- Provide clear output about installation progress\n";
$context .= "- Handle errors gracefully\n";
$context .= "- Include configuration validation\n";
}
$context .= "\nUser requirements: $prompt\n";
$context .= "\nReturn the response in this JSON format:\n";
$context .= "{\n";
if ($target === 'template') {
$context .= ' "script": "[Interface]\\nPrivateKey = {{private_key}}...",' . "\n";
} else {
$context .= ' "script": "#!/bin/bash\\n# Complete script",' . "\n";
}
$context .= ' "suggestions": ["suggestion 1", "suggestion 2"],' . "\n";
$context .= ' "ubuntu_compatible": true,' . "\n";
$context .= ' "estimated_time": "5 minutes"' . "\n";
$context .= "}\n";
return $context;
}
/**
* Database methods
*/
private function saveAIGeneration(array $data): int
{
$pdo = DB::conn();
$stmt = $pdo->prepare('
INSERT INTO ai_generations (protocol_id, model_used, prompt, generated_script, suggestions, ubuntu_compatible, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
');
$stmt->execute([
$data['protocol_id'],
$data['model_used'],
$data['prompt'],
$data['generated_script'],
$data['suggestions'],
$data['ubuntu_compatible'],
$data['created_at']
]);
return (int) $pdo->lastInsertId();
}
private function getAIGeneration(int $id): ?array
{
$pdo = DB::conn();
$stmt = $pdo->prepare('
SELECT ag.*, p.name as protocol_name, p.slug as protocol_slug
FROM ai_generations ag
LEFT JOIN protocols p ON ag.protocol_id = p.id
WHERE ag.id = ?
');
$stmt->execute([$id]);
$generation = $stmt->fetch(PDO::FETCH_ASSOC);
return $generation ?: null;
}
private function getAIGenerationsByProtocol(int $protocolId): array
{
$pdo = DB::conn();
$stmt = $pdo->prepare('
SELECT ag.*, p.name as protocol_name
FROM ai_generations ag
LEFT JOIN protocols p ON ag.protocol_id = p.id
WHERE ag.protocol_id = ?
ORDER BY ag.created_at DESC
LIMIT 50
');
$stmt->execute([$protocolId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
}
+408
View File
@@ -0,0 +1,408 @@
<?php
/**
* LogsController
* Manages application logs viewing and management
* Admin-only access to application log files
*/
class LogsController {
private const LOGS_DIR = __DIR__ . '/../logs';
private const MAX_FILE_SIZE = 10485760; // 10 MB
private const ALLOWED_EXTENSIONS = ['log', 'txt'];
/**
* List and view application logs
* GET /admin/logs
*/
public function index() {
requireAdmin();
$logFiles = $this->getLogFiles();
$selectedFile = $_GET['file'] ?? null;
$logContent = '';
$logLines = [];
$fileSize = 0;
$lineCount = 0;
if ($selectedFile && $this->isValidLogFile($selectedFile)) {
$filePath = self::LOGS_DIR . '/' . basename($selectedFile);
if (file_exists($filePath)) {
$fileSize = filesize($filePath);
// Read log file (last 1000 lines or complete if small)
$logContent = $this->readLogFile($filePath);
$logLines = array_filter(explode("\n", $logContent));
$lineCount = count($logLines);
}
}
View::render('settings/logs.twig', [
'log_files' => $logFiles,
'selected_file' => $selectedFile,
'log_content' => $logContent,
'log_lines' => $logLines,
'line_count' => $lineCount,
'file_size' => $fileSize,
'section' => 'logs'
]);
}
/**
* Get list of available log files
*/
private function getLogFiles(): array {
$files = [];
if (!is_dir(self::LOGS_DIR)) {
return $files;
}
$dirContents = @scandir(self::LOGS_DIR, SCANDIR_SORT_DESCENDING);
if ($dirContents === false) {
return $files;
}
foreach ($dirContents as $filename) {
// Skip . and ..
if ($filename === '.' || $filename === '..') {
continue;
}
$filePath = self::LOGS_DIR . '/' . $filename;
// Only regular files
if (!is_file($filePath)) {
continue;
}
// Check extension
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if (!in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
continue;
}
$size = filesize($filePath);
$modified = filemtime($filePath);
$files[] = [
'name' => $filename,
'path' => $filename,
'size' => $size,
'size_formatted' => $this->formatBytes($size),
'modified' => $modified,
'modified_formatted' => date('Y-m-d H:i:s', $modified),
'readable' => is_readable($filePath)
];
}
return $files;
}
/**
* Read log file content (last N lines)
*/
private function readLogFile(string $filePath, int $maxLines = 1000): string {
if (!is_readable($filePath)) {
return '';
}
$fileSize = filesize($filePath);
// If file is small enough, read completely
if ($fileSize <= self::MAX_FILE_SIZE) {
return file_get_contents($filePath) ?: '';
}
// For large files, read last N lines
$handle = fopen($filePath, 'r');
if ($handle === false) {
return '';
}
// Seek to end and read backwards
fseek($handle, 0, SEEK_END);
$lines = [];
$lineCount = 0;
while ($lineCount < $maxLines && ftell($handle) > 0) {
$chunk = '';
$pos = ftell($handle);
// Read backwards in chunks
$chunkSize = min(4096, $pos);
fseek($handle, -$chunkSize, SEEK_CUR);
$chunk = fread($handle, $chunkSize);
fseek($handle, -$chunkSize, SEEK_CUR);
$parts = explode("\n", $chunk);
$lines = array_merge($parts, $lines);
$lineCount = count($lines);
if ($pos <= $chunkSize) {
break;
}
}
fclose($handle);
// Take last N lines and rejoin
$lines = array_slice($lines, -$maxLines);
return implode("\n", $lines);
}
/**
* Download log file
* GET /admin/logs/download?file=filename
*/
public function download() {
requireAdmin();
$file = $_GET['file'] ?? null;
if (!$file || !$this->isValidLogFile($file)) {
http_response_code(400);
echo 'Invalid file';
return;
}
$filePath = self::LOGS_DIR . '/' . basename($file);
if (!file_exists($filePath) || !is_readable($filePath)) {
http_response_code(404);
echo 'File not found';
return;
}
header('Content-Type: text/plain; charset=utf-8');
header('Content-Disposition: attachment; filename="' . basename($file) . '"');
header('Content-Length: ' . filesize($filePath));
readfile($filePath);
exit;
}
/**
* Delete log file
* POST /admin/logs/delete
*/
public function delete() {
requireAdmin();
header('Content-Type: application/json');
$file = $_POST['file'] ?? null;
if (!$file || !$this->isValidLogFile($file)) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Invalid file']);
return;
}
$filePath = self::LOGS_DIR . '/' . basename($file);
if (!file_exists($filePath)) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'File not found']);
return;
}
if (@unlink($filePath)) {
echo json_encode([
'success' => true,
'message' => 'Log file deleted successfully',
'redirect' => '/admin/logs'
]);
} else {
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Failed to delete file']);
}
}
/**
* Clear all log files
* POST /admin/logs/clear-all
*/
public function clearAll() {
requireAdmin();
header('Content-Type: application/json');
$logFiles = $this->getLogFiles();
$deleted = 0;
$failed = 0;
foreach ($logFiles as $file) {
$filePath = self::LOGS_DIR . '/' . $file['path'];
if (@unlink($filePath)) {
$deleted++;
} else {
$failed++;
}
}
echo json_encode([
'success' => true,
'deleted' => $deleted,
'failed' => $failed,
'message' => "Deleted $deleted log files" . ($failed > 0 ? ", failed to delete $failed" : ''),
'redirect' => '/admin/logs'
]);
}
/**
* Search logs
* POST /admin/logs/search
*/
public function search() {
requireAdmin();
header('Content-Type: application/json');
$query = $_POST['query'] ?? '';
$file = $_POST['file'] ?? null;
$caseSensitive = isset($_POST['case_sensitive']) && $_POST['case_sensitive'] === 'on';
if (empty($query) || strlen($query) < 2) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Search query too short']);
return;
}
if (!$file || !$this->isValidLogFile($file)) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Invalid file']);
return;
}
$filePath = self::LOGS_DIR . '/' . basename($file);
if (!file_exists($filePath) || !is_readable($filePath)) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'File not found']);
return;
}
$content = file_get_contents($filePath);
if ($content === false) {
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Failed to read file']);
return;
}
$lines = explode("\n", $content);
$results = [];
$flags = $caseSensitive ? 0 : PREG_GREP_INVERT;
foreach ($lines as $lineNum => $line) {
if (empty($line)) {
continue;
}
// Case-sensitive or case-insensitive search
$matches = $caseSensitive
? (strpos($line, $query) !== false)
: (stripos($line, $query) !== false);
if ($matches) {
$results[] = [
'line' => $lineNum + 1,
'content' => $line
];
}
}
echo json_encode([
'success' => true,
'query' => $query,
'results_count' => count($results),
'results' => array_slice($results, 0, 100) // Limit to 100 results
]);
}
/**
* Get log statistics
* POST /admin/logs/stats
*/
public function stats() {
requireAdmin();
header('Content-Type: application/json');
$file = $_POST['file'] ?? null;
if (!$file || !$this->isValidLogFile($file)) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Invalid file']);
return;
}
$filePath = self::LOGS_DIR . '/' . basename($file);
if (!file_exists($filePath) || !is_readable($filePath)) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'File not found']);
return;
}
$content = file_get_contents($filePath);
if ($content === false) {
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Failed to read file']);
return;
}
$lines = array_filter(explode("\n", $content));
$errorCount = count(preg_grep('/error|exception|fatal|fail/i', $lines));
$warningCount = count(preg_grep('/warning|warn/i', $lines));
$successCount = count(preg_grep('/success|completed|ok/i', $lines));
echo json_encode([
'success' => true,
'total_lines' => count($lines),
'errors' => $errorCount,
'warnings' => $warningCount,
'success' => $successCount,
'file_size' => filesize($filePath),
'file_size_formatted' => $this->formatBytes(filesize($filePath)),
'last_modified' => date('Y-m-d H:i:s', filemtime($filePath))
]);
}
/**
* Validate log file name
*/
private function isValidLogFile(string $filename): bool {
// Prevent directory traversal
if (strpos($filename, '/') !== false || strpos($filename, '\\') !== false) {
return false;
}
if (strpos($filename, '..') !== false) {
return false;
}
// Check extension
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if (!in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
return false;
}
return true;
}
/**
* Format bytes to human readable
*/
private function formatBytes(int $bytes, int $precision = 2): string {
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
$bytes /= 1024;
}
return round($bytes, $precision) . ' ' . $units[$i];
}
}
@@ -0,0 +1,926 @@
<?php
class ProtocolManagementController
{
/**
* Display protocols list and management interface
*/
public function index(): void
{
requireAdmin();
try {
$protocols = $this->getAllProtocols();
$selectedId = isset($_GET['id']) ? (int) $_GET['id'] : null;
$isNew = isset($_GET['new']);
$isTemplate = isset($_GET['template']);
$editing = null;
if (!$isNew && $selectedId) {
$editing = $this->getProtocolById($selectedId);
}
$pdo = DB::conn();
$stmt = $pdo->prepare("SELECT api_key FROM api_keys WHERE service_name = 'openrouter' AND is_active = 1 LIMIT 1");
$stmt->execute();
$openrouterKey = $stmt->fetchColumn() ?: '';
if ($isTemplate && $editing) {
View::render('settings/protocol_template_editor.twig', [
'protocol' => $editing,
'success' => $_SESSION['protocol_success'] ?? null,
'error' => $_SESSION['protocol_error'] ?? null,
'openrouter_key' => $openrouterKey,
]);
} elseif ($editing || $isNew) {
View::render('settings/protocol_form.twig', [
'editing' => $editing,
'success' => $_SESSION['protocol_success'] ?? null,
'error' => $_SESSION['protocol_error'] ?? null,
'openrouter_key' => $openrouterKey,
]);
} else {
View::render('settings/protocols_management.twig', [
'protocols' => $protocols,
'success' => $_SESSION['protocol_success'] ?? null,
'error' => $_SESSION['protocol_error'] ?? null,
'openrouter_key' => $openrouterKey,
]);
}
unset($_SESSION['protocol_success'], $_SESSION['protocol_error']);
} catch (Exception $e) {
error_log("Error in ProtocolManagementController::index: " . $e->getMessage());
$_SESSION['protocol_error'] = 'Failed to load protocols: ' . $e->getMessage();
redirect('/settings/protocols-management');
}
}
/**
* Create or update protocol
*/
public function save(): void
{
requireAdmin();
try {
$id = isset($_POST['id']) && $_POST['id'] !== '' ? (int) $_POST['id'] : null;
$name = trim($_POST['name'] ?? '');
$slug = trim($_POST['slug'] ?? '');
$description = trim($_POST['description'] ?? '');
$installScript = trim($_POST['install_script'] ?? '');
$passwordCommand = trim($_POST['password_command'] ?? '');
$uninstallScript = trim($_POST['uninstall_script'] ?? '');
$outputTemplate = trim($_POST['output_template'] ?? '');
$qrCodeTemplate = trim($_POST['qr_code_template'] ?? '');
$qrCodeFormat = trim($_POST['qr_code_format'] ?? 'amnezia_compressed');
$ubuntuCompatible = isset($_POST['ubuntu_compatible']) ? 1 : 0;
$showTextContent = isset($_POST['show_text_content']) ? 1 : 0;
$isActive = isset($_POST['is_active']) ? 1 : 0;
// Validation
if ($name === '' || $slug === '') {
throw new Exception('Name and slug are required');
}
if (!preg_match('/^[a-z0-9_-]+$/i', $slug)) {
throw new Exception('Slug may contain only letters, numbers, dashes, and underscores');
}
// Check if slug is unique (for new protocols or when updating slug)
if ($this->isSlugExists($slug, $id)) {
throw new Exception('Protocol with this slug already exists');
}
$protocolData = [
'name' => $name,
'slug' => $slug,
'description' => $description,
'install_script' => $installScript,
'output_template' => $outputTemplate,
'qr_code_template' => $qrCodeTemplate,
'qr_code_format' => $qrCodeFormat,
'password_command' => $passwordCommand,
'uninstall_script' => $uninstallScript,
'ubuntu_compatible' => $ubuntuCompatible,
'show_text_content' => $showTextContent,
'is_active' => $isActive,
'updated_at' => date('Y-m-d H:i:s')
];
if ($id) {
// Update existing protocol
$this->updateProtocol($id, $protocolData);
$savedId = $id;
$_SESSION['protocol_success'] = 'Protocol updated successfully';
} else {
// Create new protocol
$protocolData['created_at'] = date('Y-m-d H:i:s');
$savedId = $this->createProtocol($protocolData);
$_SESSION['protocol_success'] = 'Protocol created successfully';
}
redirect('/settings/protocols-management?id=' . $savedId);
} catch (Exception $e) {
$_SESSION['protocol_error'] = $e->getMessage();
$id = isset($_POST['id']) ? (int) $_POST['id'] : null;
redirect('/settings/protocols-management' . ($id ? '?id=' . $id : '?new=1'));
}
}
/**
* Delete protocol
*/
public function delete(int $id): void
{
requireAdmin();
try {
$protocol = $this->getProtocolById($id);
if (!$protocol) {
throw new Exception('Protocol not found');
}
// Check if protocol is used by any servers
if ($this->isProtocolUsed($id)) {
throw new Exception('Cannot delete protocol that is currently used by servers');
}
$this->deleteProtocol($id);
$_SESSION['protocol_success'] = 'Protocol deleted successfully';
} catch (Exception $e) {
$_SESSION['protocol_error'] = $e->getMessage();
}
redirect('/settings/protocols-management');
}
/**
* API endpoint: Get all protocols (JSON)
*/
public function apiGetProtocols(): void
{
requireAdmin();
try {
$protocols = $this->getAllProtocols();
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'data' => $protocols
]);
} catch (Exception $e) {
header('Content-Type: application/json');
http_response_code(500);
echo json_encode([
'success' => false,
'error' => $e->getMessage()
]);
}
}
/**
* API endpoint: Get single protocol (JSON)
*/
public function apiGetProtocol(int $id): void
{
requireAdmin();
try {
$protocol = $this->getProtocolById($id);
if (!$protocol) {
throw new Exception('Protocol not found');
}
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'data' => $protocol
]);
} catch (Exception $e) {
header('Content-Type: application/json');
http_response_code(404);
echo json_encode([
'success' => false,
'error' => $e->getMessage()
]);
}
}
/**
* API endpoint: Create protocol (JSON)
*/
public function apiCreateProtocol(): void
{
requireAdmin();
try {
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
throw new Exception('Invalid JSON input');
}
// Validate required fields
$requiredFields = ['name', 'slug'];
foreach ($requiredFields as $field) {
if (empty($input[$field])) {
throw new Exception("Field '$field' is required");
}
}
// Validate slug format
if (!preg_match('/^[a-z0-9_-]+$/i', $input['slug'])) {
throw new Exception('Slug may contain only letters, numbers, dashes, and underscores');
}
// Check if slug exists
if ($this->isSlugExists($input['slug'])) {
throw new Exception('Protocol with this slug already exists');
}
$protocolData = [
'name' => trim($input['name']),
'slug' => trim($input['slug']),
'description' => trim($input['description'] ?? ''),
'install_script' => trim($input['install_script'] ?? ''),
'output_template' => trim($input['output_template'] ?? ''),
'qr_code_template' => trim($input['qr_code_template'] ?? ''),
'qr_code_format' => trim($input['qr_code_format'] ?? 'amnezia_compressed'),
'ubuntu_compatible' => (bool) ($input['ubuntu_compatible'] ?? false),
'show_text_content' => (bool) ($input['show_text_content'] ?? false),
'is_active' => (bool) ($input['is_active'] ?? true),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
];
$id = $this->createProtocol($protocolData);
$protocol = $this->getProtocolById($id);
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'data' => $protocol,
'message' => 'Protocol created successfully'
]);
} catch (Exception $e) {
header('Content-Type: application/json');
http_response_code(400);
echo json_encode([
'success' => false,
'error' => $e->getMessage()
]);
}
}
/**
* API endpoint: Update protocol (JSON)
*/
public function apiUpdateProtocol(int $id): void
{
requireAdmin();
try {
$protocol = $this->getProtocolById($id);
if (!$protocol) {
throw new Exception('Protocol not found');
}
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
throw new Exception('Invalid JSON input');
}
$protocolData = [];
$allowedFields = ['name', 'slug', 'description', 'install_script', 'output_template', 'qr_code_template', 'qr_code_format', 'ubuntu_compatible', 'show_text_content', 'is_active'];
foreach ($allowedFields as $field) {
if (isset($input[$field])) {
if ($field === 'ubuntu_compatible' || $field === 'is_active') {
$protocolData[$field] = (bool) $input[$field];
} elseif ($field === 'slug') {
$slug = trim($input[$field]);
if (!preg_match('/^[a-z0-9_-]+$/i', $slug)) {
throw new Exception('Slug may contain only letters, numbers, dashes, and underscores');
}
if ($this->isSlugExists($slug, $id)) {
throw new Exception('Protocol with this slug already exists');
}
$protocolData[$field] = $slug;
} else {
$protocolData[$field] = trim($input[$field]);
}
}
}
if (!empty($protocolData)) {
$protocolData['updated_at'] = date('Y-m-d H:i:s');
$this->updateProtocol($id, $protocolData);
$protocol = $this->getProtocolById($id);
}
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'data' => $protocol,
'message' => 'Protocol updated successfully'
]);
} catch (Exception $e) {
header('Content-Type: application/json');
http_response_code(400);
echo json_encode([
'success' => false,
'error' => $e->getMessage()
]);
}
}
/**
* API endpoint: Delete protocol (JSON)
*/
public function apiDeleteProtocol(int $id): void
{
requireAdmin();
try {
$protocol = $this->getProtocolById($id);
if (!$protocol) {
throw new Exception('Protocol not found');
}
if ($this->isProtocolUsed($id)) {
throw new Exception('Cannot delete protocol that is currently used by servers');
}
$this->deleteProtocol($id);
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'message' => 'Protocol deleted successfully'
]);
} catch (Exception $e) {
header('Content-Type: application/json');
http_response_code(400);
echo json_encode([
'success' => false,
'error' => $e->getMessage()
]);
}
}
public function apiTestInstallProtocol(int $id): void
{
requireAdmin();
// Suppress all errors and warnings to prevent HTML output before JSON
@ini_set('display_errors', '0');
error_reporting(0);
// Clean any previous output
if (ob_get_level())
ob_end_clean();
ob_start();
header('Content-Type: application/json');
try {
$protocol = $this->getProtocolById($id);
if (!$protocol) {
throw new Exception('Protocol not found');
}
$script = trim($protocol['install_script'] ?? '');
if ($script === '') {
throw new Exception('Install script is empty');
}
$container = 'proto-test-' . $id;
$this->runHostCommand('docker rm -f ' . escapeshellarg($container) . ' >/dev/null 2>&1 || true');
$run = $this->runHostCommandChecked('docker run --privileged -d -v /var/run/docker.sock:/var/run/docker.sock --name ' . escapeshellarg($container) . ' ubuntu:22.04 sleep infinity');
if ($run['rc'] !== 0) {
throw new Exception('Docker not accessible: ' . trim($run['out']));
}
$cliPath = '/usr/local/bin/docker';
$try1 = $this->runHostCommandChecked('docker run --rm docker:24-dind sh -lc "cat ' . $cliPath . '"');
if ($try1['rc'] !== 0 || $try1['out'] === '') {
$cliPath = '/usr/bin/docker';
$try2 = $this->runHostCommandChecked('docker run --rm docker:24-dind sh -lc "cat ' . $cliPath . '"');
if ($try2['rc'] !== 0 || $try2['out'] === '') {
throw new Exception('Failed to read docker CLI from docker:24-dind image');
}
}
$cp = $this->runHostCommandChecked('docker run --rm docker:24-dind sh -lc "cat ' . $cliPath . '" | docker exec -i ' . escapeshellarg($container) . ' sh -lc "cat > /usr/local/bin/docker && chmod +x /usr/local/bin/docker"');
if ($cp['rc'] !== 0) {
throw new Exception('Failed to provide docker CLI to test container: ' . trim($cp['out']));
}
$this->execInContainerChecked($container, 'chmod +x /usr/local/bin/docker');
$ver = $this->execInContainerChecked($container, 'docker --version');
if ($ver['rc'] !== 0) {
throw new Exception('Docker CLI not available in test container');
}
$prelude = <<<'SH'
set -euo pipefail
set -x
CONTAINER_NAME="${CONTAINER_NAME:-amnezia-awg}"
wg() {
if docker ps --format '{{.Names}}' | grep -qx "$CONTAINER_NAME"; then
docker exec -i "$CONTAINER_NAME" wg "$@"
else
docker pull -q amneziavpn/amnezia-wg:latest >/dev/null 2>&1 || true
docker run --rm -i --privileged --cap-add=NET_ADMIN amneziavpn/amnezia-wg:latest wg "$@"
fi
}
SH;
$wrapped = $prelude . "\n" . $script;
$runScript = $this->execInContainerChecked($container, $wrapped);
if ($runScript['rc'] !== 0) {
throw new Exception("Install script failed: " . trim($runScript['out']));
}
$output = $runScript['out'];
$extracted = $this->extractValuesFromOutput($output);
$variables = $this->getProtocolVariables($id);
foreach ($extracted as $k => $v) {
if (array_key_exists($k, $variables)) {
$variables[$k] = $v;
}
}
$preview = ProtocolService::generateProtocolOutput($protocol, $variables);
// Cleanup test containers: proto-test and AWG if created
$this->runHostCommand('docker rm -f ' . escapeshellarg($container) . ' >/dev/null 2>&1 || true');
$this->runHostCommand('docker rm -f amnezia-awg >/dev/null 2>&1 || true');
// Clean buffer and output JSON
if (ob_get_level())
ob_end_clean();
echo json_encode([
'success' => true,
'logs' => $output,
'extracted' => $extracted,
'preview' => $preview
]);
} catch (Exception $e) {
// Clean buffer and output error JSON
if (ob_get_level())
ob_end_clean();
echo json_encode([
'success' => false,
'error' => $e->getMessage()
]);
}
}
public function apiTestInstallProtocolStream(int $id): void
{
requireAdmin();
// Suppress all errors and warnings to prevent HTML output
@ini_set('display_errors', '0');
error_reporting(0);
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
@ini_set('output_buffering', 'off');
@ini_set('zlib.output_compression', '0');
@ob_implicit_flush(true);
// CRITICAL: Use ob_end_clean() instead of ob_end_flush() to DISCARD any
// buffered warnings/errors that would corrupt the SSE stream
while (ob_get_level()) {
@ob_end_clean();
}
$send = function (array $data) {
echo 'data: ' . json_encode($data) . "\n\n";
@flush();
};
try {
$protocol = $this->getProtocolById($id);
if (!$protocol) {
$send(['type' => 'error', 'error' => 'Protocol not found']);
return;
}
$script = trim($protocol['install_script'] ?? '');
if ($script === '') {
$send(['type' => 'error', 'error' => 'Install script is empty']);
return;
}
$container = 'proto-test-' . $id;
$send(['type' => 'start']);
$send(['type' => 'cmd', 'cmd' => 'docker rm -f ' . $container]);
$rm = $this->runHostCommandChecked('docker rm -f ' . escapeshellarg($container) . ' >/dev/null 2>&1 || true');
$send(['type' => 'cmd_done', 'rc' => $rm['rc']]);
$cmdRun = 'docker run --privileged -d --name ' . $container . ' ubuntu:22.04 sleep infinity';
$send(['type' => 'cmd', 'cmd' => $cmdRun]);
$run = $this->runHostCommandChecked('docker run --privileged -d -v /var/run/docker.sock:/var/run/docker.sock --name ' . escapeshellarg($container) . ' ubuntu:22.04 sleep infinity');
if ($run['rc'] !== 0) {
$send(['type' => 'error', 'error' => 'Docker not accessible: ' . trim($run['out'])]);
return;
}
$send(['type' => 'cmd_done', 'rc' => 0]);
$send(['type' => 'cmd', 'cmd' => 'provide docker cli']);
$cliPath = '/usr/local/bin/docker';
$send(['type' => 'cmd', 'cmd' => 'provide docker cli from docker:24-dind image']);
$try1 = $this->runHostCommandChecked('docker run --rm docker:24-dind sh -lc "cat ' . $cliPath . '"');
if ($try1['rc'] !== 0 || $try1['out'] === '') {
$cliPath = '/usr/bin/docker';
}
$cp = $this->runHostCommandChecked('docker run --rm docker:24-dind sh -lc "cat ' . $cliPath . '" | docker exec -i ' . escapeshellarg($container) . ' sh -lc "cat > /usr/local/bin/docker && chmod +x /usr/local/bin/docker"');
if ($cp['rc'] !== 0) {
$send(['type' => 'error', 'error' => 'Failed to provide docker CLI to test container: ' . trim($cp['out'])]);
return;
}
$this->execInContainerChecked($container, 'chmod +x /usr/local/bin/docker');
$ver = $this->execInContainerChecked($container, 'docker --version');
$send(['type' => 'out', 'line' => $ver['out']]);
if ($ver['rc'] !== 0) {
$send(['type' => 'error', 'error' => 'Docker CLI not available in test container']);
return;
}
$prelude = <<<'SH'
set -euo pipefail
set -x
CONTAINER_NAME="${CONTAINER_NAME:-amnezia-awg}"
wg() {
if docker ps --format '{{.Names}}' | grep -qx "$CONTAINER_NAME"; then
docker exec -i "$CONTAINER_NAME" wg "$@"
else
docker pull -q amneziavpn/amnezia-wg:latest >/dev/null 2>&1 || true
docker run --rm -i --privileged --cap-add=NET_ADMIN amneziavpn/amnezia-wg:latest wg "$@"
fi
}
SH;
$wrapped = $prelude . "\n" . $script;
$send(['type' => 'cmd', 'cmd' => 'install_script']);
$runScript = $this->execInContainerChecked($container, $wrapped);
$outLines = explode("\n", trim($runScript['out']));
foreach ($outLines as $line) {
if ($line !== '')
$send(['type' => 'out', 'line' => $line]);
}
if ($runScript['rc'] !== 0) {
$send(['type' => 'error', 'error' => 'Install script failed: ' . trim($runScript['out'])]);
$this->runHostCommandChecked('docker rm -f ' . escapeshellarg($container) . ' >/dev/null 2>&1 || true');
return;
}
$send(['type' => 'cmd_done', 'rc' => 0]);
$extracted = $this->extractValuesFromOutput($runScript['out']);
$variables = $this->getProtocolVariables($id);
// Merge all extracted variables (not just existing ones)
$variables = array_merge($variables, $extracted);
$preview = ProtocolService::generateProtocolOutput($protocol, $variables);
$send(['type' => 'preview', 'preview' => $preview]);
$this->runHostCommandChecked('docker rm -f ' . escapeshellarg($container) . ' >/dev/null 2>&1 || true');
$this->runHostCommandChecked('docker rm -f amnezia-awg >/dev/null 2>&1 || true');
$send(['type' => 'done']);
} catch (Exception $e) {
echo 'data: ' . json_encode(['type' => 'error', 'error' => $e->getMessage()]) . "\n\n";
@flush();
}
}
public function apiTestUninstallProtocolStream(int $id): void
{
requireAdmin();
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
@ini_set('output_buffering', 'off');
@ini_set('zlib.output_compression', '0');
@ob_implicit_flush(true);
@ob_end_flush();
$send = function (array $data) {
echo 'data: ' . json_encode($data) . "\n\n";
@flush();
};
try {
$protocol = $this->getProtocolById($id);
if (!$protocol) {
$send(['type' => 'error', 'error' => 'Protocol not found']);
return;
}
$installScript = trim($protocol['install_script'] ?? '');
$uninstallScript = trim($protocol['uninstall_script'] ?? '');
if ($installScript === '') {
$send(['type' => 'error', 'error' => 'Install script is empty (required for setup)']);
return;
}
if ($uninstallScript === '') {
$send(['type' => 'error', 'error' => 'Uninstall script is empty']);
return;
}
// Normalize uninstall script if needed? For now assume it's fine.
$container = 'proto-test-uninstall-' . $id;
$send(['type' => 'start']);
// 1. Setup container
$send(['type' => 'cmd', 'cmd' => 'Setting up test environment...']);
$this->runHostCommandChecked('docker rm -f ' . escapeshellarg($container) . ' >/dev/null 2>&1 || true');
$run = $this->runHostCommandChecked('docker run --privileged -d -v /var/run/docker.sock:/var/run/docker.sock --name ' . escapeshellarg($container) . ' ubuntu:22.04 sleep infinity');
if ($run['rc'] !== 0) {
$send(['type' => 'error', 'error' => 'Docker not accessible: ' . trim($run['out'])]);
return;
}
// Provide docker CLI
$cliPath = '/usr/local/bin/docker';
$try1 = $this->runHostCommandChecked('docker run --rm docker:24-dind sh -lc "cat ' . $cliPath . '"');
if ($try1['rc'] !== 0 || $try1['out'] === '') {
$cliPath = '/usr/bin/docker';
}
$cp = $this->runHostCommandChecked('docker run --rm docker:24-dind sh -lc "cat ' . $cliPath . '" | docker exec -i ' . escapeshellarg($container) . ' sh -lc "cat > /usr/local/bin/docker && chmod +x /usr/local/bin/docker"');
if ($cp['rc'] !== 0) {
$send(['type' => 'error', 'error' => 'Failed to provide docker CLI']);
return;
}
$this->execInContainerChecked($container, 'chmod +x /usr/local/bin/docker');
$prelude = <<<'SH'
set -euo pipefail
set -x
CONTAINER_NAME="${CONTAINER_NAME:-amnezia-awg}"
wg() {
if docker ps --format '{{.Names}}' | grep -qx "$CONTAINER_NAME"; then
docker exec -i "$CONTAINER_NAME" wg "$@"
else
docker pull -q amneziavpn/amnezia-wg:latest >/dev/null 2>&1 || true
docker run --rm -i --privileged --cap-add=NET_ADMIN amneziavpn/amnezia-wg:latest wg "$@"
fi
}
SH;
// 2. Run Install Script
$send(['type' => 'cmd', 'cmd' => 'Running installation script...']);
$wrappedInstall = $prelude . "\n" . $installScript;
$runInstall = $this->execInContainerChecked($container, $wrappedInstall);
if ($runInstall['rc'] !== 0) {
$send(['type' => 'error', 'error' => 'Setup (install) failed: ' . trim($runInstall['out'])]);
$this->runHostCommandChecked('docker rm -f ' . escapeshellarg($container) . ' >/dev/null 2>&1 || true');
return;
}
$send(['type' => 'out', 'line' => 'Installation successful. Now running uninstall...']);
// 3. Run Uninstall Script
$send(['type' => 'cmd', 'cmd' => 'Running uninstallation script...']);
$wrappedUninstall = $prelude . "\n" . $uninstallScript;
$runUninstall = $this->execInContainerChecked($container, $wrappedUninstall);
$outLines = explode("\n", trim($runUninstall['out']));
foreach ($outLines as $line) {
if ($line !== '')
$send(['type' => 'out', 'line' => $line]);
}
if ($runUninstall['rc'] !== 0) {
$send(['type' => 'error', 'error' => 'Uninstall script failed: ' . trim($runUninstall['out'])]);
} else {
$send(['type' => 'cmd_done', 'rc' => 0]);
$send(['type' => 'out', 'line' => 'Uninstallation completed successfully.']);
}
// Cleanup
$this->runHostCommandChecked('docker rm -f ' . escapeshellarg($container) . ' >/dev/null 2>&1 || true');
$this->runHostCommandChecked('docker rm -f amnezia-awg >/dev/null 2>&1 || true'); // Cleanup potential leftover
$send(['type' => 'done']);
} catch (Exception $e) {
echo 'data: ' . json_encode(['type' => 'error', 'error' => $e->getMessage()]) . "\n\n";
@flush();
}
}
private function runHostCommand(string $cmd): void
{
$out = shell_exec($cmd);
}
private function runHostCommandChecked(string $cmd): array
{
$lines = [];
$rc = 0;
exec($cmd . ' 2>&1', $lines, $rc);
return ['out' => implode("\n", $lines), 'rc' => $rc];
}
private function execInContainer(string $container, string $cmd): string
{
$full = 'docker exec ' . escapeshellarg($container) . ' bash -lc ' . escapeshellarg($cmd);
$out = shell_exec($full . ' 2>&1');
return $out ?? '';
}
private function execInContainerChecked(string $container, string $cmd): array
{
$lines = [];
$rc = 0;
$full = 'docker exec ' . escapeshellarg($container) . ' bash -lc ' . escapeshellarg($cmd);
exec($full . ' 2>&1', $lines, $rc);
return ['out' => implode("\n", $lines), 'rc' => $rc];
}
private function normalizeAwgInstallScript(string $script): string
{
// Script in DB already has #!/bin/bash and set -euo pipefail
// Just return it as-is since variables are already defined in the script
return $script;
}
private function extractValuesFromOutput(string $output): array
{
$res = [];
// Extract port
if (preg_match('/Port:\s*(\d+)/i', $output, $m)) {
$res['server_port'] = $m[1];
}
// Extract server public key
if (preg_match('/Server Public Key:\s*([A-Za-z0-9+\/=]+)/i', $output, $m)) {
$res['server_public_key'] = $m[1];
}
// Extract preshared key
if (preg_match('/PresharedKey\s*=\s*([A-Za-z0-9+\/=]+)/i', $output, $m)) {
$res['preshared_key'] = $m[1];
}
// Extract subnet (format: "Subnet: 10.8.1.1/24")
if (preg_match('/Subnet:\s*([0-9.]+)\/(\d+)/i', $output, $m)) {
$res['subnet_ip'] = $m[1];
$res['subnet_cidr'] = $m[2];
}
// Extract password (for non-WireGuard protocols)
if (preg_match('/Password:\s*(\S+)/i', $output, $m)) {
$res['password'] = $m[1];
}
// Extract method (for protocols like Shadowsocks)
if (preg_match('/Method:\s*(\S+)/i', $output, $m)) {
$res['method'] = $m[1];
}
// Extract client ID (for protocols that use it)
if (preg_match('/ClientID\s*:\s*([0-9a-fA-F-]+)/i', $output, $m)) {
$res['client_id'] = $m[1];
}
// Generic variable extraction (Variable: KEY=VALUE)
if (preg_match_all('/Variable:\\s*([a-zA-Z0-9_]+)=(.*)/', $output, $matches, PREG_SET_ORDER)) {
foreach ($matches as $m) {
$key = trim($m[1]);
$val = trim($m[2]);
// Remove surrounding quotes if present
$val = trim($val, "'\"");
$res[$key] = $val;
}
}
return $res;
}
private function getProtocolVariables(int $protocolId): array
{
$pdo = DB::conn();
$stmt = $pdo->prepare('SELECT variable_name, COALESCE(default_value, "") as val FROM protocol_variables WHERE protocol_id = ?');
$stmt->execute([$protocolId]);
$vars = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$vars[$row['variable_name']] = $row['val'] ?? '';
}
return $vars;
}
/**
* Database methods
*/
private function getAllProtocols(): array
{
return ProtocolService::getAllProtocolsWithStats();
}
private function getProtocolById(int $id): ?array
{
$pdo = DB::conn();
$stmt = $pdo->prepare('
SELECT p.*,
COUNT(DISTINCT sp.server_id) as server_count
FROM protocols p
LEFT JOIN server_protocols sp ON p.id = sp.protocol_id
WHERE p.id = ?
GROUP BY p.id
');
$stmt->execute([$id]);
$protocol = $stmt->fetch(PDO::FETCH_ASSOC);
return $protocol ?: null;
}
private function createProtocol(array $data): int
{
$pdo = DB::conn();
$stmt = $pdo->prepare('
INSERT INTO protocols (name, slug, description, install_script, uninstall_script, password_command, output_template, ubuntu_compatible, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
');
$stmt->execute([
$data['name'],
$data['slug'],
$data['description'],
$data['install_script'],
$data['uninstall_script'],
$data['password_command'] ?? '',
$data['output_template'],
$data['ubuntu_compatible'],
$data['is_active'],
$data['created_at'],
$data['updated_at']
]);
return (int) $pdo->lastInsertId();
}
private function updateProtocol(int $id, array $data): void
{
$setParts = [];
$values = [];
foreach ($data as $key => $value) {
$setParts[] = "$key = ?";
$values[] = $value;
}
$values[] = $id;
$sql = 'UPDATE protocols SET ' . implode(', ', $setParts) . ' WHERE id = ?';
$pdo = DB::conn();
$stmt = $pdo->prepare($sql);
$stmt->execute($values);
}
private function deleteProtocol(int $id): void
{
$pdo = DB::conn();
$stmt = $pdo->prepare('DELETE FROM protocols WHERE id = ?');
$stmt->execute([$id]);
}
private function isSlugExists(string $slug, ?int $excludeId = null): bool
{
$pdo = DB::conn();
if ($excludeId) {
$stmt = $pdo->prepare('SELECT COUNT(*) FROM protocols WHERE slug = ? AND id != ?');
$stmt->execute([$slug, $excludeId]);
} else {
$stmt = $pdo->prepare('SELECT COUNT(*) FROM protocols WHERE slug = ?');
$stmt->execute([$slug]);
}
return (bool) $stmt->fetchColumn();
}
private function isProtocolUsed(int $id): bool
{
$pdo = DB::conn();
$stmt = $pdo->prepare('SELECT COUNT(*) FROM server_protocols WHERE protocol_id = ?');
$stmt->execute([$id]);
return (bool) $stmt->fetchColumn();
}
}
+375
View File
@@ -0,0 +1,375 @@
<?php
/**
* ScenarioController
* Manages protocol installation scenarios (CRUD operations)
* Allows administrators to view, create, edit, and delete VPN protocol deployment scenarios
*/
class ScenarioController {
/**
* List all protocol scenarios
* GET /admin/scenarios
*/
public function listScenarios() {
requireAdmin();
$scenarios = InstallProtocolManager::getAll();
View::render('settings/scenarios.twig', [
'scenarios' => $scenarios,
'section' => 'scenarios'
]);
}
/**
* View single scenario details
* GET /admin/scenario/:id
*/
public function viewScenario($id) {
requireAdmin();
$scenario = InstallProtocolManager::getById((int)$id);
if (!$scenario) {
http_response_code(404);
View::render('404.twig');
return;
}
$definition = $scenario['definition'] ?? [];
View::render('settings/scenario_view.twig', [
'scenario' => $scenario,
'definition' => $definition,
'section' => 'scenarios'
]);
}
/**
* Show form to create new scenario
* GET /admin/scenario/create
*/
public function createScenarioForm() {
requireAdmin();
$templateDefinition = [
'engine' => 'shell',
'metadata' => [
'container_name' => 'custom-container',
'config_path' => '/opt/amnezia/custom'
],
'scripts' => [
'detect' => 'echo \'{"status":"absent","message":"Custom protocol not found"}\'',
'install' => 'echo \'{"success":true,"message":"Custom protocol installed"}\'',
'restore' => 'echo \'{"success":true,"message":"Custom protocol restored"}\''
]
];
View::render('settings/scenario_form.twig', [
'scenario' => null,
'templateDefinition' => json_encode($templateDefinition, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
'section' => 'scenarios'
]);
}
/**
* Show form to edit existing scenario
* GET /admin/scenario/:id/edit
*/
public function editScenarioForm($id) {
requireAdmin();
$scenario = InstallProtocolManager::getById((int)$id);
if (!$scenario) {
http_response_code(404);
View::render('404.twig');
return;
}
$definitionJson = json_encode($scenario['definition'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
View::render('settings/scenario_form.twig', [
'scenario' => $scenario,
'templateDefinition' => $definitionJson,
'section' => 'scenarios'
]);
}
/**
* Save scenario (create or update)
* POST /admin/scenario
*/
public function saveScenario() {
requireAdmin();
$data = $_POST;
// Validate required fields
if (empty($data['slug']) || empty($data['name'])) {
header('Content-Type: application/json');
http_response_code(400);
echo json_encode([
'success' => false,
'message' => 'Slug and name are required'
]);
return;
}
// Parse definition JSON
if (!empty($data['definition'])) {
if (is_string($data['definition'])) {
$definition = json_decode($data['definition'], true);
if (json_last_error() !== JSON_ERROR_NONE) {
header('Content-Type: application/json');
http_response_code(400);
echo json_encode([
'success' => false,
'message' => 'Invalid JSON in definition: ' . json_last_error_msg()
]);
return;
}
} else {
$definition = $data['definition'];
}
} else {
$definition = [];
}
// Validate definition structure
if (empty($definition['engine'])) {
header('Content-Type: application/json');
http_response_code(400);
echo json_encode([
'success' => false,
'message' => 'Definition must have "engine" field'
]);
return;
}
$saveData = [
'slug' => $data['slug'],
'name' => $data['name'],
'description' => $data['description'] ?? null,
'definition' => $definition,
'is_active' => isset($data['is_active']) ? (int)$data['is_active'] : 1
];
if (!empty($data['id'])) {
$saveData['id'] = (int)$data['id'];
}
try {
$id = InstallProtocolManager::save($saveData);
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'id' => $id,
'message' => 'Scenario saved successfully',
'redirect' => '/admin/scenarios'
]);
} catch (Exception $e) {
header('Content-Type: application/json');
http_response_code(500);
echo json_encode([
'success' => false,
'message' => 'Error saving scenario: ' . $e->getMessage()
]);
}
}
/**
* Delete scenario
* DELETE /admin/scenario/:id or POST /admin/scenario/:id/delete
*/
public function deleteScenario($id) {
requireAdmin();
$id = (int)$id;
// Prevent deletion of default scenario
$scenario = InstallProtocolManager::getById($id);
if (!$scenario) {
header('Content-Type: application/json');
http_response_code(404);
echo json_encode([
'success' => false,
'message' => 'Scenario not found'
]);
return;
}
if ($scenario['slug'] === InstallProtocolManager::getDefaultSlug()) {
header('Content-Type: application/json');
http_response_code(400);
echo json_encode([
'success' => false,
'message' => 'Cannot delete default scenario'
]);
return;
}
try {
InstallProtocolManager::delete($id);
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'message' => 'Scenario deleted successfully',
'redirect' => '/admin/scenarios'
]);
} catch (Exception $e) {
header('Content-Type: application/json');
http_response_code(500);
echo json_encode([
'success' => false,
'message' => 'Error deleting scenario: ' . $e->getMessage()
]);
}
}
/**
* Test scenario script (detection)
* POST /admin/scenario/:id/test
*/
public function testScenario($id) {
requireAdmin();
$scenario = InstallProtocolManager::getById((int)$id);
if (!$scenario) {
header('Content-Type: application/json');
http_response_code(404);
echo json_encode([
'success' => false,
'message' => 'Scenario not found'
]);
return;
}
$serverId = (int)($_POST['server_id'] ?? 0);
if (!$serverId) {
header('Content-Type: application/json');
http_response_code(400);
echo json_encode([
'success' => false,
'message' => 'Server ID required'
]);
return;
}
$server = new VpnServer($serverId);
if (!$server->getData()) {
header('Content-Type: application/json');
http_response_code(404);
echo json_encode([
'success' => false,
'message' => 'Server not found'
]);
return;
}
try {
// Test SSH connection first
if (!$server->testConnection()) {
throw new Exception('SSH connection to server failed');
}
// Run detection script
$result = InstallProtocolManager::runDetection($server, $scenario);
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'result' => $result
]);
} catch (Exception $e) {
header('Content-Type: application/json');
http_response_code(500);
echo json_encode([
'success' => false,
'message' => 'Error testing scenario: ' . $e->getMessage()
]);
}
}
/**
* Export scenario as JSON
* GET /admin/scenario/:id/export
*/
public function exportScenario($id) {
requireAdmin();
$scenario = InstallProtocolManager::getById((int)$id);
if (!$scenario) {
http_response_code(404);
return;
}
header('Content-Type: application/json');
header('Content-Disposition: attachment; filename="scenario-' . $scenario['slug'] . '-' . date('Y-m-d') . '.json"');
// Remove database IDs from export
unset($scenario['id']);
echo json_encode($scenario, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
/**
* Import scenario from JSON
* POST /admin/scenario/import
*/
public function importScenario() {
requireAdmin();
$file = $_FILES['file'] ?? null;
if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
header('Content-Type: application/json');
http_response_code(400);
echo json_encode([
'success' => false,
'message' => 'No file provided'
]);
return;
}
$contents = file_get_contents($file['tmp_name']);
$scenario = json_decode($contents, true);
if (!is_array($scenario)) {
header('Content-Type: application/json');
http_response_code(400);
echo json_encode([
'success' => false,
'message' => 'Invalid JSON file'
]);
return;
}
try {
// Remove ID so it creates a new entry
unset($scenario['id']);
// Ensure required fields exist
if (empty($scenario['slug']) || empty($scenario['name'])) {
throw new Exception('Imported scenario must have slug and name');
}
$id = InstallProtocolManager::save($scenario);
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'id' => $id,
'message' => 'Scenario imported successfully',
'redirect' => '/admin/scenarios'
]);
} catch (Exception $e) {
header('Content-Type: application/json');
http_response_code(500);
echo json_encode([
'success' => false,
'message' => 'Error importing scenario: ' . $e->getMessage()
]);
}
}
}
+72 -3
View File
@@ -13,11 +13,48 @@ class SettingsController {
$stats = $this->getTranslationStats();
$users = $this->getAllUsers();
$apiKey = $this->getApiKey('openrouter');
// LDAP data for embedded tab
$stmt = $this->pdo->query("SELECT * FROM ldap_configs WHERE id = 1");
$config = $stmt->fetch() ?: [];
$stmt = $this->pdo->query("SELECT * FROM ldap_group_mappings ORDER BY ldap_group");
$mappings = $stmt->fetchAll();
// Protocols data for embedded tab (new management)
$protocols = ProtocolService::getAllProtocolsWithStats();
$selectedId = isset($_GET['id']) ? (int)$_GET['id'] : null;
$isNew = isset($_GET['new']);
$editing = null;
if (!$isNew) {
if ($selectedId) {
try {
$editing = ProtocolService::getProtocolWithDetails($selectedId);
} catch (Exception $e) {
$editing = null;
}
}
if (!$editing && !empty($protocols)) {
$firstId = (int)($protocols[0]['id'] ?? 0);
if ($firstId) {
try { $editing = ProtocolService::getProtocolWithDetails($firstId); } catch (Exception $e) { $editing = null; }
}
}
}
$definitionPretty = json_encode([], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$data = [
'translation_stats' => $stats,
'users' => $users,
'openrouter_key' => $apiKey
'openrouter_key' => $apiKey,
// LDAP
'config' => $config,
'mappings' => $mappings,
// Protocols
'protocols' => $protocols,
'editing' => $editing,
'definition_json' => $definitionPretty,
'is_new' => $isNew,
'default_slug' => isset($editing['slug']) ? $editing['slug'] : (isset($protocols[0]['slug']) ? $protocols[0]['slug'] : 'amnezia-wg'),
];
// Check for session messages
@@ -29,6 +66,15 @@ class SettingsController {
$data['error'] = $_SESSION['settings_error'];
unset($_SESSION['settings_error']);
}
// Also pick up protocol messages if present
if (isset($_SESSION['protocol_success']) && !isset($data['success'])) {
$data['success'] = $_SESSION['protocol_success'];
unset($_SESSION['protocol_success']);
}
if (isset($_SESSION['protocol_error']) && !isset($data['error'])) {
$data['error'] = $_SESSION['protocol_error'];
unset($_SESSION['protocol_error']);
}
View::render('settings.twig', $data);
}
@@ -79,6 +125,29 @@ class SettingsController {
exit;
}
public function updateProfile() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /settings');
exit;
}
$user = Auth::user();
$displayName = trim($_POST['display_name'] ?? '');
if ($displayName === '') {
$_SESSION['settings_error'] = 'Display name cannot be empty';
header('Location: /settings#profile');
exit;
}
$stmt = $this->pdo->prepare("UPDATE users SET display_name = ? WHERE id = ?");
$stmt->execute([$displayName, $user['id']]);
$_SESSION['settings_success'] = 'Profile updated';
header('Location: /settings#profile');
exit;
}
public function addUser() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /settings');
@@ -422,7 +491,7 @@ class SettingsController {
]);
$_SESSION['settings_success'] = 'LDAP settings saved successfully';
header('Location: /settings/ldap');
header('Location: /settings#ldap');
exit;
}