REST API
atlasd web -i <iface> serves the dashboard and a JSON API on 0.0.0.0:9800. The API is the same state every other tool reads — one snapshot, many doors — plus the mutating endpoints that make configuration changes safe to perform remotely. Every endpoint takes an optional ?interface= parameter on multi-daemon hosts, and unknown /api paths return JSON 404 {"error":"not_found"}, never a page. This page documents api contract 1, the number /api/health reports under contracts.api: it rises when an endpoint is removed or a field changes meaning, while a new endpoint or an added field leaves it where it is.
The auth model in one minute
- No accounts configured: reads are open, every mutating endpoint answers
403 auth_not_configured. Safe by default on a bench; create accounts before exposing the port. - Accounts: username/password (argon2id) with optional WebAuthn passkeys — enrolling a passkey makes second-factor mandatory for that account. Sessions are signed HttpOnly cookies; login is rate-limited.
- Protection posture:
[web] protect = "writes"(default) gates mutations only;protect = "all"gates reads too — the dashboard then shows a login screen and makes zero data requests before auth. - CSRF: every mutating request carries the header
X-Atlas-CSRF: 1(and a matchingOriginwhen a browser sends one). - TLS:
[web] tls = trueserves a locally generated certificate — wanted in practice for passkeys.
Machine credentials
Two non-interactive credentials, deliberately different in power:
| Node token | API token | |
|---|---|---|
| Header | X-Atlas-Node-Token: … | Authorization: Bearer atlas_… |
| Minted by | atlasd config node-token — derived from the mesh secret, time-windowed, stateless | atlasd web token add <name> --scope read|write [--expires 30d] — secret printed once |
| Scope | Read-only, whole paths only: /api/stats, /api/config, /api/health, metrics, crash listing. Never a write, never /api/auth. | read = all GETs; write adds exactly the config-apply lifecycle, PUT /api/qos, PUT /api/position, enrolment, the admission writes, POST /api/fleet/revoke, /api/fleet/select-area, /api/fleet/zeroize and /api/fleet/update-class; the rotation writes take a session only. /api/auth/* is unreachable for any token. |
| Meant for | Fleet members reading each other under protect = "all"; scrape credentials. | CI, dashboards, automation acting as an operator. |
Fan-out endpoints (/api/fleet/*, support bundles) are deliberately excluded from node-token scope — a low-power credential must not be able to amplify itself across a fleet.
In a record fleet ([auth] provider = "fleet") the node token is node-bound: n1. + base64url(node id ‖ window ‖ Ed25519 signature by the minting node's signing_key), verified against that node's record. Its dashboard flag decides the reach — fleet reads any member, own only the node that minted it, none nothing — and mesh-secret tokens are not accepted. atlasd config node-token mints the right form for the fleet it runs in. See capability flags.
Read endpoints
The full per-interface snapshot: links (RTT, jitter, loss, capacity, quality, counters, quota), peers and their per-link views, mesh membership, routes, topology with node roles and positions, crypto posture, bonding-tax ledger, the terrain block when [terrain] is configured (see reading it), and the control plane's security block (protocol, lsa_sig_rejected, gossip_sig_rejected, evicted_total, handshake_rate_limited, handshake_cookie_sent, mesh_secret), and the release block with each peer's negotiated capabilities.
curl -s http://node-a:9800/api/stats
{
"interface": "nexus0",
"address": "10.0.100.1/24",
"uptime_secs": 5312.4,
"strategy": "lowest_delivery_time",
"probe_interval_ms": 250,
"dead_probe_count": 5,
"identity": {
"node_name": "node-a",
"node_role": null,
"listen_port": 51820,
"mtu": 1416,
"public_key_short": "8b4c..901a"
},
"release": {
"version": "3.0.0",
"build": "7c341a4",
"contracts": { "wire": 5, "relay": 2, "seal": 2, "pairing": 3, "adapter": 1, "report": 2, "api": 1 },
"caps": ["understood", "qos", "fec", "bundle", "seal", "piggyback"]
},
"protocol_refused": 0,
"links": [
{
"id": 0,
"name": "wired",
"bind_device": "eth0",
"bearer": "ethernet",
"alive": true,
"rtt_ms": 0.42,
"jitter_ms": 0.05,
"loss_pct": 0.0,
"capacity_mbps": 100.0,
"quality_score": 0.98,
"tx_bytes": 104857,
"rx_bytes": 99231,
"quota": null
}
],
"peers": [
{
"id": 0,
"public_key_short": "6324..7876",
"tunnel_ips": ["10.0.100.2"],
"session_established": true,
"last_handshake_unix": 1786297090.9,
"caps": ["understood", "qos", "fec", "bundle", "piggyback"],
"links": [ { "link_name": "wired", "alive": true, "rtt_ms": 0.4 } ]
}
],
"mesh": { "enabled": false, "member_count": 0 },
"routes": [],
"total_tx_bytes": 1048576,
"total_rx_bytes": 992310
}
Release, contracts and capabilities — what atlasd --version prints and what the dashboard's About card on the Settings tab shows. release carries version (x.y.z), build (the git short hash, or unknown), contracts (wire, relay, seal, pairing, adapter, report, api; report is absent for an embedder that sends no console reports) and caps, the capabilities this node advertises in every handshake. Each peers[] entry carries caps: the names that peer advertised in the handshake behind its current session (bit<N> for one this build does not know), or null until a handshake has completed. A feature this node has and the peer lacks is negotiated off towards that peer, and these counters show where:
protocol_refused— datagrams refused because their protocol byte names wire 1, 2, 3 or 4: a node accepts wire 5 only (the relay servers accept 5 and 4). Non-zero means a peer speaking another wire is trying to reach this one, and the two will not establish until every node of the fleet runs the same wire; its endpoint and version are logged once and journaled aspeer.protocol_refused.e2e.unsealed_no_cap— unicast sent hop-encrypted only, because the destination does not advertiseseal. Always zero undere2e_require, which never downgrades.multicast.child_without_cap— tree branches skipped because the child does not advertisemulticast.interconnect[].dropped_no_cap— seam egress refused because the far daemon on that seam does not advertiseinterconnect.
Bearer kinds — what each link physically rides on. Every node advertises, for each of its links, its kind and whether it is alive, in the bearer extension of its signed advert; kinds only, never link names or addresses. The kinds are wifi, cellular, ethernet, radio, bluetooth, mesh, relay, satellite and unknown, shown as Wi-Fi, Cellular, Ethernet and so on. A link's kind comes from [[link]] bearer when set, otherwise from the link itself: a relay link is relay, a serial link radio, and the bind device's name and type decide the rest. The Android app reports Wi-Fi or Cellular for each of its links.
links[].bearer— this node's kind for each of its own links.members[].bearers— the kinds a member advertises,[{kind, alive, relay?}]in advert order,relay: truemarking a link a relay server carries; the self row lists this node's own. Absent for a member whose advert carries no bearer extension.members[].direct_over— the kind at the far end of this node's direct path to the member: set only while a non-relay link of this node is alive to it and the member's advert singles one kind out; omitted when ambiguous.
The dashboard and atlasd status print the same facts as one line per member: alive kinds first, a dead one marked, then the direct path.
members[] entry, abbreviated{
"name": "phone01",
"state": "present",
"bearers": [
{ "kind": "wifi", "alive": true },
{ "kind": "cellular", "alive": true, "relay": true }
],
"direct_over": "wifi"
}
phone01: Wi-Fi + Cellular · direct over Wi-Fi
Interconnect record limits — in a record fleet, what a seam host's record allows it to import and export (see Atlas Interconnect). Zero or absent in a fleet without a fleet list, where nothing is limited.
interconnect_imports_refused— on every member: (node, prefix) pairs refused right now, kept out of routing because the announcing node's record does not allow them. Each is logged once.interconnect_refused_imports[]— one row per refusal,{node_id_short, name?, prefix, kind}, sorted by node and prefix;kindisroute(an imported prefix) ormapped(a mapped address).members[].interconnect_imports_refused,members[].interconnect_refused[]— the same on the announcing member's row; absent for every other member.interconnect_record— on a node with a seam configured or a record carrying the section:{enforced, record_name?, section, may_import, may_export}.enforced: falseoutside a record fleet;section: falsemeans the record imports and exports nothing.interconnect[].refused_routes,interconnect[].refused_maps,interconnect[].refused_export— per seam, the configured routes, mappedlocaladdresses and export entries this node's record does not allow: advertised as configured, never carried.interconnect[].dropped_by_record— packets refused in either direction for a route or map outside the record.
// the seam host
"interconnect_record": {
"enforced": true, "record_name": "op-a", "section": true,
"may_import": ["10.0.9.0/25", "10.0.101.130/32"],
"may_export": ["10.0.101.0/24"]
},
"interconnect": [{ "refused_routes": ["10.0.9.192/26"], "dropped_by_record": 14, … }]
// any member
"interconnect_imports_refused": 1,
"interconnect_refused_imports": [
{ "node_id_short": "3f1a..9c2e", "name": "op-a", "prefix": "10.0.9.192/26", "kind": "route" }
]
Array of daemon instances on this host.
curl -s http://node-a:9800/api/interfaces
["nexus0"]
Liveness verdict — 200 healthy / 503 not — with named checks: daemon alive, data path genuinely moving, links probed on cadence, peers established. Built for external monitors: curl -fsS …/api/health is a complete probe. Beside version it names the build (git short hash) and every contracts number the daemon speaks, so a monitor can flag version skew across a fleet from the same probe.
curl -fsS http://node-a:9800/api/health
{
"ok": true,
"interface": "nexus0",
"version": "3.0.0",
"build": "7c341a4",
"contracts": { "wire": 5, "relay": 2, "seal": 2, "pairing": 3, "adapter": 1, "report": 2, "api": 1 },
"process": { "running": true, "pid": 1234, "uptime_secs": 5312.4 },
"data_path": { "ok": true, "tun_present": true, "engine_advancing": true },
"links": { "total": 2, "alive": 2, "probed_recently": 2 },
"peers": { "total": 1, "established": 1 },
"watchdog": { "systemd": true, "interval_ms": 30000, "tripped": false },
"checks": [
{ "name": "daemon", "ok": true, "required": true,
"detail": "stats snapshot 0.4s old" },
{ "name": "data_path", "ok": true, "required": true,
"detail": "TUN present, engine advancing, links probed, traffic flowing" },
{ "name": "links", "ok": true, "required": true,
"detail": "2/2 alive, 2 probed within cadence" },
{ "name": "peers", "ok": true, "required": false,
"detail": "1/1 sessions established" }
]
}
The persistent event journal, newest first, cursor-paged. Filters: since, until, category, severity, code, limit.
curl -s "http://node-a:9800/api/journal?category=link&severity=warn&since=1h"
{
"interface": "nexus0",
"schema_version": 1,
"entries": [
{
"v": 1,
"seq": 891,
"ts": 1786297001,
"category": "link",
"severity": "warn",
"code": "link.down",
"msg": "link wired on nexus0 went down",
"fields": { "reason": "probe_timeout", "missed_probes": 5 }
}
],
"count": 1,
"next_cursor": "890"
}
Ring buffer of recent scheduler decisions — which link won each placement and at what cost. The fastest way to understand why traffic moved.
The NAT classification report from the traversal netcheck. The relay directory and federation add their own blocks to /api/stats — traversal.directory, traversal.federation, links[].relay.via, peers[].directory — listed on Relay protocol.
The live configuration with secrets replaced by __ATLAS_REDACTED__; and the JSON Schema the daemon generates from its own structs — the same document behind the configuration reference.
curl -s http://node-a:9800/api/config/schema
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Nexus Atlas config",
"type": "object",
"required": ["interface", "link"],
"properties": {
"interface": { "$ref": "#/definitions/InterfaceConfig" },
"link": { "type": "array", "items": { "$ref": "#/definitions/LinkConfig" } },
"peer": { "type": "array", "items": { "$ref": "#/definitions/PeerConfig" } },
"scheduler": { "allOf": [ { "$ref": "#/definitions/SchedulerConfig" } ] }
},
"definitions": { "InterfaceConfig": { }, "LinkConfig": { } }
}
Prometheus/OpenMetrics exposition for every interface on the host. On by default; follows the node's protection posture like any read.
curl -s http://node-a:9800/metrics
# HELP atlas_link_up Link liveness (1 = alive)
# TYPE atlas_link_up gauge
atlas_link_up{interface="nexus0",link="wired"} 1
atlas_link_up{interface="nexus0",link="wireless"} 1
atlas_link_rtt_seconds{interface="nexus0",link="wired"} 0.00042
atlas_link_tx_bytes_total{interface="nexus0",link="wired"} 104857
atlas_peer_last_handshake_seconds{interface="nexus0",peer="6324..7876"} 1.7862970909e+09
The elevation tiles the running daemon loaded under [terrain], and the grid the dashboard's Terrain tab draws. /api/terrain returns loaded, prediction, each tile (name, rows, cols, bounds as south, west, north, east, cell_m, min_m, max_m, bytes), the union bounds, the finest cell_m, load_errors, grid_max (512) and grid_default (256); with nothing loaded, loaded: false, configured, load_errors and a hint on how to load a tile. /api/terrain/grid takes bbox=S,W,N,E (the tiles' extent when absent) and size (2–512, default 256, samples on the longer side) and returns application/octet-stream: a resampled .nadt grid (48-byte header, little-endian i16 rows, −32768 where no tile covers a cell), never finer than the finest tile, with an ETag and an X-Terrain-Grid: <rows>x<cols> header. If-None-Match with the same tag is a 304; a malformed box is a 400, a box off the tiles a 422, no tiles a 404 with the hint. Both read under the same gate as /api/stats and are not in the mesh node-token scope. See Load elevation data onto a node.
curl -s 'http://node-a:9800/api/terrain?interface=atlas0'
curl -s -D - -o grid.nadt 'http://node-a:9800/api/terrain/grid?interface=atlas0&bbox=42.12,24.02,42.18,24.12&size=256'
{"interface":"atlas0","loaded":true,"prediction":true,
"tiles":[{"name":"ao.nadt","rows":362,"cols":506,"bounds":[42.1,24.0,42.2003,24.1403],"cell_m":30.87,…}],
"bounds":[42.1,24.0,42.2003,24.1403],"cell_m":30.87,"load_errors":[],"grid_max":512,"grid_default":256}
HTTP/1.1 200 OK
content-type: application/octet-stream
etag: "t5c0e…"
x-terrain-grid: 207x256
QoS policy document; node position; locally spooled crash reports; shell mode probe; and the anonymous auth-posture probe a client uses to decide whether to show a login.
This node's identity as the running daemon resolved it — mode (fleet or keys), node id, record UUID and name, each key of its record with validity and whether it is held here, its capability flags and the fleet list it holds — plus the counters the flags and the routing rules move: refused_no_record, fleet_lists_rejected, caps_counters.{relay_refused, class_clamped, class_clamped_transit, origin_unrecorded, sink_dropped, sink_local_dropped}, lsa_no_session, lsa_one_sided_links, and revoked_by on a node that was removed. /api/fleet returns the verified fleet list, records keyed by node id with flags and key sets, its revoked entries, each record's geofence, its interconnect section (null without one: nothing may be imported or exported) and ext_keys (every record extension it carries), and the list's commands[], re-verified from disk on every request ({"mode":"keys"} without one). See Identity and authorisation.
curl -s http://node-a:9800/api/identity
{
"interface": "atlas0",
"mode": "fleet",
"node_id_short": "e191..81b6",
"node_uuid": "af2e580d-5365-4b5f-acfa-9a9da1f69e73",
"record_name": "drone",
"transport_key_id": "5123339a",
"next_transport_key_id": "6142ed49",
"keys": [
{ "kind": "transport", "key_id": "6142ed49", "valid_from": 0, "valid_to": 0, "valid_now": true, "held": true },
{ "kind": "transport", "key_id": "5123339a", "valid_from": 0, "valid_to": 0, "valid_now": true, "held": true },
{ "kind": "signing", "key_id": "e5c003f3", "valid_from": 0, "valid_to": 0, "valid_now": true, "held": true }
],
"capabilities": { "relay": true, "classes": ["Control", "Voice", "Position", "Telemetry", "Video", "Bulk"],
"config_author": false, "dashboard": "own" },
"fleet": { "fleet": "field-1", "seq": 2, "hash8": "ffc955e8", "records": 4, "signed_by": "operator" },
"refused_no_record": 0,
"fleet_lists_rejected": 0,
"caps_counters": { "relay_refused": 0, "class_clamped": 0, "class_clamped_transit": 0, "origin_unrecorded": 0,
"sink_dropped": 0, "sink_local_dropped": 0 },
"lsa_no_session": 0,
"lsa_one_sided_links": 0
}
The staged rollout this node is driving — record, release gate, canary progress and the live timeline of every node's status changes (rollout is null on a node that started none) — and the archive of finished rollouts, newest first; /api/config/rollouts/<rollout_id> returns one archive with its timeline. See following a push.
curl -s http://node-a:9800/api/config/rollout
{
"rollout": {
"rollout_id": "r-1791695967-1f47ff58",
"phase": "released",
"canary_nodes": ["2244..c218"],
"canary_epoch": 1,
"final_epoch": 2,
"timeline": [
{ "at": 1791695985, "node_id_short": "9edd..0553", "state": "received", "via": "self", "epoch": 2 },
{ "at": 1791695989, "node_id_short": "575a..b158", "state": "received", "via": "9edd..0553", "epoch": 2 },
{ "at": 1791695995, "node_id_short": "2244..c218", "state": "received", "via": "575a..b158", "epoch": 2 }
]
},
"gate": { "total": 1, "confirmed": 1, "failed": 0, "pending": 0, "silent": 0,
"satisfied": true, "blocked": false, "expired": false, "reason": "…" },
"canaries": [ … ]
}
curl -s http://node-a:9800/api/config/rollouts
{ "rollouts": [ { "rollout_id": "r-1791695967-1f47ff58", "phase": "released",
"started_at": 1791695967, "finished_at": 1791695983, "canary_epoch": 1, "final_epoch": 2,
"nodes": 3, "events": 13, "closed": true } ] }
The snapshot's admission block (see Admission modes): pending_enabled, psk (configured or not, never the key), can_sign, pending knocks, blocked keys, invites without their tokens (state, uses left, who minted it, the nodes it admitted), local records held outside the signed list (and whether they are queued for an author, with each one's forward state: status, attempts, author, reason), queued, accept_forwarded, held (records other nodes forwarded to this author, waiting for a decision), certified records and counters (invited, token_refused, cert_accepted, cert_refused, psk_dropped, knocks, knocks_limited, approved, denied, blocked_dropped, and for forwarding forwarded, forward_unreachable, forward_accepted, forward_held, forward_refused, forward_listed, intake_accepted, intake_held, intake_refused, intake_rejected). /pending returns {enabled, pending_max, pending, blocked, held, accept_forwarded}; /invites returns {can_sign, invites}.
curl -s http://hub-a:9800/api/admission/pending
{
"interface": "atlas0",
"enabled": true,
"pending_max": 200,
"pending": [
{ "id": "5d2e81a0", "key": "…", "source": "198.51.100.23:51820", "claimed_name": "phone-07",
"uuid": null, "first_seen": 1791804903, "last_seen": 1791804911, "count": 4 }
],
"blocked": []
}
Write endpoints
Runs the daemon's exact load pipeline over posted TOML — the same findings atlasd config check prints, without touching the running state.
curl -X POST -b "$SESSION" -H "X-Atlas-CSRF: 1" \
-H "Content-Type: application/json" \
-d '{"toml": "[interface]\nname = \"nexus0\"\n..."}' \
http://node-a:9800/api/config/validate
{
"ok": false,
"findings": [
{
"fatal": true,
"path": "peer[0].allowed_ips[0]",
"message": "invalid CIDR: \"10.0.100.2/33\""
},
{
"fatal": false,
"path": "scheduler.priority_order",
"message": "unknown link name \"wierd\" is ignored"
}
]
}
The commit-confirmed apply lifecycle: stage a config, commit it, and confirm within the window (default 90 s, configurable 5 s–24 h) — or the node reverts itself. The question "can I brick a remote node with a config push?" is answered by this machinery: no, it un-bricks itself. GET /api/config/apply/status reports the state machine.
curl -X POST -H "Authorization: Bearer $TOKEN_W" -H "X-Atlas-CSRF: 1" \
-H "Content-Type: application/json" \
-d '{"toml": "[interface]\n...", "confirm_secs": 90}' \
http://node-a:9800/api/config/stage
{
"stage_id": "9f2c41d3a8b0",
"diff": [ "…unified diff against the live config…" ],
"hot": false,
"restart": true
}
curl -X POST -H "Authorization: Bearer $TOKEN_W" -H "X-Atlas-CSRF: 1" \
-H "Content-Type: application/json" \
-d '{"stage_id": "9f2c41d3a8b0"}' \
http://node-a:9800/api/config/commit
{
"state": "awaiting_confirm",
"stage_id": "9f2c41d3a8b0",
"deadline": 1786297180,
"auto_confirm": "manual"
}
curl -X POST -H "Authorization: Bearer $TOKEN_W" -H "X-Atlas-CSRF: 1" \
http://node-a:9800/api/config/confirm
{ "state": "committed", "stage_id": "9f2c41d3a8b0" }
{ "state": "reverted", "reason": "timeout" }
Merge-update the live QoS policy (applied within the reload cadence, no restart); operator-inject a position for nodes without a feed.
curl -X PUT -H "Authorization: Bearer $TOKEN_W" -H "X-Atlas-CSRF: 1" \
-H "Content-Type: application/json" \
-d '{"classes": [{
"class": "Video",
"reliability": "DeadlineBounded",
"multipath": { "kind": "Fec", "overhead_pct": 25 },
"priority": 2,
"latency_budget_ms": 200
}]}' \
http://node-a:9800/api/qos
{
"classes": [
{
"class": "Video",
"reliability": "DeadlineBounded",
"multipath": { "kind": "Fec", "overhead_pct": 25 },
"priority": 2,
"latency_budget_ms": 200
},
{ "class": "Control", "...": "..." }
]
}
curl -X PUT -H "Authorization: Bearer $TOKEN_W" -H "X-Atlas-CSRF: 1" \
-H "Content-Type: application/json" \
-d '{"lat_deg": 42.6977, "lon_deg": 23.3219, "alt_m": 590}' \
http://node-a:9800/api/position
{
"injected": { "lat_deg": 42.6977, "lon_deg": 23.3219, "alt_m": 590.0 },
"advertised": { "lat_deg": 42.6977, "lon_deg": 23.3219, "alt_m": 590.0 }
}
Fleet configuration: sign the policy sections as the next epoch and distribute it mesh-wide, directly or as a staged canary rollout (start at a canary set, then release fleet-wide or abort).
curl -X POST -H "Authorization: Bearer $TOKEN_W" -H "X-Atlas-CSRF: 1" \
-H "Content-Type: application/json" \
-d '{"confirm_secs": 90, "target_roles": ["relay"]}' \
http://node-a:9800/api/config/push
{
"epoch": 7,
"hash8": "3d92c1aa",
"targeted": { "roles": ["relay"] }
}
curl -X POST -H "Authorization: Bearer $TOKEN_W" -H "X-Atlas-CSRF: 1" \
-H "Content-Type: application/json" \
-d '{"canary_nodes": ["8b4c901a"], "confirm_secs": 120}' \
http://node-a:9800/api/config/rollout/start
{
"rollout_id": "r-8f31",
"phase": 1,
"canary_epoch": 8,
"hash8": "5e07b2c4",
"canary_nodes": ["8b4c901a"],
"unknown_nodes": []
}
curl -X POST -H "Authorization: Bearer $TOKEN_W" -H "X-Atlas-CSRF: 1" \
http://node-a:9800/api/config/rollout/release
# 400 "empty_canary_set" — a rollout targeting everyone is just a push
# 409 "rollout_in_flight" — release or abort the current one first
The dashboard's Remove nodes… (see Revocation). Body {"nodes": [uuid…], "reason"?, "dry_run"}. A dry run previews the removal; the real run signs the next list with this node's config-author key and hands it to its daemon, which adopts and floods it. A signed-in session or an API token with write scope may call it, so the backend that admitted a node can remove it. A node cannot remove itself.
curl -X POST -b "$SESSION" -H "X-Atlas-CSRF: 1" \
-H "Content-Type: application/json" \
-d '{"nodes": ["5a1c3e70-8d2b-4f61-9c0e-2e7b44d19e02"], "reason": "lost on sortie 12", "dry_run": true}' \
http://node-a:9800/api/fleet/revoke
{
"ok": true,
"preview": {
"fleet": "field-1",
"current_seq": 7,
"next_seq": 8,
"remove": [{ "node_id": "5a1c3e70-8d2b-4f61-9c0e-2e7b44d19e02", "name": "drone03", "node_id_short": "5a1c..9e02" }],
"remaining": 5,
"rotation": { "kind": "mesh_secret", "recipients": 5 },
"warnings": []
}
}
{ "ok": true, "seq": 8, "hash8": "9b0e7d21", "removed": [ … ], "pushed": true }
# 400 "unknown_node" — no record with that node id
# 404 "not_a_record_fleet" — the node runs without a fleet list
# 409 "no_author_key" — this node's key cannot sign the list ("hint" says why)
# 422 "invalid" — no nodes selected, or the node named itself
The Topology map's area tool (see Remove nodes by area). Body {"polygon": [[lat, lon], …]} or {"geojson": …} ([lon, lat]). Resolves the polygon against this node's last known positions and returns the review; it removes nothing — the removal is /api/fleet/revoke with the node ids you confirmed. A session or a write-scope API token may call it.
curl -X POST -H "Authorization: Bearer $TOKEN_W" -H "X-Atlas-CSRF: 1" \
-H "Content-Type: application/json" \
-d '{"polygon": [[42.66, 23.28], [42.66, 23.38], [42.72, 23.38], [42.72, 23.28]]}' \
http://node-a:9800/api/fleet/select-area
{
"ok": true, "fleet": "field-1", "seq": 7,
"inside": [
{ "node_id": "4c1e…", "node_id_short": "4c1e..a902", "name": "n3", "lat_deg": 42.6977, "lon_deg": 23.3219,
"fix_age_secs": 1, "live": true, "is_self": false },
…
],
"outside": 2,
"no_position": [{ "node_id": "e5a3…", "node_id_short": "e5a3..1b7c", "name": "n2", "is_self": false }],
"polygon": [[42.66, 23.28], [42.66, 23.38], [42.72, 23.38], [42.72, 23.28]]
}
# 404 "not_a_record_fleet" — the node runs without a fleet list
# 422 "invalid" — fewer than 3 or more than 256 corners, out of range, or winding around a pole
# 503 "no_stats" — the daemon is not running
The dashboard's Zeroize… (see Wipe a node remotely). Body {"node", "not_before"?, "revoke"?, "cancel"?, "dry_run"?}. A dry run previews the next list; the real run signs the command — and, unless "revoke": false, the node's revocation — into the next list with this node's config-author key and hands it to its daemon, which floods it. "cancel": true drops a pending command. A node cannot zeroize itself remotely. A session or a write-scope API token may call it.
curl -X POST -b "$SESSION" -H "X-Atlas-CSRF: 1" -H "Content-Type: application/json" \
-d '{"node": "n2", "dry_run": true}' http://node-a:9800/api/fleet/zeroize
{ "ok": true, "preview": { "fleet": "field-1", "current_seq": 8, "next_seq": 9,
"command": { "node_id": "…", "node_id_short": "e5a3..1b7c", "name": "n2", "not_before": 0, … }, "can_sign": true } }
{ "ok": true, "seq": 9, "hash8": "4f7a2c10", "command": { … }, "pushed": true }
# 400 "unknown_node" — no record or revoked node by that name or id
# 404 "not_a_record_fleet" — the node runs without a fleet list
# 409 "no_author_key" — this node's key cannot sign the list ("hint" says why)
# 422 "invalid" — no node given, no pending command to cancel, or the node named itself
The admission lifecycle (see Admit nodes by pairing code, certificate or approval). Each request goes to the daemon through a file beside its config — the daemon is the only writer of the admission store — and the API waits up to 5 s for its answer. The journal records the operator: web:<user> for a session, token:<name> for an API token; the server sets it, never the body. All five are on the write-token allowlist.
| Path | Body | Answer |
|---|---|---|
/api/admission/invites | {name, allowed_ips | pool, uses, ttl_secs, classes, relay, dashboard, roles, region} — ttl_secs defaults to 900, uses to 1 | {id, name, token, uri, uri_len, code: {version, hub_key, anchors, authors, address, interface_address, hub_address, region}, expires_at, uses, capabilities, allowed_ips, pool, can_sign}; the token is returned once; code spells out what the v=3 pairing code carries under the token's MAC (see what the code carries) |
/api/admission/invites/withdraw | {id} (or its first 8 characters) | {id} |
/api/admission/approve | {id, allowed_ips | pool, name, classes, relay, dashboard, roles}; for a forwarded record held on an author, {id} signs it exactly as minted | {id, name, node_id, allowed_ips, capabilities, signed, queued, adopted_seq} |
/api/admission/deny | {id, block_secs, reason} | {id, blocked_until} |
/api/admission/unblock | {id} | {id} |
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}' \
http://hub-a:9800/api/admission/invites
{ "ok": true, "id": "7c41e09a2f5d4b8e9a3c11d07f62b5e4", "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": { … }, "allowed_ips": ["10.1.0.17/32"], "pool": null, "can_sign": true }
# 404 "unknown_invite" | "unknown_knock" | "unknown_block"
# 409 "not_a_record_fleet" — invitations need provider = "fleet" and a list
# 409 "refused" — the key, UUID or signing key is revoked or taken
# 422 "invalid" — a bad CIDR, no name, or both allowed_ips and pool
# 503 "daemon_unavailable" — the daemon did not answer within 5 s
Fleet reads
GET /api/fleet/configs and GET /api/fleet/health fan out over mesh members through the tunnel and return per-node rows — including honest reachable: false entries for nodes that did not answer. Session-only by design.
Fleet pushes and rotations
The release gate, update classes and rotation schedules are read and written here; the dashboard's Configure tab and atlasd fleet rollout / fleet rotate use the same calls. Writes sign with this node's config-author key (409 no_author_key otherwise) and take a session with the CSRF header; update-class also accepts a write-scope API token, the rotation writes do not.
The live rollout record. Beside the canary gate, a release_gate during and after a release watch: { epoch, deadline, required_total, required_confirmed, required_silent, required_failed, holding, stragglers, satisfied, nodes: [{ node_id_short, name, class, pinned, state, confirmed, failed, silent, epochs_behind }], reason }; phase may be complete; abort_cause is { node_id_short, name, reason, phase } after an automatic abort; classes lists every record's class.
Body gains "release_watch_secs": this rollout's release deadline, counted from the release; omitted, [config_mgmt] release_watch_secs applies, and 0 means three confirm windows plus two minutes; given, it must be 10 to 604 800. /release and /abort are unchanged, except that an abort may also end a released rollout still waiting on a required node.
Body {"node", "class": "required"|"best_effort"|"left_behind", "pinned"?, "dry_run"?}, node a record name, UUID or short node id. Writes the record's class into the next fleet list, signs and pushes it; answers { ok, node, class, pinned, seq, hash8, pushed }. The Fleet view's Set class action.
Every schedule in the list (rotations[]: id, kind, subject, subject_name, next_key_id, the three instants, state, recipients) with its retire gate — required_total, required_on_new, holding, satisfied, reason, every node's reported state in nodes and the nodes still on the old key or secret in on_old — plus own (this node's view from its snapshot), nodes with each record's class and lifeline flag, the defaults and lifeline_secs in force, and can_sign. The Configure tab's Rotations view.
$ curl -sS -H "Authorization: Bearer $TOKEN" "http://gs1:9800/api/fleet/rotations?interface=atlas0" \
| jq '.rotations[0] | {id, kind, state, gate: (.gate | {required_total, required_on_new, satisfied, holding}), on_old: [.gate.on_old[].name]}'
{
"id": "rot-1791900000-mesh_secret",
"kind": "mesh_secret",
"state": "published",
"gate": { "required_total": 2, "required_on_new": 2, "satisfied": true, "holding": [] },
"on_old": ["drone-b", "drone-c"]
}Plan a rotation. Body {"kinds": ["mesh_secret"|"node_transport"|"node_signing", …], "subject"?, "next_key"?, "next_signing_key"?, "publish_in"?, "activate_after"?, "dry_run"?} — several kinds plan together in one revision; publish_in is seconds from now (default 0), activate_after seconds from publish (default [rotation] default_publish_to_activate_secs); a mesh-secret plan mints the secret on this node and seals it to every record. Signs and pushes the list; answers { ok, planned: [{ id, kind, publish_at, activate_at, recipients, next_key_id }], seq, hash8, pushed }.
Sign a revision: bring activation forward to now; set retire_at to now — 409 gate_not_met with the gate while a required node, or the subject, has not reported active, unless {"force": true, "reason"}, which is recorded as rotation.retire_forced and answered with forced: true; or cancel a rotation that has not activated.
Each record's capabilities now include sink (a read-only member, the feed gateway host), beside update_class and lifeline; a sink's compiled capabilities carry no relay, no classes and no authorship whatever the record lists. On any node, /api/stats counts caps.sink_dropped: data packets a sink originated that this node dropped (journalled caps.sink_dropped once a minute per sink). On the sink itself, caps.sink_local_dropped counts what its own TUN offered and it held back, and multicast carries redundant_parents, redundant_honoured (a sink asking for 2) and alt_parent_accepted (packets taken from the second parent).
The clamp at every hop (see capability flags): on any node that forwards relayed traffic, caps.class_clamped_transit counts packets whose class byte this node, as a transit hop, lowered to what the origin's record allows — the origin being the tunnel address in the 12-byte relay header — and caps.origin_unrecorded counts classified packets it dropped because no record covers that origin. On a destination running [crypto] e2e, e2e.class_tampered counts sealed packets that opened but arrived with a class above the one their sender sealed, or under another origin: a relay upgraded them or changed their sender, and they were dropped. The dashboard's Refusals row shows the three as clamped-in-transit, relayed-from-no-record and relay-tampered. GET /api/identity returns the same caps counters as caps_counters.
Realistic examples
# External monitor: healthy exit or non-zero
curl -fsS http://node-a:9800/api/health
# Read a locked-down (protect = "all") fleet node over the tunnel
curl -H "X-Atlas-Node-Token: $(atlasd config node-token)" \
http://10.0.100.2:9800/api/config
# CI: mint once, read forever (well, 30 days)
TOKEN=$(atlasd web token add ci --scope read --expires 30d)
curl -H "Authorization: Bearer $TOKEN" \
"http://node-a:9800/api/stats?interface=nexus0"
# Change QoS policy from automation (write scope + CSRF header)
curl -X PUT -H "Authorization: Bearer $TOKEN_W" -H "X-Atlas-CSRF: 1" \
-H "Content-Type: application/json" \
-d '{"classes":[{"class":"Video","reliability":"DeadlineBounded",
"multipath":{"kind":"Fec","overhead_pct":25},"priority":2}]}' \
http://node-a:9800/api/qos
/opt/atlas/web), served by the same process. Without them the port is API-only — which is exactly what a headless integration wants.Building a client?
Official SDKs are planned, with a published OpenAPI document shipping first — the pipeline, language lineup and scope are laid out on the SDKs page. Until then, every response shape on this page is verified against the daemon source, and the config schema is already machine-readable today.