encrypted-dns
SkillDev toolsEncrypted DNS protocols, resolver health, bootstrap, SOCKS transport, and tamper diagnostics.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the encrypted-dns skill
What this skill tells your AI
The instructions your AI receives, as published by po4yka/ripdpi in .agents/skills/encrypted-dns/SKILL.md and read by ahel’s review.
Supported protocols
| Protocol | RFC/Spec | Transport | Default port | TLS required |
|---|---|---|---|---|
| DoH | RFC 8484 | HTTPS POST with application/dns-message | 443 | Yes (HTTPS) |
| DoT | RFC 7858 | TCP + TLS, length-prefixed DNS wire format | 853 | Yes |
| DNSCrypt | dnscrypt.info spec | TCP, XSalsa20Poly1305 (ChaChaBox) | 443 | No (own crypto) |
| DoQ | RFC 9250 | QUIC bidirectional streams, length-prefixed | 853 | Yes (QUIC TLS) |
All protocols use standard DNS wire format (RFC 1035) for the query and response payloads. The protocol layer only differs in how the wire bytes are transported and encrypted.
DoQ does not support SOCKS5 transport because SOCKS5 is TCP-only; the
resolver returns EncryptedDnsError::Request if you try.
Architecture
ResolverPool (pool.rs)
|-- HealthRegistry (health.rs) -- EWMA scoring per endpoint
|-- FallbackCache (LRU, pool.rs) -- cold-start memory
|-- Vec<EncryptedDnsResolver> -- one per configured endpoint
|
EncryptedDnsResolver (resolver.rs)
|-- protocol dispatch: Doh | Dot | DnsCrypt | Doq
|-- ConnectionPool -- single idle DoT/DNSCrypt session
|-- reqwest::Client -- DoH HTTP client (when not using hooks)
|-- quinn::Endpoint -- DoQ QUIC client
|-- DnsCryptCachedCertificate -- cached cert with validity window
|
transport.rs -- shared helpers
|-- normalize_endpoint() -- fills defaults, validates fields
|-- build_doh_client() -- configures reqwest with bootstrap IPs
|-- build_client_config() -- rustls TLS config with webpki roots
|-- build_dns_query() -- hickory-proto Message construction
|-- extract_ip_answers() -- parses A/AAAA from response bytes
|
dnscrypt.rs -- DNSCrypt-specific crypto
hickory_backend.rs -- optional hickory-resolver backend (feature-gated)
Key files
| File | Purpose |
|---|---|
src/lib.rs | Public API re-exports |
src/types.rs | EncryptedDnsProtocol, EncryptedDnsEndpoint, EncryptedDnsTransport, EncryptedDnsError, EncryptedDnsConnectHooks |
src/resolver.rs | EncryptedDnsResolver -- per-endpoint resolver with protocol dispatch |
src/pool.rs | ResolverPool, ResolverPoolBuilder -- multi-endpoint pool with health rotation |
src/health.rs | HealthRegistry, HealthScoreSnapshot -- EWMA health tracking |
src/transport.rs | Shared TLS config, DNS query building, SOCKS5 helpers |
src/dnscrypt.rs | Certificate parsing, encryption/decryption, padding |
src/hickory_backend.rs | Feature-gated hickory-resolver backend for DoH/DoT |
src/tests.rs | Integration tests with local TLS/DNSCrypt servers |
Health scoring
HealthRegistry tracks per-endpoint and per-bootstrap-IP health using
exponentially weighted moving averages (EWMA).
Score model (health.rs:HealthScore):
ewma_success_rate: decays toward 0.5 (neutral prior) with configurable half-life (default 60s viaDEFAULT_HEALTH_HALF_LIFEin pool.rs)ewma_latency_ms: decays toward 200ms initial prior- Composite:
success_rate * 0.7 + (1 - latency/2000) * 0.3
How scores influence pool selection (pool.rs:try_order()):
HealthRegistry::rank_indices()sorts endpoints by composite score (best first)- Cold-start override: if the top-ranked endpoint has zero observations, the
FallbackCache(LRU) promotes the most recently successful endpoint to rank 0 - Round-robin injection: every N-th call, the rotation counter promotes a non-top-2 endpoint to position 1, preventing starvation of lower-ranked endpoints that may have recovered
SNI blocking penalty: record_sni_blocked() records failure with a 4000ms
latency penalty, rapidly deprioritizing endpoints whose TLS handshake is being
RST-injected by middlebox DPI equipment.
Bootstrap IP ranking: rank_bootstrap_ips() uses the same EWMA model on
per-IP health data. Both build_doh_client() and connect_direct_tcp_with()
consult this ranking to try the healthiest bootstrap IP first.
Sharing across pool recreations: ResolverPoolBuilder::health_registry()
accepts a pre-existing registry so that dropping and rebuilding a pool preserves
all accumulated health data.
DNSCrypt specifics
DNSCrypt uses its own crypto layer instead of TLS. The implementation lives
in dnscrypt.rs and the exchange logic in resolver.rs:exchange_dnscrypt().
Certificate lifecycle (fetch_dnscrypt_certificate() / current_dnscrypt_certificate()):
- Query the provider for
2.dnscrypt-cert.<provider_name>TXT records - Parse the 124-byte certificate (
DNSCRYPT_CERT_SIZE):- Bytes 0-3: magic
DNSC(DNSCRYPT_CERT_MAGIC) - Bytes 4-5: es_version (must be 2 = XSalsa20Poly1305)
- Bytes 8-71: Ed25519 signature over the signed portion (bytes 72-123)
- Bytes 72-103: resolver's X25519 public key
- Bytes 104-111: client_magic (8 bytes, prefixed to every query)
- Bytes 116-119: valid_from (Unix timestamp)
- Bytes 120-123: valid_until (Unix timestamp)
- Bytes 0-3: magic
- Verify Ed25519 signature using the provider's public key (
ring::signature) - Cache in
Mutex<Option<DnsCryptCachedCertificate>>, reuse until 60s before expiry
Query encryption (exchange_dnscrypt_with_session()):
- Generate ephemeral X25519 keypair (
crypto_box::SecretKey) - Build
ChaChaBoxfrom resolver public key + client secret - Generate random 12-byte nonce half (
DNSCRYPT_QUERY_NONCE_HALF) - Pad query to 64-byte blocks (
dnscrypt_pad(): append 0x80 then 0x00s) - Encrypt with ChaChaBox using full 24-byte nonce
- Wrap:
client_magic || client_public_key || nonce_half || ciphertext
Response decryption (decrypt_dnscrypt_response()):
- Verify 8-byte response magic (
DNSCRYPT_RESPONSE_MAGIC) - Extract full 24-byte nonce; verify first 12 bytes match query nonce half
- Decrypt with ChaChaBox, then unpad (
dnscrypt_unpad(): find last 0x80)
Bootstrap problem
When system DNS is poisoned, you cannot resolve the hostname of your encrypted
DNS server (e.g., dns.google) via the poisoned system resolver. The solution
is hardcoded bootstrap IPs in EncryptedDnsEndpoint::bootstrap_ips.
How it works:
normalize_endpoint()enforcesMissingBootstrapIpserror forDirecttransport if no bootstrap IPs are provided- For DoH:
build_doh_client()callsreqwest::ClientBuilder::resolve_to_addrs()to pin the hostname to bootstrap IPs, bypassing system DNS entirely - For DoT/DNSCrypt/DoQ:
connect_direct_tcp_with()iterates bootstrap IPs directly, connecting toSocketAddr::new(ip, port)without any DNS lookup - SOCKS5 transport delegates resolution to the proxy (
socks5h://scheme), bypassing the bootstrap problem entirely
EncryptedDnsConnectHooks: allows the Android VPN layer to inject a custom
DirectTcpConnector (for protect-socket bypass) and DirectUdpBinder (for
DoQ). When a TCP connector hook is set, DoH falls back to manual HTTP/1.1
over the hooked TCP stream (exchange_doh_manually()) instead of reqwest.
hickory-resolver backend
The hickory-backend feature flag (Cargo.toml) enables an alternative code
path that routes DoH and DoT through hickory-resolver instead of the manual
reqwest/tokio-rustls implementations.
Fallback conditions (can_use_hickory() in resolver.rs):
- Custom TLS roots are provided -> manual path (hickory uses its own root store)
- Custom TLS verifier is set -> manual path
- DirectTcpConnector hook is set -> manual path (hickory manages its own sockets)
- SOCKS5 transport with DoT -> manual path (hickory does not support SOCKS5)
DNSCrypt always uses the manual path because hickory-resolver has no DNSCrypt support. DoQ is not routed through hickory either.
DNS tamper detection
The diagnostics runner uses encrypted DNS as a ground-truth oracle to detect DNS tampering by the ISP/middlebox.
Detection flow (strategy.rs:detect_strategy_probe_dns_tampering()):
- For each target domain, resolve via system DNS (
resolve_addresses()) - Resolve the same domain via encrypted DNS (
resolve_via_encrypted_dns()) - Compare results:
- System returns NXDOMAIN but encrypted returns IPs ->
DnsTampering - System returns different IPs than encrypted ->
DnsTampering - System resolves suspiciously fast ->
is_dns_injection_suspected()heuristic
- System returns NXDOMAIN but encrypted returns IPs ->
- Results feed into
diagnosis.rswhich emitsdns_tamperingdiagnostic codes
Diagnostics DNS helper (ripdpi-diagnostics-dns/src/dns.rs, consumed through ripdpi-diagnostics-runner):
resolve_via_encrypted_dns()creates a one-shotEncryptedDnsResolverwith a preconfigured endpoint and callsexchange_blocking()extract_ip_answers()(re-exported from ripdpi-dns-resolver) parses A/AAAA records from the response bytesbuild_fallback_encrypted_dns_endpoints()indns.rsprovides 3-4 fallback DoH resolvers when the primary fails. Used in bothdetect_strategy_probe_dns_tampering()andrun_dns_probe().
Built-in DNS providers
Default provider and ordering
Default encrypted DNS is AdGuard (changed from Cloudflare). Priority:
AdGuard > DNS.SB > Mullvad > Google IP > Cloudflare IP > Google > Quad9 > Cloudflare.
Defined in BuiltInDnsProviders (DnsResolverConfig.kt) and DEFAULT_DOH_* (util.rs).
DNS.SB TLS server name
tlsServerName for DNS.SB is "dns.sb" (not "doh.dns.sb"). The DoT cert
at port 853 is only valid for dns.sb; doh.dns.sb causes handshake failures.
Eager DNS failover
VpnEncryptedDnsFailoverController triggers immediately on catastrophic errors
(connection reset, refused, "operation not permitted") via isCatastrophicDnsError(),
skipping the 2-failure threshold for bootstrap-phase queries (<=3 queries attempted).
DNS failure counting
The native tunnel counts bootstrap connection failures in dnsFailuresTotal.
Previously only DNS query failures (after successful connection) were counted;
connection-level failures during bootstrap were silently dropped.
Adding a new DNS protocol
End-to-end walkthrough for adding a hypothetical "DoX" protocol:
-
types.rs: Add
Doq-style variant toEncryptedDnsProtocol, implementas_str()anddefault_port(). Add any protocol-specific fields toEncryptedDnsEndpoint. Add error variants toEncryptedDnsErrorand classify them inkind(). -
transport.rs: Add normalization branch in
normalize_endpoint()to validate required fields and fill defaults. If the protocol needs TLS, reusebuild_client_config(). -
resolver.rs: Add
exchange_dox()method onEncryptedDnsResolver. Wire it intoexchange_with_metadata()protocol dispatch match. If the protocol uses connection reuse, add a variant toPooledConnectionand implementtake_dox_session()/connect_dox_session()following the DoT/DNSCrypt pattern. If it needs a persistent client (like quinn::Endpoint for DoQ), add it toResolverInnerand initialize inwith_health(). -
lib.rs: No changes needed unless you add new public types -- the protocol enum variant propagates through existing re-exports.
-
pool.rs: No changes needed --
ResolverPoolis protocol-agnostic. It delegates toEncryptedDnsResolver::exchange_with_metadata(). -
hickory_backend.rs (optional): If hickory-resolver gains support for the protocol, add
exchange_dox()and wirecan_use_hickory()fallback. -
tests.rs: Add integration tests following the existing patterns:
- Spawn a local server (see
DnsCryptTestServer, TLS listener patterns) - Create an endpoint pointing to
127.0.0.1with the local port - Test both success path and error conditions
- Spawn a local server (see
Testing patterns
Integration tests (tests.rs, ~72K lines):
- Local TLS servers using
rcgenself-signed certs +rustls::ServerConfig - Local DNSCrypt servers with test Ed25519 keypairs
turmoilcrate for deterministic network simulation (used forTcpClientStreamtrait)local-network-fixturedev-dependency for network test infrastructure
Unit tests (inline in each module):
health.rs: fake clock (advancing_fake_clock()) for deterministic EWMA testingdnscrypt.rs: Ed25519 keypair generation for certificate round-trip testspool.rs: manual fallback cache seeding to test cold-start behavior
Mock resolvers: Tests create EncryptedDnsResolver::with_extra_tls_roots()
pointing to localhost with self-signed certificate DER bytes passed as extra
TLS roots. The EncryptedDnsConnectHooks mechanism allows injecting custom
TCP connectors for test isolation.
Key test helpers:
build_query(name)-> DNS wire-format query bytesbuild_response(query, answer_ip)-> DNS wire-format response bytesDnsCryptTestServer-> full DNSCrypt server with cert generation + crypto
Signals
- GitHub stars
- 71
- Forks
- 4
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
encrypted-dns- Source
- github.com/po4yka/ripdpi