Add IPv6 prefix delegation and PPPoE uplink

IPv6: router.wan.ipv6.enable mirrors the existing uplink config (DHCPv6 with
WithoutRA=solicit, delegated prefix). The ISP delegates a single /64, so
exactly one LAN may claim it via router.lans.<name>.ipv6.pd (asserted); other
LANs stay IPv4-only. SLAAC only, no NAT66.

PPPoE: router.wan.ipv4.type = "pppoe" runs pppd on an optional VLAN of the
uplink, with the password read from a file at service start (never the Nix
store). NAT, forwarding and MSS clamping (MTU 1492) use the derived PPP
device; native IPv6 stays on the untagged link.

Tests: basic.nix is parameterised by uplink type; the pppoe variant uses a
fake ISP with pppoe-server (VLAN 7, CHAP). Both variants cover IPv6 PD, SLAAC,
routing without NAT and WAN-deny. Assertion tests gain PD/PPPoE cases and a
full-system evaluation step.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 16:29:52 +01:00
co-authored by Claude Sonnet 5.5
parent 96e5b7b859
commit 6038367252
13 changed files with 671 additions and 72 deletions
+16
View File
@@ -79,6 +79,22 @@ in
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 = cfg.wan.ipv4.type != "pppoe" || cfg.wan.ipv4.pppoe.username != "";
message = "router: router.wan.ipv4.pppoe.username must be set for a PPPoE uplink.";
}
{
assertion = cfg.wan.ipv4.type != "pppoe" || cfg.wan.ipv4.pppoe.passwordFile != null;
message = "router: router.wan.ipv4.pppoe.passwordFile must be set for a PPPoE uplink.";
}
{
assertion = lib.length (lib.filter (l: l.ipv6.pd) lans) <= 1;
message = "router: more than one LAN sets ipv6.pd, but the delegated /64 cannot be split.";
}
{
assertion = !(lib.any (l: l.ipv6.pd) lans) || cfg.wan.ipv6.enable;
message = "router: a LAN sets ipv6.pd but router.wan.ipv6.enable is false.";
}
{
assertion =
let
+1
View File
@@ -3,6 +3,7 @@
./options.nix
./assertions.nix
./networking.nix
./pppoe.nix
./firewall.nix
./dhcp.nix
./dns.nix
+8 -2
View File
@@ -25,8 +25,14 @@ in
interface = l.interface;
pools = [ { pool = "${l.dhcp.pool.start} - ${l.dhcp.pool.end}"; } ];
option-data = [
{ name = "routers"; data = l.ipv4.address; }
{ name = "domain-name-servers"; data = l.ipv4.address; }
{
name = "routers";
data = l.ipv4.address;
}
{
name = "domain-name-servers";
data = l.ipv4.address;
}
];
}) dhcpLans;
};
+4 -2
View File
@@ -14,9 +14,11 @@ in
ip-freebind = true;
access-control = [
"127.0.0.0/8 allow"
] ++ map (l: "${l.ipv4.subnet} allow") lans;
]
++ map (l: "${l.ipv4.subnet} allow") lans;
};
} // lib.optionalAttrs (cfg.dns.forwarders != [ ]) {
}
// lib.optionalAttrs (cfg.dns.forwarders != [ ]) {
forward-zone = [
{
name = ".";
+19 -5
View File
@@ -6,6 +6,14 @@ let
set = ifaces: "{ ${lib.concatMapStringsSep ", " (i: ''"${i}"'') ifaces} }";
lanIfaces = map (l: l.interface) lans;
mgmtIfaces = map (l: l.interface) (lib.filter (l: l.allowManagement) lans);
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
];
mss = cfg.wan.ipv4.pppoe.mtu - 40;
dhcpIfaces = map (l: l.interface) (lib.filter (l: l.dhcp.enable) lans);
in
{
@@ -34,12 +42,13 @@ in
iifname ${set lanIfaces} icmp type echo-request accept
iifname ${set lanIfaces} icmpv6 type echo-request accept
iifname "${cfg.wan.interface}" udp dport 68 accept
${lib.optionalString (!pppoe) ''iifname "${cfg.wan.interface}" udp dport 68 accept''}
${lib.optionalString cfg.wan.ipv6.enable ''iifname "${cfg.wan.interface}" udp dport 546 accept''}
iifname ${set lanIfaces} udp dport 53 accept
iifname ${set lanIfaces} tcp dport 53 accept
${lib.optionalString (dhcpIfaces != [ ]) ''iifname ${set dhcpIfaces} udp dport 67 accept''}
${lib.optionalString (mgmtIfaces != [ ]) ''iifname ${set mgmtIfaces} tcp dport 22 accept''}
${lib.optionalString (dhcpIfaces != [ ]) "iifname ${set dhcpIfaces} udp dport 67 accept"}
${lib.optionalString (mgmtIfaces != [ ]) "iifname ${set mgmtIfaces} tcp dport 22 accept"}
}
chain forward {
@@ -50,7 +59,12 @@ in
icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem } accept
iifname ${set lanIfaces} oifname "${cfg.wan.interface}" accept
${lib.optionalString pppoe ''
# Clamp MSS to the PPP MTU in both directions (never raised).
iifname "${uplink4}" tcp flags syn tcp option maxseg size > ${toString mss} tcp option maxseg size set ${toString mss}
oifname "${uplink4}" tcp flags syn tcp option maxseg size > ${toString mss} tcp option maxseg size set ${toString mss}
''}
iifname ${set lanIfaces} oifname ${set uplinks} accept
}
'';
};
@@ -60,7 +74,7 @@ in
content = ''
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
oifname "${cfg.wan.interface}" masquerade
oifname "${uplink4}" masquerade
}
'';
};
+3 -1
View File
@@ -33,7 +33,9 @@ rec {
networkOf = address: prefixLength: builtins.bitAnd address (netMask prefixLength);
# "192.168.10.1" /24 -> "192.168.10.0/24"
cidr = address: prefixLength: "${format (networkOf (parse address) prefixLength)}/${toString prefixLength}";
cidr =
address: prefixLength:
"${format (networkOf (parse address) prefixLength)}/${toString prefixLength}";
inSubnet =
{ address, prefixLength }: ip: networkOf ip prefixLength == networkOf address prefixLength;
+74 -8
View File
@@ -1,6 +1,11 @@
{ config, lib, ... }:
let
cfg = config.router;
v6 = cfg.wan.ipv6.enable;
pppoe = cfg.wan.ipv4.type == "pppoe";
vlan = cfg.wan.ipv4.pppoe.vlan;
vlanDevice = "${cfg.wan.interface}.${toString vlan}";
dhcp = if pppoe then (if v6 then "ipv6" else "no") else (if v6 then "yes" else "ipv4");
in
{
config = lib.mkIf cfg.enable {
@@ -9,22 +14,83 @@ in
boot.kernel.sysctl = {
"net.ipv4.conf.all.forwarding" = 1;
"net.ipv6.conf.all.forwarding" = 1;
};
systemd.network.netdevs = lib.optionalAttrs (pppoe && vlan != null) {
"05-wan-pppoe" = {
netdevConfig = {
Kind = "vlan";
Name = vlanDevice;
};
vlanConfig.Id = vlan;
};
};
systemd.network.networks = {
"10-wan" = {
matchConfig.Name = cfg.wan.interface;
networkConfig.DHCP = "ipv4";
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.mapAttrs' (
name: lan:
lib.nameValuePair "20-lan-${name}" {
matchConfig.Name = lan.interface;
address = [ "${lan.ipv4.address}/${toString lan.ipv4.prefixLength}" ];
networkConfig.ConfigureWithoutCarrier = true;
}
// 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.interface;
address = [ "${lan.ipv4.address}/${toString lan.ipv4.prefixLength}" ];
networkConfig = {
ConfigureWithoutCarrier = true;
# Never accept RAs from the inside.
IPv6AcceptRA = false;
}
// 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;
};
}
+74 -2
View File
@@ -1,4 +1,4 @@
{ lib, ... }:
{ config, lib, ... }:
let
inherit (lib) mkOption mkEnableOption types;
rlib = import ./lib.nix { inherit lib; };
@@ -11,8 +11,66 @@ in
interface = mkOption {
type = types.str;
example = "eth0";
description = "Name of the uplink interface.";
description = ''
Physical uplink interface. Native IPv6 (DHCPv6) runs on it untagged;
with PPPoE it also carries the (optionally tagged) PPPoE session.
'';
};
ipv4 = {
type = mkOption {
type = types.enum [
"dhcp"
"pppoe"
];
default = "dhcp";
description = "How the router gets its IPv4 uplink.";
};
pppoe = {
vlan = mkOption {
type = types.nullOr (types.ints.between 1 4094);
default = null;
description = "VLAN ID the ISP requires for PPPoE, or null for untagged.";
};
username = mkOption {
type = types.str;
default = "";
description = "PPP authentication username.";
};
passwordFile = mkOption {
type = types.nullOr (
types.pathWith {
absolute = true;
inStore = false;
}
);
default = null;
example = "/var/lib/secrets/pppoe-password";
description = ''
File containing the PPP password, read at service start. Must not
be in the Nix store (it would be world-readable).
'';
};
mtu = mkOption {
type = types.ints.between 576 1500;
default = 1492;
description = "PPP MTU/MRU; TCP MSS is clamped to match on forwarded traffic.";
};
};
interface = mkOption {
type = types.str;
readOnly = true;
internal = true;
description = "Device IPv4 traffic leaves through (derived).";
};
};
ipv6.enable = mkEnableOption ''
IPv6 on the uplink: DHCPv6 (soliciting even without RA flags) plus
prefix delegation. The ISP is expected to delegate a single /64
'';
};
lans = mkOption {
@@ -63,6 +121,17 @@ in
};
};
ipv6.pd = mkOption {
type = types.bool;
default = false;
description = ''
Whether this LAN receives the /64 delegated on the uplink
(assigned to the interface and announced via SLAAC). A /64
cannot be split, so at most one LAN may set this; the others
stay IPv4-only.
'';
};
allowManagement = mkOption {
type = types.bool;
default = true;
@@ -87,4 +156,7 @@ in
description = "Upstream resolvers. When empty, Unbound resolves recursively.";
};
};
config.router.wan.ipv4.interface =
if config.router.wan.ipv4.type == "pppoe" then "pppoe-wan" else config.router.wan.interface;
}
+86
View File
@@ -0,0 +1,86 @@
# PPPoE uplink for IPv4. pppd owns the session (address, default route); the
# VLAN device it runs on is created in networking.nix.
{
config,
lib,
pkgs,
...
}:
let
cfg = config.router;
wan = cfg.wan;
pppoe = wan.ipv4.pppoe;
# Device carrying the PPPoE session: the VLAN on the uplink, or the uplink itself.
device = if pppoe.vlan == null then wan.interface else "${wan.interface}.${toString pppoe.vlan}";
# Unit that appears once udev has seen the device (VLAN devices are created
# by networkd after boot has begun, so pppd must not start before it).
deviceUnit = "sys-subsystem-net-devices-${lib.replaceStrings [ "-" ] [ "\\x2d" ] device}.device";
staticOptions = pkgs.writeText "router-pppoe-options" ''
plugin pppoe.so
nic-${device}
ifname ${wan.ipv4.interface}
user "${pppoe.username}"
noauth
hide-password
defaultroute
replacedefaultroute
noipdefault
nodefaultroute6
noccp
mtu ${toString pppoe.mtu}
mru ${toString pppoe.mtu}
lcp-echo-interval 10
lcp-echo-failure 3
nodetach
persist
maxfail 0
holdoff 5
'';
prepare = pkgs.writeShellScript "router-pppoe-prepare" ''
set -eu
umask 077
password=$(cat ${lib.escapeShellArg (toString pppoe.passwordFile)})
# pppd option files use C-style escapes inside double quotes.
password=''${password//\\/\\\\}
password=''${password//\"/\\\"}
{
cat ${staticOptions}
printf 'password "%s"\n' "$password"
} > /run/router-pppoe/options
'';
in
{
config = lib.mkIf (cfg.enable && wan.ipv4.type == "pppoe") {
boot.kernelModules = [
"pppoe"
"pppox"
"ppp_generic"
];
# pppd refuses to start without a (possibly empty) system options file.
environment.etc."ppp/options".text = "";
systemd.services.router-pppoe = {
description = "PPPoE uplink (${device})";
wantedBy = [ "multi-user.target" ];
wants = [ deviceUnit ];
after = [
"network.target"
"systemd-networkd.service"
deviceUnit
];
serviceConfig = {
RuntimeDirectory = "router-pppoe";
RuntimeDirectoryMode = "0700";
ExecStartPre = prepare;
ExecStart = "${pkgs.ppp}/bin/pppd file /run/router-pppoe/options";
Restart = "always";
RestartSec = 5;
};
};
};
}