Skip to main content

isoloom_render/network/
trunks.rs

1//! 802.1Q trunks: a machine on several VLANs of one LAN gets one tagged link instead of one
2//! interface per VLAN, as a router-on-a-stick or a server on a trunk port has.
3//!
4//! **Containers.** Each VLAN stays a Docker network of its own (machines on one VLAN, the router,
5//! the checks and the internet work as for any network). The LAN gets a switch container on each
6//! of its VLAN networks (at the controller address, unused on Docker) and on one internal link
7//! per trunk machine; inside, a bridge per VLAN joins the VLAN network and the trunks' `.<id>`
8//! subinterfaces, so frames cross the trunk tagged. The trunk machine's network sidecar names
9//! its end after the LAN and puts its addresses on `<lan>.<id>`. A LAN with a `switch` of its
10//! own (a switch appliance) gets no switch container: the appliance takes its place on the same
11//! networks, and the trunk machines' end is the same.
12//!
13//! **VMs.** The hypervisor carries each VLAN on its own network, one NIC each, as for any
14//! network; inside, a veth pair stands for the trunk: its end named after the LAN holds the
15//! addresses on `<lan>.<id>`, the other end's `.<id>` joins a bridge with the VLAN's NIC. Tagged
16//! frames cross the veth (a capture on `<lan>` shows them). Each `<lan>.<id>` takes its NIC's MAC
17//! address and the NIC, now only a bridge port, a local one, so the hypervisor still sees only
18//! the MAC it gave the VM (no promiscuous mode, on every provider).
19
20use std::net::Ipv4Addr;
21
22use isoloom_plan::addressing::cidr;
23use isoloom_spec::images::is_windows;
24use isoloom_spec::model::{Machine, Spec};
25
26/// One VLAN a trunk carries.
27#[derive(Debug, Clone, PartialEq, Eq)]
28#[non_exhaustive]
29pub struct Vlan {
30    /// The VLAN's network.
31    pub network: String,
32    /// Its 802.1Q id.
33    pub id: u16,
34    /// The machine's last octet on it.
35    pub octet: u8,
36}
37
38/// A machine's trunk on a LAN: the VLANs it carries.
39#[derive(Debug, Clone, PartialEq, Eq)]
40#[non_exhaustive]
41pub struct Trunk {
42    /// The LAN the VLANs are tagged on.
43    pub lan: String,
44    /// The machine carrying the trunk.
45    pub machine: String,
46    /// Each VLAN it carries, in the machine's order.
47    pub vlans: Vec<Vlan>,
48}
49
50/// The trunks of the machines run as containers: each on two or more VLANs of one LAN.
51#[must_use]
52pub fn containers(spec: &Spec) -> Vec<Trunk> {
53    of_machines(spec, |m| m.docker.is_some())
54}
55
56/// The trunks of the machines run as Linux VMs: each on two or more VLANs of one LAN.
57#[must_use]
58pub fn vms(spec: &Spec) -> Vec<Trunk> {
59    of_machines(spec, |m| m.vm.as_ref().is_some_and(|vm| !is_windows(&vm.os)))
60}
61
62/// The trunks of the machines `kept` keeps: a trunk per machine and LAN with two VLANs or more.
63fn of_machines(spec: &Spec, kept: impl Fn(&Machine) -> bool) -> Vec<Trunk> {
64    let mut out: Vec<Trunk> = Vec::new();
65    for (name, m) in spec.machines.iter().filter(|(_, m)| kept(m)) {
66        for (net, octet) in &m.networks {
67            let Some((lan, id)) = spec.networks.get(net).and_then(|n| n.vlan.as_ref()) else {
68                continue;
69            };
70            let vlan = Vlan {
71                network: net.clone(),
72                id: *id,
73                octet: *octet,
74            };
75            match out.iter_mut().find(|t| &t.machine == name && &t.lan == lan) {
76                Some(t) => t.vlans.push(vlan),
77                None => out.push(Trunk {
78                    lan: lan.clone(),
79                    machine: name.clone(),
80                    vlans: vec![vlan],
81                }),
82            }
83        }
84    }
85    out.retain(|t| t.vlans.len() >= 2);
86    out
87}
88
89/// The trunks of one machine.
90pub fn of<'a>(trunks: &'a [Trunk], machine: &str) -> impl Iterator<Item = &'a Trunk> {
91    trunks.iter().filter(move |t| t.machine == machine)
92}
93
94/// Whether a machine reaches a VLAN network through a trunk rather than an interface of its own.
95#[must_use]
96pub fn carried(trunks: &[Trunk], machine: &str, network: &str) -> bool {
97    of(trunks, machine).any(|t| t.vlans.iter().any(|v| v.network == network))
98}
99
100/// The Compose network of a trunk: an internal link between the machine and the LAN's switch.
101#[must_use]
102pub fn link_network(t: &Trunk) -> String {
103    format!("isoloom-trunk-{}-{}", t.lan, t.machine)
104}
105
106/// The LAN's switch service.
107#[must_use]
108pub fn switch_name(lan: &str) -> String {
109    format!("isoloom-switch-{lan}")
110}
111
112/// The interface name on the machine's end: the LAN's (`office`, then `office.10`), within
113/// Linux's 15 characters with room for `.<id>`.
114#[must_use]
115pub fn interface(lan: &str) -> String {
116    lan.chars().take(10).collect::<String>().trim_end_matches('-').to_owned()
117}
118
119/// One end of a trunk's link.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum End {
122    /// The machine's end.
123    Machine,
124    /// The switch's end.
125    Switch,
126}
127
128/// A locally administered MAC address for one end of a trunk, so each end finds its interface
129/// whatever Docker names it: FNV-1a of `<lan>/<machine>`, the last bit telling the ends apart.
130#[must_use]
131pub fn mac(t: &Trunk, end: End) -> String {
132    let hash = format!("{}/{}", t.lan, t.machine)
133        .bytes()
134        .fold(2_166_136_261_u32, |hash, byte| (hash ^ u32::from(byte)).wrapping_mul(16_777_619));
135    let [first, second, third, last] = hash.to_be_bytes();
136    let side = match end {
137        End::Machine => 0,
138        End::Switch => 1,
139    };
140    format!("02:1e:{first:02x}:{second:02x}:{third:02x}:{:02x}", (last & 0xfe) | side)
141}
142
143/// Shell that sets `IF` to the interface with this MAC address.
144fn find_by_mac(mac: &str) -> String {
145    format!("IF=$(ip -o link | awk 'tolower($0) ~ /{mac}/ {{print $2}}' | cut -d@ -f1 | tr -d : | head -n 1)")
146}
147
148/// Shell that sets `IF` to the interface holding this address.
149fn find_by_address(addr: Ipv4Addr) -> String {
150    format!(
151        "IF=$(ip -o -4 addr show | awk '$4 ~ /^{}\\//{{print $2}}' | head -n 1)",
152        addr.to_string().replace('.', "\\.")
153    )
154}
155
156/// The switch's address on a VLAN network: the controller's, which Docker leaves free.
157#[must_use]
158pub fn switch_address(spec: &Spec, network: &str) -> Ipv4Addr {
159    cidr(spec, network).controller()
160}
161
162/// The switch's start: a bridge per VLAN joining the VLAN network and every trunk's `.<id>`.
163#[must_use]
164pub fn switch_script(spec: &Spec, trunks: &[&Trunk]) -> String {
165    let mut bridged: Vec<&Vlan> = Vec::new();
166    for v in trunks.iter().flat_map(|t| &t.vlans) {
167        if !bridged.iter().any(|b| b.id == v.id) {
168            bridged.push(v);
169        }
170    }
171    let bridge_lines = bridged.iter().map(|v| {
172        format!(
173            "{} && ip addr flush dev $IF && ip link add br{id} type bridge && ip link set $IF master br{id} && ip link set br{id} up && ",
174            find_by_address(switch_address(spec, &v.network)),
175            id = v.id
176        )
177    });
178    let trunk_lines = trunks.iter().map(|t| {
179        let tagged: String = t
180            .vlans
181            .iter()
182            .map(|v| {
183                format!(
184                    "ip link add link $IF name $IF.{id} type vlan id {id} && ip link set $IF.{id} master br{id} && ip link set $IF.{id} up && ",
185                    id = v.id
186                )
187            })
188            .collect::<Vec<_>>()
189            .concat();
190        format!(
191            "{} && ip addr flush dev $IF && ip link set $IF up && {tagged}",
192            find_by_mac(&mac(t, End::Switch))
193        )
194    });
195    bridge_lines
196        .chain(trunk_lines)
197        .chain(std::iter::once("exec sleep infinity".to_owned()))
198        .collect()
199}
200
201/// What the switch's healthcheck waits for: every bridge up.
202#[must_use]
203pub fn switch_ready(trunks: &[&Trunk]) -> String {
204    let mut ids: Vec<u16> = trunks.iter().flat_map(|t| t.vlans.iter().map(|v| v.id)).collect();
205    ids.sort_unstable();
206    ids.dedup();
207    ids.iter()
208        .map(|id| format!("ip link show br{id} | grep -q 'state UP'"))
209        .collect::<Vec<_>>()
210        .join(" && ")
211}
212
213/// The machine's end, first thing its network sidecar does: the link named after the LAN, and
214/// its addresses on `<lan>.<id>`. With no other way out, the default route goes through a VLAN
215/// with internet (Docker's gateway on it, reached through the switch).
216#[must_use]
217pub fn machine_commands(spec: &Spec, m: &Machine, t: &Trunk, address: impl Fn(&str, u8) -> Ipv4Addr, gateway: impl Fn(&str) -> Ipv4Addr) -> Vec<String> {
218    let name = interface(&t.lan);
219    let mut cmds = vec![format!(
220        "{} && ip addr flush dev $IF && ip link set $IF down && ip link set $IF name {name} && ip link set {name} up",
221        find_by_mac(&mac(t, End::Machine))
222    )];
223    for v in &t.vlans {
224        cmds.push(format!(
225            "ip link add link {name} name {name}.{id} type vlan id {id} && ip addr add {}/{} dev {name}.{id} && ip link set {name}.{id} up",
226            address(&v.network, v.octet),
227            cidr(spec, &v.network).len,
228            id = v.id
229        ));
230    }
231    let internet = |n: &str| spec.networks.get(n).is_some_and(|net| net.internet);
232    let other_way_out = m.networks.keys().any(|n| !t.vlans.iter().any(|v| &v.network == n) && internet(n));
233    if !other_way_out && let Some(v) = t.vlans.iter().find(|v| internet(&v.network)) {
234        cmds.push(format!("ip route replace default via {}", gateway(&v.network)));
235    }
236    cmds
237}
238
239/// What the machine's sidecar healthcheck waits for: its addresses on the trunk.
240#[must_use]
241pub fn machine_ready(t: &Trunk) -> String {
242    let name = interface(&t.lan);
243    t.vlans
244        .iter()
245        .map(|v| format!("ip link show {name}.{} | grep -q 'UP'", v.id))
246        .collect::<Vec<_>>()
247        .join(" && ")
248}
249
250/// The veth's other end, inside the VM (8 characters of the LAN, `-s`, then `.<id>` fits 15).
251fn vm_peer(lan: &str) -> String {
252    format!("{}-s", lan.chars().take(8).collect::<String>().trim_end_matches('-'))
253}
254
255/// Commands that build a VM's trunk (once per boot: they do nothing when it's there).
256#[must_use]
257pub fn vm_commands(spec: &Spec, t: &Trunk, address: impl Fn(&str, u8) -> Ipv4Addr) -> Vec<String> {
258    let name = interface(&t.lan);
259    let peer = vm_peer(&t.lan);
260    let mut steps = vec![
261        format!("ip link add {name} type veth peer name {peer}"),
262        format!("ip link set {name} up"),
263        format!("ip link set {peer} up"),
264    ];
265    for v in &t.vlans {
266        let (id, addr) = (v.id, address(&v.network, v.octet));
267        let len = cidr(spec, &v.network).len;
268        // The NIC keeps a local MAC; `<lan>.<id>` takes the one the hypervisor gave it.
269        let local = format!("02:1f:{}", addr.octets().map(|o| format!("{o:02x}")).join(":"));
270        steps.push(format!(
271            "{} && MAC=$(cat \"/sys/class/net/$IF/address\") && ip addr flush dev \"$IF\" && ip link set \"$IF\" address {local}",
272            find_by_address(addr)
273        ));
274        steps.push(format!(
275            "ip link add br{id} type bridge && ip link set \"$IF\" master br{id} && ip link set br{id} up"
276        ));
277        steps.push(format!(
278            "ip link add link {peer} name {peer}.{id} type vlan id {id} && ip link set {peer}.{id} master br{id} && ip link set {peer}.{id} up"
279        ));
280        steps.push(format!(
281            "ip link add link {name} name {name}.{id} address \"$MAC\" type vlan id {id} && ip addr add {addr}/{len} dev {name}.{id} && ip link set {name}.{id} up"
282        ));
283    }
284    vec![format!("ip link show {name} >/dev/null 2>&1 || {{ {}; }}", steps.join(" && "))]
285}
286
287#[cfg(test)]
288mod tests;