The firewall page now manages zones (assign each LAN or tunnel, add and remove zones) and rules with zone, address and port on both sides plus the protocol. router.firewall.zones declares zones without members and rules take sourcePorts. The temperature chart draws every thermal zone and hwmon sensor; the overview defaults to 15 minutes, 1h stays an option. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
585 lines
21 KiB
Nix
585 lines
21 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).
|
|
'';
|
|
};
|
|
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;
|
|
};
|
|
};
|
|
}
|
|
);
|
|
};
|
|
};
|
|
}
|
|
)
|
|
);
|
|
};
|
|
|
|
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.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.
|
|
'';
|
|
};
|
|
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. Not for port forwards.";
|
|
};
|
|
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).";
|
|
};
|
|
};
|
|
}
|
|
);
|
|
};
|
|
|
|
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;
|
|
}
|