Decide per-app routing once, in Rust, with tests - #737
Merged
kasnder merged 2 commits intoAug 20, 2026
Conversation
kasnder
force-pushed
the
claude/730-shared-policy-module
branch
from
August 19, 2026 21:23
c7b1658 to
ea9d792
Compare
kasnder
changed the base branch from
claude/per-app-remote-vpn-routing
to
claude/details-protection-sheet
August 19, 2026 21:23
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
force-pushed
the
claude/730-shared-policy-module
branch
from
August 20, 2026 10:56
ea9d792 to
976de54
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.
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.cand a Java mirror inRemoteRoutingLogic— 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
policymodule insidewgbridge-rs, where CI already runscargo test --locked --offline, and reduces the C to a caller.route.cbecomespolicy.c:dlopen/dlsymunder apthread_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_tunnelnatively, 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
d7b0b69fand the commit on top of it). Routing every packet through FFI plus anRwLockwould 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
wgbridgeBuildis attached tomerge*JniLibFolders, notpreBuild, specifically so JVM unit tests do not cross-compile four ABIs. Linking would invert that, add a hardDT_NEEDED— solibnetguardwould fail to load at all whenever the bridge is missing — and couple the CMake output to a cargo one.jni_initcallspolicy_ensure()eagerly so neither the cost nor any error message lands on the packet path. Java's ownSystem.loadLibrary("wgbridge")returns the samesoinfowhen a tunnel starts, so there is exactly one policy table. Neverdlclosed.No Java load change. Adding
System.loadLibrary("wgbridge")toUtilwould make every user map the bridge at service start even thoughwg_enableddefaults to false. Deliberately not done.The one duplication left
route_wants_tunnel's three branches are also implemented inpolicy.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 withdlerror(), 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:
tunnel_uid = true, dns_direct = falsethe result equals the pre-feature expression!local_dest || is_dnsfor every combination — which is what proves the default mode is byte-identical to the behaviour before per-app routing existed.RemoteRoutingLogickeeps 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.dynsymfor 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. Usesreadelf, which reads any architecture's ELF unlike the hostnm.Verification
cargo test --manifest-path wgbridge-rs/Cargo.toml— 37 passed:app:compileGithubDebugJavaWithJavac,:app:testGithubDebugUnitTest,:app:lintGithubDebug,assembleGithubDebug— all passnm -D --defined-only app/build/rustJniLibs/arm64-v8a/libwgbridge.so | grep tc_policy— all five present, so they survivestrip = true:app:assembleFdroidReleaseruns produce byte-identicallibwgbridge.soandlibnetguard.soNot device-tested. The smoke test in
agents/docs/device-testing.mdstill needs running, and enabling the VPN revokes whatever VPN app currently holds consent.Not in scope
agents/docs/build-and-test.mdclaimswgbridgeBuildis adependsOnofpreBuildand that Gradle installscargo-ndkif missing. Neither matchesapp/gradle/wgbridge.gradle, which hooksmerge*JniLibFoldersdeliberately and throws whencargo-ndkis absent. This PR's rejection of CMake linking depends on that ordering, so the doc should be corrected — but the file landed onmasterafter this branch was cut, so it needs its own commit there.master-level root cause:handle_ipdiverts to WireGuard before the DNS and session machinery runs, so everything that machinery provides disappears while the tunnel is on. Independent of this stack.🤖 Generated with Claude Code