The Power Manage Agent runs on managed devices and executes actions dispatched from the Control Server. It supports autonomous operation, executing scheduled actions even when disconnected from the server.
The executor delegates low-level system operations to the SDK's sys/ packages (sys/exec, sys/fs, sys/user, sys/systemd), keeping the agent focused on action dispatch, idempotency checks, and result reporting.
LUKS executor exception (audit F039): the LUKS action type does NOT delegate purely to
sys/encryption. Key material flows through a separateLuksKeyStoreinterface that proxies to the gateway over the live SDK stream — the executor callse.luksKeyStore.GetKey/StoreKey/RotateKey, those methods round-trip through the connectedsdk.Clientto the gateway, and the gateway forwards to the control server's encrypted key store. Implication for operators: LUKS rotations cannot proceed when the agent is disconnected from the gateway; an offline agent will see the action in its scheduled queue but fail at theGetKeycall with "LUKS key store not configured (no stream connection)". This is intentional fail-closed behaviour — no LUKS operation should rely on a stale local key.
┌─────────────────────────────────────────────────────────────────┐
│ Agent │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ Handler │ │ Scheduler │ │ Executor │ │
│ │ │ │ │ │ │ │
│ │ - Server │ │ - Cron/ │ │ - Package management │ │
│ │ commands │ │ Interval │ │ - Shell scripts │ │
│ │ - Reports │ │ execution │ │ - File management │ │
│ │ results │ │ - Offline │ │ - Systemd units │ │
│ └─────────────┘ │ capable │ │ - User/group management │ │
│ │ └──────┬──────┘ │ - SSH/sudo policies │ │
│ │ │ └───────────┬─────────────┘ │
│ └────────────────┴─────────────────────┘ │
│ │ │ │
│ ┌─────▼─────┐ ┌─────▼──────────────┐ │
│ │ Results │ │ SDK sys/ packages │ │
│ │ Store │ │ (exec, fs, user, │ │
│ └───────────┘ │ systemd) │ │
│ └────────────────────┘ │
└───────────┬─────────────────────────────────────┬───────────────┘
│ (1) Register │ (2) Stream
▼ ▼
┌───────────────────────────┐ ┌─────────────────────────────────┐
│ Control Server (RPC) │ │ Gateway Server (mTLS gRPC) │
│ - Token validation │ │ - Bidirectional streaming │
│ - Certificate signing │ │ - Action dispatch │
│ - Returns gateway URL │ │ - Heartbeats & results │
└───────────────────────────┘ └─────────────────────────────────┘
The agent supports two enrollment methods:
- The install script starts the agent as a systemd service
- The unenrolled agent opens an enrollment socket at
/run/pm-agent/enroll.sock(mode 0666, any local user can connect) - A regular user runs
power-manage-agent enroll -server=URL -token-file=PATH(orPM_REGISTRATION_TOKEN=… power-manage-agent enroll -server=URL) - The CLI sends an
EnrollRPC to the agent over the unix socket - The agent calls the Control Server
RegisterRPC with the token and a locally-generated CSR - The Control Server validates the token, signs the certificate, and returns credentials
- The agent saves credentials, closes the enrollment socket, starts the auth socket, and connects to the gateway
Trust boundary (deliberate self-service design). The enrollment socket is intentionally world-accessible (mode
0666) so a non-root user can enroll their own corporate/BYOD device without sudo — that is the whole point of the socket. The security boundary is the registration token, validated by the Control Server; an admin who does not want self-service can pre-enroll devices with a bulk registration token instead. The agent applies a global rate limit of 5 enrollment attempts per minute, so a local user can briefly disrupt a legitimate enrollment by flooding bad tokens — acceptable for a self-hosted MDM, not for adversarial multi-tenant hosts.
Hardening (WS9).
server_urlmust be https (cleartext/opaque URLs are refused before any network call). Token delivery is via-token-fileor thePM_REGISTRATION_TOKENenv var; passing-tokenon argv still works but warns (it leaks via/proc/<pid>/cmdline). Optionally the operator can pass an out-of-band CA fingerprint pin (-pin, or&pin=in apower-manage://URI): the agent verifies the Control Server CA matches the pin before trusting it, defending against a first-enrollment trust-anchor swap. Without a pin, first enrollment is trust-on-first-use — an accepted residual mitigated by enroll-at- install plus short-lived, single-use, revocable tokens. See ADR 0013.
┌─────────────────────────┐
User (no sudo) │ Agent (systemd svc) │
power-manage-agent enroll ───► │ /run/pm-agent/ │
-server=URL -token=TOK │ enroll.sock (0666) │
│ │ │
│ Register RPC ──────► Control Server
│ │ │ - Validate token
│ Save credentials │ - Sign certificate
│ Close enroll socket │ - Return gateway URL
│ Start auth socket │
│ Connect to gateway │
└─────────────────────────┘
For environments where the agent is not yet running as a service, direct enrollment still works:
sudo power-manage-agent -server=URL -token=TOKENThis is equivalent to the previous behavior: the agent registers directly, saves credentials, and starts.
Both enrollment methods use the same underlying protocol:
- The agent generates an ECDSA P-256 key pair and CSR locally — the private key never leaves the device
- The Control Server validates the registration token, signs the certificate, and returns:
- Device ID
- Signed mTLS certificate
- CA certificate
- Gateway URL
- The agent stores the encrypted credentials and connects to the Gateway using mTLS for streaming communication
# Download, install, and enroll in one step
curl -fsSL https://your-server/install.sh | sudo bash -s -- \
--server control.example.com:8081 \
--token YOUR_REGISTRATION_TOKENThe install script:
- Downloads and installs the agent binary (verifies SHA256 against the publisher's
SHA256SUMS) - Creates
/var/lib/power-manageas a root-owned, mode 0700 data directory - Removes any leftover
/etc/sudoers.d/power-manage,/etc/sudoers.d/power-manage-luks, or/etc/doas.d/power-manage.conffrom a previous (pre-root-mode) install - Installs the systemd unit with
User=rootand the documented capability bounding set, then enables and starts the service - Enrolls via the enrollment socket if
--serverand--tokenwere provided
The power-manage:// desktop URI handler is opt-in (--enable-uri-handler or POWER_MANAGE_ENABLE_URI_HANDLER=true) and off by default — an unconditional handler exposes the root-capable binary to drive-by browser links (WS7 #4). When enabled, the .desktop entry sets Terminal=false so a link cannot auto-spawn a terminal.
There is no LUKS sudoers rule — power-manage-agent luks set-passphrase is an unprivileged client to the root agent's LUKS daemon socket (see LUKS passphrase daemon).
The legacy --user flag is accepted but ignored — the agent runs as root and no service user is created.
# Build the agent
go build -o power-manage-agent ./agent/cmd/agent
# Start the agent as root (it will wait for enrollment)
sudo ./power-manage-agent
# In another terminal (as any user), enroll via the local socket:
power-manage-agent enroll -server=https://control.example.com:8081 -token=YOUR_TOKENFor a production-style manual install (without the install script):
# 1. Place the binary
sudo install -m 0755 power-manage-agent /usr/local/bin/
# 2. Create the data directory
sudo mkdir -p /var/lib/power-manage && sudo chmod 700 /var/lib/power-manage
# 3. Write the systemd unit (User=root, RuntimeDirectory=pm-agent for the
# enrollment socket). See install.sh for the full unit including
# the AmbientCapabilities / CapabilityBoundingSet block.
sudo systemctl daemon-reload
sudo systemctl enable --now power-manage-agent
# 4. Enroll (any user, no sudo needed)
power-manage-agent enroll -server=https://control.example.com:8081 -token=YOUR_TOKENThe agent supports a power-manage:// URI scheme for easy enrollment from the web UI.
When clicked, the desktop handler launches the agent which tries socket enrollment first (no sudo), falling back to direct registration.
The desktop handler that registers this scheme is opt-in (
--enable-uri-handler, off by default — WS7 #4). The CLI invocation below works regardless; only the clickable browser-link registration requires the handler.
power-manage-agent 'power-manage://control.example.com:8081?token=abc123'URI Parameters:
server:port- Control server address (required)token- Registration token (required for first run)tls=false- Use HTTP instead of HTTPS
TLS verification is mandatory; the previous
skip-verifyURI parameter was removed (it has no effect on current builds).
| Subcommand | Description |
|---|---|
enroll |
Enroll the agent via the enrollment socket (no sudo required). Supports -s/-t shorthand flags. |
version |
Print agent version |
setup |
No-op deprecation notice (agent now runs as root; see Security section) |
query |
Query system information via osquery |
luks |
LUKS passphrase management |
tty |
Manage the device-local remote terminal toggle (enable / disable / status) |
self-test |
Connectivity probe used by the self-update flow to validate a new binary before installing |
| Flag | Default | Description |
|---|---|---|
-server |
(required) | Control server URL (used for registration) |
-token |
(optional) | Registration token (first run only) |
-data-dir |
/var/lib/power-manage |
Data directory for state |
-log-level |
info |
Log level (debug, info, warn, error) |
TLS verification is mandatory; the previous
-skip-verifyflag was removed (along with thePOWER_MANAGE_SKIP_VERIFYenv var below).
When both a flag and an env var are set, the env var wins (flag defaults are applied first, then env vars override).
| Variable | Description |
|---|---|
POWER_MANAGE_SERVER |
Control server URL (used for registration) |
POWER_MANAGE_TOKEN |
Registration token (first run only) |
POWER_MANAGE_DATA_DIR |
Data directory for state |
POWER_MANAGE_PRIVILEGE_BACKEND |
Privilege backend override: sudo (default) or doas |
POWER_MANAGE_SERVICE_BACKEND |
Service backend override: systemd (default) |
POWER_MANAGE_ENCRYPTION_BACKEND |
Encryption backend override: luks (default) |
Log level has no env var; set it via the -log-level flag.
The agent supports 16 action types for managing the system:
Install, update, or remove system packages using the detected package manager (apt, dnf, pacman, or zypper).
| Field | Description |
|---|---|
name |
Package name |
version |
Optional version constraint |
allow_downgrade |
Allow downgrading to specified version |
Desired State:
PRESENT: Install or update the package, optionally pin to versionABSENT: Unpin and remove the package
PACKAGE and UPDATE actions honor their per-action timeout_seconds; when none is set they fall back to a default 30-minute ceiling so a wedged package-manager operation (mirror outage, lock contention) cannot run unbounded. The package manager is bound to the action's deadline, so a timeout aborts the underlying apt/dnf/zypper/pacman subprocess.
Perform system-wide package updates.
| Field | Description |
|---|---|
security_only |
Only install security updates |
autoremove |
Remove unused dependencies after update |
reboot_if_required |
Automatically reboot if required |
Desired State: Not applicable - updates always run forward.
Execute arbitrary shell scripts on the device.
| Field | Description |
|---|---|
script |
The script content to execute |
interpreter |
Interpreter path (default: /bin/bash) |
run_as_root |
Execute with root privileges |
timeout_seconds |
Maximum execution time (default: 300s) |
Desired State: Not applicable - scripts execute the same regardless of state. Implement conditional logic within the script if needed.
Manage systemd service units.
| Field | Description |
|---|---|
unit_name |
Name of the systemd unit (e.g., nginx.service) |
desired_state |
Unit state: RUNNING, STOPPED, or RESTARTED |
enable |
Enable unit to start on boot |
Desired State: Not applicable - uses separate SystemdUnitState enum for unit-specific states.
Create, modify, or remove files on the system.
| Field | Description |
|---|---|
path |
Absolute path to the file |
content |
File contents (for PRESENT state) |
owner |
File owner (username) |
group |
File group |
mode |
File permissions (e.g., 0644) |
Desired State:
PRESENT: Create or update the file with specified content and permissionsABSENT: Remove the file
Create or remove directories on the system.
| Field | Description |
|---|---|
path |
Absolute path to the directory |
owner |
Directory owner (username) |
group |
Directory group |
mode |
Directory permissions (e.g., 0755) |
Desired State:
PRESENT: Create or update the directory with specified ownership and permissionsABSENT: Remove the directory
Create, modify, or remove system users.
| Field | Description |
|---|---|
username |
Login name |
comment |
Full name / GECOS field |
shell |
Login shell (e.g., /bin/bash) |
groups |
Supplementary groups |
On creation, a temporary password is generated and returned in the lps.rotations metadata field. The power-manage system user is protected from deletion.
disabled: true shadow-locks the account (usermod -L, a leading ! on the password hash) and — for regular users — defaults the shell to /usr/sbin/nologin for offboarding. Disabling the superuser is deliberately lock-only: for any UID-0 account (keyed on the UID, not the name root, so a renamed superuser is covered without a name list) the shell is left untouched, so sudo -i and key-based root SSH keep working while password login stops — the same posture as Ubuntu's locked-by-default root. This is an operator choice and has no effect on the agent itself (a running root service neither re-authenticates nor uses the login shell); the agent logs a warning when it locks a UID-0 account. An explicitly set shell is always honored, superuser included.
Desired State:
PRESENT: Create the user or update attributes (shell, groups, comment)ABSENT: Delete the user and remove their home directory
Create, modify, or remove system groups.
| Field | Description |
|---|---|
name |
Group name |
members |
List of usernames to add as group members |
The power-manage system group is protected from deletion.
Desired State:
PRESENT: Create the group or update membershipABSENT: Delete the group
Manage per-action sudoers policies in /etc/sudoers.d/.
| Field | Description |
|---|---|
access_level |
FULL (unrestricted) or LIMITED (specific commands only) |
users |
Users to grant sudo access |
commands |
Allowed commands (for LIMITED access) |
Each policy is installed as a separate file, validated with visudo -c before activation. A dedicated group is created for each policy and users are added to it.
Desired State:
PRESENT: Install or update the sudoers policyABSENT: Remove the sudoers file and associated group
Manage per-action SSH access policies in /etc/ssh/sshd_config.d/.
| Field | Description |
|---|---|
users |
Users to grant SSH access |
allow_pubkey |
Allow public key authentication |
allow_password |
Allow password authentication |
Creates a dedicated group per policy and adds an sshd_config.d snippet restricting access. The SSHD config is validated with sshd -t before applying.
Desired State:
PRESENT: Install or update the SSH access policyABSENT: Remove the SSH config snippet and associated group
Manage global SSHD configuration directives in /etc/ssh/sshd_config.d/.
| Field | Description |
|---|---|
priority |
Config file priority (0-9999, lower = applied first) |
directives |
Map of SSHD directive key-value pairs |
Configuration is validated with sshd -t before applying and the service is reloaded.
Desired State:
PRESENT: Install or update the SSHD configurationABSENT: Remove the configuration snippet
Automated password rotation with encrypted state tracking.
| Field | Description |
|---|---|
usernames |
Users whose passwords to manage |
password_length |
Generated password length |
complexity |
ALPHANUMERIC or COMPLEX |
rotation_interval_days |
Days between rotations |
Rotated passwords are returned in the lps.rotations metadata field and stored encrypted on the server. The LPS state is tracked per-action in /var/lib/power-manage/lps/.
Desired State:
PRESENT: Rotate passwords if the rotation interval has elapsedABSENT: Remove LPS state tracking (does not change existing passwords)
Download and install standalone application binaries.
| Field | Description |
|---|---|
url |
Download URL — HTTPS only |
checksum_sha256 |
Mandatory SHA256 checksum (64 lowercase hex) — the download is rejected without it |
install_path |
Installation path |
Desired State:
PRESENT: Download and install the applicationABSENT: Remove the installed file
Mandatory integrity (WS7):
urlmust be HTTPS andchecksum_sha256is required for all download-and-install actions (APP_IMAGE/DEB/RPM). Without a checksum the only authenticity would be TLS to a possibly-compromised origin, so the agent fails closed.
Install or remove Debian packages directly from URLs.
| Field | Description |
|---|---|
url |
Download URL for the .deb file — HTTPS only |
checksum_sha256 |
Mandatory SHA256 checksum (64 lowercase hex) |
Desired State:
PRESENT: Download and install withdpkgABSENT: Remove the package
Install or remove RPM packages directly from URLs.
| Field | Description |
|---|---|
url |
Download URL for the .rpm file — HTTPS only |
checksum_sha256 |
Mandatory SHA256 checksum (64 lowercase hex) |
Desired State:
PRESENT: Download and install withrpmABSENT: Remove the package
Manage LUKS disk encryption on the device. The agent auto-detects the primary LUKS-encrypted volume, generates a managed passphrase stored on the server, and rotates it on schedule. Optionally, a device-bound key (TPM or user passphrase) can be enrolled in LUKS slot 7.
| Field | Description |
|---|---|
preshared_key |
Pre-shared key for initial ownership (agent uses this to authenticate against the volume on first run) |
rotation_interval_days |
Days between scheduled passphrase rotations (1–365) |
min_words |
Minimum words in generated managed passphrase (default 5, range 3–10) |
device_bound_key_type |
What goes in LUKS slot 7: NONE, TPM (auto-unlock at boot), or USER_PASSPHRASE (user-defined via CLI) |
user_passphrase_min_length |
Minimum length for user-defined passphrases (16–128, only for USER_PASSPHRASE type) |
user_passphrase_complexity |
Complexity requirement for user-defined passphrases (ALPHANUMERIC or COMPLEX) |
The agent communicates with the server via bidirectional stream messages (GetLuksKey, StoreLuksKey) to retrieve and store managed passphrases. Key rotation only proceeds after the server confirms receipt of the new passphrase, preventing key loss.
Desired State:
PRESENT: Take ownership of the LUKS volume, rotate the managed passphrase if the interval has elapsedABSENT: Remove LUKS state tracking (does not modify the LUKS volume itself)
When device_bound_key_type is USER_PASSPHRASE, the user sets their slot-7
passphrase with power-manage-agent luks set-passphrase --token <token>. This
command is unprivileged and requires no sudo / sudoers rule: it is a
thin client to a root daemon the agent runs in-process on a Unix socket at
/run/pm-agent/luks.sock (mode 0666, created under the unit's
RuntimeDirectory=pm-agent).
The client sends only {token, passphrase}. The root agent then, with its
own credentials over its own authenticated gateway connection:
- validates (and consumes) the server-issued token — it is device-bound, single-use, and short-TTL; authorization is the token, never the local OS user of the socket peer (so AD/SSSD logins are unaffected);
- enforces the passphrase policy and reuse rules server-side (the unprivileged client cannot read the root-owned reuse history);
- fetches the managed key and runs
cryptsetupdirectly — no--data-dir, no sudo.
This replaces the previous model, where the command ran under a
NOPASSWD: ... luks set-passphrase * sudoers rule with an
attacker-controllable --data-dir flag — a local privilege escalation (any
local user could point root's cryptsetup at a forged credential store and a
hostile gateway). The token, not the local user, is the sole authority.
Manage package manager repositories.
| Field | Description |
|---|---|
name |
Repository identifier |
apt |
APT repository config (url, distribution, components, gpg_key, trusted) |
dnf |
DNF repository config (baseurl, gpgkey, gpgcheck) |
pacman |
Pacman repository config (server, sig_level) |
zypper |
Zypper repository config (baseurl, gpgkey, gpgcheck) |
Only one repository type should be set per action. The matching type is determined by the detected package manager.
Argument hardening: dnf.baseurl / zypper.url / pacman.server must be HTTPS (these fetch root-installed packages, so the transport is the trust boundary); apt.url is exempt because apt's security is the gpg-signed Release file. dnf/zypper gpgkey refs are restricted to an https URL, a file:///abs path, or an absolute path (never a flag, http://, or rpm ext:: transport) and are imported via rpm --import -- <ref>. gpgcheck is an operator choice — an https mirror with gpgcheck=false is permitted (the transport is still verified; package-signature verification is the operator's call, mirroring the auto-update checksum_url posture). See ADR 0012.
Desired State:
PRESENT: Add or update the repository configurationABSENT: Remove the repository configuration and GPG keys
| Action Type | Supports Desired State | PRESENT | ABSENT |
|---|---|---|---|
PACKAGE |
Yes | Install/pin package | Unpin and remove |
APP_IMAGE |
Yes | Download and install | Remove file |
DEB |
Yes | Install package | Remove package |
RPM |
Yes | Install package | Remove package |
FILE |
Yes | Create/update file | Remove file |
DIRECTORY |
Yes | Create/update directory | Remove directory |
USER |
Yes | Create/update user | Delete user |
GROUP |
Yes | Create/update group | Delete group |
SUDO |
Yes | Install sudoers policy | Remove policy |
SSH |
Yes | Install SSH access policy | Remove policy |
SSHD |
Yes | Install SSHD config | Remove config |
LPS |
Yes | Rotate passwords | Remove state tracking |
LUKS |
Yes | Take ownership, rotate passphrase | Remove state tracking |
REPOSITORY |
Yes | Add/update repository | Remove repository |
WIFI |
Yes | Create/update NetworkManager profile | Delete profile and EAP-TLS certificates |
SHELL |
No | Execute script | Execute script (same) |
UPDATE |
No | Run update | Run update (same) |
SYSTEMD |
No | Uses SystemdUnitState |
Uses SystemdUnitState |
The agent supports remote journalctl log queries dispatched from the web UI. The handler accepts filters for systemd unit, line count, priority level (0-7), time range, and grep pattern. Output is capped at 1 MB.
The agent is added to the systemd-journal group during installation to enable log access without root.
Before every package operation, the agent runs automatic repair steps:
- Lock cleanup: Removes stale lock files (apt, pacman, zypper)
- Interrupted dpkg: Runs
dpkg --configure -aif needed - Read-only filesystem: Detects
romount via/proc/mountsand attemptsmount -o remount,rw / - DNF history: Attempts
history redoandrpm --verifydbfor corrupted state
Actions can be configured with schedules for autonomous execution on the agent. The scheduler operates independently and continues running actions even when disconnected from the server. Results are stored locally and synced when connection is restored.
| Field | Description |
|---|---|
cron |
Cron expression (e.g., 0 2 * * * for 2 AM daily) |
interval_hours |
Run every N hours (alternative to cron, default: 8 hours) |
run_on_assign |
Execute immediately when first assigned, then follow the schedule |
skip_if_unchanged |
Skip scheduled execution if system state already matches desired state |
When enabled, the action executes immediately when it is first assigned to a device, rather than waiting for the first scheduled interval. After the initial execution, the action follows its normal schedule.
Use case: You want a package installed right away when assigning the action to new devices, but also want periodic checks to ensure it stays installed.
When enabled, the scheduler checks if the system state already matches the desired state before executing. If nothing needs to change, the execution is skipped.
Examples:
- Package action with
PRESENTstate: Skip if the package is already installed - File action with
PRESENTstate: Skip if the file already exists with correct content - Systemd action with
RUNNINGstate: Skip if the service is already running
Use case: Reduce unnecessary operations for idempotent actions that run frequently. Instead of reinstalling a package every 4 hours, only act when the package is missing or changed.
The offline scheduler and the SDK stream loop are hardened against corrupt local state, clock excursions, crashes, and a compromised/relay gateway:
- Maintenance window fails closed on decode error. The active maintenance window is persisted to the agent's SQLite store so a restart inside a freeze keeps gating. If that persisted window exists but cannot be proto-decoded (a corrupt or tampered settings row), the scheduler does not boot unconstrained — it enters a deny-until-next-sync state that defers every due dispatch until the next successful window sync overwrites the bad row. A truly-absent window (a never-synced device) still boots unconstrained, the same default a fresh install gets.
- Forward clock jumps are clamped. A future-dated
next_execute_atcursor (left behind by a transient forward wall-clock excursion that was later corrected back) is clamped to at mostnow + interval, so a single clock jump can delay drift-prevention by at most one interval instead of suppressing it indefinitely. - Crash-replay guard. The due cursor is advanced one interval before an action executes, so a crash between execution and result recording does not silently re-run a non-idempotent action on the next boot. (Best-effort for non-idempotent actions; idempotent actions are unaffected, and the authoritative cursor is still written when the result is recorded.)
- One handler panic cannot crash the agent. The SDK stream-dispatch loop wraps each inbound
ServerMessagein a scopedrecover()and isolates its goroutine fan-out, so a panic triggered by a malformed or hostile frame from a compromised gateway is logged and dropped as non-fatal rather than crash-looping the agent (a fleet DoS). Oversized inbound frames are refused (resource-exhausted) instead of being allocated, and PTY dimensions are validated before use. See the SDK README "Stream-loop robustness". - The offline results table is bounded independently of sync state. Synced results are pruned after the retention period, but unsynced results are also evicted past a hard age ceiling (30 days) and the table is capped to a maximum row count (oldest first) — so an agent that cannot reach the server for a long time cannot exhaust local disk with undelivered results. When unsynced (undelivered) results are dropped, the scheduler logs a warning.
- A server-dispatched action runs off the receive loop. Actions execute on a single worker goroutine (in order, one at a time), so a long-running action no longer head-of-line-blocks terminal control frames (Stop/Input/Resize). The privileged terminal-setup steps (usermod, chown) also run under a bounded context, so a slow/hung step surfaces an error within a deadline instead of wedging the loop. On reconnect the prior connection's idle transport connections are released, so a long-lived reconnect loop doesn't leak sockets.
- Shutdown joins the scheduler before the store closes.
Scheduler.Stop()blocks until the run loop has returned; on SIGTERM the agent calls it before closing the offline store, so an action executing at shutdown finishes recording its result rather than racing the store close (no lost result / use-after-close). The offline store also re-asserts0700on its data dir and0600on the DB files (incl. WAL/SHM) on everyNew().
The agent automatically detects the system's package manager:
| Distribution | Package Manager |
|---|---|
| Debian, Ubuntu | apt |
| Fedora, RHEL, CentOS | dnf |
| Arch Linux | pacman |
| openSUSE | zypper |
The agent runs as root (systemd User=root). The previous "dedicated power-manage user with sudoers escalation" model has been retired — every privileged operation now runs in-process without a sudo -n round-trip, eliminating the sudoers drop-in as both an attack surface and a per-distro install / validate / packaging burden.
The power-manage-agent setup subcommand still exists for backwards compatibility but is a no-op deprecation notice; legacy operator scripts that invoke it keep working without erroring. Operators upgrading from the sudoers-mode build should:
- Remove
/etc/sudoers.d/power-manage(or/etc/doas.d/power-manage.conf) left over from the previous install. - Update the systemd unit's
User=toroot(waspower-manage). - The
power-managesystem user can be deleted if no other tooling depends on it.
The privileged-operation surface is the same as before — package managers, systemd unit lifecycle, file/user/group management, LUKS, etc. — it just no longer tunnels through sudo.
The agent prevents actions from modifying its own infrastructure:
- The
power-manageuser and group cannot be deleted via USER/GROUP actions (defensive holdover from the sudoers-mode era — harmless if neither exists) - This prevents accidental self-destruction through misconfigured actions
- Registration: Agent registers with the Control Server over HTTPS, authenticating with a registration token
- mTLS: After registration, the agent connects to the Gateway using mutual TLS with certificates signed by the Control Server CA
- Certificate Rotation: The agent automatically renews its mTLS certificate at 80% of its lifetime (~292 days for a 1-year cert). Renewal uses the existing private key to generate a new CSR and calls the Control Server's
RenewCertificateRPC, presenting the current certificate for identity verification. The response includes the active CA certificate, which the agent stores locally — this enables seamless CA rotation without re-registration. On failure, the agent retries hourly. - Trust roots — control server vs gateway asymmetry: Every gateway-bound RPC validates the gateway certificate against the internal CA that signed the agent's own certificate (i.e.,
WithMTLSFromPEM). The Control Server'sRenewCertificateRPC is the one exception: it validates against the host system trust roots (WithMTLSFromPEMAndSystemRoots), because the control server typically sits behind a public CA (Let's Encrypt, an enterprise CA, etc.). If you front the control server with a corporate-CA proxy, the host's CA bundle must include that proxy's root — otherwise certificate renewal will fail with a TLS verification error even though every other RPC keeps working.
- Certificate Storage: Credentials are encrypted at rest using AES-256-GCM with a key derived from the machine ID via Argon2id (plus a per-store random salt), written
0600in a0700owner-only directory. The store fails closed if its directory is group/world-writable (a non-owner could otherwise forge the salt/ciphertext), and Save tightens an existing directory to0700. The machine-ID binding means the ciphertext will not decrypt if copied to another host, but it is not protection against offline theft of the disk or a backup (the machine ID lives on the same disk) — use full-disk encryption for that. A same-disk key file would add no real defense, so it is intentionally not used (WS10 accepted residual; see ADR 0014).
The agent runs as root, and the Gateway/Valkey relay is untrusted for origination. So beyond signed actions, the four root stream-RPCs are verified fail-closed against the CA certificate before any root work:
| RPC | Verified before | Refusal |
|---|---|---|
OnQuery (osquery, incl. raw SQL) |
invoking osquery | Success=false, error "refusing to execute unsigned/tampered query" |
OnLogQuery (journalctl) |
building any journalctl invocation | "refusing to execute unsigned/tampered log query" |
OnRevokeLuksDeviceKey (slot-7 wipe) |
touching the key store | "refusing to revoke unsigned/tampered LUKS device key" |
OnRequestInventory (server-originated) |
running osquery | returns no inventory |
Each verifies the Control Server's signature over the message's canonical bytes under that surface's disjoint domain. Raw SQL is permitted only when signed (an unsigned/tampered raw query never reaches osquery); the message is validated at the boundary first. A missing verifier fails closed (production always has one — the CA cert is required at startup). All inventory collection is server-initiated over this signed path (manual refresh and the spec-22 server-side scheduler); the agent has no periodic collector of its own.
Every value that reaches a package-manager argv is validated against its
intent before the command runs, and positionals are passed after an explicit
-- end-of-options separator (via the SDK's exec.SeparatePositionals) so a
flag-shaped value can never be reparsed as an option:
| Surface | Validation | Argv |
|---|---|---|
RPM %{NAME} (read off the downloaded .rpm) |
pkg.ValidateRpmPackageName — a crafted .rpm cannot name itself --eval=%{lua:…} |
rpm -q -- <name>, rpm -e -- <name> |
RPM/DEB/APP_IMAGE artifact |
https + non-empty checksum, fail-closed before any privileged remount or download | — |
| dnf/zypper/pacman base URL | pkg.ValidateRepoBaseURL (https + host) |
written to the repo config |
| dnf/zypper GPG key ref | pkg.ValidateGpgKeyRef (https / file:// abs / abs path) |
rpm --import -- <ref> |
FLATPAK app-id / remote |
pkg.ValidatePackageName / pkg.ValidateRemoteName, before dispatch |
flatpak install … -- <remote> <appId> |
A self-discovering test walks every string field of each repository proto and fails closed if a newly-added field forgets its config-injection guard. See ADR 0012.
The enrollment socket limits enrollment attempts to 5 per minute using a sliding window. This prevents brute-force token guessing via the local socket.
Remote terminal sessions are disabled by default on every device. Before a server can open a PTY on the device, a local admin must explicitly enable the toggle. The server cannot bypass this — any remote action that tries to flip the flag still runs on the device and goes through the same CLI surface that requires local privileges.
Commands:
sudo power-manage-agent tty enable # allow remote terminals on this device
sudo power-manage-agent tty disable # revoke access; existing sessions must be closed separately
power-manage-agent tty status # prints "enabled" or "disabled"; exit 0 / 1How it works:
- State lives in the agent's SQLite database (
tty.enabledkey in thesettingstable) - Default is disabled — fresh installs and upgrades must explicitly enable
- The
power-manageuser owns the data directory, so the CLI must run as that user (viasudoor equivalent privilege escalation) - The terminal handler fails-closed: if the flag is missing, the store is unreachable, or any read error occurs, sessions are refused
- The rejection message sent to the server is intentionally opaque (
terminal sessions are disabled on this device) — it never distinguishes "disabled" from other failure modes - No agent restart is required; the check runs at every
TerminalStartrequest
Fleet detection:
The toggle is queryable from the server via a compliance shell action:
power-manage-agent tty statusExit code 0 = enabled, 1 = disabled. Combined with is_compliance=true + compliance_expected_output=enabled (or disabled), admins can report on fleet-wide TTY state without a new action type.
The agent treats every server/gateway-supplied field that reaches a filesystem path, a shell, or a privileged call as untrusted and validates it before use. These device-side rejection guards are exercised by dedicated rejection-path tests:
- TTY
session_idis validated as a ULID.TerminalStart.session_idis spliced into the per-session/tmp/<tty-user>.<session-id>directory that is created andchown-ed as root, so it must parse as a ULID. Path-meaningful values (../../etc,a/b), embedded NULs, and the empty string are refused before any filesystem use; together with the validatedpm-tty-*username this makes the joined path unable to escape/tmp. - Locked/disabled TTY users are refused. A
pm-tty-*account that is shadow-locked cannot open a session. FILEactions refuse to overwrite a directory. Writing a file to a path that already exists as a directory reportsFAILEDand leaves the directory untouched, rather than moving a temp file inside it (WS6).WIFIaction IDs are filesystem-validated. The action ID is run through the samevalidateActionIDForFilesystemguard the sudo/ssh/sshd executors use before it is spliced into the EAP-TLS cert directory or thepm-wifi-<id>connection name.SHELLenv vars go through the SDK hijack allow-list. Caller-suppliedLD_PRELOAD/PATH/LD_LIBRARY_PATHand friends are refused before the interpreter is launched.- The gateway URL must be
https://host. Cleartext / h2c / opaque / hostless gateway URLs are refused before the mTLS dial (WS15). - osquery refuses credential-bearing tables. The SDK's convenience table path denies
shadow/process_envs/crontab/shell_history/sudoers(the signedRawSqlescape hatch is unaffected) — see the SDK README.
CI runs the agent unit/arch suite (go test -race ./..., no build tags) separately from the per-distro -tags=integration executor suite, so these pure-function and handler-level guards are covered without requiring root or a real device.
Logs are written to stdout/stderr and can be collected by systemd journal:
# View agent logs
journalctl -u power-manage-agent -f/var/lib/power-manage/
├── certs/
│ ├── device.crt # Device certificate
│ └── device.key # Device private key
├── agent.db # SQLite database (LPS state, execution tracking)
├── luks/ # LUKS encryption state (per-action SQLite databases)
├── state.json # Agent state (server URL, device ID, gateway URL)
├── update/ # Auto-update working directory
│ └── agent-update-*.tmp # Downloaded new binary (during update, deleted after self-test)
└── results/ # Pending execution results
The agent self-updates via the ACTION_TYPE_AGENT_UPDATE action. Admins schedule this action on their managed devices; the action payload carries, per architecture, an HTTPS binary_url and an integrity source. The action is CA-signed, so its fields cannot be tampered in transit.
Each arch must provide at least one of:
checksum_url(default) — aSHA256SUMSfile the agent fetches and verifies the binary against. This is the hands-off option: pointbinary_url+checksum_urlat yourreleases/latest/...assets and the fleet tracks new releases with no per-release action change. Authenticity here is origin-trust (TLS + your release host).expected_sha256(opt-in) — an exact, lowercase-hex hash. When set it overrideschecksum_urland is the authoritative gate: it travels inside the CA-signed action, so the agent verifies against a hash bound to the control server's signature, not a file from the download origin. Use it to pin an exact binary (staged rollouts) or for stronger authenticity. The trade-off: a new version means updating + re-signing the action, so it's opt-in, not the default.
binary_url (and checksum_url, when used) must be HTTPS. An action with neither integrity source is rejected at create time and by the agent.
Accepted risk (default mode): with
checksum_url, a compromised download origin serving a malicious binary and a matching checksum file would not be detected — the checksum is self-attesting. The control plane provides ease of use, not artifact provenance. If you need a stronger guarantee: pinexpected_sha256, hostchecksum_urlon a separate host from the binary, or — since this is open source — build and distribute the binaries yourself and pin hashes as you require. Binary code-signing and signing of the releaseSHA256SUMSare possible future backstops, not current guarantees.
The agent refuses a candidate older than the running version (vYYYY.MM.PP comparison); an unparseable version fails closed (never treated as newer). A downgrade requires the signed action to set allow_downgrade — the bypass is an explicit, authenticated operator decision, also inside the CA-signed payload.
- Download binary to
/var/lib/power-manage/update/agent-update-*.tmpand verify its SHA256 against the pinnedexpected_sha256if set, otherwise against the hash fromchecksum_url - Run
<tmpPath> version, compare with running — skip if same; refuse a downgrade unlessallow_downgrade - Run
<tmpPath> self-testas a subprocess (60s timeout). The self-test:- Loads stored credentials
- Establishes an mTLS connection to the gateway
- Sends
Hello, waits forWelcome(proves bidirectional stream) - Calls
SyncActions(proves unary RPC works) - Exits 0 on success, 1 on any failure
- If the self-test exits 0: swap the live binary via
SafeBackupAndReplace(O_NOFOLLOW + renameat2; the previous binary is copied to<BinaryPath>.bakfirst) and trigger graceful shutdown. Systemd restarts with the new binary. - If the self-test fails: report the action as
EXECUTION_STATUS_FAILEDand leave the live binary untouched.
The old binary is never replaced until the new binary proves it can connect, sync, and handle the protocol. The previous binary is kept at <BinaryPath>.bak for manual rollback if the new one fails to start after restart (a case the self-test cannot catch).
Failed updates have no cooldown. If an admin schedules AGENT_UPDATE every 30 minutes and the target version is broken (self-test fails), the agent re-downloads and re-validates every cycle until a fixed release is published. Retry frequency is governed entirely by the admin's action schedule — the agent does not impose its own throttling. The old binary remains on disk, untouched, until a version passes its self-test.
The install script creates a systemd service:
[Unit]
Description=Power Manage Agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# Agent runs as root — LUKS rotation, package management, systemd
# unit installs, /etc edits, and signed-cert provisioning all need
# root. Don't change this to a regular user without first auditing
# every action executor for capability requirements.
User=root
ExecStart=/usr/local/bin/power-manage-agent
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.targetManage with:
sudo systemctl start power-manage-agent
sudo systemctl stop power-manage-agent
sudo systemctl status power-manage-agent
sudo systemctl enable power-manage-agentThe agent pins a specific SDK tag via go.mod's replace directive:
replace github.com/manchtools/power-manage/sdk => github.com/manchtools/power-manage-sdk v0.1.0
The replace maps the monorepo-style import path (github.com/manchtools/power-manage/sdk) to the actual polyrepo URL (github.com/manchtools/power-manage-sdk). go build fetches the exact tagged version from GitHub — SDK main can move freely without breaking agent builds. When the agent is ready to consume a newer SDK:
cd agent
go get github.com/manchtools/power-manage-sdk@v0.2.0 # or any tag / commit SHA
go mod tidyThe SDK is still pre-v1.0.0, so minor bumps (v0.1.0 → v0.2.0) may carry breaking API changes. Expect each bump PR to carry the matching migration in the same commit.
For cross-cutting changes, point the agent at a local SDK checkout via a go.work at the workspace root (the directory that contains both agent/ and sdk/ checkouts). go.work overrides any replace directive and fetched module versions.
# At the workspace root (NOT committed — each dev manages their own):
cat > go.work <<'EOF'
go 1.25
use (
./sdk
./agent
)
EOFRemove it, or rename it to go.work.off, when you want go build back to using the pinned SDK.
# Build for current platform
make build
# Cross-compile for ARM64
make build GOARCH=arm64# Build + deploy + restart on remote machine
make deploy SSH=user@testserver
# Full install (including setup)
make install SSH=user@testserverThe agent includes a comprehensive integration test suite that runs inside Docker/Podman containers. Tests execute as the power-manage user with the production sudoers template, ensuring the test environment matches production exactly.
# Run tests on a single distro
make test-integration-debian
make test-integration-fedora
make test-integration-opensuse
make test-integration-archlinux
# Run all 4 distros in parallel
make test-integration-all
# Run privileged edge case tests (requires --privileged container)
make test-integration-edgecaseIntegration tests run automatically on push to main and on pull requests via GitHub Actions (.github/workflows/integration-test.yml). The workflow is triggered only on actual code changes (Go files, go.mod/sum, Makefile, cmd/, test/, internal/**).
The release workflow (.github/workflows/release.yml) gates binary builds on passing integration tests, ensuring no release is published without all tests passing across all 4 distros.
The test suite (internal/executor/integration_test.go, ~3,500 lines) exercises the executor against real system state inside containerized environments. Each test container mirrors production: tests run as root directly, the same way the agent runs in production (systemd User=root).
Each distro has its own Dockerfile in test/:
| Container | Dockerfile | Base Image | Package Manager |
|---|---|---|---|
| Debian | Dockerfile.integration |
golang:1.26-bookworm |
apt |
| Fedora | Dockerfile.integration.fedora |
fedora:42 |
dnf |
| openSUSE | Dockerfile.integration.opensuse |
opensuse/tumbleweed |
zypper |
| Arch Linux | Dockerfile.integration.archlinux |
archlinux:latest |
pacman |
Container setup:
- Install Go toolchain and test dependencies
- Create LPS state directory under
/var/lib/power-manage/(root-owned) - Set up SSHD host keys and config directory for validation tests
- Pre-download Go module dependencies
Tests run via gotestsum ... directly as root — same execution context as the production systemd unit.
These tests verify the full lifecycle (create, idempotent re-run, update, remove) for each action type:
| Test | Action Type | What It Verifies |
|---|---|---|
TestIntegration_Package |
PACKAGE | Install, idempotent re-install, remove via apt |
TestIntegration_Package_Dnf |
PACKAGE | Install, idempotent re-install, remove via dnf |
TestIntegration_Package_Pacman |
PACKAGE | Install, idempotent re-install, remove via pacman |
TestIntegration_Package_Zypper |
PACKAGE | Install, idempotent re-install, remove via zypper |
TestIntegration_Package_GracefulSkip |
PACKAGE | Graceful handling when package is not in any repo (apt) |
TestIntegration_Package_GracefulSkip_Dnf |
PACKAGE | Graceful handling when package is not in any repo (dnf) |
TestIntegration_Package_GracefulSkip_Pacman |
PACKAGE | Graceful handling when package is not in any repo (pacman) |
TestIntegration_Package_GracefulSkip_Zypper |
PACKAGE | Graceful handling when package is not in any repo (zypper) |
TestIntegration_Update |
UPDATE | System update via apt |
TestIntegration_Update_Dnf |
UPDATE | System update via dnf |
TestIntegration_Update_Pacman |
UPDATE | System update via pacman |
TestIntegration_Update_Zypper |
UPDATE | System update via zypper |
TestIntegration_Shell |
SHELL | Basic execution, exit code handling, stderr capture, timeout, run_as_root, working directory, environment variables, multi-line scripts |
TestIntegration_File |
FILE | Create with content/owner/group/mode, idempotent re-create, update content, remove, binary content |
TestIntegration_Directory |
DIRECTORY | Create with owner/group/mode, idempotent re-create, update permissions, nested directories, remove, remove non-existent |
TestIntegration_User |
USER | Create, idempotent re-create, update shell, remove, remove non-existent, protect power-manage |
TestIntegration_Group |
GROUP | Create, idempotent re-create, add members, remove, protect power-manage |
TestIntegration_Sudo |
SUDO | Full-access policy setup, idempotent re-setup, remove (sudoers file + group) |
TestIntegration_SSH |
SSH | Access policy setup (group + sshd_config.d snippet), idempotent re-setup, remove |
TestIntegration_SSHD |
SSHD | Config directives setup, idempotent re-setup, sshd -t validation, remove |
TestIntegration_LPS |
LPS | Initial password rotation, idempotent skip (interval not elapsed), remove state |
TestIntegration_Deb |
DEB | Build test .deb, serve via HTTP, install via dpkg, remove |
TestIntegration_Rpm |
RPM | Build test .rpm via rpmbuild, serve via HTTP, install, remove |
TestIntegration_AppImage |
APP_IMAGE | Download with checksum verification, install to custom path, idempotent, remove |
TestIntegration_Repository |
REPOSITORY | Add/remove apt sources.list.d entry |
TestIntegration_Repository_Dnf |
REPOSITORY | Add/remove dnf .repo file |
TestIntegration_Repository_Pacman |
REPOSITORY | Add/remove pacman.conf server entry |
TestIntegration_Repository_Zypper |
REPOSITORY | Add/remove zypper repository |
TestIntegration_Systemd |
SYSTEMD | Unit file creation, daemon-reload, start, status check, stop, remove, invalid unit handling |
These tests verify resilience against real-world failure conditions. Some require --privileged containers:
Package manager lock recovery:
| Test | Description |
|---|---|
EdgeCase_AptLock |
Stale /var/lib/dpkg/lock* files are cleaned up before install |
EdgeCase_PacmanLock |
Stale /var/lib/pacman/db.lck is removed before install |
EdgeCase_ZypperLock |
Stale /var/run/zypp.pid is removed before install |
EdgeCase_DnfStaleHistory |
DNF repair path (history redo, remove --duplicates, rpm --verifydb) runs cleanly |
EdgeCase_InterruptedDpkg |
dpkg --configure -a repairs interrupted dpkg state |
EdgeCase_InterruptedDpkgConfigure |
Partial dpkg configure is repaired before package install |
EdgeCase_PackagePinConflict |
Package version pinning conflict is handled gracefully |
LPS state corruption:
| Test | Description |
|---|---|
EdgeCase_LpsInvalidJson |
Corrupted JSON state file is treated as initial rotation |
EdgeCase_LpsMissingDirectory |
Missing /var/lib/power-manage/lps/ directory is re-created |
Missing system directories:
| Test | Description |
|---|---|
EdgeCase_MissingSudoersDir |
Missing /etc/sudoers.d/ is re-created before policy install |
EdgeCase_MissingSshdConfigDir |
Missing /etc/ssh/sshd_config.d/ is re-created before config install |
Download failures:
| Test | Description |
|---|---|
EdgeCase_DownloadHttp500 |
HTTP 500 from download server returns FAILED status |
EdgeCase_DownloadHttp404 |
HTTP 404 returns FAILED status |
EdgeCase_DownloadChecksumMismatch |
SHA256 checksum mismatch is detected and reported |
EdgeCase_DownloadTimeout |
Slow/hanging server is handled with timeout |
EdgeCase_DNSResolutionFailure |
Unresolvable hostname returns FAILED status |
EdgeCase_HTTPSCertError |
Invalid TLS certificate returns FAILED status |
Invalid input:
| Test | Description |
|---|---|
EdgeCase_NilParams |
Nil action params for all types return FAILED status |
EdgeCase_InvalidUsername |
Usernames with special characters (../, ;, etc.) are rejected |
EdgeCase_InvalidPaths |
Path traversal (../../etc/passwd), relative paths, and empty paths are rejected |
EdgeCase_SystemdInvalidUnit |
Invalid systemd unit names are rejected |
Filesystem edge cases (require --privileged):
| Test | Description |
|---|---|
EdgeCase_DiskFull |
File write to a full tmpfs reports clear error |
EdgeCase_ReadOnlyMount |
File write to read-only filesystem reports clear error |
EdgeCase_ImmutableFile |
Overwriting a chattr +i immutable file reports clear error |
EdgeCase_SymlinkCircular |
Circular symlink at target path is detected |
EdgeCase_VeryLongFilePath |
Paths exceeding filesystem limits are handled |
User/group edge cases:
| Test | Description |
|---|---|
EdgeCase_UserExistsDifferentShell |
Updating a user's shell correctly reports changed=true |
EdgeCase_UserDeleteWhileLoggedIn |
Deleting a user with active sessions terminates sessions first |
EdgeCase_GroupIsPrimaryGroup |
Deleting a user's primary group returns clear error |
File content edge cases:
| Test | Description |
|---|---|
EdgeCase_FileExistsDifferentPerms |
Updating permissions on existing file reports changed=true |
EdgeCase_FileExistsAsDirectory |
Writing file to path that is a directory returns FAILED |
EdgeCase_EmptyFileContent |
Empty content creates a zero-byte file |
EdgeCase_BinaryFileContent |
Binary content with null bytes is written correctly |
EdgeCase_ConcurrentFileWrites |
Parallel file writes to different paths all succeed (atomic write safety) |
SSH/sudo edge cases:
| Test | Description |
|---|---|
EdgeCase_BrokenSudoersFile |
Pre-existing broken sudoers file is replaced with valid policy |
EdgeCase_SSHDirWrongPermissions |
~/.ssh directory with wrong permissions is corrected |
Other edge cases:
| Test | Description |
|---|---|
EdgeCase_ShellTimeout |
Shell script exceeding timeout is killed |
EdgeCase_LargeShellOutput |
Large stdout/stderr output is captured without truncation or hang |
EdgeCase_PartialAppImage |
Incomplete download leaves no partial file on disk |
EdgeCase_RepositoryExpiredGPGKey |
Expired GPG key is handled gracefully |