A tiny authoritative DNS server + HTTP service that tells you which recursive resolver your device actually uses, and whether that resolver forwards an EDNS Client Subnet (ECS) option to authoritative servers.
Try it: edns.upset.dev, or from a terminal:
$ curl -sL https://edns.upset.dev/
{
"dns": {
"ip": "172.253.211.5"
},
"edns": {
"ip": "203.0.113.0"
}
}dns.ip: the recursive resolver that queried the authoritative server.edns.ip: the client subnet the resolver forwarded. Present only when the resolver sends ECS.
Useful for debugging DNS-based geo routing and CDN steering, checking which resolver a VPN, DoH setting, or ISP is really using, and checking whether a resolver leaks client subnets.
The service is authoritative for its own zone and serves HTTP(S) on the same host. A lookup takes three steps:
- Mint. A request to the apex (
https://edns.upset.dev/) creates a random, short-lived, signed token and redirects (302) tohttps://<token>.edns.upset.dev/. - Capture. To follow the redirect, the client resolves
<token>.edns.upset.dev. The name is new, so no cache has it, and the query reaches this server. The resolver's source IP and any ECS subnet are recorded against the token. Only the first lookup counts: later ones (another tool or device opening the same link, resolver prefetch) would name a different resolver. - Report. The request to the token host returns what was recorded as JSON.
A token carries its own expiry and an HMAC, so the server can check it
without storing anything: minting writes nothing, and forged or expired tokens
are rejected before they reach the store. Only captures are stored, for the
rest of the token's one-hour life. The store is in memory by default. With
Redis, any instance can handle any step, as long as all instances share the
same Redis and TOKEN_SECRET.
HTTPS uses one Let's Encrypt certificate for <base> and *.<base>, obtained
and renewed automatically (when a third of its lifetime is left) using a
DNS-01 challenge, and stored on disk or, for several instances, in Redis. This
process is the zone's authoritative server, so publishing the challenge only
means writing a TXT value to the store. The DNS handler then serves it to the
ACME validator.
- Requests for any host outside the zone (the server's IP, its
fly.devname, another domain pointed at it) are redirected (301) tohttps://<base>/. - With TLS on, plain HTTP is redirected to HTTPS, and HTTPS responses carry HSTS.
- Answers
SOA,NS,AandAAAAfor the zone. Other types getNODATA(SOA in the authority section). - Answers
A/AAAAfor its own nameserver hostnames (theNSlist), even when they sit outside the zone. - Refuses queries for names outside the zone.
- Answers
ANYwith a singleHINFOrecord (RFC 8482). - Replies to EDNS queries with an OPT record, and echoes any client subnet
with scope
/0(RFC 7871): the answer is the same for every subnet. Resolvers such as Google Public DNS only keep forwarding ECS to nameservers that echo it. Aqueries for<token>.<base>trigger the capture. Names that aren't valid, unexpired tokens are answered without touching the store.TXTqueries for_acme-challenge.<base>return the current DNS-01 values.
- Signed tokens. Random-subdomain floods and forged or expired tokens are rejected in memory, so they cost no Redis commands. Only a token's first capture is written; later lookups of it are dropped without touching Redis.
- DNS rate limiting, per source
/24(IPv4) or/56(IPv6), with UDP and TCP limited separately. Over the UDP limit the reply is an empty truncated answer (TC), no bigger than the query, so it's useless for reflection. A real resolver retries over TCP, which a spoofed source can't complete. Over the TCP limit the reply isREFUSED. - HTTP rate limiting per client IPv4 address or IPv6
/64(429withRetry-After). Favicon requests aren't limited. - Connection limits and timeouts on every TCP listener, so slow or idle clients can't exhaust the machine.
Rate limits need the real client address. Behind a TCP proxy, enable the PROXY
protocol on the proxy and set PROXY_PROTOCOL=true (the Fly.io setup does
this).
edns.upset.dev runs on Fly.io:
two Machines in Singapore (sin), sharing
Upstash Redis for captures and the
certificate. Every
push to main that passes CI is deployed automatically with fly deploy
(.github/workflows/ci.yml); the configuration is
in fly.toml.
You need a domain where you can delegate a subdomain (for example
edns.example.com) to nameservers you control, and a host with a public IPv4
address that can receive UDP and TCP on port 53.
In the parent zone (example.com), add:
edns.example.com. NS ns1.edns.example.com.
edns.example.com. NS ns2.edns.example.com.
ns1.edns.example.com. A <your public IPv4>
ns2.edns.example.com. A <your public IPv4>
If your DNS provider proxies traffic (for example Cloudflare's orange cloud), turn it off for these records.
fly apps create <app>
fly ips allocate-v4 -a <app> # dedicated IPv4: Fly only routes UDP on one
fly ips allocate-v6 -a <app>
fly redis create --name <app>-redis --region <region> --plan Pay-as-you-go --no-replicas
fly secrets set -a <app> REDIS_URL='redis://default:...@fly-<app>-redis.upstash.io:6379'
fly secrets set -a <app> TOKEN_SECRET=$(openssl rand -hex 32)For a single Machine without Redis, skip the Redis steps, create a volume
(fly volumes create edns_certs -a <app> -r <region> -s 1), and in
fly.toml set STORE = "memory", CERT_STORE = "file", and mount the volume
at /data with a [mounts] section. Deploy with fly deploy --ha=false.
Copy fly.toml, then set app, primary_region, BASE_DOMAIN, NS, HTTP_IP
and HTTP_IPV6 to your values, and deploy two Machines:
fly deploy
fly scale count 2 -a <app>Fly.io details to know:
- UDP must bind
fly-global-services:53. Fly doesn't rewrite UDP destination ports, and replies have to leave from that address.fly.tomlhandles this withDNS_UDP_ADDR. The image grantsCAP_NET_BIND_SERVICEso the non-root process can bind port 53. - Several Machines share one certificate. With
CERT_STORE=redis(andSTORE=redis, plus oneTOKEN_SECRETfor all), a lock in Redis makes sure only one Machine orders or renews it, and no volume is needed. Certificates are served from memory, so this adds no Redis commands per TLS handshake. WithCERT_STORE=fileinstead, each Machine has its own volume and would request its own certificate, so run only one. - Keep the Machines in one region. Every capture and report goes to the Redis primary; a second region would add that round trip to each lookup.
- TCP uses the PROXY protocol. Fly's TCP proxy hides the client address,
so the TCP services enable Fly's
proxy_protohandler and the app requires the header (PROXY_PROTOCOL=true). Change both together. - Keep the Machines running. Incoming UDP doesn't wake a stopped Machine,
so
fly.tomldisables auto-stop.
docker build -t edns .
docker run -d --name edns \
-p 53:53/udp -p 53:53/tcp -p 80:8080 -p 443:8443 \
-v edns-certs:/data \
-e BASE_DOMAIN=edns.example.com \
-e HTTP_IP=<your public IPv4> \
-e ACME_EMAIL=you@example.com \
ednsThis keeps state in memory. To run several instances, give them the same
TOKEN_SECRET and a shared Redis (-e STORE=redis -e REDIS_ADDR=<host>:6379).
Publish the ports so that the container sees the client's real source address. A proxy or load balancer that rewrites source IPs (SNAT, or Docker Swarm's ingress routing mesh, for example) makes every lookup report the proxy's IP.
All configuration is through environment variables.
| Variable | Default | Description |
|---|---|---|
HTTP_IP |
required | Public IPv4 returned in A answers for the zone. |
HTTP_IPV6 |
(empty) | Public IPv6 returned in AAAA answers. |
BASE_DOMAIN |
edns.upset.dev |
The zone this server is authoritative for. |
NS |
ns1.<base>,ns2.<base> |
Comma-separated authoritative nameserver hostnames. |
HOSTMASTER |
hostmaster.<base> |
SOA RNAME (contact mailbox, . instead of @). |
DNS_ADDR |
:53 |
DNS listen address (UDP and TCP). |
DNS_UDP_ADDR |
$DNS_ADDR |
Separate UDP listen address (Fly.io: fly-global-services:53). |
HTTP_ADDR |
:8080 |
HTTP listen address. |
HTTPS_ADDR |
:8443 |
HTTPS listen address. |
STORE |
memory |
memory (single instance) or redis (shared between instances). |
REDIS_ADDR |
redis:6379 |
Redis host:port, with STORE=redis. |
REDIS_URL |
(empty) | redis://user:pass@host:port. Overrides REDIS_ADDR when set. |
TOKEN_SECRET |
(random per process) | HMAC key for tokens, at least 32 characters. Must match across instances. |
DNS_RATE_LIMIT |
100 |
DNS queries per second per source /24 or /56. 0 disables. |
DNS_RATE_BURST |
500 |
DNS queries allowed at once per source /24 or /56. |
HTTP_RATE_LIMIT |
10 |
HTTP requests per second per client IPv4 or IPv6 /64. 0 disables. |
HTTP_RATE_BURST |
500 |
HTTP requests allowed at once per client: a 50-lookup run is about 100. |
PROXY_PROTOCOL |
false |
Require a PROXY protocol header on TCP listeners (DNS, HTTP, HTTPS). |
TLS |
true |
Serve HTTPS with a Let's Encrypt certificate and redirect plain HTTP to it. |
ACME_EMAIL |
(empty) | Let's Encrypt account contact. |
ACME_STAGING |
false |
Use the Let's Encrypt staging CA, for testing. |
CERT_STORE |
file |
file (CERT_DIR) or redis (shared by instances; requires STORE=redis). |
CERT_DIR |
/data |
Certificate storage directory, with CERT_STORE=file. |
The server refuses to start if the configuration is invalid, for example when
HTTP_IP is missing or isn't an IPv4 address.
With STORE=redis, each lookup uses about 2 Redis commands: 1 to record the
capture and 1 to report. Minting, favicon requests, repeat lookups of a
captured token, and queries for invalid or expired tokens use none. On Upstash Pay-as-you-go ($0.20 per 100K commands),
10,000 lookups a day come to about $1.20 a month. Set a spending cap on the
database as well, so a sustained flood can't run up the bill.
Requires Rust (the version is pinned in
rust-toolchain.toml; rustup installs it on first use).
The tests need no external services. The Redis-backed tests run as well when
EDNS_TEST_REDIS_URL points at a Redis; make test-redis starts a throwaway
one in Docker.
make test # unit + end-to-end tests
make test-redis # the same, plus the Redis backend (needs Docker)
make lint # rustfmt, clippy
make vuln # cargo audit
make check # lint, test and vuln, as run in CI (CI also runs Redis)The code, in src/:
| Module | What it does |
|---|---|
main.rs |
Startup: binds the listeners and runs the servers. |
config.rs |
Environment variables, validated. |
token.rs |
Signed, self-expiring tokens. |
dns.rs |
The authoritative DNS server and token capture. |
http.rs |
Mint, report, favicon, HTTPS redirect, CORS. |
limit.rs |
Per-source rate limits and the per-token capture guard. |
net.rs |
TCP/UDP listeners: connection caps, PROXY protocol. |
store.rs |
Captures and ACME challenge records, in memory (store/memory.rs) or Redis (store/redis.rs). |
tls.rs |
The ACME client and certificate renewal loop. |
certstore.rs |
Certificate storage (files or Redis) and the distributed lock. |
clock.rs |
A clock tests can freeze and advance. |
To run locally without TLS, on unprivileged ports (avoid 5353, which mDNS usually holds):
cargo build --release
HTTP_IP=127.0.0.1 BASE_DOMAIN=edns.localhost TLS=false DNS_ADDR=:5300 ./target/release/ednsYour system resolver never queries this server for *.localhost, so do the
three steps by hand:
curl -si http://edns.localhost:8080/ | grep -i location # 1. mint
dig @127.0.0.1 -p 5300 +subnet=203.0.113.0/24 <token>.edns.localhost # 2. capture
curl -s http://<token>.edns.localhost:8080/ # 3. reportContributions are welcome. See CONTRIBUTING.md. To report a security issue, follow SECURITY.md instead of opening a public issue.