Admit nodes by pairing code, certificate or approval shipped

Documents the atlasd 3.0.0 release lineView as Markdown

A record fleet refuses every key it holds no record for. Four admission modes give a new node a deliberate way in without editing the fleet list on the operator host: a single-use pairing code, a certificate signed by your fleet authority, a pre-shared secret as a second factor at the handshake, and a queue where an operator approves each knock. This guide picks a mode, configures both ends and shows what each one leaves in the journal. The mechanism is described under Admission modes.

What you need: for pairing codes and certificates, a record fleet ([auth] provider = "fleet") and a config author the admitting node can reach — the admitting node itself (its record carries config_author, or it holds the config-author key the fleet trusts) or another node it forwards to. The pre-shared secret and the approval queue work under every provider. For the API, a token with write scope: atlasd web token add backend --scope write.

Choose a mode

SituationModeWhy
A user adds a device from your application; hardware you did not buildPairing codeOne API call and a QR; least privilege unless the code grants more; one use
You build the hardware, and each unit must join any hub that trusts you with nothing to edit thereFleet certificateSigned once at the factory; no list entry; revoked through the list
A stolen node key must not be enough, and recorded handshakes must need more than the keysKey plus secretTwo factors; a wrong secret gets no answer; the secret enters the session keys
Devices nobody can list in advance: a handset issued at an incident, a partner’s vehicleApproval queueThe device knocks, a person decides, every decision is journalled
Your database already knows every deviceThe command hook or records written by your backendYour system of record answers directly

The modes combine. A pre-shared secret sits in front of any of them, and the approval queue catches the keys every other check refused.

Pairing code

On the admitting node, mint an invitation. The pairing code goes to the new node as text or as a QR; it lives for --ttl and admits --uses nodes.

$ atlasd fleet invite --name drone07 --allowed-ip 10.1.0.17/32 --ttl 15m
invitation 7c41e09a2f5d4b8e9a3c11d07f62b5e4 for `drone07` — 1 use(s), expires in 15m
  classes Telemetry,Position,Bulk · relay no · address 10.1.0.17/32
  the code carries 1 anchor(s), 0 author(s), the address 10.1.0.17/32 and this node's address 10.1.0.1/24 — all under the token's MAC (202 characters)
atlas:pair?v=3&k=<this node's key>&n=drone07&a=<anchors>&i=10.1.0.17/32&h=10.1.0.1/24&t=<token>

What the code carries

The pairing code is a v=3 URI of about 200–260 characters (a version 10–12 QR). Its fields:

FieldCarriesUnder the token's MAC
kThe inviting node's X25519 key — the [[peer]] the new node handshakes withyes
n, r, eThe suggested name, a region hint, an endpoint hintno — hints nothing trusts
aThe fleet's anchors: the Ed25519 keys of the inviting node's config-authors.toml, at most fouryes
cThe config_author keys of the inviting node's list that are not anchors, at most four (absent when there are none)yes
iThe address the record gets — the invitation's first allowed_ips; absent for a --pool code, which hands out the address at admissionyes
hThe inviting node's own tunnel address with the tunnel's prefix (10.1.0.1/24)yes
tThe 40-byte token: id ‖ expiry ‖ BLAKE2s-MAC(invite secret, id ‖ expiry ‖ capabilities ‖ binding), the binding a BLAKE2s digest of k, a, c, i and h—

So a code whose anchor, author or address was altered does not verify, and the bare token is no longer enough: [auth] invite_token takes the whole code. The API answer spells the parts out as code: {version, hub_key, anchors, authors, address, interface_address, hub_address, region} with uri_len. A v=1 code (key and name only) is still parsed; v=2 is not.

  • Least privilege by default: no relaying, Telemetry, Position and Bulk, dashboard = "own". Raise it in the invitation with --classes, --relay, --dashboard and --role; the token’s MAC covers the flags, so the code cannot be edited into more.
  • Several nodes from one code: --pool 10.1.0.64/26 --uses 10 gives each node the next free /32 and numbers the names (cam, cam-2 …).
  • Housekeeping: atlasd fleet invite --list shows each invitation’s state and the nodes it admitted; --withdraw <id> voids one.
  • If the node is not a config author, the command says so: a node it admits is kept there and queued for an author (see Boundaries).

From a platform backend, the same call over the REST API:

curl -X POST -H "Authorization: Bearer $TOKEN_W" -H "X-Atlas-CSRF: 1" \
     -H "Content-Type: application/json" \
     -d '{"name": "drone07", "allowed_ips": ["10.1.0.17/32"], "ttl_secs": 900, "uses": 1}' \
     http://hub-a:9800/api/admission/invites
{ "ok": true, "id": "7c41e09a…", "name": "drone07", "token": "…",
  "uri": "atlas:pair?v=3&k=…&n=drone07&a=…&i=10.1.0.17/32&h=10.1.0.1/24&t=…", "uri_len": 202,
  "code": { "version": 3, "hub_key": "…", "anchors": ["…"], "authors": [], "address": "10.1.0.17/32",
            "interface_address": "10.1.0.17/24", "hub_address": "10.1.0.1/24", "region": null },
  "expires_at": 1791805500, "uses": 1,
  "capabilities": { "relay": false, "classes": ["Telemetry", "Position", "Bulk"], … },
  "allowed_ips": ["10.1.0.17/32"], "can_sign": true }

On the new node, generate its identity with atlasd keygen --identity, list the inviting node as a peer (its key is the code’s k=) and put the whole code in [auth] invite_token. It needs no fleet list and no config-authors.toml; [interface] address may be left out (taken from the code’s i with the prefix of h), and so may the inviting node’s [[peer]] entry or its allowed_ips (the peer k with h/32, added when missing):

the new node — first start
[interface]
node_id     = "<record uuid>"
private_key = "…"
signing_key = "…"
# address: taken from the code (i, with the prefix of h) when left out

[[peer]]
public_key = "<the code's k= key>"
endpoints  = ["hub-a.example:51820"]
# allowed_ips: the code's h, as a /32, when left out

[auth]
provider     = "fleet"
invite_token = "atlas:pair?v=3&k=…&n=drone07&a=…&i=10.1.0.17/32&h=10.1.0.1/24&t=…"   # the whole code, never only t=

Its first handshake carries the code’s token and binding with its UUID, its signing key and a proof that it holds both. The inviting node spends the use, mints drone07, signs the next fleet list and floods it — and on every session with the node it sends {"invite_ok": <binding>} on the fleet-list channel, which the node takes only from the session with the code’s k and keeps across restarts (<state dir>/<iface>.invite-confirmed, journal identity.invitation_confirmed). The new node’s identity.mode reads joining until it adopts the first list signed by an anchor of its own config-authors.toml, by an anchor the code carried or a carried author the list itself names config_author (both counting once the hub has confirmed the code), or by a config_author record whose transport key is the inviting node’s; then fleet. So a node invited by a hub that cannot sign adopts the author’s list with no anchors file of its own, and the carried anchors are its anchors from then on; identity.invitation in the snapshot reads {inviter, anchors, authors, confirmed}. A pool code carries no i: the node starts with its interface up and unaddressed, and the hub’s confirmation names the address its record got with the tunnel prefix ({"invite_ok": …, "address": "10.1.0.66/32", "prefix": 24}), which the node configures on the spot — interface, adverts, relay-header origin, roster and the codes it mints — with no restart; snapshot identity.tunnel_address = {address, source: config | invitation | record, pending, assigned_at}, journal identity.address_assigned. A [dns] resolver on such a node needs an explicit listen. The same code presented again, or from a second key, gets no session: a code that is spent, past its expiry or withdrawn is journalled admission.expired, and a UUID or key the list holds or has revoked is refused without spending the use.

Fleet certificate

On the authority’s host, sign each node’s record as a certificate. Describe the record on the command line, or certify one already in the authoring list with --node drone-c --file <list>:

$ atlasd fleet cert --name drone-c --node-id <uuid> \
    --transport-key <pub> --signing-key <pub> --allowed-ip 10.1.0.8/32 \
    --classes Telemetry,Video,Bulk --fleet tenant-1 --expires 365d \
    --key /etc/atlas/config-author.key --out drone-c.cert
certificate for `drone-c` (5e0b..c4a1) in fleet `tenant-1` signed by 3c24f045 → drone-c.cert (812 bytes, expires 1823341500)
on the node: [auth] cert_file = "drone-c.cert"

The signing key must be an anchor in each admitting node’s config-authors.toml, or the signing key of a config_author record in its list; the command warns when it is not an anchor on the host it runs on. On the node, the certificate replaces the fleet list:

[auth]
provider  = "fleet"
cert_file = "/etc/atlas/drone-c.cert"

Any node that trusts the authority admits it with the certificate’s flags (admission.cert_accepted) and offers the certificate on over its sessions, so every member learns the record and verifies the node’s signed adverts. To take it out, revoke the certificate and push: atlasd fleet revoke --cert drone-c.cert --push /etc/atlas/config.toml. Every node evicts it in the same intake, and the certificate is refused from then on, even from a factory-fresh start.

Key plus secret

Give every node of the fleet the same 32-byte secret:

$ head -c 32 /dev/urandom | base64

[auth]
psk = "…"        # the same on both ends of every pair

For secrets per group of nodes, put named keys in [auth] psk_file (one id = "<base64>" line each, mode 0600) and name one in a record’s psk_id (or with fleet cert --psk-id). A pair whose records name a psk_id uses the key of the lower node id; both ends compute the same.

Every handshake initiation then carries a tag over its bytes under the secret, and the handshake runs Noise IKpsk2, so the secret enters the session keys too. A missing or wrong tag is dropped with no answer at all — not even a cookie — and counted in admission.counters.psk_dropped. From the sender’s side it looks exactly like a port nobody listens on. With a fleet-wide secret the tag is checked before any Diffie-Hellman.

The approval queue

[auth]
pending     = true
pending_max = 200     # default: knocks beyond this are dropped
block_secs  = 3600    # default: how long a denied key stays blocked

A key that fails every other check — no record, a refused invitation, a denied hook — is parked with its source, the name it calls itself, the UUID it proved, first and last seen and a count. A knock is listed within half a second:

$ atlasd fleet pending list
5d2e81a0  from 198.51.100.23:51820   "phone-07"         4× first 9s ago last 1s ago

$ atlasd fleet pending approve 5d2e81a0 --allowed-ip 10.1.0.70/32 --classes Position,Telemetry
approved as `phone-07` (9b1f..07c2), 10.1.0.70/32 — in fleet list seq 3

$ atlasd fleet pending deny 5d2e81a0 --block 1h --reason "not ours"
denied 5d2e81a0 — blocked in 1h

Approve mints the record with the flags you give (least privilege for the rest), signed into the next list by a config author, and the knocking node’s next retry gets a session — about two seconds later, with no restart. Deny drops the key’s handshakes unanswered until the block ends; atlasd fleet pending unblock <id> lifts it early. The dashboard’s Peers tab opens with an Admission · Pending card that does the same: Approve with the name pre-filled from what the node calls itself, an address or pool, classes and relay; Deny; Unblock; and Invite a node, which shows a pairing code as text and QR once. From a backend:

curl -s -H "Authorization: Bearer $TOKEN_R" http://hub-a:9800/api/admission/pending
curl -X POST -H "Authorization: Bearer $TOKEN_W" -H "X-Atlas-CSRF: 1" -H "Content-Type: application/json" \
     -d '{"id": "5d2e81a0", "allowed_ips": ["10.1.0.70/32"], "classes": ["Position", "Telemetry"]}' \
     http://hub-a:9800/api/admission/approve
{ "ok": true, "id": "5d2e81a0", "name": "phone-07", "node_id": "9b1f…", "signed": true, "queued": false, … }

When the admitting node cannot sign

A hub that admits a node by pairing code or approval but cannot sign the fleet list keeps a local record that admits the node there, and hands it to a config author on its own: over the session when the author is a neighbour, end to end on the control channel when it is several hops away. Nothing to configure on the hub beyond end-to-end sealing for the far case; the author needs a record with config_author and its key.

# on the hub: lets it reach an author that is not a neighbour
[crypto]
e2e = true

# on the author (default true): sign forwarded records at once
[auth]
accept_forwarded = true

About a second after the admission the author has signed the record into the next list and flooded it, and the hub drops its local copy. On the hub, atlasd fleet pending list shows each record on its way:

$ atlasd fleet pending list          # on the hub
no keys waiting for approval
queued `drone01` (invite:7c41e09a2f5d4b8e9a3c11d07f62b5e4) — accepted (author `gs`)

The author applies its own policy, not the hub's word: the hub must hold a record, the record must have the shape a node mints, it is never a config author, and it carries no right the hub lacks itself. A drone invited with Video by a hub without Video is refused exceeds_forwarder and keeps working with that hub only. Other refusals are collides (an id, name or key already held) and revoked.

To decide each forwarded record by hand, set accept_forwarded = false on the author:

$ atlasd fleet pending list          # on the author
no keys waiting for approval
forwarded 3f9c0e12  `drone02` 10.1.0.22/32 from `hub1` (invite:b81d44c09e2a4f37a0c5e19d62f7a301) 4s ago  — awaiting approval
$ atlasd fleet pending approve 3f9c0e12
$ atlasd fleet pending deny 3f9c0e12

Approve signs the record exactly as minted; deny refuses it, and the hub's next forward is answered refused, denied. The dashboard's Pending card lists forwarded records with Approve and Refuse. With no author reachable the hub retries, 5 s doubling to 5 minutes, and counts forward_unreachable.

Seeing it

EventJournalAlso in
Invitation minted, withdrawnadmission.invite_created, admission.invite_revokedGET /api/admission/invites (never the token)
Node admitted by a codeadmission.invited, with the operator who minted itthe invitation’s admitted list
Spent, expired or withdrawn code presentedadmission.expiredadmission.counters.token_refused
Certificate accepted, refusedadmission.cert_accepted, admission.cert_refusedadmission.certified
Wrong or missing secret—admission.counters.psk_dropped
Record forwarded to an author, answeredhub: admission.forwarded (author, path), forward_waiting, forward_accepted, forward_held, forward_refused (reason), forward_listed; author: admission.intake_accepted, intake_held, intake_refused, intake_rejectedadmission.queued, each local record's forward state, the matching counters
Knock, approval, denial, unblockadmission.knock, admission.approved, admission.denied, admission.unblocked, with the operator (web:<user>, token:<name>, cli:<user>)admission.pending, admission.blocked, counters.knocks, counters.blocked_dropped

GET /api/admission returns the whole snapshot block: admission.{pending_enabled, psk, can_sign, pending, blocked, invites, local, certified, queued, accept_forwarded, held, counters}.

Boundaries

  • Pairing codes and certificates admit into a record fleet; a node without a fleet list cannot mint an invitation (409 not_a_record_fleet).
  • A node that cannot sign the fleet list forwards the records it mints to an author; an author that is not a neighbour takes the forward only sealed end to end, so the hub needs [crypto] e2e for it. A refused record stays on the hub, still admitting its node there; remove it, or bring it into the authoring file with atlasd fleet pull -c <config> --with-queued, sign and push.
  • The node a hub that cannot sign admitted adopts the author's list as its first list only when an anchor of its config-authors.toml (or its inviter) signed it. Until then it stays joining: every other node knows it from the list, and it reaches its configured peers directly.
  • A joining node learns the mesh secret only from a rotation sealed to its record.
  • A certificate is at most 940 bytes and carries no geofence. A revoked certificate whose record named a psk_id is refused at the secret check, so it shows in psk_dropped rather than as admission.cert_refused.
  • A handshake initiation is 162 bytes bare, 319 with a pairing code and about 850 with a certificate and a code; the daemon checks at start that it stays within 1,200.
Esc
↑ ↓ navigateEnter — openEsc — close