Subnets
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
/24of 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 outsideallow_fromgets 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.
[[subnets]]
id = "vlan-iot"
display_name = "IoT VLAN"
cidrs = ["10.10.50.0/24"]
profile = "iot-strict"
priority = 100Dimensions
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).
| Group | Dimension | TOML field | What it tunes |
|---|---|---|---|
| Identity | id | id | Stable cross-reference. CLI verbs and audit log target the subnet by this id. Charset [a-z0-9-], ≤64 chars, no leading/trailing -. |
| Display name | display_name | Operator-facing label rendered in warden subnet list / show and the TUI master list. Must not be empty. | |
| Address coverage | CIDR list | cidrs | One 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 binding | Bound profile | profile | Profile 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-break | Priority | priority | Defaults 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. Emptyallow_frommeans “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 aliasenforce_client_macstill 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_fieldsrejects them. Tags, by contrast, are a subnet field — see the row below.
Field reference
| Field | Type | Required | Default | Purpose |
|---|---|---|---|---|
id | string, [a-z0-9-], ≤64 chars | yes | — | Stable cross-reference. |
display_name | string, non-empty | yes | — | Operator-facing label. |
cidrs | array of CIDR strings, non-empty | yes | — | IPv4 and/or IPv6 blocks; bare addresses accepted as /32 or /128. |
profile | profile id | yes | — | Bound profile; must exist in [profiles.*]. |
priority | i32 | no | 0 | Tie-breaker at equal prefix length. Higher wins. |
tags | list 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 form | Example | Stored as | Notes |
|---|---|---|---|
| Plain CIDR | 10.14.0.0/24, 2001:db8::/32 | passthrough | IPv4 and IPv6. |
| Bare address | 10.14.0.5, ::1 | 10.14.0.5/32, ::1/128 | Both families. |
| Wildcard suffix | 10.14.0.*, 10.14.*.*, 10.*.*.* | /24, /16, /8 | IPv4 only. |
| CIDR-aligned range | 10.14.0.0-10.14.0.255 | 10.14.0.0/24 | IPv4 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:
[[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:
[[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 = 100A 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:
[[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:
[[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:
[[subnets]]
id = "corp"
display_name = "Corp"
cidrs = ["10.0.0.0/8", "fd00::/8"]
profile = "corp"
priority = 5subnets.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.
| Command | What it does |
|---|---|
warden subnet list | One 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:
# 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.exampleAfter 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 livedaemon not running — change will take effect on next startnote: 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).
| Element | What it shows | Useful 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 modal | Empty form for a new subnet. | a open; Tab / Shift-Tab move between fields; ← / → cycle the profile dropdown; Enter on Submit applies. |
| Edit modal | Pre-filled with the focused configured subnet. | e open; same field navigation. |
| Delete modal | Single-key y / n confirm — subnets are scope-narrow, no typed phrase required. | d / Delete open. |
| Promote-from-suggestion | Enter 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_revert → IpcCommand::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):
- Active schedule that targets the matched device or its group.
- Direct device profile (
[[devices]].profile). - Highest-priority group the device belongs to.
- Longest-prefix subnet match. ← this page.
[server].default_profile. If unset, the answer isREFUSED.
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
/24always beats a/16regardless ofpriority. Longest prefix is sovereign. - Two subnets at the same prefix length: the higher
prioritywins. 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 bywarden resolveand 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:
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_fromis a flatVec<CIDR>of source ranges the daemon will answer at all. A source outsideallow_fromis refused before the resolver even runs. This is your network ACL.[[subnets]]decides which profile an answered query gets. It runs only afterallow_fromaccepts 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.
IPv6 link-local, ULA, loopback
These are perfectly fine in [[subnets]].cidrs — fd00::/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 / removeeach write asubnet.add/subnet.set/subnet.removeCliMutationrecord withscope = "subnet"(src/cli/commands/subnets.rs:196-200, 246-250, 298-302); the accepted reload also lands aReloadrow with a newpost_hash— every edit is recoverable from the audit trail.warden subnet allow / denyrecordsscope = "subnet",target_id = "<subnet-id-or-cidr>",action = "rule.add"/"rule.remove"— the same shape as profile-scoped allow/deny.warden subnet tag add / removerecords asubnet.tag_add/subnet.tag_removeaudit 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
profileset, 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:
grep -RnE 'id\s*=\s*"<id>"' /etc/purge-warden/subnets.dAn 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 …andwarden resolvesurfaces.