Cross-platform local DNS-over-HTTPS (DoH) resolver written in Rust. Converts standard local UDP DNS queries into encrypted HTTPS POST requests to a DoH provider.
Connects directly to DoH servers by IP, bypassing system DNS. Discovers and caches new providers in a local JSON config.
sequenceDiagram
participant OS as System (UDP)
participant Stub as doh-rs
participant JSON as bootstrap.json
participant DoH as DoH Provider (HTTPS)
OS->>Stub: Standard DNS query (UDP:53/5300)
Note over Stub: Check bootstrap.json / Resolve IP / Fallback
Stub->>DoH: HTTPS POST (Direct IP + Random TLS)
DoH->>Stub: Response (application/dns-message)
Stub->>OS: Standard DNS response (UDP)
Note over Stub: Background: hourly IP refresh & client hot-swap
- Direct IP connection — Does not depend on system DNS to resolve the DoH server itself.
- Automatic provider discovery — Detects whether the
--dohargument is an IP or a domain, resolves it via bootstrap providers, and validates the URL. - Provider fallback — If the primary provider fails (timeout, connection error, HTTP error), automatically retries with the next provider from
bootstrap.json. - Bootstrap caching (
bootstrap.json) — New DoH URLs passed via-dare automatically resolved and appended to the config for instant startup next time. - Random TLS fingerprints — Randomizes TLS Client Hello fingerprints (
Emulation::random()) on every connection to evade DPI and censorship detection. - Background IP auto-updater — A background Tokio task automatically resolves and updates the DoH provider's IP address every hour.
- Transparent client hot-swap — Replaces the underlying
wreqHTTP client transparently when IPs change, without dropping active queries or connections. - Request timeouts — 10s total timeout, 5s connect timeout, 8s per-request timeout to prevent hanging on dead connections.
- HPACK-aware traffic padding (
-P) — Adds random-byte padding to requests, sized to account for HPACK/QPACK header compression (ported from Xray's XHTTP padding logic), so the padded size actually survives on the wire instead of being compressed away. Disabled by default (-P 0), a fixed size (-P 500), or a random size picked from a range (-P 100-500) on every request.
cargo build --releaseIf you are using NixOS, you can cross-compile:
mv flake.windows.nix flake.nix
nix develop
cargo xwin build --release --target x86_64-pc-windows-msvcmv flake.android.nix flake.nix
nix develop
cargo build --release --target aarch64-linux-androidmv flake.linux.nix flake.nix
nix develop
cargo build --release --target x86_64-unknown-linux-muslmv flake.nixos.nix flake.nix
nix develop
cargo build --releaseDefault settings (port 5300, Cloudflare primary with Google fallback):
./target/release/doh-rsCustom port and provider:
./target/release/doh-rs -p 53 -P 0-50 -d https://dns.google/dns-queryUsing a direct IP (no domain resolution needed):
./target/release/doh-rs -p 53 -P 100-500 -d https://1.1.1.1/dns-query| Flag | Description | Default |
|---|---|---|
-p |
Local UDP port for incoming DNS queries | 5300 |
-d |
DoH provider URL | https://cloudflare-dns.com/dns-query |
-P |
Padding size: 0 (disabled), a fixed value (500), or a range (100-500) |
0 |
When a new provider is passed via -d, the stub validates it before use:
- Scheme must be
https—httpis rejected. - Path must contain
dns-query— standard DoH endpoint required. - Host is automatically detected as either an IP or a domain. Domains are resolved via existing bootstrap providers.
Providers are tried in order until one succeeds:
- The provider from
-d(if it exists inbootstrap.json, it is moved to first position). - Remaining providers from
bootstrap.jsonin their stored order.
If a provider fails, the error is logged and the next one is attempted immediately. If all providers fail, the query is dropped with a fatal log entry.
To resist deep-packet inspection and blocking, doh-rs employs multiple layers:
- Random TLS fingerprints — Every HTTPS connection uses a randomized TLS Client Hello fingerprint (
Emulation::random()), making it harder for DPI systems to fingerprint or block the stub based on static TLS signatures. - Browser SNI emulation — The TLS handshake mimics a real browser, blending into normal HTTPS traffic.
- Direct IP connection — Bypasses system DNS entirely, preventing local DNS-based blocking from affecting the DoH path.
- HPACK-aware padding (
-P) — Randomizes request size to defeat length-based traffic analysis. Naive padding gets mostly eaten by HPACK/QPACK header compression, so the padding size is calculated to survive compression and still land close to the requested size on the wire.
Created automatically on first run:
{
"primary": "dns",
"providers": [
{
"name": "cloudflare-dns",
"domain": "cloudflare-dns.com",
"url": "https://cloudflare-dns.com/dns-query",
"ips": ["104.16.249.249", "104.16.248.249"]
},
{
"name": "google-dns",
"domain": "dns.google",
"url": "https://dns.google/dns-query",
"ips": ["8.8.8.8", "8.8.4.4"]
}
]
}New providers discovered via -d are appended to this file automatically.
To intercept all system DNS traffic, bind to port 53 and set 127.0.0.1 as the system resolver.
Example NixOS service:
systemd.services.doh-rs = {
description = "Custom Rust DoH Resolver";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = "/path/to/doh-rs -p 53 -P 100-200 -d https://your-doh-provider.com/dns-query";
WorkingDirectory = "/path/to/doh-dir";
Restart = "always";
RestartSec = "3s";
User = "root";
};
};MIT