docs: document domain routing, fake-IP data path, and its invariants/limits

This commit is contained in:
heaven 2026-06-16 16:04:58 +03:00
parent ef165b6b9b
commit 4e419146ca
2 changed files with 66 additions and 4 deletions

View file

@ -76,8 +76,9 @@ file top.
- **`ui/`** — `MainScreen` hosts a 4-tab `HorizontalPager`: `ConnectionScreen`, `ServersScreen`, - **`ui/`** — `MainScreen` hosts a 4-tab `HorizontalPager`: `ConnectionScreen`, `ServersScreen`,
`AppsScreen`, `SettingsScreen`. `MainViewModel` bridges UI ↔ `ConfigStore`/`VpnState`. `AppsScreen`, `SettingsScreen`. `MainViewModel` bridges UI ↔ `ConfigStore`/`VpnState`.
- **native**`tun2socks.c` is the JNI bridge (start/stop, hands the tun fd + YAML config to - **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 hev). `hev-socks5-tunnel/` is the vendored engine; its `hev-mapped-dns.c` fake-DNS facility is
facility worth knowing about for any domain-aware routing work). 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) ## 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 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 — 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<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 ## 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. applied config id, not the merely-selected server.
- Settings take effect only at `establish()` / `buildEngineConfig()` time. `MainViewModel` - Settings take effect only at `establish()` / `buildEngineConfig()` time. `MainViewModel`
force-restarts a running tunnel (`reconnectIfRunning()``ACTION_RESTART`) when the active 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 ### Status & journal

View file

@ -45,6 +45,20 @@ against the code and the harness before touching.
write and recovers from it; only a genuine `SerializationException`/`IllegalArgumentException` 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). 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. 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 ## The "no universal core" decision