Skip to content

YAML Reference

This page lists the configuration fields that matter for offline mode.

Required Sections

At minimum, define:

  • sniffer
  • capture
  • offline_output
  • logging

A configuration that leaves one of them out is refused when it is loaded, by name:

offline_output is a required section, but the configuration does not define it

A section written with nothing under it -- offline_output: on a line of its own -- counts as absent, because YAML reads it as unset. A section written as an empty block, offline_output: {}, is present: every setting in it takes its default. Every section not listed above is optional and may be left out.

Only the four sections above are required, and only at the top level. Every setting inside them, and every sub-block, may be left out and takes the default listed for it below.

Changed in 2.1.0

Before 2.1.0 none of these was checked as a section. Three were refused anyway, by accident, and named a key inside the missing block rather than the block itself. The fourth was not refused at all: a configuration with no offline_output section loaded and wrote its records to /var/log/pqc-sniffer/connections.ndjson, a path the file had never named. Such a file now fails to load rather than writing somewhere nobody asked for. Check an existing file with pqc-sniffer --config <file> --validate-config before upgrading.

sniffer

sniffer:
  customer_id: "customer-12345"
  sniffer_id: "sniffer-offline-01"
  mode: "offline"
  • customer_id: identifies the deployment and tags output records
  • sniffer_id: used in runtime metadata and observer output
  • mode: must be offline — the only supported mode in this release. It also defaults to offline when omitted; connected (PostgreSQL) mode is not available and is rejected at startup.

capture

capture:
  interface: "eth0"
  mode: "local"
  bpf_filter: "tcp or udp or proto 47 or ip6 proto 0 or ip6 proto 43 or ip6 proto 44 or ip6 proto 51 or ip6 proto 60 or ip6 proto 135"
  promiscuous: false
  queue_size: 10000
  timeout_ms: 100
  snaplen: 65535
  pcap_buffer_size_mb: 32
  max_offline_file_size_mb: 4096
  offline_replay_mode: fast
  duplicate_window_ms: 0
  duplicate_filter_entries: 8192
  fanout_threads: auto
  processing_threads: auto
  immediate_mode: false
  enable_raw_sockets: false
  raw_socket_filter: ""
  capture_incomplete_handshakes: false
  pre_capture:
    enabled: false
    tcpdump_binary: "tcpdump"
    initialization_delay_seconds: 5
    pcap_output_path: "/var/lib/pqc-sniffer/pcap/precap.pcap"
    buffer_size_mb: 16
    keep_pcap_file: false
    max_file_size_mb: 100
    max_files: 10
    min_free_disk_mb: 500
    restart_immediate_attempts: 3
    restart_interval_seconds: 60
  • interface: live interface name, or an absolute path to a .pcap/.pcapng savefile (optionally written file:///path/to/capture.pcap). Savefile inputs are validated before they are opened — see Offline input path security
  • mode: one of local, span, tap, aws_vpc_mirror, gcp_packet_mirror, azure_vtap
  • bpf_filter: libpcap filter expression (default: tcp or udp or proto 47 or ip6 proto 0 or ip6 proto 43 or ip6 proto 44 or ip6 proto 51 or ip6 proto 60 or ip6 proto 135). See Choosing a capture filter before narrowing it
  • promiscuous: enables or disables promiscuous capture
  • queue_size: internal packet queue size
  • timeout_ms: libpcap read timeout in milliseconds (default: 100, range 10–1000; values outside it are clamped with a warning, not rejected)
  • snaplen: packet snapshot length. Also sets the reassembler's maximum fragment size — see Fragment limits — and bounds how much of a large datagram is available to analyse at all. See Lowering snaplen before reducing it
  • pcap_buffer_size_mb: libpcap buffer size
  • duplicate_window_ms: suppress packets that arrive more than once within this many milliseconds (default: 0 = disabled, range 0–100). See Duplicated packet delivery
  • duplicate_filter_entries: recent-packet slots for the whole process, divided across the flow shards and rounded up to a power of two (default: 8192, range 64–1048576). Fixed memory per shard, not per connection. Each shard carries roughly a sixty-fourth of the traffic, so it needs a sixty-fourth of the slots to cover the same window; a shard's share is floored at 64 slots so a small configured value does not round down to a table that collides on every packet
  • max_offline_file_size_mb: size ceiling for a savefile input (default: 4096, range 0–1048576; 0 disables the cap). Offline work is proportional to the input, so this bounds the CPU, memory and output volume a single oversized capture can consume. Enforced on the opened file, not on the configured path
  • offline_replay_mode: how a savefile is fed to the pipeline (default: fast, or timestamp-paced). Ignored for live capture. See Replaying a capture file
  • fanout_threads: capture threads sharing a Linux PACKET_FANOUT group (default: auto, or 1–16; values outside the range are clamped with a warning). Linux live capture only
  • processing_threads: workers parsing and tracking packets (default: auto, or 0–16; values outside the range are clamped with a warning). 0 means one worker with sharding disabled, not automatic sizing — see processing_threads
  • immediate_mode: deliver packets individually rather than in kernel batches (default: false)
  • enable_raw_sockets: enables early AF_PACKET capture. Before 2.0.1 this captured nothing usable: the path hands the parser bare IP packets, which the parser could not decode, so every packet was discarded and the run reported no connections while logging that raw-socket capture had started successfully (TQR-1404)
  • raw_socket_filter: optional filter just for raw-socket capture
  • capture_incomplete_handshakes: emit TLS connections whose handshake was only partly captured, and non-TLS traffic that was never identified (default: false). It changes which connections are reported, not when: records are still written when the connection ends, as Emission Rules describes.

The sniffer decodes these link layers, live or from a savefile:

Link type Where it comes from
Ethernet (DLT_EN10MB) ordinary interfaces, and most .pcap files
Linux cooked v1 and v2 (DLT_LINUX_SLL, DLT_LINUX_SLL2) the any pseudo-interface
Raw IP (DLT_RAW) tun/utun interfaces — VPN and IPsec tunnels
Loopback (DLT_NULL, DLT_LOOP) loopback captures, including those taken on BSD and macOS

Raw IP and the two loopback types were added in 2.0.1 (TQR-1404). A tunnel interface is a natural capture point for this product, because it is where an IPsec or IKEv2 key exchange can be observed.

Any other link type is refused when the capture is opened, with the type named in the error:

Unsupported capture link type 105 (IEEE802_11: 802.11 wireless). This build decodes
Ethernet, Linux SLL and SLL2, raw IP (DLT_RAW) and the loopback types DLT_NULL and
DLT_LOOP.

That is deliberate. Such a capture would otherwise be processed into zero connections and a success exit code, which reads as "nothing of interest here" rather than "nothing was parsed". To analyse one, convert it first:

tshark -r wireless.pcap -w ethernet.pcap -F pcap

Choosing a capture filter

bpf_filter decides what reaches the sniffer at all. It is applied in the kernel, so anything it excludes is not analysed, not counted, and not reported — and there is no record that it existed. A narrow filter does not produce a smaller report; it produces a report that looks clean.

The default, tcp or udp or proto 47 or ip6 proto 0 or ip6 proto 43 or ip6 proto 44 or ip6 proto 51 or ip6 proto 60 or ip6 proto 135, is chosen to admit every protocol the sniffer ships a parser for:

Component Needs
TLS, SSH, SMTP, TACACS+, NTS-KE, plaintext MQTT TCP, on standard and alternate ports alike
QUIC / HTTP-3, DTLS, IKEv2, NTP / NTS, WireGuard, RADIUS UDP
VXLAN and GENEVE decapsulation UDP (4789, 6081, 8472)
GRE and ERSPAN decapsulation IP protocol 47
Any of the above over IPv6 with extension headers IPv6 Next Header 0, 43, 44, 51, 60, 135

Why the IPv6 clauses are needed

A BPF tcp or udp primitive tests the Next Header byte of the IPv6 fixed header and does not follow the extension-header chain. An IPv6 packet carrying Hop-by-Hop, Routing, Destination Options, Authentication or Mobility before its transport header therefore matches neither primitive. The ip6 proto clauses admit those packets; the sniffer then walks the chain itself.

Before version 2.1.0 the default did not include them, so IPv6 traffic using extension headers was dropped in the kernel. Upgrading widens what is captured on networks where that traffic exists, which may raise packet volume, CPU and connection-table size. Set capture.bpf_filter explicitly to keep the previous scope.

Narrow the filter deliberately, and know what it costs

Restricting the filter to a handful of ports — tcp port 443 or tcp port 22, for instance — silently removes every UDP protocol above, along with SMTPS, TACACS+, DNS-over-TLS and every alternate TLS/SSH port. The sniffer reports no findings for them, which is indistinguishable from a network that has none. If you narrow the filter, treat the excluded protocols as unassessed rather than as clean.

The filter in force is written to the log at capture start as Effective BPF filter: <expression>, so the scope of any given run can be confirmed from a support bundle.

proto 47, not ip proto 47

The two are not equivalent: ip proto 47 matches IPv4 only, so it silently drops GRE/ERSPAN carried over IPv6. Use the unqualified proto 47 — or proto gre — unless you specifically intend to exclude IPv6.

This affects which frames reach the sniffer. A tunnel the filter excludes is never decapsulated, so its inner flows produce no records at all.

Duplicated packet delivery

Some capture placements deliver every packet more than once — a mirrored SPAN port that also sees the original, or a sniffer VM running on the same hypervisor host as a system it monitors, where the vSwitch mirror duplicates the traffic.

Connection tracking is correct either way. The TCP state machine treats a repeated SYN, FIN or RST as idempotent (TQR-1392), so duplicates cannot corrupt connection state, advance a close early, or lose a session. That holds with this setting at its default of 0, and it is the fix that matters — retransmissions are normal on any network, not just duplicated ones.

What duplication still costs is work and accuracy of the counters: every duplicate is parsed, looked up, and counted, so packets_* and bytes_* report roughly twice the traffic that actually crossed the link.

Setting duplicate_window_ms to a small value (2–5ms is usually enough; mirrored copies arrive microseconds apart) drops the second copy before flow tracking sees it.

This is a trade-off, which is why it is off by default

A duplicated packet and a legitimately repeated one are not always distinguishable. The case that matters is the duplicate ACK: TCP fast retransmit works by the receiver sending three byte-identical ACKs, and on a fast link those can be spaced as closely as mirrored copies. With the filter on, they are counted once.

Enable it where duplication is known and systematic. Leave it off otherwise — you lose nothing but the deduplication, because correctness does not depend on it.

The window is capped at 100ms deliberately: it must stay well below the 200ms Linux minimum retransmission timeout, or genuine retransmissions would be suppressed and real packet loss would become invisible.

When the filter is suppressing anything, the sniffer logs it:

Duplicate packets suppressed: 148230 (49.7% of 298104 processed) - the capture is
delivering packets more than once

A figure near 50% means every packet is arriving twice, which is worth fixing at the capture placement rather than only filtering.

processing_threads

Packets are parsed and tracked by this many worker threads. Each packet is routed to a worker by an orientation-independent hash of its flow, so both directions of a connection always reach the same worker and each worker owns its connection state without locking against the others.

The default is auto, and an omitted key means auto too. See Automatic thread sizing for what it picks. Writing a number instead pins the worker count to exactly that, and also opts that deployment out of adaptive scaling — a number you wrote is honoured as written. Since 2.0.1 flows are always routed across a fixed set of flow shards, so 1 means one worker draining all of them rather than sharding being switched off.

0 used to mean "choose from the machine", and selected as many as four workers. That was removed because the value it chose could be the worst one available — see the measurements below. 0 in an existing config file is still accepted and reads as 1, with a warning saying so. It is not treated as a request for auto: a config that says 0 or 1 keeps the behaviour it has today, and only auto or an absent key opts in to sizing.

connection_tracker.max_connections is a whole-process ceiling. Since 2.0.1 it is enforced as a single shared budget that every flow shard consults, rather than divided into per-worker shares — so the number you configure is the number the process enforces, whatever the worker count is and however unevenly traffic is distributed. capture.queue_size is per shard (see Flow shards), not per worker, so total buffering no longer changes when the worker count does.

How many to use

Measured on a loopback capture offering about 1,000,000 packets/sec, varying only this setting:

processing_threads Captured Lost, and where
1 65% of offered 15,805,462 in the sniffer's own queue
2 100% of offered nothing
4 65% of offered 31,880,873 dropped by the kernel

Both failures lose traffic; they lose it in different places and call for different responses.

One worker could not keep up with what one capture thread delivered. The packets were read from the kernel and then discarded because no worker had room — visible as Queue drops on the heartbeat.

Four workers lost more, and further upstream. Workers waiting for packets compete for CPU with the single capture thread that feeds them; at four they starved it, and the kernel discarded packets before the sniffer saw them at all — visible as Dropped. This is why the old machine-selected default was removed: on a host with cores to spare it chose four.

Two workers was the best configuration at every rate tested here. Start at 1, and raise it only if Queue drops is growing. If raising it makes Dropped grow instead, the workers are now starving capture: either come back down, or add a capture thread with fanout_threads.

A single connection belongs to one worker, so a lone heavy flow is never spread by adding workers — that limit is inherent rather than tunable. Watch Deepest shard queue in the heartbeat against Queue drops to tell an unbalanced deployment from an overloaded one — see Reading the Capture-Quality Counters.

fanout_threads

Capture is otherwise a single thread: one AF_PACKET socket, one read loop, one core. Once that thread cannot keep up, the kernel discards packets before any worker sees them, and no value of processing_threads helps — the packets are already gone.

Setting this above 1 opens that many capture sockets and joins them into a Linux PACKET_FANOUT group. The kernel spreads arriving packets across the group by its own flow hash, so several threads read from the kernel in parallel.

The default is auto, and an omitted key means auto too — see Automatic thread sizing. A savefile input always resolves it to 1, because reading a savefile ignores PACKET_FANOUT completely. That is decided by capture.interface, not by sniffer.mode.

Since 2.0.1 a PACKET_FANOUT group is created even for a single capture thread, so fanout_threads: 1 and fanout_threads: 2 deliver the same packets — previously 1 saw raw IP fragments and 2 saw kernel-reassembled datagrams. See the note in Troubleshooting. The count is also the starting value: sustained kernel drops grow the group while the process runs, though it is never shrunk without a restart.

A savefile gets no such help, because reading one ignores PACKET_FANOUT entirely, so an offline replay sees whatever fragments the file holds. A fragment after the first carries no transport header and is refused rather than guessed at, counting under Malformed packets; a first fragment is analysed as usual. See Datagram lengths and fragments in Troubleshooting for what changed and why.

What it was measured to do

On a loopback capture with the traffic generator pinned to separate cores, offering roughly 1,000,000 packets/sec — the most the generator could sustain — with processing_threads: 2:

fanout_threads Captured Kernel drops CPU
1 100% of offered 0 124.2s
2 100% of offered 0 114.7s
4 100% of offered 0 109.6s

One capture thread was enough for everything that could be offered. The ceiling this setting exists to raise was never reached, so these runs do not show it raising throughput — they show it costing about 12% less CPU for the same complete capture.

Where it made a decisive difference was with more workers than the machine could feed:

Configuration Captured Kernel drops
fanout_threads: 1, processing_threads: 4 65% of offered 31,880,873
fanout_threads: 2, processing_threads: 4 100% of offered 0

Workers waiting for packets compete with the single capture thread that feeds them, and at four workers they starved it badly enough to lose a third of the traffic. A second capture thread removed the loss entirely. Prefer fixing the worker count — see processing_threads — but if a deployment needs many workers, it needs more than one capture thread to feed them.

Absolute numbers depend on the host; reproduce them with tests/docker/run-fanout-scaling-test.sh. Note that the generator, not the sniffer, was the limit in the first table: on hardware that can offer more than a capture thread can read, the picture may be different, and the honest way to find out is to run the sweep on that hardware.

Automatic thread sizing

fanout_threads and processing_threads both accept the word auto, and both mean auto when the key is absent. One capture thread and one worker is the right answer on a small appliance and plainly wrong on a sixteen-CPU host, and auto is how a deployment gets the second without anyone editing a file.

What counts as a CPU

Not the host's core count. The sniffer takes the smallest of:

  • std::thread::hardware_concurrency() — the machine
  • the process CPU affinity mask, so a service pinned with CPUAffinity= in its systemd unit is sized for the CPUs it was given
  • the cgroup CPU quota, v2 (cpu.max) or v1 (cpu.cfs_quota_us / cpu.cfs_period_us), read up the whole cgroup path because a parent slice's quota binds a child regardless of what the child asks for

A container limited to four CPUs on a 64-CPU host therefore behaves as a four-CPU process. A quota below one whole CPU still yields one.

The same quota is what the heartbeat's CPU figure reports a share of — see Reading the Heartbeat CPU Figure. Thread sizing rounds the quota down to whole CPUs, because a thread is a whole thing; the heartbeat share does not, so a process allowed 1.5 CPUs and using all of them reads 100% of its quota rather than 150%.

What it picks

Effective CPUs fanout_threads processing_threads
1–3 1 1
4–7 1 2
8 or more 2 4

A savefile input always gets fanout_threads: 1, whatever the machine has — decided by capture.interface naming a .pcap/.pcapng file, not by sniffer.mode.

Why it stops at 2 and 4

Because every pairing auto can choose was measured, and 2/4 is the largest one that loses nothing. It is deliberately not "half the CPUs": more workers can lose more traffic, not less, because workers waiting for packets take CPU from the capture thread that feeds them.

The sweep below was run for TQR-1491 with the sniffer pinned to 8 CPUs — the same budget auto sizes 2/4 from — and the senders on separate cores, offering 2,000,000 packets/sec (about 925,000 achieved, the generator's own ceiling). Reproduce it with FANOUT_LIST="1 2" WORKER_LIST="1 2 3 4" ./tests/docker/run-fanout-scaling-test.sh.

fanout_threads processing_threads Captured Kernel drops Queue drops CPU
1 1 63.4% 0 10,966,988 42.2s
1 2 100% 0 0 77.0s
1 3 100% 0 0 95.2s
1 4 92.1% 4,724,959 0 112.4s
2 1 61.5% 0 11,557,685 40.4s
2 2 100% 0 0 74.6s
2 3 100% 0 0 85.1s
2 4 100% 0 0 103.3s

Three things follow, and each one shapes a band of the table above.

One worker is the bottleneck, and a second capture thread cannot rescue it. At 1 worker the loss is in the sniffer's own queue, not the kernel; going from 1 to 2 capture threads moved nothing (63.4% → 61.5%). This is why the 1–3 CPU band is the least-bad option rather than a safe one: on a machine that small there is nothing else to give.

More workers than two per capture thread is the failure to avoid. In the sweep above, 4 workers behind one capture thread was the only configuration to lose packets in the kernel — 4,724,959 of them, before the sniffer ever saw them — while 3 workers behind one capture thread came back clean.

That last point did not survive being re-measured. TQR-1503 ran the same pairing on a generator able to push it harder, and 1 capture / 3 workers is not safe:

fanout_threads processing_threads Captured Kernel drops Queue drops User CPU System CPU
1 1 54.0% 0 13,805,109 54.2s 1.7s
1 2 99.6% 0 112,119 88.2s 1.9s
1 3 55.4% 26,781,341 0 59.9s 12.2s
1 4 5.7% 56,587,150 0 13.7s 16.7s

Read the CPU columns together with the loss: from 2 workers to 3, user CPU falls while system CPU rises six-fold and the loss moves out of the sniffer's queue and into the kernel. The process is not doing more work and failing to keep up; it has stopped doing work and started contending. Adding the third worker made a capture that was at 99.6% into one at 55.4%.

So the pairing rule is two workers per capture thread, which is what auto and the adaptive controller both enforce. The 4–7 CPU band stops at 2 workers rather than 3 for this reason — those are exactly the machine sizes where half the CPUs is 3 but the second capture thread has not arrived yet.

Past 2 workers, more workers buy nothing at these rates and cost CPU. With 2 capture threads, 2, 3 and 4 workers all captured 100%; the CPU went 74.6s → 85.1s → 103.3s for the identical result. That is not an argument for capping at 2, because the generator was the limit here — 2 workers were never pushed to their ceiling, so the sweep shows them sufficient up to about 940,000 packets/sec rather than preferable.

Where that ceiling actually is can be read off the two rows where a single worker was saturated and dropping: it processed 578,335 pps on one capture thread and 582,696 pps on two — two independent measurements agreeing to within 0.7% on what one worker can parse and track. At roughly 580,000 pps per worker:

Workers Approximate ceiling Reachable behind
1 0.58M pps 1 capture thread
2 1.16M pps 1 capture thread
3 1.74M pps 2 capture threads
4 2.32M pps 2 capture threads

The right-hand column is the pairing rule, not a separate limit: three or four workers are only ever reached with two capture threads feeding them.

1 Gbps of minimum-size frames is about 1.49M pps, so a 2-worker deployment on a busy link is well inside the range where it would start losing packets to its own queue. That is why the cap is 4: the extra workers are headroom for rates this harness cannot generate, paid for out of capacity a 16-CPU host has spare anyway (2 + 4 leaves 10 reserved).

Why the cap is not raised

TQR-1503 set out to raise this cap and ended up confirming it, after finding that the loopback harness the earlier figures came from cannot measure the sniffer at all above about 1.2M packets/sec:

achieved
Traffic generator alone, no sniffer attached 3,059,886 pps
Sniffer attached, BPF filter matching nothing 1,204,837 pps
Sniffer attached, BPF filter matching everything 1,213,027 pps

The last two are the same number. Whether the sniffer parses none of the traffic or all of it makes no difference, so that ceiling is the AF_PACKET tap on loopback — the kernel cloning and filtering every packet in the sender's own context — and not anything the sniffer does afterwards. Two workers are worth about 1.16M pps, just under that ceiling, so on a loopback harness two workers always look sufficient and four are never exercised, however fast the generator is. The earlier conclusion that "the generator topped out around 940,000 pps" was measuring the tap.

The parse-and-track side is therefore measured with no socket in the path at all, by benchmarks/bench_flow_shards.cpp, which pushes in-memory frames through the dispatcher, the shard scheduler, the worker pool and the connection trackers:

Producers Workers Aggregate
1 1 626,740 pps
1 2 1,082,940 pps
1 4 1,176,830 pps
1 8 852,001 pps
2 2 1,123,090 pps
2 4 1,287,480 pps
2 6 1,196,660 pps
2 8 1,107,850 pps

One worker measures at about 627,000 pps here — close to the 580,000 the live sweep derived by an entirely different route, which is the strongest evidence either figure has. Six and eight workers are worse than four at every producer count, so 4 is where this pipeline stops gaining, not a placeholder awaiting better hardware. The best row at one producer is two workers and at two producers is four: the same two-per-capture-thread pairing the live measurements arrived at from kernel drops.

Raising the cap is still possible in principle, but it needs evidence the parse side now contradicts, and a capture-side harness that does not exist here — traffic from a separate host over a real NIC, so the generator's cost is not on the machine under test.

"Separate host" means separate hardware. A virtual machine on the same desktop looks like a cheap way to satisfy that, and does not work. Measured on a gigabit link: a Linux VM bridged to a physical port managed 20,494 pps with an emulated e1000 adapter and 18,994 pps with a paravirtualised vmxnet3 one, against the 1,488,095 pps that link carries at minimum frame size. Adding a third generator thread split the same total three ways instead of raising it — the signature of a serialised path on the host side, in the hypervisor's bridge rather than the guest's adapter or the NIC. The receiving machine recorded no driver errors, no drops and no FIFO overruns throughout; it was never troubled.

The link speed is not what blocks the first question, though. A gigabit port carries 1,488,095 pps and one capture thread measured at roughly 1.2M, so an ordinary second Linux machine with an ordinary gigabit port is enough to find where one capture thread breaks on a real NIC — a figure no harness here has yet produced. A faster link is only needed for the separate question of saturating two.

Treat that per-worker figure as an upper bound. The harness offers synthetic UDP that falls through to minimal parsing; real TLS ClientHellos, certificate chains and SSH KEXINIT cost considerably more per packet, so a worker saturates sooner in the field than the table says.

If CPU rather than capture completeness is the binding constraint on a particular deployment, processing_threads: 2 is measured to be just as complete below ~1.1M pps and noticeably cheaper — but that is a deliberate trade against a known rate, which is why it is not the default.

A deployment that genuinely needs more can write a number — the explicit range is still 1–16 for both — but that is a decision made with the heartbeat counters in front of you, not a default. Raising the automatic cap is a code change, and one that should come with a tests/docker/run-fanout-scaling-test.sh run at the new pairing on hardware whose generator can outrun the capture path.

Seeing what it chose

Startup logs a line before anything else uses the counts:

Auto threading: 16 effective CPUs (host 16, affinity 16, no cgroup quota, limited by host CPU count) -> 2 capture thread(s), 4 processing worker(s)

and the worker line that follows says where its number came from:

Packet processing: 4 worker thread(s) (capture.processing_threads=auto -> 4)

If neither key is automatic, neither line mentions auto and no CPU detection is done at all.

auto also survives a round trip: saveToFile() writes auto back out rather than the number this host resolved, so a config copied to a larger machine sizes itself again there.

Linux live capture only. pcap_open_offline never touches AF_PACKET, so this setting is ignored entirely when interface names a pcap file. If the kernel refuses to create the group — an older kernel, or a container without the capability — the sniffer logs a warning and carries on with one capture thread rather than failing to start. The startup log reports what was actually opened:

Capture fanout: 4 thread(s) sharing group 27183

Check that line rather than the configured value; they differ when the kernel refuses.

This does not change which worker owns a connection. The kernel's PACKET_FANOUT_HASH is not direction-symmetric, so the two directions of one connection can arrive on different capture threads. Every capture thread feeds the same dispatcher, which routes by an orientation-independent hash, so a connection still reaches exactly one worker and connection state is unaffected by this setting.

Raise it only when the kernel is actually dropping packets. Dropped on the heartbeat line is the counter to watch; if it is not growing, capture is keeping up and more capture threads buy nothing.

More members distribute less evenly. The kernel assigns each flow to a member by hashing it, and the split gets coarser as the group grows: four members were measured splitting the same traffic with a 36% gap between the busiest and quietest, against 11% for two. That is harmless while every member keeps up — a member only drops when its own ring fills, and without PACKET_FANOUT_FLAG_ROLLOVER it drops rather than handing the packet to an idle sibling. If the shutdown log reports an uneven split and drops, prefer fewer, busier members to more idle ones.

timeout_ms

How long the kernel may hold a partly-filled capture buffer before handing it to the sniffer. It bounds delivery latency when traffic is too sparse to fill the buffer — with steady traffic the buffer fills first and the timeout rarely applies.

It is also the worst-case shutdown delay. Stopping the capture asks libpcap to break out of its read loop, but a read that is already blocked is not interrupted, so the shutdown waits for the current read to return. That is why the accepted range tops out at one second rather than the minute the setting previously advertised.

Values below the range cost wake-ups for no useful latency gain; see immediate_mode below for what happens at the extreme.

A value outside 10–1000 is clamped to the nearest bound and logged, not rejected — the sniffer starts. This setting had no effect at all before, so an existing out-of-range value reflects no deliberate choice, and refusing to start over one would turn a dormant configuration defect into a capture outage. The warning names the configured value, the supported range, and what is being used instead:

capture.timeout_ms is 5000 ms, above the supported range of 10-1000 ms; using 1000 ms.

The clamped value is what gets written back on the next save, so the file ends up agreeing with what the sniffer is doing.

Lowering snaplen

snaplen is the number of bytes libpcap hands over per packet. The default, 65535, is above any normal MTU, so nothing is truncated at that setting. The minimum accepted is 128, and lowering it trades analysis for CPU and buffer space.

What a lowered value costs is not spread evenly across protocols, because a TCP segment carries no length of its own while a UDP datagram declares one:

At a snaplen that truncates Before 2.0.1 Now
TLS over TCP record written, payload truncated unchanged
QUIC, DTLS, WireGuard over UDP no record at all record written, payload truncated

A UDP datagram declaring more than was captured used to be refused outright and counted under Malformed packets, so reducing snaplen silently removed whole protocol families — the UDP-based ones, which are where post-quantum key exchange is most widely deployed — while TCP flows carried on producing records. A truncated datagram is now kept, with its payload bounded by what was captured.

That is recovery, not a reason to lower the value. A truncated handshake is still a handshake the sniffer cannot fully assess: a ClientHello carrying a post-quantum key share does not fit in a small snapshot, and what is cut off cannot be analysed. Leave snaplen at its default unless a measurement says otherwise, and raise it toward 262144 where large ML-KEM ServerHellos matter.

A UDP length that does not fit a capture which was not truncated is a different matter: the header does not describe the packet, and such a datagram is still refused and still counted under Malformed packets.

immediate_mode

With immediate mode off, libpcap hands over a buffer of packets at a time, and the capture read timeout bounds how long a partly-filled buffer is held before delivery. With it on, the kernel wakes the capture thread for every single packet.

That wake-up is expensive enough to lose packets. Offering 100,000 packets/sec to a loopback capture, measured twice in each configuration:

packets processed dropped by the kernel
immediate_mode: true 30% of those offered ~1,470,000
immediate_mode: false everything offered while the capture was running 0

The losses are in the kernel ring, before the sniffer sees them: per-packet delivery cannot drain the ring fast enough, so it overflows. They appear as Interface drops in the heartbeat, not as Queue drops.

Turn it on only when per-packet delivery latency genuinely matters — interactive debugging of a single handshake, for instance — and only at low packet rates. A line is written to the log at startup whenever it is enabled, so a host quietly running in this mode is visible without reading its configuration.

By default a TLS connection is written only when both the ClientHello and the ServerHello were parsed, and then when the connection ends, so a flow whose handshake was partly missed is discarded rather than recorded with unknown crypto. That keeps the output trustworthy, but it means packet loss during a handshake removes the entire connection from the output. The count and reason are reported in the shutdown statistics and in each heartbeat — see Fewer records than connections.

Enable this while diagnosing missing records, or on captures that routinely start mid-flow.

pre_capture

Pre-capture runs a background tcpdump that writes a raw PCAP alongside live capture. A PCAP contains full packet payloads (credentials, tokens, application data), so its handling is hardened:

  • enabled: turn pre-capture on
  • tcpdump_binary: only an approved absolute path is executed (see Output path security)
  • initialization_delay_seconds: wait for tcpdump to stabilize before continuing
  • pcap_output_path: PCAP location. Must resolve within an allowed service directory (e.g. /var/lib/pqc-sniffer/…) — a shared/predictable path such as /tmp/… is refused at launch. The parent directory is created service-owned and private (0700) and each PCAP file is set to 0600; a symlinked target or world-writable parent is rejected. Default: /var/lib/pqc-sniffer/pcap/precap.pcap.
  • buffer_size_mb: tcpdump kernel buffer size
  • keep_pcap_file: when false (default) the PCAP file(s) are deleted on shutdown, on every startup/error failure path, and at the next startup — the last of these reclaims captures left behind when the sniffer was killed rather than stopped, since no cleanup code runs in that case. Deletion is an unlink, not an overwrite-based erase; confidentiality rests on the 0700 directory and 0600 file mode. Files under a previous pcap_output_path, and files not owned by the service account, are never removed. Failures are logged at error severity to the application and database logs. When true, nothing is deleted at any point and files are retained with restrictive permissions.
  • max_file_size_mb: rotate the PCAP after this many MB (tcpdump -C); 0 disables size rotation. Default 100.
  • max_files: keep at most this many rotated files (tcpdump -W); 0 is unlimited. Default 10.
  • restart_immediate_attempts: after an unexpected tcpdump exit, this many relaunch attempts on consecutive monitoring ticks (default 3) before the slow timer takes over. 0 goes straight to the timer.
  • restart_interval_seconds: the slow relaunch interval once the fast attempts are spent (default 60). Retrying continues indefinitely, so a fault that clears hours later still recovers. 0 disables relaunch entirely — the unexpected-exit error is still logged. A stop the sniffer made on purpose (shutdown, the free-disk guard) is never relaunched.
  • min_free_disk_mb: an application-level free-disk guard checked periodically. If free space on the PCAP filesystem drops below this, pre-capture is stopped before the disk fills — independent of tcpdump's own rotation. 0 disables the guard. Default 500.

Together max_file_size_mb × max_files caps total PCAP disk usage (default ~1 GB). With rotation enabled, tcpdump writes numbered files (precap.pcap00, precap.pcap01, …) rather than a single file. Setting both to 0 makes the capture unbounded and is warned against. As a backstop, the sniffer also stops pre-capture if the total PCAP size runs past that budget or if min_free_disk_mb is breached.

The background tcpdump is always stopped and reaped when the sniffer shuts down, and it is tied to the sniffer's lifetime at the kernel level (PR_SET_PDEATHSIG), so it can never be orphaned — even if the sniffer is force-killed.

offline_output

offline_output:
  output_file: "/var/log/pqc-sniffer/"
  append_mode: true
  buffer_size_kb: 512
  flush_per_connection: false
  rotation_enabled: true
  max_size_mb: 100
  max_files: 10
  rotation_mode: "circular"
  seal_interval_seconds: 0
  seal_gzip: false
  max_queue_size: 50000
  batch_size: 512
  batch_timeout_ms: 1000
  slow_write_warning_ms: 100
  • output_file: absolute path only; directory paths ending in / receive connections.ndjson. Must resolve within an allowed service directory (see Output path security).
  • append_mode: append on startup instead of truncating the current file
  • buffer_size_kb: write buffer size (default: 512). See Write buffer and batching — this key did not take effect in earlier releases and its default changed when it started working
  • flush_per_connection: flush after each event write
  • rotation_enabled: enable rotation (required when rotation_mode is sealed)
  • max_size_mb: per-file size threshold before rollover
  • max_files: number of files retained (indexed files in circular mode; sealed files retained in sealed mode — see below)
  • rotation_mode: circular (default) or sealed
  • seal_interval_seconds: sealed mode only; 0 (default) means size-triggered only
  • seal_gzip: sealed mode only; true gzip-compresses each sealed file to connections-*.ndjson.gz (~10:1 for NDJSON). Default false.
  • max_queue_size: records held between the sniffer and the writer thread (default: 50000). Records are discarded once it is full — see below
  • batch_size: records the writer thread waits for before writing, and therefore writes between flushes (default: 512, range 1–100000). See Write buffer and batching
  • batch_timeout_ms: write a partial batch after this long so thin traffic still reaches disk promptly (default: 1000, range 1–60000)
  • slow_write_warning_ms: warn when a single batch write to the NDJSON file takes longer than this many milliseconds (default: 100). 0 disables the warning. See Slow-write warnings below

Rotation modes

  • circular (default): rotates through a fixed set of indexed files (connections.0.ndjson … connections.<max_files-1>.ndjson), truncating and reusing the oldest when the index wraps. Total on-disk usage is bounded by max_size_mb × max_files. Best when a co-located agent (Elastic Agent, Splunk Universal Forwarder) streams the files continuously.

  • sealed: writes to a single stable active file (connections.ndjson.active) and, on rollover, atomically renames it to a unique, immutable connections-YYYYMMDD-HHMMSS-<seq>.ndjson, then opens a fresh active file. Sealed files are never reopened, truncated, or reused. This lets an agentless collector (for example BigFix) grab completed files and delete them without racing a truncation, and removes the duplicate re-ingestion that reused filenames cause. The collector should match the sealed pattern connections-*.ndjson* (which covers both .ndjson and, with seal_gzip, .ndjson.gz — and never matches the .active file or the dot-prefixed compression intermediates) and delete each file after it ships it. Footprint stays bounded: if the collector stalls, the oldest sealed files are pruned once more than max_files accumulate (a warning is logged). Set seal_interval_seconds (for example 300) so low-volume or lab segments hand off promptly instead of waiting to fill max_size_mb. Set seal_gzip: true to gzip each sealed file (~10:1) and cut transfer/relay load on busy segments. Requires rotation_enabled: true. See the BigFix collection recipe in bigfix-collection.md.

Migrating an existing host from circular to sealed: no manual step is needed. On the first sealed-mode start, files the previous configuration left behind — connections.0.ndjson … connections.N.ndjson, or connections.ndjson if rotation was off — are sealed into the collectable naming (and gzipped when seal_gzip is set), oldest first. Each sealed name carries the time the file was last written, not the time of the restart, so the collector still receives them in the order they were written. Empty files are removed. Anything that cannot be sealed stays under its old name, is logged, and is retried on the next start. The leftovers then count towards max_files like any other sealed file, so lowering max_files at the same time can prune the oldest of them at once.

max_queue_size

Connection records are handed to a writer thread through a bounded queue so that a slow disk cannot stall packet processing. When the queue is full and stays full, records are dropped: the sniffer keeps capturing and those connections never reach the output file.

That loss is reported, and it is the number to watch:

Records written to output: 100000
Records discarded by output queue: 0

Any non-zero discard count is missing evidence — connections that were captured and analysed but are absent from the inventory. Unlike a dropped packet, nothing downstream can infer they existed.

The default of 50000 is deliberately generous. It was 1000, which combined with a small write batch to drain at roughly 4,000 records/sec; a replay offering 10,000 connections/sec discarded 341,981 records without any counter surfacing it. Raising the queue is not what fixed that — batching the writes was — but a shallow queue leaves no headroom for a burst or a momentarily slow disk.

Raise it if the discard count is non-zero and the writes are bursty. If the count grows steadily instead, the disk cannot keep up with the connection rate and a larger queue only delays the loss: reduce what is written by narrowing capture.bpf_filter, or move the output to faster storage.

Write buffer and batching

Connection records do not go to disk one at a time. The writer thread gathers records until it holds batch_size of them, writes the batch through a buffer_size_kb buffer, and flushes the stream once at the end of it; batch_timeout_ms is the deadline for a batch that never fills, so thin traffic still reaches disk promptly. Together these set the rate at which the writer drains the queue described under max_queue_size — and when that drain rate falls below the connection rate, the queue fills and records are lost, not delayed.

That is not hypothetical. TQR-1379 found the writer taking 10 records per batch, which meant a flush every 10 records: the drain settled at roughly 4,000 records/sec, and a replay offering 10,000 connections/sec discarded 341,981 records. Raising the batch to 512 is what fixed it. Lower these values only with the discard count in view.

batch_size was a ceiling, not a target, before this release

Until TQR-1582 the writer wrote whenever a record was queued rather than waiting for a batch to gather. Under steady traffic it therefore held one record, wrote it, and flushed — roughly one flush per connection, with batch_size capping a batch that was never reached and buffer_size_kb flushed straight through. batch_timeout_ms only limited how long the writer slept while the queue was empty; it was not an accumulation deadline.

Both keys now mean what they say, and the same configuration will do materially less I/O. Two consequences are worth expecting. A record can now wait up to batch_timeout_ms before it is on disk, where before it was written almost at once — that latency is the cost of the batching, and batch_timeout_ms is the knob that bounds it. And slow_write_warning_ms may start firing on a deployment that was quiet, because a 512-record write takes materially longer than a one-record write; compare it against Slow-write warnings before treating it as a fault.

buffer_size_kb did not take effect in earlier releases

Before TQR-1429 this key was parsed, range-checked, documented, and offered by the setup wizard — and then overwritten with a literal 512 KB on every offline start, so setting it changed nothing. It is honoured now, and its default moved from 64 to 512 so that a config which omits the key keeps the buffer every offline deployment has actually been running with.

A config that names buffer_size_kb: 64 explicitly — which is what the shipped example used to say — now gets 64 KB where it previously got 512 KB. If you did not choose that 64 deliberately, change it to 512 or delete the line. The effective value is printed at startup:

Buffer size:      512 KB

Slow-write warnings

The writer thread times each batch write to the NDJSON file and logs a warning when one takes longer than slow_write_warning_ms:

Async NDJSON batch write took 143.20ms for 512 records (file size: 18MB, warning threshold 100ms)

The threshold is a property of the volume the file lands on, not of the sniffer. On an NVMe volume a 100 ms batch write is a real anomaly. On a spinning disk, a contended SAN LUN, or an NFS mount it can be ordinary, and during load or stress testing the warning repeats often enough to bury the log lines you were reading. Raise the threshold to match what the storage actually does, or set slow_write_warning_ms: 0 to turn the warning off.

Two things this setting does not do:

  • It does not make writes faster and does not change batching, flushing, or queue behaviour. Only the reporting changes.
  • It does not affect the write-time statistics. Every batch is timed and accumulated regardless of the threshold, so the average write time in the final statistics is unchanged whatever you set here.

If the warning fires on storage you expect to be fast, treat it as a signal rather than noise and check the discard count under max_queue_size — a disk that cannot keep up shows there as lost records.

Output path security

To prevent symbolic-link and time-of-check/time-of-use attacks, the sniffer opens every output file (NDJSON output, log files, crash reports) with O_NOFOLLOW and verifies the result with fstat. Configured output paths must also satisfy:

  • The path resolves within an allowed service directory — by default /var/log/pqc-sniffer, /var/lib/pqc-sniffer, or /etc/pqc-sniffer.
  • The final path component is not a symbolic link.
  • The parent directory is not world-writable (unless sticky, like /tmp) and is owned by root or the service account.

A path that violates these is rejected at startup with a clear validation error (for example, "offline_output.output_file: output path is outside the permitted service directories"). To allow a non-standard output location (for example a dedicated data mount), set the PQC_SNIFFER_ALLOWED_OUTPUT_DIRS environment variable to a colon-separated list of absolute directories in the service environment; it replaces the default allowlist.

Replaying a capture file

A live run and an offline run of the same traffic can produce different NDJSON, and the reason is not throughput. It is time.

Connection expiry runs on the wall clock. A live capture admits new flows as old ones age out, so at any moment the connection table holds only what is concurrently open. Reading the same traffic from a savefile as fast as the workers accept it removes all the intervals: an hour of traffic replayed in four seconds gives every flow in it four seconds to age, and none of them do. The table therefore holds every flow in the file at once, and can reach connection_tracker.max_connections where the live capture never came close. The flows refused past that point are missing from the output, and nothing about the resulting file looks incomplete.

capture.offline_replay_mode chooses between the two behaviours.

fast (default) timestamp-paced
Read rate as fast as workers accept each packet at its own capture time
Packet queue full waits, never drops waits, falls behind schedule, never drops
Output queue full waits; fails the run on a writer I/O error same
Connection table full ends the run at the first refusal continues; refuses while full, admits again as entries expire
PCAP timestamps metadata only control delivery timing
Best for bulk analysis, parsing benchmarks comparing output against a live capture

Use fast for analysis, which is what it has always done. Use timestamp-paced when the question is whether offline output matches live output, and accept that the replay then takes as long as the capture did.

capture:
  offline_replay_mode: timestamp-paced

Faster-than-real-time pacing is deliberately absent. Scaling the delivery rate would not scale expiry, which follows wall-clock time wherever the packets came from, so a 10x replay would diverge from a live run in exactly the way fast does while appearing to have been paced. Supporting it means an injectable clock throughout the tracker and every expiry path.

Nothing is dropped for backpressure

Both modes wait rather than discard, at every stage a record can be lost:

  • A full shard queue is waited on. The file is not going anywhere, so the only cost is a longer run.
  • A full output queue is waited on. offline_output.max_queue_size stops being a loss threshold in offline mode and becomes only a memory ceiling.
  • A writer that reports a real I/O error ends the run rather than being waited on, because a writer that cannot write will never drain.

The connection table is the exception, and the distinction matters. A queue that fills can be waited on because a worker is coming to drain it; a table that fills has nothing coming, since entries leave it only by expiring. Waiting would hang the run, and continuing in silence would emit a file that is short without saying so. Either way the run exits 6 (ReplayIncomplete) — see An offline replay exited 6.

What the two modes decide differently is whether reading on is worth anything, because a refusal does not mean the same thing in each:

  • In fast mode a refusal is an artifact of the replay. Nothing has aged out, so nearly every flow left in the file would be refused as well. The run ends at the first refusal.
  • In timestamp-paced mode a refusal is a result. Expiry has seen the real intervals, so the ceiling was reached only because the traffic genuinely exceeded it — which is what a live capture of the same traffic would have done. The replay continues to the end of the file, refusing new flows while the table is full and admitting them again as entries expire. Reproducing that loss and recovery is the reason to run the mode at all, so stopping at the first refusal would destroy the measurement being asked for.

A paced run that reports refusals is therefore telling you something about your configuration rather than about the replay: this connection_tracker.max_connections could not have held this traffic live either. The refusal count is the figure to compare against a live capture of the same traffic.

Reading the replay summary

Every offline run ends with a line naming the mode, the packet count, the elapsed time and the delivered rate. A paced run adds its worst lag:

Replay summary: mode=timestamp-paced, 1840221 packet(s) in 603.4s (3050 packets/sec delivered), worst lag 0.004s

Lag is how far behind its own schedule the replay fell. It is a result, not a fault: no packet is lost to it, and a growing figure says the pipeline could not have kept up with this traffic live either. A lag above one second is called out separately for that reason.

Offline input path security

The same reasoning applies to the one path the sniffer reads: the savefile named by capture.interface in offline mode. It is opened with the service's privileges, so it is validated first, and the validation deliberately splits in two.

At config load, purely on the pathname:

  • The path must be absolute and contain no .. component.
  • It must resolve within an approved capture directory — /var/lib/pqc-sniffer or /etc/pqc-sniffer by default. Set PQC_SNIFFER_ALLOWED_PCAP_DIRS to a colon-separated list of absolute directories to replace that list. Containment is checked against the realpath, so a symlinked directory cannot be used to step outside an approved root.

At open, on the file descriptor itself:

  • The file must be a regular file. FIFOs, character and block devices, sockets and directories are refused — open(2) on a FIFO with no writer never returns, which would hang startup indefinitely.
  • The final component must not be a symbolic link (O_NOFOLLOW).
  • The file must be owned by root or the service account, and must not be world-writable.
  • Its size must be within capture.max_offline_file_size_mb.

The split matters: everything in the second group is checked by fstat on the descriptor that is about to be read, and that descriptor — not the pathname — is what is handed to libpcap. A check applied to a name could not survive the name being repointed to a different file in between.

-i/--interface overrides from the command line are re-validated after they are merged over the config file, so an override is held to the same rules.

A refused input fails at startup naming the reason, for example "Refusing to open PCAP file '/tmp/x.pcap': PCAP input path is outside the permitted capture directories: /tmp".

logging

logging:
  level: "info"
  file:
    path: "/var/log/pqc-sniffer/pqc-sniffer.log"
    max_size_mb: 100
    max_files: 10
    rotation_enabled: true
    min_free_disk_mb: 100
    rate_limit_per_sec: 0
    archive:
      enabled: false
      dir: ""
      max_total_mb: 1000
      max_age_days: 0
  database:
    enabled: false
    level: "warn"
  • logging.level: debug, info, warn, or error
  • logging.file.path
  • logging.file.max_size_mb: per-file size threshold; a reused index is truncated on wraparound, so total log disk use is bounded by max_size_mb * max_files
  • logging.file.max_files
  • logging.file.rotation_enabled
  • logging.file.min_free_disk_mb: free-space floor for the log filesystem; the sink stops writing records when free space drops below it (independent of rotation) and resumes when space is reclaimed. 0 disables the guard (default: 100, valid 0–10485760)
  • logging.file.rate_limit_per_sec: cap on verbose (trace/debug/info) log records written per second, so attacker-driven traffic cannot inflate log volume; warn/error/critical are never limited, and any suppressed records are summarized in-band. 0 disables the limiter (default: 0, valid 0–10000000)
  • logging.file.archive.*: retention-aware archiving (default off). When enabled, each rotated index is gzip-compressed into dir (empty ⇒ <log dir>/archive) before the circular sink reuses it, so records are preserved for a log shipper to forward. max_total_mb (0 = unlimited) and max_age_days (0 = off) bound the archive set by pruning oldest first; a .forensic_hold marker file in dir pauses all pruning so evidence under investigation is never deleted. Circular rotation of the live files is unchanged.
  • logging.database.enabled: set to false for offline-only deployments. Defaults to false when the database: block is left out entirely, and to true when the block is written without it
  • logging.database.level

The file: and database: blocks are both optional. Leaving file: out logs to /var/log/pqc-sniffer/pqc-sniffer.log with the defaults above; leaving database: out disables database logging, which is what an offline deployment wants.

Changed in 2.1.0

Before 2.1.0 neither block could actually be left out. A configuration without file: was refused with "logging.file.path: output path cannot be empty" and one without database: with "logging.database.level must be one of: debug, info, warn, error", although neither block is required and the second asks an offline deployment to choose a database log level. Both now take the defaults documented here.

level and what it costs

debug is safe to leave available and safe to switch to for diagnosis. As of 2.0.1 a log line that the level discards costs a single atomic read: the level is tested before the arguments are evaluated, so nothing a suppressed line would have printed is computed. Earlier releases evaluated the arguments first, which is why a debug line on the packet path cost something even at info.

Switching to debug on a live sensor still costs what writing those records costs, which on a busy link is substantial. logging.file.rate_limit_per_sec bounds it.

Every shipped build can do this. Debug logging is compiled in, and no release turns it off — a SNIFFER_LOG_COMPILE_OUT_DEBUG=ON build would remove level: debug from the binary entirely, and nothing in packaging/ or teamcity/ sets it.

Two kinds of repeating condition are summarised rather than logged per occurrence, so that a flood of bad traffic cannot become a flood of log lines. Both report what they withheld:

Condition Cadence Where the total is reported
Frames refused before parsing one line a minute Malformed packets, plus a suppressed-warning count in the end-of-run statistics
DTLS handshake fragments refused first, then every ~10,000th DTLS handshake fragments refused (reassembly limit)

See Malformed-packet warnings are summarised.

monitoring

monitoring:
  heartbeat_interval_seconds: 60
  memory_check_interval_seconds: 30
  interface_check_interval_seconds: 30
  metrics:
    enabled: false
  • monitoring.heartbeat_interval_seconds
  • monitoring.memory_check_interval_seconds: how often memory pressure is sampled (default: 30, valid 1–3600)
  • monitoring.interface_check_interval_seconds: how often interface health is sampled (default: 30, valid 1–3600)
  • monitoring.metrics.enabled

Both intervals set alert cadence only. Neither is on the capture path, so changing them cannot affect throughput, drop rates or what ends up in the output — only how quickly a memory climb or a downed interface is noticed, and how often the log says so. Lower them while investigating; raise them to quieten a noisy log.

connection_tracker

connection_tracker:
  timeout_seconds: 300
  cleanup_interval_seconds: 10
  handshake_timeout_seconds: 10
  time_wait_timeout_seconds: 120
  udp_unanswered_timeout_seconds: 10
  udp_idle_timeout_seconds: 60
  max_connections: 100000
  reassembly_timeout_seconds: 30
  terminal_timeout_seconds: 10
  max_fragments_per_stream: 1000
  https_ports: [443, 8443, 9443, 8080, 8081]
  doh_hostnames:
    - dns.google
    - cloudflare-dns.com
    - one.one.one.one
    - dns.quad9.net
    - doh.opendns.com
    - dns.nextdns.io
    - doh.cleanbrowsing.org
    - dns.adguard-dns.com
    - family.adguard-dns.com
    - unfiltered.adguard-dns.com

Controls how long the sniffer keeps a flow in memory after it stops seeing traffic, and how many flows it will track at once.

  • timeout_seconds: idle timeout for established flows (default: 300, valid 1–86400)
  • cleanup_interval_seconds: how often expired flows are reclaimed (default: 10, valid 1–3600)
  • handshake_timeout_seconds: TCP flows that never complete a handshake (default: 10, valid 1–3600)
  • time_wait_timeout_seconds: TCP TIME_WAIT (default: 120, valid 1–3600). A close that has seen both FINs but not the final ACK is held for the shorter of this and terminal_timeout_seconds instead, because the only packet it is still waiting for is that ACK. 2*MSL is about an endpoint reusing the four-tuple, not about how long an observer should hold a finished flow.
  • udp_unanswered_timeout_seconds: UDP flows where no reply was ever seen (default: 10, valid 1–3600)
  • udp_idle_timeout_seconds: UDP flows with traffic in both directions (default: 60, valid 1–86400)
  • max_connections: ceiling on tracked flows; 0 means unlimited (default: 100000)
  • reassembly_timeout_seconds: how long a fragment of a half-received TCP stream is kept before it is discarded (default: 30, valid 1–300). See Choosing reassembly_timeout_seconds.
  • terminal_timeout_seconds: how long a TCP flow that has closed or been reset is kept before its table slot is released (default: 10, valid 1–300). See Choosing terminal_timeout_seconds.
  • max_fragments_per_stream: fragments buffered for one direction of one TCP stream before further ones are dropped (default: 1000, valid 1–100000). See Fragment limits.
  • https_ports: ports that may be reported as HTTPS when the handshake negotiated no ALPN to prove it (default: [443, 8443, 9443, 8080, 8081]). See Choosing https_ports.
  • doh_hostnames: resolver hostnames whose SNI identifies a flow as DNS-over-HTTPS (default: the ten well-known public resolvers listed above). See Identifying DNS-over-HTTPS resolvers.

Choosing reassembly_timeout_seconds

TCP handshake messages can span several segments — ML-KEM ServerHellos routinely exceed a single one — so the sniffer buffers fragments until a stream is complete. This bounds how long it waits before giving up on one.

It is the time-based counterpart to the fragment count limit. A flow whose remaining segments never arrive, through packet loss on a SPAN feed or a sender that simply stops, holds its fragments until this expires, so on a lossy capture the two limits bound different things and both matter.

  • Lower it where incomplete streams accumulate faster than they complete; fragments are released sooner, at the cost of abandoning handshakes that were merely slow.
  • Raise it where large handshakes are reassembled over slow or lossy links.

This setting is newly in force

reassembly_timeout_seconds has been written into wizard-generated configs for some time, and the wizard offers an editor for it, but nothing read it — the tracker used a hardcoded 30 seconds regardless. A config that already carries a non-default value will therefore change behaviour on upgrade, for the first time doing what it always said. Check the value in any config generated by the setup wizard before upgrading.

Choosing terminal_timeout_seconds

A TCP flow that has closed cleanly or been reset is finished: nothing further will be learned from it. This is how long its entry is kept anyway, so that late or retransmitted packets are matched to the flow they belong to rather than creating a new one.

It matters under short-lived churn — many brief connections, each completing properly. There, finished flows can occupy a large share of max_connections at any moment, and this is the timeout that decides how large. If the connection table fills while the traffic is mostly short connections that close, lower this before raising the ceiling; it costs nothing, because the entries it releases were never going to produce anything else.

There is little reason to raise it. The one case is a capture that sees heavy post-close retransmission and reports flows it has already emitted.

This was a hardcoded 10 seconds until TQR-1429, while every neighbouring retention — idle, TIME_WAIT, handshake, both UDP timeouts — was already configurable.

Fragment limits

Two further limits bound reassembly. One is a setting and one deliberately is not, and the difference is the point.

Maximum fragment size is derived from capture.snaplen and cannot be set independently. It used to be a fixed 64 KB while snaplen accepted up to 256 KB — a ceiling that exists precisely so large ML-KEM ServerHellos survive capture — so the reassembler could truncate a handshake the capture had kept in full. That truncation was silent, and worse than a drop: the shortened payload keeps its TCP sequence number, so the reassembled stream carries a hole that reads as contiguous data and the handshake simply fails to parse. Tying the limit to snaplen puts it beyond reach, since libpcap has already truncated at snaplen before the sniffer sees a byte. The effective value is printed at startup:

Connection tracker: idle=300s, ..., reassembly=30s, max_fragment=262144B

Maximum fragments per stream is connection_tracker.max_fragments_per_stream (default 1000, valid 1–100000). Past that, further fragments for the same half-stream are dropped, which normally costs the whole connection record — a handshake that cannot complete is reported as dropped before output (incomplete TLS handshake). That loss is counted and reported in the shutdown statistics, and warned about between heartbeats:

TCP fragments dropped (reassembly buffer full): 0

A non-zero count is usually not a limit that wants raising. Fragments are released as soon as both the ClientHello and ServerHello parse, so a healthy flow holds single digits; reaching 1000 means segments are arriving badly out of order or with heavy loss. Look there first — check it against the kernel drop counters, see Kernel Drops: Capture Cannot Keep Up.

The setting exists for the case where that investigation ends in "yes, and I cannot fix it": a lossy SPAN feed or a long-haul link that genuinely reorders. Raising it then buys back records that were being discarded. Raising it to paper over a capture-path problem only moves the loss somewhere harder to see, and costs memory while doing so.

Budget that memory before raising it. The limit is per direction, so one flow can hold up to 2 x max_fragments_per_stream x capture.snaplen bytes. At the default with a 262144 snaplen that is about 524 MB for a single flow in the worst case; real traffic comes nowhere near it, but the arithmetic is what bounds a pathological one.

DTLS handshake fragments

DTLS carries its handshake over UDP, which has no stream to reassemble, so a handshake message larger than a datagram is fragmented by DTLS itself. Those fragments are reassembled per connection, under limits that are fixed rather than configurable:

Limit Value What it bounds
Assembled message size 64 KB the largest handshake message that will be reassembled
Fragments per message 64 pieces one message may arrive in
Messages in flight 8 per connection, incomplete at once
Retained bytes 256 KB per connection, across all its messages
Fragment age 30 s how long an incomplete message is held before it is dropped

They are not settings because no deployment should need to tune them: a real DTLS ClientHello, including one carrying a post-quantum key share, is a few kilobytes across a handful of datagrams, so the values sit far above legitimate traffic and exist only to bound a peer that sends fragments it never completes. A refusal is counted and reported:

DTLS handshake fragments refused (reassembly limit): 0

A non-zero count means handshakes that cannot complete, so those connections are recorded without crypto detail. As with the TCP figure above, look at the capture path first.

Choosing https_ports

This list is a fallback, not the definition of HTTPS. A connection is classified HTTPS because its TLS handshake negotiated an HTTP ALPN (h2 or http/1.1); the port is consulted only when no ALPN was observed. Every record states which of the two decided it, in pqc.app_protocol_basis — alpn is proof, port is inference. See How the protocol is decided.

Before this setting existed, only port 443 could produce HTTPS, so TLS on 8443 or 9443 was reported as generic tls however obviously it was a web service.

An explicit list replaces the default rather than extending it, so this narrows as well as widens:

connection_tracker:
  # Report HTTPS only where an ALPN proves it.
  https_ports: []

Use [] when the inventory is being used as evidence rather than as a survey: it removes every classification that rests on a port convention, at the cost of reporting tls for HTTPS connections whose ServerHello was not captured.

Adding a port does not weaken anything else. The fallback applies only to connections already confirmed as TLS, so a plaintext service on one of these ports is still classified from its payload — a connection whose bytes begin GET is http whatever the port says.

A 0 in the list is rejected at startup, because it can never match and would otherwise make a mistyped port look like a setting that simply had no effect.

Identifying DNS-over-HTTPS resolvers

doh_hostnames is the whole of how DNS-over-HTTPS is identified, so it is worth saying plainly what a dns-over-https record means: the connection's SNI matched a name on this list. It does not mean DNS queries were seen. They cannot be — a DoH query travels inside the completed TLS tunnel, and the hostname the client asked for is the last thing a passive observer can read. Everything else in such a record, the tls.* and pqc.* fields, is measured from the handshake exactly as for any HTTPS connection.

The consequence is that a resolver not on this list is reported as https. The default covers the well-known public resolvers, which is the wrong half of the problem for most sites: an enterprise running its own DoH endpoint is the deployment where knowing beats guessing, and that traffic was indistinguishable from ordinary web browsing.

connection_tracker:
  # Keep the public resolvers and add this site's own.
  doh_hostnames:
    - dns.google
    - cloudflare-dns.com
    - one.one.one.one
    - dns.quad9.net
    - doh.opendns.com
    - dns.nextdns.io
    - doh.cleanbrowsing.org
    - dns.adguard-dns.com
    - family.adguard-dns.com
    - unfiltered.adguard-dns.com
    - doh.internal.example.net

As with https_ports, an explicit list replaces the default rather than extending it, so the list above is written in full. That is deliberate: replacing lets a site remove an entry it considers a false positive as well as add one, and the generated config file always contains the current list, so there is something to copy from. Use [] to stop reporting DoH entirely, leaving those flows as the https they otherwise are.

A name matches exactly or as a dot-delimited label suffix, and case is ignored. So dns.google also matches foo.dns.google, and does not match notdns.google — a domain that merely ends with a resolver's name is a different domain, and anyone can register one. An empty string in the list is rejected at startup, because the suffix rule would make it match every connection and publish all TLS traffic as DNS-over-HTTPS.

Choosing cleanup_interval_seconds

Reclaiming expired flows costs what is due rather than what is tracked, so a short interval is cheap: a sweep that finds nothing to reclaim takes well under a microsecond whether the table holds a thousand flows or a hundred thousand. Sweeps are also processed in bounded batches, so even a large group of flows expiring together cannot stall packet processing while they are reclaimed.

The interval therefore controls how promptly memory is returned, not how much work the sniffer does. Shorten it if Flows tracked in the heartbeat sits higher than expected; there is no throughput reason to lengthen it.

Why UDP has its own timeouts

TCP tells the sniffer when a flow is finished — a FIN or RST moves it to a terminal state, released after ten seconds. UDP has no such signal, so a UDP flow would otherwise be held for the full timeout_seconds.

That matters under connection churn. A client that opens a socket, sends one datagram and closes it uses a new source port each time, so every datagram is a distinct flow. At a few hundred per second with a five-minute timeout, the table accumulates tens of thousands of entries that are certainly finished.

The two UDP settings split on whether a reply was ever observed. A datagram with no response is treated as complete and released quickly. A flow with traffic in both directions may be a real session — DNS-over-UDP, QUIC, a VPN tunnel — and is kept longer, though still far short of the TCP figure.

Warning

max_connections: 0 means the connection table is unbounded and can grow until the host runs out of memory and begins swapping. Set a ceiling on any deployment exposed to high connection churn. The sniffer logs a warning at startup when the limit is unset, and reports refusals as New flows refused (table full) in each heartbeat.

A count ceiling is a rough proxy for memory, because flows differ in cost. To bound the process by what it is actually using instead, see Limiting the connection table by memory.

Flows currently held are reported as Flows tracked in every heartbeat, so growth is visible without waiting for shutdown — see Reading the capture-quality counters.

resource_limits

resource_limits:
  max_memory_mb: 0
  enforce_connection_memory_limit: false
  resume_memory_mb: 0
  max_cpu_percent: 0
  max_disk_mb: 0
  min_free_disk_mb: 500

0 means unlimited for the three max_* fields, and disabled for min_free_disk_mb.

  • max_memory_mb: RSS ceiling. Used for memory-pressure reporting, and -- when enforce_connection_memory_limit is on -- as the point at which new flows stop being accepted. 0 disables both.
  • enforce_connection_memory_limit: refuse new flows while process RSS is at or above max_memory_mb (default: false)
  • resume_memory_mb: RSS at or below which new flows are accepted again. Must be greater than 0 and strictly less than max_memory_mb when enforcement is on.
  • max_cpu_percent: CPU budget used for load reporting.
  • max_disk_mb: disk budget for the database-outage buffer (see below).
  • min_free_disk_mb: free-space floor for the filesystem holding the buffer; the buffer stops spilling when free space drops below it (default: 500, valid 0–10485760)

Limiting the connection table by memory

connection_tracker.max_connections bounds the table by count, which is an awkward proxy for memory: flows differ in cost, so a single number either turns flows away while memory is still plentiful or lets the process grow past what the host can spare. Picking that number is guesswork, and it has to be redone for every deployment.

Enabling enforce_connection_memory_limit adds a second, independent gate based on what the process is actually using:

resource_limits:
  max_memory_mb: 3584
  enforce_connection_memory_limit: true
  resume_memory_mb: 3072

connection_tracker:
  max_connections: 500000

The two gates are combined: a flow is created only if the table has room and memory is below the limit. That lets max_connections serve as a coarse safety ceiling rather than a carefully tuned figure, with memory as the bound that actually holds.

Flows already being tracked are unaffected -- they continue to be parsed, written and expired normally. Only the creation of new flows pauses, and refusals appear in the heartbeat as New flows refused (memory limit).

A refused flow produces no output record of any kind — no flow object is created, so there is nothing to write. That counter is therefore the only thing in a run's output that reports those flows existed at all, which is what makes it worth watching: a report from a run with a non-zero count covers less traffic than the capture saw.

Because that counter is the only evidence those flows existed, it is reported twice from independent sources. The closing summary prints the tracker sum as New flows refused (memory limit) and the admission gate's own count as Memory refusals counted by admission gate. The gate's figure is sampled first and both counters only increase, so the tracker sum can never legitimately be the smaller of the two; if it is, the sniffer logs an error naming both — see Checking the memory-refusal count against itself.

This counter was always zero on 2.0.0-5 and 2.0.0-6

Each worker counted its own refusals correctly, but the per-worker figures were not summed into the process-wide total, so the heartbeat and the closing summary reported 0 however many flows had been refused (TQR-1421, fixed in 2.0.0-7). On those two versions a zero here means the value was never measured, not that nothing was refused. The per-worker Memory admission closed at ... refused N new flow(s) warnings in the log are the only surviving record.

max_memory_mb is the whole process, not the connection table

RSS covers capture buffers, the parsers, output queues and allocator arenas as well as tracked flows. Set the threshold against the total footprint you have observed for the service, not against an estimate of per-flow cost. A value below the sniffer's normal working set closes admission immediately and keeps it closed.

The gap between max_memory_mb and resume_memory_mb is deliberate. Refusing a flow does not free memory straight away, so a single threshold would sit on the boundary and flap open and closed on every sample. Admission closes at max_memory_mb and reopens only once RSS has fallen all the way back to resume_memory_mb. Roughly 10-15% below the limit is a reasonable starting point.

Database-outage buffer

Outside offline mode, if the database becomes unreachable the sniffer holds connection records in memory and then spills them to timestamped files under /var/lib/pqc-sniffer/buffer/, so they can be replayed once the database returns. Offline deployments never create this buffer.

max_disk_mb bounds the total size of those spill files. When the budget is reached the sniffer stops spilling and drops new records rather than help drive the disk to exhaustion, and reports the running total of dropped records in the log. Records already spilled are never evicted to make room, so the earliest evidence of an incident is preserved in preference to the newest.

There is no separate setting for the number of spill files or buffered records, and none is needed. Each spill file holds a full memory buffer of 10,000 records, which is at least about 1 MB, so the disk limits cap both counts. With max_disk_mb: 1024, for example, there are fewer than 1,000 spill files.

Budget versus reserve

max_disk_mb and min_free_disk_mb protect against the same outcome by asking different questions, and you generally want both:

Question it answers Measured against
max_disk_mb Has the buffer used its allowance? The buffer's own spill directory
min_free_disk_mb Is there room left on the volume? The filesystem, via statvfs

A budget cannot see what else is on the disk. The buffer shares /var with the NDJSON output, the rotating logs and the pre-capture PCAPs, so it can sit comfortably inside a generous max_disk_mb while those other writers consume the remaining space — and still be the process that takes the filesystem to zero. A full /var also means the sniffer can no longer write the NDJSON evidence the buffer exists to preserve.

That is why min_free_disk_mb defaults to 500 while max_disk_mb defaults to 0: a reserve means the same thing on a 20 GB VM and a 2 TB appliance, whereas any default byte budget would be a guess against hardware the installer has never seen. The same guard already protects pre-capture PCAP output (capture.pre_capture.min_free_disk_mb) and the log sink (logging.file.min_free_disk_mb).

Warning

With max_disk_mb: 0 the buffer has no budget of its own: a long database outage — or an attacker generating connections during one — grows the spill directory until the free-disk reserve stops it. Set a value on any deployment that writes to a database. The sniffer logs a warning at startup when the budget is unset.

Danger

Setting min_free_disk_mb: 0 and max_disk_mb: 0 leaves the buffer entirely unbounded, and it will fill the filesystem. Disable the reserve only if you have set an explicit budget.

crash_handler

crash_handler:
  enabled: true
  crash_log_dir: ""
  generate_core_dump: false
  include_memory_stats: true
  include_recent_activity: true
  max_backtrace_frames: 50
  • enabled
  • crash_log_dir: directory for crash reports. When empty, defaults to /var/log/pqc-sniffer/ (crash reports are never written to shared /tmp).
  • generate_core_dump
  • include_memory_stats
  • include_recent_activity
  • max_backtrace_frames

security

FIPS 140-3 posture for the sniffer's own cryptography. This is distinct from anything the sniffer reports about the traffic it analyses: it governs which algorithms this process is permitted to use, not what it can detect.

security:
  fips_mode: enabled          # disabled | enabled | required
  fips_config_file: ""        # empty = packaged default
  fips_modules_dir: ""        # empty = packaged default
  • fips_mode: activation policy for OpenSSL's FIPS provider (default: enabled)

    Value Behaviour when FIPS cannot be activated
    disabled No attempt. Logged, so it is never silently off.
    enabled Logs an error and continues without FIPS.
    required Refuses to start (exit code 1).

    Production deployments should set required. enabled is the default so a build running outside the RPM, where the provider is not installed, reports the problem loudly instead of failing to start.

  • fips_config_file / fips_modules_dir: leave empty to use the packaged locations (/etc/pqc-sniffer/openssl-fips.cnf and /usr/lib64/pqc-sniffer/ossl-modules). Override only when running from a source tree, pointing at the ossl-modules directory of the OpenSSL the binary was linked against.

When FIPS is active the sniffer logs it at startup, and non-approved algorithms genuinely stop resolving. Everything the sniffer itself uses — AES-256-GCM, SHA-256, the DRBG — is approved, so enabling FIPS disables no functionality.

Post-quantum key exchange is retained

Verified against a live handshake with the provider active on both ends: TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519MLKEM768. FIPS mode and hybrid post-quantum key exchange are compatible, not alternatives.

FIPS mode is not FIPS 140-3 validation

This enforces the approved algorithm set. It does not make the product FIPS 140-3 validated — that attaches to a lab-evaluated, CMVP-certificated module build. Confirm certification status before making an external compliance claim. See FIPS encryption for detail.

Values That Are Deliberately Not Settings

Some internal constants are reviewed for configurability and kept compiled in. They are listed here so the decision is visible rather than looking like an omission — if you went looking for one of these keys, this is why it is not there.

Worker dequeue batch

A processing worker takes at most 256 packets from one shard before releasing it, and a parked worker re-checks its status every 50 ms.

Neither is a setting, for two reasons.

The batch bound is not a throughput dial, it is a fairness one. It is what stops a single busy shard monopolising a worker while other shards sit ready, so the cost of a hot flow is one shard's throughput rather than the whole pool stalling. Raising it does not make the pipeline faster; it makes one flow's burst more likely to starve the others. Lowering it adds scheduling overhead per packet. The useful range is narrow and the failure mode of a bad value is uneven latency across flows, which is hard to attribute and does not show up in any counter.

Thread count is also already adaptive. Since TQR-1503 the pool sizes itself against measured loss, so a hand-set batch bound would be tuning one half of a control loop whose other half moves on its own.

The 50 ms figure is not an idle wait or a poll interval — it is how promptly a worker notices it should exit after a resize. It bounds resize latency, not packet latency, and no packet waits on it.

If you have a measured case where either value is the bottleneck, that is worth a ticket with the measurement. It is not worth a knob fitted in advance of one.

Maximum fragment size

Derived from capture.snaplen rather than set on its own. See Fragment limits for why an independent value is unsafe.

Protocol and cryptographic constants

Protocol size limits, maximum packet sizes, queue spin counts, hash constants and cryptographic or protocol identifiers stay compiled in. These are not tuning parameters: changing them makes captures either incorrect or incompatible with the standards being analysed, and a configuration file is the wrong place to allow that.

Validation Rules

Value types

Every setting has a type, and a value that type cannot hold is refused when the configuration is loaded, by name:

offline_output.buffer_size_kb must be a whole number, but is 'lots'
offline_output.rotation_enabled must be true or false, but is 'maybe'
  • A numeric setting takes a whole number. 512 and "512" are the same value; 5.5, 512kb and lots are refused.
  • A boolean setting takes true or false. YAML also accepts yes/no, on/off and y/n, written in lower case, Capitalised or UPPER CASE. It does not accept 1 and 0.
  • A setting left out takes its default, and so does one written with no value at all (buffer_size_kb:), which YAML reads as unset.
  • A list, or an indented block, where a single value belongs is refused the same way.

Changed in 2.1.0

Before 2.1.0 a value of the wrong type was quietly replaced by the setting's default and nothing was logged, so buffer_size_kb: lots ran on 512 and rotation_enabled: maybe ran with rotation on — the opposite of a false that had been mistyped. Such a file now fails to load rather than running as something it does not say. A configuration that was written with 1 and 0 for a boolean was already running on the default, never on true or false; it now says so instead of being given a new meaning. Check an existing file with pqc-sniffer --config <file> --validate-config before upgrading.

Ranges and paths

  • capture.interface, when it names a savefile, must be an absolute path, must not contain .., and must resolve within an approved capture directory (see Offline input path security)
  • capture.max_offline_file_size_mb must be between 0 (no limit) and 1048576
  • capture.offline_replay_mode must be fast or timestamp-paced. An unrecognised value is rejected rather than treated as the default, because the two modes produce different output from the same file
  • capture.duplicate_window_ms must be between 0 (disabled) and 100
  • capture.duplicate_filter_entries must be between 64 and 1048576
  • security.fips_mode must be disabled, enabled or required
  • offline_output.output_file must be an absolute path
  • offline_output.output_file ending in / is completed to connections.ndjson in that directory before the rules below are applied (see Output Path Behavior)
  • offline_output.output_file cannot contain ..
  • offline_output.output_file must resolve within an allowed service directory and must not be a symlink (see Output path security)
  • logging.file.path is subject to the same allowed-directory and symlink checks
  • offline_output.buffer_size_kb must be between 1 and 1024
  • offline_output.max_size_mb must be between 1 and 10240 when rotation is enabled
  • offline_output.max_files must be between 1 and 1000 when rotation is enabled
  • resource_limits.max_memory_mb must be 0 (no limit) or between 100 and 10240
  • resource_limits.max_memory_mb must be non-zero when resource_limits.enforce_connection_memory_limit is true
  • resource_limits.resume_memory_mb must be non-zero when resource_limits.enforce_connection_memory_limit is true
  • resource_limits.resume_memory_mb must be strictly less than resource_limits.max_memory_mb when enforcement is true
  • resource_limits.max_cpu_percent must be 0 (no limit) or between 10 and 100
  • resource_limits.max_disk_mb must be 0 (no limit) or between 100 and 102400
  • resource_limits.min_free_disk_mb must be between 0 (disabled) and 10485760
  • connection_tracker.timeout_seconds must be between 1 and 86400
  • connection_tracker.cleanup_interval_seconds must be between 1 and 3600
  • connection_tracker.handshake_timeout_seconds must be between 1 and 3600
  • connection_tracker.time_wait_timeout_seconds must be between 1 and 3600
  • connection_tracker.udp_unanswered_timeout_seconds must be between 1 and 3600
  • connection_tracker.udp_idle_timeout_seconds must be between 1 and 86400
  • connection_tracker.max_connections must be 0 (unlimited) or up to 10000000

Info

For offline-only deployments, keep logging.database.enabled: false and do not rely on --dry-run for real file generation.