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