Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 21 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down Expand Up @@ -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 →
Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions bin/blackwalld/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1291,6 +1297,16 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
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,
Expand Down Expand Up @@ -1414,6 +1430,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
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
Expand Down
2 changes: 2 additions & 0 deletions crates/blackwall-bgp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand All @@ -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
2 changes: 2 additions & 0 deletions crates/blackwall-bgp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
mod error;
mod flowspec;
mod message;
mod render;
mod route;
mod session_net;
mod update;
Expand All @@ -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,
Expand Down
272 changes: 272 additions & 0 deletions crates/blackwall-bgp/src/render.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
//! 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<String, BirdGenError> {
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 });
}

// 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 {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\";");
}
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; }};"
);
// 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.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 net.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::<Vec<_>>()
.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 {"));
// 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 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"));
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!(
"{}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} ")
}
}
Loading
Loading