DEV Community

CorneliusHayes8579
CorneliusHayes8579

Posted on

A Node.js Read-Back Audit for MX Drift Across Registrar-Specific DNS APIs

Point the company's mail at one provider and the outcome you need is narrow: the MX records resolvers actually hand out have to match the MX records you intended. Use the least complex thing that gets you there — move every domain's nameservers to a single DNS host, keep each registrar for registration and renewal, and drive records through one interface instead of three registrar-specific APIs. Then verify by reading the zone back from the authoritative servers, because a 200 from a DNS API describes a write that was accepted, not an answer a resolver will give.

Approach Where drift hides Cost to migrate Fits when
One HTTP client per registrar API N record models, N sets of normalisation quirks Nothing up front, compounding forever Two domains, one team, no growth
Your own adapter layer over those APIs Same quirks, now behind types you maintain Weeks, plus a test matrix you own Delegation can't move for contractual reasons
Declarative zone state in git, applied by a reconciler Console edits made between runs Days, plus a CI job Record changes need review like code does
Delegate NS to one DNS host, one record API One model, one place to audit Hours per domain, days of TTL caution Most mail migrations, including this one

Say the estate is a freight brokerage: 40 domains, most of them dormant carrier brands picked up in acquisitions, four that carry real mail, two serving customer tracking pages. The last row is the one I'd take. Delegation is one cheap change at each registrar, and once every zone is served by the same nameservers, "what does the world actually see" collapses from a per-vendor guessing game into a single query you can run on a timer.

Drift is the thing you're buying protection against, so it's worth being precise about where it comes from.

Where drift comes from when nobody changed anything

The most common source isn't code at all. Someone in finance opens the registrar's web console to fix a billing contact, notices the MX record for a brand nobody uses, and tidies it. No API call, no audit trail you control, no diff in your repo. Your intent file still says what it always said.

Second most common, and far more expensive: the zone you're writing to isn't the zone being served. You onboard a domain, point your tooling at the new provider's record API, get clean 200s all afternoon, and nothing changes in the world — because the NS records at the parent still delegate to the previous host. The control plane accepted every write. The data plane never heard about it.

Then there's the boring category, normalisation. Some record APIs return an MX exchange with a trailing dot and some without. Some accept a 400-character DKIM public key as a single string and split it for you; a TXT record is a sequence of character-strings each capped at 255 octets, so what comes back on the wire is chunked no matter what you sent. Compare the chunks to your intent without joining them first and every long key looks like a diff forever. That's why Node's resolveTxt hands you string[][] rather than string[] — the nesting is the record structure, not an API quirk.

One more that bites during exactly this migration: SPF permits at most 10 DNS-resolving mechanisms per evaluation. Adding a new mail provider's include: to an existing record can push you over that limit, and the result is a permerror rather than a hard failure you'd notice — receivers treat it however their local policy says, and DMARC alignment quietly stops depending on SPF.

Route53 and Cloudflare both split these concerns the same way: the record API is one product, domain registration is another. Which is the whole argument for the shape below.

Should one DNS interface replace the registrar APIs you already have?

Partly, and the boundary matters more than the abstraction. A DNS interface owns records. Registration, transfer authorisation, renewal, registrant contact data and the DS record that anchors DNSSEC at the parent zone all stay registrar-side, because they live in the parent, not in your zone. Any "one interface" that claims to cover both is either wrapping a registrar reseller or quietly lacking half the operations you'll need on renewal day.

Two criteria decide whether a given interface is worth the migration.

The first is whether it can read back. An interface that only reports its own stored state tells you what it thinks it wrote. Reconciliation needs an answer from the authoritative nameservers — and if the abstraction can't give you the zone's nameserver set and their addresses, you'll end up querying around it anyway, at which point you've built the audit yourself and the interface is only doing writes.

The second is upsert semantics. Record writes have no cross-provider transaction. Forty domains means forty independent applies, some of which will fail halfway, so every write path needs to be idempotent on (name, type, value) and every run needs a plan you can read before it executes. A record API that exposes only create and delete forces you to model upsert yourself, and delete-then-create on a live MX set is a window where the domain has no mail exchanger at all.

Everything else — SDK ergonomics, rate limits, whether the client is generated or handwritten — is noise next to those two.

Reading MX and TXT back from the authoritative nameservers in Node

The audit is about sixty lines and has no dependencies outside node:dns. It resolves the zone's NS set, turns each nameserver into IP addresses, builds one Resolver per address, and asks each of them directly. Querying the authoritative servers rather than a recursive resolver is the point: you see the current state immediately instead of waiting out someone else's cache.

import { Resolver, resolveNs, resolve4 } from "node:dns/promises";

type ZoneIntent = {
  zone: string;
  mx: { exchange: string; priority: number }[];
  txt: Record<string, string>; // relative name -> one logical value
};

const norm = (host: string) => host.replace(/\.$/, "").toLowerCase();
const key = (r: { priority: number; exchange: string }) => `${r.priority} ${norm(r.exchange)}`;

// setServers takes IP addresses, not hostnames. Resolve the NS set first.
async function authoritative(zone: string) {
  const servers: { label: string; resolver: Resolver }[] = [];
  for (const host of await resolveNs(zone)) {
    for (const ip of await resolve4(norm(host))) {
      const resolver = new Resolver({ timeout: 2_000, tries: 2 });
      resolver.setServers([ip]);
      servers.push({ label: `${norm(host)}/${ip}`, resolver });
    }
  }
  return servers;
}

async function ask<T>(fn: () => Promise<T>, empty: T): Promise<T> {
  try {
    return await fn();
  } catch (err) {
    const code = (err as NodeJS.ErrnoException).code;
    if (code === "ENODATA" || code === "ENOTFOUND") return empty; // absent, not broken
    throw err;
  }
}

export async function auditZone(intent: ZoneIntent): Promise<string[]> {
  const drift: string[] = [];
  const want = intent.mx.map(key).sort().join(", ");

  for (const { label, resolver } of await authoritative(intent.zone)) {
    const mx = await ask(() => resolver.resolveMx(intent.zone), []);
    const got = mx.map(key).sort().join(", ");
    if (got !== want) drift.push(`${label} MX [${got || "none"}] want [${want}]`);

    for (const [name, value] of Object.entries(intent.txt)) {
      const fqdn = `${name}.${intent.zone}`;
      // Each TXT answer is a list of <=255-octet strings; join before comparing.
      const served = (await ask(() => resolver.resolveTxt(fqdn), [])).map((c) => c.join(""));
      if (!served.includes(value)) drift.push(`${label} TXT ${fqdn} [${served.join(" | ") || "none"}]`);
    }
  }
  return drift;
}
Enter fullscreen mode Exit fullscreen mode

Feed it the desired state you already keep somewhere — a JSON file in the repo is fine — and run it twice: once in CI right after an apply, as a gate that fails the deploy, and once on a schedule against every zone in the inventory. Ten minutes is a reasonable interval; each pass is a handful of UDP queries and costs nothing except the alert it eventually sends.

Two refinements worth the extra twenty lines. Compare the SOA serial across every authoritative server before you trust any answer, because unequal serials mean the zone hasn't converged yet and a diff at that moment is noise. And publish a null MX — preference 0 with the root as the exchange — on the dormant carrier domains, so "this domain must not receive mail" becomes an assertion the audit can check instead of an absence it has to ignore.

Ordering the cutover so that mail never lands nowhere

Lower the MX TTL first, and do it one full old-TTL ahead of the change: 3600 down to 300 seconds, then wait an hour. Negative answers have their own cache lifetime derived from the SOA, so a botched intermediate state can outlive the record TTL you were watching.

Then the ordering that actually matters. Configure the new provider to accept mail for the domain before any MX record moves; a provider that doesn't yet recognise the domain will reject deliveries it receives. Publish DKIM keys before enabling signing. Update SPF before the first message goes out through the new path, checking the lookup count as you go. Move MX. Leave DMARC at p=none with an rua= address for long enough to read aggregate reports from real senders, then tighten.

Raise the TTL back only after the read-back audit has been green across every authoritative server for a day.

When per-registrar code is still the right call

If every domain already sits at one provider, an abstraction layer is pure overhead — stick with that provider's API and spend the time on the audit instead, which is the part that pays. The trade-off in any unified interface is that it converges on a lowest common denominator: apex ALIAS records, weighted or latency-based answers, and health-checked failover are provider-specific by construction, and an interface that smooths them away will silently drop them on the next apply.

Registrar-only work stays registrar-only. Transfer locks, auth codes, renewals, contact updates: no DNS interface covers them, and one that pretends to is worse than three honest clients.

DNSSEC is the case I'd think hardest about. Changing delegation means re-signing under new keys and rolling the DS record at the parent, and I'm not sure there's a version of that which is genuinely low-risk on a domain carrying production mail. Rehearse it on a dormant brand first. If the answer looks expensive, keeping that one zone where it is and running the audit against it anyway is a perfectly respectable outcome — the audit is the part that removes the drift, not the migration.

Further reading

Top comments (0)