Add router-on-a-stick, commit-confirm rollback and an example config

- Allow the WAN link to also carry LANs when the uplink is PPPoE on a
  VLAN with IPv6 off; only the PPP device counts as an uplink then, so
  LAN traffic can never match WAN rules.
- Add router.rollback: router-arm / router-confirm / router-rollback
  plus `nix run .#deploy`, a commit-confirm deployment flow.
- Wait for the LAN devices instead of the uplink in networkd
  wait-online, so an unplugged uplink cannot block boot or activation.
- Add examples/desktop (single NIC, PPPoE on VLAN 1001) as
  nixosConfigurations.example, built as a flake check.

Covered by the onestick and rollback VM tests and new assertion cases.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 23:09:09 +01:00
co-authored by Claude Sonnet 5.5
parent 3f8f2f4199
commit 0653b5cf2f
14 changed files with 714 additions and 89 deletions
+10 -2
View File
@@ -310,8 +310,16 @@ in
message = "router: no LAN has allowManagement = true; you would be locked out of the router.";
}
{
assertion = !(lib.elem cfg.wan.interface (map (l: l.interface) lans));
message = "router: the WAN interface `${cfg.wan.interface}` is also used by a LAN.";
assertion =
!(lib.elem cfg.wan.interface (map (l: l.interface) lans))
|| (cfg.wan.ipv4.type == "pppoe" && cfg.wan.ipv4.pppoe.vlan != null && !cfg.wan.ipv6.enable);
message = "router: the WAN interface `${cfg.wan.interface}` is also used by a LAN (only allowed for PPPoE on a VLAN with IPv6 disabled).";
}
{
assertion =
cfg.wan.ipv4.pppoe.vlan == null
|| !(lib.any (l: l.interface == cfg.wan.interface && l.vlan == cfg.wan.ipv4.pppoe.vlan) lans);
message = "router: VLAN ${toString cfg.wan.ipv4.pppoe.vlan} on `${cfg.wan.interface}` is used by both the PPPoE uplink and a LAN.";
}
{
assertion = cfg.wan.ipv4.type != "pppoe" || cfg.wan.ipv4.pppoe.username != "";
+1
View File
@@ -8,5 +8,6 @@
./firewall.nix
./dhcp.nix
./dns.nix
./rollback.nix
];
}
+3 -4
View File
@@ -179,10 +179,9 @@ let
uplink4 = cfg.wan.ipv4.interface;
pppoe = cfg.wan.ipv4.type == "pppoe";
# IPv4 leaves via the PPP device, IPv6 via the physical uplink.
uplinks = lib.unique [
cfg.wan.interface
uplink4
];
# When the physical link also carries LANs, only the PPP device is an uplink.
sharedLink = lib.elem cfg.wan.interface (map (l: l.interface) lans);
uplinks = lib.unique ([ uplink4 ] ++ lib.optional (!sharedLink) cfg.wan.interface);
mss = cfg.wan.ipv4.pppoe.mtu - 40;
dhcpIfaces = map (l: l.device) (lib.filter (l: l.dhcp.enable) lans);
in
+86 -75
View File
@@ -5,11 +5,15 @@ let
pppoe = cfg.wan.ipv4.type == "pppoe";
vlan = cfg.wan.ipv4.pppoe.vlan;
vlanDevice = "${cfg.wan.interface}.${toString vlan}";
wanVlanOn = parent: lib.optional (pppoe && vlan != null && parent == cfg.wan.interface) vlanDevice;
lans = lib.attrValues cfg.lans;
vlanLans = lib.filter (l: l.vlan != null) lans;
# VLAN devices hang off their parent; the parent's own unit must list them.
vlansOn = parent: map (l: l.device) (lib.filter (l: l.interface == parent) vlanLans);
vlansOn =
parent: map (l: l.device) (lib.filter (l: l.interface == parent) vlanLans) ++ wanVlanOn parent;
# The WAN link's own unit is only needed when no LAN shares the link.
sharedLink = lib.elem cfg.wan.interface (map (l: l.interface) lans);
untaggedParents = map (l: l.interface) (lib.filter (l: l.vlan == null) lans);
# Parents that carry only tagged LANs still need a unit to be brought up.
trunkParents = lib.filter (p: !(lib.elem p untaggedParents)) (
@@ -22,6 +26,12 @@ in
config = lib.mkIf cfg.enable {
networking.useNetworkd = true;
networking.useDHCP = false;
# Wait for the LAN devices (VLANs included: Kea only binds interfaces that
# exist when it starts) but not for the uplink: an ISP outage or a pulled
# cable must not hold up boot or a deployment.
systemd.network.wait-online.extraArgs = map (l: "--interface=${l.device}") (
lib.attrValues cfg.lans
);
boot.kernel.sysctl = {
"net.ipv4.conf.all.forwarding" = 1;
@@ -49,87 +59,88 @@ in
}
) (lib.filterAttrs (_: l: l.vlan != null) cfg.lans);
systemd.network.networks = {
"10-wan" = {
matchConfig.Name = cfg.wan.interface;
networkConfig = {
DHCP = dhcp;
systemd.network.networks =
lib.optionalAttrs (!sharedLink) {
"10-wan" = {
matchConfig.Name = cfg.wan.interface;
networkConfig = {
DHCP = dhcp;
}
// lib.optionalAttrs v6 {
IPv6AcceptRA = true;
KeepConfiguration = "yes";
}
// lib.optionalAttrs (pppoe && vlan != null) {
VLAN = [ vlanDevice ];
};
}
// lib.optionalAttrs (!pppoe) {
# The router runs its own resolver; never adopt the ISP's DNS for the host.
dhcpV4Config.UseDNS = false;
}
// lib.optionalAttrs v6 {
IPv6AcceptRA = true;
KeepConfiguration = "yes";
}
// lib.optionalAttrs (pppoe && vlan != null) {
VLAN = [ vlanDevice ];
# The ISP sends RAs (default route) without DHCPv6 flags, so solicit anyway.
dhcpV6Config = {
WithoutRA = "solicit";
UseDelegatedPrefix = true;
UseDNS = false;
};
};
}
// lib.optionalAttrs (!pppoe) {
# The router runs its own resolver; never adopt the ISP's DNS for the host.
dhcpV4Config.UseDNS = false;
}
// lib.optionalAttrs v6 {
# The ISP sends RAs (default route) without DHCPv6 flags, so solicit anyway.
dhcpV6Config = {
WithoutRA = "solicit";
UseDelegatedPrefix = true;
UseDNS = false;
};
};
}
// lib.optionalAttrs (pppoe && vlan != null) {
# pppd configures the session; networkd only has to bring the device up.
"11-wan-pppoe" = {
matchConfig.Name = vlanDevice;
networkConfig = {
LinkLocalAddressing = "no";
IPv6AcceptRA = false;
};
};
}
// lib.mapAttrs' (
name: lan:
lib.nameValuePair "20-lan-${name}" (
{
matchConfig.Name = lan.device;
address = [ "${lan.ipv4.address}/${toString lan.ipv4.prefixLength}" ];
networkConfig = {
ConfigureWithoutCarrier = true;
# Never accept RAs from the inside.
IPv6AcceptRA = false;
}
// lib.optionalAttrs (lan.vlan == null && vlansOn lan.interface != [ ]) {
VLAN = vlansOn lan.interface;
}
// lib.optionalAttrs lan.ipv6.pd {
DHCPPrefixDelegation = true;
IPv6SendRA = true;
};
}
// lib.optionalAttrs lan.ipv6.pd {
dhcpPrefixDelegationConfig = {
UplinkInterface = cfg.wan.interface;
SubnetId = 0;
Announce = true;
Assign = true;
};
# No RDNSS yet: the global address is unknown at evaluation time.
ipv6SendRAConfig.EmitDNS = false;
}
)
) cfg.lans
// lib.listToAttrs (
map (
parent:
lib.nameValuePair "19-trunk-${parent}" {
matchConfig.Name = parent;
// lib.optionalAttrs (pppoe && vlan != null) {
# pppd configures the session; networkd only has to bring the device up.
"11-wan-pppoe" = {
matchConfig.Name = vlanDevice;
networkConfig = {
LinkLocalAddressing = "no";
IPv6AcceptRA = false;
ConfigureWithoutCarrier = true;
VLAN = vlansOn parent;
};
}
) trunkParents
);
};
}
// lib.mapAttrs' (
name: lan:
lib.nameValuePair "20-lan-${name}" (
{
matchConfig.Name = lan.device;
address = [ "${lan.ipv4.address}/${toString lan.ipv4.prefixLength}" ];
networkConfig = {
ConfigureWithoutCarrier = true;
# Never accept RAs from the inside.
IPv6AcceptRA = false;
}
// lib.optionalAttrs (lan.vlan == null && vlansOn lan.interface != [ ]) {
VLAN = vlansOn lan.interface;
}
// lib.optionalAttrs lan.ipv6.pd {
DHCPPrefixDelegation = true;
IPv6SendRA = true;
};
}
// lib.optionalAttrs lan.ipv6.pd {
dhcpPrefixDelegationConfig = {
UplinkInterface = cfg.wan.interface;
SubnetId = 0;
Announce = true;
Assign = true;
};
# No RDNSS yet: the global address is unknown at evaluation time.
ipv6SendRAConfig.EmitDNS = false;
}
)
) cfg.lans
// lib.listToAttrs (
map (
parent:
lib.nameValuePair "19-trunk-${parent}" {
matchConfig.Name = parent;
networkConfig = {
LinkLocalAddressing = "no";
IPv6AcceptRA = false;
ConfigureWithoutCarrier = true;
VLAN = vlansOn parent;
};
}
) trunkParents
);
};
}
+4
View File
@@ -14,6 +14,10 @@ in
description = ''
Physical uplink interface. Native IPv6 (DHCPv6) runs on it untagged;
with PPPoE it also carries the (optionally tagged) PPPoE session.
The link may also carry LANs ("router on a stick") when the uplink
is PPPoE on a VLAN and IPv6 is disabled: the untagged link is then
free for a LAN and only the PPP device counts as an uplink.
'';
};
+82
View File
@@ -0,0 +1,82 @@
# Commit-confirm for deployments. `router-arm` starts a timer; unless
# `router-confirm` stops it in time, `router-rollback` re-activates the last
# confirmed generation. Pair it with `nixos-rebuild test` (activate without
# changing the boot default): a reboot also comes back to the confirmed system.
{
config,
lib,
pkgs,
...
}:
let
cfg = config.router.rollback;
profile = "/nix/var/nix/profiles/system";
tools = [
pkgs.coreutils
config.systemd.package
];
# The confirmed system: the profile, or what booted if there is none.
confirmed = ''
target=${profile}
[ -e "$target/bin/switch-to-configuration" ] || target=/run/booted-system
'';
rollback = pkgs.writeShellApplication {
name = "router-rollback";
runtimeInputs = tools;
text = ''
${confirmed}
echo "router: rolling back to $(readlink -f "$target")"
exec "$target/bin/switch-to-configuration" test
'';
};
arm = pkgs.writeShellApplication {
name = "router-arm";
runtimeInputs = tools;
text = ''
timeout=''${1:-${cfg.timeout}}
systemctl stop router-rollback.timer router-rollback.service 2>/dev/null || true
systemd-run --quiet --unit=router-rollback --on-active="$timeout" \
--timer-property=AccuracySec=1s ${rollback}/bin/router-rollback
echo "router: rollback in $timeout unless confirmed (router-confirm)"
'';
};
confirm = pkgs.writeShellApplication {
name = "router-confirm";
runtimeInputs = tools;
text = ''
systemctl stop router-rollback.timer
current=$(readlink -f /run/current-system)
${config.nix.package}/bin/nix-env -p ${profile} --set "$current"
"$current/bin/switch-to-configuration" boot
echo "router: confirmed $current"
'';
};
in
{
options.router.rollback = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Install the commit-confirm helpers `router-arm`, `router-confirm` and `router-rollback`.";
};
timeout = lib.mkOption {
type = lib.types.str;
default = "5min";
example = "90s";
description = "How long a deployment may stay unconfirmed before it is rolled back (systemd time span).";
};
};
config = lib.mkIf (config.router.enable && cfg.enable) {
environment.systemPackages = [
arm
confirm
rollback
];
};
}