Declarative configuration
Warden’s configuration is declarative: you describe the state you want, not the steps to get there. A single config.toml is enough. As the setup grows you can split it across files, the same way a NixOS system is split across modules. Warden loads them, merges them in memory, and runs that picture. The files on disk stay as you wrote them.
Why the file is the policy: Project principles. What those entities do at query time: Filtering process.
File and folder structure
Start with one file. Split a directory out when a file gets hard to read or to share. The tree below is a suggestion, not a requirement. Name files for what is in them, so git diff devices/iot.toml tells the story.
The master file
The master config.toml declares which fragments to load:
schema_version = 1
includes = [
"devices/*.toml",
"profiles/*.toml",
"groups/*.toml",
"subnets/*.toml",
"schedules/*.toml",
"blocklists/*.toml",
"rules/*.toml",
]
[server]
listen = "0.0.0.0:53"
allow_from = ["10.0.0.0/8", "192.168.0.0/16"]
[upstream]
servers = ["9.9.9.9", "1.1.1.1"]The servers values are examples — pick your own. Warden ships no default upstream.
On load, Warden expands every glob, sorts the matches, and reads each fragment. A fragment uses the same TOML schema as the master: devices/family.toml is just [[devices]] entries.
- A glob that matches zero files is fine.
- A file listed by name in
includesand missing from disk is a hard error.
Merge rules
Merge happens in memory on every boot, every hot reload, and every CLI write. Disk files are not rewritten. Warden never silently picks a winner.
The rules key off the TOML shape, not the file name. A [[devices]] row in rules/misc.toml loads the same way as one in devices/. Convention is still useful: singletons in the master, arrays in devices/*.toml, profiles in profiles/*.toml.
| Structure | Where you see it | Across files |
|---|---|---|
| Singleton | schema_version, includes, [server], [upstream], [cache], [security], [anti_bypass], [ip_blocklists], [local_dns], [tracking], [socket], [api] | Exactly one file may define it. These stay in the master. |
| Array of tables | [[devices]], [[groups]], [[subnets]], [[schedules]], [[blocklists]], [[admin_rules]] | Concatenate. Every id must be unique. Split these. |
| Named map | [profiles.<id>] | Merge by key. The same id in two files is a conflict. Split these. Profiles are not [[profiles]]. |
A phantom device that depends on file order is worse than a load that fails on the exact line. Errors name (file, line) because a provenance map rides next to the merged config.
The CLI writes the same files
Every warden command that changes configuration writes back into this tree:
--into picks the fragment. Omit it and Warden looks next to the master (devices/ for warden device add, groups/ for warden group add). One file in that directory → that file. Several → Warden refuses and asks. None → the entry goes into config.toml.
The CLI is a typing aid. The files remain what you diff, commit, and back up (tar /etc/purge-warden/).
Hot reload — full re-read, atomic swap
Editing a file does not apply it. Validate, then reload:
warden config lint && sudo systemctl reload purge-warden
warden reload does the same via the daemon’s IPC socket.
On reload, Warden re-reads every file, validates the merge, and swaps the running config only if the new picture is valid. If validation fails, the previous config keeps running. The DNS handler never sees a half-applied state.
A change that binds an OS resource still needs a restart: [server].listen or [socket].path. Everything else — devices, profiles, lists, rules, schedules — is a reload.