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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
86 changes: 83 additions & 3 deletions bin/blackwalld/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1635,9 +1642,12 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
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(())
Expand Down Expand Up @@ -2336,6 +2346,15 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
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,
Expand Down Expand Up @@ -2822,6 +2841,30 @@ async fn run_rtbh(action: RtbhCmd) -> Result<(), Box<dyn std::error::Error>> {
/// `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<dyn std::error::Error>> {
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,
Expand All @@ -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());
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -3091,6 +3136,7 @@ async fn xdp_block(
operator: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let policy = require_xdp(config)?;
reject_add_in_shadow(&policy, "xdp block")?;
if policy
.prefixes
.iter()
Expand Down Expand Up @@ -3151,7 +3197,8 @@ async fn xdp_rate_limit(
config: &std::path::Path,
operator: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
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());
}
Expand Down Expand Up @@ -3298,3 +3345,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());
}
}
73 changes: 56 additions & 17 deletions crates/blackwall-bgp/src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, BirdGenError> {
pub fn render_bird_ibgp(policy: &Policy, include_defines: bool) -> Result<String, BirdGenError> {
let v4: Vec<&IpNet> = policy
.prefixes
.iter()
Expand All @@ -49,11 +60,13 @@ pub fn render_bird_ibgp(policy: &Policy) -> Result<String, BirdGenError> {
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 {
Expand Down Expand Up @@ -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 {"));
Expand Down Expand Up @@ -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 {"));
Expand All @@ -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 {"));
Expand All @@ -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
}
Expand All @@ -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)
);
}
Expand All @@ -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 { .. })
));
}
Expand Down
12 changes: 6 additions & 6 deletions crates/blackwall-bgp/tests/bird_validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, &[]);
}

Expand All @@ -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, &[]);
}

Expand All @@ -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, &[]);
}

Expand All @@ -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, &[]);
}
Expand All @@ -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
Expand All @@ -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, &[]);
}
19 changes: 15 additions & 4 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<ip>` 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
Expand Down
Loading