From bf0ca3758bdc56a1c67b188cfb9b614d41479255 Mon Sep 17 00:00:00 2001 From: OnkelClaude Date: Wed, 29 Jul 2026 09:36:34 +0200 Subject: [PATCH 1/5] mx-networkd: Add a systemd generator for the MarIuX network configuration mxnetctl, mxvlanctl and the two oneshot units around them do three things that systemd-networkd and udev can do themselves: rename interfaces by MAC address, put the primary address and the default route on one of them, and create the VLANs of the local host. mx-networkd is a systemd generator that translates the existing configuration files into .link, .netdev and .network files. Generators run before any unit starts, including systemd-udevd, so the .link files are in place before the first device is coldplugged. Everything is written to /run/systemd/network/, which keeps /etc/systemd/network/ empty as required and leaves /etc/local/mxnet, /etc/local/mxhost.conf and /etc/mxvlans as the single source of truth, unchanged in format. Three subcommands: `generate` (what the generator runs), `check` (parse and report, for the master before pushing /etc/mxvlans) and `learn` (assign netXX names to unknown MACs on a first boot, the one thing .link files cannot do). Notable details: - systemd-networkd applies only the first matching .network file to an interface, so all settings of one interface go into one file even when they come from different input files. - The output is deterministic, so re-running the generator on every daemon-reload rewrites nothing. - A broken line is skipped and logged rather than fatal: at boot a partially configured network beats no network. `check` turns the same problems into a non-zero exit status where a human can still fix them. - The prefix length, gateway and broadcast address that were hard coded in network.service are defaults now and can be overridden per host with MX_PREFIXLEN, MX_GATEWAY and MX_BROADCAST. No dependencies beyond the standard library. Nothing is enabled by this commit; see mx-networkd/README.md for the per machine migration. Written by OnkelClaude (Claude, KI-Bearbeitung im Auftrag von Boris) for pmenzel/oc-exchange issue #3. `cargo test` passes (21 tests); the generator has not yet run on a real MarIuX machine. Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + mx-networkd/Cargo.lock | 7 + mx-networkd/Cargo.toml | 18 + mx-networkd/README.md | 143 ++++++++ mx-networkd/src/config.rs | 730 ++++++++++++++++++++++++++++++++++++++ mx-networkd/src/learn.rs | 233 ++++++++++++ mx-networkd/src/main.rs | 362 +++++++++++++++++++ mx-networkd/src/render.rs | 283 +++++++++++++++ 8 files changed, 1777 insertions(+) create mode 100644 mx-networkd/Cargo.lock create mode 100644 mx-networkd/Cargo.toml create mode 100644 mx-networkd/README.md create mode 100644 mx-networkd/src/config.rs create mode 100644 mx-networkd/src/learn.rs create mode 100644 mx-networkd/src/main.rs create mode 100644 mx-networkd/src/render.rs 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/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..f724d901 --- /dev/null +++ b/mx-networkd/README.md @@ -0,0 +1,143 @@ +# 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 + +Per machine, reversible at every step: + +1. Install, then look at what would happen — nothing is applied yet: + + mx-networkd check + mx-networkd generate --dry-run + +2. Compare with reality: `ip -br addr`, `ip -d link show type vlan`. + +3. Switch over. The old and the new mechanism must not run at the same time — + both would try to own the addresses: + + systemctl disable --now mxvlan.service network.service + systemctl enable --now systemd-networkd.service + systemctl daemon-reload # runs the generator + networkctl reload + +4. Reboot once to verify that the `.link` files are applied at coldplug (the + renaming only happens on device *add*; a running interface is not renamed). + +To go back: `systemctl disable --now systemd-networkd.service`, remove +`/run/systemd/network/*-mx-*`, `systemctl enable --now network.service +mxvlan.service`. + +Note that `systemd-networkd` must be allowed to manage these interfaces; on a +machine that still has the old units enabled, do not enable networkd. + +## 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/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..0dfaba05 --- /dev/null +++ b/mx-networkd/src/main.rs @@ -0,0 +1,362 @@ +//! 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. +"; + +const DEFAULT_OUTPUT: &str = "/run/systemd/network"; + +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 { + 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!( + "{} 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()); + } +} From 87f4d1ac9372bf4fa0a1c9dd1df4e0d623ded10a Mon Sep 17 00:00:00 2001 From: OnkelClaude Date: Wed, 29 Jul 2026 09:36:48 +0200 Subject: [PATCH 2/5] Makefile, install.sh: Build and install mx-networkd `make` builds the binary with cargo, `make install` puts it into /usr/sbin/mx-networkd and installs the same binary as the generator /usr/lib/systemd/system-generators/mx-networkd-generator (it recognises being called under that name). The install is skipped when the binary was not built, so `install.sh` keeps working standalone on a machine without a Rust toolchain. Installing the generator does not change any behaviour by itself: it only writes to /run/systemd/network/, which nothing reads unless systemd-networkd is enabled on that machine. Co-Authored-By: Claude Fable 5 --- Makefile | 11 ++++++++++- install.sh | 7 +++++++ 2 files changed, 17 insertions(+), 1 deletion(-) 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/install.sh b/install.sh index 00b78e41..d8e32ebc 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,12 @@ 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 mxrouter/mxrouterctl "$DESTDIR$usr_sbindir/mxrouterctl" install_exec mxvlan/mxvlanctl "$DESTDIR$usr_sbindir/mxvlanctl" install_exec netlog/netlog "$DESTDIR$usr_sbindir/netlog" From 9f031a92054cc40d12ea33d969241e07098db5f1 Mon Sep 17 00:00:00 2001 From: OnkelClaude Date: Tue, 11 Aug 2026 22:05:41 +0200 Subject: [PATCH 3/5] mx-networkd: Make the migration switchover reboot-based network.service removes the primary address in ExecStop, so 'systemctl disable --now network.service' over SSH drops the connection and locks the admin out of the machine. Flip the units without --now and let the reboot do the swap: the old units run until shutdown, the generator and the .link files take over at the next coldplug, and the two mechanisms never overlap. The rollback gets the same treatment. Addresses the review comment on PR #1. (Commit authored by OnkelClaude, KI-Bearbeitung im Auftrag von Boris.) --- mx-networkd/README.md | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/mx-networkd/README.md b/mx-networkd/README.md index f724d901..f367ac76 100644 --- a/mx-networkd/README.md +++ b/mx-networkd/README.md @@ -102,20 +102,28 @@ Per machine, reversible at every step: 2. Compare with reality: `ip -br addr`, `ip -d link show type vlan`. -3. Switch over. The old and the new mechanism must not run at the same time — - both would try to own the addresses: - - systemctl disable --now mxvlan.service network.service - systemctl enable --now systemd-networkd.service - systemctl daemon-reload # runs the generator - networkctl reload - -4. Reboot once to verify that the `.link` files are applied at coldplug (the - renaming only happens on device *add*; a running interface is not renamed). - -To go back: `systemctl disable --now systemd-networkd.service`, remove -`/run/systemd/network/*-mx-*`, `systemctl enable --now network.service -mxvlan.service`. +3. Switch over — at the next boot, not live. Do **not** use `--now`: + `network.service` deletes the primary address on stop + (`ExecStop=ip addr del …`), so stopping it over SSH cuts the connection + and locks you out. Flip the units and let the reboot do the swap: + + systemctl disable mxvlan.service network.service + systemctl enable systemd-networkd.service + reboot + + The old units keep running until shutdown; at the next boot the generator + runs before udev, the `.link` files rename the devices at coldplug, and + networkd owns the addresses from the start. The old and the new mechanism + never run at the same time. 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: `systemctl disable systemd-networkd.service`, +`systemctl enable network.service mxvlan.service`, reboot — not live either, +for the same reason in reverse (`ip addr add` on an address networkd still +holds fails, and the units fight over the interface). Note that `systemd-networkd` must be allowed to manage these interfaces; on a machine that still has the old units enabled, do not enable networkd. From 82d82455f2865358396165b44192056a3dbc565c Mon Sep 17 00:00:00 2001 From: OnkelClaude Date: Wed, 12 Aug 2026 10:17:09 +0200 Subject: [PATCH 4/5] mx-networkd: Condition the switchover on hostconfig tag mx-network-generator Make the hostconfig tag mx-network-generator the single per-machine switch between the old and the new network setup (hyphens are fine in tag names: hostconfig matches tag terms with [a-z][a-z0-9$_-]*): * network.service and mxvlan.service get ConditionPathExists=!/node/tags/mx-network-generator, so they keep running everywhere until a machine carries the tag. * systemd-networkd.service gets a drop-in with ConditionPathExists=/node/tags/mx-network-generator, so it never fights the old units on a machine that has not been switched over. * mx-networkd generate writes nothing while the tag is absent and prunes files it wrote earlier, so a rollback needs nothing beyond removing the tag. /node/tags/ persists across boots and generators run before startup-tags.service, so the previous boot's populate-node result is what gates the generator. --dry-run ignores the tag so the preview always works; check now reports the tag state. Requested by Paul in PR #1 review. Written by OnkelClaude (AI) on behalf of Boris Bergenroth. Co-authored-by: OnkelClaude (AI) --- etc/systemd/system/mxvlan.service | 4 ++ etc/systemd/system/network.service | 4 ++ .../mx-network-generator.conf | 8 ++++ mx-networkd/src/main.rs | 47 +++++++++++++++++++ 4 files changed, 63 insertions(+) create mode 100644 etc/systemd/system/systemd-networkd.service.d/mx-network-generator.conf 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/mx-networkd/src/main.rs b/mx-networkd/src/main.rs index 0dfaba05..e7313e67 100644 --- a/mx-networkd/src/main.rs +++ b/mx-networkd/src/main.rs @@ -39,10 +39,23 @@ 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, @@ -208,6 +221,31 @@ fn collect(options: &Options) -> (render::Plan, Vec) { } 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 { @@ -256,6 +294,15 @@ fn check(options: &Options) -> ExitCode { 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(), From 264f72ad6ea8f5c697aa8e7551200d799d44f524 Mon Sep 17 00:00:00 2001 From: OnkelClaude Date: Wed, 12 Aug 2026 10:17:09 +0200 Subject: [PATCH 5/5] mx-networkd: Add migration helper script Add mx-networkd/migrate-to-mx-networkd.sh, installed as /usr/sbin/migrate-to-mx-networkd. It prepares the reboot-based switchover without touching the running network, so it is safe over SSH and idempotent: * checks that binary, generator, drop-in and conditioned units are installed and that /etc/local/mxhost.conf and /etc/local/mxnet exist * validates the configuration with mx-networkd check and shows the generate --dry-run preview for comparison with the running state * verifies the mx-network-generator tag is set in /etc/hostconfig (and prints the exact line to add on the distmaster if not - /node/tags/ is rebuilt from /etc/hostconfig at every boot, so a locally touched tag file would not survive) * runs hostconfig --populate-node and enables systemd-networkd.service for the next boot only - nothing is started, stopped or reloaded * prints a summary and reboot-based rollback instructions Rewrite the README migration section around the tag and the script. Requested by Paul in PR #1 review. Written by OnkelClaude (AI) on behalf of Boris Bergenroth. Co-authored-by: OnkelClaude (AI) --- install.sh | 1 + mx-networkd/README.md | 78 ++++++++++------- mx-networkd/migrate-to-mx-networkd.sh | 119 ++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 30 deletions(-) create mode 100755 mx-networkd/migrate-to-mx-networkd.sh diff --git a/install.sh b/install.sh index d8e32ebc..7b57af58 100755 --- a/install.sh +++ b/install.sh @@ -152,6 +152,7 @@ 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/README.md b/mx-networkd/README.md index f367ac76..407795ca 100644 --- a/mx-networkd/README.md +++ b/mx-networkd/README.md @@ -93,40 +93,58 @@ as `/usr/sbin/mx-networkd` and as the generator ## Migration -Per machine, reversible at every step: - -1. Install, then look at what would happen — nothing is applied yet: - - mx-networkd check - mx-networkd generate --dry-run - -2. Compare with reality: `ip -br addr`, `ip -d link show type vlan`. - -3. Switch over — at the next boot, not live. Do **not** use `--now`: - `network.service` deletes the primary address on stop - (`ExecStop=ip addr del …`), so stopping it over SSH cuts the connection - and locks you out. Flip the units and let the reboot do the swap: - - systemctl disable mxvlan.service network.service - systemctl enable systemd-networkd.service - reboot - - The old units keep running until shutdown; at the next boot the generator - runs before udev, the `.link` files rename the devices at coldplug, and - networkd owns the addresses from the start. The old and the new mechanism - never run at the same time. A reboot is needed anyway: renaming only - happens on device *add* — a running interface is not renamed. - +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: `systemctl disable systemd-networkd.service`, -`systemctl enable network.service mxvlan.service`, reboot — not live either, -for the same reason in reverse (`ip addr add` on an address networkd still -holds fails, and the units fight over the interface). +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): -Note that `systemd-networkd` must be allowed to manage these interfaces; on a -machine that still has the old units enabled, do not enable networkd. +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 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__