Skip to content

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.

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
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.

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.

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.