mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 15:37:03 +00:00
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:
parent
febf58fd27
commit
a5c07c3d50
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
40
packages/secubox-toolbox/nftables.d/secubox-blacklist.nft
Normal file
40
packages/secubox-toolbox/nftables.d/secubox-blacklist.nft
Normal 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
|
||||
}
|
||||
}
|
||||
98
packages/secubox-toolbox/sbin/secubox-blacklist-sync
Normal file
98
packages/secubox-toolbox/sbin/secubox-blacklist-sync
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue
Block a user