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 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 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 == 0→ Okhidden > 0 && leaks > 0→ Partial (hides some, an owned vector still leaks)hidden == 0 && leaks > 0→ Broken (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_inet6and/proc/net/devstill have no kernel backend hook./sys/class/netand the per-interface/proc/sys/nettrees 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 toNothingToLeak— while RTM_GETRULE (policy rules) is the real routing detection vector, added as a probe mirroring thefib_nl_fill_rulekernel filter. - Some checks never fire on any config: both
/proc/net/routereads (native + Java) and the removed system-proxy check. They only added false confidence; the route reads are kept because the differential now labels themNothingToLeakhonestly, 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 +
hiddenwithout 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 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 | |
so_bindtodevice | setsockopt(SO_BINDTODEVICE, tun0) | socket_bind_interface | ENODEV = hidden; a bind that succeeds and getsockopt echoes tun0 = leak |
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 | filesystem_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.