feat: ssh auth, protocol management, and cleanup
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user