Mesh & multi-hop routing

Documents the atlasd v0.2 release lineView as Markdown

Three or more nodes stop being pairs and become a fleet: every node learns the whole topology, computes shortest paths, and forwards for peers it cannot reach directly — with no controller anywhere. This page covers the routing machinery, what you actually configure for a relay chain, and one crypto boundary you must design around.

Every node floods link-state advertisements (LSAs) to its established peers: who it is, which neighbors it hears and at what cost (best alive RTT per link), which tunnel prefixes it serves, and optionally its name, role and position. LSAs age out after 100 seconds, refresh every 30, and departed nodes leave tombstones so a stale advertisement cannot resurrect them. On top of the database, Dijkstra computes routes; forwarding carries a TTL'd relay header with a hard ceiling of 8 hops.

Two cost modes: measured cost (default — routes prefer low-RTT paths and shift as quality changes) or hop count ([scheduler] strategy = "shortest_path" — every alive link costs 1, so routes hold steady under probe jitter). Cost changes only trigger a re-flood when they clear a hysteresis threshold — max(5 ms, 25% of the link's cost) — so a wobbling link does not chat the fleet to death.

What a 3-node relay chain actually requires

For A — B — C, each node peers only with its direct neighbors: A lists B, B lists A and C, C lists B. That is the whole trick — A and C need no configuration for each other. LSAs advertise every node's tunnel address, SPF resolves A→B→C, and B forwards. The middle node's config is ordinary:

# Node B — the relay. IPs are documentation addresses; use your own.
[interface]
name = "nexus0"
address = "10.0.100.11/24"
private_key = "NODE_B_PRIVATE_KEY"
listen_port = 51820
node_role = "relay"              # advertised in LSAs; display-only

[[link]]
name = "primary"
bind_device = "eth0"
bind_address = "0.0.0.0"

[[peer]]                          # node A
public_key = "NODE_A_PUBLIC_KEY"
endpoint = ["192.0.2.1:51820"]
allowed_ips = ["10.0.100.1/32"]

[[peer]]                          # node C
public_key = "NODE_C_PUBLIC_KEY"
endpoint = ["198.51.100.2:51820"]
allowed_ips = ["10.0.100.2/32"]

Want more than SPF gives you? [routing] path_redundancy = 2 computes the top-2 next-hops and fans every fragment across both with receive-side dedup — killing any single link in a diamond topology becomes loss-free because the alternate path is already carrying live traffic. Or pin routing entirely with [[routing.static_path]] entries (a named hop list with a stable path_id stamped into the relay header) — SPF keeps measuring for telemetry, but forwarding obeys the operator; automatic failover is deliberately off in that mode. The two knobs are mutually exclusive, and the static table wins.

The encryption boundary — read this one twice

Multi-hop forwarding is hop-by-hop encrypted, not end-to-end. An Atlas node relaying between two others terminates the encryption from the previous hop, decrypts, and re-encrypts to the next hop — a mesh relay node can read the traffic it forwards. Place relays only on nodes you trust with the payload, or run application-layer encryption on top. (The opposite holds for the traversal relay server, which forwards opaque encrypted envelopes and cannot read anything.)

Static versus discovered peers

Peers written in the config are static; peers learned at runtime — through mesh membership, an auth provider extending trust, or relay learning — are discovered, and the difference matters when things go quiet:

  • Static peers are never deleted. A configured peer that stops answering is marked unreachable and kept — its sessions, probes and your explicit intent survive the outage, and journal events fire on both edges of the transition.
  • Discovered peers expire after [mesh] expiry_secs (default 60) of gossip silence, destroying their sessions and any punched endpoints. Deployments that lean on discovery should size this knob generously — and know that under the default static auth provider, an expired discovered peer cannot re-add itself.

The [mesh] section is membership, not routing

A subtlety that saves confusion: LSA flooding and multi-hop SPF run unconditionally for established peers — a statically peered chain routes multi-hop with no [mesh] table at all. What [mesh] enabled = true adds is the membership plane: secret-authorized join (mesh_secret), a replicated member registry, heartbeats and expiry, anti-entropy gossip with a random peer each interval, and bootstrap via bootstrap_peers — so nodes can find each other without anyone editing peer lists. Gossip is encrypted and replay-damped, sized to fit single datagrams (large rosters page across several), periodically re-synced in full to repair losses, and re-anchored to the seed list on a slow timer so a partitioned mesh heals instead of staying split forever.

Route flooding on narrow bearers

A link that carries 250 kbit/s cannot afford the same routing chatter as a gigabit port — so the flooding cadence is per link: [[link]] route_advert_interval_secs overrides the global [mesh] route_advert_interval_secs (default 2) for that bearer only, clamped so a paced link still delivers at least three LSAs inside every aging window. A narrowband radio and a fast backbone can carry the same mesh without the radio drowning in updates.

What else rides the LSAs

  • Node identity for humans: node_name (defaults to the hostname) and node_roleground-station, uav, ugv, usv, dismount, relay, or your own value — advertised so topology views can label and symbolize nodes. Purely descriptive; routing never reads it — but with Mesh DNS enabled, node_name also becomes the node's resolvable name, answered from every node's own copy of this database.
  • Position and velocity: from static config ([interface] position), a live feed on the telemetry socket, or an operator's PUT /api/position. Fleets that move use it for predictive scheduling and range-aware route costs.
One mesh at a time: never run two multi-hop routing layers over the same nodes — Atlas mesh routing on top of a radio-level mesh (for example 802.11s) re-routing the same failures produces two control loops fighting. Let the radios be links, and let one layer own the routing.
Esc
↑ ↓ navigateEnter — openEsc — close