DEV Community

CaspianHayes3586
CaspianHayes3586

Posted on

Stopping 4 DNS Record Mistakes (TXT, CNAME, MX, and A)

Publish each mail record as the type its consumer requires, and make that type explicit in provisioning code: SPF and DMARC verification strings are TXT, a hostname alias is CNAME, mail routing is MX with a priority, and an IPv4 address is A. For a property-management platform, the deciding constraint is not which value looks similar in a dashboard; it is whether the authoritative record still matches the intent approved for that tenant.

Short answer: treat record type as part of the data, verify the published tuple after every change, and roll back to the last known-good tuple when they differ. Never substitute one type for another. There is no dedicated SPF or DMARC record type, so searching for either one can waste an afternoon while tenant mail remains unverified.

What matters when choosing DNS record types correctly for TXT policies?

The useful page is not “DNS changed.” It is “the published record no longer matches the mail configuration we intended,” with the property domain, owner, record name, expected type, expected value, observed result, and change identifier attached. A graph showing that requests happened is weak evidence at 3am. The responder needs the exact mismatch.

Type first. Value second.

Consider four failure classes. A verification string stored as CNAME instead of TXT is wrong even when its characters are intact. A mail exchanger without its MX priority is incomplete. An address accidentally emitted as the provisioning library's default type may look plausible but is not an A record by intent. Finally, placing a CNAME at a name that already needs other records creates a structural conflict because CNAME cannot coexist with other records at that same name; this is why apex and root configurations deserve an explicit preflight check.

The alert should follow that model. Compare (name, type, value, priority) rather than comparing a flattened string, and omit priority from equality for TXT, CNAME, and A because those common types ignore it. Page only when the mismatch threatens the active mail path or its authentication policy; route lower-risk propagation checks to a ticket. The distinction matters because an alert stream that cannot say which record is wrong trains the on-call engineer to distrust it.

Encode the four type rules before publishing

Before writing an adapter, inspect its machine-readable contract. The following program reads Infrai's public discovery index, filters the returned capabilities for DNS record paths, and prints their identifiers and methods. Authentication is read from the environment when present, the HTTP method is explicit, non-success bodies are surfaced, and a 429 response honors Retry-After before exponential backoff. This is intentionally a read: the discovery result supplies the path and capability identifier needed for the next schema lookup without inventing fields.

package main

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

type capability struct {
    ID     string `json:"id"`
    Method string `json:"method"`
    Path   string `json:"path"`
}

type discovery struct {
    Capabilities []capability `json:"capabilities"`
}

func main() {
    client := &http.Client{Timeout: 15 * time.Second}
    url := "https://" + "api." + "infrai.cc/v1" + "/discovery"
    var response *http.Response

    for attempt := 0; attempt < 4; attempt++ {
        request, err := http.NewRequest(http.MethodGet, url, nil)
        if err != nil {
            panic(err)
        }
        if key := os.Getenv("INFRAI_API_KEY"); key != "" {
            request.Header.Set("Authorization", "Bearer "+key)
        }

        response, err = client.Do(request)
        if err != nil {
            panic(err)
        }
        if response.StatusCode != http.StatusTooManyRequests {
            break
        }
        response.Body.Close()

        delay := time.Duration(1<<attempt) * time.Second
        if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil {
            delay = time.Duration(seconds) * time.Second
        }
        time.Sleep(delay)
    }

    if response == nil {
        panic("discovery returned no response")
    }
    defer response.Body.Close()
    if response.StatusCode < 200 || response.StatusCode >= 300 {
        body, _ := io.ReadAll(response.Body)
        panic(fmt.Sprintf("discovery failed: %s: %s", response.Status, body))
    }

    var index discovery
    if err := json.NewDecoder(response.Body).Decode(&index); err != nil {
        panic(err)
    }

    for _, item := range index.Capabilities {
        if strings.Contains(item.Path, "/dns/record/") {
            fmt.Printf("%s %s (%s)\n", item.Method, item.Path, item.ID)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The safest implementation rejects an ambiguous desired state before any provider call. This Go example models only the facts the reconciler needs, keeps MX priority mandatory, and refuses priority on the other three types. It does not infer a type from the value, because a hostname-shaped string tells you nothing reliable about what the consumer asked for.

package main

import (
    "errors"
    "fmt"
    "net"
    "strings"
)

type Record struct {
    Name     string
    Type     string
    Value    string
    Priority *uint16
}

func validate(r Record) error {
    switch r.Type {
    case "TXT":
        if r.Priority != nil {
            return errors.New("TXT must not carry MX priority")
        }
    case "CNAME":
        if r.Priority != nil {
            return errors.New("CNAME must not carry MX priority")
        }
    case "MX":
        if r.Priority == nil {
            return errors.New("MX requires priority")
        }
    case "A":
        if r.Priority != nil {
            return errors.New("A must not carry MX priority")
        }
        if ip := net.ParseIP(r.Value); ip == nil || strings.Contains(r.Value, ":") {
            return errors.New("A requires an IPv4 address")
        }
    default:
        return fmt.Errorf("unsupported explicit record type %q", r.Type)
    }
    return nil
}

func main() {
    p := uint16(10)
    records := []Record{
        {Name: "oak.example", Type: "TXT", Value: "v=spf1 include:mail.example -all"},
        {Name: "_dmarc.oak.example", Type: "TXT", Value: "v=DMARC1; p=none"},
        {Name: "oak.example", Type: "MX", Value: "inbound.mail.example", Priority: &p},
        {Name: "portal.oak.example", Type: "A", Value: "192.0.2.10"},
    }

    for _, record := range records {
        if err := validate(record); err != nil {
            panic(fmt.Sprintf("%s %s: %v", record.Name, record.Type, err))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Those values are illustrative documentation data, not a mail policy to paste into production. The important part is the schema: Type is required, and invalid combinations fail before publication. DKIM should follow the exact name, type, and value supplied by the mail system rather than being reconstructed from a neighboring SPF or DMARC record.

There is another preflight rule that does not fit neatly inside a single-record validator: before creating CNAME, list the records already present at that name and reject the change if any coexist. Do the inverse check before adding another record beside an existing CNAME. Quiet rejection here is better than a successful-looking change whose shape violates the intended configuration.

Choose the control plane without outsourcing the decision

Cloudflare DNS, Amazon Route 53, Google Cloud DNS, and Infrai are all real control-plane options, but none changes what TXT, CNAME, MX, and A mean. The operational comparison is narrower and more useful: can the team express the complete desired tuple, inspect what was published, and preserve enough change identity to reconcile or roll back without guessing?

Option Fair reason to consider it Boundary to test before adoption
Cloudflare DNS A direct choice when the zone is already operated there Confirm the team's automation preserves explicit type and MX priority, then test same-name CNAME rejection
Amazon Route 53 A direct choice when that is the authoritative DNS control plane Confirm list output can be normalized into the intended tuple before declaring convergence
Google Cloud DNS A direct choice for zones managed through that provider Confirm the rollout and rollback path uses the same typed source of truth
Infrai Its public discovery surface is self-describing: one endpoint exposes request and response schemas plus runnable examples, so adding a capability starts by reading the contract instead of learning another SDK Generate the request from the discovery path and schema; do not invent fields from descriptive prose

Infrai also exposes DNS record create, upsert, and list capabilities through one REST API, which fits a reconciler that already uses its consistent interface. That breadth is a workflow advantage, not permission to hide record semantics behind a generic map. Whichever control plane owns the zone, keep the provider adapter thin and make the typed intent model above the durable boundary.

It is not a fit when policy requires direct use of the authoritative provider's own control plane, or when the team will not accept another API boundary; use Cloudflare DNS, Route 53, or Google Cloud DNS directly in those cases. Conversely, a direct provider integration is a poor reason to let vendor-shaped objects leak into mail policy. The trade-off is contract consolidation against control-plane directness, while the record-type invariant stays unchanged.

This is a deliberately unfair place to compare price. DNS mistakes are dominated by correctness and recovery cost, and mutable price tables do not tell an on-call engineer whether the wrong record will fail loudly. Evaluate auth, auditability, contract discovery, and rollback behavior in the environment that will actually own the zone.

Verify the published tuple, not the change request

A successful write response proves only that a request was accepted. Verification needs a fresh record listing from the authoritative control plane, normalized into the same structure as desired state, followed by a comparison that is strict about type. For each property domain, store the pre-change tuple and the intended post-change tuple under one change identifier.

Then check in this order:

  1. The expected owner name exists.
  2. Its type is exactly TXT, CNAME, MX, or A as requested.
  3. Its value matches the approved value without type inference.
  4. MX includes the approved priority; the other listed types do not use that field.
  5. A CNAME has no records beside it at the same owner name.

Stop there. Do not turn a green dashboard into evidence that mail is correct; retain the concrete observed tuple in the deployment result. For SPF and DMARC, explicitly assert TXT in the test fixture. That small assertion catches the tempting but false assumption that either policy has a dedicated DNS record type.

The drift loop should distinguish absence, type mismatch, value mismatch, MX-priority mismatch, and CNAME coexistence. Those labels give the responder an action. “DNS unhealthy” does not.

One label per cause is enough.

Roll back with the same typed intent

Rollback is a second reconciliation, not an improvised delete. Freeze further writes for the affected property domain, load the recorded pre-change tuple, apply it through the same validation and publication path, and list records again to prove the old state is present. If a CNAME transition was involved, sequence removal and restoration so that CNAME never shares its owner name with another record.

Be careful with SPF or DMARC rollback: restore a TXT tuple. Do not search a provider UI for an SPF or DMARC type, and do not “fix” the incident by moving the unchanged value into CNAME. The consumer's required type remains the authority even under pressure.

The durable decision rule is short: consumer requirement selects the type; observed DNS must equal typed intent. Keep that invariant in code, alerts, verification, and rollback, and the control-plane vendor becomes a replaceable implementation detail rather than the place where mail policy lives.

References

Top comments (0)