Skip to content

gp-route: survive a split prefix another interface already owns - #29

Merged
kyaky merged 2 commits into
mainfrom
fix/split-route-conflicts
Aug 26, 2026
Merged

gp-route: survive a split prefix another interface already owns#29
kyaky merged 2 commits into
mainfrom
fix/split-route-conflicts

Conversation

@kyaky

@kyaky kyaky commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Fixes the connect failure reported against a UNSW Prisma Access gateway on Linux:

INFO  opc: split tunnel: 7 route(s) resolved - 10.0.0.0/8 129.94.0.0/16 131.236.0.0/16
      149.171.0.0/16 172.20.0.0/16 172.21.0.0/16 172.26.33.0/25
INFO  opc: gp-route: applying 7 route(s) natively on tun0
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

Root cause

ip route add is NLM_F_CREATE|NLM_F_EXCL, so it returns EEXIST when something already holds the same routing key. Docker's default address pool is 172.17.0.0/16 .. 172.31.0.0/16, which overlaps the RFC1918 ranges corporate gateways hand out. On the reporting host:

172.20.0.0/16 dev br-81f0638ae4fb proto kernel scope link src 172.20.0.1
172.21.0.0/16 dev br-5894a3fcefae proto kernel scope link src 172.21.0.1

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

add stays the fast path; only an EEXIST triggers 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: 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 and both routes coexist. A blind capture would record a default route we never displaced, and revert would 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 with ip 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:

--route-conflict take-over   # default; WARNs naming the losing interface
--route-conflict fail        # refuse, naming the owner + the three remedies
--route-conflict skip        # install everything else, leave this prefix alone

(env PGN_ROUTE_CONFLICT)

take-over is the default because these prefixes come from the caller's own --only spec, 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 show prints tokens ip route replace rejects. Replaying docker0's line verbatim:

$ ip -4 route replace 172.17.0.0/16 dev docker0 proto kernel scope link src 172.17.0.1 linkdown
Error: either "to" is duplicate, or "linkdown" is a garbage.     # rc=255

sanitize_route_entry strips 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::/64 with no flag prints nothing and exits 0 (silent capture loss); ip -4 route show exact fe80::/64 is a hard parse error. resolve_only_spec already emits /128 entries from AAAA records, so family_flag derives it per route instead of hardcoding -4 the way the gateway pin did.

3. Revert deletes ours before restoring. replace keys 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_spec emits one /32 per resolved address with no de-duplication, so --only a.corp.com,b.corp.com behind a single IP produced the same prefix twice and the second add failed EEXIST — a connect abort on main today. 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: replace would 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_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 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 tun0 return EEXIST and roll the whole connect back. That path is now a takeover, not an abort.

Other platforms

macOS gets route change on EEXIST — documented, and reachable only in exactly the case add just failed for. It deliberately does not restore the previous entry: that needs parsing route -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

check result
cargo test --workspace --locked 291 passed, 0 failed (+23)
cargo clippy --workspace --all-targets -- -D warnings clean on linux, aarch64-apple-darwin, x86_64-pc-windows-msvc
cargo fmt --all --check clean
RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps clean

Unit tests drive a CommandRunner mock, which proves the argv but not that iproute2 accepts it. crates/gp-route/examples/netns_smoke.rs does the other half — real apply + revert against a real kernel routing table, under an unprivileged user namespace (no root, no effect on the host):

$ cargo build -p gp-route --example netns_smoke
$ unshare --user --map-root-user --net -- target/debug/examples/netns_smoke
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
displaced     : ["172.20.0.0/16"]
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

OK: conflict taken over, restored exactly, uncontested default left alone

Also fixes two pre-existing clippy::doc_lazy_continuation errors that only surface on the Windows target, which CI never builds — cargo clippy -- -D warnings was red there on main.

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 doctor are 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.

kyaky added 2 commits August 26, 2026 22:35
`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
kyaky force-pushed the fix/split-route-conflicts branch from 18726f9 to d057809 Compare August 26, 2026 12:36
@kyaky
kyaky merged commit be9649e into main Aug 26, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant