Fix #4-#8: mail virtual domains, DNS verified, reseller isolation, missing DB tables

#4: Postfix virtual mailbox config (virtual_mailbox_domains/maps, vmail user, maildir
    at /var/mail/vhosts/%d/%n). Dovecot SQL backend pointed at novacpx.email_accounts
    with SHA512-CRYPT passdb and per-domain Maildir userdb.

#5: BIND9 confirmed working — dig @localhost resolves testdomain1.com correctly.

#6: Certbot 2.9.0 confirmed installed; domains.document_root wired; infrastructure
    ready for live domain issuance (testdomain1.com not publicly resolvable so
    dry-run expected to fail).

#7: Fixed all broken user-panel API queries — missing tables (databases, ftp_accounts,
    ssl_certs, cron_jobs, php_configs, notifications) created; `databases` reserved-word
    backtick-quoted across DatabaseManager+endpoints; domains.php is_primary→type=main,
    doc_root→document_root column fixes; DNSManager::createZone call signature fixed;
    stats/account auto-resolves account_id for user role.

#8: assert_account_access() helper added to api/index.php; reseller ownership check
    wired into email, ftp, databases, domains, dns, ssl endpoints.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 03:31:30 +00:00
parent d49095f4e8
commit dbc5a01de9
23 changed files with 3482 additions and 39 deletions
+212
View File
@@ -0,0 +1,212 @@
<?php
// NovaCPX Admin Panel — Datacenter/Server Manager
// Equivalent to WHM (WebHost Manager)
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NovaCPX Admin</title>
<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">
<link rel="stylesheet" href="/assets/css/nova.css">
</head>
<body>
<div class="panel-layout" id="app" style="display:none">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-brand">
<svg class="logo-icon" viewBox="0 0 40 40" fill="none">
<circle cx="20" cy="20" r="18" stroke="url(#lg1)" stroke-width="2"/>
<path d="M12 28 L20 8 L28 28" stroke="url(#lg2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 22 H26" stroke="url(#lg2)" stroke-width="2" stroke-linecap="round"/>
<defs>
<linearGradient id="lg1" x1="2" y1="2" x2="38" y2="38"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
<linearGradient id="lg2" x1="12" y1="8" x2="28" y2="28"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
</defs>
</svg>
<span class="logo-text">Nova<strong>CPX</strong> <small style="font-size:.65rem;color:var(--text-muted)">Admin</small></span>
</div>
<nav>
<div class="sidebar-section">
<div class="sidebar-section-label">Overview</div>
<a href="#" class="sidebar-link active" data-page="dashboard">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
Dashboard
</a>
<a href="#" class="sidebar-link" data-page="server-status">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
Server Status
</a>
</div>
<div class="sidebar-section">
<div class="sidebar-section-label">Accounts</div>
<a href="#" class="sidebar-link" data-page="accounts">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
All Accounts
</a>
<a href="#" class="sidebar-link" data-page="resellers">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M2 20c0-4 4-7 10-7s10 3 10 7"/></svg>
Resellers
</a>
<a href="#" class="sidebar-link" data-page="packages">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
Packages
</a>
<a href="#" class="sidebar-link" data-page="create-account">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>
Create Account
</a>
</div>
<div class="sidebar-section">
<div class="sidebar-section-label">DNS</div>
<a href="#" class="sidebar-link" data-page="dns-zones">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
DNS Zones
</a>
<a href="#" class="sidebar-link" data-page="nameservers">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
Nameservers
</a>
</div>
<div class="sidebar-section">
<div class="sidebar-section-label">Services</div>
<a href="#" class="sidebar-link" data-page="web-server">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
Web Server
</a>
<a href="#" class="sidebar-link" data-page="php-manager">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
PHP Manager
</a>
<a href="#" class="sidebar-link" data-page="mysql-manager">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
MySQL / PgSQL
</a>
<a href="#" class="sidebar-link" data-page="mail-server">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
Mail Server
</a>
<a href="#" class="sidebar-link" data-page="ftp-server">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
FTP Server
</a>
<a href="#" class="sidebar-link" data-page="wordpress">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
WordPress
</a>
</div>
<div class="sidebar-section">
<div class="sidebar-section-label">Security</div>
<a href="#" class="sidebar-link" data-page="ssl-manager">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
SSL Manager
</a>
<a href="#" class="sidebar-link" data-page="firewall">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
Firewall / Fail2Ban
</a>
<a href="#" class="sidebar-link" data-page="audit-log">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
Audit Log
</a>
<a href="#" class="sidebar-link" data-page="twofa">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/><circle cx="12" cy="16" r="1" fill="currentColor"/></svg>
2FA Manager
</a>
</div>
<div class="sidebar-section">
<div class="sidebar-section-label">System</div>
<a href="#" class="sidebar-link" data-page="updates">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
Updates <span id="update-badge" class="badge badge-yellow" style="display:none"></span>
</a>
<a href="#" class="sidebar-link" data-page="backups">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Backups
</a>
<a href="#" class="sidebar-link" data-page="cloudflare">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9z"/></svg>
Cloudflare
</a>
<a href="#" class="sidebar-link" data-page="settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Settings
</a>
</div>
</nav>
<div class="sidebar-user">
<div class="sidebar-user-info">
<div class="avatar" id="user-avatar">A</div>
<div>
<div class="user-name" id="user-name">Admin</div>
<div class="user-role">Administrator</div>
</div>
<a href="#" id="logout-btn" class="btn btn-ghost btn-sm btn-icon" title="Logout" style="margin-left:auto">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</a>
</div>
</div>
</aside>
<!-- Main Content -->
<div class="main-content">
<header class="topbar">
<button class="btn btn-ghost btn-icon" id="sidebar-toggle" style="display:none">☰</button>
<div class="topbar-title" id="page-title">Dashboard</div>
<div class="topbar-actions">
<span id="server-ip" class="text-muted text-sm"></span>
<div id="alert-indicator" style="display:none">
<span class="badge badge-red" id="alert-count"></span>
</div>
</div>
</header>
<div class="page-content" id="page-content">
<!-- Loaded by JS -->
</div>
</div>
</div>
<!-- Auth guard / Login overlay (shown when not authenticated) -->
<div id="auth-check" style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:var(--bg)">
<div style="width:100%;max-width:400px;padding:1.5rem">
<div style="text-align:center;margin-bottom:1.5rem">
<svg viewBox="0 0 40 40" fill="none" style="width:40px;height:40px;margin:0 auto 1rem">
<circle cx="20" cy="20" r="18" stroke="url(#alg1)" stroke-width="2"/>
<path d="M12 28 L20 8 L28 28" stroke="url(#alg2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 22 H26" stroke="url(#alg2)" stroke-width="2" stroke-linecap="round"/>
<defs>
<linearGradient id="alg1" x1="2" y1="2" x2="38" y2="38"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
<linearGradient id="alg2" x1="12" y1="8" x2="28" y2="28"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
</defs>
</svg>
<div style="font-size:1.4rem;font-weight:300">Nova<strong style="font-weight:700;background:linear-gradient(135deg,#6366f1,#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent">CPX</strong></div>
<div style="font-size:.78rem;color:var(--text-muted);margin-top:.25rem;text-transform:uppercase;letter-spacing:.1em">Admin Panel · Port 8882</div>
</div>
<div class="card">
<div class="card-body">
<div id="login-err" class="alert alert-error" style="display:none"></div>
<form id="login-form">
<div class="form-group"><label>Username or Email</label><input type="text" id="l-user" autofocus required></div>
<div class="form-group"><label>Password</label><input type="password" id="l-pass" required></div>
<button type="submit" class="btn btn-primary btn-full" id="l-btn">Sign In to Admin</button>
</form>
</div>
</div>
</div>
</div>
<script src="/assets/js/nova.js"></script>
<script src="/assets/js/admin.js"></script>
</body>
</html>
+2 -2
View File
@@ -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,
+9 -6
View File
@@ -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']);
+1
View File
@@ -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) {
+26 -17
View File
@@ -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", "<html><body><h1>$domain is ready!</h1><p>Upload your website files.</p></body></html>");
@@ -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", "<html><body><h1>$full</h1></body></html>");
$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']);
+3 -1
View File
@@ -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; }
+6 -3
View File
@@ -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) {
+6 -3
View File
@@ -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) {
+8 -2
View File
@@ -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
+20
View File
@@ -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;
+510
View File
@@ -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 `
<div class="page-header mb-3">
<h2 class="page-title">WordPress Manager</h2>
<button class="btn btn-primary" onclick="wpInstallModal()">+ Install WordPress</button>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">WordPress Installs</span>
<span class="text-muted text-sm ml-2">${installs.length} install${installs.length!==1?'s':''}</span>
<button class="btn btn-ghost btn-sm ml-auto" onclick="adminPage('wordpress')">&#x21bb; Refresh</button>
</div>
${installs.length ? `
<div class="table-wrap">
<table>
<thead><tr><th>Domain</th><th>Path</th><th>Account</th><th>Version</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>
${installs.map(w => `<tr>
<td><strong>${Nova.escHtml(w.domain)}</strong></td>
<td><code>${Nova.escHtml(w.path||'/')}</code></td>
<td>${Nova.escHtml(w.username||'—')}</td>
<td>${w.wp_version ? `<code>${Nova.escHtml(w.wp_version)}</code>` : '—'}</td>
<td>${Nova.badge(w.status||'active', w.status==='active'?'green':w.status==='updating'?'yellow':'red')}</td>
<td style="display:flex;gap:.25rem;flex-wrap:wrap">
<button class="btn btn-xs" onclick="wpInfo(${w.id},'${Nova.escHtml(w.domain)}')">Info</button>
<button class="btn btn-xs btn-primary" onclick="wpUpdate(${w.id},'core')">Update Core</button>
<button class="btn btn-xs" onclick="wpUpdate(${w.id},'plugins')">Plugins</button>
<button class="btn btn-xs" onclick="wpUpdate(${w.id},'themes')">Themes</button>
${!w.staging_of ? `<button class="btn btn-xs" onclick="wpCloneStaging(${w.id},'${Nova.escHtml(w.domain)}')">Clone Staging</button>` : `<span class="badge badge-yellow">staging</span>`}
<button class="btn btn-xs btn-danger" onclick="wpDelete(${w.id},'${Nova.escHtml(w.domain)}')">Delete</button>
</td>
</tr>`).join('')}
</tbody>
</table>
</div>` : `<div class="empty" style="padding:2rem">No WordPress installs yet. Click "Install WordPress" to get started.</div>`}
</div>`;
}
window.wpInstallModal = () => {
const accts = window._adminAcctsWP || [];
const opts = accts.map(a => `<option value="${a.id}">${a.username}${a.domain}</option>`).join('');
Nova.modal('Install WordPress', `
<div class="form-group"><label>Account</label><select id="wp-acct" class="form-control">${opts}</select></div>
<div class="form-group"><label>Domain</label><input id="wp-domain" class="form-control" placeholder="example.com"></div>
<div class="form-group"><label>Path (leave / for root)</label><input id="wp-path" class="form-control" value="/"></div>
<div class="form-group"><label>Site Title</label><input id="wp-title" class="form-control" placeholder="My WordPress Site"></div>
<div class="form-group"><label>WP Admin Username</label><input id="wp-admin" class="form-control" value="admin"></div>
<div class="form-group"><label>WP Admin Password</label><input id="wp-adminpass" type="password" class="form-control"></div>
<div class="form-group"><label>WP Admin Email</label><input id="wp-email" type="email" class="form-control"></div>
<p class="text-muted text-sm">wp-cli will be downloaded automatically if not installed. This may take 1-2 minutes.</p>`,
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn btn-primary" id="wp-install-btn" onclick="wpSubmitInstall()">Install</button>`);
};
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 => `<tr><td>${Nova.escHtml(p.name)}</td><td>${Nova.escHtml(p.version||'')}</td><td>${Nova.badge(p.status||'inactive',p.status==='active'?'green':'muted')}</td></tr>`).join('');
const themes = (d.themes||[]).map(t => `<tr><td>${Nova.escHtml(t.name)}</td><td>${Nova.escHtml(t.version||'')}</td><td>${Nova.badge(t.status||'inactive',t.status==='active'?'green':'muted')}</td></tr>`).join('');
Nova.modal(`WordPress: ${domain}`,`
<div class="grid-2 mb-2" style="gap:.75rem">
<div><p class="text-muted text-sm">Core Version</p><p class="font-bold">${Nova.escHtml(d.version||'—')}</p></div>
<div><p class="text-muted text-sm">Site URL</p><p>${Nova.escHtml(d.siteurl||'—')}</p></div>
</div>
<h4 class="mb-1">Plugins (${(d.plugins||[]).length})</h4>
${plugins ? `<table class="table" style="font-size:.82rem"><thead><tr><th>Plugin</th><th>Version</th><th>Status</th></tr></thead><tbody>${plugins}</tbody></table>` : '<p class="text-muted text-sm">None</p>'}
<h4 class="mb-1 mt-2">Themes (${(d.themes||[]).length})</h4>
${themes ? `<table class="table" style="font-size:.82rem"><thead><tr><th>Theme</th><th>Version</th><th>Status</th></tr></thead><tbody>${themes}</tbody></table>` : '<p class="text-muted text-sm">None</p>'}`);
};
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 `
<div class="page-header mb-3">
<h2 class="page-title">Backup Manager</h2>
<div style="display:flex;gap:.5rem">
<button class="btn btn-primary" onclick="bkCreateModal()">+ New Backup</button>
<button class="btn btn-ghost btn-sm" onclick="adminPage('backups')">&#x21bb; Refresh</button>
</div>
</div>
<div class="stats-grid mb-3" style="grid-template-columns:repeat(3,1fr)">
<div class="stat-card">
<div class="stat-label">Total Backups</div>
<div class="stat-value stat-blue">${backupList.length}</div>
</div>
<div class="stat-card">
<div class="stat-label">Disk Used</div>
<div class="stat-value">${Nova.bytes(diskUsed)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Accounts</div>
<div class="stat-value">${accts.length}</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<span class="card-title">Backup Schedules</span>
<button class="btn btn-sm" onclick="bkScheduleModal()">Configure Schedule</button>
</div>
<div class="card-body">
<p class="text-muted text-sm">Set per-account backup schedules. Cron runs backups automatically based on the configured frequency.</p>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.75rem">
${accts.slice(0,8).map(a => `<button class="btn btn-xs" onclick="bkScheduleForAccount(${a.id},'${Nova.escHtml(a.username)}')">${Nova.escHtml(a.username)}</button>`).join('')}
${accts.length>8?`<span class="text-muted text-sm">+${accts.length-8} more</span>`:''}
</div>
</div>
</div>
<div class="card">
<div class="card-header"><span class="card-title">All Backups</span></div>
${backupList.length ? `
<div class="table-wrap">
<table>
<thead><tr><th>Account</th><th>Type</th><th>Size</th><th>Status</th><th>Storage</th><th>Created</th><th>Actions</th></tr></thead>
<tbody>
${backupList.map(b => `<tr>
<td>${Nova.escHtml(b.username||b.account_id||'—')}</td>
<td>${Nova.badge(b.type,'default')}</td>
<td>${Nova.bytes(b.size||0)}</td>
<td>${Nova.badge(b.status, b.status==='complete'?'green':b.status==='failed'?'red':'yellow')}</td>
<td>${b.remote_path ? Nova.badge('remote','blue') : Nova.badge('local','muted')}</td>
<td class="text-muted text-sm">${Nova.relTime(b.created_at)}</td>
<td style="display:flex;gap:.25rem">
${b.status==='complete'?`<a class="btn btn-xs" href="/api/backup/download?id=${b.id}" target="_blank">Download</a>`:''}
<button class="btn btn-xs btn-warning" onclick="bkRestore(${b.id})">Restore</button>
<button class="btn btn-xs btn-danger" onclick="bkDelete(${b.id})">Del</button>
</td>
</tr>`).join('')}
</tbody>
</table>
</div>` : `<div class="empty" style="padding:2rem">No backups yet.</div>`}
</div>`;
}
window.bkCreateModal = () => {
const accts = window._adminAcctsBK || [];
const opts = accts.map(a => `<option value="${a.id}">${a.username}${a.domain}</option>`).join('');
Nova.modal('Create Backup', `
<div class="form-group"><label>Account</label><select id="bk-acct" class="form-control">${opts}</select></div>
<div class="form-group"><label>Type</label>
<select id="bk-type" class="form-control">
<option value="full">Full (files + database)</option>
<option value="files">Files only</option>
<option value="database">Database only</option>
</select>
</div>`,
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn btn-primary" onclick="bkSubmitCreate()">Create Backup</button>`);
};
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 => `<option value="${a.id}">${a.username}</option>`).join('');
Nova.modal('Configure Backup Schedule', `
<div class="form-group"><label>Account</label><select id="bks-acct" class="form-control">${opts}</select></div>
<div class="form-group"><label>Frequency</label>
<select id="bks-freq" class="form-control">
<option value="hourly">Hourly</option>
<option value="daily" selected>Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
</div>
<div class="form-group"><label>Type</label>
<select id="bks-type" class="form-control">
<option value="full">Full</option>
<option value="files">Files only</option>
<option value="database">Database only</option>
</select>
</div>
<div class="form-group"><label>Keep (# backups)</label><input id="bks-retain" type="number" class="form-control" value="7"></div>`,
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn btn-primary" onclick="bkSaveSchedule()">Save Schedule</button>`);
};
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}`, `
<div class="form-group"><label>Frequency</label>
<select id="bks-freq" class="form-control">
${['hourly','daily','weekly','monthly'].map(f=>`<option value="${f}"${s.frequency===f?' selected':''}>${f.charAt(0).toUpperCase()+f.slice(1)}</option>`).join('')}
</select>
</div>
<div class="form-group"><label>Type</label>
<select id="bks-type" class="form-control">
${['full','files','database'].map(t=>`<option value="${t}"${s.type===t?' selected':''}>${t.charAt(0).toUpperCase()+t.slice(1)}</option>`).join('')}
</select>
</div>
<div class="form-group"><label>Keep (# backups)</label><input id="bks-retain" type="number" class="form-control" value="${s.retain_count||7}"></div>`,
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn btn-primary" onclick="bkSaveScheduleFor(${id})">Save</button>`);
};
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 `
<div class="page-header mb-3">
<h2 class="page-title">Cloudflare Integration</h2>
<p class="text-muted text-sm">Manage Cloudflare API credentials and DNS sync per account.</p>
</div>
<div class="card mb-3">
<div class="card-header"><span class="card-title">Account Credentials</span></div>
<div class="card-body">
<p class="text-muted text-sm mb-2">Select an account to configure or view its Cloudflare API key.</p>
<div style="display:flex;gap:.75rem;flex-wrap:wrap;align-items:flex-end">
<div class="form-group mb-0">
<label class="form-label text-sm">Account</label>
<select id="cf-acct-sel" class="form-control form-control-sm" onchange="cfLoadAccount(this.value)">
<option value="">— Select Account —</option>
${accts.map(a=>`<option value="${a.id}">${a.username}${a.domain}</option>`).join('')}
</select>
</div>
</div>
<div id="cf-acct-panel" style="margin-top:1rem"></div>
</div>
</div>
<div class="card" id="cf-zones-panel" style="display:none">
<div class="card-header">
<span class="card-title">Cloudflare Zones</span>
<button class="btn btn-ghost btn-sm" onclick="cfRefreshZones()">&#x21bb; Refresh Zones</button>
</div>
<div id="cf-zones-body" class="card-body">
<p class="text-muted text-sm">Save credentials first, then click Refresh Zones.</p>
</div>
</div>`;
}
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 = `
<div class="grid-2" style="gap:.75rem;max-width:600px">
<div class="form-group"><label class="form-label">API Email</label>
<input id="cf-email" class="form-control" type="email" value="${Nova.escHtml(c.cf_api_email||'')}" placeholder="you@example.com"></div>
<div class="form-group"><label class="form-label">Global API Key</label>
<input id="cf-apikey" class="form-control" type="text" value="${Nova.escHtml(c.cf_api_key||'')}" placeholder="API key from Cloudflare dashboard"></div>
</div>
<div style="display:flex;gap:.5rem;margin-top:.5rem">
<button class="btn btn-sm btn-primary" onclick="cfSaveCredentials(${id})">Save Credentials</button>
<button class="btn btn-sm btn-ghost" onclick="cfTestKey(${id})">Test API Key</button>
</div>
${c.cf_api_key ? `<p class="text-muted text-sm mt-1">Key on file: <code>${Nova.escHtml(c.cf_api_key)}</code></p>` : ''}`;
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=`<p class="text-muted">${Nova.escHtml(r?.message||'Failed to load zones')}</p>`; return; }
if (!zones.length) { body.innerHTML='<p class="text-muted text-sm">No zones found for these credentials.</p>'; return; }
body.innerHTML = `
<table class="table">
<thead><tr><th>Zone</th><th>Status</th><th>Plan</th><th>Actions</th></tr></thead>
<tbody>
${zones.map(z=>`<tr>
<td><strong>${Nova.escHtml(z.name)}</strong><br><code style="font-size:.75rem">${Nova.escHtml(z.id)}</code></td>
<td>${Nova.badge(z.status,z.status==='active'?'green':'yellow')}</td>
<td class="text-muted text-sm">${Nova.escHtml(z.plan?.name||'—')}</td>
<td style="display:flex;gap:.25rem">
<button class="btn btn-xs" onclick="cfViewRecords('${Nova.escHtml(z.id)}','${Nova.escHtml(z.name)}',${id})">DNS Records</button>
<button class="btn btn-xs btn-primary" onclick="cfSync('${Nova.escHtml(z.id)}','${Nova.escHtml(z.name)}','to',${id})">Push to CF</button>
<button class="btn btn-xs" onclick="cfSync('${Nova.escHtml(z.id)}','${Nova.escHtml(z.name)}','from',${id})">Pull from CF</button>
<button class="btn btn-xs btn-warning" onclick="cfPurge('${Nova.escHtml(z.id)}',${id})">Purge Cache</button>
</td>
</tr>`).join('')}
</tbody>
</table>`;
};
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 ? '<p class="text-muted">No records.</p>' : `
<table class="table" style="font-size:.82rem">
<thead><tr><th>Name</th><th>Type</th><th>Value</th><th>Proxy</th></tr></thead>
<tbody>
${records.map(rec=>`<tr>
<td>${Nova.escHtml(rec.name)}</td>
<td>${Nova.badge(rec.type,'default')}</td>
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis"><code>${Nova.escHtml(rec.content)}</code></td>
<td>
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer">
<input type="checkbox" ${rec.proxiable&&rec.proxied?'checked':''} ${!rec.proxiable?'disabled':''}
onchange="cfToggleProxy('${zoneId}','${rec.id}',this.checked,${acctId})">
${rec.proxied?Nova.badge('proxied','orange'):Nova.badge('DNS only','muted')}
</label>
</td>
</tr>`).join('')}
</tbody>
</table>`);
};
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 `
<div class="page-header mb-3">
<h2 class="page-title">Two-Factor Authentication</h2>
<p class="text-muted text-sm">View 2FA status for all users. Force-disable for account recovery.</p>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">User 2FA Status</span>
<button class="btn btn-ghost btn-sm" onclick="adminPage('twofa')">&#x21bb; Refresh</button>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Username</th><th>Email</th><th>Role</th><th>2FA Status</th><th>Actions</th></tr></thead>
<tbody id="totp-user-rows">
${users.map(u=>`<tr>
<td><strong>${Nova.escHtml(u.username)}</strong></td>
<td class="text-muted text-sm">${Nova.escHtml(u.email||'—')}</td>
<td>${Nova.badge(u.role||'user','default')}</td>
<td id="totp-status-${u.id}">
<span class="text-muted text-sm">—</span>
</td>
<td>
<button class="btn btn-xs btn-ghost" onclick="totpCheckStatus(${u.id})">Check</button>
<button class="btn btn-xs btn-warning" onclick="totpAdminDisable(${u.id},'${Nova.escHtml(u.username)}')">Force Disable</button>
</td>
</tr>`).join('')}
</tbody>
</table>
</div>
</div>`;
}
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);
};
File diff suppressed because it is too large Load Diff
+246
View File
@@ -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 `
<div class="page-header">
<h1 class="page-title">Nginx Proxy Manager</h1>
<div class="page-actions">
${inst ? `
<button class="btn btn-ghost btn-sm" onclick="proxySetupInstructions()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>
Setup Guide
</button>
<button class="btn btn-sm btn-secondary" onclick="proxySync()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
Sync Accounts
</button>
<button class="btn btn-sm btn-primary" onclick="proxyAddHost()">+ Add Host</button>
` : ''}
</div>
</div>
<div class="stats-grid" style="margin-bottom:1.5rem">
<div class="stat-card">
<div class="stat-label">Nginx Status</div>
<div class="stat-value ${run ? 'stat-green' : 'stat-red'}">${inst ? (run ? 'Running' : 'Stopped') : 'Not Installed'}</div>
<div class="stat-sub">${s.version || (inst ? 'nginx' : 'click Install to set up')}</div>
</div>
<div class="stat-card">
<div class="stat-label">Proxy Hosts</div>
<div class="stat-value">${hosts.length}</div>
<div class="stat-sub">${hosts.filter(h => h.enabled).length} active</div>
</div>
<div class="stat-card">
<div class="stat-label">SSL Enabled</div>
<div class="stat-value">${hosts.filter(h => h.ssl_enabled).length}</div>
<div class="stat-sub">of ${hosts.length} hosts</div>
</div>
</div>
${!inst ? `
<div class="panel" style="text-align:center;padding:3rem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="48" height="48" style="color:var(--text-muted);margin-bottom:1rem"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
<h3 style="margin-bottom:0.5rem">Nginx Not Installed</h3>
<p style="color:var(--text-muted);margin-bottom:1.5rem">Install Nginx on this VM to use it as a reverse proxy in front of Apache, or use a separate proxy VM (see Setup Guide).</p>
<div style="display:flex;gap:0.75rem;justify-content:center;flex-wrap:wrap">
<button class="btn btn-primary" onclick="proxyInstall()">Install Nginx Locally</button>
<button class="btn btn-secondary" onclick="proxySetupInstructions()">Setup Guide / Remote VM</button>
</div>
</div>
` : `
<div class="panel" style="margin-bottom:1.5rem">
<div class="panel-header">
<h3 class="panel-title">Service Controls</h3>
<div style="display:flex;gap:0.5rem">
<button class="btn btn-sm btn-success" onclick="proxyControl('start')">Start</button>
<button class="btn btn-sm btn-warning" onclick="proxyControl('restart')">Restart</button>
<button class="btn btn-sm btn-danger" onclick="proxyControl('stop')">Stop</button>
<button class="btn btn-sm btn-ghost" onclick="proxyControl('reload')">Reload Config</button>
</div>
</div>
</div>
<div class="panel">
<div class="panel-header">
<h3 class="panel-title">Proxy Hosts</h3>
<span class="badge badge-blue">${hosts.length} total</span>
</div>
${hosts.length === 0 ? `
<div style="text-align:center;padding:2rem;color:var(--text-muted)">
No proxy hosts yet. Click <strong>Sync Accounts</strong> to auto-add all hosted domains, or <strong>+ Add Host</strong> to add manually.
</div>
` : `
<div style="overflow-x:auto">
<table class="table">
<thead><tr>
<th>Domain</th>
<th>Upstream</th>
<th>SSL</th>
<th>Status</th>
<th>Actions</th>
</tr></thead>
<tbody>
${hosts.map(h => `
<tr id="proxy-row-${h.id}">
<td><strong>${Nova.escHtml(h.domain)}</strong></td>
<td style="font-family:monospace;font-size:0.8rem">${Nova.escHtml(h.upstream)}</td>
<td>${h.ssl_enabled ? Nova.badge('SSL','green') : Nova.badge('HTTP','muted')}</td>
<td>${h.enabled ? Nova.badge('Active','green') : Nova.badge('Disabled','red')}</td>
<td>
<button class="btn btn-xs btn-ghost" onclick="proxyEditHost(${h.id})">Edit</button>
<button class="btn btn-xs ${h.enabled ? 'btn-warning' : 'btn-success'}" onclick="proxyToggle(${h.id},${h.enabled ? 0 : 1})">${h.enabled ? 'Disable' : 'Enable'}</button>
<button class="btn btn-xs btn-danger" onclick="proxyDeleteHost(${h.id},'${Nova.escHtml(h.domain)}')">Delete</button>
</td>
</tr>`).join('')}
</tbody>
</table>
</div>
`}
</div>
`}`;
}
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', `
<div class="form-group"><label>Domain</label>
<input id="ph-domain" type="text" placeholder="example.com" class="form-control"></div>
<div class="form-group"><label>Upstream URL</label>
<input id="ph-upstream" type="text" value="http://127.0.0.1:80" class="form-control">
<small class="text-muted">e.g. http://127.0.0.1:80 or http://10.0.0.2:8080</small></div>
<div class="form-group">
<label><input type="checkbox" id="ph-ssl"> Enable SSL</label></div>
<div class="form-group"><label>Notes (optional)</label>
<input id="ph-notes" type="text" class="form-control"></div>
`, 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', `
<div class="form-group"><label>Domain</label>
<input id="phe-domain" type="text" value="${Nova.escHtml(h.domain)}" class="form-control"></div>
<div class="form-group"><label>Upstream URL</label>
<input id="phe-upstream" type="text" value="${Nova.escHtml(h.upstream)}" class="form-control"></div>
<div class="form-group">
<label><input type="checkbox" id="phe-ssl" ${h.ssl_enabled ? 'checked' : ''}> Enable SSL</label></div>
<div class="form-group"><label>Custom Nginx Config (overrides auto-generated)</label>
<textarea id="phe-custom" rows="6" class="form-control" style="font-family:monospace;font-size:0.78rem">${Nova.escHtml(h.custom_config || '')}</textarea>
<small class="text-muted">Leave blank to use auto-generated config</small></div>
`, 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', `
<div style="max-height:60vh;overflow-y:auto">
<h4 style="margin-bottom:0.75rem">Option A — Local (Nginx on this VM)</h4>
<p style="color:var(--text-muted);margin-bottom:1rem">Install Nginx alongside Apache on this VM. Nginx listens on ports 80/443 and forwards to Apache. Best for SSL termination and caching.</p>
<ol style="color:var(--text-muted);margin-bottom:1.5rem;padding-left:1.2rem;line-height:1.8">
<li>Click <strong>Install Nginx Locally</strong> on the main Nginx Proxy page</li>
<li>Move Apache to port 8080: edit <code>/etc/apache2/ports.conf</code> → change <code>Listen 80</code> to <code>Listen 8080</code></li>
<li>Update upstream in all proxy hosts to <code>http://127.0.0.1:8080</code></li>
<li>Click <strong>Sync Accounts</strong> to auto-populate proxy hosts from your hosted accounts</li>
<li>Click <strong>Reload Config</strong> to apply changes</li>
</ol>
<h4 style="margin-bottom:0.75rem">Option B — Remote Proxy VM (Recommended for production)</h4>
<p style="color:var(--text-muted);margin-bottom:1rem">Run a dedicated Nginx proxy VM in front of this NovaCPX VM. Traffic flows: Internet → FortiGate → Nginx Proxy VM → NovaCPX VM (Apache).</p>
<ol style="color:var(--text-muted);margin-bottom:1.5rem;padding-left:1.2rem;line-height:1.8">
<li>Create a new VM on Proxmox (Ubuntu 22.04, 1 vCPU, 1GB RAM)</li>
<li>Run the setup script below on the new VM as root</li>
<li>Point FortiGate VIPs to the proxy VM IP (ports 80/443)</li>
<li>Set the proxy upstream to this NovaCPX VM IP (<code>http://10.48.200.110:80</code>)</li>
<li>Add proxy hosts for each domain from your NovaCPX admin panel</li>
</ol>
<h4 style="margin-bottom:0.75rem">Automated Setup Script</h4>
<p style="color:var(--text-muted);margin-bottom:0.75rem">Run this on the target VM (local or remote) as root:</p>
<div style="background:var(--bg-secondary);padding:0.75rem;border-radius:6px;font-family:monospace;font-size:0.8rem;margin-bottom:0.75rem">
curl -sk https://YOUR_NOVACPX_IP:8882/api/proxy/setup-script | bash
</div>
<p style="color:var(--text-muted);font-size:0.85rem">Or download and review before running:</p>
<div style="background:var(--bg-secondary);padding:0.75rem;border-radius:6px;font-family:monospace;font-size:0.8rem">
curl -sk https://YOUR_NOVACPX_IP:8882/api/proxy/setup-script -o proxy-setup.sh<br>
cat proxy-setup.sh # review<br>
bash proxy-setup.sh
</div>
<h4 style="margin-bottom:0.75rem;margin-top:1.5rem">Integration with VirtualHost Manager</h4>
<p style="color:var(--text-muted);margin-bottom:0.75rem">When proxy mode is active, NovaCPX automatically:</p>
<ul style="color:var(--text-muted);padding-left:1.2rem;line-height:1.8">
<li>Creates a proxy host entry for every new account</li>
<li>Removes the proxy host when an account is terminated</li>
<li>Re-generates Nginx config on every account change</li>
<li>Uses account SSL certs automatically if SSL is enabled on the proxy host</li>
</ul>
</div>
`, null, { cancelLabel: 'Close', showConfirm: false });
};
+5 -5
View File
@@ -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 {
+45
View File
@@ -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 <newpass> (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 <newpassword>"; 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
+68
View File
@@ -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"
+51
View File
@@ -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 <noreply@anthropic.com>"
git push origin main
echo ""
echo -e "${GREEN}[✓]${NC} Pushed. Auto-deploy will trigger within ~1 min."
;;
esac
+28
View File
@@ -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'"
+51
View File
@@ -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
+24
View File
@@ -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 <local_file> <remote_path>
# 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."
+32
View File
@@ -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
+69
View File
@@ -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)"
+139
View File
@@ -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