Harden least-privilege installer lifecycle state

This commit is contained in:
rcourtman
2026-09-01 15:44:30 +01:00
parent b1240c6ca3
commit 53267e149d
27 changed files with 2164 additions and 293 deletions
+59 -1
View File
@@ -6,6 +6,8 @@ import (
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -17,10 +19,15 @@ const (
collectorReduceAuthorityCommand = "collector-reduce-authority"
collectorVerifyRegistrationCommand = "collector-verify-registration"
collectorReadAgentIDCommand = "collector-read-agent-id"
collectorReadTokenCommand = "collector-read-token"
collectorDownloadInstallerCommand = "collector-download-installer"
collectorUninstallCommand = "collector-uninstall"
)
func isCollectorLifecycleCommand(command string) bool {
return command == collectorReduceAuthorityCommand || command == collectorVerifyRegistrationCommand || command == collectorReadAgentIDCommand
return command == collectorReduceAuthorityCommand || command == collectorVerifyRegistrationCommand ||
command == collectorReadAgentIDCommand || command == collectorReadTokenCommand ||
command == collectorDownloadInstallerCommand || command == collectorUninstallCommand
}
func runCollectorLifecycleCommand(ctx context.Context, command string, args []string, stdout, stderr io.Writer) error {
@@ -35,6 +42,7 @@ func runCollectorLifecycleCommand(ctx context.Context, command string, args []st
caFile := flags.String("cacert", "", "custom CA certificate bundle")
serverFingerprint := flags.String("server-fingerprint", "", "exact Pulse server leaf certificate SHA-256 fingerprint")
previousLastSeen := flags.String("previous-last-seen", "", "registration timestamp that the replacement must advance")
outputPath := flags.String("output", "", "pre-created private output file for a public lifecycle artifact")
if err := flags.Parse(args); err != nil {
return err
}
@@ -60,6 +68,49 @@ func runCollectorLifecycleCommand(ctx context.Context, command string, args []st
_, err = fmt.Fprintln(stdout, identity)
return err
}
if command == collectorReadTokenCommand {
if strings.TrimSpace(*tokenFile) == "" {
return errors.New("collector-read-token requires --token-file")
}
bearer, err := collectorlifecycle.ReadPrivateValueFile(*tokenFile, allowedTokenOwnerUID)
if err != nil {
return err
}
_, err = fmt.Fprintln(stdout, bearer)
return err
}
if command == collectorDownloadInstallerCommand {
if strings.TrimSpace(*pulseURL) == "" || !filepath.IsAbs(strings.TrimSpace(*outputPath)) {
return errors.New("collector-download-installer requires --url and an absolute --output path")
}
info, err := os.Lstat(*outputPath)
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0077 != 0 {
return errors.New("collector-download-installer output must be a pre-created private regular file")
}
requestCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
installer, signature, err := collectorlifecycle.DownloadInstaller(requestCtx, collectorlifecycle.PublicConfig{
PulseURL: *pulseURL, CACertPath: *caFile, ServerFingerprint: *serverFingerprint,
})
if err != nil {
return err
}
output, err := os.OpenFile(*outputPath, os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("open installer output: %w", err)
}
if _, err = output.Write(installer); err == nil {
err = output.Sync()
}
if closeErr := output.Close(); err == nil {
err = closeErr
}
if err != nil {
return fmt.Errorf("persist installer output: %w", err)
}
_, err = fmt.Fprintln(stdout, signature)
return err
}
if strings.TrimSpace(*pulseURL) == "" || strings.TrimSpace(*tokenFile) == "" {
return errors.New("collector lifecycle network command requires --url and --token-file")
}
@@ -97,6 +148,13 @@ func runCollectorLifecycleCommand(ctx context.Context, command string, args []st
}
_, err = fmt.Fprintln(stdout, registration.LastSeen.Format(time.RFC3339Nano))
return err
case collectorUninstallCommand:
removedAgentID, err := client.Uninstall(requestCtx, *agentID, *hostname)
if err != nil {
return err
}
_, err = fmt.Fprintln(stdout, removedAgentID)
return err
default:
return fmt.Errorf("unknown collector lifecycle command %q", command)
}
@@ -95,6 +95,50 @@ func TestCollectorLifecycleCommandSafelyReadsAgentIdentity(t *testing.T) {
}
}
func TestCollectorLifecycleCommandSafelyReadsCollectorToken(t *testing.T) {
tokenFile := writeCollectorLifecycleToken(t, "collector-file-bound")
var stdout, stderr bytes.Buffer
err := runCollectorLifecycleCommand(context.Background(), collectorReadTokenCommand, []string{
"--token-file", tokenFile,
"--token-owner-uid", collectorLifecycleTestOwnerUID(),
}, &stdout, &stderr)
if err != nil {
t.Fatalf("runCollectorLifecycleCommand: %v (stderr %q)", err, stderr.String())
}
if got := strings.TrimSpace(stdout.String()); got != "collector-file-bound" {
t.Fatalf("stdout = %q", got)
}
}
func TestCollectorLifecycleCommandDownloadsInstallerThroughPublicTransport(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/install.sh" || request.Header.Get("Authorization") != "" {
t.Errorf("request path=%q authorization=%q", request.URL.Path, request.Header.Get("Authorization"))
}
w.Header().Set("X-Signature-SSHSIG", "installer-signature")
_, _ = w.Write([]byte("#!/usr/bin/env bash\necho secure\n"))
}))
defer server.Close()
outputPath := filepath.Join(t.TempDir(), "installer.tmp")
if err := os.WriteFile(outputPath, nil, 0600); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
err := runCollectorLifecycleCommand(context.Background(), collectorDownloadInstallerCommand, []string{
"--url", server.URL,
"--output", outputPath,
}, &stdout, &stderr)
if err != nil {
t.Fatalf("runCollectorLifecycleCommand: %v (stderr %q)", err, stderr.String())
}
if got := strings.TrimSpace(stdout.String()); got != "installer-signature" {
t.Fatalf("signature stdout = %q", got)
}
if body, err := os.ReadFile(outputPath); err != nil || string(body) != "#!/usr/bin/env bash\necho secure\n" {
t.Fatalf("installer body=%q err=%v", body, err)
}
}
func TestCollectorLifecycleCommandExitCodeDistinguishesRejectedCredential(t *testing.T) {
if got := collectorLifecycleExitCode(nil); got != 0 {
t.Fatalf("nil exit code = %d", got)
+16
View File
@@ -230,6 +230,22 @@ provider hosts, container-runtime parity, appliance qualification, exact release
artifacts, and external review are still required before the profile can become
the general default.
Least-privilege installs keep mutable telemetry state under the collector
account, but keep installer lifecycle authority separate under
`/etc/pulse-agent`: the saved connection record is root-owned and private, and
the offline installer is root-owned, non-writable by the collector, and checked
against its adjacent root-owned SHA-256 record before it can recover uninstall
state. Collector-owned `agent-id` input is read through a bounded no-follow
descriptor path. Uninstall only recursively removes a state directory when an
explicit, platform-selected, or protected lifecycle record authorizes that
exact path; otherwise it leaves the directory for manual repair.
For a streamed install, the offline copy is downloaded only by the root-trusted
lifecycle client using the configured CA or exact certificate fingerprint,
with environment proxies and redirects disabled, and is persisted only after
its SSH signature passes the embedded release-key check. Credential-bearing
uninstall uses that same authenticated transport and keeps the local service,
credential, and recovery state when the server cannot durably confirm removal.
Exact-release qualification authenticates before it executes. The workflow
copies the six candidate binaries, four collector signatures, checksum
manifest, assembly and compiler provenance, and build contract into a private
@@ -954,15 +954,22 @@ update, profile rollout, command reachability, or fleet-control authority.
restart or OS reboot persistence, and complete uninstall cleanup through
the reusable lifecycle harness under `scripts/installtests/`.
31. `scripts/install.sh` shared with `deployment-installability`: the shell installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary.
`--state-dir` is a whole-lifecycle ownership boundary, not only a runtime
flag. The resolved directory owns the protected bootstrap token,
enrollment runtime token, server-acknowledged `agent-id`, buffered and
command-receipt state, `connection.env`, and the saved offline installer.
Generated service definitions must carry that same directory and its token
file through install, process restart, server restart, update,
re-enrollment, and uninstall. Explicit state wins over discovered service
state, which wins over platform defaults; a custom instance must never
borrow token or identity files from the default instance. A changed
`--state-dir` is the canonical runtime-state boundary, not an authority for
later root lifecycle work. The resolved directory owns the bootstrap and
enrollment runtime token, server-acknowledged `agent-id`, buffered state,
and command receipts. On a least-privilege install, root-owned
`connection.env`, the saved offline installer, and its SHA-256 integrity
record live separately under the installer lifecycle directory; the
collector-owned runtime directory must not contain executable or
root-trusted recovery artifacts. Generated service definitions must carry
the canonical runtime directory and token file through install, process
restart, server restart, update, and re-enrollment. Explicit state wins
over protected lifecycle recovery, which wins over discovered service state
and platform defaults; a custom instance must never borrow token or
identity files from the default instance. Recursive uninstall cleanup is
authorized only by an exact explicit/default/platform path or a protected
lifecycle record, never by process arguments or a collector-writable file.
A changed
bootstrap token may clear the old enrollment runtime token to express
re-enrollment, while an unchanged token and tokenless update must preserve
it. Default platform paths remain valid migration inputs for installations
@@ -5989,6 +5996,35 @@ installer-owned helper path: `scripts/install.sh` may not write the state file
one way and then recover it through a separate field-by-field inline parser,
because lifecycle ownership requires one canonical reader/writer for persisted
install identity and trust metadata.
On least-privilege profiles, that canonical path is a root-owned private file
under the installer lifecycle directory, alongside a root-owned mode-0700
offline installer and mode-0600 checksum record. Recovery must reject a
symlink, a file not owned by the lifecycle process, an over-permissive file, or
a group/other-writable parent. Executing the saved installer must verify its
checksum before accepting any persisted lifecycle state. Runtime `agent-id`
recovery must use the descriptor-bound, no-follow, bounded lifecycle reader so
collector-controlled symlinks, FIFOs, devices, and oversized files cannot make
the root installer disclose a file or block. Safe-profile migration rollback
must snapshot and restore all three root lifecycle artifacts with the local
profile transaction.
When an install was streamed on stdin, the offline copy may be persisted only
after the installed root-owned lifecycle binary downloads it over the canonical
system-CA/custom-CA/exact-leaf-pin transport with proxies and redirects denied,
and the shell installer verifies the returned SSH signature against its
embedded release key. Generic insecure curl plus a locally generated checksum
is not source authentication; when authenticated bytes are unavailable the
installer omits the offline copy and tells the operator to fetch a fresh one.
Collector token and identity recovery from mutable state must use the bounded
descriptor reader through a root-trusted lifecycle binary. A legacy
least-privilege binary that is collector-owned is never executed as root; an
early tokenless migration without a trusted reader fails closed and requires a
fresh scoped credential rather than falling back to `cat`.
Generated Unix token-file commands must create their bootstrap credential only
after root or sudo elevation, inside a root-owned mode-0700 directory with a
mode-0600 token file. Frontend host commands and backend Proxmox commands must
preserve that shape and remove the complete bootstrap directory on every exit;
an invoking-user-owned `mktemp` file is not a trusted input to the root
installer on a fresh host.
When persisted state is absent or partial during update, legacy running-process
or service-unit recovery is a fallback into that same lifecycle continuity
model, not a separate source of truth: it may only seed the installer-owned
@@ -6009,6 +6045,33 @@ That same rule applies to teardown: uninstall and reinstall cleanup may not
rebuild disable/remove flows inline per platform. Shared installer helpers
must own service stop/disable/remove semantics for systemd, OpenRC, SysV, and
service-command runtimes so lifecycle cleanup stays canonical.
The shared state-directory remover must additionally require the requested
path to match the exact removal authority established by an explicit option,
platform selection, or protected lifecycle record. Indeterminate or untrusted
recovery retains the runtime directory for manual repair rather than invoking
recursive removal on an attacker-selected path.
When a local collector credential exists, uninstall must first resolve and
durably remove the exact bearer-bound server record through the same
CA/fingerprint, no-proxy, redirect-denying lifecycle client. TLS failure,
invalid confirmation, or server unavailability retains the local service,
credential, and recovery state; curl `-k` lookup/unregister is never rollback
or deletion authority.
Server confirmation requires the exact host/token transaction to persist both
the host-removal tombstone and any dedicated token revocation before live
teardown. Either persistence failure returns non-success with the live host and
retry bearer retained; after success, restart must preserve the removed host
state and reject the old bearer.
The server also refuses teardown-authorizing success when a legacy collector
token still belongs to another live host; that credential must first be split
or rotated. A crash or response loss after both durable writes is fail-closed
in the other direction: the server remains removed and the old bearer remains
rejected, while the installer keeps local service and recovery state because
it never observed confirmation. Recovery is therefore an operator-verified
local-only cleanup: confirm the exact agent ID is absent through an
administrator session, stop the retained service, quarantine its collector
credential files out of the installer discovery paths, and rerun the protected
saved installer for local removal. A rejected old bearer alone is never
automatic deletion authority.
The same lifecycle rule applies to TrueNAS bootstrap too: boot-time recovery
for SCALE and CORE may only vary at the service-manager adapter, while binary
sync, service-link recreation, and startup sequencing stay on one
@@ -7565,6 +7565,13 @@ The unified-agent uninstall command contract must also fail closed on
token-required Pulse instances: copied shell and PowerShell uninstall payloads
must use the same resolved token source as install and upgrade, so required
auth cannot silently collapse into tokenless deregistration transport.
The collector self-uninstall API response is teardown authority only after the
exact host/token transaction has durably persisted the host-removal tombstone
and revoked a dedicated bearer. Either persistence failure returns a
non-success response without changing live state; a token still shared by
another live host returns conflict until it is split or rotated. A successful
response names the exact removed agent ID, and restart must preserve removal
and old-secret rejection.
Agent profile assignment payloads now also fail closed on missing profiles:
`POST /api/admin/profiles/assignments` must reject unknown `profile_id`
references with the canonical not-found response instead of writing orphan
@@ -7680,6 +7687,12 @@ download the shared installer into an ephemeral directory, run
`/download/pulse-agent?arch=...` is reachable with checksum metadata, and
pass selected tokens to the installer through an ephemeral `--token-file`
instead of a raw `--token` service argument.
The token file itself must be created only after the selected root or sudo
branch begins, inside a root-owned mode-0700 bootstrap directory and with mode
0600, then the whole directory must be removed on every exit. The frontend
host-command builder and backend Proxmox-command builder must preserve this
same executable contract so a fresh root installer never has to trust a token
file or parent directory owned by the invoking user.
`/download/pulse-agent` serves only an agent binary carrying this server's own
agent version. A local artifact that satisfies the report-contract and
signature checks but predates the running server is refused the same way an
@@ -713,9 +713,19 @@ artifact-selection behaviour.
must discover it from the active process or managed service before looking
at default-path state; explicit custom-path operations must not fall back
to another default instance. `connection.env` records the canonical state
and token-file paths without storing the token value, update rewrites the
same secure service shape, and uninstall removes the discovered canonical
directory rather than only `/var/lib/pulse-agent`.
and token-file paths without storing the token value. On least-privilege
installs that file, the saved offline installer, and its integrity record
live under a separate root-owned installer lifecycle directory rather than
the collector-writable runtime state directory. Update rewrites the same
secure service shape, and uninstall removes the discovered canonical
directory rather than only `/var/lib/pulse-agent`, but recursive removal is
permitted only when an explicit/default/platform selection or protected
lifecycle record grants exact authority for that path.
Token-bearing generated Unix commands must create the ephemeral token file
after root or sudo elevation under a root-owned mode-0700 bootstrap
directory, set the token file to mode 0600, and remove the directory on
every exit. A fresh install must not depend on executing an already-installed
lifecycle binary to trust an invoking-user-owned temporary token file.
Post-install verification must not declare server registration
unconfirmed from a single lookup: the local `/readyz` gate flips before the
agent's first report cycle completes, so the installer polls the server
@@ -4339,6 +4349,25 @@ writer/reader path: `scripts/install.sh` may not keep a heredoc writer plus a
second inline field parser for the same `connection.env` contract, because
offline uninstall must consume the same persisted install-state artifact the
installer wrote instead of reconstructing it ad hoc.
For a least-privilege service, installer ownership is physical as well as
logical: `connection.env`, the mode-0700 saved installer, and its mode-0600
SHA-256 record must be atomic root-owned files outside the collector-writable
state tree. The saved installer verifies that integrity record before loading
state. Connection recovery rejects symlinks, unexpected owners or modes, and
group/other-writable parents. Canonical `agent-id` recovery from mutable runtime
state must use the single-open, no-follow, nonblocking, bounded lifecycle
reader and reject FIFOs, devices, symlinks, and oversized content. Safe-profile
rollback snapshots and restores the root lifecycle artifacts, while successful
migration removes their stale collector-state copies.
For stdin installs, the saved installer source must come through the
root-trusted lifecycle binary's system-CA/custom-CA/exact-fingerprint transport
with proxies and redirects disabled, then pass the embedded-key SSH signature
check before it is installed. A curl `-k` response and a checksum generated from
that same response do not authenticate source bytes; absent authenticated bytes
means no offline copy is saved. Mutable-state token recovery uses the same
descriptor-safe reader. If only a collector-owned legacy binary exists, a
tokenless least-privilege upgrade fails closed and requests a fresh credential
instead of executing that binary as root.
That same shell-agent update recovery path must fail closed on partial
legacy process or service-unit state: a recovered URL without a recovered token
is not usable connection state and must not be logged or treated as recovered.
@@ -4361,6 +4390,16 @@ re-author stop, disable, remove, and daemon-reload sequences inline.
`scripts/install.sh` must route service teardown through shared installer
helpers so removal semantics stay consistent across systemd, OpenRC, SysV,
and service-command runtimes.
The shared recursive state remover must fail closed unless its target exactly
matches the path authorized by explicit input, platform selection, or protected
lifecycle recovery. A process-derived or collector-writable path may help find
a running service but cannot authorize root deletion; uncertain state is
retained with a repair warning.
When a collector credential remains locally, uninstall must also receive an
authenticated exact-agent success response from the canonical no-proxy,
redirect-denying CA/fingerprint lifecycle client before deleting services,
credentials, or recovery state. An unreachable or untrusted server is a
repair-required uninstall, not permission for local-only credential loss.
TrueNAS boot recovery must follow the same rule: SCALE and CORE bootstrap
scripts may differ only in their service-manager adapter, while binary sync,
service-link recreation, and boot-time start flow stay on one installer-owned
@@ -3565,6 +3565,23 @@ fails, but the token stays consistently active instead of disappearing only
from the live process and silently returning after restart. Success and
forced-write-failure coverage lives in
`internal/monitoring/monitor_host_agent_removal_lifecycle_test.go`.
Collector self-uninstall is stricter than operator removal: while holding the
host lifecycle write lock it verifies the exact live host/token binding,
persists the removal tombstone, and durably revokes a dedicated credential
before changing live resource state. Failure to load or write continuity, an
unavailable credential persister, or failure to persist the reduced token
inventory returns an error and retains the live host and retry credential; a
shared legacy token remains active only for its other live resources. The
production Router regression in
`internal/api/host_agent_removal_lifecycle_integration_test.go` forces both
continuity-journal and credential-inventory writes to fail, restarts the
server, retries with the exact bearer, and then proves removal plus old-secret
rejection survive a second restart.
Collector self-uninstall refuses a token that is still referenced by another
live host. That legacy shared authority must be rotated or separated before
the server can return teardown-authorizing success; preserving the bearer for
the other host is not equivalent to revoking the uninstalling collector's
credential.
### Escalation callbacks preserve exact routing intent
@@ -1413,7 +1413,7 @@ recovery scope, or a storage/recovery-owned secret source.
transport flows must not reintroduce local marker trust or token rotation
when the canonical auto-register helper can verify whether Pulse still has
a matching node.
13. Preserve the governed root-or-sudo Unix wrapper in shared backend install-command helpers so storage- and recovery-adjacent transport surfaces do not inherit a stale raw `| bash -s --` install payload shape from the canonical agent-install-command API and hosted Proxmox install responses.
13. Preserve the governed root-or-sudo Unix wrapper in shared backend install-command helpers so storage- and recovery-adjacent transport surfaces do not inherit a stale raw `| bash -s --` install payload shape from the canonical agent-install-command API and hosted Proxmox install responses. Token-bearing commands create the token only inside a root-owned mode-0700 bootstrap directory after elevation, install it at mode 0600, and remove the directory on exit; an invoking-user-owned temporary file is not valid root-installer credential state.
14. Preserve optional-auth tokenless behavior in those same shared backend install-command helpers so adjacent transport surfaces do not implicitly persist API tokens and flip auth-configured state when an operator only requested a Proxmox install command on a token-optional Pulse instance.
15. Preserve backend-owned Pulse Mobile relay runtime credential minting in those same shared `internal/api/` auth/security helpers so storage- and recovery-adjacent transport surfaces do not inherit browser-authored wildcard token bundles when they depend on the canonical security helper layer.
16. Preserve the dedicated backend-owned `relay:mobile:access` capability and its governed backward-compatible route inventory plus the shared helper call sites around it, so storage- and recovery-adjacent transport surfaces do not treat the mobile relay credential as a general AI scope bundle.
+12 -20
View File
@@ -1,22 +1,16 @@
{
"version": 1,
"base_sha": "f12c4007ab3082bed1211b1744d0b1fec284a635",
"verified_at": "2026-09-01T02:51:11Z",
"base_sha": "b1240c6ca3b56bfa48317c5e1013eac150496b14",
"verified_at": "2026-09-01T14:51:26Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/AppLayout.tsx",
"frontend-modern/src/components/shared/MobileNavBar.tsx",
"frontend-modern/src/features/home/homePageModel.ts",
"frontend-modern/src/routing/resourceLinks.ts"
"frontend-modern/src/utils/agentInstallCommand.ts"
],
"content_sha256": {
"frontend-modern/src/AppLayout.tsx": "2d54ea2114174d1a08ca6fdbfb7063d321ebcca87cfc402e7ce923e1a499ed15",
"frontend-modern/src/components/shared/MobileNavBar.tsx": "a327ba73d7f25d8c557242b946a8bc390355a894fc7de284dcca90c6af562fca",
"frontend-modern/src/features/home/homePageModel.ts": "752b80838cf993855c7e236bdea804ecbffcaab3b70a1bf2ff5bfbbd86be38fb",
"frontend-modern/src/routing/resourceLinks.ts": "802a806a933f38a4dd57d766319c79d1507eed9e1f4c33b294e8e8ebfca97b4c"
"frontend-modern/src/utils/agentInstallCommand.ts": "5c9157c99245a5a202d4383f31c19e0115db7b488e1d920541b7d541aba5eb06"
},
"routes": [
"/home"
"/settings/infrastructure"
],
"viewports": [
{
@@ -29,16 +23,14 @@
}
],
"states": [
"mixed critical, attention, stale, powered-off, and healthy fleet state",
"healthy resource group expanded from its disclosure limit",
"last loaded fleet retained after a refresh returned an error",
"Docker container and standalone agent tiles targeted scoped investigation views",
"desktop and mobile navigation count badges met the WCAG color-contrast scan"
"Add Pulse Agent flow after a fresh local install token was generated",
"default Linux install command with root and sudo elevation branches",
"desktop and narrow command layouts with no observed horizontal page overflow"
],
"interactions": [
"expanded the healthy resource disclosure by pointer",
"triggered a resource refresh failure and verified the cached-state warning",
"verified WCAG A and AA scans, reduced motion, and no horizontal overflow",
"verified scoped resource link hrefs at desktop width"
"opened Add Pulse Agent from Settings > Infrastructure",
"generated a fresh local agent token and dismissed the one-time token dialog",
"copied the generated install command at desktop and narrow widths",
"verified the copied command uses a private /tmp/pulse-agent-bootstrap.XXXXXX directory, --token-file, preflight, and sudo fallback"
]
}
@@ -230,6 +230,22 @@ provider hosts, container-runtime parity, appliance qualification, exact release
artifacts, and external review are still required before the profile can become
the general default.
Least-privilege installs keep mutable telemetry state under the collector
account, but keep installer lifecycle authority separate under
`/etc/pulse-agent`: the saved connection record is root-owned and private, and
the offline installer is root-owned, non-writable by the collector, and checked
against its adjacent root-owned SHA-256 record before it can recover uninstall
state. Collector-owned `agent-id` input is read through a bounded no-follow
descriptor path. Uninstall only recursively removes a state directory when an
explicit, platform-selected, or protected lifecycle record authorizes that
exact path; otherwise it leaves the directory for manual repair.
For a streamed install, the offline copy is downloaded only by the root-trusted
lifecycle client using the configured CA or exact certificate fingerprint,
with environment proxies and redirects disabled, and is persisted only after
its SSH signature passes the embedded release-key check. Credential-bearing
uninstall uses that same authenticated transport and keeps the local service,
credential, and recovery state when the server cannot durably confirm removal.
Exact-release qualification authenticates before it executes. The workflow
copies the six candidate binaries, four collector signatures, checksum
manifest, assembly and compiler provenance, and build contract into a private
@@ -1,3 +1,7 @@
import { execFileSync } from 'node:child_process';
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
buildUnixAgentInstallCommand,
@@ -15,7 +19,9 @@ describe('agentInstallCommand', () => {
});
expect(command).toContain("--url 'http://pulse.example:7655'");
expect(command).toContain('printf %s \'token-123\' > "$token_file"');
expect(command).toContain('token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)');
expect(command).toContain('token_dir=$(sudo mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)');
expect(command).toContain('printf %s \'token-123\' | sudo tee "$token_file" >/dev/null');
expect(command).toContain('--token-file "$token_file"');
expect(command).toContain('--insecure');
});
@@ -31,6 +37,7 @@ describe('agentInstallCommand', () => {
);
expect(command).toContain("--url 'https://pulse.example/base path/agent'\"'\"'s'");
expect(command).toContain("printf %s 'tok'\"'\"'en' > \"$token_file\"");
expect(command).toContain("printf %s 'tok'\"'\"'en' | sudo tee \"$token_file\" >/dev/null");
expect(command).toContain('--token-file "$token_file"');
expect(command).not.toContain("--token 'tok");
});
@@ -45,12 +52,98 @@ describe('agentInstallCommand', () => {
const sudoIndex = command.indexOf('sudo bash "$install_script"');
expect(command).toContain('tmp_dir=$(mktemp -d)');
expect(command).toContain('trap \'rm -rf "$tmp_dir"\' EXIT');
expect(command).toContain('trap cleanup EXIT HUP INT TERM');
expect(command).toContain('bash "$install_script" --url');
expect(command).toContain('--output json');
expect(command).toContain('--non-interactive');
expect(preflightIndex).toBeGreaterThan(-1);
expect(sudoIndex).toBeGreaterThan(preflightIndex);
expect(
command.indexOf('token_dir=$(sudo mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)'),
).toBeGreaterThan(preflightIndex);
});
it('executes root and sudo token bootstraps from trusted private directories', () => {
if (process.platform === 'win32') return;
const fixtureDir = mkdtempSync(join(tmpdir(), 'pulse-install-command-'));
try {
const binDir = join(fixtureDir, 'bin');
const installer = join(fixtureDir, 'installer.sh');
const capture = join(fixtureDir, 'captured-token');
execFileSync('mkdir', ['-p', binDir]);
writeFileSync(
installer,
`#!/usr/bin/env bash
set -e
token_file=""
while [ "$#" -gt 0 ]; do
case "$1" in
--preflight-only) exit 0 ;;
--token-file) token_file="$2"; shift 2 ;;
*) shift ;;
esac
done
[ -n "$token_file" ]
[ "$(stat -c %a "$token_file" 2>/dev/null || stat -f %Lp "$token_file")" = "600" ]
parent_dir=$(dirname "$token_file")
[ "$(stat -c %a "$parent_dir" 2>/dev/null || stat -f %Lp "$parent_dir")" = "700" ]
[ "$(stat -c %u "$token_file" 2>/dev/null || stat -f %u "$token_file")" = "$(stat -c %u "$parent_dir" 2>/dev/null || stat -f %u "$parent_dir")" ]
cat "$token_file" > "$FAKE_CAPTURE"
`,
);
chmodSync(installer, 0o700);
writeFileSync(
join(binDir, 'curl'),
`#!/bin/sh
output=""
while [ "$#" -gt 0 ]; do
case "$1" in
-o) output="$2"; shift 2 ;;
*) shift ;;
esac
done
cp "$FAKE_INSTALLER" "$output"
`,
);
writeFileSync(
join(binDir, 'sudo'),
`#!/bin/sh
exec "$@"
`,
);
writeFileSync(
join(binDir, 'id'),
`#!/bin/sh
if [ "$1" = "-u" ]; then
printf '%s\n' "$FAKE_ID_UID"
else
exec /usr/bin/id "$@"
fi
`,
);
for (const name of ['curl', 'sudo', 'id']) chmodSync(join(binDir, name), 0o700);
const command = buildUnixAgentInstallCommand({
baseUrl: 'https://pulse.example',
token: 'token-123',
});
for (const fakeUID of ['0', '1000']) {
rmSync(capture, { force: true });
execFileSync('bash', ['-c', command], {
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH || ''}`,
FAKE_CAPTURE: capture,
FAKE_ID_UID: fakeUID,
FAKE_INSTALLER: installer,
},
});
expect(readFileSync(capture, 'utf8')).toBe('token-123');
}
} finally {
rmSync(fixtureDir, { recursive: true, force: true });
}
});
it('normalizes trailing slashes before building installer transport', () => {
@@ -164,26 +164,44 @@ export const buildUnixAgentInstallCommand = ({
...normalizedExtraArgs,
'--non-interactive',
].join(' \\\n ');
const rootTokenSetup = normalizedToken
? ` token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)
token_file="$token_dir/token"
umask 077
printf %s ${shellQuoteArg(normalizedToken)} > "$token_file"
`
: '';
const sudoTokenSetup = normalizedToken
? ` token_dir=$(sudo mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)
token_file="$token_dir/token"
printf %s ${shellQuoteArg(normalizedToken)} | sudo tee "$token_file" >/dev/null
sudo chmod 0600 "$token_file"
`
: '';
return `(
set -e
tmp_dir=$(mktemp -d)
token_dir=""
install_script="$tmp_dir/install.sh"
trap 'rm -rf "$tmp_dir"' EXIT
curl ${curlFlags}${normalizedCaCertPath ? ` --cacert ${shellQuoteArg(normalizedCaCertPath)}` : ''} ${shellQuoteArg(`${normalizedBaseUrl}/install.sh`)} -o "$install_script"
chmod +x "$install_script"${
normalizedToken
? `
token_file="$tmp_dir/token"
umask 077
printf %s ${shellQuoteArg(normalizedToken)} > "$token_file"`
: ''
cleanup() {
rm -rf -- "$tmp_dir"
if [ -n "${'${token_dir:-}'}" ]; then
if [ "$(id -u)" -eq 0 ]; then
rm -rf -- "$token_dir"
elif command -v sudo >/dev/null 2>&1; then
sudo rm -rf -- "$token_dir" >/dev/null 2>&1 || true
fi
fi
}
trap cleanup EXIT HUP INT TERM
curl ${curlFlags}${normalizedCaCertPath ? ` --cacert ${shellQuoteArg(normalizedCaCertPath)}` : ''} ${shellQuoteArg(`${normalizedBaseUrl}/install.sh`)} -o "$install_script"
chmod +x "$install_script"
bash "$install_script" ${preflightArgs}${caCertArg}${insecureArg}
if [ "$(id -u)" -eq 0 ]; then
bash "$install_script" ${installArgs}${caCertArg}${insecureArg}
${rootTokenSetup} bash "$install_script" ${installArgs}${caCertArg}${insecureArg}
elif command -v sudo >/dev/null 2>&1; then
sudo bash "$install_script" ${installArgs}${caCertArg}${insecureArg}
${sudoTokenSetup} sudo bash "$install_script" ${installArgs}${caCertArg}${insecureArg}
else
echo "Root privileges required. Run as root (su -) and retry." >&2
exit 1
+22 -8
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/ed25519"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
@@ -758,16 +759,29 @@ func (h *UnifiedAgentHandlers) HandleUninstall(w http.ResponseWriter, r *http.Re
log.Info().Str("agentId", agentID).Msg("Received unregistration request from agent uninstaller")
// Ensure the token can manage this specific agent.
if !h.ensureAgentTokenMatch(w, r, agentID) {
return
monitor := h.getMonitor(r.Context())
record := getAPITokenRecordFromRequest(r)
var err error
if record != nil {
_, err = monitor.UninstallHostAgent(agentID, record.ID)
} else {
// Preserve the existing administrative/session removal surface. Collector
// self-uninstall always takes the exact-token durable path above.
_, err = monitor.RemoveHostAgent(agentID)
}
// Remove the agent from state.
_, err := h.getMonitor(r.Context()).RemoveHostAgent(agentID)
if err != nil {
// If the agent is not found, we still return success because the goal is reached.
log.Warn().Err(err).Str("agentId", agentID).Msg("Agent not found during unregistration request")
switch {
case errors.Is(err, monitoring.ErrHostAgentTokenMismatch):
writeErrorResponse(w, http.StatusForbidden, "agent_lookup_forbidden", "Agent does not belong to this API token", nil)
case errors.Is(err, monitoring.ErrHostAgentTokenShared):
writeErrorResponse(w, http.StatusConflict, "agent_token_shared", "Collector credential is still used by another agent and must be rotated before uninstall", nil)
case errors.Is(err, monitoring.ErrHostAgentNotFound):
writeErrorResponse(w, http.StatusNotFound, "agent_not_found", "Agent has not registered with Pulse yet", nil)
default:
log.Error().Err(err).Str("agentId", agentID).Msg("Collector uninstall transaction failed")
writeErrorResponse(w, http.StatusInternalServerError, "agent_uninstall_failed", "Pulse could not durably remove the agent", nil)
}
return
}
h.broadcastState(r.Context())
@@ -91,7 +91,9 @@ func TestBuildProxmoxAgentInstallCommand(t *testing.T) {
require.Contains(t, command, posixShellQuote("https://pulse.example.com/install.sh"))
require.Contains(t, command, "printf %s "+posixShellQuote("token-123")+` > "$token_file"`)
require.Contains(t, command, `--token-file "$token_file"`)
require.Contains(t, command, `rm -f "$token_file"`)
require.Contains(t, command, `token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)`)
require.Contains(t, command, `token_dir=$(sudo mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)`)
require.Contains(t, command, `rm -rf -- "$token_dir"`)
require.Contains(t, command, "--proxmox-type "+posixShellQuote("pbs"))
require.NotContains(t, command, "--enable-commands")
}
@@ -131,10 +133,11 @@ func TestBuildProxmoxAgentInstallCommand_UsesPrivilegeEscalationWrapper(t *testi
IncludeInstallType: true,
})
require.Contains(t, command, `| { if [ "$(id -u)" -eq 0 ]; then bash -s --`)
require.Contains(t, command, `elif command -v sudo >/dev/null 2>&1; then sudo bash -s --`)
require.Contains(t, command, `else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }`)
require.NotContains(t, command, "| bash -s -- --url")
require.Contains(t, command, `if [ "$(id -u)" -eq 0 ]; then`)
require.Contains(t, command, `elif command -v sudo >/dev/null 2>&1; then`)
require.Contains(t, command, `printf %s 'token-123' | sudo tee "$token_file" >/dev/null`)
require.Contains(t, command, `curl -fsSL 'https://pulse.example.com/install.sh' | sudo bash -s --`)
require.Contains(t, command, `echo "Root privileges required. Run as root (su -) and retry." >&2`)
}
func TestBuildProxmoxAgentInstallCommand_OmitsTokenWhenNotProvided(t *testing.T) {
@@ -194,8 +197,8 @@ func TestBuildProxmoxAgentInstallCommand_IncludesCommandsWhenRequested(t *testin
require.Contains(t, command, "--enable-proxmox")
require.Contains(t, command, "--proxmox-type "+posixShellQuote("pve"))
require.Contains(t, command, "--enable-commands")
require.Contains(t, command, `| { if [ "$(id -u)" -eq 0 ]; then bash -s --`)
require.Contains(t, command, `rm -f "$token_file"`)
require.Contains(t, command, `token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)`)
require.Contains(t, command, `rm -rf -- "$token_dir"`)
}
func TestBuildContainerRuntimeAgentInstallCommand_UsesLifecycleTransport(t *testing.T) {
+37 -9
View File
@@ -24,14 +24,7 @@ func BuildProxmoxAgentInstallCommand(opts AgentInstallCommandOptions) string {
curlFlags = "-kfsSL"
}
token := strings.TrimSpace(opts.Token)
tokenSetup, tokenArg, tokenCleanup := "", "", ""
if token != "" {
tokenSetup = fmt.Sprintf(`token_file=$(mktemp) && chmod 600 "$token_file" && printf %%s %s > "$token_file" && `, posixShellQuote(token))
tokenArg = " \\\n --token-file \"$token_file\""
tokenCleanup = `; rc=$?; rm -f "$token_file"; exit $rc`
}
command := fmt.Sprintf("%scurl %s %s | bash -s -- \\\n --url %s \\\n --enable-proxmox", tokenSetup, curlFlags, posixShellQuote(installScriptURL), posixShellQuote(baseURL))
command += tokenArg
command := fmt.Sprintf("curl %s %s | bash -s -- \\\n --url %s \\\n --enable-proxmox", curlFlags, posixShellQuote(installScriptURL), posixShellQuote(baseURL))
if opts.Insecure || strings.HasPrefix(strings.ToLower(baseURL), "http://") {
command += " \\\n --insecure"
}
@@ -41,7 +34,42 @@ func BuildProxmoxAgentInstallCommand(opts AgentInstallCommandOptions) string {
if opts.EnableCommands {
command += " \\\n --enable-commands"
}
return withPrivilegeEscalation(command) + tokenCleanup
if token == "" {
return withPrivilegeEscalation(command)
}
rootCommand := command + " \\\n --token-file \"$token_file\""
sudoCommand := strings.Replace(command, "| bash -s --", "| sudo bash -s --", 1) + " \\\n --token-file \"$token_file\""
return fmt.Sprintf(`(
set -e
token_dir=""
cleanup() {
if [ -n "${token_dir:-}" ]; then
if [ "$(id -u)" -eq 0 ]; then
rm -rf -- "$token_dir"
elif command -v sudo >/dev/null 2>&1; then
sudo rm -rf -- "$token_dir" >/dev/null 2>&1 || true
fi
fi
}
trap cleanup EXIT HUP INT TERM
if [ "$(id -u)" -eq 0 ]; then
token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)
token_file="$token_dir/token"
umask 077
printf %%s %s > "$token_file"
%s
elif command -v sudo >/dev/null 2>&1; then
token_dir=$(sudo mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)
token_file="$token_dir/token"
printf %%s %s | sudo tee "$token_file" >/dev/null
sudo chmod 0600 "$token_file"
%s
else
echo "Root privileges required. Run as root (su -) and retry." >&2
exit 1
fi
)`, posixShellQuote(token), rootCommand, posixShellQuote(token), sudoCommand)
}
func buildProxmoxAgentInstallCommand(opts agentInstallCommandOptions) string {
@@ -0,0 +1,89 @@
package configapi
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestBuildProxmoxAgentInstallCommandExecutesTrustedRootAndSudoTokenBootstrap(t *testing.T) {
if testing.Short() {
t.Skip("executes the generated POSIX shell command")
}
fixtureDir := t.TempDir()
binDir := filepath.Join(fixtureDir, "bin")
if err := os.Mkdir(binDir, 0o700); err != nil {
t.Fatal(err)
}
installerPath := filepath.Join(fixtureDir, "installer.sh")
capturePath := filepath.Join(fixtureDir, "captured-token")
writeExecutable(t, installerPath, `#!/usr/bin/env bash
set -e
token_file=""
while [ "$#" -gt 0 ]; do
case "$1" in
--token-file) token_file="$2"; shift 2 ;;
*) shift ;;
esac
done
[ -n "$token_file" ]
[ "$(stat -c %a "$token_file" 2>/dev/null || stat -f %Lp "$token_file")" = "600" ]
parent_dir=$(dirname "$token_file")
[ "$(stat -c %a "$parent_dir" 2>/dev/null || stat -f %Lp "$parent_dir")" = "700" ]
[ "$(stat -c %u "$token_file" 2>/dev/null || stat -f %u "$token_file")" = "$(stat -c %u "$parent_dir" 2>/dev/null || stat -f %u "$parent_dir")" ]
cat "$token_file" > "$FAKE_CAPTURE"
`)
writeExecutable(t, filepath.Join(binDir, "curl"), `#!/bin/sh
cat "$FAKE_INSTALLER"
`)
writeExecutable(t, filepath.Join(binDir, "sudo"), `#!/bin/sh
exec "$@"
`)
writeExecutable(t, filepath.Join(binDir, "id"), `#!/bin/sh
if [ "$1" = "-u" ]; then
printf '%s\n' "$FAKE_ID_UID"
else
exec /usr/bin/id "$@"
fi
`)
command := BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{
BaseURL: "https://pulse.example",
Token: "token-123",
InstallType: "pve",
IncludeInstallType: true,
})
for _, fakeUID := range []string{"0", "1000"} {
t.Run("uid_"+fakeUID, func(t *testing.T) {
if err := os.Remove(capturePath); err != nil && !os.IsNotExist(err) {
t.Fatal(err)
}
cmd := exec.Command("bash", "-c", command)
cmd.Env = append(os.Environ(),
"PATH="+binDir+":"+os.Getenv("PATH"),
"FAKE_CAPTURE="+capturePath,
"FAKE_ID_UID="+fakeUID,
"FAKE_INSTALLER="+installerPath,
)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("generated command failed: %v\n%s\ncommand:\n%s", err, output, command)
}
got, err := os.ReadFile(capturePath)
if err != nil {
t.Fatal(err)
}
if string(got) != "token-123" {
t.Fatalf("captured token = %q, want token-123", got)
}
})
}
}
func writeExecutable(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
t.Fatal(err)
}
}
+4 -4
View File
@@ -10194,16 +10194,16 @@ func TestContract_ProxmoxInstallCommandUsesPrivilegeEscalationWrapper(t *testing
IncludeInstallType: true,
})
if !strings.Contains(got, `| { if [ "$(id -u)" -eq 0 ]; then bash -s --`) {
if !strings.Contains(got, `if [ "$(id -u)" -eq 0 ]; then`) {
t.Fatalf("install command missing root-or-sudo wrapper: %s", got)
}
if !strings.Contains(got, `sudo bash -s --`) {
t.Fatalf("install command missing sudo fallback: %s", got)
}
if strings.Contains(got, "| bash -s -- --url") {
t.Fatalf("install command preserved raw bash pipe instead of governed wrapper: %s", got)
if !strings.Contains(got, `token_dir=$(sudo mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)`) {
t.Fatalf("install command missing root-owned sudo token bootstrap: %s", got)
}
if !strings.Contains(got, `rm -f "$token_file"`) {
if !strings.Contains(got, `rm -rf -- "$token_dir"`) {
t.Fatalf("install command missing ephemeral token cleanup: %s", got)
}
}
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
@@ -334,6 +335,174 @@ func TestHostAgentRemovalLifecycleThroughAuthenticatedRouterAndRestart(t *testin
}
}
func TestCollectorUninstallTransactionFailsClosedAndRetriesAfterRestart(t *testing.T) {
standardScopes := []string{config.ScopeAgentReport, config.ScopeAgentConfigRead}
for _, failure := range []struct {
name string
blockedPath func(string) string
}{
{name: "continuity persistence", blockedPath: func(dataPath string) string {
return filepath.Join(dataPath, "host_continuity.json.tmp")
}},
{name: "credential persistence", blockedPath: func(dataPath string) string {
return filepath.Join(dataPath, "api_tokens.json.tmp")
}},
} {
credentialShapes := []struct {
name string
scopes []string
}{{name: "collector", scopes: standardScopes}}
if failure.name == "credential persistence" {
credentialShapes = append(credentialShapes,
struct {
name string
scopes []string
}{name: "legacy-settings-write", scopes: []string{config.ScopeAgentReport, config.ScopeSettingsWrite}},
struct {
name string
scopes []string
}{name: "legacy-wildcard", scopes: []string{config.ScopeWildcard}},
)
}
for _, credentialShape := range credentialShapes {
t.Run(failure.name+"/"+credentialShape.name, func(t *testing.T) {
dataPath := t.TempDir()
rawToken := "collector-uninstall-" + strings.ReplaceAll(failure.name, " ", "-") + "-" + credentialShape.name + "-123.12345678"
record := newTokenRecord(t, rawToken, credentialShape.scopes, nil)
if err := config.NewConfigPersistence(dataPath).SaveAPITokens([]config.APITokenRecord{record}); err != nil {
t.Fatalf("SaveAPITokens: %v", err)
}
report := agentshost.Report{
Host: agentshost.HostInfo{
ID: "collector-uninstall-machine",
MachineID: "collector-uninstall-machine",
Hostname: "collector-uninstall.local",
Platform: "linux",
},
Agent: agentshost.AgentInfo{ID: "collector-uninstall-agent", Version: "6.1.1", Type: "unified"},
Timestamp: time.Now().UTC(),
}
runtime := newHostRemovalLifecycleHTTPRuntime(t, dataPath, []config.APITokenRecord{record})
status, hostID, body := postHostRemovalLifecycleReport(t, runtime, rawToken, report)
if status != http.StatusOK {
runtime.stop()
t.Fatalf("initial report status = %d: %s", status, body)
}
blocker := failure.blockedPath(dataPath)
if err := os.Mkdir(blocker, 0o700); err != nil {
runtime.stop()
t.Fatalf("create persistence blocker: %v", err)
}
uninstallBody, err := json.Marshal(map[string]string{"agentId": hostID})
if err != nil {
runtime.stop()
t.Fatal(err)
}
failed := serveHostRemovalLifecycleRequest(t, runtime, http.MethodPost, "/api/agents/agent/uninstall", rawToken, uninstallBody)
if failed.Code == http.StatusOK {
runtime.stop()
t.Fatalf("persistence failure authorized teardown: %s", failed.Body.String())
}
if hosts := runtime.monitor.GetLiveHostsSnapshot(); len(hosts) != 1 || hosts[0].ID != hostID {
runtime.stop()
t.Fatalf("failed uninstall changed live host state: %+v", hosts)
}
if _, ok := runtime.config.ValidateAPIToken(rawToken); !ok {
runtime.stop()
t.Fatal("failed uninstall revoked the retry credential")
}
if err := os.Remove(blocker); err != nil {
runtime.stop()
t.Fatalf("remove persistence blocker: %v", err)
}
runtime.stop()
reloaded, err := config.NewConfigPersistence(dataPath).LoadAPITokens()
if err != nil {
t.Fatalf("LoadAPITokens after failed transaction: %v", err)
}
runtime = newHostRemovalLifecycleHTTPRuntime(t, dataPath, reloaded)
t.Cleanup(runtime.stop)
retry := serveHostRemovalLifecycleRequest(t, runtime, http.MethodPost, "/api/agents/agent/uninstall", rawToken, uninstallBody)
if retry.Code != http.StatusOK {
t.Fatalf("retry status = %d: %s", retry.Code, retry.Body.String())
}
if hosts := runtime.monitor.GetLiveHostsSnapshot(); len(hosts) != 0 {
t.Fatalf("successful retry retained host: %+v", hosts)
}
if _, ok := runtime.config.ValidateAPIToken(rawToken); ok {
t.Fatal("successful retry retained collector credential")
}
persisted, err := config.NewConfigPersistence(dataPath).LoadAPITokens()
if err != nil {
t.Fatalf("LoadAPITokens after successful retry: %v", err)
}
if tokenRecordByID(persisted, record.ID) != nil {
t.Fatalf("revoked collector credential survived restart state: %+v", persisted)
}
runtime.stop()
runtime = newHostRemovalLifecycleHTTPRuntime(t, dataPath, persisted)
if hosts := runtime.monitor.GetLiveHostsSnapshot(); len(hosts) != 0 {
t.Fatalf("restart resurrected removed collector: %+v", hosts)
}
report.Timestamp = report.Timestamp.Add(time.Minute)
if status, _, body := postHostRemovalLifecycleReport(t, runtime, rawToken, report); status != http.StatusUnauthorized {
t.Fatalf("restart accepted revoked collector credential: status=%d body=%s", status, body)
}
})
}
}
}
func TestCollectorUninstallRejectsCredentialStillUsedByAnotherHost(t *testing.T) {
dataPath := t.TempDir()
const rawToken = "collector-uninstall-shared-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentReport, config.ScopeAgentConfigRead}, nil)
if err := config.NewConfigPersistence(dataPath).SaveAPITokens([]config.APITokenRecord{record}); err != nil {
t.Fatalf("SaveAPITokens: %v", err)
}
runtime := newHostRemovalLifecycleHTTPRuntime(t, dataPath, []config.APITokenRecord{record})
t.Cleanup(runtime.stop)
report := func(machineID, hostname string) agentshost.Report {
return agentshost.Report{
Host: agentshost.HostInfo{ID: machineID, MachineID: machineID, Hostname: hostname, Platform: "linux"},
Agent: agentshost.AgentInfo{ID: machineID + "-agent", Version: "6.1.1", Type: "unified"},
Timestamp: time.Now().UTC(),
}
}
status, targetID, body := postHostRemovalLifecycleReport(t, runtime, rawToken, report("shared-target", "shared-target.local"))
if status != http.StatusOK {
t.Fatalf("target report status = %d: %s", status, body)
}
status, keeperID, body := postHostRemovalLifecycleReport(t, runtime, rawToken, report("shared-keeper", "shared-keeper.local"))
if status != http.StatusOK {
t.Fatalf("keeper report status = %d: %s", status, body)
}
uninstallBody, err := json.Marshal(map[string]string{"agentId": targetID})
if err != nil {
t.Fatal(err)
}
rec := serveHostRemovalLifecycleRequest(t, runtime, http.MethodPost, "/api/agents/agent/uninstall", rawToken, uninstallBody)
if rec.Code != http.StatusConflict {
t.Fatalf("shared credential uninstall status = %d, want 409: %s", rec.Code, rec.Body.String())
}
hosts := runtime.monitor.GetLiveHostsSnapshot()
if len(hosts) != 2 {
t.Fatalf("shared credential failure changed live hosts: %+v", hosts)
}
if _, ok := runtime.config.ValidateAPIToken(rawToken); !ok {
t.Fatal("shared credential failure revoked the keeper credential")
}
if targetID == keeperID {
t.Fatalf("test setup did not create distinct hosts: %q", targetID)
}
}
func TestHostAgentFreshInstallTokenReplacesStaleDisabledCommandPolicyOnce(t *testing.T) {
dataPath := t.TempDir()
const adminRaw = "issue-1728-admin-token-123.12345678"
+113 -22
View File
@@ -23,6 +23,7 @@ const (
defaultRequestTimeout = 15 * time.Second
maximumResponseBytes = 64 << 10
maximumBearerBytes = 4 << 10
maximumInstallerBytes = 4 << 20
)
var (
@@ -48,6 +49,15 @@ type Config struct {
Timeout time.Duration
}
// PublicConfig contains the trust-only inputs for downloading a public Pulse
// lifecycle artifact without exposing a bearer.
type PublicConfig struct {
PulseURL string
CACertPath string
ServerFingerprint string
Timeout time.Duration
}
// Client can only reduce the current collector's authority and inspect its
// authoritative registration. It deliberately exposes no general request API.
type Client struct {
@@ -67,28 +77,42 @@ type Registration struct {
// New validates the destination before reading the bearer and constructs a
// redirect-denying, system-CA/custom-CA/exact-leaf-pin-aware HTTP client.
func New(config Config) (*Client, error) {
baseURL, err := securityutil.NormalizePulseHTTPBaseURL(config.PulseURL)
if err != nil {
return nil, fmt.Errorf("validate collector lifecycle URL: %w", err)
}
if baseURL.Scheme == "http" && !exactLifecycleLoopbackHost(baseURL.Hostname()) {
return nil, errors.New("collector lifecycle plaintext HTTP is allowed only for localhost, 127.0.0.1, or ::1")
}
if baseURL.Scheme == "http" && (strings.TrimSpace(config.CACertPath) != "" || strings.TrimSpace(config.ServerFingerprint) != "") {
return nil, errors.New("collector lifecycle TLS trust options require an HTTPS URL")
}
bearer, err := readPrivateBearer(config.TokenFile, config.TokenOwnerUID)
baseURL, httpClient, err := newLifecycleHTTPClient(PublicConfig{
PulseURL: config.PulseURL,
CACertPath: config.CACertPath,
ServerFingerprint: config.ServerFingerprint,
Timeout: config.Timeout,
})
if err != nil {
return nil, err
}
bearer, err := readPrivateBearer(config.TokenFile, config.TokenOwnerUID)
if err != nil {
httpClient.CloseIdleConnections()
return nil, err
}
return &Client{baseURL: baseURL, bearer: bearer, http: httpClient}, nil
}
func newLifecycleHTTPClient(config PublicConfig) (*url.URL, *http.Client, error) {
baseURL, err := securityutil.NormalizePulseHTTPBaseURL(config.PulseURL)
if err != nil {
return nil, nil, fmt.Errorf("validate collector lifecycle URL: %w", err)
}
if baseURL.Scheme == "http" && !exactLifecycleLoopbackHost(baseURL.Hostname()) {
return nil, nil, errors.New("collector lifecycle plaintext HTTP is allowed only for localhost, 127.0.0.1, or ::1")
}
if baseURL.Scheme == "http" && (strings.TrimSpace(config.CACertPath) != "" || strings.TrimSpace(config.ServerFingerprint) != "") {
return nil, nil, errors.New("collector lifecycle TLS trust options require an HTTPS URL")
}
tlsConfig, err := agenttls.NewClientTLSConfig(config.CACertPath, false, config.ServerFingerprint)
if err != nil {
return nil, fmt.Errorf("configure collector lifecycle TLS: %w", err)
return nil, nil, fmt.Errorf("configure collector lifecycle TLS: %w", err)
}
if baseURL.Scheme == "https" && strings.TrimSpace(config.CACertPath) == "" && strings.TrimSpace(config.ServerFingerprint) == "" {
roots, err := loadSystemCertPool()
if err != nil || roots == nil {
return nil, fmt.Errorf("load system certificate authorities: %w", err)
return nil, nil, fmt.Errorf("load system certificate authorities: %w", err)
}
tlsConfig.RootCAs = roots
}
@@ -96,15 +120,11 @@ func New(config Config) (*Client, error) {
if timeout <= 0 {
timeout = defaultRequestTimeout
}
return &Client{
baseURL: baseURL,
bearer: bearer,
http: &http.Client{
Timeout: timeout,
Transport: &http.Transport{TLSClientConfig: tlsConfig},
CheckRedirect: func(req *http.Request, _ []*http.Request) error {
return fmt.Errorf("collector lifecycle server returned redirect to %s; use the final Pulse URL explicitly", req.URL)
},
return baseURL, &http.Client{
Timeout: timeout,
Transport: &http.Transport{TLSClientConfig: tlsConfig},
CheckRedirect: func(req *http.Request, _ []*http.Request) error {
return fmt.Errorf("collector lifecycle server returned redirect to %s; use the final Pulse URL explicitly", req.URL)
},
}, nil
}
@@ -231,6 +251,77 @@ func (c *Client) VerifyRegistration(ctx context.Context, agentID, hostname strin
return Registration{AgentID: payload.Agent.ID, Hostname: payload.Agent.Hostname, LastSeen: lastSeen}, nil
}
// Uninstall removes the exact bearer-bound collector record. If agentID is
// unavailable, hostname is first resolved through the authenticated lookup.
// Only a bounded success response naming the exact agent authorizes teardown.
func (c *Client) Uninstall(ctx context.Context, agentID, hostname string) (string, error) {
agentID = strings.TrimSpace(agentID)
hostname = strings.TrimSpace(hostname)
if !validBoundedIdentity(agentID, 256) {
registration, err := c.VerifyRegistration(ctx, "", hostname, time.Time{})
if err != nil {
return "", fmt.Errorf("resolve collector uninstall identity: %w", err)
}
agentID = registration.AgentID
}
body, err := json.Marshal(map[string]string{"agentId": agentID})
if err != nil {
return "", err
}
response, err := c.do(ctx, http.MethodPost, "/api/agents/agent/uninstall", bytes.NewReader(body), "application/json")
if err != nil {
return "", fmt.Errorf("uninstall collector: %w", err)
}
defer response.Body.Close()
encoded, err := io.ReadAll(io.LimitReader(response.Body, maximumResponseBytes+1))
if err != nil {
return "", fmt.Errorf("read collector uninstall response: %w", err)
}
if len(encoded) > maximumResponseBytes || response.StatusCode != http.StatusOK {
return "", fmt.Errorf("uninstall collector: server returned %s", response.Status)
}
var result struct {
Success bool `json:"success"`
AgentID string `json:"agentId"`
}
if json.Unmarshal(encoded, &result) != nil || !result.Success || strings.TrimSpace(result.AgentID) != agentID {
return "", errors.New("uninstall collector: server returned invalid confirmation")
}
return agentID, nil
}
// DownloadInstaller fetches the public shell installer through the same
// validated TLS/pin, no-proxy, redirect-denying transport used by credential
// lifecycle operations. Signature verification remains the caller's job.
func DownloadInstaller(ctx context.Context, config PublicConfig) ([]byte, string, error) {
baseURL, httpClient, err := newLifecycleHTTPClient(config)
if err != nil {
return nil, "", err
}
defer httpClient.CloseIdleConnections()
target := strings.TrimRight(baseURL.String(), "/") + "/install.sh"
request, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return nil, "", err
}
response, err := httpClient.Do(request)
if err != nil {
return nil, "", fmt.Errorf("download installer: %w", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("download installer: server returned %s", response.Status)
}
encoded, err := io.ReadAll(io.LimitReader(response.Body, maximumInstallerBytes+1))
if err != nil {
return nil, "", fmt.Errorf("read installer: %w", err)
}
if len(encoded) == 0 || len(encoded) > maximumInstallerBytes {
return nil, "", errors.New("download installer: response is empty or exceeds the size limit")
}
return encoded, strings.TrimSpace(response.Header.Get("X-Signature-SSHSIG")), nil
}
func (c *Client) do(ctx context.Context, method, path string, body io.Reader, contentType string) (*http.Response, error) {
if c == nil || c.baseURL == nil || c.http == nil || c.bearer == "" {
return nil, errors.New("collector lifecycle client is not initialized")
@@ -222,6 +222,98 @@ func TestRedirectIsRejectedWithoutAuthorizingDestination(t *testing.T) {
}
}
func TestDownloadInstallerUsesPinnedNoProxyTransportAndReturnsSignature(t *testing.T) {
var proxyRequests atomic.Int32
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
proxyRequests.Add(1)
http.Error(w, "proxy must not be used", http.StatusBadGateway)
}))
defer proxy.Close()
t.Setenv("HTTP_PROXY", proxy.URL)
t.Setenv("HTTPS_PROXY", proxy.URL)
server := newTLSServer(t, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/install.sh" || request.Header.Get("Authorization") != "" {
t.Errorf("installer request path=%q authorization=%q", request.URL.Path, request.Header.Get("Authorization"))
}
w.Header().Set("X-Signature-SSHSIG", "signed-installer-header")
_, _ = w.Write([]byte("#!/usr/bin/env bash\necho secure\n"))
}))
fingerprint := sha256.Sum256(server.Certificate().Raw)
body, signature, err := DownloadInstaller(context.Background(), PublicConfig{
PulseURL: server.URL, ServerFingerprint: hex.EncodeToString(fingerprint[:]),
})
if err != nil {
t.Fatalf("DownloadInstaller: %v", err)
}
if string(body) != "#!/usr/bin/env bash\necho secure\n" || signature != "signed-installer-header" {
t.Fatalf("download body=%q signature=%q", body, signature)
}
if proxyRequests.Load() != 0 {
t.Fatalf("proxy received %d installer requests", proxyRequests.Load())
}
}
func TestDownloadInstallerRejectsFingerprintMismatchAndRedirect(t *testing.T) {
t.Run("fingerprint mismatch", func(t *testing.T) {
var reached atomic.Bool
server := newTLSServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
reached.Store(true)
_, _ = w.Write([]byte("forged"))
}))
_, _, err := DownloadInstaller(context.Background(), PublicConfig{
PulseURL: server.URL, ServerFingerprint: strings.Repeat("00", sha256.Size),
})
if err == nil || !strings.Contains(err.Error(), "fingerprint mismatch") {
t.Fatalf("error = %v, want fingerprint mismatch", err)
}
if reached.Load() {
t.Fatal("mismatched TLS handler received installer request")
}
})
t.Run("redirect", func(t *testing.T) {
destination := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("redirect destination received installer request")
}))
defer destination.Close()
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
http.Redirect(w, request, destination.URL+"/install.sh", http.StatusTemporaryRedirect)
}))
defer source.Close()
_, _, err := DownloadInstaller(context.Background(), PublicConfig{PulseURL: source.URL})
if err == nil || !strings.Contains(err.Error(), "returned redirect") {
t.Fatalf("error = %v, want redirect rejection", err)
}
})
}
func TestUninstallResolvesAndConfirmsExactBearerBoundAgent(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
if request.Header.Get("Authorization") != "Bearer "+testBearer {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
switch request.URL.Path {
case "/api/agents/agent/lookup":
_, _ = w.Write([]byte(`{"success":true,"agent":{"id":"agent-1","hostname":"host.local","lastSeen":"2026-09-01T12:00:00Z"}}`))
case "/api/agents/agent/uninstall":
_, _ = w.Write([]byte(`{"success":true,"agentId":"agent-1"}`))
default:
http.NotFound(w, request)
}
}))
defer server.Close()
client, err := New(Config{PulseURL: server.URL, TokenFile: writeToken(t), TokenOwnerUID: testTokenOwnerUID()})
if err != nil {
t.Fatal(err)
}
defer client.Close()
removed, err := client.Uninstall(context.Background(), "", "host.local")
if err != nil || removed != "agent-1" {
t.Fatalf("Uninstall removed=%q err=%v", removed, err)
}
}
func TestVerifyRegistrationRequiresFreshAuthenticatedEvidence(t *testing.T) {
prior := time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
+149 -49
View File
@@ -3,7 +3,9 @@ package monitoring
import (
"crypto/sha1"
"encoding/hex"
"errors"
"fmt"
"slices"
"strings"
"sync"
"time"
@@ -494,6 +496,12 @@ func (m *Monitor) removeDockerHostsForHostAgent(
return removedCount
}
var (
ErrHostAgentNotFound = errors.New("host agent not found")
ErrHostAgentTokenShared = errors.New("host agent token is still used by another resource")
ErrHostAgentTokenMismatch = errors.New("host agent token mismatch")
)
// RemoveHostAgent removes a host agent from monitoring state and clears related data.
func (m *Monitor) RemoveHostAgent(hostID string) (models.Host, error) {
hostID = strings.TrimSpace(hostID)
@@ -503,14 +511,40 @@ func (m *Monitor) RemoveHostAgent(hostID string) (models.Host, error) {
m.hostAgentLifecycleMu.Lock()
defer m.hostAgentLifecycleMu.Unlock()
return m.removeHostAgentLocked(hostID, "", false)
}
// UninstallHostAgent removes the exact collector record bound to tokenID. Unlike
// operator-initiated deletion, this path does not report success until the
// removal tombstone and any dedicated credential revocation are durable.
func (m *Monitor) UninstallHostAgent(hostID, tokenID string) (models.Host, error) {
hostID = strings.TrimSpace(hostID)
tokenID = strings.TrimSpace(tokenID)
if hostID == "" || tokenID == "" {
return models.Host{}, fmt.Errorf("host id and token id are required")
}
m.hostAgentLifecycleMu.Lock()
defer m.hostAgentLifecycleMu.Unlock()
return m.removeHostAgentLocked(hostID, tokenID, true)
}
func (m *Monitor) removeHostAgentLocked(hostID, requiredTokenID string, requireDurableRevocation bool) (models.Host, error) {
continuity, hasContinuity := config.HostContinuityEntry{}, false
if m.hostContinuityStore != nil {
continuity, hasContinuity = m.hostContinuityStore.Get(hostID)
}
host, removed := m.state.RemoveHost(hostID)
if !removed {
host, present := models.Host{}, false
for _, candidate := range m.state.GetHosts() {
if candidate.ID == hostID {
host = candidate
present = true
break
}
}
if !present {
if logging.IsLevelEnabled(zerolog.DebugLevel) {
log.Debug().Str("hostID", hostID).Msg("host not present in state during removal")
}
@@ -523,21 +557,88 @@ func (m *Monitor) RemoveHostAgent(hostID string) (models.Host, error) {
}
}
}
if requireDurableRevocation {
boundTokenID := strings.TrimSpace(host.TokenID)
if boundTokenID == "" {
boundTokenID = strings.TrimSpace(continuity.TokenID)
}
if boundTokenID != requiredTokenID {
if !present && hasContinuity && !continuity.RemovedAt.IsZero() && slices.Contains(continuity.DeniedTokenIDs, requiredTokenID) {
// The exact durable transaction already committed. This makes a
// duplicated in-process request harmless even after live state is gone.
return hostFromContinuityEntry(continuity), nil
}
if !present && !hasContinuity {
return models.Host{}, ErrHostAgentNotFound
}
return models.Host{}, ErrHostAgentTokenMismatch
}
if m.hostContinuityStore == nil {
return models.Host{}, fmt.Errorf("durable host continuity store unavailable")
}
if err := m.hostContinuityStore.LoadError(); err != nil {
return models.Host{}, fmt.Errorf("durable host continuity state unavailable: %w", err)
}
}
removedAt := time.Now().UTC()
if !continuity.RemovedAt.IsZero() {
removedAt = continuity.RemovedAt.UTC()
}
tombstone := removedHostContinuityEntry(hostID, host, continuity, removedAt)
tokenID := strings.TrimSpace(host.TokenID)
hostname := strings.TrimSpace(host.Hostname)
tokenStillUsed := m.hostAgentTokenUsedOutsideRemoval(tokenID, hostID, host, tombstone)
if requireDurableRevocation && tokenStillUsed {
return models.Host{}, ErrHostAgentTokenShared
}
if m.hostContinuityStore != nil {
if err := m.hostContinuityStore.Upsert(tombstone); err != nil {
if removed {
m.state.UpsertHost(host)
}
return models.Host{}, fmt.Errorf("persist host agent removal tombstone: %w", err)
}
}
var tokenRemoved *config.APITokenRecord
if tokenID != "" && !tokenStillUsed {
if requireDurableRevocation && m.persistence == nil {
rollbackErr := m.restoreHostContinuityAfterFailedRemoval(hostID, continuity, hasContinuity)
if rollbackErr != nil {
return models.Host{}, errors.Join(
errors.New("collector credential persistence unavailable"),
fmt.Errorf("restore host continuity after unavailable credential persistence: %w", rollbackErr),
)
}
return models.Host{}, errors.New("collector credential persistence unavailable")
}
var err error
tokenRemoved, err = m.revokeAPIToken(tokenID)
if err != nil && requireDurableRevocation {
rollbackErr := m.restoreHostContinuityAfterFailedRemoval(hostID, continuity, hasContinuity)
if rollbackErr != nil {
return models.Host{}, errors.Join(
fmt.Errorf("persist collector credential revocation: %w", err),
fmt.Errorf("restore host continuity after failed revocation: %w", rollbackErr),
)
}
return models.Host{}, fmt.Errorf("persist collector credential revocation: %w", err)
}
if err != nil {
log.Warn().Err(err).Str("tokenID", tokenID).Msg("API token revocation rolled back after host agent removal")
} else if tokenRemoved != nil {
log.Info().Str("tokenID", tokenID).Str("tokenName", host.TokenName).Msg("API token revoked for removed host agent")
}
} else if tokenID != "" && tokenStillUsed {
log.Info().
Str("tokenID", tokenID).
Str("hostID", hostID).
Msg("API token still used by other agents; skipping revocation during host removal")
}
host, removed := m.state.RemoveHost(hostID)
if !removed {
host = hostFromContinuityEntry(tombstone)
}
removedEntry := removedHostAgentFromContinuity(tombstone)
m.mu.Lock()
if m.removedHostAgents == nil {
@@ -550,50 +651,6 @@ func (m *Monitor) RemoveHostAgent(hostID string) (models.Host, error) {
removedDockerHosts := m.removeDockerHostsForHostAgent(hostID, host, tombstone, removedAt)
tokenID := strings.TrimSpace(host.TokenID)
hostname := strings.TrimSpace(host.Hostname)
tokenStillUsed := false
if tokenID != "" && m.state != nil {
readState := m.snapshotBackedUnifiedReadState()
for _, other := range readState.Hosts() {
if other == nil {
continue
}
if strings.TrimSpace(other.TokenID()) == tokenID {
tokenStillUsed = true
break
}
}
if !tokenStillUsed {
for _, other := range readState.DockerHosts() {
if other == nil {
continue
}
if strings.TrimSpace(other.TokenID()) == tokenID {
tokenStillUsed = true
break
}
}
}
}
var tokenRemoved *config.APITokenRecord
if tokenID != "" && !tokenStillUsed {
var err error
tokenRemoved, err = m.revokeAPIToken(tokenID)
if err != nil {
log.Warn().Err(err).Str("tokenID", tokenID).Msg("API token revocation rolled back after host agent removal")
} else if tokenRemoved != nil {
log.Info().Str("tokenID", tokenID).Str("tokenName", host.TokenName).Msg("API token revoked for removed host agent")
}
} else if tokenID != "" && tokenStillUsed {
log.Info().
Str("tokenID", tokenID).
Str("hostID", hostID).
Msg("API token still used by other agents; skipping revocation during host removal")
}
if tokenID != "" {
m.mu.Lock()
if m.hostTokenBindings == nil {
@@ -665,6 +722,49 @@ func (m *Monitor) RemoveHostAgent(hostID string) (models.Host, error) {
return host, nil
}
func (m *Monitor) hostAgentTokenUsedOutsideRemoval(
tokenID string,
hostID string,
host models.Host,
continuity config.HostContinuityEntry,
) bool {
tokenID = strings.TrimSpace(tokenID)
if tokenID == "" || m == nil || m.state == nil {
return false
}
for _, other := range m.state.GetHosts() {
if strings.TrimSpace(other.ID) == hostID {
continue
}
if strings.TrimSpace(other.TokenID) == tokenID {
return true
}
}
for _, other := range m.state.GetDockerHosts() {
if dockerHostBelongsToHostAgent(other, hostID, host, continuity) {
continue
}
if strings.TrimSpace(other.TokenID) == tokenID {
return true
}
}
return false
}
func (m *Monitor) restoreHostContinuityAfterFailedRemoval(
hostID string,
previous config.HostContinuityEntry,
existed bool,
) error {
if m == nil || m.hostContinuityStore == nil {
return nil
}
if existed {
return m.hostContinuityStore.Upsert(previous)
}
return m.hostContinuityStore.Delete(hostID)
}
func removedHostContinuityEntry(
hostID string,
host models.Host,
@@ -3,6 +3,7 @@ package monitoring
import (
"os"
"path/filepath"
"slices"
"sync"
"testing"
"time"
@@ -197,6 +198,90 @@ func TestHostAgentRemovalLifecycleRevokesDedicatedCredentialAndRetainsDenial(t *
}
}
func TestCollectorUninstallHostAgentTransactionRollsBackAndRetries(t *testing.T) {
dataPath := t.TempDir()
monitor := newHostRemovalLifecycleMonitor(t, dataPath)
monitor.persistence = config.NewConfigPersistence(dataPath)
now := time.Now().UTC()
token := config.APITokenRecord{
ID: "collector-uninstall-token",
Name: "Collector uninstall token",
Hash: "collector-uninstall-hash",
CreatedAt: now.Add(-time.Hour),
Scopes: []string{config.ScopeAgentReport, config.ScopeAgentConfigRead},
}
monitor.config.APITokens = []config.APITokenRecord{token}
if err := monitor.persistence.SaveAPITokens(monitor.config.APITokens); err != nil {
t.Fatalf("SaveAPITokens: %v", err)
}
report := hostRemovalLifecycleReport(
"collector-uninstall-machine",
"collector-uninstall-machine",
"collector-uninstall-agent",
"collector-uninstall.local",
"linux",
now,
)
host, err := monitor.ApplyHostReport(report, &token)
if err != nil {
t.Fatalf("initial ApplyHostReport: %v", err)
}
// Block only the credential inventory's atomic replacement. The removal
// tombstone can still be written first, so this exercises the rollback
// half of the durable uninstall transaction rather than a preflight error.
credentialBlocker := filepath.Join(dataPath, "api_tokens.json.tmp")
if err := os.Mkdir(credentialBlocker, 0o700); err != nil {
t.Fatalf("create credential persistence blocker: %v", err)
}
if _, err := monitor.UninstallHostAgent(host.ID, token.ID); err == nil {
t.Fatal("UninstallHostAgent authorized teardown without durable credential revocation")
}
if hosts := monitor.GetLiveHostsSnapshot(); len(hosts) != 1 || hosts[0].ID != host.ID {
t.Fatalf("failed uninstall changed live host state: %+v", hosts)
}
if len(monitor.config.APITokens) != 1 || monitor.config.APITokens[0].ID != token.ID {
t.Fatalf("failed uninstall changed live credential inventory: %+v", monitor.config.APITokens)
}
continuity, ok := monitor.hostContinuityStore.Get(host.ID)
if !ok || !continuity.RemovedAt.IsZero() || continuity.TokenID != token.ID {
t.Fatalf("failed uninstall did not restore active continuity: (%+v, %v)", continuity, ok)
}
if err := os.Remove(credentialBlocker); err != nil {
t.Fatalf("remove credential persistence blocker: %v", err)
}
removed, err := monitor.UninstallHostAgent(host.ID, token.ID)
if err != nil {
t.Fatalf("retry UninstallHostAgent: %v", err)
}
if removed.ID != host.ID {
t.Fatalf("removed host = %+v, want %q", removed, host.ID)
}
if hosts := monitor.GetLiveHostsSnapshot(); len(hosts) != 0 {
t.Fatalf("successful uninstall retained live host: %+v", hosts)
}
if len(monitor.config.APITokens) != 0 {
t.Fatalf("successful uninstall retained live credential: %+v", monitor.config.APITokens)
}
persistedTokens, err := monitor.persistence.LoadAPITokens()
if err != nil {
t.Fatalf("LoadAPITokens after retry: %v", err)
}
if len(persistedTokens) != 0 {
t.Fatalf("successful uninstall retained durable credential: %+v", persistedTokens)
}
tombstone, ok := monitor.hostContinuityStore.Get(host.ID)
if !ok || tombstone.RemovedAt.IsZero() || !slices.Contains(tombstone.DeniedTokenIDs, token.ID) {
t.Fatalf("successful uninstall tombstone = (%+v, %v)", tombstone, ok)
}
if _, err := monitor.UninstallHostAgent(host.ID, token.ID); err != nil {
t.Fatalf("idempotent uninstall retry: %v", err)
}
}
func TestRevokeAPITokenRollsBackCompleteInventoryWhenPersistenceFails(t *testing.T) {
now := time.Now().UTC()
tokens := []config.APITokenRecord{
+399 -107
View File
@@ -127,6 +127,7 @@ AGENT_LOG_FILE="" # When set, pass --log-file so the agent's rotating log writer
DEFAULT_STATE_DIR="/var/lib/pulse-agent"
STATE_DIR="$DEFAULT_STATE_DIR" # Persistent state directory (overridden per platform)
STATE_DIR_SOURCE="default" # default, explicit, recovered, or platform
STATE_DIR_REMOVAL_AUTHORITY="$DEFAULT_STATE_DIR"
CURL_CA_BUNDLE="${PULSE_CACERT:-}" # Path to CA bundle for curl and agent TLS (sets SSL_CERT_FILE)
NON_INTERACTIVE="false"
TOKEN_FILE_PATH="" # Path to file containing the token
@@ -163,7 +164,8 @@ PRIVILEGED_HELPER_SERVICE_UNIT="/etc/systemd/system/${PRIVILEGED_HELPER_NAME}.se
PRIVILEGED_HELPER_SOCKET_UNIT="/etc/systemd/system/${PRIVILEGED_HELPER_NAME}.socket"
PRIVILEGED_HELPER_SOCKET_DIR="/run/pulse-agent"
PRIVILEGED_HELPER_SOCKET_PATH="${PRIVILEGED_HELPER_SOCKET_DIR}/helper.sock"
PRIVILEGED_HELPER_CREDENTIAL_DIR="/etc/pulse-agent"
INSTALLER_LIFECYCLE_DIR="/etc/pulse-agent"
PRIVILEGED_HELPER_CREDENTIAL_DIR="$INSTALLER_LIFECYCLE_DIR"
PRIVILEGED_HELPER_STATE_DIR="/var/lib/pulse-agent-helper"
PRIVILEGED_HELPER_UPDATE_STAGING_DIR="${PRIVILEGED_HELPER_STATE_DIR}/update-staging"
PRIVILEGED_HELPER_UPDATE_QUARANTINE_DIR="/var/lib/pulse-agent/update-quarantine"
@@ -199,6 +201,7 @@ SAFE_PROFILE_TRANSACTION_ACTIVE="false"
SAFE_PROFILE_TRANSACTION_COMMITTED="false"
SAFE_PROFILE_PRIOR_REGISTRATION_LAST_SEEN=""
AGENT_REGISTRATION_LAST_SEEN=""
CONNECTION_INFO_PERSISTED="false"
SYSTEMD_ENV_LINES=""
SHELL_EXPORT_LINES=""
@@ -478,7 +481,7 @@ ensure_agent_disk_headroom() {
}
has_pinned_installer_signature_key() {
[[ -n "$PINNED_INSTALLER_SSH_PUBLIC_KEY" && "$PINNED_INSTALLER_SSH_PUBLIC_KEY" != "__PULSE_INSTALLER_SSH_PUBLIC_KEY__" ]]
[[ -n "${PINNED_INSTALLER_SSH_PUBLIC_KEY:-}" && "$PINNED_INSTALLER_SSH_PUBLIC_KEY" != "__PULSE_INSTALLER_SSH_PUBLIC_KEY__" ]]
}
decode_base64_to_file() {
@@ -656,8 +659,8 @@ collector_lifecycle_binary() {
printf '%s\n' "$TMP_BIN"
return 0
fi
if [[ -x "${INSTALL_DIR%/}/${BINARY_NAME}" ]]; then
printf '%s\n' "${INSTALL_DIR%/}/${BINARY_NAME}"
if [[ -x "${INSTALL_DIR:-/usr/local/bin}/${BINARY_NAME:-pulse-agent}" ]]; then
printf '%s\n' "${INSTALL_DIR:-/usr/local/bin}/${BINARY_NAME:-pulse-agent}"
return 0
fi
return 1
@@ -719,6 +722,7 @@ run_collector_lifecycle_command() {
local -a lifecycle_args
lifecycle_binary=$(collector_lifecycle_binary) || return 1
trusted_lifecycle_regular_file "$lifecycle_binary" 755 || return 1
prepare_collector_lifecycle_token_file || return 1
lifecycle_args=("$command_name" --url "$PULSE_URL" --token-file "$COLLECTOR_LIFECYCLE_TOKEN_FILE")
collector_uid=$(id -u "$LEAST_PRIVILEGE_USER" 2>/dev/null || true)
@@ -791,6 +795,45 @@ verify_agent_server_registration() {
return 1
}
collector_credential_state_present() {
local candidate=""
[[ -n "${PULSE_TOKEN:-}" ]] && return 0
for candidate in \
"${STATE_DIR%/}/runtime.token" \
"${RUNTIME_TOKEN_FILE:-}" \
"${STATE_DIR%/}/token" \
"${PRIVILEGED_HELPER_CREDENTIAL_DIR%/}/token"; do
[[ -n "$candidate" ]] || continue
if [[ -e "$candidate" || -L "$candidate" || -p "$candidate" ]]; then
return 0
fi
done
return 1
}
uninstall_collector_registration() {
local uninstall_hostname="${HOSTNAME_OVERRIDE:-}"
local removed_agent_id=""
local -a uninstall_args=(collector-uninstall)
if [[ -z "${AGENT_ID:-}" ]]; then
AGENT_ID=$(recover_agent_id_from_state_file || true)
fi
if [[ -z "$uninstall_hostname" ]]; then
uninstall_hostname=$(hostname 2>/dev/null || true)
fi
[[ -n "${AGENT_ID:-}" ]] && uninstall_args+=(--agent-id "$AGENT_ID")
[[ -n "$uninstall_hostname" ]] && uninstall_args+=(--hostname "$uninstall_hostname")
[[ ${#uninstall_args[@]} -gt 1 ]] || return 1
removed_agent_id=$(run_collector_lifecycle_command "${uninstall_args[@]}" 2>/dev/null) || return 1
[[ -n "$removed_agent_id" ]] || return 1
if [[ -n "${AGENT_ID:-}" && "$removed_agent_id" != "$AGENT_ID" ]]; then
return 1
fi
AGENT_ID="$removed_agent_id"
}
# verify_agent_server_registration_with_retry polls the server-side lookup for
# a short window before declaring registration unconfirmed. The local /readyz
# endpoint flips before the agent's first report cycle completes, so a single
@@ -2205,7 +2248,7 @@ safe_profile_begin_transaction() {
prior_profile=$(safe_profile_detect_current_profile)
printf '%s\n' \
"FORMAT_VERSION=2" \
"FORMAT_VERSION=3" \
"PRIOR_PROFILE=${prior_profile}" \
"TARGET_PROFILE=typed-helper-monitoring-only" \
"STATE_DIR=${STATE_DIR}" \
@@ -2241,6 +2284,9 @@ safe_profile_begin_transaction() {
safe_profile_snapshot_entry "${STATE_DIR%/}/runtime.token" runtime-token RUNTIME_TOKEN
safe_profile_snapshot_entry "${STATE_DIR%/}/agent-id" agent-id AGENT_ID_FILE
safe_profile_snapshot_entry "${STATE_DIR%/}/connection.env" connection-env CONNECTION_ENV
safe_profile_snapshot_entry "${INSTALLER_LIFECYCLE_DIR%/}/connection.env" lifecycle-connection-env LIFECYCLE_CONNECTION_ENV
safe_profile_snapshot_entry "${INSTALLER_LIFECYCLE_DIR%/}/install.sh" lifecycle-install-script LIFECYCLE_INSTALL_SCRIPT
safe_profile_snapshot_entry "${INSTALLER_LIFECYCLE_DIR%/}/install.sh.sha256" lifecycle-install-checksum LIFECYCLE_INSTALL_CHECKSUM
safe_profile_snapshot_entry "${STATE_DIR%/}/proxmox-registered" proxmox-registered PROXMOX_REGISTERED
safe_profile_snapshot_entry "${STATE_DIR%/}/proxmox-pve-registered" proxmox-pve-registered PROXMOX_PVE_REGISTERED
safe_profile_snapshot_entry "${STATE_DIR%/}/proxmox-pbs-registered" proxmox-pbs-registered PROXMOX_PBS_REGISTERED
@@ -2307,7 +2353,7 @@ safe_profile_restore_transaction() {
esac
[[ -d "$transaction_dir" && ! -L "$transaction_dir" && -f "$manifest_file" && ! -L "$manifest_file" ]] || return 1
format_version=$(safe_profile_manifest_value "$manifest_file" FORMAT_VERSION)
[[ "$format_version" == "1" || "$format_version" == "2" ]] || return 1
[[ "$format_version" == "1" || "$format_version" == "2" || "$format_version" == "3" ]] || return 1
snapshot_state_dir=$(safe_profile_manifest_value "$manifest_file" STATE_DIR)
[[ -n "$snapshot_state_dir" && "$snapshot_state_dir" == /* && "$snapshot_state_dir" != "/" ]] || return 1
prior_profile=$(safe_profile_manifest_value "$manifest_file" PRIOR_PROFILE)
@@ -2331,7 +2377,12 @@ safe_profile_restore_transaction() {
safe_profile_restore_entry "$transaction_dir" runtime-token "${snapshot_state_dir%/}/runtime.token" RUNTIME_TOKEN
safe_profile_restore_entry "$transaction_dir" agent-id "${snapshot_state_dir%/}/agent-id" AGENT_ID_FILE
safe_profile_restore_entry "$transaction_dir" connection-env "${snapshot_state_dir%/}/connection.env" CONNECTION_ENV
if [[ "$format_version" == "2" ]]; then
if [[ "$format_version" == "3" ]]; then
safe_profile_restore_entry "$transaction_dir" lifecycle-connection-env "${INSTALLER_LIFECYCLE_DIR%/}/connection.env" LIFECYCLE_CONNECTION_ENV
safe_profile_restore_entry "$transaction_dir" lifecycle-install-script "${INSTALLER_LIFECYCLE_DIR%/}/install.sh" LIFECYCLE_INSTALL_SCRIPT
safe_profile_restore_entry "$transaction_dir" lifecycle-install-checksum "${INSTALLER_LIFECYCLE_DIR%/}/install.sh.sha256" LIFECYCLE_INSTALL_CHECKSUM
fi
if [[ "$format_version" == "2" || "$format_version" == "3" ]]; then
safe_profile_restore_entry "$transaction_dir" proxmox-registered "${snapshot_state_dir%/}/proxmox-registered" PROXMOX_REGISTERED
safe_profile_restore_entry "$transaction_dir" proxmox-pve-registered "${snapshot_state_dir%/}/proxmox-pve-registered" PROXMOX_PVE_REGISTERED
safe_profile_restore_entry "$transaction_dir" proxmox-pbs-registered "${snapshot_state_dir%/}/proxmox-pbs-registered" PROXMOX_PBS_REGISTERED
@@ -2346,7 +2397,7 @@ safe_profile_restore_transaction() {
else
rmdir "$PRIVILEGED_HELPER_CREDENTIAL_DIR" 2>/dev/null || true
fi
if [[ "$format_version" == "2" ]]; then
if [[ "$format_version" == "2" || "$format_version" == "3" ]]; then
safe_profile_restore_state_metadata "$transaction_dir" "$snapshot_state_dir" || return 1
fi
rm -f "$PRIVILEGED_HELPER_SOCKET_PATH"
@@ -2911,7 +2962,10 @@ complete_installation_flow() {
local verification_rc=0
save_connection_info "$state_dir"
if [[ "$CONNECTION_INFO_PERSISTED" != "true" ]]; then
save_connection_info "$state_dir"
CONNECTION_INFO_PERSISTED="true"
fi
verify_agent_started || verification_rc=$?
if [[ $verification_rc -eq 0 ]]; then
report_proxmox_registration_outcome "$state_dir" || true
@@ -2957,20 +3011,169 @@ select_platform_state_dir() {
if [[ "${STATE_DIR_SOURCE:-default}" == "default" ]]; then
STATE_DIR="$platform_default"
STATE_DIR_SOURCE="platform"
STATE_DIR_REMOVAL_AUTHORITY="$STATE_DIR"
fi
}
portable_path_uid() {
stat -c '%u' "$1" 2>/dev/null || stat -f '%u' "$1" 2>/dev/null
}
portable_path_mode() {
stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null
}
trusted_lifecycle_regular_file() {
local path="$1"
local expected_mode="$2"
local parent=""
local effective_uid=""
local file_uid=""
local file_mode=""
local parent_uid=""
local parent_mode=""
[[ "$path" == /* && -f "$path" && ! -L "$path" ]] || return 1
parent=$(dirname "$path")
[[ -d "$parent" && ! -L "$parent" ]] || return 1
effective_uid=$(id -u) || return 1
file_uid=$(portable_path_uid "$path") || return 1
file_mode=$(portable_path_mode "$path") || return 1
parent_uid=$(portable_path_uid "$parent") || return 1
parent_mode=$(portable_path_mode "$parent") || return 1
[[ "$file_uid" == "$effective_uid" && "$file_mode" == "$expected_mode" &&
"$parent_uid" == "$effective_uid" && "$parent_mode" =~ ^[0-7]{3,4}$ ]] || return 1
(( (8#$parent_mode & 0022) == 0 ))
}
trusted_connection_state_file() {
trusted_lifecycle_regular_file "$1" 600
}
trusted_private_lifecycle_regular_file() {
local path="$1"
local parent=""
local effective_uid=""
local file_uid=""
local file_mode=""
local parent_uid=""
local parent_mode=""
[[ "$path" == /* && -f "$path" && ! -L "$path" ]] || return 1
parent=$(dirname "$path")
[[ -d "$parent" && ! -L "$parent" ]] || return 1
effective_uid=$(id -u) || return 1
file_uid=$(portable_path_uid "$path") || return 1
file_mode=$(portable_path_mode "$path") || return 1
parent_uid=$(portable_path_uid "$parent") || return 1
parent_mode=$(portable_path_mode "$parent") || return 1
[[ "$file_uid" == "$effective_uid" && "$file_mode" =~ ^[0-7]{3,4}$ &&
"$parent_uid" == "$effective_uid" && "$parent_mode" =~ ^[0-7]{3,4}$ ]] || return 1
(( (8#$file_mode & 0077) == 0 && (8#$parent_mode & 0022) == 0 ))
}
installer_file_sha256() {
sha256sum "$1" 2>/dev/null | awk '{print $1}' ||
shasum -a 256 "$1" 2>/dev/null | awk '{print $1}'
}
sync_lifecycle_path() {
local path="$1"
if command -v sync >/dev/null 2>&1; then
sync -f "$path" 2>/dev/null || sync >/dev/null 2>&1 || return 1
fi
}
prepare_installer_lifecycle_dir() {
local lifecycle_dir="$1"
[[ -n "$lifecycle_dir" && "$lifecycle_dir" == /* && "$lifecycle_dir" != "/" ]] || return 1
[[ ! -L "$lifecycle_dir" ]] || return 1
mkdir -p "$lifecycle_dir" || return 1
[[ -d "$lifecycle_dir" && ! -L "$lifecycle_dir" ]] || return 1
if [[ "$(id -u)" == "0" ]]; then
if [[ "$LEAST_PRIVILEGE" == "true" ]]; then
chown "root:${LEAST_PRIVILEGE_USER}" "$lifecycle_dir" || return 1
chmod 0750 "$lifecycle_dir" || return 1
else
chown root:root "$lifecycle_dir" || return 1
chmod 0700 "$lifecycle_dir" || return 1
fi
else
chmod 0700 "$lifecycle_dir" || return 1
fi
}
install_lifecycle_file_atomically() {
local source_path="$1"
local target_path="$2"
local target_mode="$3"
local target_dir=""
local target_name=""
local target_tmp=""
target_dir=$(dirname "$target_path")
target_name=$(basename "$target_path")
[[ -f "$source_path" && ! -L "$source_path" && -d "$target_dir" && ! -L "$target_dir" ]] || return 1
target_tmp=$(mktemp "${target_dir}/.${target_name}.XXXXXX") || return 1
TMP_FILES+=("$target_tmp")
cp "$source_path" "$target_tmp" || return 1
chmod "$target_mode" "$target_tmp" || return 1
if [[ "$(id -u)" == "0" ]]; then
chown root:root "$target_tmp" || return 1
fi
sync_lifecycle_path "$target_tmp" || return 1
mv -f "$target_tmp" "$target_path" || return 1
sync_lifecycle_path "$target_path" || return 1
sync_lifecycle_path "$target_dir" || return 1
}
verify_saved_installer_self_integrity() {
local script_path="${1:-$0}"
local script_dir=""
local lifecycle_dir=""
local expected=""
local actual=""
local checksum_path=""
[[ -f "$script_path" && ! -L "$script_path" ]] || return 0
[[ -d "$INSTALLER_LIFECYCLE_DIR" && ! -L "$INSTALLER_LIFECYCLE_DIR" ]] || return 0
script_dir=$(cd "$(dirname "$script_path")" 2>/dev/null && pwd -P) || return 1
lifecycle_dir=$(cd "$INSTALLER_LIFECYCLE_DIR" 2>/dev/null && pwd -P) || return 1
if [[ "$script_dir" != "$lifecycle_dir" || "$(basename "$script_path")" != "install.sh" ]]; then
return 0
fi
checksum_path="${script_dir}/install.sh.sha256"
trusted_lifecycle_regular_file "$script_path" 700 || return 1
trusted_lifecycle_regular_file "$checksum_path" 600 || return 1
expected=$(awk 'NR == 1 { print $1; exit }' "$checksum_path" 2>/dev/null || true)
[[ "$expected" =~ ^[a-f0-9]{64}$ ]] || return 1
actual=$(installer_file_sha256 "$script_path")
[[ -n "$actual" && "$actual" == "$expected" ]]
}
discover_state_dir_from_saved_installer() {
local script_path="${1:-$0}"
local script_dir=""
local conn_env=""
local saved_state_dir=""
if [[ "${STATE_DIR_SOURCE:-default}" != "default" || ! -f "$script_path" ]]; then
return 1
fi
script_dir=$(cd "$(dirname "$script_path")" 2>/dev/null && pwd -P) || return 1
if [[ -f "$script_dir/connection.env" ]]; then
STATE_DIR="$script_dir"
conn_env="${script_dir}/connection.env"
if trusted_connection_state_file "$conn_env"; then
saved_state_dir=$(read_connection_state_value "$conn_env" "PULSE_STATE_DIR")
if [[ -z "$saved_state_dir" && "$script_dir" != "$INSTALLER_LIFECYCLE_DIR" ]]; then
saved_state_dir="$script_dir"
fi
[[ -n "$saved_state_dir" && "$saved_state_dir" == /* && "$saved_state_dir" != "/" &&
"$saved_state_dir" != *$'\r'* && "$saved_state_dir" != *$'\n'* ]] || return 1
STATE_DIR="$saved_state_dir"
STATE_DIR_SOURCE="recovered"
STATE_DIR_REMOVAL_AUTHORITY="$saved_state_dir"
return 0
fi
return 1
@@ -2984,6 +3187,10 @@ remove_agent_state_dir() {
log_warn "Refusing to remove invalid agent state directory: ${state_dir:-<empty>}"
return 1
fi
if [[ -z "${STATE_DIR_REMOVAL_AUTHORITY:-}" || "$state_dir" != "$STATE_DIR_REMOVAL_AUTHORITY" ]]; then
log_warn "Refusing to remove agent state directory without exact trusted lifecycle authority: $state_dir"
return 1
fi
rm -rf -- "$state_dir"
}
@@ -3888,7 +4095,7 @@ read_connection_state_value() {
local file="$1"
local key="$2"
if [[ ! -f "$file" ]]; then
if ! trusted_connection_state_file "$file"; then
return 0
fi
@@ -3918,8 +4125,7 @@ recover_token_from_default_agent_token_file() {
token_paths+=("${DEFAULT_STATE_DIR:-/var/lib/pulse-agent}/token" "$TRUENAS_STATE_DIR/token")
fi
for token_path in "${token_paths[@]}"; do
[[ -n "$token_path" && -f "$token_path" ]] || continue
recovered_token=$(cat "$token_path" 2>/dev/null || true)
recovered_token=$(read_collector_token_file_safely "$token_path" 2>/dev/null || true)
if [[ -n "$recovered_token" ]]; then
PULSE_TOKEN="$recovered_token"
return 0
@@ -3933,12 +4139,15 @@ recover_connection_state() {
local file="$1"
local saved_state_dir=""
trusted_connection_state_file "$file" || return 1
saved_state_dir=$(read_connection_state_value "$file" "PULSE_STATE_DIR")
if [[ -n "$saved_state_dir" && "$saved_state_dir" == /* && "$saved_state_dir" != "/" &&
"$saved_state_dir" != *$'\r'* && "$saved_state_dir" != *$'\n'* &&
"${STATE_DIR_SOURCE:-default}" == "default" ]]; then
STATE_DIR="$saved_state_dir"
STATE_DIR_SOURCE="recovered"
STATE_DIR_REMOVAL_AUTHORITY="$saved_state_dir"
fi
if [[ -z "$PULSE_URL" ]]; then
@@ -3950,8 +4159,8 @@ recover_connection_state() {
if [[ -z "$PULSE_TOKEN" ]]; then
local saved_token_file=""
saved_token_file=$(read_connection_state_value "$file" "PULSE_TOKEN_FILE")
if [[ -n "$saved_token_file" && -f "$saved_token_file" ]]; then
PULSE_TOKEN=$(cat "$saved_token_file")
if [[ -n "$saved_token_file" ]]; then
PULSE_TOKEN=$(read_collector_token_file_safely "$saved_token_file" 2>/dev/null || true)
fi
fi
if [[ -z "$PULSE_TOKEN" && -n "$PULSE_URL" ]]; then
@@ -4017,8 +4226,8 @@ apply_recovered_agent_arg_value() {
RECOVERED_AGENT_ARG_STATE="true"
;;
token-file)
if [[ -z "$PULSE_TOKEN" && -n "$value" && -f "$value" ]]; then
PULSE_TOKEN=$(cat "$value")
if [[ -z "$PULSE_TOKEN" && -n "$value" ]]; then
PULSE_TOKEN=$(read_collector_token_file_safely "$value" 2>/dev/null || true)
fi
RECOVERED_AGENT_ARG_STATE="true"
;;
@@ -4257,8 +4466,8 @@ recover_connection_state_from_env_stream() {
;;
PULSE_TOKEN_FILE=*)
value="${env_line#*=}"
if [[ -z "$PULSE_TOKEN" && -n "$value" && -f "$value" ]]; then
PULSE_TOKEN=$(cat "$value")
if [[ -z "$PULSE_TOKEN" && -n "$value" ]]; then
PULSE_TOKEN=$(read_collector_token_file_safely "$value" 2>/dev/null || true)
fi
RECOVERED_AGENT_ENV_STATE="true"
;;
@@ -4607,13 +4816,13 @@ recover_connection_state_from_existing_agent() {
find_connection_state_file() {
local conn_env=""
local qnap_state_dir=""
local conn_paths=("${STATE_DIR%/}/connection.env")
local conn_paths=("${INSTALLER_LIFECYCLE_DIR%/}/connection.env" "${STATE_DIR%/}/connection.env")
if [[ "${STATE_DIR_SOURCE:-default}" == "default" ]]; then
conn_paths+=("${DEFAULT_STATE_DIR:-/var/lib/pulse-agent}/connection.env" /boot/config/plugins/pulse-agent/connection.env "$TRUENAS_STATE_DIR/connection.env")
fi
for conn_env in "${conn_paths[@]}"; do
if [[ -f "$conn_env" ]]; then
if trusted_connection_state_file "$conn_env"; then
printf '%s\n' "$conn_env"
return 0
fi
@@ -4621,7 +4830,7 @@ find_connection_state_file() {
if [[ "${STATE_DIR_SOURCE:-default}" == "default" ]]; then
qnap_state_dir=$(find_qnap_state_dir || true)
if [[ -n "$qnap_state_dir" ]] && [[ -f "$qnap_state_dir/connection.env" ]]; then
if [[ -n "$qnap_state_dir" ]] && trusted_connection_state_file "$qnap_state_dir/connection.env"; then
printf '%s\n' "$qnap_state_dir/connection.env"
return 0
fi
@@ -4630,6 +4839,90 @@ find_connection_state_file() {
return 1
}
read_collector_token_file_safely() {
local token_path="$1"
local explicit_path="${2:-false}"
local lifecycle_binary=""
local collector_uid=""
local token_value=""
local token_size=""
local -a token_args
[[ -n "$token_path" && "$token_path" == /* ]] || return 1
if [[ "$explicit_path" != "true" ]]; then
case "$token_path" in
"${STATE_DIR%/}/token"|"${STATE_DIR%/}/runtime.token"|"${PRIVILEGED_HELPER_CREDENTIAL_DIR:-/etc/pulse-agent}/token") ;;
*)
if [[ -z "${RUNTIME_TOKEN_FILE:-}" || "$token_path" != "$RUNTIME_TOKEN_FILE" ]]; then
return 1
fi
;;
esac
fi
[[ -e "$token_path" || -L "$token_path" || -p "$token_path" ]] || return 1
lifecycle_binary=$(collector_lifecycle_binary 2>/dev/null || true)
if [[ -n "$lifecycle_binary" ]] && trusted_lifecycle_regular_file "$lifecycle_binary" 755; then
token_args=(collector-read-token --token-file "$token_path")
collector_uid=$(id -u "${LEAST_PRIVILEGE_USER:-pulse-agent}" 2>/dev/null || true)
if [[ "$collector_uid" =~ ^[0-9]+$ ]]; then
token_args+=(--token-owner-uid "$collector_uid")
fi
if token_value=$("$lifecycle_binary" "${token_args[@]}" 2>/dev/null); then
printf '%s\n' "$token_value"
return 0
fi
fi
# Legacy root-owned token files may predate the descriptor-safe lifecycle
# command. Only a private regular file under a trusted parent can use this
# compatibility path; collector-owned state requires the Go reader.
if trusted_private_lifecycle_regular_file "$token_path"; then
token_size=$(wc -c < "$token_path" 2>/dev/null | tr -d ' ' || true)
[[ "$token_size" =~ ^[0-9]+$ && "$token_size" -ge 1 && "$token_size" -le 4096 ]] || return 1
IFS= read -r token_value < "$token_path" || true
if [[ -n "$token_value" && "$token_value" != *$'\r'* && "$token_value" != *$'\n'* ]]; then
printf '%s\n' "$token_value"
return 0
fi
fi
return 1
}
read_agent_id_file_safely() {
local aid_path="$1"
local lifecycle_binary=""
local collector_uid=""
local identity=""
local -a identity_args
[[ -e "$aid_path" || -L "$aid_path" || -p "$aid_path" ]] || return 1
lifecycle_binary=$(collector_lifecycle_binary 2>/dev/null || true)
if [[ -n "$lifecycle_binary" ]] && trusted_lifecycle_regular_file "$lifecycle_binary" 755; then
identity_args=(collector-read-agent-id --agent-id-file "$aid_path")
collector_uid=$(id -u "$LEAST_PRIVILEGE_USER" 2>/dev/null || true)
if [[ "$collector_uid" =~ ^[0-9]+$ ]]; then
identity_args+=(--token-owner-uid "$collector_uid")
fi
if identity=$("$lifecycle_binary" "${identity_args[@]}" 2>/dev/null); then
printf '%s\n' "$identity"
return 0
fi
fi
# Legacy root-owned installations may predate the descriptor-safe helper
# command. Their parent and file are not writable by the runtime, so a
# bounded shell read remains a boundary-only compatibility path.
if trusted_lifecycle_regular_file "$aid_path" 600; then
IFS= read -r identity < "$aid_path" || true
if [[ ${#identity} -ge 1 && ${#identity} -le 128 && "$identity" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]*$ ]]; then
printf '%s\n' "$identity"
return 0
fi
fi
return 1
}
recover_agent_id_from_state_file() {
local aid_path=""
local qnap_state_dir=""
@@ -4647,8 +4940,7 @@ recover_agent_id_from_state_file() {
fi
for aid_path in "${aid_paths[@]}"; do
if [[ -f "$aid_path" ]]; then
cat "$aid_path"
if read_agent_id_file_safely "$aid_path"; then
return 0
fi
done
@@ -4659,18 +4951,34 @@ recover_agent_id_from_state_file() {
# Save install script and connection details for offline uninstall
save_connection_info() {
local state_dir="$1"
local conn_env="${state_dir}/connection.env"
local lifecycle_dir="$state_dir"
local conn_env=""
local conn_tmp=""
local installer_source=""
local installer_tmp=""
local lifecycle_binary=""
local installer_signature=""
local checksum_tmp=""
local installer_sha=""
local old_umask=""
if [[ "$LEAST_PRIVILEGE" == "true" ]]; then
lifecycle_dir="$INSTALLER_LIFECYCLE_DIR"
fi
conn_env="${lifecycle_dir%/}/connection.env"
old_umask=$(umask)
umask 077
mkdir -p "$state_dir"
chmod 700 "$state_dir"
if [[ "$LEAST_PRIVILEGE" != "true" ]]; then
chmod 700 "$state_dir"
fi
prepare_installer_lifecycle_dir "$lifecycle_dir" ||
fail "Refusing unsafe installer lifecycle directory: ${lifecycle_dir}" "$EXIT_GENERAL"
# Save connection details so uninstall can deregister without --url/--token.
# Single-quote values to prevent shell interpretation on read-back.
# Legacy connection files may contain PULSE_TOKEN, but new installs persist
# only the protected token file path.
conn_tmp=$(mktemp "${state_dir}/.connection.env.XXXXXX")
conn_tmp=$(mktemp "${lifecycle_dir%/}/.connection.env.XXXXXX")
TMP_FILES+=("$conn_tmp")
write_connection_state_value "$conn_tmp" "PULSE_STATE_DIR" "$state_dir"
write_connection_state_value "$conn_tmp" "PULSE_URL" "$PULSE_URL"
@@ -4683,31 +4991,58 @@ save_connection_info() {
fi
write_connection_state_value "$conn_tmp" "PULSE_SERVER_FINGERPRINT" "$SERVER_FINGERPRINT"
write_connection_state_value "$conn_tmp" "PULSE_CACERT" "$CURL_CA_BUNDLE"
chmod 600 "$conn_tmp"
mv -f "$conn_tmp" "$conn_env"
umask "$old_umask"
install_lifecycle_file_atomically "$conn_tmp" "$conn_env" 0600 ||
fail "Failed to persist protected installer lifecycle state" "$EXIT_GENERAL"
# Save a copy of this install script for offline uninstall.
# When run via "curl | bash", $0 is /dev/stdin — not a usable file.
# Try local copy first, then download a fresh copy from the server.
local saved=false
if [[ -f "$0" && "$0" != "/dev/stdin" && "$0" != "bash" && "$0" != "-bash" ]]; then
if cp "$0" "${state_dir}/install.sh" 2>/dev/null; then
saved=true
installer_source="$0"
fi
if [[ -z "$installer_source" ]]; then
# stdin installs have no local source file. Persist a fresh copy only
# when the installed root-owned lifecycle binary can enforce the same
# CA/fingerprint/no-proxy policy and the embedded release key can verify
# the server-provided SSH signature.
installer_tmp=$(mktemp "${lifecycle_dir%/}/.install-source.XXXXXX")
TMP_FILES+=("$installer_tmp")
lifecycle_binary=$(collector_lifecycle_binary 2>/dev/null || true)
if has_pinned_installer_signature_key &&
[[ -n "$lifecycle_binary" ]] && trusted_lifecycle_regular_file "$lifecycle_binary" 755; then
local -a download_args=(collector-download-installer --url "$PULSE_URL" --output "$installer_tmp")
[[ -n "$CURL_CA_BUNDLE" ]] && download_args+=(--cacert "$CURL_CA_BUNDLE")
[[ -n "$SERVER_FINGERPRINT" ]] && download_args+=(--server-fingerprint "$SERVER_FINGERPRINT")
if installer_signature=$("$lifecycle_binary" "${download_args[@]}" 2>/dev/null) &&
[[ -n "$installer_signature" ]]; then
verify_download_signature "$installer_tmp" "$installer_signature"
installer_source="$installer_tmp"
fi
fi
if [[ -z "$installer_source" ]]; then
log_warn "Offline installer was not saved because an authenticated signed installer source was unavailable. Download a fresh installer from Pulse when removal is needed."
fi
fi
if [[ "$saved" != "true" ]]; then
# Download from the server (we know it's reachable — we just installed from it)
local dl_args=(-fsSL --connect-timeout 10 --max-time 30)
if [[ "$INSECURE" == "true" ]]; then dl_args+=(-k); fi
if [[ -n "$CURL_CA_BUNDLE" ]]; then dl_args+=(--cacert "$CURL_CA_BUNDLE"); fi
curl "${dl_args[@]}" -o "${state_dir}/install.sh" "${PULSE_URL}/install.sh" 2>/dev/null || true
fi
if [[ -f "${state_dir}/install.sh" ]]; then
chmod +x "${state_dir}/install.sh"
SAVED_INSTALL_SCRIPT="${state_dir}/install.sh"
if [[ -n "$installer_source" && -f "$installer_source" && ! -L "$installer_source" ]]; then
install_lifecycle_file_atomically "$installer_source" "${lifecycle_dir%/}/install.sh" 0700 ||
fail "Failed to persist the protected offline installer" "$EXIT_GENERAL"
installer_sha=$(installer_file_sha256 "${lifecycle_dir%/}/install.sh")
[[ "$installer_sha" =~ ^[a-f0-9]{64}$ ]] ||
fail "Failed to hash the protected offline installer" "$EXIT_GENERAL"
checksum_tmp=$(mktemp "${lifecycle_dir%/}/.install-sha.XXXXXX")
TMP_FILES+=("$checksum_tmp")
printf '%s install.sh\n' "$installer_sha" > "$checksum_tmp"
install_lifecycle_file_atomically "$checksum_tmp" "${lifecycle_dir%/}/install.sh.sha256" 0600 ||
fail "Failed to persist the protected offline installer checksum" "$EXIT_GENERAL"
SAVED_INSTALL_SCRIPT="${lifecycle_dir%/}/install.sh"
saved=true
else
SAVED_INSTALL_SCRIPT=""
fi
if [[ "$lifecycle_dir" != "$state_dir" ]]; then
rm -f -- "${state_dir%/}/connection.env" "${state_dir%/}/install.sh" "${state_dir%/}/install.sh.sha256"
fi
umask "$old_umask"
}
# --- Parse Arguments ---
@@ -4761,7 +5096,7 @@ while [[ $# -gt 0 ]]; do
--agent-id) AGENT_ID="$2"; shift 2 ;;
--hostname) HOSTNAME_OVERRIDE="$2"; shift 2 ;;
--report-ip) REPORT_IP="$2"; shift 2 ;;
--state-dir) STATE_DIR="$2"; STATE_DIR_SOURCE="explicit"; shift 2 ;;
--state-dir) STATE_DIR="$2"; STATE_DIR_SOURCE="explicit"; STATE_DIR_REMOVAL_AUTHORITY="$2"; shift 2 ;;
--kube-include-all-pods) KUBE_INCLUDE_ALL_PODS="true"; shift ;;
--kube-include-all-deployments) KUBE_INCLUDE_ALL_DEPLOYMENTS="true"; shift ;;
--disk-exclude) DISK_EXCLUDES+=("$2"); shift 2 ;;
@@ -4813,6 +5148,8 @@ case "$SAFE_PROFILE_ACTION" in
*) fail "Internal safe-profile action is invalid" "$EXIT_GENERAL" ;;
esac
verify_saved_installer_self_integrity "$0" ||
fail "Saved Pulse installer integrity verification failed; use a freshly authenticated installer instead" "$EXIT_SIGNATURE_FAILED"
discover_state_dir_from_saved_installer "$0" || true
if [[ -z "$STATE_DIR" || "$STATE_DIR" != /* || "$STATE_DIR" == "/" ||
@@ -4831,12 +5168,9 @@ fi
# Read token from file if --token-file was provided
if [[ -n "$TOKEN_FILE_PATH" ]]; then
if [[ ! -f "$TOKEN_FILE_PATH" ]]; then
fail "Token file not found: ${TOKEN_FILE_PATH}" "$EXIT_MISSING_ARGS"
fi
PULSE_TOKEN=$(cat "$TOKEN_FILE_PATH")
PULSE_TOKEN=$(read_collector_token_file_safely "$TOKEN_FILE_PATH" true 2>/dev/null || true)
if [[ -z "$PULSE_TOKEN" ]]; then
fail "Token file is empty: ${TOKEN_FILE_PATH}" "$EXIT_MISSING_ARGS"
fail "Token file must be a readable private regular file containing one bounded token: ${TOKEN_FILE_PATH}" "$EXIT_MISSING_ARGS"
fi
# Clean up token file after reading in non-interactive mode (deploy bootstrap tokens are one-time use)
if [[ "$NON_INTERACTIVE" == "true" ]]; then
@@ -4856,7 +5190,7 @@ if [[ -n "$ACTION_TOKEN_FILE_PATH" ]]; then
if [[ ! "$ACTION_TOKEN_SIZE" =~ ^[0-9]+$ || "$ACTION_TOKEN_SIZE" -lt 1 || "$ACTION_TOKEN_SIZE" -gt 4096 ]]; then
fail "Action token file must contain between 1 and 4096 bytes" "$EXIT_MISSING_ARGS"
fi
ACTION_TOKEN=$(cat "$ACTION_TOKEN_FILE_PATH")
ACTION_TOKEN=$(read_collector_token_file_safely "$ACTION_TOKEN_FILE_PATH" true 2>/dev/null || true)
if [[ -z "$ACTION_TOKEN" || "$ACTION_TOKEN" == *$'\r'* || "$ACTION_TOKEN" == *$'\n'* ]]; then
fail "Action token file must contain one non-empty token value" "$EXIT_MISSING_ARGS"
fi
@@ -5154,63 +5488,17 @@ if [[ "$UNINSTALL" == "true" ]]; then
log_info "Uninstalling ${AGENT_NAME} and cleaning up legacy agents..."
local qnap_state_dir=""
# Try to notify the Pulse server about uninstallation if we have connection details
# This ensures the agent record is removed and any linked PVE nodes are updated immediately.
if [[ -n "$PULSE_URL" ]]; then
# Try to recover agent ID if not provided.
# Priority: agent-id file (canonical) > hostname API lookup (fallback)
if [[ -z "$AGENT_ID" ]]; then
local aid_path=""
local aid_paths=("${STATE_DIR%/}/agent-id")
if [[ "$STATE_DIR_SOURCE" == "default" ]]; then
aid_paths+=("$DEFAULT_STATE_DIR/agent-id" /boot/config/plugins/pulse-agent/agent-id "$TRUENAS_STATE_DIR/agent-id")
fi
qnap_state_dir=$(find_qnap_state_dir || true)
if [[ -n "$qnap_state_dir" ]]; then
aid_paths+=("$qnap_state_dir/agent-id")
fi
# Primary: canonical agent-id file
for aid_path in "${aid_paths[@]}"; do
if [[ -f "$aid_path" ]]; then
AGENT_ID=$(cat "$aid_path")
log_info "Recovered agent ID from ${aid_path}"
break
fi
done
fi
if [[ -z "$AGENT_ID" ]]; then
# API fallback: prefer explicit hostname continuity from the caller,
# otherwise fall back to the local hostname.
LOOKUP_HOSTNAME="$HOSTNAME_OVERRIDE"
if [[ -z "$LOOKUP_HOSTNAME" ]]; then
LOOKUP_HOSTNAME=$(hostname 2>/dev/null || true)
fi
if [[ -n "$LOOKUP_HOSTNAME" ]]; then
LOOKUP_ARGS=(-fsSL --connect-timeout 5)
if [[ "$INSECURE" == "true" ]]; then LOOKUP_ARGS+=(-k); fi
if [[ -n "$CURL_CA_BUNDLE" ]]; then LOOKUP_ARGS+=(--cacert "$CURL_CA_BUNDLE"); fi
LOOKUP_HOSTNAME_ESCAPED=$(url_encode "$LOOKUP_HOSTNAME")
LOOKUP_RESP=$(curl_with_pulse_token "${LOOKUP_ARGS[@]}" "${PULSE_URL}/api/agents/agent/lookup?hostname=${LOOKUP_HOSTNAME_ESCAPED}" 2>/dev/null || true)
if [[ -n "$LOOKUP_RESP" ]]; then
# Extract .agent.id from JSON (portable, no jq dependency)
AGENT_ID=$(echo "$LOOKUP_RESP" | grep -o '"id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"id"[[:space:]]*:[[:space:]]*"//; s/"$//' || true)
if [[ -n "$AGENT_ID" ]]; then
log_info "Recovered agent ID via server lookup: ${AGENT_ID}"
fi
fi
fi
fi
if [[ -n "$AGENT_ID" ]]; then
log_info "Notifying Pulse server to unregister agent ID: ${AGENT_ID}..."
CURL_ARGS=(-fsSL --connect-timeout 5 -X POST -H "Content-Type: application/json")
if [[ "$INSECURE" == "true" ]]; then CURL_ARGS+=(-k); fi
if [[ -n "$CURL_CA_BUNDLE" ]]; then CURL_ARGS+=(--cacert "$CURL_CA_BUNDLE"); fi
# Send unregistration request (ignore errors as we are uninstalling anyway)
curl_with_pulse_token "${CURL_ARGS[@]}" -d "{\"agentId\": \"${AGENT_ID}\"}" "${PULSE_URL}/api/agents/agent/uninstall" >/dev/null 2>&1 || true
# A credential-bearing install must durably remove its exact server record
# through the same CA/fingerprint/no-proxy transport used by lifecycle
# migration before any local credential or service state is deleted.
if [[ -n "$PULSE_URL" ]] && collector_credential_state_present; then
log_info "Authenticating Pulse server removal before local teardown..."
if ! uninstall_collector_registration; then
fail "Pulse did not durably confirm collector removal; local credentials and services were retained. Restore trusted server connectivity and retry uninstall." "$EXIT_GENERAL"
fi
log_info "Pulse durably removed agent ID: ${AGENT_ID}."
elif [[ -n "$PULSE_URL" ]]; then
log_warn "No local collector credential exists; continuing with local-only removal."
fi
# Kill wrapper scripts first: they are watchdogs, so stopping the agent
@@ -5236,7 +5524,9 @@ if [[ "$UNINSTALL" == "true" ]]; then
# Remove legacy binaries
# Remove agent state directory (contains agent ID, proxmox registration state, etc.)
remove_agent_state_dir "$STATE_DIR"
if ! remove_agent_state_dir "$STATE_DIR"; then
log_warn "Retained agent state at ${STATE_DIR}; its path was not authorized by explicit or protected lifecycle state."
fi
# Remove least-privilege helper artifacts. The pulse-agent system user is
# deliberately left behind: deleting accounts can orphan files elsewhere,
@@ -6803,6 +7093,8 @@ if command -v systemctl >/dev/null 2>&1; then
if ! safe_profile_verify_declared_health; then
fail "Safe-profile collector did not satisfy local readiness, helper availability, and server registration; restoring the previous profile" "$EXIT_GENERAL"
fi
save_connection_info "$STATE_DIR"
CONNECTION_INFO_PERSISTED="true"
safe_profile_commit_transaction ||
fail "Safe-profile health passed but its atomic profile record could not be committed; restoring the previous profile" "$EXIT_GENERAL"
fi
@@ -3,6 +3,7 @@ package installtests
import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"io"
"net/http"
@@ -18,6 +19,59 @@ import (
"time"
)
func TestInstallSHAgentIDRecoveryRejectsSymlinkFIFOAndOversizedState(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix descriptor-bound identity recovery")
}
binaryPath := buildLifecycleAgent(t)
root := t.TempDir()
validPath := filepath.Join(root, "valid-agent-id")
oversizedPath := filepath.Join(root, "oversized-agent-id")
symlinkPath := filepath.Join(root, "symlink-agent-id")
fifoPath := filepath.Join(root, "fifo-agent-id")
if err := os.WriteFile(validPath, []byte("agent-safe-123\n"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(oversizedPath, []byte(strings.Repeat("a", 5000)), 0600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(validPath, symlinkPath); err != nil {
t.Fatal(err)
}
if err := syscall.Mkfifo(fifoPath, 0600); err != nil {
t.Fatal(err)
}
harness := func(path string) ([]byte, error) {
script := `
set -euo pipefail
COLLECTOR_LIFECYCLE_BINARY_PATH="` + binaryPath + `"
INSTALL_DIR="` + root + `"
BINARY_NAME="pulse-agent"
LEAST_PRIVILEGE_USER="pulse-agent-test-missing"
` + extractLifecycleTrustShellFunctions(t) + `
` + extractInstallShellFunction(t, "collector_lifecycle_binary") + `
` + extractInstallShellFunction(t, "read_agent_id_file_safely") + `
read_agent_id_file_safely "` + path + `"
`
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
return exec.CommandContext(ctx, "bash", "-c", script).CombinedOutput()
}
if out, err := harness(validPath); err != nil || strings.TrimSpace(string(out)) != "agent-safe-123" {
t.Fatalf("valid descriptor-bound agent ID recovery failed: %v\n%s", err, out)
}
for _, path := range []string{symlinkPath, fifoPath, oversizedPath} {
started := time.Now()
if out, err := harness(path); err == nil {
t.Fatalf("unsafe agent ID path %s was accepted:\n%s", path, out)
}
if elapsed := time.Since(started); elapsed >= 2*time.Second {
t.Fatalf("unsafe agent ID path %s blocked for %s", path, elapsed)
}
}
}
type agentLifecycleControlPlane struct {
mu sync.Mutex
online bool
@@ -206,6 +260,8 @@ func renderLifecycleService(t *testing.T, stateDir, stateSource, unitPath, pulse
OBSERVERS_FILE=""
ENABLE_COMMANDS="` + commandFlag + `"
LEAST_PRIVILEGE="false"
LEAST_PRIVILEGE_USER="pulse-agent"
INSTALLER_LIFECYCLE_DIR="` + filepath.Join(filepath.Dir(stateDir), "installer-lifecycle") + `"
GRANT_SMART="false"
GRANT_PCT="false"
HEALTH_ADDR_SET="true"
@@ -223,14 +279,17 @@ func renderLifecycleService(t *testing.T, stateDir, stateSource, unitPath, pulse
SYSTEMD_ENV_LINES=""
SHELL_EXPORT_LINES=""
SAVED_INSTALL_SCRIPT=""
EXIT_GENERAL=1
TMP_FILES=()
NON_INTERACTIVE="true"
log_info() { :; }
log_warn() { :; }
fail() { printf 'FAIL:%s\n' "$1" >&2; return 99; }
curl() { return 1; }
` + extractLifecyclePersistenceShellFunctions(t) + `
` + extractInstallShellFunction(t, "write_connection_state_value") + `
` + extractInstallShellFunction(t, "read_connection_state_value") + `
` + extractInstallShellFunction(t, "recover_token_from_default_agent_token_file") + `
` + extractTokenRecoveryShellFunctions(t) + `
` + extractInstallShellFunction(t, "recover_connection_state") + `
` + extractInstallShellFunction(t, "ensure_runtime_token_file") + `
` + extractInstallShellFunction(t, "build_exec_arg_items") + `
@@ -442,6 +501,7 @@ func TestPulseAgentStateDirLifecycleIntegration(t *testing.T) {
removeScript := `
set -euo pipefail
STATE_DIR="` + stateDir + `"
STATE_DIR_REMOVAL_AUTHORITY="$STATE_DIR"
log_warn() { :; }
` + extractInstallShellFunction(t, "remove_agent_state_dir") + `
remove_agent_state_dir "$STATE_DIR"
+460 -25
View File
@@ -1,6 +1,8 @@
package installtests
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
@@ -803,11 +805,11 @@ func TestInstallSHUsesHostnameOverrideForUninstallLookup(t *testing.T) {
script := string(content)
required := []string{
`LOOKUP_HOSTNAME="$HOSTNAME_OVERRIDE"`,
`if [[ -z "$LOOKUP_HOSTNAME" ]]; then`,
`LOOKUP_HOSTNAME=$(hostname 2>/dev/null || true)`,
`LOOKUP_HOSTNAME_ESCAPED=$(url_encode "$LOOKUP_HOSTNAME")`,
`"${PULSE_URL}/api/agents/agent/lookup?hostname=${LOOKUP_HOSTNAME_ESCAPED}"`,
`local uninstall_hostname="${HOSTNAME_OVERRIDE:-}"`,
`if [[ -z "$uninstall_hostname" ]]; then`,
`uninstall_hostname=$(hostname 2>/dev/null || true)`,
`uninstall_args+=(--hostname "$uninstall_hostname")`,
`removed_agent_id=$(run_collector_lifecycle_command "${uninstall_args[@]}"`,
}
for _, needle := range required {
if !strings.Contains(script, needle) {
@@ -824,9 +826,9 @@ func TestInstallSHUrlEncodesHostnameLookupQuery(t *testing.T) {
script := string(content)
required := []string{
`url_encode() {`,
`printf -v encoded '%%%02X' "'$c"`,
`LOOKUP_HOSTNAME_ESCAPED=$(url_encode "$LOOKUP_HOSTNAME")`,
`collector-uninstall`,
`uninstall_args+=(--hostname "$uninstall_hostname")`,
`run_collector_lifecycle_command "${uninstall_args[@]}"`,
}
for _, needle := range required {
if !strings.Contains(script, needle) {
@@ -941,6 +943,7 @@ func TestInstallSHRetargetPreservesIdentityWithoutOldEndpointTrust(t *testing.T)
}
connectionPath := filepath.Join(stateDir, "connection.env")
connection := strings.Join([]string{
"PULSE_STATE_DIR='" + stateDir + "'",
"PULSE_URL='https://old-pulse.example.test:7655'",
"PULSE_TOKEN_FILE='" + tokenPath + "'",
"PULSE_AGENT_ID='agent-123'",
@@ -967,8 +970,9 @@ func TestInstallSHRetargetPreservesIdentityWithoutOldEndpointTrust(t *testing.T)
STATE_DIR_SOURCE="default"
DEFAULT_STATE_DIR="/var/lib/pulse-agent"
TRUENAS_STATE_DIR="/data/pulse-agent"
` + extractLifecycleTrustShellFunctions(t) + `
` + extractInstallShellFunction(t, "read_connection_state_value") + `
` + extractInstallShellFunction(t, "recover_token_from_default_agent_token_file") + `
` + extractTokenRecoveryShellFunctions(t) + `
` + extractInstallShellFunction(t, "recover_connection_state") + `
recover_connection_state "${PULSE_TEST_CONNECTION:?}"
printf 'URL=%s\nTOKEN=%s\nAGENT_ID=%s\nHOSTNAME=%s\nINSECURE=%s\nFINGERPRINT=%s\nCACERT=%s\n' \
@@ -1040,7 +1044,7 @@ func TestInstallSHRetargetDoesNotRecoverLegacyServiceTrust(t *testing.T) {
` + extractInstallShellFunction(t, "normalize_recovered_agent_arg_key") + `
` + extractInstallShellFunction(t, "apply_recovered_agent_arg_value") + `
` + extractInstallShellFunction(t, "recovered_connection_state_ready") + `
` + extractInstallShellFunction(t, "recover_token_from_default_agent_token_file") + `
` + extractTokenRecoveryShellFunctions(t) + `
` + extractInstallShellFunction(t, "recover_connection_state_from_arg_stream") + `
` + extractInstallShellFunction(t, "recover_connection_state_from_env_stream") + `
recover_connection_state_from_arg_stream <<'ARGS'
@@ -1120,7 +1124,7 @@ func TestInstallSHRecoversV5ProcessArgsForSavedStateUpdate(t *testing.T) {
` + extractInstallShellFunction(t, "normalize_recovered_agent_arg_key") + `
` + extractInstallShellFunction(t, "apply_recovered_agent_arg_value") + `
` + extractInstallShellFunction(t, "recovered_connection_state_ready") + `
` + extractInstallShellFunction(t, "recover_token_from_default_agent_token_file") + `
` + extractTokenRecoveryShellFunctions(t) + `
` + extractInstallShellFunction(t, "recover_connection_state_from_arg_stream") + `
` + extractInstallShellFunction(t, "build_exec_arg_items") + `
` + extractInstallShellFunction(t, "join_exec_arg_items") + `
@@ -1215,7 +1219,7 @@ func TestInstallSHRecoversV5ProcessArgsWithoutProcfs(t *testing.T) {
` + extractInstallShellFunction(t, "normalize_recovered_agent_arg_key") + `
` + extractInstallShellFunction(t, "apply_recovered_agent_arg_value") + `
` + extractInstallShellFunction(t, "recovered_connection_state_ready") + `
` + extractInstallShellFunction(t, "recover_token_from_default_agent_token_file") + `
` + extractTokenRecoveryShellFunctions(t) + `
` + extractInstallShellFunction(t, "recover_connection_state_from_arg_stream") + `
` + extractInstallShellFunction(t, "split_recovered_shell_words") + `
` + extractInstallShellFunction(t, "running_agent_arg_stream") + `
@@ -1307,7 +1311,7 @@ export PULSE_CACERT='/conf/pulse-ca.pem'
` + extractInstallShellFunction(t, "normalize_recovered_agent_arg_key") + `
` + extractInstallShellFunction(t, "apply_recovered_agent_arg_value") + `
` + extractInstallShellFunction(t, "recovered_connection_state_ready") + `
` + extractInstallShellFunction(t, "recover_token_from_default_agent_token_file") + `
` + extractTokenRecoveryShellFunctions(t) + `
` + extractInstallShellFunction(t, "recover_connection_state_from_arg_stream") + `
` + extractInstallShellFunction(t, "recover_connection_state_from_env_stream") + `
` + extractInstallShellFunction(t, "split_recovered_shell_words") + `
@@ -1383,7 +1387,7 @@ func TestInstallSHRejectsPartialRecoveredProcessConnectionState(t *testing.T) {
` + extractInstallShellFunction(t, "normalize_recovered_agent_arg_key") + `
` + extractInstallShellFunction(t, "apply_recovered_agent_arg_value") + `
` + extractInstallShellFunction(t, "recovered_connection_state_ready") + `
` + extractInstallShellFunction(t, "recover_token_from_default_agent_token_file") + `
` + extractTokenRecoveryShellFunctions(t) + `
` + extractInstallShellFunction(t, "recover_connection_state_from_arg_stream") + `
if recover_connection_state_from_arg_stream <<'ARGS'
/usr/local/bin/pulse-agent
@@ -1460,7 +1464,7 @@ func TestInstallSHRecoversLegacyDefaultTokenFileForSavedStateUpdate(t *testing.T
` + extractInstallShellFunction(t, "normalize_recovered_agent_arg_key") + `
` + extractInstallShellFunction(t, "apply_recovered_agent_arg_value") + `
` + extractInstallShellFunction(t, "recovered_connection_state_ready") + `
` + extractInstallShellFunction(t, "recover_token_from_default_agent_token_file") + `
` + extractTokenRecoveryShellFunctions(t) + `
` + extractInstallShellFunction(t, "recover_connection_state_from_arg_stream") + `
` + extractInstallShellFunction(t, "build_exec_arg_items") + `
` + extractInstallShellFunction(t, "join_exec_arg_items") + `
@@ -1546,7 +1550,7 @@ func TestInstallSHCombinesRecoveredProcessArgsAndEnvConnectionState(t *testing.T
` + extractInstallShellFunction(t, "normalize_recovered_agent_arg_key") + `
` + extractInstallShellFunction(t, "apply_recovered_agent_arg_value") + `
` + extractInstallShellFunction(t, "recovered_connection_state_ready") + `
` + extractInstallShellFunction(t, "recover_token_from_default_agent_token_file") + `
` + extractTokenRecoveryShellFunctions(t) + `
` + extractInstallShellFunction(t, "recover_connection_state_from_arg_stream") + `
` + extractInstallShellFunction(t, "recover_connection_state_from_env_stream") + `
if recover_connection_state_from_arg_stream <<'ARGS'
@@ -1624,7 +1628,7 @@ func TestInstallSHUpdateModeMergesExplicitURLWithRunningV5ProcessState(t *testin
` + extractInstallShellFunction(t, "apply_recovered_agent_arg_value") + `
` + extractInstallShellFunction(t, "recovered_connection_state_ready") + `
` + extractInstallShellFunction(t, "update_connection_state_incomplete") + `
` + extractInstallShellFunction(t, "recover_token_from_default_agent_token_file") + `
` + extractTokenRecoveryShellFunctions(t) + `
` + extractInstallShellFunction(t, "recover_connection_state_from_arg_stream") + `
` + extractInstallShellFunction(t, "build_exec_arg_items") + `
` + extractInstallShellFunction(t, "join_exec_arg_items") + `
@@ -1812,7 +1816,7 @@ func TestInstallSHUsesQNAPStateForUninstallRecovery(t *testing.T) {
required := []string{
`qnap_state_dir=$(find_qnap_state_dir || true)`,
`aid_paths+=("$qnap_state_dir/agent-id")`,
`if [[ -n "$qnap_state_dir" ]] && [[ -f "$qnap_state_dir/connection.env" ]]; then`,
`if [[ -n "$qnap_state_dir" ]] && trusted_connection_state_file "$qnap_state_dir/connection.env"; then`,
`remove_qnap_autorun_block "$AUTORUN_PATH"`,
}
for _, needle := range required {
@@ -2997,9 +3001,53 @@ func extractInstallShellFunction(t *testing.T, name string) string {
return string(match)
}
func extractLifecycleTrustShellFunctions(t *testing.T) string {
t.Helper()
names := []string{
"portable_path_uid",
"portable_path_mode",
"trusted_lifecycle_regular_file",
"trusted_connection_state_file",
}
var functions strings.Builder
for _, name := range names {
functions.WriteString(extractInstallShellFunction(t, name))
functions.WriteByte('\n')
}
return functions.String()
}
func extractLifecyclePersistenceShellFunctions(t *testing.T) string {
t.Helper()
names := []string{
"installer_file_sha256",
"sync_lifecycle_path",
"prepare_installer_lifecycle_dir",
"install_lifecycle_file_atomically",
"has_pinned_installer_signature_key",
}
var functions strings.Builder
functions.WriteString(extractLifecycleTrustShellFunctions(t))
for _, name := range names {
functions.WriteString(extractInstallShellFunction(t, name))
functions.WriteByte('\n')
}
return functions.String()
}
func extractTokenRecoveryShellFunctions(t *testing.T) string {
t.Helper()
return extractLifecycleTrustShellFunctions(t) +
extractInstallShellFunction(t, "trusted_private_lifecycle_regular_file") + "\n" +
extractInstallShellFunction(t, "collector_lifecycle_binary") + "\n" +
extractInstallShellFunction(t, "read_collector_token_file_safely") + "\n" +
extractInstallShellFunction(t, "recover_token_from_default_agent_token_file")
}
func extractCollectorLifecycleShellFunctions(t *testing.T, includeVerify bool) string {
t.Helper()
functions := extractInstallShellFunction(t, "collector_lifecycle_binary") + "\n" +
functions := extractLifecycleTrustShellFunctions(t) +
extractInstallShellFunction(t, "collector_lifecycle_binary") + "\n" +
extractInstallShellFunction(t, "prepare_collector_lifecycle_token_file") + "\n" +
extractInstallShellFunction(t, "run_collector_lifecycle_command")
if includeVerify {
@@ -3275,11 +3323,15 @@ func TestStateDirFlagIsAcceptedByInstallerParser(t *testing.T) {
KUBE_INCLUDE_ALL_DEPLOYMENTS="false"
DISK_EXCLUDES=()
STATE_DIR="/var/lib/pulse-agent"
STATE_DIR_SOURCE="default"
STATE_DIR_REMOVAL_AUTHORITY="$STATE_DIR"
CURL_CA_BUNDLE=""
NON_INTERACTIVE="false"
TOKEN_FILE_PATH=""
OUTPUT_FORMAT="text"
PREFLIGHT_ONLY="false"
verify_saved_installer_self_integrity() { return 0; }
discover_state_dir_from_saved_installer() { return 1; }
set -- --state-dir /tmp/pulse-agent-state --non-interactive --url https://pulse.example.com --token deadbeef
` + extractInstallShellSection(t, "# --- Parse Arguments ---", "# Read token from file if --token-file was provided") + `
printf 'STATE_DIR=%s\nNON_INTERACTIVE=%s\nPULSE_URL=%s\n' "$STATE_DIR" "$NON_INTERACTIVE" "$PULSE_URL"
@@ -3386,7 +3438,11 @@ func TestInstallSHExplicitCustomStateNeverFallsBackToDefaultInstance(t *testing.
STATE_DIR_SOURCE="explicit"
DEFAULT_STATE_DIR="` + defaultState + `"
TRUENAS_STATE_DIR="` + filepath.Join(root, "truenas") + `"
INSTALLER_LIFECYCLE_DIR="` + filepath.Join(root, "lifecycle") + `"
LEAST_PRIVILEGE_USER="pulse-agent"
` + extractLifecycleTrustShellFunctions(t) + `
` + extractInstallShellFunction(t, "find_connection_state_file") + `
` + extractInstallShellFunction(t, "read_agent_id_file_safely") + `
` + extractInstallShellFunction(t, "recover_agent_id_from_state_file") + `
printf 'connection=%s\n' "$(find_connection_state_file)"
rm -f "$STATE_DIR/connection.env"
@@ -3422,6 +3478,10 @@ func TestInstallSHSavedInstallerDiscoversItsCustomStateDir(t *testing.T) {
set -euo pipefail
STATE_DIR="/var/lib/pulse-agent"
STATE_DIR_SOURCE="default"
STATE_DIR_REMOVAL_AUTHORITY="/var/lib/pulse-agent"
INSTALLER_LIFECYCLE_DIR="` + filepath.Join(t.TempDir(), "lifecycle") + `"
` + extractLifecycleTrustShellFunctions(t) + `
` + extractInstallShellFunction(t, "read_connection_state_value") + `
` + extractInstallShellFunction(t, "discover_state_dir_from_saved_installer") + `
discover_state_dir_from_saved_installer "` + installerPath + `"
printf 'state=%s source=%s\n' "$STATE_DIR" "$STATE_DIR_SOURCE"
@@ -3511,7 +3571,7 @@ ExecStart=/usr/local/bin/pulse-agent --url https://custom.example --token-file `
` + extractInstallShellFunction(t, "normalize_recovered_agent_arg_key") + `
` + extractInstallShellFunction(t, "apply_recovered_agent_arg_value") + `
` + extractInstallShellFunction(t, "recovered_connection_state_ready") + `
` + extractInstallShellFunction(t, "recover_token_from_default_agent_token_file") + `
` + extractTokenRecoveryShellFunctions(t) + `
` + extractInstallShellFunction(t, "recover_connection_state_from_arg_stream") + `
` + extractInstallShellFunction(t, "recover_connection_state_from_env_stream") + `
` + extractInstallShellFunction(t, "split_recovered_shell_words") + `
@@ -3520,7 +3580,7 @@ ExecStart=/usr/local/bin/pulse-agent --url https://custom.example --token-file `
recover_connection_state_from_systemd_unit
printf 'state=%s source=%s url=%s token=%s commands=%s\n' \
"$STATE_DIR" "$STATE_DIR_SOURCE" "$PULSE_URL" "$PULSE_TOKEN" "$ENABLE_COMMANDS"
remove_agent_state_dir "$STATE_DIR"
remove_agent_state_dir "$STATE_DIR" || true
`
out, err := exec.Command("bash", "-c", script).CombinedOutput()
if err != nil {
@@ -3541,8 +3601,8 @@ ExecStart=/usr/local/bin/pulse-agent --url https://custom.example --token-file `
if strings.Contains(got, "default999") {
t.Fatalf("systemd discovery borrowed default token:\n%s", got)
}
if _, err := os.Stat(customState); !os.IsNotExist(err) {
t.Fatalf("uninstall did not remove discovered custom state: %v", err)
if _, err := os.Stat(customState); err != nil {
t.Fatalf("process-derived state path should be retained without protected removal authority: %v", err)
}
if _, err := os.Stat(defaultState); err != nil {
t.Fatalf("uninstall removed the default instance instead of discovered custom state: %v", err)
@@ -3617,7 +3677,7 @@ func TestInstallSHDiscoversCustomStateDirFromGeneratedLaunchdPlist(t *testing.T)
` + extractInstallShellFunction(t, "normalize_recovered_agent_arg_key") + `
` + extractInstallShellFunction(t, "apply_recovered_agent_arg_value") + `
` + extractInstallShellFunction(t, "recovered_connection_state_ready") + `
` + extractInstallShellFunction(t, "recover_token_from_default_agent_token_file") + `
` + extractTokenRecoveryShellFunctions(t) + `
` + extractInstallShellFunction(t, "recover_connection_state_from_arg_stream") + `
` + extractInstallShellFunction(t, "launchd_agent_arg_stream") + `
` + extractInstallShellFunction(t, "recover_connection_state_from_launchd_plist") + `
@@ -3656,6 +3716,14 @@ func TestInstallSHConnectionEnvPersistsCanonicalStateDirWithoutTokenValue(t *tes
SERVER_FINGERPRINT=""
CURL_CA_BUNDLE=""
SAVED_INSTALL_SCRIPT=""
LEAST_PRIVILEGE="false"
LEAST_PRIVILEGE_USER="pulse-agent"
INSTALLER_LIFECYCLE_DIR="` + filepath.Join(t.TempDir(), "lifecycle") + `"
EXIT_GENERAL=1
TMP_FILES=()
fail() { printf 'FAIL:%s\n' "$1" >&2; return 99; }
log_warn() { :; }
` + extractLifecyclePersistenceShellFunctions(t) + `
` + extractInstallShellFunction(t, "write_connection_state_value") + `
` + extractInstallShellFunction(t, "save_connection_info") + `
curl() { return 1; }
@@ -3721,10 +3789,17 @@ func TestInstallSHStateWritesReplaceSymlinksAtomically(t *testing.T) {
SERVER_FINGERPRINT=""
CURL_CA_BUNDLE=""
SAVED_INSTALL_SCRIPT=""
LEAST_PRIVILEGE="false"
LEAST_PRIVILEGE_USER="pulse-agent"
INSTALLER_LIFECYCLE_DIR="` + filepath.Join(t.TempDir(), "lifecycle") + `"
EXIT_GENERAL=1
NON_INTERACTIVE="true"
TMP_FILES=()
log_info() { :; }
log_warn() { :; }
fail() { printf 'FAIL:%s\n' "$1" >&2; return 99; }
curl() { return 1; }
` + extractLifecyclePersistenceShellFunctions(t) + `
` + extractInstallShellFunction(t, "write_connection_state_value") + `
` + extractInstallShellFunction(t, "ensure_runtime_token_file") + `
` + extractInstallShellFunction(t, "save_connection_info") + `
@@ -3761,6 +3836,221 @@ func TestInstallSHStateWritesReplaceSymlinksAtomically(t *testing.T) {
}
}
func TestInstallSHLeastPrivilegeLifecycleStateIsRootBoundarySeparated(t *testing.T) {
root := t.TempDir()
stateDir := filepath.Join(root, "collector-state")
lifecycleDir := filepath.Join(root, "root-lifecycle")
sourceInstaller := filepath.Join(root, "source-install.sh")
if err := os.MkdirAll(stateDir, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(sourceInstaller, []byte("#!/usr/bin/env bash\necho lifecycle\n"), 0700); err != nil {
t.Fatal(err)
}
for _, name := range []string{"connection.env", "install.sh", "install.sh.sha256"} {
if err := os.WriteFile(filepath.Join(stateDir, name), []byte("collector-controlled\n"), 0600); err != nil {
t.Fatal(err)
}
}
script := `
set -euo pipefail
STATE_DIR="` + stateDir + `"
PULSE_URL="https://pulse.example.com"
RUNTIME_TOKEN_FILE="$STATE_DIR/runtime.token"
AGENT_ID="agent-safe"
HOSTNAME_OVERRIDE="host-safe"
REPORT_IP=""
INSECURE="false"
SERVER_FINGERPRINT=""
CURL_CA_BUNDLE=""
SAVED_INSTALL_SCRIPT=""
LEAST_PRIVILEGE="true"
LEAST_PRIVILEGE_USER="` + os.Getenv("USER") + `"
INSTALLER_LIFECYCLE_DIR="` + lifecycleDir + `"
EXIT_GENERAL=1
TMP_FILES=()
fail() { printf 'FAIL:%s\n' "$1" >&2; return 99; }
curl() { return 1; }
` + extractLifecyclePersistenceShellFunctions(t) + `
` + extractInstallShellFunction(t, "write_connection_state_value") + `
` + extractInstallShellFunction(t, "save_connection_info") + `
save_connection_info "$STATE_DIR"
printf 'saved=%s\n' "$SAVED_INSTALL_SCRIPT"
`
cmd := exec.Command("bash", "-c", script, sourceInstaller)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("bash: %v\n%s", err, out)
}
if !strings.Contains(string(out), "saved="+filepath.Join(lifecycleDir, "install.sh")) {
t.Fatalf("saved installer did not move to lifecycle boundary:\n%s", out)
}
for _, name := range []string{"connection.env", "install.sh", "install.sh.sha256"} {
if _, err := os.Lstat(filepath.Join(stateDir, name)); !os.IsNotExist(err) {
t.Fatalf("collector state retained privileged lifecycle file %s: %v", name, err)
}
}
for path, wantMode := range map[string]os.FileMode{
filepath.Join(lifecycleDir, "connection.env"): 0600,
filepath.Join(lifecycleDir, "install.sh"): 0700,
filepath.Join(lifecycleDir, "install.sh.sha256"): 0600,
} {
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != wantMode {
t.Fatalf("%s mode = %o, want %o", path, info.Mode().Perm(), wantMode)
}
}
connection, err := os.ReadFile(filepath.Join(lifecycleDir, "connection.env"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(connection), "PULSE_STATE_DIR='"+stateDir+"'") {
t.Fatalf("protected lifecycle state omitted collector state path:\n%s", connection)
}
installer, err := os.ReadFile(filepath.Join(lifecycleDir, "install.sh"))
if err != nil {
t.Fatal(err)
}
wantHash := fmt.Sprintf("%x", sha256.Sum256(installer))
checksum, err := os.ReadFile(filepath.Join(lifecycleDir, "install.sh.sha256"))
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(string(checksum), wantHash+" install.sh") {
t.Fatalf("installer checksum = %q, want hash %s", checksum, wantHash)
}
}
func TestInstallSHSavedInstallerTamperAndUntrustedStateFailClosed(t *testing.T) {
root := t.TempDir()
lifecycleDir := filepath.Join(root, "lifecycle")
if err := os.MkdirAll(lifecycleDir, 0700); err != nil {
t.Fatal(err)
}
installerPath := filepath.Join(lifecycleDir, "install.sh")
if err := os.WriteFile(installerPath, []byte("#!/usr/bin/env bash\necho original\n"), 0700); err != nil {
t.Fatal(err)
}
installer, err := os.ReadFile(installerPath)
if err != nil {
t.Fatal(err)
}
hash := fmt.Sprintf("%x", sha256.Sum256(installer))
if err := os.WriteFile(filepath.Join(lifecycleDir, "install.sh.sha256"), []byte(hash+" install.sh\n"), 0600); err != nil {
t.Fatal(err)
}
verifyScript := `
set -euo pipefail
INSTALLER_LIFECYCLE_DIR="` + lifecycleDir + `"
` + extractLifecycleTrustShellFunctions(t) + `
` + extractInstallShellFunction(t, "installer_file_sha256") + `
` + extractInstallShellFunction(t, "verify_saved_installer_self_integrity") + `
verify_saved_installer_self_integrity "` + installerPath + `"
`
if out, err := exec.Command("bash", "-c", verifyScript).CombinedOutput(); err != nil {
t.Fatalf("untampered installer failed verification: %v\n%s", err, out)
}
if err := os.WriteFile(installerPath, []byte("#!/usr/bin/env bash\necho replaced\n"), 0700); err != nil {
t.Fatal(err)
}
if out, err := exec.Command("bash", "-c", verifyScript).CombinedOutput(); err == nil {
t.Fatalf("tampered installer passed verification:\n%s", out)
}
attackerDir := filepath.Join(root, "collector-writable")
sentinelDir := filepath.Join(root, "must-survive")
if err := os.MkdirAll(attackerDir, 0777); err != nil {
t.Fatal(err)
}
if err := os.Chmod(attackerDir, 0777); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(sentinelDir, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sentinelDir, "sentinel"), []byte("keep"), 0600); err != nil {
t.Fatal(err)
}
attackerInstaller := filepath.Join(attackerDir, "install.sh")
if err := os.WriteFile(attackerInstaller, []byte("#!/usr/bin/env bash\n"), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(attackerDir, "connection.env"), []byte("PULSE_STATE_DIR='"+sentinelDir+"'\n"), 0600); err != nil {
t.Fatal(err)
}
recoveryScript := `
set -euo pipefail
STATE_DIR="/var/lib/pulse-agent"
STATE_DIR_SOURCE="default"
STATE_DIR_REMOVAL_AUTHORITY="/var/lib/pulse-agent"
INSTALLER_LIFECYCLE_DIR="` + lifecycleDir + `"
log_warn() { :; }
` + extractLifecycleTrustShellFunctions(t) + `
` + extractInstallShellFunction(t, "read_connection_state_value") + `
` + extractInstallShellFunction(t, "discover_state_dir_from_saved_installer") + `
` + extractInstallShellFunction(t, "remove_agent_state_dir") + `
discover_state_dir_from_saved_installer "` + attackerInstaller + `" || true
STATE_DIR="` + sentinelDir + `"
remove_agent_state_dir "$STATE_DIR" || true
`
if out, err := exec.Command("bash", "-c", recoveryScript).CombinedOutput(); err != nil {
t.Fatalf("fail-closed recovery harness: %v\n%s", err, out)
}
if _, err := os.Stat(filepath.Join(sentinelDir, "sentinel")); err != nil {
t.Fatalf("untrusted lifecycle state authorized recursive deletion: %v", err)
}
}
func TestInstallSHLegacyCollectorOwnedTokenReaderFailsClosed(t *testing.T) {
root := t.TempDir()
stateDir := filepath.Join(root, "collector-state")
if err := os.MkdirAll(stateDir, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(stateDir, "token"), []byte("legacy-monitoring-token\n"), 0600); err != nil {
t.Fatal(err)
}
fakeBinary := filepath.Join(root, "collector-owned-pulse-agent")
marker := filepath.Join(root, "executed")
if err := os.WriteFile(fakeBinary, []byte("#!/usr/bin/env bash\ntouch '"+marker+"'\n"), 0755); err != nil {
t.Fatal(err)
}
script := `
set -euo pipefail
STATE_DIR="` + stateDir + `"
STATE_DIR_SOURCE="explicit"
DEFAULT_STATE_DIR="/var/lib/pulse-agent"
TRUENAS_STATE_DIR="/data/pulse-agent"
PULSE_TOKEN=""
RUNTIME_TOKEN_FILE=""
PRIVILEGED_HELPER_CREDENTIAL_DIR="` + filepath.Join(root, "protected") + `"
LEAST_PRIVILEGE_USER="pulse-agent"
collector_lifecycle_binary() { printf '%s\n' "` + fakeBinary + `"; }
trusted_lifecycle_regular_file() { return 1; }
trusted_private_lifecycle_regular_file() { return 1; }
` + extractInstallShellFunction(t, "read_collector_token_file_safely") + `
` + extractInstallShellFunction(t, "recover_token_from_default_agent_token_file") + `
recover_token_from_default_agent_token_file || true
printf 'token=%s\n' "$PULSE_TOKEN"
`
out, err := exec.Command("bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("bash: %v\n%s", err, out)
}
if strings.TrimSpace(string(out)) != "token=" {
t.Fatalf("untrusted legacy reader recovered a token: %s", out)
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("root installer executed collector-owned lifecycle binary: %v", err)
}
}
func TestInstallSHCurlTokenTransportKeepsSecretOutOfArgv(t *testing.T) {
recordDir := t.TempDir()
argsPath := filepath.Join(recordDir, "args")
@@ -4279,6 +4569,150 @@ func TestInstallSHRequiresPinnedSignatureVerificationForReleaseDownloads(t *test
}
}
func TestInstallSHOfflineInstallerAndUninstallUseAuthenticatedLifecycleTransport(t *testing.T) {
content, err := os.ReadFile(repoFile("scripts", "install.sh"))
if err != nil {
t.Fatal(err)
}
script := string(content)
save := extractInstallShellFunction(t, "save_connection_info")
uninstall := extractInstallShellFunction(t, "uninstall_collector_registration")
for _, required := range []string{
`collector-download-installer --url "$PULSE_URL" --output "$installer_tmp"`,
`verify_download_signature "$installer_tmp" "$installer_signature"`,
`collector-uninstall`,
`run_collector_lifecycle_command "${uninstall_args[@]}"`,
`Pulse did not durably confirm collector removal; local credentials and services were retained.`,
} {
if !strings.Contains(script, required) {
t.Fatalf("installer lifecycle transport contract missing %q", required)
}
}
if regexp.MustCompile(`(?m)^[ \t]*curl[ \t]`).MatchString(save) || strings.Contains(save, `-k`) {
t.Fatal("offline installer persistence must not use curl or generic insecure TLS")
}
if regexp.MustCompile(`(?m)^[ \t]*curl[ \t]`).MatchString(uninstall) || strings.Contains(uninstall, `-k`) {
t.Fatal("collector uninstall must not use curl or generic insecure TLS")
}
}
func TestInstallSHOfflineInstallerRequiresValidServedSignature(t *testing.T) {
if _, err := exec.LookPath("ssh-keygen"); err != nil {
t.Skip("ssh-keygen is required for SSH signature verification")
}
root := t.TempDir()
privateKey := filepath.Join(root, "signing-key")
keygen := exec.Command("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", privateKey)
if out, err := keygen.CombinedOutput(); err != nil {
t.Fatalf("ssh-keygen: %v\n%s", err, out)
}
publicKeyBytes, err := os.ReadFile(privateKey + ".pub")
if err != nil {
t.Fatal(err)
}
publicKeyFields := strings.Fields(string(publicKeyBytes))
if len(publicKeyFields) < 2 {
t.Fatalf("invalid generated public key: %q", publicKeyBytes)
}
publicKey := publicKeyFields[0] + " " + publicKeyFields[1]
validPayload := filepath.Join(root, "valid-install.sh")
invalidPayload := filepath.Join(root, "invalid-install.sh")
if err := os.WriteFile(validPayload, []byte("#!/usr/bin/env bash\necho signed\n"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(invalidPayload, []byte("#!/usr/bin/env bash\necho forged\n"), 0600); err != nil {
t.Fatal(err)
}
signer := exec.Command("ssh-keygen", "-Y", "sign", "-f", privateKey, "-n", "pulse-install", validPayload)
if out, err := signer.CombinedOutput(); err != nil {
t.Fatalf("sign installer: %v\n%s", err, out)
}
signatureBytes, err := os.ReadFile(validPayload + ".sig")
if err != nil {
t.Fatal(err)
}
encodedSignature := base64.StdEncoding.EncodeToString(signatureBytes)
fakeLifecycle := filepath.Join(root, "pulse-agent")
fakeScript := `#!/usr/bin/env bash
set -euo pipefail
output=""
while [[ $# -gt 0 ]]; do
if [[ "$1" == "--output" ]]; then output="$2"; shift 2; continue; fi
shift
done
cp "${PULSE_TEST_INSTALLER_PAYLOAD:?}" "$output"
printf '%s\n' "${PULSE_TEST_INSTALLER_SIGNATURE:?}"
`
if err := os.WriteFile(fakeLifecycle, []byte(fakeScript), 0755); err != nil {
t.Fatal(err)
}
run := func(t *testing.T, payload string, wantSuccess bool) {
t.Helper()
caseRoot := t.TempDir()
stateDir := filepath.Join(caseRoot, "state")
lifecycleDir := filepath.Join(caseRoot, "lifecycle")
script := `
set -euo pipefail
STATE_DIR="` + stateDir + `"
PULSE_URL="https://pulse.example.test"
RUNTIME_TOKEN_FILE="$STATE_DIR/token"
AGENT_ID="agent-signed"
HOSTNAME_OVERRIDE="host-signed"
REPORT_IP=""
INSECURE="true"
SERVER_FINGERPRINT="` + strings.Repeat("a", 64) + `"
CURL_CA_BUNDLE=""
SAVED_INSTALL_SCRIPT=""
LEAST_PRIVILEGE="true"
LEAST_PRIVILEGE_USER="$(id -un)"
INSTALLER_LIFECYCLE_DIR="` + lifecycleDir + `"
COLLECTOR_LIFECYCLE_BINARY_PATH="` + fakeLifecycle + `"
PINNED_INSTALLER_SSH_PUBLIC_KEY="` + publicKey + `"
INSTALL_SIGNATURE_IDENTITY="pulse-installer"
INSTALL_SIGNATURE_NAMESPACE="pulse-install"
EXIT_GENERAL=1
EXIT_SIGNATURE_FAILED=17
OUTPUT_FORMAT=text
TMP_FILES=()
json_event() { :; }
log_info() { :; }
log_warn() { :; }
fail() { printf 'FAIL:%s\n' "$1" >&2; return "${2:-1}"; }
curl() { printf 'unexpected curl\n' >&2; return 98; }
` + extractLifecyclePersistenceShellFunctions(t) + `
` + extractInstallShellFunction(t, "collector_lifecycle_binary") + `
` + extractInstallShellFunction(t, "decode_base64_to_file") + `
` + extractInstallShellFunction(t, "verify_download_signature") + `
` + extractInstallShellFunction(t, "write_connection_state_value") + `
` + extractInstallShellFunction(t, "save_connection_info") + `
save_connection_info "$STATE_DIR"
`
cmd := exec.Command("bash", "-c", script)
cmd.Env = append(os.Environ(), "PULSE_TEST_INSTALLER_PAYLOAD="+payload, "PULSE_TEST_INSTALLER_SIGNATURE="+encodedSignature)
out, err := cmd.CombinedOutput()
if wantSuccess {
if err != nil {
t.Fatalf("valid signature rejected: %v\n%s", err, out)
}
body, readErr := os.ReadFile(filepath.Join(lifecycleDir, "install.sh"))
if readErr != nil || string(body) != "#!/usr/bin/env bash\necho signed\n" {
t.Fatalf("saved installer body=%q err=%v", body, readErr)
}
return
}
if err == nil {
t.Fatalf("invalid signature was accepted:\n%s", out)
}
if _, statErr := os.Stat(filepath.Join(lifecycleDir, "install.sh")); !os.IsNotExist(statErr) {
t.Fatalf("invalidly signed installer was persisted: %v", statErr)
}
}
run(t, validPayload, true)
run(t, invalidPayload, false)
}
func TestBuildContainerInstallCommandPreservesForcedVersion(t *testing.T) {
script := `
FORCE_VERSION="v1.2.3"
@@ -6171,7 +6605,8 @@ func TestInstallSHTypedPrivilegedHelperProfileIsOptInAndFailClosed(t *testing.T)
`chown root:root "$PRIVILEGED_HELPER_BINARY_PATH"`,
`chown -R "${LEAST_PRIVILEGE_USER}:${LEAST_PRIVILEGE_USER}" "$STATE_DIR"`,
`protect_typed_profile_credentials`,
`PRIVILEGED_HELPER_CREDENTIAL_DIR="/etc/pulse-agent"`,
`INSTALLER_LIFECYCLE_DIR="/etc/pulse-agent"`,
`PRIVILEGED_HELPER_CREDENTIAL_DIR="$INSTALLER_LIFECYCLE_DIR"`,
`chown "root:${LEAST_PRIVILEGE_USER}" "$PRIVILEGED_HELPER_CREDENTIAL_DIR"`,
`chmod 0750 "$PRIVILEGED_HELPER_CREDENTIAL_DIR"`,
`chown "root:${LEAST_PRIVILEGE_USER}" "$RUNTIME_TOKEN_FILE"`,
@@ -150,6 +150,9 @@ rm -f "$STATE_DIR/proxmox-registered" "$STATE_DIR/proxmox-pve-registered" "$STAT
rm -f "$STATE_DIR/proxmox-pve-registration-blocked" "$STATE_DIR/proxmox-pbs-registration-blocked" "$STATE_DIR/proxmox-detected-types"
printf 'changed-agent-id\n' > "$STATE_DIR/agent-id"
printf 'changed-connection\n' > "$STATE_DIR/connection.env"
printf 'changed-lifecycle-connection\n' > "$INSTALLER_LIFECYCLE_DIR/connection.env"
printf 'changed-lifecycle-installer\n' > "$INSTALLER_LIFECYCLE_DIR/install.sh"
printf 'changed-lifecycle-checksum\n' > "$INSTALLER_LIFECYCLE_DIR/install.sh.sha256"
chmod 0777 "$STATE_DIR" "$STATE_DIR/cache" "$STATE_DIR/cache/sample"
printf 'outside-state\n' > "$EXPECTED_DIR/outside-target"
chmod 0600 "$EXPECTED_DIR/outside-target"
@@ -166,6 +169,9 @@ cmp "$STATE_DIR/token" "$EXPECTED_DIR/state-token"
cmp "$STATE_DIR/runtime.token" "$EXPECTED_DIR/runtime-token"
cmp "$STATE_DIR/agent-id" "$EXPECTED_DIR/agent-id"
cmp "$STATE_DIR/connection.env" "$EXPECTED_DIR/connection-env"
cmp "$INSTALLER_LIFECYCLE_DIR/connection.env" "$EXPECTED_DIR/lifecycle-connection-env"
cmp "$INSTALLER_LIFECYCLE_DIR/install.sh" "$EXPECTED_DIR/lifecycle-install-script"
cmp "$INSTALLER_LIFECYCLE_DIR/install.sh.sha256" "$EXPECTED_DIR/lifecycle-install-checksum"
grep -q '^legacy-generic$' "$STATE_DIR/proxmox-registered"
grep -q '^legacy-pve$' "$STATE_DIR/proxmox-pve-registered"
grep -q '^legacy-pbs$' "$STATE_DIR/proxmox-pbs-registered"
@@ -592,6 +598,9 @@ func safeProfileHarness(t *testing.T, root string, dockerMember bool) string {
filepath.Join(stateDir, "proxmox-pve-registration-blocked"): "legacy-pve-blocked\n",
filepath.Join(stateDir, "proxmox-pbs-registration-blocked"): "legacy-pbs-blocked\n",
filepath.Join(stateDir, "proxmox-detected-types"): "pve,pbs\n",
filepath.Join(credentialDir, "connection.env"): "PULSE_STATE_DIR='" + stateDir + "'\n",
filepath.Join(credentialDir, "install.sh"): "#!/usr/bin/env bash\necho legacy-installer\n",
filepath.Join(credentialDir, "install.sh.sha256"): "legacy-checksum\n",
}
for path, body := range files {
mustMkdirAll(t, filepath.Dir(path))
@@ -607,12 +616,15 @@ func safeProfileHarness(t *testing.T, root string, dockerMember bool) string {
t.Fatal(err)
}
for source, name := range map[string]string{
filepath.Join(binDir, "pulse-agent"): "collector-binary",
filepath.Join(unitDir, "pulse-agent.service"): "collector-unit",
filepath.Join(stateDir, "token"): "state-token",
filepath.Join(stateDir, "runtime.token"): "runtime-token",
filepath.Join(stateDir, "agent-id"): "agent-id",
filepath.Join(stateDir, "connection.env"): "connection-env",
filepath.Join(binDir, "pulse-agent"): "collector-binary",
filepath.Join(unitDir, "pulse-agent.service"): "collector-unit",
filepath.Join(stateDir, "token"): "state-token",
filepath.Join(stateDir, "runtime.token"): "runtime-token",
filepath.Join(stateDir, "agent-id"): "agent-id",
filepath.Join(stateDir, "connection.env"): "connection-env",
filepath.Join(credentialDir, "connection.env"): "lifecycle-connection-env",
filepath.Join(credentialDir, "install.sh"): "lifecycle-install-script",
filepath.Join(credentialDir, "install.sh.sha256"): "lifecycle-install-checksum",
} {
body, err := os.ReadFile(source)
if err != nil {
@@ -638,6 +650,7 @@ PRIVILEGED_HELPER_SOCKET_UNIT="` + filepath.Join(unitDir, "pulse-agent-helper.so
PRIVILEGED_HELPER_SOCKET_PATH="` + filepath.Join(root, "run", "helper.sock") + `"
PRIVILEGED_HELPER_NAME=pulse-agent-helper
PRIVILEGED_HELPER_CREDENTIAL_DIR="` + credentialDir + `"
INSTALLER_LIFECYCLE_DIR="$PRIVILEGED_HELPER_CREDENTIAL_DIR"
SAFE_PROFILE_COLLECTOR_UNIT="` + filepath.Join(unitDir, "pulse-agent.service") + `"
SAFE_PROFILE_STATE_DIR="` + filepath.Join(root, "profile") + `"
SAFE_PROFILE_CURRENT_FILE="${SAFE_PROFILE_STATE_DIR}/current.env"
@@ -23,7 +23,7 @@ from release_promotion_policy_support import (
slice_requires_staged_governance_inputs,
staged_governance_input_errors,
)
from repo_file_io import REPO_ROOT, git_env, read_repo_text
from repo_file_io import REPO_ROOT, git_env, read_repo_text, strip_local_git_env
USE_STAGED_GOVERNANCE = os.environ.get("PULSE_READ_STAGED_GOVERNANCE") == "1"
@@ -918,27 +918,58 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
remote = root / "remote.git"
checkout = root / "checkout"
output = root / "github-output"
subprocess.run(["git", "init", "--bare", str(remote)], check=True, capture_output=True)
subprocess.run(["git", "init", str(checkout)], check=True, capture_output=True)
git_subprocess_env = strip_local_git_env(os.environ.copy())
subprocess.run(
["git", "init", "--bare", str(remote)],
check=True,
capture_output=True,
env=git_subprocess_env,
)
subprocess.run(
["git", "init", str(checkout)],
check=True,
capture_output=True,
env=git_subprocess_env,
)
for key, value in (
("user.name", "Pulse Test"),
("user.email", "pulse-test@example.invalid"),
):
subprocess.run(
["git", "config", key, value], cwd=checkout, check=True
["git", "config", key, value],
cwd=checkout,
check=True,
env=git_subprocess_env,
)
(checkout / "README").write_text("base\n", encoding="utf-8")
subprocess.run(["git", "add", "README"], cwd=checkout, check=True)
subprocess.run(["git", "commit", "-m", "base"], cwd=checkout, check=True, capture_output=True)
subprocess.run(["git", "remote", "add", "origin", str(remote)], cwd=checkout, check=True)
subprocess.run(
["git", "add", "README"],
cwd=checkout,
check=True,
env=git_subprocess_env,
)
subprocess.run(
["git", "commit", "-m", "base"],
cwd=checkout,
check=True,
capture_output=True,
env=git_subprocess_env,
)
subprocess.run(
["git", "remote", "add", "origin", str(remote)],
cwd=checkout,
check=True,
env=git_subprocess_env,
)
subprocess.run(
["git", "push", "origin", "HEAD:refs/heads/release-customer-promotion-lock"],
cwd=checkout,
check=True,
capture_output=True,
env=git_subprocess_env,
)
env = os.environ.copy()
env = git_subprocess_env.copy()
env.update(
{
"GH_TOKEN": "test-token",