Tags
Tags are the only thing connecting a blocklist to a device. Both sides declare subject areas; Warden intersects them.
What this is
A tag is a short kebab-case slug — ads, tracking, adult-content, iot-telemetry — that names a subject area. Blocklists declare which subject areas they cover. Devices, groups, profiles, and subnets declare which subject areas they want filtered. A list applies to a query when the two sets overlap.
There is no [[tags]] table. A tag has no lifecycle, no registration, no row anywhere in the config. It exists by being written into some entity’s tags array, and it stops existing when the last reference goes. Everything warden tags does is read, rewrite, or validate references — never create a record.
Five entities carry a tags field:
| Entity | Side | Field |
|---|---|---|
[[blocklists]] | supply — “this feed covers these areas” | tags |
[profiles.<id>] | demand — “anything on this policy filters these areas” | tags |
[[devices]] | demand — per-host addition | tags |
[[groups]] | demand — every member device inherits these | tags |
[[subnets]] | demand — for sources with no device row | tags |
Groups carry a tags field too (tag_model_consolidation §3.1): the tags on a [[groups]] entry are added to every member device’s effective set — a union that only ever widens which blocklists apply. See groups.
The model
Two formulas, and they are not cumulative — the branch depends on whether Warden recognises the source.
effective_tags = device.tags ∪ profile.tags ∪ group.tags source has a [[devices]] row
= profile.tags ∪ subnet.tags anonymous source, no device row
group.tags = union across every group the device belongs to
a blocklist applies <=> blocklist.tags ∩ effective_tags is non-emptyA source with an explicit [[devices]] row takes its own tags plus the resolved profile’s plus the tags of every group it belongs to, and the subnet contributes nothing — even when the IP sits inside a configured CIDR. Subnet tags exist to give a sensible policy to hosts Warden has never been introduced to. Source: src/profiles/profile.rs:184-212.
Three properties follow, and they explain most of the surprises:
- Either side empty means the list never applies. The intersection short-circuits on emptiness before anything else is checked. A profile with
tags = []filters nothing; a blocklist withtags = []is inert. - Adding a feed never touches a profile. Tag a new list
adsand every profile already asking foradspicks it up at the next reload. This is the whole point of the indirection. - A typo is not an error. Tags have no registry, so
tracknigis a perfectly valid slug that happens to intersect nothing. Warden emits a warning and keeps serving. See troubleshooting.
List state gates the intersection
Passing the tag check is necessary, not sufficient. The list must also be usable:
| List status | Applies? |
|---|---|
Active | Yes — cache is fresh. |
Failed, with a previous successful download | Yes — the stale cache keeps protecting rather than silently opening a hole. |
Failed, never succeeded | No — there is nothing to enforce. |
Pending (first download in flight) | No. |
| No state entry yet | Yes — the daemon stays online while the state file populates. |
A list with enabled = false is out of the picture entirely. Source: src/profiles/profile.rs:448-478 (list_applies), src/config/list_state.rs:40-50.
Worked example
Given this config, laptop-anna filters oisd-basic and urlhaus but not adult-domains, while kids-tablet filters all three.
[[blocklists]]
id = "oisd-basic"
tags = ["ads", "tracking"]
[[blocklists]]
id = "urlhaus"
tags = ["malicious"]
[[blocklists]]
id = "adult-domains"
tags = ["adult-content"]
[profiles.default]
tags = ["ads", "tracking", "malicious"]
[profiles.kids]
tags = ["ads", "tracking", "malicious", "adult-content"]
[[devices]]
id = "laptop-anna"
profile = "default"
[[devices]]
id = "kids-tablet"
profile = "kids"Walk it for laptop-anna: no device tags, profile contributes {ads, tracking, malicious}. oisd-basic intersects on ads, urlhaus on malicious, adult-domains on nothing. Done.
When you reach for it
Name subject areas, not lists and not people.
- Good:
ads,tracking,malicious,adult-content,gambling,iot-telemetry,social. - Bad:
oisd,list-3,annas-tablet,kids-profile. The first two mirror a list id into a tag, which reintroduces exactly the coupling tags exist to remove. The last two encode who into a layer that answers what.
Add a tag when you want a subject area you could switch on or off independently for some subset of the network. If two areas will always travel together for every profile you will ever write, one tag is enough — you can always split later with warden tags rename plus a second blocklists tag add.
Reach for a device tag when one host needs an extra area that its profile shouldn’t grant to everyone else on the same policy. Reach for a group tag when a set of devices should share an extra area without baking it into their profile. Reach for a subnet tag when a whole VLAN of unknown hosts should get a baseline. Reach for a profile tag for everything else — that’s the normal home.
Schema
Slug grammar: ^[a-z][a-z0-9-]{0,31}$. Lowercase-letter-led, then lowercase letters, digits, and hyphens; no trailing hyphen; 1 to 32 bytes. Stricter than an entity Id on purpose — tags appear inline in TOML arrays, and short letter-led slugs read better in dense diffs. Source: src/config/schema/tag.rs.
# Supply side
[[blocklists]]
id = "oisd-basic"
url = "https://big.oisd.nl/domainswild"
kind = "deny"
tags = ["ads", "tracking"]
# Demand side — profile
[profiles.default]
display_name = "Home default"
tags = ["ads", "tracking", "malicious"]
# Demand side — one host needs more
[[devices]]
id = "dad-laptop"
profile = "default"
tags = ["gambling"]
# Demand side — unknown hosts on the guest VLAN
[[subnets]]
id = "vlan-guest"
cidrs = ["10.10.20.0/24"]
profile = "guest"
tags = ["ads", "malicious", "adult-content"]The uncategorized sentinel
One slug is system-reserved: uncategorized. It cannot be renamed or deleted — CLI and TUI both refuse.
It exists so a deny list is never silently inert. Any kind = "deny" blocklist whose tags array is empty is auto-promoted to tags = ["uncategorized"] when the validator runs, with a line on the audit channel. A list you added in a hurry still lands somewhere visible instead of quietly filtering nothing.
Allow-direction lists get the opposite treatment: kind = "allow" with empty tags stays empty, and the validator warns that the list has no effect. Auto-granting an arbitrary allowlist to every device on the network would be a security hole, so that promotion is deliberately not symmetric. Source: src/config/schema/validator.rs:1281-1293 (auto_promote_blocklists, deny-side), :2709 (allow-side warning).
uncategorized always appears in warden tags list, even at zero usage, so you know it is there.
Migrating a pre-tag config
warden migrate converts the retired v1 model: every kind = "deny" list becomes tags = ["uncategorized"], every kind = "allow" list becomes tags = [], and profiles lose their blocklists / categories arrays. It writes a backup to <source-parent>/backups/pre-migration-<ts>.toml before touching anything, and validates the produced tree through the v1 loader before returning.
The result is a config that loads but filters coarsely — everything under one tag. Splitting uncategorized into real subject areas afterwards is manual work, and warden tags rename is the tool for it.
A profile still carrying blocklists is rejected, not migrated on the fly: deny_unknown_fields is on. See profiles.
Mutual exclusion with unfiltered
A device with unfiltered = true must have tags = []. The validator refuses a config that violates the mutex:
Device 'guest-laptop' has unfiltered=true but tags is non-empty — these are mutually exclusive. Either drop unfiltered or clear tags.
warden device tag add refuses on an unfiltered device, and warden device set-unfiltered <id> true clears the tags atomically. An unfiltered device still resolves DNS, still appears in the query log, and still counts in stats — it just skips filtering, so it looks perfectly healthy while enforcing nothing.
Examples
Splitting one area into two. You started with social covering everything and now want the marketing workstation to keep LinkedIn while the rest of the office doesn’t:
# See what you have before touching anything
warden tags list
# Narrow the existing tag, then add the new one to the list that needs it
sudo warden tags rename social social-general
sudo warden blocklist tag add linkedin-domains social-linkedinThen grant the narrower area only where it belongs:
[profiles.staff]
display_name = "Staff"
tags = ["ads", "tracking", "malicious", "social-general", "social-linkedin"]
[profiles.marketing]
display_name = "Marketing"
tags = ["ads", "tracking", "malicious", "social-general"]Adding a feed with zero profile edits. A new malware feed should reach every profile that already asks for malicious. Tag it at creation with --tag, which is repeatable:
sudo warden blocklist add threatfox \
--url https://threatfox.abuse.ch/downloads/hostfile/ \
--tag maliciousNo profile was touched. Every profile carrying malicious picks the feed up on the reload that command triggers. Omitting --tag on a deny list is not fatal — the validator auto-promotes it to uncategorized — but it means the list reaches only whoever asked for uncategorized.
For a list that already exists, warden blocklist tag add is the equivalent. To flip a list between directions, use warden blocklist set-kind <id> deny|allow — there is no --kind flag on add.
Giving one host an extra area. The exception belongs on the device, not in a new profile:
sudo warden device tag add dad-laptop gamblingCLI
The tag verbs are split across three subcommands — one per side of the model, plus a cross-cutting one.
warden tags — cross-cutting
| Command | What it does |
|---|---|
warden tags list [--json] | Every slug in use, with per-entity counts. uncategorized always shown. |
warden tags check <slug> | Persists nothing. Validates the slug against the regex and prints the command to run next. (create is a kept alias.) |
warden tags rename <old> <new> | Rewrites every tags array across the whole include graph. Refuses system slugs, and refuses renaming to one. |
warden tags delete <slug> | Refuses if referenced, naming the counts. Zero usage is a silent success. |
warden tags list prints a fixed-width table:
TAG BLOCKLISTS DEVICES PROFILES SUBNETS NOTE adult-content 1 0 1 1 ads 2 0 3 1 malicious 2 1 3 1 tracknig 0 0 1 uncategorized 0 0 0 0 [system]
The NOTE column carries [system] for uncategorized and (unused) for a slug at zero total. A row with a count of 1 and nothing on the other side — tracknig above — is almost always a typo.
Group usage is not broken out in this table — the columns are blocklists, devices, profiles, and subnets — even though [[groups]] do carry a tags field. Read a group’s tags directly with warden group show <id>.
warden blocklist tag — supply side
| Command | What it does |
|---|---|
warden blocklist tag add <list-id> <tag> [--into <file>] | Idempotent. |
warden blocklist tag remove <list-id> <tag> [--into <file>] | Idempotent. |
warden device tag — demand side, per host
| Command | What it does |
|---|---|
warden device tag add <id> <tag> [--into <file>] | Idempotent. Refused when the device has unfiltered = true. |
warden device tag remove <id> <tag> [--into <file>] | Idempotent. |
warden profile tag / warden subnet tag / warden group tag — demand side
Every demand-side entity has its own tag writer, the same add|remove <id> <tag> shape as the supply side:
| Command | What it does |
|---|---|
warden profile tag add|remove <id> <tag> [--into <file>] | Idempotent. Writes profile.tags; slug validated at the CLI boundary; triggers a hot reload. |
warden subnet tag add|remove <id> <tag> [--into <file>] | Idempotent. Writes subnet.tags — which reach only device-less sources inside the subnet’s CIDRs. |
warden group tag add|remove <id> <tag> [--into <file>] | Idempotent. Writes group.tags; every member device inherits the tag (a union). |
The IPC layer backs the profile path — ProfileUpdatePatch carries a tags field (src/ipc/protocol.rs:407), so the TUI profile editor’s tags chip picker sets them too. A raw TOML hand-edit followed by warden reload still works, but is no longer the only way.
warden tags rename parses each affected file and serialises it back through toml::to_string_pretty, which does not preserve comment layout or field ordering — upstream documents that property of the same serialiser at src/config/writer.rs:25-33. Files the rename did not touch are left byte-identical.
Commit your config (or take a warden config backup) before a rename on a hand-annotated tree, and read the diff afterwards.
warden tags rename walks the actual include graph — every file the loader merged, not a hardcoded list of .d/ directories — so tagged entities living in an unconventional include are not left behind with the old slug. All rewritten files are validated together before any of them is promoted; if validation fails, none of them land. Source: src/cli/commands/tags.rs:230-330.
TUI
The Tags tab is a dedicated leaf listing every slug across blocklists, devices, profiles, and subnets with usage counts.
| Key | Action |
|---|---|
c | Create — same validate-and-guide behaviour as the CLI |
R | Rename (uppercase, to dodge the global r refresh) |
d | Delete unused |
/ | Search |
f | Filter |
Enter | Show members — which entities reference this tag |
uncategorized refuses both rename and delete here too.
Tags also surface on two other tabs. Devices separates each host’s own tags from those inherited via its profile, and flags UNFILTERED. Lists shows which profiles reach each blocklist through the intersection. Source: src/tui/ui.rs:636-655, src/tui/app.rs:138-142.
The Profiles tab edit modal carries a tags chip picker, so profile tags can be set from the TUI as well as with warden profile tag ….
How tags interact with the rest
- A device row suppresses the subnet contribution. Adding a
[[devices]]entry for a host that previously resolved through a subnet silently drops the subnet’s tags from its effective set. If filtering weakens right after you name a device, this is why. unfiltered = trueshort-circuits to an empty set before anything else runs, so no list applies regardless of what the profile declares.block_all = truemakes tags irrelevant. Under a deny-by-default profile, nothing reaches the list layer — only admin allow rules can forward. Akids-nightprofile does not need tags.local_recordsbypass the list layer entirely. A profile-scoped local record is answered before the filter runs, so tags never enter into it.- MAC enforcement changes the branch. A device downgraded by an ARP mismatch resolves at the subnet level, which means it also picks up the subnet’s tags instead of its own device tags.
For the full resolution order see decision precedence and filtering process.
Security notes
- A typo silently disables filtering, and only warns. Because tags have no registry, a mistyped slug is valid and intersects nothing. The daemon logs a WARN on the audit channel and keeps serving — it does not refuse the reload, because a tag staged for a list you have not added yet is legitimate. Treat those warnings as errors in practice.
- Allow-lists with no tags never apply, by design. Auto-promoting them the way deny lists are promoted would grant an arbitrary allowlist to the whole network. Decision D2.
- Deny lists cannot go silently inert. The
uncategorizedauto-promotion guarantees an untagged deny list still lands somewhere countable. - Tag operations are audited — through the verb path. The audit log carries
blocklist.tag_add/remove,device.tag_add/remove,profile.tag_add/remove,subnet.tag_add/remove,group.tag_add/remove,device.set_unfiltered, andtags.rename(scopeglobal, with the before and after slugs and the list of files touched). A raw TOML hand-edit of atagsarray leaves no trace — but the verbs above are no longer the missing half of the demand side, so change tags with them and the edit is on the trail. What stays unaudited is profile lifecycle —warden profile add/set/removeemit no audit action. Subnet and group lifecycle are audited (subnet.add/set/remove,group.add/set/remove). - Read the WARN lines after every tag change. They are the only signal that an intersection went empty.
Troubleshooting
Nothing is being filtered, but the tags look right. Almost always an empty intersection. Five warnings cover the cases, all on the audit channel and all non-blocking:
profile "kids" contributes no tags — devices using it rely entirely on device-level tags profile "kids" has tags but none match any enabled deny list device "anna-iphone" has tags but none match any enabled deny list — no list filtering applies device "anna-iphone" not filtered — no tags inherited and not explicitly unfiltered blocklist "oisd-basic" tags match no device, profile, or subnet — the list applies to nothing
journalctl -u purge-warden | grep -E 'no tags|match no|not filtered'
warden tags list
warden resolve 10.10.1.107warden tags delete is refused. The tag is still referenced. The error names the counts per entity kind:
Tag 'social' is still referenced by 1 blocklist(s), 2 profile(s). Remove these references first (e.g. warden blocklist tag remove <id> social, warden device tag remove <id> social, warden profile tag remove <id> social, warden subnet tag remove <id> social, warden group tag remove <id> social).
Clear every reference with the matching verb — warden blocklist tag remove, warden device tag remove, warden profile tag remove, warden subnet tag remove, warden group tag remove. None require hand-editing.
warden tags rename says the tag is not used anywhere. Tag 'x' is not used anywhere — nothing to rename. The slug is valid but appears in no tags array. Check warden tags list for the real spelling — this is the other face of the typo problem.
warden tags rename reflowed my config files. Expected on every file it touched; see the callout above. Untouched files are byte-identical. Restore comments from version control, or from warden config backup if you took one.
A device stopped filtering after I gave it a [[devices]] row. It was inheriting subnet tags. An explicit device row switches the formula to device.tags ∪ profile.tags ∪ group.tags, and the subnet stops contributing. Add the missing areas to the device, one of its groups, or its profile.
A reload fails with unknown field: categories or blocklists. The config still uses the retired v1 model. Run warden migrate, or convert by hand — see migrating a pre-tag config.
For more, see troubleshooting.
See also
- Profiles — the main demand-side home for tags.
- Blocklists — the supply side, and where
warden blocklist tagwrites. - Devices — per-host tags and the
unfilteredflag. - Subnets — tags for sources with no device row.
- Groups — their
tagsfeed every member device’s effective set. - Filtering process — where the intersection sits in the query path.
- TOML reference — every field, every constraint.
- CLI reference — the full
warden tags …surface.