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:
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
./options.nix
|
||||
./assertions.nix
|
||||
./networking.nix
|
||||
./pppoe.nix
|
||||
./firewall.nix
|
||||
./dhcp.nix
|
||||
./dns.nix
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 = ".";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user