Docs menu ▾
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>
seqis 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
\uescapes. That's whatserde_json::to_stringproduces. The server re-serializes the receivedbodythe same way and verifies against the key stored at enrollment. kindis 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
- Shape (
400) - Agent exists (
401,unknown_agent) - Signature (
401,bad_signature) - Body (
400) - 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>"}
]}
sigis Ed25519 overagent_id\nticket_id\ndir\nname\nttl_secs\nnot_after(ttl_secsandnot_afteras decimal integers), made with the server's approver key. The daemon verifies it against/etc/genos/approver-ed25519.pubbefore 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
entitiesserialized with sorted keys, separators,and:, and UTF-8. It must equalprofile.entities_digest. - Trust: the upload is unsigned. It's marked Verified only when a signed heartbeat from the
same hostname reports the same
canonical_hashandentities_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.