ip-cidr-set-exclude¶
Overlays
Compute allow-networks minus deny-networks as an explicit, minimal CIDR
list — at Nix build time — and splat the result straight into WireGuard's
AllowedIPs.
The problem¶
For split-tunnel routing you often want "send everything through the tunnel except these ranges": exclude the LAN, RFC1918 space, a management subnet, or an overlay-mesh range so that local and mesh traffic stays off the tunnel.
WireGuard has no way to express that directly. AllowedIPs is an inclusion
list with no exclude/negate syntax. To punch a hole in a broad route like
0.0.0.0/0 you must instead list the complementary set of ranges that
tile neatly around the hole:
0.0.0.0/0 minus 10.0.0.0/8
= 0.0.0.0/5, 8.0.0.0/7, 11.0.0.0/8, 12.0.0.0/6, 16.0.0.0/4, 32.0.0.0/3, ...
Working that arithmetic out by hand — and re-doing it every time the deny list changes — is exactly the kind of thing that ends with a subtly wrong prefix and a routing leak. This recipe derives it deterministically.
The insight¶
The fiddly part (splitting a network around an excluded subnet) is already
solved by Python's standard-library ipaddress module — address_exclude
returns the minimal set of prefixes covering A \ B. So the subtraction runs
in a tiny build-time derivation (a runCommand invoking Python), and the
Nix side just feeds it comma-joined inputs and parses the one-line output back
into a list. No runtime dependency, no Python on the target host — the CIDR
list is baked into the closure.
Usage¶
Import it with a pkgs:
let tools = import ./ip-cidr-set-exclude { inherit pkgs; };
in {
networking.wireguard.interfaces.wg0.peers = [{
# ... publicKey, endpoint ...
allowedIPs = tools.buildIpList
"0.0.0.0/0,::/0" # allow: everything
"10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.0/8"; # deny: private + loopback
}];
}
Both arguments accept either a comma-joined string or a Nix list of strings, so this is equivalent:
buildIpList returns a [ "cidr" "cidr" ... ] list. Bare addresses are
widened to /32 (IPv4) or /128 (IPv6). IPv4 and IPv6 are handled
independently and IPv4 ranges come first.
As an overlay¶
Drop the directory onto your overlay list to expose pkgs.ip-mask (a CLI) and
pkgs.buildIpList:
As a CLI¶
pkgs.ip-mask wraps the same logic for interactive use:
Run it with no arguments and it prompts for the allow and deny lists.
Caveats and traps¶
- Trailing newline. The Python step prints one trailing
\n.buildIpListstrips it before splitting on", "— if you reimplement the parse, do the same, otherwise the last element carries a\nand WireGuard rejects the wholeAllowedIPsline. splitreturns interleaved groups.builtins.split ", "yields the matched separators as nested lists between the strings; the finalfilterkeeps only the string elements. Don't drop it.- Partial overlaps are enumerated host-by-host. When a deny range only
partially overlaps an allow range (rather than being fully contained in
it), the fallback walks individual host addresses and emits
/32s. Keep partial overlaps small — don't straddle a huge allow range with a deny range drawn from a different block, or you'll generate an enormous list and a slow build. In practice, keep every deny entry a subnet of some allow entry (the common case: carving private blocks out of0.0.0.0/0) and this path never triggers. - Empty result fails the build. If the deny list swallows the entire allow
list, the Python step exits non-zero (
No IPs are allowed) and the derivation fails — a loud failure instead of a silently emptyAllowedIPs. - Build-time only. The subtraction is computed when the config is built, so changing the deny list means a rebuild. That's the point: the routing table is pinned in the closure, not recomputed on the host.
Files¶
default.nix— the overlay/helper (ip-maskCLI +buildIpListfunction), with the Python subtraction step inlined.
Source¶
overlays/ip-cidr-set-exclude/default.nix
# ip-cidr-set-exclude
#
# Compute a minimal CIDR list as a set-difference (allow-networks minus
# deny-networks) at BUILD TIME. This is the clean way to derive WireGuard
# `AllowedIPs` for split-tunnel routing that must carve holes out of a broad
# route (e.g. route everything through the tunnel EXCEPT LAN / RFC1918 /
# a management subnet) without hand-maintaining the CIDR arithmetic.
#
# WireGuard's `AllowedIPs` has no "exclude" syntax: a hole in `0.0.0.0/0`
# can only be expressed as the complementary set of ranges that tile around
# it. Doing that by hand is error-prone; this does it deterministically.
#
# Two things are exported:
# * `ip-mask` — a CLI (`ip-mask <allow> <deny>`) for interactive use.
# * `buildIpList allow deny` — a Nix function returning a `[ "cidr" ... ]`
# list you can splat straight into `AllowedIPs`.
#
# Usage as a plain helper:
# let tools = import ./ip-cidr-set-exclude { inherit pkgs; };
# in tools.buildIpList "0.0.0.0/0" "10.0.0.0/8,192.168.0.0/16,127.0.0.0/8"
# # => [ "0.0.0.0/5" "8.0.0.0/7" ... ] (0/0 minus the private ranges)
#
# Usage as an overlay (put this whole dir on your overlay list and wrap):
# final: prev: (import ./ip-cidr-set-exclude { pkgs = prev; })
# # then `pkgs.ip-mask` and `pkgs.buildIpList` are available.
#
# Both allow/deny arguments accept either a comma-joined string
# ("0.0.0.0/0,::/0") or a Nix list of strings ([ "0.0.0.0/0" "::/0" ]).
# Bare addresses are widened to /32 (v4) or /128 (v6). IPv4 and IPv6 are
# handled independently and IPv4 ranges are emitted first.
{ pkgs }:
with builtins;
let
# The set-subtraction itself lives in a small Python step: Python's
# `ipaddress` module already knows how to split a network around an
# excluded subnet (`address_exclude`), which is the fiddly part.
ipMaskPy = pkgs.writeText "ip-mask.py" ''
import ipaddress
import sys
def parse_ip_networks(ip_list_str):
ip_list = ip_list_str.split(",")
networks = []
invalid_ip_addresses = []
for ip in ip_list:
ip = ip.strip()
if not ip:
continue
try:
if "/" in ip:
networks.append(ipaddress.ip_network(ip, strict=False))
else:
ip_obj = ipaddress.ip_address(ip)
if ip_obj.version == 4:
networks.append(ipaddress.ip_network(f"{ip}/32", strict=False))
else:
networks.append(ipaddress.ip_network(f"{ip}/128", strict=False))
except ValueError:
invalid_ip_addresses.append(ip)
return networks, invalid_ip_addresses
def get_input_and_parse(prompt):
while True:
user_input = input(prompt)
networks, invalid_ip_addresses = parse_ip_networks(user_input)
if not invalid_ip_addresses:
break
print("Invalid IPs or subnets: " + ", ".join(invalid_ip_addresses))
print("Please try again. Ctrl+C to exit.")
return networks
def exclude_networks(allowed_networks, disallowed_networks):
remaining_networks = set(allowed_networks)
for disallowed in disallowed_networks:
new_remaining_networks = set()
for allowed in remaining_networks:
if allowed.version == disallowed.version:
if disallowed.subnet_of(allowed):
# disallowed sits inside allowed: split allowed around it
new_remaining_networks.update(allowed.address_exclude(disallowed))
elif allowed.overlaps(disallowed):
# partial overlap
new_remaining_networks.update(
handle_partial_overlap(allowed, disallowed)
)
else:
# no overlap: keep as-is
new_remaining_networks.add(allowed)
else:
# different IP versions never overlap: keep as-is
new_remaining_networks.add(allowed)
remaining_networks = new_remaining_networks
return remaining_networks
def handle_partial_overlap(allowed, disallowed):
# Return the non-overlapping portion of `allowed`. This enumerates
# host addresses, so keep partial overlaps small (do not straddle
# a huge allowed range with a tiny deny range from a different block).
non_overlapping_networks = []
allowed_ips = list(allowed.hosts())
disallowed_ips = set(disallowed.hosts())
allowed_ips = [ip for ip in allowed_ips if ip not in disallowed_ips]
if not allowed_ips:
return non_overlapping_networks
for ip in allowed_ips:
if ip.version == 4:
non_overlapping_networks.append(
ipaddress.ip_network(f"{ip}/32", strict=False)
)
else:
non_overlapping_networks.append(
ipaddress.ip_network(f"{ip}/128", strict=False)
)
return non_overlapping_networks
def sort_networks(networks):
"""All IPv4 first, then IPv6, each from lowest to highest."""
ipv4 = []
ipv6 = []
for net in networks:
if net.version == 4:
ipv4.append(net)
else:
ipv6.append(net)
ipv4_sorted = sorted(ipv4, key=lambda ip: ip.network_address)
ipv6_sorted = sorted(ipv6, key=lambda ip: ip.network_address)
return ipv4_sorted + ipv6_sorted
def main(unittest=False):
allowed_input = ""
disallowed_input = ""
allowed_networks = []
disallowed_networks = []
if len(sys.argv) == 3:
allowed_input = sys.argv[1]
disallowed_input = sys.argv[2]
elif len(sys.argv) == 2:
disallowed_input = sys.argv[1]
else:
print("Wrong number of arguments provided, falling back to interactive mode.")
allowed_input = ""
disallowed_input = ""
if allowed_input:
allowed_networks, invalid_allowed = parse_ip_networks(allowed_input)
if invalid_allowed:
print("Invalid Allowed IPs: " + ", ".join(invalid_allowed))
allowed_networks = []
if disallowed_input:
disallowed_networks, invalid_disallowed = parse_ip_networks(disallowed_input)
if invalid_disallowed:
print("Invalid Disallowed IPs: " + ", ".join(invalid_disallowed))
disallowed_networks = []
if not allowed_networks and not len(sys.argv) == 2:
allowed_networks = get_input_and_parse(
"Enter the Allowed IPs, comma separated (e.g., 0.0.0.0/0):\n"
)
if not disallowed_networks:
disallowed_networks = get_input_and_parse(
"Enter the Disallowed IPs, comma separated "
"(e.g., 10.0.0.0/8,127.0.0.0/8,172.16.0.0/12,192.168.0.0/16):\n"
)
excluded_allowed_networks = exclude_networks(allowed_networks, disallowed_networks)
sorted_networks = sort_networks(excluded_allowed_networks)
if not sorted_networks:
print("Error: No IPs are allowed based on the provided input.")
sys.exit(1)
print(", ".join(map(str, sorted_networks)))
if unittest:
return sorted_networks
if __name__ == "__main__":
main()
'';
# Build-time subtraction: run the Python step in a derivation and capture
# its single-line, comma-separated output.
buildIp =
allowList: denyList:
pkgs.runCommand "wg-ip" { nativeBuildInputs = [ pkgs.python3 ]; } ''
${pkgs.python3}/bin/python ${ipMaskPy} ${allowList} ${denyList} > $out
'';
# Accept either a Nix list of CIDRs or an already-comma-joined string.
buildList = x: if typeOf x == "string" then x else concatStringsSep "," x;
in
{
# CLI: `ip-mask <allow> <deny>` (or run with no args for interactive mode).
ip-mask = pkgs.writeShellScriptBin "ip-mask" ''
exec ${pkgs.python3}/bin/python3 ${ipMaskPy} "$@"
'';
# `buildIpList allow deny` -> [ "cidr" ... ], ready for AllowedIPs.
#
# Trap: the Python prints one trailing newline; strip it BEFORE splitting on
# ", ", otherwise the final element carries a "\n" and WireGuard rejects it.
buildIpList =
allowList: denyList:
let
output = readFile (buildIp (buildList allowList) (buildList denyList));
takeOutEndline = x: substring 0 ((stringLength x) - 1) x;
sanitized = takeOutEndline output;
in
# `split` yields interleaved match groups (lists); keep only the strings.
filter (x: (typeOf x == "string")) (split ", " sanitized);
}