fd00:: Is Not Your 192.168.0.0/16

Code: github.com/mabels/mseg-tester

The setup

The home network this blog keeps coming back to — the OVN/OVS fabric from ovn-fabric, tested continuously by mseg-tester — runs on two independent internet uplinks, a cable connection through Vodafone and a Starlink dish, with a couple of VPN uplinks (Mullvad, Zerotier) layered on top for good measure. No single one of them is allowed to be a hard dependency.

That constraint quietly decides your IPv6 addressing scheme for you, whether you think about it up front or not. The “correct,” modern way to number a LAN is with a real, globally routable prefix handed to you by your ISP via DHCPv6-PD. But a delegated prefix belongs to one ISP session. The moment Vodafone drops and Starlink takes over, that prefix stops being routable, and every host that configured an address from it needs to renumber. Doing this properly — keeping one stable prefix that survives a failover between two unrelated providers — means either running BGP with your own address space (which means an ASN, transit agreements, and complexity a home network has no business carrying), or numbering everything with an address space that was never tied to a specific uplink in the first place.

IPv6 has exactly that address space, and it has for two decades: Unique Local Addresses, fc00::/7, defined in RFC 4193. You pick a prefix, you NAT66 it out whichever uplink happens to be up, same as you would with 192.168.0.0/16 and IPv4 NAT. Stable, portable, uplink-agnostic. So that’s what got deployed: segment 128, the “home” segment, got fd00:192:168:128::/64 — a prefix chosen to be memorable, echoing the IPv4 numbering right next to it (192.168.128.0/24).

That single choice — the memorable part, not the ULA part — is what this whole post is about.

The symptom

mseg-tester reboot-cycles a VM through every VLAN and Wi-Fi network on the site and records, per segment, whether DHCP, DHCP6, DNS, and outbound routing actually work. For weeks, one specific check — routing6, a plain TCP dial to [2606:4700:4700::1111]:443, Cloudflare’s public resolver — failed intermittently, and only ever on segment 128. Same segment, same gateway, same uplink as segment 129 right next to it, which never once failed the equivalent check. IPv4 routing on 128 was rock solid the entire time. Only the IPv6 path, only on that one segment, flapped.

That asymmetry — one segment out of several, one address family out of two — is what made this an interesting bug instead of a boring one. Anything that could explain “IPv6 sometimes doesn’t route on segment 128” also had to explain why segment 129, sharing the identical OVN router config, the identical physical uplink, and the identical conntrack table one network namespace over, never saw it at all.

The hunt

Working through this with Claude, in the mseg-tester context, went through several genuinely reasonable theories, each one killed by a specific piece of evidence rather than by hand-waving:

  • L2 VLAN mixing on the native segment — dismissed immediately: a Layer 2 problem can’t explain why IPv4 always worked while IPv6 specifically failed, since MAC learning and flooding don’t know or care what’s inside the frame.
  • A flakier egress path for segment 128 specifically — killed by comparing ovn-nbctl lr-route-list output for both routers directly: router-home and router-management (129) have byte-for-byte identical default routes, through the identical uplink router, to the identical WAN. Same path, no exceptions.
  • Conntrack table pressure in the NAT namespace — checked directly with conntrack -S; modest numbers, nowhere near exhausted, and — same objection as above — segment 129 shares the exact same table and namespace without issue.

Every one of those theories died to the same question, asked repeatedly and correctly: why does segment 129 never see this? Two segments sharing a router config, a physical uplink, and a NAT namespace rule out almost everything that isn’t specific to segment 128 itself.

What actually broke the case was building a second, static test host (ref-128) that sits permanently on segment 128 and never reboots or reconfigures — unlike the mseg-tester VM itself, which cycles segments every few minutes, which made it genuinely hard to tell “real network fault” apart from “artifact of the machine itself constantly reconfiguring.” With a boring, motionless reference point running the same checks continuously, the picture got much clearer: intermittent routing6 failures, TCP connections stuck permanently in SYN_SENT, until they timed out. conntrack on the NAT box showed exactly what a dead-end connection looks like, mid-flight:

root@mam-hh-ovn:~# conntrack -L -f ipv6 | grep fd06:d22
tcp      6 21 SYN_SENT src=fd06:d22c:9920:1ae:f049:e133:8ec1:20f1 dst=2a03:2880:f250:c8:face:b00c:0:167 sport=56166 dport=443 [UNREPLIED] src=2a03:2880:f250:c8:face:b00c:0:167 dst=fd06:d22c:9920:1ae:f049:e133:8ec1:20f1 sport=443 dport=56166 mark=0 use=1
conntrack v1.4.9 (conntrack-tools): 326 flow entries have been shown.
tcp      6 108 SYN_SENT src=fd06:d22c:9920:1ae:be24:11ff:feb4:8313 dst=2606:4700:4700::1111 sport=50694 dport=443 [UNREPLIED] src=2606:4700:4700::1111 dst=fd06:d22c:9920:1ae:be24:11ff:feb4:8313 sport=443 dport=50694 mark=0 use=1

Both entries are stuck the same way: a SYN went out with an fd06:... source address, and [UNREPLIED] means exactly that — nothing ever came back, because nothing on the NAT box knows what to do with a source address from a prefix it was never told to masquerade. Not a dropped packet, not a timeout on the wire — a connection that left the house with a return address that leads nowhere.

The detail that cracked it was in the source address of those dead connections. Every host on segment 128 was carrying two IPv6 addresses in the same /64 family: the expected fd00:192:168:128::.../64, and a second one — the same device’s EUI-64 suffix, but under a completely different prefix, fd06:d22c:9920:1ae::/64. Nobody had configured that second prefix. Nothing on the network was supposed to be handing it out.

The reveal

Decoding the MAC address embedded in that stray EUI-64 address and looking up its vendor OUI pointed at two very ordinary smart-home devices already sitting on segment 128: a Nanoleaf light and a Google Home speaker. Both are, among other things, Thread Border Routers — a role that’s completely unremarkable and entirely by design. Thread devices form their own mesh, and a Border Router’s job is to make that mesh reachable from the regular LAN by publishing its own routable ULA prefix (an “OMR” prefix, in Thread’s terminology) via a standard IPv6 Router Advertisement, exactly the way any router announces a prefix for SLAAC.

Crucially — and this took a real correction mid-investigation to get right — these devices were not posing as a rogue default router. There’s no DHCPv6 on this network at all, only SLAAC; the mseg-tester check still labeled dhcp6 reports the router address a host learned from Router Advertisements, and every single one of those results, across the whole incident, consistently reported the real gateway’s link-local address, never the Thread devices’. They weren’t setting the Router Lifetime field that would make a host add them to its default router list. They were doing something narrower and, in its own way, more insidious: just advertising a prefix. Any IPv6 host listening on that segment — the mseg-tester VM, wifi-128, the new ref-128 reference box, and presumably every laptop and phone that’s ever joined that Wi-Fi network — dutifully SLAAC’d a second, perfectly valid address out of that prefix, right alongside its real one. Nothing about that step is a bug. It’s SLAAC working exactly as specified.

The bug is what happens next, the moment any of those hosts opens a connection to somewhere on the actual internet.

Why fd00 isn’t 192.168

Here’s what that actually looked like on ref-128, the static reference host. One interface, two global-scope ULA addresses, both derived from the same MAC via EUI-64:

2: ens19: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether bc:24:11:b4:83:13 brd ff:ff:ff:ff:ff:ff
    altname enp6s19
    altname enxbc2411b48313
    inet 192.168.128.200/24 metric 100 brd 192.168.128.255 scope global dynamic ens19
       valid_lft 86350sec preferred_lft 86350sec
    inet6 fd06:d22c:9920:1ae:be24:11ff:feb4:8313/64 scope global deprecated dynamic mngtmpaddr noprefixroute
       valid_lft 1688sec preferred_lft 0sec
    inet6 fd00:192:168:128:be24:11ff:feb4:8313/64 scope global mngtmpaddr noprefixroute
       valid_lft forever preferred_lft forever
    inet6 fe80::be24:11ff:feb4:8313/64 scope link proto kernel_ll
       valid_lft forever preferred_lft forever

Both inet6 lines carry scope global. The fd06 one happens to show preferred_lft 0 here — deprecated, because its own advertised lifetime was running down at the moment this was captured — but that’s a transient detail of whichever Thread Border Router last renewed it, not something the kernel enforces going forward. A few minutes earlier or later, both addresses were equally “preferred,” equally global, equally legal to hand to any application that didn’t explicitly bind a source address. Nothing at this layer marks one as ours and the other as a stranger’s.

The routing table is, if anything, more telling — this is where the earlier worry about a hijacked default route actually got settled, with real evidence instead of a theory:

fd00:192:168:128::/64 dev ens19 proto ra metric 100 pref medium
fd06:d22c:9920:1ae::/64 dev ens19 proto ra metric 100 expires 1669sec pref medium
fe80::/64 dev ens19 proto kernel metric 256 pref medium
default nhid 1278424908 via fe80::200:c0ff:fea8:8001 dev ens19 proto ra metric 100 expires 65448sec pref medium

There’s exactly one default route, and it goes where it should — fe80::200:c0ff:fea8:8001, the real gateway’s link-local address. No hijack, no second router in the default route slot. The fd06 prefix sits right next to the real fd00 one, as a perfectly ordinary, honestly-advertised on-link route — the kernel isn’t confused about how to reach it, because it’s directly attached to ens19, same as the real one.

None of that shows up as a routing problem. The ambiguity is entirely one layer up, in which source address gets attached to an otherwise perfectly correct outbound connection.

IPv6’s source address selection (RFC 6724) has to pick, among however many addresses a host has configured, which one to use as the source for a given outbound connection. It does this with an ordered list of tie-breaking rules, and one of the rules that matters here — Rule 6 — prefers a source address whose “policy label” matches the destination’s. The default policy table hands out one label, 13, to the entire fc00::/7 range. Not per prefix. Per range.

That’s the part where the IPv4 mental model quietly betrays you. 192.168.0.0/16 carries an implicit assumption most of us never examine: it’s yours, by convention, and if you ever see traffic from that range on your LAN, it came from something you configured. Nothing else on the wire is going to hand out 192.168.x.x addresses behind your back — DHCP is a single, contested, client-driven negotiation, and a second DHCP server fighting for the same range is a loud, well-understood failure mode with alarms built around it decades ago.

ULA doesn’t carry that assumption, even though on the surface it looks like it should. fd00::/8 is explicitly designed so that every network gets a different, randomly generated 48-bit prefix, specifically so two ULA networks can never collide even if you interconnect them later. But nothing stops a second device on your own segment from generating and advertising its own ULA prefix, entirely independently, entirely within spec — and the moment it does, the kernel’s policy table can’t tell the two apart. Both prefixes are fc00::/7. Both get label 13. To a destination out on the real internet (itself label 1, unrelated to either), neither ULA source matches — so Rule 6 doesn’t discriminate between them at all, and the tie gets broken by whatever the kernel’s implementation-defined fallback happens to do. Sometimes that picks the address that’s actually routable. Sometimes it picks the Thread device’s, which nothing on the network NATs, and the connection dies silently until it times out.

SLAAC also has no negotiation step, no “offer” a client can accept or reject — it’s multicast, additive, and trusted by default. A rogue DHCP server has to win a race. A rogue Router Advertisement just gets believed, unconditionally, by design.

So fd00:192:168:128::/64, chosen to be memorable the way 192.168.128.0/24 is memorable, was never actually reserved to us the way the IPv4 range felt like it was. It just hadn’t collided with anything yet.

Why the obvious fixes don’t scale

The first two fixes considered both shared the same fatal flaw: they only protect the one machine they’re applied to.

  • ip -6 addrlabel can give your own prefix a distinct policy label so Rule 6 stops tying — but it’s a per-host, Linux-only kernel knob. No equivalent exists on macOS or Windows, and there’s no way to push it to a phone, a laptop, or an IoT device you don’t control.
  • An ip6tables rule filtering foreign Router Advertisements has the identical problem: Linux-only, one host at a time.

That’s the actual, humbling shape of the problem: filtering has to happen at the real Layer 2 chokepoint for that segment, not in software several layers above it. The standard, purpose-built tool for exactly this — dropping Router Advertisements from anything but a designated router port, in the switch’s own forwarding path — is IPv6 RA Guard, RFC 6105, a feature that’s existed since 2010 for precisely this class of “someone unauthorized is speaking router on my segment” problem. Whether it’s usable here comes down to whether segment 128’s actual physical switch supports it.

The other half of the fix

RA Guard (or physically isolating the Thread devices) closes the hole at the source. But there’s a second, complementary fix worth doing regardless: stop using an address range that’s structurally indistinguishable from a stranger’s.

Escaping the fc00::/7 policy bucket means using real global unicast space — which loops back to the BGP problem from the top of this post, except it turns out not to be one. The block only needs to be globally unique, not actually routed to us; every packet leaving the house gets NAT66’d out through whichever uplink is currently up, exactly the way fd00:192:168:128::/64 does today. That means a registered-but-never-connected allocation — a /56 or /48 from a tunnel broker like Hurricane Electric or ROUTE64 — solves it with zero BGP, zero ISP involvement, and zero dependency on the tunnel itself staying up. It’s real, collision-free GUA space, sitting in the correct policy bucket, used exactly the way ULA is used today: internally, NAT’d outbound. The one thing it isn’t allowed to be is hand-picked out of thin air — squatting an unregistered chunk of 2000::/3 trades this bug for a quieter one, where your internal routing silently shadows whatever real destination happens to already live in the block you picked.

Where it stands

Root cause: confirmed, and reproducible on demand — any segment-128 host will eventually SLAAC a second address from whichever Thread Border Router happens to be advertising that hour, and eventually lose the address-selection coin flip. The fix (RA Guard at the real L2 layer, plus retiring the hand-picked fd00 prefix in favor of a properly registered block) is decided but not yet applied — this write-up is where the diagnosis caught up to the point of actually doing something about it.

The broader lesson traveled further than this one network. 192.168.0.0/16 is safe by convention: nothing else on your LAN gets to hand out addresses from it without winning a loud, contested DHCP negotiation first. fc00::/7 carries no such guarantee — any device on the segment can advertise its own slice of it, unilaterally, and the kernel has no way to tell your prefix apart from theirs.

Don’t use fd00::/8 the way you’d use 192.168.0.0/16. It looks like the same kind of address space. It isn’t.