Skip to content

Key Concepts

These concepts appear throughout the system. Understanding them is essential before proceeding to configuration and operation.

Every DHCP packet that traverses the system carries a 32-bit nftables mark. The mark has structure: the high byte encodes the DHCP message type, the low 24 bits identify the client.

bit 31 24 23 0
+----------------+----------------------------+
| DHCP msg type | 24-bit client mark |
+----------------+----------------------------+
0xFF000000 0x00FFFFFF
  • High byte (8 bits) — DHCP message type code (0x01 = DISCOVER, 0x03 = REQUEST, 0x11 = SOLICIT v6, etc.). Set by the userspace inspector before returning the verdict. The route_by_type chain at prerouting priority -98 vmaps on this byte to dispatch the packet to the matching per-msg-type chain.
  • Low 24 bits (client mark) — derived from the last 3 bytes of the client MAC. Used as the lookup key in llm_*_marks, blocked_macs, and tracked_marks.
MAC: AA:BB:CC:DD:EE:FF
Client mark: 0x00DDEEFF (DD<<16 | EE<<8 | FF)
For a DHCPREQUEST (0x03), composite mark seen in nftables:
0x03DDEEFF

Per-msg-type chains strip the type bits with meta mark set meta mark & 0x00FFFFFF before consulting llm_*_marks, so set lookups always operate on the bare 24-bit client mark.

Two MACs that share the same last 3 bytes will receive the same client mark.

At typical network scale (thousands of clients) collisions are vanishingly rare. At very large scale (millions of unique MACs during load testing) they become more likely. Events in ClickHouse are always logged with the full 6-byte MAC, so a mark collision only affects kernel-level enforcement precision and the GUI’s mark-to-MAC reverse lookup (which displays as ??:??:??:xx:xx:xx because only the last 3 bytes can be recovered from the mark).

A collision only hurts when the colliding mark ends up in an actionable set such as blocked_macs or llm_throttled_marks — that is, when one of the two devices has communicated frequently enough or behaved suspiciously enough to be enforced against. Two MACs sharing the last 3 bytes but both behaving normally never trigger any enforcement and the collision is invisible. The scenario where a collision causes real harm — a legitimate device and a concurrently active attacker sharing the same 24-bit suffix, with the attacker triggering a block that incidentally catches the legitimate device — requires both clients to be present, both to be actively talking on the wire, and one to be misbehaving in a way that earns enforcement. At realistic deployment sizes this combination is highly unlikely.

Sets are kernel-level data structures that track marks for enforcement decisions.

The active ruleset (nft-config/nft-v2.sh) defines seven enforcement sets:

SetDefault timeoutSize capPurpose
blocked_macs2 min2.5MAuto-blocked clients (per-client rate-limit overage)
tracked_marks1 min2.5MPer-mark rate-tracking entries
llm_blocked_marks2 h1MLLM-recommended temporary blocks
llm_denied_marks2 h1MLLM-recommended denies (drops + userspace event suppression)
llm_throttled_marks7 d1MLLM-recommended rate limit (10/min)
llm_allowed_marks30 d1MLLM-trusted clients, bypass all enforcement
llm_monitored_marks14 d1MLLM-monitored clients, log only

The timeouts and size caps shown above are shipped defaults, not hard-coded limits. Every value in the table — per-set timeout, size cap, even the chain/set composition itself — is editable from the GUI’s Firewall Manager (chapter 20). The Firewall Manager exposes the active ruleset as JSON, lets you swap in pre-built throttling profiles (permissive / medium / strict), and applies your changes to the running kernel atomically. Use it whenever you need to tune enforcement to your deployment rather than reaching for nft-v2.sh by hand.

The set timeout is the kernel-side cap — it determines how long any entry can live in the set regardless of how it was created. The Action Manager additionally writes an action-level default duration into each entry it creates (sourced from config_action_definitions); see chapter 15 — Actions.

Adding a mark to a set changes enforcement behavior immediately — no application restart required.

When the Action Manager adds a mark to llm_blocked_marks, the kernel begins dropping packets with that mark on the very next per-msg-type chain traversal. After the entry’s effective timeout expires (the smaller of the action default duration and the set-level timeout), the mark is removed automatically and the client resumes normal processing.

The blocked_macs set is special: it is populated automatically by the per-msg-type chains and by the dhcp_block safety-net chain when a client exceeds its rate limit. No userspace involvement is required for that path.

Five actions control how the system treats a device’s traffic.

ActionEffectAction default durationnft set timeout cap
BlockAll packets dropped at the kernel; events still recorded2 h2 h
DenyAll packets dropped at the kernel AND event generation suppressed in the userspace inspectorPermanent (0)2 h
ThrottleLimited to 10 packets/min; excess dropped1 d7 d
AllowBypass all enforcement chains (trusted)30 d30 d
MonitorPackets logged but not restricted7 d14 d

Note: These actions are essentially named nftables sets paired with a fixed enforcement behaviour at the kernel level. They look similar to the action types named by Automation rules (see chapter 16), and in most cases they line up — an automation rule that chooses block lands a mark in llm_blocked_marks. The two lists are not guaranteed to map 1:1 in every edge case, however: Automation also exposes the analyze pseudo-action (route to the LLM, which then emits one of these), and future profile changes in Firewall Manager could rename or merge sets without breaking the Automation API. Treat them as closely related but distinct concepts.

Actions are applied by writing the client mark to the corresponding llm_*_marks set. The per-msg-type chains evaluate the sets in fixed order on every packet:

  1. llm_allowed_marks — accept (skip the rest).
  2. llm_blocked_marks — drop.
  3. llm_denied_marks — drop; inspector also suppresses event emission.
  4. llm_throttled_markslimit rate 10/minute accept, then drop overage.
  5. llm_monitored_markslog, then continue to per-client rate limit.

Allow takes precedence over block. If a mark ends up in both llm_allowed_marks and llm_blocked_marks (e.g. mid-transition), the allow wins.

mac_actions (prerouting priority 90) and dhcp_block (priority 100) act as safety nets for any packet whose mark bypassed the per-msg-type vmap dispatch — they re-run the LLM-set ladder and per-client rate limit. They are not the primary enforcement path on nft-v2.sh.

The high byte of the 32-bit mark identifies the DHCP message type. This is the byte route_by_type vmaps on.

High byteMessagePer-type chain (inbound)Per-type chain (outbound)
0x01DISCOVERv4_discover_chain
0x02OFFERv4_offer_out_chain
0x03REQUESTv4_request_chain
0x04DECLINEv4_decline_chain
0x05ACKv4_ack_out_chain
0x06NAKv4_nak_out_chain
0x07RELEASEv4_release_chain
0x08INFORMv4_inform_chain
High byteMessagePer-type chain (inbound)Per-type chain (outbound)
0x11SOLICITv6_solicit_chain
0x12ADVERTISEv6_advertise_out_chain
0x13REQUESTv6_request_chain
0x14CONFIRMv6_confirm_chain
0x15RENEWv6_renew_chain
0x16REBINDv6_rebind_chain
0x17REPLYv6_reply_out_chain
0x18RELEASEv6_release_chain
0x19DECLINEv6_decline_chain
0x1ARECONFIGUREv6_reconfigure_out_chain
0x1BINFORMATION-REQUESTv6_info_request_chain
0x1CRELAY-FORWv6_relay_forw_chain
0x1DRELAY-REPLv6_relay_repl_out_chain

Inbound packets without recognised type bits but with a non-zero mark fall through route_by_type into default_enforce_chain.

A feature must be both enabled and active to operate.

The system separates feature availability from feature operation:

  • enabled (Core config, YAML, set by the administrator): is the feature available? Cannot be changed at runtime. If false, the feature’s code path is not initialised.
  • active (Operational config, database, set by the operator): is the feature currently running? Can be toggled at runtime through the GUI.

Both must be true for a feature to function. This two-level control lets an administrator deploy the system with certain features available but not yet activated, and lets an operator toggle features without editing config files or restarting the service.

# config.yaml (Core — set by administrator)
<feature>:
enabled: true # feature module is loaded
# system_config table (Operational — set by operator)
# operational.<feature>.active = false # feature is not running yet

In this state, the feature module is loaded and ready, but it does not run until the operator activates it.

Core config lives in YAML and requires a restart. Operational config lives in the database and changes at runtime.

Core settings define infrastructure: where to connect, what to load, how many workers to run.

Set in config.yaml, read at startup. Changing them requires restarting the service. Examples:

  • Database connection strings (ClickHouse host, port, credentials)
  • Network interface and NFQueue binding (queue numbers, count)
  • Optional integration endpoints
  • API server bind address and port
  • Feature enabled flags

Operational settings tune behaviour: how aggressive to throttle, and other runtime tuning knobs.

Stored in ClickHouse’s system_config table (a ReplacingMergeTree), edited through the GUI without a restart. The system checks the database first and falls back to YAML defaults when no database entry exists. Examples:

  • Throttling and burst-detection knobs
  • WebSocket rate limits
  • Feature active toggles
  • Support session limits, alarm system on/off, validator middleware on/off

Database values always take precedence over YAML values.

ASCII fallback
Request for config value "operational.<feature>.setting"
├── Check system_config table (database, queried with FINAL)
│ Found? → use database value
└── Not found? → use config.yaml operational.<feature>.setting value
└── Not in YAML? → use compiled default in DefaultConfig()

YAML values therefore serve as defaults for fresh installations. Once an operator changes a setting through the GUI, the database value persists across restarts and overrides YAML.

Configuration tables (system_config, system_analysis_prompts, config_action_definitions, automation_rules) use the ReplacingMergeTree engine, which does not merge rows immediately after INSERT. Every read against these tables must use the FINAL keyword or risk seeing stale duplicates. Event tables (dhcp_events, users_audit_log, the Stage-1 MVs) are plain MergeTree/SummingMergeTree and must NOT use FINAL. The full key-by-key configuration breakdown ships with the install package.

Why store config in ClickHouse alongside events? This is a deliberate operational choice. The appliance keeps configuration, audit, automation rules, and event data in one database engine so operators have a single backup target, a single set of credentials to manage, a single connection to monitor, and no extra service (Postgres, SQLite, etcd) to install, patch, or fail-over. The ReplacingMergeTree + FINAL discipline is the small price paid for that simplicity.