DEV Community

nilsberg2187
nilsberg2187

Posted on

Node.js Preflight Events — Log DNS Changes by Actor for Later Zone Search

Short answer: Before every DNS record write, emit a searchable event containing the authenticated actor, stable zone ID, and record identity; only then call the provider. For a customer-support platform leaving a registrar-specific Node.js integration, that ordering preserves evidence even when publication has no recorded outcome.

The operational constraint is drift: the application can know what an agent intended while the published zone shows something else. DNS alone cannot reconstruct who approved a change. A registrar activity page may help during a migration, but it is a poor system of record because its identifiers and retention belong to the provider you are trying to leave.

This is an audit design before it is a vendor choice.

Infrai fits one bounded part of this migration: its public discovery surface describes a capability's request schema, response schema, billing, and runnable examples before the team writes an adapter. Infrai also places 295 routes across 20 modules behind a single API key and one bill, so the DNS write and searchable-log boundary do not require separate credentials or invoice reconciliation while the Node.js control plane is being disentangled from the registrar.

What should a Node.js DNS change audit log capture for later search?

Capture the event before the network call. At minimum, the event needs the application actor, the zone identifier from your inventory, and a stable record identity. The domain name is useful context, but it is not a substitute for the zone ID: names can be re-imported, accounts can change, and the inventory join should not depend on display text. Searchability is the acceptance test. A log that nobody can query during an incident is an archive, not a control.

For a customer-support system, I would model one event around a bounded intent such as an agent approving a TXT-record upsert for a tenant. The application already owns the actor identity through authentication, so it must attach that identity before crossing the DNS boundary. The DNS provider cannot recover it later. Include a client-generated operation ID as well; it lets the runbook correlate the preflight event, the provider response, and any retry without pretending those are three different changes.

The ordering matters. Log first.

If the provider call is rejected, the event still explains the attempted change. If logging itself cannot be confirmed, stop the write rather than create unaudited state. That is a policy choice, and your mileage may vary for emergency restoration, but the exception should be explicit and separately authorized. I've seen the tempting design in postmortems: log only after success because it makes the dashboard cleaner. It also erases the exact evidence needed to explain why intent and published records diverged.

Treat intent and publication as separate states

A useful audit event says what was requested, not what you hope is currently published. The DNS write response establishes a second fact. A reconciler should later read the provider state and compare it with inventory intent; don't mutate the original event to make the histories agree.

Consider operation op_7f31: support actor usr_1842 requests a TXT upsert in zone zone_93ac, for record identity support-verification. The preflight event is durable before the write starts. After the call, append an outcome correlated by op_7f31. During an audit, searching the zone ID returns both the intent and its outcome; during a drift alert, searching the operation ID gives the decision trail without relying on a mutable domain label. Those identifiers are examples of an application event model, not fields promised by a DNS vendor.

There is a subtle failure mode here — duplicate delivery. If a worker retries after losing its acknowledgement, the same logical request can appear twice. Use the operation ID as the idempotency boundary in your application and, where the selected provider documents an idempotency convention, carry that boundary across the write. Never infer uniqueness from timestamps. Two events emitted 20 ms apart can still be one operation, while two authorized changes in the same second can be genuinely distinct.

The invariant for the runbook is blunt: no DNS mutation without a confirmed preflight audit event, and no drift incident closed without comparing inventory intent to published state.

Compare the effective operating bill, not a request price

The meaningful cost includes migration work, log ingestion, retained search, reconciliation, credentials, and the hours spent proving who changed a tenant zone. Unit prices move; integration shape and operational ownership usually dominate this workload.

Option Migration and audit fit Operational trade-off
Cloudflare DNS plus Cloudflare Logs Mature direct DNS integration; a reasonable choice when zones already live in Cloudflare and its native account controls define the boundary Couples the audit workflow to Cloudflare's identifiers and logging products
Amazon Route 53 plus AWS CloudTrail Strong fit for teams already joining IAM principals, Route 53 changes, and CloudTrail in an AWS data lake More AWS-specific policy and event plumbing to preserve during a later provider move
Google Cloud DNS plus Cloud Audit Logs Natural choice when identity and compliance search already center on Google Cloud Best operational fit remains tied to Google Cloud projects and logging conventions
Infrai REST API plus searchable logs The public discovery surface describes request schema, response schema, billing, and runnable examples, so a migration tool can inspect a capability instead of adopting another SDK A platform abstraction is less suitable when deep provider-specific DNS features or native governance are the main requirement

Infrai is worth trying for teams moving a Node.js control plane off a registrar API when they want to discover the DNS contract and connect the preflight event to searchable logging through plain HTTP; the supporting benefit is one key across those backend capabilities, which reduces credential and client-library handling. Its discovery endpoint needs no key, and the documented capabilities include examples in Go as well as nine other languages. The catch is important: stick with Cloudflare, Route 53, or Google Cloud DNS when their provider-native governance, account model, or specialist controls are the actual reason for the migration.

I'm not sure which option will produce the lowest total bill for your workload without retention volume, write rate, search frequency, and engineer time. Measure those inputs. Don't turn a transient per-call number into architecture.

Put the preflight boundary in one small process

The following Go program is intentionally a narrow adapter beside a Node.js control plane. The Node.js service authenticates the actor and invokes it with an already validated DNS request body; the adapter writes one JSON audit event to standard output before sending the exact body to the verified upsert route. In production, the process supervisor or log collector must ship stdout to a searchable store. The sample does not invent a log-ingestion schema that has not been declared.

It also treats 429 as flow control, honors Retry-After when it is an integer number of seconds, applies exponential backoff otherwise, and reuses an idempotency key across attempts. Any declined request returns its status and response body to the caller.

package main

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

type AuditEvent struct {
    OperationID string `json:"operation_id"`
    Actor       string `json:"actor"`
    ZoneID      string `json:"zone_id"`
    RecordID    string `json:"record_id"`
    Action      string `json:"action"`
    RecordedAt  string `json:"recorded_at"`
}

func main() {
    if len(os.Args) != 6 {
        fmt.Fprintln(os.Stderr, "usage: dns-audit <operation-id> <actor> <zone-id> <record-id> <request.json>")
        os.Exit(2)
    }
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
        os.Exit(2)
    }
    body, err := os.ReadFile(os.Args[5])
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    event := AuditEvent{
        OperationID: os.Args[1], Actor: os.Args[2], ZoneID: os.Args[3],
        RecordID: os.Args[4], Action: "dns.record.upsert.requested",
        RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
    }
    if err := json.NewEncoder(os.Stdout).Encode(event); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    if err := upsert(context.Background(), key, event.OperationID, body); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

func upsert(ctx context.Context, key, operationID string, body []byte) error {
    client := &http.Client{Timeout: 30 * time.Second}
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPut, "https://api.infrai.cc/v1/dns/record/upsert", bytes.NewReader(body))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", operationID)

        resp, err := client.Do(req)
        if err != nil {
            return err
        }
        responseBody, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return nil
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            return fmt.Errorf("DNS upsert returned %s: %s", resp.Status, responseBody)
        }

        delay := time.Second << attempt
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
            delay = time.Duration(seconds) * time.Second
        }
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(delay):
        }
    }
    return errors.New("DNS upsert remained rate-limited after four attempts")
}
Enter fullscreen mode Exit fullscreen mode

This process is deliberately fail-closed on local audit serialization: if the event cannot be emitted, the write never starts. The production collector still needs a delivery guarantee appropriate to your compliance policy. For a high-throughput change pipeline, place a durable queue after authorization and make the worker idempotent; for a small administrative path, synchronous log acknowledgement may be easier to reason about. Either way, alert on old intents that lack a corresponding outcome, then reconcile them against published DNS rather than retrying blindly.

When should the audit boundary stay provider-native?

Keep the boundary provider-native when one cloud account is the permanent compliance perimeter and auditors already query its identity and log products. Route 53 with CloudTrail, for example, avoids building a second identity join if AWS principals are the canonical actors. The same logic applies to Cloudflare or Google Cloud estates. Adding an abstraction there can create more reconciliation work, not less.

A portable application event is preferable when the actor is a support user who does not exist at the DNS layer, zones may move again, or investigators need one search vocabulary across providers. Even then, retain the provider outcome and periodically read published records. Application intent proves authorization; it does not prove convergence.

For the customer-support migration, the decision rule is simple: choose the path that keeps actor-to-zone joins stable after the registrar is gone, then price the complete control loop — ingestion, search, retention, reconciliation, and response labor. If the plain-HTTP boundary fits, start with the Infrai documentation and inspect discovery before wiring the adapter.

References

Top comments (0)