Files
myron 1c2c11251c Streaming terminals for PHP extensions, SSL certificates; UFW logging state fix
- php.php: install-extension and remove-extension now stream via SSE (real-time progress, proper error detection, sudo)
- ssl.php: issue action now streams certbot output via SSE
- admin.js: phpExtInstall/Remove use streaming terminal modal
- admin.js: adminIssueBulkSSL uses streaming modal with per-domain progress
- admin.js: adminRenewCert now confirms before renewing
- admin.js: adminIssueSingleSSL helper for per-domain streaming SSL
- admin.js: firewall page pre-selects current UFW logging level from API response
- admin.js: fwSetLogging reloads firewall page on success
- firewall.php: ufw_status() now parses and returns logging level

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 18:04:43 +00:00

135 lines
6.8 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
require_once NOVACPX_LIB . '/SSLManager.php';
require_once NOVACPX_LIB . '/VhostManager.php';
$user = Auth::getInstance()->user();
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) {
$rows = $db->fetchAll("SELECT id, domain, type, issued_at, expires_at, auto_renew, status FROM ssl_certs WHERE account_id = ? ORDER BY domain", [$accountId]);
foreach ($rows as &$r) {
$r['days_remaining'] = $r['expires_at'] ? (int)floor((strtotime($r['expires_at']) - time()) / 86400) : null;
}
Response::success($rows);
})(),
'issue' => (function() use ($body, $accountId, $db) {
$domain = trim($body['domain'] ?? '');
$email = trim($body['email'] ?? '');
if (!$domain) Response::error("domain required");
if (!$accountId) Response::error("account_id required");
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(); };
$sse("▶ Requesting Let's Encrypt certificate for {$domain}\n");
$sse(" (This may take 3060 seconds)\n\n");
try {
$webRoot = $db->fetchOne("SELECT document_root FROM domains WHERE domain = ? AND account_id = ?", [$domain, $accountId]);
if (!$webRoot) { $sse(" ✗ Domain not found for this account\n"); echo 'data: '.json_encode(['done'=>true,'success'=>false])."\n\n"; flush(); exit; }
$docRoot = $webRoot['document_root'];
$mail = $email ?: "ssl@{$domain}";
$cmd = "certbot certonly --webroot -w " . escapeshellarg($docRoot)
. " -d " . escapeshellarg($domain) . " -d " . escapeshellarg("www.{$domain}")
. " --email " . escapeshellarg($mail)
. " --agree-tos --non-interactive 2>&1";
$proc = proc_open($cmd, [1 => ['pipe','w'], 2 => ['pipe','w']], $pipes);
if ($proc) {
while (!feof($pipes[1])) { $l = fgets($pipes[1]); if ($l !== false && $l !== '') $sse($l); }
fclose($pipes[1]); fclose($pipes[2]);
proc_close($proc);
}
$certPath = "/etc/letsencrypt/live/{$domain}/fullchain.pem";
if (!file_exists($certPath)) {
$sse("\n ✗ Certificate not created — check DNS is pointed to this server\n");
echo 'data: '.json_encode(['done'=>true,'success'=>false])."\n\n"; flush(); exit;
}
$sse("\n▶ Installing certificate on vhost…\n");
$result = SSLManager::issueLetsEncrypt($accountId, $domain, $email);
audit('ssl.issue', $domain);
$sse(" ✓ SSL certificate installed (expires {$result['expires']})\n");
echo 'data: '.json_encode(['done'=>true,'success'=>true,'cert_id'=>$result['cert_id']])."\n\n";
} catch (Exception $e) {
$sse(" ✗ Error: " . $e->getMessage() . "\n");
echo 'data: '.json_encode(['done'=>true,'success'=>false])."\n\n";
}
flush(); exit;
})(),
'generate-csr' => (function() use ($body, $accountId) {
$domain = preg_replace('/[^a-zA-Z0-9\-\.]/', '', trim($body['domain'] ?? ''));
$country = preg_replace('/[^A-Z]/', '', strtoupper(substr(trim($body['country'] ?? 'US'), 0, 2)));
$state = substr(trim($body['state'] ?? 'State'), 0, 64);
$city = substr(trim($body['city'] ?? 'City'), 0, 64);
$org = substr(trim($body['org'] ?? 'Organization'), 0, 64);
if (!$domain) Response::error("Domain required");
$keyFile = tempnam('/tmp', 'ncpx_key_');
$csrFile = tempnam('/tmp', 'ncpx_csr_');
$subj = "/C={$country}/ST={$state}/L={$city}/O={$org}/CN={$domain}";
$sanConf = "[req]\ndistinguished_name=req\n[san]\nsubjectAltName=DNS:{$domain},DNS:www.{$domain}";
$confFile = tempnam('/tmp', 'ncpx_conf_');
file_put_contents($confFile, $sanConf);
shell_exec("openssl req -newkey rsa:2048 -nodes -keyout " . escapeshellarg($keyFile)
. " -out " . escapeshellarg($csrFile)
. " -subj " . escapeshellarg($subj)
. " -reqexts san -config " . escapeshellarg($confFile) . " 2>/dev/null");
$csr = file_exists($csrFile) ? trim(file_get_contents($csrFile)) : '';
$key = file_exists($keyFile) ? trim(file_get_contents($keyFile)) : '';
foreach ([$keyFile, $csrFile, $confFile] as $f) { @unlink($f); }
if (!$csr || !$key) Response::error("OpenSSL CSR generation failed — is openssl installed?");
Response::success(['csr' => $csr, 'private_key' => $key, 'domain' => $domain], 'CSR generated');
})(),
'install-custom' => (function() use ($body, $accountId) {
$domain = trim($body['domain'] ?? '');
$cert = trim($body['cert'] ?? '');
$key = trim($body['key'] ?? '');
$chain = trim($body['chain'] ?? '');
if (!$domain || !$cert || !$key) Response::error("domain, cert, and key required");
$id = SSLManager::installCustom($accountId, $domain, $cert, $key, $chain);
audit('ssl.install-custom', $domain);
Response::success(['id' => $id], 'Custom certificate installed');
})(),
'renew' => (function() use ($db, $body, $accountId) {
$certId = (int)($body['cert_id'] ?? 0);
$cert = $db->fetchOne("SELECT * FROM ssl_certs WHERE id = ? AND account_id = ?", [$certId, $accountId]);
if (!$cert) Response::error("Certificate not found", 404);
$result = SSLManager::issueLetsEncrypt($accountId, $cert['domain']);
audit('ssl.renew', $cert['domain']);
Response::success($result, 'Certificate renewed');
})(),
'delete' => (function() use ($db, $body, $accountId) {
$certId = (int)($body['cert_id'] ?? 0);
$cert = $db->fetchOne("SELECT * FROM ssl_certs WHERE id = ? AND account_id = ?", [$certId, $accountId]);
if (!$cert) Response::error("Certificate not found", 404);
$db->execute("DELETE FROM ssl_certs WHERE id = ?", [$certId]);
$db->execute("UPDATE domains SET ssl_enabled = 0 WHERE domain = ?", [$cert['domain']]);
audit('ssl.delete', $cert['domain']);
Response::success(null, 'Certificate removed');
})(),
default => Response::error("Unknown ssl action: $action", 404),
};