android: own DoH/DoT DNS server — the tunnel's DNS becomes 198.18.0.53, the native DNS proxy forwards its UDP/53 to a local resolver (127.0.0.1) that sends each query to the user's https:// or tls:// server; probe at connect with fallback to the profile's DNS and an event, SERVFAIL on errors, check on «Save»; decisions, errors and per-period stats go to the diagnostic log without query names; version 2.19.6 (174)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1401cd8087
commit
59d989e956
21 files changed
+887
-39
No files matched your search
@@ -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}\""
|
||||
|
||||
+46
-1
@@ -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<IPRange> mAddresses = new ArrayList<>();
|
||||
private final List<IPRange> mRoutesIPv4 = new ArrayList<>();
|
||||
private final List<IPRange> 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<String> 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:
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -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<String>? = 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<String>? = get(context).servers()
|
||||
|
||||
+483
@@ -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 <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* 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:<port>; 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<DotConnection?>()
|
||||
|
||||
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<String>())
|
||||
|
||||
/** 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(' ', ':')
|
||||
}
|
||||
+36
-4
@@ -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<Pair<Boolean, String>?>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+95
-20
@@ -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,
|
||||
|
||||
@@ -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_ @}*/
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -1251,7 +1251,7 @@
|
||||
<string name="prot_cloudflare_sub">1.1.1.1 · durch den Tunnel</string>
|
||||
<string name="prot_adguard_sub">Mit eingebautem Filter</string>
|
||||
<string name="prot_custom">Eigene Adresse</string>
|
||||
<string name="prot_custom_sub">IPv4 oder IPv6, durch Komma getrennt</string>
|
||||
<string name="prot_custom_sub">IPs, DoH (https://) oder DoT (tls://)</string>
|
||||
<string name="prot_note">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.</string>
|
||||
<string name="prot_applies">Gilt nach einer Neuverbindung</string>
|
||||
<string name="prot_list_ads">Werbung</string>
|
||||
@@ -1322,4 +1322,9 @@
|
||||
<string name="event_crash_text">SHX VPN ist zuletzt abgestürzt. Senden Sie einen Bericht, damit wir die Ursache finden.</string>
|
||||
<string name="event_lockdown_off_title">Durchgehend aktives VPN wurde ausgeschaltet</string>
|
||||
<string name="event_lockdown_off_text">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.</string>
|
||||
<string name="prot_secure_ok">Der Server hat in %1$d ms geantwortet. DNS-Anfragen werden verschlüsselt.</string>
|
||||
<string name="prot_secure_fail">Der Server hat nicht geantwortet (%1$s). Bis dahin nutzt das VPN das DNS des Profils.</string>
|
||||
<string name="prot_secure_note">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.</string>
|
||||
<string name="event_secure_dns_title">Eigener DoH/DoT-Server antwortet nicht</string>
|
||||
<string name="event_secure_dns_text">Das VPN hat sich mit dem DNS des Profils verbunden. Prüfen Sie die Adresse unter „Einstellungen → DNS und Werbung“.</string>
|
||||
</resources>
|
||||
@@ -1141,7 +1141,7 @@
|
||||
<string name="prot_cloudflare_sub">1.1.1.1 · przez tunel</string>
|
||||
<string name="prot_adguard_sub">Z wbudowanym filtrowaniem</string>
|
||||
<string name="prot_custom">Własny adres</string>
|
||||
<string name="prot_custom_sub">IPv4 lub IPv6 oddzielone przecinkami</string>
|
||||
<string name="prot_custom_sub">IP, DoH (https://) lub DoT (tls://)</string>
|
||||
<string name="prot_note">Zapytania do tego DNS idą przez tunel — dostawca nie podmieni odpowiedzi. Aplikacje z pominięciem VPN używają DNS sieci.</string>
|
||||
<string name="prot_applies">Zostanie zastosowane po ponownym połączeniu</string>
|
||||
<string name="prot_list_ads">reklamy</string>
|
||||
@@ -1240,4 +1240,9 @@
|
||||
<string name="event_crash_text">Ostatnio SHX VPN uległ awarii. Wyślij raport, abyśmy znaleźli przyczynę.</string>
|
||||
<string name="event_lockdown_off_title">Stały VPN został wyłączony</string>
|
||||
<string name="event_lockdown_off_text">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.</string>
|
||||
<string name="prot_secure_ok">Serwer odpowiedział w %1$d ms. Zapytania DNS będą szyfrowane.</string>
|
||||
<string name="prot_secure_fail">Serwer nie odpowiedział (%1$s). Dopóki jest niedostępny, VPN używa DNS z profilu.</string>
|
||||
<string name="prot_secure_note">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.</string>
|
||||
<string name="event_secure_dns_title">Własny serwer DoH/DoT nie odpowiada</string>
|
||||
<string name="event_secure_dns_text">VPN połączył się z DNS z profilu. Sprawdź adres w „Ustawienia → DNS i reklamy”.</string>
|
||||
</resources>
|
||||
@@ -1312,7 +1312,7 @@
|
||||
<string name="prot_cloudflare_sub">1.1.1.1 · через туннель</string>
|
||||
<string name="prot_adguard_sub">Со встроенной фильтрацией</string>
|
||||
<string name="prot_custom">Свой адрес</string>
|
||||
<string name="prot_custom_sub">IPv4 или IPv6 через запятую</string>
|
||||
<string name="prot_custom_sub">IP, DoH (https://) или DoT (tls://)</string>
|
||||
<string name="prot_note">Запросы к этому DNS идут через туннель — провайдер не сможет подменить ответ. Приложения, которые идут мимо VPN, используют DNS сети.</string>
|
||||
<string name="prot_applies">Применится при переподключении</string>
|
||||
<string name="prot_list_ads">реклама</string>
|
||||
@@ -1351,4 +1351,9 @@
|
||||
<string name="event_crash_text">В прошлый раз SHX VPN закрылся из-за сбоя. Отправьте отчёт, чтобы мы нашли причину.</string>
|
||||
<string name="event_lockdown_off_title">Постоянный VPN выключили</string>
|
||||
<string name="event_lockdown_off_text">Раньше он был включён. Теперь при обрыве VPN приложения на секунды выходят в сеть напрямую. Включите его снова в настройках VPN Android.</string>
|
||||
<string name="prot_secure_ok">Сервер ответил за %1$d мс. DNS-запросы будут шифроваться.</string>
|
||||
<string name="prot_secure_fail">Сервер не ответил (%1$s). Пока он недоступен, VPN использует DNS профиля.</string>
|
||||
<string name="prot_secure_note">Запросы DoH/DoT идут к вашему серверу в зашифрованном виде через обычную сеть, а не через туннель. Если сервер не отвечает, VPN использует DNS профиля. При строгом «Частном DNS» Android использует свой сервер.</string>
|
||||
<string name="event_secure_dns_title">Свой DoH/DoT-сервер не отвечает</string>
|
||||
<string name="event_secure_dns_text">VPN подключился с DNS профиля. Проверьте адрес в «Настройки → DNS и реклама».</string>
|
||||
</resources>
|
||||
@@ -1151,7 +1151,7 @@
|
||||
<string name="prot_cloudflare_sub">1.1.1.1 · через тунель</string>
|
||||
<string name="prot_adguard_sub">З вбудованою фільтрацією</string>
|
||||
<string name="prot_custom">Своя адреса</string>
|
||||
<string name="prot_custom_sub">IPv4 або IPv6 через кому</string>
|
||||
<string name="prot_custom_sub">IP, DoH (https://) або DoT (tls://)</string>
|
||||
<string name="prot_note">Запити до цього DNS ідуть через тунель — провайдер не зможе підмінити відповідь. Застосунки, що йдуть повз VPN, використовують DNS мережі.</string>
|
||||
<string name="prot_applies">Застосується після перепідключення</string>
|
||||
<string name="prot_list_ads">реклама</string>
|
||||
@@ -1250,4 +1250,9 @@
|
||||
<string name="event_crash_text">Минулого разу SHX VPN закрився через збій. Надішліть звіт, щоб ми знайшли причину.</string>
|
||||
<string name="event_lockdown_off_title">Постійну VPN вимкнули</string>
|
||||
<string name="event_lockdown_off_text">Раніше її було ввімкнено. Тепер у разі обриву VPN застосунки на секунди виходять у мережу напряму. Увімкніть її знову в налаштуваннях VPN Android.</string>
|
||||
<string name="prot_secure_ok">Сервер відповів за %1$d мс. DNS-запити шифруватимуться.</string>
|
||||
<string name="prot_secure_fail">Сервер не відповів (%1$s). Поки він недоступний, VPN використовує DNS профілю.</string>
|
||||
<string name="prot_secure_note">Запити DoH/DoT ідуть до вашого сервера в зашифрованому вигляді через звичайну мережу, а не через тунель. Якщо сервер не відповідає, VPN використовує DNS профілю. За суворого «Приватного DNS» Android використовує свій сервер.</string>
|
||||
<string name="event_secure_dns_title">Власний DoH/DoT-сервер не відповідає</string>
|
||||
<string name="event_secure_dns_text">VPN підключився з DNS профілю. Перевірте адресу в «Налаштування → DNS і реклама».</string>
|
||||
</resources>
|
||||
@@ -1150,7 +1150,7 @@
|
||||
<string name="prot_cloudflare_sub">1.1.1.1 · 经隧道</string>
|
||||
<string name="prot_adguard_sub">内置过滤</string>
|
||||
<string name="prot_custom">自定义地址</string>
|
||||
<string name="prot_custom_sub">IPv4 或 IPv6,以逗号分隔</string>
|
||||
<string name="prot_custom_sub">IP、DoH (https://) 或 DoT (tls://)</string>
|
||||
<string name="prot_note">对此 DNS 的查询经隧道发送,运营商无法篡改应答。绕过 VPN 的应用使用网络自身的 DNS。</string>
|
||||
<string name="prot_applies">重新连接后生效</string>
|
||||
<string name="prot_list_ads">广告</string>
|
||||
@@ -1219,4 +1219,9 @@
|
||||
<string name="event_crash_text">SHX VPN 上次发生崩溃。请发送报告,帮助我们找到原因。</string>
|
||||
<string name="event_lockdown_off_title">始终开启的 VPN 已被关闭</string>
|
||||
<string name="event_lockdown_off_text">此前它处于开启状态。现在每次 VPN 断开时,应用都会直接联网几秒钟。请在 Android 的 VPN 设置中重新开启。</string>
|
||||
<string name="prot_secure_ok">服务器在 %1$d 毫秒内响应。DNS 查询将被加密。</string>
|
||||
<string name="prot_secure_fail">服务器没有响应(%1$s)。在它恢复之前,VPN 使用配置文件中的 DNS。</string>
|
||||
<string name="prot_secure_note">DoH/DoT 查询会经由普通网络加密发送到你的服务器,而不经过隧道。如果服务器没有响应,VPN 会使用配置文件中的 DNS。启用严格的私人 DNS 时,Android 会使用其自己的服务器。</string>
|
||||
<string name="event_secure_dns_title">自定义 DoH/DoT 服务器没有响应</string>
|
||||
<string name="event_secure_dns_text">VPN 已使用配置文件中的 DNS 连接。请在“设置 → DNS 与广告”中检查地址。</string>
|
||||
</resources>
|
||||
@@ -1135,7 +1135,7 @@
|
||||
<string name="prot_cloudflare_sub">1.1.1.1 · 經通道</string>
|
||||
<string name="prot_adguard_sub">內建過濾</string>
|
||||
<string name="prot_custom">自訂位址</string>
|
||||
<string name="prot_custom_sub">IPv4 或 IPv6,以逗號分隔</string>
|
||||
<string name="prot_custom_sub">IP、DoH (https://) 或 DoT (tls://)</string>
|
||||
<string name="prot_note">對此 DNS 的查詢經通道傳送,電信業者無法竄改回應。繞過 VPN 的應用程式使用網路本身的 DNS。</string>
|
||||
<string name="prot_applies">重新連線後生效</string>
|
||||
<string name="prot_list_ads">廣告</string>
|
||||
@@ -1204,4 +1204,9 @@
|
||||
<string name="event_crash_text">SHX VPN 上次發生當機。請傳送報告,協助我們找出原因。</string>
|
||||
<string name="event_lockdown_off_title">永久連線的 VPN 已被關閉</string>
|
||||
<string name="event_lockdown_off_text">先前它處於開啟狀態。現在每次 VPN 中斷時,應用程式都會直接連網幾秒鐘。請在 Android 的 VPN 設定中重新開啟。</string>
|
||||
<string name="prot_secure_ok">伺服器在 %1$d 毫秒內回應。DNS 查詢將會加密。</string>
|
||||
<string name="prot_secure_fail">伺服器沒有回應(%1$s)。在恢復之前,VPN 會使用設定檔中的 DNS。</string>
|
||||
<string name="prot_secure_note">DoH/DoT 查詢會經由一般網路加密傳送到你的伺服器,而不經過通道。如果伺服器沒有回應,VPN 會使用設定檔中的 DNS。啟用嚴格的私人 DNS 時,Android 會使用自己的伺服器。</string>
|
||||
<string name="event_secure_dns_title">自訂 DoH/DoT 伺服器沒有回應</string>
|
||||
<string name="event_secure_dns_text">VPN 已使用設定檔中的 DNS 連線。請在「設定 → DNS 與廣告」中檢查位址。</string>
|
||||
</resources>
|
||||
@@ -1298,7 +1298,7 @@
|
||||
<string name="prot_cloudflare_sub">1.1.1.1 · through the tunnel</string>
|
||||
<string name="prot_adguard_sub">With built-in filtering</string>
|
||||
<string name="prot_custom">Own address</string>
|
||||
<string name="prot_custom_sub">IPv4 or IPv6, comma separated</string>
|
||||
<string name="prot_custom_sub">IPs, DoH (https://) or DoT (tls://)</string>
|
||||
<string name="prot_note">Queries to this DNS go through the tunnel — the provider cannot change the answer. Apps that bypass the VPN use the network\'s DNS.</string>
|
||||
<string name="prot_applies">Applies on reconnect</string>
|
||||
<string name="prot_list_ads">ads</string>
|
||||
@@ -1337,4 +1337,9 @@
|
||||
<string name="event_crash_text">SHX VPN crashed last time. Send a report so we can find the cause.</string>
|
||||
<string name="event_lockdown_off_title">Always-on VPN was turned off</string>
|
||||
<string name="event_lockdown_off_text">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.</string>
|
||||
<string name="prot_secure_ok">The server answered in %1$d ms. DNS queries will be encrypted.</string>
|
||||
<string name="prot_secure_fail">The server did not answer (%1$s). Until it does, the VPN uses the profile’s DNS.</string>
|
||||
<string name="prot_secure_note">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.</string>
|
||||
<string name="event_secure_dns_title">Own DoH/DoT server is not answering</string>
|
||||
<string name="event_secure_dns_text">The VPN connected with the profile’s DNS. Check the address in Settings → DNS and ads.</string>
|
||||
</resources>
|
||||
+109
@@ -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 <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
@@ -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+) |
|
||||
|
||||
@@ -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' }; })
|
||||
|
||||
@@ -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' }; })
|
||||
|
||||
Reference in new issue
Block a user