Configuration
Everything is configured through environment variables. There is no configuration file: .env exists only for make dev and make pg-up, and is never read in production. Export the variables for real there, with a systemd EnvironmentFile=, an orchestrator secret, or whatever your platform offers.
An empty variable counts as unset and falls back to its default.
Database
Every connection is opened with timezone=UTC. See Database for what the roles may do.
Server
Session
NINHO_SESSION_KEY has to be set, and identical, on every instance behind a load balancer. The cookie is stateless and signed with HMAC-SHA256: an instance cannot verify a cookie another instance signed with a different key, and the symptom is a sign-in that works followed by 401 on every screen. Changing the key signs everyone out, and nothing else.
Any non-empty string works as a key. Generate one with openssl rand -base64 32.
LDAP
With an ldap:// URL and NINHO_LDAP_INSECURE unset, the connection is upgraded with StartTLS and a failure to upgrade fails the sign-in. ldaps:// URLs are always TLS. Both require TLS 1.2 or newer and a certificate the system trusts.
See Configure LDAP sign-in for the group check and The authentication model for what each bind is for.
Image build
Read only by make docker, which asks Quay to build. They are not read by the server.
Timeouts and limits
These are not configurable. They are listed here because they explain behaviour you may otherwise have to read the source to understand.
HTTP API
Two surfaces on one port: ingest, which the agent writes to, and the read API, which the UI and your scripts read from. They authenticate differently and on purpose, and the ingest path never depends on a session.
All responses are application/json; charset=utf-8 unless noted. All instants are RFC 3339 in UTC.
Ingest
POST /api/ingest
The path is configurable with NINHO_INGEST_PATH. Implements section 4 of corvo's design/api-contract.md.
Authentication: Authorization: Bearer <NINHO_INGEST_TOKEN>, compared in constant time. An empty NINHO_INGEST_TOKEN disables the check and accepts any agent.
Body: a JSON array of envelopes. Content-Encoding: gzip is accepted.
[{"v": 1, "id": "...", "host": "...", "event": { }}]
From event, these become columns: @timestamp, category, record_type, result, session, summary.actor.primary, summary.action, user.ids.auid, user.ids.uid, process.exe, and process.args joined by a space into args_text. Everything else stays in the jsonb and is still queryable there. No field inside event is required, and record_type is not validated against a list: the set of types is each host's filter's choice, and it changes with a SIGHUP.
The read rows also carry auid_name and uid_name, the id turned into the name a person is held accountable by. It comes from summary.actor or user.names when corvo resolved the id on the host. When corvo could not (an agent in a container has no access to the host's /etc/passwd), the name is recovered from /home/<name> in process.cwd, which only names the logged-in user, so it stands in for uid only when uid is that same id. Empty string when nothing was found, and the id then shows on its own.
An unparseable @timestamp falls back to the receive instant. Dropping the event would be worse than storing it with an approximate time.
Envelopes whose host is corvo-check, or whose id starts with corvo-check-, are corvo check probes: they are counted, answered 2xx, and not persisted.
Responses
Getting the 400 and 503 distinction wrong is how audit data disappears without anyone noticing. See The contract and the status codes.
Authentication
POST /api/login
{"username": "ana", "password": "..."}
Body is capped at 8 KiB. The whole LDAP exchange has a 10 second deadline.
200 sets the ninho_session cookie and returns {"auth": true, "user": "ana", "version": "0.1.0+..."}. 401 with a plain text body means an unknown user, a wrong password, or a user outside NINHO_LDAP_GROUP_DN: the three are deliberately indistinguishable to whoever is guessing. The reason is in the server log.
With no directory configured, this returns 200 and {"auth": false, "user": "", "version": "..."} without setting a cookie.
POST /api/logout
204, and clears the cookie.
GET /api/me
{"auth": bool, "user": string, "version": string}. auth is false when no directory is configured, which is how the UI decides between the sign-in screen and the trail. Needs no session.
Reading the trail
Every route below requires a session cookie or Authorization: Bearer <NINHO_READ_TOKEN>, and answers 401 not authenticated without one. When no directory is configured they are all open. All of them except the stream are gzipped when the client asks for it.
A failed query answers 503 unavailable, never a 4xx: the client asked for something valid and our side failed.
GET /api/events
The main query. Keyset pagination, never OFFSET.
{
"events": [ ],
"next_cursor_at": "2026-01-15T10:30:00Z",
"next_cursor_id": "..."
}
The two cursor fields are present only when the page came back full, which is the signal that another page may exist. Rows carry the extracted columns and never the jsonb.
Ordering is occurred_at DESC, id DESC.
GET /api/events/{id}
The only route that returns the whole raw event. occurred_at is a required query parameter in RFC 3339, because the table is partitioned by it and the primary key is the (id, occurred_at) pair.
400 without it, 404 when there is no such event.
GET /api/sessions/{host}/{session}
Everything one login session did on one host, oldest first. limit defaults to 1000, capped at 5000.
GET /api/agents
One row per host: first_seen, last_seen, last_event_at, agent_version, events_total, and silence_seconds computed in the database. Ordered by last_seen ascending, so the quietest agent is first.
silence echoes back as silence_threshold for the UI to draw a line with. It defaults to 3600 and is capped at 30 days.
GET /api/overview
{
"per_hour": [{"hour": "...", "total": 0}],
"top_exe": [{"value": "/usr/bin/rm", "total": 0}],
"top_auid": [ ], "top_host": [ ],
"silent_agents": [ ],
"silence_threshold": 3600
}
Every ranking covers the last 24 hours and is aggregated in the database. top_auid shows summary.actor.primary when there is one and falls back to the raw auid.
GET /api/stream
Server-sent events. One data: line per event, carrying the same shape as an /api/events row.
The stream starts at the moment you connect: it shows what arrives from here on, not history. It is driven by received_at and not occurred_at, so a redelivered event with an old timestamp still shows up.
A single goroutine polls every 2 seconds and fans out to every client, so open tabs do not multiply the database load, and no query runs when nobody is connected. Each client has a 64-message buffer and a client that cannot keep up loses messages rather than holding up the others. Every 20 seconds an SSE comment (: ping) goes out so no proxy closes an idle connection.
This route is not gzipped: compressing a stream that has to flush on every event only adds buffering between the database and the screen.
Everything else
GET / and anything unmatched serves the embedded UI.
Database
PostgreSQL. The schema is created by the migrations the server applies at startup, so there is no separate migration step and no migration tool to install.
Migrations
The .sql files under migrations/ are embedded in the binary and applied in name order, each in its own transaction, and recorded in schema_migration (name, applied_at). A file that has already been applied is skipped.
Startup takes a PostgreSQL advisory lock (id 8101975031) first, so two instances booting at the same time do not apply the same migration twice.
Migrations run as NINHO_DSN, the owner. The ingest and read roles never apply schema changes.
audit_event
The trail. Partitioned by RANGE (occurred_at), primary key (id, occurred_at).
The extracted columns exist to be queried and indexed. The jsonb is the source of truth, which is why a new record_type never needs a schema migration: it is already stored, and already queryable through the jsonb.
Partitions
Monthly, named audit_event_YYYY_MM, created at startup and rechecked every hour for the current month plus NINHO_PARTITION_MONTHS - 1 ahead. The hourly recheck is what makes the month rollover independent of anyone restarting the server.
audit_event_default catches everything that falls outside them. Without that escape hatch, an event from a host with a badly wrong clock would fail to insert, the response would be 503, and the agent would retry that batch forever.
If a monthly partition cannot be created because the default partition already holds rows in its range, startup logs a warning and carries on. Moving those rows is a manual operation, and blocking the server over it would be worse.
Indexes
agent
One row per host: first_seen, last_seen, last_event_at, agent_version, events_total. Upserted once per batch, in the same transaction as the events, with last_event_at taking the greater of the old and new values and events_total accumulating.
agent_version comes from the User-Agent header and not from the body, since the body is written by the same agent the field describes.
This table exists because a table of events cannot detect silence on its own.
v_command
A convenience view over events that have a process.exe, exposing exe, cwd, and session pulled out of the jsonb. The test is for process.exe being filled in and not for process existing, because process is a struct and shows up in the JSON even when empty.
principal_name(event, which)
003_principal_name.sql adds an immutable SQL function that turns 'auid' or 'uid' into the name a person is held accountable by. It reads summary.actor and user.names first, and when corvo resolved neither (an agent in a container has no host /etc/passwd), falls back to /home/<name> in process.cwd. The read queries call it for the auid_name and uid_name fields; the jsonb still never leaves the server.
Roles
002_roles.sql creates two roles, NOLOGIN and passwordless, because a password in versioned SQL is a leaked password.
Neither can DELETE or UPDATE the trail. The trail stays append-only even if the endpoint token leaks, which is the scenario this separation exists for.
On a database where the owner cannot create roles, the migration raises a notice and moves on. Create them by hand with a superuser, then follow Split the database roles.
Writes
A batch is one transaction: all events through pgx's SendBatch, plus one upsert per distinct host. The event insert is ON CONFLICT (id, occurred_at) DO NOTHING, because corvo's delivery is at-least-once and a duplicate is routine rather than an edge case.