Distributed Mode Technical Guide

Comprehensive documentation of go53’s multi-node replication system, covering architecture, cryptographic signing, protocol design, vector clocks, and Merkle tree-based integrity repair.

Overview

Distributed mode enables multi-node go53 clusters where all nodes accept mutations locally and replicate state changes to peers through signed events. This design supports eventual consistency with cryptographic integrity guarantees and automated repair of divergent state.

Key characteristics:

Use case: Distributed mode is suited for clusters needing low-latency API-driven DNS updates across geographically distributed nodes without primary–secondary write constraints.

Architecture

A go53 distributed cluster consists of independent nodes, each running with mode=distributed. Nodes form a mesh topology where each node is configured with a set of peer endpoints. In a three-node cluster, for example, each node dials the other two and accepts incoming connections from them, creating redundancy and fault tolerance.

Cluster Topology

The diagram below shows a three-node distributed cluster where events flow through multiple paths:

graph TB
  subgraph Cluster["Distributed Cluster"]
    direction TB
    NodeA["Node A<br/>node-a"]
    NodeB["Node B<br/>node-b"]
    NodeC["Node C<br/>node-c"]
  end

  subgraph Clients["Clients"]
    direction TB
    DNS1["DNS Clients"]
    API1["API Clients"]
  end

  NodeA -->|TLS sync| NodeB
  NodeB -->|TLS sync| NodeA
  NodeB -->|TLS sync| NodeC
  NodeC -->|TLS sync| NodeB
  NodeA -->|TLS sync| NodeC
  NodeC -->|TLS sync| NodeA

  DNS1 -->|UDP/TCP<br/>:53| NodeA
  DNS1 -->|UDP/TCP<br/>:53| NodeB
  DNS1 -->|UDP/TCP<br/>:53| NodeC
  API1 -->|HTTP<br/>:8053| NodeA
  API1 -->|HTTP<br/>:8053| NodeB
  API1 -->|HTTP<br/>:8053| NodeC

Each node exposes three independent listeners:

ListenerPortPurposeClients
DNS:53 (UDP/TCP)Authoritative DNS query handlingRecursive resolvers, clients
API:8053 (HTTP/HTTPS)Zone management, record operations, discoveryOperators, automation, go53ctl
Sync:53530 (TCP/TLS)Peer-to-peer event replicationOther nodes in the cluster

Node Identity

Each node has a stable identity consisting of:

Encryption & Security

Ed25519 Signing

All events and critical handshake messages are signed with Ed25519, a modern elliptic-curve signature scheme offering 128 bits of symmetric security. go53 uses Ed25519 for three purposes:

  1. Event signatures: Each event carries a base64-encoded Ed25519 signature over the canonical JSON representation of its payload (including EventID, Origin, Seq, Entity, EntityType, Zone, RRType, Name, Operation, Value, Vector, and CreatedAt).
  2. HELLO frame proof: The initial handshake frame includes a signed nonce challenge-response to verify node identity before accepting sync.
  3. Automatic TLS certificate: A self-signed X.509 certificate is generated from each node’s Ed25519 key, presenting the public key in the certificate’s subject and allowing peers to verify the connection endpoint matches the expected node.

Peer Trust Model

Rather than relying on a certificate authority, distributed mode uses a public key pinning model:

Transport Security

The sync socket can operate in four modes:

TransportEncryptionAuthUse Case
tcpNoneEd25519 signatures onlyDevelopment, air-gapped networks
tlsTLS 1.3, public key pinningEd25519 signatures + TLS cert verificationProduction, untrusted networks
mtlsTLS 1.3, mutual authClient + server certs, both pinnedZero-trust networks, strict enforcement

Recommended: Use tls or mtls transport in production. TLS 1.3 provides modern cipher suites and forward secrecy, while Ed25519 signatures provide origin authentication independent of the transport layer.

Cryptographic Flow

The diagram below illustrates how encryption and signing flow through a typical replication event:

sequenceDiagram
  participant App as Client API
  participant Node as go53 Node
  participant Store as Storage
  participant Peer as Peer Node

  App->>Node: PATCH /api/zones/example.com./records/A<br/>(new record)
  Node->>Store: Persist record
  Node->>Node: Create Event{EventID, Origin, Seq, ..., Vector}
  Node->>Node: Sign Event with Ed25519 private key<br/>Signature = Sign(canonical JSON)
  Node->>Peer: Push EVENT frame (TLS encrypted)
  Peer->>Peer: Verify Ed25519 signature over Event
  Peer->>Peer: Apply record if signature valid
  Peer->>Store: Persist record locally
  Peer->>Node: Send ACK frame

Protocol Details

Transport Layer

The sync protocol uses a persistent TCP or TLS socket with a length-prefixed frame format. Each frame is a JSON object prefixed by a 4-byte big-endian integer indicating the frame size in bytes. Frames can reach up to 16 MB.

┌─────────────┬──────────────────┐
│ 4-byte size │ JSON frame body  │
│ (big-endian)│ (UTF-8 encoded)  │
└─────────────┴──────────────────┘

This design allows pipelined frames and simple framing without additional delimiters or escape sequences.

Connection Lifecycle

Distributed nodes establish sync connections following this sequence:

sequenceDiagram
  participant Local as Local Node
  participant Remote as Remote Node

  Local->>Local: Dial tls://remote:53530
  Local->>Remote: Send HELLO frame<br/>{type: "HELLO", node_id: "local",<br/>nonce: "...", proof: "..."}
  Remote->>Remote: Verify signature(nonce) matches<br/>local's public key
  Remote->>Local: Send HELLO frame<br/>(signed response)
  Local->>Local: Verify remote's proof
  Note over Local,Remote: Connection established<br/>Ready for EVENT exchanges
  opt Sync required
    Local->>Remote: Send VECTOR_REQUEST
    Remote->>Local: Send VECTOR frame<br/>(current vector clock)
  end
  opt Divergence detected
    Local->>Remote: Send MERKLE_ROOTS_REQUEST
    Remote->>Local: Send MERKLE_ROOTS frame
  end

Frame Flow During Replication

When a record is created or updated on one node, the event propagates to peers through the following flow:

graph LR
  A["Local mutation<br/>API request"] --> B["Create Event<br/>with Vector"]
  B --> C["Sign with<br/>Ed25519"]
  C --> D["Push EVENT frame<br/>to peer 1"]
  C --> E["Push EVENT frame<br/>to peer 2"]
  D --> F["Peer 1 verifies<br/>signature"]
  E --> G["Peer 2 verifies<br/>signature"]
  F --> H["Peer 1 applies<br/>to local store"]
  G --> I["Peer 2 applies<br/>to local store"]
  H --> J["Send ACK"]
  I --> K["Send ACK"]
  J --> L["Replication<br/>complete"]
  K --> L

Vector Clocks

Concept

Vector clocks are a distributed systems primitive that establish a causal partial order over events. In go53, each event carries a map of {node_id: sequence_number} representing the event clock state at the time of creation.

For example, in a three-node cluster where node-a makes a record change:

{
  "event_id": "abc123...",
  "origin": "node-a",
  "seq": 5,
  "vector": {
    "node-a": 5,
    "node-b": 3,
    "node-c": 4
  },
  "entity": "zone:example.com,rrtype:A,name:www.example.com.",
  "operation": "UPSERT"
}

This vector tells us that:

Causal Ordering

Vector clocks enable conflict resolution without explicit coordination:

Sync Protocol

Nodes periodically (default 30 seconds) perform a background sync:

sequenceDiagram
  participant Local as Local Node
  participant Remote as Remote Node

  Local->>Remote: VECTOR_REQUEST
  Remote->>Remote: Gather current vector<br/>per origin node
  Remote->>Local: VECTOR frame<br/>{"vector": {"node-a": 10, ...}}

  Local->>Local: Compare vectors
  Local->>Local: Detect missing events

  alt Missing events exist
    Local->>Remote: EVENTS_REQUEST<br/>(origin, after_seq)
    Remote->>Remote: Fetch events<br/>after sequence
    Remote->>Local: EVENTS frame
    Local->>Local: Apply events to store
  end

  Note over Local,Remote: Vector sync<br/>ensures eventual<br/>consistency

Merkle Tree Repair

Motivation

While vector clocks and event replication provide eventual consistency, bugs, network partitions, or storage corruption can cause two nodes to diverge silently. Merkle trees provide a compact, cryptographic mechanism to detect these divergences without comparing entire datasets.

Tree Structure

For each zone, go53 builds a Merkle tree with two layers:

The diagram below illustrates the tree for a zone with a few records:

graph BT
  subgraph Zone["Zone: example.com"]
    Leaf1["Leaf: A www<br/>Hash: h1"]
    Leaf2["Leaf: A mail<br/>Hash: h2"]
    Leaf3["Leaf: MX zone<br/>Hash: h3"]
  end

  Branch1["Branch: 7a-7f<br/>Hash: branch_hash_1"]
  Branch2["Branch: 6d-73<br/>Hash: branch_hash_2"]

  Root["Root<br/>Hash: root_hash"]

  Leaf1 --> Branch1
  Leaf2 --> Branch1
  Leaf3 --> Branch2
  Branch1 --> Root
  Branch2 --> Root

Repair Protocol

Periodically, after vector clock sync, nodes compare Merkle roots for each zone:

  1. Request roots: Local node asks peer for zone Merkle roots.
  2. Compare roots: If a root differs, the zone is out of sync.
  3. Request branches: Local node requests all branch hashes for the mismatched zone.
  4. Compare branches: Find which branch prefixes differ.
  5. Request leaves: Request leaf hashes for differing prefixes.
  6. Compare and repair: For differing leaf hashes, request the full entity values from peer and apply the latest (according to vector clock).

This is more efficient than comparing all zone records directly, as it only sends necessary details after narrowing down to specific prefixes and entities.

sequenceDiagram
  participant Local as Local Node
  participant Remote as Remote Node

  Local->>Remote: MERKLE_ROOTS_REQUEST
  Remote->>Local: MERKLE_ROOTS<br/>{"roots": {"example.com": root_hash}}

  alt Roots differ
    Local->>Remote: MERKLE_BRANCHES_REQUEST<br/>(zone, prefixes to check)
    Remote->>Local: MERKLE_BRANCHES<br/>all branch hashes
    Local->>Local: Compare branch hashes
    Local->>Remote: MERKLE_LEAVES_REQUEST<br/>(zone, differing_prefixes)
    Remote->>Local: MERKLE_LEAVES<br/>leaf hashes for branches
    Local->>Local: Compare leaf hashes
    alt Leaf differs
      Local->>Remote: Request full event<br/>for entity
      Remote->>Local: Send event data
      Local->>Local: Apply event to store
    end
  else Roots match
    Note over Local,Remote: Zone in sync
  end

Cryptographic Integrity

Because each entity value is hashed as part of the leaf computation, and leaves feed into branches and the root, any undetected corruption is extremely unlikely to go unnoticed. Additionally, all event data that updates entity values is itself signed with Ed25519, providing a secondary integrity check.

Event Replication Flow

Example: Creating a DNS Record

This section walks through the complete flow when an operator uses the API to create a record on one node in a three-node cluster:

sequenceDiagram
  participant User as Operator
  participant NodeA as Node A (Primary)
  participant NodeB as Node B
  participant NodeC as Node C

  User->>NodeA: curl -X POST /api/zones/ex.com/records/A<br/>{"name": "www", ...}

  NodeA->>NodeA: 1. Validate request
  NodeA->>NodeA: 2. Generate EventID (UUID)
  NodeA->>NodeA: 3. Increment local Seq<br/>(e.g., seq=42)
  NodeA->>NodeA: 4. Build Vector<br/>{"node-a": 42, "node-b": 39, "node-c": 41}
  NodeA->>NodeA: 5. Create Event JSON<br/>{event_id, origin: "node-a",<br/>seq: 42, entity, operation,<br/>value, vector, created_at}
  NodeA->>NodeA: 6. Sign with Ed25519 key
  NodeA->>NodeA: 7. Persist Event to storage
  NodeA->>NodeA: 8. Apply record to memory
  NodeA->>User: 200 OK (record created)

  par Peer replication
    NodeA->>NodeB: EVENT frame (TLS-wrapped JSON)
    NodeA->>NodeC: EVENT frame (TLS-wrapped JSON)
  end

  NodeB->>NodeB: 1. Decode JSON
  NodeB->>NodeB: 2. Verify Ed25519 signature
  NodeB->>NodeB: 3. Check vector (not in future)
  NodeB->>NodeB: 4. Persist Event to storage
  NodeB->>NodeB: 5. Apply record to memory
  NodeB->>NodeA: ACK frame

  NodeC->>NodeC: 1. Decode JSON
  NodeC->>NodeC: 2. Verify Ed25519 signature
  NodeC->>NodeC: 3. Check vector (not in future)
  NodeC->>NodeC: 4. Persist Event to storage
  NodeC->>NodeC: 5. Apply record to memory
  NodeC->>NodeA: ACK frame

  Note over NodeA,NodeC: All three nodes now<br/>have consistent state

Conflict Resolution

If two operators simultaneously create or update the same record on different nodes, the events will have different vectors and origins. Each node will eventually receive both events. The conflict resolution rule is simple:

go53’s conflict resolution is deterministic: given the same set of concurrent events, all nodes arrive at the same conclusion without further coordination. This ensures strong eventual consistency.

Frame Types

The sync protocol defines distinct frame types for different communication patterns. All frames are JSON-encoded and length-prefixed.

Frame TypeDirectionPurpose
HELLOBidirectionalHandshake with nonce, signature proof, and node identity. Verifies peer Ed25519 key.
EVENTUnidirectional (push)Replicates a single signed event (zone record, TSIG key, DNSSEC key, config).
ACKResponse to EVENTAcknowledges receipt and processing of an event.
VECTOR_REQUESTUnidirectional (request)Asks peer for its current vector clock state.
VECTORResponse to VECTOR_REQUESTReturns the peer’s vector clock: {"vector": {"node-a": 42, ...}}.
EVENTS_REQUESTUnidirectional (request)Requests events from a specific origin after a sequence number.
EVENTSResponse to EVENTS_REQUESTReturns an array of events matching the request criteria.
MERKLE_ROOTS_REQUESTUnidirectional (request)Requests Merkle root hashes for all zones.
MERKLE_ROOTSResponse to MERKLE_ROOTS_REQUESTReturns {"merkle_roots": {"zone": root_hash, ...}}.
MERKLE_BRANCHES_REQUESTUnidirectional (request)Requests branch hashes for a zone and optional prefix filters.
MERKLE_BRANCHESResponse to MERKLE_BRANCHES_REQUESTReturns branch hashes: {"merkle_branches": {"prefix": {hash, leaf_count}, ...}}.
MERKLE_LEAVES_REQUESTUnidirectional (request)Requests leaf hashes for specific branch prefixes in a zone.
MERKLE_LEAVESResponse to MERKLE_LEAVES_REQUESTReturns leaf hashes: {"merkle_leaves": {"entity": leaf_hash, ...}}.
MERKLE_REPAIR_REQUESTUnidirectional (request)Requests full event data for entities that differ in Merkle leaves.
ERRORResponse (any)Signals a processing error; includes an error string field.

HELLO Frame Structure

The HELLO frame is the first message sent on a sync connection. It establishes node identity and proves possession of the Ed25519 private key:

{
  "type": "HELLO",
  "node_id": "node-a",
  "nonce": "base64_encoded_random_bytes",
  "proof": "base64_ed25519_signature_of_nonce"
}

The receiver verifies the proof against the nonce using the pinned public key for the node_id. If verification fails, the connection is closed.

EVENT Frame Structure

The EVENT frame carries a replicated state change:

{
  "type": "EVENT",
  "event": {
    "event_id": "550e8400-e29b-41d4-a716-446655440000",
    "origin": "node-a",
    "seq": 42,
    "entity": "zone:example.com,rrtype:A,name:www.example.com",
    "entity_type": "zone_record",
    "zone": "example.com.",
    "rrtype": "A",
    "name": "www.example.com.",
    "operation": "UPSERT",
    "value": {"ttl": 300, "ip": "192.0.2.1"},
    "vector": {"node-a": 42, "node-b": 39, "node-c": 41},
    "created_at": 1717686400000,
    "signature": "base64_ed25519_signature"
  }
}

Peers must:

  1. Verify the event’s Ed25519 signature using the origin node’s public key.
  2. Check that the vector does not claim a future state (no component > local knowledge).
  3. Apply the operation (UPSERT or DELETE) to storage and memory.
  4. Send an ACK frame.

Data Model

Entities

Distributed mode replicates four types of entities, each identified uniquely:

Entity TypeEntity Key ComponentsOperationsReplicated
Zone Recordzone:rrtype:name — e.g. zone:example.com,rrtype:A,name:www.example.comUPSERT, DELETEYes
TSIG Keytsig_key:keyname — e.g. tsig_key:update.example.comUPSERT, DELETEYes
DNSSEC Keydnssec_key:zone:keyid — e.g. dnssec_key:example.com,keyid:12345UPSERT, DELETEYes
ConfigconfigUPSERTYes (selective)

Event Structure

Each replicated event has the following fields:

FieldTypeDescription
event_idUUID stringGlobally unique event identifier (v4 random).
originStringNode ID of the event originator (e.g., “node-a”).
sequint64Sequence number for this origin (incremented per event).
entityStringUnique identifier for the data being changed.
entity_typeStringType of entity: “zone_record”, “tsig_key”, “dnssec_key”, or “config”.
zoneStringZone name (for zone records, DNSSEC keys; empty for global config/TSIG keys).
rrtypeStringRR type in uppercase (for zone records; empty for others).
nameStringOwner name (for zone records; key name for TSIG keys).
operationString“UPSERT” (create or update) or “DELETE”.
valueJSON objectThe data being replicated (RRset, key config, etc.). Null or empty for DELETE.
vectorMap<string, uint64>Vector clock at event creation: {node_id: sequence_number, …}.
created_atUnix timestamp (ms)When the event was created.
signatureBase64 stringEd25519 signature over canonical JSON of all fields except signature.

Vector Clock Storage

Each node maintains a persistent EntityClock record for every observed origin:

{
  "origin": "node-b",
  "seq": 47,
  "vector": {"node-a": 50, "node-b": 47, "node-c": 45}
}

This is used to:

Config Replication

Live configuration changes (e.g., adjusting DNSSEC TTL, enabling EDNS) replicate as config events. However, node-local settings are excluded:

This ensures each node retains its identity and peer configuration independently, preventing accidental overwrite by a peer’s config event.


Related: