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 Custom Lists to mount 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 query is decided inside a profile. 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, redirects search engines with rewrite rules, and switches the block response tonxdomainso iOS apps stop retrying. - A
kids-nightprofile (block_all = trueplus a Custom List of allow rules), bound by a schedule between 21:00 and 07:00. - An
iotprofile that turns a manufacturer-telemetry list on for itself alone, 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 goes into a Custom List mounted on the profile — see custom rules.
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. A Custom List reaches everything bound to the profile that mounts it. Devices have no rules of their own since 0.44.0, so an exception for one device means a profile for that device.
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
custom_lists = ["kids-allow-classroom"]
block_all = falseWith inline lists = { … }, key order does not matter. If you write lists as a [profiles.<id>.lists] sub-table instead, put custom_lists and every other key above that header: TOML ends a table’s plain keys at the first sub-table.
How 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 | Mounted Custom Lists | custom_lists | The only place for @@ allow, $important, wildcard and regex rules — 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 a Custom List allows. |
| 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 flag | safe_search | Selects nothing since the built-in presets were removed. Warden warns when it is set. Write the redirects as rewrite_rules; see SafeSearch and rewrites. | |
| 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.blocklistsandcategoriesare refused at load;tagsis removed at load with a notice and applies nothing. 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. |
custom_lists | list of Custom List ids | no | [] | Mounted Custom Lists; the only place @@, $important, wildcard and regex rules are honoured. Every id must match a [[custom_lists]] row. |
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 those a Custom List allows. |
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 | Retired in effect: selects no rewrite and produces a warning on load. |
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. |
Unknown fields are refused — a typo fails at config load. So does every retired key except tags: a profile carrying blocklists, categories or admin_rules is refused, not migrated in place. See troubleshooting for what to convert them to. A tags key is removed at load with a notice and applies nothing. Source: src/config/schema/target_v5.rs → ProfileV5, src/config/schema/retired_keys.rs → strip_retired_tag_keys(), src/config/loader.rs → normalise_deprecated_keys().
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"
display_name = "OISD basic"
url = "https://big.oisd.nl/domainswild"
base = "deny"
[[blocklists]]
id = "urlhaus"
display_name = "URLhaus"
url = "https://urlhaus.abuse.ch/downloads/hostfile/"
base = "deny"
[[blocklists]]
id = "adult-content"
display_name = "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, one school Custom List on top:
[profiles.kids]
display_name = "Kids"
custom_lists = ["kids-allow-classroom"]
lists = { adult-content = "deny" }
block_response = "nxdomain"
blocked_ttl_secs = 300A kids-night profile bound to a schedule — deny everything except a narrow Custom List of allow rules. 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 = "Kids, night"
block_all = true
custom_lists = ["kids-night-bedtime-audio"]
[[profiles.kids-night.local_records]]
domain = "homework.lan"
type = "A"
value = "192.168.1.20"[[schedules]]
id = "kids-bedtime"
display_name = "Kids: bedtime"
target_type = "group"
target_id = "kids"
profile = "kids-night"
days = ["mon", "tue", "wed", "thu", "sun"]
hours = "21:00-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 the name of the smart-home hub to its LAN address. A local record cannot stand in for a block: Warden refuses a record that points at 0.0.0.0, 127.0.0.1 or another reserved address, so blocking stays with the telemetry list.
[profiles.iot]
display_name = "IoT"
lists = { telemetry = "deny" }
block_response = "soa_nodata"
[profiles.iot.ecs]
mode = "off"
[[profiles.iot.local_records]]
domain = "hub.lan"
type = "A"
value = "192.168.1.10"
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 custom-list create takes --into <file> to route the write to the right file. warden profile add <id> --display-name <name> has no --into.CLI
The subcommand is warden profile (singular). add, set, remove and list-policy set|clear go through the running daemon over IPC and need the admin token. They print the daemon’s reply, or daemon refused: <message>. mount and unmount also go through the daemon unless you pass --offline. list, show and list-policy show read the config locally and 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 add <id> --display-name <n> | Create a profile. Refuses if the id already exists. |
warden profile set <id> <field> <value> | Set one field: display_name, block_response, blocked_ttl, block_all, or an ecs.* field. |
warden profile mount <id> --custom-list <list-id> | Mount a Custom List on the profile. |
warden profile unmount <id> --custom-list <list-id> | Unmount it. The list stays declared. |
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 remove <id> | Delete the profile. Refused while a device, group, subnet, schedule, or [server].default_profile references it. |
Profile-scoped local_records and rewrite_rules have their own verbs: warden local-dns add|remove|list --profile <id> and warden rewrite add|remove|list --profile <id>. There is no warden profile blocklists; it was removed with the field it wrote to.
warden profile allow, deny and admin-rule add|remove are retired. They fail without writing. Rules now live in Custom Lists; see custom rules.
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 custom-list create kids-extra --display-name "Kids extra"
sudo warden custom-list add kids-extra tiktok.com --deny
sudo warden profile mount kids --custom-list kids-extra
# Switch RCODE shape so iOS stubs stop falling through to a secondary
sudo warden profile set kids block_response nxdomain
# Inspect what would happen for an IP, no daemon needed
warden resolve 192.168.1.107TUI
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 | One list of every record, global and per-profile, under group headers. | g l |
The Resolver inspector (Shift+S from any leaf) 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. |
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 profiles. 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,ecs, andlists(aListPolicyPatchofsetandclearmaps;setis applied first, so an id named in both ends up removed).ProfileDelete { id }— refuses if any device, group, subnet, schedule, or[server].default_profilestill references the id. The reply is the generic validator refusal; the details go to the daemon log.
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 → get_config(), src/ipc/protocol.rs → IpcCommand::ProfileCreate, ProfileUpdate, ProfileDelete.
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 each level’s internal name. warden resolve prints numbered labels instead: 1 (direct device profile), 2 (active schedule override), 3 (group membership), 4 (subnet longest-prefix), 5 (global default_profile). The numbers do not follow the evaluation order, which trips people up.
| Evaluated | Level name | 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]]. For sources with no [[devices]] row, devices with no profile, group or schedule, and devices dropped by MAC enforcement. |
| 5th | global-default | [server].default_profile. If unset, the answer is REFUSED. |
An active schedule is checked before the device’s own profile, so it wins inside its window. The order is deliberate: an operator who writes a schedule for a device that also pins a profile is clearly asking the schedule to win, otherwise the schedule would do nothing. Tests pin this choice.
With enforce_device_mac = true, a MAC in the ARP table that is neither the device’s mac nor one of its mac_aliases downgrades the device to the subnet level for that query — schedule, direct, and group are all skipped. A device with an ip and no mac is dropped the same way, unless the ARP table shows one of its mac_aliases at its address. A MAC that another configured device owns identifies that device, which then walks the chain with its own schedule, profile and groups. See the MAC check.
This is the same chain documented under devices and groups. Source: src/profiles/resolver.rs → ProfileResolver::resolve().
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 → handle_inner() (the probe of the profile’s local_records), src/dns/local_profile.rs → ProfileLocalRecords::lookup_with_apex().
Once that misses, the filter walks these stages. The first decision wins.
- Custom List rules. Every rule from the profile’s mounted Custom Lists that matches the name is collected. The strongest wins:
| Priority | Rule shape |
|---|---|
| 4 | $important allow |
| 3 | $important deny |
| 2 | ordinary allow |
| 1 | ordinary deny |
- Nothing matched. If
block_all = true, the query is blocked. Otherwise it is blocked only when a deny-direction list matches and no allow-direction list does. Any allow-direction match forwards, even when a deny-direction list also matches.
Under block_all, a Custom List allow still forwards. That is how an allow-only profile works. Allow and deny rules in one list rank the same as in two lists on one profile. The rule grammar is on custom rules.
After the verdict and before the forward, rewrite_rules apply. That ordering is what stops a rewrite from being used to escape a blocklist match on the original name. See SafeSearch and rewrites for the answer the client gets.
Devices have no rule overlay
Before 0.44.0 a device could carry allow_rules, deny_rules and override_profile_deny. Schema 5 has none of them, and a config that contains them is refused. The upgrade turns a device’s rules into a Custom List plus a copy of the profile that mounts it. A device contributes only its choice of profile.
Security notes
- Only Custom Lists honour
@@,$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 Custom List 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 the timestamp,
client_ip,client_name, queried domain, query type, result, response time, and the CNAME or rewrite source when there is one. It does not record the profile or the MAC. - Profile changes are logged to the journal, not to
audit.log. Create, update and delete each write a journal line under theaudittarget (for exampleprofile.delete.v1) with the caller’s uid; seejournalctl -u purge-warden.audit.logonly gets the genericreloadrow that follows. 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 or categories is refused, not migrated. (A leftover tags key does not fail the reload: it is removed with a notice.) 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, group, subnet, schedule, or [server].default_profile still references it. The CLI prints only daemon refused: the change would leave the configuration invalid; see daemon log for the validator's full report. The change was NOT saved — the original file is unchanged. The validator’s report, with the references, is in journalctl -u purge-warden. Reassign those first.
[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 reload fails on admin_rules or override_profile_deny. Schema 5 has neither. Move the rules into a Custom List and mount it; see custom rules.
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.
- Groups — assign one profile to many devices at once.
- Subnets — fallback profile for sources with no device row.
- Schedules — time-based profile swaps.
- Custom rules — the only place
@@/$important/ regex live. - SafeSearch and rewrites —
rewrite_rulesand what the client sees. - CLI reference — the full
warden profile …surface.