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
562 lines
20 KiB
Nix
562 lines
20 KiB
Nix
{ config, lib, ... }:
|
|
let
|
|
cfg = config.router;
|
|
ipv4 = (import ./lib.nix { inherit lib; }).ipv4;
|
|
|
|
lansByName = cfg.lans;
|
|
lans = lib.attrValues lansByName;
|
|
|
|
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.";
|
|
}
|
|
{
|
|
# 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
|
|
);
|
|
singleHost = dest != null && dest.prefixLength == 32;
|
|
plainPorts = lib.all builtins.isInt r.ports;
|
|
internetMember = lib.any (l: l.internet) lans || lib.any (w: w.internet) wgs;
|
|
in
|
|
[
|
|
{
|
|
assertion = fromWan || lib.elem r.from zoneNames;
|
|
message = "router: ${where}: unknown source zone `${r.from}` (zones: wan, ${lib.concatStringsSep ", " zoneNames}).";
|
|
}
|
|
{
|
|
assertion = 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: ${lib.concatStringsSep ", " zoneNames}).";
|
|
}
|
|
{
|
|
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") && !r.dnat);
|
|
message = "router: ${where}: sourcePorts are only valid with protocol tcp or udp, and not on a port forward.";
|
|
}
|
|
{
|
|
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;
|
|
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.";
|
|
}
|
|
]
|
|
++ lib.optionals r.dnat [
|
|
{
|
|
assertion = fromWan;
|
|
message = "router: ${where}: dnat is only valid for rules from `wan`.";
|
|
}
|
|
{
|
|
assertion = singleHost;
|
|
message = "router: ${where}: dnat needs a single IPv4 host as destination.";
|
|
}
|
|
{
|
|
assertion = r.protocol == "tcp" || r.protocol == "udp";
|
|
message = "router: ${where}: dnat needs protocol tcp or udp.";
|
|
}
|
|
{
|
|
assertion = lib.all (x: !isV6 x) r.sources;
|
|
message = "router: ${where}: dnat is IPv4-only, so its sources must be IPv4.";
|
|
}
|
|
]
|
|
++ lib.optionals (r.toPort != null) [
|
|
{
|
|
assertion = r.dnat && lib.length r.ports == 1 && plainPorts;
|
|
message = "router: ${where}: toPort needs dnat and exactly one (non-range) port.";
|
|
}
|
|
]
|
|
++ lib.optionals r.hairpin [
|
|
{
|
|
assertion = r.dnat;
|
|
message = "router: ${where}: hairpin only applies to dnat rules.";
|
|
}
|
|
{
|
|
assertion = internetMember;
|
|
message = "router: ${where}: hairpin needs at least one LAN or tunnel with internet = true.";
|
|
}
|
|
];
|
|
|
|
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.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
|
|
++ [
|
|
{
|
|
assertion = !(lib.elem "wan" zoneNames);
|
|
message = "router: `wan` is a reserved zone name; 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;
|
|
};
|
|
}
|