feat(toolbox): Phase 13.A — unified nft blacklist enforcement spine (ref #521)

First track of the protection enforcement plane (#519). Pure netfilter,
additive drops, DEFAULT-DROP doctrine preserved.

  - nftables.d/secubox-blacklist.nft: table inet secubox_blacklist with
    blacklist_v4/v6 sets (interval,timeout) + a single forward-hook
    chain (priority -10, policy accept) counter-dropping any routed
    packet whose saddr OR daddr is blacklisted. One rule set covers
    every device egress path (captive/WG/br-lxc/LAN), no per-iface logic.
  - sbin/secubox-blacklist-sync: unions CrowdSec ban decisions (cscli)
    + threat-intel C2 IPs (threat_intel ip IOCs) into the sets, 2h
    per-element timeout (auto-expire on stale feeds), 50k cap, idempotent
    add (no flush race with CrowdSec's own table).
  - systemd sync .service + .timer (5min + OnBootSec 90s).
  - postinst: install drop-in, reload nft, enable+start timer, first sync.
  - api.py GET /admin/blacklist: element counts + drop counters (nft -j).

  - debian/rules FIX: moved the whole override_dh_strip body into
    execute_after_dh_auto_install. dh_strip is NOT in the binary-indep
    sequence for an Architecture: all package, so override_dh_strip
    silently never ran — the nft drop-ins / unbound / nginx / perf
    drop-ins (and now the blacklist files) had stopped shipping in the
    .deb and gk2 was running stale on-disk copies (the live-config-drift
    we kept hitting). Now they all ship again.

  - changelog 2.6.8.

Live on gk2: table loaded, first sync populated 18 v4 C2 IPs with 2h
timeouts, enforce chain active (4 drop rules, counters wired), timer
enabled, /admin/blacklist returns active:true.
This commit is contained in:
CyberMind-FR 2026-06-11 07:16:35 +02:00
parent febf58fd27
commit a5c07c3d50
8 changed files with 281 additions and 2 deletions

View File

@ -1,3 +1,31 @@
secubox-toolbox (2.6.8-1~bookworm1) bookworm; urgency=medium
* Phase 13.A (#521, parent #519) — unified blacklist enforcement spine.
- nftables.d/secubox-blacklist.nft : table inet secubox_blacklist
with blacklist_v4 / blacklist_v6 sets (flags interval,timeout)
+ a single forward-hook chain (priority -10, policy accept) that
counter-drops any routed packet whose saddr OR daddr is
blacklisted. One rule set covers every device egress path
(captive / R3 WG / br-lxc / LAN) — no per-interface logic.
Loaded by nftables.service at boot ; survives reboot.
- sbin/secubox-blacklist-sync : unions CrowdSec ban decisions
(cscli) + threat-intel C2 IPs (threat_intel ip IOCs from
toolbox.db) into the sets, per-element timeout 2h (auto-expire
on stale feeds), 50k safety cap, idempotent add (no flush race
with CrowdSec's own table).
- systemd/secubox-blacklist-sync.{service,timer} : oneshot +
every-5-min timer (OnBootSec 90s).
- postinst : install drop-in to /etc/nftables.d/, reload nft,
enable+start the timer, kick a first sync.
- api.py : GET /admin/blacklist — element counts + drop counters
from nft -j (read-only status).
Doctrine preserved : policy accept only ADDS drops (a drop in any
chain wins) — never a WAF/default-drop bypass. Detection sources are
high-confidence only (CrowdSec bans + threat-intel C2) ; WAF-ban +
Vortex-resolved-IP feeds land in 13.B/13.D.
-- Gerald KERMA <devel@cybermind.fr> Thu, 11 Jun 2026 09:00:00 +0200
secubox-toolbox (2.6.7-1~bookworm1) bookworm; urgency=medium
* Phase 12.C (#518, parent #514) — operator-grade / state-adjacent

View File

@ -151,6 +151,26 @@ fi
fi
fi
# Phase 13.A (#521) : unified blacklist enforcement spine.
# nft drop-in (loaded by nftables.service at boot) + sync timer that
# unions CrowdSec decisions + threat-intel C2 IPs into the sets.
if [ -f /usr/share/secubox/toolbox/nftables.d/secubox-blacklist.nft ]; then
install -d -m 0755 /etc/nftables.d
install -m 0644 /usr/share/secubox/toolbox/nftables.d/secubox-blacklist.nft \
/etc/nftables.d/secubox-blacklist.nft
if systemctl is-active --quiet nftables.service 2>/dev/null; then
systemctl reload nftables.service 2>/dev/null \
|| /usr/sbin/nft -f /etc/nftables.d/secubox-blacklist.nft 2>/dev/null \
|| true
fi
if [ -d /run/systemd/system ]; then
systemctl enable secubox-blacklist-sync.timer 2>/dev/null || true
systemctl start secubox-blacklist-sync.timer 2>/dev/null || true
# Kick an immediate first sync (best-effort).
systemctl start secubox-blacklist-sync.service 2>/dev/null || true
fi
fi
# Phase 7 (#498) : install unbound listeners.
# 99-secubox-wg.conf — WG peers (10.99.x). Without it WG peers' DNS
# queries get ICMP port unreachable and the iPhone stops resolving.

View File

@ -54,8 +54,14 @@ override_dh_installsystemd:
override_dh_auto_test:
@true
override_dh_strip:
@true
# NOTE: dh_strip is NOT invoked in the binary-indep sequence (this is an
# Architecture: all package with no binaries to strip), so an
# override_dh_strip target would silently never run. Everything that
# used to live there is installed from execute_after_dh_auto_install
# below — which DOES run — so nft drop-ins / unbound / nginx / perf
# drop-ins / blacklist spine actually ship in the .deb (#521 caught the
# stale-on-disk drift this caused).
execute_after_dh_auto_install:
# Phase 6.Q (#496) : DB tuning helper + uvicorn perf drop-in
install -m 755 sbin/secubox-toolbox-db-tune \
debian/secubox-toolbox/usr/sbin/
@ -73,6 +79,15 @@ override_dh_strip:
# by symlinking /etc/nftables.d/secubox-toolbox-wg.nft → this file.
install -m 0644 nftables.d/secubox-toolbox-wg-fanout.nft \
debian/secubox-toolbox/usr/share/secubox/toolbox/nftables.d/
# Phase 13.A (#521) : unified blacklist enforcement spine.
install -m 0644 nftables.d/secubox-blacklist.nft \
debian/secubox-toolbox/usr/share/secubox/toolbox/nftables.d/
install -m 0755 sbin/secubox-blacklist-sync \
debian/secubox-toolbox/usr/sbin/
install -m 0644 systemd/secubox-blacklist-sync.service \
debian/secubox-toolbox/lib/systemd/system/
install -m 0644 systemd/secubox-blacklist-sync.timer \
debian/secubox-toolbox/lib/systemd/system/
install -m 0755 sbin/secubox-toolbox-wg-restore \
debian/secubox-toolbox/usr/sbin/
install -m 0644 systemd/secubox-toolbox-wg-restore.service \

View File

@ -0,0 +1,40 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
#
# Phase 13.A (#521) — unified blacklist enforcement spine.
#
# One authoritative pair of sets (v4/v6) fed by secubox-blacklist-sync
# from ALL ban sources (CrowdSec decisions + threat-intel C2 IPs ;
# WAF-ban + Vortex-resolved feeds land in 13.B/13.D). A single
# forward-hook chain drops any ROUTED packet whose source OR destination
# is blacklisted — so it covers every device egress path at once
# (captive 10.99.0.0/24, R3 WG wg-toolbox 10.99.1.0/24, br-lxc, LAN)
# with no per-interface logic.
#
# Doctrine : policy accept here only ADDS drops ; it never bypasses the
# main inet filter default-drop (a drop in any chain wins regardless of
# another chain's accept policy). Loaded by nftables.service at boot
# via /etc/nftables.d/*.nft so it survives reboot (#498/#501 pattern).
#
# The sets are `interval` (CIDR support) + `timeout` (elements auto-expire
# if the sync stops re-adding them — fail-open on stale data, never a
# permanent black hole from a one-off bad feed).
table inet secubox_blacklist {
set blacklist_v4 {
type ipv4_addr
flags interval, timeout
}
set blacklist_v6 {
type ipv6_addr
flags interval, timeout
}
chain enforce {
type filter hook forward priority -10; policy accept;
ip daddr @blacklist_v4 counter drop
ip saddr @blacklist_v4 counter drop
ip6 daddr @blacklist_v6 counter drop
ip6 saddr @blacklist_v6 counter drop
}
}

View File

@ -0,0 +1,98 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
#
# SecuBox-Deb :: secubox-blacklist-sync
#
# Phase 13.A (#521) — union all ban sources into the nft enforcement
# sets (inet secubox_blacklist blacklist_v4 / blacklist_v6). Idempotent,
# safe to run on a timer. Each element gets a timeout so stale entries
# auto-expire if a source stops listing them.
set -euo pipefail
readonly MODULE="secubox-blacklist-sync"
readonly VERSION="13.A"
NFT=/usr/sbin/nft
TABLE="inet secubox_blacklist"
TOOLBOX_DB=/var/lib/secubox/toolbox/toolbox.db
ELEM_TIMEOUT="${SECUBOX_BL_TIMEOUT:-2h}" # auto-expiry per element
# Safety cap : never load more than this many IPs (a runaway feed
# shouldn't be able to black-hole the box).
MAX_ELEMS="${SECUBOX_BL_MAX:-50000}"
log() { logger -t "$MODULE" -- "$*" 2>/dev/null || echo "[$MODULE] $*" >&2; }
# The nft table must already exist (loaded by nftables.service from the
# drop-in). If it doesn't, load the drop-in once.
if ! $NFT list table $TABLE >/dev/null 2>&1; then
if [ -r /etc/nftables.d/secubox-blacklist.nft ]; then
$NFT -f /etc/nftables.d/secubox-blacklist.nft || {
log "ERROR: table missing and drop-in load failed"; exit 1; }
else
log "ERROR: table $TABLE missing and no drop-in to load"; exit 1
fi
fi
# ── Collect IPs from all sources into temp files (v4 / v6) ──
TMP4=$(mktemp); TMP6=$(mktemp)
trap 'rm -f "$TMP4" "$TMP6"' EXIT
# Source 1 : threat-intel C2 IPs (feodo / threatfox / sslbl) from the
# toolbox SQLite. ioc_type='ip'.
if [ -r "$TOOLBOX_DB" ] && command -v sqlite3 >/dev/null 2>&1; then
sqlite3 "$TOOLBOX_DB" \
"SELECT DISTINCT ioc FROM threat_intel WHERE ioc_type='ip';" \
2>/dev/null >> "$TMP4.raw" || true
fi
# Source 2 : CrowdSec decisions (ban scope=Ip). JSON via cscli.
if command -v cscli >/dev/null 2>&1; then
cscli decisions list -o json 2>/dev/null \
| { command -v jq >/dev/null 2>&1 \
&& jq -r '.[] | select(.decisions != null) | .decisions[] | select(.type=="ban") | .value' 2>/dev/null \
|| sed -n 's/.*"value":"\([0-9a-fA-F:.]*\)".*/\1/p'; } \
>> "$TMP4.raw" || true
fi
# Split v4 / v6, validate, dedup, cap.
if [ -f "$TMP4.raw" ]; then
# IPv6 = contains a colon ; IPv4 = dotted quad (optionally /CIDR).
grep -E ':' "$TMP4.raw" 2>/dev/null | grep -vE '^\s*$' | sort -u > "$TMP6" || true
grep -vE ':' "$TMP4.raw" 2>/dev/null \
| grep -E '^([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]{1,2})?$' \
| sort -u > "$TMP4" || true
rm -f "$TMP4.raw"
fi
n4=$(wc -l < "$TMP4" 2>/dev/null || echo 0)
n6=$(wc -l < "$TMP6" 2>/dev/null || echo 0)
if [ "$n4" -gt "$MAX_ELEMS" ]; then
log "WARN: $n4 v4 IPs exceeds cap $MAX_ELEMS — truncating"
head -n "$MAX_ELEMS" "$TMP4" > "$TMP4.cap" && mv "$TMP4.cap" "$TMP4"
n4=$MAX_ELEMS
fi
# ── Push into the nft sets (batched add-element with per-elem timeout) ──
# We don't flush : timeouts handle expiry, and re-adding refreshes the
# timeout on still-active entries. Batching keeps it to one nft call.
add_batch() {
local set="$1" file="$2" fam="$3"
[ -s "$file" ] || return 0
local elems
elems=$(awk -v t="$ELEM_TIMEOUT" 'NF{printf "%s timeout %s, ", $1, t}' "$file")
elems="${elems%, }"
[ -n "$elems" ] || return 0
# add (not flush+add) so concurrent CrowdSec table updates don't race.
$NFT add element $TABLE "$set" "{ $elems }" 2>/dev/null \
|| log "WARN: partial add to $set ($fam)"
}
add_batch blacklist_v4 "$TMP4" v4
add_batch blacklist_v6 "$TMP6" v6
# ── Report ──
live4=$($NFT list set $TABLE blacklist_v4 2>/dev/null | grep -c 'timeout' || echo 0)
live6=$($NFT list set $TABLE blacklist_v6 2>/dev/null | grep -c 'timeout' || echo 0)
log "synced: +${n4} v4 / +${n6} v6 (live ~${live4} v4 / ~${live6} v6, timeout ${ELEM_TIMEOUT})"
exit 0

View File

@ -2085,6 +2085,48 @@ async def admin_social_aggregate(hours: int = 24) -> dict:
return _s.aggregate(hours=hours)
@router.get("/admin/blacklist")
async def admin_blacklist() -> dict:
"""Phase 13.A (#521) — enforcement-spine status : element counts +
drop counters of the unified nft blacklist sets. Read-only ; parses
`nft -j list table inet secubox_blacklist`.
"""
import json as _json
import subprocess as _sp
out: dict = {
"active": False,
"v4_count": 0,
"v6_count": 0,
"drops": 0,
"sources": ["crowdsec", "threat-intel"],
}
try:
r = _sp.run(
["/usr/sbin/nft", "-j", "list", "table", "inet", "secubox_blacklist"],
capture_output=True, text=True, timeout=5,
)
if r.returncode != 0:
return out
data = _json.loads(r.stdout or "{}")
for item in data.get("nftables", []):
if "set" in item:
s = item["set"]
n = len(s.get("elem", []) or [])
if s.get("name") == "blacklist_v4":
out["v4_count"] = n
elif s.get("name") == "blacklist_v6":
out["v6_count"] = n
if "rule" in item:
for ex in item["rule"].get("expr", []):
c = ex.get("counter")
if c:
out["drops"] += int(c.get("packets", 0) or 0)
out["active"] = True
except Exception as e: # noqa: BLE001
log.warning("admin_blacklist failed: %s", e)
return out
@router.get("/social/report/{token}.pdf")
async def social_report_pdf(token: str) -> Response:
"""Phase 11.C (#508) — bilingual FR/EN evidence PDF for a peer.

View File

@ -0,0 +1,20 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Phase 13.A (#521) — union ban sources into the nft blacklist sets.
[Unit]
Description=SecuBox blacklist enforcement sync (CrowdSec + threat-intel -> nft sets)
Documentation=https://github.com/CyberMind-FR/secubox-deb/issues/521
After=nftables.service secubox-toolbox.service
Wants=nftables.service
[Service]
Type=oneshot
ExecStart=/usr/sbin/secubox-blacklist-sync
# Needs CAP_NET_ADMIN for nft set writes ; root is simplest + the script
# is read-only against the toolbox DB + cscli.
User=root
Nice=10
IOSchedulingClass=idle
TimeoutStartSec=120
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,16 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Phase 13.A (#521) — periodic blacklist sync.
[Unit]
Description=SecuBox blacklist sync timer (every 5 min + boot)
Documentation=https://github.com/CyberMind-FR/secubox-deb/issues/521
[Timer]
# Run shortly after boot, then every 5 minutes. The element timeout
# (2h) is well above the period so active entries never lapse.
OnBootSec=90s
OnUnitActiveSec=5min
AccuracySec=30s
Persistent=true
[Install]
WantedBy=timers.target