Skip to main content

dnslens

A cross-platform DNS toolkit for developers and DevOps. Query, trace, propagation checks, benchmarks, health audits, DNSSEC checks, a local DNS server with an AdGuard Home style web UI (statistics, charts, query log, blocklists, DNS rewrites) and an HTTP/Prometheus API. One package, one dependency.

CI PyPI Python License: MIT


Screenshot 1 Screenshot 2 Screenshot 3 Screenshot 4 Screenshot 5 Screenshot 6 Screenshot 7

Contents


Why dnslens

You need to... Use
look up records like dig, on Windows too, with JSON output dnslens query
see exactly where a resolution breaks dnslens trace
know whether a DNS change has reached the world dnslens propagate
block a deploy pipeline until DNS is ready dnslens wait
pick the fastest resolver dnslens bench
check a domain's NS, SOA, mail auth, CAA and DNSSEC dnslens audit, dnslens dnssec
override names for dev/staging/CI, or block ads on a LAN dnslens serve
manage that server from a browser: stats, charts, query log, blocklists dnslens serve --web
expose all of this to dashboards and Prometheus dnslens api

Design goals: works the same on Windows, Linux and macOS, scriptable (--json, meaningful exit codes), no native tools required (no dig, no nslookup), and safe defaults for the two server components.

Install

pip install dnslens                 # Python 3.9+
pip install "dnslens[doh]"          # adds DNS-over-HTTPS (httpx)
pipx install dnslens                # isolated CLI install

Check it works: dnslens --version or python -m dnslens --version.

Quick start

dnslens query example.com MX                       # dig-like lookup via the OS resolver
dnslens query example.com A -r 1.1.1.1 --short     # only the data
dnslens propagate app.example.com --expect 203.0.113.10
dnslens audit example.com
dnslens serve --record "app.test A 10.0.0.5"       # local DNS server on 127.0.0.1:5353
dnslens serve --web                                # same, plus the web UI on http://127.0.0.1:8080
dnslens api --token secret                         # HTTP API on 127.0.0.1:8053

How it fits together

Three front ends share the same tools. Every tool is built on a single query primitive (core.query) that talks to resolvers through dnspython.

flowchart LR
    subgraph FE["Front ends"]
        CLI["CLI<br/>dnslens ..."]
        HTTP["HTTP API<br/>dnslens api"]
        LIB["Python import<br/>from dnslens import ..."]
    end

    subgraph TOOLS["Tools"]
        QRY["query / reverse"]
        TRC["trace"]
        PRP["propagate / wait / bench"]
        AUD["audit"]
        SEC["dnssec"]
    end

    SRV["DNS server<br/>dnslens serve"]
    WEBUI["Web UI + query log<br/>dnslens serve --web"]
    CORE["core.query<br/>transport, retries, EDNS, TCP fallback"]

    subgraph NET["Network"]
        RES["Recursive resolvers<br/>system / public / custom"]
        AUTH["Authoritative servers"]
        ROOT["Root servers"]
    end

    CLI --> TOOLS
    HTTP --> TOOLS
    LIB --> TOOLS
    CLI --> SRV
    LIB --> SRV
    SRV --- WEBUI
    WEBUI --> TOOLS
    QRY --> CORE
    PRP --> CORE
    AUD --> CORE
    SEC --> CORE
    TRC --> CORE
    SRV -->|"forwards cache misses"| CORE
    CORE --> RES
    CORE --> AUTH
    TRC --> ROOT

How it fits together

How a single query travels

flowchart TD
    A["query(name, type, server, opts)"] --> B{"server given?"}
    B -- "no" --> C["use OS resolvers<br/>try each until one answers"]
    B -- "yes" --> D["use that server<br/>IP, hostname, IP#PORT or DoH URL"]
    C --> E{"transport"}
    D --> E
    E -- "udp" --> F["send over UDP with EDNS 1232"]
    E -- "tcp" --> G["send over TCP"]
    E -- "tls" --> H["DNS-over-TLS, port 853"]
    E -- "https" --> I["DNS-over-HTTPS"]
    F --> J{"truncated?"}
    J -- "yes" --> K["retry the same query over TCP"]
    J -- "no" --> L["parse response"]
    K --> L
    G --> L
    H --> L
    I --> L
    F -. "timeout" .-> M{"retries left?"}
    M -- "yes" --> F
    M -- "no" --> N["Answer with error=timeout"]
    L --> O["Answer: rcode, flags, records, elapsed ms"]

How a single query travels

Command reference

Options shared by most commands

Option Default Meaning
-r, --resolver ADDR OS resolvers Resolver IP, hostname, IP#PORT or DoH URL. NAME=ADDR gives it a label. Repeatable.
--public off Use the built-in public resolver set (11 resolvers).
-t, --timeout SEC 3 Per-query timeout.
--retries N 1 Retries on timeout.
--transport udp udp (auto TCP fallback), tcp, tls, https.
-p, --port N by transport Server port.
--json off Machine-readable output.
--no-color off Disable ANSI colour (NO_COLOR is also honoured).

The built-in public set: Google (2), Cloudflare (2), Quad9, OpenDNS, AdGuard, CleanBrowsing, Verisign, DNS.WATCH, Yandex.

query

dig-like lookup. Several types at once: A,AAAA,MX.

$ dnslens query example.test A,MX -r 127.0.0.1#5401
;; example.test A @ 127.0.0.1#5401  NOERROR  [QR AA RD]  2.6 ms  udp
NAME           TTL  TYPE  DATA
-------------  ---  ----  ----------
example.test.  300  A     192.0.2.11
example.test.  300  A     192.0.2.10

;; example.test MX @ 127.0.0.1#5401  NOERROR  [QR AA RD]  1.4 ms  udp
NAME           TTL  TYPE  DATA
-------------  ---  ----  ---------------------
example.test.  300  MX    10 mail.example.test.

Extra options: --short (data only, ideal for scripts), --dnssec (set the DO bit), --norecurse (clear RD). Exit code is 1 for NXDOMAIN, errors, or no records.

trace

Iterative resolution from the root servers, like dig +trace. Follows referrals, uses glue, resolves glueless nameservers, follows CNAMEs, detects referral loops.

flowchart TD
    S["start at the root servers"] --> Q["ask the current servers<br/>with RD off"]
    Q --> R{"response"}
    R -- "answer for the type" --> D["done: print records"]
    R -- "CNAME only" --> C["restart from the root<br/>for the CNAME target"]
    C --> Q
    R -- "referral: NS + glue" --> P{"zone is deeper<br/>than before?"}
    P -- "no" --> E["error: referral loop<br/>or lame delegation"]
    P -- "yes" --> G{"glue present?"}
    G -- "yes" --> N["use glue addresses"]
    G -- "no" --> L["resolve NS names<br/>with the system resolver"]
    N --> Q
    L --> Q
    R -- "no NS, no answer" --> X["done: NODATA or NXDOMAIN"]
    R -- "all servers timed out" --> F["error: no server answered"]

trace

dnslens trace www.example.com
dnslens trace www.example.com AAAA --json

Each hop shows the zone, the server asked, the round-trip time and the outcome (referral, cname, answer, nodata, nxdomain, error).

propagate

Queries many resolvers in parallel and groups identical answers. Defaults to the public set.

sequenceDiagram
    autonumber
    actor U as You
    participant C as dnslens
    participant P as Thread pool
    participant R1 as Resolver 1
    participant R2 as Resolver 2
    participant RN as Resolver N

    U->>C: propagate app.example.com A --expect 203.0.113.10
    C->>P: one task per resolver
    par in parallel
        P->>R1: A app.example.com?
        R1-->>P: 203.0.113.10
    and
        P->>R2: A app.example.com?
        R2-->>P: 203.0.113.10
    and
        P->>RN: A app.example.com?
        RN--xP: timeout
    end
    P-->>C: results
    C->>C: group answers, ignore unreachable resolvers
    C-->>U: table and verdict, exit code 0 or 1

propagate

$ dnslens propagate example.test A -r alpha=127.0.0.1#5401 -r beta=127.0.0.1#5402 --expect 192.0.2.10 --expect 192.0.2.11
RESOLVER  STATE  ANSWER                  TTL  TIME
--------  -----  ----------------------  ---  ----
alpha     OK     192.0.2.10, 192.0.2.11  300  3 ms
beta      OK     192.0.2.10, 192.0.2.11  300  3 ms

2/2 responding resolvers return the expected answer
Option Meaning
--expect VALUE Expected record data (repeatable). The answer set must equal it.
--strict Unreachable resolvers count as a failure (default: ignored).

Success means: all responding resolvers agree (or match --expect). Round-robin and GeoDNS records legitimately differ between resolvers, so use --expect for those.

wait

Blocks until DNS reaches a state, then exits 0. Exits 1 on timeout. Progress goes to stderr, so stdout stays clean.

flowchart TD
    A["start timer"] --> B["query all selected resolvers"]
    B --> C{"every responding resolver<br/>satisfies the condition?"}
    C -- "yes" --> D["exit 0: condition met"]
    C -- "no" --> E{"elapsed + interval<br/>over max-wait?"}
    E -- "yes" --> F["exit 1: timed out"]
    E -- "no" --> G["sleep interval"]
    G --> B

wait

dnslens wait app.example.com A --expect 203.0.113.10 --public --max-wait 600
dnslens wait old.example.com A --absent                       # wait until the record is gone
dnslens wait example.com TXT --expect "v=spf1 -all" --subset  # value must be present, others allowed
Option Default Meaning
--expect VALUE none Wanted data. Without it, any non-empty answer satisfies.
--subset off Expected values only need to be included in the answer.
--absent off Wait until no records are returned.
--interval SEC 5 Poll interval.
--max-wait SEC 300 Give up after this long.

bench

Latency statistics per resolver, sorted by median.

$ dnslens bench -r alpha=127.0.0.1#5401 -r beta=127.0.0.1#5402 -d example.test -n 20
RESOLVER  OK     LOSS  MIN  AVG  MEDIAN  P95  MAX  ERROR
--------  -----  ----  ---  ---  ------  ---  ---  -----
alpha     20/20  0%    0.6  1.3  1.2     1.9  3.5
beta      20/20  0%    0.6  1.2  1.2     2.0  2.2

times in ms, sorted by median

-d/--domain (repeatable, defaults to five popular domains), -n/--count (default 10), --uncached prepends a random label to each name so you measure real recursion instead of the resolver's cache (NXDOMAIN counts as answered).

audit

A health check for one domain. Every finding has a level: OK, INFO, WARN or FAIL. The exit code is 1 if any FAIL is present.

flowchart TD
    A["audit example.com"] --> B["NS records"]
    B --> B1{"exists?"}
    B1 -- "NXDOMAIN" --> Z["FAIL: domain does not exist"]
    B1 -- "yes" --> C["resolve each NS to A / AAAA"]
    C --> D["ask every NS address directly<br/>SOA with RD off"]
    D --> D1{"reachable?"}
    D1 -- "no" --> D2["FAIL for IPv4, WARN for IPv6"]
    D1 -- "yes" --> D3{"AA flag set?"}
    D3 -- "no" --> D4["FAIL: lame delegation"]
    D3 -- "yes" --> D5["compare SOA serials"]
    D5 --> E["apex CNAME check"]
    E --> F["mail: MX, SPF, DMARC, DKIM selectors"]
    F --> G["CAA"]
    G --> H["DNSSEC: DS, DNSKEY, RRSIG"]
    H --> I["print findings, exit 1 on any FAIL"]

audit

What it checks:

Area Checks
Delegation at least 2 nameservers, every NS has an address, every NS answers authoritatively (lame delegation), IPv6 failures are only warnings
SOA serial identical on all reachable nameservers
Apex CNAME at the zone apex (illegal next to SOA/NS)
SPF present, single record, restrictive ending (-all / ~all / redirect=), flags +all, RFC 7208 10-lookup limit (follows include and redirect)
DMARC present, policy is quarantine or reject (warns on p=none), rua= reporting address
DKIM each --dkim-selector publishes a key
CAA present or not (informational)
DNSSEC see dnssec

Domains without MX only get INFO for missing SPF/DMARC; domains that receive mail get WARN.

$ dnslens audit example.com --dkim-selector google        # illustrative output
OK    ns     2 nameservers: a.iana-servers.net, b.iana-servers.net
OK    soa    serial 2024081201 consistent on all 4 reachable servers
OK    mx     1 MX record(s)
OK    spf    SPF present with a restrictive policy
OK    spf    1/10 DNS lookups
WARN  dmarc  policy p=none (monitoring only)
FAIL  dkim   selector 'google' not found
INFO  caa    no CAA records (any CA may issue certificates)
OK    dnssec 1 DS record(s) published at parent

1 failure(s), 1 warning(s)

dnssec

Zone-level DNSSEC sanity check (also part of audit).

flowchart TD
    A["dnssec example.com"] --> B["query DS at the parent with DO bit"]
    B --> C{"DS exists?"}
    C -- "no" --> D["INFO: zone is not signed"]
    C -- "yes" --> E["query DNSKEY + RRSIG"]
    E --> F{"DNSKEY served?"}
    F -- "no" --> G["FAIL: validating resolvers will SERVFAIL"]
    F -- "yes" --> H{"a DS digest matches<br/>a published DNSKEY?"}
    H -- "no" --> I["FAIL: broken chain of trust"]
    H -- "yes" --> J["OK: DS matches DNSKEY"]
    J --> K["validate the RRSIG over the DNSKEY set"]
    K --> L{"valid?"}
    L -- "no" --> M["FAIL: invalid signature"]
    L -- "yes" --> N{"expires within 3 days?"}
    N -- "yes" --> O["WARN: signatures expire soon"]
    N -- "no" --> P["OK: signatures valid for N days"]

dnssec

It verifies the zone against its parent's DS record and its own signatures. It does not walk the chain of trust up to the root.

reverse

dnslens reverse 8.8.8.8 is a PTR lookup (IPv4 and IPv6). Exit code 2 for an invalid IP.

DNS server: dnslens serve

A small, dependency-light DNS server (UDP and TCP) for development, staging, CI mocks, split-horizon setups and LAN ad-blocking. It answers from local records, blocks listed domains, and forwards everything else to upstream resolvers with a cache.

How it decides

flowchart TD
    IN["datagram or TCP frame"] --> P{"parses as DNS?"}
    P -- "too short" --> DROP["drop"]
    P -- "garbage, 12+ bytes" --> FE["reply FORMERR"]
    P -- "ok" --> ACL{"client allowed<br/>by ACL?"}
    ACL -- "no" --> REF["REFUSED"]
    ACL -- "yes" --> OP{"standard QUERY,<br/>one question, class IN,<br/>not AXFR / IXFR?"}
    OP -- "no" --> REF2["NOTIMP / FORMERR / REFUSED"]
    OP -- "yes" --> BL{"name or parent<br/>on the blocklist?"}
    BL -- "yes" --> BM["NXDOMAIN, or 0.0.0.0 / :: in null mode"]
    BL -- "no" --> LOC{"local record?<br/>exact or wildcard"}
    LOC -- "yes" --> LA["answer with AA flag<br/>follow local CNAMEs"]
    LA --> TAIL{"CNAME target<br/>not local?"}
    TAIL -- "yes" --> FWD
    TAIL -- "no" --> OUT
    LOC -- "no" --> AUTHQ{"under an authoritative<br/>zone?"}
    AUTHQ -- "yes" --> NX["NXDOMAIN (NODATA for empty<br/>non-terminals)"]
    AUTHQ -- "no" --> FQ{"forwarding enabled?"}
    FQ -- "no" --> REF3["REFUSED"]
    FQ -- "yes" --> FWD["cache lookup, then upstream"]
    FWD --> OUT["encode response"]
    NX --> OUT
    BM --> OUT
    OUT --> SZ{"UDP and larger than<br/>client's limit?"}
    SZ -- "yes" --> TC["set TC, client retries over TCP"]
    SZ -- "no" --> SEND["send"]
    TC --> SEND

How it decides

Forwarding and caching

sequenceDiagram
    autonumber
    participant C as Client
    participant S as dnslens serve
    participant K as Cache
    participant U as Upstream resolver

    C->>S: A real.example.com?
    S->>K: lookup (name, type, DO bit)
    K-->>S: miss
    S->>U: A real.example.com? (RD=1)
    U-->>S: 203.0.113.7, TTL 60
    S->>K: store, expires after 60 s
    S-->>C: 203.0.113.7, TTL 60

    Note over C,S: 20 seconds later
    C->>S: A real.example.com?
    S->>K: lookup
    K-->>S: hit, remaining TTL 40
    S-->>C: 203.0.113.7, TTL 40

Forwarding and caching

Negative answers (NXDOMAIN / NODATA) are cached using the SOA minimum (30 s if there is none). SERVFAIL and REFUSED are never cached. If the first upstream fails, the next one is tried; if all fail the client gets SERVFAIL.

Running it

dnslens serve -c zone.json --watch --log-queries            # 127.0.0.1:5353
dnslens serve --record "app.test A 10.0.0.5" --record "app.test TXT hello"
dnslens serve --host 0.0.0.0 --port 53 \
              --blocklist hosts.txt --allow 192.168.0.0/16   # LAN resolver (needs root/Administrator)
dnslens query app.test -r 127.0.0.1#5353                      # test it with the client

zone.json

{
  "ttl": 300,
  "authoritative": ["dev.test."],
  "upstream": ["1.1.1.1", "9.9.9.9"],
  "block": ["ads.example.net"],
  "blocklists": ["hosts.txt"],
  "block_mode": "nxdomain",
  "records": {
    "app.dev.test.": {
      "A": ["10.0.0.5", "10.0.0.6"],
      "TXT": "plain text, quoted for you",
      "AAAA": { "ttl": 30, "data": ["fd00::5"] }
    },
    "*.svc.dev.test.": { "A": "10.1.1.1" },
    "www.dev.test.": { "CNAME": "app.dev.test." }
  }
}

Unknown keys, invalid IPs or a CNAME mixed with other types are rejected at load time with a clear message (and keep the old config when --watch is reloading).

Options

Option Default Meaning
--host / -p, --port 127.0.0.1 / 5353 Bind address and UDP+TCP port
-c, --config FILE none JSON zone/config file
--record 'NAME TYPE DATA' none Add a record inline (repeatable)
--authoritative ZONE none NXDOMAIN for unknown names under ZONE instead of forwarding
--block DOMAIN / --blocklist FILE none Block a domain and its subdomains; hosts-format or one-domain-per-line files
--block-mode nxdomain Or null: answer 0.0.0.0 / ::
--upstream ADDR OS resolvers (never itself), else 1.1.1.1 and 9.9.9.9 Upstream, IP or IP#PORT (repeatable)
--no-forward off Authoritative-only; REFUSE everything else
--cache-size / --max-ttl 10000 / 86400 0 disables the cache; TTL ceiling in seconds
--allow CIDR see below Allowed client networks (repeatable)
--watch off Hot-reload the config and blocklists when the files change
--log-queries / -v off Log every query (client, name, type, rcode, source, ms) / debug logging
--workers 32 UDP worker threads

Behaviour notes

  • Wildcards match at any depth: *.svc.dev.test. also answers a.b.svc.dev.test.
  • A CNAME whose target is not local is resolved through the upstream and appended to the answer, so simple stub resolvers work.
  • Blocking example.net also blocks x.y.example.net. Hosts-file lines like 0.0.0.0 ads.test are understood; localhost style entries are ignored.
  • EDNS is supported (payload capped at 1232). DNSSEC records are forwarded only to clients that set the DO bit.
  • AXFR / IXFR are refused. Statistics are logged on shutdown.

Pointing machines at it

Operating-system resolver settings cannot specify a port, so for system-wide use run on port 53 (see Troubleshooting) or put it behind a forwarder that supports custom ports (dnsmasq server=127.0.0.1#5353, systemd-resolved drop-ins, Windows NRPT). For per-tool testing, dnslens query -r 127.0.0.1#5353 and dig -p 5353 @127.0.0.1 work directly.

Web UI: dnslens serve --web

An AdGuard Home style control panel for the DNS server: live statistics and charts, a searchable query log, blocklist subscriptions, DNS rewrites, runtime settings and the diagnostics from this package, all in the browser. It is a single-page app shipped inside the Python package (plain HTML/CSS/JS, no Node, no build step, no CDN), so it works offline, on Windows and on a Raspberry Pi alike.

dnslens serve --web                                   # DNS on 127.0.0.1:5353, UI on http://127.0.0.1:8080
dnslens serve --web --host 0.0.0.0 --port 53 --web-host 0.0.0.0 --web-cert cert.pem --web-key key.pem
dnslens serve --web --web-password 'a long passphrase' --data-dir /var/lib/dnslens

On the first start a random admin password is generated, printed once in the log and stored hashed (PBKDF2-SHA256, 240k iterations). Change it on the Account page. --web-password (or env DNSLENS_WEB_PASSWORD) sets it explicitly, which is also how you recover a forgotten one.

Pages

Page What you get
Dashboard Query, blocked, cache-hit and rule counters; stacked timeline chart (resolved / blocked / failed) with hover details; top queried domains, top blocked domains, top clients, upstream servers with average latency, query types and response codes; windows of 1 h, 6 h, 24 h, 7 d, 30 d; refreshes every 10 s. One click blocks or allows a domain straight from the top lists.
Query log Every query with time, domain, type, status (Blocked, Cached, Local, Resolved, Failed), answers, client, upstream and latency. Search, status filter, paging, live mode, row details (which rule in which list blocked it), one-click Block / Unblock.
Filters Subscribe to blocklists by URL (presets included), enable/disable, update now, automatic refresh. Custom rules editor (||domain^, @@||domain^). Check a host tells you which rule and list would block a name.
DNS rewrites Point names (wildcards allowed) at an address or another name, with A/AAAA/CNAME/TXT/MX/PTR/NS/SRV/CAA, TTL, enable/disable, edit in place.
Settings Upstream servers (with a connectivity test), timeout, cache size and max TTL, forwarding on/off, block response (NXDOMAIN or 0.0.0.0), query-log on/off, retention, client-IP anonymization, list update interval, clear cache.
Tools The dnslens diagnostics in the browser: lookup, propagation, trace, audit, DNSSEC, benchmark, reverse, optionally through this server.
Account Change username and password (other sessions are signed out).

The top bar has the protection switch (enable, or pause for 10 min / 1 h / 24 h / until re-enabled) and a dark/light theme toggle.

How the pieces connect

flowchart LR
    subgraph CLIENTS["Your network"]
        C1["Laptops, phones, services"]
    end

    subgraph PROC["one dnslens process"]
        DNS["DNS server<br/>UDP + TCP"]
        OBS["Query observer"]
        Q["Writer thread<br/>batched inserts"]
        MGR["Manager<br/>settings, lists, rewrites, rules"]
        WEB["Web server<br/>sessions, JSON API, static UI"]
    end

    DB[("queries.db<br/>SQLite")]
    CFG[("config.json<br/>0600")]
    FLT[("filters/*.txt")]
    UP["Upstream resolvers"]
    BR["Your browser"]

    C1 -->|"DNS queries"| DNS
    DNS -->|"cache miss"| UP
    DNS --> OBS --> Q --> DB
    BR -->|"HTTPS or HTTP"| WEB
    WEB --> MGR
    WEB -->|"statistics, log search"| DB
    MGR -->|"rebuild zone, hot swap"| DNS
    MGR --> CFG
    MGR --> FLT

Query logging never slows DNS down: the DNS path only drops an event into a bounded in-memory queue; a separate thread writes batches to SQLite (WAL mode). If the queue ever overflows, events are dropped and counted rather than blocking answers.

What happens to a query

Rules are applied in this order, and the first match wins:

flowchart TD
    Q["query for NAME"] --> RW{"DNS rewrite or<br/>static record for NAME?"}
    RW -- "yes" --> LOCAL["answer locally<br/>status: Local"]
    RW -- "no" --> PR{"protection enabled?"}
    PR -- "paused" --> FWD
    PR -- "on" --> AL{"allow rule matches NAME<br/>or a parent domain?"}
    AL -- "yes" --> FWD
    AL -- "no" --> BL{"block rule matches NAME<br/>or a parent domain?"}
    BL -- "yes" --> BLOCKED["NXDOMAIN or 0.0.0.0<br/>status: Blocked<br/>log keeps rule + list"]
    BL -- "no" --> FWD["cache, then upstream<br/>status: Cached or Resolved"]
  • Allow beats block, so @@||example.org^ always wins, even against a list. A custom block rule beats a list's allow rule for the same domain.
  • A rule for a domain covers its subdomains. ||ads.example.com^ blocks x.ads.example.com too.
  • Unblocking from the query log adds an @@||domain^ rule; blocking adds ||domain^ (and removes the opposite rule if present).

Blocklist formats

Format Example Notes
AdGuard / ABP ||ads.example.com^ $important accepted; other modifiers ($dnsrewrite, $client, regex, cosmetic rules) are skipped and counted
Allow rule @@||good.example.com^ overrides every block rule
Hosts file 0.0.0.0 ads.example.com localhost style entries ignored
Plain domains ads.example.com one per line

Lists are downloaded over HTTP(S) (max 64 MB each), validated, stored under filters/ and re-downloaded on the interval you choose. A list that fails to download keeps its previous rules and shows the error. Built-in presets: AdGuard DNS filter, StevenBlack unified hosts, Peter Lowe's ad servers.

Sign-in, sessions and CSRF

sequenceDiagram
    autonumber
    actor U as Browser
    participant W as Web server
    participant M as Manager

    U->>W: POST /api/login {username, password}
    W->>W: failed 5 times in 5 min from this IP? answer 429
    W->>M: verify (PBKDF2, constant-time compare)
    M-->>W: ok
    W-->>U: 200 + Set-Cookie session (HttpOnly, SameSite=Strict, Secure on HTTPS)
    U->>W: GET /api/stats (cookie)
    W-->>U: 200 JSON
    U->>W: PUT /api/settings (cookie, X-Requested-With: dnslens, Origin = Host)
    W->>W: custom header + same-origin check, then validate input
    W->>M: apply and persist
    M-->>U: 200 JSON

Data directory

Everything the UI manages lives in one directory (--data-dir; default %APPDATA%\dnslens on Windows, ~/.local/share/dnslens elsewhere). It is created with owner-only permissions because it holds client IPs and your password hash.

config.json    settings, lists, rewrites, custom rules, password hash   (written atomically, 0600)
queries.db     query log + statistics (SQLite, WAL)
filters/N.txt  downloaded blocklists

Static vs managed configuration. -c zone.json, --record, --block, --blocklist and --authoritative still work and are layered under what you manage in the UI (they show up as "static configuration" and are read-only there). Flags such as --upstream, --cache-size, --max-ttl, --no-forward and --block-mode only seed config.json the first time; afterwards the UI's settings win.

Web options

Option Default Meaning
--web off Enable the UI
--web-host / --web-port 127.0.0.1 / 8080 UI bind address and port
--data-dir DIR per-user Where state lives
--web-user / --web-password admin / generated Set credentials (password also via DNSLENS_WEB_PASSWORD)
--web-cert / --web-key none PEM files; serve the UI over HTTPS (TLS 1.2+, HSTS, Secure cookie)

Behind a reverse proxy

Terminate TLS in front and keep the original Host header (the same-origin check compares Origin with Host):

server {
    listen 443 ssl;
    server_name dns.example.com;
    # ssl_certificate / ssl_certificate_key ...
    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Comparison with AdGuard Home

dnslens web UI AdGuard Home
Dashboard, charts, top lists yes yes
Query log with search and filters yes yes
Blocklist subscriptions, custom rules, check host yes (AdGuard/hosts/domain syntax subset) yes (full syntax)
DNS rewrites, wildcards yes yes
Pause protection with a timer yes yes
Upstream test, cache, retention, anonymization yes yes
Built-in diagnostics (propagation, trace, audit, DNSSEC) yes no
Prometheus probes / REST for automation yes (dnslens api) partial
DoH / DoT / DoQ listeners no yes
DHCP server no yes
Per-client settings, parental control, safe search no yes
Install pip install dnslens (Python 3.9+) single binary

dnslens is a good fit for developers, home labs and small networks that already use Python tooling; it is not a drop-in replacement for AdGuard Home's full feature set.

HTTP API: dnslens api

A JSON API over every tool, plus Prometheus-format probes. Pure standard library (http.server), so it has no extra dependencies.

flowchart TD
    REQ["GET /endpoint?params"] --> H{"path"}
    H -- "/healthz" --> OKH["200 status ok<br/>no auth needed"]
    H -- "other" --> AU{"token configured<br/>and valid?"}
    AU -- "no" --> E401["401"]
    AU -- "yes" --> RT{"known route?"}
    RT -- "no" --> E404["404"]
    RT -- "yes" --> SEM{"free slot under<br/>max-concurrent?"}
    SEM -- "no" --> E503["503 busy"]
    SEM -- "yes" --> VAL["validate and clamp parameters"]
    VAL -- "invalid" --> E400["400 with message"]
    VAL -- "ok" --> RUN["run the tool<br/>named resolvers only"]
    RUN --> FMT{"endpoint type"}
    FMT -- "/probe, /metrics" --> PROM["200 Prometheus text"]
    FMT -- "others" --> JSON["200 JSON"]

HTTP API

dnslens api --port 8053 --token "$TOKEN" -r internal=10.0.0.2
curl -H "Authorization: Bearer $TOKEN" \
     "localhost:8053/propagate?name=app.example.com&expect=203.0.113.10"

Endpoints

Endpoint Parameters
/query name, type (comma list, max 5), resolver, dnssec, norecurse, transport, timeout, retries
/propagate name, type, expect (repeatable), strict, resolver (default: public set)
/trace name, type
/audit domain, dkim_selector (repeatable), resolver
/dnssec domain, resolver
/bench domain (repeatable), count (max 50), uncached, resolver
/reverse ip, resolver
/probe kind=query|propagate|audit|dnssec plus that tool's parameters; returns Prometheus text
/metrics API request counters, durations, in-flight gauge, build info
/healthz, / liveness (always open), endpoint index

resolver accepts names: google, cloudflare, quad9, opendns, ..., public, system, or anything registered with -r NAME=ADDR. timeout is clamped to 0.2 to 10 seconds, retries to 0 to 2.

Prometheus monitoring

/probe works like the blackbox exporter: Prometheus passes the check as URL parameters and reads the result as metrics.

sequenceDiagram
    autonumber
    participant P as Prometheus
    participant A as dnslens api
    participant R as Public resolvers
    participant AM as Alertmanager

    loop every scrape interval
        P->>A: GET /probe?kind=propagate&name=app.example.com&expect=203.0.113.10
        A->>R: parallel queries
        R-->>A: answers
        A-->>P: dnslens_probe_success 1<br/>dnslens_probe_resolvers_matching 10 ...
    end
    P->>AM: alert when dnslens_probe_success == 0 for 10m

Prometheus monitoring

# prometheus.yml
scrape_configs:
  - job_name: dns-propagation
    metrics_path: /probe
    params:
      kind: [propagate]
      name: [app.example.com]
      expect: ["203.0.113.10"]
    authorization: { credentials: "<token>" }
    static_configs:
      - targets: ["localhost:8053"]

Metrics returned by /probe:

kind Metrics
all dnslens_probe_success, dnslens_probe_duration_seconds
query dnslens_probe_answer_records, dnslens_probe_dns_seconds
propagate dnslens_probe_resolvers_total, _responding, _matching, dnslens_probe_distinct_answers
audit, dnssec dnslens_probe_failures, dnslens_probe_warnings

Example alert rule:

- alert: DnsRecordNotPropagated
  expr: dnslens_probe_success{job="dns-propagation"} == 0
  for: 10m

API options

Option Default Meaning
--host / -p, --port 127.0.0.1 / 8053 Bind address and port
--token TOKEN env DNSLENS_API_TOKEN Require Authorization: Bearer ... or X-API-Key
-r, --resolver NAME=ADDR none Register named resolvers; they become the defaults
--allow-custom-resolver off Let callers pass arbitrary resolver addresses
-t, --timeout 3 Default per-query timeout
--max-concurrent 16 Concurrent tool requests before 503

Recipes

Gate a deployment on DNS (any CI)

sequenceDiagram
    autonumber
    participant D as Pipeline
    participant T as Terraform / DNS provider
    participant L as dnslens
    participant S as Smoke tests

    D->>T: apply (new A record)
    T-->>D: applied
    D->>L: dnslens wait app.example.com A --expect NEW_IP --public --max-wait 900
    loop until all resolvers match
        L->>L: query, sleep 5 s
    end
    L-->>D: exit 0
    D->>S: run smoke tests against the hostname

Recipes

# GitHub Actions
- run: pip install dnslens
- run: dnslens wait app.example.com A --expect ${{ steps.tf.outputs.ip }} --public --max-wait 900

Mock DNS in tests

Start a throwaway server inside the test run, no root and no network:

import pytest
from dnslens.server import DNSHandler, DNSServer, ServerOptions, build_zone

@pytest.fixture
def dns_server():
    zone = build_zone(records=["api.test A 127.0.0.1", "db.test A 10.0.0.9"])
    handler = DNSHandler(zone, ServerOptions(forward=False))
    with DNSServer(handler, "127.0.0.1", 0) as srv:   # port 0 = any free port
        yield srv.port

Shell and jq

dnslens query example.com A --json | jq -r '.[0].records[].data'
dnslens propagate example.com --json | jq '.groups[] | {values, resolvers}'
dnslens audit example.com --json | jq -e '.ok' >/dev/null || echo "audit failed"
[ "$(dnslens query example.com TXT --short | grep -c spf1)" -eq 1 ]

Find the fastest resolver for this network

dnslens bench --uncached -n 30 --json | jq -r 'sort_by(.median)[0] | "\(.resolver) \(.median) ms"'

Run the server and API as services (Linux, systemd)

# /etc/systemd/system/dnslens-dns.service
[Unit]
Description=dnslens DNS server
After=network-online.target

[Service]
ExecStart=/usr/local/bin/dnslens serve -c /etc/dnslens/zone.json --watch --host 0.0.0.0 --port 53 --allow 192.168.0.0/16
AmbientCapabilities=CAP_NET_BIND_SERVICE
DynamicUser=yes
Restart=on-failure

[Install]
WantedBy=multi-user.target
# /etc/systemd/system/dnslens-api.service
[Service]
Environment=DNSLENS_API_TOKEN=change-me
ExecStart=/usr/local/bin/dnslens api --host 127.0.0.1 --port 8053
DynamicUser=yes
Restart=on-failure

On Windows run them under a service wrapper such as NSSM, or as a scheduled task at startup.

Use as a Python library

Everything the CLI does is importable. All functions return plain dataclasses with a to_dict().

from dnslens.core import PUBLIC_RESOLVERS, QueryOpts, query
from dnslens.tools import propagate, wait
from dnslens.trace import trace
from dnslens.audit import audit
from dnslens.dnssec import check_dnssec

opts = QueryOpts(timeout=2.0, retries=1)

a = query("example.com", "A", "1.1.1.1", opts)
print(a.rcode, a.values(), a.elapsed_ms)          # NOERROR frozenset({'93.184.216.34'}) 12.3

rep = propagate(PUBLIC_RESOLVERS, "example.com", "A", opts, expected=["93.184.216.34"])
print(rep.ok, rep.responders, rep.failed)

ok, _ = wait(PUBLIC_RESOLVERS, "app.example.com", "A", opts,
             expected=["203.0.113.10"], interval=5, max_wait=600)

for f in audit("example.com", None, opts, dkim_selectors=["google"]):
    print(f.level, f.check, f.message)

Main types:

classDiagram
    class Target {
        name
        address
    }
    class QueryOpts {
        timeout
        retries
        transport
        port
        dnssec
    }
    class Answer {
        server
        rcode
        flags
        records
        elapsed_ms
        error
        ok
        values()
    }
    class Record {
        name
        ttl
        type
        data
    }
    class PropagationReport {
        groups
        failed
        consistent
        ok
    }
    class Finding {
        level
        check
        message
    }
    class TraceResult {
        hops
        final
        error
    }
    Answer "1" o-- "*" Record
    PropagationReport "1" o-- "*" Answer
    TraceResult "1" o-- "0..1" Answer
    Target <.. PropagationReport : queried
    QueryOpts <.. Answer : produced with

Main types

Embedding the servers:

from dnslens.server import DNSHandler, DNSServer, ServerOptions, build_zone
from dnslens.api import ApiConfig, ApiServer

zone = build_zone(records=["app.test A 10.0.0.5"])
with DNSServer(DNSHandler(zone, ServerOptions(forward=False)), "127.0.0.1", 5353), \
     ApiServer(ApiConfig(token="secret"), "127.0.0.1", 8053):
    ...  # both are running on background threads

Output formats and exit codes

--json prints one JSON document per command (a list for multi-resolver commands). Example for query:

[
  {
    "server": "1.1.1.1",
    "qname": "example.com",
    "type": "A",
    "rcode": "NOERROR",
    "flags": "QR RD RA",
    "elapsed_ms": 11.4,
    "transport": "udp",
    "error": null,
    "records": [
      { "name": "example.com.", "ttl": 3600, "type": "A", "data": "93.184.216.34" }
    ],
    "resolver": "system"
  }
]
Exit code Meaning
0 success, healthy, condition met
1 check failed: inconsistent or mismatched answers, timeout, FAIL finding, no records, NXDOMAIN, bind error
2 usage or configuration error (bad arguments, invalid zone file, invalid IP)
130 interrupted (Ctrl-C)

Security notes

  • serve binds to loopback by default. Binding anywhere else limits clients to private ranges (RFC 1918, ULA, link-local, CGNAT, loopback) unless you pass --allow. Open resolvers are abused for DDoS amplification, so do not expose it to the internet. AXFR/IXFR are refused. There is no rate limiting.
  • Web UI. Binds to loopback by default; on other addresses it warns when TLS is off. Sign-in uses PBKDF2-SHA256 (240k iterations), constant-time comparison, a lockout after 5 failed attempts in 5 minutes per IP, HttpOnly + SameSite=Strict session cookies (24 h sliding), and sessions are invalidated when the password changes. State-changing requests need a custom header and a same-origin Origin. Responses carry a strict Content-Security-Policy (no inline scripts), X-Frame-Options: DENY, nosniff and no-referrer. All DNS-derived text (domains from clients!) is rendered with textContent, never innerHTML; this is covered by a test that sends a hostile name. List URLs must be http(s); the admin is trusted to choose them, so do not expose the UI to people you would not trust with outbound HTTP requests from this host.
  • api binds to loopback by default and warns when you bind elsewhere without --token. Token comparison is constant-time. Callers can only use resolvers you registered (plus the public set) unless --allow-custom-resolver is set, which prevents using it to send packets to arbitrary hosts. Inputs are validated and clamped, and concurrency is capped. It speaks plain HTTP: put it behind a TLS reverse proxy if it leaves localhost.
  • Zone and blocklist files are data only; nothing is executed.
  • Reports from audit and propagate reveal what public DNS says about a domain. They contain nothing secret, but the API makes your host perform those queries on request.

Troubleshooting

Symptom Cause and fix
cannot bind 0.0.0.0:53: permission denied Ports below 1024 need root/Administrator. Linux alternatives: sudo sysctl net.ipv4.ip_unprivileged_port_start=53, or systemd AmbientCapabilities=CAP_NET_BIND_SERVICE, or run on 5353 and redirect.
cannot bind ...: address already in use on Linux systemd-resolved owns 127.0.0.53:53. Bind a specific address (--host 192.168.1.5) or disable its stub listener.
Port 53 in use on Windows Internet Connection Sharing or Hyper-V may hold it. Find it with netstat -ano | findstr :53 and stop the service, or use another port.
propagate says INCONSISTENT for a CDN hostname Round-robin / GeoDNS answers differ legitimately. Use --expect with the full set, or --subset with wait.
Some public resolvers show FAIL timeout Your network blocks UDP/53 to them. They are ignored unless --strict. Try --transport tcp or tls.
audit reports an unreachable IPv6 nameserver Only a WARN: the host may lack IPv6 connectivity.
Forgot the web UI password Restart with --web-password 'new passphrase' (it is stored hashed). The data directory keeps everything else.
Dashboard is empty Query logging is switched off in Settings, or no client uses this server yet. Test with dnslens query example.com -r 127.0.0.1#5353.
A blocklist cannot be downloaded The host has no outbound HTTP(S), or the URL is wrong. The error is shown on the Filters page; the list keeps its old rules.
UI says cross-origin request refused behind a proxy Pass the original Host header (proxy_set_header Host $host;).
--transport https fails with NoDOH Install the extra: pip install "dnslens[doh]".
Colours look broken in a log file Colour is only used on a TTY. Set NO_COLOR=1 or pass --no-color.
Server answers REFUSED Your client is outside --allow, or --no-forward is set and the name is not local.

Limitations

  • dnssec checks the zone against its parent DS and its own signatures; it does not validate the chain up to the root, and serve does not perform DNSSEC validation itself (it passes validation results from the upstream through).
  • serve is not a full recursive resolver or authoritative server: no zone transfers, no dynamic updates, no rate limiting, no DoT/DoH listener, no negative-cache SOA synthesis for local NODATA.
  • Wildcards match at any depth (a deliberate, dnsmasq-like simplification of RFC 4592).
  • trace uses a built-in list of root server addresses and IPv4 glue first; an IPv6-only network may need the system resolver for glueless delegations.
  • Zone files are JSON, not RFC 1035 master files.
  • The web UI is a single-admin panel: no multi-user roles, no per-client rules, no DHCP, no DoH/DoT/DoQ listeners. Blocklist support covers the DNS-relevant subset of AdGuard syntax (see the table above).
  • The query log lives in SQLite; for very busy resolvers (millions of queries a day) use a short retention.

Development and releasing

git clone https://github.com/cumulus13/dnslens && cd dnslens
pip install -e ".[dev]"
pytest && ruff check src tests && mypy src

The tests start an in-process fake DNS server on localhost and need no network. CI (on pushes to main/master and on pull requests) runs lint, type checks and the tests on Ubuntu, Windows and macOS for Python 3.9 to 3.13, plus a web UI smoke test.

# optional: UI smoke test (needs Node 20+)
cd tests/ui && npm install && cd ../.. && bash tests/ui/run.sh
src/dnslens/
  core.py      query primitive, targets, Answer / Finding types
  tools.py     propagate, wait, bench
  trace.py     iterative resolution from the root
  audit.py     domain health audit (SPF, DMARC, DKIM, delegation, SOA)
  dnssec.py    DS / DNSKEY / RRSIG check
  server.py    DNS server, zone data, cache, reloader
  api.py       HTTP API, probes, metrics
  manage.py    runtime-managed settings, lists, rewrites, accounts (web UI back end)
  web.py       web server: sessions, CSRF, JSON API for the UI
  querylog.py  SQLite query log + statistics
  filters.py   blocklist parsing and subscriptions
  webui/       the single-page app (index.html, app.js, charts.js, style.css)
  cli.py       argparse front end
  output.py    tables, colour (Windows-safe)
tests/         fake-server based tests
tests/ui/      browser-less (jsdom) smoke test of the web UI against a live stack
.github/workflows/  ci.yml, release.yml

Release pipeline

flowchart LR
    T["git tag v1.2.3<br/>git push --tags"] --> B1
    subgraph BUILD["build job"]
        B1["tag matches<br/>pyproject version?"] --> B2["run tests"] --> B3["python -m build"] --> B4["twine check --strict"]
    end
    B4 --> PUB["publish-pypi job<br/>environment: pypi<br/>OIDC trusted publishing"]
    PUB --> PYPI[("PyPI")]
    PUB --> GH["github-release job<br/>attach wheel and sdist"]
    M["manual run of the workflow"] --> B1
    B4 -. "manual run only" .-> TP["publish-testpypi job"]
    TP --> TPI[("TestPyPI")]

Release pipeline

  1. Bump version in pyproject.toml, update CHANGELOG.md, commit.
  2. git tag v1.2.3 && git push --tags. The workflow refuses to publish if the tag and the package version differ.
  3. One-time setup: on PyPI add a pending publisher (project dnslens, owner cumulus13, repository dnslens, workflow release.yml, environment pypi) and create the GitHub environment pypi. Repeat on test.pypi.org with environment testpypi for dry runs (run the Release workflow manually). No API tokens are stored anywhere.

License

MIT. See LICENSE.

👤 Author

Hadi Cahyadi

Buy Me a Coffee

Donate via Ko-fi

Support me on Patreon

Metadata

Release files for dnslens 1.0.3

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for dnslens 1.0.3
File Size Uploaded Provenance Actions
dnslens-1.0.3.tar.gz 131.1 kB With provenance Details for dnslens-1.0.3.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for dnslens 1.0.3
File Interpreter ABI Platform Actions
dnslens-1.0.3-py3-none-any.whl Python 3 none any Details for dnslens-1.0.3-py3-none-any.whl

Total release size: 221.3 kB

Release files / dnslens-1.0.3.tar.gz

Download URL dnslens-1.0.3.tar.gz
Size 131.1 kB
Tags Source
SHA-256 checksum
How to use checksums
f5efaff0d868ccdf0328f858547d04f4cc7f307d08bd167e4280452774577584
BLAKE2b-256 checksum
How to use checksums
a720d075c88e0b4e96fca66878bf41f275bec8ff894e118c819bf85214fa8577
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 2, 2026.

Transparency log

Release files / dnslens-1.0.3-py3-none-any.whl

Download URL dnslens-1.0.3-py3-none-any.whl
Size 90.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
43e86c47b69105bfe3d4db07515dc55aa8c9ececb2cfaacb868b083dc2d4a041
BLAKE2b-256 checksum
How to use checksums
fb5477f4a811d5d102d192133fba39e4306224eb829ab37ce39a7ce2681514cb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 2, 2026.

Transparency log

Security for dnslens 1.0.3

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for dnslens-1.0.3.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 2, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

1.0.3 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page