You are reading the documentation for the 2.0.0 release line. The current line is 3.0.0. Read this page in the current line

REST API

Examples verified against the daemon sourceView as Markdown

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 matching Origin when a browser sends one).
  • TLS: [web] tls = true serves a locally generated certificate — wanted in practice for passkeys.

Machine credentials

Two non-interactive credentials, deliberately different in power:

Node tokenAPI token
HeaderX-Atlas-Node-Token: …Authorization: Bearer atlas_…
Minted byatlasd config node-token — derived from the mesh secret, time-windowed, statelessatlasd web token add <name> --scope read|write [--expires 30d] — secret printed once
ScopeRead-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 forFleet 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

GET/api/stats

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.

Request
curl -s http://node-a:9800/api/stats
Response — abbreviated
{
  "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": "2.0.0",
    "build": "7c1e2a9",
    "contracts": { "wire": 4, "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",
      "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
}
GET/api/stats

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 or 3: a node accepts wire 4 only (the relay servers accept 4 and 3). 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 as peer.protocol_refused.
  • e2e.unsealed_no_cap — unicast sent hop-encrypted only, because the destination does not advertise seal. Always zero under e2e_require, which never downgrades.
  • multicast.child_without_cap — tree branches skipped because the child does not advertise multicast.
  • interconnect[].dropped_no_cap — seam egress refused because the far daemon on that seam does not advertise interconnect.
GET/api/interfaces

Array of daemon instances on this host.

Request
curl -s http://node-a:9800/api/interfaces
Response
["nexus0"]
GET/api/health

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.

Request
curl -fsS http://node-a:9800/api/health
Response — 200 when healthy, 503 otherwise
{
  "ok": true,
  "interface": "nexus0",
  "version": "2.0.0",
  "build": "7c1e2a9",
  "contracts": { "wire": 4, "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" }
  ]
}
GET/api/journal

The persistent event journal, newest first, cursor-paged. Filters: since, until, category, severity, code, limit.

Request
curl -s "http://node-a:9800/api/journal?category=link&severity=warn&since=1h"
Response
{
  "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"
}
GET/api/scheduler/log

Ring buffer of recent scheduler decisions — which link won each placement and at what cost. The fastest way to understand why traffic moved.

GET/api/netcheck

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.

GET/api/config · /api/config/schema

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.

Request
curl -s http://node-a:9800/api/config/schema
Response — abbreviated
{
  "$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": { } }
}
GET/metrics

Prometheus/OpenMetrics exposition for every interface on the host. On by default; follows the node's protection posture like any read.

Request
curl -s http://node-a:9800/metrics
Response — excerpt
# 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
GET/api/terrain · /api/terrain/grid

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.

Request
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'
Response — abbreviated
{"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
GET/api/qos · /api/position · /api/crash · /api/mode · /api/auth/status

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.

GET/api/identity · /api/fleet

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 and the list's commands[], re-verified from disk on every request ({"mode":"keys"} without one). See Identity and authorisation.

Request
curl -s http://node-a:9800/api/identity
Response — abbreviated, a node mid-rotation
{
  "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
}
GET/api/config/rollout · /api/config/rollouts

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.

Request
curl -s http://node-a:9800/api/config/rollout
Response — abbreviated
{
  "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": [ … ]
}
The archive list
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 } ] }
GET/api/admission · /api/admission/pending · /api/admission/invites

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}.

Request
curl -s http://hub-a:9800/api/admission/pending
Response — one knock waiting
{
  "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

POST/api/config/validate

Runs the daemon's exact load pipeline over posted TOML — the same findings atlasd config check prints, without touching the running state.

Request — session-gated (not reachable via API tokens)
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
Response — ok is false only on fatal findings
{
  "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"
    }
  ]
}
POST/api/config/stage → commit → confirm | revert

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.

1 — Stage the new config
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
}
2 — Commit; the daemon re-validates, applies and restarts in place
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"
}
3 — Confirm inside the window (or the node reverts itself)
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" }
A missed window instead reports
{ "state": "reverted", "reason": "timeout" }
PUT/api/qos · /api/position

Merge-update the live QoS policy (applied within the reload cadence, no restart); operator-inject a position for nodes without a feed.

Request — thicken Video's FEC; other classes keep their persisted policy
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
Response — the full merged policy view, abbreviated
{
  "classes": [
    {
      "class": "Video",
      "reliability": "DeadlineBounded",
      "multipath": { "kind": "Fec", "overhead_pct": 25 },
      "priority": 2,
      "latency_budget_ms": 200
    },
    { "class": "Control", "...": "..." }
  ]
}
Position: request, then the injected + advertised view
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 }
}
POST/api/config/push · /api/config/rollout/*

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).

Push — sign the live config's policy as the next epoch (toml optional)
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"] }
}
Canary rollout — phase 1 targets only the named nodes
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": []
}
Then release fleet-wide, or abort — and the guard rails
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
POST/api/fleet/revoke

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.

Preview
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": []
  }
}
Sign and push — the same body with "dry_run": false
{ "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
POST/api/fleet/select-area

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.

Request
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
POST/api/fleet/zeroize

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.

Preview
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 } }
Sign and push — the same body with "dry_run": false
{ "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
POST/api/admission/invites · invites/withdraw · approve · deny · unblock

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.

PathBodyAnswer
/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}
Mint a pairing code from a backend
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.

GET/api/config/rollout

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.

POST/api/config/rollout/start

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.

POST/api/fleet/update-class

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.

GET/api/fleet/rotations

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.

Example
$ 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"]
}
POST/api/fleet/rotations

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 }.

POST/api/fleet/rotations/<id>/activate · retire · cancel

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.

GET/api/fleet

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).

GET/api/stats

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
Serving the dashboard: the browser UI is static files on disk (/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.

Esc
↑ ↓ navigateEnter — openEsc — close