Files
amneziavpnphp/inc/VpnServer.php
T
2025-11-07 13:34:06 +03:00

450 lines
15 KiB
PHP

<?php
/**
* VPN Server Management Class
* Handles deployment and management of Amnezia VPN servers
* Based on amnezia_deploy_v2.php
*/
class VpnServer {
private $serverId;
private $data;
public function __construct(?int $serverId = null) {
$this->serverId = $serverId;
if ($serverId) {
$this->load();
}
}
/**
* Load server data from database
*/
private function load(): void {
$pdo = DB::conn();
$stmt = $pdo->prepare('SELECT * FROM vpn_servers WHERE id = ?');
$stmt->execute([$this->serverId]);
$this->data = $stmt->fetch();
if (!$this->data) {
throw new Exception('Server not found');
}
}
/**
* Create new VPN server in database
*/
public static function create(array $data): int {
$pdo = DB::conn();
// Validate required fields
$required = ['user_id', 'name', 'host', 'port', 'username', 'password'];
foreach ($required as $field) {
if (empty($data[$field])) {
throw new Exception("Field {$field} is required");
}
}
$stmt = $pdo->prepare('
INSERT INTO vpn_servers
(user_id, name, host, port, username, password, container_name, vpn_subnet, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
');
$stmt->execute([
$data['user_id'],
$data['name'],
$data['host'],
$data['port'],
$data['username'],
$data['password'],
$data['container_name'] ?? 'amnezia-awg',
$data['vpn_subnet'] ?? '10.8.1.0/24',
'deploying'
]);
return (int)$pdo->lastInsertId();
}
/**
* Deploy VPN server using amnezia_deploy_v2.php logic
*/
public function deploy(): array {
if (!$this->data) {
throw new Exception('Server not loaded');
}
$pdo = DB::conn();
$errors = [];
try {
// Update status to deploying
$pdo->prepare('UPDATE vpn_servers SET status = ? WHERE id = ?')
->execute(['deploying', $this->serverId]);
// Test SSH connection
if (!$this->testConnection()) {
throw new Exception('SSH connection failed');
}
// Install Docker if needed
$this->installDocker();
// Create directories
$this->executeCommand('mkdir -p /opt/amnezia/amnezia-awg', true);
// Find free UDP port
$vpnPort = $this->findFreeUdpPort();
// Create Dockerfile
$this->createDockerfile();
// Create start script
$this->createStartScript();
// Build Docker image
$this->buildDockerImage();
// Run container
$this->runContainer($vpnPort);
// Initialize server config
$keys = $this->initializeServerConfig($vpnPort);
// Update database with deployment info
$stmt = $pdo->prepare('
UPDATE vpn_servers
SET vpn_port = ?,
server_public_key = ?,
preshared_key = ?,
awg_params = ?,
status = ?,
deployed_at = NOW(),
error_message = NULL
WHERE id = ?
');
$stmt->execute([
$vpnPort,
$keys['public_key'],
$keys['preshared_key'],
json_encode($keys['awg_params']),
'active',
$this->serverId
]);
// Reload data
$this->load();
return [
'success' => true,
'vpn_port' => $vpnPort,
'public_key' => $keys['public_key']
];
} catch (Exception $e) {
// Update status to error
$pdo->prepare('UPDATE vpn_servers SET status = ?, error_message = ? WHERE id = ?')
->execute(['error', $e->getMessage(), $this->serverId]);
throw $e;
}
}
/**
* Test SSH connection to server
*/
private function testConnection(): bool {
$testCommand = sprintf(
"sshpass -p '%s' ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o PreferredAuthentications=password -o PubkeyAuthentication=no -o ConnectTimeout=10 %s@%s 'echo test' 2>/dev/null",
$this->data['password'],
$this->data['username'],
$this->data['host']
);
$result = shell_exec($testCommand);
return trim($result) === 'test';
}
/**
* Execute command on remote server
*/
private function executeCommand(string $command, bool $sudo = false): string {
if ($sudo && strtolower($this->data['username']) !== 'root') {
$command = "echo '{$this->data['password']}' | sudo -S " . $command;
}
$escapedCommand = escapeshellarg($command);
$sshCommand = sprintf(
"sshpass -p '%s' ssh -q -o LogLevel=ERROR -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o PreferredAuthentications=password -o PubkeyAuthentication=no %s@%s %s 2>&1",
$this->data['password'],
$this->data['username'],
$this->data['host'],
$escapedCommand
);
return shell_exec($sshCommand) ?? '';
}
/**
* Install Docker on remote server
*/
private function installDocker(): void {
$dockerVersion = $this->executeCommand('docker --version');
if (stripos($dockerVersion, 'version') !== false) {
return; // Docker already installed
}
$this->executeCommand('curl -fsSL https://get.docker.com | sh', true);
$this->executeCommand('systemctl enable --now docker', true);
}
/**
* Find free UDP port on remote server
*/
private function findFreeUdpPort(): int {
$min = 30000;
$max = 65000;
for ($attempt = 0; $attempt < 30; $attempt++) {
$candidate = random_int($min, $max);
$cmd = "ss -lun | awk '{print \$4}' | grep -E ':(" . $candidate . ")($| )' || true";
$out = $this->executeCommand($cmd, false);
if (trim($out) === '') {
return $candidate;
}
}
throw new Exception('Could not find free UDP port');
}
/**
* Create Dockerfile on remote server
*/
private function createDockerfile(): void {
$dockerfile = <<<'DOCKERFILE'
FROM amneziavpn/amnezia-wg:latest
LABEL maintainer="AmneziaVPN"
RUN apk add --no-cache bash curl dumb-init
RUN apk --update upgrade --no-cache
RUN mkdir -p /opt/amnezia
RUN echo -e "#!/bin/bash\ntail -f /dev/null" > /opt/amnezia/start.sh
RUN chmod a+x /opt/amnezia/start.sh
ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
CMD [ "" ]
DOCKERFILE;
$escaped = addslashes(trim($dockerfile));
$this->executeCommand("echo \"{$escaped}\" > /opt/amnezia/amnezia-awg/Dockerfile", true);
}
/**
* Create start script on remote server
*/
private function createStartScript(): void {
$script = <<<'BASH'
#!/bin/bash
echo "Container startup"
# Wait for config if not exists yet
for i in {1..30}; do
if [ -f /opt/amnezia/awg/wg0.conf ]; then
break
fi
sleep 1
done
# Kill daemons in case of restart
wg-quick down /opt/amnezia/awg/wg0.conf 2>/dev/null || true
# Start daemons if configured
if [ -f /opt/amnezia/awg/wg0.conf ]; then
wg-quick up /opt/amnezia/awg/wg0.conf
echo "WireGuard started"
else
echo "No wg0.conf found, skipping WireGuard startup"
fi
# Allow traffic on the TUN interface
iptables -A INPUT -i wg0 -j ACCEPT 2>/dev/null || true
iptables -A FORWARD -i wg0 -j ACCEPT 2>/dev/null || true
iptables -A OUTPUT -o wg0 -j ACCEPT 2>/dev/null || true
# Allow forwarding traffic only from the VPN
iptables -A FORWARD -i wg0 -o eth0 -s 10.8.1.0/24 -j ACCEPT 2>/dev/null || true
iptables -A FORWARD -i wg0 -o eth1 -s 10.8.1.0/24 -j ACCEPT 2>/dev/null || true
iptables -A FORWARD -m state --state ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true
iptables -t nat -A POSTROUTING -s 10.8.1.0/24 -o eth0 -j MASQUERADE 2>/dev/null || true
iptables -t nat -A POSTROUTING -s 10.8.1.0/24 -o eth1 -j MASQUERADE 2>/dev/null || true
tail -f /dev/null
BASH;
$escaped = addslashes(trim($script));
$this->executeCommand("echo \"{$escaped}\" > /opt/amnezia/amnezia-awg/start.sh", true);
$this->executeCommand("chmod +x /opt/amnezia/amnezia-awg/start.sh", true);
}
/**
* Build Docker image
*/
private function buildDockerImage(): void {
$containerName = $this->data['container_name'];
// Cleanup old container/image
$this->executeCommand("docker stop {$containerName} 2>/dev/null || true", true);
$this->executeCommand("docker rm -fv {$containerName} 2>/dev/null || true", true);
$this->executeCommand("docker rmi {$containerName} 2>/dev/null || true", true);
// Build new image
$buildCmd = sprintf(
'docker build --no-cache --pull -t %s /opt/amnezia/amnezia-awg',
$containerName
);
$this->executeCommand($buildCmd, true);
}
/**
* Run Docker container
*/
private function runContainer(int $vpnPort): void {
$containerName = $this->data['container_name'];
$runCmd = sprintf(
'docker run -d --log-driver none --restart always --privileged --cap-add=NET_ADMIN --cap-add=SYS_MODULE -p %d:%d/udp -v /lib/modules:/lib/modules --name %s %s',
$vpnPort,
$vpnPort,
$containerName,
$containerName
);
$this->executeCommand($runCmd, true);
sleep(3); // Wait for container to start
}
/**
* Initialize server configuration with AWG parameters
*/
private function initializeServerConfig(int $vpnPort): array {
$containerName = $this->data['container_name'];
// Create directory
$this->executeCommand("docker exec -i {$containerName} mkdir -p /opt/amnezia/awg", true);
// Generate keys
$this->executeCommand("docker exec -i {$containerName} sh -c 'cd /opt/amnezia/awg && umask 077 && wg genkey | tee server_private.key | wg pubkey > wireguard_server_public_key.key'", true);
$this->executeCommand("docker exec -i {$containerName} sh -c 'cd /opt/amnezia/awg && wg genpsk > wireguard_psk.key'", true);
$this->executeCommand("docker exec -i {$containerName} chmod 600 /opt/amnezia/awg/server_private.key /opt/amnezia/awg/wireguard_psk.key /opt/amnezia/awg/wireguard_server_public_key.key", true);
// Get keys
$privKey = trim($this->executeCommand("docker exec -i {$containerName} cat /opt/amnezia/awg/server_private.key", true));
$pubKey = trim($this->executeCommand("docker exec -i {$containerName} cat /opt/amnezia/awg/wireguard_server_public_key.key", true));
$psk = trim($this->executeCommand("docker exec -i {$containerName} cat /opt/amnezia/awg/wireguard_psk.key", true));
// Generate AWG parameters
$awgParams = [
'Jc' => 3,
'Jmin' => 10,
'Jmax' => 50,
'S1' => rand(50, 250),
'S2' => rand(50, 250),
'H1' => rand(100000, 2000000000),
'H2' => rand(100000, 2000000000),
'H3' => rand(100000, 2000000000),
'H4' => rand(100000, 2000000000)
];
// Create wg0.conf
$wgConfig = "[Interface]\n";
$wgConfig .= "PrivateKey = {$privKey}\n";
$wgConfig .= "Address = {$this->data['vpn_subnet']}\n";
$wgConfig .= "ListenPort = {$vpnPort}\n";
foreach ($awgParams as $key => $value) {
$wgConfig .= "{$key} = {$value}\n";
}
$wgConfig .= "\n";
$escaped = addslashes($wgConfig);
$this->executeCommand("docker exec -i {$containerName} sh -c 'echo \"{$escaped}\" > /opt/amnezia/awg/wg0.conf'", true);
$this->executeCommand("docker exec -i {$containerName} chmod 600 /opt/amnezia/awg/wg0.conf", true);
// Create clientsTable
$this->executeCommand("docker exec -i {$containerName} sh -c 'echo \"[]\" > /opt/amnezia/awg/clientsTable'", true);
// Start WireGuard
$this->executeCommand("docker exec -i {$containerName} wg-quick up /opt/amnezia/awg/wg0.conf 2>&1", true);
// Apply firewall rules
$this->executeCommand("docker exec -i {$containerName} sh -c 'iptables -A INPUT -i wg0 -j ACCEPT 2>/dev/null || true'", true);
$this->executeCommand("docker exec -i {$containerName} sh -c 'iptables -A FORWARD -i wg0 -j ACCEPT 2>/dev/null || true'", true);
$this->executeCommand("docker exec -i {$containerName} sh -c 'iptables -A OUTPUT -o wg0 -j ACCEPT 2>/dev/null || true'", true);
$this->executeCommand("docker exec -i {$containerName} sh -c 'iptables -A FORWARD -i wg0 -o eth0 -s 10.8.1.0/24 -j ACCEPT 2>/dev/null || true'", true);
$this->executeCommand("docker exec -i {$containerName} sh -c 'iptables -t nat -A POSTROUTING -s 10.8.1.0/24 -o eth0 -j MASQUERADE 2>/dev/null || true'", true);
sleep(2);
return [
'public_key' => $pubKey,
'preshared_key' => $psk,
'awg_params' => $awgParams
];
}
/**
* Get server status from database
*/
public function getStatus(): string {
return $this->data['status'] ?? 'unknown';
}
/**
* Get all servers for a user
*/
public static function listByUser(int $userId): array {
$pdo = DB::conn();
$stmt = $pdo->prepare('SELECT * FROM vpn_servers WHERE user_id = ? ORDER BY created_at DESC');
$stmt->execute([$userId]);
return $stmt->fetchAll();
}
/**
* Get all servers (admin only)
*/
public static function listAll(): array {
$pdo = DB::conn();
$stmt = $pdo->query('SELECT s.*, u.email as user_email FROM vpn_servers s LEFT JOIN users u ON s.user_id = u.id ORDER BY s.created_at DESC');
return $stmt->fetchAll();
}
/**
* Delete server
*/
public function delete(): bool {
// Stop and remove container
try {
$containerName = $this->data['container_name'];
$this->executeCommand("docker stop {$containerName} 2>/dev/null || true", true);
$this->executeCommand("docker rm -fv {$containerName} 2>/dev/null || true", true);
$this->executeCommand("rm -rf /opt/amnezia/amnezia-awg", true);
} catch (Exception $e) {
// Ignore errors during cleanup
}
// Delete from database
$pdo = DB::conn();
$stmt = $pdo->prepare('DELETE FROM vpn_servers WHERE id = ?');
return $stmt->execute([$this->serverId]);
}
/**
* Get server data
*/
public function getData(): ?array {
return $this->data;
}
}