Compare commits

...

16 Commits

Author SHA1 Message Date
CyberMind
c9397e3008
Merge pull request #631 from CyberMind-FR/feature/630-make-live-ops-fixes-permanent-package-di
Some checks are pending
License Headers / check (push) Waiting to run
Make live ops fixes permanent: core traversal fix + dirs-guard timer + toolbox stream_inject default (closes #630)
2026-06-17 10:00:37 +02:00
CyberMind
206157047e
Merge pull request #629 from CyberMind-FR/feature/628-hub-health-monitor-page-vital-common-ser
hub: Health Monitor page (vital + common services, live) (closes #628)
2026-06-17 10:00:26 +02:00
CyberMind
bed4c1c6d3
Merge pull request #627 from CyberMind-FR/feature/626-haproxy-smart-self-healing-error-pages-5
haproxy: smart self-healing error pages + wire errorfile in generator (closes #626)
2026-06-17 10:00:16 +02:00
CyberMind
9ba49e3bf7
Merge pull request #625 from CyberMind-FR/feature/624-waf-robustness-package-self-healing-insp
WAF robustness: package self-healing inspector watchdog + HAProxy redispatch (durable)
2026-06-17 10:00:04 +02:00
ebf714f123 fix(core): stop clobbering /var/lib+/usr/share/secubox to 0750 + ship secubox-dirs-guard timer; toolbox: stream_inject default on (closes #630) 2026-06-17 09:33:26 +02:00
5763aa3a73 fix(hub): health monitor reads nested health-batch .modules (ref #628) 2026-06-17 08:59:40 +02:00
2a8c1b33de feat(hub): Health Monitor page — vital + common service status, live (closes #628) 2026-06-17 08:55:44 +02:00
9d1b0abade docs: spec for HAProxy complete dynamic vhost auto-discovery (landed for later) 2026-06-17 08:49:29 +02:00
41d78ef455 docs(haproxy): tidy 1.3.1 changelog (secubox-errors path, drift guard, traversal fix) 2026-06-17 08:47:11 +02:00
fbd474b2c3 fix(haproxy): postinst set shared /run|/var/lib/secubox to 0750, breaking traversal (kbin/toolbox 500) -> 0755 parents, 0750 leaves (ref #626) 2026-06-17 08:46:26 +02:00
c47e454532 fix(haproxy): ship error pages to /etc/haproxy/secubox-errors (avoid file conflict with haproxy pkg) (ref #626) 2026-06-17 08:44:06 +02:00
e12790efbd fix(haproxy): repair broken generate (set -e abort + dup backend) + drift guard (ref #626)
haproxyctl generate exited 1 producing no backends: set -e + && {} vhost-loop
chains aborted on the first non-SSL vhost, and a duplicate mitmproxy_inspector
(auto + user TOML) was fatal. Converted chains to if/then/fi, dedup user
backends. Added a drift guard: refuse to install a cfg with fewer vhosts/
backends than live, so a successful regen can't silently drop hand-maintained
vhosts (kbin/gitea/matrix/...) absent from haproxy.toml.
2026-06-17 08:36:27 +02:00
ce636273a6 feat(haproxy): smart self-healing error pages + wire errorfile in generator (closes #626)
502/503/504 poll the URL and auto-reload on backend recovery (live status +
manual retry); 400/403/408/500 branded static. haproxyctl now emits errorfile
directives (durable across regen) + retries/redispatch in defaults. Pages shipped
to /etc/haproxy/errors/.
2026-06-17 07:46:43 +02:00
4dd87eae2f fix(mitmproxy): ExecStartPost chmod raced socket creation -> wait+non-fatal (ref #624) 2026-06-17 07:36:33 +02:00
af02a9731c fix(mitmproxy): service used absent /usr/bin/uvicorn (203/EXEC crash-loop) -> python3 -m uvicorn + stale-socket unlink (ref #624) 2026-06-17 07:34:01 +02:00
663715af0f feat(mitmproxy): package self-healing WAF inspector watchdog (closes #624)
secubox-waf-watchdog timer checks inspector :8080 every 60s and auto-recovers
the mitmproxy LXC after 3 consecutive failures (rate-limited once/10min) — an
inspector crash becomes a ~3min auto-recovery instead of a multi-hour 503.
Shipped in secubox-mitmproxy; enabled in postinst, disabled in prerm. Makes the
live hotfix from the #624 incident durable across reflash.
2026-06-17 07:31:29 +02:00
34 changed files with 867 additions and 15 deletions

View File

@ -0,0 +1,56 @@
<!-- SPDX-License-Identifier: LicenseRef-CMSD-1.0 -->
# Spec — HAProxy complete dynamic vhost auto-discovery
*2026-06-17 · landed for later (per user) · found while fixing #626/#627*
## Problem
`haproxyctl generate` must produce a COMPLETE config so regen never drops
hand-maintained vhosts. Today ~10 vhosts live only in the hand-edited
`/etc/haproxy/haproxy.cfg` — not in `haproxy.toml` or `cfg.d/`:
- `kbin.gk2.secubox.in``toolbox_landing` (backend also live-only)
- `matrix`, `gitea`, `peertube`, `photoprism` (.gk2.secubox.in) → `nginx_vhosts`
These bypass the WAF today (route direct, not through `mitmproxy_inspector`).
A clean regen omits them. PR #627 added a **drift guard** so regen refuses to
clobber when its output has fewer vhosts/backends than live — safe, but not
complete.
## Design (approved direction: auto-discover from modules/LXC)
1. **Drop-in registry.** Generator aggregates `haproxy.toml [vhosts.*]` **plus**
`/etc/secubox/vhosts.d/*.toml` — one file per module/LXC, dropped by that
module's postinst (self-registration). New modules appear automatically.
2. **Per-vhost routing intent** replaces the blanket `waf_enabled` override
(which currently forces *every* vhost through the WAF):
```toml
[vhost]
domain = "peertube.gk2.secubox.in"
backend = "nginx_vhosts" # or a module/LXC backend
ssl = true
inspect = false # true → mitmproxy_inspector (WAF); false → direct
```
3. **One-time migration.** Seed `vhosts.d/` from the ~10 drifted live entries
with their real backends + `inspect=false`; register `toolbox_landing` as a
known backend. After this, regen output == live → drift guard passes.
4. **Module-registration helper** for postinsts (e.g. `haproxyctl vhost register
--from-file` or a tiny library) so each LXC/module declares its vhost.
5. **Keep the drift guard** as the transition safety net.
## Validation gate (non-negotiable)
Never apply a regenerated cfg to production until a diff proves it reproduces all
~100 live vhosts/backends 1:1 (drift-guard counts match).
## Related: finish the traversal-footgun sweep (#623)
The systemic `install -d -m 0750 .../secubox` footgun is broader than first
swept: **multi-arg** forms like `install -d -m 0750 /run/secubox /var/lib/secubox
…` (e.g. secubox-haproxy, fixed in #627) were missed by the earlier grep
(`…/secubox/[a-z]` required a leaf). Re-sweep with a pattern that catches bare
`/var/{lib,log,cache}/secubox` arguments, and add a **tmpfiles.d + periodic
guard** so the shared parents self-heal to `0755` regardless of which package
clobbers them (this is the root of the recurring kbin/toolbox 500s).
## Status
- Generator no longer crashes (set -e + dup-backend fixed, #627).
- Drift guard prevents clobbering (#627).
- Error pages live + served from `/etc/haproxy/secubox-errors/` (#627).
- This auto-discovery rework + the #623 re-sweep are the remaining work.

View File

@ -1,3 +1,14 @@
secubox-core (1.1.7-1~bookworm1) bookworm; urgency=medium
* fix(postinst): /var/lib/secubox + /usr/share/secubox/www were set 0750,
breaking traversal for non-secubox daemons (kbin/toolbox 500) — now 0755
(same reasoning the postinst already applied to /etc/secubox).
* feat: secubox-dirs-guard timer — re-asserts the shared /…/secubox parents
to 0755 every minute, self-healing the recurring 0750 clobber from module
postinsts (closes #630).
-- Gerald Kerma <devel@cybermind.fr> Tue, 17 Jun 2026 11:00:00 +0200
secubox-core (1.1.6-1~bookworm1) bookworm; urgency=medium
* postinst: relax /etc/secubox to 0755 (was 0750). Non-secubox daemons

View File

@ -18,8 +18,12 @@ case "$1" in
# secubox:secubox), so opening the parent dir does NOT leak secrets.
install -d -o secubox -g secubox -m 755 /etc/secubox
install -d -m 1777 /run/secubox # World-writable for all service sockets
install -d -o secubox -g secubox -m 750 /var/lib/secubox
install -d -o secubox -g secubox -m 750 /usr/share/secubox/www
# Same reasoning as /etc/secubox above: these SHARED parents must be 0755 so
# every secubox-* daemon (running as its own user, not in group secubox) can
# traverse to its own subtree. 0750 here broke kbin/toolbox repeatedly
# (#626/#630). Per-module leaves + secrets stay restricted.
install -d -o secubox -g secubox -m 755 /var/lib/secubox
install -d -o secubox -g secubox -m 755 /usr/share/secubox/www
# ── tmpfiles.d for persistent /run/secubox ──
systemd-tmpfiles --create 2>/dev/null || true
@ -70,6 +74,11 @@ case "$1" in
# Enable runtime directory service (ensures /run/secubox exists before other services)
systemctl enable --now secubox-runtime.service 2>/dev/null || true
# Shared-dir traversal guard (#630): self-heals the recurring 0750 clobber
# (from this and other module postinsts) that breaks kbin/toolbox.
systemctl enable --now secubox-dirs-guard.timer 2>/dev/null || true
systemctl start secubox-dirs-guard.service 2>/dev/null || true
# ── Python dependencies (ensure compatible versions) ──
# Debian bookworm ships old pydantic v1 and fastapi, upgrade via pip
if command -v pip3 >/dev/null 2>&1; then

View File

@ -58,6 +58,18 @@ override_dh_auto_install:
install -m 644 tmpfiles.d/secubox.conf \
debian/secubox-core/usr/lib/tmpfiles.d/
# Shared-dir traversal guard (#630): keep /var/{lib,log,cache} + /etc +
# /usr/share /secubox parents at 0755 so every secubox-* user can traverse.
# Counters the recurring `install -d -m 0750 …/secubox` clobber in module
# postinsts that breaks kbin/toolbox.
install -d debian/secubox-core/usr/sbin
install -m 755 usr/sbin/secubox-dirs-guard.sh \
debian/secubox-core/usr/sbin/secubox-dirs-guard.sh
install -m 644 systemd/secubox-dirs-guard.service \
debian/secubox-core/usr/lib/systemd/system/
install -m 644 systemd/secubox-dirs-guard.timer \
debian/secubox-core/usr/lib/systemd/system/
# LED scripts and services
install -d debian/secubox-core/usr/sbin
install -m 755 usr/sbin/secubox-led-heartbeat \

View File

@ -0,0 +1,7 @@
[Unit]
Description=SecuBox shared-dir traversal guard (keep /…/secubox parents 0755)
Documentation=https://github.com/CyberMind-FR/secubox-deb
[Service]
Type=oneshot
ExecStart=/usr/sbin/secubox-dirs-guard.sh
Nice=10

View File

@ -0,0 +1,8 @@
[Unit]
Description=Re-assert SecuBox shared-dir perms every minute
[Timer]
OnCalendar=*:0/1
AccuracySec=10s
Persistent=true
[Install]
WantedBy=timers.target

View File

@ -0,0 +1,13 @@
#!/bin/sh
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# SecuBox-Deb :: shared-dir traversal guard
# Keep the shared SecuBox parent dirs traversable (0755) so EVERY secubox-* user
# can reach its own subtree. Counters the recurring `install -d -m 0750 …/secubox`
# clobber in various module postinsts that breaks kbin/toolbox (#626/#630).
# chmod-only (owner-agnostic); runs every minute via secubox-dirs-guard.timer.
for d in /var/lib/secubox /var/log/secubox /var/cache/secubox /etc/secubox /usr/share/secubox; do
[ -d "$d" ] || continue
[ "$(stat -c %a "$d" 2>/dev/null)" = "755" ] || chmod 0755 "$d" 2>/dev/null || true
done
exit 0

View File

@ -1,3 +1,28 @@
secubox-haproxy (1.3.1-1~bookworm1) bookworm; urgency=medium
* feat(errors): smart self-healing HAProxy error pages (closes #626).
- errors/{502,503,504}.http: branded pages that poll the URL and
auto-reload when the backend recovers (transient blips heal in-browser),
with live status + manual retry. {400,403,408,500}.http: branded static.
- haproxyctl generator: wire errorfile directives (durable across regen)
and persist retries 3 + option redispatch in defaults.
- Ship pages to /etc/haproxy/secubox-errors/ (own dir; /etc/haproxy/errors
is owned by the haproxy package). errorfile wired there.
* fix(generator): `haproxyctl generate` was broken (exited 1, produced no
backends) — root causes: (a) `set -e` + `[ ] && [ ] && { }` vhost-loop
chains aborted generation on the first non-SSL vhost; (b) duplicate
`backend mitmproxy_inspector` (auto + user TOML) → fatal. Converted the
chains to if/then/fi and dedup user backends already emitted.
* fix(generator): drift guard — refuse to install a generated cfg with fewer
vhosts/backends than the live one (prevents a successful regen from silently
dropping hand-maintained vhosts like kbin/gitea/matrix not yet in
haproxy.toml). Surfaces the drift instead of clobbering.
* fix(postinst): keep /run/secubox + /var/lib/secubox at 0755 (was set 0750,
breaking directory traversal for other secubox-* daemons — kbin/toolbox 500).
Only the haproxy-private leaves are 0750.
-- Gerald Kerma <devel@cybermind.fr> Tue, 17 Jun 2026 09:00:00 +0200
secubox-haproxy (1.3.0-1~bookworm1) bookworm; urgency=medium
* sbin/haproxyctl: rewrite cmd_generate to be safe + complete (#286).

View File

@ -4,7 +4,12 @@ case "$1" in
configure)
id -u secubox >/dev/null 2>&1 || \
adduser --system --group --no-create-home --home /var/lib/secubox --shell /usr/sbin/nologin secubox
install -d -o secubox -g secubox -m 750 /run/secubox /var/lib/secubox /var/lib/secubox/haproxy /var/lib/secubox/haproxy/config_backups
# Shared parents stay 0755 (traversable by every secubox-* daemon — setting
# them 0750 here broke kbin/toolbox by blocking traversal, #626). Only the
# haproxy-private leaves are restricted.
install -d -o secubox -g secubox -m 755 /run/secubox /var/lib/secubox
install -d -o secubox -g secubox -m 750 /var/lib/secubox/haproxy /var/lib/secubox/haproxy/config_backups
chmod 0755 /var/lib/secubox /run/secubox 2>/dev/null || true
# Create /etc/haproxy if not present (haproxy is Recommends, not Depends)
# Required for systemd namespace setup
install -d -m 755 /etc/haproxy

View File

@ -16,6 +16,10 @@ override_dh_auto_install:
[ -d www ] && cp -r www/. debian/secubox-haproxy/usr/share/secubox/www/ || true
install -d debian/secubox-haproxy/usr/share/secubox/menu.d
[ -d menu.d ] && cp -r menu.d/. debian/secubox-haproxy/usr/share/secubox/menu.d/ || true
# Smart self-healing HAProxy error pages (#626) — wired via errorfile in
# haproxyctl. Own dir (NOT /etc/haproxy/errors, which the haproxy package owns).
install -d debian/secubox-haproxy/etc/haproxy/secubox-errors
[ -d errors ] && install -m 644 errors/*.http debian/secubox-haproxy/etc/haproxy/secubox-errors/ || true
# Modular nginx config
install -d debian/secubox-haproxy/etc/nginx/secubox.d
[ -f nginx/haproxy.conf ] && cp nginx/haproxy.conf debian/secubox-haproxy/etc/nginx/secubox.d/ || true

View File

@ -0,0 +1,35 @@
HTTP/1.1 400 Bad Request
Cache-Control: no-store
Connection: close
Content-Type: text/html; charset=utf-8
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bad request — SecuBox</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{min-height:100vh;background:#0a0a0f;color:#e8e6d9;font-family:system-ui,-apple-system,'Segoe UI',sans-serif;display:flex;align-items:center;justify-content:center;padding:24px;
background-image:radial-gradient(ellipse at 30% 20%,rgba(230,57,70,.06)0%,transparent 50%),radial-gradient(ellipse at 70% 80%,rgba(110,64,201,.05)0%,transparent 50%)}
.card{max-width:520px;width:100%;text-align:center;background:rgba(17,23,32,.85);border:1px solid rgba(255,255,255,.08);border-radius:16px;padding:40px 32px;box-shadow:0 8px 40px rgba(0,0,0,.5)}
.code{font-family:'JetBrains Mono',monospace;font-size:5.5rem;font-weight:700;line-height:1;color:#c9a84c;text-shadow:0 0 30px rgba(201,168,76,.4)}
h1{font-size:1.5rem;font-weight:600;margin:12px 0 8px}
p{color:#8a9aa8;font-size:.95rem;line-height:1.6;margin:6px 0}
button{margin-top:22px;background:transparent;color:#00d4ff;border:1px solid #00d4ff;border-radius:8px;padding:10px 20px;font:inherit;font-size:.9rem;cursor:pointer;transition:all .2s}
button:hover{background:#00d4ff;color:#0a0a0f}
.brand{margin-top:28px;font-family:'JetBrains Mono',monospace;font-size:.72rem;color:#6b6b7a;letter-spacing:1px}
.brand b{color:#0a5840;filter:brightness(1.6)}
</style>
</head>
<body>
<div class="card">
<div class="code">400</div>
<h1>Bad request</h1>
<p>The request could not be understood. Check the URL and try again.</p>
<button type="button" onclick="history.length>1?history.back():location.reload()">Go back</button>
<div class="brand">SecuBox<b>·</b> secured edge</div>
</div>
</body>
</html>

View File

@ -0,0 +1,35 @@
HTTP/1.1 403 Forbidden
Cache-Control: no-store
Connection: close
Content-Type: text/html; charset=utf-8
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blocked — SecuBox</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{min-height:100vh;background:#0a0a0f;color:#e8e6d9;font-family:system-ui,-apple-system,'Segoe UI',sans-serif;display:flex;align-items:center;justify-content:center;padding:24px;
background-image:radial-gradient(ellipse at 30% 20%,rgba(230,57,70,.06)0%,transparent 50%),radial-gradient(ellipse at 70% 80%,rgba(110,64,201,.05)0%,transparent 50%)}
.card{max-width:520px;width:100%;text-align:center;background:rgba(17,23,32,.85);border:1px solid rgba(255,255,255,.08);border-radius:16px;padding:40px 32px;box-shadow:0 8px 40px rgba(0,0,0,.5)}
.code{font-family:'JetBrains Mono',monospace;font-size:5.5rem;font-weight:700;line-height:1;color:#e63946;text-shadow:0 0 30px rgba(230,57,70,.4)}
h1{font-size:1.5rem;font-weight:600;margin:12px 0 8px}
p{color:#8a9aa8;font-size:.95rem;line-height:1.6;margin:6px 0}
button{margin-top:22px;background:transparent;color:#00d4ff;border:1px solid #00d4ff;border-radius:8px;padding:10px 20px;font:inherit;font-size:.9rem;cursor:pointer;transition:all .2s}
button:hover{background:#00d4ff;color:#0a0a0f}
.brand{margin-top:28px;font-family:'JetBrains Mono',monospace;font-size:.72rem;color:#6b6b7a;letter-spacing:1px}
.brand b{color:#0a5840;filter:brightness(1.6)}
</style>
</head>
<body>
<div class="card">
<div class="code">403</div>
<h1>Blocked</h1>
<p>This request was blocked by the SecuBox WAF. If you believe this is a mistake, contact the operator.</p>
<button type="button" onclick="history.length>1?history.back():location.reload()">Go back</button>
<div class="brand">SecuBox<b>·</b> secured edge</div>
</div>
</body>
</html>

View File

@ -0,0 +1,35 @@
HTTP/1.1 408 Request Timeout
Cache-Control: no-store
Connection: close
Content-Type: text/html; charset=utf-8
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Request timeout — SecuBox</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{min-height:100vh;background:#0a0a0f;color:#e8e6d9;font-family:system-ui,-apple-system,'Segoe UI',sans-serif;display:flex;align-items:center;justify-content:center;padding:24px;
background-image:radial-gradient(ellipse at 30% 20%,rgba(230,57,70,.06)0%,transparent 50%),radial-gradient(ellipse at 70% 80%,rgba(110,64,201,.05)0%,transparent 50%)}
.card{max-width:520px;width:100%;text-align:center;background:rgba(17,23,32,.85);border:1px solid rgba(255,255,255,.08);border-radius:16px;padding:40px 32px;box-shadow:0 8px 40px rgba(0,0,0,.5)}
.code{font-family:'JetBrains Mono',monospace;font-size:5.5rem;font-weight:700;line-height:1;color:#c9a84c;text-shadow:0 0 30px rgba(201,168,76,.4)}
h1{font-size:1.5rem;font-weight:600;margin:12px 0 8px}
p{color:#8a9aa8;font-size:.95rem;line-height:1.6;margin:6px 0}
button{margin-top:22px;background:transparent;color:#00d4ff;border:1px solid #00d4ff;border-radius:8px;padding:10px 20px;font:inherit;font-size:.9rem;cursor:pointer;transition:all .2s}
button:hover{background:#00d4ff;color:#0a0a0f}
.brand{margin-top:28px;font-family:'JetBrains Mono',monospace;font-size:.72rem;color:#6b6b7a;letter-spacing:1px}
.brand b{color:#0a5840;filter:brightness(1.6)}
</style>
</head>
<body>
<div class="card">
<div class="code">408</div>
<h1>Request timeout</h1>
<p>The request took too long. Check your connection and try again.</p>
<button type="button" onclick="history.length>1?history.back():location.reload()">Go back</button>
<div class="brand">SecuBox<b>·</b> secured edge</div>
</div>
</body>
</html>

View File

@ -0,0 +1,35 @@
HTTP/1.1 500 Internal Server Error
Cache-Control: no-store
Connection: close
Content-Type: text/html; charset=utf-8
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Internal error — SecuBox</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{min-height:100vh;background:#0a0a0f;color:#e8e6d9;font-family:system-ui,-apple-system,'Segoe UI',sans-serif;display:flex;align-items:center;justify-content:center;padding:24px;
background-image:radial-gradient(ellipse at 30% 20%,rgba(230,57,70,.06)0%,transparent 50%),radial-gradient(ellipse at 70% 80%,rgba(110,64,201,.05)0%,transparent 50%)}
.card{max-width:520px;width:100%;text-align:center;background:rgba(17,23,32,.85);border:1px solid rgba(255,255,255,.08);border-radius:16px;padding:40px 32px;box-shadow:0 8px 40px rgba(0,0,0,.5)}
.code{font-family:'JetBrains Mono',monospace;font-size:5.5rem;font-weight:700;line-height:1;color:#e63946;text-shadow:0 0 30px rgba(230,57,70,.4)}
h1{font-size:1.5rem;font-weight:600;margin:12px 0 8px}
p{color:#8a9aa8;font-size:.95rem;line-height:1.6;margin:6px 0}
button{margin-top:22px;background:transparent;color:#00d4ff;border:1px solid #00d4ff;border-radius:8px;padding:10px 20px;font:inherit;font-size:.9rem;cursor:pointer;transition:all .2s}
button:hover{background:#00d4ff;color:#0a0a0f}
.brand{margin-top:28px;font-family:'JetBrains Mono',monospace;font-size:.72rem;color:#6b6b7a;letter-spacing:1px}
.brand b{color:#0a5840;filter:brightness(1.6)}
</style>
</head>
<body>
<div class="card">
<div class="code">500</div>
<h1>Internal error</h1>
<p>Something went wrong on our side. The incident is logged. Try again shortly.</p>
<button type="button" onclick="history.length>1?history.back():location.reload()">Go back</button>
<div class="brand">SecuBox<b>·</b> secured edge</div>
</div>
</body>
</html>

View File

@ -0,0 +1,70 @@
HTTP/1.1 502 Bad Gateway
Cache-Control: no-store, no-cache, must-revalidate
Connection: close
Content-Type: text/html; charset=utf-8
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service recovering — SecuBox</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{min-height:100vh;background:#0a0a0f;color:#e8e6d9;font-family:system-ui,-apple-system,'Segoe UI',sans-serif;display:flex;align-items:center;justify-content:center;padding:24px;
background-image:radial-gradient(ellipse at 30% 20%,rgba(201,168,76,.06)0%,transparent 50%),radial-gradient(ellipse at 70% 80%,rgba(0,212,255,.05)0%,transparent 50%)}
.card{max-width:520px;width:100%;text-align:center;background:rgba(17,23,32,.85);border:1px solid rgba(255,255,255,.08);border-radius:16px;padding:40px 32px;box-shadow:0 8px 40px rgba(0,0,0,.5)}
.code{font-family:'JetBrains Mono',monospace;font-size:5.5rem;font-weight:700;line-height:1;color:#c9a84c;text-shadow:0 0 30px rgba(201,168,76,.4)}
h1{font-size:1.5rem;font-weight:600;margin:12px 0 8px;color:#e8e6d9}
p{color:#8a9aa8;font-size:.95rem;line-height:1.6;margin:6px 0}
.dot{display:inline-block;width:10px;height:10px;border-radius:50%;background:#c9a84c;margin-right:8px;animation:pulse 1.2s ease-in-out infinite;vertical-align:middle}
@keyframes pulse{0%,100%{opacity:.3;transform:scale(.85)}50%{opacity:1;transform:scale(1.15)}}
.status{margin:24px 0 8px;font-family:'JetBrains Mono',monospace;font-size:.9rem;color:#00d4ff;min-height:1.4em}
.status.ok{color:#00ff41}
.bar{height:3px;background:rgba(255,255,255,.08);border-radius:3px;overflow:hidden;margin:16px 0}
.bar>i{display:block;height:100%;width:30%;background:linear-gradient(90deg,transparent,#00d4ff,transparent);animation:scan 1.6s linear infinite}
@keyframes scan{0%{transform:translateX(-120%)}100%{transform:translateX(400%)}}
button{margin-top:18px;background:transparent;color:#c9a84c;border:1px solid #c9a84c;border-radius:8px;padding:10px 20px;font:inherit;font-size:.9rem;cursor:pointer;transition:all .2s}
button:hover{background:#c9a84c;color:#0a0a0f}
.brand{margin-top:28px;font-family:'JetBrains Mono',monospace;font-size:.72rem;color:#6b6b7a;letter-spacing:1px}
.brand b{color:#0a5840;filter:brightness(1.6)}
</style>
</head>
<body>
<div class="card">
<div class="code" id="code">502</div>
<h1><span class="dot"></span>Service recovering</h1>
<p>This service is temporarily unavailable. It's being inspected by the SecuBox WAF and is recovering.</p>
<p>This page checks automatically and will reload the moment it's back — no need to refresh.</p>
<div class="bar"><i></i></div>
<div class="status" id="status">⟳ checking…</div>
<button id="retry" type="button">Retry now</button>
<div class="brand">SecuBox<b>·</b> secured edge — self-healing</div>
</div>
<script>
(function(){
"use strict";
var s=document.getElementById('status'),attempt=0,delay=4000,timer=null;
function recovered(st){return st>0&&st<500;}
function check(){
attempt++;
s.className='status';s.textContent='⟳ checking… (attempt '+attempt+')';
fetch(location.href,{method:'GET',cache:'no-store',redirect:'manual',headers:{'x-secubox-healthcheck':'1'}})
.then(function(r){
if(recovered(r.type==='opaqueredirect'?200:r.status)){
s.className='status ok';s.textContent='✓ back online — reloading…';
clearTimeout(timer);setTimeout(function(){location.reload();},600);
}else{schedule();}
})
.catch(function(){schedule();});
}
function schedule(){
delay=Math.min(delay+1000,15000);
timer=setTimeout(check,delay);
}
document.getElementById('retry').addEventListener('click',function(){clearTimeout(timer);delay=4000;check();});
timer=setTimeout(check,2500);
})();
</script>
</body>
</html>

View File

@ -0,0 +1,70 @@
HTTP/1.1 503 Service Unavailable
Cache-Control: no-store, no-cache, must-revalidate
Connection: close
Content-Type: text/html; charset=utf-8
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service recovering — SecuBox</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{min-height:100vh;background:#0a0a0f;color:#e8e6d9;font-family:system-ui,-apple-system,'Segoe UI',sans-serif;display:flex;align-items:center;justify-content:center;padding:24px;
background-image:radial-gradient(ellipse at 30% 20%,rgba(201,168,76,.06)0%,transparent 50%),radial-gradient(ellipse at 70% 80%,rgba(0,212,255,.05)0%,transparent 50%)}
.card{max-width:520px;width:100%;text-align:center;background:rgba(17,23,32,.85);border:1px solid rgba(255,255,255,.08);border-radius:16px;padding:40px 32px;box-shadow:0 8px 40px rgba(0,0,0,.5)}
.code{font-family:'JetBrains Mono',monospace;font-size:5.5rem;font-weight:700;line-height:1;color:#c9a84c;text-shadow:0 0 30px rgba(201,168,76,.4)}
h1{font-size:1.5rem;font-weight:600;margin:12px 0 8px;color:#e8e6d9}
p{color:#8a9aa8;font-size:.95rem;line-height:1.6;margin:6px 0}
.dot{display:inline-block;width:10px;height:10px;border-radius:50%;background:#c9a84c;margin-right:8px;animation:pulse 1.2s ease-in-out infinite;vertical-align:middle}
@keyframes pulse{0%,100%{opacity:.3;transform:scale(.85)}50%{opacity:1;transform:scale(1.15)}}
.status{margin:24px 0 8px;font-family:'JetBrains Mono',monospace;font-size:.9rem;color:#00d4ff;min-height:1.4em}
.status.ok{color:#00ff41}
.bar{height:3px;background:rgba(255,255,255,.08);border-radius:3px;overflow:hidden;margin:16px 0}
.bar>i{display:block;height:100%;width:30%;background:linear-gradient(90deg,transparent,#00d4ff,transparent);animation:scan 1.6s linear infinite}
@keyframes scan{0%{transform:translateX(-120%)}100%{transform:translateX(400%)}}
button{margin-top:18px;background:transparent;color:#c9a84c;border:1px solid #c9a84c;border-radius:8px;padding:10px 20px;font:inherit;font-size:.9rem;cursor:pointer;transition:all .2s}
button:hover{background:#c9a84c;color:#0a0a0f}
.brand{margin-top:28px;font-family:'JetBrains Mono',monospace;font-size:.72rem;color:#6b6b7a;letter-spacing:1px}
.brand b{color:#0a5840;filter:brightness(1.6)}
</style>
</head>
<body>
<div class="card">
<div class="code" id="code">503</div>
<h1><span class="dot"></span>Service recovering</h1>
<p>This service is temporarily unavailable. It's being inspected by the SecuBox WAF and is recovering.</p>
<p>This page checks automatically and will reload the moment it's back — no need to refresh.</p>
<div class="bar"><i></i></div>
<div class="status" id="status">⟳ checking…</div>
<button id="retry" type="button">Retry now</button>
<div class="brand">SecuBox<b>·</b> secured edge — self-healing</div>
</div>
<script>
(function(){
"use strict";
var s=document.getElementById('status'),attempt=0,delay=4000,timer=null;
function recovered(st){return st>0&&st<500;}
function check(){
attempt++;
s.className='status';s.textContent='⟳ checking… (attempt '+attempt+')';
fetch(location.href,{method:'GET',cache:'no-store',redirect:'manual',headers:{'x-secubox-healthcheck':'1'}})
.then(function(r){
if(recovered(r.type==='opaqueredirect'?200:r.status)){
s.className='status ok';s.textContent='✓ back online — reloading…';
clearTimeout(timer);setTimeout(function(){location.reload();},600);
}else{schedule();}
})
.catch(function(){schedule();});
}
function schedule(){
delay=Math.min(delay+1000,15000);
timer=setTimeout(check,delay);
}
document.getElementById('retry').addEventListener('click',function(){clearTimeout(timer);delay=4000;check();});
timer=setTimeout(check,2500);
})();
</script>
</body>
</html>

View File

@ -0,0 +1,70 @@
HTTP/1.1 504 Gateway Timeout
Cache-Control: no-store, no-cache, must-revalidate
Connection: close
Content-Type: text/html; charset=utf-8
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service recovering — SecuBox</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{min-height:100vh;background:#0a0a0f;color:#e8e6d9;font-family:system-ui,-apple-system,'Segoe UI',sans-serif;display:flex;align-items:center;justify-content:center;padding:24px;
background-image:radial-gradient(ellipse at 30% 20%,rgba(201,168,76,.06)0%,transparent 50%),radial-gradient(ellipse at 70% 80%,rgba(0,212,255,.05)0%,transparent 50%)}
.card{max-width:520px;width:100%;text-align:center;background:rgba(17,23,32,.85);border:1px solid rgba(255,255,255,.08);border-radius:16px;padding:40px 32px;box-shadow:0 8px 40px rgba(0,0,0,.5)}
.code{font-family:'JetBrains Mono',monospace;font-size:5.5rem;font-weight:700;line-height:1;color:#c9a84c;text-shadow:0 0 30px rgba(201,168,76,.4)}
h1{font-size:1.5rem;font-weight:600;margin:12px 0 8px;color:#e8e6d9}
p{color:#8a9aa8;font-size:.95rem;line-height:1.6;margin:6px 0}
.dot{display:inline-block;width:10px;height:10px;border-radius:50%;background:#c9a84c;margin-right:8px;animation:pulse 1.2s ease-in-out infinite;vertical-align:middle}
@keyframes pulse{0%,100%{opacity:.3;transform:scale(.85)}50%{opacity:1;transform:scale(1.15)}}
.status{margin:24px 0 8px;font-family:'JetBrains Mono',monospace;font-size:.9rem;color:#00d4ff;min-height:1.4em}
.status.ok{color:#00ff41}
.bar{height:3px;background:rgba(255,255,255,.08);border-radius:3px;overflow:hidden;margin:16px 0}
.bar>i{display:block;height:100%;width:30%;background:linear-gradient(90deg,transparent,#00d4ff,transparent);animation:scan 1.6s linear infinite}
@keyframes scan{0%{transform:translateX(-120%)}100%{transform:translateX(400%)}}
button{margin-top:18px;background:transparent;color:#c9a84c;border:1px solid #c9a84c;border-radius:8px;padding:10px 20px;font:inherit;font-size:.9rem;cursor:pointer;transition:all .2s}
button:hover{background:#c9a84c;color:#0a0a0f}
.brand{margin-top:28px;font-family:'JetBrains Mono',monospace;font-size:.72rem;color:#6b6b7a;letter-spacing:1px}
.brand b{color:#0a5840;filter:brightness(1.6)}
</style>
</head>
<body>
<div class="card">
<div class="code" id="code">504</div>
<h1><span class="dot"></span>Service is slow — recovering</h1>
<p>This service took too long to respond through the SecuBox WAF and is recovering.</p>
<p>This page checks automatically and will reload the moment it's back — no need to refresh.</p>
<div class="bar"><i></i></div>
<div class="status" id="status">⟳ checking…</div>
<button id="retry" type="button">Retry now</button>
<div class="brand">SecuBox<b>·</b> secured edge — self-healing</div>
</div>
<script>
(function(){
"use strict";
var s=document.getElementById('status'),attempt=0,delay=4000,timer=null;
function recovered(st){return st>0&&st<500;}
function check(){
attempt++;
s.className='status';s.textContent='⟳ checking… (attempt '+attempt+')';
fetch(location.href,{method:'GET',cache:'no-store',redirect:'manual',headers:{'x-secubox-healthcheck':'1'}})
.then(function(r){
if(recovered(r.type==='opaqueredirect'?200:r.status)){
s.className='status ok';s.textContent='✓ back online — reloading…';
clearTimeout(timer);setTimeout(function(){location.reload();},600);
}else{schedule();}
})
.catch(function(){schedule();});
}
function schedule(){
delay=Math.min(delay+1000,15000);
timer=setTimeout(check,delay);
}
document.getElementById('retry').addEventListener('click',function(){clearTimeout(timer);delay=4000;check();});
timer=setTimeout(check,2500);
})();
</script>
</body>
</html>

View File

@ -620,9 +620,19 @@ defaults
timeout connect 5s
timeout client 30s
timeout server 30s
retries 3
option redispatch
option httplog
option dontlognull
option forwardfor
# Smart self-healing error pages (#626) — shipped by secubox-haproxy.
errorfile 400 /etc/haproxy/secubox-errors/400.http
errorfile 403 /etc/haproxy/secubox-errors/403.http
errorfile 408 /etc/haproxy/secubox-errors/408.http
errorfile 500 /etc/haproxy/secubox-errors/500.http
errorfile 502 /etc/haproxy/secubox-errors/502.http
errorfile 503 /etc/haproxy/secubox-errors/503.http
errorfile 504 /etc/haproxy/secubox-errors/504.http
frontend stats
bind *:${stats_port}
@ -661,16 +671,18 @@ EOF
local backend=$(echo "$section" | grep -E '^backend\s*=' | cut -d'"' -f2)
local enabled=$(echo "$section" | grep -E '^enabled\s*=' | grep -q 'false' && echo "0" || echo "1")
# WebUI strict-regex already covers admin.${SECUBOX_HOSTNAME}.${SECUBOX_DOMAIN_SUFFIX}
[ -n "$_admin_skip_domain" ] && [ "$domain" = "$_admin_skip_domain" ] && continue
if [ -n "$_admin_skip_domain" ] && [ "$domain" = "$_admin_skip_domain" ]; then continue; fi
[ "$enabled" = "1" ] && [ -n "$domain" ] && {
# NB: use if/then/fi, not `[ ] && [ ] && { }` — under `set -e` a
# short-circuited &&-chain (e.g. a non-SSL vhost) aborts generation.
if [ "$enabled" = "1" ] && [ -n "$domain" ]; then
echo " acl host_$name hdr(host) -i $domain"
if [ "$waf_enabled" = "1" ]; then
echo " use_backend mitmproxy_inspector if host_$name"
else
echo " use_backend $backend if host_$name"
fi
}
fi
done >> "$out"
fi
@ -709,16 +721,17 @@ EOF
local ssl=$(echo "$section" | grep -E '^ssl\s*=' | grep -q 'true' && echo "1" || echo "0")
local enabled=$(echo "$section" | grep -E '^enabled\s*=' | grep -q 'false' && echo "0" || echo "1")
# WebUI strict-regex already covers admin.${SECUBOX_HOSTNAME}.${SECUBOX_DOMAIN_SUFFIX}
[ -n "$_admin_skip_domain" ] && [ "$domain" = "$_admin_skip_domain" ] && continue
if [ -n "$_admin_skip_domain" ] && [ "$domain" = "$_admin_skip_domain" ]; then continue; fi
[ "$enabled" = "1" ] && [ "$ssl" = "1" ] && [ -n "$domain" ] && {
# if/then/fi (not `&& { }`) — set -e safe for non-SSL vhosts.
if [ "$enabled" = "1" ] && [ "$ssl" = "1" ] && [ -n "$domain" ]; then
echo " acl host_$name hdr(host) -i $domain"
if [ "$waf_enabled" = "1" ]; then
echo " use_backend mitmproxy_inspector if host_$name"
else
echo " use_backend $backend if host_$name"
fi
}
fi
done >> "$out"
fi
@ -728,7 +741,8 @@ EOF
EOF
# WAF inspector backend (mitmproxy LXC container)
[ "$waf_enabled" = "1" ] && cat >> "$out" << EOF
# if/then/fi (not `&& cat`) — under set -e a false test here aborts generation.
if [ "$waf_enabled" = "1" ]; then cat >> "$out" << EOF
# WAF Inspector Backend (mitmproxy LXC at $waf_ip:$waf_port)
backend mitmproxy_inspector
mode http
@ -738,6 +752,7 @@ backend mitmproxy_inspector
server waf ${waf_ip}:${waf_port} check
EOF
fi
# WebUI direct backend (issue #44 — for the strict-regex ACL above)
# Only emit if SECUBOX_HOSTNAME is set (i.e., the strict ACL was injected).
@ -753,6 +768,9 @@ EOF
if [ -f "$CONF_PATH" ]; then
grep '^\[backends\.' "$CONF_PATH" | while read -r line; do
local name=$(echo "$line" | sed 's/\[backends\.//;s/\]//')
# Skip backends already emitted (mitmproxy_inspector/webui_direct are
# auto-generated above) to avoid duplicate-backend fatal errors.
if grep -q "^backend $name\$" "$out"; then continue; fi
local section=$(sed -n "/^\[backends\.$name\]/,/^\[/p" "$CONF_PATH" | head -n -1)
local mode=$(echo "$section" | grep -E '^mode\s*=' | cut -d'"' -f2 || echo "http")
local balance=$(echo "$section" | grep -E '^balance\s*=' | cut -d'"' -f2 || echo "roundrobin")
@ -765,11 +783,11 @@ backend $name
EOF
local i=0
echo "$servers" | while read -r srv; do
[ -n "$srv" ] && {
if [ -n "$srv" ]; then
srv=$(echo "$srv" | tr -d ' ')
echo " server srv$i $srv check" >> "$out"
i=$((i + 1))
}
fi
done
echo "" >> "$out"
done
@ -824,6 +842,22 @@ EOF
# to the live cfg and only validated at the end, leaving the file in a
# broken state on failure (recurring "broken-by-vhost-add" backups).
if haproxy -c -f "$out" 2>/dev/null; then
# Drift guard (#626): refuse to overwrite a live config that has MORE
# vhosts/backends than we just generated — that means the live cfg holds
# entries absent from our inputs (haproxy.toml / cfg.d). Without this,
# a successful regen would silently drop hand-maintained vhosts (kbin,
# gitea, …). `local x=$(...)` masks grep's exit so set -e stays happy.
if [ -f "$CONFIG_DIR/haproxy.cfg" ]; then
local _nh=$(grep -c '^[[:space:]]*acl host_' "$out")
local _oh=$(grep -c '^[[:space:]]*acl host_' "$CONFIG_DIR/haproxy.cfg")
local _nb=$(grep -c '^backend ' "$out")
local _ob=$(grep -c '^backend ' "$CONFIG_DIR/haproxy.cfg")
if [ "${_nh:-0}" -lt "${_oh:-0}" ] || [ "${_nb:-0}" -lt "${_ob:-0}" ]; then
error "Drift guard: generated cfg has fewer vhosts/backends than live (acl ${_nh}<${_oh} or backend ${_nb}<${_ob}) — refusing to clobber. Migrate the missing entries into haproxy.toml/cfg.d first."
rm -f "$out"
return 1
fi
fi
install -m 0644 -o root -g root "$out" "$CONFIG_DIR/haproxy.cfg"
rm -f "$out"
log "Configuration generated, validated and installed: $CONFIG_DIR/haproxy.cfg"

View File

@ -1,3 +1,11 @@
secubox-hub (1.4.5-1~bookworm1) bookworm; urgency=medium
* feat(health): Health Monitor page (/health/) — live status of vital +
common services from public/health-batch, grouped + severity-sorted,
auto-refresh, dark hybrid-skin. Sidebar menu entry (closes #628).
-- Gerald Kerma <devel@cybermind.fr> Tue, 17 Jun 2026 10:00:00 +0200
secubox-hub (1.4.4-1~bookworm1) bookworm; urgency=medium
* fix(perf): dashboard/services cache never warmed under the aggregator,

View File

@ -0,0 +1,9 @@
{
"id": "health",
"name": "Health Monitor",
"category": "root",
"icon": "💚",
"path": "/health/",
"order": 5,
"description": "Live status of vital and common SecuBox services"
}

View File

@ -0,0 +1,51 @@
/* SPDX-License-Identifier: LicenseRef-CMSD-1.0
SecuBox-Deb :: Hub Health Monitor dark hybrid-skin styles */
:root {
--gmb-h: 48px; --sp-s: 8px; --sp-m: 16px; --sp-l: 24px; --sp-xl: 40px; --r-card: 14px;
--bg0: var(--bg-dark, #0A0E14); --bg1: var(--surface-dark, #141A24);
--bd: var(--border-dark, #2A3444); --tx: var(--text-dark, #E8E6E0); --mut: var(--muted-dark, #8A9AA8);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--font-body, 'Space Grotesk', system-ui, sans-serif); font-size: 14px;
line-height: 1.6; background: var(--bg0); color: var(--tx); min-height: 100vh; }
.main { margin-left: 220px; padding: calc(var(--gmb-h) + var(--sp-l)) var(--sp-l) var(--sp-xl); max-width: 1280px; }
@media (max-width: 768px) { .main { margin-left: 0; padding: calc(var(--gmb-h) + var(--sp-m)) var(--sp-m) var(--sp-l); } }
header.page { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-m);
margin-bottom: var(--sp-l); padding-bottom: var(--sp-m); border-bottom: 1px solid var(--bd); }
header.page h1 { font-size: 24px; font-weight: 600; letter-spacing: -0.4px; color: var(--root-light, #148C66); }
header.page .ver { font-family: var(--font-mono, monospace); font-size: 12px; color: var(--mut); margin-left: var(--sp-s); }
.actions { display: flex; gap: var(--sp-m); align-items: center; }
.updated { font-family: var(--font-mono, monospace); font-size: 11px; color: var(--mut); }
.btn { background: var(--bg1); color: var(--tx); border: 1px solid var(--bd); border-radius: 6px;
padding: 6px 12px; cursor: pointer; font: inherit; font-size: 13px; }
.btn:hover { border-color: var(--root-light, #148C66); }
h2 { font-size: 16px; font-weight: 600; margin: var(--sp-xl) 0 var(--sp-m); }
.count { font-family: var(--font-mono, monospace); font-size: 12px; color: var(--mut); font-weight: 400; }
.banner { padding: var(--sp-m); border-radius: 8px; margin-bottom: var(--sp-m); font-size: 13px; }
.banner.info { background: var(--bg1); color: var(--mut); border: 1px solid var(--bd); }
.banner.err { background: rgba(192,64,64,.12); color: #E8845A; border: 1px solid #803018; }
/* Summary */
.summary { display: flex; gap: var(--sp-m); flex-wrap: wrap; }
.sum { flex: 1; min-width: 120px; background: var(--bg1); border: 1px solid var(--bd);
border-radius: var(--r-card); padding: var(--sp-m); text-align: center; }
.sum b { display: block; font-size: 2rem; font-weight: 700; font-family: var(--font-mono, monospace); line-height: 1; }
.sum span { font-size: 12px; color: var(--mut); }
.sum.ok b { color: #2ecc8f; } .sum.warn b { color: #f0b94c; } .sum.err b { color: #ff7a6b; } .sum.total b { color: var(--tx); }
.sum.ok { border-left: 3px solid #0A5840; } .sum.warn { border-left: 3px solid #9A6010; } .sum.err { border-left: 3px solid #803018; }
/* Service grid */
.svc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: var(--sp-s); }
.svc { display: flex; align-items: center; gap: var(--sp-s); background: var(--bg1); border: 1px solid var(--bd);
border-radius: 8px; padding: 10px 12px; min-width: 0; }
.svc .led { width: 9px; height: 9px; border-radius: 50%; flex: none; box-shadow: 0 0 6px currentColor; }
.svc.ok .led { background: #2ecc8f; color: #2ecc8f; }
.svc.warn .led, .svc.unknown .led { background: #f0b94c; color: #f0b94c; }
.svc.error .led { background: #ff7a6b; color: #ff7a6b; animation: pulse 1.2s infinite; }
@keyframes pulse { 50% { opacity: .4; } }
.svc.error { border-left: 3px solid #803018; }
.svc-name { font-weight: 600; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.svc-msg { margin-left: auto; font-size: 11px; color: var(--mut); font-family: var(--font-mono, monospace);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 45%; }
.empty { color: var(--mut); font-size: 13px; padding: var(--sp-m); }

View File

@ -0,0 +1,89 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
// SecuBox-Deb :: Hub Health Monitor — CyberMind https://cybermind.fr
(function () {
'use strict';
const BATCH = '/api/v1/hub/public/health-batch';
const INFO = '/api/v1/hub/public/info';
const REFRESH_MS = 15000;
// Vital services — the security/serving spine; everything else is "common".
const VITAL = ['waf', 'crowdsec', 'mitmproxy', 'haproxy', 'aggregator', 'hub',
'system', 'vortex-dns', 'dns-guard', 'certs', 'wireguard', 'soc',
'metrics', 'core'];
const VITAL_SET = new Set(VITAL);
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
async function getJSON(u) {
const r = await fetch(u, { headers: { 'Accept': 'application/json' }, cache: 'no-store' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}
function chip(id, st) {
const status = (st && st.status) || 'unknown';
const msg = (st && st.msg) || '';
return `<div class="svc ${status}" title="${esc(id)}: ${esc(msg)}">
<span class="led"></span>
<span class="svc-name">${esc(id)}</span>
<span class="svc-msg">${esc(msg)}</span>
</div>`;
}
function render(modules) {
const ids = Object.keys(modules).sort();
let ok = 0, warn = 0, err = 0;
ids.forEach((id) => {
const s = (modules[id] || {}).status;
if (s === 'ok') ok++; else if (s === 'error') err++; else warn++;
});
$('summary').innerHTML =
`<div class="sum ok"><b>${ok}</b><span>healthy</span></div>` +
`<div class="sum warn"><b>${warn}</b><span>degraded</span></div>` +
`<div class="sum err"><b>${err}</b><span>down</span></div>` +
`<div class="sum total"><b>${ids.length}</b><span>services</span></div>`;
const vital = ids.filter((id) => VITAL_SET.has(id));
const common = ids.filter((id) => !VITAL_SET.has(id));
// Sort each: errors first, then warn, then ok, so problems surface.
const rank = (id) => ({ error: 0, warn: 1, unknown: 1, ok: 2 }[(modules[id] || {}).status] ?? 1);
const bySeverity = (a, b) => rank(a) - rank(b) || a.localeCompare(b);
$('vital').innerHTML = vital.sort(bySeverity).map((id) => chip(id, modules[id])).join('')
|| '<div class="empty">no vital services reported</div>';
$('common').innerHTML = common.sort(bySeverity).map((id) => chip(id, modules[id])).join('')
|| '<div class="empty">none</div>';
$('vitalCount').textContent = '(' + vital.length + ')';
$('commonCount').textContent = '(' + common.length + ')';
}
async function load() {
try {
const batch = await getJSON(BATCH);
// health-batch returns {modules: {id: {status,msg}}, count: N}
const modules = (batch && batch.modules) || batch || {};
render(modules);
$('updated').textContent = 'updated ' + new Date().toLocaleTimeString();
$('loading').hidden = true; $('error').hidden = true; $('content').hidden = false;
getJSON(INFO).then((i) => {
if (i && i.hostname) $('ver').textContent = esc(i.hostname);
}).catch(() => {});
} catch (e) {
$('error').hidden = false;
$('error').textContent = 'Could not load health: ' + e.message;
$('loading').hidden = true;
}
}
document.addEventListener('DOMContentLoaded', () => {
$('refresh').addEventListener('click', load);
load();
setInterval(load, REFRESH_MS);
});
})();

View File

@ -0,0 +1,54 @@
<!DOCTYPE html>
<!--
SecuBox · Health Monitor — live status of vital + common services.
Consumes /api/v1/hub/public/health-batch. Dark hybrid-skin via /shared/sidebar.js.
-->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate">
<title>SecuBox · Health Monitor</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/shared/design-tokens.css">
<link rel="stylesheet" href="/shared/sidebar.css">
<link rel="stylesheet" href="health.css">
</head>
<body class="module-root">
<nav class="sidebar" id="sidebar"></nav>
<main class="main">
<header class="page">
<h1>💚 Health Monitor <span class="ver" id="ver">v1.0</span></h1>
<div class="actions">
<span class="updated" id="updated"></span>
<button class="btn" id="refresh" type="button">↻ Refresh</button>
</div>
</header>
<div id="error" class="banner err" hidden></div>
<div id="loading" class="banner info">Loading service health…</div>
<div id="content" hidden>
<!-- Summary strip -->
<section class="summary" id="summary"></section>
<!-- Vital services -->
<section>
<h2>Vital services <span class="count" id="vitalCount"></span></h2>
<div class="svc-grid" id="vital"></div>
</section>
<!-- Common services -->
<section>
<h2>Common services <span class="count" id="commonCount"></span></h2>
<div class="svc-grid" id="common"></div>
</section>
</div>
</main>
<script src="/shared/sidebar.js"></script>
<script src="health.js" defer></script>
</body>
</html>

View File

@ -0,0 +1,46 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
# SecuBox-Deb :: WAF inspector watchdog (#624)
# Auto-recover the mitmproxy LXC if its inspector port is down for MAXFAIL
# consecutive checks. Rate-limited by COOLDOWN to avoid restart flap. The whole
# point: an inspector crash becomes a ~3-minute auto-recovery instead of a
# multi-hour board-wide 503.
set -uo pipefail
readonly HOST="${SECUBOX_WAF_HOST:-10.100.0.60}"
readonly PORT="${SECUBOX_WAF_PORT:-8080}"
readonly CTN="${SECUBOX_WAF_CTN:-mitmproxy}"
readonly STATE="/run/secubox-waf-watchdog.state"
readonly MAXFAIL="${SECUBOX_WAF_MAXFAIL:-3}"
readonly COOLDOWN="${SECUBOX_WAF_COOLDOWN:-600}"
now="$(date +%s)"
fails=0
last=0
if [ -f "$STATE" ]; then
read -r fails last < "$STATE" 2>/dev/null || true
fi
[[ "$fails" =~ ^[0-9]+$ ]] || fails=0
[[ "$last" =~ ^[0-9]+$ ]] || last=0
# Healthy → reset the failure counter and exit.
if timeout 4 bash -c "echo >/dev/tcp/$HOST/$PORT" 2>/dev/null; then
echo "0 $last" > "$STATE"
exit 0
fi
fails=$((fails + 1))
logger -t secubox-waf-watchdog "inspector $HOST:$PORT DOWN (fail $fails/$MAXFAIL)"
if [ "$fails" -ge "$MAXFAIL" ] && [ $((now - last)) -ge "$COOLDOWN" ]; then
logger -t secubox-waf-watchdog "auto-recovering container $CTN"
timeout 45 lxc-stop -n "$CTN" -k 2>/dev/null || true
timeout 45 lxc-start -n "$CTN" -d 2>/dev/null || true
echo "0 $now" > "$STATE"
else
echo "$fails $last" > "$STATE"
fi

View File

@ -1,3 +1,17 @@
secubox-mitmproxy (1.0.9-1~bookworm1) bookworm; urgency=medium
* feat(robustness): self-healing WAF inspector watchdog (closes #624).
- secubox-waf-watchdog.{sh,service,timer}: every 60s check inspector
:8080; after 3 consecutive failures auto-recover the mitmproxy LXC
(lxc-stop -k / lxc-start), rate-limited once/10min. Turns an inspector
crash from a multi-hour board-wide 503 into a ~3-minute auto-recovery.
- Enabled in postinst, stopped/disabled in prerm.
* fix(service): secubox-mitmproxy.service ran /usr/bin/uvicorn (absent →
203/EXEC crash-loop). Use portable `python3 -m uvicorn` + unlink stale
socket on (re)start.
-- Gerald Kerma <devel@cybermind.fr> Tue, 17 Jun 2026 08:00:00 +0200
secubox-mitmproxy (1.0.8-1~bookworm1) bookworm; urgency=medium
* fix(waf): live-reload haproxy-routes.json on change (#609). The addon now

View File

@ -111,6 +111,10 @@ EOF
systemctl enable secubox-mitmproxy.service
systemctl start secubox-mitmproxy.service || true
# WAF inspector self-healing watchdog (#624)
systemctl enable secubox-waf-watchdog.timer 2>/dev/null || true
systemctl start secubox-waf-watchdog.timer 2>/dev/null || true
# Reload nginx if installed
systemctl reload nginx 2>/dev/null || true
;;

View File

@ -5,6 +5,8 @@ case "$1" in
remove|purge)
systemctl stop secubox-mitmproxy.service || true
systemctl disable secubox-mitmproxy.service || true
systemctl stop secubox-waf-watchdog.timer 2>/dev/null || true
systemctl disable secubox-waf-watchdog.timer 2>/dev/null || true
;;
esac
#DEBHELPER#

View File

@ -19,6 +19,10 @@ override_dh_auto_install:
install -m 644 debian/mitmproxy.toml $(CURDIR)/debian/secubox-mitmproxy/etc/secubox/
install -d $(CURDIR)/debian/secubox-mitmproxy/usr/lib/systemd/system
install -m 644 debian/secubox-mitmproxy.service $(CURDIR)/debian/secubox-mitmproxy/usr/lib/systemd/system/
# WAF inspector watchdog (#624) — auto-recover the mitmproxy LXC if :8080 dies
install -m 755 bin/secubox-waf-watchdog.sh $(CURDIR)/debian/secubox-mitmproxy/usr/sbin/
install -m 644 debian/secubox-waf-watchdog.service $(CURDIR)/debian/secubox-mitmproxy/usr/lib/systemd/system/
install -m 644 debian/secubox-waf-watchdog.timer $(CURDIR)/debian/secubox-mitmproxy/usr/lib/systemd/system/
install -d $(CURDIR)/debian/secubox-mitmproxy/etc/nginx/secubox.d
install -m 644 nginx/mitmproxy.conf $(CURDIR)/debian/secubox-mitmproxy/etc/nginx/secubox.d/
# Cookie audit (issue #156) hardening — AppArmor abstraction + logrotate (issue #170)

View File

@ -0,0 +1,7 @@
# Automatically added by dh_installtmpfiles/13.14.1ubuntu5
if [ "$1" = "configure" ] || [ "$1" = "abort-upgrade" ] || [ "$1" = "abort-deconfigure" ] || [ "$1" = "abort-remove" ] ; then
if [ -x "$(command -v systemd-tmpfiles)" ]; then
systemd-tmpfiles ${DPKG_ROOT:+--root="$DPKG_ROOT"} --create secubox-thp.conf || true
fi
fi
# End automatically added section

View File

@ -8,10 +8,13 @@ Type=simple
User=secubox
Group=secubox
WorkingDirectory=/usr/lib/secubox/mitmproxy
ExecStart=/usr/bin/uvicorn api.main:app \
ExecStartPre=-/bin/rm -f /run/secubox/mitmproxy.sock
ExecStart=/usr/bin/python3 -m uvicorn api.main:app \
--uds /run/secubox/mitmproxy.sock \
--log-level warning
ExecStartPost=/bin/chmod 660 /run/secubox/mitmproxy.sock
# Wait for uvicorn to bind the uds before chmod; non-fatal so a race never
# fails the unit (Type=simple runs ExecStartPost without waiting for readiness).
ExecStartPost=-/bin/sh -c 'for i in 1 2 3 4 5 6; do [ -S /run/secubox/mitmproxy.sock ] && exec chmod 660 /run/secubox/mitmproxy.sock; sleep 0.5; done'
Restart=on-failure
RestartSec=5

View File

@ -0,0 +1,9 @@
[Unit]
Description=SecuBox WAF inspector watchdog (auto-recover mitmproxy LXC)
Documentation=https://github.com/CyberMind-FR/secubox-deb
ConditionPathExists=/usr/sbin/secubox-waf-watchdog.sh
[Service]
Type=oneshot
ExecStart=/usr/sbin/secubox-waf-watchdog.sh
Nice=10

View File

@ -0,0 +1,10 @@
[Unit]
Description=Run the SecuBox WAF inspector watchdog every minute
[Timer]
OnBootSec=120
OnUnitActiveSec=60
AccuracySec=10s
[Install]
WantedBy=timers.target

View File

@ -1,3 +1,11 @@
secubox-toolbox (2.6.41-1~bookworm1) bookworm; urgency=medium
* perf(ttfb): stream_inject now defaults ON (#630) — phase-2 streaming loader
inject is the default (workers ~71MB/8% vs ~100MB/28%). Operators can still
toggle it off via /etc/secubox/toolbox/filters.json.
-- Gerald Kerma <devel@cybermind.fr> Tue, 17 Jun 2026 11:00:00 +0200
secubox-toolbox (2.6.40-1~bookworm1) bookworm; urgency=medium
* fix(postinst): stop clobbering shared parent dir modes (#620). The

View File

@ -22,7 +22,7 @@ DEFAULTS: Dict = {
"ad_ghost": True, # R3+/R4 silent ad/banner/widget ghosting
"ad_ghost_block": True, # 204 known ad/tracker hosts (save bandwidth)
"media_cache": False, # #577 shared media proxy-cache (opt-in)
"stream_inject": False, # #620 stream loader inject (TTFB) — opt-in, phase 2
"stream_inject": True, # #620/#630 stream loader inject (TTFB) — default on
"autolearn": True, # #589 also block auto-learned bad hosts
"ad_ghost_categories": { # cosmetic ghost groups
"ads": True,