proxy/docs/architecture.md

9.9 KiB

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 LocalSocks5ServerbuildEngineConfig() → 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/VpnStateStateFlow 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.
  • nativetun2socks.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 StateFlows) 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.