EN

Profiles

Work in progress — content may be incomplete.

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 default profile for anything on the LAN, overriding nothing — every list applies at its own base.
  • A kids profile that flips an adult-content list from ignore to deny, denies a few specific domains, turns on SafeSearch, and switches the block response to nxdomain so iOS apps stop retrying.
  • A kids-night profile (block_all = true plus a narrow admin allowlist), bound by a schedule between 21:00 and 07:00.
  • An iot profile that turns a manufacturer-telemetry list on for itself alone, with a few local_records pinned so the smart-TV apps stop calling home.
  • A work profile 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.

toml
[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      = true

How lists attach: the per-profile override

Every [[blocklists]] row carries a basedeny unless the row says otherwise. That base is what a profile gets by default. A profile changes it one list at a time.

text
effective(profile, list) = profile.lists[list.id]   when the key is present
                         = list.base                otherwise

Three directions, three meanings:

DirectionWhat it means for this profile
denythe list’s domains are blocked
allowthe list’s domains are permitted, ahead of every deny-direction list
ignorethe 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 lists key inherits every list’s base, and base is deny unless 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 lists are 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.rseffective_direction().

Setting a profile's list policy
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.

GroupDimensionTOML fieldWhat it tunes
Identityid(map key)Stable cross-reference. Every device, group, subnet, and schedule that wants this profile names this id.
Display namedisplay_nameOperator-facing label shown in the TUI and CLI listings.
List policyPer-list direction overridelistsMaps a blocklist id to deny / allow / ignore. An id with no entry follows that list’s own base.
Custom rulesAdmin-rule referencesadmin_rulesThe only path for @@ allow, $important, and regex patterns — external blocklists are sandboxed away from those powers.
Wire-level responseAnswer shape on a blockblock_responsezero (default), nxdomain, refused, soa_nodata. See the table below.
TTL on the block answerblocked_ttl_secsPer-profile override; falls back to [server].default_blocked_ttl_secs.
ModeDeny-by-default switchblock_allWhen true, every query is denied except those covered by an admin allow rule.
Static DNSPer-profile recordslocal_recordsA/AAAA/CNAME entries served before going upstream. Consulted before the global [local_dns] table.
RewritesName-to-name redirectsrewrite_rulesApplied after the block check and before the upstream forward, so a rewrite cannot bypass filtering on the original name.
SafeSearch presetsafe_searchOne bool; injects eight vendor-documented search-engine rewrites at resolve time.
UpstreamECS 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]].profileA 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. blocklists and categories stopped being fields, and the tags key 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’s lists map 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

FieldTypeRequiredDefaultPurpose
<id> (map key)string, [a-z0-9-], 1–64 bytesyesStable cross-reference.
display_namestringno""TUI / CLI label.
listsmap of blocklist id → deny | allow | ignoreno{}Per-list direction override. Every key must match a [[blocklists]] id. An absent id follows that list’s base.
admin_ruleslist of admin-rule idsno[]Operator-curated rules; the only place @@, $important, and regex are honoured.
block_responseenumnoinherits [server].default_block_responsezero | nxdomain | refused | soa_nodata.
blocked_ttl_secsu32 (seconds)noinherits [server].default_blocked_ttl_secsTTL stamped on the canned block answer.
block_allboolnofalseWhen true, denies every query except admin-allow matches.
local_recordslist of LocalDnsRecordno[]Per-profile A/AAAA/CNAME, with optional ttl_secs and match_subdomains. Consulted before the global table.
rewrite_ruleslist of RewriteRuleno[]Name-to-name rewrites, applied post-filter and pre-forward.
safe_searchboolnofalseInjects the curated search-engine rewrite set.
ecssub-tablenoinherits [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 valueWire effectWhen 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.
nxdomainRCODE=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.
refusedRCODE=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_nodataNOERROR + 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:

/etc/purge-warden/profiles.d/default.toml
toml
[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:

/etc/purge-warden/blocklists.d/core.toml
toml
[[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:

/etc/purge-warden/profiles.d/kids.toml
toml
[profiles.kids]
display_name     = "Bambini"
lists            = { adult-content = "deny" }
admin_rules      = ["kids-allow-classroom"]
block_response   = "nxdomain"
blocked_ttl_secs = 300
safe_search      = true

A 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.

/etc/purge-warden/profiles.d/kids.toml
toml
[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"
/etc/purge-warden/schedules.d/family.toml
toml
[[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:

/etc/purge-warden/profiles.d/iot.toml
toml
[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 = true
Tip
For more than a handful of profiles, split them across profiles.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.

CommandWhat it does
warden profile listList 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:

bash
# 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.107

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

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

Source: src/cli/mod.rs: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].

TabProfile relevanceReached by
ProfilesMaster list plus detail card. Add / Edit / Delete drive the ProfileCreate / ProfileUpdate / ProfileDelete IPC verbs directly.4, g p
ListsOne row per [[blocklists]] entry.g i
Custom ListsThe operator-owned rule files, and which profiles mount each one.g t
DevicesProfile column per row, UNFILTERED flagged on the detail card. Group-by profile is one of the cycle options.3, g v
Local DNSTwo panels — the global table and the per-profile records.g l
RulesPer-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

MethodPathWhat
GET/api/configFull merged config including [profiles.*] as JSON. token_hash is redacted.
GET/api/whitelistThe default profile’s allow list, reconstructed from its admin_rules references whose rule text starts with @@.
POST/api/whitelist/addAdd an allow rule to the default profile. 409 if already present.
DELETE/api/whitelist/removeSymmetric. 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 carries display_name, block_response, blocked_ttl_secs, block_all, admin_rules (add / remove deltas), ecs, and lists (a ListPolicyPatch of set and clear maps; set is 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.

EvaluatedLevel labelCondition
1stscheduleA schedule window is active for the matched device or one of its groups.
2nddevice-directThe [[devices]] row sets profile = "...".
3rdgroupThe device’s highest-priority group. Same-priority-different-profile is a validator error, so the choice is never ambiguous.
4thsubnetLongest-prefix match against [[subnets]]. Only for sources with no [[devices]] row — or for a device downgraded by MAC enforcement.
5thglobal-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.

  1. block_all short-circuit. If block_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 pierce block_all — a sandboxed external allowlist must not weaken an explicit operator “deny everything”.
  2. Admin-rule priority scan, four tiers, highest wins:
PriorityRule shape
3$important allow — short-circuits everything below
2$important deny
1normal allow
0normal deny
  1. 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
  2. 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_rules 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_records are unfiltered by construction. A profile-scoped record is served before the filter, the cache, and upstream — no blocklist, no admin rule, and no block_all applies 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 written lists = { <id> = "ignore" }.
  • allow is the one direction that needs consent. It makes a profile permit somebody else’s domains, so it is refused unless the list is trust = "local" or its row carries accept_unsigned_allow = true. deny and ignore only narrow what a profile permits.
  • block_unmapped_clients is gone. Express “deny unmapped clients” by leaving [server].default_profile unset, so the chain returns REFUSED at 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, which warden profile allow and warden profile deny go through, carrying scope, 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:

text
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:

text
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:

bash
grep -RnE 'profile\s*=\s*"[^"]+"' /etc/purge-warden

warden 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:

bash
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 base is 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_rules references; the only place @@ / $important / regex live.
  • TOML reference — every field, every constraint.
  • CLI reference — the full warden profile … surface.
↑↓ to navigate · Enter to open · Esc to close See all results