docs: add agent onboarding guide (architecture, build/test, conventions, invariants)

This commit is contained in:
heaven 2026-06-16 12:45:21 +03:00
parent 0b3f4adc77
commit db12b63c06
4 changed files with 290 additions and 0 deletions

47
docs/README.md Normal file
View file

@ -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/` |

111
docs/architecture.md Normal file
View file

@ -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 port>, 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<String>` 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<ProxyConfig>, 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.

75
docs/building-testing.md Normal file
View file

@ -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.

57
docs/conventions.md Normal file
View file

@ -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.