feat: ssh auth, protocol management, and cleanup

This commit is contained in:
infosave2007
2026-01-23 17:55:40 +03:00
parent 60fc55fd47
commit bbab877eac
70 changed files with 16225 additions and 986 deletions
@@ -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();
}
}