From 176ed22ff35e3480b2033a639b8c2df5ddd9336c Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Thu, 16 Jul 2026 00:04:46 -0400 Subject: [PATCH 1/2] fix(arming): unblock M1 field-report arming blockers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from the 2026-07-15 box field report, each of which silently broke a documented M1 arming step: - bird-config: gate the `define OWN_V4/V6` lines behind `--with-defines` (default off). A real bird.conf already declares them; BIRD rejects a duplicate `define`, so `birdc configure` refused the whole include and the iBGP session never installed. The generated session does not reference the defines (import filters use inline prefix-sets), so omitting them is free. - plane add in shadow: refuse `rtbh add` / `flowspec add` / `xdp block` when the config carries `shadow`. In shadow the daemon never announces the intent, but the row stays `pending` and fires on the first tick after arming — a delayed, unattended mitigation. New `reject_add_in_shadow` guard. - flow control API: spawn serve_api on the flow daemon path so `api listen=` under a `flow` block actually serves /v1/audit, instead of being inert and leaving the shadow would-mitigate set reviewable only by direct SQL. Real-BIRD lab validation now covers the shipping default (defines off). Adds unit coverage for the defines-off render and the shadow guard. --- bin/blackwalld/src/main.rs | 83 ++++++++++++++++++++- crates/blackwall-bgp/src/render.rs | 73 +++++++++++++----- crates/blackwall-bgp/tests/bird_validate.rs | 12 +-- 3 files changed, 143 insertions(+), 25 deletions(-) diff --git a/bin/blackwalld/src/main.rs b/bin/blackwalld/src/main.rs index 022590e..57d94c5 100644 --- a/bin/blackwalld/src/main.rs +++ b/bin/blackwalld/src/main.rs @@ -46,6 +46,13 @@ enum Command { /// Path to the Blackwall config file. #[arg(long)] config: PathBuf, + /// Also emit `define OWN_V4`/`OWN_V6` lines. Off by default: a real + /// `bird.conf` already declares them, and BIRD rejects a duplicate + /// `define` — which makes `birdc configure` refuse the whole include, so + /// the session silently never installs. Only pass this when generating a + /// standalone snippet for a config that does not already define them. + #[arg(long)] + with_defines: bool, }, /// Parse a config, persist it, and apply the ruleset to the kernel. Apply { @@ -1635,9 +1642,12 @@ async fn run() -> Result<(), Box> { println!("{json}"); Ok(()) } - Command::BirdConfig { config } => { + Command::BirdConfig { + config, + with_defines, + } => { let policy = blackwall_config::parse_and_resolve(&config)?; - match blackwall_bgp::render_bird_ibgp(&policy) { + match blackwall_bgp::render_bird_ibgp(&policy, with_defines) { Ok(s) => { print!("{s}"); Ok(()) @@ -2336,6 +2346,15 @@ async fn run() -> Result<(), Box> { tokio::spawn(metrics::metrics_server(metrics_listen, sources)); } + // Optional read-only control API — the same `/v1/audit` (and + // friends) the deception daemon serves. Without this, `api listen=` + // under a `flow` block is silently inert, leaving the shadow + // would-mitigate set reviewable only via direct SQL. `store` is + // already an `Arc` on this path. + if let Some(api_cfg) = policy.api.clone() { + tokio::spawn(api::serve_api(api_cfg, store.clone())); + } + tracing::info!(%listen, "sflow collector starting"); let collector = blackwall_flow::run_collector( listen, @@ -2822,6 +2841,30 @@ async fn run_rtbh(action: RtbhCmd) -> Result<(), Box> { /// `rtbh add`: reject `ip` up front (no database connection made yet) if it /// falls outside the config's eligible prefixes or has no next-hop for its /// address family; otherwise queue an `"add"` intent row. +/// Refuse a plane `add`/`block` when the config carries `shadow`. +/// +/// In shadow mode the daemon never wires a real executor, so a queued intent row +/// is not announced now — but it stays `pending` in the request table and the +/// daemon applies it on the first tick *after* the operator later arms (removes +/// `shadow`), possibly days later with no one watching. Enqueueing in shadow is +/// never useful and plants a delayed-mitigation landmine; refuse it and point the +/// operator at the safe alternatives. +fn reject_add_in_shadow( + policy: &blackwall_core::Policy, + verb: &str, +) -> Result<(), Box> { + if policy.shadow { + return Err(format!( + "config is in shadow mode: `{verb}` would queue an intent that announces nothing now \ + and then fires on the first tick after you arm (remove `shadow`) — a delayed, \ + unattended mitigation. Arm first, or test-fire the data path directly (e.g. a BIRD \ + static route) instead of queueing daemon intent." + ) + .into()); + } + Ok(()) +} + async fn rtbh_add( ip: IpAddr, config: &std::path::Path, @@ -2831,6 +2874,7 @@ async fn rtbh_add( let Some(rtbh) = policy.rtbh.clone() else { return Err("config has no `rtbh` block; RTBH is not enabled".into()); }; + reject_add_in_shadow(&policy, "rtbh add")?; let controller = blackwall_rtbh::RtbhController::new(rtbh_config_from(&policy, &rtbh)); if !controller.is_eligible(ip) { return Err(format!("{ip} is outside the configured RTBH-eligible prefixes").into()); @@ -2929,6 +2973,7 @@ async fn flowspec_add( let Some(fs) = policy.flowspec.clone() else { return Err("config has no `flowspec` block; FlowSpec is not enabled".into()); }; + reject_add_in_shadow(&policy, "flowspec add")?; let controller = blackwall_rtbh::FlowSpecController::new(flowspec_config_from(&policy, &fs)); if !controller.is_eligible(ip) { return Err(format!("{ip} is outside the configured FlowSpec-eligible prefixes").into()); @@ -3091,6 +3136,7 @@ async fn xdp_block( operator: Option, ) -> Result<(), Box> { let policy = require_xdp(config)?; + reject_add_in_shadow(&policy, "xdp block")?; if policy .prefixes .iter() @@ -3298,3 +3344,36 @@ async fn xdp_capture( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn policy_from(cfg: &str) -> blackwall_core::Policy { + blackwall_config::parse_str(cfg).expect("parse") + } + + const RTBH_CFG: &str = "interface wan eth0\n\ + ipv4 203.0.113.0/24\n\ + rtbh peer=10.0.0.2:179 local-as=65000 peer-as=65000 router-id=10.0.0.1 \ + next-hop-v4=192.0.2.1 max=256 hold-down=60s local-addr=10.0.0.3\n"; + + #[test] + fn reject_add_in_shadow_refuses_when_shadow() { + let policy = policy_from(&format!("shadow\n{RTBH_CFG}")); + let err = reject_add_in_shadow(&policy, "rtbh add").expect_err("must refuse under shadow"); + let msg = err.to_string(); + assert!(msg.contains("shadow mode"), "message: {msg}"); + assert!( + msg.contains("rtbh add"), + "message must name the verb: {msg}" + ); + } + + #[test] + fn reject_add_in_shadow_allows_when_armed() { + // No `shadow` directive → armed → the guard must pass. + let policy = policy_from(RTBH_CFG); + assert!(reject_add_in_shadow(&policy, "rtbh add").is_ok()); + } +} diff --git a/crates/blackwall-bgp/src/render.rs b/crates/blackwall-bgp/src/render.rs index ce0487c..32decb3 100644 --- a/crates/blackwall-bgp/src/render.rs +++ b/crates/blackwall-bgp/src/render.rs @@ -24,15 +24,26 @@ pub enum BirdGenError { }, } -/// Render the BIRD include: `OWN_V4/V6` defines + (if `rtbh` set) the -/// `protocol bgp blackwall` MP-BGP session. +/// Render the BIRD include: the `protocol bgp blackwall` MP-BGP session (when +/// `rtbh` is set), optionally preceded by `OWN_V4/V6` `define`s. +/// +/// `include_defines` gates the `define OWN_V4`/`OWN_V6` lines. They are **off by +/// default at the CLI** because a real deployment's `bird.conf` almost always +/// already declares `OWN_V4`/`OWN_V6` (operators reference them in their own +/// egress/RTBH-export filters), and BIRD rejects a duplicate `define` — a +/// re-declared symbol makes `birdc configure` fail validation and silently +/// install *nothing*, so the whole include (session and all) never loads. The +/// generated session does not reference these defines itself (its import filters +/// use inline prefix-sets), so omitting them costs nothing. Pass `true` only +/// when generating a standalone snippet for a config that does *not* already +/// define them. /// /// # Errors /// /// Returns [`BirdGenError::LocalAddrMissing`] if `rtbh` is set but /// `rtbh.local_addr` is `None`, or [`BirdGenError::FamilyMismatch`] if /// `rtbh.local_addr` and `rtbh.peer_addr` are different IP families. -pub fn render_bird_ibgp(policy: &Policy) -> Result { +pub fn render_bird_ibgp(policy: &Policy, include_defines: bool) -> Result { let v4: Vec<&IpNet> = policy .prefixes .iter() @@ -49,11 +60,13 @@ pub fn render_bird_ibgp(policy: &Policy) -> Result { out, "# Generated by blackwall — do not edit; regenerate with `blackwalld bird-config`." ); - if !v4.is_empty() { - let _ = writeln!(out, "define OWN_V4 = [ {} ];", join_prefixes(&v4, false)); - } - if !v6.is_empty() { - let _ = writeln!(out, "define OWN_V6 = [ {} ];", join_prefixes(&v6, false)); + if include_defines { + if !v4.is_empty() { + let _ = writeln!(out, "define OWN_V4 = [ {} ];", join_prefixes(&v4, false)); + } + if !v6.is_empty() { + let _ = writeln!(out, "define OWN_V6 = [ {} ];", join_prefixes(&v6, false)); + } } let Some(rtbh) = policy.rtbh.as_ref() else { @@ -159,7 +172,7 @@ mod tests { #[test] fn renders_defines_and_session_no_auth() { - let out = render_bird_ibgp(&policy_from(&base_cfg())).unwrap(); + let out = render_bird_ibgp(&policy_from(&base_cfg()), true).unwrap(); assert!(out.contains("define OWN_V4 = [ 203.0.113.0/24 ];")); assert!(out.contains("define OWN_V6 = [ 2001:db8::/48 ];")); assert!(out.contains("protocol bgp blackwall {")); @@ -188,7 +201,7 @@ mod tests { ipv4 203.0.113.0/24\n\ rtbh peer=10.0.0.2:179 local-as=65000 peer-as=65000 router-id=10.0.0.1 \ next-hop-v4=192.0.2.1 max=256 hold-down=60s local-addr=10.0.0.3\n"; - let out = render_bird_ibgp(&policy_from(cfg)).unwrap(); + let out = render_bird_ibgp(&policy_from(cfg), true).unwrap(); assert!(out.contains("define OWN_V4")); assert!(!out.contains("define OWN_V6")); assert!(out.contains("ipv4 { import filter {")); @@ -204,7 +217,7 @@ mod tests { ipv6 2001:db8::/48\n\ rtbh peer=[2001:db8::2]:179 local-as=65000 peer-as=65000 router-id=10.0.0.1 \ next-hop-v6=2001:db8::1 max=256 hold-down=60s local-addr=2001:db8::9\n"; - let out = render_bird_ibgp(&policy_from(cfg)).unwrap(); + let out = render_bird_ibgp(&policy_from(cfg), true).unwrap(); assert!(out.contains("define OWN_V6")); assert!(!out.contains("define OWN_V4")); assert!(out.contains("ipv6 { import filter {")); @@ -219,7 +232,7 @@ mod tests { "{}md5=s3cret\n", strip_trailing_newline_and_space(&base_cfg()) ); - let out = render_bird_ibgp(&policy_from(&cfg)).unwrap(); + let out = render_bird_ibgp(&policy_from(&cfg), false).unwrap(); assert!(out.contains("include \"blackwall-secret.conf\";")); assert!(!out.contains("s3cret")); // never leak the secret into the output } @@ -230,25 +243,51 @@ mod tests { "{}gtsm-hops=1\n", strip_trailing_newline_and_space(&base_cfg()) ); - assert!(render_bird_ibgp(&policy_from(&cfg)) + assert!(render_bird_ibgp(&policy_from(&cfg), true) .unwrap() .contains("ttl security on;")); } #[test] - fn no_rtbh_emits_defines_only() { + fn no_rtbh_with_defines_emits_defines_only() { let cfg = "interface wan eth0\nipv4 203.0.113.0/24\nipv6 2001:db8::/48\n"; - let out = render_bird_ibgp(&policy_from(cfg)).unwrap(); + let out = render_bird_ibgp(&policy_from(cfg), true).unwrap(); assert!(out.contains("define OWN_V4")); assert!(!out.contains("protocol bgp blackwall")); } + #[test] + fn defines_omitted_by_default() { + // The CLI default (`include_defines = false`): NO `define OWN_V*` lines, + // because a real bird.conf already declares them and a duplicate + // `define` makes `birdc configure` reject the whole include. The session + // itself must still render — it does not reference the defines. + let out = render_bird_ibgp(&policy_from(&base_cfg()), false).unwrap(); + assert!(!out.contains("define OWN_V4")); + assert!(!out.contains("define OWN_V6")); + assert!(out.contains("protocol bgp blackwall {")); + // The import filters carry the prefixes inline, so scoping survives the + // absence of the defines. + assert!(out.contains("if net ~ [ 203.0.113.0/24+ ]")); + } + + #[test] + fn no_rtbh_without_defines_is_comment_only() { + // No rtbh block and defines off: the include has no session and no + // defines — just the generated-by header. Installing it is a no-op, + // never a duplicate-symbol error. + let cfg = "interface wan eth0\nipv4 203.0.113.0/24\nipv6 2001:db8::/48\n"; + let out = render_bird_ibgp(&policy_from(cfg), false).unwrap(); + assert!(!out.contains("define OWN_V")); + assert!(!out.contains("protocol bgp blackwall")); + } + #[test] fn errors_on_missing_local_addr() { // base_cfg's rtbh line ends with " local-addr=10.0.0.3\n"; drop it. let cfg = base_cfg().replace(" local-addr=10.0.0.3", ""); assert_eq!( - render_bird_ibgp(&policy_from(&cfg)), + render_bird_ibgp(&policy_from(&cfg), false), Err(BirdGenError::LocalAddrMissing) ); } @@ -258,7 +297,7 @@ mod tests { // peer is v4 (10.0.0.2); a v6 local-addr mismatches its family. let cfg = base_cfg().replace("local-addr=10.0.0.3", "local-addr=2001:db8::9"); assert!(matches!( - render_bird_ibgp(&policy_from(&cfg)), + render_bird_ibgp(&policy_from(&cfg), false), Err(BirdGenError::FamilyMismatch { .. }) )); } diff --git a/crates/blackwall-bgp/tests/bird_validate.rs b/crates/blackwall-bgp/tests/bird_validate.rs index 3198122..381c524 100644 --- a/crates/blackwall-bgp/tests/bird_validate.rs +++ b/crates/blackwall-bgp/tests/bird_validate.rs @@ -121,7 +121,7 @@ fn full_session_v4_v6_no_auth_parses_under_real_bird() { eprintln!("`bird` not on PATH; skipping bird -p validation"); return; } - let out = render_bird_ibgp(&policy_from(&base_cfg())).expect("render"); + let out = render_bird_ibgp(&policy_from(&base_cfg()), true).expect("render"); assert_bird_accepts("full-v4-v6", &out, true, true, &[]); } @@ -135,7 +135,7 @@ fn v4_only_parses_under_real_bird() { ipv4 203.0.113.0/24\n\ rtbh peer=10.0.0.2:179 local-as=65000 peer-as=65000 router-id=10.0.0.1 \ next-hop-v4=192.0.2.1 max=256 hold-down=60s local-addr=10.0.0.3\n"; - let out = render_bird_ibgp(&policy_from(cfg)).expect("render"); + let out = render_bird_ibgp(&policy_from(cfg), false).expect("render"); assert_bird_accepts("v4-only", &out, true, false, &[]); } @@ -149,7 +149,7 @@ fn v6_only_parses_under_real_bird() { ipv6 2001:db8::/48\n\ rtbh peer=[2001:db8::2]:179 local-as=65000 peer-as=65000 router-id=10.0.0.1 \ next-hop-v6=2001:db8::1 max=256 hold-down=60s local-addr=2001:db8::9\n"; - let out = render_bird_ibgp(&policy_from(cfg)).expect("render"); + let out = render_bird_ibgp(&policy_from(cfg), false).expect("render"); assert_bird_accepts("v6-only", &out, false, true, &[]); } @@ -160,7 +160,7 @@ fn gtsm_session_parses_under_real_bird() { return; } let cfg = format!("{} gtsm-hops=1\n", base_cfg().trim_end()); - let out = render_bird_ibgp(&policy_from(&cfg)).expect("render"); + let out = render_bird_ibgp(&policy_from(&cfg), false).expect("render"); assert!(out.contains("ttl security on;")); assert_bird_accepts("gtsm", &out, true, true, &[]); } @@ -172,7 +172,7 @@ fn md5_session_parses_under_real_bird_with_stub_secret() { return; } let cfg = format!("{} md5=s3cret\n", base_cfg().trim_end()); - let out = render_bird_ibgp(&policy_from(&cfg)).expect("render"); + let out = render_bird_ibgp(&policy_from(&cfg), false).expect("render"); assert!(out.contains("include \"blackwall-secret.conf\";")); // The real secret is never in the generated output (checked in // render.rs's own tests); here it only matters that BIRD accepts a @@ -197,7 +197,7 @@ fn defines_only_no_rtbh_parses_under_real_bird() { return; } let cfg = "interface wan eth0\nipv4 203.0.113.0/24\nipv6 2001:db8::/48\n"; - let out = render_bird_ibgp(&policy_from(cfg)).expect("render"); + let out = render_bird_ibgp(&policy_from(cfg), false).expect("render"); assert!(!out.contains("protocol bgp blackwall")); assert_bird_accepts("defines-only", &out, false, false, &[]); } From a55d0dae776684648af65900b5ebb79db7b67b5b Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Thu, 16 Jul 2026 00:11:24 -0400 Subject: [PATCH 2/2] fix(arming): guard xdp rate-limit in shadow; reconcile bird-config docs Addresses two code-review findings on the field-report fixes: - xdp rate-limit had the same shadow landmine as xdp block: it enqueues a `rate_limit` row drained through the identical pending queue, so one queued under `shadow` fires unattended on the first post-arm tick. Guard it with reject_add_in_shadow (binding the previously-discarded require_xdp policy). All four add-style paths (rtbh add / flowspec add / xdp block / xdp rate-limit) are now guarded; removal paths intentionally are not. - deployment.md + README described bird-config as the sole source of the OWN_V4/OWN_V6 defines, which the new default-off behavior contradicts (an operator relying on that would now hit an undefined-symbol failure in their own export filters). Reworked both to state the default omits the defines and to document --with-defines for the single-definer case. --- README.md | 3 ++- bin/blackwalld/src/main.rs | 3 ++- docs/deployment.md | 19 +++++++++++++++---- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 29e21dc..a61e18a 100644 --- a/README.md +++ b/README.md @@ -285,7 +285,8 @@ AF_XDP, rate limiting. deployment on an anycast ISP (centralized BGP brain + multi-POP telemetry). Staged, not flag-day: - 🟡 **M0 — detection-only (shadow):** telemetry ingest ✅ (above), a POP-sensor deploy contract (hsflowd) ✅, a **BIRD iBGP-snippet generator** ✅ (`blackwalld bird-config` emits BIRD's side of - the session + `OWN_V4/V6` defines from blackwall's config, validated against real BIRD2), and + the session from blackwall's config — `OWN_V4/V6` defines opt-in via `--with-defines`, off by + default so they don't collide with the operator's own — validated against real BIRD2), and **network-wide shadow mode** ✅ (a `shadow` directive logs+records every RTBH/FlowSpec/XDP mitigation the daemon *would* apply, via `/v1/audit` + `blackwall_shadow_would_mitigate_total`, without executing it). Remaining: Incus/metrics deploy glue. Run live, watch, tune — act on nothing. diff --git a/bin/blackwalld/src/main.rs b/bin/blackwalld/src/main.rs index 57d94c5..9c2611a 100644 --- a/bin/blackwalld/src/main.rs +++ b/bin/blackwalld/src/main.rs @@ -3197,7 +3197,8 @@ async fn xdp_rate_limit( config: &std::path::Path, operator: Option, ) -> Result<(), Box> { - require_xdp(config)?; + let policy = require_xdp(config)?; + reject_add_in_shadow(&policy, "xdp rate-limit")?; if pps == 0 { return Err("rate-limit pps must be >= 1".into()); } diff --git a/docs/deployment.md b/docs/deployment.md index 7164050..749ebea 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -264,13 +264,24 @@ interface, and the home box's config must accept it (`flow --listen ## Generate BIRD's side of the session (`bird-config`) Blackwall's native speaker peers *into* your BIRD; the BIRD side of that iBGP -session (the `protocol bgp` stanza + the `OWN_V4/OWN_V6` prefix defines its -export filters reference) is generated from the same `blackwall.conf`, so you -don't hand-maintain the prefix/session lists twice: +session (the `protocol bgp` stanza) is generated from the same `blackwall.conf`, +so you don't hand-maintain the prefix/session lists twice: ```bash blackwalld bird-config --config /etc/blackwall/blackwall.conf > /etc/bird/blackwall.conf ``` -Add `include "blackwall.conf";` to your `bird.conf` and `birdc configure`. This +Add `include "blackwall.conf";` to your `bird.conf` and `birdc configure`. + +> **`OWN_V4`/`OWN_V6` defines.** By default `bird-config` does **not** emit +> `define OWN_V4`/`OWN_V6` — most `bird.conf`s already declare them (for their own +> egress/RTBH-export filters), and BIRD rejects a *duplicate* `define`, which makes +> `birdc configure` refuse the **entire** include so nothing installs. The generated +> session doesn't reference these symbols itself (its import filters carry the +> prefixes inline), so omitting them is free. If your `bird.conf` does *not* already +> declare `OWN_V4`/`OWN_V6` and your own export filters reference them, pass +> `--with-defines` so blackwall's include is their single source — but then don't +> also declare them elsewhere, or you're back to the duplicate-define failure. + +This requires `local-addr=` on the `rtbh` directive — blackwall's own BGP source address, which the generator emits as BIRD's `neighbor` and the speaker binds as its source so the session matches by construction (its family must match the