Add WireGuard tunnels as firewall zone members
router.wireguard.<name> configures tunnels through networkd: road warrior (listening, dual-stack), site-to-site (no transfer network) and provider dial-out with masquerade. A tunnel has a zone like a LAN, so inter-zone rules apply; management, DNS and internet are opt-in. Peers' allowedIPs become routes (destination-based routing; default-route tunnels are rejected). Tunnels have a kill switch (default on): forwarded packets for their allowedIPs are dropped if they would leave via an uplink, before the established-accept so flows that used the tunnel are cut too. Private keys stay outside the Nix store and are passed to the unprivileged systemd-networkd with LoadCredential, so key files can be root-only. Assertions cover CIDR/key/endpoint formats, duplicate allowedIPs, overlaps with LAN subnets, port and device clashes, and rule destinations inside tunnel networks. A five-VM `wireguard` check covers road warrior, site-to-site, the provider tunnel with an IPv6 transfer net, zone rules and the kill switch. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+114
-11
@@ -60,24 +60,52 @@ let
|
||||
]
|
||||
);
|
||||
|
||||
zoneNames = lib.unique (map (l: l.zone) lans);
|
||||
wgs = lib.attrValues cfg.wireguard;
|
||||
zoneNames = lib.unique (map (l: l.zone) lans ++ map (w: w.zone) wgs);
|
||||
|
||||
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);
|
||||
|
||||
ruleAssertions =
|
||||
r:
|
||||
let
|
||||
where = "router.firewall.rules (${r.from} -> ${r.to})";
|
||||
dest = if r.destination == null then null else ipv4.parseNet r.destination;
|
||||
destLans = lib.filter (l: l.zone == r.to && validIp l.ipv4.address) lans;
|
||||
inZone =
|
||||
dest != null
|
||||
&& lib.any (
|
||||
l:
|
||||
dest.prefixLength >= l.ipv4.prefixLength
|
||||
&& ipv4.inSubnet {
|
||||
address = ipv4.parse l.ipv4.address;
|
||||
inherit (l.ipv4) prefixLength;
|
||||
} dest.address
|
||||
) destLans;
|
||||
&& lib.any (n: dest.prefixLength >= n.prefixLength && ipv4.inSubnet n dest.address) (
|
||||
zoneV4Nets r.to
|
||||
);
|
||||
in
|
||||
[
|
||||
{
|
||||
@@ -104,7 +132,7 @@ let
|
||||
}
|
||||
{
|
||||
assertion = dest == null || !(lib.elem r.to zoneNames) || inZone;
|
||||
message = "router: ${where}: destination `${r.destination}` is not inside any LAN of zone `${r.to}`.";
|
||||
message = "router: ${where}: destination `${r.destination}` is not inside any network of zone `${r.to}`.";
|
||||
}
|
||||
{
|
||||
assertion = dest == null || ipv4.networkOf dest.address dest.prefixLength == dest.address;
|
||||
@@ -112,6 +140,61 @@ let
|
||||
}
|
||||
];
|
||||
|
||||
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 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:
|
||||
@@ -163,6 +246,26 @@ in
|
||||
]
|
||||
++ lib.concatLists (lib.mapAttrsToList perLan lansByName)
|
||||
++ lib.concatMap ruleAssertions cfg.firewall.rules
|
||||
++ 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
|
||||
|
||||
Reference in New Issue
Block a user