Skip to main content

isoloom_render/network/
router.rs

1//! The router both kinds of target add when a spec has `reach` rules: a machine on every network
2//! at its last address, forwarding between networks and filtering with nftables. Machines route
3//! the networks they aren't on through it.
4//!
5//! A network can instead name a machine of the environment as its `gateway` (an edge firewall):
6//! the router stays off it, and its machines route through that gateway.
7//!
8//! The facts (where the router stands, which networks it routes) are the plan's
9//! (`isoloom_plan::routing`), re-exported here beside what is rendered from them, so a backend
10//! reads the router in one place.
11
12use std::net::Ipv4Addr;
13
14pub use isoloom_plan::routing::{NAME, address, default_gateway, is_gateway, needed, networks, plain, routed, routes};
15use isoloom_spec::model::{Machine, Spec};
16
17use super::ipv6;
18
19/// The nftables ruleset: forwarding between networks is dropped, except what `reach` allows
20/// (and replies to it).
21#[must_use]
22pub fn nftables(spec: &Spec) -> String {
23    let body = forward_rules(spec).iter().map(|r| format!("    {r}")).collect::<Vec<_>>().join("\n");
24    format!("table inet isoloom {{\n  chain forward {{\n    type filter hook forward priority 0; policy drop;\n{body}\n  }}\n}}\n")
25}
26
27/// The rules of the router's forward chain: replies, then what each `reach` rule between two
28/// networks it routes opens.
29#[must_use]
30pub fn forward_rules(spec: &Spec) -> Vec<String> {
31    let mut rules = vec!["ct state established,related accept".to_owned()];
32    for r in spec.reach.iter().filter(|r| plain(spec, &r.from) && plain(spec, &r.to)) {
33        let (Some(from), Some(to)) = (spec.networks.get(&r.from), spec.networks.get(&r.to)) else {
34            continue;
35        };
36        let (from, to) = (&from.cidr, &to.cidr);
37        if r.ports.is_empty() {
38            rules.push(format!("ip saddr {from} ip daddr {to} accept"));
39        } else {
40            let ports = r.ports.iter().map(u16::to_string).collect::<Vec<_>>().join(", ");
41            rules.push(format!(
42                "ip saddr {from} ip daddr {to} meta l4proto {{ tcp, udp }} th dport {{ {ports} }} accept"
43            ));
44            rules.push(format!("ip saddr {from} ip daddr {to} icmp type echo-request accept"));
45        }
46        rules.extend(ipv6::forward_rules(spec, &r.from, &r.to, &r.ports));
47    }
48    rules
49}
50
51/// Shell that applies each network's `tc` on the router's interface into it (found by the
52/// router's address there), or `None` when no network asks for it. Idempotent (`replace`).
53#[must_use]
54pub fn tc_script(spec: &Spec) -> Option<String> {
55    netem_script(spec, networks(spec).map(|n| (n.as_str(), address(spec, n))))
56}
57
58/// The same on a machine's own interfaces (`addr`: its address on a network), so traffic
59/// between machines of one network (a direct cable, a LAN) is impaired too, not only what the
60/// router forwards into it: each end delays what it sends, `delay` is one-way.
61#[must_use]
62pub fn machine_tc_script(spec: &Spec, m: &Machine, addr: impl Fn(&str, u8) -> Ipv4Addr) -> Option<String> {
63    netem_script(spec, m.networks.iter().map(|(n, octet)| (n.as_str(), addr(n, *octet))))
64}
65
66/// One line per interface (found by its address) on a network with `tc`.
67fn netem_script<'a>(spec: &Spec, at: impl Iterator<Item = (&'a str, Ipv4Addr)>) -> Option<String> {
68    let lines: Vec<String> = at
69        .filter_map(|(n, a)| spec.networks.get(n).and_then(|net| net.tc.as_ref()).map(|t| (a, t)))
70        .map(|(a, t)| {
71            let addr = a.to_string().replace('.', "\\.");
72            format!(
73                "IF=$(ip -o -4 addr show | awk '$4 ~ /^{addr}\\//{{print $2}}' | head -n 1); [ -n \"$IF\" ] && tc qdisc replace dev \"$IF\" root netem {}",
74                t.netem()
75            )
76        })
77        .collect();
78    (!lines.is_empty()).then(|| format!("{}\n", lines.join("\n")))
79}
80
81#[cfg(test)]
82mod tests;