feat(net): split routing via hev mapped-DNS + protected direct dial

Enable fake-DNS when sites set (force IPv4); LocalSocks5Server routes proxy-vs-direct by domain, dialing direct on the underlying internet network.
This commit is contained in:
heaven 2026-06-16 16:04:13 +03:00
parent e01b0c2a87
commit fadb0870e8
3 changed files with 166 additions and 9 deletions

View file

@ -1,13 +1,18 @@
package chat.vojo.proxy.core.socks package chat.vojo.proxy.core.socks
import android.util.Log import android.util.Log
import chat.vojo.proxy.core.net.ATYP_DOMAIN
import chat.vojo.proxy.core.net.parseSocksAddress import chat.vojo.proxy.core.net.parseSocksAddress
import chat.vojo.proxy.core.net.readExact import chat.vojo.proxy.core.net.readExact
import chat.vojo.proxy.core.net.readFully import chat.vojo.proxy.core.net.readFully
import chat.vojo.proxy.core.net.readSocksAddress import chat.vojo.proxy.core.net.readSocksAddress
import chat.vojo.proxy.core.net.u16be import chat.vojo.proxy.core.net.u16be
import chat.vojo.proxy.core.route.RouteDecision
import chat.vojo.proxy.core.route.SiteRouter
import chat.vojo.proxy.core.upstream.DirectDialer
import chat.vojo.proxy.core.upstream.UdpAssociation import chat.vojo.proxy.core.upstream.UdpAssociation
import chat.vojo.proxy.core.upstream.Upstream import chat.vojo.proxy.core.upstream.Upstream
import chat.vojo.proxy.core.upstream.UpstreamConn
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -58,9 +63,15 @@ private const val MAX_INFLIGHT_DNS_TCP = 8
* *
* [onUpstreamError] receives the actual failure so the caller can classify it * [onUpstreamError] receives the actual failure so the caller can classify it
* (auth vs TLS vs timeout vs wrong Shadowsocks key) instead of a generic "down". * (auth vs TLS vs timeout vs wrong Shadowsocks key) instead of a generic "down".
*
* [router] decides proxy-vs-direct per destination (the "Сайты" split). When it says
* DIRECT, [directDialer] dials the real host on the underlying network instead of the
* proxy; both paths then share the same half-close relay below.
*/ */
class LocalSocks5Server( class LocalSocks5Server(
private val upstream: Upstream, private val upstream: Upstream,
private val router: SiteRouter,
private val directDialer: DirectDialer,
private val onUpstreamError: ((Throwable) -> Unit)? = null, private val onUpstreamError: ((Throwable) -> Unit)? = null,
) { ) {
private val serverSocket = ServerSocket().apply { private val serverSocket = ServerSocket().apply {
@ -186,11 +197,21 @@ class LocalSocks5Server(
cout: OutputStream, cout: OutputStream,
dest: chat.vojo.proxy.core.net.Destination, dest: chat.vojo.proxy.core.net.Destination,
) { ) {
// Domain split: only domain destinations can be matched (mapped-DNS restores the
// name into dest.host); IP-literal/IPv6 flows take the router's global default.
val decision = router.decide(dest.host, dest.atyp == ATYP_DOMAIN)
val conn = try { val conn = try {
upstream.connectTcp(dest) if (decision == RouteDecision.DIRECT) {
val s = directDialer.connectDirect(dest.host, dest.port)
UpstreamConn(s, s.getInputStream(), s.getOutputStream())
} else {
upstream.connectTcp(dest)
}
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "upstream connect failed for $dest: ${e.message}") Log.w(TAG, "$decision connect failed for $dest: ${e.message}")
onUpstreamError?.invoke(e) // Only proxy failures feed the upstream-error classifier; a failed direct dial
// is a real-network problem, not a misconfigured proxy.
if (decision == RouteDecision.PROXY) onUpstreamError?.invoke(e)
runCatching { writeReply(cout, rep = 0x05) } // connection refused runCatching { writeReply(cout, rep = 0x05) } // connection refused
return return
} }
@ -259,6 +280,9 @@ class LocalSocks5Server(
} }
private fun handleUdpAssociate(client: Socket, cin: InputStream, cout: OutputStream) { private fun handleUdpAssociate(client: Socket, cin: InputStream, cout: OutputStream) {
// NOTE: UDP is not domain-routed — unlike handleConnect it never consults `router`,
// so UDP (incl. QUIC/HTTP-3) always goes through the proxy regardless of routing mode.
// Direct UDP would need a protected per-destination relay; see docs/architecture.md.
val relay = DatagramSocket(InetSocketAddress("127.0.0.1", 0)) val relay = DatagramSocket(InetSocketAddress("127.0.0.1", 0))
track(relay) track(relay)
val session = UdpSession(relay) val session = UdpSession(relay)

View file

@ -0,0 +1,16 @@
package chat.vojo.proxy.core.upstream
import java.net.Socket
/**
* Opens a VPN-protected TCP socket straight to a destination, bypassing the proxy.
*
* The "direct" half of domain split-routing: for sites the [SiteRouter][chat.vojo.proxy.core.route.SiteRouter]
* sends direct, the local SOCKS5 bridge dials the real host through this instead of the
* upstream. Resolution and egress MUST happen on the underlying (non-VPN) network, or a
* direct flow loops back into the tun / fake DNS (see the protect() invariant in
* docs/conventions.md). Implemented by ProxyVpnService.
*/
fun interface DirectDialer {
fun connectDirect(host: String, port: Int): Socket
}

View file

@ -21,14 +21,19 @@ import chat.vojo.proxy.R
import chat.vojo.proxy.core.AppSettings import chat.vojo.proxy.core.AppSettings
import chat.vojo.proxy.core.ProxyConfig import chat.vojo.proxy.core.ProxyConfig
import chat.vojo.proxy.core.ProxyType import chat.vojo.proxy.core.ProxyType
import chat.vojo.proxy.core.RoutingMode
import chat.vojo.proxy.core.Tun2Socks import chat.vojo.proxy.core.Tun2Socks
import chat.vojo.proxy.core.net.Destination import chat.vojo.proxy.core.net.Destination
import chat.vojo.proxy.core.net.readExact import chat.vojo.proxy.core.net.readExact
import chat.vojo.proxy.core.formatBytes import chat.vojo.proxy.core.formatBytes
import chat.vojo.proxy.core.formatUptime import chat.vojo.proxy.core.formatUptime
import chat.vojo.proxy.core.route.SiteRouter
import chat.vojo.proxy.core.socks.LocalSocks5Server import chat.vojo.proxy.core.socks.LocalSocks5Server
import chat.vojo.proxy.core.upstream.CONNECT_TIMEOUT_MS
import chat.vojo.proxy.core.upstream.DirectDialer
import chat.vojo.proxy.core.upstream.SocketProtector import chat.vojo.proxy.core.upstream.SocketProtector
import chat.vojo.proxy.core.upstream.Upstream import chat.vojo.proxy.core.upstream.Upstream
import chat.vojo.proxy.data.AssetGeositeSource
import chat.vojo.proxy.data.ConfigStore import chat.vojo.proxy.data.ConfigStore
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -40,10 +45,13 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import java.net.DatagramSocket import java.net.DatagramSocket
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket import java.net.Socket
import java.net.UnknownHostException
import java.util.concurrent.Executors import java.util.concurrent.Executors
class ProxyVpnService : VpnService(), SocketProtector { class ProxyVpnService : VpnService(), SocketProtector, DirectDialer {
// Every lifecycle transition (start/stop/restart/engine-exit) runs on this // Every lifecycle transition (start/stop/restart/engine-exit) runs on this
// single thread: no start/stop races, no blocking joins on the main thread. // single thread: no start/stop races, no blocking joins on the main thread.
@ -146,14 +154,28 @@ class ProxyVpnService : VpnService(), SocketProtector {
updateNotification(getString(R.string.notif_connecting)) updateNotification(getString(R.string.notif_connecting))
val upstream = Upstream.create(config, this) val upstream = Upstream.create(config, this)
val socks = LocalSocks5Server(upstream, ::onUpstreamUnreachable).also { it.start() } val router = SiteRouter.build(
state.settings.routingMode,
state.settings.routedSites,
AssetGeositeSource(this),
)
val socks = LocalSocks5Server(upstream, router, this, ::onUpstreamUnreachable).also { it.start() }
server = socks server = socks
val pfd = establish(config, state.settings, socks.port) val pfd = establish(config, state.settings, socks.port)
vpnInterface = pfd vpnInterface = pfd
VpnState.event("Интерфейс поднят", "$TUN_IPV4 · mtu ${state.settings.mtu}") VpnState.event("Интерфейс поднят", "$TUN_IPV4 · mtu ${state.settings.mtu}")
VpnState.event("SOCKS5-мост", "127.0.0.1:${socks.port}") VpnState.event("SOCKS5-мост", "127.0.0.1:${socks.port}")
VpnState.event("DNS через туннель", dnsServerFor(state.settings)) if (domainRoutingActive(state.settings)) {
val modeLabel = when (state.settings.routingMode) {
RoutingMode.PROXY_ALL -> "через прокси, кроме списка"
RoutingMode.DIRECT_ALL -> "напрямую, кроме списка"
}
VpnState.event("Маршрутизация по доменам", "${state.settings.routedSites.size} правил · $modeLabel")
VpnState.event("Перехват DNS (fake-IP)", "$MAPDNS_ADDR · только IPv4")
} else {
VpnState.event("DNS через туннель", dnsServerFor(state.settings))
}
val hevConfig = buildEngineConfig(socks, state.settings) val hevConfig = buildEngineConfig(socks, state.settings)
val fd = pfd.fd val fd = pfd.fd
@ -330,12 +352,16 @@ class ProxyVpnService : VpnService(), SocketProtector {
} }
private fun establish(config: ProxyConfig, settings: AppSettings, socksPort: Int): ParcelFileDescriptor { private fun establish(config: ProxyConfig, settings: AppSettings, socksPort: Int): ParcelFileDescriptor {
// Domain routing needs the OS resolver to hit hev's fake-DNS so domains survive
// into the SOCKS5 CONNECT. Point DNS at the synthetic resolver; otherwise keep the
// tunnelled real resolver.
val sites = domainRoutingActive(settings)
val builder = Builder() val builder = Builder()
.setSession("vojo · ${config.name}") .setSession("vojo · ${config.name}")
.setMtu(settings.mtu) .setMtu(settings.mtu)
.addAddress(TUN_IPV4, 32) .addAddress(TUN_IPV4, 32)
.addRoute("0.0.0.0", 0) .addRoute("0.0.0.0", 0)
.addDnsServer(dnsServerFor(settings)) .addDnsServer(if (sites) MAPDNS_ADDR else dnsServerFor(settings))
.setBlocking(false) .setBlocking(false)
// IPv6 is only pulled into the tun when the user enables it AND the engine is // IPv6 is only pulled into the tun when the user enables it AND the engine is
@ -345,7 +371,11 @@ class ProxyVpnService : VpnService(), SocketProtector {
// rejects it (rep=5), which breaks Happy-Eyeballs apps that then prefer v6. // rejects it (rep=5), which breaks Happy-Eyeballs apps that then prefer v6.
// With v6 absent here, Android adds its own `::/0 unreachable` route, so native // With v6 absent here, Android adds its own `::/0 unreachable` route, so native
// v6 is blackholed (no leak) and apps fall back to v4 through the proxy. // v6 is blackholed (no leak) and apps fall back to v4 through the proxy.
if (settings.ipv6) { //
// While domain routing is active we force IPv4-only regardless of settings.ipv6:
// hev mapped-DNS is IPv4-only, so a dual-stack app reaching a routed site over v6
// would bypass the split. Suppressing v6 funnels everything through the fake-DNS.
if (settings.ipv6 && !sites) {
builder.addAddress(TUN_IPV6, 128).addRoute("::", 0) builder.addAddress(TUN_IPV6, 128).addRoute("::", 0)
} }
@ -386,11 +416,20 @@ class ProxyVpnService : VpnService(), SocketProtector {
return if (!settings.ipv6 && ':' in dns) "1.1.1.1" else dns return if (!settings.ipv6 && ':' in dns) "1.1.1.1" else dns
} }
/**
* Domain ("Сайты") routing is on whenever there are site entries that's when we need
* hev to restore domains via fake-DNS so the bridge can split proxy-vs-direct. Empty
* list = legacy behaviour (no mapped-DNS, no forced-IPv4), even under DIRECT_ALL where
* every IP-literal flow simply takes the direct default.
*/
private fun domainRoutingActive(settings: AppSettings): Boolean = settings.routedSites.isNotEmpty()
private fun buildEngineConfig(socks: LocalSocks5Server, settings: AppSettings): String = buildString { private fun buildEngineConfig(socks: LocalSocks5Server, settings: AppSettings): String = buildString {
val sites = domainRoutingActive(settings)
append("tunnel:\n") append("tunnel:\n")
append(" mtu: ${settings.mtu}\n") append(" mtu: ${settings.mtu}\n")
append(" ipv4: $TUN_IPV4\n") append(" ipv4: $TUN_IPV4\n")
if (settings.ipv6) append(" ipv6: '$TUN_IPV6'\n") if (settings.ipv6 && !sites) append(" ipv6: '$TUN_IPV6'\n")
append("socks5:\n") append("socks5:\n")
append(" address: 127.0.0.1\n") append(" address: 127.0.0.1\n")
append(" port: ${socks.port}\n") append(" port: ${socks.port}\n")
@ -402,6 +441,18 @@ class ProxyVpnService : VpnService(), SocketProtector {
append("misc:\n") append("misc:\n")
append(" connect-timeout: 8000\n") append(" connect-timeout: 8000\n")
append(" log-level: warn\n") append(" log-level: warn\n")
// Fake-IP / mapped-DNS: hev intercepts DNS to MAPDNS_ADDR:MAPDNS_PORT, answers with
// a synthetic IP from FAKE_NET/FAKE_MASK, and reverse-maps it to the original domain
// on the SOCKS5 CONNECT — so LocalSocks5Server can route by domain. Only emitted when
// sites are configured; absent = the engine leaves DNS untouched (cache-size 0 = off).
if (sites) {
append("mapdns:\n")
append(" address: $MAPDNS_ADDR\n")
append(" port: $MAPDNS_PORT\n")
append(" network: $FAKE_NET\n")
append(" netmask: $FAKE_MASK\n")
append(" cache-size: $MAPDNS_CACHE_SIZE\n")
}
} }
private fun startStatsLoop() { private fun startStatsLoop() {
@ -474,6 +525,56 @@ class ProxyVpnService : VpnService(), SocketProtector {
override fun protect(socket: Socket): Boolean = super<VpnService>.protect(socket) override fun protect(socket: Socket): Boolean = super<VpnService>.protect(socket)
override fun protect(socket: DatagramSocket): Boolean = super<VpnService>.protect(socket) override fun protect(socket: DatagramSocket): Boolean = super<VpnService>.protect(socket)
// --- DirectDialer ---
/**
* Dials [host]:[port] directly, bypassing the proxy, for sites routed DIRECT. Both the
* DNS resolution and the socket egress are pinned to the underlying (non-VPN) network so
* a direct flow can never loop back into the tun or the fake DNS. protect() is applied as
* belt-and-suspenders; its false return is benign (see the SocketProtector contract).
*/
override fun connectDirect(host: String, port: Int): Socket {
// The underlying (non-VPN) network is REQUIRED: resolving/dialing on the process
// default would hit the VPN's fake-DNS (198.18.0.2), which our excluded sockets can't
// even reach (it only lives inside the tun) — so a missing network must fail loudly,
// never fall back to the default resolver.
val net = underlyingNetwork() ?: throw UnknownHostException("no underlying network for direct dial")
// Resolve on that network's real resolver and bind the socket to it, so the direct
// flow egresses the physical interface and never loops back into the tun.
val target = net.getAllByName(host).firstOrNull() ?: throw UnknownHostException(host)
val socket = Socket()
protect(socket)
runCatching { net.bindSocket(socket) }
socket.tcpNoDelay = true
socket.connect(InetSocketAddress(target, port), CONNECT_TIMEOUT_MS)
return socket
}
/**
* The real (non-VPN) transport to dial direct flows over. While we're connected,
* cm.activeNetwork is the VPN itself, but the default-network callback reports the network
* BENEATH our VPN, so [lastNetwork] is the right egress validated here as still non-VPN,
* with an allNetworks scan as a fallback.
*/
private fun underlyingNetwork(): Network? {
val cm = getSystemService(ConnectivityManager::class.java) ?: return null
// Must carry general internet — NOT just any non-VPN transport. A device may expose an
// IMS/MMTEL cellular network (NOT_VPN, TRANSPORT_CELLULAR, but NO INTERNET capability)
// that can't resolve hostnames; picking it broke every direct dial (device-confirmed).
fun isUsable(n: Network): Boolean {
val caps = cm.getNetworkCapabilities(n) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
(caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET))
}
fun validated(n: Network) =
cm.getNetworkCapabilities(n)?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == true
lastNetwork?.let { if (isUsable(it) && validated(it)) return it }
val usable = cm.allNetworks.filter { isUsable(it) }
return usable.firstOrNull { validated(it) } ?: usable.firstOrNull()
}
private fun fail(message: String) { // lifecycle thread private fun fail(message: String) { // lifecycle thread
VpnState.event("Ошибка", message, VpnState.TunnelEvent.Tone.ERROR) VpnState.event("Ошибка", message, VpnState.TunnelEvent.Tone.ERROR)
shutdown(errorMessage = message) shutdown(errorMessage = message)
@ -685,6 +786,22 @@ class ProxyVpnService : VpnService(), SocketProtector {
const val TUN_IPV4 = "198.18.0.1" const val TUN_IPV4 = "198.18.0.1"
private const val TUN_IPV6 = "fc00::1" private const val TUN_IPV6 = "fc00::1"
// hev mapped-DNS (fake-IP) parameters, used only while domain routing is active.
// Synthetic resolver the OS DNS is pointed at; hev intercepts UDP to this addr:port.
private const val MAPDNS_ADDR = "198.18.0.2"
private const val MAPDNS_PORT = 53
// Fake-IP pool. 198.19.0.0/16 sits in the RFC 2544 benchmarking range (non-routable),
// clears the 198.18.0.1/32 tun address and the synthetic resolver, and is NOT in the
// CGNAT/private set isUnroutableRelay() rewrites — these fake IPs become domains before
// ever reaching the upstream, so that relay path is never involved. /16 = 65534 slots.
private const val FAKE_NET = "198.19.0.0"
private const val FAKE_MASK = "255.255.0.0"
// Size the LRU to the full /16 pool (max allowed is ~mask = 65535). A smaller cache would
// evict still-referenced fake IPs for broad categories (geolocation-!cn alone is ~24.7k
// domains), and an evicted IP reverse-maps to nothing → the raw 198.19.x.x literal leaks
// to the bridge and dead-ends. Filling the whole pool makes eviction effectively impossible.
private const val MAPDNS_CACHE_SIZE = 65534
fun start(context: Context) { fun start(context: Context) {
val intent = Intent(context, ProxyVpnService::class.java).setAction(ACTION_START) val intent = Intent(context, ProxyVpnService::class.java).setAction(ACTION_START)
ContextCompat.startForegroundService(context, intent) ContextCompat.startForegroundService(context, intent)