Skip to content

Model packs

A model pack is a versioned set of detection models — YAML files exactly like the shipped model file — published on the key server, signed with the release key and pulled by a Pro installation whose licence carries model_packs. The analyzer loads the packs over the built-in model set. Without a licence, or without the feature, the built-in set runs alone and nothing on this page applies; what a free installation gets is on Pro content.

Packs are additive by construction. A pack can add models, replace a built-in model of the same id and carry roles for its own models. It can never weaken a shipped policy, never set the aggregation section, and never carry code: every file in a pack goes through the analyzer’s own validator — the one that reads models/network-core.yaml — before anything is installed, and a detector or a parameter the running binary does not know refuses the whole pack.

Terminal window
ndr models pull [--channel stable|beta] [--id network-core] # as nyxtrace
ndr models status # the set the analyzer runs
ndr models validate DIR # the publisher's check; no configuration, no connection

ndr models pull reads the cached entitlement out of the state store, asks the key server for the newest manifest of the pack in the channel (NDR_CONTENT_CHANNEL, or the update channel when that is empty), verifies the manifest against /etc/ndr/release-pubkeys.json (NDR_UPDATE_KEYRING, the same file the updater reads — a pack is signed with the release key), refuses a pack whose min_appliance_version is newer than the running version, downloads the tar.gz through the key server’s redirect, checks the signed size and SHA-256, extracts plain *.yaml files only (bounded in count and size, no paths), runs every model through the validator, loads the whole staging directory once more to catch duplicate ids or role names across files, compares the model count with the signed entries, and only then renames the version into place and writes current.json — last, atomically. Its three outcomes:

installed: network-core 1.2.0 (9 models, signed by nyx-release-2026a)
the analyzer loads it at its next pass; ndr models status shows the set
unchanged: network-core 1.2.0 is installed
none: the channel holds no network-core pack yet

Exit code 3 is not entitled — the free core, licensing switched off, a licence without model_packs — and is printed with the reason (the licence does not include model_packs). No connection is opened in that case. Any other failure keeps the installed version; the analyzer keeps running it.

The daily unit does the same thing on a timer:

Terminal window
systemctl enable --now ndr-model-packs.timer
systemctl status ndr-model-packs.service ndr-model-packs.timer

ndr-model-packs.service runs as the service user — a pack is data the analyzer validates, so no root step is needed — at 03:41 with a random delay of up to four hours, persistent across a missed slot, and treats exit code 3 as success: on a free installation the timer runs and does nothing, which is correct. The installer ships the two units but enables neither.

NDR_MODEL_PACK_DIR (/var/lib/ndr/model-packs, owned by the service user) holds one directory per pack id and, under it, one per installed version:

/var/lib/ndr/model-packs/
└── network-core/
├── current.json # the version in use
├── 1.1.0/ # kept for a rollback by hand
│ └── *.yaml
└── 1.2.0/
└── *.yaml

current.json is the pointer: version is the directory the analyzer loads, and the rest is what the pull recorded — id, channel, key_id, sha256, models, notes, pulled_at and, after a pull that changed nothing for a reason, error with that reason. The previous version stays on disk for a rollback by hand; older ones are pruned. A pull that ends in unchanged — same version and checksum as the pointer — writes nothing.

ndr models status prints the set exactly as the analyzer composes it:

built-in: /opt/nyxtrace/models/network-core.yaml
pack directory: /var/lib/ndr/model-packs
pack: network-core 1.2.0 (9 models, signed by nyx-release-2026a, pulled 2026-09-19T03:52:10Z)
models in use: 9 (rare_destination_v1, periodic_destination_v1, …)

or packs: none (the built-in model runs alone). A notice: line names a pack, a model id and the reason whenever the composition skipped something — the same line the analyzer writes to its log as operation=models.load pack=… model=… note="…".

The rule lives in one place in the code and is short enough to state whole:

  1. The built-in model file is loaded first.
  2. A pack model with the same id as a built-in model replaces it in place; the position in the file, and therefore the order of evaluation, is the built-in one.
  3. Packs apply in id order. On a collision between two packs the first pack’s model stays and the later one is skipped with a notice.
  4. A pack whose files no longer load — the binary was downgraded, a file was edited by hand — contributes nothing and a notice. A pack never stops the built-in model.

Since roles and the daily cap exist, the composition has three more lines:

  1. A pack may carry a roles section for the models it defines; it is validated against the pack’s own models, and a role name may not repeat across the pack’s files. The built-in roles come first in file order and a pack’s roles are appended in pack order. Because the first matching role wins, a device that matches a shipped role (infrastructure) keeps the shipped policies — content can add roles for its own models but never weaken a shipped policy. A pack role that reuses a shipped name is skipped with a notice.
  2. aggregation is the built-in file’s alone. A pack that sets one gets a notice and no effect.
  3. After a pack is applied the merged file is validated again. A pack that would leave a shipped policy invalid — say it replaces periodic_destination_v1 with a model of another detector while the shipped infrastructure role says allow_known_destinations for it — contributes nothing, with the reason in the notice and on the health row. It stays listed as installed. A policy that should govern shipped models is a change to the shipped file, not to a pack.

When a pull lands matters too. A long-running catch-up loop (ndr analyze --loop, see analysis runs) compares every current.json before each pass and reloads the set — models, roles and the daily cap together — without a restart, keeping the previous set when a reload fails. The timer-driven analyzer reads the packs at its next run. ndr analysis replay evaluates “current” as the configured file with the installed packs applied — what actually runs — and a --model candidate alone; ndr analysis forecast --model FILE joins the candidate alone too.

GET /api/v1/health and the system view carry a models component (kind: analysis) since 0.39:

{ "id": "models", "kind": "analysis", "status": "ok",
"last_event_at": "2026-09-19T03:52:10.000Z", "lag_seconds": null, "message": null,
"built_in": 8,
"packs": [ { "id": "network-core", "version": "1.2.0", "channel": "stable",
"signing_key_id": "nyx-release-2026a", "models": 9,
"pulled_at": "2026-09-19T03:52:10.000Z", "loaded": true } ] }
Status Meaning
unavailable no pack is installed — the built-in model runs alone, the free core. Not a fault
ok every installed pack loads
stale the last pull failed while the installed version runs
degraded an installed pack does not load, or the built-in catalogue is broken; the admin-only message says which

built_in is the size of the built-in catalogue and packs is always a list. The row never changes the overall status. In /metrics it is one more health_component row with kind="analysis"; there is no separate family.

ndr models has no --rollback. The previous version is still on disk, so the rollback is a pointer edit as the service user:

Terminal window
systemctl stop ndr-model-packs.timer # or the next pull re-installs the newer one
cd /var/lib/ndr/model-packs/network-core
sudo -u nyxtrace sed -i 's/"version":"1.2.0"/"version":"1.1.0"/' current.json
ndr models status # pack: network-core 1.1.0 …

Only version matters for the loader; the other fields keep describing the pull that wrote them, and the next successful pull rewrites the whole file.

Removing the pack directory altogether — rm -r /var/lib/ndr/model-packs/network-core — makes the analyzer run the built-in model at its next pass; the models row goes back to unavailable. Neither step restarts anything: the loop picks the change up before its next pass, the timer at its next run. To stop pulling for good, disable the timer; to force the built-in model on a Pro installation, remove model_packs from the licence in the portal — the next pull ends not entitled and the installed packs stay until you remove them.

A pack manifest carries min_appliance_version: the oldest appliance that understands its parameters. A pull that meets a newer requirement than the running version refuses the pack and keeps the installed version; the models row turns stale with the reason for admins. Update the appliance, and the next pull takes the pack. The rule is the same as the one the 0.38 model file states for a downgrade: a model file must not be handed to a binary that does not know its parameters, and the validator is what makes sure it is not.

  • Two versions are kept per pack; a rollback further back needs the publisher.
  • One pack id is published today (network-core); the --id flag exists for the day there is a second.
  • The pull is not exercised against the live key server by the project’s own tests — they run against an in-test key server with real signatures, the wrong ring, the wrong artefact type, a truncated body, an expired manifest and the validator. The first live pull on the reference installation is part of the 0.39 rollout.