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 — give that device a profile of its own with a Custom List, see custom rules. 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", "alice-iphone"]Five fields, three of them required. 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. The validator refuses only a tie at the top priority whose groups point at different profiles. | |
| 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, Custom Lists, block response. Those live on the profile the group binds to. To block one extra domain “for the kids”, add it to a Custom List and mount that list on the
kidsprofile withwarden profile mount kids --custom-list <id>. - 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, so there is no group that exists purely as an organisational label with no resolution effect. For a label that changes nothing, use a device’sowner,device_type, ordepartmentinstead — those are the TUI’s group-by axes.
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. |
#[serde(deny_unknown_fields)] is on — typos fail at config load with unknown field. Source: src/config/schema/group.rs, src/config/schema/validator.rs → 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"
display_name = "Kids"
profile = "kids"
devices = ["kids-tablet", "alice-iphone"]# Device-side enrolment — same effect
[[devices]]
id = "kids-tablet"
display_name = "Kids' iPad"
mac = "11:22:33:44:55:66"
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", "alice-iphone", "school-laptop"]Two overlapping groups, with priority deciding which profile wins for a device that belongs to both — Alice is in both kids (priority 10) and school (priority 20), so she always resolves through the school-allow-classroom profile, not the stricter kids. Priority is not time-based; use a schedule to switch profiles by the clock:
[[groups]]
id = "kids"
display_name = "Kids"
profile = "kids"
priority = 10
devices = ["kids-tablet", "alice-iphone", "school-laptop"]
[[groups]]
id = "school"
display_name = "School devices"
profile = "school-allow-classroom"
priority = 20
devices = ["alice-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"
display_name = "Kids: bedtime"
target_type = "group"
target_id = "kids"
profile = "kids-night"
days = ["mon", "tue", "wed", "thu", "sun"]
hours = "21:00-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 | Print configured groups (N):, then one line per group: <id> "<display_name>" profile=<p> priority=<n> devices=[a,b]. With no groups it prints no groups configured and a hint. |
warden group show <id> | Print id, display_name, profile, priority, and the group’s own devices roster. Device-side groups back-references are not shown. |
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). The id field is not mutable — see renaming a group. |
warden group remove <id> [--into <file>] | Delete the group. Refused before writing if any device still lists <id> in its groups field, or if a schedule targets the group. The error names every offending device or schedule. A missing id prints group "<id>" not found — nothing to remove and exits 0. |
warden group profile-allow and warden group profile-deny are retired in 0.44.0. They fail without writing. A group has no rules of its own: to express a per-group exception that doesn’t bleed onto every device sharing the underlying profile, carve a dedicated profile and mount a Custom List on it. See custom rules.
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,alice-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
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
# 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 <device> groups <remaining-list>`.After add, set, or a remove that removed something, the CLI asks the daemon to reload and prints one of four messages:
daemon accepted the reload request — activation is not correlateddaemon 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 `systemctl reload purge-warden` to activate now (SIGHUP is unauthenticated, so it works without a token).warning: change landed on disk but the daemon rejected the reload (<msg>). Check `journalctl -u purge-warden`, then try `systemctl reload purge-warden` — it enters the same reload through SIGHUP instead of IPC. Escalate to `restart` only if that fails too: it stops DNS until the lists reload.
Source: src/cli/commands/groups.rs, src/cli/mod.rs → GroupAction, src/cli/commands/ipc_reload.rs → report_reload_outcome().
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, then run sudo warden reload. If you want to stop the old id from being reused for 90 days, add a [[retired]] entry for it by hand; no command writes one. For a freshly-created group with no schedules attached yet, removing and re-adding under the new id is usually faster.
TUI
The Network section has a Groups screen. Reach it with g o. It lists the configured groups and opens Add, Edit and Delete forms. The forms call the same writers as warden group add / set / remove.
| Tab | How groups appear | Useful keys |
|---|---|---|
| Groups | One row per configured group, with a details view. Delete asks for a single y / n. | ↑/↓ select, a add, Enter / e edit, d / Delete remove, i / Esc open or close details. |
| Devices | The detail card of the focused device shows Group: with every membership, comma-joined. The Edit form multi-selects groups (Space toggles); the Add form sets one group. | 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 Shift+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?”. | Shift+S open inspector, type the IP, Enter. |
Source: src/tui/tabs/groups.rs, src/tui/group_modal.rs, src/tui/tabs/devices.rs → group_line(), src/tui/resolver_modal.rs.
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 or the TUI, never the API.
GET /api/devices (Bearer-token authenticated) returns one row per device with the resolved profile name. The rows have no groups field; for a per-group view, join them with the [[groups]] rosters from GET /api/config.
IPC
The Unix socket protocol has no group-specific verbs — no IpcCommand::GroupAdd, GroupUpdate, GroupRemove, or GroupList. Groups are written to disk by the CLI or the TUI Groups screen, through the same code: 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 → build_router(), src/ipc/protocol.rs → DevicePatch.
Decision precedence
Groups sit at level 3 of Warden’s 5-level resolver chain. The full chain, in execution order — first match wins. (warden resolve labels the direct profile 1 and the schedule 2, although the schedule is checked first.)
- 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. - Direct device profile. Source IP (or MAC via ARP) matches a
[[devices]]row, and the device setsprofile = "...". - 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. No earlier level matched and the source IP falls inside a
[[subnets]]CIDR. Longest prefix wins. This serves sources with no device row, devices with no profile, group or schedule, and devices the MAC check dropped (below). - 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 ARP table shows a MAC at the source address that is neither the device’s mac nor one of its mac_aliases, the device is downgraded to level 4. 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. The schedule, direct-profile, and group levels are all skipped — the group binding is bypassed, not just the direct profile. A MAC that another configured device owns is not a downgrade: the query resolves as that device, with its own groups. The downgrade is ergonomic, not cryptographic; see security notes and the MAC check.
An active schedule is checked before the device’s own profile, so it wins inside its window.
Source: src/profiles/resolver.rs → ProfileResolver::resolve() (the MAC check, then schedule, direct profile and group).
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 "alice-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 → check_group_priority_conflicts().
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→add_inner_locked(),set_fields_inner_locked(),remove_inner_locked()). Operators tracing “who created thekidsgroup?” find it inaudit.log, not only in the file’s git history. A raw TOML hand-edit leaves no trace; the verbs do.- Removing a referenced group is refused before anything is written. The CLI names every device that still lists the group in its
groupsfield, and every schedule that targets it. - 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. When the ARP table shows a MAC that no configured device owns, the device drops to subnet-level resolution; the group profile is bypassed alongside the direct profile. A MAC that another configured device owns resolves as that device, with its own groups. 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.
- A Custom List mounted on a group’s profile reaches everything on that profile. That includes other groups, devices and
[server].default_profilethat point at it. 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 192.168.1.107
# Source IP: 192.168.1.107
# Matched device: alice-iphone (Alice's iPhone)
# Match level: 3 (group membership)
# Via group: school
# Active profile: school-allow-classroomIf 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 CLI refuses with group "<id>" is still the target of schedule(s): …; remove it first with warden schedule remove <schedule-id>.
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". - Custom rules — Custom Lists, the only place rules live.
- CLI reference — the full
warden group …surface.