Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions docs/debug-bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,39 @@ Everything below is a `state.json` field unless noted.

## 2. The 30-second triage (read in this order)

1. **`captureKind`, `app.version`, `device`** — what am I even looking at? (model, Android release, ABI, app+backend version).
1. **`captureKind`, `app.version`, `device`, `schema`** — what am I even looking at? (model, Android release, ABI, app+backend version, and which bundle format — §2.1 if it isn't the one this doc describes).
2. **`gate`** — was the run *measurable*? Only `ROUTED` yields real verdicts. `VPN_OFF` / `SELF_NOT_ROUTED` / `NEEDS_RESTART` mean "we deliberately measured nothing" — a clean-looking report there is **not** evidence of health. (See §4.)
3. **`nativeVerdict` / `javaVerdict`** — `Ok` / `Partial` / `Broken` / `null`. `null` = gated (see #2) or not measured.
4. **`rootShell`** — did the snapshot shell actually have root? If `uid != 0` or `runtimeCheckable=false`, "inactive"/"not verified" readings are unreliable, not facts. (See §6 — this is the #1 misread.)
5. **`activeBackend` + `kmodLoadStatus`** — which backend is in charge, did it load this boot, any `brokenReason`.
6. **`errors`** — non-fatal capture failures. If it lists `snapshot truncated at: <section>`, every section at/after that one is missing or partial (see §8).
7. Then drill into the raw `sections` and logs for the specific symptom (§5, §7, §9 playbooks).

### 2.1 Schema versions

`state.json → schema` says which shape the bundle was written in. **This doc
describes schema 2.** One number covers the whole bundle; there is no per-object
version.

| Schema | Shipped in | What changed |
|---|---|---|
| 2 | unreleased | Sealed `kind` discriminators are compact snake_case everywhere. Previously `dashboard.lsposed` and `dashboard.protection` carried fully-qualified class names (`dev.okhsunrog.vpnhide.LsposedState.Active`) instead of `active`/`blocked`. The separate `report.schema` field is gone — the top-level one is the only version. |
| 1 | 1.0.0 – 1.2.5 | Initial format. |

Rules for changing it (enforced by `BundleSchemaGoldenTest`, which pins the
serialized shape against `app/src/test/resources/bundle/state_golden.json`):

- A field removed, renamed, or given a new meaning → **bump** `VPNHIDE_STATE_SCHEMA`
and add a row above.
- A field added → no bump; a reader of an older bundle just finds it missing.
- Either way the golden file has to be refreshed
(`UPDATE_GOLDEN=1 ./gradlew :app:testDebugUnitTest --tests '*BundleSchemaGoldenTest*'`),
so no shape change reaches a release unnoticed.

Bundles are read, not machine-parsed, so nothing rejects an old schema — the
number exists to tell you *which* doc revision applies to the file in front of
you.

---

## 3. Top-level `state.json` fields
Expand All @@ -67,7 +92,7 @@ renders*, so the bundle can't disagree with what the user saw on screen.

| Field | Meaning |
|---|---|
| `schema` | State schema version (currently 1). |
| `schema` | Bundle schema version — **this doc describes 2** (§2.1). |
| `generatedAt` | ISO-8601 capture time. |
| `captureKind` | `debug` / `full_system_logcat` / `kernel_images` (§1). |
| `app` | `{packageName, version}` — `version` is `"1.2.5 (10205)"` (name + versionCode). |
Expand Down
2 changes: 1 addition & 1 deletion docs/detection-vectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ detectors actually probe:
| kmod | `kmod/vpnhide_kmod.c` (10 kretprobes + socket-bind redirects + four optional VFS redirects); iface matcher `kmod/generated/iface_lists.h` |
| KPM | `kmod/kpm/vpnhide_kpm.c` (KernelPatch inline hooks + ctl0, including four optional VFS hooks); offsets in `kmod/kpm/kver_offsets.h` |
| zygisk | `zygisk/src/hooks.rs` (ioctl/getifaddrs/openat/recv*); `zygisk/src/filter.rs` (procfs + netlink filters) |
| lsposed | `lsposed/app/.../HookEntry.kt`, `PackageVisibilityHooks.kt`; iface matcher `.../generated/IfaceLists.kt` |
| lsposed | `lsposed/app/.../hook/HookEntry.kt`, `hook/PackageVisibilityHooks.kt`; iface matcher `.../generated/IfaceLists.kt` |
| iface match rules | single source of truth `data/interfaces.toml` → `scripts/codegen-interfaces.py` renders all four targets (kmod/KPM C, zygisk Rust, lsposed native Rust, lsposed Kotlin) |

The interface-name patterns (`tun`/`tap`/`wg`/`ppp`/`ipsec`/`xfrm`/`utun`/`l2tp`/`gre`,
Expand Down
2 changes: 1 addition & 1 deletion docs/lsposed-hook-debugging.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ covers which vector), and [lsposed/AGENTS.md](../lsposed/AGENTS.md) (the Kotlin
module architecture). The wire the state file speaks is in
[protocol.md](protocol.md).

The hooks live in `lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/HookEntry.kt`;
The hooks live in `lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/hook/HookEntry.kt`;
the state file they publish is written by `LsposedState.kt` and rendered by
`HookDiagnostics.kt`.

Expand Down
21 changes: 21 additions & 0 deletions lsposed/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ duplication / god-function drift that AI-assisted edits cause when each change
only sees its local neighbourhood. **Reuse the abstractions below — don't
reinvent them.** `grep` for an existing helper before writing a new one.

## Two processes, one APK

The single most important thing about this module: `hook/` is loaded by LSPosed
**into `system_server`**; everything else runs in the app process. So `hook/`
carries no Compose, no Activity, no app resources — and nothing outside it may
touch `de.robv.android.xposed` (absent in the app process). The shared vocabulary
they both use (canonical-config parsing, `HookRegistry`, `LsposedStats`,
`LogTags`) stays in the root package. `HookPackageBoundaryTest` enforces both
directions, since `internal` is module-wide and the compiler will not.

`assets/xposed_init` names the entry class (`…vpnhide.hook.HookEntry`) and
`proguard-rules.pro` keeps it — moving or renaming it means editing both.

## Data flow

- **Read path:** one batched root shell → `RootSnapshotCache` → typed snapshots
Expand Down Expand Up @@ -70,6 +83,14 @@ reinvent them.** `grep` for an existing helper before writing a new one.
fragments through `ProcessBuilder("sh", ...)` like `ShellCommandBuildersTest`).
- **`grep` before adding** any parser / formatter / shell-builder / status
colour — it probably already exists above.
- **Changing a `@Serializable` type that reaches the debug bundle bumps the
bundle schema.** `BundleSchemaGoldenTest` pins the serialized shape; when it
fails, refresh the golden (`UPDATE_GOLDEN=1 ./gradlew :app:testDebugUnitTest
--tests '*BundleSchemaGoldenTest*'`) and, if a field was removed/renamed or
changed meaning, bump `VPNHIDE_STATE_SCHEMA` + add a row to
[docs/debug-bundle.md §2.1](../docs/debug-bundle.md). Sealed subclasses that
land in the bundle need an explicit `@SerialName` — without one kotlinx emits
the fully-qualified class name, which changes the moment the class moves.

## Quality gates

Expand Down
2 changes: 1 addition & 1 deletion lsposed/app/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Keep Xposed entry points — LSPosed loads these by reflection via assets/xposed_init
-keep class dev.okhsunrog.vpnhide.HookEntry { *; }
-keep class dev.okhsunrog.vpnhide.hook.HookEntry { *; }
-keepnames class dev.okhsunrog.vpnhide.** { *; }

# Keep Xposed API types
Expand Down
2 changes: 1 addition & 1 deletion lsposed/app/src/main/assets/xposed_init
Original file line number Diff line number Diff line change
@@ -1 +1 @@
dev.okhsunrog.vpnhide.HookEntry
dev.okhsunrog.vpnhide.hook.HookEntry
Original file line number Diff line number Diff line change
Expand Up @@ -82,19 +82,23 @@ internal data class ModuleProblem(
@Serializable
sealed interface LsposedState {
@Serializable
@SerialName("not_installed")
data object NotInstalled : LsposedState

@Serializable
@SerialName("installed_inactive")
data class InstalledInactive(
val version: String?,
) : LsposedState

@Serializable
@SerialName("needs_reboot")
data class NeedsReboot(
val version: String?,
) : LsposedState

@Serializable
@SerialName("active")
data class Active(
val version: String?,
val targetCount: Int,
Expand All @@ -108,6 +112,7 @@ internal sealed interface ProtectionCheck {
// self-restart. Carries the shared [DiagnosticGate] so the hero/agent explain
// which without a second enum. Never [DiagnosticGate.ROUTED] — that is [Checked].
@Serializable
@SerialName("blocked")
data class Blocked(
val gate: DiagnosticGate,
) : ProtectionCheck {
Expand All @@ -120,9 +125,11 @@ internal sealed interface ProtectionCheck {
// just couldn't measure. Distinct from a VPN-off gate so the hero doesn't tell an
// active-VPN user to turn their VPN on.
@Serializable
@SerialName("failed")
data object Failed : ProtectionCheck

@Serializable
@SerialName("checked")
data class Checked(
val native: LayerStatus,
val java: LayerStatus,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,6 @@ package dev.okhsunrog.vpnhide
import dev.okhsunrog.vpnhide.generated.HookIds
import kotlinx.serialization.Serializable

/**
* Schema version for the serialized report (the debug bundle's `diagnostics.json`
* and the summary header). Bump when the wire shape changes.
*/
internal const val DIAGNOSTIC_REPORT_SCHEMA: Int = 1

@Serializable
internal enum class CheckLayer { NATIVE, JAVA }

Expand Down Expand Up @@ -98,7 +92,6 @@ internal data class DiagnosticReport(
val java: LayerReport,
// False after the fast core phase, true once the slow Java probes have filled in.
val complete: Boolean,
val schema: Int = DIAGNOSTIC_REPORT_SCHEMA,
) {
/** Per-layer Ok/Partial/Broken — the ONLY way to read a report's verdict.
* Null unless the run was actually measured ([DiagnosticGate.ROUTED]); a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.okhsunrog.vpnhide.generated.IfaceLists
import dev.okhsunrog.vpnhide.hook.HookLog
import dev.okhsunrog.vpnhide.ui.components.ButtonSpinner
import dev.okhsunrog.vpnhide.ui.components.EnhancedButton
import dev.okhsunrog.vpnhide.ui.components.EnhancedCard
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package dev.okhsunrog.vpnhide

import android.content.Context
import android.os.Process
import dev.okhsunrog.vpnhide.hook.HookLog
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.os.Handler
import android.os.HandlerThread
import android.os.Process
import dev.okhsunrog.vpnhide.generated.HookIds
import dev.okhsunrog.vpnhide.hook.HookLog
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
Expand Down
21 changes: 17 additions & 4 deletions lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/VpnHideState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,24 @@ import kotlinx.serialization.json.JsonElement
* object: the derived state, the raw shell sections it was derived from, the
* captured logs, and the root-shell self-diagnosis.
*
* Compatibility is intentionally NOT a concern: nothing consumes this format but
* an operator (or an AI) reading the file, so fields may be added/removed freely.
* The invariant that matters is completeness.
* Nothing machine-parses this format but an operator (or an AI) reading a bug
* report, so the shape may change freely — but a bundle can arrive months late,
* from an app version nobody has the source of at hand, so the version it was
* written against travels with it.
*
* The number below is the ONE version of the whole bundle (there is no separate
* per-object version). Bump it when a field is removed, renamed, or changes
* meaning; a pure addition needs no bump. Either way `BundleSchemaGoldenTest`
* fails until the golden file is updated, so no shape change ships unnoticed.
* Every version gets a row in the history table in docs/debug-bundle.md — the
* number is only worth carrying if it can be looked up.
*
* 2: sealed `kind` discriminators are compact snake_case everywhere. Before
* this, LsposedState and ProtectionCheck serialized as fully-qualified class
* names, which also made them unmovable between packages.
* 1: initial.
*/
internal const val VPNHIDE_STATE_SCHEMA: Int = 1
internal const val VPNHIDE_STATE_SCHEMA: Int = 2

/**
* What to include in a captured [VpnHideState]. The ONE source of truth for the
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package dev.okhsunrog.vpnhide
package dev.okhsunrog.vpnhide.hook

import dev.okhsunrog.vpnhide.LsposedStats
import dev.okhsunrog.vpnhide.bit
import dev.okhsunrog.vpnhide.generated.HookIds
import java.util.concurrent.CopyOnWriteArrayList

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package dev.okhsunrog.vpnhide
package dev.okhsunrog.vpnhide.hook

import android.net.ConnectivityManager
import android.net.LinkProperties
Expand All @@ -16,6 +16,8 @@ import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XposedBridge
import de.robv.android.xposed.XposedHelpers
import de.robv.android.xposed.callbacks.XC_LoadPackage
import dev.okhsunrog.vpnhide.LsposedStats
import dev.okhsunrog.vpnhide.bit
import dev.okhsunrog.vpnhide.generated.HookIds
import dev.okhsunrog.vpnhide.generated.IfaceLists
import java.util.concurrent.atomic.AtomicBoolean
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package dev.okhsunrog.vpnhide
package dev.okhsunrog.vpnhide.hook

import android.os.FileObserver
import android.util.Log
import de.robv.android.xposed.XposedBridge
import dev.okhsunrog.vpnhide.GatedLogger
import dev.okhsunrog.vpnhide.LogTags

/**
* system_server logcat facade for LSPosed hooks, which can't reach the app's
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@file:Suppress("DEPRECATION")

package dev.okhsunrog.vpnhide
package dev.okhsunrog.vpnhide.hook

import android.net.LinkProperties
import android.net.NetworkInfo
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package dev.okhsunrog.vpnhide
package dev.okhsunrog.vpnhide.hook

import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo
Expand All @@ -10,6 +10,7 @@ import android.os.Process
import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XposedBridge
import de.robv.android.xposed.XposedHelpers
import dev.okhsunrog.vpnhide.LsposedStats
import dev.okhsunrog.vpnhide.generated.HookIds

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package dev.okhsunrog.vpnhide
package dev.okhsunrog.vpnhide.hook

import android.os.FileObserver

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package dev.okhsunrog.vpnhide
package dev.okhsunrog.vpnhide.hook

import android.os.SystemClock
import dev.okhsunrog.vpnhide.CANONICAL_CONFIG_FILE
import dev.okhsunrog.vpnhide.LsposedJavaHookEntries
import dev.okhsunrog.vpnhide.generated.HookIds
import dev.okhsunrog.vpnhide.hasHook
import dev.okhsunrog.vpnhide.hookSelectionMask
import dev.okhsunrog.vpnhide.parseCanonicalConfig
import java.io.File

internal data class SystemServerConfig(
Expand Down
Loading