Docs menu ▾
Genos docs / updated 2026-10-28

API reference

The wire protocol between hosts and the fleet server. All endpoints are on https://genossec.com, and all are client-initiated: there's no endpoint that sends anything to a host.

Endpoint Caller Auth
POST /v1/enroll Daemon, once One-time token
POST /v1/heartbeat Daemon, every 30 s Signed envelope
POST /v1/events Daemon Signed envelope
POST /v1/approvals Daemon, polling Signed envelope
POST /baseline Scanner, once per host None; verified later by heartbeat
GET /dashboard/catalog Scanner None (public data)

The clients are genos_scanner_enforcer/enforcer/genos-daemon/src/reporter.rs and genos_scanner_enforcer/installer/scanner.sh.in. The server is fleet/fleetmon/ingest.py.

Errors are JSON, {"error": "…"}, with these statuses:

Status Meaning
400 Malformed request
401 Unknown agent, bad signature or bad token
409 Replay, or a duplicate baseline
413 Too large

POST /v1/enroll

Trades a one-time token for an agent_id, and registers the host's public key.

{
  "enrollment_token": "genos_enr_…",
  "agent_pubkey_ed25519": "<base64 of the raw 32-byte public key>",
  "hostname": "web-01"
}

201 → {"agent_id": "<uuid>"}

  • The token must exist, be unused and be under 24 hours old. It's spent in the same transaction that creates the agent, so two concurrent requests can't both use it.
  • The server stores only the SHA-256 of each token.
  • A public key can enroll only once (409).
  • The pubkey may also be 64 hex characters.

Signed envelopes

Every daemon message after enrollment is wrapped in an envelope:

{
  "agent_id": "…",
  "seq": 42,
  "nonce": "<base64, 16 random bytes>",
  "sent_at": "1793181600",
  "kind": "heartbeat",
  "body": { … },
  "sig": "<base64 Ed25519 signature, 64 bytes>"
}

Signature

The signature covers these six fields joined by \n, with no trailing newline:

agent_id \n seq \n nonce \n sent_at \n kind \n <body as compact JSON>
  • seq is its decimal form.
  • Compact JSON means no whitespace, keys in the order they appear on the wire, and non-ASCII characters as UTF-8 rather than \u escapes. That's what serde_json::to_string produces. The server re-serializes the received body the same way and verifies against the key stored at enrollment.
  • kind is inside the signature, so a message can't be replayed as a different kind.

Sequence numbers

seq increases by one with each message from a host, across all kinds. The server accepts each seq once. A message may arrive up to 1024 behind the highest seq seen, because the daemon's heartbeat and event threads send concurrently, so delivery order isn't guaranteed. A repeated seq, or one older than that window, is rejected with 409 and raises replayed_seq.

Check order

  1. Shape (400)
  2. Agent exists (401, unknown_agent)
  3. Signature (401, bad_signature)
  4. Body (400)
  5. Sequence (409, replayed_seq)

A message is stored only if all five pass.

POST /v1/heartbeat

kind: "heartbeat". The body is the node's posture:

Field Type Meaning
enforcing bool Hooks attached and the protected set loaded
hooks_attached string[] e.g. inode_create, inode_rename, inode_link
protected_dir, protected_inode string, int What's protected
live_grants int Grants active right now (count only)
policy_wasm_sha256 string SHA-256 of the installed grant policy (empty if none)
scanner_version, bundle_sha256 string The scanner build that made the baseline
canonical_hash, entities_digest string The baseline the daemon enforces against; checked against the upload
boot_id, kernel_release string Host identity for this boot
daemon_uptime_secs int Seconds since the daemon started

200 → {"ok": true}

Each heartbeat is checked against the expected hashes in the server config and against the host's uploaded baseline. A heartbeat that ends a silence longer than 3× the interval raises heartbeat_gap.

POST /v1/events

Decisions: kind: "events"

{"decisions": [
  {"observed_at": "1793181600", "action": "create", "decision": "DENY",
   "dir_inode": 262145, "dir": "/etc/sudoers.d", "name": "zz-new", "uid": 1000}
]}
Field Meaning
observed_at When it happened. Epoch seconds (or ms/ns), or ISO 8601
action create, rename, link, symlink or open
decision ALLOW or DENY
dir_inode The protected directory's inode
dir, name The directory and bare file name, when the daemon can resolve them. A DENY with both opens a ticket
uid The acting user

Up to 5,000 decisions per message. 200 → {"ok": true, "accepted": <n>, "tickets": <n>}

Lifecycle: kind: "lifecycle"

{"lifecycle": "daemon_start"}

A daemon_start from a node that has already sent heartbeats raises daemon_restart.

POST /v1/approvals

The daemon pulls approvals an admin granted for its blocked files. kind: "approvals", body {}. Read-only and idempotent, so the server doesn't check seq on it — a repeated poll returns the same list.

200 →

{"approvals": [
  {"ticket_id": "TKT-ab12cd34", "dir": "/etc/cron.d", "name": "backup",
   "ttl_secs": 300, "not_after": 1793181600, "sig": "<base64 ed25519>"}
]}
  • sig is Ed25519 over agent_id\nticket_id\ndir\nname\nttl_secs\nnot_after (ttl_secs and not_after as decimal integers), made with the server's approver key. The daemon verifies it against /etc/genos/approver-ed25519.pub before acting.
  • This is the only node-facing response that carries more than an acknowledgement. It's still host-initiated: the server offers approvals, the daemon decides whether to honor them. The server never opens a connection to a host. See Security model.

POST /baseline

The scanner's install-time report, sent once per host before the daemon exists.

Content-Type: application/json
Content-Encoding: gzip
{
  "report": "genos.phase0.baseline/1",
  "status": "ok",
  "host": "web-01",
  "sent_at": "2026-10-28T12:00:00Z",
  "scanner_version": "0.3.0",
  "bundle_sha256": "<64 hex>",
  "timing": {"scan_started_at": "…", "scan_seconds": 312},
  "profile": {"header": {…}, "coverage": {…}, "vectors_present": […],
              "entities_digest": "<64 hex>", "canonical_hash": "<64 hex>"},
  "unmapped_queue": {…},
  "entities": [{"entity_key": "…", "kind": "…", "path": "…", "integrity_class": "…",
                "metadata": {…}, "vector_ids": […], "families": […]}, …]
}

A failed scan sends "status": "failed" and an error: {exit_code, log_tail} instead of profile and entities.

Response When
200 {"saved", "kind": "baseline", "entities"} Stored
200 {"kind": "failed" \| "incomplete"} Logged as an attempt; a later complete scan is still accepted
400 Wrong format, or entities don't hash to profile.entities_digest
409 This host already has a baseline
413 Over 32 MB compressed or 96 MB uncompressed
  • Digest check: SHA-256 of entities serialized with sorted keys, separators , and :, and UTF-8. It must equal profile.entities_digest.
  • Trust: the upload is unsigned. It's marked Verified only when a signed heartbeat from the same hostname reports the same canonical_hash and entities_digest (how).

GET /dashboard/catalog

The catalog bundle the scanner downloads:

{"fingerprint": "<64 hex>", "entities": [{"entity_id": "ENT-…", "entity_kind": "…", …}, …]}
Header Value
X-Genos-Catalog-SHA256 SHA-256 of the exact body; what scanners pin as GENOS_CATALOG_SHA256
ETag The same hash. Send If-None-Match to get 304 when unchanged
Cache-Control no-cache

The body is byte-for-byte the output of python3 -m catalog bundle in genos_inventory. It's 503 if the catalog couldn't be built.

View this page on GitHub ↗