Profiles
A profile is the policy. Devices, groups, and subnets are how Warden decides which policy to apply.
What this is
A profile is a named bundle of filter behaviour: what happens to each list’s domains, which admin rules to layer on top, what shape the canned answer takes when a query is denied, and — optionally — static DNS records, name rewrites, SafeSearch, and an EDNS Client Subnet policy. Every entity that resolves to a filter decision — devices, groups, subnets, and the global default_profile — points at a profile by id.
A profile does not subscribe to blocklists. Every configured list already reaches every profile; what a profile controls is the direction each list takes for it — deny, allow, or ignore — written as a per-list override in its lists map. An id with no entry inherits that list’s own base. That override is the single most important thing on this page; everything else is detail.
This page covers when to add a [profiles.<id>] entry, every dimension you can tune on it, the CLI / TUI / API surfaces that read and write profiles, and how a profile’s contents interact with the per-device overlay. For the bigger picture of how a query gets matched to a profile in the first place, see filtering process and decision precedence below.
When you reach for it
Create one profile per filter behaviour you want to express, not one per device. A typical setup ends up with three to five:
- A
defaultprofile for anything on the LAN, overriding nothing — every list applies at its ownbase. - A
kidsprofile that flips an adult-content list fromignoretodeny, denies a few specific domains, turns on SafeSearch, and switches the block response tonxdomainso iOS apps stop retrying. - A
kids-nightprofile (block_all = trueplus a narrow admin allowlist), bound by a schedule between 21:00 and 07:00. - An
iotprofile that turns a manufacturer-telemetry list on for itself alone, with a fewlocal_recordspinned so the smart-TV apps stop calling home. - A
workprofile carved out of the kids profile with classroom domains added back.
You don’t need a separate profile for one-off exceptions. A single domain that should be blocked or allowed for one person belongs in [[devices]].allow_rules / deny_rules — see devices.
You do need a separate profile whenever an exception must not bleed. Profiles are flat — there is no extends, no inheritance, no merging of two profiles. warden group allow and warden group deny write to the group’s underlying profile, so the rule reaches everything else bound to that profile too.
Schema
A profile is a TOML map keyed by id — unlike [[devices]], [[blocklists]], or [[subnets]], profiles use [profiles.<id>], not array-of-tables. The id (the part after the dot) is what every other entity references.
[profiles.kids]
display_name = "Kids profile"
lists = { adult-content = "deny" }
block_response = "nxdomain"
blocked_ttl_secs = 300
admin_rules = ["kids-allow-classroom"]
block_all = false
safe_search = trueHow lists attach: the per-profile override
Every [[blocklists]] row carries a base — deny unless the row says otherwise. That base is what a profile gets by default. A profile changes it one list at a time.
effective(profile, list) = profile.lists[list.id] when the key is present
= list.base otherwiseThree directions, three meanings:
| Direction | What it means for this profile |
|---|---|
deny | the list’s domains are blocked |
allow | the list’s domains are permitted, ahead of every deny-direction list |
ignore | the list is not consulted at all |
Three consequences worth internalising:
- A new profile filters straight away. There is no subscription step. A profile with no
listskey inherits every list’sbase, andbaseisdenyunless you changed it. - Adding a list is a zero-touch operation on profiles. A new
[[blocklists]]row starts filtering for every profile on the next reload. You edit a profile only to carve out an exception. - A typo is an error, not a shrug. The keys of
listsare blocklist ids, cross-checked at load. A profile naming an id no[[blocklists]]row has is refused — see troubleshooting.
One implementation decides the pair, and the resolver, the validator, warden resolve, and warden blocklist list all read it: src/config/schema/blocklist.rs → effective_direction().
warden profile list-policy set <id> <list-id> <deny|allow|ignore> writes one override, clear drops the key, and show prints every list’s in-force direction and whether it was set here or inherited. clear is not set … ignore: ignore survives a later change to the list’s base, a cleared pair follows base wherever it goes. Both writes travel over IPC, so the daemon is the single writer for the CLI and the TUI alike.Where a list comes from, and where its base is set, is on Import lists.
Dimensions
A profile is a vector across these independent axes. Every dimension has a sensible default and is optional except the id.
| Group | Dimension | TOML field | What it tunes |
|---|---|---|---|
| Identity | id | (map key) | Stable cross-reference. Every device, group, subnet, and schedule that wants this profile names this id. |
| Display name | display_name | Operator-facing label shown in the TUI and CLI listings. | |
| List policy | Per-list direction override | lists | Maps a blocklist id to deny / allow / ignore. An id with no entry follows that list’s own base. |
| Custom rules | Admin-rule references | admin_rules | The only path for @@ allow, $important, and regex patterns — external blocklists are sandboxed away from those powers. |
| Wire-level response | Answer shape on a block | block_response | zero (default), nxdomain, refused, soa_nodata. See the table below. |
| TTL on the block answer | blocked_ttl_secs | Per-profile override; falls back to [server].default_blocked_ttl_secs. | |
| Mode | Deny-by-default switch | block_all | When true, every query is denied except those covered by an admin allow rule. |
| Static DNS | Per-profile records | local_records | A/AAAA/CNAME entries served before going upstream. Consulted before the global [local_dns] table. |
| Rewrites | Name-to-name redirects | rewrite_rules | Applied after the block check and before the upstream forward, so a rewrite cannot bypass filtering on the original name. |
| SafeSearch preset | safe_search | One bool; injects eight vendor-documented search-engine rewrites at resolve time. | |
| Upstream | ECS policy | [profiles.<id>.ecs] | Per-profile EDNS Client Subnet override. Gated by the [upstream.ecs].enabled master switch. |
| Schedule binding (external) | Schedules pointing at this profile | [[schedules]].profile | A schedule swaps a device’s or group’s profile to this one inside its window. |
| Resolver bindings (external) | Devices, groups, subnets, and [server].default_profile referencing this id | (other entities) | Profiles are referenced; they don’t reference back. Removing a referenced profile is refused. |
Things that look like profile dimensions but aren’t:
- Explicit blocklist subscriptions. Removed.
blocklistsandcategoriesstopped being fields, and thetagskey that briefly replaced them was retired in turn — all three are refused at load. A profile names a list only to override its direction. - Profile inheritance. Profiles are flat — there is no
extends. The resolver picks exactly one profile per query, and that profile’slistsmap is the whole list policy for the query. - Cache TTL is global (
[cache]) — only the block-response TTL is per-profile. - Upstream forwarder choice is global (
[[forwarding]]) — you can’t pin “kids uses Quad9, work uses Cloudflare”. ECS is the one upstream knob that is per-profile. - Per-profile rate limits — RRL is global (
[security]).
Field reference
| Field | Type | Required | Default | Purpose |
|---|---|---|---|---|
<id> (map key) | string, [a-z0-9-], 1–64 bytes | yes | — | Stable cross-reference. |
display_name | string | no | "" | TUI / CLI label. |
lists | map of blocklist id → deny | allow | ignore | no | {} | Per-list direction override. Every key must match a [[blocklists]] id. An absent id follows that list’s base. |
admin_rules | list of admin-rule ids | no | [] | Operator-curated rules; the only place @@, $important, and regex are honoured. |
block_response | enum | no | inherits [server].default_block_response | zero | nxdomain | refused | soa_nodata. |
blocked_ttl_secs | u32 (seconds) | no | inherits [server].default_blocked_ttl_secs | TTL stamped on the canned block answer. |
block_all | bool | no | false | When true, denies every query except admin-allow matches. |
local_records | list of LocalDnsRecord | no | [] | Per-profile A/AAAA/CNAME, with optional ttl_secs and match_subdomains. Consulted before the global table. |
rewrite_rules | list of RewriteRule | no | [] | Name-to-name rewrites, applied post-filter and pre-forward. |
safe_search | bool | no | false | Injects the curated search-engine rewrite set. |
ecs | sub-table | no | inherits [upstream.ecs] | mode (off | coarse | subnet), plus source_prefix_v4 / source_prefix_v6 under subnet. Each field falls back independently. |
#[serde(deny_unknown_fields)] is on — a typo fails at config load. So does every retired key: a profile carrying blocklists, categories, or tags is refused, not migrated in place. See troubleshooting for what to convert them to. Source: src/config/schema/profile.rs, src/config/schema/retired_keys.rs.
Block response variants
| TOML value | Wire effect | When to use |
|---|---|---|
zero (default) | 0.0.0.0 for A, :: for AAAA, NOERROR. | Fast client give-up. Browsers stop retrying within milliseconds. The right default. |
nxdomain | RCODE=NXDOMAIN. | Apps treat the name as missing and cache that aggressively. iOS apps especially stop retrying. Some stub resolvers fall through to a secondary DNS — bypassable in that case. |
refused | RCODE=REFUSED. | Stub resolvers fall through to the next configured DNS. Useful only when Warden is one of several upstreams on purpose. Always used by safety paths regardless of profile setting. |
soa_nodata | NOERROR + empty answer + authority SOA. | RFC 2308 negative-cache friendly. Best for noisy queriers (tablets, IoT) that honour the SOA minimum TTL and stop re-asking. |
All four are settable from the CLI, plus clear to fall back to the server global.
Examples
A household default — no override at all, so every list applies at its own base, with a fast give-up on a block:
[profiles.default]
display_name = "Home default"
block_response = "zero"The lists it filters with are declared on their own and ask the profile for nothing. The third is parked on ignore so it reaches only the profiles that opt in:
[[blocklists]]
id = "oisd-basic"
url = "https://big.oisd.nl/domainswild"
base = "deny"
[[blocklists]]
id = "urlhaus"
url = "https://urlhaus.abuse.ch/downloads/hostfile/"
base = "deny"
[[blocklists]]
id = "adult-content"
url = "https://lists.purge.cc/adult.txt"
base = "ignore"A kids profile — one list flipped from ignore to deny, NXDOMAIN to short-circuit retries, SafeSearch on, one school allow on top:
[profiles.kids]
display_name = "Bambini"
lists = { adult-content = "deny" }
admin_rules = ["kids-allow-classroom"]
block_response = "nxdomain"
blocked_ttl_secs = 300
safe_search = trueA kids-night profile bound to a schedule — deny everything except a narrow admin allowlist. The profile overrides no list at all: under block_all the list layer is irrelevant, because nothing reaches it. The local_records entry still resolves, because local records are answered before the filter runs.
[profiles.kids-night]
display_name = "Bambini, notte"
block_all = true
admin_rules = ["kids-night-bedtime-audio"]
[[profiles.kids-night.local_records]]
name = "homework.lan"
type = "A"
value = "10.10.1.20"[[schedules]]
id = "kids-bedtime"
target_type = "group"
target_id = "kids"
profile = "kids-night"
days = ["mon", "tue", "wed", "thu", "sun"]
start = "21:00"
end = "07:00"An IoT profile that turns a telemetry list on for itself alone, suppresses ECS so the vendor CDN learns nothing about the LAN, and pins one local override:
[profiles.iot]
display_name = "IoT"
lists = { telemetry = "deny" }
block_response = "soa_nodata"
[profiles.iot.ecs]
mode = "off"
[[profiles.iot.local_records]]
name = "samsungcloudsolution.com"
type = "A"
value = "0.0.0.0"
match_subdomains = trueprofiles.d/*.toml (one file per persona — default.toml, kids.toml, iot.toml, work.toml). The master config.toml pulls them in via includes. warden profile allow and warden profile deny take --into <file> to route the write to the right file.CLI
The subcommand is warden profile (singular). Every mutating verb triggers a hot reload of the running daemon (atomic config swap, no restart). Read-only verbs work without the daemon.
| Command | What it does |
|---|---|
warden profile list | List configured profiles with a one-line summary. |
warden profile show <id> | Full detail dump of one profile. |
warden profile create <id> --display-name <n> | Create a profile. Refuses if the id already exists. |
warden profile update <id> --display-name <n> | Change the label. |
warden profile block-response <id> <variant> | Set the wire-level block answer. Variants: zero | nxdomain | refused | soa_nodata | clear. |
warden profile blocked-ttl <id> <secs> | Set the block-answer TTL. 0 clears it (inherit the server global). |
warden profile block-all <id> <true|false> | Toggle deny-by-default. |
warden profile admin-rule-add <id> <rule_id> | Reference an existing [[admin_rules]] row. Create the row first with warden rules. |
warden profile admin-rule-remove <id> <rule_id> | Drop the reference. The rule row itself stays. |
warden profile list-policy set <id> <list-id> <deny|allow|ignore> | Write the per-profile direction override for one list. |
warden profile list-policy clear <id> <list-id> | Drop the key so the pair follows the list’s base again. Not the same as set … ignore. |
warden profile list-policy show <id> | Print the in-force direction for every configured list, and whether it was set here or inherited. |
warden profile allow <id> <domain> [--id <rule_id>] [--remove] [--into <file>] | Synthesise an `@@ |
warden profile deny <id> <domain> [...same flags] | Symmetric — synthesises ||domain^. |
warden profile ecs <id> --mode <m> [--prefix-v4 N] [--prefix-v6 N] | Set the per-profile ECS policy. Prefixes apply only under --mode subnet. |
warden profile ecs-clear <id> | Drop the [profiles.<id>.ecs] subtree so the profile inherits [upstream.ecs]. |
warden profile remove <id> | Delete the profile. Refused if any device, subnet, or schedule still references it. |
There is no verb for local_records or rewrite_rules — those are hand-edited TOML plus warden reload. There is no warden profile blocklists either; it was removed with the field it wrote to.
Common patterns:
# Turn one list off for the work profile, leaving every other profile alone
sudo warden profile list-policy set work oisd-basic ignore
# See every list's in-force direction for a profile, and where each came from
warden profile list-policy show kids
# Block a domain on the kids profile only
sudo warden profile deny kids tiktok.com
# Switch RCODE shape so iOS stubs stop falling through to a secondary
sudo warden profile block-response kids nxdomain
# Inspect what would happen for an IP, no daemon needed
warden resolve 10.10.1.107After 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:536-630, src/cli/commands/profiles.rs.
TUI
There is a dedicated Profiles tab — the default leaf of the Filters section (4, or g p), an offline-backed master/detail view over [profiles].
| Tab | Profile relevance | Reached by |
|---|---|---|
| Profiles | Master list plus detail card. Add / Edit / Delete drive the ProfileCreate / ProfileUpdate / ProfileDelete IPC verbs directly. | 4, g p |
| Lists | One row per [[blocklists]] entry. | g i |
| Custom Lists | The operator-owned rule files, and which profiles mount each one. | g t |
| Devices | Profile column per row, UNFILTERED flagged on the detail card. Group-by profile is one of the cycle options. | 3, g v |
| Local DNS | Two panels — the global table and the per-profile records. | g l |
| Rules | Per-profile and per-device admin rules. | g u |
The Resolver inspector takes a source IP and shows the full chain — matched device, match level, active profile, active schedule. It’s the fastest way to answer “why does this client get that profile?”.
Profile writes from the TUI take the same road as the CLI: IpcCommand::ProfileUpdate carries the patch, and the daemon re-validates the whole config before the write lands. Source: src/tui/app.rs, src/ipc/socket_server.rs.
REST API
| Method | Path | What |
|---|---|---|
GET | /api/config | Full merged config including [profiles.*] as JSON. token_hash is redacted. |
GET | /api/whitelist | The default profile’s allow list, reconstructed from its admin_rules references whose rule text starts with @@. |
POST | /api/whitelist/add | Add an allow rule to the default profile. 409 if already present. |
DELETE | /api/whitelist/remove | Symmetric. 404 if absent. |
All /api/* routes are gated by Bearer token (Authorization: Bearer ps_<64hex>), constant-time-verified, with a 10-failure / 5-minute lockout. Each authenticated route carries a 30-second timeout.
There is no REST CRUD for arbitrary profiles. The whitelist endpoints are a compatibility shape over the default profile only; everything else goes through the CLI, the TUI, or a hand-edit plus reload.
IPC
Three profile verbs exist on the Unix socket, all in the Mutating tier (admin token required):
ProfileCreate { id, display_name }— refuses if the id exists.ProfileUpdate { id, patch }— the patch carriesdisplay_name,block_response,blocked_ttl_secs,block_all,admin_rules(add / remove deltas),ecs, andlists(aListPolicyPatchofsetandclearmaps;setis applied first, so an id named in both ends up removed).ProfileDelete { id }— refuses if any device, subnet, or schedule still references the id.
Hand-edited fields land on disk and the daemon picks them up via IpcCommand::Reload { token }, the same path every CLI mutation uses. DeviceUpdate and DevicePromote carry an optional profile field for rebinding a device. Source: src/api/handlers.rs:863-895, src/ipc/protocol.rs:267-300,407.
Decision precedence
A query goes through two ordered passes: choose the profile, then evaluate inside it.
Choosing the profile
Five levels, first match wins. The table gives the runtime evaluation order alongside the label warden resolve and the audit log print — the two do not run in the same sequence, which trips people up.
| Evaluated | Level label | Condition |
|---|---|---|
| 1st | schedule | A schedule window is active for the matched device or one of its groups. |
| 2nd | device-direct | The [[devices]] row sets profile = "...". |
| 3rd | group | The device’s highest-priority group. Same-priority-different-profile is a validator error, so the choice is never ambiguous. |
| 4th | subnet | Longest-prefix match against [[subnets]]. Only for sources with no [[devices]] row — or for a device downgraded by MAC enforcement. |
| 5th | global-default | [server].default_profile. If unset, the answer is REFUSED. |
The schedule-before-direct ordering is deliberate: an operator who writes a schedule for a device that also pins a profile is clearly asking the schedule to win inside its window, otherwise the schedule would do nothing. Tests pin this choice.
enforce_device_mac = true plus an ARP mismatch downgrades the device to the subnet level for that query — schedule, direct, and group are all skipped.
This is the same chain documented under devices and groups. Source: src/profiles/resolver.rs:440-520.
Evaluating inside the profile
Before any of this, the handler probes the profile’s local_records. A hit is answered straight away and bypasses the filter, the cache, and upstream entirely — it is not “allowed past” the filter, it never reaches it. That holds under block_all too, which is what keeps homework.lan reachable in the bedtime example above. Only A, AAAA, and CNAME participate; any other qtype falls through to the global [local_dns] table and then upstream. Source: src/dns/handler.rs:940-985.
Once that misses, the filter walks these stages. The first decision wins.
block_allshort-circuit. Ifblock_all = true: forward only when an admin allow rule matches (exact-domain set or a matching allow rule in the priority scan). Everything else is denied, attributed to the profile policy rather than to whichever rule was the proximate match. Allow-direction lists deliberately do not pierceblock_all— a sandboxed external allowlist must not weaken an explicit operator “deny everything”.- Admin-rule priority scan, four tiers, highest wins:
| Priority | Rule shape |
|---|---|
| 3 | $important allow — short-circuits everything below |
| 2 | $important deny |
| 1 | normal allow |
| 0 | normal deny |
- Unified subdomain walk, one byte pass probing every enabled set per dot position:
- exact-domain allow set hit → forward (outranks a tier-0 deny that already matched)
- exact-domain deny set hit → block (only consulted when no rule produced a result)
- list allow-direction mask hit → forward
- list deny-direction mask hit → block
- Default forward — anything that matched nothing goes upstream.
After the verdict and before the forward, rewrite_rules and the SafeSearch presets apply. That ordering is what stops a rewrite from being used to escape a blocklist match on the original name.
Allow beats deny at equal strength: $important allow > $important deny > normal allow > list-level allow > list-level deny ~ normal deny. Source: src/filter/engine.rs:401-560, src/filter/evaluator.rs:22-83.
Device overlay (per-query, on top of the resolved profile)
A device may carry allow_rules and deny_rules of its own, plus override_profile_deny: bool. Across the nine combinations of (profile decision × device decision × override flag), one row is load-bearing:
Profile denies, device allows, override_profile_deny = true → the query is allowed, attributed to RuleSource::Device(<id>) [OVERRIDE].
When override_profile_deny = false the same situation falls back to block — defensive on drift; the CLI and TUI also refuse to write a contradicting allow without flipping the flag. Per-device deny is always additive: it can block what the profile would allow, but the profile cannot override it.
The device cannot override block_response, blocked_ttl_secs, block_all, local_records, rewrite_rules, or the profile’s list policy — those come from the resolved profile only. What a device contributes is its own allow_rules and deny_rules, and nothing else. Source: src/profiles/profile.rs.
Security notes
- Only
admin_ruleshonour@@,$important, and regex. External blocklists are sandboxed — they can deny but cannot override or use anchored / regex patterns. This keeps a hostile or compromised list source from punching through your overrides. local_recordsare unfiltered by construction. A profile-scoped record is served before the filter, the cache, and upstream — no blocklist, no admin rule, and noblock_allapplies to it. Treat the list as a small, deliberate allowlist that also happens to answer, not as a convenience for pinning names you still want checked.- Filtering is on by default; switching it off is the explicit act. Absent an override every list applies at its
base, so no profile ends up filtering nothing by omission. Exempting one is a writtenlists = { <id> = "ignore" }. allowis the one direction that needs consent. It makes a profile permit somebody else’s domains, so it is refused unless the list istrust = "local"or its row carriesaccept_unsigned_allow = true.denyandignoreonly narrow what a profile permits.block_unmapped_clientsis gone. Express “deny unmapped clients” by leaving[server].default_profileunset, so the chain returnsREFUSEDat the global-default level. The legacy key is refused at load.- The query log records
client_ip,client_name, queried domain, RCODE, and the resolved profile name. It does not record MAC. - Profile lifecycle is unaudited. Create, update, remove, and ECS changes are not written to the audit log. What is audited:
rule.add/rule.remove/rule.undo, whichwarden profile allowandwarden profile denygo through, carryingscope,target_id, and the effective profile. To trace “who changed the kids profile’s block mode?”, use the file’s git history. warden audit tail [-n N]prints recent audit lines as tab-aligned rows: timestamp, event, uid, ok / rejected, detail.- MAC enforcement is ergonomic, not cryptographic. A determined attacker on the LAN can spoof. See the threat model.
Troubleshooting
A reload fails on an unknown list id. The lists map names an id no [[blocklists]] row carries:
profile "kids" sets lists.adult but no [[blocklists]] entry has id "adult"
This is an error, not a warning: the daemon refuses the config and stays on the last good one, and reports every unknown id at once. Add a [[blocklists]] entry with that id, or drop the key.
Over a verb the same mistake is caught before anything reaches the file — a set naming two lists with one typo writes neither:
profile "kids" names blocklist "adult" in its list policy, but no [[blocklists]] entry with that id is configured. Run `warden blocklist list` to see what is.
A reload fails on a retired key. A profile still carrying blocklists, categories, or tags is refused, not migrated. Convert by hand: a list everyone used becomes a plain row at base = "deny" with nothing on the profile; a list only some profiles wanted becomes base = "ignore" plus lists = { <id> = "deny" } on those profiles.
A reload fails with “profile X not found”. Some entity references a profile that no longer exists — usually a typo, sometimes a deleted profile. The validator refuses the reload and the daemon stays on the previous good config. Run warden config lint, or grep across the config tree:
grep -RnE 'profile\s*=\s*"[^"]+"' /etc/purge-wardenwarden profile remove is refused. Some device, subnet, or schedule still references it. The error names them — reassign those first. The same applies if [server].default_profile points at the profile being deleted.
[server].default_profile is unset and unmapped clients fail. The last level returns REFUSED for everything. Symptom: a guest laptop gets RCODE=REFUSED for every name. Either set default_profile to an explicit id, or accept that as the policy — it does keep an open resolver from forwarding for strangers.
A device with override_profile_deny = true keeps slipping past the kids profile. That’s what the flag is for, but check whether you meant to leave it on:
warden device show anna-iphone | grep -E 'allow|override'A device is resolving to the right profile but skipping every list. Check unfiltered. A device with unfiltered = true is exempted from filtering at whatever level it resolves — including the subnet and global-default levels — while DNS resolution, query logging, and stats all keep working, so the device looks healthy while nothing applies to it.
For more, see troubleshooting.
See also
- Import lists — where a list comes from, and where its
baseis set. - Devices — pin a host to a profile, layer a per-device allow / deny overlay.
- Groups — assign one profile to many devices at once.
- Subnets — fallback profile for sources with no device row.
- Schedules — time-based profile swaps.
- Admin rules — what
admin_rulesreferences; the only place@@/$important/ regex live. - TOML reference — every field, every constraint.
- CLI reference — the full
warden profile …surface.