EN

Subnets

Work in progress — content may be incomplete.

A subnet binds a CIDR range to a profile. It’s the fallback that catches every host you didn’t pin individually.

What this is

A subnet is a named bundle of one or more CIDR blocks (IPv4, IPv6, or mixed) that map to a profile. When the resolver gets a query and the source IP doesn’t match any [[devices]] row, it walks the configured subnets longest-prefix-first and applies the matching subnet’s profile. A subnet is the path you reach for when you have categories of hosts — a guest VLAN, an IoT range, a corporate /8 — that should share a profile without being individually enumerated.

This page covers every dimension you can put on a [[subnets]] entry, the CLI / TUI / API surfaces that read and write them, how a subnet matches inside the 5-level resolver chain, and the security boundary around source-IP-based identity. For the chain itself see profiles; for the network ACL that decides whether the daemon answers a source at all (a separate concept), see [server].allow_from in server globals.

When you reach for it

Add or edit a subnet when:

  • You have a VLAN. Guest Wi-Fi, IoT VLAN, kids VLAN, corp VLAN — each is a CIDR that wants its own profile.
  • You don’t want to enumerate every host. A /24 of unknown printers and tablets is fine left unmapped if a subnet binds the whole range to a sensible profile.
  • A whole address range needs a different default. A test rack on 10.99.0.0/16, a remote-office /22, a Tailscale CGNAT range.
  • You’re carving a narrow exception out of a broader range. Two subnets at different prefix lengths: the longer prefix wins where they overlap.

You don’t reach for [[subnets]] when you want to:

  • Restrict who the daemon answers. That’s [server].allow_from — a separate, daemon-wide CIDR ACL. A source outside allow_from gets refused before any subnet is consulted. See security notes.
  • Bind a profile to a single host. Use [[devices]] — it’s at level 1 of the resolver chain (subnets are at level 4) and carries per-device overlay rules.
  • Express schedule-driven profile swaps. Those live on [[schedules]], not on subnets.

Schema

A subnet is an array-of-tables row[[subnets]], repeatable. Each row needs an id, a display name, at least one CIDR, and a profile to bind to.

toml
[[subnets]]
id           = "vlan-iot"
display_name = "IoT VLAN"
cidrs        = ["10.10.50.0/24"]
profile      = "iot-strict"
priority     = 100

Dimensions

A subnet is a small, deliberately closed vector: just identity, address coverage, and profile binding. The schema enforces #[serde(deny_unknown_fields)] — there is no notes, no description, no per-subnet ACL, no per-subnet MAC enforcement. If you reach for one of those, it lives elsewhere (admin rules, server globals, devices).

GroupDimensionTOML fieldWhat it tunes
IdentityididStable cross-reference. CLI verbs and audit log target the subnet by this id. Charset [a-z0-9-], ≤64 chars, no leading/trailing -.
Display namedisplay_nameOperator-facing label rendered in warden subnet list / show and the TUI master list. Must not be empty.
Address coverageCIDR listcidrsOne or more CIDR blocks. IPv4 (10.0.0.0/8), IPv6 (fd00::/8), and mixed in the same row are all accepted. A bare address parses as /32 (v4) or /128 (v6). Must be non-empty.
Profile bindingBound profileprofileProfile id applied to source IPs that fall in any cidrs block and aren’t otherwise mapped at levels 1–3. Cross-ref-checked at config load.
Tie-breakPrioritypriorityDefaults to 0. Used to break ties when two subnets share the same longest prefix length; higher priority wins, then smallest id ASCII. Cannot override longest-prefix matching — a /24 always wins over a /16 regardless of priority.
Cross-reference (external)Profile this subnet binds to[profiles.<id>]The bound profile is referenced; the subnet doesn’t reference back. Removing a referenced profile is refused unless every referencing entity is reassigned first.

Things that look like subnet dimensions but aren’t:

  • Network ACL (which sources the daemon answers at all) is [server].allow_from, not a per-subnet field. Empty allow_from means “no ACL” and is refused by the validator on production builds — it would expose an open resolver.
  • MAC enforcement is [server].enforce_device_mac, daemon-wide. Subnets don’t carry per-row enforcement flags. (Legacy alias enforce_client_mac still parses with a deprecation note.)
  • Per-subnet block response or TTL — those live on the profile the subnet binds to.
  • Description / notes — not on the schema. The closed deny_unknown_fields rejects them. Tags, by contrast, are a subnet field — see the row below.

Field reference

FieldTypeRequiredDefaultPurpose
idstring, [a-z0-9-], ≤64 charsyesStable cross-reference.
display_namestring, non-emptyyesOperator-facing label.
cidrsarray of CIDR strings, non-emptyyesIPv4 and/or IPv6 blocks; bare addresses accepted as /32 or /128.
profileprofile idyesBound profile; must exist in [profiles.*].
priorityi32no0Tie-breaker at equal prefix length. Higher wins.
tagslist of tag slugs, ^[a-z][a-z0-9-]{0,31}$no[]Subject areas contributed to sources inside cidrs that have no [[devices]] row. A source with an explicit device row takes device.tags ∪ profile.tags ∪ group.tags and the subnet contributes nothing, even when its IP falls inside the range.

#[serde(deny_unknown_fields)] is on. The legacy enforce_client_mac field on [server] is still accepted via a serde alias (renamed to enforce_device_mac); it is not a per-subnet field. Source: src/config/schema/subnet.rs:35-55, src/config/schema/mod.rs:101-136.

Subnet tags have a first-class writer: warden subnet tag add|remove <id> <tag> (idempotent — re-adding a tag the subnet already carries, or removing one it doesn’t, prints a no-op line and skips the write; the slug is validated at the CLI boundary; a real change triggers a hot reload). What warden subnet set does not touch is tags — it accepts only display_name, profile, priority, and cidrs. See tags for the full model and profiles for how the intersection decides which blocklists apply.

CIDR input — strict and friendly forms

The TOML on disk always carries canonical CIDRs (network/prefix). The CLI and TUI accept a friendly superset at input time, normalised to canonical form before being written:

Input formExampleStored asNotes
Plain CIDR10.14.0.0/24, 2001:db8::/32passthroughIPv4 and IPv6.
Bare address10.14.0.5, ::110.14.0.5/32, ::1/128Both families.
Wildcard suffix10.14.0.*, 10.14.*.*, 10.*.*.*/24, /16, /8IPv4 only.
CIDR-aligned range10.14.0.0-10.14.0.25510.14.0.0/24IPv4 only. Size must be a power of two and start aligned to the resulting prefix boundary.

Friendly forms that are rejected with descriptive errors: non-contiguous wildcards (10.*.0.*), wrong octet count, all-wildcards, mixed wildcard + range, misaligned ranges (10.14.0.5-10.14.0.30), inverted ranges, IPv6 with wildcards. The on-disk TOML always carries the canonical strict form. Source: src/config/cidr.rs:37-313.

Address-family matching

A v4 CIDR will not match a v6 source IP, and vice versa — there is no IPv4-mapped-IPv6 special-casing (::ffff:10.0.0.1 is treated as v6 only). If you need both families covered by the same subnet, list both CIDRs in cidrs:

toml
[[subnets]]
id           = "corp"
display_name = "Corp"
cidrs        = ["10.0.0.0/8", "fd00::/8"]
profile      = "corp"

Source: src/config/cidr.rs:148-162, schema test multiple_cidrs_ok at src/config/schema/subnet.rs:60-74.

Examples

A home LAN catch-all plus an IoT exception — the longer prefix wins where they overlap:

/etc/purge-warden/subnets.d/home.toml
toml
[[subnets]]
id           = "lan-home"
display_name = "Home LAN (catch-all)"
cidrs        = ["10.10.0.0/16"]
profile      = "default"
priority     = 0

[[subnets]]
id           = "vlan-iot"
display_name = "IoT VLAN"
cidrs        = ["10.10.50.0/24"]
profile      = "iot-strict"
priority     = 100

A client at 10.10.50.42 matches both rows. vlan-iot wins because /24 is more specific than /16. The priority numbers don’t decide this match — they would only matter if two subnets shared the same longest prefix.

A multi-range guest VLAN on two adjacent /24s:

/etc/purge-warden/subnets.d/guest.toml
toml
[[subnets]]
id           = "vlan-guest"
display_name = "Guest Wi-Fi"
cidrs        = ["10.10.200.0/24", "10.10.201.0/24"]
profile      = "guest"

A broad rule with a narrow exception, useful for taming a noisy upstream block:

/etc/purge-warden/subnets.d/ranges.toml
toml
[[subnets]]
id           = "range-74"
display_name = "Range 74.x — default"
cidrs        = ["74.0.0.0/8"]
profile      = "range-default"

[[subnets]]
id           = "range-74-28"
display_name = "74.28.x — exception"
cidrs        = ["74.28.0.0/16"]
profile      = "range-exception"

A dual-stack corporate subnet covering both IPv4 and IPv6:

/etc/purge-warden/subnets.d/corp.toml
toml
[[subnets]]
id           = "corp"
display_name = "Corp"
cidrs        = ["10.0.0.0/8", "fd00::/8"]
profile      = "corp"
priority     = 5
Tip
For more than a handful of subnets, split them across subnets.d/<theme>.toml (home.toml, guest.toml, office.toml). The master config.toml pulls them via includes. CLI verbs accept --into <file> to route writes to the right fragment.

CLI

Every mutating subcommand triggers a hot reload (atomic config swap, no daemon restart). Read-only subcommands work without the daemon.

CommandWhat it does
warden subnet listOne line per subnet: <id> "<display_name>" cidrs=[...] profile=<id> priority=<n>. Empty list prints a hint with the add syntax.
warden subnet show <id>Field-by-field detail dump. Errors subnet not found: <id> if missing.
warden subnet add <id> --cidrs <csv> --profile <id> [--display-name --priority --into <file>]Add a new subnet. CIDRs are comma-separated, accept strict and friendly forms, are canonicalised to strict on write. Refuses if the id already exists, if --cidrs is empty, or if the referenced profile is undefined.
warden subnet set <id> <field> <value>Mutate one field. Supported: display_name, profile, priority, cidrs (comma-separated). Not tags — those have their own tag verb. Cross-reference checks happen on the post-write reload.
warden subnet tag add|remove <id> <tag> [--into <file>]Add or remove a subject-area tag on the subnet. Idempotent — re-adding an existing tag or removing an absent one prints a no-op line and skips the write. The slug is validated at the CLI boundary; a real change triggers a hot reload.
warden subnet remove <id> [--into <file>]Drop the entry. Errors subnet "<id>" not found in <path> with a hint to use --into if it lives in another fragment.
warden subnet allow <subnet-or-cidr> <domain> [--id <rule_id>] [--remove] [--into <file>]Add a domain to the profile the subnet binds to. The first arg accepts either the subnet id or any CIDR string in its cidrs list. If a CIDR string matches more than one subnet, the CLI errors and tells you to use the id.
warden subnet deny <subnet-or-cidr> <domain> [...same flags]Symmetric for deny.
warden resolve <ip>Read-only. Walks the 5-level chain for a source IP and prints which level matched, including Via subnet: <id> when level 4 fires. The fastest way to debug a wrong-profile mystery.

Common patterns:

bash
# Add an IoT VLAN, bound to a strict profile
sudo warden subnet add vlan-iot \
  --cidrs       10.10.50.0/24 \
  --profile     iot-strict \
  --display-name "IoT VLAN" \
  --priority    100

# Friendly input — written canonically to disk
sudo warden subnet add lan-guest --cidrs 10.10.1.200-10.10.1.207 --profile guest
# stored as 10.10.1.200/29

# Replace the CIDR list (keeps id + profile + priority)
sudo warden subnet set vlan-iot cidrs 10.10.50.0/24,10.10.51.0/24

# Repoint a subnet to a different profile
sudo warden subnet set vlan-iot profile iot-strictest

# Inspect what would happen for a given source — without touching the daemon
warden resolve 10.10.50.42
# → Match level: 4 (subnet longest-prefix)
# → Via subnet: vlan-iot
# → Active profile: iot-strict

# Block a single domain across every device that matches the subnet's CIDRs
sudo warden subnet deny vlan-guest gambling.example

After every hot-reload-aware mutation, exactly one of four messages is printed — the same set used by every CLI write path:

  • daemon reloaded — change is live
  • daemon not running — change will take effect on next start
  • note: change landed on disk but no admin token is available to request a daemon reload. Run warden token generate or restart the daemon to activate.
  • warning: change landed on disk but the daemon rejected the reload (<msg>). Check journalctl -u purge-warden and consider systemctl restart purge-warden.

Source: src/cli/mod.rs:162-167, 674-741, src/cli/commands/subnets.rs, src/cli/commands/rules.rs:645-731.

TUI

The Subnets tab lives under the Network section. Reach it with g s from anywhere, or with the section number key (4 from the top-level tab bar). Layout is master / detail (the detail card collapses below 110 columns).

ElementWhat it showsUseful keys
Master list (left)Configured [[subnets]] first, then auto-discovered candidate buckets tagged [suggested]. Buckets group unmapped IPs into /24 (v4) or /64 (v6) and surface ranges with ≥2 distinct hosts, sorted populous-first.j / k / / scroll
Detail card (right)24h hourly traffic linechart, 7-metric KV panel (profile, source file, online + total devices, queries today + 1h, blocked count + percent, top vendor), per-subnet client list.
Add modalEmpty form for a new subnet.a open; Tab / Shift-Tab move between fields; / cycle the profile dropdown; Enter on Submit applies.
Edit modalPre-filled with the focused configured subnet.e open; same field navigation.
Delete modalSingle-key y / n confirm — subnets are scope-narrow, no typed phrase required.d / Delete open.
Promote-from-suggestionEnter on a [suggested] candidate row opens Add pre-filled with the canonical CIDR and a synthesised display name (lan-10-10-50 for v4, lan6-2001-db8-0-0 for v6). The id stays empty so you choose deliberately.Enter on a candidate row.

Modals share the same validate-or-revert + TOCTOU + atomic-write core as the CLI — submitting a TUI form runs the same add_inner / set_inner / remove_inner paths as warden subnet add / set / remove. Source: src/tui/tabs/subnets.rs, src/tui/subnet_modal.rs, src/tui/mod.rs:519-562.

The Resolver modal (reachable from any leaf with s) is the fastest way to confirm “why did this client get that profile?”. For a source IP it prints the same 5-level chain as warden resolve, with Via subnet: <id> when level 4 fires.

REST API

No subnet routes. The REST surface (/api/*) does not expose any subnet read or mutation endpoint — subnet does not appear in src/api/routes.rs or src/api/handlers.rs. Subnet authoring runs through the CLI (or TUI), which writes the TOML and triggers a generic Reload over IPC.

IPC

The Unix socket has no subnet-specific verb either. Subnet edits flow through the same path as every other config change: CLI write → validate_or_revertIpcCommand::Reload { token } → daemon picks up the new [[subnets]] from disk on the next reload. Source: src/ipc/protocol.rs.

Decision precedence

A subnet contributes to the filter only as level 4 of the resolver chain. The full chain (documented under profiles):

  1. Active schedule that targets the matched device or its group.
  2. Direct device profile ([[devices]].profile).
  3. Highest-priority group the device belongs to.
  4. Longest-prefix subnet match. ← this page.
  5. [server].default_profile. If unset, the answer is REFUSED.

Inside level 4 — longest-prefix match

The resolver builds a sorted list of every CIDR across every subnet — sorted by prefix length DESC, then priority DESC, then id ASC — and walks it linearly. The first match wins. So:

  • A /24 always beats a /16 regardless of priority. Longest prefix is sovereign.
  • Two subnets at the same prefix length: the higher priority wins. Equal priorities: the lexicographically smaller id wins. The validator does not flag overlapping CIDRs at equal prefix — the deterministic sort silently picks one.
  • A v4 CIDR will not match a v6 source IP, and vice versa.
  • The match attribution is recorded as Via subnet: <id>, exposed by warden resolve and the TUI Resolver modal.

Source: src/profiles/resolver.rs:456-476, 859-902.

MAC-mismatch downgrade

When [server].enforce_device_mac = true (default) and the matched device pins a MAC, the resolver consults the live ARP snapshot before returning. If ARP shows a different MAC than the pinned one, the device is downgraded: levels 1, 2, 3 are skipped and resolution continues at level 4 (subnet).

A device under MAC suspicion loses its direct, group, and schedule overrides — but is not auto-blocked. The operator decides what level 4 / 5 catches it. The audit log records the downgrade as a tracing::warn!(target = "audit", ...) line: MAC mismatch — dropping device / group / schedule levels, falling through to subnet / default. Source: src/profiles/resolver.rs:343-376.

No subnet match and default_profile unset

Level 5 returns REFUSED — the resolver yields no profile and the daemon answers RCODE=REFUSED to every query from that source. Symptom: a guest laptop on the LAN gets refused for every name. warden resolve <ip> exits with code 1 and prints:

text
Match level: <REFUSED — no level 1-5 matched>

This source would be REFUSED by the daemon.
To resolve it, either map the IP to a [[devices]] row, wire a [[subnets]] that
covers it, or set server.default_profile to a fallback profile.

This is intentional — the SN3 retirement of block_unmapped_clients expresses “deny unmapped clients” via this very path. Source: src/profiles/resolver.rs:478-491, src/cli/commands/resolve.rs:90-96.

priority is mostly informational

The schema doc comment calls priority “informational only,” and that’s true in the broad sense: it cannot override longest-prefix matching. But it does silently break ties when two subnets cover overlapping CIDRs at the same prefix length. If your intent is “subnet A wins over subnet B for 10.10.50.0/24,” prefer to make the prefixes different — split A into 10.10.50.0/25 + 10.10.50.128/25 if you must — rather than relying on priority. The intent is more obvious to the next operator.

Security notes

Subnets are NOT the network ACL

This is the single most important distinction on this page. Two separate concepts:

  • [server].allow_from is a flat Vec<CIDR> of source ranges the daemon will answer at all. A source outside allow_from is refused before the resolver even runs. This is your network ACL.
  • [[subnets]] decides which profile an answered query gets. It runs only after allow_from accepts the source.

If a guest-VLAN client gets refused with no log of a subnet match, the cause is almost always allow_from, not [[subnets]]. The handler increments tracking.engine.refused_acl for ACL refusals; subnet misses don’t show up in the ACL counter at all because they happen later in the pipeline. Source: src/config/schema/mod.rs:120-123, src/cli/commands/start.rs:539-577, src/tracking/engine.rs:32.

Source-IP identity trusts the L2/L3 boundary

A subnet binding assumes the network boundary (VLAN, switch ACL, 802.1X) is enforced upstream. If layer-2 is open, anyone on the LAN can assert any IP, and a subnet-based identity is no stronger than the IP they claim. Verbatim from the upstream threat model:

MAC address spoofing on the LAN — any device on the same layer-2 segment can present any MAC. MAC-based identity in purge-warden is ergonomic, not security.

IP spoofing on a flat LAN — similar concern. Subnet-based identity assumes the network boundary (VLAN, switch ACL, 802.1X) is enforced upstream. If layer-2 is open, subnet identity is assertable by anyone on the L2.

The MAC-mismatch downgrade above is part of this story: it’s defence in depth, not authentication. Don’t rely on subnet-based profile binding for real isolation between trust tiers — use VLANs and per-VLAN bind addresses for that. Source: DOCUMENTATION.md:2467-2468.

These are perfectly fine in [[subnets]].cidrsfd00::/8, fe80::/10, ::1/128 all parse and match. The reserved-host SSRF guard that refuses [[blocklists]].url = "http://10.0.0.5/foo" is a separate sandbox for blocklist URLs and does not apply to subnet CIDRs. Subnet CIDRs are about who you map, not who you fetch from. Source: src/config/cidr.rs:382-406.

Audit log

Subnet mutations land on the audit log alongside every other config change:

  • warden subnet add / set / remove each write a subnet.add / subnet.set / subnet.remove CliMutation record with scope = "subnet" (src/cli/commands/subnets.rs:196-200, 246-250, 298-302); the accepted reload also lands a Reload row with a new post_hash — every edit is recoverable from the audit trail.
  • warden subnet allow / deny records scope = "subnet", target_id = "<subnet-id-or-cidr>", action = "rule.add" / "rule.remove" — the same shape as profile-scoped allow/deny.
  • warden subnet tag add / remove records a subnet.tag_add / subnet.tag_remove audit action — tag edits are on the trail, a raw TOML hand-edit is not.
  • The MAC-mismatch downgrade emits a tracing::warn!(target = "audit", ...) line.

warden audit tail [-n N] prints recent audit lines. The audit log path is /var/lib/purge-warden/audit/audit.log. Source: src/config/audit.rs:81, 194-201.

RRL is unrelated

The runtime tracks queries-per-source in fixed /24 (v4) and /48 (v6) buckets for amplification mitigation. Those are hard-coded prefix lengths, not your configured [[subnets]]. Don’t try to tune RRL by editing subnet rows. Source: src/security/rrl.rs:3.

Troubleshooting

A reload fails with subnets[i] "<id>".profile "<p>" is not defined. The subnet binds to a profile that doesn’t exist. The validator refuses the reload and the daemon stays on the previous good config. Either create the profile first (warden profile create <p> ...) or fix the typo (warden subnet set <id> profile <correct>).

A reload fails with subnets[i] "<id>".cidrs[j] "<c>" is not a valid CIDR. Hand-edit drift. The CLI canonicalises on write, so this almost always comes from a manual edit. Run warden config lint to see every diagnostic at once.

A reload fails with subnets[i] "<id>".cidrs must not be empty. A cidrs = [] row has no purpose. warden subnet remove <id> is the right move.

warden subnet set <id> cidrs "" is rejected with cidrs cannot be empty — remove the subnet instead. The CLI refuses to write an empty cidrs. Either pass a non-empty list or remove the row.

warden subnet add <id> says subnet "<id>" already exists. Either re-edit the existing one (warden subnet set ...) or pick a new id. There’s a TOCTOU re-check on the post-write path that catches concurrent same-id adds, with the same error string.

warden subnet allow <CIDR> <domain> errors CIDR "<c>" matches N subnets. Two or more subnets list the same CIDR string in their cidrs arrays. The CLI refuses to guess. Pass the subnet id instead of the CIDR string.

A device gets the wrong profile — you expected level 3 (group), got level 4 (subnet). Run warden resolve <ip>. The output names the matched level, the via-id, and the active profile. The two common culprits:

  • The device row carries no group with a profile set, so level 3 returns nothing and level 4 fires next. Fix: set a profile on the group, or assign one directly on the device.
  • The device was MAC-downgraded. The audit log carries the warn line MAC mismatch — dropping device / group / schedule levels, falling through to subnet / default. Either fix the pinned MAC or accept the subnet fallback as the policy under suspicion.

warden subnet remove <id> says subnet "<id>" not found in <path>. The entry lives in a different fragment. Pass --into <path> pointing at the right file, or grep for it:

bash
grep -RnE 'id\s*=\s*"<id>"' /etc/purge-warden/subnets.d

An unmapped guest laptop gets RCODE=REFUSED for every name. No level 1–4 matched and [server].default_profile is unset. Either wire a [[subnets]] that covers the guest range with a sensible profile, set default_profile to a fallback, or accept the policy (it does prevent the daemon from forwarding for strangers).

Two subnets overlap and the wrong one keeps winning. Same prefix length → check priority and id ordering. Different prefix lengths → the longer prefix is winning, which is the design. If you actually want a broad rule to override a narrow one, the broad rule needs a longer prefix to do it — restructure the CIDRs rather than fight the resolver.

For more, see troubleshooting.

See also

  • Profiles — what a subnet binds to; the 5-level resolver chain in full.
  • Devices — level 1 of the chain; the way to pin a single host above the subnet level.
  • Groups — level 3; named bundles of devices that share a profile.
  • Schedules — level 2 (active schedule); time-based profile swaps that override device / subnet binding inside a window.
  • Server globals[server].allow_from (the network ACL), default_profile (level 5), enforce_device_mac (the MAC-mismatch downgrade trigger).
  • TOML reference — every field, every constraint.
  • CLI reference — the full warden subnet … and warden resolve surfaces.