why it is built this way
Explanation
The reasoning behind the decisions, and what each one costs.
Architecture
ninho is one Go binary with the web UI compiled into it, and PostgreSQL behind it. Nothing else is installed on the server. The constraint is deliberate. Every extra service under an audit system is one more thing that can be down on the day you need the trail.
corvo agents ninho PostgreSQL
(many hosts) ──▶ POST /api/ingest ──▶ audit_event (partitioned)
agent
browser ──▶ GET /api/events ──▶ read pool
GET /api/stream (2s poll, fanned out)
Two surfaces, one port
Ingest is what the agents write to. It authenticates with a bearer token, implements section 4 of corvo's contract, and never depends on a session. If the whole read side were broken, ingest would keep accepting events. That is the right priority. A trail you cannot read today can be read tomorrow. An event that was never stored is just gone.
The read API is what the UI and your scripts use. It authenticates against LDAP, holds a signed cookie, and never writes.
They are separated all the way down to the database connection, so the identity that writes cannot delete and the identity that reads cannot write. See Split the database roles.
Storage
One table, audit_event, partitioned monthly by occurred_at. Extracted columns for the fields that get filtered and indexed, and the whole original event in a jsonb column that is never edited.
The columns make the trail queryable. Keeping the whole event beside them is what makes it faithful, and that pairing is the central design decision here. A new record_type, a new field, a change in what the agent's filter collects: none of it needs a schema migration, because it is already stored and already reachable through the jsonb.
Partitioning is by time because retention is by time. Dropping a month is a DROP TABLE, not a DELETE scanning tens of millions of rows.
The default partition exists so that an event nothing else accepts still lands somewhere. Without it, one host with a wildly wrong clock would fail its insert, get a 503, and retry that batch forever. The queue never drains, over a row nobody ever looked at.
Migrations at startup
The migrations are embedded in the binary and applied when the server starts, under an advisory lock so several instances booting together apply them once. There is no migration command, no migration tool to install, and no step between copying the binary and running it.
The monthly partitions are created the same way, at startup and then every hour, so the month rollover does not depend on anybody having restarted anything.
The live tail
A single goroutine polls the database every two seconds and fans out to every connected client over Server-Sent Events. The poll runs once for everyone. Open tabs are exactly the variable an operator does not control, and multiplying database load by that number is how a dashboard takes down the system it monitors. With nobody connected, no query runs at all.
It reads by received_at, not occurred_at, because a redelivered event can carry an old timestamp and would otherwise never appear in the tail.
SSE instead of WebSockets. The traffic only goes one way, and SSE survives the ordinary reverse proxies, reconnects, and corporate networks this actually runs behind.
Statelessness
Nothing that matters across requests is kept in memory. The session cookie carries its own claim and its own signature, so verifying it needs no lookup, no shared cache, and no sticky sessions. The only shared state is PostgreSQL.
This costs one thing. The signing key has to be the same on every instance, and when it is not set, each process invents its own. That is why its absence is a startup warning. See The authentication model.
The frontend
React and Vite, built into web/dist and embedded with //go:embed. The binary serves it from memory. In development, Vite runs alongside and proxies /api to the Go server, which is what make dev sets up.
The UI holds no logic that the API does not: every aggregation happens in the database, and no screen pulls rows into memory to count them.
The contract and the status codes
corvo's design/api-contract.md is what both sides implement, and section 4 of it is the part that decides whether audit data survives. It is worth understanding why a status code carries that much weight here.
What the agent does with each answer
corvo queues events on disk and delivers them at least once. What it does with a batch depends entirely on the status code it gets back:
Read that table as a description of consequences rather than of syntax. A 4xx means "this data is bad, stop trying". A 5xx means "this data is fine, I am not ready".
The mistake that loses audit data
Returning 400 because the database is down.
The body was valid. The agent did everything right. But 400 tells it the batch is malformed, so the batch goes to dead-letter, the queue moves on, and those records are gone. Nobody gets paged, because from the agent's point of view delivery ended normally, and from the server's point of view a client sent something bad. The loss is silent, and audit data does not come back.
So: any failure on our side is 503, always. The database being down, a connection pool exhausted, a query timing out. The client asked for something valid and we could not serve it, which is what 5xx means.
The mirror-image mistake
Returning 503 for a genuinely malformed body freezes the agent. It retries that batch forever, the queue behind it never drains, and every good event queued after the broken one stops arriving. One bad batch takes a host offline.
So a body that is not readable, is not a JSON array, or contains an unparseable event gets 400. It is bad data, it will never become good, and the agent needs to set it aside and carry on.
Why 401 and not 403
An unauthorized batch gets 401, which corvo retries for fifteen minutes. That window exists so a token rotation, delivered to the agent by SIGHUP, can land while the agent is still holding the events. A 403 would dead-letter them immediately and the rotation would cost you every event in flight.
Why the ingest response has no body
204 No Content. The agent has nothing to read: it acts on the status code alone. A body would be a place for the two implementations to disagree about something that does not matter.
Validation the server deliberately does not do
record_type is not checked against a list. The set of types is decided by each host's filter and changes with a SIGHUP. A closed list here would mean the server rejecting events an operator deliberately started collecting.
- No field inside
event is required. Different record types carry different fields, and a required field is a future rejection of a valid event.
- An unknown
v is not rejected. A newer agent talking to an older server keeps delivering, and its extra fields sit in the jsonb waiting for the server to catch up.
- An unparseable
@timestamp does not fail the event. The receive instant is used instead. An event with an approximate time is worth more than no event.
Every validation is a way to lose data, so a validation has to earn its place here. Two of them earned it: the size ceilings, so that one host cannot take the server down, and JSON that cannot be parsed at all.
corvo check
The probe corvo check sends is recognised by its host (corvo-check) or its id prefix, answered 2xx, and discarded. Persisting it would be wrong: its id collides between two probes in the same second, and its event carries almost nothing.
It is recognised rather than validated for the same reason as everything else here: a strict schema check would make check report failure on a perfectly healthy install.
On the read side
The same principle, pointed the other way. A failed query answers 503, never a 4xx, because the client asked for something valid and our side failed. The only 4xx on the read API are a missing required parameter, an event that does not exist, and a request with no valid credential.
The authentication model
Two independent mechanisms, because they protect different things from different people.
Ingest is protected by a bearer token, shared with the agents. It is a machine credential, rotated by editing a file and sending SIGHUP.
Reading is protected by the directory. It is a human credential, and ninho holds no copy of it.
The rest of this document is about the second one.
No user registry
There is no user table, no password hash, no account management, no "forgotten password". LDAP decides who exists and who may sign in, which means someone leaving the organisation loses access to the audit trail by the same act that removes their other access, with nothing here to remember to update.
The cost is a hard dependency: with the directory down, nobody new can sign in. Existing sessions keep working, because verifying a session touches nothing but the cookie.
The double bind
Signing in takes three LDAP operations:
- Bind as the service account. This is what makes the search possible.
- Search under
NINHO_LDAP_BASE_DN with NINHO_LDAP_FILTER for the typed name, and take the one entry's DN.
- Bind as that DN with the typed password. This is the actual password check, done by the directory, which is the only thing entitled to do it.
The alternative, building the DN from a template like uid=%s,ou=people,dc=example,dc=com, needs no service account and is what several products offer. It is rejected here because a fixed template does not survive a real directory tree: people live under different OUs, get moved, and sign in with something that is not their RDN.
Why the service account is mandatory
The search could be anonymous, and many products allow it. ninho does not, and refuses to start without NINHO_LDAP_BIND_DN.
A directory that answers anonymous reads today can stop answering them after one hardening pass. When that happens, the search returns nothing, every sign-in fails as "invalid username or password", and nothing in the failure suggests the cause. Requiring the account converts a future silent outage into a startup error today. That is a good trade for one variable.
Active Directory has never served anonymous reads, and OpenLDAP 2.5 onwards discourages them, so the configuration this forbids is one that mostly does not work anyway.
Escaping
The typed username goes through ldap.EscapeFilter before it reaches the filter. Without that, a username containing ), (, or * rewrites the filter, which is LDAP injection with the same shape and the same consequences as SQL injection. A filter of (uid=*) matches everyone.
The empty password
An empty password is refused before any connection is opened. Most LDAP servers treat a bind with an empty password as an anonymous bind and report success, so without that guard, any known username would sign in with no password at all.
The group check
With NINHO_LDAP_GROUP_DN set, a fourth step asks the group object whether it lists the user, matching member, uniqueMember, or memberUid.
Two details are less obvious than they look.
It rebinds as the service account first. After step 3 the connection is bound as the person who just signed in, and nothing guarantees they can read the group object. Under a restrictive ACL the search would answer "no entries", which is indistinguishable from not being a member, and a correctly configured group would silently lock everyone out.
The uid comes from the directory entry, not from what was typed. RFC 2307 defines memberUid as holding the value of the user's uid attribute. When NINHO_LDAP_FILTER matches on mail or cn, the typed name is not that value, and comparing it would never match.
A posixGroup that is only someone's primary group, through gidNumber and with no memberUid entry, does not count as membership. That is deliberate. Membership lives in one explicit list, instead of two places that can disagree.
The session cookie
After a successful sign-in the browser gets ninho_session:
<username>|<expiry as a unix timestamp>|<HMAC-SHA256 of the first two, base64url>
HttpOnly, SameSite=Lax, Path=/, and Secure when ninho itself terminated the TLS. The signature is compared in constant time, and the expiry is checked after it.
It is stateless on purpose. Verifying a request touches no database, no cache, and no shared store, so the server scales horizontally with nothing to replicate and no sticky sessions.
The price is that revocation is not possible for an individual session. Signing out clears the browser's copy. The cookie itself stays valid until it expires. Changing NINHO_SESSION_KEY invalidates every session at once, which is the blunt instrument available. For a read-only audit UI with a 12 hour session, that trade is reasonable. It would not be for a system that can change anything.
The key
NINHO_SESSION_KEY signs it. When unset, each process generates a random key at startup, which has two consequences worth stating plainly:
- Every restart signs everyone out.
- Two instances never agree. The cookie one signed is meaningless to the other, so behind a load balancer the sign-in succeeds and every request after it answers 401.
Startup warns when the key is absent. It is a warning and not a hard error because running one instance with ephemeral sessions is a legitimate way to run in development.
What is not here
No rate limiting or account lockout on sign-in. Repeated failures are the directory's business, where the lockout policy already lives, and duplicating it here would mean two systems disagreeing about whether an account is locked.
No distinction in the failure message. An unknown user, a wrong password, and a non-member all produce the same 401 and the same text. Telling them apart hands an attacker a user enumeration oracle. The server log has the real reason, for the operator who is entitled to it.
No roles. Anyone who can sign in can read everything. The trail is not segmented, and there is nothing to write, so there is no privilege to escalate to.
Reading the trail
An audit trail is only worth what it can prove months after the fact, to someone who was not there. Several of ninho's choices only make sense from that vantage point, and look arbitrary from any other.
Silence is the failure that hides
An audit system fails in two ways, and only one of them is visible.
A wrong event is on screen, where somebody eventually notices it. An event that stopped arriving looks exactly like a machine where nothing happened. There is no error, no gap, no red mark. Just a host that is quiet, and a healthy idle host looks the same.
That asymmetry matters because taking the agent down is the cheapest way for an attacker to go unnoticed. Everything after that moment is invisible, and it is invisible in the same shape as normality.
So ninho keeps an agent table, updated once per batch in the same transaction as the events, and the Agents screen exists to show time since the last batch rather than events received. Nobody spends time on that screen until the day it is the only one that would have told them.
The known limitation, and it is in the contract: corvo only sends a POST when it has something to deliver, so on a genuinely idle machine, silence is legitimate, and the server cannot tell the two cases apart. Use a generous threshold, and read it against what you know about the host. A workstation quiet overnight is fine. An hour of silence from a busy database server is not.
process.args, never process.title
The kernel truncates process.title at exactly 128 bytes. Mid-word, with no ellipsis, no flag, and no way to tell a truncated command from a short one.
A long command is precisely what matters most in an audit. The interesting part of a rsync or a psql or a curl is at the end: the destination, the credential, the flag that made it destructive. Truncation cuts off the evidence and leaves something that reads like a complete record.
So the command is stored as process.args joined by a space, in args_text, and that is what the UI shows and what q searches. process.title is in the jsonb like everything else, and nothing reads it.
auid survives what uid does not
uid is who a process ran as. auid is who logged in, and the kernel keeps it across every su and sudo in the chain.
An entry showing uid=0 says root did it, which in an audit is close to saying nothing. The same entry with auid=1000 says which person became root, and that is the entire question. Both are stored, indexed, and shown, and the rankings prefer summary.actor.primary when the agent resolved a name, falling back to the raw auid.
Two clocks, and neither is authoritative
Every event has two instants:
occurred_at, the event's own @timestamp, from the audited machine.
received_at, when ninho stored it.
occurred_at is what actually happened and what you filter and order by. But it comes from a machine whose clock you do not control, which may drift, may be wrong by hours, and may have been set deliberately by whoever had root on it. Timestamps from different hosts are not comparable with each other.
received_at is ours, from one clock, and it orders arrival rather than occurrence.
The UI shows times in UTC and in the viewer's zone, both. The whole server process runs in UTC and every database connection is opened with timezone=UTC, because an audit trail with a server's local zone mixed into it is a trap for whoever reads it later, in another zone, after a daylight saving change.
The live tail orders by arrival, so a redelivered event with an old @timestamp still shows up there.
The jsonb is the record
The extracted columns are an index over the evidence. The jsonb is the evidence itself.
Nothing edits it, nothing normalises it, and nothing drops a field it does not recognise. When a question comes up in six months that nobody thought to extract a column for, the answer is already stored and reachable with a jsonb query. When the agent starts collecting a new record type, it lands without a migration.
This is also why the detail screen shows the raw JSON in full underneath the tidy fields. The tidy fields are a convenience. The JSON is what you would put in front of an auditor.
Duplicates are routine
corvo delivers at least once. A batch that was stored but whose response was lost gets sent again, and that is the correct behaviour: the alternative, at-most-once, drops events whenever a response goes missing.
So the insert is ON CONFLICT (id, occurred_at) DO NOTHING, and a redelivery is silently a no-op rather than an error. Deduplication is the receiver's job, because only the receiver can do it without risking loss.
Retention is a DROP TABLE
The trail is partitioned monthly. Dropping a month of data is dropping one table, which is instant and reclaims the space immediately. A DELETE over tens of millions of rows would take hours, bloat the table, and leave the indexes to be rebuilt.
Retention policy is written in months, so the storage is laid out in months. That is the whole reason for partitioning by time.