Nginx proxy: remote VM support via SSH

- ProxyManager: all ops (start/stop/reload, config push) work over SSH
  when proxy_mode=remote; sysctl/reload/writeHostConfig/deleteHost all
  route to remoteExec/remotePush helpers
- proxy.php: add GET/POST /api/proxy/settings and POST /api/proxy/test-remote
- admin.js: Settings modal with mode selector + remote fields + Test Connection;
  page header always shows Settings button; status card shows mode + remote host;
  'not installed' state directs to Configure Remote Proxy VM

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 10:14:18 +00:00
parent 89c9bfdc49
commit 6b95571548
3 changed files with 331 additions and 105 deletions
+43 -1
View File
@@ -3,7 +3,7 @@
* Proxy endpoint — manage Nginx reverse proxy * Proxy endpoint — manage Nginx reverse proxy
* Routes: * Routes:
* GET /api/proxy/status — nginx status * 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} * POST /api/proxy/control — {action: start|stop|restart|reload}
* GET /api/proxy/hosts — list proxy hosts * GET /api/proxy/hosts — list proxy hosts
* POST /api/proxy/hosts — add proxy host * POST /api/proxy/hosts — add proxy host
@@ -13,6 +13,9 @@
* POST /api/proxy/sync — sync hosts from accounts * POST /api/proxy/sync — sync hosts from accounts
* POST /api/proxy/write-configs — regenerate all nginx configs * POST /api/proxy/write-configs — regenerate all nginx configs
* GET /api/proxy/setup-script — return bash install script * 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'); Auth::getInstance()->require('admin');
@@ -99,6 +102,45 @@ try {
exit; 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), default => Response::error('Not found', 404),
}; };
+190 -89
View File
@@ -2,6 +2,13 @@
/** /**
* ProxyManager — manages Nginx reverse proxy for NovaCPX hosted accounts. * ProxyManager — manages Nginx reverse proxy for NovaCPX hosted accounts.
* Supports local nginx (on same VM) or remote nginx (separate proxy VM via SSH). * 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 { class ProxyManager {
@@ -9,60 +16,107 @@ class ProxyManager {
private static string $enabledDir = '/etc/nginx/sites-enabled'; private static string $enabledDir = '/etc/nginx/sites-enabled';
private static string $confPrefix = 'novacpx-proxy-'; 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 --- // --- Status & Control ---
public static function isInstalled(): bool { 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')); return file_exists('/usr/sbin/nginx') || !empty(shell_exec('which nginx 2>/dev/null'));
} }
public static function isRunning(): bool { public static function isRunning(): bool {
$out = shell_exec('systemctl is-active nginx 2>/dev/null'); if (self::isRemote()) {
return trim($out ?? '') === 'active'; 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 { 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(); $installed = self::isInstalled();
$running = $installed && self::isRunning(); $running = $installed && self::isRunning();
$version = $installed ? trim(shell_exec('nginx -v 2>&1') ?: '') : ''; $version = '';
$db = DB::getInstance(); if ($installed) {
$row = $db->fetchOne("SELECT value FROM settings WHERE `key` = 'proxy_mode'"); $raw = $remote ? self::remoteExec('nginx -v') : (shell_exec('nginx -v 2>&1') ?: '');
$mode = $row['value'] ?? 'disabled'; $version = trim($raw);
return [ }
'installed' => $installed,
'running' => $running, $data = [
'version' => $version, 'installed' => $installed,
'mode' => $mode, '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 { public static function start(): string { return self::sysctl('start'); }
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 stop(): string {
return self::sysctl('stop');
}
public static function restart(): string {
return self::sysctl('restart');
}
public static function reload(): string { public static function reload(): string {
if (!self::isInstalled()) return 'nginx not installed'; if (self::isRemote()) {
$test = shell_exec('sudo nginx -t 2>&1'); $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; 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'; return 'reloaded';
} }
public static function install(): string { 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'; 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'; if (!self::isInstalled()) return 'install failed';
// Disable default site
@unlink('/etc/nginx/sites-enabled/default'); @unlink('/etc/nginx/sites-enabled/default');
shell_exec('sudo systemctl enable nginx 2>/dev/null'); shell_exec('systemctl enable nginx 2>/dev/null');
shell_exec('sudo systemctl start nginx 2>/dev/null'); shell_exec('systemctl start nginx 2>/dev/null');
return 'installed'; return 'installed';
} }
@@ -74,15 +128,17 @@ class ProxyManager {
} }
public static function syncFromAccounts(): int { public static function syncFromAccounts(): int {
$db = DB::getInstance(); $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'") ?: []; $backendIp = $db->fetchOne("SELECT value FROM settings WHERE `key`='proxy_backend_ip'")['value'] ?? '127.0.0.1';
$count = 0; $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) { foreach ($accounts as $acct) {
$existing = $db->fetchOne("SELECT id FROM proxy_hosts WHERE domain=?", [$acct['domain']]); if (!$db->fetchOne("SELECT id FROM proxy_hosts WHERE domain=?", [$acct['domain']])) {
if (!$existing) {
$db->insert( $db->insert(
"INSERT INTO proxy_hosts (account_id, domain, upstream, ssl_enabled, enabled, created_at) VALUES (?,?,?,0,1,NOW())", "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++; $count++;
} }
@@ -92,8 +148,8 @@ class ProxyManager {
} }
public static function addHost(array $data): int { public static function addHost(array $data): int {
$db = DB::getInstance(); $db = DB::getInstance();
$id = (int)$db->insert( $id = (int)$db->insert(
"INSERT INTO proxy_hosts (account_id, domain, upstream, ssl_enabled, enabled, custom_config, created_at) VALUES (?,?,?,?,1,?,NOW())", "INSERT INTO proxy_hosts (account_id, domain, upstream, ssl_enabled, enabled, custom_config, created_at) VALUES (?,?,?,?,1,?,NOW())",
[ [
$data['account_id'] ?? null, $data['account_id'] ?? null,
@@ -121,8 +177,16 @@ class ProxyManager {
$host = $db->fetchOne("SELECT domain FROM proxy_hosts WHERE id=?", [$id]); $host = $db->fetchOne("SELECT domain FROM proxy_hosts WHERE id=?", [$id]);
$db->execute("DELETE FROM proxy_hosts WHERE id=?", [$id]); $db->execute("DELETE FROM proxy_hosts WHERE id=?", [$id]);
if ($host) { if ($host) {
@unlink(self::$confDir . '/' . self::$confPrefix . $host['domain'] . '.conf'); $safe = preg_replace('/[^a-z0-9._-]/', '', strtolower($host['domain']));
@unlink(self::$enabledDir . '/' . self::$confPrefix . $host['domain'] . '.conf'); 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(); self::reload();
} }
@@ -139,9 +203,21 @@ class ProxyManager {
if (!self::isInstalled()) return; if (!self::isInstalled()) return;
$db = DB::getInstance(); $db = DB::getInstance();
$hosts = $db->fetchAll("SELECT * FROM proxy_hosts") ?: []; $hosts = $db->fetchAll("SELECT * FROM proxy_hosts") ?: [];
// Remove old novacpx proxy configs
foreach (glob(self::$confDir . '/' . self::$confPrefix . '*.conf') ?: [] as $f) @unlink($f); if (self::isRemote()) {
foreach (glob(self::$enabledDir . '/' . self::$confPrefix . '*.conf') ?: [] as $f) @unlink($f); // 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) { foreach ($hosts as $host) {
if (!$host['enabled']) continue; if (!$host['enabled']) continue;
self::writeHostConfig($host); self::writeHostConfig($host);
@@ -151,65 +227,84 @@ class ProxyManager {
private static function writeHostConfig(array $host): void { private static function writeHostConfig(array $host): void {
$safe = preg_replace('/[^a-z0-9._-]/', '', strtolower($host['domain'])); $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'; $linkPath = self::$enabledDir . '/' . self::$confPrefix . $safe . '.conf';
if ($host['custom_config']) { $content = $host['custom_config'] ?: self::buildConf($host);
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']);
$conf = "server {\n"; if (self::isRemote()) {
$conf .= " listen 80;\n"; self::remotePush($content, $confPath);
if ($ssl) $conf .= " listen 443 ssl http2;\n"; self::remoteExec('ln -sf ' . escapeshellarg($confPath) . ' ' . escapeshellarg($linkPath));
$conf .= " server_name {$host['domain']} www.{$host['domain']};\n"; } else {
if ($ssl) { file_put_contents($confPath, $content);
$conf .= " ssl_certificate {$certDir}/cert.pem;\n"; @symlink($confPath, $linkPath);
$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);
} }
@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 --- // --- Setup Script ---
public static function setupScript(): string { 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 <<<BASH return <<<BASH
#!/bin/bash #!/bin/bash
# NovaCPX Nginx Reverse Proxy Setup Script # NovaCPX Nginx Reverse Proxy Setup Script
# Run as root on the proxy VM (or this VM for local proxy) # Run as root on the dedicated proxy VM
set -e set -e
echo "[NovaCPX] Installing Nginx reverse proxy..." echo "[NovaCPX] Installing Nginx reverse proxy..."
apt-get update -qq apt-get update -qq
apt-get install -y nginx certbot python3-certbot-nginx apt-get install -y nginx openssh-server
# Disable default site # Disable default site
rm -f /etc/nginx/sites-enabled/default rm -f /etc/nginx/sites-enabled/default
# Create NovaCPX proxy conf directory # Create NovaCPX proxy conf directories
mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled
# Main nginx.conf tuning # Tune nginx for proxying
cat > /etc/nginx/conf.d/novacpx-proxy.conf << 'EOF' cat > /etc/nginx/conf.d/novacpx-proxy.conf << 'EOF'
client_max_body_size 256M; client_max_body_size 256M;
proxy_buffers 16 16k; proxy_buffers 16 16k;
@@ -218,11 +313,8 @@ gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml; gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
EOF EOF
# Point proxy back to NovaCPX Apache backend (update SERVER_IP below) # Catch-all that drops unrecognised hosts
BACKEND_IP={$serverIp} cat > /etc/nginx/sites-available/novacpx-default.conf << 'EOF'
# Generic catch-all for testing
cat > /etc/nginx/sites-available/novacpx-default.conf << EOF
server { server {
listen 80 default_server; listen 80 default_server;
server_name _; server_name _;
@@ -231,21 +323,30 @@ server {
EOF EOF
ln -sf /etc/nginx/sites-available/novacpx-default.conf /etc/nginx/sites-enabled/ ln -sf /etc/nginx/sites-available/novacpx-default.conf /etc/nginx/sites-enabled/
# Test and reload
nginx -t && systemctl reload nginx nginx -t && systemctl reload nginx
systemctl enable nginx systemctl enable nginx
echo "[NovaCPX] Nginx proxy installed and running." echo "[NovaCPX] Nginx proxy installed and running."
echo " Backend IP: \$BACKEND_IP" echo " NovaCPX backend (Apache): {$serverIp}"
echo " Add proxy hosts from the NovaCPX admin panel → Nginx Proxy" echo ""
echo " Now go to NovaCPX Admin -> Nginx Proxy -> Settings and set:"
echo " Mode: remote"
echo " Remote host: <this VM's IP>"
echo " Remote user: root"
echo " Remote pass: <this VM's root password>"
echo " Backend IP: {$serverIp}"
BASH; BASH;
} }
// --- Helpers --- // --- Helpers ---
private static function sysctl(string $action): string { private static function sysctl(string $action): string {
if (!self::isInstalled()) return 'nginx not installed'; if (self::isRemote()) {
shell_exec("sudo systemctl {$action} nginx 2>/dev/null"); self::remoteExec("systemctl {$action} nginx");
sleep(1);
return self::isRunning() ? 'running' : 'stopped';
}
shell_exec("systemctl {$action} nginx 2>/dev/null");
sleep(1); sleep(1);
return self::isRunning() ? 'running' : 'stopped'; return self::isRunning() ? 'running' : 'stopped';
} }
+98 -15
View File
@@ -2585,24 +2585,32 @@ window.totpAdminDisable = (userId, username) => {
// ── Nginx Proxy Manager ─────────────────────────────────────────────────────── // ── Nginx Proxy Manager ───────────────────────────────────────────────────────
async function nginxProxyPage() { async function nginxProxyPage() {
const [statusR, hostsR] = await Promise.all([ const [statusR, hostsR, settingsR] = await Promise.all([
Nova.api('proxy', 'status'), Nova.api('proxy', 'status'),
Nova.api('proxy', 'hosts'), Nova.api('proxy', 'hosts'),
Nova.api('proxy', 'settings'),
]); ]);
const s = statusR?.data || {}; const s = statusR?.data || {};
const hosts = hostsR?.data || (Array.isArray(hostsR) ? hostsR : []); const hosts = hostsR?.data || (Array.isArray(hostsR) ? hostsR : []);
const run = s.running; const cfg = settingsR?.data || {};
const inst = s.installed; 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 ` return `
<div class="page-header"> <div class="page-header">
<h1 class="page-title">Nginx Proxy Manager</h1> <h1 class="page-title">Nginx Proxy Manager</h1>
<div class="page-actions"> <div class="page-actions">
<button class="btn btn-ghost btn-sm" onclick="proxySettings()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Settings
</button>
<button class="btn btn-ghost btn-sm" onclick="proxySetupInstructions()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>
Setup Guide
</button>
${inst ? ` ${inst ? `
<button class="btn btn-ghost btn-sm" onclick="proxySetupInstructions()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>
Setup Guide
</button>
<button class="btn btn-sm btn-secondary" onclick="proxySync()"> <button class="btn btn-sm btn-secondary" onclick="proxySync()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
Sync Accounts Sync Accounts
@@ -2615,8 +2623,13 @@ async function nginxProxyPage() {
<div class="stats-grid" style="margin-bottom:1.5rem"> <div class="stats-grid" style="margin-bottom:1.5rem">
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">Nginx Status</div> <div class="stat-label">Nginx Status</div>
<div class="stat-value ${run ? 'stat-green' : 'stat-red'}">${inst ? (run ? 'Running' : 'Stopped') : 'Not Installed'}</div> <div class="stat-value ${run ? 'stat-green' : 'stat-red'}">${inst ? (run ? 'Running' : 'Stopped') : 'Not Configured'}</div>
<div class="stat-sub">${s.version || (inst ? 'nginx' : 'click Install to set up')}</div> <div class="stat-sub">${s.version || (inst ? 'nginx' : 'configure in Settings')}</div>
</div>
<div class="stat-card">
<div class="stat-label">Mode</div>
<div class="stat-value" style="font-size:1rem">${modeLabel}</div>
<div class="stat-sub">${isRemote ? 'configs pushed via SSH' : (cfg.mode === 'local' ? 'nginx on this VM' : 'click Settings to enable')}</div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">Proxy Hosts</div> <div class="stat-label">Proxy Hosts</div>
@@ -2633,11 +2646,11 @@ async function nginxProxyPage() {
${!inst ? ` ${!inst ? `
<div class="panel" style="text-align:center;padding:3rem"> <div class="panel" style="text-align:center;padding:3rem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="48" height="48" style="color:var(--text-muted);margin-bottom:1rem"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="48" height="48" style="color:var(--text-muted);margin-bottom:1rem"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
<h3 style="margin-bottom:0.5rem">Nginx Not Installed</h3> <h3 style="margin-bottom:0.5rem">Nginx Proxy Not Active</h3>
<p style="color:var(--text-muted);margin-bottom:1.5rem">Install Nginx on this VM to use it as a reverse proxy in front of Apache, or use a separate proxy VM (see Setup Guide).</p> <p style="color:var(--text-muted);margin-bottom:1.5rem">Use a dedicated proxy VM (recommended) — run nginx on a separate LXC and control it from here via SSH. Or install nginx locally alongside Apache (requires moving Apache to port 8080).</p>
<div style="display:flex;gap:0.75rem;justify-content:center;flex-wrap:wrap"> <div style="display:flex;gap:0.75rem;justify-content:center;flex-wrap:wrap">
<button class="btn btn-primary" onclick="proxyInstall()">Install Nginx Locally</button> <button class="btn btn-primary" onclick="proxySettings()">Configure Remote Proxy VM</button>
<button class="btn btn-secondary" onclick="proxySetupInstructions()">Setup Guide / Remote VM</button> <button class="btn btn-secondary" onclick="proxySetupInstructions()">Setup Guide</button>
</div> </div>
</div> </div>
` : ` ` : `
@@ -2829,6 +2842,76 @@ window.proxySetupInstructions = async () => {
`, null, { cancelLabel: 'Close', showConfirm: false }); `, 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', `
<div class="form-group">
<label>Proxy Mode</label>
<select id="ps-mode" class="form-control" onchange="document.getElementById('ps-remote-fields').style.display=this.value==='remote'?'':'none'">
<option value="disabled" ${cfg.mode==='disabled'?'selected':''}>Disabled</option>
<option value="remote" ${cfg.mode==='remote' ?'selected':''}>Remote VM (SSH)</option>
<option value="local" ${cfg.mode==='local' ?'selected':''}>Local (nginx on this VM)</option>
</select>
</div>
<div id="ps-remote-fields" style="display:${cfg.mode==='remote'?'':'none'}">
<div class="form-group">
<label>Remote Host <small class="text-muted">(IP of your nginx proxy VM)</small></label>
<input id="ps-host" type="text" class="form-control" placeholder="10.48.200.112" value="${Nova.escHtml(cfg.remote_host||'')}">
</div>
<div class="form-group">
<label>SSH User</label>
<input id="ps-user" type="text" class="form-control" value="${Nova.escHtml(cfg.remote_user||'root')}">
</div>
<div class="form-group">
<label>SSH Password</label>
<input id="ps-pass" type="password" class="form-control" placeholder="${cfg.remote_pass?'(saved — leave blank to keep)':'Enter password'}">
</div>
<div class="form-group">
<label>Backend IP <small class="text-muted">(NovaCPX Apache IP — used in proxy host upstreams)</small></label>
<input id="ps-backend" type="text" class="form-control" placeholder="10.48.200.110" value="${Nova.escHtml(cfg.backend_ip||'')}">
</div>
<div style="margin-bottom:1rem">
<button class="btn btn-sm btn-ghost" onclick="proxyTestRemote()">Test Connection</button>
<span id="ps-test-result" style="margin-left:0.75rem;font-size:0.85rem"></span>
</div>
</div>
`, 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 ─────────────────────────────────────────────────────── // ── #29 Session Manager ───────────────────────────────────────────────────────
async function sessionsPage() { async function sessionsPage() {
const r = await Nova.api('sessions', 'list'); const r = await Nova.api('sessions', 'list');