feat(network): add host VLAN-bridge networking foundation #29

Merged
alexion merged 1 commits from task-0003-network-vlan-foundation into main 2026-07-25 16:30:12 -04:00
2 changed files with 180 additions and 0 deletions

View File

@@ -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<id>` 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<id>`, 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 `<trunk>.<id>` tagged sub-interface stacked on the trunk, a `br-vlan<id>` 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.

145
modules/network.nix Normal file
View File

@@ -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
);
};
};
}