Architecture
Nyxtrace is one appliance made of a small number of processes, each with one job and one trust level. Everything runs natively under systemd: there are no containers in the appliance.
The pieces
Section titled “The pieces”| Component | Language / origin | Job |
|---|---|---|
Go core (ndr) |
Go, first-party | Normalisation, flow correlation, detection models, incident workflow, the internal REST API, and every write to the databases |
| ClickHouse | third-party | Telemetry and raw evidence |
| SQLite | third-party | Inventory, incident workflow, scheduler checkpoints, idempotency receipts, analyst budgets |
| Vector | third-party | Transport only: it collects source records and posts them to the Go core |
| Zeek | third-party | Packet-level metadata from the mirrored traffic |
| Suricata | third-party, on the firewall | Signature alerts and protocol events, passive IDS mode |
| Next.js UI | TypeScript, first-party | The browser interface and the backend-for-frontend that authenticates it |
| Grafana | third-party | Sensor health and capture dashboards, loopback-bound |
How a packet becomes an incident
Section titled “How a packet becomes an incident”Zeek · Suricata · NetFlow · resolver query log │ ▼ Vector ──────────── transport only, per-source bearer token │ (disk buffer, local quarantine on parse failure) ▼ Go core ─────────── derives tenant and sensor identity from the token, │ normalises, verifies Community ID, correlates flows ├──────────────► ClickHouse (telemetry, raw evidence, model results) └──────────────► SQLite (inventory, incidents, workflow, budgets) │ hourly `ndr analyze` ──────┘ models score devices, incidents appear │ Browser ──► Next.js BFF ──► Go core (signed, tenant-scoped requests)Four properties of that chain are worth stating plainly, because they are the reason the rest of the system can be trusted:
Vector never decides anything. It reads, buffers and forwards. It does not normalise fields, it does not assign a tenant, and it does not classify trust. Its whole configuration is transport.
Identity comes from the listener, never from the payload. Each source has its own bearer token, and the token maps to exactly one sensor, one tenant, one set of allowed record kinds and one trust class. A record that claims to be from somewhere else is still attributed to the token that delivered it.
Only the Go core writes. The UI cannot reach the databases. The analyst cannot write at all. Grafana has a read-only, restricted ClickHouse account that cannot write and cannot run foreign DDL.
The browser never talks to the Go core. The Go API binds to loopback. The Next.js layer authenticates the person, resolves their tenant and roles from the session, and then signs each request to the Go core with a short-lived HMAC-SHA256 signature over the method, path, canonical query, body digest, tenant, subject, roles and a nonce. Both layers enforce the role independently. The details are in the API overview.
Trust classes
Section titled “Trust classes”Every observation carries where it came from and how much that source can be trusted about the thing it claims:
| Trust | Source | Means |
|---|---|---|
packet |
Zeek, on mirrored traffic | The bytes were seen |
firewall_app |
Suricata on the firewall | The firewall’s application view of a session |
exporter |
NetFlow / IPFIX | A sampler’s counters, one direction at a time |
inventory |
Connectors, operator input | A fact asserted by a foreign system or a person |
derived |
Models | Computed, with its inputs recorded |
Trust describes provenance, not malice. A packet-trust record is not more
suspicious than an exporter one; it is simply better evidence about what
actually crossed the wire.
Correlation, and why counters are never added
Section titled “Correlation, and why counters are never added”The same session can be reported by three sources at once: Zeek saw the packets, Suricata classified the application, and the firewall exported a flow record. Nyxtrace joins them into one canonical flow when a verified Community ID plus time overlap agree, or a normalised tuple plus time overlap does.
It then takes its counters from one preferred observation — a packet source first, an exporter record otherwise — and never sums overlapping counters. Every other observation stays available as evidence. Adding them would silently multiply your traffic volumes by the number of sensors that happened to see it, and that error is invisible once it is in a chart.
Two consequences follow, and both are surfaced rather than hidden:
- A NetFlow-only record keeps its exporter direction and is marked
unidirectional_export. The API never invents the reverse counters. - NAT can prevent two sensors from recognising the same session. Those stay two flows unless stronger evidence links them.
Multi-tenancy
Section titled “Multi-tenancy”Every record belongs to exactly one tenant. A tenant is a separate operating context with its own devices, VLAN definitions, incidents, retention and budgets. Nothing crosses that line: not reads, not cursors, not search, not peer-group comparisons, not analyst context.
A single-site installation simply has one tenant, and the interface hides the tenant control entirely.