DEV Community

YancySterling6529
YancySterling6529

Posted on

Customer DNS Reconciliation Explained: Application History Meets Published State

Use two ledgers for customer-domain audits: an application change log for intent and actor history, plus recurring live-zone reads for published truth. Neither ledger is complete evidence by itself. The deciding constraint is drift between what your product meant to publish and what DNS serves now.

TL;DR: Log every in-product request with a stable zone identifier, read each zone on a schedule, and reconcile the normalized record sets. The join catches both missing application events and out-of-band DNS edits while preserving who requested an authorized change.

This matters in a developer tool that lets customers attach their own domains. A polished activity feed may look convincing, but an auditor needs defensible answers to two different questions: who asked for the change, and what is published? Only the application knows the first. Only the zone knows the second.

How should application logs and live DNS reads work together?

An application log has context that DNS cannot provide: the authenticated customer, an internal request, the intended record set, and the time the product accepted the request. It gives you history. It cannot guarantee completeness because a person, another automation system, or a provider console can change the zone without passing through your service.

A live zone read has the opposite shape. It reports the records published at observation time, including changes made elsewhere, but it does not explain the actor or reconstruct yesterday's state. Reading it more often narrows the blind interval; it does not turn current state into event history.

That distinction kills the simple design. Storing only successful application writes produces a tidy but potentially fictional timeline. Polling only the zone produces snapshots with no trustworthy attribution. The useful unit is a reconciliation result that references both the logged intent and the observed state.

Current state isn't history.

The experiment: compare normalized sets, not API responses

Start with one customer zone and a deliberately narrow record scope. Normalize provider-specific responses into a small internal type, then compare the latest intended set with the latest observed set. Store the provider's immutable zone identifier on every intent event and observation. Domain names can change representation or be reused; joining on a display name invites guesswork.

This runnable TypeScript core stays behind the provider adapter. It deliberately does not couple the evidence model to a vendor response shape.

type RecordValue = { type: string; name: string; value: string; ttl: number };
type Snapshot = { zoneId: string; capturedAt: string; records: RecordValue[] };
type Drift = {
  kind: "missing" | "unexpected" | "changed";
  key: string;
  intended?: RecordValue;
  observed?: RecordValue;
};

const keyOf = (record: RecordValue): string =>
  `${record.type.toUpperCase()}|${record.name.toLowerCase()}`;

function reconcile(intended: Snapshot, observed: Snapshot): Drift[] {
  if (intended.zoneId !== observed.zoneId) {
    throw new Error("Cannot reconcile snapshots from different zones");
  }
  const wanted = new Map(intended.records.map((r) => [keyOf(r), r]));
  const live = new Map(observed.records.map((r) => [keyOf(r), r]));
  const drift: Drift[] = [];

  for (const [key, record] of wanted) {
    const current = live.get(key);
    if (!current) drift.push({ kind: "missing", key, intended: record });
    else if (current.value !== record.value || current.ttl !== record.ttl) {
      drift.push({ kind: "changed", key, intended: record, observed: current });
    }
  }
  for (const [key, record] of live) {
    if (!wanted.has(key)) drift.push({ kind: "unexpected", key, observed: record });
  }
  return drift;
}

const intended: Snapshot = {
  zoneId: "zone_customer_1842",
  capturedAt: "2026-09-25T02:00:00Z",
  records: [{
    type: "CNAME",
    name: "docs.example.com",
    value: "tenant.host.example",
    ttl: 300
  }]
};
const observed: Snapshot = {
  zoneId: "zone_customer_1842",
  capturedAt: "2026-09-25T02:05:00Z",
  records: [{
    type: "CNAME",
    name: "docs.example.com",
    value: "legacy.host.example",
    ttl: 300
  }]
};

console.log(JSON.stringify(reconcile(intended, observed), null, 2));
Enter fullscreen mode Exit fullscreen mode

The example calls a target changed when its value or TTL differs. Production normalization also needs an explicit policy for multi-value records, case rules by record type, trailing dots, and provider defaults. Those are schema decisions, not details to bury inside string comparison. Keep the raw provider response beside the normalized snapshot when evidence retention rules allow it; normalization helps comparison, while the raw form preserves what was returned.

The five-minute gap above is illustrative, not a recommended polling interval. Choose cadence after measuring how often managed zones change, how quickly a compliance control must detect drift, provider limits, and zone count. More polling has a direct request and storage cost. A solo team should spend that budget where the detection objective demands it.

What should the evidence record contain?

For an application-originated change, retain the stable zone ID, actor or service identity, request ID, requested record set, decision time, and outcome. For an observation, retain the same zone ID, capture time, normalized records, and enough provider identity to trace the source. A reconciliation run then stores its comparison window and findings.

The zone ID is non-negotiable. It makes the intent event, observation, and finding joinable without fuzzy matching. A domain name still belongs in the evidence for readability, but it should not be the relational key.

Join first.

Three outcomes deserve different handling. missing means intended state is absent from the live read. changed means the key exists but material fields differ. unexpected means the zone contains a managed-scope record with no corresponding current intent. The last case is the signal that application-only logging cannot produce.

Do not automatically label every difference a security incident. DNS propagation, an approved migration, or ownership outside the product's managed scope may explain it. Record the discrepancy first, then apply a policy that understands timing and ownership. Detection and judgment are separate steps.

Provider choices change collection, not the evidence model

Cloudflare DNS, Amazon Route 53, and Google Cloud DNS can all supply live state. Their surrounding audit products differ: Cloudflare exposes account audit logs, AWS records Route 53 API activity through CloudTrail, and Google Cloud documents Cloud DNS audit logging through Cloud Audit Logs. Those logs can strengthen attribution, but they belong beside a live read rather than replacing it; retention, enabled log categories, and administrative boundaries vary by platform and account configuration.

Option Useful fit Boundary to design around
Cloudflare DNS plus Audit Logs Teams already managing zones in Cloudflare Keep product actor context and current observations in your evidence model
Amazon Route 53 plus CloudTrail AWS control planes already aggregating CloudTrail Map AWS resource identity to the product's stable zone ID
Google Cloud DNS plus Cloud Audit Logs GCP systems with established audit-log routing Confirm enabled log categories and still perform live reads
Provider-neutral adapter Products supporting multiple DNS backends You own normalization, scheduling, retention, and reconciliation

Infrai puts 295 routes across 20 modules behind one key and one REST API, which is a reasonable provider-neutral fit when a small team values discovery over adding another SDK. Plain HTTP works from any language or runtime, with no SDK to install. Its public, keyless discovery surface describes request and response schemas, billing, and runnable examples, so wiring a new capability starts by reading one endpoint rather than learning a client library. The tradeoff is an extra platform dependency that a single-provider product may not need.

The minimal collector below uses the verified log-search and DNS-record-list routes. It keeps the credential in the environment, sends an explicit method, checks response status, and backs off on HTTP 429, with a hard ceiling of four retries. INFRAI_BASE_URL must be the documented v1 API base.

const baseUrl = process.env.INFRAI_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
if (!baseUrl || !apiKey) throw new Error("Missing Infrai environment variables");

async function read(path: string, attempt = 0): Promise<unknown> {
  const response = await fetch(`${baseUrl}${path}`, {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` }
  });
  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1000
      : 500 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return read(path, attempt + 1);
  }
  if (!response.ok) {
    throw new Error(`${response.status}: ${await response.text()}`);
  }
  return response.json();
}

const [intentHistory, publishedRecords] = await Promise.all([
  read("/logs/search"),
  read("/dns/record/list")
]);
console.log(JSON.stringify({ intentHistory, publishedRecords }, null, 2));
Enter fullscreen mode Exit fullscreen mode

I would still choose the native stack when a team needs its cloud's identity and governance integration; Route 53 with CloudTrail or Cloud DNS with Cloud Audit Logs fits those environments better. A neutral adapter is attractive for a product that must span DNS providers, but it does not define record normalization, evidence retention, or reconciliation cadence for you. This boundary matters more than a little initial wiring.

DMARC is relevant when the product configures email authentication records. RFC 7489 defines the DNS-published DMARC policy and reporting model. It does not provide general zone change history, so treat DMARC reports as domain-specific evidence rather than a replacement for control-plane reconciliation.

Measure before you copy the schedule

The first implementation can be small: capture every product-originated intent, take a baseline live snapshot, reconcile on a fixed schedule, and retain the finding with pointers to both inputs. Alert only after defining an ownership boundary and a propagation allowance. Otherwise the system will page on records it never managed or changes that have not converged.

Before increasing frequency, measure zones reconciled per run, reconciliation duration, throttled or failed reads, and the age of the oldest unchecked zone. Also track findings by missing, changed, and unexpected, plus time to acknowledge a finding. These numbers reveal whether you need faster polling, partitioned workers, or a narrower managed scope. They beat copying somebody else's interval.

Measure first.

My decision rule is conservative: use native provider audit logs as extra evidence when they already exist, but make the durable contract the pair of application intent and periodic live observation, joined by zone ID. This costs more storage and read traffic than either simple approach. It also answers both halves of an audit without pretending one incomplete ledger is complete.

References

Top comments (0)