gp-route: survive a split prefix another interface already owns - #29
Merged
Conversation
`opc connect --only …` aborted outright whenever one of the requested
prefixes was already in the routing table:
WARN gp_route: route add 172.20.0.0/16 on tun0 failed
(ip command failed: route add: RTNETLINK answers: File exists);
rolling back
error: gp-route apply: ip command failed: route add:
RTNETLINK answers: File exists
`ip route add` is NLM_F_CREATE|NLM_F_EXCL, so it returns EEXIST when
something else holds the same key. Docker's default address pool
(172.17.0.0/16 .. 172.31.0.0/16) overlaps the RFC1918 ranges corporate
gateways hand out, so this fires on any developer machine with a few
compose projects: on the reporting host, `172.20.0.0/16` sits on
`br-81f0638ae4fb` and `172.21.0.0/16` on `br-5894a3fcefae`, with seven
more bridges one corporate prefix away from the same crash.
`add` stays the fast path; only an EEXIST triggers the new work. That
is deliberate rather than conservative — it makes the kernel, not a
heuristic, decide whether a displacement is really happening. Capturing
unconditionally would be actively harmful: with `default via <gw>
metric 100` in the table, `ip route add 0.0.0.0/0 dev tun0` *succeeds*
(metric 0 is a different key, both coexist), so a blind capture would
record a default route we never displaced and revert would resurrect it
hours later, possibly onto an interface the machine has since roamed
off. Verified both branches in a netns.
On EEXIST the Linux backend now captures the existing entry, installs
with `ip route replace`, and hands the prefix back on disconnect —
`ip route replace` is what upstream vpnc-script uses for split routes.
`--route-conflict` (env `PGN_ROUTE_CONFLICT`) picks the policy:
`take-over` (default, announced at WARN naming the losing interface),
`fail` (refuse, naming the owner and the three remedies), or `skip`.
Three details the restore path depends on, each reproduced against
iproute2 before being coded:
* `ip route show` prints tokens `ip route replace` rejects. Replaying
docker0's line verbatim gives `Error: either "to" is duplicate, or
"linkdown" is a garbage.` and exits 255. `sanitize_route_entry`
strips them. This was a live latent bug in the *shipped*
gateway-pin restore, which captured verbatim — fixed here too.
* The family flag must be per-route. `ip route show exact fe80::/64`
with no flag prints nothing and exits 0 (silent capture loss);
`ip -4 route show exact fe80::/64` is a hard parse error. Since
`resolve_only_spec` already emits /128 entries from AAAA records,
`family_flag` derives it per route instead of hardcoding `-4` the
way the gateway pin did.
* Revert deletes ours (device-scoped, so it can only ever match our
own route) before restoring, because `replace` keys on
dst+metric+table: restoring alone would leave our metric-0 record
beside a restored metric-N one and quietly keep the prefix in the
tunnel.
Also fixed, same root cause: duplicate CIDRs in the route list. Two
`--only` hostnames behind one IP produced the same /32 twice and the
second `add` failed EEXIST. `dedupe_routes` collapses them before any
backend runs, comparing prefixes with host bits masked off.
Takeover is refused when more than one entry shares the prefix — `ip
route replace` would collapse them and revert could only put one back.
IPv6 makes that reachable, since the v6 route key excludes the device.
macOS gets `route change` on EEXIST (documented, and only reachable in
exactly the case `add` just failed for) but cannot restore the previous
entry: that needs parsing `route -n get`, which no CI runner here can
exercise. The warning says so plainly. Windows keys routes per
interface, so a Docker Desktop / WSL2 route on another adapter never
conflicts; the retry there is gated on "object already exists" and
names our own interface, so it can only clear our own stale entry.
`AppliedState::installed_routes` becomes `Vec<InstalledRoute>` to carry
the displaced entry. Nothing outside the crate reads it.
-- dns_pin_routes hardening (follow-up to #24) --
#24 pinned *every* pushed nameserver a split prefix did not already
cover. The resolvers it exists to serve are consumed in a scoped way
(systemd-resolved `~domain`, NRPT, /etc/resolver) but the route it
installs is global, so a gateway pushing a public resolver alongside
its internal one got that resolver forced through the corporate tunnel
for every process on the machine — on a split-tunnel gateway that does
not forward it, the host looks like it lost DNS the instant the VPN
comes up. `dns_pin_routes` now takes the tunnel's own subnet and pins
only servers plausibly behind the tunnel (in that subnet, or RFC1918 /
CGNAT). It returns a `DnsPinPlan` so the skipped globally-routable and
IPv6 servers are named at WARN instead of vanishing silently — an
unrouted pushed resolver is exactly the shape of #23, so it has to be
visible in the log.
This also removes the case where a pin could kill the connection: a
pushed nameserver equal to the gateway-exclude address made
`ip route add <ip>/32 dev tun0` return EEXIST and roll the whole
connect back. That path is now a takeover, not an abort.
-- verification --
On aarch64 Linux:
cargo test --workspace --locked -> 291 passed, 0 failed
cargo clippy --workspace --all-targets -- -D warnings
linux / aarch64-apple-darwin / x86_64-pc-windows-msvc -> clean
cargo fmt --all --check -> clean
RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps -> clean
End-to-end against a real routing table (crates/gp-route/examples/
netns_smoke.rs, run under `unshare --user --map-root-user --net`):
BEFORE docker : 172.20.0.0/16 dev br-81f0638ae4fb proto kernel scope link src 172.20.0.1
AFTER docker : 172.20.0.0/16 dev tun0 scope link
REVERT errors : []
AFTER docker : 172.20.0.0/16 dev br-81f0638ae4fb proto kernel scope link src 172.20.0.1
AFTER default: default via 192.168.88.1 dev eth0 metric 100 (untouched)
Known gap, called out rather than papered over: a hard kill skips
revert, leaving a taken-over prefix unrouted until the owning interface
is bounced. `opc recover` / `opc doctor` are Linux no-ops today; making
them restore stranded entries is the follow-up. The live table on the
reporting host already carries a stranded gateway pin from an earlier
crash, so this is not hypothetical.
Also fixes two pre-existing `clippy::doc_lazy_continuation` errors that
only surface on the Windows target, which CI never builds.
`129.94.0.230` and `129.94.0.0/16` were literal test data in eleven assertions — a real GlobalProtect gateway host inside a real university's public allocation, asserted verbatim as the `/32` exclude route. Every other address in the same tests was already RFC 5737 documentation space (`192.0.2.1`, `192.0.2.10`), so the real one was an inconsistency rather than a requirement. Substituted TEST-NET-2 keeping the prefix shape intact: `129.94.0.230` -> `198.51.100.230`, `129.94.0.0/16` -> `198.51.100.0/16`. Kept on this branch rather than in the repo-hygiene commit because the takeover work rewrote most of these same test lines; splitting it would have produced a merge conflict for no benefit. cargo test -p gp-route --locked -> 43 passed, 0 failed
kyaky
force-pushed
the
fix/split-route-conflicts
branch
from
August 26, 2026 12:36
18726f9 to
d057809
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the connect failure reported against a UNSW Prisma Access gateway on Linux:
Root cause
ip route addisNLM_F_CREATE|NLM_F_EXCL, so it returnsEEXISTwhen something already holds the same routing key. Docker's default address pool is172.17.0.0/16..172.31.0.0/16, which overlaps the RFC1918 ranges corporate gateways hand out. On the reporting host:plus seven more bridges (172.17–172.24, 172.30) sitting one corporate prefix away from the same crash. Any developer machine with a few compose projects hits this.
Approach
addstays the fast path; only anEEXISTtriggers new work. That is deliberate, not conservative — it makes the kernel, not a heuristic, decide whether a displacement is really happening.The alternative (always capture, always
replace) is actively harmful, and I checked why in a netns: withdefault via <gw> ... metric 100in the table,ip route add 0.0.0.0/0 dev tun0succeeds — metric 0 is a different key and both routes coexist. A blind capture would record a default route we never displaced, andrevertwould faithfully resurrect it hours later, possibly onto an interface the machine has since roamed off.add-first makes that case structurally impossible.On
EEXIST, the Linux backend captures the existing entry, installs withip route replace(what upstream vpnc-script uses for split routes), and hands the prefix back on disconnect.New flag, since the right answer is a policy question:
(env
PGN_ROUTE_CONFLICT)take-overis the default because these prefixes come from the caller's own--onlyspec, so routing them through the tunnel is exactly what was asked for — but it is announced loudly, and it is reversible in one flag if you disagree.Three details the restore path depends on
Each reproduced against iproute2 before being coded:
1.
ip route showprints tokensip route replacerejects. Replaying docker0's line verbatim:sanitize_route_entrystrips them. This was a live latent bug in the already-shipped gateway-pin restore, which captured verbatim — fixed here too.2. The family flag must be per-route.
ip route show exact fe80::/64with no flag prints nothing and exits 0 (silent capture loss);ip -4 route show exact fe80::/64is a hard parse error.resolve_only_specalready emits/128entries from AAAA records, sofamily_flagderives it per route instead of hardcoding-4the way the gateway pin did.3. Revert deletes ours before restoring.
replacekeys on dst+metric+table, so restoring alone would leave our metric-0 record beside a restored metric-N one and quietly keep the prefix in the tunnel. The delete is device-scoped, so it can only ever match our own route.Also fixed, same root cause
Duplicate CIDRs in the route list.
resolve_only_specemits one/32per resolved address with no de-duplication, so--only a.corp.com,b.corp.combehind a single IP produced the same prefix twice and the secondaddfailedEEXIST— a connect abort onmaintoday.dedupe_routescollapses them before any backend runs, comparing prefixes with host bits masked off.Takeover is refused when more than one entry shares the prefix:
replacewould collapse them and revert could only put one back. IPv6 makes that reachable, since the v6 route key excludes the device.Follow-up to #24
#24 pinned every pushed nameserver a split prefix did not already cover. The resolvers it serves are consumed in a scoped way (systemd-resolved
~domain, NRPT,/etc/resolver) but the route it installs is global, so a gateway pushing a public resolver alongside its internal one got that resolver forced through the corporate tunnel for every process on the machine. On a split-tunnel gateway that does not forward it, the host looks like it lost DNS the instant the VPN comes up.dns_pin_routesnow takes the tunnel's own subnet and pins only servers plausibly behind the tunnel (in that subnet, or RFC1918/CGNAT). It returns aDnsPinPlanso skipped globally-routable and IPv6 servers are named at WARN rather than vanishing — an unrouted pushed resolver is exactly the shape of #23, so it has to be visible in the log.This also removes a way a pin could kill the connection: a pushed nameserver equal to the gateway-exclude address made
ip route add <ip>/32 dev tun0returnEEXISTand roll the whole connect back. That path is now a takeover, not an abort.Other platforms
macOS gets
route changeonEEXIST— documented, and reachable only in exactly the caseaddjust failed for. It deliberately does not restore the previous entry: that needs parsingroute -n get, which no CI runner here can exercise (all Linux), and shipping unverified route-mutation logic is worse than a documented gap. The warning says so plainly.Windows keys routes on (prefix, interface, nexthop), so a Docker Desktop / WSL2 route on another adapter never conflicts. The retry there is gated on "object already exists" and names our own interface, so it can only ever clear our own stale entry on a recycled Wintun adapter.
Verification
cargo test --workspace --lockedcargo clippy --workspace --all-targets -- -D warningsaarch64-apple-darwin,x86_64-pc-windows-msvccargo fmt --all --checkRUSTDOCFLAGS=-D warnings cargo doc --workspace --no-depsUnit tests drive a
CommandRunnermock, which proves the argv but not that iproute2 accepts it.crates/gp-route/examples/netns_smoke.rsdoes the other half — realapply+revertagainst a real kernel routing table, under an unprivileged user namespace (no root, no effect on the host):Also fixes two pre-existing
clippy::doc_lazy_continuationerrors that only surface on the Windows target, which CI never builds —cargo clippy -- -D warningswas red there onmain.Known gap, stated rather than papered over
A hard kill (SIGKILL, power loss) skips
revert, leaving a taken-over prefix unrouted until the owning interface is bounced. This is not hypothetical — the reporting host's live table still carries a stranded gateway pin from an earlier crash.opc recover/opc doctorare Linux no-ops today; teaching them to restore stranded entries is the natural follow-up, and I'd suggest tracking it as an issue rather than growing this PR.