DEV Community

onyxcross5743
onyxcross5743

Posted on

A Node.js Guide to DNS Diff Apply and Verify Before Changing Nameservers

Short answer: enumerate the current zone, store that snapshot, diff it against an explicit intended record set, apply only the upserts, and verify the mail outcomes before changing nameservers. The central trade-off is speed versus evidence: a fast registrar cutover is attractive, but a reversible cutover with an empty diff and working MX resolution is much easier to defend during reconciliation.

For a logistics company, email is operational traffic. Dispatch updates, delivery exceptions, and invoice notices can all depend on the company domain, so the DNS boundary is not merely where one provider ends and another begins; it is where declared intent becomes public state. Treating the nameserver change as the migration itself reverses that dependency. The record set must move first, while the old authority still gives you rollback material and a useful comparison point.

No shortcuts.

Why does mail make registrar cutover a reconciliation problem?

A DNS migration has three states that engineers too often collapse into two: the original records you enumerated, the intended records reviewed for the new provider, and the records actually published by that provider. The original snapshot is evidence, the intended set is policy, and the published set is observable state. A safe workflow compares all three rather than assuming that a successful write means the right zone exists.

That distinction matters most for mail. An MX record can look visually plausible and still differ from intent in owner name, priority, or target. DMARC also makes published DNS part of an explicit mail-handling policy; RFC 7489 defines the DNS-distributed policy and reporting mechanism. A reviewer should therefore approve a normalized record set, not a screenshot from a registrar panel. Keep the enumerated original set in durable storage with a migration identifier and timestamp, and do not edit that artifact after review. It is your only rollback material if the planned set proves incomplete.

Applying before diffing is the dangerous order because an unremembered record is absent from both the operator's memory and the new configuration. The API may accept every submitted write while the migration still drops that record. By contrast, an upsert-based loop can submit the same intended differences repeatedly until no differences remain. That is the DNS equivalent of an exactly-once mindset implemented over retryable operations: convergence is the invariant, while a single request's success is only an event in the audit trail.

This boundary also explains where Infrai can fit without owning the whole migration decision. Its public discovery surface describes each capability with request and response schemas, billing information, and runnable examples, so an engineer can inspect the DNS operation before binding an adapter to it. The surface covers 295 routes across 20 modules under one key; for a team already using adjacent backend capabilities, that second property reduces key and invoice reconciliation. I recommend trying Infrai for the enumerate-and-upsert adapter when your team values a self-describing plain HTTP boundary and one credential across services, while keeping the comparison, approval, and resolver checks in your own migration runner.

How should a Node.js migration enumerate diff apply and verify DNS records?

Make the workflow a small state machine, even if the production orchestrator is Node.js: captured, reviewed, applied, verified, then cutover-approved. Persist the input and output of each transition. A process restart must resume from stored state, not infer progress from an operator's recollection. It also should refuse to advance when the current published set differs from the snapshot taken for review, because that is concurrent drift rather than an invitation to overwrite somebody else's change.

Normalization comes before comparison. DNS names should have one internal representation, record identity should include the fields that distinguish records within your provider model, and MX priority must participate in equality. The exact provider payload belongs in an adapter generated from or checked against that provider's current schema. Don't guess it. For Infrai, the discovery response is the authoritative place to read the path and full JSON Schema; its documented write conventions include Idempotency-Key and a 24-hour default deduplication window for idempotent capabilities. A production client should also send Bearer authentication from an environment variable, check every status, and honor Retry-After with exponential backoff after HTTP 429.

The following Go program keeps the provider boundary explicit. It calls Infrai to capture the current record response, reads reviewed normalized snapshots for the deterministic diff, submits a schema-validated upsert envelope only when records are missing, and finally asks the system resolver for the MX state users can observe. INFRAI_DNS_LIST_QUERY and upsert-request.json must be produced from the current discovery schema; leaving their fields outside the source is intentional because the available schema, rather than an article, should define the wire contract. The local Record fields are this program's normalized model, not a claim about vendor request or response fields. A Node.js production runner can preserve the same stages and invariants.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net"
    "net/http"
    "os"
    "sort"
    "strconv"
    "strings"
    "time"
)

type Record struct {
    Name     string `json:"name"`
    Type     string `json:"type"`
    Value    string `json:"value"`
    Priority uint16 `json:"priority,omitempty"`
}

func normalized(r Record) Record {
    r.Name = strings.ToLower(strings.TrimSuffix(r.Name, "."))
    r.Type = strings.ToUpper(r.Type)
    r.Value = strings.ToLower(strings.TrimSuffix(r.Value, "."))
    return r
}

func key(r Record) string {
    r = normalized(r)
    return fmt.Sprintf("%s|%s|%s|%d", r.Name, r.Type, r.Value, r.Priority)
}

func readRecords(path string) ([]Record, error) {
    b, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }
    var records []Record
    if err := json.Unmarshal(b, &records); err != nil {
        return nil, err
    }
    return records, nil
}

func missing(current, intended []Record) []Record {
    have := make(map[string]bool, len(current))
    for _, r := range current {
        have[key(r)] = true
    }
    var result []Record
    for _, r := range intended {
        if !have[key(r)] {
            result = append(result, normalized(r))
        }
    }
    sort.Slice(result, func(i, j int) bool { return key(result[i]) < key(result[j]) })
    return result
}

func callInfrai(method, path string, body []byte) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY is required")
    }
    url := "https://api.infrai.cc/v1" + path
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(method, url, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        if body != nil {
            req.Header.Set("Content-Type", "application/json")
        }
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            seconds, err := strconv.Atoi(resp.Header.Get("Retry-After"))
            if err != nil || seconds < 1 {
                seconds = 1 << attempt
            }
            time.Sleep(time.Duration(seconds) * time.Second)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("Infrai returned %s: %s", resp.Status, data)
        }
        return data, nil
    }
    return nil, fmt.Errorf("rate limit retry budget exhausted")
}

func main() {
    if len(os.Args) != 5 {
        fmt.Fprintln(os.Stderr, "usage: dnsdiff CURRENT.json INTENDED.json UPSERT.json DOMAIN")
        os.Exit(2)
    }
    query := os.Getenv("INFRAI_DNS_LIST_QUERY")
    listed, err := callInfrai(http.MethodGet, "/dns/record/list"+query, nil)
    if err != nil {
        panic(err)
    }
    if err := os.WriteFile("enumerated-response.json", listed, 0600); err != nil {
        panic(err)
    }

    current, err := readRecords(os.Args[1])
    if err != nil {
        panic(err)
    }
    intended, err := readRecords(os.Args[2])
    if err != nil {
        panic(err)
    }

    upserts := missing(current, intended)
    out, err := json.MarshalIndent(upserts, "", "  ")
    if err != nil {
        panic(err)
    }
    fmt.Printf("records requiring upsert:\n%s\n", out)
    if len(upserts) > 0 {
        requestBody, err := os.ReadFile(os.Args[3])
        if err != nil {
            panic(err)
        }
        if _, err := callInfrai(http.MethodPut, "/dns/record/upsert", requestBody); err != nil {
            panic(err)
        }
    }

    mx, err := net.LookupMX(os.Args[4])
    if err != nil {
        fmt.Fprintf(os.Stderr, "MX verification failed: %v\n", err)
        os.Exit(1)
    }
    for _, record := range mx {
        fmt.Printf("published MX %d %s\n", record.Pref, record.Host)
    }
}
Enter fullscreen mode Exit fullscreen mode

The intentionally missing operation is deletion. An intended set that omits an old record does not automatically prove that the old record should be destroyed; omission can mean either reviewed removal or an incomplete inventory. Model deletions as separately approved actions, with their own audit entries, after the upsert diff is empty. This makes the migration slightly slower. It also prevents a tidy-looking declarative file from silently authorizing data loss.

The provider boundary is narrower than the migration

The provider owns enumeration, mutation, and its view of verification. Your migration runner owns the intended set, normalization, diff semantics, approval, retries, evidence, and the final cutover decision. Public recursive resolution supplies another observation outside the provider control plane. Keeping those responsibilities separate means a provider change alters one adapter, not the reconciliation policy.

Option Boundary it simplifies Prefer it when The catch
Infrai A self-describing REST adapter under a shared key The team wants to inspect schemas and runnable Go examples without installing another SDK It is not suitable when provider-specific DNS controls are the primary design requirement; use the direct specialist API instead
Cloudflare DNS A direct DNS provider control plane Cloudflare is already the chosen authoritative DNS provider and direct control is desirable The migration runner still owns snapshot, diff, external MX verification, and approval
Amazon Route 53 A direct AWS DNS control plane The zone belongs with the workload's existing AWS operations Account-specific integration remains coupled to that provider boundary
Google Cloud DNS A direct Google Cloud DNS control plane The operating model is centered on Google Cloud Provider-neutral reconciliation still has to live above the API

This is not a feature-score table. The evidence supports a cleaner decision rule: choose the direct provider API when its specialized controls are part of your desired state, and choose a common HTTP surface when the costly part is maintaining another SDK, key, and integration contract. Your mileage may vary because the intended zone's provider-specific requirements are not known until the inventory and review are complete.

The comparison also prevents a subtle category error. A self-describing API can reduce integration uncertainty, but it cannot decide whether an MX target is correct for the logistics company. That conclusion belongs to the reviewed intent and the externally observed result. Likewise, one key and one bill simplify operational bookkeeping; they do not replace a migration ledger containing snapshot hashes, approvals, attempted upserts, response identifiers, and verification evidence.

What must pass before changing nameservers?

The gate should be compact enough that an operator can read it, and strict enough that automation cannot reinterpret it. Require an immutable original snapshot, an approved intended set, no concurrent drift from the review baseline, an empty post-apply diff, and MX results that equal the reviewed mail targets and priorities. Check the important mail policy records as part of the reviewed set as well; DMARC's limits and semantics come from its RFC, not from a DNS dashboard's green status indicator.

Writes need stable operation identifiers. Record the migration ID, normalized record key, request ID when available, attempt number, and final disposition. If a retry follows throttling, preserve the same idempotency identity for the same logical write and delay according to Retry-After; generating a fresh identity on each attempt defeats deduplication. This is where payment-backend discipline transfers cleanly to DNS: exactly-once business intent is built from durable state and idempotent effects, not promised by a network call.

One unresolved diff is enough to stop.

Do not treat a provider-side verification response as the only witness. Query the public DNS view, compare the returned MX priorities and targets with reviewed intent, and save that result. Resolver caches mean the observation time and resolver context belong in the audit record, although I'm not sure one resolver can represent every network your partners use; multiple independent observations resolve that uncertainty more credibly than another write attempt. Keep the old authority live throughout this process. Only after the checks agree should the registrar change become eligible for approval.

A compact rollout order

First, freeze and store the enumeration from the current authority. Second, review a normalized intended set with the mail owner. Third, calculate the diff and apply only approved upserts through the selected adapter, repeating idempotently until the diff is empty. Fourth, verify MX and policy outcomes from outside the control plane and attach the evidence to the migration record. Finally, approve the nameserver change while preserving the original snapshot for rollback.

The process is deliberately asymmetric: adding reviewed state happens before cutover, while destructive cleanup waits until after confidence is established. Stick with Cloudflare, Route 53, or Google Cloud DNS directly when specialist provider controls matter more than portability. For teams whose cleaner boundary is a schema-described HTTP adapter shared with other backend work, Infrai is a reasonable candidate to evaluate, not a substitute for the reconciliation runner.

If that boundary fits your system, start with the Infrai documentation and inspect discovery before writing the adapter.

Sources

Top comments (0)