The plan to get from "strong production beta" to "the sensor Maltrail ships, enabled by default".
Structured as gates, not a task list. Each gate has an exit criterion that is verifiable by running something — not by judgement. Nothing advances to the next gate until its criterion is demonstrably met, because the whole value of this port is that its claims are checkable.
Where an item came from a third-party review it is marked [R1] / [R2].
Done and verified, so the plan does not re-litigate it:
| Evidence | |
|---|---|
Detection parity with sensor.py |
36/36 fixture corpus, 8/8 real-trail corpus, 0 event differences either direction |
| Trail loading parity | Every row of the real 1,505,265-row CSV, field by field, vs core.common.load_trails() |
| Never panics on hostile input | ~200k fuzzed inputs per run through every parser, in debug and release |
| Speed | 14-37x lower steady-state per-packet cost than sensor.py, depending on run and hardware (docs/REPORT.md §4a); scales across cores where the old sensor's single capture process caps at ~2.6x from 8 processes |
| Operational surface | -T config test, capability-based privileges (no root), hardened systemd unit, SIGHUP reload, Prometheus endpoint, 1 s trail-refresh pickup |
| Upstream bugs found and fixed | 9, including silently-stale trails and a self-stopping sensor |
Status: complete, and shipped. Every gate below is met, CI runs the whole gate on every push
and pull request, every item has a regression test in the suite, meta.sqlite — the last feature
gap — is written and differentially parity-tested against core/meta.py, and the default worker
count is settled at one. The sensor became Maltrail's default in 3.0 (2026-08-08); 3.0.1,
3.1 and 3.1.1 have shipped since, the last of them adding install.sh and prebuilt
x86_64/aarch64 binaries.
This document is kept as the record of how that was gated, and for the deferred items in
"Deferred deliberately" below, which are still open. It is not a current work queue — for what is
in flight, see HANDOVER.md.
A sensor may not fail silently. Everything here is a way it currently can.
Exit criterion: every item's regression test is in the suite and green in both profiles, and
tools/check.sh passes.
worker::run returns (); main discards the join results. A capture worker can die and the
process still exits 0, so Restart=on-failure never fires and the host silently stops being
monitored.
enum WorkerExit { Shutdown, OfflineEof }/enum WorkerError { Capture(CaptureError), Panic }.- Unexpected completion of a live worker → coordinated shutdown → non-zero exit.
maltrail_worker_alive{worker="N"}andmaltrail_worker_last_heartbeat_seconds.maltrail_upbecomes 0 when no capture worker is alive. Today it means "the HTTP thread is answering", which is close to meaningless as a health signal.- Test: kill a worker's capture handle mid-run; assert non-zero exit and
maltrail_up 0.
If the trail load or update fails, the sensor starts with zero trails, looks healthy, and detects
nothing. Fail startup unless ALLOW_EMPTY_TRAILS true. On reload, keep the last known-good store
and refuse an implausible drop (e.g. >50% fewer trails) — publish maltrail_trail_reload_rejected_total.
- Test: start with an empty CSV → non-zero exit; reload a truncated CSV → old store retained, counter incremented, detections continue.
The binary gives each -r file its own worker and its own state; tests/replay.rs asserts they
share state and only exercises the harness. One of the two is wrong.
Decision: offline replay processes files sequentially through one WorkerState. Replay must be
deterministic and evidence split across files must accumulate — that is what an analyst replaying a
capture set expects.
- Test: black-box, two pcaps, neither individually reaching a scan threshold, combined does.
User=maltrail (no home) + ProtectHome=yes + a $HOME-derived default TRAILS_FILE +
ExecStartPre=-T that rejects a missing trails file, and the updater that would create it runs
after the preflight. A fresh install cannot start.
StateDirectory=maltrail,LogsDirectory=maltrail, explicitTRAILS_FILE=/var/lib/maltrail/trails.csv.UMask=0027[R1] — event logs contain addresses, domains and URLs; world-readable is wrong for a hardened unit.- Install the binary to
/usr/local/bin, not Cargo's disposabletarget/. -Tmust accept a missing trails file when trail updating is enabled (bootstrap), and only fail when nothing will ever create it.- Test: container smoke test — install unit,
systemctl start, assert active and detecting.
"Bounded" is claimed but not universally true: DNS-exhaustion, NXDOMAIN and condensed-output maps can grow within their windows. Every network-influenced structure needs a hard cap, a documented eviction policy, a saturation metric, and a degraded mode that still performs exact trail matching.
- Test: flood each structure past its cap; assert memory plateaus and exact IOC detection is unaffected.
Zero snaplen, zero throttle window, zero caps, absurd worker counts, and integer narrowing on cast
all currently produce silent misbehaviour. -T should enforce typed bounds and print the
effective clamped configuration, including estimated capture-ring memory.
Single-worker parity does not prove multi-worker parity. That is the largest untested surface.
Exit criterion: multi-worker parity runs clean, and a shadow deployment shows no Python-only detections over a sustained period on real traffic.
PACKET_FANOUT_HASH splits by flow; the scan heuristics count per source, and a scan is many
flows. With N workers a threshold needs roughly N times more probes. The docs now say this honestly
(COMPATIBILITY.md §2, difference 3), but the runtime is unchanged.
Options, in preference order:
PACKET_FANOUT_EBPFwith a source-address hash. Attach a small eBPF program that hashes only the source address. This is the real fix and it keeps multi-core throughput.- Sharded heuristic aggregation — packet processing stays flow-affine, scan evidence is routed to a per-source shard. More invasive; also the right shape if TCP reassembly ever arrives.
- Default to
CAPTURE_WORKERS 1, with a startup warning when an operator opts into more and scan heuristics are enabled.
Done — (3), as the default rather than as a stopgap. The dilution stopped being a qualitative
claim once tests/multi_worker_parity.rs measured it on the corpus: of the heuristic alerts one
worker raises, 91% survive at 2 workers, 86% at 4, 65% at 8. The old default derived
CAPTURE_WORKERS from PROCESS_COUNT, so the shipped config ran 16 — past the end of that
curve — and every stock install paid for throughput it did not need. CAPTURE_WORKERS is now 1
unless CAPTURE_FANOUT/CAPTURE_WORKERS is set explicitly, and the sensor and -T still warn
when it is.
That makes option (1), PACKET_FANOUT_EBPF with a source-only hash, an optimisation for
operators who scale out rather than a prerequisite: it would let them keep undiluted heuristics
at N workers. Worth doing, no longer blocking.
The 36-case corpus is single-worker deterministic. Add a set-comparison mode: replay the corpus with N workers and compare event sets against the 1-worker run. This is the only thing that can catch dilution regressions.
Done in tests/multi_worker_parity.rs: the whole corpus is replayed with packets routed by
5-tuple flow hash across 1, 2, 4 and 8 workers, and event sets are compared.
- Exact trail detections are identical at every worker count, in both directions — fanout never loses an IOC detection and never invents one. This is the invariant that matters, and it holds because trail matching is a stateless per-packet decision.
- Heuristic alerts are measured rather than asserted equal (dilution is inherent), with one assertion that does hold: fanout may lose a heuristic alert, never invent one.
Still open: a privileged live test injecting the same scan through 1 and N real fanout sockets.
Both sensors, same traffic, same trails, ≥7 days on a real gateway. Compare nightly with
sensor/tools/shadow_diff.py, which answers the one question that decides the cutover — are
there detections the old sensor makes that the new one does not? — and exits non-zero when
there are, so it can run from cron:
# One command: capture live traffic while driving an adversarial workload, replay that ONE
# capture through BOTH sensors, and diff. Capture needs CAP_NET_RAW; everything else does not.
bash sensor/tools/shadow_run.sh --seconds 600
# Re-analyse a capture taken earlier — no privileges, no traffic, same comparison:
bash sensor/tools/shadow_run.sh --pcap /path/to/traffic.pcap
# A live pair of sensors instead, compared nightly from cron:
python3 sensor/tools/shadow_diff.py --new /var/log/maltrail --old /var/log/maltrail-old --days 7Replaying one capture through both beats running both live: two AF_PACKET sockets see slightly different packets and drop differently, so a difference could be the network rather than the sensor. One capture gives both byte-identical input, and the pcap is kept as evidence.
adversarial_traffic.py drives the workload from indicators sampled out of the operator's real
trails.csv. It never connects to malicious infrastructure: DNS trails are resolved (a lookup,
not contact), HTTP host/path/user-agent trails are exercised against a local listener, IP and
IP:port trails are deliberately not dialled, and the scan/DGA heuristics run against localhost
and .invalid.
RESULT — one hour of live traffic on a real interface, 2026-08-07/08:
capture: 1.2 GB, 1,690k packets, captured live while adversarial_traffic.py drove the load
trail set: 1,694,415 rows, one snapshot shared by both sensors
old sensor: 4,285 lines, 1,775 distinct detections
new sensor: 7,185 lines, 1,801 distinct detections
agreed on 1,775
[o] no detection was made only by the old sensor
[i] total: 0 missed by the new sensor, 26 extra (all DNS trail hits)
Zero old-sensor-only detections across 1,775 real detections. The workload included 2,120 DNS lookups of real malware domains, 1,643 HTTP requests carrying malicious host/path/user-agent indicators, 629 real SYNs to real trail addresses, 478 scan bursts and 607 DGA/NXDOMAIN bursts, against 623 benign background exchanges.
The run happened to cross midnight, which exercised the log-dating difference for free: the new
sensor wrote two daily files (it stamps events with the packet's time) where sensor.py wrote
one (wall clock at replay). --all compares by content, so this reported 1,775 agreements rather
than a total disagreement.
Also exercised beforehand: all 34 Ethernet corpus captures merged and replayed through both sensors against the same trail set — 16 detections, 16 agreed, zero old-sensor-only.
A long-lived deployment on a busy production gateway would still add value (different traffic mix, sustained memory behaviour, real drop rates), but the exit criterion as written — no Python-only detections on real traffic — is met.
It found a real bug on its first run — see tests/fail_closed.rs: a capture that opened but
could not be READ replayed to "success" with zero packets, so an unreadable file looked exactly
like a clean one.
Detections are compared as SETS of (src_ip, dst_ip, type, trail, info). Ports and timestamps
are excluded deliberately: the same beacon reappears on a new ephemeral port every time, and the
two throttles are different mechanisms, so line COUNTS legitimately differ. New-sensor-only
detections are reported but are not a failure — the Rust sensor genuinely detects more.
Compare nightly:
- normalized event sets (target: zero Python-only detections),
capture_drops/if_dropson both,- RSS and CPU,
- state-saturation counters.
This is the gate that actually earns "production ready". Nothing above substitutes for it.
At the time this was written none of the above could be protected, because the code was not in version control.
Exit criterion: the release gate is one command, CI runs it on every push, and it fails on any regression including fixture drift.
~15k lines were untracked; a git clean -fdx would have deleted the port and nothing was
bisectable. Landed as f1fa4dc3 ("Rust sensor becomes the sensor", 2026-08-07).
.github/workflows/ci.yml runs on every push and pull request:
- exact MSRV 1.74 (
msrvjob), - current stable:
fmt --check,clippy -D warnings, debug and release test runs, plus strict parity and loader parity — all of it throughsensor/tools/check.sh, - the generated-file freshness test (
tests/generated.rs) runs before regeneration, so drift is reported rather than silently repaired, - the Python server suite on the 3.6 floor and on current versions,
docker,installer(five distributions) andauditjobs.
Trail-data commits are excluded by path filter: they are the input, not the program, and a ten-minute gate on every indicator commit would make CI a tax on the project's most common contribution.
sensor/tools/check.sh is the gate, and .github/workflows/release.yml runs it first on a tag —
nothing is published unless it passes — followed by a version-matches-tag check, the binary builds
and the image push. There is no make release-check; the shell script and the release workflow
cover it, and a Makefile for two commands was not worth the third place to keep in step.
USE_CONDENSED_STORAGE defaults true, so a host that swapped sensors kept its config and
silently lost the server's condensed-observable and retro-hunt views. The sensor warned about it,
which was honest but still a feature regression for anyone on the default config — the deciding
item for whether cutover is "drop-in".
src/meta.rs now writes the store. The aggregate is per worker and drains on the housekeeping
tick the worker already runs, rather than on a thread of its own: the plan called for a separate
thread to keep SQLite off the capture path, but the tick is already off it (it runs between packet
batches, at most once a second) and a thread would have needed a lock on the very map the packet
path bumps. The packet path does one hash bump per endpoint, keyed by the native address, with no
text rendered until a key is first inserted.
Evidence. tools/parity.py replays all 36 corpus cases through both sensors with the store
enabled and diffs the resulting databases row for row: identical in every case — same
observables, same key encoding, same flags, same counts. tests/meta.rs covers the schema, the
BLOB/TEXT storage class (a mis-keyed store writes and reads back perfectly and matches nothing),
the out-of-order merge, the junk filter, prune() against the same 20-established-vs-200-DGA
fixture tests/test_meta.py uses, and a failing flush.
Bounds, unchanged from Python: CONDENSED_MAX_WINDOW_KEYS per window (refusals counted into
maltrail_state_saturations_total), META_MAX_ROWS on disk via score-based pruning on the
trail-update cycle. A flush that fails drops its window rather than growing a backlog, and is
reported through maltrail_meta_flush_errors_total.
-p took Python callables. Plugins were removed from Maltrail entirely in 3.0 — both sensors and
the plugins/ directory — so there is nothing to port. Consume events from
LOG_SERVER/LOGSTASH_SERVER instead.
- Default
maltrail-sensor.service→ this sensor (done). sensor.pystays inold/purely as the differential oracle and reference implementation:tools/parity.pyonly exists while it does, so it should never be deleted. It is not a supported fallback and carries no deprecation schedule — this is a rewrite, not a migration programme, and nobody is owed an overlap release.docs/INSTALL.md§13 covers verifying a deployment: replay a corpus, shadow live traffic, scale out only if the drop counter says to.
There is nothing further to schedule here.
Performance work is not on the critical path — the port is already 14-37x faster than the thing it replaces, and correctness gates outrank optimisation. These are queued behind Gate 3:
- Binary/mmap trail store — the only remaining place the old sensor wins (1.18 s startup, 88 MB
RSS vs a
mmap'd prebuilt store). Versioned, checksummed, atomically created, CSV fallback. - Batched capture (
pcap_dispatch) — libpcap 1.10.4 already uses TPACKET_V3, so live capture is zero-copy; what remains is per-packet FFI, which needs measuring on a real interface before building anything. - Record-boundary-preserving buffered writes — plain
BufWriteris wrong here (it splits records mid-line, which corrupts interleaving between workers); a buffer that flushes only on whole-record boundaries gets the syscall reduction safely. - O(1) throttle eviction — extend
src/lru.rsto return the evicted entry and refresh recency inget_mut, so index and recency live in one structure. A previous second-index attempt desynchronised and was reverted. - Throttle summary honesty — report the true suppressed count (
suppressed=N represented=M) rather than resetting it silently pastMAX_CONDENSED_EVENTS. - Aho-Corasick /
RegexSetfor the suspicious-request battery — measure first; theregexcrate's literal prefilter may already give most of it.
- Default worker count: ONE.
CAPTURE_WORKERSno longer derives fromPROCESS_COUNT, so the shippedmaltrail.conf(PROCESS_COUNT 16) now runs a single capture worker. The old default spent scan-heuristic sensitivity — of the alerts one worker raises, 91% survive at 2 sockets, 86% at 4, 65% at 8 — to buy throughput that a single worker already has: 550 ns/packet (1.8M packets/s) on the reference laptop CPU, 272 ns (3.7M) on a Ryzen 9 5900X. Exact trail detection is identical at every worker count, so nothing is traded away for IOC matching.CAPTURE_FANOUT/CAPTURE_WORKERSstill scale out for anyone who measures a real drop rate, which also demotes source-affine fanout (§2.1) from a fix to an optimisation. Locked byconfig::tests::worker_count_is_opt_in.
now 1.1 worker death 1.2 fail closed 1.3 multi-pcap 1.4 systemd [Gate 1]
3.1 commit -> 3.2 CI [unblocks everything]
next 1.5 bounded state 1.6 -T ranges 2.2 multi-worker parity
then 2.1 source affinity
finally 2.3 shadow deployment (>= 7 days) -> cutover
Gate 1 plus 3.1/3.2 is the honest minimum for "production ready" on a host you control. Gate 2.3 is the minimum for "Maltrail's default sensor".