Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
d760993
feat(config): protect directive + protected_prefixes plumbing (C1)
vxfemboy Jul 14, 2026
9f9b1a9
feat(rtbh): skip protected prefixes before eligibility (C1 anycast se…
vxfemboy Jul 14, 2026
7b29044
fix(rtbh): manual add of a protected target returns Rejected, not Def…
vxfemboy Jul 14, 2026
6345f27
fix(rtbh): roll back active entry on failed announce; apply_failures …
vxfemboy Jul 14, 2026
27964e8
fix(flowspec): roll back active rule on failed announce; apply_failur…
vxfemboy Jul 14, 2026
b7aea11
fix(xdp): do not mark active on failed map apply; apply_failures metr…
vxfemboy Jul 14, 2026
b2bae52
fix(bgp): gate FlowSpec/IPv6 announces on the peer's negotiated SAFIs…
vxfemboy Jul 14, 2026
173c16f
fix(bgp): gate FlowSpec/IPv6 withdraws on negotiated SAFIs too (C3 fo…
vxfemboy Jul 14, 2026
346f20b
fix(flowspec): re-announce when a re-asserted rule's action changes (C4)
vxfemboy Jul 14, 2026
52a8998
feat(rtbh): cross-plane rate cap on new mitigations (C6)
vxfemboy Jul 14, 2026
7689be0
feat: SIGUSR1 disarm — withdraw-all + record-only + blackwall_armed g…
vxfemboy Jul 15, 2026
322de14
test(lab): armed-mode BIRD gate — protected skip, rate re-announce, n…
vxfemboy Jul 15, 2026
c72d23a
fix(arming): manual add returns Deferred/Rejected when rate-capped/di…
vxfemboy Jul 15, 2026
2be07c1
docs: changelog for M1 arming interlock (C1-C6)
vxfemboy Jul 15, 2026
0fa2333
ci: quote armed lab-gate step names (unquoted colon broke the workflo…
vxfemboy Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,38 @@ jobs:
sudo timeout --kill-after=15s 300s env "PATH=$HOME/.cargo/bin:$PATH" "HOME=$HOME" \
"CARGO_HOME=${CARGO_HOME:-$HOME/.cargo}" "RUSTUP_HOME=${RUSTUP_HOME:-$HOME/.rustup}" \
./target/debug/lab test crates/blackwall-lab/scenarios/flowspec-auto-bird.kdl lab-junit-flowspec-auto.xml
- name: "Run the lab gate (armed: protected skip + rate re-announce)"
# M1 arming interlocks C1/C4 against real BIRD2: a target inside a
# `protect`ed prefix never reaches BIRD's RIB while a normal target
# in the same prefix does, and a FlowSpec rule re-asserted with a
# changed rate re-announces the new action.
timeout-minutes: 6
run: |
trap 'rc=$?; exec 1>&3 2>&4; cat lab-gate.log; exit $rc' EXIT
exec 3>&1 4>&2 >lab-gate.log 2>&1
sudo timeout --kill-after=15s 300s env "PATH=$HOME/.cargo/bin:$PATH" "HOME=$HOME" \
"CARGO_HOME=${CARGO_HOME:-$HOME/.cargo}" "RUSTUP_HOME=${RUSTUP_HOME:-$HOME/.rustup}" \
./target/debug/lab test crates/blackwall-lab/scenarios/armed-protect-reannounce-bird.kdl lab-junit-armed-protect-reannounce.xml
- name: "Run the lab gate (armed: no-reset on un-negotiated SAFI)"
# M1 arming interlock C3 against real BIRD2: a peer that never
# negotiates the FlowSpec SAFI is skipped, not NOTIFICATION-reset.
timeout-minutes: 6
run: |
trap 'rc=$?; exec 1>&3 2>&4; cat lab-gate.log; exit $rc' EXIT
exec 3>&1 4>&2 >lab-gate.log 2>&1
sudo timeout --kill-after=15s 300s env "PATH=$HOME/.cargo/bin:$PATH" "HOME=$HOME" \
"CARGO_HOME=${CARGO_HOME:-$HOME/.cargo}" "RUSTUP_HOME=${RUSTUP_HOME:-$HOME/.rustup}" \
./target/debug/lab test crates/blackwall-lab/scenarios/armed-noneg-flowspec-bird.kdl lab-junit-armed-noneg-flowspec.xml
- name: "Run the lab gate (armed: SIGUSR1 disarm)"
# M1 arming interlock C5 against real BIRD2: SIGUSR1 withdraws every
# active mitigation from BIRD's RIB while the daemon keeps running.
timeout-minutes: 6
run: |
trap 'rc=$?; exec 1>&3 2>&4; cat lab-gate.log; exit $rc' EXIT
exec 3>&1 4>&2 >lab-gate.log 2>&1
sudo timeout --kill-after=15s 300s env "PATH=$HOME/.cargo/bin:$PATH" "HOME=$HOME" \
"CARGO_HOME=${CARGO_HOME:-$HOME/.cargo}" "RUSTUP_HOME=${RUSTUP_HOME:-$HOME/.rustup}" \
./target/debug/lab test crates/blackwall-lab/scenarios/armed-disarm-bird.kdl lab-junit-armed-disarm.xml
- name: Run the lab gate (xdp AF_XDP redirect)
# B3.1: end-to-end AF_XDP redirect on a veth pair (attach xdp_filter, bind
# an AF_XDP socket into XSKS, inject a UDP frame, assert it lands on the
Expand Down Expand Up @@ -302,3 +334,6 @@ jobs:
lab-junit-bird-gen.xml
lab-junit-flowspec.xml
lab-junit-flowspec-auto.xml
lab-junit-armed-protect-reannounce.xml
lab-junit-armed-noneg-flowspec.xml
lab-junit-armed-disarm.xml
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ All notable changes to this project are documented here, following
- nftables rendering bound the `prerouting` filter chain to the managed interface (`type filter hook prerouting … device`), which the kernel rejects — only ingress/egress chains may bind a device — so `blackwall-nft::apply` failed on any real ruleset. The chain is now unbound and classification is scoped per-rule with an `iifname` match, the correct pattern for a prerouting filter chain. Because an unbound chain runs for every interface, the closed posture (`default_state` closed) is now enforced by an explicit interface-scoped terminal `drop` rule instead of a chain-wide drop policy, so it no longer black-holes loopback or other-interface host traffic. Caught by the new deception↔scanner lab gate, the first end-to-end run of `apply` against a real `nft`.

### Added
- M1 arming interlock (AS214806 milestone M1) — the safety guards that make removing the `shadow` directive and letting the mitigation plane act for real *safe*. Six control-plane interlocks: (**C1**) **anycast self-protection** — a new repeatable `protect <prefix>` directive; a target inside a protected prefix is skipped *before* the eligibility check across RTBH, FlowSpec, and the XDP auto-sink, so a flood of your own anycast VIP can never blackhole your own service (a manual `add` of a protected target now returns `Rejected`, not a silent forever-pending `Deferred`). It also applies under `shadow`, so it stops false "would-blackhole-own-VIP" records in the observation window. (**C2**) **confirm-before-active** — the mitigation managers now roll back the in-memory active entry when the BGP announce / eBPF-map write fails, so the control plane never believes it mitigated something the router/kernel didn't take (previously a failed announce left a phantom "active" entry that deduped future detections into an invisible protection gap); surfaced as `blackwall_{rtbh,flowspec,xdp}_apply_failures_total`. (**C3**) **capability-gated OPEN** — FlowSpec/IPv6 announces *and* withdraws are gated on the peer's negotiated AFI/SAFIs, so a peer that never negotiated SAFI 133 no longer NOTIFICATION-resets the session in a loop (skipped + `blackwall_bgp_unnegotiated_announce_skipped_total{safi}`). (**C4**) a re-asserted FlowSpec rule whose action changed (e.g. tightening a rate-limit to a full drop mid-attack) now re-announces instead of silently no-op'ing. (**C6**) a cross-plane **rate cap** (`max-new-per-min` on the `rtbh` directive) bounds the transient blast radius of a detection storm or bug — new mitigations over the ceiling are rejected + counted (`blackwall_mitigations_ratecapped_total{plane}`), live-only. (**C5**) an **in-daemon disarm** on `SIGUSR1` — withdraws every announced route then keeps detecting + recording but applies nothing (record-only), so an operator can instantly stop mitigating without losing the detector; a `blackwall_armed` gauge reads 1 live / 0 shadow / 0 disarmed. All guards are inert under `shadow` (except C1's recording) and every new config field defaults to today's behavior. Proven end-to-end by three armed-mode lab gates against real BIRD 2.17.1 (protected-skip, rate re-announce, no-reset-on-un-negotiated-peer, SIGUSR1 withdraw-all). Arming remains `remove shadow + restart`; RPKI cross-check, the XDP data-plane armed-only bugs, per-plane arming, runtime re-arm, and a control-API disarm endpoint are deferred follow-ons.
- BIRD iBGP-snippet generator (deployment #3): `blackwalld bird-config --config <cfg>` generates BIRD's side of the blackwall↔BIRD iBGP session from blackwall's own config, so prefix lists and session params aren't hand-maintained in both `blackwall.conf` and `bird.conf`. A pure `blackwall_bgp::render_bird_ibgp` emits an `include` file: `OWN_V4/V6` prefix defines plus one MP-BGP `protocol bgp blackwall` session (ipv4/ipv6/flow4/flow6 channels; unicast import filters `net ~ [prefix+]`, flow filters `net.dst ~ [prefix+]`; MD5 → an `include "blackwall-secret.conf";` reference so the secret never lands in the generated file; GTSM → `ttl security on`). A new `rtbh local-addr=` sets blackwall's BGP source, emitted as BIRD's `neighbor` and bound by the speaker as its source so the two sides match by construction. Validated against real **BIRD 2.17.1** by a new `bird-gen` lab gate (the generated include establishes the session and imports both a `/32` blackhole and a FlowSpec rule) plus a `bird -p` parse-check. BIRD stays the fan-out point — blackwall injects once, BIRD re-advertises to every upstream via its existing per-peer filters. Non-breaking.
- Network-wide shadow mode (deployment #9): a global, opt-in `shadow` config directive that makes the mitigation plane log + record + meter every RTBH/FlowSpec/XDP mitigation the daemon *would* apply, **without executing it** — the interlock for running a detection-only deployment live on the security boundary. Shadow decorates the execution boundary only (a `ShadowBgpExecutor` that holds no BGP handle, so it's structurally incapable of announcing; no-op journals keep the mirror empty; the real iBGP session isn't spawned; the XDP map-apply is gated), while detection/selection/controller logic runs identically. Intended actions surface via INFO logs, a `blackwall_shadow_would_mitigate_total{plane,action}` counter, and `audit_log` rows (queryable through `/v1/audit`), with a startup `WARN: SHADOW MODE` banner. Non-breaking (absent `shadow` = live behavior).
- Anycast telemetry ingest (deployment #1/#2, sub-project D): the sFlow agent address (previously parsed then discarded) is now threaded into every `FlowObservation`, so the central `flow` daemon reads feeds from many anycast POPs as one logical view. A `pop <name> agent=<ip> sampling=<n>` config directive maps agents to POP names + expected rates; detections are tagged with the contributing POPs and a top-N attacker source-block (/24 v4, /48 v6) rollup; per-agent liveness and a sampling-sanity clamp (bounded to known agents, closing a spoofed-agent memory-DoS) guard the feed, exposed as `blackwall_flow_{pop_last_seen_seconds,agent_sampling_mismatch_total,unknown_agent_observations_total}`. `blackwalld sensor render-hsflowd` generates each POP's hsflowd.conf from the POP-map. Non-breaking (no `pop` block = per-victim detection as before, everything tagged `unknown`).
Expand Down
Loading
Loading