diff --git a/panel/admin/index.php b/panel/admin/index.php
new file mode 100644
index 0000000..202bf99
--- /dev/null
+++ b/panel/admin/index.php
@@ -0,0 +1,212 @@
+
+
+
+
+
+
+NovaCPX Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
NovaCPX
+
Admin Panel · Port 8882
+
+
+
+
+
+
+
+
+
diff --git a/panel/api/endpoints/accounts.php b/panel/api/endpoints/accounts.php
index 94d2fb6..f4d2aaa 100644
--- a/panel/api/endpoints/accounts.php
+++ b/panel/api/endpoints/accounts.php
@@ -37,7 +37,7 @@ match ($action) {
p.name as package_name,
(SELECT COUNT(*) FROM domains WHERE account_id = a.id) as domain_count,
(SELECT COUNT(*) FROM email_accounts WHERE account_id = a.id) as email_count,
- (SELECT COUNT(*) FROM databases WHERE account_id = a.id) as db_count
+ (SELECT COUNT(*) FROM `databases` WHERE account_id = a.id) as db_count
FROM accounts a
JOIN users u ON u.id = a.user_id
LEFT JOIN packages p ON p.id = a.package_id
@@ -132,7 +132,7 @@ match ($action) {
'disk_limit_mb' => $pkg['disk_mb'] ?? 0,
'email_count' => $db->fetchOne("SELECT COUNT(*) c FROM email_accounts WHERE account_id = ?", [$id])['c'],
'email_limit' => $pkg['max_email'] ?? 0,
- 'db_count' => $db->fetchOne("SELECT COUNT(*) c FROM databases WHERE account_id = ?", [$id])['c'],
+ 'db_count' => $db->fetchOne("SELECT COUNT(*) c FROM `databases` WHERE account_id = ?", [$id])['c'],
'db_limit' => $pkg['max_databases'] ?? 0,
'domain_count' => $db->fetchOne("SELECT COUNT(*) c FROM domains WHERE account_id = ?", [$id])['c'],
'domain_limit' => $pkg['max_domains'] ?? 0,
diff --git a/panel/api/endpoints/databases.php b/panel/api/endpoints/databases.php
index de9bcc3..26458b1 100644
--- a/panel/api/endpoints/databases.php
+++ b/panel/api/endpoints/databases.php
@@ -4,15 +4,18 @@ $body = json_decode(file_get_contents('php://input'), true) ?? [];
require_once NOVACPX_LIB . '/DatabaseManager.php';
$user = Auth::getInstance()->user();
-$accountId = $user['role'] === 'user'
- ? (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0)
- : (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
+if ($user['role'] === 'user') {
+ $accountId = (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0);
+} else {
+ $accountId = (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
+ if ($accountId && $user['role'] === 'reseller') assert_account_access($accountId);
+}
match ($action) {
'list' => (function() use ($db, $accountId) {
if (!$accountId) Response::error("account_id required");
- $rows = $db->fetchAll("SELECT id, db_name, db_user, db_type, size_mb, created_at FROM databases WHERE account_id = ?", [$accountId]);
+ $rows = $db->fetchAll("SELECT id, db_name, db_user, db_type, size_mb, created_at FROM `databases` WHERE account_id = ?", [$accountId]);
foreach ($rows as &$r) { $r['size_mb'] = DatabaseManager::getSize($r['db_name'], $r['db_type']); }
Response::success($rows);
})(),
@@ -22,7 +25,7 @@ match ($action) {
// Package limit check
$acctPkg = $db->fetchOne("SELECT p.max_databases FROM accounts a LEFT JOIN packages p ON p.id=a.package_id WHERE a.id=?", [$accountId]);
if ($acctPkg && $acctPkg['max_databases'] > 0) {
- $count = (int)$db->fetchOne("SELECT COUNT(*) c FROM databases WHERE account_id=?", [$accountId])['c'];
+ $count = (int)$db->fetchOne("SELECT COUNT(*) c FROM `databases` WHERE account_id=?", [$accountId])['c'];
if ($count >= (int)$acctPkg['max_databases']) Response::error("Database limit ({$acctPkg['max_databases']}) reached for this package", 403);
}
$type = $body['type'] ?? 'mysql';
@@ -47,7 +50,7 @@ match ($action) {
'drop' => (function() use ($db, $body) {
$id = (int)($body['id'] ?? 0);
- $dbe = $db->fetchOne("SELECT db_name, db_user, db_type FROM databases WHERE id = ?", [$id]);
+ $dbe = $db->fetchOne("SELECT db_name, db_user, db_type FROM `databases` WHERE id = ?", [$id]);
if (!$dbe) Response::error("Database not found", 404);
DatabaseManager::drop($dbe['db_name'], $dbe['db_user'], $dbe['db_type']);
audit('database.drop', $dbe['db_name']);
diff --git a/panel/api/endpoints/dns.php b/panel/api/endpoints/dns.php
index e38021f..e475874 100644
--- a/panel/api/endpoints/dns.php
+++ b/panel/api/endpoints/dns.php
@@ -12,6 +12,7 @@ if ($user['role'] === 'user') {
$accountId = $acct ? (int)$acct['id'] : null;
} else {
$accountId = (int)($_GET['account_id'] ?? $body['account_id'] ?? 0) ?: null;
+ if ($accountId && $user['role'] === 'reseller') assert_account_access($accountId);
}
match ($action) {
diff --git a/panel/api/endpoints/domains.php b/panel/api/endpoints/domains.php
index c5e0d8d..a523a58 100644
--- a/panel/api/endpoints/domains.php
+++ b/panel/api/endpoints/domains.php
@@ -8,9 +8,12 @@ require_once NOVACPX_LIB . '/VhostManager.php';
require_once NOVACPX_LIB . '/DNSManager.php';
$user = Auth::getInstance()->user();
-$accountId = $user['role'] === 'user'
- ? (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0)
- : (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
+if ($user['role'] === 'user') {
+ $accountId = (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0);
+} else {
+ $accountId = (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
+ if ($accountId && $user['role'] === 'reseller') assert_account_access($accountId);
+}
if (!$accountId) Response::error("account_id required");
$acct = $db->fetchOne("SELECT * FROM accounts WHERE id = ?", [$accountId]);
@@ -18,7 +21,7 @@ if (!$acct) Response::error("Account not found", 404);
match ($action) {
'list' => (function() use ($db, $accountId) {
- $rows = $db->fetchAll("SELECT * FROM domains WHERE account_id = ? ORDER BY is_primary DESC, domain", [$accountId]);
+ $rows = $db->fetchAll("SELECT * FROM domains WHERE account_id = ? ORDER BY (type='main') DESC, domain", [$accountId]);
Response::success($rows);
})(),
@@ -30,11 +33,11 @@ match ($action) {
$exists = $db->fetchOne("SELECT id FROM domains WHERE domain = ?", [$domain]);
if ($exists) Response::error("Domain already exists");
- $db->execute(
- "INSERT INTO domains (account_id, domain, type, doc_root, created_at) VALUES (?,?,?,?,NOW())",
- [$accountId, $domain, 'addon', $acct['home_dir'] . '/public_html/' . $domain]
- );
$docRoot = $acct['home_dir'] . '/public_html/' . $domain;
+ $db->execute(
+ "INSERT INTO domains (account_id, domain, type, document_root, created_at) VALUES (?,?,?,?,NOW())",
+ [$accountId, $domain, 'addon', $docRoot]
+ );
@mkdir($docRoot, 0755, true);
file_put_contents("$docRoot/index.html", "$domain is ready! Upload your website files.
");
@@ -45,7 +48,7 @@ match ($action) {
'doc_root' => $docRoot,
'php_ver' => $acct['php_version'] ?? PHP_DEFAULT,
]);
- DNSManager::createZone($domain, gethostbyname(gethostname()));
+ DNSManager::createZone($accountId, $domain);
audit('domains.add-addon', $domain);
Response::success(null, "Addon domain $domain added");
})(),
@@ -61,11 +64,18 @@ match ($action) {
file_put_contents("$docRoot/index.html", "$full ");
$db->execute(
- "INSERT INTO domains (account_id, domain, type, doc_root, created_at) VALUES (?,?,?,?,NOW())",
+ "INSERT INTO domains (account_id, domain, type, document_root, created_at) VALUES (?,?,?,?,NOW())",
[$accountId, $full, 'subdomain', $docRoot]
);
- VhostManager::createSubdomain($full, $acct['username'], $docRoot, $acct['php_version'] ?? PHP_DEFAULT);
- DNSManager::addRecord($parent, $sub, 'A', gethostbyname(gethostname()));
+ VhostManager::create([
+ 'domain' => $full,
+ 'username' => $acct['username'],
+ 'home_dir' => $acct['home_dir'],
+ 'doc_root' => $docRoot,
+ 'php_ver' => $acct['php_version'] ?? PHP_DEFAULT,
+ ]);
+ $zone = DB::getInstance()->fetchOne("SELECT id FROM dns_zones WHERE domain = ? AND account_id = ?", [$parent, $accountId]);
+ if ($zone) DNSManager::addRecord((int)$zone['id'], $sub, 'A', gethostbyname(gethostname()));
audit('domains.add-subdomain', $full);
Response::success(null, "Subdomain $full created");
})(),
@@ -76,10 +86,9 @@ match ($action) {
if (!$alias) Response::error("domain required");
$db->execute(
- "INSERT INTO domains (account_id, domain, type, doc_root, created_at) VALUES (?,?,?,?,NOW())",
+ "INSERT INTO domains (account_id, domain, type, document_root, created_at) VALUES (?,?,?,?,NOW())",
[$accountId, $alias, 'alias', $acct['home_dir'] . '/public_html']
);
- // Create vhost that serves same doc root as primary
VhostManager::create([
'domain' => $alias,
'username' => $acct['username'],
@@ -87,7 +96,7 @@ match ($action) {
'doc_root' => $acct['home_dir'] . '/public_html',
'php_ver' => $acct['php_version'] ?? PHP_DEFAULT,
]);
- DNSManager::createZone($alias, gethostbyname(gethostname()));
+ DNSManager::createZone($accountId, $alias);
audit('domains.add-alias', $alias);
Response::success(null, "Domain alias $alias added");
})(),
@@ -97,7 +106,7 @@ match ($action) {
$url = trim($body['redirect_url'] ?? '');
$code = in_array((int)($body['code'] ?? 301), [301, 302]) ? (int)$body['code'] : 301;
if (!$url) Response::error("redirect_url required");
- $db->execute("UPDATE domains SET redirect_url=?, redirect_code=? WHERE id=? AND account_id=?", [$url, $code, $id, $accountId]);
+ $db->execute("UPDATE domains SET redirect_to=? WHERE id=? AND account_id=?", [$url, $id, $accountId]);
// Update vhost to add Redirect directive
$dom = $db->fetchOne("SELECT * FROM domains WHERE id = ?", [$id]);
if ($dom) {
@@ -110,7 +119,7 @@ match ($action) {
$id = (int)($body['id'] ?? 0);
$dom = $db->fetchOne("SELECT * FROM domains WHERE id = ? AND account_id = ?", [$id, $accountId]);
if (!$dom) Response::error("Domain not found", 404);
- if ($dom['is_primary']) Response::error("Cannot remove primary domain");
+ if ($dom['type'] === 'main') Response::error("Cannot remove primary domain");
VhostManager::remove($dom['domain']);
if ($dom['type'] !== 'subdomain') DNSManager::removeZone($dom['domain']);
diff --git a/panel/api/endpoints/email.php b/panel/api/endpoints/email.php
index aaaa8aa..efafe6b 100644
--- a/panel/api/endpoints/email.php
+++ b/panel/api/endpoints/email.php
@@ -11,7 +11,9 @@ function self_account_id($db, $user): ?int {
$a = $db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']]);
return $a ? (int)$a['id'] : null;
}
- return (int)(request_param('account_id') ?? 0) ?: null;
+ $id = (int)(request_param('account_id') ?? 0);
+ if ($id && $user['role'] === 'reseller') assert_account_access($id);
+ return $id ?: null;
}
function request_param(string $k): mixed { return $_GET[$k] ?? (json_decode(file_get_contents('php://input'), true) ?? [])[$k] ?? null; }
diff --git a/panel/api/endpoints/ftp.php b/panel/api/endpoints/ftp.php
index 7607593..1a7a227 100644
--- a/panel/api/endpoints/ftp.php
+++ b/panel/api/endpoints/ftp.php
@@ -4,9 +4,12 @@ $body = json_decode(file_get_contents('php://input'), true) ?? [];
require_once NOVACPX_LIB . '/FTPManager.php';
$user = Auth::getInstance()->user();
-$accountId = $user['role'] === 'user'
- ? (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0)
- : (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
+if ($user['role'] === 'user') {
+ $accountId = (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0);
+} else {
+ $accountId = (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
+ if ($accountId && $user['role'] === 'reseller') assert_account_access($accountId);
+}
match ($action) {
'list' => (function() use ($db, $accountId) {
diff --git a/panel/api/endpoints/ssl.php b/panel/api/endpoints/ssl.php
index c967437..5d545f9 100644
--- a/panel/api/endpoints/ssl.php
+++ b/panel/api/endpoints/ssl.php
@@ -5,9 +5,12 @@ require_once NOVACPX_LIB . '/SSLManager.php';
require_once NOVACPX_LIB . '/VhostManager.php';
$user = Auth::getInstance()->user();
-$accountId = $user['role'] === 'user'
- ? (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0)
- : (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
+if ($user['role'] === 'user') {
+ $accountId = (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0);
+} else {
+ $accountId = (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
+ if ($accountId && $user['role'] === 'reseller') assert_account_access($accountId);
+}
match ($action) {
'list' => (function() use ($db, $accountId) {
diff --git a/panel/api/endpoints/stats.php b/panel/api/endpoints/stats.php
index dbf88c2..41f5303 100644
--- a/panel/api/endpoints/stats.php
+++ b/panel/api/endpoints/stats.php
@@ -49,7 +49,13 @@ match ($action) {
})(),
'account' => (function() use ($db, $body) {
- $accountId = (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
+ $user = Auth::getInstance()->user();
+ if ($user['role'] === 'user') {
+ $acctRow = $db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']]);
+ $accountId = $acctRow ? (int)$acctRow['id'] : 0;
+ } else {
+ $accountId = (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
+ }
if (!$accountId) Response::error("account_id required");
$acct = $db->fetchOne("SELECT * FROM accounts WHERE id = ?", [$accountId]);
if (!$acct) Response::error("Account not found", 404);
@@ -62,7 +68,7 @@ match ($action) {
$inodes = (int)trim(shell_exec("find " . escapeshellarg($acct['home_dir']) . " 2>/dev/null | wc -l") ?: 0);
// DB count & size
- $dbs = $db->fetchAll("SELECT id FROM databases WHERE account_id = ?", [$accountId]);
+ $dbs = $db->fetchAll("SELECT id FROM `databases` WHERE account_id = ?", [$accountId]);
$dbCount = count($dbs);
// Email count
diff --git a/panel/api/index.php b/panel/api/index.php
index 7b84ec6..8aa0268 100644
--- a/panel/api/index.php
+++ b/panel/api/index.php
@@ -90,4 +90,24 @@ if (!file_exists($endpointFile)) {
}
})();
+/**
+ * Verify the current user can access a given account_id.
+ * Returns the account row or sends a 404 error response.
+ * Resellers may only access their own customers; users may only access their own account.
+ */
+function assert_account_access(int $accountId): array {
+ global $currentUser;
+ $db = DB::getInstance();
+ $acct = $db->fetchOne("SELECT a.*, u.reseller_id FROM accounts a JOIN users u ON u.id = a.user_id WHERE a.id = ?", [$accountId]);
+ if (!$acct) Response::error("Account not found", 404);
+ if ($currentUser['role'] === 'reseller' && (int)$acct['reseller_id'] !== $currentUser['uid']) {
+ Response::error("Account not found", 404);
+ }
+ if ($currentUser['role'] === 'user') {
+ $own = $db->fetchOne("SELECT id FROM accounts WHERE id = ? AND user_id = ?", [$accountId, $currentUser['uid']]);
+ if (!$own) Response::error("Account not found", 404);
+ }
+ return $acct;
+}
+
require $endpointFile;
diff --git a/panel/assets/js/admin-additions.js b/panel/assets/js/admin-additions.js
new file mode 100644
index 0000000..c2251b8
--- /dev/null
+++ b/panel/assets/js/admin-additions.js
@@ -0,0 +1,510 @@
+// ── 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)}
+
+
+
Accounts
+
${accts.length}
+
+
+
+
+
+
+
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);
+};
diff --git a/panel/assets/js/admin.js b/panel/assets/js/admin.js
new file mode 100644
index 0000000..5c3d3ac
--- /dev/null
+++ b/panel/assets/js/admin.js
@@ -0,0 +1,1921 @@
+/**
+ * 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,
+ wordpress,
+ 'ssl-manager': sslManager,
+ firewall,
+ 'audit-log': auditLog,
+ twofa,
+ updates,
+ backups,
+ cloudflare,
+ settings,
+ };
+
+ 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 `
+
+
+
+
+
Username *
+
Password *
+
Email
+
Domain *
+
Package ${pkgOpts}
+
PHP Version
+
+ ${['8.3','8.2','8.1','7.4'].map(v => `PHP ${v} `).join('')}
+
+
+
+
+ Create Account
+ Cancel
+
+
+
+
`;
+ }
+
+ 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 `
+
+
+
+
Primary Nameserver
+
Secondary Nameserver
+
Hostname
+
+ Save Nameservers
+ Set Hostname
+
+
+
`;
+ }
+ 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('')}
+
+
+
+
+
+ View Queue
+ Flush Queue
+
+
+
`;
+ }
+ 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…
`; }
+
+ // ── 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)}
+
+
+
Accounts
+
${accts.length}
+
+
+
+
+
+
+
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);
+};
diff --git a/panel/assets/js/proxy-additions.js b/panel/assets/js/proxy-additions.js
new file mode 100644
index 0000000..304c2e5
--- /dev/null
+++ b/panel/assets/js/proxy-additions.js
@@ -0,0 +1,246 @@
+
+// ── 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 });
+};
diff --git a/panel/lib/DatabaseManager.php b/panel/lib/DatabaseManager.php
index e052884..c4dcfd7 100644
--- a/panel/lib/DatabaseManager.php
+++ b/panel/lib/DatabaseManager.php
@@ -15,7 +15,7 @@ class DatabaseManager {
$pdo->exec("FLUSH PRIVILEGES");
return (int)$db->insert(
- "INSERT INTO databases (account_id, db_name, db_user, db_pass, db_type) VALUES (?,?,?,?,?)",
+ "INSERT INTO `databases`(account_id, db_name, db_user, db_pass, db_type) VALUES (?,?,?,?,?)",
[$accountId, $dbName, $dbUser, encrypt($dbPass), 'mysql']
);
}
@@ -28,7 +28,7 @@ class DatabaseManager {
shell_exec("sudo -u postgres createdb -O {$dbUser} {$dbName} 2>/dev/null");
return (int)$db->insert(
- "INSERT INTO databases (account_id, db_name, db_user, db_pass, db_type) VALUES (?,?,?,?,?)",
+ "INSERT INTO `databases`(account_id, db_name, db_user, db_pass, db_type) VALUES (?,?,?,?,?)",
[$accountId, $dbName, $dbUser, encrypt($dbPass), 'postgresql']
);
}
@@ -43,12 +43,12 @@ class DatabaseManager {
shell_exec("sudo -u postgres dropdb --if-exists " . escapeshellarg($dbName) . " 2>/dev/null");
shell_exec("sudo -u postgres dropuser --if-exists " . escapeshellarg($dbUser) . " 2>/dev/null");
}
- DB::getInstance()->execute("DELETE FROM databases WHERE db_name = ? AND db_type = ?", [$dbName, $type]);
+ DB::getInstance()->execute("DELETE FROM `databases` WHERE db_name = ? AND db_type = ?", [$dbName, $type]);
}
public static function changePassword(int $id, string $newPass): void {
$db = DB::getInstance();
- $dbe = $db->fetchOne("SELECT * FROM databases WHERE id = ?", [$id]);
+ $dbe = $db->fetchOne("SELECT * FROM `databases` WHERE id = ?", [$id]);
if (!$dbe) throw new RuntimeException("Database not found");
if ($dbe['db_type'] === 'mysql') {
$pdo = $db->pdo();
@@ -56,7 +56,7 @@ class DatabaseManager {
} else {
shell_exec("sudo -u postgres psql -c \"ALTER USER {$dbe['db_user']} WITH PASSWORD " . escapeshellarg($newPass) . "\" 2>/dev/null");
}
- $db->execute("UPDATE databases SET db_pass = ? WHERE id = ?", [encrypt($newPass), $id]);
+ $db->execute("UPDATE `databases` SET db_pass = ? WHERE id = ?", [encrypt($newPass), $id]);
}
public static function getSize(string $dbName, string $type = 'mysql'): float {
diff --git a/tools/nova-db.sh b/tools/nova-db.sh
new file mode 100755
index 0000000..aa7e6e1
--- /dev/null
+++ b/tools/nova-db.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+# nova-db.sh — Quick DB access on NovaCPX VM
+# Usage: bash nova-db.sh [query] (run query and return result)
+# bash nova-db.sh (open interactive MySQL shell)
+# bash nova-db.sh --tables (list all tables with row counts)
+# bash nova-db.sh --users (list all panel users)
+# bash nova-db.sh --reset-admin (reset admin password)
+
+PVE1_HOST="orbisne.fortiddns.com"
+PVE1_PASS="Joker1974!!!"
+VM_IP="10.48.200.110"
+VM_PASS="Joker1974!!!"
+SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10"
+DB="novacpx"
+
+vm_mysql() {
+ sshpass -p "$PVE1_PASS" ssh $SSH_OPTS root@$PVE1_HOST \
+ "sshpass -p '$VM_PASS' ssh $SSH_OPTS root@$VM_IP 'mysql $DB -e \"$1\"'"
+}
+
+case "${1:-}" in
+ --tables)
+ echo "Tables in $DB:"
+ vm_mysql "SELECT table_name, table_rows FROM information_schema.tables WHERE table_schema='$DB' ORDER BY table_name;" 2>&1
+ ;;
+ --users)
+ echo "Panel users:"
+ vm_mysql "SELECT id, username, email, role, status, created_at FROM users ORDER BY id;" 2>&1
+ ;;
+ --reset-admin)
+ [[ -z "${2:-}" ]] && { echo "Usage: $0 --reset-admin "; exit 1; }
+ NEWPASS="$2"
+ HASH=$(php8.3 -r "echo password_hash('$NEWPASS', PASSWORD_BCRYPT);" 2>/dev/null)
+ vm_mysql "UPDATE users SET password='$HASH' WHERE username='admin';" 2>&1
+ echo "Admin password reset to: $NEWPASS"
+ ;;
+ "")
+ echo "Opening MySQL shell on $DB..."
+ sshpass -p "$PVE1_PASS" ssh -t $SSH_OPTS root@$PVE1_HOST \
+ "sshpass -p '$VM_PASS' ssh -t $SSH_OPTS root@$VM_IP 'mysql $DB'"
+ ;;
+ *)
+ vm_mysql "$*" 2>&1
+ ;;
+esac
diff --git a/tools/nova-deploy.sh b/tools/nova-deploy.sh
new file mode 100755
index 0000000..45c83f6
--- /dev/null
+++ b/tools/nova-deploy.sh
@@ -0,0 +1,68 @@
+#!/usr/bin/env bash
+# nova-deploy.sh — Full panel sync to NovaCPX VM
+# Usage: bash nova-deploy.sh [--php-check] [--restart]
+# --php-check : validate all PHP files before pushing (recommended)
+# --restart : restart apache2 after deploy
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(dirname "$SCRIPT_DIR")"
+WEB_ROOT="/srv/novacpx/public"
+
+PVE1_HOST="orbisne.fortiddns.com"
+PVE1_PASS="Joker1974!!!"
+VM_IP="10.48.200.110"
+VM_PASS="Joker1974!!!"
+SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10"
+
+PHP_CHECK=false
+DO_RESTART=false
+
+for arg in "$@"; do
+ case "$arg" in
+ --php-check) PHP_CHECK=true ;;
+ --restart) DO_RESTART=true ;;
+ esac
+done
+
+# PHP syntax check before deploy
+if $PHP_CHECK; then
+ echo "Running PHP syntax check..."
+ ERRORS=0
+ while IFS= read -r -d '' f; do
+ php8.3 -l "$f" > /dev/null 2>&1 || { echo "Syntax error: $f"; ERRORS=$((ERRORS+1)); }
+ done < <(find "$REPO_ROOT/panel" -name "*.php" -print0)
+ if [[ $ERRORS -gt 0 ]]; then
+ echo "Aborting: $ERRORS PHP syntax error(s) found"
+ exit 1
+ fi
+ echo "All PHP files OK"
+fi
+
+echo "Deploying panel files to VM..."
+
+# Pack panel into a tarball and push via base64
+TMPTAR=$(mktemp /tmp/novacpx-deploy-XXXX.tar.gz)
+tar -czf "$TMPTAR" -C "$REPO_ROOT/panel" public api lib
+CONTENT=$(base64 -w0 "$TMPTAR")
+rm -f "$TMPTAR"
+
+sshpass -p "$PVE1_PASS" ssh $SSH_OPTS root@$PVE1_HOST \
+ "sshpass -p '$VM_PASS' ssh $SSH_OPTS root@$VM_IP \
+ \"echo '$CONTENT' | base64 -d > /tmp/novacpx-deploy.tar.gz && \
+ tar -xzf /tmp/novacpx-deploy.tar.gz -C $WEB_ROOT --strip-components=1 && \
+ chown -R www-data:www-data $WEB_ROOT && \
+ find $WEB_ROOT -name '.htaccess' -exec chmod 644 {} \\; && \
+ rm /tmp/novacpx-deploy.tar.gz && \
+ echo 'Deploy complete'\""
+
+if $DO_RESTART; then
+ echo "Restarting web server..."
+ sshpass -p "$PVE1_PASS" ssh $SSH_OPTS root@$PVE1_HOST \
+ "sshpass -p '$VM_PASS' ssh $SSH_OPTS root@$VM_IP \
+ 'systemctl reload apache2 && systemctl reload php8.3-fpm && echo Reloaded'"
+fi
+
+echo ""
+echo "Deploy done. Panel: https://$VM_IP:8882"
diff --git a/tools/nova-github.sh b/tools/nova-github.sh
new file mode 100755
index 0000000..a9db988
--- /dev/null
+++ b/tools/nova-github.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+# nova-github.sh — Quick GitHub push for NovaCPX repo
+# Usage: bash nova-github.sh "commit message" (add all, commit, push)
+# bash nova-github.sh --status (git status + diff --stat)
+# bash nova-github.sh --log (last 10 commits)
+#
+# Requires: git, GitHub PAT already set on remote (see CLAUDE.md)
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(dirname "$SCRIPT_DIR")"
+
+GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m'
+
+cd "$REPO_ROOT" || { echo "Cannot cd to repo root: $REPO_ROOT"; exit 1; }
+
+case "${1:-}" in
+ --status)
+ git status
+ echo ""
+ git diff --stat
+ ;;
+ --log)
+ git log --oneline -10
+ ;;
+ "")
+ echo "Usage: $0 \"commit message\""
+ echo " $0 --status"
+ echo " $0 --log"
+ exit 1
+ ;;
+ *)
+ MSG="$*"
+ # PHP syntax check before commit
+ echo "Running PHP syntax check..."
+ if ! bash "$SCRIPT_DIR/nova-phpcheck.sh" > /dev/null 2>&1; then
+ echo -e "${RED}[✗]${NC} PHP syntax errors found. Run: bash tools/nova-phpcheck.sh --fix"
+ exit 1
+ fi
+ echo -e "${GREEN}[✓]${NC} PHP OK"
+
+ git add -A
+ git status --short
+ echo ""
+ git commit -m "$MSG
+
+Co-Authored-By: Claude Sonnet 4.6 "
+ git push origin main
+ echo ""
+ echo -e "${GREEN}[✓]${NC} Pushed. Auto-deploy will trigger within ~1 min."
+ ;;
+esac
diff --git a/tools/nova-logs.sh b/tools/nova-logs.sh
new file mode 100755
index 0000000..aefa272
--- /dev/null
+++ b/tools/nova-logs.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# nova-logs.sh — Stream/view NovaCPX logs from VM
+# Usage: bash nova-logs.sh [apache|access|install|fail2ban|all]
+# (no arg) : apache error log (default)
+
+PVE1_HOST="orbisne.fortiddns.com"
+PVE1_PASS="Joker1974!!!"
+VM_IP="10.48.200.110"
+VM_PASS="Joker1974!!!"
+SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10"
+
+TARGET="${1:-apache}"
+
+case "$TARGET" in
+ apache) LOG_CMD="tail -f /var/log/apache2/error.log" ;;
+ access) LOG_CMD="tail -f /var/log/novacpx/access.log" ;;
+ install) LOG_CMD="tail -100 /var/log/novacpx-install.log" ;;
+ fail2ban) LOG_CMD="tail -f /var/log/fail2ban.log" ;;
+ all) LOG_CMD="tail -f /var/log/apache2/error.log /var/log/novacpx/access.log" ;;
+ *) echo "Unknown log: $TARGET. Options: apache|access|install|fail2ban|all"; exit 1 ;;
+esac
+
+echo "Streaming $TARGET logs from VM $VM_IP..."
+echo "(Ctrl+C to stop)"
+echo ""
+
+sshpass -p "$PVE1_PASS" ssh -t $SSH_OPTS root@$PVE1_HOST \
+ "sshpass -p '$VM_PASS' ssh -t $SSH_OPTS root@$VM_IP '$LOG_CMD'"
diff --git a/tools/nova-phpcheck.sh b/tools/nova-phpcheck.sh
new file mode 100755
index 0000000..66258f1
--- /dev/null
+++ b/tools/nova-phpcheck.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+# nova-phpcheck.sh — PHP static analysis on all panel PHP files
+# Usage: bash nova-phpcheck.sh [path] (default: ../panel)
+# bash nova-phpcheck.sh --fix (show errors + line context)
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+TARGET="${1:-$SCRIPT_DIR/../panel}"
+FIX=false
+[[ "${1:-}" == "--fix" ]] && { FIX=true; TARGET="${2:-$SCRIPT_DIR/../panel}"; }
+
+GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m'
+
+PHP_BIN=$(command -v php8.3 || command -v php || echo "")
+[[ -z "$PHP_BIN" ]] && { echo "No PHP binary found. Install php8.3."; exit 1; }
+
+echo "Checking PHP syntax in: $TARGET"
+echo "PHP: $($PHP_BIN --version | head -1)"
+echo ""
+
+ERRORS=0; FILES=0
+while IFS= read -r -d '' file; do
+ FILES=$((FILES+1))
+ OUTPUT=$("$PHP_BIN" -l "$file" 2>&1)
+ if echo "$OUTPUT" | grep -q "No syntax errors"; then
+ :
+ else
+ echo -e "${RED}[ERROR]${NC} $file"
+ echo " $OUTPUT"
+ if $FIX; then
+ # Show 3 lines of context around the error
+ LINENUM=$(echo "$OUTPUT" | grep -oP 'on line \K[0-9]+' | head -1)
+ if [[ -n "$LINENUM" ]]; then
+ START=$(( LINENUM > 3 ? LINENUM - 3 : 1 ))
+ echo ""
+ sed -n "${START},$((LINENUM+2))p" "$file" | nl -ba -v"$START" | \
+ awk -v err="$LINENUM" '{if(NR==err-START+4) printf "\033[31m→ %s\033[0m\n",$0; else print " "$0}'
+ echo ""
+ fi
+ fi
+ ERRORS=$((ERRORS+1))
+ fi
+done < <(find "$TARGET" -name "*.php" -not -path "*/vendor/*" -print0)
+
+echo ""
+if [[ $ERRORS -eq 0 ]]; then
+ echo -e "${GREEN}[✓]${NC} All $FILES PHP files OK"
+ exit 0
+else
+ echo -e "${RED}[✗]${NC} $ERRORS error(s) in $FILES files"
+ exit 1
+fi
diff --git a/tools/nova-push.sh b/tools/nova-push.sh
new file mode 100755
index 0000000..194e566
--- /dev/null
+++ b/tools/nova-push.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+# nova-push.sh — Push a local file to the NovaCPX VM via double-hop
+# Usage: bash nova-push.sh
+# Example: bash nova-push.sh panel/api/index.php /srv/novacpx/public/api/index.php
+
+set -euo pipefail
+
+PVE1_HOST="orbisne.fortiddns.com"
+PVE1_PASS="Joker1974!!!"
+VM_IP="10.48.200.110"
+VM_PASS="Joker1974!!!"
+SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10"
+
+LOCAL="$1"
+REMOTE="$2"
+
+[[ -f "$LOCAL" ]] || { echo "Error: $LOCAL not found"; exit 1; }
+
+echo "Pushing $LOCAL → VM:$REMOTE"
+CONTENT=$(base64 -w0 "$LOCAL")
+sshpass -p "$PVE1_PASS" ssh $SSH_OPTS root@$PVE1_HOST \
+ "sshpass -p '$VM_PASS' ssh $SSH_OPTS root@$VM_IP \
+ \"mkdir -p \$(dirname $REMOTE) && echo '$CONTENT' | base64 -d > $REMOTE && chown www-data:www-data $REMOTE\""
+echo "Done."
diff --git a/tools/nova-ssh.sh b/tools/nova-ssh.sh
new file mode 100755
index 0000000..67ab4e3
--- /dev/null
+++ b/tools/nova-ssh.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+# nova-ssh.sh — SSH into the NovaCPX VM via double-hop through PVE1
+# Usage: bash nova-ssh.sh [command] (run command on VM)
+# bash nova-ssh.sh (interactive shell on VM)
+# bash nova-ssh.sh --pve1 [command] (run on PVE1 instead)
+#
+# Hop path: local → PVE1 (10.48.200.90 via orbisne.fortiddns.com) → VM (10.48.200.110)
+
+PVE1_HOST="orbisne.fortiddns.com"
+PVE1_PASS="Joker1974!!!"
+VM_IP="10.48.200.110"
+VM_PASS="Joker1974!!!"
+SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10"
+
+if [[ "${1:-}" == "--pve1" ]]; then
+ shift
+ if [[ $# -eq 0 ]]; then
+ sshpass -p "$PVE1_PASS" ssh $SSH_OPTS root@$PVE1_HOST
+ else
+ sshpass -p "$PVE1_PASS" ssh $SSH_OPTS root@$PVE1_HOST "$*"
+ fi
+else
+ CMD="$*"
+ if [[ -z "$CMD" ]]; then
+ # Interactive shell
+ sshpass -p "$PVE1_PASS" ssh $SSH_OPTS root@$PVE1_HOST \
+ "sshpass -p '$VM_PASS' ssh $SSH_OPTS root@$VM_IP"
+ else
+ sshpass -p "$PVE1_PASS" ssh $SSH_OPTS root@$PVE1_HOST \
+ "sshpass -p '$VM_PASS' ssh $SSH_OPTS root@$VM_IP '$CMD'"
+ fi
+fi
diff --git a/tools/nova-status.sh b/tools/nova-status.sh
new file mode 100755
index 0000000..21ef9ec
--- /dev/null
+++ b/tools/nova-status.sh
@@ -0,0 +1,69 @@
+#!/usr/bin/env bash
+# nova-status.sh — Check NovaCPX VM health: SSH, panel ports, services, logs
+# Usage: bash nova-status.sh [--full]
+# (no flags) : quick port check
+# --full : also show recent error logs
+
+PVE1_HOST="orbisne.fortiddns.com"
+PVE1_PASS="Joker1974!!!"
+VM_IP="10.48.200.110"
+VM_PASS="Joker1974!!!"
+SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=8"
+FULL=false
+[[ "${1:-}" == "--full" ]] && FULL=true
+
+GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m'
+ok() { echo -e "${GREEN}[✓]${NC} $*"; }
+fail() { echo -e "${RED}[✗]${NC} $*"; }
+warn() { echo -e "${YELLOW}[!]${NC} $*"; }
+
+echo "=== NovaCPX VM Status ==="
+echo "VM: $VM_IP"
+
+# SSH reachability
+if sshpass -p "$PVE1_PASS" ssh $SSH_OPTS root@$PVE1_HOST \
+ "sshpass -p '$VM_PASS' ssh $SSH_OPTS root@$VM_IP 'echo ok'" 2>/dev/null | grep -q ok; then
+ ok "SSH reachable via PVE1"
+else
+ fail "SSH NOT reachable via PVE1 → $VM_IP"
+fi
+
+# Panel port checks via curl through DO
+echo ""
+echo "Panel ports:"
+for PORT in 8880 8881 8882; do
+ LABEL="user"
+ [[ $PORT -eq 8881 ]] && LABEL="reseller"
+ [[ $PORT -eq 8882 ]] && LABEL="admin"
+ STATUS=$(curl -sk --max-time 5 -o /dev/null -w "%{http_code}" "https://$VM_IP:$PORT/" 2>/dev/null || echo "ERR")
+ if [[ "$STATUS" =~ ^[23] ]]; then
+ ok "Port $PORT ($LABEL): HTTP $STATUS"
+ elif [[ "$STATUS" == "401" || "$STATUS" == "403" ]]; then
+ ok "Port $PORT ($LABEL): HTTP $STATUS (auth required — panel is up)"
+ else
+ fail "Port $PORT ($LABEL): HTTP $STATUS"
+ fi
+done
+
+# API endpoint
+API_STATUS=$(curl -sk --max-time 5 -o /dev/null -w "%{http_code}" -X POST \
+ "https://$VM_IP:8882/api/auth/login" \
+ -H "Content-Type: application/json" \
+ -d '{"username":"probe","password":"probe"}' 2>/dev/null || echo "ERR")
+if [[ "$API_STATUS" == "401" || "$API_STATUS" == "200" ]]; then
+ ok "API auth endpoint: HTTP $API_STATUS (responding)"
+else
+ fail "API auth endpoint: HTTP $API_STATUS"
+fi
+
+if $FULL; then
+ echo ""
+ echo "=== Recent error logs ==="
+ sshpass -p "$PVE1_PASS" ssh $SSH_OPTS root@$PVE1_HOST \
+ "sshpass -p '$VM_PASS' ssh $SSH_OPTS root@$VM_IP \
+ 'tail -20 /var/log/apache2/error.log 2>/dev/null; echo ---; tail -20 /var/log/novacpx/access.log 2>/dev/null'" 2>/dev/null || \
+ warn "Could not read logs (SSH unavailable)"
+fi
+
+echo ""
+echo "Panel URL: https://$VM_IP:8882 (admin)"
diff --git a/tools/nova-test.sh b/tools/nova-test.sh
new file mode 100755
index 0000000..a8b79d8
--- /dev/null
+++ b/tools/nova-test.sh
@@ -0,0 +1,139 @@
+#!/usr/bin/env bash
+# nova-test.sh — NovaCPX API endpoint test suite
+# Tests auth, common endpoints, and panel responses against the live VM
+# Usage: bash nova-test.sh [--host IP] [--admin-pass PASS]
+
+set -euo pipefail
+
+HOST="${NOVACPX_HOST:-10.48.200.110}"
+ADMIN_PASS="${NOVACPX_ADMIN_PASS:-bUe9JXTRmWJbyrFA}"
+PORT_USER=8880; PORT_RESELLER=8881; PORT_ADMIN=8882; PORT_WEBMAIL=8883
+PASS_CHECKS=0; FAIL_CHECKS=0; SKIP_CHECKS=0
+
+GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
+
+for arg in "$@"; do
+ case "$arg" in
+ --host) HOST="${2:-}"; shift ;;
+ --admin-pass) ADMIN_PASS="${2:-}"; shift ;;
+ esac
+done
+
+check() {
+ local name="$1" expected="$2" actual="$3" detail="${4:-}"
+ if [[ "$actual" == "$expected" ]]; then
+ echo -e "${GREEN}[PASS]${NC} $name${detail:+ — $detail}"
+ PASS_CHECKS=$((PASS_CHECKS+1))
+ else
+ echo -e "${RED}[FAIL]${NC} $name — expected $expected, got $actual ${detail:+($detail)}"
+ FAIL_CHECKS=$((FAIL_CHECKS+1))
+ fi
+}
+
+api() {
+ local port="$1" method="$2" endpoint="$3" data="${4:-}"
+ local url="https://$HOST:$port/api/$endpoint"
+ local args=(-sk --max-time 8 -X "$method" -H "Content-Type: application/json")
+ [[ -n "${TOKEN:-}" ]] && args+=(-H "Authorization: Bearer $TOKEN")
+ [[ -n "$data" ]] && args+=(-d "$data")
+ curl "${args[@]}" -w "\n%{http_code}" "$url" 2>/dev/null
+}
+
+http_code() { echo "$1" | tail -1; }
+body() { echo "$1" | head -n -1; }
+
+echo ""
+echo -e "${BLUE}══════════════════════════════════════════${NC}"
+echo -e "${BLUE} NovaCPX API Test Suite${NC}"
+echo -e "${BLUE} Host: $HOST${NC}"
+echo -e "${BLUE}══════════════════════════════════════════${NC}"
+echo ""
+
+# ── 1. Panel ports reachable ──────────────────────────────────────────────────
+echo "── Panel Ports ──"
+for PORT in $PORT_USER $PORT_RESELLER $PORT_ADMIN $PORT_WEBMAIL; do
+ LABEL="user"; [[ $PORT -eq 8881 ]] && LABEL="reseller"; [[ $PORT -eq 8882 ]] && LABEL="admin"; [[ $PORT -eq 8883 ]] && LABEL="webmail"
+ SC=$(curl -sk --max-time 6 -o /dev/null -w "%{http_code}" "https://$HOST:$PORT/" 2>/dev/null || echo "ERR")
+ if [[ "$SC" =~ ^[23] ]] || [[ "$SC" == "401" ]] || [[ "$SC" == "403" ]]; then
+ echo -e "${GREEN}[PASS]${NC} Port $PORT ($LABEL): HTTP $SC"
+ PASS_CHECKS=$((PASS_CHECKS+1))
+ else
+ echo -e "${RED}[FAIL]${NC} Port $PORT ($LABEL): HTTP $SC (not responding)"
+ FAIL_CHECKS=$((FAIL_CHECKS+1))
+ fi
+done
+
+# ── 2. Auth — bad credentials ─────────────────────────────────────────────────
+echo ""
+echo "── Auth Endpoint ──"
+RESP=$(api $PORT_ADMIN POST "auth/login" '{"username":"nobody","password":"badpass"}')
+check "Bad login returns 401" "401" "$(http_code "$RESP")"
+
+# ── 3. Auth — valid login ─────────────────────────────────────────────────────
+RESP=$(api $PORT_ADMIN POST "auth/login" "{\"username\":\"admin\",\"password\":\"$ADMIN_PASS\"}")
+SC=$(http_code "$RESP")
+check "Admin login succeeds (200)" "200" "$SC"
+TOKEN=$(body "$RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('data',{}).get('token',''))" 2>/dev/null || echo "")
+if [[ -n "$TOKEN" ]]; then
+ echo -e "${GREEN}[INFO]${NC} Token acquired: ${TOKEN:0:20}..."
+else
+ echo -e "${YELLOW}[WARN]${NC} No token in login response — subsequent tests will fail auth"
+fi
+
+# ── 4. Auth — me endpoint ─────────────────────────────────────────────────────
+RESP=$(api $PORT_ADMIN GET "auth/me")
+check "auth/me returns 200" "200" "$(http_code "$RESP")"
+ROLE=$(body "$RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('data',{}).get('role',''))" 2>/dev/null || echo "")
+check "auth/me role=admin" "admin" "$ROLE"
+
+# ── 5. Packages ───────────────────────────────────────────────────────────────
+echo ""
+echo "── Packages ──"
+RESP=$(api $PORT_ADMIN GET "packages/list")
+check "packages/list returns 200" "200" "$(http_code "$RESP")"
+
+# ── 6. Domains ────────────────────────────────────────────────────────────────
+echo ""
+echo "── Domains ──"
+RESP=$(api $PORT_ADMIN GET "domains/list")
+check "domains/list returns 200" "200" "$(http_code "$RESP")"
+
+# ── 7. System ─────────────────────────────────────────────────────────────────
+echo ""
+echo "── System ──"
+RESP=$(api $PORT_ADMIN GET "system/status")
+check "system/status returns 200" "200" "$(http_code "$RESP")"
+
+RESP=$(api $PORT_ADMIN GET "system/services")
+check "system/services returns 200" "200" "$(http_code "$RESP")"
+
+# ── 8. Firewall ───────────────────────────────────────────────────────────────
+echo ""
+echo "── Firewall ──"
+RESP=$(api $PORT_ADMIN GET "firewall/status")
+check "firewall/status returns 200" "200" "$(http_code "$RESP")"
+
+# ── 9. Stats ──────────────────────────────────────────────────────────────────
+echo ""
+echo "── Stats ──"
+RESP=$(api $PORT_ADMIN GET "stats/overview")
+check "stats/overview returns 200" "200" "$(http_code "$RESP")"
+
+# ── 10. Unauthorized access ───────────────────────────────────────────────────
+echo ""
+echo "── Auth Enforcement ──"
+TOKEN=""
+RESP=$(api $PORT_ADMIN GET "packages/list")
+check "packages/list without token returns 401" "401" "$(http_code "$RESP")"
+
+RESP=$(api $PORT_ADMIN GET "firewall/status")
+check "firewall/status without token returns 401" "401" "$(http_code "$RESP")"
+
+# ── Summary ───────────────────────────────────────────────────────────────────
+TOTAL=$((PASS_CHECKS + FAIL_CHECKS + SKIP_CHECKS))
+echo ""
+echo -e "${BLUE}══════════════════════════════════════════${NC}"
+echo -e " Results: ${GREEN}$PASS_CHECKS passed${NC} ${RED}$FAIL_CHECKS failed${NC} ${YELLOW}$SKIP_CHECKS skipped${NC} / $TOTAL total"
+echo -e "${BLUE}══════════════════════════════════════════${NC}"
+echo ""
+[[ $FAIL_CHECKS -eq 0 ]] && exit 0 || exit 1