From c489c62723c97c840fcf28b316a82422105ce6ec Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 11 Jul 2026 19:37:24 -0400 Subject: [PATCH 1/8] =?UTF-8?q?feat(config):=20rtbh=20local-addr=3D=20?= =?UTF-8?q?=E2=86=92=20RtbhPolicy.local=5Faddr=20(BGP=20source)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/blackwall-config/src/parser.rs | 30 +++++++++++++++++++++++++++ crates/blackwall-core/src/rtbh.rs | 5 +++++ 2 files changed, 35 insertions(+) diff --git a/crates/blackwall-config/src/parser.rs b/crates/blackwall-config/src/parser.rs index fde0305..3bd1967 100644 --- a/crates/blackwall-config/src/parser.rs +++ b/crates/blackwall-config/src/parser.rs @@ -221,6 +221,7 @@ pub fn parse(lines: &[Line]) -> Result { | "community" | "md5" | "gtsm-hops" + | "local-addr" ) { return Err(ConfigError::BadValue { line: line.number, @@ -335,6 +336,10 @@ pub fn parse(lines: &[Line]) -> Result { .get("md5") .map(|s| blackwall_core::Md5Secret::new((*s).to_owned())), gtsm_hops, + local_addr: kv + .get("local-addr") + .map(|v| v.parse().map_err(|_| bad("local-addr", v))) + .transpose()?, }); } "flowspec" => { @@ -1611,6 +1616,31 @@ tenant t { assert!(parse_text(src).unwrap().rtbh.unwrap().md5.is_none()); } + #[test] + fn parses_rtbh_local_addr() { + let p = parse_text( + "interface wan eth0\nipv4 203.0.113.0/24\nrtbh 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=8 hold-down=60s local-addr=10.0.0.3\n", + ).unwrap(); + let rtbh = p.rtbh.expect("rtbh"); + assert_eq!(rtbh.local_addr, Some("10.0.0.3".parse().unwrap())); + } + + #[test] + fn rtbh_local_addr_defaults_none() { + let p = parse_text( + "interface wan eth0\nipv4 203.0.113.0/24\nrtbh 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=8 hold-down=60s\n", + ).unwrap(); + assert_eq!(p.rtbh.unwrap().local_addr, None); + } + + #[test] + fn rtbh_rejects_bad_local_addr() { + let err = parse_text( + "interface wan eth0\nipv4 203.0.113.0/24\nrtbh 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=8 hold-down=60s local-addr=notanip\n", + ); + assert!(err.is_err()); + } + #[test] fn parses_flowspec_directive() { let src = "\ diff --git a/crates/blackwall-core/src/rtbh.rs b/crates/blackwall-core/src/rtbh.rs index 37bb5d7..1e80004 100644 --- a/crates/blackwall-core/src/rtbh.rs +++ b/crates/blackwall-core/src/rtbh.rs @@ -36,6 +36,10 @@ pub struct RtbhPolicy { /// directly connected peer, TTL 255) and sends with TTL 255; `None` /// disables the TTL check. pub gtsm_hops: Option, + /// Blackwall's own BGP source address — bound by the speaker as the TCP + /// source and emitted as BIRD's `neighbor`. `None` = OS-chosen source (no + /// generated BIRD session possible). Its family should match `peer_addr`. + pub local_addr: Option, } #[cfg(test)] @@ -56,6 +60,7 @@ mod tests { max_ttl: Some(std::time::Duration::from_secs(7200)), md5: Some(crate::Md5Secret::new("pw".into())), gtsm_hops: Some(1), + local_addr: Some("10.222.255.2".parse().unwrap()), }; let json = serde_json::to_string(&p).unwrap(); let back: RtbhPolicy = serde_json::from_str(&json).unwrap(); From ca58989a2d6d3bf9cbd290788bb33da9a4085354 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 11 Jul 2026 19:42:17 -0400 Subject: [PATCH 2/8] =?UTF-8?q?feat(bgp):=20render=5Fbird=5Fibgp=20?= =?UTF-8?q?=E2=80=94=20generate=20the=20BIRD=20iBGP=20session=20+=20OWN=20?= =?UTF-8?q?defines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 2 + crates/blackwall-bgp/Cargo.toml | 2 + crates/blackwall-bgp/src/lib.rs | 2 + crates/blackwall-bgp/src/render.rs | 219 +++++++++++++++++++++++++++++ 4 files changed, 225 insertions(+) create mode 100644 crates/blackwall-bgp/src/render.rs diff --git a/Cargo.lock b/Cargo.lock index 51386d9..48c9aad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -255,6 +255,8 @@ dependencies = [ name = "blackwall-bgp" version = "0.1.0" dependencies = [ + "blackwall-config", + "blackwall-core", "ipnet", "libc", "socket2 0.5.10", diff --git a/crates/blackwall-bgp/Cargo.toml b/crates/blackwall-bgp/Cargo.toml index e667e69..cfb9c9f 100644 --- a/crates/blackwall-bgp/Cargo.toml +++ b/crates/blackwall-bgp/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true repository.workspace = true [dependencies] +blackwall-core = { path = "../blackwall-core" } thiserror = { workspace = true } ipnet = { workspace = true } tokio = { workspace = true, features = ["sync", "time"] } @@ -15,6 +16,7 @@ libc = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } +blackwall-config = { path = "../blackwall-config" } [lints] workspace = true diff --git a/crates/blackwall-bgp/src/lib.rs b/crates/blackwall-bgp/src/lib.rs index 721a441..a6ba4bd 100644 --- a/crates/blackwall-bgp/src/lib.rs +++ b/crates/blackwall-bgp/src/lib.rs @@ -3,6 +3,7 @@ mod error; mod flowspec; mod message; +mod render; mod route; mod session_net; mod update; @@ -14,6 +15,7 @@ pub use message::{ encode_notification, encode_open, parse_header, BgpMessage, MsgType, NotificationMsg, OpenMsg, HEADER_LEN, MARKER, }; +pub use render::{render_bird_ibgp, BirdGenError}; pub use route::{Origin, Route}; pub use session_net::{ spawn, BgpHandle, BgpSendError, PeerConfig, PeerConfigError, SessionCommand, SessionState, diff --git a/crates/blackwall-bgp/src/render.rs b/crates/blackwall-bgp/src/render.rs new file mode 100644 index 0000000..963a566 --- /dev/null +++ b/crates/blackwall-bgp/src/render.rs @@ -0,0 +1,219 @@ +//! Render the BIRD-side iBGP config (the `protocol bgp blackwall` session + +//! `OWN_V4/V6` defines) from blackwall's own policy, so prefix/session lists +//! aren't hand-maintained in both blackwall.conf and bird.conf. + +use blackwall_core::Policy; +use ipnet::IpNet; +use std::fmt::Write as _; +use std::net::IpAddr; + +/// Why the BIRD session could not be rendered. +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum BirdGenError { + /// `rtbh` is configured but `local-addr` is unset — no `neighbor` address to + /// emit (and the speaker would connect from an unpredictable source). + #[error("rtbh is configured but local-addr= is required to generate the BIRD session")] + LocalAddrMissing, + /// `local-addr`'s family differs from `peer_addr`'s — the session can't form. + #[error("local-addr {local} family differs from peer address {peer}")] + FamilyMismatch { + /// Configured local (source) address. + local: IpAddr, + /// BIRD peer address. + peer: IpAddr, + }, +} + +/// Render the BIRD include: `OWN_V4/V6` defines + (if `rtbh` set) the +/// `protocol bgp blackwall` MP-BGP session. +/// +/// # 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 { + let v4: Vec<&IpNet> = policy + .prefixes + .iter() + .filter(|p| matches!(p, IpNet::V4(_))) + .collect(); + let v6: Vec<&IpNet> = policy + .prefixes + .iter() + .filter(|p| matches!(p, IpNet::V6(_))) + .collect(); + + let mut out = String::new(); + let _ = writeln!( + 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)); + } + + let Some(rtbh) = policy.rtbh.as_ref() else { + return Ok(out); + }; + let local = rtbh.local_addr.ok_or(BirdGenError::LocalAddrMissing)?; + let peer = rtbh.peer_addr.ip(); + if local.is_ipv4() != peer.is_ipv4() { + return Err(BirdGenError::FamilyMismatch { local, peer }); + } + + let _ = writeln!(out, "\nprotocol bgp blackwall {{"); + let _ = writeln!(out, " local {local} as {};", rtbh.local_asn); + let _ = writeln!(out, " neighbor {peer} as {};", rtbh.peer_asn); + let _ = writeln!(out, " allow local as {};", rtbh.local_asn); + if rtbh.md5.is_some() { + let _ = writeln!(out, " include \"blackwall-secret.conf\";"); + } + if rtbh.gtsm_hops.is_some() { + let _ = writeln!(out, " ttl security on;"); + } + if !v4.is_empty() { + let m = join_prefixes(&v4, true); + let _ = writeln!( + out, + " ipv4 {{ import filter {{ if net ~ [ {m} ] then accept; reject; }}; export none; next hop self; }};" + ); + let _ = writeln!( + out, + " flow4 {{ import filter {{ if flow4.dst ~ [ {m} ] then accept; reject; }}; export none; }};" + ); + } + if !v6.is_empty() { + let m = join_prefixes(&v6, true); + let _ = writeln!( + out, + " ipv6 {{ import filter {{ if net ~ [ {m} ] then accept; reject; }}; export none; next hop self; }};" + ); + let _ = writeln!( + out, + " flow6 {{ import filter {{ if flow6.dst ~ [ {m} ] then accept; reject; }}; export none; }};" + ); + } + let _ = writeln!(out, " hold time 600; keepalive time 200;"); + let _ = writeln!(out, "}}"); + Ok(out) +} + +/// Join prefixes for a BIRD set literal; `more_specific` appends `+` to each +/// (this-and-all-more-specific), needed for import filters that must accept the +/// /32-/128 blackholes blackwall injects within its space. +fn join_prefixes(prefixes: &[&IpNet], more_specific: bool) -> String { + prefixes + .iter() + .map(|p| { + if more_specific { + format!("{p}+") + } else { + p.to_string() + } + }) + .collect::>() + .join(", ") +} + +#[cfg(test)] +mod tests { + use super::*; + use blackwall_core::Policy; + + /// Build a `Policy` by parsing a real config string through + /// `blackwall_config::parse_str` — this exercises the actual + /// parse-to-render path rather than a hand-written `Policy` literal that + /// could drift out of sync with the parser's field set. + fn policy_from(cfg: &str) -> Policy { + blackwall_config::parse_str(cfg).expect("parse") + } + + fn base_cfg() -> String { + "interface wan eth0\n\ + ipv4 203.0.113.0/24\n\ + ipv6 2001:db8::/48\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 next-hop-v6=2001:db8::1 max=256 hold-down=60s \ + local-addr=10.0.0.3\n" + .to_string() + } + + #[test] + fn renders_defines_and_session_no_auth() { + let out = render_bird_ibgp(&policy_from(&base_cfg())).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 {")); + assert!(out.contains("local 10.0.0.3 as 65000;")); + assert!(out.contains("neighbor 10.0.0.2 as 65000;")); + assert!(out.contains( + "ipv4 { import filter { if net ~ [ 203.0.113.0/24+ ] then accept; reject; }; export none; next hop self; };" + )); + assert!(out.contains( + "flow4 { import filter { if flow4.dst ~ [ 203.0.113.0/24+ ] then accept; reject; }; export none; };" + )); + assert!(!out.contains("password")); + assert!(!out.contains("ttl security")); + } + + #[test] + fn md5_emits_secret_include_not_plaintext() { + let cfg = format!( + "{}md5=s3cret\n", + strip_trailing_newline_and_space(&base_cfg()) + ); + let out = render_bird_ibgp(&policy_from(&cfg)).unwrap(); + assert!(out.contains("include \"blackwall-secret.conf\";")); + assert!(!out.contains("s3cret")); // never leak the secret into the output + } + + #[test] + fn gtsm_emits_ttl_security() { + let cfg = format!( + "{}gtsm-hops=1\n", + strip_trailing_newline_and_space(&base_cfg()) + ); + assert!(render_bird_ibgp(&policy_from(&cfg)) + .unwrap() + .contains("ttl security on;")); + } + + #[test] + fn no_rtbh_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(); + assert!(out.contains("define OWN_V4")); + 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)), + Err(BirdGenError::LocalAddrMissing) + ); + } + + #[test] + fn errors_on_family_mismatch() { + // 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)), + Err(BirdGenError::FamilyMismatch { .. }) + )); + } + + /// Strip the trailing `\n` from `base_cfg()` so an extra `key=value` + /// token can be appended to the `rtbh` line before its own `\n`. + fn strip_trailing_newline_and_space(cfg: &str) -> String { + let trimmed = cfg.strip_suffix('\n').unwrap_or(cfg); + format!("{trimmed} ") + } +} From 838c11bc5f12064b6ab25f4764e0d7ed0a3c8844 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 11 Jul 2026 19:46:18 -0400 Subject: [PATCH 3/8] test(bgp): cover v4-only/v6-only + hold-time in render_bird_ibgp --- crates/blackwall-bgp/src/render.rs | 33 ++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/blackwall-bgp/src/render.rs b/crates/blackwall-bgp/src/render.rs index 963a566..e666ff7 100644 --- a/crates/blackwall-bgp/src/render.rs +++ b/crates/blackwall-bgp/src/render.rs @@ -156,10 +156,43 @@ mod tests { assert!(out.contains( "flow4 { import filter { if flow4.dst ~ [ 203.0.113.0/24+ ] then accept; reject; }; export none; };" )); + assert!(out.contains("hold time 600; keepalive time 200;")); assert!(!out.contains("password")); assert!(!out.contains("ttl security")); } + #[test] + fn renders_v4_only() { + // v4 prefixes + a v4 peer, no ipv6 line: only the v4 channels render. + let cfg = "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"; + let out = render_bird_ibgp(&policy_from(cfg)).unwrap(); + assert!(out.contains("define OWN_V4")); + assert!(!out.contains("define OWN_V6")); + assert!(out.contains("ipv4 { import filter {")); + assert!(out.contains("flow4 { import filter {")); + assert!(!out.contains("ipv6 { import filter {")); + assert!(!out.contains("flow6 { import filter {")); + } + + #[test] + fn renders_v6_only() { + // v6 prefixes + a v6 peer, no ipv4 line: only the v6 channels render. + let cfg = "interface wan eth0\n\ + 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(); + assert!(out.contains("define OWN_V6")); + assert!(!out.contains("define OWN_V4")); + assert!(out.contains("ipv6 { import filter {")); + assert!(out.contains("flow6 { import filter {")); + assert!(!out.contains("ipv4 { import filter {")); + assert!(!out.contains("flow4 { import filter {")); + } + #[test] fn md5_emits_secret_include_not_plaintext() { let cfg = format!( From 913280f72d3128e7a73d0449d332f308c5dfd66b Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 11 Jul 2026 19:49:16 -0400 Subject: [PATCH 4/8] feat(bgp): bind BGP source address (PeerConfig.local_addr) for pinned-neighbor peers --- bin/blackwalld/src/main.rs | 1 + crates/blackwall-bgp/src/session_net.rs | 50 ++++++++++++++++--- .../blackwall-bgp/tests/flowspec_interop.rs | 1 + crates/blackwall-bgp/tests/interop.rs | 1 + 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/bin/blackwalld/src/main.rs b/bin/blackwalld/src/main.rs index ff6d8a6..757fddf 100644 --- a/bin/blackwalld/src/main.rs +++ b/bin/blackwalld/src/main.rs @@ -1414,6 +1414,7 @@ async fn run() -> Result<(), Box> { hold_time: 90, md5: rtbh.md5.as_ref().map(|s| s.reveal().to_owned()), gtsm_hops: rtbh.gtsm_hops, + local_addr: rtbh.local_addr, }; // `BgpHandle` is a cloneable mpsc sender; both the RTBH // and (optionally) FlowSpec managers share the one diff --git a/crates/blackwall-bgp/src/session_net.rs b/crates/blackwall-bgp/src/session_net.rs index 5df0bac..83d0506 100644 --- a/crates/blackwall-bgp/src/session_net.rs +++ b/crates/blackwall-bgp/src/session_net.rs @@ -70,6 +70,10 @@ pub struct PeerConfig { /// below `256 - n` (so `1` = a directly-connected peer must arrive with TTL /// 255). Cheaply defeats off-link spoofed BGP packets. `None` disables it. pub gtsm_hops: Option, + /// Optional BGP source address. When `Some`, the session binds it as the TCP + /// source before connecting, so a pinned-`neighbor` peer (e.g. BIRD) accepts + /// the session. `None` = OS-chosen source. + pub local_addr: Option, } /// A `PeerConfig` that cannot form a valid iBGP session. @@ -388,7 +392,14 @@ async fn session_once( consecutive_failures: &mut u32, ) -> SessionOutcome { // ── 1. TCP connect ────────────────────────────────────────────────────── - let mut stream = match connect_peer(cfg.peer_addr, cfg.md5.as_deref(), cfg.gtsm_hops).await { + let mut stream = match connect_peer( + cfg.peer_addr, + cfg.md5.as_deref(), + cfg.gtsm_hops, + cfg.local_addr, + ) + .await + { Ok(s) => { info!(peer = %cfg.peer_addr, "TCP connected"); s @@ -916,20 +927,24 @@ struct TcpMd5Sig { } /// Connect to `addr`, optionally installing a TCP-MD5 (RFC 2385) signature for -/// the peer. With `md5 == None` this is exactly [`TcpStream::connect`]. +/// the peer and/or binding a specific source address. With `md5 == None`, +/// `gtsm_hops == None`, and `local_addr == None` this is exactly +/// [`TcpStream::connect`]. /// /// # Errors /// -/// Returns any TCP connect error, or an error from installing the MD5 key (e.g. -/// a key longer than [`libc::TCP_MD5SIG_MAXKEYLEN`] bytes). +/// Returns any TCP connect error, an error from installing the MD5 key (e.g. +/// a key longer than [`libc::TCP_MD5SIG_MAXKEYLEN`] bytes), or an error from +/// binding `local_addr`. async fn connect_peer( addr: SocketAddr, md5: Option<&str>, gtsm_hops: Option, + local_addr: Option, ) -> io::Result { - // The plain path is only valid with neither socket option set; otherwise - // build the socket ourselves so we can apply them before connect. - if md5.is_none() && gtsm_hops.is_none() { + // The plain path is only valid with none of the socket options set; + // otherwise build the socket ourselves so we can apply them before connect. + if md5.is_none() && gtsm_hops.is_none() && local_addr.is_none() { return TcpStream::connect(addr).await; } let sock = Socket::new(Domain::for_address(addr), Type::STREAM, Some(Protocol::TCP))?; @@ -939,6 +954,9 @@ async fn connect_peer( if let Some(hops) = gtsm_hops { set_gtsm(&sock, addr, hops)?; } + if let Some(src) = local_addr { + sock.bind(&socket2::SockAddr::from(std::net::SocketAddr::new(src, 0)))?; + } sock.set_nonblocking(true)?; // A nonblocking connect returns EINPROGRESS; hand the fd to tokio, which // drives the connect to completion. @@ -1088,6 +1106,7 @@ mod tests { hold_time: hold, md5: None, gtsm_hops: None, + local_addr: None, } } @@ -1200,4 +1219,21 @@ mod tests { 255 ); } + + #[test] + fn bind_source_sets_local_addr() { + let peer: SocketAddr = "127.0.0.1:179".parse().unwrap(); + let sock = + Socket::new(Domain::for_address(peer), Type::STREAM, Some(Protocol::TCP)).unwrap(); + sock.bind(&socket2::SockAddr::from(SocketAddr::new( + "127.0.0.1".parse().unwrap(), + 0, + ))) + .unwrap(); + let local = sock.local_addr().unwrap().as_socket().unwrap(); + assert_eq!( + local.ip(), + std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST) + ); + } } diff --git a/crates/blackwall-bgp/tests/flowspec_interop.rs b/crates/blackwall-bgp/tests/flowspec_interop.rs index 1757609..a5d0b9f 100644 --- a/crates/blackwall-bgp/tests/flowspec_interop.rs +++ b/crates/blackwall-bgp/tests/flowspec_interop.rs @@ -30,6 +30,7 @@ async fn flowspec_rule_reaches_bird() { hold_time: 90, md5: None, gtsm_hops: None, + local_addr: None, }) .expect("valid iBGP config"); tokio::time::sleep(Duration::from_secs(3)).await; // let the session establish diff --git a/crates/blackwall-bgp/tests/interop.rs b/crates/blackwall-bgp/tests/interop.rs index 98e4cce..1e654d3 100644 --- a/crates/blackwall-bgp/tests/interop.rs +++ b/crates/blackwall-bgp/tests/interop.rs @@ -25,6 +25,7 @@ async fn announces_a_host_route() { // authenticated session must be accepted by BIRD's `password` clause. md5: std::env::var("BW_BGP_MD5").ok().filter(|s| !s.is_empty()), gtsm_hops: None, + local_addr: None, }; let (handle, _join) = blackwall_bgp::spawn(cfg).expect("valid iBGP config"); tokio::time::sleep(std::time::Duration::from_secs(3)).await; // let it establish From e5e9a2ebce28fe9000eb65d61fe54e272ead5d6c Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 11 Jul 2026 19:49:28 -0400 Subject: [PATCH 5/8] test(rtbh): thread PeerConfig.local_addr: None into interop harness literals --- crates/blackwall-rtbh/tests/flowspec_auto_interop.rs | 1 + crates/blackwall-rtbh/tests/interop.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/crates/blackwall-rtbh/tests/flowspec_auto_interop.rs b/crates/blackwall-rtbh/tests/flowspec_auto_interop.rs index 85bb399..61ad36d 100644 --- a/crates/blackwall-rtbh/tests/flowspec_auto_interop.rs +++ b/crates/blackwall-rtbh/tests/flowspec_auto_interop.rs @@ -123,6 +123,7 @@ async fn selection_routes_to_flowspec_and_rtbh_on_real_bird() { hold_time: 90, md5: None, gtsm_hops: None, + local_addr: None, }) .expect("valid iBGP config"); tokio::time::sleep(Duration::from_secs(3)).await; // let the session establish diff --git a/crates/blackwall-rtbh/tests/interop.rs b/crates/blackwall-rtbh/tests/interop.rs index 4f63b74..8f38ad1 100644 --- a/crates/blackwall-rtbh/tests/interop.rs +++ b/crates/blackwall-rtbh/tests/interop.rs @@ -48,6 +48,7 @@ async fn blackholes_a_detected_target() { hold_time: 90, md5: None, gtsm_hops: None, + local_addr: None, }) .expect("valid iBGP config"); tokio::time::sleep(Duration::from_secs(3)).await; // let the session establish From 351dd82437e5119af5be55c4b70c854f616f2534 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 11 Jul 2026 19:53:49 -0400 Subject: [PATCH 6/8] =?UTF-8?q?feat(cli):=20blackwalld=20bird-config=20?= =?UTF-8?q?=E2=80=94=20print=20the=20generated=20BIRD=20include?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/blackwalld/src/main.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/bin/blackwalld/src/main.rs b/bin/blackwalld/src/main.rs index 757fddf..bf3e9f1 100644 --- a/bin/blackwalld/src/main.rs +++ b/bin/blackwalld/src/main.rs @@ -41,6 +41,12 @@ enum Command { #[arg(long)] config: PathBuf, }, + /// Parse a config and print the generated BIRD iBGP include. + BirdConfig { + /// Path to the Blackwall config file. + #[arg(long)] + config: PathBuf, + }, /// Parse a config, persist it, and apply the ruleset to the kernel. Apply { /// Path to the Blackwall config file. @@ -1291,6 +1297,16 @@ async fn run() -> Result<(), Box> { println!("{json}"); Ok(()) } + Command::BirdConfig { config } => { + let policy = blackwall_config::parse_file(&config)?; + match blackwall_bgp::render_bird_ibgp(&policy) { + Ok(s) => { + print!("{s}"); + Ok(()) + } + Err(e) => Err(format!("bird-config: {e}").into()), + } + } Command::Speedtest { librespeed_server, max_bytes, From 492e94343300bfa65cca2fda6063fd7fb6b9fdb1 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 11 Jul 2026 20:17:42 -0400 Subject: [PATCH 7/8] test(bird): validate the generated BIRD include against real BIRD2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds bird -p parse-check tests (blackwall-bgp/tests/bird_validate.rs, gated on `bird` being on PATH) and a live netns lab gate (bird-gen.kdl) that peers real BIRD2 against blackwall's *actual* generated include rather than the lab's hand-rolled approximation. Both caught real bugs in render_bird_ibgp, now fixed: - flow4.dst/flow6.dst is not valid BIRD2 filter syntax; flowspec routes are matched the same way as any other route, via `net ~ [ prefix-set ]`. - local/neighbor were swapped: the generated snippet runs on and is executed by the BIRD box itself, so its own `local` clause must be an address it owns (peer_addr), and `neighbor` is blackwall's own speaker (local_addr). Both directions are individually valid syntax, so only a live session (not bird -p) could catch this — confirmed via a hand netns proof ("Socket error: bind: Cannot assign requested address" before the fix, Established + blackhole route imported after). Also extends blackwall-lab's render_bird() with an include-file daemon setting so a scenario can feed BIRD a pre-generated include verbatim, and threads BW_BGP_LOCAL_ADDR through the rtbh interop driver so the speaker can bind its BGP source to match. --- .github/workflows/ci.yml | 22 +- crates/blackwall-bgp/src/render.rs | 32 ++- crates/blackwall-bgp/tests/bird_validate.rs | 194 ++++++++++++++++++ crates/blackwall-lab/scenarios/bird-gen.kdl | 47 +++++ .../scenarios/fixtures/bird-gen.conf | 3 + crates/blackwall-lab/src/render/bird.rs | 116 ++++++++++- crates/blackwall-rtbh/tests/interop.rs | 11 +- 7 files changed, 410 insertions(+), 15 deletions(-) create mode 100644 crates/blackwall-bgp/tests/bird_validate.rs create mode 100644 crates/blackwall-lab/scenarios/bird-gen.kdl create mode 100644 crates/blackwall-lab/scenarios/fixtures/bird-gen.conf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf403d1..34c7631 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,7 +81,10 @@ jobs: sudo mkdir -p /opt/host-sflow sudo cp /tmp/host-sflow/src/Linux/hsflowd /tmp/host-sflow/src/Linux/mod_pcap.so /opt/host-sflow/ - name: Build the lab + interop tests (pre-built binaries the gates run directly) - run: cargo build -p blackwall-lab && cargo build -p blackwall-trafficgen && bash scripts/build-lab-tests.sh + # blackwalld is built here too: the bird-gen gate shells out to + # `blackwalld bird-config` (not `cargo run`) to generate the include + # it feeds real BIRD, matching the "pre-built binaries only" rule. + run: cargo build -p blackwall-lab && cargo build -p blackwall-trafficgen && cargo build -p blackwalld && bash scripts/build-lab-tests.sh - name: Run the lab gate timeout-minutes: 6 run: | @@ -190,6 +193,22 @@ 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/rtbh-bird.kdl lab-junit-rtbh.xml + - name: Run the lab gate (bird-gen) + # Peers real BIRD2 against blackwall's *actual* generated iBGP + # include (not the lab's own hand-rolled approximation the other + # *-bird gates use) — see crates/blackwall-lab/scenarios/bird-gen.kdl + # for why this catches bugs `bird -p` alone cannot (e.g. a swapped + # local/neighbor address, individually valid but unbindable). + 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 + ./target/debug/blackwalld bird-config \ + --config crates/blackwall-lab/scenarios/fixtures/bird-gen.conf \ + > /tmp/blackwall-lab-bird-gen-include.conf + 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/bird-gen.kdl lab-junit-bird-gen.xml - name: Run the lab gate (flowspec) # Re-enabled: its wedge was cross-gate residue left by the preceding # deception-resilience gate's engine (broken tproxy → unclean teardown → @@ -280,5 +299,6 @@ jobs: lab-junit-syncookie.xml lab-junit-syncookie-v6.xml lab-junit-rtbh.xml + lab-junit-bird-gen.xml lab-junit-flowspec.xml lab-junit-flowspec-auto.xml diff --git a/crates/blackwall-bgp/src/render.rs b/crates/blackwall-bgp/src/render.rs index e666ff7..7fdc2a7 100644 --- a/crates/blackwall-bgp/src/render.rs +++ b/crates/blackwall-bgp/src/render.rs @@ -65,9 +65,18 @@ pub fn render_bird_ibgp(policy: &Policy) -> Result { return Err(BirdGenError::FamilyMismatch { local, peer }); } + // This snippet is installed on and executed *by* the BIRD box itself + // (the box at `rtbh.peer_addr`, from blackwall's point of view) — so + // BIRD's own `local` clause must be an address *it* owns: `peer_addr`. + // `neighbor` is the box BIRD expects the session from: blackwall's own + // speaker, at `rtbh.local_addr`. Getting this backwards type-checks + // fine (both sides are just `IP as ASN`) but breaks at runtime — BIRD + // refuses to bind a `local` address it doesn't own (confirmed live + // against BIRD 2.17.1: "Socket error: bind: Cannot assign requested + // address"), which only a live-session test catches, not `bird -p`. let _ = writeln!(out, "\nprotocol bgp blackwall {{"); - let _ = writeln!(out, " local {local} as {};", rtbh.local_asn); - let _ = writeln!(out, " neighbor {peer} as {};", rtbh.peer_asn); + let _ = writeln!(out, " local {peer} as {};", rtbh.peer_asn); + let _ = writeln!(out, " neighbor {local} as {};", rtbh.local_asn); let _ = writeln!(out, " allow local as {};", rtbh.local_asn); if rtbh.md5.is_some() { let _ = writeln!(out, " include \"blackwall-secret.conf\";"); @@ -81,9 +90,13 @@ pub fn render_bird_ibgp(policy: &Policy) -> Result { out, " ipv4 {{ import filter {{ if net ~ [ {m} ] then accept; reject; }}; export none; next hop self; }};" ); + // BIRD2 has no `flow4.dst`/`flow6.dst` accessor; a flowspec route's + // destination-prefix component is matched the same way as a plain + // route's network, via `net ~ [ prefix-set ]` (confirmed against real + // BIRD 2.17.1 with `bird -p`). let _ = writeln!( out, - " flow4 {{ import filter {{ if flow4.dst ~ [ {m} ] then accept; reject; }}; export none; }};" + " flow4 {{ import filter {{ if net ~ [ {m} ] then accept; reject; }}; export none; }};" ); } if !v6.is_empty() { @@ -94,7 +107,7 @@ pub fn render_bird_ibgp(policy: &Policy) -> Result { ); let _ = writeln!( out, - " flow6 {{ import filter {{ if flow6.dst ~ [ {m} ] then accept; reject; }}; export none; }};" + " flow6 {{ import filter {{ if net ~ [ {m} ] then accept; reject; }}; export none; }};" ); } let _ = writeln!(out, " hold time 600; keepalive time 200;"); @@ -148,13 +161,18 @@ mod tests { 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 {")); - assert!(out.contains("local 10.0.0.3 as 65000;")); - assert!(out.contains("neighbor 10.0.0.2 as 65000;")); + // The snippet runs *on* the BIRD box (peer_addr, 10.0.0.2): its own + // `local` clause must be an address it owns, and `neighbor` is + // blackwall's own speaker address (local_addr, 10.0.0.3) — getting + // this backwards parses fine but fails to bind at runtime (confirmed + // live against BIRD 2.17.1). + assert!(out.contains("local 10.0.0.2 as 65000;")); + assert!(out.contains("neighbor 10.0.0.3 as 65000;")); assert!(out.contains( "ipv4 { import filter { if net ~ [ 203.0.113.0/24+ ] then accept; reject; }; export none; next hop self; };" )); assert!(out.contains( - "flow4 { import filter { if flow4.dst ~ [ 203.0.113.0/24+ ] then accept; reject; }; export none; };" + "flow4 { import filter { if net ~ [ 203.0.113.0/24+ ] then accept; reject; }; export none; };" )); assert!(out.contains("hold time 600; keepalive time 200;")); assert!(!out.contains("password")); diff --git a/crates/blackwall-bgp/tests/bird_validate.rs b/crates/blackwall-bgp/tests/bird_validate.rs new file mode 100644 index 0000000..d6b3d59 --- /dev/null +++ b/crates/blackwall-bgp/tests/bird_validate.rs @@ -0,0 +1,194 @@ +//! Validates `blackwall_bgp::render_bird_ibgp`'s output against **real BIRD2** +//! via `bird -p -c ` — a parse + config-check pass that does not start a +//! daemon or need root/netns. This is the renderer's #1 risk check: catching +//! syntactically- or semantically-invalid BIRD2 config before it ever reaches +//! a lab or production node. +//! +//! Skips cleanly (prints a message, does not fail) when `bird` isn't on +//! `PATH`, so it doesn't break `cargo test` on dev boxes without BIRD +//! installed. CI has `/usr/bin/bird` (BIRD 2.17.1), so it runs there. + +use blackwall_bgp::render_bird_ibgp; +use blackwall_core::Policy; +use std::path::Path; +use std::process::Command; + +/// `true` if a `bird` binary answers `--version` on `PATH`. +fn bird_on_path() -> bool { + Command::new("bird") + .arg("--version") + .output() + .is_ok_and(|o| o.status.success()) +} + +/// Build a `Policy` by parsing a real config string, exercising the same +/// parse-to-render path the `render.rs` unit tests use. +fn policy_from(cfg: &str) -> Policy { + blackwall_config::parse_str(cfg).expect("parse") +} + +fn base_cfg() -> String { + "interface wan eth0\n\ + ipv4 203.0.113.0/24\n\ + ipv6 2001:db8::/48\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 next-hop-v6=2001:db8::1 max=256 hold-down=60s \ + local-addr=10.0.0.3\n" + .to_string() +} + +/// The minimal top-level `bird.conf` BIRD needs to parse-check a generated +/// include: +/// - a `router id` (BIRD wants one even for `-p`); +/// - at least one `protocol` block — BIRD refuses a config with none +/// (`"No protocol is specified in the config file"`), so a no-op +/// `protocol device {}` is added unconditionally; +/// - `flow4`/`flow6 table` declarations, only when the include uses those +/// channels — unlike `ipv4`/`ipv6` (which fall back to the built-in +/// `master4`/`master6` tables), BIRD has no default table for flowspec +/// AFIs and errors with `"Routing table not specified"` without one. +fn wrapper(snippet_path: &Path, needs_flow4: bool, needs_flow6: bool) -> String { + let mut w = String::new(); + w.push_str("router id 10.0.0.1;\n"); + w.push_str("protocol device {}\n"); + if needs_flow4 { + w.push_str("flow4 table flow4tab;\n"); + } + if needs_flow6 { + w.push_str("flow6 table flow6tab;\n"); + } + w.push_str(&format!("include \"{}\";\n", snippet_path.display())); + w +} + +/// Write `snippet` (and any `extra_files` alongside it, e.g. a stub +/// `blackwall-secret.conf`) to a scratch dir, wrap it per [`wrapper`], and +/// assert `bird -p -c ` exits `0` — i.e. real BIRD2 accepts the +/// generated config as valid. Prints BIRD's stderr on failure so a syntax +/// regression is easy to diagnose from `cargo test` output. +fn assert_bird_accepts( + case: &str, + snippet: &str, + needs_flow4: bool, + needs_flow6: bool, + extra_files: &[(&str, &str)], +) { + let dir = std::env::temp_dir().join(format!( + "blackwall-bird-validate-{}-{case}", + std::process::id() + )); + std::fs::create_dir_all(&dir).expect("mkdir scratch dir"); + + let snippet_path = dir.join("blackwall.conf"); + std::fs::write(&snippet_path, snippet).expect("write snippet"); + for (name, contents) in extra_files { + std::fs::write(dir.join(name), contents).expect("write extra file"); + } + let wrapper_path = dir.join("bird.conf"); + std::fs::write( + &wrapper_path, + wrapper(&snippet_path, needs_flow4, needs_flow6), + ) + .expect("write wrapper"); + + let out = Command::new("bird") + .args(["-p", "-c"]) + .arg(&wrapper_path) + .output() + .expect("run `bird -p`"); + + let _ = std::fs::remove_dir_all(&dir); + + assert!( + out.status.success(), + "bird -p rejected the generated config for `{case}`:\n{}\n--- generated include ---\n{snippet}", + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn full_session_v4_v6_no_auth_parses_under_real_bird() { + if !bird_on_path() { + eprintln!("`bird` not on PATH; skipping bird -p validation"); + return; + } + let out = render_bird_ibgp(&policy_from(&base_cfg())).expect("render"); + assert_bird_accepts("full-v4-v6", &out, true, true, &[]); +} + +#[test] +fn v4_only_parses_under_real_bird() { + if !bird_on_path() { + eprintln!("`bird` not on PATH; skipping bird -p validation"); + return; + } + let cfg = "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"; + let out = render_bird_ibgp(&policy_from(cfg)).expect("render"); + assert_bird_accepts("v4-only", &out, true, false, &[]); +} + +#[test] +fn v6_only_parses_under_real_bird() { + if !bird_on_path() { + eprintln!("`bird` not on PATH; skipping bird -p validation"); + return; + } + let cfg = "interface wan eth0\n\ + 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"); + assert_bird_accepts("v6-only", &out, false, true, &[]); +} + +#[test] +fn gtsm_session_parses_under_real_bird() { + if !bird_on_path() { + eprintln!("`bird` not on PATH; skipping bird -p validation"); + return; + } + let cfg = format!("{} gtsm-hops=1\n", base_cfg().trim_end()); + let out = render_bird_ibgp(&policy_from(&cfg)).expect("render"); + assert!(out.contains("ttl security on;")); + assert_bird_accepts("gtsm", &out, true, true, &[]); +} + +#[test] +fn md5_session_parses_under_real_bird_with_stub_secret() { + if !bird_on_path() { + eprintln!("`bird` not on PATH; skipping bird -p validation"); + return; + } + let cfg = format!("{} md5=s3cret\n", base_cfg().trim_end()); + let out = render_bird_ibgp(&policy_from(&cfg)).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 + // `password "...";` sourced from that include, at the point the + // generated snippet places it inside `protocol bgp blackwall { ... }`. + assert_bird_accepts( + "md5", + &out, + true, + true, + &[( + "blackwall-secret.conf", + "password \"stub-not-the-real-secret\";\n", + )], + ); +} + +#[test] +fn defines_only_no_rtbh_parses_under_real_bird() { + if !bird_on_path() { + eprintln!("`bird` not on PATH; skipping bird -p validation"); + 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"); + assert!(!out.contains("protocol bgp blackwall")); + assert_bird_accepts("defines-only", &out, false, false, &[]); +} diff --git a/crates/blackwall-lab/scenarios/bird-gen.kdl b/crates/blackwall-lab/scenarios/bird-gen.kdl new file mode 100644 index 0000000..8bd0e75 --- /dev/null +++ b/crates/blackwall-lab/scenarios/bird-gen.kdl @@ -0,0 +1,47 @@ +// bird-gen gate: peers real BIRD2 against blackwall's *actual generated* +// iBGP include (`blackwalld bird-config` / `blackwall_bgp::render_bird_ibgp`) +// rather than the lab's own hand-rolled `daemon "bird"` config the other +// *-bird gates use. This is the strongest proof available that the renderer +// emits config real BIRD accepts *and* that establishes a working session — +// `bird -p` (blackwall-bgp's `bird_validate` tests) only proves it parses; +// this proves it also binds/negotiates correctly (it caught a real local/ +// neighbor address swap that `bird -p` could not, since both sides of a +// swapped `local`/`neighbor` pair are individually valid syntax). +// +// The include must be generated *before* this scenario runs (topology +// realization reads `include-file` synchronously; there is no in-scenario +// step that runs early enough to produce it first): +// target/debug/blackwalld bird-config \ +// --config crates/blackwall-lab/scenarios/fixtures/bird-gen.conf \ +// > /tmp/blackwall-lab-bird-gen-include.conf +// +// The fixture's `rtbh peer=10.0.0.1:179 local-addr=10.0.0.2` is hardcoded to +// match this topology's link (`subnet="10.0.0.0/30"` allocates `.1` to the +// first-declared endpoint, "peer", and `.2` to "speaker" — see +// `blackwall-lab/src/addr.rs::allocate`). From blackwall's Policy +// perspective, `peer` names BIRD's own address (so BIRD's box is "peer", +// running the generated config) and `local-addr` names blackwall's own +// speaker's address (so the speaker, "speaker", binds its BGP source to it +// via `BW_BGP_LOCAL_ADDR`). +topology "bird-gen" { + node "peer" { + daemon "bird" include-file="/tmp/blackwall-lab-bird-gen-include.conf" flowspec="yes" + } + node "speaker" { + run "speaker" \ + cmd="target/debug/lab-tests/blackwall-rtbh-interop blackholes_a_detected_target --ignored --nocapture" \ + env="BW_BGP_PEER={peer.addr}:179 BW_BGP_LOCAL_ADDR={speaker.addr}" + } + link "peer" "speaker" subnet="10.0.0.0/30" +} + +scenario "generated-include-establishes-session" { + // Proves the generated `protocol bgp blackwall { local ...; neighbor ...; }` + // binds and negotiates against real BIRD2 (the address-swap bug this + // gate exists to catch would leave this stuck in `start`/`Active`). + step wait node="peer" until="bgp-established" timeout="20s" + // Proves the generated `+`-form OWN import filter accepts blackwall's + // /32 blackhole (community 65535:666, RFC 7999). + step assert node="peer" cmd="birdc show route 203.0.113.7/32 all" \ + contains="(65535,666)" timeout="15s" +} diff --git a/crates/blackwall-lab/scenarios/fixtures/bird-gen.conf b/crates/blackwall-lab/scenarios/fixtures/bird-gen.conf new file mode 100644 index 0000000..f7b3097 --- /dev/null +++ b/crates/blackwall-lab/scenarios/fixtures/bird-gen.conf @@ -0,0 +1,3 @@ +interface wan eth0 +ipv4 203.0.113.0/24 +rtbh peer=10.0.0.1:179 local-as=214806 peer-as=214806 router-id=10.0.0.1 next-hop-v4=10.0.0.2 max=64 hold-down=10s local-addr=10.0.0.2 diff --git a/crates/blackwall-lab/src/render/bird.rs b/crates/blackwall-lab/src/render/bird.rs index f0c7d90..31b5e84 100644 --- a/crates/blackwall-lab/src/render/bird.rs +++ b/crates/blackwall-lab/src/render/bird.rs @@ -3,6 +3,7 @@ use crate::addr::AddressMap; use crate::error::LabError; use crate::topology::model::{DaemonKind, Node, Topology}; +use std::net::IpAddr; /// Render the BIRD config for `node`'s `bird` daemon. /// @@ -12,9 +13,19 @@ use crate::topology::model::{DaemonKind, Node, Topology}; /// When `flowspec="yes"`, the `protocol bgp` block also gets `flow4`/`flow6` /// channels (RFC 8955/8956) so the peer negotiates FlowSpec SAFI 133. /// +/// When the daemon has an `include-file` setting instead, the `protocol bgp` +/// block is *not* derived from `local-as`/`neighbor-node`/etc. at all: the +/// file's contents (blackwall's own generated `protocol bgp blackwall { ... }` +/// include — see `blackwalld bird-config` / `blackwall_bgp::render_bird_ibgp`) +/// are spliced in verbatim after the same router id / table / base-protocol +/// preamble. This is how the `bird-gen` lab scenario proves the *actual* +/// generated include establishes a real BIRD2 session, rather than the +/// lab's own hand-rolled approximation of one. +/// /// # Errors /// Returns [`LabError::Plan`] if the node has no `bird` daemon, a required -/// setting is missing, or an address cannot be resolved. +/// setting is missing, an address cannot be resolved, or (`include-file` +/// mode) the file cannot be read. pub fn render_bird(node: &Node, _topo: &Topology, map: &AddressMap) -> Result { let daemon = node .daemons @@ -22,6 +33,15 @@ pub fn render_bird(node: &Node, _topo: &Topology, map: &AddressMap) -> Result Result Result { + let included = std::fs::read_to_string(include_path).map_err(|e| { + LabError::Plan(format!( + "bird on `{}`: reading include-file `{include_path}`: {e}", + node.name + )) + })?; + let flow_tables = if flowspec { + "flow4 table flow4tab;\nflow6 table flow6tab;\n\n" + } else { + "" + }; + Ok(format!( + "log stderr all;\n\ +router id {router_id};\n\ +\n\ +{flow_tables}\ +protocol device {{\n\ + scan time 5;\n\ +}}\n\ +\n\ +protocol kernel {{\n\ + ipv4 {{ import none; export none; }};\n\ +}}\n\ +\n\ +{included}" + )) +} + #[cfg(test)] mod tests { use super::*; @@ -235,4 +290,55 @@ protocol bgp peer_speaker {\n\ Err(LabError::Plan(_)) )); } + + #[test] + fn include_file_splices_in_verbatim_and_skips_derived_settings() { + // `include-file` mode ignores local-as/neighbor-node/etc entirely — + // only `router_id`/`flowspec` (for the table preamble) still apply. + let mut topo = proof_topo(); + let dir = std::env::temp_dir().join(format!( + "blackwall-lab-render-bird-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("blackwall-gen.conf"); + std::fs::write( + &path, + "protocol bgp blackwall {\n local 10.0.0.1 as 65000;\n}\n", + ) + .unwrap(); + + topo.nodes[0].daemons[0].settings.clear(); + topo.nodes[0].daemons[0].settings.insert( + "include-file".to_owned(), + path.to_string_lossy().into_owned(), + ); + topo.nodes[0].daemons[0] + .settings + .insert("flowspec".to_owned(), "yes".to_owned()); + + let map = allocate(&topo).unwrap(); + let out = render_bird(&topo.nodes[0], &topo, &map).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + + assert!(out.contains("router id 10.0.0.1;")); + assert!(out.contains("flow4 table flow4tab;")); + assert!(out.contains("protocol bgp blackwall {\n local 10.0.0.1 as 65000;\n}\n")); + assert!(!out.contains("protocol bgp peer_")); + } + + #[test] + fn include_file_missing_errors() { + let mut topo = proof_topo(); + topo.nodes[0].daemons[0].settings.clear(); + topo.nodes[0].daemons[0].settings.insert( + "include-file".to_owned(), + "/nonexistent/blackwall-gen.conf".to_owned(), + ); + let map = allocate(&topo).unwrap(); + assert!(matches!( + render_bird(&topo.nodes[0], &topo, &map), + Err(LabError::Plan(_)) + )); + } } diff --git a/crates/blackwall-rtbh/tests/interop.rs b/crates/blackwall-rtbh/tests/interop.rs index 8f38ad1..f3008bb 100644 --- a/crates/blackwall-rtbh/tests/interop.rs +++ b/crates/blackwall-rtbh/tests/interop.rs @@ -1,8 +1,13 @@ //! Manual/netns interop exercise: drives an `RtbhManager` (auto-detection + //! operator-manual paths) against a real BGP peer to announce /32 blackholes //! (community 65535:666) via the native speaker. Ignored in CI; run by the -//! lab's rtbh-bird scenario. +//! lab's rtbh-bird and bird-gen scenarios. //! BW_BGP_PEER=10.0.0.1:179 cargo test -p blackwall-rtbh --test interop -- --ignored --nocapture +//! +//! `BW_BGP_LOCAL_ADDR` is optional: when set, the speaker binds its BGP +//! source to it (`PeerConfig::local_addr`) instead of letting the kernel +//! pick — needed by bird-gen, whose generated BIRD-side config pins a +//! specific `neighbor` address the speaker must connect *from*. use async_trait::async_trait; use blackwall_bgp::{spawn, PeerConfig}; @@ -48,7 +53,9 @@ async fn blackholes_a_detected_target() { hold_time: 90, md5: None, gtsm_hops: None, - local_addr: None, + local_addr: std::env::var("BW_BGP_LOCAL_ADDR") + .ok() + .map(|s| s.parse().expect("BW_BGP_LOCAL_ADDR must be an IP address")), }) .expect("valid iBGP config"); tokio::time::sleep(Duration::from_secs(3)).await; // let the session establish From e616924b9f74f134274c74ff801e713256146058 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 11 Jul 2026 20:31:06 -0400 Subject: [PATCH 8/8] fix(bird): flow import filter must use net.dst (not net) + live FlowSpec test --- crates/blackwall-bgp/src/render.rs | 16 ++++---- crates/blackwall-bgp/tests/bird_validate.rs | 9 +++++ crates/blackwall-lab/scenarios/bird-gen.kdl | 39 ++++++++++++++----- crates/blackwall-lab/src/render/bird.rs | 10 +++++ .../tests/flowspec_auto_interop.rs | 9 ++++- 5 files changed, 66 insertions(+), 17 deletions(-) diff --git a/crates/blackwall-bgp/src/render.rs b/crates/blackwall-bgp/src/render.rs index 7fdc2a7..ce0487c 100644 --- a/crates/blackwall-bgp/src/render.rs +++ b/crates/blackwall-bgp/src/render.rs @@ -90,13 +90,15 @@ pub fn render_bird_ibgp(policy: &Policy) -> Result { out, " ipv4 {{ import filter {{ if net ~ [ {m} ] then accept; reject; }}; export none; next hop self; }};" ); - // BIRD2 has no `flow4.dst`/`flow6.dst` accessor; a flowspec route's - // destination-prefix component is matched the same way as a plain - // route's network, via `net ~ [ prefix-set ]` (confirmed against real - // BIRD 2.17.1 with `bird -p`). + // On a flowspec channel `net` is the whole flow spec, not a plain + // prefix — its destination-prefix component is reached via `net.dst`. + // `net ~ [ prefix-set ]` type-checks (so `bird -p` passes) but at + // RUNTIME never matches a flow route, silently rejecting every + // FlowSpec rule; `net.dst ~ [ prefix-set ]` is the correct form + // (confirmed against real BIRD 2.17.1 by the bird-gen lab scenario). let _ = writeln!( out, - " flow4 {{ import filter {{ if net ~ [ {m} ] then accept; reject; }}; export none; }};" + " flow4 {{ import filter {{ if net.dst ~ [ {m} ] then accept; reject; }}; export none; }};" ); } if !v6.is_empty() { @@ -107,7 +109,7 @@ pub fn render_bird_ibgp(policy: &Policy) -> Result { ); let _ = writeln!( out, - " flow6 {{ import filter {{ if net ~ [ {m} ] then accept; reject; }}; export none; }};" + " flow6 {{ import filter {{ if net.dst ~ [ {m} ] then accept; reject; }}; export none; }};" ); } let _ = writeln!(out, " hold time 600; keepalive time 200;"); @@ -172,7 +174,7 @@ mod tests { "ipv4 { import filter { if net ~ [ 203.0.113.0/24+ ] then accept; reject; }; export none; next hop self; };" )); assert!(out.contains( - "flow4 { import filter { if net ~ [ 203.0.113.0/24+ ] then accept; reject; }; export none; };" + "flow4 { import filter { if net.dst ~ [ 203.0.113.0/24+ ] then accept; reject; }; export none; };" )); assert!(out.contains("hold time 600; keepalive time 200;")); assert!(!out.contains("password")); diff --git a/crates/blackwall-bgp/tests/bird_validate.rs b/crates/blackwall-bgp/tests/bird_validate.rs index d6b3d59..3198122 100644 --- a/crates/blackwall-bgp/tests/bird_validate.rs +++ b/crates/blackwall-bgp/tests/bird_validate.rs @@ -7,6 +7,15 @@ //! Skips cleanly (prints a message, does not fail) when `bird` isn't on //! `PATH`, so it doesn't break `cargo test` on dev boxes without BIRD //! installed. CI has `/usr/bin/bird` (BIRD 2.17.1), so it runs there. +//! +//! NOTE: `bird -p` is a **parse + type-check only** — it proves the config is +//! syntactically valid BIRD2, but does NOT execute any filter, so it cannot +//! catch filter-logic (runtime-match) bugs. For example, a flow import filter +//! written `if net ~ [...]` type-checks fine here yet silently rejects every +//! FlowSpec route at runtime (the correct accessor is `net.dst`). Semantic +//! correctness of the filters is proven only by the live lab scenario +//! (`crates/blackwall-lab/scenarios/bird-gen.kdl`), which runs a real session +//! and asserts routes actually propagate through them. use blackwall_bgp::render_bird_ibgp; use blackwall_core::Policy; diff --git a/crates/blackwall-lab/scenarios/bird-gen.kdl b/crates/blackwall-lab/scenarios/bird-gen.kdl index 8bd0e75..c75d33b 100644 --- a/crates/blackwall-lab/scenarios/bird-gen.kdl +++ b/crates/blackwall-lab/scenarios/bird-gen.kdl @@ -2,11 +2,15 @@ // iBGP include (`blackwalld bird-config` / `blackwall_bgp::render_bird_ibgp`) // rather than the lab's own hand-rolled `daemon "bird"` config the other // *-bird gates use. This is the strongest proof available that the renderer -// emits config real BIRD accepts *and* that establishes a working session — -// `bird -p` (blackwall-bgp's `bird_validate` tests) only proves it parses; -// this proves it also binds/negotiates correctly (it caught a real local/ -// neighbor address swap that `bird -p` could not, since both sides of a -// swapped `local`/`neighbor` pair are individually valid syntax). +// emits config real BIRD accepts *and* that establishes a working session +// *and* whose import filters actually accept the routes blackwall announces +// at runtime — the last of which `bird -p` (blackwall-bgp's `bird_validate` +// tests) fundamentally cannot check, since it type-checks filters but never +// executes them. This gate has already caught two real renderer bugs +// `bird -p` could not: a swapped local/neighbor address (individually valid +// syntax, unbindable at runtime) and a flow import filter written `net ~` +// instead of `net.dst ~` (type-checks, but silently rejects every FlowSpec +// route at runtime). // // The include must be generated *before* this scenario runs (topology // realization reads `include-file` synchronously; there is no in-scenario @@ -23,13 +27,23 @@ // running the generated config) and `local-addr` names blackwall's own // speaker's address (so the speaker, "speaker", binds its BGP source to it // via `BW_BGP_LOCAL_ADDR`). +// +// `flowspec="yes"` makes the generated include's flow4/flow6 channels usable: +// the include-file preamble then declares `flow4tab`/`flow6tab` and a +// `protocol direct` (for RFC 8955 §6 covering-route next-hop resolution), +// mirroring flowspec-bird.kdl's derived path. topology "bird-gen" { node "peer" { daemon "bird" include-file="/tmp/blackwall-lab-bird-gen-include.conf" flowspec="yes" } node "speaker" { + // The auto-mitigation driver announces (over the generated session) a + // covering unicast route, a FlowSpec drop rule for the *concentrated* + // target 203.0.113.7 (proto 17, dport 53), and an RTBH /32 blackhole + // for the *diffuse* target 203.0.113.8 — exercising BOTH the ipv4 and + // the flow4 generated import filters end to end. run "speaker" \ - cmd="target/debug/lab-tests/blackwall-rtbh-interop blackholes_a_detected_target --ignored --nocapture" \ + cmd="target/debug/lab-tests/blackwall-rtbh-flowspec_auto_interop selection_routes_to_flowspec_and_rtbh_on_real_bird --ignored --nocapture" \ env="BW_BGP_PEER={peer.addr}:179 BW_BGP_LOCAL_ADDR={speaker.addr}" } link "peer" "speaker" subnet="10.0.0.0/30" @@ -40,8 +54,15 @@ scenario "generated-include-establishes-session" { // binds and negotiates against real BIRD2 (the address-swap bug this // gate exists to catch would leave this stuck in `start`/`Active`). step wait node="peer" until="bgp-established" timeout="20s" - // Proves the generated `+`-form OWN import filter accepts blackwall's - // /32 blackhole (community 65535:666, RFC 7999). - step assert node="peer" cmd="birdc show route 203.0.113.7/32 all" \ + // Proves the generated `+`-form OWN *unicast* import filter (`net ~ [...]`) + // accepts blackwall's /32 blackhole (community 65535:666, RFC 7999). + step assert node="peer" cmd="birdc show route 203.0.113.8/32 all" \ contains="(65535,666)" timeout="15s" + // Proves the generated `flow4` import filter (`net.dst ~ [...]`) accepts a + // real FlowSpec route end to end — the assertion that catches the + // `net` vs `net.dst` filter-logic bug (`net ~` would leave flow4tab + // empty here). BIRD 2.17 renders the rule as: + // flow4 { dst 203.0.113.7/32; proto 17; dport 53; } + step assert node="peer" cmd="birdc show route table flow4tab" \ + contains="dst 203.0.113.7/32; proto 17; dport 53" timeout="15s" } diff --git a/crates/blackwall-lab/src/render/bird.rs b/crates/blackwall-lab/src/render/bird.rs index 31b5e84..090c99c 100644 --- a/crates/blackwall-lab/src/render/bird.rs +++ b/crates/blackwall-lab/src/render/bird.rs @@ -138,6 +138,15 @@ fn render_bird_with_include( } else { "" }; + // Same RFC 8955 §6 "safe update" requirement as the derived path: a + // FlowSpec route is only accepted if its covering unicast route's next + // hop resolves, which needs the connected /30 imported via `protocol + // direct`. Only added when flowspec, matching `render_bird`. + let direct_proto = if flowspec { + "protocol direct {\n ipv4;\n ipv6;\n interface \"*\";\n}\n\n" + } else { + "" + }; Ok(format!( "log stderr all;\n\ router id {router_id};\n\ @@ -151,6 +160,7 @@ protocol kernel {{\n\ ipv4 {{ import none; export none; }};\n\ }}\n\ \n\ +{direct_proto}\ {included}" )) } diff --git a/crates/blackwall-rtbh/tests/flowspec_auto_interop.rs b/crates/blackwall-rtbh/tests/flowspec_auto_interop.rs index 61ad36d..8ea87ba 100644 --- a/crates/blackwall-rtbh/tests/flowspec_auto_interop.rs +++ b/crates/blackwall-rtbh/tests/flowspec_auto_interop.rs @@ -15,6 +15,11 @@ //! destination is covered by a unicast route from the same origin AS, so we //! announce a covering `203.0.113.0/24` route before the FlowSpec rule. //! BW_BGP_PEER=10.0.0.1:179 cargo test -p blackwall-rtbh --test flowspec_auto_interop -- --ignored --nocapture +//! +//! `BW_BGP_LOCAL_ADDR` is optional: when set, the speaker binds its BGP +//! source to it (`PeerConfig::local_addr`) instead of letting the kernel +//! pick — needed by the bird-gen scenario, whose generated BIRD-side config +//! pins a specific `neighbor` address the speaker must connect *from*. use async_trait::async_trait; use blackwall_bgp::{spawn, FlowSpecRule, Origin, PeerConfig, Route}; @@ -123,7 +128,9 @@ async fn selection_routes_to_flowspec_and_rtbh_on_real_bird() { hold_time: 90, md5: None, gtsm_hops: None, - local_addr: None, + local_addr: std::env::var("BW_BGP_LOCAL_ADDR") + .ok() + .map(|s| s.parse().expect("BW_BGP_LOCAL_ADDR must be an IP address")), }) .expect("valid iBGP config"); tokio::time::sleep(Duration::from_secs(3)).await; // let the session establish