diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..b695cbd --- /dev/null +++ b/docs/README.md @@ -0,0 +1,47 @@ +# vojo proxy — agent onboarding + +Native Android proxy client. App traffic is captured by a `VpnService` tun, fed through a +vendored native tun2socks engine into a **local** SOCKS5 server, then out to a real upstream +proxy (SOCKS5 / HTTP(S) / Shadowsocks). UI is Jetpack Compose; persistence is DataStore. + +The repo-root [`README.md`](../README.md) is the product / Russian overview; this `docs/` set is +the agent map. It exists so an agent session can load context fast. Read in this order: + +1. **[architecture.md](architecture.md)** — the data path, the component map, and *why* the + design is shaped this way (the local-SOCKS5-bridge, what runs native vs Kotlin). +2. **[building-testing.md](building-testing.md)** — how to build the APK and run the three + test layers (JVM unit tests, the interop harness, on-device QA). +3. **[conventions.md](conventions.md)** — code style, commit rules, and the **load-bearing + invariants** — choices that look wrong but are deliberate. Read this before "fixing" the + data path; several obvious-looking changes have already been tried and reverted. + +## 60-second orientation + +- **Language/stack:** Kotlin + Compose (Material3), coroutines, kotlinx-serialization, + DataStore Preferences. Native C: a vendored `hev-socks5-tunnel` + a thin JNI bridge. +- **Modules:** single Gradle module `:app`. `minSdk 28`, `compileSdk 35`, `arm64-v8a` only. +- **Package root:** `chat.vojo.proxy` (debug app id `chat.vojo.proxy.debug`). +- **Entry points:** `MainActivity` → `ui/MainScreen` (4 tabs: Туннель / Серверы / Приложения / + Опции). `service/ProxyVpnService` is the `VpnService`. `service/ProxyTileService` is the + quick-settings tile. +- **Single source of truth for state:** `data/ConfigStore` (persisted `ProxyState`) and + `service/VpnState` (live tunnel status/journal as `StateFlow`s). +- **No upstream "universal core".** sing-box / xray / clash are GPLv3 and would force + open-sourcing this closed app (and bloat the APK). The engine is `hev-socks5-tunnel` (MIT); + the Shadowsocks crypto and proxy clients are our own Kotlin, verified against canon. See + conventions.md before proposing a core swap. + +## Where things live + +| Area | Path | +|---|---| +| VpnService lifecycle, tun builder, probe, reaper | `app/src/main/java/chat/vojo/proxy/service/ProxyVpnService.kt` | +| Local loopback SOCKS5 server (hev talks to this) | `core/socks/LocalSocks5Server.kt` | +| Upstream clients | `core/upstream/{Socks5,Http,Shadowsocks}Upstream.kt`, `Upstream.kt` | +| Shadowsocks AEAD (SIP004) | `core/crypto/{ShadowsocksCrypto,ShadowsocksStream}.kt` | +| SOCKS address codec / stream utils | `core/net/{SocksAddress,StreamUtils}.kt` | +| Config model + share-link import | `core/ProxyConfig.kt`, `core/ConfigImport.kt` | +| Persistence / app list | `data/{ConfigStore,AppRepository}.kt` | +| UI | `ui/` (`MainScreen`, `ConnectionScreen`, `ServersScreen`, `AppsScreen`, `SettingsScreen`, `MainViewModel`) | +| Native (JNI bridge + engine) | `core/Tun2Socks.kt`, `app/src/main/jni/tun2socks.c`, `app/src/main/jni/hev-socks5-tunnel/` | +| Unit tests | `app/src/test/` | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..122027f --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,111 @@ +# Architecture + +## The data path + +``` + routed app + │ (only apps allowed by the per-app filter; see below) + ▼ + VpnService tun ProxyVpnService.establish() + │ IPv4 198.18.0.1/32, route 0.0.0.0/0, DNS = dnsServerFor(settings) + │ (IPv6 fc00::1/128 ULA + ::/0 ONLY when settings.ipv6 = true) + ▼ + hev-socks5-tunnel native engine, reads the tun fd over JNI (Tun2Socks / tun2socks.c) + │ config built by ProxyVpnService.buildEngineConfig() (a small YAML blob) + │ speaks SOCKS5 upstream only → points at our loopback server + ▼ + LocalSocks5Server 127.0.0.1:, random per-session user/pass + │ RFC 1928/1929; handles CONNECT (TCP) and UDP ASSOCIATE + ▼ + Upstream client Socks5Upstream | HttpUpstream | ShadowsocksUpstream + │ socket is VpnService.protect()-ed → it exits the tunnel, no loop + ▼ + real proxy server → internet +``` + +**Why a local SOCKS5 server in the middle?** `hev-socks5-tunnel` only speaks SOCKS5 to its +upstream. To support HTTP(S) and Shadowsocks upstreams we run our own loopback SOCKS5 server +that hev forwards into, and *it* translates to whatever the configured upstream speaks. This +loopback-bridge pattern is standard for this engine. The bridge is gated with a random +per-session username/password so a non-whitelisted local app can't hijack it. + +**Why the upstream socket is `protect()`-ed.** Without `protect()`, the upstream connection +would itself be captured by the tun and loop. `protect()` excludes that socket from the VPN. +(Note: `protect()` returning `false` here is benign — our own app is already excluded by the +app filter — so we log and continue, never throw. See conventions.md.) + +## Connect / restart / stop sequence + +The `VpnService` is driven by three intent actions (`ProxyVpnService.ACTION_START` / `ACTION_STOP` +/ `ACTION_RESTART`; static helpers `ProxyVpnService.start/stop/restart(context)`): + +1. UI tap → `MainActivity` calls `VpnService.prepare()` (consent dialog, or null when the + `ACTIVATE_VPN` appop is granted) → `ProxyVpnService.start()`. +2. `onStartCommand(ACTION_START)` promotes to foreground immediately (the 5 s FGS window starts + here), then runs `startTunnel()` on a dedicated lifecycle thread. +3. `startTunnel()` → `establish()` builds the tun → starts `LocalSocks5Server` → + `buildEngineConfig()` → starts the native engine (generation counter + stuck-engine reaper) → + fires the one-shot upstream **probe**. +4. `MainViewModel.reconnectIfRunning()` sends `ACTION_RESTART` when the active server or the app + filter changes while connected. `ACTION_STOP` tears down and `stopSelf()`s. + +To touch lifecycle, start at `onStartCommand` / `startTunnel` / `establish`, not the 700-line +file top. + +## Components + +- **`service/ProxyVpnService`** — the heart. Builds the tun (`establish()`), starts/stops the + native engine with a generation counter + a stuck-engine reaper thread, runs a one-shot + upstream connectivity **probe** after connect (journal flips to «Прокси подтверждён» or a + classified error), watches network changes, owns the foreground notification. +- **`service/VpnState`** — `StateFlow` holder for status, stats, and the journal (`TunnelEvent` + list). The UI observes this; the service writes it. +- **`core/socks/LocalSocks5Server`** — the loopback bridge. Accept loop on a control dispatcher, + relay on a separate dispatcher, a both-directions idle watchdog, per-session auth. +- **`core/upstream/*`** — `Upstream` is the interface (`connectTcp`, `openUdpAssociation`, + `protect`). Three implementations. `Socks5Upstream` also does UDP ASSOCIATE with a BND.ADDR + rewrite for broken servers (see conventions.md). +- **`core/crypto/*`** — Shadowsocks AEAD (SIP004): `ShadowsocksCrypto` (EVP_BytesToKey master + key, HKDF-SHA1 subkey, AES-GCM / ChaCha20-Poly1305 `Aead`), `ShadowsocksStream` (the TCP + chunk framing + UDP packet framing). Implemented on platform JCA — no extra crypto dep. +- **`core/ConfigImport`** — parses `ss://` (SIP002 + legacy), `socks5://`, `http(s)://` share + links into `ProxyConfig`. +- **`data/ConfigStore`** — the only persistence. One DataStore Preferences entry `state` holding + `ProxyState` JSON (`configs` + `settings`). Keeps a last-known-good backup and recovers from + it rather than wiping on a decode failure. +- **`ui/`** — `MainScreen` hosts a 4-tab `HorizontalPager`: `ConnectionScreen`, `ServersScreen`, + `AppsScreen`, `SettingsScreen`. `MainViewModel` bridges UI ↔ `ConfigStore`/`VpnState`. +- **native** — `tun2socks.c` is the JNI bridge (start/stop, hands the tun fd + YAML config to + hev). `hev-socks5-tunnel/` is the vendored engine (includes `hev-mapped-dns.c`, a fake-DNS + facility worth knowing about for any domain-aware routing work). + +## Per-app routing (model for any future per-target filtering) + +`AppSettings.allowedApps: Set` drives `ProxyVpnService.applyAppFilter()`: + +- **non-empty** → whitelist: `builder.addAllowedApplication(pkg)` for each (skipping our own + package). If *every* selected package is uninstalled it throws rather than silently routing + everything (that would violate the whitelist contract). +- **empty** → route everything except our own app (`addDisallowedApplication(self)`). + +This is an **OS-level filter keyed by app UID**. It is the model the "Apps" tab exposes. Note +that anything keyed by *destination* (domain/IP) is NOT an OS feature and cannot reuse this — +it has to be decided in the data path (the local SOCKS5 server sees the destination host). + +## State & status + +- `ProxyState` = `{ configs: List, settings: AppSettings }`. `activeConfig` is + `settings.activeConfigId` (or the first config). +- The tunnel is built from a *snapshot* of the active config; live/green UI styling keys off the + applied config id, not the merely-selected server. +- Settings take effect only at `establish()` / `buildEngineConfig()` time. `MainViewModel` + force-restarts a running tunnel (`reconnectIfRunning()` → `ACTION_RESTART`) when the active + server or the app filter changes. + +### Status & journal + +`service/VpnState` (a set of `StateFlow`s) is the service↔UI contract. To surface a step in the +UI, call `VpnState.event(label, detail?, tone)` — `tone ∈ {NEUTRAL, OK, WARN, ERROR}`. Events are +a ring buffer (`MAX_EVENTS = 30`); `ConnectionScreen` renders them newest-first with auto-scroll. +This is the *only* channel the service uses to report progress (connect steps, the probe verdict, +errors) — use it rather than inventing a parallel one. diff --git a/docs/building-testing.md b/docs/building-testing.md new file mode 100644 index 0000000..57382f4 --- /dev/null +++ b/docs/building-testing.md @@ -0,0 +1,75 @@ +# Building & testing + +## Prerequisites & first run + +- Android SDK **platform-35** + **build-tools 35**, and the **NDK** version pinned in + `app/build.gradle.kts` (`ndkVersion`). Point `local.properties` (`sdk.dir=…`) at the SDK. +- The Gradle wrapper is bundled (`./gradlew`, currently Gradle 8.10.2) — no system Gradle needed. +- First device run: `./gradlew :app:installDebug` (or `assembleDebug` then `adb install`). The + tunnel itself can't be shell-started (see on-device QA) — tap «Подключиться» in the UI. + +## Build + +```bash +./gradlew :app:assembleDebug # debug APK (id chat.vojo.proxy.debug) +./gradlew :app:assembleRelease # minified + shrunk; signed with the bundled debug keystore +``` + +- `minSdk 28`, `targetSdk`/`compileSdk 35`, single ABI **arm64-v8a**. +- Native build is **ndk-build** via `app/src/main/jni/Android.mk`, which includes the vendored + `hev-socks5-tunnel/Android.mk` and builds `tun2socks.c` into `libtun2socks.so`. `ndkVersion` + is pinned in `app/build.gradle.kts`. `jniLibs` are kept uncompressed + 16 KB-aligned. + +## Three test layers + +### 1. JVM unit tests (committed, CI-friendly) — `app/src/test/` + +```bash +./gradlew :app:testDebugUnitTest +``` + +- `ShadowsocksCryptoTest` — SIP004 **known-answer tests** (master key, HKDF subkey, AES-128/256 + GCM ciphertext — all cross-checked against shadowsocks-rust/sing-shadowsocks and recomputed in + python), round-trips, wrong-key failure, the zero-length-chunk regression. +- `ConfigImportTest` — share-link parser (the percent-decode and bare-IPv6 fixes, scheme + dispatch). Base64 `ss://` paths are stubbed on the JVM; those are covered by the harness/device. +- `unitTests.isReturnDefaultValues = true` lets the few `android.util.Base64` calls return null + instead of throwing the "Stub!" error. ChaCha20 is **not** unit-tested (desktop SunJCE has no + `ChaCha20/Poly1305` provider — it runs via Conscrypt on device + in the harness). + +### 2. Interop harness (local only, gitignored) — `.harness/` + +A standalone JVM rig that compiles **vendored copies** of the upstream Kotlin and runs them +against Python reference servers (real SOCKS5 / HTTP(S) / Shadowsocks). + +```bash +bash .harness/run.sh # all green (0 FAIL); run.sh is the source of truth +``` + +- Covers things unit tests can't: real AEAD round trips through a reference SS server, SOCKS5 + UDP ASSOCIATE incl. wrong-source-drop, HTTP CONNECT 407/bare-LF, HTTPS hostname verification, + the post-connect probe classification. +- **Drift caveat:** `.harness/src/*.kt` are *copies* of the real source. If you change upstream + code (`core/upstream`, `core/crypto`, `core/net`), copy the changed file into `.harness/src/` + before trusting a green run. `.harness/` is gitignored (it ships throwaway test-CA keys and + build artifacts). + +### 3. On-device QA — Galaxy S23 over network ADB + +The debug build is debuggable, so config can be injected without tapping through the UI: + +- **VPN consent without the dialog:** `adb shell appops set chat.vojo.proxy.debug ACTIVATE_VPN allow` + → `VpnService.prepare()` returns null. +- **Inject a config:** build the Preferences-DataStore protobuf yourself (one entry, key `state` + = the `ProxyState` JSON) and write it via `run-as`, piping base64 through stdin (run-as can't + read `/sdcard`). Force-stop the app first (it caches DataStore in memory). +- **Start the tunnel:** the service is not shell-startable (`BIND_VPN_SERVICE`, not exported) — + tap «Подключиться» in the UI (`adb shell input tap …`). +- **Exit-IP / leak probe:** `adb shell curl -s https://1.1.1.1/cdn-cgi/trace` → the `ip=` line. + Through the tunnel it's the proxy's exit IP; after disconnect it must return to the WAN IP. + Use `curl -w '%{time_namelookup}'` to watch DNS latency (the old UDP-relay bug showed as ~6 s). +- **Per-app gotcha:** to probe via `adb shell curl`, the shell UID must be routed — i.e. set + `allowedApps` empty (route all) for the test, or the probe bypasses the tunnel and looks like a + leak. +- **WiFi-ADB drops** are the phone's Wi-Fi power-save on screen-sleep, not WSL. Keep the screen + awake (`settings put global stay_on_while_plugged_in 3`) or use USB via usbipd. diff --git a/docs/conventions.md b/docs/conventions.md new file mode 100644 index 0000000..6202224 --- /dev/null +++ b/docs/conventions.md @@ -0,0 +1,57 @@ +# Conventions & load-bearing invariants + +## Code style + +- Match the surrounding code: dense, explanatory comments that say **why** (the data path is + full of non-obvious protocol/platform reasoning — keep that). Kotlin idioms, coroutines with + explicit dispatchers, `runCatching` at boundaries. +- Keep platform glue thin and honest. Comments must not over-claim (a probe *detects* a bad + Shadowsocks key but usually can't *name* it — say exactly that). +- No new dependencies without a strong reason — a deliberate property of this app is a tiny + dependency surface (no crypto lib; AEAD is on platform JCA). + +## Commits + +- **No `Co-Authored-By` trailer.** Keep each commit message **≤ 30 words.** +- Split work into logical, per-subsystem commits. + +## Load-bearing invariants — DO NOT "fix" these without reading + +Each of these looks like a bug or an easy cleanup and has already cost a regression. Verify +against the code and the harness before touching. + +1. **`protect()` returning `false` is benign.** Our own sockets are already excluded by the + app filter, so `protect()` may return false with no harm. Log, don't throw. (Throwing here + once broke *every* upstream connection.) +2. **IPv6 is routed into the tun only when `settings.ipv6`.** hev does not drop unconfigured- + family packets — it forwards a v6 CONNECT to a v6-incapable proxy (`rep=5`), stalling + Happy-Eyeballs apps. With v6 absent, Android adds `::/0 unreachable`, so v6 is blackholed + (no leak). `dnsServerFor()` coerces a v6-literal DNS to `1.1.1.1` while v6 is off — keep it. +3. **SOCKS5 UDP socket is `connect()`-ed to the relay.** RFC 1928 §7 anti-spoof = the kernel + `(IP, port)` filter; that's also the ephemeral-port DNS-spoof defence. Do **not** replace it + with a host-only userspace filter (weaker — drops the port check). Matches sing-box/canon. +4. **`isUnroutableRelay()` rewrite is intentional and broader than canon.** A SOCKS5 UDP + ASSOCIATE BND.ADDR that is wildcard/loopback/private/CGNAT/ULA is rewritten to the proxy + host. This fixes real servers (e.g. 3proxy returning `10.x`) that would otherwise black-hole + all UDP/DNS. Narrowing it to wildcard-only would reintroduce a device-confirmed bug. +5. **Shadowsocks reader EOF semantics.** EOF on a *frame boundary* = clean end of stream + (return -1). EOF *inside* a frame = truncation = error. And a zero-length chunk is **valid** + (canon emits/accepts empty keep-alive frames) — reject only `> MAX_PAYLOAD`. +6. **The local SOCKS5 bridge requires random per-session auth.** It's the boundary that stops a + non-whitelisted local app from using the loopback proxy. Don't make it no-auth. +7. **ChaCha20 cipher name is `ChaCha20/Poly1305/NoPadding`** (Conscrypt registers the slash name + since API 28; the hyphenated alias only exists on newer Mainline). Not a typo. +8. **`ConfigStore` never wipes on a decode failure.** It snapshots last-known-good before each + write and recovers from it; only a genuine `SerializationException`/`IllegalArgumentException` + counts as corruption (let `Error`/OOM propagate so a transient failure doesn't wipe servers). +9. **MTU 8500 is the baseline** (not a tuned value); the engine config and tun must agree. + +## The "no universal core" decision + +Replacing the Kotlin backend (SS crypto + local SOCKS5 + upstream clients) with sing-box / +sing-tun / mihomo / libbox was evaluated and **rejected**: those are **GPLv3** (live +enforcement) and would force open-sourcing this closed app, and any Go/Rust runtime breaks the +small-APK goal. `hev-socks5-tunnel` is MIT and stays. The only defensible strategic swap is +`shadowsocks-rust` (MIT) for the SS crypto surface *only*, and only if Kotlin-SS upkeep ever +dominates — soak-test behind hev before deleting anything. Our SS crypto is verified correct +against shadowsocks-rust and sing-shadowsocks.