diff --git a/panel/api/endpoints/proxy.php b/panel/api/endpoints/proxy.php index c5adc03..45f7dd6 100644 --- a/panel/api/endpoints/proxy.php +++ b/panel/api/endpoints/proxy.php @@ -3,7 +3,7 @@ * Proxy endpoint — manage Nginx reverse proxy * Routes: * GET /api/proxy/status — nginx status - * POST /api/proxy/install — install nginx + * POST /api/proxy/install — install nginx (local only) * POST /api/proxy/control — {action: start|stop|restart|reload} * GET /api/proxy/hosts — list proxy hosts * POST /api/proxy/hosts — add proxy host @@ -13,6 +13,9 @@ * POST /api/proxy/sync — sync hosts from accounts * POST /api/proxy/write-configs — regenerate all nginx configs * GET /api/proxy/setup-script — return bash install script + * GET /api/proxy/settings — get proxy settings (mode, remote host, etc.) + * POST /api/proxy/settings — save proxy settings + * POST /api/proxy/test-remote — test SSH connectivity to remote proxy VM */ Auth::getInstance()->require('admin'); @@ -99,6 +102,45 @@ try { exit; })(), + // GET settings — return current proxy configuration + $action === 'settings' && $method === 'GET' => (function() { + $db = DB::getInstance(); + $get = fn(string $k, string $d = '') => $db->fetchOne("SELECT value FROM settings WHERE `key`=?", [$k])['value'] ?? $d; + Response::json(['success' => true, 'data' => [ + 'mode' => $get('proxy_mode', 'disabled'), + 'remote_host' => $get('proxy_remote_host'), + 'remote_user' => $get('proxy_remote_user', 'root'), + 'remote_pass' => $get('proxy_remote_pass') ? '••••••••' : '', + 'backend_ip' => $get('proxy_backend_ip'), + ]]); + })(), + + // POST settings — save proxy configuration + $action === 'settings' && $method === 'POST' => (function() use ($body) { + $db = DB::getInstance(); + $allowed = ['proxy_mode', 'proxy_remote_host', 'proxy_remote_user', 'proxy_remote_pass', 'proxy_backend_ip']; + $map = [ + 'mode' => 'proxy_mode', + 'remote_host' => 'proxy_remote_host', + 'remote_user' => 'proxy_remote_user', + 'remote_pass' => 'proxy_remote_pass', + 'backend_ip' => 'proxy_backend_ip', + ]; + foreach ($map as $field => $key) { + if (!array_key_exists($field, $body)) continue; + if ($field === 'remote_pass' && $body[$field] === '••••••••') continue; // unchanged placeholder + $db->execute( + "INSERT INTO settings (`key`, value) VALUES (?,?) ON DUPLICATE KEY UPDATE value=VALUES(value)", + [$key, $body[$field]] + ); + } + Response::json(['success' => true, 'message' => 'Proxy settings saved']); + })(), + + // POST test-remote — verify SSH connection to remote proxy VM + $action === 'test-remote' && $method === 'POST' => + Response::json(['success' => true, 'data' => ProxyManager::testRemote()]), + default => Response::error('Not found', 404), }; diff --git a/panel/lib/ProxyManager.php b/panel/lib/ProxyManager.php index 5fa7bc2..4072d2e 100644 --- a/panel/lib/ProxyManager.php +++ b/panel/lib/ProxyManager.php @@ -2,6 +2,13 @@ /** * ProxyManager — manages Nginx reverse proxy for NovaCPX hosted accounts. * Supports local nginx (on same VM) or remote nginx (separate proxy VM via SSH). + * + * Settings keys: + * proxy_mode — 'disabled' | 'local' | 'remote' + * proxy_remote_host — IP/hostname of remote nginx VM + * proxy_remote_user — SSH user (default: root) + * proxy_remote_pass — SSH password + * proxy_backend_ip — IP of NovaCPX Apache server (used when syncing proxy hosts) */ class ProxyManager { @@ -9,60 +16,107 @@ class ProxyManager { private static string $enabledDir = '/etc/nginx/sites-enabled'; private static string $confPrefix = 'novacpx-proxy-'; + // --- Remote helpers --- + + private static function isRemote(): bool { + $db = DB::getInstance(); + return ($db->fetchOne("SELECT value FROM settings WHERE `key`='proxy_mode'")['value'] ?? '') === 'remote'; + } + + private static function getRemote(): array { + $db = DB::getInstance(); + $get = fn(string $k, string $d = '') => $db->fetchOne("SELECT value FROM settings WHERE `key`=?", [$k])['value'] ?? $d; + return [ + 'host' => $get('proxy_remote_host'), + 'user' => $get('proxy_remote_user', 'root'), + 'pass' => $get('proxy_remote_pass'), + ]; + } + + private static function remoteExec(string $cmd): string { + $r = self::getRemote(); + if (!$r['host']) return 'no remote host configured'; + return shell_exec( + 'sshpass -p ' . escapeshellarg($r['pass']) . + ' ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 ' . + escapeshellarg($r['user'] . '@' . $r['host']) . ' ' . + escapeshellarg($cmd) . ' 2>&1' + ) ?? ''; + } + + private static function remotePush(string $content, string $remotePath): void { + $encoded = base64_encode($content); + self::remoteExec('echo ' . escapeshellarg($encoded) . ' | base64 -d > ' . escapeshellarg($remotePath)); + } + // --- Status & Control --- public static function isInstalled(): bool { + if (self::isRemote()) { + return trim(self::remoteExec('which nginx')) !== ''; + } return file_exists('/usr/sbin/nginx') || !empty(shell_exec('which nginx 2>/dev/null')); } public static function isRunning(): bool { - $out = shell_exec('systemctl is-active nginx 2>/dev/null'); - return trim($out ?? '') === 'active'; + if (self::isRemote()) { + return trim(self::remoteExec('systemctl is-active nginx')) === 'active'; + } + return trim(shell_exec('systemctl is-active nginx 2>/dev/null') ?? '') === 'active'; } public static function status(): array { + $db = DB::getInstance(); + $get = fn(string $k, string $d = '') => $db->fetchOne("SELECT value FROM settings WHERE `key`=?", [$k])['value'] ?? $d; + $mode = $get('proxy_mode', 'disabled'); + $remote = self::isRemote(); + $installed = self::isInstalled(); $running = $installed && self::isRunning(); - $version = $installed ? trim(shell_exec('nginx -v 2>&1') ?: '') : ''; - $db = DB::getInstance(); - $row = $db->fetchOne("SELECT value FROM settings WHERE `key` = 'proxy_mode'"); - $mode = $row['value'] ?? 'disabled'; - return [ - 'installed' => $installed, - 'running' => $running, - 'version' => $version, - 'mode' => $mode, + $version = ''; + if ($installed) { + $raw = $remote ? self::remoteExec('nginx -v') : (shell_exec('nginx -v 2>&1') ?: ''); + $version = trim($raw); + } + + $data = [ + 'installed' => $installed, + 'running' => $running, + 'version' => $version, + 'mode' => $mode, ]; + if ($remote) { + $data['remote_host'] = $get('proxy_remote_host'); + $data['remote_user'] = $get('proxy_remote_user', 'root'); + } + return $data; } - public static function start(): string { - return self::sysctl('start'); - } - - public static function stop(): string { - return self::sysctl('stop'); - } - - public static function restart(): string { - return self::sysctl('restart'); - } + public static function start(): string { return self::sysctl('start'); } + public static function stop(): string { return self::sysctl('stop'); } + public static function restart(): string { return self::sysctl('restart'); } public static function reload(): string { - if (!self::isInstalled()) return 'nginx not installed'; - $test = shell_exec('sudo nginx -t 2>&1'); + if (self::isRemote()) { + $test = self::remoteExec('nginx -t'); + if (strpos($test, 'successful') === false) return 'Config test failed: ' . $test; + self::remoteExec('systemctl reload nginx'); + return 'reloaded'; + } + $test = shell_exec('nginx -t 2>&1'); if (strpos($test ?? '', 'successful') === false) return 'Config test failed: ' . $test; - shell_exec('sudo systemctl reload nginx 2>/dev/null'); + shell_exec('systemctl reload nginx 2>/dev/null'); return 'reloaded'; } public static function install(): string { + if (self::isRemote()) return 'Use the setup script to install nginx on the remote proxy VM'; if (self::isInstalled()) return 'already installed'; - shell_exec('sudo apt-get update -qq 2>/dev/null && sudo apt-get install -y nginx 2>&1'); + shell_exec('apt-get update -qq 2>/dev/null && apt-get install -y nginx 2>&1'); if (!self::isInstalled()) return 'install failed'; - // Disable default site @unlink('/etc/nginx/sites-enabled/default'); - shell_exec('sudo systemctl enable nginx 2>/dev/null'); - shell_exec('sudo systemctl start nginx 2>/dev/null'); + shell_exec('systemctl enable nginx 2>/dev/null'); + shell_exec('systemctl start nginx 2>/dev/null'); return 'installed'; } @@ -74,15 +128,17 @@ class ProxyManager { } public static function syncFromAccounts(): int { - $db = DB::getInstance(); - $accounts = $db->fetchAll("SELECT a.*, d.domain FROM accounts a JOIN domains d ON d.account_id=a.id AND d.type='main' WHERE a.status='active'") ?: []; - $count = 0; + $db = DB::getInstance(); + $backendIp = $db->fetchOne("SELECT value FROM settings WHERE `key`='proxy_backend_ip'")['value'] ?? '127.0.0.1'; + $accounts = $db->fetchAll( + "SELECT a.*, d.domain FROM accounts a JOIN domains d ON d.account_id=a.id AND d.type='main' WHERE a.status='active'" + ) ?: []; + $count = 0; foreach ($accounts as $acct) { - $existing = $db->fetchOne("SELECT id FROM proxy_hosts WHERE domain=?", [$acct['domain']]); - if (!$existing) { + if (!$db->fetchOne("SELECT id FROM proxy_hosts WHERE domain=?", [$acct['domain']])) { $db->insert( "INSERT INTO proxy_hosts (account_id, domain, upstream, ssl_enabled, enabled, created_at) VALUES (?,?,?,0,1,NOW())", - [$acct['id'], $acct['domain'], 'http://127.0.0.1:80'] + [$acct['id'], $acct['domain'], "http://{$backendIp}:80"] ); $count++; } @@ -92,8 +148,8 @@ class ProxyManager { } public static function addHost(array $data): int { - $db = DB::getInstance(); - $id = (int)$db->insert( + $db = DB::getInstance(); + $id = (int)$db->insert( "INSERT INTO proxy_hosts (account_id, domain, upstream, ssl_enabled, enabled, custom_config, created_at) VALUES (?,?,?,?,1,?,NOW())", [ $data['account_id'] ?? null, @@ -121,8 +177,16 @@ class ProxyManager { $host = $db->fetchOne("SELECT domain FROM proxy_hosts WHERE id=?", [$id]); $db->execute("DELETE FROM proxy_hosts WHERE id=?", [$id]); if ($host) { - @unlink(self::$confDir . '/' . self::$confPrefix . $host['domain'] . '.conf'); - @unlink(self::$enabledDir . '/' . self::$confPrefix . $host['domain'] . '.conf'); + $safe = preg_replace('/[^a-z0-9._-]/', '', strtolower($host['domain'])); + if (self::isRemote()) { + self::remoteExec('rm -f ' . + escapeshellarg(self::$confDir . '/' . self::$confPrefix . $safe . '.conf') . ' ' . + escapeshellarg(self::$enabledDir . '/' . self::$confPrefix . $safe . '.conf') + ); + } else { + @unlink(self::$confDir . '/' . self::$confPrefix . $safe . '.conf'); + @unlink(self::$enabledDir . '/' . self::$confPrefix . $safe . '.conf'); + } } self::reload(); } @@ -139,9 +203,21 @@ class ProxyManager { if (!self::isInstalled()) return; $db = DB::getInstance(); $hosts = $db->fetchAll("SELECT * FROM proxy_hosts") ?: []; - // Remove old novacpx proxy configs - foreach (glob(self::$confDir . '/' . self::$confPrefix . '*.conf') ?: [] as $f) @unlink($f); - foreach (glob(self::$enabledDir . '/' . self::$confPrefix . '*.conf') ?: [] as $f) @unlink($f); + + if (self::isRemote()) { + // Remove old proxy configs on remote + self::remoteExec('rm -f ' . + escapeshellarg(self::$confDir . '/' . self::$confPrefix . '*.conf') . ' ' . + escapeshellarg(self::$enabledDir . '/' . self::$confPrefix . '*.conf') + ); + // Use glob expansion via shell, not escaped + self::remoteExec('rm -f ' . self::$confDir . '/' . self::$confPrefix . '*.conf ' . + self::$enabledDir . '/' . self::$confPrefix . '*.conf'); + } else { + foreach (glob(self::$confDir . '/' . self::$confPrefix . '*.conf') ?: [] as $f) @unlink($f); + foreach (glob(self::$enabledDir . '/' . self::$confPrefix . '*.conf') ?: [] as $f) @unlink($f); + } + foreach ($hosts as $host) { if (!$host['enabled']) continue; self::writeHostConfig($host); @@ -151,65 +227,84 @@ class ProxyManager { private static function writeHostConfig(array $host): void { $safe = preg_replace('/[^a-z0-9._-]/', '', strtolower($host['domain'])); - $confPath = self::$confDir . '/' . self::$confPrefix . $safe . '.conf'; + $confPath = self::$confDir . '/' . self::$confPrefix . $safe . '.conf'; $linkPath = self::$enabledDir . '/' . self::$confPrefix . $safe . '.conf'; - if ($host['custom_config']) { - file_put_contents($confPath, $host['custom_config']); - } else { - $upstream = rtrim($host['upstream'], '/'); - $ssl = !empty($host['ssl_enabled']); - $certDir = "/etc/novacpx/ssl/accounts/" . preg_replace('/[^a-z0-9._-]/', '', $host['domain']); + $content = $host['custom_config'] ?: self::buildConf($host); - $conf = "server {\n"; - $conf .= " listen 80;\n"; - if ($ssl) $conf .= " listen 443 ssl http2;\n"; - $conf .= " server_name {$host['domain']} www.{$host['domain']};\n"; - if ($ssl) { - $conf .= " ssl_certificate {$certDir}/cert.pem;\n"; - $conf .= " ssl_certificate_key {$certDir}/key.pem;\n"; - $conf .= " ssl_protocols TLSv1.2 TLSv1.3;\n"; - $conf .= " ssl_ciphers HIGH:!aNULL:!MD5;\n"; - } - $conf .= " location / {\n"; - $conf .= " proxy_pass {$upstream};\n"; - $conf .= " proxy_http_version 1.1;\n"; - $conf .= " proxy_set_header Host \$host;\n"; - $conf .= " proxy_set_header X-Real-IP \$remote_addr;\n"; - $conf .= " proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;\n"; - $conf .= " proxy_set_header X-Forwarded-Proto \$scheme;\n"; - $conf .= " proxy_set_header Upgrade \$http_upgrade;\n"; - $conf .= " proxy_set_header Connection 'upgrade';\n"; - $conf .= " proxy_cache_bypass \$http_upgrade;\n"; - $conf .= " proxy_read_timeout 86400;\n"; - $conf .= " }\n"; - $conf .= "}\n"; - file_put_contents($confPath, $conf); + if (self::isRemote()) { + self::remotePush($content, $confPath); + self::remoteExec('ln -sf ' . escapeshellarg($confPath) . ' ' . escapeshellarg($linkPath)); + } else { + file_put_contents($confPath, $content); + @symlink($confPath, $linkPath); } - @symlink($confPath, $linkPath); + } + + private static function buildConf(array $host): string { + $upstream = rtrim($host['upstream'], '/'); + $ssl = !empty($host['ssl_enabled']); + $certDir = '/etc/novacpx/ssl/accounts/' . preg_replace('/[^a-z0-9._-]/', '', $host['domain']); + + $c = "server {\n"; + $c .= " listen 80;\n"; + if ($ssl) $c .= " listen 443 ssl http2;\n"; + $c .= " server_name {$host['domain']} www.{$host['domain']};\n"; + if ($ssl) { + $c .= " ssl_certificate {$certDir}/cert.pem;\n"; + $c .= " ssl_certificate_key {$certDir}/key.pem;\n"; + $c .= " ssl_protocols TLSv1.2 TLSv1.3;\n"; + $c .= " ssl_ciphers HIGH:!aNULL:!MD5;\n"; + } + $c .= " location / {\n"; + $c .= " proxy_pass {$upstream};\n"; + $c .= " proxy_http_version 1.1;\n"; + $c .= " proxy_set_header Host \$host;\n"; + $c .= " proxy_set_header X-Real-IP \$remote_addr;\n"; + $c .= " proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;\n"; + $c .= " proxy_set_header X-Forwarded-Proto \$scheme;\n"; + $c .= " proxy_set_header Upgrade \$http_upgrade;\n"; + $c .= " proxy_set_header Connection 'upgrade';\n"; + $c .= " proxy_cache_bypass \$http_upgrade;\n"; + $c .= " proxy_read_timeout 86400;\n"; + $c .= " }\n"; + $c .= "}\n"; + return $c; + } + + // --- Remote connectivity test --- + + public static function testRemote(): array { + $r = self::getRemote(); + if (!$r['host']) return ['ok' => false, 'message' => 'No remote host configured']; + $out = self::remoteExec('nginx -v'); + if (strpos($out, 'nginx') === false) { + return ['ok' => false, 'message' => 'Connected but nginx not found: ' . trim($out)]; + } + return ['ok' => true, 'message' => 'Connected — ' . trim($out)]; } // --- Setup Script --- public static function setupScript(): string { - $serverIp = trim(shell_exec("hostname -I | awk '{print $1}'") ?: '127.0.0.1'); + $serverIp = trim(shell_exec("hostname -I | awk '{print \$1}'") ?: '127.0.0.1'); return << /etc/nginx/conf.d/novacpx-proxy.conf << 'EOF' client_max_body_size 256M; proxy_buffers 16 16k; @@ -218,11 +313,8 @@ gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml; EOF -# Point proxy back to NovaCPX Apache backend (update SERVER_IP below) -BACKEND_IP={$serverIp} - -# Generic catch-all for testing -cat > /etc/nginx/sites-available/novacpx-default.conf << EOF +# Catch-all that drops unrecognised hosts +cat > /etc/nginx/sites-available/novacpx-default.conf << 'EOF' server { listen 80 default_server; server_name _; @@ -231,21 +323,30 @@ server { EOF ln -sf /etc/nginx/sites-available/novacpx-default.conf /etc/nginx/sites-enabled/ -# Test and reload nginx -t && systemctl reload nginx systemctl enable nginx echo "[NovaCPX] Nginx proxy installed and running." -echo " Backend IP: \$BACKEND_IP" -echo " Add proxy hosts from the NovaCPX admin panel → Nginx Proxy" +echo " NovaCPX backend (Apache): {$serverIp}" +echo "" +echo " Now go to NovaCPX Admin -> Nginx Proxy -> Settings and set:" +echo " Mode: remote" +echo " Remote host: " +echo " Remote user: root" +echo " Remote pass: " +echo " Backend IP: {$serverIp}" BASH; } // --- Helpers --- private static function sysctl(string $action): string { - if (!self::isInstalled()) return 'nginx not installed'; - shell_exec("sudo systemctl {$action} nginx 2>/dev/null"); + if (self::isRemote()) { + self::remoteExec("systemctl {$action} nginx"); + sleep(1); + return self::isRunning() ? 'running' : 'stopped'; + } + shell_exec("systemctl {$action} nginx 2>/dev/null"); sleep(1); return self::isRunning() ? 'running' : 'stopped'; } diff --git a/panel/public/assets/js/admin.js b/panel/public/assets/js/admin.js index 32d4f9c..a9a2083 100644 --- a/panel/public/assets/js/admin.js +++ b/panel/public/assets/js/admin.js @@ -2585,24 +2585,32 @@ window.totpAdminDisable = (userId, username) => { // ── Nginx Proxy Manager ─────────────────────────────────────────────────────── async function nginxProxyPage() { - const [statusR, hostsR] = await Promise.all([ + const [statusR, hostsR, settingsR] = await Promise.all([ Nova.api('proxy', 'status'), Nova.api('proxy', 'hosts'), + Nova.api('proxy', 'settings'), ]); - const s = statusR?.data || {}; - const hosts = hostsR?.data || (Array.isArray(hostsR) ? hostsR : []); - const run = s.running; - const inst = s.installed; + const s = statusR?.data || {}; + const hosts = hostsR?.data || (Array.isArray(hostsR) ? hostsR : []); + const cfg = settingsR?.data || {}; + const run = s.running; + const inst = s.installed; + const isRemote = cfg.mode === 'remote'; + const modeLabel = cfg.mode === 'remote' ? `Remote (${cfg.remote_host || 'unconfigured'})` : (cfg.mode === 'local' ? 'Local' : 'Disabled'); return ` ` : ` @@ -2829,6 +2842,76 @@ window.proxySetupInstructions = async () => { `, null, { cancelLabel: 'Close', showConfirm: false }); }; +window.proxySettings = async () => { + const r = await Nova.api('proxy', 'settings'); + const cfg = r?.data || {}; + const ov = Nova.modal('Nginx Proxy Settings', ` +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ `, async () => { + const mode = document.getElementById('ps-mode')?.value; + const pass = document.getElementById('ps-pass')?.value; + const body = { + mode, + remote_host: document.getElementById('ps-host')?.value?.trim() || '', + remote_user: document.getElementById('ps-user')?.value?.trim() || 'root', + remote_pass: pass || '••••••••', + backend_ip: document.getElementById('ps-backend')?.value?.trim() || '', + }; + const r = await Nova.api('proxy', 'settings', { method: 'POST', body }); + Nova.toast(r?.success ? 'Settings saved' : (r?.message || 'Failed'), r?.success ? 'success' : 'error'); + if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages); + }); +}; + +window.proxyTestRemote = async () => { + const host = document.getElementById('ps-host')?.value?.trim(); + const user = document.getElementById('ps-user')?.value?.trim() || 'root'; + const pass = document.getElementById('ps-pass')?.value; + const el = document.getElementById('ps-test-result'); + if (!host) { if (el) el.textContent = 'Enter a host first'; return; } + if (el) el.textContent = 'Testing…'; + // Save current fields temporarily so the test can use them + await Nova.api('proxy', 'settings', { method: 'POST', body: { + remote_host: host, remote_user: user, + remote_pass: pass || '••••••••', + }}); + const r = await Nova.api('proxy', 'test-remote', { method: 'POST' }); + const d = r?.data || {}; + if (el) { + el.style.color = d.ok ? 'var(--color-success)' : 'var(--color-error)'; + el.textContent = d.message || (d.ok ? 'Connected' : 'Failed'); + } +}; + // ── #29 Session Manager ─────────────────────────────────────────────────────── async function sessionsPage() { const r = await Nova.api('sessions', 'list');