Go DNS Audit Reports — Dated Evidence for Mail Record Changes

go dev.to

Read every managed DNS zone and its records on a schedule, render a dated report, and archive the artifact. Short answer: for company mail, the control is complete only when each run leaves durable evidence containing the zone identifier and the MX-related record set; a live DNS console proves current state, but it cannot prove what the configuration was at an earlier audit boundary.

Treat an empty result as failure. A polished report containing zero zones is more dangerous than a noisy failed job because it can pass a superficial review while documenting nothing.

This architecture decision optimizes for deliverability evidence, reconciliation, and replayable history. The scheduled reader is read-only, the renderer consumes a normalized snapshot, and the archive key includes the report date plus a content digest.

How should an unattended job produce a dated DNS configuration report?

The primary invariant is coverage: every expected zone must appear once, identified by a stable zone identifier that can later be joined to an internal domain inventory. Names alone are weak join keys; domains can move between accounts, labels can change, and two administrative systems may describe the same ownership boundary differently. Preserve both the identifier and the human-readable name.

The second invariant is evidence integrity. The archived input snapshot and rendered report need a digest derived from their bytes, not from a mutable database row that merely points at them. Re-running the renderer against identical normalized input should produce the same logical report, while the archive operation should use an idempotency key such as dns-mail-audit/<report-date>/<snapshot-sha256>. This is an exactly-once objective built over operations that may execute more than once: duplicate delivery is acceptable; duplicate, conflicting evidence is not.

The third invariant is explicit failure. Zero zones, an expected zone missing from the snapshot, a record-read failure, a render failure, or an archive failure must prevent the run from being marked complete. Partial output may help diagnosis, but it is not an audit artifact.

No exceptions.

For mail operations, capture MX and the TXT material relevant to SPF and DMARC without pretending that presence alone proves deliverability. RFC 7489 defines DMARC policy and reporting; an inventory report can preserve published state, but it does not replace message-flow tests, recipient-side telemetry, or policy evaluation. Compliance has the same boundary: the report supplies dated configuration evidence, while retention periods, access controls, reviewer approval, and legal sufficiency remain organizational decisions.

A useful run ledger is small: scheduled time, actual start and completion time, expected and observed zone counts, snapshot digest, renderer version, archive key, and final status. Keep it beside the artifact. This makes reconciliation mechanical: count scheduled runs, count successful ledger entries, and account for every gap.

Failure boundaries and the evidence chain

Separate acquisition from rendering. The collector enumerates zones, reads the records for each zone, and writes one normalized JSON snapshot; the renderer accepts only that snapshot; the archive writer stores the snapshot, rendered report, and run ledger together. A later reviewer can then determine whether a surprising report came from collection, normalization, or presentation.

The dangerous sequence is easy to miss. A credential or account-selection mistake can yield an empty collection without producing malformed JSON. The renderer then does its job perfectly and creates an authoritative-looking blank page. The control must fail at the collector-to-renderer boundary when len(zones) == 0, before any successful ledger entry exists.

Be strict here.

Retries belong at individual reads and at the archive write, with bounded backoff and an immutable run identity. Final status changes only after all expected zones reconcile, both artifacts are durable, and their digests are recorded. If schedules can overlap, acquire a lease keyed by reporting period or allow concurrent computation while making publication conditional on the same deterministic idempotency key. The latter wastes work but reduces dependence on a lock service.

A rendered PDF is often the review artifact an auditor accepts, whereas the JSON snapshot is the engineering evidence that makes rendering reproducible. Keep both. Current-state reads are inexpensive compared with the evidentiary value of a dated series, so retention should follow the audit obligation rather than a desire to minimize calls.

Option record: provider-native export or independent pipeline

These products can all participate in a sound design. The decisive question is where the authoritative zones live and how much independent evidence the control requires, not which logo appears on the report.

Option Best fit Evidence trade-off Operational boundary
Cloudflare DNS Zones administered in Cloudflare Its API can feed a dated evidence pipeline; the archive still needs explicit completeness and retention rules Provider account scope must reconcile with the internal inventory
Amazon Route 53 DNS governed with AWS identity and audit controls AWS-native automation can reduce integration spread, while a rendered cross-account report remains your responsibility Account and hosted-zone enumeration define coverage
Google Cloud DNS Teams standardized on Google Cloud projects and IAM Project-native automation is natural, but project inventory must be joined to the report to detect omission Project boundaries can fragment the evidence set
Infrai REST API A small team wants DNS reads behind one plain HTTP interface One key and a consistent API avoid another client library; the report still needs independent completeness checks Verified DNS reads sit within a surface of 295 routes across 20 modules

There is no universal winner. Provider-native APIs keep the shortest path to their own control plane and align with existing identity policy. A plain REST aggregation layer is attractive when the team refuses to maintain another SDK and wants any language capable of HTTP to run the workflow; a consistent interface also reduces integration variance. Breadth does not remove the need to verify the returned zone set against an internal inventory.

The trade-off is concrete: I would accept a little duplicated collection code to keep evidence close to an existing cloud identity boundary, but I would accept an aggregation dependency when cross-provider reconciliation is the harder problem. That is an architectural preference, not a deliverability measurement.

For Infrai, the relevant reads are GET /v1/dns/domain/list and GET /v1/dns/record/list. Request and response fields should be generated from the public discovery schema rather than inferred from prose. Pin that schema in source control, review changes, and map the response into the small internal contract below.

The critical path in Go

This program begins after acquisition. It consumes a normalized snapshot, rejects zero zones, sorts unstable input, writes a dated JSON snapshot and HTML report, and records SHA-256 digests in a ledger. That narrow contract keeps provider response shapes out of the renderer and makes the evidence path testable without network access.

Save it as main.go, then run go run main.go normalized.json evidence.

package main

import (
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "html/template"
    "io"
    "net/http"
    "os"
    "path/filepath"
    "sort"
    "strconv"
    "strings"
    "time"
)

type Record struct {
    Type, Name, Value string
}
type Zone struct {
    ID, Name string
    Records  []Record
}
type Snapshot struct {
    CapturedAt time.Time `json:"captured_at"`
    Zones      []Zone    `json:"zones"`
}
type Ledger struct {
    Status, ReportDate, SnapshotSHA256, ReportSHA256, ArchiveKey string
    ZoneCount int
}

func digest(b []byte) string {
    sum := sha256.Sum256(b)
    return hex.EncodeToString(sum[:])
}

func create(path string, data []byte) error {
    return os.WriteFile(path, data, 0o600)
}

func fetch(client *http.Client, baseURL, path, key string) ([]byte, error) {
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(http.MethodGet, baseURL+path, nil)
        if err != nil { return nil, err }
        req.Header.Set("Authorization", "Bearer "+key)
        res, err := client.Do(req)
        if err != nil { return nil, err }
        body, readErr := io.ReadAll(res.Body)
        res.Body.Close()
        if readErr != nil { return nil, readErr }
        if res.StatusCode == http.StatusTooManyRequests {
            wait := time.Second << attempt
            if seconds, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil {
                wait = time.Duration(seconds) * time.Second
            }
            time.Sleep(wait)
            continue
        }
        if res.StatusCode < 200 || res.StatusCode >= 300 {
            return nil, fmt.Errorf("GET %s: status %d: %s", path, res.StatusCode, strings.TrimSpace(string(body)))
        }
        return body, nil
    }
    return nil, fmt.Errorf("GET %s: rate limit persisted after retries", path)
}

func main() {
    if len(os.Args) != 3 {
        fmt.Fprintln(os.Stderr, "usage: go run main.go normalized.json evidence-dir")
        os.Exit(2)
    }
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" { panic("INFRAI_API_KEY is required") }
    baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
    if baseURL == "" { panic("INFRAI_BASE_URL is required") }
    client := &http.Client{Timeout: 30 * time.Second}
    domainsRaw, err := fetch(client, baseURL, "/v1/dns/domain/list", key)
    if err != nil { panic(err) }
    recordsRaw, err := fetch(client, baseURL, "/v1/dns/record/list", key)
    if err != nil { panic(err) }

    input, err := os.ReadFile(os.Args[1])
    if err != nil { panic(err) }
    var snapshot Snapshot
    if err := json.Unmarshal(input, &snapshot); err != nil { panic(err) }
    if len(snapshot.Zones) == 0 {
        panic("refusing to render an audit report with zero zones")
    }

    sort.Slice(snapshot.Zones, func(i, j int) bool {
        return snapshot.Zones[i].ID < snapshot.Zones[j].ID
    })
    normalized, err := json.MarshalIndent(snapshot, "", "  ")
    if err != nil { panic(err) }
    date := snapshot.CapturedAt.UTC().Format("2006-01-02")
    snapshotHash := digest(normalized)
    archiveKey := fmt.Sprintf("dns-mail-audit/%s/%s", date, snapshotHash)
    dir := filepath.Join(os.Args[2], date, snapshotHash)
    if err := os.MkdirAll(dir, 0o700); err != nil { panic(err) }
    if err := create(filepath.Join(dir, "domains.raw.json"), domainsRaw); err != nil { panic(err) }
    if err := create(filepath.Join(dir, "records.raw.json"), recordsRaw); err != nil { panic(err) }

    const page = `<!doctype html><html><body><h1>DNS mail audit — {{.Date}}</h1>
<p>Zones: {{len .Snapshot.Zones}}</p>{{range .Snapshot.Zones}}<h2>{{.Name}}</h2>
<p>Zone ID: <code>{{.ID}}</code></p><table><tr><th>Type</th><th>Name</th>
<th>Value</th></tr>{{range .Records}}<tr><td>{{.Type}}</td><td>{{.Name}}</td>
<td>{{.Value}}</td></tr>{{end}}</table>{{end}}</body></html>`
    t := template.Must(template.New("report").Parse(page))
    reportPath := filepath.Join(dir, "report.html")
    reportFile, err := os.Create(reportPath)
    if err != nil { panic(err) }
    if err := t.Execute(reportFile, map[string]any{
        "Date": date, "Snapshot": snapshot,
    }); err != nil { panic(err) }
    if err := reportFile.Close(); err != nil { panic(err) }
    report, err := os.ReadFile(reportPath)
    if err != nil { panic(err) }
    if err := create(filepath.Join(dir, "snapshot.json"), normalized); err != nil { panic(err) }

    ledger := Ledger{"complete", date, snapshotHash, digest(report), archiveKey, len(snapshot.Zones)}
    ledgerBytes, err := json.MarshalIndent(ledger, "", "  ")
    if err != nil { panic(err) }
    if err := create(filepath.Join(dir, "ledger.json"), ledgerBytes); err != nil { panic(err) }
    fmt.Println(archiveKey)
}
Enter fullscreen mode Exit fullscreen mode

The deterministic directory is intentional. A retry with the same date and digest addresses the same evidence; production orchestration should verify existing digests before accepting it as success, and treat any mismatch as an incident. It should also compare observed zone IDs with the expected inventory and alert when the observed count is zero before invoking this renderer.

HTML appears here because Go can render it without an external dependency. If the audit process requires PDF, make PDF generation a separate, versioned step and retain the snapshot beside it. The acceptance test should open the document, verify the date, count, identifiers, and record rows, then prove that a zero-zone fixture exits unsuccessfully.

Why reject screenshots and live consoles?

A screenshot is useful for a one-time investigation, and a live provider console is the right tool for an operator changing a record now. Neither is the system of record for a recurring control. Screenshots are difficult to reconcile across many zones, while a live page changes underneath the audit period and may omit identifiers required for later joins.

The rejected option is “open each console and capture evidence manually.” Its valid use case is a small, exceptional review in which someone must inspect provider-specific presentation or confirm a change interactively. Once the requirement becomes unattended, dated, and repeatable, manual capture fails on coverage and auditability even if every screenshot is accurate.

A provider-native scheduled export remains valid when all zones sit inside one administrative boundary and its output format, identity controls, and retention behavior satisfy audit policy. Choose it when reducing moving parts matters more than maintaining a provider-neutral snapshot. Choose the independent pipeline when zones span providers, when the mail inventory is maintained elsewhere, or when reviewers require one stable evidence format.

The decision rule is compact: schedule complete reads, fail closed on missing coverage, preserve stable identifiers, render a dated human-reviewable artifact, and retain the normalized source plus digests. Everything else is implementation choice.

References

Source: dev.to

arrow_back Back to Tutorials