vpnhide/docs/diagnostics.md
Danila Gornushko dde3a58aa0
Diagnostics & dashboard redesign: honest who-hid-the-VPN attribution (#231)
* Replace gobley/UniFFI with a cargo-ndk + JNI/JSON native transport

The native check probes reached the app through UniFFI, built by the gobley
Gradle plugin — which needs an AGP-9 fork on a private maven repo to build at
all. Now that diagnostics also want a root-exec ground-truth probe (a second,
JSON-over-stdout transport), the whole native surface is just "run the checks,
return a blob", so a typed-FFI codegen framework no longer earns its keep. Both
paths now share one JSON schema from one crate: a cdylib with a single JNI
function for the in-process app-view run, and a vhprobe bin for the root-exec
run. Check logic and diagnostics behaviour are unchanged.

- native: drop uniffi; add serde + jni 0.22.4; crate-type cdylib+rlib plus a
  vhprobe bin; run_all_json() and one JNI export
- build: remove the gobley plugins/version and the maven fork repo; add a
  config-cache-safe cargo-ndk Exec task (cdylib -> jniLibs, bin -> assets)
  wired via preBuild
- app: NativeProbe (loadLibrary + JNI + JSON parse) replaces the generated
  bindings; native specs join probe results by id

* Add the root-differential CheckOutcome (who hid the VPN)

Each native check now gets an honest outcome — hidden by the backend, hidden by
SELinux, nothing to leak, a real leak, or not measured — instead of one green
"pass" that conflated all of them. The app runs the probes in-process (its own
filtered view) and execs the same vhprobe binary as root (uid 0 is not a hook
target, so the unfiltered ground truth), then diffs per check: root sees the VPN
and the app doesn't = backend hid it; the app was EACCES-blocked = SELinux hid
it; root also sees nothing = nothing to leak. The legacy passed: Boolean? path
and the dashboard are untouched for now; the outcome is exposed on the agent
bridge for validation.

- native: CheckStatus gains SelinuxBlocked; the four EACCES sites report it
  instead of masquerading as pass
- app: CheckOutcome + classifier, and GroundTruthProbe (extract the asset, stage
  to /data/local/tmp, su-exec, parse), computed per run and carried on CheckResults
- bridge: AgentCheckResult gains an outcome token for the native checks

* Rewire the dashboard onto LayerStatus (backend health, not pass-count)

The native/Java tiles were NativeResult/JavaResult rolled up from the raw
pass-count, so an unloaded backend read "Partial" (from SELinux-only passes) and
a couple of failing Java probes read a blanket "Not working". Replace both with
one LayerStatus { Absent, Inactive, Active(hidden, leaks) } and a verdict
Ok/Partial/Broken. Presence is gated before the checks, so an inactive backend
can only render "Not active", never Partial; and each backend is judged only on
the vectors it owns — a leak on a not-owned vector (e.g. /proc/net/dev under a
kernel backend) goes to the hero, not the tile. hidden/leaks come from the
root-differential CheckOutcome, so the counts are measured, not inferred.

- DashboardData: ProtectionCheck.Checked carries LayerStatus; drop
  NativeResult/JavaResult and toNativeResult/toJavaResult; hero, fully-passed,
  and the checks-failed warning read the verdict
- summarizeNativeLayer / summarizeJavaLayer / verdict in LayerStatus.kt
- DashboardScreen: native and Java tiles share one layerSummaryText/Accent
- bridge: AgentProtectionSummary reports the verdict + hidden/leaks
- strings: add "Not active"; unit tests updated (a few Java fails = Partial)

* feat(lsposed): add RTM_GETRULE policy-rule probe, fix if_inet6 attribution

Policy routing is where Android expresses per-app VPN membership: an
`ip rule ... uidrange N-N lookup tun0` steers a UID into the tunnel's own
table, invisible to /proc/net/route (main table only). The new native
probe mirrors the fib_nl_fill_rule kernel filter (VPN iface name, or this
UID steered into a non-standard table), so a working backend reads as
hidden_backend, SELinux-blocked as hidden_selinux, and root ground-truth
as a leak. Validated on the Pixel 4a across enforcing/permissive/root.

Also drop INET6_FILL_IFADDR from the proc_if_inet6 coverage set — no
kernel seq_show hook exists for /proc/net/if_inet6; only the zygisk
openat filter (or SELinux) owns that vector.

* feat(lsposed): rewire diagnostics screen onto CheckOutcome attribution

Each native check now renders its root-differential outcome instead of a
bare pass/fail: a green card with a 'hidden by backend' pill when a hook
did the work, a green card with a blue 'hidden by SELinux' pill when the
app was EACCES-blocked, 'nothing to hide' when nothing was there, red on
a real leak, grey when not measured. The who-hid-it split is what the
whole redesign is for, and it now reads at a glance on the screen.

CheckOutcome rides along on each native CheckResult so the card is a pure
function of the list; Java checks (no root ground truth) keep the
passed tri-state, shown as clean/leak/not-measured in the same vocabulary.

* feat(lsposed): probe legacy NetworkInfo APIs, drop system-proxy check

The NetworkInfo hook (lsposed_network_info) was installed but exercised by
no check — getActiveNetworkInfo() / getNetworkInfo(TYPE_VPN) were untested,
so a green diagnostics run did not actually verify that hook. Add both
probes: getActiveNetworkInfo() marshals a NetworkInfo across Binder (hits
the parcel hook), getNetworkInfo(TYPE_VPN) is the legacy type query the
result sanitizer nulls (issue #85). Together they close the coverage gap so
the Java layer verifies every VPN-hiding hook.

Drop the system-proxy check: no backend hooks the proxy properties, a
consumer VPN sets no system proxy, so it never fired and only added a
green row that proved nothing.

Note: device was in sideload mode at commit time; on-device verification
(both checks pass with LSPosed active) still pending.

* test(lsposed): cover the root-differential classifier and layer rollup

Pins the truth table verified on-device this cycle: classifyNativeOutcome
(leak / hidden-by-backend / hidden-by-SELinux / nothing-to-leak / not-measured,
incl. empty-ground-truth-wins), CheckStatus.toPassed's SELinux mapping, the
Ok/Partial/Broken verdict, and that a native tile is judged only on the vectors
its backend family owns (kernel vs zygisk masks) — an unowned leak surfaces via
unownedNativeLeaks, not the tile. No device needed.

* feat(lsposed): gate diagnostics on VPN Hide being in the tunnel

Diagnostics measure VPN Hide's own process. If VPN Hide is split-tunnelled
out of the VPN, its own traffic never touches the tunnel, so the checks have
nothing to hide from us and a clean run is meaningless. Gate on it: read the
policy routing rules as root (hook-inert ground truth) and ask whether this
app's uid is steered into the VPN's own routing table.

- vhprobe --uid <n>: report {uid, routed, detail}. Identifies the VPN egress
  table via an 'oif <vpn-iface>' rule (is_vpn_iface, not a hardcoded tun0),
  then checks uidrange membership against exactly that table — so a wlan/rmnet
  per-network uidrange rule no longer counts as 'routed through the VPN'.
- New DiagnosticsCache.State.SelfNotRouted + ProtectionCheck.SelfNotRouted;
  a SelfNotRoutedPrompt ('add VPN Hide to your tunnel') on both the diagnostics
  screen and the dashboard, distinct from 'VPN off'. A null (no-root) answer
  does not block. Agent bridge reports state 'self_not_routed'.

Verified on device: uid 10572 (full tunnel) -> routed, 999999 -> not; the
happy path runs the checks (no self_not_routed transition). The blocking path's
prompt still needs a split-tunnel config to see live.

* feat(lsposed): classify Java checks by outcome (hidden/leak)

With the self-in-tunnel gate guaranteeing a VPN artifact is present, a clean
Java-API check means the LSPosed hook removed it and a dirty one is a leak —
so the Java layer becomes binary Leak / HiddenByBackend (NotMeasured only as a
defensive edge for a probe that could not run). classifyJavaOutcome rides onto
each Java CheckResult via withJavaOutcomes; summarizeJavaLayer now reads
hidden/leaks off the outcome so Partial vs Broken is a measurement, mirroring
the native tile. The diagnostics screen renders the same outcome pills for
Java, and the agent bridge threads the Java outcome token.

On-device pill screenshot still pending (GUI nav flaky on this ROM); logic is
unit-tested and the gate round-trip is verified.

* fix(lsposed): probe IPv6 policy rules in RTM_GETRULE and the self-in-tunnel gate

The kernel's RTM_GETRULE dump is per-family, so requesting AF_INET only
returned IPv4 policy rules. Android installs the per-app VPN uidrange/oif
rules for both families, so an IPv6-only rule set was invisible to both the
netlink_getrule diagnostic and the self-in-tunnel gate.

Add a shared dump_fib_rules helper and dump AF_INET + AF_INET6 on the same
socket (distinct seq), merging the results. Verified on-device: the gate
reports routed=true/false correctly and the rule set now covers both families.

* fix(lsposed): tighten diagnostics attribution honesty

Three refinements so the who-hid-it view can't overclaim:

- Java checks that couldn't observe (no active network / capabilities / link
  properties, or getNetworkForType unreachable via reflection) now report
  passed=null so they classify as NotMeasured instead of a false "hidden by
  backend". The self-in-tunnel gate makes these edges near-impossible, but they
  must not paint a backend success.
- Fold the Java-implemented native-level probes (NetworkInterface enum,
  /proc/net/route via ART) into the unowned-native-leak count. They carry no
  root differential, so a leak there was previously invisible to the dashboard.
- Scope the native tile's hidden count to owned vectors like its leak count, so
  the two describe the same vector set and a cross-backend hidden can't mask an
  owned Broken verdict.

* feat(lsposed): compact the diagnostics screen to a status dot + word, detail on tap

The detailed diagnostics screen was a wall of monospace detail — on an enforcing
device 6-7 cards repeated the same "access denied by SELinux" string, and the long
"Hidden by backend / Hidden by SELinux" pills wrapped the check titles.

Each check now reads as one compact card: name + a coloured status dot + a short
word (OK / SELinux / Leak / No data), matching the status-dot idiom used on the
module rows. The card colour tracks current reality only (green when hidden by the
backend, by SELinux, or nothing to leak; red on a leak; neutral when not measured),
so a normal enforcing device stays all-green and the backend-vs-SELinux attribution
rides on the dot colour, not an alarm. The raw detail is collapsed by default and
revealed on tap; a leak is expanded up front. Drops the misleading "Clean" pill that
showed on access-denied fallback rows.

* ci: resolve the lsposed NDK from ANDROID_NDK_HOME, and rustfmt the probe

Two CI fixes for the branch:

- buildRustProbe hard-coded the NDK path as $SDK/ndk/28.2.13676358, but the CI
  image installs the NDK standalone at $ANDROID_NDK_HOME (r28c == that build
  number), not under the SDK. Prefer ANDROID_NDK_HOME/ANDROID_NDK_ROOT like the
  zygisk build already does, falling back to the SDK-managed path for local dev.
- Run rustfmt over the RTM_GETRULE/gate changes in the probe so `cargo fmt
  --check` passes.

* ci: drop orphaned string resources the diagnostics rewire left unused

Android lint (lintDebug, previously skipped because rustfmt failed first) flags
badge_pass/fail/info and dashboard_protection_hooks_inactive as UnusedResources —
they were left behind when the diagnostics screen moved to the outcome model and
the tiles unified onto LayerStatus. Remove them from both locales.

* docs: add docs/diagnostics.md — the self-diagnosis / attribution model

Curated reference for how the app self-tests hiding and attributes who hid the
VPN: the root-differential classifier, the CheckOutcome taxonomy, the
LayerStatus/verdict rollup, the self-in-tunnel gate, the empirical facts that
shape the checks (SELinux carries part of protection; the VPN lives in policy
tables not the main route table), and the native check→hook map. Indexed from
AGENTS.md; complements detection-vectors.md (the hiding side).

* fix(lsposed): show "not checked" on both tiles for every unmeasured state

VPN-off, self-not-routed and needs-restart all mean the layer's hiding checks
did not run, so the Native / Java API tiles now read a plain "not checked" (neutral
accent) in all three — instead of cramming the hero's "VPN is off" title into the
tile for the VPN-off case only. The hero and banner remain the single place that
says why (turn on VPN / add to tunnel / restart); the tiles just report that the
layer was not measured.
2026-07-04 14:42:04 +03:00

8.2 KiB

Diagnostics — how VPN Hide self-tests hiding and attributes the result

This is the self-diagnosis model: how the app checks that hiding actually works for its own process and honestly reports who hid the VPN on each vector. For the hiding side (which backend covers which detection vector) see detection-vectors.md; for the app↔backend wire see protocol.md.

Devices this was validated on: Pixel 4a (sunfish, Magisk, 4.14, kmod/KPM/Zygisk), Pixel 8 Pro (husky, KernelSU-Next, GKI 6.1, KPM), and an Android 13 Zygisk device.

1. The problem it solves

A check result used to be a single tri-state boolean that conflated three unrelated meanings of "pass": (a) the backend hid the VPN, (b) SELinux denied the probe (EACCES), (c) there was nothing to leak on that surface. Counting all "passes" as backend wins made an installed-but-inactive backend read as "Partial" (its SELinux-blocked reads counted as passes) and a mostly-working Java layer read as "Not working" (any one failing probe painted the whole layer red).

2. Root-differential — the reliable classifier

A clean probe result is ambiguous on its own: hook-suppressed and nothing-to-leak look identical. Root (uid 0) is not a hook target, so a privileged read is the ground truth for "what is actually on this surface". The app runs each native probe twice — in-process (its own uid + SELinux domain + hooks) and as root — and diffs them:

root sees app read → outcome
nothing NothingToLeak (empty ground truth wins, even over EACCES)
VPN EACCES HiddenBySelinux
VPN ok, clean HiddenByBackend
app saw VPN Leak

Priority: an empty ground truth is checked before EACCES — if root sees nothing, the SELinux block is moot, it is simply nothing-to-leak.

Ground truth is the same Rust probe binary run as root, not shell ip/cat. GroundTruthProbe extracts vhprobe from the APK, stages it to /data/local/tmp, and execs it via su; it emits the same JSON as the in-process JNI path (run_all_json), so the two views are directly comparable per check id. (This replaced an earlier gobley/UniFFI binding — the whole native surface is now one JSON-returning function built with plain cargo-ndk.)

3. Per-check outcome (CheckOutcome)

Leak · HiddenByBackend · HiddenBySelinux · NothingToLeak · NotMeasured(reason). Wire/log tokens: leak, hidden_backend, hidden_selinux, nothing_to_leak, not_measured_no_network, not_measured_no_ground_truth.

The Rust probe reports Pass / Fail / SelinuxBlocked (EACCES/EPERM, no longer folded into Pass) / NetworkBlocked (ECONNREFUSED from socket() — no network permission). Native checks classify via the root differential above. Java checks have no root differential (framework IPC), so they are binary — clean ⟹ HiddenByBackend, dirty ⟹ Leak — which is honest only because the self-in-tunnel gate (§5) guarantees a VPN artifact was present to hide.

4. Layer status & verdict (dashboard tiles)

Each dashboard tile is a LayerStatus: Absent (no module installed) · Inactive (installed, not loaded this boot) · Active(hidden, leaks). Presence is decided before the checks, so an unloaded backend can never render a verdict — it just reads "not active" (this is the type-level fix for the old "Partial"). An Active tile's verdict:

  • leaks == 0Ok
  • hidden > 0 && leaks > 0Partial (hides some, an owned vector still leaks)
  • hidden == 0 && leaks > 0Broken (loaded but suppressed nothing)

hidden must be a measurement (the root differential), never inferred from a clean probe — otherwise Partial and Broken are indistinguishable. The native tile is judged only on vectors the active backend owns (has a hook for): a leak on a not-owned vector (e.g. /proc/net/dev under a kernel backend — no kernel hook exists) does not turn the tile red; it surfaces as a hero warning instead. So the tile answers "is this module doing its job" and the hero answers "is the VPN hidden at all". The Java tile uses the same rollup; LSPosed owns every Java check, so all its leaks count.

5. Self-in-tunnel gate

Diagnostics are meaningless if VPN Hide itself is not routed through the VPN: split- tunnelled out, there is no VPN artifact for its own probes to be hidden from, so every check would read misleadingly clean. Before running any checks the app asks vhprobe --uid <selfUid> (root, hook-inert) whether its own uid is routed through the VPN. Two passes over the policy rules (both address families): learn the VPN egress table id(s) from rules that egress via a VPN interface (oif tun*), then check whether a uidrange rule steers this uid into exactly that table. This is stricter than the broad netlink_getrule diagnostic predicate — every uid sits in some per-network table (wlan/rmnet, also non-standard), so it must pin the VPN table specifically. If not routed, the UI shows an "add VPN Hide to your tunnel" prompt instead of clean results. A null answer (no root) does not block.

6. Empirical facts that shape the checks

  • SELinux carries part of "protection", and it is invisible without the differential. On both test kernels, 8 of 13 native probes "pass" under enforcing only because SELinux denies the read. Three of those — /proc/net/if_inet6, /proc/net/dev, /sys/class/net — have no backend coverage at all under a kernel backend (no kernel hook exists for those procfs/sysfs paths, by design); on a permissive device they leak tun0. This is why the permissive-SELinux warning exists and why SELinux attribution is dev-facing, not a user alarm.
  • The VPN lives in protected sockets + per-UID policy tables, not the main route table. A split-tunnel VPN app marks its sockets and installs ip rule … uidrange <uid> lookup tun0; it does not put a default route in the main table. So /proc/net/route (main table only) shows no VPN for the target and resolves to NothingToLeak — while RTM_GETRULE (policy rules) is the real routing detection vector, added as a probe mirroring the fib_nl_fill_rule kernel filter.
  • Some checks never fire on any config: both /proc/net/route reads (native + Java) and the removed system-proxy check. They only added false confidence; the route reads are kept because the differential now labels them NothingToLeak honestly, the proxy check was dropped.
  • Suppression counters can distinguish "hook not loaded" from "hook not working" (a per-hook Δ>0 during a probe is proof the hook did real work). They are not used by diagnostics — the root differential already gives the full 4-way + hidden without them — and stay only in the Statistics tab.

7. Native check → owning hook (KPM/kmod), verified on Pixel 4a

The kernel backend's 10 hooks map to the diagnostic checks below; the "gaps" rows are SELinux/zygisk territory by design, not bugs. Full hiding matrix in detection-vectors.md.

check id probes kernel hook notes
ioctl_flags, ioctl_mtu SIOCGIF* by name dev_ioctl ENODEV for tun0
ioctl_conf SIOCGIFCONF sock_ioctl tun0 absent from ifconf
getifaddrs, netlink_getlink RTM_GETLINK / getifaddrs rtnl_fill_ifinfo, inet*_fill_ifaddr
netlink_getroute RTM_GETROUTE v4/v6 fib_dump_info, rt6_fill_node
netlink_getrule RTM_GETRULE policy rules fib_nl_fill_rule v4+v6; kernel-only vector
proc_route /proc/net/route fib_route_seq_show main table — empty for split-tunnel VPN
proc_ipv6_route /proc/net/ipv6_route ipv6_route_seq_show
proc_if_inet6 /proc/net/if_inet6 (none) no kernel seq_show hook — zygisk openat or SELinux only
proc_dev /proc/net/dev (none) zygisk openat or SELinux only
sys_class_net /sys/class/net (none) SELinux only

Java-level checks (LSPosed) cover the framework side — hasTransport(VPN), NET_CAPABILITY_NOT_VPN, VpnTransportInfo, getAllNetworks, LinkProperties, getNetworkForType(TYPE_VPN), the push NetworkCallback (issue #70), and the legacy getActiveNetworkInfo / getNetworkInfo(TYPE_VPN) APIs.