diff --git a/db/migrations/003_proxy_hosts.sql b/db/migrations/003_proxy_hosts.sql new file mode 100644 index 0000000..81c91a8 --- /dev/null +++ b/db/migrations/003_proxy_hosts.sql @@ -0,0 +1,19 @@ +-- Migration 003: Nginx Proxy Hosts table +CREATE TABLE IF NOT EXISTS proxy_hosts ( + id INT AUTO_INCREMENT PRIMARY KEY, + account_id INT UNSIGNED DEFAULT NULL, + domain VARCHAR(255) NOT NULL, + upstream VARCHAR(500) NOT NULL DEFAULT 'http://127.0.0.1:80', + ssl_enabled TINYINT(1) NOT NULL DEFAULT 0, + enabled TINYINT(1) NOT NULL DEFAULT 1, + custom_config TEXT DEFAULT NULL, + notes VARCHAR(500) DEFAULT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + INDEX (account_id), + UNIQUE KEY uq_domain (domain) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Panel settings for proxy mode +INSERT INTO settings (`key`, `value`) VALUES ('proxy_mode', 'disabled') ON DUPLICATE KEY UPDATE `key`=`key`; +INSERT INTO settings (`key`, `value`) VALUES ('proxy_auto_sync', '0') ON DUPLICATE KEY UPDATE `key`=`key`; diff --git a/panel/api/endpoints/proxy.php b/panel/api/endpoints/proxy.php new file mode 100644 index 0000000..112fe33 --- /dev/null +++ b/panel/api/endpoints/proxy.php @@ -0,0 +1,100 @@ +requireRole('admin'); +require_once PANEL_ROOT . '/lib/ProxyManager.php'; + +$method = $_SERVER['REQUEST_METHOD']; +$parts = $routeParts ?? []; +$subpath = implode('/', array_slice($parts, 1)); + +// Numeric id extraction +preg_match('|hosts/(\d+)(/.+)?|', $subpath, $m); +$hostId = isset($m[1]) ? (int)$m[1] : null; +$hostSub = $m[2] ?? ''; + +try { + // GET proxy/status + if ($method === 'GET' && $subpath === 'status') { + json_ok(ProxyManager::status()); + + // POST proxy/install + } elseif ($method === 'POST' && $subpath === 'install') { + $result = ProxyManager::install(); + json_ok(['result' => $result]); + + // POST proxy/control + } elseif ($method === 'POST' && $subpath === 'control') { + $action = $body['action'] ?? ''; + if (!in_array($action, ['start','stop','restart','reload'])) json_error('Invalid action', 400); + $result = match($action) { + 'start' => ProxyManager::start(), + 'stop' => ProxyManager::stop(), + 'restart' => ProxyManager::restart(), + 'reload' => ProxyManager::reload(), + }; + json_ok(['result' => $result, 'running' => ProxyManager::isRunning()]); + + // GET proxy/hosts + } elseif ($method === 'GET' && $subpath === 'hosts') { + json_ok(ProxyManager::listHosts()); + + // POST proxy/hosts — add + } elseif ($method === 'POST' && $subpath === 'hosts') { + if (empty($body['domain'])) json_error('domain required', 400); + if (empty($body['upstream'])) json_error('upstream required', 400); + $id = ProxyManager::addHost($body); + json_ok(['id' => $id]); + + // PUT proxy/hosts/{id} + } elseif ($method === 'PUT' && $hostId && !$hostSub) { + ProxyManager::updateHost($hostId, $body); + json_ok(); + + // DELETE proxy/hosts/{id} + } elseif ($method === 'DELETE' && $hostId && !$hostSub) { + ProxyManager::deleteHost($hostId); + json_ok(); + + // POST proxy/hosts/{id}/toggle + } elseif ($method === 'POST' && $hostId && $hostSub === '/toggle') { + ProxyManager::toggleHost($hostId, (bool)($body['enabled'] ?? true)); + json_ok(); + + // POST proxy/sync + } elseif ($method === 'POST' && $subpath === 'sync') { + $added = ProxyManager::syncFromAccounts(); + json_ok(['added' => $added]); + + // POST proxy/write-configs + } elseif ($method === 'POST' && $subpath === 'write-configs') { + ProxyManager::writeAllConfigs(); + json_ok(['result' => 'configs written']); + + // GET proxy/setup-script + } elseif ($method === 'GET' && $subpath === 'setup-script') { + header('Content-Type: text/plain'); + echo ProxyManager::setupScript(); + exit; + + } else { + json_error('Not found', 404); + } +} catch (Throwable $e) { + novacpx_log('error', 'proxy endpoint: ' . $e->getMessage()); + json_error($e->getMessage(), 500); +} diff --git a/panel/lib/ProxyManager.php b/panel/lib/ProxyManager.php new file mode 100644 index 0000000..5fa7bc2 --- /dev/null +++ b/panel/lib/ProxyManager.php @@ -0,0 +1,252 @@ +/dev/null')); + } + + public static function isRunning(): bool { + $out = shell_exec('systemctl is-active nginx 2>/dev/null'); + return trim($out ?? '') === 'active'; + } + + public static function status(): array { + $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, + ]; + } + + 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 (strpos($test ?? '', 'successful') === false) return 'Config test failed: ' . $test; + shell_exec('sudo systemctl reload nginx 2>/dev/null'); + return 'reloaded'; + } + + public static function install(): string { + if (self::isInstalled()) return 'already installed'; + shell_exec('sudo apt-get update -qq 2>/dev/null && sudo 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'); + return 'installed'; + } + + // --- Proxy Hosts --- + + public static function listHosts(): array { + $db = DB::getInstance(); + return $db->fetchAll("SELECT * FROM proxy_hosts ORDER BY domain") ?: []; + } + + 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; + foreach ($accounts as $acct) { + $existing = $db->fetchOne("SELECT id FROM proxy_hosts WHERE domain=?", [$acct['domain']]); + if (!$existing) { + $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'] + ); + $count++; + } + } + if ($count > 0) self::writeAllConfigs(); + return $count; + } + + public static function addHost(array $data): int { + $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, + $data['domain'], + $data['upstream'] ?? 'http://127.0.0.1:80', + (int)($data['ssl_enabled'] ?? 0), + $data['custom_config'] ?? null, + ] + ); + self::writeAllConfigs(); + return $id; + } + + public static function updateHost(int $id, array $data): void { + $db = DB::getInstance(); + $db->execute( + "UPDATE proxy_hosts SET domain=?, upstream=?, ssl_enabled=?, enabled=?, custom_config=? WHERE id=?", + [$data['domain'], $data['upstream'], (int)($data['ssl_enabled'] ?? 0), (int)($data['enabled'] ?? 1), $data['custom_config'] ?? null, $id] + ); + self::writeAllConfigs(); + } + + public static function deleteHost(int $id): void { + $db = DB::getInstance(); + $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'); + } + self::reload(); + } + + public static function toggleHost(int $id, bool $enable): void { + $db = DB::getInstance(); + $db->execute("UPDATE proxy_hosts SET enabled=? WHERE id=?", [(int)$enable, $id]); + self::writeAllConfigs(); + } + + // --- Config Generation --- + + public static function writeAllConfigs(): void { + 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); + foreach ($hosts as $host) { + if (!$host['enabled']) continue; + self::writeHostConfig($host); + } + self::reload(); + } + + private static function writeHostConfig(array $host): void { + $safe = preg_replace('/[^a-z0-9._-]/', '', strtolower($host['domain'])); + $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']); + + $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); + } + @symlink($confPath, $linkPath); + } + + // --- Setup Script --- + + public static function setupScript(): string { + $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; +proxy_buffer_size 16k; +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 +server { + listen 80 default_server; + server_name _; + return 444; +} +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" +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"); + sleep(1); + return self::isRunning() ? 'running' : 'stopped'; + } +} diff --git a/panel/public/admin/index.php b/panel/public/admin/index.php index 202bf99..c16e457 100644 --- a/panel/public/admin/index.php +++ b/panel/public/admin/index.php @@ -97,6 +97,10 @@ FTP Server + + + Nginx Proxy + WordPress diff --git a/panel/public/assets/js/admin.js b/panel/public/assets/js/admin.js index 5c3d3ac..a7d7032 100644 --- a/panel/public/assets/js/admin.js +++ b/panel/public/assets/js/admin.js @@ -87,6 +87,7 @@ 'mysql-manager': mysqlManager, 'mail-server': mailServer, 'ftp-server': ftpServer, + 'nginx-proxy': nginxProxy, wordpress, 'ssl-manager': sslManager, firewall, @@ -98,6 +99,7 @@ settings, }; + window._novaPages = pages; Nova.initNav(pages); await Nova.loadPage('dashboard', pages); checkUpdates(); @@ -1341,6 +1343,7 @@ ${ips.length ? ` async function wordpress() { return `

Loading…

`; } async function cloudflare() { return `

Loading…

`; } async function twofa() { return `

Loading…

`; } + async function nginxProxy() { return `

Loading…

`; } // ── Global action helpers ────────────────────────────────────────────────── window.adminPage = (page) => Nova.loadPage(page, pages); @@ -1919,3 +1922,249 @@ window.totpAdminDisable = (userId, username) => { } }, true); }; + +// ── Nginx Proxy Manager ─────────────────────────────────────────────────────── +async function nginxProxy() { + const [statusR, hostsR] = await Promise.all([ + Nova.api('proxy', 'status'), + Nova.api('proxy', 'hosts'), + ]); + const s = statusR?.data || {}; + const hosts = hostsR?.data || (Array.isArray(hostsR) ? hostsR : []); + const run = s.running; + const inst = s.installed; + + return ` + + +
+
+
Nginx Status
+
${inst ? (run ? 'Running' : 'Stopped') : 'Not Installed'}
+
${s.version || (inst ? 'nginx' : 'click Install to set up')}
+
+
+
Proxy Hosts
+
${hosts.length}
+
${hosts.filter(h => h.enabled).length} active
+
+
+
SSL Enabled
+
${hosts.filter(h => h.ssl_enabled).length}
+
of ${hosts.length} hosts
+
+
+ +${!inst ? ` +
+ +

Nginx Not Installed

+

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).

+
+ + +
+
+` : ` +
+
+

Service Controls

+
+ + + + +
+
+
+ +
+
+

Proxy Hosts

+ ${hosts.length} total +
+ ${hosts.length === 0 ? ` +
+ No proxy hosts yet. Click Sync Accounts to auto-add all hosted domains, or + Add Host to add manually. +
+ ` : ` +
+ + + + + + + + + + ${hosts.map(h => ` + + + + + + + `).join('')} + +
DomainUpstreamSSLStatusActions
${Nova.escHtml(h.domain)}${Nova.escHtml(h.upstream)}${h.ssl_enabled ? Nova.badge('SSL','green') : Nova.badge('HTTP','muted')}${h.enabled ? Nova.badge('Active','green') : Nova.badge('Disabled','red')} + + + +
+
+ `} +
+`}`; +} + +window.proxyInstall = async () => { + if (!confirm('Install Nginx on this VM? This will run apt-get install nginx.')) return; + Nova.toast('Installing nginx...', 'info'); + const r = await Nova.api('proxy', 'install', { method: 'POST' }); + Nova.toast(r?.data?.result || r?.message || 'Done', r?.data?.result === 'installed' ? 'success' : 'info'); + Nova.loadPage('nginx-proxy', window._novaPages); +}; + +window.proxyControl = async (action) => { + const r = await Nova.api('proxy', 'control', { method: 'POST', body: { action } }); + Nova.toast(r?.data?.result || r?.message || action + ' done', 'success'); + setTimeout(() => Nova.loadPage('nginx-proxy', window._novaPages), 800); +}; + +window.proxySync = async () => { + const r = await Nova.api('proxy', 'sync', { method: 'POST' }); + Nova.toast(`Synced: ${r?.data?.added ?? 0} new hosts added`, 'success'); + Nova.loadPage('nginx-proxy', window._novaPages); +}; + +window.proxyAddHost = () => { + Nova.modal('Add Proxy Host', ` +
+
+
+ + e.g. http://127.0.0.1:80 or http://10.0.0.2:8080
+
+
+
+
+ `, async () => { + const domain = document.getElementById('ph-domain')?.value?.trim(); + const upstream = document.getElementById('ph-upstream')?.value?.trim(); + if (!domain || !upstream) { Nova.toast('Domain and upstream required', 'error'); return; } + const r = await Nova.api('proxy', 'hosts', { + method: 'POST', + body: { domain, upstream, ssl_enabled: document.getElementById('ph-ssl')?.checked ? 1 : 0 } + }); + Nova.toast(r?.success ? 'Host added' : (r?.message || 'Failed'), r?.success ? 'success' : 'error'); + if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages); + }); +}; + +window.proxyEditHost = async (id) => { + const hostsR = await Nova.api('proxy', 'hosts'); + const hosts = hostsR?.data || (Array.isArray(hostsR) ? hostsR : []); + const h = hosts.find(x => x.id == id); + if (!h) return; + Nova.modal('Edit Proxy Host', ` +
+
+
+
+
+
+
+ + Leave blank to use auto-generated config
+ `, async () => { + const r = await Nova.api('proxy', `hosts/${id}`, { + method: 'PUT', + body: { + domain: document.getElementById('phe-domain')?.value?.trim(), + upstream: document.getElementById('phe-upstream')?.value?.trim(), + ssl_enabled: document.getElementById('phe-ssl')?.checked ? 1 : 0, + custom_config: document.getElementById('phe-custom')?.value?.trim() || null, + } + }); + Nova.toast(r?.success ? 'Updated' : (r?.message || 'Failed'), r?.success ? 'success' : 'error'); + if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages); + }); +}; + +window.proxyToggle = async (id, enable) => { + const r = await Nova.api('proxy', `hosts/${id}/toggle`, { method: 'POST', body: { enabled: enable } }); + Nova.toast(r?.success ? (enable ? 'Enabled' : 'Disabled') : 'Failed', r?.success ? 'success' : 'error'); + if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages); +}; + +window.proxyDeleteHost = (id, domain) => { + Nova.confirm(`Delete proxy host for ${domain}?`, async () => { + const r = await Nova.api('proxy', `hosts/${id}`, { method: 'DELETE' }); + Nova.toast(r?.success ? 'Deleted' : 'Failed', r?.success ? 'success' : 'error'); + if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages); + }, true); +}; + +window.proxySetupInstructions = async () => { + const scriptUrl = '/api/proxy/setup-script'; + Nova.modal('Nginx Proxy Setup Guide', ` +
+

Option A — Local (Nginx on this VM)

+

Install Nginx alongside Apache on this VM. Nginx listens on ports 80/443 and forwards to Apache. Best for SSL termination and caching.

+
    +
  1. Click Install Nginx Locally on the main Nginx Proxy page
  2. +
  3. Move Apache to port 8080: edit /etc/apache2/ports.conf → change Listen 80 to Listen 8080
  4. +
  5. Update upstream in all proxy hosts to http://127.0.0.1:8080
  6. +
  7. Click Sync Accounts to auto-populate proxy hosts from your hosted accounts
  8. +
  9. Click Reload Config to apply changes
  10. +
+ +

Option B — Remote Proxy VM (Recommended for production)

+

Run a dedicated Nginx proxy VM in front of this NovaCPX VM. Traffic flows: Internet → FortiGate → Nginx Proxy VM → NovaCPX VM (Apache).

+
    +
  1. Create a new VM on Proxmox (Ubuntu 22.04, 1 vCPU, 1GB RAM)
  2. +
  3. Run the setup script below on the new VM as root
  4. +
  5. Point FortiGate VIPs to the proxy VM IP (ports 80/443)
  6. +
  7. Set the proxy upstream to this NovaCPX VM IP (http://10.48.200.110:80)
  8. +
  9. Add proxy hosts for each domain from your NovaCPX admin panel
  10. +
+ +

Automated Setup Script

+

Run this on the target VM (local or remote) as root:

+
+ curl -sk https://YOUR_NOVACPX_IP:8882/api/proxy/setup-script | bash +
+

Or download and review before running:

+
+ curl -sk https://YOUR_NOVACPX_IP:8882/api/proxy/setup-script -o proxy-setup.sh
+ cat proxy-setup.sh # review
+ bash proxy-setup.sh +
+ +

Integration with VirtualHost Manager

+

When proxy mode is active, NovaCPX automatically:

+
    +
  • Creates a proxy host entry for every new account
  • +
  • Removes the proxy host when an account is terminated
  • +
  • Re-generates Nginx config on every account change
  • +
  • Uses account SSL certs automatically if SSL is enabled on the proxy host
  • +
+
+ `, null, { cancelLabel: 'Close', showConfirm: false }); +};