Skip to content

Decide per-app routing once, in Rust, with tests - #737

Merged
kasnder merged 2 commits into
claude/details-protection-sheetfrom
claude/730-shared-policy-module
Aug 20, 2026
Merged

Decide per-app routing once, in Rust, with tests#737
kasnder merged 2 commits into
claude/details-protection-sheetfrom
claude/730-shared-policy-module

Conversation

@kasnder

@kasnder kasnder commented Aug 19, 2026

Copy link
Copy Markdown
Member

Top of a four-PR stack: #729#730#733#737.

Base is claude/details-protection-sheet (#733); review the layers below first. The diff here is only the policy module — #733's UI change and #730's routing fixes are in their own PRs.

What this does

The per-app routing fork added in #730 had two implementations — the native route.c and a Java mirror in RemoteRoutingLogic — and no test harness on the side that actually runs it. The mirror existed purely because the native path was untestable.

This moves the decision into a Rust policy module inside wgbridge-rs, where CI already runs cargo test --locked --offline, and reduces the C to a caller. route.c becomes policy.c: dlopen/dlsym under a pthread_once, an ABI check, four shims, and the per-flow verdict cache. No policy logic, with one marked exception.

It also puts the policy where a future gotatun that reads the tun itself would call policy::wants_tunnel natively, with no boundary at all.

The hot path does not cross the boundary

This is the part worth reviewing closely, because the obvious design is wrong.

The three facts the packet path reads per packet — whether any override exists, the global default, whether DNS follows it — stay in C atomics written at push time. Rediscovering them per packet is exactly what made this fork expensive enough to surface as degraded DNS (see #730's d7b0b69f and the commit on top of it). Routing every packet through FFI plus an RwLock would have reintroduced that regression. Only a configured override crosses the boundary, and only to look one UID up.

The per-flow verdict cache stays in C too: it is a cache, not policy, and it is owned by the tunnel thread.

Resolving rather than linking

wgbridgeBuild is attached to merge*JniLibFolders, not preBuild, specifically so JVM unit tests do not cross-compile four ABIs. Linking would invert that, add a hard DT_NEEDED — so libnetguard would fail to load at all whenever the bridge is missing — and couple the CMake output to a cargo one. jni_init calls policy_ensure() eagerly so neither the cost nor any error message lands on the packet path. Java's own System.loadLibrary("wgbridge") returns the same soinfo when a tunnel starts, so there is exactly one policy table. Never dlclosed.

No Java load change. Adding System.loadLibrary("wgbridge") to Util would make every user map the bridge at service start even though wg_enabled defaults to false. Deliberately not done.

The one duplication left

route_wants_tunnel's three branches are also implemented in policy.c, for the case where the bridge could not be resolved — a packet still has to go somewhere. That path additionally pins every UID to the global default, so a resolution failure means a per-app choice is ignored: exactly what shipped before per-app routing existed. Never a leak out of the tunnel, never a new drop. It logs at ERROR with dlerror(), and logs the resolved handle on success so a device test can tell the two apart.

Tests

16 new Rust tests, 37 in the crate total. They pin the decision table including two behaviours that are surprising rather than wrong, and say so in comments:

  • a loopback DNS query is handed to the tunnel;
  • with tunnel_uid = true, dns_direct = false the result equals the pre-feature expression !local_dest || is_dns for every combination — which is what proves the default mode is byte-identical to the behaviour before per-app routing existed.

RemoteRoutingLogic keeps its Java-side tests; the mirror is now a genuine mirror of a tested implementation rather than the only tested copy.

CI

New assertion that all five tc_policy_* symbols are in .dynsym for every ABI of the F-Droid APK. Without it a stripped or renamed export degrades silently into "no per-app routing" instead of failing the build. Uses readelf, which reads any architecture's ELF unlike the host nm.

Verification

  • cargo test --manifest-path wgbridge-rs/Cargo.toml — 37 passed
  • :app:compileGithubDebugJavaWithJavac, :app:testGithubDebugUnitTest, :app:lintGithubDebug, assembleGithubDebug — all pass
  • nm -D --defined-only app/build/rustJniLibs/arm64-v8a/libwgbridge.so | grep tc_policy — all five present, so they survive strip = true
  • Two :app:assembleFdroidRelease runs produce byte-identical libwgbridge.so and libnetguard.so

Not device-tested. The smoke test in agents/docs/device-testing.md still needs running, and enabling the VPN revokes whatever VPN app currently holds consent.

Not in scope

🤖 Generated with Claude Code

@kasnder
kasnder force-pushed the claude/730-shared-policy-module branch from c7b1658 to ea9d792 Compare August 19, 2026 21:23
@kasnder
kasnder changed the base branch from claude/per-app-remote-vpn-routing to claude/details-protection-sheet August 19, 2026 21:23
kasnder and others added 2 commits August 20, 2026 12:52
The per-app routing fork had two implementations and no test harness on
the side that actually runs it. Move the decision into a Rust `policy`
module inside wgbridge-rs, where CI already runs `cargo test`, and reduce
the C to a caller.

`route.c` becomes `policy.c`: dlopen/dlsym against libwgbridge under a
pthread_once, an ABI check, and the four shims — no policy logic, with one
marked exception. `jni_init` resolves it eagerly so neither the cost nor
the error message lands on the packet path. Resolving rather than linking
is deliberate: wgbridgeBuild is attached to merge*JniLibFolders, not
preBuild, specifically so JVM unit tests do not cross-compile four ABIs,
and a DT_NEEDED would invert that and stop libnetguard loading at all
whenever the bridge is absent.

The packet path does not cross the boundary in the common case. The three
facts it reads per packet — whether any override exists, the global
default, whether DNS follows it — stay in C atomics written at push time,
because rediscovering them per packet is what made this fork expensive
enough to surface as degraded DNS. Only a configured override crosses,
and only to look a UID up.

The per-flow verdict cache stays in C too: it is a cache, not policy, and
it is owned by the tunnel thread.

The one duplication left is route_wants_tunnel's three branches, which
policy.c also implements for the case where the bridge could not be
resolved — a packet still has to go somewhere. That path also pins every
UID to the global default, so a failure means a per-app choice is ignored,
which is what shipped before per-app routing existed: never a leak out of
the tunnel, never a new drop. It logs at ERROR with dlerror(), and logs
the resolved handle on success so a device test can tell the two apart.

The Rust tests pin the decision table, including the two behaviours that
are surprising rather than wrong: a loopback DNS query is handed to the
tunnel, and the default mode is byte-identical to the pre-feature
expression `!local_dest || is_dns`.

CI now asserts the five exported symbols are in .dynsym for every ABI of
the F-Droid APK. Without that, a stripped or renamed export degrades
silently into "no per-app routing" instead of failing the build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kasnder
kasnder force-pushed the claude/730-shared-policy-module branch from ea9d792 to 976de54 Compare August 20, 2026 10:56
@kasnder
kasnder merged commit b8cec6c into master Aug 20, 2026
2 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