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:
parent
e01b0c2a87
commit
fadb0870e8
3 changed files with 166 additions and 9 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue