echo/docs/federation.md
2026-07-19 22:27:22 +00:00

5.8 KiB

Federation (gossip) — deployment and trust model

Echo runs perfectly as a single node; gossip is off unless you configure it. Only enable it if you actually need more than one node. This document explains the three deployment tiers and how to run each one safely.

TL;DR

You want… Do this
One services node (almost everyone) Nothing — leave [gossip] out of the config.
Redundancy/geo, your nodes only [gossip] + shared secret + mutual TLS + firewall.
Link with another operator's network [gossip.signing] (per-origin signatures) and TLS.

How gossip works

Each node owns an append-only event log. The log is split into two scopes:

  • Global events (account identity: register, password/cert, oper grants, suspensions, network bans) — these replicate to peers.
  • Local events (channel state, moderation queues, news) — these never leave the node.

Nodes sync via anti-entropy: each advertises a per-origin version vector, and peers send whatever Global entries the other is missing. Delivery is idempotent, so a re-sent entry is dropped, and each account event is an idempotent upsert.

Entries are applied strictly in order per origin, tracked by a (epoch, seq) cursor. Each node's seq increments per event; a compaction bumps that node's epoch and resets its seq to 0. A receiver therefore distinguishes the two forward jumps cleanly:

  • A relay gap (same epoch, a seq ahead of the receiver's) is deferred — the cursor doesn't advance, so the digest re-requests the missing seqs in order. This keeps a 3+-node relay mesh from silently dropping updates.
  • A compaction (a higher epoch beginning at seq 0) is adopted — the receiver takes the reset as the new authoritative baseline, so a peer converges across a compaction even if it was mid-sync.

The trust model

Authentication has two independent layers:

  1. The shared secret + TLS authenticate the connection — who may link at all.
  2. Per-origin signatures (optional) authenticate each record — which node is allowed to assert a given account/oper/ban change.

Without layer 2, trust is flat: any node that completes the handshake is believed completely. That is fine when every node is yours, and unsafe when a peer is run by someone else (a malicious or compromised peer could forge your accounts, grant itself oper, or push a network-wide ban). Layer 2 removes that: a peer can only assert changes to records authored by an origin whose key you trust, and only if the signature verifies.

Tier A — single node (default)

Leave [gossip] out entirely. Nothing to secure. This is the recommended posture for any network that doesn't specifically need multiple nodes.

Tier B — multiple nodes, one operator (HA / geo)

Flat trust is acceptable because every node is yours. Required hardening:

[gossip]
bind   = "10.0.0.1:7400"      # where this node listens for peers
secret = "<32+ random bytes>" # e.g. `openssl rand -base64 32`

[gossip.tls]                  # MANDATORY across any real network
cert = "/etc/echo/gossip.pem"
key  = "/etc/echo/gossip.key"
ca   = "/etc/echo/peer-ca.pem" # a peer's cert must be signed by this CA

[[peer]]
addr = "10.0.0.2:7400"
name = "node-b"               # must match the peer cert's name

Rules:

  • Never run gossip in plaintext over an untrusted network — the secret and all account data are on that wire. Echo warns loudly at startup if TLS is off.
  • Firewall the bind port to your peer nodes' IPs only.
  • Use a strong random secret and rotate it periodically.
  • 3+-node relay meshes converge correctly (out-of-order delivery is deferred and re-requested; compaction is handled by the epoch cursor — see "How gossip works"). The 2-node path is still the most exercised, so test your topology.

Tier C — cross-operator federation (per-origin signing)

To link with a network you do not fully control, add signatures. Each node holds an Ed25519 keypair; it signs every Global entry it authors, and verifies every Global entry it receives against the author's public key. A peer therefore cannot forge records for an origin whose private key it doesn't hold.

1. Generate a keypair on each node

echo --gen-gossip-key
# prints:  secret = "<base64>"   public = "<base64>"   (keep the secret private)

2. Configure signing

[gossip]
bind   = "..."
secret = "..."
[gossip.tls]
# ... (still required)

[gossip.signing]
# This node's PRIVATE key (from --gen-gossip-key). Keep it secret.
key = "<base64 ed25519 secret key>"

# Every origin whose entries you accept -> its PUBLIC key. Include your OWN
# origin (your server SID) so your entries verify after a restart, plus each
# partner's SID -> their public key.
[gossip.signing.trust]
"1AA" = "<this node's public key>"
"2BB" = "<partner node's public key>"

Behavior when [gossip.signing] is present:

  • Global entries this node authors are signed with key.
  • An incoming Global entry is rejected unless its origin is listed in trust and its signature verifies against that public key.
  • Local entries are never signed or gossiped.

Bootstrapping note

Signing is greenfield-clean: a network that enables signing from day one signs every entry and verifies cleanly. Retrofitting signing onto a node whose log already contains unsigned entries requires re-signing that log first (unsigned Global entries won't verify at a signing peer). Enable signing before you first federate, not after.

What is intentionally out of scope

  • A public/anonymous federation directory. Peers are explicit config.
  • Byzantine consensus. Signing proves who authored a record; it does not resolve a node that legitimately signs conflicting records — last-writer-wins by (ts, home) still applies for account/channel registration conflicts.