Watch
0
0
Fork
You've already forked hyperhive
0

squid intercepts restricted and log agents' web traffic, named from the bound source address

This commit is contained in:
atlas 2026-11-13 05:17:01 +01:00 • committed by mara
commit 712dcf2b25
2 changed files with 1005 additions and 154 deletions

View file

@ -37,9 +37,11 @@ not the whole daemon-shared crate.
`container` (lifecycle), `container_config` (nspawn flags, systemd drop-ins),
`storage` (btrfs subvolumes, quotas, snapshots, disk measurement), `files`
(pause marker, socket dir, legacy tmpfiles) and `infra` (gateway nginx, forge
admin, infra containers). `egress` holds three sub-commands that never
touch the socket, `egress-load`, `egress-pin` and `egress-lease`; the `hive-egress`
unit, udev, a timer and dnsmasq run them to fill the sets of the anti-spoofing table (see
admin, infra containers). `egress` holds four sub-commands that never
touch the socket, `egress-load`, `egress-pin`, `egress-lease` and `egress-whois`; the
`hive-egress` unit, udev, a timer and dnsmasq run the first three to fill the sets of
the anti-spoofing table and the interception exemptions, and squid runs the last to
name the agent of an intercepted flow (see
`docs/4-implementation/networking/network.md`). Helpers more than one family calls live in `shared`.
Each module's `//!` header lists its ops.

View file

@ -1,6 +1,6 @@
//! The per-port bindings behind the `bridge hive_egress` anti-spoofing table
//! (`nix/host-modules/hive-egress.nix`), as three sub-commands run by systemd
//! units and by dnsmasq, never through the socket.
//! (`nix/host-modules/hive-egress.nix`), as four sub-commands run by systemd
//! units, by dnsmasq and by squid, never through the socket.
//!
//! - `egress-load <ruleset-file>` replaces the tables with the ruleset, the
//! current bindings and the leases of dnsmasq's lease file, in one nft
@ -14,6 +14,8 @@
//! still unbound.
//! - `egress-lease <action> [<mac> <ip> [<host>]]` is dnsmasq's `dhcp-script`:
//! it keeps the table's set of leased MAC and IPv4 pairs in step.
//! - `egress-whois` is squid's external ACL helper: it answers a source address
//! with the agent that holds it ([`agent_of`]).
//!
//! A port's owner is found from the host side: the port's peer index is the
//! index of one link in a registered container's network namespace (the leader
@ -36,6 +38,9 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anyhow::{Context as _, Result, bail};
/// The sub-command squid runs to learn which agent holds a source address.
pub(crate) const WHOIS: &str = "egress-whois";
/// The sub-command that binds ports.
pub(crate) const PIN: &str = "egress-pin";
@ -72,9 +77,19 @@ const STATE: &str = "/run/hive-egress-pins";
/// `HIVE_EGRESS_LOCK` names no other file.
const LOCK: &str = "/run/lock/hive-egress-pin.lock";
/// The file of the unrestricted agents' names, one per line, that
/// `HIVE_EGRESS_UNRESTRICTED` names. Unset or unreadable lists no agent.
const UNRESTRICTED_ENV: &str = "HIVE_EGRESS_UNRESTRICTED";
/// The prefix of an agent container's machine name.
const AGENT_PREFIX: &str = "h-";
/// The nft table the commands write into.
const TABLE: &str = "bridge hive_egress";
/// The table whose `direct_mac` set the commands fill.
const INET_TABLE: &str = "inet hive_egress";
/// An Ethernet address.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Mac([u8; 6]);
@ -264,8 +279,27 @@ fn is_plain_name(name: &str) -> bool {
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
}
/// The nft input that adds the bindings `pins` to the two sets.
pub(crate) fn pin_elements(pins: &[Pin]) -> String {
/// The MACs of the ports squid does not intercept: those of containers that are
/// not agents (`h-<name>`), and of the agents `unrestricted` names. A MAC in no
/// binding is in none of them, so a flow from an unknown source is intercepted
/// and squid refuses it.
pub(crate) fn direct_macs(pins: &[Pin], unrestricted: &[String]) -> Vec<Mac> {
let mut macs: Vec<Mac> = pins
.iter()
.filter(|p| match p.machine.strip_prefix(AGENT_PREFIX) {
Some(agent) => unrestricted.iter().any(|u| u == agent),
None => true,
})
.map(|p| p.mac)
.collect();
macs.sort_by_key(|m| m.0);
macs.dedup();
macs
}
/// The nft input that adds the bindings `pins` to the two sets of the bridge
/// table and the [`direct_macs`] to the exemption set of the inet table.
pub(crate) fn pin_elements(pins: &[Pin], unrestricted: &[String]) -> String {
let mut script = String::new();
for Pin { port, mac, .. } in pins {
let _ = writeln!(
@ -275,18 +309,93 @@ pub(crate) fn pin_elements(pins: &[Pin]) -> String {
mac.as_integer()
);
}
for mac in direct_macs(pins, unrestricted) {
let _ = writeln!(script, "add element {INET_TABLE} direct_mac {{ {mac} }}");
}
script
}
/// The nft input that makes the table's bindings exactly `pins`, in one
/// transaction: a reader never sees the sets empty in between.
pub(crate) fn pin_script(pins: &[Pin]) -> String {
/// The nft input that makes the bindings and the exemptions exactly those of
/// `pins` and `unrestricted`, in one transaction: a reader never sees a set
/// empty in between.
pub(crate) fn pin_script(pins: &[Pin], unrestricted: &[String]) -> String {
format!(
"flush set {TABLE} port_mac\nflush set {TABLE} port_chaddr\n{}",
pin_elements(pins)
"flush set {TABLE} port_mac\nflush set {TABLE} port_chaddr\n\
flush set {INET_TABLE} direct_mac\n{}",
pin_elements(pins, unrestricted)
)
}
/// The names in the unrestricted file: trimmed, without blank lines and
/// comments. A file that cannot be read lists no one, so every agent is then
/// intercepted.
fn unrestricted_names() -> Vec<String> {
let Some(path) = std::env::var_os(UNRESTRICTED_ENV).filter(|p| !p.is_empty()) else {
return Vec::new();
};
std::fs::read_to_string(path)
.unwrap_or_default()
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(str::to_owned)
.collect()
}
/// The agent whose container holds the IPv4 address `ip`, as dnsmasq's lease
/// file and the bindings of the ports say: the lease names a MAC, the MAC is
/// bound to one port, and that port's machine is `h-<agent>`. `None` when any
/// step is missing or ambiguous (two leases for the address under different
/// MACs, a MAC bound twice, a machine that is no agent, a name that is no
/// identifier): a flow whose agent cannot be told is refused, never guessed.
pub(crate) fn agent_of(ip: &str, leases: &str, pins: &[Pin], now: u64) -> Option<String> {
let ip: std::net::Ipv4Addr = ip.parse().ok()?;
let mut macs = leases.lines().filter_map(|line| {
let mut cols = line.split_whitespace();
let (expiry, mac, addr) = (cols.next()?, cols.next()?, cols.next()?);
let live = match expiry.parse::<u64>().ok()? {
0 => true,
t => t > now,
};
(live && addr.parse::<std::net::Ipv4Addr>().ok()? == ip).then(|| Mac::parse(mac))
});
let mac = macs.next()??;
if macs.any(|other| other != Some(mac)) {
return None;
}
let mut machines = pins.iter().filter(|p| p.mac == mac).map(|p| &p.machine);
let machine = machines.next()?;
if machines.any(|other| other != machine) {
return None;
}
let agent = machine.strip_prefix(AGENT_PREFIX)?;
hive_types::Ident::parse(agent).ok()?;
Some(agent.to_owned())
}
/// `egress-whois`: squid's external ACL helper. Each line on stdin starts with
/// a source address; the answer is `OK user=<agent>` or `ERR`. The two files
/// are read for every line, since squid caches the answers itself.
fn whois() -> Result<()> {
use std::io::{BufRead as _, Write as _};
let leasefile = std::env::var("HIVE_EGRESS_LEASEFILE").unwrap_or_else(|_| LEASEFILE.into());
let stdin = std::io::stdin();
let mut stdout = std::io::stdout().lock();
for line in stdin.lock().lines() {
let line = line.context("read stdin")?;
let ip = line.split_whitespace().next().unwrap_or_default();
let pins = parse_state(&std::fs::read_to_string(state_path()).unwrap_or_default());
let leases = std::fs::read_to_string(&leasefile).unwrap_or_default();
match agent_of(ip, &leases, &pins, now_secs()) {
Some(agent) => writeln!(stdout, "OK user={agent}"),
None => writeln!(stdout, "ERR"),
}
.context("write stdout")?;
stdout.flush().context("flush stdout")?;
}
Ok(())
}
/// The nft input for one lease event, or `None` for an event that changes
/// nothing: `init`, an IPv6 address, an address or MAC that does not parse.
///
@ -588,7 +697,7 @@ fn pin(args: &[String]) -> Result<()> {
let survey = {
let _lock = lock()?;
let survey = survey(&bridge)?;
nft(&pin_script(&survey.resolution.pins))?;
nft(&pin_script(&survey.resolution.pins, &unrestricted_names()))?;
commit(&survey)?;
survey
};
@ -654,7 +763,7 @@ fn load(args: &[String]) -> Result<()> {
let _lock = lock()?;
let found = survey(&bridge);
let pins = match &found {
Ok(found) => pin_elements(&found.resolution.pins),
Ok(found) => pin_elements(&found.resolution.pins, &unrestricted_names()),
Err(e) => {
eprintln!("{LOAD}: binding the ports failed, loading without bindings: {e:#}");
String::new()
@ -690,6 +799,7 @@ pub(crate) fn run(args: &[String]) -> Option<i32> {
let (name, rest) = args.split_first()?;
let result = match name.as_str() {
PIN => pin(rest),
WHOIS => whois(),
LOAD => load(rest),
LEASE => lease(rest),
_ => return None,
@ -902,7 +1012,7 @@ mod tests {
#[test]
fn the_ruleset_loads_with_the_bindings_and_leases_in_one_script() {
let calls = std::cell::RefCell::new(Vec::new());
let pins = pin_elements(&[pin("vb-a", 10, 10, "a")]);
let pins = pin_elements(&[pin("vb-a", 10, 10, "a")], &[]);
let leases = lease_script("add", "02:00:00:00:00:0a", "10.42.0.10", 90).unwrap();
load_ruleset("table bridge hive_egress { }", &pins, &leases, |s| {
calls.borrow_mut().push(s.to_string());
@ -921,7 +1031,7 @@ mod tests {
#[test]
fn a_lease_load_that_fails_is_a_warning_and_the_bindings_still_load() {
let calls = std::cell::RefCell::new(Vec::new());
let pins = pin_elements(&[pin("vb-a", 10, 10, "a")]);
let pins = pin_elements(&[pin("vb-a", 10, 10, "a")], &[]);
let leases = lease_script("add", "02:00:00:00:00:0a", "10.42.0.10", 90).unwrap();
let result = load_ruleset("RULESET", &pins, &leases, |s| {
calls.borrow_mut().push(s.to_string());
@ -944,16 +1054,152 @@ mod tests {
}
#[test]
fn pin_script_replaces_both_sets_in_one_script() {
let script = pin_script(&[pin("vb-a", 10, 10, "a")]);
fn pin_script_replaces_all_three_sets_in_one_script() {
let script = pin_script(&[pin("vb-h-a", 10, 10, "h-a")], &[]);
assert_eq!(
script,
"flush set bridge hive_egress port_mac\n\
flush set bridge hive_egress port_chaddr\n\
add element bridge hive_egress port_mac { \"vb-a\" . 02:00:00:00:00:0a }\n\
add element bridge hive_egress port_chaddr { \"vb-a\" . 0x02000000000a }\n"
flush set inet hive_egress direct_mac\n\
add element bridge hive_egress port_mac { \"vb-h-a\" . 02:00:00:00:00:0a }\n\
add element bridge hive_egress port_chaddr { \"vb-h-a\" . 0x02000000000a }\n"
);
assert!(!pin_script(&[]).contains("add element"));
assert!(!pin_script(&[], &[]).contains("add element"));
}
/// Service containers and the agents the file lists are exempt; an agent the
/// file does not list, and one it lists under another name, are not.
#[test]
fn only_service_containers_and_unrestricted_agents_are_exempt() {
let pins = [
pin("vb-h-atlas", 10, 1, "h-atlas"),
pin("vb-h-uma", 11, 2, "h-uma"),
pin("vb-matrix", 12, 3, "hive-matrix"),
pin("vb-h-umax", 13, 4, "h-umax"),
];
let listed = ["uma".to_string()];
assert_eq!(direct_macs(&pins, &listed), vec![mac(2), mac(3)]);
// Control: with nothing listed, only the service container stays.
assert_eq!(direct_macs(&pins, &[]), vec![mac(3)]);
let script = pin_elements(&pins, &listed);
assert!(script.contains("add element inet hive_egress direct_mac { 02:00:00:00:00:02 }"));
assert!(script.contains("add element inet hive_egress direct_mac { 02:00:00:00:00:03 }"));
assert!(!script.contains("direct_mac { 02:00:00:00:00:01 }"));
assert!(!script.contains("direct_mac { 02:00:00:00:00:04 }"));
}
const NOW: u64 = 1_000;
fn lease_line(expiry: u64, last: u8, ip: &str) -> String {
format!("{expiry} {} {ip} * *\n", mac(last))
}
#[test]
fn an_address_belongs_to_the_agent_of_the_leased_mac() {
let pins = [
pin("vb-h-atlas", 10, 1, "h-atlas"),
pin("vb-h-uma", 11, 2, "h-uma"),
];
let leases = lease_line(NOW + 60, 1, "10.42.0.5") + &lease_line(NOW + 60, 2, "10.42.0.6");
assert_eq!(
agent_of("10.42.0.5", &leases, &pins, NOW).as_deref(),
Some("atlas")
);
assert_eq!(
agent_of("10.42.0.6", &leases, &pins, NOW).as_deref(),
Some("uma")
);
// A lease with no end counts.
let forever = lease_line(0, 1, "10.42.0.7");
assert_eq!(
agent_of("10.42.0.7", &forever, &pins, NOW).as_deref(),
Some("atlas")
);
}
#[test]
fn an_address_nobody_can_be_told_to_hold_has_no_agent() {
let pins = [
pin("vb-h-atlas", 10, 1, "h-atlas"),
pin("vb-matrix", 11, 2, "hive-matrix"),
pin("vb-h-x", 12, 3, "h-x y"),
];
let leases = lease_line(NOW + 60, 1, "10.42.0.5");
// Control for every refusal below: the same lease and pins name an agent.
assert!(agent_of("10.42.0.5", &leases, &pins, NOW).is_some());
// No lease for the address, an expired one, and text that is no address.
assert_eq!(agent_of("10.42.0.9", &leases, &pins, NOW), None);
assert_eq!(
agent_of("10.42.0.5", &lease_line(NOW, 1, "10.42.0.5"), &pins, NOW),
None
);
assert_eq!(
agent_of(
"10.42.0.5",
&lease_line(NOW - 1, 1, "10.42.0.5"),
&pins,
NOW
),
None
);
for bad in ["", "-", "10.42.0.5.1", "fe80::1", "10.42.0.5/32"] {
assert_eq!(agent_of(bad, &leases, &pins, NOW), None, "{bad:?}");
}
// A leased MAC no port is bound to.
assert_eq!(
agent_of(
"10.42.0.8",
&lease_line(NOW + 60, 7, "10.42.0.8"),
&pins,
NOW
),
None
);
// A service container is no agent, and a machine name that is no identifier names none.
assert_eq!(
agent_of(
"10.42.0.8",
&lease_line(NOW + 60, 2, "10.42.0.8"),
&pins,
NOW
),
None
);
assert_eq!(
agent_of(
"10.42.0.8",
&lease_line(NOW + 60, 3, "10.42.0.8"),
&pins,
NOW
),
None
);
}
#[test]
fn two_macs_leased_the_same_address_name_no_agent() {
let pins = [
pin("vb-h-atlas", 10, 1, "h-atlas"),
pin("vb-h-uma", 11, 2, "h-uma"),
];
let leases = lease_line(NOW + 60, 1, "10.42.0.5") + &lease_line(NOW + 60, 2, "10.42.0.5");
assert_eq!(agent_of("10.42.0.5", &leases, &pins, NOW), None);
// Control: an expired second lease is no rival.
let leases = lease_line(NOW + 60, 1, "10.42.0.5") + &lease_line(NOW - 5, 2, "10.42.0.5");
assert_eq!(
agent_of("10.42.0.5", &leases, &pins, NOW).as_deref(),
Some("atlas")
);
}
#[test]
fn a_mac_bound_to_two_machines_names_no_agent() {
let pins = [
pin("vb-h-atlas", 10, 1, "h-atlas"),
pin("vb-h-uma", 11, 1, "h-uma"),
];
let leases = lease_line(NOW + 60, 1, "10.42.0.5");
assert_eq!(agent_of("10.42.0.5", &leases, &pins, NOW), None);
}
#[test]