From ceeacb8e58fdabf6917ad7c6e366ad8c17856a49 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Fri, 17 Jul 2026 14:51:54 -0400 Subject: [PATCH 01/19] feat(systemctl): add restricted systemd unit management --- AGENTS.md | 2 +- LICENSE-3rdparty.csv | 1 + README.md | 49 +- SHELL_FEATURES.md | 5 +- analysis/symbols_builtins.go | 24 + analysis/symbols_interp.go | 4 + builtins/builtins.go | 25 +- builtins/systemctl/systemctl.go | 968 ++++++++++++++++++ builtins/systemctl/systemctl_test.go | 601 +++++++++++ builtins/systemd.go | 71 ++ cmd/rshell/main.go | 7 +- cmd/rshell/main_test.go | 24 +- docs/RULES.md | 89 +- go.mod | 1 + go.sum | 2 + internal/systemd/manager_dbus_limit.go | 129 +++ internal/systemd/manager_dbus_limit_test.go | 111 ++ internal/systemd/manager_incoming.go | 27 + internal/systemd/manager_incoming_test.go | 35 + internal/systemd/manager_linux.go | 255 +++++ internal/systemd/manager_protocol.go | 730 +++++++++++++ internal/systemd/manager_protocol_test.go | 492 +++++++++ internal/systemd/manager_signal.go | 89 ++ internal/systemd/manager_signal_test.go | 70 ++ internal/systemd/manager_unsupported.go | 47 + internal/systemd/target.go | 7 +- internal/systemd/target_test.go | 21 + interp/api.go | 4 +- interp/register_builtins.go | 2 + interp/runner_exec.go | 2 + interp/system_services.go | 61 +- interp/system_services_test.go | 157 ++- interp/systemd_target.go | 2 + interp/systemd_target_test.go | 3 + .../basic/restricted_empty_list.yaml | 12 + .../cmd/systemctl/errors/denied_status.yaml | 11 + .../cmd/systemctl/errors/glob_rejected.yaml | 11 + .../cmd/systemctl/errors/start_denied.yaml | 12 + .../cmd/systemctl/errors/start_read_only.yaml | 11 + tests/scenarios/cmd/systemctl/help/help.yaml | 16 + 40 files changed, 4116 insertions(+), 74 deletions(-) create mode 100644 builtins/systemctl/systemctl.go create mode 100644 builtins/systemctl/systemctl_test.go create mode 100644 internal/systemd/manager_dbus_limit.go create mode 100644 internal/systemd/manager_dbus_limit_test.go create mode 100644 internal/systemd/manager_incoming.go create mode 100644 internal/systemd/manager_incoming_test.go create mode 100644 internal/systemd/manager_linux.go create mode 100644 internal/systemd/manager_protocol.go create mode 100644 internal/systemd/manager_protocol_test.go create mode 100644 internal/systemd/manager_signal.go create mode 100644 internal/systemd/manager_signal_test.go create mode 100644 internal/systemd/manager_unsupported.go create mode 100644 tests/scenarios/cmd/systemctl/basic/restricted_empty_list.yaml create mode 100644 tests/scenarios/cmd/systemctl/errors/denied_status.yaml create mode 100644 tests/scenarios/cmd/systemctl/errors/glob_rejected.yaml create mode 100644 tests/scenarios/cmd/systemctl/errors/start_denied.yaml create mode 100644 tests/scenarios/cmd/systemctl/errors/start_read_only.yaml create mode 100644 tests/scenarios/cmd/systemctl/help/help.yaml diff --git a/AGENTS.md b/AGENTS.md index f9e90d12..f1f9642d 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, and remediation mode for mutations. 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 bus peer's machine ID before fixed manager 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..464cf1b6 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 | | 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. `read` is available in read-only mode and is also the visibility grant for restricted `systemctl list-units`: units without an exact `read` grant are never enumerated. Every other action is mutating and requires remediation mode in addition to 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 bus peer's machine ID against `MachineIDPath` before issuing any fixed manager 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` 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` | + +All mutating operations require remediation mode as well as their listed 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 every mutation 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. diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index dfa53fc5..b869e9ed 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]...` — bounded Linux system-manager inspection and control for exact, fully suffixed unit names; 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 operations are `status`, fixed-property `show`, `is-active`, `is-failed`, and `is-enabled`; remediation-only 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,8 +123,8 @@ 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, `list-units` sees only exact `read` grants, every mutating action requires remediation mode, and 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 its 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` 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..bd217714 --- /dev/null +++ b/builtins/systemctl/systemctl.go @@ -0,0 +1,968 @@ +// 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 accepts only exact unit names. +// Enumeration is restricted to units with an explicit read grant, inspection +// exposes a fixed set of fields, and every mutation 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, +} + +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 { + 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("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..310853ed --- /dev/null +++ b/builtins/systemctl/systemctl_test.go @@ -0,0 +1,601 @@ +// 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{ + AuthorizeSystemd: func(operations ...builtins.SystemdOperation) error { + if authorized != nil { + *authorized = append(*authorized, operations...) + } + return nil + }, + Systemd: &builtins.SystemdServices{ServiceState: reader, ServiceControl: controller}, + } +} + +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}, 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{}) + + 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, "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..0b9fcae9 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,13 +164,14 @@ 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(&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)") if err := cmd.ExecuteContext(ctx); err != nil { diff --git a/cmd/rshell/main_test.go b/cmd/rshell/main_test.go index 7d620d17..0afd1367 100644 --- a/cmd/rshell/main_test.go +++ b/cmd/rshell/main_test.go @@ -180,13 +180,14 @@ 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, "--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 +320,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 +348,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 +357,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 +392,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 +410,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..9d91deee 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,81 @@ 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`, and compare +`org.freedesktop.DBus.Peer.GetMachineId` with the configured machine ID before +addressing `org.freedesktop.systemd1`. 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 manager access, the builtin MUST validate the complete request and +authorize every exact unit/action pair. `read` is the only non-mutating action. +Runtime `start`, `stop`, `reload`, and `restart` jobs, `reset-failed`, and +persistent `enable`/`disable` are structured remediation exceptions and require +both their exact grants and remediation mode. 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..428d2ed9 --- /dev/null +++ b/internal/systemd/manager_linux.go @@ -0,0 +1,255 @@ +// 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" + "strings" + "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} + body, err := bus.call(ctx, "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus.Peer.GetMachineId") + if err != nil { + return closeOnError(managerMethodError("Peer.GetMachineId", "", err)) + } + var actualMachineID string + if err := storeManagerReply(body, &actualMachineID); err != nil { + return closeOnError(fmt.Errorf("systemd manager peer returned an invalid machine ID reply: %w", err)) + } + if len(actualMachineID) != 32 || !validID128(actualMachineID) { + return closeOnError(fmt.Errorf("systemd manager peer returned an invalid machine ID")) + } + if !strings.EqualFold(actualMachineID, expectedMachineID) { + return closeOnError(fmt.Errorf("systemd manager bus machine ID does not match the configured target")) + } + 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..75a70fe8 --- /dev/null +++ b/internal/systemd/manager_protocol.go @@ -0,0 +1,730 @@ +// 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" + 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) +} + +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) + } + if !path.IsValid() || !strings.HasPrefix(string(path), prefix) || len(path) == len(prefix) { + 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..1703e6db --- /dev/null +++ b/internal/systemd/manager_protocol_test.go @@ -0,0 +1,492 @@ +// 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 + } +} + +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)) + + 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..f605545e 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 @@ -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 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..291a97bf 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") @@ -66,37 +84,99 @@ func TestAllowedSystemServicesAllowsReadOutsideRemediationMode(t *testing.T) { 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 +242,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 +300,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/systemctl/basic/restricted_empty_list.yaml b/tests/scenarios/cmd/systemctl/basic/restricted_empty_list.yaml new file mode 100644 index 00000000..3c2a8948 --- /dev/null +++ b/tests/scenarios/cmd/systemctl/basic/restricted_empty_list.yaml @@ -0,0 +1,12 @@ +# 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: + 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..71e591ce --- /dev/null +++ b/tests/scenarios/cmd/systemctl/errors/denied_status.yaml @@ -0,0 +1,11 @@ +# Scenario runners do not grant systemd capabilities by default. +skip_assert_against_bash: true +description: systemctl status enforces the exact unit read allowlist +input: + 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..70d8a955 --- /dev/null +++ b/tests/scenarios/cmd/systemctl/errors/glob_rejected.yaml @@ -0,0 +1,11 @@ +# 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: + 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/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/errors/start_read_only.yaml b/tests/scenarios/cmd/systemctl/errors/start_read_only.yaml new file mode 100644 index 00000000..d42dd434 --- /dev/null +++ b/tests/scenarios/cmd/systemctl/errors/start_read_only.yaml @@ -0,0 +1,11 @@ +# Runtime jobs are unavailable outside remediation mode, even when no grant is configured. +skip_assert_against_bash: true +description: systemctl start requires remediation mode +input: + script: |+ + systemctl start api.service +expect: + stdout: |+ + stderr: |+ + systemctl: systemd action "start" requires remediation mode + 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..42badc14 --- /dev/null +++ b/tests/scenarios/cmd/systemctl/help/help.yaml @@ -0,0 +1,16 @@ +# 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: + script: |+ + systemctl --help +expect: + stdout_contains: + - "Usage: systemctl [OPTION]... COMMAND [UNIT]..." + - "list-units" + - "status" + - "reload-or-restart" + - "reset-failed" + - "enable" + stderr: |+ + exit_code: 0 From ffaee1e925f73470c496f936b6c73bffedd19c65 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Fri, 17 Jul 2026 15:04:44 -0400 Subject: [PATCH 02/19] fix(systemctl): require remediation mode for all commands --- AGENTS.md | 2 +- README.md | 10 +-- SHELL_FEATURES.md | 6 +- builtins/systemctl/systemctl.go | 24 +++++-- builtins/systemctl/systemctl_test.go | 65 ++++++++++++++++++- docs/RULES.md | 20 ++++-- interp/api.go | 17 +++-- interp/system_services_test.go | 4 +- .../cmd/help/help_readonly_mode.yaml | 3 +- .../basic/restricted_empty_list.yaml | 1 + .../cmd/systemctl/errors/denied_status.yaml | 1 + .../cmd/systemctl/errors/glob_rejected.yaml | 1 + .../cmd/systemctl/errors/help_read_only.yaml | 11 ++++ .../cmd/systemctl/errors/read_only.yaml | 11 ++++ .../cmd/systemctl/errors/start_read_only.yaml | 11 ---- tests/scenarios/cmd/systemctl/help/help.yaml | 2 + 16 files changed, 144 insertions(+), 45 deletions(-) create mode 100644 tests/scenarios/cmd/systemctl/errors/help_read_only.yaml create mode 100644 tests/scenarios/cmd/systemctl/errors/read_only.yaml delete mode 100644 tests/scenarios/cmd/systemctl/errors/start_read_only.yaml diff --git a/AGENTS.md b/AGENTS.md index f1f9642d..80af20ea 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` 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, and remediation mode for mutations. 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 bus peer's machine ID before fixed manager requests. See the trusted systemd target exception in `docs/RULES.md` for the complete backend requirements and indirect systemd effects. +- **`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 bus peer's machine ID before fixed manager 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/README.md b/README.md index 464cf1b6..0ab3b77e 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 units | All units and actions blocked | `AllowedSystemServices` with exact unit/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 | @@ -73,7 +73,7 @@ Every access path is default-deny: 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. `read` is available in read-only mode and is also the visibility grant for restricted `systemctl list-units`: units without an exact `read` grant are never enumerated. Every other action is mutating and requires remediation mode in addition to 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. +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{ @@ -117,7 +117,7 @@ Explicit targets may map those host locations to arbitrary absolute container pa ### Restricted systemctl -`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. +`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 | |-----------|-------------------|----------------------| @@ -130,7 +130,7 @@ Explicit targets may map those host locations to arbitrary absolute container pa | Clear failed state | `reset-failed UNIT...` | `UNIT:reset-failed` | | Unit-file state | `enable\|disable [--now] UNIT...` | Matching `enable`/`disable`; `--now` additionally requires `start`/`stop` | -All mutating operations require remediation mode as well as their listed 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 every mutation remains remediation-only. +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. @@ -175,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 b869e9ed..4bee1c5e 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -28,7 +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]...` — bounded Linux system-manager inspection and control for exact, fully suffixed unit names; 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 operations are `status`, fixed-property `show`, `is-active`, `is-failed`, and `is-enabled`; remediation-only 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` +- ✅ `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 @@ -123,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 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, `list-units` sees only exact `read` grants, every mutating action requires remediation mode, and allowing all commands does not bypass this policy; configure grants through `interp.AllowedSystemServices` or CLI `--allowed-services UNIT:ACTION[+ACTION...]` +- ✅ 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 its 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/builtins/systemctl/systemctl.go b/builtins/systemctl/systemctl.go index bd217714..45fb5df9 100644 --- a/builtins/systemctl/systemctl.go +++ b/builtins/systemctl/systemctl.go @@ -5,10 +5,11 @@ // Package systemctl implements a capability-bounded systemd unit manager. // -// Unlike the host systemctl binary, this builtin accepts only exact unit names. -// Enumeration is restricted to units with an explicit read grant, inspection -// exposes a fixed set of fields, and every mutation is authorized before the -// trusted systemd backend is called. +// 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 ( @@ -66,9 +67,10 @@ var showPropertySet = func() map[string]struct{} { // Cmd is the systemctl builtin command descriptor. var Cmd = builtins.Command{ - Name: "systemctl", - Description: "inspect and control explicitly authorized systemd units", - MakeFlags: makeFlags, + Name: "systemctl", + Description: "inspect and control explicitly authorized systemd units", + MakeFlags: makeFlags, + RemediationOnly: true, } type flags struct { @@ -102,6 +104,13 @@ func makeFlags(fs *builtins.FlagSet) builtins.HandlerFunc { 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{} @@ -175,6 +184,7 @@ func (options flags) run(fs *builtins.FlagSet) builtins.HandlerFunc { 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") diff --git a/builtins/systemctl/systemctl_test.go b/builtins/systemctl/systemctl_test.go index 310853ed..bdaa5e60 100644 --- a/builtins/systemctl/systemctl_test.go +++ b/builtins/systemctl/systemctl_test.go @@ -124,6 +124,7 @@ func runSystemctl(t *testing.T, args []string, callCtx *builtins.CallContext) in 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...) @@ -134,6 +135,65 @@ func permissiveContext(reader builtins.SystemServiceStateReader, controller buil } } +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, @@ -580,7 +640,7 @@ func TestDangerousHostSystemctlOptionsAreRejected(t *testing.T) { } { t.Run(strings.Join(args, "_"), func(t *testing.T) { var stdout, stderr bytes.Buffer - result := handler(context.Background(), &builtins.CallContext{Stdout: &stdout, Stderr: &stderr}, args) + 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") @@ -589,11 +649,12 @@ func TestDangerousHostSystemctlOptionsAreRejected(t *testing.T) { } func TestHelpDocumentsRestrictedEnumerationWithoutCapabilities(t *testing.T) { - got := runSystemctl(t, []string{"--help"}, &builtins.CallContext{}) + 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") diff --git a/docs/RULES.md b/docs/RULES.md index 9d91deee..8315dbcf 100644 --- a/docs/RULES.md +++ b/docs/RULES.md @@ -158,13 +158,19 @@ 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 manager access, the builtin MUST validate the complete request and -authorize every exact unit/action pair. `read` is the only non-mutating action. -Runtime `start`, `stop`, `reload`, and `restart` jobs, `reset-failed`, and -persistent `enable`/`disable` are structured remediation exceptions and require -both their exact grants and remediation mode. Conditional reload-or-restart -operations require both `reload` and `restart`; `enable --now` additionally -requires `start`, and `disable --now` additionally requires `stop`. Compound +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 diff --git a/interp/api.go b/interp/api.go index f605545e..7bcf1b90 100644 --- a/interp/api.go +++ b/interp/api.go @@ -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 @@ -874,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/system_services_test.go b/interp/system_services_test.go index 291a97bf..bcdc8126 100644 --- a/interp/system_services_test.go +++ b/interp/system_services_test.go @@ -80,7 +80,9 @@ 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", 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 index 3c2a8948..a07166f4 100644 --- a/tests/scenarios/cmd/systemctl/basic/restricted_empty_list.yaml +++ b/tests/scenarios/cmd/systemctl/basic/restricted_empty_list.yaml @@ -2,6 +2,7 @@ skip_assert_against_bash: true description: bare systemctl lists no units when no read grants are configured input: + mode: remediation script: |+ systemctl expect: diff --git a/tests/scenarios/cmd/systemctl/errors/denied_status.yaml b/tests/scenarios/cmd/systemctl/errors/denied_status.yaml index 71e591ce..9ffb3ea0 100644 --- a/tests/scenarios/cmd/systemctl/errors/denied_status.yaml +++ b/tests/scenarios/cmd/systemctl/errors/denied_status.yaml @@ -2,6 +2,7 @@ skip_assert_against_bash: true description: systemctl status enforces the exact unit read allowlist input: + mode: remediation script: |+ systemctl status api.service expect: diff --git a/tests/scenarios/cmd/systemctl/errors/glob_rejected.yaml b/tests/scenarios/cmd/systemctl/errors/glob_rejected.yaml index 70d8a955..1208d2de 100644 --- a/tests/scenarios/cmd/systemctl/errors/glob_rejected.yaml +++ b/tests/scenarios/cmd/systemctl/errors/glob_rejected.yaml @@ -2,6 +2,7 @@ skip_assert_against_bash: true description: systemctl rejects globbed unit operands before authorization input: + mode: remediation script: |+ systemctl status 'api*.service' expect: 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_read_only.yaml b/tests/scenarios/cmd/systemctl/errors/start_read_only.yaml deleted file mode 100644 index d42dd434..00000000 --- a/tests/scenarios/cmd/systemctl/errors/start_read_only.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# Runtime jobs are unavailable outside remediation mode, even when no grant is configured. -skip_assert_against_bash: true -description: systemctl start requires remediation mode -input: - script: |+ - systemctl start api.service -expect: - stdout: |+ - stderr: |+ - systemctl: systemd action "start" requires remediation mode - exit_code: 1 diff --git a/tests/scenarios/cmd/systemctl/help/help.yaml b/tests/scenarios/cmd/systemctl/help/help.yaml index 42badc14..d3808a17 100644 --- a/tests/scenarios/cmd/systemctl/help/help.yaml +++ b/tests/scenarios/cmd/systemctl/help/help.yaml @@ -2,11 +2,13 @@ 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" From 376a1047abab46dd1f6e9f90dc363aad249667c6 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Fri, 17 Jul 2026 16:43:09 -0400 Subject: [PATCH 03/19] fix(cli): document systemctl remediation mode --- cmd/rshell/main.go | 2 +- cmd/rshell/main_test.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/rshell/main.go b/cmd/rshell/main.go index 0b9fcae9..3c548448 100644 --- a/cmd/rshell/main.go +++ b/cmd/rshell/main.go @@ -172,7 +172,7 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. 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(&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)") + 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 0afd1367..4a7ac2ac 100644 --- a/cmd/rshell/main_test.go +++ b/cmd/rshell/main_test.go @@ -183,6 +183,7 @@ func TestHelp(t *testing.T) { 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") From a21e4ec0ca57630038863458cda0fd3d0c0326a3 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Mon, 20 Jul 2026 10:53:46 -0400 Subject: [PATCH 04/19] fix(systemctl): reject nested manager object paths --- internal/systemd/manager_protocol.go | 3 ++- internal/systemd/manager_protocol_test.go | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/systemd/manager_protocol.go b/internal/systemd/manager_protocol.go index 75a70fe8..9e7dc961 100644 --- a/internal/systemd/manager_protocol.go +++ b/internal/systemd/manager_protocol.go @@ -641,7 +641,8 @@ func validateManagerObjectPath(name string, path dbus.ObjectPath, prefix string) if len(path) > maxManagerObjectPath { return fmt.Errorf("systemd manager returned an oversized %s object path", name) } - if !path.IsValid() || !strings.HasPrefix(string(path), prefix) || len(path) == len(prefix) { + 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 diff --git a/internal/systemd/manager_protocol_test.go b/internal/systemd/manager_protocol_test.go index 1703e6db..8450e015 100644 --- a/internal/systemd/manager_protocol_test.go +++ b/internal/systemd/manager_protocol_test.go @@ -481,6 +481,8 @@ func TestManagerBoundaryValidation(t *testing.T) { 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 { From 1966e4de2a329260d0097677afa322ef05dccc47 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Mon, 20 Jul 2026 12:59:19 -0400 Subject: [PATCH 05/19] fix(systemctl): verify systemd manager machine ID --- AGENTS.md | 2 +- README.md | 2 +- SHELL_FEATURES.md | 2 +- docs/RULES.md | 11 ++++++----- internal/systemd/manager_linux.go | 16 ++-------------- internal/systemd/manager_protocol.go | 19 +++++++++++++++++++ internal/systemd/manager_protocol_test.go | 23 +++++++++++++++++++++++ 7 files changed, 53 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 80af20ea..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` 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 bus peer's machine ID before fixed manager requests. See the trusted systemd target exception in `docs/RULES.md` for the complete backend requirements and indirect systemd effects. +- **`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/README.md b/README.md index 0ab3b77e..afd72bfc 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ The development CLI accepts equivalent grants through `--allowed-services mysql. **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. 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 bus peer's machine ID against `MachineIDPath` before issuing any fixed manager request. +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 | |-----------|------------------------| diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index 4bee1c5e..10914654 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -124,7 +124,7 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c - ✅ 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 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 its machine ID, while the private `/run/systemd/private` endpoint is unsupported +- ✅ 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` diff --git a/docs/RULES.md b/docs/RULES.md index 8315dbcf..d698af79 100644 --- a/docs/RULES.md +++ b/docs/RULES.md @@ -123,11 +123,12 @@ 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`, and compare -`org.freedesktop.DBus.Peer.GetMachineId` with the configured machine ID before -addressing `org.freedesktop.systemd1`. Missing manager configuration, machine-ID -mismatch, path replacement, malformed authentication, and unsupported platforms -MUST fail closed. +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, diff --git a/internal/systemd/manager_linux.go b/internal/systemd/manager_linux.go index 428d2ed9..867b4f30 100644 --- a/internal/systemd/manager_linux.go +++ b/internal/systemd/manager_linux.go @@ -15,7 +15,6 @@ import ( "os" "path/filepath" "strconv" - "strings" "time" "github.com/DataDog/rshell/builtins" @@ -186,19 +185,8 @@ func (c *Client) openManagerBus(ctx context.Context) (*dbusManagerBus, error) { } bus := &dbusManagerBus{connection: connection, signalHandler: signalHandler} - body, err := bus.call(ctx, "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus.Peer.GetMachineId") - if err != nil { - return closeOnError(managerMethodError("Peer.GetMachineId", "", err)) - } - var actualMachineID string - if err := storeManagerReply(body, &actualMachineID); err != nil { - return closeOnError(fmt.Errorf("systemd manager peer returned an invalid machine ID reply: %w", err)) - } - if len(actualMachineID) != 32 || !validID128(actualMachineID) { - return closeOnError(fmt.Errorf("systemd manager peer returned an invalid machine ID")) - } - if !strings.EqualFold(actualMachineID, expectedMachineID) { - return closeOnError(fmt.Errorf("systemd manager bus machine ID does not match the configured target")) + if err := verifySystemdManagerMachineID(ctx, bus, expectedMachineID); err != nil { + return closeOnError(err) } return bus, nil } diff --git a/internal/systemd/manager_protocol.go b/internal/systemd/manager_protocol.go index 9e7dc961..56d1d58d 100644 --- a/internal/systemd/manager_protocol.go +++ b/internal/systemd/manager_protocol.go @@ -24,6 +24,7 @@ const ( 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/" @@ -41,6 +42,24 @@ type managerBus interface { 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 diff --git a/internal/systemd/manager_protocol_test.go b/internal/systemd/manager_protocol_test.go index 8450e015..d9cbf4e1 100644 --- a/internal/systemd/manager_protocol_test.go +++ b/internal/systemd/manager_protocol_test.go @@ -60,6 +60,29 @@ func (b *fakeManagerBus) removeSignals(channel chan<- *dbus.Signal) { } } +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 From 524d626190e437eee23d0b508857bf3f1aea0c25 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 22 Jul 2026 13:36:03 -0400 Subject: [PATCH 06/19] refactor: move systemd constants and rename builtins/systemd.go --- builtins/builtins.go | 18 ------------------ builtins/{systemd.go => system_service.go} | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 18 deletions(-) rename builtins/{systemd.go => system_service.go} (88%) diff --git a/builtins/builtins.go b/builtins/builtins.go index 0e4964a9..902f2d75 100644 --- a/builtins/builtins.go +++ b/builtins/builtins.go @@ -57,24 +57,6 @@ type AllowedPath struct { Access AllowedPathAccess } -// SystemServiceAction identifies an operation that a builtin may perform on -// 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" - SystemServiceStart SystemServiceAction = "start" - SystemServiceStop SystemServiceAction = "stop" - SystemServiceReload SystemServiceAction = "reload" - SystemServiceRestart SystemServiceAction = "restart" - SystemServiceResetFailed SystemServiceAction = "reset-failed" - SystemServiceEnable SystemServiceAction = "enable" - SystemServiceDisable SystemServiceAction = "disable" -) - const ( // SystemdJournaldService is the exact service name used for journal-wide // operations such as kernel log reads, disk usage, rotation, and vacuuming. diff --git a/builtins/systemd.go b/builtins/system_service.go similarity index 88% rename from builtins/systemd.go rename to builtins/system_service.go index 542e9587..e075a587 100644 --- a/builtins/systemd.go +++ b/builtins/system_service.go @@ -33,6 +33,24 @@ const ( MaxSystemServiceFieldBytes = 64 * 1024 ) +// SystemServiceAction identifies an operation that a builtin may perform on +// 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" + SystemServiceStart SystemServiceAction = "start" + SystemServiceStop SystemServiceAction = "stop" + SystemServiceReload SystemServiceAction = "reload" + SystemServiceRestart SystemServiceAction = "restart" + SystemServiceResetFailed SystemServiceAction = "reset-failed" + SystemServiceEnable SystemServiceAction = "enable" + SystemServiceDisable SystemServiceAction = "disable" +) + // 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 From 8d9ba182067c28632afaa933c905105e846c946d Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 22 Jul 2026 13:56:36 -0400 Subject: [PATCH 07/19] test(systemctl): cover D-Bus auth write boundary --- internal/systemd/manager_dbus_limit.go | 5 +++- internal/systemd/manager_dbus_limit_test.go | 27 +++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/internal/systemd/manager_dbus_limit.go b/internal/systemd/manager_dbus_limit.go index 23a0373d..7781449b 100644 --- a/internal/systemd/manager_dbus_limit.go +++ b/internal/systemd/manager_dbus_limit.go @@ -37,7 +37,10 @@ type boundedDBusConn struct { func (c *boundedDBusConn) Write(data []byte) (int, error) { if bytes.Equal(data, dbusAuthBegin) { - // Auth writes BEGIN immediately before starting the binary message reader. + // godbus currently emits BEGIN\r\n as an isolated Write immediately before + // starting the binary message reader. These outbound bytes are not + // peer-controlled, and this exact match intentionally depends on that write + // boundary; a coalesced write remains in bounded authentication mode. // Publish the mode first so a fast bus reply cannot race the transition. c.binaryMode.Store(true) } diff --git a/internal/systemd/manager_dbus_limit_test.go b/internal/systemd/manager_dbus_limit_test.go index 19fb3ff3..88cd14ba 100644 --- a/internal/systemd/manager_dbus_limit_test.go +++ b/internal/systemd/manager_dbus_limit_test.go @@ -63,6 +63,33 @@ func TestBoundedDBusConnPassesAuthenticationThenBoundsFrames(t *testing.T) { assert.Equal(t, dbusAuthBegin, transport.writes.Bytes()) } +func TestBoundedDBusConnRequiresIsolatedAuthenticationBeginWrite(t *testing.T) { + tests := []struct { + name string + write []byte + }{ + {name: "preceding bytes", write: append([]byte("OK\r\n"), dbusAuthBegin...)}, + {name: "following bytes", write: append(append([]byte(nil), dbusAuthBegin...), 'l')}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + transport := &memoryReadWriteCloser{reader: bytes.NewReader(bytes.Repeat([]byte{'x'}, maxManagerDBusAuthBytes+1))} + connection := &boundedDBusConn{ReadWriteCloser: transport} + + n, err := connection.Write(test.write) + require.NoError(t, err) + assert.Equal(t, len(test.write), n) + assert.False(t, connection.binaryMode.Load()) + assert.Equal(t, test.write, transport.writes.Bytes()) + + data, err := io.ReadAll(connection) + require.Error(t, err) + assert.Len(t, data, maxManagerDBusAuthBytes) + assert.Contains(t, err.Error(), "authentication response exceeds") + }) + } +} + func TestBoundedDBusConnRejectsOversizedFrameBeforeExposingHeader(t *testing.T) { header := dbusTestFrame(binary.LittleEndian, nil)[:16] binary.LittleEndian.PutUint32(header[4:8], maxManagerDBusMessageSize) From e619c92d1dd364678ef0e984da2ab891a4a14dac Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 22 Jul 2026 14:00:48 -0400 Subject: [PATCH 08/19] test(systemctl): cover path-shaped unit rejection --- .../cmd/systemctl/errors/path_rejected.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 tests/scenarios/cmd/systemctl/errors/path_rejected.yaml diff --git a/tests/scenarios/cmd/systemctl/errors/path_rejected.yaml b/tests/scenarios/cmd/systemctl/errors/path_rejected.yaml new file mode 100644 index 00000000..44d1a8c2 --- /dev/null +++ b/tests/scenarios/cmd/systemctl/errors/path_rejected.yaml @@ -0,0 +1,14 @@ +# Unit operands cannot use path syntax to escape exact systemd selectors. +skip_assert_against_bash: true +description: systemctl rejects path-shaped unit operands before authorization +input: + mode: remediation + script: |+ + systemctl status '../../etc/passwd.service' + systemctl status 'foo/bar.service' +expect: + stdout: |+ + stderr: |+ + systemctl: invalid character in exact unit name "../../etc/passwd.service" + systemctl: invalid character in exact unit name "foo/bar.service" + exit_code: 1 From ee93f9f8fa57b9d694ace8d407d44abfa92f48b5 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 22 Jul 2026 14:07:07 -0400 Subject: [PATCH 09/19] test(systemctl): assert non-Linux manager stubs fail closed --- internal/systemd/manager_unsupported_test.go | 70 ++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 internal/systemd/manager_unsupported_test.go diff --git a/internal/systemd/manager_unsupported_test.go b/internal/systemd/manager_unsupported_test.go new file mode 100644 index 00000000..93a66bb1 --- /dev/null +++ b/internal/systemd/manager_unsupported_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. + +//go:build !linux + +package systemd + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/builtins" +) + +func TestSystemdManagerMethodsFailClosedOffLinux(t *testing.T) { + client := NewClient(Target{}) + ctx := context.Background() + + states, err := client.ListSystemServices(ctx, builtins.SystemServiceListRequest{Services: []string{"api.service"}}) + assert.Nil(t, states) + require.ErrorIs(t, err, builtins.ErrSystemdUnsupported) + + states, err = client.InspectSystemServices(ctx, []string{"api.service"}) + assert.Nil(t, states) + require.ErrorIs(t, err, builtins.ErrSystemdUnsupported) + + enabledStates, err := client.SystemServiceEnabledState(ctx, []string{"api.service"}) + assert.Nil(t, enabledStates) + require.ErrorIs(t, err, builtins.ErrSystemdUnsupported) + + controllerCalls := []struct { + name string + call func() error + }{ + { + name: "run jobs", + call: func() error { + return client.RunSystemServiceJobs(ctx, builtins.SystemServiceJobStart, []string{"api.service"}) + }, + }, + { + name: "reset failed", + call: func() error { + return client.ResetFailedSystemServices(ctx, []string{"api.service"}) + }, + }, + { + name: "enable", + call: func() error { + return client.EnableSystemServices(ctx, []string{"api.service"}) + }, + }, + { + name: "disable", + call: func() error { + return client.DisableSystemServices(ctx, []string{"api.service"}) + }, + }, + } + for _, test := range controllerCalls { + t.Run(test.name, func(t *testing.T) { + require.ErrorIs(t, test.call(), builtins.ErrSystemdUnsupported) + }) + } +} From 13f015e9976bfa4e57e5c6a15eac14d0505516b0 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 22 Jul 2026 14:14:01 -0400 Subject: [PATCH 10/19] test(systemctl): exercise job wait terminal paths --- internal/systemd/manager_protocol_test.go | 150 ++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/internal/systemd/manager_protocol_test.go b/internal/systemd/manager_protocol_test.go index d9cbf4e1..b581ec8c 100644 --- a/internal/systemd/manager_protocol_test.go +++ b/internal/systemd/manager_protocol_test.go @@ -339,6 +339,156 @@ func TestRunSystemServiceJobsAcceptsSkippedResult(t *testing.T) { require.NoError(t, runSystemServiceJobsWithBus(context.Background(), bus, builtins.SystemServiceJobTryRestart, []string{"api.service"})) } +func TestWaitSystemdJobClassifiesTerminalPaths(t *testing.T) { + const selector = "api.service" + expectedPath := dbus.ObjectPath(systemdJobPathPrefix + "42") + jobRemoved := func(id uint32, path dbus.ObjectPath, unit, result string) *dbus.Signal { + return &dbus.Signal{ + Path: systemdManagerPath, + Name: systemdManagerIface + ".JobRemoved", + Body: []any{id, path, unit, result}, + } + } + matching := func(result string) *dbus.Signal { + return jobRemoved(42, expectedPath, selector, result) + } + + unrelatedSignals := make([]*dbus.Signal, 0, maxManagerSignalsRead+1) + for range maxManagerSignalsRead { + unrelatedSignals = append(unrelatedSignals, jobRemoved(41, dbus.ObjectPath(systemdJobPathPrefix+"41"), "other.service", "done")) + } + unrelatedSignals = append(unrelatedSignals, matching("done")) + + tests := []struct { + name string + signals []*dbus.Signal + closeSignals bool + cancelContext bool + overflow bool + wantErr string + wantErrIs error + wantRemaining int + }{ + { + name: "done", + signals: []*dbus.Signal{matching("done")}, + }, + { + name: "skipped", + signals: []*dbus.Signal{matching("skipped")}, + }, + { + name: "canceled context", + cancelContext: true, + wantErr: "context canceled", + wantErrIs: context.Canceled, + }, + { + name: "closed signal channel", + closeSignals: true, + wantErr: `systemd manager connection closed while waiting for "api.service"`, + }, + { + name: "nil signal", + signals: []*dbus.Signal{nil}, + wantErr: "systemd manager returned a nil job signal", + }, + { + name: "malformed body", + signals: []*dbus.Signal{{ + Path: systemdManagerPath, + Name: systemdManagerIface + ".JobRemoved", + }}, + wantErr: "systemd manager JobRemoved signal has an invalid body: dbus.Store: length mismatch", + }, + { + name: "mismatched job ID and path", + signals: []*dbus.Signal{jobRemoved(42, dbus.ObjectPath(systemdJobPathPrefix+"43"), selector, "done")}, + wantErr: "systemd manager returned an invalid job object path", + }, + { + name: "invalid unit", + signals: []*dbus.Signal{jobRemoved(42, expectedPath, "", "done")}, + wantErr: "systemd manager JobRemoved signal has an invalid unit: systemd unit name must not be empty", + }, + { + name: "invalid result", + signals: []*dbus.Signal{matching("")}, + wantErr: "job result must not be empty", + }, + { + name: "failed result", + signals: []*dbus.Signal{matching("failed")}, + wantErr: `systemd manager job for "api.service" finished with result "failed"`, + }, + { + name: "canceled result", + signals: []*dbus.Signal{matching("canceled")}, + wantErr: `systemd manager job for "api.service" finished with result "canceled"`, + }, + { + name: "timeout result", + signals: []*dbus.Signal{matching("timeout")}, + wantErr: `systemd manager job for "api.service" finished with result "timeout"`, + }, + { + name: "unrelated signals before matching result", + signals: []*dbus.Signal{ + {Path: systemdManagerPath, Name: "org.example.Unrelated"}, + {Path: dbus.ObjectPath("/org/example"), Name: systemdManagerIface + ".JobRemoved"}, + jobRemoved(41, dbus.ObjectPath(systemdJobPathPrefix+"41"), "other.service", "done"), + matching("done"), + }, + }, + { + name: "signal queue overflow", + overflow: true, + wantErr: `systemd manager signal queue overflowed while waiting for "api.service"`, + }, + { + name: "unrelated signal limit", + signals: unrelatedSignals, + wantErr: `systemd manager emitted too many unrelated job results while waiting for "api.service"`, + wantRemaining: 1, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var ctx context.Context = context.Background() + if test.cancelContext { + canceled, cancel := context.WithCancel(ctx) + cancel() + ctx = canceled + } + + signals := make(chan *dbus.Signal, len(test.signals)) + for _, signal := range test.signals { + signals <- signal + } + if test.closeSignals { + close(signals) + } + + overflow := make(chan struct{}) + if test.overflow { + close(overflow) + } + + err := waitSystemdJob(ctx, signals, overflow, expectedPath, selector) + require.Equal(t, test.wantRemaining, len(signals)) + if test.wantErr == "" { + require.NoError(t, err) + return + } + require.EqualError(t, err, test.wantErr) + if test.wantErrIs != nil { + require.ErrorIs(t, err, test.wantErrIs) + } + }) + } +} + func TestRunSystemServiceTryJobsIgnoreMaskedUnits(t *testing.T) { tests := []struct { name string From 68860aa81d45d90ea7d0d5699f5439ea779df56f Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 22 Jul 2026 14:20:41 -0400 Subject: [PATCH 11/19] test(systemctl): cover persistent mutation failures --- internal/systemd/manager_protocol_test.go | 212 ++++++++++++++++++++-- 1 file changed, 194 insertions(+), 18 deletions(-) diff --git a/internal/systemd/manager_protocol_test.go b/internal/systemd/manager_protocol_test.go index b581ec8c..18d776f9 100644 --- a/internal/systemd/manager_protocol_test.go +++ b/internal/systemd/manager_protocol_test.go @@ -607,6 +607,42 @@ func TestRunSystemServiceJobsReportsSignalOverflowAsUnknownOutcome(t *testing.T) assert.Contains(t, err.Error(), "final state is unknown") } +func TestResetFailedSystemServicesCallsManagerAndValidatesEmptyReply(t *testing.T) { + tests := []struct { + name string + reply []any + wantErr string + }{ + {name: "success"}, + { + name: "unexpected non-empty reply", + reply: []any{true}, + wantErr: `systemd manager operation stopped after completing 1 units (api.service); completed operations were not rolled back: systemd manager ResetFailedUnit returned an invalid reply for "api.service": reply has 1 values; expected 0`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + bus := &fakeManagerBus{respond: func(fakeManagerCall) ([]any, error) { + return test.reply, nil + }} + + err := resetFailedSystemServicesWithBus(context.Background(), bus, []string{"api.service"}) + if test.wantErr == "" { + require.NoError(t, err) + } else { + require.EqualError(t, err, test.wantErr) + } + require.Equal(t, []fakeManagerCall{{ + destination: systemdBusDestination, + path: systemdManagerPath, + method: systemdManagerIface + ".ResetFailedUnit", + arguments: []any{"api.service"}, + }}, bus.calls) + }) + } +} + func TestResetFailedSystemServicesReportsAmbiguousTransportFailure(t *testing.T) { bus := &fakeManagerBus{respond: func(fakeManagerCall) ([]any, error) { return nil, errors.New("connection closed") @@ -618,26 +654,166 @@ func TestResetFailedSystemServicesReportsAmbiguousTransportFailure(t *testing.T) 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) - } +func TestSystemServiceUnitFileMutations(t *testing.T) { + units := []string{"api.service", "backup.timer"} + type mutationTest struct { + name string + method string + arguments []any + validChange []any + replyForChanges func([][]any) []any + replyValues int + run func(context.Context, managerBus, []string) error + } + mutations := []mutationTest{ + { + name: "enable", + method: "EnableUnitFiles", + arguments: []any{units, false, false}, + validChange: []any{"symlink", "/etc/systemd/system/multi-user.target.wants/api.service", "/usr/lib/systemd/system/api.service"}, + replyForChanges: func(changes [][]any) []any { + return []any{true, changes} + }, + replyValues: 2, + run: enableSystemServicesWithBus, + }, + { + name: "disable", + method: "DisableUnitFiles", + arguments: []any{units, false}, + validChange: []any{"unlink", "/etc/systemd/system/multi-user.target.wants/api.service", ""}, + replyForChanges: func(changes [][]any) []any { + return []any{changes} + }, + replyValues: 1, + run: disableSystemServicesWithBus, + }, } - 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") + const completedWarning = "unit-file changes completed, but manager reload/validation failed; changes were not rolled back: " + for _, mutation := range mutations { + t.Run(mutation.name, func(t *testing.T) { + validChanges := [][]any{mutation.validChange} + duplicateChanges := [][]any{mutation.validChange, mutation.validChange} + tooManyChanges := make([][]any, maxUnitFileChanges+1) + for index := range tooManyChanges { + tooManyChanges[index] = mutation.validChange + } + methodFailureWarning := fmt.Sprintf("systemd manager %s failed; unit-file changes may have been partially applied and were not rolled back: ", mutation.method) + tests := []struct { + name string + mutationReply []any + mutationErr error + reloadReply []any + reloadErr error + wantReload bool + wantErr string + wantErrIs error + wantCompletedWarning bool + }{ + { + name: "success", + mutationReply: mutation.replyForChanges(validChanges), + wantReload: true, + }, + { + name: "D-Bus rejection", + mutationErr: dbus.Error{Name: "org.freedesktop.DBus.Error.AccessDenied"}, + wantErr: methodFailureWarning + fmt.Sprintf("systemd manager %s failed: org.freedesktop.DBus.Error.AccessDenied", mutation.method), + }, + { + name: "transport ambiguity", + mutationErr: context.DeadlineExceeded, + wantErr: methodFailureWarning + fmt.Sprintf("systemd manager %s failed: context deadline exceeded", mutation.method), + wantErrIs: context.DeadlineExceeded, + }, + { + name: "malformed mutation reply", + wantErr: completedWarning + fmt.Sprintf("systemd manager %s returned an invalid reply: reply has 0 values; expected %d", mutation.method, mutation.replyValues), + wantCompletedWarning: true, + }, + { + name: "unsupported change record", + mutationReply: mutation.replyForChanges([][]any{{"copy", "/etc/systemd/system/api.service", "/usr/lib/systemd/system/api.service"}}), + wantErr: completedWarning + "systemd manager returned an unsupported unit-file change type", + wantCompletedWarning: true, + }, + { + name: "duplicate change record", + mutationReply: mutation.replyForChanges(duplicateChanges), + wantErr: completedWarning + "systemd manager returned a duplicate unit-file change", + wantCompletedWarning: true, + }, + { + name: "too many change records", + mutationReply: mutation.replyForChanges(tooManyChanges), + wantErr: completedWarning + fmt.Sprintf("systemd manager returned too many unit-file changes (maximum %d)", maxUnitFileChanges), + wantCompletedWarning: true, + }, + { + name: "reload error", + mutationReply: mutation.replyForChanges(validChanges), + reloadErr: context.DeadlineExceeded, + wantReload: true, + wantErr: completedWarning + "systemd manager Reload failed: context deadline exceeded", + wantErrIs: context.DeadlineExceeded, + wantCompletedWarning: true, + }, + { + name: "malformed reload reply", + mutationReply: mutation.replyForChanges(validChanges), + reloadReply: []any{true}, + wantReload: true, + wantErr: completedWarning + "systemd manager Reload returned an invalid reply: reply has 1 values; expected 0", + wantCompletedWarning: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mutationMethod := systemdManagerIface + "." + mutation.method + bus := &fakeManagerBus{respond: func(call fakeManagerCall) ([]any, error) { + switch call.method { + case mutationMethod: + return test.mutationReply, test.mutationErr + case systemdManagerIface + ".Reload": + return test.reloadReply, test.reloadErr + default: + return nil, fmt.Errorf("unexpected method %q", call.method) + } + }} + + err := mutation.run(context.Background(), bus, units) + if test.wantErr == "" { + require.NoError(t, err) + } else { + require.EqualError(t, err, test.wantErr) + } + if test.wantErrIs != nil { + require.ErrorIs(t, err, test.wantErrIs) + } + if test.wantCompletedWarning { + require.ErrorContains(t, err, completedWarning) + } + + wantCalls := []fakeManagerCall{{ + destination: systemdBusDestination, + path: systemdManagerPath, + method: mutationMethod, + arguments: mutation.arguments, + }} + if test.wantReload { + wantCalls = append(wantCalls, fakeManagerCall{ + destination: systemdBusDestination, + path: systemdManagerPath, + method: systemdManagerIface + ".Reload", + }) + } + require.Equal(t, wantCalls, bus.calls) + }) + } + }) + } } func TestManagerBoundaryValidation(t *testing.T) { From e1ae16220ef595f2d38edba235a6e393be0d3bf1 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 22 Jul 2026 14:56:37 -0400 Subject: [PATCH 12/19] test(systemctl): cover pinned manager transport --- internal/systemd/manager_linux.go | 19 +- internal/systemd/manager_linux_test.go | 450 +++++++++++++++++++++++++ 2 files changed, 468 insertions(+), 1 deletion(-) create mode 100644 internal/systemd/manager_linux_test.go diff --git a/internal/systemd/manager_linux.go b/internal/systemd/manager_linux.go index 867b4f30..62a32ac5 100644 --- a/internal/systemd/manager_linux.go +++ b/internal/systemd/manager_linux.go @@ -192,18 +192,35 @@ func (c *Client) openManagerBus(ctx context.Context) (*dbusManagerBus, error) { } func (c *Client) dialManagerBus(ctx context.Context, path string) (net.Conn, error) { + socket, err := c.openManagerBusSocket(path) + if err != nil { + return nil, err + } + return dialPinnedManagerBus(ctx, socket) +} + +func (c *Client) openManagerBusSocket(path string) (*os.File, 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 { + _ = socket.Close() return nil, fmt.Errorf("inspect systemd manager bus socket: %w", err) } if info.Mode()&os.ModeSocket == 0 { + _ = socket.Close() return nil, fmt.Errorf("systemd manager bus endpoint is not a Unix socket") } + return socket, nil +} + +// dialPinnedManagerBus takes ownership of socket and closes it before +// returning. Reopening the pinned descriptor prevents later pathname changes +// from redirecting the connection. +func dialPinnedManagerBus(ctx context.Context, socket *os.File) (net.Conn, error) { + defer socket.Close() endpoint := filepath.Join(managerBusFDDir, strconv.Itoa(int(socket.Fd()))) var dialer net.Dialer connection, err := dialer.DialContext(ctx, "unix", endpoint) diff --git a/internal/systemd/manager_linux_test.go b/internal/systemd/manager_linux_test.go new file mode 100644 index 00000000..2cc4e037 --- /dev/null +++ b/internal/systemd/manager_linux_test.go @@ -0,0 +1,450 @@ +// 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 ( + "bufio" + "bytes" + "context" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + "time" + + "github.com/godbus/dbus/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testManagerMachineID = "0123456789abcdef0123456789abcdef" + testManagerBusGUID = "fedcba9876543210fedcba9876543210" +) + +func TestDialManagerBusConnectsToRealSocket(t *testing.T) { + dir := managerLinuxTestDir(t) + path := filepath.Join(dir, "bus") + listener := listenUnixSocket(t, path) + + connection, err := (&Client{}).dialManagerBus(context.Background(), path) + require.NoError(t, err) + t.Cleanup(func() { _ = connection.Close() }) + + require.NoError(t, listener.SetDeadline(time.Now().Add(time.Second))) + accepted, err := listener.AcceptUnix() + require.NoError(t, err) + t.Cleanup(func() { _ = accepted.Close() }) + + require.NoError(t, connection.SetDeadline(time.Now().Add(time.Second))) + require.NoError(t, accepted.SetDeadline(time.Now().Add(time.Second))) + _, err = connection.Write([]byte{0x42}) + require.NoError(t, err) + var received [1]byte + _, err = io.ReadFull(accepted, received[:]) + require.NoError(t, err) + assert.Equal(t, byte(0x42), received[0]) +} + +func TestDialManagerBusRejectsNonSocketEndpoints(t *testing.T) { + tests := []struct { + name string + setup func(*testing.T, string) string + }{ + { + name: "regular file", + setup: func(t *testing.T, dir string) string { + path := filepath.Join(dir, "file") + require.NoError(t, os.WriteFile(path, []byte("not a socket"), 0o600)) + return path + }, + }, + { + name: "final symlink to live socket", + setup: func(t *testing.T, dir string) string { + target := filepath.Join(dir, "real") + listenUnixSocket(t, target) + path := filepath.Join(dir, "link") + require.NoError(t, os.Symlink(target, path)) + return path + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + path := test.setup(t, managerLinuxTestDir(t)) + connection, err := (&Client{}).dialManagerBus(context.Background(), path) + assert.Nil(t, connection) + require.EqualError(t, err, "systemd manager bus endpoint is not a Unix socket") + }) + } +} + +func TestDialPinnedManagerBusIgnoresPathReplacementAndClosesPin(t *testing.T) { + dir := managerLinuxTestDir(t) + path := filepath.Join(dir, "bus") + original := listenUnixSocket(t, path) + + pinned, err := (&Client{}).openManagerBusSocket(path) + require.NoError(t, err) + require.NoError(t, os.Rename(path, filepath.Join(dir, "original"))) + attacker := listenUnixSocket(t, path) + + connection, dialErr := dialPinnedManagerBus(context.Background(), pinned) + _, statErr := pinned.Stat() + require.ErrorIs(t, statErr, os.ErrClosed) + require.NoError(t, dialErr) + t.Cleanup(func() { _ = connection.Close() }) + + require.NoError(t, original.SetDeadline(time.Now().Add(time.Second))) + accepted, err := original.AcceptUnix() + require.NoError(t, err) + t.Cleanup(func() { _ = accepted.Close() }) + + require.NoError(t, connection.SetDeadline(time.Now().Add(time.Second))) + require.NoError(t, accepted.SetDeadline(time.Now().Add(time.Second))) + _, err = connection.Write([]byte{0x7f}) + require.NoError(t, err) + var received [1]byte + _, err = io.ReadFull(accepted, received[:]) + require.NoError(t, err) + assert.Equal(t, byte(0x7f), received[0]) + + require.NoError(t, attacker.SetDeadline(time.Now().Add(100*time.Millisecond))) + _, err = attacker.AcceptUnix() + require.Error(t, err) + var netErr net.Error + require.ErrorAs(t, err, &netErr) + assert.True(t, netErr.Timeout()) +} + +func TestDialPinnedManagerBusHonorsDeadlineAndClosesPin(t *testing.T) { + dir := managerLinuxTestDir(t) + path := filepath.Join(dir, "bus") + listenUnixSocket(t, path) + pinned, err := (&Client{}).openManagerBusSocket(path) + require.NoError(t, err) + + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + connection, err := dialPinnedManagerBus(ctx, pinned) + assert.Nil(t, connection) + require.ErrorIs(t, err, context.DeadlineExceeded) + _, statErr := pinned.Stat() + require.ErrorIs(t, statErr, os.ErrClosed) +} + +func TestOpenManagerBusAuthenticatesAndVerifiesMachineID(t *testing.T) { + tests := []struct { + name string + peerMachineID string + wantErr string + }{ + {name: "matching machine ID", peerMachineID: testManagerMachineID}, + { + name: "mismatching machine ID", + peerMachineID: "11111111111111111111111111111111", + wantErr: "systemd manager peer machine ID does not match the configured target", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + dir := managerLinuxTestDir(t) + path := filepath.Join(dir, "bus") + listener := listenUnixSocket(t, path) + serverDone := startRawManagerDBusServer(listener, test.peerMachineID) + client := managerLinuxTestClient(t, dir, path) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + bus, openErr := client.openManagerBus(ctx) + var closeErr error + if bus != nil { + closeErr = bus.connection.Close() + } + serverErr := waitManagerDBusServer(t, serverDone) + + require.NoError(t, serverErr) + require.NoError(t, closeErr) + if test.wantErr == "" { + require.NoError(t, openErr) + require.NotNil(t, bus) + return + } + assert.Nil(t, bus) + require.EqualError(t, openErr, test.wantErr) + }) + } +} + +func TestOpenManagerBusHonorsDeadlineDuringAuthentication(t *testing.T) { + dir := managerLinuxTestDir(t) + path := filepath.Join(dir, "bus") + listener := listenUnixSocket(t, path) + authStarted := make(chan struct{}) + serverDone := startStalledManagerAuthServer(listener, authStarted) + client := managerLinuxTestClient(t, dir, path) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + bus, err := client.openManagerBus(ctx) + assert.Nil(t, bus) + require.ErrorIs(t, err, context.DeadlineExceeded) + select { + case <-authStarted: + default: + t.Fatal("manager D-Bus authentication did not start before the deadline") + } + require.NoError(t, waitManagerDBusServer(t, serverDone)) +} + +func managerLinuxTestDir(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("", "rsd-") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return dir +} + +func managerLinuxTestClient(t *testing.T, dir, socketPath string) *Client { + t.Helper() + machineIDPath := filepath.Join(dir, "machine-id") + require.NoError(t, os.WriteFile(machineIDPath, []byte(testManagerMachineID+"\n"), 0o600)) + return NewClient(Target{MachineIDPath: machineIDPath, ManagerBusSocket: socketPath}) +} + +func startRawManagerDBusServer(listener *net.UnixListener, machineID string) <-chan error { + done := make(chan error, 1) + go func() { + done <- serveRawManagerDBus(listener, machineID) + }() + return done +} + +func serveRawManagerDBus(listener *net.UnixListener, machineID string) error { + if err := listener.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + return err + } + connection, err := listener.AcceptUnix() + if err != nil { + return fmt.Errorf("accept manager D-Bus connection: %w", err) + } + defer connection.Close() + if err := connection.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + return err + } + reader := bufio.NewReader(connection) + if err := authenticateRawManagerDBus(reader, connection); err != nil { + return err + } + + hello, err := dbus.DecodeMessage(reader) + if err != nil { + return fmt.Errorf("decode Hello call: %w", err) + } + if err := validateRawManagerCall(hello, 0, "org.freedesktop.DBus", dbus.ObjectPath("/org/freedesktop/DBus"), "org.freedesktop.DBus", "Hello"); err != nil { + return fmt.Errorf("validate Hello call: %w", err) + } + if err := writeRawManagerReply(connection, hello, 1, ":1.42"); err != nil { + return fmt.Errorf("reply to Hello call: %w", err) + } + + machineIDCall, err := dbus.DecodeMessage(reader) + if err != nil { + return fmt.Errorf("decode Peer.GetMachineId call: %w", err) + } + if err := validateRawManagerCall(machineIDCall, dbus.FlagNoAutoStart, systemdBusDestination, systemdManagerPath, "org.freedesktop.DBus.Peer", "GetMachineId"); err != nil { + return fmt.Errorf("validate Peer.GetMachineId call: %w", err) + } + if err := writeRawManagerReply(connection, machineIDCall, 2, machineID); err != nil { + return fmt.Errorf("reply to Peer.GetMachineId call: %w", err) + } + + var trailing [1]byte + _, err = reader.Read(trailing[:]) + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return fmt.Errorf("wait for manager D-Bus client cleanup: %w", err) + } + return fmt.Errorf("manager D-Bus client sent unexpected data before closing") +} + +func authenticateRawManagerDBus(reader *bufio.Reader, writer io.Writer) error { + if err := readInitialManagerAuth(reader); err != nil { + return err + } + if err := writeAll(writer, []byte("REJECTED EXTERNAL\r\n")); err != nil { + return fmt.Errorf("offer EXTERNAL authentication: %w", err) + } + line, err := readManagerAuthLine(reader) + if err != nil { + return err + } + fields := strings.Fields(line) + if len(fields) < 2 || len(fields) > 3 || fields[0] != "AUTH" || fields[1] != "EXTERNAL" { + return fmt.Errorf("unexpected manager D-Bus authentication request %q", line) + } + if len(fields) == 3 { + identity, err := hex.DecodeString(fields[2]) + if err != nil || string(identity) != strconv.Itoa(os.Geteuid()) { + return fmt.Errorf("manager D-Bus EXTERNAL authentication used an unexpected identity") + } + } + if err := writeAll(writer, []byte("OK "+testManagerBusGUID+"\r\n")); err != nil { + return fmt.Errorf("accept EXTERNAL authentication: %w", err) + } + line, err = readManagerAuthLine(reader) + if err != nil { + return err + } + if line != "BEGIN" { + return fmt.Errorf("unexpected manager D-Bus authentication terminator %q", line) + } + return nil +} + +func readInitialManagerAuth(reader *bufio.Reader) error { + initial, err := reader.ReadByte() + if err != nil { + return fmt.Errorf("read manager D-Bus authentication prefix: %w", err) + } + if initial != 0 { + return fmt.Errorf("manager D-Bus authentication omitted the initial NUL byte") + } + line, err := readManagerAuthLine(reader) + if err != nil { + return err + } + if line != "AUTH" { + return fmt.Errorf("unexpected initial manager D-Bus authentication request %q", line) + } + return nil +} + +func readManagerAuthLine(reader *bufio.Reader) (string, error) { + line, err := reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("read manager D-Bus authentication line: %w", err) + } + if !strings.HasSuffix(line, "\r\n") { + return "", fmt.Errorf("manager D-Bus authentication line is not CRLF terminated") + } + return strings.TrimSuffix(line, "\r\n"), nil +} + +func validateRawManagerCall(message *dbus.Message, flags dbus.Flags, destination string, path dbus.ObjectPath, iface, member string) error { + if message.Type != dbus.TypeMethodCall { + return fmt.Errorf("message type is %s, not method call", message.Type) + } + if message.Flags != flags { + return fmt.Errorf("message flags are %v, expected %v", message.Flags, flags) + } + if message.Serial() == 0 { + return fmt.Errorf("message serial is zero") + } + if len(message.Body) != 0 { + return fmt.Errorf("message body is not empty") + } + expected := map[dbus.HeaderField]any{ + dbus.FieldDestination: destination, + dbus.FieldPath: path, + dbus.FieldInterface: iface, + dbus.FieldMember: member, + } + for field, want := range expected { + value, ok := message.Headers[field] + if !ok { + return fmt.Errorf("message header %d is missing", field) + } + if !reflect.DeepEqual(value.Value(), want) { + return fmt.Errorf("message header %d is %v, expected %v", field, value.Value(), want) + } + } + return nil +} + +func writeRawManagerReply(writer io.Writer, request *dbus.Message, serial uint32, body ...any) error { + headers := map[dbus.HeaderField]dbus.Variant{ + dbus.FieldReplySerial: dbus.MakeVariant(request.Serial()), + } + if len(body) > 0 { + headers[dbus.FieldSignature] = dbus.MakeVariant(dbus.SignatureOf(body...)) + } + reply := &dbus.Message{Type: dbus.TypeMethodReply, Headers: headers, Body: body} + var encoded bytes.Buffer + if err := reply.EncodeTo(&encoded, binary.LittleEndian); err != nil { + return err + } + frame := encoded.Bytes() + if len(frame) < 12 { + return fmt.Errorf("encoded manager D-Bus reply is truncated") + } + binary.LittleEndian.PutUint32(frame[8:12], serial) + return writeAll(writer, frame) +} + +func startStalledManagerAuthServer(listener *net.UnixListener, authStarted chan<- struct{}) <-chan error { + done := make(chan error, 1) + go func() { + if err := listener.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + done <- err + return + } + connection, err := listener.AcceptUnix() + if err != nil { + done <- fmt.Errorf("accept stalled manager D-Bus connection: %w", err) + return + } + defer connection.Close() + if err := connection.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + done <- err + return + } + reader := bufio.NewReader(connection) + if err := readInitialManagerAuth(reader); err != nil { + done <- err + return + } + close(authStarted) + var trailing [1]byte + _, err = reader.Read(trailing[:]) + if errors.Is(err, io.EOF) { + done <- nil + return + } + if err != nil { + done <- fmt.Errorf("wait for stalled manager D-Bus client cleanup: %w", err) + return + } + done <- fmt.Errorf("stalled manager D-Bus client sent unexpected data") + }() + return done +} + +func waitManagerDBusServer(t *testing.T, done <-chan error) error { + t.Helper() + select { + case err := <-done: + return err + case <-time.After(6 * time.Second): + t.Fatal("timed out waiting for manager D-Bus test server") + return nil + } +} From 0e0728b824fb7749c425a21774c1d7f0e59563d7 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 22 Jul 2026 15:25:37 -0400 Subject: [PATCH 13/19] fix(systemctl): limit supported command surface --- README.md | 21 +- SHELL_FEATURES.md | 4 +- analysis/symbols_builtins.go | 4 +- builtins/system_service.go | 52 ++- builtins/systemctl/systemctl.go | 323 ++---------------- builtins/systemctl/systemctl_test.go | 299 ++++++---------- docs/RULES.md | 23 +- internal/systemd/manager_linux.go | 22 -- internal/systemd/manager_protocol.go | 81 ----- internal/systemd/manager_protocol_test.go | 146 ++------ internal/systemd/manager_unsupported.go | 8 - internal/systemd/manager_unsupported_test.go | 10 - interp/system_services.go | 22 +- interp/system_services_test.go | 10 +- .../systemctl/errors/removed_commands.yaml | 34 ++ .../cmd/systemctl/errors/removed_flags.yaml | 22 ++ tests/scenarios/cmd/systemctl/help/help.yaml | 6 +- 17 files changed, 268 insertions(+), 819 deletions(-) create mode 100644 tests/scenarios/cmd/systemctl/errors/removed_commands.yaml create mode 100644 tests/scenarios/cmd/systemctl/errors/removed_flags.yaml diff --git a/README.md b/README.md index c0939aa9..f52cfc35 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ 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 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. +**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`, `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. @@ -85,7 +85,6 @@ interp.AllowedSystemServices([]interp.SystemdControlGrant{ interp.SystemServiceStop, interp.SystemServiceRestart, interp.SystemServiceReload, - interp.SystemServiceResetFailed, interp.SystemServiceEnable, interp.SystemServiceDisable, }, @@ -101,7 +100,7 @@ interp.AllowedSystemServices([]interp.SystemdControlGrant{ }) ``` -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. +The development CLI accepts equivalent grants through `--allowed-services mysql.service:read+start+stop+reload+restart+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 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. @@ -117,26 +116,22 @@ Explicit targets may map those host locations to arbitrary absolute container pa ### 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. +`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 or explicit `list-units`, `status`, mutations, 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` | +| Runtime jobs | `start\|stop\|reload\|restart UNIT...` | Matching action for every operand | +| Unit-file state | `enable\|disable UNIT...` | Matching action for every operand | -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. +Remediation mode enables the builtin but does not bypass its listed exact grants. Every requested unit/action pair is authorized 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. +`status` exposes only the requested unit name, description, load state, unit-file state, active/sub state, service main PID, and the unit type's bounded result. It deliberately omits process command lines, journal output, arbitrary properties, unit-file paths, and D-Bus object paths. 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. +Other command verbs and operation-specific options outside the eight-command table are unavailable. In particular, rshell omits `show`, `is-active`, `is-failed`, `is-enabled`, `try-restart`, `reload-or-restart`, `try-reload-or-restart`, `reset-failed`, and `--now`. Other unavailable operations include `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 diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index a720a744..ed5e4a0d 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -29,7 +29,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` +- ✅ `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/status inspection, and mutations, 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); the supported command set is exactly `list-units`, `status`, `start`, `stop`, `reload`, `restart`, `enable`, and `disable`; listing and status require exact `read` grants, and each mutation requires its matching exact action grant for every operand 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, logs, arbitrary properties, unit-file paths, and D-Bus object paths, and every human-readable field is sanitized; `show`, the `is-*` predicates, conditional restart variants, `reset-failed`, `--now`, 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 @@ -124,7 +124,7 @@ 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 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...]` +- ✅ AllowedSystemServices policy — one shared default-deny capability map for exact unit names with `read`, `clean`, `start`, `stop`, `reload`, `restart`, `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 diff --git a/analysis/symbols_builtins.go b/analysis/symbols_builtins.go index d6297abf..62280b4e 100644 --- a/analysis/symbols_builtins.go +++ b/analysis/symbols_builtins.go @@ -227,11 +227,9 @@ var builtinPerCommandSymbols = map[string][]string{ "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.SplitSeq", // 🟢 streams bounded filter 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. diff --git a/builtins/system_service.go b/builtins/system_service.go index e075a587..fd9a4fbf 100644 --- a/builtins/system_service.go +++ b/builtins/system_service.go @@ -40,15 +40,14 @@ const ( type SystemServiceAction string const ( - 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" + SystemServiceRead SystemServiceAction = "read" + SystemServiceClean SystemServiceAction = "clean" + SystemServiceStart SystemServiceAction = "start" + SystemServiceStop SystemServiceAction = "stop" + SystemServiceReload SystemServiceAction = "reload" + SystemServiceRestart SystemServiceAction = "restart" + SystemServiceEnable SystemServiceAction = "enable" + SystemServiceDisable SystemServiceAction = "disable" ) // SystemServiceState is the fixed, bounded unit state exposed to the restricted @@ -57,18 +56,16 @@ const ( // 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 + Name string + CanonicalName string + Description string + LoadState string + ActiveState string + SubState string + UnitFileState string + MainPID uint32 + Result string + JobID uint32 } // SystemServiceListRequest selects exact pre-authorized units for bounded @@ -84,7 +81,6 @@ type SystemServiceListRequest struct { 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 @@ -92,20 +88,16 @@ type SystemServiceStateReader interface { 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" + SystemServiceJobStart SystemServiceJobAction = "start" + SystemServiceJobStop SystemServiceJobAction = "stop" + SystemServiceJobReload SystemServiceJobAction = "reload" + SystemServiceJobRestart SystemServiceJobAction = "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 } diff --git a/builtins/systemctl/systemctl.go b/builtins/systemctl/systemctl.go index 45fb5df9..812dc891 100644 --- a/builtins/systemctl/systemctl.go +++ b/builtins/systemctl/systemctl.go @@ -16,7 +16,6 @@ import ( "context" "fmt" "slices" - "strconv" "strings" "unicode" "unicode/utf8" @@ -27,7 +26,6 @@ import ( const ( maxFilterValues = 32 maxFilterValueBytes = 64 - maxPropertyValues = 32 ) var supportedUnitTypes = map[string]struct{}{ @@ -44,27 +42,6 @@ var supportedUnitTypes = map[string]struct{}{ "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", @@ -74,30 +51,22 @@ var Cmd = builtins.Command{ } type flags struct { - all *bool - unitTypes *[]string - states *[]string - noLegend *bool - properties *[]string - value *bool - quiet *bool - now *bool - help *bool + all *bool + unitTypes *[]string + states *[]string + noLegend *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"), + 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"), + help: fs.BoolP("help", "h", false, "print usage and exit"), } return options.run(fs) } @@ -105,8 +74,8 @@ func makeFlags(fs *builtins.FlagSet) builtins.HandlerFunc { 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. + // inspection and help 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} @@ -134,21 +103,6 @@ func (options flags) run(fs *builtins.FlagSet) builtins.HandlerFunc { 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": @@ -157,22 +111,11 @@ func (options flags) run(fs *builtins.FlagSet) builtins.HandlerFunc { 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 { + if result, ok := rejectFlags(callCtx, fs, verb); !ok { return result } - return options.runEnableDisable(ctx, callCtx, verb, operands) + return runEnableDisable(ctx, callCtx, verb, operands) default: callCtx.Errf("systemctl: unsupported command %q\n", safeText(verb)) callCtx.Errf("Try 'systemctl --help' for more information.\n") @@ -190,15 +133,9 @@ func printHelp(callCtx *builtins.CallContext, fs *builtins.FlagSet) { 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(" restart UNIT... Queue and wait for an authorized job\n") + callCtx.Out(" enable|disable UNIT... Change unit-file state\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") @@ -333,119 +270,7 @@ func runStatus(ctx context.Context, callCtx *builtins.CallContext, operands []st 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 { +func runJobWithFlags(ctx context.Context, callCtx *builtins.CallContext, fs *builtins.FlagSet, verb string, operands []string, job builtins.SystemServiceJobAction, action builtins.SystemServiceAction) builtins.Result { if result, ok := rejectFlags(callCtx, fs, verb); !ok { return result } @@ -453,7 +278,7 @@ func runJobWithFlags(ctx context.Context, callCtx *builtins.CallContext, fs *bui if err != nil { return commandError(callCtx, err) } - if result := authorize(callCtx, units, actions...); result.Code != 0 { + if result := authorize(callCtx, units, action); result.Code != 0 { return result } if callCtx.Systemd == nil || callCtx.Systemd.ServiceControl == nil { @@ -465,47 +290,16 @@ func runJobWithFlags(ctx context.Context, callCtx *builtins.CallContext, fs *bui 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 { +func 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 + action := builtins.SystemServiceEnable if verb == "disable" { - actions[0] = builtins.SystemServiceDisable - nowAction = builtins.SystemServiceStop - nowJob = builtins.SystemServiceJobStop - } - if *options.now { - actions = append(actions, nowAction) + action = builtins.SystemServiceDisable } - if result := authorize(callCtx, units, actions...); result.Code != 0 { + if result := authorize(callCtx, units, action); result.Code != 0 { return result } if callCtx.Systemd == nil || callCtx.Systemd.ServiceControl == nil { @@ -520,11 +314,6 @@ func (options flags) runEnableDisable(ctx context.Context, callCtx *builtins.Cal 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{} } @@ -720,35 +509,6 @@ func parseFilterValues(raw []string, name string, unitType bool) (map[string]str 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 { @@ -896,36 +656,6 @@ func writeUnitList(callCtx *builtins.CallContext, states []builtins.SystemServic } } -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 { @@ -934,19 +664,10 @@ func unitTypeOf(unit string) string { 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 "-" diff --git a/builtins/systemctl/systemctl_test.go b/builtins/systemctl/systemctl_test.go index bdaa5e60..a57e7030 100644 --- a/builtins/systemctl/systemctl_test.go +++ b/builtins/systemctl/systemctl_test.go @@ -29,10 +29,6 @@ type fakeStateReader struct { 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) { @@ -50,12 +46,6 @@ func (f *fakeStateReader) InspectSystemServices(_ context.Context, units []strin 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 @@ -64,36 +54,24 @@ type jobCall struct { 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 } @@ -144,18 +122,10 @@ func TestSystemctlRequiresRemediationModeBeforeAnyCapability(t *testing.T) { {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"}}, } @@ -184,9 +154,7 @@ func TestSystemctlRequiresRemediationModeBeforeAnyCapability(t *testing.T) { 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) }) @@ -194,6 +162,46 @@ func TestSystemctlRequiresRemediationModeBeforeAnyCapability(t *testing.T) { assert.True(t, Cmd.RemediationOnly) } +func TestRemovedCommandsAreRejectedBeforeCapabilities(t *testing.T) { + for _, verb := range []string{ + "show", + "is-active", + "is-failed", + "is-enabled", + "try-restart", + "reload-or-restart", + "try-reload-or-restart", + "reset-failed", + } { + t.Run(verb, func(t *testing.T) { + reader := &fakeStateReader{} + controller := &fakeController{} + capabilityCalls := 0 + callCtx := permissiveContext(reader, controller, nil) + callCtx.ReadableSystemServices = func() []string { + capabilityCalls++ + return []string{"api.service"} + } + callCtx.AuthorizeSystemd = func(...builtins.SystemdOperation) error { + capabilityCalls++ + return nil + } + + got := runSystemctl(t, []string{verb, "api.service"}, callCtx) + + assert.Equal(t, uint8(1), got.result.Code) + assert.Empty(t, got.stdout) + assert.Equal(t, "systemctl: unsupported command \""+verb+"\"\nTry 'systemctl --help' for more information.\n", got.stderr) + assert.Zero(t, capabilityCalls) + assert.Zero(t, reader.listCalls) + assert.Zero(t, reader.inspectCalls) + assert.Empty(t, controller.jobs) + assert.Empty(t, controller.enableUnits) + assert.Empty(t, controller.disableUnits) + }) + } +} + func unitState(name, active, sub string) builtins.SystemServiceState { return builtins.SystemServiceState{ Name: name, @@ -327,10 +335,6 @@ func TestArgumentTokenCountsAreBoundedBeforeBackendWork(t *testing.T) { _, 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) { @@ -367,49 +371,15 @@ func TestStatusExitCodes(t *testing.T) { } } -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)) + got := runSystemctl(t, []string{"status", "api.service"}, permissiveContext(reader, nil, nil)) assert.Equal(t, uint8(0), got.result.Code) - assert.Equal(t, "Description=hello?world?????\n", got.stdout) + assert.Equal(t, "api.service - hello?world?????\n Loaded: loaded (enabled)\n Active: active (running)\n Result: success\n", got.stdout) assert.NotContains(t, got.stdout, "\x1b") assert.Empty(t, got.stderr) @@ -423,46 +393,6 @@ func TestBackendStringsCannotInjectTerminalControls(t *testing.T) { 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 @@ -473,9 +403,6 @@ func TestJobVerbsUseFixedBackendActionsAndAuthorizations(t *testing.T) { {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) { @@ -495,68 +422,32 @@ func TestJobVerbsUseFixedBackendActionsAndAuthorizations(t *testing.T) { } } -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) { +func TestEnableDisableUseDedicatedBackendAndAuthorization(t *testing.T) { tests := []struct { verb string - first builtins.SystemServiceAction - second builtins.SystemServiceAction - job builtins.SystemServiceJobAction - wantOrder []string + action builtins.SystemServiceAction 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 }}, + {verb: "enable", action: builtins.SystemServiceEnable, configured: func(controller *fakeController) []string { return controller.enableUnits }}, + {verb: "disable", action: builtins.SystemServiceDisable, configured: func(controller *fakeController) []string { return controller.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)) + + got := runSystemctl(t, []string{test.verb, "api.service", "api.service"}, 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.Empty(t, got.stdout) + assert.Empty(t, got.stderr) 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) + assert.Equal(t, []builtins.SystemdOperation{{Service: "api.service", Action: test.action}}, authorized) + assert.Empty(t, controller.jobs) }) } } -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) @@ -578,42 +469,12 @@ func TestCanceledMutationStillReportsUncertainOutcome(t *testing.T) { 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"}, + {"status", "api.service", "--state=active"}, + {"start", "api.service", "--type=service"}, + {"enable", "api.service", "--no-legend"}, } for _, args := range tests { t.Run(strings.Join(args, "_"), func(t *testing.T) { @@ -646,6 +507,27 @@ func TestDangerousHostSystemctlOptionsAreRejected(t *testing.T) { assert.Contains(t, stderr.String(), "unrecognized option") }) } + + for _, test := range []struct { + option string + args []string + wantErr string + }{ + {option: "--now", args: []string{"enable", "api.service", "--now"}, wantErr: "unrecognized option '--now'"}, + {option: "--property", args: []string{"status", "api.service", "--property"}, wantErr: "unrecognized option '--property'"}, + {option: "-p", args: []string{"status", "api.service", "-p"}, wantErr: "invalid option -- 'p'"}, + {option: "--value", args: []string{"status", "api.service", "--value"}, wantErr: "unrecognized option '--value'"}, + {option: "--quiet", args: []string{"status", "api.service", "--quiet"}, wantErr: "unrecognized option '--quiet'"}, + {option: "-q", args: []string{"status", "api.service", "-q"}, wantErr: "invalid option -- 'q'"}, + } { + t.Run("removed_"+strings.TrimLeft(test.option, "-"), func(t *testing.T) { + var stdout, stderr bytes.Buffer + result := handler(context.Background(), &builtins.CallContext{Stdout: &stdout, Stderr: &stderr, RemediationMode: true}, test.args) + assert.Equal(t, uint8(1), result.Code) + assert.Empty(t, stdout.String()) + assert.Equal(t, "systemctl: "+test.wantErr+"\nTry 'systemctl --help' for more information.\n", stderr.String()) + }) + } } func TestHelpDocumentsRestrictedEnumerationWithoutCapabilities(t *testing.T) { @@ -658,5 +540,30 @@ func TestHelpDocumentsRestrictedEnumerationWithoutCapabilities(t *testing.T) { 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") + assert.Contains(t, got.stdout, "--type") + for _, retained := range []string{ + " list-units List read-authorized units", + " status UNIT... Show bounded unit status without logs", + " start|stop|reload UNIT... Queue and wait for an authorized job", + " restart UNIT... Queue and wait for an authorized job", + " enable|disable UNIT... Change unit-file state", + } { + assert.Contains(t, got.stdout, retained) + } + for _, removed := range []string{ + " show UNIT", + "is-active", + "is-failed", + "is-enabled", + "try-restart", + "reload-or-restart", + "try-reload-or-restart", + "reset-failed", + "--now", + "--property", + "--value", + "--quiet", + } { + assert.NotContains(t, got.stdout, removed) + } } diff --git a/docs/RULES.md b/docs/RULES.md index d698af79..111723ff 100644 --- a/docs/RULES.md +++ b/docs/RULES.md @@ -134,8 +134,9 @@ 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. +fixed manager and properties methods required for bounded list/status +inspection, runtime `start`/`stop`/`reload`/`restart` jobs, and persistent +`enable`/`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 @@ -161,18 +162,18 @@ 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 +and explicit `list-units`, `status`, every mutation, 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 +After the mode gate, the builtin MUST expose exactly `list-units`, `status`, +`start`, `stop`, `reload`, `restart`, `enable`, and `disable`. `show`, every +`is-*` predicate, conditional restart variants, `reset-failed`, and `--now` +MUST remain unsupported. 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 +`restart` jobs and persistent `enable`/`disable` are structured remediation +capabilities and require their exact grants. Authorization for every requested +unit 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 diff --git a/internal/systemd/manager_linux.go b/internal/systemd/manager_linux.go index 62a32ac5..8e1e4e11 100644 --- a/internal/systemd/manager_linux.go +++ b/internal/systemd/manager_linux.go @@ -61,19 +61,6 @@ func (c *Client) InspectSystemServices(ctx context.Context, units []string) ([]b 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 @@ -86,15 +73,6 @@ func (c *Client) RunSystemServiceJobs(ctx context.Context, action builtins.Syste }) } -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 diff --git a/internal/systemd/manager_protocol.go b/internal/systemd/manager_protocol.go index 56d1d58d..3c10a254 100644 --- a/internal/systemd/manager_protocol.go +++ b/internal/systemd/manager_protocol.go @@ -117,35 +117,6 @@ func inspectSystemServicesWithBus(ctx context.Context, bus managerBus, units []s 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 { @@ -217,12 +188,6 @@ func inspectSystemUnit(ctx context.Context, bus managerBus, selector string, loa 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 } @@ -276,9 +241,6 @@ func runSystemServiceJobsWithBus(ctx context.Context, bus managerBus, action bui 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) @@ -299,26 +261,6 @@ func runSystemServiceJobsWithBus(ctx context.Context, bus managerBus, action bui 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 @@ -392,10 +334,6 @@ 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: @@ -406,22 +344,11 @@ func managerJobMethod(action builtins.SystemServiceJobAction) (string, bool) { 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 { @@ -495,14 +422,6 @@ func managerUint32Property(ctx context.Context, bus managerBus, path dbus.Object 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 { diff --git a/internal/systemd/manager_protocol_test.go b/internal/systemd/manager_protocol_test.go index 18d776f9..abebdd28 100644 --- a/internal/systemd/manager_protocol_test.go +++ b/internal/systemd/manager_protocol_test.go @@ -84,18 +84,16 @@ func TestVerifySystemdManagerMachineIDRejectsMismatch(t *testing.T) { } 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 + selector string + canonical string + description string + loadState string + activeState string + subState string + unitFileState string + jobID uint32 + mainPID uint32 + result string } func fakeManagerStateBus(units ...fakeManagerUnit) *fakeManagerBus { @@ -149,10 +147,6 @@ func fakeManagerStateBus(units ...fakeManagerUnit) *fakeManagerBus { 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) } @@ -210,17 +204,15 @@ func TestListSystemServicesAllLoadsInactiveAndPreservesAuthorizedAlias(t *testin 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, + selector: "api.service", + canonical: "api.service", + description: "API", + loadState: "loaded", + activeState: "failed", + subState: "failed", + unitFileState: "enabled", + mainPID: 123, + result: "exit-code", }) states, err := inspectSystemServicesWithBus(context.Background(), bus, []string{"api.service", "missing.service"}) require.NoError(t, err) @@ -233,11 +225,13 @@ func TestInspectSystemServicesReturnsDetailsAndSynthesizesNotFound(t *testing.T) ActiveState: "inactive", SubState: "dead", }, states[1]) + var properties []string for _, call := range bus.calls { if call.method == dbusPropertiesGet { - assert.NotEqual(t, "Job", call.arguments[1]) + properties = append(properties, call.arguments[1].(string)) } } + assert.Equal(t, []string{"Id", "Description", "LoadState", "ActiveState", "SubState", "UnitFileState", "Result", "MainPID"}, properties) } func TestInspectSystemServicesUsesTypeSpecificResultInterface(t *testing.T) { @@ -282,27 +276,13 @@ func TestManagerResultInterfaceIsFixedBySupportedUnitType(t *testing.T) { } } -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": + case systemdManagerIface + ".ReloadUnit": require.Equal(t, []any{"api.service", "replace"}, call.arguments) bus.signalSink <- &dbus.Signal{ Path: systemdManagerPath, @@ -314,7 +294,7 @@ func TestRunSystemServiceJobsUsesFixedMethodAndWaitsForMatchingResult(t *testing return nil, fmt.Errorf("unexpected method %q", call.method) } } - err := runSystemServiceJobsWithBus(context.Background(), bus, builtins.SystemServiceJobTryReloadOrRestart, []string{"api.service"}) + err := runSystemServiceJobsWithBus(context.Background(), bus, builtins.SystemServiceJobReload, []string{"api.service"}) require.NoError(t, err) assert.Nil(t, bus.signalSink) } @@ -325,7 +305,7 @@ func TestRunSystemServiceJobsAcceptsSkippedResult(t *testing.T) { switch call.method { case systemdManagerIface + ".Subscribe": return nil, nil - case systemdManagerIface + ".TryRestartUnit": + case systemdManagerIface + ".StartUnit": bus.signalSink <- &dbus.Signal{ Path: systemdManagerPath, Name: systemdManagerIface + ".JobRemoved", @@ -336,7 +316,7 @@ func TestRunSystemServiceJobsAcceptsSkippedResult(t *testing.T) { return nil, fmt.Errorf("unexpected method %q", call.method) } } - require.NoError(t, runSystemServiceJobsWithBus(context.Background(), bus, builtins.SystemServiceJobTryRestart, []string{"api.service"})) + require.NoError(t, runSystemServiceJobsWithBus(context.Background(), bus, builtins.SystemServiceJobStart, []string{"api.service"})) } func TestWaitSystemdJobClassifiesTerminalPaths(t *testing.T) { @@ -489,33 +469,6 @@ func TestWaitSystemdJobClassifiesTerminalPaths(t *testing.T) { } } -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) { @@ -607,53 +560,6 @@ func TestRunSystemServiceJobsReportsSignalOverflowAsUnknownOutcome(t *testing.T) assert.Contains(t, err.Error(), "final state is unknown") } -func TestResetFailedSystemServicesCallsManagerAndValidatesEmptyReply(t *testing.T) { - tests := []struct { - name string - reply []any - wantErr string - }{ - {name: "success"}, - { - name: "unexpected non-empty reply", - reply: []any{true}, - wantErr: `systemd manager operation stopped after completing 1 units (api.service); completed operations were not rolled back: systemd manager ResetFailedUnit returned an invalid reply for "api.service": reply has 1 values; expected 0`, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - bus := &fakeManagerBus{respond: func(fakeManagerCall) ([]any, error) { - return test.reply, nil - }} - - err := resetFailedSystemServicesWithBus(context.Background(), bus, []string{"api.service"}) - if test.wantErr == "" { - require.NoError(t, err) - } else { - require.EqualError(t, err, test.wantErr) - } - require.Equal(t, []fakeManagerCall{{ - destination: systemdBusDestination, - path: systemdManagerPath, - method: systemdManagerIface + ".ResetFailedUnit", - arguments: []any{"api.service"}, - }}, bus.calls) - }) - } -} - -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 TestSystemServiceUnitFileMutations(t *testing.T) { units := []string{"api.service", "backup.timer"} type mutationTest struct { diff --git a/internal/systemd/manager_unsupported.go b/internal/systemd/manager_unsupported.go index 7d7427a1..7009aa9b 100644 --- a/internal/systemd/manager_unsupported.go +++ b/internal/systemd/manager_unsupported.go @@ -22,18 +22,10 @@ func (*Client) InspectSystemServices(context.Context, []string) ([]builtins.Syst 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() } diff --git a/internal/systemd/manager_unsupported_test.go b/internal/systemd/manager_unsupported_test.go index 93a66bb1..e95dab62 100644 --- a/internal/systemd/manager_unsupported_test.go +++ b/internal/systemd/manager_unsupported_test.go @@ -29,10 +29,6 @@ func TestSystemdManagerMethodsFailClosedOffLinux(t *testing.T) { assert.Nil(t, states) require.ErrorIs(t, err, builtins.ErrSystemdUnsupported) - enabledStates, err := client.SystemServiceEnabledState(ctx, []string{"api.service"}) - assert.Nil(t, enabledStates) - require.ErrorIs(t, err, builtins.ErrSystemdUnsupported) - controllerCalls := []struct { name string call func() error @@ -43,12 +39,6 @@ func TestSystemdManagerMethodsFailClosedOffLinux(t *testing.T) { return client.RunSystemServiceJobs(ctx, builtins.SystemServiceJobStart, []string{"api.service"}) }, }, - { - name: "reset failed", - call: func() error { - return client.ResetFailedSystemServices(ctx, []string{"api.service"}) - }, - }, { name: "enable", call: func() error { diff --git a/interp/system_services.go b/interp/system_services.go index eae962d4..79a70e02 100644 --- a/interp/system_services.go +++ b/interp/system_services.go @@ -23,15 +23,14 @@ type SystemdOperation = builtins.SystemdOperation type SystemServiceAction = builtins.SystemServiceAction const ( - 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 + SystemServiceRead = builtins.SystemServiceRead + SystemServiceClean = builtins.SystemServiceClean + SystemServiceStart = builtins.SystemServiceStart + SystemServiceStop = builtins.SystemServiceStop + SystemServiceReload = builtins.SystemServiceReload + SystemServiceRestart = builtins.SystemServiceRestart + SystemServiceEnable = builtins.SystemServiceEnable + SystemServiceDisable = builtins.SystemServiceDisable ) // SystemServiceControlGrant grants Actions for one exact systemd unit. Service @@ -53,8 +52,8 @@ type systemdGrants map[string]map[SystemServiceAction]struct{} // // Grants without actions are ignored. Invalid services and unsupported actions // 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. +// reload, restart, 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. @@ -97,7 +96,6 @@ func validSystemServiceAction(action SystemServiceAction) bool { action == SystemServiceStop || action == SystemServiceReload || action == SystemServiceRestart || - action == SystemServiceResetFailed || action == SystemServiceEnable || action == SystemServiceDisable } diff --git a/interp/system_services_test.go b/interp/system_services_test.go index bcdc8126..28df8a3c 100644 --- a/interp/system_services_test.go +++ b/interp/system_services_test.go @@ -30,7 +30,6 @@ func TestAllowedSystemServicesAuthorizesExactServiceAndAction(t *testing.T) { SystemServiceStop, SystemServiceReload, SystemServiceRestart, - SystemServiceResetFailed, SystemServiceEnable, SystemServiceDisable, }, @@ -51,7 +50,6 @@ func TestAllowedSystemServicesAuthorizesExactServiceAndAction(t *testing.T) { SystemServiceStop, SystemServiceReload, SystemServiceRestart, - SystemServiceResetFailed, SystemServiceEnable, SystemServiceDisable, } { @@ -93,7 +91,6 @@ func TestAllowedSystemServicesKeepsSharedJournalReadOutsideRemediationMode(t *te SystemServiceStop, SystemServiceReload, SystemServiceRestart, - SystemServiceResetFailed, SystemServiceEnable, SystemServiceDisable, }, @@ -109,7 +106,6 @@ func TestAllowedSystemServicesKeepsSharedJournalReadOutsideRemediationMode(t *te SystemServiceStop, SystemServiceReload, SystemServiceRestart, - SystemServiceResetFailed, SystemServiceEnable, SystemServiceDisable, } { @@ -169,7 +165,6 @@ func TestAllowedSystemServicesReadDoesNotEnableMutation(t *testing.T) { SystemServiceStop, SystemServiceReload, SystemServiceRestart, - SystemServiceResetFailed, SystemServiceEnable, SystemServiceDisable, } { @@ -274,7 +269,7 @@ func TestAllowedSystemServicesSkipsUnsupportedActions(t *testing.T) { WithMode(ModeRemediation), WarningsWriter(&warningOutput), AllowedSystemServices([]SystemServiceControlGrant{ - {Service: "mysql.service", Actions: []SystemServiceAction{SystemServiceRead, SystemServiceStop, "freeze", SystemServiceReload}}, + {Service: "mysql.service", Actions: []SystemServiceAction{SystemServiceRead, SystemServiceStop, "freeze", SystemServiceAction("reset-failed"), SystemServiceReload}}, {Service: "ignored.service", Actions: []SystemServiceAction{"mask"}}, }), ) @@ -287,8 +282,9 @@ func TestAllowedSystemServicesSkipsUnsupportedActions(t *testing.T) { assert.NotContains(t, runner.allowedSystemServices, "ignored.service") warnings := runner.Warnings() - require.Len(t, warnings, 2) + require.Len(t, warnings, 3) 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 "reset-failed" in grant 0 for "mysql.service"`) assert.Contains(t, warningOutput.String(), `AllowedSystemServices: skipping unsupported action "mask" in grant 1 for "ignored.service"`) } diff --git a/tests/scenarios/cmd/systemctl/errors/removed_commands.yaml b/tests/scenarios/cmd/systemctl/errors/removed_commands.yaml new file mode 100644 index 00000000..67cb5f57 --- /dev/null +++ b/tests/scenarios/cmd/systemctl/errors/removed_commands.yaml @@ -0,0 +1,34 @@ +# Removed compatibility and predicate verbs must not reach systemd capabilities. +skip_assert_against_bash: true +description: systemctl rejects commands outside its eight-command surface +input: + mode: remediation + script: |+ + systemctl show api.service + systemctl is-active api.service + systemctl is-failed api.service + systemctl is-enabled api.service + systemctl try-restart api.service + systemctl reload-or-restart api.service + systemctl try-reload-or-restart api.service + systemctl reset-failed api.service +expect: + stdout: |+ + stderr: |+ + systemctl: unsupported command "show" + Try 'systemctl --help' for more information. + systemctl: unsupported command "is-active" + Try 'systemctl --help' for more information. + systemctl: unsupported command "is-failed" + Try 'systemctl --help' for more information. + systemctl: unsupported command "is-enabled" + Try 'systemctl --help' for more information. + systemctl: unsupported command "try-restart" + Try 'systemctl --help' for more information. + systemctl: unsupported command "reload-or-restart" + Try 'systemctl --help' for more information. + systemctl: unsupported command "try-reload-or-restart" + Try 'systemctl --help' for more information. + systemctl: unsupported command "reset-failed" + Try 'systemctl --help' for more information. + exit_code: 1 diff --git a/tests/scenarios/cmd/systemctl/errors/removed_flags.yaml b/tests/scenarios/cmd/systemctl/errors/removed_flags.yaml new file mode 100644 index 00000000..ebe330aa --- /dev/null +++ b/tests/scenarios/cmd/systemctl/errors/removed_flags.yaml @@ -0,0 +1,22 @@ +# Flags that existed only for removed commands or compound mutations are no longer accepted. +skip_assert_against_bash: true +description: systemctl rejects flags outside its reduced surface +input: + mode: remediation + script: |+ + systemctl enable api.service --now + systemctl status api.service --property + systemctl status api.service --value + systemctl status api.service --quiet +expect: + stdout: |+ + stderr: |+ + systemctl: unrecognized option '--now' + Try 'systemctl --help' for more information. + systemctl: unrecognized option '--property' + Try 'systemctl --help' for more information. + systemctl: unrecognized option '--value' + Try 'systemctl --help' for more information. + systemctl: unrecognized option '--quiet' + Try 'systemctl --help' for more information. + exit_code: 1 diff --git a/tests/scenarios/cmd/systemctl/help/help.yaml b/tests/scenarios/cmd/systemctl/help/help.yaml index d3808a17..2199e32f 100644 --- a/tests/scenarios/cmd/systemctl/help/help.yaml +++ b/tests/scenarios/cmd/systemctl/help/help.yaml @@ -11,8 +11,8 @@ expect: - "The entire command is available only in remediation mode." - "list-units" - "status" - - "reload-or-restart" - - "reset-failed" - - "enable" + - "start|stop|reload" + - "restart UNIT" + - "enable|disable" stderr: |+ exit_code: 0 From 1b3ca11162491cd2c579e848574a7a7cfd19b532 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 22 Jul 2026 15:53:03 -0400 Subject: [PATCH 14/19] refactor(systemctl): centralize supported unit types --- builtins/system_service.go | 11 ++++++++ builtins/system_service_test.go | 38 ++++++++++++++++++++++++++++ builtins/systemctl/systemctl.go | 20 +++------------ internal/systemd/manager_protocol.go | 13 ++-------- 4 files changed, 54 insertions(+), 28 deletions(-) create mode 100644 builtins/system_service_test.go diff --git a/builtins/system_service.go b/builtins/system_service.go index fd9a4fbf..29bc4467 100644 --- a/builtins/system_service.go +++ b/builtins/system_service.go @@ -50,6 +50,17 @@ const ( SystemServiceDisable SystemServiceAction = "disable" ) +// IsSupportedSystemdUnitType reports whether unitType is part of the fixed +// systemd unit-type surface shared by systemctl and its manager backend. +func IsSupportedSystemdUnitType(unitType string) bool { + switch unitType { + case "service", "socket", "target", "device", "mount", "automount", "swap", "timer", "path", "slice", "scope": + return true + default: + return false + } +} + // 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 diff --git a/builtins/system_service_test.go b/builtins/system_service_test.go new file mode 100644 index 00000000..17062a05 --- /dev/null +++ b/builtins/system_service_test.go @@ -0,0 +1,38 @@ +// 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 builtins + +import "testing" + +func TestIsSupportedSystemdUnitType(t *testing.T) { + for _, unitType := range []string{ + "service", + "socket", + "target", + "device", + "mount", + "automount", + "swap", + "timer", + "path", + "slice", + "scope", + } { + t.Run(unitType, func(t *testing.T) { + if !IsSupportedSystemdUnitType(unitType) { + t.Fatalf("expected %q to be supported", unitType) + } + }) + } + + for _, unitType := range []string{"", "unit", "Service", ".service", "service/"} { + t.Run("unsupported_"+unitType, func(t *testing.T) { + if IsSupportedSystemdUnitType(unitType) { + t.Fatalf("expected %q to be unsupported", unitType) + } + }) + } +} diff --git a/builtins/systemctl/systemctl.go b/builtins/systemctl/systemctl.go index 812dc891..c67f7be2 100644 --- a/builtins/systemctl/systemctl.go +++ b/builtins/systemctl/systemctl.go @@ -28,20 +28,6 @@ const ( maxFilterValueBytes = 64 ) -var supportedUnitTypes = map[string]struct{}{ - "automount": {}, - "device": {}, - "mount": {}, - "path": {}, - "scope": {}, - "service": {}, - "slice": {}, - "socket": {}, - "swap": {}, - "target": {}, - "timer": {}, -} - // Cmd is the systemctl builtin command descriptor. var Cmd = builtins.Command{ Name: "systemctl", @@ -419,7 +405,7 @@ func validateUnitName(unit string) error { return fmt.Errorf("invalid exact unit name %q", safeText(unit)) } unitType := unit[dot+1:] - if _, ok := supportedUnitTypes[unitType]; !ok { + if !builtins.IsSupportedSystemdUnitType(unitType) { return fmt.Errorf("unsupported unit type %q in %q", safeText(unitType), safeText(unit)) } base := unit[:dot] @@ -449,7 +435,7 @@ func validateCanonicalUnitName(unit string) error { if dot <= 0 || dot == len(unit)-1 { return fmt.Errorf("invalid canonical unit name %q", safeText(unit)) } - if _, ok := supportedUnitTypes[unit[dot+1:]]; !ok { + if !builtins.IsSupportedSystemdUnitType(unit[dot+1:]) { return fmt.Errorf("unsupported canonical unit type in %q", safeText(unit)) } @@ -499,7 +485,7 @@ func parseFilterValues(raw []string, name string, unitType bool) (map[string]str return nil, fmt.Errorf("invalid --%s value %q", name, safeText(value)) } if unitType { - if _, ok := supportedUnitTypes[value]; !ok { + if !builtins.IsSupportedSystemdUnitType(value) { return nil, fmt.Errorf("unsupported --type value %q", safeText(value)) } } diff --git a/internal/systemd/manager_protocol.go b/internal/systemd/manager_protocol.go index 3c10a254..8c0afae6 100644 --- a/internal/systemd/manager_protocol.go +++ b/internal/systemd/manager_protocol.go @@ -485,7 +485,7 @@ func validateManagerUnit(unit string) error { 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:]) { + if separator <= 0 || separator == len(unit)-1 || !builtins.IsSupportedSystemdUnitType(unit[separator+1:]) { return fmt.Errorf("systemd unit name %q must have a supported unit suffix", unit) } base := unit[:separator] @@ -516,7 +516,7 @@ func validateManagerCanonicalUnit(unit string) error { 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:]) { + if separator <= 0 || separator == len(unit)-1 || !builtins.IsSupportedSystemdUnitType(unit[separator+1:]) { return fmt.Errorf("systemd unit name %q must have a supported unit suffix", unit) } base := unit[:separator] @@ -548,15 +548,6 @@ 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) From 833ebd121312d11907a7930143f2c5d37d87b376 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 22 Jul 2026 15:58:33 -0400 Subject: [PATCH 15/19] fix(systemd): handle coalesced D-Bus auth begin --- internal/systemd/manager_dbus_limit.go | 9 ++++----- internal/systemd/manager_dbus_limit_test.go | 16 ++++++++-------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/internal/systemd/manager_dbus_limit.go b/internal/systemd/manager_dbus_limit.go index 7781449b..a8c37516 100644 --- a/internal/systemd/manager_dbus_limit.go +++ b/internal/systemd/manager_dbus_limit.go @@ -36,11 +36,10 @@ type boundedDBusConn struct { } func (c *boundedDBusConn) Write(data []byte) (int, error) { - if bytes.Equal(data, dbusAuthBegin) { - // godbus currently emits BEGIN\r\n as an isolated Write immediately before - // starting the binary message reader. These outbound bytes are not - // peer-controlled, and this exact match intentionally depends on that write - // boundary; a coalesced write remains in bounded authentication mode. + if bytes.Contains(data, dbusAuthBegin) { + // BEGIN\r\n is a locally generated protocol marker, so it is safe to detect + // inside a coalesced write rather than depending on godbus preserving an + // isolated Write call. // Publish the mode first so a fast bus reply cannot race the transition. c.binaryMode.Store(true) } diff --git a/internal/systemd/manager_dbus_limit_test.go b/internal/systemd/manager_dbus_limit_test.go index 88cd14ba..6fdbb4fd 100644 --- a/internal/systemd/manager_dbus_limit_test.go +++ b/internal/systemd/manager_dbus_limit_test.go @@ -63,29 +63,29 @@ func TestBoundedDBusConnPassesAuthenticationThenBoundsFrames(t *testing.T) { assert.Equal(t, dbusAuthBegin, transport.writes.Bytes()) } -func TestBoundedDBusConnRequiresIsolatedAuthenticationBeginWrite(t *testing.T) { +func TestBoundedDBusConnAcceptsCoalescedAuthenticationBeginWrite(t *testing.T) { + frame := dbusTestFrame(binary.LittleEndian, []byte("reply")) tests := []struct { name string write []byte }{ - {name: "preceding bytes", write: append([]byte("OK\r\n"), dbusAuthBegin...)}, - {name: "following bytes", write: append(append([]byte(nil), dbusAuthBegin...), 'l')}, + {name: "preceding bytes", write: append([]byte("AUTH EXTERNAL 30\r\n"), dbusAuthBegin...)}, + {name: "following bytes", write: append(append([]byte(nil), dbusAuthBegin...), dbusTestFrame(binary.LittleEndian, []byte("hello"))...)}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - transport := &memoryReadWriteCloser{reader: bytes.NewReader(bytes.Repeat([]byte{'x'}, maxManagerDBusAuthBytes+1))} + transport := &memoryReadWriteCloser{reader: bytes.NewReader(frame)} connection := &boundedDBusConn{ReadWriteCloser: transport} n, err := connection.Write(test.write) require.NoError(t, err) assert.Equal(t, len(test.write), n) - assert.False(t, connection.binaryMode.Load()) + assert.True(t, connection.binaryMode.Load()) assert.Equal(t, test.write, transport.writes.Bytes()) data, err := io.ReadAll(connection) - require.Error(t, err) - assert.Len(t, data, maxManagerDBusAuthBytes) - assert.Contains(t, err.Error(), "authentication response exceeds") + require.NoError(t, err) + assert.Equal(t, frame, data) }) } } From d7635ebbcfbeca4d9aae2583bb8a87d80a7b0505 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman <91019033+matt-dz@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:10:15 -0400 Subject: [PATCH 16/19] fix: stop sanitizing potentially dangerous value in parseFilterValues Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- builtins/systemctl/systemctl.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/builtins/systemctl/systemctl.go b/builtins/systemctl/systemctl.go index c67f7be2..9c2bbec8 100644 --- a/builtins/systemctl/systemctl.go +++ b/builtins/systemctl/systemctl.go @@ -477,11 +477,13 @@ func parseFilterValues(raw []string, name string, unitType bool) (map[string]str 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 == "" { + return nil, fmt.Errorf("invalid --%s value (empty)", name) } - if value == "" || len(value) > maxFilterValueBytes || !validStateToken(value) { + if len(value) > maxFilterValueBytes { + return nil, fmt.Errorf("--%s value exceeds %d bytes", name, maxFilterValueBytes) + } + if !validStateToken(value) { return nil, fmt.Errorf("invalid --%s value %q", name, safeText(value)) } if unitType { From beccd953ca985033a4b55d262911cecafae73f23 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman <91019033+matt-dz@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:16:00 -0400 Subject: [PATCH 17/19] fix: sanitize rejectFlags unsafe output Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- builtins/systemctl/systemctl.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/builtins/systemctl/systemctl.go b/builtins/systemctl/systemctl.go index 9c2bbec8..8b35cbcd 100644 --- a/builtins/systemctl/systemctl.go +++ b/builtins/systemctl/systemctl.go @@ -150,7 +150,8 @@ func rejectFlags(callCtx *builtins.CallContext, fs *builtins.FlagSet, verb strin if rejected == "" { return builtins.Result{}, true } - callCtx.Errf("systemctl: --%s is not supported with %s\n", rejected, verb) + callCtx.Errf("systemctl: --%s is not supported with %s\n", rejected, safeText(verb)) + callCtx.Errf("Try 'systemctl --help' for more information.\n") return builtins.Result{Code: 1}, false } From c2eaf3ebad34987c06a8f3c4c000b592b59375d9 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 22 Jul 2026 16:06:41 -0400 Subject: [PATCH 18/19] fix: trim service name string when printing error message --- builtins/systemctl/systemctl.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/builtins/systemctl/systemctl.go b/builtins/systemctl/systemctl.go index 8b35cbcd..e5f1b653 100644 --- a/builtins/systemctl/systemctl.go +++ b/builtins/systemctl/systemctl.go @@ -396,7 +396,7 @@ func validateUnitName(unit string) error { 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) + return fmt.Errorf("unit name %q exceeds %d bytes", safeText(unit[:builtins.MaxSystemServiceNameBytes]), builtins.MaxSystemServiceNameBytes) } if !utf8.ValidString(unit) { return fmt.Errorf("unit name contains invalid UTF-8") From 293f1f3e85f4c6c5179196303cbbe84b0b2073d3 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 22 Jul 2026 16:21:38 -0400 Subject: [PATCH 19/19] fix(systemctl): restore filter value bound --- builtins/systemctl/systemctl.go | 4 ++++ builtins/systemctl/systemctl_test.go | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/builtins/systemctl/systemctl.go b/builtins/systemctl/systemctl.go index e5f1b653..9d99e128 100644 --- a/builtins/systemctl/systemctl.go +++ b/builtins/systemctl/systemctl.go @@ -478,6 +478,10 @@ func parseFilterValues(raw []string, name string, unitType bool) (map[string]str 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 == "" { return nil, fmt.Errorf("invalid --%s value (empty)", name) } diff --git a/builtins/systemctl/systemctl_test.go b/builtins/systemctl/systemctl_test.go index a57e7030..72f21481 100644 --- a/builtins/systemctl/systemctl_test.go +++ b/builtins/systemctl/systemctl_test.go @@ -335,6 +335,11 @@ func TestArgumentTokenCountsAreBoundedBeforeBackendWork(t *testing.T) { _, err = parseFilterValues([]string{strings.Repeat("active,", 100_000)}, "state", false) require.Error(t, err) assert.Contains(t, err.Error(), "too many --state values") + + tooLong := strings.Repeat("x", maxFilterValueBytes+1) + _, err = parseFilterValues([]string{tooLong}, "state", false) + require.EqualError(t, err, "--state value exceeds 64 bytes") + assert.NotContains(t, err.Error(), tooLong) } func TestStatusDeduplicatesAndFormatsBoundedState(t *testing.T) {