Watch
1
0
Fork
You've already forked ninho
0
One Go binary with the web UI embedded inside it, and a PostgreSQL behind it. Nothing else has to be installed on the server: a single static file to copy onto the machine. https://corvo.rda.run/ninho
  • Go 51%
  • TypeScript 37.2%
  • HTML 3.8%
  • CSS 3.3%
  • Makefile 2.9%
  • Other 1.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Rodrigo de Avila 16fac87cad
web: remove record_type column from events table
Give the freed space to the host column and bump .version to 0.6.2.
2026-10-16 13:56:10 -03:00
cmd/ninho store, ninho: log boot steps and stop silent waits on migration lock 2026-10-16 10:44:49 -03:00
design web: split the sign-in screen into form and brand art 2026-09-08 11:14:39 -03:00
docs store: add configurable automatic retention for old partitions 2026-09-21 09:17:47 -03:00
internal store, ninho: log boot steps and stop silent waits on migration lock 2026-10-16 10:44:49 -03:00
migrations store: index dashboard and principal filters on audit_event 2026-10-16 10:12:17 -03:00
testdata first commit 2026-09-06 11:31:03 -03:00
web web: remove record_type column from events table 2026-10-16 13:56:10 -03:00
.dockerignore first commit 2026-09-06 11:31:03 -03:00
.env.example store: add configurable automatic retention for old partitions 2026-09-21 09:17:47 -03:00
.gitignore first commit 2026-09-06 11:31:03 -03:00
.golangci.yml treewide: configure golangci-lint and handle unchecked errors 2026-10-16 10:21:01 -03:00
.version web: remove record_type column from events table 2026-10-16 13:56:10 -03:00
Dockerfile treewide: update Go to 1.27, Node to 24 and all dependencies 2026-10-15 20:17:02 -03:00
go.mod treewide: update Go to 1.27, Node to 24 and all dependencies 2026-10-15 20:17:02 -03:00
go.sum treewide: update Go to 1.27, Node to 24 and all dependencies 2026-10-15 20:17:02 -03:00
Makefile api: return the version from the sign-in response 2026-09-08 10:18:42 -03:00
README.md store: add configurable automatic retention for old partitions 2026-09-21 09:17:47 -03:00
sqlc.yaml first commit 2026-09-06 11:31:03 -03:00

ninho

The server that receives, stores, and displays corvo's audit events.

A single Go binary, with the web UI embedded inside it, and a PostgreSQL behind it. Nothing else needs to be installed on the server.

The full documentation is in docs/: a tutorial, how-to guides, reference, and explanation of why it is built this way. What follows here is the short version.

The contract for what arrives here is corvo's design/api-contract.md, and it drives this server's design. Status codes carry the weight. A 400 returned because the database is down pulls the batch out of the agent's queue and drops it into dead-letter, and that audit data doesn't come back. Infrastructure failure here is always 503.

Build

make build     # runs Vite and then go build, producing ./ninho

The result is a single static file to copy onto the server. Building needs Go 1.23 or newer and Node 20 or newer. Running needs none of that.

Run

export NINHO_DSN="postgres://ninho:password@localhost:5432/ninho?sslmode=disable"
export NINHO_INGEST_TOKEN="the same token as in corvo.yaml"
./ninho

Migrations are applied at startup, and the partitions for the current month and the following two are created and rechecked every hour.

On the agent side, corvo.yaml points here:

endpoint:
  url: https://ninho.example.com/api/ingest
  token_file: /etc/corvo/token
  gzip: true

Check it with corvo check, which sends a synthetic probe: it gets a 2xx and is not persisted.

Configuration

Everything through environment variables.

variable default what it's for
NINHO_DSN (required) The database's owner: applies migrations and creates partitions.
NINHO_INGEST_DSN NINHO_DSN Ingest connection. In production, a user with the ninho_ingest role.
NINHO_READ_DSN NINHO_DSN Read connection. In production, a user with the ninho_reader role.
NINHO_ADDR :8080 Listen address.
NINHO_INGEST_PATH /api/ingest Path corvo calls.
NINHO_INGEST_TOKEN empty The bearer token the agent sends. Empty accepts any agent, and the server warns at startup.
NINHO_PARTITION_MONTHS 3 How many months of partitions to keep created ahead of time.
NINHO_RETENTION_MONTHS 0 How many months of data to keep, current month included. Older monthly partitions are dropped. 0 disables retention.
NINHO_LDAP_URL empty ldaps://dir.example:636 or ldap://dir.example:389. Empty leaves the UI open.
NINHO_LDAP_BIND_DN (required with LDAP) Service account that looks up the user. The search is never anonymous.
NINHO_LDAP_BIND_PASSWORD (required with LDAP) Its password.
NINHO_LDAP_BASE_DN empty Where to search, e.g. ou=people,dc=example,dc=com.
NINHO_LDAP_FILTER (uid=%s) %s receives the typed username, escaped.
NINHO_LDAP_GROUP_DN empty If set, only members of this group can sign in. A posixGroup (memberUid) or a groupOfNames/groupOfUniqueNames (member, uniqueMember).
NINHO_LDAP_INSECURE false Accepts ldap:// without StartTLS. Development only: without this, the password travels in the clear.
NINHO_SESSION_KEY random Key that signs the session cookie. Without it, sessions drop on every restart, and two instances never agree on one. Required, and identical, on every instance behind a load balancer.
NINHO_SESSION_HOURS 12 Session lifetime.
NINHO_READ_TOKEN empty Optional bearer token to read the API from a script, bypassing the directory.

Authentication

There is no user registry in ninho: OpenLDAP decides who gets in. Sign-in does the double bind (the service account looks up the DN, and the following bind uses the typed password), and what stays in the browser is a signed HttpOnly cookie. The service account is mandatory, the lookup never falls back to an anonymous search, and the server refuses to start without it.

With NINHO_LDAP_GROUP_DN set, a third step asks the group object whether it lists the user, and only members sign in. That search runs as the service account again, because whoever just signed in is not guaranteed to be allowed to read the group. The uid compared against memberUid comes from the directory entry, not from what was typed, so the check keeps working with a NINHO_LDAP_FILTER that matches on mail or cn.

With no NINHO_LDAP_URL, the UI and the read API stay open. That's the development mode, and startup logs a warning. In production, configure the directory or put ninho behind something that authenticates.

Ingest is independent of this: it only checks NINHO_INGEST_TOKEN.

Separation of powers in the database

The migrations create the ninho_ingest role (only INSERT on the trail, plus the UPDATE the agent upsert needs) and ninho_reader (only SELECT). They're born NOLOGIN and passwordless, because a password in versioned SQL is a leaked password. Create the users and grant the roles:

CREATE USER ninho_app  LOGIN PASSWORD '...';   GRANT ninho_ingest TO ninho_app;
CREATE USER ninho_web  LOGIN PASSWORD '...';   GRANT ninho_reader TO ninho_web;

Then point NINHO_INGEST_DSN and NINHO_READ_DSN at them, leaving NINHO_DSN as the owner. The trail stays append-only even if the endpoint token leaks, which is exactly the scenario this separation exists for.

If the database owner can't create roles, the migration warns and moves on: create them by hand with a superuser.

The screens

  • Overview: events per hour over the last 24 h, the most frequent exe, auid, and hosts, and the agents gone silent.
  • Events: the table, with filters by host, auid, record_type, result, a time range, and command search. Optional live tail.
  • Detail: the resolved fields plus the full raw JSON.
  • Session: everything a login did, in order.
  • Agents: one per host, with the current silence.

Two of those behaviours come straight from the contract.

The command is displayed and searched through process.args, never through process.title. The kernel truncates title at exactly 128 bytes, mid-word and with no mark that a cut happened, and the end of a long command is where the destination and the dangerous flag sit.

Times appear in UTC and in the local zone. @timestamp from two different hosts is not comparable, because the clocks diverge. Order by the receive instant instead.

Agent gone silent

A wrong event is on screen, where somebody eventually notices it. An event that stopped arriving looks exactly like a machine where nothing happened, and taking the agent down is the cheapest way for an attacker to go unnoticed. Wire this threshold into monitoring before anything else.

The known limitation is in the contract: corvo only sends a POST when it has an event to deliver, so on an idle machine the silence is legitimate, and the server can't tell the two cases apart. Use a generous threshold and cross-check it with what you know about the machine.

Development

cp .env.example .env   # adjust NINHO_DSN and the token, just once
make dev       # loads the .env on its own, runs Go and Vite together
make test      # the tests. The integration ones spin up a Postgres 18 with podman
make generate  # regenerates the database access code with sqlc
make pg-up     # starts a disposable Postgres 18, for the development .env
make pg-down   # stops that Postgres, data included
make help      # the other targets

The SQL is hand-written in internal/store/query/, and sqlc generates the access code in internal/store/db/. That generated code is checked in, so whoever compiles the binary doesn't need sqlc installed.

The suite goes after the failures that actually lose data: corvo's golden batch persisted and read back, the corvo check probe answering 2xx without writing anything, the database being down returning 503 and not 400, and resending the same batch not duplicating a row. Plus the gzip bomb rejected at decompression, timestamps with variable fractional precision, and a thin event (a lone avc, with empty user and blank session) being accepted.

Limits

Accepts bodies of up to 8 MiB compressed and 32 MiB after decompression. The ceiling applies to the gzip output, not Content-Length, because the compressed body's size says nothing about the decompressed one.

No record_type is validated against a list, and no field inside event is required: the set of types is decided by each host's filter and changes with a SIGHUP.

Out of scope

No user registry, no multi-tenancy, no cache, no alerting system. TLS is terminated by the reverse proxy in front.