diff --git a/.gitignore b/.gitignore index 41c83a25..70c83453 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ setuid/setuid prun/*.1 +mx-networkd/target/ diff --git a/Makefile b/Makefile index e9847449..7e9c996e 100644 --- a/Makefile +++ b/Makefile @@ -66,10 +66,14 @@ INSTALL_DATA = $(INSTALL) -m 644 manpages += prun/prun.1 prun/pman.1 prun/ptype.1 -all: $(manpages) +CARGO ?= cargo +mx_networkd = mx-networkd/target/release/mx-networkd + +all: $(manpages) $(mx_networkd) clean: @rm -f $(manpages) + @rm -rf mx-networkd/target install: all @prefix="$(prefix)" usr_prefix="$(usr_prefix)" usrlocal_prefix="$(usrlocal_prefix)" \ @@ -89,3 +93,8 @@ install: all %.1: %.md @if [ "$$UID" = 0 ]; then echo "Please build (\`make\`) as non-root before running \`make install\` as root" >&2;exit 1;fi pandoc --standalone --to man $< -o $@ + +$(mx_networkd): mx-networkd/Cargo.toml $(wildcard mx-networkd/src/*.rs) + @if [ "$$UID" = 0 ]; then echo "Please build (\`make\`) as non-root before running \`make install\` as root" >&2;exit 1;fi + @command -v $(CARGO) >/dev/null || { echo "$(CARGO) not found - it is needed to build mx-networkd" >&2; exit 1; } + $(CARGO) build --release --manifest-path mx-networkd/Cargo.toml diff --git a/etc/systemd/system/mxvlan.service b/etc/systemd/system/mxvlan.service index 2087dbb2..0c6c0622 100644 --- a/etc/systemd/system/mxvlan.service +++ b/etc/systemd/system/mxvlan.service @@ -3,6 +3,10 @@ DefaultDependencies=no After=sysinit.target After=network.service Before=network.target +# Machines carrying the hostconfig tag mx-network-generator are configured +# by systemd-networkd, fed by the mx-networkd generator instead; see +# mx-networkd/README.md, section Migration. +ConditionPathExists=!/node/tags/mx-network-generator [Service] Type=oneshot diff --git a/etc/systemd/system/network.service b/etc/systemd/system/network.service index 6eba6542..76ca0fa6 100644 --- a/etc/systemd/system/network.service +++ b/etc/systemd/system/network.service @@ -3,6 +3,10 @@ DefaultDependencies=no After=sysinit.target Before=network.target Wants=network.target +# Machines carrying the hostconfig tag mx-network-generator are configured +# by systemd-networkd, fed by the mx-networkd generator instead; see +# mx-networkd/README.md, section Migration. +ConditionPathExists=!/node/tags/mx-network-generator [Service] EnvironmentFile=/etc/local/mxhost.conf diff --git a/etc/systemd/system/systemd-networkd.service.d/mx-network-generator.conf b/etc/systemd/system/systemd-networkd.service.d/mx-network-generator.conf new file mode 100644 index 00000000..acddb9b8 --- /dev/null +++ b/etc/systemd/system/systemd-networkd.service.d/mx-network-generator.conf @@ -0,0 +1,8 @@ +# Run systemd-networkd only on machines that have been switched over to the +# mx-networkd generator (hostconfig tag mx-network-generator). On all other +# machines network.service/mxvlan.service keep configuring the network; the +# two mechanisms must never run at the same time. See mx-networkd/README.md, +# section Migration. /node/tags/ is populated by startup-tags.service, which +# runs Before=sysinit.target, i.e. before this unit's condition is checked. +[Unit] +ConditionPathExists=/node/tags/mx-network-generator diff --git a/install.sh b/install.sh index 00b78e41..7b57af58 100755 --- a/install.sh +++ b/install.sh @@ -32,6 +32,7 @@ fi : ${sysconfdir:=$prefix/etc} : ${systemdunitdir:=$sysconfdir/systemd/system} +: ${systemdgeneratordir:=$usr_libdir/systemd/system-generators} : ${udev_rulesdir:=$sysconfdir/udev/rules.d} : ${crond_dir:=$sysconfdir/cron.d} : ${udev_helperdir:=$prefix/lib/udev} @@ -145,6 +146,13 @@ install_exec mkmotd/mkmotd.pl "$DESTDIR$usr_sbindir/m install_data mkmotd/motd.service "$DESTDIR$systemdunitdir/motd.service" install_exec mxgrub/mxgrub "$DESTDIR$usr_sbindir/mxgrub" install_exec mxnetctl/mxnetctl "$DESTDIR$usr_sbindir/mxnetctl" +# mx-networkd is built by `make`; it is installed but not enabled -- the +# generator only does something on a machine that runs systemd-networkd. +if [ -x mx-networkd/target/release/mx-networkd ]; then +install_exec mx-networkd/target/release/mx-networkd "$DESTDIR$usr_sbindir/mx-networkd" +install_exec mx-networkd/target/release/mx-networkd "$DESTDIR$systemdgeneratordir/mx-networkd-generator" +fi +install_exec mx-networkd/migrate-to-mx-networkd.sh "$DESTDIR$usr_sbindir/migrate-to-mx-networkd" install_exec mxrouter/mxrouterctl "$DESTDIR$usr_sbindir/mxrouterctl" install_exec mxvlan/mxvlanctl "$DESTDIR$usr_sbindir/mxvlanctl" install_exec netlog/netlog "$DESTDIR$usr_sbindir/netlog" diff --git a/mx-networkd/Cargo.lock b/mx-networkd/Cargo.lock new file mode 100644 index 00000000..27fe399b --- /dev/null +++ b/mx-networkd/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "mx-networkd" +version = "0.1.0" diff --git a/mx-networkd/Cargo.toml b/mx-networkd/Cargo.toml new file mode 100644 index 00000000..3da20e4b --- /dev/null +++ b/mx-networkd/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "mx-networkd" +version = "0.1.0" +edition = "2021" +rust-version = "1.70" +description = "Translate the central MarIuX network configuration into systemd-networkd and udev configuration" +license = "MIT" +publish = false + +# No dependencies on purpose: this runs as a systemd generator before any +# file system other than / is guaranteed to be there, and it has to be +# auditable by everyone who maintains the network configuration. +[dependencies] + +[profile.release] +opt-level = "s" +panic = "abort" +strip = true diff --git a/mx-networkd/README.md b/mx-networkd/README.md new file mode 100644 index 00000000..407795ca --- /dev/null +++ b/mx-networkd/README.md @@ -0,0 +1,169 @@ +# mx-networkd + +Translates the centrally managed MarIuX network configuration into +systemd-networkd and udev configuration, so that `mxnetctl`, `mxvlanctl`, +`network.service` and `mxvlan.service` can be retired without changing any of +the pushed configuration files. + +Status: **not yet in use anywhere.** Nothing in this directory is installed or +enabled by default; the old units keep running until someone switches a +machine over by hand (see [Migration](#migration)). + +## What it does + +It runs as a **systemd generator**. Generators run at every boot and at every +`systemctl daemon-reload`, before any unit starts — including +`systemd-udevd.service`. That is early enough for the `.link` files to be in +place before the first network device is coldplugged, and it means +`/etc/systemd/network/` stays permanently empty: everything is written to +`/run/systemd/network/`, which is also a search path for udev and networkd. + + /etc/local/mxnet ─┐ ┌─ /run/systemd/network/10-mx-.link + /etc/local/mxhost.conf ─┼─ mx-networkd ──────┼─ /run/systemd/network/10-mx-.netdev + /etc/mxvlans ─┘ └─ /run/systemd/network/20-mx-.network + +The three input files keep their current format and stay the single source of +truth. No file below `/etc` is written (except by `mx-networkd learn`, see +below). + +### Mapping + +| today | with mx-networkd | +| --- | --- | +| `mxnetctl start` renames by MAC | `.link` file per interface, applied by udev | +| `network.service` adds `MX_IPADDR/20` and the default route | `.network` file for `MX_NETDEV` | +| `mxvlanctl start` creates VLANs and their addresses | `.netdev` + `.network` per VLAN | +| `mxvlanctl` brings the base device up | `.network` for the base device | + +The prefix length (20), the gateway (141.14.16.128) and the broadcast address +were hard coded in `network.service`. They are defaults here and can be +overridden per host with `MX_PREFIXLEN`, `MX_GATEWAY` and `MX_BROADCAST` in +`mxhost.conf` without touching the tool. + +### One rule worth knowing + +systemd-networkd applies **only the first matching `.network` file** to an +interface. Everything belonging to one interface therefore has to be in one +file, even when it comes from two different inputs — the primary address from +`mxhost.conf` and the `VLAN=` entries from `mxvlans` end up in the same +`20-mx-net00.network`. That is why this is a program and not a pair of +`sed` scripts. + +## Usage + + mx-networkd generate [--root DIR] [--output DIR] [-n] + mx-networkd check [--root DIR] + mx-networkd learn [--root DIR] [-n] + +`generate` is what the generator symlink runs; systemd passes three directory +arguments, which are accepted and ignored. Output is deterministic — no time +stamps — so re-running it rewrites nothing and `daemon-reload` does not churn +the network configuration. Files that mx-networkd generated earlier and no +longer wants are removed; files in `/run/systemd/network/` belonging to +anything else are never touched. + +`check` parses the same inputs and exits non-zero if anything is wrong. Run it +on the master before pushing `/etc/mxvlans`, and a typo stops there instead of +on 300 machines. At boot the same problems are logged and the offending line +is skipped: a partially configured network beats no network at all. + +`learn` replaces the one thing `.link` files cannot do — inventing a name for a +MAC address nobody has seen before. It uses mxnetctl's algorithm (prefer +`netNN` matching the kernel's `ethN`, otherwise the lowest free number), writes +`/etc/local/mxnet`, and like mxnetctl it refuses to write when +`/etc/local/USB.usb` exists. It does not rename anything; that is udev's job +after the next `generate`. Only needed on first installation. + +To preview on any machine without touching it: + + mx-networkd generate --root / --dry-run + +## Building + + cargo build --release + cargo test + +No dependencies, no build script, `std` only. This is deliberate: the binary +runs as a generator before most of the system exists, and everyone who +maintains the network configuration should be able to read all of it. + +`make` in the repository root builds it and `make install` installs the binary +as `/usr/sbin/mx-networkd` and as the generator +`/usr/lib/systemd/system-generators/mx-networkd-generator`. + +## Migration + +The switch per machine is the **hostconfig tag `mx-network-generator`** +(hostconfig allows hyphens in tag names: a tag term matches +`[a-z][a-z0-9$_-]*`). Tags live in the centrally pushed `/etc/hostconfig` +and are materialised as files below `/node/tags/` by `startup-tags.service` +(`hostconfig --populate-node`) at every boot. Everything is conditioned on +that one tag: + +| | with the tag | without the tag | +| --- | --- | --- | +| `mx-networkd generate` | writes to `/run/systemd/network/` | writes nothing, prunes its old files | +| `systemd-networkd.service` | runs (drop-in `ConditionPathExists=/node/tags/mx-network-generator`) | skipped | +| `network.service`, `mxvlan.service` | skipped (`ConditionPathExists=!/node/tags/mx-network-generator`) | run as before | + +`/node/tags/` is on the root file system and persists across boots, so the +tag file is already there when the generators run — before +`startup-tags.service` has run again. The old and the new mechanism never +run at the same time. `--dry-run` ignores the tag, so the preview always +works. + +Per machine, reversible at every step — use the helper script: + + migrate-to-mx-networkd + +It validates the prerequisites and the configuration (`mx-networkd check`), +shows the `--dry-run` preview, verifies the tag is set in `/etc/hostconfig` +(and tells you what to add on the distmaster if it is not), refreshes +`/node/tags/` and enables `systemd-networkd.service` for the next boot. It +never starts, stops or reloads anything, so it is safe to run over SSH and +safe to run repeatedly. Do **not** flip the services by hand on a running +machine: `network.service` deletes the primary address on stop +(`ExecStop=ip addr del …`), so stopping it over SSH cuts the connection and +locks you out. The switchover is the reboot: + +1. Add `tag mx-network-generator` for the host to `/etc/hostconfig` on the + distmaster and push it. +2. Run `migrate-to-mx-networkd`, read its output, compare the preview with + `ip -br addr` and `ip -d link show type vlan`. +3. `reboot`. The generator runs before udev, the `.link` files rename the + devices at coldplug, and networkd owns the addresses from the start. A + reboot is needed anyway: renaming only happens on device *add* — a + running interface is not renamed. +4. After the reboot, verify: `ip -br addr`, `ip -d link show type vlan`, + `networkctl status`. + +To go back — also at the next boot, not live, for the same reason in +reverse (`ip addr add` on an address networkd still holds fails, and the +units fight over the interface): + +1. Remove the tag from `/etc/hostconfig` on the distmaster and push it. +2. `hostconfig --populate-node` +3. `systemctl disable systemd-networkd.service` +4. `reboot` — the old units run again; the generator removes its files. + +## Deliberate non-goals + +* **No `apply` subcommand.** Applying the configuration is + `networkctl reload`. A second implementation of "make the kernel match the + files" is exactly the divergence this change is supposed to remove. +* **No new file format.** If `/etc/mxvlans` needs to grow a field one day, + that is a separate discussion; this change must be a no-op for the people + who edit it. +* **No IPv6.** The current scripts do not configure any, so neither does this. + `.network` files are the obvious place to add it later. + +## Known gaps + +* `learn` assumes kernel names of the form `ethN`, i.e. `net.ifnames=0`, which + is what mxnetctl assumed too. On a machine with predictable names + (`enp1s0f0`) it will find nothing and say so. +* Renaming still only happens at device add. Changing a name in + `/etc/local/mxnet` needs a reboot (or `udevadm trigger` plus taking the + interface down), same as before. +* `/etc/mxvlans` is read for the local host only, matched on the short host + name, as mxvlanctl does. diff --git a/mx-networkd/migrate-to-mx-networkd.sh b/mx-networkd/migrate-to-mx-networkd.sh new file mode 100755 index 00000000..1ca89d15 --- /dev/null +++ b/mx-networkd/migrate-to-mx-networkd.sh @@ -0,0 +1,119 @@ +#! /bin/bash + +# migrate-to-mx-networkd - switch this machine from network.service/mxnetctl/ +# mxvlanctl to systemd-networkd fed by the mx-networkd generator. +# +# The switchover happens at the NEXT BOOT, never live: network.service +# deletes the primary address on stop, so flipping services on a running +# machine over SSH locks you out (see mx-networkd/README.md, Migration). +# This script therefore never starts, stops or reloads anything -- it only +# validates the configuration, checks the hostconfig tag, refreshes +# /node/tags/ and enables systemd-networkd for the next boot. It is +# idempotent: run it as often as you like, over SSH too. +# +# The single switch is the hostconfig tag 'mx-network-generator': +# - with the tag: mx-networkd generates, systemd-networkd runs, +# network.service/mxvlan.service are skipped by their +# ConditionPathExists=!/node/tags/mx-network-generator +# - without the tag: mx-networkd writes nothing, systemd-networkd is +# skipped by its drop-in condition, the old units run +# +# Written by OnkelClaude (AI) on behalf of Boris Bergenroth. + +TAG=mx-network-generator +TAGFILE=/node/tags/$TAG +DROPIN=/etc/systemd/system/systemd-networkd.service.d/$TAG.conf + +die() { + echo "$0: error: $*" >&2 + exit 1 +} + +step() { + echo + echo "== $*" +} + +[ "$(id -u)" = 0 ] || die "must be run as root" + +step "checking prerequisites" + +[ -x /usr/sbin/mx-networkd ] \ + || die "/usr/sbin/mx-networkd is not installed - update mxtools first" +[ -x /usr/lib/systemd/system-generators/mx-networkd-generator ] \ + || die "generator /usr/lib/systemd/system-generators/mx-networkd-generator is not installed - update mxtools first" +[ -x /usr/sbin/hostconfig ] \ + || die "/usr/sbin/hostconfig is not installed" +[ -e /etc/local/mxhost.conf ] \ + || die "/etc/local/mxhost.conf does not exist - this machine does not use the MarIuX network configuration" +[ -e /etc/local/mxnet ] \ + || die "/etc/local/mxnet does not exist - run 'mx-networkd learn' (or mxnetctl) first" +[ -e "$DROPIN" ] \ + || die "$DROPIN is missing - update mxtools first" +systemctl cat systemd-networkd.service >/dev/null 2>&1 \ + || die "systemd-networkd.service does not exist on this machine" +grep -q 'ConditionPathExists=!'"$TAGFILE" /etc/systemd/system/network.service 2>/dev/null \ + || die "/etc/systemd/system/network.service is not conditioned on the tag yet - update mxtools first" +echo "ok" + +step "validating the network configuration (mx-networkd check)" +/usr/sbin/mx-networkd check \ + || die "configuration problems found - fix them first; nothing was changed" + +step "preview of the generated configuration (nothing is applied)" +/usr/sbin/mx-networkd generate --dry-run +echo +echo "Compare with the running state: ip -br addr ; ip -d link show type vlan" + +step "checking hostconfig tag '$TAG'" +if /usr/sbin/hostconfig "$TAG"; then + echo "tag '$TAG' is set for this host in /etc/hostconfig" +else + cat >&2 <<__EOF__ + +The tag '$TAG' is NOT set for this host in /etc/hostconfig. +/node/tags/ is rebuilt from /etc/hostconfig at every boot, so the tag has +to come from there to survive. Add it on the distmaster: + + $(uname -n | cut -d. -f1) tag $TAG + +push /etc/hostconfig to this machine, then run this script again. + +Nothing was changed. +__EOF__ + exit 1 +fi + +step "refreshing /node/tags/ (hostconfig --populate-node)" +/usr/sbin/hostconfig --populate-node \ + || die "hostconfig --populate-node failed" +[ -e "$TAGFILE" ] \ + || die "$TAGFILE did not appear after populate-node" +echo "ok: $TAGFILE" + +step "enabling systemd-networkd.service for the next boot (not starting it)" +systemctl enable systemd-networkd.service \ + || die "systemctl enable systemd-networkd.service failed" + +cat <<__EOF__ + +== done - this machine switches to mx-networkd at the next boot + +Nothing has changed on the running system: network.service keeps the +addresses until shutdown. network.service and mxvlan.service stay enabled; +from the next boot on they are skipped by their tag condition, and +systemd-networkd takes over. Reboot when ready, then verify with: + + ip -br addr + ip -d link show type vlan + networkctl status + +Rollback (also reboot-based, do NOT flip services on a running machine): + + 1. remove the '$TAG' tag for this host from /etc/hostconfig + on the distmaster and push the file + 2. hostconfig --populate-node + 3. systemctl disable systemd-networkd.service + 4. reboot + +__EOF__ diff --git a/mx-networkd/src/config.rs b/mx-networkd/src/config.rs new file mode 100644 index 00000000..721edbda --- /dev/null +++ b/mx-networkd/src/config.rs @@ -0,0 +1,730 @@ +//! Parsers for the three centrally managed MarIuX network configuration files. +//! +//! * `/etc/local/mxnet` MAC address -> stable interface name (written by mxnetctl) +//! * `/etc/mxvlans` per-host VLAN table +//! * `/etc/local/mxhost.conf` environment file with the primary address +//! +//! The file formats are not changed by mx-networkd; these parsers accept +//! exactly what mxnetctl(8) and mxvlanctl(8) accept today. + +use std::collections::BTreeMap; +use std::fmt; +use std::fs; +use std::net::Ipv4Addr; +use std::path::{Path, PathBuf}; + +/// Prefix length of the primary interface, as hard coded in the old +/// `network.service`. Overridable with `MX_PREFIXLEN` in mxhost.conf. +pub const DEFAULT_PREFIX_LEN: u8 = 20; + +/// Default gateway, as hard coded in the old `network.service`. +/// Overridable with `MX_GATEWAY` in mxhost.conf. +pub const DEFAULT_GATEWAY: &str = "141.14.16.128"; + +/// A problem in one of the input files. +/// +/// Carries the location so that both the boot time path (warn and skip) and +/// the `check` subcommand (refuse to push) can report something useful. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigError { + pub path: PathBuf, + pub line: Option, + pub message: String, +} + +impl ConfigError { + fn new(path: &Path, line: usize, message: impl Into) -> Self { + ConfigError { + path: path.to_path_buf(), + line: Some(line), + message: message.into(), + } + } + + fn whole_file(path: &Path, message: impl Into) -> Self { + ConfigError { + path: path.to_path_buf(), + line: None, + message: message.into(), + } + } +} + +impl fmt::Display for ConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.line { + Some(line) => write!(f, "{}:{}: {}", self.path.display(), line, self.message), + None => write!(f, "{}: {}", self.path.display(), self.message), + } + } +} + +impl std::error::Error for ConfigError {} + +/// Result of parsing one file: everything that was understood, plus every +/// problem found. A broken line never invalidates the rest of the file -- +/// at boot time a partially configured network beats no network at all, +/// and `mx-networkd check` turns the problems into a non-zero exit status +/// before the file is ever pushed to a machine. +#[derive(Debug)] +pub struct Parsed { + pub items: Vec, + pub problems: Vec, +} + +// Written out rather than derived: the derive would demand `T: Default`, +// which none of the parsed types has a sensible value for. +impl Default for Parsed { + fn default() -> Self { + Parsed { + items: Vec::new(), + problems: Vec::new(), + } + } +} + +/// An IPv4 address with a prefix length, e.g. `141.14.20.5/20`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Cidr { + pub addr: Ipv4Addr, + pub prefix_len: u8, +} + +impl Cidr { + pub fn parse(text: &str) -> Option { + let (addr, len) = text.split_once('/')?; + let addr: Ipv4Addr = addr.parse().ok()?; + let prefix_len: u8 = len.parse().ok()?; + if prefix_len > 32 { + return None; + } + Some(Cidr { addr, prefix_len }) + } + + /// The broadcast address implied by address and prefix length. + pub fn broadcast(&self) -> Ipv4Addr { + let host_mask = if self.prefix_len == 0 { + u32::MAX + } else if self.prefix_len == 32 { + 0 + } else { + u32::MAX >> self.prefix_len + }; + Ipv4Addr::from(u32::from(self.addr) | host_mask) + } +} + +impl fmt::Display for Cidr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}/{}", self.addr, self.prefix_len) + } +} + +/// One MAC address to interface name mapping from `/etc/local/mxnet`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Link { + pub mac: String, + pub name: String, +} + +/// One VLAN of the local host, from `/etc/mxvlans`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Vlan { + pub base: String, + pub id: u16, + pub device: String, + pub cidr: Option, +} + +/// The primary interface, from `/etc/local/mxhost.conf`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostConfig { + pub netdev: String, + pub address: Cidr, + pub gateway: Option, + /// Only set when it differs from the address' implied broadcast. + pub broadcast: Option, +} + +/// Strip a `#` comment and surrounding whitespace. +fn strip_comment(line: &str) -> &str { + match line.split_once('#') { + Some((head, _)) => head.trim(), + None => line.trim(), + } +} + +/// Lower case a MAC address and reject anything that is not `xx:xx:xx:xx:xx:xx`. +pub(crate) fn normalize_mac(text: &str) -> Option { + let mut out = String::with_capacity(17); + let mut count = 0; + for part in text.split(':') { + if part.len() != 2 || !part.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + if count > 0 { + out.push(':'); + } + out.push_str(&part.to_ascii_lowercase()); + count += 1; + } + if count != 6 { + return None; + } + Some(out) +} + +/// Accept what the kernel accepts as an interface name (IFNAMSIZ is 16 +/// including the terminating NUL). +pub fn valid_ifname(name: &str) -> bool { + !name.is_empty() + && name.len() <= 15 + && name != "." + && name != ".." + && name + .chars() + .all(|c| c.is_ascii_graphic() && c != '/' && c != ':') +} + +/// Read a file, mapping a missing file to `Ok(None)`. +fn read_optional(path: &Path) -> Result, ConfigError> { + match fs::read_to_string(path) { + Ok(text) => Ok(Some(text)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(ConfigError::whole_file(path, e.to_string())), + } +} + +/// Parse `/etc/local/mxnet`. +/// +/// Format (as written by mxnetctl): ` `, `#` comments, blank +/// lines ignored. mxnetctl keeps the mapping one to one, so a repeated MAC +/// or a repeated name is a bug in the pushed file and is reported. +pub fn parse_mxnet(path: &Path) -> Result, ConfigError> { + let mut parsed = Parsed::default(); + let text = match read_optional(path)? { + Some(text) => text, + None => { + parsed.problems.push(ConfigError::whole_file( + path, + "file does not exist, no interfaces will be renamed", + )); + return Ok(parsed); + } + }; + + let mut by_mac: BTreeMap = BTreeMap::new(); + let mut by_name: BTreeMap = BTreeMap::new(); + + for (index, raw) in text.lines().enumerate() { + let lineno = index + 1; + let line = strip_comment(raw); + if line.is_empty() { + continue; + } + + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() != 2 { + parsed.problems.push(ConfigError::new( + path, + lineno, + format!("expected ' ', got {} fields", fields.len()), + )); + continue; + } + + let mac = match normalize_mac(fields[0]) { + Some(mac) => mac, + None => { + parsed.problems.push(ConfigError::new( + path, + lineno, + format!("not a MAC address: {}", fields[0]), + )); + continue; + } + }; + + let name = fields[1].to_string(); + if !valid_ifname(&name) { + parsed.problems.push(ConfigError::new( + path, + lineno, + format!("not a usable interface name: {}", name), + )); + continue; + } + + if let Some(first) = by_mac.get(&mac) { + parsed.problems.push(ConfigError::new( + path, + lineno, + format!("MAC {} already used on line {}", mac, first), + )); + continue; + } + if let Some(first) = by_name.get(&name) { + parsed.problems.push(ConfigError::new( + path, + lineno, + format!("name {} already used on line {}", name, first), + )); + continue; + } + + by_mac.insert(mac.clone(), lineno); + by_name.insert(name.clone(), lineno); + parsed.items.push(Link { mac, name }); + } + + Ok(parsed) +} + +/// Drop the DNS domain from a host name, as mxvlanctl does. +pub fn short_hostname(name: &str) -> String { + let name = name.trim(); + match name.split_once('.') { + Some((head, _)) => head.to_ascii_lowercase(), + None => name.to_ascii_lowercase(), + } +} + +/// mxvlanctl accepts the historical `ethX` spelling in `/etc/mxvlans` and +/// maps it to `netXX` when only the latter exists. It decides that by +/// looking at `/sys`, which a generator cannot do -- the devices are not +/// there yet. We do the same substitution statically instead: `ethN` becomes +/// `netNN` when `netNN` is a name we are going to hand out. +fn fix_eth(device: &str, known_names: &[String]) -> String { + let digits = match device.strip_prefix("eth") { + Some(digits) if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) => digits, + _ => return device.to_string(), + }; + let number: u32 = match digits.parse() { + Ok(number) => number, + Err(_) => return device.to_string(), + }; + let candidate = format!("net{:02}", number); + if known_names.iter().any(|name| name == &candidate) { + candidate + } else { + device.to_string() + } +} + +/// Parse `/etc/mxvlans` and keep the lines belonging to `hostname`. +/// +/// Format: ` []`. +pub fn parse_mxvlans( + path: &Path, + hostname: &str, + known_names: &[String], +) -> Result, ConfigError> { + let mut parsed = Parsed::default(); + let text = match read_optional(path)? { + Some(text) => text, + None => return Ok(parsed), + }; + + let hostname = short_hostname(hostname); + let mut seen_devices: BTreeMap = BTreeMap::new(); + + for (index, raw) in text.lines().enumerate() { + let lineno = index + 1; + let line = strip_comment(raw); + if line.is_empty() { + continue; + } + + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() != 4 && fields.len() != 5 { + parsed.problems.push(ConfigError::new( + path, + lineno, + format!( + "expected 4 or 5 fields (host base id device [cidr]), got {}", + fields.len() + ), + )); + continue; + } + + if short_hostname(fields[0]) != hostname { + continue; + } + + let id: u16 = match fields[2].parse() { + Ok(id) if (1..=4094).contains(&id) => id, + _ => { + parsed.problems.push(ConfigError::new( + path, + lineno, + format!("VLAN id must be a number between 1 and 4094: {}", fields[2]), + )); + continue; + } + }; + + let base = fix_eth(fields[1], known_names); + let device = fields[3].to_string(); + if !valid_ifname(&base) || !valid_ifname(&device) { + parsed.problems.push(ConfigError::new( + path, + lineno, + format!("not a usable interface name: {} or {}", base, device), + )); + continue; + } + if base == device { + parsed.problems.push(ConfigError::new( + path, + lineno, + format!("VLAN device and base device are both {}", base), + )); + continue; + } + + let cidr = match fields.get(4) { + None => None, + Some(text) => match Cidr::parse(text) { + Some(cidr) => Some(cidr), + None => { + parsed.problems.push(ConfigError::new( + path, + lineno, + format!("not an IPv4 address with prefix length: {}", text), + )); + continue; + } + }, + }; + + if let Some(first) = seen_devices.get(&device) { + parsed.problems.push(ConfigError::new( + path, + lineno, + format!("VLAN device {} already defined on line {}", device, first), + )); + continue; + } + seen_devices.insert(device.clone(), lineno); + + parsed.items.push(Vlan { + base, + id, + device, + cidr, + }); + } + + Ok(parsed) +} + +/// Parse an environment file of the kind systemd's `EnvironmentFile=` reads. +pub fn parse_env_file(path: &Path) -> Result, ConfigError> { + let mut map = BTreeMap::new(); + let text = match read_optional(path)? { + Some(text) => text, + None => return Ok(map), + }; + + for raw in text.lines() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let line = line.strip_prefix("export ").unwrap_or(line); + let (key, value) = match line.split_once('=') { + Some(pair) => pair, + None => continue, + }; + let value = value.trim(); + let value = value + .strip_prefix('"') + .and_then(|v| v.strip_suffix('"')) + .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\''))) + .unwrap_or(value); + map.insert(key.trim().to_string(), value.to_string()); + } + + Ok(map) +} + +/// Build the primary interface configuration from `/etc/local/mxhost.conf`. +/// +/// `MX_NETDEV` and `MX_IPADDR` come from the file; prefix length, gateway +/// and broadcast keep the values that were hard coded in `network.service` +/// unless the file overrides them with `MX_PREFIXLEN`, `MX_GATEWAY` or +/// `MX_BROADCAST`. +pub fn host_config(path: &Path) -> Result, ConfigError> { + let mut parsed = Parsed::default(); + let env = parse_env_file(path)?; + + let netdev = match env.get("MX_NETDEV") { + Some(netdev) if valid_ifname(netdev) => netdev.clone(), + Some(netdev) => { + parsed.problems.push(ConfigError::whole_file( + path, + format!("MX_NETDEV is not a usable interface name: {}", netdev), + )); + return Ok(parsed); + } + None => { + parsed.problems.push(ConfigError::whole_file( + path, + "no MX_NETDEV, the primary interface will not be configured", + )); + return Ok(parsed); + } + }; + + let ipaddr = match env.get("MX_IPADDR") { + Some(ipaddr) => ipaddr.clone(), + None => { + parsed.problems.push(ConfigError::whole_file( + path, + "no MX_IPADDR, the primary interface will not be configured", + )); + return Ok(parsed); + } + }; + + let prefix_len = match env.get("MX_PREFIXLEN") { + None => DEFAULT_PREFIX_LEN, + Some(text) => match text.parse::() { + Ok(len) if len <= 32 => len, + _ => { + parsed.problems.push(ConfigError::whole_file( + path, + format!("MX_PREFIXLEN is not a prefix length: {}", text), + )); + return Ok(parsed); + } + }, + }; + + // MX_IPADDR is a bare address in mxhost.conf, but accept a prefix, too. + let address = match Cidr::parse(&ipaddr) { + Some(cidr) => cidr, + None => match ipaddr.parse::() { + Ok(addr) => Cidr { addr, prefix_len }, + Err(_) => { + parsed.problems.push(ConfigError::whole_file( + path, + format!("MX_IPADDR is not an IPv4 address: {}", ipaddr), + )); + return Ok(parsed); + } + }, + }; + + let gateway_text = env + .get("MX_GATEWAY") + .cloned() + .unwrap_or_else(|| DEFAULT_GATEWAY.to_string()); + let gateway = if gateway_text.is_empty() { + None + } else { + match gateway_text.parse::() { + Ok(gateway) => Some(gateway), + Err(_) => { + parsed.problems.push(ConfigError::whole_file( + path, + format!("MX_GATEWAY is not an IPv4 address: {}", gateway_text), + )); + None + } + } + }; + + // Only carry the broadcast address when it is not the obvious one; + // systemd-networkd derives it otherwise. + let broadcast = match env.get("MX_BROADCAST") { + None => None, + Some(text) => match text.parse::() { + Ok(broadcast) if broadcast == address.broadcast() => None, + Ok(broadcast) => Some(broadcast), + Err(_) => { + parsed.problems.push(ConfigError::whole_file( + path, + format!("MX_BROADCAST is not an IPv4 address: {}", text), + )); + None + } + }, + }; + + parsed.items.push(HostConfig { + netdev, + address, + gateway, + broadcast, + }); + Ok(parsed) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn temp_file(name: &str, content: &str) -> PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!("mx-networkd-test-{}-{}", std::process::id(), name)); + let mut file = fs::File::create(&path).expect("create temp file"); + file.write_all(content.as_bytes()).expect("write temp file"); + path + } + + #[test] + fn cidr_broadcast() { + let cidr = Cidr::parse("141.14.20.5/20").unwrap(); + assert_eq!(cidr.broadcast(), Ipv4Addr::new(141, 14, 31, 255)); + assert_eq!( + Cidr::parse("172.20.200.1/16").unwrap().broadcast(), + Ipv4Addr::new(172, 20, 255, 255) + ); + assert_eq!( + Cidr::parse("10.0.0.1/32").unwrap().broadcast(), + Ipv4Addr::new(10, 0, 0, 1) + ); + assert_eq!( + Cidr::parse("10.0.0.1/0").unwrap().broadcast(), + Ipv4Addr::new(255, 255, 255, 255) + ); + assert!(Cidr::parse("141.14.20.5/33").is_none()); + assert!(Cidr::parse("141.14.20.5").is_none()); + } + + #[test] + fn mac_normalisation() { + assert_eq!( + normalize_mac("F0:1F:AF:E1:5B:A2").as_deref(), + Some("f0:1f:af:e1:5b:a2") + ); + assert!(normalize_mac("f0:1f:af:e1:5b").is_none()); + assert!(normalize_mac("f0-1f-af-e1-5b-a2").is_none()); + assert!(normalize_mac("g0:1f:af:e1:5b:a2").is_none()); + } + + #[test] + fn mxnet_is_parsed_like_mxnetctl_wrote_it() { + let path = temp_file( + "mxnet", + "# generated Wed Oct 7 16:47:28 2015 by /usr/sbin/mxnetctl\n\ + \n\ + f0:1f:af:e1:5b:a2 net00\n\ + f0:1f:af:e1:5b:a3 net01\n\ + a0:36:9f:28:8e:b0 net02\n", + ); + let parsed = parse_mxnet(&path).unwrap(); + fs::remove_file(&path).ok(); + + assert!(parsed.problems.is_empty(), "{:?}", parsed.problems); + assert_eq!(parsed.items.len(), 3); + assert_eq!(parsed.items[0].mac, "f0:1f:af:e1:5b:a2"); + assert_eq!(parsed.items[0].name, "net00"); + } + + #[test] + fn mxnet_reports_duplicates_and_keeps_going() { + let path = temp_file( + "mxnet-dup", + "f0:1f:af:e1:5b:a2 net00\n\ + f0:1f:af:e1:5b:a2 net01\n\ + junk\n\ + a0:36:9f:28:8e:b0 net02\n", + ); + let parsed = parse_mxnet(&path).unwrap(); + fs::remove_file(&path).ok(); + + assert_eq!(parsed.items.len(), 2); + assert_eq!(parsed.problems.len(), 2); + assert_eq!(parsed.problems[0].line, Some(2)); + assert_eq!(parsed.problems[1].line, Some(3)); + } + + #[test] + fn mxvlans_keeps_only_the_local_host() { + let path = temp_file( + "mxvlans", + "#\n\ + # hostname base-device vlan-number vlan-device [cidr]\n\ + #\n\ + theinternet\t\tnet00\t20\tvlan.mgmt0\t172.20.200.1/16\n\ + theinternet\t\tnet00\t43\tvlan.printer0\t172.19.104.80/24\n\ + theinternet\t\tnet00\t49\tvlan.test0\n\ + othermachine\t\tnet00\t99\tvlan.other0\n", + ); + let parsed = parse_mxvlans(&path, "theinternet.molgen.mpg.de", &[]).unwrap(); + fs::remove_file(&path).ok(); + + assert!(parsed.problems.is_empty(), "{:?}", parsed.problems); + assert_eq!(parsed.items.len(), 3); + assert_eq!(parsed.items[0].device, "vlan.mgmt0"); + assert_eq!(parsed.items[0].id, 20); + assert_eq!( + parsed.items[0].cidr.unwrap().to_string(), + "172.20.200.1/16" + ); + assert!(parsed.items[2].cidr.is_none()); + } + + #[test] + fn mxvlans_rejects_bad_ids_but_keeps_the_rest() { + let path = temp_file( + "mxvlans-bad", + "host net00 4095 vlan.a\n\ + host net00 20 vlan.b\n\ + host net00 21 vlan.c 10.0.0.1\n", + ); + let parsed = parse_mxvlans(&path, "host", &[]).unwrap(); + fs::remove_file(&path).ok(); + + assert_eq!(parsed.items.len(), 1); + assert_eq!(parsed.items[0].device, "vlan.b"); + assert_eq!(parsed.problems.len(), 2); + } + + #[test] + fn eth_names_are_mapped_to_net_names() { + let known = vec!["net00".to_string(), "net05".to_string()]; + assert_eq!(fix_eth("eth0", &known), "net00"); + assert_eq!(fix_eth("eth5", &known), "net05"); + assert_eq!(fix_eth("eth7", &known), "eth7"); + assert_eq!(fix_eth("net00", &known), "net00"); + assert_eq!(fix_eth("ethX", &known), "ethX"); + } + + #[test] + fn host_config_uses_the_old_defaults() { + let path = temp_file( + "mxhost.conf", + "MX_NETDEV=net00\nMX_IPADDR=141.14.20.5\nMX_SOMETHING_ELSE=ignored\n", + ); + let parsed = host_config(&path).unwrap(); + fs::remove_file(&path).ok(); + + assert!(parsed.problems.is_empty(), "{:?}", parsed.problems); + let host = &parsed.items[0]; + assert_eq!(host.netdev, "net00"); + assert_eq!(host.address.to_string(), "141.14.20.5/20"); + assert_eq!(host.gateway, Some(DEFAULT_GATEWAY.parse().unwrap())); + // 141.14.31.255 is what /20 implies, so it is not carried explicitly. + assert_eq!(host.broadcast, None); + } + + #[test] + fn host_config_overrides_are_honoured() { + let path = temp_file( + "mxhost-override.conf", + "MX_NETDEV=\"net02\"\nMX_IPADDR=10.0.0.5\nMX_PREFIXLEN=24\nMX_GATEWAY=10.0.0.1\n", + ); + let parsed = host_config(&path).unwrap(); + fs::remove_file(&path).ok(); + + let host = &parsed.items[0]; + assert_eq!(host.netdev, "net02"); + assert_eq!(host.address.to_string(), "10.0.0.5/24"); + assert_eq!(host.gateway, Some(Ipv4Addr::new(10, 0, 0, 1))); + } +} diff --git a/mx-networkd/src/learn.rs b/mx-networkd/src/learn.rs new file mode 100644 index 00000000..0a1162fb --- /dev/null +++ b/mx-networkd/src/learn.rs @@ -0,0 +1,233 @@ +//! First boot support: give unknown MAC addresses a stable `netXX` name. +//! +//! `.link` files cannot invent names, they can only apply a mapping that +//! already exists. mxnetctl covered that case by learning the mapping on the +//! first boot and writing it to `/etc/local/mxnet`. This is the same +//! algorithm, minus the renaming -- udev does that once the `.link` files +//! exist. +//! +//! Like mxnetctl, this only looks at devices named `ethN` or `netNN`, which +//! implies `net.ifnames=0` on the kernel command line, and it refuses to +//! write anything on a machine booted from USB (`/etc/local/USB.usb`). + +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; +use std::process::ExitCode; + +use crate::config; + +/// `ethN` or `netNN` -> N +fn device_number(name: &str) -> Option { + let digits = name + .strip_prefix("eth") + .or_else(|| name.strip_prefix("net"))?; + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + digits.parse().ok() +} + +/// Hardware interfaces below `sys_class_net`, sorted by name. +fn hardware_interfaces(sys_class_net: &Path) -> Vec<(String, String)> { + let mut found = Vec::new(); + let entries = match fs::read_dir(sys_class_net) { + Ok(entries) => entries, + Err(error) => { + eprintln!( + "mx-networkd: cannot read {}: {}", + sys_class_net.display(), + error + ); + return found; + } + }; + + for entry in entries.flatten() { + let name = match entry.file_name().into_string() { + Ok(name) => name, + Err(_) => continue, + }; + if device_number(&name).is_none() { + continue; + } + // Only real hardware has a device link; bonds, dummies and VLANs + // do not, and must never end up in mxnet. + if !sys_class_net.join(&name).join("device").exists() { + continue; + } + let address = match fs::read_to_string(sys_class_net.join(&name).join("address")) { + Ok(address) => address, + Err(_) => continue, + }; + let mac = match config::normalize_mac(address.trim()) { + Some(mac) => mac, + None => continue, + }; + found.push((name, mac)); + } + + found.sort(); + found +} + +/// The name mxnetctl would pick: the number the kernel already used if that +/// `netNN` is free, otherwise the lowest free number. +fn preferred_name(current: &str, taken: &BTreeMap) -> String { + if let Some(number) = device_number(current) { + let candidate = format!("net{:02}", number); + if !taken.contains_key(&candidate) { + return candidate; + } + } + for number in 0.. { + let candidate = format!("net{:02}", number); + if !taken.contains_key(&candidate) { + return candidate; + } + } + unreachable!() +} + +pub fn run(sys_class_net: &Path, etc_local: &Path, dry_run: bool) -> ExitCode { + let mxnet = etc_local.join("mxnet"); + + let mut by_name: BTreeMap = BTreeMap::new(); + match config::parse_mxnet(&mxnet) { + Ok(parsed) => { + for problem in &parsed.problems { + // A missing file is the normal case on a first boot. + if problem.line.is_some() { + eprintln!("mx-networkd: {}", problem); + } + } + for link in parsed.items { + by_name.insert(link.name, link.mac); + } + } + Err(error) => { + eprintln!("mx-networkd: {}", error); + return ExitCode::FAILURE; + } + } + + let known_macs: Vec = by_name.values().cloned().collect(); + let mut added = 0; + + for (device, mac) in hardware_interfaces(sys_class_net) { + if known_macs.contains(&mac) { + continue; + } + let name = preferred_name(&device, &by_name); + println!("{} {} # was {}", mac, name, device); + by_name.insert(name, mac); + added += 1; + } + + if added == 0 { + println!("mx-networkd: no new interfaces, {} unchanged", mxnet.display()); + return ExitCode::SUCCESS; + } + + if etc_local.join("USB.usb").exists() { + eprintln!( + "mx-networkd: {} exists, not writing {}", + etc_local.join("USB.usb").display(), + mxnet.display() + ); + return ExitCode::SUCCESS; + } + + if dry_run { + println!("mx-networkd: would add {} entry/entries", added); + return ExitCode::SUCCESS; + } + + let mut text = String::from("# generated by mx-networkd learn\n\n"); + for (name, mac) in &by_name { + text.push_str(mac); + text.push(' '); + text.push_str(name); + text.push('\n'); + } + text.push('\n'); + + match fs::write(&mxnet, text) { + Ok(()) => { + println!("mx-networkd: added {} entry/entries to {}", added, mxnet.display()); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("mx-networkd: cannot write {}: {}", mxnet.display(), error); + ExitCode::FAILURE + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn device_numbers() { + assert_eq!(device_number("eth0"), Some(0)); + assert_eq!(device_number("net07"), Some(7)); + assert_eq!(device_number("net123"), Some(123)); + assert_eq!(device_number("lo"), None); + assert_eq!(device_number("enp1s0f0"), None); + assert_eq!(device_number("eth"), None); + } + + #[test] + fn preferred_names_follow_mxnetctl() { + let mut taken: BTreeMap = BTreeMap::new(); + assert_eq!(preferred_name("eth3", &taken), "net03"); + + taken.insert("net03".to_string(), "aa:bb:cc:dd:ee:ff".to_string()); + // net03 is gone, so the lowest free number wins. + assert_eq!(preferred_name("eth3", &taken), "net00"); + + taken.insert("net00".to_string(), "aa:bb:cc:dd:ee:00".to_string()); + taken.insert("net01".to_string(), "aa:bb:cc:dd:ee:01".to_string()); + assert_eq!(preferred_name("eth3", &taken), "net02"); + } + + #[test] + fn learns_from_a_fake_sysfs() { + let mut root = std::env::temp_dir(); + root.push(format!("mx-networkd-learn-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + + let sys = root.join("sys"); + let etc_local = root.join("etc-local"); + fs::create_dir_all(&etc_local).unwrap(); + + // Two hardware interfaces and one virtual one. + for (name, mac, hardware) in [ + ("eth0", "F0:1F:AF:E1:5B:A2", true), + ("eth1", "f0:1f:af:e1:5b:a3", true), + ("bond0", "f0:1f:af:e1:5b:a4", false), + ] { + let dir = sys.join(name); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("address"), format!("{}\n", mac)).unwrap(); + if hardware { + fs::create_dir_all(dir.join("device")).unwrap(); + } + } + + run(&sys, &etc_local, false); + + let written = fs::read_to_string(etc_local.join("mxnet")).unwrap(); + assert!(written.contains("f0:1f:af:e1:5b:a2 net00"), "{}", written); + assert!(written.contains("f0:1f:af:e1:5b:a3 net01"), "{}", written); + assert!(!written.contains("5b:a4"), "bond0 must not be learned"); + + // Running again is a no-op. + let before = fs::read_to_string(etc_local.join("mxnet")).unwrap(); + run(&sys, &etc_local, false); + assert_eq!(before, fs::read_to_string(etc_local.join("mxnet")).unwrap()); + + fs::remove_dir_all(&root).ok(); + } +} diff --git a/mx-networkd/src/main.rs b/mx-networkd/src/main.rs new file mode 100644 index 00000000..e7313e67 --- /dev/null +++ b/mx-networkd/src/main.rs @@ -0,0 +1,409 @@ +//! mx-networkd -- translate the central MarIuX network configuration into +//! systemd-networkd and udev configuration. +//! +//! Run as a systemd generator, it reads `/etc/local/mxnet`, +//! `/etc/local/mxhost.conf` and `/etc/mxvlans` and writes `.link`, `.netdev` +//! and `.network` files to `/run/systemd/network/`. Generators run before +//! any unit starts, including systemd-udevd, so the `.link` files are in +//! place before the first device is coldplugged -- and `/etc/systemd/network/` +//! stays empty, which was the constraint that started this. +//! +//! See README.md for the whole picture. + +mod config; +mod learn; +mod render; + +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +const USAGE: &str = "\ +usage: mx-networkd [options] + +commands: + generate [DIR ...] write the networkd configuration (systemd generator) + check parse the configuration and report every problem + learn assign netXX names to unknown MACs in /etc/local/mxnet + help show this text + +options: + --root DIR read the configuration below DIR instead of / + --output DIR write to DIR instead of /run/systemd/network + --hostname NAME use NAME instead of the system host name + -n, --dry-run print what would be written, change nothing + --version show the version + +When installed as /usr/lib/systemd/system-generators/mx-networkd-generator, +systemd calls it with three directory arguments; they are accepted and +ignored, because networkd configuration does not belong into a unit +directory. + +generate writes nothing (and removes its earlier output) unless the +hostconfig tag mx-network-generator is set for this machine -- see +README.md, section Migration. --dry-run always shows the full preview. +"; + +const DEFAULT_OUTPUT: &str = "/run/systemd/network"; + +/// The hostconfig tag that switches a machine over to the generator. +/// `hostconfig --populate-node` (startup-tags.service) materialises the tags +/// of /etc/hostconfig as files below /node/tags. That directory is on the +/// root file system and persists across boots, so the file is already there +/// when the generators run early in the next boot -- before +/// startup-tags.service has run again. The migration script runs +/// populate-node itself to keep the two in sync (see README.md, Migration). +const TAG_PATH: &str = "/node/tags/mx-network-generator"; + +struct Options { + root: PathBuf, + output: PathBuf, + hostname: Option, + dry_run: bool, +} + +impl Options { + fn path(&self, absolute: &str) -> PathBuf { + let relative = absolute.trim_start_matches('/'); + self.root.join(relative) + } +} + +fn main() -> ExitCode { + let argv: Vec = std::env::args().collect(); + + // systemd calls generators by path with three directory arguments and no + // command, so recognise that spelling as `generate`. + let called_as_generator = argv + .first() + .map(|argv0| { + Path::new(argv0) + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.ends_with("-generator")) + .unwrap_or(false) + }) + .unwrap_or(false); + + let (command, first_option) = match argv.get(1).map(String::as_str) { + // A real command word. + Some(word) if !word.starts_with('-') && !word.starts_with('/') => (word.to_string(), 2), + // Options or the directories systemd passes to a generator. + Some(_) => ("generate".to_string(), 1), + None if called_as_generator => ("generate".to_string(), 1), + None => { + eprintln!("{}", USAGE); + return ExitCode::from(2); + } + }; + + let mut args = argv[first_option..].iter().map(String::as_str); + + let mut options = Options { + root: PathBuf::from("/"), + output: PathBuf::from(DEFAULT_OUTPUT), + hostname: None, + dry_run: false, + }; + let mut output_given = false; + + while let Some(arg) = args.next() { + match arg { + "--root" => match args.next() { + Some(value) => options.root = PathBuf::from(value), + None => return fail("--root needs a directory"), + }, + "--output" => match args.next() { + Some(value) => { + options.output = PathBuf::from(value); + output_given = true; + } + None => return fail("--output needs a directory"), + }, + "--hostname" => match args.next() { + Some(value) => options.hostname = Some(value.to_string()), + None => return fail("--hostname needs a name"), + }, + "-n" | "--dry-run" => options.dry_run = true, + "--version" => { + println!("mx-networkd {}", env!("CARGO_PKG_VERSION")); + return ExitCode::SUCCESS; + } + "-h" | "--help" => { + print!("{}", USAGE); + return ExitCode::SUCCESS; + } + // The three directories systemd hands to a generator. + _ if arg.starts_with('/') => {} + _ => return fail(&format!("unknown option: {}", arg)), + } + } + + if !output_given && options.root != Path::new("/") { + options.output = options.path(DEFAULT_OUTPUT); + } + + match command.as_str() { + "generate" => generate(&options), + "check" => check(&options), + "learn" => learn::run( + &options.path("/sys/class/net"), + &options.path("/etc/local"), + options.dry_run, + ), + "help" => { + print!("{}", USAGE); + ExitCode::SUCCESS + } + other => fail(&format!("unknown command: {}", other)), + } +} + +fn fail(message: &str) -> ExitCode { + eprintln!("mx-networkd: {}", message); + eprintln!("{}", USAGE); + ExitCode::from(2) +} + +/// Read the host name the way mxvlanctl does, but without running a program: +/// `/etc/hostname` first, the kernel's own name second. PID 1 sets the kernel +/// host name before it runs generators, so both are available at that point. +fn hostname(options: &Options) -> String { + if let Some(name) = &options.hostname { + return name.clone(); + } + for candidate in ["/etc/hostname", "/proc/sys/kernel/hostname"] { + if let Ok(text) = fs::read_to_string(options.path(candidate)) { + let name = text.trim(); + if !name.is_empty() { + return name.to_string(); + } + } + } + String::new() +} + +/// Parse everything. Problems are returned, not fatal: at boot time we +/// configure as much as we understood and complain about the rest. +fn collect(options: &Options) -> (render::Plan, Vec) { + let mut problems = Vec::new(); + + let mut links = Vec::new(); + match config::parse_mxnet(&options.path("/etc/local/mxnet")) { + Ok(parsed) => { + links = parsed.items; + problems.extend(parsed.problems); + } + Err(error) => problems.push(error), + } + + let mut host = None; + match config::host_config(&options.path("/etc/local/mxhost.conf")) { + Ok(parsed) => { + host = parsed.items.into_iter().next(); + problems.extend(parsed.problems); + } + Err(error) => problems.push(error), + } + + let known_names: Vec = links.iter().map(|link| link.name.clone()).collect(); + let mut vlans = Vec::new(); + match config::parse_mxvlans(&options.path("/etc/mxvlans"), &hostname(options), &known_names) { + Ok(parsed) => { + vlans = parsed.items; + problems.extend(parsed.problems); + } + Err(error) => problems.push(error), + } + + (render::build(&links, host.as_ref(), &vlans), problems) +} + +fn generate(options: &Options) -> ExitCode { + // Machine not switched over (no tag): write nothing, but prune what an + // earlier run may have left behind, so a rollback needs nothing beyond + // removing the tag. --dry-run still shows the full preview, so the + // configuration can be inspected before setting the tag. + if !options.dry_run && !options.path(TAG_PATH).exists() { + return match sync(&options.output, &render::Plan::default()) { + Ok(0) => ExitCode::SUCCESS, + Ok(removed) => { + eprintln!( + "mx-networkd: tag {} is not set, removed {} stale file(s)", + TAG_PATH, removed + ); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!( + "mx-networkd: cannot clean {}: {}", + options.output.display(), + error + ); + ExitCode::FAILURE + } + }; + } + + let (plan, problems) = collect(options); + + for problem in &problems { + eprintln!("mx-networkd: {}", problem); + } + + if options.dry_run { + let stdout = io::stdout(); + let mut out = stdout.lock(); + for (name, content) in &plan.files { + let _ = writeln!(out, "### {}/{}", options.output.display(), name); + let _ = write!(out, "{}", content); + let _ = writeln!(out); + } + return ExitCode::SUCCESS; + } + + match sync(&options.output, &plan) { + Ok(changed) => { + if changed > 0 { + eprintln!( + "mx-networkd: wrote {} file(s) to {}", + changed, + options.output.display() + ); + } + ExitCode::SUCCESS + } + Err(error) => { + // Never abort the boot over this: whatever did get written is + // still better than nothing, and the message reaches the journal. + eprintln!( + "mx-networkd: cannot write to {}: {}", + options.output.display(), + error + ); + ExitCode::FAILURE + } + } +} + +fn check(options: &Options) -> ExitCode { + let (plan, problems) = collect(options); + + for problem in &problems { + println!("{}", problem); + } + + println!( + "tag {}: {}", + TAG_PATH, + if options.path(TAG_PATH).exists() { + "set (this machine uses the generator)" + } else { + "not set (network.service/mxvlan.service keep running)" + } + ); + println!( + "{} problem(s), {} file(s) would be written to {}", + problems.len(), + plan.files.len(), + options.output.display() + ); + + if problems.is_empty() { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + } +} + +/// Make `dir` match `plan`: write files whose content differs, remove our own +/// stale files, leave everything else alone. +fn sync(dir: &Path, plan: &render::Plan) -> io::Result { + fs::create_dir_all(dir)?; + + let mut changed = 0; + for (name, content) in &plan.files { + let path = dir.join(name); + let current = fs::read_to_string(&path).ok(); + if current.as_deref() == Some(content.as_str()) { + continue; + } + fs::write(&path, content)?; + changed += 1; + } + + for entry in fs::read_dir(dir)? { + let entry = entry?; + let name = entry.file_name(); + let name = match name.to_str() { + Some(name) => name, + None => continue, + }; + if !is_ours(name) || plan.files.contains_key(name) { + continue; + } + fs::remove_file(entry.path())?; + changed += 1; + } + + Ok(changed) +} + +/// A file we generated: `-mx-.`. +fn is_ours(name: &str) -> bool { + let rest = match name.split_once('-') { + Some((digits, rest)) if digits.len() == 2 && digits.bytes().all(|b| b.is_ascii_digit()) => { + rest + } + _ => return false, + }; + rest.starts_with(render::PREFIX) + && (name.ends_with(".link") || name.ends_with(".netdev") || name.ends_with(".network")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recognises_its_own_files() { + assert!(is_ours("10-mx-net00.link")); + assert!(is_ours("20-mx-vlan.mgmt0.network")); + assert!(is_ours("10-mx-vlan.mgmt0.netdev")); + assert!(!is_ours("10-other.link")); + assert!(!is_ours("mx-net00.link")); + assert!(!is_ours("10-mx-net00.conf")); + assert!(!is_ours("99-default.link")); + } + + #[test] + fn sync_writes_updates_and_prunes() { + let mut dir = std::env::temp_dir(); + dir.push(format!("mx-networkd-sync-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + // A stale file of ours and a file belonging to somebody else. + fs::write(dir.join("10-mx-old.link"), "stale\n").unwrap(); + fs::write(dir.join("99-someone-else.network"), "keep\n").unwrap(); + + let mut plan = render::Plan::default(); + plan.files + .insert("10-mx-net00.link".to_string(), "content\n".to_string()); + + let changed = sync(&dir, &plan).unwrap(); + assert_eq!(changed, 2, "one written, one pruned"); + assert_eq!( + fs::read_to_string(dir.join("10-mx-net00.link")).unwrap(), + "content\n" + ); + assert!(!dir.join("10-mx-old.link").exists()); + assert!(dir.join("99-someone-else.network").exists()); + + // Running again changes nothing. + assert_eq!(sync(&dir, &plan).unwrap(), 0); + + fs::remove_dir_all(&dir).ok(); + } +} diff --git a/mx-networkd/src/render.rs b/mx-networkd/src/render.rs new file mode 100644 index 00000000..ad52158d --- /dev/null +++ b/mx-networkd/src/render.rs @@ -0,0 +1,283 @@ +//! Turn the parsed MarIuX configuration into systemd-networkd and udev files. +//! +//! One rule drives the whole layout: systemd-networkd applies **only the +//! first matching `.network` file** to an interface. Everything that belongs +//! to one interface -- its address, its default route and the VLANs on top of +//! it -- therefore has to end up in a single file, even though it comes from +//! two different input files. + +use std::collections::BTreeMap; + +use crate::config::{Cidr, HostConfig, Link, Vlan}; + +/// File name prefix of everything we write. `sync` only ever deletes files +/// that match it, so the output directory can be shared with other tools. +pub const PREFIX: &str = "mx-"; + +const HEADER: &str = "\ +# Generated by mx-networkd. Do not edit -- this file is recreated on every +# boot and on every `systemctl daemon-reload`, and local changes are lost. +# +# Sources: /etc/local/mxnet, /etc/local/mxhost.conf, /etc/mxvlans +"; + +/// Everything mx-networkd wants to see in the output directory: +/// file name -> file content. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct Plan { + pub files: BTreeMap, +} + +/// Accumulated settings of one interface, before they are rendered. +#[derive(Debug, Default)] +struct NetworkSpec { + addresses: Vec<(Cidr, Option)>, + gateway: Option, + vlans: Vec, +} + +/// Build the complete set of files. +/// +/// The output depends only on the inputs -- no time stamps, no host state -- +/// so that re-running the generator produces byte identical files and +/// `sync` has nothing to do. +pub fn build(links: &[Link], host: Option<&HostConfig>, vlans: &[Vlan]) -> Plan { + let mut plan = Plan::default(); + let mut networks: BTreeMap = BTreeMap::new(); + + for link in links { + plan.files + .insert(format!("10-{}{}.link", PREFIX, link.name), link_file(link)); + } + + if let Some(host) = host { + let spec = networks.entry(host.netdev.clone()).or_default(); + spec.addresses.push((host.address, host.broadcast)); + spec.gateway = host.gateway; + } + + for vlan in vlans { + plan.files.insert( + format!("10-{}{}.netdev", PREFIX, vlan.device), + netdev_file(vlan), + ); + + networks + .entry(vlan.base.clone()) + .or_default() + .vlans + .push(vlan.device.clone()); + + let spec = networks.entry(vlan.device.clone()).or_default(); + if let Some(cidr) = vlan.cidr { + spec.addresses.push((cidr, None)); + } + } + + for (name, spec) in &networks { + plan.files.insert( + format!("20-{}{}.network", PREFIX, name), + network_file(name, spec), + ); + } + + plan +} + +fn link_file(link: &Link) -> String { + format!( + "{HEADER}\n\ + [Match]\n\ + MACAddress={mac}\n\ + \n\ + [Link]\n\ + Name={name}\n", + HEADER = HEADER, + mac = link.mac, + name = link.name, + ) +} + +fn netdev_file(vlan: &Vlan) -> String { + format!( + "{HEADER}\n\ + [NetDev]\n\ + Name={name}\n\ + Kind=vlan\n\ + \n\ + [VLAN]\n\ + Id={id}\n", + HEADER = HEADER, + name = vlan.device, + id = vlan.id, + ) +} + +fn network_file(name: &str, spec: &NetworkSpec) -> String { + let mut out = String::with_capacity(512); + out.push_str(HEADER); + out.push_str("\n[Match]\nName="); + out.push_str(name); + out.push('\n'); + + // Addresses without an explicit broadcast go here; the ones that need + // a broadcast get their own [Address] section below. + let mut network = String::new(); + for (cidr, broadcast) in &spec.addresses { + if broadcast.is_none() { + network.push_str(&format!("Address={}\n", cidr)); + } + } + for vlan in &spec.vlans { + network.push_str(&format!("VLAN={}\n", vlan)); + } + // An interface with nothing in [Network] -- a VLAN without an address -- + // still gets a .network file: matching one is what makes networkd manage + // the interface and bring it up, which `ip link set dev X up` did before. + if !network.is_empty() { + out.push_str("\n[Network]\n"); + out.push_str(&network); + } + + for (cidr, broadcast) in &spec.addresses { + if let Some(broadcast) = broadcast { + out.push_str(&format!( + "\n[Address]\nAddress={}\nBroadcast={}\n", + cidr, broadcast + )); + } + } + + if let Some(gateway) = spec.gateway { + out.push_str(&format!("\n[Route]\nGateway={}\n", gateway)); + } + + out.push_str("\n[Link]\n"); + if spec.addresses.is_empty() { + out.push_str("RequiredForOnline=no\n"); + } else { + out.push_str("RequiredForOnline=yes\n"); + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Cidr, HostConfig, Link, Vlan}; + + fn example() -> Plan { + let links = vec![ + Link { + mac: "f0:1f:af:e1:5b:a2".to_string(), + name: "net00".to_string(), + }, + Link { + mac: "f0:1f:af:e1:5b:a3".to_string(), + name: "net01".to_string(), + }, + ]; + let host = HostConfig { + netdev: "net00".to_string(), + address: Cidr::parse("141.14.20.5/20").unwrap(), + gateway: Some("141.14.16.128".parse().unwrap()), + broadcast: None, + }; + let vlans = vec![ + Vlan { + base: "net00".to_string(), + id: 20, + device: "vlan.mgmt0".to_string(), + cidr: Cidr::parse("172.20.200.1/16"), + }, + Vlan { + base: "net01".to_string(), + id: 49, + device: "vlan.test0".to_string(), + cidr: None, + }, + ]; + build(&links, Some(&host), &vlans) + } + + #[test] + fn every_interface_gets_exactly_one_network_file() { + let plan = example(); + let networks: Vec<&String> = plan + .files + .keys() + .filter(|name| name.ends_with(".network")) + .collect(); + assert_eq!( + networks, + vec![ + "20-mx-net00.network", + "20-mx-net01.network", + "20-mx-vlan.mgmt0.network", + "20-mx-vlan.test0.network", + ] + ); + } + + #[test] + fn primary_interface_carries_address_route_and_its_vlan() { + let plan = example(); + let text = &plan.files["20-mx-net00.network"]; + assert!(text.contains("[Match]\nName=net00\n"), "{}", text); + assert!(text.contains("Address=141.14.20.5/20\n"), "{}", text); + assert!(text.contains("VLAN=vlan.mgmt0\n"), "{}", text); + assert!(text.contains("[Route]\nGateway=141.14.16.128\n"), "{}", text); + assert!(text.contains("RequiredForOnline=yes"), "{}", text); + } + + #[test] + fn vlan_base_without_address_is_not_waited_for() { + let plan = example(); + let text = &plan.files["20-mx-net01.network"]; + assert!(text.contains("VLAN=vlan.test0\n"), "{}", text); + assert!(!text.contains("Address="), "{}", text); + assert!(text.contains("RequiredForOnline=no"), "{}", text); + } + + #[test] + fn vlan_netdev_and_address() { + let plan = example(); + let netdev = &plan.files["10-mx-vlan.mgmt0.netdev"]; + assert!(netdev.contains("Kind=vlan\n"), "{}", netdev); + assert!(netdev.contains("[VLAN]\nId=20\n"), "{}", netdev); + + let network = &plan.files["20-mx-vlan.mgmt0.network"]; + assert!(network.contains("Address=172.20.200.1/16\n"), "{}", network); + } + + #[test] + fn link_files_rename_by_mac() { + let plan = example(); + let text = &plan.files["10-mx-net00.link"]; + assert!(text.contains("MACAddress=f0:1f:af:e1:5b:a2\n"), "{}", text); + assert!(text.contains("[Link]\nName=net00\n"), "{}", text); + } + + #[test] + fn explicit_broadcast_gets_its_own_address_section() { + let host = HostConfig { + netdev: "net00".to_string(), + address: Cidr::parse("141.14.20.5/20").unwrap(), + gateway: None, + broadcast: Some("141.14.19.255".parse().unwrap()), + }; + let plan = build(&[], Some(&host), &[]); + let text = &plan.files["20-mx-net00.network"]; + assert!( + text.contains("[Address]\nAddress=141.14.20.5/20\nBroadcast=141.14.19.255\n"), + "{}", + text + ); + } + + #[test] + fn output_is_deterministic() { + assert_eq!(example(), example()); + } +}