diff --git a/cmd/agent_setup.go b/cmd/agent_setup.go index 304825d8c..ae7653c0e 100644 --- a/cmd/agent_setup.go +++ b/cmd/agent_setup.go @@ -82,7 +82,7 @@ func setupAgent( log.Info("running on darwin") } - execManager := exec.New(log) + execManager := exec.New(log, appConfig.Agent.PrivilegeEscalation.Enabled) // --- Node providers --- var hostProvider nodeHost.Provider @@ -303,6 +303,7 @@ func setupAgent( registry, b.registryKV, b.factsKV, + execManager, ) enabledOrDisabled := func(enabled bool) string { diff --git a/configs/osapi.dev.yaml b/configs/osapi.dev.yaml index 24a2be6b6..c745c4e0a 100644 --- a/configs/osapi.dev.yaml +++ b/configs/osapi.dev.yaml @@ -48,6 +48,10 @@ agent: metrics: enabled: true port: 9091 + # Least-privilege mode. When enabled, write commands use sudo and + # Linux capabilities are verified at startup. See Agent Hardening docs. + privilege_escalation: + enabled: false nats: stream: diff --git a/configs/osapi.yaml b/configs/osapi.yaml index bfac9d191..49ba886c7 100644 --- a/configs/osapi.yaml +++ b/configs/osapi.yaml @@ -171,4 +171,8 @@ agent: enabled: true host: '0.0.0.0' port: 9091 + # Least-privilege mode. When enabled, write commands use sudo and + # Linux capabilities are verified at startup. See Agent Hardening docs. + privilege_escalation: + enabled: false diff --git a/docs/docs/sidebar/features/agent-hardening.md b/docs/docs/sidebar/features/agent-hardening.md new file mode 100644 index 000000000..23fba6c20 --- /dev/null +++ b/docs/docs/sidebar/features/agent-hardening.md @@ -0,0 +1,247 @@ +--- +sidebar_position: 20 +sidebar_label: Agent Hardening +--- + +# Agent Hardening + +OSAPI supports running the agent as an unprivileged user with config-driven +privilege escalation for write operations. Reads run as the agent's own user. +Writes use `sudo` when configured. Linux capabilities provide an alternative for +file-level access without a full sudo setup. When either option is enabled, the +agent automatically verifies the configuration at startup before accepting any +jobs. + +When none of these options are enabled, the agent behaves as before — commands +run as the current user, root or otherwise. + +## Configuration + +```yaml +agent: + privilege_escalation: + # Activate least-privilege mode: sudo for write commands + # and capability verification at startup. + enabled: false +``` + +| Field | Type | Default | Description | +| --------- | ---- | ------- | ----------------------------------------------------------------- | +| `enabled` | bool | false | Activate sudo for write commands and capability checks at startup | + +## How It Works + +The exec manager exposes two execution paths: + +- **`RunCmd`** — runs the command as the agent's current user. Used for all read + operations (listing services, reading kernel parameters, querying package + state, etc.). +- **`RunPrivilegedCmd`** — runs the command with `sudo` prepended when + `privilege_escalation.enabled: true`. When disabled, this is identical to + `RunCmd`. + +Providers call `RunCmd` for reads and `RunPrivilegedCmd` for writes. The +providers themselves have no knowledge of whether `sudo` is enabled — the exec +manager handles it transparently. + +```go +// Read — always unprivileged +output, _ := d.execManager.RunCmd("systemctl", []string{"is-active", name}) + +// Write — elevated when configured +_, err := d.execManager.RunPrivilegedCmd( + "systemctl", []string{"start", name}) +``` + +## Sudoers Drop-In + +Create `/etc/sudoers.d/osapi-agent` with the following content to allow the +`osapi` system user to run write commands without a password: + +```sudoers +# Service management +osapi ALL=(root) NOPASSWD: /usr/bin/systemctl start * +osapi ALL=(root) NOPASSWD: /usr/bin/systemctl stop * +osapi ALL=(root) NOPASSWD: /usr/bin/systemctl restart * +osapi ALL=(root) NOPASSWD: /usr/bin/systemctl enable * +osapi ALL=(root) NOPASSWD: /usr/bin/systemctl disable * +osapi ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload + +# Kernel parameters +osapi ALL=(root) NOPASSWD: /usr/sbin/sysctl -p * +osapi ALL=(root) NOPASSWD: /usr/sbin/sysctl --system + +# Timezone +osapi ALL=(root) NOPASSWD: /usr/bin/timedatectl set-timezone * + +# Hostname +osapi ALL=(root) NOPASSWD: /usr/bin/hostnamectl set-hostname * + +# NTP +osapi ALL=(root) NOPASSWD: /usr/bin/chronyc reload sources + +# User and group management +osapi ALL=(root) NOPASSWD: /usr/sbin/useradd * +osapi ALL=(root) NOPASSWD: /usr/sbin/usermod * +osapi ALL=(root) NOPASSWD: /usr/sbin/userdel * +osapi ALL=(root) NOPASSWD: /usr/sbin/groupadd * +osapi ALL=(root) NOPASSWD: /usr/sbin/groupdel * +osapi ALL=(root) NOPASSWD: /usr/bin/gpasswd * +osapi ALL=(root) NOPASSWD: /usr/bin/chown * +osapi ALL=(root) NOPASSWD: /bin/sh -c echo * + +# Package management +osapi ALL=(root) NOPASSWD: /usr/bin/apt-get install * +osapi ALL=(root) NOPASSWD: /usr/bin/apt-get remove * +osapi ALL=(root) NOPASSWD: /usr/bin/apt-get update + +# Certificate trust store +osapi ALL=(root) NOPASSWD: /usr/sbin/update-ca-certificates + +# Power management +osapi ALL=(root) NOPASSWD: /sbin/shutdown * +``` + +Validate the file with `sudo visudo -c -f /etc/sudoers.d/osapi-agent` before +reloading. + +## Linux Capabilities + +As an alternative to `sudo` for file-level access, grant the agent binary +specific Linux capabilities: + +```bash +sudo setcap \ + 'cap_dac_read_search+ep cap_dac_override+ep cap_fowner+ep cap_kill+ep' \ + /usr/local/bin/osapi +``` + +When `privilege_escalation.enabled: true`, the agent reads `/proc/self/status` +at startup and checks the `CapEff` bitmask for the required bits: + +| Capability | Bit | Purpose | +| --------------------- | --- | ------------------------------- | +| `CAP_DAC_READ_SEARCH` | 2 | Read restricted files | +| `CAP_DAC_OVERRIDE` | 1 | Write files regardless of owner | +| `CAP_FOWNER` | 3 | Change file ownership | +| `CAP_KILL` | 5 | Signal any process | + +If any required capability is missing the agent logs the failure and exits with +a non-zero status. + +## Systemd Unit File + +The recommended way to run the agent as an unprivileged user with capabilities +preserved across restarts: + +```ini +[Unit] +Description=OSAPI Agent +After=network.target + +[Service] +Type=simple +User=osapi +Group=osapi +ExecStart=/usr/local/bin/osapi agent start +Restart=always +RestartSec=5 +AmbientCapabilities=CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE CAP_FOWNER CAP_KILL +CapabilityBoundingSet=CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE CAP_FOWNER CAP_KILL +SecureBits=keep-caps +NoNewPrivileges=no +PrivateTmp=true + +[Install] +WantedBy=multi-user.target +``` + +`AmbientCapabilities` grants the capabilities to the process without requiring +`setcap` on the binary. `NoNewPrivileges=no` is required so that `sudo` (if also +used) can elevate correctly. + +## Preflight Checks + +When `enabled` is true, the agent automatically runs a verification pass during +`agent start` before subscribing to NATS. Checks are sequential: sudo first, +then capabilities. If any check fails, the agent logs the failure and exits with +a non-zero status. + +The sudo check runs `sudo -n --version` (or `sudo -n which ` +for commands that do not support `--version`). The `-n` flag makes `sudo` fail +immediately if a password prompt would be required, confirming that the sudoers +entry is present and correct. + +Example output: + +``` +OSAPI Agent Preflight Check +───────────────────────────── +Sudo access: + ✓ systemctl ✓ sysctl ✓ timedatectl + ✓ hostnamectl ✓ chronyc ✓ useradd + ✓ usermod ✓ userdel ✓ groupadd + ✓ groupdel ✓ gpasswd ✓ chown + ✓ apt-get ✓ shutdown ✓ update-ca-certificates + ✗ sh (sudoers rule missing) + +Capabilities: + ✓ CAP_DAC_READ_SEARCH ✓ CAP_DAC_OVERRIDE + ✓ CAP_FOWNER ✓ CAP_KILL + +Result: FAILED (1 error) + - sudo: sh not configured in /etc/sudoers.d/osapi-agent +``` + +## Command Reference + +### Write Operations (use `RunPrivilegedCmd`) + +| Command | Domain | +| -------------------------- | ----------- | +| `systemctl start/stop/…` | Service | +| `systemctl daemon-reload` | Service | +| `sysctl -p`, `--system` | Sysctl | +| `timedatectl set-timezone` | Timezone | +| `hostnamectl set-hostname` | Hostname | +| `chronyc reload sources` | NTP | +| `useradd`, `usermod` | User | +| `userdel -r` | User | +| `groupadd`, `groupdel` | Group | +| `gpasswd -M` | Group | +| `chown -R` | SSH Key | +| `apt-get install/remove` | Package | +| `apt-get update` | Package | +| `update-ca-certificates` | Certificate | +| `shutdown -r/-h` | Power | +| `sh -c "echo … chpasswd"` | User | + +### Read Operations (use `RunCmd`) + +| Command | Domain | +| --------------------------- | -------- | +| `systemctl list-units` | Service | +| `systemctl list-unit-files` | Service | +| `systemctl show` | Service | +| `systemctl is-active` | Service | +| `systemctl is-enabled` | Service | +| `sysctl -n` | Sysctl | +| `timedatectl show` | Timezone | +| `hostnamectl hostname` | Hostname | +| `journalctl` | Log | +| `chronyc tracking` | NTP | +| `chronyc sources -c` | NTP | +| `id -Gn` | User | +| `passwd -S` | User | +| `dpkg-query` | Package | +| `apt list --upgradable` | Package | +| `date +%:z` | Timezone | + +## What Is Not Changed + +- **Controller and NATS server** — already run unprivileged, no changes needed. +- **`command exec` and `command shell`** — these endpoints execute arbitrary + user-provided commands and inherit whatever privileges the agent has. They are + gated by the `command:execute` RBAC permission. +- **Docker provider** — talks to the Docker API socket, not system commands. The + `osapi` user needs to be in the `docker` group. diff --git a/docs/docs/sidebar/usage/configuration.md b/docs/docs/sidebar/usage/configuration.md index 5697c6cf1..88a8dc162 100644 --- a/docs/docs/sidebar/usage/configuration.md +++ b/docs/docs/sidebar/usage/configuration.md @@ -90,6 +90,7 @@ uppercased: | `agent.process_conditions.high_cpu_percent` | `OSAPI_AGENT_PROCESS_CONDITIONS_HIGH_CPU_PERCENT` | | `agent.metrics.enabled` | `OSAPI_AGENT_METRICS_ENABLED` | | `agent.metrics.port` | `OSAPI_AGENT_METRICS_PORT` | +| `agent.privilege_escalation.enabled` | `OSAPI_AGENT_PRIVILEGE_ESCALATION_ENABLED` | Environment variables take precedence over file values. @@ -504,6 +505,10 @@ agent: enabled: true # Port the metrics server listens on. port: 9091 + # Least-privilege mode. When enabled, write commands use sudo and + # Linux capabilities are verified at startup. + privilege_escalation: + enabled: false ``` ## Section Reference @@ -695,6 +700,7 @@ When enabled, the port also serves `/health` (liveness) and `/health/ready` | `labels` | map[string]string | Key-value pairs for label-based routing | | `metrics.enabled` | bool | Enable the metrics server (default: true) | | `metrics.port` | int | Port the metrics server listens on (default: 9091) | +| `privilege_escalation.enabled` | bool | Activate sudo and capability checks (default false) | When `metrics.enabled` is true, the port also serves `/health` (liveness) and `/health/ready` (readiness) probes without authentication. diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts index f75efa81d..3ba2d6f71 100644 --- a/docs/docusaurus.config.ts +++ b/docs/docusaurus.config.ts @@ -203,6 +203,11 @@ const config: Config = { type: 'doc', label: 'Service Management', docId: 'sidebar/features/service-management' + }, + { + type: 'doc', + label: 'Agent Hardening', + docId: 'sidebar/features/agent-hardening' } ] }, diff --git a/docs/plans/2026-04-02-agent-privilege-escalation-design.md b/docs/plans/2026-04-02-agent-privilege-escalation-design.md new file mode 100644 index 000000000..4f3a1f589 --- /dev/null +++ b/docs/plans/2026-04-02-agent-privilege-escalation-design.md @@ -0,0 +1,281 @@ +# Agent Privilege Escalation Design + +Run the OSAPI agent as an unprivileged user with config-driven sudo escalation +for write operations, Linux capabilities for direct file access, and preflight +verification at startup. + +## Problem + +The agent runs as root by default. This grants full system access to a +network-facing process that accepts jobs from NATS. A compromised agent (or a +malicious job) has unrestricted access to the host. The guiding principles call +for least-privilege mode. + +## Solution + +Split command execution into read and write paths. Reads run unprivileged. +Writes run through `sudo` when configured. The agent verifies its privileges at +startup and refuses to start if the configuration doesn't match the system +state. + +## Config + +```yaml +agent: + privilege_escalation: + sudo: true + capabilities: true + preflight: true +``` + +| Field | Type | Default | Description | +| -------------- | ---- | ------- | ------------------------------------------ | +| `sudo` | bool | false | Prepend `sudo` to write commands | +| `capabilities` | bool | false | Verify Linux capabilities at startup | +| `preflight` | bool | false | Run privilege checks before accepting jobs | + +When all fields are false (or the section is absent), the agent behaves as +before — commands run as the current user. + +## Exec Manager Interface + +Add `RunPrivilegedCmd` to the `Manager` interface: + +```go +type Manager interface { + RunCmd(name string, args []string) (string, error) + RunPrivilegedCmd(name string, args []string) (string, error) + RunCmdFull(name string, args []string, cwd string, timeout int) (*CmdResult, error) +} +``` + +The `Exec` struct gains a `sudo bool` field: + +```go +func (e *Exec) RunPrivilegedCmd( + name string, + args []string, +) (string, error) { + if e.sudo { + args = append([]string{name}, args...) + name = "sudo" + } + return e.RunCmdImpl(name, args, "") +} +``` + +When `sudo` is false, `RunPrivilegedCmd` is identical to `RunCmd`. + +## Provider Changes + +Every provider write operation changes from `RunCmd` to `RunPrivilegedCmd`. Read +operations stay on `RunCmd`. The providers themselves don't know or care whether +sudo is enabled — the exec manager handles it. + +```go +// Read — always unprivileged +output, _ := d.execManager.RunCmd("systemctl", []string{"is-active", name}) + +// Write — elevated when configured +_, err := d.execManager.RunPrivilegedCmd("systemctl", []string{"start", name}) +``` + +Tests enforce this: the mock `Manager` has both methods. If a write operation +calls `RunCmd` instead of `RunPrivilegedCmd`, the mock expectation fails. + +## Command Classification + +### Write operations (use `RunPrivilegedCmd`) + +| Command | Domain | +| -------------------------- | ----------- | +| `systemctl start/stop/…` | Service | +| `systemctl daemon-reload` | Service | +| `sysctl -p`, `--system` | Sysctl | +| `timedatectl set-timezone` | Timezone | +| `hostnamectl set-hostname` | Hostname | +| `chronyc reload sources` | NTP | +| `useradd`, `usermod` | User | +| `userdel -r` | User | +| `groupadd`, `groupdel` | Group | +| `gpasswd -M` | Group | +| `chown -R` | SSH Key | +| `apt-get install/remove` | Package | +| `apt-get update` | Package | +| `update-ca-certificates` | Certificate | +| `shutdown -r/-h` | Power | +| `sh -c "echo … chpasswd"` | User | + +### Read operations (use `RunCmd`) + +| Command | Domain | +| --------------------------- | -------- | +| `systemctl list-units` | Service | +| `systemctl list-unit-files` | Service | +| `systemctl show` | Service | +| `systemctl is-active` | Service | +| `systemctl is-enabled` | Service | +| `sysctl -n` | Sysctl | +| `timedatectl show` | Timezone | +| `hostnamectl hostname` | Hostname | +| `journalctl` | Log | +| `chronyc tracking` | NTP | +| `chronyc sources -c` | NTP | +| `id -Gn` | User | +| `passwd -S` | User | +| `dpkg-query` | Package | +| `apt list --upgradable` | Package | +| `date +%:z` | Timezone | + +## Preflight Checks + +Run during `agent start` before the agent subscribes to NATS. Checks are +sequential: sudo first, then capabilities. If any check fails, the agent logs +the failure and exits with a non-zero status. + +### Sudo verification + +For each write command, run `sudo -n --version` (or equivalent no-op +flag). The `-n` flag makes sudo fail immediately if a password would be +required. If the command doesn't support `--version`, use +`sudo -n which ` as a fallback. + +### Capability verification + +Read `/proc/self/status`, parse the `CapEff` hexadecimal bitmask, and check that +required capability bits are set: + +| Capability | Bit | Purpose | +| --------------------- | --- | ------------------------------- | +| `CAP_DAC_READ_SEARCH` | 2 | Read restricted files | +| `CAP_DAC_OVERRIDE` | 1 | Write files regardless of owner | +| `CAP_FOWNER` | 3 | Change file ownership | +| `CAP_KILL` | 5 | Signal any process | + +### Output format + +``` +OSAPI Agent Preflight Check +───────────────────────────── +Sudo access: + ✓ systemctl ✓ sysctl ✓ timedatectl + ✓ hostnamectl ✓ chronyc ✓ useradd + ✓ usermod ✓ userdel ✓ groupadd + ✓ groupdel ✓ gpasswd ✓ chown + ✓ apt-get ✓ shutdown ✓ update-ca-certificates + ✗ sh (sudoers rule missing) + +Capabilities: + ✓ CAP_DAC_READ_SEARCH ✓ CAP_DAC_OVERRIDE + ✓ CAP_FOWNER ✓ CAP_KILL + +Result: FAILED (1 error) + - sudo: sh not configured in /etc/sudoers.d/osapi-agent +``` + +## Deployment Artifacts + +### Sudoers drop-in (`/etc/sudoers.d/osapi-agent`) + +```sudoers +# Service management +osapi ALL=(root) NOPASSWD: /usr/bin/systemctl start * +osapi ALL=(root) NOPASSWD: /usr/bin/systemctl stop * +osapi ALL=(root) NOPASSWD: /usr/bin/systemctl restart * +osapi ALL=(root) NOPASSWD: /usr/bin/systemctl enable * +osapi ALL=(root) NOPASSWD: /usr/bin/systemctl disable * +osapi ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload + +# Kernel parameters +osapi ALL=(root) NOPASSWD: /usr/sbin/sysctl -p * +osapi ALL=(root) NOPASSWD: /usr/sbin/sysctl --system + +# Timezone +osapi ALL=(root) NOPASSWD: /usr/bin/timedatectl set-timezone * + +# Hostname +osapi ALL=(root) NOPASSWD: /usr/bin/hostnamectl set-hostname * + +# NTP +osapi ALL=(root) NOPASSWD: /usr/bin/chronyc reload sources + +# User and group management +osapi ALL=(root) NOPASSWD: /usr/sbin/useradd * +osapi ALL=(root) NOPASSWD: /usr/sbin/usermod * +osapi ALL=(root) NOPASSWD: /usr/sbin/userdel * +osapi ALL=(root) NOPASSWD: /usr/sbin/groupadd * +osapi ALL=(root) NOPASSWD: /usr/sbin/groupdel * +osapi ALL=(root) NOPASSWD: /usr/bin/gpasswd * +osapi ALL=(root) NOPASSWD: /usr/bin/chown * +osapi ALL=(root) NOPASSWD: /bin/sh -c echo * + +# Package management +osapi ALL=(root) NOPASSWD: /usr/bin/apt-get install * +osapi ALL=(root) NOPASSWD: /usr/bin/apt-get remove * +osapi ALL=(root) NOPASSWD: /usr/bin/apt-get update + +# Certificate trust store +osapi ALL=(root) NOPASSWD: /usr/sbin/update-ca-certificates + +# Power management +osapi ALL=(root) NOPASSWD: /sbin/shutdown * +``` + +### Linux capabilities + +```bash +sudo setcap \ + 'cap_dac_read_search+ep cap_dac_override+ep cap_fowner+ep cap_kill+ep' \ + /usr/local/bin/osapi +``` + +### Systemd unit file + +```ini +[Unit] +Description=OSAPI Agent +After=network.target + +[Service] +Type=simple +User=osapi +Group=osapi +ExecStart=/usr/local/bin/osapi agent start +Restart=always +RestartSec=5 +AmbientCapabilities=CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE CAP_FOWNER CAP_KILL +CapabilityBoundingSet=CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE CAP_FOWNER CAP_KILL +SecureBits=keep-caps +NoNewPrivileges=no +PrivateTmp=true + +[Install] +WantedBy=multi-user.target +``` + +## Not Changing + +- Controller and NATS server — already run unprivileged, no changes needed +- The `command exec` and `command shell` endpoints — these execute arbitrary + user-provided commands, so they inherit whatever privileges the agent has. + They are gated by the `command:execute` permission in RBAC. +- Docker provider — talks to the Docker API socket, not system commands. The + `osapi` user needs to be in the `docker` group. + +## Files Changed + +- `internal/config/types.go` — add `PrivilegeEscalation` struct +- `internal/exec/manager.go` — add `RunPrivilegedCmd` to interface +- `internal/exec/types.go` — add `sudo bool` to `Exec` struct +- `internal/exec/run_privileged_cmd.go` — new file, implementation +- `internal/exec/run_privileged_cmd_public_test.go` — tests +- `internal/exec/mocks/` — regenerate +- `internal/agent/preflight.go` — new file, sudo + caps verification +- `internal/agent/preflight_public_test.go` — tests +- `internal/agent/agent.go` — call preflight during `Start()` +- `cmd/agent_setup.go` — pass `sudo` bool to exec manager +- Every provider `debian*.go` file — change write `RunCmd` to `RunPrivilegedCmd` + (~37 call sites) +- Every provider `debian*_public_test.go` — update mock expectations +- `docs/docs/sidebar/usage/configuration.md` — add config reference +- `docs/docs/sidebar/features/` — add agent hardening feature page diff --git a/docs/plans/2026-04-02-agent-privilege-escalation.md b/docs/plans/2026-04-02-agent-privilege-escalation.md new file mode 100644 index 000000000..7531ceb34 --- /dev/null +++ b/docs/plans/2026-04-02-agent-privilege-escalation.md @@ -0,0 +1,989 @@ +# Agent Privilege Escalation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add config-driven sudo escalation for write commands, Linux capability +verification, and startup preflight checks to the OSAPI agent so it can run as +an unprivileged user. + +**Architecture:** The exec `Manager` interface gains `RunPrivilegedCmd` which +prepends `sudo` when configured. Providers call it for write operations. At +startup, the agent runs preflight checks to verify sudo and capabilities before +accepting jobs. + +**Tech Stack:** Go, Linux capabilities (`/proc/self/status`), sudo + +--- + +### Task 1: Add privilege escalation config + +**Files:** + +- Modify: `internal/config/types.go:353-374` +- Modify: `configs/osapi.yaml` +- Modify: `configs/osapi.nerd.yaml` +- Modify: `configs/osapi.local.yaml` + +- [ ] **Step 1: Add PrivilegeEscalation struct and wire into AgentConfig** + +In `internal/config/types.go`, add a new struct before `AgentConfig`: + +```go +// PrivilegeEscalation configuration for least-privilege agent mode. +type PrivilegeEscalation struct { + // Sudo prepends "sudo" to write commands when true. + Sudo bool `mapstructure:"sudo"` + // Capabilities verifies Linux capabilities at startup when true. + Capabilities bool `mapstructure:"capabilities"` + // Preflight runs privilege checks before accepting jobs when true. + Preflight bool `mapstructure:"preflight"` +} +``` + +Add the field to `AgentConfig`: + +```go +type AgentConfig struct { + // ... existing fields ... + // PrivilegeEscalation configures least-privilege agent mode. + PrivilegeEscalation PrivilegeEscalation `mapstructure:"privilege_escalation,omitempty"` +} +``` + +- [ ] **Step 2: Add config to YAML files** + +In `configs/osapi.yaml`, `configs/osapi.nerd.yaml`, and +`configs/osapi.local.yaml`, add to the `agent:` section (disabled by default): + +```yaml +# Least-privilege mode. When enabled, the agent runs as an +# unprivileged user and uses sudo for write operations. +# privilege_escalation: +# sudo: false +# capabilities: false +# preflight: false +``` + +- [ ] **Step 3: Verify build** + +Run: `go build ./...` + +- [ ] **Step 4: Commit** + +``` +feat(agent): add privilege escalation config +``` + +--- + +### Task 2: Add RunPrivilegedCmd to exec manager + +**Files:** + +- Modify: `internal/exec/manager.go` +- Modify: `internal/exec/types.go` +- Modify: `internal/exec/exec.go` +- Create: `internal/exec/run_privileged_cmd.go` +- Create: `internal/exec/run_privileged_cmd_public_test.go` +- Modify: `internal/exec/mocks/generate.go` + +- [ ] **Step 1: Add RunPrivilegedCmd to the Manager interface** + +In `internal/exec/manager.go`: + +```go +type Manager interface { + // RunCmd executes the provided command with arguments, using the + // current working directory. Use for read operations. + RunCmd( + name string, + args []string, + ) (string, error) + + // RunPrivilegedCmd executes a command with privilege escalation. + // When sudo is enabled in config, prepends "sudo" to the command. + // When sudo is disabled, behaves identically to RunCmd. + // Use for write operations that modify system state. + RunPrivilegedCmd( + name string, + args []string, + ) (string, error) + + // RunCmdFull executes a command with separate stdout/stderr capture, + // an optional working directory, and a timeout in seconds. + RunCmdFull( + name string, + args []string, + cwd string, + timeout int, + ) (*CmdResult, error) +} +``` + +- [ ] **Step 2: Add sudo field to Exec struct and update constructor** + +In `internal/exec/types.go`, add the `sudo` field: + +```go +type Exec struct { + logger *slog.Logger + sudo bool +} +``` + +In `internal/exec/exec.go`, update the constructor: + +```go +func New( + logger *slog.Logger, + sudo bool, +) *Exec { + return &Exec{ + logger: logger.With(slog.String("subsystem", "exec")), + sudo: sudo, + } +} +``` + +- [ ] **Step 3: Create RunPrivilegedCmd implementation** + +Create `internal/exec/run_privileged_cmd.go`: + +```go +package exec + +// RunPrivilegedCmd executes a command with privilege escalation. +// When sudo is enabled, the command is run via "sudo". When disabled, +// it behaves identically to RunCmd. +func (e *Exec) RunPrivilegedCmd( + name string, + args []string, +) (string, error) { + if e.sudo { + args = append([]string{name}, args...) + name = "sudo" + } + + return e.RunCmdImpl(name, args, "") +} +``` + +- [ ] **Step 4: Write tests** + +Create `internal/exec/run_privileged_cmd_public_test.go`. Test: + +- When sudo is false, RunPrivilegedCmd behaves like RunCmd (runs the command + directly) +- When sudo is true, the command is prefixed with sudo (verify the actual args + passed to the underlying exec) + +Since `RunCmdImpl` calls `os/exec.Command`, use a test helper pattern: override +the command execution to capture what would be run. Look at how +`run_cmd_public_test.go` tests `RunCmd` and follow the same pattern. + +- [ ] **Step 5: Update agent_setup.go to pass sudo config** + +In `cmd/agent_setup.go`, change the `exec.New` call: + +```go +// Before: +execManager := exec.New(log) + +// After: +execManager := exec.New( + log, + appConfig.Agent.PrivilegeEscalation.Sudo, +) +``` + +- [ ] **Step 6: Regenerate mocks** + +Run: `go generate ./internal/exec/mocks/...` + +- [ ] **Step 7: Fix compilation errors** + +The mock `Manager` now requires `RunPrivilegedCmd`. Any existing test that +constructs a mock `Manager` may need updating. Run `go build ./...` and fix any +compile errors. + +- [ ] **Step 8: Run tests** + +Run: `go test ./internal/exec/... -count=1` Run: `go build ./...` + +- [ ] **Step 9: Commit** + +``` +feat(exec): add RunPrivilegedCmd with config-driven sudo +``` + +--- + +### Task 3: Add preflight checks + +**Files:** + +- Create: `internal/agent/preflight.go` +- Create: `internal/agent/preflight_public_test.go` +- Modify: `internal/agent/server.go` + +- [ ] **Step 1: Create preflight types and runner** + +Create `internal/agent/preflight.go`: + +```go +package agent + +import ( + "bufio" + "encoding/hex" + "fmt" + "log/slog" + "os" + "strings" + + "github.com/retr0h/osapi/internal/exec" +) + +// PreflightResult holds the outcome of a single preflight check. +type PreflightResult struct { + Name string + Passed bool + Error string +} + +// sudoCommands lists the binaries that require sudo access. +var sudoCommands = []string{ + "systemctl", + "sysctl", + "timedatectl", + "hostnamectl", + "chronyc", + "useradd", + "usermod", + "userdel", + "groupadd", + "groupdel", + "gpasswd", + "chown", + "apt-get", + "shutdown", + "update-ca-certificates", + "sh", +} + +// requiredCapabilities maps capability names to their bit positions +// in the CapEff bitmask from /proc/self/status. +var requiredCapabilities = map[string]uint{ + "CAP_DAC_OVERRIDE": 1, + "CAP_DAC_READ_SEARCH": 2, + "CAP_FOWNER": 3, + "CAP_KILL": 5, +} + +// procStatusPath is the path to read for capability detection. +// Overridden in tests. +var procStatusPath = "/proc/self/status" + +// RunPreflight checks sudo access and capabilities. Returns all +// results and whether all checks passed. +func RunPreflight( + logger *slog.Logger, + execManager exec.Manager, + checkSudo bool, + checkCaps bool, +) ([]PreflightResult, bool) { + var results []PreflightResult + allPassed := true + + if checkSudo { + sudoResults := checkSudoAccess(logger, execManager) + results = append(results, sudoResults...) + for _, r := range sudoResults { + if !r.Passed { + allPassed = false + } + } + } + + if checkCaps { + capResults := checkCapabilities(logger) + results = append(results, capResults...) + for _, r := range capResults { + if !r.Passed { + allPassed = false + } + } + } + + return results, allPassed +} + +// checkSudoAccess verifies that sudo -n works for each required +// command. Uses "sudo -n which " which is a no-op that +// tests sudo access without side effects. +func checkSudoAccess( + logger *slog.Logger, + execManager exec.Manager, +) []PreflightResult { + results := make([]PreflightResult, 0, len(sudoCommands)) + + for _, cmd := range sudoCommands { + _, err := execManager.RunCmd( + "sudo", + []string{"-n", "which", cmd}, + ) + + result := PreflightResult{ + Name: "sudo:" + cmd, + Passed: err == nil, + } + if err != nil { + result.Error = fmt.Sprintf( + "sudo access denied for %s: %s", + cmd, + err.Error(), + ) + } + + logger.Debug( + "preflight sudo check", + slog.String("command", cmd), + slog.Bool("passed", result.Passed), + ) + + results = append(results, result) + } + + return results +} + +// checkCapabilities reads /proc/self/status and verifies that +// required capability bits are set in the effective capability mask. +func checkCapabilities( + logger *slog.Logger, +) []PreflightResult { + capEff, err := readCapEff() + if err != nil { + return []PreflightResult{{ + Name: "capabilities", + Passed: false, + Error: fmt.Sprintf("failed to read capabilities: %s", err), + }} + } + + results := make([]PreflightResult, 0, len(requiredCapabilities)) + + for name, bit := range requiredCapabilities { + hasCap := (capEff>>bit)&1 == 1 + result := PreflightResult{ + Name: "cap:" + name, + Passed: hasCap, + } + if !hasCap { + result.Error = fmt.Sprintf("%s not set", name) + } + + logger.Debug( + "preflight capability check", + slog.String("capability", name), + slog.Bool("passed", hasCap), + ) + + results = append(results, result) + } + + return results +} + +// readCapEff reads the effective capability bitmask from +// /proc/self/status. +func readCapEff() (uint64, error) { + f, err := os.Open(procStatusPath) + if err != nil { + return 0, fmt.Errorf("open %s: %w", procStatusPath, err) + } + defer func() { _ = f.Close() }() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "CapEff:") { + hexStr := strings.TrimSpace( + strings.TrimPrefix(line, "CapEff:"), + ) + bytes, err := hex.DecodeString(hexStr) + if err != nil { + return 0, fmt.Errorf( + "decode CapEff %q: %w", + hexStr, + err, + ) + } + // Convert big-endian bytes to uint64. + var val uint64 + for _, b := range bytes { + val = (val << 8) | uint64(b) + } + return val, nil + } + } + + return 0, fmt.Errorf("CapEff not found in %s", procStatusPath) +} +``` + +- [ ] **Step 2: Write preflight tests** + +Create `internal/agent/preflight_public_test.go`. Use testify/suite with +table-driven tests. Test: + +**TestCheckSudoAccess:** + +- All commands pass (mock RunCmd returns nil for all sudo -n which calls) +- One command fails (mock RunCmd returns error for one) +- Multiple commands fail + +**TestCheckCapabilities:** + +- All capabilities present (write a fake /proc/self/status file with full + CapEff, override `procStatusPath` via export_test.go) +- Missing capability (write CapEff without a required bit) +- Cannot read file (set procStatusPath to nonexistent path) + +**TestRunPreflight:** + +- Both sudo and caps enabled and pass → allPassed true +- Sudo fails → allPassed false +- Caps fails → allPassed false +- Both disabled → empty results, allPassed true + +Create `internal/agent/export_test.go` (or add to existing) to expose +`procStatusPath` for testing: + +```go +package agent + +func SetProcStatusPath(p string) { procStatusPath = p } +func ResetProcStatusPath() { procStatusPath = "/proc/self/status" } +``` + +- [ ] **Step 3: Wire preflight into agent Start()** + +In `internal/agent/server.go`, add preflight check after hostname determination +but before starting heartbeat: + +```go +func (a *Agent) Start() { + a.ctx, a.cancel = context.WithCancel(context.Background()) + a.startedAt = time.Now() + a.state = job.AgentStateReady + + a.logger.Info("starting node agent") + + a.hostname, _ = job.GetAgentHostname(a.appConfig.Agent.Hostname) + + // Run preflight checks if configured. + pe := a.appConfig.Agent.PrivilegeEscalation + if pe.Preflight { + results, ok := RunPreflight( + a.logger, + a.execManager, + pe.Sudo, + pe.Capabilities, + ) + if !ok { + for _, r := range results { + if !r.Passed { + a.logger.Error( + "preflight check failed", + slog.String("check", r.Name), + slog.String("error", r.Error), + ) + } + } + a.logger.Error("preflight failed, agent cannot start") + a.cancel() + return + } + a.logger.Info("preflight checks passed") + } + + // ... rest of Start() unchanged ... +``` + +The agent needs access to the exec manager. Check if it's already on the `Agent` +struct. If not, add an `execManager exec.Manager` field and pass it from +`agent_setup.go`. + +- [ ] **Step 4: Run tests** + +Run: `go test ./internal/agent/... -count=1` Run: `go build ./...` + +- [ ] **Step 5: Commit** + +``` +feat(agent): add preflight checks for sudo and capabilities +``` + +--- + +### Task 4: Migrate service provider to RunPrivilegedCmd + +**Files:** + +- Modify: `internal/provider/node/service/debian_action.go` +- Modify: `internal/provider/node/service/debian_unit.go` +- Modify: `internal/provider/node/service/debian_action_public_test.go` +- Modify: `internal/provider/node/service/debian_unit_public_test.go` + +- [ ] **Step 1: Update write calls in debian_action.go** + +Change these `RunCmd` calls to `RunPrivilegedCmd`: + +```go +// Start — line 49: "systemctl start" is a write +d.execManager.RunPrivilegedCmd("systemctl", []string{"start", unitName}) + +// Stop — "systemctl stop" is a write +d.execManager.RunPrivilegedCmd("systemctl", []string{"stop", unitName}) + +// Restart — "systemctl restart" is a write +d.execManager.RunPrivilegedCmd("systemctl", []string{"restart", unitName}) + +// Enable — "systemctl enable" is a write +d.execManager.RunPrivilegedCmd("systemctl", []string{"enable", unitName}) + +// Disable — "systemctl disable" is a write +d.execManager.RunPrivilegedCmd("systemctl", []string{"disable", unitName}) +``` + +Keep these as `RunCmd` (reads): + +- `systemctl is-active` (Start, Stop) +- `systemctl is-enabled` (Enable, Disable) + +- [ ] **Step 2: Update write calls in debian_unit.go** + +```go +// Delete — "systemctl stop" and "systemctl disable" are writes +d.execManager.RunPrivilegedCmd("systemctl", []string{"stop", unitName}) +d.execManager.RunPrivilegedCmd("systemctl", []string{"disable", unitName}) + +// daemonReload — "systemctl daemon-reload" is a write +d.execManager.RunPrivilegedCmd("systemctl", []string{"daemon-reload"}) +``` + +- [ ] **Step 3: Update test mock expectations** + +In `debian_action_public_test.go`, change all mock expectations for write +commands from `RunCmd` to `RunPrivilegedCmd`. Keep read command expectations on +`RunCmd`. + +In `debian_unit_public_test.go`, change mock expectations for `systemctl stop`, +`systemctl disable`, `systemctl daemon-reload` from `RunCmd` to +`RunPrivilegedCmd`. + +- [ ] **Step 4: Run tests** + +Run: `go test ./internal/provider/node/service/... -count=1` + +- [ ] **Step 5: Commit** + +``` +refactor(service): use RunPrivilegedCmd for write operations +``` + +--- + +### Task 5: Migrate sysctl provider + +**Files:** + +- Modify: `internal/provider/node/sysctl/debian.go` +- Modify: `internal/provider/node/sysctl/debian_public_test.go` + +- [ ] **Step 1: Update write calls** + +Change to `RunPrivilegedCmd`: + +- `sysctl -p ` (apply config) +- `sysctl --system` (reload all) + +Keep as `RunCmd`: + +- `sysctl -n ` (read parameter) + +- [ ] **Step 2: Update test mock expectations** + +- [ ] **Step 3: Run tests** + +Run: `go test ./internal/provider/node/sysctl/... -count=1` + +- [ ] **Step 4: Commit** + +``` +refactor(sysctl): use RunPrivilegedCmd for write operations +``` + +--- + +### Task 6: Migrate hostname provider + +**Files:** + +- Modify: `internal/provider/node/host/debian_update_hostname.go` +- Modify: `internal/provider/node/host/debian_update_hostname_public_test.go` + +- [ ] **Step 1: Update write calls** + +Change to `RunPrivilegedCmd`: + +- `hostnamectl set-hostname ` + +Keep as `RunCmd` (in other host files): + +- `hostnamectl hostname` (read) + +- [ ] **Step 2: Update test mock expectations** + +- [ ] **Step 3: Run tests** + +Run: `go test ./internal/provider/node/host/... -count=1` + +- [ ] **Step 4: Commit** + +``` +refactor(host): use RunPrivilegedCmd for write operations +``` + +--- + +### Task 7: Migrate timezone provider + +**Files:** + +- Modify: `internal/provider/node/timezone/debian.go` +- Modify: `internal/provider/node/timezone/debian_public_test.go` + +- [ ] **Step 1: Update write calls** + +Change to `RunPrivilegedCmd`: + +- `timedatectl set-timezone ` + +Keep as `RunCmd`: + +- `timedatectl show -p Timezone --value` (read) +- `date +%:z` (read) + +- [ ] **Step 2: Update test mock expectations** + +- [ ] **Step 3: Run tests** + +Run: `go test ./internal/provider/node/timezone/... -count=1` + +- [ ] **Step 4: Commit** + +``` +refactor(timezone): use RunPrivilegedCmd for write operations +``` + +--- + +### Task 8: Migrate NTP provider + +**Files:** + +- Modify: `internal/provider/node/ntp/debian.go` +- Modify: `internal/provider/node/ntp/debian_public_test.go` + +- [ ] **Step 1: Update write calls** + +Change to `RunPrivilegedCmd`: + +- `chronyc reload sources` + +Keep as `RunCmd`: + +- `chronyc tracking` (read) +- `chronyc sources -c` (read) + +- [ ] **Step 2: Update test mock expectations** + +- [ ] **Step 3: Run tests** + +Run: `go test ./internal/provider/node/ntp/... -count=1` + +- [ ] **Step 4: Commit** + +``` +refactor(ntp): use RunPrivilegedCmd for write operations +``` + +--- + +### Task 9: Migrate user provider + +**Files:** + +- Modify: `internal/provider/node/user/debian_user.go` +- Modify: `internal/provider/node/user/debian_group.go` +- Modify: `internal/provider/node/user/debian_ssh_key.go` +- Modify: `internal/provider/node/user/debian_user_public_test.go` +- Modify: `internal/provider/node/user/debian_group_public_test.go` +- Modify: `internal/provider/node/user/debian_ssh_key_public_test.go` + +- [ ] **Step 1: Update write calls in debian_user.go** + +Change to `RunPrivilegedCmd`: + +- `useradd --create-home ...` +- `usermod ...` (all variants) +- `userdel -r ...` +- `sh -c "echo ... | chpasswd"` + +Keep as `RunCmd`: + +- `id -Gn ` (read) +- `passwd -S ` (read) + +- [ ] **Step 2: Update write calls in debian_group.go** + +Change to `RunPrivilegedCmd`: + +- `groupadd ...` +- `groupdel ...` +- `gpasswd -M ...` + +- [ ] **Step 3: Update write calls in debian_ssh_key.go** + +Change to `RunPrivilegedCmd`: + +- `chown -R ...` + +- [ ] **Step 4: Update all test mock expectations** + +- [ ] **Step 5: Run tests** + +Run: `go test ./internal/provider/node/user/... -count=1` + +- [ ] **Step 6: Commit** + +``` +refactor(user): use RunPrivilegedCmd for write operations +``` + +--- + +### Task 10: Migrate package provider + +**Files:** + +- Modify: `internal/provider/node/apt/debian.go` +- Modify: `internal/provider/node/apt/debian_public_test.go` + +- [ ] **Step 1: Update write calls** + +Change to `RunPrivilegedCmd`: + +- `apt-get install -y ...` +- `apt-get remove -y ...` +- `apt-get update` + +Keep as `RunCmd`: + +- `dpkg-query ...` (read) +- `apt list --upgradable` (read) + +- [ ] **Step 2: Update test mock expectations** + +- [ ] **Step 3: Run tests** + +Run: `go test ./internal/provider/node/apt/... -count=1` + +- [ ] **Step 4: Commit** + +``` +refactor(apt): use RunPrivilegedCmd for write operations +``` + +--- + +### Task 11: Migrate power provider + +**Files:** + +- Modify: `internal/provider/node/power/debian.go` +- Modify: `internal/provider/node/power/debian_public_test.go` + +- [ ] **Step 1: Update write calls** + +Change to `RunPrivilegedCmd`: + +- `shutdown -r ...` (reboot) +- `shutdown -h ...` (halt) + +- [ ] **Step 2: Update test mock expectations** + +- [ ] **Step 3: Run tests** + +Run: `go test ./internal/provider/node/power/... -count=1` + +- [ ] **Step 4: Commit** + +``` +refactor(power): use RunPrivilegedCmd for write operations +``` + +--- + +### Task 12: Migrate certificate provider + +**Files:** + +- Modify: `internal/provider/node/certificate/debian.go` +- Modify: `internal/provider/node/certificate/debian_public_test.go` + +- [ ] **Step 1: Update write calls** + +Change to `RunPrivilegedCmd`: + +- `update-ca-certificates` + +- [ ] **Step 2: Update test mock expectations** + +- [ ] **Step 3: Run tests** + +Run: `go test ./internal/provider/node/certificate/... -count=1` + +- [ ] **Step 4: Commit** + +``` +refactor(certificate): use RunPrivilegedCmd for write operations +``` + +--- + +### Task 13: Migrate DNS provider + +**Files:** + +- Modify: + `internal/provider/network/dns/debian_update_resolv_conf_by_interface.go` (or + equivalent write file) +- Modify: corresponding test file + +- [ ] **Step 1: Check if DNS uses exec for writes** + +Read the DNS provider to determine if it executes commands for writes. The DNS +provider may use `resolvectl` or write files directly. If it uses `RunCmd` for +writes, change to `RunPrivilegedCmd`. If it only writes files via `avfs`, no +changes needed (file writes are covered by capabilities). + +- [ ] **Step 2: Update if needed and run tests** + +Run: `go test ./internal/provider/network/dns/... -count=1` + +- [ ] **Step 3: Commit (if changes made)** + +``` +refactor(dns): use RunPrivilegedCmd for write operations +``` + +--- + +### Task 14: Update documentation + +**Files:** + +- Modify: `docs/docs/sidebar/usage/configuration.md` +- Create: `docs/docs/sidebar/features/agent-hardening.md` + +- [ ] **Step 1: Add config reference to configuration.md** + +In the agent section of configuration.md, add: + +```yaml +# Least-privilege mode for running the agent as an unprivileged user. +privilege_escalation: + # Prepend "sudo" to write commands. + sudo: false + # Verify Linux capabilities at startup. + capabilities: false + # Run privilege checks before accepting jobs. + preflight: false +``` + +Add the environment variable mappings: + +| Config Key | Environment Variable | +| ----------------------------------------- | ----------------------------------------------- | +| `agent.privilege_escalation.sudo` | `OSAPI_AGENT_PRIVILEGE_ESCALATION_SUDO` | +| `agent.privilege_escalation.capabilities` | `OSAPI_AGENT_PRIVILEGE_ESCALATION_CAPABILITIES` | +| `agent.privilege_escalation.preflight` | `OSAPI_AGENT_PRIVILEGE_ESCALATION_PREFLIGHT` | + +- [ ] **Step 2: Create agent hardening feature page** + +Create `docs/docs/sidebar/features/agent-hardening.md` with: + +- Overview of least-privilege mode +- Configuration reference +- Sudoers drop-in (copy from spec) +- Capabilities setup (copy from spec) +- Systemd unit file (copy from spec) +- Preflight output example +- Command privilege reference tables (copy from spec) + +- [ ] **Step 3: Update docusaurus.config.ts** + +Add "Agent Hardening" to the Features navbar dropdown. + +- [ ] **Step 4: Commit** + +``` +docs: add agent hardening feature page and config reference +``` + +--- + +### Task 15: Final verification + +- [ ] **Step 1: Run full test suite** + +```bash +just go::unit +``` + +All tests must pass. + +- [ ] **Step 2: Build and lint** + +```bash +go build ./... +just go::vet +``` + +- [ ] **Step 3: Verify no stray RunCmd calls for write operations** + +Check each write command from the spec against the codebase to confirm it uses +`RunPrivilegedCmd`: + +```bash +grep -rn 'RunCmd.*"systemctl".*"start\|stop\|restart\|enable\|disable\|daemon-reload"' \ + internal/provider/ --include="*.go" | grep -v _test.go | grep -v RunPrivilegedCmd +``` + +Repeat for other write commands (`useradd`, `usermod`, `apt-get`, `shutdown`, +`sysctl -p`, etc.). Expect: no matches. + +- [ ] **Step 4: Verify read commands stayed on RunCmd** + +```bash +grep -rn 'RunPrivilegedCmd.*"systemctl".*"list-units\|list-unit-files\|show\|is-active\|is-enabled"' \ + internal/provider/ --include="*.go" | grep -v _test.go +``` + +Expect: no matches (reads should not use RunPrivilegedCmd). diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 333dcad81..78035c3de 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -31,6 +31,7 @@ import ( sdkmetric "go.opentelemetry.io/otel/sdk/metric" "github.com/retr0h/osapi/internal/config" + "github.com/retr0h/osapi/internal/exec" "github.com/retr0h/osapi/internal/job" "github.com/retr0h/osapi/internal/job/client" "github.com/retr0h/osapi/internal/provider" @@ -58,6 +59,7 @@ func New( registry *ProviderRegistry, registryKV jetstream.KeyValue, factsKV jetstream.KeyValue, + execManager exec.Manager, ) *Agent { logger = logger.With(slog.String("subsystem", "agent")) @@ -76,6 +78,7 @@ func New( registry: registry, registryKV: registryKV, factsKV: factsKV, + execManager: execManager, heartbeatLogger: logger.With(slog.String("subsystem", "heartbeat")), factsLogger: logger.With(slog.String("subsystem", "facts")), } diff --git a/internal/agent/agent_public_test.go b/internal/agent/agent_public_test.go index 9ab2649a6..9b9f606c3 100644 --- a/internal/agent/agent_public_test.go +++ b/internal/agent/agent_public_test.go @@ -22,8 +22,11 @@ package agent_test import ( "context" + "fmt" "log/slog" "net" + "os" + "path/filepath" "testing" "time" @@ -34,6 +37,7 @@ import ( "github.com/retr0h/osapi/internal/agent" "github.com/retr0h/osapi/internal/config" + execmocks "github.com/retr0h/osapi/internal/exec/mocks" "github.com/retr0h/osapi/internal/job" "github.com/retr0h/osapi/internal/job/mocks" commandMocks "github.com/retr0h/osapi/internal/provider/command/mocks" @@ -164,6 +168,107 @@ func (s *AgentPublicTestSuite) TestStart() { a.Stop(stopCtx) }, }, + { + name: "returns early when preflight fails", + setupFunc: func() *agent.Agent { + mockExecMgr := execmocks.NewMockManager(s.mockCtrl) + mockExecMgr.EXPECT(). + RunCmd("sudo", gomock.Any()). + Return("", fmt.Errorf("sudo: a password is required")). + AnyTimes() + + cfg := s.appConfig + cfg.Agent.PrivilegeEscalation = config.PrivilegeEscalation{ + Enabled: true, + } + + return newTestAgent(newTestAgentParams{ + appFs: s.appFs, + appConfig: cfg, + logger: s.logger, + jobClient: s.mockJobClient, + streamName: "test-stream", + hostProvider: hostMocks.NewDefaultMockProvider(s.mockCtrl), + diskProvider: diskMocks.NewDefaultMockProvider(s.mockCtrl), + memProvider: memMocks.NewDefaultMockProvider(s.mockCtrl), + loadProvider: loadMocks.NewDefaultMockProvider(s.mockCtrl), + dnsProvider: dnsMocks.NewDefaultMockProvider(s.mockCtrl), + pingProvider: pingMocks.NewDefaultMockProvider(s.mockCtrl), + netinfoProvider: netinfoMocks.NewDefaultMockProvider(s.mockCtrl), + commandProvider: commandMocks.NewDefaultMockProvider(s.mockCtrl), + processProvider: processMocks.NewDefaultMockProvider(s.mockCtrl), + execManager: mockExecMgr, + }) + }, + stopFunc: func(a *agent.Agent) { + // Agent should not have started consumers, so IsReady fails. + err := a.IsReady() + s.Error(err) + }, + }, + { + name: "starts when preflight passes", + setupFunc: func() *agent.Agent { + mockExecMgr := execmocks.NewMockManager(s.mockCtrl) + mockExecMgr.EXPECT(). + RunCmd("sudo", gomock.Any()). + Return("/usr/bin/something", nil). + AnyTimes() + + // Write a fake proc status file with all capabilities set. + tmpDir := s.T().TempDir() + path := filepath.Join(tmpDir, "status") + err := os.WriteFile( + path, + []byte("Name:\tosapi\nCapEff:\t000000000000003f\n"), + 0o644, + ) + s.Require().NoError(err) + agent.SetProcStatusPath(path) + s.T().Cleanup(func() { + agent.ResetProcStatusPath() + }) + + cfg := s.appConfig + cfg.Agent.PrivilegeEscalation = config.PrivilegeEscalation{ + Enabled: true, + } + + s.mockJobClient.EXPECT(). + CreateOrUpdateConsumer(gomock.Any(), "test-stream", gomock.Any()). + Return(nil). + Times(6) + + s.mockJobClient.EXPECT(). + ConsumeJobs(gomock.Any(), "test-stream", gomock.Any(), gomock.Any(), gomock.Any()). + Return(context.Canceled). + Times(6) + + return newTestAgent(newTestAgentParams{ + appFs: s.appFs, + appConfig: cfg, + logger: s.logger, + jobClient: s.mockJobClient, + streamName: "test-stream", + hostProvider: hostMocks.NewDefaultMockProvider(s.mockCtrl), + diskProvider: diskMocks.NewDefaultMockProvider(s.mockCtrl), + memProvider: memMocks.NewDefaultMockProvider(s.mockCtrl), + loadProvider: loadMocks.NewDefaultMockProvider(s.mockCtrl), + dnsProvider: dnsMocks.NewDefaultMockProvider(s.mockCtrl), + pingProvider: pingMocks.NewDefaultMockProvider(s.mockCtrl), + netinfoProvider: netinfoMocks.NewDefaultMockProvider(s.mockCtrl), + commandProvider: commandMocks.NewDefaultMockProvider(s.mockCtrl), + processProvider: processMocks.NewDefaultMockProvider(s.mockCtrl), + execManager: mockExecMgr, + }) + }, + stopFunc: func(a *agent.Agent) { + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + a.Stop(stopCtx) + }, + }, { name: "stop times out when agents are slow to finish", setupFunc: func() *agent.Agent { diff --git a/internal/agent/export_test.go b/internal/agent/export_test.go index 4a78103a8..d6629ff19 100644 --- a/internal/agent/export_test.go +++ b/internal/agent/export_test.go @@ -24,11 +24,13 @@ import ( "context" "encoding/json" "io/fs" + "log/slog" "time" "github.com/nats-io/nats.go/jetstream" "github.com/retr0h/osapi/internal/config" + "github.com/retr0h/osapi/internal/exec" "github.com/retr0h/osapi/internal/job" "github.com/retr0h/osapi/internal/provider/command" dockerProv "github.com/retr0h/osapi/internal/provider/container/docker" @@ -332,6 +334,31 @@ func SetDockerNewFn(fn func() (*dockerProv.Client, error)) { // ResetDockerNewFn is a no-op kept for backward compat with test suites. func ResetDockerNewFn() {} +// SetProcStatusPath overrides the procStatusPath for testing. +func SetProcStatusPath(p string) { + procStatusPath = p +} + +// ResetProcStatusPath restores the default procStatusPath. +func ResetProcStatusPath() { + procStatusPath = "/proc/self/status" +} + +// ExportCheckSudoAccess exposes the private checkSudoAccess function for testing. +func ExportCheckSudoAccess( + logger *slog.Logger, + execManager exec.Manager, +) []PreflightResult { + return checkSudoAccess(logger, execManager) +} + +// ExportCheckCapabilities exposes the private checkCapabilities function for testing. +func ExportCheckCapabilities( + logger *slog.Logger, +) []PreflightResult { + return checkCapabilities(logger) +} + // --- Field accessors for Agent struct --- // GetAgentAppConfig returns the agent's appConfig field for testing. diff --git a/internal/agent/fixture_public_test.go b/internal/agent/fixture_public_test.go index a4b113e3b..f48932512 100644 --- a/internal/agent/fixture_public_test.go +++ b/internal/agent/fixture_public_test.go @@ -28,6 +28,7 @@ import ( "github.com/retr0h/osapi/internal/agent" "github.com/retr0h/osapi/internal/config" + "github.com/retr0h/osapi/internal/exec" "github.com/retr0h/osapi/internal/job/client" "github.com/retr0h/osapi/internal/provider/command" dockerProv "github.com/retr0h/osapi/internal/provider/container/docker" @@ -71,6 +72,7 @@ type newTestAgentParams struct { timezoneProvider timezoneProv.Provider powerProvider powerProv.Provider processProvider process.Provider + execManager exec.Manager registryKV jetstream.KeyValue factsKV jetstream.KeyValue } @@ -152,5 +154,6 @@ func newTestAgent(p newTestAgentParams) *agent.Agent { registry, p.registryKV, p.factsKV, + p.execManager, ) } diff --git a/internal/agent/preflight.go b/internal/agent/preflight.go new file mode 100644 index 000000000..78aef988a --- /dev/null +++ b/internal/agent/preflight.go @@ -0,0 +1,218 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package agent + +import ( + "bufio" + "fmt" + "log/slog" + "os" + "strconv" + "strings" + + "github.com/retr0h/osapi/internal/exec" +) + +// PreflightResult holds the outcome of a single preflight check. +type PreflightResult struct { + Name string + Passed bool + Error string +} + +// sudoCommands lists binaries that require sudo access for agent operations. +var sudoCommands = []string{ + "systemctl", + "sysctl", + "timedatectl", + "hostnamectl", + "chronyc", + "useradd", + "usermod", + "userdel", + "groupadd", + "groupdel", + "gpasswd", + "chown", + "apt-get", + "shutdown", + "update-ca-certificates", + "sh", +} + +// requiredCapabilities maps Linux capability names to their bit positions +// in the CapEff bitmask. +var requiredCapabilities = map[string]int{ + "CAP_DAC_OVERRIDE": 1, + "CAP_DAC_READ_SEARCH": 2, + "CAP_FOWNER": 3, + "CAP_KILL": 5, +} + +// procStatusPath is the path to the proc status file for reading capabilities. +// Overridable in tests via export_test.go. +var procStatusPath = "/proc/self/status" + +// RunPreflight runs sudo and capability checks. When called, it always +// checks both sudo access and Linux capabilities. Returns the combined +// results and whether all checks passed. +func RunPreflight( + logger *slog.Logger, + execManager exec.Manager, +) ([]PreflightResult, bool) { + allPassed := true + + sudoResults := checkSudoAccess(logger, execManager) + capResults := checkCapabilities(logger) + + results := make([]PreflightResult, 0, len(sudoResults)+len(capResults)) + results = append(results, sudoResults...) + + for _, r := range sudoResults { + if !r.Passed { + allPassed = false + } + } + + results = append(results, capResults...) + + for _, r := range capResults { + if !r.Passed { + allPassed = false + } + } + + return results, allPassed +} + +// checkSudoAccess verifies that the agent can run each required command +// via sudo without a password prompt. +func checkSudoAccess( + logger *slog.Logger, + execManager exec.Manager, +) []PreflightResult { + var results []PreflightResult + + for _, cmd := range sudoCommands { + name := fmt.Sprintf("sudo:%s", cmd) + + _, err := execManager.RunCmd("sudo", []string{"-n", "which", cmd}) + if err != nil { + logger.Debug("sudo preflight check failed", + slog.String("command", cmd), + slog.String("error", err.Error()), + ) + results = append(results, PreflightResult{ + Name: name, + Passed: false, + Error: fmt.Sprintf("sudo -n which %s: %s", cmd, err.Error()), + }) + + continue + } + + results = append(results, PreflightResult{ + Name: name, + Passed: true, + }) + } + + return results +} + +// checkCapabilities verifies that the agent process has the required Linux +// capabilities in its effective capability set. +func checkCapabilities( + logger *slog.Logger, +) []PreflightResult { + var results []PreflightResult + + capEff, err := readCapEff() + if err != nil { + logger.Debug("capability preflight check failed", + slog.String("error", err.Error()), + ) + + for capName := range requiredCapabilities { + results = append(results, PreflightResult{ + Name: fmt.Sprintf("cap:%s", capName), + Passed: false, + Error: fmt.Sprintf("failed to read capabilities: %s", err.Error()), + }) + } + + return results + } + + for capName, bit := range requiredCapabilities { + name := fmt.Sprintf("cap:%s", capName) + + if capEff&(1< 0 { cmd := "resolvectl" args := append([]string{"dns", interfaceName}, servers...) - output, err := u.execManager.RunCmd(cmd, args) + output, err := u.execManager.RunPrivilegedCmd(cmd, args) if err != nil { return nil, fmt.Errorf( "failed to set DNS servers with resolvectl: %w - %s", @@ -110,7 +110,7 @@ func (u *Debian) UpdateResolvConfByInterface( if len(filteredDomains) > 0 { cmd := "resolvectl" args := append([]string{"domain", interfaceName}, filteredDomains...) - output, err := u.execManager.RunCmd(cmd, args) + output, err := u.execManager.RunPrivilegedCmd(cmd, args) if err != nil { return nil, fmt.Errorf( "failed to set search domains with resolvectl: %w - %s", diff --git a/internal/provider/node/apt/debian.go b/internal/provider/node/apt/debian.go index 842c3ca84..de4bded1a 100644 --- a/internal/provider/node/apt/debian.go +++ b/internal/provider/node/apt/debian.go @@ -100,7 +100,7 @@ func (d *Debian) Install( _ context.Context, name string, ) (*Result, error) { - _, err := d.execManager.RunCmd( + _, err := d.execManager.RunPrivilegedCmd( "apt-get", []string{"install", "-y", name}, ) @@ -124,7 +124,7 @@ func (d *Debian) Remove( _ context.Context, name string, ) (*Result, error) { - _, err := d.execManager.RunCmd( + _, err := d.execManager.RunPrivilegedCmd( "apt-get", []string{"remove", "-y", name}, ) @@ -147,7 +147,7 @@ func (d *Debian) Remove( func (d *Debian) Update( _ context.Context, ) (*Result, error) { - _, err := d.execManager.RunCmd( + _, err := d.execManager.RunPrivilegedCmd( "apt-get", []string{"update"}, ) diff --git a/internal/provider/node/apt/debian_public_test.go b/internal/provider/node/apt/debian_public_test.go index 14a73bcdc..1f5db0820 100644 --- a/internal/provider/node/apt/debian_public_test.go +++ b/internal/provider/node/apt/debian_public_test.go @@ -241,7 +241,7 @@ func (suite *DebianPublicTestSuite) TestInstall() { pkgName: "vim", setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("apt-get", []string{"install", "-y", "vim"}). + RunPrivilegedCmd("apt-get", []string{"install", "-y", "vim"}). Return("", nil) }, validateFunc: func(result *apt.Result) { @@ -255,7 +255,7 @@ func (suite *DebianPublicTestSuite) TestInstall() { pkgName: "badpkg", setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("apt-get", []string{"install", "-y", "badpkg"}). + RunPrivilegedCmd("apt-get", []string{"install", "-y", "badpkg"}). Return("", fmt.Errorf("E: Unable to locate package badpkg")) }, wantErr: true, @@ -296,7 +296,7 @@ func (suite *DebianPublicTestSuite) TestRemove() { pkgName: "vim", setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("apt-get", []string{"remove", "-y", "vim"}). + RunPrivilegedCmd("apt-get", []string{"remove", "-y", "vim"}). Return("", nil) }, validateFunc: func(result *apt.Result) { @@ -310,7 +310,7 @@ func (suite *DebianPublicTestSuite) TestRemove() { pkgName: "vim", setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("apt-get", []string{"remove", "-y", "vim"}). + RunPrivilegedCmd("apt-get", []string{"remove", "-y", "vim"}). Return("", fmt.Errorf("permission denied")) }, wantErr: true, @@ -349,7 +349,7 @@ func (suite *DebianPublicTestSuite) TestUpdate() { name: "when update succeeds", setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("apt-get", []string{"update"}). + RunPrivilegedCmd("apt-get", []string{"update"}). Return("", nil) }, validateFunc: func(result *apt.Result) { @@ -361,7 +361,7 @@ func (suite *DebianPublicTestSuite) TestUpdate() { name: "when exec error", setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("apt-get", []string{"update"}). + RunPrivilegedCmd("apt-get", []string{"update"}). Return("", fmt.Errorf("permission denied")) }, wantErr: true, diff --git a/internal/provider/node/certificate/debian.go b/internal/provider/node/certificate/debian.go index 01984bb5f..68491534f 100644 --- a/internal/provider/node/certificate/debian.go +++ b/internal/provider/node/certificate/debian.go @@ -215,7 +215,7 @@ func certFilePath( // updateCACertificates runs update-ca-certificates to rebuild the trust store. func (d *Debian) updateCACertificates() error { - _, err := d.execManager.RunCmd("update-ca-certificates", nil) + _, err := d.execManager.RunPrivilegedCmd("update-ca-certificates", nil) if err != nil { return fmt.Errorf("update-ca-certificates: %w", err) } diff --git a/internal/provider/node/certificate/debian_public_test.go b/internal/provider/node/certificate/debian_public_test.go index 9faa2e9b7..47cdcbc19 100644 --- a/internal/provider/node/certificate/debian_public_test.go +++ b/internal/provider/node/certificate/debian_public_test.go @@ -104,7 +104,7 @@ func (suite *DebianPublicTestSuite) TestCreate() { Path: "/usr/local/share/ca-certificates/osapi-my-ca.crt", }, nil) suite.mockExecManager.EXPECT(). - RunCmd("update-ca-certificates", nil). + RunPrivilegedCmd("update-ca-certificates", nil). Return("", nil) }, validateFunc: func( @@ -169,7 +169,7 @@ func (suite *DebianPublicTestSuite) TestCreate() { Deploy(gomock.Any(), gomock.Any()). Return(&file.DeployResult{Changed: true}, nil) suite.mockExecManager.EXPECT(). - RunCmd("update-ca-certificates", nil). + RunPrivilegedCmd("update-ca-certificates", nil). Return("", errors.New("exec error")) }, validateFunc: func( @@ -273,7 +273,7 @@ func (suite *DebianPublicTestSuite) TestUpdate() { Path: "/usr/local/share/ca-certificates/osapi-my-ca.crt", }, nil) suite.mockExecManager.EXPECT(). - RunCmd("update-ca-certificates", nil). + RunPrivilegedCmd("update-ca-certificates", nil). Return("", nil) }, validateFunc: func( @@ -368,7 +368,7 @@ func (suite *DebianPublicTestSuite) TestUpdate() { Deploy(gomock.Any(), gomock.Any()). Return(&file.DeployResult{Changed: true}, nil) suite.mockExecManager.EXPECT(). - RunCmd("update-ca-certificates", nil). + RunPrivilegedCmd("update-ca-certificates", nil). Return("", errors.New("exec error")) }, validateFunc: func( @@ -425,7 +425,7 @@ func (suite *DebianPublicTestSuite) TestUpdate() { }). Return(&file.DeployResult{Changed: true}, nil) suite.mockExecManager.EXPECT(). - RunCmd("update-ca-certificates", nil). + RunPrivilegedCmd("update-ca-certificates", nil). Return("", nil) }, validateFunc: func( @@ -525,7 +525,7 @@ func (suite *DebianPublicTestSuite) TestDelete() { Path: "/usr/local/share/ca-certificates/osapi-my-ca.crt", }, nil) suite.mockExecManager.EXPECT(). - RunCmd("update-ca-certificates", nil). + RunPrivilegedCmd("update-ca-certificates", nil). Return("", nil) }, validateFunc: func( @@ -585,7 +585,7 @@ func (suite *DebianPublicTestSuite) TestDelete() { Undeploy(gomock.Any(), gomock.Any()). Return(&file.UndeployResult{Changed: true}, nil) suite.mockExecManager.EXPECT(). - RunCmd("update-ca-certificates", nil). + RunPrivilegedCmd("update-ca-certificates", nil). Return("", errors.New("exec error")) }, validateFunc: func( diff --git a/internal/provider/node/host/debian_update_hostname.go b/internal/provider/node/host/debian_update_hostname.go index bdbfb8cbc..e9e514817 100644 --- a/internal/provider/node/host/debian_update_hostname.go +++ b/internal/provider/node/host/debian_update_hostname.go @@ -40,7 +40,7 @@ func (u *Debian) UpdateHostname( return &UpdateHostnameResult{Changed: false}, nil } - if _, err := u.execManager.RunCmd("hostnamectl", []string{"set-hostname", name}); err != nil { + if _, err := u.execManager.RunPrivilegedCmd("hostnamectl", []string{"set-hostname", name}); err != nil { return nil, fmt.Errorf("failed to set hostname: %w", err) } diff --git a/internal/provider/node/host/debian_update_hostname_public_test.go b/internal/provider/node/host/debian_update_hostname_public_test.go index 57f540d54..06004f1e2 100644 --- a/internal/provider/node/host/debian_update_hostname_public_test.go +++ b/internal/provider/node/host/debian_update_hostname_public_test.go @@ -67,7 +67,7 @@ func (suite *DebianUpdateHostnamePublicTestSuite) TestUpdateHostname() { RunCmd("hostnamectl", []string{"hostname"}). Return("old-host", nil), mock.EXPECT(). - RunCmd("hostnamectl", []string{"set-hostname", "new-host"}). + RunPrivilegedCmd("hostnamectl", []string{"set-hostname", "new-host"}). Return("", nil), ) return mock @@ -124,7 +124,7 @@ func (suite *DebianUpdateHostnamePublicTestSuite) TestUpdateHostname() { RunCmd("hostnamectl", []string{"hostname"}). Return("old-host", nil), mock.EXPECT(). - RunCmd("hostnamectl", []string{"set-hostname", "new-host"}). + RunPrivilegedCmd("hostnamectl", []string{"set-hostname", "new-host"}). Return("", assert.AnError), ) return mock diff --git a/internal/provider/node/ntp/debian.go b/internal/provider/node/ntp/debian.go index 2830cd521..094621522 100644 --- a/internal/provider/node/ntp/debian.go +++ b/internal/provider/node/ntp/debian.go @@ -188,7 +188,7 @@ func (d *Debian) Delete( // reloadSources runs chronyc reload sources. Failures are logged as // warnings but do not fail the operation. func (d *Debian) reloadSources() { - if _, err := d.execManager.RunCmd("chronyc", []string{"reload", "sources"}); err != nil { + if _, err := d.execManager.RunPrivilegedCmd("chronyc", []string{"reload", "sources"}); err != nil { d.logger.Warn( "chronyc reload sources failed", slog.String("error", err.Error()), diff --git a/internal/provider/node/ntp/debian_public_test.go b/internal/provider/node/ntp/debian_public_test.go index 9951d1f21..9d724cca3 100644 --- a/internal/provider/node/ntp/debian_public_test.go +++ b/internal/provider/node/ntp/debian_public_test.go @@ -210,7 +210,7 @@ func (suite *DebianPublicTestSuite) TestCreate() { setupFs: func() {}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("chronyc", []string{"reload", "sources"}). + RunPrivilegedCmd("chronyc", []string{"reload", "sources"}). Return("", nil) }, validateFunc: func(r *ntp.CreateResult) { @@ -306,7 +306,7 @@ func (suite *DebianPublicTestSuite) TestCreate() { setupFs: func() {}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("chronyc", []string{"reload", "sources"}). + RunPrivilegedCmd("chronyc", []string{"reload", "sources"}). Return("", errors.New("chronyc not running")) }, validateFunc: func(r *ntp.CreateResult) { @@ -365,7 +365,7 @@ func (suite *DebianPublicTestSuite) TestUpdate() { }, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("chronyc", []string{"reload", "sources"}). + RunPrivilegedCmd("chronyc", []string{"reload", "sources"}). Return("", nil) }, validateFunc: func(r *ntp.UpdateResult) { @@ -464,7 +464,7 @@ func (suite *DebianPublicTestSuite) TestUpdate() { }, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("chronyc", []string{"reload", "sources"}). + RunPrivilegedCmd("chronyc", []string{"reload", "sources"}). Return("", errors.New("chronyc not running")) }, validateFunc: func(r *ntp.UpdateResult) { @@ -519,7 +519,7 @@ func (suite *DebianPublicTestSuite) TestDelete() { }, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("chronyc", []string{"reload", "sources"}). + RunPrivilegedCmd("chronyc", []string{"reload", "sources"}). Return("", nil) }, validateFunc: func(r *ntp.DeleteResult) { @@ -577,7 +577,7 @@ func (suite *DebianPublicTestSuite) TestDelete() { }, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("chronyc", []string{"reload", "sources"}). + RunPrivilegedCmd("chronyc", []string{"reload", "sources"}). Return("", errors.New("chronyc not running")) }, validateFunc: func(r *ntp.DeleteResult) { diff --git a/internal/provider/node/power/debian.go b/internal/provider/node/power/debian.go index d294a6aa8..d7cd85e0f 100644 --- a/internal/provider/node/power/debian.go +++ b/internal/provider/node/power/debian.go @@ -102,7 +102,7 @@ func (d *Debian) executePowerAction( args = append(args, opts.Message) } - if _, err := d.execManager.RunCmd("shutdown", args); err != nil { + if _, err := d.execManager.RunPrivilegedCmd("shutdown", args); err != nil { return nil, fmt.Errorf("power: %s: %w", action, err) } diff --git a/internal/provider/node/power/debian_public_test.go b/internal/provider/node/power/debian_public_test.go index 9226db49e..1e2475482 100644 --- a/internal/provider/node/power/debian_public_test.go +++ b/internal/provider/node/power/debian_public_test.go @@ -72,7 +72,7 @@ func (suite *DebianPublicTestSuite) TestReboot() { opts: power.Opts{}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("shutdown", []string{"-r", "+1"}). + RunPrivilegedCmd("shutdown", []string{"-r", "+1"}). Return("", nil) }, validateFunc: func(r *power.Result) { @@ -86,7 +86,7 @@ func (suite *DebianPublicTestSuite) TestReboot() { opts: power.Opts{Delay: 180}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("shutdown", []string{"-r", "+3"}). + RunPrivilegedCmd("shutdown", []string{"-r", "+3"}). Return("", nil) }, validateFunc: func(r *power.Result) { @@ -100,7 +100,7 @@ func (suite *DebianPublicTestSuite) TestReboot() { opts: power.Opts{Delay: 30}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("shutdown", []string{"-r", "+1"}). + RunPrivilegedCmd("shutdown", []string{"-r", "+1"}). Return("", nil) }, validateFunc: func(r *power.Result) { @@ -114,7 +114,7 @@ func (suite *DebianPublicTestSuite) TestReboot() { opts: power.Opts{Message: "maintenance reboot"}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("shutdown", []string{"-r", "+1", "maintenance reboot"}). + RunPrivilegedCmd("shutdown", []string{"-r", "+1", "maintenance reboot"}). Return("", nil) }, validateFunc: func(r *power.Result) { @@ -128,7 +128,7 @@ func (suite *DebianPublicTestSuite) TestReboot() { opts: power.Opts{Delay: 300, Message: "scheduled reboot"}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("shutdown", []string{"-r", "+5", "scheduled reboot"}). + RunPrivilegedCmd("shutdown", []string{"-r", "+5", "scheduled reboot"}). Return("", nil) }, validateFunc: func(r *power.Result) { @@ -142,7 +142,7 @@ func (suite *DebianPublicTestSuite) TestReboot() { opts: power.Opts{}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("shutdown", []string{"-r", "+1"}). + RunPrivilegedCmd("shutdown", []string{"-r", "+1"}). Return("", errors.New("permission denied")) }, wantErr: true, @@ -185,7 +185,7 @@ func (suite *DebianPublicTestSuite) TestShutdown() { opts: power.Opts{}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("shutdown", []string{"-h", "+1"}). + RunPrivilegedCmd("shutdown", []string{"-h", "+1"}). Return("", nil) }, validateFunc: func(r *power.Result) { @@ -199,7 +199,7 @@ func (suite *DebianPublicTestSuite) TestShutdown() { opts: power.Opts{Delay: 180}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("shutdown", []string{"-h", "+3"}). + RunPrivilegedCmd("shutdown", []string{"-h", "+3"}). Return("", nil) }, validateFunc: func(r *power.Result) { @@ -213,7 +213,7 @@ func (suite *DebianPublicTestSuite) TestShutdown() { opts: power.Opts{Delay: 30}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("shutdown", []string{"-h", "+1"}). + RunPrivilegedCmd("shutdown", []string{"-h", "+1"}). Return("", nil) }, validateFunc: func(r *power.Result) { @@ -227,7 +227,7 @@ func (suite *DebianPublicTestSuite) TestShutdown() { opts: power.Opts{Message: "maintenance shutdown"}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("shutdown", []string{"-h", "+1", "maintenance shutdown"}). + RunPrivilegedCmd("shutdown", []string{"-h", "+1", "maintenance shutdown"}). Return("", nil) }, validateFunc: func(r *power.Result) { @@ -241,7 +241,7 @@ func (suite *DebianPublicTestSuite) TestShutdown() { opts: power.Opts{Delay: 300, Message: "scheduled shutdown"}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("shutdown", []string{"-h", "+5", "scheduled shutdown"}). + RunPrivilegedCmd("shutdown", []string{"-h", "+5", "scheduled shutdown"}). Return("", nil) }, validateFunc: func(r *power.Result) { @@ -255,7 +255,7 @@ func (suite *DebianPublicTestSuite) TestShutdown() { opts: power.Opts{}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("shutdown", []string{"-h", "+1"}). + RunPrivilegedCmd("shutdown", []string{"-h", "+1"}). Return("", errors.New("permission denied")) }, wantErr: true, diff --git a/internal/provider/node/service/debian_action.go b/internal/provider/node/service/debian_action.go index 32c8030b5..3cddbbc36 100644 --- a/internal/provider/node/service/debian_action.go +++ b/internal/provider/node/service/debian_action.go @@ -46,7 +46,7 @@ func (d *Debian) Start( return &ActionResult{Name: name, Changed: false}, nil } - if _, err := d.execManager.RunCmd("systemctl", []string{"start", unitName}); err != nil { + if _, err := d.execManager.RunPrivilegedCmd("systemctl", []string{"start", unitName}); err != nil { return nil, fmt.Errorf("service: start: %w", err) } @@ -72,7 +72,7 @@ func (d *Debian) Stop( return &ActionResult{Name: name, Changed: false}, nil } - if _, err := d.execManager.RunCmd("systemctl", []string{"stop", unitName}); err != nil { + if _, err := d.execManager.RunPrivilegedCmd("systemctl", []string{"stop", unitName}); err != nil { return nil, fmt.Errorf("service: stop: %w", err) } @@ -92,7 +92,7 @@ func (d *Debian) Restart( d.logger.Debug("executing service.Restart", slog.String("name", unitName)) - if _, err := d.execManager.RunCmd("systemctl", []string{"restart", unitName}); err != nil { + if _, err := d.execManager.RunPrivilegedCmd("systemctl", []string{"restart", unitName}); err != nil { return nil, fmt.Errorf("service: restart: %w", err) } @@ -118,7 +118,7 @@ func (d *Debian) Enable( return &ActionResult{Name: name, Changed: false}, nil } - if _, err := d.execManager.RunCmd("systemctl", []string{"enable", unitName}); err != nil { + if _, err := d.execManager.RunPrivilegedCmd("systemctl", []string{"enable", unitName}); err != nil { return nil, fmt.Errorf("service: enable: %w", err) } @@ -144,7 +144,7 @@ func (d *Debian) Disable( return &ActionResult{Name: name, Changed: false}, nil } - if _, err := d.execManager.RunCmd("systemctl", []string{"disable", unitName}); err != nil { + if _, err := d.execManager.RunPrivilegedCmd("systemctl", []string{"disable", unitName}); err != nil { return nil, fmt.Errorf("service: disable: %w", err) } diff --git a/internal/provider/node/service/debian_action_public_test.go b/internal/provider/node/service/debian_action_public_test.go index a84ccc7ac..ed1dd2684 100644 --- a/internal/provider/node/service/debian_action_public_test.go +++ b/internal/provider/node/service/debian_action_public_test.go @@ -82,7 +82,7 @@ func (suite *DebianActionPublicTestSuite) TestStart() { RunCmd("systemctl", []string{"is-active", "osapi-nginx.service"}). Return("inactive\n", errors.New("exit status 3")) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"start", "osapi-nginx.service"}). + RunPrivilegedCmd("systemctl", []string{"start", "osapi-nginx.service"}). Return("", nil) }, validateFunc: func( @@ -121,7 +121,7 @@ func (suite *DebianActionPublicTestSuite) TestStart() { RunCmd("systemctl", []string{"is-active", "osapi-nginx.service"}). Return("inactive\n", errors.New("exit status 3")) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"start", "osapi-nginx.service"}). + RunPrivilegedCmd("systemctl", []string{"start", "osapi-nginx.service"}). Return("", errors.New("start failed")) }, validateFunc: func( @@ -174,7 +174,7 @@ func (suite *DebianActionPublicTestSuite) TestStop() { RunCmd("systemctl", []string{"is-active", "osapi-nginx.service"}). Return("active\n", nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"stop", "osapi-nginx.service"}). + RunPrivilegedCmd("systemctl", []string{"stop", "osapi-nginx.service"}). Return("", nil) }, validateFunc: func( @@ -213,7 +213,7 @@ func (suite *DebianActionPublicTestSuite) TestStop() { RunCmd("systemctl", []string{"is-active", "osapi-nginx.service"}). Return("active\n", nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"stop", "osapi-nginx.service"}). + RunPrivilegedCmd("systemctl", []string{"stop", "osapi-nginx.service"}). Return("", errors.New("stop failed")) }, validateFunc: func( @@ -263,7 +263,7 @@ func (suite *DebianActionPublicTestSuite) TestRestart() { serviceName: "nginx", setup: func() { suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"restart", "osapi-nginx.service"}). + RunPrivilegedCmd("systemctl", []string{"restart", "osapi-nginx.service"}). Return("", nil) }, validateFunc: func( @@ -281,7 +281,7 @@ func (suite *DebianActionPublicTestSuite) TestRestart() { serviceName: "nginx", setup: func() { suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"restart", "osapi-nginx.service"}). + RunPrivilegedCmd("systemctl", []string{"restart", "osapi-nginx.service"}). Return("", errors.New("restart failed")) }, validateFunc: func( @@ -334,7 +334,7 @@ func (suite *DebianActionPublicTestSuite) TestEnable() { RunCmd("systemctl", []string{"is-enabled", "osapi-nginx.service"}). Return("disabled\n", errors.New("exit status 1")) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"enable", "osapi-nginx.service"}). + RunPrivilegedCmd("systemctl", []string{"enable", "osapi-nginx.service"}). Return("", nil) }, validateFunc: func( @@ -373,7 +373,7 @@ func (suite *DebianActionPublicTestSuite) TestEnable() { RunCmd("systemctl", []string{"is-enabled", "osapi-nginx.service"}). Return("disabled\n", errors.New("exit status 1")) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"enable", "osapi-nginx.service"}). + RunPrivilegedCmd("systemctl", []string{"enable", "osapi-nginx.service"}). Return("", errors.New("enable failed")) }, validateFunc: func( @@ -426,7 +426,7 @@ func (suite *DebianActionPublicTestSuite) TestDisable() { RunCmd("systemctl", []string{"is-enabled", "osapi-nginx.service"}). Return("enabled\n", nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"disable", "osapi-nginx.service"}). + RunPrivilegedCmd("systemctl", []string{"disable", "osapi-nginx.service"}). Return("", nil) }, validateFunc: func( @@ -465,7 +465,7 @@ func (suite *DebianActionPublicTestSuite) TestDisable() { RunCmd("systemctl", []string{"is-enabled", "osapi-nginx.service"}). Return("enabled\n", nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"disable", "osapi-nginx.service"}). + RunPrivilegedCmd("systemctl", []string{"disable", "osapi-nginx.service"}). Return("", errors.New("disable failed")) }, validateFunc: func( diff --git a/internal/provider/node/service/debian_unit.go b/internal/provider/node/service/debian_unit.go index fa6019c67..ac7033373 100644 --- a/internal/provider/node/service/debian_unit.go +++ b/internal/provider/node/service/debian_unit.go @@ -150,14 +150,14 @@ func (d *Debian) Delete( unitName := managedPrefix + name + ".service" // Best-effort stop and disable before removing the unit file. - if _, err := d.execManager.RunCmd("systemctl", []string{"stop", unitName}); err != nil { + if _, err := d.execManager.RunPrivilegedCmd("systemctl", []string{"stop", unitName}); err != nil { d.logger.Warn("failed to stop service before delete", slog.String("name", name), slog.String("error", err.Error()), ) } - if _, err := d.execManager.RunCmd("systemctl", []string{"disable", unitName}); err != nil { + if _, err := d.execManager.RunPrivilegedCmd("systemctl", []string{"disable", unitName}); err != nil { d.logger.Warn("failed to disable service before delete", slog.String("name", name), slog.String("error", err.Error()), @@ -196,7 +196,7 @@ func unitFilePath( // daemonReload runs systemctl daemon-reload to pick up unit file changes. func (d *Debian) daemonReload() error { - _, err := d.execManager.RunCmd("systemctl", []string{"daemon-reload"}) + _, err := d.execManager.RunPrivilegedCmd("systemctl", []string{"daemon-reload"}) if err != nil { return fmt.Errorf("daemon-reload: %w", err) } diff --git a/internal/provider/node/service/debian_unit_public_test.go b/internal/provider/node/service/debian_unit_public_test.go index fbe25910e..ee6ab35ea 100644 --- a/internal/provider/node/service/debian_unit_public_test.go +++ b/internal/provider/node/service/debian_unit_public_test.go @@ -122,7 +122,7 @@ func (suite *DebianUnitPublicTestSuite) TestCreate() { Path: "/etc/systemd/system/osapi-myapp.service", }, nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"daemon-reload"}). + RunPrivilegedCmd("systemctl", []string{"daemon-reload"}). Return("", nil) }, validateFunc: func( @@ -187,7 +187,7 @@ func (suite *DebianUnitPublicTestSuite) TestCreate() { Deploy(gomock.Any(), gomock.Any()). Return(&file.DeployResult{Changed: true}, nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"daemon-reload"}). + RunPrivilegedCmd("systemctl", []string{"daemon-reload"}). Return("", errors.New("exec error")) }, validateFunc: func( @@ -275,7 +275,7 @@ func (suite *DebianUnitPublicTestSuite) TestUpdate() { Path: "/etc/systemd/system/osapi-myapp.service", }, nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"daemon-reload"}). + RunPrivilegedCmd("systemctl", []string{"daemon-reload"}). Return("", nil) }, validateFunc: func( @@ -370,7 +370,7 @@ func (suite *DebianUnitPublicTestSuite) TestUpdate() { Deploy(gomock.Any(), gomock.Any()). Return(&file.DeployResult{Changed: true}, nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"daemon-reload"}). + RunPrivilegedCmd("systemctl", []string{"daemon-reload"}). Return("", errors.New("exec error")) }, validateFunc: func( @@ -427,7 +427,7 @@ func (suite *DebianUnitPublicTestSuite) TestUpdate() { }). Return(&file.DeployResult{Changed: true}, nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"daemon-reload"}). + RunPrivilegedCmd("systemctl", []string{"daemon-reload"}). Return("", nil) }, validateFunc: func( @@ -519,10 +519,10 @@ func (suite *DebianUnitPublicTestSuite) TestDelete() { 0o644, ) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"stop", "osapi-myapp.service"}). + RunPrivilegedCmd("systemctl", []string{"stop", "osapi-myapp.service"}). Return("", nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"disable", "osapi-myapp.service"}). + RunPrivilegedCmd("systemctl", []string{"disable", "osapi-myapp.service"}). Return("", nil) suite.mockDeployer.EXPECT(). Undeploy(gomock.Any(), file.UndeployRequest{ @@ -533,7 +533,7 @@ func (suite *DebianUnitPublicTestSuite) TestDelete() { Path: "/etc/systemd/system/osapi-myapp.service", }, nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"daemon-reload"}). + RunPrivilegedCmd("systemctl", []string{"daemon-reload"}). Return("", nil) }, validateFunc: func( @@ -568,10 +568,10 @@ func (suite *DebianUnitPublicTestSuite) TestDelete() { 0o644, ) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"stop", "osapi-myapp.service"}). + RunPrivilegedCmd("systemctl", []string{"stop", "osapi-myapp.service"}). Return("", nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"disable", "osapi-myapp.service"}). + RunPrivilegedCmd("systemctl", []string{"disable", "osapi-myapp.service"}). Return("", nil) suite.mockDeployer.EXPECT(). Undeploy(gomock.Any(), gomock.Any()). @@ -596,16 +596,16 @@ func (suite *DebianUnitPublicTestSuite) TestDelete() { 0o644, ) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"stop", "osapi-myapp.service"}). + RunPrivilegedCmd("systemctl", []string{"stop", "osapi-myapp.service"}). Return("", nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"disable", "osapi-myapp.service"}). + RunPrivilegedCmd("systemctl", []string{"disable", "osapi-myapp.service"}). Return("", nil) suite.mockDeployer.EXPECT(). Undeploy(gomock.Any(), gomock.Any()). Return(&file.UndeployResult{Changed: true}, nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"daemon-reload"}). + RunPrivilegedCmd("systemctl", []string{"daemon-reload"}). Return("", errors.New("exec error")) }, validateFunc: func( @@ -627,10 +627,10 @@ func (suite *DebianUnitPublicTestSuite) TestDelete() { 0o644, ) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"stop", "osapi-myapp.service"}). + RunPrivilegedCmd("systemctl", []string{"stop", "osapi-myapp.service"}). Return("", errors.New("stop error")) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"disable", "osapi-myapp.service"}). + RunPrivilegedCmd("systemctl", []string{"disable", "osapi-myapp.service"}). Return("", errors.New("disable error")) suite.mockDeployer.EXPECT(). Undeploy(gomock.Any(), file.UndeployRequest{ @@ -641,7 +641,7 @@ func (suite *DebianUnitPublicTestSuite) TestDelete() { Path: "/etc/systemd/system/osapi-myapp.service", }, nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"daemon-reload"}). + RunPrivilegedCmd("systemctl", []string{"daemon-reload"}). Return("", nil) }, validateFunc: func( diff --git a/internal/provider/node/sysctl/debian.go b/internal/provider/node/sysctl/debian.go index bf8f0123d..f2110de2c 100644 --- a/internal/provider/node/sysctl/debian.go +++ b/internal/provider/node/sysctl/debian.go @@ -227,7 +227,7 @@ func (d *Debian) deploy( } // Apply the sysctl conf file. - if _, execErr := d.execManager.RunCmd("sysctl", []string{"-p", confPath}); execErr != nil { + if _, execErr := d.execManager.RunPrivilegedCmd("sysctl", []string{"-p", confPath}); execErr != nil { d.logger.Warn( "sysctl apply failed after deploy", slog.String("key", entry.Key), @@ -310,7 +310,7 @@ func (d *Debian) Delete( // Reload sysctl defaults. if changed { - if _, execErr := d.execManager.RunCmd("sysctl", []string{"--system"}); execErr != nil { + if _, execErr := d.execManager.RunPrivilegedCmd("sysctl", []string{"--system"}); execErr != nil { d.logger.Warn( "sysctl reload failed after delete", slog.String("key", key), diff --git a/internal/provider/node/sysctl/debian_public_test.go b/internal/provider/node/sysctl/debian_public_test.go index 6be9b18d3..46b752cc5 100644 --- a/internal/provider/node/sysctl/debian_public_test.go +++ b/internal/provider/node/sysctl/debian_public_test.go @@ -127,7 +127,7 @@ func (suite *DebianPublicTestSuite) TestCreate() { Put(gomock.Any(), gomock.Any(), gomock.Any()). Return(uint64(1), nil) suite.mockExec.EXPECT(). - RunCmd("sysctl", []string{"-p", "/etc/sysctl.d/osapi-net.ipv4.ip_forward.conf"}). + RunPrivilegedCmd("sysctl", []string{"-p", "/etc/sysctl.d/osapi-net.ipv4.ip_forward.conf"}). Return("", nil) }, validateFunc: func( @@ -237,7 +237,7 @@ func (suite *DebianPublicTestSuite) TestCreate() { Put(gomock.Any(), gomock.Any(), gomock.Any()). Return(uint64(1), nil) suite.mockExec.EXPECT(). - RunCmd("sysctl", gomock.Any()). + RunPrivilegedCmd("sysctl", gomock.Any()). Return("", nil) }, validateFunc: func( @@ -311,7 +311,7 @@ func (suite *DebianPublicTestSuite) TestCreate() { Put(gomock.Any(), gomock.Any(), gomock.Any()). Return(uint64(1), nil) suite.mockExec.EXPECT(). - RunCmd("sysctl", gomock.Any()). + RunPrivilegedCmd("sysctl", gomock.Any()). Return("", errors.New("sysctl failed")) }, validateFunc: func( @@ -366,7 +366,7 @@ func (suite *DebianPublicTestSuite) TestUpdate() { Put(gomock.Any(), gomock.Any(), gomock.Any()). Return(uint64(1), nil) suite.mockExec.EXPECT(). - RunCmd("sysctl", []string{"-p", "/etc/sysctl.d/osapi-net.ipv4.ip_forward.conf"}). + RunPrivilegedCmd("sysctl", []string{"-p", "/etc/sysctl.d/osapi-net.ipv4.ip_forward.conf"}). Return("", nil) }, validateFunc: func( @@ -656,7 +656,7 @@ func (suite *DebianPublicTestSuite) TestDelete() { Put(gomock.Any(), gomock.Any(), gomock.Any()). Return(uint64(1), nil) suite.mockExec.EXPECT(). - RunCmd("sysctl", []string{"--system"}). + RunPrivilegedCmd("sysctl", []string{"--system"}). Return("", nil) }, validateFunc: func( @@ -911,7 +911,7 @@ func (suite *DebianPublicTestSuite) TestDelete() { Put(gomock.Any(), gomock.Any(), gomock.Any()). Return(uint64(1), nil) suite.mockExec.EXPECT(). - RunCmd("sysctl", []string{"--system"}). + RunPrivilegedCmd("sysctl", []string{"--system"}). Return("", errors.New("sysctl failed")) }, validateFunc: func( diff --git a/internal/provider/node/timezone/debian.go b/internal/provider/node/timezone/debian.go index 59b1d6549..7302c6664 100644 --- a/internal/provider/node/timezone/debian.go +++ b/internal/provider/node/timezone/debian.go @@ -105,7 +105,7 @@ func (d *Debian) Update( }, nil } - if _, setErr := d.execManager.RunCmd("timedatectl", []string{"set-timezone", timezone}); setErr != nil { + if _, setErr := d.execManager.RunPrivilegedCmd("timedatectl", []string{"set-timezone", timezone}); setErr != nil { return nil, fmt.Errorf("timezone: set-timezone: %w", setErr) } diff --git a/internal/provider/node/timezone/debian_public_test.go b/internal/provider/node/timezone/debian_public_test.go index bfb7e239b..d512f1afe 100644 --- a/internal/provider/node/timezone/debian_public_test.go +++ b/internal/provider/node/timezone/debian_public_test.go @@ -147,7 +147,7 @@ func (suite *DebianPublicTestSuite) TestUpdate() { RunCmd("timedatectl", []string{"show", "-p", "Timezone", "--value"}). Return("America/New_York\n", nil) suite.mockExec.EXPECT(). - RunCmd("timedatectl", []string{"set-timezone", "Europe/London"}). + RunPrivilegedCmd("timedatectl", []string{"set-timezone", "Europe/London"}). Return("", nil) }, validateFunc: func(r *timezone.UpdateResult) { @@ -187,7 +187,7 @@ func (suite *DebianPublicTestSuite) TestUpdate() { RunCmd("timedatectl", []string{"show", "-p", "Timezone", "--value"}). Return("America/New_York\n", nil) suite.mockExec.EXPECT(). - RunCmd("timedatectl", []string{"set-timezone", "Europe/London"}). + RunPrivilegedCmd("timedatectl", []string{"set-timezone", "Europe/London"}). Return("", errors.New("permission denied")) }, wantErr: true, diff --git a/internal/provider/node/user/debian_group.go b/internal/provider/node/user/debian_group.go index f544acc7d..bc364f45b 100644 --- a/internal/provider/node/user/debian_group.go +++ b/internal/provider/node/user/debian_group.go @@ -78,7 +78,7 @@ func (d *Debian) CreateGroup( args := d.buildGroupaddArgs(opts) - _, err := d.execManager.RunCmd("groupadd", args) + _, err := d.execManager.RunPrivilegedCmd("groupadd", args) if err != nil { return nil, fmt.Errorf("group: groupadd failed: %w", err) } @@ -103,7 +103,7 @@ func (d *Debian) UpdateGroup( members := strings.Join(opts.Members, ",") - _, err := d.execManager.RunCmd("gpasswd", []string{"-M", members, name}) + _, err := d.execManager.RunPrivilegedCmd("gpasswd", []string{"-M", members, name}) if err != nil { return nil, fmt.Errorf("group: gpasswd failed: %w", err) } @@ -125,7 +125,7 @@ func (d *Debian) DeleteGroup( ) (*GroupResult, error) { _ = ctx - _, err := d.execManager.RunCmd("groupdel", []string{name}) + _, err := d.execManager.RunPrivilegedCmd("groupdel", []string{name}) if err != nil { return nil, fmt.Errorf("group: groupdel failed: %w", err) } diff --git a/internal/provider/node/user/debian_public_test.go b/internal/provider/node/user/debian_public_test.go index 70db98d24..5b7327bc2 100644 --- a/internal/provider/node/user/debian_public_test.go +++ b/internal/provider/node/user/debian_public_test.go @@ -356,7 +356,7 @@ func (suite *DebianPublicTestSuite) TestCreateUser() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("useradd", []string{"--create-home", "newuser"}). + RunPrivilegedCmd("useradd", []string{"--create-home", "newuser"}). Return("", nil) }, validateFunc: func(result *user.Result, err error) { @@ -379,7 +379,7 @@ func (suite *DebianPublicTestSuite) TestCreateUser() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("useradd", []string{ + RunPrivilegedCmd("useradd", []string{ "--create-home", "-u", "2000", "-g", "2000", @@ -406,10 +406,10 @@ func (suite *DebianPublicTestSuite) TestCreateUser() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("useradd", []string{"--create-home", "newuser"}). + RunPrivilegedCmd("useradd", []string{"--create-home", "newuser"}). Return("", nil) suite.mockExec.EXPECT(). - RunCmd("sh", []string{"-c", "echo 'newuser:secret123' | chpasswd"}). + RunPrivilegedCmd("sh", []string{"-c", "echo 'newuser:secret123' | chpasswd"}). Return("", nil) }, validateFunc: func(result *user.Result, err error) { @@ -426,7 +426,7 @@ func (suite *DebianPublicTestSuite) TestCreateUser() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("useradd", []string{"--create-home", "newuser"}). + RunPrivilegedCmd("useradd", []string{"--create-home", "newuser"}). Return("", errors.New("user already exists")) }, validateFunc: func(result *user.Result, err error) { @@ -443,10 +443,10 @@ func (suite *DebianPublicTestSuite) TestCreateUser() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("useradd", []string{"--create-home", "newuser"}). + RunPrivilegedCmd("useradd", []string{"--create-home", "newuser"}). Return("", nil) suite.mockExec.EXPECT(). - RunCmd("sh", []string{"-c", "echo 'newuser:secret123' | chpasswd"}). + RunPrivilegedCmd("sh", []string{"-c", "echo 'newuser:secret123' | chpasswd"}). Return("", errors.New("chpasswd failed")) }, validateFunc: func(result *user.Result, err error) { @@ -486,7 +486,7 @@ func (suite *DebianPublicTestSuite) TestUpdateUser() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("usermod", []string{"-s", "/bin/zsh", "john"}). + RunPrivilegedCmd("usermod", []string{"-s", "/bin/zsh", "john"}). Return("", nil) }, validateFunc: func(result *user.Result, err error) { @@ -504,7 +504,7 @@ func (suite *DebianPublicTestSuite) TestUpdateUser() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("usermod", []string{"-G", "sudo,docker,admin", "john"}). + RunPrivilegedCmd("usermod", []string{"-G", "sudo,docker,admin", "john"}). Return("", nil) }, validateFunc: func(result *user.Result, err error) { @@ -521,7 +521,7 @@ func (suite *DebianPublicTestSuite) TestUpdateUser() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("usermod", []string{"-L", "john"}). + RunPrivilegedCmd("usermod", []string{"-L", "john"}). Return("", nil) }, validateFunc: func(result *user.Result, err error) { @@ -538,7 +538,7 @@ func (suite *DebianPublicTestSuite) TestUpdateUser() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("usermod", []string{"-U", "john"}). + RunPrivilegedCmd("usermod", []string{"-U", "john"}). Return("", nil) }, validateFunc: func(result *user.Result, err error) { @@ -555,7 +555,7 @@ func (suite *DebianPublicTestSuite) TestUpdateUser() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("usermod", []string{"-d", "/opt/john", "-m", "john"}). + RunPrivilegedCmd("usermod", []string{"-d", "/opt/john", "-m", "john"}). Return("", nil) }, validateFunc: func(result *user.Result, err error) { @@ -584,7 +584,7 @@ func (suite *DebianPublicTestSuite) TestUpdateUser() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("usermod", []string{"-s", "/bin/zsh", "john"}). + RunPrivilegedCmd("usermod", []string{"-s", "/bin/zsh", "john"}). Return("", errors.New("usermod error")) }, validateFunc: func(result *user.Result, err error) { @@ -617,7 +617,7 @@ func (suite *DebianPublicTestSuite) TestDeleteUser() { userName: "john", setup: func() { suite.mockExec.EXPECT(). - RunCmd("userdel", []string{"-r", "john"}). + RunPrivilegedCmd("userdel", []string{"-r", "john"}). Return("", nil) }, validateFunc: func(result *user.Result, err error) { @@ -632,7 +632,7 @@ func (suite *DebianPublicTestSuite) TestDeleteUser() { userName: "nonexistent", setup: func() { suite.mockExec.EXPECT(). - RunCmd("userdel", []string{"-r", "nonexistent"}). + RunPrivilegedCmd("userdel", []string{"-r", "nonexistent"}). Return("", errors.New("user does not exist")) }, validateFunc: func(result *user.Result, err error) { @@ -667,7 +667,7 @@ func (suite *DebianPublicTestSuite) TestChangePassword() { password: "newpassword", setup: func() { suite.mockExec.EXPECT(). - RunCmd("sh", []string{"-c", "echo 'john:newpassword' | chpasswd"}). + RunPrivilegedCmd("sh", []string{"-c", "echo 'john:newpassword' | chpasswd"}). Return("", nil) }, validateFunc: func(result *user.Result, err error) { @@ -683,7 +683,7 @@ func (suite *DebianPublicTestSuite) TestChangePassword() { password: "newpassword", setup: func() { suite.mockExec.EXPECT(). - RunCmd("sh", []string{"-c", "echo 'john:newpassword' | chpasswd"}). + RunPrivilegedCmd("sh", []string{"-c", "echo 'john:newpassword' | chpasswd"}). Return("", errors.New("chpasswd error")) }, validateFunc: func(result *user.Result, err error) { @@ -874,7 +874,7 @@ func (suite *DebianPublicTestSuite) TestCreateGroup() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("groupadd", []string{"newgroup"}). + RunPrivilegedCmd("groupadd", []string{"newgroup"}). Return("", nil) }, validateFunc: func(result *user.GroupResult, err error) { @@ -892,7 +892,7 @@ func (suite *DebianPublicTestSuite) TestCreateGroup() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("groupadd", []string{"-g", "5000", "newgroup"}). + RunPrivilegedCmd("groupadd", []string{"-g", "5000", "newgroup"}). Return("", nil) }, validateFunc: func(result *user.GroupResult, err error) { @@ -910,7 +910,7 @@ func (suite *DebianPublicTestSuite) TestCreateGroup() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("groupadd", []string{"-r", "sysgroup"}). + RunPrivilegedCmd("groupadd", []string{"-r", "sysgroup"}). Return("", nil) }, validateFunc: func(result *user.GroupResult, err error) { @@ -927,7 +927,7 @@ func (suite *DebianPublicTestSuite) TestCreateGroup() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("groupadd", []string{"newgroup"}). + RunPrivilegedCmd("groupadd", []string{"newgroup"}). Return("", errors.New("group already exists")) }, validateFunc: func(result *user.GroupResult, err error) { @@ -964,7 +964,7 @@ func (suite *DebianPublicTestSuite) TestUpdateGroup() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("gpasswd", []string{"-M", "john,jane,alice", "developers"}). + RunPrivilegedCmd("gpasswd", []string{"-M", "john,jane,alice", "developers"}). Return("", nil) }, validateFunc: func(result *user.GroupResult, err error) { @@ -982,7 +982,7 @@ func (suite *DebianPublicTestSuite) TestUpdateGroup() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("gpasswd", []string{"-M", "john", "developers"}). + RunPrivilegedCmd("gpasswd", []string{"-M", "john", "developers"}). Return("", errors.New("gpasswd error")) }, validateFunc: func(result *user.GroupResult, err error) { @@ -1015,7 +1015,7 @@ func (suite *DebianPublicTestSuite) TestDeleteGroup() { groupName: "developers", setup: func() { suite.mockExec.EXPECT(). - RunCmd("groupdel", []string{"developers"}). + RunPrivilegedCmd("groupdel", []string{"developers"}). Return("", nil) }, validateFunc: func(result *user.GroupResult, err error) { @@ -1030,7 +1030,7 @@ func (suite *DebianPublicTestSuite) TestDeleteGroup() { groupName: "nonexistent", setup: func() { suite.mockExec.EXPECT(). - RunCmd("groupdel", []string{"nonexistent"}). + RunPrivilegedCmd("groupdel", []string{"nonexistent"}). Return("", errors.New("group does not exist")) }, validateFunc: func(result *user.GroupResult, err error) { diff --git a/internal/provider/node/user/debian_ssh_key.go b/internal/provider/node/user/debian_ssh_key.go index 4185cc48f..0a4253007 100644 --- a/internal/provider/node/user/debian_ssh_key.go +++ b/internal/provider/node/user/debian_ssh_key.go @@ -138,7 +138,7 @@ func (d *Debian) AddKey( } // Best-effort chown. - _, chownErr := d.execManager.RunCmd("chown", []string{ + _, chownErr := d.execManager.RunPrivilegedCmd("chown", []string{ "-R", username + ":" + username, sshDir, diff --git a/internal/provider/node/user/debian_ssh_key_public_test.go b/internal/provider/node/user/debian_ssh_key_public_test.go index b47a6a689..69bd23d34 100644 --- a/internal/provider/node/user/debian_ssh_key_public_test.go +++ b/internal/provider/node/user/debian_ssh_key_public_test.go @@ -385,7 +385,7 @@ func (suite *DebianSSHKeyPublicTestSuite) TestAddKey() { setupFS: func() {}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("chown", []string{"-R", "testuser:testuser", "/home/testuser/.ssh"}). + RunPrivilegedCmd("chown", []string{"-R", "testuser:testuser", "/home/testuser/.ssh"}). Return("", nil) }, validateFunc: func(result *user.SSHKeyResult, err error) { @@ -441,7 +441,7 @@ func (suite *DebianSSHKeyPublicTestSuite) TestAddKey() { }, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("chown", []string{"-R", "testuser:testuser", "/home/testuser/.ssh"}). + RunPrivilegedCmd("chown", []string{"-R", "testuser:testuser", "/home/testuser/.ssh"}). Return("", nil) }, validateFunc: func(result *user.SSHKeyResult, err error) { @@ -480,7 +480,7 @@ func (suite *DebianSSHKeyPublicTestSuite) TestAddKey() { }, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("chown", []string{"-R", "testuser:testuser", "/home/testuser/.ssh"}). + RunPrivilegedCmd("chown", []string{"-R", "testuser:testuser", "/home/testuser/.ssh"}). Return("", nil) }, validateFunc: func(result *user.SSHKeyResult, err error) { @@ -662,7 +662,7 @@ func (suite *DebianSSHKeyPublicTestSuite) TestAddKey() { setupFS: func() {}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("chown", []string{"-R", "testuser:testuser", "/home/testuser/.ssh"}). + RunPrivilegedCmd("chown", []string{"-R", "testuser:testuser", "/home/testuser/.ssh"}). Return("", errors.New("permission denied")) }, validateFunc: func(result *user.SSHKeyResult, err error) { diff --git a/internal/provider/node/user/debian_user.go b/internal/provider/node/user/debian_user.go index d8b2c78d5..aa82fb684 100644 --- a/internal/provider/node/user/debian_user.go +++ b/internal/provider/node/user/debian_user.go @@ -111,7 +111,7 @@ func (d *Debian) CreateUser( args := d.buildUseraddArgs(opts) - _, err := d.execManager.RunCmd("useradd", args) + _, err := d.execManager.RunPrivilegedCmd("useradd", args) if err != nil { return nil, fmt.Errorf("user: useradd failed: %w", err) } @@ -148,7 +148,7 @@ func (d *Debian) UpdateUser( }, nil } - _, err := d.execManager.RunCmd("usermod", args) + _, err := d.execManager.RunPrivilegedCmd("usermod", args) if err != nil { return nil, fmt.Errorf("user: usermod failed: %w", err) } @@ -170,7 +170,7 @@ func (d *Debian) DeleteUser( ) (*Result, error) { _ = ctx - _, err := d.execManager.RunCmd("userdel", []string{"-r", name}) + _, err := d.execManager.RunPrivilegedCmd("userdel", []string{"-r", name}) if err != nil { return nil, fmt.Errorf("user: userdel failed: %w", err) } @@ -358,7 +358,7 @@ func (d *Debian) setPassword( ) error { input := fmt.Sprintf("%s:%s", name, password) - _, err := d.execManager.RunCmd( + _, err := d.execManager.RunPrivilegedCmd( "sh", []string{"-c", fmt.Sprintf("echo '%s' | chpasswd", input)}, ) diff --git a/test/integration/osapi.yaml b/test/integration/osapi.yaml index dfc4e3ece..b7c5f778a 100644 --- a/test/integration/osapi.yaml +++ b/test/integration/osapi.yaml @@ -118,4 +118,6 @@ agent: max_jobs: 10 metrics: enabled: false + privilege_escalation: + enabled: false