Compare commits

...

5 Commits

Author SHA1 Message Date
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
5 changed files with 120 additions and 21 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

@ -6,7 +6,20 @@ secubox-haproxy (1.3.1-1~bookworm1) bookworm; urgency=medium
with live status + manual retry. {400,403,408,500}.http: branded static. with live status + manual retry. {400,403,408,500}.http: branded static.
- haproxyctl generator: wire errorfile directives (durable across regen) - haproxyctl generator: wire errorfile directives (durable across regen)
and persist retries 3 + option redispatch in defaults. and persist retries 3 + option redispatch in defaults.
- Ship pages to /etc/haproxy/errors/; postinst regen-safe applies them. - 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 -- Gerald Kerma <devel@cybermind.fr> Tue, 17 Jun 2026 09:00:00 +0200

View File

@ -4,7 +4,12 @@ case "$1" in
configure) configure)
id -u secubox >/dev/null 2>&1 || \ id -u secubox >/dev/null 2>&1 || \
adduser --system --group --no-create-home --home /var/lib/secubox --shell /usr/sbin/nologin secubox 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) # Create /etc/haproxy if not present (haproxy is Recommends, not Depends)
# Required for systemd namespace setup # Required for systemd namespace setup
install -d -m 755 /etc/haproxy install -d -m 755 /etc/haproxy

View File

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

View File

@ -626,13 +626,13 @@ defaults
option dontlognull option dontlognull
option forwardfor option forwardfor
# Smart self-healing error pages (#626) — shipped by secubox-haproxy. # Smart self-healing error pages (#626) — shipped by secubox-haproxy.
errorfile 400 /etc/haproxy/errors/400.http errorfile 400 /etc/haproxy/secubox-errors/400.http
errorfile 403 /etc/haproxy/errors/403.http errorfile 403 /etc/haproxy/secubox-errors/403.http
errorfile 408 /etc/haproxy/errors/408.http errorfile 408 /etc/haproxy/secubox-errors/408.http
errorfile 500 /etc/haproxy/errors/500.http errorfile 500 /etc/haproxy/secubox-errors/500.http
errorfile 502 /etc/haproxy/errors/502.http errorfile 502 /etc/haproxy/secubox-errors/502.http
errorfile 503 /etc/haproxy/errors/503.http errorfile 503 /etc/haproxy/secubox-errors/503.http
errorfile 504 /etc/haproxy/errors/504.http errorfile 504 /etc/haproxy/secubox-errors/504.http
frontend stats frontend stats
bind *:${stats_port} bind *:${stats_port}
@ -671,16 +671,18 @@ EOF
local backend=$(echo "$section" | grep -E '^backend\s*=' | cut -d'"' -f2) 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") 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} # 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" echo " acl host_$name hdr(host) -i $domain"
if [ "$waf_enabled" = "1" ]; then if [ "$waf_enabled" = "1" ]; then
echo " use_backend mitmproxy_inspector if host_$name" echo " use_backend mitmproxy_inspector if host_$name"
else else
echo " use_backend $backend if host_$name" echo " use_backend $backend if host_$name"
fi fi
} fi
done >> "$out" done >> "$out"
fi fi
@ -719,16 +721,17 @@ EOF
local ssl=$(echo "$section" | grep -E '^ssl\s*=' | grep -q 'true' && echo "1" || echo "0") 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") 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} # 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" echo " acl host_$name hdr(host) -i $domain"
if [ "$waf_enabled" = "1" ]; then if [ "$waf_enabled" = "1" ]; then
echo " use_backend mitmproxy_inspector if host_$name" echo " use_backend mitmproxy_inspector if host_$name"
else else
echo " use_backend $backend if host_$name" echo " use_backend $backend if host_$name"
fi fi
} fi
done >> "$out" done >> "$out"
fi fi
@ -738,7 +741,8 @@ EOF
EOF EOF
# WAF inspector backend (mitmproxy LXC container) # 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) # WAF Inspector Backend (mitmproxy LXC at $waf_ip:$waf_port)
backend mitmproxy_inspector backend mitmproxy_inspector
mode http mode http
@ -748,6 +752,7 @@ backend mitmproxy_inspector
server waf ${waf_ip}:${waf_port} check server waf ${waf_ip}:${waf_port} check
EOF EOF
fi
# WebUI direct backend (issue #44 — for the strict-regex ACL above) # 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). # Only emit if SECUBOX_HOSTNAME is set (i.e., the strict ACL was injected).
@ -763,6 +768,9 @@ EOF
if [ -f "$CONF_PATH" ]; then if [ -f "$CONF_PATH" ]; then
grep '^\[backends\.' "$CONF_PATH" | while read -r line; do grep '^\[backends\.' "$CONF_PATH" | while read -r line; do
local name=$(echo "$line" | sed 's/\[backends\.//;s/\]//') 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 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 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") local balance=$(echo "$section" | grep -E '^balance\s*=' | cut -d'"' -f2 || echo "roundrobin")
@ -775,11 +783,11 @@ backend $name
EOF EOF
local i=0 local i=0
echo "$servers" | while read -r srv; do echo "$servers" | while read -r srv; do
[ -n "$srv" ] && { if [ -n "$srv" ]; then
srv=$(echo "$srv" | tr -d ' ') srv=$(echo "$srv" | tr -d ' ')
echo " server srv$i $srv check" >> "$out" echo " server srv$i $srv check" >> "$out"
i=$((i + 1)) i=$((i + 1))
} fi
done done
echo "" >> "$out" echo "" >> "$out"
done done
@ -834,6 +842,22 @@ EOF
# to the live cfg and only validated at the end, leaving the file in a # 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). # broken state on failure (recurring "broken-by-vhost-add" backups).
if haproxy -c -f "$out" 2>/dev/null; then 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" install -m 0644 -o root -g root "$out" "$CONFIG_DIR/haproxy.cfg"
rm -f "$out" rm -f "$out"
log "Configuration generated, validated and installed: $CONFIG_DIR/haproxy.cfg" log "Configuration generated, validated and installed: $CONFIG_DIR/haproxy.cfg"