mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
* docs(spec): Health banner live panel design (ref #92) Three public banner sections sharing one polling/CORS pipeline: - VisitorOrigin: nft set seen_src + GeoLite2-ASN.mmdb, threshold-gated rollup, raw IPs discarded before persistence - LiveHosts: HAProxy admin socket, 60 x 1-min ring buffer over req_tot deltas, hostname-heuristic frontend filter - CertStatus: scan /etc/letsencrypt/live + cryptography parse, classify valid / expiring_soon / expiring_critical / expired Each section fails independently; section hidden on enabled=false, empty entries, or fetch error. All three endpoints are unauthenticated, CORS-open, Cache-Control max-age=300. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(plan): Health banner live panel implementation plan (ref #92) 14-task TDD plan: tests scaffold -> config helpers -> three aggregators (visitor-origin / live-hosts / cert-status) -> FastAPI lifespan wiring -> nftables ruleset -> geoipupdate timer -> debian packaging -> banner v1.3.0 -> README + tracking docs -> full-suite verification + PR. Also reconciles spec with codebase conventions: service user is 'secubox' (not 'secubox-metrics'); config lives in /etc/secubox/secubox.conf, not a separate metrics.toml. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(metrics): Scaffold pytest layout for new aggregators (ref #92) Adds tests/__init__.py and conftest.py that wire packages/secubox-metrics/api and the repo-wide common/ onto sys.path so individual aggregator modules can be imported in isolation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(core): Add visitor_origin / live_hosts / cert_status config helpers (ref #92) Three new section helpers in secubox_core.config that merge defaults with operator-supplied TOML overrides. Each section defaults to enabled=false so the live-panel aggregators stay quiet on systems that haven't opted in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(metrics): Use tmp_path fixture + drop unused import (ref #92) Switches the config-helper tests from a hard-coded /tmp path to pytest's tmp_path fixture, matching the pattern in packages/secubox-haproxy/tests/. Removes the now-unused 'from unittest.mock import patch' line. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(metrics): VisitorOrigin aggregator (ref #92) Pure-Python aggregator that polls the nft seen_src set, resolves ASNs via GeoLite2 mmdb, and emits a threshold-gated top-N rollup. Private/loopback IPs are skipped at lookup time; raw IPs never leave the function scope; the threshold gate runs before persistence so the cache file never contains attributable counts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(metrics): VisitorOrigin code-review followups (ref #92) - mmdb auto-reopen on mtime change (Important): close + reopen when stat reports a different mtime; previously _mmdb_mtime was dead state. - _read_nft_set defensive parse (Important): guard against {elem: str} shapes to prevent a latent TypeError. - current() returns a defensive copy (Minor): no more by-reference leak of internal state. - Drop unused ip_address import and unused monkeypatch parameter (Minor). - Tighten tiebreak test to assert ASN order, not just count order (Minor). - Add test for mtime-based reopen. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(metrics): Pin VisitorOrigin error-path behaviour (ref #92) Regression tests for refresh_once: disabled config, missing mmdb, and nft subprocess failure must all yield a non-throwing degraded payload. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(metrics): LiveHosts aggregator with 60x1-min ring buffer (ref #92) Reads HAProxy admin socket via raw AF_UNIX, parses 'show stat' CSV, filters internal frontends (leading underscore or no dot), ring-buffers per-frontend deltas over 60 minutes, and emits a top-N hostname rollup. Counter-reset detection (cur < prev) yields a fresh-baseline bucket instead of a negative delta. current() returns a defensive copy mirroring the VisitorOrigin fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(metrics): Pin LiveHosts CSV parser + missing-socket paths (ref #92) Tests the show-stat CSV parser against the real HAProxy column order and asserts that an absent admin socket returns a degraded payload rather than raising. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(metrics): CertStatus aggregator (ref #92) Scans /etc/letsencrypt/live for cert.pem files, classifies each by days remaining (valid / expiring_soon / expiring_critical / expired) using the operator's warn_days/critical_days thresholds, and emits a summary + soonest next-renewal host. A single corrupt PEM never kills the scan. Days remaining computed with math.ceil so a cert expiring in 2.99d reports 3d, consistent with certbot/renewal tooling expectations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(metrics): correct expired boundary when using math.ceil (ref #92) math.ceil maps any cert that expired within the last 24 h to days=0, which the previous `days < 0` guard treated as expiring_critical instead of expired. Changing the guard to `days <= 0` closes the gap: with ceil, days=0 means actual remaining time is in (-86400, 0] — i.e. already past or exactly at expiry — so classifying it as expired is correct. All four existing tests continue to pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(metrics): Wire three live-panel aggregators into FastAPI lifespan (ref #92) Adds the three asyncio background tasks under a single lifespan and exposes their current() payloads on /api/v1/metrics/{visitor-origin,live-hosts,cert-status} with a 5-min Cache-Control. Endpoints stay unauthenticated by design — the aggregators only emit threshold-gated, hostname-only data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(metrics): Await cancelled lifespan tasks + tidy import order (ref #92) - Important: lifespan finally now awaits gather(*tasks, return_exceptions=True) after cancel(), so blocking subprocess/socket I/O in aggregator refreshes doesn't race uvicorn's shutdown timeout. - Minor: move 'from contextlib import asynccontextmanager' into the stdlib import group at the top of the file. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(metrics): Ship nftables ingress tap for visitor-origin (ref #92) Private inet secubox_metrics table with a timeout'd src-IP set, hooked from prerouting at priority -300 so additions happen before secubox-firewall's filter chain decides whether to drop the packet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(metrics): Weekly GeoLite2 ASN refresh timer (ref #92) Conditional on /etc/secubox/secrets/maxmind.conf existing, so the unit is a silent no-op on installs that haven't supplied a license key. RandomizedDelay spreads load when many boxes deploy together. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * build(metrics): Package live-panel deps, nft ruleset, and geoipupdate timer (ref #92) - control: add python3-maxminddb, python3-cryptography, geoipupdate, nftables - rules: install nftables/ and systemd/ assets - postinst: secubox -> haproxy group, cache + secrets + GeoIP dirs, nftables reload, timer enable - service: ReadWritePaths gains /var/cache/secubox Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(metrics): Run geoipupdate as secubox + restart svc on upgrade (ref #92) - secubox-geoipupdate.service: User/Group=secubox so .mmdb files inherit the ownership the metrics service expects when reading them. Previously the unit ran as root and created root-owned files that secubox-metrics could not open. - postinst: switch 'systemctl start' to 'systemctl restart' so the secubox user's new haproxy-group membership is picked up by an already-running service after an upgrade. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(banner): v1.3.0 live panel — visitor origin, live hosts, cert status (ref #92) Three independent fetch loops on a shared 30s cadence, three DOM sections, per-section hide on enabled=false / empty / fetch error. Uses existing design tokens (gold/cyan/matrix-green) so no new CSS variables are added. A failing section never affects the others. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(banner): Correct banner element id lookup in live-panel (ref #92) The new live-panel sectionContainer() helper looked up getElementById('sbx-health-banner'), but the actual banner element is created with id='health-banner'. The mismatch made banner null, so the three live-panel sections were silently never appended to the DOM. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: Session 160 — Health Banner Live Panel (ref #92) README documents the three new endpoints + config blocks. HISTORY / WIP / MIGRATION-MAP entries describe the feature, the spec/plan paths, and the session's outcome. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(metrics): Address whole-branch review findings (ref #92) - Wrap _read_nft_set / _read_haproxy_stats in asyncio.to_thread so blocking I/O (subprocess up to 5s, AF_UNIX recv up to 2s) no longer stalls the event loop on every refresh tick. - Replace falsy current() guard with explicit _refreshed flag. Previously, a successful refresh that produced entries=[] would fall through to the on-disk cache, serving stale non-empty data during low-traffic periods. - Move geoipupdate from Depends to Recommends. It lives in bookworm/contrib, so a hard dependency breaks 'apt install secubox-metrics' on systems without contrib enabled. The aggregator already degrades gracefully when the mmdb is absent, making Recommends the correct strength. README documents the contrib note. - prerm stops + disables secubox-geoipupdate.timer/service so 'apt remove' doesn't leave an orphan timer firing weekly with a missing unit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: CyberMind-FR <gandalf@Gk2.net> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
982 lines
38 KiB
JavaScript
982 lines
38 KiB
JavaScript
/**
|
|
* ═══════════════════════════════════════════════════════════════════════════════
|
|
* SECUBOX HEALTH BANNER — Global Health Monitor with Smart Doctor
|
|
* v1.0.0 — Double-buffered cache with lock protection
|
|
* ═══════════════════════════════════════════════════════════════════════════════
|
|
*
|
|
* SecuBox-Deb :: Health Banner Component
|
|
* CyberMind — https://cybermind.fr
|
|
* Author: Gerald Kerma <gandalf@gk2.net>
|
|
* License: Proprietary / ANSSI CSPN candidate
|
|
*
|
|
* ═══════════════════════════════════════════════════════════════════════════════
|
|
*/
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
const VERSION = '1.3.0';
|
|
const VISITOR_ORIGIN_API = window.SECUBOX_VISITOR_ORIGIN_API
|
|
|| '/api/v1/metrics/visitor-origin';
|
|
const LIVE_HOSTS_API = window.SECUBOX_LIVE_HOSTS_API
|
|
|| '/api/v1/metrics/live-hosts';
|
|
const CERT_STATUS_API = window.SECUBOX_CERT_STATUS_API
|
|
|| '/api/v1/metrics/cert-status';
|
|
const LIVE_REFRESH_INTERVAL = 30000; // 30 s
|
|
// Use global config if injected by CDN/WAF, otherwise use relative path
|
|
const HEALTH_API = window.SECUBOX_HEALTH_API || '/api/v1/metrics/health/summary';
|
|
const REFRESH_INTERVAL = 30000; // 30s
|
|
const CACHE_KEY = 'sbx_health_cache';
|
|
const IS_CDN_INJECTED = !!window.SECUBOX_HEALTH_API;
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// DOUBLE-BUFFER CACHE with Lock Protection
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
const HealthCache = {
|
|
active: null,
|
|
shadow: null,
|
|
locked: false,
|
|
lastSwap: 0,
|
|
|
|
read() {
|
|
return this.active;
|
|
},
|
|
|
|
write(data) {
|
|
if (this.locked) {
|
|
console.warn('[HealthBanner] Cache locked, queuing write');
|
|
setTimeout(() => this.write(data), 100);
|
|
return;
|
|
}
|
|
this.shadow = data;
|
|
},
|
|
|
|
swap() {
|
|
if (this.locked) return false;
|
|
this.locked = true;
|
|
const temp = this.active;
|
|
this.active = this.shadow;
|
|
this.shadow = temp;
|
|
this.lastSwap = Date.now();
|
|
this.locked = false;
|
|
this.persist();
|
|
return true;
|
|
},
|
|
|
|
persist() {
|
|
try {
|
|
localStorage.setItem(CACHE_KEY, JSON.stringify({
|
|
ts: Date.now(),
|
|
data: this.active
|
|
}));
|
|
} catch (e) {}
|
|
},
|
|
|
|
restore() {
|
|
try {
|
|
const cached = localStorage.getItem(CACHE_KEY);
|
|
if (cached) {
|
|
const parsed = JSON.parse(cached);
|
|
if (parsed.ts && (Date.now() - parsed.ts) < 300000) { // 5min TTL
|
|
this.active = parsed.data;
|
|
return true;
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// SMART DOCTOR ADVISOR
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
// Module emoji map for spunky display
|
|
const MODULE_EMOJIS = {
|
|
waf: ['🛡️', '⚔️', '🔰'],
|
|
crowdsec: ['👮', '🚔', '🚨'],
|
|
haproxy: ['🌐', '🔀', '🔄'],
|
|
nginx: ['🌍', '📡', '🚀'],
|
|
system: ['💻', '🖥️', '⚙️']
|
|
};
|
|
|
|
const STATUS_EMOJIS = {
|
|
ok: ['✅', '🟢', '💚', '🌟'],
|
|
warn: ['⚠️', '🟡', '🔶', '⏳'],
|
|
error: ['❌', '🔴', '💔', '🆘'],
|
|
off: ['⬜', '💤', '🔌']
|
|
};
|
|
|
|
const DoctorRules = [
|
|
{
|
|
id: 'all-good',
|
|
check: h => h.score >= 95,
|
|
severity: 'celebration',
|
|
icon: '🎉',
|
|
message: h => `System looking sexy! ${h.score}% health`,
|
|
action: null
|
|
},
|
|
{
|
|
id: 'waf-high-block',
|
|
check: h => h.waf?.blocked_pct > 25,
|
|
severity: 'warning',
|
|
icon: '⚔️',
|
|
message: h => `WAF slaying ${h.waf.blocked_pct}% baddies`,
|
|
action: '/waf/'
|
|
},
|
|
{
|
|
id: 'crowdsec-many-bans',
|
|
check: h => h.crowdsec?.active_decisions > 50,
|
|
severity: 'info',
|
|
icon: '🚨',
|
|
message: h => `${h.crowdsec.active_decisions} threats neutralized`,
|
|
action: '/crowdsec/'
|
|
},
|
|
{
|
|
id: 'crowdsec-patrol',
|
|
check: h => h.crowdsec?.active_decisions > 0 && h.crowdsec?.active_decisions <= 50,
|
|
severity: 'patrol',
|
|
icon: '👮',
|
|
message: h => `${h.crowdsec.active_decisions} bans active`,
|
|
action: '/crowdsec/'
|
|
},
|
|
{
|
|
id: 'cpu-critical',
|
|
check: h => h.system?.cpu > 85,
|
|
severity: 'critical',
|
|
icon: '🔥',
|
|
message: h => `CPU blazing ${h.system.cpu}%`,
|
|
action: '/system/'
|
|
},
|
|
{
|
|
id: 'cpu-warm',
|
|
check: h => h.system?.cpu > 60 && h.system?.cpu <= 85,
|
|
severity: 'info',
|
|
icon: '🌡️',
|
|
message: h => `CPU working ${h.system.cpu}%`,
|
|
action: '/system/'
|
|
},
|
|
{
|
|
id: 'memory-high',
|
|
check: h => h.system?.memory > 90,
|
|
severity: 'critical',
|
|
icon: '🧠',
|
|
message: h => `Memory stuffed ${h.system.memory}%`,
|
|
action: '/system/'
|
|
},
|
|
{
|
|
id: 'disk-full',
|
|
check: h => h.system?.disk > 85,
|
|
severity: 'warning',
|
|
icon: '💾',
|
|
message: h => `Disk chunky ${h.system.disk}%`,
|
|
action: '/system/'
|
|
},
|
|
{
|
|
id: 'services-down',
|
|
check: h => h.services?.down > 0,
|
|
severity: 'critical',
|
|
icon: '💀',
|
|
message: h => `${h.services.down} services need CPR!`,
|
|
action: '/hub/'
|
|
},
|
|
{
|
|
id: 'lxc-running',
|
|
check: h => h.services?.lxc_running > 0,
|
|
severity: 'info',
|
|
icon: '📦',
|
|
message: h => `${h.services.lxc_running} containers vibing`,
|
|
action: '/system/'
|
|
}
|
|
];
|
|
|
|
function diagnose(health) {
|
|
const alerts = [];
|
|
for (const rule of DoctorRules) {
|
|
try {
|
|
if (rule.check(health)) {
|
|
alerts.push({
|
|
id: rule.id,
|
|
severity: rule.severity,
|
|
icon: rule.icon,
|
|
message: rule.message(health),
|
|
action: rule.action
|
|
});
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
return alerts.sort((a, b) => {
|
|
const order = { critical: 0, warning: 1, info: 2 };
|
|
return order[a.severity] - order[b.severity];
|
|
});
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// BANNER UI
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function getScoreEmoji(score) {
|
|
if (score >= 95) return '💖';
|
|
if (score >= 85) return '💚';
|
|
if (score >= 70) return '💛';
|
|
if (score >= 50) return '🧡';
|
|
return '💔';
|
|
}
|
|
|
|
function getScoreVibe(score) {
|
|
if (score >= 95) return 'VIBING';
|
|
if (score >= 85) return 'SOLID';
|
|
if (score >= 70) return 'OKAY';
|
|
if (score >= 50) return 'MEH';
|
|
return 'YIKES';
|
|
}
|
|
|
|
function renderSslStatus(ssl) {
|
|
if (!ssl) return '';
|
|
|
|
const statusConfig = {
|
|
ok: { emoji: '🔒', label: 'Certificate OK' },
|
|
warn: { emoji: '🔐', label: 'Certificate expiring soon' },
|
|
error: { emoji: '🔓', label: 'Certificate critical' },
|
|
expired: { emoji: '🔓', label: 'Certificate EXPIRED' },
|
|
unknown: { emoji: '❓', label: 'Certificate unknown' }
|
|
};
|
|
|
|
const config = statusConfig[ssl.status] || statusConfig.unknown;
|
|
const days = ssl.days_remaining;
|
|
const daysText = ssl.status === 'expired' ? 'EXPIRÉ' :
|
|
days !== null ? `${days}j` : '--';
|
|
|
|
return `
|
|
<div class="hb-ssl ssl-${ssl.status}" title="${config.label} — ${ssl.domain || 'unknown'}">
|
|
<span class="hb-ssl-icon">${config.emoji}</span>
|
|
<span class="hb-ssl-days">${daysText}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function createBannerElement() {
|
|
// Create trigger button (always visible)
|
|
const trigger = document.createElement('div');
|
|
trigger.id = 'health-banner-trigger';
|
|
trigger.className = 'hb-trigger';
|
|
trigger.innerHTML = `
|
|
<span class="hb-trigger-icon">◀</span>
|
|
<span class="hb-trigger-score">--</span>
|
|
`;
|
|
|
|
// Create banner panel (hidden by default)
|
|
const banner = document.createElement('div');
|
|
banner.id = 'health-banner';
|
|
banner.className = 'health-banner';
|
|
banner.innerHTML = `
|
|
<div class="hb-content">
|
|
<div class="hb-score">
|
|
<span class="hb-icon">💖</span>
|
|
<div class="hb-score-info">
|
|
<span class="hb-label">VIBING</span>
|
|
<div class="hb-bar"><div class="hb-fill"></div></div>
|
|
</div>
|
|
<span class="hb-pct">--</span>
|
|
</div>
|
|
<div class="hb-ssl-container"></div>
|
|
<div class="hb-modules"></div>
|
|
<div class="hb-alerts"></div>
|
|
<div class="hb-sparkle">✨</div>
|
|
<div class="hb-details">
|
|
<div class="hb-stats-grid"></div>
|
|
</div>
|
|
<div class="hb-footer">v${VERSION}</div>
|
|
</div>
|
|
`;
|
|
|
|
return { banner, trigger };
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// LIVE PANEL — VisitorOrigin / LiveHosts / CertStatus (issue #92)
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function sectionContainer(id) {
|
|
let el = document.getElementById(id);
|
|
if (!el) {
|
|
el = document.createElement('div');
|
|
el.id = id;
|
|
el.className = 'sbx-live-section';
|
|
const banner = document.getElementById('health-banner');
|
|
if (banner) banner.appendChild(el);
|
|
}
|
|
return el;
|
|
}
|
|
|
|
function hideSection(id) {
|
|
const el = document.getElementById(id);
|
|
if (el) el.style.display = 'none';
|
|
}
|
|
|
|
function showSection(id) {
|
|
const el = document.getElementById(id);
|
|
if (el) el.style.display = '';
|
|
}
|
|
|
|
function renderVisitorOrigin(data) {
|
|
if (!data || !data.enabled || !data.entries || !data.entries.length) {
|
|
hideSection('sbx-visitor-origin');
|
|
return;
|
|
}
|
|
const el = sectionContainer('sbx-visitor-origin');
|
|
const rows = data.entries.map(e =>
|
|
`<div class="sbx-row"><span class="sbx-asn">AS${e.asn}</span> <span class="sbx-org">${e.org}</span><span class="sbx-count">${e.count}</span></div>`
|
|
).join('');
|
|
el.innerHTML = `<div class="sbx-section-title">VisitorOrigin · ${data.window_minutes}min · top ${data.entries.length}</div>${rows}`;
|
|
showSection('sbx-visitor-origin');
|
|
}
|
|
|
|
function renderLiveHosts(data) {
|
|
if (!data || !data.enabled || !data.entries || !data.entries.length) {
|
|
hideSection('sbx-live-hosts');
|
|
return;
|
|
}
|
|
const el = sectionContainer('sbx-live-hosts');
|
|
const rows = data.entries.map(e =>
|
|
`<div class="sbx-row"><span class="sbx-host">${e.host}</span><span class="sbx-count">${e.count}</span></div>`
|
|
).join('');
|
|
el.innerHTML = `<div class="sbx-section-title">LiveHosts · ${data.window_minutes}min · top ${data.entries.length}</div>${rows}`;
|
|
showSection('sbx-live-hosts');
|
|
}
|
|
|
|
function renderCertStatus(data) {
|
|
if (!data || !data.enabled || !data.summary || !data.summary.total) {
|
|
hideSection('sbx-cert-status');
|
|
return;
|
|
}
|
|
const el = sectionContainer('sbx-cert-status');
|
|
const s = data.summary;
|
|
const next = data.next_renewal
|
|
? `<div class="sbx-row">next renewal: ${data.next_renewal.host} · ${data.next_renewal.days}d</div>`
|
|
: '';
|
|
el.innerHTML =
|
|
`<div class="sbx-section-title">CertStatus · ${s.total} total</div>` +
|
|
`<div class="sbx-row">✓ ${s.valid} valid · ⚠ ${s.expiring_soon} soon · ✗ ${s.expiring_critical + s.expired} critical</div>` +
|
|
next;
|
|
showSection('sbx-cert-status');
|
|
}
|
|
|
|
async function pollLivePanel() {
|
|
const fetchSafe = async (url) => {
|
|
try {
|
|
const r = await fetch(url, { credentials: 'omit' });
|
|
if (!r.ok) return null;
|
|
return await r.json();
|
|
} catch (e) { return null; }
|
|
};
|
|
const [vo, lh, cs] = await Promise.all([
|
|
fetchSafe(VISITOR_ORIGIN_API),
|
|
fetchSafe(LIVE_HOSTS_API),
|
|
fetchSafe(CERT_STATUS_API),
|
|
]);
|
|
if (vo) renderVisitorOrigin(vo); else hideSection('sbx-visitor-origin');
|
|
if (lh) renderLiveHosts(lh); else hideSection('sbx-live-hosts');
|
|
if (cs) renderCertStatus(cs); else hideSection('sbx-cert-status');
|
|
}
|
|
|
|
function injectBannerStyles() {
|
|
if (document.getElementById('health-banner-styles')) return;
|
|
|
|
const style = document.createElement('style');
|
|
style.id = 'health-banner-styles';
|
|
style.textContent = `
|
|
/* SIDE-SLIDING HEALTH BANNER */
|
|
/* Hidden by default, slides from right, pushes content */
|
|
|
|
.health-banner {
|
|
position: fixed;
|
|
top: 0;
|
|
right: 0;
|
|
width: 0;
|
|
height: 100vh;
|
|
background: linear-gradient(180deg, #0a0a0f 0%, #0f0f14 50%, #0a0a0f 100%);
|
|
border-left: 1px solid rgba(201,168,76,0.3);
|
|
z-index: 9998;
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 10px;
|
|
color: var(--text-primary, #e8e6d9);
|
|
transition: width 0.3s ease, box-shadow 0.3s ease;
|
|
overflow: hidden;
|
|
box-shadow: -4px 0 20px rgba(0,0,0,0.5);
|
|
}
|
|
|
|
/* Hidden trigger button - always visible */
|
|
.hb-trigger {
|
|
position: fixed;
|
|
top: 50%;
|
|
right: 0;
|
|
transform: translateY(-50%);
|
|
width: 24px;
|
|
height: 60px;
|
|
background: linear-gradient(180deg, #0a0a0f 0%, #1a1a24 100%);
|
|
border: 1px solid rgba(201,168,76,0.4);
|
|
border-right: none;
|
|
border-radius: 8px 0 0 8px;
|
|
z-index: 9999;
|
|
cursor: pointer;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 4px;
|
|
transition: all 0.3s ease;
|
|
}
|
|
.hb-trigger:hover {
|
|
width: 32px;
|
|
background: linear-gradient(180deg, #1a1a24 0%, #2a2a34 100%);
|
|
border-color: rgba(201,168,76,0.8);
|
|
box-shadow: -4px 0 15px rgba(201,168,76,0.2);
|
|
}
|
|
.hb-trigger-icon {
|
|
font-size: 14px;
|
|
transition: transform 0.3s ease;
|
|
}
|
|
.hb-trigger-score {
|
|
font-size: 8px;
|
|
color: var(--gold-hermetic, #c9a84c);
|
|
font-weight: bold;
|
|
writing-mode: vertical-rl;
|
|
text-orientation: mixed;
|
|
}
|
|
body.health-banner-open .hb-trigger {
|
|
right: 280px;
|
|
}
|
|
body.health-banner-open .hb-trigger-icon {
|
|
transform: rotate(180deg);
|
|
}
|
|
|
|
/* Expanded state */
|
|
.health-banner.expanded {
|
|
width: 280px;
|
|
}
|
|
|
|
.hb-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 16px;
|
|
gap: 16px;
|
|
width: 280px;
|
|
height: 100%;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
/* Score section */
|
|
.hb-score {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 12px;
|
|
background: rgba(255,255,255,0.05);
|
|
border-radius: 8px;
|
|
border: 1px solid rgba(201,168,76,0.2);
|
|
}
|
|
.hb-icon { font-size: 24px; }
|
|
.hb-score-info {
|
|
flex: 1;
|
|
}
|
|
.hb-label {
|
|
font-size: 10px;
|
|
letter-spacing: 1px;
|
|
color: var(--gold-hermetic, #c9a84c);
|
|
display: block;
|
|
margin-bottom: 4px;
|
|
}
|
|
.hb-bar {
|
|
width: 100%;
|
|
height: 8px;
|
|
background: rgba(255,255,255,0.1);
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
}
|
|
.hb-fill {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, #ef4444, #eab308, #22c55e);
|
|
border-radius: 4px;
|
|
transition: width 0.5s ease;
|
|
width: 0%;
|
|
}
|
|
.hb-pct {
|
|
font-size: 18px;
|
|
font-weight: bold;
|
|
color: var(--text-primary, #e8e6d9);
|
|
}
|
|
|
|
/* SSL Certificate Status */
|
|
.hb-ssl {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 8px 12px;
|
|
background: rgba(255,255,255,0.03);
|
|
border-radius: 6px;
|
|
border: 1px solid rgba(255,255,255,0.08);
|
|
font-size: 11px;
|
|
}
|
|
.hb-ssl-icon { font-size: 14px; }
|
|
.hb-ssl-days { font-weight: 600; }
|
|
.hb-ssl.ssl-ok { border-color: rgba(0, 255, 65, 0.3); }
|
|
.hb-ssl.ssl-ok .hb-ssl-days { color: #00ff41; }
|
|
.hb-ssl.ssl-warn { border-color: rgba(255, 193, 7, 0.3); }
|
|
.hb-ssl.ssl-warn .hb-ssl-days { color: #ffc107; }
|
|
.hb-ssl.ssl-error { border-color: rgba(230, 57, 70, 0.3); }
|
|
.hb-ssl.ssl-error .hb-ssl-days { color: #e63946; }
|
|
.hb-ssl.ssl-expired {
|
|
border-color: rgba(230, 57, 70, 0.5);
|
|
background: rgba(230, 57, 70, 0.1);
|
|
animation: ssl-blink 1s infinite;
|
|
}
|
|
.hb-ssl.ssl-expired .hb-ssl-days { color: #e63946; }
|
|
@keyframes ssl-blink { 50% { opacity: 0.6; } }
|
|
.hb-ssl.ssl-unknown { border-color: rgba(107, 107, 122, 0.3); }
|
|
.hb-ssl.ssl-unknown .hb-ssl-days { color: #6b6b7a; }
|
|
|
|
/* Modules grid */
|
|
.hb-modules {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 8px;
|
|
}
|
|
.hb-mod {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 8px;
|
|
border-radius: 6px;
|
|
background: rgba(255,255,255,0.05);
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
text-decoration: none;
|
|
position: relative;
|
|
}
|
|
.hb-mod:hover {
|
|
background: rgba(255,255,255,0.12);
|
|
transform: translateY(-2px);
|
|
}
|
|
.hb-mod.ok { border: 1px solid rgba(34,197,94,0.5); }
|
|
.hb-mod.warn { border: 1px solid rgba(234,179,8,0.5); }
|
|
.hb-mod.err { border: 1px solid rgba(239,68,68,0.5); }
|
|
.hb-mod.off { border: 1px solid rgba(102,102,102,0.5); opacity: 0.6; }
|
|
.hb-mod-emoji { font-size: 20px; }
|
|
.hb-mod-name {
|
|
font-size: 8px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
color: var(--text-muted, #6b6b7a);
|
|
}
|
|
.hb-mod-status {
|
|
font-size: 10px;
|
|
position: absolute;
|
|
top: 2px;
|
|
right: 4px;
|
|
}
|
|
|
|
/* Alerts */
|
|
.hb-alerts {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
.hb-alert {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 8px 10px;
|
|
border-radius: 6px;
|
|
font-size: 10px;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
}
|
|
.hb-alert.critical {
|
|
background: rgba(239,68,68,0.15);
|
|
border: 1px solid rgba(239,68,68,0.4);
|
|
color: #ef4444;
|
|
}
|
|
.hb-alert.warning {
|
|
background: rgba(234,179,8,0.15);
|
|
border: 1px solid rgba(234,179,8,0.4);
|
|
color: #eab308;
|
|
}
|
|
.hb-alert.info {
|
|
background: rgba(0,212,255,0.15);
|
|
border: 1px solid rgba(0,212,255,0.4);
|
|
color: #00d4ff;
|
|
}
|
|
.hb-alert.celebration {
|
|
background: linear-gradient(135deg, rgba(201,168,76,0.2), rgba(255,215,0,0.15));
|
|
border: 1px solid rgba(255,215,0,0.5);
|
|
color: #ffd700;
|
|
}
|
|
.hb-alert.patrol {
|
|
background: rgba(100,149,237,0.15);
|
|
border: 1px solid rgba(100,149,237,0.4);
|
|
color: #6495ed;
|
|
}
|
|
.hb-alert-icon { font-size: 14px; }
|
|
.hb-alert-text {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* Stats grid */
|
|
.hb-details {
|
|
margin-top: auto;
|
|
padding-top: 12px;
|
|
border-top: 1px solid rgba(201,168,76,0.2);
|
|
}
|
|
.hb-stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: 8px;
|
|
}
|
|
.hb-stat {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 2px;
|
|
padding: 8px;
|
|
background: rgba(255,255,255,0.03);
|
|
border-radius: 6px;
|
|
}
|
|
.hb-stat-icon { font-size: 16px; }
|
|
.hb-stat-label {
|
|
font-size: 8px;
|
|
color: var(--gold-hermetic, #c9a84c);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
.hb-stat-value {
|
|
font-size: 14px;
|
|
font-weight: bold;
|
|
color: var(--text-primary, #e8e6d9);
|
|
}
|
|
|
|
.hb-sparkle {
|
|
position: absolute;
|
|
top: 10px;
|
|
right: 10px;
|
|
font-size: 16px;
|
|
animation: hb-sparkle 3s infinite ease-in-out;
|
|
display: none;
|
|
}
|
|
.hb-footer {
|
|
position: absolute;
|
|
bottom: 5px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
font-size: 9px;
|
|
color: #444;
|
|
font-family: monospace;
|
|
}
|
|
.hb-toggle { display: none; } /* Hidden in sidebar mode */
|
|
|
|
@keyframes hb-pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.7; }
|
|
}
|
|
@keyframes hb-glow {
|
|
0%, 100% { box-shadow: 0 0 5px rgba(255,215,0,0.3); }
|
|
50% { box-shadow: 0 0 15px rgba(255,215,0,0.6); }
|
|
}
|
|
@keyframes hb-sparkle {
|
|
0%, 100% { transform: scale(1) rotate(0deg); opacity: 1; }
|
|
50% { transform: scale(1.2) rotate(180deg); opacity: 0.8; }
|
|
}
|
|
|
|
/* Push main content when banner is open */
|
|
body.health-banner-open .main,
|
|
body.health-banner-open .sidebar + .main {
|
|
margin-right: 280px;
|
|
transition: margin-right 0.3s ease;
|
|
}
|
|
|
|
/* Mobile: slide from bottom */
|
|
@media (max-width: 768px) {
|
|
.health-banner {
|
|
top: auto;
|
|
bottom: 0;
|
|
right: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 0;
|
|
border-left: none;
|
|
border-top: 1px solid rgba(201,168,76,0.3);
|
|
}
|
|
.health-banner.expanded {
|
|
width: 100%;
|
|
height: 60vh;
|
|
}
|
|
.hb-trigger {
|
|
top: auto;
|
|
bottom: 0;
|
|
right: 50%;
|
|
transform: translateX(50%);
|
|
width: 60px;
|
|
height: 24px;
|
|
border-radius: 8px 8px 0 0;
|
|
border-bottom: none;
|
|
flex-direction: row;
|
|
}
|
|
body.health-banner-open .hb-trigger {
|
|
right: 50%;
|
|
bottom: 60vh;
|
|
}
|
|
.hb-trigger-score {
|
|
writing-mode: horizontal-tb;
|
|
}
|
|
.hb-content {
|
|
width: 100%;
|
|
}
|
|
.hb-modules {
|
|
grid-template-columns: repeat(5, 1fr);
|
|
}
|
|
.hb-stats-grid {
|
|
grid-template-columns: repeat(3, 1fr);
|
|
}
|
|
body.health-banner-open .main {
|
|
margin-right: 0;
|
|
margin-bottom: 60vh;
|
|
}
|
|
}
|
|
.sbx-live-section { padding: 6px 10px; border-top: 1px solid var(--text-muted, #6b6b7a); font-size: 11px; line-height: 1.4; }
|
|
.sbx-section-title { font-weight: 600; color: var(--gold-hermetic, #c9a84c); margin-bottom: 2px; }
|
|
.sbx-row { display: flex; justify-content: space-between; gap: 8px; }
|
|
.sbx-row .sbx-count { color: var(--cyber-cyan, #00d4ff); font-variant-numeric: tabular-nums; }
|
|
.sbx-row .sbx-asn { color: var(--matrix-green, #00ff41); }
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
function renderBanner(health) {
|
|
const banner = document.getElementById('health-banner');
|
|
const trigger = document.getElementById('health-banner-trigger');
|
|
if (!banner) return;
|
|
|
|
// Calculate overall score
|
|
const score = health?.score || 0;
|
|
const pctEl = banner.querySelector('.hb-pct');
|
|
const fillEl = banner.querySelector('.hb-fill');
|
|
const iconEl = banner.querySelector('.hb-icon');
|
|
const labelEl = banner.querySelector('.hb-label');
|
|
const sparkleEl = banner.querySelector('.hb-sparkle');
|
|
|
|
// Update trigger score
|
|
if (trigger) {
|
|
const triggerScore = trigger.querySelector('.hb-trigger-score');
|
|
const triggerIcon = trigger.querySelector('.hb-trigger-icon');
|
|
if (triggerScore) triggerScore.textContent = score + '%';
|
|
if (triggerIcon) triggerIcon.textContent = getScoreEmoji(score);
|
|
}
|
|
|
|
if (pctEl) pctEl.textContent = score + '%';
|
|
if (fillEl) {
|
|
fillEl.style.width = score + '%';
|
|
// Color based on score
|
|
if (score >= 85) fillEl.style.background = 'linear-gradient(90deg, #22c55e, #10b981, #34d399)';
|
|
else if (score >= 70) fillEl.style.background = 'linear-gradient(90deg, #eab308, #fbbf24, #f59e0b)';
|
|
else fillEl.style.background = 'linear-gradient(90deg, #ef4444, #dc2626, #f87171)';
|
|
}
|
|
if (iconEl) iconEl.textContent = getScoreEmoji(score);
|
|
if (labelEl) labelEl.textContent = getScoreVibe(score);
|
|
if (sparkleEl) {
|
|
sparkleEl.style.display = score >= 90 ? 'block' : 'none';
|
|
}
|
|
|
|
// Update SSL status
|
|
const sslContainer = banner.querySelector('.hb-ssl-container');
|
|
if (sslContainer && health.ssl) {
|
|
sslContainer.innerHTML = renderSslStatus(health.ssl);
|
|
}
|
|
|
|
// Render module cards
|
|
const modsEl = banner.querySelector('.hb-modules');
|
|
if (modsEl && health?.modules) {
|
|
const modules = ['waf', 'crowdsec', 'haproxy', 'nginx', 'system'];
|
|
modsEl.innerHTML = modules.map(m => {
|
|
const mod = health.modules[m] || {};
|
|
const status = mod.status || 'off';
|
|
const ledClass = status === 'ok' ? 'ok' : status === 'warn' ? 'warn' : status === 'error' ? 'err' : 'off';
|
|
const emojis = MODULE_EMOJIS[m] || ['📦'];
|
|
const statusEmojis = STATUS_EMOJIS[status] || STATUS_EMOJIS.off;
|
|
const emoji = emojis[0];
|
|
const statusDot = statusEmojis[1] || statusEmojis[0];
|
|
return `<a href="/${m}/" class="hb-mod ${ledClass}" title="${m}: ${status}">
|
|
<span class="hb-mod-emoji">${emoji}</span>
|
|
<span class="hb-mod-name">${m}</span>
|
|
<span class="hb-mod-status">${statusDot}</span>
|
|
</a>`;
|
|
}).join('');
|
|
}
|
|
|
|
// Render doctor alerts
|
|
const alertsEl = banner.querySelector('.hb-alerts');
|
|
if (alertsEl) {
|
|
const alerts = diagnose(health);
|
|
const visibleAlerts = alerts.filter(a => a.severity !== 'celebration' || score >= 95).slice(0, 5);
|
|
alertsEl.innerHTML = visibleAlerts.map(a =>
|
|
a.action
|
|
? `<a href="${a.action}" class="hb-alert ${a.severity}" title="${a.message}">
|
|
<span class="hb-alert-icon">${a.icon}</span>
|
|
<span class="hb-alert-text">${a.message}</span>
|
|
</a>`
|
|
: `<div class="hb-alert ${a.severity}" title="${a.message}">
|
|
<span class="hb-alert-icon">${a.icon}</span>
|
|
<span class="hb-alert-text">${a.message}</span>
|
|
</div>`
|
|
).join('');
|
|
}
|
|
|
|
// Render expanded details
|
|
const detailsGrid = banner.querySelector('.hb-stats-grid');
|
|
if (detailsGrid && health) {
|
|
detailsGrid.innerHTML = `
|
|
<div class="hb-stat">
|
|
<span class="hb-stat-icon">🖥️</span>
|
|
<span class="hb-stat-label">CPU</span>
|
|
<span class="hb-stat-value">${health.system?.cpu || 0}%</span>
|
|
</div>
|
|
<div class="hb-stat">
|
|
<span class="hb-stat-icon">🧠</span>
|
|
<span class="hb-stat-label">RAM</span>
|
|
<span class="hb-stat-value">${health.system?.memory || 0}%</span>
|
|
</div>
|
|
<div class="hb-stat">
|
|
<span class="hb-stat-icon">💾</span>
|
|
<span class="hb-stat-label">Disk</span>
|
|
<span class="hb-stat-value">${health.system?.disk || 0}%</span>
|
|
</div>
|
|
<div class="hb-stat">
|
|
<span class="hb-stat-icon">📦</span>
|
|
<span class="hb-stat-label">LXC</span>
|
|
<span class="hb-stat-value">${health.services?.lxc_running || 0}</span>
|
|
</div>
|
|
<div class="hb-stat">
|
|
<span class="hb-stat-icon">🚨</span>
|
|
<span class="hb-stat-label">Bans</span>
|
|
<span class="hb-stat-value">${health.crowdsec?.active_decisions || 0}</span>
|
|
</div>
|
|
<div class="hb-stat">
|
|
<span class="hb-stat-icon">🌐</span>
|
|
<span class="hb-stat-label">VHosts</span>
|
|
<span class="hb-stat-value">${health.counts?.vhosts || 0}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// DATA FETCHING
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
async function fetchHealth() {
|
|
try {
|
|
// Pass current domain for SSL certificate check
|
|
const currentDomain = window.location.hostname;
|
|
const apiUrl = HEALTH_API + (HEALTH_API.includes('?') ? '&' : '?') + 'domain=' + encodeURIComponent(currentDomain);
|
|
|
|
const resp = await fetch(apiUrl, {
|
|
headers: { 'Accept': 'application/json' },
|
|
credentials: 'omit' // Cross-origin request
|
|
});
|
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
return await resp.json();
|
|
} catch (e) {
|
|
console.warn('[HealthBanner] Fetch failed:', e.message);
|
|
// Return mock data for graceful degradation
|
|
return {
|
|
score: 85,
|
|
modules: {
|
|
waf: { status: 'ok' },
|
|
crowdsec: { status: 'ok' },
|
|
haproxy: { status: 'ok' },
|
|
nginx: { status: 'ok' },
|
|
system: { status: 'ok' }
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
async function refreshHealth() {
|
|
const data = await fetchHealth();
|
|
HealthCache.write(data);
|
|
HealthCache.swap();
|
|
renderBanner(HealthCache.read());
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// INITIALIZATION
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function init() {
|
|
// Inject styles
|
|
injectBannerStyles();
|
|
pollLivePanel();
|
|
setInterval(pollLivePanel, LIVE_REFRESH_INTERVAL);
|
|
|
|
// Create banner and trigger
|
|
const { banner, trigger } = createBannerElement();
|
|
document.body.appendChild(trigger);
|
|
document.body.appendChild(banner);
|
|
|
|
// Toggle banner on trigger click
|
|
trigger.addEventListener('click', () => {
|
|
const isOpen = banner.classList.toggle('expanded');
|
|
document.body.classList.toggle('health-banner-open', isOpen);
|
|
});
|
|
|
|
// Close on escape key
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && banner.classList.contains('expanded')) {
|
|
banner.classList.remove('expanded');
|
|
document.body.classList.remove('health-banner-open');
|
|
}
|
|
});
|
|
|
|
// Restore from cache first (instant display)
|
|
if (HealthCache.restore()) {
|
|
renderBanner(HealthCache.read());
|
|
}
|
|
|
|
// Fetch fresh data
|
|
refreshHealth();
|
|
|
|
// Periodic refresh
|
|
setInterval(refreshHealth, REFRESH_INTERVAL);
|
|
|
|
// Listen for sidebar collapse
|
|
document.addEventListener('sidebar-toggle', (e) => {
|
|
if (e.detail?.collapsed) {
|
|
banner.classList.add('sidebar-collapsed');
|
|
} else {
|
|
banner.classList.remove('sidebar-collapsed');
|
|
}
|
|
});
|
|
|
|
console.log('[HealthBanner] v' + VERSION + ' initialized (sidebar mode)');
|
|
}
|
|
|
|
// Start
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
|
|
// Export for external access
|
|
window.SecuBoxHealthBanner = {
|
|
refresh: refreshHealth,
|
|
diagnose: diagnose,
|
|
cache: HealthCache
|
|
};
|
|
|
|
})();
|