CLI that detects overlapping IP ranges across WireGuard/docker/netplan/dnsmasq configs you already have, before they collide.
You add a WireGuard peer, docker picks a bridge subnet, netplan owns the LAN and dnsmasq hands out
leases — each of them correct on its own, and one of them silently eats another's addresses.
subnet-clash reads those files, puts every range on the same table, and tells you which two lines
fight, quoting the file and line number of both.
Two threads describing exactly this, with the quotes and the dates, are in
docs/motivation.md; tests/test_scenarios.py rebuilds both from fixture
files and checks the tool finds the collision.
- Reads files only. No sockets, no
dockeroripinvocation, no look at the live system. Everything it knows comes from paths you name (or stdin), so the same inputs always give the same report, on your laptop and in CI. - Standard library for the logic, one dependency for YAML. Every range computation — reading a
CIDR, containment, intersection, summarising a start–end pool into networks — is the standard
library's
ipaddressand nothing else. The one third-party package is PyYAML, and it only parses the YAML sources (netplan), because netplan files are YAML and a YAML file deserves a YAML parser rather than a hand-written subset of one. WireGuard and dnsmasq configs, NetworkManager keyfiles (INI, not YAML) and saveddocker network inspectJSON are read with the standard library alone.
pip install subnet-clash
# or, from a checkout
pip install .Python 3.11+, and PyYAML, which pip pulls in. Installing provides the subnet-clash command;
python -m subnet_clash works too.
subnet-clash check \
--wg /etc/wireguard/wg0.conf \
--docker docker-net.json \
--netplan /etc/netplan/01-netcfg.yaml \
--nm /etc/NetworkManager/system-connections/lab-eth.nmconnection \
--dnsmasq /etc/dnsmasq.conf| flag | source | what is read |
|---|---|---|
--wg |
WireGuard config | [Interface] Address, [Peer] AllowedIPs |
--docker |
saved docker network inspect JSON |
every IPAM.Config[].Subnet and .IPRange |
--netplan |
netplan YAML | per-device addresses (plain and address-options form), static routes[].to |
--nm |
NetworkManager keyfile | [ipv4]/[ipv6] addressN, routeN |
--dnsmasq |
dnsmasq config | dhcp-range= pools (start–end, or a base address with a netmask; modes, broadcast addresses and lease times are skipped) |
Every flag is repeatable, and - reads that source from stdin (once per run):
docker network inspect $(docker network ls -q) | subnet-clash check --docker - --wg /etc/wireguard/wg0.confOther options: --format markdown|json (default markdown), -o FILE, --no-default-check,
--include-default-routes. subnet-clash defaults prints the table of well-known default ranges.
Running against the fixtures in tests/fixtures/:
subnet-clash check --wg wg0.conf --docker docker-net.json \
--netplan 01-netcfg.yaml --dnsmasq dnsmasq.conf**11 clash(es)** across 12 range(s): 2 overlap, 2 identical, 7 contains.
### 1. overlap: `01-netcfg.yaml:8` vs `dnsmasq.conf:6`
- **A** `192.168.50.1/24` - netplan enp3s0 address - `01-netcfg.yaml:8` (`network.ethernets.enp3s0.addresses[0]`)
- **B** `192.168.50.200-192.168.51.50` - dnsmasq dhcp-range - `dnsmasq.conf:6` (`dhcp-range`)
- shared: `192.168.50.200/29`, `192.168.50.208/28`, `192.168.50.224/27`
- partial overlap - each side also owns addresses the other does not; almost always a bug
### 4. identical: `docker-net.json:36` vs `wg0.conf:3`
- **A** `10.8.0.0/24` - docker network "lab" Subnet - `docker-net.json:36` (`$[1].IPAM.Config[0].Subnet`)
- **B** `10.8.0.1/24` - wg0 [Interface] Address - `wg0.conf:3` (`[Interface].Address`)
- shared: `10.8.0.0/24`
- the two ranges are exactly the same block
### 7. contains: `dnsmasq.conf:9` vs `wg0.conf:3`
- **A** `10.8.0.100-10.8.0.150` - dnsmasq dhcp-range - `dnsmasq.conf:9` (`dhcp-range`)
- **B** `10.8.0.1/24` - wg0 [Interface] Address - `wg0.conf:3` (`[Interface].Address`)
- shared: `10.8.0.100/30`, `10.8.0.104/29`, ...
- A is fully inside B
- one range sits entirely inside the other - frequently deliberate (...)Both sides of every finding carry a file and a line. For JSON sources the line is accompanied by
the key path that produced it ($[1].IPAM.Config[0].Subnet), and for YAML by the document path
(network.ethernets.enp3s0.addresses[0]).
Not every intersection is a bug, so they are reported as three different kinds:
| kind | meaning | how worried to be |
|---|---|---|
| overlap | the two ranges share addresses, and each one also owns addresses the other does not | high — nobody configures this on purpose; a DHCP pool running past the end of its LAN, or two networks half-covering each other |
| identical | both sides describe exactly the same block | high — two tools believe they own the same addresses |
| contains | one range sits entirely inside the other (10.1.2.0/24 inside 10.0.0.0/8) |
often intentional: a DHCP pool inside its LAN, a WireGuard peer's /32 inside the tunnel subnet, a route into a larger aggregate. Read these, do not panic at them |
Two plain CIDRs can only ever be nested — CIDR blocks never half-overlap. A true overlap therefore
only shows up once a range is not a single prefix, which is exactly what a dnsmasq pool
(192.168.50.200,192.168.51.50) is: an arbitrary start–end span that gets summarised into several
networks. That is also the case most likely to be a genuine mistake.
Ranges are only compared across different logical configuration units, so the tool does not report a config for being internally consistent:
| source | unit | not compared with itself |
|---|---|---|
| WireGuard | the interface, and each peer separately | [Interface] Address vs its own peers' AllowedIPs — peers are supposed to live in the tunnel subnet. Two peers of the same tunnel overlapping is reported: that breaks routing |
| docker | one network per Name |
Subnet vs its own IPRange — the pool is carved out of the subnet by design |
| netplan | one device | a device's addresses vs its own routes |
| NetworkManager | one connection file, per address family | — |
| dnsmasq | each dhcp-range= line |
— |
0.0.0.0/0 and ::/0 are put aside instead of compared: a full-tunnel AllowedIPs = 0.0.0.0/0
contains every other range on the table and would bury the report. They are listed in a separate
section; pass --include-default-routes to compare them anyway. Every reader hands its default
routes over for that decision — a WireGuard AllowedIPs, a NetworkManager route1=0.0.0.0/0,…
and a netplan to: default alike. netplan's default keyword means 0.0.0.0/0 or ::/0
depending on the family of the route, which is read from its via:.
Beyond clashes between your own files, subnet-clash warns when a range is one of the defaults
that tools hand out unprompted — the ones most likely to collide with a machine you have not looked
at yet. The rule for this table: a row exists only if it links to a document that describes the
range. No link, no row.
| range | kind | what uses it | source |
|---|---|---|---|
172.17.0.0/16 |
assignment | Docker: first default address pool; the default bridge network lands here |
https://docs.docker.com/engine/network/ |
172.18.0.0/16 |
pool | Docker: second default address pool (user-defined bridges) | https://docs.docker.com/engine/network/ |
172.19.0.0/16 |
pool | Docker: third default address pool | https://docs.docker.com/engine/network/ |
172.20.0.0/14 |
pool | Docker: fourth default address pool | https://docs.docker.com/engine/network/ |
172.24.0.0/14 |
pool | Docker: fifth default address pool | https://docs.docker.com/engine/network/ |
172.28.0.0/14 |
pool | Docker: sixth default address pool | https://docs.docker.com/engine/network/ |
192.168.0.0/16 |
pool | Docker: last default address pool, carved into /20s | https://docs.docker.com/engine/network/ |
10.0.0.0/24 |
assignment | Docker Swarm: lowest /24 of the 10.0.0.0/8 global-scope default pool that overlay networks are carved out of |
https://github.com/moby/moby/blob/master/daemon/libnetwork/ipamutils/utils.go |
10.96.0.0/12 |
assignment | Kubernetes: kubeadm default service CIDR (--service-cidr) |
https://kubernetes.io/docs/reference/setup-tools/kubeadm/kubeadm-init/ |
10.244.0.0/16 |
assignment | Flannel: default pod network for the documented kube-flannel manifest | https://github.com/flannel-io/flannel/blob/master/Documentation/kubernetes.md |
192.168.122.0/24 |
assignment | libvirt: the default NAT network created on install (virbr0, 192.168.122.1/255.255.255.0) |
https://wiki.libvirt.org/VirtualNetworking.html |
192.168.1.0/24 |
assignment | pfSense: the LAN network of a fresh install (192.168.1.1, mask 255.255.255.0); the classic home-router LAN | https://docs.netgate.com/pfsense/en/latest/network/subnets.html |
192.168.56.0/24 |
assignment | VirtualBox: default host-only network | https://www.virtualbox.org/manual/topics/networkingdetails.html |
100.64.0.0/10 |
pool | Shared address space (CGNAT); Tailscale assigns node addresses from it | https://tailscale.com/kb/1015/100.x-addresses |
10.192.122.0/24 |
assignment | WireGuard: the example tunnel subnet in the wg-quick(8) man page | https://man7.org/linux/man-pages/man8/wg-quick.8.html |
192.168.0.0/24 |
assignment | dnsmasq: the example dhcp-range=192.168.0.50,192.168.0.150,12h from dnsmasq(8) |
https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html |
169.254.0.0/16 |
assignment | IPv4 link-local autoconfiguration, the 169.254/16 prefix; never assign it by hand | https://www.rfc-editor.org/rfc/rfc3927 |
fe80::/10 |
assignment | IPv6 link-local addressing (RFC 4291 §2.5.6) | https://www.rfc-editor.org/rfc/rfc4291 |
kind is assignment for a network some tool actually creates, and pool for a block a tool
auto-allocates smaller networks out of.
The rule above is the whole filter, and it is a filter on evidence, not on how common a range
is. 10.0.0.0/24 and 192.168.1.0/24 are the two ranges most people would name first, and both
are in the table because a published document states them: moby's ipamutils splits the
10.0.0.0/8 global-scope pool into /24s starting at 10.0.0.0/24, and Netgate's documentation
states the network address of a fresh pfSense LAN. What stays out is the folklore that nobody
publishes — 192.168.2.0/24 as "the second router", 10.10.10.0/24 as "the lab" — because
there is no vendor document to point at, only habit. The same goes for a whole RFC 1918 block:
192.168.0.0/16 earns a row as Docker's last default pool, not as a range anything assigns.
If you have a citable source for a range that is missing, add the row to
src/subnet_clash/defaults.py with the link — that is all it takes.
| code | meaning |
|---|---|
0 |
no clashes (warnings about default ranges or undetermined ranges do not change this) |
1 |
at least one clash found |
2 |
unusable input — missing file, malformed JSON/YAML, invalid CIDR, non-UTF-8 bytes (file or stdin), a document nested deeper than the parser's stack, no sources given — with the file and line on stderr |
The split matters in a pipeline: 1 always means "something was found", never "the input could
not be read", so docker network inspect ... | subnet-clash check --docker - on garbage bytes
exits 2, not 1.
$ subnet-clash check --netplan broken.yaml
subnet-clash: error: broken.yaml:2: invalid YAML: while scanning for the next token: found character '\t' that cannot start any token
$ echo $?
2Nothing reaches you as a traceback, including the input that breaks the parser rather than the grammar: both parsers underneath are recursive, so a file that opens a few thousand sequences without closing them runs out of stack, and that is reported like any other unusable file.
$ subnet-clash check --netplan generated.yaml
subnet-clash: error: generated.yaml: nests too deeply to parse (more levels than the parser's stack allows)
$ echo $?
2A file that parses cleanly but contains no ranges at all is not an error — an empty config is legal — but it is named on stderr, because that is also what a half-read file looks like:
$ subnet-clash check --wg keys-only.conf
subnet-clash: warning: keys-only.conf: read as wireguard, no ranges found--format json emits sources, summary, every parsed ranges entry, clashes,
default_range_warnings, skipped_default_routes and undetermined_ranges. Each clash carries both sides with file, line, key and location,
plus the intersection networks:
{
"kind": "contains",
"container": "a",
"intersection": ["172.17.5.0/24"],
"a": { "source": "docker", "role": "docker-subnet", "name": "docker network \"bridge\" Subnet",
"range": "172.17.0.0/16", "networks": ["172.17.0.0/16"],
"location": "docker-net.json:14",
"file": "docker-net.json", "line": 14, "key": "$[0].IPAM.Config[0].Subnet" },
"b": { "source": "wireguard", "role": "peer-allowedips", "name": "wg0 [Peer #2] AllowedIPs",
"range": "172.17.5.0/24", "networks": ["172.17.5.0/24"],
"location": "wg0.conf:15",
"file": "wg0.conf", "line": 15, "key": "[Peer].AllowedIPs", "undetermined": null }
}For a contains clash, container names the side that holds the other: "a" here says the
docker /16 is the outer range and the WireGuard /24 sits inside it.
dhcp-range=10.60.0.0,static names one address and no netmask: dnsmasq takes the prefix from the
interface it serves the range on, and v0.1 never looks at an interface. Calling that a /32 would
be inventing a range — and the invented /32 sits inside any LAN containing the address, so it
would manufacture a contains finding on every run. Dropping the line would be worse: a real pool
would vanish from the comparison.
So the line is read, kept, and marked: it is listed under ## Undetermined ranges in the Markdown
report and under undetermined_ranges in JSON (with the reason in each range's undetermined
field), a warning names it on stderr, and it is compared with nothing.
$ subnet-clash check --dnsmasq dnsmasq.conf
subnet-clash: warning: dnsmasq.conf:2: dhcp-range names one address and no netmask, so its size comes from the interface; reported as an undetermined range instead of being guessed atGive the line its netmask (dhcp-range=10.60.0.0,static,255.255.255.0) and it becomes an ordinary
range that is compared like any other.
The fields of a dhcp-range are read in the order dnsmasq documents them —
<start-addr>[,<end-addr>|<mode>][,<netmask>[,<broadcast>]][,<lease time>] — so a longer spelling
of a pool describes the same pool. In
dhcp-range=192.168.90.0,static,255.255.255.0,192.168.90.255, 255.255.255.0 is the netmask and
192.168.90.255 the broadcast address; neither is the end of the pool, which static says this
line does not give. The pool is 192.168.90.0/24.
A comment does not change a line either. dnsmasq ends a config line at the first unquoted #, so
dhcp-range=10.60.0.0,static,255.255.255.0 # tftp clients is read exactly like the line without
the note, and a pool is never turned into an undetermined one by what someone wrote to the right
of it.
-
It never fixes anything: no config is written or suggested.
-
No live monitoring and no reading of system state — if you want the current docker networks, save
docker network inspectto a file and pass it in. -
Windows networking and VPNs other than WireGuard are out of scope.
-
dnsmasq
conf-file/conf-dirincludes are not followed; name those files yourself. -
netplan files go through PyYAML, so every YAML spelling of a device is read: block and flow mappings, block and flow sequences (including a flow sequence broken over several lines), and anchors and aliases, which are resolved — an aliased address is reported at the line where the anchor writes it out. Three things are still refused with exit code 2 rather than half-read: a file holding more than one document, a duplicate mapping key, and a merge key (
<<: *defaults, which the composer does not expand for us — ignoring it would drop whatever it carried). Concatenating two netplan files into one is therefore an error (duplicatenetwork:), not a silent read of the last one — pass each file with its own--netplan. -
NetworkManager keyfiles are INI, not YAML, and are read line by line rather than with
configparser, which does not report the line a value sits on. -
Both documented
addresses:forms are read: the plain- 10.100.1.38/24and the address-options one MAAS writes, where the address is the key —addresses: - 10.100.1.38/24: lifetime: 0 label: "maas"
Anything else under
addresses:orroutes:— a value that is not a list, a list item that is not an address, ato:that is not a single address — is exit code 2, never a skipped address. -
Every key under
network:is visited, not just the device groups this version knows. netplan adds device types between releases, so an unknown key holding a mapping is read as a device group — its addresses and routes are compared — and named on stderr; an unknown key that cannot hold devices (a scalar, a list) is named on stderr and ignored.version:andrenderer:are known to carry no range and pass without a word.$ subnet-clash check --netplan 90-ovs.yaml subnet-clash: warning: 90-ovs.yaml:7: 'network.ovs-bridges:' is not a netplan device group subnet-clash knows; read as one, so its addresses and routes are still compared
The line is the first device in that group, which is where the ranges it contributed start.
python -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
pytest -q # tests, all on file fixtures under tests/fixtures/
ruff check . && ruff format --check .
python -m build # wheel + sdistCI runs lint, the test suite on Python 3.11/3.12/3.13, and a build on every push and pull request.
Pushing a v* tag builds a wheel and an sdist and attaches them to the GitHub release.
MIT — see LICENSE.