feat(waf-ng): reconcile to a single HARDENED non-root unit; retire worker@ fan-out (ref #896)
Some checks failed
License Headers / check (push) Has been cancelled

The board ran a hand-created unhardened ROOT sbxwaf on :8085 while the package
shipped only a worker@ fan-out that crash-looped (panic on the root-only
cookie-audit log) and, as secubox-waf + RuntimeDirectory=secubox, re-chowned the
shared /run/secubox on every crash-restart — breaking every secubox-user socket
bind (profiles 502s). Ship ONE hardened unit (secubox-waf-ng.service, :8085,
User=secubox-waf, full sandbox, NO RuntimeDirectory — sbxwaf only connects to
waker.sock). postinst asserts the perms a non-root sbxwaf needs
(haproxy-routes.json 0644, cookie-audit ledger writable by secubox-waf),
disables leftover worker@ units, drops the /etc override. Validated live on gk2:
264 routes, real routing, admin/gitea/billets/yacy 200; a WAF restart no longer
touches /run/secubox and profiles stays up.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-21 07:28:46 +02:00
parent 7b8fd5f1e3
commit bd8e2e382e
4 changed files with 151 additions and 32 deletions

View File

@ -1,3 +1,18 @@
secubox-waf-ng (1.1.0-1~bookworm1) bookworm; urgency=medium
* #896 WAF-drift reconcile: ship a SINGLE HARDENED unit (secubox-waf-ng.service,
:8085, User=secubox-waf, full sandbox) that replaces BOTH the crash-looping
worker@ fan-out template (retired — it panicked on the root-only cookie-audit
log and, via RuntimeDirectory=secubox as secubox-waf, re-chowned the shared
/run/secubox and broke every secubox-user socket bind) AND the hand-created
unhardened root :8085 override the board was running.
* NO RuntimeDirectory=secubox (sbxwaf only CONNECTS to waker.sock) → kills the
re-chown landmine. postinst asserts perms a non-root sbxwaf needs
(haproxy-routes.json 0644, cookie-audit ledger writable by secubox-waf),
disables leftover worker@ units, and drops the /etc override.
-- Gerald KERMA <devel@cybermind.fr> Tue, 21 Jul 2026 10:00:00 +0200
secubox-waf-ng (1.0.0-1~bookworm1) bookworm; urgency=medium
* Initial packaging of the Go WAF reverse-proxy engine (#744 Task 8.1).

View File

@ -59,6 +59,20 @@ case "$1" in
mkdir -p /var/log/secubox/cookie-audit
chown secubox-waf:secubox-waf /var/log/secubox/cookie-audit
chmod 0750 /var/log/secubox/cookie-audit
# The ledger FILE must be writable by the non-root sbxwaf that appends to it.
# A pre-existing file owned by another user (e.g. secubox from an old addon)
# makes sbxwaf panic on open → crash-loop (#896 reconcile). Chown if present.
if [ -e /var/log/secubox/cookie-audit/server.jsonl ]; then
chown secubox-waf:secubox-waf /var/log/secubox/cookie-audit/server.jsonl || true
fi
# haproxy-routes.json must be READABLE by the non-root sbxwaf (#896 reconcile):
# root-only 0600 makes it load 0 routes → 421 everything. Routes are
# [host,port] pairs, not secret → 0644 (the norm; the profiles actuator
# preserves this mode on its atomic writes).
if [ -e /etc/secubox/waf/haproxy-routes.json ]; then
chmod 0644 /etc/secubox/waf/haproxy-routes.json || true
fi
# ── 4. Cache directories ──────────────────────────────────────────────────
# Same shared-parent pattern: /var/cache/secubox must stay 0755.
@ -81,32 +95,32 @@ case "$1" in
aa-enforce /etc/apparmor.d/usr.sbin.sbxwaf 2>/dev/null || true
fi
# ── 6. Systemd: enable and start workers ─────────────────────────────────
# ── 6. Systemd: single hardened unit (#896 reconcile) ────────────────────
# The crash-looping worker@ fan-out is retired. Retire any leftover worker@
# instances, drop the hand-created /etc override that shadowed the packaged
# unit, then enable + (re)start the single hardened secubox-waf-ng.service.
if [ -d /run/systemd/system ]; then
# Disable + stop any surviving worker@ instances (they re-chowned the
# shared /run/secubox and broke sibling sockets — see the unit header).
for unit in secubox-waf-ng-worker@1.service secubox-waf-ng-worker@2.service; do
systemctl disable --now "$unit" 2>/dev/null || true
done
# A previous cutover left a hand-created override at
# /etc/systemd/system/secubox-waf-ng.service that shadows this packaged
# /lib unit. Remove it so the packaged (hardened) unit takes effect. Only
# a plain file (not a symlink an operator may have set deliberately).
if [ -f /etc/systemd/system/secubox-waf-ng.service ] && \
[ ! -L /etc/systemd/system/secubox-waf-ng.service ]; then
rm -f /etc/systemd/system/secubox-waf-ng.service
fi
systemctl daemon-reload || true
# Enable + start both worker instances.
# Guard: only when dpkg is not in --simulate / no-systemd build env.
for instance in 1 2; do
unit="secubox-waf-ng-worker@${instance}.service"
systemctl enable "$unit" 2>/dev/null || true
systemctl start "$unit" 2>/dev/null || true
done
fi
# ── 7. On UPGRADE: restart any running enabled workers ───────────────────
# dpkg upgrade SIGTERMs the units BEFORE this postinst runs, so
# `try-restart` (a no-op on a stopped unit) would leave workers DEAD.
# Mirror the toolbox postinst fix: ENABLED units get a full `restart`.
if [ -n "${2:-}" ] && [ -d /run/systemd/system ]; then
for unit in secubox-waf-ng-worker@1.service \
secubox-waf-ng-worker@2.service; do
if systemctl is-enabled --quiet "$unit" 2>/dev/null; then
systemctl restart "$unit" 2>/dev/null || true
else
systemctl try-restart "$unit" 2>/dev/null || true
fi
done
systemctl enable secubox-waf-ng.service 2>/dev/null || true
# Fresh install starts it; upgrade restarts it (dpkg SIGTERMs the unit
# before this postinst runs, so `restart` — not `try-restart` — is needed
# to bring it back). HAProxy srv0 → 127.0.0.1:8085 keeps pointing at it.
systemctl restart secubox-waf-ng.service 2>/dev/null || true
fi
;;

View File

@ -52,16 +52,14 @@ override_dh_auto_clean:
rm -rf _gocache _gopath
override_dh_installsystemd:
# Install the worker template unit manually (dh_installsystemd expects the
# unit file at debian/<package>.<unitname>.service or debian/<unitname>.service;
# ours lives in systemd/ so we install it explicitly, mirroring the toolbox
# package pattern for override_dh_installsystemd).
# #896 reconcile: ship the SINGLE hardened unit (secubox-waf-ng.service,
# :8085) — it replaces the crash-looping worker@ fan-out template, which is
# NO LONGER installed (a reinstall must not resurrect the re-chown landmine;
# the postinst disables + removes any leftover worker@ units on the board).
install -d debian/secubox-waf-ng/lib/systemd/system
install -m 0644 systemd/secubox-waf-ng-worker@.service \
install -m 0644 systemd/secubox-waf-ng.service \
debian/secubox-waf-ng/lib/systemd/system/
# Let dh_installsystemd generate the enable helpers for @1 and @2.
# The postinst's `systemctl enable --now` handles activation.
dh_installsystemd --name=secubox-waf-ng-worker@
dh_installsystemd --name=secubox-waf-ng
execute_after_dh_auto_install:
install -d debian/secubox-waf-ng/usr/sbin

View File

@ -0,0 +1,92 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# SecuBox-Deb :: secubox-waf-ng — Go WAF single reverse-proxy unit (#896 reconcile)
#
# Reconcile of the WAF-ng drift (2026-07-21): the board ran a HAND-CREATED,
# UNHARDENED root unit on :8085 (HAProxy srv0 -> 127.0.0.1:8085) while the
# package shipped only a worker@ fan-out template that crash-looped (it panicked
# on the cookie-audit log — writable only by root — and, running as secubox-waf
# with RuntimeDirectory=secubox, each crash-restart re-chowned the SHARED
# /run/secubox, breaking every secubox-user service's socket bind). This single
# unit REPLACES both: one hardened, non-root sbxwaf on :8085, reproducible from
# source. See docs / issue #896.
#
# KEY DIFFERENCES from the (retired) worker@ template:
# * single instance on :8085 (matches the live HAProxy backend), NOT an
# nft-DNAT fan-out to :8081/:8082 (that never actually ran);
# * single --vhost-signals file (secubox-sleeper's reader falls back to the
# legacy non-@ path);
# * NO RuntimeDirectory=secubox — sbxwaf only CONNECTS to the waker socket
# (/run/secubox/waker.sock) as a client; it creates nothing under
# /run/secubox, so it must not take ownership of the shared dir. /run/secubox
# stays in ReadWritePaths so the client connect works under ProtectSystem.
#
# PREREQUISITES the postinst asserts (so a non-root sbxwaf can read them):
# * /etc/secubox/waf/haproxy-routes.json readable (0644 — [host,port] pairs,
# not secret); root-only 0600 makes sbxwaf load 0 routes -> 421 everything;
# * /var/log/secubox/cookie-audit/server.jsonl writable by secubox-waf.
[Unit]
Description=SecuBox WAF-NG Go reverse-proxy (single instance, port 8085)
Documentation=https://github.com/CyberMind-FR/secubox-deb/issues/896
After=network.target
ConditionPathExists=/usr/sbin/sbxwaf
[Service]
Type=simple
User=secubox-waf
Group=secubox-waf
ExecStart=/usr/sbin/sbxwaf \
--listen 127.0.0.1:8085 \
--routes /etc/secubox/waf/haproxy-routes.json \
--rules /etc/secubox/waf/waf-rules.json \
--on-demand-vhosts /etc/secubox/waf/on-demand-vhosts.json \
--vhost-signals /var/cache/secubox/waf/vhost-signals.json \
--threat-log /var/log/secubox/waf/waf-threats.log \
--cookie-audit-log /var/log/secubox/cookie-audit/server.jsonl \
--media-cache-dir /var/cache/secubox/waf/media \
--crowdsec-url http://10.100.0.1:8080
# --crowdsec-url is the real CrowdSec LAPI (LXC-bridge gateway 10.100.0.1:8080),
# NOT 127.0.0.1:8080 (the nft DNAT VIP that would self-loop into this proxy).
Restart=on-failure
RestartSec=5
# ── Hardening ─────────────────────────────────────────────────────────────────
# Same posture as the worker@ template (secubox-mesh convention, §6 CSPN), minus
# RuntimeDirectory (see header). /run/secubox is READ-WRITE so the client connect
# to waker.sock works under ProtectSystem=strict.
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ReadOnlyPaths=/etc/secubox
ReadWritePaths=/var/log/secubox/waf /var/log/secubox/cookie-audit /var/cache/secubox/waf /run/secubox
CapabilityBoundingSet=
AmbientCapabilities=
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
RestrictNamespaces=yes
LockPersonality=yes
RestrictSUIDSGID=yes
RestrictRealtime=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
MemoryDenyWriteExecute=yes
MemoryHigh=256M
MemoryMax=384M
TasksMax=512
RuntimeMaxSec=12h
[Install]
WantedBy=multi-user.target