Skip to content

About

DNSProxy listens for incoming DNS requests, parses and encapsulates such queries into HTTP requests towards DoH resolvers as per RFC-8484. Upon valid response from DoH HTTP service DNSProxy will craft and send RFC-1035 compliant DNS responses back to calling client. DNSProxy protects you from privacy leaks, with option to store raw response packets

Topics

Resources

Security policy

Stars

15 stars

Watchers

1 watching

Forks

 
 

Latest commit

 

History

503 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

DNSProxy — DNS over HTTPS (DoH)

Tests and packages CodeQL Latest stable modern release Downloads
MIT license GitHub stars Open issues Last commit on master
Platforms: Linux, macOS and Windows PoC Shared resolver core: C11 Install DEB or RPM packages for amd64 and arm64
Upstream DoH with HTTP/2 and optional HTTP/3 RFC 8484 Security policy and private vulnerability reporting

DNSProxy bridges conventional DNS clients (UDP/TCP) to upstream DNS-over-HTTPS (DoH) resolvers. The dnsp-modern forwarder uses RFC 8484 application/dns-message POST requests, libcurl's event-driven multi interface, TLS verification, and opportunistic HTTP/2 multiplexing.

Download for Windows — 64-bit portable ZIP

Download for macOS — Apple Silicon ARM64

Download for macOS — Intel x86-64

Download for Linux — DEB and RPM packages

Windows setup guide · macOS setup guide · Linux installation guide

On the release page, choose Portable ZIP in the Windows row. Extract the entire archive and double-click Start-DNSProxy.cmd. Keep dnsp-modern.exe, its DLLs and the certificate bundle together. The native Windows PoC uses the same C resolver as Linux; the setup guide covers service installation, Administrator options, DNS adapter configuration and rollback.

macOS 15+: choose the Intel or Apple Silicon archive in the download table, extract it into its own folder and run Start-DNSProxy.command. Both architectures run natively using Apple's system libcurl; no Homebrew or Rosetta is required. The launcher uses local port 5053. See the macOS guide for port 53, system DNS setup and download approval. Developer ID signing and notarization remain follow-ups.

This project grew out of DNS transport and privacy experiments dating to roughly 2009–2010 and work surrounding RFC 8484. Historical project notes, experiments, and original CLI examples are preserved in docs/README-legacy.md.

The upstream resolver handles recursion and DNSSEC validation. DNS privacy also depends on how clients route their DNS traffic and which upstream they use.

Rust feasibility port

We have experimentally ported dnsp-modern to Rust to assess feasibility, protocol compatibility and performance. The code and port checklist live on the separate rust-poc branch. The refreshed port passes the C integration fixtures on Linux amd64 and arm64, the HTTP/2 and HTTP/3 fixtures, and 24,226 DNS wire comparison cases.

The Linux performance comparison includes the method, latency, CPU/memory measurements and raw results. Below are median queries per second from three short loopback runs with a local TLS DoH upstream, a 1 ms upstream delay, UDP clients and the answer cache disabled.

Upstream Concurrent clients C QPS Rust QPS
HTTP/2 1 683 693
HTTP/2 8 2,755 2,871
HTTP/2 32 4,285 4,412
HTTP/3 1 659 673
HTTP/3 8 3,570 3,450
HTTP/3 32 3,451 3,387

A separate C/Rust/Go application baseline covers UDP and persistent TCP, larger replies and one CPU per proxy, using AdGuard dnsproxy as the Go reference.

We are staying with C for now. These measurements show similar performance and slightly higher Rust memory use; they do not establish a consistent performance advantage or predict Internet throughput. C already has the maintained release, DEB/RPM packaging and systemd deployment path. Keeping one supported implementation also avoids maintaining two copies of protocol and lifecycle fixes. Rust offers safer ownership and memory handling in the ported code, while both implementations still depend on native libcurl for HTTP/TLS/QUIC. The Rust branch remains available for further evaluation.

Supported executable

make builds dnsp-modern, the executable shipped in DNSProxy releases. The legacy dnsp and dnsp-h2 compilation recipes have been removed from the current build. Their old target names print a retirement message, and the historical sources and CLI instructions are archived for reference.

dnsp-modern now supports persistent DNS-over-TCP client sessions, ordered sequential/pipelined messages, and an adjustable --tcp-idle-ms timeout. See the TCP session documentation for behavior, limits, metrics and tests.

Build, test and run

On Ubuntu or Debian:

sudo apt-get update
sudo apt-get install -y build-essential pkg-config libcurl4-openssl-dev python3 dnsutils
make
make modern-check

With Clang and its sanitizer runtime installed, make modern-fuzz runs the DNS wire and HTTP cache parser fuzzer for 30 seconds. Set FUZZ_SECONDS for longer runs.

Run as an unprivileged process; UDP and TCP DNS both listen on port 5300:

./dnsp-modern -l 127.0.0.1 -p 5300 \
  -s https://cloudflare-dns.com/dns-query -m 9153

Do not replace your system resolver or bind port 53 until the proxy is tested. The DNS listeners are IPv4; -l expects an IPv4 bind address. Metrics always bind 127.0.0.1 regardless of -l.

Quick tests

See deployment checks for protocol troubleshooting and the coverage of each test suite.

With the proxy running, open a second terminal. Check a UDP query, a TCP query, and an EDNS query that requests DNSSEC records.

dig @127.0.0.1 -p 5300 example.com A
dig @127.0.0.1 -p 5300 +tcp example.com AAAA
dig @127.0.0.1 -p 5300 +dnssec +bufsize=1232 example.com DNSKEY

Compare the proxy with direct DoH. Install dnsperf with DoH support (sudo apt install dnsperf on Debian 13). The same queries go through the local UDP listener and directly to the Cloudflare DoH upstream, at up to 20 queries per second for five seconds per run.

queries=$(mktemp)
printf '%s\n' 'example.com A' 'example.net AAAA' 'example.org MX' > "$queries"

dnsperf -m udp -s 127.0.0.1 -p 5300 -d "$queries" -l 5 -Q 20 -q 10
dnsperf -m doh -s 1.1.1.1 -p 443 -d "$queries" -l 5 -Q 20 -q 10 \
  -O doh-uri=https://cloudflare-dns.com/dns-query -O doh-method=POST

rm -f "$queries"

Compare completed and lost queries, response codes, and average latency. These runs compare two end-to-end paths with different client transports and connection setup. The rate cap makes this a smoke test. For a Google baseline, point the proxy upstream at https://dns.google/dns-query and use that URI and -s 8.8.8.8 in the direct dnsperf command.

Query a DoH URL with curl. An RFC 8484 GET puts the base64url-encoded DNS wire query in ?dns=. This example asks Cloudflare for example.com A, prints the HTTP response headers, and saves the binary answer to doh-answer.bin. Google's separate JSON endpoint returns a readable answer for the same query.

curl -fsS -D - -o doh-answer.bin -H 'Accept: application/dns-message' \
  'https://cloudflare-dns.com/dns-query?dns=AAABAAABAAAAAAAAB2V4YW1wbGUDY29tAAABAAE'

curl -fsS 'https://dns.google/resolve?name=example.com&type=A'

Both HTTPS requests go directly to the upstream providers. To see what the local proxy handled, run curl -fsS http://127.0.0.1:9153/metrics if it was started with -m 9153.

dnsp-modern options

Run ./dnsp-modern --help for the options available in your build.

Flag Meaning Default
-l IPv4 DNS UDP/TCP bind address 127.0.0.1
-p PORT DNS listener port, 1–65535 5300
-s URL Upstream DoH endpoint https://cloudflare-dns.com/dns-query
--backup-upstream URL Backup DoH endpoint for eligible transport failures Disabled
--backup-bootstrap-address IP Numeric address for the backup HTTPS hostname Resolve the hostname normally
-m PORT Loopback-only metrics port, 1–65535 and different from -p Disabled for manual runs. The packaged service uses 9153.
--bootstrap-address IP Numeric IPv4 or unbracketed IPv6 address for an HTTPS upstream hostname Resolve the hostname normally
--edns-padding Pad eligible upstream HTTPS DNS queries to a multiple of 128 bytes Disabled
--ca-file PEM CA certificate bundle for upstream TLS verification libcurl's default CA bundle
--upstream-http MODE auto for HTTP/2 with HTTP/1.1 fallback, or prefer-h3 for HTTP/3 with TCP fallback auto
--max-host-connections N Maximum upstream connections per host, 1–16 8
--tcp-max-per-source N Maximum concurrent DNS/TCP connections per IPv4 source, 1–128 32
--tcp-idle-ms MS How long a TCP session can wait idle for its next query, 100–600000 ms 15000
--tcp-io-ms MS Separate deadlines to read a TCP request frame and write a response frame, 100–600000 ms 15000
--doh-connect-ms MS Deadline for the upstream connection phase, 100–60000 ms 1500
--doh-timeout-ms MS Total deadline for an upstream request, 100–60000 ms 3000
--cache-entries N In-memory DNS answer cache slots, 0–1024 0 (disabled)
--udp-reply-max BYTES Maximum UDP reply size with EDNS, 512–4096 1232
-V, --version Print DNSProxy and libcurl versions, the TLS backend, and HTTP capabilities
-h, --help Print options and exit

Unknown options or invalid numeric options exit with status 2. Remote upstreams must use HTTPS. Plaintext HTTP is limited to the 127.0.0.1 testing endpoint and bypasses proxy environment settings. HTTPS upstreams use TLS certificate and hostname verification, and redirects are disabled.

If the host uses DNSProxy as its system resolver, configure an upstream IP to bootstrap the HTTPS connection without consulting the system resolver:

./dnsp-modern -s https://cloudflare-dns.com/dns-query --bootstrap-address 1.1.1.1

--bootstrap-address IP accepts one numeric IPv4 or unbracketed IPv6 address for an HTTPS upstream hostname. It connects directly to that address, bypassing proxy environment settings, while keeping the URL hostname for TLS and HTTP. Choose an address served by your DoH provider and update it if the provider changes addresses.

Optional upstream HTTP/3

./dnsp-modern --upstream-http=prefer-h3 -s https://cloudflare-dns.com/dns-query -m 9153
dig @127.0.0.1 -p 5300 example.com A
curl -fsS http://127.0.0.1:9153/metrics | grep dnsp_upstream_http_responses_total

prefer-h3 requires an HTTPS upstream and a linked libcurl 7.88.0 or newer with HTTP/3 support. Debian 13 supplies a compatible libcurl. An unsupported explicit preference exits at startup with status 2. When QUIC connection setup fails or is slow, libcurl can negotiate HTTP/2 or HTTP/1.1 over TCP within the same upstream deadlines. A fallback connection can be reused for subsequent queries. libcurl uses TCP when a forward proxy is in use. Direct QUIC requires UDP reachability to the upstream HTTPS port, usually 443.

Startup and SIGUSR1 reports show the requested http_preference. dnsp_upstream_http_responses_total{version="3"} counts negotiated HTTP/3 responses. libcurl HTTP version selection

The upstream connection deadline covers name resolution and handshakes for a new connection. The total deadline includes time queued by libcurl. Set --doh-connect-ms no higher than --doh-timeout-ms. This example allows a slower connection and more time for the reply.

./dnsp-modern --doh-connect-ms 2500 --doh-timeout-ms 5000

With a backup configured, eligible queries reserve half of the available deadline for retry. See upstream failover for eligible requests, failure policy, and per-endpoint metrics.

Check the active deadlines in the on-demand status report. For systemd overrides, see the packaging guide.

The bundled HTTP/2 benchmark measures concurrent streams, QPS, latency percentiles, CPU and RSS on local test servers.

Architecture and protocol scope

With --edns-padding, the proxy appends zero-filled EDNS Padding to unsigned queries with a final EDNS(0) OPT record. Queries that already carry Padding, use message signatures, lack a suitable OPT record, or would exceed the DNS message limit pass through as sent. The proxy removes response padding from an unsigned final EDNS(0) OPT when it added the request padding itself. RFC 8467

flowchart LR
    Client["DNS client"] -->|"UDP or TCP"| Request["Validate DNS query"]
    Request --> Loop["poll event loop and libcurl"]
    Loop <-->|"DoH over TLS"| Upstream["Upstream resolver"]
    Loop --> Reply["Validate reply and apply UDP size limit"]
    Reply -->|"UDP or TCP"| Client
Loading
  • Binary DNS wire packets arrive over UDP or framed DNS-over-TCP.
  • DoH requests use a persistent libcurl multi manager; libcurl socket and timer callbacks are integrated into the main poll() event loop.
  • The default HTTPS preference is HTTP/2 with HTTP/1.1 fallback. Operators can opt in to HTTP/3 where the linked libcurl and upstream support it. The HTTP version counters report the protocol used for each response.
  • Outstanding upstream requests, TCP client slots, and response sizes are bounded; upstream requests have connection and overall timeouts.
  • Incoming DNS packets and upstream HTTP status, Content-Type, transaction ID, question and DNS record boundaries are validated before replies are sent.
  • The IPv4 UDP listener accepts up to 65,507 bytes per datagram. TCP and the DoH wire body accept DNS messages up to 65,535 bytes. DNS/TCP carries a two-byte length prefix outside that limit. Name compression pointer walks are bounded across each message. Compressed names cannot read the TTL bytes the proxy may age. The UDP receive loop yields after 32 datagrams or after reaching 64 KiB of wire data, whichever comes first.
  • On an IPv4 wildcard bind, Linux packet information preserves the local address for UDP replies, including cache hits and coalesced replies.
  • UDP replies are limited to 512 bytes without EDNS. With EDNS, the client's advertised size is clamped between 512 and --udp-reply-max (default 1232). Larger replies use TC=1 for TCP retry. The input and reply limits are independent.
  • DoH transports arbitrary QTYPEs and QCLASSes in standard DNS QUERY messages. Unknown record types and well-framed EDNS options are relayed as opaque data. The upstream resolver determines the actual answer and supported EDNS versions. DNSSEC records and flags pass through. The proxy keeps the original DNS transaction ID and supports questionless exchanges such as DNS Cookie negotiation when the upstream implements them.
  • Query format errors receive FORMERR when the DNS header is available. AXFR, IXFR and unsupported opcodes receive NOTIMP. This service forwards single DNS exchanges. The upstream handles DNSSEC validation.
  • A persistent TCP session accepts pipelined queries and sends replies in order, with one upstream request in flight per session. DNS/TCP has 128 connection slots. Set --tcp-max-per-source for clients sharing an address. With 96 or more open connections, the idle timeout is capped at 3 seconds. Partial frame reads and response writes keep their separate I/O deadlines.

The client-to-proxy DNS leg is not encrypted merely because the upstream DoH connection is encrypted. Do not bind the proxy to an untrusted interface.

Forwarded unsigned DNS replies have their record TTLs reduced by the DoH Age header, including when the answer cache is disabled. OPT fields and message signatures are preserved.

Optional in-memory answer cache

./dnsp-modern --cache-entries 512 -m 9153
dig @127.0.0.1 -p 5300 +noedns example.com A
dig @127.0.0.1 -p 5300 +noedns example.com A
curl -fsS http://127.0.0.1:9153/metrics | grep '^dnsp_cache_'

The cache is disabled by default. It reuses complete positive IN A and IN AAAA answers, including CNAME chains, for matching UDP and TCP queries. The wire query must match byte for byte apart from the transaction ID. An empty EDNS(0) OPT is eligible. EDNS options, DNSSEC DO/CD requests, negative answers, and truncated upstream replies go through the upstream path.

Entries expire at the earliest answer TTL or upstream HTTP freshness limit, with a local retention cap of one hour. Cache hits restore the caller's ID and age each answer TTL, including the HTTP Age of the original DoH response. Zero-TTL answers and HTTP responses with reuse restrictions such as no-store, no-cache, or private skip storage. Vary: Accept is eligible because the proxy always sends the same upstream Accept value. Other Vary values skip storage. The cache accepts numeric max-age and s-maxage and conservatively skips unfamiliar cache directives. Expired entries are never served, even during upstream failure. RFC 8484 cache interaction

Storage is allocated once, with at most 4 KiB of reply data per slot plus metadata. A hash collision replaces the previous entry in that slot. Restarting the process clears the cache. Hit and miss counts are also in the SIGUSR1 status report. Misses count eligible queries that did not get a cached reply.

Metrics and observability

On-demand status report

Send SIGUSR1 to a running dnsp-modern process built from this source. For a manual run, use kill -USR1 "$pid" with the proxy's PID and read stderr. For a systemd installation, run the following commands.

sudo systemctl kill --kill-who=main --signal=SIGUSR1 dnsp-modern
sudo journalctl -u dnsp-modern -n 20 --grep='event=stats'

The event loop logs one event=stats line with uptime, listener and timeout settings, queries by transport, upstream outcomes, truncation, connection reuse and current load. It works with metrics disabled. Signals sent in quick succession may produce one report. The report omits the upstream URL, query names and client addresses.

queries_satisfied counts complete upstream NOERROR and NXDOMAIN replies written to the client socket, including valid negative answers. Truncation and error replies are excluded. upstream_ok counts validated DNS messages from the upstream, including valid DNS error responses. For UDP, a successful socket write means the kernel accepted the datagram for sending. Counters are cumulative since start.

Logs and Prometheus metrics

The proxy writes startup and failure diagnostics to stderr. For a systemd installation, read them with journalctl -u dnsp-modern. Warnings include an event name, client transport, upstream role, and numeric libcurl, HTTP and proxy CONNECT codes when available. Each warning category emits at most once per upstream role every ten seconds. The next warning reports how many messages it suppressed. Startup logs identify the build-time and runtime libcurl versions. The log omits the upstream URL, DNS questions, payloads, and client addresses.

Matching in-flight UDP A and AAAA queries without EDNS can share one upstream transfer. Each reply uses the caller's transaction ID, and repeated datagrams with the same client endpoint, local destination and ID use one reply slot. Each transfer can serve the original request plus eight waiters. They share the original upstream deadline. When the returned HTTP response forbids reuse, joined clients get separate upstream attempts within their original time budgets.

When all upstream slots are in use, queries that need a new transfer receive SERVFAIL. dnsp_overload_total includes these replies and local request setup failures.

The packaged systemd unit enables HTTP metrics at http://127.0.0.1:9153/metrics; manual runs enable metrics only with -m. Useful Prometheus-compatible series:

Metric Meaning
dnsp_queries_total{transport="udp"} / {transport="tcp"} Submitted queries by protocol
dnsp_queries_satisfied_total Complete NOERROR and NXDOMAIN replies written to client sockets
dnsp_queries_inflight Active upstream requests
dnsp_tcp_connections_rejected_total{reason="source_limit"} / {reason="capacity"} DNS/TCP connections closed at admission due to the per-source limit or a full table
dnsp_udp_queries_coalesced_total UDP datagrams that joined an active transfer, including retransmits
dnsp_upstream_requests_total Submitted upstream DoH requests
dnsp_upstream_success_total, dnsp_upstream_failures_total, dnsp_upstream_timeouts_total Completion and errors
dnsp_invalid_queries_total, dnsp_invalid_responses_total, dnsp_overload_total Validation/capacity events
dnsp_truncated_responses_total Truncated UDP answers
dnsp_upstream_http_responses_total{version="1"}, {version="2"}, {version="3"} Responses by negotiated upstream HTTP version
dnsp_upstream_new_connections_total, dnsp_upstream_reused_requests_total libcurl connection activity
dnsp_upstream_easy_handles_reused_total Transfers that took a reset easy handle from the idle pool
dnsp_upstream_duration_seconds_sum, dnsp_upstream_duration_seconds_count Aggregate upstream time
dnsp_upstream_phase_duration_seconds_sum, dnsp_upstream_phase_duration_seconds_count Queue wait (libcurl 8.6+), new TLS handshake, and time from transfer-ready to first byte, selected by phase
dnsp_curl_socket_callbacks_total, dnsp_curl_timer_callbacks_total Event-loop activity

Metrics reset when the process restarts. They do not expose query names or client addresses. The HTTP version and connection reuse counters do not measure simultaneous HTTP/2 streams; use the bundled benchmark for that measurement.

Debian packaging and systemd

sudo apt-get install -y dpkg-dev
sh scripts/build-deb.sh
# The default version comes from VERSION.
sudo apt install "./dist/dnsp-modern_$(cat VERSION)_$(dpkg --print-architecture).deb"

A different version can be supplied for packaging:

VERSION=0.2.1 sh scripts/build-deb.sh
sudo apt install ./dist/dnsp-modern_0.2.1_$(dpkg --print-architecture).deb

The package installs /usr/bin/dnsp-modern and a systemd service, disabled and stopped by default. It does not edit /etc/resolv.conf or take port 53. The packaged service runs under a restricted dynamic user and forwards to the Cloudflare DoH endpoint on 127.0.0.1:5300 with metrics on 127.0.0.1:9153.

sudo systemctl enable --now dnsp-modern
systemctl status dnsp-modern
journalctl -u dnsp-modern -f

Change settings with sudo systemctl edit dnsp-modern, clearing ExecStart= before supplying a replacement. See packaging/README-packaging.md for details.

Packages and releases

GitHub Actions builds DEBs for Debian 13 and Ubuntu 22.04/24.04, and RPMs for Rocky Linux 9. Each distribution has native x86-64 and ARM64 builds. Download the matching package and SHA256SUMS from GitHub Releases. From v4.0.3 onward, releases also include the native Windows x86-64 PoC ZIP; see the Windows guide for launch and DNS setup instructions.

Set the next version in VERSION and merge to master to publish once the Linux package matrix, sanitizer job, and native Windows build and tests pass. The release requires all eight Linux packages and the Windows ZIP, covered by SHA256SUMS. See the packaging guide for installation, local builds, and manual release recovery.

IPv4 and IPv6 listeners

The same C resolver supports native UDP/TCP listeners on Windows and Linux. Repeat -l for up to eight numeric addresses, sharing one DNS port and cache:

./dnsp-modern -l 127.0.0.1 -l ::1 -p 5300 --bootstrap-address 1.1.1.1

Omitting -l preserves the IPv4 loopback default. IPv6 sockets explicitly use IPV6_V6ONLY=1; use both addresses to serve both families. An explicitly requested listener must bind successfully, otherwise startup closes all listeners and fails. Wildcards (0.0.0.0 / ::) expose the selected family on all interfaces and require appropriate network access controls. Link-local IPv6 addresses need an interface scope, for example fe80::1%eth0 or a numeric interface index. UDP replies preserve the receiving local address and IPv6 interface scope. Non-jumbo UDP input limits are 65,507 bytes on IPv4 and 65,527 bytes on IPv6. TCP source limits distinguish address families and scoped IPv6 addresses. Metrics remain on IPv4 loopback; DNS records and upstream transports are independent of the listener family.

Feature ideas

  • DNS-aware response caching. Bound memory, honor positive and negative TTLs, and start with queries free of client-specific EDNS options.
  • Tail-latency metrics. Add bounded latency buckets by client transport and upstream outcome so operators can see slow replies behind the mean.

Contributing

See the C maintenance guide for repository layout criteria, C review checks, and commands to run before opening a PR. See the security policy for private vulnerability reporting.

About

DNSProxy listens for incoming DNS requests, parses and encapsulates such queries into HTTP requests towards DoH resolvers as per RFC-8484. Upon valid response from DoH HTTP service DNSProxy will craft and send RFC-1035 compliant DNS responses back to calling client. DNSProxy protects you from privacy leaks, with option to store raw response packets

Topics

Resources

Security policy

Stars

15 stars

Watchers

1 watching

Forks

Releases

Used by

Contributors

Languages

⌂