diff --git a/panel/api/endpoints/accounts.php b/panel/api/endpoints/accounts.php index d206c03..1dfcfa6 100644 --- a/panel/api/endpoints/accounts.php +++ b/panel/api/endpoints/accounts.php @@ -69,23 +69,34 @@ match ($action) { $required = ['username','domain','email','password']; foreach ($required as $f) { if (empty($body[$f])) Response::error("$f is required"); } - // Create user account - $userId = (int)$db->insert( - "INSERT INTO users (username, password, email, role, status, reseller_id) VALUES (?,?,?,?,?,?)", - [ - $body['username'], - password_hash($body['password'], PASSWORD_BCRYPT), - $body['email'], - 'user', - 'active', - $user['role'] === 'reseller' ? $user['uid'] : null, - ] - ); - $body['user_id'] = $userId; + if (!filter_var($body['email'], FILTER_VALIDATE_EMAIL)) Response::error("Invalid email address"); + if ($db->fetchOne("SELECT id FROM users WHERE email = ?", [$body['email']])) Response::error("Email already in use by another account"); + if ($db->fetchOne("SELECT id FROM users WHERE username = ?", [$body['username']])) Response::error("Username already taken"); + + // Wrap user creation + account provisioning in a single transaction + $db->beginTransaction(); + try { + $userId = (int)$db->insert( + "INSERT INTO users (username, password, email, role, status, reseller_id) VALUES (?,?,?,?,?,?)", + [ + $body['username'], + password_hash($body['password'], PASSWORD_BCRYPT), + $body['email'], + 'user', + 'active', + $user['role'] === 'reseller' ? $user['uid'] : null, + ] + ); + $body['user_id'] = $userId; + + $result = AccountManager::create($body); + $db->commit(); + } catch (Throwable $e) { + $db->rollBack(); + throw $e; + } - $result = AccountManager::create($body); audit('account.create', $body['domain'], $result); - // Send welcome email to user + admin notification Notifier::accountCreated(array_merge($body, ['email' => $body['email']]), $body['password']); Response::success($result, 'Account created successfully'); })(), diff --git a/panel/assets/js/admin.js b/panel/assets/js/admin.js index 237656c..2249b14 100644 --- a/panel/assets/js/admin.js +++ b/panel/assets/js/admin.js @@ -93,6 +93,7 @@ docker, 'ssl-manager': sslManager, firewall, + fail2ban, 'audit-log': auditLog, twofa, updates, @@ -262,11 +263,12 @@ } // ── Updates ──────────────────────────────────────────────────────────────── - async function updates() { + async function updates(force = false) { + const qp = force ? { force: 1 } : {}; const [ver, ncpxCheck, osCheck] = await Promise.all([ Nova.api('system', 'version'), - Nova.api('system', 'check-novacpx-update'), - Nova.api('system', 'check-os-update'), + Nova.api('system', 'check-novacpx-update', { params: qp }), + Nova.api('system', 'check-os-update', { params: qp }), ]); const v = ver?.data || {}; const ncpx = ncpxCheck?.data || {}; @@ -274,10 +276,13 @@ const ncpxCount = ncpx.updates_available || 0; const osCount = os.upgradable || 0; - return ` + const html = ` @@ -295,8 +300,8 @@

Installed

${v.installed_version || '—'}

-

Commit

${ncpx.current_commit || v.git_commit || '—'}
-

Branch

${ncpx.branch || 'main'}
+

Latest (${ncpx.channel || 'stable'})

${ncpx.remote_version || (ncpxCount > 0 ? 'available' : v.installed_version || '—')}

+

Channel

${Nova.badge(ncpx.channel || 'stable', ncpx.channel === 'beta' ? 'yellow' : 'green')}

PHP

${v.php_version || '—'}
@@ -316,6 +321,20 @@
+ +
+
+ + + Installed Services + + +
+
+
Loading service inventory…
+
+
+
@@ -348,8 +367,42 @@ ` : `

All OS packages are current.

`}
`; + + setTimeout(loadServiceVersions, 80); + return html; } + window.forceRefreshUpdates = () => { + const content = document.getElementById('page-content'); + if (!content) return; + content.innerHTML = '
Checking for updates…
'; + updates(true).then(html => { if (html) content.innerHTML = html; }); + }; + + window.loadServiceVersions = async () => { + const body = document.getElementById('svc-versions-body'); + if (!body) return; + body.innerHTML = '
Scanning installed services…
'; + const r = await Nova.api('system', 'service-versions'); + const svcs = r?.data?.services || []; + if (!svcs.length) { body.innerHTML = '

No tracked services found.

'; return; } + const statusDot = s => s === 'active' + ? '● running' + : s === null ? '' + : '● stopped'; + body.innerHTML = `
+ + ${svcs.map(s => ` + + + + + + + `).join('')} +
ServiceDescriptionInstalledLatestStatusState
${Nova.escHtml(s.label)}
${Nova.escHtml(s.pkg)}
${Nova.escHtml(s.desc)}${Nova.escHtml(s.installed)}${Nova.escHtml(s.latest)}${s.up_to_date === true ? Nova.badge('current','green') : s.up_to_date === false ? Nova.badge('update available','yellow') : ''}${statusDot(s.status)}
`; + }; + // ── Audit Log ────────────────────────────────────────────────────────────── async function auditLog(opts = {}) { const { page = 1, user = '', action = '', date_from = '', date_to = '' } = opts; @@ -594,22 +647,65 @@ }); }; - window.phpExtInstall = async (ver) => { + const _phpExtStream = (ver, ext, action) => { + const termId = 'phpext-term-' + Date.now(); + const verb = action === 'install-extension' ? 'Installing' : 'Removing'; + Nova.modal(`${verb} ${ext} (PHP ${ver})`, ` +
+ Starting…\n +
`, + ``); + const term = document.getElementById(termId); + const append = t => { term.textContent += t; term.scrollTop = term.scrollHeight; }; + fetch(`/api/php/${action}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ version: ver, extension: ext }), + credentials: 'same-origin', + }).then(resp => { + if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; } + const reader = resp.body.getReader(); + const dec = new TextDecoder(); + let buf = ''; + const read = () => reader.read().then(({ done, value }) => { + if (done) { append('\n[done]'); return; } + buf += dec.decode(value, { stream: true }); + const parts = buf.split('\n\n'); + buf = parts.pop(); + for (const part of parts) { + const m = part.match(/^data: (.+)$/m); + if (!m) continue; + try { + const obj = JSON.parse(m[1]); + if (obj.line) { append(obj.line); } + else if (obj.done) { + const btn = document.getElementById('phpext-close'); + if (btn) { + btn.textContent = obj.success ? 'Done ✓' : 'Close'; + btn.className = obj.success ? 'btn btn-primary' : 'btn btn-ghost'; + btn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); phpExtModal(ver); }; + } + } + } catch(e) {} + } + read(); + }).catch(err => append(`\n[error: ${err.message}]`)); + read(); + }).catch(err => append(`\nFetch error: ${err.message}`)); + }; + + window.phpExtInstall = (ver) => { const sel = document.getElementById('php-ext-add-sel')?.value; const custom = document.getElementById('php-ext-add-custom')?.value?.trim(); const ext = custom || sel; if (!ext) { Nova.toast('Choose or type an extension name', 'error'); return; } - Nova.toast(`Installing ${ext} for PHP ${ver}…`, 'info', 15000); - const r = await Nova.api('php', 'install-extension', { method: 'POST', body: { version: ver, extension: ext } }); - if (r?.success) { Nova.toast(r.message, 'success'); phpExtModal(ver); } - else Nova.toast(r?.message || 'Install failed', 'error'); + _phpExtStream(ver, ext, 'install-extension'); }; window.phpExtRemove = (ver, ext) => { - Nova.confirm(`Remove extension ${ext} from PHP ${ver}?`, async () => { - const r = await Nova.api('php', 'remove-extension', { method: 'POST', body: { version: ver, extension: ext } }); - if (r?.success) { Nova.toast(r.message, 'success'); phpExtModal(ver); } - else Nova.toast(r?.message || 'Remove failed', 'error'); + Nova.confirm(`Remove extension ${ext} from PHP ${ver}?`, () => { + _phpExtStream(ver, ext, 'remove-extension'); }, true); }; @@ -617,6 +713,7 @@ async function notifications() { const res = await Nova.api('system', 'notify-settings'); const s = res?.data || {}; + setTimeout(etLoadList, 80); return ` @@ -660,18 +757,12 @@
-
Notification Triggers
-
- - - - - - - - -
EventRecipientNotes
Account CreatedNew user + AdminSends welcome email with credentials
Account SuspendedAccount holder + AdminIncludes suspension reason
Disk Quota ≥85%Account holder + AdminOnce per day per account (cron)
SSL Expiry ≤14 daysAccount holder + AdminOnce per threshold per domain (cron)
-

Disk quota and SSL expiry checks run daily via cron.

+
+ Email Templates + +
+
+
Loading templates…
`; } @@ -695,24 +786,150 @@ else Nova.toast(res?.message || 'Send failed', 'error'); }; + // ── Email Template Management ────────────────────────────────────────────── + window.etLoadList = async () => { + const body = document.getElementById('et-list-body'); + if (!body) return; + body.innerHTML = '
Loading templates…
'; + const r = await Nova.api('system', 'email-templates'); + const tmpls = r?.data?.templates || []; + if (!tmpls.length) { + body.innerHTML = '

No templates found. Create one.

'; + return; + } + body.innerHTML = `
+ + ${tmpls.map(t => ` + + + + + + `).join('')} +
TriggerLabelSubjectStatusActions
${Nova.escHtml(t.trigger_key)}${Nova.escHtml(t.label)}${Nova.escHtml(t.subject)}${t.enabled ? Nova.badge('enabled','green') : Nova.badge('disabled','red')} + + + +
`; + }; + + window.etEdit = async (id) => { + const r = await Nova.api('system', 'email-template-get', { method: 'POST', body: { id } }); + if (!r?.success) { Nova.toast(r?.message || 'Load failed', 'error'); return; } + const t = r.data; + Nova.modal(id ? `Edit Template: ${t.label}` : 'New Template', ` +
+ +
+
+ +
+
+ +
+
+ +
`, + ` + ` + ); + }; + + window.etNew = () => { + Nova.modal('New Email Template', ` +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
`, + ` + ` + ); + }; + + window.etSave = async (id) => { + const subject = document.getElementById('et-subject')?.value?.trim(); + const body_html = document.getElementById('et-html')?.value?.trim(); + const body_text = document.getElementById('et-text')?.value?.trim(); + const enabled = document.getElementById('et-enabled')?.value ?? '1'; + if (!subject || !body_html) { Nova.toast('Subject and HTML body required', 'error'); return; } + const extra = id ? {} : { + trigger_key: document.getElementById('et-trigger')?.value?.trim(), + label: document.getElementById('et-label')?.value?.trim(), + }; + const r = await Nova.api('system', 'email-template-save', { method: 'POST', body: { id, subject, body_html, body_text, enabled, ...extra } }); + if (r?.success) { + Nova.toast('Template saved', 'success'); + document.querySelector('.modal-overlay')?.remove(); + etLoadList(); + } else { Nova.toast(r?.message || 'Save failed', 'error'); } + }; + + window.etDelete = (id, label) => { + Nova.confirm(`Delete template "${label}"? This cannot be undone.`, async () => { + const r = await Nova.api('system', 'email-template-delete', { method: 'POST', body: { id } }); + if (r?.success) { Nova.toast('Template deleted', 'success'); etLoadList(); } + else Nova.toast(r?.message || 'Delete failed', 'error'); + }, true); + }; + + window.etSendTest = async (id) => { + const email = prompt('Send test email to:'); + if (!email) return; + const r = await Nova.api('system', 'email-template-test', { method: 'POST', body: { id, to: email } }); + if (r?.success) Nova.toast(r.message, 'success'); + else Nova.toast(r?.message || 'Send failed', 'error'); + }; + // ── Settings ─────────────────────────────────────────────────────────────── async function settings() { + const r = await Nova.api('system', 'server-options'); + const o = r?.data || {}; + const cur = { + panel_name: o.panel_name || 'NovaCPX', + default_php: o.default_php || '8.3', + ns1: o.default_nameserver1 || '', + ns2: o.default_nameserver2 || '', + channel: o.update_channel || 'stable', + }; + const phpOpts = ['7.4','8.1','8.2','8.3'].map(v => + ``).join(''); + const chanOpts = [ + ['stable', 'Stable — major releases (main branch)'], + ['beta', 'Beta — minor & patch releases (beta branch)'], + ].map(([v, l]) => ``).join(''); return `
Panel Settings
-
+
-
+
- +
-
-
-
- +
+
+
+ + +
+ Stable receives major releases pushed to main. + Beta tracks the beta branch for minor & patch releases. +
@@ -721,6 +938,25 @@
`; } + window.adminSaveSettings = async () => { + const btn = document.querySelector('#settings-form button[type=submit]'); + if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; } + const saves = [ + ['panel_name', document.getElementById('sf-panel-name')?.value?.trim()], + ['default_php', document.getElementById('sf-default-php')?.value], + ['default_nameserver1',document.getElementById('sf-ns1')?.value?.trim()], + ['default_nameserver2',document.getElementById('sf-ns2')?.value?.trim()], + ['update_channel', document.getElementById('sf-channel')?.value], + ].filter(([, v]) => v != null); + let ok = true; + for (const [key, value] of saves) { + const res = await Nova.api('system', 'save-option', { method: 'POST', body: { key, value } }); + if (!res?.success) { ok = false; Nova.toast(`Failed to save ${key}`, 'error'); break; } + } + if (ok) Nova.toast('Settings saved', 'success'); + if (btn) { btn.disabled = false; btn.textContent = 'Save Settings'; } + }; + // ── Accounts ─────────────────────────────────────────────────────────────── async function accounts() { const res = await Nova.api('accounts', 'list'); @@ -743,16 +979,17 @@ function renderAccountTable(accts) { if (!accts.length) return '
No accounts found.
'; - return ` + return `
UsernameDomainPackagePHPStatusCreatedActions
${accts.map(a => ` - - - - + + + +
UsernameDomainOwnerPackageStatusCreatedActions
${a.username}${a.domain}${a.package_name || ''}${a.php_version || '—'}${Nova.escHtml(a.username)}${Nova.escHtml(a.domain)}${a.reseller_username ? `${Nova.escHtml(a.reseller_username)}` : 'Admin'}${a.package_name ? Nova.escHtml(a.package_name) : ''} ${Nova.badge(a.status, a.status==='active'?'green':a.status==='suspended'?'yellow':'red')} ${Nova.relTime(a.created_at)} - + + ${a.status==='active' ? `` : ``} @@ -763,6 +1000,19 @@
`; } + window.adminLoginAs = async (userId, username) => { + Nova.confirm(`Login as ${username}? You'll be taken to their panel. Use the banner to return.`, async () => { + Nova.loading(`Switching to ${username}…`); + const res = await Nova.api('auth', 'impersonate', { method: 'POST', body: { user_id: userId } }); + Nova.loadingDone(); + if (res?.success) { + window.location.href = res.data?.portal_url || location.origin + "/"; + } else { + Nova.toast(res?.message || 'Impersonation failed', 'error'); + } + }); + }; + window.adminSearchAccounts = async (q) => { const res = await Nova.api('accounts', 'list', { params: q ? { search: q } : {}}); const el = document.getElementById('admin-acct-table'); @@ -793,28 +1043,51 @@ }; window.adminEditAccount = async (id) => { - const [acctRes, pkgRes] = await Promise.all([ + Nova.loading('Loading account…'); + const [acctRes, pkgRes, usersRes, dnsRes] = await Promise.all([ Nova.api('accounts', 'get', { params: { id } }), Nova.api('packages', 'list'), + Nova.api('users', 'list', { params: { role: 'reseller' } }), + Nova.api('dns', 'zones'), ]); + Nova.loadingDone(); if (!acctRes?.success) { Nova.toast(acctRes?.message || 'Failed to load account', 'error'); return; } const a = acctRes.data; const pkgs = pkgRes?.data || []; + const resellers = (usersRes?.data || []).filter(u => u.role === 'reseller'); + const zone = (dnsRes?.data || []).find(z => z.account_id == id || z.domain === a.domain); + const pkgOpts = `` + pkgs.map(p => ``).join(''); const phpOpts = ['8.3','8.2','8.1','7.4'].map(v => ``).join(''); + const ownerOpts = `` + + resellers.map(r => ``).join(''); Nova.modal(`Edit Account — ${Nova.escHtml(a.username)}`, `
+
+
+
+
-
-
+
+
+
+
+
DNS Zone — ${Nova.escHtml(a.domain)}
+
+
+
+
+
+
+ ${zone ? `
Zone ID: ${zone.id}  ·  Serial: ${zone.serial}
` : '
No DNS zone found for this account
'}
`, ` ` @@ -825,10 +1098,13 @@ const body = { id, email: document.getElementById('ae-email')?.value?.trim(), + reseller_id: document.getElementById('ae-owner')?.value || null, package_id: document.getElementById('ae-pkg')?.value || null, php_version: document.getElementById('ae-php')?.value, + ns1: document.getElementById('ae-ns1')?.value?.trim(), + ns2: document.getElementById('ae-ns2')?.value?.trim(), }; - Nova.loading('Saving…'); + Nova.loading('Saving account…'); const res = await Nova.api('accounts', 'update', { method: 'POST', body }); Nova.loadingDone(); if (res?.success) { @@ -890,7 +1166,7 @@ // ── Resellers ────────────────────────────────────────────────────────────── async function resellers() { - const res = await Nova.api('accounts', 'list', { params:{ role: 'reseller' }}); + const res = await Nova.api('users', 'list', { params:{ role: 'reseller' }}); const rows = res?.data || []; return `
@@ -899,14 +1175,18 @@
- ${rows.length ? ` + ${rows.length ? `
UsernameEmailAccountsStatusActions
${rows.map(r => ` - - - + + + + `).join('')}
UsernameEmailStatusCreatedActions
${r.username}${r.email||'—'}${r.account_count||0}${Nova.badge(r.status,r.status==='active'?'green':'red')}${Nova.escHtml(r.username)}${Nova.escHtml(r.email||'—')}${Nova.badge(r.status, r.status==='active'?'green':'red')}${r.created_at ? r.created_at.slice(0,10) : '—'} - - + + ${r.status === 'active' + ? `` + : ``} +
` @@ -917,10 +1197,51 @@ window.adminAddReseller = () => { Nova.modal('Create Reseller Account', ` -
-
-
`, - ``); +
+
+
`, + ``); + }; + + window.adminResellerPasswd = (id, user) => { + Nova.modal(`Change Password — ${user}`, + `
`, + ``); + }; + + window.adminResellerSuspend = (id, user) => { + Nova.confirm(`Suspend reseller ${user}?`, async () => { + const r = await Nova.api('users','suspend',{method:'POST',body:{id}}); + if (r?.success) { Nova.toast('Suspended','success'); adminPage('resellers'); } + else Nova.toast(r?.message||'Error','error'); + }); + }; + + window.adminResellerUnsuspend = async (id) => { + const r = await Nova.api('users','unsuspend',{method:'POST',body:{id}}); + if (r?.success) { Nova.toast('Unsuspended','success'); adminPage('resellers'); } + else Nova.toast(r?.message||'Error','error'); + }; + + window.adminResellerDelete = (id, user) => { + Nova.confirm(`Delete reseller ${user}? Their accounts will be disowned (moved to admin).`, async () => { + const r = await Nova.api('users','delete',{method:'POST',body:{id}}); + if (r?.success) { Nova.toast('Reseller deleted','success'); adminPage('resellers'); } + else Nova.toast(r?.message||'Error','error'); + }, true); }; // ── Packages ─────────────────────────────────────────────────────────────── @@ -1145,23 +1466,88 @@
`; } + const _sslStream = (domain, accountId, label) => { + const termId = 'ssl-term-' + Date.now(); + Nova.modal(`SSL: ${label || domain}`, ` +
+ Requesting certificate…\n +
`, + ``); + const term = document.getElementById(termId); + const append = t => { term.textContent += t; term.scrollTop = term.scrollHeight; }; + fetch('/api/ssl/issue', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ domain, account_id: accountId }), + credentials: 'same-origin', + }).then(resp => { + if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; } + const reader = resp.body.getReader(); + const dec = new TextDecoder(); + let buf = ''; + const read = () => reader.read().then(({ done, value }) => { + if (done) { append('\n[done]'); return; } + buf += dec.decode(value, { stream: true }); + const parts = buf.split('\n\n'); + buf = parts.pop(); + for (const part of parts) { + const m = part.match(/^data: (.+)$/m); + if (!m) continue; + try { + const obj = JSON.parse(m[1]); + if (obj.line) { append(obj.line); } + else if (obj.done) { + const btn = document.getElementById('ssl-term-close'); + if (btn) { + btn.textContent = obj.success ? 'Done ✓' : 'Close'; + btn.className = obj.success ? 'btn btn-primary' : 'btn btn-ghost'; + btn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); adminPage('ssl-manager'); }; + } + } + } catch(e) {} + } + read(); + }).catch(err => append(`\n[error: ${err.message}]`)); + read(); + }).catch(err => append(`\nFetch error: ${err.message}`)); + }; + window.adminIssueBulkSSL = async () => { - Nova.toast('Queuing SSL for all domains without certificates…','info',6000); const accts = await Nova.api('accounts','list',{params:{limit:1000}}); - let count = 0; - for (const a of (accts?.data || [])) { - await Nova.api('ssl','issue',{method:'POST',body:{domain:a.domain}}); - count++; + const domains = (accts?.data || []).map(a => ({domain: a.domain, id: a.id})); + if (!domains.length) { Nova.toast('No accounts found','error'); return; } + const termId = 'ssl-bulk-' + Date.now(); + Nova.modal('Bulk SSL Issuance', ` +
+ Starting bulk SSL for ${domains.length} domains…\n +
`, + ``); + const term = document.getElementById(termId); + const append = t => { term.textContent += t; term.scrollTop = term.scrollHeight; }; + let done = 0; + for (const a of domains) { + append(`\n[${++done}/${domains.length}] ${a.domain}…\n`); + try { + const r = await Nova.api('ssl','issue',{method:'POST',body:{domain:a.domain,account_id:a.id}}); + append(r?.success ? ` ✓ Issued\n` : ` ✗ ${r?.message || 'failed'}\n`); + } catch(e) { append(` ✗ ${e.message}\n`); } } - Nova.toast(`SSL issued for ${count} domains`,'success'); - adminPage('ssl-manager'); + append(`\nDone. ${done} domains processed.\n`); + const btn = document.getElementById('ssl-bulk-close'); + if (btn) { btn.textContent = 'Done'; btn.className = 'btn btn-primary'; btn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); adminPage('ssl-manager'); }; } }; - window.adminRenewCert = async (id) => { - Nova.toast('Renewing…','info'); - const r = await Nova.api('ssl','renew',{method:'POST',body:{cert_id:id}}); - if (r?.success) { Nova.toast('Renewed','success'); adminPage('ssl-manager'); } - else Nova.toast(r?.message,'error'); + + window.adminRenewCert = (id) => { + Nova.confirm('Renew this SSL certificate now?', async () => { + const r = await Nova.api('ssl','renew',{method:'POST',body:{cert_id:id}}); + if (r?.success) { Nova.toast('Renewed','success'); adminPage('ssl-manager'); } + else Nova.toast(r?.message,'error'); + }); }; + + window.adminIssueSingleSSL = (domain, accountId) => _sslStream(domain, accountId, domain); window.adminDelCert = (id, domain) => { Nova.confirm(`Delete SSL cert for ${domain}?`, async () => { const r = await Nova.api('ssl','delete',{method:'POST',body:{cert_id:id}}); @@ -1240,6 +1626,7 @@ const fwIgnoreips = ignoreipRes?.data?.ignoreip || ignoreipRes?.data?.detected || []; const rules = fw.rules || []; const active = fw.active; + const curLogging = fw.logging || 'off'; const totalBanned = jails.reduce((s,j) => s + (j.currently_banned||0), 0); @@ -1469,7 +1856,7 @@
@@ -1479,6 +1866,227 @@
`; } + // ── Fail2Ban Manager ──────────────────────────────────────────────────────── + async function fail2ban() { + const [f2bRes, cfgRes, ignoreipRes] = await Promise.all([ + Nova.api('firewall', 'f2b-status'), + Nova.api('firewall', 'f2b-config-get'), + Nova.api('firewall', 'f2b-ignoreip-list'), + ]); + const jails = f2bRes?.data?.jails || []; + const cfg = cfgRes?.data || { bantime: 3600, findtime: 600, maxretry: 5 }; + const ignoreips = ignoreipRes?.data?.ignoreip || ignoreipRes?.data?.detected || []; + const totalBanned = jails.reduce((s, j) => s + (j.currently_banned || 0), 0); + + return ` + + + +
+
Global Settings
+
+
+
+ + + How long IPs stay banned +
+
+ + + Window to count failures +
+
+ + + Failures before ban +
+ +
+
+
+ + +
+
+ Active Jails + ${jails.length} jail${jails.length !== 1 ? 's' : ''} +
+ ${jails.length ? ` +
+ + + + ${jails.map(j => ` + + + + + + `).join('')} + +
JailCurrently BannedTotal BannedFailed
${Nova.escHtml(j.jail)}${j.currently_banned > 0 + ? `${j.currently_banned}` + : '0'}${j.total_banned}${j.currently_failed} + ${j.currently_banned > 0 + ? `` + : ''} + +
+
` : `

Fail2Ban not running or no jails active.

`} +
+ + +
+
+ Whitelist (Never Ban) + ${ignoreips.length} entr${ignoreips.length !== 1 ? 'ies' : 'y'} + +
+
+
+ + +
+
+ ${ignoreips.map(ip => `${Nova.escHtml(ip)} ×`).join('')} +
+

Your own IP/subnet should always be whitelisted.

+
+
+ + +
+
+ Log Viewer +
+ + +
+
+
+
+ Click "Load Log" to view Fail2Ban activity. +
+
+
`; + } + + window.f2bSaveCfg = async () => { + const bantime = document.getElementById('f2b-bantime')?.value; + const findtime = document.getElementById('f2b-findtime')?.value; + const maxretry = document.getElementById('f2b-maxretry')?.value; + const r = await Nova.api('firewall', 'f2b-config-save', { method: 'POST', body: { bantime, findtime, maxretry } }); + Nova.toast(r?.message || (r?.success ? 'Saved' : 'Failed'), r?.success ? 'success' : 'error'); + }; + + window.f2bReloadCfg = async () => { + const r = await Nova.api('firewall', 'f2b-reload', { method: 'POST' }); + Nova.toast(r?.message || (r?.success ? 'Reloaded' : 'Failed'), r?.success ? 'success' : 'error'); + }; + + window.f2bRestartSvc = async () => { + const r = await Nova.api('firewall', 'f2b-restart', { method: 'POST' }); + Nova.toast(r?.message || (r?.success ? 'Restarted' : 'Failed'), r?.success ? 'success' : 'error'); + if (r?.success) adminPage('fail2ban'); + }; + + window.f2bViewJail = async (jail) => { + const r = await Nova.api('firewall', 'f2b-jail', { method: 'POST', body: { jail } }); + const d = r?.data || {}; + const ips = d.banned_ips || []; + Nova.modal(`Jail: ${jail}`, + `
+

Currently Banned

${d.currently_banned ?? 0}

+

Total Banned

${d.total_banned ?? 0}

+

Currently Failed

${d.currently_failed ?? 0}

+
+ ${ips.length ? ` + ${ips.map(ip => ` + + + `).join('')} +
IP Address
${Nova.escHtml(ip)}
` : '

No IPs currently banned.

'}` + ); + }; + + window.f2bBanModal = (jail) => { + Nova.modal(`Ban IP in jail: ${jail}`, + `
+ + +
`, + `` + ); + }; + + window.f2bBanSubmit = async (jail) => { + const ip = document.getElementById('f2b-ban-ip')?.value?.trim(); + if (!ip) return; + document.querySelector('.modal-overlay')?.remove(); + const r = await Nova.api('firewall', 'f2b-ban', { method: 'POST', body: { ip, jail } }); + Nova.toast(r?.message || (r?.success ? 'Banned' : 'Failed'), r?.success ? 'success' : 'error'); + if (r?.success) adminPage('fail2ban'); + }; + + window.f2bUnban = async (ip, jail) => { + document.querySelector('.modal-overlay')?.remove(); + const r = await Nova.api('firewall', 'f2b-unban', { method: 'POST', body: { ip, jail } }); + Nova.toast(r?.message || (r?.success ? 'Unbanned' : 'Failed'), r?.success ? 'success' : 'error'); + if (r?.success) adminPage('fail2ban'); + }; + + window.f2bWhitelistAdd = async () => { + const ip = document.getElementById('f2b-ignoreip-input')?.value?.trim(); + if (!ip) return; + const r = await Nova.api('firewall', 'f2b-ignoreip-add', { method: 'POST', body: { ip } }); + Nova.toast(r?.message || (r?.success ? 'Added' : 'Failed'), r?.success ? 'success' : 'error'); + if (r?.success) adminPage('fail2ban'); + }; + + window.f2bWhitelistRemove = async (ip) => { + const r = await Nova.api('firewall', 'f2b-ignoreip-remove', { method: 'POST', body: { ip } }); + Nova.toast(r?.message || (r?.success ? 'Removed' : 'Failed'), r?.success ? 'success' : 'error'); + if (r?.success) adminPage('fail2ban'); + }; + + window.f2bLoadLog = async () => { + const lines = document.getElementById('f2b-log-lines')?.value || 100; + const el = document.getElementById('f2b-log-content'); + if (el) el.innerHTML = 'Loading…'; + const r = await Nova.api('firewall', 'f2b-log', { method: 'POST', body: { lines: parseInt(lines) } }); + if (el) { + if (r?.success && r.data?.log) { + // Colorize: NOTICE=green, WARNING=yellow, ERROR/BAN=red, UNBAN=blue + const colored = Nova.escHtml(r.data.log) + .replace(/(NOTICE)/g, '$1') + .replace(/(WARNING)/g, '$1') + .replace(/\b(BAN)\b/g, '$1') + .replace(/\b(UNBAN)\b/g, '$1') + .replace(/(ERROR)/g, '$1'); + el.innerHTML = colored; + el.scrollTop = el.scrollHeight; + } else { + el.innerHTML = 'Failed to load log.'; + } + } + }; + function fwActionBadge(action) { const a = (action||'').toLowerCase(); if (a.includes('allow')) return Nova.badge('ALLOW','green'); @@ -1673,7 +2281,12 @@ ${ips.length ? ` window.fwSetLogging = async () => { const level = document.getElementById('fw-log-level')?.value; const r = await Nova.api('firewall','set-logging',{method:'POST',body:{level}}); - Nova.toast(r?.message || 'Logging updated', r?.success ? 'success' : 'error'); + if (r?.success) { + Nova.toast(`UFW logging set to ${level}`, 'success'); + adminPage('firewall'); + } else { + Nova.toast(r?.message || 'Logging update failed — UFW may need to be enabled first', 'error'); + } }; function fwIgnoreipChip(ip) { @@ -1718,13 +2331,15 @@ ${ips.length ? ` // ── MySQL/DB Manager ─────────────────────────────────────────────────────── async function mysqlManager() { - const [engRes, dbRes] = await Promise.all([ + const [engRes, dbRes, toolsRes] = await Promise.all([ Nova.api('system','db-engines'), Nova.api('databases','list',{params:{account_id:0}}), + Nova.api('system','db-tools'), ]); - const eng = engRes?.data?.engines || {}; - const actE = engRes?.data?.active_engine || 'mysql'; - const dbs = dbRes?.data || []; + const eng = engRes?.data?.engines || {}; + const actE = engRes?.data?.active_engine || 'mysql'; + const dbs = dbRes?.data || []; + const tools = toolsRes?.data || {}; const engineCard = (id, label, icon) => { const e = eng[id] || {}; @@ -1738,7 +2353,7 @@ ${ips.length ? ` ${e.version ? `v${e.version}` : ''}
-
+
${!e.installed ? `` : ` @@ -1748,8 +2363,31 @@ ${ips.length ? ` ` }
- ${e.installed && id !== 'postgresql' ? `phpMyAdmin ↗` : ''} - ${e.installed && id === 'postgresql' ? `pgAdmin ↗` : ''} +
+
`; + }; + + const toolCard = (id, label, icon, url) => { + const t = tools[id] || {}; + const statusColor = t.installed ? 'green' : 'default'; + const statusText = t.installed ? 'Installed' : 'Not Installed'; + return ` +
+
+ ${icon} ${label} + ${Nova.badge(statusText, statusColor)} + ${t.version ? `v${t.version}` : ''} +
+
+
+ ${!t.installed + ? `` + : ` + + + Open ↗` + } +
`; }; @@ -1787,6 +2425,16 @@ ${dbs.map(d=>` +
+
Database Admin Tools
+
+
+ ${toolCard('phpmyadmin', 'phpMyAdmin', '🛢', `http://${location.hostname}/phpmyadmin`)} + ${toolCard('pgadmin', 'pgAdmin 4', '🐘', `http://${location.hostname}/pgadmin4`)} +
+
+
+
All Databases${dbs.length} total
${dbTable} @@ -1821,6 +2469,142 @@ ${dbs.map(d=>` if (r?.success) adminPage('mysql-manager'); }; + window.dbToolAction = (tool, action) => { + const names = { phpmyadmin: 'phpMyAdmin', pgadmin: 'pgAdmin 4' }; + const name = names[tool] || tool; + const msgs = { + install: `Install ${name}?`, + reinstall: `Reinstall ${name}? The existing installation will be removed first.`, + remove: `Remove ${name}?`, + }; + + // pgAdmin needs an admin account — collect credentials before install/reinstall + if (tool === 'pgadmin' && action !== 'remove') { + Nova.modal(`${action === 'reinstall' ? 'Reinstall' : 'Install'} pgAdmin 4`, ` +

pgAdmin requires an admin account to be created on first run.

+
+
`, + ` + `); + return; + } + + const openTerminal = (extra = {}) => { + document.querySelector('.modal-overlay')?.remove(); + const termId = 'dbt-term-' + Date.now(); + const verb = action === 'remove' ? 'Removing' : action === 'reinstall' ? 'Reinstalling' : 'Installing'; + Nova.modal(`${verb} ${name}`, ` +
+ Starting…\n +
`, + ``); + + const term = document.getElementById(termId); + const append = t => { term.textContent += t; term.scrollTop = term.scrollHeight; }; + + fetch('/api/system/db-tools-stream', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tool, action, ...extra }), + credentials: 'same-origin', + }).then(resp => { + if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; } + const reader = resp.body.getReader(); + const dec = new TextDecoder(); + let buf = ''; + const read = () => reader.read().then(({ done, value }) => { + if (done) { append('\n[stream closed]'); return; } + buf += dec.decode(value, { stream: true }); + const parts = buf.split('\n\n'); + buf = parts.pop(); + for (const part of parts) { + const m = part.match(/^data: (.+)$/m); + if (!m) continue; + try { + const obj = JSON.parse(m[1]); + if (obj.line) { append(obj.line); } + else if (obj.error) { append(`\n✗ ${obj.error}\n`); } + else if (obj.done) { + const btn = document.getElementById('dbt-term-close'); + if (btn) { + btn.textContent = 'Done'; + btn.className = 'btn btn-primary'; + btn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); adminPage('mysql-manager'); }; + } + } + } catch(e) {} + } + read(); + }).catch(err => append(`\n[error: ${err.message}]`)); + read(); + }).catch(err => append(`\nFetch error: ${err.message}`)); + }; + + Nova.confirm(msgs[action], () => openTerminal(), action === 'remove'); + }; + + window.dbToolRunInstall = (tool, action) => { + const email = document.getElementById('pga-email')?.value?.trim(); + const pass = document.getElementById('pga-pass')?.value; + if (!email) { Nova.toast('Email is required','error'); return; } + if (!pass) { Nova.toast('Password is required','error'); return; } + // Re-invoke with credentials — openTerminal will close the form modal first + const names = { phpmyadmin: 'phpMyAdmin', pgadmin: 'pgAdmin 4' }; + const name = names[tool] || tool; + const msgs = { install: `Install ${name}?`, reinstall: `Reinstall ${name}?` }; + const doOpen = () => { + document.querySelector('.modal-overlay')?.remove(); + const termId = 'dbt-term-' + Date.now(); + const verb = action === 'reinstall' ? 'Reinstalling' : 'Installing'; + Nova.modal(`${verb} ${name}`, ` +
+ Starting…\n +
`, + ``); + const term = document.getElementById(termId); + const append = t => { term.textContent += t; term.scrollTop = term.scrollHeight; }; + fetch('/api/system/db-tools-stream', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tool, action, pga_email: email, pga_pass: pass }), + credentials: 'same-origin', + }).then(resp => { + if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; } + const reader = resp.body.getReader(); + const dec = new TextDecoder(); + let buf = ''; + const read = () => reader.read().then(({ done, value }) => { + if (done) { append('\n[stream closed]'); return; } + buf += dec.decode(value, { stream: true }); + const parts = buf.split('\n\n'); + buf = parts.pop(); + for (const part of parts) { + const m = part.match(/^data: (.+)$/m); + if (!m) continue; + try { + const obj = JSON.parse(m[1]); + if (obj.line) { append(obj.line); } + else if (obj.error) { append(`\n✗ ${obj.error}\n`); } + else if (obj.done) { + const btn = document.getElementById('dbt-term-close'); + if (btn) { + btn.textContent = 'Done'; + btn.className = 'btn btn-primary'; + btn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); adminPage('mysql-manager'); }; + } + } + } catch(e) {} + } + read(); + }).catch(err => append(`\n[error: ${err.message}]`)); + read(); + }).catch(err => append(`\nFetch error: ${err.message}`)); + }; + doOpen(); + }; + // ── Mail Server ──────────────────────────────────────────────────────────── async function mailServer() { const r = await Nova.api('system','stats'); @@ -1903,7 +2687,7 @@ ${dbs.map(d=>` window.applyNovaCPXUpdate = async () => { Nova.confirm('Apply NovaCPX update? PHP syntax is checked first, and a backup is taken automatically. The panel will self-restore if anything breaks.', async () => { Nova.loading('Pulling NovaCPX update from GitHub…'); - const res = await Nova.api('system', 'apply-novacpx-update', { method: 'POST' }); + const res = await Nova.api('system', 'apply-update', { method: 'POST' }); Nova.loadingDone(); const d = res?.data; if (!res?.success) { @@ -2088,32 +2872,129 @@ window.wpInstallModal = () => { Nova.modal('Install WordPress', `
-
+
+ +
+ + +
+

Install at site root (/) unless you want WordPress at a subdirectory.

+
-

wp-cli will be downloaded automatically if not installed. This may take 1-2 minutes.

`, +

wp-cli will be downloaded automatically if not installed. Installation takes 1-2 minutes — a live terminal will show progress.

`, ` `); }; -window.wpSubmitInstall = async () => { - const btn = document.getElementById('wp-install-btn'); - if (btn) { btn.disabled = true; btn.textContent = 'Installing…'; } - Nova.toast('Installing WordPress — this may take 1-2 minutes…', 'info', 90000); - const res = await Nova.api('wordpress','install',{method:'POST',body:{ - account_id: +document.getElementById('wp-acct')?.value, - domain: document.getElementById('wp-domain')?.value, - path: document.getElementById('wp-path')?.value || '/', - site_title: document.getElementById('wp-title')?.value, - admin_user: document.getElementById('wp-admin')?.value, - admin_pass: document.getElementById('wp-adminpass')?.value, - admin_email:document.getElementById('wp-email')?.value, - }}); +window.wpPathPreset = (sel) => { + const pathInput = document.getElementById('wp-path'); + if (sel.value === '__custom') { + pathInput.style.display = ''; + pathInput.value = '/'; + pathInput.focus(); + } else { + pathInput.style.display = 'none'; + pathInput.value = sel.value; + } +}; + +window.wpSubmitInstall = () => { + const acctId = +document.getElementById('wp-acct')?.value; + const domain = document.getElementById('wp-domain')?.value?.trim(); + const preset = document.getElementById('wp-path-preset')?.value; + const path = preset === '__custom' + ? (document.getElementById('wp-path')?.value?.trim() || '/') + : (preset || '/'); + const title = document.getElementById('wp-title')?.value?.trim(); + const admin = document.getElementById('wp-admin')?.value?.trim(); + const pass = document.getElementById('wp-adminpass')?.value; + const email = document.getElementById('wp-email')?.value?.trim(); + + if (!domain) { Nova.toast('Domain is required','error'); return; } + if (!title) { Nova.toast('Site title is required','error'); return; } + if (!admin) { Nova.toast('Admin username is required','error'); return; } + if (!pass) { Nova.toast('Admin password is required','error'); return; } + if (!email) { Nova.toast('Admin email is required','error'); return; } + + // Close form modal, open terminal modal document.querySelector('.modal-overlay')?.remove(); - if (res?.success) { Nova.toast('WordPress installed!','success'); adminPage('wordpress'); } - else Nova.toast(res?.message || 'Install failed','error'); + + const termId = 'wp-term-' + Date.now(); + Nova.modal('Installing WordPress', ` +
+ Connecting to server…\n +
`, + ``); + + const term = document.getElementById(termId); + const append = (text) => { + term.textContent += text; + term.scrollTop = term.scrollHeight; + }; + + const body = JSON.stringify({ account_id: acctId, domain, path, site_title: title, + admin_user: admin, admin_pass: pass, admin_email: email }); + + fetch(`/api/wordpress/install-stream`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + credentials: 'same-origin', + }).then(resp => { + if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; } + const reader = resp.body.getReader(); + const dec = new TextDecoder(); + let buf = ''; + const read = () => reader.read().then(({ done, value }) => { + if (done) { append('\n[stream closed]'); return; } + buf += dec.decode(value, { stream: true }); + const parts = buf.split('\n\n'); + buf = parts.pop(); + for (const part of parts) { + const m = part.match(/^data: (.+)$/m); + if (!m) continue; + try { + const obj = JSON.parse(m[1]); + if (obj.line) { + // Check for __DONE__ sentinel + if (obj.line.startsWith('__DONE__')) { + try { + const result = JSON.parse(obj.line.slice(8)); + append(`\n✓ WordPress installed! Admin: ${result.admin_user} | ID #${result.id}\n`); + } catch(e) { append('\n✓ WordPress installed!\n'); } + } else { + append(obj.line); + } + } else if (obj.error) { + append(`\n✗ Error: ${obj.error}\n`); + } else if (obj.done) { + const cancelBtn = document.getElementById('wp-term-cancel'); + if (cancelBtn) { + cancelBtn.textContent = 'Done'; + cancelBtn.className = 'btn btn-primary'; + cancelBtn.onclick = () => { + document.querySelector('.modal-overlay')?.remove(); + adminPage('wordpress'); + }; + } + } + } catch(e) {} + } + read(); + }).catch(err => append(`\n[connection error: ${err.message}]`)); + read(); + }).catch(err => append(`\nFetch error: ${err.message}`)); }; window.wpUpdate = async (id, type) => { @@ -2545,28 +3426,43 @@ 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 ` @@ -2662,9 +3574,13 @@ window.proxyInstall = async () => { }; window.proxyControl = async (action) => { + Nova.loading(action.charAt(0).toUpperCase() + action.slice(1) + 'ing nginx…'); 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); + Nova.loadingDone(); + const ok = r?.success; + const msg = r?.data?.result || r?.message || (ok ? action + ' done' : action + ' failed'); + Nova.toast(msg, ok ? 'success' : 'error'); + Nova.loadPage('nginx-proxy', window._novaPages); }; window.proxySync = async () => { @@ -2674,7 +3590,7 @@ window.proxySync = async () => { }; window.proxyAddHost = () => { - Nova.modal('Add Proxy Host', ` + const ov = Nova.modal('Add Proxy Host', `
@@ -2684,16 +3600,23 @@ window.proxyAddHost = () => {
- `, async () => { + `, + ` + ` + ); + ov.querySelector('#ph-save-btn').addEventListener('click', 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 btn = ov.querySelector('#ph-save-btn'); + btn.disabled = true; btn.textContent = 'Adding…'; 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); + if (r?.success) { ov.remove(); Nova.loadPage('nginx-proxy', window._novaPages); } + else { btn.disabled = false; btn.textContent = 'Add Host'; } }); }; @@ -2702,7 +3625,7 @@ window.proxyEditHost = async (id) => { const hosts = hostsR?.data || (Array.isArray(hostsR) ? hostsR : []); const h = hosts.find(x => x.id == id); if (!h) return; - Nova.modal('Edit Proxy Host', ` + const ov = Nova.modal('Edit Proxy Host', `
@@ -2712,7 +3635,13 @@ window.proxyEditHost = async (id) => {
Leave blank to use auto-generated config
- `, async () => { + `, + ` + ` + ); + ov.querySelector('#phe-save-btn').addEventListener('click', async () => { + const btn = ov.querySelector('#phe-save-btn'); + btn.disabled = true; btn.textContent = 'Saving…'; const r = await Nova.api('proxy', 'host', { method: 'PUT', body: { id, @@ -2723,7 +3652,8 @@ window.proxyEditHost = async (id) => { } }); Nova.toast(r?.success ? 'Updated' : (r?.message || 'Failed'), r?.success ? 'success' : 'error'); - if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages); + if (r?.success) { ov.remove(); Nova.loadPage('nginx-proxy', window._novaPages); } + else { btn.disabled = false; btn.textContent = 'Save Changes'; } }); }; @@ -2742,53 +3672,309 @@ window.proxyDeleteHost = (id, domain) => { }; 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 +
+ Designed for Proxmox (or any Linux hypervisor)
+ + Run NovaCPX on one VM and a lightweight Debian LXC as the nginx proxy. + The panel pushes configs and controls nginx via SSH. + Works equally well on VMware, AWS, DigitalOcean, bare-metal — see Option C below. +
-

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
  • +

    Option A — Proxmox LXC (Recommended)

    +

    Create a 512MB Debian 12 LXC on the same Proxmox node. Costs almost no resources.

    +
      +
    1. In Proxmox: Create CT → Debian 12 → 512MB RAM, 8GB disk, same bridge as NovaCPX VM
    2. +
    3. Boot the LXC, set root password
    4. +
    5. Go to Settings → set Mode=Remote, enter the LXC IP, root password, and this VM's IP as Backend IP
    6. +
    7. Click Run Setup on Remote VM — watch live progress
    8. +
    9. Point your router/firewall port 80/443 to the LXC IP
    10. +
    11. Click Sync Accounts to auto-populate proxy hosts
    12. +
    + +

    Option B — Other hypervisors (VMware, Hyper-V, KVM)

    +

    Same flow — any Debian/Ubuntu VM reachable by SSH works.

    +
      +
    1. Create a Debian/Ubuntu VM (1 vCPU, 512MB RAM)
    2. +
    3. Enable SSH root login: PermitRootLogin yes in /etc/ssh/sshd_config
    4. +
    5. Install sshpass on the NovaCPX server: apt-get install -y sshpass
    6. +
    7. Follow steps 3–6 from Option A above
    8. +
    + +

    Option C — Cloud / Remote Server (AWS, DigitalOcean, etc.)

    +

    NovaCPX pushes configs via public SSH. The proxy VM's public IP handles port 80/443; it forwards to NovaCPX over a private network or VPN.

    +
      +
    1. Provision a small Debian droplet/instance in the same region or with low latency to NovaCPX
    2. +
    3. Open port 22 (SSH) from NovaCPX's IP only; open 80/443 from anywhere
    4. +
    5. Set Backend IP to NovaCPX's IP reachable from the cloud proxy (use VPN/private IP if possible)
    6. +
    7. In Settings: set Remote Host to the cloud server's public IP or hostname
    8. +
    9. Click Run Setup, then Sync Accounts
    10. +
    + +

    Option D — Local nginx on this VM

    +

    Not recommended — requires moving Apache off port 80/443 first.

    +
      +
    1. Edit /etc/apache2/ports.conf → change Listen 80 to Listen 8090, restart Apache
    2. +
    3. Set Settings → Mode = Local, Backend IP = 127.0.0.1
    4. +
    5. Click Install Nginx Locally
    6. +
    7. Set upstream http://127.0.0.1:8090 on all proxy hosts
    8. +
    9. Click Sync Accounts
    10. +
    + +

    Settings Reference (Admin → Nginx Proxy → Settings)

    + + + + + + + +
    FieldDescription
    Modedisabled / remote / local
    Remote HostIP or hostname of nginx proxy VM (SSH target)
    Remote UserSSH user on proxy VM (default: root)
    Remote PasswordSSH password (stored encrypted in DB)
    Backend IPIP of this NovaCPX Apache — used in auto-generated proxy upstream URLs
    + +

    How it works

    +
      +
    • Each domain gets an nginx vhost config on the proxy VM, proxying to Apache on the backend IP
    • +
    • Configs are pushed automatically when accounts are created/terminated or manually via Sync Accounts
    • +
    • The panel starts/stops/reloads nginx on the proxy VM over SSH
    • +
    • Every 5 minutes the health check verifies nginx is running and restarts it if not
    • +
    • Use Uninstall to remove proxy configs or wipe nginx from the remote VM entirely
`, null, { cancelLabel: 'Close', showConfirm: false }); }; +window.proxySwitchLocal = () => { + const slOv = Nova.modal('Enable Local Nginx Proxy', ` +

Nginx will be installed on this server and take over ports 80/443. Apache moves to an internal port and keeps serving all PHP sites — end users see no change.

+
+ What will happen:
+ + 1. nginx installed (if not present)
+ 2. Apache moved from port 80 → 8090
+ 3. All existing vhosts updated
+ 4. nginx starts on port 80/443 and proxies to Apache
+ 5. Proxy hosts auto-synced from your accounts +
+
+
+ + +
+ `, + ` + ` + ); + slOv.querySelector('#sl-switch-btn').addEventListener('click', () => { + slOv.remove(); + const port = parseInt(document.getElementById('sl-port')?.value) || 8090; + const ov = Nova.modal('Switching to Local Proxy Mode', ` +

Moving Apache to port ${port} and starting nginx on 80/443…

+
Starting…\n
+ `, null, { cancelLabel: 'Close', showConfirm: false }); + + const log = document.getElementById('proxy-local-log'); + let done = false; + + fetch('/api/proxy/switch-local', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ apache_port: port }), + }).then(async res => { + const reader = res.body.getReader(); + const dec = new TextDecoder(); + let buf = ''; + while (true) { + const { value, done: d } = await reader.read(); + if (d) break; + buf += dec.decode(value, { stream: true }); + const parts = buf.split('\n\n'); + buf = parts.pop(); + for (const part of parts) { + const m = part.match(/^data: (.+)$/m); + if (!m) continue; + try { + const evt = JSON.parse(m[1]); + if (evt.line) { log.textContent += evt.line; log.scrollTop = log.scrollHeight; } + if (evt.done) { done = true; log.textContent += '\n— Done.\n'; setTimeout(() => Nova.loadPage('nginx-proxy', window._novaPages), 1500); } + } catch {} + } + } + }).catch(e => { log.textContent += '\n— Connection error: ' + e.message + '\n'; }); + + ov.querySelector('.modal-close')?.addEventListener('click', () => { done = true; }); + }); +}; + +window.proxyDisableLocal = () => { + Nova.confirm('Revert to direct Apache mode? nginx will be stopped and Apache will move back to port 80.', () => { + const ov = Nova.modal('Disabling Local Proxy Mode', ` +
Starting…\n
+ `, null, { cancelLabel: 'Close', showConfirm: false }); + const log = document.getElementById('proxy-disable-log'); + fetch('/api/proxy/disable-local', { method: 'POST', credentials: 'include' }).then(async res => { + const reader = res.body.getReader(); + const dec = new TextDecoder(); + let buf = ''; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + const parts = buf.split('\n\n'); + buf = parts.pop(); + for (const part of parts) { + const m = part.match(/^data: (.+)$/m); + if (m) try { + const evt = JSON.parse(m[1]); + if (evt.line) { log.textContent += evt.line; log.scrollTop = log.scrollHeight; } + if (evt.done) setTimeout(() => Nova.loadPage('nginx-proxy', window._novaPages), 1000); + } catch {} + } + } + }); + }, true); +}; + +window.proxyRunSetup = () => { + const ov = Nova.modal('Setting Up Remote Nginx Proxy', ` +

Running setup on the remote proxy VM — this takes about 30 seconds.

+
Connecting…\n
+ `); + + const log = document.getElementById('proxy-setup-log'); + let done = false; + + fetch('/api/proxy/setup-remote', { method: 'POST', credentials: 'include' }) + .then(async res => { + if (!res.ok) { log.textContent += '\n— Server error (' + res.status + '). Check remote host settings.\n'; return; } + const reader = res.body.getReader(); + const dec = new TextDecoder(); + let buf = ''; + while (true) { + const { value, done: d } = await reader.read(); + if (d) break; + buf += dec.decode(value, { stream: true }); + const parts = buf.split('\n\n'); + buf = parts.pop(); + for (const part of parts) { + const m = part.match(/^data: (.+)$/m); + if (!m) continue; + try { + const evt = JSON.parse(m[1]); + if (evt.line) { log.textContent += evt.line; log.scrollTop = log.scrollHeight; } + if (evt.done) { done = true; log.textContent += '\n— Done. Refreshing status…\n'; setTimeout(() => Nova.loadPage('nginx-proxy', window._novaPages), 1200); } + } catch {} + } + } + }) + .catch(e => { log.textContent += '\n— Connection error: ' + e.message + '\n'; }); + + ov.querySelector('.modal-close')?.addEventListener('click', () => { done = true; }); +}; + +window.proxyUninstall = () => { + const ov = Nova.modal('Uninstall Nginx Proxy', ` +

Choose what to remove from the remote proxy VM:

+
+
+ +
+ `, + ` + ` + ); + ov.querySelector('#uninst-btn').addEventListener('click', async () => { + const btn = ov.querySelector('#uninst-btn'); + btn.disabled = true; btn.textContent = 'Removing…'; + const full = ov.querySelector('input[name="uninst"]:checked')?.value === 'full'; + const r = await Nova.api('proxy', 'uninstall', { method: 'DELETE', body: { remove_nginx: full } }); + Nova.toast(r?.data?.result || r?.message || 'Done', r?.success ? 'success' : 'error'); + if (r?.success) { ov.remove(); Nova.loadPage('nginx-proxy', window._novaPages); } + else { btn.disabled = false; btn.textContent = 'Uninstall'; } + }); +}; + +window.proxySettings = async () => { + const r = await Nova.api('proxy', 'settings'); + const cfg = r?.data || {}; + const ov = Nova.modal('Nginx Proxy Settings', ` +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ `, + ` + ` + ); + ov.querySelector('#ps-save-btn').addEventListener('click', async () => { + const btn = ov.querySelector('#ps-save-btn'); + btn.disabled = true; btn.textContent = 'Saving…'; + 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) { ov.remove(); Nova.loadPage('nginx-proxy', window._novaPages); } + else { btn.disabled = false; btn.textContent = 'Save Settings'; } + }); +}; + +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'); @@ -2906,8 +4092,8 @@ async function docker() { ${(status.disk||[]).map(d=>`
${Nova.escHtml(d.Type||d.type||'?')}
${Nova.escHtml(d.TotalCount||d.Size||'—')}
${Nova.escHtml(d.Reclaimable||d.reclaimable||'')}
`).join('')}
- ${tab('containers','Containers')} ${tab('images','Images')} ${tab('volumes','Volumes')} ${tab('networks','Networks')} ${tab('stacks','Compose Stacks')} ${tab('quotas','User Quotas')} - + ${tab('containers','Containers')} ${tab('images','Images')} ${tab('volumes','Volumes')} ${tab('networks','Networks')} ${tab('stacks','Compose Stacks')} ${tab('catalog','App Catalog')} ${tab('quotas','User Quotas')} +
Loading…
`; } @@ -3002,11 +4188,29 @@ ${stacks.map(s=>` + `).join('')}
`}`; + } else if (tab === 'catalog') { + const r = await Nova.api('docker', 'catalog'); + const catalog = r?.data?.catalog || {}; + tc.innerHTML = ` +

One-click app deployment. Each app runs as an isolated Docker Compose stack. Select an account after clicking Launch.

+
+${Object.entries(catalog).map(([key,app])=>` +
+
+
${Nova.escHtml(app.icon)}
+
${Nova.escHtml(app.name)}
+
${Nova.escHtml(app.description)}
+ +
+
`).join('')} +
`; + } else if (tab === 'quotas') { const r = await Nova.api('accounts', 'list', { params: { limit: 200 } }); const users = r?.data || []; @@ -3024,6 +4228,47 @@ ${users.map(u=>` } } +window.dockerAdminLaunchApp = async (preselect) => { + const catRes = await Nova.api('docker', 'catalog'); + const catalog = catRes?.data?.catalog || {}; + const acctRes = await Nova.api('accounts', 'list', { params: { limit: 200 } }); + const accounts = acctRes?.data || []; + const appOpts = Object.entries(catalog).map(([k,a])=>``).join(''); + const acctOpts = accounts.map(a=>``).join(''); + + window.dockerAdminUpdateParams = (key) => { + const app = catalog[key]; if (!app) return; + const tc = document.getElementById('dal-params'); if (!tc) return; + tc.innerHTML = (app.params||[]).map(p=>` +
+
`).join(''); + }; + + const ov = Nova.modal('Launch App (Admin)', + `
+
+
`, + ` + ` + ); + dockerAdminUpdateParams(preselect || Object.keys(catalog)[0] || ''); + + window.dockerAdminLaunchSubmit = async () => { + const appKey = document.getElementById('dal-app').value; + const accountId = parseInt(document.getElementById('dal-account').value); + const app = catalog[appKey]; if (!app) return; + const params = {}; + (app.params||[]).forEach(p => { const el = document.getElementById(`dal-${p.key}`); if (el) params[p.key] = el.value.trim(); }); + const missing = (app.params||[]).filter(p => p.required && !params[p.key]); + if (missing.length) { Nova.toast(`Required: ${missing.map(p=>p.label).join(', ')}`, 'error'); return; } + ov.remove(); + Nova.toast(`Launching ${app.name}…`, 'info', 15000); + const r = await Nova.api('docker', 'launch', { method: 'POST', body: { app_key: appKey, account_id: accountId, params } }); + Nova.toast(r?.success ? `${app.name} launched!` : (r?.error || r?.message || 'Launch failed'), r?.success ? 'success' : 'error'); + if (r?.success) dockerLoadTab('stacks'); + }; +}; + window.dockerContainerAct = async (cid, action) => { const r = await Nova.api('docker', 'container-action', { method: 'POST', body: { container_id: cid, action } }); Nova.toast(r?.success ? `Container ${action}ed` : (r?.message || 'Failed'), r?.success ? 'success' : 'error'); @@ -3102,6 +4347,13 @@ window.dockerStackAct = async (id, action) => { } }; +window.dockerStackReinstall = (id) => Nova.confirm('Reinstall this stack? Latest images will be pulled and containers restarted. Data volumes are preserved.', async () => { + Nova.toast('Reinstalling stack…', 'info', 15000); + const r = await Nova.api('docker', 'stack-reinstall', { method: 'POST', body: { stack_id: id } }); + Nova.toast(r?.success ? 'Stack reinstalled' : (r?.message||'Reinstall failed'), r?.success?'success':'error'); + if (r?.success) dockerLoadTab('stacks'); +}, true); + window.dockerStackRemove = (id) => Nova.confirm('Remove this stack? Docker Compose down will be run first.', async () => { const r = await Nova.api('docker', 'stack-remove', { method: 'DELETE', body: { stack_id: id } }); Nova.toast(r?.success ? 'Stack removed' : (r?.message||'Failed'), r?.success?'success':'error'); @@ -3283,15 +4535,55 @@ async function serverOptions() {
`; } -window.soSave = async (key, inputId, label) => { +window.soSave = (key, inputId, label) => { const val = document.getElementById(inputId)?.value; if (!val) return; - Nova.confirm(`Switch ${label} to "${val}"? This will stop the current service and start the new one.`, async () => { - Nova.loading(`Switching ${label} to ${val}…`); - const r = await Nova.api('system', 'save-option', { method:'POST', body:{ key, value: val } }); - Nova.loadingDone(); - Nova.toast(r?.success ? `${label} switched to ${val}` : (r?.message || 'Failed'), r?.success ? 'success' : 'error'); - if (r?.success) adminPage('server-options'); + Nova.confirm(`Switch ${label} to "${val}"? This will stop the current service and start the new one.`, () => { + const termId = 'so-term-' + Date.now(); + Nova.modal(`Switching ${label} to ${val}`, ` +
+ Starting…\n +
`, + ``); + const term = document.getElementById(termId); + const append = t => { term.textContent += t; term.scrollTop = term.scrollHeight; }; + fetch('/api/system/service-switch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key, value: val }), + credentials: 'same-origin', + }).then(resp => { + if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; } + const reader = resp.body.getReader(); + const dec = new TextDecoder(); + let buf = ''; + const read = () => reader.read().then(({ done, value }) => { + if (done) { append('\n[stream closed]'); return; } + buf += dec.decode(value, { stream: true }); + const parts = buf.split('\n\n'); + buf = parts.pop(); + for (const part of parts) { + const m = part.match(/^data: (.+)$/m); + if (!m) continue; + try { + const obj = JSON.parse(m[1]); + if (obj.line) { append(obj.line); } + else if (obj.error) { append(`\n✗ ${obj.error}\n`); } + else if (obj.done) { + const btn = document.getElementById('so-term-close'); + if (btn) { + btn.textContent = 'Done'; + btn.className = 'btn btn-primary'; + btn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); adminPage('server-options'); }; + } + } + } catch(e) {} + } + read(); + }).catch(err => append(`\n[error: ${err.message}]`)); + read(); + }).catch(err => append(`\nFetch error: ${err.message}`)); }, true); };