android: DoH/DoT probe asks one.one.one.one (then ya.ru) instead of example.com, which some servers drop (dns.shnt.top closes DoT / returns an empty 200); replies are checked for header, QR bit and id with clear errors («empty reply», «connection closed without a reply»); a failed probe is cached for 1 min, so one connect no longer probes 4 times; dns diag logs the VPN network's private DNS state; version 2.19.7 (175)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-11-09 22:11:20 +07:00
1 parent c5c5f5d461
commit b9ea35832c
5 files changed
+90 -27

No files matched your search

+2 -2
View File
@@ -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}\""
@@ -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);
@@ -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)
@@ -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()
{
+1 -1
View File
@@ -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» и соответствие артбордов коду.