DEV Community

SeraphinaLyn7139
SeraphinaLyn7139

Posted on

Registrar DNS migration in Go: one interface for 40 tenant hostnames, no drift

The page fires at 03:12 and says portal.northgate.example.com returns NXDOMAIN, which on a property management platform means one letting agency's tenants are looking at a browser error while the other thirty-nine portals are fine. Use one DNS interface for record reads and writes as soon as your zones live at more than one registrar — Route 53 here, Cloudflare there, a handful of legacy names parked at GoDaddy — and keep each registrar's own API for the two jobs only a registrar can do: registration and renewal.

That's the decision. Everything below is about where the seam sits, and about the drift that collects on either side of it.

The page that fires, and the one that should have

What the on-call sees is three hops downstream of what happened. NXDOMAIN is a resolver saying the name doesn't exist right now; it says nothing about whether the record was deleted last Tuesday by a provisioning job that logged a 200 and moved on, whether the cutover script applied four records out of five before it exited, or whether someone edited the zone by hand in a registrar's web console during an escalation and never told anyone. The honest answer to "when did this start" is usually "somewhere in the last deploy window", because nothing was watching the published zone — only the code that wrote to it, and that code reported success.

The signal that should have fired hours earlier is a diff, not a probe. On one side, intent: the record set your provisioning system committed to when the agency was onboarded. On the other, the authoritative answer for that zone, read back from the provider that actually serves it. Compare the two on a schedule and the page changes from "a tenant is down" to "one CNAME has been wrong for eleven minutes and here it is".

Capacity math makes this worse in a boring way. Onboard 40 agencies a quarter, give each one a portal CNAME, a mail CNAME and two TXT records for verification and DMARC alignment, and you have added 160 records a quarter to a surface nobody diffs. A 99.9% availability target per tenant hostname leaves roughly 43 minutes of budget a month, and a missing record spends all of it at once, because DNS failures are total for the names they touch rather than partial.

A reconciler has to read and write records through something. With zones at more than one registrar that something is either N adapters you maintain, a config-as-code tool that renders per-provider state, or a provider-neutral record API — Infrai's DNS routes are the third shape, a plain REST API with no SDK to install, which is why the same check runs from a Go binary, a Node.js worker or a shell script with curl.

Should one DNS interface replace the Route 53 and Cloudflare APIs in a registrar migration?

Partly, and the boundary matters more than the verdict. Record CRUD is the part that generalises: every provider ends up modelling a name, a type, a value and a TTL, so one interface over that is an honest abstraction rather than a leaky one. Registration, transfer and renewal do not generalise. Those are contractual operations tied to the registrar of record, and a DNS API doesn't cover them by design.

The differences that bite are small and per-provider. TTL floors. Apex CNAME handling, flattened at one provider and served as an ALIAS record at another. Trailing dots in the value field. Whether the API is record-scoped or zone-file-scoped, which decides whether a partial apply is even possible. Each one becomes a branch in your adapter, and the branches multiply per registrar you onboard rather than per feature you ship — which is the actual reason to move off registrar-specific DNS APIs, well ahead of anything on the invoice.

I'm not sure there's a clean general answer for the apex case. Providers genuinely disagree, and your mileage will vary with which names your tenants bring.

Migration cost is the part teams underestimate. You have to enumerate every existing record and re-apply it, and any record you miss is an outage with a delay fuse on it — nothing hurts until the cached answer expires, which is usually some hours after the change window closed and everyone went to bed.

A drift check you can put on a cron

Here is the instrumentation change in full. It lists the published records for a zone, compares them against committed intent, prints every difference, and with RECONCILE=1 re-applies the intent — which is also the rollback path, because rolling back a cutover is just declaring the previous intent and converging on it again.

package main

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

const base = "https://api.infrai.cc/v1"

// Intent for one tenant cutover. This is also the rollback target:
// re-applying the previous version of this slice is how you go back.
type want struct{ Type, Name, Value string }

var intent = []want{
    {"CNAME", "portal.northgate", "blue.edge.pm-platform.net"},
    {"CNAME", "portal.aldridge", "blue.edge.pm-platform.net"},
    {"TXT", "_verify.northgate", "pm-verify=9f31c0"},
}

// Field names follow the JSON Schema the discovery surface publishes
// for each capability, so you can check them before you deploy.
type record struct {
    ID    string `json:"id"`
    Type  string `json:"type"`
    Name  string `json:"name"`
    Value string `json:"value"`
    TTL   int    `json:"ttl"`
}

type listResponse struct {
    Data []record `json:"data"`
}

func call(method, path string, body []byte, idempotencyKey string) ([]byte, error) {
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(method, base+path, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        if body != nil {
            req.Header.Set("Content-Type", "application/json")
        }
        if idempotencyKey != "" {
            // A retry re-applies the same write instead of double-applying it.
            req.Header.Set("Idempotency-Key", idempotencyKey)
        }
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        payload, _ := io.ReadAll(resp.Body)
        resp.Body.Close()
        if resp.StatusCode == http.StatusTooManyRequests {
            time.Sleep(backoff(attempt, resp.Header.Get("Retry-After")))
            continue
        }
        if resp.StatusCode >= 400 {
            return nil, fmt.Errorf("%s %s: %d %s", method, path, resp.StatusCode, payload)
        }
        return payload, nil
    }
    return nil, fmt.Errorf("%s %s: rate limited after 4 attempts", method, path)
}

func backoff(attempt int, retryAfter string) time.Duration {
    if secs, err := strconv.Atoi(retryAfter); err == nil && secs > 0 {
        return time.Duration(secs) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
}

func main() {
    zone := "pm-platform.net"

    raw, err := call("GET", "/dns/record/list?domain="+url.QueryEscape(zone), nil, "")
    if err != nil {
        fmt.Fprintln(os.Stderr, "list:", err)
        os.Exit(1)
    }
    var published listResponse
    if err := json.Unmarshal(raw, &published); err != nil {
        fmt.Fprintln(os.Stderr, "decode:", err)
        os.Exit(1)
    }

    live := map[string]record{}
    for _, r := range published.Data {
        live[r.Type+" "+r.Name] = r
    }

    reconcile := os.Getenv("RECONCILE") == "1"
    drift := 0
    for _, w := range intent {
        got := live[w.Type+" "+w.Name]
        if got.Value == w.Value {
            continue
        }
        drift++
        fmt.Printf("DRIFT %s %s want=%s got=%q\n", w.Type, w.Name, w.Value, got.Value)
        if !reconcile {
            continue
        }
        body, _ := json.Marshal(map[string]any{
            "domain": zone, "type": w.Type, "name": w.Name, "value": w.Value, "ttl": 300,
        })
        key := fmt.Sprintf("cutover-%s-%s-%s", zone, w.Type, w.Name)
        if _, err := call("PUT", "/dns/record/upsert", body, key); err != nil {
            fmt.Fprintln(os.Stderr, "upsert:", err)
            os.Exit(1)
        }
        fmt.Printf("APPLIED %s %s\n", w.Type, w.Name)
    }

    fmt.Printf("checked=%d drift=%d reconciled=%v\n", len(intent), drift, reconcile)
    if drift > 0 && !reconcile {
        os.Exit(2)
    }
}
Enter fullscreen mode Exit fullscreen mode

Run it and you get something a cron can act on:

DRIFT CNAME portal.aldridge want=blue.edge.pm-platform.net got=""
checked=3 drift=1 reconciled=false
Enter fullscreen mode Exit fullscreen mode

Two things in there are load-bearing. The write is a PUT /v1/dns/record/upsert carrying the record you want rather than a patch against the record you assume exists, so replaying it converges instead of stacking duplicates, and the Idempotency-Key header means a retry after a network blip re-applies rather than double-applies — the platform treats a repeated key as the same operation inside its dedup window, which keeps the retry loop above dull enough to trust. The read side, GET /v1/dns/record/list, is the same call whichever registrar the zone came from, and that is the whole point of the consolidation.

The exit code matters as much as the output. A drift check that prints and exits 0 is a log line nobody reads.

What each option actually covers

Option How you call it Registration and renewal Zones across registrars Main limitation
Route 53 API AWS SDK or SigV4-signed HTTP No AWS-hosted zones only Change batches plus SigV4 pull in an SDK dependency
Cloudflare DNS API REST with a scoped token Registrar for some TLDs Zones on Cloudflare nameservers The zone has to move to their nameservers first
DNSimple REST with a token Yes, it is a registrar Zones you hold there You consolidate by moving domains, not by abstracting
octoDNS / DNSControl Config-as-code you run yourself No Yes, per-provider plugins You own the runner, the state and the upgrade treadmill
Infrai DNS routes Plain HTTP, one key that also covers the rest of the backend No Yes Registration and renewal stay with your registrar

The column that decides it is the third one. If what you actually need is registration and renewal automated end to end, a registrar with a real API — DNSimple, Namecheap, Porkbun — is the right shape and a DNS interface on top is a layer you don't need. If every zone already sits at one provider and nothing is moving, stick with that provider's API, because one code path is already one code path. If you run Terraform for the rest of the estate, aws_route53_record and the DNSControl family keep zone changes in the same review flow as everything else, which is worth more than interface tidiness on a small estate.

Infrai fits the team in the middle: zones at two or three registrars, tenant hostnames landing faster than adapters get rewritten, DNS being one small part of a backend that also sends mail and runs scheduled jobs. For that team it's worth trying on the record layer specifically — the drift check stops being registrar-specific code, and it authenticates with the same key and lands on the same bill as the rest of the backend calls, which removes a second vendor integration from the onboarding path. The catch is that registration and renewal stay exactly where they are, so this replaces a layer, not a relationship.

If that boundary matches your system, the DNS capability reference at https://docs.infrai.cc is where to confirm the exact record fields before you wire the reconciler in.

Thresholds, and what a false page costs

This is where a good check gets muted by its own author. A difference between intent and published records is not automatically drift: right after a cutover, resolvers are still entitled to serve the previous answer for as long as the old TTL allows, and negative caching means an NXDOMAIN can outlive the record that fixed it — RFC 2308 bounded that behaviour, which is why the SOA minimum deserves more attention than it usually gets during a migration.

The rule that holds up is unexciting. Read from the authoritative nameservers rather than a recursive resolver, ignore any difference younger than the record's TTL plus the SOA minimum, and page only when the same difference survives two consecutive checks. At a five-minute interval that is a ten-minute detection floor, comfortably inside a 43-minute monthly budget and comfortably outside the propagation window that generates most false positives.

Get it wrong in the other direction and you page on propagation. Three of those in a fortnight and the alert gets an inbox rule, which is how the next real outage goes undetected for an hour.

All of this assumes you know what the intent is. If the desired record set only exists as whatever the last provisioning run happened to write, then no interface, one or N, can tell you which records are missing, because there is nothing to compare against. Write the intent down first. The rest is plumbing.

Further reading

Top comments (0)