proxy/docs/architecture.md

178 lines
11 KiB
Markdown

# 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; its `hev-mapped-dns.c` fake-DNS facility is
what makes domain routing possible and is enabled via a `mapdns:` config block (see Domain
routing below).
## 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). That
is exactly how the "Сайты" tab works, below.
## Domain routing ("Сайты" tab)
`AppSettings.routedSites: Set<String>` + `routingMode: RoutingMode` add a **destination-keyed**
proxy-vs-direct split *on top of* the per-app filter — orthogonal layers: per-app decides which
apps enter the tun; domain routing decides, within that traffic, where each destination goes.
The data path is IP-only by the time it reaches hev (the OS already resolved the domain), so
domains are recovered with hev's **mapped-DNS / fake-IP** facility, enabled only when
`routedSites` is non-empty:
1. `buildEngineConfig()` emits a `mapdns:` block (synthetic resolver `198.18.0.2:53`, fake-IP pool
`198.19.0.0/16`, `cache-size 10000`); `establish()` points the tun's DNS at `198.18.0.2`.
2. hev intercepts A-queries to that resolver, answers with a fake IP from the pool, and on the
resulting TCP connection **reverse-maps the fake IP back to the original domain**, sending it
upstream as a SOCKS5 *domain* CONNECT (`hev-mapped-dns.c` + `misc/hev-utils.c`).
3. `LocalSocks5Server` now sees the real domain in `dest.host`; `core/route/SiteRouter` picks
PROXY (the existing upstream path) or DIRECT (a `protect()`-ed socket dialed straight to the
host on the underlying non-VPN network, via `ProxyVpnService` as `DirectDialer`) — both reuse
the same half-close relay.
Entries are explicit suffix domains (`example.com` ⇒ it + all subdomains) or geosite categories
(`geosite:netflix`). The full MIT v2fly `geosite.dat` (`assets/geosite/`, ~1500 categories,
refresh via `tools/fetch-geosite.sh`) is parsed lazily by `core/route/GeositeIndex` (a small
hand-rolled protobuf reader, no dependency) into a process-shared index used both by the Сайты
search (live category/domain suggestions) and by `SiteRouter` at tunnel build. `RoutingMode` is
the global default and the list
is the exceptions: `PROXY_ALL` ⇒ listed go direct; `DIRECT_ALL` ⇒ listed go through the proxy.
**Known limitation — DNS that bypasses the fake resolver isn't split.** hev only diverts UDP whose
destination is exactly the advertised resolver `198.18.0.2:53`. An app that hardcodes its own
resolver (e.g. `8.8.8.8`), or uses DoH/DoT/QUIC, never hits the fake DNS, so its flows arrive as
IP literals and take the **global default**. Under `DIRECT_ALL` that means a site the user added
to go *through the proxy* can still exit direct if its app resolves on its own. Closing this would
require hijacking all `:53` (and ideally DoH) in the engine — a possible follow-up. (Also: synthesized
answers carry TTL=1 and only A records; AAAA/HTTPS-SVCB get an empty answer, which is fine while v6
is suppressed but strips HTTP/3 hints.)
**Known limitation — UDP is not domain-routed.** Only `handleConnect` (TCP) consults the router;
`handleUdpAssociate` always sends UDP through the proxy (and an HTTP(S) upstream, which has no UDP
relay, drops it). So QUIC / HTTP-3 is proxied regardless of mode — under `DIRECT_ALL` a "direct"
site's QUIC still traverses the proxy. TCP is the routed path; UDP direct-routing is a possible
follow-up (it needs a protected per-destination UDP relay, mindful of the §3 anti-spoof invariant).
Mapped-DNS is **IPv4-only**, so while `routedSites` is non-empty the tun is forced IPv4-only (v6
suppressed regardless of `settings.ipv6`) to stop dual-stack apps bypassing the split over v6.
## 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, the app filter, or the site list / routing mode 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.
## Localization (RU / EN)
Base language is **English** (`res/values/strings.xml`); the Russian override is
`res/values-ru/strings.xml`, and `resourceConfigurations += setOf("en", "ru")` keeps other
locales out of the APK (they fall back to English). The in-app switcher (Система / Русский /
English) is `LanguageSelector` in `ui/SettingsScreen.kt`.
The per-app locale is applied **without AppCompat** — this app is Compose + `ComponentActivity`
only and the tiny dependency surface is a hard constraint, so localization uses the classic
framework-only pattern. `core/LocaleManager` persists the tag in its own small SharedPreferences
and applies it as a `Configuration` override from `attachBaseContext` of *every* component that
resolves strings: `MainActivity`, `ProxyVpnService`, `ProxyTileService`. Switching persists the
tag and `recreate()`s the activity. On `SYSTEM` the process-default `Locale` is restored, so a
once-picked UI language can't leak into date/number formatting elsewhere.
**Language is deliberately not in `AppSettings`/DataStore.** It is a presentation concern, it has
to be read *synchronously* at `attachBaseContext` (before any coroutine or DataStore exists), and
it must not run through `reconnectIfRunning()` the way a routing or app-filter change does.