diff --git a/AGENTS.md b/AGENTS.md index f9e90d12..afd174fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ The shell is supported on Linux, Windows and macOS. - **`df` bypasses `AllowedPaths` for mount-table enumeration.** `df` delegates filesystem listing to `builtins/internal/diskstats`, which on Linux reads `/proc/self/mountinfo` directly via `os.Open` and then calls `unix.Statfs(2)` on every mount point returned by the kernel. On macOS it calls `unix.Getfsstat(2)`. The mount-point paths are kernel-controlled — never derived from user input — so the same trade-off as `ss` / `ip route` applies: operators cannot use `AllowedPaths` to hide individual mounts from `df`. `Statfs` returns metadata only (block / inode counts, filesystem type, block size); no file content is read. -- **`journalctl` bypasses `AllowedPaths` for trusted systemd target access.** The builtin delegates journal discovery and reads, disk-usage scans, vacuuming, machine-ID reads, and journald control-socket access to `internal/systemd`, which opens the configured paths directly with `os` APIs. `SystemdTargetConfig.JournalDirs`, `SystemdTargetConfig.MachineIDPath`, and `SystemdTargetConfig.JournalControlSocket` are fixed by the embedding application when constructing the runner and cannot be supplied or changed by shell scripts. Operators therefore cannot use `AllowedPaths` to block these accesses; authorization is enforced separately by `AllowedCommands`, exact `AllowedSystemServices` grants, and remediation mode for mutations. All configured target paths are trusted and must refer to the same host. See the trusted systemd target exception in `docs/RULES.md` for the backend requirements. +- **`journalctl` and `systemctl` bypass `AllowedPaths` for trusted systemd target access.** The builtins delegate journal discovery and reads, disk-usage scans, vacuuming, machine-ID reads, journald control-socket access, and restricted manager-bus operations to `internal/systemd`, which opens the configured paths directly with `os` APIs. `SystemdTargetConfig.JournalDirs`, `SystemdTargetConfig.MachineIDPath`, `SystemdTargetConfig.JournalControlSocket`, and `SystemdTargetConfig.ManagerBusSocket` are fixed by the embedding application when constructing the runner and cannot be supplied or changed by shell scripts. Operators therefore cannot use `AllowedPaths` to block these accesses; authorization is enforced separately by `AllowedCommands`, exact `AllowedSystemServices` unit/action grants, remediation mode for journal mutations, and remediation mode for every `systemctl` invocation. All configured target paths are trusted and must refer to the same host. On Linux, manager access pins the configured public system D-Bus socket through `/proc/self/fd`, authenticates with EXTERNAL, and verifies the systemd manager peer's machine ID before fixed manager-interface requests. See the trusted systemd target exception in `docs/RULES.md` for the complete backend requirements and indirect systemd effects. ## CRITICAL: Bug Fixes and Bash Compatibility diff --git a/LICENSE-3rdparty.csv b/LICENSE-3rdparty.csv index 8e5bcbd7..64852cbe 100644 --- a/LICENSE-3rdparty.csv +++ b/LICENSE-3rdparty.csv @@ -7,6 +7,7 @@ github.com/DataDog/datadog-agent/pkg/version,https://github.com/DataDog/datadog- github.com/davecgh/go-spew,https://github.com/davecgh/go-spew,ISC,Copyright (c) 2012-2016 Dave Collins github.com/ebitengine/purego,https://github.com/ebitengine/purego,Apache-2.0,Copyright 2022 The Ebitengine Authors github.com/go-ole/go-ole,https://github.com/go-ole/go-ole,MIT,"Copyright (c) 2013-2017 Yasuhiro Matsumoto" +github.com/godbus/dbus/v5,https://github.com/godbus/dbus,BSD-2-Clause,"Copyright (c) 2013, Georg Reinke, Google" github.com/google/uuid,https://github.com/google/uuid,BSD-3-Clause,Copyright (c) 2018 Google Inc. github.com/inconshreveable/mousetrap,https://github.com/inconshreveable/mousetrap,Apache-2.0,Copyright 2014 Alan Shreve github.com/klauspost/compress,https://github.com/klauspost/compress,BSD-3-Clause,"Copyright (c) 2012 The Go Authors; Copyright (c) 2019 Klaus Post" diff --git a/README.md b/README.md index 2a627399..afd72bfc 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Every access path is default-deny: | Resource | Default | Opt-in | |----------------------|-------------------------------------|----------------------------------------------| | Command execution | All commands blocked (exit code 127)| `AllowedCommands` with namespaced command list (e.g. `rshell:cat`) | -| Systemd services | All services and actions blocked | `AllowedSystemServices` with exact service/action grants | +| Systemd units | All units and actions blocked | `AllowedSystemServices` with exact unit/action grants; `systemctl` also requires `ModeRemediation` | | External commands | Blocked (exit code 127) | Provide an `ExecHandler` | | Filesystem access | Blocked | Configure `AllowedPaths` with `PATH[:ro|:rw]` root specs | | Environment variables| Empty (no host env inherited) | Pass variables via the `Env` option | @@ -69,18 +69,31 @@ Every access path is default-deny: **AllowedCommands** restricts which commands (builtins or external) the interpreter may execute. Commands must be specified with the `rshell:` namespace prefix (e.g. `rshell:cat`, `rshell:echo`). If not set, no commands are allowed. -**AllowedSystemServices** is the single capability policy shared by systemd-aware builtins. Grants pair one exact service with generic actions (`read`, `clean`, `reload`, or `restart`), using `SERVICE:ACTION[+ACTION...]` syntax. Grants without actions are ignored; invalid services and unsupported actions are skipped with warnings. Service names are matched exactly without adding suffixes, resolving aliases, changing case, or otherwise normalizing them: `mysql` and `mysql.service` are different services. Empty service names, service names containing `:`, whitespace, path-like names, and glob patterns are also skipped with warnings. The policy defaults to denying every operation and remains enforced when all commands are allowed. `read` is available in read-only mode; mutating actions require remediation mode. +**AllowedSystemServices** is the single capability policy shared by systemd-aware builtins. Grants pair one exact unit with one or more actions (`read`, `clean`, `start`, `stop`, `reload`, `restart`, `reset-failed`, `enable`, or `disable`), using `UNIT:ACTION[+ACTION...]` syntax. The API retains its original "service" terminology, but grants may name any valid systemd unit type, including `.service`, `.timer`, and `.socket` units. Grants without actions are ignored; invalid unit names and unsupported actions are skipped with warnings. Unit names are matched exactly without adding suffixes, changing case, or otherwise normalizing them: `mysql` and `mysql.service` are different grants, and restricted `systemctl` operations require the full unit name with a standard unit suffix. Empty names and names containing `:`, whitespace, path separators, or glob patterns are also skipped with warnings. + +Rshell does not resolve aliases while matching policy. If an exact configured selector is itself a systemd alias, the public manager API may resolve it while performing the authorized operation. Output retains the requested, granted selector, and the resolved canonical unit ID does not become an additional grant or authorize later requests under that name. + +The policy defaults to denying every operation and remains enforced when all commands are allowed. The shared `read` action remains available in read-only mode for bounded `journalctl` queries. Within restricted `systemctl`, `read` is the visibility and inspection grant, but the entire `systemctl` builtin refuses to run unless the runner is in remediation mode; units without an exact `read` grant are never enumerated. Every other action is mutating and requires remediation mode as well as its exact grant. `clean` is currently reserved for the bounded `journalctl` rotation and vacuum operations; it does not enable the much broader host `systemctl clean` operation, which is unsupported. ```go interp.AllowedSystemServices([]interp.SystemdControlGrant{ { Service: "mysql.service", Actions: []interp.SystemServiceAction{ + interp.SystemServiceRead, + interp.SystemServiceStart, + interp.SystemServiceStop, interp.SystemServiceRestart, interp.SystemServiceReload, - interp.SystemServiceRead, + interp.SystemServiceResetFailed, + interp.SystemServiceEnable, + interp.SystemServiceDisable, }, }, + { + Service: "backup.timer", + Actions: []interp.SystemServiceAction{interp.SystemServiceRead, interp.SystemServiceStart}, + }, { Service: "systemd-journald.service", Actions: []interp.SystemServiceAction{interp.SystemServiceRead, interp.SystemServiceClean}, @@ -88,19 +101,43 @@ interp.AllowedSystemServices([]interp.SystemdControlGrant{ }) ``` -The development CLI accepts equivalent grants through `--allowed-services mysql.service:restart+reload+read,systemd-journald.service:read+clean`. Service selectors are always exact service names; `:` separates a selector from its actions and is not allowed inside service names. The restricted `journalctl` builtin is available as described below; `systemctl` is not yet implemented. +The development CLI accepts equivalent grants through `--allowed-services mysql.service:read+start+stop+reload+restart+reset-failed+enable+disable,backup.timer:read+start,systemd-journald.service:read+clean`. Unit selectors are always exact names; `:` separates a selector from its actions and is not allowed inside unit names. -**SystemdTargetConfig** selects which Linux host journal-aware builtins address. With no option, standard local paths are used. Container integrations can instead provide `JournalDirs`, `MachineIDPath`, and `JournalControlSocket` as direct absolute paths visible to the rshell process. Once any explicit field is supplied, omitted fields stay unavailable and never fall back to local endpoints. `MachineIDPath` is mandatory for every explicit target. The development CLI exposes the equivalent `--systemd-journal-dirs`, `--systemd-machine-id-path`, and `--systemd-journal-socket` flags. +**SystemdTargetConfig** selects which Linux host systemd-aware builtins address. With no option, standard local paths are used. Container integrations can instead provide `JournalDirs`, `MachineIDPath`, `JournalControlSocket`, and `ManagerBusSocket` as direct absolute paths visible to the rshell process. Once any explicit field is supplied, omitted fields stay unavailable and never fall back to local endpoints. `MachineIDPath` is mandatory for every explicit target. The development CLI exposes the equivalent `--systemd-journal-dirs`, `--systemd-machine-id-path`, `--systemd-journal-socket`, and `--systemd-manager-socket` flags. -The target paths intentionally bypass `AllowedPaths`: they are trusted runner configuration and cannot be supplied by shell scripts. The embedding application is responsible for mounting every supplied path from the same host. Every selected journal file header is checked against the configured machine ID, but journald's Rotate Varlink method does not return a machine ID that can independently attest its socket. +The target paths intentionally bypass `AllowedPaths`: they are trusted runner configuration and cannot be supplied by shell scripts. The embedding application is responsible for mounting every supplied path from the same host. Every selected journal file header is checked against the configured machine ID, but journald's Rotate Varlink method does not return a machine ID that can independently attest its socket. Restricted `systemctl` uses the public D-Bus system bus at `/run/dbus/system_bus_socket` by default; systemd's private `/run/systemd/private` socket is not a supported API. The Linux backend requires procfs descriptor links at `/proc/self/fd`, pins the configured bus socket without following a final symlink, authenticates to the bus, and verifies the systemd manager peer's machine ID against `MachineIDPath` before issuing any fixed manager-interface request. | Operation | Required target access | |-----------|------------------------| | Journal query, disk usage, or vacuum | `/etc/machine-id` and one or both of `/var/log/journal`, `/run/log/journal` | | Journal rotation | `/etc/machine-id` and `/run/systemd/journal/io.systemd.journal` | +| Unit state or control | `/etc/machine-id`, `/run/dbus/system_bus_socket`, and procfs descriptor links at `/proc/self/fd` | Explicit targets may map those host locations to arbitrary absolute container paths. A command fails closed when one of its required paths was omitted. +### Restricted systemctl + +`systemctl` is available only when the runner uses `interp.WithMode(interp.ModeRemediation)` (CLI: `--mode remediation`). This command-wide gate applies to every invocation, including bare `list-units`, `status`, `show`, the `is-*` predicates, and `--help`; no grant lookup or manager access occurs in read-only mode. Once enabled, `systemctl` provides bounded unit inspection and control without executing the host binary or exposing a generic D-Bus client. A bare invocation is the restricted equivalent of `list-units`. `--system` and `--no-pager` are accepted as no-op compatibility flags because only the configured system manager is available and rshell never starts a pager. + +| Operation | Supported options | Required exact grant | +|-----------|-------------------|----------------------| +| List configured visible units | `list-units`, `--all`, `--type TYPE[,TYPE...]`, `--state STATE[,STATE...]`, `--no-legend` | Only units with `UNIT:read` are considered | +| Human-readable bounded state | `status UNIT...` | `UNIT:read` for every operand | +| Fixed properties | `show [-p\|--property PROPERTY]... [--value] UNIT...` | `UNIT:read` for every operand | +| State predicates | `is-active\|is-failed\|is-enabled [--quiet] UNIT...` | `UNIT:read` for every operand | +| Runtime jobs | `start`, `stop`, `reload`, `restart`, or `try-restart` with `UNIT...` | Matching action; `try-restart` requires `restart` | +| Conditional reload/restart | `reload-or-restart` or `try-reload-or-restart` with `UNIT...` | Both `UNIT:reload` and `UNIT:restart` | +| Clear failed state | `reset-failed UNIT...` | `UNIT:reset-failed` | +| Unit-file state | `enable\|disable [--now] UNIT...` | Matching `enable`/`disable`; `--now` additionally requires `start`/`stop` | + +Remediation mode enables the builtin but does not bypass its listed exact grants. Compound requests authorize every required unit/action pair before the backend performs any operation. Runtime jobs use systemd's fixed `replace` job mode, wait synchronously for systemd to report completion, and remain subject to the runner's context and execution deadline; each manager-backend operation also has an unconditional 30-second cap. Multiple runtime-job operands are submitted and awaited sequentially in operand order rather than as one batched systemd transaction, so ordering-only relationships between directly named anchors can differ from newer host `systemctl` implementations. The exact grant authorizes the directly named anchor unit, but systemd may still start, stop, or reload dependency-related units as part of its normal transaction semantics. `enable` and `disable` follow systemd installation metadata, including `[Install] Alias=`, `Also=`, and template `DefaultInstance=`, so one authorized anchor may change auxiliary or instantiated unit-file state. After those changes the backend performs a global `Manager.Reload`; that reload re-reads the whole manager configuration, runs generators, and may pick up unrelated unit changes already present on the host. Rshell does not inspect or constrain the granted unit's configured payload, installation metadata, aliases, or dependency graph: operators must trust every granted anchor and must not grant lifecycle targets, services, or aliases when reboot, shutdown, sleep, rescue, or similar effects are forbidden. These manager-controlled indirect effects are part of why all `systemctl` access remains remediation-only. + +Each invocation accepts at most 32 exact unit operands; repeated names within that bound are coalesced. Names are capped at 255 bytes, must include a standard systemd unit suffix, and may identify any unit type; rshell does not add `.service`, expand globs, change case, or select a user manager, machine, root, or disk image. `list-units` sorts and queries only the configured `read` grants, so it cannot reveal other units on the target. Without `--all`, the backend considers only read-granted units already loaded by systemd and returns those that are active, failed, or carrying a job. With `--all`, it may load and inspect the full valid read-granted candidate set and includes inactive units; names that genuinely do not exist may still be omitted. + +`status` deliberately omits process command lines and journal output. `show` exposes only `Id`, `Description`, `LoadState`, `ActiveState`, `SubState`, `UnitFileState`, `MainPID`, `Result`, `ExecMainCode`, and `ExecMainStatus`; arbitrary properties and object paths are unavailable. `Result` is read from the fixed type-specific interface for services, sockets, mounts, automounts, timers, swaps, paths, and scopes. `MainPID`, `ExecMainCode`, and `ExecMainStatus` are service-only and remain zero for other unit types. Every returned string is capped at 64 KiB, and human-readable fields are sanitized before output. + +Unrestricted enumeration and host-changing operations outside the table are unavailable. This includes `list-unit-files`, `cat`, `start`/`stop` without exact operands, `clean`, standalone `daemon-reload`, `kill`, `set-property`, `edit`, `link`, `mask`, `preset`, power-management verbs, user/global/machine/root/image targeting, asynchronous `--no-block`, arbitrary job modes, and explicit dependency-expansion switches. As described above, `enable`/`disable` still perform their fixed implicit global manager reload. + ### Restricted journalctl `journalctl` provides bounded investigation and disk-cleanup operations without executing the host binary or accepting user-selected files, directories, journal matches, cursors, machines, or namespaces. @@ -138,7 +175,7 @@ Vacuum thresholds are provided by the `journalctl` command itself. The backend r **ProcPath** (Linux-only) overrides the proc filesystem root used by the `ps` builtin (default `/proc`). This is a privileged option set at runner construction time by trusted caller code — scripts cannot influence it. Access to the proc path is intentionally not subject to `AllowedPaths` restrictions. To avoid leaking secrets passed as CLI arguments, `ps` does not read `/proc//cmdline`; the `CMD` column reports only the process comm/executable name. -**RemediationMode** opts the runner into host-remediation mode, enabling file-target output redirections (`>`, `>>`, `2>`, `&>`, `&>>`) within `:rw` entries in the configured `AllowedPaths` and enabling remediation-only builtins such as `truncate` and `logrotate`. Targets outside the allowlist or inside read-only roots are rejected with `permission denied` (exit 1); symlinked write targets are rejected with `symlinks are not supported as write targets`; `/dev/null` is always accepted. `<>` (read-write open) remains blocked in all modes. CLI flag: `--mode remediation` (default: `--mode read-only`). The `logrotate` builtin is an rshell-safe log truncation helper, not a full `logrotate(8)` replacement. +**RemediationMode** opts the runner into host-remediation mode, enabling file-target output redirections (`>`, `>>`, `2>`, `&>`, `&>>`) within `:rw` entries in the configured `AllowedPaths` and enabling remediation-only builtins such as `truncate`, `logrotate`, and the entire restricted `systemctl` surface. Targets outside the allowlist or inside read-only roots are rejected with `permission denied` (exit 1); symlinked write targets are rejected with `symlinks are not supported as write targets`; `/dev/null` is always accepted. `<>` (read-write open) remains blocked in all modes. CLI flag: `--mode remediation` (default: `--mode read-only`). The `logrotate` builtin is an rshell-safe log truncation helper, not a full `logrotate(8)` replacement. ## Shell Features diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index dfa53fc5..10914654 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -28,6 +28,7 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c - ✅ `logrotate (-s SIZE|-f) [-n] [-v] FILE...` — remediation-mode helper that truncates log files to zero bytes through `AllowedPaths`; `--size SIZE` truncates only files at least SIZE bytes, `--force` explicitly truncates without a threshold, `--dry-run` reports without modifying files, and `--verbose` reports per-file actions. Symlinked write targets are rejected with `symlinks are not supported as write targets`; pass the real log path instead. This is not a full `logrotate(8)` replacement: no config parsing, rename-based rotation, retained copies, compression, state files, or rotate scripts. - ✅ `sort [-rnhubfds] [-k KEYDEF] [-t SEP] [-c|-C] [FILE]...` — sort lines of text files; `-h`/`--human-numeric-sort` orders by SI suffix (none < K/k < M < G < T < P < E < Z < Y < R < Q) then by numeric value (single-letter suffixes only — `Ki`, `Mi`, etc. are not recognised); `-o`, `--compress-program`, and `-T` are rejected (filesystem write / exec) - ✅ `ss [-tuaxlans4689Hoehs] [OPTION]...` — display network socket statistics; reads kernel socket state directly via `os.Open` (bypassing `AllowedPaths`) from: Linux: `/proc/net/`; macOS: sysctl; Windows: iphlpapi.dll; `-F`/`--filter` (GTFOBins file-read), `-p`/`--processes` (PID disclosure), `-K`/`--kill`, `-E`/`--events`, and `-N`/`--net` are rejected +- ✅ `systemctl [--system] [--no-pager] COMMAND [OPTION]... [UNIT]...` — remediation-mode-only bounded Linux system-manager inspection and control for exact, fully suffixed unit names; every invocation, including `--help`, bare/list inspection, and state predicates, fails before grant lookup or manager access in read-only mode; once enabled, a bare invocation is restricted `list-units`, which supports `--all`, `--type`, `--state`, and `--no-legend` but considers only units with exact `read` grants; by default it returns already-loaded units that are active, failed, or carrying a job, while `--all` may load valid read-granted candidates and includes inactive units (nonexistent names may be omitted); read-granted operations are `status`, fixed-property `show`, `is-active`, `is-failed`, and `is-enabled`; mutating operations are `start`, `stop`, `reload`, `restart`, `try-restart`, `reload-or-restart`, `try-reload-or-restart`, `reset-failed`, and `enable`/`disable` (optionally `--now`) with exact action grants; compound operations authorize every required action before any effect; runtime jobs use fixed `replace` mode, process multiple anchors sequentially in operand order, and may act on dependency-related units through normal systemd transaction semantics; enable/disable may follow `[Install] Alias=`, `Also=`, and template `DefaultInstance=` into auxiliary unit-file changes, then globally reload the manager, which may run generators and pick up unrelated host changes; granted unit payloads, install metadata, aliases, and dependency graphs are operator-trusted, so dedicated lifecycle verbs being absent does not make lifecycle effects impossible through a granted unit; every manager-backend operation has a fixed 30-second cap in addition to the runner deadline; accepts `.service`, `.timer`, `.socket`, and other valid unit types, with at most 32 operands and 64 KiB per returned field; `status` omits process command lines and logs, `show` has a fixed safe property allowlist with type-specific `Result` and service-only process fields, and every human-readable field is sanitized; arbitrary enumeration/properties, globs, implicit `.service`, user/machine/root/image targets, `clean`, standalone `daemon-reload`, `kill`, unit-file editing/linking/masking/presets, dedicated power-management verbs, asynchronous jobs, arbitrary job modes, and explicit dependency-expansion switches are unavailable; requires procfs descriptor links at `/proc/self/fd` and uses the configured public system D-Bus socket without executing host `systemctl` - ✅ `ls [-1aAdFhlpRrSt] [--offset N] [--limit N] [FILE]...` — list directory contents; `--offset`/`--limit` are non-standard pagination flags (single-directory only, silently ignored with `-R` or multiple arguments, capped at 1,000 entries per call); offset operates on filesystem order (not sorted order) for O(n) memory - ✅ `ping [-c N] [-W DURATION] [-i DURATION] [-q] [-4|-6] [-h] HOST` — send ICMP echo requests to a network host and report round-trip statistics; `-f` (flood), `-b` (broadcast), `-s` (packet size), `-I` (interface), `-p` (pattern), and `-R` (record route) are blocked; count/wait/interval are clamped to safe ranges with a warning; multicast, unspecified (`0.0.0.0`/`::`), and broadcast addresses (IPv4 last-octet `.255`) are rejected — note: directed broadcasts on non-standard subnets (e.g. `.127` on a `/25`) are not blocked without subnet-mask knowledge - ✅ `ps [-e|-A] [-f] [-p PIDLIST]` — report process status; default shows current-session processes; `-e`/`-A` shows all; `-f` adds UID/PPID/STIME columns; `-p` selects by PID list; `CMD` shows only the process comm/executable name, never argv @@ -122,12 +123,12 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c ## Execution - ✅ AllowedCommands — restricts which commands (builtins or external) may be executed; commands require the `rshell:` namespace prefix (e.g. `rshell:cat`); if not set, no commands are allowed -- ✅ AllowedSystemServices policy — one shared default-deny capability map for exact services with generic `read`, `clean`, `reload`, and `restart` actions; actionless grants are ignored, invalid services/actions are skipped, service names are case-sensitive, are not normalized, and cannot contain `:`, mutating actions require remediation mode, and allowing all commands does not bypass this policy; configure services through `interp.AllowedSystemServices` or CLI `--allowed-services SERVICE:ACTION[+ACTION...]` -- ✅ SystemdTargetConfig — journal-aware builtins use standard local paths by default; explicit `JournalDirs`, `MachineIDPath`, and `JournalControlSocket` paths support container mount layouts without falling back to local endpoints; target paths are trusted configuration and bypass `AllowedPaths`; explicit mounts must all refer to the same host +- ✅ AllowedSystemServices policy — one shared default-deny capability map for exact unit names with `read`, `clean`, `start`, `stop`, `reload`, `restart`, `reset-failed`, `enable`, and `disable` actions; actionless grants are ignored, invalid names/actions are skipped, names are case-sensitive, are not normalized, and cannot contain `:` or globs; all valid unit types are accepted; `read` remains usable by bounded `journalctl` queries in read-only mode, every non-read action requires remediation mode, and the entire `systemctl` builtin separately requires remediation mode while `list-units` sees only exact `read` grants; allowing all commands does not bypass this policy; configure grants through `interp.AllowedSystemServices` or CLI `--allowed-services UNIT:ACTION[+ACTION...]` +- ✅ SystemdTargetConfig — systemd-aware builtins use standard local paths by default; explicit `JournalDirs`, `MachineIDPath`, `JournalControlSocket`, and `ManagerBusSocket` paths support container mount layouts without falling back to local endpoints; target paths are trusted configuration and bypass `AllowedPaths`; explicit mounts must all refer to the same host; manager operations use the public system D-Bus socket (default `/run/dbus/system_bus_socket`), pin its inode, authenticate, and verify the systemd manager peer's machine ID, while the private `/run/systemd/private` endpoint is unsupported - ✅ AllowedPaths filesystem sandboxing — restricts all file access (read and write) to specified directories. Entries may end with `:ro` or `:rw` to indicate read-only and read-write permissions, respectively; entries without a suffix default to read-only. In remediation mode, write operations are accepted only inside the most-specific matching `:rw` root. Cross-root symlink fallback is read-only to avoid TOCTOU on writes; on Unix, symlink components in write targets are rejected with `symlinks are not supported as write targets` via a no-follow `openat` walk - ✅ Whole-run execution timeout — callers can bound a `Run()` call via `context.Context`, `interp.MaxExecutionTime`, or the CLI `--timeout` flag; the deadline applies to the entire script, not each individual command - ✅ ProcPath — overrides the proc filesystem path used by `ps` (default `/proc`; Linux-only; useful for testing/container environments); `ps` does not read `/proc//cmdline` -- ✅ RemediationMode — opt-in mode (`interp.WithMode(interp.ModeRemediation)` / `--mode remediation`) that enables file-target output redirections (`>`, `>>`, `2>`, `&>`, `&>>`) within `:rw` `AllowedPaths` and remediation-only builtins such as `truncate` and `logrotate`; targets outside the allowlist or inside read-only roots fail with `permission denied` (exit 1); symlinked write targets fail with `symlinks are not supported as write targets`; `/dev/null` always accepted; `<>` remains blocked +- ✅ RemediationMode — opt-in mode (`interp.WithMode(interp.ModeRemediation)` / `--mode remediation`) that enables file-target output redirections (`>`, `>>`, `2>`, `&>`, `&>>`) within `:rw` `AllowedPaths` and remediation-only builtins such as `truncate`, `logrotate`, and the entire restricted `systemctl` surface; targets outside the allowlist or inside read-only roots fail with `permission denied` (exit 1); symlinked write targets fail with `symlinks are not supported as write targets`; `/dev/null` always accepted; `<>` remains blocked - ❌ External commands — blocked by default; requires an ExecHandler to be configured and the binary to be within AllowedPaths - ❌ Background execution: `cmd &` - ❌ Coprocesses: `coproc` diff --git a/analysis/symbols_builtins.go b/analysis/symbols_builtins.go index 6f11d710..8a0144c3 100644 --- a/analysis/symbols_builtins.go +++ b/analysis/symbols_builtins.go @@ -212,6 +212,19 @@ var builtinPerCommandSymbols = map[string][]string{ // Note: builtins/internal/sizeparse symbols are exempt from this // allowlist (internal packages are checked separately). }, + "systemctl": { + "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. + "fmt.Errorf", // 🟢 constructs bounded validation and backend errors in memory; no I/O. + "slices.SortFunc", // 🟢 deterministically sorts authorized selectors and bounded state values; pure in-memory operation. + "strconv.FormatInt", // 🟢 formats fixed numeric status properties; pure conversion. + "strconv.FormatUint", // 🟢 formats fixed numeric status properties; pure conversion. + "strings.LastIndexByte", // 🟢 locates a unit's final suffix separator; pure string inspection. + "strings.Map", // 🟢 sanitizes untrusted manager text at the output boundary; pure string transformation. + "strings.SplitSeq", // 🟢 streams bounded filter/property tokens without allocating an attacker-sized slice. + "strings.ToValidUTF8", // 🟢 replaces malformed manager output before display; pure string transformation. + "unicode.IsGraphic", // 🟢 identifies non-graphic manager output that must be sanitized; pure lookup. + "unicode/utf8.ValidString", // 🟢 validates exact unit selectors before authorization or backend access; pure inspection. + }, "logrotate": { "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. "errors.Is", // 🟢 error comparison; pure function, no I/O. @@ -572,6 +585,7 @@ var callCtxAllFields = []string{ "ReadDir", "ReadDirLimited", "ReadlinkFile", + "ReadableSystemServices", "RunCommand", "RunCommandWithStdin", "SetVar", @@ -661,6 +675,11 @@ var builtinPerCommandCallContextFields = map[string][]string{ "AuthorizeSystemd", "Systemd", }, + "systemctl": { + "AuthorizeSystemd", + "ReadableSystemServices", + "Systemd", + }, "logrotate": { "PortableErr", "TruncateToZeroIfAtLeast", @@ -852,9 +871,13 @@ var builtinAllowedSymbols = []string{ "strings.HasSuffix", // 🟢 pure function for suffix matching; no I/O. "strings.IndexByte", // 🟢 finds byte in string; pure function, no I/O. "strings.Join", // 🟢 concatenates a slice of strings with a separator; pure function, no I/O. + "strings.LastIndexByte", // 🟢 finds the final byte occurrence in a string; pure function, no I/O. + "strings.Map", // 🟢 transforms runes in a string using a caller-supplied pure mapper; no I/O. "strings.ReplaceAll", // 🟢 replaces all occurrences of a substring; pure function, no I/O. "strings.Split", // 🟢 splits a string by separator into a slice; pure function, no I/O. + "strings.SplitSeq", // 🟢 iterates over string tokens without allocating a result slice; pure transformation. "strings.ToLower", // 🟢 converts string to lowercase; pure function, no I/O. + "strings.ToValidUTF8", // 🟢 replaces invalid UTF-8 byte sequences; pure function, no I/O. "strings.TrimPrefix", // 🟢 removes a leading prefix from a string; pure function, no I/O. "strings.TrimSpace", // 🟢 removes leading/trailing whitespace; pure function. "syscall.ByHandleFileInformation", // 🟢 Windows file info struct for extracting nlink; read-only type, no I/O. @@ -883,6 +906,7 @@ var builtinAllowedSymbols = []string{ "unicode.Co", // 🟢 private-use character category range table; pure data, no I/O. "unicode.Is", // 🟢 checks if rune belongs to a range table; pure function, no I/O. "unicode.IsGraphic", // 🟢 reports whether rune is defined as a graphic character; pure function, no I/O. + "unicode/utf8.ValidString", // 🟢 reports whether a string contains valid UTF-8; pure function, no I/O. "unicode.Me", // 🟢 enclosing mark category range table; pure data, no I/O. "unicode.Mn", // 🟢 nonspacing mark category range table; pure data, no I/O. "unicode.Range16", // 🟢 struct type for 16-bit Unicode ranges; pure data. diff --git a/analysis/symbols_interp.go b/analysis/symbols_interp.go index 98a460c5..35cb94be 100644 --- a/analysis/symbols_interp.go +++ b/analysis/symbols_interp.go @@ -62,6 +62,7 @@ var interpAllowedSymbols = []string{ "path/filepath.Join", // 🟢 joins path elements; pure function, no I/O. "path/filepath.ListSeparator", // 🟢 OS-specific path list separator; pure constant. "runtime.GOOS", // 🟢 current OS name constant; pure constant, no I/O. + "sort.Strings", // 🟢 sorts exact systemd grant selectors deterministically in memory; no I/O. "strconv.Itoa", // 🟢 int-to-string conversion; pure function, no I/O. "strings.Builder", // 🟢 efficient string concatenation; pure in-memory buffer, no I/O. "strings.ContainsRune", // 🟢 checks if a rune is in a string; pure function, no I/O. @@ -82,6 +83,7 @@ var interpAllowedSymbols = []string{ "time.Time", // 🟢 time value type; pure data, no side effects. "unicode.IsControl", // 🟢 reports whether a rune is a Unicode control character; pure function, no I/O. "unicode.IsSpace", // 🟢 reports whether a rune is a Unicode whitespace character; pure function, no I/O. + "unicode/utf8.ValidString", // 🟢 validates configured exact systemd selectors; pure string inspection. // --- github.com/DataDog/datadog-agent/pkg/fleet/installer/telemetry --- (lightweight span tracer used by the Agent Installer) @@ -213,6 +215,7 @@ var interpPerModeSymbols = map[string][]string{ "path/filepath.Join", // 🟢 joins path elements; pure function, no I/O. "path/filepath.ListSeparator", // 🟢 OS-specific path list separator; pure constant. "runtime.GOOS", // 🟢 current OS name constant; pure constant, no I/O. + "sort.Strings", // 🟢 sorts exact systemd grant selectors deterministically in memory; no I/O. "strconv.Itoa", // 🟢 int-to-string conversion; pure function, no I/O. "strings.Builder", // 🟢 efficient string concatenation; pure in-memory buffer, no I/O. "strings.ContainsRune", // 🟢 checks if a rune is in a string; pure function, no I/O. @@ -233,6 +236,7 @@ var interpPerModeSymbols = map[string][]string{ "time.Time", // 🟢 time value type; pure data, no side effects. "unicode.IsControl", // 🟢 reports whether a rune is a Unicode control character; pure function, no I/O. "unicode.IsSpace", // 🟢 reports whether a rune is a Unicode whitespace character; pure function, no I/O. + "unicode/utf8.ValidString", // 🟢 validates configured exact systemd selectors; pure string inspection. // --- github.com/DataDog/datadog-agent/pkg/fleet/installer/telemetry --- diff --git a/builtins/builtins.go b/builtins/builtins.go index 17cf053e..0e4964a9 100644 --- a/builtins/builtins.go +++ b/builtins/builtins.go @@ -58,14 +58,21 @@ type AllowedPath struct { } // SystemServiceAction identifies an operation that a builtin may perform on -// an explicitly configured systemd service. +// an explicitly configured systemd unit. The historical "Service" name is +// retained for API compatibility; grants may name any exact unit type, such +// as .service, .timer, or .socket. type SystemServiceAction string const ( - SystemServiceRead SystemServiceAction = "read" - SystemServiceClean SystemServiceAction = "clean" - SystemServiceReload SystemServiceAction = "reload" - SystemServiceRestart SystemServiceAction = "restart" + SystemServiceRead SystemServiceAction = "read" + SystemServiceClean SystemServiceAction = "clean" + SystemServiceStart SystemServiceAction = "start" + SystemServiceStop SystemServiceAction = "stop" + SystemServiceReload SystemServiceAction = "reload" + SystemServiceRestart SystemServiceAction = "restart" + SystemServiceResetFailed SystemServiceAction = "reset-failed" + SystemServiceEnable SystemServiceAction = "enable" + SystemServiceDisable SystemServiceAction = "disable" ) const ( @@ -74,7 +81,7 @@ const ( SystemdJournaldService = "systemd-journald.service" ) -// SystemdOperation is one service action that must be authorized before a +// SystemdOperation is one unit action that must be authorized before a // builtin interacts with systemd. type SystemdOperation struct { Service string @@ -280,6 +287,12 @@ type CallContext struct { // original service allowlist API. AuthorizeSystemServices func(action SystemServiceAction, services ...string) error + // ReadableSystemServices returns the exact, sorted unit selectors granted + // the read action. The returned slice is a defensive copy. Restricted + // enumeration commands use this capability instead of listing every unit on + // the configured systemd target. + ReadableSystemServices func() []string + // AllowedPathsList returns the resolved absolute paths and configured // access modes of the AllowedPaths sandbox roots. An empty/nil slice means // no allowed paths are configured, which blocks all filesystem access. diff --git a/builtins/systemctl/systemctl.go b/builtins/systemctl/systemctl.go new file mode 100644 index 00000000..45fb5df9 --- /dev/null +++ b/builtins/systemctl/systemctl.go @@ -0,0 +1,978 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Package systemctl implements a capability-bounded systemd unit manager. +// +// Unlike the host systemctl binary, this builtin is available only in +// remediation mode and accepts only exact unit names. Enumeration is restricted +// to units with an explicit read grant, inspection exposes a fixed set of +// fields, and every operation is authorized before the trusted systemd backend +// is called. +package systemctl + +import ( + "context" + "fmt" + "slices" + "strconv" + "strings" + "unicode" + "unicode/utf8" + + "github.com/DataDog/rshell/builtins" +) + +const ( + maxFilterValues = 32 + maxFilterValueBytes = 64 + maxPropertyValues = 32 +) + +var supportedUnitTypes = map[string]struct{}{ + "automount": {}, + "device": {}, + "mount": {}, + "path": {}, + "scope": {}, + "service": {}, + "slice": {}, + "socket": {}, + "swap": {}, + "target": {}, + "timer": {}, +} + +var showPropertyNames = []string{ + "Id", + "Description", + "LoadState", + "ActiveState", + "SubState", + "UnitFileState", + "MainPID", + "Result", + "ExecMainCode", + "ExecMainStatus", +} + +var showPropertySet = func() map[string]struct{} { + properties := make(map[string]struct{}, len(showPropertyNames)) + for _, property := range showPropertyNames { + properties[property] = struct{}{} + } + return properties +}() + +// Cmd is the systemctl builtin command descriptor. +var Cmd = builtins.Command{ + Name: "systemctl", + Description: "inspect and control explicitly authorized systemd units", + MakeFlags: makeFlags, + RemediationOnly: true, +} + +type flags struct { + all *bool + unitTypes *[]string + states *[]string + noLegend *bool + properties *[]string + value *bool + quiet *bool + now *bool + help *bool +} + +func makeFlags(fs *builtins.FlagSet) builtins.HandlerFunc { + _ = fs.Bool("system", false, "operate on the configured system manager (accepted for compatibility)") + _ = fs.Bool("no-pager", false, "do not invoke a pager (accepted for compatibility)") + options := flags{ + all: fs.BoolP("all", "a", false, "include inactive read-authorized units in list-units"), + unitTypes: fs.StringArrayP("type", "t", nil, "list only unit TYPE (repeatable or comma-separated)"), + states: fs.StringArray("state", nil, "list only unit STATE (repeatable or comma-separated)"), + noLegend: fs.Bool("no-legend", false, "omit the list-units header and restriction summary"), + properties: fs.StringArrayP("property", "p", nil, "show only fixed PROPERTY (repeatable or comma-separated)"), + value: fs.Bool("value", false, "with show, print property values only"), + quiet: fs.BoolP("quiet", "q", false, "suppress is-* state output"), + now: fs.Bool("now", false, "start after enable or stop after disable"), + help: fs.BoolP("help", "h", false, "print usage and exit"), + } + return options.run(fs) +} + +func (options flags) run(fs *builtins.FlagSet) builtins.HandlerFunc { + return func(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + // The entire systemctl surface is a host-remediation capability. Keep + // inspection, help, and state predicates behind the same mode boundary + // as mutations, before consulting grants or touching the manager. + if !callCtx.RemediationMode { + callCtx.Errf("systemctl: remediation mode required\n") + return builtins.Result{Code: 1} + } + if *options.help { + printHelp(callCtx, fs) + return builtins.Result{} + } + + verb := "list-units" + operands := args + if len(args) > 0 { + verb = args[0] + operands = args[1:] + } + + switch verb { + case "list-units": + if result, ok := rejectFlags(callCtx, fs, verb, "all", "type", "state", "no-legend"); !ok { + return result + } + return options.runList(ctx, callCtx, operands) + case "status": + if result, ok := rejectFlags(callCtx, fs, verb); !ok { + return result + } + return runStatus(ctx, callCtx, operands) + case "show": + if result, ok := rejectFlags(callCtx, fs, verb, "property", "value"); !ok { + return result + } + return options.runShow(ctx, callCtx, operands) + case "is-active", "is-failed": + if result, ok := rejectFlags(callCtx, fs, verb, "quiet"); !ok { + return result + } + return options.runIsState(ctx, callCtx, verb, operands) + case "is-enabled": + if result, ok := rejectFlags(callCtx, fs, verb, "quiet"); !ok { + return result + } + return options.runIsEnabled(ctx, callCtx, operands) + case "start": + return runJobWithFlags(ctx, callCtx, fs, verb, operands, builtins.SystemServiceJobStart, builtins.SystemServiceStart) + case "stop": + return runJobWithFlags(ctx, callCtx, fs, verb, operands, builtins.SystemServiceJobStop, builtins.SystemServiceStop) + case "reload": + return runJobWithFlags(ctx, callCtx, fs, verb, operands, builtins.SystemServiceJobReload, builtins.SystemServiceReload) + case "restart": + return runJobWithFlags(ctx, callCtx, fs, verb, operands, builtins.SystemServiceJobRestart, builtins.SystemServiceRestart) + case "try-restart": + return runJobWithFlags(ctx, callCtx, fs, verb, operands, builtins.SystemServiceJobTryRestart, builtins.SystemServiceRestart) + case "reload-or-restart": + return runJobWithFlags(ctx, callCtx, fs, verb, operands, builtins.SystemServiceJobReloadOrRestart, builtins.SystemServiceReload, builtins.SystemServiceRestart) + case "try-reload-or-restart": + return runJobWithFlags(ctx, callCtx, fs, verb, operands, builtins.SystemServiceJobTryReloadOrRestart, builtins.SystemServiceReload, builtins.SystemServiceRestart) + case "reset-failed": + if result, ok := rejectFlags(callCtx, fs, verb); !ok { + return result + } + return runResetFailed(ctx, callCtx, operands) + case "enable", "disable": + if result, ok := rejectFlags(callCtx, fs, verb, "now"); !ok { + return result + } + return options.runEnableDisable(ctx, callCtx, verb, operands) + default: + callCtx.Errf("systemctl: unsupported command %q\n", safeText(verb)) + callCtx.Errf("Try 'systemctl --help' for more information.\n") + return builtins.Result{Code: 1} + } + } +} + +func printHelp(callCtx *builtins.CallContext, fs *builtins.FlagSet) { + callCtx.Out("Usage: systemctl [OPTION]... COMMAND [UNIT]...\n") + callCtx.Out("Inspect and control exact systemd units through bounded capabilities.\n") + callCtx.Out("The entire command is available only in remediation mode.\n") + callCtx.Out("Bare systemctl is equivalent to list-units. list-units can see only\n") + callCtx.Out("the exact units granted read access; it never enumerates the whole host.\n\n") + callCtx.Out("Commands:\n") + callCtx.Out(" list-units List read-authorized units\n") + callCtx.Out(" status UNIT... Show bounded unit status without logs\n") + callCtx.Out(" show UNIT... Show a fixed safe property set\n") + callCtx.Out(" is-active UNIT... Test whether any unit is active\n") + callCtx.Out(" is-failed UNIT... Test whether any unit is failed\n") + callCtx.Out(" is-enabled UNIT... Test whether any unit is enabled\n") + callCtx.Out(" start|stop|reload UNIT... Queue and wait for an authorized job\n") + callCtx.Out(" restart|try-restart UNIT...\n") + callCtx.Out(" reload-or-restart|try-reload-or-restart UNIT...\n") + callCtx.Out(" reset-failed UNIT... Clear failed state\n") + callCtx.Out(" enable|disable UNIT... Change unit-file state; supports --now\n\n") + callCtx.Out("Systemd dependencies and install metadata may affect additional units.\n") + callCtx.Out("enable/disable also reload the whole configured manager.\n\n") + callCtx.Out("Options are accepted only by the commands described in their help text:\n") + fs.SetOutput(callCtx.Stdout) + fs.PrintDefaults() +} + +func rejectFlags(callCtx *builtins.CallContext, fs *builtins.FlagSet, verb string, allowed ...string) (builtins.Result, bool) { + allowedSet := make(map[string]struct{}, len(allowed)+3) + allowedSet["help"] = struct{}{} + allowedSet["system"] = struct{}{} + allowedSet["no-pager"] = struct{}{} + for _, name := range allowed { + allowedSet[name] = struct{}{} + } + + var rejected string + fs.Visit(func(flag *builtins.Flag) { + if rejected != "" { + return + } + if _, ok := allowedSet[flag.Name]; !ok { + rejected = flag.Name + } + }) + if rejected == "" { + return builtins.Result{}, true + } + callCtx.Errf("systemctl: --%s is not supported with %s\n", rejected, verb) + return builtins.Result{Code: 1}, false +} + +func (options flags) runList(ctx context.Context, callCtx *builtins.CallContext, operands []string) builtins.Result { + if len(operands) != 0 { + callCtx.Errf("systemctl: list-units does not accept unit operands\n") + return builtins.Result{Code: 1} + } + unitTypes, err := parseFilterValues(*options.unitTypes, "type", true) + if err != nil { + return commandError(callCtx, err) + } + states, err := parseFilterValues(*options.states, "state", false) + if err != nil { + return commandError(callCtx, err) + } + if callCtx.ReadableSystemServices == nil { + return commandError(callCtx, fmt.Errorf("readable systemd unit capability is not available")) + } + units := readableSystemctlUnits(callCtx.ReadableSystemServices(), unitTypes) + if len(units) > builtins.MaxSystemServiceOperands { + return commandError(callCtx, fmt.Errorf("too many readable units (maximum %d)", builtins.MaxSystemServiceOperands)) + } + slices.SortFunc(units, func(left, right string) int { + if left < right { + return -1 + } + if left > right { + return 1 + } + return 0 + }) + if len(units) == 0 { + writeUnitList(callCtx, nil, *options.noLegend) + return builtins.Result{} + } + if result := authorize(callCtx, units, builtins.SystemServiceRead); result.Code != 0 { + return result + } + if callCtx.Systemd == nil || callCtx.Systemd.ServiceState == nil { + return commandError(callCtx, fmt.Errorf("systemd unit state capability is not available")) + } + + listed, err := callCtx.Systemd.ServiceState.ListSystemServices(ctx, builtins.SystemServiceListRequest{ + Services: append([]string(nil), units...), + IncludeInactive: *options.all, + }) + if err != nil { + return backendError(ctx, callCtx, err) + } + listed, err = validateListedStates(units, listed) + if err != nil { + return commandError(callCtx, err) + } + listed = filterStates(listed, unitTypes, states) + writeUnitList(callCtx, listed, *options.noLegend) + return builtins.Result{} +} + +func runStatus(ctx context.Context, callCtx *builtins.CallContext, operands []string) builtins.Result { + units, err := validateUnits(operands, false) + if err != nil { + return commandError(callCtx, err) + } + states, result := inspectAuthorized(ctx, callCtx, units) + if result.Code != 0 { + return result + } + + code := uint8(0) + for i, state := range states { + if i > 0 { + callCtx.Out("\n") + } + if state.Description == "" { + callCtx.Outf("%s\n", state.Name) + } else { + callCtx.Outf("%s - %s\n", state.Name, state.Description) + } + callCtx.Outf(" Loaded: %s", displayToken(state.LoadState)) + if state.UnitFileState != "" { + callCtx.Outf(" (%s)", state.UnitFileState) + } + callCtx.Out("\n") + callCtx.Outf(" Active: %s", displayToken(state.ActiveState)) + if state.SubState != "" { + callCtx.Outf(" (%s)", state.SubState) + } + callCtx.Out("\n") + if state.MainPID != 0 { + callCtx.Outf(" Main PID: %d\n", state.MainPID) + } + if state.Result != "" { + callCtx.Outf(" Result: %s\n", state.Result) + } + + if state.LoadState == "not-found" { + code = 4 + } else if code < 3 && !activeStateSuccess(state.ActiveState) { + code = 3 + } + } + return builtins.Result{Code: code} +} + +func (options flags) runShow(ctx context.Context, callCtx *builtins.CallContext, operands []string) builtins.Result { + properties, err := parseProperties(*options.properties) + if err != nil { + return commandError(callCtx, err) + } + units, err := validateUnits(operands, false) + if err != nil { + return commandError(callCtx, err) + } + states, result := inspectAuthorized(ctx, callCtx, units) + if result.Code != 0 { + return result + } + + for i, state := range states { + if i > 0 { + callCtx.Out("\n") + } + for _, property := range properties { + value := propertyValue(state, property) + if *options.value { + callCtx.Outf("%s\n", value) + } else { + callCtx.Outf("%s=%s\n", property, value) + } + } + } + return builtins.Result{} +} + +func (options flags) runIsState(ctx context.Context, callCtx *builtins.CallContext, verb string, operands []string) builtins.Result { + units, err := validateUnits(operands, false) + if err != nil { + return commandError(callCtx, err) + } + states, result := inspectAuthorized(ctx, callCtx, units) + if result.Code != 0 { + return result + } + + success := false + allNotFound := true + for _, state := range states { + if !*options.quiet { + callCtx.Outf("%s\n", displayToken(state.ActiveState)) + } + if verb == "is-active" && activeStateSuccess(state.ActiveState) { + success = true + } + if verb == "is-failed" && state.ActiveState == "failed" { + success = true + } + if state.LoadState != "not-found" { + allNotFound = false + } + } + if success { + return builtins.Result{} + } + if allNotFound { + return builtins.Result{Code: 4} + } + if verb == "is-active" { + return builtins.Result{Code: 3} + } + return builtins.Result{Code: 1} +} + +func (options flags) runIsEnabled(ctx context.Context, callCtx *builtins.CallContext, operands []string) builtins.Result { + units, err := validateUnits(operands, false) + if err != nil { + return commandError(callCtx, err) + } + if result := authorize(callCtx, units, builtins.SystemServiceRead); result.Code != 0 { + return result + } + if callCtx.Systemd == nil || callCtx.Systemd.ServiceState == nil { + return commandError(callCtx, fmt.Errorf("systemd unit state capability is not available")) + } + states, err := callCtx.Systemd.ServiceState.SystemServiceEnabledState(ctx, append([]string(nil), units...)) + if err != nil { + return backendError(ctx, callCtx, err) + } + if len(states) != len(units) { + return commandError(callCtx, fmt.Errorf("systemd manager returned %d enabled states for %d units", len(states), len(units))) + } + + success := false + allNotFound := true + for _, state := range states { + if err := validateStateToken("unit file state", state); err != nil { + return commandError(callCtx, err) + } + if !*options.quiet { + callCtx.Outf("%s\n", displayToken(state)) + } + if enabledStateSuccess(state) { + success = true + } + if state != "not-found" { + allNotFound = false + } + } + if success { + return builtins.Result{} + } + if allNotFound { + return builtins.Result{Code: 4} + } + return builtins.Result{Code: 1} +} + +func runJobWithFlags(ctx context.Context, callCtx *builtins.CallContext, fs *builtins.FlagSet, verb string, operands []string, job builtins.SystemServiceJobAction, actions ...builtins.SystemServiceAction) builtins.Result { + if result, ok := rejectFlags(callCtx, fs, verb); !ok { + return result + } + units, err := validateUnits(operands, false) + if err != nil { + return commandError(callCtx, err) + } + if result := authorize(callCtx, units, actions...); result.Code != 0 { + return result + } + if callCtx.Systemd == nil || callCtx.Systemd.ServiceControl == nil { + return commandError(callCtx, fmt.Errorf("systemd unit control capability is not available")) + } + if err := callCtx.Systemd.ServiceControl.RunSystemServiceJobs(ctx, job, append([]string(nil), units...)); err != nil { + return backendError(ctx, callCtx, err) + } + return builtins.Result{} +} + +func runResetFailed(ctx context.Context, callCtx *builtins.CallContext, operands []string) builtins.Result { + units, err := validateUnits(operands, false) + if err != nil { + return commandError(callCtx, err) + } + if result := authorize(callCtx, units, builtins.SystemServiceResetFailed); result.Code != 0 { + return result + } + if callCtx.Systemd == nil || callCtx.Systemd.ServiceControl == nil { + return commandError(callCtx, fmt.Errorf("systemd unit control capability is not available")) + } + if err := callCtx.Systemd.ServiceControl.ResetFailedSystemServices(ctx, append([]string(nil), units...)); err != nil { + return backendError(ctx, callCtx, err) + } + return builtins.Result{} +} + +func (options flags) runEnableDisable(ctx context.Context, callCtx *builtins.CallContext, verb string, operands []string) builtins.Result { + units, err := validateUnits(operands, false) + if err != nil { + return commandError(callCtx, err) + } + if *options.now { + for _, unit := range units { + if isTemplateUnit(unit) { + return commandError(callCtx, fmt.Errorf("--now is not supported for template unit %q; use an exact instance", unit)) + } + } + } + actions := []builtins.SystemServiceAction{builtins.SystemServiceEnable} + nowAction := builtins.SystemServiceStart + nowJob := builtins.SystemServiceJobStart + if verb == "disable" { + actions[0] = builtins.SystemServiceDisable + nowAction = builtins.SystemServiceStop + nowJob = builtins.SystemServiceJobStop + } + if *options.now { + actions = append(actions, nowAction) + } + if result := authorize(callCtx, units, actions...); result.Code != 0 { + return result + } + if callCtx.Systemd == nil || callCtx.Systemd.ServiceControl == nil { + return commandError(callCtx, fmt.Errorf("systemd unit control capability is not available")) + } + controller := callCtx.Systemd.ServiceControl + if verb == "enable" { + err = controller.EnableSystemServices(ctx, append([]string(nil), units...)) + } else { + err = controller.DisableSystemServices(ctx, append([]string(nil), units...)) + } + if err != nil { + return backendError(ctx, callCtx, err) + } + if *options.now { + if err := controller.RunSystemServiceJobs(ctx, nowJob, append([]string(nil), units...)); err != nil { + return backendError(ctx, callCtx, fmt.Errorf("%s completed, but the --now %s job failed; unit-file changes were not rolled back: %w", verb, nowJob, err)) + } + } + return builtins.Result{} +} + +func inspectAuthorized(ctx context.Context, callCtx *builtins.CallContext, units []string) ([]builtins.SystemServiceState, builtins.Result) { + if result := authorize(callCtx, units, builtins.SystemServiceRead); result.Code != 0 { + return nil, result + } + if callCtx.Systemd == nil || callCtx.Systemd.ServiceState == nil { + return nil, commandError(callCtx, fmt.Errorf("systemd unit state capability is not available")) + } + states, err := callCtx.Systemd.ServiceState.InspectSystemServices(ctx, append([]string(nil), units...)) + if err != nil { + return nil, backendError(ctx, callCtx, err) + } + states, err = validateInspectedStates(units, states) + if err != nil { + return nil, commandError(callCtx, err) + } + return states, builtins.Result{} +} + +func authorize(callCtx *builtins.CallContext, units []string, actions ...builtins.SystemServiceAction) builtins.Result { + if callCtx.AuthorizeSystemd == nil { + return commandError(callCtx, fmt.Errorf("systemd authorization capability is not available")) + } + operations := make([]builtins.SystemdOperation, 0, len(units)*len(actions)) + for _, unit := range units { + for _, action := range actions { + operations = append(operations, builtins.SystemdOperation{Service: unit, Action: action}) + } + } + if err := callCtx.AuthorizeSystemd(operations...); err != nil { + return commandError(callCtx, err) + } + return builtins.Result{} +} + +func validateUnits(raw []string, allowEmpty bool) ([]string, error) { + if len(raw) == 0 && !allowEmpty { + return nil, fmt.Errorf("at least one exact unit is required") + } + if len(raw) > builtins.MaxSystemServiceOperands { + return nil, fmt.Errorf("too many unit operands (maximum %d)", builtins.MaxSystemServiceOperands) + } + units := make([]string, 0, len(raw)) + seen := make(map[string]struct{}, len(raw)) + for _, unit := range raw { + if err := validateUnitName(unit); err != nil { + return nil, err + } + if _, exists := seen[unit]; exists { + continue + } + seen[unit] = struct{}{} + units = append(units, unit) + } + return units, nil +} + +func readableSystemctlUnits(raw []string, unitTypes map[string]struct{}) []string { + capacity := len(raw) + if capacity > builtins.MaxSystemServiceOperands+1 { + capacity = builtins.MaxSystemServiceOperands + 1 + } + units := make([]string, 0, capacity) + seen := make(map[string]struct{}, capacity) + for _, unit := range raw { + // The shared policy also serves journalctl, whose exact selectors may be + // legacy service names without a unit suffix. They remain valid grants but + // are not candidates for systemctl enumeration. + if validateUnitName(unit) != nil { + continue + } + if len(unitTypes) > 0 { + if _, ok := unitTypes[unitTypeOf(unit)]; !ok { + continue + } + } + if _, exists := seen[unit]; exists { + continue + } + seen[unit] = struct{}{} + units = append(units, unit) + if len(units) > builtins.MaxSystemServiceOperands { + return units + } + } + return units +} + +func validateUnitName(unit string) error { + if unit == "" { + return fmt.Errorf("unit name must not be empty") + } + if len(unit) > builtins.MaxSystemServiceNameBytes { + return fmt.Errorf("unit name %q exceeds %d bytes", safeText(unit), builtins.MaxSystemServiceNameBytes) + } + if !utf8.ValidString(unit) { + return fmt.Errorf("unit name contains invalid UTF-8") + } + dot := strings.LastIndexByte(unit, '.') + if dot <= 0 || dot == len(unit)-1 { + return fmt.Errorf("invalid exact unit name %q", safeText(unit)) + } + unitType := unit[dot+1:] + if _, ok := supportedUnitTypes[unitType]; !ok { + return fmt.Errorf("unsupported unit type %q in %q", safeText(unitType), safeText(unit)) + } + base := unit[:dot] + atCount := 0 + for i := 0; i < len(base); i++ { + char := base[i] + if char == '@' { + atCount++ + if i == 0 || atCount > 1 { + return fmt.Errorf("invalid exact unit name %q", safeText(unit)) + } + continue + } + if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' { + continue + } + return fmt.Errorf("invalid character in exact unit name %q", safeText(unit)) + } + return nil +} + +func validateCanonicalUnitName(unit string) error { + if unit == "" || len(unit) > builtins.MaxSystemServiceNameBytes || !utf8.ValidString(unit) { + return fmt.Errorf("invalid canonical unit name") + } + dot := strings.LastIndexByte(unit, '.') + if dot <= 0 || dot == len(unit)-1 { + return fmt.Errorf("invalid canonical unit name %q", safeText(unit)) + } + if _, ok := supportedUnitTypes[unit[dot+1:]]; !ok { + return fmt.Errorf("unsupported canonical unit type in %q", safeText(unit)) + } + + base := unit[:dot] + atCount := 0 + for i := 0; i < len(base); i++ { + char := base[i] + if char == '\\' { + if i+3 >= len(base) || base[i+1] != 'x' || !isHex(base[i+2]) || !isHex(base[i+3]) { + return fmt.Errorf("invalid escape in canonical unit name %q", safeText(unit)) + } + i += 3 + continue + } + if char == '@' { + atCount++ + if i == 0 || atCount > 1 { + return fmt.Errorf("invalid canonical unit name %q", safeText(unit)) + } + continue + } + if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' || char == ':' { + continue + } + return fmt.Errorf("invalid character in canonical unit name %q", safeText(unit)) + } + return nil +} + +func isHex(char byte) bool { + return (char >= '0' && char <= '9') || (char >= 'a' && char <= 'f') || (char >= 'A' && char <= 'F') +} + +func parseFilterValues(raw []string, name string, unitType bool) (map[string]struct{}, error) { + if len(raw) > maxFilterValues { + return nil, fmt.Errorf("too many --%s values (maximum %d)", name, maxFilterValues) + } + values := make(map[string]struct{}) + total := 0 + for _, item := range raw { + for value := range strings.SplitSeq(item, ",") { + total++ + if total > maxFilterValues { + return nil, fmt.Errorf("too many --%s values (maximum %d)", name, maxFilterValues) + } + if value == "" || len(value) > maxFilterValueBytes || !validStateToken(value) { + return nil, fmt.Errorf("invalid --%s value %q", name, safeText(value)) + } + if unitType { + if _, ok := supportedUnitTypes[value]; !ok { + return nil, fmt.Errorf("unsupported --type value %q", safeText(value)) + } + } + values[value] = struct{}{} + } + } + return values, nil +} + +func parseProperties(raw []string) ([]string, error) { + if len(raw) == 0 { + return append([]string(nil), showPropertyNames...), nil + } + if len(raw) > maxPropertyValues { + return nil, fmt.Errorf("too many properties (maximum %d)", maxPropertyValues) + } + properties := make([]string, 0, len(showPropertyNames)) + seen := make(map[string]struct{}, len(showPropertyNames)) + total := 0 + for _, item := range raw { + for property := range strings.SplitSeq(item, ",") { + total++ + if total > maxPropertyValues { + return nil, fmt.Errorf("too many properties (maximum %d)", maxPropertyValues) + } + if _, ok := showPropertySet[property]; !ok { + return nil, fmt.Errorf("unsupported property %q", safeText(property)) + } + if _, exists := seen[property]; exists { + continue + } + seen[property] = struct{}{} + properties = append(properties, property) + } + } + return properties, nil +} + +func validateListedStates(units []string, states []builtins.SystemServiceState) ([]builtins.SystemServiceState, error) { + allowed := make(map[string]struct{}, len(units)) + for _, unit := range units { + allowed[unit] = struct{}{} + } + seen := make(map[string]struct{}, len(states)) + validated := make([]builtins.SystemServiceState, 0, len(states)) + for _, state := range states { + if _, ok := allowed[state.Name]; !ok { + return nil, fmt.Errorf("systemd manager returned unauthorized unit %q", safeText(state.Name)) + } + if _, exists := seen[state.Name]; exists { + return nil, fmt.Errorf("systemd manager returned duplicate unit %q", safeText(state.Name)) + } + seen[state.Name] = struct{}{} + state, err := validateBackendState(state) + if err != nil { + return nil, err + } + validated = append(validated, state) + } + slices.SortFunc(validated, func(left, right builtins.SystemServiceState) int { + if left.Name < right.Name { + return -1 + } + if left.Name > right.Name { + return 1 + } + return 0 + }) + return validated, nil +} + +func validateInspectedStates(units []string, states []builtins.SystemServiceState) ([]builtins.SystemServiceState, error) { + if len(states) != len(units) { + return nil, fmt.Errorf("systemd manager returned %d states for %d units", len(states), len(units)) + } + validated := make([]builtins.SystemServiceState, len(states)) + for i, state := range states { + if state.Name != units[i] { + return nil, fmt.Errorf("systemd manager returned unit %q for requested unit %q", safeText(state.Name), units[i]) + } + var err error + validated[i], err = validateBackendState(state) + if err != nil { + return nil, err + } + } + return validated, nil +} + +func validateBackendState(state builtins.SystemServiceState) (builtins.SystemServiceState, error) { + if err := validateUnitName(state.Name); err != nil { + return state, fmt.Errorf("systemd manager returned invalid unit selector: %w", err) + } + if state.CanonicalName != "" { + if err := validateCanonicalUnitName(state.CanonicalName); err != nil { + return state, fmt.Errorf("systemd manager returned invalid canonical unit: %w", err) + } + } + fields := []struct { + name string + value *string + token bool + }{ + {name: "description", value: &state.Description}, + {name: "load state", value: &state.LoadState, token: true}, + {name: "active state", value: &state.ActiveState, token: true}, + {name: "sub state", value: &state.SubState, token: true}, + {name: "unit file state", value: &state.UnitFileState, token: true}, + {name: "result", value: &state.Result, token: true}, + } + for _, field := range fields { + if len(*field.value) > builtins.MaxSystemServiceFieldBytes { + return state, fmt.Errorf("systemd manager %s exceeds %d bytes", field.name, builtins.MaxSystemServiceFieldBytes) + } + if field.token { + if err := validateStateToken(field.name, *field.value); err != nil { + return state, err + } + } else { + *field.value = safeText(*field.value) + } + } + return state, nil +} + +func validateStateToken(name, value string) error { + if len(value) > builtins.MaxSystemServiceFieldBytes { + return fmt.Errorf("systemd manager %s exceeds %d bytes", name, builtins.MaxSystemServiceFieldBytes) + } + if value != "" && !validStateToken(value) { + return fmt.Errorf("systemd manager returned invalid %s %q", name, safeText(value)) + } + return nil +} + +func validStateToken(value string) bool { + for i := 0; i < len(value); i++ { + char := value[i] + if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' { + continue + } + return false + } + return true +} + +func filterStates(states []builtins.SystemServiceState, unitTypes, wantedStates map[string]struct{}) []builtins.SystemServiceState { + filtered := make([]builtins.SystemServiceState, 0, len(states)) + for _, state := range states { + if len(unitTypes) > 0 { + if _, ok := unitTypes[unitTypeOf(state.Name)]; !ok { + continue + } + } + if len(wantedStates) > 0 { + _, loadMatch := wantedStates[state.LoadState] + _, activeMatch := wantedStates[state.ActiveState] + _, subMatch := wantedStates[state.SubState] + if !loadMatch && !activeMatch && !subMatch { + continue + } + } + filtered = append(filtered, state) + } + return filtered +} + +func writeUnitList(callCtx *builtins.CallContext, states []builtins.SystemServiceState, noLegend bool) { + if !noLegend { + callCtx.Out("UNIT LOAD ACTIVE SUB DESCRIPTION\n") + } + for _, state := range states { + callCtx.Outf("%s %s %s %s %s\n", + state.Name, + displayToken(state.LoadState), + displayToken(state.ActiveState), + displayToken(state.SubState), + state.Description, + ) + } + if !noLegend { + callCtx.Outf("%d units listed (restricted to units granted read access).\n", len(states)) + } +} + +func propertyValue(state builtins.SystemServiceState, property string) string { + switch property { + case "Id": + if state.CanonicalName != "" { + return state.CanonicalName + } + return state.Name + case "Description": + return state.Description + case "LoadState": + return state.LoadState + case "ActiveState": + return state.ActiveState + case "SubState": + return state.SubState + case "UnitFileState": + return state.UnitFileState + case "MainPID": + return strconv.FormatUint(uint64(state.MainPID), 10) + case "Result": + return state.Result + case "ExecMainCode": + return strconv.FormatInt(int64(state.ExecMainCode), 10) + case "ExecMainStatus": + return strconv.FormatInt(int64(state.ExecMainStatus), 10) + default: + return "" + } +} + +func unitTypeOf(unit string) string { + dot := strings.LastIndexByte(unit, '.') + if dot < 0 || dot == len(unit)-1 { + return "" + } + return unit[dot+1:] +} + +func isTemplateUnit(unit string) bool { + dot := strings.LastIndexByte(unit, '.') + return dot > 0 && unit[dot-1] == '@' +} + +func activeStateSuccess(state string) bool { + return state == "active" || state == "reloading" || state == "refreshing" +} + +func enabledStateSuccess(state string) bool { + return state == "enabled" || state == "enabled-runtime" || state == "static" || state == "alias" || state == "indirect" || state == "generated" +} + +func displayToken(value string) string { + if value == "" { + return "-" + } + return value +} + +func backendError(_ context.Context, callCtx *builtins.CallContext, err error) builtins.Result { + // Manager mutations can complete, partially complete, or remain in flight + // after the caller's context is canceled. Always preserve the backend's + // outcome warning; the runner may additionally surface its generic timeout. + callCtx.Errf("systemctl: %s\n", safeText(err.Error())) + return builtins.Result{Code: 1} +} + +func commandError(callCtx *builtins.CallContext, err error) builtins.Result { + callCtx.Errf("systemctl: %s\n", safeText(err.Error())) + return builtins.Result{Code: 1} +} + +func safeText(value string) string { + value = strings.ToValidUTF8(value, "?") + return strings.Map(func(r rune) rune { + if r == ' ' || unicode.IsGraphic(r) { + return r + } + return '?' + }, value) +} diff --git a/builtins/systemctl/systemctl_test.go b/builtins/systemctl/systemctl_test.go new file mode 100644 index 00000000..bdaa5e60 --- /dev/null +++ b/builtins/systemctl/systemctl_test.go @@ -0,0 +1,662 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package systemctl + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/builtins" +) + +type fakeStateReader struct { + listRequest builtins.SystemServiceListRequest + listStates []builtins.SystemServiceState + listErr error + listCalls int + inspectUnits []string + inspectState []builtins.SystemServiceState + inspectErr error + inspectCalls int + enabledUnits []string + enabledState []string + enabledErr error + enabledCalls int +} + +func (f *fakeStateReader) ListSystemServices(_ context.Context, request builtins.SystemServiceListRequest) ([]builtins.SystemServiceState, error) { + f.listCalls++ + f.listRequest = builtins.SystemServiceListRequest{ + Services: append([]string(nil), request.Services...), + IncludeInactive: request.IncludeInactive, + } + return append([]builtins.SystemServiceState(nil), f.listStates...), f.listErr +} + +func (f *fakeStateReader) InspectSystemServices(_ context.Context, units []string) ([]builtins.SystemServiceState, error) { + f.inspectCalls++ + f.inspectUnits = append([]string(nil), units...) + return append([]builtins.SystemServiceState(nil), f.inspectState...), f.inspectErr +} + +func (f *fakeStateReader) SystemServiceEnabledState(_ context.Context, units []string) ([]string, error) { + f.enabledCalls++ + f.enabledUnits = append([]string(nil), units...) + return append([]string(nil), f.enabledState...), f.enabledErr +} + +type jobCall struct { + action builtins.SystemServiceJobAction + units []string +} + +type fakeController struct { + jobs []jobCall + jobErr error + resetUnits []string + resetErr error + enableUnits []string + enableErr error + disableUnits []string + disableErr error + order []string +} + +func (f *fakeController) RunSystemServiceJobs(_ context.Context, action builtins.SystemServiceJobAction, units []string) error { + f.jobs = append(f.jobs, jobCall{action: action, units: append([]string(nil), units...)}) + f.order = append(f.order, "job:"+string(action)) + return f.jobErr +} + +func (f *fakeController) ResetFailedSystemServices(_ context.Context, units []string) error { + f.resetUnits = append([]string(nil), units...) + f.order = append(f.order, "reset-failed") + return f.resetErr +} + +func (f *fakeController) EnableSystemServices(_ context.Context, units []string) error { + f.enableUnits = append([]string(nil), units...) + f.order = append(f.order, "enable") + return f.enableErr +} + +func (f *fakeController) DisableSystemServices(_ context.Context, units []string) error { + f.disableUnits = append([]string(nil), units...) + f.order = append(f.order, "disable") + return f.disableErr +} + +type invocation struct { + result builtins.Result + stdout string + stderr string + authorized []builtins.SystemdOperation +} + +func runSystemctl(t *testing.T, args []string, callCtx *builtins.CallContext) invocation { + t.Helper() + var stdout, stderr bytes.Buffer + if callCtx.Stdout == nil { + callCtx.Stdout = &stdout + } + if callCtx.Stderr == nil { + callCtx.Stderr = &stderr + } + + fs := pflag.NewFlagSet("systemctl", pflag.ContinueOnError) + fs.SetOutput(io.Discard) + handler := makeFlags(fs) + require.NoError(t, fs.Parse(args)) + result := handler(context.Background(), callCtx, fs.Args()) + return invocation{result: result, stdout: stdout.String(), stderr: stderr.String()} +} + +func permissiveContext(reader builtins.SystemServiceStateReader, controller builtins.SystemServiceController, authorized *[]builtins.SystemdOperation) *builtins.CallContext { + return &builtins.CallContext{ + RemediationMode: true, + AuthorizeSystemd: func(operations ...builtins.SystemdOperation) error { + if authorized != nil { + *authorized = append(*authorized, operations...) + } + return nil + }, + Systemd: &builtins.SystemdServices{ServiceState: reader, ServiceControl: controller}, + } +} + +func TestSystemctlRequiresRemediationModeBeforeAnyCapability(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {name: "bare list"}, + {name: "explicit list", args: []string{"list-units"}}, + {name: "help", args: []string{"--help"}}, + {name: "status", args: []string{"status", "api.service"}}, + {name: "show", args: []string{"show", "api.service"}}, + {name: "is active", args: []string{"is-active", "api.service"}}, + {name: "is failed", args: []string{"is-failed", "api.service"}}, + {name: "is enabled", args: []string{"is-enabled", "api.service"}}, + {name: "start", args: []string{"start", "api.service"}}, + {name: "stop", args: []string{"stop", "api.service"}}, + {name: "reload", args: []string{"reload", "api.service"}}, + {name: "restart", args: []string{"restart", "api.service"}}, + {name: "try restart", args: []string{"try-restart", "api.service"}}, + {name: "reload or restart", args: []string{"reload-or-restart", "api.service"}}, + {name: "try reload or restart", args: []string{"try-reload-or-restart", "api.service"}}, + {name: "reset failed", args: []string{"reset-failed", "api.service"}}, + {name: "enable", args: []string{"enable", "api.service"}}, + {name: "disable", args: []string{"disable", "api.service"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + reader := &fakeStateReader{} + controller := &fakeController{} + capabilityCalls := 0 + callCtx := &builtins.CallContext{ + ReadableSystemServices: func() []string { + capabilityCalls++ + return []string{"api.service"} + }, + AuthorizeSystemd: func(...builtins.SystemdOperation) error { + capabilityCalls++ + return nil + }, + Systemd: &builtins.SystemdServices{ServiceState: reader, ServiceControl: controller}, + } + + got := runSystemctl(t, test.args, callCtx) + + assert.Equal(t, uint8(1), got.result.Code) + assert.Empty(t, got.stdout) + assert.Equal(t, "systemctl: remediation mode required\n", got.stderr) + assert.Zero(t, capabilityCalls) + assert.Zero(t, reader.listCalls) + assert.Zero(t, reader.inspectCalls) + assert.Zero(t, reader.enabledCalls) + assert.Empty(t, controller.jobs) + assert.Empty(t, controller.resetUnits) + assert.Empty(t, controller.enableUnits) + assert.Empty(t, controller.disableUnits) + }) + } + assert.True(t, Cmd.RemediationOnly) +} + +func unitState(name, active, sub string) builtins.SystemServiceState { + return builtins.SystemServiceState{ + Name: name, + Description: name + " description", + LoadState: "loaded", + ActiveState: active, + SubState: sub, + UnitFileState: "enabled", + Result: "success", + } +} + +func TestListUnitsUsesOnlyFilteredReadableGrants(t *testing.T) { + reader := &fakeStateReader{listStates: []builtins.SystemServiceState{ + unitState("worker.socket", "inactive", "dead"), + unitState("api.service", "active", "running"), + }} + var authorized []builtins.SystemdOperation + callCtx := permissiveContext(reader, nil, &authorized) + callCtx.ReadableSystemServices = func() []string { + return []string{"legacy", "db.timer", "worker.socket", "api.service"} + } + + got := runSystemctl(t, []string{"list-units", "--all", "--type=service,socket", "--state=active"}, callCtx) + + assert.Equal(t, uint8(0), got.result.Code) + assert.Empty(t, got.stderr) + assert.Equal(t, "UNIT LOAD ACTIVE SUB DESCRIPTION\napi.service loaded active running api.service description\n1 units listed (restricted to units granted read access).\n", got.stdout) + assert.Equal(t, builtins.SystemServiceListRequest{ + Services: []string{"api.service", "worker.socket"}, + IncludeInactive: true, + }, reader.listRequest) + assert.Equal(t, []builtins.SystemdOperation{ + {Service: "api.service", Action: builtins.SystemServiceRead}, + {Service: "worker.socket", Action: builtins.SystemServiceRead}, + }, authorized) +} + +func TestBareListPrefiltersTypeBeforeOperandCap(t *testing.T) { + readable := []string{"only.service", "legacy"} + for i := 0; i <= builtins.MaxSystemServiceOperands; i++ { + readable = append(readable, strings.Repeat("t", i+1)+".timer") + } + reader := &fakeStateReader{listStates: []builtins.SystemServiceState{unitState("only.service", "active", "running")}} + callCtx := permissiveContext(reader, nil, nil) + callCtx.ReadableSystemServices = func() []string { return readable } + + got := runSystemctl(t, []string{"--type=service", "--no-legend", "--system", "--no-pager"}, callCtx) + + assert.Equal(t, uint8(0), got.result.Code) + assert.Equal(t, "only.service loaded active running only.service description\n", got.stdout) + assert.Empty(t, got.stderr) + assert.Equal(t, []string{"only.service"}, reader.listRequest.Services) +} + +func TestListUnitsAuthorizesCompleteSetBeforeBackend(t *testing.T) { + reader := &fakeStateReader{} + var calls int + callCtx := permissiveContext(reader, nil, nil) + callCtx.ReadableSystemServices = func() []string { return []string{"a.service", "b.timer"} } + callCtx.AuthorizeSystemd = func(operations ...builtins.SystemdOperation) error { + calls++ + assert.Len(t, operations, 2) + return errors.New("denied\n\x1b[31m") + } + + got := runSystemctl(t, nil, callCtx) + + assert.Equal(t, uint8(1), got.result.Code) + assert.Equal(t, 1, calls) + assert.Zero(t, reader.listCalls) + assert.NotContains(t, got.stderr, "\n\x1b") + assert.NotContains(t, got.stderr, "\x1b") + assert.Equal(t, "systemctl: denied??[31m\n", got.stderr) +} + +func TestListUnitsRejectsBackendUnitOutsideReadableSet(t *testing.T) { + reader := &fakeStateReader{listStates: []builtins.SystemServiceState{unitState("secret.service", "active", "running")}} + callCtx := permissiveContext(reader, nil, nil) + callCtx.ReadableSystemServices = func() []string { return []string{"api.service"} } + + got := runSystemctl(t, nil, callCtx) + + assert.Equal(t, uint8(1), got.result.Code) + assert.Empty(t, got.stdout) + assert.Contains(t, got.stderr, "unauthorized unit") +} + +func TestExplicitOperandsAreValidatedBeforeAuthorization(t *testing.T) { + tests := []string{ + "legacy", + "bad.unit", + "*.service", + "tenant:api.service", + "path/api.service", + `escaped\x2dapi.service`, + "café.service", + "@instance.service", + "a@b@c.service", + strings.Repeat("a", builtins.MaxSystemServiceNameBytes) + ".service", + } + for _, unit := range tests { + t.Run(strings.ReplaceAll(unit, "/", "_"), func(t *testing.T) { + var authorizeCalls int + reader := &fakeStateReader{} + callCtx := permissiveContext(reader, nil, nil) + callCtx.AuthorizeSystemd = func(...builtins.SystemdOperation) error { + authorizeCalls++ + return nil + } + + got := runSystemctl(t, []string{"status", unit}, callCtx) + + assert.Equal(t, uint8(1), got.result.Code) + assert.Zero(t, authorizeCalls) + assert.Zero(t, reader.inspectCalls) + assert.NotEmpty(t, got.stderr) + }) + } +} + +func TestArgumentTokenCountsAreBoundedBeforeBackendWork(t *testing.T) { + duplicateUnits := make([]string, builtins.MaxSystemServiceOperands+1) + for index := range duplicateUnits { + duplicateUnits[index] = "api.service" + } + _, err := validateUnits(duplicateUnits, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "too many unit operands") + + _, err = parseFilterValues([]string{strings.Repeat("active,", 100_000)}, "state", false) + require.Error(t, err) + assert.Contains(t, err.Error(), "too many --state values") + + _, err = parseProperties([]string{strings.Repeat("Id,", 100_000)}) + require.Error(t, err) + assert.Contains(t, err.Error(), "too many properties") +} + +func TestStatusDeduplicatesAndFormatsBoundedState(t *testing.T) { + state := unitState("api.service", "active", "running") + state.Description = "API" + state.MainPID = 42 + reader := &fakeStateReader{inspectState: []builtins.SystemServiceState{state}} + var authorized []builtins.SystemdOperation + + got := runSystemctl(t, []string{"status", "api.service", "api.service"}, permissiveContext(reader, nil, &authorized)) + + assert.Equal(t, uint8(0), got.result.Code) + assert.Empty(t, got.stderr) + assert.Equal(t, "api.service - API\n Loaded: loaded (enabled)\n Active: active (running)\n Main PID: 42\n Result: success\n", got.stdout) + assert.Equal(t, []string{"api.service"}, reader.inspectUnits) + assert.Equal(t, []builtins.SystemdOperation{{Service: "api.service", Action: builtins.SystemServiceRead}}, authorized) +} + +func TestStatusExitCodes(t *testing.T) { + tests := []struct { + name string + state builtins.SystemServiceState + code uint8 + }{ + {name: "inactive", state: unitState("api.service", "inactive", "dead"), code: 3}, + {name: "missing", state: builtins.SystemServiceState{Name: "api.service", LoadState: "not-found", ActiveState: "inactive", SubState: "dead"}, code: 4}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + reader := &fakeStateReader{inspectState: []builtins.SystemServiceState{test.state}} + got := runSystemctl(t, []string{"status", "api.service"}, permissiveContext(reader, nil, nil)) + assert.Equal(t, test.code, got.result.Code) + }) + } +} + +func TestShowUsesFixedPropertiesAndCanonicalID(t *testing.T) { + state := unitState("alias.service", "active", "running") + state.CanonicalName = `real\x2dapi:blue.service` + state.Description = "API" + state.MainPID = 12 + state.JobID = 99 + reader := &fakeStateReader{inspectState: []builtins.SystemServiceState{state}} + + got := runSystemctl(t, []string{"show", "alias.service", "-p", "Id,Description", "-p", "MainPID"}, permissiveContext(reader, nil, nil)) + + assert.Equal(t, uint8(0), got.result.Code) + assert.Equal(t, "Id=real\\x2dapi:blue.service\nDescription=API\nMainPID=12\n", got.stdout) + assert.NotContains(t, got.stdout, "Job") + assert.Empty(t, got.stderr) + + got = runSystemctl(t, []string{"show", "alias.service", "--property=Id,ActiveState", "--value"}, permissiveContext(reader, nil, nil)) + assert.Equal(t, "real\\x2dapi:blue.service\nactive\n", got.stdout) +} + +func TestShowRejectsArbitraryPropertyBeforeAuthorization(t *testing.T) { + var calls int + callCtx := permissiveContext(&fakeStateReader{}, nil, nil) + callCtx.AuthorizeSystemd = func(...builtins.SystemdOperation) error { + calls++ + return nil + } + + got := runSystemctl(t, []string{"show", "api.service", "--property=Environment"}, callCtx) + + assert.Equal(t, uint8(1), got.result.Code) + assert.Zero(t, calls) + assert.Contains(t, got.stderr, `unsupported property "Environment"`) +} + +func TestBackendStringsCannotInjectTerminalControls(t *testing.T) { + state := unitState("api.service", "active", "running") + state.Description = "hello\nworld\x1b\t\x00\u202e\xff" + reader := &fakeStateReader{inspectState: []builtins.SystemServiceState{state}} + + got := runSystemctl(t, []string{"show", "api.service", "-p", "Description"}, permissiveContext(reader, nil, nil)) + + assert.Equal(t, uint8(0), got.result.Code) + assert.Equal(t, "Description=hello?world?????\n", got.stdout) + assert.NotContains(t, got.stdout, "\x1b") + assert.Empty(t, got.stderr) + + state.Description = "safe" + state.ActiveState = "active\nforged" + reader.inspectState = []builtins.SystemServiceState{state} + got = runSystemctl(t, []string{"status", "api.service"}, permissiveContext(reader, nil, nil)) + assert.Equal(t, uint8(1), got.result.Code) + assert.Empty(t, got.stdout) + assert.NotContains(t, got.stderr, "\nforged") + assert.NotContains(t, got.stderr, "\x1b") +} + +func TestIsActiveAndIsFailedUseAnyMatchSemantics(t *testing.T) { + reader := &fakeStateReader{inspectState: []builtins.SystemServiceState{ + unitState("a.service", "inactive", "dead"), + unitState("b.service", "active", "running"), + }} + got := runSystemctl(t, []string{"is-active", "a.service", "b.service"}, permissiveContext(reader, nil, nil)) + assert.Equal(t, uint8(0), got.result.Code) + assert.Equal(t, "inactive\nactive\n", got.stdout) + + reader.inspectState[1].ActiveState = "failed" + got = runSystemctl(t, []string{"is-failed", "a.service", "b.service", "--quiet"}, permissiveContext(reader, nil, nil)) + assert.Equal(t, uint8(0), got.result.Code) + assert.Empty(t, got.stdout) +} + +func TestIsPredicatesReturnFourWhenEveryUnitIsMissing(t *testing.T) { + reader := &fakeStateReader{inspectState: []builtins.SystemServiceState{ + {Name: "a.service", LoadState: "not-found", ActiveState: "inactive"}, + {Name: "b.service", LoadState: "not-found", ActiveState: "inactive"}, + }} + got := runSystemctl(t, []string{"is-active", "a.service", "b.service", "--quiet"}, permissiveContext(reader, nil, nil)) + assert.Equal(t, uint8(4), got.result.Code) + assert.Empty(t, got.stdout) + + reader.enabledState = []string{"not-found", "not-found"} + got = runSystemctl(t, []string{"is-enabled", "a.service", "b.service", "--quiet"}, permissiveContext(reader, nil, nil)) + assert.Equal(t, uint8(4), got.result.Code) +} + +func TestIsEnabledMatchesNativeSuccessfulStates(t *testing.T) { + for _, enabled := range []string{"enabled", "enabled-runtime", "static", "alias", "indirect", "generated"} { + t.Run(enabled, func(t *testing.T) { + reader := &fakeStateReader{enabledState: []string{"disabled", enabled}} + got := runSystemctl(t, []string{"is-enabled", "a.service", "b.timer"}, permissiveContext(reader, nil, nil)) + assert.Equal(t, uint8(0), got.result.Code) + assert.Equal(t, "disabled\n"+enabled+"\n", got.stdout) + }) + } +} + +func TestJobVerbsUseFixedBackendActionsAndAuthorizations(t *testing.T) { + tests := []struct { + verb string + job builtins.SystemServiceJobAction + actions []builtins.SystemServiceAction + }{ + {verb: "start", job: builtins.SystemServiceJobStart, actions: []builtins.SystemServiceAction{builtins.SystemServiceStart}}, + {verb: "stop", job: builtins.SystemServiceJobStop, actions: []builtins.SystemServiceAction{builtins.SystemServiceStop}}, + {verb: "reload", job: builtins.SystemServiceJobReload, actions: []builtins.SystemServiceAction{builtins.SystemServiceReload}}, + {verb: "restart", job: builtins.SystemServiceJobRestart, actions: []builtins.SystemServiceAction{builtins.SystemServiceRestart}}, + {verb: "try-restart", job: builtins.SystemServiceJobTryRestart, actions: []builtins.SystemServiceAction{builtins.SystemServiceRestart}}, + {verb: "reload-or-restart", job: builtins.SystemServiceJobReloadOrRestart, actions: []builtins.SystemServiceAction{builtins.SystemServiceReload, builtins.SystemServiceRestart}}, + {verb: "try-reload-or-restart", job: builtins.SystemServiceJobTryReloadOrRestart, actions: []builtins.SystemServiceAction{builtins.SystemServiceReload, builtins.SystemServiceRestart}}, + } + for _, test := range tests { + t.Run(test.verb, func(t *testing.T) { + controller := &fakeController{} + var authorized []builtins.SystemdOperation + got := runSystemctl(t, []string{test.verb, "api.service", "api.service"}, permissiveContext(nil, controller, &authorized)) + assert.Equal(t, uint8(0), got.result.Code) + require.Len(t, controller.jobs, 1) + assert.Equal(t, test.job, controller.jobs[0].action) + assert.Equal(t, []string{"api.service"}, controller.jobs[0].units) + expected := make([]builtins.SystemdOperation, 0, len(test.actions)) + for _, action := range test.actions { + expected = append(expected, builtins.SystemdOperation{Service: "api.service", Action: action}) + } + assert.Equal(t, expected, authorized) + }) + } +} + +func TestCompoundJobAuthorizationCompletesBeforeBackend(t *testing.T) { + controller := &fakeController{} + var gotOperations []builtins.SystemdOperation + callCtx := permissiveContext(nil, controller, nil) + callCtx.AuthorizeSystemd = func(operations ...builtins.SystemdOperation) error { + gotOperations = append(gotOperations, operations...) + return errors.New("restart denied") + } + + got := runSystemctl(t, []string{"reload-or-restart", "a.service", "b.timer"}, callCtx) + + assert.Equal(t, uint8(1), got.result.Code) + assert.Empty(t, controller.jobs) + assert.Equal(t, []builtins.SystemdOperation{ + {Service: "a.service", Action: builtins.SystemServiceReload}, + {Service: "a.service", Action: builtins.SystemServiceRestart}, + {Service: "b.timer", Action: builtins.SystemServiceReload}, + {Service: "b.timer", Action: builtins.SystemServiceRestart}, + }, gotOperations) +} + +func TestEnableDisableNowPreauthorizesAndOrdersEffects(t *testing.T) { + tests := []struct { + verb string + first builtins.SystemServiceAction + second builtins.SystemServiceAction + job builtins.SystemServiceJobAction + wantOrder []string + configured func(*fakeController) []string + }{ + {verb: "enable", first: builtins.SystemServiceEnable, second: builtins.SystemServiceStart, job: builtins.SystemServiceJobStart, wantOrder: []string{"enable", "job:start"}, configured: func(c *fakeController) []string { return c.enableUnits }}, + {verb: "disable", first: builtins.SystemServiceDisable, second: builtins.SystemServiceStop, job: builtins.SystemServiceJobStop, wantOrder: []string{"disable", "job:stop"}, configured: func(c *fakeController) []string { return c.disableUnits }}, + } + for _, test := range tests { + t.Run(test.verb, func(t *testing.T) { + controller := &fakeController{} + var authorized []builtins.SystemdOperation + got := runSystemctl(t, []string{test.verb, "api.service", "--now"}, permissiveContext(nil, controller, &authorized)) + assert.Equal(t, uint8(0), got.result.Code) + assert.Equal(t, []builtins.SystemdOperation{ + {Service: "api.service", Action: test.first}, + {Service: "api.service", Action: test.second}, + }, authorized) + assert.Equal(t, []string{"api.service"}, test.configured(controller)) + assert.Equal(t, test.wantOrder, controller.order) + require.Len(t, controller.jobs, 1) + assert.Equal(t, test.job, controller.jobs[0].action) + }) + } +} + +func TestEnableNowJobFailureReportsPartialMutation(t *testing.T) { + controller := &fakeController{jobErr: errors.New("job failed\n\x1b")} + got := runSystemctl(t, []string{"enable", "api.service", "--now"}, permissiveContext(nil, controller, nil)) + + assert.Equal(t, uint8(1), got.result.Code) + assert.Equal(t, []string{"enable", "job:start"}, controller.order) + assert.Contains(t, got.stderr, "enable completed") + assert.Contains(t, got.stderr, "not rolled back") + assert.NotContains(t, got.stderr, "\x1b") +} + +func TestCanceledMutationStillReportsUncertainOutcome(t *testing.T) { + controller := &fakeController{jobErr: errors.New("job accepted, final state is unknown and was not rolled back")} + callCtx := permissiveContext(nil, controller, nil) + var stdout, stderr bytes.Buffer + callCtx.Stdout = &stdout + callCtx.Stderr = &stderr + + fs := pflag.NewFlagSet("systemctl", pflag.ContinueOnError) + fs.SetOutput(io.Discard) + handler := makeFlags(fs) + require.NoError(t, fs.Parse([]string{"restart", "api.service"})) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result := handler(ctx, callCtx, fs.Args()) + assert.Equal(t, uint8(1), result.Code) + assert.Empty(t, stdout.String()) + assert.Contains(t, stderr.String(), "job accepted") + assert.Contains(t, stderr.String(), "not rolled back") +} + +func TestEnableDisableNowRejectTemplateBeforeAuthorization(t *testing.T) { + for _, verb := range []string{"enable", "disable"} { + t.Run(verb, func(t *testing.T) { + controller := &fakeController{} + var authorizeCalls int + callCtx := permissiveContext(nil, controller, nil) + callCtx.AuthorizeSystemd = func(...builtins.SystemdOperation) error { + authorizeCalls++ + return nil + } + got := runSystemctl(t, []string{verb, "worker@.service", "--now"}, callCtx) + assert.Equal(t, uint8(1), got.result.Code) + assert.Zero(t, authorizeCalls) + assert.Empty(t, controller.order) + assert.Contains(t, got.stderr, "exact instance") + }) + } +} + +func TestResetFailedUsesDedicatedCapability(t *testing.T) { + controller := &fakeController{} + var authorized []builtins.SystemdOperation + got := runSystemctl(t, []string{"reset-failed", "api.service"}, permissiveContext(nil, controller, &authorized)) + + assert.Equal(t, uint8(0), got.result.Code) + assert.Equal(t, []string{"api.service"}, controller.resetUnits) + assert.Equal(t, []builtins.SystemdOperation{{Service: "api.service", Action: builtins.SystemServiceResetFailed}}, authorized) +} + +func TestFlagsAreScopedToTheirCommands(t *testing.T) { + tests := [][]string{ + {"status", "api.service", "--all"}, + {"list-units", "--quiet"}, + {"start", "api.service", "--now"}, + {"show", "api.service", "--state=active"}, + {"is-active", "api.service", "--value"}, + } + for _, args := range tests { + t.Run(strings.Join(args, "_"), func(t *testing.T) { + got := runSystemctl(t, args, permissiveContext(&fakeStateReader{}, &fakeController{}, nil)) + assert.Equal(t, uint8(1), got.result.Code) + assert.Contains(t, got.stderr, "is not supported with") + }) + } +} + +func TestDangerousHostSystemctlOptionsAreRejected(t *testing.T) { + Cmd.Register() + handler, ok := builtins.Lookup("systemctl") + require.True(t, ok) + + for _, args := range [][]string{ + {"--root=/host", "status", "api.service"}, + {"--machine=host", "status", "api.service"}, + {"--user", "status", "api.service"}, + {"--global", "enable", "api.service"}, + {"--runtime", "enable", "api.service"}, + {"--force", "restart", "api.service"}, + {"--job-mode=ignore-dependencies", "restart", "api.service"}, + } { + t.Run(strings.Join(args, "_"), func(t *testing.T) { + var stdout, stderr bytes.Buffer + result := handler(context.Background(), &builtins.CallContext{Stdout: &stdout, Stderr: &stderr, RemediationMode: true}, args) + assert.Equal(t, uint8(1), result.Code) + assert.Empty(t, stdout.String()) + assert.Contains(t, stderr.String(), "unrecognized option") + }) + } +} + +func TestHelpDocumentsRestrictedEnumerationWithoutCapabilities(t *testing.T) { + got := runSystemctl(t, []string{"--help"}, &builtins.CallContext{RemediationMode: true}) + + assert.Equal(t, uint8(0), got.result.Code) + assert.Empty(t, got.stderr) + assert.Contains(t, got.stdout, "Usage: systemctl") + assert.Contains(t, got.stdout, "entire command is available only in remediation mode") + assert.Contains(t, got.stdout, "exact units granted read access") + assert.Contains(t, got.stdout, "--system") + assert.Contains(t, got.stdout, "--no-pager") + assert.Contains(t, got.stdout, "--property") +} diff --git a/builtins/systemd.go b/builtins/systemd.go index a689fe48..542e9587 100644 --- a/builtins/systemd.go +++ b/builtins/systemd.go @@ -21,8 +21,77 @@ const ( MaxJournalQueryEntries = 1000 // MaxJournalQueryUnits bounds exact unit scopes before any backend work. MaxJournalQueryUnits = 32 + // MaxSystemServiceOperands bounds exact unit selectors accepted by one + // systemctl invocation, including the configured readable set used by + // list-units. + MaxSystemServiceOperands = 32 + // MaxSystemServiceNameBytes matches systemd's maximum unit-name payload + // (UNIT_NAME_MAX minus the terminating NUL). + MaxSystemServiceNameBytes = 255 + // MaxSystemServiceFieldBytes bounds every string returned by a manager + // backend before it reaches command formatting. + MaxSystemServiceFieldBytes = 64 * 1024 ) +// SystemServiceState is the fixed, bounded unit state exposed to the restricted +// systemctl builtin. The historical "Service" name is retained for API +// compatibility. Name preserves the exact authorized selector; CanonicalName +// is used only to validate manager replies and is not an arbitrary D-Bus +// object path. +type SystemServiceState struct { + Name string + CanonicalName string + Description string + LoadState string + ActiveState string + SubState string + UnitFileState string + MainPID uint32 + Result string + ExecMainCode int32 + ExecMainStatus int32 + JobID uint32 +} + +// SystemServiceListRequest selects exact pre-authorized units for bounded +// list-units output. IncludeInactive permits loading inactive configured +// units; false restricts the result to units already loaded by systemd. +type SystemServiceListRequest struct { + Services []string + IncludeInactive bool +} + +// SystemServiceStateReader exposes fixed unit state without a generic +// property, object-path, or transport API. +type SystemServiceStateReader interface { + ListSystemServices(ctx context.Context, request SystemServiceListRequest) ([]SystemServiceState, error) + InspectSystemServices(ctx context.Context, services []string) ([]SystemServiceState, error) + SystemServiceEnabledState(ctx context.Context, services []string) ([]string, error) +} + +// SystemServiceJobAction is the fixed set of runtime jobs exposed by the +// restricted systemctl backend. +type SystemServiceJobAction string + +const ( + SystemServiceJobStart SystemServiceJobAction = "start" + SystemServiceJobStop SystemServiceJobAction = "stop" + SystemServiceJobReload SystemServiceJobAction = "reload" + SystemServiceJobRestart SystemServiceJobAction = "restart" + SystemServiceJobTryRestart SystemServiceJobAction = "try-restart" + SystemServiceJobReloadOrRestart SystemServiceJobAction = "reload-or-restart" + SystemServiceJobTryReloadOrRestart SystemServiceJobAction = "try-reload-or-restart" +) + +// SystemServiceController performs only fixed unit operations. Job methods +// return after systemd reports completion for every requested unit. +type SystemServiceController interface { + RunSystemServiceJobs(ctx context.Context, action SystemServiceJobAction, services []string) error + ResetFailedSystemServices(ctx context.Context, services []string) error + EnableSystemServices(ctx context.Context, services []string) error + DisableSystemServices(ctx context.Context, services []string) error +} + // JournalQuery is the bounded, structured query accepted by the trusted // journal backend. Callers cannot provide raw journal matches or paths. type JournalQuery struct { @@ -96,4 +165,6 @@ type SystemdServices struct { JournalStorage JournalStorageReader JournalCleaner JournalCleaner JournalRotator JournalRotator + ServiceState SystemServiceStateReader + ServiceControl SystemServiceController } diff --git a/cmd/rshell/main.go b/cmd/rshell/main.go index 2d680ebf..3c548448 100644 --- a/cmd/rshell/main.go +++ b/cmd/rshell/main.go @@ -46,6 +46,7 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. journalDirs string machineIDPath string journalSocket string + managerSocket string mode string ) @@ -98,7 +99,7 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. if journalDirs != "" { configuredJournalDirs = strings.Split(journalDirs, ",") } - systemdTargetSet := journalDirs != "" || machineIDPath != "" || journalSocket != "" + systemdTargetSet := journalDirs != "" || machineIDPath != "" || journalSocket != "" || managerSocket != "" execOpts := executeOpts{ allowedPaths: paths, @@ -110,6 +111,7 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. JournalDirs: configuredJournalDirs, MachineIDPath: machineIDPath, JournalControlSocket: journalSocket, + ManagerBusSocket: managerSocket, }, systemdTargetSet: systemdTargetSet, mode: parsedMode, @@ -162,14 +164,15 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. cmd.Flags().MarkHidden("command") //nolint:errcheck // flag is guaranteed to exist cmd.Flags().StringVarP(&allowedPaths, "allowed-paths", "p", "", "comma-separated list of PATH[:ro|:rw] directories the shell is allowed to access; entries without a suffix are read-only") cmd.Flags().StringVar(&allowedCommands, "allowed-commands", "", "comma-separated list of namespaced commands (e.g. rshell:cat,rshell:find)") - cmd.Flags().StringVar(&allowedServices, "allowed-services", "", "comma-separated systemd service grants in SERVICE:ACTION[+ACTION...] form") + cmd.Flags().StringVar(&allowedServices, "allowed-services", "", "comma-separated systemd unit grants in UNIT:ACTION[+ACTION...] form") cmd.Flags().BoolVar(&allowAllCmds, "allow-all-commands", false, "allow execution of all commands (builtins and external)") cmd.Flags().DurationVar(&timeout, "timeout", 0, "maximum execution time for the entire shell run (e.g. 100ms, 5s, 1m)") cmd.Flags().StringVar(&procPath, "proc-path", "", "path to the proc filesystem used by ps (default \"/proc\")") cmd.Flags().StringVar(&journalDirs, "systemd-journal-dirs", "", "comma-separated journal root directories for an explicit systemd target") cmd.Flags().StringVar(&machineIDPath, "systemd-machine-id-path", "", "machine-id file for an explicit systemd target") cmd.Flags().StringVar(&journalSocket, "systemd-journal-socket", "", "journald Varlink socket for an explicit systemd target") - cmd.Flags().StringVar(&mode, "mode", "read-only", "shell execution mode: read-only (default) or remediation (enables file-target output redirections within :rw AllowedPaths roots)") + cmd.Flags().StringVar(&managerSocket, "systemd-manager-socket", "", "system D-Bus socket for an explicit systemd target") + cmd.Flags().StringVar(&mode, "mode", "read-only", "shell execution mode: read-only (default) or remediation (enables file-target output redirections within :rw AllowedPaths roots and remediation-only builtins, including the restricted systemctl builtin)") if err := cmd.ExecuteContext(ctx); err != nil { var status interp.ExitStatus diff --git a/cmd/rshell/main_test.go b/cmd/rshell/main_test.go index 7d620d17..4a7ac2ac 100644 --- a/cmd/rshell/main_test.go +++ b/cmd/rshell/main_test.go @@ -180,13 +180,15 @@ func TestHelp(t *testing.T) { assert.Contains(t, stdout, "entries without a suffix are read-only") assert.Contains(t, stdout, "--allowed-commands") assert.Contains(t, stdout, "--allowed-services") - assert.Contains(t, stdout, "SERVICE:ACTION[+ACTION...]") + assert.Contains(t, stdout, "UNIT:ACTION[+ACTION...]") assert.Contains(t, stdout, "--allow-all-commands") assert.Contains(t, stdout, "file-target output redirections within :rw AllowedPaths roots") + assert.Contains(t, stdout, "remediation-only builtins, including the restricted systemctl builtin") assert.Contains(t, stdout, "--timeout") assert.Contains(t, stdout, "--systemd-journal-dirs") assert.Contains(t, stdout, "--systemd-machine-id-path") assert.Contains(t, stdout, "--systemd-journal-socket") + assert.Contains(t, stdout, "--systemd-manager-socket") assert.NotContains(t, stdout, "--command", "-c/--command should be hidden from help") } @@ -319,12 +321,12 @@ func TestAllowedServicesFlagIgnoresGrantsWithoutActions(t *testing.T) { func TestAllowedServicesFlagWarnsAndSkipsUnknownAction(t *testing.T) { code, stdout, stderr := runCLI(t, "--allow-all-commands", - "--allowed-services", "mysql.service:stop", + "--allowed-services", "mysql.service:freeze", "-c", `echo hello`, ) assert.Equal(t, 0, code) assert.Equal(t, "hello\n", stdout) - assert.Contains(t, stderr, `skipping unsupported action "stop"`) + assert.Contains(t, stderr, `skipping unsupported action "freeze"`) } func TestAllowedServicesFlagWarnsAndSkipsInvalidService(t *testing.T) { @@ -347,7 +349,7 @@ func TestParseAllowedServicesParsesServiceActions(t *testing.T) { } func TestParseAllowedServicesPreservesEntriesForPolicyValidation(t *testing.T) { - grants := parseAllowedServices("ignored.service,tenant:mysql.service:read,mysql.service:read+stop") + grants := parseAllowedServices("ignored.service,tenant:mysql.service:read,mysql.service:read+freeze") require.Len(t, grants, 3) assert.Equal(t, interp.SystemdControlGrant{Service: "ignored.service"}, grants[0]) assert.Equal(t, interp.SystemdControlGrant{ @@ -356,14 +358,14 @@ func TestParseAllowedServicesPreservesEntriesForPolicyValidation(t *testing.T) { }, grants[1]) assert.Equal(t, interp.SystemdControlGrant{ Service: "mysql.service", - Actions: []interp.SystemServiceAction{interp.SystemServiceRead, "stop"}, + Actions: []interp.SystemServiceAction{interp.SystemServiceRead, "freeze"}, }, grants[2]) } func TestAllowedServicesFlagAcceptsServiceGrants(t *testing.T) { code, stdout, stderr := runCLI(t, "--allow-all-commands", - "--allowed-services", "mysql.service:read+restart,systemd-journald.service:read+clean", + "--allowed-services", "mysql.service:read+restart,backup.timer:start+stop,api.socket:read+enable+disable,systemd-journald.service:read+clean", "--mode", "remediation", "-c", `echo hello`, ) @@ -391,6 +393,7 @@ func TestSystemdTargetFlags(t *testing.T) { "--systemd-journal-dirs", filepath.Join(dir, "journal"), "--systemd-machine-id-path", filepath.Join(dir, "machine-id"), "--systemd-journal-socket", filepath.Join(dir, "journal.sock"), + "--systemd-manager-socket", filepath.Join(dir, "system-bus.sock"), "-c", `echo hello`, ) assert.Equal(t, 0, code) @@ -408,6 +411,16 @@ func TestExplicitSystemdTargetRequiresMachineIDPath(t *testing.T) { assert.Contains(t, stderr, "machine ID path is required") } +func TestExplicitSystemdManagerTargetRequiresMachineIDPath(t *testing.T) { + code, _, stderr := runCLI(t, + "--allow-all-commands", + "--systemd-manager-socket", "/host/run/dbus/system_bus_socket", + "-c", `echo hello`, + ) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "machine ID path is required") +} + func TestAllowAllCommandsFlag(t *testing.T) { code, stdout, _ := runCLI(t, "--allow-all-commands", "-c", `echo hello`) assert.Equal(t, 0, code) diff --git a/docs/RULES.md b/docs/RULES.md index 09641a6e..d698af79 100644 --- a/docs/RULES.md +++ b/docs/RULES.md @@ -75,17 +75,17 @@ only the filesystem-accessing *functions* are forbidden. Systemd-aware builtins MUST use the structured services on `callCtx.Systemd` and MUST NOT open target paths themselves. The trusted `internal/systemd` backend may -read paths selected by `interp.WithSystemdTarget`; those paths intentionally -bypass `AllowedPaths`, like `ProcPath`, because they are fixed by the embedding -application and cannot be supplied by shell scripts. +read paths and connect to sockets selected by `interp.WithSystemdTarget`; those +targets intentionally bypass `AllowedPaths`, like `ProcPath`, because they are +fixed by the embedding application and cannot be supplied by shell scripts. Configured target paths are used directly in the rshell process namespace. The -embedding application MUST ensure the journal directories, machine-ID path, and -journald control socket refer to the same host. Journal discovery and reads MUST -reject symlinked machine directories and journal files and verify file identity -across open. Vacuum MUST remain rooted within each configured journal directory, -and journal control socket access MUST remain pinned to the validated socket -inode. +embedding application MUST ensure the journal directories, machine-ID path, +journald control socket, and manager bus socket refer to the same host. Journal +discovery and reads MUST reject symlinked machine directories and journal files +and verify file identity across open. Vacuum MUST remain rooted within each +configured journal directory, and journal control and manager bus socket access +MUST remain pinned to the validated socket inode. Journal reads and storage metadata are limited to regular, non-symlink `.journal` files directly under the configured machine-ID directories. The pure-Go reader @@ -118,12 +118,88 @@ cannot redirect the request. It applies fixed response-size and execution time bounds. A generic Varlink method or parameter interface must not be exposed to builtins. +Restricted system-manager access MUST use the public system D-Bus endpoint +selected by `SystemdTargetConfig.ManagerBusSocket` (locally, +`/run/dbus/system_bus_socket`). The private `/run/systemd/private` endpoint is +not a supported API. On Linux, the backend MUST reject a symlinked final socket, +pin its inode before connecting, connect through the pinned descriptor, perform +D-Bus authentication and `Hello`, call +`org.freedesktop.DBus.Peer.GetMachineId` on `org.freedesktop.systemd1` at the +fixed `/org/freedesktop/systemd1` manager object, and compare the result with the +configured machine ID before issuing any manager-interface request. Missing +manager configuration, machine-ID mismatch, path replacement, malformed +authentication, and unsupported platforms MUST fail closed. + +Builtins MUST receive only the structured `SystemServiceStateReader` and +`SystemServiceController` capabilities. A raw D-Bus connection, bus name, +object path, interface/member selector, property name, signal subscription, or +method-parameter interface MUST NOT be exposed. The backend may use only the +fixed manager and properties methods required for bounded list, inspection, +enabled-state, runtime-job, failed-state reset, enable, and disable operations. +It MUST apply fixed message, field, result-count, outstanding-call, job-wait, +execution-time, and cancellation bounds. Runtime jobs MUST use the fixed +`replace` job mode, match completion to the returned job identity, wait +synchronously, and report failed, canceled, timed-out, or disconnected jobs as +errors. + +Every systemctl operand MUST be an exact, fully suffixed systemd unit name of at +most 255 bytes; implicit `.service` suffixes, glob patterns, aliases resolved by +rshell, and unrestricted manager enumeration are forbidden. An exact configured +selector may itself be a systemd alias and may be resolved by the public manager +API, but output MUST retain the requested/granted selector and the canonical ID +MUST NOT be inserted into or treated as an additional policy grant. All valid +unit types may be selected, including `.service`, `.timer`, and `.socket`. A list +request MUST be constructed solely from the sorted exact names carrying a `read` +grant, with no more than 32 names, so units outside the capability map are +never enumerated. Without `--all`, list processing may consider only already +loaded candidates and return those that are active, failed, or carrying a job. +With `--all`, the backend may load valid read-granted candidates and include +inactive units, while omitting genuinely nonexistent names. Inspection may +return only the fixed, bounded state fields declared by `SystemServiceState`; +status output MUST NOT contain journal records, process command lines, arbitrary +properties, unit-file paths, or D-Bus object paths. + +Before any grant lookup, authorization, or manager access, the systemctl +builtin MUST require remediation mode. This command-wide gate applies to bare +and explicit `list-units`, `status`, `show`, every `is-*` predicate, mutations, +and direct `systemctl --help`; read-only mode MUST produce no manager or policy +capability call. This does not change the shared non-mutating `read` action, +which remains available to bounded journalctl queries outside remediation mode. +After the mode gate, the builtin MUST validate the complete request and +authorize every exact unit/action pair. Runtime `start`, `stop`, `reload`, and +`restart` jobs, `reset-failed`, and persistent `enable`/`disable` are structured +remediation capabilities and require their exact grants. Conditional +reload-or-restart operations require both `reload` and `restart`; +`enable --now` additionally requires `start`, and `disable --now` additionally +requires `stop`. Compound +authorization MUST finish before the first effect. A later systemd failure may +leave earlier authorized effects complete, so backends and builtins MUST return +partial-progress errors rather than imply rollback. An exact runtime grant +authorizes the directly named anchor unit, but the trusted backend may permit +systemd to act on dependency-related units through its normal transaction +semantics. The fixed enable/disable methods may permit systemd to follow +`[Install]` `Alias=`, `Also=`, and template `DefaultInstance=` metadata, +creating or removing installation state for auxiliary or instantiated units. +They also perform a fixed global `Manager.Reload` after the unit-file operation, +which may re-read unrelated host unit changes and run generators. These +indirect, manager-controlled effects MUST be documented and MUST NOT be +generalized into arbitrary dependency, path, alias, or reload parameters. +Standalone `daemon-reload` remains unsupported. The `clean` action remains +restricted to the journal exceptions above and MUST NOT authorize systemctl's +general cleanup operation. + +The embedding operator MUST treat each granted unit's configured payload, +aliases, and dependency graph as trusted. Omitting dedicated lifecycle verbs +does not prevent an explicitly granted target, service, alias, or dependency +from rebooting, shutting down, suspending, or otherwise changing host state; +operators MUST withhold those anchor grants when such effects are forbidden. + --- ## Implementation Rules ### File System Safety -- Commands MUST NOT write to any files on the system except through an explicitly documented, structured remediation capability such as `TruncateToZeroIfAtLeast` or `JournalCleaner` +- Commands MUST NOT write to any files on the system except through an explicitly documented, structured remediation capability such as `TruncateToZeroIfAtLeast`, `JournalCleaner`, or the fixed enable/disable methods on `SystemServiceController` - Commands MUST NOT execute any files or external binaries on the system in any way - Commands MUST NOT create, modify, or delete files, directories, or symlinks except as explicitly permitted by such a remediation capability - Commands MUST NOT follow symlinks during write operations (no writes = no risk, but verify) diff --git a/go.mod b/go.mod index 40b21224..124642cb 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ toolchain go1.26.2 require ( github.com/DataDog/datadog-agent/pkg/fleet/installer v0.78.0 + github.com/godbus/dbus/v5 v5.2.2 github.com/klauspost/compress v1.19.0 github.com/pierrec/lz4/v4 v4.1.27 github.com/prometheus-community/pro-bing v0.8.0 diff --git a/go.sum b/go.sum index c7812b78..b199a31b 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/internal/systemd/manager_dbus_limit.go b/internal/systemd/manager_dbus_limit.go new file mode 100644 index 00000000..23a0373d --- /dev/null +++ b/internal/systemd/manager_dbus_limit.go @@ -0,0 +1,129 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package systemd + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "sync/atomic" +) + +const ( + maxManagerDBusMessageSize = 1024 * 1024 + maxManagerDBusAuthBytes = 4 * 1024 +) + +var dbusAuthBegin = []byte("BEGIN\r\n") + +// boundedDBusConn leaves the line-oriented authentication exchange untouched, +// then validates the fixed header of every incoming D-Bus message before the +// decoder can observe it. godbus otherwise accepts messages up to 128 MiB and +// allocates the complete body before application-level field validation. +type boundedDBusConn struct { + io.ReadWriteCloser + + binaryMode atomic.Bool + authBytes int + authLine int + header [16]byte + headerOff int + remaining uint64 +} + +func (c *boundedDBusConn) Write(data []byte) (int, error) { + if bytes.Equal(data, dbusAuthBegin) { + // Auth writes BEGIN immediately before starting the binary message reader. + // Publish the mode first so a fast bus reply cannot race the transition. + c.binaryMode.Store(true) + } + return c.ReadWriteCloser.Write(data) +} + +func (c *boundedDBusConn) Read(output []byte) (int, error) { + if len(output) == 0 { + return c.ReadWriteCloser.Read(output) + } + if !c.binaryMode.Load() { + remainingTotal := maxManagerDBusAuthBytes - c.authBytes + remainingLine := maxManagerDBusAuthBytes - c.authLine + if remainingTotal <= 0 || remainingLine <= 0 { + return 0, fmt.Errorf("systemd manager D-Bus authentication response exceeds %d bytes", maxManagerDBusAuthBytes) + } + limit := len(output) + if limit > remainingTotal { + limit = remainingTotal + } + if limit > remainingLine { + limit = remainingLine + } + n, err := c.ReadWriteCloser.Read(output[:limit]) + c.authBytes += n + for _, character := range output[:n] { + c.authLine++ + if character == '\n' { + c.authLine = 0 + } + } + return n, err + } + if c.headerOff == 0 && c.remaining == 0 { + if _, err := io.ReadFull(c.ReadWriteCloser, c.header[:]); err != nil { + return 0, err + } + total, err := boundedDBusMessageSize(c.header[:]) + if err != nil { + return 0, err + } + c.remaining = total - uint64(len(c.header)) + } + if c.headerOff < len(c.header) { + n := copy(output, c.header[c.headerOff:]) + c.headerOff += n + if c.headerOff == len(c.header) && c.remaining == 0 { + c.headerOff = 0 + } + return n, nil + } + + limit := len(output) + if uint64(limit) > c.remaining { + limit = int(c.remaining) + } + n, err := c.ReadWriteCloser.Read(output[:limit]) + if uint64(n) > c.remaining { + return 0, fmt.Errorf("systemd manager D-Bus reader crossed a message boundary") + } + c.remaining -= uint64(n) + if c.remaining == 0 { + c.headerOff = 0 + } + return n, err +} + +func boundedDBusMessageSize(header []byte) (uint64, error) { + if len(header) != 16 { + return 0, fmt.Errorf("systemd manager D-Bus fixed header has %d bytes; expected 16", len(header)) + } + var order binary.ByteOrder + switch header[0] { + case 'l': + order = binary.LittleEndian + case 'B': + order = binary.BigEndian + default: + return 0, fmt.Errorf("systemd manager D-Bus message has invalid byte order") + } + bodySize := uint64(order.Uint32(header[4:8])) + headerFieldsSize := uint64(order.Uint32(header[12:16])) + paddedHeaderFieldsSize := (headerFieldsSize + 7) &^ uint64(7) + total := uint64(len(header)) + paddedHeaderFieldsSize + bodySize + if total > maxManagerDBusMessageSize { + return 0, fmt.Errorf("systemd manager D-Bus message exceeds %d bytes", maxManagerDBusMessageSize) + } + return total, nil +} diff --git a/internal/systemd/manager_dbus_limit_test.go b/internal/systemd/manager_dbus_limit_test.go new file mode 100644 index 00000000..19fb3ff3 --- /dev/null +++ b/internal/systemd/manager_dbus_limit_test.go @@ -0,0 +1,111 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package systemd + +import ( + "bytes" + "encoding/binary" + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type memoryReadWriteCloser struct { + reader *bytes.Reader + writes bytes.Buffer +} + +func (m *memoryReadWriteCloser) Read(output []byte) (int, error) { + return m.reader.Read(output) +} + +func (m *memoryReadWriteCloser) Write(data []byte) (int, error) { + return m.writes.Write(data) +} + +func (*memoryReadWriteCloser) Close() error { return nil } + +func dbusTestFrame(order binary.ByteOrder, body []byte) []byte { + header := make([]byte, 16) + if order == binary.LittleEndian { + header[0] = 'l' + } else { + header[0] = 'B' + } + header[1] = 2 + header[3] = 1 + order.PutUint32(header[4:8], uint32(len(body))) + order.PutUint32(header[8:12], 1) + return append(header, body...) +} + +func TestBoundedDBusConnPassesAuthenticationThenBoundsFrames(t *testing.T) { + first := dbusTestFrame(binary.LittleEndian, []byte("first")) + second := dbusTestFrame(binary.BigEndian, []byte("second")) + transport := &memoryReadWriteCloser{reader: bytes.NewReader(append([]byte("OK abcdef\r\n"), append(first, second...)...))} + connection := &boundedDBusConn{ReadWriteCloser: transport} + + auth := make([]byte, len("OK abcdef\r\n")) + _, err := io.ReadFull(connection, auth) + require.NoError(t, err) + assert.Equal(t, "OK abcdef\r\n", string(auth)) + _, err = connection.Write(dbusAuthBegin) + require.NoError(t, err) + + frames, err := io.ReadAll(connection) + require.NoError(t, err) + assert.Equal(t, append(first, second...), frames) + assert.Equal(t, dbusAuthBegin, transport.writes.Bytes()) +} + +func TestBoundedDBusConnRejectsOversizedFrameBeforeExposingHeader(t *testing.T) { + header := dbusTestFrame(binary.LittleEndian, nil)[:16] + binary.LittleEndian.PutUint32(header[4:8], maxManagerDBusMessageSize) + transport := &memoryReadWriteCloser{reader: bytes.NewReader(header)} + connection := &boundedDBusConn{ReadWriteCloser: transport} + _, err := connection.Write(dbusAuthBegin) + require.NoError(t, err) + + output := make([]byte, 16) + n, err := connection.Read(output) + require.Error(t, err) + assert.Zero(t, n) + assert.Contains(t, err.Error(), "exceeds") +} + +func TestBoundedDBusConnRejectsInvalidEndianBeforeDecode(t *testing.T) { + header := make([]byte, 16) + header[0] = 'x' + transport := &memoryReadWriteCloser{reader: bytes.NewReader(header)} + connection := &boundedDBusConn{ReadWriteCloser: transport} + _, err := connection.Write(dbusAuthBegin) + require.NoError(t, err) + + _, err = connection.Read(make([]byte, 1)) + require.Error(t, err) + assert.Contains(t, err.Error(), "byte order") +} + +func TestBoundedDBusConnBoundsAuthenticationInput(t *testing.T) { + transport := &memoryReadWriteCloser{reader: bytes.NewReader(bytes.Repeat([]byte{'x'}, maxManagerDBusAuthBytes+1))} + connection := &boundedDBusConn{ReadWriteCloser: transport} + + data, err := io.ReadAll(connection) + require.Error(t, err) + assert.Len(t, data, maxManagerDBusAuthBytes) + assert.Contains(t, err.Error(), "authentication response exceeds") +} + +func TestBoundedDBusMessageSizeIncludesHeaderPadding(t *testing.T) { + header := dbusTestFrame(binary.LittleEndian, nil)[:16] + binary.LittleEndian.PutUint32(header[4:8], 7) + binary.LittleEndian.PutUint32(header[12:16], 9) + total, err := boundedDBusMessageSize(header) + require.NoError(t, err) + assert.Equal(t, uint64(16+16+7), total) +} diff --git a/internal/systemd/manager_incoming.go b/internal/systemd/manager_incoming.go new file mode 100644 index 00000000..96e71df2 --- /dev/null +++ b/internal/systemd/manager_incoming.go @@ -0,0 +1,27 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package systemd + +import ( + "io" + "sync" + + "github.com/godbus/dbus/v5" +) + +// rejectManagerInboundMethodCalls closes the transport before godbus dispatches +// an unsolicited method call. This client exports no objects; accepting method +// calls would only expose godbus's goroutine-per-call server path to an +// untrusted peer. The current call may still reach a single fail-closed handler, +// but closing the transport prevents any subsequent call from being decoded. +func rejectManagerInboundMethodCalls(transport io.Closer) dbus.Interceptor { + var closeOnce sync.Once + return func(message *dbus.Message) { + if message != nil && message.Type == dbus.TypeMethodCall { + closeOnce.Do(func() { _ = transport.Close() }) + } + } +} diff --git a/internal/systemd/manager_incoming_test.go b/internal/systemd/manager_incoming_test.go new file mode 100644 index 00000000..ced0f9cf --- /dev/null +++ b/internal/systemd/manager_incoming_test.go @@ -0,0 +1,35 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package systemd + +import ( + "testing" + + "github.com/godbus/dbus/v5" + "github.com/stretchr/testify/assert" +) + +type countingManagerCloser struct { + closes int +} + +func (c *countingManagerCloser) Close() error { + c.closes++ + return nil +} + +func TestRejectManagerInboundMethodCallsClosesOnce(t *testing.T) { + transport := &countingManagerCloser{} + intercept := rejectManagerInboundMethodCalls(transport) + intercept(nil) + intercept(&dbus.Message{Type: dbus.TypeSignal}) + intercept(&dbus.Message{Type: dbus.TypeMethodReply}) + assert.Zero(t, transport.closes) + + intercept(&dbus.Message{Type: dbus.TypeMethodCall}) + intercept(&dbus.Message{Type: dbus.TypeMethodCall}) + assert.Equal(t, 1, transport.closes) +} diff --git a/internal/systemd/manager_linux.go b/internal/systemd/manager_linux.go new file mode 100644 index 00000000..867b4f30 --- /dev/null +++ b/internal/systemd/manager_linux.go @@ -0,0 +1,243 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build linux + +package systemd + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/DataDog/rshell/builtins" + "github.com/godbus/dbus/v5" + "golang.org/x/sys/unix" +) + +const ( + managerOperationTimeout = 30 * time.Second + managerBusFDDir = "/proc/self/fd" +) + +type dbusManagerBus struct { + connection *dbus.Conn + signalHandler *boundedManagerSignalHandler +} + +func (c *Client) ListSystemServices(ctx context.Context, request builtins.SystemServiceListRequest) ([]builtins.SystemServiceState, error) { + if err := validateManagerUnits(request.Services, true); err != nil { + return nil, err + } + if len(request.Services) == 0 { + return []builtins.SystemServiceState{}, nil + } + var states []builtins.SystemServiceState + err := c.withManagerBus(ctx, func(ctx context.Context, bus *dbusManagerBus) error { + var err error + states, err = listSystemServicesWithBus(ctx, bus, request) + return err + }) + return states, err +} + +func (c *Client) InspectSystemServices(ctx context.Context, units []string) ([]builtins.SystemServiceState, error) { + if err := validateManagerUnits(units, false); err != nil { + return nil, err + } + var states []builtins.SystemServiceState + err := c.withManagerBus(ctx, func(ctx context.Context, bus *dbusManagerBus) error { + var err error + states, err = inspectSystemServicesWithBus(ctx, bus, units) + return err + }) + return states, err +} + +func (c *Client) SystemServiceEnabledState(ctx context.Context, units []string) ([]string, error) { + if err := validateManagerUnits(units, false); err != nil { + return nil, err + } + var states []string + err := c.withManagerBus(ctx, func(ctx context.Context, bus *dbusManagerBus) error { + var err error + states, err = systemServiceEnabledStateWithBus(ctx, bus, units) + return err + }) + return states, err +} + +func (c *Client) RunSystemServiceJobs(ctx context.Context, action builtins.SystemServiceJobAction, units []string) error { + if err := validateManagerUnits(units, false); err != nil { + return err + } + if _, ok := managerJobMethod(action); !ok { + return fmt.Errorf("unsupported systemd manager job action %q", action) + } + return c.withManagerBus(ctx, func(ctx context.Context, bus *dbusManagerBus) error { + return runSystemServiceJobsWithBus(ctx, bus, action, units) + }) +} + +func (c *Client) ResetFailedSystemServices(ctx context.Context, units []string) error { + if err := validateManagerUnits(units, false); err != nil { + return err + } + return c.withManagerBus(ctx, func(ctx context.Context, bus *dbusManagerBus) error { + return resetFailedSystemServicesWithBus(ctx, bus, units) + }) +} + +func (c *Client) EnableSystemServices(ctx context.Context, units []string) error { + if err := validateManagerUnits(units, false); err != nil { + return err + } + return c.withManagerBus(ctx, func(ctx context.Context, bus *dbusManagerBus) error { + return enableSystemServicesWithBus(ctx, bus, units) + }) +} + +func (c *Client) DisableSystemServices(ctx context.Context, units []string) error { + if err := validateManagerUnits(units, false); err != nil { + return err + } + return c.withManagerBus(ctx, func(ctx context.Context, bus *dbusManagerBus) error { + return disableSystemServicesWithBus(ctx, bus, units) + }) +} + +func (c *Client) withManagerBus(ctx context.Context, operation func(context.Context, *dbusManagerBus) error) error { + if err := ctx.Err(); err != nil { + return err + } + operationCtx, cancel := context.WithTimeout(ctx, managerOperationTimeout) + defer cancel() + bus, err := c.openManagerBus(operationCtx) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil { + return fmt.Errorf("systemd manager operation timed out after %s: %w", managerOperationTimeout, err) + } + return err + } + defer bus.connection.Close() + if err := operation(operationCtx, bus); err != nil { + if errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil { + return fmt.Errorf("systemd manager operation timed out after %s: %w", managerOperationTimeout, err) + } + return err + } + return nil +} + +func (c *Client) openManagerBus(ctx context.Context) (*dbusManagerBus, error) { + if c.target.MachineIDPath == "" { + return nil, fmt.Errorf("systemd target machine ID path is unavailable") + } + expectedMachineID, err := c.readMachineID() + if err != nil { + return nil, fmt.Errorf("validate systemd target machine ID: %w", err) + } + if c.target.ManagerBusSocket == "" { + return nil, fmt.Errorf("systemd target manager bus socket is unavailable") + } + + networkConnection, err := c.dialManagerBus(ctx, c.target.ManagerBusSocket) + if err != nil { + return nil, err + } + if deadline, ok := ctx.Deadline(); ok { + if err := networkConnection.SetDeadline(deadline); err != nil { + networkConnection.Close() + return nil, fmt.Errorf("set systemd manager bus deadline: %w", err) + } + } + bounded := &boundedDBusConn{ReadWriteCloser: networkConnection} + signalHandler := newBoundedManagerSignalHandler() + connection, err := dbus.NewConn( + bounded, + dbus.WithContext(ctx), + dbus.WithSignalHandler(signalHandler), + dbus.WithIncomingInterceptor(rejectManagerInboundMethodCalls(bounded)), + ) + if err != nil { + networkConnection.Close() + return nil, fmt.Errorf("create systemd manager D-Bus connection: %w", err) + } + closeOnError := func(err error) (*dbusManagerBus, error) { + connection.Close() + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, err + } + if err := connection.Auth([]dbus.Auth{dbus.AuthExternal(strconv.Itoa(os.Geteuid()))}); err != nil { + return closeOnError(fmt.Errorf("authenticate systemd manager D-Bus connection: %w", err)) + } + if err := connection.Hello(); err != nil { + return closeOnError(fmt.Errorf("initialize systemd manager D-Bus connection: %w", err)) + } + + bus := &dbusManagerBus{connection: connection, signalHandler: signalHandler} + if err := verifySystemdManagerMachineID(ctx, bus, expectedMachineID); err != nil { + return closeOnError(err) + } + return bus, nil +} + +func (c *Client) dialManagerBus(ctx context.Context, path string) (net.Conn, error) { + socket, err := c.openTargetFileFlags(path, unix.O_PATH|unix.O_NOFOLLOW) + if err != nil { + return nil, fmt.Errorf("inspect systemd manager bus socket: %w", err) + } + defer socket.Close() + info, err := socket.Stat() + if err != nil { + return nil, fmt.Errorf("inspect systemd manager bus socket: %w", err) + } + if info.Mode()&os.ModeSocket == 0 { + return nil, fmt.Errorf("systemd manager bus endpoint is not a Unix socket") + } + endpoint := filepath.Join(managerBusFDDir, strconv.Itoa(int(socket.Fd()))) + var dialer net.Dialer + connection, err := dialer.DialContext(ctx, "unix", endpoint) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("connect to pinned systemd manager bus socket: %w", err) + } + return connection, nil +} + +func (b *dbusManagerBus) call(ctx context.Context, destination string, path dbus.ObjectPath, method string, arguments ...any) ([]any, error) { + call := b.connection.Object(destination, path).CallWithContext(ctx, method, dbus.FlagNoAutoStart, arguments...) + if call.Err != nil { + return nil, call.Err + } + return call.Body, nil +} + +func (b *dbusManagerBus) addJobRemovedMatch(ctx context.Context) error { + return b.connection.AddMatchSignalContext(ctx, + dbus.WithMatchSender(systemdBusDestination), + dbus.WithMatchObjectPath(systemdManagerPath), + dbus.WithMatchInterface(systemdManagerIface), + dbus.WithMatchMember("JobRemoved"), + ) +} + +func (b *dbusManagerBus) registerSignals(channel chan<- *dbus.Signal) <-chan struct{} { + b.connection.Signal(channel) + return b.signalHandler.Overflow() +} + +func (b *dbusManagerBus) removeSignals(channel chan<- *dbus.Signal) { + b.connection.RemoveSignal(channel) +} diff --git a/internal/systemd/manager_protocol.go b/internal/systemd/manager_protocol.go new file mode 100644 index 00000000..56d1d58d --- /dev/null +++ b/internal/systemd/manager_protocol.go @@ -0,0 +1,750 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package systemd + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "unicode" + "unicode/utf8" + + "github.com/DataDog/rshell/builtins" + "github.com/godbus/dbus/v5" +) + +const ( + systemdBusDestination = "org.freedesktop.systemd1" + systemdManagerPath = dbus.ObjectPath("/org/freedesktop/systemd1") + systemdManagerIface = "org.freedesktop.systemd1.Manager" + systemdUnitIface = "org.freedesktop.systemd1.Unit" + systemdServiceIface = "org.freedesktop.systemd1.Service" + dbusPeerGetMachineID = "org.freedesktop.DBus.Peer.GetMachineId" + dbusPropertiesGet = "org.freedesktop.DBus.Properties.Get" + + systemdUnitPathPrefix = "/org/freedesktop/systemd1/unit/" + systemdJobPathPrefix = "/org/freedesktop/systemd1/job/" + maxManagerObjectPath = 4096 + maxUnitFileChanges = 256 + maxManagerSignalQueue = 64 + maxManagerSignalsRead = 256 +) + +type managerBus interface { + call(ctx context.Context, destination string, path dbus.ObjectPath, method string, arguments ...any) ([]any, error) + addJobRemovedMatch(ctx context.Context) error + registerSignals(channel chan<- *dbus.Signal) <-chan struct{} + removeSignals(channel chan<- *dbus.Signal) +} + +func verifySystemdManagerMachineID(ctx context.Context, bus managerBus, expectedMachineID string) error { + body, err := bus.call(ctx, systemdBusDestination, systemdManagerPath, dbusPeerGetMachineID) + if err != nil { + return managerMethodError("Peer.GetMachineId", "", err) + } + var actualMachineID string + if err := storeManagerReply(body, &actualMachineID); err != nil { + return fmt.Errorf("systemd manager peer returned an invalid machine ID reply: %w", err) + } + if len(actualMachineID) != 32 || !validID128(actualMachineID) { + return fmt.Errorf("systemd manager peer returned an invalid machine ID") + } + if !strings.EqualFold(actualMachineID, expectedMachineID) { + return fmt.Errorf("systemd manager peer machine ID does not match the configured target") + } + return nil +} + +type unitJobProperty struct { + ID uint32 + Path dbus.ObjectPath +} + +type unitFileChange struct { + Type string + Destination string + Source string +} + +func listSystemServicesWithBus(ctx context.Context, bus managerBus, request builtins.SystemServiceListRequest) ([]builtins.SystemServiceState, error) { + if err := validateManagerUnits(request.Services, true); err != nil { + return nil, err + } + states := make([]builtins.SystemServiceState, 0, len(request.Services)) + for _, unit := range request.Services { + state, found, err := inspectSystemUnit(ctx, bus, unit, request.IncludeInactive, true, true, false) + if err != nil { + return nil, err + } + if found && (request.IncludeInactive || state.ActiveState != "inactive" || state.JobID != 0) { + states = append(states, state) + } + } + if len(states) > len(request.Services) { + return nil, fmt.Errorf("systemd manager returned more units than requested") + } + return states, nil +} + +func inspectSystemServicesWithBus(ctx context.Context, bus managerBus, units []string) ([]builtins.SystemServiceState, error) { + if err := validateManagerUnits(units, false); err != nil { + return nil, err + } + states := make([]builtins.SystemServiceState, 0, len(units)) + for _, unit := range units { + state, found, err := inspectSystemUnit(ctx, bus, unit, true, true, false, true) + if err != nil { + return nil, err + } + if !found { + state = builtins.SystemServiceState{ + Name: unit, + LoadState: "not-found", + ActiveState: "inactive", + SubState: "dead", + } + } + states = append(states, state) + } + if len(states) != len(units) { + return nil, fmt.Errorf("systemd manager returned an unexpected unit count") + } + return states, nil +} + +func systemServiceEnabledStateWithBus(ctx context.Context, bus managerBus, units []string) ([]string, error) { + if err := validateManagerUnits(units, false); err != nil { + return nil, err + } + states := make([]string, 0, len(units)) + for _, unit := range units { + body, err := bus.call(ctx, systemdBusDestination, systemdManagerPath, systemdManagerIface+".GetUnitFileState", unit) + if err != nil { + if isNoSuchUnitError(err) { + states = append(states, "not-found") + continue + } + return nil, managerMethodError("GetUnitFileState", unit, err) + } + var state string + if err := storeManagerReply(body, &state); err != nil { + return nil, fmt.Errorf("systemd manager GetUnitFileState returned an invalid reply for %q: %w", unit, err) + } + if err := validateManagerString("unit file state", state, false); err != nil { + return nil, fmt.Errorf("systemd manager returned an invalid enabled state for %q: %w", unit, err) + } + states = append(states, state) + } + if len(states) != len(units) { + return nil, fmt.Errorf("systemd manager returned an unexpected enabled-state count") + } + return states, nil +} + +func inspectSystemUnit(ctx context.Context, bus managerBus, selector string, load, allowMissing, includeJob, includeDetails bool) (builtins.SystemServiceState, bool, error) { + method := "GetUnit" + if load { + method = "LoadUnit" + } + body, err := bus.call(ctx, systemdBusDestination, systemdManagerPath, systemdManagerIface+"."+method, selector) + if err != nil { + if allowMissing && isNoSuchUnitError(err) { + return builtins.SystemServiceState{}, false, nil + } + return builtins.SystemServiceState{}, false, managerMethodError(method, selector, err) + } + var path dbus.ObjectPath + if err := storeManagerReply(body, &path); err != nil { + return builtins.SystemServiceState{}, false, fmt.Errorf("systemd manager %s returned an invalid reply for %q: %w", method, selector, err) + } + if err := validateManagerObjectPath("unit", path, systemdUnitPathPrefix); err != nil { + return builtins.SystemServiceState{}, false, err + } + + state := builtins.SystemServiceState{Name: selector} + if state.CanonicalName, err = managerStringProperty(ctx, bus, path, systemdUnitIface, "Id", false); err != nil { + return builtins.SystemServiceState{}, false, err + } + if err := validateManagerCanonicalUnit(state.CanonicalName); err != nil { + return builtins.SystemServiceState{}, false, fmt.Errorf("systemd manager returned an invalid canonical unit name: %w", err) + } + if state.Description, err = managerStringProperty(ctx, bus, path, systemdUnitIface, "Description", true); err != nil { + return builtins.SystemServiceState{}, false, err + } + if state.LoadState, err = managerStringProperty(ctx, bus, path, systemdUnitIface, "LoadState", false); err != nil { + return builtins.SystemServiceState{}, false, err + } + if state.ActiveState, err = managerStringProperty(ctx, bus, path, systemdUnitIface, "ActiveState", false); err != nil { + return builtins.SystemServiceState{}, false, err + } + if state.SubState, err = managerStringProperty(ctx, bus, path, systemdUnitIface, "SubState", false); err != nil { + return builtins.SystemServiceState{}, false, err + } + if state.LoadState == "not-found" { + return builtins.SystemServiceState{}, false, nil + } + if includeJob { + job, err := managerJobProperty(ctx, bus, path) + if err != nil { + return builtins.SystemServiceState{}, false, err + } + state.JobID = job.ID + if job.ID == 0 { + if job.Path != "/" { + return builtins.SystemServiceState{}, false, fmt.Errorf("systemd manager returned a zero job with an unexpected object path") + } + } else if err := validateManagerJobPath(job.Path, job.ID); err != nil { + return builtins.SystemServiceState{}, false, err + } + } + + if includeDetails { + if state.UnitFileState, err = managerStringProperty(ctx, bus, path, systemdUnitIface, "UnitFileState", true); err != nil { + return builtins.SystemServiceState{}, false, err + } + if resultInterface, ok := managerResultInterface(state.CanonicalName); ok { + if state.Result, err = managerStringProperty(ctx, bus, path, resultInterface, "Result", true); err != nil { + return builtins.SystemServiceState{}, false, err + } + } + } + if includeDetails && strings.HasSuffix(state.CanonicalName, ".service") { + if state.MainPID, err = managerUint32Property(ctx, bus, path, systemdServiceIface, "MainPID"); err != nil { + return builtins.SystemServiceState{}, false, err + } + if state.ExecMainCode, err = managerInt32Property(ctx, bus, path, systemdServiceIface, "ExecMainCode"); err != nil { + return builtins.SystemServiceState{}, false, err + } + if state.ExecMainStatus, err = managerInt32Property(ctx, bus, path, systemdServiceIface, "ExecMainStatus"); err != nil { + return builtins.SystemServiceState{}, false, err + } + } + return state, true, nil +} + +func managerResultInterface(unit string) (string, bool) { + switch { + case strings.HasSuffix(unit, ".service"): + return systemdServiceIface, true + case strings.HasSuffix(unit, ".socket"): + return "org.freedesktop.systemd1.Socket", true + case strings.HasSuffix(unit, ".mount"): + return "org.freedesktop.systemd1.Mount", true + case strings.HasSuffix(unit, ".automount"): + return "org.freedesktop.systemd1.Automount", true + case strings.HasSuffix(unit, ".timer"): + return "org.freedesktop.systemd1.Timer", true + case strings.HasSuffix(unit, ".swap"): + return "org.freedesktop.systemd1.Swap", true + case strings.HasSuffix(unit, ".path"): + return "org.freedesktop.systemd1.Path", true + case strings.HasSuffix(unit, ".scope"): + return "org.freedesktop.systemd1.Scope", true + default: + return "", false + } +} + +func runSystemServiceJobsWithBus(ctx context.Context, bus managerBus, action builtins.SystemServiceJobAction, units []string) error { + if err := validateManagerUnits(units, false); err != nil { + return err + } + method, ok := managerJobMethod(action) + if !ok { + return fmt.Errorf("unsupported systemd manager job action %q", action) + } + + signals := make(chan *dbus.Signal, maxManagerSignalQueue) + overflow := bus.registerSignals(signals) + defer bus.removeSignals(signals) + if err := bus.addJobRemovedMatch(ctx); err != nil { + return fmt.Errorf("subscribe to systemd manager job results: %w", err) + } + body, err := bus.call(ctx, systemdBusDestination, systemdManagerPath, systemdManagerIface+".Subscribe") + if err != nil { + return managerMethodError("Subscribe", "", err) + } + if err := storeManagerReply(body); err != nil { + return fmt.Errorf("systemd manager Subscribe returned an invalid reply: %w", err) + } + + for index, unit := range units { + body, err := bus.call(ctx, systemdBusDestination, systemdManagerPath, systemdManagerIface+"."+method, unit, "replace") + if err != nil { + if managerTryMethodIgnoresMasked(method, err) { + continue + } + methodErr := managerMethodError(method, unit, err) + if managerDBusErrorName(err) == "" { + methodErr = managerJobDeliveryUncertain(unit, methodErr) + } + return managerPartialProgress(units[:index], methodErr) + } + var jobPath dbus.ObjectPath + if err := storeManagerReply(body, &jobPath); err != nil { + return managerPartialProgress(units[:index], managerJobOutcomeUncertain(unit, fmt.Errorf("systemd manager %s returned an invalid reply: %w", method, err))) + } + if err := validateReturnedManagerJobPath(jobPath); err != nil { + return managerPartialProgress(units[:index], managerJobOutcomeUncertain(unit, err)) + } + if err := waitSystemdJob(ctx, signals, overflow, jobPath, unit); err != nil { + return managerPartialProgress(units[:index], managerJobOutcomeUncertain(unit, err)) + } + } + return nil +} + +func resetFailedSystemServicesWithBus(ctx context.Context, bus managerBus, units []string) error { + if err := validateManagerUnits(units, false); err != nil { + return err + } + for index, unit := range units { + body, err := bus.call(ctx, systemdBusDestination, systemdManagerPath, systemdManagerIface+".ResetFailedUnit", unit) + if err != nil { + methodErr := managerMethodError("ResetFailedUnit", unit, err) + if managerDBusErrorName(err) == "" { + methodErr = managerResetDeliveryUncertain(unit, methodErr) + } + return managerPartialProgress(units[:index], methodErr) + } + if err := storeManagerReply(body); err != nil { + return managerPartialProgress(units[:index+1], fmt.Errorf("systemd manager ResetFailedUnit returned an invalid reply for %q: %w", unit, err)) + } + } + return nil +} + +func enableSystemServicesWithBus(ctx context.Context, bus managerBus, units []string) error { + if err := validateManagerUnits(units, false); err != nil { + return err + } + body, err := bus.call(ctx, systemdBusDestination, systemdManagerPath, systemdManagerIface+".EnableUnitFiles", units, false, false) + if err != nil { + return fmt.Errorf("systemd manager EnableUnitFiles failed; unit-file changes may have been partially applied and were not rolled back: %w", managerMethodError("EnableUnitFiles", "", err)) + } + var carriesInstallInfo bool + var changes []unitFileChange + if err := storeManagerReply(body, &carriesInstallInfo, &changes); err != nil { + return unitFilePartialProgress(fmt.Errorf("systemd manager EnableUnitFiles returned an invalid reply: %w", err)) + } + if err := validateUnitFileChanges(changes); err != nil { + return unitFilePartialProgress(err) + } + if err := reloadSystemdManager(ctx, bus); err != nil { + return unitFilePartialProgress(err) + } + return nil +} + +func disableSystemServicesWithBus(ctx context.Context, bus managerBus, units []string) error { + if err := validateManagerUnits(units, false); err != nil { + return err + } + body, err := bus.call(ctx, systemdBusDestination, systemdManagerPath, systemdManagerIface+".DisableUnitFiles", units, false) + if err != nil { + return fmt.Errorf("systemd manager DisableUnitFiles failed; unit-file changes may have been partially applied and were not rolled back: %w", managerMethodError("DisableUnitFiles", "", err)) + } + var changes []unitFileChange + if err := storeManagerReply(body, &changes); err != nil { + return unitFilePartialProgress(fmt.Errorf("systemd manager DisableUnitFiles returned an invalid reply: %w", err)) + } + if err := validateUnitFileChanges(changes); err != nil { + return unitFilePartialProgress(err) + } + if err := reloadSystemdManager(ctx, bus); err != nil { + return unitFilePartialProgress(err) + } + return nil +} + +func reloadSystemdManager(ctx context.Context, bus managerBus) error { + body, err := bus.call(ctx, systemdBusDestination, systemdManagerPath, systemdManagerIface+".Reload") + if err != nil { + return managerMethodError("Reload", "", err) + } + if err := storeManagerReply(body); err != nil { + return fmt.Errorf("systemd manager Reload returned an invalid reply: %w", err) + } + return nil +} + +func unitFilePartialProgress(err error) error { + return fmt.Errorf("unit-file changes completed, but manager reload/validation failed; changes were not rolled back: %w", err) +} + +func managerPartialProgress(completed []string, err error) error { + if len(completed) == 0 { + return err + } + return fmt.Errorf("systemd manager operation stopped after completing %d units (%s); completed operations were not rolled back: %w", len(completed), strings.Join(completed, ", "), err) +} + +func managerJobOutcomeUncertain(unit string, err error) error { + return fmt.Errorf("systemd manager accepted a job for %q, but its final state is unknown and was not rolled back: %w", unit, err) +} + +func managerJobDeliveryUncertain(unit string, err error) error { + return fmt.Errorf("systemd manager may have accepted a job for %q, but its outcome is unknown and was not rolled back: %w", unit, err) +} + +func managerResetDeliveryUncertain(unit string, err error) error { + return fmt.Errorf("systemd manager may have reset the failure state for %q, but its outcome is unknown and was not rolled back: %w", unit, err) +} + +func managerJobMethod(action builtins.SystemServiceJobAction) (string, bool) { + switch action { + case builtins.SystemServiceJobStart: + return "StartUnit", true + case builtins.SystemServiceJobStop: + return "StopUnit", true + case builtins.SystemServiceJobReload: + return "ReloadUnit", true + case builtins.SystemServiceJobRestart: + return "RestartUnit", true + case builtins.SystemServiceJobTryRestart: + return "TryRestartUnit", true + case builtins.SystemServiceJobReloadOrRestart: + return "ReloadOrRestartUnit", true + case builtins.SystemServiceJobTryReloadOrRestart: + return "ReloadOrTryRestartUnit", true + default: + return "", false + } +} + +func managerTryMethodIgnoresMasked(method string, err error) bool { + return managerDBusErrorName(err) == "org.freedesktop.systemd1.UnitMasked" && + (method == "TryRestartUnit" || method == "ReloadOrTryRestartUnit") +} + +func waitSystemdJob(ctx context.Context, signals <-chan *dbus.Signal, overflow <-chan struct{}, expectedPath dbus.ObjectPath, selector string) error { + for examined := 0; examined < maxManagerSignalsRead; examined++ { + select { + case <-overflow: + return fmt.Errorf("systemd manager signal queue overflowed while waiting for %q", selector) + default: + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-overflow: + return fmt.Errorf("systemd manager signal queue overflowed while waiting for %q", selector) + case signal, ok := <-signals: + if !ok { + return fmt.Errorf("systemd manager connection closed while waiting for %q", selector) + } + if signal == nil { + return fmt.Errorf("systemd manager returned a nil job signal") + } + if signal.Name != systemdManagerIface+".JobRemoved" || signal.Path != systemdManagerPath { + continue + } + var jobID uint32 + var jobPath dbus.ObjectPath + var unit, result string + if err := dbus.Store(signal.Body, &jobID, &jobPath, &unit, &result); err != nil { + return fmt.Errorf("systemd manager JobRemoved signal has an invalid body: %w", err) + } + if err := validateManagerJobPath(jobPath, jobID); err != nil { + return err + } + if err := validateManagerCanonicalUnit(unit); err != nil { + return fmt.Errorf("systemd manager JobRemoved signal has an invalid unit: %w", err) + } + if err := validateManagerString("job result", result, false); err != nil { + return err + } + if jobPath != expectedPath { + continue + } + select { + case <-overflow: + return fmt.Errorf("systemd manager signal queue overflowed while waiting for %q", selector) + default: + } + if result != "done" && result != "skipped" { + return fmt.Errorf("systemd manager job for %q finished with result %q", selector, result) + } + return nil + } + } + return fmt.Errorf("systemd manager emitted too many unrelated job results while waiting for %q", selector) +} + +func managerStringProperty(ctx context.Context, bus managerBus, path dbus.ObjectPath, iface, property string, allowEmpty bool) (string, error) { + var value string + if err := managerProperty(ctx, bus, path, iface, property, &value); err != nil { + return "", err + } + if err := validateManagerString(property, value, allowEmpty); err != nil { + return "", fmt.Errorf("systemd manager returned an invalid %s property: %w", property, err) + } + return value, nil +} + +func managerUint32Property(ctx context.Context, bus managerBus, path dbus.ObjectPath, iface, property string) (uint32, error) { + var value uint32 + if err := managerProperty(ctx, bus, path, iface, property, &value); err != nil { + return 0, err + } + return value, nil +} + +func managerInt32Property(ctx context.Context, bus managerBus, path dbus.ObjectPath, iface, property string) (int32, error) { + var value int32 + if err := managerProperty(ctx, bus, path, iface, property, &value); err != nil { + return 0, err + } + return value, nil +} + +func managerJobProperty(ctx context.Context, bus managerBus, path dbus.ObjectPath) (unitJobProperty, error) { + var value unitJobProperty + if err := managerProperty(ctx, bus, path, systemdUnitIface, "Job", &value); err != nil { + return unitJobProperty{}, err + } + return value, nil +} + +func managerProperty(ctx context.Context, bus managerBus, path dbus.ObjectPath, iface, property string, destination any) error { + if err := validateManagerObjectPath("unit", path, systemdUnitPathPrefix); err != nil { + return err + } + body, err := bus.call(ctx, systemdBusDestination, path, dbusPropertiesGet, iface, property) + if err != nil { + return fmt.Errorf("read fixed systemd %s.%s property: %w", iface, property, managerMethodError("Properties.Get", "", err)) + } + if err := storeManagerReply(body, destination); err != nil { + return fmt.Errorf("systemd manager returned an invalid %s.%s property: %w", iface, property, err) + } + return nil +} + +func storeManagerReply(body []any, destinations ...any) error { + if len(body) != len(destinations) { + return fmt.Errorf("reply has %d values; expected %d", len(body), len(destinations)) + } + if len(destinations) == 0 { + return nil + } + return dbus.Store(body, destinations...) +} + +func validateManagerUnits(units []string, allowEmpty bool) error { + if len(units) == 0 && !allowEmpty { + return fmt.Errorf("at least one systemd unit is required") + } + if len(units) > builtins.MaxSystemServiceOperands { + return fmt.Errorf("systemd manager request has too many units (maximum %d)", builtins.MaxSystemServiceOperands) + } + seen := make(map[string]struct{}, len(units)) + for _, unit := range units { + if err := validateManagerUnit(unit); err != nil { + return err + } + if _, exists := seen[unit]; exists { + return fmt.Errorf("systemd manager request contains duplicate unit %q", unit) + } + seen[unit] = struct{}{} + } + return nil +} + +func validateManagerUnit(unit string) error { + if unit == "" { + return fmt.Errorf("systemd unit name must not be empty") + } + if len(unit) > builtins.MaxSystemServiceNameBytes { + return fmt.Errorf("systemd unit name exceeds %d bytes", builtins.MaxSystemServiceNameBytes) + } + if !utf8.ValidString(unit) { + return fmt.Errorf("systemd unit name must be valid UTF-8") + } + separator := strings.LastIndexByte(unit, '.') + if separator <= 0 || separator == len(unit)-1 || !validManagerUnitSuffix(unit[separator+1:]) { + return fmt.Errorf("systemd unit name %q must have a supported unit suffix", unit) + } + base := unit[:separator] + if strings.Count(base, "@") > 1 || strings.HasPrefix(base, "@") { + return fmt.Errorf("systemd unit name %q has an invalid instance separator", unit) + } + for _, character := range base { + if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' { + continue + } + switch character { + case '_', '-', '.', '@': + continue + } + return fmt.Errorf("systemd unit name %q contains an unsupported character", unit) + } + return nil +} + +func validateManagerCanonicalUnit(unit string) error { + if unit == "" { + return fmt.Errorf("systemd unit name must not be empty") + } + if len(unit) > builtins.MaxSystemServiceNameBytes { + return fmt.Errorf("systemd unit name exceeds %d bytes", builtins.MaxSystemServiceNameBytes) + } + if !utf8.ValidString(unit) { + return fmt.Errorf("systemd unit name must be valid UTF-8") + } + separator := strings.LastIndexByte(unit, '.') + if separator <= 0 || separator == len(unit)-1 || !validManagerUnitSuffix(unit[separator+1:]) { + return fmt.Errorf("systemd unit name %q must have a supported unit suffix", unit) + } + base := unit[:separator] + if strings.Count(base, "@") > 1 || strings.HasPrefix(base, "@") { + return fmt.Errorf("systemd unit name %q has an invalid instance separator", unit) + } + for index := 0; index < len(base); index++ { + character := base[index] + if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' { + continue + } + switch character { + case '_', '-', '.', '@', ':': + continue + case '\\': + if index+3 >= len(base) || base[index+1] != 'x' || !isASCIIHex(base[index+2]) || !isASCIIHex(base[index+3]) { + return fmt.Errorf("systemd unit name %q contains an invalid escape", unit) + } + index += 3 + continue + default: + return fmt.Errorf("systemd unit name %q contains an unsupported character", unit) + } + } + return nil +} + +func isASCIIHex(character byte) bool { + return character >= '0' && character <= '9' || character >= 'a' && character <= 'f' || character >= 'A' && character <= 'F' +} + +func validManagerUnitSuffix(suffix string) bool { + switch suffix { + case "service", "socket", "target", "device", "mount", "automount", "swap", "timer", "path", "slice", "scope": + return true + default: + return false + } +} + +func validateManagerString(name, value string, allowEmpty bool) error { + if value == "" && !allowEmpty { + return fmt.Errorf("%s must not be empty", name) + } + if len(value) > builtins.MaxSystemServiceFieldBytes { + return fmt.Errorf("%s exceeds %d bytes", name, builtins.MaxSystemServiceFieldBytes) + } + if !utf8.ValidString(value) { + return fmt.Errorf("%s is not valid UTF-8", name) + } + for _, character := range value { + if unicode.IsControl(character) { + return fmt.Errorf("%s contains a control character", name) + } + } + return nil +} + +func validateManagerObjectPath(name string, path dbus.ObjectPath, prefix string) error { + if len(path) > maxManagerObjectPath { + return fmt.Errorf("systemd manager returned an oversized %s object path", name) + } + suffix, hasPrefix := strings.CutPrefix(string(path), prefix) + if !path.IsValid() || !hasPrefix || suffix == "" || strings.ContainsRune(suffix, '/') { + return fmt.Errorf("systemd manager returned an invalid %s object path", name) + } + return nil +} + +func validateManagerJobPath(path dbus.ObjectPath, id uint32) error { + if err := validateManagerObjectPath("job", path, systemdJobPathPrefix); err != nil { + return err + } + parsed, err := strconv.ParseUint(strings.TrimPrefix(string(path), systemdJobPathPrefix), 10, 32) + if err != nil || uint32(parsed) != id || id == 0 { + return fmt.Errorf("systemd manager returned an invalid job object path") + } + return nil +} + +func validateReturnedManagerJobPath(path dbus.ObjectPath) error { + if err := validateManagerObjectPath("job", path, systemdJobPathPrefix); err != nil { + return err + } + parsed, err := strconv.ParseUint(strings.TrimPrefix(string(path), systemdJobPathPrefix), 10, 32) + if err != nil || parsed == 0 { + return fmt.Errorf("systemd manager returned an invalid job object path") + } + return nil +} + +func validateUnitFileChanges(changes []unitFileChange) error { + if len(changes) > maxUnitFileChanges { + return fmt.Errorf("systemd manager returned too many unit-file changes (maximum %d)", maxUnitFileChanges) + } + seen := make(map[unitFileChange]struct{}, len(changes)) + for _, change := range changes { + if change.Type != "symlink" && change.Type != "unlink" { + return fmt.Errorf("systemd manager returned an unsupported unit-file change type") + } + if err := validateManagerString("unit-file change destination", change.Destination, false); err != nil { + return err + } + if err := validateManagerString("unit-file change source", change.Source, change.Type == "unlink"); err != nil { + return err + } + if _, exists := seen[change]; exists { + return fmt.Errorf("systemd manager returned a duplicate unit-file change") + } + seen[change] = struct{}{} + } + return nil +} + +func isNoSuchUnitError(err error) bool { + switch managerDBusErrorName(err) { + case "org.freedesktop.systemd1.NoSuchUnit", "org.freedesktop.systemd1.NoSuchUnitFile": + return true + default: + return false + } +} + +func managerDBusErrorName(err error) string { + var value dbus.Error + if errors.As(err, &value) { + return value.Name + } + var pointer *dbus.Error + if errors.As(err, &pointer) && pointer != nil { + return pointer.Name + } + return "" +} + +func managerMethodError(method, unit string, err error) error { + if err == nil { + return nil + } + if name := managerDBusErrorName(err); name != "" { + if unit != "" { + return fmt.Errorf("systemd manager %s failed for %q: %s", method, unit, name) + } + return fmt.Errorf("systemd manager %s failed: %s", method, name) + } + if unit != "" { + return fmt.Errorf("systemd manager %s failed for %q: %w", method, unit, err) + } + return fmt.Errorf("systemd manager %s failed: %w", method, err) +} diff --git a/internal/systemd/manager_protocol_test.go b/internal/systemd/manager_protocol_test.go new file mode 100644 index 00000000..d9cbf4e1 --- /dev/null +++ b/internal/systemd/manager_protocol_test.go @@ -0,0 +1,517 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package systemd + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/DataDog/rshell/builtins" + "github.com/godbus/dbus/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeManagerCall struct { + destination string + path dbus.ObjectPath + method string + arguments []any +} + +type fakeManagerBus struct { + calls []fakeManagerCall + respond func(fakeManagerCall) ([]any, error) + matchErr error + signalSink chan<- *dbus.Signal + overflow chan struct{} +} + +func (b *fakeManagerBus) call(_ context.Context, destination string, path dbus.ObjectPath, method string, arguments ...any) ([]any, error) { + call := fakeManagerCall{destination: destination, path: path, method: method, arguments: append([]any(nil), arguments...)} + b.calls = append(b.calls, call) + if b.respond == nil { + return nil, nil + } + return b.respond(call) +} + +func (b *fakeManagerBus) addJobRemovedMatch(context.Context) error { + return b.matchErr +} + +func (b *fakeManagerBus) registerSignals(channel chan<- *dbus.Signal) <-chan struct{} { + b.signalSink = channel + if b.overflow == nil { + b.overflow = make(chan struct{}) + } + return b.overflow +} + +func (b *fakeManagerBus) removeSignals(channel chan<- *dbus.Signal) { + if b.signalSink == channel { + b.signalSink = nil + } +} + +func TestVerifySystemdManagerMachineIDUsesManagerPeer(t *testing.T) { + expectedMachineID := "0123456789abcdef0123456789abcdef" + bus := &fakeManagerBus{respond: func(call fakeManagerCall) ([]any, error) { + assert.Equal(t, "org.freedesktop.systemd1", call.destination) + assert.Equal(t, dbus.ObjectPath("/org/freedesktop/systemd1"), call.path) + assert.Equal(t, "org.freedesktop.DBus.Peer.GetMachineId", call.method) + assert.Empty(t, call.arguments) + return []any{strings.ToUpper(expectedMachineID)}, nil + }} + + require.NoError(t, verifySystemdManagerMachineID(context.Background(), bus, expectedMachineID)) + require.Len(t, bus.calls, 1) +} + +func TestVerifySystemdManagerMachineIDRejectsMismatch(t *testing.T) { + bus := &fakeManagerBus{respond: func(fakeManagerCall) ([]any, error) { + return []any{"fedcba9876543210fedcba9876543210"}, nil + }} + + err := verifySystemdManagerMachineID(context.Background(), bus, "0123456789abcdef0123456789abcdef") + require.EqualError(t, err, "systemd manager peer machine ID does not match the configured target") +} + +type fakeManagerUnit struct { + selector string + canonical string + description string + loadState string + activeState string + subState string + unitFileState string + jobID uint32 + mainPID uint32 + result string + execMainCode int32 + execMainStatus int32 +} + +func fakeManagerStateBus(units ...fakeManagerUnit) *fakeManagerBus { + bySelector := make(map[string]fakeManagerUnit, len(units)) + byPath := make(map[dbus.ObjectPath]fakeManagerUnit, len(units)) + for index, unit := range units { + bySelector[unit.selector] = unit + byPath[dbus.ObjectPath(fmt.Sprintf("%sunit_%d", systemdUnitPathPrefix, index+1))] = unit + } + bus := &fakeManagerBus{} + bus.respond = func(call fakeManagerCall) ([]any, error) { + switch call.method { + case systemdManagerIface + ".GetUnit", systemdManagerIface + ".LoadUnit": + selector := call.arguments[0].(string) + unit, found := bySelector[selector] + if !found { + return nil, dbus.Error{Name: "org.freedesktop.systemd1.NoSuchUnit"} + } + for path, candidate := range byPath { + if candidate.selector == unit.selector { + return []any{path}, nil + } + } + case dbusPropertiesGet: + unit, found := byPath[call.path] + if !found { + return nil, errors.New("unexpected unit object path") + } + property := call.arguments[1].(string) + var value any + switch property { + case "Id": + value = unit.canonical + case "Description": + value = unit.description + case "LoadState": + value = unit.loadState + case "ActiveState": + value = unit.activeState + case "SubState": + value = unit.subState + case "UnitFileState": + value = unit.unitFileState + case "Job": + path := dbus.ObjectPath("/") + if unit.jobID != 0 { + path = dbus.ObjectPath(fmt.Sprintf("%s%d", systemdJobPathPrefix, unit.jobID)) + } + value = unitJobProperty{ID: unit.jobID, Path: path} + case "MainPID": + value = unit.mainPID + case "Result": + value = unit.result + case "ExecMainCode": + value = unit.execMainCode + case "ExecMainStatus": + value = unit.execMainStatus + default: + return nil, fmt.Errorf("unexpected property %q", property) + } + return []any{dbus.MakeVariant(value)}, nil + } + return nil, fmt.Errorf("unexpected manager method %q", call.method) + } + return bus +} + +func TestListSystemServicesUsesOnlyFixedMinimalPropertiesAndFiltersInactive(t *testing.T) { + bus := fakeManagerStateBus( + fakeManagerUnit{selector: "active.service", canonical: "active.service", description: "active", loadState: "loaded", activeState: "active", subState: "running"}, + fakeManagerUnit{selector: "idle.timer", canonical: "idle.timer", description: "idle", loadState: "loaded", activeState: "inactive", subState: "dead"}, + fakeManagerUnit{selector: "queued.socket", canonical: "queued.socket", description: "queued", loadState: "loaded", activeState: "inactive", subState: "dead", jobID: 42}, + ) + + states, err := listSystemServicesWithBus(context.Background(), bus, builtins.SystemServiceListRequest{ + Services: []string{"active.service", "idle.timer", "queued.socket", "missing.path"}, + }) + require.NoError(t, err) + require.Len(t, states, 2) + assert.Equal(t, "active.service", states[0].Name) + assert.Equal(t, "queued.socket", states[1].Name) + assert.Equal(t, uint32(42), states[1].JobID) + + for _, call := range bus.calls { + if call.method != dbusPropertiesGet { + continue + } + property := call.arguments[1].(string) + assert.Contains(t, []string{"Id", "Description", "LoadState", "ActiveState", "SubState", "Job"}, property) + } +} + +func TestListSystemServicesAllLoadsInactiveAndPreservesAuthorizedAlias(t *testing.T) { + bus := fakeManagerStateBus(fakeManagerUnit{ + selector: "alias.timer", + canonical: `real\x2dname:tenant.timer`, + description: "timer", + loadState: "loaded", + activeState: "inactive", + subState: "dead", + }) + states, err := listSystemServicesWithBus(context.Background(), bus, builtins.SystemServiceListRequest{ + Services: []string{"alias.timer", "missing.timer"}, + IncludeInactive: true, + }) + require.NoError(t, err) + require.Len(t, states, 1) + assert.Equal(t, "alias.timer", states[0].Name) + assert.Equal(t, `real\x2dname:tenant.timer`, states[0].CanonicalName) + assert.Equal(t, systemdManagerIface+".LoadUnit", bus.calls[0].method) +} + +func TestInspectSystemServicesReturnsDetailsAndSynthesizesNotFound(t *testing.T) { + bus := fakeManagerStateBus(fakeManagerUnit{ + selector: "api.service", + canonical: "api.service", + description: "API", + loadState: "loaded", + activeState: "failed", + subState: "failed", + unitFileState: "enabled", + mainPID: 123, + result: "exit-code", + execMainCode: 1, + execMainStatus: 2, + }) + states, err := inspectSystemServicesWithBus(context.Background(), bus, []string{"api.service", "missing.service"}) + require.NoError(t, err) + require.Len(t, states, 2) + assert.Equal(t, uint32(123), states[0].MainPID) + assert.Equal(t, "enabled", states[0].UnitFileState) + assert.Equal(t, builtins.SystemServiceState{ + Name: "missing.service", + LoadState: "not-found", + ActiveState: "inactive", + SubState: "dead", + }, states[1]) + for _, call := range bus.calls { + if call.method == dbusPropertiesGet { + assert.NotEqual(t, "Job", call.arguments[1]) + } + } +} + +func TestInspectSystemServicesUsesTypeSpecificResultInterface(t *testing.T) { + bus := fakeManagerStateBus( + fakeManagerUnit{selector: "backup.timer", canonical: "backup.timer", loadState: "loaded", activeState: "inactive", subState: "dead", result: "success"}, + fakeManagerUnit{selector: "backup.target", canonical: "backup.target", loadState: "loaded", activeState: "active", subState: "active"}, + ) + states, err := inspectSystemServicesWithBus(context.Background(), bus, []string{"backup.timer", "backup.target"}) + require.NoError(t, err) + require.Len(t, states, 2) + assert.Equal(t, "success", states[0].Result) + assert.Empty(t, states[1].Result) + + var resultCalls []fakeManagerCall + for _, call := range bus.calls { + if call.method == dbusPropertiesGet && call.arguments[1] == "Result" { + resultCalls = append(resultCalls, call) + } + } + require.Len(t, resultCalls, 1) + assert.Equal(t, "org.freedesktop.systemd1.Timer", resultCalls[0].arguments[0]) +} + +func TestManagerResultInterfaceIsFixedBySupportedUnitType(t *testing.T) { + tests := map[string]string{ + "api.service": "org.freedesktop.systemd1.Service", + "api.socket": "org.freedesktop.systemd1.Socket", + "data.mount": "org.freedesktop.systemd1.Mount", + "data.automount": "org.freedesktop.systemd1.Automount", + "backup.timer": "org.freedesktop.systemd1.Timer", + "scratch.swap": "org.freedesktop.systemd1.Swap", + "incoming.path": "org.freedesktop.systemd1.Path", + "session-1.scope": "org.freedesktop.systemd1.Scope", + "multi-user.target": "", + "dev-null.device": "", + "system.slice": "", + } + for unit, expected := range tests { + actual, ok := managerResultInterface(unit) + assert.Equal(t, expected != "", ok, unit) + assert.Equal(t, expected, actual, unit) + } +} + +func TestSystemServiceEnabledStateTranslatesNoSuchUnit(t *testing.T) { + bus := &fakeManagerBus{} + bus.respond = func(call fakeManagerCall) ([]any, error) { + unit := call.arguments[0].(string) + if unit == "missing.timer" { + return nil, dbus.Error{Name: "org.freedesktop.systemd1.NoSuchUnitFile"} + } + return []any{"enabled"}, nil + } + states, err := systemServiceEnabledStateWithBus(context.Background(), bus, []string{"api.service", "missing.timer"}) + require.NoError(t, err) + assert.Equal(t, []string{"enabled", "not-found"}, states) +} + +func TestRunSystemServiceJobsUsesFixedMethodAndWaitsForMatchingResult(t *testing.T) { + bus := &fakeManagerBus{} + bus.respond = func(call fakeManagerCall) ([]any, error) { + switch call.method { + case systemdManagerIface + ".Subscribe": + return nil, nil + case systemdManagerIface + ".ReloadOrTryRestartUnit": + require.Equal(t, []any{"api.service", "replace"}, call.arguments) + bus.signalSink <- &dbus.Signal{ + Path: systemdManagerPath, + Name: systemdManagerIface + ".JobRemoved", + Body: []any{uint32(77), dbus.ObjectPath(systemdJobPathPrefix + "77"), `real\x2dapi.service`, "done"}, + } + return []any{dbus.ObjectPath(systemdJobPathPrefix + "77")}, nil + default: + return nil, fmt.Errorf("unexpected method %q", call.method) + } + } + err := runSystemServiceJobsWithBus(context.Background(), bus, builtins.SystemServiceJobTryReloadOrRestart, []string{"api.service"}) + require.NoError(t, err) + assert.Nil(t, bus.signalSink) +} + +func TestRunSystemServiceJobsAcceptsSkippedResult(t *testing.T) { + bus := &fakeManagerBus{} + bus.respond = func(call fakeManagerCall) ([]any, error) { + switch call.method { + case systemdManagerIface + ".Subscribe": + return nil, nil + case systemdManagerIface + ".TryRestartUnit": + bus.signalSink <- &dbus.Signal{ + Path: systemdManagerPath, + Name: systemdManagerIface + ".JobRemoved", + Body: []any{uint32(78), dbus.ObjectPath(systemdJobPathPrefix + "78"), "api.service", "skipped"}, + } + return []any{dbus.ObjectPath(systemdJobPathPrefix + "78")}, nil + default: + return nil, fmt.Errorf("unexpected method %q", call.method) + } + } + require.NoError(t, runSystemServiceJobsWithBus(context.Background(), bus, builtins.SystemServiceJobTryRestart, []string{"api.service"})) +} + +func TestRunSystemServiceTryJobsIgnoreMaskedUnits(t *testing.T) { + tests := []struct { + name string + action builtins.SystemServiceJobAction + method string + }{ + {name: "try restart", action: builtins.SystemServiceJobTryRestart, method: "TryRestartUnit"}, + {name: "try reload or restart", action: builtins.SystemServiceJobTryReloadOrRestart, method: "ReloadOrTryRestartUnit"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + bus := &fakeManagerBus{} + bus.respond = func(call fakeManagerCall) ([]any, error) { + switch call.method { + case systemdManagerIface + ".Subscribe": + return nil, nil + case systemdManagerIface + "." + test.method: + return nil, dbus.Error{Name: "org.freedesktop.systemd1.UnitMasked"} + default: + return nil, fmt.Errorf("unexpected method %q", call.method) + } + } + require.NoError(t, runSystemServiceJobsWithBus(context.Background(), bus, test.action, []string{"api.service"})) + }) + } +} + +func TestRunSystemServiceJobsReportsPartialProgress(t *testing.T) { + bus := &fakeManagerBus{} + bus.respond = func(call fakeManagerCall) ([]any, error) { + switch call.method { + case systemdManagerIface + ".Subscribe": + return nil, nil + case systemdManagerIface + ".RestartUnit": + unit := call.arguments[0].(string) + if unit == "second.service" { + return nil, errors.New("rejected") + } + bus.signalSink <- &dbus.Signal{ + Path: systemdManagerPath, + Name: systemdManagerIface + ".JobRemoved", + Body: []any{uint32(1), dbus.ObjectPath(systemdJobPathPrefix + "1"), unit, "done"}, + } + return []any{dbus.ObjectPath(systemdJobPathPrefix + "1")}, nil + default: + return nil, fmt.Errorf("unexpected method %q", call.method) + } + } + err := runSystemServiceJobsWithBus(context.Background(), bus, builtins.SystemServiceJobRestart, []string{"first.service", "second.service"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "after completing 1 units (first.service)") + assert.Contains(t, err.Error(), "not rolled back") +} + +func TestRunSystemServiceJobsReportsAcceptedJobWithUnknownOutcome(t *testing.T) { + bus := &fakeManagerBus{} + bus.respond = func(call fakeManagerCall) ([]any, error) { + switch call.method { + case systemdManagerIface + ".Subscribe": + return nil, nil + case systemdManagerIface + ".StartUnit": + return []any{"not-an-object-path"}, nil + default: + return nil, fmt.Errorf("unexpected method %q", call.method) + } + } + err := runSystemServiceJobsWithBus(context.Background(), bus, builtins.SystemServiceJobStart, []string{"api.service"}) + require.Error(t, err) + assert.Contains(t, err.Error(), `accepted a job for "api.service"`) + assert.Contains(t, err.Error(), "final state is unknown") + assert.Contains(t, err.Error(), "not rolled back") +} + +func TestRunSystemServiceJobsDistinguishesTransportAmbiguityFromDBusRejection(t *testing.T) { + tests := []struct { + name string + callErr error + wantUncertain bool + }{ + {name: "transport", callErr: context.DeadlineExceeded, wantUncertain: true}, + {name: "dbus rejection", callErr: dbus.Error{Name: "org.freedesktop.systemd1.UnitMasked"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + bus := &fakeManagerBus{} + bus.respond = func(call fakeManagerCall) ([]any, error) { + if call.method == systemdManagerIface+".Subscribe" { + return nil, nil + } + return nil, test.callErr + } + err := runSystemServiceJobsWithBus(context.Background(), bus, builtins.SystemServiceJobStart, []string{"api.service"}) + require.Error(t, err) + assert.Equal(t, test.wantUncertain, strings.Contains(err.Error(), "may have accepted a job")) + }) + } +} + +func TestRunSystemServiceJobsReportsSignalOverflowAsUnknownOutcome(t *testing.T) { + bus := &fakeManagerBus{overflow: make(chan struct{})} + bus.respond = func(call fakeManagerCall) ([]any, error) { + switch call.method { + case systemdManagerIface + ".Subscribe": + return nil, nil + case systemdManagerIface + ".StartUnit": + close(bus.overflow) + return []any{dbus.ObjectPath(systemdJobPathPrefix + "88")}, nil + default: + return nil, fmt.Errorf("unexpected method %q", call.method) + } + } + err := runSystemServiceJobsWithBus(context.Background(), bus, builtins.SystemServiceJobStart, []string{"api.service"}) + require.Error(t, err) + assert.Contains(t, err.Error(), `accepted a job for "api.service"`) + assert.Contains(t, err.Error(), "signal queue overflowed") + assert.Contains(t, err.Error(), "final state is unknown") +} + +func TestResetFailedSystemServicesReportsAmbiguousTransportFailure(t *testing.T) { + bus := &fakeManagerBus{respond: func(fakeManagerCall) ([]any, error) { + return nil, errors.New("connection closed") + }} + err := resetFailedSystemServicesWithBus(context.Background(), bus, []string{"api.service"}) + require.Error(t, err) + assert.Contains(t, err.Error(), `may have reset the failure state for "api.service"`) + assert.Contains(t, err.Error(), "outcome is unknown") + assert.Contains(t, err.Error(), "not rolled back") +} + +func TestEnableSystemServicesReloadsManagerAndBoundsChanges(t *testing.T) { + bus := &fakeManagerBus{} + bus.respond = func(call fakeManagerCall) ([]any, error) { + switch call.method { + case systemdManagerIface + ".EnableUnitFiles": + return []any{false, []unitFileChange{{Type: "symlink", Destination: "/etc/systemd/system/example.target.wants/api.service", Source: "/usr/lib/systemd/system/api.service"}}}, nil + case systemdManagerIface + ".Reload": + return nil, nil + default: + return nil, fmt.Errorf("unexpected method %q", call.method) + } + } + require.NoError(t, enableSystemServicesWithBus(context.Background(), bus, []string{"api.service"})) + require.Len(t, bus.calls, 2) + assert.Equal(t, systemdManagerIface+".Reload", bus.calls[1].method) + + changes := make([]unitFileChange, maxUnitFileChanges+1) + err := validateUnitFileChanges(changes) + require.Error(t, err) + assert.Contains(t, err.Error(), "too many") +} + +func TestManagerBoundaryValidation(t *testing.T) { + for _, unit := range []string{"api.service", "backup.timer", "events.socket", "-.mount", "templ@.service"} { + require.NoError(t, validateManagerUnit(unit), unit) + } + for _, unit := range []string{"api", `real\x2dname.service`, "tenant:api.service", "api.snapshot", "two@@x.service"} { + require.Error(t, validateManagerUnit(unit), unit) + } + for _, unit := range []string{`real\x2dname.service`, "tenant:api.service", "templ@instance.service"} { + require.NoError(t, validateManagerCanonicalUnit(unit), unit) + } + require.Error(t, validateManagerCanonicalUnit(`bad\q00.service`)) + require.Error(t, validateReturnedManagerJobPath(dbus.ObjectPath(systemdJobPathPrefix+"not-a-number"))) + require.NoError(t, validateReturnedManagerJobPath(dbus.ObjectPath(systemdJobPathPrefix+"123"))) + require.Error(t, validateManagerObjectPath("unit", dbus.ObjectPath("/attacker/unit/1"), systemdUnitPathPrefix)) + require.NoError(t, validateManagerObjectPath("unit", dbus.ObjectPath(systemdUnitPathPrefix+"api_2eservice"), systemdUnitPathPrefix)) + require.Error(t, validateManagerObjectPath("unit", dbus.ObjectPath(systemdUnitPathPrefix+"api_2eservice/extra"), systemdUnitPathPrefix)) + + tooMany := make([]string, builtins.MaxSystemServiceOperands+1) + for index := range tooMany { + tooMany[index] = fmt.Sprintf("unit-%d.service", index) + } + require.Error(t, validateManagerUnits(tooMany, false)) + require.Error(t, validateManagerUnits([]string{"api.service", "api.service"}, false)) + require.Error(t, validateManagerString("description", strings.Repeat("x", builtins.MaxSystemServiceFieldBytes+1), false)) +} diff --git a/internal/systemd/manager_signal.go b/internal/systemd/manager_signal.go new file mode 100644 index 00000000..bbc96ed5 --- /dev/null +++ b/internal/systemd/manager_signal.go @@ -0,0 +1,89 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package systemd + +import ( + "sync" + + "github.com/godbus/dbus/v5" +) + +// boundedManagerSignalHandler delivers signals synchronously and never queues +// work outside the caller-provided bounded channels. An overflow is terminal +// for a manager job wait because a dropped JobRemoved signal makes the final +// mutation outcome unknowable. +type boundedManagerSignalHandler struct { + mu sync.Mutex + closed bool + channels []chan<- *dbus.Signal + overflow chan struct{} + overflowOnce sync.Once +} + +func newBoundedManagerSignalHandler() *boundedManagerSignalHandler { + return &boundedManagerSignalHandler{overflow: make(chan struct{})} +} + +func (h *boundedManagerSignalHandler) DeliverSignal(_, _ string, signal *dbus.Signal) { + h.mu.Lock() + defer h.mu.Unlock() + if h.closed { + return + } + for _, channel := range h.channels { + select { + case channel <- signal: + default: + h.overflowOnce.Do(func() { close(h.overflow) }) + } + } +} + +func (h *boundedManagerSignalHandler) AddSignal(channel chan<- *dbus.Signal) { + h.mu.Lock() + defer h.mu.Unlock() + if h.closed { + return + } + for _, registered := range h.channels { + if registered == channel { + return + } + } + h.channels = append(h.channels, channel) +} + +func (h *boundedManagerSignalHandler) RemoveSignal(channel chan<- *dbus.Signal) { + h.mu.Lock() + defer h.mu.Unlock() + if h.closed { + return + } + for index := len(h.channels) - 1; index >= 0; index-- { + if h.channels[index] == channel { + copy(h.channels[index:], h.channels[index+1:]) + h.channels[len(h.channels)-1] = nil + h.channels = h.channels[:len(h.channels)-1] + } + } +} + +func (h *boundedManagerSignalHandler) Terminate() { + h.mu.Lock() + defer h.mu.Unlock() + if h.closed { + return + } + for _, channel := range h.channels { + close(channel) + } + h.channels = nil + h.closed = true +} + +func (h *boundedManagerSignalHandler) Overflow() <-chan struct{} { + return h.overflow +} diff --git a/internal/systemd/manager_signal_test.go b/internal/systemd/manager_signal_test.go new file mode 100644 index 00000000..f7663d26 --- /dev/null +++ b/internal/systemd/manager_signal_test.go @@ -0,0 +1,70 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package systemd + +import ( + "testing" + "time" + + "github.com/godbus/dbus/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBoundedManagerSignalHandlerReportsSaturationWithoutBlocking(t *testing.T) { + handler := newBoundedManagerSignalHandler() + signals := make(chan *dbus.Signal, 1) + handler.AddSignal(signals) + first := &dbus.Signal{Name: "first"} + handler.DeliverSignal("", "", first) + + delivered := make(chan struct{}) + go func() { + handler.DeliverSignal("", "", &dbus.Signal{Name: "dropped"}) + close(delivered) + }() + select { + case <-delivered: + case <-time.After(time.Second): + t.Fatal("saturated signal delivery blocked") + } + select { + case <-handler.Overflow(): + default: + t.Fatal("saturated signal delivery did not report overflow") + } + require.Len(t, signals, 1) + assert.Same(t, first, <-signals) +} + +func TestBoundedManagerSignalHandlerTerminateClosesRegisteredChannels(t *testing.T) { + handler := newBoundedManagerSignalHandler() + first := make(chan *dbus.Signal, 1) + second := make(chan *dbus.Signal, 1) + handler.AddSignal(first) + handler.AddSignal(second) + handler.AddSignal(first) + handler.Terminate() + handler.Terminate() + handler.DeliverSignal("", "", &dbus.Signal{Name: "ignored"}) + + _, firstOpen := <-first + _, secondOpen := <-second + assert.False(t, firstOpen) + assert.False(t, secondOpen) +} + +func TestBoundedManagerSignalHandlerRemovePreventsDeliveryAndClose(t *testing.T) { + handler := newBoundedManagerSignalHandler() + signals := make(chan *dbus.Signal, 1) + handler.AddSignal(signals) + handler.RemoveSignal(signals) + handler.DeliverSignal("", "", &dbus.Signal{Name: "ignored"}) + handler.Terminate() + + assert.Empty(t, signals) + close(signals) +} diff --git a/internal/systemd/manager_unsupported.go b/internal/systemd/manager_unsupported.go new file mode 100644 index 00000000..7d7427a1 --- /dev/null +++ b/internal/systemd/manager_unsupported.go @@ -0,0 +1,47 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build !linux + +package systemd + +import ( + "context" + "fmt" + + "github.com/DataDog/rshell/builtins" +) + +func (*Client) ListSystemServices(context.Context, builtins.SystemServiceListRequest) ([]builtins.SystemServiceState, error) { + return nil, managerUnsupported() +} + +func (*Client) InspectSystemServices(context.Context, []string) ([]builtins.SystemServiceState, error) { + return nil, managerUnsupported() +} + +func (*Client) SystemServiceEnabledState(context.Context, []string) ([]string, error) { + return nil, managerUnsupported() +} + +func (*Client) RunSystemServiceJobs(context.Context, builtins.SystemServiceJobAction, []string) error { + return managerUnsupported() +} + +func (*Client) ResetFailedSystemServices(context.Context, []string) error { + return managerUnsupported() +} + +func (*Client) EnableSystemServices(context.Context, []string) error { + return managerUnsupported() +} + +func (*Client) DisableSystemServices(context.Context, []string) error { + return managerUnsupported() +} + +func managerUnsupported() error { + return fmt.Errorf("%w: systemd manager access requires Linux", builtins.ErrSystemdUnsupported) +} diff --git a/internal/systemd/target.go b/internal/systemd/target.go index f31f90c9..62ea1681 100644 --- a/internal/systemd/target.go +++ b/internal/systemd/target.go @@ -21,6 +21,7 @@ type Target struct { JournalDirs []string MachineIDPath string JournalControlSocket string + ManagerBusSocket string } // LocalTarget returns the standard paths for the local systemd host. @@ -32,6 +33,7 @@ func LocalTarget() Target { }, MachineIDPath: filepath.FromSlash("/etc/machine-id"), JournalControlSocket: filepath.FromSlash("/run/systemd/journal/io.systemd.journal"), + ManagerBusSocket: filepath.FromSlash("/run/dbus/system_bus_socket"), } } @@ -39,7 +41,7 @@ func LocalTarget() Target { // local host. Once any explicit field is supplied, omitted fields remain empty // and never fall back to local paths. func ResolveTarget(target Target) (Target, error) { - if len(target.JournalDirs) == 0 && target.MachineIDPath == "" && target.JournalControlSocket == "" { + if len(target.JournalDirs) == 0 && target.MachineIDPath == "" && target.JournalControlSocket == "" && target.ManagerBusSocket == "" { return LocalTarget(), nil } if len(target.JournalDirs) > MaxJournalDirs { @@ -70,6 +72,9 @@ func ResolveTarget(target Target) (Target, error) { if resolved.JournalControlSocket, err = validateOptionalAbsolutePath("journal control socket", target.JournalControlSocket); err != nil { return Target{}, err } + if resolved.ManagerBusSocket, err = validateOptionalAbsolutePath("manager bus socket", target.ManagerBusSocket); err != nil { + return Target{}, err + } return resolved, nil } diff --git a/internal/systemd/target_test.go b/internal/systemd/target_test.go index f71470d3..64d35e46 100644 --- a/internal/systemd/target_test.go +++ b/internal/systemd/target_test.go @@ -21,6 +21,7 @@ func TestResolveTargetDefaultsToLocalPaths(t *testing.T) { assert.Equal(t, []string{"/var/log/journal", "/run/log/journal"}, target.JournalDirs) assert.Equal(t, "/etc/machine-id", target.MachineIDPath) assert.Equal(t, "/run/systemd/journal/io.systemd.journal", target.JournalControlSocket) + assert.Equal(t, "/run/dbus/system_bus_socket", target.ManagerBusSocket) } func TestResolveTargetUsesOnlyExplicitPaths(t *testing.T) { @@ -28,12 +29,27 @@ func TestResolveTargetUsesOnlyExplicitPaths(t *testing.T) { JournalDirs: []string{"/mnt/logs", "/mnt/logs", "/mnt/runtime-logs/../runtime-logs"}, MachineIDPath: "/mnt/etc/machine-id", JournalControlSocket: "/mnt/run/journal.sock", + ManagerBusSocket: "/mnt/run/dbus/system_bus_socket", }) require.NoError(t, err) assert.Equal(t, []string{"/mnt/logs", "/mnt/runtime-logs"}, target.JournalDirs) assert.Equal(t, "/mnt/etc/machine-id", target.MachineIDPath) assert.Equal(t, "/mnt/run/journal.sock", target.JournalControlSocket) + assert.Equal(t, "/mnt/run/dbus/system_bus_socket", target.ManagerBusSocket) +} + +func TestResolveTargetManagerOnlyDoesNotFallBackToLocalPaths(t *testing.T) { + target, err := ResolveTarget(Target{ + MachineIDPath: "/host/etc/machine-id", + ManagerBusSocket: "/host/run/dbus/system_bus_socket", + }) + require.NoError(t, err) + + assert.Empty(t, target.JournalDirs) + assert.Empty(t, target.JournalControlSocket) + assert.Equal(t, "/host/etc/machine-id", target.MachineIDPath) + assert.Equal(t, "/host/run/dbus/system_bus_socket", target.ManagerBusSocket) } func TestResolveTargetRejectsInvalidConfiguration(t *testing.T) { @@ -57,6 +73,11 @@ func TestResolveTargetRejectsInvalidConfiguration(t *testing.T) { target: Target{MachineIDPath: "/etc/machine-id", JournalControlSocket: "run/systemd/journal.sock"}, needle: "must be absolute", }, + { + name: "relative manager bus socket", + target: Target{MachineIDPath: "/etc/machine-id", ManagerBusSocket: "run/dbus/system_bus_socket"}, + needle: "must be absolute", + }, { name: "NUL path", target: Target{MachineIDPath: "/etc/machine-id\x00suffix"}, diff --git a/interp/api.go b/interp/api.go index e94a72e9..7bcf1b90 100644 --- a/interp/api.go +++ b/interp/api.go @@ -81,7 +81,7 @@ type runnerConfig struct { // command. Intended for testing convenience. allowAllCommands bool - // allowedSystemServices maps exact services to their permitted actions. It + // allowedSystemServices maps exact systemd units to their permitted actions. It // is independent of allowAllCommands and // defaults to denying every systemd operation. allowedSystemServices systemdGrants @@ -101,8 +101,8 @@ type runnerConfig struct { // Defaults to "/proc" when empty. procPath string - // remediationMode enables write operations (file-target redirections, etc.) - // when true. Enables file-target output redirections (>, >>) within AllowedPaths. + // remediationMode enables remediation-only capabilities, including file-target + // output redirections within AllowedPaths and the restricted systemctl builtin. remediationMode bool // proc is the ProcProvider constructed from procPath, created once in @@ -338,6 +338,8 @@ func New(opts ...RunnerOption) (*Runner, error) { JournalStorage: systemdClient, JournalCleaner: systemdClient, JournalRotator: systemdClient, + ServiceState: systemdClient, + ServiceControl: systemdClient, } r.proc = builtins.NewProcProvider(r.procPath) return r, nil @@ -872,16 +874,19 @@ func ProcPath(path string) RunnerOption { type Mode string const ( - // ModeReadOnly is the default mode: all write operations are blocked. + // ModeReadOnly is the default mode: write operations and remediation-only + // builtins are blocked. ModeReadOnly Mode = "read-only" - // ModeRemediation enables write operations (file-target redirections, etc.) - // within the configured AllowedPaths. + // ModeRemediation enables remediation-only capabilities. Filesystem writes + // remain restricted by AllowedPaths; other capabilities retain their own + // exact authorization policies. ModeRemediation Mode = "remediation" ) // WithMode sets the execution mode of the runner. Use [ModeReadOnly] (the default) -// to block all writes, or [ModeRemediation] to allow file-target output -// redirections (>, >>, 2>, &>, &>>) within the configured [AllowedPaths]. +// to block writes and remediation-only builtins, or [ModeRemediation] to enable +// capabilities such as file-target output redirections (>, >>, 2>, &>, &>>) +// within the configured [AllowedPaths] and restricted systemctl operations. // Passing an unrecognised mode value returns an error. func WithMode(m Mode) RunnerOption { return func(r *Runner) error { diff --git a/interp/register_builtins.go b/interp/register_builtins.go index 8d766ef5..845e0cf1 100644 --- a/interp/register_builtins.go +++ b/interp/register_builtins.go @@ -36,6 +36,7 @@ import ( sortcmd "github.com/DataDog/rshell/builtins/sort" "github.com/DataDog/rshell/builtins/ss" "github.com/DataDog/rshell/builtins/strings_cmd" + "github.com/DataDog/rshell/builtins/systemctl" "github.com/DataDog/rshell/builtins/tail" "github.com/DataDog/rshell/builtins/testcmd" "github.com/DataDog/rshell/builtins/tr" @@ -79,6 +80,7 @@ func registerBuiltins() { sed.Cmd, ss.Cmd, strings_cmd.Cmd, + systemctl.Cmd, tail.Cmd, testcmd.Cmd, testcmd.BracketCmd, diff --git a/interp/runner_exec.go b/interp/runner_exec.go index d65d788e..f1edbdaf 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -664,6 +664,7 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { }, AuthorizeSystemd: r.authorizeSystemd, AuthorizeSystemServices: r.authorizeSystemServices, + ReadableSystemServices: r.readableSystemServices, AllowedPathsList: func() []builtins.AllowedPath { return allowedPathsList(r.sandbox) }, @@ -795,6 +796,7 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { }, AuthorizeSystemd: r.authorizeSystemd, AuthorizeSystemServices: r.authorizeSystemServices, + ReadableSystemServices: r.readableSystemServices, AllowedPathsList: func() []builtins.AllowedPath { return allowedPathsList(r.sandbox) }, diff --git a/interp/system_services.go b/interp/system_services.go index dc704d5e..eae962d4 100644 --- a/interp/system_services.go +++ b/interp/system_services.go @@ -7,27 +7,35 @@ package interp import ( "fmt" + "sort" "strings" "unicode" + "unicode/utf8" "github.com/DataDog/rshell/builtins" ) -// SystemdOperation is one service action checked by the shared policy. +// SystemdOperation is one unit action checked by the shared policy. type SystemdOperation = builtins.SystemdOperation -// SystemServiceAction identifies an operation that may be granted for a -// systemd service. +// SystemServiceAction identifies an operation that may be granted for an exact +// systemd unit. The historical "Service" name is retained for compatibility. type SystemServiceAction = builtins.SystemServiceAction const ( - SystemServiceRead = builtins.SystemServiceRead - SystemServiceClean = builtins.SystemServiceClean - SystemServiceReload = builtins.SystemServiceReload - SystemServiceRestart = builtins.SystemServiceRestart + SystemServiceRead = builtins.SystemServiceRead + SystemServiceClean = builtins.SystemServiceClean + SystemServiceStart = builtins.SystemServiceStart + SystemServiceStop = builtins.SystemServiceStop + SystemServiceReload = builtins.SystemServiceReload + SystemServiceRestart = builtins.SystemServiceRestart + SystemServiceResetFailed = builtins.SystemServiceResetFailed + SystemServiceEnable = builtins.SystemServiceEnable + SystemServiceDisable = builtins.SystemServiceDisable ) -// SystemServiceControlGrant grants Actions for one exact Service. +// SystemServiceControlGrant grants Actions for one exact systemd unit. Service +// may contain any unit type suffix, including .service, .timer, and .socket. type SystemServiceControlGrant struct { Service string Actions []SystemServiceAction @@ -38,14 +46,15 @@ type SystemdControlGrant = SystemServiceControlGrant type systemdGrants map[string]map[SystemServiceAction]struct{} -// AllowedSystemServices configures the services and actions that systemd-aware -// builtins may use. Service names are matched exactly: for example, "mysql" -// and "mysql.service" are different services. +// AllowedSystemServices configures the units and actions that systemd-aware +// builtins may use. Unit names are matched exactly: for example, "mysql" and +// "mysql.service" are different selectors. Despite the historical API name, +// .timer, .socket, and other unit types are accepted. // // Grants without actions are ignored. Invalid services and unsupported actions -// are skipped with a warning. Supported actions are read, clean, reload, and -// restart. Duplicate services and actions are accepted and combined -// idempotently. +// are skipped with a warning. Supported actions are read, clean, start, stop, +// reload, restart, reset-failed, enable, and disable. Duplicate units and +// actions are accepted and combined idempotently. // // When not set (default), or when passed an empty slice, every systemd // operation is denied. This policy is not bypassed by allowing all commands. @@ -84,14 +93,36 @@ func AllowedSystemServices(grants []SystemServiceControlGrant) RunnerOption { func validSystemServiceAction(action SystemServiceAction) bool { return action == SystemServiceRead || action == SystemServiceClean || + action == SystemServiceStart || + action == SystemServiceStop || action == SystemServiceReload || - action == SystemServiceRestart + action == SystemServiceRestart || + action == SystemServiceResetFailed || + action == SystemServiceEnable || + action == SystemServiceDisable +} + +func (r *Runner) readableSystemServices() []string { + services := make([]string, 0, len(r.allowedSystemServices)) + for service, actions := range r.allowedSystemServices { + if _, ok := actions[SystemServiceRead]; ok { + services = append(services, service) + } + } + sort.Strings(services) + return services } func validateSystemServiceName(service string) error { if service == "" { return fmt.Errorf("system service name must not be empty") } + if len(service) > builtins.MaxSystemServiceNameBytes { + return fmt.Errorf("system service name exceeds %d bytes", builtins.MaxSystemServiceNameBytes) + } + if !utf8.ValidString(service) { + return fmt.Errorf("system service name must be valid UTF-8") + } if strings.ContainsRune(service, '/') || strings.ContainsRune(service, '\\') { return fmt.Errorf("system service name %q must not contain a path separator", service) } diff --git a/interp/system_services_test.go b/interp/system_services_test.go index 1059ee89..bcdc8126 100644 --- a/interp/system_services_test.go +++ b/interp/system_services_test.go @@ -8,10 +8,13 @@ package interp import ( "bytes" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/builtins" ) func TestAllowedSystemServicesAuthorizesExactServiceAndAction(t *testing.T) { @@ -21,10 +24,15 @@ func TestAllowedSystemServicesAuthorizesExactServiceAndAction(t *testing.T) { { Service: "mysql.service", Actions: []SystemServiceAction{ - SystemServiceRestart, - SystemServiceReload, SystemServiceRead, SystemServiceClean, + SystemServiceStart, + SystemServiceStop, + SystemServiceReload, + SystemServiceRestart, + SystemServiceResetFailed, + SystemServiceEnable, + SystemServiceDisable, }, }, { @@ -36,9 +44,19 @@ func TestAllowedSystemServicesAuthorizesExactServiceAndAction(t *testing.T) { require.NoError(t, err) defer runner.Close() - require.NoError(t, runner.authorizeSystemServices(SystemServiceRestart, "mysql.service")) - require.NoError(t, runner.authorizeSystemServices(SystemServiceReload, "mysql.service")) - require.NoError(t, runner.authorizeSystemServices(SystemServiceClean, "mysql.service")) + for _, action := range []SystemServiceAction{ + SystemServiceRead, + SystemServiceClean, + SystemServiceStart, + SystemServiceStop, + SystemServiceReload, + SystemServiceRestart, + SystemServiceResetFailed, + SystemServiceEnable, + SystemServiceDisable, + } { + require.NoError(t, runner.authorizeSystemServices(action, "mysql.service"), "action %q", action) + } require.NoError(t, runner.authorizeSystemServices(SystemServiceRead, "mysql.service", "nginx.service")) err = runner.authorizeSystemServices(SystemServiceRestart, "nginx.service") @@ -62,41 +80,105 @@ func TestAllowedSystemServicesDefaultDenyIsIndependentOfAllowedCommands(t *testi assert.Contains(t, err.Error(), "not allowed") } -func TestAllowedSystemServicesAllowsReadOutsideRemediationMode(t *testing.T) { +func TestAllowedSystemServicesKeepsSharedJournalReadOutsideRemediationMode(t *testing.T) { + // The shared read action remains available to journalctl. The systemctl + // builtin applies its separate command-wide remediation-mode gate. runner, err := New(AllowedSystemServices([]SystemServiceControlGrant{ { Service: "mysql.service", - Actions: []SystemServiceAction{SystemServiceRead, SystemServiceRestart, SystemServiceClean}, + Actions: []SystemServiceAction{ + SystemServiceRead, + SystemServiceClean, + SystemServiceStart, + SystemServiceStop, + SystemServiceReload, + SystemServiceRestart, + SystemServiceResetFailed, + SystemServiceEnable, + SystemServiceDisable, + }, }, })) require.NoError(t, err) defer runner.Close() require.NoError(t, runner.authorizeSystemServices(SystemServiceRead, "mysql.service")) - err = runner.authorizeSystemServices(SystemServiceRestart, "mysql.service") - require.Error(t, err) - assert.Contains(t, err.Error(), `action "restart" requires remediation mode`) + for _, action := range []SystemServiceAction{ + SystemServiceClean, + SystemServiceStart, + SystemServiceStop, + SystemServiceReload, + SystemServiceRestart, + SystemServiceResetFailed, + SystemServiceEnable, + SystemServiceDisable, + } { + err = runner.authorizeSystemServices(action, "mysql.service") + require.Error(t, err, "action %q", action) + assert.Contains(t, err.Error(), `action "`+string(action)+`" requires remediation mode`) + } +} - err = runner.authorizeSystemServices(SystemServiceClean, "mysql.service") +func TestAllowedSystemServicesReadableExactGrants(t *testing.T) { + runner, err := New( + WithMode(ModeRemediation), + AllowedSystemServices([]SystemServiceControlGrant{ + {Service: "nightly.timer", Actions: []SystemServiceAction{SystemServiceRead, SystemServiceStart}}, + {Service: "api.socket", Actions: []SystemServiceAction{SystemServiceRead, SystemServiceStop}}, + {Service: "worker.service", Actions: []SystemServiceAction{SystemServiceRestart}}, + {Service: "dbus.service", Actions: []SystemServiceAction{SystemServiceRead}}, + }), + ) + require.NoError(t, err) + defer runner.Close() + + want := []string{"api.socket", "dbus.service", "nightly.timer"} + readable := runner.readableSystemServices() + assert.Equal(t, want, readable) + + // The configured exact names remain valid regardless of unit type. A + // similarly named service unit is not implicitly granted by a timer grant. + require.NoError(t, runner.authorizeSystemServices(SystemServiceRead, "nightly.timer", "api.socket")) + require.NoError(t, runner.authorizeSystemServices(SystemServiceStart, "nightly.timer")) + require.NoError(t, runner.authorizeSystemServices(SystemServiceStop, "api.socket")) + err = runner.authorizeSystemServices(SystemServiceRead, "nightly.service") require.Error(t, err) - assert.Contains(t, err.Error(), `action "clean" requires remediation mode`) + assert.Contains(t, err.Error(), `system service "nightly.service" is not allowed for action "read"`) + + // Callers cannot mutate the runner's policy through the returned slice. + readable[0] = "changed.service" + assert.Equal(t, want, runner.readableSystemServices()) } func TestAllowedSystemServicesReadDoesNotEnableMutation(t *testing.T) { - runner, err := New(AllowedSystemServices([]SystemdControlGrant{ - {Service: "systemd-journald.service", Actions: []SystemServiceAction{SystemServiceRead}}, - })) + runner, err := New( + WithMode(ModeRemediation), + AllowedSystemServices([]SystemdControlGrant{ + {Service: "systemd-journald.service", Actions: []SystemServiceAction{SystemServiceRead}}, + }), + ) require.NoError(t, err) defer runner.Close() require.NoError(t, runner.authorizeSystemd( SystemdOperation{Service: "systemd-journald.service", Action: SystemServiceRead}, )) - err = runner.authorizeSystemd( - SystemdOperation{Service: "systemd-journald.service", Action: SystemServiceClean}, - ) - require.Error(t, err) - assert.Contains(t, err.Error(), `action "clean" requires remediation mode`) + for _, action := range []SystemServiceAction{ + SystemServiceClean, + SystemServiceStart, + SystemServiceStop, + SystemServiceReload, + SystemServiceRestart, + SystemServiceResetFailed, + SystemServiceEnable, + SystemServiceDisable, + } { + err = runner.authorizeSystemd( + SystemdOperation{Service: "systemd-journald.service", Action: action}, + ) + require.Error(t, err, "action %q", action) + assert.Contains(t, err.Error(), `is not allowed for action "`+string(action)+`"`) + } } func TestAllowedSystemServicesCopiesAndCombinesGrants(t *testing.T) { @@ -162,27 +244,52 @@ func TestAllowedSystemServicesSkipsEmptyAndInvalidGrants(t *testing.T) { assert.NotContains(t, warningOutput.String(), "ignored.service") } +func TestAllowedSystemServicesValidatesNameEncodingAndLength(t *testing.T) { + maxUnit := strings.Repeat("a", builtins.MaxSystemServiceNameBytes-len(".service")) + ".service" + tooLongUnit := "x" + maxUnit + invalidUTF8Unit := string([]byte{'a', 'p', 'i', 0xff, '.', 's', 'e', 'r', 'v', 'i', 'c', 'e'}) + + var warningOutput bytes.Buffer + runner, err := New( + WarningsWriter(&warningOutput), + AllowedSystemServices([]SystemServiceControlGrant{ + {Service: maxUnit, Actions: []SystemServiceAction{SystemServiceRead}}, + {Service: tooLongUnit, Actions: []SystemServiceAction{SystemServiceRead}}, + {Service: invalidUTF8Unit, Actions: []SystemServiceAction{SystemServiceRead}}, + }), + ) + require.NoError(t, err) + defer runner.Close() + + require.NoError(t, runner.authorizeSystemServices(SystemServiceRead, maxUnit)) + assert.Len(t, runner.allowedSystemServices, 1) + require.Len(t, runner.Warnings(), 2) + assert.Contains(t, warningOutput.String(), "system service name exceeds 255 bytes") + assert.Contains(t, warningOutput.String(), "system service name must be valid UTF-8") +} + func TestAllowedSystemServicesSkipsUnsupportedActions(t *testing.T) { var warningOutput bytes.Buffer runner, err := New( WithMode(ModeRemediation), WarningsWriter(&warningOutput), AllowedSystemServices([]SystemServiceControlGrant{ - {Service: "mysql.service", Actions: []SystemServiceAction{SystemServiceRead, "stop", SystemServiceReload}}, - {Service: "ignored.service", Actions: []SystemServiceAction{"enable"}}, + {Service: "mysql.service", Actions: []SystemServiceAction{SystemServiceRead, SystemServiceStop, "freeze", SystemServiceReload}}, + {Service: "ignored.service", Actions: []SystemServiceAction{"mask"}}, }), ) require.NoError(t, err) defer runner.Close() require.NoError(t, runner.authorizeSystemServices(SystemServiceRead, "mysql.service")) + require.NoError(t, runner.authorizeSystemServices(SystemServiceStop, "mysql.service")) require.NoError(t, runner.authorizeSystemServices(SystemServiceReload, "mysql.service")) assert.NotContains(t, runner.allowedSystemServices, "ignored.service") warnings := runner.Warnings() require.Len(t, warnings, 2) - assert.Contains(t, warningOutput.String(), `AllowedSystemServices: skipping unsupported action "stop" in grant 0 for "mysql.service"`) - assert.Contains(t, warningOutput.String(), `AllowedSystemServices: skipping unsupported action "enable" in grant 1 for "ignored.service"`) + assert.Contains(t, warningOutput.String(), `AllowedSystemServices: skipping unsupported action "freeze" in grant 0 for "mysql.service"`) + assert.Contains(t, warningOutput.String(), `AllowedSystemServices: skipping unsupported action "mask" in grant 1 for "ignored.service"`) } func TestAuthorizeSystemServicesRejectsInvalidRequests(t *testing.T) { @@ -195,17 +302,21 @@ func TestAuthorizeSystemServicesRejectsInvalidRequests(t *testing.T) { require.NoError(t, err) defer runner.Close() + tooLongUnit := strings.Repeat("a", builtins.MaxSystemServiceNameBytes-len(".service")+1) + ".service" + invalidUTF8Unit := string([]byte{'a', 'p', 'i', 0xff, '.', 's', 'e', 'r', 'v', 'i', 'c', 'e'}) tests := []struct { name string action SystemServiceAction services []string needle string }{ - {name: "unknown action", action: "stop", services: []string{"mysql.service"}, needle: "unsupported systemd action"}, + {name: "unknown action", action: "freeze", services: []string{"mysql.service"}, needle: "unsupported systemd action"}, {name: "no services", action: SystemServiceRead, needle: "at least one system service"}, {name: "runtime resource separator", action: SystemServiceRead, services: []string{"tenant:mysql.service"}, needle: "must not contain ':'"}, {name: "runtime glob", action: SystemServiceRead, services: []string{"mysql*.service"}, needle: "glob pattern"}, {name: "runtime backslash path", action: SystemServiceRead, services: []string{"..\\mysql.service"}, needle: "path separator"}, + {name: "runtime name too long", action: SystemServiceRead, services: []string{tooLongUnit}, needle: "exceeds 255 bytes"}, + {name: "runtime invalid utf8", action: SystemServiceRead, services: []string{invalidUTF8Unit}, needle: "must be valid UTF-8"}, } for _, test := range tests { diff --git a/interp/systemd_target.go b/interp/systemd_target.go index baf67ab7..c9e11ff5 100644 --- a/interp/systemd_target.go +++ b/interp/systemd_target.go @@ -19,6 +19,7 @@ type SystemdTargetConfig struct { JournalDirs []string MachineIDPath string JournalControlSocket string + ManagerBusSocket string } // WithSystemdTarget configures the trusted systemd target. Scripts cannot @@ -29,6 +30,7 @@ func WithSystemdTarget(config SystemdTargetConfig) RunnerOption { JournalDirs: append([]string(nil), config.JournalDirs...), MachineIDPath: config.MachineIDPath, JournalControlSocket: config.JournalControlSocket, + ManagerBusSocket: config.ManagerBusSocket, }) if err != nil { return fmt.Errorf("WithSystemdTarget: %w", err) diff --git a/interp/systemd_target_test.go b/interp/systemd_target_test.go index 07f2b443..1081d523 100644 --- a/interp/systemd_target_test.go +++ b/interp/systemd_target_test.go @@ -22,6 +22,7 @@ func TestWithSystemdTargetDefaultsToLocal(t *testing.T) { assert.Equal(t, []string{"/var/log/journal", "/run/log/journal"}, runner.systemdTarget.JournalDirs) assert.Equal(t, "/etc/machine-id", runner.systemdTarget.MachineIDPath) assert.Equal(t, "/run/systemd/journal/io.systemd.journal", runner.systemdTarget.JournalControlSocket) + assert.Equal(t, "/run/dbus/system_bus_socket", runner.systemdTarget.ManagerBusSocket) } func TestWithSystemdTargetCopiesConfiguration(t *testing.T) { @@ -30,6 +31,7 @@ func TestWithSystemdTargetCopiesConfiguration(t *testing.T) { JournalDirs: dirs, MachineIDPath: "/host/etc/machine-id", JournalControlSocket: "/host/run/systemd/journal/io.systemd.journal", + ManagerBusSocket: "/host/run/dbus/system_bus_socket", })) require.NoError(t, err) defer runner.Close() @@ -37,4 +39,5 @@ func TestWithSystemdTargetCopiesConfiguration(t *testing.T) { dirs[0] = "/changed" assert.Equal(t, []string{"/host/var/log/journal"}, runner.systemdTarget.JournalDirs) assert.Equal(t, "/host/run/systemd/journal/io.systemd.journal", runner.systemdTarget.JournalControlSocket) + assert.Equal(t, "/host/run/dbus/system_bus_socket", runner.systemdTarget.ManagerBusSocket) } diff --git a/tests/scenarios/cmd/help/help_readonly_mode.yaml b/tests/scenarios/cmd/help/help_readonly_mode.yaml index 3015b40a..d975c63b 100644 --- a/tests/scenarios/cmd/help/help_readonly_mode.yaml +++ b/tests/scenarios/cmd/help/help_readonly_mode.yaml @@ -1,4 +1,4 @@ -description: help in read-only mode shows the mode in the header and truncate in disabled commands. +description: help in read-only mode shows the mode and remediation-only commands as disabled. skip_assert_against_bash: true input: script: |+ @@ -7,5 +7,6 @@ expect: stdout_contains: - "read-only mode" - "truncate" + - "systemctl" stderr: |+ exit_code: 0 diff --git a/tests/scenarios/cmd/systemctl/basic/restricted_empty_list.yaml b/tests/scenarios/cmd/systemctl/basic/restricted_empty_list.yaml new file mode 100644 index 00000000..a07166f4 --- /dev/null +++ b/tests/scenarios/cmd/systemctl/basic/restricted_empty_list.yaml @@ -0,0 +1,13 @@ +# rshell never enumerates units that are absent from the exact read allowlist. +skip_assert_against_bash: true +description: bare systemctl lists no units when no read grants are configured +input: + mode: remediation + script: |+ + systemctl +expect: + stdout: |+ + UNIT LOAD ACTIVE SUB DESCRIPTION + 0 units listed (restricted to units granted read access). + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/systemctl/errors/denied_status.yaml b/tests/scenarios/cmd/systemctl/errors/denied_status.yaml new file mode 100644 index 00000000..9ffb3ea0 --- /dev/null +++ b/tests/scenarios/cmd/systemctl/errors/denied_status.yaml @@ -0,0 +1,12 @@ +# Scenario runners do not grant systemd capabilities by default. +skip_assert_against_bash: true +description: systemctl status enforces the exact unit read allowlist +input: + mode: remediation + script: |+ + systemctl status api.service +expect: + stdout: |+ + stderr: |+ + systemctl: system service "api.service" is not allowed for action "read" + exit_code: 1 diff --git a/tests/scenarios/cmd/systemctl/errors/glob_rejected.yaml b/tests/scenarios/cmd/systemctl/errors/glob_rejected.yaml new file mode 100644 index 00000000..1208d2de --- /dev/null +++ b/tests/scenarios/cmd/systemctl/errors/glob_rejected.yaml @@ -0,0 +1,12 @@ +# Unit operands must remain exact selectors and cannot expand the capability scope. +skip_assert_against_bash: true +description: systemctl rejects globbed unit operands before authorization +input: + mode: remediation + script: |+ + systemctl status 'api*.service' +expect: + stdout: |+ + stderr: |+ + systemctl: invalid character in exact unit name "api*.service" + exit_code: 1 diff --git a/tests/scenarios/cmd/systemctl/errors/help_read_only.yaml b/tests/scenarios/cmd/systemctl/errors/help_read_only.yaml new file mode 100644 index 00000000..7c67cba7 --- /dev/null +++ b/tests/scenarios/cmd/systemctl/errors/help_read_only.yaml @@ -0,0 +1,11 @@ +# Even informational invocation of systemctl is behind the command-wide gate. +skip_assert_against_bash: true +description: systemctl help requires remediation mode +input: + script: |+ + systemctl --help +expect: + stdout: |+ + stderr: |+ + systemctl: remediation mode required + exit_code: 1 diff --git a/tests/scenarios/cmd/systemctl/errors/read_only.yaml b/tests/scenarios/cmd/systemctl/errors/read_only.yaml new file mode 100644 index 00000000..61b6cbdf --- /dev/null +++ b/tests/scenarios/cmd/systemctl/errors/read_only.yaml @@ -0,0 +1,11 @@ +# The entire systemctl builtin is unavailable outside remediation mode. +skip_assert_against_bash: true +description: systemctl inspection requires remediation mode +input: + script: |+ + systemctl list-units +expect: + stdout: |+ + stderr: |+ + systemctl: remediation mode required + exit_code: 1 diff --git a/tests/scenarios/cmd/systemctl/errors/start_denied.yaml b/tests/scenarios/cmd/systemctl/errors/start_denied.yaml new file mode 100644 index 00000000..946cd70a --- /dev/null +++ b/tests/scenarios/cmd/systemctl/errors/start_denied.yaml @@ -0,0 +1,12 @@ +# Remediation mode alone does not bypass the exact unit/action allowlist. +skip_assert_against_bash: true +description: systemctl start requires an exact start grant +input: + mode: remediation + script: |+ + systemctl start api.service +expect: + stdout: |+ + stderr: |+ + systemctl: system service "api.service" is not allowed for action "start" + exit_code: 1 diff --git a/tests/scenarios/cmd/systemctl/help/help.yaml b/tests/scenarios/cmd/systemctl/help/help.yaml new file mode 100644 index 00000000..d3808a17 --- /dev/null +++ b/tests/scenarios/cmd/systemctl/help/help.yaml @@ -0,0 +1,18 @@ +# rshell systemctl is a restricted builtin and the Bash image has no usable system manager. +skip_assert_against_bash: true +description: systemctl help describes the bounded unit-management surface +input: + mode: remediation + script: |+ + systemctl --help +expect: + stdout_contains: + - "Usage: systemctl [OPTION]... COMMAND [UNIT]..." + - "The entire command is available only in remediation mode." + - "list-units" + - "status" + - "reload-or-restart" + - "reset-failed" + - "enable" + stderr: |+ + exit_code: 0