Add project files
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
class Auth {
|
||||
public static function register(string $name, string $email, string $password): bool {
|
||||
$pdo = DB::conn();
|
||||
$email = strtolower(trim($email));
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) return false;
|
||||
if (strlen($password) < 6) return false;
|
||||
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = ? LIMIT 1');
|
||||
$stmt->execute([$email]);
|
||||
if ($stmt->fetchColumn()) return false;
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$stmt = $pdo->prepare('INSERT INTO users (email, password_hash, name, role, status) VALUES (?, ?, ?, ?, ?)');
|
||||
return $stmt->execute([$email, $hash, $name ?: $email, 'user', 'active']);
|
||||
}
|
||||
|
||||
public static function login(string $email, string $password): bool {
|
||||
$pdo = DB::conn();
|
||||
$email = strtolower(trim($email));
|
||||
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ? LIMIT 1');
|
||||
$stmt->execute([$email]);
|
||||
$user = $stmt->fetch();
|
||||
if (!$user) return false;
|
||||
if (!password_verify($password, $user['password_hash'])) return false;
|
||||
$_SESSION['user_id'] = (int)$user['id'];
|
||||
$pdo->prepare('UPDATE users SET last_login_at = NOW() WHERE id = ?')->execute([$user['id']]);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function logout(): void { unset($_SESSION['user_id']); }
|
||||
public static function check(): bool { return isset($_SESSION['user_id']); }
|
||||
|
||||
public static function getUserByEmail(string $email): ?array {
|
||||
$pdo = DB::conn();
|
||||
$email = strtolower(trim($email));
|
||||
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ? LIMIT 1');
|
||||
$stmt->execute([$email]);
|
||||
$user = $stmt->fetch();
|
||||
return $user ?: null;
|
||||
}
|
||||
|
||||
public static function user(): ?array {
|
||||
if (!self::check()) return null;
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ? LIMIT 1');
|
||||
$stmt->execute([$_SESSION['user_id']]);
|
||||
$u = $stmt->fetch();
|
||||
return $u ?: null;
|
||||
}
|
||||
|
||||
public static function isAdmin(): bool {
|
||||
$u = self::user();
|
||||
return $u && ($u['role'] === 'admin');
|
||||
}
|
||||
|
||||
public static function seedAdmin(string $email, string $password): void {
|
||||
$pdo = DB::conn();
|
||||
$email = strtolower(trim($email));
|
||||
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = ? LIMIT 1');
|
||||
$stmt->execute([$email]);
|
||||
if ($stmt->fetchColumn()) return;
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$stmt = $pdo->prepare('INSERT INTO users (email, password_hash, name, role, status) VALUES (?, ?, ?, ?, ?)');
|
||||
$stmt->execute([$email, $hash, 'Administrator', 'admin', 'active']);
|
||||
}
|
||||
|
||||
public static function listUsers(): array {
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->query('SELECT id, email, name, role, status, created_at, last_login_at FROM users ORDER BY id DESC');
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public static function setRole(int $userId, string $role): bool {
|
||||
if (!in_array($role, ['admin','user'], true)) return false;
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->prepare('UPDATE users SET role = ? WHERE id = ?');
|
||||
return $stmt->execute([$role, $userId]);
|
||||
}
|
||||
|
||||
public static function saveSetting(?int $userId, string $namespace, string $key, string $valueJson): bool {
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->prepare('INSERT INTO settings (user_id, namespace, `key`, `value`) VALUES (?, ?, ?, CAST(? AS JSON))
|
||||
ON DUPLICATE KEY UPDATE `value` = VALUES(`value`), updated_at = NOW()');
|
||||
return $stmt->execute([$userId, $namespace, $key, $valueJson]);
|
||||
}
|
||||
|
||||
public static function getSetting(?int $userId, string $namespace, string $key): array {
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->prepare('SELECT `value` FROM settings WHERE user_id <=> ? AND namespace = ? AND `key` = ? LIMIT 1');
|
||||
$stmt->execute([$userId, $namespace, $key]);
|
||||
$val = $stmt->fetchColumn();
|
||||
if (!$val) return [];
|
||||
$decoded = json_decode($val, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
class Config {
|
||||
protected static array $env = [];
|
||||
|
||||
public static function load(string $path): void {
|
||||
if (!file_exists($path)) {
|
||||
// allow running with only environment variables exported
|
||||
return;
|
||||
}
|
||||
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (str_starts_with(trim($line), '#')) continue;
|
||||
$parts = explode('=', $line, 2);
|
||||
if (count($parts) !== 2) continue;
|
||||
$key = trim($parts[0]);
|
||||
$value = trim($parts[1]);
|
||||
$value = trim($value, "\"' ");
|
||||
self::$env[$key] = $value;
|
||||
@putenv($key . '=' . $value);
|
||||
}
|
||||
}
|
||||
|
||||
public static function get(string $key, $default = null) {
|
||||
$env = getenv($key);
|
||||
if ($env !== false && $env !== null) return $env;
|
||||
return self::$env[$key] ?? $default;
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
class DB {
|
||||
private static ?PDO $pdo = null;
|
||||
|
||||
public static function conn(): PDO {
|
||||
if (self::$pdo) return self::$pdo;
|
||||
$host = Config::get('DB_HOST', '127.0.0.1');
|
||||
$port = Config::get('DB_PORT', '3306');
|
||||
$db = Config::get('DB_DATABASE', 'amnezia_panel');
|
||||
$user = Config::get('DB_USERNAME', 'amnezia');
|
||||
$pass = Config::get('DB_PASSWORD', '');
|
||||
$dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $db);
|
||||
$options = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
];
|
||||
self::$pdo = new PDO($dsn, $user, $pass, $options);
|
||||
return self::$pdo;
|
||||
}
|
||||
}
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
<?php
|
||||
/**
|
||||
* JWT Authentication Helper
|
||||
* Provides JWT token generation and validation for API authentication
|
||||
*/
|
||||
|
||||
use Firebase\JWT\JWT as FirebaseJWT;
|
||||
use Firebase\JWT\Key;
|
||||
|
||||
class JWT {
|
||||
private static ?string $secretKey = null;
|
||||
|
||||
/**
|
||||
* Get or generate JWT secret key
|
||||
*/
|
||||
private static function getSecretKey(): string {
|
||||
if (self::$secretKey !== null) {
|
||||
return self::$secretKey;
|
||||
}
|
||||
|
||||
// Try to get from environment
|
||||
$envKey = getenv('JWT_SECRET');
|
||||
if ($envKey && strlen($envKey) >= 32) {
|
||||
self::$secretKey = $envKey;
|
||||
return self::$secretKey;
|
||||
}
|
||||
|
||||
// Try to get from database settings
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->prepare('SELECT value FROM settings WHERE key = ?');
|
||||
$stmt->execute(['jwt_secret']);
|
||||
$result = $stmt->fetch();
|
||||
|
||||
if ($result && !empty($result['value'])) {
|
||||
self::$secretKey = $result['value'];
|
||||
return self::$secretKey;
|
||||
}
|
||||
|
||||
// Generate new secret key and save it
|
||||
$newKey = bin2hex(random_bytes(32));
|
||||
|
||||
$stmt = $pdo->prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON DUPLICATE KEY UPDATE value = ?');
|
||||
$stmt->execute(['jwt_secret', $newKey, $newKey]);
|
||||
|
||||
self::$secretKey = $newKey;
|
||||
return self::$secretKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JWT token for user
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @param int $expiresIn Token lifetime in seconds (default: 30 days)
|
||||
* @return string JWT token
|
||||
*/
|
||||
public static function generate(int $userId, int $expiresIn = 2592000): string {
|
||||
$issuedAt = time();
|
||||
$expire = $issuedAt + $expiresIn;
|
||||
|
||||
$payload = [
|
||||
'iss' => 'amnezia-panel', // Issuer
|
||||
'aud' => 'amnezia-api', // Audience
|
||||
'iat' => $issuedAt, // Issued at
|
||||
'exp' => $expire, // Expiration
|
||||
'sub' => $userId, // Subject (user ID)
|
||||
'jti' => bin2hex(random_bytes(16)) // JWT ID (unique token identifier)
|
||||
];
|
||||
|
||||
return FirebaseJWT::encode($payload, self::getSecretKey(), 'HS256');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and decode JWT token
|
||||
*
|
||||
* @param string $token JWT token
|
||||
* @return object|null Decoded token payload or null if invalid
|
||||
*/
|
||||
public static function decode(string $token): ?object {
|
||||
try {
|
||||
$decoded = FirebaseJWT::decode($token, new Key(self::getSecretKey(), 'HS256'));
|
||||
|
||||
// Verify issuer and audience
|
||||
if ($decoded->iss !== 'amnezia-panel' || $decoded->aud !== 'amnezia-api') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
} catch (Exception $e) {
|
||||
error_log('JWT decode error: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user ID from JWT token
|
||||
*
|
||||
* @param string $token JWT token
|
||||
* @return int|null User ID or null if invalid
|
||||
*/
|
||||
public static function getUserId(string $token): ?int {
|
||||
$decoded = self::decode($token);
|
||||
|
||||
if ($decoded === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int)$decoded->sub;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify JWT token and get user data
|
||||
*
|
||||
* @param string $token JWT token
|
||||
* @return array|null User data or null if invalid
|
||||
*/
|
||||
public static function verify(string $token): ?array {
|
||||
$userId = self::getUserId($token);
|
||||
|
||||
if ($userId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->prepare('SELECT id, name, email, role FROM users WHERE id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
return $user ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract token from Authorization header
|
||||
*
|
||||
* @return string|null Token or null if not found
|
||||
*/
|
||||
public static function getTokenFromHeader(): ?string {
|
||||
// Try getallheaders() first (Apache/FPM)
|
||||
if (function_exists('getallheaders')) {
|
||||
$headers = getallheaders();
|
||||
} else {
|
||||
// Fallback for other environments (nginx, CLI)
|
||||
$headers = [];
|
||||
foreach ($_SERVER as $key => $value) {
|
||||
if (strpos($key, 'HTTP_') === 0) {
|
||||
$header = str_replace(' ', '-', ucwords(str_replace('_', ' ', strtolower(substr($key, 5)))));
|
||||
$headers[$header] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check Authorization header
|
||||
if (isset($headers['Authorization'])) {
|
||||
$auth = $headers['Authorization'];
|
||||
|
||||
// Bearer token format: "Bearer {token}"
|
||||
if (preg_match('/Bearer\s+(.+)/', $auth, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Check X-API-Token header (alternative)
|
||||
if (isset($headers['X-Api-Token'])) {
|
||||
return $headers['X-Api-Token'];
|
||||
}
|
||||
|
||||
// Also check direct $_SERVER access for Authorization
|
||||
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
|
||||
$auth = $_SERVER['HTTP_AUTHORIZATION'];
|
||||
if (preg_match('/Bearer\s+(.+)/', $auth, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: Require JWT authentication for API endpoints
|
||||
*
|
||||
* @return array|null User data if authenticated, sends 401 response and returns null if not
|
||||
*/
|
||||
public static function requireAuth(): ?array {
|
||||
$token = self::getTokenFromHeader();
|
||||
|
||||
if ($token === null) {
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => 'Missing authentication token']);
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = self::verify($token);
|
||||
|
||||
if ($user === null) {
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => 'Invalid or expired token']);
|
||||
return null;
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create API token for user (saves to database)
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @param string|null $name Token name/description
|
||||
* @param int $expiresIn Token lifetime in seconds (default: 30 days)
|
||||
* @return array Token data (id, token, expires_at)
|
||||
*/
|
||||
public static function createApiToken(int $userId, ?string $name = null, int $expiresIn = 2592000): array {
|
||||
$token = self::generate($userId, $expiresIn);
|
||||
$expiresAt = date('Y-m-d H:i:s', time() + $expiresIn);
|
||||
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO api_tokens (user_id, token, name, expires_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
$userId,
|
||||
$token,
|
||||
$name ?? 'API Token',
|
||||
$expiresAt
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => (int)$pdo->lastInsertId(),
|
||||
'token' => $token,
|
||||
'name' => $name ?? 'API Token',
|
||||
'expires_at' => $expiresAt
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke API token
|
||||
*
|
||||
* @param int $tokenId Token ID
|
||||
* @param int $userId User ID (for ownership verification)
|
||||
* @return bool Success
|
||||
*/
|
||||
public static function revokeApiToken(int $tokenId, int $userId): bool {
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->prepare('DELETE FROM api_tokens WHERE id = ? AND user_id = ?');
|
||||
return $stmt->execute([$tokenId, $userId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all API tokens for user
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @return array List of tokens
|
||||
*/
|
||||
public static function getUserTokens(int $userId): array {
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT id, name, LEFT(token, 20) as token_preview, created_at, expires_at
|
||||
FROM api_tokens
|
||||
WHERE user_id = ? AND (expires_at IS NULL OR expires_at > NOW())
|
||||
ORDER BY created_at DESC
|
||||
');
|
||||
$stmt->execute([$userId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
use Endroid\QrCode\QrCode;
|
||||
use Endroid\QrCode\Writer\PngWriter;
|
||||
use Endroid\QrCode\Writer\SvgWriter;
|
||||
use Endroid\QrCode\ErrorCorrectionLevel;
|
||||
use Endroid\QrCode\Label\Label;
|
||||
use Endroid\QrCode\Label\LabelAlignment;
|
||||
use Endroid\QrCode\Encoding\Encoding;
|
||||
|
||||
class QrUtil {
|
||||
public static function pngBase64(string $text, int $size = 300, int $margin = 1, string $label = 'Amnezia QR (old)') : string {
|
||||
// Try to load Composer autoload if not yet loaded
|
||||
if (!class_exists(QrCode::class)) {
|
||||
$autoload = __DIR__ . '/vendor/autoload.php';
|
||||
if (file_exists($autoload)) {
|
||||
require_once $autoload;
|
||||
}
|
||||
}
|
||||
// Prefer Composer library; PNG when GD is available, otherwise SVG fallback
|
||||
if (class_exists(QrCode::class)) {
|
||||
$qrCode = QrCode::create($text)
|
||||
->setSize($size)
|
||||
->setMargin($margin)
|
||||
->setErrorCorrectionLevel(ErrorCorrectionLevel::Medium)
|
||||
->setEncoding(new Encoding('UTF-8'));
|
||||
|
||||
if (class_exists(PngWriter::class) && extension_loaded('gd')) {
|
||||
// Avoid labels in PNG to sidestep GD freetype dependency
|
||||
$writer = new PngWriter();
|
||||
$result = $writer->write($qrCode);
|
||||
return 'data:image/png;base64,' . base64_encode($result->getString());
|
||||
}
|
||||
if (class_exists(SvgWriter::class)) {
|
||||
$writer = new SvgWriter();
|
||||
$result = $writer->write($qrCode, null, Label::create($label)->setAlignment(LabelAlignment::Center));
|
||||
return 'data:image/svg+xml;base64,' . base64_encode($result->getString());
|
||||
}
|
||||
}
|
||||
// Fallback to phpqrcode.php if available
|
||||
$libPath = __DIR__ . '/phpqrcode.php';
|
||||
if (file_exists($libPath)) {
|
||||
require_once $libPath;
|
||||
ob_start();
|
||||
// Avoid direct constant references to satisfy linter
|
||||
$args = [$text];
|
||||
if (function_exists('constant') && defined('QR_ECLEVEL_M')) {
|
||||
$args = [$text, null, constant('QR_ECLEVEL_M'), $size / 40, $margin];
|
||||
}
|
||||
call_user_func_array(['QRcode', 'png'], $args);
|
||||
$png = ob_get_clean();
|
||||
return 'data:image/png;base64,' . base64_encode($png);
|
||||
}
|
||||
throw new RuntimeException('QR library not available');
|
||||
}
|
||||
|
||||
private static function urlsafe_b64_encode(string $bytes): string {
|
||||
return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
public static function encodeOldPayloadFromJson(string $jsonText): string {
|
||||
$json = self::normalizeJson($jsonText);
|
||||
// Old format uses zlib (gzcompress) with header [version, compressed_len, uncompressed_len]
|
||||
$compressed = gzcompress($json, 9);
|
||||
if ($compressed === false) {
|
||||
throw new RuntimeException('gzcompress failed');
|
||||
}
|
||||
$uncompressedLen = strlen($json);
|
||||
$compressedLen = strlen($compressed) + 4;
|
||||
$version = 0x07C00100; // align with working payload header (big-endian)
|
||||
$header = pack('N3', $version, $compressedLen, $uncompressedLen);
|
||||
return self::urlsafe_b64_encode($header . $compressed);
|
||||
}
|
||||
|
||||
public static function encodeOldPayloadFromConf(string $confText): string {
|
||||
$payload = self::buildOldEnvelopeFromConf($confText);
|
||||
return self::encodeOldPayloadFromJson(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
private static function resolveServerDescription(?string $endpointHost): string {
|
||||
$desc = (string)($endpointHost ?? '');
|
||||
try {
|
||||
$cfgPath = __DIR__ . '/config.php';
|
||||
$dbPath = __DIR__ . '/Database.php';
|
||||
if (file_exists($cfgPath) && file_exists($dbPath)) {
|
||||
$config = require $cfgPath;
|
||||
require_once $dbPath;
|
||||
$pdo = (new Database($config['db']))->pdo();
|
||||
$stmt = $pdo->prepare('SELECT name FROM servers WHERE host=? LIMIT 1');
|
||||
$stmt->execute([$endpointHost]);
|
||||
$row = $stmt->fetch();
|
||||
if ($row && !empty($row['name'])) {
|
||||
$desc = $row['name'];
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// fallback to host
|
||||
}
|
||||
return $desc;
|
||||
}
|
||||
|
||||
private static function buildOldEnvelopeFromConf(string $conf): array {
|
||||
$endpointHost = null; $endpointPort = null; $mtu = null; $dns = []; $keepAlive = null;
|
||||
$privKey = null; $pubKeyServer = null; $psk = null; $address = null; $allowedIps = [];
|
||||
foreach (explode("\n", $conf) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || $line[0] === '#') { continue; }
|
||||
if (stripos($line, 'Endpoint') === 0 && strpos($line, '=') !== false) {
|
||||
[, $v] = array_map('trim', explode('=', $line, 2));
|
||||
if (preg_match('/^\[?([^\]]+)\]?:([0-9]{2,5})$/', $v, $m)) {
|
||||
$endpointHost = $m[1];
|
||||
$endpointPort = (int)$m[2];
|
||||
}
|
||||
} elseif (stripos($line, 'MTU') === 0 && strpos($line, '=') !== false) {
|
||||
[, $v] = array_map('trim', explode('=', $line, 2));
|
||||
$mtu = (int)$v;
|
||||
} elseif (stripos($line, 'DNS') === 0 && strpos($line, '=') !== false) {
|
||||
[, $v] = array_map('trim', explode('=', $line, 2));
|
||||
$dns = array_map('trim', preg_split('/[,\s]+/', $v));
|
||||
} elseif (stripos($line, 'PrivateKey') === 0 && strpos($line, '=') !== false) {
|
||||
[, $v] = array_map('trim', explode('=', $line, 2));
|
||||
$privKey = $v;
|
||||
} elseif (stripos($line, 'PublicKey') === 0 && strpos($line, '=') !== false) {
|
||||
[, $v] = array_map('trim', explode('=', $line, 2));
|
||||
$pubKeyServer = $v;
|
||||
} elseif (stripos($line, 'PresharedKey') === 0 && strpos($line, '=') !== false) {
|
||||
[, $v] = array_map('trim', explode('=', $line, 2));
|
||||
$psk = $v;
|
||||
} elseif (stripos($line, 'Address') === 0 && strpos($line, '=') !== false) {
|
||||
[, $v] = array_map('trim', explode('=', $line, 2));
|
||||
$address = $v;
|
||||
} elseif (stripos($line, 'AllowedIPs') === 0 && strpos($line, '=') !== false) {
|
||||
[, $v] = array_map('trim', explode('=', $line, 2));
|
||||
$allowedIps = array_map('trim', preg_split('/[,\s]+/', $v));
|
||||
} elseif (stripos($line, 'PersistentKeepalive') === 0 && strpos($line, '=') !== false) {
|
||||
[, $v] = array_map('trim', explode('=', $line, 2));
|
||||
$keepAlive = (int)$v;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$endpointPort) { $endpointPort = 51820; }
|
||||
if (!$mtu) { $mtu = 1280; }
|
||||
if (!$keepAlive) { $keepAlive = 25; }
|
||||
$dns1 = $dns[0] ?? '1.1.1.1';
|
||||
$dns2 = $dns[1] ?? '1.0.0.1';
|
||||
|
||||
// Derive client public key if sodium available
|
||||
$clientPubKey = '';
|
||||
if ($privKey && function_exists('sodium_crypto_scalarmult_base')) {
|
||||
$bin = base64_decode($privKey, true);
|
||||
if ($bin !== false && strlen($bin) === 32) {
|
||||
$pub = sodium_crypto_scalarmult_base($bin);
|
||||
$clientPubKey = base64_encode($pub);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect obfuscation params from conf if present
|
||||
$params = [
|
||||
'H1' => null, 'H2' => null, 'H3' => null, 'H4' => null,
|
||||
'Jc' => null, 'Jmin' => null, 'Jmax' => null,
|
||||
'S1' => null, 'S2' => null,
|
||||
];
|
||||
foreach (explode("\n", $conf) as $line) {
|
||||
$line = trim($line);
|
||||
foreach (array_keys($params) as $k) {
|
||||
if (stripos($line, $k) === 0 && strpos($line, '=') !== false) {
|
||||
[, $v] = array_map('trim', explode('=', $line, 2));
|
||||
$params[$k] = $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build last_config JSON object (stringified, pretty-printed)
|
||||
$lastConfigObj = [
|
||||
'H1' => (string)($params['H1'] ?? ''),
|
||||
'H2' => (string)($params['H2'] ?? ''),
|
||||
'H3' => (string)($params['H3'] ?? ''),
|
||||
'H4' => (string)($params['H4'] ?? ''),
|
||||
'Jc' => (string)($params['Jc'] ?? ''),
|
||||
'Jmax' => (string)($params['Jmax'] ?? ''),
|
||||
'Jmin' => (string)($params['Jmin'] ?? ''),
|
||||
'S1' => (string)($params['S1'] ?? ''),
|
||||
'S2' => (string)($params['S2'] ?? ''),
|
||||
'allowed_ips' => $allowedIps ?: ['0.0.0.0/0', '::/0'],
|
||||
'clientId' => $clientPubKey ?: '',
|
||||
'client_ip' => preg_replace('/\/(\d{1,2})$/', '', (string)($address ?? '')),
|
||||
'client_priv_key' => (string)($privKey ?? ''),
|
||||
'client_pub_key' => $clientPubKey ?: '',
|
||||
'config' => $conf,
|
||||
'hostName' => (string)($endpointHost ?? ''),
|
||||
'mtu' => (string)$mtu,
|
||||
'persistent_keep_alive' => (string)$keepAlive,
|
||||
'port' => $endpointPort,
|
||||
'psk_key' => (string)($psk ?? ''),
|
||||
'server_pub_key' => (string)($pubKeyServer ?? ''),
|
||||
];
|
||||
|
||||
$serverDesc = self::resolveServerDescription($endpointHost);
|
||||
|
||||
// Envelope with keys ordered like variant 1: containers first
|
||||
$envelope = [
|
||||
'containers' => [
|
||||
[
|
||||
// awg first, then container (as in the working QR)
|
||||
'awg' => [
|
||||
'H1' => (string)($params['H1'] ?? ''),
|
||||
'H2' => (string)($params['H2'] ?? ''),
|
||||
'H3' => (string)($params['H3'] ?? ''),
|
||||
'H4' => (string)($params['H4'] ?? ''),
|
||||
'Jc' => (string)($params['Jc'] ?? ''),
|
||||
'Jmax' => (string)($params['Jmax'] ?? ''),
|
||||
'Jmin' => (string)($params['Jmin'] ?? ''),
|
||||
'S1' => (string)($params['S1'] ?? ''),
|
||||
'S2' => (string)($params['S2'] ?? ''),
|
||||
'last_config' => json_encode($lastConfigObj, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT),
|
||||
'port' => (string)$endpointPort,
|
||||
'transport_proto' => 'udp',
|
||||
],
|
||||
'container' => 'amnezia-awg',
|
||||
],
|
||||
],
|
||||
'defaultContainer' => 'amnezia-awg',
|
||||
'description' => $serverDesc,
|
||||
'dns1' => $dns1,
|
||||
'dns2' => $dns2,
|
||||
'hostName' => $endpointHost,
|
||||
];
|
||||
return $envelope;
|
||||
}
|
||||
|
||||
private static function normalizeJson(string $text): string {
|
||||
$decoded = json_decode($text, true);
|
||||
if (!is_array($decoded)) throw new InvalidArgumentException('Invalid JSON');
|
||||
return json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
class Router {
|
||||
private static array $routes = [];
|
||||
|
||||
public static function add(string $method, string $pattern, callable $handler): void {
|
||||
self::$routes[] = [
|
||||
'method' => strtoupper($method),
|
||||
'pattern' => self::normalizePattern($pattern),
|
||||
'handler' => $handler,
|
||||
];
|
||||
}
|
||||
public static function get(string $pattern, callable $handler): void { self::add('GET', $pattern, $handler); }
|
||||
public static function post(string $pattern, callable $handler): void { self::add('POST', $pattern, $handler); }
|
||||
public static function delete(string $pattern, callable $handler): void { self::add('DELETE', $pattern, $handler); }
|
||||
|
||||
private static function normalizePattern(string $pattern): string {
|
||||
$pattern = '/' . trim($pattern, '/');
|
||||
$pattern = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '(?P<$1>[^/]+)', $pattern);
|
||||
return '#^' . $pattern . '$#';
|
||||
}
|
||||
|
||||
public static function dispatch(string $method, string $uri): void {
|
||||
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
|
||||
$path = '/' . trim($path, '/');
|
||||
foreach (self::$routes as $route) {
|
||||
if ($route['method'] !== strtoupper($method)) continue;
|
||||
if (preg_match($route['pattern'], $path, $matches)) {
|
||||
$params = [];
|
||||
foreach ($matches as $k => $v) { if (!is_int($k)) $params[$k] = $v; }
|
||||
call_user_func($route['handler'], $params);
|
||||
return;
|
||||
}
|
||||
}
|
||||
http_response_code(404);
|
||||
echo '404 Not Found';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
<?php
|
||||
/**
|
||||
* Translator class for multi-language support
|
||||
* Supports automatic translation using external services
|
||||
*/
|
||||
class Translator {
|
||||
private static ?string $currentLanguage = null;
|
||||
private static array $translations = [];
|
||||
private static array $supportedLanguages = [];
|
||||
|
||||
/**
|
||||
* Initialize translator
|
||||
*/
|
||||
public static function init(): void {
|
||||
// Load supported languages
|
||||
self::loadSupportedLanguages();
|
||||
|
||||
// Detect language from session, cookie, or browser
|
||||
self::detectLanguage();
|
||||
|
||||
// Load translations for current language
|
||||
self::loadTranslations(self::$currentLanguage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load supported languages from database
|
||||
*/
|
||||
private static function loadSupportedLanguages(): void {
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->query('SELECT code, name, native_name FROM languages WHERE is_active = 1');
|
||||
self::$supportedLanguages = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect user's preferred language
|
||||
*/
|
||||
private static function detectLanguage(): void {
|
||||
// 1. Check session
|
||||
if (isset($_SESSION['language'])) {
|
||||
self::$currentLanguage = $_SESSION['language'];
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Check cookie
|
||||
if (isset($_COOKIE['language'])) {
|
||||
self::$currentLanguage = $_COOKIE['language'];
|
||||
$_SESSION['language'] = self::$currentLanguage;
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Check browser language
|
||||
if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
|
||||
$browserLang = substr($_SERVER['HTTP_ACCEPT_LANGUAGE'], 0, 2);
|
||||
if (self::isSupported($browserLang)) {
|
||||
self::$currentLanguage = $browserLang;
|
||||
$_SESSION['language'] = self::$currentLanguage;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Default to English
|
||||
self::$currentLanguage = 'en';
|
||||
$_SESSION['language'] = 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if language is supported
|
||||
*/
|
||||
public static function isSupported(string $code): bool {
|
||||
foreach (self::$supportedLanguages as $lang) {
|
||||
if ($lang['code'] === $code) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load translations for specific language
|
||||
*/
|
||||
private static function loadTranslations(string $languageCode): void {
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->prepare('SELECT translation_key, translation_value FROM translations WHERE language_code = ?');
|
||||
$stmt->execute([$languageCode]);
|
||||
|
||||
$translations = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
self::$translations = $translations ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a key
|
||||
*
|
||||
* @param string $key Translation key
|
||||
* @param array $params Parameters for sprintf
|
||||
* @return string Translated text
|
||||
*/
|
||||
public static function translate(string $key, array $params = []): string {
|
||||
$translation = self::$translations[$key] ?? $key;
|
||||
|
||||
if (!empty($params)) {
|
||||
return sprintf($translation, ...$params);
|
||||
}
|
||||
|
||||
return $translation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Short alias for translate()
|
||||
*/
|
||||
public static function t(string $key, array $params = []): string {
|
||||
return self::translate($key, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current language code
|
||||
*/
|
||||
public static function getCurrentLanguage(): string {
|
||||
return self::$currentLanguage ?? 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set current language
|
||||
*/
|
||||
public static function setLanguage(string $code): bool {
|
||||
if (!self::isSupported($code)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self::$currentLanguage = $code;
|
||||
$_SESSION['language'] = $code;
|
||||
setcookie('language', $code, time() + 31536000, '/'); // 1 year
|
||||
|
||||
// Reload translations
|
||||
self::loadTranslations($code);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all supported languages
|
||||
*/
|
||||
public static function getSupportedLanguages(): array {
|
||||
return self::$supportedLanguages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-translate missing keys using AI (OpenRouter API)
|
||||
*
|
||||
* @param string $targetLang Target language code
|
||||
* @param string $key Translation key
|
||||
* @param string $sourceText Source text (English)
|
||||
* @return bool Success status
|
||||
*/
|
||||
public static function autoTranslate(string $targetLang, string $key, string $sourceText): bool {
|
||||
if ($targetLang === 'en') {
|
||||
return false; // English is source language
|
||||
}
|
||||
|
||||
try {
|
||||
// Language mapping
|
||||
$langNames = [
|
||||
'ru' => 'Russian',
|
||||
'es' => 'Spanish',
|
||||
'de' => 'German',
|
||||
'fr' => 'French',
|
||||
'zh' => 'Chinese'
|
||||
];
|
||||
|
||||
$targetLanguage = $langNames[$targetLang] ?? 'English';
|
||||
|
||||
// Use OpenRouter API with multiple free model candidates
|
||||
$translatedText = self::translateWithAI($sourceText, $targetLanguage);
|
||||
|
||||
if (!$translatedText || $translatedText === $sourceText) {
|
||||
error_log("Translation failed for '{$sourceText}' to {$targetLang}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Save to database
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO translations (language_code, translation_key, translation_value)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE translation_value = VALUES(translation_value)
|
||||
');
|
||||
|
||||
return $stmt->execute([$targetLang, $key, $translatedText]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Auto-translation error: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate text using AI with model fallback
|
||||
*/
|
||||
private static function translateWithAI(string $text, string $targetLanguage): ?string {
|
||||
// Try multiple free models for reliability
|
||||
$models = [
|
||||
'google/gemini-2.0-flash-exp:free',
|
||||
'meta-llama/llama-3.2-3b-instruct:free',
|
||||
'qwen/qwen-2-7b-instruct:free'
|
||||
];
|
||||
|
||||
foreach ($models as $model) {
|
||||
try {
|
||||
$result = self::callOpenRouter($model, $text, $targetLanguage);
|
||||
if ($result && $result !== $text) {
|
||||
return $result;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error_log("Model {$model} failed: " . $e->getMessage());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OpenRouter API key from database
|
||||
*/
|
||||
private static function getOpenRouterKey(): ?string {
|
||||
try {
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->prepare("SELECT api_key FROM api_keys WHERE service_name = 'openrouter' AND is_active = 1 LIMIT 1");
|
||||
$stmt->execute();
|
||||
return $stmt->fetchColumn() ?: null;
|
||||
} catch (Exception $e) {
|
||||
error_log('Failed to get OpenRouter API key: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call OpenRouter API
|
||||
*/
|
||||
private static function callOpenRouter(string $model, string $text, string $targetLanguage): ?string {
|
||||
$apiKey = self::getOpenRouterKey();
|
||||
|
||||
if (!$apiKey) {
|
||||
error_log('OpenRouter API key not configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
$messages = [
|
||||
[
|
||||
'role' => 'system',
|
||||
'content' => "You are a professional translator. Translate the given English text to {$targetLanguage}. Return ONLY the translation, no explanations or additional text. Keep the same tone and style. If there are parameters in curly braces like {param}, keep them unchanged."
|
||||
],
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => "Translate to {$targetLanguage}: {$text}"
|
||||
]
|
||||
];
|
||||
|
||||
$data = [
|
||||
'model' => $model,
|
||||
'messages' => $messages,
|
||||
'max_tokens' => 200,
|
||||
'temperature' => 0.1
|
||||
];
|
||||
|
||||
$ch = curl_init('https://openrouter.ai/api/v1/chat/completions');
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
'HTTP-Referer: https://amnez.ia',
|
||||
'X-Title: Amnezia VPN Panel'
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
error_log("OpenRouter API error: HTTP {$httpCode} - Model: {$model}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = json_decode($response, true);
|
||||
|
||||
if (!isset($result['choices'][0]['message']['content'])) {
|
||||
error_log("OpenRouter API error: No content in response - Model: {$model}");
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim($result['choices'][0]['message']['content']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate all missing keys for a language
|
||||
*
|
||||
* @param string $targetLang Target language code
|
||||
* @return array Statistics (total, translated, failed)
|
||||
*/
|
||||
public static function translateMissingKeys(string $targetLang): array {
|
||||
if ($targetLang === 'en') {
|
||||
return ['total' => 0, 'translated' => 0, 'failed' => 0];
|
||||
}
|
||||
|
||||
$pdo = DB::conn();
|
||||
|
||||
// Get all English keys
|
||||
$stmt = $pdo->query("SELECT translation_key, translation_value FROM translations WHERE language_code = 'en'");
|
||||
$englishKeys = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
|
||||
// Get existing translations for target language
|
||||
$stmt = $pdo->prepare("SELECT translation_key FROM translations WHERE language_code = ?");
|
||||
$stmt->execute([$targetLang]);
|
||||
$existingKeys = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
$stats = [
|
||||
'total' => count($englishKeys),
|
||||
'translated' => count($existingKeys),
|
||||
'failed' => 0
|
||||
];
|
||||
|
||||
// Find missing keys
|
||||
$missingKeys = [];
|
||||
foreach ($englishKeys as $key => $value) {
|
||||
if (!in_array($key, $existingKeys)) {
|
||||
$missingKeys[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($missingKeys)) {
|
||||
return $stats;
|
||||
}
|
||||
|
||||
// Try batch translation first
|
||||
$batchResult = self::translateBatch($missingKeys, $targetLang);
|
||||
|
||||
if ($batchResult) {
|
||||
foreach ($batchResult as $key => $translatedText) {
|
||||
if (isset($missingKeys[$key]) && $translatedText) {
|
||||
self::setTranslation($targetLang, $key, $translatedText);
|
||||
$stats['translated']++;
|
||||
}
|
||||
}
|
||||
return $stats;
|
||||
}
|
||||
|
||||
// Fallback to individual translation
|
||||
foreach ($missingKeys as $key => $value) {
|
||||
if (self::autoTranslate($targetLang, $key, $value)) {
|
||||
$stats['translated']++;
|
||||
usleep(500000); // 500ms delay between requests
|
||||
} else {
|
||||
$stats['failed']++;
|
||||
}
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch translate multiple texts at once (more efficient)
|
||||
*/
|
||||
private static function translateBatch(array $texts, string $targetLang): ?array {
|
||||
if (empty($texts) || !is_array($texts)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$langNames = [
|
||||
'ru' => 'Russian',
|
||||
'es' => 'Spanish',
|
||||
'de' => 'German',
|
||||
'fr' => 'French',
|
||||
'zh' => 'Chinese'
|
||||
];
|
||||
|
||||
$targetLanguage = $langNames[$targetLang] ?? 'English';
|
||||
|
||||
// Prepare texts for JSON
|
||||
$textsForJson = [];
|
||||
foreach ($texts as $key => $text) {
|
||||
$textsForJson[] = [
|
||||
'key' => $key,
|
||||
'text' => $text
|
||||
];
|
||||
}
|
||||
|
||||
$jsonTexts = json_encode($textsForJson, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$models = [
|
||||
'google/gemini-2.0-flash-exp:free',
|
||||
'meta-llama/llama-3.2-3b-instruct:free'
|
||||
];
|
||||
|
||||
foreach ($models as $model) {
|
||||
try {
|
||||
$result = self::callOpenRouterBatch($model, $jsonTexts, $targetLanguage);
|
||||
|
||||
if ($result && is_array($result)) {
|
||||
// Validate results
|
||||
$translations = [];
|
||||
foreach ($result as $item) {
|
||||
if (isset($item['key']) && isset($item['text']) && isset($texts[$item['key']])) {
|
||||
$translations[$item['key']] = $item['text'];
|
||||
}
|
||||
}
|
||||
|
||||
if (count($translations) > 0) {
|
||||
error_log("Batch translation successful: " . count($translations) . " texts to {$targetLang}");
|
||||
return $translations;
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error_log("Batch translation with {$model} failed: " . $e->getMessage());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (Exception $e) {
|
||||
error_log('Batch translation error: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call OpenRouter API for batch translation
|
||||
*/
|
||||
private static function callOpenRouterBatch(string $model, string $jsonTexts, string $targetLanguage): ?array {
|
||||
$apiKey = self::getOpenRouterKey();
|
||||
|
||||
if (!$apiKey) {
|
||||
error_log('OpenRouter API key not configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
$messages = [
|
||||
[
|
||||
'role' => 'system',
|
||||
'content' => "You are a professional translator. Translate the given English texts to {$targetLanguage}. Return ONLY a JSON array with objects containing 'key' and 'text' fields. Each 'text' should contain only the translated text. Keep the same tone and style. If there are parameters in curly braces like {param}, keep them unchanged. Do not add any explanations or additional text outside the JSON."
|
||||
],
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => "Translate these English texts to {$targetLanguage}:\n{$jsonTexts}"
|
||||
]
|
||||
];
|
||||
|
||||
$data = [
|
||||
'model' => $model,
|
||||
'messages' => $messages,
|
||||
'max_tokens' => 4000,
|
||||
'temperature' => 0.1
|
||||
];
|
||||
|
||||
$ch = curl_init('https://openrouter.ai/api/v1/chat/completions');
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
'HTTP-Referer: https://amnez.ia',
|
||||
'X-Title: Amnezia VPN Panel'
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
error_log("OpenRouter batch API error: HTTP {$httpCode}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = json_decode($response, true);
|
||||
|
||||
if (!isset($result['choices'][0]['message']['content'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$responseText = trim($result['choices'][0]['message']['content']);
|
||||
|
||||
// Remove markdown code blocks if present
|
||||
if (strpos($responseText, '```json') !== false) {
|
||||
$responseText = preg_replace('/```json\s*/', '', $responseText);
|
||||
$responseText = preg_replace('/\s*```/', '', $responseText);
|
||||
$responseText = trim($responseText);
|
||||
}
|
||||
|
||||
$translatedJson = json_decode($responseText, true);
|
||||
|
||||
if (!is_array($translatedJson)) {
|
||||
error_log("Batch translation: Invalid JSON response");
|
||||
return null;
|
||||
}
|
||||
|
||||
return $translatedJson;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translation statistics
|
||||
*/
|
||||
public static function getStatistics(): array {
|
||||
$pdo = DB::conn();
|
||||
|
||||
$stmt = $pdo->query("
|
||||
SELECT
|
||||
l.code,
|
||||
l.name,
|
||||
l.native_name,
|
||||
COUNT(t.id) as translated_count,
|
||||
(SELECT COUNT(*) FROM translations WHERE language_code = 'en') as total_count
|
||||
FROM languages l
|
||||
LEFT JOIN translations t ON l.code = t.language_code
|
||||
WHERE l.is_active = 1
|
||||
GROUP BY l.code, l.name, l.native_name
|
||||
");
|
||||
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update translation
|
||||
*/
|
||||
public static function setTranslation(string $languageCode, string $key, string $value): bool {
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO translations (language_code, translation_key, translation_value)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE translation_value = VALUES(translation_value)
|
||||
');
|
||||
|
||||
return $stmt->execute([$languageCode, $key, $value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export translations to JSON file
|
||||
*/
|
||||
public static function exportToJson(string $languageCode): string {
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->prepare('SELECT translation_key, translation_value FROM translations WHERE language_code = ?');
|
||||
$stmt->execute([$languageCode]);
|
||||
|
||||
$translations = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
|
||||
return json_encode($translations, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import translations from JSON file
|
||||
*/
|
||||
public static function importFromJson(string $languageCode, string $json): bool {
|
||||
$translations = json_decode($json, true);
|
||||
|
||||
if (!is_array($translations)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$pdo = DB::conn();
|
||||
$pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
foreach ($translations as $key => $value) {
|
||||
self::setTranslation($languageCode, $key, $value);
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
$pdo->rollBack();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save API key for translation service
|
||||
*/
|
||||
public static function saveApiKey(string $serviceName, string $apiKey): bool {
|
||||
try {
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO api_keys (service_name, api_key, is_active)
|
||||
VALUES (?, ?, 1)
|
||||
ON DUPLICATE KEY UPDATE api_key = VALUES(api_key), updated_at = NOW()
|
||||
');
|
||||
return $stmt->execute([$serviceName, $apiKey]);
|
||||
} catch (Exception $e) {
|
||||
error_log('Failed to save API key: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key for service
|
||||
*/
|
||||
public static function getApiKey(string $serviceName): ?string {
|
||||
try {
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->prepare("SELECT api_key FROM api_keys WHERE service_name = ? AND is_active = 1 LIMIT 1");
|
||||
$stmt->execute([$serviceName]);
|
||||
return $stmt->fetchColumn() ?: null;
|
||||
} catch (Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
use Twig\Environment;
|
||||
use Twig\Loader\FilesystemLoader;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
class View {
|
||||
private static ?Environment $twig = null;
|
||||
|
||||
public static function init(string $templatesPath, array $globals = []): void {
|
||||
if (!class_exists(Environment::class)) {
|
||||
throw new RuntimeException('Twig is not installed. Run composer require twig/twig');
|
||||
}
|
||||
$loader = new FilesystemLoader($templatesPath);
|
||||
self::$twig = new Environment($loader, [
|
||||
'cache' => false,
|
||||
'autoescape' => 'html',
|
||||
]);
|
||||
|
||||
// Add translation function
|
||||
$tFunc = new TwigFunction('t', function (string $key, array $params = []) {
|
||||
return Translator::t($key, $params);
|
||||
});
|
||||
self::$twig->addFunction($tFunc);
|
||||
|
||||
// Add flag emoji function
|
||||
$flagFunc = new TwigFunction('getFlag', function (string $langCode) {
|
||||
$flags = [
|
||||
'en' => '🇬🇧',
|
||||
'ru' => '🇷🇺',
|
||||
'es' => '🇪🇸',
|
||||
'de' => '🇩🇪',
|
||||
'fr' => '🇫🇷',
|
||||
'zh' => '🇨🇳',
|
||||
];
|
||||
return $flags[$langCode] ?? '🌐';
|
||||
});
|
||||
self::$twig->addFunction($flagFunc);
|
||||
|
||||
// Add globals
|
||||
foreach ($globals as $k => $v) self::$twig->addGlobal($k, $v);
|
||||
}
|
||||
|
||||
public static function render(string $template, array $vars = []): void {
|
||||
if (!self::$twig) throw new RuntimeException('Twig is not initialized');
|
||||
echo self::$twig->render($template, $vars);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
<?php
|
||||
/**
|
||||
* VPN Client Management Class
|
||||
* Handles creation and management of VPN client configurations
|
||||
* Based on amnezia_client_config_v2.php
|
||||
*/
|
||||
class VpnClient {
|
||||
private $clientId;
|
||||
private $data;
|
||||
|
||||
public function __construct(?int $clientId = null) {
|
||||
$this->clientId = $clientId;
|
||||
if ($clientId) {
|
||||
$this->load();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load client data from database
|
||||
*/
|
||||
private function load(): void {
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->prepare('SELECT * FROM vpn_clients WHERE id = ?');
|
||||
$stmt->execute([$this->clientId]);
|
||||
$this->data = $stmt->fetch();
|
||||
if (!$this->data) {
|
||||
throw new Exception('Client not found');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new VPN client
|
||||
*/
|
||||
public static function create(int $serverId, int $userId, string $name): int {
|
||||
$pdo = DB::conn();
|
||||
|
||||
// Get server data
|
||||
$server = new VpnServer($serverId);
|
||||
$serverData = $server->getData();
|
||||
|
||||
if (!$serverData || $serverData['status'] !== 'active') {
|
||||
throw new Exception('Server is not active');
|
||||
}
|
||||
|
||||
// Generate client keys
|
||||
$containerName = $serverData['container_name'];
|
||||
$keys = self::generateClientKeys($serverData, $name);
|
||||
|
||||
// Get next available IP
|
||||
$clientIP = self::getNextClientIP($serverData);
|
||||
|
||||
// Get AWG parameters from server
|
||||
$awgParams = json_decode($serverData['awg_params'], true);
|
||||
|
||||
// Build client configuration
|
||||
$config = self::buildClientConfig(
|
||||
$keys['private'],
|
||||
$clientIP,
|
||||
$serverData['server_public_key'],
|
||||
$serverData['preshared_key'],
|
||||
$serverData['host'],
|
||||
$serverData['vpn_port'],
|
||||
$awgParams
|
||||
);
|
||||
|
||||
// Add client to server
|
||||
self::addClientToServer($serverData, $keys['public'], $clientIP);
|
||||
|
||||
// Generate QR code
|
||||
$qrCode = self::generateQRCode($config);
|
||||
|
||||
// Insert into database
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO vpn_clients
|
||||
(server_id, user_id, name, client_ip, public_key, private_key, preshared_key, config, qr_code, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
$serverId,
|
||||
$userId,
|
||||
$name,
|
||||
$clientIP,
|
||||
$keys['public'],
|
||||
$keys['private'],
|
||||
$serverData['preshared_key'],
|
||||
$config,
|
||||
$qrCode,
|
||||
'active'
|
||||
]);
|
||||
|
||||
return (int)$pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate client keys on remote server
|
||||
*/
|
||||
private static function generateClientKeys(array $serverData, string $clientName): array {
|
||||
$containerName = $serverData['container_name'];
|
||||
|
||||
$cmd = sprintf(
|
||||
"docker exec -i %s sh -c \"umask 077; wg genkey | tee /tmp/%s_priv.key | wg pubkey > /tmp/%s_pub.key; cat /tmp/%s_priv.key; echo '---'; cat /tmp/%s_pub.key; rm -f /tmp/%s_priv.key /tmp/%s_pub.key\"",
|
||||
$containerName,
|
||||
$clientName, $clientName, $clientName, $clientName, $clientName, $clientName
|
||||
);
|
||||
|
||||
$escaped = escapeshellarg($cmd);
|
||||
$sshCmd = 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",
|
||||
$serverData['password'],
|
||||
$serverData['username'],
|
||||
$serverData['host'],
|
||||
$escaped
|
||||
);
|
||||
|
||||
$out = shell_exec($sshCmd);
|
||||
$parts = explode("---", trim($out));
|
||||
|
||||
if (count($parts) < 2) {
|
||||
throw new Exception("Failed to generate client keys");
|
||||
}
|
||||
|
||||
return [
|
||||
'private' => trim($parts[0]),
|
||||
'public' => trim($parts[1])
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next available client IP
|
||||
*/
|
||||
private static function getNextClientIP(array $serverData): string {
|
||||
$pdo = DB::conn();
|
||||
|
||||
// Get used IPs from database
|
||||
$stmt = $pdo->prepare('SELECT client_ip FROM vpn_clients WHERE server_id = ? AND status = ?');
|
||||
$stmt->execute([$serverData['id'], 'active']);
|
||||
$usedIPs = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
// Parse subnet
|
||||
$parts = explode('/', $serverData['vpn_subnet']);
|
||||
$networkLong = ip2long($parts[0]);
|
||||
|
||||
// Reserve network address
|
||||
$used = ['10.8.1.0' => true];
|
||||
foreach ($usedIPs as $ip) {
|
||||
$used[$ip] = true;
|
||||
}
|
||||
|
||||
// Find next free IP starting from .1
|
||||
for ($i = 1; $i <= 253; $i++) {
|
||||
$candidate = long2ip($networkLong + $i);
|
||||
if (!isset($used[$candidate])) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Exception('No free IP addresses in subnet');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build client configuration file
|
||||
*/
|
||||
private static function buildClientConfig(
|
||||
string $privateKey,
|
||||
string $clientIP,
|
||||
string $serverPublicKey,
|
||||
string $presharedKey,
|
||||
string $serverHost,
|
||||
int $serverPort,
|
||||
array $awgParams
|
||||
): string {
|
||||
$config = "[Interface]\n";
|
||||
$config .= "PrivateKey = {$privateKey}\n";
|
||||
$config .= "Address = {$clientIP}/32\n";
|
||||
$config .= "DNS = 1.1.1.1, 1.0.0.1\n";
|
||||
|
||||
// Add AWG parameters
|
||||
foreach (['Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4'] as $key) {
|
||||
if (isset($awgParams[$key])) {
|
||||
$config .= "{$key} = {$awgParams[$key]}\n";
|
||||
}
|
||||
}
|
||||
|
||||
$config .= "\n[Peer]\n";
|
||||
$config .= "PublicKey = {$serverPublicKey}\n";
|
||||
$config .= "PresharedKey = {$presharedKey}\n";
|
||||
$config .= "Endpoint = {$serverHost}:{$serverPort}\n";
|
||||
$config .= "AllowedIPs = 0.0.0.0/0, ::/0\n";
|
||||
$config .= "PersistentKeepalive = 25\n";
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add client to server using official method (append + wg syncconf)
|
||||
*/
|
||||
private static function addClientToServer(array $serverData, string $publicKey, string $clientIP): void {
|
||||
$containerName = $serverData['container_name'];
|
||||
|
||||
// Build peer block
|
||||
$peerBlock = "\n[Peer]\n";
|
||||
$peerBlock .= "PublicKey = {$publicKey}\n";
|
||||
$peerBlock .= "PresharedKey = {$serverData['preshared_key']}\n";
|
||||
$peerBlock .= "AllowedIPs = {$clientIP}/32\n";
|
||||
|
||||
$escaped = addslashes($peerBlock);
|
||||
$tempFile = '/tmp/' . bin2hex(random_bytes(8)) . '.tmp';
|
||||
|
||||
// Create temp file
|
||||
$cmd1 = sprintf("docker exec -i %s sh -c 'echo \"%s\" > %s'", $containerName, $escaped, $tempFile);
|
||||
self::executeServerCommand($serverData, $cmd1, true);
|
||||
|
||||
// Append to wg0.conf
|
||||
$cmd2 = sprintf("docker exec -i %s sh -c 'cat %s >> /opt/amnezia/awg/wg0.conf'", $containerName, $tempFile);
|
||||
self::executeServerCommand($serverData, $cmd2, true);
|
||||
|
||||
// Apply via wg syncconf
|
||||
$cmd3 = sprintf("docker exec -i %s bash -c 'wg syncconf wg0 <(wg-quick strip /opt/amnezia/awg/wg0.conf)'", $containerName);
|
||||
self::executeServerCommand($serverData, $cmd3, true);
|
||||
|
||||
// Remove temp file
|
||||
$cmd4 = sprintf("docker exec -i %s rm -f %s", $containerName, $tempFile);
|
||||
self::executeServerCommand($serverData, $cmd4, true);
|
||||
|
||||
// Update clientsTable
|
||||
self::updateClientsTable($serverData, $publicKey, $clientIP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update clientsTable on server
|
||||
*/
|
||||
private static function updateClientsTable(array $serverData, string $publicKey, string $name): void {
|
||||
$containerName = $serverData['container_name'];
|
||||
|
||||
// Read current table
|
||||
$cmd = sprintf("docker exec -i %s cat /opt/amnezia/awg/clientsTable 2>/dev/null", $containerName);
|
||||
$tableJson = self::executeServerCommand($serverData, $cmd, true);
|
||||
$table = json_decode(trim($tableJson), true);
|
||||
|
||||
if (!is_array($table)) {
|
||||
$table = [];
|
||||
}
|
||||
|
||||
// Add new client
|
||||
$table[] = [
|
||||
'clientId' => $publicKey,
|
||||
'userData' => [
|
||||
'clientName' => $name,
|
||||
'creationDate' => date('D M j H:i:s Y')
|
||||
]
|
||||
];
|
||||
|
||||
// Save back
|
||||
$newTableJson = json_encode($table, JSON_PRETTY_PRINT);
|
||||
$escaped = addslashes($newTableJson);
|
||||
$updateCmd = sprintf("docker exec -i %s sh -c 'echo \"%s\" > /opt/amnezia/awg/clientsTable'", $containerName, $escaped);
|
||||
self::executeServerCommand($serverData, $updateCmd, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute command on server
|
||||
*/
|
||||
private static function executeServerCommand(array $serverData, string $command, bool $sudo = false): string {
|
||||
if ($sudo && strtolower($serverData['username']) !== 'root') {
|
||||
$command = "echo '{$serverData['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",
|
||||
$serverData['password'],
|
||||
$serverData['username'],
|
||||
$serverData['host'],
|
||||
$escapedCommand
|
||||
);
|
||||
|
||||
return shell_exec($sshCommand) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code for configuration using Amnezia format
|
||||
* Uses working QrUtil from /Users/oleg/Documents/amnezia
|
||||
*/
|
||||
private static function generateQRCode(string $config): string {
|
||||
require_once __DIR__ . '/QrUtil.php';
|
||||
|
||||
try {
|
||||
// Use old Amnezia format with Qt/QDataStream encoding
|
||||
$payloadOld = QrUtil::encodeOldPayloadFromConf($config);
|
||||
$dataUri = QrUtil::pngBase64($payloadOld);
|
||||
return $dataUri;
|
||||
} catch (Throwable $e) {
|
||||
error_log('Failed to generate QR code: ' . $e->getMessage());
|
||||
return ''; // QR code generation failed, but continue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all clients for a server
|
||||
*/
|
||||
public static function listByServer(int $serverId): array {
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->prepare('SELECT * FROM vpn_clients WHERE server_id = ? ORDER BY created_at DESC');
|
||||
$stmt->execute([$serverId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all clients for a user
|
||||
*/
|
||||
public static function listByUser(int $userId): array {
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT c.*, s.name as server_name, s.host as server_host
|
||||
FROM vpn_clients c
|
||||
LEFT JOIN vpn_servers s ON c.server_id = s.id
|
||||
WHERE c.user_id = ?
|
||||
ORDER BY c.created_at DESC
|
||||
');
|
||||
$stmt->execute([$userId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke client access (disable without deleting)
|
||||
*/
|
||||
public function revoke(): bool {
|
||||
if (!$this->data) {
|
||||
throw new Exception('Client not loaded');
|
||||
}
|
||||
|
||||
// Remove from server
|
||||
$server = new VpnServer($this->data['server_id']);
|
||||
$serverData = $server->getData();
|
||||
|
||||
if ($serverData && $serverData['status'] === 'active') {
|
||||
try {
|
||||
self::removeClientFromServer($serverData, $this->data['public_key']);
|
||||
} catch (Exception $e) {
|
||||
error_log('Failed to remove client from server: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as disabled in database
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->prepare('UPDATE vpn_clients SET status = ? WHERE id = ?');
|
||||
return $stmt->execute(['disabled', $this->clientId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore client access
|
||||
*/
|
||||
public function restore(): bool {
|
||||
if (!$this->data) {
|
||||
throw new Exception('Client not loaded');
|
||||
}
|
||||
|
||||
// Re-add to server
|
||||
$server = new VpnServer($this->data['server_id']);
|
||||
$serverData = $server->getData();
|
||||
|
||||
if ($serverData && $serverData['status'] === 'active') {
|
||||
try {
|
||||
self::addClientToServer($serverData, $this->data['public_key'], $this->data['client_ip']);
|
||||
} catch (Exception $e) {
|
||||
throw new Exception('Failed to restore client on server: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as active in database
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->prepare('UPDATE vpn_clients SET status = ? WHERE id = ?');
|
||||
return $stmt->execute(['active', $this->clientId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete client permanently
|
||||
*/
|
||||
public function delete(): bool {
|
||||
if (!$this->data) {
|
||||
throw new Exception('Client not loaded');
|
||||
}
|
||||
|
||||
// First revoke to remove from server
|
||||
if ($this->data['status'] === 'active') {
|
||||
$this->revoke();
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->prepare('DELETE FROM vpn_clients WHERE id = ?');
|
||||
return $stmt->execute([$this->clientId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove client from server WireGuard configuration
|
||||
*/
|
||||
private static function removeClientFromServer(array $serverData, string $publicKey): void {
|
||||
$containerName = $serverData['container_name'];
|
||||
|
||||
// First, remove using wg command (live removal)
|
||||
$removeCmd = sprintf(
|
||||
"docker exec -i %s wg set wg0 peer %s remove",
|
||||
$containerName,
|
||||
escapeshellarg($publicKey)
|
||||
);
|
||||
|
||||
self::executeServerCommand($serverData, $removeCmd, true);
|
||||
|
||||
// Then remove from wg0.conf file to make it persistent
|
||||
// Use a more reliable method: read, filter, write
|
||||
$readCmd = sprintf("docker exec -i %s cat /opt/amnezia/awg/wg0.conf", $containerName);
|
||||
$config = self::executeServerCommand($serverData, $readCmd, true);
|
||||
|
||||
// Parse and remove the peer section
|
||||
$newConfig = self::removePeerFromConfig($config, $publicKey);
|
||||
|
||||
// Write back to file
|
||||
$escapedConfig = str_replace("'", "'\\''", $newConfig);
|
||||
$writeCmd = sprintf(
|
||||
"docker exec -i %s sh -c 'echo '\''%s'\'' > /opt/amnezia/awg/wg0.conf'",
|
||||
$containerName,
|
||||
$escapedConfig
|
||||
);
|
||||
|
||||
self::executeServerCommand($serverData, $writeCmd, true);
|
||||
|
||||
// Save config
|
||||
$saveCmd = sprintf("docker exec -i %s wg-quick save wg0", $containerName);
|
||||
self::executeServerCommand($serverData, $saveCmd, true);
|
||||
|
||||
// Remove from clientsTable
|
||||
self::removeFromClientsTable($serverData, $publicKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove peer section from WireGuard config
|
||||
*/
|
||||
private static function removePeerFromConfig(string $config, string $publicKey): string {
|
||||
$lines = explode("\n", $config);
|
||||
$newLines = [];
|
||||
$inPeerBlock = false;
|
||||
$skipBlock = false;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$trimmed = trim($line);
|
||||
|
||||
// Start of new section
|
||||
if (strpos($trimmed, '[') === 0) {
|
||||
$inPeerBlock = ($trimmed === '[Peer]');
|
||||
$skipBlock = false;
|
||||
}
|
||||
|
||||
// Check if this peer block should be skipped
|
||||
if ($inPeerBlock && strpos($trimmed, 'PublicKey') === 0) {
|
||||
$parts = explode('=', $line, 2);
|
||||
if (count($parts) === 2 && trim($parts[1]) === $publicKey) {
|
||||
$skipBlock = true;
|
||||
// Remove the [Peer] line that was already added
|
||||
array_pop($newLines);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip lines in the block to be removed
|
||||
if ($skipBlock && $inPeerBlock) {
|
||||
// Empty line ends the peer block
|
||||
if (empty($trimmed)) {
|
||||
$skipBlock = false;
|
||||
$inPeerBlock = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$newLines[] = $line;
|
||||
}
|
||||
|
||||
return implode("\n", $newLines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove client from clientsTable
|
||||
*/
|
||||
private static function removeFromClientsTable(array $serverData, string $publicKey): void {
|
||||
$containerName = $serverData['container_name'];
|
||||
|
||||
// Read current table
|
||||
$cmd = sprintf("docker exec -i %s cat /opt/amnezia/awg/clientsTable 2>/dev/null", $containerName);
|
||||
$tableJson = self::executeServerCommand($serverData, $cmd, true);
|
||||
$table = json_decode(trim($tableJson), true);
|
||||
|
||||
if (!is_array($table)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out the client
|
||||
$table = array_filter($table, function($client) use ($publicKey) {
|
||||
return ($client['clientId'] ?? '') !== $publicKey;
|
||||
});
|
||||
|
||||
// Re-index array
|
||||
$table = array_values($table);
|
||||
|
||||
// Save back
|
||||
$newTableJson = json_encode($table, JSON_PRETTY_PRINT);
|
||||
$escaped = addslashes($newTableJson);
|
||||
$updateCmd = sprintf("docker exec -i %s sh -c 'echo \"%s\" > /opt/amnezia/awg/clientsTable'", $containerName, $escaped);
|
||||
self::executeServerCommand($serverData, $updateCmd, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client data
|
||||
*/
|
||||
public function getData(): ?array {
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration file content
|
||||
*/
|
||||
public function getConfig(): string {
|
||||
return $this->data['config'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get QR code
|
||||
*/
|
||||
public function getQRCode(): string {
|
||||
return $this->data['qr_code'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync traffic statistics from server
|
||||
*/
|
||||
public function syncStats(): bool {
|
||||
if (!$this->data) {
|
||||
throw new Exception('Client not loaded');
|
||||
}
|
||||
|
||||
$server = new VpnServer($this->data['server_id']);
|
||||
$serverData = $server->getData();
|
||||
|
||||
if (!$serverData || $serverData['status'] !== 'active') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$stats = self::getClientStatsFromServer($serverData, $this->data['public_key']);
|
||||
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE vpn_clients
|
||||
SET bytes_sent = ?, bytes_received = ?, last_handshake = ?, last_sync_at = NOW()
|
||||
WHERE id = ?
|
||||
');
|
||||
|
||||
$lastHandshake = $stats['last_handshake'] > 0
|
||||
? date('Y-m-d H:i:s', $stats['last_handshake'])
|
||||
: null;
|
||||
|
||||
return $stmt->execute([
|
||||
$stats['bytes_sent'],
|
||||
$stats['bytes_received'],
|
||||
$lastHandshake,
|
||||
$this->clientId
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
error_log('Failed to sync client stats: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client statistics from server
|
||||
*/
|
||||
private static function getClientStatsFromServer(array $serverData, string $publicKey): array {
|
||||
$containerName = $serverData['container_name'];
|
||||
|
||||
// Get WireGuard interface stats
|
||||
$cmd = sprintf("docker exec -i %s wg show wg0 dump", $containerName);
|
||||
$output = self::executeServerCommand($serverData, $cmd, true);
|
||||
|
||||
$stats = [
|
||||
'bytes_sent' => 0,
|
||||
'bytes_received' => 0,
|
||||
'last_handshake' => 0
|
||||
];
|
||||
|
||||
// Parse wg dump output
|
||||
// Format: public_key preshared_key endpoint allowed_ips latest_handshake transfer_rx transfer_tx persistent_keepalive
|
||||
// First line is server (private key), skip it
|
||||
// For clients: transfer_rx = bytes received by server (sent by client)
|
||||
// transfer_tx = bytes sent by server (received by client)
|
||||
$lines = explode("\n", trim($output));
|
||||
foreach ($lines as $line) {
|
||||
if (empty($line)) continue;
|
||||
|
||||
$parts = preg_split('/\s+/', trim($line));
|
||||
|
||||
// Skip first line (server) - it has different format
|
||||
if (count($parts) < 7) continue;
|
||||
|
||||
// Match by public key
|
||||
if ($parts[0] === $publicKey) {
|
||||
$stats['last_handshake'] = (int)$parts[4];
|
||||
$stats['bytes_sent'] = (int)$parts[5]; // transfer_rx - client sent
|
||||
$stats['bytes_received'] = (int)$parts[6]; // transfer_tx - client received
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync stats for all active clients on a server
|
||||
*/
|
||||
public static function syncAllStatsForServer(int $serverId): int {
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->prepare('SELECT id FROM vpn_clients WHERE server_id = ? AND status = ?');
|
||||
$stmt->execute([$serverId, 'active']);
|
||||
$clientIds = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
$synced = 0;
|
||||
foreach ($clientIds as $clientId) {
|
||||
try {
|
||||
$client = new VpnClient($clientId);
|
||||
if ($client->syncStats()) {
|
||||
$synced++;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error_log('Failed to sync stats for client ' . $clientId . ': ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $synced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable traffic statistics
|
||||
*/
|
||||
public function getFormattedStats(): array {
|
||||
if (!$this->data) {
|
||||
return ['sent' => 'N/A', 'received' => 'N/A', 'total' => 'N/A', 'last_seen' => 'Never'];
|
||||
}
|
||||
|
||||
$sent = $this->formatBytes($this->data['bytes_sent'] ?? 0);
|
||||
$received = $this->formatBytes($this->data['bytes_received'] ?? 0);
|
||||
$total = $this->formatBytes(($this->data['bytes_sent'] ?? 0) + ($this->data['bytes_received'] ?? 0));
|
||||
|
||||
$lastSeen = 'Never';
|
||||
if (!empty($this->data['last_handshake'])) {
|
||||
$lastHandshake = strtotime($this->data['last_handshake']);
|
||||
$diff = time() - $lastHandshake;
|
||||
|
||||
if ($diff < 300) {
|
||||
$lastSeen = 'Online';
|
||||
} elseif ($diff < 3600) {
|
||||
$lastSeen = floor($diff / 60) . ' minutes ago';
|
||||
} elseif ($diff < 86400) {
|
||||
$lastSeen = floor($diff / 3600) . ' hours ago';
|
||||
} else {
|
||||
$lastSeen = floor($diff / 86400) . ' days ago';
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'sent' => $sent,
|
||||
'received' => $received,
|
||||
'total' => $total,
|
||||
'last_seen' => $lastSeen,
|
||||
'is_online' => !empty($this->data['last_handshake']) && (time() - strtotime($this->data['last_handshake'])) < 300
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human-readable string
|
||||
*/
|
||||
private function formatBytes(int $bytes): string {
|
||||
if ($bytes === 0) return '0 B';
|
||||
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$i = floor(log($bytes) / log(1024));
|
||||
|
||||
return round($bytes / pow(1024, $i), 2) . ' ' . $units[$i];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user