Web UI: dark/light themes (appliance layout, sidebar, switch checkboxes), theme preference on the Advanced page, Changes page with the pending diff. Interface names: router.interfaces.<name>.mac, lans.<l>.deviceName and pppoe.vlanName; a rename is staged for the next reboot by router-apply. Remote access: an MCP server (POST /mcp) and an OpenAPI REST API (/api/v1, /api/openapi.json) with the same tokens, scopes, limits and audit log; tools to read status, settings, leases, logs (get_logs), and to edit and check the saved settings (never applied). Tokens on the API access page. Firewall: a reserved `local` zone (the router itself) as the destination of rules, so the web UI or SSH can be allowed from the WAN. PLAN.md lists what is still left. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
657 lines
25 KiB
Nix
657 lines
25 KiB
Nix
{ config, lib, ... }:
|
|
let
|
|
cfg = config.router;
|
|
ipv4 = (import ./lib.nix { inherit lib; }).ipv4;
|
|
|
|
lansByName = cfg.lans;
|
|
lans = lib.attrValues lansByName;
|
|
|
|
# A name chosen for a network card or a VLAN device: short, plain, and not
|
|
# one the kernel or udev hands out itself (a rename could then collide).
|
|
# Keep in step with reservedName in the web UI (pkgs/router-webui/settings.go).
|
|
chosenNameOk =
|
|
s:
|
|
builtins.match "[a-z][a-z0-9-]{0,14}" s != null
|
|
&& builtins.match "(eth|wlan|ppp|wg|veth|bond|tun|tap|dummy|ifb|docker|virbr|br|lo)[0-9]*|(en|wl|ww|sl)[opsx][0-9a-f].*" s == null;
|
|
chosenNames =
|
|
lib.attrNames cfg.interfaces
|
|
++ lib.concatMap (l: lib.optional (l.deviceName != null) l.deviceName) lans
|
|
++ lib.optional (cfg.wan.ipv4.pppoe.vlanName != null) cfg.wan.ipv4.pppoe.vlanName;
|
|
macs = lib.mapAttrsToList (_: i: i.mac) cfg.interfaces;
|
|
|
|
validIp = s: ipv4.parse s != null;
|
|
hostnameOk = s: builtins.match "[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?" s != null;
|
|
# A DNS name: labels of letters, digits, dashes and underscores.
|
|
dnsNameOk =
|
|
s:
|
|
builtins.match "[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?(\\.[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?)*" s
|
|
!= null;
|
|
|
|
# Assertions about a single LAN; skipped (reported once) when its addresses
|
|
# do not even parse, so we never feed null into the integer helpers.
|
|
perLan =
|
|
name: lan:
|
|
let
|
|
where = "router.lans.${name}";
|
|
addrOk = validIp lan.ipv4.address;
|
|
startOk = validIp lan.dhcp.pool.start;
|
|
endOk = validIp lan.dhcp.pool.end;
|
|
addr = ipv4.parse lan.ipv4.address;
|
|
start = ipv4.parse lan.dhcp.pool.start;
|
|
end = ipv4.parse lan.dhcp.pool.end;
|
|
net = {
|
|
address = addr;
|
|
inherit (lan.ipv4) prefixLength;
|
|
};
|
|
parsed = addrOk && startOk && endOk;
|
|
in
|
|
[
|
|
{
|
|
assertion = addrOk;
|
|
message = "router: ${where}.ipv4.address `${lan.ipv4.address}` is not a valid IPv4 address.";
|
|
}
|
|
{
|
|
assertion = lan.deviceName == null || lan.vlan != null;
|
|
message = "router: ${where}.deviceName needs a vlan.";
|
|
}
|
|
{
|
|
# IFNAMSIZ is 16 including the terminating NUL.
|
|
assertion = lib.stringLength lan.device <= 15;
|
|
message = "router: ${where}: device name `${lan.device}` is longer than 15 characters.";
|
|
}
|
|
]
|
|
++ lib.optionals (lan.ipv6.address != null) (
|
|
let
|
|
m = builtins.match "([0-9a-fA-F:]+)/([0-9]{1,3})" lan.ipv6.address;
|
|
in
|
|
[
|
|
{
|
|
assertion = isV6 lan.ipv6.address && v6Cidr lan.ipv6.address && m != null;
|
|
message = "router: ${where}.ipv6.address `${lan.ipv6.address}` is not an IPv6 address in CIDR notation.";
|
|
}
|
|
]
|
|
++ lib.optionals (m != null) [
|
|
{
|
|
assertion = builtins.elemAt m 1 == "64";
|
|
message = "router: ${where}.ipv6.address must be a /64 (SLAAC needs one).";
|
|
}
|
|
{
|
|
assertion = builtins.match "[fF][cCdD][0-9a-fA-F]{2}:.*" (builtins.elemAt m 0) != null;
|
|
message = "router: ${where}.ipv6.address must be a unique-local address (fc00::/7).";
|
|
}
|
|
]
|
|
)
|
|
++ lib.optionals lan.dhcp.enable (
|
|
[
|
|
{
|
|
assertion = startOk && endOk;
|
|
message = "router: ${where}.dhcp.pool.start/end must be valid IPv4 addresses.";
|
|
}
|
|
]
|
|
++ lib.optionals parsed [
|
|
{
|
|
assertion = start <= end;
|
|
message = "router: ${where}.dhcp.pool starts after it ends.";
|
|
}
|
|
{
|
|
assertion = ipv4.inSubnet net start && ipv4.inSubnet net end;
|
|
message = "router: ${where}.dhcp.pool is not inside ${lan.ipv4.subnet}.";
|
|
}
|
|
{
|
|
assertion = !(start <= addr && addr <= end);
|
|
message = "router: ${where}.dhcp.pool contains the router's own address ${lan.ipv4.address}.";
|
|
}
|
|
]
|
|
)
|
|
++ lib.concatLists (
|
|
lib.mapAttrsToList (
|
|
host: r:
|
|
let
|
|
rwhere = "${where}.dhcp.reservations.${host}";
|
|
rok = validIp r.address;
|
|
in
|
|
[
|
|
{
|
|
assertion = hostnameOk host;
|
|
message = "router: ${rwhere}: `${host}` is not a valid hostname (letters, digits and dashes).";
|
|
}
|
|
{
|
|
assertion = builtins.match "([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}" r.mac != null;
|
|
message = "router: ${rwhere}.mac `${r.mac}` is not a MAC address like aa:bb:cc:dd:ee:ff.";
|
|
}
|
|
{
|
|
assertion = rok;
|
|
message = "router: ${rwhere}.address `${r.address}` is not a valid IPv4 address.";
|
|
}
|
|
{
|
|
assertion = lan.dhcp.enable;
|
|
message = "router: ${rwhere}: reservations need dhcp.enable on the LAN.";
|
|
}
|
|
]
|
|
++ lib.optionals (rok && addrOk) [
|
|
{
|
|
assertion = ipv4.inSubnet net (ipv4.parse r.address);
|
|
message = "router: ${rwhere}.address ${r.address} is not inside ${lan.ipv4.subnet}.";
|
|
}
|
|
{
|
|
assertion = r.address != lan.ipv4.address;
|
|
message = "router: ${rwhere}.address is the router's own address.";
|
|
}
|
|
]
|
|
) lan.dhcp.reservations
|
|
)
|
|
++ (
|
|
let
|
|
rs = lib.attrValues lan.dhcp.reservations;
|
|
macs = map (r: lib.toLower r.mac) rs;
|
|
addrs = map (r: r.address) rs;
|
|
in
|
|
[
|
|
{
|
|
assertion = lib.length macs == lib.length (lib.unique macs);
|
|
message = "router: ${where}.dhcp.reservations: two reservations share a MAC address.";
|
|
}
|
|
{
|
|
assertion = lib.length addrs == lib.length (lib.unique addrs);
|
|
message = "router: ${where}.dhcp.reservations: two reservations share an address.";
|
|
}
|
|
]
|
|
);
|
|
|
|
wgs = lib.attrValues cfg.wireguard;
|
|
zoneNames = lib.unique (map (l: l.zone) lans ++ map (w: w.zone) wgs ++ cfg.firewall.zones);
|
|
|
|
isV6 = lib.hasInfix ":";
|
|
v4Cidr = s: lib.hasInfix "/" s && ipv4.parseNet s != null;
|
|
v6Cidr =
|
|
s:
|
|
let
|
|
m = builtins.match "([0-9a-fA-F:.]+)/([0-9]{1,3})" s;
|
|
in
|
|
isV6 s && m != null && lib.toInt (builtins.elemAt m 1) <= 128;
|
|
validCidr = s: v4Cidr s || v6Cidr s;
|
|
|
|
lanNets = map (l: {
|
|
address = ipv4.parse l.ipv4.address;
|
|
inherit (l.ipv4) prefixLength;
|
|
}) (lib.filter (l: validIp l.ipv4.address) lans);
|
|
|
|
# IPv4 networks a tunnel brings into its zone (own addresses and what peers may send).
|
|
wgV4Nets =
|
|
w:
|
|
lib.filter (n: n != null) (
|
|
map ipv4.parseNet (
|
|
lib.filter (n: !isV6 n && v4Cidr n) (
|
|
w.addresses ++ lib.concatMap (p: p.allowedIPs) (lib.attrValues w.peers)
|
|
)
|
|
)
|
|
);
|
|
zoneV4Nets =
|
|
zone:
|
|
map (l: {
|
|
address = ipv4.parse l.ipv4.address;
|
|
inherit (l.ipv4) prefixLength;
|
|
}) (lib.filter (l: l.zone == zone && validIp l.ipv4.address) lans)
|
|
++ lib.concatMap wgV4Nets (lib.filter (w: w.zone == zone) wgs);
|
|
|
|
portOk =
|
|
p:
|
|
if builtins.isInt p then
|
|
p >= 1 && p <= 65535
|
|
else
|
|
let
|
|
m = builtins.match "([0-9]+)-([0-9]+)" p;
|
|
lo = lib.toInt (builtins.elemAt m 0);
|
|
hi = lib.toInt (builtins.elemAt m 1);
|
|
in
|
|
lo >= 1 && hi <= 65535 && lo < hi;
|
|
|
|
ruleAssertions =
|
|
r:
|
|
let
|
|
where = "router.firewall.rules (${r.from} -> ${r.to})";
|
|
fromWan = r.from == "wan";
|
|
v6Dest = r.destination != null && isV6 r.destination;
|
|
dest = if r.destination == null || v6Dest then null else ipv4.parseNet r.destination;
|
|
inZone =
|
|
dest != null
|
|
&& lib.any (n: dest.prefixLength >= n.prefixLength && ipv4.inSubnet n dest.address) (
|
|
zoneV4Nets r.to
|
|
);
|
|
in
|
|
[
|
|
{
|
|
assertion = fromWan || lib.elem r.from zoneNames;
|
|
message = "router: ${where}: unknown source zone `${r.from}` (zones: wan, ${lib.concatStringsSep ", " zoneNames}).";
|
|
}
|
|
{
|
|
assertion = r.to == "local" || lib.elem r.to zoneNames;
|
|
message =
|
|
if r.to == "wan" then
|
|
"router: ${where}: `wan` cannot be a destination; use `internet = true` on the LAN or tunnel."
|
|
else
|
|
"router: ${where}: unknown destination zone `${r.to}` (zones: local, ${lib.concatStringsSep ", " zoneNames}).";
|
|
}
|
|
{
|
|
assertion = !(r.to == "local" && r.natOnly);
|
|
message = "router: ${where}: natOnly does not apply to the router itself (`local`); a port forward leads to a host in a zone.";
|
|
}
|
|
{
|
|
assertion = r.from != r.to;
|
|
message = "router: ${where}: source and destination are the same zone; traffic within a zone is already allowed.";
|
|
}
|
|
{
|
|
assertion = r.ports == [ ] || r.protocol == "tcp" || r.protocol == "udp";
|
|
message = "router: ${where}: ports are only valid with protocol tcp or udp.";
|
|
}
|
|
{
|
|
assertion = r.sourcePorts == [ ] || r.protocol == "tcp" || r.protocol == "udp";
|
|
message = "router: ${where}: sourcePorts are only valid with protocol tcp or udp.";
|
|
}
|
|
{
|
|
assertion = lib.all portOk (r.ports ++ r.sourcePorts);
|
|
message = "router: ${where}: a port or port range is out of range (1-65535, ranges need low < high).";
|
|
}
|
|
{
|
|
assertion = lib.all validCidr (
|
|
map (
|
|
x:
|
|
if lib.hasInfix "/" x then
|
|
x
|
|
else if isV6 x then
|
|
"${x}/128"
|
|
else
|
|
"${x}/32"
|
|
) r.sources
|
|
);
|
|
message = "router: ${where}: every source must be an IPv4/IPv6 address or CIDR.";
|
|
}
|
|
]
|
|
# Rules from the outside must not be able to expose a whole zone by accident.
|
|
++ lib.optionals fromWan [
|
|
{
|
|
assertion = r.destination != null || r.to == "local";
|
|
message = "router: ${where}: rules from `wan` must name a destination host or network.";
|
|
}
|
|
{
|
|
assertion = r.protocol != "any";
|
|
message = "router: ${where}: rules from `wan` must name a protocol (tcp, udp or icmp).";
|
|
}
|
|
{
|
|
assertion = r.protocol == "icmp" || r.protocol == "any" || r.ports != [ ];
|
|
message = "router: ${where}: rules from `wan` must list the ports they open.";
|
|
}
|
|
]
|
|
++ lib.optionals (r.destination != null) [
|
|
{
|
|
assertion = v6Dest || dest != null;
|
|
message = "router: ${where}: destination `${r.destination}` is not a valid IPv4 address or CIDR.";
|
|
}
|
|
{
|
|
assertion = v6Dest || dest == null || !(lib.elem r.to zoneNames) || inZone;
|
|
message = "router: ${where}: destination `${r.destination}` is not inside any network of zone `${r.to}`.";
|
|
}
|
|
{
|
|
assertion = v6Dest || dest == null || ipv4.networkOf dest.address dest.prefixLength == dest.address;
|
|
message = "router: ${where}: destination `${r.destination}` has host bits set.";
|
|
}
|
|
{
|
|
assertion =
|
|
!v6Dest
|
|
|| v6Cidr (if lib.hasInfix "/" r.destination then r.destination else "${r.destination}/128");
|
|
message = "router: ${where}: destination `${r.destination}` is not a valid IPv6 address or CIDR.";
|
|
}
|
|
]
|
|
;
|
|
|
|
natAssertions =
|
|
n:
|
|
let
|
|
where = "router.firewall.nat (${n.type} ${toString n.from} -> ${n.to})";
|
|
isDnat = n.type == "dnat";
|
|
isSnat = n.type == "snat";
|
|
v4Ok = a: (v4Cidr (if lib.hasInfix "/" a then a else "${a}/32"));
|
|
toAddr = if n.toAddress != null && validIp n.toAddress then ipv4.parseNet "${n.toAddress}/32" else null;
|
|
inZone =
|
|
toAddr != null
|
|
&& lib.any (z: toAddr.prefixLength >= z.prefixLength && ipv4.inSubnet z toAddr.address) (zoneV4Nets n.to);
|
|
internetMember = lib.any (l: l.internet) lans || lib.any (w: w.internet) wgs;
|
|
plainPorts = lib.all builtins.isInt n.ports;
|
|
in
|
|
[
|
|
{
|
|
assertion = n.from == null || n.from == "wan" || lib.elem n.from zoneNames;
|
|
message = "router: ${where}: unknown source zone `${toString n.from}` (zones: wan, ${lib.concatStringsSep ", " zoneNames}).";
|
|
}
|
|
{
|
|
assertion = lib.elem n.to zoneNames || (!isDnat && n.to == "wan");
|
|
message = "router: ${where}: unknown zone `${n.to}` (zones: ${lib.concatStringsSep ", " zoneNames}${lib.optionalString (!isDnat) ", wan"}).";
|
|
}
|
|
{
|
|
assertion = !isDnat || n.from != null;
|
|
message = "router: ${where}: a port forward needs a source zone (`from`).";
|
|
}
|
|
{
|
|
assertion = (n.ports == [ ] && n.sourcePorts == [ ]) || n.protocol == "tcp" || n.protocol == "udp";
|
|
message = "router: ${where}: ports are only valid with protocol tcp or udp.";
|
|
}
|
|
{
|
|
assertion = lib.all portOk (n.ports ++ n.sourcePorts);
|
|
message = "router: ${where}: a port or port range is out of range (1-65535, ranges need low < high).";
|
|
}
|
|
{
|
|
assertion = lib.all v4Ok n.sources;
|
|
message = "router: ${where}: every source must be an IPv4 address or CIDR (translation is IPv4 only).";
|
|
}
|
|
{
|
|
assertion = n.destination == null || v4Ok n.destination;
|
|
message = "router: ${where}: destination `${toString n.destination}` is not an IPv4 address or CIDR.";
|
|
}
|
|
{
|
|
assertion = isDnat || (n.toPort == null && !n.hairpin);
|
|
message = "router: ${where}: toPort and hairpin only apply to port forwards (dnat).";
|
|
}
|
|
{
|
|
assertion = (isDnat || isSnat) == (n.toAddress != null);
|
|
message = "router: ${where}: dnat and snat need a toAddress; masquerade has none.";
|
|
}
|
|
{
|
|
assertion = n.toAddress == null || validIp n.toAddress;
|
|
message = "router: ${where}: toAddress `${toString n.toAddress}` is not a valid IPv4 address.";
|
|
}
|
|
]
|
|
++ lib.optionals isDnat [
|
|
{
|
|
assertion = n.protocol == "tcp" || n.protocol == "udp";
|
|
message = "router: ${where}: a port forward needs protocol tcp or udp.";
|
|
}
|
|
{
|
|
assertion = n.ports != [ ];
|
|
message = "router: ${where}: a port forward must list the ports it forwards.";
|
|
}
|
|
{
|
|
assertion = toAddr == null || !(lib.elem n.to zoneNames) || inZone;
|
|
message = "router: ${where}: toAddress `${toString n.toAddress}` is not inside any network of zone `${n.to}`.";
|
|
}
|
|
{
|
|
assertion = n.toPort == null || (lib.length n.ports == 1 && plainPorts);
|
|
message = "router: ${where}: toPort needs exactly one (non-range) port.";
|
|
}
|
|
{
|
|
assertion = !n.hairpin || n.from == "wan";
|
|
message = "router: ${where}: hairpin only applies to forwards from `wan`.";
|
|
}
|
|
{
|
|
assertion = !n.hairpin || internetMember;
|
|
message = "router: ${where}: hairpin needs at least one LAN or tunnel with internet = true.";
|
|
}
|
|
]
|
|
++ lib.optionals (!isDnat) [
|
|
{
|
|
assertion = n.from == null || n.from != n.to;
|
|
message = "router: ${where}: source and destination are the same zone.";
|
|
}
|
|
];
|
|
|
|
dnsCfg = cfg.dns;
|
|
anyIp = s: validIp s || v6Cidr "${s}/128";
|
|
dnsAssertions =
|
|
let
|
|
names =
|
|
lib.attrNames dnsCfg.hosts ++ lib.attrNames dnsCfg.overrides ++ lib.attrNames dnsCfg.forwardZones;
|
|
recordSets = dnsCfg.hosts // dnsCfg.overrides;
|
|
shortNames = lib.filter (n: !(lib.hasInfix "." n)) (lib.attrNames dnsCfg.hosts);
|
|
reservationNames = lib.concatMap (l: lib.attrNames l.dhcp.reservations) lans;
|
|
in
|
|
[
|
|
{
|
|
assertion = lib.all dnsNameOk names && (dnsCfg.domain == null || dnsNameOk dnsCfg.domain);
|
|
message = "router.dns: a name in domain/hosts/overrides/forwardZones is not a valid DNS name.";
|
|
}
|
|
{
|
|
assertion = lib.all (as: as != [ ] && lib.all anyIp as) (lib.attrValues recordSets);
|
|
message = "router.dns: every host/override needs at least one valid IPv4 or IPv6 address.";
|
|
}
|
|
{
|
|
assertion = lib.all (as: as != [ ] && lib.all anyIp as) (lib.attrValues dnsCfg.forwardZones);
|
|
message = "router.dns.forwardZones: every zone needs at least one valid resolver address.";
|
|
}
|
|
{
|
|
assertion = dnsCfg.domain != null || shortNames == [ ];
|
|
message = "router.dns.hosts: `${lib.concatStringsSep ", " shortNames}` has no dot, so router.dns.domain must be set.";
|
|
}
|
|
{
|
|
assertion =
|
|
lib.length (lib.attrNames dnsCfg.hosts) == lib.length (
|
|
lib.unique (
|
|
map (n: if lib.hasInfix "." n || dnsCfg.domain == null then n else "${n}.${dnsCfg.domain}") (
|
|
lib.attrNames dnsCfg.hosts
|
|
)
|
|
)
|
|
);
|
|
message = "router.dns.hosts: two names are the same once placed in the domain.";
|
|
}
|
|
{
|
|
assertion = lib.all (n: !(lib.elem n (lib.attrNames dnsCfg.hosts))) reservationNames;
|
|
message = "router.dns.hosts: a name is also a DHCP reservation, which already publishes it.";
|
|
}
|
|
];
|
|
|
|
overlapsLan = n: lib.any (l: ipv4.overlaps n l) lanNets;
|
|
|
|
wgAssertions =
|
|
name: w:
|
|
let
|
|
where = "router.wireguard.${name}";
|
|
peers = lib.attrValues w.peers;
|
|
allowed = lib.concatMap (p: p.allowedIPs) peers;
|
|
v4Allowed = lib.filter (n: v4Cidr n) allowed;
|
|
v4Own = lib.filter v4Cidr w.addresses;
|
|
asNet = n: ipv4.parseNet n;
|
|
in
|
|
[
|
|
{
|
|
assertion = w.peers != { };
|
|
message = "router: ${where} has no peers.";
|
|
}
|
|
{
|
|
assertion = lib.stringLength w.interface <= 15;
|
|
message = "router: ${where}: device name `${w.interface}` is longer than 15 characters.";
|
|
}
|
|
{
|
|
assertion = lib.all anyIp w.sourceAddresses;
|
|
message = "router: ${where}.sourceAddresses must be IPv4/IPv6 addresses.";
|
|
}
|
|
{
|
|
assertion =
|
|
lib.length (lib.filter isV6 w.sourceAddresses) <= 1
|
|
&& lib.length (lib.filter (s: !isV6 s) w.sourceAddresses) <= 1;
|
|
message = "router: ${where}.sourceAddresses may hold at most one address per family.";
|
|
}
|
|
{
|
|
assertion = lib.all (
|
|
s:
|
|
isV6 s
|
|
|| !(validIp s)
|
|
|| lib.elem s (
|
|
map (l: l.ipv4.address) lans
|
|
++ map (a: builtins.head (lib.splitString "/" a)) (lib.concatMap (x: x.addresses) wgs)
|
|
)
|
|
) w.sourceAddresses;
|
|
message = "router: ${where}.sourceAddresses: an IPv4 source must be the router's address on a LAN or a tunnel.";
|
|
}
|
|
{
|
|
assertion = lib.all validCidr w.addresses;
|
|
message = "router: ${where}.addresses must be IPv4/IPv6 addresses in CIDR notation.";
|
|
}
|
|
{
|
|
assertion = lib.all validCidr allowed;
|
|
message = "router: ${where}: every peer allowedIPs entry must be an IPv4/IPv6 CIDR.";
|
|
}
|
|
{
|
|
assertion = !(lib.any (n: lib.hasSuffix "/0" n) allowed);
|
|
message = "router: ${where}: default-route tunnels (allowedIPs ending in /0) are not supported; list the destinations instead.";
|
|
}
|
|
{
|
|
assertion = lib.length allowed == lib.length (lib.unique allowed);
|
|
message = "router: ${where}: an allowedIPs entry appears more than once (WireGuard would give it to one peer only).";
|
|
}
|
|
{
|
|
assertion = lib.all (n: !(overlapsLan (asNet n))) (
|
|
lib.filter (n: !lib.hasSuffix "/0" n) (v4Own ++ v4Allowed)
|
|
);
|
|
message = "router: ${where}: a tunnel address or allowedIPs entry overlaps one of the LAN subnets.";
|
|
}
|
|
{
|
|
assertion = lib.all (p: builtins.match "[A-Za-z0-9+/]{43}=" p.publicKey != null) peers;
|
|
message = "router: ${where}: a peer publicKey is not a valid WireGuard public key.";
|
|
}
|
|
{
|
|
assertion = lib.all (
|
|
p: p.endpoint == null || builtins.match ".+:[0-9]{1,5}" p.endpoint != null
|
|
) peers;
|
|
message = "router: ${where}: a peer endpoint must look like host:port or [ipv6]:port.";
|
|
}
|
|
];
|
|
|
|
overlapPairs = lib.concatLists (
|
|
lib.imap0 (
|
|
i: a:
|
|
map (b: {
|
|
inherit a b;
|
|
}) (lib.drop (i + 1) (lib.attrNames lansByName))
|
|
) (lib.attrNames lansByName)
|
|
);
|
|
in
|
|
{
|
|
config = lib.mkIf cfg.enable {
|
|
assertions = [
|
|
{
|
|
assertion = lans != [ ];
|
|
message = "router: at least one LAN must be defined.";
|
|
}
|
|
{
|
|
assertion = lib.length macs == lib.length (lib.unique macs);
|
|
message = "router.interfaces: two names are given to the same MAC address.";
|
|
}
|
|
{
|
|
assertion = lib.all chosenNameOk chosenNames;
|
|
message = "router: names for interfaces and VLAN devices are lower case letters, digits and dashes, start with a letter, have at most 15 characters and do not look like a kernel name (eth0, wlan0, enp2s0, ...): ${lib.concatStringsSep ", " (lib.filter (n: !chosenNameOk n) chosenNames)}.";
|
|
}
|
|
{
|
|
assertion = lib.length chosenNames == lib.length (lib.unique chosenNames);
|
|
message = "router: a name is used twice among `interfaces` and the VLAN devices.";
|
|
}
|
|
{
|
|
assertion = cfg.wan.ipv4.pppoe.vlanName == null || cfg.wan.ipv4.pppoe.vlan != null;
|
|
message = "router.wan.ipv4.pppoe.vlanName needs pppoe.vlan.";
|
|
}
|
|
{
|
|
assertion = lib.any (l: l.allowManagement) lans;
|
|
message = "router: no LAN has allowManagement = true; you would be locked out of the router.";
|
|
}
|
|
{
|
|
assertion =
|
|
!(lib.elem cfg.wan.interface (map (l: l.interface) lans))
|
|
|| (
|
|
cfg.wan.ipv4.type == "pppoe"
|
|
&& cfg.wan.ipv4.pppoe.vlan != null
|
|
&& (!cfg.wan.ipv6.enable || cfg.wan.ipv6.transport == "ppp")
|
|
);
|
|
message = "router: the WAN interface `${cfg.wan.interface}` is also used by a LAN (only allowed for PPPoE on a VLAN, with IPv6 disabled or inside the PPP session).";
|
|
}
|
|
{
|
|
assertion = cfg.wan.ipv6.transport == "native" || cfg.wan.ipv4.type == "pppoe";
|
|
message = "router: wan.ipv6.transport = \"ppp\" needs a PPPoE uplink (wan.ipv4.type = \"pppoe\").";
|
|
}
|
|
{
|
|
assertion =
|
|
cfg.wan.ipv4.pppoe.vlan == null
|
|
|| !(lib.any (l: l.interface == cfg.wan.interface && l.vlan == cfg.wan.ipv4.pppoe.vlan) lans);
|
|
message = "router: VLAN ${toString cfg.wan.ipv4.pppoe.vlan} on `${cfg.wan.interface}` is used by both the PPPoE uplink and 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
|
|
devices = map (l: l.device) lans;
|
|
in
|
|
lib.length devices == lib.length (lib.unique devices);
|
|
message = "router: two LANs share the same device (same interface and VLAN).";
|
|
}
|
|
]
|
|
++ lib.concatLists (lib.mapAttrsToList perLan lansByName)
|
|
++ dnsAssertions
|
|
++ lib.concatMap ruleAssertions cfg.firewall.rules
|
|
++ lib.concatMap natAssertions cfg.firewall.nat
|
|
++ [
|
|
{
|
|
assertion = !(lib.elem "wan" zoneNames);
|
|
message = "router: `wan` is a reserved zone name; give the LAN or tunnel a different `zone`.";
|
|
}
|
|
{
|
|
assertion = !(lib.elem "local" zoneNames);
|
|
message = "router: `local` is a reserved zone name (the router itself); give the LAN or tunnel a different `zone`.";
|
|
}
|
|
]
|
|
++ lib.concatLists (lib.mapAttrsToList wgAssertions cfg.wireguard)
|
|
++ [
|
|
{
|
|
assertion =
|
|
let
|
|
ports = lib.filter (p: p != null) (map (w: w.listenPort) wgs);
|
|
in
|
|
lib.length ports == lib.length (lib.unique ports);
|
|
message = "router: two WireGuard tunnels listen on the same UDP port.";
|
|
}
|
|
{
|
|
assertion =
|
|
let
|
|
own = map (w: w.interface) wgs;
|
|
others = map (l: l.device) lans ++ [ cfg.wan.interface ];
|
|
in
|
|
lib.length own == lib.length (lib.unique own) && !(lib.any (d: lib.elem d others) own);
|
|
message = "router: a WireGuard device name clashes with a LAN device or the WAN interface.";
|
|
}
|
|
]
|
|
++ map (
|
|
{ a, b }:
|
|
let
|
|
la = lansByName.${a};
|
|
lb = lansByName.${b};
|
|
in
|
|
{
|
|
# Only comparable when both addresses parse; the per-LAN assertion reports the rest.
|
|
assertion =
|
|
!(validIp la.ipv4.address && validIp lb.ipv4.address)
|
|
|| !(ipv4.overlaps
|
|
{
|
|
address = ipv4.parse la.ipv4.address;
|
|
inherit (la.ipv4) prefixLength;
|
|
}
|
|
{
|
|
address = ipv4.parse lb.ipv4.address;
|
|
inherit (lb.ipv4) prefixLength;
|
|
}
|
|
);
|
|
message = "router: LANs `${a}` (${la.ipv4.subnet}) and `${b}` (${lb.ipv4.subnet}) overlap.";
|
|
}
|
|
) overlapPairs;
|
|
};
|
|
}
|