Commit graph

949 commits

Author SHA1 Message Date
Danila Gornushko
0067f997d9
Merge pull request #317 from okhsunrog/fix/hook-debug-flag-stale
Some checks failed
CI / kmod (android12-5.10) (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / kmod-activator (push) Has been cancelled
CI / kmod (android13-5.10) (push) Has been cancelled
CI / kmod (android13-5.15) (push) Has been cancelled
CI / kmod (android14-5.15) (push) Has been cancelled
CI / kmod (android14-6.1) (push) Has been cancelled
CI / kmod (android15-6.6) (push) Has been cancelled
CI / kmod (android16-6.12) (push) Has been cancelled
CI / kmod-qemu (android12-5.10) (push) Has been cancelled
CI / kmod-qemu (android13-5.10) (push) Has been cancelled
CI / kmod-qemu (android14-6.1) (push) Has been cancelled
CI / kmod-qemu (android15-6.6) (push) Has been cancelled
CI / kmod-qemu (android16-6.12) (push) Has been cancelled
CI / kpm-qemu (android12-5.10) (push) Has been cancelled
CI / kpm-qemu (android13-5.10) (push) Has been cancelled
CI / kpm-qemu (android13-5.15) (push) Has been cancelled
CI / kpm-qemu (android14-5.15) (push) Has been cancelled
CI / kpm-qemu (android14-6.1) (push) Has been cancelled
CI / kpm-qemu (android15-6.6) (push) Has been cancelled
CI / kpm-qemu (android16-6.12) (push) Has been cancelled
CI / kpm-qemu-legacy (4.14) (push) Has been cancelled
CI / kpm-qemu-legacy (4.19) (push) Has been cancelled
CI / kpm-qemu-legacy (4.9) (push) Has been cancelled
CI / kpm-qemu-legacy (5.4) (push) Has been cancelled
CI / kpm (push) Has been cancelled
CI / zygisk (push) Has been cancelled
CI / lsposed (push) Has been cancelled
CI / portshide (push) Has been cancelled
CI / release (push) Has been cancelled
Stop the LSPosed hooks' debug flag from depending on inotify
2026-08-26 12:13:46 +03:00
okhsunrog
cd5b94e2d1 fix(lsposed): stop the hooks' debug flag from depending on inotify
Found on a live Pixel 8 Pro: system_server was writing VpnHide-NC/NI lines to
logcat — the ones that name the exact UIDs we hook — at ~3/s, five days after
Debug logging had been turned off. The canonical config on disk said
debug:false and had said so since the day after that boot.

HookLog.enabled was assigned in exactly one place, HookLog.reload(), reachable
only from install() at boot and from the FileObserver callback. That observer
had stopped delivering: an atomic rename over the config produced no callback at
all, confirmed on the device. So the flag was frozen at whatever the config said
at boot, and no amount of toggling in the app could move it — only a reboot.

The irony is that SystemServerConfigCache sits right beside it re-reading the
same file every second by fingerprint, and was current the whole time. The
hooks' targeting was right; only the logging flag was stale. So give the flag to
the component that already polls: the cache sets HookLog.enabled whenever it
installs a fresh config. The watcher stays, as latency rather than correctness —
it makes a flip land immediately instead of within a second — and the comments
now say so, because the next person to add state here will be tempted to hang it
off the same callback.

Not a leak: nothing an app can read. It is a toggle that silently did nothing,
plus a stream of Binder-hot-path log calls the user asked us to stop making.
2026-08-26 02:01:21 +03:00
Danila Gornushko
d2877caba0
Merge pull request #316 from okhsunrog/chore/clear-compiler-warnings
Some checks are pending
CI / kmod (android12-5.10) (push) Blocked by required conditions
CI / kmod (android13-5.10) (push) Blocked by required conditions
CI / kmod (android13-5.15) (push) Blocked by required conditions
CI / kmod (android14-5.15) (push) Blocked by required conditions
CI / kmod (android14-6.1) (push) Blocked by required conditions
CI / kmod (android15-6.6) (push) Blocked by required conditions
CI / kmod (android16-6.12) (push) Blocked by required conditions
CI / kmod-qemu (android12-5.10) (push) Blocked by required conditions
CI / kmod-qemu (android13-5.10) (push) Blocked by required conditions
CI / kmod-qemu (android13-5.15) (push) Blocked by required conditions
CI / kmod-qemu (android14-5.15) (push) Blocked by required conditions
CI / kmod-qemu (android14-6.1) (push) Blocked by required conditions
CI / kmod-qemu (android15-6.6) (push) Blocked by required conditions
CI / kmod-qemu (android16-6.12) (push) Blocked by required conditions
CI / kpm-qemu (android12-5.10) (push) Blocked by required conditions
CI / kpm-qemu (android13-5.10) (push) Blocked by required conditions
CI / kpm-qemu (android13-5.15) (push) Blocked by required conditions
CI / kpm-qemu (android14-5.15) (push) Blocked by required conditions
CI / kpm-qemu (android14-6.1) (push) Blocked by required conditions
CI / kpm-qemu (android15-6.6) (push) Blocked by required conditions
CI / kpm-qemu (android16-6.12) (push) Blocked by required conditions
CI / kpm-qemu-legacy (4.14) (push) Blocked by required conditions
CI / kpm-qemu-legacy (4.19) (push) Blocked by required conditions
CI / kpm-qemu-legacy (4.9) (push) Blocked by required conditions
CI / kpm-qemu-legacy (5.4) (push) Blocked by required conditions
CI / kpm (push) Blocked by required conditions
CI / zygisk (push) Blocked by required conditions
CI / lsposed (push) Blocked by required conditions
CI / portshide (push) Blocked by required conditions
CI / release (push) Blocked by required conditions
Clear the standing compiler warnings and gate on them in CI
2026-08-26 01:32:15 +03:00
okhsunrog
0cd33b6589 ci(lsposed): fail the build on Kotlin warnings
The module now compiles clean, so make that the enforced state rather than a
snapshot. `-PvpnhideWarningsAsErrors=true` on the CI Gradle invocations, matching
how -PvpnhideEmulatorX86 is already wired; off by default, because a warning
mid-edit is information, not a reason to stop a local build.

The usual objection to allWarningsAsErrors is that a toolchain bump turns
unrelated new warnings into a red CI on someone else's PR. That does not apply
here: every input that decides what warns is pinned — Gradle by the wrapper,
AGP/Kotlin/Compose in libs.versions.toml, the NDK by ndkVersion, and the JDK by
the CI container image. New warnings can only arrive with a deliberate version
bump, on the PR that does the bumping, where they belong.

Verified both directions: reintroducing the nullable-receiver warning fixed in
the previous commit passes without the flag and fails with it.
2026-08-26 01:23:01 +03:00
okhsunrog
99a69a417a chore(lsposed): clear the six standing compiler warnings
Six is enough noise that a seventh — a real one — scrolls past unread. Only one
of them was a latent defect; the rest are recorded here so nobody re-litigates
them later.

The real one: BundleSchemaGoldenTest called File.parentFile.mkdirs() on a
receiver Kotlin now types as nullable. It cannot actually be null for the
relative path used there, but the warning is on its way to becoming an error,
and a golden-refresh path that NPEs would be found at the worst moment.

The other five were deliberate: four `Unit` literals standing in for empty
branches in Compose `when` slots, and one Json format rebuilt inside a test
body. The `Unit`s said "nothing happens here", which is what an empty branch
already says, so the branches are now empty; the format is built once per class.

No behaviour change. Not adding allWarningsAsErrors along with this: it would
lock in a clean build today at the cost of breaking CI on the next Kotlin or AGP
bump, which is the maintainer's call rather than a side effect of a cleanup.
2026-08-26 01:08:05 +03:00
Danila Gornushko
d4197f63d0
Merge pull request #315 from okhsunrog/refactor/dashboard-issues
Make the dashboard's banner logic a pure, tested function
2026-08-25 23:07:29 +03:00
okhsunrog
d8c4585d04 refactor(lsposed): make the dashboard's banner logic a pure, tested function
loadDashboardState was 623 lines, and the ~25 guards that decide which banners
a device gets were ~290 of them, each ending in a res.getString. That shape is
what kept them untested: every branch needed a real Resources, this module has
no Robolectric, and so the single most user-visible piece of logic in the app
had zero coverage while the pure detectors feeding it had plenty.

Split along the seam the codebase already uses elsewhere — classifyKmodProblem
/renderKmodProblem, classifyKpmProblem/renderKpmProblem. dashboardIssues() takes
a DashboardFacts and returns List<DashboardIssue>, no Android types anywhere;
DashboardIssue.toMessage() words one. A case carries the data its wording needs
rather than a preformatted string, so the split is real and not cosmetic.
filesystemHidingDashboardMessage was the odd one out doing both halves itself;
it folds in, and its file goes.

Emission order is load-bearing — the screen groups by severity but keeps
emission order within a group, so this decides which error a user reads first.
It used to be implied by the physical layout of one long block, where inserting
a guard in the wrong place silently reordered banners. It is now eight named
calls in one place, pinned by a test.

The four backends' diagnosis blocks were near-identical eighteen-line copies
differing only in kind, activator path and classifier; they collapse into
deriveModuleFact. Derivation splits into deriveModuleFacts / deriveLsposedFacts
/ deriveEnvironmentFacts / resolveProtectionFacts, leaving an orchestrator that
reads as the four steps it always was. Both detekt suppressions come off — the
gate now passes on merit rather than by exemption.

Behaviour is unchanged, checked emission by emission against the original: all
34 sites map one-to-one, in the same order, with the same severities. Two things
that only looked like changes: the non-kmod module problems dropped their
downloadArtifact at the callsite, which was a no-op because only renderKmodProblem
ever sets one, and Active.version being null already meant no mismatch banner
because versionsMismatch returns false for it. One dead local (zygiskStatusRaw)
went with the rewrite.

Also fixes a mark the profiler has been asking for and never received:
measure-startup.py reads dashboard_issues_done, the code only ever emitted
dashboard_messages_done, so the "issues" stage has always measured nothing.
2026-08-25 17:38:15 +03:00
Danila Gornushko
da81f1c6e4
Merge pull request #314 from okhsunrog/refactor/cache-registry-and-suppressions
Some checks failed
CI / kmod (android16-6.12) (push) Blocked by required conditions
CI / kmod-qemu (android14-5.15) (push) Blocked by required conditions
CI / kmod-qemu (android14-6.1) (push) Blocked by required conditions
CI / kmod-qemu (android15-6.6) (push) Blocked by required conditions
CI / kmod-qemu (android16-6.12) (push) Blocked by required conditions
CI / kpm-qemu (android12-5.10) (push) Blocked by required conditions
CI / kpm-qemu (android13-5.10) (push) Blocked by required conditions
CI / kpm-qemu (android13-5.15) (push) Blocked by required conditions
CI / kpm-qemu (android14-6.1) (push) Blocked by required conditions
CI / kpm-qemu-legacy (4.14) (push) Blocked by required conditions
CI / kpm-qemu-legacy (4.9) (push) Blocked by required conditions
CI / kpm-qemu-legacy (5.4) (push) Blocked by required conditions
CI / kpm (push) Blocked by required conditions
CI / kmod-qemu (android13-5.15) (push) Blocked by required conditions
CI / kpm-qemu (android14-5.15) (push) Blocked by required conditions
CI / kpm-qemu (android15-6.6) (push) Blocked by required conditions
CI / kpm-qemu (android16-6.12) (push) Blocked by required conditions
CI / kpm-qemu-legacy (4.19) (push) Blocked by required conditions
CI / zygisk (push) Blocked by required conditions
CI / lsposed (push) Blocked by required conditions
CI / portshide (push) Blocked by required conditions
CI / release (push) Blocked by required conditions
QEMU Test Images / build-push (1290154c48656bd1012a837076a0fbe379330eb3, android12-5.10) (push) Has been cancelled
QEMU Test Images / build-push (309759bcbe6d85ba8c702d8e1c83be556304722d, android16-6.12) (push) Has been cancelled
QEMU Test Images / build-push (3f3ca1c40482a51cc007a86606ea1516fd4c5077, android14-6.1) (push) Has been cancelled
QEMU Test Images / build-push (cf8ba5678385b2b8832ed2e87c035925eb12bc4a, android15-6.6) (push) Has been cancelled
QEMU Test Images / build-push (f9b6dcd0cc376e7e90e86d06ec344202425d6878, android14-5.15) (push) Has been cancelled
QEMU Test Images / build-push (fe59c182e7da104c649b661eac72fb2a2da0bb01, android13-5.15) (push) Has been cancelled
QEMU Test Images / build-legacy (push) Has been cancelled
QEMU Test Images / build-push (0e7b22b8ec1e944e03b3ab937657aff77f67822e, android13-5.10) (push) Has been cancelled
Cache-refresh membership rule, honest @Suppress, diagnostics glossary
2026-08-25 15:23:16 +03:00
okhsunrog
50f5350630 docs(lsposed): glossary for the seven things called "diagnostic"
The word is attached to the check suite, that suite's run state, the canonical
report model, the precondition gate, hook attach telemetry, the export, and two
bundle section names. None of it is misfiled — they really are all diagnostics —
but the name alone no longer tells you which layer you are in, and the pair that
actually bites is DiagnosticsCache (a run) versus RoutingGateCache (a
precondition, and the only one of the two derived from the canonical config).

Also records the derivedCaches membership rule next to the StateCache entry, so
someone adding a cache reads it where they are already looking.
2026-08-25 15:04:12 +03:00
okhsunrog
b2820f1d5f refactor(lsposed): say why each @Suppress is there, and stop repeating it
Three unrelated things were wearing the same annotation, so the annotation had
stopped carrying information.

JavaChecks used six per-function DEPRECATION suppressions for one reason: the
deprecated ConnectivityManager surface is the point of that file. allNetworks,
getNetworkInfo(type), the network-handle calls — those are what a VPN-probing
app reaches for, so the checks proving we hid the tunnel must reach for the same
ones, and "modernising" them would quietly drop detection coverage. Stated once
at file level with a pointer to docs/detection-vectors.md. The file holds no
non-probe code for the blanket to hide a real warning in; it also carried 34
imports left behind when the UI moved out, now gone.

Motion.kt repeated one identical UNCHECKED_CAST six times to re-type specs
cached as Any. One private helper, one suppression, and the soundness argument
(these are tweens and thresholdless springs — they never touch a value of T)
written down, along with what must not be routed through it.

The four casts in the hook process are NOT collapsible the same way, which is
worth recording: `as?` against a concrete generic type still checks the raw
class at runtime, so a ROM that reshaped a field falls out as null. Behind a
helper taking an unbounded T the cast erases and that check silently disappears,
turning a clean bail-out into a ClassCastException somewhere later. They keep
their own suppressions and now state which AOSP declaration each one trusts.
2026-08-25 15:04:10 +03:00
okhsunrog
6b648a4444 refactor(lsposed): make the post-write cache set a list, not a procedure
The four config-derived caches were refreshed by four open-coded calls whose
two invariants — root snapshot first, everyone else with force=false — lived
only in a comment. Getting the flag wrong is not a crash, it is a silent extra
root shell per cache, so nothing would ever surface the mistake.

They are a list now, iterated after the root refresh, with the membership rule
written down: a cache belongs here iff its load reads the canonical config. The
caches that deliberately do not qualify are named too, since "did someone forget
to add this one?" was previously answerable only by reading all nine of them.

Skip caches that have never loaded. refreshInPlace bypasses the concrete cache's
ensureLoaded, so calling it on a pristine cache runs load() without the inputs
that method stashes; the load fails, the cache records the error, and ensure()
then early-returns on that error forever. A cache with no value has nothing that
can go stale, so there was never a reason to touch it. Replaces the runCatching
that was papering over exactly this for RoutingGateCache.
2026-08-25 15:04:09 +03:00
Danila Gornushko
a8f8b7717a
Merge pull request #313 from okhsunrog/diag/kmod-net-break-probe
Field-debug node for the kmod, and stop truncating bundles on big ROMs
2026-08-25 14:27:59 +03:00
okhsunrog
20b5ba381d docs: record the diagnostic proc node and the bundle ordering
The branch added /proc/vpnhide_diag and a kmod_diag bundle section but
documented neither, and this repo's rule is that every path it creates is
written down with its owner and lifetime.

Also logs the truncation fix that came with the reordering: bundles from
bloatware-heavy ROMs were losing exactly the network and routing sections
a connectivity report needs, because the per-user package scan ahead of
them ate the su timeout.
2026-08-25 14:19:40 +03:00
okhsunrog
e51c1abbdd diag(kmod): add VPNHIDE_BARE_INIT early-return build flag
Build-time -DVPNHIDE_BARE_INIT makes vpnhide_init return immediately (no hooks,
no proc nodes, no symbol resolution) so a loaded-but-does-nothing .ko can be
tested in the field to separate 'loading the module at all' from anything the
module's init does. Default builds unaffected.
2026-08-25 14:18:04 +03:00
okhsunrog
b1948a0c71 diag(kmod): build-time probe_mask hook gate + front-load network snapshot sections
probe_mask (module_param, baked in via -DVPNHIDE_PROBE_MASK_DEFAULT) gates which
hooks register at load — bits 0-9 the kretprobes, 10 socket-bind, 11 filesystem
(ANDed with filesystem_hiding). Default 0xfff = current behaviour; a diagnostic
variant bakes in a reduced set with no source edit. A fully-masked build stays
loaded-but-inert (init no longer aborts when nothing was attempted). Surfaced in
/proc/vpnhide_diag.

Also reorder the debug snapshot so network_*/proc_net_* emit before the heavy
per-user pm scans (which overran the su timeout on bloatware-heavy HyperOS and
truncated exactly the route sections); timeout 60s->120s as backstop.
2026-08-25 14:18:04 +03:00
okhsunrog
418fdc8bd0 feat(kmod): add /proc/vpnhide_diag field-debug node
Read-only proc node, separate from the frozen control/telemetry wire, for
root-causing field reports where the module perturbs networking. Surfaces
kernel-internal state that /proc/vpnhide_ctl does not: per-probe registration
plus kretprobe/kprobe nmissed counters (to see if a device exhausts
VPNHIDE_KRETPROBE_MAXACTIVE), active vs installed hook masks, and the LIVE
is_vpn_ifname() verdict against every netdev in the reader's netns. Reports the
current verdict as-is, bugs included, so a false-positive interface match is
observable. The debug-bundle collector cats it into a new kmod_diag section; no
UI surface.
2026-08-25 14:17:10 +03:00
Danila Gornushko
fd89279395
Merge pull request #312 from okhsunrog/refactor/shell-to-resources
Move the root probes into .sh files, and group the flat package
2026-08-25 14:15:58 +03:00
okhsunrog
a50dc24cb0 refactor(lsposed): group the remaining feature leaves into packages
The rest of the cheap moves out of the flat package: picker/ (the Hiding
tab), diagnostics/ (the check suite and its report), settings/ and debug/.
Each holds a feature's screen, its cache and its pure model together, so
the tree says what the module is made of instead of listing 96 files.

42 files stay at the root on purpose — the vocabulary both processes and
several features share (StorageConfig, ShellUtils, RootSnapshotCache,
HookRegistry, DashboardData, the agent bridge). Moving those would be
import churn with nothing gained; dashboard/ and agent/ are left alone for
the same reason.

No logic touched: package lines, imports, and a layout table in AGENTS.md.
2026-08-25 14:08:41 +03:00
okhsunrog
f723943677 refactor(lsposed): move startup/ and statistics/ into their own packages
Two of the cheap leaves from the flat 96-file package: nothing else in the
module reaches into them beyond a handful of entry points, so the move is
mechanical and the tree gains two names that say what they hold.

- startup/: MainActivity, StartupCoordinator, StartupTrace and the
  blocking screen. The manifest and scripts/measure-startup.py follow the
  activity to `.startup.MainActivity` — verified against the built APK,
  which carries dev/okhsunrog/vpnhide/startup/MainActivity
- statistics/: the screen, its cache and the pure counter model

No logic touched; the diff is package lines and imports.
2026-08-25 13:51:08 +03:00
okhsunrog
e18c8b5eee refactor(lsposed): move the root probes out of Kotlin string literals
~700 lines of shell lived inside `"""…"""` blocks with every `$` written
as `${'$'}`. That cost a release: an apostrophe in a comment inside a
single-quoted block ended the quoting, the batched command stopped
parsing, and every forensic section vanished from bug reports until
someone read a bundle closely (#306). Nothing could have caught it —
the only check was a `contains("emit_file …")` assertion, and shellcheck
cannot read Kotlin.

The scripts are now files under `app/src/main/resources/shell/`, verified
to be packaged into the APK, and CI shellchecks them beside the module
scripts. Parameters (paths, framing prefixes, the two feature flags)
arrive as a generated assignment prelude, so the values stay defined once
in Kotlin and the script stays something you can lint and run.

Equivalence was checked by executing the old and new commands and
comparing the section sequences they emit: 48 identical sections for the
root probe, 60 for the debug probe, same order.

- root_snapshot.sh, debug_snapshot.sh, hook_counters.sh, and the shared
  package_inventory.sh (now a function the callers invoke)
- shellcheck found what it was brought in for: a dozen unquoted
  expansions and a pattern-matching `${FRAC%${FRAC#???}}`. The `eval`-body
  quoting rule is suppressed per file with the reason
- tests assert against the prelude and the script's use of a variable
  rather than an interpolated literal
2026-08-25 13:42:41 +03:00
Danila Gornushko
d8f6542b22
Merge pull request #311 from okhsunrog/fix/zygisk-bind-probe
Zygisk: mirror the kernel's answer for an absent interface
2026-08-25 13:21:57 +03:00
Danila Gornushko
98fe9c7c37
Merge pull request #310 from okhsunrog/fix/bind-hook-and-native-panics
Cover the bind vector below kernel 5.9, and stop a probe panic from killing the app
2026-08-25 13:21:43 +03:00
Danila Gornushko
a1187d8289
Merge pull request #309 from okhsunrog/docs/setup-examples
Say which app the roles go on
2026-08-25 13:21:35 +03:00
okhsunrog
ba6311e82c fix(zygisk): mirror the kernel's answer for an absent interface
The bind hook decided whether to act by comparing the release string:
below 5.7 it stayed inert, on the assumption that the kernel refuses an
unprivileged bind before it even parses the name, so a name-specific
ENODEV from us would announce the interface instead of hiding it. The
oracle argument is sound; the version test is not the way to ask it. A
LineageOS 5.4 build (the KPM report in the previous commit) lets an app
bind to tun0, and there the same gate means we simply do not hide.

Replaced with the question that actually decides it: what does this
kernel return for a bind to a name that cannot exist? Denying a hidden
interface with exactly that errno is oracle-free by construction —
EPERM where every bind is refused (indistinguishable, as today), ENODEV
where names resolve first (hidden, as on 5.7+).

- one socket + one setsockopt through the real libc entry, cached for the
  process; an unusable measurement falls back to the old heuristic, so
  behaviour is never worse than before
- hidden_bind_errno is pure and unit-tested over the decision table, and
  the hook test now covers the EPERM-mirroring path end to end
- bind-probe gains a bind_absent_name case, so the QEMU lanes record what
  each supported kernel family answers instead of us assuming it
2026-08-25 12:57:44 +03:00
okhsunrog
fdde1d073b fix(kpm): deny a hidden bind only where the caller could have bound
The index-helper hook runs before that helper's own CAP_NET_RAW check, so
it answered ENODEV even for callers the kernel was about to refuse with
EPERM. On a tree where the check bites, a VPN name then reads differently
from every other name — the exact oracle the Zygisk hook refuses to
create — and widening the hook to all kernels below 5.9 in the previous
commit widened that to the 4.x families where the check really does bite.

Ask capable(CAP_NET_RAW) first and stay out of the way when it fails: the
kernel refuses those callers itself, identically for every interface.
Deny only the ones that would otherwise have bound, which is the case the
LineageOS 5.4 report is about. capable() needs no struct offsets, so it
cannot go stale on a vendor kernel the way an offset table can; a kernel
where it will not resolve keeps the previous unconditional denial.
2026-08-25 12:57:32 +03:00
okhsunrog
f565e0dcc1 docs: say which app the roles go on
A user on 4PDA configured VPN Hide by putting roles on his VPN app and
then asked for a manual with real examples. The roles list explains what
Java/Native/Apps/Ports each do, but never says whose row to set them on —
and the answer (the detector app, not the VPN) is the one thing you
cannot guess from the labels.

- a "which app do I configure" block, first in the Hiding tab's help
  sheet, where the confusion actually happens
- a worked-examples table in all three READMEs: bank that must not see
  the VPN, bank that also scans the app list, an app that objects to
  Zygisk, an app probing a localhost proxy port — plus a concrete
  bank + WireGuard example
2026-08-24 19:04:26 +03:00
okhsunrog
965516218b fix(storage): stop settings writes from rebuilding the app list
`writeSuperkeySetting` and `writeFilesystemHidingSetting` each change one
field in `settings`, but took their base from
`buildCanonicalConfigFromTargetsSnapshot` — rebuilding every app's roles
from the snapshot's per-role sets and writing that back. One of those
sets round-tripped through UIDs: `appHiding` was stored as resolved UIDs
and mapped back through `pm list packages`, so a target the inventory
could not see (a profile the scan failed to read — the case #293 added
diagnostics for) disappeared from the projection and lost its role on
disk. A toggle unrelated to the app list silently unconfigured an app.

- both writers take `snapshot.canonicalConfig` and copy the one field
- TargetsSnapshot no longer stores the five per-role sets beside the
  config they came from. They are projections of it now, so the object
  cannot carry two versions of one truth, and `observerNames` no longer
  passes through UIDs at all. `observerUids` stays for the consumers that
  need the wire's language, derived on demand
- regression test: an app-hiding target absent from the inventory keeps
  its role

Not reproduced on a device — the chain is read off the code, and the
missing-from-inventory precondition is one users have hit.
2026-08-24 18:58:55 +03:00
okhsunrog
528bb16dae fix(zygisk): read the ioctl interface name through the fault-contained path
The SIOCGIF* pre-screen dereferenced the caller's `arg` before the real
ioctl had a chance to validate it. A target app passing a bad or short
pointer — the case the kernel answers with EFAULT — instead took a
SIGSEGV inside its own process, caused by our hook. The setsockopt hook
was written to avoid exactly this (copy_from_self, with the reasoning in
its doc comment); the ioctl path was the inconsistent one.

Reads the 16-byte name through copy_from_self and passes a non-socket fd
straight through, since this ioctl family cannot apply to one. Fault
containment itself is already covered by
self_copy_contains_bad_caller_pointers.

Found by a review pass over the project's unsafe code.
2026-08-24 18:51:15 +03:00
okhsunrog
6646bf1d06 fix(native): a panicking probe should not kill the app
Two devices report the app dying at startup whenever a VPN interface is
up, with nothing in the app's own logs. The probe crate built with
`panic = "abort"`, and the full check suite runs on every cold start, so
any panic in a probe — parsing whatever an arbitrary vendor kernel hands
back — took the process down with SIGABRT and no Java trace. The JNI
entry already wrapped the run in `catch_unwind` (via jni's `with_env` +
ThrowRuntimeExAndDefault); abort made that dead code.

- release and dev profiles unwind. Costs ~39 KB of unwind tables on
  arm64, the only ABI we ship
- a panic hook logs the message and its file:line to logcat under
  VpnHide-Native, which the bundle's filter now captures. The default
  hook writes to stderr, which for an app process goes nowhere — this is
  why such a crash leaves no trace. Backtraces are deliberately not
  attempted: fat LTO + strip would make them unsymbolised addresses
- the Kotlin caller swallows a thrown probe run, so the worst case is one
  empty check run instead of a dead app
- pass the SIOCGIFFLAGS/SIOCGIFMTU ifreq by unique reference. The kernel
  writes the result through that pointer, so deriving it from a shared
  borrow is UB — and it is the tun0-visible branch that reads the value
  back, exactly the state these reports are about
2026-08-24 18:51:15 +03:00
okhsunrog
b21b126e3d fix(kpm): hook the bind helper by symbol instead of by kernel version
@sliva_ru reported `setsockopt SO_BINDTODEVICE tun0` leaking on a
self-built LineageOS sm8350 (5.4.302-qgki) while the KPM was fully
healthy — every hook installed, error 0x0. The vector was never covered
there: the resolved-ifindex hook was wired only for kernels in [5.7,
5.9), and below that we deliberately relied on the kernel refusing an
unprivileged bind without CAP_NET_RAW.

That assumption came from upstream sources and the QEMU reference images,
and it does not survive contact with vendor trees. His kernel has the
gate compiled in — `sock_bindtoindex_locked` calls `ns_capable`, verified
by disassembling the vmlinux rebuilt from his boot image — and an
untrusted app still bound a socket to tun0. Whatever satisfies the check
on that ROM, a kernel-side policy we do not control cannot back a
coverage claim.

- probe both helper names by symbol on any kernel below 5.9: upstream
  renamed sock_setbindtodevice_locked to sock_bindtoindex_locked in 5.8,
  and 5.4 vendor trees backport the newer one (his does)
- when neither resolves, leave the hook bit clear rather than reporting a
  vector we do not cover
- the partial-hooks warning now fires only when a missing hook costs a
  measured vector, so kernels that never had the symbol — and close the
  surface by capability or SELinux anyway — stay quiet
- correct the invariant in detection-vectors.md and at the guard in
  socket_bind_before_common, with the counterexample, so it does not get
  reintroduced. The zygisk hook still carries the same version gate; its
  oracle argument holds only while every bind is refused, noted in the
  doc as wanting a runtime probe.
2026-08-24 18:51:15 +03:00
Danila Gornushko
b19660ee71
Merge pull request #307 from okhsunrog/chore/bundle-schema-versioning
Some checks are pending
CI / kmod-activator (push) Blocked by required conditions
CI / kmod (android12-5.10) (push) Blocked by required conditions
CI / kmod (android13-5.10) (push) Blocked by required conditions
CI / kmod-qemu (android13-5.10) (push) Blocked by required conditions
CI / kmod (android14-5.15) (push) Blocked by required conditions
CI / kmod (android14-6.1) (push) Blocked by required conditions
CI / kmod (android15-6.6) (push) Blocked by required conditions
CI / kmod (android16-6.12) (push) Blocked by required conditions
CI / kmod-qemu (android12-5.10) (push) Blocked by required conditions
CI / kmod-qemu (android13-5.15) (push) Blocked by required conditions
CI / kmod-qemu (android14-5.15) (push) Blocked by required conditions
CI / kmod-qemu (android14-6.1) (push) Blocked by required conditions
CI / kmod-qemu (android15-6.6) (push) Blocked by required conditions
CI / kmod-qemu (android16-6.12) (push) Blocked by required conditions
CI / kpm-qemu (android12-5.10) (push) Blocked by required conditions
CI / kpm-qemu (android13-5.10) (push) Blocked by required conditions
CI / kpm-qemu (android13-5.15) (push) Blocked by required conditions
CI / kpm-qemu (android14-5.15) (push) Blocked by required conditions
CI / kpm-qemu (android14-6.1) (push) Blocked by required conditions
CI / kpm-qemu (android15-6.6) (push) Blocked by required conditions
CI / kpm-qemu (android16-6.12) (push) Blocked by required conditions
CI / kpm-qemu-legacy (4.14) (push) Blocked by required conditions
CI / kpm-qemu-legacy (4.19) (push) Blocked by required conditions
CI / kpm-qemu-legacy (4.9) (push) Blocked by required conditions
CI / kpm-qemu-legacy (5.4) (push) Blocked by required conditions
CI / kpm (push) Blocked by required conditions
CI / zygisk (push) Blocked by required conditions
CI / lsposed (push) Blocked by required conditions
CI / portshide (push) Blocked by required conditions
CI / release (push) Blocked by required conditions
Version the debug bundle, and move the system_server code into hook/
2026-08-24 18:48:51 +03:00
Danila Gornushko
1675d2b81d
Merge pull request #308 from okhsunrog/fix/kpm-partial-hooks-diagnostics
Install KPM hooks on CFI/LTO kernels, and explain a partial install when they don't
2026-08-24 18:48:38 +03:00
okhsunrog
4aa83f5ab3 fix(activator): name the credential a refused KPM supercall used
`kpm list supercall failed with rc=-1` was the whole diagnosis in a bug
report — the same message whether the saved SuperKey was rejected, or the
activator only ever had KernelPatch's trusted-`su` grant (enough for the
hello ping it authenticates with, not necessarily for module management).
Telling those apart took reading the activator source alongside a second
capture of superkey_saved.

The failure detail now carries `(auth: saved superkey)` / `(auth: trusted
su)`, so the next bundle answers it by itself.
2026-08-22 18:05:07 +03:00
okhsunrog
03d9a621bb fix(diagnostics): explain a partially loaded native backend
A report came in with the KPM active and green on the dashboard, and a red
`ioctl SIOCGIFCONF` leak in Diagnostics — no way for the user (or for me)
to connect the two. The backend was reporting PARTIAL_HOOKS with three
kernel hooks missing, and nothing rendered that: hook ownership was
derived from the backend family alone, so a vector whose hook never
installed still counted as covered and read as an ordinary leak.

- missingBackendHooks(): the gap between the family set and the mask the
  kernel backend actually reported. Empty for an unread status (that is
  "unknown", not "nothing installed") and for Zygisk, whose mask is
  per-process
- dashboard warning naming the count and the unresolved symbols, so the
  green card is no longer the whole story. Not an error: what installed
  still works, and no reinstall fixes a renamed kernel symbol
- the leaking check now carries its missing hooks and says so when
  expanded, turning "leak" into "your kernel does not expose sock_ioctl"
- classifyKpmProblem gains NeedsSuperkey: APatch answered but refused the
  module control call while no SuperKey is saved. Previously this fell to
  the generic branch, which told the user to collect a log or reinstall
  the zip; the actual fix is one field in Settings → Security
2026-08-22 18:04:51 +03:00
okhsunrog
b86b2f90c6 fix(kpm): resolve hook targets renamed by Clang CFI + full LTO
A bug report from a MediaTek 4.14 device (POCO M3 Pro 5G, HyperOS) came
back with the KPM loaded but reporting PARTIAL_HOOKS and mask 0x20003bc —
sock_ioctl, fib_route_seq_show and ipv6_route_seq_show missing, so
ioctl(SIOCGIFCONF) still enumerated tun0 for targeted apps.

kallsyms on that build carries exactly those three as
`name$<hex>` (Clang CFI + full LTO promoting a local symbol), while every
target that did install is either global or `name.llvm.<decimal>`. The
suffix matcher only accepted `.isra.N` / `.constprop.N` / `.llvm.N`, so
lookup_fn returned 0 and install_hook silently skipped them.

- accept `$<hex>` as a clone suffix, requiring the hex run to end the
  string so the neighbouring `name$<hex>.cfi_jt` jump-table alias is
  rejected — hooking a trampoline would patch the wrong instructions
- move the matcher to shared/vpnhide_logic.h where it is freestanding and
  host-testable, with the observed forms (including the .cfi_jt alias)
  pinned in test_vpnhide_logic.c
- skip module-owned symbols in the kallsyms walk; hook targets are
  vmlinux functions and a same-named module symbol is a different one

The kallsyms correlation is 3/3 missing and 0/9 installed, but this is
not yet confirmed on the device — the reporter is sending a kernel-image
export next, which will show whether the hook then installs.
2026-08-22 18:04:24 +03:00
okhsunrog
62044e4fbd refactor(lsposed): move the system_server code into a hook package
96 files sat flat in one package, which hid the only boundary in this
module with a real failure mode: `hook/` is loaded by LSPosed into
system_server, everything else runs in the app process. Nothing enforced
it — `internal` is module-wide, so a hook file referencing a Compose
screen compiles fine and fails as a system_server crash on a device.

The seam was already clean (no Compose in any Xposed-importing file), so
this is a pure move: 7 files, no logic touched, 12 imports added.

- hook/: HookEntry, PackageVisibilityHooks, SystemServerConfigCache,
  HookLog, HookReflectionProbe, ConnectivityAttachDiagnostics and the
  shared /data/system watcher the three of them use
- shared vocabulary stays at the root package (HookRegistry, LsposedStats,
  canonical-config parsing, LogTags) — it is used by ~15 app-side files
  and belongs to neither process
- assets/xposed_init and the R8 keep rule now name the new class;
  verified against the built APK, which carries
  dev/okhsunrog/vpnhide/hook/HookEntry after minification
- HookPackageBoundaryTest enforces both directions: no UI in hook/, no
  Xposed imports outside it, and xposed_init matching the real package
2026-08-22 05:47:41 +03:00
okhsunrog
d5077c67d0 chore(diagnostics): make the debug-bundle schema a real version
`state.json` carried two schema numbers (one on the state, one on the
report), both pinned at 1 since the format existed, and a KDoc saying
compatibility was not a concern. A number that never moves tells a
triager nothing about the file in front of them, and nothing stopped the
shape from drifting between releases.

One version for the whole bundle, and a golden file so it cannot go
stale: the serialized shape is pinned in a test, which fails on any
change and points at the refresh command and the bump rule.

- give LsposedState and ProtectionCheck explicit @SerialName values.
  Without them kotlinx emits the fully-qualified class name as the `kind`
  discriminator, so those two blocks read
  "dev.okhsunrog.vpnhide.LsposedState.Active" while every other sealed
  type reads "active" — and the value would silently change if the class
  ever moved package
- drop DiagnosticReport.schema; the top-level one is the bundle's version
- bump it to 2 and document the history + bump rules in
  docs/debug-bundle.md §2.1, with the current schema in the triage list
- BundleSchemaGoldenTest pins the encoded shape (including the dashboard
  block, the only path those two sealed types reach) and asserts no
  fully-qualified name reaches the wire
2026-08-22 05:37:26 +03:00
Danila Gornushko
aeda6f3e22
Merge pull request #305 from okhsunrog/feat/legacy-config-import
Some checks failed
CI / kmod-qemu (android14-6.1) (push) Has been cancelled
CI / kmod-qemu (android15-6.6) (push) Has been cancelled
CI / kmod-qemu (android16-6.12) (push) Has been cancelled
CI / qemu-native-probes (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / kmod-activator (push) Has been cancelled
CI / kmod (android12-5.10) (push) Has been cancelled
CI / kmod (android13-5.10) (push) Has been cancelled
CI / kmod-qemu (android12-5.10) (push) Has been cancelled
CI / kmod (android13-5.15) (push) Has been cancelled
CI / kmod (android14-5.15) (push) Has been cancelled
CI / kmod (android14-6.1) (push) Has been cancelled
CI / kmod (android15-6.6) (push) Has been cancelled
CI / kmod (android16-6.12) (push) Has been cancelled
CI / kpm-qemu (android12-5.10) (push) Has been cancelled
CI / kpm-qemu (android13-5.10) (push) Has been cancelled
CI / kpm-qemu (android13-5.15) (push) Has been cancelled
CI / kpm-qemu (android14-5.15) (push) Has been cancelled
CI / kpm-qemu (android14-6.1) (push) Has been cancelled
CI / kpm-qemu (android15-6.6) (push) Has been cancelled
CI / kpm-qemu (android16-6.12) (push) Has been cancelled
CI / kpm-qemu-legacy (4.14) (push) Has been cancelled
CI / kpm-qemu-legacy (4.19) (push) Has been cancelled
CI / kpm-qemu-legacy (4.9) (push) Has been cancelled
CI / kpm-qemu-legacy (5.4) (push) Has been cancelled
CI / kpm (push) Has been cancelled
CI / zygisk (push) Has been cancelled
CI / lsposed (push) Has been cancelled
CI / portshide (push) Has been cancelled
CI / release (push) Has been cancelled
Import (or delete) configs left by pre-1.0 versions
2026-08-22 05:19:28 +03:00
Danila Gornushko
455148d157
Merge pull request #306 from okhsunrog/fix/debug-probe-and-line-truncation
Fix the debug-bundle shell probe and a UTF-8 truncation panic
2026-08-22 05:18:57 +03:00
okhsunrog
1c826f6a86 feat(storage): let the pre-1.0 files be deleted without importing
Importing is not what everyone wants from the leftovers: someone who has
already set the app up again by hand just wants them off /data/adb, and
nothing else on the device removes them — not a module uninstall, not an
app reinstall.

The dialog now offers merge / replace / delete-without-importing. Delete
runs the same rm as the two import paths, but on its own: no canonical
write and no activator run, since the config the backends use does not
change. Caches are reloaded afterwards so the banner and the Settings
entry disappear on the spot.

The three choices moved into the dialog body as full-width rows — three
labels in the button row overflow on narrow screens — and the Settings
entry is now "Pre-1.0 configuration" rather than "Import …", since
importing is no longer the only thing it does.
2026-08-22 04:22:44 +03:00
okhsunrog
a2a389af36 feat(storage): import pre-1.0 configs again
Up to 0.7.1 the hiding config lived in per-component text files. 1.0.0
folded them into the canonical JSON on first launch and 1.2.0 removed
that fold, so a device upgrading straight from 0.7.x to 1.2.x kept the
files on disk, read none of them, and looked like the update had wiped
its settings.

The importer is back, and because 1.2.0 also dropped the cleanup that
deleted those files, the data is still recoverable on affected devices.

- read the pre-1.0 lists in the root snapshot and resolve them to roles,
  mapping observer UIDs back through the current package inventory
- fold them in silently at startup when the canonical config holds no
  user-configured app (auto-hidden VPN apps don't count), same as 1.0.0
- otherwise offer a dashboard banner and a Settings entry with merge
  (union of roles, keeps existing per-hook selections) or replace
  (legacy list wins; settings, self and auto-hide marks are kept)
- delete the legacy files in the same root transaction as the config
  write, so "files gone" is the imported marker; declining only sets a
  DataStore flag that silences the banner
- clean the same paths on Full Reset, and carry them in the debug bundle
2026-08-22 04:22:44 +03:00
okhsunrog
698413075e fix(native): truncate /proc/net lines on a char boundary
`line[..line.len().min(80)]` panics when byte 80 lands inside a
multi-byte character, and the crate builds with `panic = "abort"`, so the
app would die running its own diagnostics. Interface names are arbitrary
bytes, so a line that matches is_vpn_iface can legitimately carry
non-ASCII UTF-8; the truncation branch itself is routine, since
/proc/net/route lines run past 80 bytes.

Backs the cut off to the nearest boundary instead of cutting at a fixed
character count, so the 80-byte budget on the reported detail is kept.

Spotted by DanSqw (github.com/DanSqw/vpnhide).
2026-08-22 04:21:34 +03:00
okhsunrog
76206cbaac fix(diagnostics): unbreak the debug bundle shell probe
An apostrophe in a comment inside the single-quoted app_scan_diagnostics
block closed the quoting early: `#` starts no comment inside `'...'`, so
the `for U in $IDS; do ... done` loop was split across the quote boundary
and the whole batched command failed to parse. Every forensic section was
lost with it — the bundle only carried "root debug snapshot command
failed". Shipped in 1.2.5 via the package-list streaming change.

Rewrites the comment without apostrophes (and says why), plus a test that
runs the generated probes through `sh -n`. A contains("emit_file …")
assertion cannot see this class of break; a real parse can.
2026-08-22 04:21:24 +03:00
okhsunrog
c8280fcf58 docs: add a guide for reading the diagnostic debug bundle
Some checks failed
CI / kmod (android13-5.10) (push) Has been cancelled
CI / kmod (android13-5.15) (push) Has been cancelled
CI / kmod-qemu (android12-5.10) (push) Has been cancelled
CI / kmod-qemu (android13-5.10) (push) Has been cancelled
CI / kmod-qemu (android13-5.15) (push) Has been cancelled
CI / kmod-qemu (android14-5.15) (push) Has been cancelled
CI / kmod-qemu (android14-6.1) (push) Has been cancelled
CI / kmod-qemu (android15-6.6) (push) Has been cancelled
CI / kmod-qemu (android16-6.12) (push) Has been cancelled
CI / kpm-qemu (android12-5.10) (push) Has been cancelled
CI / kpm-qemu (android13-5.10) (push) Has been cancelled
CI / kpm (push) Has been cancelled
CI / zygisk (push) Has been cancelled
CI / lsposed (push) Has been cancelled
CI / portshide (push) Has been cancelled
CI / release (push) Has been cancelled
CI / kmod (android12-5.10) (push) Has been cancelled
CI / kmod (android14-5.15) (push) Has been cancelled
CI / kmod (android14-6.1) (push) Has been cancelled
CI / kmod (android15-6.6) (push) Has been cancelled
CI / kmod (android16-6.12) (push) Has been cancelled
CI / kpm-qemu (android13-5.15) (push) Has been cancelled
CI / kpm-qemu (android14-5.15) (push) Has been cancelled
CI / kpm-qemu (android14-6.1) (push) Has been cancelled
CI / kpm-qemu (android15-6.6) (push) Has been cancelled
CI / kpm-qemu (android16-6.12) (push) Has been cancelled
CI / kpm-qemu-legacy (4.14) (push) Has been cancelled
CI / kpm-qemu-legacy (4.19) (push) Has been cancelled
CI / kpm-qemu-legacy (4.9) (push) Has been cancelled
CI / kpm-qemu-legacy (5.4) (push) Has been cancelled
Explains the vpnhide_debug_*.zip / state.json format for humans and agents
triaging a bug report: capture kinds and zip layout, every top-level field, the
report/gate/outcome/verdict semantics, all raw sections grouped by purpose, the
rootShell not-verified-vs-inactive caveat, truncation/redaction, kernel-image
bundle, triage playbooks, and a code map of where each part is produced.
2026-08-20 20:04:16 +03:00
okhsunrog
40ce4eecb2 chore: update-json for v1.2.5 2026-08-20 19:26:01 +03:00
okhsunrog
c47a2a0655 chore: release v1.2.5 2026-08-20 19:16:35 +03:00
okhsunrog
4f6eb1a990 docs(changelog): merge the two app-list fragments into one
The ARG_MAX load fix (#302) and the fail-soft picker change (#304) both
addressed the same user-visible problem — the app list not loading / demanding
a profile unlock on MIUI/HyperOS — and read as two overlapping entries. Fold
them into a single Fixed entry describing the combined outcome.
2026-08-20 19:11:16 +03:00
Danila Gornushko
fdaaa880c8
Merge pull request #304 from okhsunrog/fix/app-list-fail-soft
fix(picker): fail-soft app enumeration instead of a hard unlock-a-profile wall
2026-08-20 17:24:47 +03:00
Danila Gornushko
1c998be276
Merge pull request #303 from okhsunrog/fix/kmod-dev-get-by-index-rcu
fix(kmod): resolve dev_get_by_index_rcu via kallsyms for trimmed-KMI kernels
2026-08-20 17:24:44 +03:00
okhsunrog
ecb9548edb style(kmod): clang-format the dev_get_by_index_rcu resolve 2026-08-20 17:17:57 +03:00