diff --git a/src/frontends/android/app/build.gradle b/src/frontends/android/app/build.gradle index 99eb26177..91278898f 100644 --- a/src/frontends/android/app/build.gradle +++ b/src/frontends/android/app/build.gradle @@ -45,8 +45,8 @@ android { minSdk = 24 targetSdk = 36 - versionCode 174 - versionName "2.19.6" + versionCode 175 + versionName "2.19.7" /* Base URL of the update storage, trailing slash included */ buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\"" diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java index 1c3d3f1f8..cf1ef2874 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java @@ -1191,6 +1191,11 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe && vpnCaps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) + ", dns=" + (lp != null ? lp.getDnsServers() : "?") + ", routes=" + (lp != null ? lp.getRoutes().size() : "?")); + if (lp != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + { /* whether Android validated the strict private DNS server over the tunnel */ + DiagLog.i(TAG, "dns diag: vpn privateDns active=" + lp.isPrivateDnsActive() + + ", name=" + lp.getPrivateDnsServerName()); + } if (underlying != null) { NetworkCapabilities uCaps = cm.getNetworkCapabilities(underlying); diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/protection/SecureDns.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/protection/SecureDns.kt index c5bfb3a65..03df8640e 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/protection/SecureDns.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/protection/SecureDns.kt @@ -143,15 +143,31 @@ object SecureDnsCodec fun readFrame(input: InputStream): ByteArray { val data = DataInputStream(input) - val len = data.readUnsignedShort() - val msg = ByteArray(len) - data.readFully(msg) - return msg + try + { + val len = data.readUnsignedShort() + val msg = ByteArray(len) + data.readFully(msg) + return msg + } + catch (e: java.io.EOFException) + { /* some servers drop names they refuse to answer by closing the connection */ + throw IOException("connection closed without a reply") + } } /** Same transaction id: a reply that belongs to the query. */ @JvmStatic fun sameId(a: ByteArray, b: ByteArray): Boolean = a.size >= 2 && b.size >= 2 && a[0] == b[0] && a[1] == b[1] + + /** Throws unless [reply] is a DNS response (header, QR bit) to [query]. */ + @JvmStatic + fun checkReply(query: ByteArray, reply: ByteArray) + { + if (reply.isEmpty()) throw IOException("empty reply") + if (reply.size < 12 || (reply[2].toInt() and 0x80) == 0) throw IOException("not a DNS reply") + if (!sameId(query, reply)) throw IOException("reply id mismatch") + } } /** @@ -177,6 +193,7 @@ object SecureDns private const val TIMEOUT_MS = 3000 private const val PROBE_TTL_MS = 10 * 60 * 1000L + private const val PROBE_FAIL_TTL_MS = 60 * 1000L private const val SUMMARY_EVERY_MS = 15 * 60 * 1000L private const val WORKERS = 4 @@ -202,41 +219,62 @@ object SecureDns val ok: Boolean get() = error == null } - /** One query for a fixed name; for the connect decision and the «Save» check. */ + /** + * Names for the probe; any well-formed reply (any rcode) means the server + * speaks DoH/DoT. Not example.com: some servers silently drop it. + */ + private val PROBE_NAMES = arrayOf("one.one.one.one", "ya.ru") + + /** One query (a second name if the first gets no reply); for the connect decision and the «Save» check. */ @JvmStatic fun probe(config: SecureDnsConfig): Probe { val start = SystemClock.elapsedRealtime() - return try + var error: String? = null + for (name in PROBE_NAMES) { - val q = SecureDnsCodec.query((start and 0xffff).toInt(), "example.com", 1) - val reply = resolve(config, q) - if (!SecureDnsCodec.sameId(q, reply)) throw IOException("reply id mismatch") - Probe(SystemClock.elapsedRealtime() - start, null) - } - catch (e: Exception) - { - Probe(SystemClock.elapsedRealtime() - start, describe(e)) + try + { + val q = SecureDnsCodec.query((SystemClock.elapsedRealtime() and 0xffff).toInt(), name, 1) + SecureDnsCodec.checkReply(q, resolve(config, q)) + error = null + break + } + catch (e: Exception) + { + error = describe(e) + if (e is java.net.SocketTimeoutException) break /* a dead server: no point waiting twice */ + } } + val result = Probe(SystemClock.elapsedRealtime() - start, error) + remember(config, result.ok) + return result + } + + @Synchronized + private fun remember(config: SecureDnsConfig, ok: Boolean) + { + probedConfig = config + probedAt = SystemClock.elapsedRealtime() + probeOk = ok } /** - * Whether [config] works right now (probe result cached for 10 min, so - * a reconnect does not wait again). Logs the decision. + * Whether [config] works right now. The result of the last probe (also + * the one from «Save») is reused: a success for 10 min, a failure for + * 1 min, so one connect does not probe (and wait) several times. Logs + * the decision when it probes. */ @JvmStatic @Synchronized fun ensureReady(config: SecureDnsConfig): Boolean { - val now = SystemClock.elapsedRealtime() - if (config == probedConfig && probeOk && now - probedAt < PROBE_TTL_MS) + val age = SystemClock.elapsedRealtime() - probedAt + if (config == probedConfig && age < if (probeOk) PROBE_TTL_MS else PROBE_FAIL_TTL_MS) { - return true + return probeOk } val result = probe(config) - probedConfig = config - probedAt = now - probeOk = result.ok DiagLog.i(TAG, if (result.ok) "probe ${config.label} ok in ${result.ms} ms" else "probe ${config.label} failed after ${result.ms} ms: ${result.error}; falling back to plain DNS") return result.ok @@ -312,7 +350,7 @@ object SecureDns val reply = try { resolve(config, query).also { - if (!SecureDnsCodec.sameId(query, it)) throw IOException("reply id mismatch") + SecureDnsCodec.checkReply(query, it) totalMs.addAndGet(SystemClock.elapsedRealtime() - start) if (failing) { @@ -427,7 +465,7 @@ object SecureDns conn.out.write(SecureDnsCodec.frame(query)) conn.out.flush() val reply = SecureDnsCodec.readFrame(conn.input) - if (!SecureDnsCodec.sameId(query, reply)) throw IOException("reply id mismatch") + SecureDnsCodec.checkReply(query, reply) return reply } catch (e: IOException) diff --git a/src/frontends/android/app/src/test/java/org/strongswan/android/logic/protection/SecureDnsTest.kt b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/protection/SecureDnsTest.kt index f0e026710..1bb56a6fe 100644 --- a/src/frontends/android/app/src/test/java/org/strongswan/android/logic/protection/SecureDnsTest.kt +++ b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/protection/SecureDnsTest.kt @@ -15,6 +15,7 @@ package org.strongswan.android.logic.protection import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.Test import java.io.ByteArrayInputStream @@ -98,6 +99,25 @@ class SecureDnsTest assertThat(SecureDnsCodec.servfail(ByteArray(12))!!.size).isEqualTo(12) } + @Test + fun checkReplyRejectsEmptyAndForeign() + { + val q = SecureDnsCodec.query(0x1234, "ya.ru", 1) + assertThatThrownBy { SecureDnsCodec.checkReply(q, ByteArray(0)) }.hasMessage("empty reply") + /* the query itself has no QR bit */ + assertThatThrownBy { SecureDnsCodec.checkReply(q, q) }.hasMessage("not a DNS reply") + val other = SecureDnsCodec.servfail(SecureDnsCodec.query(0x4321, "ya.ru", 1))!! + assertThatThrownBy { SecureDnsCodec.checkReply(q, other) }.hasMessage("reply id mismatch") + SecureDnsCodec.checkReply(q, SecureDnsCodec.servfail(q)!!) /* any rcode is a reply */ + } + + @Test + fun dotClosedConnectionIsReported() + { + assertThatThrownBy { SecureDnsCodec.readFrame(ByteArrayInputStream(ByteArray(0))) } + .hasMessage("connection closed without a reply") + } + @Test fun dotFramingRoundTrip() { diff --git a/src/frontends/android/docs/README.md b/src/frontends/android/docs/README.md index d9ebea711..1555d8983 100644 --- a/src/frontends/android/docs/README.md +++ b/src/frontends/android/docs/README.md @@ -1,6 +1,6 @@ # SHX VPN для Android -Форк Android-клиента strongSwan (IKEv2/IPsec), пакет `one.shx.strongswan.ext`. Текущая версия — **2.19.6 (versionCode 174)**. +Форк Android-клиента strongSwan (IKEv2/IPsec), пакет `one.shx.strongswan.ext`. Текущая версия — **2.19.7 (versionCode 175)**. Распространяется вне Google Play: APK и манифест обновлений лежат в приватном бакете S3, приложение обновляется само. - Дизайн: [design/README.md](design/README.md) — макет «Финал 2026» и соответствие артбордов коду.