TL;DR: For a logistics admin console that must produce dated mail-DNS evidence, query an authoritative server and archive a normalized, hashed snapshot. A recursive resolver is easier, but its cache can make the report describe an intermediary's view rather than the zone's current published state. Use recursive lookups only for discovery and for a separate outside-in check. The archive is evidence of an observation, not proof that a record existed for the whole day.
| Choice | Evidence quality | Setup cost | Best fit |
|---|---|---|---|
| Authoritative snapshot | Direct observation of published RRsets | NS discovery, address resolution, retry logic | Scheduled compliance capture |
| Recursive snapshot | Resolver-dependent, possibly cached observation | Very low | User-path diagnostics |
Pick authoritative snapshots for the archive. Keep the recursive result as a second signal, clearly labeled with the resolver and observation time. This recommendation applies when deliverability evidence matters more than reproducing what one office or carrier happened to resolve.
How should an unattended job produce a dated DNS configuration report?
A dated report needs to answer a dull but important question: what did the collector observe, from where, and when? A JSON dump with a date in its filename answers only the last part, and even that date came from the machine running the job.
Recursive DNS inserts cache state between the collector and the authoritative publication point. That behavior is useful for normal applications. It is awkward for evidence. Two jobs can see different answers when they use different recursive resolvers or reach different cache states.
Cache state wins.
The evidence model should be explicit. Record the UTC observation time, queried name, requested type, responding authoritative server, normalized answer, and collection result. Hash the canonical payload after sorting it. Store the payload and hash together in append-only storage under a date-based key.
Do not call this a full zone archive unless it really is one. A deliverability report normally captures selected public RRsets: MX, apex TXT for SPF discovery, _dmarc TXT, relevant DKIM selector names, and the zone's NS and SOA data. That targeted snapshot is useful and honest about its boundary.
RFC 7489 defines DMARC policy as a DNS TXT record at the _dmarc subdomain and describes aggregate reporting through the rua tag. The snapshot preserves the published policy record. It does not replace DMARC aggregate reports or prove that receiving systems applied the policy.
The two criteria that decide it
The first criterion is provenance. An auditor should be able to distinguish a direct query to an authoritative server from a query through an unspecified cache. Preserve the server IP as well as the NS hostname used during discovery. Preserve negative answers as structured results, too. An absent _dmarc record is evidence worth retaining; an unreachable nameserver is a collection failure, not an absent record. Mixing those states destroys the report's value.
That distinction matters.
The second criterion is reproducibility. Byte-for-byte equality should depend on the observation, not object insertion order or DNS answer order. Sort record values. Normalize TXT chunks into complete strings before sorting. Serialize a stable structure, then hash those exact bytes.
Small details dominate this job. One trailing space in a TXT value can change a digest. Flattening every value to an untyped string can erase MX priority. Silently turning a timeout into an empty array can manufacture evidence that a record was missing. I treat those as schema bugs, not DNS trivia.
A timestamp generated by the collector is still only the collector's assertion. The SHA-256 digest detects later payload changes when the digest is anchored somewhere the archive writer cannot rewrite. Neither feature independently proves trusted time. If the requirement calls for externally verifiable time, add an approved timestamp or signing service at the storage boundary and document that trust chain.
The recommended approach has a real limitation: direct authoritative queries add network-policy work, retry logic, IPv4 and IPv6 handling, and another failure mode to the scheduled job. They are not suitable when the report's actual purpose is to reproduce a particular employee, depot, or carrier resolver's cached view. In that case, choose the recursive alternative and identify the resolver in every artifact. The trade-off is weaker publication provenance in exchange for a faithful client-path observation. For strict audit capture, a blocked direct-DNS path should fail visibly; quietly switching modes makes two unlike evidence streams look comparable. A team that cannot operate those retries and labels reliably is better off producing a smaller, clearly scoped recursive report than an authoritative report whose failures are hidden.
A compact unattended collector
This TypeScript example discovers the zone's NS names through the default recursive path, resolves their addresses, selects one address deterministically, then sends evidence queries to that server. In production, retry the remaining authoritative addresses on transport failure and record every attempt. Do not fall back silently to recursion.
The tracked names are configuration because DKIM selectors are deployment-specific. Config bloat starts when a collector guesses them.
import { Resolver } from "node:dns/promises";
import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
type Query = { name: string; type: "MX" | "TXT" | "NS" | "SOA" };
type Result = { name: string; type: Query["type"]; status: "present" | "absent"; values: string[] };
const zone = "parcel.example";
const queries: Query[] = [
{ name: zone, type: "MX" },
{ name: zone, type: "TXT" },
{ name: `_dmarc.${zone}`, type: "TXT" },
{ name: `outbound._domainkey.${zone}`, type: "TXT" },
{ name: zone, type: "NS" },
{ name: zone, type: "SOA" },
];
function isAbsent(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException).code;
return code === "ENODATA" || code === "ENOTFOUND";
}
async function query(resolver: Resolver, item: Query): Promise<Result> {
try {
let values: string[];
if (item.type === "MX") {
values = (await resolver.resolveMx(item.name)).map(
({ priority, exchange }) => `${priority} ${exchange}`,
);
} else if (item.type === "TXT") {
values = (await resolver.resolveTxt(item.name)).map((chunks) => chunks.join(""));
} else if (item.type === "NS") {
values = await resolver.resolveNs(item.name);
} else {
const row = await resolver.resolveSoa(item.name);
values = [[row.nsname, row.hostmaster, row.serial, row.refresh, row.retry, row.expire, row.minttl].join(" ")];
}
return { ...item, status: "present", values: values.sort() };
} catch (error) {
if (isAbsent(error)) return { ...item, status: "absent", values: [] };
throw error;
}
}
const bootstrap = new Resolver();
const nsNames = (await bootstrap.resolveNs(zone)).sort();
const addressSets = await Promise.all(nsNames.map((name) => bootstrap.resolve4(name)));
const serverIp = addressSets.flat().sort()[0];
if (!serverIp) throw new Error(`No IPv4 address found for authoritative servers of ${zone}`);
const authoritative = new Resolver();
authoritative.setServers([serverIp]);
const results = await Promise.all(queries.map((item) => query(authoritative, item)));
results.sort((a, b) => `${a.name}:${a.type}`.localeCompare(`${b.name}:${b.type}`));
const observedAt = new Date().toISOString();
const payload = {
schemaVersion: 1,
zone,
observedAt,
source: { mode: "authoritative", serverIp, discoveredNs: nsNames },
results,
};
const canonical = JSON.stringify(payload);
const sha256 = createHash("sha256").update(canonical).digest("hex");
const artifact = JSON.stringify({ payload, sha256 }, null, 2) + "\n";
const directory = join("dns-evidence", zone, observedAt.slice(0, 10));
await mkdir(directory, { recursive: true });
await writeFile(join(directory, `${observedAt.replaceAll(":", "-")}.json`), artifact, { flag: "wx" });
flag: "wx" prevents this process from overwriting an existing artifact. It does not make the directory append-only against other processes or administrators. Enforce retention and immutability in the storage system, outside this script.
The code makes one simplifying choice: IPv4 transport to an authoritative server. A production collector should support authoritative server IPv6 addresses, bounded timeouts, retries across servers, and a top-level failed-run artifact. Keep partial results, but never label a partial run complete.
What belongs in the logistics report?
A logistics operator can have several mail streams: shipment notifications, exception alerts, return labels, and internal operations mail. The report should map each tracked domain and DKIM selector to an owner and a sending purpose. DNS alone cannot infer either one. Put that inventory beside the collector configuration, review it when a sending system changes, and version it.
No guessing.
The admin console should show the latest complete capture and its predecessor as a semantic diff. Added and removed TXT values matter. So do MX priority changes, SOA serial movement, a policy moving from monitoring to enforcement, and a reporting destination change inside a DMARC record. Keep raw strings in the evidence artifact even if the UI parses them into fields. Parsers change; source evidence should not.
Schedule captures around the compliance question. A daily archive supports a daily claim. It cannot establish the exact minute of a short-lived change between runs. Capture immediately before and after an approved DNS change as well, attach the change identifier as separate metadata, and leave the scheduled cadence running.
Measure the collector, too. Useful operational counters include completed runs, failed runs, partial runs, query latency by authoritative server, and consecutive absence for each required RRset. Alert on collection failure separately from policy drift. Otherwise a network problem can masquerade as a domain configuration incident.
When is the runner-up better?
Use a recursive snapshot when the question is explicitly outside-in: what might an application using this resolver see right now? It is also pragmatic when network policy blocks direct queries to authoritative servers. Record the recursive server address and label the artifact recursive; do not merge it into the authoritative series.
During a DNS change, comparing authoritative and recursive observations exposes propagation behavior that either view alone hides. The pair is diagnostic evidence. It still does not justify replacing the authoritative archive with cached answers.
The decision rule stays simple: archive direct authoritative observations for publication evidence, and collect recursive observations for client-path behavior. If the system cannot state which path produced a result, the report is not ready for an audit.
Top comments (0)