Proxy: setup progress stream, self-healing, uninstall, health check cron

- ProxyManager::runSetupOnRemote() — generator yields step-by-step
  progress; drives SSE stream from /api/proxy/setup-remote POST
- ProxyManager::uninstall(bool) — removes configs from remote or local;
  optionally apt-get removes nginx and sets mode=disabled
- ProxyManager::healthCheck() — called every 5 min from collect-stats.php;
  restarts nginx on remote if found stopped
- proxy.php: POST /api/proxy/setup-remote (SSE stream), DELETE /api/proxy/uninstall
- admin.js: proxyRunSetup() streams output to a live log modal;
  proxyUninstall() with configs-only vs full removal choice;
  'Run Setup on Remote VM' / 'Uninstall' buttons in page header

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 10:23:02 +00:00
parent 6b95571548
commit ed552cd5a6
4 changed files with 148 additions and 0 deletions
+27
View File
@@ -141,6 +141,33 @@ try {
$action === 'test-remote' && $method === 'POST' =>
Response::json(['success' => true, 'data' => ProxyManager::testRemote()]),
// POST setup-remote — run nginx setup on remote VM, stream output via SSE
($action === 'setup-remote') && $method === 'POST' => (function() {
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();
foreach (ProxyManager::runSetupOnRemote() as $line) {
echo 'data: ' . json_encode(['line' => $line]) . "\n\n";
flush();
}
echo "data: " . json_encode(['done' => true]) . "\n\n";
flush();
exit;
})(),
// DELETE uninstall — remove proxy configs (and optionally nginx)
$action === 'uninstall' && $method === 'DELETE' => (function() use ($body) {
$removeNginx = !empty($body['remove_nginx']);
$result = ProxyManager::uninstall($removeNginx);
if ($removeNginx) {
$db = DB::getInstance();
$db->execute("INSERT INTO settings (`key`, value) VALUES ('proxy_mode','disabled') ON DUPLICATE KEY UPDATE value='disabled'");
}
Response::json(['success' => true, 'data' => ['result' => $result]]);
})(),
default => Response::error('Not found', 404),
};
+4
View File
@@ -63,3 +63,7 @@ $db->execute(
// Prune rows older than 30 days
$db->execute("DELETE FROM server_stats WHERE recorded_at < DATE_SUB(NOW(), INTERVAL 30 DAY)");
// Proxy health check — restart nginx on remote proxy VM if it's stopped
require_once NOVACPX_LIB . '/ProxyManager.php';
ProxyManager::healthCheck();
+68
View File
@@ -284,6 +284,74 @@ class ProxyManager {
return ['ok' => true, 'message' => 'Connected — ' . trim($out)];
}
// --- Remote setup & uninstall ---
public static function runSetupOnRemote(): \Generator {
$r = self::getRemote();
if (!$r['host']) { yield "ERROR: No remote host configured\n"; return; }
$steps = [
'Updating package lists' => 'apt-get update -qq 2>&1',
'Installing nginx' => 'apt-get install -y nginx 2>&1',
'Disabling default site' => 'rm -f /etc/nginx/sites-enabled/default',
'Creating conf directories' => 'mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled',
'Writing tune config' => 'printf "client_max_body_size 256M;\nproxy_buffers 16 16k;\nproxy_buffer_size 16k;\n" > /etc/nginx/conf.d/novacpx-proxy.conf',
'Writing catch-all vhost' => 'printf "server {\n listen 80 default_server;\n server_name _;\n return 444;\n}\n" > /etc/nginx/sites-available/novacpx-default.conf && ln -sf /etc/nginx/sites-available/novacpx-default.conf /etc/nginx/sites-enabled/',
'Testing nginx config' => 'nginx -t 2>&1',
'Enabling and starting nginx' => 'systemctl enable nginx 2>/dev/null && systemctl restart nginx 2>&1 && systemctl is-active nginx',
];
foreach ($steps as $label => $cmd) {
yield "» {$label}...\n";
$out = self::remoteExec($cmd);
if ($out) yield trim($out) . "\n";
// Bail on critical failures
if (str_contains($label, 'install') && !str_contains($out ?? '', 'nginx')) {
$chk = self::remoteExec('which nginx 2>/dev/null');
if (!trim($chk)) { yield "ERROR: nginx install failed\n"; return; }
}
}
yield "✓ Nginx proxy setup complete on {$r['host']}\n";
}
public static function uninstall(bool $removeNginx = false): string {
if (self::isRemote()) {
// Remove all NovaCPX proxy configs from remote
self::remoteExec('rm -f /etc/nginx/sites-available/novacpx-proxy-*.conf /etc/nginx/sites-enabled/novacpx-proxy-*.conf /etc/nginx/conf.d/novacpx-proxy.conf');
self::remoteExec('rm -f /etc/nginx/sites-available/novacpx-default.conf /etc/nginx/sites-enabled/novacpx-default.conf');
if ($removeNginx) {
self::remoteExec('systemctl stop nginx 2>/dev/null; apt-get remove -y nginx nginx-common 2>/dev/null');
return 'nginx removed from remote VM';
}
// Just reload to apply config removal
self::remoteExec('nginx -t 2>/dev/null && systemctl reload nginx 2>/dev/null || true');
return 'proxy configs removed from remote VM';
}
// Local uninstall
foreach (glob(self::$confDir . '/' . self::$confPrefix . '*.conf') ?: [] as $f) @unlink($f);
foreach (glob(self::$enabledDir . '/' . self::$confPrefix . '*.conf') ?: [] as $f) @unlink($f);
if ($removeNginx) {
shell_exec('systemctl stop nginx 2>/dev/null; apt-get remove -y nginx nginx-common 2>/dev/null');
return 'nginx removed';
}
shell_exec('systemctl reload nginx 2>/dev/null');
return 'proxy configs removed';
}
// --- Health check (called from cron / watchdog) ---
public static function healthCheck(): string {
$db = DB::getInstance();
$mode = $db->fetchOne("SELECT value FROM settings WHERE `key`='proxy_mode'")['value'] ?? 'disabled';
if ($mode === 'disabled') return 'disabled';
if (!self::isRunning()) {
$result = self::sysctl('start');
novacpx_log('warn', "ProxyManager: nginx was stopped, attempted restart: $result");
return "restarted: $result";
}
return 'ok';
}
// --- Setup Script ---
public static function setupScript(): string {
+49
View File
@@ -2610,11 +2610,18 @@ async function nginxProxyPage() {
<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>
${isRemote && cfg.remote_host ? `
<button class="btn btn-ghost btn-sm" onclick="proxyRunSetup()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
${inst ? 'Re-run Setup' : 'Run Setup on Remote VM'}
</button>
` : ''}
${inst ? `
<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-danger btn-ghost" onclick="proxyUninstall()" style="margin-left:0.25rem">Uninstall</button>
<button class="btn btn-sm btn-primary" onclick="proxyAddHost()">+ Add Host</button>
` : ''}
</div>
@@ -2842,6 +2849,48 @@ window.proxySetupInstructions = async () => {
`, null, { cancelLabel: 'Close', showConfirm: false });
};
window.proxyRunSetup = () => {
const ov = Nova.modal('Setting Up Remote Nginx Proxy', `
<p style="color:var(--text-muted);margin-bottom:0.75rem">Running setup on the remote proxy VM — this takes about 30 seconds.</p>
<pre id="proxy-setup-log" style="background:var(--bg-secondary);padding:0.75rem;border-radius:6px;font-size:0.78rem;max-height:320px;overflow-y:auto;white-space:pre-wrap;font-family:monospace">Connecting…\n</pre>
`, null, { cancelLabel: 'Close', showConfirm: false });
const log = document.getElementById('proxy-setup-log');
const es = new EventSource('/api/proxy/setup-remote');
let done = false;
es.onmessage = (e) => {
try {
const d = JSON.parse(e.data);
if (d.line) { log.textContent += d.line; log.scrollTop = log.scrollHeight; }
if (d.done) { done = true; es.close(); log.textContent += '\n— Done. Refreshing status…\n'; setTimeout(() => Nova.loadPage('nginx-proxy', window._novaPages), 1200); }
} catch {}
};
es.onerror = () => {
if (!done) {
es.close();
log.textContent += '\n— Connection lost. Check remote host settings and try again.\n';
}
};
// Close SSE when modal is dismissed
ov.querySelector('.modal-close')?.addEventListener('click', () => es.close());
};
window.proxyUninstall = () => {
Nova.modal('Uninstall Nginx Proxy', `
<p>Choose what to remove from the <strong>remote proxy VM</strong>:</p>
<div class="form-group" style="margin-top:1rem">
<label><input type="radio" name="uninst" value="configs" checked> Remove proxy host configs only <small class="text-muted">(keep nginx running)</small></label><br>
<label style="margin-top:0.5rem"><input type="radio" name="uninst" value="full"> Remove everything <small class="text-muted">(uninstall nginx, delete all configs, disable proxy mode)</small></label>
</div>
`, async () => {
const full = document.querySelector('input[name="uninst"]:checked')?.value === 'full';
const r = await Nova.api('proxy', 'uninstall', { method: 'DELETE', body: { remove_nginx: full } });
Nova.toast(r?.data?.result || r?.message || 'Done', r?.success ? 'success' : 'error');
if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages);
}, { confirmLabel: 'Uninstall', danger: true });
};
window.proxySettings = async () => {
const r = await Nova.api('proxy', 'settings');
const cfg = r?.data || {};