diff --git a/packages/secubox-toolbox/debian/changelog b/packages/secubox-toolbox/debian/changelog index e853adad..918bd7ef 100644 --- a/packages/secubox-toolbox/debian/changelog +++ b/packages/secubox-toolbox/debian/changelog @@ -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 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 diff --git a/packages/secubox-toolbox/debian/postinst b/packages/secubox-toolbox/debian/postinst index 12f31905..59e7ef7f 100755 --- a/packages/secubox-toolbox/debian/postinst +++ b/packages/secubox-toolbox/debian/postinst @@ -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. diff --git a/packages/secubox-toolbox/debian/rules b/packages/secubox-toolbox/debian/rules index 6070122e..b947ed3b 100755 --- a/packages/secubox-toolbox/debian/rules +++ b/packages/secubox-toolbox/debian/rules @@ -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 \ diff --git a/packages/secubox-toolbox/nftables.d/secubox-blacklist.nft b/packages/secubox-toolbox/nftables.d/secubox-blacklist.nft new file mode 100644 index 00000000..188b528c --- /dev/null +++ b/packages/secubox-toolbox/nftables.d/secubox-blacklist.nft @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# +# 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 + } +} diff --git a/packages/secubox-toolbox/sbin/secubox-blacklist-sync b/packages/secubox-toolbox/sbin/secubox-blacklist-sync new file mode 100644 index 00000000..dc63c84e --- /dev/null +++ b/packages/secubox-toolbox/sbin/secubox-blacklist-sync @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# +# 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 diff --git a/packages/secubox-toolbox/secubox_toolbox/api.py b/packages/secubox-toolbox/secubox_toolbox/api.py index b1693edd..2db1224a 100644 --- a/packages/secubox-toolbox/secubox_toolbox/api.py +++ b/packages/secubox-toolbox/secubox_toolbox/api.py @@ -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. diff --git a/packages/secubox-toolbox/systemd/secubox-blacklist-sync.service b/packages/secubox-toolbox/systemd/secubox-blacklist-sync.service new file mode 100644 index 00000000..1465a472 --- /dev/null +++ b/packages/secubox-toolbox/systemd/secubox-blacklist-sync.service @@ -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 diff --git a/packages/secubox-toolbox/systemd/secubox-blacklist-sync.timer b/packages/secubox-toolbox/systemd/secubox-blacklist-sync.timer new file mode 100644 index 00000000..d67dbf16 --- /dev/null +++ b/packages/secubox-toolbox/systemd/secubox-blacklist-sync.timer @@ -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