Diagnostics

September 17, 2026 · View on GitHub

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.

For the baseline execution states, screen/export dependencies, historical decisions and the semantic questions that motivated the redesign, see the diagnostics state analysis. The replacement is specified in the app state transition contract: diagnostic execution and operation impacts (§7), the presentation, the Situation and the Dashboard projection (§8) and capture through the run coordinator (§9) are implemented; only the §9 capture machine (reservation, cancellation, packaging) is not. How each stage got there is in the contract's implementation history.

The observation coordinator owns cache refreshes, and a process-owned DiagnosticRunCoordinator behind DiagnosticsCache owns suite execution: each run is an identified, immutable attempt that survives screen changes and Activity recreation, a retry is a new run, and a run records its measurement context (process, self configuration, VPN routing, coverage) at start and end. The Dashboard's tiles are projected from the same presentation the hero classifies, on every change of it, so they cannot lag behind a run. A user retry (retryDiagnosticsAndDashboard, the one explicit entry point) refreshes the app-VPN observation, queues one explicit diagnostic run and re-reads the Dashboard's root facts; no cache refresh requests a run on its own. Measurement classification and the completed-run retention policy below are unchanged.

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 seesapp read→ outcome
nothingNothingToLeak (empty ground truth wins, even over EACCES)
VPNEACCESHiddenBySelinux
VPNok, cleanHiddenByBackend
app saw VPNLeak

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 vhhelper from the APK, stages its immutable content-addressed copy 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.)

The same Rust executable also has a read-only observe kpm-list mode used by the dashboard's installation-integrity check. The helper owns both runtime paths: it invokes kpatch kpm list when the KPatch-Next CLI is present and otherwise tries the APatch/FolkPatch list supercall. The root snapshot shell only stages and invokes the helper; it does not parse module names or construct the observation JSON. This detects a runtime-loaded vpnhide KPM when the /data/adb/modules/vpnhide_kpm flashable module is absent. That combination indicates that the user loaded or embedded the inner vpnhide.kpm file without installing the complete vpnhide-kpm.zip; the app explains that the raw file lacks the activator, boot scripts, and config delivery, then directs the user to remove the raw KPM, install the ZIP through the root manager's Modules screen, and reboot. APatch authentication uses the saved root-only SuperKey or its trusted su token; the probe never prints either credential.

Helper observation envelope

vhhelper and the JNI library share a small app/helper response contract. Every observation is one JSON object with version: 1, a kind (checks, app_vpn_state, legacy routing, or kpm_list) and a status (ok or error). Successful responses carry a typed data value:

{"version":1,"kind":"checks","status":"ok","data":[{"id":"...","status":"pass","detail":"..."}]}
{"version":1,"kind":"app_vpn_state","status":"ok","data":{"uid":10042,"state":"routed","session":"framework:143:tun0","interfaces":["tun0"],"method":"uid_rule","detail":"uid_in_vpn_table"}}
{"version":1,"kind":"routing","status":"ok","data":{"uid":10042,"routed":null,"detail":"netlink unavailable"}}
{"version":1,"kind":"kpm_list","status":"ok","data":{"available":true,"modules":[]}}

An unavailable root/runtime observation is an error object such as {"version":1,"kind":"kpm_list","status":"error","error":"unavailable"}. KernelPatch and KPatch-Next produce an empty buffer or module names separated by newlines. The helper also reads kpm num through the same runtime and requires the parsed name count to match. A successful command with any other output, a truncated/full buffer, a count mismatch, invalid UTF-8, an unsafe/numeric name, a blank record, or a duplicate name is {"version":1,"kind":"kpm_list","status":"error","error":"malformed"}; it is never interpreted as an empty list. For the direct APatch supercall path, a successful zero module count returns an empty observation without calling the list operation: older KernelPatch versions leave the kernel list buffer uninitialized when there are no modules. This is an observation at count time, not an atomic count/list snapshot. The app treats malformed/truncated JSON, an unsupported version, a wrong kind and unknown status/error codes as unusable observations; none can become a clean check or a false routing result. A valid empty KPM list (available: true, modules: []) remains distinct from unavailable and malformed output. KPM credentials and tool diagnostics never enter this stdout envelope.

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, not_measured_unknown_native_status.

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)

The tiles are one input to the hero, not the hero: its colour, title, subtitle and the prompt under it are decided from the one Situation every surface renders (situation() in diagnostics/SituationData.kt, worded by heroVisual; see the transition contract §8). A LayerStatus only ranks a Measured situation.

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. Such an unowned leak is a surface no active backend can close on this device, so it also does not raise a dashboard warning or the "Issues" count — alarming about a gap the user cannot act on is just noise (and support churn). Instead it is shown neutrally ("not covered") in a separate group of the per-check breakdown, so the residual surface stays honest without reading as a failure. The only thing that raises the hero to attention is an owned leak — a vector the active backend should hide but didn't (the user can act: report the device / switch backend) — or a genuine module/version problem. So the tile answers "is this module doing its job", and the dashboard stays clean whenever the active backend hides everything it can. 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. RoutingGateCache therefore owns one direct root-helper observation, vhhelper observe app-vpn-state --uid <selfUid>. It returns the complete app-scoped state: vpn_off, excluded, routed, or unknown, plus a VPN-session identity and candidate interfaces.

The helper reads only current NetworkAgentInfo records from root dumpsys connectivity; requests, history and idle VPN-manager objects do not count. Transport VPN establishes a framework VPN and NOT_VPN excludes carrier IMS/IWLAN interfaces such as ipsec*. An up/unknown tunnel-named sysfs interface is the fallback for an unmanaged root tunnel. The helper does not dump global route tables or use their text as a change fingerprint.

For candidate interfaces it performs the narrow netlink policy-rule check for both address families: first learn the VPN egress table id(s) from rules with 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 online UID sits in some per-network table, so membership must be pinned to the VPN table. Table identifiers are scoped by address family and an output-interface-only rule is not UID membership. Any unavailable or malformed framework/rule observation becomes unknown, never vpn_off or excluded.

The foreground AppVpnStatePoller requests this observation after each one-second delay and on every Activity resume. routed publishes immediately; negative states require two equal samples 750 ms apart, so tunnel setup cannot briefly publish a false exclusion. The poller only keeps the observation current; the confirmation suite is owed by the presentation it produces (owedConfirmation): one automatic run per measurement key (routing identity, coverage, self configuration, change epoch) that no measurement and no attempt covers, so a re-established tunnel, an excluded → routed return or a coverage change each get exactly one run, whether the app was in the foreground for the edge or not. Repeated samples of one routed session reveal no new key and rerun nothing. User Retry uses the same cache and queues an explicit suite behind any automatic run already in progress, so the click cannot be absorbed by that run.

6. Empirical facts that shape the checks

  • SELinux can carry part of "protection", and it is invisible without the differential. /proc/net/if_inet6 and /proc/net/dev still have no kernel backend hook. /sys/class/net and the per-interface /proc/sys/net trees are covered by a kernel backend only when the optional filesystem feature was enabled before reboot; Zygisk provides a weaker libc-routed version after the target process restarts. When SELinux denies a path first, the differential attributes that result to SELinux rather than overstating backend coverage. This is also why permissive devices need an explicit warning.
  • 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, verified on Pixel 4a

The native backend hooks map to the diagnostic checks below. The kernel backends (.ko, built-in and KPM: the first two are one driver, loaded versus compiled in) provide the strongest coverage; the optional Zygisk filesystem group covers ordinary libc-routed probes but not raw syscalls or aliases. Full hiding matrix in detection-vectors.md.

check idprobeskernel hooknotes
ioctl_flags, ioctl_mtuSIOCGIF* by namedev_ioctlENODEV for tun0
ioctl_confSIOCGIFCONFsock_ioctltun0 absent from ifconf
getifaddrs, netlink_getlinkRTM_GETLINK / getifaddrsrtnl_fill_ifinfo, inet*_fill_ifaddr
so_bindtodevicesetsockopt(SO_BINDTODEVICE, tun0)socket_bind_interfaceENODEV = hidden; a bind that succeeds and getsockopt echoes tun0 = leak
netlink_getrouteRTM_GETROUTE v4/v6fib_dump_info, rt6_fill_node
netlink_getruleRTM_GETRULE policy rulesfib_nl_fill_rulev4+v6; kernel-only vector
proc_route/proc/net/routefib_route_seq_showmain table — empty for split-tunnel VPN
proc_ipv6_route/proc/net/ipv6_routeipv6_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/netfilesystem_iface_paths (.ko/KPM/Zygisk, optional)kernel: resolved-dentry and reboot-gated; Zygisk: best-effort libc and restart-gated
proc_sys_net/proc/sys/net/*/{conf,neigh}filesystem_iface_paths (.ko/KPM/Zygisk, optional)kernel: resolved-dentry and reboot-gated; Zygisk: best-effort libc and restart-gated

The so_bindtodevice check probes this vector from the app process — setsockopt(SO_BINDTODEVICE, "tun0"), classified by the same root differential. To avoid blessing a broken return-only implementation on errno alone, a bind is a leak only when it takes: getsockopt must echo tun0 back (so a hook that returns 0 without binding — or the kernel's own capability block — is not a false positive), while ENODEV is the backend's pre-mutation denial. This catches the common failure (a bindable VPN interface), but a single process cannot verify the pre-mutation property itself — that the socket was never bound before ENODEV. The QEMU bind-probe still performs that full state-level test with a raw syscall and a second, non-target UID inspecting the inherited socket. Runtime deny hits also appear in Statistics. Zygisk has its own libc-routed zygisk_setsockopt hook but does not claim this vector in its owned-hook mask (so a leak here reads as an unowned surface under Zygisk, not a tile failure) and emits no per-hook statistics yet.

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.

The network_view check is cross-vector rather than single-surface: it captures the app's synchronous network model — every handle and the capabilities, link properties and NetworkInfo each answers, the legacy type answers and a blind netId scan — and asserts it holds together. The active handle must be listed by getAllNetworks(), a listed network must carry a transport, a connected one an interface, its NetworkInfo type must name a transport it has, no VPN handle may answer outside the enumeration, and (under the gate) no VPN transport, handle or active-VPN legacy state may appear anywhere. It catches an incoherence the per-surface checks miss — a handle whose facts describe a different network. The same invariants are what the debug bundle's networkView and the external scripts/network-view-probe.py evaluate. The push-callback coherence half — that a pushed handle's link properties name the interface getLinkProperties returns for it — is folded into the network_callback check, which now fails a callback that is clean in capabilities but carries a mismatched interface.

Network enumeration failure is distinct from an empty successful enumeration: checks depending on that list are not applicable until it can be read. Failed netId scan reads are recorded as errors rather than proof of no phantom networks. The capture rechecks the active handle and enumeration at the end; an observed change makes cross-call consistency comparisons inconclusive. This is a stability check, not an atomic framework snapshot. Direct observations of VPN transport or active VPN legacy state still fail even when unrelated reads failed. Only violations whose prerequisites were observed take precedence over capture errors.

The callback probe publishes a complete pair for one handle atomically and retains any observed VPN capabilities, even if no LinkProperties event arrives. Registration/read failures without a VPN observation are not measured. These results use the existing CheckOutcome and measurement completeness rollup; the additional forensic snapshot remains a separate point-in-time capture.

The network_info_vpn check compares the app-side getNetworkInfo(TYPE_VPN) reply with VPN entries in getAllNetworkInfo(), after Binder unmarshalling. Both paths must retain type 17 with state DISCONNECTED and detailed state DISCONNECTED or policy BLOCKED. A disconnected Wi-Fi/mobile substitution, an active/transitional VPN state, or a missing/duplicate enumeration entry is a failure. Separate calls may straddle a UID-policy change, so a difference between DISCONNECTED and BLOCKED alone is not a failure. Availability is included in the detail for inspection, not treated as connection status. A direct null is inconclusive (NotMeasured), unless enumeration already proves a failure: null is allowed by Android but cannot verify correct legacy type semantics. In particular, the old nulling hook no longer earns a green pass. This check measures the current VPN-on run; it does not replace comparing VPN-off baselines with hiding enabled and disabled on a test device.