Files
nixos-router/modules/router/options.nix
T
nilsandClaude Sonnet 5.5 0653b5cf2f 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>
2026-10-27 23:09:09 +01:00

428 lines
15 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
'';
};
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";
};
};
};
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;
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.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;
}