feat(guests): place networked guests on tagged VLANs (task 0004)
A guest sets `vlan` to attach to its host's `br-vlan<id>` bridge, `mac` to reuse an existing address (else a stable one is derived from its namespace path and read back via `nix eval`), and `address` for a static IP (else DHCP). The MAC and address are pinned inside the guest by its own networkd, the only stable MAC pin for a nested container. A guest naming a VLAN its host has not declared fails the build with an actionable message. The `br-vlan<id>` naming moves into a shared `bridgeName` in the lib, so the bridge a guest attaches to and the bridge the host emits have one source.
This commit was merged in pull request #30.
This commit is contained in:
42
.claude/tasks/0004-guest-networking-placement.md
Normal file
42
.claude/tasks/0004-guest-networking-placement.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
spec: guests
|
||||
blocked-by: [0002-guest-walking-skeleton, 0003-network-vlan-foundation]
|
||||
---
|
||||
|
||||
## What to build
|
||||
|
||||
The Host-side placement fields that make a Guest a first-class L2 citizen on a tagged VLAN, exactly as Proxmox did.
|
||||
A Host sets `vlan`, and the Guest attaches to that Host's `br-vlan<id>` bridge by the naming convention, so the Guest states only which VLAN it lives on.
|
||||
A Host may set `mac` to reuse an existing address so its router's DHCP reservations keep working; an unset `mac` derives a stable, readable address from the Guest's namespace path in a locally-administered range, surfaced via evaluation so the operator can add a reservation.
|
||||
The MAC is pinned inside the guest through the guest's own systemd-networkd, which is the only way a nested-container MAC stays stable.
|
||||
A Host may set `address` for a static IP; unset means DHCP, keeping IP management centralized at the router.
|
||||
A build-time assertion ties the Guest's `vlan` to the set of VLANs its Host's `modules.network` declares, so a Guest naming an undeclared VLAN fails the Host build with a clear message rather than as a broken bridge at runtime.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] A Host setting `guests.<path>.vlan` attaches the Guest to that Host's `br-vlan<id>` bridge by the naming convention.
|
||||
- [x] Setting `mac` pins that exact address on the Guest via the guest's own systemd-networkd; leaving it unset derives a stable MAC from the Guest's namespace path in a locally-administered range, readable via `nix eval`.
|
||||
- [x] Setting `address` gives the Guest a static IP on its VLAN; leaving it unset takes the address by DHCP.
|
||||
- [x] A Guest whose `vlan` is not among its Host's declared VLANs fails `nix flake check` with a clear, actionable message naming the offending Guest and VLAN.
|
||||
- [x] A Host with a correctly-placed networked Guest builds via `nix flake check`, and the Guest's resolved bridge attachment and derived MAC are verifiable by `nix eval`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `br-vlan<id>` naming was a local helper in `modules/network.nix` and is now a shared `bridgeName` in `lib.nix`, exported through `my` and consumed by both the network foundation and guest placement.
|
||||
The convention has one source, so the bridge a guest attaches to can never drift from the bridge the host emits.
|
||||
|
||||
The interior networking is realized by a small module injected into the guest's container config only when `vlan` is set.
|
||||
It enables the guest's own systemd-networkd on `eth0` — the name a nested container gives its bridged veth — pinning the placement MAC there and taking the static `address` or DHCP when it is unset.
|
||||
Pinning the MAC through the guest's own networkd is the only way a nested-container MAC stays stable; the nspawn-assigned veth MAC is otherwise regenerated.
|
||||
Enabling networkd default-enables `systemd-resolved`, so a DHCP guest also gets its resolver.
|
||||
|
||||
The derived MAC is the `mac` option's default, so an unset MAC reads back through `nix eval .#nixosConfigurations.<host>.config.guests.<path>.mac`.
|
||||
The first octet is `02` (locally-administered, unicast) and the remaining five octets are a hash slice of the namespace path.
|
||||
|
||||
A static `address` sets only the on-VLAN IP, with no gateway or DNS.
|
||||
This mirrors `modules.network`, which deliberately dropped a `management.gateway` as speculative for the foundation slice; off-VLAN routing for a statically-addressed guest is a later concern, and the centralized path stays DHCP.
|
||||
|
||||
The networked path is verified by `nix eval` against `neogaia` through `extendModules` rather than by committing an enablement, exactly as task 0003 verified `modules.network`.
|
||||
`neogaia` is a wifi laptop that cannot bridge, and enabling networkd on it would take over its DNS, so its committed `guests.sample` placement leaves `vlan` unset.
|
||||
Verified: with `vlan = 10` the guest resolves `hostBridge = br-vlan10` and the interior `eth0` networkd pins the derived MAC; an unset `address` yields `DHCP = "yes"` and a set one yields the static CIDR; and `vlan = 99` against declared `[10 20]` fails the build with the actionable message.
|
||||
`nix flake check` passes with `guests.sample` building its interior in full.
|
||||
79
lib.nix
79
lib.nix
@@ -39,6 +39,22 @@ let
|
||||
my = self.lib;
|
||||
};
|
||||
|
||||
# The name of a tagged VLAN's bridge, kept here as the one definition of a
|
||||
# convention shared across the flake.
|
||||
bridgeName = id: "br-vlan${toString id}";
|
||||
|
||||
# A guest with no operator-set MAC derives a stable one from its namespace path.
|
||||
# The first octet 02 marks the address locally-administered and unicast.
|
||||
# The rest is a slice of the path's hash.
|
||||
# The same guest therefore always lands on the same address, which the operator can reserve at the router.
|
||||
deriveMac =
|
||||
name:
|
||||
let
|
||||
hash = builtins.hashString "sha256" name;
|
||||
octet = i: builtins.substring (i * 2) 2 hash;
|
||||
in
|
||||
lib.concatStringsSep ":" ([ "02" ] ++ map octet [ 0 1 2 3 4 ]);
|
||||
|
||||
# Build one host: every module and every guest is imported unconditionally
|
||||
# (inert until its `enable` flag is set), alongside chaotic, the host base,
|
||||
# and the host's own directory.
|
||||
@@ -77,6 +93,25 @@ let
|
||||
optionPath = [ "guests" ] ++ lib.splitString "." name;
|
||||
cfg = lib.getAttrFromPath optionPath config;
|
||||
machineName = lib.replaceStrings [ "." ] [ "-" ] name;
|
||||
|
||||
networked = cfg.vlan != null;
|
||||
|
||||
# A networked guest owns its bridged interface through its own networkd, the only stable MAC pin for a nested container.
|
||||
# The interface is eth0, the name a nested container gives its bridged veth.
|
||||
# It takes the placement MAC, and the static address or DHCP when that is unset.
|
||||
guestNet =
|
||||
{ lib, ... }:
|
||||
{
|
||||
config = lib.mkIf networked {
|
||||
networking.useNetworkd = true;
|
||||
systemd.network.networks."20-eth0" = {
|
||||
matchConfig.Name = "eth0";
|
||||
linkConfig.MACAddress = cfg.mac;
|
||||
networkConfig = lib.mkIf (cfg.address == null) { DHCP = "yes"; };
|
||||
address = lib.mkIf (cfg.address != null) [ cfg.address ];
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
options = lib.setAttrByPath optionPath {
|
||||
@@ -93,6 +128,38 @@ let
|
||||
hard-isolation backend and is not built yet.
|
||||
'';
|
||||
};
|
||||
vlan = lib.mkOption {
|
||||
type = lib.types.nullOr (lib.types.ints.between 1 4094);
|
||||
default = null;
|
||||
example = 10;
|
||||
description = ''
|
||||
The tagged VLAN this guest lives on. The guest attaches to its host's
|
||||
`br-vlan<id>` bridge for that VLAN. Left null, the guest keeps a
|
||||
private network with no bridge attachment. The id must be one of the
|
||||
host's `modules.network.vlans`.
|
||||
'';
|
||||
};
|
||||
mac = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = deriveMac name;
|
||||
defaultText = lib.literalMD "a stable address derived from the guest's namespace path";
|
||||
example = "bc:24:11:00:00:01";
|
||||
description = ''
|
||||
The guest's MAC address on its VLAN, pinned inside the guest by its
|
||||
own networkd. Set it to reuse an existing address so a router's DHCP
|
||||
reservation keeps working. Left unset, a stable address is derived
|
||||
from the guest's namespace path in the locally-administered range.
|
||||
'';
|
||||
};
|
||||
address = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "10.0.10.5/24";
|
||||
description = ''
|
||||
The guest's static address, in CIDR form, on its VLAN. Left null, the
|
||||
guest takes its address by DHCP, keeping IP management at the router.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
@@ -103,6 +170,12 @@ let
|
||||
guests.${name}.backend = "${cfg.backend}" is not implemented. Only the "container" backend is built; "microvm" is reserved for future work.
|
||||
'';
|
||||
}
|
||||
{
|
||||
assertion = !networked || lib.elem cfg.vlan config.modules.network.vlans;
|
||||
message = ''
|
||||
guests.${name}.vlan = ${toString cfg.vlan} is not among its host's modules.network.vlans (${lib.concatMapStringsSep ", " toString config.modules.network.vlans}). Declare the VLAN on the host or correct the guest's placement.
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
containers.${machineName} = lib.mkIf (cfg.backend == "container") {
|
||||
@@ -112,11 +185,16 @@ let
|
||||
# sshd included — never contend with the host's.
|
||||
privateNetwork = lib.mkDefault true;
|
||||
|
||||
# A networked guest's veth is enslaved to the VLAN's bridge, making it
|
||||
# a first-class L2 citizen on that segment.
|
||||
hostBridge = lib.mkIf networked (bridgeName cfg.vlan);
|
||||
|
||||
inherit specialArgs;
|
||||
|
||||
config = {
|
||||
imports = [
|
||||
(self + "/guest.nix")
|
||||
guestNet
|
||||
interior
|
||||
];
|
||||
};
|
||||
@@ -138,5 +216,6 @@ in
|
||||
mkHost
|
||||
mkHosts
|
||||
guest
|
||||
bridgeName
|
||||
;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
my,
|
||||
...
|
||||
}:
|
||||
# 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}";
|
||||
# Each tagged VLAN materializes as a bridge named for its id, by the shared
|
||||
# convention.
|
||||
inherit (my) bridgeName;
|
||||
|
||||
# The tagged sub-interface stacked on the trunk that feeds one bridge.
|
||||
vlanName = id: "${cfg.trunk}.${toString id}";
|
||||
|
||||
Reference in New Issue
Block a user