From 53267e149da837cbb9ee6ee3889cd167478870d5 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:44:30 +0100 Subject: [PATCH 1/2] Harden least-privilege installer lifecycle state --- cmd/pulse-agent/collector_lifecycle.go | 60 ++- cmd/pulse-agent/collector_lifecycle_test.go | 44 ++ docs/AGENT_SECURITY.md | 16 + .../v6/internal/subsystems/agent-lifecycle.md | 81 ++- .../v6/internal/subsystems/api-contracts.md | 13 + .../subsystems/deployment-installability.md | 45 +- .../v6/internal/subsystems/monitoring.md | 17 + .../internal/subsystems/storage-recovery.md | 2 +- frontend-modern/browser-verification.json | 32 +- frontend-modern/public/docs/AGENT_SECURITY.md | 16 + .../__tests__/agentInstallCommand.test.ts | 97 +++- .../src/utils/agentInstallCommand.ts | 40 +- internal/api/agent_ingest.go | 30 +- .../api/agent_install_command_shared_test.go | 17 +- internal/api/configapi/install_command.go | 46 +- .../api/configapi/install_command_test.go | 89 +++ internal/api/contract_test.go | 8 +- ...gent_removal_lifecycle_integration_test.go | 169 ++++++ internal/collectorlifecycle/client.go | 135 ++++- internal/collectorlifecycle/client_test.go | 92 ++++ internal/monitoring/monitor_agents.go | 198 +++++-- ...nitor_host_agent_removal_lifecycle_test.go | 85 +++ scripts/install.sh | 506 ++++++++++++++---- .../agent_state_dir_lifecycle_test.go | 62 ++- scripts/installtests/install_sh_test.go | 485 ++++++++++++++++- .../safe_profile_migration_test.go | 25 +- .../release_promotion_policy_test.py | 47 +- 27 files changed, 2164 insertions(+), 293 deletions(-) create mode 100644 internal/api/configapi/install_command_test.go diff --git a/cmd/pulse-agent/collector_lifecycle.go b/cmd/pulse-agent/collector_lifecycle.go index cd371b9dd..3bfe4057f 100644 --- a/cmd/pulse-agent/collector_lifecycle.go +++ b/cmd/pulse-agent/collector_lifecycle.go @@ -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) } diff --git a/cmd/pulse-agent/collector_lifecycle_test.go b/cmd/pulse-agent/collector_lifecycle_test.go index 540984a08..8a14fe274 100644 --- a/cmd/pulse-agent/collector_lifecycle_test.go +++ b/cmd/pulse-agent/collector_lifecycle_test.go @@ -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) diff --git a/docs/AGENT_SECURITY.md b/docs/AGENT_SECURITY.md index 0fecba77e..c7bbc8e01 100644 --- a/docs/AGENT_SECURITY.md +++ b/docs/AGENT_SECURITY.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index d47f75910..54f2c5271 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 3e7403f4d..4226dd7f9 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index a4d187905..c363bf037 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 954138f84..ecfaee934 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 3766846e8..f480e684d 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index b963b13c7..9057f1ecf 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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" ] } diff --git a/frontend-modern/public/docs/AGENT_SECURITY.md b/frontend-modern/public/docs/AGENT_SECURITY.md index 0fecba77e..c7bbc8e01 100644 --- a/frontend-modern/public/docs/AGENT_SECURITY.md +++ b/frontend-modern/public/docs/AGENT_SECURITY.md @@ -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 diff --git a/frontend-modern/src/utils/__tests__/agentInstallCommand.test.ts b/frontend-modern/src/utils/__tests__/agentInstallCommand.test.ts index cc208d19b..a71a99782 100644 --- a/frontend-modern/src/utils/__tests__/agentInstallCommand.test.ts +++ b/frontend-modern/src/utils/__tests__/agentInstallCommand.test.ts @@ -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', () => { diff --git a/frontend-modern/src/utils/agentInstallCommand.ts b/frontend-modern/src/utils/agentInstallCommand.ts index 99b4fd46e..b63e91107 100644 --- a/frontend-modern/src/utils/agentInstallCommand.ts +++ b/frontend-modern/src/utils/agentInstallCommand.ts @@ -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 diff --git a/internal/api/agent_ingest.go b/internal/api/agent_ingest.go index 29d1f8d5a..60f4e90d4 100644 --- a/internal/api/agent_ingest.go +++ b/internal/api/agent_ingest.go @@ -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()) diff --git a/internal/api/agent_install_command_shared_test.go b/internal/api/agent_install_command_shared_test.go index ef8db2413..2ee7329ab 100644 --- a/internal/api/agent_install_command_shared_test.go +++ b/internal/api/agent_install_command_shared_test.go @@ -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) { diff --git a/internal/api/configapi/install_command.go b/internal/api/configapi/install_command.go index 882e8bc4b..c495efa03 100644 --- a/internal/api/configapi/install_command.go +++ b/internal/api/configapi/install_command.go @@ -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 { diff --git a/internal/api/configapi/install_command_test.go b/internal/api/configapi/install_command_test.go new file mode 100644 index 000000000..2568f7107 --- /dev/null +++ b/internal/api/configapi/install_command_test.go @@ -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) + } +} diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 3f0017a16..4a766f2e6 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -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) } } diff --git a/internal/api/host_agent_removal_lifecycle_integration_test.go b/internal/api/host_agent_removal_lifecycle_integration_test.go index 079df408d..ce5dbb94e 100644 --- a/internal/api/host_agent_removal_lifecycle_integration_test.go +++ b/internal/api/host_agent_removal_lifecycle_integration_test.go @@ -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" diff --git a/internal/collectorlifecycle/client.go b/internal/collectorlifecycle/client.go index be80244a3..f8f1b07a5 100644 --- a/internal/collectorlifecycle/client.go +++ b/internal/collectorlifecycle/client.go @@ -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") diff --git a/internal/collectorlifecycle/client_test.go b/internal/collectorlifecycle/client_test.go index 7d7c64001..317c473fc 100644 --- a/internal/collectorlifecycle/client_test.go +++ b/internal/collectorlifecycle/client_test.go @@ -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) { diff --git a/internal/monitoring/monitor_agents.go b/internal/monitoring/monitor_agents.go index 1570932ec..17019c2b0 100644 --- a/internal/monitoring/monitor_agents.go +++ b/internal/monitoring/monitor_agents.go @@ -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, diff --git a/internal/monitoring/monitor_host_agent_removal_lifecycle_test.go b/internal/monitoring/monitor_host_agent_removal_lifecycle_test.go index 91903701b..a86772836 100644 --- a/internal/monitoring/monitor_host_agent_removal_lifecycle_test.go +++ b/internal/monitoring/monitor_host_agent_removal_lifecycle_test.go @@ -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{ diff --git a/scripts/install.sh b/scripts/install.sh index b653b471d..a85730a39 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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:-}" 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 diff --git a/scripts/installtests/agent_state_dir_lifecycle_test.go b/scripts/installtests/agent_state_dir_lifecycle_test.go index d4cecc56d..24b26b81c 100644 --- a/scripts/installtests/agent_state_dir_lifecycle_test.go +++ b/scripts/installtests/agent_state_dir_lifecycle_test.go @@ -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" diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index 18f2c06c5..2e1078ce8 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -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"`, diff --git a/scripts/installtests/safe_profile_migration_test.go b/scripts/installtests/safe_profile_migration_test.go index f81ef2602..d0bc57f96 100644 --- a/scripts/installtests/safe_profile_migration_test.go +++ b/scripts/installtests/safe_profile_migration_test.go @@ -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" diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index a8303467d..2d3962bc0 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -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", From fa2e3791229dc3e440fc0bc412d988081f87921e Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:01:52 +0100 Subject: [PATCH 2/2] Block fabricated pulse-branded domains with a lint audit Offline guard for the pulseapp.io class: any domain-shaped token containing 'pulse' in published surfaces (root markdown, docs and mirrors, non-test frontend src and Go source) must be pulserelay.pro, 1mk.app, or a documented placeholder. Catches the invented security@pulseapp.io / docs.pulseapp.io (10 months as the published security contact, bouncing every disclosure) and the pulse.app OpenRouter referer fixed in b1240c6ca. Runs in npm run lint, so the pre-push hook enforces it. --- frontend-modern/package.json | 3 +- .../scripts/external-domain-audit.mjs | 125 ++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 frontend-modern/scripts/external-domain-audit.mjs diff --git a/frontend-modern/package.json b/frontend-modern/package.json index 53e006f71..8c57a319a 100644 --- a/frontend-modern/package.json +++ b/frontend-modern/package.json @@ -35,11 +35,12 @@ "test:coverage": "vitest run --coverage --coverage.provider=v8 --coverage.include=src/**/*.ts --coverage.include=src/**/*.tsx --coverage.exclude=src/index.tsx", "test:coverage:ai": "vitest run --coverage --coverage.provider=v8 --coverage.thresholds.100 --coverage.thresholds.perFile --coverage.include=src/components/AI/aiChatUtils.ts", "type-check": "tsc --noEmit", - "lint": "npm run lint:eslint && npm run lint:theme && npm run lint:copy && npm run lint:canonical-platforms && npm run lint:form-labels", + "lint": "npm run lint:eslint && npm run lint:theme && npm run lint:copy && npm run lint:canonical-platforms && npm run lint:form-labels && npm run lint:external-domains", "lint:canonical-platforms": "node scripts/canonical-platform-audit.mjs", "lint:eslint": "eslint \"src/**/*.{ts,tsx}\"", "lint:copy": "node scripts/copy-style-audit.mjs", "lint:form-labels": "node scripts/form-label-audit.mjs", + "lint:external-domains": "node scripts/external-domain-audit.mjs", "lint:theme": "node scripts/theme-audit.mjs", "lint:headers": "node scripts/header-audit.mjs", "lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix", diff --git a/frontend-modern/scripts/external-domain-audit.mjs b/frontend-modern/scripts/external-domain-audit.mjs new file mode 100644 index 000000000..7960db31a --- /dev/null +++ b/frontend-modern/scripts/external-domain-audit.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +// Blocks fabricated Pulse-branded domains from entering published surfaces. +// +// Context: commit 524f42cc28 invented security@pulseapp.io and docs.pulseapp.io +// (pulseapp.io was never a Pulse domain); the address shipped as the published +// security contact for ~10 months and bounced every disclosure. A follow-up +// audit on 2026-09-01 found https://pulse.app claimed as the OpenRouter +// attribution referer. This audit is the offline guard for that class: any +// domain-shaped token containing "pulse" that is not on the owned/placeholder +// allowlist fails the push. It cannot judge ownership of non-Pulse-branded +// domains; the periodic link audit covers those. +import fs from 'node:fs'; +import path from 'node:path'; + +const ROOT = process.cwd(); +const REPO_ROOT = path.resolve(ROOT, '..'); + +// Real public suffixes a fabricated brand domain would plausibly use. Tokens +// ending in anything else (.role, .local, .example, .sh script names, Go +// identifiers) are not treated as domains. +const PUBLIC_TLDS = new Set([ + 'com', 'net', 'org', 'io', 'app', 'dev', 'pro', 'cloud', 'ai', 'me', + 'co', 'eu', 'de', 'es', 'fr', 'uk', 'us', 'xyz', 'info', 'tech', 'online', + 'site', 'blog', +]); + +function isAllowedHost(host) { + if (host === 'pulserelay.pro' || host.endsWith('.pulserelay.pro')) return true; + if (host === '1mk.app' || host.endsWith('.1mk.app')) return true; + // Cloudflare Pages previews of the landing site. + if (host.endsWith('.pages.dev') && host.includes('pulserelay-landing')) return true; + const labels = host.split('.'); + // Documentation placeholders. + if (labels.includes('example') || labels.some((l) => l.includes('yourdomain'))) return true; + if (host.startsWith('your-') || host.startsWith('yourname.')) return true; + return false; +} + +const SCAN_ROOT_FILES = ['SECURITY.md', 'README.md', 'CONTRIBUTING.md', 'TERMS.md']; +const SCAN_DIRS = [ + { dir: 'docs', extensions: null }, + { dir: 'frontend-modern/public/docs', extensions: null }, + { dir: 'frontend-modern/src', extensions: /\.(ts|tsx)$/ }, + { dir: 'internal', extensions: /\.go$/ }, + { dir: 'cmd', extensions: /\.go$/ }, + { dir: 'pkg', extensions: /\.go$/ }, +]; +const SKIP_DIR_NAMES = new Set(['node_modules', '__tests__', 'testdata', '.git']); +const SKIP_FILE_PATTERNS = [/_test\.go$/, /\.test\.(ts|tsx)$/, /\.(png|jpg|jpeg|gif|webp|svg|ico|woff2?|mp4|pdf|zip|gz)$/i]; +const DOMAIN_TOKEN = /[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+/gi; + +const findings = []; + +function toRelative(absPath) { + return path.relative(REPO_ROOT, absPath).replaceAll(path.sep, '/'); +} + +function collectFiles(directory, extensions) { + const files = []; + let entries; + try { + entries = fs.readdirSync(directory, { withFileTypes: true }); + } catch { + return files; + } + for (const entry of entries) { + if (SKIP_DIR_NAMES.has(entry.name)) continue; + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...collectFiles(fullPath, extensions)); + continue; + } + if (!entry.isFile()) continue; + if (extensions && !extensions.test(entry.name)) continue; + if (SKIP_FILE_PATTERNS.some((p) => p.test(entry.name))) continue; + files.push(fullPath); + } + return files; +} + +function inspectFile(absPath) { + let content; + try { + content = fs.readFileSync(absPath, 'utf8'); + } catch { + return; + } + if (content.includes('\u0000')) return; + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i++) { + for (const match of lines[i].matchAll(DOMAIN_TOKEN)) { + // Capitalized single-label .app tokens are macOS/iOS bundle names + // (Pulse.app), not domains; a fabricated domain reads lowercase. + if (/^[A-Z][A-Za-z0-9-]*\.app$/.test(match[0])) continue; + const host = match[0].toLowerCase(); + if (!host.includes('pulse')) continue; + const labels = host.split('.'); + if (!PUBLIC_TLDS.has(labels[labels.length - 1])) continue; + if (isAllowedHost(host)) continue; + findings.push({ file: toRelative(absPath), line: i + 1, host }); + } + } +} + +const targets = SCAN_ROOT_FILES.map((f) => path.join(REPO_ROOT, f)).filter((f) => fs.existsSync(f)); +for (const { dir, extensions } of SCAN_DIRS) { + targets.push(...collectFiles(path.join(REPO_ROOT, dir), extensions)); +} +for (const file of targets) inspectFile(file); + +if (findings.length > 0) { + console.error( + 'External domain audit failed. Pulse-branded domains must be pulserelay.pro or 1mk.app;', + ); + console.error( + 'anything else is fabricated (see the pulseapp.io incident). Fix the reference or, for a', + ); + console.error('legitimate new Pulse-owned domain, add it to isAllowedHost with a comment.'); + for (const finding of findings) { + console.error(`- ${finding.file}:${finding.line}: ${finding.host}`); + } + process.exit(1); +} + +console.log('External domain audit passed with no unrecognized Pulse-branded domains.');