docs: add agent onboarding guide (architecture, build/test, conventions, invariants)
This commit is contained in:
parent
0b3f4adc77
commit
db12b63c06
4 changed files with 290 additions and 0 deletions
47
docs/README.md
Normal file
47
docs/README.md
Normal 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
111
docs/architecture.md
Normal 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
75
docs/building-testing.md
Normal 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
57
docs/conventions.md
Normal 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.
|
||||||
Loading…
Add table
Reference in a new issue