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
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.readExact
import chat.vojo.proxy.core.net.readFully
import chat.vojo.proxy.core.net.readSocksAddress
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.Upstream
import chat.vojo.proxy.core.upstream.UpstreamConn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
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
* (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(
private val upstream: Upstream,
private val router: SiteRouter,
private val directDialer: DirectDialer,
private val onUpstreamError: ((Throwable) -> Unit)? = null,
) {
private val serverSocket = ServerSocket().apply {
@ -186,11 +197,21 @@ class LocalSocks5Server(
cout: OutputStream,
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 {
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) {
Log.w(TAG, "upstream connect failed for $dest: ${e.message}")
onUpstreamError?.invoke(e)
Log.w(TAG, "$decision connect failed for $dest: ${e.message}")
// 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
return
}
@ -259,6 +280,9 @@ class LocalSocks5Server(
}
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))
track(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.ProxyConfig
import chat.vojo.proxy.core.ProxyType
import chat.vojo.proxy.core.RoutingMode
import chat.vojo.proxy.core.Tun2Socks
import chat.vojo.proxy.core.net.Destination
import chat.vojo.proxy.core.net.readExact
import chat.vojo.proxy.core.formatBytes
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.upstream.CONNECT_TIMEOUT_MS
import chat.vojo.proxy.core.upstream.DirectDialer
import chat.vojo.proxy.core.upstream.SocketProtector
import chat.vojo.proxy.core.upstream.Upstream
import chat.vojo.proxy.data.AssetGeositeSource
import chat.vojo.proxy.data.ConfigStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -40,10 +45,13 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.net.DatagramSocket
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.net.UnknownHostException
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
// 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))
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
val pfd = establish(config, state.settings, socks.port)
vpnInterface = pfd
VpnState.event("Интерфейс поднят", "$TUN_IPV4 · mtu ${state.settings.mtu}")
VpnState.event("SOCKS5-мост", "127.0.0.1:${socks.port}")
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 fd = pfd.fd
@ -330,12 +352,16 @@ class ProxyVpnService : VpnService(), SocketProtector {
}
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()
.setSession("vojo · ${config.name}")
.setMtu(settings.mtu)
.addAddress(TUN_IPV4, 32)
.addRoute("0.0.0.0", 0)
.addDnsServer(dnsServerFor(settings))
.addDnsServer(if (sites) MAPDNS_ADDR else dnsServerFor(settings))
.setBlocking(false)
// 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.
// 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.
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)
}
@ -386,11 +416,20 @@ class ProxyVpnService : VpnService(), SocketProtector {
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 {
val sites = domainRoutingActive(settings)
append("tunnel:\n")
append(" mtu: ${settings.mtu}\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(" address: 127.0.0.1\n")
append(" port: ${socks.port}\n")
@ -402,6 +441,18 @@ class ProxyVpnService : VpnService(), SocketProtector {
append("misc:\n")
append(" connect-timeout: 8000\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() {
@ -474,6 +525,56 @@ class ProxyVpnService : VpnService(), SocketProtector {
override fun protect(socket: Socket): 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
VpnState.event("Ошибка", message, VpnState.TunnelEvent.Tone.ERROR)
shutdown(errorMessage = message)
@ -685,6 +786,22 @@ class ProxyVpnService : VpnService(), SocketProtector {
const val TUN_IPV4 = "198.18.0.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) {
val intent = Intent(context, ProxyVpnService::class.java).setAction(ACTION_START)
ContextCompat.startForegroundService(context, intent)