/**
* NovaCPX Admin Panel — page controllers
*/
(async () => {
// ── Auth guard ─────────────────────────────────────────────────────────────
// Inline login handler on port 8882
let _loginCredentials = null;
const loginForm = document.getElementById('login-form');
if (loginForm) {
loginForm.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.getElementById('l-btn');
const err = document.getElementById('login-err');
btn.disabled = true; err.style.display = 'none';
// Step 2: TOTP code entry
const totpInput = document.getElementById('l-totp');
if (totpInput && _loginCredentials) {
btn.textContent = 'Verifying…';
const res = await Nova.api('auth', 'login', {
method: 'POST',
body: { ..._loginCredentials, totp_code: totpInput.value.trim() }
});
if (res?.success && res.data?.user?.role === 'admin') {
location.reload();
} else {
err.textContent = res?.message || 'Invalid 2FA code';
err.style.display = '';
btn.disabled = false; btn.textContent = 'Verify';
}
return;
}
// Step 1: username + password
btn.textContent = 'Signing in…';
const creds = { username: document.getElementById('l-user').value, password: document.getElementById('l-pass').value };
const res = await Nova.api('auth', 'login', { method: 'POST', body: creds });
if (res?.success && res.data?.user?.role === 'admin') {
location.reload();
} else if (res?.totp_required) {
// Show TOTP step
_loginCredentials = creds;
document.getElementById('l-user').closest('.form-group').style.display = 'none';
document.getElementById('l-pass').closest('.form-group').style.display = 'none';
const totpGroup = document.createElement('div');
totpGroup.className = 'form-group';
totpGroup.innerHTML = '2FA Code ';
loginForm.insertBefore(totpGroup, btn.parentNode || btn);
btn.textContent = 'Verify'; btn.disabled = false;
} else {
err.textContent = res?.message || 'Invalid credentials or insufficient role';
err.style.display = '';
btn.disabled = false; btn.textContent = 'Sign In to Admin';
}
});
}
const me = await Nova.api('auth', 'me');
if (!me?.success || me.data.role !== 'admin') {
// Already showing the login form in #auth-check
return;
}
document.getElementById('auth-check').style.display = 'none';
document.getElementById('app').style.display = '';
document.getElementById('user-name').textContent = me.data.username;
document.getElementById('user-avatar').textContent = me.data.username[0].toUpperCase();
// ── Logout ─────────────────────────────────────────────────────────────────
document.getElementById('logout-btn').addEventListener('click', async e => {
e.preventDefault();
await Nova.api('auth', 'logout', { method: 'POST' });
location.href = '/';
});
// ── Page definitions ───────────────────────────────────────────────────────
const pages = {
dashboard,
'server-status': serverStatus,
accounts,
resellers,
packages,
'create-account': createAccount,
'dns-zones': dnsZones,
nameservers,
'web-server': webServer,
'php-manager': phpManager,
'mysql-manager': mysqlManager,
'mail-server': mailServer,
'ftp-server': ftpServer,
'nginx-proxy': nginxProxy,
wordpress,
'ssl-manager': sslManager,
firewall,
'audit-log': auditLog,
twofa,
updates,
backups,
cloudflare,
settings,
};
window._novaPages = pages;
Nova.initNav(pages);
await Nova.loadPage('dashboard', pages);
checkUpdates();
// ── Dashboard ──────────────────────────────────────────────────────────────
async function dashboard() {
const [stats, version] = await Promise.all([
Nova.api('system', 'stats'),
Nova.api('system', 'version'),
]);
const s = stats?.data || {};
const v = version?.data || {};
document.getElementById('server-ip').textContent = '';
return `
CPU Usage
${s.cpu?.pct ?? 0}%
Load: ${(s.cpu?.load || [0,0,0]).join(' / ')}
${Nova.progressBar(s.cpu?.pct || 0)}
Memory
${s.ram?.pct ?? 0}%
${Nova.bytes((s.ram?.used_kb||0)*1024)} / ${Nova.bytes((s.ram?.total_kb||0)*1024)}
${Nova.progressBar(s.ram?.pct || 0)}
Disk
${s.disk?.pct ?? 0}%
${Nova.bytes(s.disk?.total - s.disk?.free || 0)} used
${Nova.progressBar(s.disk?.pct || 0)}
Uptime
${s.uptime || '—'}
PHP ${v.php_version || '—'}
${Object.entries(s.services || {}).map(([svc, status]) => `
${Nova.serviceDot(status)} ${svc}
${Nova.badge(status, status === 'active' ? 'green' : 'red')}
Restart
Stop
`).join('')}
Installed ${v.installed_version || '—'}
Branch ${v.git_branch || 'main'}
Commit ${v.git_commit || '—'}${v.git_dirty ? ' dirty ' : ''}
PHP ${v.php_version || '—'}
OS ${v.os || '—'}
Check for Updates
`;
}
// ── Server Status ──────────────────────────────────────────────────────────
async function serverStatus() {
const res = await Nova.api('system', 'stats');
const s = res?.data || {};
return `
CPU
${s.cpu?.pct}% ${Nova.progressBar(s.cpu?.pct||0)}
RAM
${s.ram?.pct}% ${Nova.progressBar(s.ram?.pct||0)}
Disk
${s.disk?.pct}% ${Nova.progressBar(s.disk?.pct||0)}
Load Average
${(s.cpu?.load||[]).join(' / ')}
`;
}
// ── Updates ────────────────────────────────────────────────────────────────
async function updates() {
const [ver, ncpxCheck, osCheck] = await Promise.all([
Nova.api('system', 'version'),
Nova.api('system', 'check-novacpx-update'),
Nova.api('system', 'check-os-update'),
]);
const v = ver?.data || {};
const ncpx = ncpxCheck?.data || {};
const os = osCheck?.data || {};
const ncpxCount = ncpx.updates_available || 0;
const osCount = os.upgradable || 0;
return `
Installed
${v.installed_version || '—'}
Commit
${ncpx.current_commit || v.git_commit || '—'}
Branch
${ncpx.branch || 'main'}
PHP
${v.php_version || '—'}
${ncpxCount > 0 ? `
${ncpx.commits?.map(c => `
${Nova.escHtml(c)}
`).join('') || 'None'}
PHP syntax is validated before deploy. If the panel goes down after update, it will automatically restore from backup.
Update NovaCPX
` : `
NovaCPX is up to date.
`}
${osCount > 0 ? `
Package From To
${os.packages?.map(p => `
${Nova.escHtml(p.name)}
${Nova.escHtml(p.from || '(new)')}
${Nova.escHtml(p.to)}
`).join('') || ''}
Services are automatically restarted if an upgrade stops them. The NovaCPX web root is backed up before upgrade and restored if panel ports go down.
Apply OS Upgrade
` : `
All OS packages are current.
`}
`;
}
// ── Audit Log ──────────────────────────────────────────────────────────────
async function auditLog() {
const res = await Nova.api('system', 'audit-log', { params: { per_page: 50 } });
const rows = res?.data || [];
return `
Time User Action Resource IP
${rows.map(r => `
${Nova.relTime(r.created_at)}
${r.username || '—'}
${r.action}
${r.resource || '—'}
${r.ip_address || '—'}
`).join('')}
`;
}
// ── PHP Manager ────────────────────────────────────────────────────────────
async function phpManager() {
return `
Manage installed PHP versions and global extensions.
${['7.4','8.1','8.2','8.3'].map(v => `
PHP ${v}
${Nova.badge('Active','green')}
Restart FPM
`).join('')}
Global PHP Extensions
Extensions installed across all PHP versions: mbstring, curl, gd, xml, zip, opcache, redis, imagick, pdo, pdo_mysql, pdo_pgsql
`;
}
// ── Settings ───────────────────────────────────────────────────────────────
async function settings() {
return `
`;
}
// ── Accounts ───────────────────────────────────────────────────────────────
async function accounts() {
const res = await Nova.api('accounts', 'list');
const accts = res?.data?.accounts || [];
window._adminAccts = accts;
return `
${renderAccountTable(accts)}
`;
}
function renderAccountTable(accts) {
if (!accts.length) return 'No accounts found.
';
return `Username Domain Reseller Package Disk Status Created Actions
${accts.map(a => `
${a.username}
${a.domain}
${a.reseller_username || 'admin '}
${a.package_name || '—'}
${a.disk_usage_mb || 0} MB
${Nova.badge(a.status, a.status==='active'?'green':a.status==='suspended'?'yellow':'red')}
${Nova.relTime(a.created_at)}
${a.status==='active'
? `Suspend `
: `Unsuspend `}
Passwd
Terminate
`).join('')}
`;
}
window.adminSearchAccounts = async (q) => {
const res = await Nova.api('accounts', 'list', { params: q ? { search: q } : {}});
const el = document.getElementById('admin-acct-table');
if (el) el.innerHTML = renderAccountTable(res?.data?.accounts || []);
};
window.adminSuspend = async (id, user) => {
Nova.confirm(`Suspend ${user}?`, async () => {
const res = await Nova.api('accounts','suspend',{method:'POST',body:{account_id:id}});
if (res?.success) { Nova.toast('Suspended','success'); adminPage('accounts'); }
else Nova.toast(res?.message,'error');
});
};
window.adminUnsuspend = async (id) => {
const res = await Nova.api('accounts','unsuspend',{method:'POST',body:{account_id:id}});
if (res?.success) { Nova.toast('Unsuspended','success'); adminPage('accounts'); }
else Nova.toast(res?.message,'error');
};
window.adminChangePass = (id, user) => {
Nova.modal(`Change Password — ${user}`, `New Password
`,
`Update `);
};
window.adminTerminate = (id, user) => {
Nova.confirm(`TERMINATE ${user}? This permanently deletes all files, DBs, DNS, email. IRREVERSIBLE.`, async () => {
const res = await Nova.api('accounts','terminate',{method:'POST',body:{account_id:id}});
if (res?.success) { Nova.toast('Terminated','success'); adminPage('accounts'); }
else Nova.toast(res?.message,'error');
}, true);
};
// ── Create Account ─────────────────────────────────────────────────────────
async function createAccount() {
const pkgRes = await Nova.api('packages', 'list');
const pkgOpts = (pkgRes?.data || []).map(p => `${p.name} — ${p.disk_mb}MB `).join('');
return `
`;
}
window.adminSubmitCreateAccount = async () => {
const res = await Nova.api('accounts','create',{method:'POST',body:{
username: document.getElementById('nca-user')?.value,
password: document.getElementById('nca-pass')?.value,
email: document.getElementById('nca-email')?.value,
domain: document.getElementById('nca-domain')?.value,
package_id: document.getElementById('nca-pkg')?.value,
php_version:document.getElementById('nca-php')?.value,
}});
const el = document.getElementById('nca-result');
if (res?.success) {
Nova.toast('Account created!','success');
if (el) el.innerHTML = ``;
} else {
Nova.toast(res?.message || 'Failed','error');
if (el) el.innerHTML = `${res?.message || 'Error creating account'}
`;
}
};
// ── Resellers ──────────────────────────────────────────────────────────────
async function resellers() {
const res = await Nova.api('accounts', 'list', { params:{ role: 'reseller' }});
const rows = res?.data?.accounts || [];
return `
${rows.length ? `
Username Email Accounts Status Actions
${rows.map(r => `
${r.username} ${r.email||'—'}
${r.account_count||0}
${Nova.badge(r.status,r.status==='active'?'green':'red')}
Passwd
Suspend
`).join('')}
`
: '
No resellers yet.
'}
`;
}
window.adminAddReseller = () => {
Nova.modal('Create Reseller Account', `
Username
Password
Email
`,
`Create `);
};
// ── Packages ───────────────────────────────────────────────────────────────
async function packages() {
const res = await Nova.api('packages', 'list');
const pkgs = res?.data || [];
return `
${pkgs.length ? `
Name Disk BW DBs Emails Price Accounts Actions
${pkgs.map(p => `
${p.name}
${p.disk_mb > 0 ? p.disk_mb+'MB' : '∞'}
${p.bandwidth_mb > 0 ? p.bandwidth_mb+'MB' : '∞'}
${p.databases||'∞'}
${p.email_accounts||'∞'}
${p.price ? '$'+p.price : 'Free'}
${p.account_count||0}
Edit
Del
`).join('')}
`
: '
No packages yet. Create one to start hosting accounts.
'}
`;
}
window.adminAddPkg = () => showAdminPkgModal();
window.adminEditPkg = async (id) => {
const r = await Nova.api('packages','get',{params:{id}});
if (r?.success) showAdminPkgModal(r.data);
};
function showAdminPkgModal(p = {}) {
Nova.modal(p.id ? 'Edit Package' : 'Add Package', `
`,
`Save `);
}
window.adminSavePkg = async (id) => {
const body = {name:document.getElementById('ap-name')?.value,disk_mb:+document.getElementById('ap-disk')?.value,bandwidth_mb:+document.getElementById('ap-bw')?.value,databases:+document.getElementById('ap-db')?.value,email_accounts:+document.getElementById('ap-email')?.value,addon_domains:+document.getElementById('ap-dom')?.value,subdomains:+document.getElementById('ap-sub')?.value,ftp_accounts:+document.getElementById('ap-ftp')?.value,price:+document.getElementById('ap-price')?.value};
const res = id ? await Nova.api('packages','update',{method:'POST',body:{...body,id}}) : await Nova.api('packages','create',{method:'POST',body});
if (res?.success) { Nova.toast(id?'Updated':'Created','success'); document.querySelector('.modal-overlay')?.remove(); adminPage('packages'); }
else Nova.toast(res?.message,'error');
};
window.adminDelPkg = (id, name) => {
Nova.confirm(`Delete package "${name}"?`, async () => {
const r = await Nova.api('packages','delete',{method:'POST',body:{id}});
if (r?.success) { Nova.toast('Deleted','success'); adminPage('packages'); }
else Nova.toast(r?.message,'error');
}, true);
};
// ── DNS Zones ──────────────────────────────────────────────────────────────
async function dnsZones() {
const res = await Nova.api('dns', 'zones');
const zones = res?.data || [];
return `
${zones.length ? `
Domain Account Records Actions
${zones.map(z => `
${z.domain}
${z.username||'—'}
${z.record_count||0}
Records
Del
`).join('')}
`
: '
No DNS zones yet.
'}
`;
}
window.adminAddZone = () => {
Nova.modal('Create DNS Zone', `Domain
`,
`Create `);
};
window.adminEditZone = async (id, domain) => {
const res = await Nova.api('dns', 'records', {params:{zone_id:id}});
if (!res?.success) { Nova.toast('Failed to load records','error'); return; }
const rows = res.data.map(r => `${r.name} ${Nova.badge(r.type,'default')} ${r.value}${r.ttl}
Del `).join('');
Nova.modal(`DNS: ${domain}`, `
+ Add Record
`);
};
window.adminAddRecord = (zoneId, domain) => {
Nova.modal('Add Record', `
Name
Type A AAAA CNAME MX TXT NS
Value
TTL
`,
`Add `);
};
window.adminDelRecord = async (id, zoneId, domain) => {
Nova.confirm('Delete this record?', async () => {
const r = await Nova.api('dns','delete-record',{method:'POST',body:{id}});
if (r?.success) { Nova.toast('Deleted','success'); document.querySelector('.modal-overlay')?.remove(); adminEditZone(zoneId,domain); }
else Nova.toast(r?.message,'error');
}, true);
};
window.adminDelZone = (id, domain) => {
Nova.confirm(`Delete DNS zone for ${domain}?`, async () => {
const r = await Nova.api('dns','delete-zone',{method:'POST',body:{zone_id:id}});
if (r?.success) { Nova.toast('Zone deleted','success'); adminPage('dns-zones'); }
else Nova.toast(r?.message,'error');
}, true);
};
// ── Nameservers ────────────────────────────────────────────────────────────
async function nameservers() {
const r = await Nova.api('server_setup','get');
const d = r?.data || {};
return `
`;
}
window.adminSaveNS = async () => {
const r = await Nova.api('server_setup','nameservers',{method:'POST',body:{ns1:document.getElementById('ns1')?.value,ns2:document.getElementById('ns2')?.value}});
if (r?.success) Nova.toast('Nameservers saved','success');
else Nova.toast(r?.message,'error');
};
window.adminSetHostname = async () => {
const r = await Nova.api('server_setup','set-hostname',{method:'POST',body:{hostname:document.getElementById('srvhost')?.value}});
if (r?.success) Nova.toast(`Hostname set to ${r.data?.hostname}`,'success');
else Nova.toast(r?.message,'error');
};
// ── Web Server ────────────────────────────────────────────────────────────
async function webServer() {
const r = await Nova.api('system', 'stats');
const svcs = r?.data?.services || {};
const webSvc = Object.keys(svcs).find(k => k.includes('apache') || k.includes('nginx')) || 'apache2';
return `
${Object.entries(svcs).map(([s,st]) => `
${s} ${Nova.badge(st,st==='active'?'green':'red')}
Restart
Start
Stop
`).join('')}
`;
}
// ── SSL Manager ────────────────────────────────────────────────────────────
async function sslManager() {
const res = await Nova.api('ssl', 'list', {params:{account_id:0}});
const certs = res?.data || [];
return `
${certs.length ? `
Domain Account Type Expires Days Actions
${certs.map(c => {
const days = c.days_remaining;
const badge = days !== null ? Nova.badge(days+'d', days<7?'red':days<30?'yellow':'green') : Nova.badge('unknown','muted');
return `
${c.domain}
${c.username||'—'}
${Nova.badge(c.type,'default')}
${c.expires_at||'—'}
${badge}
Renew
Del
`;
}).join('')}
`
: '
No SSL certificates issued yet.
'}
`;
}
window.adminIssueBulkSSL = async () => {
Nova.toast('Queuing SSL for all domains without certificates…','info',6000);
// Get all accounts, then issue SSL for each domain
const accts = await Nova.api('accounts','list',{params:{limit:1000}});
let count = 0;
for (const a of (accts?.data?.accounts || [])) {
await Nova.api('ssl','issue',{method:'POST',body:{domain:a.domain}});
count++;
}
Nova.toast(`SSL issued for ${count} domains`,'success');
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.adminDelCert = (id, domain) => {
Nova.confirm(`Delete SSL cert for ${domain}?`, async () => {
const r = await Nova.api('ssl','delete',{method:'POST',body:{cert_id:id}});
if (r?.success) { Nova.toast('Deleted','success'); adminPage('ssl-manager'); }
else Nova.toast(r?.message,'error');
}, true);
};
// ── Firewall ───────────────────────────────────────────────────────────────
// ── Firewall ───────────────────────────────────────────────────────────────
async function firewall() {
const [fwRes, f2bRes, ipRes, ignoreipRes] = await Promise.all([
Nova.api('firewall','status'),
Nova.api('firewall','f2b-status'),
Nova.api('firewall','ip-lists'),
Nova.api('firewall','f2b-ignoreip-list'),
]);
const fw = fwRes?.data || {};
const jails = f2bRes?.data?.jails || [];
const trusted = ipRes?.data?.trusted || [];
const blocked = ipRes?.data?.blocked || [];
const fwIgnoreips = ignoreipRes?.data?.ignoreip || ignoreipRes?.data?.detected || [];
const rules = fw.rules || [];
const active = fw.active;
const totalBanned = jails.reduce((s,j) => s + (j.currently_banned||0), 0);
return `
Incoming
${['deny','allow','reject'].map(p=>`${p.charAt(0).toUpperCase()+p.slice(1)} `).join('')}
Outgoing
${['allow','deny','reject'].map(p=>`${p.charAt(0).toUpperCase()+p.slice(1)} `).join('')}
Save Policies
Active Jails
${jails.length}
Currently Banned
${totalBanned}
Reload Config
Restart
${rules.length ? `
# To / Port Action From
${rules.map(r => `
${r.num}
${Nova.escHtml(r.to)}
${fwActionBadge(r.action)}
${Nova.escHtml(r.from)}
Delete
`).join('')}
` : `
`}
Action
Allow
Deny
Reject
Limit
Direction
In
Out
Port / Service
Protocol
TCP
UDP
Any
From IP (optional)
Comment
Add Rule
Allow
${trusted.length ? `
${trusted.map(ip => `${Nova.escHtml(ip)} × `).join('')}
` : `
No trusted IPs.
`}
Block
${blocked.length ? `
${blocked.map(ip => `${Nova.escHtml(ip)} × `).join('')}
` : `
No blocked IPs.
`}
${jails.length ? `
Jail Currently Banned Total Banned Failed Actions
${jails.map(j => `
${Nova.escHtml(j.jail)}
${j.currently_banned > 0 ? `${j.currently_banned} ` : '0'}
${j.total_banned}
${j.currently_failed}
${j.currently_banned > 0 ? `View Banned ` : ''}
Ban IP
`).join('')}
` : `
Fail2Ban not running or no jails configured.
`}
Add to Whitelist
${(fwIgnoreips||[]).map(ip => fwIgnoreipChip(ip)).join('')}
Loopback (127.0.0.0/8, ::1) and the server's own LAN IPs are added automatically.
Add your home/office IP or subnet here so you never lock yourself out.
Log Level
${['off','on','low','medium','high','full'].map(l=>`${l.charAt(0).toUpperCase()+l.slice(1)} `).join('')}
Apply
Logs at /var/log/ufw.log
`;
}
function fwActionBadge(action) {
const a = (action||'').toLowerCase();
if (a.includes('allow')) return Nova.badge('ALLOW','green');
if (a.includes('deny')) return Nova.badge('DENY','red');
if (a.includes('reject'))return Nova.badge('REJECT','red');
if (a.includes('limit')) return Nova.badge('LIMIT','yellow');
return `${Nova.escHtml(action)} `;
}
window.fwToggle = async (enable) => {
const label = enable ? 'Enable' : 'Disable';
Nova.confirm(`${label} UFW firewall?`, async () => {
const r = await Nova.api('firewall', enable ? 'enable' : 'disable', {method:'POST'});
Nova.toast(r?.message || label + 'd', r?.success ? 'success' : 'error');
adminPage('firewall');
}, !enable);
};
window.fwSavePolicies = async () => {
const inc = document.getElementById('pol-incoming')?.value;
const out = document.getElementById('pol-outgoing')?.value;
await Nova.api('firewall','default-policy',{method:'POST',body:{direction:'incoming',policy:inc}});
const r2 = await Nova.api('firewall','default-policy',{method:'POST',body:{direction:'outgoing',policy:out}});
Nova.toast(r2?.success ? 'Policies saved' : r2?.message, r2?.success ? 'success' : 'error');
adminPage('firewall');
};
window.fwDeleteRule = (num) => {
Nova.confirm(`Delete rule #${num}? This cannot be undone.`, async () => {
const r = await Nova.api('firewall','delete-rule',{method:'POST',body:{num}});
Nova.toast(r?.message || 'Deleted', r?.success ? 'success' : 'error');
adminPage('firewall');
}, true);
};
window.fwResetModal = () => {
Nova.confirm('Reset ALL firewall rules to NovaCPX defaults? SSH, HTTP, HTTPS, and panel ports will be re-allowed automatically.', async () => {
Nova.toast('Resetting firewall…','info',5000);
const r = await Nova.api('firewall','reset',{method:'POST'});
Nova.toast(r?.message || 'Reset complete','success');
adminPage('firewall');
}, true);
};
window.fwQuickRule = async () => {
const body = {
action: document.getElementById('qr-action')?.value,
direction: document.getElementById('qr-dir')?.value,
port: document.getElementById('qr-port')?.value,
proto: document.getElementById('qr-proto')?.value,
from_ip: document.getElementById('qr-from')?.value || 'any',
comment: document.getElementById('qr-comment')?.value,
};
if (!body.port) { Nova.toast('Port/service is required','error'); return; }
const r = await Nova.api('firewall','add-rule',{method:'POST',body});
Nova.toast(r?.message || (r?.success ? 'Rule added' : 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('firewall');
};
window.fwAddRuleModal = () => {
Nova.modal('Add Firewall Rule',`
Action
Allow Deny
Reject Limit (rate-limit)
Direction
Incoming Outgoing
Port / Service
Protocol
TCP UDP Any
From IP / CIDR (leave blank for any)
To IP / CIDR (leave blank for any)
Comment
`,`Cancel
Add Rule `);
};
window.fwSubmitAddRule = async () => {
const body = {
action: document.getElementById('m-action')?.value,
direction: document.getElementById('m-dir')?.value,
port: document.getElementById('m-port')?.value,
proto: document.getElementById('m-proto')?.value,
from_ip: document.getElementById('m-from')?.value || 'any',
to_ip: document.getElementById('m-to')?.value || 'any',
comment: document.getElementById('m-comment')?.value,
};
if (!body.port) { Nova.toast('Port is required','error'); return; }
document.querySelector('.modal-overlay')?.remove();
const r = await Nova.api('firewall','add-rule',{method:'POST',body});
Nova.toast(r?.message || (r?.success ? 'Rule added' : 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('firewall');
};
window.fwAllowIp = async () => {
const ip = document.getElementById('fw-trust-ip')?.value?.trim();
if (!ip) { Nova.toast('Enter an IP or CIDR','error'); return; }
const r = await Nova.api('firewall','allow-ip',{method:'POST',body:{ip}});
Nova.toast(r?.message || (r?.success ? 'IP allowed' : 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('firewall');
};
window.fwBlockIp = async () => {
const ip = document.getElementById('fw-block-ip')?.value?.trim();
if (!ip) { Nova.toast('Enter an IP or CIDR','error'); return; }
Nova.confirm(`Block ${ip}? This will deny all incoming traffic from this address.`, async () => {
const r = await Nova.api('firewall','block-ip',{method:'POST',body:{ip}});
Nova.toast(r?.message || (r?.success ? 'IP blocked' : 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('firewall');
}, true);
};
window.fwRemoveIp = (ip, action) => {
Nova.confirm(`Remove ${action} rule for ${ip}?`, async () => {
const r = await Nova.api('firewall','remove-ip',{method:'POST',body:{ip,action}});
Nova.toast(r?.message || 'Removed', r?.success ? 'success' : 'error');
if (r?.success) adminPage('firewall');
}, true);
};
window.fwJailDetail = 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(`Fail2Ban: ${jail}`,`
Currently Banned
${d.currently_banned}
Total Banned
${d.total_banned}
${ips.length ? `
Banned IP
${ips.map(ip=>`
${Nova.escHtml(ip)}
Unban
`).join('')}
` : 'No IPs currently banned in this jail.
'}`);
};
window.fwUnbanIp = async (ip, jail, btn) => {
if (btn) btn.disabled = true;
const r = await Nova.api('firewall','f2b-unban',{method:'POST',body:{ip,jail}});
Nova.toast(r?.message || 'Unbanned', r?.success ? 'success' : 'error');
if (r?.success && btn) btn.closest('tr')?.remove();
};
window.fwManualBanModal = (jail) => {
Nova.modal(`Manual Ban — ${jail}`,`
IP Address to Ban
`,`
Cancel
Ban IP `);
};
window.fwSubmitManualBan = async (jail) => {
const ip = document.getElementById('mb-ip')?.value?.trim();
if (!ip) { Nova.toast('Enter an IP','error'); 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('firewall');
};
window.fwF2bReload = async () => {
const r = await Nova.api('firewall','f2b-reload',{method:'POST'});
Nova.toast(r?.message || 'Reloaded', r?.success ? 'success' : 'error');
};
window.fwF2bRestart = async () => {
Nova.confirm('Restart Fail2Ban? Active bans will be preserved.', async () => {
const r = await Nova.api('firewall','f2b-restart',{method:'POST'});
Nova.toast(r?.message || 'Restarted', r?.success ? 'success' : 'error');
adminPage('firewall');
});
};
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');
};
function fwIgnoreipChip(ip) {
const isLoopback = ip === '127.0.0.0/8' || ip === '127.0.0.1' || ip === '::1';
return `
${Nova.escHtml(ip)}${isLoopback ? ' 🔒' : ' ×'}
`;
}
window.fwIgnoreipAdd = async () => {
const ip = document.getElementById('fw-ignoreip-input')?.value?.trim();
if (!ip) { Nova.toast('Enter an IP address or CIDR range', 'error'); 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) {
const chips = document.getElementById('ignoreip-chips');
if (chips) chips.innerHTML = (r.data?.ignoreip || []).map(fwIgnoreipChip).join('');
const inp = document.getElementById('fw-ignoreip-input');
if (inp) inp.value = '';
}
};
window.fwIgnoreipRemove = async (ip) => {
Nova.confirm(`Remove ${ip} from Fail2Ban whitelist? They could get banned if they fail too many login attempts.`, async () => {
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) {
const chips = document.getElementById('ignoreip-chips');
if (chips) chips.innerHTML = (r.data?.ignoreip || []).map(fwIgnoreipChip).join('');
}
}, true);
};
window.fwIgnoreipReset = () => {
Nova.confirm('Reset Fail2Ban whitelist to server defaults (loopback + local IPs)?', async () => {
const r = await Nova.api('firewall','f2b-ignoreip-reset',{method:'POST'});
Nova.toast(r?.message || 'Reset', r?.success ? 'success' : 'error');
if (r?.success) adminPage('firewall');
});
};
// ── MySQL/DB Manager ───────────────────────────────────────────────────────
async function mysqlManager() {
const res = await Nova.api('databases','list',{params:{account_id:0}});
const dbs = res?.data || [];
return `
${dbs.length ? `
Database User Type Account Size Actions
${dbs.map(d => `
${d.db_name}
${d.db_user}
${Nova.badge(d.db_type,'default')}
${d.username||'—'}
${d.size||'—'}
Drop
`).join('')}
`
: '
No databases.
'}
`;
}
window.adminDropDB = (id, name) => {
Nova.confirm(`Drop database ${name}? ALL DATA WILL BE LOST.`, async () => {
const r = await Nova.api('databases','drop',{method:'POST',body:{id}});
if (r?.success) { Nova.toast('Dropped','success'); adminPage('mysql-manager'); }
else Nova.toast(r?.message,'error');
}, true);
};
// ── Mail Server ────────────────────────────────────────────────────────────
async function mailServer() {
const r = await Nova.api('system','stats');
const svcs = r?.data?.services || {};
const mailStatus = svcs['postfix'] || 'unknown';
const doveStatus = svcs['dovecot'] || 'unknown';
return `
${[['postfix',mailStatus],['dovecot',doveStatus],['spamassassin','unknown']].map(([s,st]) => `
${s} ${Nova.badge(st,st==='active'?'green':'red')}
Restart
Reload
`).join('')}
`;
}
window.adminViewMailQueue = async () => {
const r = await Nova.api('system','service',{method:'POST',body:{service:'mailq',command:'status'}});
Nova.modal('Mail Queue', `${r?.data?.output || 'Queue is empty'} `);
};
// ── FTP Server ────────────────────────────────────────────────────────────
async function ftpServer() {
const r = await Nova.api('system','stats');
const ftpStatus = r?.data?.services?.proftpd || 'unknown';
return `
ProFTPD uses virtual users stored in /etc/proftpd/novacpx-users.passwd
FTP connections use SFTP on port 22 or passive FTP on ports 20/21.
Per-account FTP management is available in each account's FTP page.
`;
}
// ── Backups — delegates to backupsFull() defined in additions ─────────────
async function backups() { return backupsFull(); }
// ── Stubs for new pages — implementations in additions block below ─────────
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);
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 () => {
const btn = document.getElementById('ncpx-update-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Updating…'; }
Nova.toast('Pulling update from GitHub…', 'info', 12000);
const res = await Nova.api('system', 'apply-novacpx-update', { method: 'POST' });
if (res?.data?.updated) {
Nova.toast(`Updated to ${res.data.to_commit}`, 'success', 6000);
setTimeout(() => Nova.loadPage('updates', pages), 2000);
} else if (res?.error) {
Nova.toast(res.error, 'error', 8000);
if (btn) { btn.disabled = false; btn.textContent = 'Update NovaCPX'; }
} else {
Nova.toast('Already up to date.', 'info');
if (btn) { btn.disabled = false; btn.textContent = 'Update NovaCPX'; }
}
});
};
window.applyOSUpdate = async () => {
Nova.confirm('Apply OS package upgrades? Services will be automatically restarted if needed. The NovaCPX panel will self-restore from backup if any ports go down.', async () => {
const btn = document.getElementById('os-update-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Upgrading…'; }
Nova.toast('Running apt-get upgrade — this may take a few minutes…', 'info', 20000);
const res = await Nova.api('system', 'apply-os-update', { method: 'POST', timeout: 120000 });
if (res?.data) {
const d = res.data;
const healed = Object.entries(d.services_healed || {}).map(([s,r]) => `${s}: ${r}`).join(', ');
let msg = 'OS upgrade complete.';
if (healed) msg += ` Auto-healed: ${healed}.`;
if (!d.panel_ports_ok) msg += ' ⚠ Panel ports were down — auto-restored from backup.';
Nova.toast(msg, d.panel_ports_ok ? 'success' : 'warning', 10000);
Nova.loadPage('updates', pages);
} else {
Nova.toast(res?.error || 'Upgrade failed', 'error', 8000);
if (btn) { btn.disabled = false; btn.textContent = 'Apply OS Upgrade'; }
}
});
};
// keep old alias for any lingering references
window.applyUpdate = window.applyNovaCPXUpdate;
window.adminServiceAction = async (svc, cmd) => {
const res = await Nova.api('system', 'service', { method: 'POST', body: { service: svc, command: cmd } });
Nova.toast(`${svc}: ${cmd} → ${res?.success ? 'OK' : res?.message}`, res?.success ? 'success' : 'error');
};
window.phpAction = async (ver, cmd) => {
const svc = `php${ver}-fpm`;
await window.adminServiceAction(svc, 'restart');
};
// ── Check for updates badge ────────────────────────────────────────────────
async function checkUpdates() {
const [ncpx, os] = await Promise.all([
Nova.api('system', 'check-novacpx-update'),
Nova.api('system', 'check-os-update'),
]);
const ncpxN = ncpx?.data?.updates_available || 0;
const osN = os?.data?.upgradable || 0;
const total = ncpxN + osN;
const badge = document.getElementById('update-badge');
if (badge && total > 0) { badge.textContent = total; badge.style.display = ''; }
}
})();
// ── ADDITIONS: appended by features #14-17 ────────────────────────────────
// ── WordPress Manager (#14) ────────────────────────────────────────────────
async function wordpress() {
const [acctRes, wpRes] = await Promise.all([
Nova.api('accounts','list',{params:{limit:500}}),
Nova.api('wordpress','list'),
]);
const accts = acctRes?.data?.accounts || [];
const installs = wpRes?.data?.installs || [];
window._adminAcctsWP = accts;
return `
${installs.length ? `
Domain Path Account Version Status Actions
${installs.map(w => `
${Nova.escHtml(w.domain)}
${Nova.escHtml(w.path||'/')}
${Nova.escHtml(w.username||'—')}
${w.wp_version ? `${Nova.escHtml(w.wp_version)}` : '—'}
${Nova.badge(w.status||'active', w.status==='active'?'green':w.status==='updating'?'yellow':'red')}
Info
Update Core
Plugins
Themes
${!w.staging_of ? `Clone Staging ` : `staging `}
Delete
`).join('')}
` : `
No WordPress installs yet. Click "Install WordPress" to get started.
`}
`;
}
window.wpInstallModal = () => {
const accts = window._adminAcctsWP || [];
const opts = accts.map(a => `${a.username} — ${a.domain} `).join('');
Nova.modal('Install WordPress', `
Account ${opts}
Domain
Path (leave / for root)
Site Title
WP Admin Username
WP Admin Password
WP Admin Email
wp-cli will be downloaded automatically if not installed. This may take 1-2 minutes.
`,
`Cancel
Install `);
};
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,
}});
document.querySelector('.modal-overlay')?.remove();
if (res?.success) { Nova.toast('WordPress installed!','success'); adminPage('wordpress'); }
else Nova.toast(res?.message || 'Install failed','error');
};
window.wpUpdate = async (id, type) => {
const action = type === 'core' ? 'update-core' : type === 'plugins' ? 'update-plugins' : 'update-themes';
Nova.toast(`Updating ${type}…`,'info',15000);
const r = await Nova.api('wordpress', action, {method:'POST',body:{install_id:id}});
Nova.toast(r?.message || (r?.success ? 'Updated' : 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('wordpress');
};
window.wpInfo = async (id, domain) => {
Nova.toast('Loading info…','info',5000);
const r = await Nova.api('wordpress','info',{params:{install_id:id}});
if (!r?.success) { Nova.toast(r?.message,'error'); return; }
const d = r.data || {};
const plugins = (d.plugins||[]).map(p => `${Nova.escHtml(p.name)} ${Nova.escHtml(p.version||'')} ${Nova.badge(p.status||'inactive',p.status==='active'?'green':'muted')} `).join('');
const themes = (d.themes||[]).map(t => `${Nova.escHtml(t.name)} ${Nova.escHtml(t.version||'')} ${Nova.badge(t.status||'inactive',t.status==='active'?'green':'muted')} `).join('');
Nova.modal(`WordPress: ${domain}`,`
Core Version
${Nova.escHtml(d.version||'—')}
Site URL
${Nova.escHtml(d.siteurl||'—')}
Plugins (${(d.plugins||[]).length})
${plugins ? `Plugin Version Status ${plugins}
` : 'None
'}
Themes (${(d.themes||[]).length})
${themes ? `Theme Version Status ${themes}
` : 'None
'}`);
};
window.wpCloneStaging = (id, domain) => {
Nova.confirm(`Clone ${domain} to a staging environment? This copies all files and the database.`, async () => {
Nova.toast('Cloning to staging…','info',30000);
const r = await Nova.api('wordpress','clone-staging',{method:'POST',body:{install_id:id}});
Nova.toast(r?.message || (r?.success ? 'Staging created' : 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('wordpress');
});
};
window.wpDelete = (id, domain) => {
Nova.confirm(`DELETE WordPress install on ${domain}? This removes all files AND drops the database. IRREVERSIBLE.`, async () => {
const r = await Nova.api('wordpress','delete',{method:'POST',body:{install_id:id}});
Nova.toast(r?.message || (r?.success ? 'Deleted' : 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('wordpress');
}, true);
};
// ── Backup Manager — full implementation (#15) ─────────────────────────────
async function backupsFull() {
const [acctRes, bkRes] = await Promise.all([
Nova.api('accounts','list',{params:{limit:500}}),
Nova.api('backup','list'),
]);
const accts = acctRes?.data?.accounts || [];
const backupList = bkRes?.data?.backups || [];
const diskUsed = bkRes?.data?.disk_used || 0;
window._adminAcctsBK = accts;
return `
Total Backups
${backupList.length}
Disk Used
${Nova.bytes(diskUsed)}
Set per-account backup schedules. Cron runs backups automatically based on the configured frequency.
${accts.slice(0,8).map(a => `${Nova.escHtml(a.username)} `).join('')}
${accts.length>8?`+${accts.length-8} more `:''}
${backupList.length ? `
Account Type Size Status Storage Created Actions
${backupList.map(b => `
${Nova.escHtml(b.username||b.account_id||'—')}
${Nova.badge(b.type,'default')}
${Nova.bytes(b.size||0)}
${Nova.badge(b.status, b.status==='complete'?'green':b.status==='failed'?'red':'yellow')}
${b.remote_path ? Nova.badge('remote','blue') : Nova.badge('local','muted')}
${Nova.relTime(b.created_at)}
${b.status==='complete'?`Download `:''}
Restore
Del
`).join('')}
` : `
No backups yet.
`}
`;
}
window.bkCreateModal = () => {
const accts = window._adminAcctsBK || [];
const opts = accts.map(a => `${a.username} — ${a.domain} `).join('');
Nova.modal('Create Backup', `
Account ${opts}
Type
Full (files + database)
Files only
Database only
`,
`Cancel
Create Backup `);
};
window.bkSubmitCreate = async () => {
const id = +document.getElementById('bk-acct')?.value;
const type = document.getElementById('bk-type')?.value;
document.querySelector('.modal-overlay')?.remove();
Nova.toast('Creating backup…','info',30000);
const r = await Nova.api('backup','create',{method:'POST',body:{account_id:id,type}});
Nova.toast(r?.message||(r?.success?'Backup complete':'Failed'), r?.success?'success':'error');
if (r?.success) adminPage('backups');
};
window.bkRestore = (id) => {
Nova.confirm('Restore this backup? Current files and databases will be overwritten. IRREVERSIBLE.', async () => {
Nova.toast('Restoring…','info',30000);
const r = await Nova.api('backup','restore',{method:'POST',body:{id}});
Nova.toast(r?.message||(r?.success?'Restored':'Failed'), r?.success?'success':'error');
}, true);
};
window.bkDelete = (id) => {
Nova.confirm('Delete this backup?', async () => {
const r = await Nova.api('backup','delete',{method:'POST',body:{id}});
Nova.toast(r?.message||(r?.success?'Deleted':'Failed'), r?.success?'success':'error');
if (r?.success) adminPage('backups');
}, true);
};
window.bkScheduleModal = () => {
const accts = window._adminAcctsBK || [];
const opts = accts.map(a => `${a.username} `).join('');
Nova.modal('Configure Backup Schedule', `
Account ${opts}
Frequency
Hourly
Daily
Weekly
Monthly
Type
Full
Files only
Database only
Keep (# backups)
`,
`Cancel
Save Schedule `);
};
window.bkScheduleForAccount = async (id, user) => {
const r = await Nova.api('backup','get-schedule',{params:{account_id:id}});
const s = r?.data || {};
Nova.modal(`Schedule: ${user}`, `
Frequency
${['hourly','daily','weekly','monthly'].map(f=>`${f.charAt(0).toUpperCase()+f.slice(1)} `).join('')}
Type
${['full','files','database'].map(t=>`${t.charAt(0).toUpperCase()+t.slice(1)} `).join('')}
Keep (# backups)
`,
`Cancel
Save `);
};
window.bkSaveSchedule = async () => {
const id = +document.getElementById('bks-acct')?.value;
await bkSaveScheduleFor(id);
};
window.bkSaveScheduleFor = async (id) => {
const r = await Nova.api('backup','schedule',{method:'POST',body:{
account_id: id,
frequency: document.getElementById('bks-freq')?.value,
type: document.getElementById('bks-type')?.value,
retain: +document.getElementById('bks-retain')?.value,
}});
document.querySelector('.modal-overlay')?.remove();
Nova.toast(r?.message||(r?.success?'Schedule saved':'Failed'), r?.success?'success':'error');
};
// ── Cloudflare Integration (#16) ──────────────────────────────────────────
async function cloudflare() {
const acctRes = await Nova.api('accounts','list',{params:{limit:500}});
const accts = acctRes?.data?.accounts || [];
window._adminAcctsCF = accts;
return `
Select an account to configure or view its Cloudflare API key.
Account
— Select Account —
${accts.map(a=>`${a.username} — ${a.domain} `).join('')}
Save credentials first, then click Refresh Zones.
`;
}
window.cfLoadAccount = async (id) => {
if (!id) { document.getElementById('cf-acct-panel').innerHTML=''; return; }
const r = await Nova.api('cloudflare','get-credentials',{params:{account_id:id}});
const c = r?.data || {};
document.getElementById('cf-acct-panel').innerHTML = `
Save Credentials
Test API Key
${c.cf_api_key ? `Key on file: ${Nova.escHtml(c.cf_api_key)}
` : ''}`;
document.getElementById('cf-zones-panel').style.display = '';
window._cfCurrentAcct = id;
};
window.cfSaveCredentials = async (id) => {
const r = await Nova.api('cloudflare','save-credentials',{method:'POST',body:{
account_id: id,
api_key: document.getElementById('cf-apikey')?.value,
email: document.getElementById('cf-email')?.value,
}});
Nova.toast(r?.message||(r?.success?'Saved':'Failed'), r?.success?'success':'error');
};
window.cfTestKey = async (id) => {
const r = await Nova.api('cloudflare','test-key',{method:'POST',body:{
account_id: id,
api_key: document.getElementById('cf-apikey')?.value,
email: document.getElementById('cf-email')?.value,
}});
Nova.toast(r?.message||(r?.data?.valid?'API key is valid':'Invalid key'), r?.data?.valid?'success':'error');
};
window.cfRefreshZones = async () => {
const id = window._cfCurrentAcct;
if (!id) { Nova.toast('Select an account first','error'); return; }
const r = await Nova.api('cloudflare','list-zones',{params:{account_id:id}});
const zones = r?.data?.zones || r?.data || [];
const body = document.getElementById('cf-zones-body');
if (!body) return;
if (!r?.success) { body.innerHTML=`${Nova.escHtml(r?.message||'Failed to load zones')}
`; return; }
if (!zones.length) { body.innerHTML='No zones found for these credentials.
'; return; }
body.innerHTML = `
Zone Status Plan Actions
${zones.map(z=>`
${Nova.escHtml(z.name)} ${Nova.escHtml(z.id)}
${Nova.badge(z.status,z.status==='active'?'green':'yellow')}
${Nova.escHtml(z.plan?.name||'—')}
DNS Records
Push to CF
Pull from CF
Purge Cache
`).join('')}
`;
};
window.cfViewRecords = async (zoneId, domain, acctId) => {
const r = await Nova.api('cloudflare','list-records',{method:'POST',body:{zone_id:zoneId,account_id:acctId}});
const records = r?.data?.records || r?.data || [];
Nova.modal(`CF DNS: ${domain}`, !records.length ? 'No records.
' : `
`);
};
window.cfToggleProxy = async (zoneId, recordId, proxied, acctId) => {
const r = await Nova.api('cloudflare','toggle-proxy',{method:'POST',body:{zone_id:zoneId,record_id:recordId,proxied,account_id:acctId}});
Nova.toast(r?.message||(r?.success?'Updated':'Failed'), r?.success?'success':'error');
};
window.cfSync = async (zoneId, domain, dir, acctId) => {
const action = dir==='to' ? 'sync-to-cf' : 'sync-from-cf';
const label = dir==='to' ? 'Pushing to Cloudflare' : 'Pulling from Cloudflare';
Nova.toast(`${label}…`,'info',10000);
const r = await Nova.api('cloudflare',action,{method:'POST',body:{zone_id:zoneId,domain,account_id:acctId}});
Nova.toast(r?.message||(r?.success?'Done':'Failed'), r?.success?'success':'error');
};
window.cfPurge = async (zoneId, acctId) => {
Nova.confirm('Purge all Cloudflare cache for this zone?', async () => {
const r = await Nova.api('cloudflare','purge-cache',{method:'POST',body:{zone_id:zoneId,account_id:acctId}});
Nova.toast(r?.message||(r?.success?'Cache purged':'Failed'), r?.success?'success':'error');
});
};
// ── TOTP / 2FA Admin (#17) ────────────────────────────────────────────────
async function twofa() {
const res = await Nova.api('accounts','list',{params:{limit:500}});
const users = res?.data?.accounts || [];
return `
Username Email Role 2FA Status Actions
${users.map(u=>`
${Nova.escHtml(u.username)}
${Nova.escHtml(u.email||'—')}
${Nova.badge(u.role||'user','default')}
—
Check
Force Disable
`).join('')}
`;
}
window.totpCheckStatus = async (userId) => {
const r = await Nova.api('totp','admin-status',{method:'POST',body:{user_id:userId}});
const el = document.getElementById(`totp-status-${userId}`);
if (!el) return;
const enabled = r?.data?.totp_enabled;
el.innerHTML = enabled
? Nova.badge('Enabled','green')
: Nova.badge('Disabled','muted');
};
window.totpAdminDisable = (userId, username) => {
Nova.confirm(`Force-disable 2FA for ${username}? Use only for account recovery when user cannot log in.`, async () => {
const r = await Nova.api('totp','admin-disable',{method:'POST',body:{user_id:userId}});
Nova.toast(r?.message||(r?.success?'2FA disabled':'Failed'), r?.success?'success':'error');
if (r?.success) {
const el = document.getElementById(`totp-status-${userId}`);
if (el) el.innerHTML = Nova.badge('Disabled','muted');
}
}, 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).
Install Nginx Locally
Setup Guide / Remote VM
` : `
${hosts.length === 0 ? `
No proxy hosts yet. Click Sync Accounts to auto-add all hosted domains, or + Add Host to add manually.
` : `
Domain
Upstream
SSL
Status
Actions
${hosts.map(h => `
${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')}
Edit
${h.enabled ? 'Disable' : 'Enable'}
Delete
`).join('')}
`}
`}`;
}
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', `
Domain
Upstream URL
e.g. http://127.0.0.1:80 or http://10.0.0.2:8080
Enable SSL
Notes (optional)
`, 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', `
Domain
Upstream URL
Enable SSL
Custom Nginx Config (overrides auto-generated)
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.
Click Install Nginx Locally on the main Nginx Proxy page
Move Apache to port 8080: edit /etc/apache2/ports.conf → change Listen 80 to Listen 8080
Update upstream in all proxy hosts to http://127.0.0.1:8080
Click Sync Accounts to auto-populate proxy hosts from your hosted accounts
Click Reload Config to apply changes
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).
Create a new VM on Proxmox (Ubuntu 22.04, 1 vCPU, 1GB RAM)
Run the setup script below on the new VM as root
Point FortiGate VIPs to the proxy VM IP (ports 80/443)
Set the proxy upstream to this NovaCPX VM IP (http://10.48.200.110:80)
Add proxy hosts for each domain from your NovaCPX admin panel
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 });
};