Collaboration between TU Dresden and the Internet Society Pulse. A project to better understand the DNS resolver ecosystem and assess the resilience of recursive DNS resolvers.
Clone with submodules so measurement tools such as measurements/tools/zdns are available:
git clone --recurse-submodules <repository-url>
cd dns-resilience-observatoryIf the repository was already cloned without submodules, initialize them with:
git submodule update --init --recursiveRuntime .env files can contain credentials and should stay local. Use the matching .example files as templates.
| Runtime file | Purpose |
|---|---|
.env |
Local Docker/Django/PostgreSQL/pgAdmin settings and data-gathering database connection settings. |
data_gathering/external_sources/caida/spoofer/spoofer.conf |
CAIDA Spoofer fetcher URL, paging, and data directory. |
data_gathering/tasks/apnic_dnssec/apnic_dnssec.conf |
APNIC DNSSEC task URLs, worker counts, batch sizes, and data directory. |
data_gathering/tasks/caida_spoofer/caida_spoofer.conf |
CAIDA Spoofer task fetch/import settings. |
data_gathering/tasks/manycast/manycast.conf |
Manycast task logging and data directory settings. |
data_gathering/tasks/odns_v4/odns_v4.conf |
ODNS API, Manycast fetch, and ODNS import settings. |
data_gathering/tasks/webpage_resolver/webpage_resolver.conf |
Web resolver URL import definitions and column mappings. |
measurements/tasks/verify_resolvers/verify_resolvers.conf |
Active resolver verification measurement using ZDNS. |
measurements/tasks/metainformation_resolvers/metainformation_resolvers.conf |
Resolver metainformation measurement using ZDNS PTR, SVCB, A, AAAA, and HTTPS lookups. |
db/data-sources.conf |
Source metadata inserted into the data_source table. |
Copy examples before running services:
cp .env.example .env
cp data_gathering/tasks/odns_v4/odns_v4.conf.example data_gathering/tasks/odns_v4/odns_v4.conf
cp measurements/tasks/verify_resolvers/verify_resolvers.conf.example measurements/tasks/verify_resolvers/verify_resolvers.conf
cp measurements/tasks/metainformation_resolvers/metainformation_resolvers.conf.example measurements/tasks/metainformation_resolvers/metainformation_resolvers.confReplace these placeholders for setup:
.env: setPOSTGRES_PASSWORD,DATABASE_PASSWORD,DJANGO_SECRET_KEY,DJANGO_SUPERUSER_PASSWORD, and adjustDJANGO_ALLOWED_HOSTS/API_BASE_URLfor deployment. Docker Compose configures the frontend to use same-originAPI_BASE_URL=/in production.data_gathering/tasks/odns_v4/odns_v4.conf: replace<ODNS_API_AUTH_TOKEN>with the ODNS API token.measurements/tasks/verify_resolvers/verify_resolvers.conf: setzdns_pathto the built ZDNS binary if it differs frommeasurements/tools/zdns/zdns; adjustdomainif needed.measurements/tasks/metainformation_resolvers/metainformation_resolvers.conf: adjustmodules(svcb,svcb,ptr,a, orsvcb,ptr,a,aaaa,https),threads, resolver filters, andrecursive_name_serversif needed.- Task
.conffiles: adjustdata_dir, worker counts, fetch windows, URLs, and source mappings only if your deployment differs from the defaults. db/data-sources.conf: update source metadata only when adding or changing data sources.
If a runtime .env or .conf file is already tracked, keep the local file but remove it from Git with:
git rm --cached <path>pgAdmin automatically renders db/pgadmin/servers.json.tmp from the PostgreSQL environment variables and loads the resulting server definition. No manual file rename or substitution is required.
Base system without active measurements: 8 CPU cores and 16 GB RAM.
For local testing, use a project-local virtual environment. This project supports uv; install it
from the official Astral documentation: Installing uv.
Quick install on Linux/macOS:
curl -LsSf https://astral.sh/uv/install.sh | shCreate and populate the virtual environment:
uv venv
source .venv/bin/activate
uv pip install -r requirements.txtThis installs the project-compatible celery command. If celery is not found, activate the virtual environment or run it through .venv/bin/celery; do not install or use the system package with apt, because distro Celery packages can pull incompatible dependencies.
requirements.txt uses polars[rtcompat] instead of plain polars so older CPUs/systems can use Polars without failing its runtime CPU feature check. For local runs on such systems, set POLARS_SKIP_CPU_CHECK=1 before starting Python, Celery, or Django.
Activate the environment in later shells with:
source .venv/bin/activateDeactivate it with:
deactivateSchema creation and updates are handled by db/apply_schema.py. The script is idempotent and can run against a local or remote PostgreSQL instance.
Connection settings (in order of precedence):
DATABASE_URL(full PostgreSQL URL)DATABASE_HOST,DATABASE_PORT,DATABASE_USER,DATABASE_PASSWORD,DATABASE_NAME(defaults tolocalhost,5432,postgres, empty password,dns_resilience_observatory)
Install dependencies:
python -m pip install "psycopg[binary]" python-dotenvLocal PostgreSQL via Docker Compose is available in docker-compose.yml.
- Copy the env file and edit credentials if needed.
cp .env.example .env- Start the database.
docker compose up -d- Apply the schema.
python db/apply_schema.pyRun:
python db/apply_schema.pyNginx is the only service with a public host-port mapping: 8000:80. It accepts both domain-name and IP-address requests and forwards them to the private frontend container. PostgreSQL, RabbitMQ, and the API are available only on Docker networks. pgAdmin is bound to the server loopback interface (127.0.0.1:5050) and can be reached remotely using an SSH tunnel:
ssh -L 5050:127.0.0.1:5050 <USER>@<SERVER_IP>Then open http://localhost:5050 on your workstation.
Keep only the internal service names and local hosts in .env:
DJANGO_ALLOWED_HOSTS=api,frontend,localhost,127.0.0.1Nginx forwards the upstream request with Host: frontend, so Django needs only the internal frontend hostname in DJANGO_ALLOWED_HOSTS; the public domain or IP does not need to be listed. The public frontend uses same-origin /api/ paths, so browser requests stay on port 8000 while the separate api service remains private.
The pgadmin-init service automatically creates the session directory and assigns the pgAdmin container user (UID 5050) ownership of the mounted db/pgadmin_vol directory before pgAdmin starts. No host-side chmod or chown is required. After deploying this configuration, recreate pgAdmin and its dependency:
docker compose up -d --force-recreate pgadmin-init pgadminIf it still fails, inspect the one-shot initializer:
docker compose logs pgadmin-initThe data-gathering worker runs scheduled tasks and can be triggered manually. Tasks live under
data_gathering/tasks/<topic>/ and are auto-discovered.
Scheduling is controlled via environment variables on the data-gathering service:
CELERY_SCHEDULED_TASK: task name to run on a schedule (default:data_gathering.tasks.dispatch.run_all).CELERY_SCHEDULE_CRON: cron expression with 5 fields (default:0 0 * * *).DATA_GATHERING_TASKS: optional comma-separated allowlist of task names.
Resolver lists can be exported from the database:
python -m measurements.scripts.get_resolvers --verified true --is-public true --country DE --format txtThe first measurement task verifies resolvers by running a ZDNS A lookup for the configured domain through each resolver:
docker compose up -d --build measurements
docker compose exec measurements \
celery -A measurements.celery_app call measurements.tasks.verify_resolvers.run --queue measurementsResolver metainformation measurement:
docker compose exec measurements \
celery -A measurements.celery_app call measurements.tasks.metainformation_resolvers.run --queue measurementsThe resolver verification task expects a ZDNS binary at measurements/tools/zdns/zdns.
The measurements Docker image builds this binary automatically. For local runs without Docker, install Go first, then compile the submodule:
git submodule update --init --recursive
cd measurements/tools/zdns
make
cd ../../..Check the binary:
measurements/tools/zdns/zdns --helpRun these commands through Docker Compose on deployments. Do not run host /usr/bin/celery unless the project virtual environment is active.
- Start the services:
docker compose up -d rabbitmq data-gathering- Manual trigger (run all registered tasks):
docker compose run --rm data-gathering python3 db/data_source.pyThen dispatch all registered tasks:
docker compose run --rm data-gathering \
celery -A data_gathering.celery_app call data_gathering.tasks.dispatch.run_all- First database bootstrap, only when the database has no imported content:
docker compose run --rm data-gathering python3 db/data_source.pyThen dispatch the first-start bootstrap:
docker compose run --rm data-gathering \
celery -A data_gathering.celery_app call data_gathering.tasks.dispatch.bootstrap_if_emptyThe one-shot data-gathering-run-on-start service inserts data sources first and then uses this bootstrap task automatically. It skips itself once core content tables already contain rows.
- Manual first-bootstrap task order:
docker compose run --rm data-gathering python3 db/data_source.py
docker compose run --rm data-gathering \
celery -A data_gathering.celery_app call data_gathering.tasks.manycast.refresh
docker compose run --rm data-gathering \
celery -A data_gathering.celery_app call data_gathering.tasks.caida_spoofer.refresh
docker compose run --rm data-gathering \
celery -A data_gathering.celery_app call data_gathering.tasks.odns.refresh
docker compose run --rm data-gathering \
celery -A data_gathering.celery_app call data_gathering.tasks.apnic_dnssec.refresh
docker compose run --rm data-gathering \
celery -A data_gathering.celery_app call data_gathering.tasks.webpage_resolver.refresh- Manual trigger (single task example):
docker compose run --rm data-gathering \
celery -A data_gathering.celery_app call data_gathering.tasks.<topic>.<task_name>The webpage resolver task imports resolver lists configured in
data_gathering/tasks/webpage_resolver/webpage_resolver.conf.
To add a URL, add one entry under [urls] and a matching [url.<name>] section:
[urls]
example_resolvers = https://example.org/resolvers.txt
[url.example_resolvers]
headers = resolver_ip
no_header = true
mapping = ip:resolver_ip
modules = resolver
separator = ,
source = webpage-resolver.example
description = Resolver list from example.org.
verified = false
force = falseThe task name is data_gathering.tasks.webpage_resolver.refresh.
Note: both ASGI and WSGI entry points are included; use ASGI for async/WebSockets and WSGI for traditional sync deployments.
Interactive API docs are available at /api/docs/; the OpenAPI document is available at /api/openapi.json.
All list-style endpoints accept ?limit=N with 1 <= N <= 1000 and return matching resolver rows with metadata such as ASN, prefix, country, domains, and protocol:port services.
| Endpoint | Usage |
|---|---|
GET /api/dns-resilience/resolver/{resolver_ip} |
Resolver lookup by IPv4 or IPv6 address. |
GET /api/dns-resilience/prefix/{network_prefix} |
Resolver lookup by CIDR prefix. URL-encode /, for example 9.9.9.0%252F24 when called through the frontend-style double encoding. |
GET /api/dns-resilience/ASN/{asn} |
Resolver and aggregate lookup by ASN, e.g. AS3320 or 3320. |
GET /api/dns-resilience/country/{country} |
Resolver and aggregate lookup by ISO country code, alpha-2 or alpha-3. |
GET /api/dns-resilience/domain/{domain} |
Resolver lookup by associated resolver domain, e.g. one.one.one.one. |
GET /api/dns-resilience/protocol/{service} |
Resolver lookup by protocol or protocol:port, e.g. doh, doh3:443, dot:853, doq:853, dotcp:53, or doudp:53. |
GET /api/dns-resilience/resolver/{resolver_ip}/summary |
Resolver summary for the frontend: metadata, domains, sibling IPs, QMIN, anycast, spoofing, and open-forwarder relay aggregates. |
GET /api/dns-resilience/resolver/{resolver_ip}/qmin |
QMIN data for one resolver IP. |
GET /api/dns-resilience/resolver/{resolver_ip}/anycast |
Anycast prefix coverage for one resolver IP. |
GET /api/dns-resilience/resolver/{resolver_ip}/anycast/sites |
Anycast backend countries and ASNs for one resolver IP. |
GET /api/dns-resilience/resolver/{resolver_ip}/spoofing |
Spoofing prefix data containing one resolver IP. |
GET /api/dns-resilience/ASN/{asn}/qmin |
QMIN aggregate data for an ASN. |
GET /api/dns-resilience/ASN/{asn}/anycast |
Anycast prefix coverage for an ASN. |
GET /api/dns-resilience/ASN/{asn}/anycast/sites |
Anycast backend countries and ASNs for an ASN. |
GET /api/dns-resilience/ASN/{asn}/spoofing |
Spoofing aggregate data for an ASN. |
GET /api/dns-resilience/country/{country}/qmin |
QMIN aggregate data for a country. |
GET /api/dns-resilience/country/{country}/anycast |
Anycast prefix coverage for a country. |
GET /api/dns-resilience/country/{country}/anycast/sites |
Anycast backend countries and ASNs for a country. |
GET /api/dns-resilience/country/{country}/spoofing |
Spoofing aggregate data for a country. |
GET /api/dns-resilience/country/{country}/dnssec |
DNSSEC validation data for a country. |
GET /api/dns-resilience/global/ipv4 |
Global IPv4 recursive resolver count and public share. |
GET /api/dns-resilience/global/ipv6 |
Global IPv6 recursive resolver count and public share. |
GET /api/dns-resilience/global/dual-stack |
Number of resolver IDs with both IPv4 and IPv6 addresses. |
GET /api/dns-resilience/global/scope |
Global observatory coverage: resolver count, unique ASNs, unique country codes, latest resolver observation. |
GET /api/dns-resilience/global/anycast |
Global resolver anycast count and top resolver IPs by anycast backend sites. |
GET /api/dns-resilience/global/qmin |
Global QMIN measurement summary and parameter distributions. |
GET /api/dns-resilience/global/protocols |
Resolver service usage by protocol, port, and protocol-port combination. |
GET /api/dns-resilience/global/spoofing |
Resolver IPs located in networks that allow spoofing. |
GET /api/dns-resilience/global/countries |
Resolver deployment by country code, including coordinates for the world map and top 10 countries. |
GET /api/dns-resilience/global/asns |
Top 10 ASNs by resolver count. |
GET /api/dns-resilience/global/dnssec |
Country-code DNSSEC measurement count and average validation percentage. |
The database is normalized around the currently populated data areas. Schema creation lives in db/apply_schema.py.
| Area | Purpose | Associated tables |
|---|---|---|
data_source |
Registry of external data sources. Every imported source value must exist here first. |
data_source |
resolver |
Recursive resolver IPs and resolver attributes. The base table maps IPs to stable resolver IDs; attributes are stored in one-purpose tables. | resolver_id, resolver, resolver_asn, resolver_prefix, resolver_org, resolver_location, resolver_service, resolver_dohpath, resolver_domain, resolver_verification |
forwarder |
Forwarder IPs, forwarder attributes, and upstream relationships to resolvers or other forwarders. | forwarder_id, forwarder, forwarder_asn, forwarder_prefix, forwarder_org, forwarder_location, forwarder_protocol, forwarder_endpoint, forwarder_domain, forwarder_resolver_upstream, forwarder_forwarder_upstream |
anycast |
Anycast prefixes, prefix ASNs, and backend evidence by country and ASN. | anycast, anycast_asn, anycast_country_backend, anycast_asn_backend |
spoofing |
CAIDA Spoofer prefix-level spoofing results with ASN and country attributes. | spoofing, spoofing_asn, spoofing_country |
All source-bearing tables use source as a foreign key to data_source(source). Add source
metadata before running imports that reference that source.
Example source registration:
INSERT INTO data_source (
source,
url,
api_endpoint,
documentation_endpoint,
apikey_required
)
VALUES
(
'manycast',
'https://manycast.net/',
'https://manycast.net/api/v1/export/IPv4-latest.parquet',
'https://manycast.net/',
FALSE
),
(
'odns-api',
'https://odns-data.netd.cs.tu-dresden.de/',
'https://odns-data.netd.cs.tu-dresden.de/api/v2/ODNSQuery/GetDnsEntries',
'https://odns-data.netd.cs.tu-dresden.de/',
TRUE
),
(
'caida-spoofer',
'https://www.caida.org/projects/spoofer/',
'https://api.spoofer.caida.org/sessions',
'https://www.caida.org/projects/spoofer/',
FALSE
)
ON CONFLICT (source)
DO UPDATE SET
url = EXCLUDED.url,
api_endpoint = EXCLUDED.api_endpoint,
documentation_endpoint = EXCLUDED.documentation_endpoint,
apikey_required = EXCLUDED.apikey_required;| Source | Used for | API endpoint | API key required |
|---|---|---|---|
odns-api |
Resolver, forwarder, and ODNS-derived anycast backend evidence | https://odns-data.netd.cs.tu-dresden.de/api/v2/ODNSQuery/GetDnsEntries |
Yes |
manycast |
Anycast prefix, ASN, and country-location evidence | https://manycast.net/api/v1/export/IPv4-latest.parquet |
No |
caida-spoofer |
Prefix-level spoofing observations | https://api.spoofer.caida.org/sessions |
No |
The generic importers accept CSV, Parquet (.parquet/.pq), JSON, and NDJSON files. Use
--mapping db_column:file_column to map file columns to importer fields. Mappings can be repeated
or comma-separated. Imports run as dry-runs by default; pass --no-dry-run to commit. Pass
--force to overwrite existing rows regardless of timestamp checks.
If no last_update_ts column is mapped, resolver and forwarder importers use the current UTC
timestamp for the import run. The anycast importer also fills last_update_ts when absent.
Script: data_gathering/imports/resolver/import_resolvers.py
Modules and required mapped fields:
| Module | Required mapping | Optional fields used |
|---|---|---|
resolver |
ip |
is_public, source, last_update_ts |
asn |
ip, asn |
source, last_update_ts |
prefix |
ip, prefix |
source, last_update_ts |
location |
ip, country |
city, source, last_update_ts |
protocol |
ip, protocol |
source, last_update_ts |
dohpath |
ip, dohpath |
source, last_update_ts |
org |
ip, org |
source, last_update_ts |
domain |
ip, domain |
source, last_update_ts |
Example:
python data_gathering/imports/resolver/import_resolvers.py data/resolvers.pq \
--mapping "ip:resolver_ip,is_public:is_public,source:source,last_update_ts:observed_at,asn:asn,prefix:bgp_prefix,country:country,protocol:protocol" \
--modules "resolver,asn,prefix,location,protocol" \
--no-dry-runScript: data_gathering/imports/forwarder/import_forwarders.py
Modules and required mapped fields:
| Module | Required mapping | Optional fields used |
|---|---|---|
forwarder |
ip |
is_public, source, last_update_ts |
asn |
ip, asn |
source, last_update_ts |
prefix |
ip, prefix |
source, last_update_ts |
location |
ip, country |
city, source, last_update_ts |
protocol |
ip, protocol |
source, last_update_ts |
endpoint |
ip, endpoint |
source, last_update_ts |
org |
ip, org |
source, last_update_ts |
domain |
ip, domain |
source, last_update_ts |
upstream |
ip, upstream_ip |
source, last_update_ts |
Example:
python data_gathering/imports/forwarder/import_forwarders.py data/forwarders.pq \
--mapping "ip:forwarder_ip,is_public:is_public,source:source,last_update_ts:observed_at,asn:asn,prefix:bgp_prefix,country:country,protocol:protocol,upstream_ip:resolver_ip" \
--modules "forwarder,asn,prefix,location,protocol,upstream" \
--no-dry-runScript: data_gathering/imports/anycast/import_anycast.py
Modules and required mapped fields:
| Module | Required mapping | Optional fields used |
|---|---|---|
anycast |
prefix |
backing_prefix, partial, source, last_update_ts |
asn |
prefix, asn |
source, last_update_ts |
asn_backend |
prefix, asn |
asn_count, source, last_update_ts |
location |
prefix, country |
country_count, source, last_update_ts |
If source is not mapped, pass --source. The source must already exist in data_source.
For backend tables, non-force updates only apply when the incoming timestamp is newer and the
incoming count is higher.
Example:
python data_gathering/imports/anycast/import_anycast.py data/manycast.pq \
--mapping "prefix:prefix,backing_prefix:backing_prefix,partial:partial,asn:ASN,country:locations" \
--modules "anycast,asn,location" \
--source manycast \
--no-dry-run