diff --git a/.gitignore b/.gitignore
index ef81971..58cb9c3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,6 +35,3 @@ cmd/*/resource_windows_*.syso
.gochache/
.android-build-audit/
.tmp-android-engine-upstream/
-
-# Local engineering audit (human-readable, intentionally not committed)
-reports/cottendns-network-engineering-audit.html
diff --git a/README.MD b/README.MD
index 7f7e0d9..92dde25 100644
--- a/README.MD
+++ b/README.MD
@@ -37,8 +37,7 @@ Recent work focused on staying usable on highly restrictive, lossy networks:
- **DNS-over-TLS and DNS-over-HTTPS.** `RESOLVER_TRANSPORT = dot | doh` encrypts the client→resolver hop, so on networks that fingerprint plaintext DNS on 53 the tunnel looks like a device using an encrypted DNS provider. **A public resolver needs no server change at all** — keep the resolver IPs you already use (`1.1.1.1`, `8.8.8.8`, `9.9.9.9`); the encryption covers exactly the hop that gets fingerprinted, and the resolver still reaches your server through the normal delegation. Strictly opt-in (`auto` never escalates into them) and never a one-way door: if the TLS port is blocked the client falls back to **UDP → TCP/53** by itself. Verify-by-default, with SPKI pinning (`RESOLVER_TLS_PIN`) for a self-signed server. Details: [Engineering Notes §17](docs/ENGINEERING_CHANGES.md).
- **Optional DoT/DoH server listeners that can share :443.** Only needed to point clients *directly* at this server. They reuse the same transport-agnostic packet handler as UDP/TCP, and TLS material resolves cert/key → ACME → self-signed so an enabled listener always comes up. `DOH_COEXIST_MODE` defaults to **never binding :443**, so a co-hosted panel (3x-ui, Hiddify, …) keeps the port and every inbound it supports — VMess/VLESS/Trojan, xhttp/gRPC/raw/ws/tls, CDN-fronted — keeps working untouched; taking the port is always an explicit choice. Both listeners off by default, and they draw from a capped connection sub-budget so flooding them can never starve the plain TCP/53 survival path.
-- **Adaptive per-resolver transport.** The server serves UDP/53 and TCP/53 on the same port. With `RESOLVER_TRANSPORT = auto`, the client measures both paths for every resolver and independently uses the fastest healthy one, so a poisoned or slow UDP resolver can move to TCP without forcing healthy resolvers to follow it. DoT/DoH remain user opt-ins. Every response channel (TXT/CNAME/A/NULL/HTTPS) works across the transport set.
-- **Packet-size-aware joint routing.** Resolver and transport are scored as one path for each packet. A fast low-MTU backup remains in the pool for ACK/control and small fragments instead of being discarded, while large fragments stay on paths whose measured upload MTU can carry them. Background scans can therefore add capacity without lowering the session MTU.
+- **DNS-over-TCP/53 fallback.** The server serves both UDP/53 and TCP/53 on the same port, and the client (`RESOLVER_TRANSPORT = auto`) probes over UDP first, then transparently re-probes the **whole fleet over TCP/53** if UDP finds no resolvers — surviving networks that filter or truncate UDP/53. Zero cost when UDP works. Every response channel (TXT/CNAME/A/NULL/HTTPS) works over TCP too.
- **TCP survival-path guardrails.** TCP/53 is treated as a first-class fallback path: per-IP connection caps, optional per-connection query limits, read-idle timeouts, and write deadlines protect the listener while keeping persistent DNS-over-TCP useful.
- **Paired config presets.** Bundled client/server pairs (`speed`, `survival`, `tcp-survival`) tune both sides together through `CONFIG_PRESET`, while explicit TOML/CLI values still override the profile.
- **More honest MTU loss reporting.** Loss-aware MTU probing now reports failures against the configured sample budget, so scans can show intermediate loss percentages instead of collapsing early rejects into only `0%` or `100%`.
@@ -530,16 +529,15 @@ So the worst case is that you end up exactly where you were before.
| Key (client) | Default | Purpose |
| --- | --- | --- |
| `RESOLVER_TRANSPORT` | `auto` | `auto` \| `udp` \| `tcp` \| `dot` \| `doh` |
-| `RESOLVER_TRANSPORT_PATHS` | `{}` | Optional per-resolver overrides, for example `{ "1.1.1.1" = "doh", "8.8.8.8" = "auto" }`. Keys may be resolver IPs, labels, or connection keys. |
-| `RESOLVER_TRANSPORT_BACKGROUND_SCAN_INTERVAL_SECONDS` | `30` | Low-rate interval for remeasuring alternate paths one resolver at a time. |
| `RESOLVER_TLS_SERVER_NAME` | *(empty)* | SNI/cert name. **Leave empty for public resolvers**; set it only when pointing at your own DoT/DoH server. |
| `RESOLVER_TLS_PIN` | *(empty)* | Base64 SHA-256 of the server certificate's SubjectPublicKeyInfo. Replaces CA validation — the right way to trust a **self-signed** server. Survives certificate renewal. |
| `RESOLVER_TLS_INSECURE_SKIP_VERIFY` | `false` | Last resort. The payload stays AEAD-encrypted either way, but an unverified hop can be silently intercepted. |
| `RESOLVER_DOT_PORT` / `RESOLVER_DOH_PORT` / `RESOLVER_DOH_PATH` | `853` / `443` / `/dns-query` | Where the resolver IP is contacted. |
-> `auto` never escalates into DoT/DoH. Add an explicit per-resolver override when
-> an encrypted resolver hop is wanted. Port/path and TLS settings remain shared by
-> resolvers using that encrypted transport.
+> **Limits.** The transport and its port/path are **client-wide, not per-resolver**:
+> you cannot run one resolver over DoH while another stays on UDP, and a provider
+> using a different path needs its own profile. Resolver entries are IPs, not
+> hostnames.
#### Optional: run your own DoT/DoH endpoint
@@ -636,7 +634,7 @@ The sample files are the source of truth for defaults and operational comments:
| 🪪 Tunnel identity/security | `DOMAINS`, `DATA_ENCRYPTION_METHOD`, `ENCRYPTION_KEY`, `QUERY_TYPES`, `DNS_RANDOMIZE_QUERY_ID`, `DNS_EDNS_COOKIE`, `DNS_QNAME_CASE_RANDOMIZATION`, `EDNS_UDP_SIZE`, `RESOLVER_IGNORE_INJECTED_NXDOMAIN` |
| 🧦 Local proxy | `PROTOCOL_TYPE`, `LISTEN_IP`, `LISTEN_PORT`, `SOCKS5_AUTH`, `SOCKS5_USER`, `SOCKS5_PASS` |
| 📛 Local DNS | `LOCAL_DNS_ENABLED`, `LOCAL_DNS_IP`, `LOCAL_DNS_PORT`, `LOCAL_DNS_CACHE_MAX_RECORDS`, `LOCAL_DNS_CACHE_TTL_SECONDS`, `LOCAL_DNS_PENDING_TIMEOUT_SECONDS`, `DNS_RESPONSE_FRAGMENT_TIMEOUT_SECONDS`, `LOCAL_DNS_CACHE_PERSIST_TO_FILE`, `LOCAL_DNS_CACHE_FLUSH_INTERVAL_SECONDS` |
-| 📡 Resolver/loss handling | `RESOLVER_TRANSPORT`, `RESOLVER_TRANSPORT_PATHS`, `RESOLVER_TRANSPORT_BACKGROUND_SCAN_INTERVAL_SECONDS`, `RESOLVER_BALANCING_STRATEGY`, `RESOLVER_RATE_LIMIT_ENABLED`, `UPLOAD_PACKET_DUPLICATION_COUNT`, `DOWNLOAD_PACKET_DUPLICATION_COUNT`, `UPLOAD_SETUP_PACKET_DUPLICATION_COUNT`, `DOWNLOAD_SETUP_PACKET_DUPLICATION_COUNT`, `STREAM_RESOLVER_FAILOVER_RESEND_THRESHOLD`, `STREAM_RESOLVER_FAILOVER_COOLDOWN`, `RECHECK_INACTIVE_SERVERS_ENABLED`, `RECHECK_INACTIVE_INTERVAL_SECONDS`, `RECHECK_SERVER_INTERVAL_SECONDS`, `RECHECK_BATCH_SIZE`, `AUTO_DISABLE_TIMEOUT_SERVERS`, `AUTO_DISABLE_TIMEOUT_WINDOW_SECONDS`, `AUTO_DISABLE_MIN_OBSERVATIONS`, `AUTO_DISABLE_CHECK_INTERVAL_SECONDS`, `BASE_ENCODE_DATA`, `DUPLICATION_PREFER_DISTINCT_DOMAINS`, `ADAPTIVE_DUPLICATION`, `ADAPTIVE_DUPLICATION_TARGET_DELIVERY` |
+| 📡 Resolver/loss handling | `RESOLVER_TRANSPORT`, `RESOLVER_BALANCING_STRATEGY`, `RESOLVER_RATE_LIMIT_ENABLED`, `UPLOAD_PACKET_DUPLICATION_COUNT`, `DOWNLOAD_PACKET_DUPLICATION_COUNT`, `UPLOAD_SETUP_PACKET_DUPLICATION_COUNT`, `DOWNLOAD_SETUP_PACKET_DUPLICATION_COUNT`, `STREAM_RESOLVER_FAILOVER_RESEND_THRESHOLD`, `STREAM_RESOLVER_FAILOVER_COOLDOWN`, `RECHECK_INACTIVE_SERVERS_ENABLED`, `RECHECK_INACTIVE_INTERVAL_SECONDS`, `RECHECK_SERVER_INTERVAL_SECONDS`, `RECHECK_BATCH_SIZE`, `AUTO_DISABLE_TIMEOUT_SERVERS`, `AUTO_DISABLE_TIMEOUT_WINDOW_SECONDS`, `AUTO_DISABLE_MIN_OBSERVATIONS`, `AUTO_DISABLE_CHECK_INTERVAL_SECONDS`, `BASE_ENCODE_DATA`, `DUPLICATION_PREFER_DISTINCT_DOMAINS`, `ADAPTIVE_DUPLICATION`, `ADAPTIVE_DUPLICATION_TARGET_DELIVERY` |
| 📦 Compression | `UPLOAD_COMPRESSION_TYPE`, `DOWNLOAD_COMPRESSION_TYPE`, `COMPRESSION_MIN_SIZE` |
| 📏 MTU discovery | `MIN_UPLOAD_MTU`, `MIN_DOWNLOAD_MTU`, `MAX_UPLOAD_MTU`, `MAX_DOWNLOAD_MTU`, `MTU_TEST_RETRIES_RESOLVERS`, `MTU_TEST_TIMEOUT_RESOLVERS`, `MTU_TEST_PARALLELISM_RESOLVERS`, `MTU_TEST_RETRIES_LOGS`, `MTU_TEST_TIMEOUT_LOGS`, `MTU_TEST_PARALLELISM_LOGS`, `MTU_PROBE_SAMPLES`, `MTU_MAX_LOSS`, `MTU_GROUP_GAP_RATIO`, `MTU_ADAPTIVE_GROUPING` |
| ⚙️ Workers/queues/timers | `RX_TX_WORKERS`, `TUNNEL_PROCESS_WORKERS`, `TUNNEL_PACKET_TIMEOUT_SECONDS`, `DISPATCHER_IDLE_POLL_INTERVAL_SECONDS`, `TX_CHANNEL_SIZE`, `RX_CHANNEL_SIZE`, `RESOLVER_UDP_CONNECTION_POOL_SIZE`, `STREAM_QUEUE_INITIAL_CAPACITY`, `ORPHAN_QUEUE_INITIAL_CAPACITY`, `DNS_RESPONSE_FRAGMENT_STORE_CAPACITY`, `SOCKS_UDP_ASSOCIATE_READ_TIMEOUT_SECONDS`, `CLIENT_TERMINAL_STREAM_RETENTION_SECONDS`, `CLIENT_CANCELLED_SETUP_RETENTION_SECONDS` |
@@ -854,7 +852,6 @@ Requirements:
| Go `1.25.0` | Build and test |
| Git | Version metadata and normal development |
| Python 3 | Optional local multi-target build helper |
-| GCC or Clang | CGO race-detector tests |
Build current platform:
@@ -884,17 +881,6 @@ go test -v -run TestName ./internal/client
go test -race ./internal/client ./internal/udpserver
```
-Reproduce the local poison, hijack, all-transport, and 40%/84%-loss environment
-on Windows:
-
-```powershell
-powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\test-hostile-network.ps1 -Count 10 -FullRace
-```
-
-The harness creates local hostile UDP/TCP responders, exercises native queries
-over UDP/TCP/DoT/DoH, and performs actual Reed-Solomon reconstruction under
-randomized loss. It does not contact public resolvers or require a live server.
-
Local multi-target build:
```bash
diff --git a/client_config.toml.simple b/client_config.toml.simple
index 2dd2f87..89ab670 100644
--- a/client_config.toml.simple
+++ b/client_config.toml.simple
@@ -154,10 +154,9 @@ LOCAL_DNS_CACHE_FLUSH_INTERVAL_SECONDS = 60.0
RESOLVER_BALANCING_STRATEGY = 3
# How DNS queries reach resolvers:
-# "auto" (default) - test UDP/53 and TCP/53 per resolver, then keep using the
-# fastest healthy path for each resolver independently.
-# A slow/failing UDP path can move to TCP without changing
-# the rest of the resolver pool.
+# "auto" (default) - probe over UDP/53 first; if NO resolver passes MTU
+# testing, retry the whole fleet over DNS-over-TCP/53.
+# Zero cost on UDP-working networks (TCP is never tried).
# "udp" - UDP only (legacy behavior).
# "tcp" - TCP/53 only, for networks that block or truncate UDP/53.
# "dot" - DNS-over-TLS (RFC 7858), normally :853.
@@ -174,19 +173,6 @@ RESOLVER_BALANCING_STRATEGY = 3
# DOH_LISTENER_ENABLED on the server (both off by default).
RESOLVER_TRANSPORT = "auto"
-# Optional per-resolver transport policy. Unlisted resolvers inherit the global
-# RESOLVER_TRANSPORT above. Keys may be an IP or IP:port. "auto" compares UDP
-# and TCP for that resolver; explicit "udp"/"tcp" stay pinned. "dot"/"doh" are
-# opt-in and retain UDP/TCP survival fallbacks if their encrypted path is blocked.
-# Examples:
-# RESOLVER_TRANSPORT_PATHS = { "1.1.1.1" = "tcp", "8.8.8.8:53" = "auto" }
-RESOLVER_TRANSPORT_PATHS = {}
-
-# Low-rate background path/MTU verification. At most one active resolver is
-# checked at a time, at the current session MTU, so alternate-path health and
-# latency stay fresh without taking bandwidth away from user traffic.
-RESOLVER_TRANSPORT_BACKGROUND_SCAN_INTERVAL_SECONDS = 30.0
-
# Encrypted-resolver settings (used only by "dot"/"doh").
# RESOLVER_TLS_SERVER_NAME is the SNI + certificate name presented to the
# resolver; leave empty to use the resolver IP (which then needs an IP SAN or a
diff --git a/docs/ANDROID_ENGINE_INTEGRATION.md b/docs/ANDROID_ENGINE_INTEGRATION.md
index 48659a2..d8737d3 100644
--- a/docs/ANDROID_ENGINE_INTEGRATION.md
+++ b/docs/ANDROID_ENGINE_INTEGRATION.md
@@ -59,10 +59,6 @@ existing launcher contract. The linker flags provide 16 KiB page compatibility.
and `WD_SCAN`.
- Generic SOCKS5 UDP, DNS fallback, loss recovery, adaptive duplication, and
server-advertised fairness remain part of this source tree.
-- Poison-aware question validation, per-resolver transport selection,
- packet-size-aware narrow-MTU routing, and rotating background path discovery
- are implemented inside this engine. Android receives the same behavior without
- a Kotlin port when its pinned CottenDNS SHA is advanced.
Pinning the engine SHA makes debug and release builds use identical engine code
and prevents stale prebuilt binaries from silently surviving an app merge.
diff --git a/docs/ENGINEERING_CHANGES.md b/docs/ENGINEERING_CHANGES.md
index a267e7d..6b46acc 100644
--- a/docs/ENGINEERING_CHANGES.md
+++ b/docs/ENGINEERING_CHANGES.md
@@ -389,11 +389,10 @@ length-prefixed, routed through the **exact same** transport-agnostic
load-shedding, graceful shutdown — so all tunnel logic (sessions, FEC, channels,
encryption) is shared with UDP, no duplication.
-**Client.** Resolver-local transport policy via
-`RESOLVER_TRANSPORT = auto | udp | tcp`:
-- **`auto` (default)** measures UDP and TCP/53 for every resolver, then keeps
- each resolver on its fastest healthy path. A bad UDP path can switch without
- moving the rest of the fleet.
+**Client.** Client-wide transport via `RESOLVER_TRANSPORT = auto | udp | tcp`:
+- **`auto` (default)** probes over UDP first; if **zero** resolvers pass MTU
+ testing, it flips to TCP and **re-probes the whole fleet over TCP/53**. On a
+ UDP-working network TCP is never attempted (zero cost).
- A `queryExchanger` abstraction makes the probe, session-init, and health paths
transport-agnostic.
- A persistent **per-resolver TCP connection manager** (`tcp_data.go`) serves the
@@ -667,9 +666,8 @@ doh ─► UDP ─► TCP/53 tcp ─► (no fallback)
auto ─► UDP ─► TCP/53
```
-The chain is `resolverTransportChain()` and MTU discovery walks it independently
-for each resolver. The background scanner keeps alternate paths measured after
-startup.
+The chain is `resolverTransportChain()`; the walk is in `RunInitialMTUTests`,
+which re-probes the whole fleet on each step down.
### 17.2 How they are wired into the data path
@@ -709,9 +707,10 @@ from the entry plus the transport's own port/path, so `1.1.1.1` becomes
`https://1.1.1.1:443/dns-query`. Cloudflare, Google and Quad9 publish certificates
carrying their **IP as a SAN**, so (A) validates with no configuration at all.
-Per-resolver overrides were added later in §25. The encrypted transport's
-port/path and TLS identity remain shared, but individual resolvers can now select
-`auto`, `udp`, `tcp`, `dot`, or `doh`.
+*Caveat:* the transport and its port/path are client-wide, not per-resolver. You
+cannot run one resolver over DoH while another stays on UDP, and providers using a
+different path cannot be mixed in one profile. The hedging is sequential
+(fallback), not parallel.
### 17.4 Certificate trust
@@ -1116,94 +1115,6 @@ than merely accepting it as a configuration value.
---
-## 25. Poison-aware per-resolver transport and path MTU
-
-Transport selection is now resolver-local instead of a whole-client fallback.
-`RESOLVER_TRANSPORT_PATHS` can override the global policy by connection key,
-resolver label, IP:port, or IP. `auto` measures UDP and TCP/53 for each resolver;
-DoT and DoH remain explicit user choices and retain UDP/TCP survival fallbacks.
-
-Initial MTU discovery probes every configured path separately and stores its RTT,
-loss, upload MTU, and download MTU. A bounded background scanner performs full
-MTU discovery on one rotating resolver/transport path at a time at
-`RESOLVER_TRANSPORT_BACKGROUND_SCAN_INTERVAL_SECONDS`, keeping alternate paths
-fresh without creating a scan burst or competing materially with user traffic.
-Selection uses estimated delivered goodput and will move a resolver away from a
-slow, failing, poisoned, or session-MTU-incompatible path. Bulk packets use only
-the best path; sparse high-priority/control traffic may hedge one alternate at a
-bounded interval, so duplication cannot cap bulk throughput.
-
-The runtime scheduler now scores `(resolver, transport)` jointly for the actual
-native packet type and payload size. MTU probe capacity is normalized for each
-packet header before eligibility is checked. A resolver or transport below the
-global session MTU is therefore not dead capacity: it can carry ACKs, controls,
-and any data fragment that fits, while larger fragments remain on wider paths.
-Stream affinity receives a small stability bias, not a hard pin, preventing
-reordering between equivalent paths without trapping a stream on a materially
-slower route. Bulk data never uses an unmeasured transport.
-
-If local UDP transmission, persistent TCP/DoT dialing/writing, or a DoH exchange
-fails, the exact unacknowledged DNS query is replayed immediately on the best
-eligible alternate resolver/transport. Replay preserves the native frame,
-session, and sequence identity, is capped at two path hops, and does not wait for
-the full ARQ RTO. ARQ/NACK remains the correctness backstop after the bounded
-fast replay is exhausted.
-
-Inbound replies are bound to resolver address, local socket, transport, query ID,
-and the complete DNS question. Same-ID replies carrying a different question are
-treated as injection and ignored. When injected NXDOMAIN filtering is enabled,
-the forged answer no longer consumes the outstanding request, allowing a genuine
-answer arriving moments later to win. Poison is evidence that an alternate path
-must be compared, not an automatic penalty against a UDP path that remains the
-fastest working option.
-
-Question fingerprints canonicalize ASCII letter case because DNS names are
-case-insensitive; resolvers that normalize 0x20 casing are accepted without
-weakening QTYPE/QCLASS or name matching. UDP replies carrying `TC=1` are treated
-as explicit hard path failures and move that resolver toward TCP immediately
-instead of spending multiple timeout windows on an answer that cannot fit.
-Poison evidence accelerates a following timeout for two minutes, then expires so
-an old incident cannot make a clean future network overly sensitive.
-
-An unusually fast forged response also acts as a Happy-Eyeballs trigger. The
-still-pending query is raced once on the best alternate path; the original is not
-cancelled, and the first response that passes DNS-question and native tunnel
-authentication atomically claims all replay siblings. Existing control hedges
-count as the alternate, preventing poison from causing duplicate amplification.
-
-Background path exploration is congestion-aware and capacity-budgeted. One
-rotating full MTU refresh is charged per 4096 original foreground frames, a
-conservative approximately 1-2% allowance using a 64-query scan cost model.
-No scan starts when TX, encoded-TX, RX, or pending-query pressure is elevated.
-Completely idle paths receive a stale-state refresh no more often than every two
-minutes (or four configured scan intervals), but even that exception yields to a
-single queued user packet.
-
-The final speed audit ranks replay candidates across the complete eligible
-`(resolver, transport)` set instead of preferring the same resolver by default.
-The common non-duplicated response path no longer scans the complete pending map,
-DNS question fingerprints are parsed once per normal ingress, and immutable
-encoded DNS frames are retained rather than copied into each pending/stream
-queue. Transport-manager publication is protected during runtime teardown.
-Poison/timeout correlation also accepts the timeout deadline being a few
-milliseconds earlier than the adjacent poison event, eliminating an
-event-ordering-dependent missed fast switch found by repeated race testing.
-
-After three successful samples, pending-query blackhole detection uses a
-conservative RTT-derived deadline (`6 × RTT + 500 ms`, with a 1.5-second floor
-and the configured request timeout as its ceiling). Slow/high-jitter paths retain
-the configured timeout. Late genuine replies remain claimable during the
-existing grace period and retract their timeout observation.
-
-The server remains transport-agnostic: UDP, TCP, DoT, and DoH feed the same
-authenticated native packet handler and keep the client's selected settings
-dynamic. The server's Super-FEC band now chooses enough parity for a 90% modeled
-block-recovery target, within Reed-Solomon and configured caps. Randomized loss
-tests exercise actual encoding and reconstruction at 40% and 84% loss; ARQ
-remains the correctness backstop when a block exceeds its parity budget.
-
----
-
*All changes keep ARQ as the correctness backstop; every optimization above is
designed to fail safe — if FEC, MTU grouping, a carrier, or a transport channel
does not help on a given path, the tunnel still delivers through the surviving
diff --git a/internal/client/async_runtime.go b/internal/client/async_runtime.go
index f2e04a9..0644ed1 100644
--- a/internal/client/async_runtime.go
+++ b/internal/client/async_runtime.go
@@ -11,11 +11,9 @@ package client
import (
"context"
- "encoding/binary"
"errors"
"fmt"
"net"
- "sort"
"time"
"cottendns-go/internal/arq"
@@ -25,43 +23,12 @@ import (
fragmentStore "cottendns-go/internal/fragmentstore"
)
-const (
- poisonReplayMaxDepth = uint8(1)
- failureReplayMaxDepth = uint8(2)
-)
-
const clientRXDropLogInterval = 2 * time.Second
type asyncReadPacket struct {
data []byte
addr *net.UDPAddr
localAddr string
- transport resolverTransport
-}
-
-func (c *Client) stopStreamDataManagers() {
- c.streamDataMu.Lock()
- managers := make([]streamDataTransport, 0, len(c.streamData))
- for transport, manager := range c.streamData {
- if manager != nil {
- managers = append(managers, manager)
- }
- delete(c.streamData, transport)
- }
- c.streamDataMu.Unlock()
- for _, manager := range managers {
- manager.Stop()
- }
-}
-
-func (c *Client) streamDataManager(transport resolverTransport) streamDataTransport {
- if c == nil {
- return nil
- }
- c.streamDataMu.RLock()
- manager := c.streamData[transport]
- c.streamDataMu.RUnlock()
- return manager
}
// StopAsyncRuntime stops all running workers (Readers, Writers, Processors).
@@ -70,7 +37,10 @@ func (c *Client) StopAsyncRuntime() {
if c.asyncCancel != nil {
c.log.Debugf("\U0001F6D1 Stopping Async Runtime...")
c.asyncCancel()
- c.stopStreamDataManagers()
+ if c.streamData != nil {
+ c.streamData.Stop()
+ c.streamData = nil
+ }
c.closeTunnelSockets()
c.asyncWG.Wait()
c.asyncCancel = nil
@@ -271,7 +241,10 @@ func (c *Client) StartAsyncRuntime(parentCtx context.Context) error {
return
}
cancel()
- c.stopStreamDataManagers()
+ if c.streamData != nil {
+ c.streamData.Stop()
+ c.streamData = nil
+ }
if c.tcpListener != nil {
c.tcpListener.Stop()
c.tcpListener = nil
@@ -285,13 +258,12 @@ func (c *Client) StartAsyncRuntime(parentCtx context.Context) error {
c.resetRuntimeBindings(false)
}()
- // 3. Keep every configured path ready concurrently. Auto itself still uses
- // only UDP/TCP; DoT/DoH are opened only when the user opts into them globally
- // or for an individual resolver.
- neededTransports := c.runtimeTransportsNeeded()
- useUDP := neededTransports[transportUDP]
+ // 3. Open dedicated UDP sockets only for UDP mode. Stream transports own
+ // their sockets and queues; allocating unused UDP descriptors wastes scarce
+ // resources on Android and large resolver fleets.
+ useStream := c.usesStreamTransport()
conns := make([]*net.UDPConn, 0, c.tunnelRX_TX_Workers)
- for i := 0; useUDP && i < c.tunnelRX_TX_Workers; i++ {
+ for i := 0; !useStream && i < c.tunnelRX_TX_Workers; i++ {
conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
for _, opened := range conns {
@@ -306,9 +278,6 @@ func (c *Client) StartAsyncRuntime(parentCtx context.Context) error {
c.tunnelConns = conns
c.resetTunnelActivity(c.now())
- c.runtimeOriginalSends.Store(0)
- c.warmPathBudgetSends.Store(0)
- c.warmPathLastScanUnix.Store(c.now().UnixNano())
c.log.Infof("\U0001F4E1 Async Runtime Initialized: %d RX/TX Workers, %d Processors",
c.tunnelRX_TX_Workers, c.tunnelProcessWorkers)
@@ -329,35 +298,22 @@ func (c *Client) StartAsyncRuntime(parentCtx context.Context) error {
}
}
- // 6. Stream transports feed the same receive channel as UDP and stay warm so
- // per-resolver switching has no reconnect-wide pause.
- c.streamDataMu.Lock()
- c.streamData = make(map[resolverTransport]streamDataTransport, 3)
- c.streamDataMu.Unlock()
- for _, transport := range []resolverTransport{transportTCP, transportDoT, transportDoH} {
- if !neededTransports[transport] {
- continue
- }
- var manager streamDataTransport
- switch transport {
+ // 6. Spawn ingestion. In UDP mode each socket has a reader worker. In TCP
+ // mode the persistent per-resolver TCP connections feed rxChannel from their
+ // own read loops, so no UDP readers are started.
+ if useStream {
+ active := c.activeTransport()
+ switch active {
case transportDoH:
- manager = newDoHDataManager(c)
+ c.streamData = newDoHDataManager(c)
case transportDoT:
- manager = newDoTDataManager(c)
+ c.streamData = newDoTDataManager(c)
default:
- manager = newTCPDataManager(c)
+ c.streamData = newTCPDataManager(c)
}
- manager.Start(runtimeCtx)
- c.streamDataMu.Lock()
- c.streamData[transport] = manager
- c.streamDataMu.Unlock()
- }
- if c.perResolverAutoTransport() {
- c.log.Infof("\U0001F517 Resolver transport: adaptive per resolver")
+ c.streamData.Start(runtimeCtx)
+ c.log.Infof("\U0001F517 Resolver transport: %s", active)
} else {
- c.log.Infof("\U0001F517 Resolver transport: %s", c.activeTransport())
- }
- if useUDP {
for i := 0; i < c.tunnelRX_TX_Workers; i++ {
c.asyncWG.Add(1)
go c.asyncReaderWorker(runtimeCtx, i, conns[i])
@@ -380,7 +336,7 @@ func (c *Client) StartAsyncRuntime(parentCtx context.Context) error {
for i := 0; i < c.tunnelRX_TX_Workers; i++ {
c.asyncWG.Add(1)
var conn *net.UDPConn
- if useUDP {
+ if !useStream {
conn = conns[i]
}
go c.asyncWriterWorker(runtimeCtx, i, conn)
@@ -573,7 +529,7 @@ func (c *Client) asyncEncodeWorker(ctx context.Context, id int) {
return
}
- if len(task.paths) == 0 {
+ if len(task.conns) == 0 {
if !task.wasPacked && task.selected != nil {
task.selected.ReleaseTXPacket(task.item)
}
@@ -601,8 +557,7 @@ func (c *Client) asyncEncodeWorker(ctx context.Context, id int) {
}
frames = frames[:0]
- for _, runtimePath := range task.paths {
- resolverConn := runtimePath.connection
+ for _, resolverConn := range task.conns {
datagramQueryType := c.nextQueryTypeForPath(resolverConn.Key)
domain := resolverConn.Domain
if domain == "" {
@@ -621,7 +576,7 @@ func (c *Client) asyncEncodeWorker(ctx context.Context, id int) {
continue
}
if preparedDomainByName == nil {
- preparedDomainByName = make(map[string]preparedTunnelDomain, len(task.paths))
+ preparedDomainByName = make(map[string]preparedTunnelDomain, len(task.conns))
}
preparedDomainByName[domain] = prepared
}
@@ -640,7 +595,7 @@ func (c *Client) asyncEncodeWorker(ctx context.Context, id int) {
dnsPacket = firstDNSPacket
default:
if packetByDomain == nil {
- packetByDomain = make(map[string][]byte, len(task.paths)-1)
+ packetByDomain = make(map[string][]byte, len(task.conns)-1)
}
var cached bool
cacheKey := domain + "#" + itoaInt(int(datagramQueryType))
@@ -655,14 +610,10 @@ func (c *Client) asyncEncodeWorker(ctx context.Context, id int) {
}
frames = append(frames, encodedOutboundDatagram{
- addr: addr,
- serverKey: resolverConn.Key,
- packet: dnsPacket,
- priority: Enums.DefaultPacketPriority(task.packetType),
- transport: runtimePath.transport,
- hedge: runtimePath.hedge,
- packetType: task.packetType,
- payloadSize: len(task.payload),
+ addr: addr,
+ serverKey: resolverConn.Key,
+ packet: dnsPacket,
+ priority: Enums.DefaultPacketPriority(task.packetType),
})
}
@@ -672,11 +623,6 @@ func (c *Client) asyncEncodeWorker(ctx context.Context, id int) {
}
continue
}
- if len(frames) > 1 {
- for index := range frames {
- frames[index].mayHaveSibling = true
- }
- }
encodedTask := encodedOutboundTask{
wasPacked: task.wasPacked,
@@ -707,6 +653,7 @@ func (c *Client) asyncWriterWorker(ctx context.Context, id int, conn *net.UDPCon
localAddr = conn.LocalAddr().String()
}
refreshWindow := c.tunnelPacketTimeout / 2
+ useStream := c.usesStreamTransport()
if refreshWindow < 250*time.Millisecond {
refreshWindow = 250 * time.Millisecond
}
@@ -719,7 +666,7 @@ func (c *Client) asyncWriterWorker(ctx context.Context, id int, conn *net.UDPCon
return
}
now := time.Now()
- if conn != nil && c.tunnelPacketTimeout > 0 {
+ if !useStream && conn != nil && c.tunnelPacketTimeout > 0 {
if lastDeadline.IsZero() || now.Add(refreshWindow).After(lastDeadline) {
lastDeadline = now.Add(c.tunnelPacketTimeout)
_ = conn.SetWriteDeadline(lastDeadline)
@@ -729,10 +676,19 @@ func (c *Client) asyncWriterWorker(ctx context.Context, id int, conn *net.UDPCon
if frame.addr == nil || len(frame.packet) == 0 {
continue
}
- if c.sendRuntimeFrameOver(conn, localAddr, frame, frame.transport, now) {
+ if useStream {
+ // TCP/DoT/DoH: route through the persistent per-resolver
+ // transport; Send handles its own send-tracking.
+ if c.streamData != nil {
+ c.streamData.Send(frame.serverKey, frame.addr, frame.packet, frame.priority, now)
+ }
continue
}
- c.replayRuntimeFrame(frame, frame.transport, conn, localAddr, failureReplayMaxDepth)
+ if _, err := conn.WriteToUDP(frame.packet, frame.addr); err == nil {
+ c.recordTunnelSend(now)
+ c.trackResolverSend(frame.packet, frame.addr.String(), localAddr, frame.serverKey, now)
+ c.txTotalBytes.Add(uint64(len(frame.packet)))
+ }
}
if !task.wasPacked && task.selected != nil {
task.selected.ReleaseTXPacket(task.item)
@@ -741,186 +697,6 @@ func (c *Client) asyncWriterWorker(ctx context.Context, id int, conn *net.UDPCon
}
}
-func (c *Client) sendRuntimeFrameOver(
- udpConn *net.UDPConn,
- localAddr string,
- frame encodedOutboundDatagram,
- transport resolverTransport,
- now time.Time,
-) bool {
- if c.resolverReplayCompleted(frame, now) {
- return true
- }
- if transport == transportUDP {
- if udpConn == nil {
- return false
- }
- if _, err := udpConn.WriteToUDP(frame.packet, frame.addr); err == nil {
- c.recordTunnelSend(now)
- c.trackResolverFrameOver(frame, localAddr, transportUDP, now)
- c.txTotalBytes.Add(uint64(len(frame.packet)))
- c.noteOriginalRuntimeSend(frame)
- return true
- } else {
- c.recordResolverHealthEvent(frame.serverKey, false, now)
- c.noteResolverTransportFailure(frame.serverKey, transportUDP, now)
- }
- return false
- }
- if manager := c.streamDataManager(transport); manager != nil {
- frame.transport = transport
- if manager.Send(frame, now) {
- c.noteOriginalRuntimeSend(frame)
- return true
- }
- }
- c.noteResolverTransportFailure(frame.serverKey, transport, now)
- return false
-}
-
-func (c *Client) noteOriginalRuntimeSend(frame encodedOutboundDatagram) {
- if c != nil && frame.replayDepth == 0 && !frame.hedge {
- c.runtimeOriginalSends.Add(1)
- }
-}
-
-// replayPendingResolverSample turns an authenticated-question poison signal
-// into a single immediate race on the best alternate path. The original sample
-// stays pending; trackResolverSuccessOver atomically lets only the first genuine
-// tunnel response win.
-func (c *Client) replayPendingResolverSample(key resolverSampleKey, maxDepth uint8) bool {
- if c == nil {
- return false
- }
- c.resolverStatsMu.Lock()
- actualKey, sample, ok := c.resolverSampleLocked(key)
- if !ok || len(sample.packet) == 0 || sample.replayDepth >= maxDepth || sample.replayTriggered {
- c.resolverStatsMu.Unlock()
- return false
- }
- activeSibling := false
- for siblingKey, sibling := range c.resolverPending {
- if siblingKey.dnsID != actualKey.dnsID ||
- sibling.questionFingerprint != sample.questionFingerprint {
- continue
- }
- if siblingKey != actualKey && !sibling.timedOut {
- activeSibling = true
- }
- }
- sample.replayTriggered = true
- c.resolverPending[actualKey] = sample
- c.resolverStatsMu.Unlock()
- // A normal hedge is already the desired Happy-Eyeballs race.
- if activeSibling {
- return true
- }
- frame := encodedOutboundDatagram{
- serverKey: sample.serverKey,
- packet: sample.packet,
- priority: sample.priority,
- transport: sample.transport,
- packetType: sample.packetType,
- payloadSize: sample.payloadSize,
- replayDepth: sample.replayDepth,
- }
- return c.replayRuntimeFrame(frame, sample.transport, nil, "", maxDepth)
-}
-
-// replayRuntimeFrame reuses the exact DNS query and native tunnel frame on an
-// alternate resolver/transport. No session handshake or ARQ wait is involved.
-// Replays are bounded and are never recursively duplicated by normal hedging.
-func (c *Client) replayRuntimeFrame(
- frame encodedOutboundDatagram,
- failed resolverTransport,
- udpConn *net.UDPConn,
- localAddr string,
- maxDepth uint8,
-) bool {
- if c == nil || len(frame.packet) == 0 || frame.replayDepth >= maxDepth {
- return false
- }
-
- type replayCandidate struct {
- connection Connection
- transport resolverTransport
- score float64
- }
- candidates := make([]replayCandidate, 0, 8)
- connections := c.connections
- if c.balancer != nil {
- connections = c.balancer.AllValidConnectionsIncludingBackup()
- }
- eligible := make([]Connection, 0, len(connections))
- for _, conn := range connections {
- if conn.IsValid && conn.Key != "" && !c.isRuntimeDisabledResolver(conn.Key) {
- eligible = append(eligible, conn)
- }
- }
- c.resolverTransportMu.Lock()
- for _, conn := range eligible {
- state := c.resolverTransportStateLocked(conn.Key)
- for _, transport := range c.resolverTransportCandidates(conn.Key) {
- if conn.Key == frame.serverKey && transport == failed {
- continue
- }
- pathScore := pathScoreFor(state, transport)
- if !pathSupportsPacket(pathScore, frame.packetType, frame.payloadSize) {
- continue
- }
- score := pathEstimatedGoodputForPacket(pathScore, frame.packetType)
- if !pathScore.probed {
- score = fallbackConnectionPathScore(conn, frame.packetType) * 0.5
- }
- candidates = append(candidates, replayCandidate{
- connection: conn,
- transport: transport,
- score: score,
- })
- }
- }
- c.resolverTransportMu.Unlock()
- sort.SliceStable(candidates, func(i, j int) bool {
- if candidates[i].score == candidates[j].score {
- if candidates[i].connection.Key == candidates[j].connection.Key {
- return candidates[i].transport < candidates[j].transport
- }
- return candidates[i].connection.Key < candidates[j].connection.Key
- }
- return candidates[i].score > candidates[j].score
- })
-
- for _, candidate := range candidates {
- addr, err := c.getResolverUDPAddr(candidate.connection)
- if err != nil {
- continue
- }
- replay := frame
- replay.addr = addr
- replay.serverKey = candidate.connection.Key
- replay.transport = candidate.transport
- replay.hedge = false
- replay.replayDepth++
- replay.mayHaveSibling = true
-
- if udpConn != nil {
- if c.sendRuntimeFrameOver(udpConn, localAddr, replay, replay.transport, c.now()) {
- return true
- }
- continue
- }
- select {
- case c.encodedTXChannel <- encodedOutboundTask{frames: []encodedOutboundDatagram{replay}}:
- return true
- default:
- // A saturated writer queue is congestion, not permission to amplify
- // it. ARQ remains the final recovery layer.
- return false
- }
- }
- return false
-}
-
// asyncReaderWorker reads raw UDP data and pushes to the rxChannel (Internal Queue).
func (c *Client) asyncReaderWorker(ctx context.Context, id int, conn *net.UDPConn) {
defer c.asyncWG.Done()
@@ -963,7 +739,7 @@ func (c *Client) asyncReaderWorker(ctx context.Context, id int, conn *net.UDPCon
packetData := buf[:n]
select {
- case c.rxChannel <- asyncReadPacket{data: packetData, addr: addr, localAddr: localAddr, transport: transportUDP}:
+ case c.rxChannel <- asyncReadPacket{data: packetData, addr: addr, localAddr: localAddr}:
default:
// Queue full! Drop packet and RECYCLE buffer.
c.udpBufferPool.Put(buf)
@@ -982,7 +758,7 @@ func (c *Client) asyncProcessorWorker(ctx context.Context, id int) {
case <-ctx.Done():
return
case pkt := <-c.rxChannel:
- c.handleInboundPacketOver(pkt.data, pkt.addr, pkt.localAddr, pkt.transport)
+ c.handleInboundPacket(pkt.data, pkt.addr, pkt.localAddr)
// RECYCLE buffer back to the pool.
c.udpBufferPool.Put(pkt.data[:cap(pkt.data)])
@@ -992,15 +768,7 @@ func (c *Client) asyncProcessorWorker(ctx context.Context, id int) {
// handleInboundPacket is the central entry point for all received tunnel packets.
func (c *Client) handleInboundPacket(data []byte, addr *net.UDPAddr, localAddr string) {
- c.handleInboundPacketOver(data, addr, localAddr, c.activeTransport())
-}
-
-func (c *Client) handleInboundPacketOver(data []byte, addr *net.UDPAddr, localAddr string, transport resolverTransport) {
// c.log.Debugf("Inbound packet from %v (%d bytes)", addr, len(data))
- validQuestion, questionFingerprint := c.validateInboundQuestionFingerprint(data, addr, localAddr, transport)
- if !validQuestion {
- return
- }
// 1. Extract VPN Packet from DNS Response (TXT chunks or, for A2 rotated
// queries, a CNAME answer decoded against the configured tunnel domains).
@@ -1008,35 +776,18 @@ func (c *Client) handleInboundPacketOver(data []byte, addr *net.UDPAddr, localAd
if err != nil {
if errors.Is(err, DnsParser.ErrTXTAnswerMissing) {
receivedAt := time.Now()
- if parsed, parseErr := DnsParser.ParsePacketLite(data); parseErr == nil {
- if transport == transportUDP && parsed.Header.TC != 0 {
- // TC=1 is a direct indication that this UDP carrier cannot
- // deliver the answer. Consume the attempt as a hard path
- // failure so the resolver moves to TCP without waiting for
- // repeated timeouts. ARQ retains the native frame.
- c.replayPendingResolverSample(resolverSampleKey{
- resolverAddr: addr.String(),
- localAddr: localAddr,
- dnsID: binary.BigEndian.Uint16(data[:2]),
- transport: transport,
- questionFingerprint: dnsQuestionFingerprint(data),
- }, failureReplayMaxDepth)
- c.trackResolverHardFailureOver(data, addr, localAddr, transport, receivedAt)
- return
- }
- if parsed.Header.RCode != 0 && c.rcodeIsInjectedNoise(parsed.Header.RCode) {
+ if parsed, parseErr := DnsParser.ParsePacketLite(data); parseErr == nil && parsed.Header.RCode != 0 {
+ if c.rcodeIsInjectedNoise(parsed.Header.RCode) {
// On-path DNS poisoning: a forged NXDOMAIN raced the real
// answer. Ignore it WITHOUT consuming the pending query
// sample, so the genuine response can still be scored as a
// success (or time out if the resolver is truly dead). This
// stops the censor from throttling/disabling working
// resolvers — and their share of forged failures — for free.
- c.noteInjectedResolverNoise(data, addr, localAddr, transport)
+ c.noteInjectedResolverNoise(addr)
return
}
- if parsed.Header.RCode != 0 {
- c.trackResolverFailureOver(data, addr, localAddr, transport, receivedAt)
- }
+ c.trackResolverFailure(data, addr, localAddr, receivedAt)
}
// summary := DnsParser.DescribeResponseWithoutTunnelPayload(data)
// c.log.Debugf("DNS response from %v had no tunnel TXT payload | %s", addr, summary)
@@ -1046,16 +797,7 @@ func (c *Client) handleInboundPacketOver(data []byte, addr *net.UDPAddr, localAd
return
}
- // DNS question matching proves that the reply belongs to this query; the
- // native session identity proves that its tunnel frame belongs to the live
- // session. Do not let a syntactically valid forged frame win a replay race.
- if c.sessionReady &&
- (vpnPacket.SessionID != c.sessionID || vpnPacket.SessionCookie != c.sessionCookie) {
- return
- }
- if !c.trackResolverSuccessOverFingerprint(data, addr, localAddr, transport, time.Now(), questionFingerprint) {
- return
- }
+ c.trackResolverSuccess(data, addr, localAddr, time.Now())
// if c.log != nil && c.log.Enabled(logger.LevelDebug) && vpnPacket.PacketType != Enums.PACKET_PONG {
// if vpnPacket.PacketType == Enums.PACKET_STREAM_DATA_ACK {
// c.log.Debugf("Client received ACK | Stream: %d | Seq: %d", vpnPacket.StreamID, vpnPacket.SequenceNum)
diff --git a/internal/client/async_runtime_test.go b/internal/client/async_runtime_test.go
index c8c5986..5b2cdb8 100644
--- a/internal/client/async_runtime_test.go
+++ b/internal/client/async_runtime_test.go
@@ -11,8 +11,6 @@ import (
"context"
"encoding/binary"
"net"
- "sync"
- "sync/atomic"
"testing"
"time"
@@ -24,16 +22,6 @@ import (
"cottendns-go/internal/security"
)
-type testStreamDataManager struct {
- stops atomic.Int32
-}
-
-func (*testStreamDataManager) Start(context.Context) {}
-func (m *testStreamDataManager) Stop() { m.stops.Add(1) }
-func (*testStreamDataManager) Send(encodedOutboundDatagram, time.Time) bool {
- return true
-}
-
func createTestClient(t *testing.T) *Client {
cfg := config.ClientConfig{
LogLevel: "debug",
@@ -417,28 +405,3 @@ func TestHandleInboundPacketTreatsServerFailureWithoutTXTAsResolverFailure(t *te
t.Fatalf("expected one failure health event after SERVFAIL response, got=%d", len(state.Events))
}
}
-
-func TestStreamManagerLookupAndShutdownAreConcurrentSafe(t *testing.T) {
- manager := &testStreamDataManager{}
- c := &Client{streamData: map[resolverTransport]streamDataTransport{
- transportTCP: manager,
- }}
- var wg sync.WaitGroup
- wg.Add(1)
- go func() {
- defer wg.Done()
- for i := 0; i < 10_000; i++ {
- if current := c.streamDataManager(transportTCP); current != nil {
- current.Send(encodedOutboundDatagram{}, time.Now())
- }
- }
- }()
- c.stopStreamDataManagers()
- wg.Wait()
- if got := manager.stops.Load(); got != 1 {
- t.Fatalf("manager Stop calls=%d, want 1", got)
- }
- if got := c.streamDataManager(transportTCP); got != nil {
- t.Fatal("stopped manager remained published")
- }
-}
diff --git a/internal/client/client.go b/internal/client/client.go
index 74e97cc..e3b9ebd 100644
--- a/internal/client/client.go
+++ b/internal/client/client.go
@@ -53,32 +53,27 @@ type Client struct {
codec *security.Codec
balancer *Balancer
- connections []Connection
- connectionsByKey map[string]int
- successMTUChecks bool
- udpBufferPool sync.Pool
- resolverConnsMu sync.Mutex
- resolverConns map[string]chan pooledUDPConn
- resolverAddrMu sync.RWMutex
- resolverAddrCache map[string]*net.UDPAddr
- resolverStatsMu sync.RWMutex
- resolverPending map[resolverSampleKey]resolverSample
- resolverCompleted map[resolverCompletedKey]time.Time
- resolverTransportMu sync.Mutex
- resolverTransports map[string]*resolverTransportState
- resolverHealthMu sync.RWMutex
- resolverHealth map[string]*resolverHealthState
- resolverRecheck map[string]resolverRecheckState
- runtimeDisabled map[string]resolverDisabledState
- resolverRecheckSem chan struct{}
+ connections []Connection
+ connectionsByKey map[string]int
+ successMTUChecks bool
+ udpBufferPool sync.Pool
+ resolverConnsMu sync.Mutex
+ resolverConns map[string]chan pooledUDPConn
+ resolverAddrMu sync.RWMutex
+ resolverAddrCache map[string]*net.UDPAddr
+ resolverStatsMu sync.RWMutex
+ resolverPending map[resolverSampleKey]resolverSample
+ resolverHealthMu sync.RWMutex
+ resolverHealth map[string]*resolverHealthState
+ resolverRecheck map[string]resolverRecheckState
+ runtimeDisabled map[string]resolverDisabledState
+ resolverRecheckSem chan struct{}
// Unix-nanos of the last speculative "discovery" recheck (re-probing a
// never-valid resolver). Trickles discovery so it never bursts bandwidth
// away from the user's live traffic; see runResolverRecheckBatch.
lastDiscoveryRecheckUnix atomic.Int64
nowFn func() time.Time
recheckConnectionFn func(conn *Connection) bool
- probeConnectionMTUOverFn func(context.Context, *Connection, int, resolverTransport) (mtuConnectionProbeResult, mtuRejectReason)
- probeSessionMTUOverFn func(context.Context, *Connection, resolverTransport) (mtuConnectionProbeResult, bool)
resolverRuntimeLogMu sync.Mutex
lastResolverRuntimeLog string
lastResolverRuntimeLogAt time.Time
@@ -183,22 +178,13 @@ type Client struct {
txAdmissionDrops atomic.Uint64
streamDialFailures atomic.Uint64
streamWriteFailures atomic.Uint64
- // Warm-path discovery is charged against successful foreground sends. One
- // bounded MTU refresh is allowed per 4096 original frames (roughly a 1-2%
- // probe budget even for a conservative 64-query scan), or when the tunnel
- // has been idle long enough that alternate transport state would go stale.
- runtimeOriginalSends atomic.Uint64
- warmPathBudgetSends atomic.Uint64
- warmPathLastScanUnix atomic.Int64
- lastFECReceived atomic.Int64
- runtimeReadBufferSize int
- lastRXDropLogUnix atomic.Int64
+ lastFECReceived atomic.Int64
+ runtimeReadBufferSize int
+ lastRXDropLogUnix atomic.Int64
// injectedNXDOMAINCount counts forged NXDOMAIN responses ignored as on-path
// DNS poisoning (see RESOLVER_IGNORE_INJECTED_NXDOMAIN). Purely observational.
injectedNXDOMAINCount atomic.Uint64
lastInjectionLogUnix atomic.Int64
- resolverHijackCount atomic.Uint64
- lastHijackLogUnix atomic.Int64
// Traffic byte counters (per-session, reset on resetRuntimeBindings)
txTotalBytes atomic.Uint64
@@ -222,14 +208,12 @@ type Client struct {
// RunInitialMTUTests: "auto" escalates UDP->TCP, while the opt-in encrypted
// transports (DoT/DoH) fall back to UDP and then TCP/53 if they cannot carry
// the tunnel. All query paths (probe, session-init, health, data plane)
- // dispatch on it. streamData keeps each required persistent transport warm,
- // allowing one resolver to use TCP while another uses DoT/DoH without a
- // client-wide restart.
- transport atomic.Int32
- streamDataMu sync.RWMutex
- streamData map[resolverTransport]streamDataTransport
- dohHTTPMu sync.Mutex
- dohHTTP *http.Client
+ // dispatch on it. streamData carries the persistent per-resolver connections
+ // used by the data plane whenever the transport is not UDP.
+ transport atomic.Int32
+ streamData streamDataTransport
+ dohHTTPMu sync.Mutex
+ dohHTTP *http.Client
// pacer applies per-resolver adaptive rate limiting (see resolver_pacer.go).
pacer *resolverPacer
@@ -292,22 +276,14 @@ type rawOutboundTask struct {
wasPacked bool
item *clientStreamTXPacket
selected *Stream_client
- paths []resolverRuntimePath
+ conns []Connection
}
type encodedOutboundDatagram struct {
- addr *net.UDPAddr
- serverKey string
- packet []byte
- priority int
- transport resolverTransport
- hedge bool
- packetType uint8
- payloadSize int
- // replayDepth bounds path-failure recovery. It is deliberately separate
- // from ARQ retry state: the native frame and session remain unchanged.
- replayDepth uint8
- mayHaveSibling bool
+ addr *net.UDPAddr
+ serverKey string
+ packet []byte
+ priority int
}
type encodedOutboundTask struct {
@@ -499,9 +475,6 @@ func New(cfg config.ClientConfig, log *logger.Logger, codec *security.Codec) *Cl
resolverConns: make(map[string]chan pooledUDPConn),
resolverAddrCache: make(map[string]*net.UDPAddr),
resolverPending: make(map[resolverSampleKey]resolverSample),
- resolverCompleted: make(map[resolverCompletedKey]time.Time),
- resolverTransports: make(map[string]*resolverTransportState),
- streamData: make(map[resolverTransport]streamDataTransport),
resolverHealth: make(map[string]*resolverHealthState),
resolverRecheck: make(map[string]resolverRecheckState),
runtimeDisabled: make(map[string]resolverDisabledState),
diff --git a/internal/client/dispatcher.go b/internal/client/dispatcher.go
index acc2dbc..42d60a0 100644
--- a/internal/client/dispatcher.go
+++ b/internal/client/dispatcher.go
@@ -220,39 +220,6 @@ dispatchLoop:
opts.TotalFragments = item.TotalFragments
}
- targetCount := c.runtimePacketDuplicationCount(finalPacketType)
- paths := c.selectJointRuntimePaths(
- finalPacketType,
- selectedStreamID,
- len(finalPayload),
- targetCount,
- c.now(),
- )
- if len(paths) == 0 {
- // The global active pool was already checked above. Preserve the
- // historical route as an emergency fallback if path telemetry is
- // temporarily incomplete during startup.
- paths = make([]resolverRuntimePath, 0, len(conns))
- for _, conn := range conns {
- decision := c.chooseResolverTransport(
- conn.Key,
- Enums.DefaultPacketPriority(finalPacketType),
- c.now(),
- )
- paths = append(paths, resolverRuntimePath{
- connection: conn,
- transport: decision.primary,
- })
- if decision.hedge && decision.secondary != decision.primary {
- paths = append(paths, resolverRuntimePath{
- connection: conn,
- transport: decision.secondary,
- hedge: true,
- })
- }
- }
- }
-
task := rawOutboundTask{
packetType: finalPacketType,
payload: finalPayload,
@@ -260,7 +227,7 @@ dispatchLoop:
wasPacked: wasPacked,
item: item,
selected: selected,
- paths: paths,
+ conns: conns,
}
select {
diff --git a/internal/client/doh_transport.go b/internal/client/doh_transport.go
index 11adb48..86722f6 100644
--- a/internal/client/doh_transport.go
+++ b/internal/client/doh_transport.go
@@ -24,7 +24,6 @@ package client
import (
"bytes"
"context"
- "encoding/binary"
"errors"
"io"
"net"
@@ -148,16 +147,7 @@ func (t *dohQueryTransport) exchange(packet []byte, timeout time.Duration) ([]by
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
- response, err := dohExchange(ctx, t.httpClient, t.endpoint, packet)
- if err != nil {
- return nil, err
- }
- if len(packet) < 2 || len(response) < 2 ||
- binary.BigEndian.Uint16(packet[:2]) != binary.BigEndian.Uint16(response[:2]) ||
- (dnsQuestionFingerprint(packet) != 0 && dnsQuestionFingerprint(packet) != dnsQuestionFingerprint(response)) {
- return nil, errors.New("doh: response does not match query")
- }
- return response, nil
+ return dohExchange(ctx, t.httpClient, t.endpoint, packet)
}
func (t *dohQueryTransport) Close() error {
@@ -182,8 +172,10 @@ type dohDataManager struct {
}
type dohDataJob struct {
- frame encodedOutboundDatagram
- now time.Time
+ serverKey string
+ addr *net.UDPAddr
+ body []byte
+ now time.Time
}
func newDoHDataManager(c *Client) *dohDataManager {
@@ -225,30 +217,26 @@ func (m *dohDataManager) Stop() {
// Send queues one already-built DNS query. A bounded worker pool performs the
// POST and feeds the answer back into rxChannel; control packets have reserved
// capacity so a bulk burst cannot strand ACKs or session traffic.
-func (m *dohDataManager) Send(frame encodedOutboundDatagram, now time.Time) bool {
- if m == nil || frame.addr == nil || len(frame.packet) == 0 {
- return false
+func (m *dohDataManager) Send(serverKey string, addr *net.UDPAddr, packet []byte, priority int, now time.Time) {
+ if m == nil || addr == nil || len(packet) == 0 {
+ return
}
m.mu.Lock()
ctx, dead := m.ctx, m.dead
m.mu.Unlock()
if dead || ctx == nil || ctx.Err() != nil {
- return false
+ return
}
- // Encoded runtime packets are immutable; retaining the frame is sufficient
- // to keep its backing array alive until the HTTP exchange completes.
- job := dohDataJob{frame: frame, now: now}
+ job := dohDataJob{serverKey: serverKey, addr: addr, body: append([]byte(nil), packet...), now: now}
queue := m.dataQ
- if frame.priority <= Enums.PacketPriorityHigh {
+ if priority <= Enums.PacketPriorityHigh {
queue = m.controlQ
}
select {
case queue <- job:
- return true
default:
m.client.txAdmissionDrops.Add(1)
- return false
}
}
@@ -277,18 +265,13 @@ func (m *dohDataManager) worker(ctx context.Context) {
}
func (m *dohDataManager) exchangeJob(ctx context.Context, job dohDataJob) {
- if m.client.resolverReplayCompleted(job.frame, time.Now()) {
- return
- }
- endpoint := m.client.dohEndpoint(job.frame.addr.String())
- m.client.trackResolverFrameOver(job.frame, "", transportDoH, job.now)
- m.client.txTotalBytes.Add(uint64(len(job.frame.packet)))
+ endpoint := m.client.dohEndpoint(job.addr.String())
+ m.client.trackResolverSend(job.body, job.addr.String(), "", job.serverKey, job.now)
+ m.client.txTotalBytes.Add(uint64(len(job.body)))
reqCtx, cancel := context.WithTimeout(ctx, m.client.resolverRequestTimeout())
defer cancel()
- response, err := dohExchange(reqCtx, m.httpClient, endpoint, job.frame.packet)
+ response, err := dohExchange(reqCtx, m.httpClient, endpoint, job.body)
if err != nil || len(response) < 12 || (response[2]&0x80) == 0 {
- m.client.trackResolverFailureOver(job.frame.packet, job.frame.addr, "", transportDoH, time.Now())
- m.client.replayRuntimeFrame(job.frame, transportDoH, nil, "", failureReplayMaxDepth)
return
}
buf := m.client.getRuntimeUDPBuffer()
@@ -299,9 +282,9 @@ func (m *dohDataManager) exchangeJob(ctx context.Context, job dohDataJob) {
n := copy(buf, response)
m.client.rxTotalBytes.Add(uint64(n))
select {
- case m.client.rxChannel <- asyncReadPacket{data: buf[:n], addr: job.frame.addr, localAddr: "", transport: transportDoH}:
+ case m.client.rxChannel <- asyncReadPacket{data: buf[:n], addr: job.addr, localAddr: ""}:
default:
m.client.putRuntimeUDPBuffer(buf)
- m.client.onRXDrop(job.frame.addr)
+ m.client.onRXDrop(job.addr)
}
}
diff --git a/internal/client/hostile_network_test.go b/internal/client/hostile_network_test.go
deleted file mode 100644
index 29855cb..0000000
--- a/internal/client/hostile_network_test.go
+++ /dev/null
@@ -1,356 +0,0 @@
-package client
-
-import (
- "encoding/binary"
- "io"
- "net"
- "testing"
- "time"
-
- "cottendns-go/internal/config"
- DnsParser "cottendns-go/internal/dnsparser"
- Enums "cottendns-go/internal/enums"
-)
-
-func hostileDNSResponse(query []byte, rcode uint8) []byte {
- response := append([]byte(nil), query...)
- binary.BigEndian.PutUint16(response[2:4], 0x8100|uint16(rcode&0x0f))
- return response
-}
-
-func TestSynchronousUDPProbeSurvivesPoisonRace(t *testing.T) {
- server, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
- if err != nil {
- t.Fatal(err)
- }
- defer server.Close()
-
- clientConn, err := net.DialUDP("udp", nil, server.LocalAddr().(*net.UDPAddr))
- if err != nil {
- t.Fatal(err)
- }
- defer clientConn.Close()
-
- query, err := DnsParser.BuildTXTQuestionPacket("probe.tunnel.example", Enums.DNS_RECORD_TYPE_TXT, 4096)
- if err != nil {
- t.Fatal(err)
- }
- done := make(chan error, 1)
- go func() {
- buffer := make([]byte, 4096)
- n, peer, readErr := server.ReadFromUDP(buffer)
- if readErr != nil {
- done <- readErr
- return
- }
- request := append([]byte(nil), buffer[:n]...)
- if _, writeErr := server.WriteToUDP(hostileDNSResponse(request, Enums.DNSR_CODE_NAME_ERROR), peer); writeErr != nil {
- done <- writeErr
- return
- }
- time.Sleep(10 * time.Millisecond)
- _, writeErr := server.WriteToUDP(hostileDNSResponse(request, 0), peer)
- done <- writeErr
- }()
-
- c := &Client{cfg: config.ClientConfig{ResolverIgnoreInjectedNXDOMAIN: true}}
- c.runtimeReadBufferSize = 4096
- c.udpBufferPool.New = func() any { return make([]byte, 4096) }
- response, err := c.exchangeUDPQueryWithConn(clientConn, query, time.Second)
- if err != nil {
- t.Fatalf("poison race blocked the genuine UDP response: %v", err)
- }
- parsed, err := DnsParser.ParsePacketLite(response)
- if err != nil || parsed.Header.RCode != 0 {
- t.Fatalf("returned poisoned response instead of genuine answer: rcode=%d err=%v", parsed.Header.RCode, err)
- }
- if err := <-done; err != nil {
- t.Fatal(err)
- }
-}
-
-func TestSynchronousTCPProbeRejectsHijackedQuestion(t *testing.T) {
- clientSide, serverSide := net.Pipe()
- defer clientSide.Close()
- defer serverSide.Close()
-
- query, err := DnsParser.BuildTXTQuestionPacket("probe.tunnel.example", Enums.DNS_RECORD_TYPE_TXT, 4096)
- if err != nil {
- t.Fatal(err)
- }
- done := make(chan error, 1)
- go func() {
- request, readErr := readTCPDNSFramed(serverSide)
- if readErr != nil {
- done <- readErr
- return
- }
- hijack, buildErr := DnsParser.BuildTXTQuestionPacket("block.invalid", Enums.DNS_RECORD_TYPE_TXT, 0)
- if buildErr != nil {
- done <- buildErr
- return
- }
- binary.BigEndian.PutUint16(hijack[:2], binary.BigEndian.Uint16(request[:2]))
- hijack = hostileDNSResponse(hijack, 0)
- if writeErr := writeTCPDNSFramed(serverSide, hijack); writeErr != nil {
- done <- writeErr
- return
- }
- done <- writeTCPDNSFramed(serverSide, hostileDNSResponse(request, 0))
- }()
-
- transport := &tcpQueryTransport{client: &Client{}, conn: clientSide}
- response, err := transport.exchange(query, time.Second)
- if err != nil {
- t.Fatalf("hijacked TCP question blocked the genuine response: %v", err)
- }
- if dnsQuestionFingerprint(response) != dnsQuestionFingerprint(query) {
- t.Fatal("TCP exchanger returned the mismatched hijack response")
- }
- if err := <-done; err != nil && err != io.EOF {
- t.Fatal(err)
- }
-}
-
-func TestOutstandingQueriesWithSameTXIDStayIndependent(t *testing.T) {
- c := newAutoTransportPolicyClient()
- c.resolverPending = make(map[resolverSampleKey]resolverSample)
- addr := &net.UDPAddr{IP: net.ParseIP("192.0.2.53"), Port: 53}
- first, err := DnsParser.BuildTXTQuestionPacket("first.tunnel.example", Enums.DNS_RECORD_TYPE_TXT, 4096)
- if err != nil {
- t.Fatal(err)
- }
- second, err := DnsParser.BuildTXTQuestionPacket("second.tunnel.example", Enums.DNS_RECORD_TYPE_TXT, 4096)
- if err != nil {
- t.Fatal(err)
- }
- binary.BigEndian.PutUint16(second[:2], binary.BigEndian.Uint16(first[:2]))
- now := time.Now()
- c.trackResolverSendOver(first, addr.String(), "local", "resolver-a", transportUDP, now)
- c.trackResolverSendOver(second, addr.String(), "local", "resolver-a", transportUDP, now)
- if len(c.resolverPending) != 2 {
- t.Fatalf("same-TXID queries collided: pending=%d, want 2", len(c.resolverPending))
- }
- if !c.trackResolverSuccessOver(first, addr, "local", transportUDP, now.Add(20*time.Millisecond)) {
- t.Fatal("first same-TXID response was not claimed")
- }
- if len(c.resolverPending) != 1 {
- t.Fatalf("claiming first query removed its distinct sibling: pending=%d", len(c.resolverPending))
- }
- if !c.trackResolverSuccessOver(second, addr, "local", transportUDP, now.Add(30*time.Millisecond)) {
- t.Fatal("second same-TXID response was not claimed")
- }
-}
-
-func TestQuestionFingerprintAcceptsDNSCaseNormalization(t *testing.T) {
- query, err := DnsParser.BuildTXTQuestionPacket("MiXeD.tunnel.example", Enums.DNS_RECORD_TYPE_TXT, 4096)
- if err != nil {
- t.Fatal(err)
- }
- normalized := append([]byte(nil), query...)
- for offset := 13; offset < len(normalized); offset++ {
- if normalized[offset] >= 'A' && normalized[offset] <= 'Z' {
- normalized[offset] += 'a' - 'A'
- }
- }
- if got, want := dnsQuestionFingerprint(normalized), dnsQuestionFingerprint(query); got == 0 || got != want {
- t.Fatalf("case-normalized DNS question fingerprint=%x, want=%x", got, want)
- }
-}
-
-func TestUDPTruncationImmediatelyPrefersTCP(t *testing.T) {
- c := newAutoTransportPolicyClient()
- c.resolverPending = make(map[resolverSampleKey]resolverSample)
- addr := &net.UDPAddr{IP: net.ParseIP("192.0.2.75"), Port: 53}
- query, err := DnsParser.BuildTXTQuestionPacket("payload.tunnel.example", Enums.DNS_RECORD_TYPE_TXT, 4096)
- if err != nil {
- t.Fatal(err)
- }
- now := time.Now()
- c.trackResolverSendOver(query, addr.String(), "udp-local", "resolver-a", transportUDP, now)
-
- truncated := append([]byte(nil), query...)
- truncated[2] |= 0x80 // QR
- truncated[2] |= 0x02 // TC
- c.handleInboundPacketOver(truncated, addr, "udp-local", transportUDP)
-
- if got := c.preferredResolverTransport("resolver-a"); got != transportTCP {
- t.Fatalf("UDP TC response did not immediately prefer TCP: got %s", got)
- }
-}
-
-func TestPoisonSignalImmediatelyRacesAlternateAndFirstResponseWins(t *testing.T) {
- c := newAutoTransportPolicyClient()
- c.resolverPending = make(map[resolverSampleKey]resolverSample)
- c.resolverAddrCache = make(map[string]*net.UDPAddr)
- c.resolverHealth = make(map[string]*resolverHealthState)
- c.encodedTXChannel = make(chan encodedOutboundTask, 2)
- c.connections = []Connection{{
- Key: "resolver-a", Domain: "tunnel.example", Resolver: "192.0.2.75",
- ResolverPort: 53, IsValid: true, UploadMTUBytes: 220, DownloadMTUBytes: 1200,
- }}
- c.connectionsByKey = map[string]int{"resolver-a": 0}
- c.balancer = NewBalancer(BalancingRoundRobinDefault)
- c.balancer.SetConnections([]*Connection{&c.connections[0]})
-
- addr := &net.UDPAddr{IP: net.ParseIP("192.0.2.75"), Port: 53}
- query, err := DnsParser.BuildTXTQuestionPacket("payload.tunnel.example", Enums.DNS_RECORD_TYPE_TXT, 4096)
- if err != nil {
- t.Fatal(err)
- }
- frame := encodedOutboundDatagram{
- addr: addr, serverKey: "resolver-a", packet: query,
- priority: Enums.PacketPriorityNormal, transport: transportUDP,
- packetType: Enums.PACKET_STREAM_DATA, payloadSize: 80,
- }
- now := time.Now()
- c.trackResolverFrameOver(frame, "udp-local", transportUDP, now)
- c.noteInjectedResolverNoise(hostileDNSResponse(query, Enums.DNSR_CODE_NAME_ERROR), addr, "udp-local", transportUDP)
-
- var replay encodedOutboundDatagram
- select {
- case task := <-c.encodedTXChannel:
- if len(task.frames) != 1 {
- t.Fatalf("poison replay frame count=%d, want 1", len(task.frames))
- }
- replay = task.frames[0]
- default:
- t.Fatal("poison signal did not immediately queue an alternate path")
- }
- if replay.transport != transportTCP || replay.replayDepth != 1 {
- t.Fatalf("poison replay=%+v, want one-hop TCP alternate", replay)
- }
-
- c.trackResolverFrameOver(replay, "tcp-local", replay.transport, now.Add(time.Millisecond))
- response := hostileDNSResponse(query, 0)
- if !c.trackResolverSuccessOver(response, addr, "tcp-local", transportTCP, now.Add(20*time.Millisecond)) {
- t.Fatal("authenticated alternate response did not win")
- }
- if c.trackResolverSuccessOver(response, addr, "udp-local", transportUDP, now.Add(30*time.Millisecond)) {
- t.Fatal("slower original response won after the alternate was claimed")
- }
- if len(c.resolverPending) != 0 {
- t.Fatalf("logical replay siblings remained pending: %d", len(c.resolverPending))
- }
-}
-
-func TestExpiredPathReplaysFrameBeforeARQRetry(t *testing.T) {
- c := newAutoTransportPolicyClient()
- c.resolverPending = make(map[resolverSampleKey]resolverSample)
- c.resolverAddrCache = make(map[string]*net.UDPAddr)
- c.resolverHealth = make(map[string]*resolverHealthState)
- c.encodedTXChannel = make(chan encodedOutboundTask, 2)
- c.tunnelPacketTimeout = 500 * time.Millisecond
- c.connections = []Connection{{
- Key: "resolver-a", Domain: "tunnel.example", Resolver: "192.0.2.75",
- ResolverPort: 53, IsValid: true, UploadMTUBytes: 220, DownloadMTUBytes: 1200,
- }}
- c.connectionsByKey = map[string]int{"resolver-a": 0}
- c.balancer = NewBalancer(BalancingRoundRobinDefault)
- c.balancer.SetConnections([]*Connection{&c.connections[0]})
-
- addr := &net.UDPAddr{IP: net.ParseIP("192.0.2.75"), Port: 53}
- query, err := DnsParser.BuildTXTQuestionPacket("payload.tunnel.example", Enums.DNS_RECORD_TYPE_TXT, 4096)
- if err != nil {
- t.Fatal(err)
- }
- sentAt := time.Now().Add(-time.Second)
- c.trackResolverFrameOver(encodedOutboundDatagram{
- addr: addr, serverKey: "resolver-a", packet: query,
- priority: Enums.PacketPriorityNormal, transport: transportUDP,
- packetType: Enums.PACKET_STREAM_DATA, payloadSize: 80,
- }, "udp-local", transportUDP, sentAt)
-
- c.collectExpiredResolverTimeouts(time.Now())
- select {
- case task := <-c.encodedTXChannel:
- if len(task.frames) != 1 || task.frames[0].replayDepth != 1 || task.frames[0].transport != transportTCP {
- t.Fatalf("timeout replay did not preserve and reroute the frame: %+v", task.frames)
- }
- default:
- t.Fatal("failed path waited for ARQ instead of replaying the in-flight frame")
- }
-}
-
-func TestOriginalWinnerCancelsQueuedPoisonReplay(t *testing.T) {
- c := newAutoTransportPolicyClient()
- c.resolverPending = make(map[resolverSampleKey]resolverSample)
- c.resolverCompleted = make(map[resolverCompletedKey]time.Time)
- c.resolverAddrCache = make(map[string]*net.UDPAddr)
- c.resolverHealth = make(map[string]*resolverHealthState)
- c.encodedTXChannel = make(chan encodedOutboundTask, 1)
- c.connections = []Connection{{
- Key: "resolver-a", Domain: "tunnel.example", Resolver: "192.0.2.75",
- ResolverPort: 53, IsValid: true, UploadMTUBytes: 220, DownloadMTUBytes: 1200,
- }}
- c.connectionsByKey = map[string]int{"resolver-a": 0}
- c.balancer = NewBalancer(BalancingRoundRobinDefault)
- c.balancer.SetConnections([]*Connection{&c.connections[0]})
-
- addr := &net.UDPAddr{IP: net.ParseIP("192.0.2.75"), Port: 53}
- query, err := DnsParser.BuildTXTQuestionPacket("payload.tunnel.example", Enums.DNS_RECORD_TYPE_TXT, 4096)
- if err != nil {
- t.Fatal(err)
- }
- now := time.Now()
- c.trackResolverFrameOver(encodedOutboundDatagram{
- addr: addr, serverKey: "resolver-a", packet: query,
- priority: Enums.PacketPriorityNormal, transport: transportUDP,
- packetType: Enums.PACKET_STREAM_DATA, payloadSize: 80,
- }, "udp-local", transportUDP, now)
- c.noteInjectedResolverNoise(hostileDNSResponse(query, Enums.DNSR_CODE_NAME_ERROR), addr, "udp-local", transportUDP)
- if !c.trackResolverSuccessOver(hostileDNSResponse(query, 0), addr, "udp-local", transportUDP, now.Add(10*time.Millisecond)) {
- t.Fatal("genuine original response did not win")
- }
- task := <-c.encodedTXChannel
- if len(task.frames) != 1 || !c.resolverReplayCompleted(task.frames[0], now.Add(11*time.Millisecond)) {
- t.Fatal("queued poison replay was not cancelled after the original won")
- }
-}
-
-func TestPoisonReplayRanksAllPathsByDeliveredSpeed(t *testing.T) {
- c := newAutoTransportPolicyClient()
- c.resolverPending = make(map[resolverSampleKey]resolverSample)
- c.resolverAddrCache = make(map[string]*net.UDPAddr)
- c.encodedTXChannel = make(chan encodedOutboundTask, 1)
- c.connections = []Connection{
- {
- Key: "resolver-a", Domain: "tunnel.example", Resolver: "192.0.2.75",
- ResolverPort: 53, IsValid: true, UploadMTUBytes: 220, DownloadMTUBytes: 1200,
- MTUResolveTime: 300 * time.Millisecond,
- },
- {
- Key: "resolver-b", Domain: "tunnel.example", Resolver: "192.0.2.76",
- ResolverPort: 53, IsValid: true, UploadMTUBytes: 220, DownloadMTUBytes: 1200,
- MTUResolveTime: 50 * time.Millisecond,
- },
- }
- c.connectionsByKey = map[string]int{"resolver-a": 0, "resolver-b": 1}
- c.balancer = NewBalancer(BalancingRoundRobinDefault)
- c.balancer.SetConnections([]*Connection{&c.connections[0], &c.connections[1]})
- now := time.Now()
- c.noteResolverTransportProbe("resolver-a", transportTCP, mtuConnectionProbeResult{
- UploadBytes: 220, DownloadBytes: 1200, ResolveTime: 300 * time.Millisecond,
- }, true, now)
- c.noteResolverTransportProbe("resolver-b", transportUDP, mtuConnectionProbeResult{
- UploadBytes: 220, DownloadBytes: 1200, ResolveTime: 50 * time.Millisecond,
- }, true, now)
-
- addr := &net.UDPAddr{IP: net.ParseIP("192.0.2.75"), Port: 53}
- query, err := DnsParser.BuildTXTQuestionPacket("payload.tunnel.example", Enums.DNS_RECORD_TYPE_TXT, 4096)
- if err != nil {
- t.Fatal(err)
- }
- c.trackResolverFrameOver(encodedOutboundDatagram{
- addr: addr, serverKey: "resolver-a", packet: query,
- priority: Enums.PacketPriorityNormal, transport: transportUDP,
- packetType: Enums.PACKET_STREAM_DATA, payloadSize: 80,
- }, "udp-local", transportUDP, now)
- c.noteInjectedResolverNoise(hostileDNSResponse(query, Enums.DNSR_CODE_NAME_ERROR), addr, "udp-local", transportUDP)
-
- task := <-c.encodedTXChannel
- replay := task.frames[0]
- if replay.serverKey != "resolver-b" || replay.transport != transportUDP {
- t.Fatalf("replay selected %s/%s, want fastest resolver-b/udp", replay.serverKey, replay.transport)
- }
-}
diff --git a/internal/client/mtu.go b/internal/client/mtu.go
index 6aa7a77..d5f90a9 100644
--- a/internal/client/mtu.go
+++ b/internal/client/mtu.go
@@ -80,20 +80,60 @@ type mtuScanCounters struct {
rejectDownload atomic.Int32
}
-// RunInitialMTUTests probes every resolver over its configured transport chain.
-// Auto measures UDP and TCP/53 independently; explicit DoT/DoH measures the
-// encrypted path and its plain survival fallbacks. Each resolver keeps the
-// fastest healthy path that can sustain the negotiated session MTU.
+// RunInitialMTUTests tests all connections before the client starts, walking a
+// transport fallback chain until one carries the tunnel:
+//
+// "udp" / "tcp" — that transport only, no fallback.
+// "auto" — UDP, then TCP/53 (a network that blocks or truncates UDP).
+// "dot" / "doh" — the chosen encrypted transport, then UDP, then TCP/53.
+//
+// The encrypted transports are deliberately *opt-in only*: nothing ever escalates
+// into them, because they exist to disguise the resolver hop, not to rescue a
+// broken one. But choosing one is not a commitment — if the TLS port is blocked
+// (a common censorship response) the client silently degrades to the plain
+// survival transports rather than failing to connect at all.
func (c *Client) RunInitialMTUTests(ctx context.Context) error {
if len(c.connections) == 0 {
return ErrNoValidConnections
}
chain := resolverTransportChain(c.cfg.ResolverTransport)
- if len(chain) > 0 {
- c.setActiveTransport(chain[0])
+ var firstErr error
+ for i, transport := range chain {
+ c.setActiveTransport(transport)
+ if i > 0 {
+ if c.log != nil {
+ c.log.Warnf(
+ "No resolvers passed over %s — retrying the whole fleet over %s…",
+ chain[i-1], transport,
+ )
+ }
+ for idx := range c.connections {
+ c.prepareConnectionMTUScanState(&c.connections[idx])
+ }
+ }
+
+ err := c.runMTUScan(ctx)
+ if err == nil {
+ if i > 0 && c.log != nil {
+ c.log.Infof("✅ Resolver transport fell back to %s.", transport)
+ }
+ return nil
+ }
+ if firstErr == nil {
+ firstErr = err
+ }
+ // Only "no usable resolver" is a transport problem worth falling back on;
+ // anything else (cancellation, config) would fail identically elsewhere.
+ if !errors.Is(err, ErrNoValidConnections) {
+ c.setActiveTransport(chain[0])
+ return err
+ }
}
- return c.runMTUScan(ctx)
+
+ // Nothing worked — restore the configured transport so state is predictable.
+ c.setActiveTransport(chain[0])
+ return firstErr
}
func (c *Client) runMTUScan(ctx context.Context) error {
@@ -720,56 +760,9 @@ func (c *Client) runConnectionMTUTest(ctx context.Context, conn *Connection, ser
}
func (c *Client) probeConnectionMTU(ctx context.Context, conn *Connection, maxUploadPayload int) (mtuConnectionProbeResult, mtuRejectReason) {
- if conn == nil {
- return mtuConnectionProbeResult{}, mtuRejectUpload
- }
- transports := c.resolverTransportCandidates(conn.Key)
- if len(transports) == 0 {
- transports = []resolverTransport{c.activeTransport()}
- }
-
- var (
- best mtuConnectionProbeResult
- bestReason = mtuRejectUpload
- bestScore = -1.0
- )
- for _, transport := range transports {
- result, reason := c.probeConnectionMTUOver(ctx, conn, maxUploadPayload, transport)
- ok := reason == mtuRejectNone
- c.noteResolverTransportProbe(conn.Key, transport, result, ok, c.now())
- if !ok {
- // Preserve the most advanced rejection for useful diagnostics.
- if reason == mtuRejectDownload {
- best, bestReason = result, reason
- }
- continue
- }
-
- score := float64(max(1, result.DownloadBytes)) * (1 - result.DownloadLoss)
- rttMillis := float64(result.ResolveTime) / float64(time.Millisecond)
- if rttMillis < 1 {
- rttMillis = 1
- }
- score /= rttMillis
- if score > bestScore {
- best, bestReason, bestScore = result, mtuRejectNone, score
- }
- }
- return best, bestReason
-}
-
-func (c *Client) probeConnectionMTUOver(
- ctx context.Context,
- conn *Connection,
- maxUploadPayload int,
- transport resolverTransport,
-) (mtuConnectionProbeResult, mtuRejectReason) {
- if c.probeConnectionMTUOverFn != nil {
- return c.probeConnectionMTUOverFn(ctx, conn, maxUploadPayload, transport)
- }
var result mtuConnectionProbeResult
- probeTransport, err := c.newQueryTransportOver(conn.ResolverLabel, transport)
+ probeTransport, err := c.newQueryTransport(conn.ResolverLabel)
if err != nil {
return result, mtuRejectUpload
}
diff --git a/internal/client/resolver_health.go b/internal/client/resolver_health.go
index 0071607..c80f130 100644
--- a/internal/client/resolver_health.go
+++ b/internal/client/resolver_health.go
@@ -10,8 +10,6 @@ import (
"cottendns-go/internal/logger"
)
-const warmPathForegroundFramesPerScan = uint64(4096)
-
type resolverHealthEvent struct {
At time.Time
}
@@ -158,7 +156,6 @@ func (c *Client) initResolverRecheckMeta() {
c.resolverStatsMu.Lock()
c.resolverPending = make(map[resolverSampleKey]resolverSample)
- c.resolverCompleted = make(map[resolverCompletedKey]time.Time)
c.resolverStatsMu.Unlock()
c.resolverHealthMu.Lock()
@@ -198,7 +195,6 @@ func (c *Client) runResolverHealthLoop(ctx context.Context) {
c.collectExpiredResolverTimeouts(now)
c.runResolverAutoDisable(now)
c.runResolverRecheckBatch(ctx, now)
- c.runResolverTransportBackgroundScan(ctx, now)
waitFor := c.nextResolverHealthWait(now)
timer := time.NewTimer(waitFor)
@@ -215,130 +211,6 @@ func (c *Client) runResolverHealthLoop(ctx context.Context) {
}
}
-// runResolverTransportBackgroundScan performs full MTU discovery on one
-// resolver/transport path per interval. Narrow successful paths remain useful
-// for packets that fit instead of being rejected against the global session
-// MTU. The scan shares the bounded recheck semaphore and rotates slowly, keeping
-// path intelligence fresh without creating a burst beside live user traffic.
-func (c *Client) runResolverTransportBackgroundScan(ctx context.Context, now time.Time) {
- if c == nil || !c.successMTUChecks || c.syncedUploadMTU <= 0 || c.syncedDownloadMTU <= 0 {
- return
- }
- interval := c.transportBackgroundScanInterval()
- var (
- selected Connection
- selectedSet bool
- oldest time.Time
- )
- c.resolverTransportMu.Lock()
- for _, conn := range c.connections {
- if !conn.IsValid {
- continue
- }
- state := c.resolverTransportStateLocked(conn.Key)
- if !state.lastBackgroundScan.IsZero() && now.Sub(state.lastBackgroundScan) < interval {
- continue
- }
- if !selectedSet || state.lastBackgroundScan.Before(oldest) {
- selected, selectedSet, oldest = conn, true, state.lastBackgroundScan
- }
- }
- c.resolverTransportMu.Unlock()
- if !selectedSet || !c.tryAcquireResolverRecheckSlot() {
- return
- }
- if !c.allowWarmPathExploration(now, interval) {
- c.releaseResolverRecheckSlot()
- return
- }
- c.resolverTransportMu.Lock()
- c.resolverTransportStateLocked(selected.Key).lastBackgroundScan = now
- c.resolverTransportMu.Unlock()
-
- go func(conn Connection) {
- defer c.releaseResolverRecheckSlot()
- c.refreshResolverTransportPath(ctx, &conn)
- }(selected)
-}
-
-func (c *Client) allowWarmPathExploration(now time.Time, interval time.Duration) bool {
- if c == nil || c.runtimeCongested() {
- return false
- }
- foreground := c.runtimeOriginalSends.Load()
- chargedThrough := c.warmPathBudgetSends.Load()
- if foreground >= chargedThrough+warmPathForegroundFramesPerScan {
- c.warmPathBudgetSends.Store(foreground)
- c.warmPathLastScanUnix.Store(now.UnixNano())
- return true
- }
-
- staleAfter := 4 * interval
- if staleAfter < 2*time.Minute {
- staleAfter = 2 * time.Minute
- }
- lastUnix := c.warmPathLastScanUnix.Load()
- if lastUnix == 0 || now.Sub(time.Unix(0, lastUnix)) < staleAfter {
- return false
- }
- // The stale-path exception exists only for truly idle capacity. Any queued
- // user packet wins, even when the queue is below the congestion threshold.
- if len(c.txChannel) != 0 || len(c.encodedTXChannel) != 0 || len(c.rxChannel) != 0 {
- return false
- }
- c.warmPathBudgetSends.Store(foreground)
- c.warmPathLastScanUnix.Store(now.UnixNano())
- return true
-}
-
-func (c *Client) runtimeCongested() bool {
- if c == nil {
- return true
- }
- channelBusy := func(length, capacity int) bool {
- return capacity > 0 && length >= max(1, capacity/4)
- }
- if channelBusy(len(c.txChannel), cap(c.txChannel)) ||
- channelBusy(len(c.encodedTXChannel), cap(c.encodedTXChannel)) ||
- channelBusy(len(c.rxChannel), cap(c.rxChannel)) {
- return true
- }
- c.resolverStatsMu.RLock()
- pending := len(c.resolverPending)
- c.resolverStatsMu.RUnlock()
- return pending >= resolverPendingSoftCap/4
-}
-
-func (c *Client) refreshResolverTransportPath(ctx context.Context, conn *Connection) {
- if c == nil || conn == nil {
- return
- }
- candidates := c.resolverTransportCandidates(conn.Key)
- if len(candidates) == 0 {
- return
- }
-
- c.resolverTransportMu.Lock()
- state := c.resolverTransportStateLocked(conn.Key)
- index := state.backgroundCursor % len(candidates)
- state.backgroundCursor = (state.backgroundCursor + 1) % len(candidates)
- transport := candidates[index]
- c.resolverTransportMu.Unlock()
-
- maxUploadPayload := c.cfg.MaxUploadMTU
- if maxUploadPayload <= 0 || maxUploadPayload > defaultUploadMaxCap {
- maxUploadPayload = defaultUploadMaxCap
- }
- if c.codec != nil {
- if domainCap := c.maxUploadMTUPayload(conn.Domain); domainCap > 0 && domainCap < maxUploadPayload {
- maxUploadPayload = domainCap
- }
- }
-
- result, reason := c.probeConnectionMTUOver(ctx, conn, maxUploadPayload, transport)
- c.noteResolverTransportProbe(conn.Key, transport, result, reason == mtuRejectNone, c.now())
-}
-
func (c *Client) nextResolverHealthWait(now time.Time) time.Duration {
waitFor := 2 * time.Second
if c == nil {
@@ -1016,68 +888,43 @@ func (c *Client) recheckResolverConnection(ctx context.Context, conn *Connection
return true
}
- anyPassed := false
- for _, transport := range c.resolverTransportCandidates(conn.Key) {
- result, passed := c.probeResolverAtSessionMTU(ctx, conn, transport)
- c.noteResolverTransportProbe(conn.Key, transport, result, passed, c.now())
- if passed {
- anyPassed = true
- }
- }
- return anyPassed
-}
-
-func (c *Client) probeResolverAtSessionMTU(
- ctx context.Context,
- conn *Connection,
- transportKind resolverTransport,
-) (mtuConnectionProbeResult, bool) {
- if c.probeSessionMTUOverFn != nil {
- return c.probeSessionMTUOverFn(ctx, conn, transportKind)
- }
- var result mtuConnectionProbeResult
- transport, err := c.newQueryTransportOver(conn.ResolverLabel, transportKind)
+ transport, err := c.newQueryTransport(conn.ResolverLabel)
if err != nil {
- return result, false
+ return false
}
defer transport.Close()
upOK := false
for attempt := 0; attempt < c.mtuTestRetries; attempt++ {
if err := ctx.Err(); err != nil {
- return result, false
+ return false
}
- passed, rtt, err := c.sendUploadMTUProbe(ctx, conn, transport, c.syncedUploadMTU, mtuProbeOptions{Quiet: true, IsRetry: attempt > 0})
+ passed, _, err := c.sendUploadMTUProbe(ctx, conn, transport, c.syncedUploadMTU, mtuProbeOptions{Quiet: true, IsRetry: attempt > 0})
if err == nil && passed {
upOK = true
- result.UploadBytes = c.syncedUploadMTU
- result.UploadChars = c.encodedCharsForPayload(c.syncedUploadMTU)
- result.ResolveTime = rtt
break
}
}
if !upOK {
- return result, false
+ return false
}
downOK := false
for attempt := 0; attempt < c.mtuTestRetries; attempt++ {
if err := ctx.Err(); err != nil {
- return result, false
+ return false
}
- passed, rtt, err := c.sendDownloadMTUProbe(ctx, conn, transport, c.syncedDownloadMTU, c.syncedUploadMTU, mtuProbeOptions{Quiet: true, IsRetry: attempt > 0})
+ passed, _, err := c.sendDownloadMTUProbe(ctx, conn, transport, c.syncedDownloadMTU, c.syncedUploadMTU, mtuProbeOptions{Quiet: true, IsRetry: attempt > 0})
if err == nil && passed {
downOK = true
- result.DownloadBytes = c.syncedDownloadMTU
- result.ResolveTime = averageMTUProbeRTT(result.ResolveTime, rtt)
break
}
}
if !downOK {
- return result, false
+ return false
}
- return result, true
+ return true
}
func (c *Client) applyRecheckedResolverMTU(serverKey string) bool {
diff --git a/internal/client/resolver_stats.go b/internal/client/resolver_stats.go
index 93453ab..a0c83e0 100644
--- a/internal/client/resolver_stats.go
+++ b/internal/client/resolver_stats.go
@@ -40,28 +40,11 @@ func (c *Client) rcodeIsInjectedNoise(rcode uint8) bool {
// answer can still be scored as a success (or the sample times out if the
// resolver is truly unreachable). A throttled warning lets the operator see that
// DNS poisoning is happening and being absorbed.
-func (c *Client) noteInjectedResolverNoise(packet []byte, addr *net.UDPAddr, localAddr string, transport resolverTransport) {
+func (c *Client) noteInjectedResolverNoise(addr *net.UDPAddr) {
if c == nil {
return
}
total := c.injectedNXDOMAINCount.Add(1)
- if len(packet) >= 2 && addr != nil {
- fingerprint := dnsQuestionFingerprint(packet)
- key := resolverSampleKey{
- resolverAddr: addr.String(),
- localAddr: localAddr,
- dnsID: binary.BigEndian.Uint16(packet[:2]),
- transport: transport,
- questionFingerprint: fingerprint,
- }
- c.resolverStatsMu.RLock()
- actualKey, sample, ok := c.resolverSampleLocked(key)
- c.resolverStatsMu.RUnlock()
- if ok && (sample.questionFingerprint == 0 || sample.questionFingerprint == fingerprint) {
- c.noteResolverTransportPoison(sample.serverKey, transport)
- c.replayPendingResolverSample(actualKey, poisonReplayMaxDepth)
- }
- }
now := time.Now().UnixNano()
last := c.lastInjectionLogUnix.Load()
if now-last < injectionLogInterval.Nanoseconds() {
@@ -79,95 +62,23 @@ func (c *Client) noteInjectedResolverNoise(packet []byte, addr *net.UDPAddr, loc
}
}
-// validateInboundQuestion rejects same-ID responses whose question section does
-// not match the outstanding query. TXID-only matching is insufficient in an
-// actively poisoned network because an injector can observe or guess the ID.
-// The pending sample remains intact so the authentic response can still win.
-func (c *Client) validateInboundQuestion(packet []byte, addr *net.UDPAddr, localAddr string, transport resolverTransport) bool {
- valid, _ := c.validateInboundQuestionFingerprint(packet, addr, localAddr, transport)
- return valid
-}
-
-func (c *Client) validateInboundQuestionFingerprint(
- packet []byte,
- addr *net.UDPAddr,
- localAddr string,
- transport resolverTransport,
-) (bool, uint64) {
- if c == nil || len(packet) < 2 || addr == nil {
- return false, 0
- }
- fingerprint := dnsQuestionFingerprint(packet)
- key := resolverSampleKey{
- resolverAddr: addr.String(),
- localAddr: localAddr,
- dnsID: binary.BigEndian.Uint16(packet[:2]),
- transport: transport,
- questionFingerprint: fingerprint,
- }
- c.resolverStatsMu.RLock()
- actualKey, sample, ok := c.resolverSampleLocked(key)
- c.resolverStatsMu.RUnlock()
- if !ok || sample.questionFingerprint == 0 {
- return true, fingerprint
- }
- if fingerprint == sample.questionFingerprint {
- return true, fingerprint
- }
-
- total := c.resolverHijackCount.Add(1)
- c.noteResolverTransportPoison(sample.serverKey, transport)
- c.replayPendingResolverSample(actualKey, poisonReplayMaxDepth)
- now := time.Now().UnixNano()
- last := c.lastHijackLogUnix.Load()
- if now-last >= injectionLogInterval.Nanoseconds() &&
- c.lastHijackLogUnix.CompareAndSwap(last, now) &&
- c.log != nil {
- c.log.Warnf(
- "\U0001F6E1 Ignored mismatched DNS response (resolver hijack/injection) | Total: %d | Resolver: %v",
- total,
- addr,
- )
- }
- return false, fingerprint
-}
-
type resolverSampleKey struct {
- resolverAddr string
- localAddr string
- dnsID uint16
- transport resolverTransport
- questionFingerprint uint64
-}
-
-type resolverCompletedKey struct {
- dnsID uint16
- questionFingerprint uint64
+ resolverAddr string
+ localAddr string
+ dnsID uint16
}
type resolverSample struct {
- serverKey string
- transport resolverTransport
- questionFingerprint uint64
- sentAt time.Time
- timeoutAt time.Time
- timedOut bool
- timedOutAt time.Time
- evictAfter time.Time
- packet []byte
- packetType uint8
- payloadSize int
- priority int
- replayDepth uint8
- replayTriggered bool
- mayHaveSibling bool
+ serverKey string
+ sentAt time.Time
+ timedOut bool
+ timedOutAt time.Time
+ evictAfter time.Time
}
type resolverTimeoutObservation struct {
serverKey string
- transport resolverTransport
at time.Time
- key resolverSampleKey
}
func (c *Client) resolverSampleTTL() time.Duration {
@@ -204,60 +115,15 @@ func (c *Client) noteResolverSuccess(serverKey string, rtt time.Duration) {
}
func (c *Client) trackResolverSend(packet []byte, resolverAddr string, localAddr string, serverKey string, sentAt time.Time) {
- c.trackResolverSendOver(packet, resolverAddr, localAddr, serverKey, c.activeTransport(), sentAt)
-}
-
-func (c *Client) trackResolverSendOver(packet []byte, resolverAddr string, localAddr string, serverKey string, transport resolverTransport, sentAt time.Time) {
- c.trackResolverSendMetadata(packet, resolverAddr, localAddr, serverKey, transport, sentAt, 0, 0, 0, 0, false, false)
-}
-
-func (c *Client) trackResolverFrameOver(frame encodedOutboundDatagram, localAddr string, transport resolverTransport, sentAt time.Time) {
- if frame.addr == nil {
- return
- }
- c.trackResolverSendMetadata(
- frame.packet,
- frame.addr.String(),
- localAddr,
- frame.serverKey,
- transport,
- sentAt,
- frame.packetType,
- frame.payloadSize,
- frame.priority,
- frame.replayDepth,
- frame.mayHaveSibling,
- true,
- )
-}
-
-func (c *Client) trackResolverSendMetadata(
- packet []byte,
- resolverAddr string,
- localAddr string,
- serverKey string,
- transport resolverTransport,
- sentAt time.Time,
- packetType uint8,
- payloadSize int,
- priority int,
- replayDepth uint8,
- mayHaveSibling bool,
- keepPacket bool,
-) {
if c == nil || len(packet) < 2 || resolverAddr == "" || serverKey == "" {
return
}
- fingerprint := dnsQuestionFingerprint(packet)
key := resolverSampleKey{
- resolverAddr: resolverAddr,
- localAddr: localAddr,
- dnsID: binary.BigEndian.Uint16(packet[:2]),
- transport: transport,
- questionFingerprint: fingerprint,
+ resolverAddr: resolverAddr,
+ localAddr: localAddr,
+ dnsID: binary.BigEndian.Uint16(packet[:2]),
}
- timeoutAt := sentAt.Add(c.resolverPathRequestTimeout(serverKey, transport))
var timeoutObservations []resolverTimeoutObservation
c.resolverStatsMu.Lock()
@@ -267,123 +133,45 @@ func (c *Client) trackResolverSendMetadata(
c.evictResolverPendingLocked(overflow + 1)
}
}
- sample := resolverSample{
- serverKey: serverKey,
- transport: transport,
- questionFingerprint: fingerprint,
- sentAt: sentAt,
- timeoutAt: timeoutAt,
- packetType: packetType,
- payloadSize: payloadSize,
- priority: priority,
- replayDepth: replayDepth,
- mayHaveSibling: mayHaveSibling,
- }
- if keepPacket {
- // Runtime DNS frames are immutable after encoding. Retaining the slice
- // keeps its backing array alive for replay without adding one allocation
- // and full packet copy to every foreground send.
- sample.packet = packet
- }
- c.resolverPending[key] = sample
+ c.resolverPending[key] = resolverSample{
+ serverKey: serverKey,
+ sentAt: sentAt,
+ }
c.resolverStatsMu.Unlock()
for _, observation := range timeoutObservations {
c.noteResolverTimeout(observation.serverKey, observation.at)
- c.noteResolverTransportFailure(observation.serverKey, observation.transport, observation.at)
- c.replayPendingResolverSample(observation.key, failureReplayMaxDepth)
}
c.noteResolverSend(serverKey)
}
-func (c *Client) trackResolverSuccess(packet []byte, addr *net.UDPAddr, localAddr string, receivedAt time.Time) bool {
- return c.trackResolverSuccessOver(packet, addr, localAddr, c.activeTransport(), receivedAt)
-}
-
-func (c *Client) trackResolverSuccessOver(packet []byte, addr *net.UDPAddr, localAddr string, transport resolverTransport, receivedAt time.Time) bool {
- return c.trackResolverSuccessOverFingerprint(
- packet, addr, localAddr, transport, receivedAt, dnsQuestionFingerprint(packet),
- )
-}
-
-func (c *Client) trackResolverSuccessOverFingerprint(
- packet []byte,
- addr *net.UDPAddr,
- localAddr string,
- transport resolverTransport,
- receivedAt time.Time,
- fingerprint uint64,
-) bool {
+func (c *Client) trackResolverSuccess(packet []byte, addr *net.UDPAddr, localAddr string, receivedAt time.Time) {
if c == nil || len(packet) < 2 || addr == nil {
- return false
+ return
}
key := resolverSampleKey{
- resolverAddr: addr.String(),
- localAddr: localAddr,
- dnsID: binary.BigEndian.Uint16(packet[:2]),
- transport: transport,
- questionFingerprint: fingerprint,
- }
- completedKey := resolverCompletedKey{
- dnsID: key.dnsID,
- questionFingerprint: key.questionFingerprint,
+ resolverAddr: addr.String(),
+ localAddr: localAddr,
+ dnsID: binary.BigEndian.Uint16(packet[:2]),
}
c.resolverStatsMu.Lock()
- if expiresAt, completed := c.resolverCompleted[completedKey]; completed {
- if expiresAt.After(receivedAt) {
- c.resolverStatsMu.Unlock()
- return false
- }
- delete(c.resolverCompleted, completedKey)
- }
- actualKey, sample, ok := c.resolverSampleLocked(key)
- if ok && sample.questionFingerprint != 0 && sample.questionFingerprint != fingerprint {
- ok = false
- }
+ sample, ok := c.resolverPending[key]
if ok {
- delete(c.resolverPending, actualKey)
- // A hedge or replay may use another socket, resolver, or transport. Claim
- // every logically identical query when the first authenticated answer
- // wins, so a slower copy cannot be dispatched or counted as a timeout.
- if sample.mayHaveSibling || sample.replayTriggered || sample.replayDepth > 0 {
- for siblingKey, sibling := range c.resolverPending {
- sameLogicalQuery := siblingKey.dnsID == key.dnsID &&
- sample.questionFingerprint != 0 &&
- sibling.questionFingerprint == sample.questionFingerprint
- legacySibling := sample.questionFingerprint == 0 &&
- siblingKey.resolverAddr == key.resolverAddr &&
- sibling.serverKey == sample.serverKey
- if sameLogicalQuery || legacySibling {
- delete(c.resolverPending, siblingKey)
- }
- }
- }
- if completedKey.questionFingerprint != 0 {
- if c.resolverCompleted == nil {
- c.resolverCompleted = make(map[resolverCompletedKey]time.Time)
- }
- if len(c.resolverCompleted) >= resolverPendingHardCap {
- for oldKey := range c.resolverCompleted {
- delete(c.resolverCompleted, oldKey)
- break
- }
- }
- c.resolverCompleted[completedKey] = receivedAt.Add(c.resolverSampleTTL())
- }
+ delete(c.resolverPending, key)
}
c.resolverStatsMu.Unlock()
if !ok || sample.serverKey == "" {
- return false
+ return
}
// Credit the carrier only after atomically claiming a real outstanding
// sample. handleInboundPacket calls this path only after decoding a tunnel
// frame, so empty/NODATA replies and duplicated DNS answers cannot inflate a
// carrier's delivery rate.
- if qType, qTypeOK := DnsParser.FirstQuestionQType(packet); qTypeOK && c.carrier != nil {
+ if qType, qTypeOK := DnsParser.FirstQuestionQType(packet); qTypeOK {
c.carrier.recordSuccessForPath(sample.serverKey, qType)
}
@@ -393,71 +181,23 @@ func (c *Client) trackResolverSuccessOverFingerprint(
c.recordTunnelResponse(receivedAt)
c.noteResolverSuccess(sample.serverKey, receivedAt.Sub(sample.sentAt))
- c.noteResolverTransportSuccess(sample.serverKey, sample.transport, receivedAt.Sub(sample.sentAt), receivedAt)
- return true
-}
-
-func (c *Client) resolverReplayCompleted(frame encodedOutboundDatagram, now time.Time) bool {
- if c == nil || frame.replayDepth == 0 || len(frame.packet) < 2 {
- return false
- }
- key := resolverCompletedKey{
- dnsID: binary.BigEndian.Uint16(frame.packet[:2]),
- questionFingerprint: dnsQuestionFingerprint(frame.packet),
- }
- if key.questionFingerprint == 0 {
- return false
- }
- c.resolverStatsMu.Lock()
- expiresAt, completed := c.resolverCompleted[key]
- if completed && !expiresAt.After(now) {
- delete(c.resolverCompleted, key)
- completed = false
- }
- c.resolverStatsMu.Unlock()
- return completed
}
func (c *Client) trackResolverFailure(packet []byte, addr *net.UDPAddr, localAddr string, failedAt time.Time) {
- c.trackResolverFailureOver(packet, addr, localAddr, c.activeTransport(), failedAt)
-}
-
-func (c *Client) trackResolverFailureOver(packet []byte, addr *net.UDPAddr, localAddr string, transport resolverTransport, failedAt time.Time) {
- c.trackResolverFailureSeverityOver(packet, addr, localAddr, transport, failedAt, false)
-}
-
-func (c *Client) trackResolverHardFailureOver(packet []byte, addr *net.UDPAddr, localAddr string, transport resolverTransport, failedAt time.Time) {
- c.trackResolverFailureSeverityOver(packet, addr, localAddr, transport, failedAt, true)
-}
-
-func (c *Client) trackResolverFailureSeverityOver(
- packet []byte,
- addr *net.UDPAddr,
- localAddr string,
- transport resolverTransport,
- failedAt time.Time,
- hard bool,
-) {
if c == nil || len(packet) < 2 || addr == nil {
return
}
- fingerprint := dnsQuestionFingerprint(packet)
key := resolverSampleKey{
- resolverAddr: addr.String(),
- localAddr: localAddr,
- dnsID: binary.BigEndian.Uint16(packet[:2]),
- transport: transport,
- questionFingerprint: fingerprint,
+ resolverAddr: addr.String(),
+ localAddr: localAddr,
+ dnsID: binary.BigEndian.Uint16(packet[:2]),
}
c.resolverStatsMu.Lock()
- actualKey, sample, ok := c.resolverSampleLocked(key)
- if ok && sample.questionFingerprint != 0 && sample.questionFingerprint != fingerprint {
- ok = false
- }
+ sample, ok := c.resolverPending[key]
if ok {
- delete(c.resolverPending, actualKey)
+ delete(c.resolverPending, key)
}
c.resolverStatsMu.Unlock()
@@ -469,78 +209,6 @@ func (c *Client) trackResolverFailureSeverityOver(
}
c.recordResolverHealthEvent(sample.serverKey, false, failedAt)
- if hard {
- c.noteResolverTransportHardFailure(sample.serverKey, sample.transport, failedAt)
- } else {
- c.noteResolverTransportFailure(sample.serverKey, sample.transport, failedAt)
- }
-}
-
-// resolverSampleLocked returns the exact fingerprinted sample, falling back to
-// a zero-fingerprint entry for legacy/tests that predate question-keyed samples.
-// The caller must hold resolverStatsMu for reading or writing.
-func (c *Client) resolverSampleLocked(key resolverSampleKey) (resolverSampleKey, resolverSample, bool) {
- if sample, ok := c.resolverPending[key]; ok {
- return key, sample, true
- }
- if key.questionFingerprint != 0 {
- legacy := key
- legacy.questionFingerprint = 0
- if sample, ok := c.resolverPending[legacy]; ok {
- return legacy, sample, true
- }
- }
- for candidate, sample := range c.resolverPending {
- if candidate.resolverAddr == key.resolverAddr &&
- candidate.localAddr == key.localAddr &&
- candidate.dnsID == key.dnsID &&
- candidate.transport == key.transport {
- return candidate, sample, true
- }
- }
- return resolverSampleKey{}, resolverSample{}, false
-}
-
-func dnsQuestionFingerprint(packet []byte) uint64 {
- if len(packet) < 12 || binary.BigEndian.Uint16(packet[4:6]) != 1 {
- return 0
- }
- const fnvOffset64 = uint64(14695981039346656037)
- const fnvPrime64 = uint64(1099511628211)
- hash := fnvOffset64
- offset := 12
- for {
- if offset >= len(packet) {
- return 0
- }
- lengthByte := packet[offset]
- length := int(lengthByte)
- offset++
- hash ^= uint64(lengthByte)
- hash *= fnvPrime64
- if length == 0 {
- break
- }
- if length&0xc0 != 0 || length > 63 || offset+length > len(packet) {
- return 0
- }
- for _, labelByte := range packet[offset : offset+length] {
- if labelByte >= 'A' && labelByte <= 'Z' {
- labelByte += 'a' - 'A'
- }
- hash ^= uint64(labelByte)
- hash *= fnvPrime64
- }
- offset += length
- }
- if offset+4 > len(packet) {
- return 0
- }
- for _, b := range packet[offset : offset+4] {
- hash ^= uint64(b)
- hash *= fnvPrime64
- }
- return hash
}
func (c *Client) collectExpiredResolverTimeouts(now time.Time) {
@@ -552,8 +220,6 @@ func (c *Client) collectExpiredResolverTimeouts(now time.Time) {
c.resolverStatsMu.Unlock()
for _, observation := range timeoutObservations {
c.noteResolverTimeout(observation.serverKey, observation.at)
- c.noteResolverTransportFailure(observation.serverKey, observation.transport, observation.at)
- c.replayPendingResolverSample(observation.key, failureReplayMaxDepth)
}
}
@@ -574,33 +240,6 @@ func (c *Client) resolverRequestTimeout() time.Duration {
return timeout
}
-// resolverPathRequestTimeout shortens blackhole detection only after a path has
-// enough successful RTT history. Late replies remain claimable during the
-// existing grace window, so a transient DPI delay can repair the timeout sample
-// instead of permanently disabling a working resolver.
-func (c *Client) resolverPathRequestTimeout(serverKey string, transport resolverTransport) time.Duration {
- base := c.resolverRequestTimeout()
- if c == nil || serverKey == "" || !validResolverTransport(transport) {
- return base
- }
- c.resolverTransportMu.Lock()
- state := c.resolverTransportStateLocked(serverKey)
- score := pathScoreFor(state, transport)
- successes, rtt := score.successes, score.rttEWMA
- c.resolverTransportMu.Unlock()
- if successes < transportSpeedSampleThreshold || rtt <= 0 {
- return base
- }
- adaptive := 6*rtt + 500*time.Millisecond
- if adaptive < 1500*time.Millisecond {
- adaptive = 1500 * time.Millisecond
- }
- if adaptive > base {
- return base
- }
- return adaptive
-}
-
func (c *Client) resolverLateResponseGrace(timeout time.Duration) time.Duration {
if timeout <= 0 {
timeout = c.resolverRequestTimeout()
@@ -621,24 +260,16 @@ func (c *Client) pruneResolverSamplesLocked(now time.Time) []resolverTimeoutObse
return nil
}
+ timeoutBefore := now.Add(-c.resolverRequestTimeout())
absoluteCutoff := now.Add(-c.resolverSampleTTL())
requestTimeout := c.resolverRequestTimeout()
lateGrace := c.resolverLateResponseGrace(requestTimeout)
var timeoutObservations []resolverTimeoutObservation
- for key, expiresAt := range c.resolverCompleted {
- if !expiresAt.After(now) {
- delete(c.resolverCompleted, key)
- }
- }
for key, sample := range c.resolverPending {
if !sample.timedOut {
- timeoutAt := sample.timeoutAt
- if timeoutAt.IsZero() {
- timeoutAt = sample.sentAt.Add(requestTimeout)
- }
- if !timeoutAt.After(now) {
+ if !sample.sentAt.After(timeoutBefore) {
sample.timedOut = true
- sample.timedOutAt = timeoutAt
+ sample.timedOutAt = sample.sentAt.Add(requestTimeout)
if sample.timedOutAt.After(now) {
sample.timedOutAt = now
}
@@ -647,9 +278,7 @@ func (c *Client) pruneResolverSamplesLocked(now time.Time) []resolverTimeoutObse
if sample.serverKey != "" {
timeoutObservations = append(timeoutObservations, resolverTimeoutObservation{
serverKey: sample.serverKey,
- transport: sample.transport,
at: sample.timedOutAt,
- key: key,
})
}
}
diff --git a/internal/client/resolver_transport_policy.go b/internal/client/resolver_transport_policy.go
deleted file mode 100644
index 313bb0a..0000000
--- a/internal/client/resolver_transport_policy.go
+++ /dev/null
@@ -1,767 +0,0 @@
-package client
-
-import (
- "fmt"
- "net"
- "sort"
- "strings"
- "time"
-
- Enums "cottendns-go/internal/enums"
- VpnProto "cottendns-go/internal/vpnproto"
-)
-
-const (
- transportFailureSwitchThreshold = 2
- transportSpeedSampleThreshold = 3
- transportSpeedSwitchRatio = 1.20
- transportProbeInterval = 2 * time.Second
- transportSwitchCooldown = 3 * time.Second
- transportPoisonMemory = 2 * time.Minute
-)
-
-type resolverPathScore struct {
- rttEWMA time.Duration
- successes uint32
- failures uint32
- failureStreak uint8
- poisonEvents uint32
- lastPoison time.Time
- probed bool
- viable bool
- uploadMTU int
- downloadMTU int
- uploadLoss float64
- downloadLoss float64
- lastSuccess time.Time
- lastFailure time.Time
-}
-
-type resolverTransportState struct {
- preferred resolverTransport
- paths [4]resolverPathScore
- lastProbe time.Time
- lastSwitch time.Time
- lastBackgroundScan time.Time
- probeCursor int
- backgroundCursor int
-}
-
-type resolverTransportDecision struct {
- primary resolverTransport
- secondary resolverTransport
- hedge bool
-}
-
-type resolverRuntimePath struct {
- connection Connection
- transport resolverTransport
- score float64
- hedge bool
-}
-
-func validResolverTransport(transport resolverTransport) bool {
- return transport >= transportUDP && transport <= transportDoH
-}
-
-func (c *Client) resolverTransportPolicyName(serverKey string) string {
- if c == nil {
- return "udp"
- }
- conn, hasConnection := c.GetConnectionByKey(serverKey)
- candidates := []string{serverKey}
- if hasConnection {
- candidates = append(candidates, conn.ResolverLabel, conn.Resolver)
- if host, _, err := net.SplitHostPort(conn.ResolverLabel); err == nil {
- candidates = append(candidates, host)
- }
- }
- for _, candidate := range candidates {
- if policy, ok := c.cfg.ResolverTransportPaths[strings.TrimSpace(candidate)]; ok {
- return policy
- }
- }
- policy := strings.ToLower(strings.TrimSpace(c.cfg.ResolverTransport))
- if policy == "" {
- return "auto"
- }
- return policy
-}
-
-func (c *Client) resolverTransportCandidates(serverKey string) []resolverTransport {
- return resolverTransportChain(c.resolverTransportPolicyName(serverKey))
-}
-
-func (c *Client) perResolverAutoTransport() bool {
- if c == nil {
- return false
- }
- for _, conn := range c.connections {
- if len(c.resolverTransportCandidates(conn.Key)) > 1 {
- return true
- }
- }
- return len(resolverTransportChain(c.cfg.ResolverTransport)) > 1
-}
-
-func (c *Client) resolverTransportPolicyKey(serverKey string) string {
- if conn, ok := c.GetConnectionByKey(serverKey); ok && conn.ResolverLabel != "" {
- return conn.ResolverLabel
- }
- return serverKey
-}
-
-func (c *Client) resolverTransportStateLocked(serverKey string) *resolverTransportState {
- serverKey = c.resolverTransportPolicyKey(serverKey)
- if c.resolverTransports == nil {
- c.resolverTransports = make(map[string]*resolverTransportState)
- }
- state := c.resolverTransports[serverKey]
- if state == nil {
- candidates := c.resolverTransportCandidates(serverKey)
- preferred := c.activeTransport()
- if len(candidates) > 0 {
- preferred = candidates[0]
- }
- state = &resolverTransportState{preferred: preferred}
- c.resolverTransports[serverKey] = state
- }
- return state
-}
-
-func pathScoreFor(state *resolverTransportState, transport resolverTransport) *resolverPathScore {
- if state == nil || !validResolverTransport(transport) {
- return nil
- }
- return &state.paths[int(transport)]
-}
-
-func (c *Client) pathSupportsSession(score *resolverPathScore) bool {
- if score == nil {
- return false
- }
- // Cached/log-based startup may not have measured the alternate yet. Keep it
- // available for a control hedge or emergency failover; the first result will
- // immediately replace this optimistic state. A path that was actually
- // probed and failed is never selected.
- if !score.probed {
- return true
- }
- if !score.viable {
- return false
- }
- if c.syncedUploadMTU > 0 && score.uploadMTU > 0 && score.uploadMTU < c.syncedUploadMTU {
- return false
- }
- if c.syncedDownloadMTU > 0 && score.downloadMTU > 0 && score.downloadMTU < c.syncedDownloadMTU {
- return false
- }
- return true
-}
-
-// requiredUploadProbeMTU translates a runtime packet into the payload size used
-// by MTU_UP probes. Probe results describe the MTU request payload, while native
-// packet headers vary slightly by type, so compare equivalent raw sizes.
-func requiredUploadProbeMTU(packetType uint8, payloadSize int) int {
- if payloadSize < 0 {
- payloadSize = 0
- }
- required := payloadSize + VpnProto.HeaderRawSize(packetType) - VpnProto.HeaderRawSize(Enums.PACKET_MTU_UP_REQ)
- if required < 1 {
- return 1
- }
- return required
-}
-
-func pathSupportsPacket(score *resolverPathScore, packetType uint8, payloadSize int) bool {
- if score == nil {
- return false
- }
- if !score.probed {
- return true
- }
- if !score.viable {
- return false
- }
- required := requiredUploadProbeMTU(packetType, payloadSize)
- return score.uploadMTU <= 0 || score.uploadMTU >= required
-}
-
-func pathEstimatedGoodput(score *resolverPathScore) float64 {
- if score == nil || !score.probed || !score.viable {
- return 0
- }
- mtu := score.downloadMTU
- if mtu <= 0 {
- mtu = 1
- }
- delivery := 1 - score.downloadLoss
- if delivery < 0.01 {
- delivery = 0.01
- }
- rttMillis := float64(score.rttEWMA) / float64(time.Millisecond)
- if rttMillis < 1 {
- rttMillis = 1
- }
- return float64(mtu) * delivery / rttMillis
-}
-
-func pathEstimatedGoodputForPacket(score *resolverPathScore, packetType uint8) float64 {
- if score == nil || !score.probed || !score.viable {
- return 0
- }
- mtu, loss := score.uploadMTU, score.uploadLoss
- switch packetType {
- case Enums.PACKET_STREAM_DATA_ACK, Enums.PACKET_STREAM_DATA_NACK, Enums.PACKET_PING:
- mtu, loss = score.downloadMTU, score.downloadLoss
- }
- if mtu <= 0 {
- mtu = 1
- }
- delivery := 1 - loss
- if delivery < 0.01 {
- delivery = 0.01
- }
- rttMillis := float64(score.rttEWMA) / float64(time.Millisecond)
- if rttMillis < 1 {
- rttMillis = 1
- }
- value := float64(mtu) * delivery / rttMillis
- if score.failureStreak > 0 {
- value /= 1 + float64(score.failureStreak*2)
- }
- return value
-}
-
-func fallbackConnectionPathScore(conn Connection, packetType uint8) float64 {
- mtu, loss := conn.UploadMTUBytes, conn.UploadMTULoss
- switch packetType {
- case Enums.PACKET_STREAM_DATA_ACK, Enums.PACKET_STREAM_DATA_NACK, Enums.PACKET_PING:
- mtu, loss = conn.DownloadMTUBytes, conn.DownloadMTULoss
- }
- if mtu <= 0 {
- mtu = 1
- }
- delivery := 1 - loss
- if delivery < 0.01 {
- delivery = 0.01
- }
- rttMillis := float64(conn.MTUResolveTime) / float64(time.Millisecond)
- if rttMillis < 1 {
- rttMillis = 1
- }
- return float64(mtu) * delivery / rttMillis
-}
-
-// bestPacketTransportLocked selects a resolver's best transport for the actual
-// packet size. Unlike session-level selection, this deliberately permits a
-// measured narrow path when the current packet fits it.
-func (c *Client) bestPacketTransportLocked(
- serverKey string,
- state *resolverTransportState,
- packetType uint8,
- payloadSize int,
-) (resolverTransport, float64, bool) {
- var (
- best resolverTransport
- bestScore = -1.0
- found bool
- )
- for _, transport := range c.resolverTransportCandidates(serverKey) {
- path := pathScoreFor(state, transport)
- if !path.probed &&
- (packetType == Enums.PACKET_STREAM_DATA || packetType == Enums.PACKET_STREAM_RESEND) {
- // Never infer bulk capacity on an unmeasured transport. Startup's
- // emergency legacy fallback remains available when no measured path
- // exists, while normal joint routing keeps bulk off unknown MTUs.
- continue
- }
- if !pathSupportsPacket(path, packetType, payloadSize) {
- continue
- }
- value := pathEstimatedGoodputForPacket(path, packetType)
- // Unmeasured configured paths remain emergency candidates but never
- // outrank a measured healthy path.
- if !path.probed {
- value = 0.0001
- }
- if !found || value > bestScore {
- best, bestScore, found = transport, value, true
- }
- }
- return best, bestScore, found
-}
-
-// selectJointRuntimePaths scores resolver and transport together. Backup
-// resolvers participate whenever the concrete packet fits their measured MTU,
-// which converts narrow paths into useful capacity without lowering the global
-// session MTU or penalizing bulk traffic on clean paths.
-func (c *Client) selectJointRuntimePaths(
- packetType uint8,
- streamID uint16,
- payloadSize int,
- count int,
- now time.Time,
-) []resolverRuntimePath {
- if c == nil || c.balancer == nil {
- return nil
- }
- if count < 1 {
- count = 1
- }
-
- connections := c.balancer.AllValidConnectionsIncludingBackup()
- eligibleConnections := make([]Connection, 0, len(connections))
- for _, conn := range connections {
- if conn.IsValid && conn.Key != "" && !c.isRuntimeDisabledResolver(conn.Key) {
- eligibleConnections = append(eligibleConnections, conn)
- }
- }
- paths := make([]resolverRuntimePath, 0, len(eligibleConnections))
-
- preferredKey := ""
- if streamID != 0 &&
- (packetType == Enums.PACKET_STREAM_DATA || packetType == Enums.PACKET_STREAM_RESEND) {
- if stream, ok := c.getStream(streamID); ok && stream != nil {
- stream.resolverMu.Lock()
- preferredKey = stream.PreferredServerKey
- stream.resolverMu.Unlock()
- }
- }
-
- c.resolverTransportMu.Lock()
- for _, conn := range eligibleConnections {
- state := c.resolverTransportStateLocked(conn.Key)
- transport, score, ok := c.bestPacketTransportLocked(conn.Key, state, packetType, payloadSize)
- if !ok {
- continue
- }
- if score <= 0.0001 {
- score = fallbackConnectionPathScore(conn, packetType)
- }
- if conn.Key == preferredKey {
- // Mild stickiness prevents reordering for statistically equivalent
- // paths while still allowing a meaningfully faster path to win.
- score *= 1.10
- }
- paths = append(paths, resolverRuntimePath{
- connection: conn,
- transport: transport,
- score: score,
- })
- }
- c.resolverTransportMu.Unlock()
-
- sort.SliceStable(paths, func(i, j int) bool {
- if paths[i].score == paths[j].score {
- return paths[i].connection.Key < paths[j].connection.Key
- }
- return paths[i].score > paths[j].score
- })
- if len(paths) == 0 {
- return nil
- }
-
- selected := make([]resolverRuntimePath, 0, count+1)
- seenResolvers := make(map[string]struct{}, count)
- seenDomains := make(map[string]struct{}, count)
- appendPath := func(path resolverRuntimePath) bool {
- if _, exists := seenResolvers[path.connection.Key]; exists {
- return false
- }
- selected = append(selected, path)
- seenResolvers[path.connection.Key] = struct{}{}
- seenDomains[path.connection.Domain] = struct{}{}
- return true
- }
-
- if c.dupPreferDistinctDomains && count > 1 {
- for _, path := range paths {
- if len(selected) >= count {
- break
- }
- if _, duplicateDomain := seenDomains[path.connection.Domain]; duplicateDomain {
- continue
- }
- appendPath(path)
- }
- }
- for _, path := range paths {
- if len(selected) >= count {
- break
- }
- appendPath(path)
- }
-
- // Sparse control traffic can explore one alternate transport on the same
- // resolver. Bulk packets never hedge, so exploration cannot cap throughput.
- if len(selected) > 0 && Enums.DefaultPacketPriority(packetType) <= Enums.PacketPriorityHigh {
- primary := selected[0]
- c.resolverTransportMu.Lock()
- state := c.resolverTransportStateLocked(primary.connection.Key)
- if state.lastProbe.IsZero() || now.Sub(state.lastProbe) >= transportProbeInterval {
- for _, alternate := range c.resolverTransportCandidates(primary.connection.Key) {
- if alternate == primary.transport ||
- !pathSupportsPacket(pathScoreFor(state, alternate), packetType, payloadSize) {
- continue
- }
- selected = append(selected, resolverRuntimePath{
- connection: primary.connection,
- transport: alternate,
- score: primary.score,
- hedge: true,
- })
- state.lastProbe = now
- break
- }
- }
- c.resolverTransportMu.Unlock()
- }
- return selected
-}
-
-func (c *Client) alternateResolverTransportForPacket(
- serverKey string,
- exclude resolverTransport,
- packetType uint8,
- payloadSize int,
-) (resolverTransport, bool) {
- c.resolverTransportMu.Lock()
- defer c.resolverTransportMu.Unlock()
- state := c.resolverTransportStateLocked(serverKey)
- var (
- best resolverTransport
- bestScore = -1.0
- found bool
- )
- for _, transport := range c.resolverTransportCandidates(serverKey) {
- if transport == exclude {
- continue
- }
- score := pathScoreFor(state, transport)
- if !pathSupportsPacket(score, packetType, payloadSize) {
- continue
- }
- value := pathEstimatedGoodputForPacket(score, packetType)
- if !score.probed {
- value = 0.0001
- }
- if !found || value > bestScore {
- best, bestScore, found = transport, value, true
- }
- }
- return best, found
-}
-
-func (c *Client) bestResolverTransportLocked(serverKey string, state *resolverTransportState) resolverTransport {
- candidates := c.resolverTransportCandidates(serverKey)
- if len(candidates) == 0 {
- return c.activeTransport()
- }
- best := candidates[0]
- bestScore := -1.0
- for _, transport := range candidates {
- score := pathScoreFor(state, transport)
- if !c.pathSupportsSession(score) {
- continue
- }
- value := pathEstimatedGoodput(score)
- if score.failureStreak >= transportFailureSwitchThreshold {
- value *= 0.05
- }
- if value > bestScore {
- best, bestScore = transport, value
- }
- }
- if bestScore < 0 {
- for _, transport := range candidates {
- if transport == state.preferred {
- return transport
- }
- }
- return candidates[0]
- }
- return best
-}
-
-func (c *Client) chooseResolverTransport(serverKey string, priority int, now time.Time) resolverTransportDecision {
- c.resolverTransportMu.Lock()
- defer c.resolverTransportMu.Unlock()
- state := c.resolverTransportStateLocked(serverKey)
- candidates := c.resolverTransportCandidates(serverKey)
- if len(candidates) <= 1 {
- if len(candidates) == 1 {
- state.preferred = candidates[0]
- }
- return resolverTransportDecision{primary: state.preferred}
- }
-
- best := c.bestResolverTransportLocked(serverKey, state)
- if best != state.preferred && now.Sub(state.lastSwitch) >= transportSwitchCooldown {
- state.preferred = best
- state.lastSwitch = now
- }
- decision := resolverTransportDecision{primary: state.preferred}
-
- // Sparse control/setup traffic samples one alternate path. Bulk data never
- // gets transport-duplicated, so path learning cannot halve useful goodput.
- if priority <= Enums.PacketPriorityHigh &&
- (state.lastProbe.IsZero() || now.Sub(state.lastProbe) >= transportProbeInterval) {
- for attempts := 0; attempts < len(candidates); attempts++ {
- state.probeCursor = (state.probeCursor + 1) % len(candidates)
- alternate := candidates[state.probeCursor]
- if alternate == decision.primary || !c.pathSupportsSession(pathScoreFor(state, alternate)) {
- continue
- }
- decision.secondary = alternate
- decision.hedge = true
- state.lastProbe = now
- break
- }
- }
- return decision
-}
-
-func updatePathRTT(score *resolverPathScore, rtt time.Duration) {
- if score == nil {
- return
- }
- if rtt < 0 {
- rtt = 0
- }
- if score.rttEWMA == 0 {
- score.rttEWMA = rtt
- return
- }
- score.rttEWMA = (score.rttEWMA*7 + rtt) / 8
-}
-
-func (c *Client) noteResolverTransportProbe(
- serverKey string,
- transport resolverTransport,
- result mtuConnectionProbeResult,
- ok bool,
- now time.Time,
-) {
- if c == nil || serverKey == "" || !validResolverTransport(transport) {
- return
- }
- c.resolverTransportMu.Lock()
- defer c.resolverTransportMu.Unlock()
- state := c.resolverTransportStateLocked(serverKey)
- score := pathScoreFor(state, transport)
- score.probed = true
- score.viable = ok
- if ok {
- score.uploadMTU = result.UploadBytes
- score.downloadMTU = result.DownloadBytes
- score.uploadLoss = result.UploadLoss
- score.downloadLoss = result.DownloadLoss
- score.failureStreak = 0
- score.lastSuccess = now
- updatePathRTT(score, result.ResolveTime)
- } else {
- score.lastFailure = now
- }
- best := c.bestResolverTransportLocked(serverKey, state)
- if best != state.preferred {
- state.preferred = best
- state.lastSwitch = now
- }
-}
-
-func (c *Client) noteResolverTransportSuccess(serverKey string, transport resolverTransport, rtt time.Duration, now time.Time) {
- if c == nil || serverKey == "" || !validResolverTransport(transport) {
- return
- }
- c.resolverTransportMu.Lock()
- defer c.resolverTransportMu.Unlock()
- state := c.resolverTransportStateLocked(serverKey)
- score := pathScoreFor(state, transport)
- score.probed = true
- score.viable = true
- if score.uploadMTU == 0 {
- score.uploadMTU = max(1, c.syncedUploadMTU)
- }
- if score.downloadMTU == 0 {
- score.downloadMTU = max(1, c.syncedDownloadMTU)
- }
- score.successes++
- score.failureStreak = 0
- score.lastSuccess = now
- updatePathRTT(score, rtt)
-
- if now.Sub(state.lastSwitch) < transportSwitchCooldown {
- return
- }
- best := c.bestResolverTransportLocked(serverKey, state)
- current := pathScoreFor(state, state.preferred)
- better := pathScoreFor(state, best)
- if best != state.preferred &&
- better != nil && better.successes >= transportSpeedSampleThreshold &&
- (current == nil || current.successes >= transportSpeedSampleThreshold) &&
- pathEstimatedGoodput(better) > pathEstimatedGoodput(current)*transportSpeedSwitchRatio {
- state.preferred = best
- state.lastSwitch = now
- }
-}
-
-func (c *Client) noteResolverTransportFailure(serverKey string, transport resolverTransport, now time.Time) {
- if c == nil || serverKey == "" || !validResolverTransport(transport) {
- return
- }
- c.resolverTransportMu.Lock()
- defer c.resolverTransportMu.Unlock()
- state := c.resolverTransportStateLocked(serverKey)
- score := pathScoreFor(state, transport)
- score.failures++
- score.lastFailure = now
- if score.failureStreak < 255 {
- score.failureStreak++
- }
- requiredFailures := uint8(transportFailureSwitchThreshold)
- if !score.lastPoison.IsZero() {
- poisonAge := now.Sub(score.lastPoison)
- // Timeout observations carry their scheduled deadline, which can be a
- // few milliseconds earlier than the wall-clock poison arrival processed
- // beside it. Treat that ordering as simultaneous, not stale/future.
- if poisonAge < 0 {
- poisonAge = 0
- }
- if poisonAge <= transportPoisonMemory {
- requiredFailures = 1
- }
- }
- if state.preferred != transport || score.failureStreak < requiredFailures ||
- now.Sub(state.lastSwitch) < transportSwitchCooldown {
- return
- }
- best := c.bestResolverTransportLocked(serverKey, state)
- if best == transport {
- for _, candidate := range c.resolverTransportCandidates(serverKey) {
- if candidate != transport && c.pathSupportsSession(pathScoreFor(state, candidate)) {
- best = candidate
- break
- }
- }
- }
- if best != transport && c.pathSupportsSession(pathScoreFor(state, best)) {
- state.preferred = best
- state.lastSwitch = now
- }
-}
-
-// noteResolverTransportHardFailure handles an explicit path-level rejection
-// such as UDP truncation. Waiting for a second timeout wastes an entire ARQ RTO,
-// so an eligible alternate becomes preferred immediately.
-func (c *Client) noteResolverTransportHardFailure(serverKey string, transport resolverTransport, now time.Time) {
- if c == nil || serverKey == "" || !validResolverTransport(transport) {
- return
- }
- c.resolverTransportMu.Lock()
- defer c.resolverTransportMu.Unlock()
- state := c.resolverTransportStateLocked(serverKey)
- score := pathScoreFor(state, transport)
- score.failures++
- score.lastFailure = now
- if score.failureStreak < transportFailureSwitchThreshold {
- score.failureStreak = transportFailureSwitchThreshold
- }
- if state.preferred != transport {
- return
- }
- for _, candidate := range c.resolverTransportCandidates(serverKey) {
- if candidate == transport {
- continue
- }
- alternate := pathScoreFor(state, candidate)
- if !alternate.probed || alternate.viable {
- state.preferred = candidate
- state.lastSwitch = now
- state.lastProbe = time.Time{}
- return
- }
- }
-}
-func (c *Client) noteResolverTransportPoison(serverKey string, transport resolverTransport) {
- if c == nil || serverKey == "" || !validResolverTransport(transport) {
- return
- }
- c.resolverTransportMu.Lock()
- defer c.resolverTransportMu.Unlock()
- state := c.resolverTransportStateLocked(serverKey)
- score := pathScoreFor(state, transport)
- score.poisonEvents++
- score.lastPoison = c.now()
- // Poison alone is not failure: if the authenticated answer still wins
- // quickly, the poisoned environment remains usable. Force a prompt alternate
- // comparison; timeout/RTT decides whether to leave the path.
- state.lastProbe = time.Time{}
-}
-
-func (c *Client) preferredResolverTransport(serverKey string) resolverTransport {
- return c.chooseResolverTransport(serverKey, Enums.PacketPriorityNormal, c.now()).primary
-}
-
-func (c *Client) orderedResolverTransports(serverKey string) []resolverTransport {
- c.resolverTransportMu.Lock()
- defer c.resolverTransportMu.Unlock()
- state := c.resolverTransportStateLocked(serverKey)
- candidates := c.resolverTransportCandidates(serverKey)
- out := make([]resolverTransport, 0, len(candidates))
- if validResolverTransport(state.preferred) {
- out = append(out, state.preferred)
- }
- for _, transport := range candidates {
- if transport != state.preferred {
- out = append(out, transport)
- }
- }
- return out
-}
-
-func (c *Client) runtimeTransportsNeeded() map[resolverTransport]bool {
- needed := make(map[resolverTransport]bool)
- if c == nil || len(c.connections) == 0 {
- for _, transport := range resolverTransportChain(c.cfg.ResolverTransport) {
- needed[transport] = true
- }
- return needed
- }
- for _, conn := range c.connections {
- for _, transport := range c.resolverTransportCandidates(conn.Key) {
- needed[transport] = true
- }
- }
- return needed
-}
-
-func (c *Client) transportBackgroundScanInterval() time.Duration {
- if c == nil || c.cfg.ResolverTransportBackgroundScanIntervalSec <= 0 {
- return 30 * time.Second
- }
- return time.Duration(c.cfg.ResolverTransportBackgroundScanIntervalSec * float64(time.Second))
-}
-
-func (c *Client) resolverTransportSummary() string {
- if c == nil {
- return "unknown"
- }
- if !c.perResolverAutoTransport() {
- return c.activeTransport().String()
- }
- var counts [4]int
- c.resolverTransportMu.Lock()
- for _, conn := range c.connections {
- if !conn.IsValid {
- continue
- }
- transport := c.resolverTransportStateLocked(conn.Key).preferred
- if validResolverTransport(transport) {
- counts[int(transport)]++
- }
- }
- c.resolverTransportMu.Unlock()
- return fmt.Sprintf("adaptive UDP=%d TCP=%d DoT=%d DoH=%d",
- counts[transportUDP], counts[transportTCP], counts[transportDoT], counts[transportDoH])
-}
diff --git a/internal/client/resolver_transport_policy_test.go b/internal/client/resolver_transport_policy_test.go
deleted file mode 100644
index 6f1a2a5..0000000
--- a/internal/client/resolver_transport_policy_test.go
+++ /dev/null
@@ -1,364 +0,0 @@
-package client
-
-import (
- "context"
- "net"
- "sort"
- "testing"
- "time"
-
- "cottendns-go/internal/config"
- DnsParser "cottendns-go/internal/dnsparser"
- Enums "cottendns-go/internal/enums"
-)
-
-func newAutoTransportPolicyClient() *Client {
- c := &Client{
- cfg: config.ClientConfig{ResolverTransport: "auto"},
- connectionsByKey: make(map[string]int),
- resolverTransports: make(map[string]*resolverTransportState),
- }
- c.setActiveTransport(transportUDP)
- return c
-}
-
-func TestPerResolverTransportOverrides(t *testing.T) {
- c := newAutoTransportPolicyClient()
- c.cfg.ResolverTransportPaths = map[string]string{
- "192.0.2.1": "tcp",
- "192.0.2.2:53": "dot",
- "key-c": "doh",
- }
- c.connections = []Connection{
- {Key: "key-a", Resolver: "192.0.2.1", ResolverLabel: "192.0.2.1:53"},
- {Key: "key-b", Resolver: "192.0.2.2", ResolverLabel: "192.0.2.2:53"},
- {Key: "key-c", Resolver: "192.0.2.3", ResolverLabel: "192.0.2.3:53"},
- }
- c.connectionsByKey = map[string]int{"key-a": 0, "key-b": 1, "key-c": 2}
-
- cases := map[string][]resolverTransport{
- "key-a": {transportTCP},
- "key-b": {transportDoT, transportUDP, transportTCP},
- "key-c": {transportDoH, transportUDP, transportTCP},
- }
- for key, want := range cases {
- got := c.resolverTransportCandidates(key)
- if len(got) != len(want) {
- t.Fatalf("%s candidates=%v, want=%v", key, got, want)
- }
- for i := range want {
- if got[i] != want[i] {
- t.Fatalf("%s candidates=%v, want=%v", key, got, want)
- }
- }
- }
-}
-
-func TestInitialAndBackgroundMTUProbeSupportAllTransports(t *testing.T) {
- c := newAutoTransportPolicyClient()
- c.syncedUploadMTU = 100
- c.syncedDownloadMTU = 1000
- c.mtuTestRetries = 1
- c.cfg.ResolverTransportPaths = map[string]string{
- "udp": "udp",
- "tcp": "tcp",
- "dot": "dot",
- "doh": "doh",
- }
- c.connections = []Connection{
- {Key: "udp", ResolverLabel: "192.0.2.1:53"},
- {Key: "tcp", ResolverLabel: "192.0.2.2:53"},
- {Key: "dot", ResolverLabel: "192.0.2.3:53"},
- {Key: "doh", ResolverLabel: "192.0.2.4:53"},
- }
- c.connectionsByKey = map[string]int{"udp": 0, "tcp": 1, "dot": 2, "doh": 3}
-
- initialSeen := map[resolverTransport]bool{}
- c.probeConnectionMTUOverFn = func(_ context.Context, _ *Connection, _ int, transport resolverTransport) (mtuConnectionProbeResult, mtuRejectReason) {
- initialSeen[transport] = true
- return mtuConnectionProbeResult{
- UploadBytes: 100, UploadChars: 100, DownloadBytes: 1000,
- ResolveTime: 50 * time.Millisecond,
- }, mtuRejectNone
- }
- for i := range c.connections {
- if _, reason := c.probeConnectionMTU(context.Background(), &c.connections[i], 100); reason != mtuRejectNone {
- t.Fatalf("initial probe rejected %s", c.connections[i].Key)
- }
- }
- for _, transport := range []resolverTransport{transportUDP, transportTCP, transportDoT, transportDoH} {
- if !initialSeen[transport] {
- t.Errorf("initial MTU scan did not exercise %s", transport)
- }
- }
-
- var backgroundSeen []string
- c.probeSessionMTUOverFn = func(_ context.Context, conn *Connection, transport resolverTransport) (mtuConnectionProbeResult, bool) {
- backgroundSeen = append(backgroundSeen, conn.Key+":"+transport.String())
- return mtuConnectionProbeResult{UploadBytes: 100, DownloadBytes: 1000, ResolveTime: 60 * time.Millisecond}, true
- }
- for i := range c.connections {
- if !c.recheckResolverConnection(context.Background(), &c.connections[i]) {
- t.Fatalf("background probe rejected %s", c.connections[i].Key)
- }
- }
- sort.Strings(backgroundSeen)
- for _, want := range []string{"doh:DoH", "dot:DoT", "tcp:TCP/53", "udp:UDP/53"} {
- idx := sort.SearchStrings(backgroundSeen, want)
- if idx >= len(backgroundSeen) || backgroundSeen[idx] != want {
- t.Errorf("background MTU scan missing %s; got=%v", want, backgroundSeen)
- }
- }
-}
-
-func TestBackgroundDiscoveryRetainsNarrowTransportMTUs(t *testing.T) {
- c := newAutoTransportPolicyClient()
- c.cfg.MaxUploadMTU = 250
- c.connections = []Connection{{
- Key: "resolver-a", Domain: "tunnel.example", Resolver: "192.0.2.20",
- ResolverLabel: "192.0.2.20:53", IsValid: true,
- }}
- c.connectionsByKey = map[string]int{"resolver-a": 0}
-
- var scanned []resolverTransport
- c.probeConnectionMTUOverFn = func(
- _ context.Context,
- _ *Connection,
- maxUpload int,
- transport resolverTransport,
- ) (mtuConnectionProbeResult, mtuRejectReason) {
- if maxUpload != 250 {
- t.Fatalf("background max upload=%d, want 250", maxUpload)
- }
- scanned = append(scanned, transport)
- upload := 64
- if transport == transportTCP {
- upload = 96
- }
- return mtuConnectionProbeResult{
- UploadBytes: upload, DownloadBytes: 600, ResolveTime: 40 * time.Millisecond,
- }, mtuRejectNone
- }
-
- c.refreshResolverTransportPath(context.Background(), &c.connections[0])
- c.refreshResolverTransportPath(context.Background(), &c.connections[0])
- if len(scanned) != 2 || scanned[0] != transportUDP || scanned[1] != transportTCP {
- t.Fatalf("background scan did not rotate UDP/TCP: %v", scanned)
- }
-
- c.resolverTransportMu.Lock()
- state := c.resolverTransportStateLocked("resolver-a")
- udp, tcp := *pathScoreFor(state, transportUDP), *pathScoreFor(state, transportTCP)
- c.resolverTransportMu.Unlock()
- if !udp.viable || udp.uploadMTU != 64 || !tcp.viable || tcp.uploadMTU != 96 {
- t.Fatalf("narrow MTUs were not retained: udp=%+v tcp=%+v", udp, tcp)
- }
-}
-
-func TestAutoTransportKeepsPoisonedFastUDP(t *testing.T) {
- c := newAutoTransportPolicyClient()
- now := time.Now()
- c.noteResolverTransportPoison("resolver-a", transportUDP)
- for i := 0; i < 4; i++ {
- c.noteResolverTransportSuccess("resolver-a", transportUDP, 80*time.Millisecond, now.Add(time.Duration(i)*time.Second))
- }
- if got := c.chooseResolverTransport("resolver-a", Enums.PacketPriorityNormal, now.Add(5*time.Second)).primary; got != transportUDP {
- t.Fatalf("poison alone moved a healthy UDP path: got %s", got)
- }
- if got := c.chooseResolverTransport("resolver-a", Enums.PacketPriorityCritical, now.Add(5*time.Second)); !got.hedge {
- t.Fatal("poison should make the next control packet compare the alternate path")
- }
-}
-
-func TestAutoTransportSwitchesOnlyFailingResolver(t *testing.T) {
- c := newAutoTransportPolicyClient()
- now := time.Now()
- c.noteResolverTransportFailure("resolver-a", transportUDP, now)
- c.noteResolverTransportFailure("resolver-a", transportUDP, now.Add(4*time.Second))
-
- if got := c.chooseResolverTransport("resolver-a", Enums.PacketPriorityNormal, now.Add(5*time.Second)).primary; got != transportTCP {
- t.Fatalf("failing resolver did not switch to TCP: got %s", got)
- }
- if got := c.chooseResolverTransport("resolver-b", Enums.PacketPriorityNormal, now.Add(5*time.Second)).primary; got != transportUDP {
- t.Fatalf("unrelated resolver was switched globally: got %s", got)
- }
-}
-
-func TestPoisonPlusTimeoutSwitchesImmediately(t *testing.T) {
- c := newAutoTransportPolicyClient()
- now := time.Now()
- c.nowFn = func() time.Time { return now.Add(20 * time.Millisecond) }
- c.noteResolverTransportPoison("resolver-a", transportUDP)
- c.noteResolverTransportFailure("resolver-a", transportUDP, now)
- if got := c.chooseResolverTransport("resolver-a", Enums.PacketPriorityNormal, now.Add(time.Second)).primary; got != transportTCP {
- t.Fatalf("poisoned UDP timeout did not fast-switch to TCP: got %s", got)
- }
-}
-
-func TestExpiredPoisonDoesNotMakeFutureTransientFailureHard(t *testing.T) {
- c := newAutoTransportPolicyClient()
- now := time.Now()
- c.nowFn = func() time.Time { return now }
- c.noteResolverTransportPoison("resolver-a", transportUDP)
-
- now = now.Add(transportPoisonMemory + time.Second)
- c.noteResolverTransportFailure("resolver-a", transportUDP, now)
- if got := c.chooseResolverTransport("resolver-a", Enums.PacketPriorityNormal, now).primary; got != transportUDP {
- t.Fatalf("stale poison made one later timeout switch paths: got %s", got)
- }
-
- now = now.Add(transportSwitchCooldown + time.Second)
- c.noteResolverTransportFailure("resolver-a", transportUDP, now)
- if got := c.chooseResolverTransport("resolver-a", Enums.PacketPriorityNormal, now).primary; got != transportTCP {
- t.Fatalf("two current failures did not switch paths: got %s", got)
- }
-}
-
-func TestResolverPathTimeoutUsesStableRTTHistory(t *testing.T) {
- c := newAutoTransportPolicyClient()
- c.tunnelPacketTimeout = 5 * time.Second
- now := time.Now()
- for i := 0; i < transportSpeedSampleThreshold; i++ {
- c.noteResolverTransportSuccess(
- "resolver-a",
- transportUDP,
- 100*time.Millisecond,
- now.Add(time.Duration(i)*time.Second),
- )
- }
- if got := c.resolverPathRequestTimeout("resolver-a", transportUDP); got != 1500*time.Millisecond {
- t.Fatalf("adaptive path timeout=%s, want 1.5s", got)
- }
-
- for i := 0; i < transportSpeedSampleThreshold; i++ {
- c.noteResolverTransportSuccess(
- "resolver-b",
- transportUDP,
- time.Second,
- now.Add(time.Duration(i)*time.Second),
- )
- }
- if got := c.resolverPathRequestTimeout("resolver-b", transportUDP); got != 5*time.Second {
- t.Fatalf("slow path timeout=%s, want configured 5s cap", got)
- }
-}
-
-func TestAutoTransportMovesOffSlowUDP(t *testing.T) {
- c := newAutoTransportPolicyClient()
- now := time.Now().Add(-10 * time.Second)
- for i := 0; i < transportSpeedSampleThreshold; i++ {
- at := now.Add(time.Duration(i) * time.Second)
- c.noteResolverTransportSuccess("resolver-a", transportTCP, 100*time.Millisecond, at)
- c.noteResolverTransportSuccess("resolver-a", transportUDP, 250*time.Millisecond, at)
- }
- if got := c.chooseResolverTransport("resolver-a", Enums.PacketPriorityNormal, time.Now()).primary; got != transportTCP {
- t.Fatalf("slow UDP remained preferred despite a consistently faster TCP path: got %s", got)
- }
-}
-
-func TestExplicitTransportNeverHedges(t *testing.T) {
- c := newAutoTransportPolicyClient()
- c.cfg.ResolverTransport = "udp"
- got := c.chooseResolverTransport("resolver-a", Enums.PacketPriorityCritical, time.Now())
- if got.primary != transportUDP || got.hedge {
- t.Fatalf("explicit UDP changed behavior: %+v", got)
- }
-}
-
-func TestHedgedResponseIsClaimedOnlyOnce(t *testing.T) {
- c := newAutoTransportPolicyClient()
- c.resolverPending = make(map[resolverSampleKey]resolverSample)
- addr := &net.UDPAddr{IP: net.ParseIP("192.0.2.53"), Port: 53}
- packet, err := DnsParser.BuildTXTQuestionPacket("payload.tunnel.example", Enums.DNS_RECORD_TYPE_TXT, 4096)
- if err != nil {
- t.Fatal(err)
- }
- now := time.Now()
- c.trackResolverFrameOver(encodedOutboundDatagram{
- addr: addr, serverKey: "resolver-a", packet: packet,
- transport: transportUDP, mayHaveSibling: true,
- }, "udp-local", transportUDP, now)
- c.trackResolverFrameOver(encodedOutboundDatagram{
- addr: addr, serverKey: "resolver-a", packet: packet,
- transport: transportTCP, mayHaveSibling: true,
- }, "tcp-local", transportTCP, now)
-
- if !c.trackResolverSuccessOver(packet, addr, "tcp-local", transportTCP, now.Add(50*time.Millisecond)) {
- t.Fatal("first hedged response was not claimed")
- }
- if c.trackResolverSuccessOver(packet, addr, "udp-local", transportUDP, now.Add(80*time.Millisecond)) {
- t.Fatal("slower duplicate hedged response was claimed twice")
- }
- if len(c.resolverPending) != 0 {
- t.Fatalf("hedged sibling remained pending: %d", len(c.resolverPending))
- }
-}
-
-func TestJointPathSelectionUsesNarrowBackupForSmallPackets(t *testing.T) {
- c := newAutoTransportPolicyClient()
- c.syncedUploadMTU = 220
- c.syncedDownloadMTU = 1200
- c.connections = []Connection{
- {
- Key: "wide", Domain: "wide.tunnel.example", Resolver: "192.0.2.1",
- ResolverLabel: "192.0.2.1:53", IsValid: true,
- UploadMTUBytes: 220, DownloadMTUBytes: 1200, MTUResolveTime: 180 * time.Millisecond,
- },
- {
- Key: "narrow", Domain: "narrow.tunnel.example", Resolver: "192.0.2.2",
- ResolverLabel: "192.0.2.2:53", IsValid: true, Backup: true,
- UploadMTUBytes: 70, DownloadMTUBytes: 900, MTUResolveTime: 30 * time.Millisecond,
- },
- }
- c.connectionsByKey = map[string]int{"wide": 0, "narrow": 1}
- c.balancer = NewBalancer(BalancingRoundRobinDefault)
- c.balancer.SetConnections([]*Connection{&c.connections[0], &c.connections[1]})
-
- now := time.Now()
- c.noteResolverTransportProbe("wide", transportUDP, mtuConnectionProbeResult{
- UploadBytes: 220, DownloadBytes: 1200, ResolveTime: 180 * time.Millisecond,
- }, true, now)
- c.noteResolverTransportProbe("narrow", transportUDP, mtuConnectionProbeResult{
- UploadBytes: 70, DownloadBytes: 900, ResolveTime: 30 * time.Millisecond,
- }, true, now)
-
- small := c.selectJointRuntimePaths(Enums.PACKET_STREAM_DATA_ACK, 0, 0, 1, now)
- if len(small) == 0 || small[0].connection.Key != "narrow" {
- t.Fatalf("small control packet did not use fast narrow backup: %+v", small)
- }
-
- large := c.selectJointRuntimePaths(Enums.PACKET_STREAM_DATA, 0, 180, 1, now)
- if len(large) == 0 || large[0].connection.Key != "wide" {
- t.Fatalf("large data packet used an MTU-ineligible path: %+v", large)
- }
-}
-
-func TestJointPathSelectionChoosesTransportByPacketSize(t *testing.T) {
- c := newAutoTransportPolicyClient()
- c.connections = []Connection{{
- Key: "resolver-a", Domain: "tunnel.example", Resolver: "192.0.2.10",
- ResolverLabel: "192.0.2.10:53", IsValid: true,
- UploadMTUBytes: 220, DownloadMTUBytes: 1200,
- }}
- c.connectionsByKey = map[string]int{"resolver-a": 0}
- c.balancer = NewBalancer(BalancingRoundRobinDefault)
- c.balancer.SetConnections([]*Connection{&c.connections[0]})
-
- now := time.Now()
- c.noteResolverTransportProbe("resolver-a", transportUDP, mtuConnectionProbeResult{
- UploadBytes: 70, DownloadBytes: 900, ResolveTime: 25 * time.Millisecond,
- }, true, now)
- c.noteResolverTransportProbe("resolver-a", transportTCP, mtuConnectionProbeResult{
- UploadBytes: 220, DownloadBytes: 1200, ResolveTime: 90 * time.Millisecond,
- }, true, now)
-
- small := c.selectJointRuntimePaths(Enums.PACKET_STREAM_DATA, 0, 30, 1, now)
- if len(small) == 0 || small[0].transport != transportUDP {
- t.Fatalf("small packet did not use faster narrow UDP: %+v", small)
- }
-
- large := c.selectJointRuntimePaths(Enums.PACKET_STREAM_DATA, 0, 180, 1, now)
- if len(large) == 0 || large[0].transport != transportTCP {
- t.Fatalf("large packet did not move to wide TCP: %+v", large)
- }
-}
diff --git a/internal/client/tcp_data.go b/internal/client/tcp_data.go
index 71dc44f..7d33ed5 100644
--- a/internal/client/tcp_data.go
+++ b/internal/client/tcp_data.go
@@ -4,10 +4,10 @@
// Github: https://github.com/TaJirax/CottenDns
// Year: 2026
// ==============================================================================
-// tcp_data.go — high-throughput data-plane transport over DNS-over-TCP/53 or
-// DoT. Adaptive mode may use it for one resolver while others remain on UDP. It
-// keeps one persistent TCP connection per resolver, writes length-prefixed
-// queries, and runs a read loop per
+// tcp_data.go — high-throughput data-plane transport over DNS-over-TCP/53, used
+// when the client runs in TCP mode (RESOLVER_TRANSPORT=tcp, or the auto fallback
+// after a UDP scan finds zero resolvers). It keeps one persistent TCP connection
+// per resolver, writes length-prefixed queries, and runs a read loop per
// connection that pushes responses into the SAME rxChannel the UDP reader feeds —
// so handleInboundPacket processes TCP and UDP responses identically. Broken
// connections are re-dialed lazily on the next send.
@@ -44,7 +44,7 @@ const (
type streamDataTransport interface {
Start(ctx context.Context)
Stop()
- Send(frame encodedOutboundDatagram, now time.Time) bool
+ Send(serverKey string, addr *net.UDPAddr, packet []byte, priority int, now time.Time)
}
type tcpDataManager struct {
@@ -55,7 +55,6 @@ type tcpDataManager struct {
// between plain TCP/53 and DoT, so both share every other line in this file.
dial func(addr *net.UDPAddr) (net.Conn, error)
transport string // for logs: "TCP/53" or "DoT"
- kind resolverTransport
mu sync.Mutex
conns map[string]*tcpDataConn // keyed by resolver address string
@@ -67,8 +66,10 @@ type tcpDataManager struct {
}
type tcpDataJob struct {
- frame encodedOutboundDatagram
- now time.Time
+ serverKey string
+ addr *net.UDPAddr
+ packet []byte
+ now time.Time
}
type tcpDataConn struct {
@@ -88,7 +89,6 @@ func newTCPDataManager(c *Client) *tcpDataManager {
controlQ: make(chan tcpDataJob, tcpControlQueueCap),
dataQ: make(chan tcpDataJob, tcpDataQueueCap),
transport: "TCP/53",
- kind: transportTCP,
dial: func(addr *net.UDPAddr) (net.Conn, error) {
d := net.Dialer{Timeout: tcpDataDialTimeout}
return d.Dial("tcp", net.JoinHostPort(addr.IP.String(), itoaInt(addr.Port)))
@@ -106,7 +106,6 @@ func newDoTDataManager(c *Client) *tcpDataManager {
controlQ: make(chan tcpDataJob, tcpControlQueueCap),
dataQ: make(chan tcpDataJob, tcpDataQueueCap),
transport: "DoT",
- kind: transportDoT,
dial: func(addr *net.UDPAddr) (net.Conn, error) {
return c.dialDoTResolver(addr.String(), tcpDataDialTimeout)
},
@@ -149,29 +148,19 @@ func (m *tcpDataManager) Stop() {
// Send transmits one already-built DNS query to the resolver over its persistent
// TCP connection, dialing lazily and re-dialing on failure. On success it mirrors
// the UDP writer's bookkeeping (resolver send tracking + tx byte counter).
-func (m *tcpDataManager) Send(frame encodedOutboundDatagram, now time.Time) bool {
- if m == nil || frame.addr == nil || len(frame.packet) == 0 {
- return false
- }
- m.mu.Lock()
- dead := m.dead
- m.mu.Unlock()
- if dead {
- return false
+func (m *tcpDataManager) Send(serverKey string, addr *net.UDPAddr, packet []byte, priority int, now time.Time) {
+ if m == nil || addr == nil || len(packet) == 0 {
+ return
}
- // Encoded runtime packets are immutable; the queued frame retains ownership
- // of the backing array, avoiding a per-query copy on stream transports.
- job := tcpDataJob{frame: frame, now: now}
+ job := tcpDataJob{serverKey: serverKey, addr: addr, packet: append([]byte(nil), packet...), now: now}
queue := m.dataQ
- if frame.priority <= Enums.PacketPriorityHigh {
+ if priority <= Enums.PacketPriorityHigh {
queue = m.controlQ
}
select {
case queue <- job:
- return true
default:
m.client.txAdmissionDrops.Add(1)
- return false
}
}
@@ -198,36 +187,27 @@ func (m *tcpDataManager) worker(ctx context.Context) {
}
func (m *tcpDataManager) sendJob(job tcpDataJob) {
- if m.client.resolverReplayCompleted(job.frame, time.Now()) {
- return
- }
slot := int(m.next.Add(1)-1) % tcpDataStripes
- dc, err := m.connFor(job.frame.addr, slot)
+ dc, err := m.connFor(job.addr, slot)
if err != nil || dc == nil {
m.client.streamDialFailures.Add(1)
- m.client.recordResolverHealthEvent(job.frame.serverKey, false, job.now)
- m.client.noteResolverTransportFailure(job.frame.serverKey, m.kind, job.now)
- m.client.replayRuntimeFrame(job.frame, m.kind, nil, "", failureReplayMaxDepth)
return
}
dc.writeMu.Lock()
_ = dc.conn.SetWriteDeadline(time.Now().Add(tcpDataWriteTimeout))
- werr := writeTCPDNSFramed(dc.conn, job.frame.packet)
+ werr := writeTCPDNSFramed(dc.conn, job.packet)
dc.writeMu.Unlock()
if werr != nil {
m.client.streamWriteFailures.Add(1)
- m.client.recordResolverHealthEvent(job.frame.serverKey, false, job.now)
- m.client.noteResolverTransportFailure(job.frame.serverKey, m.kind, job.now)
dc.close()
m.remove(dc)
- m.client.replayRuntimeFrame(job.frame, m.kind, nil, "", failureReplayMaxDepth)
return
}
- m.client.trackResolverFrameOver(job.frame, dc.localAddr, m.kind, job.now)
- m.client.txTotalBytes.Add(uint64(len(job.frame.packet)))
+ m.client.trackResolverSend(job.packet, job.addr.String(), dc.localAddr, job.serverKey, job.now)
+ m.client.txTotalBytes.Add(uint64(len(job.packet)))
}
// connFor returns the existing connection for a resolver or dials a new one and
@@ -337,7 +317,7 @@ func (dc *tcpDataConn) readLoop(ctx context.Context) {
c := dc.manager.client
c.rxTotalBytes.Add(uint64(n))
select {
- case c.rxChannel <- asyncReadPacket{data: buf[:n], addr: dc.resolverAddr, localAddr: dc.localAddr, transport: dc.manager.kind}:
+ case c.rxChannel <- asyncReadPacket{data: buf[:n], addr: dc.resolverAddr, localAddr: dc.localAddr}:
default:
c.putRuntimeUDPBuffer(buf)
c.onRXDrop(dc.resolverAddr)
diff --git a/internal/client/traffic_stats.go b/internal/client/traffic_stats.go
index 058092e..eefadb3 100644
--- a/internal/client/traffic_stats.go
+++ b/internal/client/traffic_stats.go
@@ -90,7 +90,7 @@ func (c *Client) runTrafficStatsReporter(ctx context.Context) {
formatBytes(currentRX),
float64(lossPM)/10.0,
activeResolvers,
- c.resolverTransportSummary(),
+ c.activeTransport(),
len(c.txChannel), len(c.encodedTXChannel), len(c.rxChannel),
c.rxDroppedPackets.Load(), c.txAdmissionDrops.Load(),
c.transportRecoveryCount.Load(),
diff --git a/internal/client/transport.go b/internal/client/transport.go
index 723522e..38dd11e 100644
--- a/internal/client/transport.go
+++ b/internal/client/transport.go
@@ -7,10 +7,10 @@
// transport.go — resolver query transport abstraction. The synchronous query
// paths (MTU probing, session init, health rechecks) talk to a resolver through
// a queryExchanger so they work identically over UDP or DNS-over-TCP/53. The
-// RESOLVER_TRANSPORT supplies the default policy, while optional per-resolver
-// overrides and runtime measurements can select a different path for each
-// resolver. The high-throughput data plane keeps the required stream transports
-// warm so a path change does not restart the tunnel.
+// active transport is chosen client-wide: RESOLVER_TRANSPORT = udp | tcp | auto,
+// where "auto" tries UDP first and falls back to TCP when a full UDP MTU scan
+// finds zero usable resolvers (see RunInitialMTUTests). The high-throughput data
+// plane has its own persistent TCP path (tcp_data.go).
// ==============================================================================
package client
@@ -24,8 +24,6 @@ import (
"strconv"
"strings"
"time"
-
- "cottendns-go/internal/dnsparser"
)
const tcpQueryDialTimeout = 4 * time.Second
@@ -36,9 +34,9 @@ type queryExchanger interface {
Close() error
}
-// resolverTransport identifies one client-to-resolver DNS transport. The
-// Client's atomic value remains the configured/default path for compatibility;
-// adaptive paths are stored independently in resolverTransportState.
+// resolverTransport is the client-wide active transport for the resolver hop.
+// It is stored as an atomic int32 on the Client so every path (MTU probe,
+// session init, health recheck, data plane) dispatches on one value.
type resolverTransport int32
const (
@@ -92,14 +90,10 @@ func (c *Client) usesStreamTransport() bool {
return c.activeTransport() != transportUDP
}
-// newQueryTransport opens a synchronous query transport using the compatibility
-// default. New resolver-aware call sites should use newQueryTransportOver.
+// newQueryTransport opens a synchronous query transport to resolverLabel using
+// the client's active transport.
func (c *Client) newQueryTransport(resolverLabel string) (queryExchanger, error) {
- return c.newQueryTransportOver(resolverLabel, c.activeTransport())
-}
-
-func (c *Client) newQueryTransportOver(resolverLabel string, transport resolverTransport) (queryExchanger, error) {
- switch transport {
+ switch c.activeTransport() {
case transportDoH:
return c.newDoHQueryTransport(resolverLabel)
case transportDoT:
@@ -109,13 +103,9 @@ func (c *Client) newQueryTransportOver(resolverLabel string, transport resolverT
}
// DoT is the TCP/53 wire format inside TLS, so the framing exchanger is
// reused verbatim — only the dial differs.
- return &tcpQueryTransport{client: c, conn: conn}, nil
+ return &tcpQueryTransport{conn: conn}, nil
case transportTCP:
- transport, err := newTCPQueryTransport(resolverLabel, tcpQueryDialTimeout)
- if transport != nil {
- transport.client = c
- }
- return transport, err
+ return newTCPQueryTransport(resolverLabel, tcpQueryDialTimeout)
default:
conn, err := dialUDPResolver(resolverLabel)
if err != nil {
@@ -161,8 +151,7 @@ func resolverHostWithPort(resolverLabel string, port int) string {
// reused across the many queries a probe sends, so there is no per-query
// handshake cost.
type tcpQueryTransport struct {
- client *Client
- conn net.Conn
+ conn net.Conn
}
func newTCPQueryTransport(resolverLabel string, dialTimeout time.Duration) (*tcpQueryTransport, error) {
@@ -182,7 +171,6 @@ func (t *tcpQueryTransport) exchange(packet []byte, timeout time.Duration) ([]by
return nil, errors.New("malformed dns query")
}
expectedID := binary.BigEndian.Uint16(packet[:2])
- expectedQuestion := dnsQuestionFingerprint(packet)
deadline := time.Now().Add(timeout)
_ = t.conn.SetDeadline(deadline)
@@ -198,15 +186,6 @@ func (t *tcpQueryTransport) exchange(packet []byte, timeout time.Duration) ([]by
return nil, err
}
if len(resp) >= 2 && binary.BigEndian.Uint16(resp[:2]) == expectedID {
- if expectedQuestion != 0 && dnsQuestionFingerprint(resp) != expectedQuestion {
- continue
- }
- if t.client != nil {
- if parsed, parseErr := dnsparser.ParsePacketLite(resp); parseErr == nil &&
- t.client.rcodeIsInjectedNoise(parsed.Header.RCode) {
- continue
- }
- }
return resp, nil
}
}
diff --git a/internal/client/tunnel_runtime.go b/internal/client/tunnel_runtime.go
index 440e9bd..df3f277 100644
--- a/internal/client/tunnel_runtime.go
+++ b/internal/client/tunnel_runtime.go
@@ -88,7 +88,6 @@ func (c *Client) exchangeUDPQueryWithConn(conn *net.UDPConn, packet []byte, time
return nil, errors.New("malformed dns query")
}
expectedID := binary.BigEndian.Uint16(packet[:2])
- expectedQuestion := dnsQuestionFingerprint(packet)
buffer := c.getRuntimeUDPBuffer()
defer c.putRuntimeUDPBuffer(buffer)
@@ -122,24 +121,9 @@ func (c *Client) exchangeUDPQueryWithConn(conn *net.UDPConn, packet []byte, time
}
if n >= 2 && binary.BigEndian.Uint16(buffer[:2]) == expectedID {
- response := buffer[:n]
- if expectedQuestion != 0 && dnsQuestionFingerprint(response) != expectedQuestion {
- mismatchedResponses++
- if mismatchedResponses >= runtimeUDPMaxMismatchedResponses {
- return nil, errors.New("too many mismatched dns responses on shared udp socket")
- }
- continue
- }
- if parsed, parseErr := dnsparser.ParsePacketLite(response); parseErr == nil &&
- c.rcodeIsInjectedNoise(parsed.Header.RCode) {
- // A forged NXDOMAIN may race the genuine authoritative answer.
- // Keep reading on the same query deadline instead of letting the
- // injected packet fail MTU/session/background probes.
- continue
- }
// Copy matched response out so the pooled buffer can be recycled.
result := make([]byte, n)
- copy(result, response)
+ copy(result, buffer[:n])
return result, nil
}
@@ -151,13 +135,12 @@ func (c *Client) exchangeUDPQueryWithConn(conn *net.UDPConn, packet []byte, time
}
func (c *Client) sendOneWayDNSQuery(resolver Connection, packet []byte, deadline time.Time) error {
- transportKind := c.preferredResolverTransport(resolver.Key)
- if transportKind != transportUDP {
- // Best-effort one-shot over the resolver's selected stream transport
- // (e.g. the session-close burst). DoH has no one-way form, so it reuses
- // the request/response exchanger and discards the answer.
- if transportKind == transportDoH {
- transport, err := c.newQueryTransportOver(resolver.ResolverLabel, transportDoH)
+ if c.usesStreamTransport() {
+ // Best-effort one-shot over the active stream transport (e.g. the
+ // session-close burst). DoH has no one-way form, so it reuses the normal
+ // request/response exchanger and discards the answer.
+ if c.activeTransport() == transportDoH {
+ transport, err := c.newDoHQueryTransport(resolver.ResolverLabel)
if err != nil {
return err
}
@@ -170,7 +153,7 @@ func (c *Client) sendOneWayDNSQuery(resolver Connection, packet []byte, deadline
conn net.Conn
err error
)
- if transportKind == transportDoT {
+ if c.activeTransport() == transportDoT {
conn, err = c.dialDoTResolver(resolver.ResolverLabel, time.Until(deadline))
} else {
d := net.Dialer{Timeout: time.Until(deadline)}
@@ -345,29 +328,38 @@ func (t *udpQueryTransport) Close() error {
return t.conn.Close()
}
-// exchangeDNSOverConnection sends a synchronous DNS query over this resolver's
-// selected path and returns the authenticated tunnel packet.
+// exchangeDNSOverConnection sends a DNS query and returns the extracted VPN
+// packet, over the client's active transport (UDP, or DNS-over-TCP in TCP mode).
func (c *Client) exchangeDNSOverConnection(conn Connection, query []byte, timeout time.Duration) (VpnProto.Packet, error) {
- transportKind := c.preferredResolverTransport(conn.Key)
- transport, err := c.newQueryTransportOver(conn.ResolverLabel, transportKind)
- if err != nil {
- return VpnProto.Packet{}, err
- }
- startedAt := c.now()
- response, err := transport.exchange(query, timeout)
- _ = transport.Close()
- if err != nil {
- c.noteResolverTransportFailure(conn.Key, transportKind, c.now())
- return VpnProto.Packet{}, err
+ var response []byte
+
+ if c.usesStreamTransport() {
+ transport, err := c.newQueryTransport(conn.ResolverLabel)
+ if err != nil {
+ return VpnProto.Packet{}, err
+ }
+ response, err = transport.exchange(query, timeout)
+ _ = transport.Close()
+ if err != nil {
+ return VpnProto.Packet{}, err
+ }
+ } else {
+ udpConn, err := c.getUDPConn(conn.ResolverLabel)
+ if err != nil {
+ return VpnProto.Packet{}, err
+ }
+ response, err = c.exchangeUDPQueryWithConn(udpConn, query, timeout)
+ if err != nil {
+ _ = udpConn.Close()
+ return VpnProto.Packet{}, err
+ }
+ c.putUDPConn(conn.ResolverLabel, udpConn)
}
packet, err := dnsparser.ExtractVPNResponseMatching(response, c.responseMode == mtuProbeBase64Reply, c.cfg.Domains)
if err != nil {
- c.noteResolverTransportFailure(conn.Key, transportKind, c.now())
return VpnProto.Packet{}, err
}
- completedAt := c.now()
- c.noteResolverTransportSuccess(conn.Key, transportKind, completedAt.Sub(startedAt), completedAt)
return packet, nil
}
@@ -377,13 +369,11 @@ func (c *Client) exchangeDNSOverConnection(conn Connection, query []byte, timeou
// exchanges immediately instead of keeping sockets and HTTP streams alive until
// their full timeout.
func (c *Client) exchangeDNSOverConnectionContext(ctx context.Context, conn Connection, query []byte, timeout time.Duration) (VpnProto.Packet, error) {
- transportKind := c.preferredResolverTransport(conn.Key)
- transport, err := c.newQueryTransportOver(conn.ResolverLabel, transportKind)
+ transport, err := c.newQueryTransport(conn.ResolverLabel)
if err != nil {
return VpnProto.Packet{}, err
}
defer transport.Close()
- startedAt := c.now()
type result struct {
response []byte
@@ -401,16 +391,8 @@ func (c *Client) exchangeDNSOverConnectionContext(ctx context.Context, conn Conn
return VpnProto.Packet{}, ctx.Err()
case res := <-done:
if res.err != nil {
- c.noteResolverTransportFailure(conn.Key, transportKind, c.now())
return VpnProto.Packet{}, res.err
}
- packet, extractErr := dnsparser.ExtractVPNResponseMatching(res.response, c.responseMode == mtuProbeBase64Reply, c.cfg.Domains)
- if extractErr != nil {
- c.noteResolverTransportFailure(conn.Key, transportKind, c.now())
- return VpnProto.Packet{}, extractErr
- }
- completedAt := c.now()
- c.noteResolverTransportSuccess(conn.Key, transportKind, completedAt.Sub(startedAt), completedAt)
- return packet, nil
+ return dnsparser.ExtractVPNResponseMatching(res.response, c.responseMode == mtuProbeBase64Reply, c.cfg.Domains)
}
}
diff --git a/internal/client/warm_path_budget_test.go b/internal/client/warm_path_budget_test.go
deleted file mode 100644
index 08bdb32..0000000
--- a/internal/client/warm_path_budget_test.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package client
-
-import (
- "testing"
- "time"
-)
-
-func newWarmPathBudgetClient(now time.Time) *Client {
- c := &Client{
- txChannel: make(chan rawOutboundTask, 100),
- encodedTXChannel: make(chan encodedOutboundTask, 100),
- rxChannel: make(chan asyncReadPacket, 100),
- resolverPending: make(map[resolverSampleKey]resolverSample),
- }
- c.warmPathLastScanUnix.Store(now.UnixNano())
- return c
-}
-
-func TestWarmPathExplorationUsesBoundedForegroundBudget(t *testing.T) {
- now := time.Now()
- c := newWarmPathBudgetClient(now)
- c.runtimeOriginalSends.Store(warmPathForegroundFramesPerScan - 1)
- if c.allowWarmPathExploration(now.Add(time.Minute), 30*time.Second) {
- t.Fatal("warm-path scan ran before its foreground capacity budget accrued")
- }
- c.runtimeOriginalSends.Add(1)
- if !c.allowWarmPathExploration(now.Add(time.Minute), 30*time.Second) {
- t.Fatal("warm-path scan did not run after its bounded budget accrued")
- }
- if c.allowWarmPathExploration(now.Add(time.Minute), 30*time.Second) {
- t.Fatal("warm-path budget was charged more than once")
- }
-}
-
-func TestWarmPathExplorationStopsDuringCongestion(t *testing.T) {
- now := time.Now()
- c := newWarmPathBudgetClient(now)
- c.runtimeOriginalSends.Store(warmPathForegroundFramesPerScan)
- for i := 0; i < cap(c.txChannel)/4; i++ {
- c.txChannel <- rawOutboundTask{}
- }
- if c.allowWarmPathExploration(now.Add(time.Minute), 30*time.Second) {
- t.Fatal("warm-path scan competed with a congested foreground queue")
- }
-}
-
-func TestWarmPathExplorationRefreshesStaleIdlePaths(t *testing.T) {
- now := time.Now()
- c := newWarmPathBudgetClient(now)
- if !c.allowWarmPathExploration(now.Add(2*time.Minute), 30*time.Second) {
- t.Fatal("completely idle alternate paths were allowed to become stale")
- }
- c.encodedTXChannel <- encodedOutboundTask{}
- c.warmPathLastScanUnix.Store(now.UnixNano())
- if c.allowWarmPathExploration(now.Add(2*time.Minute), 30*time.Second) {
- t.Fatal("stale-path exception displaced queued user traffic")
- }
-}
diff --git a/internal/config/client.go b/internal/config/client.go
index 7a28dad..fed5c37 100644
--- a/internal/config/client.go
+++ b/internal/config/client.go
@@ -73,17 +73,6 @@ type ClientConfig struct {
// fatal — the client falls back to UDP and then TCP/53 on its own, so a
// blocked TLS port degrades to the survival path instead of no tunnel.
ResolverTransport string `toml:"RESOLVER_TRANSPORT"`
- // ResolverTransportPaths optionally pins individual resolvers to a transport
- // policy. Keys may be a resolver IP, resolver label (IP:port), or connection
- // key; values are auto|udp|tcp|dot|doh. "auto" compares UDP and TCP for that
- // resolver. Explicit udp/tcp stay fixed; dot/doh keep their plain survival
- // fallbacks. Unlisted resolvers inherit ResolverTransport.
- ResolverTransportPaths map[string]string `toml:"RESOLVER_TRANSPORT_PATHS"`
- // ResolverTransportBackgroundScanIntervalSec controls the low-rate active
- // path check. One resolver is checked at a time at the current session MTU;
- // zero is finalized to 30 seconds. This keeps alternate path RTT/loss fresh
- // without competing with user traffic.
- ResolverTransportBackgroundScanIntervalSec float64 `toml:"RESOLVER_TRANSPORT_BACKGROUND_SCAN_INTERVAL_SECONDS"`
// Encrypted-resolver settings, used only by the dot/doh transports.
// ResolverTLSServerName is the SNI + certificate name presented to the
// resolver (leave empty to use the resolver IP itself). ResolverTLSPin is an
@@ -313,28 +302,26 @@ type ClientConfigFlagBinder struct {
func defaultClientConfig() ClientConfig {
return ClientConfig{
- ConfigPreset: "default",
- ProtocolType: "SOCKS5",
- Domains: nil,
- ListenIP: "127.0.0.1",
- ListenPort: 18000,
- SOCKS5Auth: false,
- SOCKS5User: "master_dns_vpn",
- SOCKS5Pass: "master_dns_vpn",
- LocalDNSEnabled: false,
- LocalDNSIP: "127.0.0.1",
- LocalDNSPort: 53,
- LocalDNSCacheMaxRecords: 10000,
- LocalDNSCacheTTLSeconds: 14400.0,
- LocalDNSPendingTimeoutSec: 300.0,
- LocalDNSCachePersist: true,
- LocalDNSCacheFlushSec: 60.0,
- ResolverBalancingStrategy: 3,
- QNameLabelLength: 63,
- ResolverRateLimitEnabled: true,
- ResolverTransport: "auto",
- ResolverTransportPaths: map[string]string{},
- ResolverTransportBackgroundScanIntervalSec: 30.0,
+ ConfigPreset: "default",
+ ProtocolType: "SOCKS5",
+ Domains: nil,
+ ListenIP: "127.0.0.1",
+ ListenPort: 18000,
+ SOCKS5Auth: false,
+ SOCKS5User: "master_dns_vpn",
+ SOCKS5Pass: "master_dns_vpn",
+ LocalDNSEnabled: false,
+ LocalDNSIP: "127.0.0.1",
+ LocalDNSPort: 53,
+ LocalDNSCacheMaxRecords: 10000,
+ LocalDNSCacheTTLSeconds: 14400.0,
+ LocalDNSPendingTimeoutSec: 300.0,
+ LocalDNSCachePersist: true,
+ LocalDNSCacheFlushSec: 60.0,
+ ResolverBalancingStrategy: 3,
+ QNameLabelLength: 63,
+ ResolverRateLimitEnabled: true,
+ ResolverTransport: "auto",
ResolverDoTPort: 853,
ResolverDoHPort: 443,
ResolverDoHPath: "/dns-query",
@@ -672,32 +659,6 @@ func finalizeClientConfig(cfg ClientConfig) (ClientConfig, error) {
default:
return cfg, fmt.Errorf("invalid RESOLVER_TRANSPORT: %q (want auto|udp|tcp|dot|doh)", cfg.ResolverTransport)
}
- if cfg.ResolverTransportPaths == nil {
- cfg.ResolverTransportPaths = map[string]string{}
- }
- normalizedTransportPaths := make(map[string]string, len(cfg.ResolverTransportPaths))
- for rawResolver, rawTransport := range cfg.ResolverTransportPaths {
- resolver := strings.TrimSpace(rawResolver)
- if resolver == "" {
- return cfg, fmt.Errorf("RESOLVER_TRANSPORT_PATHS contains an empty resolver key")
- }
- transport := strings.ToLower(strings.TrimSpace(rawTransport))
- switch transport {
- case "auto", "udp", "tcp", "dot", "doh":
- default:
- return cfg, fmt.Errorf(
- "invalid RESOLVER_TRANSPORT_PATHS value for %q: %q (want auto|udp|tcp|dot|doh)",
- resolver, rawTransport,
- )
- }
- normalizedTransportPaths[resolver] = transport
- }
- cfg.ResolverTransportPaths = normalizedTransportPaths
- cfg.ResolverTransportBackgroundScanIntervalSec = clampFloat(
- defaultFloatAtMostZero(cfg.ResolverTransportBackgroundScanIntervalSec, 30.0),
- 5.0,
- 3600.0,
- )
cfg.ResolverDoTPort = clampInt(defaultIntBelow(cfg.ResolverDoTPort, 1, 853), 1, 65535)
cfg.ResolverDoHPort = clampInt(defaultIntBelow(cfg.ResolverDoHPort, 1, 443), 1, 65535)
if cfg.ResolverDoHPath == "" || cfg.ResolverDoHPath[0] != '/' {
diff --git a/internal/config/client_test.go b/internal/config/client_test.go
index a003754..a15af07 100644
--- a/internal/config/client_test.go
+++ b/internal/config/client_test.go
@@ -91,36 +91,6 @@ func TestAndroidEmbeddingDefaultsAndLimits(t *testing.T) {
}
}
-func TestLoadClientConfigPerResolverTransportPaths(t *testing.T) {
- dir := t.TempDir()
- configPath := filepath.Join(dir, "client_config.toml")
- if err := os.WriteFile(configPath, []byte(`
-PROTOCOL_TYPE = "socks5"
-DOMAINS = ["v.domain.com"]
-ENCRYPTION_KEY = "secret"
-RESOLVER_TRANSPORT = "auto"
-RESOLVER_TRANSPORT_PATHS = { "1.1.1.1" = "TCP", "8.8.8.8:53" = "doh" }
-RESOLVER_TRANSPORT_BACKGROUND_SCAN_INTERVAL_SECONDS = 2
-`), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(dir, "client_resolvers.txt"), []byte("1.1.1.1\n8.8.8.8\n"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- cfg, err := LoadClientConfig(configPath)
- if err != nil {
- t.Fatal(err)
- }
- if cfg.ResolverTransportPaths["1.1.1.1"] != "tcp" ||
- cfg.ResolverTransportPaths["8.8.8.8:53"] != "doh" {
- t.Fatalf("unexpected normalized transport paths: %#v", cfg.ResolverTransportPaths)
- }
- if cfg.ResolverTransportBackgroundScanIntervalSec != 5 {
- t.Fatalf("background interval=%v, want clamped 5", cfg.ResolverTransportBackgroundScanIntervalSec)
- }
-}
-
func TestLoadClientConfigRejectsInvalidProtocol(t *testing.T) {
dir := t.TempDir()
diff --git a/internal/fec/fec.go b/internal/fec/fec.go
index 4b22c9c..9208fa4 100644
--- a/internal/fec/fec.go
+++ b/internal/fec/fec.go
@@ -20,7 +20,6 @@ package fec
import (
"encoding/binary"
"errors"
- "math"
"github.com/klauspost/reedsolomon"
)
@@ -179,61 +178,3 @@ func ParityForLoss(dataShards int, lossFrac float64) int {
}
return parity
}
-
-// ParityForLossTarget returns enough parity for a requested block-recovery
-// probability under independent random loss. ParityForLoss guarantees only the
-// expected survivor count plus one shard; at extreme loss that succeeds in
-// roughly half of random blocks. Super-FEC uses this stronger calculation so an
-// 84% link has a useful recovery probability instead of a merely possible one.
-func ParityForLossTarget(dataShards int, lossFrac, recoveryTarget float64) int {
- if dataShards < 1 {
- return 0
- }
- if lossFrac < 0 {
- lossFrac = 0
- }
- if lossFrac > 0.95 {
- lossFrac = 0.95
- }
- if recoveryTarget <= 0 || recoveryTarget >= 1 {
- recoveryTarget = 0.90
- }
- minParity := ParityForLoss(dataShards, lossFrac)
- for total := dataShards + minParity; total <= maxShards; total++ {
- if shardRecoveryProbability(total, dataShards, 1-lossFrac) >= recoveryTarget {
- return total - dataShards
- }
- }
- return MaxParity(dataShards)
-}
-
-// shardRecoveryProbability is P(X >= required) for X surviving shards out of
-// total under independent survival probability p.
-func shardRecoveryProbability(total, required int, p float64) float64 {
- if required <= 0 {
- return 1
- }
- if total < required || p <= 0 {
- return 0
- }
- if p >= 1 {
- return 1
- }
- // Sum the failure tail P(X < required) using the binomial recurrence. With
- // at most 256 shards this is stable and far cheaper than an encode.
- q := 1 - p
- term := math.Pow(q, float64(total)) // P(X=0)
- failure := term
- for k := 0; k < required-1; k++ {
- term *= float64(total-k) / float64(k+1) * p / q
- failure += term
- }
- recovery := 1 - failure
- if recovery < 0 {
- return 0
- }
- if recovery > 1 {
- return 1
- }
- return recovery
-}
diff --git a/internal/fec/fec_test.go b/internal/fec/fec_test.go
index e0d542f..be60ed4 100644
--- a/internal/fec/fec_test.go
+++ b/internal/fec/fec_test.go
@@ -10,7 +10,6 @@ package fec
import (
"bytes"
"fmt"
- "math/rand"
"testing"
)
@@ -158,90 +157,3 @@ func TestParityForLossMonotonic(t *testing.T) {
prev = p
}
}
-
-func TestLossyNetworkRecoveryEffectiveness(t *testing.T) {
- tests := []struct {
- name string
- loss float64
- parity int
- minRecovery float64
- }{
- {
- name: "auto-fec-40-percent",
- loss: 0.40,
- parity: ParityForLoss(4, 0.40),
- minRecovery: 0.75,
- },
- {
- name: "super-fec-84-percent",
- loss: 0.84,
- parity: ParityForLossTarget(4, 0.84, 0.90),
- minRecovery: 0.85,
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- source := samplePackets(4)
- encoded, err := EncodePackets(source, tc.parity)
- if err != nil {
- t.Fatal(err)
- }
- rng := rand.New(rand.NewSource(0xC077E))
- const trials = 1000
- recovered := 0
- rawSurvived := 0
- for trial := 0; trial < trials; trial++ {
- block := &Block{
- DataShards: encoded.DataShards,
- ParityShards: encoded.ParityShards,
- ShardSize: encoded.ShardSize,
- Shards: make([][]byte, len(encoded.Shards)),
- }
- rawOK := true
- for i, shard := range encoded.Shards {
- if rng.Float64() < tc.loss {
- if i < encoded.DataShards {
- rawOK = false
- }
- continue
- }
- block.Shards[i] = append([]byte(nil), shard...)
- }
- if rawOK {
- rawSurvived++
- }
- got, decodeErr := DecodePackets(block)
- if decodeErr != nil {
- continue
- }
- ok := len(got) == len(source)
- for i := range source {
- ok = ok && bytes.Equal(got[i], source[i])
- }
- if ok {
- recovered++
- }
- }
- recoveryRate := float64(recovered) / trials
- rawRate := float64(rawSurvived) / trials
- if recoveryRate < tc.minRecovery {
- t.Fatalf("recovery %.1f%% below %.1f%% target (loss=%.0f%% parity=%d)",
- recoveryRate*100, tc.minRecovery*100, tc.loss*100, tc.parity)
- }
- if recoveryRate < rawRate+0.50 {
- t.Fatalf("FEC improvement too small: recovery=%.1f%% raw=%.1f%%", recoveryRate*100, rawRate*100)
- }
- })
- }
-}
-
-func TestSuperFECParityMeetsRecoveryTarget(t *testing.T) {
- for _, loss := range []float64{0.75, 0.80, 0.84} {
- parity := ParityForLossTarget(4, loss, 0.90)
- probability := shardRecoveryProbability(4+parity, 4, 1-loss)
- if probability < 0.90 {
- t.Fatalf("loss=%.0f%% parity=%d recovery=%.3f, want >= 0.90", loss*100, parity, probability)
- }
- }
-}
diff --git a/internal/udpserver/stream_server.go b/internal/udpserver/stream_server.go
index 8844fc5..628cdc7 100644
--- a/internal/udpserver/stream_server.go
+++ b/internal/udpserver/stream_server.go
@@ -610,10 +610,7 @@ func (s *Stream_server) maybeAdjustAutoFEC() {
// code rate tracks how bad the link actually is, lifted above the normal
// auto ceiling up to the super cap (0 = Reed-Solomon hard limit). This is
// loss-aware, not a flat slam: 76% loss buys less parity than 84%.
- // At extreme loss, sizing parity only to the expected survivor count
- // makes reconstruction a coin flip. Super-FEC targets 90% random-loss
- // block recovery (subject to the configured/hard parity cap).
- parity := fec.ParityForLossTarget(s.fecAutoBlock, loss, 0.90)
+ parity := fec.ParityForLoss(s.fecAutoBlock, loss)
superCap := s.fecSuperMaxParity
hardMax := fec.MaxParity(s.fecAutoBlock)
if superCap <= 0 || superCap > hardMax {
diff --git a/internal/udpserver/transport_matrix_test.go b/internal/udpserver/transport_matrix_test.go
index 9c077b9..de79a1d 100644
--- a/internal/udpserver/transport_matrix_test.go
+++ b/internal/udpserver/transport_matrix_test.go
@@ -73,14 +73,7 @@ func newDynamicTransportTestServer(t *testing.T) (*Server, []byte) {
return s, query
}
-func TestDynamicNativeQueryAcrossAllTransports(t *testing.T) {
- t.Run("UDP", func(t *testing.T) {
- s, query := newDynamicTransportTestServer(t)
- if response := s.safeHandlePacket(query); len(response) == 0 {
- t.Fatal("UDP transport-agnostic handler returned no response")
- }
- })
-
+func TestDynamicNativeQueryAcrossStreamTransports(t *testing.T) {
t.Run("TCP", func(t *testing.T) {
s, query := newDynamicTransportTestServer(t)
client, server := net.Pipe()
diff --git a/scripts/test-hostile-network.ps1 b/scripts/test-hostile-network.ps1
deleted file mode 100644
index 67dd0a2..0000000
--- a/scripts/test-hostile-network.ps1
+++ /dev/null
@@ -1,52 +0,0 @@
-[CmdletBinding()]
-param(
- [ValidateRange(1, 100)]
- [int]$Count = 1,
-
- [switch]$FullRace
-)
-
-$ErrorActionPreference = "Stop"
-$env:CGO_ENABLED = "1"
-
-function Invoke-GoTest {
- param([string[]]$TestArguments)
-
- & go test @TestArguments
- if ($LASTEXITCODE -ne 0) {
- throw "go test failed with exit code $LASTEXITCODE"
- }
-}
-
-$goCompiler = (& go env CC).Trim()
-if (-not $goCompiler) {
- throw "Go has no C compiler configured. Install GCC or Clang and set go env CC."
-}
-
-Write-Host "CGO compiler: $goCompiler"
-Write-Host "Hostile-network repetitions: $Count"
-
-Invoke-GoTest @(
- "-race", "-v", "./internal/client",
- "-run", "Test(Synchronous|Outstanding|QuestionFingerprint|UDPTruncation|PerResolver|InitialAndBackground|BackgroundDiscovery|AutoTransport|PoisonPlus|PoisonSignal|OriginalWinner|ExpiredPoison|ExpiredPath|ResolverPathTimeout|ExplicitTransport|HedgedResponse|JointPathSelection|WarmPath)",
- "-count=$Count"
-)
-
-Invoke-GoTest @(
- "-race", "-v", "./internal/udpserver",
- "-run", "TestDynamicNativeQueryAcrossAllTransports",
- "-count=$Count"
-)
-
-Invoke-GoTest @(
- "-race", "-v", "./internal/fec",
- "-run", "Test(Survives75PercentLoss|Survives84PercentLoss|LossyNetworkRecoveryEffectiveness|SuperFECParityMeetsRecoveryTarget)",
- "-count=$Count"
-)
-
-if ($FullRace) {
- Write-Host "Running the complete repository race suite..."
- Invoke-GoTest @("-race", "./...", "-count=1")
-}
-
-Write-Host "Hostile-network test environment passed."