Files
nilsandClaude Sonnet 5.5 56d6e153ae Add router-upgrade for nixpkgs upgrades on the router
router.update.channel names the nixpkgs branch to follow (unstable or a
release like 26.05). router-upgrade check compares its newest tested
revision with the running one; preview locks a copy of the configuration
flake to it and lists the packages that change version and the size of the
download without fetching anything; download fetches it; apply takes the
lock and activates with commit-confirm, the lock going back on a rollback
or a failed build. router-update keeps an upgraded nixpkgs pinned.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-08 21:06:10 +01:00

894 lines
33 KiB
Nix

{ config, lib, ... }:
let
inherit (lib) mkOption mkEnableOption types;
rlib = import ./lib.nix { inherit lib; };
in
{
options.router = {
enable = mkEnableOption "the router role";
interfaces = mkOption {
default = { };
example = {
wan.mac = "52:54:00:12:34:56";
};
description = ''
Names for physical interfaces, keyed by the name to give: the network
card with the given MAC address is called that from the next boot on
(`wan.interface` and `lans.*.interface` use these names). Interfaces
that are not listed keep the kernel's name. Names cannot change while
the system runs, so a change only takes effect after a reboot.
'';
type = types.attrsOf (
types.submodule {
options.mac = mkOption {
type = types.strMatching "([0-9a-f]{2}:){5}[0-9a-f]{2}";
example = "52:54:00:12:34:56";
description = "Permanent MAC address of the network card, in lower case.";
};
}
);
};
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.";
};
vlanName = mkOption {
type = types.nullOr types.str;
default = null;
example = "isp";
description = "Name of the VLAN device of the PPPoE session; null: `<interface>.<vlan>`.";
};
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`.";
};
deviceName = mkOption {
type = types.nullOr types.str;
default = null;
example = "dmz";
description = ''
Name of the VLAN device (only with `vlan`); null: `<interface>.<vlan>`.
Like `interfaces`, a changed name is safe only after a reboot.
'';
};
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 if config.deviceName != null then
config.deviceName
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).
'';
};
sourceAddresses = mkOption {
type = types.listOf types.str;
default = [ ];
example = [ "192.168.10.1" ];
description = ''
Source address (at most one per family) for traffic the router
itself sends into this tunnel, e.g. DNS queries to the other
site. Needed when the tunnel has no address of its own, so the
other side sees a source it routes back instead of whatever the
kernel picks. IPv4 ones must be an address the router holds on
a LAN or a tunnel.
'';
};
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;
};
};
}
);
};
};
}
)
);
};
wireguardEndpointRefresh = mkOption {
type = types.nullOr (types.strMatching "[0-9]+(s|min|h)");
default = "1min";
example = "5min";
description = ''
How often the names in WireGuard peers' `endpoint` are looked up
again, for peers that have not completed a handshake in the last two
minutes. systemd-networkd resolves a name only when it creates the
tunnel, so without this a tunnel whose peer got a new address (dynamic
DNS) stays down until the peer contacts the router, or forever if both
ends changed. A tunnel that works is not touched. null turns it off.
'';
};
update.channel = mkOption {
type = types.nullOr (types.strMatching "unstable|[0-9]{2}\\.[0-9]{2}");
default = null;
example = "26.05";
description = ''
The nixpkgs branch whose packages and kernel `router-upgrade` offers:
`unstable`, or a release like `26.05` (the branch `nixos-26.05`). It
takes effect when you upgrade, not when you apply the settings. null:
the packages that came with the installed release of nixos-router.
'';
};
firewall.zones = mkOption {
type = types.listOf types.str;
default = [ ];
example = [ "guests" ];
description = ''
Zones to define besides those of the LANs and tunnels (`zone` there
assigns them), so rules can name them before anything is assigned. A
zone without members matches nothing.
'';
};
firewall.nat = mkOption {
default = [ ];
description = ''
Address translation, separate from the allow rules. `dnat` sends
traffic that arrives in zone `from` (`wan` for the uplink) and matches
the rule to `toAddress` (a host in zone `to`); `snat` and `masquerade`
rewrite the source of traffic leaving through zone `to` (`wan` for the
uplink) to `toAddress`, or to the address of the outgoing interface.
LANs with `internet = true` and tunnels with `masquerade` are already
masqueraded; these rules come first. A translation does not allow
anything: a forward needs a matching entry in `firewall.rules`.
'';
type = types.listOf (
types.submodule {
options = {
type = mkOption {
type = types.enum [
"dnat"
"snat"
"masquerade"
];
description = "Kind of translation.";
};
from = mkOption {
type = types.nullOr types.str;
default = null;
description = "Zone the traffic comes from, or `wan`. For `snat` and `masquerade` null means any.";
};
to = mkOption {
type = types.str;
description = ''
`dnat`: the zone of the host the traffic is sent to. `snat` and
`masquerade`: the zone (or `wan`) the traffic leaves through.
'';
};
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 = [ ];
description = "Destination ports or ranges to match (tcp/udp only); empty means all.";
};
sourcePorts = mkOption {
type = types.listOf (types.either types.port (types.strMatching "[0-9]+-[0-9]+"));
default = [ ];
description = "Source ports or ranges to match (tcp/udp only).";
};
sources = mkOption {
type = types.listOf types.str;
default = [ ];
description = "Only match traffic from these IPv4 addresses or CIDRs; empty means any.";
};
destination = mkOption {
type = types.nullOr types.str;
default = null;
example = "203.0.113.7";
description = ''
Only match traffic addressed to this IPv4 address or CIDR.
For `dnat` null means the router's own addresses (from `wan`:
anything that arrives on the uplink).
'';
};
toAddress = mkOption {
type = types.nullOr types.str;
default = null;
example = "192.168.10.5";
description = "`dnat`: the host to send the traffic to. `snat`: the source address to use. Not for `masquerade`.";
};
toPort = mkOption {
type = types.nullOr types.port;
default = null;
description = "`dnat`: the port on the host when it differs (needs exactly one `ports` entry).";
};
hairpin = mkOption {
type = types.bool;
default = false;
description = ''
`dnat` from `wan` only: let LANs with `internet = true` reach
the forward through the router's public address (NAT
loopback, the source is masqueraded) and allow it to the host.
'';
};
};
}
);
};
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, or the reserved
zone `local`, the router itself (SSH, the web UI, ...); `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` (not for `local`) and protocol (and ports for tcp/udp),
so a typo cannot expose a whole zone. A port forward on the same port
takes the traffic first, so it never reaches `local`. Address translation is separate (`firewall.nat`);
a port forward needs an allow rule as well, usually with `natOnly`.
'';
type = types.listOf (
types.submodule {
options = {
from = mkOption {
type = types.str;
description = "Source zone, or `wan`.";
};
to = mkOption {
type = types.str;
description = "Destination zone, or `local` (the router itself).";
};
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. After a port forward these are the translated ports
(its `toPort`, if it has one).
'';
};
sourcePorts = mkOption {
type = types.listOf (types.either types.port (types.strMatching "[0-9]+-[0-9]+"));
default = [ ];
example = [ 53 ];
description = "Source ports or ranges (tcp/udp only); empty means any.";
};
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.";
};
natOnly = mkOption {
type = types.bool;
default = false;
description = ''
Only match connections that a `firewall.nat` port forward
translated (the allow rule that goes with a forward), not
traffic addressed directly to the destination.
'';
};
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).";
};
};
}
);
};
dnsProviders = mkOption {
default = { };
description = ''
Accounts at DNS providers, by name. Dynamic DNS (`ddns.provider`) and
certificates (`acme.provider`) use them, so a token is kept once.
'';
type = types.attrsOf (
types.submodule {
options = {
type = mkOption {
type = types.str;
example = "cloudflare";
description = ''
The provider. For dynamic DNS the ddclient protocol
(<https://ddclient.net/protocols.html>); for certificates the
provider code of lego (<https://go-acme.github.io/lego/dns>).
The names are the same where both know the provider.
The lists are in `pkgs/router-webui/providers.json`.
'';
};
username = mkOption {
type = types.str;
default = "token";
description = "Login for dynamic DNS; `token` for an API token (Cloudflare).";
};
server = mkOption {
type = types.nullOr types.str;
default = null;
example = "dynupdate.no-ip.com";
description = "The provider's update server, for protocols that need one (`dyndns2`).";
};
secretFile = mkOption {
type = types.nullOr types.str;
default = null;
example = "/var/lib/router/dns-secrets/cloudflare";
description = ''
File with the password or API token (dynamic DNS; for
certificates through cloudflare, desec, digitalocean, duckdns
and hetzner the same token). Never a path in the Nix store.
'';
};
environmentFile = mkOption {
type = types.nullOr types.str;
default = null;
example = "/var/lib/router/dns-secrets/route53.env";
description = ''
File with `VARIABLE=value` lines for lego, for certificates
through providers other than those above; the variables are
listed at <https://go-acme.github.io/lego/dns>. Never a path in the Nix store.
'';
};
};
}
);
};
acme = {
enable = mkOption {
type = types.bool;
default = false;
description = ''
A certificate from Let's Encrypt for the web UI, proven by a DNS
record (DNS-01, no port open to the internet), renewed automatically.
'';
};
provider = mkOption {
type = types.nullOr types.str;
default = null;
example = "cloudflare";
description = "The name of a connection in `dnsProviders` that can change the records of the domains.";
};
domains = mkOption {
type = types.listOf types.str;
default = [ ];
example = [ "router.example.org" ];
description = "Names in the certificate; the first is its name. A wildcard like `*.home.example.org` is possible.";
};
email = mkOption {
type = types.nullOr types.str;
default = null;
description = "Where Let's Encrypt writes about expiring certificates.";
};
dnsResolver = mkOption {
type = types.nullOr types.str;
default = "1.1.1.1:53";
example = "9.9.9.9:53";
description = ''
Resolver (`address:port`) that lego asks to find the zone's name
servers and to check that the challenge record has spread. Without
one it would use the router's own Unbound, which answers the local
domain itself and caches. `null`: the router's resolver.
'';
};
staging = mkOption {
type = types.bool;
default = true;
description = "Use the staging service: certificates that browsers do not trust, for trying it out.";
};
acceptTerms = mkOption {
type = types.bool;
default = false;
description = "Accept the Let's Encrypt subscriber agreement.";
};
};
ddns = {
enable = mkOption {
type = types.bool;
default = false;
description = ''
Keep DNS records at a provider pointing at the router's uplink
addresses (ddclient). It checks every `interval` and as soon as an
uplink address changes.
'';
};
provider = mkOption {
type = types.nullOr types.str;
default = null;
example = "cloudflare";
description = "The name of a connection in `dnsProviders` to update the records through.";
};
zone = mkOption {
type = types.nullOr types.str;
default = null;
example = "example.org";
description = "The zone the names are in, for providers that need it (Cloudflare).";
};
domains = mkOption {
type = types.listOf types.str;
default = [ ];
example = [ "home.example.org" ];
description = "Names to keep up to date.";
};
ipv4 = mkOption {
type = types.bool;
default = true;
description = "Update A records with the IPv4 address of the uplink.";
};
ipv6 = mkOption {
type = types.bool;
default = false;
description = "Update AAAA records with the IPv6 address of the uplink.";
};
ssl = mkOption {
type = types.bool;
default = true;
description = "Talk to the provider over HTTPS.";
};
interval = mkOption {
type = types.str;
default = "5min";
description = "How often to check, besides when an address changes (systemd time span).";
};
};
managementPorts = mkOption {
type = types.listOf types.port;
default = [ ];
example = [ 443 ];
description = ''
TCP ports on the router, besides SSH, reachable from LANs and tunnels
with `allowManagement` (the web UI adds its own).
'';
};
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.dynamicLeases = mkOption {
type = types.bool;
default = false;
description = ''
Publish the host names of DHCP clients as `<name>.<dns.domain>` (with
reverse records), updated as leases come and go. Needs `dns.domain`.
A client picks its own name, so only its first label is used, only
under the local domain, and a name that is configured here (`dns.hosts`,
a reservation, the router itself, a wildcard or a forward zone it falls
under) is never taken over. Of two clients claiming one name the newer
lease wins. Run by `router-webui leases-dns` (the settings module).
'';
};
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;
}