Device Management
This guide covers the full device lifecycle in Yuzu: enrolling agents, monitoring connectivity, managing device identity, and performing over-the-air agent updates.
Prerequisites: A running Yuzu server. All curl examples assume an active admin session cookie in $COOKIE — replace with your session token. The server listens on http://localhost:8080 by default.
Agent Enrollment
Yuzu uses a three-tier enrollment model. Every agent must register with the server before it can receive instructions. The tier determines how much manual intervention is required.
Tier 1: Manual Approval
The default enrollment mode. When an agent connects without a token, it enters a pending queue. An administrator must explicitly approve or deny the agent before it can participate in the fleet.
Agent side:
yuzu-agent --server server.example.com:50051
The agent will attempt registration, receive a “pending” status, and retry with an increasing delay (2 s, doubling, up to 256 s) until approved.
Server side (dashboard): Navigate to Estate > Groups and enrolment (panel Agents waiting for approval) or Settings > Pending Agent Approvals; both show the same queue. The page is named “Groups and enrolment” (UK spelling); the Settings panels keep their existing US-spelling names. Each entry shows the agent’s hostname, OS, architecture, version and the time it first asked to join (UTC). Click Approve, Deny, or Forget (DELETE /api/settings/pending-agents/{id}, audited as enrollment.remove; it removes a waiting request, and the agent is queued again the next time it asks to join). A denied agent stays listed as Denied (“denial recorded”) with no button. The page offers no way to reverse a denial and the product has no reinstate action today (see below); the Deny and Deny all confirmations say “This page offers no way to reverse a denial.” Approve all and Deny all act on every waiting agent, including any that arrive after the page loaded, and their confirmation gives the total and the first ten hostnames. At most 200 rows are listed (waiting agents first, then denied). When more than 200 agents are waiting, the panel says “Showing 200 of N” and the two bulk buttons are disabled, because they would also act on the agents not shown; when every waiting agent is listed but denied ones are cut off, it says how many denied agents are not listed.
The page opens for any signed-in user, but the three panels need the Administrator account role; other users see “Not available to your account” in each panel, and those page views write no denied-access audit rows.
Server side (API):
To list the queue as JSON, use GET /api/v1/enrollment/pending-agents (pending and denied requests; permission Enrollment:Read; 503 when the enrollment store is degraded; REST only, there is no MCP tool for it). See REST API. Approve, deny and remove have no JSON endpoint; they are the dashboard routes below. The dashboard’s own list is an HTMX fragment:
# List pending agents (returns HTML fragment)
curl -s http://localhost:8080/fragments/settings/pending -b "$COOKIE"
# Approve a pending agent
curl -s -X POST http://localhost:8080/api/settings/pending-agents/agent-001/approve \
-H "Origin: http://localhost:8080" \
-b "$COOKIE"
# Deny a pending agent
curl -s -X POST http://localhost:8080/api/settings/pending-agents/agent-001/deny \
-H "Origin: http://localhost:8080" \
-b "$COOKIE"
Approve and deny, like remove below, only act on a row that is still pending. On an agent that was already approved or denied they return 409 (with the refreshed list and an error toast) and change nothing, so a stale click or a repeated request can never reverse a denial or turn an enrolled agent into a denied one. An unknown agent id changes nothing and gets a warning toast; both cases are recorded as denied audit rows.
A denial is permanent for that agent id. There is no reinstate action, no supported way to delete a denied row, and nothing overrides it: an enrollment token or an auto-approve rule presented by a denied agent is refused (enrollment_status denied), and a token’s use is rolled back rather than consumed. If a machine was denied by mistake, it can only come back as a new agent: reinstall it or wipe its agent data directory (it holds the agent id in agent.db and, by default, the client certificate in certs/), so that it generates a new id and certificate. A different --agent-id / YUZU_AGENT_ID alone is not enough on a server that requires client certificates, because the agent still presents the old certificate (CN = old id) and the server refuses the mismatch; use it only together with a wiped data directory. The new id is an ordinary first registration: a valid enrollment token enrolls it at once, an enabled auto-approve rule it matches approves it, and otherwise it waits in the pending queue. The denial is keyed on the old id only, so if you meant to keep that machine out, remove or tighten the token or rule that would admit it. The old id stays listed as Denied.
# Remove a pending agent from the queue entirely
curl -s -X DELETE http://localhost:8080/api/settings/pending-agents/agent-001 \
-H "Origin: http://localhost:8080" \
-b "$COOKIE"
DELETE only succeeds against a row that is still pending — it returns 409 if the agent has
already been approved or denied, refusing to silently reverse that admin decision (WS-6 6.2). A
Tier 1 agent removed while genuinely pending simply re-enters the pending queue on its next
reconnect attempt, same as if it had never been seen. Before this guard, removing an already-
approved row (typically a Tier 2, token-enrolled agent) deleted the row backing its enrollment
with no admin decision behind it and no audit trail — that specific gap is now closed, since an
approved row can no longer be removed via this endpoint at all. There is no remaining asymmetry for
the remove-then-reconnect case: a still-pending agent (Tier 1 or Tier 2, before it has presented a
valid token) behaves identically either way, and a Tier 2 agent’s token-based enrollment
(consume_and_enroll) creates a fresh approved row on the next successful token presentation
regardless of whether a prior pending row existed.
Tier 2: Pre-Shared Enrollment Tokens
For automated deployments, administrators generate enrollment tokens — time-limited and use-limited secrets that agents present at registration for immediate enrollment without manual approval.
Generate a token (HTMX — returns HTML fragment):
This endpoint is designed for the HTMX dashboard and accepts form-encoded parameters. It returns an HTML fragment containing the token list with the new token’s raw value revealed once.
curl -s -X POST http://localhost:8080/api/settings/enrollment-tokens \
-H "Origin: http://localhost:8080" \
-b "$COOKIE" \
-d "label=datacenter-rollout-q1&max_uses=100&ttl_hours=72"
The response is an HTML fragment (the updated tokens table) sent with Cache-Control: no-store, private. The raw token value is displayed once in the fragment and cannot be retrieved again.
On the dashboard (Estate > Groups and enrolment, panel Enrolment tokens, or Settings > Enrollment Tokens) the token is shown once above the list, with Copy to Clipboard and Done. It lives inside the token panel: navigating away, Done, revoking a token, generating another or anything else that reloads the panel removes it for good, and the only recovery is to revoke that token and generate a new one. Copy to Clipboard needs HTTPS or localhost. Where it cannot copy, the Groups and enrolment button says “Copy failed. Select the text.” and you copy the token by hand. The Settings panel keeps its older button, which has no failure message: without HTTPS or localhost it does nothing, and if the browser refuses the copy it still says “Copied!”. Check the clipboard, or select the token by hand, before you leave the panel. The list shows each token’s expiry in UTC and its status (Active, Expired, Exhausted or Revoked, active first) and lists at most 200 tokens; the form shows “Unlimited uses and no expiry” only while both limit fields hold a literal 0 — a field left blank takes the bounded default (100 uses, 72 hours), not unlimited.
Generate tokens in batch (JSON API for scripting):
This endpoint accepts JSON and returns a JSON response, making it suitable for automation and scripting.
curl -s -X POST http://localhost:8080/api/settings/enrollment-tokens/batch \
-H "Origin: http://localhost:8080" \
-b "$COOKIE" \
-H "Content-Type: application/json" \
-d '{
"label": "site-deploy",
"count": 10,
"max_uses": 50,
"ttl_hours": 24
}'
Response:
{
"count": 10,
"tokens": ["<64-hex-token>", "<64-hex-token>", "..."]
}
count, max_uses and ttl_hours may each be a JSON integer or a numeric string ("count": "10"); an empty string or an omitted key keeps the default (count 10, max_uses 1, ttl_hours 72 hours). An explicit 0 for ttl_hours still means no expiry. A float, boolean, null, a number outside the 32-bit range, a negative ttl_hours or max_uses, a count outside 1 to 10000, or a body that is not one JSON object is 400 and mints nothing.
Agent side:
yuzu-agent --server server.example.com:50051 \
--enrollment-token "<64-hex-token>"
The agent includes the token in its RegisterRequest. If the token is valid and has remaining uses, enrollment succeeds immediately.
List active tokens (returns HTML fragment):
There is no dedicated JSON API endpoint to list enrollment tokens. Use the HTMX fragment:
curl -s http://localhost:8080/fragments/settings/tokens -b "$COOKIE"
Revoke a token:
curl -s -X DELETE http://localhost:8080/api/settings/enrollment-tokens/tok_a1b2c3d4 \
-H "Origin: http://localhost:8080" \
-b "$COOKIE"
Tokens are persisted in the PostgreSQL auth.enrollment_tokens table (WS-6 6.2) — shared by every server replica, not a per-replica file. They survive server restarts and image swaps. A leftover enrollment-tokens.cfg in the directory holding yuzu-server.cfg or in --data-dir is imported once, at any boot where the database holds no import record for it, as live tokens, without any check of where it came from, so a fresh deployment must not start with one (see docs/adr/2002-high-availability-architecture.md §8).
Tier 3: Platform Trust (Planned)
Reserved for future implementation. Proto fields exist for machine_certificate, attestation_signature, and attestation_provider to support Windows certificate store attestation and cloud identity verification. Agents on domain-joined machines or cloud VMs will be able to prove their identity cryptographically without any shared secret.
Auto-Approval Policies
Administrators can configure rules that automatically approve agents matching specific criteria, reducing manual toil for large deployments.
View current auto-approval policies (dashboard): Navigate to Estate > Groups and enrolment (panel Auto-approve rules) or Settings > Auto-Approve Policies. Each rule is described by what it matches: “Agent-reported hostname matches …” and “Agent-reported provider …” are marked “(not verified)” because the agent states the hostname and the attestation provider itself; “Address the server sees matches …” is the connection’s peer address (IPv4 only), so it never matches an agent that enrolls through a gateway, which carries no peer address; and “Agent CA fingerprint …” is marked “(never matches at this release)” because Register does not yet populate the fingerprint. The match mode is two buttons (Any rule matches (first match) and All enabled rules must match) and each asks for confirmation. In “all” mode an enabled CA fingerprint rule means no agent is ever auto-approved, because that rule never matches at this release; disable or remove it, or use “any” mode. A rule that matches every agent is flagged Matches every agent.
Add an auto-approval rule:
The auto-approve endpoints use form-encoded parameters (designed for the HTMX dashboard) and return HTML fragments. The four writes below are dashboard writes: a request whose Origin (or Referer) names a different site is refused with 403; a request with neither header (curl, automation) is accepted, as for the other Settings writes.
Every write is recorded before it is applied: an attempt audit row is written first and, if it cannot be stored, the write is refused with 503 (Sec-Audit-Failed: true) and nothing changes. A rule that cannot be saved to disk is refused with 500 and nothing changes. Error responses are the standard JSON error body; the dashboard shows a toast.
curl -s -X POST http://localhost:8080/api/settings/auto-approve \
-H "Origin: http://localhost:8080" \
-b "$COOKIE" \
-d "type=hostname_glob&value=web-*.example.com&label=Production+web+servers"
This rule automatically approves any agent whose hostname matches the glob pattern web-*.example.com.
Supported rule types: hostname_glob, trusted_ca, ip_subnet, cloud_provider. A value is at most 253 characters and a label at most 128. The server refuses two shapes outright: a hostname glob made only of * characters, and an ip_subnet with a /0 prefix (for example 0.0.0.0/0). It accepts other rules that are just as broad, for example a hostname glob of only * and ? characters such as ?*. The dashboard flags every existing rule that matches every agent (Matches every agent). On Groups and enrolment the Add rule confirmation also says “This matches every agent.” when the value you typed has that shape (for a *-only glob or a /0 range the server then refuses the rule); the Settings panel asks the same fixed confirmation for every value.
These rules are not a strong admission control. hostname_glob and cloud_provider match strings the registering agent states about itself (its hostname and attestation provider); any client that can reach the Register endpoint can claim a matching value, which makes such a rule no stronger than *. ip_subnet matches the connection’s peer address: an agent that registers through a gateway (ProxyRegister) carries no peer address, so an ip_subnet rule never matches it. In all mode that means no gateway-connected agent is auto-approved while an enabled ip_subnet rule exists. trusted_ca can never match today because Register does not yet populate the CA fingerprint; in all mode an enabled trusted_ca rule therefore stops every auto-approval (all enabled rules must match). Verified proof for the first two is reserved for a later tier. Treat any-mode hostname_glob/cloud_provider rules as a convenience for a trusted network, not as a security boundary.
If the saved policy file (auto-approve.cfg) is damaged (a line with an unknown type, a short line, or a mode: other than exactly any or all), the server activates no auto-approve rules at start (every agent waits for manual approval), logs an error, and keeps the original as auto-approve.cfg.rejected; re-create the rules in Settings.
Set the auto-approve match mode:
curl -s -X POST http://localhost:8080/api/settings/auto-approve/mode \
-H "Origin: http://localhost:8080" \
-b "$COOKIE" \
-d "mode=any"
Supported modes:
| Mode | Behavior |
|---|---|
any | First matching enabled rule auto-approves the agent (default) |
all | All enabled rules must match for auto-approval |
Note: Auto-approval only applies when rules exist and are enabled. When no rules are configured, all agents enter the pending queue regardless of mode. To disable auto-approval entirely, remove or disable all rules.
Toggle a rule on/off:
curl -s -X POST http://localhost:8080/api/settings/auto-approve/0/toggle \
-H "Origin: http://localhost:8080" \
-b "$COOKIE"
Delete a rule:
curl -s -X DELETE http://localhost:8080/api/settings/auto-approve/0 \
-H "Origin: http://localhost:8080" \
-b "$COOKIE"
Rules are addressed by their position in the list, and the position shifts when another administrator adds or removes a rule. The dashboard therefore also sends the rule it is showing (type and value, and for a toggle the enabled state it saw, as form fields on the toggle and as query parameters on the delete). If the rule now at that position is a different one, or that position no longer exists, nothing changes: the answer is 409 with the refreshed list and a warning, and the audit row is denied (stale_rule_view, or rule_not_found when the position is gone). A script that states none of these keeps acting on the position, and an unknown position is a 200 no-op. To make a script safe against a concurrent edit, add them, for example -d "type=hostname_glob&value=web-*.example.com&enabled=1" on the toggle.
View the auto-approve settings fragment (HTMX):
curl -s http://localhost:8080/fragments/settings/auto-approve -b "$COOKIE"
Device Identity
Every enrolled agent reports identity attributes during registration and heartbeat:
| Field | Description | Example |
|---|---|---|
agent_id | Unique identifier (UUID) | a3f1c9e2-7b4d-4e8a-9f12-3c5d6e7f8a9b |
hostname | Machine hostname | web-prod-01 |
os | Operating system | windows, linux, darwin |
arch | CPU architecture | x86_64, aarch64 |
os_version | OS version string | Windows 11 Pro 10.0.26200 |
agent_version | Running agent binary version | 0.9.3 |
These fields are used by the Scope Engine for device targeting and by Management Groups for access scoping.
Viewing Connected Agents
Dashboard
/devices now 302-redirects to /hardware (the Hardware CI list — round-3, see docs/user-manual/inventory.md) — the old standalone Devices page no longer renders directly; a bookmarked /devices link still works, since browsers follow the redirect transparently. Hardware’s own list requires Inventory:Read rather than the old page’s Infrastructure:Read; every seeded built-in role holding one also holds the other, so no default-role regression, but a custom role granted Infrastructure:Read alone loses list access via the old URL. Each row shows hostname, OS, architecture, online status, and per-device DEX score, plus (new) agent version, claimed IP, and tags. Filter by OS or search by name; click any row to open that device’s Hardware CI record. DEX scoring is per-render, never fleet-wide: opening a record scores that one device, and the list scores only the rows actually rendered after filtering — no page load may trigger a whole-fleet scoring pass.
The list is sourced from the live connection registry, so it shows connected devices only (there is no offline/status filter). Enrolled-but-offline devices and real last-seen times arrive with the persistent device-inventory slice.
Device page (/device?id=, redirects to /hardware/ci?id=)
The per-device page is the shared entity view reached from any dashboard, organised into lens tabs — the same lens-tab machinery renders both the legacy device page’s tabs and the Hardware CI record’s Overview/DEX/Guardian tabs:
Every per-device route is scoped to the device’s management group (a global grant or a role assigned on the device’s group / an ancestor): opening a device outside your scope returns forbidden, never its data.
| Tab | Contents | Gate |
|---|---|---|
| Device info | Identity (agent ID, OS, arch, version), tags, group membership. | Infrastructure:Read, scoped to the device |
| DEX | Per-device DEX score + a summary of recent signal observations, with a link to the full DEX drill-down (which carries an Application performance over time panel — retained daily per-app-version CPU/memory from the central store, no live query, no Execute). | GuaranteedState:Read, scoped to the device. Signal-history view audited as dex.device.view; the app-perf panel audited as dex.device.app_perf.view (separate verb) |
| Guardian | Per-guard compliance state for the device (guard, state, last evaluated). | GuaranteedState:Read, scoped to the device (audited as guardian.device.view) |
A device with zero reported guards renders “No guards evaluated” even if the fleet-wide rule catalogue read is itself degraded — the per-device read no longer depends on the catalogue’s own health for a zero-row device (a small, disclosed rendering delta from the ADR-0031 WS-A4 seam rewire).
Get live info
The Get live info button (shown when the device is online) dispatches read-only instructions to the agent now — not from cached heartbeat data — and renders an Echo-styled live snapshot: a KPI strip (uptime, process/service/connection/user counts) over a grid of collapsible, uniformly-sized cards. Cards are collapsed by default; an Expand all / Collapse all control toggles them together, and each card has a pop-out (⤢) for a larger view. Each card is one live query against the OS, dispatched through the same proven chokepoint as before.
Each card has its own audit verb so a usage-class read (what a person is running) stays separately countable from a machine-health read — the works-council posture (see Audit log):
| Card | Source | Audit verb | OS |
|---|---|---|---|
| Uptime (KPI) | os_info/uptime | device.live.uptime | all |
Processes — a parent→child tree (Echo /echo viewer style), each node showing the SHA-256 of its on-disk image and its live network connections (joined by PID, public endpoints highlighted); suspicious parent→child spawns are flagged. Searchable by name / PID / hash / endpoint. | processes/list_tree + network_diag/connections | device.live.process_tree | tree all; hash all; connection join Windows |
| Services — run state | services/list | device.live.services | all |
| Adapters & IP | network_config/ip_addresses | device.live.netconfig | all |
| ARP / neighbours | network_config/arp | device.live.arp | all (constrained on Linux/macOS) |
| DNS cache | network_config/dns_cache | device.live.dns_cache | Windows, Linux |
| Listening ports | network_diag/listening | device.live.listening | all |
| Active connections | network_diag/connections | device.live.connections | all |
| Logged-in users | users/logged_on | device.live.users | all |
Capture sources — read-only view of which Echo warehouse sources are capturing locally (toggle state + $-table + live row count); configure on the Echo page | echo/status | device.live.capture_sources | all |
| Disk space — free/used for the root volume with a colour-coded usage bar (>=90% used or <5 GiB free = red) | disk_space/free | device.live.disk | all |
Every card requires Execution:Execute in addition to GuaranteedState:Read, each scoped to the device’s management group; without Execute the card shows an explanatory note rather than failing silently, and a device outside your scope cannot be live-queried at all. The process tree, connections, logged-in-user, and DNS-cache reads are usage-class behavioral telemetry (a person’s running software, active connections, sessions, and resolved names) and are individually audit-logged; the remaining kinds (uptime, services, adapters/IP, listening ports, ARP, capture sources, disk space) are machine-health reads.
The ARP card now works on all three platforms, with per-OS limits: Windows reports IPv4 ARP and IPv6 neighbours including incomplete entries; Linux reports IPv4 ARP only (IPv6 neighbours live in a separate kernel table, and incomplete or non-Ethernet entries are skipped); macOS reports address and MAC only, with the interface name and static/dynamic type shown as - because the routing-socket dump does not carry them. The DNS-cache card works on Windows and Linux (resolvectl, falling back to systemd-resolve --statistics) but not on macOS, which exposes no resolver-cache contents; that card renders an honest “not available on this OS” note there. On a host with many distinct large executables the process card can take up to ~30 seconds (it hashes each on-disk image; the SDK sha256_file helper is YUZU_EXPORT, capped at 512 MiB per file, and deduplicates repeated images); a “Waiting for the device to respond…” message followed by a timeout with a Reload to retry prompt is normal, not a failure.
Result-poll scoping invariant (#1634). The live-snapshot result poll is scoped at the store seam: the route’s ResponsesFn threads the device’s agent_id into ResponseQuery{.agent_id}, so the response store only ever returns that device’s rows. The route-level post-filter is retained purely as defense-in-depth — a future refactor that dropped it must still be unable to leak cross-agent rows. Any new poll surface must scope at the query, never rely on post-filtering alone.
The Processes (tree) and ARP cards depend on the processes/list_tree and network_config/arp agent actions, which ship with agents built from this release onward. An older agent that predates them returns an unknown action response, which currently renders as an empty card — upgrade the agent to populate those cards. (Tracked follow-up: have the agent emit an explicit “unsupported on this agent version” note instead of an empty card.)
The machine-readable equivalents (for agentic workers and automation) are GET /api/v1/dex/devices/{id} (the per-device DEX read model), GET /api/v1/dex/devices/{id}/app-perf (the REST twin of the Application performance over time panel — the retained per-(app, version, day) history, same dex.device.app_perf.view audit, fail-closed), and POST /api/v1/dex/devices/{id}/live?kind=uptime|processes (the live dispatch — POST because it has a side effect; the dashboard’s additional cards — process tree, services, users, network, capture sources — are dashboard-only pending the REST/JSON A1 backfill #1649). They enforce the same scoped permissions and emit the same audit verbs as these panels. They differ in failure mode, though: the REST endpoints are audit-fail-closed — if the audit row cannot persist they return 503 + Sec-Audit-Failed: true and serve no data (or, for /live, dispatch nothing), whereas these dashboard panels set Sec-Audit-Failed: true on the response but continue to render (a transient audit hiccup must not blank the dashboard). Alert on Sec-Audit-Failed: true from either surface as a SOC 2 CC7.2 evidence-gap signal. See REST API — DEX.
Physical
The Hardware CI record’s own Live lens (/hardware → device → Live tab) hx-gets the same /fragments/device/live/* fragments as the device page above — same dispatch-and-poll machinery, same cards — and adds ten physical-hardware cards, each wiring an already-shipped agent plugin action into the shared card renderer:
| Card | Source | Audit verb | OS |
|---|---|---|---|
| Disks — per-disk model, capacity, media type, and interface | hardware/disks | device.live.hw_disks | all |
| Memory — installed memory modules: slot, capacity, type, speed | hardware/memory | device.live.hw_memory | all |
| Processors — installed CPU(s): model, cores, threads, clock speed | hardware/processors | device.live.hw_processors | all |
| Drivers — installed device drivers: name, version, date, provider, class | hardware/drivers | device.live.hw_drivers | Windows, Linux |
| Battery — presence, charge state, percent, time-to-empty, cycle count, health | power_health/battery | device.live.battery | all |
| Thermal — thermal zone status and temperature | power_health/thermal | device.live.thermal | all |
Disk health (SMART) — per-disk model, bus and media type, plus SMART health, percent used and spare percent for NVMe drives on Windows only (health is unknown on macOS and for SATA, USB and virtual disks, whose percent used and spare percent are -) | disk_actions/smart | device.live.smart | Windows, macOS |
| Volumes — mounted volumes: mount points, backing device, filesystem, size | disk_actions/volumes | device.live.volumes | Windows, macOS |
| Network adapters — adapter name, MAC, link speed, status | network_config/adapters | device.live.adapters | all |
| Wi-Fi — current association: SSID, signal, security, BSSID/channel | wifi/connected | device.live.wifi | all |
All ten are machine-health reads (hardware attributes, not per-user behavioral data) and require Execution:Execute plus GuaranteedState:Read, scoped to the device’s management group — same gating as every other live card. Drivers is Windows- and Linux-only: the agent enumerates no equivalent driver registry on macOS. Disk health (SMART) and Volumes are Windows- and macOS-only: the disk_actions plugin’s Linux leg does not back either action. The remaining six (Disks, Memory, Processors, Battery, Thermal, Network adapters, Wi-Fi) report on all three platforms.
Deferred: disk-encryption status. BitLocker/FileVault/LUKS state (bitlocker/state) was investigated for this round and dropped, not shipped. Its three platform legs return incompatible wire shapes — Windows a 5-field volume/conversion/percent/method/protection record, Linux a 3-field volume/crypto-type/active-or-inactive record with entirely different field semantics at the same positions, and macOS a bare 1-field enabled/disabled flag — so one generic column table would mislabel the Linux and macOS data. It needs a bespoke per-OS renderer rather than the shared card table; filed as a follow-up.
REST API
List all agents (legacy endpoint):
This endpoint does not require authentication. It returns only currently connected agents (those with an active gRPC session).
curl -s http://localhost:8080/api/agents
Response:
[
{
"agent_id": "a3f1c9e2-7b4d-4e8a-9f12-3c5d6e7f8a9b",
"hostname": "web-prod-01",
"os": "windows",
"arch": "x86_64",
"agent_version": "0.9.3"
}
]
Note: This legacy endpoint does not include status or last_heartbeat fields. It only lists agents with active gRPC streams (implicitly online).
Agent Heartbeat and Session Keepalive
After registration, agents maintain a persistent gRPC bidirectional stream with the server. The heartbeat serves three purposes:
- Liveness — the server marks agents as offline if no heartbeat is received within the configured timeout.
- Metadata refresh — agents report updated OS version, IP address, and plugin inventory on each heartbeat.
- Command delivery — the server uses the open stream to push
CommandRequestmessages to the agent without polling.
Heartbeat interval is configured agent-side (default: 30 seconds). The server’s offline threshold is typically 3x the heartbeat interval.
Agent CLI Flags
The full set of agent command-line flags:
| Flag | Description | Default |
|---|---|---|
--server | Server address (host:port) | localhost:50051 |
--enrollment-token | Pre-shared token for Tier 2 enrollment | (none) |
--enrollment-token-file | File holding the enrollment token (first line); read once at start (under a 5 s deadline) and removed after the first accepted registration or when the server refuses the token. A token read from it takes precedence over --enrollment-token / YUZU_ENROLLMENT_TOKEN; if the file is absent or unusable, the option or environment value stands. Accepted only if the first line is a 64-hex-character token (anything else, including a yuzu_enroll_...-shaped string, is ignored with a warning rather than sent, unlike --enrollment-token, which sends whatever it is given and lets the server reject it) and the opened file is owned by SYSTEM or root, by Administrators (Windows) or by the account the agent runs as, and on Linux and macOS is not group- or other-writable and is readable by the agent (for example mode 0640 root:agent or 0600 agent-owned); a file that fails the owner or mode rule is ignored with a warning. The check reads mode bits only; macOS ACLs are not consulted (the owner rule still applies). On Windows only a path on a local fixed drive is read; a network-share, removable-drive or extended-length (\\?\) path is ignored with a warning. The agent removes the file after the first accepted registration: under the shipped systemd unit (ProtectSystem=strict, only /var/lib/yuzu-agent writable) a file elsewhere is read but cannot be removed, so place it under /var/lib/yuzu-agent. When the server refuses the token (unknown, expired, revoked or used up) the agent removes the file, and its next start registers without the file’s token (with an --enrollment-token / YUZU_ENROLLMENT_TOKEN value if one is set, otherwise without a token, into the approval queue); a file that could not be removed is read and presented again on the next start, so keep it where the agent can delete it (see the placement rule above); a refusal that is not about the token, such as an administrator denying the agent, leaves the file in place, and to enrol with a token again you supply a current one; do not bake a token file into a golden image or VDI template (each machine started from it uses one of the token’s uses, and a non-persistent image that still carries a token the server has expired, revoked or used up adds a new entry to Pending Agent Approvals on every boot, because each boot is a new agent id; the queue is not capped). The Windows installer writes it to %ProgramData%\Yuzu\enrollment-token (Administrators and SYSTEM only) for the service, so a service it registers does not carry the token on its command line. An agent older than this option exits with code 109 when started with it. Env: YUZU_ENROLLMENT_TOKEN_FILE | (none) |
--ca-cert | Path to CA certificate PEM file (verifies the server) | (auto-discover) |
--client-cert | Path to client certificate PEM file (mTLS) | (auto-discover) |
--client-key | Path to client private key PEM file (mTLS) | (auto-discover) |
--cert-store | Windows certificate store name (e.g. MY) | (none) |
--cert-subject | Subject CN match for cert store lookup | (none) |
--cert-thumbprint | SHA-1 thumbprint for cert store lookup (hex) | (none) |
--no-cert-discovery | Disable client-certificate auto-discovery. By default, when no --client-cert and no --cert-store is given, the agent looks for a client certificate and key pair in YUZU_CLIENT_CERT/YUZU_CLIENT_KEY, then (Linux and macOS only) at /etc/yuzu/agent.pem + /etc/yuzu/agent-key.pem, /etc/pki/tls/certs/yuzu-agent.pem + /etc/pki/tls/private/yuzu-agent.pem, and /etc/ssl/certs/yuzu-agent.pem + /etc/ssl/private/yuzu-agent.pem, using the first pair whose files both exist. Windows checks only the environment variables. This flag turns that search off; it does not affect discovery of the install CA used to verify the server. It does not stop YUZU_CLIENT_CERT/YUZU_CLIENT_KEY from being used: those are also the environment bindings of --client-cert/--client-key, so when set they still configure the client certificate | (enabled) |
--cert-dir | Directory for the auto-provisioned per-agent mTLS credential (env YUZU_CERT_DIR) | <data-dir>/certs |
--no-auto-provision-cert | Disable PKI auto-provisioning (do not request a per-agent client certificate at enrollment) | (enabled) |
--tls-system-roots | Allow the agent to verify the server against the operating system’s trust store when no CA is pinned (no --ca-cert, and no install CA found at the standard path, for example /etc/yuzu/certs/default-ca.pem or C:/ProgramData/Yuzu/certs/default-ca.pem). Without it the agent refuses to connect in that situation, because the system store does not trust a Yuzu self-signed install CA and accepting any publicly trusted certificate would allow an impostor server. Use it only when the server certificate chains to a public or corporate CA already in the system store; the agent logs a warning when it is in effect. Env: YUZU_TLS_SYSTEM_ROOTS, read by value: 1/true/yes/on enable it, 0/false/no/off or unset leave it off. The flag can only turn it on | off |
--plugin-dir | Directory containing plugin shared libraries (env YUZU_PLUGIN_DIR). The default is <exe_dir>/../plugins, where <exe_dir> is the directory part of the path the agent was started by (argv[0]), not resolved through PATH or symlinks. Started by absolute path, as the published agent image (yuzu-agent-chisel, ENTRYPOINT ["/usr/local/bin/yuzu-agent"]) does, it is /usr/local/plugins. Nothing is installed there: the image’s default CMD passes --plugin-dir /usr/lib/yuzu/plugins. Started by bare name through PATH, argv[0] has no directory part, so the default resolves against the working directory (<cwd>/../plugins). A compose command: (or docker run argument) override replaces the image’s CMD, so it must restate --plugin-dir | <exe_dir>/../plugins |
--log-level | Logging verbosity (trace, debug, info, warn, error; lowercase and case sensitive, and an unrecognised value, including WARN, is treated as off; --verbose forces trace whatever this says). The agent_actions plugin’s set_log_level action (needs Infrastructure:Write and, through the REST command dispatch, Execution:Execute) changes it at runtime but does not persist it, so it reverts when the agent restarts (env YUZU_LOG_LEVEL) | info |
--log-file | Path for an on-disk log file, written in addition to the console. A Windows service agent has no console, so it defaults to yuzu-agent.log under its data directory (env YUZU_LOG_FILE) | (none) |
--log-max-size | Size in bytes at which the agent’s log file rotates. Applies whenever the agent writes a log file (--log-file, or the Windows-service default above), otherwise ignored (env YUZU_LOG_MAX_SIZE) | 52428800 (50 MB) |
--log-max-files | Number of rotated log files kept. Applies whenever the agent writes a log file, otherwise ignored (env YUZU_LOG_MAX_FILES) | 5 |
--debug | Sets the agent’s debug_mode configuration value to true. In this release it enables no extra behaviour: the only effect is that the status plugin’s connection output reports debug_mode as true. It does not change the log level; use --log-level debug or --verbose for more logging. No environment variable | off |
--update-check-interval | Seconds between OTA update checks. Values are clamped to between 1 second and 8,760 hours (one year); the startup log line OTA update checker started (interval=…s) shows the effective value. Ignored with --no-auto-update (env YUZU_UPDATE_CHECK_INTERVAL) | 21600 (6 hours) |
Agent logging is asynchronous (#4666 PR-2): a producer thread enqueues a formatted line onto a fixed-size 8192-slot queue and returns; one dedicated worker thread does the actual sink I/O, so a stalled log destination no longer blocks the agent. Under sustained overload the queue silently drops the oldest still-queued lines (overrun_oldest) rather than growing or blocking. There is no --log-sync flag to opt back into synchronous logging. A shutdown that cannot tear this logger down within an internal 2-second grace, or that fails outright while doing so, self-exits with a new code, 5 (distinct from the existing 1/3/4); see “Stopping a wedged agent” in Server Administration for the full exit-code reference and the async-logging behavior details, and “If log volume matters” in the same page for the level-gating consequence for the SIGINT/SIGTERM shutdown log line (it goes silent at --log-level warn or above).
Per-agent mTLS auto-provisioning (PKI)
When the server runs with its built-in CA and the agent has no operator-supplied
client certificate (--client-cert/--cert-store) but does have the server CA
(--ca-cert), the agent automatically obtains its own client certificate at
enrollment: it generates an EC P-256 keypair + CSR, the server signs a per-agent
leaf (CN=<agent-id>) and returns it, and the agent persists the leaf + key
(mode 0600) + issuing chain under --cert-dir, then reconnects with mutual TLS.
The leaf auto-renews once two-thirds of its lifetime has elapsed (checked at agent
start). Pass --no-auto-provision-cert to opt out (e.g. when you supply your own
client certificate). See docs/auth-architecture.md “Per-agent mTLS”.
This applies equally to agents enrolling through the Erlang gateway — the
gateway relays the RegisterResponse verbatim and signs the forwarded CSR
(ProxyRegister, PKI PR5d), so the per-agent leaf reaches the agent on both the
direct and gateway paths.
Files written under --cert-dir (default <data-dir>/certs):
| File | Contents | Mode |
|---|---|---|
agent-client.key | The agent’s EC P-256 private key — never leaves the host, never sent to the server | 0600 |
agent-client.pem | The per-agent client leaf the server issued (CN=<agent-id>) | 0644 |
agent-ca.pem | The issuing CA chain the agent pins the server against | 0644 |
Key loss → automatic re-provisioning. If agent-client.key is deleted or
corrupted, the agent transparently re-provisions on its next enrollment: it
generates a fresh keypair + CSR and the server issues a new leaf with a
new serial. The previous serial remains in the server’s issued-cert inventory
(GET /api/v1/ca/issued, which a management-group-confined account, or one with no Inventory:Read grant, is refused) as an orphaned row that no live agent holds — this is
harmless, but if you reconcile the inventory you should expect one orphan per
key-loss event and may revoke it for tidiness. No manual re-enrollment is needed.
The same happens when the key is unreadable or unparseable at startup, or when
agent-client.pem no longer matches agent-client.key (an interrupted renewal
replaced the key but not the leaf): the agent logs agent_csr: ... treating the credential as missing, enrols without a client certificate (accepted under
gradual enforcement) and receives a new leaf. A transient read failure of the key
file triggers this too. After upgrading to this version, any agent already
holding a mismatched pair re-enrols once on its first restart; the signer applies
a per-agent 30 s floor and no global limit, so expect one issued row per such
agent.
Revocation is not bypassable by key deletion. Auto-re-provisioning applies only when the agent’s prior cert was not revoked. If you revoke an agent’s certificate (
POST /api/v1/ca/revoke) and that agent then deletes its key and reconnects, the server refuses to issue a fresh leaf (auditca.cert.reissue_blocked, metricyuzu_server_ca_reissue_blocked_total) — the agent cannot silently resurrect a revoked identity. To bring a revoked agent back, an operator must deliberately re-approve it (clearing the revocation), not merely let it re-enroll.
Gateway-proxied agents: per-agent mTLS revocation is enforced on direct-connect agents only. An agent that reaches the server through a gateway presents its leaf to the gateway, not the server, so revoking that agent’s certificate (
POST /api/v1/ca/revoke) does not by itself cut it off the data plane — the server never sees the agent’s leaf on the gateway→server hop. To decommission a gateway-proxied agent promptly, also disconnect it at the gateway/management layer. Through-gateway cryptographic revocation is tracked in #5578 (no committed release). Seedocs/auth-architecture.md“Gateway-proxied agents: revocation scope”.
Windows Certificate Store Integration
On Windows, agents can read their mTLS client certificate and private key directly from the Windows certificate store instead of PEM files on disk. This uses CryptoAPI/CNG (CertOpenStore, CertFindCertificateInStore, NCryptExportKey) and searches the Local Machine store first, falling back to Current User.
By subject name:
yuzu-agent --server server.example.com:50051 \
--cert-store MY \
--cert-subject "yuzu-agent"
By thumbprint:
yuzu-agent --server server.example.com:50051 \
--cert-store MY \
--cert-thumbprint "AB12CD34EF56..."
The agent exports the full certificate chain (leaf + intermediates) as PEM in memory. Either --cert-subject or --cert-thumbprint is required when --cert-store is specified.
Over-the-Air Agent Updates
Administrators can upload new agent binaries to the server and promote them to production. Connected agents will download and apply the update automatically.
Upload a New Binary
Dashboard: Navigate to Settings > Plugins and updates (or the Agent Updates panel on the Settings page) and use the upload form. The upload names the version (the file name without its extension). On Settings > Plugins and updates the upload and each rollout change ask you to confirm; the older Settings page does not. Uploading a package with the same platform, architecture and version replaces it and resets its rollout and mandatory flag. The rollout percentage is the share of devices that may be offered the package, not how many have updated.
API:
The upload endpoint accepts multipart form data. The version is derived automatically from the uploaded filename (with common extensions like .tar.gz, .zip, .exe, .msi, .bin stripped).
curl -s -X POST http://localhost:8080/api/settings/updates/upload \
-H "Origin: http://localhost:8080" \
-b "$COOKIE" \
-F "file=@yuzu-agent-0.9.4-linux-x64.tar.gz" \
-F "platform=linux" \
-F "arch=x86_64" \
-F "rollout_pct=100" \
-F "mandatory=true"
| Field | Required | Description |
|---|---|---|
file | Yes | The agent binary to upload |
platform | Yes | Target platform (linux, windows, darwin) |
arch | Yes | Target architecture (x86_64, aarch64) |
rollout_pct | No | Rollout percentage, 0—100 (default: 100). An empty value is the default; a malformed value (0x5, 5abc, 1e1) is 400 and nothing is stored, it never becomes 100; an out-of-range integer is clamped to 0—100 |
mandatory | No | Whether agents must apply this update (true/false, default: false) |
View Available Updates
curl -s http://localhost:8080/fragments/settings/updates -b "$COOKIE"
Set Rollout Percentage
The rollout endpoint controls what percentage of eligible agents receive a given update. Set to 100 for full deployment, or use a lower value for staged rollouts.
curl -s -X POST http://localhost:8080/api/settings/updates/linux/x86_64/0.9.4/rollout \
-H "Origin: http://localhost:8080" \
-b "$COOKIE" \
-d "rollout_pct=100"
To do a staged rollout, set a lower percentage first and increase it over time:
# Start with 10% of agents
curl -s -X POST http://localhost:8080/api/settings/updates/linux/x86_64/0.9.4/rollout \
-H "Origin: http://localhost:8080" \
-b "$COOKIE" \
-d "rollout_pct=10"
Remove an Uploaded Binary
curl -s -X DELETE http://localhost:8080/api/settings/updates/linux/x86_64/0.9.4 \
-H "Origin: http://localhost:8080" \
-b "$COOKIE"
Settings Page Route Reference
Most enrollment and device management operations are performed through the HTMX dashboard. The endpoints below are designed for the dashboard (accepting form-encoded parameters and returning HTML fragments), with the exception of the batch token endpoint which accepts and returns JSON.
HTMX Fragments (Server-Rendered HTML)
| Route | Description |
|---|---|
GET /fragments/settings/pending | Pending agents queue (also hosted by /groups) |
GET /fragments/settings/tokens | Enrollment tokens list (also hosted by /groups) |
GET /fragments/settings/auto-approve | Auto-approval policies (also hosted by /groups) |
GET /fragments/settings/updates | OTA agent updates |
Action Endpoints
Unless noted, these endpoints accept form-encoded parameters and return HTML fragments (designed for HTMX consumption).
| Method | Route | Description | Format |
|---|---|---|---|
GET | /api/agents | List all connected agents (no auth required) | JSON |
POST | /api/settings/pending-agents/{agent_id}/approve | Approve a pending agent | HTMX |
POST | /api/settings/pending-agents/{agent_id}/deny | Deny a pending agent | HTMX |
DELETE | /api/settings/pending-agents/{agent_id} | Remove from pending queue | HTMX |
POST | /api/settings/enrollment-tokens | Generate an enrollment token (form-encoded) | HTMX |
POST | /api/settings/enrollment-tokens/batch | Generate tokens in batch (JSON in, JSON out) | JSON |
DELETE | /api/settings/enrollment-tokens/{token_id} | Revoke a token | HTMX |
POST | /api/settings/auto-approve | Add a rule (form: type, value, label); 400 and nothing saved for an unknown type, empty value, : or control character in value or label, malformed ip_subnet (IPv4 with optional /0-32) or trusted_ca that is not 64 hex characters | HTMX |
POST | /api/settings/auto-approve/mode | Set match mode (form: mode=any|all); other values return 400 | HTMX |
POST | /api/settings/auto-approve/{index}/toggle | Enable/disable a rule | HTMX |
DELETE | /api/settings/auto-approve/{index} | Delete a rule | HTMX |
POST | /api/settings/updates/upload | Upload an agent binary (multipart) | HTMX |
POST | /api/settings/updates/{platform}/{arch}/{version}/rollout | Set rollout percentage (form: rollout_pct; a malformed value is 400 with an error toast and nothing changes) | HTMX |
DELETE | /api/settings/updates/{platform}/{arch}/{version} | Remove an uploaded binary | HTMX |
Custom Device Properties
Administrators can define typed key-value properties on devices beyond the built-in identity fields and structured tags. Properties support four types: string, int, bool, and datetime.
Property Schemas
Before setting properties, define a schema to enforce type and validation rules.
Create a schema:
curl -s -X POST http://localhost:8080/api/v1/custom-properties/schemas \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"key": "asset_owner",
"type": "string",
"description": "Department or individual responsible for this device",
"validation_regex": "^[a-zA-Z0-9_ -]{1,128}$"
}'
Supported types: string, int, bool, datetime. Key names must be 1-64 characters matching [a-zA-Z0-9_.-:]. Values are limited to 1024 bytes.
Setting Properties
curl -s -X PUT http://localhost:8080/api/v1/devices/agent-001/properties/asset_owner \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"value": "IT Operations"}'
Using Properties in Scope Expressions
Custom properties are available in scope expressions using the props.<key> syntax:
props.asset_owner == "IT Operations" AND ostype == "windows"
Device Discovery
Discover unmanaged devices on your network using the discovery agent plugin and server-side discovery store.
Running a Subnet Scan
Execute the scan_subnet action from the discovery plugin against an agent on the target subnet:
curl -s -X POST http://localhost:8080/api/v1/executions \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"definition_id": "discovery.scan_subnet",
"parameters": {"subnet": "192.168.1.0/24"},
"scope": "agent_id == \"agent-on-target-subnet\""
}'
The plugin performs an ARP scan and ping sweep, returning discovered hosts with IP address, MAC address, resolved hostname, and whether the host is already managed by Yuzu.
Viewing Discovered Devices
curl -s http://localhost:8080/api/v1/discovery \
-H "Authorization: Bearer $TOKEN"
Marking a Device as Managed
When a discovered device is enrolled, link it to the enrolled agent:
curl -s -X POST http://localhost:8080/api/v1/discovery/{device_id}/mark-managed \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"agent_id": "a3f1c9e2-7b4d-..."}'
Deployment Jobs
Not functional; scheduled for removal (#6326). Deployment jobs are record-only. The server stores a row and lets you list, read and cancel it, but nothing executes a job and nothing advances it past
pending: no agent is installed on any host. Thesshandgroup_policymethods are accepted by validation but not implemented, and formanualthe server only returns a placeholder install command (it points at the literal hostyuzu-server, not your server). The route is the unversioned/api/deployment-jobs; there is no/api/v1/deployment-jobs. To get the agent onto a host, install it on that host directly; to install software on devices that are already enrolled, use the/autoDeploy workflow.
The record-only API below still answers today. It needs Infrastructure:Read (list, get) or Infrastructure:Write (create, cancel).
Creating a Deployment Job Record
Each job records exactly one host (target_host). os is windows, linux (default) or darwin; method is ssh, group_policy or manual (default). target_host must be a hostname or IP of at most 253 characters using only [a-zA-Z0-9._-], and must not start or end with -.
curl -s -X POST http://localhost:8080/api/deployment-jobs \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"target_host": "192.168.1.10",
"os": "linux",
"method": "manual"
}'
The response is 201 with {"id": "...", "status": "pending"}, plus a placeholder install_command when method is manual.
Job States
The store defines the states below, but only pending and cancelled are ever reached through the product. No caller moves a job to running, completed or failed.
| State | Reachable today | Description |
|---|---|---|
pending | Yes | Job record created; nothing will start it |
running | No | Defined, never set |
completed | No | Defined, never set |
failed | No | Defined, never set |
cancelled | Yes | Job was cancelled through the API |
Reading Job Records
curl -s http://localhost:8080/api/deployment-jobs \
-H "Authorization: Bearer $TOKEN"
curl -s http://localhost:8080/api/deployment-jobs/{job_id} \
-H "Authorization: Bearer $TOKEN"
Cancelling a Job
curl -s -X DELETE http://localhost:8080/api/deployment-jobs/{job_id} \
-H "Authorization: Bearer $TOKEN"