require('admin', 'reseller', 'user'); $db = DB::getInstance(); $body = json_decode(file_get_contents('php://input'), true) ?? []; match ($action) { // ── Version & Update Info ───────────────────────────────────────────────── 'version' => (function() use ($db) { $installed = $db->fetchOne("SELECT version, installed_at, git_commit FROM novacpx_version ORDER BY id DESC LIMIT 1"); $gitDir = NOVACPX_ROOT . '/.git'; $gitCommit = null; $gitBranch = null; $gitDirty = false; if (is_dir($gitDir)) { $gitCommit = trim(shell_exec("git -C " . escapeshellarg(NOVACPX_ROOT) . " rev-parse --short HEAD 2>/dev/null") ?: ''); $gitBranch = trim(shell_exec("git -C " . escapeshellarg(NOVACPX_ROOT) . " rev-parse --abbrev-ref HEAD 2>/dev/null") ?: ''); $status = shell_exec("git -C " . escapeshellarg(NOVACPX_ROOT) . " status --porcelain 2>/dev/null"); $gitDirty = !empty(trim($status)); } Response::success([ 'installed_version' => $installed['version'] ?? NOVACPX_VERSION, 'installed_at' => $installed['installed_at'], 'git_commit' => $gitCommit ?: ($installed['git_commit'] ?? null), 'git_branch' => $gitBranch, 'git_dirty' => $gitDirty, 'php_version' => PHP_VERSION, 'os' => php_uname('s') . ' ' . php_uname('r'), ]); })(), // ── Check for updates ───────────────────────────────────────────────────── 'check-update' => (function() use ($db) { Auth::getInstance()->require('admin'); // Source repo is at /opt/novacpx-src — the web root is a deployed copy, not a git repo $srcDir = '/opt/novacpx-src'; if (!is_dir($srcDir . '/.git')) Response::error('Source repository not found at ' . $srcDir . '. Re-run the installer to restore it.'); $output = 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($output ?: '')))); Response::success([ 'updates_available' => count($updates), 'commits' => $updates, ]); })(), // ── Apply update ────────────────────────────────────────────────────────── 'apply-update' => (function() use ($db) { Auth::getInstance()->require('admin'); $srcDir = '/opt/novacpx-src'; if (!is_dir($srcDir . '/.git')) Response::error('Source repository not found at ' . $srcDir); $before = trim(shell_exec("git -C " . escapeshellarg($srcDir) . " rev-parse HEAD 2>/dev/null") ?: ''); $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) { // Deploy updated files from source repo to web root $dst = rtrim(NOVACPX_ROOT, '/'); shell_exec("cp -a " . escapeshellarg($srcDir . '/panel/public/.') . " {$dst}/"); shell_exec("cp -a " . escapeshellarg($srcDir . '/panel/api/.') . " {$dst}/api/"); shell_exec("cp -a " . escapeshellarg($srcDir . '/panel/lib/.') . " {$dst}/lib/"); if (is_dir($srcDir . '/panel/bin')) { shell_exec("cp -a " . escapeshellarg($srcDir . '/panel/bin/.') . " {$dst}/bin/"); } shell_exec("chown -R www-data:www-data " . escapeshellarg($dst) . " 2>/dev/null"); // Run any 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`,updated_at) VALUES (?,datetime('now'),datetime('now')) ON CONFLICT(`key`) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at", ["migration_$migName"]); novacpx_log('info', "Migration applied: $migName"); } } } audit('system.update', "novacpx:$before→$after"); novacpx_log('info', "NovaCPX updated $before → $after"); } Response::success([ 'updated' => $changed, 'from_commit' => $before, 'to_commit' => $after, 'pull_output' => $pull, ]); })(), // ── Check OS updates ───────────────────────────────────────────────────── 'check-os-update' => (function() use ($db) { Auth::getInstance()->require('admin'); $force = !empty($_GET['force']); $cached = $db->fetchOne("SELECT value, updated_at FROM settings WHERE `key`='update_cache_os'"); $age = $cached ? (time() - strtotime($cached['updated_at'])) : PHP_INT_MAX; if (!$force && $cached && $age < 43200) { $data = json_decode($cached['value'], true) ?: []; $data['cached'] = true; $data['cached_at'] = $cached['updated_at']; Response::success($data); } 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")); $result = [ 'upgradable' => count($packages), 'security_updates' => count($security), 'packages' => $packages, 'last_checked' => date('Y-m-d H:i:s'), ]; $db->execute("INSERT INTO settings(`key`,value,updated_at) VALUES('update_cache_os',?,datetime('now')) ON CONFLICT(`key`) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at", [json_encode($result)]); Response::success($result); })(), // ── Start OS update (background job) ───────────────────────────────────── 'apply-os-update' => (function() use ($db) { Auth::getInstance()->require('admin'); $jobId = bin2hex(random_bytes(8)); $logFile = "/tmp/ncpx-os-update-{$jobId}.log"; $doneFile = "/tmp/ncpx-os-update-{$jobId}.done"; $script = "/tmp/ncpx-os-update-{$jobId}.sh"; $webSvc = defined('WEB_SERVER') && WEB_SERVER === 'nginx' ? 'nginx' : 'apache2'; $webRoot = defined('WEB_ROOT') ? WEB_ROOT : '/srv/novacpx/public'; $backupDir = '/tmp/novacpx-backup-' . date('YmdHis'); $sh = << {$logFile} 2>&1 ts() { date -u +"%H:%M:%S UTC"; } echo "[\$(ts)] Preparing backup..." mkdir -p {$backupDir} cp -a {$webRoot} {$backupDir}/public 2>/dev/null echo "[\$(ts)] Updating package lists..." sudo apt-get update -q echo "[\$(ts)] Running upgrade (non-interactive)..." DEBIAN_FRONTEND=noninteractive sudo apt-get upgrade -y \ -o Dpkg::Options::="--force-confdef" \ -o Dpkg::Options::="--force-confold" UPGRADE_EXIT=\$? echo "[\$(ts)] Checking services..." for SVC in {$webSvc} mysql postfix dovecot; do if systemctl is-active --quiet \$SVC 2>/dev/null; then :; else echo "[\$(ts)] Restarting \$SVC..." sudo systemctl restart \$SVC 2>/dev/null && echo " \$SVC restarted OK" || echo " \$SVC restart FAILED" fi done if [ \$UPGRADE_EXIT -eq 0 ]; then echo "[\$(ts)] Upgrade complete." else echo "[\$(ts)] Upgrade finished with errors (exit code \$UPGRADE_EXIT)." fi echo \$UPGRADE_EXIT > {$doneFile} BASH; file_put_contents($script, $sh); chmod($script, 0755); shell_exec("nohup " . escapeshellarg($script) . " > /dev/null 2>&1 &"); audit('system.os-update-start', $jobId); Response::success(['job_id' => $jobId]); })(), // ── Poll OS update job status ───────────────────────────────────────────── 'os-update-status' => (function() { Auth::getInstance()->require('admin'); $jobId = preg_replace('/[^a-f0-9]/', '', $_GET['job_id'] ?? ''); if (!$jobId) Response::error('job_id required'); $logFile = "/tmp/ncpx-os-update-{$jobId}.log"; $doneFile = "/tmp/ncpx-os-update-{$jobId}.done"; $content = @file_get_contents($logFile) ?: ''; $lines = $content !== '' ? explode("\n", rtrim($content)) : []; $done = file_exists($doneFile); $exitCode = $done ? (int)trim(@file_get_contents($doneFile) ?: '1') : null; if ($done) { @unlink($logFile); @unlink($doneFile); @unlink("/tmp/ncpx-os-update-{$jobId}.sh"); } Response::success(['lines' => $lines, 'done' => $done, 'exit_code' => $exitCode]); })(), // ── Check NovaCPX update ───────────────────────────────────────────────── 'check-novacpx-update' => (function() use ($db) { Auth::getInstance()->require('admin'); $force = !empty($_GET['force']); $cached = $db->fetchOne("SELECT value, updated_at FROM settings WHERE `key`='update_cache_novacpx'"); $age = $cached ? (time() - strtotime($cached['updated_at'])) : PHP_INT_MAX; if (!$force && $cached && $age < 43200) { $data = json_decode($cached['value'], true) ?: []; $data['cached'] = true; $data['cached_at'] = $cached['updated_at']; Response::success($data); } $srcDir = '/opt/novacpx-src'; if (!is_dir($srcDir)) Response::error('Source repo not found at /opt/novacpx-src'); $channelRow = $db->fetchOne("SELECT value FROM settings WHERE `key`='update_channel'"); $channel = in_array($channelRow['value'] ?? '', ['stable', 'beta']) ? $channelRow['value'] : 'stable'; $remoteBranch = $channel === 'beta' ? 'origin/beta' : 'origin/main'; shell_exec("sudo git -C " . escapeshellarg($srcDir) . " fetch origin 2>/dev/null"); $logOut = shell_exec("sudo git -C " . escapeshellarg($srcDir) . " log HEAD.." . escapeshellarg($remoteBranch) . " --oneline 2>/dev/null") ?: ''; $updates = array_values(array_filter(explode("\n", trim($logOut)))); $branch = trim(shell_exec("sudo git -C " . escapeshellarg($srcDir) . " branch --show-current 2>/dev/null") ?: 'main'); $commit = trim(shell_exec("sudo git -C " . escapeshellarg($srcDir) . " rev-parse --short HEAD 2>/dev/null") ?: ''); $remoteVer = trim(shell_exec("sudo git -C " . escapeshellarg($srcDir) . " show " . escapeshellarg("{$remoteBranch}:VERSION") . " 2>/dev/null") ?: ''); $result = ['updates_available' => count($updates), 'current_commit' => $commit, 'branch' => $branch, 'channel' => $channel, 'remote_version' => $remoteVer, 'commits' => $updates]; $db->execute("INSERT INTO settings(`key`,value,updated_at) VALUES('update_cache_novacpx',?,datetime('now')) ON CONFLICT(`key`) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at", [json_encode($result)]); Response::success($result); })(), // ── Apply NovaCPX update ───────────────────────────────────────────────── 'apply-novacpx-update' => (function() use ($db) { Auth::getInstance()->require('admin'); set_time_limit(180); $srcDir = '/opt/novacpx-src'; $webRoot = defined('WEB_ROOT') ? WEB_ROOT : '/srv/novacpx/public'; $steps = []; if (!is_dir($srcDir)) Response::error('Source repo not found at /opt/novacpx-src'); $channelRow = $db->fetchOne("SELECT value FROM settings WHERE `key`='update_channel'"); $channel = in_array($channelRow['value'] ?? '', ['stable', 'beta']) ? $channelRow['value'] : 'stable'; $targetBranch = $channel === 'beta' ? 'beta' : 'main'; $before = trim(shell_exec("sudo git -C " . escapeshellarg($srcDir) . " rev-parse HEAD 2>/dev/null") ?: ''); $steps[] = "Before: $before (channel: $channel)"; // Backup current web root to /tmp (writable, no sudo needed) // rsync contents into $backupDir so restore can rsync $backupDir/ back symmetrically $backupDir = '/tmp/novacpx-backup-' . date('YmdHis'); shell_exec("rsync -a " . escapeshellarg("$webRoot/") . " " . escapeshellarg("$backupDir/") . " 2>&1"); $steps[] = "Backup: $backupDir"; // Pull new code from the channel branch (sudo so www-data can write root-owned repo) $pull = shell_exec("sudo git -C " . escapeshellarg($srcDir) . " pull origin " . escapeshellarg($targetBranch) . " 2>&1"); $steps[] = "Pull: " . trim($pull ?: '(no output)'); $after = trim(shell_exec("sudo git -C " . escapeshellarg($srcDir) . " rev-parse HEAD 2>/dev/null") ?: ''); $changed = $before !== $after; $steps[] = "After: $after" . ($changed ? " (changed)" : " (no change)"); if ($changed) { // Validate PHP syntax — only check files changed in this update $diffOut = shell_exec("sudo git -C " . escapeshellarg($srcDir) . " diff " . escapeshellarg($before) . " " . escapeshellarg($after) . " --name-only 2>/dev/null") ?: ''; $phpFiles = []; foreach (array_filter(explode("\n", trim($diffOut))) as $f) { $f = trim($f); if (str_ends_with($f, '.php')) { $full = "$srcDir/$f"; if (file_exists($full)) $phpFiles[] = $full; } } $syntaxErr = []; foreach ($phpFiles as $f) { $check = shell_exec("php8.3 -l " . escapeshellarg($f) . " 2>&1"); if (!str_contains($check, 'No syntax errors')) { $syntaxErr[] = basename($f) . ': ' . trim($check); } } $steps[] = "Syntax check: " . count($phpFiles) . " changed files, " . count($syntaxErr) . " errors"; if ($syntaxErr) { shell_exec("sudo 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("sudo rsync -a --delete " . escapeshellarg("$srcDir/panel/public/") . " " . escapeshellarg("$webRoot/") . " 2>&1"); shell_exec("sudo rsync -a " . escapeshellarg("$srcDir/panel/lib/") . " " . escapeshellarg("$webRoot/lib/") . " 2>&1"); shell_exec("sudo rsync -a " . escapeshellarg("$srcDir/panel/api/") . " " . escapeshellarg("$webRoot/api/") . " 2>&1"); shell_exec("sudo cp " . escapeshellarg("$srcDir/VERSION") . " " . escapeshellarg("$webRoot/VERSION") . " 2>/dev/null"); shell_exec("sudo chown -R www-data:www-data " . escapeshellarg($webRoot)); $steps[] = "Deploy: rsync complete"; // Run pending DB migrations (SQLite syntax) $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) { try { $db->pdo()->exec(file_get_contents($sql)); } catch (\Throwable $e) { /* skip dupes */ } $db->execute("INSERT INTO settings (`key`,`value`,updated_at) VALUES (?,datetime('now'),datetime('now')) ON CONFLICT(`key`) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at", ["migration_$migName"]); $steps[] = "Migration: $migName applied"; } } } // Record new version in novacpx_version table and settings $newVersion = trim(shell_exec("sudo cat " . escapeshellarg("$srcDir/VERSION") . " 2>/dev/null") ?: ''); if ($newVersion) { $db->execute("INSERT INTO novacpx_version (version, installed_at, notes, git_commit) VALUES (?,datetime('now'),?,?)", [$newVersion, "Updated via admin panel from {$before} (channel: {$channel})", $after]); $db->execute("INSERT INTO settings (`key`,`value`,updated_at) VALUES ('panel_version',?,datetime('now')) ON CONFLICT(`key`) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at", [$newVersion]); $steps[] = "Version: $newVersion recorded"; } // Reload PHP-FPM shell_exec("sudo systemctl reload php8.3-fpm 2>/dev/null || sudo systemctl reload php8.2-fpm 2>/dev/null || true"); $steps[] = "PHP-FPM reloaded"; // Verify panel is still up sleep(2); $port = defined('PORT_ADMIN') ? PORT_ADMIN : 8882; $panelOk = false; foreach (['https','http'] as $scheme) { $code = trim(shell_exec("curl -sk -o /dev/null -w '%{http_code}' {$scheme}://127.0.0.1:{$port}/api/system/version --max-time 5 2>/dev/null") ?: ''); if (in_array($code, ['200','401','302','301'])) { $panelOk = true; break; } } if (!$panelOk) { shell_exec("sudo rsync -a --delete " . escapeshellarg("$backupDir/") . " " . escapeshellarg("$webRoot/") . " 2>&1"); 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.'); } audit('system.novacpx-update', "novacpx:{$before}→{$after} (channel:{$channel})"); novacpx_log('info', "NovaCPX updated $before → $after via $channel channel"); } Response::success([ 'updated' => $changed, 'from_commit' => $before, 'to_commit' => $after, 'channel' => $channel, 'pull_output' => trim($pull ?? ''), 'backup_path' => $backupDir, 'steps' => $steps, ]); })(), // ── Server Stats ────────────────────────────────────────────────────────── 'stats' => (function() use ($db) { // CPU/load $load = sys_getloadavg(); $cpuPct = round(($load[0] / max(1, (int)shell_exec('nproc'))) * 100, 1); // RAM $memRaw = file_get_contents('/proc/meminfo'); preg_match('/MemTotal:\s+(\d+)/', $memRaw, $mt); preg_match('/MemAvailable:\s+(\d+)/', $memRaw, $ma); $ramTotal = (int)($mt[1] ?? 0); $ramAvail = (int)($ma[1] ?? 0); $ramPct = $ramTotal > 0 ? round((($ramTotal - $ramAvail) / $ramTotal) * 100, 1) : 0; // Disk $diskTotal = disk_total_space('/'); $diskFree = disk_free_space('/'); $diskPct = $diskTotal > 0 ? round((($diskTotal - $diskFree) / $diskTotal) * 100, 1) : 0; // Services — dynamic list based on configured servers $webSetting = $db->fetchOne("SELECT `value` FROM settings WHERE `key`='web_server'")['value'] ?? 'apache'; $ftpSetting = $db->fetchOne("SELECT `value` FROM settings WHERE `key`='ftp_server'")['value'] ?? 'proftpd'; $dnsSetting = $db->fetchOne("SELECT `value` FROM settings WHERE `key`='dns_server'")['value'] ?? 'bind9'; $webSvc = match($webSetting) { 'nginx' => 'nginx', 'apache' => 'apache2', default => 'apache2' }; $ftpSvc = match($ftpSetting) { 'vsftpd' => 'vsftpd', 'pureftpd' => 'pure-ftpd', default => 'proftpd' }; $dnsSvc = match($dnsSetting) { 'powerdns' => 'pdns', 'nsd' => 'nsd', 'none' => null, default => 'named' }; $svcList = array_filter([$webSvc,'mysql','postfix','dovecot',$ftpSvc,$dnsSvc,'fail2ban']); $services = []; foreach ($svcList as $svc) { $active = trim(shell_exec("systemctl is-active $svc 2>/dev/null") ?: ''); if ($active) $services[$svc] = $active; } // Persist to DB for history (non-fatal — don't let lock errors kill the stats response) try { $db->execute( "INSERT INTO server_stats (cpu_usage,ram_usage,disk_usage,load_avg) VALUES (?,?,?,?)", [$cpuPct, $ramPct, $diskPct, $load[0]] ); } catch (Throwable $_) {} Response::success([ 'cpu' => ['pct' => $cpuPct, 'load' => $load], 'ram' => ['total_kb' => $ramTotal, 'used_kb' => $ramTotal - $ramAvail, 'pct' => $ramPct], 'disk' => ['total' => $diskTotal, 'free' => $diskFree, 'pct' => $diskPct], 'services' => $services, 'uptime' => trim(shell_exec('uptime -p') ?: ''), ]); })(), // ── Single-service status check (lightweight) ───────────────────────────── 'svc-check' => (function() { Auth::getInstance()->require('admin'); $svc = preg_replace('/[^a-z0-9\-_.]/', '', $_GET['service'] ?? ''); $status = $svc ? trim(shell_exec("systemctl is-active " . escapeshellarg($svc) . " 2>/dev/null") ?: 'unknown') : 'unknown'; Response::success(['service' => $svc, 'status' => $status]); })(), // ── Installed service versions with latest available ────────────────────── 'service-versions' => (function() { Auth::getInstance()->require('admin'); // pkg => [label, description, systemd service name] $catalog = [ 'apache2' => ['Apache 2', 'Web server — serves all hosted websites on ports 80/443', 'apache2'], 'nginx' => ['Nginx', 'High-performance web server / reverse proxy', 'nginx'], 'php8.3' => ['PHP 8.3', 'Server-side scripting runtime (CLI + FPM)', 'php8.3-fpm'], 'php8.2' => ['PHP 8.2', 'PHP 8.2 runtime (legacy support)', 'php8.2-fpm'], 'php8.1' => ['PHP 8.1', 'PHP 8.1 runtime (legacy support)', 'php8.1-fpm'], 'php7.4' => ['PHP 7.4', 'PHP 7.4 runtime (end-of-life compatibility)', 'php7.4-fpm'], 'mysql-server' => ['MySQL', 'Relational database server (MariaDB/MySQL) for hosted sites', 'mysql'], 'mariadb-server' => ['MariaDB', 'MySQL-compatible database server fork', 'mariadb'], 'postfix' => ['Postfix', 'MTA — routes and delivers email for hosted domains', 'postfix'], 'dovecot-core' => ['Dovecot', 'IMAP/POP3 server — lets users retrieve email via mail clients', 'dovecot'], 'rspamd' => ['Rspamd', 'Spam filter that scores and rejects unwanted email', 'rspamd'], 'proftpd' => ['ProFTPD', 'FTP server for account file transfers', 'proftpd'], 'vsftpd' => ['vsftpd', 'Lightweight FTP server', 'vsftpd'], 'bind9' => ['BIND9', 'Authoritative DNS server — serves zone files for hosted domains', 'named'], 'fail2ban' => ['Fail2Ban', 'Intrusion prevention — bans IPs after repeated failed logins', 'fail2ban'], 'ufw' => ['UFW', 'Uncomplicated Firewall — iptables front-end for port management', null], 'certbot' => ['Certbot', "Let's Encrypt client — automates SSL certificate issuance", null], 'sqlite3' => ['SQLite3', 'Embedded SQL database — stores NovaCPX panel data', null], 'redis-server' => ['Redis', 'In-memory data store — used for caching and queuing', 'redis'], 'memcached' => ['Memcached', 'Memory object cache', 'memcached'], 'nodejs' => ['Node.js', 'JavaScript runtime — used by some web applications', null], 'git' => ['Git', 'Version control system — used for site deployments', null], 'curl' => ['curl', 'HTTP client — used by health checks and API calls', null], ]; $dpkgOut = shell_exec("dpkg-query -W -f='\${Package} \${Version}\\n' 2>/dev/null") ?: ''; $installed = []; foreach (explode("\n", trim($dpkgOut)) as $line) { [$pkg, $ver] = array_pad(explode(' ', $line, 2), 2, ''); if ($pkg) $installed[$pkg] = trim($ver); } $aptOut = shell_exec("apt-cache policy " . implode(' ', array_keys($catalog)) . " 2>/dev/null") ?: ''; $latest = []; $curPkg = null; foreach (explode("\n", $aptOut) as $line) { if (preg_match('/^(\S+):$/', trim($line), $m)) { $curPkg = $m[1]; continue; } if ($curPkg && preg_match('/Candidate:\s+(.+)/', $line, $m)) { $latest[$curPkg] = trim($m[1]); $curPkg = null; } } $services = []; foreach ($catalog as $pkg => [$label, $desc, $svc]) { $ver = $installed[$pkg] ?? null; if (!$ver) continue; // skip not-installed $latestVer = $latest[$pkg] ?? 'unknown'; $status = $svc ? trim(shell_exec("systemctl is-active $svc 2>/dev/null") ?: 'inactive') : null; $upToDate = ($latestVer === 'unknown' || $latestVer === '(none)') ? null : (version_compare($ver, $latestVer, '>=')); $services[] = [ 'pkg' => $pkg, 'label' => $label, 'desc' => $desc, 'installed' => $ver, 'latest' => $latestVer, 'up_to_date' => $upToDate, 'status' => $status, ]; } Response::success(['services' => $services]); })(), // ── Service control (start/stop/restart) ────────────────────────────────── 'service' => (function() use ($body, $db) { Auth::getInstance()->require('admin'); $svc = preg_replace('/[^a-z0-9\-_]/', '', $body['service'] ?? ''); $cmd = $body['command'] ?? 'status'; $allowed = ['apache2','nginx','lighttpd','caddy','mysql','mariadb','postgresql','postfix','dovecot', 'proftpd','vsftpd','pure-ftpd','named','bind9','pdns','nsd','fail2ban', 'php7.4-fpm','php8.1-fpm','php8.2-fpm','php8.3-fpm']; if (!in_array($svc, $allowed)) Response::error("Service not managed: $svc"); if (!in_array($cmd, ['start','stop','restart','reload','status','flush'])) Response::error("Invalid command"); if ($cmd === 'flush' && $svc === 'postfix') { $out = shell_exec("sudo postqueue -f 2>&1"); } else { $out = shell_exec("sudo systemctl $cmd " . escapeshellarg($svc) . " 2>&1"); } audit("service.$cmd", $svc); Response::success(['output' => $out]); })(), // ── Audit log ───────────────────────────────────────────────────────────── 'audit-log' => (function() use ($db) { Auth::getInstance()->require('admin'); $page = max(1, (int)($_GET['page'] ?? 1)); $perPage = min(100, max(10, (int)($_GET['per_page'] ?? 50))); $offset = ($page - 1) * $perPage; $user = trim($_GET['user'] ?? ''); $action = trim($_GET['action'] ?? ''); $dateFrom = trim($_GET['date_from'] ?? ''); $dateTo = trim($_GET['date_to'] ?? ''); $where = 'WHERE 1=1'; $params = []; if ($user) { $where .= ' AND username LIKE ?'; $params[] = "%$user%"; } if ($action) { $where .= ' AND action LIKE ?'; $params[] = "%$action%"; } if ($dateFrom) { $where .= ' AND created_at >= ?'; $params[] = $dateFrom . ' 00:00:00'; } if ($dateTo) { $where .= ' AND created_at <= ?'; $params[] = $dateTo . ' 23:59:59'; } $total = $db->fetchOne("SELECT COUNT(*) as c FROM audit_log $where", $params)['c'] ?? 0; $rows = $db->fetchAll( "SELECT id, user_id, username, action, resource, ip_address, detail, created_at FROM audit_log $where ORDER BY created_at DESC LIMIT ? OFFSET ?", [...$params, $perPage, $offset] ); Response::paginate($rows, (int)$total, $page, $perPage); })(), // ── Server Options (#22a-e) ─────────────────────────────────────────────── 'server-options' => (function() use ($db) { Auth::getInstance()->require('admin'); $keys = ['web_server','mail_server','ftp_server','dns_server','whmcs_api_key','whmcs_enabled','ns1_hostname','ns2_hostname', 'panel_name','default_php','default_nameserver1','default_nameserver2','update_channel']; $opts = []; foreach ($db->fetchAll("SELECT `key`,`value` FROM settings WHERE `key` IN ('" . implode("','", $keys) . "')") as $r) { $opts[$r['key']] = $r['value']; } // Detect actually-running services $opts['apache_active'] = !empty(trim(shell_exec('systemctl is-active apache2 2>/dev/null') ?: '')) && trim(shell_exec('systemctl is-active apache2 2>/dev/null')) === 'active'; $opts['nginx_active'] = trim(shell_exec('systemctl is-active nginx 2>/dev/null') ?: '') === 'active'; $opts['proftpd_active'] = trim(shell_exec('systemctl is-active proftpd 2>/dev/null') ?: '') === 'active'; $opts['vsftpd_active'] = trim(shell_exec('systemctl is-active vsftpd 2>/dev/null') ?: '') === 'active'; $opts['pureftpd_active'] = trim(shell_exec('systemctl is-active pure-ftpd 2>/dev/null') ?: '') === 'active'; $opts['bind9_active'] = trim(shell_exec('systemctl is-active named 2>/dev/null || systemctl is-active bind9 2>/dev/null') ?: '') === 'active'; $opts['powerdns_active'] = trim(shell_exec('systemctl is-active pdns 2>/dev/null') ?: '') === 'active'; Response::success($opts); })(), 'save-option' => (function() use ($db, $body) { Auth::getInstance()->require('admin'); $key = $body['key'] ?? ''; $value = $body['value'] ?? ''; $allowed = ['web_server','mail_server','ftp_server','dns_server','whmcs_api_key','whmcs_enabled','ns1_hostname','ns2_hostname', 'panel_name','default_php','default_nameserver1','default_nameserver2','update_channel']; if (!in_array($key, $allowed)) Response::error("Invalid setting key: $key"); $db->execute("INSERT INTO settings (`key`,`value`,updated_at) VALUES (?,?,datetime('now')) ON CONFLICT(`key`) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at", [$key, $value]); audit("settings.{$key}", $value); Response::success(null, "Setting saved: {$key} = {$value}"); })(), // Streaming service switch for web/mail/ftp/dns server changes 'service-switch' => (function() use ($db, $body) { Auth::getInstance()->require('admin'); $key = $body['key'] ?? ''; $value = $body['value'] ?? ''; $serviceKeys = ['web_server','mail_server','ftp_server','dns_server']; if (!in_array($key, $serviceKeys)) Response::error("Invalid service key: $key"); header('Content-Type: text/event-stream'); header('Cache-Control: no-cache'); header('X-Accel-Buffering: no'); while (ob_get_level()) ob_end_clean(); $sse = function(string $line) { echo 'data: ' . json_encode(['line' => $line]) . "\n\n"; flush(); }; $run = function(string $cmd) use ($sse): int { $proc = proc_open($cmd, [1 => ['pipe','w'], 2 => ['pipe','w']], $pipes); if (!$proc) { $sse(" [failed to start]\n"); return 1; } while (!feof($pipes[1])) { $line = fgets($pipes[1]); if ($line !== false && $line !== '') $sse($line); } while (!feof($pipes[2])) { $line = fgets($pipes[2]); if ($line !== false && $line !== '') $sse(" " . $line); } fclose($pipes[1]); fclose($pipes[2]); return proc_close($proc); }; // Persist selection $db->execute("INSERT INTO settings (`key`,`value`) VALUES (?,?) ON CONFLICT(`key`) DO UPDATE SET value=excluded.value", [$key, $value]); // Sync config.ini $configFile = '/etc/novacpx/config.ini'; if (file_exists($configFile)) { $ini = file_get_contents($configFile); if ($key === 'web_server') $ini = preg_replace('/^server\s*=\s*.*/m', "server = $value", $ini); file_put_contents($configFile, $ini); } if ($key === 'web_server') { $target = ($value === 'nginx') ? 'nginx' : 'apache'; $sse("▶ Switching web server to {$target}…\n"); if (file_exists('/usr/local/bin/novacpx-webserver-switch')) { $run("sudo /usr/local/bin/novacpx-webserver-switch " . escapeshellarg($target) . " 2>&1"); } else { // Fallback: manage services directly if ($target === 'nginx') { $sse(" Stopping Apache on ports 80/443…\n"); $run("sudo systemctl stop apache2 2>&1"); $sse(" Starting Nginx…\n"); $run("sudo systemctl enable nginx 2>&1 && sudo systemctl start nginx 2>&1"); } else { $sse(" Stopping Nginx…\n"); $run("sudo systemctl stop nginx 2>&1"); $sse(" Starting Apache…\n"); $run("sudo systemctl enable apache2 2>&1 && sudo systemctl start apache2 2>&1"); } } $sse(" ✓ Web server switched to {$target}\n"); } elseif ($key === 'mail_server') { $sse("▶ Updating mail server config to {$value}…\n"); if ($value === 'postfix-dovecot-rspamd') { $installed = trim(shell_exec("dpkg -l rspamd 2>/dev/null | grep -c '^ii'") ?: '0'); if ($installed === '0') { $sse(" Installing Rspamd (this may take 1–2 minutes)…\n"); $run("sudo apt-get install -y rspamd 2>&1"); } $sse(" Enabling Rspamd…\n"); $run("sudo systemctl enable rspamd 2>&1 && sudo systemctl start rspamd 2>&1"); $sse(" ✓ Rspamd enabled\n"); } else { $run("sudo systemctl stop rspamd 2>/dev/null; sudo systemctl disable rspamd 2>/dev/null; true"); $sse(" ✓ Rspamd disabled\n"); } // Postfix + Dovecot always run $run("sudo systemctl is-active postfix || sudo systemctl start postfix 2>&1"); $run("sudo systemctl is-active dovecot || sudo systemctl start dovecot 2>&1"); $sse(" ✓ Mail stack: postfix + dovecot running\n"); } elseif ($key === 'ftp_server') { $sse("▶ Switching FTP server to {$value}…\n"); foreach (['proftpd','vsftpd','pure-ftpd'] as $s) { $run("sudo systemctl stop $s 2>/dev/null; sudo systemctl disable $s 2>/dev/null; true"); } $startSvc = match($value) { 'vsftpd' => 'vsftpd', 'pureftpd' => 'pure-ftpd', default => 'proftpd' }; $installed = trim(shell_exec("dpkg -l $startSvc 2>/dev/null | grep -c '^ii'") ?: '0'); if ($installed === '0') { $sse(" Installing {$startSvc}…\n"); $run("sudo apt-get install -y $startSvc 2>&1"); } $sse(" Starting {$startSvc}…\n"); $run("sudo systemctl enable $startSvc 2>&1 && sudo systemctl start $startSvc 2>&1"); $sse(" ✓ FTP server switched to {$startSvc}\n"); } elseif ($key === 'dns_server') { $sse("▶ Switching DNS server to {$value}…\n"); foreach (['named','bind9','pdns','nsd'] as $s) { $run("sudo systemctl stop $s 2>/dev/null; sudo systemctl disable $s 2>/dev/null; true"); } if ($value !== 'none') { $startSvc = match($value) { 'powerdns' => 'pdns', 'nsd' => 'nsd', default => 'named' }; $installed = trim(shell_exec("dpkg -l $startSvc 2>/dev/null | grep -c '^ii'") ?: '0'); if ($installed === '0') { $sse(" Installing {$startSvc}…\n"); $run("sudo apt-get install -y $startSvc 2>&1"); } $sse(" Starting {$startSvc}…\n"); $run("sudo systemctl enable $startSvc 2>&1 && sudo systemctl start $startSvc 2>&1"); $sse(" ✓ DNS server switched to {$startSvc}\n"); } else { $sse(" ✓ All local DNS servers stopped\n"); } } audit("settings.{$key}", $value); echo 'data: ' . json_encode(['done' => true]) . "\n\n"; flush(); exit; })(), // ── Notification settings (#25) ─────────────────────────────────────────── 'notify-settings' => (function() use ($db) { Auth::getInstance()->require('admin'); $keys = ['cybermail_api_key','notify_from_email','notify_from_name','notify_admin_email','notifications_enabled']; $out = []; foreach ($db->fetchAll("SELECT `key`,`value` FROM settings WHERE `key` IN ('" . implode("','", $keys) . "')") as $r) { $out[$r['key']] = $r['value']; } // Mask API key for display if (!empty($out['cybermail_api_key'])) { $k = $out['cybermail_api_key']; $out['cybermail_api_key_masked'] = substr($k, 0, 10) . str_repeat('*', max(0, strlen($k) - 14)) . substr($k, -4); } Response::success($out); })(), 'save-notify-settings' => (function() use ($db, $body) { Auth::getInstance()->require('admin'); $allowed = ['cybermail_api_key','notify_from_email','notify_from_name','notify_admin_email','notifications_enabled']; $saved = []; foreach ($allowed as $key) { if (!array_key_exists($key, $body)) continue; $value = trim($body[$key]); if ($key === 'cybermail_api_key' && str_contains($value, '***')) continue; // skip masked placeholder $db->execute( "INSERT INTO settings (`key`,`value`) VALUES (?,?) ON CONFLICT(`key`) DO UPDATE SET value=excluded.value", [$key, $value] ); $saved[] = $key; } audit('settings.notify', implode(',', $saved)); Response::success(null, 'Notification settings saved'); })(), 'test-notify' => (function() use ($db, $body) { Auth::getInstance()->require('admin'); require_once NOVACPX_LIB . '/Notifier.php'; $to = trim($body['to'] ?? ''); if (!$to || !filter_var($to, FILTER_VALIDATE_EMAIL)) Response::error("Valid email address required"); // Send a test email directly $apiKey = $db->fetchOne("SELECT `value` FROM settings WHERE `key` = 'cybermail_api_key'")['value'] ?? ''; $fromEmail = $db->fetchOne("SELECT `value` FROM settings WHERE `key` = 'notify_from_email'")['value'] ?: 'noreply@novacpx.local'; $fromName = $db->fetchOne("SELECT `value` FROM settings WHERE `key` = 'notify_from_name'")['value'] ?: 'NovaCPX Panel'; if (!$apiKey) Response::error("No CyberMail API key configured"); $payload = json_encode([ 'from' => $fromEmail, 'to' => $to, 'subject' => 'NovaCPX — test notification', 'html' => '

Test Notification

Email notifications are working correctly from your NovaCPX panel.

', 'text' => 'Test Notification: Email notifications are working correctly from your NovaCPX panel.', ]); $ch = curl_init('https://platform.cyberpersons.com/email/v1/send'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey, 'Content-Type: application/json'], CURLOPT_TIMEOUT => 15, ]); $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($code === 202) Response::success(null, "Test email sent to {$to}"); else Response::error("CyberMail returned HTTP {$code}: " . substr($resp, 0, 200)); })(), // ── Email template CRUD ─────────────────────────────────────────────────── 'email-templates' => (function() use ($db) { Auth::getInstance()->require('admin'); $templates = $db->fetchAll("SELECT id,trigger_key,label,subject,enabled,updated_at FROM email_templates ORDER BY trigger_key"); Response::success(['templates' => $templates]); })(), 'email-template-get' => (function() use ($db, $body) { Auth::getInstance()->require('admin'); $id = (int)($body['id'] ?? $_GET['id'] ?? 0); $row = $db->fetchOne("SELECT * FROM email_templates WHERE id = ?", [$id]); if (!$row) Response::error("Template not found", 404); Response::success($row); })(), 'email-template-save' => (function() use ($db, $body) { Auth::getInstance()->require('admin'); $id = (int)($body['id'] ?? 0); $subject = trim($body['subject'] ?? ''); $bodyHtml = trim($body['body_html'] ?? ''); $bodyText = trim($body['body_text'] ?? ''); $enabled = isset($body['enabled']) ? (int)(bool)$body['enabled'] : 1; if (!$subject || !$bodyHtml) Response::error("Subject and HTML body required"); if ($id) { $db->execute("UPDATE email_templates SET subject=?,body_html=?,body_text=?,enabled=?,updated_at=datetime('now') WHERE id=?", [$subject, $bodyHtml, $bodyText, $enabled, $id]); } else { $triggerKey = preg_replace('/[^a-z0-9_]/', '_', strtolower(trim($body['trigger_key'] ?? ''))); $label = trim($body['label'] ?? $triggerKey); if (!$triggerKey) Response::error("trigger_key required for new template"); $id = (int)$db->insert("INSERT INTO email_templates (trigger_key,label,subject,body_html,body_text,enabled) VALUES (?,?,?,?,?,?)", [$triggerKey, $label, $subject, $bodyHtml, $bodyText, $enabled]); } audit("email_template.save", (string)$id); Response::success(['id' => $id], 'Template saved'); })(), 'email-template-delete' => (function() use ($db, $body) { Auth::getInstance()->require('admin'); $id = (int)($body['id'] ?? 0); $row = $db->fetchOne("SELECT trigger_key FROM email_templates WHERE id = ?", [$id]); if (!$row) Response::error("Template not found", 404); $db->execute("DELETE FROM email_templates WHERE id = ?", [$id]); audit("email_template.delete", $row['trigger_key']); Response::success(null, 'Template deleted'); })(), 'email-template-test' => (function() use ($db, $body) { Auth::getInstance()->require('admin'); $id = (int)($body['id'] ?? 0); $to = trim($body['to'] ?? ''); if (!$to || !filter_var($to, FILTER_VALIDATE_EMAIL)) Response::error("Valid email address required"); $tmpl = $id ? $db->fetchOne("SELECT * FROM email_templates WHERE id = ?", [$id]) : null; if (!$tmpl && $id) Response::error("Template not found", 404); $apiKey = $db->fetchOne("SELECT `value` FROM settings WHERE `key` = 'cybermail_api_key'")['value'] ?? ''; $fromEmail = $db->fetchOne("SELECT `value` FROM settings WHERE `key` = 'notify_from_email'")['value'] ?: 'noreply@novacpx.local'; $fromName = $db->fetchOne("SELECT `value` FROM settings WHERE `key` = 'notify_from_name'")['value'] ?: 'NovaCPX Panel'; if (!$apiKey) Response::error("No CyberMail API key configured"); // Replace placeholders with sample values for test $samples = [ '{{name}}' => 'Test User', '{{domain}}' => 'example.com', '{{username}}' => 'testuser', '{{password}}' => '••••••••', '{{panel_url}}' => 'https://panel.yourdomain.com', '{{reason}}' => 'Non-payment', '{{support_email}}' => $fromEmail, '{{days}}' => '14', '{{expiry_date}}' => date('Y-m-d', strtotime('+14 days')), '{{usage}}' => '87', '{{used}}' => '8.7 GB', '{{quota}}' => '10 GB', '{{package}}' => 'Basic', '{{created_by}}' => 'admin', '{{reset_url}}' => 'https://panel.yourdomain.com/reset?token=EXAMPLE', ]; $subject = $tmpl ? strtr($tmpl['subject'], $samples) : 'NovaCPX Test'; $html = $tmpl ? strtr($tmpl['body_html'], $samples) : '

Test

'; $text = $tmpl ? strtr($tmpl['body_text'] ?? '', $samples) : 'Test'; $payload = json_encode(['from' => $fromEmail, 'to' => $to, 'subject' => '[TEST] ' . $subject, 'html' => $html, 'text' => $text]); $ch = curl_init('https://platform.cyberpersons.com/email/v1/send'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey, 'Content-Type: application/json'], CURLOPT_TIMEOUT => 15, ]); $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($code === 202) Response::success(null, "Test email sent to {$to}"); else Response::error("CyberMail returned HTTP {$code}: " . substr($resp, 0, 200)); })(), // ── Database engine management ──────────────────────────────────────────── 'db-engines' => (function() use ($db) { Auth::getInstance()->require('admin'); $engines = []; foreach (['mysql','mariadb','postgresql'] as $eng) { $svc = match($eng) { 'mysql' => 'mysql', 'mariadb' => 'mariadb', 'postgresql' => 'postgresql', }; $active = trim(shell_exec("systemctl is-active $svc 2>/dev/null") ?: ''); $enabled = trim(shell_exec("systemctl is-enabled $svc 2>/dev/null") ?: ''); $pkgCheck = match($eng) { 'mysql' => 'dpkg -l mysql-server 2>/dev/null | grep -c "^ii"', 'mariadb' => 'dpkg -l mariadb-server 2>/dev/null | grep -c "^ii"', 'postgresql' => 'dpkg -l postgresql 2>/dev/null | grep -c "^ii"', }; $installed = (int)trim(shell_exec($pkgCheck) ?: '0') > 0; $version = ''; if ($installed) { $version = match($eng) { 'mysql' => trim(shell_exec("mysql --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+'") ?: ''), 'mariadb' => trim(shell_exec("mariadb --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+'") ?: ''), 'postgresql' => trim(shell_exec("psql --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+'") ?: ''), }; } $engines[$eng] = [ 'installed' => $installed, 'active' => $active === 'active', 'enabled' => $enabled === 'enabled', 'version' => $version, ]; } $active_db = $db->fetchOne("SELECT `value` FROM settings WHERE `key` = 'active_db_engine'")['value'] ?? 'mysql'; Response::success(['engines' => $engines, 'active_engine' => $active_db]); })(), 'db-engine-action' => (function() use ($db, $body) { Auth::getInstance()->require('admin'); $engine = preg_replace('/[^a-z]/', '', $body['engine'] ?? ''); $action = $body['action'] ?? ''; if (!in_array($engine, ['mysql','mariadb','postgresql'])) Response::error("Invalid engine"); if (!in_array($action, ['install','remove','start','stop','restart','set-active'])) Response::error("Invalid action"); // Panel DB is SQLite — no MySQL engine hosts it, so any MySQL/MariaDB/PG can be removed freely $out = ''; if ($action === 'install') { $pkg = match($engine) { 'mysql' => 'mysql-server', 'mariadb' => 'mariadb-server', 'postgresql' => 'postgresql postgresql-contrib', }; $out = shell_exec("sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y $pkg 2>&1"); shell_exec("sudo systemctl enable $engine 2>/dev/null && sudo systemctl start $engine 2>/dev/null"); } elseif ($action === 'remove') { $pkg = match($engine) { 'mysql' => 'mysql-server mysql-client', 'mariadb' => 'mariadb-server mariadb-client', 'postgresql' => 'postgresql postgresql-contrib', }; shell_exec("sudo systemctl stop $engine 2>/dev/null || true"); $out = shell_exec("sudo env DEBIAN_FRONTEND=noninteractive apt-get remove -y $pkg 2>&1"); } elseif ($action === 'set-active') { $db->execute("INSERT INTO settings (`key`,`value`) VALUES ('active_db_engine',?) ON CONFLICT(`key`) DO UPDATE SET value=excluded.value", [$engine]); audit('settings.active_db_engine', $engine); Response::success(null, "Active database engine set to $engine"); } else { shell_exec("sudo systemctl $action $engine 2>/dev/null"); } audit("db-engine.$action", $engine); Response::success(['output' => substr($out ?: '', -1000)], ucfirst($action) . " $engine done"); })(), 'db-tools' => (function() { Auth::getInstance()->require('admin'); $pmaInstalled = (int)trim(shell_exec("dpkg -l phpmyadmin 2>/dev/null | grep -c '^ii'") ?: '0') > 0 || is_dir('/usr/share/phpmyadmin'); $pgaInstalled = (int)trim(shell_exec("dpkg -l pgadmin4 2>/dev/null | grep -c '^ii'") ?: '0') > 0 || is_file('/usr/pgadmin4/bin/pgadmin4') || is_dir('/usr/pgadmin4'); $pmaVer = $pmaInstalled ? trim(shell_exec("dpkg -l phpmyadmin 2>/dev/null | awk '/^ii/{print $3}' | head -1") ?: '') : ''; $pgaVer = $pgaInstalled ? trim(shell_exec("pgadmin4 --version 2>/dev/null | grep -oP '[0-9]+\\.[0-9]+' | head -1") ?: '') : ''; Response::success([ 'phpmyadmin' => ['installed' => $pmaInstalled, 'version' => $pmaVer], 'pgadmin' => ['installed' => $pgaInstalled, 'version' => $pgaVer], ]); })(), 'db-tools-stream' => (function() use ($body) { Auth::getInstance()->require('admin'); $tool = $body['tool'] ?? ''; $action = $body['action'] ?? ''; if (!in_array($tool, ['phpmyadmin','pgadmin'])) { echo 'data:'.json_encode(['error'=>'Invalid tool'])."\n\n"; exit; } if (!in_array($action, ['install','reinstall','remove'])) { echo 'data:'.json_encode(['error'=>'Invalid action'])."\n\n"; exit; } header('Content-Type: text/event-stream'); header('Cache-Control: no-cache'); header('X-Accel-Buffering: no'); ob_implicit_flush(true); while (ob_get_level() > 0) ob_end_flush(); set_time_limit(300); $sse = function(string $line) { echo 'data: ' . json_encode(['line' => $line]) . "\n\n"; flush(); }; $run = function(string $cmd) use ($sse): int { $proc = proc_open($cmd, [1 => ['pipe','w'], 2 => ['pipe','w']], $pipes); if (!$proc) { $sse(" [failed to start process]\n"); return 1; } stream_set_blocking($pipes[1], false); stream_set_blocking($pipes[2], false); while (true) { $r = [$pipes[1], $pipes[2]]; $w = $e = []; if (stream_select($r, $w, $e, 1) > 0) { foreach ($r as $s) { $line = fgets($s, 4096); if ($line !== false && $line !== '') $sse($line); } } if (feof($pipes[1]) && feof($pipes[2])) break; } fclose($pipes[1]); fclose($pipes[2]); return proc_close($proc); }; $env = 'sudo env DEBIAN_FRONTEND=noninteractive'; if ($tool === 'phpmyadmin') { if ($action === 'remove') { $sse("▶ Removing phpMyAdmin…\n"); $run("$env apt-get remove -y phpmyadmin 2>&1"); } else { if ($action === 'reinstall') { $sse("▶ Removing existing phpMyAdmin installation…\n"); $run("$env apt-get remove -y phpmyadmin 2>&1"); } $sse("▶ Pre-configuring debconf answers…\n"); shell_exec("echo 'phpmyadmin phpmyadmin/reconfigure-webserver multiselect apache2' | sudo debconf-set-selections 2>/dev/null"); shell_exec("echo 'phpmyadmin phpmyadmin/dbconfig-install boolean true' | sudo debconf-set-selections 2>/dev/null"); $sse(" ✓ Done\n"); $sse("▶ Installing phpMyAdmin (this takes 20–60 seconds)…\n"); $rc = $run("$env apt-get install -y phpmyadmin php8.3-mbstring php8.3-xml php8.3-zip 2>&1"); if ($rc !== 0) { echo 'data:'.json_encode(['error'=>'apt-get failed (see output above)'])."\n\n"; flush(); exit; } $sse("▶ Configuring web server alias…\n"); if (!is_link('/etc/apache2/conf-enabled/phpmyadmin.conf') && is_file('/etc/phpmyadmin/apache.conf')) { shell_exec("sudo ln -sf /etc/phpmyadmin/apache.conf /etc/apache2/conf-enabled/phpmyadmin.conf 2>/dev/null"); shell_exec("sudo systemctl reload apache2 2>/dev/null || true"); $sse(" ✓ Apache alias enabled\n"); } if (is_dir('/etc/nginx') && !is_file('/etc/nginx/conf.d/phpmyadmin.conf')) { $nginxConf = "location /phpmyadmin {\n root /usr/share/;\n index index.php;\n location ~ ^/phpmyadmin/(.+\\.php)\$ {\n root /usr/share/;\n fastcgi_pass unix:/run/php/php8.3-fpm.sock;\n fastcgi_index index.php;\n include fastcgi.conf;\n }\n}\n"; shell_exec("echo " . escapeshellarg($nginxConf) . " | sudo tee /etc/nginx/conf.d/phpmyadmin.conf > /dev/null"); shell_exec("sudo systemctl reload nginx 2>/dev/null || true"); $sse(" ✓ Nginx alias created\n"); } } } else { // pgAdmin4 if ($action === 'remove') { $sse("▶ Removing pgAdmin 4…\n"); $run("$env apt-get remove -y pgadmin4 pgadmin4-web 2>&1"); } else { if ($action === 'reinstall') { $sse("▶ Removing existing pgAdmin 4 installation…\n"); $run("$env apt-get remove -y pgadmin4 pgadmin4-web 2>&1"); } if (!is_file('/etc/apt/sources.list.d/pgadmin4.list')) { $sse("▶ Adding pgAdmin apt repository…\n"); $distro = trim(shell_exec("lsb_release -cs 2>/dev/null") ?: 'jammy'); $run("sudo curl -fsS https://www.pgadmin.org/static/packages_pgadmin_org.pub | sudo gpg --dearmor -o /usr/share/keyrings/pgadmin4.gpg 2>&1"); $repoLine = "deb [signed-by=/usr/share/keyrings/pgadmin4.gpg] https://ftp.postgresql.org/pub/pgadmin/pgadmin4/apt/{$distro} pgadmin4 main\n"; shell_exec("echo " . escapeshellarg($repoLine) . " | sudo tee /etc/apt/sources.list.d/pgadmin4.list > /dev/null"); $sse("▶ Updating package lists…\n"); $run("sudo apt-get update 2>&1"); } $sse("▶ Installing pgAdmin 4 web (this takes 1–3 minutes)…\n"); $rc = $run("$env apt-get install -y pgadmin4-web 2>&1"); if ($rc !== 0) { echo 'data:'.json_encode(['error'=>'apt-get failed (see output above)'])."\n\n"; flush(); exit; } // Enable Apache config $sse("▶ Enabling Apache configuration…\n"); shell_exec("sudo a2enconf pgadmin4 2>/dev/null"); shell_exec("sudo systemctl reload apache2 2>/dev/null"); $sse(" ✓ Apache config enabled\n"); // Initialise pgAdmin DB with provided credentials $pgaEmail = $body['pga_email'] ?? ''; $pgaPass = $body['pga_pass'] ?? ''; if ($pgaEmail && $pgaPass) { $sse("▶ Initialising pgAdmin database and admin user…\n"); // Wipe any broken DB from prior attempts shell_exec("sudo rm -f /var/lib/pgadmin/pgadmin4.db /var/lib/pgadmin/pgadmin4.db.* 2>/dev/null"); $setupCmd = "sudo env" . " PGADMIN_SETUP_EMAIL=" . escapeshellarg($pgaEmail) . " PGADMIN_SETUP_PASSWORD=" . escapeshellarg($pgaPass) . " /usr/pgadmin4/venv/bin/python3 /usr/pgadmin4/web/setup.py setup-db 2>&1"; $run($setupCmd); // Fix ownership so Apache/www-data can read the DB and log shell_exec("sudo mkdir -p /var/log/pgadmin && sudo chown -R www-data:www-data /var/lib/pgadmin /var/log/pgadmin 2>/dev/null"); shell_exec("sudo systemctl reload apache2 2>/dev/null"); } else { $sse(" ⚠ No credentials provided — run Reinstall to set up admin user\n"); } } } audit("db-tools.$action", $tool); $verb = ['install'=>'Installed','reinstall'=>'Reinstalled','remove'=>'Removed'][$action]; $sse(" ✓ {$verb}!\n"); echo 'data: ' . json_encode(['done' => true, 'message' => "$verb $tool"]) . "\n\n"; flush(); exit; })(), 'read-log' => (function() { Auth::getInstance()->require('admin'); $log = preg_replace('/[^a-z0-9-]/', '', $_GET['log'] ?? 'panel'); $map = [ 'panel' => '/var/log/novacpx/panel.log', 'deploy' => '/var/log/novacpx/deploy.log', 'nginx-error' => '/var/log/novacpx/nginx-error.log', 'nginx-access' => '/var/log/novacpx/nginx-access.log', 'mail' => '/var/log/mail.log', 'stats' => '/var/log/novacpx/stats-collector.log', ]; $path = $map[$log] ?? '/var/log/novacpx/panel.log'; $raw = file_exists($path) ? trim(shell_exec('tail -100 ' . escapeshellarg($path)) ?: '') : ''; Response::success(['content' => $raw, 'log' => $log]); })(), default => Response::error("Unknown system action: $action", 404), };