mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
Add DKIM auto-provisioning, OS/panel self-update with self-healing
- AccountManager: auto-generate DKIM keypair + inject SPF/DKIM/DMARC DNS records on account create - AccountManager: rotateDKIM() method for key rotation with new selector - New dkim.php endpoint: list/view/rotate/provision DKIM keys per domain - schema.sql: add dkim_keys table - install.sh: install opendkim, wire into Postfix milter, fix dotfile copy (. vs *), fix config.ini permissions (root:www-data 640), copy VERSION to web root, add opendkim to service restart - api/index.php: fix NOVACPX_ROOT path (was 2 levels too high), fix CORS ports (8880-8883), VERSION fallback to /opt/novacpx-src - api/.htaccess: route all /api/* requests through index.php - system.php: check-os-update, apply-os-update (self-healing: auto-restart downed services, restore web root if panel ports go down), check-novacpx-update, apply-novacpx-update (PHP syntax validation before deploy, backup + restore on failure) - admin.js: Updates page now shows both NovaCPX panel updates and OS package upgrades in one section; sidebar badge shows combined count Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
/**
|
||||
* DKIM key management endpoint
|
||||
* Actions: list, rotate, view
|
||||
*/
|
||||
|
||||
require_once NOVACPX_LIB . '/AccountManager.php';
|
||||
require_once NOVACPX_LIB . '/DNSManager.php';
|
||||
|
||||
$db = DB::getInstance();
|
||||
$user = $currentUser;
|
||||
|
||||
switch ($action) {
|
||||
|
||||
case 'list':
|
||||
// Admin: all domains. User/reseller: their own accounts
|
||||
if ($user['role'] === 'admin') {
|
||||
$keys = $db->fetchAll(
|
||||
"SELECT dk.*, a.username FROM dkim_keys dk JOIN accounts a ON dk.account_id = a.id ORDER BY dk.domain"
|
||||
);
|
||||
} else {
|
||||
$keys = $db->fetchAll(
|
||||
"SELECT dk.* FROM dkim_keys dk
|
||||
JOIN accounts a ON dk.account_id = a.id
|
||||
WHERE a.user_id = ?
|
||||
ORDER BY dk.domain",
|
||||
[$user['id']]
|
||||
);
|
||||
}
|
||||
foreach ($keys as &$k) { unset($k['private_key_path']); }
|
||||
Response::json(['keys' => $keys]);
|
||||
break;
|
||||
|
||||
case 'view':
|
||||
$domain = trim($body['domain'] ?? '');
|
||||
if (!$domain) Response::error('domain required', 400);
|
||||
|
||||
$row = $db->fetchOne("SELECT * FROM dkim_keys WHERE domain = ?", [$domain]);
|
||||
if (!$row) Response::error('No DKIM key found for domain', 404);
|
||||
|
||||
// Access control
|
||||
if ($user['role'] !== 'admin') {
|
||||
$acct = $db->fetchOne("SELECT id FROM accounts WHERE id = ? AND user_id = ?", [$row['account_id'], $user['id']]);
|
||||
if (!$acct) Response::error('Forbidden', 403);
|
||||
}
|
||||
|
||||
unset($row['private_key_path']);
|
||||
// Also return the full DNS TXT value
|
||||
$row['dns_record_name'] = "mail._domainkey.{$domain}";
|
||||
$row['dns_record_value'] = "v=DKIM1; k=rsa; p={$row['public_key']}";
|
||||
Response::json(['key' => $row]);
|
||||
break;
|
||||
|
||||
case 'rotate':
|
||||
$domain = trim($body['domain'] ?? '');
|
||||
if (!$domain) Response::error('domain required', 400);
|
||||
|
||||
$acct = $db->fetchOne("SELECT a.id, a.user_id FROM accounts a JOIN domains d ON d.account_id = a.id WHERE d.domain = ? AND d.type = 'main'", [$domain]);
|
||||
if (!$acct) Response::error('Domain not found', 404);
|
||||
|
||||
if ($user['role'] !== 'admin' && (int)$acct['user_id'] !== (int)$user['id']) {
|
||||
Response::error('Forbidden', 403);
|
||||
}
|
||||
|
||||
$selector = AccountManager::rotateDKIM((int)$acct['id'], $domain);
|
||||
Response::json(['ok' => true, 'selector' => $selector, 'message' => "DKIM rotated. New selector: {$selector}._domainkey.{$domain}"]);
|
||||
break;
|
||||
|
||||
case 'provision':
|
||||
// Re-provision SPF/DKIM/DMARC for a domain (admin only or own account)
|
||||
$domain = trim($body['domain'] ?? '');
|
||||
if (!$domain) Response::error('domain required', 400);
|
||||
|
||||
$acct = $db->fetchOne("SELECT a.id, a.user_id FROM accounts a JOIN domains d ON d.account_id = a.id WHERE d.domain = ?", [$domain]);
|
||||
if (!$acct) Response::error('Domain not found', 404);
|
||||
|
||||
if ($user['role'] !== 'admin' && (int)$acct['user_id'] !== (int)$user['id']) {
|
||||
Response::error('Forbidden', 403);
|
||||
}
|
||||
|
||||
AccountManager::provisionEmailDNS((int)$acct['id'], $domain);
|
||||
Response::json(['ok' => true, 'message' => "SPF, DKIM, and DMARC records provisioned for {$domain}"]);
|
||||
break;
|
||||
|
||||
default:
|
||||
Response::error("Unknown action: $action", 404);
|
||||
}
|
||||
@@ -88,6 +88,201 @@ match ($action) {
|
||||
]);
|
||||
})(),
|
||||
|
||||
// ── Check OS updates ─────────────────────────────────────────────────────
|
||||
'check-os-update' => (function() use ($db) {
|
||||
Auth::getInstance()->require('admin');
|
||||
shell_exec('apt-get update -qq 2>/dev/null');
|
||||
$out = shell_exec('apt-get -s upgrade 2>/dev/null | grep "^Inst " | head -50') ?: '';
|
||||
$packages = array_values(array_filter(array_map(function($line) {
|
||||
if (preg_match('/^Inst (\S+).*\[(\S+)\].*\((\S+)/', $line, $m)) {
|
||||
return ['name' => $m[1], 'from' => $m[2], 'to' => $m[3]];
|
||||
} elseif (preg_match('/^Inst (\S+)\s+\((\S+)/', $line, $m)) {
|
||||
return ['name' => $m[1], 'from' => '', 'to' => $m[2]];
|
||||
}
|
||||
return null;
|
||||
}, explode("\n", trim($out)))));
|
||||
$security = array_filter($packages, fn($p) => str_contains($p['name'] ?? '', 'security') ||
|
||||
(bool)shell_exec("apt-get -s upgrade 2>/dev/null | grep -c \"^Inst {$p['name']}.*security\" 2>/dev/null"));
|
||||
Response::success([
|
||||
'upgradable' => count($packages),
|
||||
'security_updates' => count($security),
|
||||
'packages' => $packages,
|
||||
'last_checked' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
})(),
|
||||
|
||||
// ── Apply OS update ───────────────────────────────────────────────────────
|
||||
'apply-os-update' => (function() use ($db) {
|
||||
Auth::getInstance()->require('admin');
|
||||
|
||||
$panelPorts = [PORT_USER, PORT_RESELLER, PORT_ADMIN];
|
||||
$webSvc = defined('WEB_SERVER') && WEB_SERVER === 'nginx' ? 'nginx' : 'apache2';
|
||||
|
||||
// Snapshot service states before upgrade
|
||||
$beforeServices = [];
|
||||
foreach ([$webSvc, 'mysql', 'postfix', 'dovecot', 'proftpd', 'named'] as $svc) {
|
||||
$beforeServices[$svc] = trim(shell_exec("systemctl is-active $svc 2>/dev/null") ?: 'unknown');
|
||||
}
|
||||
|
||||
// Backup panel web root
|
||||
$backupDir = '/var/novacpx/backups/pre-os-update-' . date('YmdHis');
|
||||
$webRoot = defined('WEB_ROOT') ? WEB_ROOT : '/srv/novacpx/public';
|
||||
shell_exec("mkdir -p " . escapeshellarg($backupDir));
|
||||
shell_exec("cp -a " . escapeshellarg($webRoot) . " " . escapeshellarg("$backupDir/public") . " 2>&1");
|
||||
|
||||
// Run upgrade (non-interactive, hold back kernel packages to avoid reboot surprise)
|
||||
$env = 'DEBIAN_FRONTEND=noninteractive';
|
||||
$opts = '-o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold"';
|
||||
$out = shell_exec("$env apt-get upgrade -y -q $opts 2>&1");
|
||||
|
||||
// Self-healing: restart any service that went down
|
||||
$healed = [];
|
||||
sleep(3);
|
||||
foreach ($beforeServices as $svc => $wasBefore) {
|
||||
if ($wasBefore !== 'active') continue;
|
||||
$nowState = trim(shell_exec("systemctl is-active $svc 2>/dev/null") ?: '');
|
||||
if ($nowState !== 'active') {
|
||||
shell_exec("systemctl restart $svc 2>/dev/null");
|
||||
sleep(2);
|
||||
$afterHeal = trim(shell_exec("systemctl is-active $svc 2>/dev/null") ?: '');
|
||||
$healed[$svc] = $afterHeal === 'active' ? 'restarted' : 'FAILED';
|
||||
if ($afterHeal !== 'active') {
|
||||
novacpx_log('error', "Self-heal FAILED for $svc after OS upgrade");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify panel ports respond
|
||||
$panelOk = [];
|
||||
foreach ($panelPorts as $port) {
|
||||
$resp = @fsockopen('127.0.0.1', $port, $errno, $errstr, 3);
|
||||
$panelOk[$port] = (bool)$resp;
|
||||
if ($resp) fclose($resp);
|
||||
}
|
||||
$panelDown = array_keys(array_filter($panelOk, fn($ok) => !$ok));
|
||||
|
||||
// If panel ports down, restore from backup and restart web server
|
||||
if ($panelDown) {
|
||||
shell_exec("cp -a " . escapeshellarg("$backupDir/public") . " " . escapeshellarg($webRoot) . " 2>&1");
|
||||
shell_exec("systemctl restart $webSvc 2>/dev/null");
|
||||
novacpx_log('error', 'Panel ports down after OS upgrade — restored from backup');
|
||||
}
|
||||
|
||||
audit('system.os-update', "upgraded; healed:" . implode(',', array_keys($healed)));
|
||||
Response::success([
|
||||
'upgraded' => true,
|
||||
'panel_ports_ok' => empty($panelDown),
|
||||
'panel_ports_down' => $panelDown,
|
||||
'services_healed' => $healed,
|
||||
'backup_path' => $backupDir,
|
||||
'upgrade_output' => substr($out ?: '', -2000),
|
||||
]);
|
||||
})(),
|
||||
|
||||
// ── Check NovaCPX update ─────────────────────────────────────────────────
|
||||
'check-novacpx-update' => (function() use ($db) {
|
||||
Auth::getInstance()->require('admin');
|
||||
$srcDir = '/opt/novacpx-src';
|
||||
if (!is_dir($srcDir)) Response::error('Source repo not found at /opt/novacpx-src');
|
||||
$out = shell_exec("git -C " . escapeshellarg($srcDir) . " fetch origin 2>&1 && git -C " . escapeshellarg($srcDir) . " log HEAD..origin/main --oneline 2>/dev/null");
|
||||
$updates = array_values(array_filter(explode("\n", trim($out ?: ''))));
|
||||
$branch = trim(shell_exec("git -C " . escapeshellarg($srcDir) . " branch --show-current 2>/dev/null") ?: 'main');
|
||||
$commit = trim(shell_exec("git -C " . escapeshellarg($srcDir) . " rev-parse --short HEAD 2>/dev/null") ?: '');
|
||||
Response::success([
|
||||
'updates_available' => count($updates),
|
||||
'current_commit' => $commit,
|
||||
'branch' => $branch,
|
||||
'commits' => $updates,
|
||||
]);
|
||||
})(),
|
||||
|
||||
// ── Apply NovaCPX update ─────────────────────────────────────────────────
|
||||
'apply-novacpx-update' => (function() use ($db) {
|
||||
Auth::getInstance()->require('admin');
|
||||
$srcDir = '/opt/novacpx-src';
|
||||
$webRoot = defined('WEB_ROOT') ? WEB_ROOT : '/srv/novacpx/public';
|
||||
$webSvc = defined('WEB_SERVER') && WEB_SERVER === 'nginx' ? 'nginx' : 'apache2';
|
||||
|
||||
if (!is_dir($srcDir)) Response::error('Source repo not found at /opt/novacpx-src');
|
||||
|
||||
$before = trim(shell_exec("git -C " . escapeshellarg($srcDir) . " rev-parse HEAD 2>/dev/null") ?: '');
|
||||
|
||||
// Backup current web root
|
||||
$backupDir = '/var/novacpx/backups/pre-novacpx-update-' . date('YmdHis');
|
||||
shell_exec("mkdir -p " . escapeshellarg($backupDir));
|
||||
shell_exec("cp -a " . escapeshellarg($webRoot) . " " . escapeshellarg("$backupDir/public") . " 2>&1");
|
||||
|
||||
// Pull new code
|
||||
$pull = shell_exec("git -C " . escapeshellarg($srcDir) . " pull origin main 2>&1");
|
||||
$after = trim(shell_exec("git -C " . escapeshellarg($srcDir) . " rev-parse HEAD 2>/dev/null") ?: '');
|
||||
$changed = $before !== $after;
|
||||
|
||||
if ($changed) {
|
||||
// Validate PHP syntax before deploying
|
||||
$phpFiles = glob($srcDir . '/panel/**/*.php', GLOB_BRACE) ?: [];
|
||||
$syntaxErr = [];
|
||||
foreach ($phpFiles as $f) {
|
||||
$check = shell_exec("php -l " . escapeshellarg($f) . " 2>&1");
|
||||
if (!str_contains($check, 'No syntax errors')) {
|
||||
$syntaxErr[] = basename($f) . ': ' . trim($check);
|
||||
}
|
||||
}
|
||||
|
||||
if ($syntaxErr) {
|
||||
// Syntax errors — abort, restore
|
||||
shell_exec("git -C " . escapeshellarg($srcDir) . " reset --hard " . escapeshellarg($before) . " 2>&1");
|
||||
Response::error('Update aborted — PHP syntax errors: ' . implode('; ', $syntaxErr));
|
||||
}
|
||||
|
||||
// Deploy files to web root
|
||||
shell_exec("rsync -a --delete " . escapeshellarg("$srcDir/panel/public/") . " " . escapeshellarg("$webRoot/") . " 2>&1");
|
||||
shell_exec("rsync -a " . escapeshellarg("$srcDir/panel/lib/") . " " . escapeshellarg("$webRoot/lib/") . " 2>&1");
|
||||
shell_exec("rsync -a " . escapeshellarg("$srcDir/panel/api/") . " " . escapeshellarg("$webRoot/api/") . " 2>&1");
|
||||
shell_exec("cp " . escapeshellarg("$srcDir/VERSION") . " " . escapeshellarg("$webRoot/VERSION") . " 2>/dev/null");
|
||||
shell_exec("chown -R www-data:www-data " . escapeshellarg($webRoot));
|
||||
|
||||
// Run pending DB migrations
|
||||
$migrDir = "$srcDir/db/migrations";
|
||||
if (is_dir($migrDir)) {
|
||||
foreach (glob("$migrDir/*.sql") as $sql) {
|
||||
$migName = basename($sql, '.sql');
|
||||
$already = $db->fetchOne("SELECT 1 FROM settings WHERE `key` = ?", ["migration_$migName"]);
|
||||
if (!$already) {
|
||||
$db->pdo()->exec(file_get_contents($sql));
|
||||
$db->execute("INSERT INTO settings (`key`,`value`) VALUES (?,NOW()) ON DUPLICATE KEY UPDATE `value`=NOW()", ["migration_$migName"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reload PHP-FPM to pick up new code
|
||||
shell_exec("systemctl reload php8.3-fpm 2>/dev/null || true");
|
||||
|
||||
// Verify panel is still up
|
||||
sleep(2);
|
||||
$panelOk = @fsockopen('127.0.0.1', PORT_ADMIN, $e, $es, 3);
|
||||
if (!$panelOk) {
|
||||
// Restore backup and reload
|
||||
shell_exec("rsync -a --delete " . escapeshellarg("$backupDir/public/") . " " . escapeshellarg("$webRoot/") . " 2>&1");
|
||||
shell_exec("systemctl reload $webSvc 2>/dev/null");
|
||||
novacpx_log('error', "NovaCPX update failed — panel down after deploy; restored from backup");
|
||||
Response::error('Update deployed but panel went down — auto-restored from backup. Check logs.');
|
||||
} else {
|
||||
fclose($panelOk);
|
||||
}
|
||||
|
||||
audit('system.novacpx-update', "novacpx:$before→$after");
|
||||
novacpx_log('info', "NovaCPX updated $before → $after");
|
||||
}
|
||||
|
||||
Response::success([
|
||||
'updated' => $changed,
|
||||
'from_commit' => $before,
|
||||
'to_commit' => $after,
|
||||
'pull_output' => $pull,
|
||||
'backup_path' => $backupDir,
|
||||
]);
|
||||
})(),
|
||||
|
||||
// ── Server Stats ──────────────────────────────────────────────────────────
|
||||
'stats' => (function() use ($db) {
|
||||
// CPU/load
|
||||
|
||||
+8
-5
@@ -4,16 +4,19 @@
|
||||
* All requests: /api/{endpoint}/{action}
|
||||
*/
|
||||
|
||||
define('NOVACPX_ROOT', dirname(__DIR__, 2));
|
||||
define('NOVACPX_ROOT', dirname(__DIR__));
|
||||
define('NOVACPX_API', __DIR__);
|
||||
define('NOVACPX_LIB', NOVACPX_ROOT . '/panel/lib');
|
||||
define('NOVACPX_LIB', NOVACPX_ROOT . '/lib');
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('X-NovaCPX-Version: ' . (file_get_contents(NOVACPX_ROOT . '/VERSION') ?: '1.0.0'));
|
||||
$_ver = file_get_contents(NOVACPX_ROOT . '/VERSION')
|
||||
?: file_get_contents('/opt/novacpx-src/VERSION')
|
||||
?: '1.0.0';
|
||||
header('X-NovaCPX-Version: ' . trim($_ver));
|
||||
|
||||
// CORS for same-origin panel requests
|
||||
// CORS for same-origin panel requests (ports 8880/8881/8882/8883)
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
if (preg_match('#^https?://[^/]+:2083$#', $origin)) {
|
||||
if (preg_match('#^https?://[^/]+:(888[0-3])$#', $origin)) {
|
||||
header("Access-Control-Allow-Origin: $origin");
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
|
||||
Reference in New Issue
Block a user