mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Expose secure agent runtime posture
This commit is contained in:
@@ -200,6 +200,29 @@ The safe runner accepts only versioned host update/storage-cleanup, Proxmox
|
||||
guest lifecycle, and container lifecycle/update requests. Generic shell,
|
||||
`exec`, unrestricted `read_file`, and deploy operations are rejected.
|
||||
|
||||
**Settings → Infrastructure → Agent Doctor** presents this boundary as
|
||||
evidence, not an inferred security grade. The collector reports whether it is
|
||||
root, its service user and local command-authority ceiling, and whether the
|
||||
typed helper, SMART helper, and `pct` helper are configured. Pulse separately
|
||||
joins the current tenant's token inventory and admitted command sessions to
|
||||
show whether the collector credential is known and execution-scoped, whether
|
||||
a host-bound runner credential is active, and whether a compatible
|
||||
`action-runner` / `typed_actions.v1` session is actually connected. Missing
|
||||
evidence remains unknown; collector health never proves remediation readiness.
|
||||
|
||||
Runner enrollment is one host at a time. The issuance request must resolve to
|
||||
exactly one non-conflicted monitored agent ID and normalized hostname in the
|
||||
current tenant. The returned secret is shown once and held only in the open
|
||||
Agent Doctor page's memory; it is not written to browser storage, a URL, a
|
||||
diagnostic report, or an installer command. The generated handoff first prompts
|
||||
for the secret through `/dev/tty` into the root-owned
|
||||
`/etc/pulse-agent-runner/token` file with mode `0600`, then passes only that
|
||||
file path through `--action-token-file`. Issuing again rotates the credential
|
||||
for that tenant/host binding: Pulse durably replaces the previous record as one
|
||||
transaction and restores it if persistence fails. A successful rotation makes
|
||||
the previous secret invalid, so complete the new token-file handoff before
|
||||
expecting a disconnected runner to reconnect.
|
||||
|
||||
The existing combined collector command path remains available only as the
|
||||
explicit legacy/full-trust migration profile. It is not part of the typed
|
||||
helper/runner security claim and will remain until supported command-enabled
|
||||
@@ -217,6 +240,16 @@ install.sh --safe-profile-rollback ...
|
||||
Inspection makes no changes. Apply snapshots collector/helper files and
|
||||
identity before switching profiles, and rollback restores that snapshot. The
|
||||
separately installed action runner is not changed by either operation.
|
||||
Inspection reports platform support, the current unit user and groups, ambient
|
||||
capabilities, collector-binary owner and mode, enabled provider flags, helper
|
||||
and collector-command state, independent runner presence, and the calculated
|
||||
typed-helper target and degraded Docker/action differences. Apply is supported
|
||||
only on reviewed standard Linux systemd hosts: it retains the monitoring token
|
||||
and agent identity, lowers the collector to monitoring-only, removes legacy
|
||||
sudo/Docker-group/ambient authority, requires collector health, helper socket
|
||||
health, and declared server registration before commit, and automatically
|
||||
restores the exact snapshot on failure. Ordinary `--update` preserves the
|
||||
installed profile and never performs this migration.
|
||||
|
||||
The older `--least-privilege` profile without `--enable-privileged-helper`
|
||||
remains available for compatibility. Its trade-offs are documented below.
|
||||
|
||||
@@ -111,6 +111,19 @@ without removing the collector. The runner accepts only the documented typed
|
||||
host, Proxmox guest, and container operations; shell, generic exec,
|
||||
unrestricted file reads, and deploy requests remain forbidden.
|
||||
|
||||
Agent Doctor shows the reported collector service user, command-authority
|
||||
ceiling, collector credential scope, and typed-helper configuration separately
|
||||
from the tenant- and host-bound action-runner credential and connected-session
|
||||
state. It does not treat a healthy collector as proof that actions are ready.
|
||||
For an eligible Linux systemd host, enrollment returns one credential reveal
|
||||
kept only in the open page's memory. Start the generated `/dev/tty` prompt,
|
||||
copy and paste the secret into that prompt to write the private root-owned
|
||||
token file, and then run the installer command, which carries only
|
||||
`--action-token-file <private-file>` in its arguments. Re-enrollment atomically
|
||||
replaces the prior credential for the same tenant and canonical agent ID; a
|
||||
failed persistence write retains the previous credential, while a successful
|
||||
rotation invalidates it.
|
||||
|
||||
Use `--safe-profile-inspect` to report the current profile and calculated
|
||||
differences without changing the host. `--safe-profile-apply` performs the
|
||||
explicit collector/helper migration and retains a rollback snapshot;
|
||||
@@ -118,6 +131,16 @@ explicit collector/helper migration and retains a rollback snapshot;
|
||||
Linux systemd and fail closed elsewhere. Appliance, non-systemd, Windows, and
|
||||
macOS installs remain explicit legacy/full-trust profiles until their own
|
||||
runtime and migration boundaries are qualified.
|
||||
Inspection includes unit identity/groups, ambient capabilities, binary
|
||||
ownership/mode, provider flags, helper/command state, independent runner
|
||||
presence, and the expected telemetry/action degradation. Apply preserves the
|
||||
collector token and agent identity, commits only after local health, helper
|
||||
socket, and server registration checks, and restores the exact prior
|
||||
collector/helper profile on failure. It never changes a separately installed
|
||||
runner, and ordinary `--update` never implies a profile migration. The secure
|
||||
runtime separation remains a proposed hardening lane; the product-wide default
|
||||
does not change until representative real Linux hosts pass migration,
|
||||
activation, rollback, provider-parity, and action-session qualification.
|
||||
|
||||
On Linux, the host module automatically checks for `virsh`. When the agent can
|
||||
open the default libvirt connection read-only, defined domains appear as VM
|
||||
|
||||
@@ -815,6 +815,44 @@ only while Agent Doctor is open, enriches canonical connection-ledger rows by
|
||||
stable `connectionId`, and retains ledger-only fallback rows when structured
|
||||
evidence is absent.
|
||||
|
||||
Secure-runtime posture on that read model is factual and split by authority
|
||||
source. The collector-authored report supplies effective-root state, service
|
||||
user, local `commandAuthority`, and configured typed/SMART/`pct` helpers. The
|
||||
server adds only evidence it can independently observe: whether the reporting
|
||||
token is present and carries `agent:exec`, whether the current organization has
|
||||
an active action-runner credential bound to the canonical agent ID, and whether
|
||||
the organization-scoped live-session registry contains that agent under the
|
||||
closed `action-runner` role and `typed_actions.v1` capability. Connected
|
||||
runner version, connection time, binding version, receipt protocol, preflight
|
||||
protocol, and Docker-observation protocol remain separate optional facts.
|
||||
Absence is not projected as safe, and neither collector liveness nor a healthy
|
||||
fleet verdict may stand in for action-session readiness.
|
||||
|
||||
Agent Doctor may issue or rotate an action-runner credential only after the
|
||||
selected diagnostic proves a non-root, monitoring-only collector with a known
|
||||
non-exec credential and typed helper, resolves to a standard-looking Linux
|
||||
systemd installation, and has one complete canonical agent ID/hostname pair.
|
||||
The operator-only endpoint re-resolves that pair against exactly one live,
|
||||
non-conflicted, non-integration host in the request tenant. The resulting
|
||||
credential has only `agent:exec`, the explicit `action-runner` role,
|
||||
`typed_actions.v1`, binding version 1, organization ID, canonical agent ID, and
|
||||
normalized hostname. Rotation replaces only the earlier action-runner record
|
||||
for that organization/agent binding; the token inventory is saved atomically,
|
||||
and a failed save restores the complete previous inventory before the secret
|
||||
can be returned.
|
||||
|
||||
The raw secret is a one-time UI reveal, not a diagnostic field. The page keeps
|
||||
it in component memory only, permits at most one successful issuance for that
|
||||
target in the page session, and clears it explicitly or on navigation. The
|
||||
host handoff prompts through `/dev/tty`, writes
|
||||
`/etc/pulse-agent-runner/token` under a root-owned `0700` directory with file
|
||||
mode `0600`, and puts only `--action-token-file` plus canonical identity in the
|
||||
installer command. Secrets must not enter URLs, browser storage, diagnostics,
|
||||
command arguments, or logs. A successful rotation invalidates the prior
|
||||
credential immediately; the UI therefore blocks enrollment while a compatible
|
||||
runner session is already connected and presents rotation only for an issued
|
||||
but disconnected binding.
|
||||
|
||||
Repair entries remain handoffs to existing lifecycle operations:
|
||||
`copy_upgrade_command` renders a local operator command and
|
||||
`allow_reenroll` invokes the existing removed-agent flow. Agent Doctor exposes
|
||||
@@ -6720,6 +6758,38 @@ the only server delivery path: it rejects non-Linux targets, serves the exact
|
||||
separate helper artifact with checksum plus detached signatures, and requires a
|
||||
published signed release asset when no local binary exists.
|
||||
|
||||
### Safe collector profile migration is an explicit transaction
|
||||
|
||||
`scripts/install.sh --safe-profile-inspect` is a standalone no-write action.
|
||||
It reports platform support, detected current profile, unit user and groups,
|
||||
ambient capabilities, collector-binary owner/mode, host/Docker/Kubernetes/
|
||||
Proxmox provider flags, typed-helper and collector-command state, independent
|
||||
runner presence, and the calculated typed-helper target plus Docker/action
|
||||
degradation. Those host-local facts are broader than the remote Agent Doctor
|
||||
projection and must not be guessed from it.
|
||||
|
||||
`--safe-profile-apply` is supported only for an existing collector on reviewed
|
||||
standard Linux systemd hosts. It snapshots exact collector and helper binaries,
|
||||
units, legacy sudoers/wrappers, service enable/active state, Docker-group
|
||||
membership, credential-directory metadata, monitoring token files, agent ID,
|
||||
and connection state before mutation. It then installs root-owned collector
|
||||
and helper artifacts, lowers the collector to monitoring-only, removes legacy
|
||||
sudo, Docker-group, and ambient authority, and commits an atomic current/prior
|
||||
profile record only after collector health, helper-socket health, and declared
|
||||
server registration succeed. Any pre-commit failure restores the snapshot;
|
||||
`--safe-profile-rollback` restores the retained committed snapshot explicitly.
|
||||
Rollback may restore only privilege recorded in that explicit prior profile and
|
||||
must never invent sudo, Docker-group, ambient, root, or command authority. The
|
||||
action runner is independently installed and is never snapshotted, stopped,
|
||||
rewritten, or restored by this transaction. Ordinary `--update` preserves the
|
||||
installed profile and cannot trigger migration.
|
||||
|
||||
This remains candidate-lane evidence, not a product-wide default flip. The
|
||||
secure collector profile stays explicit until representative real Linux hosts
|
||||
qualify migration and failure rollback, typed-helper update activation,
|
||||
provider parity and documented degradation, and separately credentialed live
|
||||
action sessions. Unsupported platforms continue to fail closed on migration.
|
||||
|
||||
### Command dispatch is context-honest and abandoned executions are canceled
|
||||
|
||||
The agent command transport now refuses to dispatch work its caller has
|
||||
|
||||
@@ -32,15 +32,19 @@ and its two signature sidecars.
|
||||
The action runner has a parallel Linux-only, rate-limited signed-binary
|
||||
contract at `GET|HEAD /download/pulse-agent-runner?arch=linux-*`; it never
|
||||
falls back to a collector or helper asset. `POST
|
||||
/api/agents/action-runner/credential` is an admin and `actions:execute`
|
||||
operation that accepts one canonical monitored host identity and returns a
|
||||
new, separately persisted `agent:exec` credential bound to organization,
|
||||
agent ID, normalized hostname, runtime role `action-runner`, and capability
|
||||
`typed_actions.v1`. Monitoring credentials cannot call the issuance route or
|
||||
authenticate an action-runner session, and action-runner credentials are not
|
||||
collector report/config credentials. Unknown request fields, ambiguous or
|
||||
conflicted host identities, and persistence failures fail before any usable
|
||||
credential is returned.
|
||||
/api/agents/action-runner/credential` is an operator-only admin,
|
||||
`settings:write`, and `actions:execute` operation. It accepts one canonical
|
||||
monitored `agentId` / `hostname` pair and returns a new, separately persisted
|
||||
`agent:exec` credential bound to organization, agent ID, normalized hostname,
|
||||
runtime role `action-runner`, binding version 1, and capability
|
||||
`typed_actions.v1`. The route re-resolves the pair against exactly one live,
|
||||
non-conflicted, non-integration host in the request tenant. Issuing again
|
||||
atomically replaces only the earlier action-runner record for that tenant and
|
||||
agent ID; a persistence failure restores the complete token inventory and
|
||||
returns no usable new secret. Monitoring credentials cannot call the issuance
|
||||
route or authenticate an action-runner session, and action-runner credentials
|
||||
cannot report, read agent configuration, or manage collectors. Unknown request
|
||||
fields and ambiguous, mismatched, or conflicted host identities fail closed.
|
||||
|
||||
The API runtime is decomposed along production domain boundaries so Go can
|
||||
compile and execute domain qualification packages concurrently. Shared tenant
|
||||
@@ -10042,14 +10046,29 @@ execution-profile projection; neither external clients nor model output can
|
||||
select or widen the surface.
|
||||
|
||||
The agent fleet diagnostics payload (`GET /api/agents/diagnostics`) now
|
||||
carries an optional per-agent `privilege` object — `runningAsRoot`,
|
||||
`serviceUser`, `smartctlHelper`, `pctHelper` — mirrored by the frontend
|
||||
transport in `frontend-modern/src/api/agentDiagnostics.ts`
|
||||
(`AgentFleetDiagnosticPrivilege`). The field is descriptive fleet evidence:
|
||||
it appears only when the agent reported a profile, it never carries
|
||||
credentials or paths, and consumers must not derive health status from it.
|
||||
The unified agent report contract (`pkg/agents/host/report.go`) gains the
|
||||
matching agent-authored `privilege` block with the same fields.
|
||||
carries an optional per-agent `privilege` object mirrored by
|
||||
`frontend-modern/src/api/agentDiagnostics.ts`
|
||||
(`AgentFleetDiagnosticPrivilege`). The agent-authored portion is
|
||||
`runningAsRoot`, `serviceUser`, `commandAuthority`, `typedHelper`,
|
||||
`smartctlHelper`, and `pctHelper`. The server may add `credentialKnown` and
|
||||
`credentialExec` from the reporting-token inventory plus
|
||||
`actionRunnerCredentialIssued`, `actionRunnerCredentialActive`,
|
||||
`actionRunnerRuntimeRole`, `actionRunnerCapability`,
|
||||
`actionRunnerBindingVersion`, `actionRunnerConnected`,
|
||||
`actionRunnerVersion`, `actionRunnerConnectedAt`, and the receipt, preflight,
|
||||
and Docker-observation protocol versions from the organization-scoped
|
||||
credential and admitted-session registries. The unified agent report contract
|
||||
(`pkg/agents/host/report.go`) carries only the agent-authored subset.
|
||||
|
||||
These fields are descriptive fleet evidence, not authorization and not a
|
||||
health reason. They never contain raw credentials, credential paths, helper
|
||||
paths, unit groups/capabilities, or binary ownership. Consumers must not infer
|
||||
those host-local facts, safe-profile completion, or action readiness from an
|
||||
absent field, collector health, or the mere existence of a runner credential.
|
||||
Agent Doctor may show the raw `token` returned by the issuance response only as
|
||||
a one-time component-memory reveal; it must not persist that value in browser
|
||||
storage, URLs, diagnostics, installer commands, or process arguments. The
|
||||
generated installer handoff carries only a private `--action-token-file` path.
|
||||
|
||||
### Proxmox node network inventory is an optional canonical facet
|
||||
|
||||
|
||||
@@ -38,6 +38,14 @@ command-capable runtime whose monitoring credential cannot re-enable commands
|
||||
is a scope-mismatch warning; an actively command-enabled runtime without
|
||||
`agent:exec` remains critical. These facts do not change collection truth or
|
||||
make a least-privilege service unhealthy by themselves.
|
||||
Action-runner posture is a server-side join, not collector-reported monitoring
|
||||
truth. The collector may report only that its typed local helper is configured;
|
||||
Agent Doctor separately overlays the current tenant's host-bound runner-token
|
||||
inventory and admitted command-session inventory. A runner is connected only
|
||||
when the canonical agent ID matches an admitted `action-runner` session with
|
||||
the closed `typed_actions.v1` capability. Credential presence, expiry,
|
||||
connection, version, and protocol facts remain distinct, and none may be
|
||||
inferred from collector health or helper configuration.
|
||||
Monitor construction also applies the persisted alert schedule's normalized
|
||||
initial-delivery target to the tenant notification manager. This is runtime
|
||||
wiring only: monitoring does not choose destinations or own notification
|
||||
|
||||
@@ -65,6 +65,13 @@ shell/exec, unrestricted `read_file`, deploy, and trusted-origin bypasses are
|
||||
forbidden. The legacy combined command channel remains a disclosed full-trust
|
||||
migration boundary only until runner enrollment and live session parity are
|
||||
qualified; it is not safe-profile authority.
|
||||
Action-runner issuance and rotation are one durable host-bound transition per
|
||||
organization and canonical agent ID. Re-issuance replaces the prior runner
|
||||
record even when the monitored hostname has changed, returns the new plaintext
|
||||
secret only after persistence succeeds, and restores the complete prior token
|
||||
inventory if persistence fails. A successful rotation invalidates the previous
|
||||
secret immediately; it never widens the collector credential or turns a
|
||||
monitoring session into an action session.
|
||||
|
||||
Own Pulse's canonical privacy disclosures, outbound usage-data boundary,
|
||||
and the security-facing settings surfaces that expose authentication posture,
|
||||
|
||||
@@ -34,6 +34,12 @@ preserves collector identity and installation files only; it must not rewrite
|
||||
storage/recovery evidence or reinterpret a restored legacy collector profile as
|
||||
recovery success. Typed `host.storage_cleanup` remains governed remediation,
|
||||
not storage-recovery authority inferred from the credential itself.
|
||||
Runner credential rotation follows the shared token-inventory commit boundary:
|
||||
failed persistence restores the complete prior inventory and returns no new
|
||||
secret, while successful persistence replaces the previous organization/agent
|
||||
binding and makes its secret unusable. That rollback protects restart-time
|
||||
credential truth only; it is not a customer backup, recovery point, restore
|
||||
operation, or storage-cleanup grant.
|
||||
The helper-backed collector updater likewise retains only one fixed
|
||||
last-known-good executable and an identity-bound activation receipt. That
|
||||
binary rollback is agent lifecycle state, not a Pulse storage snapshot,
|
||||
|
||||
@@ -1,28 +1,26 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "d607d5cf4616e52b2fc7a576846dbe7204e9a7cf",
|
||||
"verified_at": "2026-08-29T23:04:06Z",
|
||||
"base_sha": "28be70071e8a7c2d0b9663f5d2eab5f5ba2f6528",
|
||||
"verified_at": "2026-08-29T23:37:43Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/docker/DockerPageSurface.tsx",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx"
|
||||
"frontend-modern/src/api/agentDiagnostics.ts",
|
||||
"frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx",
|
||||
"frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/docker/DockerPageSurface.tsx": "013f32dc0201ed993a68763a53e88374fe9b08892e321806fb5dbb9aa51bf77f",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx": "c8ab6ff8abdf7c61a5c75f0d024a682c1b83a10efa46278c87b946afe654ef60",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "2158aed352e98b2fc1f0d308b494d0a8d387aefc28fbf90bbc92a498e49e161a"
|
||||
"frontend-modern/src/api/agentDiagnostics.ts": "1e3c207615fd27fedf811b40830789f779b80b570085c54970cfb277f54eb07f",
|
||||
"frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx": "0053b3fa0be0fbf463a6569c4fef4d977401647fe7c717a27543eb023a69e8ff",
|
||||
"frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts": "5157fd513d3d149fdd844e0e807d0d73879661ab165cf2850efa8286a888ecbf"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox/overview",
|
||||
"/proxmox/backups/date",
|
||||
"/docker/overview",
|
||||
"/kubernetes/overview"
|
||||
"/settings/infrastructure/agent-doctor",
|
||||
"/settings/infrastructure/agent-doctor?agents=host-node-mock-cluster-4-pve22"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1280,
|
||||
"height": 720
|
||||
"width": 1440,
|
||||
"height": 1000
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
@@ -30,18 +28,17 @@
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Proxmox Overview with the node table and paginated guest inventory populated from only the source-scoped Overview owner",
|
||||
"Proxmox Backups with PBS servers, backup-health summary, activity chart, filters, and workload rows composed from the existing guest snapshot plus PBS rows",
|
||||
"Docker Overview with provider-owned hosts and containers populated from one source-scoped inventory request",
|
||||
"Kubernetes Overview with provider-owned clusters, deployments, and pods populated from one source-scoped inventory request",
|
||||
"all four provider states at desktop and narrow viewport widths"
|
||||
"full fleet summary and expanded legacy Linux agent with explicit safe-profile inspect and apply guidance",
|
||||
"eligible monitoring-only collector with typed-helper configuration and no action-runner credential or session",
|
||||
"one-time host-bound action-runner credential reveal with private token prompt and separate runner installer handoff",
|
||||
"credential-cleared page-session state with repeat issuance disabled",
|
||||
"eligible safe collector at desktop and narrow viewport widths"
|
||||
],
|
||||
"interactions": [
|
||||
"reloaded Proxmox Overview with browser request tracing and verified that only its ten paginated source=proxmox Overview requests ran, with no inactive Storage, Backups, Replication, Ceph, or Mail hydration",
|
||||
"navigated from Proxmox Overview to Backups and verified that the transition added exactly one source=pbs request without repeating the paginated guest inventory",
|
||||
"reloaded Docker Overview and verified that its only inventory request included source=docker and populated the host and container surfaces",
|
||||
"reloaded Kubernetes Overview and verified that its only inventory request included source=kubernetes and populated the cluster and workload surfaces",
|
||||
"repeated the provider route and transition checks at 390 by 844 and verified the tables, navigation, filters, status summaries, and workload rows remained present",
|
||||
"reset the temporary viewport override and closed the verification tab"
|
||||
"expanded a legacy Linux agent and copied the standalone safe-profile inspection command",
|
||||
"issued an isolated non-persistent action-runner credential fixture and verified the normalized host identity, closed runtime role, and typed capability before reveal",
|
||||
"verified the revealed secret was absent from the private token-file command, installer command, URL, and diagnostic report",
|
||||
"cleared the one-time credential and verified the secret disappeared while repeat issuance stayed disabled for the page session",
|
||||
"verified the 390 by 844 layout had no document overflow, checked the rendered pixels, reset the viewport override, and removed the temporary request fixture"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ root read access:
|
||||
|
||||
- install it only on hosts you trust Pulse to monitor;
|
||||
- keep the agent token scoped to that Pulse server;
|
||||
- keep command execution disabled unless you explicitly need governed
|
||||
remediation;
|
||||
- keep the local command-authority profile `monitoring-only` unless you
|
||||
explicitly accept the transitional combined command runtime;
|
||||
- update from signed release assets rather than arbitrary branch snapshots.
|
||||
|
||||
The agent is primarily an outbound reporter to your Pulse server. By default it
|
||||
@@ -52,12 +52,16 @@ capacity monitoring, for example a log2ram `/var/log` mount. This reports
|
||||
filesystem capacity and usage metadata. It does not read or transmit file
|
||||
contents. Local `--disk-exclude` rules still take precedence.
|
||||
|
||||
Command execution is disabled by default. It can be enabled with
|
||||
`--enable-commands`, `PULSE_ENABLE_COMMANDS=true`, or the centralized agent
|
||||
command setting after enrollment. Leave it disabled for read-only monitoring.
|
||||
When enabled, commands still flow through Pulse's command policy and approval
|
||||
surfaces instead of silently turning every agent into an unrestricted remote
|
||||
shell.
|
||||
Fresh installs use a local `monitoring-only` command-authority ceiling and a
|
||||
credential without `agent:exec`. Remote configuration cannot promote that
|
||||
service. Selecting the advanced legacy combined command profile at install time
|
||||
adds `--enable-commands`, records `command-capable`, and issues an execution-
|
||||
scoped credential. The root monitoring process can then also accept server
|
||||
command requests through the existing policy and approval surfaces. Existing
|
||||
unmarked services upgrade as `legacy` during the migration window so upgrades
|
||||
do not silently revoke an operator's prior command choice. Agent Doctor shows
|
||||
the process privilege, local authority ceiling, and credential execution scope
|
||||
separately and warns about mismatches.
|
||||
|
||||
Custom numeric sensors are a separate, local configuration boundary. Enabling
|
||||
them with `--custom-sensors-file` does not enable remote commands and
|
||||
@@ -139,6 +143,117 @@ user and token.
|
||||
|
||||
## Least-Privilege Agent Profile
|
||||
|
||||
For a standard Linux systemd host, the stronger opt-in profile is:
|
||||
|
||||
```bash
|
||||
install.sh --least-privilege --enable-privileged-helper ...
|
||||
```
|
||||
|
||||
This profile keeps the networked collector unprivileged and keeps both the
|
||||
collector and helper binaries root-owned. Its installer token lives under the
|
||||
root-owned `/etc/pulse-agent` directory with `root:pulse-agent` group-read
|
||||
access; mutable identity, buffering, and enrolled monitoring-token state remain
|
||||
under the collector-owned state directory. The collector is not added to the
|
||||
rootful Docker group and cannot enable command execution. Automatic binary
|
||||
replacement uses a separate typed transaction: the collector downloads and
|
||||
self-tests a signed artifact inside its fixed quarantine, while the helper
|
||||
revalidates ownership, digest, ELF shape, and signature before promoting it to
|
||||
root-only staging and atomically activating it. A failed process restart asks
|
||||
the helper to restore the identity-bound last-known-good binary; there is no
|
||||
collector-writable direct-replacement fallback.
|
||||
|
||||
Exceptional telemetry crosses `/run/pulse-agent/helper.sock` to a separate
|
||||
root process. The socket admits only the `pulse-agent` UID, and the helper has
|
||||
no Pulse URL, API token, or network namespace. Its protocol exposes bounded,
|
||||
versioned SMART and Proxmox LXC filesystem snapshots, not a shell, executable
|
||||
path, device path, VMID, environment, or caller-selected arguments. The helper
|
||||
service keeps `PrivateNetwork=true`, `RestrictAddressFamilies=AF_UNIX`,
|
||||
`NoNewPrivileges=true`, `ProtectSystem=strict`, and `ProtectHome=true`.
|
||||
`PrivateDevices` is intentionally not enabled because SMART needs the host
|
||||
block devices. If the helper is missing, incompatible, or rejects a request,
|
||||
only the affected telemetry disappears; the collector does not fall back to
|
||||
sudo, root, or a broader local command path.
|
||||
|
||||
The typed-helper profile cannot be combined with `--grant-smart` or
|
||||
`--grant-pct`. Rootful Docker-socket monitoring is also unavailable because
|
||||
membership in the Docker group is root-equivalent; API monitoring or a
|
||||
separately scoped rootless runtime socket is required instead. The profile is
|
||||
currently explicit rather than the installer default. Its inspect, apply, and
|
||||
rollback transaction is implemented for Linux systemd, but representative
|
||||
live-host migration and helper update staging/activation/rollback exercises,
|
||||
container-runtime parity, and appliance qualification are still required
|
||||
before the profile can become the general default.
|
||||
|
||||
Monitoring never implies remediation. On the supported Linux systemd profile,
|
||||
an operator may separately enroll the typed action runner:
|
||||
|
||||
```bash
|
||||
install.sh --least-privilege --enable-privileged-helper \
|
||||
--enable-action-runner --action-token-file /root/pulse-runner.token ...
|
||||
```
|
||||
|
||||
Create that token through the authenticated action-runner credential endpoint
|
||||
for the exact monitored host; do not reuse the collector token. The installer
|
||||
keeps the runner binary, service, credential, health record, and receipts
|
||||
root-owned. Disabling or uninstalling the runner leaves monitoring active.
|
||||
The safe runner accepts only versioned host update/storage-cleanup, Proxmox
|
||||
guest lifecycle, and container lifecycle/update requests. Generic shell,
|
||||
`exec`, unrestricted `read_file`, and deploy operations are rejected.
|
||||
|
||||
**Settings → Infrastructure → Agent Doctor** presents this boundary as
|
||||
evidence, not an inferred security grade. The collector reports whether it is
|
||||
root, its service user and local command-authority ceiling, and whether the
|
||||
typed helper, SMART helper, and `pct` helper are configured. Pulse separately
|
||||
joins the current tenant's token inventory and admitted command sessions to
|
||||
show whether the collector credential is known and execution-scoped, whether
|
||||
a host-bound runner credential is active, and whether a compatible
|
||||
`action-runner` / `typed_actions.v1` session is actually connected. Missing
|
||||
evidence remains unknown; collector health never proves remediation readiness.
|
||||
|
||||
Runner enrollment is one host at a time. The issuance request must resolve to
|
||||
exactly one non-conflicted monitored agent ID and normalized hostname in the
|
||||
current tenant. The returned secret is shown once and held only in the open
|
||||
Agent Doctor page's memory; it is not written to browser storage, a URL, a
|
||||
diagnostic report, or an installer command. The generated handoff first prompts
|
||||
for the secret through `/dev/tty` into the root-owned
|
||||
`/etc/pulse-agent-runner/token` file with mode `0600`, then passes only that
|
||||
file path through `--action-token-file`. Issuing again rotates the credential
|
||||
for that tenant/host binding: Pulse durably replaces the previous record as one
|
||||
transaction and restores it if persistence fails. A successful rotation makes
|
||||
the previous secret invalid, so complete the new token-file handoff before
|
||||
expecting a disconnected runner to reconnect.
|
||||
|
||||
The existing combined collector command path remains available only as the
|
||||
explicit legacy/full-trust migration profile. It is not part of the typed
|
||||
helper/runner security claim and will remain until supported command-enabled
|
||||
installs have a runner enrollment path and live action-session parity has been
|
||||
qualified.
|
||||
|
||||
Safe-profile conversion is always deliberate:
|
||||
|
||||
```bash
|
||||
install.sh --safe-profile-inspect ...
|
||||
install.sh --safe-profile-apply ...
|
||||
install.sh --safe-profile-rollback ...
|
||||
```
|
||||
|
||||
Inspection makes no changes. Apply snapshots collector/helper files and
|
||||
identity before switching profiles, and rollback restores that snapshot. The
|
||||
separately installed action runner is not changed by either operation.
|
||||
Inspection reports platform support, the current unit user and groups, ambient
|
||||
capabilities, collector-binary owner and mode, enabled provider flags, helper
|
||||
and collector-command state, independent runner presence, and the calculated
|
||||
typed-helper target and degraded Docker/action differences. Apply is supported
|
||||
only on reviewed standard Linux systemd hosts: it retains the monitoring token
|
||||
and agent identity, lowers the collector to monitoring-only, removes legacy
|
||||
sudo/Docker-group/ambient authority, requires collector health, helper socket
|
||||
health, and declared server registration before commit, and automatically
|
||||
restores the exact snapshot on failure. Ordinary `--update` preserves the
|
||||
installed profile and never performs this migration.
|
||||
|
||||
The older `--least-privilege` profile without `--enable-privileged-helper`
|
||||
remains available for compatibility. Its trade-offs are documented below.
|
||||
|
||||
On standard Linux systemd hosts, `install.sh --least-privilege` is a supported
|
||||
alternative to the root profile. It runs the service as a dedicated
|
||||
`pulse-agent` system user (nologin shell, owning only its state directory and
|
||||
@@ -172,9 +287,10 @@ platforms (TrueNAS, Synology, QNAP, Unraid) and non-systemd init systems keep
|
||||
the root profile; the installer refuses `--least-privilege` there rather than
|
||||
silently falling back to root.
|
||||
|
||||
`--update` preserves an existing least-privilege profile and its grants
|
||||
without the flags being repeated. Uninstall removes the sudoers file and
|
||||
helpers; the inert system user is left behind deliberately.
|
||||
`--update` preserves either installed least-privilege profile without the flags
|
||||
being repeated. Uninstall removes the typed helper socket/service and its
|
||||
installer credential directory, or the legacy sudoers file and wrappers; the
|
||||
inert system user is left behind deliberately.
|
||||
|
||||
## Supply-Chain Boundary
|
||||
|
||||
|
||||
@@ -90,6 +90,58 @@ curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
- **Auto-Update**: Automatically updates when a new version is released
|
||||
- **Multi-Platform**: Linux, macOS, Windows support
|
||||
|
||||
The opt-in Linux typed-helper collector profile keeps auto-update enabled
|
||||
without making its root-owned binary writable by the service account. The
|
||||
collector downloads and self-tests the signed artifact in a fixed
|
||||
collector-owned quarantine; the no-network helper revalidates it, promotes it
|
||||
to root-only staging, atomically activates it, and owns identity-bound rollback
|
||||
if restart fails. There is no fallback to direct unprivileged replacement.
|
||||
This transaction is covered by unit, race, release-build, and installer
|
||||
contract tests; live-host restart/health and rollback qualification remains
|
||||
required before the profile becomes the general default.
|
||||
See [Agent Security](AGENT_SECURITY.md#least-privilege-agent-profile).
|
||||
|
||||
On Linux systemd, the safe monitoring profile and remediation lifecycle are
|
||||
separate install choices. `--least-privilege --enable-privileged-helper`
|
||||
selects the opt-in unprivileged collector and no-network helper. Adding
|
||||
`--enable-action-runner --action-token-file <private-file>` installs the
|
||||
root-owned runner with a separately issued, host-bound action credential.
|
||||
`--disable-action-runner` and `--uninstall-action-runner` remove remediation
|
||||
without removing the collector. The runner accepts only the documented typed
|
||||
host, Proxmox guest, and container operations; shell, generic exec,
|
||||
unrestricted file reads, and deploy requests remain forbidden.
|
||||
|
||||
Agent Doctor shows the reported collector service user, command-authority
|
||||
ceiling, collector credential scope, and typed-helper configuration separately
|
||||
from the tenant- and host-bound action-runner credential and connected-session
|
||||
state. It does not treat a healthy collector as proof that actions are ready.
|
||||
For an eligible Linux systemd host, enrollment returns one credential reveal
|
||||
kept only in the open page's memory. Start the generated `/dev/tty` prompt,
|
||||
copy and paste the secret into that prompt to write the private root-owned
|
||||
token file, and then run the installer command, which carries only
|
||||
`--action-token-file <private-file>` in its arguments. Re-enrollment atomically
|
||||
replaces the prior credential for the same tenant and canonical agent ID; a
|
||||
failed persistence write retains the previous credential, while a successful
|
||||
rotation invalidates it.
|
||||
|
||||
Use `--safe-profile-inspect` to report the current profile and calculated
|
||||
differences without changing the host. `--safe-profile-apply` performs the
|
||||
explicit collector/helper migration and retains a rollback snapshot;
|
||||
`--safe-profile-rollback` restores it. These commands are proven only for
|
||||
Linux systemd and fail closed elsewhere. Appliance, non-systemd, Windows, and
|
||||
macOS installs remain explicit legacy/full-trust profiles until their own
|
||||
runtime and migration boundaries are qualified.
|
||||
Inspection includes unit identity/groups, ambient capabilities, binary
|
||||
ownership/mode, provider flags, helper/command state, independent runner
|
||||
presence, and the expected telemetry/action degradation. Apply preserves the
|
||||
collector token and agent identity, commits only after local health, helper
|
||||
socket, and server registration checks, and restores the exact prior
|
||||
collector/helper profile on failure. It never changes a separately installed
|
||||
runner, and ordinary `--update` never implies a profile migration. The secure
|
||||
runtime separation remains a proposed hardening lane; the product-wide default
|
||||
does not change until representative real Linux hosts pass migration,
|
||||
activation, rollback, provider-parity, and action-session qualification.
|
||||
|
||||
On Linux, the host module automatically checks for `virsh`. When the agent can
|
||||
open the default libvirt connection read-only, defined domains appear as VM
|
||||
workloads under that host. Collection uses libvirt's bounded bulk statistics
|
||||
|
||||
@@ -54,4 +54,32 @@ describe('AgentDiagnosticsAPI', () => {
|
||||
summary: { total: 0, healthy: 0, warning: 0, critical: 0, removed: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('issues a separately scoped host-bound action-runner credential', async () => {
|
||||
mockedApiFetchJSON.mockResolvedValueOnce({
|
||||
token: 'runner-secret',
|
||||
tokenId: 'token-1',
|
||||
organizationId: 'org-1',
|
||||
agentId: 'host-1',
|
||||
hostname: 'host-1.local',
|
||||
runtimeRole: 'action-runner',
|
||||
actionCapability: 'typed_actions.v1',
|
||||
});
|
||||
|
||||
await expect(
|
||||
AgentDiagnosticsAPI.issueActionRunnerCredential({
|
||||
agentId: 'host-1',
|
||||
hostname: 'host-1.local',
|
||||
name: 'host-1 action runner',
|
||||
}),
|
||||
).resolves.toMatchObject({ token: 'runner-secret', agentId: 'host-1' });
|
||||
expect(mockedApiFetchJSON).toHaveBeenCalledWith('/api/agents/action-runner/credential', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
agentId: 'host-1',
|
||||
hostname: 'host-1.local',
|
||||
name: 'host-1 action runner',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,8 +54,36 @@ export interface AgentFleetDiagnosticPrivilege {
|
||||
commandAuthority?: 'monitoring-only' | 'command-capable' | 'legacy' | string;
|
||||
credentialKnown?: boolean;
|
||||
credentialExec?: boolean;
|
||||
typedHelper?: boolean;
|
||||
smartctlHelper?: boolean;
|
||||
pctHelper?: boolean;
|
||||
actionRunnerCredentialIssued?: boolean;
|
||||
actionRunnerCredentialActive?: boolean;
|
||||
actionRunnerRuntimeRole?: string;
|
||||
actionRunnerCapability?: string;
|
||||
actionRunnerBindingVersion?: string;
|
||||
actionRunnerConnected?: boolean;
|
||||
actionRunnerVersion?: string;
|
||||
actionRunnerConnectedAt?: number;
|
||||
actionRunnerReceiptProtocol?: number;
|
||||
actionRunnerPreflightProtocol?: number;
|
||||
actionRunnerDockerObservationProtocol?: number;
|
||||
}
|
||||
|
||||
export interface ActionRunnerCredentialRequest {
|
||||
agentId: string;
|
||||
hostname: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface ActionRunnerCredentialResponse {
|
||||
token: string;
|
||||
tokenId: string;
|
||||
organizationId: string;
|
||||
agentId: string;
|
||||
hostname: string;
|
||||
runtimeRole: 'action-runner';
|
||||
actionCapability: 'typed_actions.v1';
|
||||
}
|
||||
|
||||
export interface AgentFleetAgentDiagnostic {
|
||||
@@ -130,4 +158,13 @@ export class AgentDiagnosticsAPI {
|
||||
agents: response.agents ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
static issueActionRunnerCredential(
|
||||
request: ActionRunnerCredentialRequest,
|
||||
): Promise<ActionRunnerCredentialResponse> {
|
||||
return apiFetchJSON('/api/agents/action-runner/credential', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,12 +15,17 @@ import { copyToClipboard } from '@/utils/clipboard';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { MonitoringAPI } from '@/api/monitoring';
|
||||
import { AgentDiagnosticsAPI } from '@/api/agentDiagnostics';
|
||||
import {
|
||||
getUnifiedAgentClipboardCopyErrorMessage,
|
||||
getUnifiedAgentClipboardCopySuccessMessage,
|
||||
} from '@/utils/unifiedAgentInventoryPresentation';
|
||||
import {
|
||||
formatInfrastructureAgentDoctorReport,
|
||||
buildActionRunnerInstallCommand,
|
||||
buildActionRunnerTokenFileCommand,
|
||||
buildSafeCollectorApplyCommand,
|
||||
buildSafeCollectorInspectCommand,
|
||||
getInfrastructureAgentDoctorUninstallHandoff,
|
||||
summarizeInfrastructureAgentDoctorTargets,
|
||||
type InfrastructureAgentDoctorStatus,
|
||||
@@ -192,6 +197,61 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
|
||||
new Map(),
|
||||
);
|
||||
const [reenrollPendingId, setReenrollPendingId] = createSignal('');
|
||||
const [runnerCredentialPendingKey, setRunnerCredentialPendingKey] = createSignal('');
|
||||
const [issuedRunnerCredentialKeys, setIssuedRunnerCredentialKeys] = createSignal<
|
||||
ReadonlySet<string>
|
||||
>(new Set());
|
||||
const [runnerCredentialReveal, setRunnerCredentialReveal] = createSignal<{
|
||||
targetKey: string;
|
||||
token: string;
|
||||
} | null>(null);
|
||||
|
||||
const issueActionRunnerCredential = async (target: InfrastructureAgentDoctorTarget) => {
|
||||
if (
|
||||
!target.actionRunnerCredentialEligible ||
|
||||
!target.actionRunnerAgentId ||
|
||||
!target.actionRunnerHostname
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setRunnerCredentialReveal(null);
|
||||
setRunnerCredentialPendingKey(target.key);
|
||||
try {
|
||||
const response = await AgentDiagnosticsAPI.issueActionRunnerCredential({
|
||||
agentId: target.actionRunnerAgentId,
|
||||
hostname: target.actionRunnerHostname,
|
||||
name: `${target.displayName} action runner`,
|
||||
});
|
||||
const normalizedHostname = (value: string) => value.trim().toLowerCase().replace(/\.+$/, '');
|
||||
if (
|
||||
response.agentId !== target.actionRunnerAgentId ||
|
||||
normalizedHostname(response.hostname) !== normalizedHostname(target.actionRunnerHostname) ||
|
||||
response.runtimeRole !== 'action-runner' ||
|
||||
response.actionCapability !== 'typed_actions.v1' ||
|
||||
!response.token
|
||||
) {
|
||||
throw new Error(
|
||||
'Pulse returned an unexpected action-runner credential identity or authority.',
|
||||
);
|
||||
}
|
||||
setRunnerCredentialReveal({ targetKey: target.key, token: response.token });
|
||||
setIssuedRunnerCredentialKeys((previous) => new Set(previous).add(target.key));
|
||||
notificationStore.success('Action-runner credential issued. Save it now. It is shown once.');
|
||||
try {
|
||||
await props.onRetryDiagnostics?.();
|
||||
} catch {
|
||||
notificationStore.error(
|
||||
'The credential was issued, but Pulse could not refresh its runner posture.',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
notificationStore.error(
|
||||
error instanceof Error ? error.message : 'Failed to issue action-runner credential.',
|
||||
);
|
||||
} finally {
|
||||
setRunnerCredentialPendingKey('');
|
||||
}
|
||||
};
|
||||
const allowReenroll = async (target: InfrastructureAgentDoctorTarget) => {
|
||||
const separator = target.connectionId.indexOf(':');
|
||||
const type = separator > 0 ? target.connectionId.slice(0, separator) : '';
|
||||
@@ -552,6 +612,205 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
|
||||
</Show>
|
||||
</p>
|
||||
|
||||
<div class="space-y-2 rounded-md border border-border bg-surface px-3 py-3 text-xs">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-base-content">Security posture</span>
|
||||
<Show when={target.safeCollector}>
|
||||
<span class="rounded-full bg-emerald-100 px-2 py-0.5 font-medium text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200">
|
||||
Safe collector confirmed
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={target.safeProfileGuidance}>
|
||||
<p class="leading-5 text-muted">{target.safeProfileGuidance}</p>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
!target.safeCollector &&
|
||||
target.status !== 'removed' &&
|
||||
target.commandPlatform === 'linux' &&
|
||||
target.actionRunnerAgentId &&
|
||||
target.actionRunnerHostname
|
||||
}
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<div class="mb-1 font-medium text-base-content">
|
||||
Inspect current service profile
|
||||
</div>
|
||||
<div class="relative">
|
||||
<CommandCopyButton
|
||||
onClick={() =>
|
||||
void copyCommand(
|
||||
buildSafeCollectorInspectCommand({
|
||||
pulseUrl: operations.selectedAgentUrl(),
|
||||
insecure: operations.insecureMode(),
|
||||
customCaPath: operations.customCaPath(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
label="Copy safe-profile inspection command"
|
||||
/>
|
||||
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
|
||||
<code>
|
||||
{buildSafeCollectorInspectCommand({
|
||||
pulseUrl: operations.selectedAgentUrl(),
|
||||
insecure: operations.insecureMode(),
|
||||
customCaPath: operations.customCaPath(),
|
||||
})}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 font-medium text-base-content">
|
||||
Apply reviewed safe collector profile
|
||||
</div>
|
||||
<div class="relative">
|
||||
<CommandCopyButton
|
||||
onClick={() =>
|
||||
void copyCommand(
|
||||
buildSafeCollectorApplyCommand({
|
||||
pulseUrl: operations.selectedAgentUrl(),
|
||||
insecure: operations.insecureMode(),
|
||||
customCaPath: operations.customCaPath(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
label="Copy safe-profile apply command"
|
||||
/>
|
||||
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
|
||||
<code>
|
||||
{buildSafeCollectorApplyCommand({
|
||||
pulseUrl: operations.selectedAgentUrl(),
|
||||
insecure: operations.insecureMode(),
|
||||
customCaPath: operations.customCaPath(),
|
||||
})}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={target.actionRunnerPosture.length > 0}>
|
||||
<ul class="list-disc space-y-1 pl-5 text-muted">
|
||||
<For each={target.actionRunnerPosture}>
|
||||
{(fact) => <li>{fact}</li>}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
<Show
|
||||
when={target.actionRunnerCredentialEligible}
|
||||
fallback={
|
||||
<Show when={target.actionRunnerCredentialBlockReason}>
|
||||
<p class="text-muted">
|
||||
Action runner: {target.actionRunnerCredentialBlockReason}
|
||||
</p>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
isLoading={runnerCredentialPendingKey() === target.key}
|
||||
disabled={
|
||||
Boolean(runnerCredentialReveal()) ||
|
||||
issuedRunnerCredentialKeys().has(target.key)
|
||||
}
|
||||
onClick={() => void issueActionRunnerCredential(target)}
|
||||
>
|
||||
{issuedRunnerCredentialKeys().has(target.key)
|
||||
? 'Credential issued for this page session'
|
||||
: target.actionRunnerCredentialAction === 'rotate'
|
||||
? 'Rotate action-runner credential'
|
||||
: 'Issue one-time action-runner credential'}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={
|
||||
runnerCredentialReveal()?.targetKey === target.key
|
||||
? runnerCredentialReveal()
|
||||
: null
|
||||
}
|
||||
>
|
||||
{(reveal) => {
|
||||
const tokenFileCommand = () => buildActionRunnerTokenFileCommand();
|
||||
const installCommand = () =>
|
||||
buildActionRunnerInstallCommand({
|
||||
pulseUrl: operations.selectedAgentUrl(),
|
||||
agentId: target.actionRunnerAgentId!,
|
||||
hostname: target.actionRunnerHostname!,
|
||||
insecure: operations.insecureMode(),
|
||||
customCaPath: operations.customCaPath(),
|
||||
});
|
||||
return (
|
||||
<section class="space-y-3 rounded-md border border-amber-300 bg-amber-50 px-3 py-3 text-xs text-amber-950 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100">
|
||||
<div class="font-semibold">Save this credential now</div>
|
||||
<p class="leading-5">
|
||||
This secret is held only in this page's memory and cannot be
|
||||
recovered after you clear or leave it. It is never placed in a
|
||||
URL, diagnostic report, installer command, or process
|
||||
argument.
|
||||
</p>
|
||||
<div>
|
||||
<div class="mb-1 font-medium">
|
||||
1. Start a private token prompt
|
||||
</div>
|
||||
<div class="relative">
|
||||
<CommandCopyButton
|
||||
onClick={() => void copyCommand(tokenFileCommand())}
|
||||
label="Copy private token-file command"
|
||||
/>
|
||||
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
|
||||
<code>{tokenFileCommand()}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 font-medium">
|
||||
2. Copy this credential and paste it at the prompt
|
||||
</div>
|
||||
<div class="relative">
|
||||
<CommandCopyButton
|
||||
onClick={() => void copyCommand(reveal().token)}
|
||||
title="Copy one-time action-runner credential"
|
||||
label={`Copy action-runner credential for ${target.displayName}`}
|
||||
/>
|
||||
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
|
||||
<code>{reveal().token}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 font-medium">
|
||||
3. Install the separate runner
|
||||
</div>
|
||||
<div class="relative">
|
||||
<CommandCopyButton
|
||||
onClick={() => void copyCommand(installCommand())}
|
||||
label="Copy action-runner installer command"
|
||||
/>
|
||||
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
|
||||
<code>{installCommand()}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setRunnerCredentialReveal(null)}
|
||||
>
|
||||
Clear credential from this page
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={target.reasons.length > 0}
|
||||
fallback={
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { InfrastructureAgentDoctorPage } from '../InfrastructureAgentDoctorPage';
|
||||
import type { InfrastructureAgentDoctorTarget } from '../infrastructureAgentUpdateCommandsModel';
|
||||
|
||||
const issueCredential = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/api/agentDiagnostics', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('@/api/agentDiagnostics')>('@/api/agentDiagnostics');
|
||||
return {
|
||||
...actual,
|
||||
AgentDiagnosticsAPI: {
|
||||
...actual.AgentDiagnosticsAPI,
|
||||
issueActionRunnerCredential: issueCredential,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../useInfrastructureOperationsState', () => ({
|
||||
useInfrastructureOperationsContext: () => ({
|
||||
getAgentConnectionUpgradeCommandRequiresToken: () => false,
|
||||
commandsUnlocked: () => true,
|
||||
selectedAgentUrl: () => 'https://pulse.example.test',
|
||||
insecureMode: () => false,
|
||||
customCaPath: () => '/etc/pulse/ca.pem',
|
||||
}),
|
||||
}));
|
||||
|
||||
const targetFixture = (
|
||||
overrides: Partial<InfrastructureAgentDoctorTarget> = {},
|
||||
): InfrastructureAgentDoctorTarget => ({
|
||||
key: 'agent:host-1',
|
||||
connectionId: 'agent:host-1',
|
||||
displayName: 'host-1',
|
||||
contextLabel: 'Machine',
|
||||
currentVersion: '6.3.0',
|
||||
installFlags: [],
|
||||
status: 'healthy',
|
||||
reasons: [],
|
||||
evidence: [],
|
||||
needsUpdate: false,
|
||||
needsCredentialRepair: false,
|
||||
commandPlatform: 'linux',
|
||||
privilegeLabel:
|
||||
'least privilege (pulse-agent) · commands monitoring-only · monitoring credential · typed helper configured',
|
||||
safeCollector: true,
|
||||
actionRunnerPosture: ['Credential not issued', 'Runner not connected'],
|
||||
actionRunnerCredentialEligible: true,
|
||||
actionRunnerCredentialAction: 'issue',
|
||||
actionRunnerAgentId: 'host-1',
|
||||
actionRunnerHostname: 'host-1.local',
|
||||
source: 'diagnostics',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('InfrastructureAgentDoctorPage action-runner enrollment', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
issueCredential.mockResolvedValue({
|
||||
token: 'one-time-runner-secret',
|
||||
tokenId: 'token-1',
|
||||
organizationId: 'org-1',
|
||||
agentId: 'host-1',
|
||||
hostname: 'HOST-1.LOCAL.',
|
||||
runtimeRole: 'action-runner',
|
||||
actionCapability: 'typed_actions.v1',
|
||||
});
|
||||
});
|
||||
|
||||
it('reveals the host-bound secret once and keeps it out of both commands', async () => {
|
||||
render(() => <InfrastructureAgentDoctorPage targets={[targetFixture()]} />);
|
||||
|
||||
expect(screen.getByText('Safe collector confirmed')).toBeInTheDocument();
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: 'Issue one-time action-runner credential' }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('one-time-runner-secret')).toBeInTheDocument());
|
||||
expect(issueCredential).toHaveBeenCalledWith({
|
||||
agentId: 'host-1',
|
||||
hostname: 'host-1.local',
|
||||
name: 'host-1 action runner',
|
||||
});
|
||||
const promptCommand = screen
|
||||
.getByRole('button', { name: 'Copy private token-file command' })
|
||||
.parentElement?.querySelector('code')?.textContent;
|
||||
const installCommand = screen
|
||||
.getByRole('button', { name: 'Copy action-runner installer command' })
|
||||
.parentElement?.querySelector('code')?.textContent;
|
||||
expect(promptCommand).toContain('read -rsp');
|
||||
expect(promptCommand).toContain('Action runner token: ');
|
||||
expect(promptCommand).toContain('chmod 0600');
|
||||
expect(installCommand).toContain('--preflight-only');
|
||||
expect(installCommand).toContain('--update');
|
||||
expect(installCommand).toContain('--non-interactive');
|
||||
expect(installCommand).toContain('--action-token-file');
|
||||
expect(installCommand).toContain("--cacert '/etc/pulse/ca.pem'");
|
||||
expect(promptCommand).not.toContain('one-time-runner-secret');
|
||||
expect(installCommand).not.toContain('one-time-runner-secret');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear credential from this page' }));
|
||||
expect(screen.queryByText('one-time-runner-secret')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not offer issuance when the reported posture is ineligible', () => {
|
||||
render(() => (
|
||||
<InfrastructureAgentDoctorPage
|
||||
targets={[
|
||||
targetFixture({
|
||||
safeCollector: false,
|
||||
safeProfileGuidance: 'Safe collector profile is not confirmed.',
|
||||
actionRunnerCredentialEligible: false,
|
||||
actionRunnerCredentialBlockReason: 'Confirm the safe collector profile first.',
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Issue one-time action-runner credential' }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Safe collector profile is not confirmed.')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Copy safe-profile inspection command' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Copy safe-profile apply command' }),
|
||||
).toBeInTheDocument();
|
||||
const commands = screen.getAllByText(/--preflight-only/).map((node) => node.textContent ?? '');
|
||||
expect(commands).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('--safe-profile-inspect'),
|
||||
expect.stringContaining('--safe-profile-apply'),
|
||||
]),
|
||||
);
|
||||
expect(commands.every((command) => !command.includes('--update'))).toBe(true);
|
||||
});
|
||||
|
||||
it('offers explicit rotation for an issued credential when the runner is disconnected', () => {
|
||||
render(() => (
|
||||
<InfrastructureAgentDoctorPage
|
||||
targets={[
|
||||
targetFixture({
|
||||
actionRunnerPosture: ['Credential issued (active)', 'Runner not connected'],
|
||||
actionRunnerCredentialAction: 'rotate',
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Rotate action-runner credential' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+184
@@ -3,6 +3,10 @@ import type { AgentFleetAgentDiagnostic } from '@/api/agentDiagnostics';
|
||||
import type { Connection } from '@/api/connections';
|
||||
import type { InfrastructureSystemRow } from '../connectionsTableModel';
|
||||
import {
|
||||
buildActionRunnerInstallCommand,
|
||||
buildActionRunnerTokenFileCommand,
|
||||
buildSafeCollectorApplyCommand,
|
||||
buildSafeCollectorInspectCommand,
|
||||
collectInfrastructureAgentDoctorTargets,
|
||||
diagnosticConnectionID,
|
||||
formatInfrastructureAgentDoctorReport,
|
||||
@@ -183,6 +187,182 @@ describe('Agent Doctor model', () => {
|
||||
expect(target.privilegeLabel).toBe('root · commands monitoring-only · credential grants exec');
|
||||
});
|
||||
|
||||
it('classifies a safe collector only from the complete reported boundary', () => {
|
||||
const connection = connectionFixture();
|
||||
const safePrivilege = {
|
||||
runningAsRoot: false,
|
||||
serviceUser: 'pulse-agent',
|
||||
commandAuthority: 'monitoring-only',
|
||||
credentialKnown: true,
|
||||
credentialExec: false,
|
||||
typedHelper: true,
|
||||
};
|
||||
const [safe] = collectInfrastructureAgentDoctorTargets({
|
||||
rows: [rowFixture(connection)],
|
||||
connections: [connection],
|
||||
diagnostics: [diagnosticFixture({ privilege: safePrivilege })],
|
||||
diagnosticsAvailable: true,
|
||||
targetVersion: '6.2.0',
|
||||
});
|
||||
expect(safe).toMatchObject({
|
||||
safeCollector: true,
|
||||
actionRunnerCredentialEligible: true,
|
||||
actionRunnerAgentId: 'host-1',
|
||||
actionRunnerHostname: 'host-1',
|
||||
});
|
||||
expect(safe.privilegeLabel).toContain('typed helper configured');
|
||||
|
||||
for (const privilege of [
|
||||
{ ...safePrivilege, runningAsRoot: true },
|
||||
{ ...safePrivilege, commandAuthority: 'legacy' },
|
||||
{ ...safePrivilege, credentialExec: true },
|
||||
{ ...safePrivilege, typedHelper: false },
|
||||
]) {
|
||||
const [notSafe] = collectInfrastructureAgentDoctorTargets({
|
||||
rows: [rowFixture(connection)],
|
||||
connections: [connection],
|
||||
diagnostics: [diagnosticFixture({ privilege })],
|
||||
diagnosticsAvailable: true,
|
||||
targetVersion: '6.2.0',
|
||||
});
|
||||
expect(notSafe.safeCollector).toBe(false);
|
||||
expect(notSafe.actionRunnerCredentialEligible).toBe(false);
|
||||
expect(notSafe.safeProfileGuidance).toContain('not confirmed');
|
||||
}
|
||||
});
|
||||
|
||||
it('presents runner credential, connection, authority, and protocol facts', () => {
|
||||
const connection = connectionFixture();
|
||||
const [target] = collectInfrastructureAgentDoctorTargets({
|
||||
rows: [rowFixture(connection)],
|
||||
connections: [connection],
|
||||
diagnostics: [
|
||||
diagnosticFixture({
|
||||
privilege: {
|
||||
runningAsRoot: false,
|
||||
serviceUser: 'pulse-agent',
|
||||
commandAuthority: 'monitoring-only',
|
||||
credentialKnown: true,
|
||||
typedHelper: true,
|
||||
actionRunnerCredentialIssued: true,
|
||||
actionRunnerCredentialActive: true,
|
||||
actionRunnerConnected: true,
|
||||
actionRunnerVersion: '6.3.0',
|
||||
actionRunnerRuntimeRole: 'action-runner',
|
||||
actionRunnerCapability: 'typed-v1',
|
||||
actionRunnerBindingVersion: 'host-v1',
|
||||
actionRunnerReceiptProtocol: 1,
|
||||
actionRunnerPreflightProtocol: 2,
|
||||
actionRunnerDockerObservationProtocol: 3,
|
||||
},
|
||||
}),
|
||||
],
|
||||
diagnosticsAvailable: true,
|
||||
targetVersion: '6.2.0',
|
||||
});
|
||||
|
||||
expect(target.actionRunnerCredentialEligible).toBe(false);
|
||||
expect(target.actionRunnerPosture).toEqual(
|
||||
expect.arrayContaining([
|
||||
'Credential issued (active)',
|
||||
'Runner connected · 6.3.0',
|
||||
'Authority action-runner · typed-v1 · host-v1',
|
||||
'Protocols receipt v1 · preflight v2 · Docker observation v3',
|
||||
]),
|
||||
);
|
||||
|
||||
const [disconnected] = collectInfrastructureAgentDoctorTargets({
|
||||
rows: [rowFixture(connection)],
|
||||
connections: [connection],
|
||||
diagnostics: [
|
||||
diagnosticFixture({
|
||||
privilege: {
|
||||
runningAsRoot: false,
|
||||
serviceUser: 'pulse-agent',
|
||||
commandAuthority: 'monitoring-only',
|
||||
credentialKnown: true,
|
||||
typedHelper: true,
|
||||
actionRunnerCredentialIssued: true,
|
||||
actionRunnerCredentialActive: true,
|
||||
actionRunnerConnected: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
diagnosticsAvailable: true,
|
||||
targetVersion: '6.2.0',
|
||||
});
|
||||
expect(disconnected.actionRunnerCredentialEligible).toBe(true);
|
||||
expect(disconnected.actionRunnerCredentialAction).toBe('rotate');
|
||||
});
|
||||
|
||||
it('never offers runner enrollment for a removed diagnostic', () => {
|
||||
const [removed] = collectInfrastructureAgentDoctorTargets({
|
||||
rows: [],
|
||||
connections: [],
|
||||
diagnostics: [
|
||||
diagnosticFixture({
|
||||
status: 'removed',
|
||||
privilege: {
|
||||
runningAsRoot: false,
|
||||
serviceUser: 'pulse-agent',
|
||||
commandAuthority: 'monitoring-only',
|
||||
credentialKnown: true,
|
||||
typedHelper: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
diagnosticsAvailable: true,
|
||||
targetVersion: '6.2.0',
|
||||
});
|
||||
|
||||
expect(removed.status).toBe('removed');
|
||||
expect(removed.actionRunnerCredentialEligible).toBe(false);
|
||||
expect(removed.actionRunnerCredentialBlockReason).toContain('Removed agents');
|
||||
});
|
||||
|
||||
it('builds a secret-free prompt-to-file handoff and explicit safe-profile migration commands', () => {
|
||||
const secret = 'must-not-appear';
|
||||
const prompt = buildActionRunnerTokenFileCommand();
|
||||
expect(prompt).toContain('sudo bash -c');
|
||||
expect(prompt).toContain('read -rsp');
|
||||
expect(prompt).toContain('Action runner token: ');
|
||||
expect(prompt).toContain('</dev/tty');
|
||||
expect(prompt).toContain('chmod 0600');
|
||||
expect(prompt).not.toContain(secret);
|
||||
|
||||
const install = buildActionRunnerInstallCommand({
|
||||
pulseUrl: 'https://pulse.example.test',
|
||||
agentId: 'host-1',
|
||||
hostname: 'host-1.local',
|
||||
customCaPath: '/etc/pulse/ca.pem',
|
||||
});
|
||||
expect(install).toContain('--preflight-only');
|
||||
expect(install).toContain('--update');
|
||||
expect(install).toContain('--non-interactive');
|
||||
expect(install).toContain('--least-privilege');
|
||||
expect(install).toContain('--enable-privileged-helper');
|
||||
expect(install).toContain('--enable-action-runner');
|
||||
expect(install).toContain('--action-token-file');
|
||||
expect(install).toContain("--agent-id 'host-1'");
|
||||
expect(install).toContain("--hostname 'host-1.local'");
|
||||
expect(install).toContain("--cacert '/etc/pulse/ca.pem'");
|
||||
expect(install).not.toContain(secret);
|
||||
expect(install).not.toContain('--action-token ');
|
||||
|
||||
const inspect = buildSafeCollectorInspectCommand({
|
||||
pulseUrl: 'https://pulse.example.test',
|
||||
});
|
||||
expect(inspect).toContain('--preflight-only');
|
||||
expect(inspect).toContain('--safe-profile-inspect');
|
||||
expect(inspect).not.toContain('--update');
|
||||
const apply = buildSafeCollectorApplyCommand({
|
||||
pulseUrl: 'https://pulse.example.test',
|
||||
});
|
||||
expect(apply).toContain('--preflight-only');
|
||||
expect(apply).toContain('--safe-profile-apply');
|
||||
expect(apply).not.toContain('--update');
|
||||
});
|
||||
|
||||
it('turns a missing credential into a token-gated authentication repair', () => {
|
||||
const connection = connectionFixture({
|
||||
agentUpdateAvailable: false,
|
||||
@@ -779,6 +959,10 @@ describe('Agent Doctor model', () => {
|
||||
needsUpdate: false,
|
||||
needsCredentialRepair: false,
|
||||
commandPlatform: null,
|
||||
safeCollector: false,
|
||||
actionRunnerPosture: [],
|
||||
actionRunnerCredentialEligible: false,
|
||||
actionRunnerCredentialAction: 'issue',
|
||||
source: 'diagnostics',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -5,8 +5,12 @@ import {
|
||||
formatAgentVersionDisplay,
|
||||
parseAgentVersion,
|
||||
} from '@/utils/agentVersion';
|
||||
import type { AgentCommandPlatform } from '@/utils/agentInstallCommand';
|
||||
import {
|
||||
buildUnixAgentInstallCommand,
|
||||
type AgentCommandPlatform,
|
||||
} from '@/utils/agentInstallCommand';
|
||||
import type { InfrastructureSystemRow } from './connectionsTableModel';
|
||||
import { shellQuoteArg } from './infrastructureOperationsModel';
|
||||
|
||||
export type InfrastructureAgentUpdateTarget = {
|
||||
key: string;
|
||||
@@ -39,6 +43,14 @@ export type InfrastructureAgentDoctorTarget = Omit<
|
||||
profileLabel?: string;
|
||||
profileVersionLabel?: string;
|
||||
privilegeLabel?: string;
|
||||
safeCollector: boolean;
|
||||
safeProfileGuidance?: string;
|
||||
actionRunnerPosture: string[];
|
||||
actionRunnerCredentialEligible: boolean;
|
||||
actionRunnerCredentialAction: 'issue' | 'rotate';
|
||||
actionRunnerCredentialBlockReason?: string;
|
||||
actionRunnerAgentId?: string;
|
||||
actionRunnerHostname?: string;
|
||||
lastSeen?: number | string | null;
|
||||
source: 'diagnostics' | 'ledger-fallback' | 'removed';
|
||||
};
|
||||
@@ -603,6 +615,7 @@ const doctorTargetFromBinding = (
|
||||
const profileLabel =
|
||||
diagnostic?.profileName?.trim() || diagnostic?.profileId?.trim() || undefined;
|
||||
const profileVersionLabel = diagnosticProfileVersionLabel(diagnostic);
|
||||
const actionRunner = actionRunnerPresentation(diagnostic, commandPlatform, connection);
|
||||
|
||||
return {
|
||||
key: connection.id,
|
||||
@@ -625,6 +638,7 @@ const doctorTargetFromBinding = (
|
||||
profileLabel,
|
||||
profileVersionLabel,
|
||||
privilegeLabel: diagnosticPrivilegeLabel(diagnostic),
|
||||
...actionRunner,
|
||||
lastSeen: connection.lastSeen ?? diagnostic?.lastSeen,
|
||||
source: diagnosticsAvailable && diagnostic ? 'diagnostics' : 'ledger-fallback',
|
||||
};
|
||||
@@ -685,15 +699,170 @@ const diagnosticPrivilegeLabel = (diagnostic?: AgentFleetAgentDiagnostic): strin
|
||||
if (privilege.credentialKnown) {
|
||||
helpers.push(privilege.credentialExec ? 'credential grants exec' : 'monitoring credential');
|
||||
}
|
||||
if (privilege.typedHelper) helpers.push('typed helper configured');
|
||||
if (privilege.smartctlHelper) helpers.push('SMART helper active');
|
||||
if (privilege.pctHelper) helpers.push('pct helper active');
|
||||
return helpers.length > 0 ? `${base} · ${helpers.join(' · ')}` : base;
|
||||
};
|
||||
|
||||
const isSafeCollectorPrivilege = (diagnostic?: AgentFleetAgentDiagnostic): boolean => {
|
||||
const privilege = diagnostic?.privilege;
|
||||
return Boolean(
|
||||
privilege &&
|
||||
!privilege.runningAsRoot &&
|
||||
privilege.commandAuthority?.trim() === 'monitoring-only' &&
|
||||
privilege.credentialKnown &&
|
||||
!privilege.credentialExec &&
|
||||
privilege.typedHelper,
|
||||
);
|
||||
};
|
||||
|
||||
const actionRunnerPresentation = (
|
||||
diagnostic: AgentFleetAgentDiagnostic | undefined,
|
||||
commandPlatform: AgentCommandPlatform | null,
|
||||
connection?: Connection,
|
||||
removed = false,
|
||||
) => {
|
||||
const privilege = diagnostic?.privilege;
|
||||
const safeCollector = isSafeCollectorPrivilege(diagnostic);
|
||||
const agentId =
|
||||
diagnostic?.agentId?.trim() || normalizeAgentConnectionID(connection?.id).slice(6);
|
||||
const hostname =
|
||||
diagnostic?.hostname?.trim() || connection?.agentIdentity?.hostname?.trim() || undefined;
|
||||
const systemdLooking =
|
||||
commandPlatform === 'linux' && Boolean(privilege?.serviceUser?.trim()) && Boolean(hostname);
|
||||
const posture: string[] = [];
|
||||
if (privilege) {
|
||||
posture.push(
|
||||
privilege.actionRunnerCredentialIssued
|
||||
? `Credential issued (${privilege.actionRunnerCredentialActive ? 'active' : 'inactive'})`
|
||||
: 'Credential not issued',
|
||||
);
|
||||
posture.push(
|
||||
privilege.actionRunnerConnected
|
||||
? `Runner connected${privilege.actionRunnerVersion?.trim() ? ` · ${privilege.actionRunnerVersion.trim()}` : ''}`
|
||||
: 'Runner not connected',
|
||||
);
|
||||
const authority = [
|
||||
privilege.actionRunnerRuntimeRole?.trim(),
|
||||
privilege.actionRunnerCapability?.trim(),
|
||||
privilege.actionRunnerBindingVersion?.trim(),
|
||||
].filter(Boolean);
|
||||
if (authority.length > 0) posture.push(`Authority ${authority.join(' · ')}`);
|
||||
const protocols = [
|
||||
privilege.actionRunnerReceiptProtocol
|
||||
? `receipt v${privilege.actionRunnerReceiptProtocol}`
|
||||
: '',
|
||||
privilege.actionRunnerPreflightProtocol
|
||||
? `preflight v${privilege.actionRunnerPreflightProtocol}`
|
||||
: '',
|
||||
privilege.actionRunnerDockerObservationProtocol
|
||||
? `Docker observation v${privilege.actionRunnerDockerObservationProtocol}`
|
||||
: '',
|
||||
].filter(Boolean);
|
||||
if (protocols.length > 0) posture.push(`Protocols ${protocols.join(' · ')}`);
|
||||
if (privilege.actionRunnerConnectedAt) {
|
||||
posture.push(`Connected at ${new Date(privilege.actionRunnerConnectedAt).toLocaleString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
let safeProfileGuidance: string | undefined;
|
||||
if (!safeCollector) {
|
||||
safeProfileGuidance =
|
||||
commandPlatform === 'linux'
|
||||
? 'Safe collector profile is not confirmed. Inspect the host service and apply --least-privilege --enable-privileged-helper only on a reviewed standard Linux systemd host.'
|
||||
: 'The safe collector profile is supported only on reviewed standard Linux systemd hosts. Inspect this installation. Pulse will not default or guess a migration for this platform.';
|
||||
}
|
||||
|
||||
let blockReason: string | undefined;
|
||||
if (removed) blockReason = 'Removed agents cannot enroll an action runner.';
|
||||
else if (!safeCollector) blockReason = 'Confirm the safe collector profile first.';
|
||||
else if (!systemdLooking)
|
||||
blockReason = 'The diagnostic does not prove a standard Linux systemd installation.';
|
||||
else if (privilege?.actionRunnerConnected)
|
||||
blockReason = 'The action runner is already connected.';
|
||||
else if (!agentId || !hostname) blockReason = 'Canonical host identity is incomplete.';
|
||||
|
||||
return {
|
||||
safeCollector,
|
||||
safeProfileGuidance,
|
||||
actionRunnerPosture: posture,
|
||||
actionRunnerCredentialEligible: !blockReason,
|
||||
actionRunnerCredentialAction: privilege?.actionRunnerCredentialIssued
|
||||
? ('rotate' as const)
|
||||
: ('issue' as const),
|
||||
actionRunnerCredentialBlockReason: blockReason,
|
||||
actionRunnerAgentId: agentId || undefined,
|
||||
actionRunnerHostname: hostname,
|
||||
};
|
||||
};
|
||||
|
||||
const ACTION_RUNNER_TOKEN_FILE = '/etc/pulse-agent-runner/token';
|
||||
|
||||
export const buildActionRunnerTokenFileCommand = (): string =>
|
||||
`sudo bash -c ${shellQuoteArg(
|
||||
[
|
||||
'set -e',
|
||||
`install -d -m 0700 ${shellQuoteArg(ACTION_RUNNER_TOKEN_FILE.slice(0, ACTION_RUNNER_TOKEN_FILE.lastIndexOf('/')))}`,
|
||||
'umask 077',
|
||||
`IFS= read -rsp 'Action runner token: ' token </dev/tty`,
|
||||
`printf '\\n' >/dev/tty`,
|
||||
`printf '%s\\n' "$token" > ${shellQuoteArg(ACTION_RUNNER_TOKEN_FILE)}`,
|
||||
`chmod 0600 ${shellQuoteArg(ACTION_RUNNER_TOKEN_FILE)}`,
|
||||
'unset token',
|
||||
].join('\n'),
|
||||
)}`;
|
||||
|
||||
export const buildActionRunnerInstallCommand = (options: {
|
||||
pulseUrl: string;
|
||||
agentId: string;
|
||||
hostname: string;
|
||||
insecure?: boolean;
|
||||
customCaPath?: string;
|
||||
}): string =>
|
||||
buildUnixAgentInstallCommand({
|
||||
baseUrl: options.pulseUrl,
|
||||
insecure: options.insecure,
|
||||
caCertPath: options.customCaPath,
|
||||
extraArgs: [
|
||||
'--update',
|
||||
'--least-privilege',
|
||||
'--enable-privileged-helper',
|
||||
'--enable-action-runner',
|
||||
`--action-token-file ${shellQuoteArg(ACTION_RUNNER_TOKEN_FILE)}`,
|
||||
`--agent-id ${shellQuoteArg(options.agentId)}`,
|
||||
`--hostname ${shellQuoteArg(options.hostname)}`,
|
||||
],
|
||||
});
|
||||
|
||||
type SafeCollectorCommandOptions = {
|
||||
pulseUrl: string;
|
||||
insecure?: boolean;
|
||||
customCaPath?: string;
|
||||
};
|
||||
|
||||
export const buildSafeCollectorInspectCommand = (options: SafeCollectorCommandOptions): string =>
|
||||
buildUnixAgentInstallCommand({
|
||||
baseUrl: options.pulseUrl,
|
||||
insecure: options.insecure,
|
||||
caCertPath: options.customCaPath,
|
||||
extraArgs: ['--safe-profile-inspect'],
|
||||
});
|
||||
|
||||
export const buildSafeCollectorApplyCommand = (options: SafeCollectorCommandOptions): string =>
|
||||
buildUnixAgentInstallCommand({
|
||||
baseUrl: options.pulseUrl,
|
||||
insecure: options.insecure,
|
||||
caCertPath: options.customCaPath,
|
||||
extraArgs: ['--safe-profile-apply'],
|
||||
});
|
||||
|
||||
const diagnosticOnlyDoctorTarget = (
|
||||
diagnostic: AgentFleetAgentDiagnostic,
|
||||
): InfrastructureAgentDoctorTarget => {
|
||||
const removed = diagnostic.status === 'removed';
|
||||
const commandPlatform = resolveKnownAgentCommandPlatform(diagnostic.platform);
|
||||
const actionRunner = actionRunnerPresentation(diagnostic, commandPlatform, undefined, removed);
|
||||
return {
|
||||
key: `${removed ? 'removed' : 'diagnostic'}:${diagnostic.rowKey || diagnostic.id}`,
|
||||
connectionId: diagnosticConnectionID(diagnostic),
|
||||
@@ -707,10 +876,11 @@ const diagnosticOnlyDoctorTarget = (
|
||||
evidence: evidenceFor(undefined, diagnostic),
|
||||
needsUpdate: false,
|
||||
needsCredentialRepair: false,
|
||||
commandPlatform: resolveKnownAgentCommandPlatform(diagnostic.platform),
|
||||
commandPlatform,
|
||||
profileLabel: diagnostic.profileName?.trim() || diagnostic.profileId?.trim() || undefined,
|
||||
profileVersionLabel: diagnosticProfileVersionLabel(diagnostic),
|
||||
privilegeLabel: diagnosticPrivilegeLabel(diagnostic),
|
||||
...actionRunner,
|
||||
lastSeen: diagnostic.lastSeen,
|
||||
source: removed ? 'removed' : 'diagnostics',
|
||||
};
|
||||
@@ -872,6 +1042,8 @@ export const formatInfrastructureAgentDoctorReport = (
|
||||
if (lastSeen) lines.push(` Last seen ${lastSeen}`);
|
||||
if (target.updaterLabel) lines.push(` Updater ${target.updaterLabel}`);
|
||||
if (target.privilegeLabel) lines.push(` Privilege ${target.privilegeLabel}`);
|
||||
lines.push(` Safe collector ${target.safeCollector ? 'confirmed' : 'not confirmed'}`);
|
||||
for (const fact of target.actionRunnerPosture ?? []) lines.push(` Action runner ${fact}`);
|
||||
if (target.profileLabel) {
|
||||
lines.push(
|
||||
` Profile ${target.profileLabel}${
|
||||
|
||||
@@ -80,6 +80,32 @@ func TestIssueActionRunnerCredentialRejectsUnknownOrMismatchedHost(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueActionRunnerCredentialRouteRotatesExistingHostBinding(t *testing.T) {
|
||||
router, cfg, hostID := newActionRunnerCredentialTestRouter(t)
|
||||
issue := func() actionRunnerCredentialResponse {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/agents/action-runner/credential", actionRunnerCredentialBody(hostID, "HOST-1"))
|
||||
rec := httptest.NewRecorder()
|
||||
router.handleIssueActionRunnerCredential(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var response actionRunnerCredentialResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
return response
|
||||
}
|
||||
first := issue()
|
||||
second := issue()
|
||||
if first.TokenID == second.TokenID || first.Token == second.Token {
|
||||
t.Fatalf("re-enrollment did not rotate credential: first=%#v second=%#v", first, second)
|
||||
}
|
||||
if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != second.TokenID {
|
||||
t.Fatalf("route accumulated host-bound credentials: %#v", cfg.APITokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRunnerCredentialRouteAuthSupportsAdminSessionAndScopedToken(t *testing.T) {
|
||||
for _, mode := range []string{"api-token", "admin-session"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
|
||||
@@ -3,8 +3,13 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -26,9 +31,113 @@ func (r *Router) handleAgentFleetDiagnostics(w http.ResponseWriter, req *http.Re
|
||||
serverVersion = versionInfo.Version
|
||||
}
|
||||
agentUpdateTargetVersion := currentAgentTargetVersion()
|
||||
now := time.Now().UTC()
|
||||
diagnostics := monitor.GetAgentFleetDiagnosticsForTarget(serverVersion, agentUpdateTargetVersion, now)
|
||||
organizationID := GetOrgID(req.Context())
|
||||
connected := []agentexec.ConnectedAgent(nil)
|
||||
if r.agentExecServer != nil {
|
||||
connected = r.agentExecServer.GetConnectedAgentsForOrganization(organizationID)
|
||||
}
|
||||
applyAgentFleetActionRunnerState(&diagnostics, actionRunnerCredentialSnapshot(r.config), connected, organizationID, now)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(monitor.GetAgentFleetDiagnosticsForTarget(serverVersion, agentUpdateTargetVersion, time.Now().UTC())); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(diagnostics); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to serialize agent fleet diagnostics")
|
||||
}
|
||||
}
|
||||
|
||||
// applyAgentFleetActionRunnerState joins the existing organization-scoped
|
||||
// command-session inventory to the existing Agent Doctor response. Admission
|
||||
// has already bound an action runner to one canonical host identity; the
|
||||
// projection still filters the closed runtime role and capability before
|
||||
// reporting it as connected.
|
||||
func actionRunnerCredentialSnapshot(cfg *config.Config) []config.APITokenRecord {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
config.Mu.RLock()
|
||||
defer config.Mu.RUnlock()
|
||||
records := make([]config.APITokenRecord, len(cfg.APITokens))
|
||||
for i := range cfg.APITokens {
|
||||
records[i] = cfg.APITokens[i].Clone()
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func applyAgentFleetActionRunnerState(
|
||||
diagnostics *monitoring.AgentFleetDiagnostics,
|
||||
records []config.APITokenRecord,
|
||||
connected []agentexec.ConnectedAgent,
|
||||
organizationID string,
|
||||
now time.Time,
|
||||
) {
|
||||
if diagnostics == nil || len(diagnostics.Agents) == 0 {
|
||||
return
|
||||
}
|
||||
organizationID = strings.TrimSpace(organizationID)
|
||||
if organizationID == "" {
|
||||
organizationID = "default"
|
||||
}
|
||||
issued := make(map[string]config.APITokenRecord, len(records))
|
||||
for _, record := range records {
|
||||
if strings.TrimSpace(record.OrgID) != organizationID ||
|
||||
strings.TrimSpace(record.Metadata[agenttokens.CredentialKindMetadataKey]) != agenttokens.CredentialKindActionRunner {
|
||||
continue
|
||||
}
|
||||
agentID := strings.TrimSpace(record.Metadata["bound_agent_id"])
|
||||
if agentID == "" {
|
||||
continue
|
||||
}
|
||||
previous, found := issued[agentID]
|
||||
if !found || (!actionRunnerCredentialActive(previous, now) && actionRunnerCredentialActive(record, now)) {
|
||||
issued[agentID] = record
|
||||
}
|
||||
}
|
||||
runners := make(map[string]agentexec.ConnectedAgent, len(connected))
|
||||
for _, candidate := range connected {
|
||||
agentID := strings.TrimSpace(candidate.AgentID)
|
||||
if agentID == "" || strings.TrimSpace(candidate.RuntimeRole) != agentexec.RuntimeRoleActionRunner ||
|
||||
strings.TrimSpace(candidate.ActionCapability) != agentexec.ActionCapabilityTypedV1 {
|
||||
continue
|
||||
}
|
||||
runners[agentID] = candidate
|
||||
}
|
||||
for i := range diagnostics.Agents {
|
||||
agentID := strings.TrimSpace(diagnostics.Agents[i].AgentID)
|
||||
record, credentialIssued := issued[agentID]
|
||||
runner, runnerConnected := runners[agentID]
|
||||
if !credentialIssued && !runnerConnected {
|
||||
continue
|
||||
}
|
||||
if diagnostics.Agents[i].Privilege == nil {
|
||||
diagnostics.Agents[i].Privilege = &monitoring.AgentFleetDiagnosticPrivilege{}
|
||||
}
|
||||
privilege := diagnostics.Agents[i].Privilege
|
||||
if credentialIssued {
|
||||
privilege.ActionRunnerCredentialIssued = true
|
||||
privilege.ActionRunnerRuntimeRole = strings.TrimSpace(record.Metadata[agenttokens.CredentialKindMetadataKey])
|
||||
privilege.ActionRunnerCapability = strings.TrimSpace(record.Metadata[agenttokens.ActionCapabilityMetadataKey])
|
||||
privilege.ActionRunnerBindingVersion = strings.TrimSpace(record.Metadata[agenttokens.ActionBindingVersionMetadataKey])
|
||||
privilege.ActionRunnerCredentialActive = actionRunnerCredentialActive(record, now)
|
||||
}
|
||||
if runnerConnected {
|
||||
privilege.ActionRunnerConnected = true
|
||||
privilege.ActionRunnerRuntimeRole = strings.TrimSpace(runner.RuntimeRole)
|
||||
privilege.ActionRunnerCapability = strings.TrimSpace(runner.ActionCapability)
|
||||
privilege.ActionRunnerVersion = strings.TrimSpace(runner.Version)
|
||||
privilege.ActionRunnerReceiptProtocol = runner.OperationReceiptVersion
|
||||
privilege.ActionRunnerPreflightProtocol = runner.ActionPreflightVersion
|
||||
privilege.ActionRunnerDockerObservationProtocol = runner.DockerObservationVersion
|
||||
if !runner.ConnectedAt.IsZero() {
|
||||
privilege.ActionRunnerConnectedAt = runner.ConnectedAt.UTC().UnixMilli()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func actionRunnerCredentialActive(record config.APITokenRecord, now time.Time) bool {
|
||||
return record.HasScope(config.ScopeAgentExec) &&
|
||||
strings.TrimSpace(record.Metadata[agenttokens.ActionCapabilityMetadataKey]) == agenttokens.ActionCapabilityTypedV1 &&
|
||||
strings.TrimSpace(record.Metadata[agenttokens.ActionBindingVersionMetadataKey]) == agenttokens.ActionBindingVersion &&
|
||||
(record.ExpiresAt == nil || record.ExpiresAt.After(now))
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
||||
@@ -59,3 +61,58 @@ func TestHandleAgentFleetDiagnosticsReturnsFleetPayload(t *testing.T) {
|
||||
t.Fatalf("agent update target = %q, want %q", payload.AgentUpdateTargetVersion, currentAgentTargetVersion())
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAgentFleetActionRunnerStateMatchesOnlyBoundTypedRunner(t *testing.T) {
|
||||
connectedAt := time.Date(2026, 8, 29, 16, 30, 0, 0, time.UTC)
|
||||
expiredAt := connectedAt.Add(-time.Minute)
|
||||
diagnostics := monitoring.AgentFleetDiagnostics{Agents: []monitoring.AgentFleetAgentDiagnostic{
|
||||
{
|
||||
AgentID: "host-a",
|
||||
Hostname: "host-a.example",
|
||||
Privilege: &monitoring.AgentFleetDiagnosticPrivilege{
|
||||
CommandAuthority: "monitoring-only",
|
||||
TypedHelper: true,
|
||||
},
|
||||
},
|
||||
{AgentID: "host-b", Hostname: "host-b.example"},
|
||||
}}
|
||||
applyAgentFleetActionRunnerState(&diagnostics, []config.APITokenRecord{
|
||||
{
|
||||
OrgID: "default", Scopes: []string{config.ScopeAgentExec},
|
||||
Metadata: map[string]string{
|
||||
agenttokens.CredentialKindMetadataKey: agenttokens.CredentialKindActionRunner,
|
||||
agenttokens.ActionCapabilityMetadataKey: agenttokens.ActionCapabilityTypedV1,
|
||||
agenttokens.ActionBindingVersionMetadataKey: agenttokens.ActionBindingVersion,
|
||||
"bound_agent_id": "host-a",
|
||||
},
|
||||
},
|
||||
{
|
||||
OrgID: "default", Scopes: []string{config.ScopeAgentExec}, ExpiresAt: &expiredAt,
|
||||
Metadata: map[string]string{
|
||||
agenttokens.CredentialKindMetadataKey: agenttokens.CredentialKindActionRunner,
|
||||
agenttokens.ActionCapabilityMetadataKey: agenttokens.ActionCapabilityTypedV1,
|
||||
agenttokens.ActionBindingVersionMetadataKey: agenttokens.ActionBindingVersion,
|
||||
"bound_agent_id": "host-b",
|
||||
},
|
||||
},
|
||||
{OrgID: "other-org", Metadata: map[string]string{agenttokens.CredentialKindMetadataKey: agenttokens.CredentialKindActionRunner, "bound_agent_id": "host-a"}},
|
||||
}, []agentexec.ConnectedAgent{
|
||||
{AgentID: "host-a", RuntimeRole: agentexec.RuntimeRoleActionRunner, ActionCapability: agentexec.ActionCapabilityTypedV1, Version: "6.3.0-linux-amd64", ConnectedAt: connectedAt, OperationReceiptVersion: 1, ActionPreflightVersion: 2, DockerObservationVersion: 2},
|
||||
{AgentID: "host-b", RuntimeRole: agentexec.RuntimeRoleLegacyFullTrust, ActionCapability: agentexec.ActionCapabilityTypedV1, Version: "legacy"},
|
||||
{AgentID: "host-c", RuntimeRole: agentexec.RuntimeRoleActionRunner, ActionCapability: agentexec.ActionCapabilityTypedV1, Version: "other-host"},
|
||||
}, "default", connectedAt)
|
||||
|
||||
hostA := diagnostics.Agents[0].Privilege
|
||||
if hostA == nil || hostA.CommandAuthority != "monitoring-only" || !hostA.TypedHelper ||
|
||||
!hostA.ActionRunnerCredentialIssued || !hostA.ActionRunnerCredentialActive || !hostA.ActionRunnerConnected ||
|
||||
hostA.ActionRunnerRuntimeRole != agentexec.RuntimeRoleActionRunner || hostA.ActionRunnerCapability != agentexec.ActionCapabilityTypedV1 ||
|
||||
hostA.ActionRunnerBindingVersion != agenttokens.ActionBindingVersion || hostA.ActionRunnerVersion != "6.3.0-linux-amd64" ||
|
||||
hostA.ActionRunnerConnectedAt != connectedAt.UnixMilli() || hostA.ActionRunnerReceiptProtocol != 1 ||
|
||||
hostA.ActionRunnerPreflightProtocol != 2 || hostA.ActionRunnerDockerObservationProtocol != 2 {
|
||||
t.Fatalf("host-a runtime posture = %+v", hostA)
|
||||
}
|
||||
hostB := diagnostics.Agents[1].Privilege
|
||||
if hostB == nil || !hostB.ActionRunnerCredentialIssued || hostB.ActionRunnerCredentialActive || hostB.ActionRunnerConnected {
|
||||
t.Fatalf("host-b credential/session posture = %+v", hostB)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,15 @@ func ParseCommandPolicyIntent(record *config.APITokenRecord) (bool, bool) {
|
||||
}
|
||||
|
||||
func IssueAndPersist(cfg *config.Config, persistence *config.ConfigPersistence, opts IssueOptions) (string, *config.APITokenRecord, error) {
|
||||
return issueAndPersistReplacing(cfg, persistence, opts, nil)
|
||||
}
|
||||
|
||||
func issueAndPersistReplacing(
|
||||
cfg *config.Config,
|
||||
persistence *config.ConfigPersistence,
|
||||
opts IssueOptions,
|
||||
replace func(config.APITokenRecord) bool,
|
||||
) (string, *config.APITokenRecord, error) {
|
||||
if cfg == nil {
|
||||
return "", nil, fmt.Errorf("config is required")
|
||||
}
|
||||
@@ -147,7 +156,17 @@ func IssueAndPersist(cfg *config.Config, persistence *config.ConfigPersistence,
|
||||
defer config.Mu.Unlock()
|
||||
|
||||
previousTokens := append([]config.APITokenRecord(nil), cfg.APITokens...)
|
||||
cfg.APITokens = append(cfg.APITokens, *record)
|
||||
if replace == nil {
|
||||
cfg.APITokens = append(cfg.APITokens, *record)
|
||||
} else {
|
||||
nextTokens := make([]config.APITokenRecord, 0, len(cfg.APITokens)+1)
|
||||
for _, existing := range cfg.APITokens {
|
||||
if !replace(existing) {
|
||||
nextTokens = append(nextTokens, existing)
|
||||
}
|
||||
}
|
||||
cfg.APITokens = append(nextTokens, *record)
|
||||
}
|
||||
cfg.SortAPITokens()
|
||||
if persistence != nil {
|
||||
if err := persistence.SaveAPITokens(cfg.APITokens); err != nil {
|
||||
@@ -187,7 +206,7 @@ func IssueActionRunnerAndPersist(cfg *config.Config, persistence *config.ConfigP
|
||||
if tokenName == "" {
|
||||
tokenName = "action-runner:" + hostname
|
||||
}
|
||||
return IssueAndPersist(cfg, persistence, IssueOptions{
|
||||
return issueAndPersistReplacing(cfg, persistence, IssueOptions{
|
||||
TokenName: tokenName,
|
||||
OrgID: organizationID,
|
||||
OwnerUserID: opts.OwnerUserID,
|
||||
@@ -200,6 +219,10 @@ func IssueActionRunnerAndPersist(cfg *config.Config, persistence *config.ConfigP
|
||||
"bound_hostname": hostname,
|
||||
"bound_at": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
}, func(record config.APITokenRecord) bool {
|
||||
return strings.TrimSpace(record.OrgID) == organizationID &&
|
||||
strings.TrimSpace(record.Metadata[CredentialKindMetadataKey]) == CredentialKindActionRunner &&
|
||||
strings.TrimSpace(record.Metadata["bound_agent_id"]) == agentID
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,88 @@ func TestIssueActionRunnerAndPersistRejectsIncompleteBinding(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueActionRunnerAndPersistReplacesMatchingBoundCredential(t *testing.T) {
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
_, first, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{
|
||||
OrgID: "org-a", AgentID: "machine-123", Hostname: "Node.EXAMPLE",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first IssueActionRunnerAndPersist: %v", err)
|
||||
}
|
||||
_, otherHost, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{
|
||||
OrgID: "org-a", AgentID: "machine-456", Hostname: "other.example",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("other-host IssueActionRunnerAndPersist: %v", err)
|
||||
}
|
||||
_, replacement, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{
|
||||
// Canonical agent identity survives a host rename. The old binding can no
|
||||
// longer admit that host and must not remain as an orphaned secret.
|
||||
OrgID: "org-a", AgentID: "machine-123", Hostname: "renamed.example",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("replacement IssueActionRunnerAndPersist: %v", err)
|
||||
}
|
||||
if replacement.ID == first.ID {
|
||||
t.Fatalf("replacement reused token id %q", replacement.ID)
|
||||
}
|
||||
if len(cfg.APITokens) != 2 {
|
||||
t.Fatalf("token inventory = %#v, want one replacement plus other host", cfg.APITokens)
|
||||
}
|
||||
foundReplacement, foundOther, foundFirst := false, false, false
|
||||
for _, record := range cfg.APITokens {
|
||||
foundReplacement = foundReplacement || record.ID == replacement.ID
|
||||
foundOther = foundOther || record.ID == otherHost.ID
|
||||
foundFirst = foundFirst || record.ID == first.ID
|
||||
}
|
||||
if !foundReplacement || !foundOther || foundFirst {
|
||||
t.Fatalf("replacement inventory = %#v", cfg.APITokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueActionRunnerAndPersistRestoresReplacedCredentialOnPersistenceFailure(t *testing.T) {
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
_, prior, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{
|
||||
OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed action credential: %v", err)
|
||||
}
|
||||
_, unrelated, err := IssueAndPersist(cfg, nil, IssueOptions{TokenName: "collector", OrgID: "org-a"})
|
||||
if err != nil {
|
||||
t.Fatalf("seed unrelated credential: %v", err)
|
||||
}
|
||||
|
||||
statePath := filepath.Join(t.TempDir(), "blocked-state")
|
||||
persistence := config.NewConfigPersistence(statePath)
|
||||
if err := os.RemoveAll(statePath); err != nil {
|
||||
t.Fatalf("remove persistence directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(statePath, []byte("not a directory"), 0o600); err != nil {
|
||||
t.Fatalf("create persistence blocker: %v", err)
|
||||
}
|
||||
raw, replacement, err := IssueActionRunnerAndPersist(cfg, persistence, ActionRunnerIssueOptions{
|
||||
OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example",
|
||||
})
|
||||
if !errors.Is(err, ErrPersist) {
|
||||
t.Fatalf("replacement error = %v, want ErrPersist", err)
|
||||
}
|
||||
if raw != "" || replacement != nil {
|
||||
t.Fatalf("failed replacement returned credential: raw=%q record=%#v", raw, replacement)
|
||||
}
|
||||
if len(cfg.APITokens) != 2 {
|
||||
t.Fatalf("restored inventory = %#v", cfg.APITokens)
|
||||
}
|
||||
foundPrior, foundUnrelated := false, false
|
||||
for _, record := range cfg.APITokens {
|
||||
foundPrior = foundPrior || record.ID == prior.ID
|
||||
foundUnrelated = foundUnrelated || record.ID == unrelated.ID
|
||||
}
|
||||
if !foundPrior || !foundUnrelated {
|
||||
t.Fatalf("full prior inventory was not restored: %#v", cfg.APITokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueAndPersistRejectsMonitoringRoleWithExecAuthority(t *testing.T) {
|
||||
_, _, err := IssueAndPersist(&config.Config{}, nil, IssueOptions{
|
||||
TokenName: "invalid",
|
||||
|
||||
@@ -20798,7 +20798,8 @@ func TestContract_AgentFleetDiagnosticsEndpointSurfacesStableShape(t *testing.T)
|
||||
!strings.Contains(routerSrc, `RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.handleAgentFleetDiagnostics))`) {
|
||||
t.Error("agent fleet diagnostics route must remain admin settings:read only")
|
||||
}
|
||||
if !strings.Contains(handlerSrc, "GetAgentFleetDiagnosticsForTarget(serverVersion, agentUpdateTargetVersion, time.Now().UTC())") {
|
||||
if !strings.Contains(handlerSrc, "GetAgentFleetDiagnosticsForTarget(serverVersion, agentUpdateTargetVersion, now)") ||
|
||||
!strings.Contains(handlerSrc, "applyAgentFleetActionRunnerState(&diagnostics") {
|
||||
t.Error("agent fleet diagnostics handler must delegate to the monitoring-owned read-only producer")
|
||||
}
|
||||
for _, required := range []struct {
|
||||
@@ -20818,6 +20819,15 @@ func TestContract_AgentFleetDiagnosticsEndpointSurfacesStableShape(t *testing.T)
|
||||
{reflect.TypeOf(monitoring.AgentFleetAgentDiagnostic{}), "AgentModules", reflect.TypeOf([]monitoring.AgentFleetDiagnosticModule(nil)), "agentModules,omitempty"},
|
||||
{reflect.TypeOf(monitoring.AgentFleetAgentDiagnostic{}), "Reasons", reflect.TypeOf([]monitoring.AgentFleetDiagnosticReason(nil)), "reasons"},
|
||||
{reflect.TypeOf(monitoring.AgentFleetAgentDiagnostic{}), "RepairActions", reflect.TypeOf([]monitoring.AgentFleetDiagnosticRepair(nil)), "repairActions,omitempty"},
|
||||
{reflect.TypeOf(monitoring.AgentFleetDiagnosticPrivilege{}), "TypedHelper", reflect.TypeOf(false), "typedHelper,omitempty"},
|
||||
{reflect.TypeOf(monitoring.AgentFleetDiagnosticPrivilege{}), "ActionRunnerCredentialIssued", reflect.TypeOf(false), "actionRunnerCredentialIssued,omitempty"},
|
||||
{reflect.TypeOf(monitoring.AgentFleetDiagnosticPrivilege{}), "ActionRunnerCredentialActive", reflect.TypeOf(false), "actionRunnerCredentialActive,omitempty"},
|
||||
{reflect.TypeOf(monitoring.AgentFleetDiagnosticPrivilege{}), "ActionRunnerRuntimeRole", reflect.TypeOf(""), "actionRunnerRuntimeRole,omitempty"},
|
||||
{reflect.TypeOf(monitoring.AgentFleetDiagnosticPrivilege{}), "ActionRunnerCapability", reflect.TypeOf(""), "actionRunnerCapability,omitempty"},
|
||||
{reflect.TypeOf(monitoring.AgentFleetDiagnosticPrivilege{}), "ActionRunnerConnected", reflect.TypeOf(false), "actionRunnerConnected,omitempty"},
|
||||
{reflect.TypeOf(monitoring.AgentFleetDiagnosticPrivilege{}), "ActionRunnerReceiptProtocol", reflect.TypeOf(0), "actionRunnerReceiptProtocol,omitempty"},
|
||||
{reflect.TypeOf(monitoring.AgentFleetDiagnosticPrivilege{}), "ActionRunnerPreflightProtocol", reflect.TypeOf(0), "actionRunnerPreflightProtocol,omitempty"},
|
||||
{reflect.TypeOf(monitoring.AgentFleetDiagnosticPrivilege{}), "ActionRunnerDockerObservationProtocol", reflect.TypeOf(0), "actionRunnerDockerObservationProtocol,omitempty"},
|
||||
} {
|
||||
field, ok := required.typeOf.FieldByName(required.name)
|
||||
if !ok {
|
||||
|
||||
@@ -696,6 +696,36 @@ func TestActionRunnerCredentialAdmissionRejectsWrongIdentityAndUnboundOrganizati
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRunnerCredentialRotationRevokesPreviousSession(t *testing.T) {
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
firstToken, firstRecord, err := agenttokens.IssueActionRunnerAndPersist(cfg, nil, agenttokens.ActionRunnerIssueOptions{
|
||||
OrgID: "org-a", AgentID: "machine-a", Hostname: "node.example",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router := &Router{config: cfg}
|
||||
if _, ok := router.admitAgentExecToken(firstToken, "machine-a", "node.example"); !ok {
|
||||
t.Fatal("initial action runner credential was rejected")
|
||||
}
|
||||
|
||||
secondToken, secondRecord, err := agenttokens.IssueActionRunnerAndPersist(cfg, nil, agenttokens.ActionRunnerIssueOptions{
|
||||
OrgID: "org-a", AgentID: "machine-a", Hostname: "renamed.example",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if firstRecord.ID == secondRecord.ID || firstToken == secondToken || len(cfg.APITokens) != 1 {
|
||||
t.Fatalf("rotation did not replace the prior credential: %#v", cfg.APITokens)
|
||||
}
|
||||
if _, ok := router.admitAgentExecToken(firstToken, "machine-a", "node.example"); ok {
|
||||
t.Fatal("replaced action runner credential remained admissible")
|
||||
}
|
||||
if _, ok := router.admitAgentExecToken(secondToken, "machine-a", "renamed.example"); !ok {
|
||||
t.Fatal("replacement action runner credential was rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentExecTokenRejectsAmbiguousMultiOrganizationAuthority(t *testing.T) {
|
||||
rawToken := "multi-org-agent-token-123.12345678"
|
||||
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentExec}, map[string]string{
|
||||
|
||||
@@ -18,6 +18,7 @@ func collectPrivilegeStatus(commandAuthority CommandAuthorityProfile) *agentshos
|
||||
status := &agentshost.PrivilegeStatus{
|
||||
RunningAsRoot: os.Geteuid() == 0,
|
||||
CommandAuthority: string(commandAuthority),
|
||||
TypedHelper: strings.TrimSpace(os.Getenv("PULSE_AGENT_HELPER_SOCKET")) != "",
|
||||
SmartctlHelper: strings.TrimSpace(os.Getenv("PULSE_SMARTCTL_PATH")) != "",
|
||||
PctHelper: strings.TrimSpace(os.Getenv("PULSE_PCT_PATH")) != "",
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func TestCollectPrivilegeStatusReportsProcessFacts(t *testing.T) {
|
||||
t.Setenv("PULSE_AGENT_HELPER_SOCKET", "")
|
||||
t.Setenv("PULSE_SMARTCTL_PATH", "")
|
||||
t.Setenv("PULSE_PCT_PATH", "")
|
||||
|
||||
@@ -23,17 +24,18 @@ func TestCollectPrivilegeStatusReportsProcessFacts(t *testing.T) {
|
||||
if status.CommandAuthority != string(CommandAuthorityMonitoringOnly) {
|
||||
t.Fatalf("CommandAuthority = %q", status.CommandAuthority)
|
||||
}
|
||||
if status.SmartctlHelper || status.PctHelper {
|
||||
if status.TypedHelper || status.SmartctlHelper || status.PctHelper {
|
||||
t.Fatalf("helper flags set without overrides: %+v", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectPrivilegeStatusReportsHelperOverrides(t *testing.T) {
|
||||
t.Setenv("PULSE_AGENT_HELPER_SOCKET", "/run/pulse-agent/helper.sock")
|
||||
t.Setenv("PULSE_SMARTCTL_PATH", "/usr/local/lib/pulse-agent/smartctl-helper")
|
||||
t.Setenv("PULSE_PCT_PATH", "/usr/local/lib/pulse-agent/pct-helper")
|
||||
|
||||
status := collectPrivilegeStatus(CommandAuthorityCommandCapable)
|
||||
if !status.SmartctlHelper || !status.PctHelper {
|
||||
if !status.TypedHelper || !status.SmartctlHelper || !status.PctHelper {
|
||||
t.Fatalf("helper overrides not reported: %+v", status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,6 +431,7 @@ type AgentPrivilegeStatus struct {
|
||||
RunningAsRoot bool `json:"runningAsRoot"`
|
||||
ServiceUser string `json:"serviceUser,omitempty"`
|
||||
CommandAuthority string `json:"commandAuthority,omitempty"`
|
||||
TypedHelper bool `json:"typedHelper,omitempty"`
|
||||
SmartctlHelper bool `json:"smartctlHelper,omitempty"`
|
||||
PctHelper bool `json:"pctHelper,omitempty"`
|
||||
}
|
||||
|
||||
@@ -116,18 +116,31 @@ type AgentFleetDiagnosticModule struct {
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// AgentFleetDiagnosticPrivilege is the agent-reported privilege profile,
|
||||
// surfaced descriptively beside modules and update state. It is deliberately
|
||||
// not a Reason: a least-privilege install is an intentional hardening choice,
|
||||
// so it must never degrade the agent's health status by itself.
|
||||
// AgentFleetDiagnosticPrivilege combines the collector-reported privilege
|
||||
// profile with server-observed action-runner connection state. It is surfaced
|
||||
// descriptively beside modules and update state. It is deliberately not a
|
||||
// Reason: a least-privilege install or a disconnected optional runner must
|
||||
// never degrade monitoring health by itself.
|
||||
type AgentFleetDiagnosticPrivilege struct {
|
||||
RunningAsRoot bool `json:"runningAsRoot"`
|
||||
ServiceUser string `json:"serviceUser,omitempty"`
|
||||
CommandAuthority string `json:"commandAuthority,omitempty"`
|
||||
CredentialKnown bool `json:"credentialKnown,omitempty"`
|
||||
CredentialExec bool `json:"credentialExec,omitempty"`
|
||||
SmartctlHelper bool `json:"smartctlHelper,omitempty"`
|
||||
PctHelper bool `json:"pctHelper,omitempty"`
|
||||
RunningAsRoot bool `json:"runningAsRoot"`
|
||||
ServiceUser string `json:"serviceUser,omitempty"`
|
||||
CommandAuthority string `json:"commandAuthority,omitempty"`
|
||||
CredentialKnown bool `json:"credentialKnown,omitempty"`
|
||||
CredentialExec bool `json:"credentialExec,omitempty"`
|
||||
TypedHelper bool `json:"typedHelper,omitempty"`
|
||||
SmartctlHelper bool `json:"smartctlHelper,omitempty"`
|
||||
PctHelper bool `json:"pctHelper,omitempty"`
|
||||
ActionRunnerCredentialIssued bool `json:"actionRunnerCredentialIssued,omitempty"`
|
||||
ActionRunnerCredentialActive bool `json:"actionRunnerCredentialActive,omitempty"`
|
||||
ActionRunnerRuntimeRole string `json:"actionRunnerRuntimeRole,omitempty"`
|
||||
ActionRunnerCapability string `json:"actionRunnerCapability,omitempty"`
|
||||
ActionRunnerBindingVersion string `json:"actionRunnerBindingVersion,omitempty"`
|
||||
ActionRunnerConnected bool `json:"actionRunnerConnected,omitempty"`
|
||||
ActionRunnerVersion string `json:"actionRunnerVersion,omitempty"`
|
||||
ActionRunnerConnectedAt int64 `json:"actionRunnerConnectedAt,omitempty"`
|
||||
ActionRunnerReceiptProtocol int `json:"actionRunnerReceiptProtocol,omitempty"`
|
||||
ActionRunnerPreflightProtocol int `json:"actionRunnerPreflightProtocol,omitempty"`
|
||||
ActionRunnerDockerObservationProtocol int `json:"actionRunnerDockerObservationProtocol,omitempty"`
|
||||
}
|
||||
|
||||
type AgentFleetDiagnosticReason struct {
|
||||
@@ -964,6 +977,7 @@ func agentFleetPrivilegeForSubject(subject agentFleetSubject, inventory agentFle
|
||||
RunningAsRoot: privilege.RunningAsRoot,
|
||||
ServiceUser: strings.TrimSpace(privilege.ServiceUser),
|
||||
CommandAuthority: strings.TrimSpace(privilege.CommandAuthority),
|
||||
TypedHelper: privilege.TypedHelper,
|
||||
SmartctlHelper: privilege.SmartctlHelper,
|
||||
PctHelper: privilege.PctHelper,
|
||||
}
|
||||
|
||||
@@ -442,6 +442,7 @@ func TestAgentFleetDiagnosticsSurfacesPrivilegeProfileWithoutDegradingHealth(t *
|
||||
RunningAsRoot: false,
|
||||
ServiceUser: "pulse-agent",
|
||||
CommandAuthority: "monitoring-only",
|
||||
TypedHelper: true,
|
||||
SmartctlHelper: true,
|
||||
PctHelper: false,
|
||||
},
|
||||
@@ -455,6 +456,7 @@ func TestAgentFleetDiagnosticsSurfacesPrivilegeProfileWithoutDegradingHealth(t *
|
||||
}
|
||||
if agent.Privilege.RunningAsRoot || agent.Privilege.ServiceUser != "pulse-agent" ||
|
||||
agent.Privilege.CommandAuthority != "monitoring-only" ||
|
||||
!agent.Privilege.TypedHelper ||
|
||||
!agent.Privilege.SmartctlHelper || agent.Privilege.PctHelper {
|
||||
t.Fatalf("privilege profile = %+v", agent.Privilege)
|
||||
}
|
||||
|
||||
@@ -3469,6 +3469,7 @@ func convertAgentPrivilegeStatus(value *agentshost.PrivilegeStatus) *models.Agen
|
||||
RunningAsRoot: value.RunningAsRoot,
|
||||
ServiceUser: strings.TrimSpace(value.ServiceUser),
|
||||
CommandAuthority: strings.TrimSpace(value.CommandAuthority),
|
||||
TypedHelper: value.TypedHelper,
|
||||
SmartctlHelper: value.SmartctlHelper,
|
||||
PctHelper: value.PctHelper,
|
||||
}
|
||||
|
||||
@@ -5488,6 +5488,7 @@ func TestApplyHostReportCarriesAgentPrivilegeProfile(t *testing.T) {
|
||||
Privilege: &agentshost.PrivilegeStatus{
|
||||
RunningAsRoot: false,
|
||||
ServiceUser: " pulse-agent ",
|
||||
TypedHelper: true,
|
||||
SmartctlHelper: true,
|
||||
},
|
||||
},
|
||||
@@ -5503,6 +5504,7 @@ func TestApplyHostReportCarriesAgentPrivilegeProfile(t *testing.T) {
|
||||
if host.AgentPrivilege == nil ||
|
||||
host.AgentPrivilege.RunningAsRoot ||
|
||||
host.AgentPrivilege.ServiceUser != "pulse-agent" ||
|
||||
!host.AgentPrivilege.TypedHelper ||
|
||||
!host.AgentPrivilege.SmartctlHelper ||
|
||||
host.AgentPrivilege.PctHelper {
|
||||
t.Fatalf("ingested privilege = %+v", host.AgentPrivilege)
|
||||
|
||||
@@ -179,6 +179,10 @@ type PrivilegeStatus struct {
|
||||
RunningAsRoot bool `json:"runningAsRoot"`
|
||||
ServiceUser string `json:"serviceUser,omitempty"`
|
||||
CommandAuthority string `json:"commandAuthority,omitempty"`
|
||||
// TypedHelper reports that the collector is configured to use the local
|
||||
// no-network privilege helper socket. It reports posture only and grants no
|
||||
// authority by itself.
|
||||
TypedHelper bool `json:"typedHelper,omitempty"`
|
||||
// SmartctlHelper reports that a PULSE_SMARTCTL_PATH override is in effect,
|
||||
// which a least-privilege install uses to route SMART reads through a
|
||||
// scoped privilege helper.
|
||||
|
||||
@@ -136,6 +136,7 @@ func TestAgentInfoPrivilegeStatusRoundTrip(t *testing.T) {
|
||||
RunningAsRoot: false,
|
||||
ServiceUser: "pulse-agent",
|
||||
CommandAuthority: "monitoring-only",
|
||||
TypedHelper: true,
|
||||
SmartctlHelper: true,
|
||||
PctHelper: false,
|
||||
},
|
||||
@@ -152,6 +153,7 @@ func TestAgentInfoPrivilegeStatusRoundTrip(t *testing.T) {
|
||||
decoded.Privilege.RunningAsRoot ||
|
||||
decoded.Privilege.ServiceUser != "pulse-agent" ||
|
||||
decoded.Privilege.CommandAuthority != "monitoring-only" ||
|
||||
!decoded.Privilege.TypedHelper ||
|
||||
!decoded.Privilege.SmartctlHelper ||
|
||||
decoded.Privilege.PctHelper {
|
||||
t.Fatalf("privilege round trip = %+v", decoded.Privilege)
|
||||
|
||||
Reference in New Issue
Block a user