diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt
index 2f138157d9..75558f6f8d 100644
--- a/client/CMakeLists.txt
+++ b/client/CMakeLists.txt
@@ -40,11 +40,20 @@ endif()
find_package(Qt6 REQUIRED COMPONENTS ${PACKAGES})
+# MasterDnsVPN engine compression layer (§8) — zlib raw deflate, zstd, lz4.
+# Pulled in via conan (see conanfile.py).
+find_package(ZLIB REQUIRED)
+find_package(zstd REQUIRED)
+find_package(lz4 REQUIRED)
+
set(LIBS ${LIBS}
Qt6::Core Qt6::Gui
Qt6::Network Qt6::Xml Qt6::RemoteObjects
Qt6::Quick Qt6::Svg Qt6::QuickControls2
Qt6::Core5Compat Qt6::Concurrent
+ ZLIB::ZLIB
+ zstd::libzstd_static
+ lz4::lz4
)
if(WIN32 OR (APPLE AND NOT IOS) OR (LINUX AND NOT ANDROID))
@@ -119,6 +128,7 @@ endif()
include_directories(mozilla)
include_directories(mozilla/shared)
include_directories(mozilla/models)
+include_directories(masterdnsvpn)
configure_file(${CMAKE_CURRENT_LIST_DIR}/../version.h.in ${CMAKE_CURRENT_BINARY_DIR}/version.h)
diff --git a/client/android/AndroidManifest.xml b/client/android/AndroidManifest.xml
index a0cddbb354..215a015f1b 100644
--- a/client/android/AndroidManifest.xml
+++ b/client/android/AndroidManifest.xml
@@ -191,6 +191,19 @@
+
+
+
+
+
+
+
+ * → poll MasterDnsVpnNative.nativeSocksPort() // wait for listener
+ * → buildVpnInterface(...) // VpnService.Builder TUN
+ * → LibXray.startTun2Socks(...) // tun2socks dials SOCKS5
+ *
+ * The native engine and tun2socks both live in the same process — no IPC,
+ * no subprocess, no bundled binary. tun2socks comes from libxray.aar (a
+ * dependency Amnezia already ships for the Xray protocol); we reuse its
+ * tun2socks engine rather than vendoring a second copy.
+ */
+class MasterDnsVpn : Protocol() {
+
+ private var isRunning: Boolean = false
+ override val statistics: Statistics = Statistics.EMPTY_STATISTICS
+
+ override fun internalInit() {
+ // No persistent native init: the engine instance is created fresh
+ // on every startVpn() and discarded on stopVpn().
+ }
+
+ override suspend fun startVpn(
+ config: JSONObject,
+ vpnBuilder: Builder,
+ protect: (Int) -> Boolean
+ ) {
+ if (isRunning) {
+ Log.w(TAG, "MasterDnsVpn already running")
+ return
+ }
+
+ val configData = config.optJSONObject("masterdnsvpn_config_data")
+ ?: throw BadConfigException("masterdnsvpn_config_data not found")
+
+ if (!MasterDnsVpnNative.nativeStart(configData.toString())) {
+ val err = MasterDnsVpnNative.nativeLastError()
+ throw VpnStartException("Failed to start MasterDnsVpn engine: $err")
+ }
+
+ // Engine binds the SOCKS5 listener asynchronously after start();
+ // tun2socks must not race the listener. Poll until non-zero or
+ // we hit the deadline.
+ val socksPort = waitForSocksListener()
+ if (socksPort == 0) {
+ MasterDnsVpnNative.nativeStop()
+ throw VpnStartException("MasterDnsVpn engine did not bind a SOCKS5 listener")
+ }
+ Log.d(TAG, "MasterDnsVpn engine listening on 127.0.0.1:$socksPort")
+
+ val mdnsvpnConfig = parseConfig(config, configData, socksPort)
+
+ var startupSucceeded = false
+ try {
+ buildVpnInterface(mdnsvpnConfig, vpnBuilder)
+ vpnBuilder.establish().use { tunFd ->
+ if (tunFd == null) {
+ throw VpnStartException(
+ "Create VPN interface: permission not granted or revoked"
+ )
+ }
+ Log.d(TAG, "Run tun2Socks (SOCKS5 backend = native MasterDnsVpn engine)")
+ runTun2Socks(mdnsvpnConfig, tunFd.detachFd())
+ }
+ startupSucceeded = true
+ } finally {
+ if (!startupSucceeded) {
+ MasterDnsVpnNative.nativeStop()
+ }
+ }
+
+ state.value = CONNECTED
+ isRunning = true
+ }
+
+ override fun stopVpn() {
+ LibXray.stopTun2Socks().isNotNullOrBlank { err ->
+ Log.e(TAG, "Failed to stop tun2Socks: $err")
+ }
+ MasterDnsVpnNative.nativeStop()
+ isRunning = false
+ state.value = DISCONNECTED
+ }
+
+ override fun reconnectVpn(vpnBuilder: Builder, protect: (Int) -> Boolean) {
+ // Engine handles its own resilience (ARQ retries, per-resolver
+ // failover). From Amnezia's perspective the tunnel is up as long
+ // as tun2socks + the engine are alive.
+ state.value = CONNECTED
+ }
+
+ private fun parseConfig(
+ config: JSONObject,
+ configData: JSONObject,
+ socksPort: Int
+ ): MasterDnsVpnConfig {
+ return MasterDnsVpnConfig.build {
+ addAddress(MasterDnsVpnConfig.DEFAULT_IPV4_ADDRESS)
+
+ config.optString("dns1").let {
+ if (it.isNotBlank()) addDnsServer(parseInetAddress(it))
+ }
+ config.optString("dns2").let {
+ if (it.isNotBlank()) addDnsServer(parseInetAddress(it))
+ }
+
+ // Default-route both v4 and v6 — MasterDnsVPN is intended as a
+ // full-tunnel transport for environments where only DNS leaves
+ // the network.
+ addRoute(InetNetwork("0.0.0.0", 0))
+ addRoute(InetNetwork("2000::0", 3))
+
+ // Carve the operator's server hostname out of the tunnel so the
+ // underlying DNS queries can still reach it.
+ config.optString("hostName").let {
+ if (it.isNotBlank()) excludeRoute(InetNetwork(it, 32))
+ }
+
+ config.optString("mtu").let {
+ if (it.isNotBlank()) setMtu(it.toInt())
+ }
+
+ setSocksPort(socksPort)
+
+ configSplitTunneling(config)
+ configAppSplitTunneling(config)
+ }
+ }
+
+ private fun runTun2Socks(config: MasterDnsVpnConfig, fd: Int) {
+ // Engine binds 127.0.0.1 only; no auth needed (the device-to-engine
+ // hop never leaves the loopback interface).
+ val proxyUrl = "socks5://127.0.0.1:${config.socksPort}"
+ val tun2SocksConfig = Tun2SocksConfig().apply {
+ mtu = config.mtu.toLong()
+ proxy = proxyUrl
+ device = "fd://$fd"
+ logLevel = "warn"
+ }
+ LibXray.startTun2Socks(tun2SocksConfig, fd.toLong()).isNotNullOrBlank { err ->
+ throw VpnStartException("Failed to start tun2socks for MasterDnsVpn: $err")
+ }
+ }
+
+ private fun waitForSocksListener(): Int {
+ // Up to 60 s — the engine's MTU discovery + handshake can take a
+ // bit on a fresh connect. The native engine publishes a non-zero
+ // SOCKS5 port only after its underlying QTcpServer.listen() has
+ // succeeded, so any positive value already means "accepting
+ // connections" (QTcpServer.serverPort() returns 0 until listen
+ // returns true). No follow-up TCP probe required.
+ val deadline = System.currentTimeMillis() + SOCKS_WAIT_TIMEOUT_MS
+ while (System.currentTimeMillis() < deadline) {
+ val port = MasterDnsVpnNative.nativeSocksPort()
+ if (port > 0) {
+ return port
+ }
+ try {
+ Thread.sleep(SOCKS_POLL_INTERVAL_MS.toLong())
+ } catch (_: InterruptedException) {
+ Thread.currentThread().interrupt()
+ return 0
+ }
+ }
+ return 0
+ }
+
+ /**
+ * Process-wide singleton plus the timing constants for the SOCKS5
+ * listener handshake. Mirrors the pattern used by the Xray protocol
+ * module in the same package.
+ */
+ companion object {
+ private const val SOCKS_WAIT_TIMEOUT_MS = 60_000
+ private const val SOCKS_POLL_INTERVAL_MS = 250
+
+ val instance: MasterDnsVpn by lazy { MasterDnsVpn() }
+ }
+}
+
+private fun String?.isNotNullOrBlank(block: (String) -> Unit) {
+ if (!this.isNullOrBlank()) {
+ block(this)
+ }
+}
diff --git a/client/android/master_dns_vpn/src/main/kotlin/MasterDnsVpnConfig.kt b/client/android/master_dns_vpn/src/main/kotlin/MasterDnsVpnConfig.kt
new file mode 100644
index 0000000000..654c243811
--- /dev/null
+++ b/client/android/master_dns_vpn/src/main/kotlin/MasterDnsVpnConfig.kt
@@ -0,0 +1,58 @@
+package org.amnezia.vpn.protocol.masterdnsvpn
+
+import org.amnezia.vpn.protocol.ProtocolConfig
+import org.amnezia.vpn.util.net.InetNetwork
+
+private const val MASTER_DNS_VPN_DEFAULT_MTU = 1500
+
+/**
+ * Per-connection configuration for the MasterDnsVPN protocol on Android.
+ *
+ * Built from the JSON received from the desktop/IPC layer via the
+ * [Builder]. The `socksPort` is filled in at runtime after the native
+ * engine binds its listener (it cannot be predicted at config time
+ * because the engine asks the OS for an ephemeral port).
+ */
+class MasterDnsVpnConfig protected constructor(
+ protocolConfigBuilder: ProtocolConfig.Builder,
+ val socksPort: Int,
+) : ProtocolConfig(protocolConfigBuilder) {
+
+ protected constructor(builder: Builder) : this(
+ builder,
+ builder.socksPort,
+ )
+
+ /**
+ * Mutable accumulator for [MasterDnsVpnConfig] fields. Inherits the
+ * generic protocol-config fields (addresses, routes, DNS, MTU,
+ * split-tunnel rules) from [ProtocolConfig.Builder] and adds the one
+ * MasterDnsVPN-specific field, [socksPort].
+ */
+ class Builder : ProtocolConfig.Builder(false) {
+ internal var socksPort: Int = 0
+ private set
+
+ override var mtu: Int = MASTER_DNS_VPN_DEFAULT_MTU
+
+ /** Set the loopback SOCKS5 listener port published by the engine. */
+ fun setSocksPort(port: Int) = apply { socksPort = port }
+
+ override fun build(): MasterDnsVpnConfig =
+ configBuild().run { MasterDnsVpnConfig(this@Builder) }
+ }
+
+ /**
+ * Static defaults and the DSL-style [build] entry point.
+ */
+ companion object {
+ // /30 private subnet for the TUN interface; matches the /30 trick
+ // XrayConfig uses to give us a gateway/.1 + TUN local/.2 pair that
+ // doesn't collide with typical LAN ranges.
+ internal val DEFAULT_IPV4_ADDRESS: InetNetwork = InetNetwork("10.0.43.2", 30)
+
+ /** Apply [block] to a fresh [Builder] and produce the config. */
+ inline fun build(block: Builder.() -> Unit): MasterDnsVpnConfig =
+ Builder().apply(block).build()
+ }
+}
diff --git a/client/android/master_dns_vpn/src/main/kotlin/MasterDnsVpnNative.kt b/client/android/master_dns_vpn/src/main/kotlin/MasterDnsVpnNative.kt
new file mode 100644
index 0000000000..5c1f7e8966
--- /dev/null
+++ b/client/android/master_dns_vpn/src/main/kotlin/MasterDnsVpnNative.kt
@@ -0,0 +1,55 @@
+package org.amnezia.vpn.protocol.masterdnsvpn
+
+/**
+ * JNI bridge to the native MasterDnsVPN engine.
+ *
+ * The native implementation lives in client/masterdnsvpn/android_jni.cpp and
+ * is compiled into the main Qt-for-Android shared library that the activity
+ * already loads — no separate System.loadLibrary call needed here.
+ *
+ * All methods are static (object members) and serialise on a process-global
+ * mutex on the C++ side; safe to call from any Kotlin coroutine context.
+ */
+internal object MasterDnsVpnNative {
+
+ /** Engine::State enum, mirrored from C++. */
+ const val STATE_IDLE = 0
+ const val STATE_STARTING = 1
+ const val STATE_CONNECTED = 2
+ const val STATE_STOPPING = 3
+ const val STATE_FAILED = 4
+
+ /**
+ * Spin up the native engine with the structured JSON config the Kotlin
+ * Protocol class composes from the connect-time wrapper. Returns true
+ * on a successful synchronous start (the SOCKS5 listener will bind
+ * shortly after); false when the config fails validation.
+ */
+ @JvmStatic
+ external fun nativeStart(configJson: String): Boolean
+
+ /** Tear down the engine. Idempotent; safe to call when already stopped. */
+ @JvmStatic
+ external fun nativeStop()
+
+ /**
+ * Local SOCKS5 listen port (0 if not yet bound). Caller polls this after
+ * nativeStart() returns true; tun2socks dials it once non-zero.
+ */
+ @JvmStatic
+ external fun nativeSocksPort(): Int
+
+ /** Engine state ordinal — see STATE_* constants above. */
+ @JvmStatic
+ external fun nativeState(): Int
+
+ /** Human-readable last error from the engine (empty string when none). */
+ @JvmStatic
+ external fun nativeLastError(): String
+
+ @JvmStatic
+ external fun nativeBytesReceived(): Long
+
+ @JvmStatic
+ external fun nativeBytesSent(): Long
+}
diff --git a/client/android/settings.gradle.kts b/client/android/settings.gradle.kts
index 0ad70055cd..d120309acb 100644
--- a/client/android/settings.gradle.kts
+++ b/client/android/settings.gradle.kts
@@ -36,6 +36,7 @@ include(":awg")
include(":openvpn")
include(":xray")
include(":xray:libXray")
+include(":master_dns_vpn")
// get values from gradle or local properties
val androidBuildToolsVersion: String by gradleProperties
diff --git a/client/android/src/org/amnezia/vpn/MasterDnsVpnService.kt b/client/android/src/org/amnezia/vpn/MasterDnsVpnService.kt
new file mode 100644
index 0000000000..2347b8c439
--- /dev/null
+++ b/client/android/src/org/amnezia/vpn/MasterDnsVpnService.kt
@@ -0,0 +1,13 @@
+package org.amnezia.vpn
+
+/**
+ * Android `VpnService` host for the MasterDnsVPN protocol.
+ *
+ * Inherits the full lifecycle (start, stop, reconnect, foreground
+ * notification, split-tunneling glue) from [AmneziaVpnService] and only
+ * exists so the platform can resolve a class name in the manifest. The
+ * actual MasterDnsVPN engine (SOCKS5 listener, ARQ, resolver pool, DNS
+ * tunnel) lives in the `master_dns_vpn` module, owned by the singleton
+ * `MasterDnsVpn` that the parent class dispatches to.
+ */
+class MasterDnsVpnService : AmneziaVpnService()
diff --git a/client/android/src/org/amnezia/vpn/VpnProto.kt b/client/android/src/org/amnezia/vpn/VpnProto.kt
index e1993ad01d..37ed578d2e 100644
--- a/client/android/src/org/amnezia/vpn/VpnProto.kt
+++ b/client/android/src/org/amnezia/vpn/VpnProto.kt
@@ -2,6 +2,7 @@ package org.amnezia.vpn
import org.amnezia.vpn.protocol.Protocol
import org.amnezia.vpn.protocol.awg.Awg
+import org.amnezia.vpn.protocol.masterdnsvpn.MasterDnsVpn
import org.amnezia.vpn.protocol.openvpn.OpenVpn
import org.amnezia.vpn.protocol.wireguard.Wireguard
import org.amnezia.vpn.protocol.xray.Xray
@@ -49,6 +50,14 @@ enum class VpnProto(
XrayService::class.java
) {
override fun createProtocol(): Protocol = Xray.instance
+ },
+
+ MASTERDNSVPN(
+ "MasterDnsVPN",
+ "org.amnezia.vpn:amneziaMasterDnsVpnService",
+ MasterDnsVpnService::class.java
+ ) {
+ override fun createProtocol(): Protocol = MasterDnsVpn.instance
};
private var _protocol: Protocol? = null
diff --git a/client/cmake/android.cmake b/client/cmake/android.cmake
index 01c1a4900c..6325c560e4 100644
--- a/client/cmake/android.cmake
+++ b/client/cmake/android.cmake
@@ -40,6 +40,11 @@ set(SOURCES ${SOURCES}
${CMAKE_CURRENT_SOURCE_DIR}/platforms/android/android_utils.cpp
${CMAKE_CURRENT_SOURCE_DIR}/core/protocols/androidVpnProtocol.cpp
${CMAKE_CURRENT_SOURCE_DIR}/core/utils/installedAppsImageProvider.cpp
+ # JNI bridge into the native MasterDnsVPN engine. Compiled into the main
+ # Qt-for-Android shared library so the Java loader resolves
+ # Java_org_amnezia_vpn_protocol_masterdnsvpn_MasterDnsVpnNative_*
+ # via the same .so the activity already pulls in.
+ ${CMAKE_CURRENT_SOURCE_DIR}/masterdnsvpn/android_jni.cpp
)
diff --git a/client/cmake/sources.cmake b/client/cmake/sources.cmake
index ddc44d47be..4e87c950d4 100644
--- a/client/cmake/sources.cmake
+++ b/client/cmake/sources.cmake
@@ -75,6 +75,25 @@ set(HEADERS ${HEADERS}
${CLIENT_ROOT_DIR}/mozilla/controllerimpl.h
)
+# MasterDnsVPN native engine — sibling to mozilla/. Cross-platform Qt-using
+# C++ that implements the full DNS-tunnel protocol stack in-process; consumed
+# directly by the desktop protocol class and via JNI by the Android module.
+set(HEADERS ${HEADERS}
+ ${CLIENT_ROOT_DIR}/masterdnsvpn/engine.h
+ ${CLIENT_ROOT_DIR}/masterdnsvpn/crypto.h
+ ${CLIENT_ROOT_DIR}/masterdnsvpn/socks5server.h
+ ${CLIENT_ROOT_DIR}/masterdnsvpn/wireframing.h
+ ${CLIENT_ROOT_DIR}/masterdnsvpn/dnsframing.h
+ ${CLIENT_ROOT_DIR}/masterdnsvpn/arq.h
+ ${CLIENT_ROOT_DIR}/masterdnsvpn/resolverpool.h
+ ${CLIENT_ROOT_DIR}/masterdnsvpn/session.h
+ ${CLIENT_ROOT_DIR}/masterdnsvpn/pingpacer.h
+ ${CLIENT_ROOT_DIR}/masterdnsvpn/mtuprober.h
+ ${CLIENT_ROOT_DIR}/masterdnsvpn/compression.h
+ ${CLIENT_ROOT_DIR}/masterdnsvpn/dnsmsg.h
+ ${CLIENT_ROOT_DIR}/masterdnsvpn/dnscache.h
+)
+
if(NOT IOS AND NOT MACOS_NE)
set(HEADERS ${HEADERS}
${CLIENT_ROOT_DIR}/platforms/ios/QRCodeReaderBase.h
@@ -154,6 +173,22 @@ set(SOURCES ${SOURCES}
${CLIENT_ROOT_DIR}/mozilla/shared/leakdetector.cpp
)
+# MasterDnsVPN native engine sources — see HEADERS list above for rationale.
+set(SOURCES ${SOURCES}
+ ${CLIENT_ROOT_DIR}/masterdnsvpn/crypto.cpp
+ ${CLIENT_ROOT_DIR}/masterdnsvpn/socks5server.cpp
+ ${CLIENT_ROOT_DIR}/masterdnsvpn/wireframing.cpp
+ ${CLIENT_ROOT_DIR}/masterdnsvpn/dnsframing.cpp
+ ${CLIENT_ROOT_DIR}/masterdnsvpn/arq.cpp
+ ${CLIENT_ROOT_DIR}/masterdnsvpn/resolverpool.cpp
+ ${CLIENT_ROOT_DIR}/masterdnsvpn/session.cpp
+ ${CLIENT_ROOT_DIR}/masterdnsvpn/engine.cpp
+ ${CLIENT_ROOT_DIR}/masterdnsvpn/mtuprober.cpp
+ ${CLIENT_ROOT_DIR}/masterdnsvpn/compression.cpp
+ ${CLIENT_ROOT_DIR}/masterdnsvpn/dnsmsg.cpp
+ ${CLIENT_ROOT_DIR}/masterdnsvpn/dnscache.cpp
+)
+
if(NOT IOS AND NOT MACOS_NE)
set(SOURCES ${SOURCES}
${CLIENT_ROOT_DIR}/platforms/ios/QRCodeReaderBase.cpp
@@ -271,6 +306,7 @@ if(WIN32 OR (APPLE AND NOT IOS AND NOT MACOS_NE) OR (LINUX AND NOT ANDROID))
${CLIENT_ROOT_DIR}/core/protocols/wireGuardProtocol.h
${CLIENT_ROOT_DIR}/core/protocols/xrayProtocol.h
${CLIENT_ROOT_DIR}/core/protocols/awgProtocol.h
+ ${CLIENT_ROOT_DIR}/core/protocols/masterDnsVpnProtocol.h
${CLIENT_ROOT_DIR}/mozilla/localsocketcontroller.h
)
@@ -282,6 +318,7 @@ if(WIN32 OR (APPLE AND NOT IOS AND NOT MACOS_NE) OR (LINUX AND NOT ANDROID))
${CLIENT_ROOT_DIR}/core/protocols/wireGuardProtocol.cpp
${CLIENT_ROOT_DIR}/core/protocols/xrayProtocol.cpp
${CLIENT_ROOT_DIR}/core/protocols/awgProtocol.cpp
+ ${CLIENT_ROOT_DIR}/core/protocols/masterDnsVpnProtocol.cpp
)
endif()
diff --git a/client/core/controllers/coreController.cpp b/client/core/controllers/coreController.cpp
index 77b951f9e3..58f4cfc58a 100644
--- a/client/core/controllers/coreController.cpp
+++ b/client/core/controllers/coreController.cpp
@@ -86,6 +86,9 @@ void CoreController::initModels()
m_xrayConfigModel = new XrayConfigModel(this);
setQmlContextProperty("XrayConfigModel", m_xrayConfigModel);
+ m_masterDnsVpnConfigModel = new MasterDnsVpnConfigModel(this);
+ setQmlContextProperty("MasterDnsVpnConfigModel", m_masterDnsVpnConfigModel);
+
m_xrayConfigSnapshotsModel = new XrayConfigSnapshotsModel(m_appSettingsRepository, m_xrayConfigModel, this);
setQmlContextProperty("XrayConfigSnapshotsModel", m_xrayConfigSnapshotsModel);
@@ -174,7 +177,8 @@ void CoreController::initControllers()
}
m_installUiController = new InstallUiController(m_installController, m_serversController, m_settingsController, m_protocolsModel, m_usersController,
- m_awgConfigModel, m_wireGuardConfigModel, m_openVpnConfigModel, m_xrayConfigModel, m_torConfigModel,
+ m_awgConfigModel, m_wireGuardConfigModel, m_openVpnConfigModel, m_xrayConfigModel,
+ m_masterDnsVpnConfigModel, m_torConfigModel,
#ifdef Q_OS_WINDOWS
m_ikev2ConfigModel,
#endif
diff --git a/client/core/controllers/coreController.h b/client/core/controllers/coreController.h
index 70033d61b1..d5672156d6 100644
--- a/client/core/controllers/coreController.h
+++ b/client/core/controllers/coreController.h
@@ -65,6 +65,7 @@
#include "ui/models/protocols/openvpnConfigModel.h"
#include "ui/models/protocols/wireguardConfigModel.h"
#include "ui/models/protocols/xrayConfigModel.h"
+#include "ui/models/protocols/masterDnsVpnConfigModel.h"
#include "ui/models/protocols/xrayConfigSnapshotsModel.h"
#include "ui/models/protocolsModel.h"
#include "ui/models/services/torConfigModel.h"
@@ -206,6 +207,7 @@ class CoreController : public QObject
OpenVpnConfigModel* m_openVpnConfigModel;
XrayConfigModel* m_xrayConfigModel;
+ MasterDnsVpnConfigModel* m_masterDnsVpnConfigModel;
XrayConfigSnapshotsModel* m_xrayConfigSnapshotsModel;
TorConfigModel* m_torConfigModel;
WireGuardConfigModel* m_wireGuardConfigModel;
diff --git a/client/core/models/containerConfig.cpp b/client/core/models/containerConfig.cpp
index 6a008a13da..4319899ac2 100644
--- a/client/core/models/containerConfig.cpp
+++ b/client/core/models/containerConfig.cpp
@@ -93,6 +93,16 @@ const XrayProtocolConfig* ContainerConfig::getXrayProtocolConfig() const
return protocolConfig.as();
}
+MasterDnsVpnProtocolConfig* ContainerConfig::getMasterDnsVpnProtocolConfig()
+{
+ return protocolConfig.as();
+}
+
+const MasterDnsVpnProtocolConfig* ContainerConfig::getMasterDnsVpnProtocolConfig() const
+{
+ return protocolConfig.as();
+}
+
SftpProtocolConfig* ContainerConfig::getSftpProtocolConfig()
{
return protocolConfig.as();
diff --git a/client/core/models/containerConfig.h b/client/core/models/containerConfig.h
index b07ff6dff0..010e9ca891 100644
--- a/client/core/models/containerConfig.h
+++ b/client/core/models/containerConfig.h
@@ -50,7 +50,10 @@ struct ContainerConfig {
XrayProtocolConfig* getXrayProtocolConfig();
const XrayProtocolConfig* getXrayProtocolConfig() const;
-
+
+ MasterDnsVpnProtocolConfig* getMasterDnsVpnProtocolConfig();
+ const MasterDnsVpnProtocolConfig* getMasterDnsVpnProtocolConfig() const;
+
SftpProtocolConfig* getSftpProtocolConfig();
const SftpProtocolConfig* getSftpProtocolConfig() const;
diff --git a/client/core/models/protocolConfig.cpp b/client/core/models/protocolConfig.cpp
index 24e879f18a..2e10122116 100644
--- a/client/core/models/protocolConfig.cpp
+++ b/client/core/models/protocolConfig.cpp
@@ -30,6 +30,8 @@ Proto ProtocolConfig::type() const
return Proto::OpenVpn;
} else if constexpr (std::is_same_v) {
return Proto::Xray;
+ } else if constexpr (std::is_same_v) {
+ return Proto::MasterDnsVpn;
} else if constexpr (std::is_same_v) {
return Proto::Sftp;
} else if constexpr (std::is_same_v) {
@@ -61,6 +63,8 @@ QString ProtocolConfig::port() const
return arg.serverConfig.port;
} else if constexpr (std::is_same_v) {
return arg.serverConfig.port;
+ } else if constexpr (std::is_same_v) {
+ return arg.serverConfig.port;
} else if constexpr (std::is_same_v) {
return arg.port;
} else if constexpr (std::is_same_v) {
@@ -92,6 +96,10 @@ QString ProtocolConfig::transportProto() const
return arg.serverConfig.transportProto;
} else if constexpr (std::is_same_v) {
return arg.serverConfig.transportProto;
+ } else if constexpr (std::is_same_v) {
+ // Always UDP (DNS envelopes); the server-side TOML doesn't carry a
+ // string in the same wire slot, so we synthesize the literal here.
+ return QStringLiteral("udp");
} else if constexpr (std::is_same_v) {
return QString();
} else if constexpr (std::is_same_v) {
@@ -115,6 +123,7 @@ bool ProtocolConfig::hasClientConfig() const
std::is_same_v ||
std::is_same_v ||
std::is_same_v ||
+ std::is_same_v ||
std::is_same_v) {
return arg.hasClientConfig();
}
@@ -142,6 +151,10 @@ QString ProtocolConfig::clientId() const
if (arg.clientConfig.has_value()) {
return arg.clientConfig->id;
}
+ } else if constexpr (std::is_same_v) {
+ if (arg.clientConfig.has_value()) {
+ return arg.clientConfig->id;
+ }
} else if constexpr (std::is_same_v) {
if (arg.clientConfig.has_value()) {
return arg.clientConfig->clientId;
@@ -171,6 +184,10 @@ QJsonObject ProtocolConfig::getClientConfigJson() const
if (arg.hasClientConfig()) {
return arg.clientConfig->toJson();
}
+ } else if constexpr (std::is_same_v) {
+ if (arg.hasClientConfig()) {
+ return arg.clientConfig->toJson();
+ }
} else if constexpr (std::is_same_v) {
if (arg.hasClientConfig()) {
return arg.clientConfig->toJson();
@@ -192,6 +209,8 @@ void ProtocolConfig::setClientConfigJson(const QJsonObject& json)
arg.setClientConfig(OpenVpnClientConfig::fromJson(json));
} else if constexpr (std::is_same_v) {
arg.setClientConfig(XrayClientConfig::fromJson(json));
+ } else if constexpr (std::is_same_v) {
+ arg.setClientConfig(MasterDnsVpnClientConfig::fromJson(json));
} else if constexpr (std::is_same_v) {
arg.setClientConfig(Ikev2ClientConfig::fromJson(json));
}
@@ -206,6 +225,7 @@ void ProtocolConfig::clearClientConfig()
std::is_same_v ||
std::is_same_v ||
std::is_same_v ||
+ std::is_same_v ||
std::is_same_v) {
arg.clearClientConfig();
}
@@ -232,6 +252,11 @@ QString ProtocolConfig::nativeConfig() const
if (arg.clientConfig.has_value()) {
return arg.clientConfig->nativeConfig;
}
+ } else if constexpr (std::is_same_v) {
+ // No imported-config-as-string slot for MasterDnsVPN: per-client
+ // state is structured JSON consumed by the in-process engine.
+ // The "nativeConfig" concept doesn't apply.
+ return QString();
} else if constexpr (std::is_same_v) {
if (arg.clientConfig.has_value()) {
return arg.clientConfig->nativeConfig;
@@ -261,6 +286,9 @@ void ProtocolConfig::setNativeConfig(const QString &config)
if (arg.clientConfig.has_value()) {
arg.clientConfig->nativeConfig = config;
}
+ } else if constexpr (std::is_same_v) {
+ // No-op: see ProtocolConfig::nativeConfig() above for rationale.
+ (void)config;
} else if constexpr (std::is_same_v) {
if (arg.clientConfig.has_value()) {
arg.clientConfig->nativeConfig = config;
@@ -277,6 +305,7 @@ bool ProtocolConfig::isThirdPartyConfig() const
std::is_same_v ||
std::is_same_v ||
std::is_same_v ||
+ std::is_same_v ||
std::is_same_v) {
return arg.serverConfig.isThirdPartyConfig;
}
@@ -303,6 +332,8 @@ ProtocolConfig ProtocolConfig::fromJson(const QJsonObject& json, Proto type)
case Proto::Xray:
case Proto::SSXray:
return ProtocolConfig{XrayProtocolConfig::fromJson(json)};
+ case Proto::MasterDnsVpn:
+ return ProtocolConfig{MasterDnsVpnProtocolConfig::fromJson(json)};
case Proto::Sftp:
return ProtocolConfig{SftpProtocolConfig::fromJson(json)};
case Proto::Socks5Proxy:
diff --git a/client/core/models/protocolConfig.h b/client/core/models/protocolConfig.h
index 3245300873..6f930ad166 100644
--- a/client/core/models/protocolConfig.h
+++ b/client/core/models/protocolConfig.h
@@ -17,6 +17,7 @@
#include "core/models/protocols/wireGuardProtocolConfig.h"
#include "core/models/protocols/openVpnProtocolConfig.h"
#include "core/models/protocols/xrayProtocolConfig.h"
+#include "core/models/protocols/masterDnsVpnProtocolConfig.h"
#include "core/models/protocols/sftpProtocolConfig.h"
#include "core/models/protocols/socks5ProxyProtocolConfig.h"
#include "core/models/protocols/ikev2ProtocolConfig.h"
@@ -36,6 +37,7 @@ struct ProtocolConfig {
WireGuardProtocolConfig,
OpenVpnProtocolConfig,
XrayProtocolConfig,
+ MasterDnsVpnProtocolConfig,
SftpProtocolConfig,
Socks5ProxyProtocolConfig,
MtProxyProtocolConfig,
diff --git a/client/core/models/protocols/masterDnsVpnProtocolConfig.cpp b/client/core/models/protocols/masterDnsVpnProtocolConfig.cpp
new file mode 100644
index 0000000000..dae8badd13
--- /dev/null
+++ b/client/core/models/protocols/masterDnsVpnProtocolConfig.cpp
@@ -0,0 +1,198 @@
+#include "masterDnsVpnProtocolConfig.h"
+
+#include
+
+#include "../../../core/protocols/protocolUtils.h"
+#include "../../../core/utils/constants/configKeys.h"
+#include "../../../core/utils/constants/protocolConstants.h"
+#include "../../../core/utils/protocolEnum.h"
+
+using namespace amnezia;
+using namespace ProtocolUtils;
+
+namespace amnezia
+{
+
+QJsonObject MasterDnsVpnServerConfig::toJson() const
+{
+ QJsonObject obj;
+
+ if (!domains.isEmpty()) {
+ obj[configKey::mdvDomains] = domains;
+ }
+ if (!port.isEmpty()) {
+ obj[configKey::port] = port;
+ }
+ if (!bind.isEmpty()) {
+ obj[configKey::mdvBind] = bind;
+ }
+ obj[configKey::mdvEncryptionMethod] = encryptionMethod;
+ if (!encryptionKey.isEmpty()) {
+ obj[configKey::mdvEncryptionKey] = encryptionKey;
+ }
+ if (!protocolType.isEmpty()) {
+ obj[configKey::mdvProtocolType] = protocolType;
+ }
+ if (!dnsUpstreamServers.isEmpty()) {
+ obj[configKey::mdvDnsUpstreamServers] = dnsUpstreamServers;
+ }
+ if (!forwardIp.isEmpty()) {
+ obj[configKey::mdvForwardIp] = forwardIp;
+ }
+ if (forwardPort != 0) {
+ obj[configKey::mdvForwardPort] = forwardPort;
+ }
+ if (useExternalSocks5) {
+ obj[configKey::mdvUseExternalSocks5] = useExternalSocks5;
+ }
+ if (socks5Auth) {
+ obj[configKey::mdvSocks5Auth] = socks5Auth;
+ }
+ if (!socks5User.isEmpty()) {
+ obj[configKey::mdvSocks5User] = socks5User;
+ }
+ if (!socks5Pass.isEmpty()) {
+ obj[configKey::mdvSocks5Pass] = socks5Pass;
+ }
+ if (!additionalConfig.isEmpty()) {
+ obj[configKey::mdvAdditionalConfig] = additionalConfig;
+ }
+ if (isThirdPartyConfig) {
+
+ obj[configKey::isThirdPartyConfig] = isThirdPartyConfig;
+ }
+
+ return obj;
+}
+
+MasterDnsVpnServerConfig MasterDnsVpnServerConfig::fromJson(const QJsonObject &json)
+{
+ MasterDnsVpnServerConfig config;
+
+ config.domains = json.value(configKey::mdvDomains).toArray();
+ config.port = json.value(configKey::port).toString();
+ config.bind = json.value(configKey::mdvBind).toString();
+ config.encryptionMethod = json.value(configKey::mdvEncryptionMethod)
+ .toInt(protocols::masterDnsVpn::defaultEncryptionMethod);
+ config.encryptionKey = json.value(configKey::mdvEncryptionKey).toString();
+ config.protocolType = json.value(configKey::mdvProtocolType).toString();
+ config.dnsUpstreamServers = json.value(configKey::mdvDnsUpstreamServers).toArray();
+ config.forwardIp = json.value(configKey::mdvForwardIp).toString();
+ config.forwardPort = json.value(configKey::mdvForwardPort).toInt(0);
+ config.useExternalSocks5 = json.value(configKey::mdvUseExternalSocks5).toBool(false);
+ config.socks5Auth = json.value(configKey::mdvSocks5Auth).toBool(false);
+ config.socks5User = json.value(configKey::mdvSocks5User).toString();
+ config.socks5Pass = json.value(configKey::mdvSocks5Pass).toString();
+ config.additionalConfig = json.value(configKey::mdvAdditionalConfig).toObject();
+ config.isThirdPartyConfig = json.value(configKey::isThirdPartyConfig).toBool(false);
+
+ return config;
+}
+
+QJsonObject MasterDnsVpnClientConfig::toJson() const
+{
+ QJsonObject obj;
+
+ if (!listenPort.isEmpty()) {
+ obj[configKey::mdvListenPort] = listenPort;
+ }
+ if (!socks5User.isEmpty()) {
+ obj[configKey::mdvSocks5User] = socks5User;
+ }
+ if (!socks5Pass.isEmpty()) {
+ obj[configKey::mdvSocks5Pass] = socks5Pass;
+ }
+ if (!resolvers.isEmpty()) {
+ obj[configKey::mdvResolvers] = resolvers;
+ }
+ if (balancingStrategy != 0) {
+ obj[configKey::mdvBalancingStrategy] = balancingStrategy;
+ }
+ if (packetDuplication != 0) {
+ obj[configKey::mdvPacketDuplication] = packetDuplication;
+ }
+ if (setupPacketDuplication != 0) {
+ obj[configKey::mdvSetupPacketDuplication] = setupPacketDuplication;
+ }
+ if (uploadCompression != 0) {
+ obj[configKey::mdvUploadCompression] = uploadCompression;
+ }
+ if (downloadCompression != 0) {
+ obj[configKey::mdvDownloadCompression] = downloadCompression;
+ }
+ if (!additionalConfig.isEmpty()) {
+ obj[configKey::mdvAdditionalConfig] = additionalConfig;
+ }
+ if (!id.isEmpty()) {
+ obj[configKey::clientId] = id;
+ }
+
+ return obj;
+}
+
+MasterDnsVpnClientConfig MasterDnsVpnClientConfig::fromJson(const QJsonObject &json)
+{
+ MasterDnsVpnClientConfig config;
+
+ config.listenPort = json.value(configKey::mdvListenPort).toString();
+ config.socks5User = json.value(configKey::mdvSocks5User).toString();
+ config.socks5Pass = json.value(configKey::mdvSocks5Pass).toString();
+ config.resolvers = json.value(configKey::mdvResolvers).toArray();
+ // Defaults match upstream sample config when an operator omits a field;
+ // the engine clamps these to sane ranges before use.
+ config.balancingStrategy = json.value(configKey::mdvBalancingStrategy).toInt(5);
+ config.packetDuplication = json.value(configKey::mdvPacketDuplication).toInt(3);
+ config.setupPacketDuplication = json.value(configKey::mdvSetupPacketDuplication).toInt(4);
+ config.uploadCompression = json.value(configKey::mdvUploadCompression).toInt(0);
+ config.downloadCompression = json.value(configKey::mdvDownloadCompression).toInt(0);
+ config.additionalConfig = json.value(configKey::mdvAdditionalConfig).toObject();
+ config.id = json.value(configKey::clientId).toString();
+
+ return config;
+}
+
+QJsonObject MasterDnsVpnProtocolConfig::toJson() const
+{
+ QJsonObject obj = serverConfig.toJson();
+
+ if (clientConfig.has_value()) {
+ QJsonObject clientJson = clientConfig->toJson();
+ obj[configKey::lastConfig] = QString::fromUtf8(QJsonDocument(clientJson).toJson(QJsonDocument::Compact));
+ }
+
+ return obj;
+}
+
+MasterDnsVpnProtocolConfig MasterDnsVpnProtocolConfig::fromJson(const QJsonObject &json)
+{
+ MasterDnsVpnProtocolConfig config;
+
+ config.serverConfig = MasterDnsVpnServerConfig::fromJson(json);
+
+ QString lastConfigStr = json.value(configKey::lastConfig).toString();
+ if (!lastConfigStr.isEmpty()) {
+ QJsonDocument doc = QJsonDocument::fromJson(lastConfigStr.toUtf8());
+ if (doc.isObject()) {
+ config.clientConfig = MasterDnsVpnClientConfig::fromJson(doc.object());
+ }
+ }
+
+ return config;
+}
+
+bool MasterDnsVpnProtocolConfig::hasClientConfig() const
+{
+ return clientConfig.has_value();
+}
+
+void MasterDnsVpnProtocolConfig::setClientConfig(const MasterDnsVpnClientConfig &config)
+{
+ clientConfig = config;
+}
+
+void MasterDnsVpnProtocolConfig::clearClientConfig()
+{
+ clientConfig.reset();
+}
+
+} // namespace amnezia
diff --git a/client/core/models/protocols/masterDnsVpnProtocolConfig.h b/client/core/models/protocols/masterDnsVpnProtocolConfig.h
new file mode 100644
index 0000000000..dc3602497e
--- /dev/null
+++ b/client/core/models/protocols/masterDnsVpnProtocolConfig.h
@@ -0,0 +1,131 @@
+#ifndef MASTERDNSVPNPROTOCOLCONFIG_H
+#define MASTERDNSVPNPROTOCOLCONFIG_H
+
+#include
+#include
+#include
+#include
+
+namespace amnezia
+{
+
+// Server-side singleton — every MasterDnsVPN client of a given operator shares the
+// same encryption key and dialing parameters. Per-client variation lives in
+// MasterDnsVpnClientConfig (resolvers, local SOCKS5 listen port, etc.).
+struct MasterDnsVpnServerConfig
+{
+ // JSON array of NS-delegated FQDNs the server is authoritative for
+ // (e.g. ["v.example.com"]). Stored as a QJsonArray to round-trip cleanly
+ // through the wire JSON without re-parsing string forms.
+ QJsonArray domains;
+
+ // UDP port the server's mdnsvpn binary binds. Default 53.
+ QString port;
+
+ // Server bind address. Almost always "0.0.0.0".
+ QString bind;
+
+ // 0..5 — see protocols::masterDnsVpn encryptionMethod* constants.
+ int encryptionMethod = 0;
+
+ // Lower-case hex shared secret. Same value baked into every client config.
+ QString encryptionKey;
+
+ // "SOCKS5" (clients pick destination per stream) or "TCP" (every connection
+ // forwards to forwardIp:forwardPort).
+ QString protocolType;
+
+ // Comma- or array-encoded list of upstream resolvers used to satisfy
+ // DNS_QUERY_REQ tunnel envelopes. Stored as JSON array.
+ QJsonArray dnsUpstreamServers;
+
+ // Used in TCP mode (every connection forwards here) OR in SOCKS5 mode when
+ // useExternalSocks5=true (server chains through an upstream SOCKS5 proxy).
+ QString forwardIp;
+ int forwardPort = 0;
+
+ bool useExternalSocks5 = false;
+ bool socks5Auth = false;
+ QString socks5User;
+ QString socks5Pass;
+
+ // Free-form JSON object merged into the wire config the operator side
+ // emits, carried through for round-trip integrity. The native engine
+ // does not introspect this — keys outside the documented schema flow
+ // through unchanged so the operator can extend the format independently
+ // of an Amnezia release.
+ QJsonObject additionalConfig;
+
+ // True when imported from a third-party "vpn config" string (i.e. operator
+ // doesn't have shell access to install the server via Amnezia's docker flow).
+ bool isThirdPartyConfig = false;
+
+ QJsonObject toJson() const;
+ static MasterDnsVpnServerConfig fromJson(const QJsonObject &json);
+};
+
+// Per-client config — what gets handed to a single end user's MasterDnsVPN
+// peer. Shares the singleton encryption key from the server config; differs
+// in resolver selection, local listening port, and runtime tuning.
+//
+// Everything is structured JSON — the native engine consumes these fields
+// directly. No TOML round-trip, no subprocess hand-off. The operator-side
+// renderer (e.g. awg-easy-rs) produces this exact shape; users without an
+// operator-side panel can synthesise it from the documented schema.
+struct MasterDnsVpnClientConfig
+{
+ // Local SOCKS5 listen port the engine binds for user-app traffic.
+ QString listenPort;
+
+ // Optional local SOCKS5 auth — empty disables.
+ QString socks5User;
+ QString socks5Pass;
+
+ // Public DNS resolvers the engine talks through. JSON array of strings,
+ // each "ip[:port]" or "[v6]:port".
+ QJsonArray resolvers;
+
+ // RESOLVER_BALANCING_STRATEGY (1..8) — matches upstream values:
+ // 1 = Random, 2 = Round Robin, 3 = Least Loss,
+ // 4 = Lowest Latency, 5 = Hybrid Score,
+ // 6 = Loss Then Latency, 7 = Least-Loss-Top-Random,
+ // 8 = Least-Loss-Top-Round-Robin.
+ int balancingStrategy = 5;
+
+ // Packet duplication factor for normal data packets (clamped 1..10
+ // by the engine) and setup packets (clamped to [packetDuplication, 12]).
+ int packetDuplication = 3;
+ int setupPacketDuplication = 4;
+
+ // Compression negotiation. 0 = off, 1 = ZSTD, 2 = LZ4, 3 = ZLIB.
+ int uploadCompression = 0;
+ int downloadCompression = 0;
+
+ // Free-form JSON object passed through verbatim. The engine surfaces
+ // unrecognised keys via debug logging; they don't break operation.
+ QJsonObject additionalConfig;
+
+ // Stable identity slot used by Amnezia's per-peer revoke / expiry UX.
+ // Mirrors XrayClientConfig::id.
+ QString id;
+
+ QJsonObject toJson() const;
+ static MasterDnsVpnClientConfig fromJson(const QJsonObject &json);
+};
+
+struct MasterDnsVpnProtocolConfig
+{
+ MasterDnsVpnServerConfig serverConfig;
+ std::optional clientConfig;
+
+ QJsonObject toJson() const;
+ static MasterDnsVpnProtocolConfig fromJson(const QJsonObject &json);
+
+ bool hasClientConfig() const;
+ void setClientConfig(const MasterDnsVpnClientConfig &config);
+ void clearClientConfig();
+};
+
+} // namespace amnezia
+
+#endif // MASTERDNSVPNPROTOCOLCONFIG_H
diff --git a/client/core/protocols/masterDnsVpnProtocol.cpp b/client/core/protocols/masterDnsVpnProtocol.cpp
new file mode 100644
index 0000000000..d7b58ae936
--- /dev/null
+++ b/client/core/protocols/masterDnsVpnProtocol.cpp
@@ -0,0 +1,287 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "masterDnsVpnProtocol.h"
+
+#include "core/protocols/protocolUtils.h"
+#include "core/utils/constants/configKeys.h"
+#include "core/utils/constants/protocolConstants.h"
+#include "core/utils/ipcClient.h"
+#include "core/utils/networkUtilities.h"
+#include "ipc.h"
+
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+#ifdef Q_OS_MACOS
+constexpr char kTunName[] = "utun24";
+#else
+constexpr char kTunName[] = "tun3";
+#endif
+} // namespace
+
+MasterDnsVpnProtocol::MasterDnsVpnProtocol(const QJsonObject &configuration, QObject *parent)
+ : VpnProtocol(configuration, parent)
+{
+ m_vpnGateway = amnezia::protocols::masterDnsVpn::defaultLocalAddr;
+ m_vpnLocalAddress = amnezia::protocols::masterDnsVpn::defaultLocalAddr;
+ m_routeGateway = NetworkUtilities::getGatewayAndIface().first;
+
+ m_routeMode = static_cast(
+ configuration.value(amnezia::configKey::splitTunnelType).toInt());
+ m_remoteAddress = NetworkUtilities::getIPAddress(
+ m_rawConfig.value(amnezia::configKey::hostName).toString());
+
+ const QString primaryDns = configuration.value(amnezia::configKey::dns1).toString();
+ if (!primaryDns.isEmpty()) {
+ m_dnsServers.push_back(QHostAddress(primaryDns));
+ }
+ if (primaryDns != amnezia::protocols::dns::amneziaDnsIp) {
+ const QString secondaryDns = configuration.value(amnezia::configKey::dns2).toString();
+ if (!secondaryDns.isEmpty()) {
+ m_dnsServers.push_back(QHostAddress(secondaryDns));
+ }
+ }
+
+ // The wrapped JSON the model stores is the full MasterDnsVpnProtocolConfig
+ // serialised form; the engine wants just the inner per-session blob.
+ // We pass the whole object through — the engine ignores keys it doesn't
+ // recognise (additionalConfig is preserved for future use).
+ m_engineConfig =
+ configuration.value(ProtocolUtils::key_proto_config_data(Proto::MasterDnsVpn))
+ .toObject();
+}
+
+MasterDnsVpnProtocol::~MasterDnsVpnProtocol()
+{
+ qDebug() << "MasterDnsVpnProtocol::~MasterDnsVpnProtocol()";
+ MasterDnsVpnProtocol::stop();
+}
+
+ErrorCode MasterDnsVpnProtocol::start()
+{
+ qDebug() << "MasterDnsVpnProtocol::start()";
+
+ if (m_engineConfig.isEmpty()) {
+ qCritical() << "MasterDnsVpn config wrapper is empty";
+ return ErrorCode::InternalError;
+ }
+
+ const QString configJson = QString::fromUtf8(
+ QJsonDocument(m_engineConfig).toJson(QJsonDocument::Compact));
+
+ return IpcClient::withInterface(
+ [&](QSharedPointer iface) {
+ auto startReply = iface->masterDnsVpnStart(configJson);
+ if (!startReply.waitForFinished() || !startReply.returnValue()) {
+ qCritical() << "Failed to start MasterDnsVpn engine in service";
+ return ErrorCode::InternalError;
+ }
+
+ auto portReply = iface->masterDnsVpnSocksPort();
+ if (!portReply.waitForFinished()) {
+ qCritical() << "Failed to fetch MasterDnsVpn SOCKS5 port";
+ return ErrorCode::InternalError;
+ }
+ const quint16 socksPort = portReply.returnValue();
+ if (socksPort == 0) {
+ qCritical() << "MasterDnsVpn engine did not bind a SOCKS5 port";
+ return ErrorCode::InternalError;
+ }
+
+ return startTun2Socks(socksPort);
+ },
+ []() { return ErrorCode::AmneziaServiceConnectionFailed; });
+}
+
+void MasterDnsVpnProtocol::stop()
+{
+ qDebug() << "MasterDnsVpnProtocol::stop()";
+
+ IpcClient::withInterface([](QSharedPointer iface) {
+ auto disableKillSwitch = iface->disableKillSwitch();
+ if (!disableKillSwitch.waitForFinished() || !disableKillSwitch.returnValue())
+ qWarning() << "Failed to disable killswitch";
+
+ auto StartRoutingIpv6 = iface->StartRoutingIpv6();
+ if (!StartRoutingIpv6.waitForFinished() || !StartRoutingIpv6.returnValue())
+ qWarning() << "Failed to start routing ipv6";
+
+ auto restoreResolvers = iface->restoreResolvers();
+ if (!restoreResolvers.waitForFinished() || !restoreResolvers.returnValue())
+ qWarning() << "Failed to restore resolvers";
+
+ auto deleteTun = iface->deleteTun(kTunName);
+ if (!deleteTun.waitForFinished() || !deleteTun.returnValue())
+ qWarning() << "Failed to delete tun";
+
+ auto stopReply = iface->masterDnsVpnStop();
+ if (!stopReply.waitForFinished() || !stopReply.returnValue())
+ qWarning() << "Failed to stop MasterDnsVpn engine in service";
+ });
+
+ if (m_tun2socksProcess) {
+ m_tun2socksProcess->blockSignals(true);
+#ifndef Q_OS_WIN
+ m_tun2socksProcess->terminate();
+ auto waitForFinished = m_tun2socksProcess->waitForFinished(1000);
+ if (!waitForFinished.waitForFinished() || !waitForFinished.returnValue()) {
+ qWarning() << "Failed to terminate tun2socks; killing the process";
+ m_tun2socksProcess->kill();
+ }
+#else
+ m_tun2socksProcess->kill();
+#endif
+ m_tun2socksProcess->close();
+ m_tun2socksProcess.reset();
+ }
+
+ setConnectionState(Vpn::ConnectionState::Disconnected);
+}
+
+ErrorCode MasterDnsVpnProtocol::startTun2Socks(quint16 socksPort)
+{
+ m_tun2socksProcess = IpcClient::CreatePrivilegedProcess();
+ if (!m_tun2socksProcess->waitForSource()) {
+ return ErrorCode::AmneziaServiceConnectionFailed;
+ }
+
+ const QString proxyUrl = QStringLiteral("socks5://127.0.0.1:%1").arg(socksPort);
+
+ m_tun2socksProcess->setProgram(amnezia::PermittedProcess::Tun2Socks);
+ m_tun2socksProcess->setArguments({ QStringLiteral("-device"),
+ QStringLiteral("tun://%1").arg(kTunName),
+ QStringLiteral("-proxy"), proxyUrl });
+
+ connect(m_tun2socksProcess.data(), &IpcProcessInterfaceReplica::readyReadStandardError, this,
+ [this]() {
+ auto readAllStandardError = m_tun2socksProcess->readAllStandardError();
+ if (!readAllStandardError.waitForFinished()) {
+ return;
+ }
+ const QString line = readAllStandardError.returnValue();
+ if (!line.contains("[TCP]") && !line.contains("[UDP]")) {
+ qDebug() << "[tun2socks]:" << line;
+ }
+ if (line.contains("[STACK] tun://") && line.contains("<-> socks5://")) {
+ disconnect(m_tun2socksProcess.data(),
+ &IpcProcessInterfaceReplica::readyReadStandardOutput, this, nullptr);
+
+ if (ErrorCode res = setupRouting(); res != ErrorCode::NoError) {
+ stop();
+ setLastError(res);
+ } else {
+ setConnectionState(Vpn::ConnectionState::Connected);
+ }
+ }
+ },
+ Qt::QueuedConnection);
+
+ connect(m_tun2socksProcess.data(), &IpcProcessInterfaceReplica::finished, this,
+ [this](int exitCode, QProcess::ExitStatus exitStatus) {
+ if (exitStatus == QProcess::ExitStatus::CrashExit) {
+ qCritical() << "Tun2socks crashed (mdnsvpn flow)";
+ } else {
+ qCritical() << "Tun2socks exited with" << exitCode << "(mdnsvpn flow)";
+ }
+ stop();
+ setLastError(ErrorCode::Tun2SockExecutableCrashed);
+ },
+ Qt::QueuedConnection);
+
+ m_tun2socksProcess->start();
+ return ErrorCode::NoError;
+}
+
+ErrorCode MasterDnsVpnProtocol::setupRouting()
+{
+ return IpcClient::withInterface(
+ [this](QSharedPointer iface) -> ErrorCode {
+#ifdef Q_OS_WIN
+ const int inetAdapterIndex =
+ NetworkUtilities::AdapterIndexTo(QHostAddress(m_remoteAddress));
+#endif
+ auto createTun = iface->createTun(kTunName,
+ amnezia::protocols::masterDnsVpn::defaultLocalAddr);
+ if (!createTun.waitForFinished() || !createTun.returnValue()) {
+ qCritical() << "Failed to assign IP address for TUN";
+ return ErrorCode::InternalError;
+ }
+
+ auto updateResolvers = iface->updateResolvers(kTunName, m_dnsServers);
+ if (!updateResolvers.waitForFinished() || !updateResolvers.returnValue()) {
+ qCritical() << "Failed to set DNS resolvers for TUN";
+ return ErrorCode::InternalError;
+ }
+
+#ifdef Q_OS_WIN
+ int vpnAdapterIndex = -1;
+ QList netInterfaces = QNetworkInterface::allInterfaces();
+ for (auto &netInterface : netInterfaces) {
+ for (auto &address : netInterface.addressEntries()) {
+ if (m_vpnLocalAddress == address.ip().toString())
+ vpnAdapterIndex = netInterface.index();
+ }
+ }
+#else
+ static const int vpnAdapterIndex = 0;
+#endif
+ const bool killSwitchEnabled = QVariant(
+ m_rawConfig.value(amnezia::configKey::killSwitchOption).toString())
+ .toBool();
+ if (killSwitchEnabled) {
+ if (vpnAdapterIndex != -1) {
+ QJsonObject config = m_rawConfig;
+ config.insert("vpnServer", m_remoteAddress);
+ auto enableKillSwitch =
+ IpcClient::Interface()->enableKillSwitch(config, vpnAdapterIndex);
+ if (!enableKillSwitch.waitForFinished() || !enableKillSwitch.returnValue()) {
+ qCritical() << "Failed to enable killswitch";
+ return ErrorCode::InternalError;
+ }
+ } else {
+ qWarning() << "Failed to get vpnAdapterIndex. Killswitch disabled";
+ }
+ }
+
+ if (m_routeMode == amnezia::RouteMode::VpnAllSites) {
+ static const QStringList subnets = { "1.0.0.0/8", "2.0.0.0/7", "4.0.0.0/6",
+ "8.0.0.0/5", "16.0.0.0/4", "32.0.0.0/3",
+ "64.0.0.0/2", "128.0.0.0/1" };
+ auto routeAddList = iface->routeAddList(m_vpnGateway, subnets);
+ if (!routeAddList.waitForFinished()
+ || routeAddList.returnValue() != subnets.count()) {
+ qCritical() << "Failed to set routes for TUN";
+ return ErrorCode::InternalError;
+ }
+ }
+
+ auto StopRoutingIpv6 = iface->StopRoutingIpv6();
+ if (!StopRoutingIpv6.waitForFinished() || !StopRoutingIpv6.returnValue()) {
+ qCritical() << "Failed to disable IPv6 routing";
+ return ErrorCode::InternalError;
+ }
+
+#ifdef Q_OS_WIN
+ if (inetAdapterIndex != -1 && vpnAdapterIndex != -1) {
+ QJsonObject config = m_rawConfig;
+ config.insert("inetAdapterIndex", inetAdapterIndex);
+ config.insert("vpnAdapterIndex", vpnAdapterIndex);
+ config.insert("vpnGateway", m_vpnGateway);
+ config.insert("vpnServer", m_remoteAddress);
+ auto enablePeerTraffic = iface->enablePeerTraffic(config);
+ if (!enablePeerTraffic.waitForFinished() || !enablePeerTraffic.returnValue()) {
+ qCritical() << "Failed to enable peer traffic";
+ return ErrorCode::InternalError;
+ }
+ } else {
+ qWarning() << "Failed to get adapter indexes. Split-tunneling disabled";
+ }
+#endif
+ return ErrorCode::NoError;
+ },
+ []() { return ErrorCode::AmneziaServiceConnectionFailed; });
+}
diff --git a/client/core/protocols/masterDnsVpnProtocol.h b/client/core/protocols/masterDnsVpnProtocol.h
new file mode 100644
index 0000000000..034b8af8f4
--- /dev/null
+++ b/client/core/protocols/masterDnsVpnProtocol.h
@@ -0,0 +1,40 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef MASTERDNSVPNPROTOCOL_H
+#define MASTERDNSVPNPROTOCOL_H
+
+#include
+#include
+#include
+#include
+
+#include "core/utils/commonStructs.h"
+#include "core/utils/errorCodes.h"
+#include "core/utils/ipcClient.h"
+#include "core/utils/routeModes.h"
+#include "vpnProtocol.h"
+
+class MasterDnsVpnProtocol : public VpnProtocol
+{
+ Q_OBJECT
+
+public:
+ MasterDnsVpnProtocol(const QJsonObject &configuration, QObject *parent = nullptr);
+ ~MasterDnsVpnProtocol() override;
+
+ ErrorCode start() override;
+ void stop() override;
+
+private:
+ ErrorCode startTun2Socks(quint16 socksPort);
+ ErrorCode setupRouting();
+
+ QJsonObject m_engineConfig;
+ amnezia::RouteMode m_routeMode = amnezia::RouteMode::VpnAllSites;
+ QList m_dnsServers;
+ QString m_remoteAddress;
+
+ QSharedPointer m_tun2socksProcess;
+};
+
+#endif // MASTERDNSVPNPROTOCOL_H
diff --git a/client/core/protocols/protocolUtils.cpp b/client/core/protocols/protocolUtils.cpp
index fe8a1454b1..65f9c83d92 100644
--- a/client/core/protocols/protocolUtils.cpp
+++ b/client/core/protocols/protocolUtils.cpp
@@ -64,6 +64,7 @@ QMap ProtocolUtils::protocolHumanNames()
{ Proto::Ikev2, "IKEv2" },
{ Proto::Xray, "XRay" },
{ Proto::SSXray, "Shadowsocks"},
+ { Proto::MasterDnsVpn, "MasterDnsVPN" },
{ Proto::TorWebSite, "Website in Tor network" },
{ Proto::Dns, "DNS Service" },
@@ -90,6 +91,7 @@ ServiceType ProtocolUtils::protocolService(Proto p)
case Proto::Awg: return ServiceType::Vpn;
case Proto::Ikev2: return ServiceType::Vpn;
case Proto::Xray: return ServiceType::Vpn;
+ case Proto::MasterDnsVpn: return ServiceType::Vpn;
case Proto::TorWebSite: return ServiceType::Other;
case Proto::Dns: return ServiceType::Other;
@@ -124,6 +126,7 @@ int ProtocolUtils::defaultPort(Proto p)
case Proto::WireGuard: return QString(protocols::wireguard::defaultPort).toInt();
case Proto::Awg: return QString(protocols::awg::defaultPort).toInt();
case Proto::Xray: return QString(protocols::xray::defaultPort).toInt();
+ case Proto::MasterDnsVpn: return QString(protocols::masterDnsVpn::defaultPort).toInt();
case Proto::Ikev2: return -1;
case Proto::TorWebSite: return -1;
@@ -145,6 +148,7 @@ bool ProtocolUtils::defaultPortChangeable(Proto p)
case Proto::Awg: return true;
case Proto::Ikev2: return false;
case Proto::Xray: return true;
+ case Proto::MasterDnsVpn: return true;
case Proto::TorWebSite: return false;
case Proto::Dns: return false;
@@ -166,6 +170,7 @@ TransportProto ProtocolUtils::defaultTransportProto(Proto p)
case Proto::Ikev2: return TransportProto::Udp;
case Proto::Xray: return TransportProto::Tcp;
case Proto::SSXray: return TransportProto::Tcp;
+ case Proto::MasterDnsVpn: return TransportProto::Udp; // tunnel envelopes ride DNS over UDP
// non-vpn
case Proto::TorWebSite: return TransportProto::Tcp;
@@ -187,6 +192,7 @@ bool ProtocolUtils::defaultTransportProtoChangeable(Proto p)
case Proto::Awg: return false;
case Proto::Ikev2: return false;
case Proto::Xray: return false;
+ case Proto::MasterDnsVpn: return false;
// non-vpn
case Proto::TorWebSite: return false;
diff --git a/client/core/protocols/vpnProtocol.cpp b/client/core/protocols/vpnProtocol.cpp
index 82d8b6befe..a3847437d3 100644
--- a/client/core/protocols/vpnProtocol.cpp
+++ b/client/core/protocols/vpnProtocol.cpp
@@ -5,6 +5,7 @@
#include "vpnProtocol.h"
#if defined(Q_OS_WINDOWS) || defined(Q_OS_MACX) and !defined MACOS_NE || (defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID))
+ #include "masterDnsVpnProtocol.h"
#include "openVpnProtocol.h"
#include "wireGuardProtocol.h"
#include "xrayProtocol.h"
@@ -119,6 +120,7 @@ VpnProtocol *VpnProtocol::factory(DockerContainer container, const QJsonObject &
case DockerContainer::Awg: return new WireguardProtocol(configuration);
case DockerContainer::Xray: return new XrayProtocol(configuration);
case DockerContainer::SSXray: return new XrayProtocol(configuration);
+ case DockerContainer::MasterDnsVpn: return new MasterDnsVpnProtocol(configuration);
#endif
default: return nullptr;
}
diff --git a/client/core/utils/constants/configKeys.h b/client/core/utils/constants/configKeys.h
index b896cdc372..fa18949dcc 100644
--- a/client/core/utils/constants/configKeys.h
+++ b/client/core/utils/constants/configKeys.h
@@ -117,6 +117,29 @@ namespace amnezia
constexpr QLatin1String amneziaAwg("amnezia-awg");
constexpr QLatin1String amneziaXray("amnezia-xray");
constexpr QLatin1String amneziaSsxray("amnezia-ssxray");
+ constexpr QLatin1String amneziaMasterDnsVpn("amnezia-masterdnsvpn");
+
+ // MasterDnsVPN-specific JSON keys for the protocol_config_data wrapper.
+ constexpr QLatin1String mdvDomains("domains");
+ constexpr QLatin1String mdvBind("bind");
+ constexpr QLatin1String mdvEncryptionMethod("encryptionMethod");
+ constexpr QLatin1String mdvEncryptionKey("encryptionKey");
+ constexpr QLatin1String mdvProtocolType("protocolType");
+ constexpr QLatin1String mdvDnsUpstreamServers("dnsUpstreamServers");
+ constexpr QLatin1String mdvForwardIp("forwardIp");
+ constexpr QLatin1String mdvForwardPort("forwardPort");
+ constexpr QLatin1String mdvUseExternalSocks5("useExternalSocks5");
+ constexpr QLatin1String mdvSocks5Auth("socks5Auth");
+ constexpr QLatin1String mdvSocks5User("socks5User");
+ constexpr QLatin1String mdvSocks5Pass("socks5Pass");
+ constexpr QLatin1String mdvAdditionalConfig("additionalConfig");
+ constexpr QLatin1String mdvListenPort("listenPort");
+ constexpr QLatin1String mdvResolvers("resolvers");
+ constexpr QLatin1String mdvBalancingStrategy("balancingStrategy");
+ constexpr QLatin1String mdvPacketDuplication("packetDuplication");
+ constexpr QLatin1String mdvSetupPacketDuplication("setupPacketDuplication");
+ constexpr QLatin1String mdvUploadCompression("uploadCompression");
+ constexpr QLatin1String mdvDownloadCompression("downloadCompression");
constexpr QLatin1String clientName("clientName");
constexpr QLatin1String userData("userData");
diff --git a/client/core/utils/constants/protocolConstants.h b/client/core/utils/constants/protocolConstants.h
index 5c65d881e1..de82f4cd28 100644
--- a/client/core/utils/constants/protocolConstants.h
+++ b/client/core/utils/constants/protocolConstants.h
@@ -209,6 +209,41 @@ namespace amnezia
constexpr char proxyConfigPath[] = "/usr/local/3proxy/conf/3proxy.cfg";
}
+ namespace masterDnsVpn
+ {
+ // Operator-facing TUN gateway / address. Mirrors xray's defaultLocalAddr —
+ // arbitrary RFC1918 range that won't collide with the operator's LAN.
+ constexpr char defaultLocalAddr[] = "10.34.0.2";
+
+ // Local SOCKS5 the bundled mdnsvpn client opens; tun2socks dials this.
+ constexpr char defaultLocalProxyPort[] = "18000";
+
+ // Default UDP port the operator's mdnsvpn server listens on (a NS-delegated
+ // tunnel subdomain points public resolvers here).
+ constexpr char defaultPort[] = "53";
+
+ // JSON keys in the mdnsvpn_config_data wrapper carried over the wire.
+ constexpr char domains[] = "domains";
+ constexpr char encryptionMethod[] = "encryptionMethod";
+ constexpr char encryptionKey[] = "encryptionKey";
+ constexpr char protocolType[] = "protocolType";
+ constexpr char resolvers[] = "resolvers";
+ constexpr char listenPort[] = "listenPort";
+ constexpr char socks5User[] = "socks5User";
+ constexpr char socks5Pass[] = "socks5Pass";
+ constexpr char additionalConfig[] = "additionalConfig";
+
+ // Encryption methods accepted by the mdnsvpn core.
+ // 0 = None, 1 = XOR, 2 = ChaCha20, 3..5 = AES-128/192/256-GCM.
+ constexpr int encryptionMethodNone = 0;
+ constexpr int encryptionMethodXor = 1;
+ constexpr int encryptionMethodChaCha20 = 2;
+ constexpr int encryptionMethodAes128Gcm = 3;
+ constexpr int encryptionMethodAes192Gcm = 4;
+ constexpr int encryptionMethodAes256Gcm = 5;
+ constexpr int defaultEncryptionMethod = encryptionMethodXor;
+ }
+
namespace mtProxy
{
constexpr char secretKey[] = "mtproxy_secret";
diff --git a/client/core/utils/containerEnum.h b/client/core/utils/containerEnum.h
index 986aff92f9..35b978f8a9 100644
--- a/client/core/utils/containerEnum.h
+++ b/client/core/utils/containerEnum.h
@@ -18,6 +18,7 @@ namespace amnezia
Ipsec,
Xray,
SSXray,
+ MasterDnsVpn,
// non-vpn
TorWebSite,
diff --git a/client/core/utils/containers/containerUtils.cpp b/client/core/utils/containers/containerUtils.cpp
index 29664408f1..2b0c75eaf7 100644
--- a/client/core/utils/containers/containerUtils.cpp
+++ b/client/core/utils/containers/containerUtils.cpp
@@ -68,6 +68,7 @@ QMap ContainerUtils::containerHumanNames()
{ DockerContainer::Xray, "XRay" },
{ DockerContainer::Ipsec, QObject::tr("IPsec") },
{ DockerContainer::SSXray, "Shadowsocks"},
+ { DockerContainer::MasterDnsVpn, QObject::tr("MasterDnsVPN") },
{ DockerContainer::TorWebSite, QObject::tr("Website in Tor network") },
{ DockerContainer::Dns, QObject::tr("AmneziaDNS") },
@@ -98,6 +99,10 @@ QMap ContainerUtils::containerDescriptions()
{ DockerContainer::Ipsec,
QObject::tr("IKEv2/IPsec - Modern stable protocol, a bit faster than others, restores connection after "
"signal loss. It has native support on the latest versions of Android and iOS.") },
+ { DockerContainer::MasterDnsVpn,
+ QObject::tr("MasterDnsVPN tunnels TCP traffic inside DNS queries that traverse public resolvers. "
+ "Designed to keep working when only DNS leaves the network — useful in heavily filtered or "
+ "captive-portal environments.") },
{ DockerContainer::TorWebSite, QObject::tr("Deploy a WordPress site on the Tor network in two clicks.") },
{ DockerContainer::Dns,
@@ -172,6 +177,17 @@ QMap ContainerUtils::containerDetailedDescriptions()
"* Minimal configuration required\n"
"* Detectable by DPI analysis systems(easily blocked)\n"
"* Operates over UDP protocol(ports 500 and 4500)") },
+ { DockerContainer::MasterDnsVpn,
+ QObject::tr("MasterDnsVPN is a DNS-tunnel transport: the client encrypts and fragments TCP traffic into "
+ "DNS queries that travel through public DNS resolvers, and the server listens on UDP/53 for "
+ "the tunnel envelopes via an NS-delegated subdomain. Optimised for harsh networks where only "
+ "DNS leaves the host (captive portals, deep filtering, lossy or paid mobile data).\n"
+ "\nFeatures:\n"
+ "* Survives blackout networks where only DNS resolves\n"
+ "* Encrypted with operator-chosen cipher (XOR / ChaCha20 / AES-128/192/256-GCM)\n"
+ "* Resilient to packet loss via ARQ retransmission and per-resolver MTU discovery\n"
+ "* Higher latency and lower throughput than direct VPN protocols (DNS-frame overhead)\n"
+ "* Operator must own a domain and create an NS delegation pointing to the server") },
{ DockerContainer::TorWebSite, QObject::tr("Website in Tor network") },
{ DockerContainer::Dns, QObject::tr("DNS Service") },
@@ -208,6 +224,7 @@ Proto ContainerUtils::defaultProtocol(DockerContainer c)
case DockerContainer::Xray: return Proto::Xray;
case DockerContainer::Ipsec: return Proto::Ikev2;
case DockerContainer::SSXray: return Proto::SSXray;
+ case DockerContainer::MasterDnsVpn: return Proto::MasterDnsVpn;
case DockerContainer::TorWebSite: return Proto::TorWebSite;
case DockerContainer::Dns: return Proto::Dns;
@@ -266,6 +283,7 @@ bool ContainerUtils::isSupportedByCurrentPlatform(DockerContainer c)
switch (c) {
case DockerContainer::WireGuard: return true;
case DockerContainer::Ipsec: return false;
+ case DockerContainer::MasterDnsVpn: return false; // pending macOS build of mdnsvpn binary
default: return true;
}
@@ -277,6 +295,7 @@ bool ContainerUtils::isSupportedByCurrentPlatform(DockerContainer c)
case DockerContainer::Awg: return true;
case DockerContainer::Xray: return true;
case DockerContainer::SSXray: return true;
+ case DockerContainer::MasterDnsVpn: return true;
case DockerContainer::MtProxy: return true;
case DockerContainer::Telemt: return true;
default: return false;
@@ -371,6 +390,7 @@ int ContainerUtils::installPageOrder(DockerContainer container)
case DockerContainer::Xray: return 3;
case DockerContainer::Ipsec: return 7;
case DockerContainer::SSXray: return 8;
+ case DockerContainer::MasterDnsVpn: return 9;
case DockerContainer::MtProxy:
case DockerContainer::Telemt:
return 20;
diff --git a/client/core/utils/protocolEnum.h b/client/core/utils/protocolEnum.h
index 19fdc67dce..842356d371 100644
--- a/client/core/utils/protocolEnum.h
+++ b/client/core/utils/protocolEnum.h
@@ -25,6 +25,7 @@ namespace amnezia
Ikev2,
Xray,
SSXray,
+ MasterDnsVpn,
// non-vpn
TorWebSite,
diff --git a/client/masterdnsvpn/android_jni.cpp b/client/masterdnsvpn/android_jni.cpp
new file mode 100644
index 0000000000..8726cf3ec6
--- /dev/null
+++ b/client/masterdnsvpn/android_jni.cpp
@@ -0,0 +1,141 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// JNI bridge for the Android VpnService → native MasterDnsVPN engine path.
+//
+// Compiled into the main Qt-for-Android shared library (the same .so the
+// Java loader pulls in for the Activity), so no extra `loadLibrary` call
+// is needed on the Kotlin side beyond what Qt already does.
+//
+// The Android architecture is meaningfully different from desktop:
+// * No privileged service daemon — the VpnService runs in the same
+// process as the activity / Qt SO.
+// * Engine runs in-process inside that same SO.
+// * tun2socks is provided by libxray.aar (already a dep) and is
+// called from Kotlin, which feeds it the SOCKS5 port we expose
+// here.
+
+#include "engine.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+
+// One Engine per JVM process — the Android UX is "one tunnel at a time"
+// and the engine is heavy enough that we don't want multiple instances.
+std::mutex g_engineMutex;
+std::unique_ptr g_engine;
+
+QString jstringToQString(JNIEnv *env, jstring s)
+{
+ if (!s) {
+ return {};
+ }
+ const char *raw = env->GetStringUTFChars(s, nullptr);
+ QString out = QString::fromUtf8(raw);
+ env->ReleaseStringUTFChars(s, raw);
+ return out;
+}
+
+} // namespace
+
+extern "C" {
+
+// ---- Lifecycle -----------------------------------------------------------
+//
+// Method signature mapping (mangled JNI symbol -> Kotlin):
+//
+// org.amnezia.vpn.protocol.masterdnsvpn.MasterDnsVpnNative.nativeStart
+// (Ljava/lang/String;)Z
+//
+JNIEXPORT jboolean JNICALL
+Java_org_amnezia_vpn_protocol_masterdnsvpn_MasterDnsVpnNative_nativeStart(JNIEnv *env,
+ jclass /*clazz*/,
+ jstring configJson)
+{
+ const QString json = jstringToQString(env, configJson);
+ QJsonParseError err {};
+ const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8(), &err);
+ if (err.error != QJsonParseError::NoError || !doc.isObject()) {
+ qWarning() << "masterdnsvpn JNI: bad config JSON:" << err.errorString();
+ return JNI_FALSE;
+ }
+
+ std::lock_guard lock(g_engineMutex);
+ if (g_engine) {
+ g_engine->stop();
+ g_engine.reset();
+ }
+ g_engine = std::make_unique();
+ if (!g_engine->start(doc.object())) {
+ qWarning() << "masterdnsvpn JNI: engine start failed:" << g_engine->lastError();
+ g_engine.reset();
+ return JNI_FALSE;
+ }
+ return JNI_TRUE;
+}
+
+JNIEXPORT void JNICALL
+Java_org_amnezia_vpn_protocol_masterdnsvpn_MasterDnsVpnNative_nativeStop(JNIEnv * /*env*/,
+ jclass /*clazz*/)
+{
+ std::lock_guard lock(g_engineMutex);
+ if (g_engine) {
+ g_engine->stop();
+ g_engine.reset();
+ }
+}
+
+JNIEXPORT jint JNICALL
+Java_org_amnezia_vpn_protocol_masterdnsvpn_MasterDnsVpnNative_nativeSocksPort(JNIEnv * /*env*/,
+ jclass /*clazz*/)
+{
+ std::lock_guard lock(g_engineMutex);
+ return g_engine ? static_cast(g_engine->socksPort()) : 0;
+}
+
+// State enum returned to Kotlin. Mirrors Engine::State integer values so
+// the Kotlin side can treat them as a plain ordinal. Callers should only
+// rely on the relative ordering (Connected = positive, Failed = negative).
+JNIEXPORT jint JNICALL
+Java_org_amnezia_vpn_protocol_masterdnsvpn_MasterDnsVpnNative_nativeState(JNIEnv * /*env*/,
+ jclass /*clazz*/)
+{
+ std::lock_guard lock(g_engineMutex);
+ return g_engine ? static_cast(g_engine->state())
+ : static_cast(amnezia::masterdnsvpn::Engine::State::Idle);
+}
+
+JNIEXPORT jstring JNICALL
+Java_org_amnezia_vpn_protocol_masterdnsvpn_MasterDnsVpnNative_nativeLastError(JNIEnv *env,
+ jclass /*clazz*/)
+{
+ std::lock_guard lock(g_engineMutex);
+ if (!g_engine) {
+ return env->NewStringUTF("");
+ }
+ return env->NewStringUTF(g_engine->lastError().toUtf8().constData());
+}
+
+JNIEXPORT jlong JNICALL
+Java_org_amnezia_vpn_protocol_masterdnsvpn_MasterDnsVpnNative_nativeBytesReceived(
+ JNIEnv * /*env*/, jclass /*clazz*/)
+{
+ std::lock_guard lock(g_engineMutex);
+ return g_engine ? static_cast(g_engine->bytesReceived()) : 0;
+}
+
+JNIEXPORT jlong JNICALL
+Java_org_amnezia_vpn_protocol_masterdnsvpn_MasterDnsVpnNative_nativeBytesSent(JNIEnv * /*env*/,
+ jclass /*clazz*/)
+{
+ std::lock_guard lock(g_engineMutex);
+ return g_engine ? static_cast(g_engine->bytesSent()) : 0;
+}
+
+} // extern "C"
diff --git a/client/masterdnsvpn/arq.cpp b/client/masterdnsvpn/arq.cpp
new file mode 100644
index 0000000000..3d4cce64d2
--- /dev/null
+++ b/client/masterdnsvpn/arq.cpp
@@ -0,0 +1,880 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "arq.h"
+
+#include
+#include
+#include
+#include
+
+namespace amnezia::masterdnsvpn {
+
+namespace {
+
+// Spec §6.4 — RTO growth multipliers.
+constexpr double kDataGrowthFactor = 1.35;
+constexpr double kControlGrowthFactor = 1.25;
+
+// Backpressure threshold (spec §6.2): we accept new bytes from the
+// application as long as the in-flight count stays below this fraction of
+// the negotiated window.
+constexpr double kBackpressureFraction = 0.8;
+
+// Spec §6.5 — the front-budget retransmit selector.
+int frontBudget(int window, int jobsCount)
+{
+ return std::min({ std::max(window / 10, 1), 64, jobsCount });
+}
+
+qint64 clamp64(qint64 v, qint64 lo, qint64 hi)
+{
+ return std::max(lo, std::min(hi, v));
+}
+
+} // namespace
+
+// ---------------------------------------------------------------------------
+// Construction
+// ---------------------------------------------------------------------------
+
+ArqStream::ArqStream(quint16 streamId, const ArqConfig &cfg, Sink outboundSink, DeliverySink deliverySink)
+ : m_streamId(streamId), m_cfg(cfg), m_sink(std::move(outboundSink)), m_deliver(std::move(deliverySink))
+{
+ // Spec §6.2 — window floor 300.
+ if (m_cfg.windowSize < 300) {
+ m_cfg.windowSize = 300;
+ }
+ // Spec §6.4 — RTO floor 50 ms on both sides.
+ m_cfg.initialDataRtoMs = std::max(50, m_cfg.initialDataRtoMs);
+ m_cfg.maxDataRtoMs = std::max(m_cfg.initialDataRtoMs, m_cfg.maxDataRtoMs);
+ m_cfg.initialControlRtoMs = std::max(50, m_cfg.initialControlRtoMs);
+ m_cfg.maxControlRtoMs = std::max(m_cfg.initialControlRtoMs, m_cfg.maxControlRtoMs);
+
+ m_currentDataRtoMs = m_cfg.initialDataRtoMs;
+ m_currentControlRtoMs = m_cfg.initialControlRtoMs;
+}
+
+bool ArqStream::isTerminal() const
+{
+ return m_state == ArqState::Closed || m_state == ArqState::Reset;
+}
+
+int ArqStream::inFlightCount() const
+{
+ return m_sndBuf.size();
+}
+
+// ---------------------------------------------------------------------------
+// Sequence helpers (uint16 wrap-around)
+// ---------------------------------------------------------------------------
+
+bool ArqStream::isAhead(quint16 sn, quint16 baseline) const
+{
+ // Per §3.5: diff < 32768 means `sn` is ahead of `baseline` in the
+ // wrap-around sequence space.
+ return ((static_cast(sn - baseline)) < 0x8000);
+}
+
+// ---------------------------------------------------------------------------
+// Application -> network
+// ---------------------------------------------------------------------------
+
+qsizetype ArqStream::writeApp(const QByteArray &bytes)
+{
+ if (m_state != ArqState::Open && m_state != ArqState::HalfClosedRemote) {
+ return 0;
+ }
+ // Backpressure: hold the writer when the in-flight set is full.
+ const int inFlightCap = static_cast(m_cfg.windowSize * kBackpressureFraction);
+ if (m_sndBuf.size() >= std::max(inFlightCap, 50)) {
+ return 0;
+ }
+
+ // Single-fragment send for now — the framing layer chunks via the
+ // resolver MTU. Fragmentation extension is honoured but the dispatcher
+ // sets fragmentId/totalFragments from MTU discovery output.
+ PendingSend pending;
+ pending.seq = m_sndNxt++;
+ pending.payload = bytes;
+ pending.type = PacketType::StreamData;
+ pending.firstSentMs = 0;
+ pending.lastSentMs = 0;
+ pending.sampleEligible = true;
+ m_sndBuf.insert(pending.seq, pending);
+
+ Packet p;
+ p.type = PacketType::StreamData;
+ p.streamId = m_streamId;
+ p.sequenceNum = pending.seq;
+ p.fragmentId = 0;
+ p.totalFragments = 1;
+ p.compression = 0;
+ p.payload = bytes;
+ dispatch(p, /*retransmit=*/false);
+
+ return bytes.size();
+}
+
+void ArqStream::halfCloseWrite()
+{
+ if (m_localClosedWrite || isTerminal()) {
+ return;
+ }
+ m_localClosedWrite = true;
+
+ // Send STREAM_CLOSE_WRITE (control packet, allocates a sequence).
+ Packet p;
+ p.type = PacketType::StreamCloseWrite;
+ p.streamId = m_streamId;
+ const quint16 seq = m_sndNxt++;
+ p.sequenceNum = seq;
+ dispatch(p, /*retransmit=*/false);
+ trackControlSent(PacketType::StreamCloseWrite, seq, 0);
+
+ if (m_state == ArqState::HalfClosedRemote) {
+ m_state = ArqState::Closing;
+ } else {
+ m_state = ArqState::HalfClosedLocal;
+ }
+}
+
+void ArqStream::reset()
+{
+ if (isTerminal()) {
+ return;
+ }
+ Packet p;
+ p.type = PacketType::StreamRst;
+ p.streamId = m_streamId;
+ const quint16 seq = m_sndNxt++;
+ p.sequenceNum = seq;
+ dispatch(p, /*retransmit=*/false);
+ trackControlSent(PacketType::StreamRst, seq, 0);
+ m_state = ArqState::Reset;
+}
+
+// ---------------------------------------------------------------------------
+// Network -> application
+// ---------------------------------------------------------------------------
+
+void ArqStream::onPacketReceived(const Packet &pkt)
+{
+ if (!pkt.streamId || *pkt.streamId != m_streamId) {
+ return;
+ }
+ m_lastActivityMs = 0; // refreshed by tickMs
+
+ switch (pkt.type) {
+ case PacketType::StreamData:
+ case PacketType::StreamResend:
+ onDataPacket(pkt);
+ break;
+ case PacketType::StreamDataAck:
+ if (pkt.sequenceNum) onAck(*pkt.sequenceNum);
+ break;
+ case PacketType::StreamDataNack:
+ // Inbound NACK: route through HandleDataNack, which honours the
+ // per-seq cooldown and does NOT bump retries / RTO. (Upstream
+ // semantics — `internal/arq/arq.go::HandleDataNack`.)
+ if (pkt.sequenceNum) HandleDataNack(*pkt.sequenceNum);
+ break;
+ case PacketType::StreamCloseRead:
+ onCloseRead();
+ break;
+ case PacketType::StreamCloseWrite:
+ onCloseWrite();
+ break;
+ case PacketType::StreamRst:
+ onRst();
+ break;
+ default:
+ // Other packet types (SYN/SYN_ACK, half-close ACKs, etc.) are
+ // handled by the session-level state machine; ARQ ignores them.
+ break;
+ }
+}
+
+void ArqStream::onDataPacket(const Packet &pkt)
+{
+ if (!pkt.sequenceNum) {
+ return;
+ }
+ const quint16 sn = *pkt.sequenceNum;
+ const qint64 nowMs = QDateTime::currentMSecsSinceEpoch();
+
+ // Behind rcvNxt → duplicate: emit a refreshing ACK so the peer can
+ // retire the sndBuf entry, then drop the payload. (Upstream emits
+ // an ACK in this branch and returns immediately —
+ // internal/arq/arq.go:1576-1583.)
+ if (!isAhead(sn, m_rcvNxt) && sn != m_rcvNxt) {
+ emitDataAck(sn);
+ return;
+ }
+
+ // Receive-window cap. Upstream maintains
+ // `receiveWindowSize = 2 * windowSize` and drops seqs that would
+ // expand rcvBuf beyond that bound. Two distinct checks:
+ // (a) the seq itself is too far ahead of rcvNxt (would overrun
+ // the window), and
+ // (b) the rcvBuf is already at capacity and this seq isn't the
+ // in-order frontier.
+ // Either rejects the packet outright (no ACK, no buffering) — the
+ // sender will retry once its RTO fires. Mirrors
+ // internal/arq/arq.go:1586-1595.
+ const int receiveWindowSize = m_cfg.windowSize * 2;
+ const quint16 diff = static_cast(sn - m_rcvNxt);
+ if (static_cast(diff) > receiveWindowSize) {
+ return;
+ }
+ if (sn != m_rcvNxt
+ && !m_rcvBuf.contains(sn)
+ && m_rcvBuf.size() >= receiveWindowSize) {
+ return;
+ }
+
+ // Always ACK on accept — duplicates already returned above.
+ emitDataAck(sn);
+
+ if (sn == m_rcvNxt) {
+ // In-order: deliver immediately, advance, drain backlog.
+ if (!pkt.payload.isEmpty()) {
+ ArqDelivery d;
+ d.bytes = pkt.payload;
+ m_deliver(d);
+ }
+ ++m_rcvNxt;
+ deliverContiguous();
+ // After rcvNxt advances, NACK state for now-resolved seqs must
+ // be dropped so subsequent gaps can re-arm them. Mirrors upstream
+ // post-delivery cleanup via clearSentDataNack on each filled gap.
+ pruneDataNackStateLocked(m_rcvNxt);
+ } else {
+ // Out-of-order: buffer the payload, then run the bounded NACK
+ // path. m_rcvBuf must be populated FIRST so maybeSendDataNacks
+ // can skip already-buffered seqs in the gap (per upstream).
+ m_rcvBuf.insert(sn, { sn, pkt.payload });
+ maybeSendDataNacks(sn, nowMs);
+ }
+}
+
+void ArqStream::deliverContiguous()
+{
+ while (true) {
+ auto it = m_rcvBuf.find(m_rcvNxt);
+ if (it == m_rcvBuf.end()) {
+ return;
+ }
+ if (!it->payload.isEmpty()) {
+ ArqDelivery d;
+ d.bytes = it->payload;
+ m_deliver(d);
+ }
+ m_rcvBuf.erase(it);
+ ++m_rcvNxt;
+ }
+}
+
+void ArqStream::onAck(quint16 ackedSeq)
+{
+ auto it = m_sndBuf.find(ackedSeq);
+ if (it == m_sndBuf.end()) {
+ return; // duplicate or already-ACKed
+ }
+ if (it->sampleEligible && it->firstSentMs > 0) {
+ // Karn's algorithm: only sample RTT for packets that were sent
+ // exactly once (sampleEligible flips to false on any retransmit
+ // or HandleDataNack-driven resend). Mirrors upstream
+ // `ARQ.noteSuccessfulDataSample` (internal/arq/arq.go:1858).
+ const qint64 nowMs = QDateTime::currentMSecsSinceEpoch();
+ const qint64 sampleMs = nowMs - it->firstSentMs;
+ if (sampleMs > 0) {
+ updateRttSample(sampleMs, /*isControl=*/false);
+ }
+ }
+ m_sndBuf.erase(it);
+}
+
+// ---------------------------------------------------------------------------
+// Inbound STREAM_DATA_NACK path
+// ---------------------------------------------------------------------------
+//
+// Receipt of a peer's STREAM_DATA_NACK is dispatched through
+// HandleDataNack (the receive-side public wrapper) — see
+// onPacketReceived → HandleDataNack. The retransmit semantics are
+// described there: no retries bump, no RTO growth, with a per-seq
+// cooldown gate. The RTO-driven retransmit path lives in
+// scheduleRetransmits and follows separate semantics (does bump
+// retries + grow RTO per §6.4/§6.5).
+
+void ArqStream::onCloseRead()
+{
+ // Peer signals "won't send more data" — drain any remaining bytes
+ // already in m_rcvBuf, then signal EOF upstream.
+ deliverContiguous();
+ ArqDelivery eof;
+ eof.endOfStream = true;
+ m_deliver(eof);
+}
+
+void ArqStream::onCloseWrite()
+{
+ m_remoteClosedWrite = true;
+ deliverContiguous();
+ ArqDelivery eof;
+ eof.endOfStream = true;
+ m_deliver(eof);
+
+ if (m_state == ArqState::HalfClosedLocal) {
+ m_state = ArqState::Closing;
+ } else if (m_state == ArqState::Open) {
+ m_state = ArqState::HalfClosedRemote;
+ }
+}
+
+void ArqStream::onRst()
+{
+ m_state = ArqState::Reset;
+ m_sndBuf.clear();
+ m_rcvBuf.clear();
+ m_controlSndBuf.clear();
+}
+
+// ---------------------------------------------------------------------------
+// Periodic tick
+// ---------------------------------------------------------------------------
+
+void ArqStream::tickMs(qint64 nowMs)
+{
+ if (m_lastActivityMs == 0) {
+ m_lastActivityMs = nowMs;
+ }
+ if (nowMs - m_lastActivityMs > m_cfg.inactivityTimeoutMs) {
+ // Spec §6.8 — terminate on inactivity.
+ reset();
+ return;
+ }
+ if (isTerminal()) {
+ return;
+ }
+
+ scheduleRetransmits(nowMs);
+ scheduleControlRetransmits(nowMs);
+
+ // Terminal-drain watchdog.
+ if (m_state == ArqState::Closing && m_sndBuf.isEmpty()) {
+ if (m_terminalStartMs == 0) {
+ m_terminalStartMs = nowMs;
+ }
+ if (nowMs - m_terminalStartMs > m_cfg.terminalAckWaitMs) {
+ m_state = ArqState::Closed;
+ }
+ }
+}
+
+void ArqStream::scheduleRetransmits(qint64 nowMs)
+{
+ QVector due;
+ for (auto it = m_sndBuf.begin(); it != m_sndBuf.end(); ++it) {
+ if (it->lastSentMs == 0) {
+ // Not actually transmitted yet — first send happened via
+ // emit() but the dispatcher may not have called tickMs since.
+ // Set the timestamp now; the next tick checks RTO from here.
+ it->lastSentMs = nowMs;
+ if (it->firstSentMs == 0) {
+ it->firstSentMs = nowMs;
+ }
+ continue;
+ }
+ const qint64 rto = currentRto(/*isControl=*/false);
+ if (nowMs - it->lastSentMs >= rto) {
+ due.append(it.key());
+ }
+ }
+
+ if (due.isEmpty()) {
+ return;
+ }
+
+ // §6.5 — front-budget priority. Sort by sequence (oldest first).
+ std::sort(due.begin(), due.end());
+
+ const int budget = frontBudget(m_cfg.windowSize, due.size());
+ for (int i = 0; i < due.size(); ++i) {
+ const quint16 seq = due[i];
+ auto it = m_sndBuf.find(seq);
+ if (it == m_sndBuf.end()) {
+ continue;
+ }
+ ++it->retries;
+ if (it->retries > m_cfg.maxDataRetries) {
+ // §6.8 — terminate after max retries.
+ reset();
+ return;
+ }
+ it->sampleEligible = false;
+ it->lastSentMs = nowMs;
+
+ // Backoff.
+ m_currentDataRtoMs = clamp64(static_cast(m_currentDataRtoMs * kDataGrowthFactor),
+ m_cfg.initialDataRtoMs,
+ m_cfg.maxDataRtoMs);
+
+ Packet p;
+ p.type = (i < budget) ? PacketType::StreamResend : PacketType::StreamData;
+ p.streamId = m_streamId;
+ p.sequenceNum = seq;
+ p.fragmentId = 0;
+ p.totalFragments = 1;
+ p.compression = 0;
+ p.payload = it->payload;
+ dispatch(p, /*retransmit=*/true);
+ }
+}
+
+void ArqStream::scheduleControlRetransmits(qint64 nowMs)
+{
+ // Walk the per-control-packet send buffer and re-emit entries whose
+ // control RTO has expired. Mirrors the data-plane scheduleRetransmits
+ // (same RTO-doubling + max-retries terminate semantics) but uses the
+ // control-RTO knobs and never reads from the data-plane sndBuf.
+ if (!m_cfg.enableControlReliability || m_controlSndBuf.isEmpty()) {
+ return;
+ }
+
+ QVector due;
+ for (auto it = m_controlSndBuf.begin(); it != m_controlSndBuf.end(); ++it) {
+ if (it->lastSentMs == 0) {
+ it->lastSentMs = nowMs;
+ if (it->firstSentMs == 0) {
+ it->firstSentMs = nowMs;
+ }
+ continue;
+ }
+ if (nowMs - it->lastSentMs >= currentRto(/*isControl=*/true)) {
+ due.append(it.key());
+ }
+ }
+
+ if (due.isEmpty()) {
+ return;
+ }
+
+ for (const quint32 key : due) {
+ auto it = m_controlSndBuf.find(key);
+ if (it == m_controlSndBuf.end()) {
+ continue;
+ }
+ ++it->retries;
+ if (it->retries > m_cfg.maxControlRetries) {
+ // Spec §6.8 — terminate after max retries.
+ reset();
+ return;
+ }
+ it->sampleEligible = false;
+ it->lastSentMs = nowMs;
+
+ // Backoff — control RTO grows at the control-side factor.
+ m_currentControlRtoMs = clamp64(
+ static_cast(m_currentControlRtoMs * kControlGrowthFactor),
+ m_cfg.initialControlRtoMs, m_cfg.maxControlRtoMs);
+
+ Packet p;
+ p.type = it->type;
+ p.streamId = m_streamId;
+ p.sequenceNum = it->seq;
+ dispatch(p, /*retransmit=*/true);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Outbound emit + control helpers
+// ---------------------------------------------------------------------------
+
+void ArqStream::dispatch(const Packet &pkt, bool retransmit)
+{
+ if (m_sink) {
+ m_sink({ pkt, retransmit });
+ }
+}
+
+void ArqStream::emitDataAck(quint16 seq)
+{
+ Packet p;
+ p.type = PacketType::StreamDataAck;
+ p.streamId = m_streamId;
+ p.sequenceNum = seq;
+ dispatch(p, false);
+}
+
+// ---------------------------------------------------------------------------
+// Bounded NACK gap + frontier sampling (§6.7)
+// ---------------------------------------------------------------------------
+//
+// On every out-of-order arrival we compute the set of still-missing seqs
+// in the gap [rcvNxt..sn) and emit at most one STREAM_DATA_NACK per
+// missing seq, subject to a per-seq cooldown and the dataNackMaxGap
+// bound. Two regimes:
+//
+// 1. Gap fits in dataNackMaxGap: walk the gap inline, skip any seqs
+// already buffered in m_rcvBuf, NACK the rest.
+// 2. Gap exceeds the bound: NACK a small recent-window sample
+// (sampleCount ≈ 5% of dataNackMaxGap, floor 1), then one frontier
+// seq at the trailing edge of the window. This bounds NACK traffic
+// when many seqs are missing simultaneously.
+//
+// Mirrors upstream `ARQ.maybeSendDataNacks` (internal/arq/arq.go:1919).
+void ArqStream::maybeSendDataNacks(quint16 sn, qint64 nowMs)
+{
+ if (m_cfg.dataNackMaxGap <= 0) {
+ return;
+ }
+ if (m_state == ArqState::Closed || m_state == ArqState::Reset) {
+ return;
+ }
+
+ // diff is the wrap-aware distance from rcvNxt to sn (sn must be
+ // strictly ahead). diff in [1, 32767] means "sn is ahead of rcvNxt";
+ // diff in [32768, 65535] means "sn is behind". diff == 0 → no gap.
+ const quint16 diff = static_cast(sn - m_rcvNxt);
+ if (diff == 0 || diff >= 32768) {
+ return;
+ }
+
+ pruneDataNackStateLocked(m_rcvNxt);
+
+ const quint16 windowSpan = static_cast(m_cfg.dataNackMaxGap);
+ QVector missingSeqs;
+ missingSeqs.reserve(m_cfg.dataNackMaxGap);
+
+ if (diff <= windowSpan) {
+ // Full-walk path: every seq in [rcvNxt..sn) that isn't already
+ // buffered gets enqueued as a NACK candidate.
+ for (quint16 missing = m_rcvNxt; missing != sn; ++missing) {
+ if (m_rcvBuf.contains(missing)) {
+ continue;
+ }
+ missingSeqs.append(missing);
+ }
+ } else {
+ // Frontier-sample path: cap the candidates at sampleCount from
+ // the head + one frontier seq at the trailing window edge.
+ const int sampleCount = std::max(1, (m_cfg.dataNackMaxGap + 19) / 20);
+ QSet seen;
+ seen.reserve(std::max(2, m_cfg.dataNackMaxGap / 20 + 1));
+
+ int added = 0;
+ for (quint16 missing = m_rcvNxt;
+ missing != sn && added < sampleCount;
+ ++missing) {
+ if (m_rcvBuf.contains(missing)) {
+ continue;
+ }
+ missingSeqs.append(missing);
+ seen.insert(missing);
+ ++added;
+ }
+
+ // The frontier: the trailing edge of the bounded NACK window —
+ // either dataNackMaxGap-1 ahead of rcvNxt, or the first unbuffered
+ // seq scanning down from there.
+ const quint16 frontier = static_cast(
+ static_cast(m_rcvNxt) + static_cast(windowSpan) - 1);
+ for (quint16 candidate = frontier;; --candidate) {
+ if (!m_rcvBuf.contains(candidate)) {
+ if (!seen.contains(candidate)) {
+ missingSeqs.append(candidate);
+ }
+ break;
+ }
+ if (candidate == m_rcvNxt) {
+ break;
+ }
+ }
+ }
+
+ for (quint16 missing : missingSeqs) {
+ if (!shouldSendDataNack(missing, nowMs)) {
+ continue;
+ }
+ Packet p;
+ p.type = PacketType::StreamDataNack;
+ p.streamId = m_streamId;
+ p.sequenceNum = missing;
+ dispatch(p, /*retransmit=*/false);
+ noteDataNackSent(missing, nowMs);
+ }
+}
+
+bool ArqStream::shouldSendDataNack(quint16 sn, qint64 nowMs)
+{
+ auto firstIt = m_firstDataNackSeenMs.find(sn);
+ if (firstIt == m_firstDataNackSeenMs.end()) {
+ // First observation of this missing seq — record the moment, then
+ // gate on initial-delay (zero → send immediately; positive → wait).
+ m_firstDataNackSeenMs.insert(sn, nowMs);
+ return m_cfg.dataNackInitialDelayMs <= 0;
+ }
+ if (m_cfg.dataNackInitialDelayMs > 0
+ && (nowMs - firstIt.value()) < m_cfg.dataNackInitialDelayMs) {
+ return false;
+ }
+ auto lastIt = m_lastNackSentMs.find(sn);
+ if (lastIt == m_lastNackSentMs.end()) {
+ return true;
+ }
+ return (nowMs - lastIt.value()) >= m_cfg.dataNackRepeatMs;
+}
+
+bool ArqStream::seqBehind(quint16 base, quint16 candidate)
+{
+ if (candidate == base) {
+ return false;
+ }
+ return static_cast(base - candidate) < 0x8000;
+}
+
+void ArqStream::pruneDataNackStateLocked(quint16 rcvNxt)
+{
+ for (auto it = m_firstDataNackSeenMs.begin(); it != m_firstDataNackSeenMs.end();) {
+ if (seqBehind(rcvNxt, it.key())) {
+ it = m_firstDataNackSeenMs.erase(it);
+ } else {
+ ++it;
+ }
+ }
+ for (auto it = m_lastNackSentMs.begin(); it != m_lastNackSentMs.end();) {
+ if (seqBehind(rcvNxt, it.key())) {
+ it = m_lastNackSentMs.erase(it);
+ } else {
+ ++it;
+ }
+ }
+}
+
+void ArqStream::emitControl(PacketType type, quint16 seq)
+{
+ Packet p;
+ p.type = type;
+ p.streamId = m_streamId;
+ p.sequenceNum = seq;
+ dispatch(p, false);
+}
+
+// ---------------------------------------------------------------------------
+// RTT sampling (§6.4)
+// ---------------------------------------------------------------------------
+
+void ArqStream::updateRttSample(qint64 sampleMs, bool isControl)
+{
+ qint64 &srtt = isControl ? m_controlSrttMs : m_dataSrttMs;
+ qint64 &rttvar = isControl ? m_controlRttvarMs : m_dataRttvarMs;
+ qint64 ¤tRto = isControl ? m_currentControlRtoMs : m_currentDataRtoMs;
+ const qint64 initialRto = isControl ? m_cfg.initialControlRtoMs : m_cfg.initialDataRtoMs;
+ const qint64 maxRto = isControl ? m_cfg.maxControlRtoMs : m_cfg.maxDataRtoMs;
+
+ if (srtt == 0) {
+ // First sample.
+ srtt = sampleMs;
+ rttvar = sampleMs / 2;
+ } else {
+ const qint64 delta = std::abs(srtt - sampleMs);
+ rttvar = (3 * rttvar + delta) / 4;
+ srtt = (7 * srtt + sampleMs) / 8;
+ }
+ currentRto = clamp64(srtt + 4 * rttvar, initialRto, maxRto);
+}
+
+qint64 ArqStream::currentRto(bool isControl) const
+{
+ return isControl ? m_currentControlRtoMs : m_currentDataRtoMs;
+}
+
+// ---------------------------------------------------------------------------
+// Upstream-API-shaped wrappers (parity surface for translated tests)
+// ---------------------------------------------------------------------------
+
+bool ArqStream::ReceiveData(quint16 sn, const QByteArray &data)
+{
+ Packet pkt;
+ pkt.type = PacketType::StreamData;
+ pkt.streamId = m_streamId;
+ pkt.sequenceNum = sn;
+ pkt.payload = data;
+ onPacketReceived(pkt);
+ return true;
+}
+
+bool ArqStream::ReceiveAck(PacketType packetType, quint16 sn)
+{
+ Packet pkt;
+ pkt.type = packetType;
+ pkt.streamId = m_streamId;
+ pkt.sequenceNum = sn;
+ const int before = inFlightCount();
+ onPacketReceived(pkt);
+ return inFlightCount() < before;
+}
+
+bool ArqStream::HandleDataNack(quint16 sn)
+{
+ // Mirrors upstream `ARQ.HandleDataNack` (internal/arq/arq.go:1873).
+ // Receipt of an inbound STREAM_DATA_NACK schedules ONE immediate
+ // STREAM_RESEND for the named seq, gated by a per-seq cooldown
+ // (dataNackRepeatMs). It is NOT a retransmit in the RTO sense —
+ // retries and currentRto are left unchanged; only sampleEligible
+ // flips to false (the seq is no longer a clean RTT sample source).
+ if (m_state == ArqState::Closed || m_state == ArqState::Reset) {
+ return false;
+ }
+ auto it = m_sndBuf.find(sn);
+ if (it == m_sndBuf.end()) {
+ return false;
+ }
+ const qint64 nowMs = QDateTime::currentMSecsSinceEpoch();
+ if (it->lastNackSentMs > 0
+ && (nowMs - it->lastNackSentMs) < m_cfg.dataNackRepeatMs) {
+ return false;
+ }
+ it->lastNackSentMs = nowMs;
+ it->sampleEligible = false;
+
+ Packet p;
+ p.type = PacketType::StreamResend;
+ p.streamId = m_streamId;
+ p.sequenceNum = sn;
+ p.fragmentId = 0;
+ p.totalFragments = 1;
+ p.compression = 0;
+ p.payload = it->payload;
+ dispatch(p, /*retransmit=*/true);
+ return true;
+}
+
+bool ArqStream::HandleAckPacket(PacketType packetType, quint16 sn, quint8 /*fragmentId*/)
+{
+ if (packetType == PacketType::StreamDataAck) {
+ const int before = inFlightCount();
+ onAck(sn);
+ return inFlightCount() < before;
+ }
+ // For close/syn/rst ACK types route via the control-ack path.
+ return ReceiveControlAck(packetType, sn, 0);
+}
+
+bool ArqStream::ReceiveControlAck(PacketType ackPacketType, quint16 sn, quint8 fragmentId)
+{
+ // Mirrors upstream `ReceiveControlAck` (arq.go:2250). Look the
+ // outstanding control entry up by (originType, seq, fragId); on
+ // tracked hits, drop the entry and feed a control-plane RTT sample.
+ const auto originPtype = reverseControlAckFor(ackPacketType);
+ if (originPtype) {
+ const quint32 key = controlKey(*originPtype, sn, fragmentId);
+ auto it = m_controlSndBuf.find(key);
+ if (it != m_controlSndBuf.end()) {
+ if (it->sampleEligible && it->firstSentMs > 0) {
+ const qint64 nowMs = QDateTime::currentMSecsSinceEpoch();
+ const qint64 sampleMs = nowMs - it->firstSentMs;
+ if (sampleMs > 0) {
+ updateRttSample(sampleMs, /*isControl=*/true);
+ }
+ }
+ m_controlSndBuf.erase(it);
+ // Also route through the state-machine for half-close /
+ // RST acks; harmless if the original type doesn't need it.
+ Packet pkt;
+ pkt.type = ackPacketType;
+ pkt.streamId = m_streamId;
+ pkt.sequenceNum = sn;
+ onPacketReceived(pkt);
+ return true;
+ }
+ }
+ // Unrecognised / untracked — still route to the state machine so the
+ // existing half-close handler observes the ACK.
+ Packet pkt;
+ pkt.type = ackPacketType;
+ pkt.streamId = m_streamId;
+ pkt.sequenceNum = sn;
+ onPacketReceived(pkt);
+ return originPtype.has_value();
+}
+
+void ArqStream::trackControlSent(PacketType type, quint16 seq, quint8 fragId)
+{
+ if (!m_cfg.enableControlReliability) {
+ return;
+ }
+ PendingSend entry;
+ entry.seq = seq;
+ entry.type = type;
+ const qint64 nowMs = QDateTime::currentMSecsSinceEpoch();
+ entry.firstSentMs = nowMs;
+ entry.lastSentMs = nowMs;
+ entry.sampleEligible = true;
+ m_controlSndBuf.insert(controlKey(type, seq, fragId), entry);
+}
+
+std::optional ArqStream::reverseControlAckFor(PacketType ackType)
+{
+ switch (ackType) {
+ case PacketType::StreamSynAck: return PacketType::StreamSyn;
+ case PacketType::StreamConnectedAck: return PacketType::StreamConnected;
+ case PacketType::StreamConnectFailAck: return PacketType::StreamConnectFail;
+ case PacketType::StreamCloseWriteAck: return PacketType::StreamCloseWrite;
+ case PacketType::StreamCloseReadAck: return PacketType::StreamCloseRead;
+ case PacketType::StreamRstAck: return PacketType::StreamRst;
+ case PacketType::Socks5SynAck: return PacketType::Socks5Syn;
+ case PacketType::Socks5ConnectedAck: return PacketType::Socks5Connected;
+ case PacketType::Socks5ConnectFailAck: return PacketType::Socks5ConnectFail;
+ case PacketType::Socks5RulesetDeniedAck: return PacketType::Socks5RulesetDenied;
+ case PacketType::Socks5NetworkUnreachableAck: return PacketType::Socks5NetworkUnreachable;
+ case PacketType::Socks5HostUnreachableAck: return PacketType::Socks5HostUnreachable;
+ case PacketType::Socks5ConnectionRefusedAck: return PacketType::Socks5ConnectionRefused;
+ case PacketType::Socks5TtlExpiredAck: return PacketType::Socks5TtlExpired;
+ case PacketType::Socks5CommandUnsupportedAck: return PacketType::Socks5CommandUnsupported;
+ case PacketType::Socks5AddressTypeUnsupportedAck:return PacketType::Socks5AddressTypeUnsupported;
+ case PacketType::Socks5AuthFailedAck: return PacketType::Socks5AuthFailed;
+ case PacketType::Socks5UpstreamUnavailableAck: return PacketType::Socks5UpstreamUnavailable;
+ case PacketType::DnsQueryReqAck: return PacketType::DnsQueryReq;
+ case PacketType::DnsQueryResAck: return PacketType::DnsQueryRes;
+ default:
+ return std::nullopt;
+ }
+}
+
+void ArqStream::MarkCloseReadReceived()
+{
+ onCloseRead();
+}
+
+void ArqStream::MarkCloseWriteReceived()
+{
+ onCloseWrite();
+}
+
+void ArqStream::MarkRstReceived()
+{
+ onRst();
+}
+
+void ArqStream::noteDataNackSent(quint16 sn, qint64 nowMs)
+{
+ // Records the actual wall-clock millisecond at which we emitted a
+ // STREAM_DATA_NACK for `sn`. Subsequent `shouldSendDataNack` calls
+ // compare `now - lastSent` against `dataNackRepeatMs` to enforce the
+ // per-seq throttle. Mirrors upstream `ARQ.noteDataNackSent`.
+ m_lastNackSentMs.insert(sn, nowMs);
+}
+
+void ArqStream::clearAllQueues(bool includeDataNacks)
+{
+ m_sndBuf.clear();
+ m_rcvBuf.clear();
+ m_controlSndBuf.clear();
+ if (includeDataNacks) {
+ m_lastNackSentMs.clear();
+ m_firstDataNackSeenMs.clear();
+ }
+}
+
+} // namespace amnezia::masterdnsvpn
diff --git a/client/masterdnsvpn/arq.h b/client/masterdnsvpn/arq.h
new file mode 100644
index 0000000000..445e04d38c
--- /dev/null
+++ b/client/masterdnsvpn/arq.h
@@ -0,0 +1,339 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// Per-stream ARQ (Automatic Repeat reQuest) reliability layer.
+//
+// Each stream gets its own ArqStream instance. The instance manages a
+// sliding window of in-flight `STREAM_DATA` packets, dispatches retransmits
+// when an RTO fires, and reorders incoming packets through `rcvBuf` until
+// they can be delivered contiguously to the application layer.
+//
+// **ArqStream is pure**: it owns no sockets, no timers, no Qt main-loop
+// anything. Time is supplied by the caller (`tickMs`), and outbound packets
+// are delivered to the caller via a `Sink` callback. This makes the state
+// machine trivially unit-testable without faking I/O.
+//
+// The dispatcher (session.cpp, written later) is responsible for:
+// * calling tickMs(now) periodically (or on incoming data),
+// * routing the Sink-emitted packets to the resolver pool,
+// * delivering incoming wireframing::Packet objects to onPacketReceived().
+//
+// All algorithm-level constants follow §6 of docs/masterdnsvpn-wire-spec.md.
+
+#ifndef MASTERDNSVPN_ARQ_H
+#define MASTERDNSVPN_ARQ_H
+
+#include "wireframing.h"
+
+#include
+#include
+#include
+#include
+#include
+
+// Forward declaration so ArqStream's `friend class TestMasterDnsVpnEngine`
+// resolves. The test class lives in the default namespace (it's the QTest
+// main class in client/tests/testMasterDnsVpnEngine.cpp).
+class TestMasterDnsVpnEngine;
+
+namespace amnezia::masterdnsvpn {
+
+// Tunable knobs lifted from operator config (server policy clamps via
+// SESSION_ACCEPT — caller applies clamping before passing the struct in).
+struct ArqConfig {
+ int windowSize = 1000; // ARQ_WINDOW_SIZE; floor 300 inside ctor
+ qint64 initialDataRtoMs = 500;
+ qint64 maxDataRtoMs = 3000;
+ qint64 initialControlRtoMs = 500;
+ qint64 maxControlRtoMs = 2000;
+ int maxDataRetries = 1200; // ARQ_MAX_DATA_RETRIES floor
+ int maxControlRetries = 400; // ARQ_MAX_CONTROL_RETRIES floor
+ qint64 inactivityTimeoutMs = 1'800'000;
+ qint64 dataPacketTtlMs = 2'400'000;
+ qint64 controlPacketTtlMs = 1'200'000;
+ int dataNackMaxGap = 32; // ARQ_DATA_NACK_MAX_GAP
+ qint64 dataNackInitialDelayMs = 0; // upstream Config zero default; raise via cfg if desired
+ qint64 dataNackRepeatMs = 800;
+ qint64 terminalDrainMs = 60'000;
+ qint64 terminalAckWaitMs = 30'000;
+
+ // Upstream-parity knobs (mirrors `internal/arq/arq.go::Config`). These
+ // are referenced by the translated test suite; the C++ engine doesn't
+ // yet act on all of them — failing tests document the implementation
+ // gap rather than being silently masked.
+ bool enableControlReliability = false;
+ bool isClient = false;
+ bool isVirtual = false;
+ bool startPaused = false;
+ quint8 compressionType = 0;
+};
+
+// Outgoing packet emitted by the ARQ machine. The dispatcher wraps this in
+// crypto + DNS framing and ships it via the resolver pool.
+struct ArqOutbound {
+ Packet packet;
+ bool isRetransmit = false; // emitted as STREAM_RESEND vs STREAM_DATA when applicable
+};
+
+// Bytes ready for delivery to the application layer (SOCKS5 socket).
+// Always in stream order; the ARQ instance buffers out-of-order packets
+// in `rcvBuf` until contiguous prefix is available.
+struct ArqDelivery {
+ QByteArray bytes;
+ bool endOfStream = false; // signalled when the peer half-closed write
+};
+
+// Stream-level lifecycle state (§6.6).
+enum class ArqState {
+ Open,
+ HalfClosedLocal, // sent CLOSE_READ
+ HalfClosedRemote, // received CLOSE_READ
+ Closing,
+ Draining,
+ TimeWait,
+ Reset,
+ Closed,
+};
+
+class ArqStream
+{
+ // The upstream Go test suite (`internal/arq/arq_test.go`) lives in the
+ // same package as the implementation and freely manipulates internal
+ // state (sndBuf, sndNxt, rcvNxt, dataNackInitialDelay, etc.). To
+ // translate those tests faithfully — without "tailoring tests to fit
+ // our existing code", per project policy — the QTest harness needs
+ // equivalent access. `TestMasterDnsVpnEngine` is in the default
+ // namespace (the test file's class), forward-declared in tests.
+ friend class ::TestMasterDnsVpnEngine;
+
+public:
+ // Sink receives outbound packets. The dispatcher is responsible for
+ // adding session id / cookie before transmission — we set sessionId=0
+ // here as a placeholder.
+ using Sink = std::function;
+
+ // Delivery sink fires whenever the contiguous receive prefix grows.
+ using DeliverySink = std::function;
+
+ ArqStream(quint16 streamId, const ArqConfig &cfg, Sink outboundSink, DeliverySink deliverySink);
+
+ // ---- Application -> network ----
+ // Append plaintext bytes to the send queue. Returns the number of
+ // bytes accepted; partial accepts mean the caller must retry later
+ // (backpressure when the send window is ≥ 80% full).
+ qsizetype writeApp(const QByteArray &bytes);
+
+ // Initiate a half-close (CLOSE_WRITE).
+ void halfCloseWrite();
+
+ // Initiate a hard reset (RST). After this, the stream is terminal.
+ void reset();
+
+ // ---- Network -> application ----
+ // Feed an incoming wire-decoded packet to the state machine.
+ void onPacketReceived(const Packet &pkt);
+
+ // ---- Periodic tick ----
+ // Call every ~50-200 ms (or on any state change). Drives the RTO
+ // expiry, NACK throttling, terminal-drain watchdog. Caller supplies
+ // the current monotonic-clock millisecond stamp.
+ void tickMs(qint64 nowMs);
+
+ quint16 streamId() const { return m_streamId; }
+ ArqState state() const { return m_state; }
+ bool isTerminal() const;
+
+ // Diagnostic: number of unacked packets currently on the wire.
+ int inFlightCount() const;
+
+ // ---- Upstream-API-shaped wrappers ----
+ //
+ // These thin wrappers expose the same external API names the Go
+ // reference uses (`internal/arq/arq.go`), so the translated test
+ // suite can call them as upstream does. They dispatch to the
+ // existing private logic.
+
+ // Equivalent of upstream `ARQ.ReceiveData(sn, data)` — feeds a
+ // STREAM_DATA packet (with the given sequence + payload) through
+ // the state machine.
+ bool ReceiveData(quint16 sn, const QByteArray &data);
+
+ // Equivalent of upstream `ARQ.ReceiveAck(packetType, sn)` — feeds
+ // a STREAM_DATA_ACK (or compatible) for the given sequence.
+ bool ReceiveAck(PacketType packetType, quint16 sn);
+
+ // Equivalent of upstream `ARQ.HandleDataNack(sn)` — handles an
+ // inbound STREAM_DATA_NACK for the given sequence; returns true
+ // when a resend was scheduled, false when suppressed by cooldown.
+ bool HandleDataNack(quint16 sn);
+
+ // Equivalent of upstream `ARQ.Start()` / `ARQ.Close(reason, opts)`.
+ // The C++ engine is purely synchronous on the Qt event loop, so
+ // these are no-ops; they exist for translation parity.
+ void Start() {}
+ struct CloseOptions { bool Force = false; bool SendRST = false; };
+ void Close(const QString & /*reason*/, const CloseOptions & /*opts*/) {}
+
+ // Equivalent of upstream `ARQ.HandleAckPacket(packetType, sn, fragId)`.
+ // Routes the ack through the type-specific handler (data vs control).
+ bool HandleAckPacket(PacketType packetType, quint16 sn, quint8 fragmentId);
+
+ // Equivalent of upstream `ARQ.ReceiveControlAck(ackType, sn, fragId)`.
+ bool ReceiveControlAck(PacketType ackPacketType, quint16 sn, quint8 fragmentId);
+
+ // Mark the peer's half-close signals as received (mirrors upstream's
+ // `Mark*Received` family — internal/arq/arq.go:775,819).
+ void MarkCloseReadReceived();
+ void MarkCloseWriteReceived();
+ void MarkRstReceived();
+
+ // Mirrors upstream `IsClosed()` / `IsReset()` (arq.go:312, 618).
+ bool IsClosed() const { return m_state == ArqState::Closed; }
+ bool IsReset() const { return m_state == ArqState::Reset; }
+
+ // Mirrors upstream `HasPendingSequence(sn)` (arq.go:324).
+ bool HasPendingSequence(quint16 sn) const { return m_sndBuf.contains(sn); }
+
+ // Mirrors upstream `noteDataNackSent` — records that we sent a NACK
+ // for `sn` so cooldown logic suppresses duplicates.
+ void noteDataNackSent(quint16 sn, qint64 nowMs);
+
+ // Mirrors upstream `clearAllQueues(includeDataNacks)` — wipes the
+ // per-stream NACK / send buffers. Used by tests verifying that
+ // teardown drops remembered NACK state.
+ void clearAllQueues(bool includeDataNacks);
+
+private:
+ Q_DISABLE_COPY_MOVE(ArqStream)
+
+ struct PendingSend {
+ quint16 seq;
+ QByteArray payload;
+ PacketType type = PacketType::StreamData;
+ qint64 firstSentMs = 0;
+ qint64 lastSentMs = 0;
+ // Last time we emitted a STREAM_RESEND for this seq in response to an
+ // inbound STREAM_DATA_NACK (HandleDataNack). Used as the per-seq
+ // cooldown gate so back-to-back NACKs don't flood resends. Mirrors
+ // upstream arqDataItem.LastNackSentAt (internal/arq/arq.go:1886).
+ qint64 lastNackSentMs = 0;
+ int retries = 0;
+ bool sampleEligible = true; // per §6.4 — false after a retransmit
+ };
+
+ struct PendingReceive {
+ quint16 seq;
+ QByteArray payload;
+ };
+
+ void dispatch(const Packet &pkt, bool retransmit);
+ void emitDataAck(quint16 seq);
+ void emitControl(PacketType type, quint16 seq);
+ void scheduleRetransmits(qint64 nowMs);
+
+ // Control-plane analogue of scheduleRetransmits: walks
+ // m_controlSndBuf, re-emits entries whose RTO has expired, and
+ // grows the control-RTO on each retransmit. Bounded by
+ // m_cfg.maxControlRetries — stream is reset when exceeded.
+ void scheduleControlRetransmits(qint64 nowMs);
+ void deliverContiguous();
+ void onDataPacket(const Packet &pkt);
+ void onAck(quint16 ackedSeq);
+ void onCloseRead();
+ void onCloseWrite();
+ void onRst();
+ void updateRttSample(qint64 sampleMs, bool isControl);
+ qint64 currentRto(bool isControl) const;
+ bool isAhead(quint16 sn, quint16 baseline) const;
+
+ // §6.7 NACK-gap path. `sn` is the seq of the just-arrived out-of-order
+ // packet; we compute the bounded list of still-missing seqs in the gap
+ // [m_rcvNxt..sn) — either the full slice when the gap is within
+ // dataNackMaxGap, or sample + frontier when it isn't — and emit a
+ // STREAM_DATA_NACK per missing seq subject to the per-seq cooldown.
+ void maybeSendDataNacks(quint16 sn, qint64 nowMs);
+
+ // Decision oracle for a single missing seq. Mirrors upstream
+ // `ARQ.shouldSendDataNack` (internal/arq/arq.go:1996). Side-effect: on
+ // the first observation of a missing seq, records firstSeenMs[sn].
+ bool shouldSendDataNack(quint16 sn, qint64 nowMs);
+
+ // Drops firstSeen / lastSent entries for any seq strictly behind
+ // `rcvNxt` (the gap is no longer relevant). Mirrors upstream
+ // `pruneDataNackStateLocked` (arq.go:2026).
+ void pruneDataNackStateLocked(quint16 rcvNxt);
+
+ // Sequence wrap-around: returns true if `candidate` is strictly behind
+ // `base` (i.e., `(base - candidate)` is in (0, 32768)). Mirrors
+ // upstream `seqBehind` (arq.go:2022).
+ static bool seqBehind(quint16 base, quint16 candidate);
+
+ // Map an inbound control ACK packet type back to the originating
+ // packet type it acks (SYN_ACK → SYN, CONNECTED_ACK → CONNECTED, …).
+ // Returns std::nullopt for types that don't ack anything trackable.
+ // Mirrors upstream `Enums.ReverseControlAckFor`.
+ static std::optional reverseControlAckFor(PacketType ackType);
+
+ // Track that a reliable control packet was just dispatched, so the
+ // matching ACK can drop the entry and feed an RTT sample. No-op when
+ // `enableControlReliability` is false (upstream parity).
+ void trackControlSent(PacketType type, quint16 seq, quint8 fragId);
+
+ // Composite key into m_controlSndBuf: PacketType << 24 | seq << 8 |
+ // fragId. Mirrors upstream's controlSndBuf keying.
+ static quint32 controlKey(PacketType type, quint16 seq, quint8 fragId)
+ {
+ return (static_cast(type) << 24)
+ | (static_cast(seq) << 8)
+ | static_cast(fragId);
+ }
+
+ quint16 m_streamId;
+ ArqConfig m_cfg;
+ Sink m_sink;
+ DeliverySink m_deliver;
+
+ ArqState m_state = ArqState::Open;
+ qint64 m_lastActivityMs = 0;
+
+ // Send-side state
+ quint16 m_sndNxt = 1; // initial sequence per spec (clients use 1+)
+ QMap m_sndBuf; // seq -> pending
+
+ // Outstanding control packets awaiting their type-specific ACK
+ // (SYN/SYN_ACK, CONNECTED/CONNECTED_ACK, CLOSE_WRITE/_ACK etc.).
+ // Keyed by (packetType << 24) | (seq << 8) | fragId to match upstream's
+ // controlSndBuf encoding. The dispatcher seeds entries when sending
+ // reliable control packets; receipt of the corresponding ack consumes
+ // them and feeds an RTT sample to the control-plane EWMA.
+ QMap m_controlSndBuf;
+ qint64 m_dataSrttMs = 0;
+ qint64 m_dataRttvarMs = 0;
+ qint64 m_currentDataRtoMs;
+ qint64 m_controlSrttMs = 0;
+ qint64 m_controlRttvarMs = 0;
+ qint64 m_currentControlRtoMs;
+
+ // Receive-side state
+ //
+ // m_rcvNxt: next contiguous in-order sequence number expected from the
+ // peer. Defaults to 0 — peers begin emitting data with seq=0 after the
+ // SYN handshake (matches upstream `ARQ.rcvNxt` zero default in
+ // internal/arq/arq.go).
+ quint16 m_rcvNxt = 0;
+ QMap m_rcvBuf;
+
+ // Per-missing-seq NACK throttle state. Values are real monotonic
+ // millisecond timestamps (QDateTime::currentMSecsSinceEpoch()), not
+ // sentinels. Mirrors upstream `firstDataNackSeen` / `lastDataNackSent`.
+ QMap m_firstDataNackSeenMs;
+ QMap m_lastNackSentMs;
+
+ // Terminal lifecycle bookkeeping
+ qint64 m_terminalStartMs = 0;
+ bool m_remoteClosedWrite = false;
+ bool m_localClosedWrite = false;
+};
+
+} // namespace amnezia::masterdnsvpn
+
+#endif // MASTERDNSVPN_ARQ_H
diff --git a/client/masterdnsvpn/compression.cpp b/client/masterdnsvpn/compression.cpp
new file mode 100644
index 0000000000..59438c3d3a
--- /dev/null
+++ b/client/masterdnsvpn/compression.cpp
@@ -0,0 +1,289 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "compression.h"
+
+#include
+#include
+
+#include
+#include
+#include
+
+namespace amnezia::masterdnsvpn::compression {
+
+namespace {
+
+// Per spec §3.4: only the SNFC-group packet types serialise a per-packet
+// compression byte and therefore can carry compressed payloads. Mirrors
+// the `comp` set in upstream's `internal/vpnproto/parser.go:306-318`.
+bool hasCompressionExtension(PacketType type)
+{
+ switch (type) {
+ case PacketType::StreamData:
+ case PacketType::StreamResend:
+ case PacketType::PackedControlBlocks:
+ case PacketType::DnsQueryReq:
+ case PacketType::DnsQueryRes:
+ case PacketType::MtuUpReq:
+ case PacketType::MtuDownRes:
+ return true;
+ default:
+ return false;
+ }
+}
+
+} // namespace
+
+quint8 normalize(quint8 compType)
+{
+ if (compType > TypeZLIB) {
+ return TypeOff;
+ }
+ return compType;
+}
+
+quint8 packPair(quint8 upload, quint8 download)
+{
+ upload = normalize(upload);
+ download = normalize(download);
+ return static_cast((upload << 4) | (download & 0x0F));
+}
+
+std::pair splitPair(quint8 packed)
+{
+ return {normalize((packed >> 4) & 0x0F), normalize(packed & 0x0F)};
+}
+
+std::pair prepareOutgoingPayload(PacketType packetType,
+ const QByteArray &payload,
+ quint8 requestedUploadType,
+ int minSize)
+{
+ requestedUploadType = normalize(requestedUploadType);
+ if (requestedUploadType == TypeOff) {
+ return {payload, TypeOff};
+ }
+ if (!hasCompressionExtension(packetType)) {
+ return {payload, TypeOff};
+ }
+ if (payload.isEmpty()) {
+ return {payload, TypeOff};
+ }
+ if (minSize <= 0) {
+ minSize = DefaultMinSize;
+ }
+ if (payload.size() <= minSize) {
+ return {payload, TypeOff};
+ }
+
+ std::optional compressed;
+ switch (requestedUploadType) {
+ case TypeZSTD: compressed = compressZstd(payload); break;
+ case TypeLZ4: compressed = compressLz4(payload); break;
+ case TypeZLIB: compressed = compressZlibRaw(payload); break;
+ default: break;
+ }
+
+ if (!compressed) {
+ return {payload, TypeOff};
+ }
+ // Spec §8 / upstream types.go:159-160: if the codec failed to make
+ // the data smaller, fall back to raw + TypeOff so the receiver
+ // doesn't pay a decompression-overhead penalty for nothing.
+ if (compressed->size() >= payload.size()) {
+ return {payload, TypeOff};
+ }
+ return {*compressed, requestedUploadType};
+}
+
+std::optional tryDecompressPayload(const QByteArray &payload, quint8 compType)
+{
+ if (payload.isEmpty()) {
+ return payload;
+ }
+ compType = normalize(compType);
+ if (compType == TypeOff) {
+ return payload;
+ }
+
+ switch (compType) {
+ case TypeZSTD: return decompressZstd(payload);
+ case TypeLZ4: return decompressLz4(payload);
+ case TypeZLIB: return decompressZlibRaw(payload);
+ default: return std::nullopt;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ZSTD
+// ---------------------------------------------------------------------------
+
+std::optional compressZstd(const QByteArray &input)
+{
+ // Upstream uses `WithEncoderLevel(zstd.SpeedFastest)` — the
+ // corresponding libzstd level is 1 (ZSTD_minCLevel..ZSTD_maxCLevel
+ // range with 1 = fastest in stable zstd).
+ constexpr int kLevel = 1;
+ const size_t bound = ZSTD_compressBound(static_cast(input.size()));
+ QByteArray out(static_cast(bound), Qt::Uninitialized);
+ const size_t written = ZSTD_compress(out.data(), bound,
+ input.constData(),
+ static_cast(input.size()),
+ kLevel);
+ if (ZSTD_isError(written)) {
+ return std::nullopt;
+ }
+ out.resize(static_cast(written));
+ return out;
+}
+
+std::optional decompressZstd(const QByteArray &input)
+{
+ // We don't trust frame headers blindly — cap output at the
+ // 10 MiB decompression-bomb limit. `ZSTD_decompress` requires the
+ // destination to be at least the original size; if a frame header
+ // lies about that, we'll catch the mismatch via the error return.
+ const unsigned long long frameSize =
+ ZSTD_getFrameContentSize(input.constData(),
+ static_cast(input.size()));
+ if (frameSize == ZSTD_CONTENTSIZE_ERROR) {
+ return std::nullopt;
+ }
+ if (frameSize == ZSTD_CONTENTSIZE_UNKNOWN) {
+ // Streaming-mode frame — fall back to a chunked decompress with
+ // a hard ceiling. Caller path doesn't exercise this in practice
+ // because upstream's encoder always emits the frame size.
+ return std::nullopt;
+ }
+ if (frameSize > MaxDecompressedSize) {
+ return std::nullopt;
+ }
+ QByteArray out(static_cast(frameSize), Qt::Uninitialized);
+ const size_t written = ZSTD_decompress(out.data(), static_cast(out.size()),
+ input.constData(),
+ static_cast(input.size()));
+ if (ZSTD_isError(written) || written != frameSize) {
+ return std::nullopt;
+ }
+ return out;
+}
+
+// ---------------------------------------------------------------------------
+// LZ4 — block compression with 4-byte LE original-size prefix
+// ---------------------------------------------------------------------------
+
+std::optional compressLz4(const QByteArray &input)
+{
+ const int bound = LZ4_compressBound(input.size());
+ if (bound <= 0) {
+ return std::nullopt;
+ }
+ QByteArray out(4 + bound, Qt::Uninitialized);
+ // Upstream LZ4 format (types.go:269-287) is
+ // [4 bytes LE original_size][lz4-compressed block]
+ // The size prefix mirrors Python `lz4.block(store_size=True)` so
+ // wire-compatibility with the reference server is exact.
+ qToLittleEndian(static_cast(input.size()), out.data());
+ const int n = LZ4_compress_default(input.constData(),
+ out.data() + 4,
+ input.size(),
+ bound);
+ if (n <= 0) {
+ return std::nullopt;
+ }
+ out.resize(4 + n);
+ return out;
+}
+
+std::optional decompressLz4(const QByteArray &input)
+{
+ if (input.size() < 4) {
+ return std::nullopt;
+ }
+ const quint32 origSize = qFromLittleEndian(input.constData());
+ if (origSize > MaxDecompressedSize) {
+ return std::nullopt;
+ }
+ QByteArray out(static_cast(origSize), Qt::Uninitialized);
+ const int n = LZ4_decompress_safe(input.constData() + 4,
+ out.data(),
+ input.size() - 4,
+ out.size());
+ if (n < 0 || static_cast(n) != origSize) {
+ return std::nullopt;
+ }
+ return out;
+}
+
+// ---------------------------------------------------------------------------
+// ZLIB — RAW deflate (windowBits = -15, no zlib header / adler32)
+// ---------------------------------------------------------------------------
+
+std::optional compressZlibRaw(const QByteArray &input)
+{
+ z_stream zs;
+ std::memset(&zs, 0, sizeof(zs));
+
+ // windowBits = -15 selects raw deflate per zlib docs. Level 1 mirrors
+ // upstream's `flate.NewWriter(io.Discard, 1)` (types.go:63).
+ if (deflateInit2(&zs, 1, Z_DEFLATED, -15, 8, Z_DEFAULT_STRATEGY) != Z_OK) {
+ return std::nullopt;
+ }
+
+ const uLong bound = deflateBound(&zs, static_cast(input.size()));
+ QByteArray out(static_cast(bound), Qt::Uninitialized);
+ zs.next_in = reinterpret_cast(const_cast(input.constData()));
+ zs.avail_in = static_cast(input.size());
+ zs.next_out = reinterpret_cast(out.data());
+ zs.avail_out = static_cast(out.size());
+
+ const int rc = deflate(&zs, Z_FINISH);
+ const int totalOut = static_cast(zs.total_out);
+ deflateEnd(&zs);
+ if (rc != Z_STREAM_END) {
+ return std::nullopt;
+ }
+ out.resize(totalOut);
+ return out;
+}
+
+std::optional decompressZlibRaw(const QByteArray &input)
+{
+ z_stream zs;
+ std::memset(&zs, 0, sizeof(zs));
+ if (inflateInit2(&zs, -15) != Z_OK) {
+ return std::nullopt;
+ }
+
+ QByteArray out;
+ out.reserve(std::max(input.size() * 4, 1024));
+ QByteArray buf(64 * 1024, Qt::Uninitialized);
+
+ zs.next_in = reinterpret_cast(const_cast(input.constData()));
+ zs.avail_in = static_cast(input.size());
+
+ int rc = Z_OK;
+ do {
+ zs.next_out = reinterpret_cast(buf.data());
+ zs.avail_out = static_cast(buf.size());
+ rc = inflate(&zs, Z_NO_FLUSH);
+ if (rc != Z_OK && rc != Z_STREAM_END) {
+ inflateEnd(&zs);
+ return std::nullopt;
+ }
+ const int produced = buf.size() - static_cast(zs.avail_out);
+ out.append(buf.constData(), produced);
+ if (out.size() > MaxDecompressedSize) {
+ inflateEnd(&zs);
+ return std::nullopt;
+ }
+ } while (rc != Z_STREAM_END && zs.avail_in > 0);
+
+ inflateEnd(&zs);
+ if (rc != Z_STREAM_END) {
+ return std::nullopt;
+ }
+ return out;
+}
+
+} // namespace amnezia::masterdnsvpn::compression
diff --git a/client/masterdnsvpn/compression.h b/client/masterdnsvpn/compression.h
new file mode 100644
index 0000000000..c18729a959
--- /dev/null
+++ b/client/masterdnsvpn/compression.h
@@ -0,0 +1,91 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// Spec §8 compression — three codec paths gated by the compression-pair
+// byte in SESSION_ACCEPT and carried in the per-packet compression
+// extension. Ported from upstream's `internal/compression/types.go`.
+//
+// Wire-level mapping (matches upstream constants exactly — do not renumber):
+//
+// * TypeOff (0) — payload is plain bytes
+// * TypeZSTD (1) — payload is a Zstandard frame
+// * TypeLZ4 (2) — `[4B LE original_size][LZ4 block]` (Python lz4.block
+// `store_size=True` layout; upstream's `compressLZ4`
+// prepends this header at types.go:269-287)
+// * TypeZLIB (3) — RAW deflate stream (windowBits = -15). The name is
+// "ZLIB" by upstream convention but there is NO zlib
+// wrapper or adler32 — just deflate-compressed bytes.
+//
+// Only packet types with the `compression` extension (§3.4 — i.e. the
+// `kSNFC` group: STREAM_DATA, STREAM_RESEND, DNS_QUERY_REQ, DNS_QUERY_RES,
+// MTU_UP_REQ, MTU_DOWN_RES, PACKED_CONTROL_BLOCKS) can carry compressed
+// payloads. `prepareOutgoingPayload()` enforces this and gracefully falls
+// back to TypeOff when the codec is unavailable, the payload is too small
+// to benefit, or the compressed result is no smaller than the input.
+//
+// Decompression is similarly defensive: oversized output (over 10 MiB)
+// is rejected as a decompression-bomb guard, matching upstream's
+// `maxDecompressedSize = 10 * 1024 * 1024` cap.
+
+#ifndef MASTERDNSVPN_COMPRESSION_H
+#define MASTERDNSVPN_COMPRESSION_H
+
+#include "wireframing.h"
+
+#include
+#include
+#include
+
+namespace amnezia::masterdnsvpn::compression {
+
+// Wire-stable codec identifiers (do not renumber — these are the bytes
+// that travel in the per-packet compression extension).
+constexpr quint8 TypeOff = 0;
+constexpr quint8 TypeZSTD = 1;
+constexpr quint8 TypeLZ4 = 2;
+constexpr quint8 TypeZLIB = 3;
+
+// Upstream `internal/compression/types.go:21`.
+constexpr int DefaultMinSize = 100;
+constexpr int MaxDecompressedSize = 10 * 1024 * 1024;
+
+// `compType` outside [Off..Zlib] becomes Off, matching upstream
+// `NormalizeAvailableType`. Used to defend against malformed received
+// compression bytes.
+quint8 normalize(quint8 compType);
+
+// Pack/split the (upload, download) pair carried in SESSION_INIT byte 1
+// and the SESSION_ACCEPT compression byte. Layout is `upload<<4 | download`,
+// where each nibble is one of the codec ids above.
+quint8 packPair(quint8 upload, quint8 download);
+std::pair splitPair(quint8 packed);
+
+// Mirrors upstream `PreparePayload` (internal/vpnproto/payload.go:19-31).
+// Returns (payload, used_codec) — falls back to (input, TypeOff) when:
+// * `packetType` is not in the compression-extension group, or
+// * payload is empty, or
+// * payload size <= minSize (use 0 to mean DefaultMinSize), or
+// * the codec is unavailable / not built in, or
+// * the compressed output is not smaller than the input.
+// The caller writes the returned codec id into `packet.compression`.
+std::pair prepareOutgoingPayload(PacketType packetType,
+ const QByteArray &payload,
+ quint8 requestedUploadType,
+ int minSize);
+
+// Mirrors upstream `TryDecompressPayload` (types.go:166-192). Returns
+// std::nullopt on any error (corrupt stream, oversized decompressed
+// output, codec-init failure). TypeOff is a pass-through.
+std::optional tryDecompressPayload(const QByteArray &payload, quint8 compType);
+
+// Roundtrip helpers — exposed for tests; production callers prefer the
+// higher-level prepare/decompress above.
+std::optional compressZstd(const QByteArray &input);
+std::optional decompressZstd(const QByteArray &input);
+std::optional compressLz4(const QByteArray &input);
+std::optional decompressLz4(const QByteArray &input);
+std::optional compressZlibRaw(const QByteArray &input);
+std::optional decompressZlibRaw(const QByteArray &input);
+
+} // namespace amnezia::masterdnsvpn::compression
+
+#endif // MASTERDNSVPN_COMPRESSION_H
diff --git a/client/masterdnsvpn/crypto.cpp b/client/masterdnsvpn/crypto.cpp
new file mode 100644
index 0000000000..4a7a8ccb98
--- /dev/null
+++ b/client/masterdnsvpn/crypto.cpp
@@ -0,0 +1,538 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "crypto.h"
+
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+namespace amnezia::masterdnsvpn {
+
+namespace {
+
+// Centralised lookup so the rest of the file doesn't sprinkle EVP_get_cipher
+// calls. Returns nullptr for non-AEAD methods (None / Xor) — callers must
+// special-case those.
+const EVP_CIPHER *evpCipherFor(CipherMethod m)
+{
+ switch (m) {
+ case CipherMethod::ChaCha20:
+ return EVP_chacha20();
+ case CipherMethod::Aes128Gcm:
+ return EVP_aes_128_gcm();
+ case CipherMethod::Aes192Gcm:
+ return EVP_aes_192_gcm();
+ case CipherMethod::Aes256Gcm:
+ return EVP_aes_256_gcm();
+ case CipherMethod::None:
+ case CipherMethod::Xor:
+ return nullptr;
+ }
+ return nullptr;
+}
+
+bool isAead(CipherMethod m)
+{
+ return m == CipherMethod::Aes128Gcm || m == CipherMethod::Aes192Gcm
+ || m == CipherMethod::Aes256Gcm;
+}
+
+void logOpensslError(const char *what)
+{
+ const unsigned long e = ERR_get_error();
+ char buf[256] = {};
+ ERR_error_string_n(e, buf, sizeof(buf));
+ qWarning("masterdnsvpn::crypto: %s failed: %s", what, buf);
+}
+
+} // namespace
+
+std::optional cipherMethodFromInt(int code)
+{
+ switch (code) {
+ case 0:
+ return CipherMethod::None;
+ case 1:
+ return CipherMethod::Xor;
+ case 2:
+ return CipherMethod::ChaCha20;
+ case 3:
+ return CipherMethod::Aes128Gcm;
+ case 4:
+ return CipherMethod::Aes192Gcm;
+ case 5:
+ return CipherMethod::Aes256Gcm;
+ default:
+ return std::nullopt;
+ }
+}
+
+int requiredKeyBytes(CipherMethod m)
+{
+ switch (m) {
+ case CipherMethod::None:
+ return 0;
+ case CipherMethod::Xor:
+ // No "key length" per se for XOR; we still derive 32 bytes so
+ // operators get a meaningful entropy floor.
+ return 32;
+ case CipherMethod::ChaCha20:
+ return 32;
+ case CipherMethod::Aes128Gcm:
+ return 16;
+ case CipherMethod::Aes192Gcm:
+ return 24;
+ case CipherMethod::Aes256Gcm:
+ return 32;
+ }
+ return 0;
+}
+
+int requiredNonceBytes(CipherMethod m)
+{
+ switch (m) {
+ case CipherMethod::None:
+ case CipherMethod::Xor:
+ return 0;
+ case CipherMethod::ChaCha20:
+ // 16 = 4-byte block counter || 12-byte nonce, the layout OpenSSL's
+ // EVP_chacha20 expects directly.
+ return 16;
+ case CipherMethod::Aes128Gcm:
+ case CipherMethod::Aes192Gcm:
+ case CipherMethod::Aes256Gcm:
+ return 12;
+ }
+ return 0;
+}
+
+int authTagBytes(CipherMethod m)
+{
+ return isAead(m) ? 16 : 0;
+}
+
+QByteArray deriveKey(CipherMethod m, const QString &passphrase)
+{
+ // Per the wire spec, each cipher derives its key from the operator's
+ // UTF-8 ENCRYPTION_KEY string by a *method-specific* rule:
+ //
+ // None — no key
+ // Xor — raw UTF-8 bytes, zero-padded (or truncated) to 32 B
+ // ChaCha20 — SHA-256 of raw UTF-8 bytes (32 B)
+ // Aes128Gcm — MD5 of raw UTF-8 bytes (16 B) <-- yes, MD5
+ // Aes192Gcm — raw UTF-8 bytes, zero-padded (or truncated) to 24 B
+ // Aes256Gcm — SHA-256 of raw UTF-8 bytes (32 B)
+ //
+ // We're not designing the protocol — these are the exact derivations the
+ // upstream implementation uses, and bit-for-bit compatibility is required
+ // for the tunnel to work at all.
+ const QByteArray utf8 = passphrase.toUtf8();
+
+ auto rawPad = [&utf8](int n) {
+ QByteArray buf(n, '\0');
+ const int copy = std::min(utf8.size(), n);
+ if (copy > 0) {
+ std::memcpy(buf.data(), utf8.constData(), copy);
+ }
+ return buf;
+ };
+
+ auto sha256Of = [&utf8]() {
+ QByteArray buf(SHA256_DIGEST_LENGTH, '\0');
+ SHA256(reinterpret_cast(utf8.constData()),
+ static_cast(utf8.size()),
+ reinterpret_cast(buf.data()));
+ return buf;
+ };
+
+ auto md5Of = [&utf8]() {
+ QByteArray buf(MD5_DIGEST_LENGTH, '\0');
+ MD5(reinterpret_cast(utf8.constData()),
+ static_cast(utf8.size()),
+ reinterpret_cast(buf.data()));
+ return buf;
+ };
+
+ switch (m) {
+ case CipherMethod::None:
+ return {};
+ case CipherMethod::Xor:
+ return rawPad(32);
+ case CipherMethod::ChaCha20:
+ case CipherMethod::Aes256Gcm:
+ return sha256Of();
+ case CipherMethod::Aes128Gcm:
+ return md5Of();
+ case CipherMethod::Aes192Gcm:
+ return rawPad(24);
+ }
+ return {};
+}
+
+struct Cipher::Impl {
+ CipherMethod method = CipherMethod::None;
+ QByteArray key;
+
+ // Stream-cipher state for XOR; OpenSSL handles its own state for the
+ // EVP variants but we hold an opaque ctx so seal/open can be cheap.
+ EVP_CIPHER_CTX *encCtx = nullptr;
+ EVP_CIPHER_CTX *decCtx = nullptr;
+
+ ~Impl()
+ {
+ if (encCtx) {
+ EVP_CIPHER_CTX_free(encCtx);
+ }
+ if (decCtx) {
+ EVP_CIPHER_CTX_free(decCtx);
+ }
+ }
+};
+
+Cipher::Cipher() : d(std::make_unique()) {}
+
+Cipher::~Cipher() = default;
+
+CipherMethod Cipher::method() const
+{
+ return d->method;
+}
+
+bool Cipher::init(CipherMethod m, const QByteArray &derivedKey)
+{
+ if (derivedKey.size() != requiredKeyBytes(m)) {
+ qWarning("masterdnsvpn::Cipher::init: key size %lld doesn't match "
+ "required %d for method %d",
+ static_cast(derivedKey.size()),
+ requiredKeyBytes(m),
+ static_cast(m));
+ return false;
+ }
+
+ d->method = m;
+ d->key = derivedKey;
+
+ if (d->encCtx) {
+ EVP_CIPHER_CTX_free(d->encCtx);
+ d->encCtx = nullptr;
+ }
+ if (d->decCtx) {
+ EVP_CIPHER_CTX_free(d->decCtx);
+ d->decCtx = nullptr;
+ }
+
+ if (m == CipherMethod::None || m == CipherMethod::Xor) {
+ return true;
+ }
+
+ d->encCtx = EVP_CIPHER_CTX_new();
+ d->decCtx = EVP_CIPHER_CTX_new();
+ if (!d->encCtx || !d->decCtx) {
+ logOpensslError("EVP_CIPHER_CTX_new");
+ return false;
+ }
+ return true;
+}
+
+namespace {
+
+// Run a stream of bytes through repeating-key XOR. Pure helper; matches
+// the trivial spec for CipherMethod::Xor on both directions.
+void xorStream(const QByteArray &key, const QByteArray &in, QByteArray &out)
+{
+ out.resize(in.size());
+ if (key.isEmpty() || in.isEmpty()) {
+ return;
+ }
+ const int klen = key.size();
+ const char *kp = key.constData();
+ const char *ip = in.constData();
+ char *op = out.data();
+ for (int i = 0; i < in.size(); ++i) {
+ op[i] = ip[i] ^ kp[i % klen];
+ }
+}
+
+bool sealAead(EVP_CIPHER_CTX *ctx,
+ const EVP_CIPHER *cipher,
+ const QByteArray &key,
+ const QByteArray &plaintext,
+ const QByteArray &nonce,
+ const QByteArray &aad,
+ QByteArray &out)
+{
+ if (!EVP_EncryptInit_ex(ctx, cipher, nullptr, nullptr, nullptr)) {
+ logOpensslError("EVP_EncryptInit_ex (cipher)");
+ return false;
+ }
+ if (!EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_SET_IVLEN, nonce.size(), nullptr)) {
+ logOpensslError("EVP_CIPHER_CTX_ctrl (set IV len)");
+ return false;
+ }
+ if (!EVP_EncryptInit_ex(ctx,
+ nullptr,
+ nullptr,
+ reinterpret_cast(key.constData()),
+ reinterpret_cast(nonce.constData()))) {
+ logOpensslError("EVP_EncryptInit_ex (key/nonce)");
+ return false;
+ }
+
+ int outLen = 0;
+ if (!aad.isEmpty()) {
+ if (!EVP_EncryptUpdate(ctx,
+ nullptr,
+ &outLen,
+ reinterpret_cast(aad.constData()),
+ aad.size())) {
+ logOpensslError("EVP_EncryptUpdate (AAD)");
+ return false;
+ }
+ }
+
+ QByteArray cipherText(plaintext.size() + EVP_CIPHER_block_size(cipher), '\0');
+ if (!EVP_EncryptUpdate(ctx,
+ reinterpret_cast(cipherText.data()),
+ &outLen,
+ reinterpret_cast(plaintext.constData()),
+ plaintext.size())) {
+ logOpensslError("EVP_EncryptUpdate");
+ return false;
+ }
+ int written = outLen;
+
+ int finalLen = 0;
+ if (!EVP_EncryptFinal_ex(ctx,
+ reinterpret_cast(cipherText.data()) + written,
+ &finalLen)) {
+ logOpensslError("EVP_EncryptFinal_ex");
+ return false;
+ }
+ written += finalLen;
+ cipherText.resize(written);
+
+ QByteArray tag(16, '\0');
+ if (!EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_GET_TAG, tag.size(), tag.data())) {
+ logOpensslError("EVP_CIPHER_CTX_ctrl (get tag)");
+ return false;
+ }
+
+ out = cipherText + tag;
+ return true;
+}
+
+bool openAead(EVP_CIPHER_CTX *ctx,
+ const EVP_CIPHER *cipher,
+ const QByteArray &key,
+ const QByteArray &ciphertextWithTag,
+ const QByteArray &nonce,
+ const QByteArray &aad,
+ QByteArray &out)
+{
+ constexpr int kTagLen = 16;
+ if (ciphertextWithTag.size() < kTagLen) {
+ qWarning("masterdnsvpn::Cipher::open: input too short for AEAD tag");
+ return false;
+ }
+ const QByteArray ct = ciphertextWithTag.left(ciphertextWithTag.size() - kTagLen);
+ const QByteArray tag = ciphertextWithTag.right(kTagLen);
+
+ if (!EVP_DecryptInit_ex(ctx, cipher, nullptr, nullptr, nullptr)) {
+ logOpensslError("EVP_DecryptInit_ex (cipher)");
+ return false;
+ }
+ if (!EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_SET_IVLEN, nonce.size(), nullptr)) {
+ logOpensslError("EVP_CIPHER_CTX_ctrl (set IV len)");
+ return false;
+ }
+ if (!EVP_DecryptInit_ex(ctx,
+ nullptr,
+ nullptr,
+ reinterpret_cast(key.constData()),
+ reinterpret_cast(nonce.constData()))) {
+ logOpensslError("EVP_DecryptInit_ex (key/nonce)");
+ return false;
+ }
+
+ int outLen = 0;
+ if (!aad.isEmpty()) {
+ if (!EVP_DecryptUpdate(ctx,
+ nullptr,
+ &outLen,
+ reinterpret_cast(aad.constData()),
+ aad.size())) {
+ logOpensslError("EVP_DecryptUpdate (AAD)");
+ return false;
+ }
+ }
+
+ QByteArray plain(ct.size() + EVP_CIPHER_block_size(cipher), '\0');
+ if (!EVP_DecryptUpdate(ctx,
+ reinterpret_cast(plain.data()),
+ &outLen,
+ reinterpret_cast(ct.constData()),
+ ct.size())) {
+ logOpensslError("EVP_DecryptUpdate");
+ return false;
+ }
+ int written = outLen;
+
+ if (!EVP_CIPHER_CTX_ctrl(ctx,
+ EVP_CTRL_AEAD_SET_TAG,
+ tag.size(),
+ const_cast(tag.constData()))) {
+ logOpensslError("EVP_CIPHER_CTX_ctrl (set tag)");
+ return false;
+ }
+
+ int finalLen = 0;
+ // Authenticated decryption returns 0 here on tag mismatch — that's the
+ // signal we surface to the caller as "this packet was tampered with".
+ if (!EVP_DecryptFinal_ex(ctx,
+ reinterpret_cast(plain.data()) + written,
+ &finalLen)) {
+ // Don't log via logOpensslError here — tag mismatches are routine on
+ // hostile networks and would spam the log. Caller decides if it's
+ // worth surfacing.
+ return false;
+ }
+ written += finalLen;
+ plain.resize(written);
+ out = std::move(plain);
+ return true;
+}
+
+bool sealStream(EVP_CIPHER_CTX *ctx,
+ const EVP_CIPHER *cipher,
+ const QByteArray &key,
+ const QByteArray &plaintext,
+ const QByteArray &nonce,
+ QByteArray &out)
+{
+ if (!EVP_EncryptInit_ex(ctx,
+ cipher,
+ nullptr,
+ reinterpret_cast(key.constData()),
+ reinterpret_cast(nonce.constData()))) {
+ logOpensslError("EVP_EncryptInit_ex (stream)");
+ return false;
+ }
+ out.resize(plaintext.size());
+ int outLen = 0;
+ if (!EVP_EncryptUpdate(ctx,
+ reinterpret_cast(out.data()),
+ &outLen,
+ reinterpret_cast(plaintext.constData()),
+ plaintext.size())) {
+ logOpensslError("EVP_EncryptUpdate (stream)");
+ return false;
+ }
+ int finalLen = 0;
+ if (!EVP_EncryptFinal_ex(ctx,
+ reinterpret_cast(out.data()) + outLen,
+ &finalLen)) {
+ logOpensslError("EVP_EncryptFinal_ex (stream)");
+ return false;
+ }
+ out.resize(outLen + finalLen);
+ return true;
+}
+
+} // namespace
+
+bool Cipher::seal(const QByteArray &plaintext,
+ const QByteArray &nonce,
+ const QByteArray &aad,
+ QByteArray &out)
+{
+ if (nonce.size() != requiredNonceBytes(d->method)) {
+ qWarning("masterdnsvpn::Cipher::seal: nonce size %lld doesn't match "
+ "required %d for method %d",
+ static_cast(nonce.size()),
+ requiredNonceBytes(d->method),
+ static_cast(d->method));
+ return false;
+ }
+ switch (d->method) {
+ case CipherMethod::None:
+ out = plaintext;
+ return true;
+ case CipherMethod::Xor:
+ xorStream(d->key, plaintext, out);
+ return true;
+ case CipherMethod::ChaCha20:
+ return sealStream(d->encCtx, EVP_chacha20(), d->key, plaintext, nonce, out);
+ case CipherMethod::Aes128Gcm:
+ case CipherMethod::Aes192Gcm:
+ case CipherMethod::Aes256Gcm:
+ return sealAead(d->encCtx, evpCipherFor(d->method), d->key, plaintext, nonce, aad, out);
+ }
+ return false;
+}
+
+bool Cipher::open(const QByteArray &ciphertext,
+ const QByteArray &nonce,
+ const QByteArray &aad,
+ QByteArray &out)
+{
+ if (nonce.size() != requiredNonceBytes(d->method)) {
+ qWarning("masterdnsvpn::Cipher::open: nonce size %lld doesn't match "
+ "required %d for method %d",
+ static_cast(nonce.size()),
+ requiredNonceBytes(d->method),
+ static_cast(d->method));
+ return false;
+ }
+ switch (d->method) {
+ case CipherMethod::None:
+ out = ciphertext;
+ return true;
+ case CipherMethod::Xor:
+ xorStream(d->key, ciphertext, out);
+ return true;
+ case CipherMethod::ChaCha20: {
+ // ChaCha20 is a stream cipher: encrypt() and decrypt() are the
+ // same operation. EVP_DecryptInit_ex would also work but we keep
+ // the symmetric-call path explicit for testability.
+ if (!EVP_DecryptInit_ex(d->decCtx,
+ EVP_chacha20(),
+ nullptr,
+ reinterpret_cast(d->key.constData()),
+ reinterpret_cast(nonce.constData()))) {
+ logOpensslError("EVP_DecryptInit_ex (chacha20)");
+ return false;
+ }
+ out.resize(ciphertext.size());
+ int outLen = 0;
+ if (!EVP_DecryptUpdate(d->decCtx,
+ reinterpret_cast(out.data()),
+ &outLen,
+ reinterpret_cast(ciphertext.constData()),
+ ciphertext.size())) {
+ logOpensslError("EVP_DecryptUpdate (chacha20)");
+ return false;
+ }
+ int finalLen = 0;
+ if (!EVP_DecryptFinal_ex(d->decCtx,
+ reinterpret_cast(out.data()) + outLen,
+ &finalLen)) {
+ logOpensslError("EVP_DecryptFinal_ex (chacha20)");
+ return false;
+ }
+ out.resize(outLen + finalLen);
+ return true;
+ }
+ case CipherMethod::Aes128Gcm:
+ case CipherMethod::Aes192Gcm:
+ case CipherMethod::Aes256Gcm:
+ return openAead(d->decCtx, evpCipherFor(d->method), d->key, ciphertext, nonce, aad, out);
+ }
+ return false;
+}
+
+} // namespace amnezia::masterdnsvpn
diff --git a/client/masterdnsvpn/crypto.h b/client/masterdnsvpn/crypto.h
new file mode 100644
index 0000000000..15511ab5df
--- /dev/null
+++ b/client/masterdnsvpn/crypto.h
@@ -0,0 +1,116 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// Symmetric ciphers for the MasterDnsVPN tunnel. Wraps OpenSSL EVP, exposes
+// a tiny seal()/open() pair that takes the operator's pre-shared key and
+// the in-band nonce/IV the wire format reserves bytes for.
+//
+// Methods (matching upstream's DATA_ENCRYPTION_METHOD integer codes):
+//
+// None (0) — passthrough; no MAC. Useful only for protocol
+// diagnostics. The Engine refuses to start with this
+// in production builds (see engine.cpp).
+// Xor (1) — repeating-key XOR. Provides obfuscation only.
+// ChaCha20 (2) — stream cipher, 256-bit key, 96-bit nonce. No MAC.
+// Aes128Gcm (3) — AEAD, 128-bit key, 96-bit nonce, 128-bit tag.
+// Aes192Gcm (4) — AEAD, 192-bit key, 96-bit nonce, 128-bit tag.
+// Aes256Gcm (5) — AEAD, 256-bit key, 96-bit nonce, 128-bit tag.
+//
+// Key derivation: the operator-supplied `ENCRYPTION_KEY` is a free-form
+// string. Each method's `requiredKeyBytes()` reports the derived key size;
+// `deriveKey()` runs SHA-256 and truncates / hashes to fit. Matches what
+// upstream's Go implementation does at the byte level (see the spec doc).
+//
+// Threading: Cipher instances are NOT thread-safe. Build one per direction
+// (one for inbound, one for outbound) and serialise calls. ChaCha20 and
+// AES-GCM use OpenSSL EVP_CIPHER_CTX which carries key schedule state.
+
+#ifndef MASTERDNSVPN_CRYPTO_H
+#define MASTERDNSVPN_CRYPTO_H
+
+#include
+#include
+#include
+#include
+
+namespace amnezia::masterdnsvpn {
+
+enum class CipherMethod : int {
+ None = 0,
+ Xor = 1,
+ ChaCha20 = 2,
+ Aes128Gcm = 3,
+ Aes192Gcm = 4,
+ Aes256Gcm = 5,
+};
+
+// Convert from the integer wire value (0..5). Returns std::nullopt for
+// unrecognised codes — callers should treat that as a configuration error.
+std::optional cipherMethodFromInt(int code);
+
+// Required derived-key length (bytes) for a given cipher.
+int requiredKeyBytes(CipherMethod m);
+
+// Required nonce/IV length (bytes) for a given cipher.
+// None — 0
+// Xor — 0 (key reused as the running XOR stream; no per-packet IV)
+// ChaCha20 — 16 (4-byte little-endian block counter || 12-byte nonce —
+// matches upstream's golang.org/x/crypto/chacha20 usage,
+// which OpenSSL's EVP_chacha20 expects in the same order)
+// Aes*Gcm — 12
+int requiredNonceBytes(CipherMethod m);
+
+// AEAD authentication tag length (bytes). 0 for non-AEAD methods (None,
+// Xor, ChaCha20). 16 for AES-GCM.
+int authTagBytes(CipherMethod m);
+
+// Derive a fixed-length symmetric key from the operator's free-form
+// passphrase. Algorithm: SHA-256(passphrase) then take the first
+// `requiredKeyBytes(m)` bytes (or hash again if more bytes are needed in
+// some future cipher). Deterministic — same passphrase always yields the
+// same key, which is the property the wire protocol depends on (operator
+// and client must derive identical keys with no out-of-band coordination).
+QByteArray deriveKey(CipherMethod m, const QString &passphrase);
+
+// Stateful cipher object. Holds the OpenSSL EVP_CIPHER_CTX (or the XOR
+// running offset). Use one instance per direction; do not share across
+// threads.
+class Cipher
+{
+public:
+ Cipher();
+ ~Cipher();
+ Q_DISABLE_COPY_MOVE(Cipher)
+
+ // Initialise with a method + derived key. Returns false on bad inputs
+ // (wrong key size, OpenSSL allocation failure, …). Safe to call again
+ // to reinitialise; previous state is discarded.
+ bool init(CipherMethod m, const QByteArray &derivedKey);
+
+ // Encrypt `plaintext` into `out`. Caller supplies a per-packet `nonce`
+ // (must be `requiredNonceBytes(m)` long); `aad` is optional additional
+ // authenticated data for AEAD modes. The output layout is method-
+ // specific — for AEAD, the AEAD tag is appended to the ciphertext.
+ // Returns false on failure; `out` is then in an indeterminate state.
+ bool seal(const QByteArray &plaintext,
+ const QByteArray &nonce,
+ const QByteArray &aad,
+ QByteArray &out);
+
+ // Inverse of seal. Returns false on tag mismatch (AEAD), bad nonce
+ // size, or any OpenSSL error. Decryption never produces partial output:
+ // if it returns false the contents of `out` are not meaningful.
+ bool open(const QByteArray &ciphertext,
+ const QByteArray &nonce,
+ const QByteArray &aad,
+ QByteArray &out);
+
+ CipherMethod method() const;
+
+private:
+ struct Impl;
+ std::unique_ptr d;
+};
+
+} // namespace amnezia::masterdnsvpn
+
+#endif // MASTERDNSVPN_CRYPTO_H
diff --git a/client/masterdnsvpn/dnscache.cpp b/client/masterdnsvpn/dnscache.cpp
new file mode 100644
index 0000000000..4d1ca8df4b
--- /dev/null
+++ b/client/masterdnsvpn/dnscache.cpp
@@ -0,0 +1,171 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "dnscache.h"
+
+#include
+
+namespace amnezia::masterdnsvpn {
+
+// ---------------------------------------------------------------------
+// DnsLocalCache
+// ---------------------------------------------------------------------
+
+DnsLocalCache::DnsLocalCache() = default;
+
+void DnsLocalCache::setTtlMs(qint64 ttlMs)
+{
+ if (ttlMs > 0) {
+ m_ttlMs = ttlMs;
+ }
+}
+
+DnsCacheStatus DnsLocalCache::lookupOrCreatePending(const DnsCacheKey &key, qint64 nowMs)
+{
+ auto it = m_entries.find(key);
+ if (it == m_entries.end()) {
+ Entry e;
+ e.status = Status::Pending;
+ e.firstSeenMs = nowMs;
+ e.expiresAtMs = nowMs + m_ttlMs;
+ m_entries.insert(key, e);
+ return DnsCacheStatus::Miss;
+ }
+ if (it->status == Status::Ready) {
+ if (it->expiresAtMs <= nowMs) {
+ // Stale — purge and re-create as pending.
+ Entry e;
+ e.status = Status::Pending;
+ e.firstSeenMs = nowMs;
+ e.expiresAtMs = nowMs + m_ttlMs;
+ *it = e;
+ return DnsCacheStatus::Miss;
+ }
+ return DnsCacheStatus::Ready;
+ }
+ return DnsCacheStatus::Pending;
+}
+
+QByteArray DnsLocalCache::readyResponseFor(const DnsCacheKey &key) const
+{
+ auto it = m_entries.find(key);
+ if (it == m_entries.end() || it->status != Status::Ready) {
+ return {};
+ }
+ return it->response;
+}
+
+void DnsLocalCache::setReady(const DnsCacheKey &key, const QByteArray &response, qint64 nowMs)
+{
+ auto it = m_entries.find(key);
+ if (it == m_entries.end()) {
+ Entry e;
+ e.status = Status::Ready;
+ e.response = response;
+ e.firstSeenMs = nowMs;
+ e.expiresAtMs = nowMs + m_ttlMs;
+ m_entries.insert(key, e);
+ return;
+ }
+ it->status = Status::Ready;
+ it->response = response;
+ it->expiresAtMs = nowMs + m_ttlMs;
+}
+
+int DnsLocalCache::sweepExpired(qint64 nowMs)
+{
+ int purged = 0;
+ for (auto it = m_entries.begin(); it != m_entries.end();) {
+ if (it->expiresAtMs <= nowMs) {
+ it = m_entries.erase(it);
+ ++purged;
+ } else {
+ ++it;
+ }
+ }
+ return purged;
+}
+
+// ---------------------------------------------------------------------
+// DnsReassemblyStore
+// ---------------------------------------------------------------------
+
+void DnsReassemblyStore::track(quint16 seq, const DnsInFlight &inflight)
+{
+ m_inFlight.insert(seq, inflight);
+}
+
+bool DnsReassemblyStore::addFragment(quint16 seq,
+ quint8 fragId,
+ quint8 total,
+ const QByteArray &payload,
+ DnsInFlight &outInflight,
+ QByteArray &assembledOut)
+{
+ auto it = m_inFlight.find(seq);
+ if (it == m_inFlight.end()) {
+ return false;
+ }
+ // Single-fragment fast path: `total == 1` means the whole response
+ // is in this packet. Skip the array dance.
+ if (total <= 1) {
+ outInflight = *it;
+ assembledOut = payload;
+ m_inFlight.erase(it);
+ return true;
+ }
+ if (it->totalFragments == 0) {
+ it->totalFragments = total;
+ it->fragments.resize(total);
+ }
+ if (fragId >= total || fragId >= it->fragments.size()) {
+ return false; // out-of-range; drop the fragment
+ }
+ if (it->fragments[fragId].isEmpty()) {
+ it->fragments[fragId] = payload;
+ }
+ // Check completion: every slot must be non-empty (note: this means
+ // empty-payload fragments aren't supported, which matches upstream
+ // — DNS responses always have at least 12 bytes of header).
+ for (const QByteArray &f : it->fragments) {
+ if (f.isEmpty()) {
+ return false;
+ }
+ }
+ // Reassemble.
+ QByteArray full;
+ int totalSize = 0;
+ for (const QByteArray &f : it->fragments) totalSize += f.size();
+ full.reserve(totalSize);
+ for (const QByteArray &f : it->fragments) full.append(f);
+ outInflight = *it;
+ assembledOut = full;
+ m_inFlight.erase(it);
+ return true;
+}
+
+std::optional DnsReassemblyStore::take(quint16 seq)
+{
+ auto it = m_inFlight.find(seq);
+ if (it == m_inFlight.end()) {
+ return std::nullopt;
+ }
+ DnsInFlight result = *it;
+ m_inFlight.erase(it);
+ return result;
+}
+
+int DnsReassemblyStore::sweepExpired(qint64 nowMs, qint64 ttlMs)
+{
+ int purged = 0;
+ for (auto it = m_inFlight.begin(); it != m_inFlight.end();) {
+ if (nowMs - it->createdMs > ttlMs) {
+ it = m_inFlight.erase(it);
+ ++purged;
+ } else {
+ ++it;
+ }
+ }
+ return purged;
+}
+
+} // namespace amnezia::masterdnsvpn
diff --git a/client/masterdnsvpn/dnscache.h b/client/masterdnsvpn/dnscache.h
new file mode 100644
index 0000000000..7f74848cb0
--- /dev/null
+++ b/client/masterdnsvpn/dnscache.h
@@ -0,0 +1,166 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// Local DNS cache + DNS_QUERY_RES fragment reassembly.
+//
+// Two related but distinct pieces of state, packaged together because
+// both belong to the same per-session DNS-tunnel layer:
+//
+// * **DnsLocalCache** — (name, type, class) → response. Repeats of
+// the same lookup get answered from this store instead of round-
+// tripping the tunnel. Both for performance and for OPSEC: a local
+// observer watching loopback should see "DNS forwarder with cache",
+// not "everything always takes a tunnel round-trip" (which would
+// stand out from `dnsmasq` / `systemd-resolved`).
+//
+// * **DnsReassemblyStore** — `(sequenceNum) → fragment array`.
+// DNS_QUERY_RES packets from the tunnel arrive in N fragments
+// (FragmentID + TotalFragments wire fields). We accumulate them in
+// this store until all `total` arrive, then concatenate and emit
+// the full response.
+//
+// Neither piece is a hot-path data structure (a single client makes
+// O(10) DNS queries per minute under normal browser load), so we use
+// QHash + a single TTL-sweep QTimer rather than anything fancy.
+
+#ifndef MASTERDNSVPN_DNSCACHE_H
+#define MASTERDNSVPN_DNSCACHE_H
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace amnezia::masterdnsvpn {
+
+// Cache key: lowercase question name + numeric type + class. Equality
+// uses all three.
+struct DnsCacheKey {
+ QString name;
+ quint16 type = 0;
+ quint16 cls = 0;
+
+ bool operator==(const DnsCacheKey &o) const
+ {
+ return type == o.type && cls == o.cls && name == o.name;
+ }
+};
+
+inline size_t qHash(const DnsCacheKey &k, size_t seed = 0) noexcept
+{
+ return qHashMulti(seed, k.name, k.type, k.cls);
+}
+
+// Status of a cache lookup. Mirrors upstream's tri-state result so the
+// caller can decide whether to dispatch to the tunnel.
+enum class DnsCacheStatus {
+ Miss, // no entry; caller should dispatch + this lookup created a Pending entry
+ Pending, // entry exists but tunnel response hasn't arrived yet
+ Ready, // entry exists and response bytes are available
+};
+
+class DnsLocalCache
+{
+public:
+ DnsLocalCache();
+
+ // Default per-entry TTL (ms). Repeated queries within this window are
+ // served from the cache. Configurable per-session via setTtlMs().
+ static constexpr qint64 kDefaultTtlMs = 60'000;
+
+ void setTtlMs(qint64 ttlMs);
+ qint64 ttlMs() const { return m_ttlMs; }
+
+ // Look up an entry by key. Side effect: if no entry exists, creates
+ // a Pending one (so future lookups for the same name see Pending
+ // instead of Miss while the tunnel is in flight). The first caller
+ // gets Miss and is responsible for dispatching the tunnel query.
+ DnsCacheStatus lookupOrCreatePending(const DnsCacheKey &key, qint64 nowMs);
+
+ // Read a cached response. Returns the raw bytes (without txid
+ // patching — caller passes through patchDnsTxid). Returns empty
+ // QByteArray when the entry is Pending or absent.
+ QByteArray readyResponseFor(const DnsCacheKey &key) const;
+
+ // Store the response bytes (from the tunnel's DNS_QUERY_RES) under
+ // `key`, transitioning the entry to Ready. Sets the new expiry.
+ void setReady(const DnsCacheKey &key, const QByteArray &response, qint64 nowMs);
+
+ // Remove entries whose expiry has passed. Returns the number purged.
+ int sweepExpired(qint64 nowMs);
+
+ // Diagnostics.
+ int size() const { return m_entries.size(); }
+ void clear() { m_entries.clear(); }
+
+private:
+ enum class Status { Pending, Ready };
+ struct Entry {
+ Status status = Status::Pending;
+ QByteArray response;
+ qint64 expiresAtMs = 0;
+ qint64 firstSeenMs = 0;
+ };
+ QHash m_entries;
+ qint64 m_ttlMs = kDefaultTtlMs;
+};
+
+// ---------------------------------------------------------------------
+// DNS_QUERY_RES fragment reassembly
+// ---------------------------------------------------------------------
+
+// One in-flight DNS query. Owns the reply route back to the local SOCKS5
+// client (UDP address + port) and the accumulator for response fragments.
+struct DnsInFlight {
+ QHostAddress replyAddr;
+ quint16 replyPort = 0;
+ quint16 clientTxid = 0; // for response txid patching
+ DnsCacheKey cacheKey; // for setReady on completion
+ QVector fragments; // indexed by fragId; missing entries are empty
+ quint8 totalFragments = 0;
+ qint64 createdMs = 0;
+};
+
+class DnsReassemblyStore
+{
+public:
+ static constexpr qint64 kDefaultTtlMs = 10'000;
+
+ // Begin tracking a tunnel-dispatched query. `seq` is the
+ // DNS_QUERY_REQ wire sequence number — also expected on the matching
+ // DNS_QUERY_RES packets.
+ void track(quint16 seq, const DnsInFlight &inflight);
+
+ // Add an incoming fragment. Returns true + sets `assembledOut` to
+ // the full reassembled response when this fragment completes the
+ // set. Returns false (and leaves assembledOut untouched) while
+ // more fragments are pending.
+ //
+ // If the seq isn't tracked (orphan/late fragment) returns false and
+ // does nothing. Drops the entry on completion.
+ bool addFragment(quint16 seq,
+ quint8 fragId,
+ quint8 total,
+ const QByteArray &payload,
+ DnsInFlight &outInflight,
+ QByteArray &assembledOut);
+
+ // Pop the in-flight entry without checking completeness. Useful for
+ // cancellation paths (session reset, association teardown).
+ std::optional take(quint16 seq);
+
+ // Sweep entries that have been pending longer than `ttlMs`.
+ int sweepExpired(qint64 nowMs, qint64 ttlMs = kDefaultTtlMs);
+
+ int size() const { return m_inFlight.size(); }
+ void clear() { m_inFlight.clear(); }
+ bool contains(quint16 seq) const { return m_inFlight.contains(seq); }
+
+private:
+ QHash m_inFlight;
+};
+
+} // namespace amnezia::masterdnsvpn
+
+#endif // MASTERDNSVPN_DNSCACHE_H
diff --git a/client/masterdnsvpn/dnsframing.cpp b/client/masterdnsvpn/dnsframing.cpp
new file mode 100644
index 0000000000..b7ffaf9e4e
--- /dev/null
+++ b/client/masterdnsvpn/dnsframing.cpp
@@ -0,0 +1,572 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "dnsframing.h"
+
+#include
+#include
+#include
+#include
+#include