Compare commits

...

1 Commits

Author SHA1 Message Date
b87076ebd3 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.
2026-07-25 14:51:49 -04:00
3 changed files with 188 additions and 0 deletions

View 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.

View File

@@ -44,6 +44,19 @@
modules.toolkit.enable = true; 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 # The walking-skeleton guest, enabled like any module: proves the guest path
# end to end through this host's `nix flake check`. # end to end through this host's `nix flake check`.
guests.sample.enable = true; guests.sample.enable = true;

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