Custom rules
Rules you write yourself — a file of many, or a single line in the config.
What this step does
Imported lists are other people’s judgement calls. Sooner or later you need your own: unblock the domain a list got wrong, block the one every list missed, keep a short household deny-list no feed will ever carry.
Warden gives you two mechanisms for that. Choosing between them is a question of volume, not of power.
Two ways to write your own rule
| Custom list | Admin rule | |
|---|---|---|
| What it is | a file of many rules, packs/<id>.txt | a single rule declared in the config |
| Declared as | [[custom_lists]] | [[admin_rules]] |
| Grammar | two forms only | wildcards, regex, $important |
| Reaches a profile by | profiles.<id>.custom_lists = ["<id>"] | a reference from a profile, device, group or subnet |
| Created from | the Custom Lists tab, or the file itself | the allow / deny verbs, the query log, the Rules tab |
Both end up in the same place: a profile’s allow_domains / deny_domains, the exact-match sets the engine probes on every query. A custom list reaches only those two sets — nothing in a pack goes anywhere else. An admin rule can land in either seat: a plain ||domain^ joins the same sets, while a wildcard, a regex or a $important modifier goes into the advanced-rule vector, scanned line by line on every query.
A custom list is not the weaker mechanism, then. It is the bulk one, and its grammar is narrow because the bulk path has to stay a hash lookup. src/profiles/profile.rs → build_v1().
Custom lists, end to end
The file
A custom list is one text file at <config_root>/packs/<id>.txt. config_root is the directory holding your master config — not that of the include fragment that declares the entity. With the default config at /var/lib/purge-warden/config.toml and a list id of minecraft:
/var/lib/purge-warden/packs/minecraft.txt
The path is derived from the id and cannot be configured — a custom list has no file field. That makes a traversal path, an absolute path, and two entries sharing one file unrepresentable. A symlink in that slot is opened O_NOFOLLOW and refused. src/config/custom_list/mod.rs → pack_path().
The grammar
This is the part that costs people an afternoon. A pack accepts exactly two rule forms. Not AdGuard syntax — a two-form subset of it — and not a hosts file either.
| Line | Verdict |
|---|---|
||ads.example.com^ | deny |
@@||cdn.example.com^ | allow |
# a note to yourself, or a blank line | ignored |
ads.example.com | refused — bare domain, no prefix, no terminator |
0.0.0.0 ads.example.com | refused — hosts syntax |
||*.example.com^ | refused — wildcard |
/^ads[0-9]+\.example\.com$/ | refused — regex |
||example.com^$important | refused — modifier |
Surrounding whitespace is tolerated. The domain body must be plain ASCII letters, digits and hyphens, is lowercased on the way in, and is capped at 253 bytes overall and 63 per label, with no hyphen at a label edge.
A refused line does not fail the file: it is skipped and counted, and the rest of the pack loads. The refusals exist for the reason above — a wildcard, a regex or a $ modifier would have to live in the advanced-rule vector, walked linearly for every query. src/config/custom_list/grammar.rs → parse_pack_line().
Declare it, then mount it
The file on disk does nothing until a [[custom_lists]] entry names it and a profile mounts it.
[[custom_lists]]
id = "minecraft"
display_name = "Minecraft servers"
[profiles.kids]
custom_lists = ["minecraft"]
lists = { ads = "deny" }custom_lists must come before lists in that table: TOML cannot emit a bare value after a table has started, so the other order is a parse error, not a style choice.
A profile mounting an id nobody declared is refused at load — profile "kids" references custom_list "minecraft" which is not defined.
There is no CLI verb for custom lists
Not warden custom-list, not warden profile custom-lists. Neither exists. You create and grow a custom list in the TUI, or write the file yourself and declare it by hand. warden config edit opens the config in $EDITOR and validates what you save, so it reaches the [[custom_lists]] block and the mount — it does not open the pack file.
Size, and what deleting removes
[custom_list_limits] max_file_bytes caps one pack, default 1 MiB. Loading is all-or-nothing: one unreadable pack fails the whole config load, though every failure is reported at once.
Deleting the declaration removes the entity, not the file — the pack stays on disk and reappears the moment you declare that id again.
Admin rules
From the CLI
warden profile allow <profile> <domain> synthesises @@||domain^ as a new [[admin_rules]] row and references it from that profile, in one step. deny does the same for ||domain^. warden subnet profile-allow / profile-deny are the subnet-shaped pair.
From the query log
The fast path: you see a name in the log and act on it without leaving the screen.
- Open the query log —
2, org q. - Move to the row you want and press
Enter. - The action is inferred from the row, never chosen.
BLOCKEDoffers Allow.ALLOWED,CACHEDandSTALEoffer Deny. ALOCAL,REFUSEDorHINFOrow offers nothing — no filter rule produced those, so there is nothing to invert, and you get a footer message instead of a modal. - Pick the scope: Device, Profile, Group, Subnet or Default.
- Confirm. The confirmation is tiered by blast radius — a device is one keypress, a profile, group or subnet makes you type its id, the default makes you type the literal
DEFAULT.
What lands on disk is an [[admin_rules]] row holding ||domain^ or @@||domain^, with an id generated as auto-<action>-<8hex>, plus a reference to it on the entity you scoped to. The daemon reloads and the write is audited as rule.add, surface = "tui".
The modal captures the domain and client at the instant you press Enter, so a log scrolling underneath you cannot change what you are about to write.
Taking one back
Neither removal path takes a rule id:
warden profile allow|deny <profile> <domain> --removeinverts what you wrote: it drops the reference, and cascades the row drop when nothing else still names that id.warden rule undopops the most recent[[admin_rules]]row and cascades the reference drop across every profile, device, group and subnet that named it.
To delete one specific older rule, use the Rules tab.
Which one wins
Warden evaluates a query in this order, stopping at the first step that produces a verdict:
block_all— BLOCK, unless an admin allow matches.$importantallow — FORWARD.$importantdeny — BLOCK.- A normal allow rule, or a custom-list allow — FORWARD.
- A normal deny rule, or a custom-list deny — BLOCK.
- An allow-direction list — FORWARD.
- A deny-direction list — BLOCK.
- Nothing matched — FORWARD.
The consequence you need: inside one pack an allow beats a deny for the same domain, and either beats any list verdict on it. Steps 2 and 3 are out of a pack’s reach — only an admin rule carries $important.
That is the short version, scoped to the rules on this page. Profiles own the full chain — how the profile itself gets chosen, and what block_all and local_records do to the order before any of this runs: decision precedence.
CLI reference
| Verb | What it does |
|---|---|
warden profile allow <profile-id> <domain> | new @@||domain^ row plus the reference on that profile. --id <s> names it, --remove inverts, --into <path> writes to a fragment |
warden profile deny <profile-id> <domain> | the same for ||domain^ |
warden profile admin-rule add <profile-id> <rule-id> | make a profile enforce a row that already exists. Does not create one |
warden profile admin-rule remove <profile-id> <rule-id> | drop the reference. The row itself stays |
warden subnet profile-allow <subnet-or-cidr> <domain> | allow on the profile that subnet references. Same three flags |
warden subnet profile-deny <subnet-or-cidr> <domain> | the same, blocking |
warden rule undo | pop the last [[admin_rules]] row and cascade the reference drop |
warden config edit | open the config in $EDITOR, validate on save. Reaches the declaration, not the pack file |
No verb on this table creates, mounts or deletes a custom list, and no other verb does either.
In the TUI
Launch warden dashboard, then 4 for Filters. g t opens Custom Lists, g u opens Rules.
| Key | Pane | Does |
|---|---|---|
a | Lists | Create a custom list — writes the pack file first, then the [[custom_lists]] block |
e | Lists | Edit the display name and description. The pack file is not touched |
d or Del | Lists | Delete the declaration. The confirm screen shows how many profiles mount it and how many rules it holds |
m | Lists | Mount or unmount on profiles. Space toggles, Enter applies, Esc cancels |
a | Rules | Add one rule to the selected list’s pack. Appends, order-preserving, and a duplicate is a no-op |
d or Del | Rules | Remove the rule under the cursor |
← →, or h l | both | Switch pane |
arrows, Home/End, PgUp/PgDn | both | Move. The Rules pane follows the Lists cursor with no keystroke of its own |
Two things that surprise people:
- There is no rename.
eedits the display name and description only; the id is the file name. din the Rules pane matches on the domain and ignores direction, so it drops both the allow and the deny form for that domain. On a comment or blank line it does nothing — no domain to match.
h and l move between panes here; j and k are bound to nothing — the TUI has no vim aliases anywhere.
The Rules tab is where admin rules are edited and deleted one at a time. f cycles the action filter, / searches the text, R clears both, Enter edits the focused row, a adds one with no row focused, d goes straight to the delete confirmation.
See also
- Create profiles — the seat every rule on this page lands in
- Import lists — the other, much larger source of verdicts
- Query log — what each outcome in the log means