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:
@@ -62,6 +62,8 @@ examples/ # sample configs
|
||||
|
||||
## Status
|
||||
|
||||
**WireGuard done**: `wireguard` VM test (5 VMs: ISP/provider, router, laptop, remote site, LAN host) covers handshakes, road-warrior DNS/SSH over IPv4 and IPv6, zone rules between tunnels and LAN, site-to-site routing without NAT or transfer net, provider tunnel with masquerade and IPv6 transfer net, and the kill switch (kill switch and masquerade mutation-tested). Not covered: preshared keys, `mtu`, tunnels with several peers, key rotation.
|
||||
|
||||
**VLANs + zones done**: `vlans` VM test (5 VMs: ISP, router, untagged `main`, VLAN 20 `iot`, VLAN 30 `cams`) covers VLAN devices, per-VLAN DHCP/gateway, opt-in internet, DNS vs SSH per LAN, intra-zone forwarding across VLANs, one-directional inter-zone rules and the destination restriction (each mutation-tested). Not covered yet: IPv6 on VLAN LANs (only the PD LAN has IPv6), port forwards from the WAN.
|
||||
|
||||
**PPPoE done**: `pppoe` VM test = the same topology with a fake ISP running `pppoe-server` (VLAN 7, CHAP, password containing a quote) — covers session, NAT, MTU, MSS clamp (mutation-tested) and WAN-deny. `basic` remains the DHCPv4 variant.
|
||||
@@ -77,7 +79,7 @@ Learned along the way:
|
||||
- In NixOS tests `fail(...)` also passes on "command not found": install probe tools explicitly and add a positive control per negative check.
|
||||
- Unbound answers `.test` locally (RFC 6761) and validates DNSSEC; tests using fake domains need both `local-zone ... transparent` and `domain-insecure` (both verified necessary; set in the test, not the module).
|
||||
- `modules/router/lib.nix` has IPv4 helpers (parse/format/netmask/cidr/overlap). `ipv4.subnet` is now derived from `address` + `prefixLength` (read-only). Assertions also cover pool bounds, pool containing the router address, and overlapping LANs. IPv6 helpers (prefix arithmetic for PD subnet-IDs) are still to do.
|
||||
- Checks: `basic`, `pppoe` and `vlans` (VM tests), `lib` (helper unit tests), `assertions` (broken configs must fail with exact messages). Not yet covered: WireGuard, port forwards, WAN-originated rules.
|
||||
- Checks: `basic`, `pppoe`, `vlans` and `wireguard` (VM tests), `lib` (helper unit tests), `assertions` (broken configs must fail with exact messages). Not yet covered: port forwards, WAN-originated rules, NPTv6.
|
||||
|
||||
## IPv6 design (confirmed)
|
||||
|
||||
@@ -103,6 +105,15 @@ Learned along the way:
|
||||
- Router services stay per LAN: DNS and DHCP always, SSH only with `allowManagement`.
|
||||
- Kea subnet IDs are derived from the network address, so adding or removing LANs does not shift IDs (and orphan persistent leases).
|
||||
|
||||
## WireGuard (confirmed)
|
||||
|
||||
- **Use cases**: road warrior (listening, dual-stack), site-to-site (no transfer network), and a provider VPN the router dials into (dual-stack transfer net, masquerade). All via `router.wireguard.<name>`.
|
||||
- **Firewall**: a tunnel is a zone member like a LAN (`zone`, `internet`, `allowManagement`, plus `dns`, `masquerade`, `killSwitch`), so `router.firewall.rules` cover it. Safer defaults than LANs: management, DNS and internet are off. A tunnel's peers never see each other (no same-device forwarding).
|
||||
- **Routing**: by destination only — a peer's `allowedIPs` become routes (no default-route tunnels; `/0` is rejected). Provider-style "policy routing" is exactly this; source-based selectors (`ip rule`) are not implemented.
|
||||
- **Kill switch** (default on): forwarded packets for a tunnel's `allowedIPs` are dropped if they would leave via an uplink (placed before the established-accept, so flows that were in the tunnel are cut too).
|
||||
- **Addresses** are explicit (`addresses` list, empty for site-to-site); nothing generated. No IPv6 internet for road warriors (the delegated /64 belongs to a LAN; NPTv6 not implemented).
|
||||
- **Keys**: `privateKeyFile` (and `presharedKeyFile`) outside the Nix store, handed to the unprivileged systemd-networkd via `LoadCredential`, so files can stay root-only 0600. Deployment must provide them.
|
||||
|
||||
## Open items
|
||||
|
||||
- **Web UI** (stats and possibly configuration) — to be discussed later. Considerations: read-only stats first is cheap; config editing must write declarative config (Nix/JSON) that feeds the module, not mutate the live system.
|
||||
|
||||
+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
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
./assertions.nix
|
||||
./networking.nix
|
||||
./pppoe.nix
|
||||
./wireguard.nix
|
||||
./firewall.nix
|
||||
./dhcp.nix
|
||||
./dns.nix
|
||||
|
||||
+18
-2
@@ -2,6 +2,21 @@
|
||||
let
|
||||
cfg = config.router;
|
||||
lans = lib.attrValues cfg.lans;
|
||||
ipv4 = (import ./lib.nix { inherit lib; }).ipv4;
|
||||
|
||||
# Tunnels that opted into DNS: listen on each of their addresses.
|
||||
dnsAddrs = lib.concatMap (w: w.addresses) (lib.filter (w: w.dns) (lib.attrValues cfg.wireguard));
|
||||
addrOf = a: builtins.head (lib.splitString "/" a);
|
||||
# IPv4 gets the proper network address; IPv6 netblocks are masked by Unbound.
|
||||
netOf =
|
||||
a:
|
||||
let
|
||||
parts = lib.splitString "/" a;
|
||||
in
|
||||
if lib.hasInfix ":" a then
|
||||
a
|
||||
else
|
||||
ipv4.cidr (builtins.head parts) (lib.toInt (builtins.elemAt parts 1));
|
||||
in
|
||||
{
|
||||
config = lib.mkIf cfg.enable {
|
||||
@@ -9,13 +24,14 @@ in
|
||||
enable = true;
|
||||
settings = {
|
||||
server = {
|
||||
interface = [ "127.0.0.1" ] ++ map (l: l.ipv4.address) lans;
|
||||
interface = [ "127.0.0.1" ] ++ map (l: l.ipv4.address) lans ++ map addrOf dnsAddrs;
|
||||
# LAN addresses are assigned by networkd, possibly after Unbound starts.
|
||||
ip-freebind = true;
|
||||
access-control = [
|
||||
"127.0.0.0/8 allow"
|
||||
]
|
||||
++ map (l: "${l.ipv4.subnet} allow") lans;
|
||||
++ map (l: "${l.ipv4.subnet} allow") lans
|
||||
++ map (a: "${netOf a} allow") dnsAddrs;
|
||||
};
|
||||
}
|
||||
// lib.optionalAttrs (cfg.dns.forwarders != [ ]) {
|
||||
|
||||
+61
-11
@@ -4,19 +4,59 @@ let
|
||||
lans = lib.attrValues cfg.lans;
|
||||
|
||||
set = ifaces: "{ ${lib.concatMapStringsSep ", " (i: ''"${i}"'') ifaces} }";
|
||||
lanIfaces = map (l: l.device) lans;
|
||||
mgmtIfaces = map (l: l.device) (lib.filter (l: l.allowManagement) lans);
|
||||
internetIfaces = map (l: l.device) (lib.filter (l: l.internet) lans);
|
||||
wgs = lib.attrValues cfg.wireguard;
|
||||
isV6 = lib.hasInfix ":";
|
||||
|
||||
# zone name -> devices of its member LANs
|
||||
zones = lib.mapAttrs (_: ls: map (l: l.device) ls) (lib.groupBy (l: l.zone) lans);
|
||||
# Everything that is a firewall zone member: LANs and tunnels.
|
||||
members =
|
||||
map (l: {
|
||||
inherit (l)
|
||||
device
|
||||
zone
|
||||
internet
|
||||
allowManagement
|
||||
;
|
||||
}) lans
|
||||
++ map (w: {
|
||||
device = w.interface;
|
||||
inherit (w) zone internet allowManagement;
|
||||
}) wgs;
|
||||
devicesOf = pred: map (m: m.device) (lib.filter pred members);
|
||||
|
||||
lanIfaces = map (l: l.device) lans;
|
||||
memberIfaces = devicesOf (_: true);
|
||||
dnsIfaces = lanIfaces ++ map (w: w.interface) (lib.filter (w: w.dns) wgs);
|
||||
mgmtIfaces = devicesOf (m: m.allowManagement);
|
||||
internetIfaces = devicesOf (m: m.internet);
|
||||
listenPorts = lib.unique (map (w: w.listenPort) (lib.filter (w: w.listenPort != null) wgs));
|
||||
masqueradeIfaces = map (w: w.interface) (lib.filter (w: w.masquerade) wgs);
|
||||
|
||||
# Destinations a kill-switched tunnel must never leak to an uplink.
|
||||
killSwitched = lib.filter (w: w.killSwitch) wgs;
|
||||
killNets = lib.concatMap (w: lib.concatMap (p: p.allowedIPs) (lib.attrValues w.peers)) killSwitched;
|
||||
killRules =
|
||||
let
|
||||
v4 = lib.filter (n: !isV6 n) killNets;
|
||||
v6 = lib.filter isV6 killNets;
|
||||
list = ns: "{ ${lib.concatStringsSep ", " ns} }";
|
||||
in
|
||||
lib.optional (v4 != [ ]) "oifname ${set uplinks} ip daddr ${list v4} drop"
|
||||
++ lib.optional (v6 != [ ]) "oifname ${set uplinks} ip6 daddr ${list v6} drop";
|
||||
|
||||
# zone name -> devices of its members
|
||||
zones = lib.mapAttrs (_: ms: map (m: m.device) ms) (lib.groupBy (m: m.zone) members);
|
||||
zoneDevices = zone: zones.${zone} or [ ];
|
||||
|
||||
# LANs sharing a zone may forward to each other (a single LAN is plain L2).
|
||||
# Members of one zone may forward to each other, but never back out of the
|
||||
# device they came in on (a tunnel's peers do not see each other).
|
||||
intraZone = lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
zone: devs:
|
||||
lib.optional (lib.length devs > 1) "iifname ${set devs} oifname ${set devs} accept # zone ${zone}"
|
||||
lib.optionals (lib.length devs > 1) (
|
||||
map (
|
||||
d: "iifname ${set [ d ]} oifname ${set (lib.filter (x: x != d) devs)} accept # zone ${zone}"
|
||||
) devs
|
||||
)
|
||||
) zones
|
||||
);
|
||||
|
||||
@@ -69,14 +109,19 @@ in
|
||||
destination-unreachable, packet-too-big, time-exceeded, parameter-problem,
|
||||
nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert
|
||||
} accept
|
||||
iifname ${set lanIfaces} icmp type echo-request accept
|
||||
iifname ${set lanIfaces} icmpv6 type echo-request accept
|
||||
iifname ${set memberIfaces} icmp type echo-request accept
|
||||
iifname ${set memberIfaces} icmpv6 type echo-request accept
|
||||
|
||||
${lib.optionalString (!pppoe) ''iifname "${cfg.wan.interface}" udp dport 68 accept''}
|
||||
${lib.optionalString cfg.wan.ipv6.enable ''iifname "${cfg.wan.interface}" udp dport 546 accept''}
|
||||
|
||||
iifname ${set lanIfaces} udp dport 53 accept
|
||||
iifname ${set lanIfaces} tcp dport 53 accept
|
||||
iifname ${set dnsIfaces} udp dport 53 accept
|
||||
iifname ${set dnsIfaces} tcp dport 53 accept
|
||||
${lib.optionalString (listenPorts != [ ])
|
||||
"iifname ${set uplinks} udp dport { ${
|
||||
lib.concatMapStringsSep ", " toString listenPorts
|
||||
} } accept # wireguard"
|
||||
}
|
||||
${lib.optionalString (dhcpIfaces != [ ]) "iifname ${set dhcpIfaces} udp dport 67 accept"}
|
||||
${lib.optionalString (mgmtIfaces != [ ]) "iifname ${set mgmtIfaces} tcp dport 22 accept"}
|
||||
}
|
||||
@@ -84,6 +129,8 @@ in
|
||||
chain forward {
|
||||
type filter hook forward priority filter; policy drop;
|
||||
|
||||
${lib.concatStringsSep "\n " killRules}
|
||||
|
||||
ct state established,related accept
|
||||
ct state invalid drop
|
||||
|
||||
@@ -110,6 +157,9 @@ in
|
||||
chain postrouting {
|
||||
type nat hook postrouting priority srcnat; policy accept;
|
||||
oifname "${uplink4}" masquerade
|
||||
${lib.optionalString (
|
||||
masqueradeIfaces != [ ]
|
||||
) "oifname ${set masqueradeIfaces} masquerade # tunnels"}
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -186,6 +186,132 @@ in
|
||||
);
|
||||
};
|
||||
|
||||
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 = ''
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
{ config, lib, ... }:
|
||||
let
|
||||
cfg = config.router;
|
||||
|
||||
# systemd-networkd runs unprivileged and could not read root-only key files.
|
||||
# LoadCredential= makes systemd read them as root and hand networkd a copy
|
||||
# in its credentials directory, so the files can stay 0600 root.
|
||||
credDir = "/run/credentials/systemd-networkd.service";
|
||||
keyCred = name: "wg-${name}-key";
|
||||
pskCred = name: peer: "wg-${name}-${peer}-psk";
|
||||
credentials = lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
name: wg:
|
||||
[ "${keyCred name}:${toString wg.privateKeyFile}" ]
|
||||
++ lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
peer: p:
|
||||
lib.optional (p.presharedKeyFile != null) "${pskCred name peer}:${toString p.presharedKeyFile}"
|
||||
) wg.peers
|
||||
)
|
||||
) cfg.wireguard
|
||||
);
|
||||
in
|
||||
{
|
||||
config = lib.mkIf (cfg.enable && cfg.wireguard != { }) {
|
||||
systemd.network.netdevs = lib.mapAttrs' (
|
||||
name: wg:
|
||||
lib.nameValuePair "30-wg-${name}" {
|
||||
netdevConfig = {
|
||||
Kind = "wireguard";
|
||||
Name = wg.interface;
|
||||
}
|
||||
// lib.optionalAttrs (wg.mtu != null) { MTUBytes = wg.mtu; };
|
||||
wireguardConfig = {
|
||||
PrivateKeyFile = "${credDir}/${keyCred name}";
|
||||
}
|
||||
// lib.optionalAttrs (wg.listenPort != null) { ListenPort = wg.listenPort; };
|
||||
wireguardPeers = lib.mapAttrsToList (
|
||||
peer: p:
|
||||
{
|
||||
PublicKey = p.publicKey;
|
||||
AllowedIPs = p.allowedIPs;
|
||||
}
|
||||
// lib.optionalAttrs (p.endpoint != null) { Endpoint = p.endpoint; }
|
||||
// lib.optionalAttrs (p.persistentKeepalive != null) {
|
||||
PersistentKeepalive = p.persistentKeepalive;
|
||||
}
|
||||
// lib.optionalAttrs (p.presharedKeyFile != null) {
|
||||
PresharedKeyFile = "${credDir}/${pskCred name peer}";
|
||||
}
|
||||
) wg.peers;
|
||||
}
|
||||
) cfg.wireguard;
|
||||
|
||||
systemd.services.systemd-networkd.serviceConfig.LoadCredential = credentials;
|
||||
|
||||
systemd.network.networks = lib.mapAttrs' (
|
||||
name: wg:
|
||||
lib.nameValuePair "30-wg-${name}" {
|
||||
matchConfig.Name = wg.interface;
|
||||
address = wg.addresses;
|
||||
networkConfig = {
|
||||
ConfigureWithoutCarrier = true;
|
||||
IPv6AcceptRA = false;
|
||||
};
|
||||
# Cryptokey routing: a peer's allowedIPs are the destinations sent into the tunnel.
|
||||
routes = lib.concatMap (p: map (ip: { Destination = ip; }) p.allowedIPs) (lib.attrValues wg.peers);
|
||||
}
|
||||
) cfg.wireguard;
|
||||
};
|
||||
}
|
||||
+162
-1
@@ -49,6 +49,18 @@ let
|
||||
} overrides
|
||||
);
|
||||
|
||||
# A throwaway public key, valid in format only.
|
||||
pub = "7MkFXOxlCsmXm98uv0txvYzx5Ce80mkg2mDsswY4FyI=";
|
||||
wg =
|
||||
overrides:
|
||||
lib.recursiveUpdate {
|
||||
privateKeyFile = "/var/lib/secrets/wg.key";
|
||||
peers.p1 = {
|
||||
publicKey = pub;
|
||||
allowedIPs = [ "10.100.0.2/32" ];
|
||||
};
|
||||
} overrides;
|
||||
|
||||
failed = router: map (a: a.message) (lib.filter (a: !a.assertion) (evalRouter router).assertions);
|
||||
|
||||
cases = {
|
||||
@@ -328,7 +340,7 @@ let
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [ "is not inside any LAN of zone `main`" ];
|
||||
expect = [ "is not inside any network of zone `main`" ];
|
||||
};
|
||||
ruleDestinationHostBits = {
|
||||
router = {
|
||||
@@ -346,6 +358,154 @@ let
|
||||
};
|
||||
expect = [ "has host bits set" ];
|
||||
};
|
||||
wgValid = {
|
||||
router = {
|
||||
wan.interface = "eth0";
|
||||
lans.main = lan { };
|
||||
wireguard = {
|
||||
rw = wg {
|
||||
listenPort = 51820;
|
||||
addresses = [
|
||||
"10.100.0.1/24"
|
||||
"fd42:100::1/64"
|
||||
];
|
||||
dns = true;
|
||||
peers.p1.allowedIPs = [
|
||||
"10.100.0.2/32"
|
||||
"fd42:100::2/128"
|
||||
];
|
||||
};
|
||||
s2s = wg {
|
||||
listenPort = 51821;
|
||||
zone = "remote";
|
||||
peers.p1 = {
|
||||
endpoint = "203.0.113.60:51821";
|
||||
allowedIPs = [ "192.168.50.0/24" ];
|
||||
persistentKeepalive = 25;
|
||||
};
|
||||
};
|
||||
ff = wg {
|
||||
zone = "freifunk";
|
||||
addresses = [
|
||||
"10.200.0.2/32"
|
||||
"fd42:200::2/128"
|
||||
];
|
||||
masquerade = true;
|
||||
peers.p1 = {
|
||||
endpoint = "[2001:db8::1]:51822";
|
||||
allowedIPs = [
|
||||
"198.18.0.0/24"
|
||||
"fd00:ff::/32"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
firewall.rules = [
|
||||
{
|
||||
from = "main";
|
||||
to = "remote";
|
||||
destination = "192.168.50.7";
|
||||
}
|
||||
{
|
||||
from = "main";
|
||||
to = "freifunk";
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [ ];
|
||||
};
|
||||
wgNoPeers = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
wireguard.rw = wg { peers = lib.mkForce { }; };
|
||||
};
|
||||
expect = [ "has no peers" ];
|
||||
};
|
||||
wgBadAddress = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
wireguard.rw = wg { addresses = [ "10.100.0.1" ]; };
|
||||
};
|
||||
expect = [ "addresses must be IPv4/IPv6 addresses in CIDR notation" ];
|
||||
};
|
||||
wgDefaultRoute = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
wireguard.rw = wg { peers.p1.allowedIPs = [ "0.0.0.0/0" ]; };
|
||||
};
|
||||
expect = [ "default-route tunnels" ];
|
||||
};
|
||||
wgDuplicateAllowedIps = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
wireguard.rw = wg {
|
||||
peers.p2 = {
|
||||
publicKey = pub;
|
||||
allowedIPs = [ "10.100.0.2/32" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
expect = [ "appears more than once" ];
|
||||
};
|
||||
wgOverlapsLan = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
wireguard.rw = wg { peers.p1.allowedIPs = [ "192.168.10.0/25" ]; };
|
||||
};
|
||||
expect = [ "overlaps one of the LAN subnets" ];
|
||||
};
|
||||
wgBadKey = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
wireguard.rw = wg { peers.p1.publicKey = "not-a-key"; };
|
||||
};
|
||||
expect = [ "not a valid WireGuard public key" ];
|
||||
};
|
||||
wgBadEndpoint = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
wireguard.rw = wg { peers.p1.endpoint = "vpn.example.org"; };
|
||||
};
|
||||
expect = [ "endpoint must look like host:port" ];
|
||||
};
|
||||
wgSamePort = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
wireguard = {
|
||||
a = wg { listenPort = 51820; };
|
||||
b = wg { listenPort = 51820; };
|
||||
};
|
||||
};
|
||||
expect = [ "listen on the same UDP port" ];
|
||||
};
|
||||
wgDeviceClash = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
wireguard.rw = wg { interface = "eth1"; };
|
||||
};
|
||||
expect = [ "clashes with a LAN device or the WAN interface" ];
|
||||
};
|
||||
wgNameTooLong = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
wireguard.a-very-long-tunnel-name = wg { };
|
||||
};
|
||||
expect = [ "longer than 15 characters" ];
|
||||
};
|
||||
wgRuleDestinationOutside = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
wireguard.s2s = wg { peers.p1.allowedIPs = [ "192.168.50.0/24" ]; };
|
||||
firewall.rules = [
|
||||
{
|
||||
from = "main";
|
||||
to = "s2s";
|
||||
destination = "192.168.60.1";
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [ "is not inside any network of zone `s2s`" ];
|
||||
};
|
||||
nonOverlappingLans = {
|
||||
router.lans = {
|
||||
a = lan { };
|
||||
@@ -391,6 +551,7 @@ let
|
||||
"pppoeValid"
|
||||
"vlansOnOneTrunk"
|
||||
"ruleValid"
|
||||
"wgValid"
|
||||
];
|
||||
|
||||
subnet =
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
netcat-openbsd
|
||||
curl
|
||||
tcpdump
|
||||
wireguard-tools
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
wan4 = "pppoe";
|
||||
};
|
||||
vlans = import ./vlans.nix { inherit self pkgs; };
|
||||
wireguard = import ./wireguard.nix { inherit self pkgs; };
|
||||
lib = import ./lib.nix { inherit pkgs; };
|
||||
assertions = import ./assertions.nix { inherit self pkgs; };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
# WireGuard: road warrior, site-to-site and a provider ("Freifunk-style")
|
||||
# tunnel, all on one router.
|
||||
#
|
||||
# laptop -----+ +--- wg-rw (dual-stack, zone vpn)
|
||||
# siteb ------+-- vlan 1 -- router -- vlan 2 -- main LAN (zone trusted)
|
||||
# isp/provider+ +--- wg-s2s (no transfer net, zone remote)
|
||||
# +--- wg-ff (dual-stack, zone freifunk)
|
||||
#
|
||||
# rules: vpn -> trusted, trusted -> remote, vpn -> remote, trusted -> freifunk
|
||||
#
|
||||
# The keys below are throwaway test keys, generated for this test only.
|
||||
{ self, pkgs }:
|
||||
let
|
||||
inherit (pkgs) lib;
|
||||
common = import ./common.nix;
|
||||
|
||||
key = {
|
||||
router-rw = {
|
||||
priv = "wHjLTos8CeZxnCPWPKlZjlzDuer2uyhhNbXR6BYYkXY=";
|
||||
pub = "7BBr4sUxdMI9OlHcEUY+sg5eh8fbeo2nZzSXEI7IJQQ=";
|
||||
};
|
||||
router-s2s = {
|
||||
priv = "CM1gl4vlKo7iULJ07wykWxrHke4YTahZq+LCakFmT0g=";
|
||||
pub = "8/XG5fihWpnHQWQ/cKEjCBZvtk+DmBK/05gR26/Wfzo=";
|
||||
};
|
||||
router-ff = {
|
||||
priv = "2FFVahDzQTlzIpHmX95WeliWpSFKGVlfvJ6iC9iqA1Q=";
|
||||
pub = "lgTbki7gCrRFQa1WmV2IVlocWDMh5Fta0S2+DfVF93k=";
|
||||
};
|
||||
laptop = {
|
||||
priv = "uEWQDRJkpi7JFThKSdLASqd+Z8+2buzzEdcehNXU9Uc=";
|
||||
pub = "ru65s6+UROJ6CDfU15MS83Wlq1jYOJ5Leus1+3FkWmo=";
|
||||
};
|
||||
siteb = {
|
||||
priv = "WPqvH1WTD2nqq/+Ja474J79PAEUNomSVNvUvuQIAw1Q=";
|
||||
pub = "VPHx2uwfYY8BVeZRMUHRBAyFJ+CUnulnlkvzibBGSXo=";
|
||||
};
|
||||
isp = {
|
||||
priv = "sNot2jWhW+MJKBVScvm56FiA0Ekt/DfrFfKYXpK5RUQ=";
|
||||
pub = "7MkFXOxlCsmXm98uv0txvYzx5Ce80mkg2mDsswY4FyI=";
|
||||
};
|
||||
};
|
||||
|
||||
# Plain (non-router) hosts: static address on the shared segment plus one
|
||||
# WireGuard device configured straight through networkd.
|
||||
segmentHost =
|
||||
{
|
||||
address,
|
||||
wgName,
|
||||
wgKey,
|
||||
listenPort ? null,
|
||||
wgAddresses ? [ ],
|
||||
peer,
|
||||
}:
|
||||
{ lib, ... }:
|
||||
{
|
||||
virtualisation.vlans = [ 1 ];
|
||||
networking.useNetworkd = true;
|
||||
networking.useDHCP = false;
|
||||
networking.firewall.enable = false;
|
||||
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
|
||||
|
||||
environment.etc."wg/${wgName}.key" = {
|
||||
text = wgKey.priv;
|
||||
mode = "0400";
|
||||
# These hosts use plain networkd, which reads the key itself.
|
||||
user = "systemd-network";
|
||||
group = "systemd-network";
|
||||
};
|
||||
systemd.network.networks."10-seg" = {
|
||||
matchConfig.Name = "eth1";
|
||||
address = [ "${address}/24" ];
|
||||
};
|
||||
systemd.network.netdevs."30-${wgName}" = {
|
||||
netdevConfig = {
|
||||
Kind = "wireguard";
|
||||
Name = wgName;
|
||||
};
|
||||
wireguardConfig = {
|
||||
PrivateKeyFile = "/etc/wg/${wgName}.key";
|
||||
}
|
||||
// lib.optionalAttrs (listenPort != null) { ListenPort = listenPort; };
|
||||
wireguardPeers = [ peer ];
|
||||
};
|
||||
systemd.network.networks."30-${wgName}" = {
|
||||
matchConfig.Name = wgName;
|
||||
address = wgAddresses;
|
||||
routes = map (ip: { Destination = ip; }) peer.AllowedIPs;
|
||||
};
|
||||
};
|
||||
|
||||
dummy = name: addresses: {
|
||||
systemd.network.netdevs."20-${name}".netdevConfig = {
|
||||
Kind = "dummy";
|
||||
Name = name;
|
||||
};
|
||||
systemd.network.networks."20-${name}" = {
|
||||
matchConfig.Name = name;
|
||||
address = addresses;
|
||||
};
|
||||
};
|
||||
|
||||
echo = {
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
virtualHosts.default = {
|
||||
default = true;
|
||||
locations."/".return = ''200 "$remote_addr"'';
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
pkgs.testers.runNixOSTest {
|
||||
name = "router-wireguard";
|
||||
|
||||
defaults.imports = [ common.tools ];
|
||||
interactive.defaults = common.interactiveDefaults;
|
||||
|
||||
nodes = {
|
||||
# ISP, DHCP server for the router, and the VPN provider: the tunnel leads
|
||||
# to 198.18.0.1 / fd00:ff::1, which are also reachable via the WAN on this
|
||||
# very host (so a leak past the kill switch would succeed).
|
||||
isp = {
|
||||
imports = [
|
||||
(segmentHost {
|
||||
address = "203.0.113.1";
|
||||
wgName = "wg-ff";
|
||||
wgKey = key.isp;
|
||||
listenPort = 51822;
|
||||
wgAddresses = [
|
||||
"10.200.0.1/24"
|
||||
"fd42:200::1/64"
|
||||
];
|
||||
peer = {
|
||||
PublicKey = key.router-ff.pub;
|
||||
AllowedIPs = [
|
||||
"10.200.0.2/32"
|
||||
"fd42:200::2/128"
|
||||
];
|
||||
};
|
||||
})
|
||||
(dummy "ff0" [
|
||||
"198.18.0.1/24"
|
||||
"fd00:ff::1/128"
|
||||
])
|
||||
echo
|
||||
];
|
||||
services.dnsmasq = {
|
||||
enable = true;
|
||||
settings = {
|
||||
interface = "eth1";
|
||||
bind-interfaces = true;
|
||||
dhcp-range = "203.0.113.100,203.0.113.100,255.255.255.0,1h";
|
||||
dhcp-option = [ "option:router,203.0.113.1" ];
|
||||
address = "/example.test/203.0.113.1";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
router =
|
||||
{ lib, ... }:
|
||||
{
|
||||
imports = [ self.nixosModules.router ];
|
||||
virtualisation.vlans = [
|
||||
1
|
||||
2
|
||||
];
|
||||
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
|
||||
networking.interfaces.eth2.ipv4.addresses = lib.mkForce [ ];
|
||||
|
||||
environment.etc = lib.listToAttrs (
|
||||
map
|
||||
(n: {
|
||||
name = "wg/${n}.key";
|
||||
value = {
|
||||
text = key."router-${n}".priv;
|
||||
mode = "0600";
|
||||
};
|
||||
})
|
||||
[
|
||||
"rw"
|
||||
"s2s"
|
||||
"ff"
|
||||
]
|
||||
);
|
||||
|
||||
router = {
|
||||
enable = true;
|
||||
wan.interface = "eth1";
|
||||
lans.main = {
|
||||
interface = "eth2";
|
||||
zone = "trusted";
|
||||
internet = true;
|
||||
ipv4.address = "192.168.10.1";
|
||||
dhcp.pool = {
|
||||
start = "192.168.10.100";
|
||||
end = "192.168.10.199";
|
||||
};
|
||||
};
|
||||
dns.forwarders = [ "203.0.113.1" ];
|
||||
|
||||
wireguard = {
|
||||
rw = {
|
||||
privateKeyFile = "/etc/wg/rw.key";
|
||||
listenPort = 51820;
|
||||
addresses = [
|
||||
"10.100.0.1/24"
|
||||
"fd42:100::1/64"
|
||||
];
|
||||
zone = "vpn";
|
||||
allowManagement = true;
|
||||
dns = true;
|
||||
peers.laptop = {
|
||||
publicKey = key.laptop.pub;
|
||||
allowedIPs = [
|
||||
"10.100.0.2/32"
|
||||
"fd42:100::2/128"
|
||||
];
|
||||
};
|
||||
};
|
||||
# Site-to-site: no transfer network, routing by allowedIPs alone.
|
||||
s2s = {
|
||||
privateKeyFile = "/etc/wg/s2s.key";
|
||||
listenPort = 51821;
|
||||
zone = "remote";
|
||||
peers.siteb = {
|
||||
publicKey = key.siteb.pub;
|
||||
endpoint = "203.0.113.60:51821";
|
||||
allowedIPs = [ "192.168.50.0/24" ];
|
||||
persistentKeepalive = 5;
|
||||
};
|
||||
};
|
||||
# Provider tunnel: only the listed destinations go through it.
|
||||
ff = {
|
||||
privateKeyFile = "/etc/wg/ff.key";
|
||||
zone = "freifunk";
|
||||
masquerade = true;
|
||||
addresses = [
|
||||
"10.200.0.2/32"
|
||||
"fd42:200::2/128"
|
||||
];
|
||||
peers.provider = {
|
||||
publicKey = key.isp.pub;
|
||||
endpoint = "203.0.113.1:51822";
|
||||
allowedIPs = [
|
||||
# The provider's own transfer addresses, plus what it serves.
|
||||
"10.200.0.1/32"
|
||||
"fd42:200::1/128"
|
||||
"198.18.0.0/24"
|
||||
"fd00:ff::/32"
|
||||
];
|
||||
persistentKeepalive = 5;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
firewall.rules = [
|
||||
{
|
||||
from = "vpn";
|
||||
to = "trusted";
|
||||
}
|
||||
{
|
||||
from = "trusted";
|
||||
to = "remote";
|
||||
}
|
||||
{
|
||||
from = "vpn";
|
||||
to = "remote";
|
||||
}
|
||||
{
|
||||
from = "trusted";
|
||||
to = "freifunk";
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
# See basic.nix: relax Unbound for the made-up test domain only.
|
||||
services.unbound.settings.server = {
|
||||
local-zone = [ "test. transparent" ];
|
||||
domain-insecure = [ "example.test" ];
|
||||
};
|
||||
};
|
||||
|
||||
laptop = segmentHost {
|
||||
address = "203.0.113.50";
|
||||
wgName = "wg-rw";
|
||||
wgKey = key.laptop;
|
||||
wgAddresses = [
|
||||
"10.100.0.2/24"
|
||||
"fd42:100::2/64"
|
||||
];
|
||||
peer = {
|
||||
PublicKey = key.router-rw.pub;
|
||||
Endpoint = "203.0.113.100:51820";
|
||||
PersistentKeepalive = 5;
|
||||
AllowedIPs = [
|
||||
"10.100.0.0/24"
|
||||
"192.168.10.0/24"
|
||||
"192.168.50.0/24"
|
||||
"fd42:100::/64"
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
siteb = {
|
||||
imports = [
|
||||
(segmentHost {
|
||||
address = "203.0.113.60";
|
||||
wgName = "wg-s2s";
|
||||
wgKey = key.siteb;
|
||||
listenPort = 51821;
|
||||
peer = {
|
||||
PublicKey = key.router-s2s.pub;
|
||||
Endpoint = "203.0.113.100:51821";
|
||||
PersistentKeepalive = 5;
|
||||
AllowedIPs = [
|
||||
"192.168.10.0/24"
|
||||
"10.100.0.0/24"
|
||||
];
|
||||
};
|
||||
})
|
||||
(dummy "lan0" [ "192.168.50.1/24" ])
|
||||
echo
|
||||
];
|
||||
};
|
||||
|
||||
main =
|
||||
{ lib, ... }:
|
||||
{
|
||||
virtualisation.vlans = [ 2 ];
|
||||
networking.useNetworkd = true;
|
||||
networking.useDHCP = false;
|
||||
networking.firewall.enable = false;
|
||||
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
|
||||
systemd.network.networks."20-lan" = {
|
||||
matchConfig.Name = "eth1";
|
||||
networkConfig.DHCP = "ipv4";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
start_all()
|
||||
|
||||
isp.wait_for_unit("dnsmasq.service")
|
||||
isp.wait_for_unit("nginx.service")
|
||||
siteb.wait_for_unit("nginx.service")
|
||||
|
||||
router.wait_for_unit("network-online.target")
|
||||
router.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q 203.0.113.100")
|
||||
router.wait_for_unit("nftables.service")
|
||||
router.wait_for_unit("kea-dhcp4-server.service")
|
||||
router.wait_for_unit("unbound.service")
|
||||
main.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q 192.168.10.100")
|
||||
|
||||
with subtest("all three tunnels complete a handshake"):
|
||||
for dev in ("wg-rw", "wg-s2s", "wg-ff"):
|
||||
router.wait_until_succeeds(f"wg show {dev} latest-handshakes | awk '$2 > 0' | grep -q .")
|
||||
|
||||
with subtest("road warrior: dual-stack tunnel to the router, DNS and SSH over it"):
|
||||
laptop.succeed("ping -c 1 -W 2 10.100.0.1")
|
||||
laptop.succeed("ping -6 -c 1 -W 2 fd42:100::1")
|
||||
laptop.succeed("dig +short +time=2 +tries=1 @10.100.0.1 example.test | grep -qx 203.0.113.1")
|
||||
laptop.succeed("dig +short +time=2 +tries=1 @fd42:100::1 example.test | grep -qx 203.0.113.1")
|
||||
laptop.succeed("nc -z -w 2 10.100.0.1 22")
|
||||
|
||||
with subtest("road warrior reaches the LAN, but not the other way round"):
|
||||
laptop.succeed("ping -c 1 -W 2 192.168.10.100")
|
||||
# Positive control: the LAN host can reach the router's own LAN address.
|
||||
main.succeed("ping -c 1 -W 2 192.168.10.1")
|
||||
main.fail("ping -c 1 -W 2 10.100.0.2")
|
||||
|
||||
with subtest("site-to-site: routed without NAT and without a transfer network"):
|
||||
assert router.succeed("ip -4 addr show dev wg-s2s | grep -c inet || true").strip() == "0"
|
||||
main.succeed("ping -c 1 -W 2 192.168.50.1")
|
||||
out = main.succeed("curl -sf --max-time 5 http://192.168.50.1/")
|
||||
assert out.strip() == "192.168.10.100", f"expected the LAN address to be preserved, got {out!r}"
|
||||
out = laptop.succeed("curl -sf --max-time 5 http://192.168.50.1/")
|
||||
assert out.strip() == "10.100.0.2", f"expected the tunnel address to be preserved, got {out!r}"
|
||||
|
||||
with subtest("site-to-site: the remote site cannot initiate towards the LAN"):
|
||||
# Positive control: it can reach the router itself over the tunnel.
|
||||
siteb.succeed("ping -c 1 -W 2 -I 192.168.50.1 192.168.10.1")
|
||||
siteb.fail("ping -c 1 -W 2 -I 192.168.50.1 192.168.10.100")
|
||||
|
||||
with subtest("provider tunnel: only the listed destinations, masqueraded"):
|
||||
out = main.succeed("curl -sf --max-time 5 http://198.18.0.1/")
|
||||
assert out.strip() == "10.200.0.2", f"expected masquerade to 10.200.0.2, got {out!r}"
|
||||
# Everything else still leaves via the WAN.
|
||||
out = main.succeed("curl -sf --max-time 5 http://203.0.113.1/")
|
||||
assert out.strip() == "203.0.113.100", f"expected the WAN address, got {out!r}"
|
||||
# IPv6 over the dual-stack transfer net (router-originated).
|
||||
router.succeed("ping -6 -c 1 -W 2 fd42:200::1")
|
||||
out = router.succeed("curl -sf -g --max-time 5 'http://[fd00:ff::1]/'")
|
||||
assert out.strip() == "fd42:200::2", f"expected the IPv6 transfer address, got {out!r}"
|
||||
|
||||
with subtest("zones apply to tunnels: the road warrior may not use the provider tunnel"):
|
||||
laptop.fail("curl -sf --max-time 3 http://198.18.0.1/")
|
||||
|
||||
with subtest("kill switch: no leak to the WAN when the provider tunnel is down"):
|
||||
router.succeed("ip link set dev wg-ff down")
|
||||
# The route is gone, so the destination would now follow the default route.
|
||||
router.succeed("ip route get 198.18.0.1 | grep -q 'dev eth1'")
|
||||
main.fail("curl -sf --max-time 3 http://198.18.0.1/")
|
||||
# Ordinary destinations are not affected.
|
||||
main.succeed("curl -sf --max-time 5 http://203.0.113.1/")
|
||||
'';
|
||||
}
|
||||
Reference in New Issue
Block a user