Skip to content

manchtools/power-manage-agent

Repository files navigation

Power Manage Agent

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.

Architecture

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 separate LuksKeyStore interface that proxies to the gateway over the live SDK stream — the executor calls e.luksKeyStore.GetKey/StoreKey/RotateKey, those methods round-trip through the connected sdk.Client to 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 the GetKey call 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        │
└───────────────────────────┘   └─────────────────────────────────┘

Enrollment Flow

The agent supports two enrollment methods:

Socket-based enrollment (recommended, no sudo required)

  1. The install script starts the agent as a systemd service
  2. The unenrolled agent opens an enrollment socket at /run/pm-agent/enroll.sock (mode 0666, any local user can connect)
  3. A regular user runs power-manage-agent enroll -server=URL -token-file=PATH (or PM_REGISTRATION_TOKEN=… power-manage-agent enroll -server=URL)
  4. The CLI sends an Enroll RPC to the agent over the unix socket
  5. The agent calls the Control Server Register RPC with the token and a locally-generated CSR
  6. The Control Server validates the token, signs the certificate, and returns credentials
  7. 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_url must be https (cleartext/opaque URLs are refused before any network call). Token delivery is via -token-file or the PM_REGISTRATION_TOKEN env var; passing -token on 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 a power-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   │
                                  └─────────────────────────┘

Direct enrollment (backwards compatible, requires sudo)

For environments where the agent is not yet running as a service, direct enrollment still works:

sudo power-manage-agent -server=URL -token=TOKEN

This is equivalent to the previous behavior: the agent registers directly, saves credentials, and starts.

Registration Protocol

Both enrollment methods use the same underlying protocol:

  1. The agent generates an ECDSA P-256 key pair and CSR locally — the private key never leaves the device
  2. The Control Server validates the registration token, signs the certificate, and returns:
    • Device ID
    • Signed mTLS certificate
    • CA certificate
    • Gateway URL
  3. The agent stores the encrypted credentials and connects to the Gateway using mTLS for streaming communication

Installation

Using the Install Script

# 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_TOKEN

The install script:

  1. Downloads and installs the agent binary (verifies SHA256 against the publisher's SHA256SUMS)
  2. Creates /var/lib/power-manage as a root-owned, mode 0700 data directory
  3. Removes any leftover /etc/sudoers.d/power-manage, /etc/sudoers.d/power-manage-luks, or /etc/doas.d/power-manage.conf from a previous (pre-root-mode) install
  4. Installs the systemd unit with User=root and the documented capability bounding set, then enables and starts the service
  5. Enrolls via the enrollment socket if --server and --token were 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 rulepower-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.

Manual Installation

# 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_TOKEN

For 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_TOKEN

Using URI Scheme

The 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-verify URI parameter was removed (it has no effect on current builds).

Subcommands

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

Configuration

Command-Line Flags

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-verify flag was removed (along with the POWER_MANAGE_SKIP_VERIFY env var below).

Environment Variables

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.

Action Types

The agent supports 16 action types for managing the system:

Package Management (PACKAGE)

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 version
  • ABSENT: 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.

System Update (UPDATE)

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.

Shell Script (SHELL)

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.

Systemd Unit (SYSTEMD)

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.

File Management (FILE)

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 permissions
  • ABSENT: Remove the file

Directory Management (DIRECTORY)

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 permissions
  • ABSENT: Remove the directory

User Management (USER)

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

Group Management (GROUP)

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 membership
  • ABSENT: Delete the group

Sudo Policy (SUDO)

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 policy
  • ABSENT: Remove the sudoers file and associated group

SSH Access (SSH)

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 policy
  • ABSENT: Remove the SSH config snippet and associated group

SSHD Configuration (SSHD)

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 configuration
  • ABSENT: Remove the configuration snippet

Local Password Solution (LPS)

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 elapsed
  • ABSENT: Remove LPS state tracking (does not change existing passwords)

App Image (APP_IMAGE)

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 application
  • ABSENT: Remove the installed file

Mandatory integrity (WS7): url must be HTTPS and checksum_sha256 is 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.

DEB Package (DEB)

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 with dpkg
  • ABSENT: Remove the package

RPM Package (RPM)

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 with rpm
  • ABSENT: Remove the package

LUKS Disk Encryption (LUKS)

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 elapsed
  • ABSENT: Remove LUKS state tracking (does not modify the LUKS volume itself)

LUKS passphrase daemon

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:

  1. 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);
  2. enforces the passphrase policy and reuse rules server-side (the unprivileged client cannot read the root-owned reuse history);
  3. fetches the managed key and runs cryptsetup directly — 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.

Repository (REPOSITORY)

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 configuration
  • ABSENT: Remove the repository configuration and GPG keys

Desired State Summary

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

Remote Log Queries

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.

Package Manager Repair

Before every package operation, the agent runs automatic repair steps:

  • Lock cleanup: Removes stale lock files (apt, pacman, zypper)
  • Interrupted dpkg: Runs dpkg --configure -a if needed
  • Read-only filesystem: Detects ro mount via /proc/mounts and attempts mount -o remount,rw /
  • DNF history: Attempts history redo and rpm --verifydb for corrupted state

Scheduling

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.

Schedule Fields

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

Run on Assign

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.

Skip if Unchanged

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 PRESENT state: Skip if the package is already installed
  • File action with PRESENT state: Skip if the file already exists with correct content
  • Systemd action with RUNNING state: 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.

Scheduling resilience

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_at cursor (left behind by a transient forward wall-clock excursion that was later corrected back) is clamped to at most now + 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 ServerMessage in a scoped recover() 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-asserts 0700 on its data dir and 0600 on the DB files (incl. WAL/SHM) on every New().

Package Manager Detection

The agent automatically detects the system's package manager:

Distribution Package Manager
Debian, Ubuntu apt
Fedora, RHEL, CentOS dnf
Arch Linux pacman
openSUSE zypper

Security

Runs as root

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:

  1. Remove /etc/sudoers.d/power-manage (or /etc/doas.d/power-manage.conf) left over from the previous install.
  2. Update the systemd unit's User= to root (was power-manage).
  3. The power-manage system 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.

Self-Protection

The agent prevents actions from modifying its own infrastructure:

  • The power-manage user 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

Network Security

  • 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 RenewCertificate RPC, 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's RenewCertificate RPC 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 0600 in a 0700 owner-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 to 0700. 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).

Root Stream-RPC Verification (WS4)

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.

Package / Repository Argument Hardening (WS8)

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.

Enrollment Rate Limiting

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 (TTY) — Device-Authoritative Toggle

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 / 1

How it works:

  • State lives in the agent's SQLite database (tty.enabled key in the settings table)
  • Default is disabled — fresh installs and upgrades must explicitly enable
  • The power-manage user owns the data directory, so the CLI must run as that user (via sudo or 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 TerminalStart request

Fleet detection:

The toggle is queryable from the server via a compliance shell action:

power-manage-agent tty status

Exit 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.

Inbound-message & action-input validation (WS17a)

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_id is validated as a ULID. TerminalStart.session_id is spliced into the per-session /tmp/<tty-user>.<session-id> directory that is created and chown-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 validated pm-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.
  • FILE actions refuse to overwrite a directory. Writing a file to a path that already exists as a directory reports FAILED and leaves the directory untouched, rather than moving a temp file inside it (WS6).
  • WIFI action IDs are filesystem-validated. The action ID is run through the same validateActionIDForFilesystem guard the sudo/ssh/sshd executors use before it is spliced into the EAP-TLS cert directory or the pm-wifi-<id> connection name.
  • SHELL env vars go through the SDK hijack allow-list. Caller-supplied LD_PRELOAD / PATH / LD_LIBRARY_PATH and 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 signed RawSql escape 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.

Logging

Logs are written to stdout/stderr and can be collected by systemd journal:

# View agent logs
journalctl -u power-manage-agent -f

Data Directory Structure

/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

Auto-Update

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.

Integrity: your choice of checksum_url (default) or a pinned expected_sha256

Each arch must provide at least one of:

  • checksum_url (default) — a SHA256SUMS file the agent fetches and verifies the binary against. This is the hands-off option: point binary_url + checksum_url at your releases/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 overrides checksum_url and 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: pin expected_sha256, host checksum_url on 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 release SHA256SUMS are possible future backstops, not current guarantees.

Anti-rollback

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.

Update Process

  1. Download binary to /var/lib/power-manage/update/agent-update-*.tmp and verify its SHA256 against the pinned expected_sha256 if set, otherwise against the hash from checksum_url
  2. Run <tmpPath> version, compare with running — skip if same; refuse a downgrade unless allow_downgrade
  3. Run <tmpPath> self-test as a subprocess (60s timeout). The self-test:
    • Loads stored credentials
    • Establishes an mTLS connection to the gateway
    • Sends Hello, waits for Welcome (proves bidirectional stream)
    • Calls SyncActions (proves unary RPC works)
    • Exits 0 on success, 1 on any failure
  4. If the self-test exits 0: swap the live binary via SafeBackupAndReplace (O_NOFOLLOW + renameat2; the previous binary is copied to <BinaryPath>.bak first) and trigger graceful shutdown. Systemd restarts with the new binary.
  5. If the self-test fails: report the action as EXECUTION_STATUS_FAILED and leave the live binary untouched.

Validate-before-swap

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).

Retry behavior

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.

Systemd Service

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.target

Manage with:

sudo systemctl start power-manage-agent
sudo systemctl stop power-manage-agent
sudo systemctl status power-manage-agent
sudo systemctl enable power-manage-agent

Development

SDK versioning

The 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 tidy

The SDK is still pre-v1.0.0, so minor bumps (v0.1.0v0.2.0) may carry breaking API changes. Expect each bump PR to carry the matching migration in the same commit.

Working on SDK + agent together

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
)
EOF

Remove it, or rename it to go.work.off, when you want go build back to using the pinned SDK.

Building

# Build for current platform
make build

# Cross-compile for ARM64
make build GOARCH=arm64

Deploying to a Test Server

# Build + deploy + restart on remote machine
make deploy SSH=user@testserver

# Full install (including setup)
make install SSH=user@testserver

Running Integration Tests

The 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-edgecase

CI/CD

Integration 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.

Integration Test Suite

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).

Test Containers

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:

  1. Install Go toolchain and test dependencies
  2. Create LPS state directory under /var/lib/power-manage/ (root-owned)
  3. Set up SSHD host keys and config directory for validation tests
  4. Pre-download Go module dependencies

Tests run via gotestsum ... directly as root — same execution context as the production systemd unit.

Core Action Tests

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

Edge Case Tests

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

About

No description, website, or topics provided.

Resources

License

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages