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 a wildcard certificate (<base> + *.<base>), obtained and renewed
automatically by CertMagic 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 Redis. The DNS
handler then serves it to the ACME validator.
- 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). 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:
a single Machine in Singapore (sin), with
Upstash Redis as the token store. 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 volumes create edns_certs -a <app> -r <region> -s 1
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) # optional on one MachineRedis is optional on a single Machine: drop the fly redis create and
REDIS_URL steps and set STORE = "memory" in fly.toml.
Copy fly.toml, then set app, primary_region, BASE_DOMAIN, NS, HTTP_IP
and HTTP_IPV6 to your values, and deploy:
fly deploy --ha=falseFly.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. - Run a single Machine, or share certificates. With the default
CERT_STORE=file, each Machine has its own certificate volume and would request its own certificate. To run several, setSTORE=redis,CERT_STORE=redisand a sharedTOKEN_SECRET, and drop the volume. They then share one certificate, and a lock in Redis makes sure only one Machine orders or renews it. Certificates are served from memory, so this adds no Redis commands per TLS handshake. - 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 Machine 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 |
20 |
DNS queries per second per source /24 or /56, bursting to 5×. 0 disables. |
HTTP_RATE_LIMIT |
2 |
HTTP requests per second per client IPv4 or IPv6 /64, bursting to 5×. 0 disables. |
PROXY_PROTOCOL |
false |
Require a PROXY protocol header on TCP listeners (DNS, HTTP, HTTPS). |
TLS |
true |
Serve HTTPS with CertMagic 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 Go (the version is in go.mod). Tests use an in-memory Redis
(miniredis), so they don't need any
external services.
make test # unit + end-to-end tests with the race detector
make lint # gofmt, go vet, staticcheck
make vuln # govulncheck
make check # all of the above, as run in CITo run locally without TLS, on unprivileged ports (avoid 5353, which mDNS usually holds):
go build -o edns .
HTTP_IP=127.0.0.1 BASE_DOMAIN=edns.localhost TLS=false DNS_ADDR=:5300 ./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.