cenko.tech

dnsmon: watching DNS records from your own machine

A small Python CLI that checks DNS records against several resolvers and alerts on failures, changed answers, unexpected values and slow lookups.

dns-monitoring installs a command called dnsmon. You give it a list of DNS records you care about and the resolvers to ask, and it tells you when a lookup fails, when the answer changes, when the answer is not the one you expected, or when a resolver takes too long. It runs on your own machine. There is no server to set up and no account to create.

This is a real run of dnsmon check with the example config it generates:

$ dnsmon check
CHECK           RESOLVER  STATUS  LATENCY  ANSWERS / ERROR
example.com A   system    SLOW    1528 ms  104.20.23.154, 172.66.147.243
example.com A   1.1.1.1   OK      39 ms    104.20.23.154, 172.66.147.243
example.com A   8.8.8.8   OK      43 ms    104.20.23.154, 172.66.147.243
Cloudflare DNS  system    SLOW    1505 ms  1.0.0.1, 1.1.1.1
Cloudflare DNS  1.1.1.1   OK      37 ms    1.0.0.1, 1.1.1.1
Cloudflare DNS  8.8.8.8   OK      44 ms    1.0.0.1, 1.1.1.1
gmail.com MX    system    SLOW    1523 ms  10 alt1.gmail-smtp-in.l.google.com, 20 alt2.gmail-smtp-in.l.google....
gmail.com MX    1.1.1.1   OK      43 ms    10 alt1.gmail-smtp-in.l.google.com, 20 alt2.gmail-smtp-in.l.google....
gmail.com MX    8.8.8.8   OK      48 ms    10 alt1.gmail-smtp-in.l.google.com, 20 alt2.gmail-smtp-in.l.google....
google.com NS   1.1.1.1   OK      45 ms    ns1.google.com, ns2.google.com, ns3.google.com, ns4.google.com

[WARNING] SLOW example.com A @ system: answered in 1528 ms (threshold 500 ms)
[WARNING] SLOW Cloudflare DNS @ system: answered in 1505 ms (threshold 500 ms)
[WARNING] SLOW gmail.com MX @ system: answered in 1523 ms (threshold 500 ms)

The public resolvers answer in about 40 ms. The one my operating system is configured to use takes 1.5 seconds for every lookup. I’ll come back to why.

Try it#

It needs Python 3.11 or newer. The only dependency is dnspython.

git clone https://github.com/0xgitpushpray/dns-monitoring.git
cd dns-monitoring
python -m venv .venv
source .venv/bin/activate      # Windows: .venv\Scripts\activate
pip install -e .

Then:

dnsmon init       # writes an example config.toml
dnsmon check      # run every check once
dnsmon watch      # keep checking every `interval` seconds
dnsmon history    # recent results (add --events for alerts only)

The config#

Everything lives in one TOML file. Each [[checks]] entry is a domain and a record type. expect is optional and pins the answer you want. resolvers is optional and overrides the global list for that one check.

interval = 60
timeout = 3.0
latency_warn_ms = 500
resolvers = ["system", "1.1.1.1", "8.8.8.8"]
database = "dnsmon.db"
retention_days = 30

[[checks]]
name = "My website"
domain = "mysite.com"
type = "A"
expect = ["203.0.113.10"]

[[checks]]
domain = "mysite.com"
type = "MX"
resolvers = ["1.1.1.1"]

"system" means the DNS servers your OS uses. Anything else has to be an IPv4 or IPv6 address.

The config is strict on purpose. Unknown keys are an error, so a typo stops the run instead of being quietly ignored:

$ dnsmon check -c bad.toml
error: checks[1]: unknown setting(s): tpye

A monitor that silently skips the expect you misspelled is worse than no monitor, because you think you are covered.

Comparing answers#

DNS answers and the values people type into a config file are not written the same way. An IPv6 address can be written several ways, names can come back with a trailing dot and in any case, and the order of records is not guaranteed. So both sides go through the same normalize() function before they are compared:

def normalize(rtype: str, value: str) -> str:
    value = value.strip()
    if rtype in ("A", "AAAA"):
        return str(ipaddress.ip_address(value))
    if rtype in NAME_TYPES:
        return value.lower().rstrip(".")
    return value

NAME_TYPES is CNAME, DNAME, MX, NS, PTR and SRV, the types whose values contain host names. Answers are then deduplicated and sorted, so ["1.1.1.1", "1.0.0.1"] in the config matches whatever order the resolver returns. Because the expect values are normalized while the config loads, an invalid IP address there is a config error, not a mismatch at 3 a.m.

Alerting once, not every minute#

The part I spent the most time on is when to alert. If a check is broken and dnsmon watch runs every 60 seconds, the naive version sends 60 alerts an hour about the same problem. dnsmon stores every result, and each new result is compared with the previous one for the same check and resolver. Events fire on transitions:

status = classify(check, result)   # ok, fail or mismatch
prev_status = previous.status if previous else None
if status != prev_status:
    if status == FAIL:
        emit("critical", "FAIL", f"lookup failed: {result.error}")
    elif status == MISMATCH:
        emit("critical", "MISMATCH", ...)
    elif previous is not None:
        emit("info", "RECOVERED", ...)
elif status != FAIL and result.answers != previous.answers:
    emit("warning", "CHANGED", ...)

A check that goes down alerts once, stays quiet while it is down, and alerts again when it recovers. Here I pinned Cloudflare’s resolver to a wrong second address, ran check twice, then fixed the config and ran it a third time:

$ dnsmon check -c demo.toml
CHECK           RESOLVER  STATUS    LATENCY  ANSWERS / ERROR
Cloudflare DNS  1.1.1.1   MISMATCH  37 ms    1.0.0.1, 1.1.1.1

[CRITICAL] MISMATCH Cloudflare DNS @ 1.1.1.1: expected [1.0.0.2, 1.1.1.1], got [1.0.0.1, 1.1.1.1]

$ dnsmon check -c demo.toml
CHECK           RESOLVER  STATUS    LATENCY  ANSWERS / ERROR
Cloudflare DNS  1.1.1.1   MISMATCH  35 ms    1.0.0.1, 1.1.1.1

$ dnsmon check -c demo.toml
CHECK           RESOLVER  STATUS  LATENCY  ANSWERS / ERROR
Cloudflare DNS  1.1.1.1   OK      35 ms    1.0.0.1, 1.1.1.1

[INFO] RECOVERED Cloudflare DNS @ 1.1.1.1: back to normal: [1.0.0.1, 1.1.1.1]

The second run still shows MISMATCH in the table and still exits with code 1, but it does not raise a new event. SLOW follows the same rule: it fires when a resolver goes from fast to slow, not on every slow answer.

CHANGED is the one I find most useful without any expect at all. It fires when the answer differs from the previous lookup, which is how you notice a record being moved, a migration you did not know about, or someone editing your DNS.

History in SQLite#

Results and events go into a local SQLite file with two tables, results and events. That is what makes the transition logic work across separate runs. dnsmon check from cron every five minutes compares against the last stored result exactly like dnsmon watch does, so you do not need a long-running process to get change detection.

dnsmon history reads it back:

$ dnsmon history --events
TIME                 LEVEL    KIND  CHECK           RESOLVER  MESSAGE
2026-10-14 20:01:26  WARNING  SLOW  example.com A   system    answered in 1528 ms (threshold 500 ms)
2026-10-14 20:01:26  WARNING  SLOW  Cloudflare DNS  system    answered in 1505 ms (threshold 500 ms)
2026-10-14 20:01:26  WARNING  SLOW  gmail.com MX    system    answered in 1523 ms (threshold 500 ms)

Old rows are deleted after retention_days (30 by default, 0 keeps everything), so the file does not grow forever.

Why the system resolver was slow#

Back to the 1.5-second lookups. On Windows, the OS resolver list often contains servers from network adapters that are not doing anything: VPN clients, Hyper-V or WSL switches. dnspython tries them in order, and the dead ones have to time out before it gets to one that answers.

dnsmon cannot fix that, but it makes sure every server gets a turn within the timeout, by splitting the time budget across the configured servers:

res.timeout = timeout / max(1, len(res.nameservers))
res.lifetime = timeout

And when the system resolver does time out, the error lists the servers it tried, so you can see which one is stale. If you want clean latency numbers, put your real DNS server’s IP in resolvers instead of "system".

Alerts outside the terminal#

Set webhook_url (or the DNSMON_WEBHOOK_URL environment variable) and events are also POSTed to a Slack or Discord webhook. Slack reads a text field and Discord reads content, and each ignores the field it does not know, so the same body works for both:

body = json.dumps({"text": text, "content": text}).encode()

Messages are cut at 1,900 characters because Discord rejects anything over 2,000. A failed delivery is logged as a warning and does not stop the run.

For scripts, dnsmon check exits with 1 when any lookup fails or mismatches, 2 on a config error, and --json prints every result with its events. A lookup that is only slow still exits 0, which is why the first run above passed.

Tests#

52 tests with pytest, and none of them touch the network. Most of the monitor tests call evaluate() directly with a result and a previous record, which covers the transition rules: the first failure alerts, a repeated one does not, then recovery, changed answers, mismatches and slow answers. run_cycle() takes the query function as a parameter, so the end-to-end test passes in a fake one. The resolver tests feed dnspython’s exceptions in to check the error messages, the config tests cover the validation errors, the store tests use a temporary SQLite file, and the CLI tests check the exit codes. GitHub Actions runs them on Linux and Windows with Python 3.11, 3.12 and 3.13.

pip install -e ".[dev]"
pytest

cd ~/blog