diff --git a/app/src/main/java/chat/vojo/proxy/core/socks/LocalSocks5Server.kt b/app/src/main/java/chat/vojo/proxy/core/socks/LocalSocks5Server.kt index 46790dc..f89ccb1 100644 --- a/app/src/main/java/chat/vojo/proxy/core/socks/LocalSocks5Server.kt +++ b/app/src/main/java/chat/vojo/proxy/core/socks/LocalSocks5Server.kt @@ -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 { - 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) { - 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) diff --git a/app/src/main/java/chat/vojo/proxy/core/upstream/DirectDialer.kt b/app/src/main/java/chat/vojo/proxy/core/upstream/DirectDialer.kt new file mode 100644 index 0000000..b25cafe --- /dev/null +++ b/app/src/main/java/chat/vojo/proxy/core/upstream/DirectDialer.kt @@ -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 +} diff --git a/app/src/main/java/chat/vojo/proxy/service/ProxyVpnService.kt b/app/src/main/java/chat/vojo/proxy/service/ProxyVpnService.kt index e1c3599..6c9969a 100644 --- a/app/src/main/java/chat/vojo/proxy/service/ProxyVpnService.kt +++ b/app/src/main/java/chat/vojo/proxy/service/ProxyVpnService.kt @@ -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}") - 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 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.protect(socket) override fun protect(socket: DatagramSocket): Boolean = super.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)