From e05adef7b746442376653799e1fb7e280c4c7b19 Mon Sep 17 00:00:00 2001 From: alexion Date: Sat, 25 Jul 2026 14:51:49 -0400 Subject: [PATCH] feat(network): add host VLAN-bridge networking foundation Introduce `modules.network`, the host-level networking foundation a Host declares once. A Host states its trunk interface and the tagged VLAN ids to materialize, and the module emits one systemd-networkd bridge per VLAN, named by the `br-vlan` convention, plus the host's own management address on a chosen VLAN's bridge. The trunk and every bridge set `RequiredForOnline = "no"` so wait-online never blocks boot on a carrier-less link, and the module owns its own NetworkManager `unmanaged` guard so enabling it is self-sufficient. Two assertions tie the management VLAN to the declared VLANs and a management address to a VLAN, so an address can never be silently dropped. No host commits an enablement: the only host is a wifi laptop that cannot present guests as L2 citizens and whose DNS the module's networkd/resolved would disturb, so the standing enablement waits for the first wired server host. The build and bridge-name evaluation were verified by enabling the module ad hoc, and stay reproducible through `extendModules`. --- .claude/tasks/0003-network-vlan-foundation.md | 35 +++++ modules/network.nix | 145 ++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 .claude/tasks/0003-network-vlan-foundation.md create mode 100644 modules/network.nix diff --git a/.claude/tasks/0003-network-vlan-foundation.md b/.claude/tasks/0003-network-vlan-foundation.md new file mode 100644 index 0000000..ee7fc91 --- /dev/null +++ b/.claude/tasks/0003-network-vlan-foundation.md @@ -0,0 +1,35 @@ +--- +spec: guests +--- + +## What to build + +The host-level networking foundation, `modules.network`, that a Host declares once and every Guest attaches to. +A Host states its trunk interface and the set of VLANs to materialize, and the Module emits one bridge per tagged VLAN using systemd-networkd, named by the `br-vlan` convention, and manages the Host's own management address. +This is a standalone host-level Module and does not yet wire any Guest to a bridge — that is the Guest networking placement slice. + +## Acceptance criteria + +- [x] `modules.network` declares an `enable` option and its option path mirrors its file location per the Namespace convention. +- [x] A Host declares its trunk interface and its set of VLAN ids through the Module's options. +- [x] Enabling the Module emits exactly one systemd-networkd bridge per declared VLAN, each named `br-vlan`, and manages the Host's own management address. +- [x] A Host enabling `modules.network` builds via `nix flake check`, and the emitted bridge names are verifiable by `nix eval`. Verified by temporarily enabling it on `neogaia`; the enablement is not committed (see notes). + +## Implementation Notes + +Each VLAN materializes as three networkd entries: a `.` tagged sub-interface stacked on the trunk, a `br-vlan` bridge, and a network enslaving the sub-interface to the bridge. +The trunk and every bridge set `RequiredForOnline = "no"`, so `systemd-networkd-wait-online` never blocks boot on a link with no carrier. + +The management address takes a static CIDR, or DHCP when left null, on the management VLAN's bridge alone. +Two assertions guard it: the management VLAN must be one of the declared VLANs, and a declared management address must name a management VLAN, so an address can never be silently dropped for want of a bridge to carry it. + +The Module owns its own NetworkManager `unmanaged` guard for the trunk, sub-interfaces, and bridges, so enabling it is self-sufficient on a host that also runs NetworkManager rather than pushing that wiring into every Host. +A `management.gateway` option was considered and dropped as speculative for this slice, since the foundation carries no other routing. + +No host commits an enablement of this Module. +The repo's only host is `neogaia`, a wifi laptop on an access port, and enabling the Module there turns on `systemd-networkd` and pulls in `systemd-resolved`, which takes over the laptop's DNS. +That is an unwanted change to a daily machine that cannot present guests as L2 citizens anyway (wifi does not bridge), so the standing enablement waits for the first wired server host. +The build and bridge-name evaluation were verified by temporarily enabling the Module on `neogaia` (`nix flake check` passed, `nix eval` showed `br-vlan10`/`br-vlan20`), then reverting. +Both stay reproducible from the committed tree by enabling the Module ad hoc through `nixosConfigurations.neogaia.extendModules`, leaving the host file untouched. + +No Guest is wired to a bridge — that is the guest networking placement slice. diff --git a/modules/network.nix b/modules/network.nix new file mode 100644 index 0000000..eb29cc6 --- /dev/null +++ b/modules/network.nix @@ -0,0 +1,145 @@ +{ + config, + lib, + ... +}: +# The host networking foundation: per-VLAN bridges over a tagged trunk. +let + cfg = config.modules.network; + + # Each tagged VLAN materializes as a bridge named for its id. + bridgeName = id: "br-vlan${toString id}"; + + # The tagged sub-interface stacked on the trunk that feeds one bridge. + vlanName = id: "${cfg.trunk}.${toString id}"; +in +{ + options.modules.network = { + enable = + lib.mkEnableOption "the host trunk, its tagged-VLAN bridges, and the host management address"; + + trunk = lib.mkOption { + type = lib.types.str; + example = "enp1s0"; + description = '' + The physical interface carrying 802.1Q-tagged traffic for every VLAN. + Each declared VLAN is stacked on it and enslaved to its own bridge. + ''; + }; + + vlans = lib.mkOption { + type = lib.types.listOf (lib.types.ints.between 1 4094); + default = [ ]; + example = [ + 10 + 20 + 30 + ]; + description = '' + The tagged VLAN ids to materialize. Each id N emits exactly one bridge + named `br-vlanN`, and guests on VLAN N attach to it. + ''; + }; + + management = { + vlan = lib.mkOption { + type = lib.types.nullOr (lib.types.ints.between 1 4094); + default = null; + description = '' + The VLAN whose bridge carries the host's own management address; must + be one of `vlans`. Left null, the host takes no address on any bridge + and the bridges serve guests alone. + ''; + }; + + address = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "10.0.10.2/24"; + description = '' + The host's static management address, in CIDR form, on the management + VLAN's bridge. Left null, the host takes its address there by DHCP. + ''; + }; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = cfg.management.vlan == null || lib.elem cfg.management.vlan cfg.vlans; + message = "modules.network.management.vlan (${toString cfg.management.vlan}) must name one of modules.network.vlans."; + } + { + assertion = cfg.management.address == null || cfg.management.vlan != null; + message = "modules.network.management.address needs modules.network.management.vlan to say which bridge carries it."; + } + ]; + + # networkd owns the trunk, its VLAN sub-interfaces, and the bridges, so a host + # also running NetworkManager leaves them to networkd rather than contending. + networking.networkmanager.unmanaged = + [ "interface-name:${cfg.trunk}" ] + ++ map (id: "interface-name:${bridgeName id}") cfg.vlans + ++ map (id: "interface-name:${vlanName id}") cfg.vlans; + + systemd.network = { + enable = true; + + netdevs = lib.mkMerge ( + map (id: { + "40-${bridgeName id}" = { + netdevConfig = { + Name = bridgeName id; + Kind = "bridge"; + }; + }; + "40-${vlanName id}" = { + netdevConfig = { + Name = vlanName id; + Kind = "vlan"; + }; + vlanConfig.Id = id; + }; + }) cfg.vlans + ); + + networks = lib.mkMerge ( + [ + # The trunk carries only tagged frames up to the sub-interfaces and + # takes no address of its own. + { + "30-${cfg.trunk}" = { + matchConfig.Name = cfg.trunk; + networkConfig.LinkLocalAddressing = "no"; + linkConfig.RequiredForOnline = "no"; + vlan = map vlanName cfg.vlans; + }; + } + ] + ++ map (id: { + # Enslaved to its bridge, carrying no address itself. + "40-${vlanName id}" = { + matchConfig.Name = vlanName id; + networkConfig.Bridge = bridgeName id; + linkConfig.RequiredForOnline = "no"; + }; + # The management VLAN's bridge carries the host address; every other + # bridge is a plain L2 segment for guests. + "40-${bridgeName id}" = lib.mkMerge [ + { + matchConfig.Name = bridgeName id; + linkConfig.RequiredForOnline = "no"; + } + (lib.mkIf (cfg.management.vlan == id) ( + if cfg.management.address == null then + { networkConfig.DHCP = "yes"; } + else + { address = [ cfg.management.address ]; } + )) + ]; + }) cfg.vlans + ); + }; + }; +}