Auditing every DNS record you manage across customer domains
Two paged reads give you the whole estate. The five findings worth flagging, and the drift check that catches a record somebody deleted by hand.
Once you’re managing custom domains for more than a handful of customers, nobody knows what’s in DNS any more. Infrai gives you the whole estate in two reads: GET /v1/dns/domain/list for every zone and GET /v1/dns/record/list per zone for its records. Both are free, and together they’re the inventory that tells you which customer onboardings are half-finished.
The audit worth running isn’t a listing. It’s five specific findings.
The estate
curl -sS "https://api.infrai.cc/v1/dns/domain/list" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"domains": [
{"zone_id": "3a9d545444dc7a02e085889a3f713078", "domain": "northwind.example",
"state": "active", "name_servers": ["lilyana.ns.cloudflare.com", "margo.ns.cloudflare.com"]},
{"zone_id": "a42e8a49552461117f536b4afc780425", "domain": "acme-trial.example",
"state": "pending", "name_servers": ["lilyana.ns.cloudflare.com", "margo.ns.cloudflare.com"]}
]
}
}
state is the first finding already. A zone still pending weeks after it was added is a customer who started onboarding and never finished — and nobody told you, because nothing was watching.
The five findings
| Finding | How to detect it | Why it matters |
|---|---|---|
Zone pending for over a week | state plus your own added-at date | a stalled onboarding nobody chased |
| Zone with no records | empty records list | added, never configured |
| Record pointing somewhere unexpected | content not in your known targets | stale after an infrastructure change |
| Duplicate at one name (single-valued types) | group by (type, name) | resolvers pick; a CNAME duplicate is always wrong |
| Missing authentication records | no SPF/DKIM TXT on a sending domain | mail going out unauthenticated |
The last one is the expensive one.
A customer whose DKIM record got deleted during an unrelated cleanup keeps sending mail, and it keeps being accepted for a while, and then it starts landing in spam — and because nothing failed loudly at any point, the first signal you get is them complaining that your product’s emails don’t arrive, which is a conversation that starts three weeks after the record went missing.
The audit
import os
from collections import defaultdict
from datetime import datetime, timezone
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
KNOWN_TARGETS = {"edge.yourapp.example", "tracking.infrai.cc"}
SINGLE_VALUED = {"CNAME"}
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
def zones() -> list[dict]:
resp = SESSION.get(f"{API}/v1/dns/domain/list", timeout=30)
resp.raise_for_status()
return resp.json()["data"].get("domains", [])
def records(zone_id: str) -> list[dict]:
resp = SESSION.get(f"{API}/v1/dns/record/list", params={"zone_id": zone_id}, timeout=25)
if resp.status_code == 404:
return []
resp.raise_for_status()
return resp.json()["data"].get("records", [])
def audit(sending_domains: set[str]) -> list[dict]:
"""One pass over the estate. Every finding names the zone and is actionable in
one call, which is the difference between an audit and a wall of text."""
findings = []
for zone in zones():
zone_id, domain, state = zone["zone_id"], zone["domain"], zone.get("state")
rows = records(zone_id)
if state != "active":
findings.append({"zone": domain, "kind": "zone_not_active", "detail": state})
if not rows:
findings.append({"zone": domain, "kind": "zone_empty", "detail": "no records"})
grouped = defaultdict(list)
for row in rows:
grouped[(row["record_type"], row["name"])].append(row)
target = row.get("content") or ""
if row["record_type"] in ("CNAME",) and target not in KNOWN_TARGETS:
findings.append({"zone": domain, "kind": "unexpected_target",
"detail": f"{row['name']} -> {target}"})
for (record_type, name), rows_at_name in grouped.items():
if record_type in SINGLE_VALUED and len(rows_at_name) > 1:
findings.append({"zone": domain, "kind": "duplicate_record",
"detail": f"{record_type} {name} x{len(rows_at_name)}"})
if domain in sending_domains:
txt = [r for r in rows if r["record_type"] == "TXT"]
if not any("v=spf1" in (r.get("content") or "") for r in txt):
findings.append({"zone": domain, "kind": "missing_spf", "detail": "no v=spf1 TXT"})
if not any("_domainkey" in (r.get("name") or "") for r in txt):
findings.append({"zone": domain, "kind": "missing_dkim", "detail": "no _domainkey TXT"})
return findings
if __name__ == "__main__":
at = datetime.now(timezone.utc).isoformat(timespec="seconds")
for finding in audit({"mail.northwind.example"}):
print(f"{at} {finding['kind']:<20} {finding['zone']:<28} {finding['detail']}")
Catch drift, not just mistakes
An audit finds what’s wrong now. A drift check finds what changed, which is more useful because DNS records mostly break by being deleted rather than by being written badly.
Keep your desired state in code, compare it to the estate, and report the difference. Anything present-but-different is drift; anything wanted-but-absent is a record somebody removed. Both are one PUT /v1/dns/record/upsert from being fixed, which is why the audit is worth automating even if nobody reads its output on a quiet week.
curl -sS -X POST "https://api.infrai.cc/v1/cron/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "dns-estate-audit",
"cron_expr": "0 6 * * 1",
"task": "https://ops.example.com/hooks/dns-audit",
"timeout_seconds": 600,
"on_failure_webhook": "https://ops.example.com/hooks/cron-failed"
}'
Weekly is enough for an estate audit; the drift check on authentication records deserves daily, because the cost of a day of unauthenticated mail is higher than the cost of a call.
Then confirm it’s actually running with GET /v1/cron/runs/list/{id}. An audit job that stopped firing is indistinguishable from a clean estate, and the two get confused exactly once.
Send the findings somewhere a person looks
An audit that writes to stdout in a container nobody tails isn’t an audit. Put the findings where they’ll be seen: POST /v1/logs/ingest for the searchable record, POST /v1/metrics/report for a count you can chart and alert on, and POST /v1/email/send for the weekly digest.
All three are the same credential as the DNS reads, which is the practical argument for keeping this on one platform — the audit needs no new account, no second token in the job’s environment, and its own cost shows up in the same GET /v1/account/usage as the records it’s checking.
Limitations
GET /v1/dns/domain/list has no filtering or tenant dimension, so mapping zones to customers depends on the metadata you set at POST /v1/dns/domain/add — or on your own store. Set it at creation time; retrofitting it means guessing from domain names.
There’s no change history either: you can see the current state of a record, not who changed it or when, so drift detection has to come from comparing against your own desired state rather than from an audit log the platform keeps. Going direct to Cloudflare, Route 53 or Google Cloud DNS gets you provider-level audit logs, which is a fair reason to prefer one of them if change forensics matters more than consolidation.
DNS routes report billing_class: free in discovery (verified 2026-09-21), so auditing the whole estate costs nothing regardless of how often you run it, and platform rates drift downward as vendor contracts improve.