Groups
A group binds many devices to one profile, with a priority that resolves conflicts when a device belongs to more than one.
What this is
A group is a named bundle of devices that all share the same filter policy. Instead of pinning the kids’ five gadgets one by one to the kids profile, you declare a kids group with profile = "kids" and either list the devices on the group side, or set groups = ["kids"] on each device. Both directions work. Either way, when a query arrives from one of those devices, Warden resolves it through the group’s profile at level 3 of the chain.
This page covers when to create a [[groups]] entry, every dimension you can tune on it, the CLI / TUI / REST surfaces that read and write groups, and how priority breaks ties when a device legitimately belongs to several groups. For the wider picture of how a query gets matched to a profile in the first place, see filtering process and the decision precedence section below.
When you reach for it
Reach for a group when a profile applies to a set of devices, not a single one. A typical home setup ends up with a handful:
- A
familygroup bound todefaultfor parents’ laptops and the desktop in the kitchen. - A
kidsgroup bound tokids, holding every device the children touch — tablets, the school laptop, the family Switch. - An
iotgroup bound toiot, gathering smart bulbs, the printer, the robot vacuum — anything whose vendor cloud you don’t trust. - A
guestsgroup bound to a stricter profile for the guest VLAN’s known devices (the rest fall through to the subnet).
You don’t need a group for one device — pin its profile directly on the [[devices]] row instead. You don’t need a group to express a per-device exception either — that’s allow_rules / deny_rules on the device, see devices. And you don’t need a group for “every host on this VLAN” — use [[subnets]], which catches unmapped hosts as a fallback.
Schema
A group is a TOML array-of-tables entry: [[groups]], the same shape as [[devices]] and [[blocklists]] — not a map like [profiles.<id>].
[[groups]]
id = "kids"
display_name = "Kids"
profile = "kids"
priority = 10
devices = ["kids-tablet", "anna-iphone"]Six fields, three of them required. The optional tags list feeds the tag-intersection model (every member device inherits the group’s tags); there’s no notes or description field — groups are deliberately small.
Dimensions
A group is a vector across these independent axes. Required dimensions are marked.
| Group | Dimension | TOML field | What it tunes |
|---|---|---|---|
| Identity | Id (required) | id | Stable cross-reference. Every device that wants this group, every schedule targeting it, names this id. |
| Display name (required) | display_name | Operator-facing label shown in the TUI side card and in CLI listings. | |
| Resolver binding | Profile (required) | profile | The policy applied to every member at level 3 of the chain. Must reference a defined [profiles.<id>]. |
| Tie-break weight | priority | When a device belongs to several groups with different profiles, the highest priority wins. Same priority + different profile is a validator error. | |
| Membership | Forward roster | devices | The list of device ids enrolled into this group, written from the group side. |
| Back-reference (external) | [[devices]].groups | The same membership written from the device side. Either direction enrols a device — the resolver merges both at load. | |
| Schedule binding (external) | Schedules targeting this group | [[schedules]].target_type = "group" + target_id = "<id>" | A schedule swaps the profile applied to the group’s members for the duration of its window. |
Things that look like group dimensions but aren’t:
- Per-group blocklists, admin rules, block response. Those live on the profile the group binds to. To block one extra domain “for the kids”, add it to the
kidsprofile withwarden profile deny kids …(orwarden group deny kids …, which is a thin shortcut to the same path). - Per-group local DNS records. Same — they live on the profile.
- A profile-less “label-only” group. Every
[[groups]]must bind aprofile— it’s required. A group does carry atagslist that feeds the filter (see the field reference below), but there is no group that exists purely as an organisational label with no resolution effect. For a device-side label that doesn’t change resolution, use the device-leveltagsfield instead.
Field reference
| Field | Type | Required | Default | Purpose |
|---|---|---|---|---|
id | string, [a-z0-9-], length 1–64, no leading or trailing dash | yes | — | Stable cross-reference. |
display_name | string | yes | — | TUI / CLI label. The CLI’s set path enforces non-empty. |
profile | profile id | yes | — | Must reference a declared [profiles.<id>]. |
priority | i32 | no | 0 | Higher wins when a device is in several groups. Negative values are accepted. |
devices | list of device ids | no | [] | Each id must reference a declared [[devices]]. Empty groups are allowed. |
tags | list of tag slugs, ^[a-z][a-z0-9-]{0,31}$ | no | [] | Subject-area tags. Every member device’s effective tag set gains these (a union — a group tag only ever adds blocklists, never removes one). See tags and profiles for how the intersection decides which lists apply. |
#[serde(deny_unknown_fields)] is on — typos fail at config load with unknown field. Source: src/config/schema/group.rs:30-61, src/config/schema/validator.rs:1882 (check_groups).
Bidirectional membership
A device can be enrolled into a group from either side — the schemas are not symmetric in name but the resolver treats them as one set:
# Group-side enrolment
[[groups]]
id = "kids"
profile = "kids"
devices = ["kids-tablet", "anna-iphone"]# Device-side enrolment — same effect
[[devices]]
id = "kids-tablet"
groups = ["kids"]Both work; you don’t need both. Pick whichever reads better for the file you’re editing — usually the device side, because that’s where warden device set <id> groups … writes. Use the group side when adding several devices to one group at once is cleaner. Mixed usage on the same membership pair is harmless but invites confusion; for a fleet of more than ~10 devices, pick one direction and keep to it.
Examples
A minimal group — three devices share the kids policy:
[[groups]]
id = "kids"
display_name = "Kids"
profile = "kids"
devices = ["kids-tablet", "anna-iphone", "school-laptop"]Two overlapping groups, with priority deciding which profile wins for a device that belongs to both — Anna is in both kids (priority 10) and school (priority 20), so during school hours she resolves through the school-allow-classroom profile, not the stricter kids:
[[groups]]
id = "kids"
display_name = "Kids"
profile = "kids"
priority = 10
devices = ["kids-tablet", "anna-iphone", "school-laptop"]
[[groups]]
id = "school"
display_name = "School devices"
profile = "school-allow-classroom"
priority = 20
devices = ["anna-iphone", "school-laptop"]An IoT group with several smart-home devices, no priority needed because none of them belongs to another group:
[[groups]]
id = "iot"
display_name = "IoT"
profile = "iot"
devices = ["smart-tv", "kitchen-bulb", "printer", "robot-vacuum"]A bedtime schedule that targets the kids group — every member resolves through the kids-night profile between 21:00 and 07:00:
[[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"groups.d/*.toml (one file per persona — family.toml, iot.toml, guests.toml). The master config.toml pulls them in via includes. warden group add … --into <file> routes the write to the right file.CLI
Every mutating subcommand triggers a hot reload of the running daemon (atomic config swap, no restart). Read-only subcommands work without the daemon.
| Command | What it does |
|---|---|
warden group list | List configured groups with id, display name, profile, priority, and member count. |
warden group show <id> | Print every field of one group, including the resolved member list (forward roster + device-side back-references). |
warden group add <id> --profile <p> [--display-name <n>] [--priority N] [--devices id1,id2] [--into <file>] | Create a new group. --profile is mandatory and must reference a defined profile. Devices listed under --devices must already exist. Refused if the id is taken. |
warden group set <id> <field> <value> [--into <file>] | Mutate one field. Supported fields: display_name, profile, priority, devices (comma-separated). Not tags — those have their own tag verb. The id field is not mutable — see renaming a group. |
warden group tag add|remove <id> <tag> [--into <file>] | Add or remove a subject-area tag on the group. Every member device inherits it (a union — a group tag only ever adds lists). Idempotent; the slug is validated at the CLI boundary; a real change triggers a hot reload. |
warden group remove <id> [--into <file>] | Delete the group. Refused if any device still lists <id> in its groups field — the error names every offending device and tells you exactly which warden device set … to run first. |
warden group allow <id> <domain> [--id <rule_id>] [--remove] [--into <file>] | Synthesise an `@@ |
warden group deny <id> <domain> [...same flags] | Symmetric for deny. |
warden group rules and warden group block are not wired — there is no group-scoped overlay analogous to per-device allow_rules / deny_rules. To express a per-group exception that doesn’t bleed onto every device sharing the underlying profile, carve a dedicated profile.
Common patterns:
# Create a group from scratch with a couple of devices already inside
sudo warden group add kids --profile kids \
--display-name "Kids" \
--priority 10 \
--devices kids-tablet,anna-iphone
# Reassign a group to a different profile (every member follows)
sudo warden group set kids profile kids-strict
# Add a domain to the kids policy via the group shortcut
sudo warden group deny kids tiktok.com
# Remove a group — fails loudly if any device still references it
sudo warden group remove guests
# error: group "guests" still appears in the groups field of device(s):
# guest-laptop, guest-phone. Remove the reference first with
# `warden device set guest-laptop groups <remaining-list>`.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 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/commands/groups.rs, src/cli/mod.rs:611-668, src/main.rs:482-559.
Renaming a group
There is no warden group rename. The set path doesn’t accept id as a field. To rename a group, hand-edit the [[groups]] entry in TOML, update every [[devices]].groups and [[schedules]].target_id that references the old id, and either restart the daemon or use the retired-ids workflow so old entries don’t break the validator. For a freshly-created group with no schedules attached yet, removing and re-adding under the new id is usually faster.
TUI
There is no dedicated Groups tab. Groups are a resolver-side concept; the TUI surfaces them where they’re useful and leaves the lifecycle to the CLI.
| Tab | How groups appear | Useful keys |
|---|---|---|
| Devices | The detail card on the right of the focused device row shows Group: <name> (single membership) or Group: <first> + N more (multi). Read-only. The Edit modal exposes a single-select group field — the TUI cannot author multi-group memberships, so use warden device set <id> groups … for those. | e edit, ↑/↓ move focus. |
| Devices, group-by axis | The G key cycles the group-by axis — none → owner → department → profile. Group membership is not currently a group-by option; track it through owner or profile, or by filename if you keep one groups.d/*.toml per persona. | G cycle group-by. |
| Resolver inspector | Reachable with s from any leaf. Given a source IP, it prints the matched device, the level it resolved at, and — if level 3 fired — Via group: <id>. Fastest answer to “why is this device on that profile?”. | s open inspector, type the IP, Enter. |
There is no TUI modal for creating, editing, or deleting a group — and group tags likewise have no dashboard editor yet (they exist in the schema and CLI, but are descoped from the TUI). Set them with warden group tag …. Source: src/tui/ui.rs:281-288, src/tui/tabs/devices.rs:531-555, src/tui/resolver_modal.rs:250-259.
REST API
Groups are not exposed over REST. There is no /api/groups endpoint, no group CRUD, and no group-specific read view. The merged config returned by GET /api/config does include [[groups]] as JSON, so an external dashboard can render the on-disk roster — but writes go through the CLI, never the API.
GET /api/devices (Bearer-token authenticated) returns one row per device with the resolved profile name; if you need the per-group view, group those rows client-side by their groups field.
IPC
The Unix socket protocol has no group-specific verbs — no IpcCommand::GroupAdd, GroupUpdate, GroupRemove, or GroupList. Groups are CLI-and-disk-only: warden group … rewrites the TOML, runs the validator, and then asks the running daemon to reload via the same IpcCommand::Reload every other CLI mutation uses. If the daemon is down the write still lands; the daemon picks the new groups up at next start.
The one IPC channel that touches group state is DevicePatch.groups: Option<Vec<String>> — used by the TUI’s device Edit modal to set the membership list of one device at a time. Source: src/api/routes.rs:27-58, src/ipc/protocol.rs:56,308-316.
Decision precedence
Groups sit at level 3 of Warden’s 5-level resolver chain. The full chain, in execution order — first match wins:
- Direct device profile. Source IP (or MAC via ARP) matches a
[[devices]]row, and the device setsprofile = "...". - Active schedule. A
[[schedules]]entry whose time window covers now targets this device or one of its groups. A schedule targeting the device wins over a schedule targeting any of its groups. - Group profile. The device has no direct
profilebut belongs to one or more[[groups]]. The highest-priority group wins; same-priority + different-profile is a validator error (see Priority and tie-breaking below). - Subnet longest-prefix match. The source IP isn’t in
[[devices]]at all but falls inside a[[subnets]]CIDR. Longest prefix wins. A configured device with a group never falls through to the subnet — being in[[devices]]is what gates levels 1–3, so subnets only fire for unmapped hosts. - Global fallback.
[server].default_profile. If unset, unmapped queries are answered withREFUSED.
If [server].enforce_device_mac = true (the default, formerly enforce_client_mac) and the live ARP table shows a MAC that doesn’t match the device’s mac or any mac_aliases, the device is downgraded to level 4. Levels 1, 2, and 3 are all skipped — the group binding is bypassed, not just the direct profile. The downgrade is ergonomic, not cryptographic; see security notes.
Source: src/profiles/resolver.rs:1-46,339-365,394-460,811-857.
Priority and tie-breaking
priority is an i32 with default 0. Higher wins. Negative values are accepted (useful for “demoted” fallback groups).
When a device belongs to two groups with the same priority and different profiles, the validator refuses to load the config:
device "anna-iphone" is in multiple groups with the same priority (10)
but different profiles: kids → kids, school → school-allow-classroom
help: raise the priority of the winning group, remove the device from
one group, or harmonise the profilesSame priority + same profile is fine — the device just resolves through that shared profile, no conflict to break. Sort key on tie at the implementation level is id ASC, but the validator catches the ambiguous case before resolution ever runs.
Source: src/config/schema/validator.rs:672-712.
Security notes
warden group add,set, andremoveare audited. Each writes aCliMutationrecord withscope = "group"andaction = "group.add"/"group.set"/"group.remove"(src/cli/commands/groups.rs:141-149, 180-189, 250-258). Operators tracing “who created thekidsgroup?” find it inaudit.log, not only in the file’s git history. The rule-write verbswarden group allow/denyare audited too, withscope = "group",target_id = "<group-id>",action = "rule.add"/"rule.remove"— and so are the tag verbs:warden group tag add / removerecords agroup.tag_add/group.tag_removeaction. A raw TOML hand-edit leaves no trace; the verbs do.- Removing a referenced group is refused, in two passes. The CLI’s inline check names every device that still references the group. After the write, the validator catches references from
[[schedules]].target_id, refusing the reload and reverting the file — these surface as aschedules[i].target_id "<gid>" is not definederror rather than the friendlier device-style message. - Group membership is not a security boundary. A device on the LAN that doesn’t match any
[[devices]]row falls through to the subnet or default profile — being absent from every group means level 4 or 5 applies, not “blocked by default”. To deny unmapped clients, leave[server].default_profileunset so the chain returnsREFUSEDat level 5. - MAC enforcement strips the group, too. Under MAC mismatch, the device drops to subnet-level resolution; the group profile is bypassed alongside the direct profile. A determined attacker on the LAN can spoof a MAC, but it stops a curious teenager from changing their static IP to escape the kids group. See the threat model.
warden group allow/denymutate the underlying profile, not the group. The synthesised admin rule is shared with everything else bound to that profile — including profiles referenced by other groups, devices, or[server].default_profile. If you want a rule that only applies to one group, carve a dedicated profile.
Troubleshooting
A device gets the wrong profile. Ask the resolver directly — it will tell you which level matched and, if level 3 fired, which group:
warden resolve 10.10.1.107
# Level 3: device 'anna-iphone' → group 'school' → profile 'school-allow-classroom'If the level shown isn’t the one you expected, walk up the chain: did the MAC match? was a schedule active? is enforce_device_mac triggering a downgrade? does the device declare a direct profile you forgot about?
A reload fails with “device ‘X’ is in multiple groups with the same priority…”. You added the device to a second group whose priority equals an existing group’s, and the two groups bind different profiles. The validator can’t pick. Fix one of: raise one priority, remove the device from one group, or harmonise the two profiles.
A reload fails with groups[i] "X".profile "Y" is not defined. The group references a profile that doesn’t exist — typo, or the profile was removed without updating the group. Run warden config lint for the full list, then either fix the typo or warden group set <id> profile <existing-profile>.
A reload fails with devices[i] "X".groups references "Y" which is not defined. A device references a group that isn’t declared. Same diagnosis — typo or stale reference. Fix with warden device set X groups <new-list>, or hand-edit the device file.
warden group remove is refused. Some device still has the group in its groups field. The error names every offending device. Either reassign them with warden device set <device> groups <remaining-list>, or hand-edit the relevant devices.d/*.toml. If a [[schedules]] entry targets the group, the device-level check passes but the post-write validator rolls the change back — clear the schedule first.
A device that’s only in [[groups]].devices doesn’t get the group’s profile. It should — the resolver merges both sides at load. If it doesn’t, run warden resolve <ip> to see the actual chain level, and check that the device’s id in the group’s devices list matches the [[devices]].id exactly (the validator catches mismatches at load, but stale references in a hand-edited file are the usual culprit).
For more, see troubleshooting.
See also
- Devices — the entries that groups bind together.
- Profiles — what a group’s
profilefield references. - Subnets — fallback profile for hosts that aren’t in
[[devices]]at all. - Schedules — time-based profile swap that can target a group with
target_type = "group". - Admin rules — what
warden group allow/denysynthesise. - TOML reference — every field, every constraint.
- CLI reference — the full
warden group …surface.