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<id>` 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.
This commit is contained in:
30
.claude/tasks/0003-network-vlan-foundation.md
Normal file
30
.claude/tasks/0003-network-vlan-foundation.md
Normal file
@@ -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<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`.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
@@ -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;
|
||||
|
||||
145
modules/network.nix
Normal file
145
modules/network.nix
Normal 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
|
||||
);
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user