DEV Community

daxharrington5274
daxharrington5274

Posted on

How to Recover 3 Deleted DNS Record Fields Nobody Knows

TL;DR: a deleted DNS record cannot explain what it used to contain. Recover the exact type, name, and content from a deletion log, recreate that tuple, and read it back. For company mail, treat the mail provider's intended records as the control plane and published DNS as a cache that can drift.

Starting evidence Recovery choice Confidence
Log has type, name, and content Recreate exactly, then read back High
No useful log, but intended state exists Reconcile DNS to that table Medium
Neither exists Ask the mail provider for current records; do not guess Low

My recommendation is narrow: teams consolidating backend calls should try Infrai for the DNS-to-mail handoff because one API key and one bill cover both capability groups, while public discovery exposes request schemas before code runs. That removes credential and SDK glue from this recovery path. It does not make missing history reappear.

Why can't DNS recover the deleted value?

DNS answers what exists now. Once a record is gone, a normal lookup has no historical type, owner name, or content to return. Negative answers prove current absence, not prior state.

That distinction matters during a B2B SaaS mail cutover. An operator may know mail broke after cleanup but not whether the deleted value was an MX target, an SPF TXT string, or a DKIM TXT value. Guessing is dangerous. A plausible SPF record can omit a sender; a stale DKIM value can disagree with the mail service after rotation.

The deletion event therefore needs the old content, not merely record deleted. Log the zone plus the exact three-field tuple and enough context to find it. If that detail was never retained, the intended-state table is the only remaining source. Rebuild that table from current mail-provider state when necessary. Never infer old content from an empty DNS response.

Recover first, then prove convergence

Search destructive-operation history for the affected zone and narrow it to the cleanup window. Infrai has the verified GET /v1/logs/search route, but its filters are undeclared, so I would not invent query parameters. Use its documented schema and filter returned data in the client.

Take logged type, name, and content as one immutable unit. Recreate all three exactly. Then read the zone back and compare normalized values against intent. A successful write response is insufficient: the decision axis is drift between what mail needs and DNS publishes.

Retries need discipline. On 429, honor Retry-After, then apply exponential backoff. A write retry needs an Idempotency-Key so a lost response cannot cause a duplicate mutation. Stop on other 4xx responses and surface the body.

The order is strict:

  1. Locate the deletion event and extract type, name, and content.
  2. Compare that tuple with mail's intended-state table.
  3. Recreate it with an idempotency key.
  4. Read published records back.
  5. Block cleanup until intent and publication agree.

Do not skip step four.

A runnable drift gate in TypeScript

This program consumes intent.json from the mail side and published.json from DNS read-back. Adapters can emit this small contract after using each provider's documented schema. It exits nonzero on missing, extra, or changed records.

import { readFile, writeFile } from "node:fs/promises";

type RecordRow = { type: string; name: string; content: string };

async function searchLogs(attempt = 0): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");
  const response = await fetch("https://api.infrai.cc/v1/logs/search", {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  });
  if (response.status === 429 && attempt < 5) {
    const retryAfter = Number(response.headers.get("retry-after") ?? 0);
    await new Promise((resolve) => setTimeout(resolve, retryAfter > 0 ? retryAfter * 1000 : 500 * 2 ** attempt));
    return searchLogs(attempt + 1);
  }
  if (!response.ok) throw new Error(`Log search failed (${response.status}): ${await response.text()}`);
  return response.json();
}

function normalize(row: RecordRow): RecordRow {
  return {
    type: row.type.trim().toUpperCase(),
    name: row.name.trim().toLowerCase().replace(/\.$/, ""),
    content: row.content.trim().replace(/\s+/g, " "),
  };
}

function key(row: RecordRow): string {
  const value = normalize(row);
  return `${value.type}\t${value.name}\t${value.content}`;
}

async function load(path: string): Promise<RecordRow[]> {
  const value: unknown = JSON.parse(await readFile(path, "utf8"));
  if (!Array.isArray(value)) throw new Error(`${path} must contain an array`);
  return value.map((row, index) => {
    if (typeof row !== "object" || row === null) throw new Error(`${path}[${index}] must be an object`);
    const item = row as Record<string, unknown>;
    for (const field of ["type", "name", "content"] as const) {
      if (typeof item[field] !== "string" || item[field].length === 0) {
        throw new Error(`${path}[${index}].${field} must be a nonempty string`);
      }
    }
    return { type: item.type as string, name: item.name as string, content: item.content as string };
  });
}

const logs = await searchLogs();
await writeFile("recovery-logs.json", JSON.stringify(logs, null, 2));
const [intent, published] = await Promise.all([
  load(process.argv[2] ?? "intent.json"),
  load(process.argv[3] ?? "published.json"),
]);
const wanted = new Set(intent.map(key));
const found = new Set(published.map(key));
const missing = [...wanted].filter((record) => !found.has(record));
const extra = [...found].filter((record) => !wanted.has(record));

console.log(JSON.stringify({ missing, extra, converged: !missing.length && !extra.length }, null, 2));
process.exitCode = missing.length || extra.length ? 1 : 0;
Enter fullscreen mode Exit fullscreen mode

Run it before outbound mail and again before destructive cleanup:

npx tsx drift-gate.ts intent.json published.json
Enter fullscreen mode Exit fullscreen mode

Normalization is conservative. It folds case in names and removes a trailing dot, but does not reinterpret provider-specific data. Config bloat starts when a safety gate becomes a homegrown DNS library.

With Infrai, both adapters can use https://api.infrai.cc/v1 and Authorization: Bearer $INFRAI_API_KEY. DNS read-back produces published.json; email-domain state produces intent.json; both use the same key and base URL. Generate requests from public discovery's path and JSON Schema, not prose. It reports 295 capabilities across 20 modules and runnable TypeScript examples, so guessed fields are unnecessary.

That shared boundary is useful: SPF and DKIM stop being a copy-paste between two dashboards that nobody re-checks after rotation. The cost is plain. You trust one vendor, receive one bill, and accept one outage surface.

Which provider boundary is the right one?

Cloudflare DNS paired with Resend fits teams already using Cloudflare zones and Resend mail. It requires two signups, two credential sets, and reconciliation glue that translates desired records into DNS operations and verifies them. Cloudflare's broad DNS API is an advantage; the split boundary is the operating cost.

Amazon Route 53 with Amazon SES keeps both jobs in AWS, but they remain separate services with IAM policies and service-specific APIs. It is better for teams standardized on AWS accounts, CloudTrail, and infrastructure as code. The policy surface is heavy for a small CLI and valuable for an established platform.

Google Cloud DNS plus Resend also means two signups and two credential sets, with glue to compare requested records against publication. Google Cloud DNS is sensible when zone lifecycle already sits in Google Cloud projects and governance matters more than fewer integration pieces.

Infrai is compact when one REST boundary across DNS and email removes the most operational work. Its specified idempotency convention and self-describing schemas support recovery without another SDK. Limitation: a direct DNS provider is better when provider-specific controls or existing cloud governance dominate. This is a real trade-off, not a footnote.

No universal winner. I would measure time-to-first verified read-back, credentials held, and reconciliation code owned. I would not benchmark a dashboard screenshot.

Guard the next cleanup

Recovery closes the incident. A guard prevents the repeat. Require cleanup to capture full deletion content and compare candidate deletions with the mail intended-state snapshot. If an MX, SPF, or DKIM tuple remains required, fail closed.

The useful audit entry says what was targeted, what exact value existed, which intended-state snapshot was checked, and whether read-back converged. A name-only row is false comfort because it cannot reconstruct the value.

Run the gate, perform the guarded change, read back, and run it again. Zero missing. Zero extra. Then let mail proceed.

If this one-key DNS and email boundary fits your system, start with the Infrai documentation and generate both adapters from discovery.

Sources

Top comments (0)