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:
1 parent
c5c5f5d461
commit
b9ea35832c
5 files changed
+90
-27
No files matched your search
@@ -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}\""
|
||||
|
||||
+5
@@ -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);
|
||||
|
||||
+62
-24
@@ -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)
|
||||
|
||||
+20
@@ -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,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» и соответствие артбордов коду.
|
||||
|
||||
Reference in new issue
Block a user