11 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)):
- UI tap →
MainActivitycallsVpnService.prepare()(consent dialog, or null when theACTIVATE_VPNappop is granted) →ProxyVpnService.start(). onStartCommand(ACTION_START)promotes to foreground immediately (the 5 s FGS window starts here), then runsstartTunnel()on a dedicated lifecycle thread.startTunnel()→establish()builds the tun → startsLocalSocks5Server→buildEngineConfig()→ starts the native engine (generation counter + stuck-engine reaper) → fires the one-shot upstream probe.MainViewModel.reconnectIfRunning()sendsACTION_RESTARTwhen the active server or the app filter changes while connected.ACTION_STOPtears down andstopSelf()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—StateFlowholder for status, stats, and the journal (TunnelEventlist). 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/*—Upstreamis the interface (connectTcp,openUdpAssociation,protect). Three implementations.Socks5Upstreamalso 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-Poly1305Aead),ShadowsocksStream(the TCP chunk framing + UDP packet framing). Implemented on platform JCA — no extra crypto dep.core/ConfigImport— parsesss://(SIP002 + legacy),socks5://,http(s)://share links intoProxyConfig.data/ConfigStore— the only persistence. One DataStore Preferences entrystateholdingProxyStateJSON (configs+settings). Keeps a last-known-good backup and recovers from it rather than wiping on a decode failure.ui/—MainScreenhosts a 4-tabHorizontalPager:ConnectionScreen,ServersScreen,AppsScreen,SettingsScreen.MainViewModelbridges UI ↔ConfigStore/VpnState.- native —
tun2socks.cis the JNI bridge (start/stop, hands the tun fd + YAML config to hev).hev-socks5-tunnel/is the vendored engine; itshev-mapped-dns.cfake-DNS facility is what makes domain routing possible and is enabled via amapdns: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:
buildEngineConfig()emits amapdns:block (synthetic resolver198.18.0.2:53, fake-IP pool198.19.0.0/16,cache-size 10000);establish()points the tun's DNS at198.18.0.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). LocalSocks5Servernow sees the real domain indest.host;core/route/SiteRouterpicks PROXY (the existing upstream path) or DIRECT (aprotect()-ed socket dialed straight to the host on the underlying non-VPN network, viaProxyVpnServiceasDirectDialer) — 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 }.activeConfigissettings.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.MainViewModelforce-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.
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.