From b87076ebd33e19cb50d33952875d6e6d5a622714 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. Enabled on neogaia as a tracer with placeholder values, proving the host build and bridge-name evaluation through `nix flake check`. No Guest is wired to a bridge yet. --- .claude/tasks/0003-network-vlan-foundation.md | 30 ++++ hosts/neogaia/default.nix | 13 ++ modules/network.nix | 145 ++++++++++++++++++ 3 files changed, 188 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..01b4141 --- /dev/null +++ b/.claude/tasks/0003-network-vlan-foundation.md @@ -0,0 +1,30 @@ +--- +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`. + +## 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. + +Enabled on `neogaia` as a tracer with a placeholder trunk and VLAN ids, mirroring the walking-skeleton guest, so the host build and bridge-name evaluation are proven through this host's `nix flake check`. +No Guest is wired to a bridge — that is the guest networking placement slice. diff --git a/hosts/neogaia/default.nix b/hosts/neogaia/default.nix index d91ea56..7bb89ad 100644 --- a/hosts/neogaia/default.nix +++ b/hosts/neogaia/default.nix @@ -44,6 +44,19 @@ modules.toolkit.enable = true; + # The networking foundation, enabled like any module: proves the host build + # and bridge-name evaluation path through this host's `nix flake check`. + # The trunk, VLAN ids, and management address are placeholders a real homelab + # host replaces, and the placeholder trunk names no interface this laptop has. + modules.network.enable = true; + modules.network.trunk = "enp1s0"; + modules.network.vlans = [ + 10 + 20 + ]; + modules.network.management.vlan = 10; + modules.network.management.address = "10.0.10.2/24"; + # The walking-skeleton guest, enabled like any module: proves the guest path # end to end through this host's `nix flake check`. guests.sample.enable = true; 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 + ); + }; + }; +}