Skip to main content

isoloom_plan/
routing.rs

1//! How traffic crosses networks: Isoloom's router (a machine on every network it routes, at the
2//! last address, when `reach` rules need one) or a machine named as a network's `gateway`. Facts
3//! only; `isoloom-render`'s `router` module renders them (routes as commands, the nftables
4//! ruleset, netem) and re-exports these.
5
6use std::net::Ipv4Addr;
7
8use isoloom_spec::model::{Machine, Spec};
9
10use crate::addressing::{address as machine_address, cidr};
11
12/// The router machine's name (a spec can't use it: machine names are user names, and this
13/// one is prefixed).
14pub const NAME: &str = "isoloom-router";
15
16/// Whether Isoloom routes this network with its own router: no machine is its gateway (a
17/// network the spec doesn't declare has none).
18#[must_use]
19pub fn plain(spec: &Spec, network: &str) -> bool {
20    spec.networks.get(network).is_none_or(|n| n.gateway.is_none())
21}
22
23/// Whether the spec needs a router: `reach` rules between networks it routes itself.
24#[must_use]
25pub fn needed(spec: &Spec) -> bool {
26    spec.reach.iter().any(|r| plain(spec, &r.from) && plain(spec, &r.to))
27}
28
29/// Whether traffic crosses networks at all (Isoloom's router, or a machine as gateway).
30#[must_use]
31pub fn routed(spec: &Spec) -> bool {
32    needed(spec) || spec.networks.values().any(|n| n.gateway.is_some())
33}
34
35/// The networks the router is on.
36pub fn networks(spec: &Spec) -> impl Iterator<Item = &String> {
37    spec.networks.keys().filter(|n| plain(spec, n))
38}
39
40/// The router's address on a network.
41#[must_use]
42pub fn address(spec: &Spec, network: &str) -> Ipv4Addr {
43    cidr(spec, network).router()
44}
45
46/// Whether the machine is the gateway of one of its networks.
47#[must_use]
48pub fn is_gateway(spec: &Spec, name: &str) -> bool {
49    spec.networks.values().any(|n| n.gateway.as_deref() == Some(name))
50}
51
52/// Where a machine sends traffic for everywhere else (the internet included): the gateway
53/// of its first network routed by another machine.
54#[must_use]
55pub fn default_gateway(spec: &Spec, name: &str, m: &Machine) -> Option<Ipv4Addr> {
56    m.networks
57        .keys()
58        .find(|n| spec.networks.get(*n).and_then(|net| net.gateway.as_deref()).is_some_and(|g| g != name))
59        .map(|n| cidr(spec, n).gateway())
60}
61
62/// Routes a machine needs to the networks it isn't on: a network with a gateway through that
63/// gateway (on a network they share), the others through the router (on the machine's first
64/// network the router is on). Networks with neither route are left to the default route.
65#[must_use]
66pub fn routes(spec: &Spec, name: &str, m: &Machine) -> Vec<(String, Ipv4Addr)> {
67    let via_router = needed(spec)
68        .then(|| m.networks.keys().find(|n| plain(spec, n)))
69        .flatten()
70        .map(|n| address(spec, n));
71    spec.networks
72        .iter()
73        .filter(|(n, _)| !m.networks.contains_key(*n))
74        .filter_map(|(_, net)| {
75            let via = match &net.gateway {
76                Some(gw) if gw != name => {
77                    let g = spec.machines.get(gw)?;
78                    m.networks
79                        .keys()
80                        .find_map(|shared| g.networks.get(shared).map(|octet| machine_address(spec, shared, *octet)))
81                }
82                Some(_) => None,
83                None => via_router,
84            }?;
85            Some((net.cidr.clone(), via))
86        })
87        .collect()
88}
89
90/// A machine is offline when none of its networks reaches the internet and no gateway
91/// decides for it.
92#[must_use]
93pub fn offline(spec: &Spec, name: &str, m: &Machine) -> bool {
94    !m.networks.keys().any(|n| spec.networks.get(n).is_some_and(|net| net.internet)) && default_gateway(spec, name, m).is_none()
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::testing;
101
102    /// `fw` routes `inside`; Isoloom's router joins `front` and `back`, which `reach` connects.
103    const LAB: &str = "version: 1
104name: t
105networks:
106  front: { cidr: 10.1.0.0/24 }
107  back: { cidr: 10.2.0.0/24, internet: false }
108  inside: { cidr: 10.3.0.0/24, gateway: fw, internet: false }
109reach: [{ from: front, to: back }]
110machines:
111  web: { networks: { front: 10 }, app: { image: nginx } }
112  db: { networks: { back: 20 }, app: { image: postgres } }
113  fw: { networks: { front: 1, inside: 1 }, app: { image: alpine } }
114  pc: { networks: { inside: 30 }, app: { image: alpine } }
115";
116
117    #[test]
118    fn the_router_joins_the_networks_no_machine_routes() {
119        let spec = testing::spec(LAB);
120        assert!(needed(&spec) && routed(&spec));
121        assert_eq!(networks(&spec).collect::<Vec<_>>(), ["front", "back"]);
122        assert!(plain(&spec, "front") && !plain(&spec, "inside"));
123        assert_eq!(address(&spec, "back"), Ipv4Addr::new(10, 2, 0, 254));
124    }
125
126    #[test]
127    fn a_gateway_routes_its_network_and_is_the_default_route_of_its_machines() {
128        let spec = testing::spec(LAB);
129        assert!(is_gateway(&spec, "fw") && !is_gateway(&spec, "web"));
130        let pc = &spec.machines["pc"];
131        assert_eq!(default_gateway(&spec, "pc", pc), Some(Ipv4Addr::new(10, 3, 0, 1)));
132        assert_eq!(default_gateway(&spec, "fw", &spec.machines["fw"]), None, "a gateway isn't its own");
133        assert!(!offline(&spec, "pc", pc), "its gateway decides");
134        assert!(offline(&spec, "db", &spec.machines["db"]));
135    }
136
137    #[test]
138    fn routes_go_through_the_router_or_the_gateway_on_a_shared_network() {
139        let spec = testing::spec(LAB);
140        assert_eq!(
141            routes(&spec, "web", &spec.machines["web"]),
142            [
143                ("10.2.0.0/24".to_owned(), Ipv4Addr::new(10, 1, 0, 254)),
144                ("10.3.0.0/24".to_owned(), Ipv4Addr::new(10, 1, 0, 1))
145            ]
146        );
147        assert_eq!(
148            routes(&spec, "fw", &spec.machines["fw"]),
149            [("10.2.0.0/24".to_owned(), Ipv4Addr::new(10, 1, 0, 254))]
150        );
151        assert_eq!(
152            routes(&spec, "pc", &spec.machines["pc"]),
153            [],
154            "nothing it shares a network with routes them: its default route does"
155        );
156    }
157
158    #[test]
159    fn a_gateway_routes_without_the_router_and_reach_into_its_network_needs_none() {
160        let spec = testing::spec(
161            "version: 1\nname: t\nnetworks: { a: { cidr: 10.1.0.0/24 }, inside: { cidr: 10.3.0.0/24, gateway: fw } }\nreach: [{ from: a, to: inside }]\nmachines:\n  fw: { networks: { a: 1, inside: 1 }, app: { image: x } }\n",
162        );
163        assert!(routed(&spec) && !needed(&spec));
164        assert_eq!(networks(&spec).collect::<Vec<_>>(), ["a"]);
165    }
166
167    #[test]
168    fn a_machine_named_the_gateway_of_a_network_it_isnt_on_routes_nothing_through_itself() {
169        // Validation refuses it; unvalidated, the plan still answers: no route.
170        let spec = testing::spec(
171            "version: 1\nname: t\nnetworks: { a: { cidr: 10.1.0.0/24 }, inside: { cidr: 10.3.0.0/24, gateway: fw } }\nmachines:\n  fw: { networks: { a: 1 }, app: { image: x } }\n",
172        );
173        assert_eq!(routes(&spec, "fw", &spec.machines["fw"]), []);
174    }
175
176    #[test]
177    fn without_reach_or_gateways_nothing_is_routed() {
178        let spec = testing::spec(
179            "version: 1\nname: t\nnetworks: { a: { cidr: 10.1.0.0/24 }, b: { cidr: 10.2.0.0/24 } }\nmachines:\n  m: { networks: { a: 10 }, app: { image: x } }\n",
180        );
181        assert!(!needed(&spec) && !routed(&spec));
182        assert_eq!(routes(&spec, "m", &spec.machines["m"]), []);
183    }
184}