lans.<n>.ipv6.address (a ULA /64) is assigned to the LAN, announced with an explicit prefix (networkd only announces the delegated prefix when prefix delegation is on) and advertised as RDNSS; Unbound listens and answers on it. Checked at build time: valid IPv6 CIDR, /64, fc00::/7. Covered by the onestick VM test (client SLAAC address, ping and DNS over the ULA, RA captured on the wire), assertion cases and a unit test for the /64 prefix helper. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
530 lines
19 KiB
Nix
530 lines
19 KiB
Nix
{ config, lib, ... }:
|
|
let
|
|
inherit (lib) mkOption mkEnableOption types;
|
|
rlib = import ./lib.nix { inherit lib; };
|
|
in
|
|
{
|
|
options.router = {
|
|
enable = mkEnableOption "the router role";
|
|
|
|
wan = {
|
|
interface = mkOption {
|
|
type = types.str;
|
|
example = "eth0";
|
|
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.
|
|
'';
|
|
};
|
|
|
|
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
|
|
'';
|
|
|
|
transport = mkOption {
|
|
type = types.enum [
|
|
"native"
|
|
"ppp"
|
|
];
|
|
default = "native";
|
|
description = ''
|
|
Where IPv6 runs. `native`: untagged on the uplink link, next to a
|
|
DHCPv4 or PPPoE (tagged) IPv4 uplink. `ppp`: inside the PPPoE
|
|
session (IPv6CP), so DHCPv6 and router advertisements arrive on
|
|
the PPP device; needs a PPPoE uplink, and leaves the physical link
|
|
free for a LAN.
|
|
'';
|
|
};
|
|
};
|
|
};
|
|
|
|
lans = mkOption {
|
|
default = { };
|
|
description = "LAN segments served by this router, keyed by name.";
|
|
type = types.attrsOf (
|
|
types.submodule (
|
|
{ name, config, ... }:
|
|
{
|
|
options = {
|
|
interface = mkOption {
|
|
type = types.str;
|
|
example = "eth1";
|
|
description = ''
|
|
Interface carrying this LAN. With `vlan` set this is the
|
|
parent (trunk) interface on which the VLAN device is created.
|
|
'';
|
|
};
|
|
|
|
vlan = mkOption {
|
|
type = types.nullOr (types.ints.between 1 4094);
|
|
default = null;
|
|
description = "802.1Q VLAN ID, or null for an untagged LAN on `interface`.";
|
|
};
|
|
|
|
device = mkOption {
|
|
type = types.str;
|
|
readOnly = true;
|
|
internal = true;
|
|
description = "Device the LAN's addresses live on (derived).";
|
|
};
|
|
|
|
zone = mkOption {
|
|
type = types.str;
|
|
default = name;
|
|
description = ''
|
|
Firewall zone this LAN belongs to. LANs sharing a zone may
|
|
talk to each other freely; traffic between zones is denied
|
|
unless a rule in `router.firewall.rules` allows it. Defaults
|
|
to a zone named after the LAN.
|
|
'';
|
|
};
|
|
|
|
internet = mkOption {
|
|
type = types.bool;
|
|
default = false;
|
|
description = "Whether this LAN may reach the internet (opt-in).";
|
|
};
|
|
|
|
ipv4 = {
|
|
address = mkOption {
|
|
type = types.str;
|
|
example = "192.168.10.1";
|
|
description = "Router address on this LAN.";
|
|
};
|
|
prefixLength = mkOption {
|
|
type = types.ints.between 8 30;
|
|
default = 24;
|
|
};
|
|
subnet = mkOption {
|
|
type = types.str;
|
|
readOnly = true;
|
|
description = "Network in CIDR notation, derived from `address` and `prefixLength`.";
|
|
};
|
|
};
|
|
|
|
dhcp = {
|
|
enable = mkOption {
|
|
type = types.bool;
|
|
default = true;
|
|
};
|
|
pool = {
|
|
start = mkOption {
|
|
type = types.str;
|
|
example = "192.168.10.100";
|
|
};
|
|
end = mkOption {
|
|
type = types.str;
|
|
example = "192.168.10.199";
|
|
};
|
|
};
|
|
reservations = mkOption {
|
|
type = types.attrsOf (
|
|
types.submodule {
|
|
options = {
|
|
mac = mkOption {
|
|
type = types.str;
|
|
example = "a8:a1:59:0b:4b:97";
|
|
description = "Hardware address of the client.";
|
|
};
|
|
address = mkOption {
|
|
type = types.str;
|
|
example = "192.168.10.20";
|
|
description = "Address handed to the client; must lie inside the LAN.";
|
|
};
|
|
};
|
|
}
|
|
);
|
|
default = { };
|
|
description = ''
|
|
Fixed leases, keyed by hostname. The name is also sent to the
|
|
client and published in DNS (under `router.dns.domain` if set).
|
|
'';
|
|
};
|
|
};
|
|
|
|
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.
|
|
'';
|
|
};
|
|
|
|
ipv6.address = mkOption {
|
|
type = types.nullOr types.str;
|
|
default = null;
|
|
example = "fd2c:cdef:cde9::1/64";
|
|
description = ''
|
|
Static unique-local (ULA) address of the router on this LAN,
|
|
as a /64. It is announced via SLAAC, so hosts get a stable
|
|
internal IPv6 address even without a delegated prefix, and
|
|
the router's resolver answers on it (also advertised as
|
|
RDNSS). Independent of `ipv6.pd`.
|
|
'';
|
|
};
|
|
|
|
allowManagement = mkOption {
|
|
type = types.bool;
|
|
default = true;
|
|
description = "Whether clients on this LAN may reach SSH on the router.";
|
|
};
|
|
};
|
|
|
|
config.device =
|
|
if config.vlan == null then config.interface else "${config.interface}.${toString config.vlan}";
|
|
|
|
# "invalid" is only ever seen when assertions.nix is already failing.
|
|
config.ipv4.subnet =
|
|
if rlib.ipv4.parse config.ipv4.address == null then
|
|
"invalid"
|
|
else
|
|
rlib.ipv4.cidr config.ipv4.address config.ipv4.prefixLength;
|
|
}
|
|
)
|
|
);
|
|
};
|
|
|
|
wireguard = mkOption {
|
|
default = { };
|
|
description = ''
|
|
WireGuard tunnels, keyed by name. A tunnel is a firewall member like a
|
|
LAN (it has a `zone`, `internet`, `allowManagement`), so
|
|
`router.firewall.rules` also cover traffic to and from it. Peers'
|
|
`allowedIPs` are the destinations routed into the tunnel; routing is
|
|
by destination only (no default-route tunnels).
|
|
'';
|
|
type = types.attrsOf (
|
|
types.submodule (
|
|
{ name, ... }:
|
|
{
|
|
options = {
|
|
interface = mkOption {
|
|
type = types.str;
|
|
default = "wg-${name}";
|
|
description = "Name of the WireGuard device.";
|
|
};
|
|
privateKeyFile = mkOption {
|
|
type = types.pathWith {
|
|
absolute = true;
|
|
inStore = false;
|
|
};
|
|
example = "/var/lib/secrets/wg-rw.key";
|
|
description = ''
|
|
File with the private key, outside the Nix store. It is
|
|
handed to systemd-networkd as a systemd credential, so it
|
|
can be root-only (mode 0600) and is read when networkd starts.
|
|
'';
|
|
};
|
|
listenPort = mkOption {
|
|
type = types.nullOr types.port;
|
|
default = null;
|
|
description = "UDP port to listen on (opened on the uplinks); null for a dial-out-only tunnel.";
|
|
};
|
|
mtu = mkOption {
|
|
type = types.nullOr (types.ints.between 576 9000);
|
|
default = null;
|
|
description = "Tunnel MTU; null keeps the kernel default (1420).";
|
|
};
|
|
addresses = mkOption {
|
|
type = types.listOf types.str;
|
|
default = [ ];
|
|
example = [
|
|
"10.100.0.1/24"
|
|
"fd42:100::1/64"
|
|
];
|
|
description = ''
|
|
Addresses (CIDR, IPv4 and/or IPv6) on the tunnel interface;
|
|
empty means no transfer network (fine for site-to-site).
|
|
'';
|
|
};
|
|
zone = mkOption {
|
|
type = types.str;
|
|
default = name;
|
|
description = "Firewall zone; shared with LANs or other tunnels if you want a common policy.";
|
|
};
|
|
internet = mkOption {
|
|
type = types.bool;
|
|
default = false;
|
|
description = "Whether traffic from this tunnel may reach the internet via the uplink.";
|
|
};
|
|
allowManagement = mkOption {
|
|
type = types.bool;
|
|
default = false;
|
|
description = "Whether SSH on the router is reachable through this tunnel.";
|
|
};
|
|
dns = mkOption {
|
|
type = types.bool;
|
|
default = false;
|
|
description = "Whether the router's resolver answers on the tunnel's IPv4 address.";
|
|
};
|
|
masquerade = mkOption {
|
|
type = types.bool;
|
|
default = false;
|
|
description = "Source-NAT IPv4 traffic leaving through this tunnel to the tunnel address.";
|
|
};
|
|
killSwitch = mkOption {
|
|
type = types.bool;
|
|
default = true;
|
|
description = ''
|
|
Drop forwarded traffic for this tunnel's peers' `allowedIPs`
|
|
when it would leave through an uplink (for example because
|
|
the tunnel device is down), instead of letting it leak.
|
|
'';
|
|
};
|
|
peers = mkOption {
|
|
default = { };
|
|
type = types.attrsOf (
|
|
types.submodule {
|
|
options = {
|
|
publicKey = mkOption { type = types.str; };
|
|
presharedKeyFile = mkOption {
|
|
type = types.nullOr (
|
|
types.pathWith {
|
|
absolute = true;
|
|
inStore = false;
|
|
}
|
|
);
|
|
default = null;
|
|
};
|
|
endpoint = mkOption {
|
|
type = types.nullOr types.str;
|
|
default = null;
|
|
example = "vpn.example.org:51820";
|
|
};
|
|
allowedIPs = mkOption {
|
|
type = types.listOf types.str;
|
|
default = [ ];
|
|
example = [ "192.168.50.0/24" ];
|
|
};
|
|
persistentKeepalive = mkOption {
|
|
type = types.nullOr (types.ints.between 1 65535);
|
|
default = null;
|
|
};
|
|
};
|
|
}
|
|
);
|
|
};
|
|
};
|
|
}
|
|
)
|
|
);
|
|
};
|
|
|
|
firewall.rules = mkOption {
|
|
default = [ ];
|
|
description = ''
|
|
Allow rules between zones, on top of the default (deny between zones).
|
|
Rules are one-directional; return traffic is allowed by connection
|
|
tracking. `to` must be the zone of a LAN or tunnel; `from` may also be
|
|
the reserved zone `wan` (traffic arriving on the uplinks), which
|
|
publishes services to the outside: such rules must name a
|
|
`destination` and protocol (and ports for tcp/udp), so a typo cannot
|
|
expose a whole zone.
|
|
'';
|
|
type = types.listOf (
|
|
types.submodule {
|
|
options = {
|
|
from = mkOption {
|
|
type = types.str;
|
|
description = "Source zone, or `wan`.";
|
|
};
|
|
to = mkOption {
|
|
type = types.str;
|
|
description = "Destination zone.";
|
|
};
|
|
protocol = mkOption {
|
|
type = types.enum [
|
|
"any"
|
|
"tcp"
|
|
"udp"
|
|
"icmp"
|
|
];
|
|
default = "any";
|
|
};
|
|
ports = mkOption {
|
|
type = types.listOf (types.either types.port (types.strMatching "[0-9]+-[0-9]+"));
|
|
default = [ ];
|
|
example = [
|
|
443
|
|
"50000-50100"
|
|
];
|
|
description = ''
|
|
Destination ports or ranges (tcp/udp only); empty means all
|
|
ports. With `dnat` these are the ports on the router's
|
|
public address.
|
|
'';
|
|
};
|
|
destination = mkOption {
|
|
type = types.nullOr types.str;
|
|
default = null;
|
|
example = "192.168.10.5";
|
|
description = ''
|
|
Restrict the rule to an IPv4 address or CIDR inside the
|
|
destination zone; null means the whole zone. IPv6 addresses
|
|
are accepted too (for pinholes) but cannot be checked against
|
|
the zone, because delegated prefixes are not known at build time.
|
|
'';
|
|
};
|
|
sources = mkOption {
|
|
type = types.listOf types.str;
|
|
default = [ ];
|
|
example = [ "198.51.100.0/24" ];
|
|
description = "Only match traffic from these IPv4/IPv6 addresses or CIDRs; empty means any source.";
|
|
};
|
|
dnat = mkOption {
|
|
type = types.bool;
|
|
default = false;
|
|
description = ''
|
|
Port forward: traffic from `wan` to `ports` on the router's
|
|
public IPv4 address is redirected to `destination` (one IPv4
|
|
host). The source address is preserved.
|
|
'';
|
|
};
|
|
toPort = mkOption {
|
|
type = types.nullOr types.port;
|
|
default = null;
|
|
description = "Port on `destination` when it differs from the public port (single-port forwards only).";
|
|
};
|
|
hairpin = mkOption {
|
|
type = types.bool;
|
|
default = false;
|
|
description = ''
|
|
Let LANs with `internet = true` reach this forward through the
|
|
router's public address (NAT loopback, source is masqueraded).
|
|
'';
|
|
};
|
|
rateLimit = mkOption {
|
|
type = types.nullOr (types.strMatching "[0-9]+/(second|minute|hour)");
|
|
default = null;
|
|
example = "10/minute";
|
|
description = "Limit new connections per source address; the excess is dropped.";
|
|
};
|
|
log = mkOption {
|
|
type = types.bool;
|
|
default = false;
|
|
description = "Log new connections accepted by this rule, and connections dropped by `rateLimit` (kernel log).";
|
|
};
|
|
};
|
|
}
|
|
);
|
|
};
|
|
|
|
dns.domain = mkOption {
|
|
type = types.nullOr types.str;
|
|
default = null;
|
|
example = "home.example.org";
|
|
description = ''
|
|
Local domain. It is authoritative on the router: names below it that
|
|
have no record answer NXDOMAIN and are never forwarded. Short names in
|
|
`dns.hosts` and DHCP reservation names live in it.
|
|
'';
|
|
};
|
|
|
|
dns.hosts = mkOption {
|
|
type = types.attrsOf (types.listOf types.str);
|
|
default = { };
|
|
example = {
|
|
nas = [ "192.168.10.20" ];
|
|
"git.example.org" = [
|
|
"10.43.0.50"
|
|
"fd00::50"
|
|
];
|
|
};
|
|
description = ''
|
|
Local records: name to IPv4/IPv6 addresses. A name without a dot is
|
|
placed in `dns.domain`; a full name overrides public DNS for that
|
|
name only (split horizon). Reverse records are created as well.
|
|
'';
|
|
};
|
|
|
|
dns.overrides = mkOption {
|
|
type = types.attrsOf (types.listOf types.str);
|
|
default = { };
|
|
example = {
|
|
"k8s.example.org" = [ "10.43.0.50" ];
|
|
};
|
|
description = "Like `dns.hosts`, but the name and everything below it answer with these addresses (wildcard).";
|
|
};
|
|
|
|
dns.forwardZones = mkOption {
|
|
type = types.attrsOf (types.listOf types.str);
|
|
default = { };
|
|
example = {
|
|
"branch.example.org" = [ "10.1.0.254" ];
|
|
};
|
|
description = ''
|
|
Zones answered by other resolvers (e.g. across a tunnel). DNSSEC
|
|
validation is skipped for them: they are private zones.
|
|
'';
|
|
};
|
|
|
|
dns.forwarders = mkOption {
|
|
type = types.listOf types.str;
|
|
default = [ ];
|
|
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;
|
|
}
|