From a5681608edb86fe32648d99301f7f556ecf829cf Mon Sep 17 00:00:00 2001 From: heaven Date: Tue, 16 Jun 2026 12:30:53 +0300 Subject: [PATCH] feat(service): probe upstream after connect, classify failures, reap stuck engine thread --- .../vojo/proxy/service/ProxyVpnService.kt | 255 ++++++++++++++++-- 1 file changed, 236 insertions(+), 19 deletions(-) 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 192dee6..e1c3599 100644 --- a/app/src/main/java/chat/vojo/proxy/service/ProxyVpnService.kt +++ b/app/src/main/java/chat/vojo/proxy/service/ProxyVpnService.kt @@ -20,7 +20,10 @@ import chat.vojo.proxy.MainActivity 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.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.socks.LocalSocks5Server @@ -111,6 +114,19 @@ class ProxyVpnService : VpnService(), SocketProtector { private fun startTunnel() { // lifecycle thread if (running) return + if (engineThread?.isAlive == true) { + // A previous engine overran the stop deadline and a reaper is still + // finishing it. Minting a new engine now collides with the native + // double-start guard (ret -2), so refuse and clean up: clear the FGS + // notification goForeground() just showed and end the service so the UI + // doesn't sit on a stale "Подключение…". The daemon reaper finishes + // closing the old fd; the user can reconnect once it's done. + VpnState.event("Туннель ещё останавливается", "подождите пару секунд и повторите", VpnState.TunnelEvent.Tone.WARN) + VpnState.setStopped(null) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + return + } stopRequested = false tornDown = false lastUpstreamWarn = 0L @@ -137,38 +153,176 @@ class ProxyVpnService : VpnService(), SocketProtector { vpnInterface = pfd VpnState.event("Интерфейс поднят", "$TUN_IPV4 · mtu ${state.settings.mtu}") VpnState.event("SOCKS5-мост", "127.0.0.1:${socks.port}") - VpnState.event("DNS через туннель", state.settings.dns) + VpnState.event("DNS через туннель", dnsServerFor(state.settings)) - val hevConfig = buildEngineConfig(socks.port, state.settings) + val hevConfig = buildEngineConfig(socks, state.settings) val fd = pfd.fd engineThread = Thread({ val ret = Tun2Socks.nativeStart(hevConfig, fd) Log.i(TAG, "engine returned $ret") - lifecycle.execute { onEngineExit(gen, ret) } + // If onDestroy already called lifecycle.shutdown(), this throws + // RejectedExecutionException on this thread (an uncaught crash). The + // engine is exiting during teardown anyway, so dropping the callback + // is correct. + runCatching { lifecycle.execute { onEngineExit(gen, ret) } } }, "vojo-tun2socks").apply { start() } running = true VpnState.setConnected(VpnState.Applied(config.id, state.settings)) - VpnState.event("Подключено", "${config.name} · ${config.type.label}", VpnState.TunnelEvent.Tone.OK) + // The tun interface is up and routing immediately. Whether the *proxy* + // actually works (reachable, creds correct) is a separate question that + // a green "Подключено" must not pre-answer — the async probe below + // confirms it with OK, or names the real failure. Until then: NEUTRAL. + VpnState.event("Туннель поднят", "${config.name} · ${config.type.label}") updateNotification(getString(R.string.notif_connected, config.name)) startStatsLoop() registerNetworkCallback() + runUpstreamProbe(upstream, config.type, gen) } catch (e: Throwable) { Log.e(TAG, "failed to start tunnel", e) fail(e.message ?: "Не удалось запустить туннель") } } - /** Throttled warning surfaced to the journal when upstream connects fail. */ - private fun onUpstreamUnreachable() { + /** Throttled, classified diagnostic surfaced to the journal when upstream flows fail. */ + private fun onUpstreamUnreachable(error: Throwable) { if (!running) return val now = System.currentTimeMillis() - if (now - lastUpstreamWarn > 30_000) { - lastUpstreamWarn = now - VpnState.event("Прокси не отвечает", "проверьте сервер или сеть", VpnState.TunnelEvent.Tone.WARN) + if (now - lastUpstreamWarn < 30_000) return + lastUpstreamWarn = now + val (label, detail, tone) = classifyUpstreamError(error) + VpnState.event(label, detail, tone) + } + + /** + * Turns a raw upstream failure into a specific, user-actionable journal line so + * "bad password" can never look like "dropped Wi-Fi". Used by both the live relay + * error path and the one-shot connectivity probe. + */ + private fun classifyUpstreamError(e: Throwable): Triple { + val warn = VpnState.TunnelEvent.Tone.WARN + val err = VpnState.TunnelEvent.Tone.ERROR + val msg = e.message ?: "" + return when { + e is javax.net.ssl.SSLPeerUnverifiedException -> + Triple("Сертификат прокси не прошёл проверку", msg.ifBlank { null }, err) + e is javax.net.ssl.SSLException -> + Triple("Ошибка TLS с прокси", msg.ifBlank { null }, err) + e is java.security.GeneralSecurityException -> + // Fires only when the server sends bytes that fail our AEAD tag (tampered or + // a true cipher mismatch). The COMMON wrong-key case never reaches here — + // most SS servers just drop the connection (see ShadowsocksKeyLikelyWrong). + Triple("Неверный пароль или шифр Shadowsocks", null, err) + e is ShadowsocksKeyLikelyWrong -> + Triple("Похоже, неверный ключ или шифр Shadowsocks", "сервер разорвал соединение, не ответив", warn) + e is java.net.UnknownHostException -> + Triple("Не удалось разрешить адрес прокси", msg.ifBlank { null }, err) + msg.contains("auth", ignoreCase = true) || msg.contains("407") -> + Triple("Прокси отклонил аутентификацию", "проверьте логин и пароль", err) + e is java.net.SocketTimeoutException || msg.contains("timed out", ignoreCase = true) -> + Triple("Прокси не отвечает", "таймаут — проверьте сервер или сеть", warn) + e is java.net.ConnectException -> + Triple("Не удалось подключиться к прокси", msg.ifBlank { null }, err) + // SOCKS5 rep!=0 / HTTP non-2xx: the proxy answered but refused the request. + // Could be access/policy (some proxies, e.g. 3proxy, accept any user/pass at + // the auth step and deny here instead) or an unreachable target — we can't + // tell which, so don't claim it's specifically the destination. + msg.contains("connect failed", ignoreCase = true) || Regex("rep=\\d").containsMatchIn(msg) -> + Triple("Прокси отклонил запрос", "доступ, политика или недоступный адрес", warn) + else -> + Triple("Ошибка прокси", msg.ifBlank { e.javaClass.simpleName }, warn) } } + /** + * One-shot proof that the configured proxy actually works: opens a real tunnelled + * connection to a stable target (1.1.1.1:53) right after connect. Success flips the + * journal to a confident OK; failure names the real cause. Makes "is the proxy up?" + * a deterministic, observable signal instead of a guess. The tunnel stays up either + * way so the user can read the diagnosis and fix the config. + */ + private fun runUpstreamProbe(upstream: Upstream, type: ProxyType, gen: Int) { + scope.launch { + val startedAt = System.currentTimeMillis() + val result = runCatching { + // Shadowsocks has no connect-time handshake, so a wrong key passes + // connect() silently — force a DNS-over-TCP round trip to :53, which makes + // the server decrypt our frame. A bad key then fails the probe (usually the + // server dropping us before replying, occasionally an AEAD error), so we + // never show a false "Прокси подтверждён". SOCKS5/HTTP(S) authenticate during + // the handshake, so a plain CONNECT to :443 confirms them without + // false-failing proxies that block :53. + if (type == ProxyType.SHADOWSOCKS) probeRoundTrip(upstream) + else upstream.connectTcp(probeDestination(443)).use { } + } + // Drop a probe whose tunnel was already superseded (restart/stop) so a + // stale verdict can't land in a newer tunnel's journal. + if (!running || gen != engineGen) return@launch + result + .onSuccess { + val ms = System.currentTimeMillis() - startedAt + VpnState.event("Прокси подтверждён", "ответ за ${ms} мс", VpnState.TunnelEvent.Tone.OK) + } + .onFailure { e -> + Log.w(TAG, "upstream probe failed", e) + val (label, detail, tone) = classifyUpstreamError(e) + VpnState.event(label, detail, tone) + } + } + } + + /** + * Real end-to-end round trip through the proxy: a DNS-over-TCP query to 1.1.1.1:53. + * Forcing a *read* validates more than reachability + auth — for Shadowsocks it + * triggers AEAD decryption, so a wrong key/cipher fails the probe instead of showing a + * false "Прокси подтверждён" (connectTcp alone does no SS round trip). The failure is + * usually NOT a crypto exception: most servers drop the connection on a bad key, which + * we see as a premature EOF and translate to a hedged key hint. + */ + private fun probeRoundTrip(upstream: Upstream) { + upstream.connectTcp(probeDestination(53)).use { conn -> + conn.socket.soTimeout = PROBE_TIMEOUT_MS + val q = dnsProbeQuery() + conn.output.write(byteArrayOf((q.size ushr 8).toByte(), q.size.toByte())) + conn.output.write(q) + conn.output.flush() + try { + conn.input.readExact(2) // a real reply proves the SS key decrypts end to end + } catch (e: java.net.SocketTimeoutException) { + throw e // proxy genuinely silent — keep the distinct "не отвечает" diagnosis + } catch (e: java.io.IOException) { + // Connected and sent our frame, but the peer died before replying — a clean + // EOF or a reset, with no AEAD exception. That's the common wrong-key/cipher + // signature for Shadowsocks (servers usually just drop). Reclassify so the + // journal can hint at the key instead of a generic error. + throw ShadowsocksKeyLikelyWrong(e) + } + } + } + + /** Premature close on the Shadowsocks probe — a likely (not certain) wrong key/cipher. */ + private class ShadowsocksKeyLikelyWrong(cause: Throwable) : + java.io.IOException("shadowsocks probe closed before reply", cause) + + /** SOCKS address for the probe target 1.1.1.1:[port]. */ + private fun probeDestination(port: Int): Destination { + val raw = byteArrayOf(0x01, 1, 1, 1, 1, (port ushr 8).toByte(), port.toByte()) + return Destination(host = "1.1.1.1", port = port, atyp = 0x01, raw = raw) + } + + /** Minimal DNS A query for cloudflare.com with recursion desired. */ + private fun dnsProbeQuery(): ByteArray { + val out = java.io.ByteArrayOutputStream() + out.write(byteArrayOf(0x13, 0x37, 0x01, 0x00, 0, 1, 0, 0, 0, 0, 0, 0)) // id, flags=RD, qd=1 + for (label in "cloudflare.com".split('.')) { + out.write(label.length) + out.write(label.toByteArray(Charsets.US_ASCII)) + } + out.write(0) // end of name + out.write(byteArrayOf(0, 1, 0, 1)) // QTYPE=A, QCLASS=IN + return out.toByteArray() + } + /** Engine thread exited on its own (not via a requested stop) — tear down loudly. */ private fun onEngineExit(gen: Int, ret: Int) { // lifecycle thread if (gen != engineGen || stopRequested) return @@ -181,9 +335,16 @@ class ProxyVpnService : VpnService(), SocketProtector { .setMtu(settings.mtu) .addAddress(TUN_IPV4, 32) .addRoute("0.0.0.0", 0) - .addDnsServer(settings.dns.ifBlank { "1.1.1.1" }) + .addDnsServer(dnsServerFor(settings)) .setBlocking(false) + // IPv6 is only pulled into the tun when the user enables it AND the engine is + // told to carry it (buildEngineConfig adds the `ipv6:` line). We must NOT route + // v6 into the tun while disabled: hev does not silently drop unconfigured-family + // packets — it forwards the v6 CONNECT to the upstream, and a v6-incapable proxy + // 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) { builder.addAddress(TUN_IPV6, 128).addRoute("::", 0) } @@ -219,15 +380,25 @@ class ProxyVpnService : VpnService(), SocketProtector { } } - private fun buildEngineConfig(socksPort: Int, settings: AppSettings): String = buildString { + /** DNS server routed through the tunnel; avoids a v6 literal while v6 isn't routed. */ + private fun dnsServerFor(settings: AppSettings): String { + val dns = settings.dns.ifBlank { "1.1.1.1" } + return if (!settings.ipv6 && ':' in dns) "1.1.1.1" else dns + } + + private fun buildEngineConfig(socks: LocalSocks5Server, settings: AppSettings): String = buildString { append("tunnel:\n") append(" mtu: ${settings.mtu}\n") append(" ipv4: $TUN_IPV4\n") if (settings.ipv6) append(" ipv6: '$TUN_IPV6'\n") append("socks5:\n") append(" address: 127.0.0.1\n") - append(" port: $socksPort\n") + append(" port: ${socks.port}\n") append(" udp: 'udp'\n") + // Authenticate the engine to the loopback bridge so no other local app can + // use it. Tokens are hex, safe to embed unquoted-style in single quotes. + append(" username: '${socks.authUser}'\n") + append(" password: '${socks.authPass}'\n") append("misc:\n") append(" connect-timeout: 8000\n") append(" log-level: warn\n") @@ -261,12 +432,20 @@ class ProxyVpnService : VpnService(), SocketProtector { private fun registerNetworkCallback() { val cm = getSystemService(ConnectivityManager::class.java) ?: return + val registeredAt = System.currentTimeMillis() lastNetwork = cm.activeNetwork val cb = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { runCatching { setUnderlyingNetworks(arrayOf(network)) } - if (network != lastNetwork) { - lastNetwork = network + if (network == lastNetwork) return + val previous = lastNetwork + lastNetwork = network + // Suppress bring-up churn: the first callbacks merely report the + // current underlying network(s) as the VPN settles, not a real roam. + // Only announce a change once we had a previous network and the tunnel + // has been up long enough that this is a genuine handover. + if (previous == null || System.currentTimeMillis() - registeredAt < NETWORK_SETTLE_MS) return + run { val caps = runCatching { cm.getNetworkCapabilities(network) }.getOrNull() val name = when { caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true -> "Wi-Fi" @@ -323,7 +502,6 @@ class ProxyVpnService : VpnService(), SocketProtector { networkCallback = null val engine = engineThread - engineThread = null if (engine != null) { // nativeStop only signals the engine's event fd; the native `running` // flag and the tun fd aren't released until nativeStart() returns and @@ -336,19 +514,25 @@ class ProxyVpnService : VpnService(), SocketProtector { runCatching { engine.join(ENGINE_JOIN_STEP_MS) } waited += ENGINE_JOIN_STEP_MS } - if (engine.isAlive) Log.w(TAG, "engine thread did not exit in time") } server?.stop() server = null - // Only reclaim the tun fd once the engine has truly stopped touching it; - // closing it out from under live native code is undefined behaviour. if (engine == null || !engine.isAlive) { + engineThread = null + // Safe to reclaim the tun fd: the engine has stopped touching it. runCatching { vpnInterface?.close() } vpnInterface = null } else { - Log.w(TAG, "leaving tun fd open — engine still alive after stop") + // The engine overran the stop deadline. Do NOT drop the handle or close + // the fd under live native code, and do NOT let startTunnel mint a second + // engine (it would hit the C double-start guard, ret -2). Retain + // engineThread + vpnInterface and let a reaper finish teardown once the + // native engine finally exits. startTunnel() refuses to start while + // engineThread.isAlive, so the fd can't be overwritten in the meantime. + Log.w(TAG, "engine still alive after stop deadline; reaping in background") + reapStuckEngine(engine) } running = false @@ -364,6 +548,32 @@ class ProxyVpnService : VpnService(), SocketProtector { } } + /** + * Finishes teardown of a native engine that overran the stop deadline, off the + * lifecycle thread so it never blocks it. Keeps re-kicking nativeStop until the + * engine truly exits, then closes the retained tun fd and clears the handle (on + * the lifecycle thread) so a subsequent connect is allowed again. + */ + private fun reapStuckEngine(engine: Thread) { + val fd = vpnInterface + Thread({ + while (engine.isAlive) { + runCatching { Tun2Socks.nativeStop() } + runCatching { engine.join(ENGINE_JOIN_STEP_MS) } + } + runCatching { fd?.close() } + runCatching { + lifecycle.execute { + if (engineThread === engine) { + engineThread = null + vpnInterface = null + } + Log.i(TAG, "reaped stuck engine; tun fd released") + } + } + }, "vojo-engine-reaper").apply { isDaemon = true; start() } + } + override fun onRevoke() { // User revoked VPN permission or another VPN took over. lifecycle.execute { @@ -465,6 +675,13 @@ class ProxyVpnService : VpnService(), SocketProtector { private const val ENGINE_JOIN_TIMEOUT_MS = 5000L private const val ENGINE_JOIN_STEP_MS = 250L + // Grace window after connect during which underlying-network callbacks are + // treated as bring-up settling, not a user-visible roam. + private const val NETWORK_SETTLE_MS = 4000L + + // Per-read timeout for the one-shot connectivity probe round trip. + private const val PROBE_TIMEOUT_MS = 8000 + const val TUN_IPV4 = "198.18.0.1" private const val TUN_IPV6 = "fc00::1"