diff --git a/src/frontends/android/app/build.gradle b/src/frontends/android/app/build.gradle index 7dc0e5b46..99eb26177 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 173 - versionName "2.19.5" + versionCode 174 + versionName "2.19.6" /* 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 e2e0b3358..1c3d3f1f8 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 @@ -961,6 +961,11 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe { TrafficHistory.stop(this); } + if (state == State.DISABLED) + { /* the local DoH/DoT resolver lives only with a tunnel */ + setDnsRedirect(null, 0); + org.strongswan.android.logic.protection.SecureDns.stop("disconnected"); + } synchronized (mServiceLock) { if (mService != null) @@ -1745,6 +1750,12 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe */ public native void initiate(String config); + /** + * Encrypted DNS: UDP/53 to vip (IPv4, 4 bytes) goes to 127.0.0.1:port; + * null or 0 turns it off. + */ + public native void setDnsRedirect(byte[] vip, int port); + /** * Sum inbound/outbound CHILD_SA bytes, provided by libandroidbridge.so. * Static so it can be polled from the UI. @@ -2369,6 +2380,8 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe */ public class BuilderCache { + /* the user's DoH/DoT resolver when it answered the probe, else null */ + private org.strongswan.android.logic.protection.SecureDnsConfig mSecureDns; private final List mAddresses = new ArrayList<>(); private final List mRoutesIPv4 = new ArrayList<>(); private final List mRoutesIPv6 = new ArrayList<>(); @@ -2469,6 +2482,19 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe sAppliedGeoExclusionsV6 = mGeoExcludedV6; + /* F_Protection: encrypted DNS (own DoH/DoT) is used only if it answers + * now; otherwise the profile's DNS, never a silent dead resolver */ + org.strongswan.android.logic.protection.SecureDnsConfig secure = + org.strongswan.android.logic.protection.Protection.secureDns(CharonVpnService.this); + if (secure != null) + { + mSecureDns = org.strongswan.android.logic.protection.SecureDns.ensureReady(secure) ? secure : null; + org.strongswan.android.logic.events.Events.secureDnsResult(CharonVpnService.this, mSecureDns != null); + if ("hostname".equals(Settings.Global.getString(getContentResolver(), "private_dns_mode"))) + { + DiagLog.i(TAG, "strict private DNS is on: the system resolver bypasses the tunnel DNS, own DoH/DoT has no effect"); + } + } /* F_Protection: a chosen resolver (filtering or not) wins over the profile's */ java.util.List chosenDns = org.strongswan.android.logic.protection.Protection.dnsServers(CharonVpnService.this); String dnsServers = chosenDns != null ? android.text.TextUtils.join(" ", chosenDns) : profile.getDnsServers(); @@ -2648,7 +2674,26 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe { builder.addAddress(address.getFrom(), address.getPrefix()); } - if (includeDns) + /* encrypted DNS needs an IPv4 address in the tunnel to reach the + * virtual DNS address; otherwise the plain servers stay */ + boolean secureDns = mSecureDns != null && mIPv4Seen; + if (mSecureDns != null && !mIPv4Seen) + { + DiagLog.i(TAG, "no IPv4 address in the tunnel, own DoH/DoT not used"); + } + int securePort = secureDns ? org.strongswan.android.logic.protection.SecureDns.start(mSecureDns) : 0; + secureDns = securePort > 0; + if (!secureDns) + { + org.strongswan.android.logic.protection.SecureDns.stop("not used for this tunnel"); + } + setDnsRedirect(secureDns ? org.strongswan.android.logic.protection.SecureDns.VIP_BYTES : null, securePort); + if (includeDns && secureDns) + { /* the only server: any plain one would let the resolver bypass DoH/DoT */ + builder.addDnsServer(org.strongswan.android.logic.protection.SecureDns.VIP); + builder.addRoute(org.strongswan.android.logic.protection.SecureDns.VIP, 32); + } + else if (includeDns) { if (mDnsServers.isEmpty()) { /* neither the profile nor the server provided DNS servers: diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/Events.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/Events.kt index b88c3d299..a34bea157 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/Events.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/Events.kt @@ -183,4 +183,20 @@ object Events context.getString(R.string.event_auto_pw_text), EventAction.OPEN_SERVER, "auto-pw:$uuid", uuid, ) } + + /** Own DoH/DoT: warn when the probe failed (plain DNS is used), clear it when it works. */ + @JvmStatic + fun secureDnsResult(context: Context, ok: Boolean) + { + val events = EventLog.getInstance(context) + if (ok) + { + events.resolve("secure-dns") + return + } + events.record( + EventKind.WARN, EventCategory.CONNECTION, context.getString(R.string.event_secure_dns_title), + context.getString(R.string.event_secure_dns_text), EventAction.NONE, "secure-dns", + ) + } } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/protection/Protection.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/protection/Protection.kt index 32294b62e..bb57181ba 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/protection/Protection.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/protection/Protection.kt @@ -39,7 +39,10 @@ data class ProtectionState( val blocksTrackers: Boolean get() = choice == DnsChoice.ADGUARD val blocksPhishing: Boolean get() = choice == DnsChoice.ADGUARD || (choice == DnsChoice.CLOUDFLARE && phishingFilter) - /** null: keep the profile's (or the server's) DNS */ + /** CUSTOM with a DoH/DoT address instead of IPs */ + val secure: SecureDnsConfig? get() = if (choice == DnsChoice.CUSTOM) SecureDnsConfig.parse(custom) else null + + /** null: keep the profile's (or the server's) DNS; a DoH/DoT choice is [secure] instead */ fun servers(): List? = when (choice) { DnsChoice.PROFILE -> null @@ -110,6 +113,10 @@ object Protection _state.value = state } + /** For CharonVpnService: the user's DoH/DoT resolver, or null. */ + @JvmStatic + fun secureDns(context: Context): SecureDnsConfig? = get(context).secure + /** For CharonVpnService: the servers replacing the profile's ones, or null. */ @JvmStatic fun dnsServers(context: Context): List? = get(context).servers() 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 new file mode 100644 index 000000000..c47f3a558 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/protection/SecureDns.kt @@ -0,0 +1,483 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. See . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +package org.strongswan.android.logic.protection + +import android.os.SystemClock +import org.strongswan.android.logic.DiagLog +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.HttpURLConnection +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket +import java.net.URI +import java.net.URL +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLSocket +import javax.net.ssl.SSLSocketFactory + +/** The user's own encrypted resolver: DoH (`https://host/path`) or DoT (`tls://host[:port]`). */ +data class SecureDnsConfig(val doh: Boolean, val host: String, val port: Int, val url: String) +{ + /** For the diag log: never the queried names, only where queries go. */ + val label: String get() = if (doh) "DoH $url" else "DoT $host:$port" + + companion object + { + /** null when the text is not a DoH/DoT address (plain IPs are handled by [ProtectionState.parseCustom]). */ + @JvmStatic + fun parse(text: String): SecureDnsConfig? + { + val t = text.trim() + return try + { + when + { + t.startsWith("https://", true) -> + { + val uri = URI(t) + val host = uri.host?.takeIf { it.isNotEmpty() } ?: return null + if (uri.userInfo != null || uri.query != null || uri.fragment != null) return null + val path = uri.rawPath?.takeIf { it.isNotEmpty() && it != "/" } ?: "/dns-query" + val port = if (uri.port > 0) uri.port else 443 + val portPart = if (uri.port > 0) ":${uri.port}" else "" + SecureDnsConfig(true, host, port, "https://$host$portPart$path") + } + t.startsWith("tls://", true) -> + { + val uri = URI(t) + val host = uri.host?.takeIf { it.isNotEmpty() } ?: return null + if (uri.userInfo != null || (uri.rawPath ?: "").let { it.isNotEmpty() && it != "/" }) return null + val port = if (uri.port > 0) uri.port else 853 + SecureDnsConfig(false, host, port, "tls://$host:$port") + } + else -> null + } + } + catch (_: Exception) + { + null + } + } + } +} + +/** DNS wire bits the resolver needs; pure functions, unit tested. */ +object SecureDnsCodec +{ + /** A minimal query (RD set) for [name] and [type], e.g. 1 = A. */ + @JvmStatic + fun query(id: Int, name: String, type: Int): ByteArray + { + val out = ByteArrayOutputStream() + out.write(byteArrayOf((id shr 8).toByte(), id.toByte(), 0x01, 0x00, 0, 1, 0, 0, 0, 0, 0, 0)) + name.trimEnd('.').split('.').filter { it.isNotEmpty() }.forEach { + val label = it.toByteArray(Charsets.US_ASCII) + out.write(label.size) + out.write(label) + } + out.write(byteArrayOf(0, (type shr 8).toByte(), type.toByte(), 0, 1)) + return out.toByteArray() + } + + /** + * SERVFAIL for [query]: header with QR and RA set, RCODE 2, the question + * kept and no records. null if the query is not even a header. + */ + @JvmStatic + fun servfail(query: ByteArray): ByteArray? + { + if (query.size < 12) return null + val end = questionEnd(query) ?: 12 + val reply = query.copyOf(end) + reply[2] = ((query[2].toInt() and 0x79) or 0x80).toByte() /* QR, keep opcode and RD */ + reply[3] = 0x82.toByte() /* RA, RCODE 2 */ + val qd = if (end > 12) 1 else 0 + reply[4] = 0; reply[5] = qd.toByte() + for (i in 6 until 12) reply[i] = 0 + return reply + } + + /** End of the first question, or null if it is malformed / absent. */ + private fun questionEnd(msg: ByteArray): Int? + { + if ((msg[4].toInt() or msg[5].toInt()) == 0) return null + var i = 12 + while (i < msg.size) + { + val len = msg[i].toInt() and 0xff + if (len == 0) return (i + 5).takeIf { it <= msg.size } + if (len and 0xc0 != 0) return null + i += len + 1 + } + return null + } + + /** RFC 7858: two-byte length prefix. */ + @JvmStatic + fun frame(msg: ByteArray): ByteArray = + byteArrayOf((msg.size shr 8).toByte(), msg.size.toByte()) + msg + + @JvmStatic + fun readFrame(input: InputStream): ByteArray + { + val data = DataInputStream(input) + val len = data.readUnsignedShort() + val msg = ByteArray(len) + data.readFully(msg) + return msg + } + + /** 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] +} + +/** + * Local resolver behind the tunnel's DNS address [VIP]. The native DNS proxy + * hands UDP queries for [VIP] to 127.0.0.1:; each one goes to the user's + * DoH/DoT server. Our own app is excluded from the VPN, so the upstream + * connection uses the physical network (encrypted) and its hostname resolves + * there, without looping through this resolver. A failed query is answered + * with SERVFAIL, never by plain DNS. + */ +object SecureDns +{ + private const val TAG = "SecureDns" + + /** + * The tunnel's DNS address while encrypted DNS is on. 198.18.0.0/15 is + * the benchmarking range (RFC 2544): never routed on the internet and not + * used for VPN pools, so it cannot shadow a real server-pushed subnet. + */ + const val VIP = "198.18.0.53" + @JvmField + val VIP_BYTES = byteArrayOf(198.toByte(), 18, 0, 53) + + private const val TIMEOUT_MS = 3000 + private const val PROBE_TTL_MS = 10 * 60 * 1000L + private const val SUMMARY_EVERY_MS = 15 * 60 * 1000L + private const val WORKERS = 4 + + private var server: DatagramSocket? = null + private var serverConfig: SecureDnsConfig? = null + private var pool: ThreadPoolExecutor? = null + private val dot = ThreadLocal() + + private var probedConfig: SecureDnsConfig? = null + private var probedAt = 0L + private var probeOk = false + + private val queries = AtomicLong() + private val failures = AtomicLong() + private val totalMs = AtomicLong() + @Volatile private var lastSummary = 0L + @Volatile private var failing = false + private val loggedErrors = java.util.Collections.synchronizedSet(HashSet()) + + /** Result of a probe: round trip in ms, or the failure. */ + class Probe(val ms: Long, val error: String?) + { + val ok: Boolean get() = error == null + } + + /** One query for a fixed name; for the connect decision and the «Save» check. */ + @JvmStatic + fun probe(config: SecureDnsConfig): Probe + { + val start = SystemClock.elapsedRealtime() + return try + { + 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)) + } + } + + /** + * Whether [config] works right now (probe result cached for 10 min, so + * a reconnect does not wait again). Logs the decision. + */ + @JvmStatic + @Synchronized + fun ensureReady(config: SecureDnsConfig): Boolean + { + val now = SystemClock.elapsedRealtime() + if (config == probedConfig && probeOk && now - probedAt < PROBE_TTL_MS) + { + return true + } + val upstream = try + { + InetAddress.getAllByName(config.host).joinToString(",") { it.hostAddress ?: "" } + } + catch (e: Exception) + { + "unresolved" + } + 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 (upstream $upstream)" + else "probe ${config.label} failed after ${result.ms} ms (upstream $upstream): ${result.error}; falling back to plain DNS") + return result.ok + } + + /** Starts (or keeps) the local resolver for [config]; returns its port, 0 on failure. */ + @JvmStatic + @Synchronized + fun start(config: SecureDnsConfig): Int + { + server?.let { if (config == serverConfig && !it.isClosed) return it.localPort } + stopLocked("restart") + return try + { + val socket = DatagramSocket(InetSocketAddress(InetAddress.getByName("127.0.0.1"), 0)) + val executor = ThreadPoolExecutor(WORKERS, WORKERS, 60, TimeUnit.SECONDS, ArrayBlockingQueue(256), + { r -> Thread(r, "SecureDns-worker").apply { isDaemon = true } }, ThreadPoolExecutor.DiscardPolicy()) + executor.allowCoreThreadTimeOut(true) + server = socket + serverConfig = config + pool = executor + lastSummary = SystemClock.elapsedRealtime() + Thread({ serve(socket, executor, config) }, "SecureDns").apply { isDaemon = true }.start() + DiagLog.i(TAG, "on: ${config.label}, tunnel DNS $VIP -> 127.0.0.1:${socket.localPort}") + socket.localPort + } + catch (e: Exception) + { + DiagLog.i(TAG, "local resolver failed to start: ${describe(e)}") + 0 + } + } + + @JvmStatic + @Synchronized + fun stop(reason: String) = stopLocked(reason) + + private fun stopLocked(reason: String) + { + val socket = server ?: return + summary(force = true) + DiagLog.i(TAG, "off ($reason)") + socket.close() + pool?.shutdownNow() + server = null + serverConfig = null + pool = null + } + + private fun serve(socket: DatagramSocket, executor: ThreadPoolExecutor, config: SecureDnsConfig) + { + val buffer = ByteArray(4096) + while (!socket.isClosed) + { + val packet = DatagramPacket(buffer, buffer.size) + try + { + socket.receive(packet) + } + catch (_: IOException) + { + break + } + val query = packet.data.copyOfRange(packet.offset, packet.offset + packet.length) + val from = packet.socketAddress + executor.execute { answer(socket, config, query, from) } + } + } + + private fun answer(socket: DatagramSocket, config: SecureDnsConfig, query: ByteArray, to: java.net.SocketAddress) + { + val start = SystemClock.elapsedRealtime() + val reply = try + { + resolve(config, query).also { + if (!SecureDnsCodec.sameId(query, it)) throw IOException("reply id mismatch") + totalMs.addAndGet(SystemClock.elapsedRealtime() - start) + if (failing) + { + failing = false + DiagLog.i(TAG, "upstream answers again") + } + } + } + catch (e: Exception) + { + failures.incrementAndGet() + failing = true + val error = describe(e) + /* the first error of each kind only: a dead upstream would flood the log */ + if (loggedErrors.add(e.javaClass.simpleName)) + { + DiagLog.i(TAG, "query failed: $error (answered SERVFAIL)") + } + SecureDnsCodec.servfail(query) + } + queries.incrementAndGet() + if (reply != null) + { + try + { + socket.send(DatagramPacket(reply, reply.size, to)) + } + catch (_: IOException) + { + } + } + summary(force = false) + } + + private fun summary(force: Boolean) + { + val now = SystemClock.elapsedRealtime() + if (!force && now - lastSummary < SUMMARY_EVERY_MS) return + lastSummary = now + val n = queries.getAndSet(0) + val failed = failures.getAndSet(0) + val ms = totalMs.getAndSet(0) + if (n == 0L) return + val ok = n - failed + DiagLog.i(TAG, "stats: $n queries, $failed failed, avg ${if (ok > 0) ms / ok else 0} ms") + } + + /** One upstream round trip; a stale pooled connection gets one retry on a fresh one. */ + private fun resolve(config: SecureDnsConfig, query: ByteArray): ByteArray = try + { + if (config.doh) doh(config, query) else dot(config, query) + } + catch (e: IOException) + { + if (e is java.net.SocketTimeoutException) throw e /* a slow upstream: retrying would only double the wait */ + dot.get()?.close() + dot.set(null) + if (config.doh) doh(config, query) else dot(config, query) + } + + /** RFC 8484 POST; HttpsURLConnection verifies the certificate and the hostname. */ + private fun doh(config: SecureDnsConfig, query: ByteArray): ByteArray + { + val connection = URL(config.url).openConnection() as HttpsURLConnection + try + { + connection.connectTimeout = TIMEOUT_MS + connection.readTimeout = TIMEOUT_MS + connection.requestMethod = "POST" + connection.doOutput = true + connection.useCaches = false + connection.setRequestProperty("Content-Type", "application/dns-message") + connection.setRequestProperty("Accept", "application/dns-message") + connection.setFixedLengthStreamingMode(query.size) + connection.outputStream.use { it.write(query) } + if (connection.responseCode != HttpURLConnection.HTTP_OK) + { + throw IOException("HTTP ${connection.responseCode}") + } + return connection.inputStream.use { readBounded(it) } + } + catch (e: IOException) + { + connection.disconnect() + throw e + } + } + + private fun readBounded(input: InputStream): ByteArray + { + val out = ByteArrayOutputStream() + val buf = ByteArray(4096) + while (true) + { + val n = input.read(buf) + if (n < 0) break + out.write(buf, 0, n) + if (out.size() > 65535) throw IOException("reply too large") + } + return out.toByteArray() + } + + /** RFC 7858 over a per-worker TLS connection. */ + private fun dot(config: SecureDnsConfig, query: ByteArray): ByteArray + { + val conn = dot.get()?.takeIf { it.config == config } ?: DotConnection.open(config).also { + dot.get()?.close() + dot.set(it) + } + conn.out.write(SecureDnsCodec.frame(query)) + conn.out.flush() + return SecureDnsCodec.readFrame(conn.input) + } + + private class DotConnection(val config: SecureDnsConfig, val socket: SSLSocket) + { + val input: InputStream = socket.inputStream.buffered() + val out: OutputStream = socket.outputStream + + fun close() + { + try + { + socket.close() + } + catch (_: IOException) + { + } + } + + companion object + { + fun open(config: SecureDnsConfig): DotConnection + { + val raw = Socket() + try + { + raw.connect(InetSocketAddress(config.host, config.port), TIMEOUT_MS) + raw.soTimeout = TIMEOUT_MS + /* the host name sets SNI; the factory does not check it against the certificate */ + val tls = (SSLSocketFactory.getDefault() as SSLSocketFactory).createSocket(raw, config.host, config.port, true) as SSLSocket + tls.startHandshake() + if (!HttpsURLConnection.getDefaultHostnameVerifier().verify(config.host, tls.session)) + { + tls.close() + throw javax.net.ssl.SSLPeerUnverifiedException("certificate does not match ${config.host}") + } + return DotConnection(config, tls) + } + catch (e: IOException) + { + raw.close() + throw e + } + } + } + } + + private fun describe(e: Exception): String = "${e.javaClass.simpleName}: ${e.message ?: ""}".trimEnd(' ', ':') +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsPages.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsPages.kt index 48ba82749..82545dcff 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsPages.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsPages.kt @@ -56,6 +56,8 @@ import org.strongswan.android.ui.compose.design.ShxButton import org.strongswan.android.ui.compose.design.ShxButtonStyle import org.strongswan.android.ui.compose.design.ShxIcons import org.strongswan.android.ui.compose.design.ShxNote +import org.strongswan.android.ui.compose.design.ShxTone +import kotlinx.coroutines.launch import org.strongswan.android.ui.compose.design.ShxPanel import org.strongswan.android.ui.compose.design.ShxSectionHeader import org.strongswan.android.ui.compose.design.ShxSwitch @@ -520,6 +522,15 @@ internal fun ProtectionPage() val stateNullable by remember { org.strongswan.android.logic.protection.Protection.flow(context) }.collectAsStateWithLifecycle() val state = stateNullable ?: return var custom by remember { mutableStateOf(state.custom) } + /* IPs, or an own DoH/DoT server */ + fun customValid(text: String) = org.strongswan.android.logic.protection.ProtectionState.parseCustom(text).isNotEmpty() || + org.strongswan.android.logic.protection.SecureDnsConfig.parse(text) != null + /* result of the check on «Save» for DoH/DoT: (ok, text) */ + var probe by remember { mutableStateOf?>(null) } + var probing by remember { mutableStateOf(false) } + val scope = androidx.compose.runtime.rememberCoroutineScope() + val probeOk = stringResource(R.string.prot_secure_ok) + val probeFail = stringResource(R.string.prot_secure_fail) val applies = stringResource(R.string.prot_applies) fun update(next: org.strongswan.android.logic.protection.ProtectionState) { @@ -573,7 +584,7 @@ internal fun ProtectionPage() verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().height(62.dp).selectable(selected = state.choice == choice, role = Role.RadioButton) { - if (choice == org.strongswan.android.logic.protection.DnsChoice.CUSTOM && org.strongswan.android.logic.protection.ProtectionState.parseCustom(custom).isEmpty()) + if (choice == org.strongswan.android.logic.protection.DnsChoice.CUSTOM && !customValid(custom)) { /* the field below takes the address first */ org.strongswan.android.logic.protection.Protection.set(context, state.copy(choice = choice)) } @@ -597,12 +608,33 @@ internal fun ProtectionPage() { Column(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - org.strongswan.android.ui.compose.design.ShxFormField(label = stringResource(R.string.prot_custom), value = custom, onValueChange = { custom = it }, placeholder = "9.9.9.9") + org.strongswan.android.ui.compose.design.ShxFormField(label = stringResource(R.string.prot_custom), value = custom, onValueChange = { custom = it; probe = null }, placeholder = "9.9.9.9") + val secure = org.strongswan.android.logic.protection.SecureDnsConfig.parse(custom) ShxButton( - stringResource(R.string.server_save), { update(state.copy(custom = custom)) }, + stringResource(R.string.server_save), + { + if (secure == null) update(state.copy(custom = custom)) + else + { /* check the server first, so a typo shows here and not as a dead tunnel */ + probing = true + scope.launch { + val result = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { org.strongswan.android.logic.protection.SecureDns.probe(secure) } + org.strongswan.android.logic.DiagLog.i("SecureDns", "check on save: ${secure.label} " + + if (result.ok) "ok in ${result.ms} ms" else "failed: ${result.error}") + probing = false + probe = if (result.ok) true to probeOk.format(result.ms) else false to probeFail.format(result.error ?: "") + update(state.copy(custom = custom)) + } + } + }, style = ShxButtonStyle.TONAL, height = 44.dp, modifier = Modifier.fillMaxWidth(), - enabled = org.strongswan.android.logic.protection.ProtectionState.parseCustom(custom).isNotEmpty(), + enabled = customValid(custom) && !probing, ) + probe?.let { (ok, text) -> ShxNote(text, tone = if (ok) ShxTone.SUCCESS else ShxTone.WARNING) } + if (secure != null) + { + Text(stringResource(R.string.prot_secure_note), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.textSub) + } } } } diff --git a/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_dns_proxy.c b/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_dns_proxy.c index d883c0c3c..e5ddeb14f 100644 --- a/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_dns_proxy.c +++ b/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_dns_proxy.c @@ -35,6 +35,21 @@ typedef struct private_android_dns_proxy_t private_android_dns_proxy_t; +/** + * Encrypted DNS redirect: the virtual address (network order) and the local + * resolver port (0 = off). Written by JNI, read on the TUN reader and + * watcher threads; the port is stored last and read first. + */ +static uint32_t redirect_vip; +static uint16_t redirect_port; + +void android_dns_proxy_set_redirect(uint32_t vip, uint16_t port) +{ + __atomic_store_n(&redirect_port, 0, __ATOMIC_RELEASE); + __atomic_store_n(&redirect_vip, vip, __ATOMIC_RELEASE); + __atomic_store_n(&redirect_port, port, __ATOMIC_RELEASE); +} + struct private_android_dns_proxy_t { /** @@ -173,6 +188,19 @@ CALLBACK(handle_response, bool, DBG1(DBG_NET, "failed to parse source address"); return TRUE; } + if (addr.ss_family == AF_INET && + ((struct sockaddr_in*)&addr)->sin_addr.s_addr == htonl(INADDR_LOOPBACK)) + { /* the app's encrypted DNS resolver: the reply must come from the + * address the query went to, or the system resolver drops it */ + uint32_t vip = __atomic_load_n(&redirect_vip, __ATOMIC_ACQUIRE); + + src->destroy(src); + src = host_create_from_chunk(AF_INET, chunk_from_thing(vip), 53); + if (!src) + { + return TRUE; + } + } packet = ip_packet_create_udp_from_data(src, this->src, chunk_create(buf, len)); src->destroy(src); @@ -302,29 +330,16 @@ static bool check_hostname(private_android_dns_proxy_t *this, chunk_t data) return success; } -METHOD(android_dns_proxy_t, handle, bool, - private_android_dns_proxy_t *this, ip_packet_t *packet) +/** + * Send the DNS payload of packet to dst through the per-source socket; takes + * the packet over on success. + */ +static bool forward(private_android_dns_proxy_t *this, ip_packet_t *packet, + chunk_t data, host_t *dst) { proxy_socket_t *skt; - host_t *dst, *src; - chunk_t data; + host_t *src; - if (packet->get_next_header(packet) != IPPROTO_UDP) - { - return FALSE; - } - dst = packet->get_destination(packet); - if (dst->get_port(dst) != 53) - { /* no DNS packet */ - return FALSE; - } - data = packet->get_payload(packet); - /* remove UDP header */ - data = chunk_skip(data, 8); - if (!check_hostname(this, data)) - { - return FALSE; - } src = packet->get_source(packet); this->lock->write_lock(this->lock); skt = this->sockets->get(this->sockets, src); @@ -354,6 +369,65 @@ METHOD(android_dns_proxy_t, handle, bool, return TRUE; } +METHOD(android_dns_proxy_t, handle, bool, + private_android_dns_proxy_t *this, ip_packet_t *packet) +{ + host_t *dst; + chunk_t data; + + if (packet->get_next_header(packet) != IPPROTO_UDP) + { + return FALSE; + } + dst = packet->get_destination(packet); + if (dst->get_port(dst) != 53) + { /* no DNS packet */ + return FALSE; + } + data = packet->get_payload(packet); + /* remove UDP header */ + data = chunk_skip(data, 8); + if (!check_hostname(this, data)) + { + return FALSE; + } + return forward(this, packet, data, dst); +} + +METHOD(android_dns_proxy_t, redirect, bool, + private_android_dns_proxy_t *this, ip_packet_t *packet) +{ + uint16_t port = __atomic_load_n(&redirect_port, __ATOMIC_ACQUIRE); + uint32_t vip; + host_t *dst, *local; + chunk_t addr; + + if (!port) + { /* the common case: encrypted DNS is off */ + return FALSE; + } + if (packet->get_version(packet) != 4 || + packet->get_next_header(packet) != IPPROTO_UDP) + { + return FALSE; + } + vip = __atomic_load_n(&redirect_vip, __ATOMIC_ACQUIRE); + dst = packet->get_destination(packet); + addr = dst->get_address(dst); + if (dst->get_port(dst) != 53 || addr.len != 4 || !memeq(addr.ptr, &vip, 4)) + { + return FALSE; + } + local = host_create_from_string("127.0.0.1", port); + if (!local || !forward(this, packet, + chunk_skip(packet->get_payload(packet), 8), local)) + { /* the virtual address leads nowhere else: drop, do not tunnel it */ + packet->destroy(packet); + } + DESTROY_IF(local); + return TRUE; +} + METHOD(android_dns_proxy_t, register_cb, void, private_android_dns_proxy_t *this, dns_proxy_response_cb_t cb, void *data) { @@ -408,6 +482,7 @@ android_dns_proxy_t *android_dns_proxy_create() .register_cb = _register_cb, .unregister_cb = _unregister_cb, .add_hostname = _add_hostname, + .redirect = _redirect, .destroy = _destroy, }, .sockets = hashtable_create((hashtable_hash_t)socket_hash, diff --git a/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_dns_proxy.h b/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_dns_proxy.h index 3747f8a70..16de5f7e8 100644 --- a/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_dns_proxy.h +++ b/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_dns_proxy.h @@ -72,6 +72,15 @@ struct android_dns_proxy_t { */ void (*add_hostname)(android_dns_proxy_t *this, char *hostname); + /** + * Forward a UDP/53 packet for the encrypted DNS address (see + * android_dns_proxy_set_redirect()) to the app's local resolver. + * + * @param packet packet to handle + * @return TRUE if handled (taken over), FALSE otherwise + */ + bool (*redirect)(android_dns_proxy_t *this, ip_packet_t *packet); + /** * Destroy an instance. */ @@ -83,5 +92,15 @@ struct android_dns_proxy_t { */ android_dns_proxy_t *android_dns_proxy_create(); +/** + * Encrypted DNS (DoH/DoT): UDP/53 queries to the IPv4 address vip go to + * 127.0.0.1:port, where the app resolves them; port 0 turns it off. Global, + * as the proxy instance is re-created with every connection. + * + * @param vip IPv4 address in network order + * @param port local resolver port, 0 to disable + */ +void android_dns_proxy_set_redirect(uint32_t vip, uint16_t port); + #endif /** ANDROID_DNS_PROXY_H_ @}*/ diff --git a/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_service.c b/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_service.c index 4b207b239..9665654cf 100644 --- a/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_service.c +++ b/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_service.c @@ -257,6 +257,10 @@ static job_requeue_t handle_plain(private_android_service_t *this) { /* handled by the DNS proxy */ } + else if (this->dns_proxy->redirect(this->dns_proxy, packet)) + { + /* encrypted DNS: answered by the app's local resolver */ + } else { direct_engine_t *engine = direct_engine_get(); diff --git a/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.c b/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.c index d0adadfea..2672acd6f 100644 --- a/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.c +++ b/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.c @@ -35,6 +35,7 @@ #include "android_jni.h" #include "backend/android_attr.h" #include "backend/android_creds.h" +#include "backend/android_dns_proxy.h" #include "backend/android_fetcher.h" #include "backend/android_private_key.h" #include "backend/android_scheduler.h" @@ -1320,6 +1321,28 @@ JNI_METHOD(CharonVpnService, setUserspaceRoutingData, void, pthread_mutex_unlock(&charon_mutex); } +/** + * Encrypted DNS (DoH/DoT): UDP/53 queries to vip (4 bytes, IPv4) go to the + * app's resolver on 127.0.0.1:port. A null vip or port 0 turns it off. Only + * stores two globals, so it is safe at any time; pushed on every connect. + */ +JNI_METHOD(CharonVpnService, setDnsRedirect, void, + jbyteArray jvip, jint port) +{ + uint32_t vip = 0; + + if (jvip && port > 0 && port < 65536 && + (*env)->GetArrayLength(env, jvip) == sizeof(vip)) + { + (*env)->GetByteArrayRegion(env, jvip, 0, sizeof(vip), (jbyte*)&vip); + android_dns_proxy_set_redirect(vip, port); + } + else + { + android_dns_proxy_set_redirect(0, 0); + } +} + /** * Snapshot of the userspace routing engine counters (mode B diagnostics). */ diff --git a/src/frontends/android/app/src/main/res/values-de/strings.xml b/src/frontends/android/app/src/main/res/values-de/strings.xml index f725c64d4..f2df73bb2 100644 --- a/src/frontends/android/app/src/main/res/values-de/strings.xml +++ b/src/frontends/android/app/src/main/res/values-de/strings.xml @@ -1251,7 +1251,7 @@ 1.1.1.1 · durch den Tunnel Mit eingebautem Filter Eigene Adresse - IPv4 oder IPv6, durch Komma getrennt + IPs, DoH (https://) oder DoT (tls://) Anfragen an diesen DNS gehen durch den Tunnel — der Anbieter kann die Antwort nicht verfälschen. Apps, die am VPN vorbeigehen, nutzen den DNS des Netzes. Gilt nach einer Neuverbindung Werbung @@ -1322,4 +1322,9 @@ SHX VPN ist zuletzt abgestürzt. Senden Sie einen Bericht, damit wir die Ursache finden. Durchgehend aktives VPN wurde ausgeschaltet Es war vorher an. Jetzt gehen Apps bei jedem VPN-Abbruch für einige Sekunden direkt ins Netz. Schalten Sie es in den VPN-Einstellungen von Android wieder ein. + Der Server hat in %1$d ms geantwortet. DNS-Anfragen werden verschlüsselt. + Der Server hat nicht geantwortet (%1$s). Bis dahin nutzt das VPN das DNS des Profils. + DoH/DoT-Anfragen gehen verschlüsselt über das normale Netz an Ihren Server, nicht durch den Tunnel. Antwortet er nicht, nutzt das VPN das DNS des Profils. Bei strengem privatem DNS nutzt Android seinen eigenen Server. + Eigener DoH/DoT-Server antwortet nicht + Das VPN hat sich mit dem DNS des Profils verbunden. Prüfen Sie die Adresse unter „Einstellungen → DNS und Werbung“. diff --git a/src/frontends/android/app/src/main/res/values-pl/strings.xml b/src/frontends/android/app/src/main/res/values-pl/strings.xml index 8af2f1f28..4b5a42ff8 100644 --- a/src/frontends/android/app/src/main/res/values-pl/strings.xml +++ b/src/frontends/android/app/src/main/res/values-pl/strings.xml @@ -1141,7 +1141,7 @@ 1.1.1.1 · przez tunel Z wbudowanym filtrowaniem Własny adres - IPv4 lub IPv6 oddzielone przecinkami + IP, DoH (https://) lub DoT (tls://) Zapytania do tego DNS idą przez tunel — dostawca nie podmieni odpowiedzi. Aplikacje z pominięciem VPN używają DNS sieci. Zostanie zastosowane po ponownym połączeniu reklamy @@ -1240,4 +1240,9 @@ Ostatnio SHX VPN uległ awarii. Wyślij raport, abyśmy znaleźli przyczynę. Stały VPN został wyłączony Wcześniej był włączony. Teraz przy każdym zerwaniu VPN aplikacje na kilka sekund łączą się bezpośrednio. Włącz go ponownie w ustawieniach VPN Androida. + Serwer odpowiedział w %1$d ms. Zapytania DNS będą szyfrowane. + Serwer nie odpowiedział (%1$s). Dopóki jest niedostępny, VPN używa DNS z profilu. + Zapytania DoH/DoT trafiają do Twojego serwera zaszyfrowane przez zwykłą sieć, nie przez tunel. Jeśli serwer nie odpowiada, VPN używa DNS z profilu. Przy ścisłym prywatnym DNS Android używa własnego serwera. + Własny serwer DoH/DoT nie odpowiada + VPN połączył się z DNS z profilu. Sprawdź adres w „Ustawienia → DNS i reklamy”. diff --git a/src/frontends/android/app/src/main/res/values-ru/strings.xml b/src/frontends/android/app/src/main/res/values-ru/strings.xml index a1895030d..16b29c3ae 100644 --- a/src/frontends/android/app/src/main/res/values-ru/strings.xml +++ b/src/frontends/android/app/src/main/res/values-ru/strings.xml @@ -1312,7 +1312,7 @@ 1.1.1.1 · через туннель Со встроенной фильтрацией Свой адрес - IPv4 или IPv6 через запятую + IP, DoH (https://) или DoT (tls://) Запросы к этому DNS идут через туннель — провайдер не сможет подменить ответ. Приложения, которые идут мимо VPN, используют DNS сети. Применится при переподключении реклама @@ -1351,4 +1351,9 @@ В прошлый раз SHX VPN закрылся из-за сбоя. Отправьте отчёт, чтобы мы нашли причину. Постоянный VPN выключили Раньше он был включён. Теперь при обрыве VPN приложения на секунды выходят в сеть напрямую. Включите его снова в настройках VPN Android. + Сервер ответил за %1$d мс. DNS-запросы будут шифроваться. + Сервер не ответил (%1$s). Пока он недоступен, VPN использует DNS профиля. + Запросы DoH/DoT идут к вашему серверу в зашифрованном виде через обычную сеть, а не через туннель. Если сервер не отвечает, VPN использует DNS профиля. При строгом «Частном DNS» Android использует свой сервер. + Свой DoH/DoT-сервер не отвечает + VPN подключился с DNS профиля. Проверьте адрес в «Настройки → DNS и реклама». diff --git a/src/frontends/android/app/src/main/res/values-uk/strings.xml b/src/frontends/android/app/src/main/res/values-uk/strings.xml index 031487b2c..b90203f5b 100644 --- a/src/frontends/android/app/src/main/res/values-uk/strings.xml +++ b/src/frontends/android/app/src/main/res/values-uk/strings.xml @@ -1151,7 +1151,7 @@ 1.1.1.1 · через тунель З вбудованою фільтрацією Своя адреса - IPv4 або IPv6 через кому + IP, DoH (https://) або DoT (tls://) Запити до цього DNS ідуть через тунель — провайдер не зможе підмінити відповідь. Застосунки, що йдуть повз VPN, використовують DNS мережі. Застосується після перепідключення реклама @@ -1250,4 +1250,9 @@ Минулого разу SHX VPN закрився через збій. Надішліть звіт, щоб ми знайшли причину. Постійну VPN вимкнули Раніше її було ввімкнено. Тепер у разі обриву VPN застосунки на секунди виходять у мережу напряму. Увімкніть її знову в налаштуваннях VPN Android. + Сервер відповів за %1$d мс. DNS-запити шифруватимуться. + Сервер не відповів (%1$s). Поки він недоступний, VPN використовує DNS профілю. + Запити DoH/DoT ідуть до вашого сервера в зашифрованому вигляді через звичайну мережу, а не через тунель. Якщо сервер не відповідає, VPN використовує DNS профілю. За суворого «Приватного DNS» Android використовує свій сервер. + Власний DoH/DoT-сервер не відповідає + VPN підключився з DNS профілю. Перевірте адресу в «Налаштування → DNS і реклама». diff --git a/src/frontends/android/app/src/main/res/values-zh-rCN/strings.xml b/src/frontends/android/app/src/main/res/values-zh-rCN/strings.xml index 890f59957..0ee87314d 100644 --- a/src/frontends/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/src/frontends/android/app/src/main/res/values-zh-rCN/strings.xml @@ -1150,7 +1150,7 @@ 1.1.1.1 · 经隧道 内置过滤 自定义地址 - IPv4 或 IPv6,以逗号分隔 + IP、DoH (https://) 或 DoT (tls://) 对此 DNS 的查询经隧道发送,运营商无法篡改应答。绕过 VPN 的应用使用网络自身的 DNS。 重新连接后生效 广告 @@ -1219,4 +1219,9 @@ SHX VPN 上次发生崩溃。请发送报告,帮助我们找到原因。 始终开启的 VPN 已被关闭 此前它处于开启状态。现在每次 VPN 断开时,应用都会直接联网几秒钟。请在 Android 的 VPN 设置中重新开启。 + 服务器在 %1$d 毫秒内响应。DNS 查询将被加密。 + 服务器没有响应(%1$s)。在它恢复之前,VPN 使用配置文件中的 DNS。 + DoH/DoT 查询会经由普通网络加密发送到你的服务器,而不经过隧道。如果服务器没有响应,VPN 会使用配置文件中的 DNS。启用严格的私人 DNS 时,Android 会使用其自己的服务器。 + 自定义 DoH/DoT 服务器没有响应 + VPN 已使用配置文件中的 DNS 连接。请在“设置 → DNS 与广告”中检查地址。 diff --git a/src/frontends/android/app/src/main/res/values-zh-rTW/strings.xml b/src/frontends/android/app/src/main/res/values-zh-rTW/strings.xml index caea6f067..8a18a28c9 100644 --- a/src/frontends/android/app/src/main/res/values-zh-rTW/strings.xml +++ b/src/frontends/android/app/src/main/res/values-zh-rTW/strings.xml @@ -1135,7 +1135,7 @@ 1.1.1.1 · 經通道 內建過濾 自訂位址 - IPv4 或 IPv6,以逗號分隔 + IP、DoH (https://) 或 DoT (tls://) 對此 DNS 的查詢經通道傳送,電信業者無法竄改回應。繞過 VPN 的應用程式使用網路本身的 DNS。 重新連線後生效 廣告 @@ -1204,4 +1204,9 @@ SHX VPN 上次發生當機。請傳送報告,協助我們找出原因。 永久連線的 VPN 已被關閉 先前它處於開啟狀態。現在每次 VPN 中斷時,應用程式都會直接連網幾秒鐘。請在 Android 的 VPN 設定中重新開啟。 + 伺服器在 %1$d 毫秒內回應。DNS 查詢將會加密。 + 伺服器沒有回應(%1$s)。在恢復之前,VPN 會使用設定檔中的 DNS。 + DoH/DoT 查詢會經由一般網路加密傳送到你的伺服器,而不經過通道。如果伺服器沒有回應,VPN 會使用設定檔中的 DNS。啟用嚴格的私人 DNS 時,Android 會使用自己的伺服器。 + 自訂 DoH/DoT 伺服器沒有回應 + VPN 已使用設定檔中的 DNS 連線。請在「設定 → DNS 與廣告」中檢查位址。 diff --git a/src/frontends/android/app/src/main/res/values/strings.xml b/src/frontends/android/app/src/main/res/values/strings.xml index 396db16a9..151a2c167 100644 --- a/src/frontends/android/app/src/main/res/values/strings.xml +++ b/src/frontends/android/app/src/main/res/values/strings.xml @@ -1298,7 +1298,7 @@ 1.1.1.1 · through the tunnel With built-in filtering Own address - IPv4 or IPv6, comma separated + IPs, DoH (https://) or DoT (tls://) Queries to this DNS go through the tunnel — the provider cannot change the answer. Apps that bypass the VPN use the network\'s DNS. Applies on reconnect ads @@ -1337,4 +1337,9 @@ SHX VPN crashed last time. Send a report so we can find the cause. Always-on VPN was turned off It was on before. Now apps go online directly for a few seconds whenever the VPN drops. Turn it back on in the Android VPN settings. + The server answered in %1$d ms. DNS queries will be encrypted. + The server did not answer (%1$s). Until it does, the VPN uses the profile’s DNS. + DoH/DoT queries go to your server encrypted over the regular network, not through the tunnel. If it does not answer, the VPN uses the profile’s DNS. With strict Private DNS, Android uses its own server. + Own DoH/DoT server is not answering + The VPN connected with the profile’s DNS. Check the address in Settings → DNS and ads. 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 new file mode 100644 index 000000000..f0e026710 --- /dev/null +++ b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/protection/SecureDnsTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. See . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +package org.strongswan.android.logic.protection + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import java.io.ByteArrayInputStream + +class SecureDnsTest +{ + @Test + fun parsesDoh() + { + assertThat(SecureDnsConfig.parse(" https://dns.example.com/dns-query ")) + .isEqualTo(SecureDnsConfig(true, "dns.example.com", 443, "https://dns.example.com/dns-query")) + /* no path: the RFC 8484 default */ + assertThat(SecureDnsConfig.parse("https://dns.example.com")!!.url).isEqualTo("https://dns.example.com/dns-query") + assertThat(SecureDnsConfig.parse("https://dns.example.com:8443/q")!!.port).isEqualTo(8443) + } + + @Test + fun parsesDot() + { + assertThat(SecureDnsConfig.parse("tls://dot.example.com")).isEqualTo(SecureDnsConfig(false, "dot.example.com", 853, "tls://dot.example.com:853")) + assertThat(SecureDnsConfig.parse("TLS://1.1.1.1:8853")!!.port).isEqualTo(8853) + } + + @Test + fun rejectsPlainAndBroken() + { + assertThat(SecureDnsConfig.parse("http://dns.example.com/dns-query")).isNull() + assertThat(SecureDnsConfig.parse("9.9.9.9")).isNull() + assertThat(SecureDnsConfig.parse("https://")).isNull() + assertThat(SecureDnsConfig.parse("https://user:pw@dns.example.com/q")).isNull() + assertThat(SecureDnsConfig.parse("https://dns.example.com/q?dns=x")).isNull() + assertThat(SecureDnsConfig.parse("tls://dot.example.com/path")).isNull() + } + + @Test + fun customChoiceUsesSecureOnlyForUrls() + { + assertThat(ProtectionState(DnsChoice.CUSTOM, custom = "tls://dot.example.com").secure).isNotNull() + assertThat(ProtectionState(DnsChoice.CUSTOM, custom = "tls://dot.example.com").servers()).isNull() + assertThat(ProtectionState(DnsChoice.CUSTOM, custom = "9.9.9.9").secure).isNull() + assertThat(ProtectionState(DnsChoice.CLOUDFLARE, custom = "tls://dot.example.com").secure).isNull() + } + + @Test + fun queryEncodesName() + { + val q = SecureDnsCodec.query(0x1234, "example.com", 1) + assertThat(q.copyOfRange(0, 6)).containsExactly(0x12, 0x34, 0x01, 0x00, 0, 1) + assertThat(q.copyOfRange(12, q.size)).containsExactly( + 7, 'e'.code.toByte(), 'x'.code.toByte(), 'a'.code.toByte(), 'm'.code.toByte(), 'p'.code.toByte(), 'l'.code.toByte(), 'e'.code.toByte(), + 3, 'c'.code.toByte(), 'o'.code.toByte(), 'm'.code.toByte(), 0, 0, 1, 0, 1) + } + + @Test + fun servfailKeepsIdAndQuestion() + { + val q = SecureDnsCodec.query(0xbeef, "example.com", 28) + val r = SecureDnsCodec.servfail(q)!! + assertThat(r.size).isEqualTo(q.size) + assertThat(SecureDnsCodec.sameId(q, r)).isTrue() + assertThat(r[2].toInt() and 0x80).isEqualTo(0x80) /* QR */ + assertThat(r[2].toInt() and 0x01).isEqualTo(0x01) /* RD kept */ + assertThat(r[3].toInt() and 0x0f).isEqualTo(2) /* SERVFAIL */ + assertThat(r.copyOfRange(4, 12)).containsExactly(0, 1, 0, 0, 0, 0, 0, 0) + assertThat(r.copyOfRange(12, r.size)).isEqualTo(q.copyOfRange(12, q.size)) + } + + @Test + fun servfailDropsAdditionalRecords() + { + /* EDNS OPT record after the question must not survive with ARCOUNT 0 */ + val q = SecureDnsCodec.query(1, "a.b", 1).also { it[11] = 1 } + byteArrayOf(0, 0, 41, 4, 0xd0.toByte(), 0, 0, 0, 0, 0, 0) + val r = SecureDnsCodec.servfail(q)!! + assertThat(r.size).isEqualTo(q.size - 11) + } + + @Test + fun servfailNeedsHeader() + { + assertThat(SecureDnsCodec.servfail(ByteArray(5))).isNull() + /* header only, no question */ + assertThat(SecureDnsCodec.servfail(ByteArray(12))!!.size).isEqualTo(12) + } + + @Test + fun dotFramingRoundTrip() + { + val msg = ByteArray(300) { it.toByte() } + val framed = SecureDnsCodec.frame(msg) + assertThat(framed.copyOfRange(0, 2)).containsExactly(1, 44) + assertThat(SecureDnsCodec.readFrame(ByteArrayInputStream(framed))).isEqualTo(msg) + } +} diff --git a/src/frontends/android/docs/README.md b/src/frontends/android/docs/README.md index e9ce1569d..d9ebea711 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.5 (versionCode 173)**. +Форк Android-клиента strongSwan (IKEv2/IPsec), пакет `one.shx.strongswan.ext`. Текущая версия — **2.19.6 (versionCode 174)**. Распространяется вне Google Play: APK и манифест обновлений лежат в приватном бакете S3, приложение обновляется само. - Дизайн: [design/README.md](design/README.md) — макет «Финал 2026» и соответствие артбордов коду. @@ -14,7 +14,7 @@ | Серверы | Один сервер = один профиль `.sswan`. Добавление по QR, ссылке, файлу или вручную; проверка профиля перед добавлением; «Поделиться» QR-кодом без пароля; страница сервера: источник профиля, ежедневная проверка ссылки и лист изменений, запасные порты, резервный сервер, мост при белых списках, DNS, MTU, фрагментация IKE | | Маршруты | Раздельный туннель, сайты РФ напрямую (база адресов, режимы «Стабильно»/«Гибко»), приложения напрямую, правила (сайт, адрес/сеть, шаблон пакета), недавние приложения, недоступные хосты, «Как ходит трафик» | | Сценарии | Правила для сетей (доверенная Wi-Fi / мобильная или чужая сеть) и свои сценарии: Wi-Fi, мобильная сеть, приложение открыто, время, роуминг, белые списки → включить, выключить, пауза, сменить сервер. Общий порядок, верхний важнее; ручное действие перекрывает сценарий до его окончания | -| Защита | DNS через туннель: из профиля, Cloudflare, AdGuard (реклама и трекеры), Cloudflare 1.1.1.2 (фишинг), свой адрес; «Без VPN — без сети» (инструкция к системному Always-on) | +| Защита | DNS через туннель: из профиля, Cloudflare, AdGuard (реклама и трекеры), Cloudflare 1.1.1.2 (фишинг), свой адрес; свой DoH (`https://…`) или DoT (`tls://…`): DNS туннеля — виртуальный `198.18.0.53`, нативный DNS-прокси отдаёт запросы локальному резолверу `SecureDns` (127.0.0.1), тот шифрует их к серверу пользователя через физическую сеть; проба при подключении, иначе DNS профиля и событие; ошибки → SERVFAIL; всё пишется в diag.log без имён запросов; «Без VPN — без сети» (инструкция к системному Always-on) | | Статистика | Трафик по дням/неделям/месяцам: через VPN и напрямую, по приложениям (нужен доступ к статистике использования), итоги недели | | Диагностика | Интернет, сайты РФ и зарубежные, сервер, DNS, белые списки за ~10 секунд; проверка сайта; отчёт в поддержку | | События | Центр событий в приложении и push по каналам (статус, проблемы, новые приложения, обновления, итоги недели); «Приложение аварийно завершилось» с кнопкой «Отправить отчёт» (`CrashWatch`: Java, нативный демон, ANR — на Android 11+ по истории завершений процесса); «Постоянный VPN выключили», если блокировка без VPN была включена (`AlwaysOnVpn`: статус спрашивает работающий `VpnService`, Android 10+) | diff --git a/src/frontends/android/docs/design/final-2026/D_Protection.dc.html b/src/frontends/android/docs/design/final-2026/D_Protection.dc.html index c68b86901..18ad940de 100644 --- a/src/frontends/android/docs/design/final-2026/D_Protection.dc.html +++ b/src/frontends/android/docs/design/final-2026/D_Protection.dc.html @@ -63,7 +63,7 @@ a{color:#B69DFF}a:hover{color:#D0C2FF} class Component extends DCLogic { renderVals() { var t = [['Рекламу', 'Баннеры в браузере и приложениях', true], ['Трекеры', 'Аналитика и сбор данных', true], ['Фишинг', 'Поддельные сайты банков и сервисов', true]]; - var d = [['Из профиля сервера', 'Как настроил администратор', false], ['Cloudflare', '1.1.1.1 · через туннель', false], ['AdGuard DNS', 'Со встроенной фильтрацией', true], ['Свой адрес', 'IPv4 или IPv6 через запятую', false]]; + var d = [['Из профиля сервера', 'Как настроил администратор', false], ['Cloudflare', '1.1.1.1 · через туннель', false], ['AdGuard DNS', 'Со встроенной фильтрацией', true], ['Свой адрес', 'IP, DoH (https://) или DoT (tls://)', false]]; return { toggles: t.map(function (x, i) { return { title: x[0], sub: x[1], on: x[2] ? 'true' : 'false', track: x[2] ? '#6D4AFF' : '#48454E', align: x[2] ? 'flex-end' : 'flex-start', divider: i < t.length - 1 ? '1px solid #2B2930' : '0' }; }), dns: d.map(function (x, i) { return { title: x[0], sub: x[1], checked: x[2], divider: i < d.length - 1 ? '1px solid #2B2930' : '0' }; }) diff --git a/src/frontends/android/docs/design/final-2026/F_Protection.dc.html b/src/frontends/android/docs/design/final-2026/F_Protection.dc.html index cccf1d1db..22a2ba2fd 100644 --- a/src/frontends/android/docs/design/final-2026/F_Protection.dc.html +++ b/src/frontends/android/docs/design/final-2026/F_Protection.dc.html @@ -63,7 +63,7 @@ a{color:#6D4AFF}a:hover{color:#4B2FD1} class Component extends DCLogic { renderVals() { var t = [['Рекламу', 'Баннеры в браузере и приложениях', true], ['Трекеры', 'Аналитика и сбор данных', true], ['Фишинг', 'Поддельные сайты банков и сервисов', true]]; - var d = [['Из профиля сервера', 'Как настроил администратор', false], ['Cloudflare', '1.1.1.1 · через туннель', false], ['AdGuard DNS', 'Со встроенной фильтрацией', true], ['Свой адрес', 'IPv4 или IPv6 через запятую', false]]; + var d = [['Из профиля сервера', 'Как настроил администратор', false], ['Cloudflare', '1.1.1.1 · через туннель', false], ['AdGuard DNS', 'Со встроенной фильтрацией', true], ['Свой адрес', 'IP, DoH (https://) или DoT (tls://)', false]]; return { toggles: t.map(function (x, i) { return { title: x[0], sub: x[1], on: x[2] ? 'true' : 'false', track: x[2] ? '#6D4AFF' : '#C9C5D0', align: x[2] ? 'flex-end' : 'flex-start', divider: i < t.length - 1 ? '1px solid #F0ECF4' : '0' }; }), dns: d.map(function (x, i) { return { title: x[0], sub: x[1], checked: x[2], divider: i < d.length - 1 ? '1px solid #F0ECF4' : '0' }; })