From 4e419146ca9fbe92e27ab6dcaed7c977382e4985 Mon Sep 17 00:00:00 2001 From: heaven Date: Tue, 16 Jun 2026 16:04:58 +0300 Subject: [PATCH] docs: document domain routing, fake-IP data path, and its invariants/limits --- docs/architecture.md | 56 ++++++++++++++++++++++++++++++++++++++++---- docs/conventions.md | 14 +++++++++++ 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 122027f..8216be8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,8 +76,9 @@ file top. - **`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). + 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) @@ -90,7 +91,54 @@ file top. 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). +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` + `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 @@ -100,7 +148,7 @@ it has to be decided in the data path (the local SOCKS5 server sees the destinat 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. + server, the app filter, or the site list / routing mode changes. ### Status & journal diff --git a/docs/conventions.md b/docs/conventions.md index 6202224..bb8a5ff 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -45,6 +45,20 @@ against the code and the harness before touching. 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. +10. **Domain routing recovers domains via hev mapped-DNS (fake-IP); the DIRECT path must bypass + it.** When `routedSites` is non-empty, `buildEngineConfig()` enables a `mapdns:` block and the + tun DNS is pointed at the synthetic resolver `198.18.0.2`. The fake-IP pool is `198.19.0.0/16` + — it clears the `198.18.0.1/32` tun address and is deliberately NOT in the `isUnroutableRelay()` + CGNAT/private set (those fake IPs become domains before reaching any upstream, so that relay + path is never involved). `DirectDialer` resolves on the **underlying non-VPN network** and + `protect()`s the socket: resolving via the default resolver would return a fake IP and loop the + "direct" flow back into the tun. The direct dialer also requires the underlying network to carry + `NET_CAPABILITY_INTERNET` — a device can expose an IMS/MMTEL cellular network (NOT_VPN, but no + INTERNET) that can't resolve, which silently broke every direct dial until filtered out + (device-confirmed). Mapped-DNS is IPv4-only, so v6 is force-suppressed while sites are active — + don't "restore" it. Keep the fake-IP cache sized to the whole pool (`MAPDNS_CACHE_SIZE` == + `~netmask`): the LRU evicts by the *cache* limit, not the pool, and an evicted-but-still-referenced + fake IP reverse-maps to nothing and dead-ends. ## The "no universal core" decision