Harden secure agent runtime transitions

This commit is contained in:
Pulse Test
2026-08-30 01:41:57 +01:00
parent 5d3571dbe8
commit d06ffc233d
39 changed files with 3818 additions and 151 deletions
+43 -3
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"net"
"net/url"
"os"
"os/signal"
@@ -25,6 +26,7 @@ type runtimeConfig struct {
StateDir string
HealthFile string
AgentIDFile string
Hostname string
ServerFingerprint string
CAFile string
Insecure bool
@@ -37,6 +39,7 @@ func loadConfig() (runtimeConfig, error) {
StateDir: strings.TrimSpace(os.Getenv("PULSE_AGENT_RUNNER_STATE_DIR")),
HealthFile: strings.TrimSpace(os.Getenv("PULSE_AGENT_RUNNER_HEALTH_FILE")),
AgentIDFile: strings.TrimSpace(os.Getenv("PULSE_AGENT_RUNNER_AGENT_ID_FILE")),
Hostname: strings.TrimSpace(os.Getenv("PULSE_AGENT_RUNNER_HOSTNAME")),
ServerFingerprint: strings.TrimSpace(os.Getenv("PULSE_SERVER_FINGERPRINT")),
CAFile: strings.TrimSpace(os.Getenv("SSL_CERT_FILE")),
Insecure: strings.EqualFold(strings.TrimSpace(os.Getenv("PULSE_INSECURE")), "true"),
@@ -44,6 +47,13 @@ func loadConfig() (runtimeConfig, error) {
if config.PulseURL == "" || config.TokenFile == "" || config.StateDir == "" || config.HealthFile == "" || config.AgentIDFile == "" {
return runtimeConfig{}, errors.New("PULSE_URL and the action-runner token, state, health, and agent identity file settings are required")
}
if config.Hostname != "" {
hostname, err := normalizeRunnerHostname(config.Hostname)
if err != nil {
return runtimeConfig{}, err
}
config.Hostname = hostname
}
parsed, err := url.Parse(config.PulseURL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "https" && !(config.Insecure && parsed.Scheme == "http")) {
return runtimeConfig{}, errors.New("PULSE_URL must be HTTPS unless PULSE_INSECURE=true explicitly permits HTTP")
@@ -83,9 +93,13 @@ func run() error {
if err != nil {
return err
}
hostname, err := os.Hostname()
if err != nil || strings.TrimSpace(hostname) == "" {
return errors.New("determine action-runner hostname")
hostname := config.Hostname
if hostname == "" {
hostname, err = os.Hostname()
hostname = strings.TrimSpace(hostname)
if err != nil || hostname == "" {
return errors.New("determine action-runner hostname")
}
}
logger := zerolog.New(os.Stderr).With().Timestamp().Str("component", "action-runner").Logger()
transportConfig := actionrunner.TransportConfig{
@@ -112,6 +126,32 @@ func run() error {
return nil
}
func normalizeRunnerHostname(value string) (string, error) {
hostname := strings.ToLower(strings.TrimSpace(value))
hostname = strings.TrimRight(hostname, ".")
if hostname == "" || len(hostname) > 253 {
return "", errors.New("PULSE_AGENT_RUNNER_HOSTNAME must be a valid hostname or IP address")
}
if net.ParseIP(hostname) != nil {
return hostname, nil
}
for _, label := range strings.Split(hostname, ".") {
if len(label) == 0 || len(label) > 63 || !runnerHostnameLabelByte(label[0]) || !runnerHostnameLabelByte(label[len(label)-1]) {
return "", errors.New("PULSE_AGENT_RUNNER_HOSTNAME must be a valid hostname or IP address")
}
for index := 1; index < len(label)-1; index++ {
if !runnerHostnameLabelByte(label[index]) && label[index] != '-' {
return "", errors.New("PULSE_AGENT_RUNNER_HOSTNAME must be a valid hostname or IP address")
}
}
}
return hostname, nil
}
func runnerHostnameLabelByte(value byte) bool {
return value >= 'a' && value <= 'z' || value >= '0' && value <= '9'
}
func readPrivateValue(path, label string) (string, error) {
info, err := os.Lstat(path)
if err != nil {
+29 -1
View File
@@ -22,16 +22,44 @@ func TestLoadConfigUsesDedicatedEnvironmentAndPrivateTokenFile(t *testing.T) {
t.Setenv("PULSE_AGENT_RUNNER_STATE_DIR", filepath.Join(dir, "state"))
t.Setenv("PULSE_AGENT_RUNNER_HEALTH_FILE", filepath.Join(dir, "state", "health.json"))
t.Setenv("PULSE_AGENT_RUNNER_AGENT_ID_FILE", agentID)
t.Setenv("PULSE_AGENT_RUNNER_HOSTNAME", " Node.Example. ")
t.Setenv("PULSE_SERVER_FINGERPRINT", "sha256:test")
config, err := loadConfig()
if err != nil {
t.Fatal(err)
}
if config.TokenFile != token || config.ServerFingerprint != "sha256:test" || config.Insecure {
if config.TokenFile != token || config.ServerFingerprint != "sha256:test" || config.Hostname != "node.example" || config.Insecure {
t.Fatalf("config = %+v", config)
}
}
func TestLoadConfigRejectsInvalidCanonicalHostnameOverride(t *testing.T) {
dir := t.TempDir()
token := filepath.Join(dir, "runner.token")
if err := os.WriteFile(token, []byte("secret\n"), 0600); err != nil {
t.Fatal(err)
}
t.Setenv("PULSE_URL", "https://pulse.example")
t.Setenv("PULSE_AGENT_RUNNER_TOKEN_FILE", token)
t.Setenv("PULSE_AGENT_RUNNER_STATE_DIR", filepath.Join(dir, "state"))
t.Setenv("PULSE_AGENT_RUNNER_HEALTH_FILE", filepath.Join(dir, "state", "health.json"))
t.Setenv("PULSE_AGENT_RUNNER_AGENT_ID_FILE", filepath.Join(dir, "agent-id"))
for _, hostname := range []string{"bad host", "-node.example", "node/example", strings.Repeat("a", 64) + ".example"} {
t.Run(hostname, func(t *testing.T) {
t.Setenv("PULSE_AGENT_RUNNER_HOSTNAME", hostname)
if _, err := loadConfig(); err == nil || !strings.Contains(err.Error(), "valid hostname") {
t.Fatalf("loadConfig error = %v", err)
}
})
}
}
func TestNormalizeRunnerHostnameAcceptsIPLiteral(t *testing.T) {
if got, err := normalizeRunnerHostname(" 2001:DB8::1 "); err != nil || got != "2001:db8::1" {
t.Fatalf("normalizeRunnerHostname = %q, %v", got, err)
}
}
func TestLoadConfigRejectsTokenInArgvEquivalentAndInsecureHTTPByDefault(t *testing.T) {
dir := t.TempDir()
t.Setenv("PULSE_URL", "http://pulse.example")
+123 -1
View File
@@ -119,6 +119,87 @@ func wireUpdaterHooks(hostCfg *hostagent.Config, updater *agentupdate.Updater) {
hostCfg.OnServerVersion = updater.NudgeVersion
}
func pendingUpdatePreviousVersion(pending *agentupdate.PendingPrivilegedUpdate) string {
if pending == nil {
return ""
}
return pending.PreviousVersion
}
func supervisePendingPrivilegedUpdate(
ctx context.Context,
update agentupdate.PrivilegedUpdate,
pending *agentupdate.PendingPrivilegedUpdate,
stateDir string,
locallyReady func() bool,
reportAccepted <-chan struct{},
pollInterval time.Duration,
logger *zerolog.Logger,
) error {
if pending == nil {
return nil
}
if update == nil || locallyReady == nil || pollInterval <= 0 {
return errors.New("pending helper update supervisor is not configured")
}
activation := pending.Activation
rollback := func(reason error) error {
rollbackCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result, err := update.Rollback(rollbackCtx, activation)
if err != nil {
return errors.Join(reason, fmt.Errorf("typed helper rollback failed: %w", err))
}
if result.Action != "rolled_back" || !strings.EqualFold(result.ActiveSHA256, activation.RollbackSHA256) {
return errors.Join(reason, errors.New("typed helper returned an invalid rollback result"))
}
if clearErr := agentupdate.ClearPendingPrivilegedUpdate(stateDir); clearErr != nil {
return errors.Join(fmt.Errorf("%w; pending update rolled back", reason), fmt.Errorf("clear pending update handoff: %w", clearErr))
}
return fmt.Errorf("%w; pending update rolled back", reason)
}
deadlineDelay := time.Until(activation.RollbackDeadline)
if deadlineDelay <= 0 {
return rollback(errors.New("pending update health deadline expired before startup"))
}
deadline := time.NewTimer(deadlineDelay)
defer deadline.Stop()
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
reportOK := false
for {
if reportOK && locallyReady() {
commitCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
result, err := update.Commit(commitCtx, activation)
cancel()
if err == nil && result.Action == "committed" && strings.EqualFold(result.ActiveSHA256, activation.ActiveSHA256) {
if err := agentupdate.ClearPendingPrivilegedUpdate(stateDir); err != nil {
return fmt.Errorf("clear committed update handoff: %w", err)
}
if logger != nil {
logger.Info().Str("activation_id", activation.ActivationID).Msg("Committed helper-backed agent update after local readiness and server report")
}
return nil
}
if logger != nil && err != nil {
logger.Warn().Err(err).Str("activation_id", activation.ActivationID).Msg("Pending helper update commit failed; retrying until rollback deadline")
}
}
select {
case <-ctx.Done():
return rollback(fmt.Errorf("runtime stopped before pending update commit: %w", ctx.Err()))
case <-deadline.C:
return rollback(errors.New("pending update did not reach local readiness and accepted-report health floor before deadline"))
case <-reportAccepted:
reportOK = true
reportAccepted = nil
case <-ticker.C:
}
}
}
type multiValue []string
func (m *multiValue) String() string {
@@ -383,7 +464,8 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
// 7. Start Auto-Updater
var privilegedUpdate agentupdate.PrivilegedUpdate
if helperSocket := strings.TrimSpace(os.Getenv("PULSE_AGENT_HELPER_SOCKET")); helperSocket != "" {
helperSocket := strings.TrimSpace(os.Getenv("PULSE_AGENT_HELPER_SOCKET"))
if helperSocket != "" {
privilegedUpdate, err = newPrivilegeHelperUpdate(helperSocket)
if err != nil {
return fmt.Errorf("configure typed privilege-helper updates: %w", err)
@@ -403,6 +485,18 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
Disabled: cfg.DisableAutoUpdate,
PrivilegedUpdate: privilegedUpdate,
})
var pendingUpdate *agentupdate.PendingPrivilegedUpdate
if helperSocket != "" {
pendingUpdate, err = agentupdate.LoadPendingPrivilegedUpdate(cfg.StateDir)
if err != nil {
return fmt.Errorf("load pending helper update handoff: %w", err)
}
}
if pendingUpdate != nil && privilegedUpdate == nil {
return errors.New("pending helper update cannot be verified without the typed privilege helper")
}
pendingReportAccepted := make(chan struct{})
var pendingReportOnce sync.Once
g.Go(func() error {
updater.RunLoop(ctx)
@@ -419,6 +513,14 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
if err != nil {
return fmt.Errorf("configure typed privilege helper: %w", err)
}
if privilegedTelemetry != nil {
healthCtx, cancelHealth := context.WithTimeout(ctx, 10*time.Second)
healthErr := privilegedTelemetry.Health(healthCtx)
cancelHealth()
if healthErr != nil {
return fmt.Errorf("verify typed privilege helper protocol: %w", healthErr)
}
}
hostCfg := hostagent.Config{
PulseURL: cfg.PulseURL,
APIToken: cfg.APIToken,
@@ -450,10 +552,16 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
ModuleStatus: runtimeStatus.moduleStatuses,
Observers: hostObserverTargets(cfg.Observers),
PrivilegedTelemetry: privilegedTelemetry,
UpdatedFromVersion: pendingUpdatePreviousVersion(pendingUpdate),
DockerContainerUpdater: dockerUpdaterBridge,
DockerContainerLifecycleOperator: dockerUpdaterBridge,
}
if pendingUpdate != nil {
hostCfg.OnPrimaryReportAccepted = func() {
pendingReportOnce.Do(func() { close(pendingReportAccepted) })
}
}
wireUpdaterHooks(&hostCfg, updater)
agent, err := newHostAgent(hostCfg)
@@ -592,6 +700,20 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
return nil
})
}
if pendingUpdate != nil {
g.Go(func() error {
return supervisePendingPrivilegedUpdate(
ctx,
privilegedUpdate,
pendingUpdate,
cfg.StateDir,
ready.Load,
pendingReportAccepted,
250*time.Millisecond,
&logger,
)
})
}
// 11. Wait for all agents to exit
if err := g.Wait(); err != nil && err != context.Canceled {
+221 -1
View File
@@ -18,6 +18,7 @@ import (
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/agenthelper"
"github.com/rcourtman/pulse-go-rewrite/internal/agentupdate"
"github.com/rcourtman/pulse-go-rewrite/internal/dockeragent"
"github.com/rcourtman/pulse-go-rewrite/internal/hostagent"
@@ -1754,9 +1755,10 @@ func TestRunConfiguresTypedPrivilegeHelperFromInstallerEnvironment(t *testing.T)
t.Setenv("PULSE_AGENT_HELPER_SOCKET", socketPath)
configuredPath := ""
configuredUpdatePath := ""
helper := &helperHealthStub{}
newPrivilegeHelperTelemetry = func(path string) (hostagent.PrivilegedTelemetry, error) {
configuredPath = path
return nil, nil
return helper, nil
}
newPrivilegeHelperUpdate = func(path string) (agentupdate.PrivilegedUpdate, error) {
configuredUpdatePath = path
@@ -1786,6 +1788,224 @@ func TestRunConfiguresTypedPrivilegeHelperFromInstallerEnvironment(t *testing.T)
if configuredUpdatePath != socketPath {
t.Fatalf("helper update socket path = %q, want %q", configuredUpdatePath, socketPath)
}
if helper.healthCalls != 1 {
t.Fatalf("helper health calls = %d, want 1", helper.healthCalls)
}
}
type helperHealthStub struct {
hostagent.PrivilegedTelemetry
healthErr error
healthCalls int
}
func (h *helperHealthStub) Health(context.Context) error {
h.healthCalls++
return h.healthErr
}
func TestRunRejectsUnhealthyTypedPrivilegeHelper(t *testing.T) {
originalHelper := newPrivilegeHelperTelemetry
originalUpdate := newPrivilegeHelperUpdate
originalUpdater := newUpdater
originalHost := newHostAgent
defer func() {
newPrivilegeHelperTelemetry = originalHelper
newPrivilegeHelperUpdate = originalUpdate
newUpdater = originalUpdater
newHostAgent = originalHost
}()
t.Setenv("PULSE_AGENT_HELPER_SOCKET", "/run/pulse-agent/helper.sock")
helper := &helperHealthStub{healthErr: errors.New("incompatible helper")}
newPrivilegeHelperTelemetry = func(string) (hostagent.PrivilegedTelemetry, error) {
return helper, nil
}
newPrivilegeHelperUpdate = func(string) (agentupdate.PrivilegedUpdate, error) {
return nil, nil
}
newUpdater = func(agentupdate.Config) *agentupdate.Updater {
return agentupdate.New(agentupdate.Config{Disabled: true})
}
hostCreated := false
newHostAgent = func(hostagent.Config) (Runnable, error) {
hostCreated = true
return &mockRunnable{}, nil
}
err := run(context.Background(), []string{
"-token", "deadbeef",
"-enable-docker=false",
"-enable-kubernetes=false",
"-health-addr", "",
}, func(string) string { return "" })
if err == nil || !strings.Contains(err.Error(), "verify typed privilege helper protocol") {
t.Fatalf("run error = %v", err)
}
if helper.healthCalls != 1 {
t.Fatalf("helper health calls = %d, want 1", helper.healthCalls)
}
if hostCreated {
t.Fatal("host agent was created after helper health failed")
}
}
type pendingUpdateSupervisorStub struct {
commitResult agenthelper.UpdateResult
rollbackResult agenthelper.UpdateResult
commitErr error
rollbackErr error
commitCalls chan agenthelper.UpdateResult
rollbackCalls chan agenthelper.UpdateResult
}
func (s *pendingUpdateSupervisorStub) CreateQuarantinedArtifact() (string, *os.File, func() error, error) {
return "", nil, func() error { return nil }, errors.New("not implemented")
}
func (s *pendingUpdateSupervisorStub) WriteQuarantinedSignature(string, string) error {
return errors.New("not implemented")
}
func (s *pendingUpdateSupervisorStub) Stage(context.Context, string, string) (agenthelper.UpdateStageResult, error) {
return agenthelper.UpdateStageResult{}, errors.New("not implemented")
}
func (s *pendingUpdateSupervisorStub) Activate(context.Context, string, string) (agenthelper.UpdateResult, error) {
return agenthelper.UpdateResult{}, errors.New("not implemented")
}
func (s *pendingUpdateSupervisorStub) Commit(_ context.Context, activation agenthelper.UpdateResult) (agenthelper.UpdateResult, error) {
s.commitCalls <- activation
return s.commitResult, s.commitErr
}
func (s *pendingUpdateSupervisorStub) Rollback(_ context.Context, activation agenthelper.UpdateResult) (agenthelper.UpdateResult, error) {
s.rollbackCalls <- activation
return s.rollbackResult, s.rollbackErr
}
func testPendingUpdate(t *testing.T, stateDir string) *agentupdate.PendingPrivilegedUpdate {
t.Helper()
activation := agenthelper.UpdateResult{
Action: "pending",
ActivationID: "pulse-agent-0123456789abcdef0123456789abcdef:0123456789abcdef",
ActiveSHA256: strings.Repeat("a", 64),
RollbackSHA256: strings.Repeat("b", 64),
RollbackDeadline: time.Now().Add(2 * time.Second).UTC(),
}
if err := agentupdate.PersistPendingPrivilegedUpdate(stateDir, "1.0.0", activation); err != nil {
t.Fatal(err)
}
return &agentupdate.PendingPrivilegedUpdate{Activation: activation, PreviousVersion: "1.0.0"}
}
func TestPendingPrivilegedUpdateCommitsOnlyAfterReadinessAndAcceptedReport(t *testing.T) {
stateDir := t.TempDir()
if err := os.Chmod(stateDir, 0o700); err != nil {
t.Fatal(err)
}
pending := testPendingUpdate(t, stateDir)
stub := &pendingUpdateSupervisorStub{
commitResult: agenthelper.UpdateResult{
Action: "committed",
ActivationID: pending.Activation.ActivationID,
ActiveSHA256: pending.Activation.ActiveSHA256,
RollbackSHA256: pending.Activation.RollbackSHA256,
},
commitCalls: make(chan agenthelper.UpdateResult, 1),
rollbackCalls: make(chan agenthelper.UpdateResult, 1),
}
reportAccepted := make(chan struct{})
close(reportAccepted)
var ready atomic.Bool
result := make(chan error, 1)
go func() {
result <- supervisePendingPrivilegedUpdate(context.Background(), stub, pending, stateDir, ready.Load, reportAccepted, time.Millisecond, nil)
}()
select {
case <-stub.commitCalls:
t.Fatal("pending update committed before local readiness")
case <-time.After(20 * time.Millisecond):
}
ready.Store(true)
select {
case activation := <-stub.commitCalls:
if activation != pending.Activation {
t.Fatalf("commit activation = %#v", activation)
}
case <-time.After(time.Second):
t.Fatal("pending update was not committed after both health signals")
}
if err := <-result; err != nil {
t.Fatal(err)
}
if loaded, err := agentupdate.LoadPendingPrivilegedUpdate(stateDir); err != nil || loaded != nil {
t.Fatalf("committed handoff = %#v, %v", loaded, err)
}
select {
case <-stub.rollbackCalls:
t.Fatal("healthy pending update rolled back")
default:
}
}
func TestPendingPrivilegedUpdateCancellationRollsBackAndClearsHandoff(t *testing.T) {
stateDir := t.TempDir()
if err := os.Chmod(stateDir, 0o700); err != nil {
t.Fatal(err)
}
pending := testPendingUpdate(t, stateDir)
stub := &pendingUpdateSupervisorStub{
rollbackResult: agenthelper.UpdateResult{
Action: "rolled_back",
ActivationID: pending.Activation.ActivationID,
ActiveSHA256: pending.Activation.RollbackSHA256,
RollbackSHA256: pending.Activation.ActiveSHA256,
},
commitCalls: make(chan agenthelper.UpdateResult, 1),
rollbackCalls: make(chan agenthelper.UpdateResult, 1),
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := supervisePendingPrivilegedUpdate(ctx, stub, pending, stateDir, func() bool { return false }, make(chan struct{}), time.Millisecond, nil)
if err == nil || !strings.Contains(err.Error(), "pending update rolled back") {
t.Fatalf("supervisor error = %v", err)
}
select {
case activation := <-stub.rollbackCalls:
if activation != pending.Activation {
t.Fatalf("rollback activation = %#v", activation)
}
default:
t.Fatal("pending update was not rolled back")
}
if loaded, loadErr := agentupdate.LoadPendingPrivilegedUpdate(stateDir); loadErr != nil || loaded != nil {
t.Fatalf("rolled-back handoff = %#v, %v", loaded, loadErr)
}
}
func TestPendingPrivilegedUpdateRollbackFailurePreservesHandoff(t *testing.T) {
stateDir := t.TempDir()
if err := os.Chmod(stateDir, 0o700); err != nil {
t.Fatal(err)
}
pending := testPendingUpdate(t, stateDir)
stub := &pendingUpdateSupervisorStub{
rollbackErr: errors.New("helper unavailable"),
commitCalls: make(chan agenthelper.UpdateResult, 1),
rollbackCalls: make(chan agenthelper.UpdateResult, 1),
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := supervisePendingPrivilegedUpdate(ctx, stub, pending, stateDir, func() bool { return false }, make(chan struct{}), time.Millisecond, nil)
if err == nil || !strings.Contains(err.Error(), "typed helper rollback failed") {
t.Fatalf("supervisor error = %v", err)
}
loaded, loadErr := agentupdate.LoadPendingPrivilegedUpdate(stateDir)
if loadErr != nil || loaded == nil || loaded.Activation != pending.Activation {
t.Fatalf("failed-rollback handoff = %#v, %v", loaded, loadErr)
}
}
func TestRun_AgentFailure(t *testing.T) {
@@ -1,6 +1,6 @@
# Unified Agent Privilege-Boundary Plan
Last updated: 2026-08-29
Last updated: 2026-08-30
Status: PROPOSED
Governance surfaces:
- `status.json.coverage_gaps.agent-privilege-boundary-separation`
@@ -70,6 +70,40 @@ systemd sandboxing, scoped API tokens, and the optional least-privilege profile
is retained as useful input. None of it substitutes for separating monitoring
from root-equivalent and remediation authority.
## Current Implementation Checkpoint (2026-08-30)
The optional Linux systemd path now establishes more of the intended boundary,
without changing the product default:
- a configured helper must answer the exact versioned health protocol before
collector readiness, and failed helper SMART/Proxmox reads do not fall back
into local privileged collection;
- helper-backed collector activation is pending under a bounded deadline until
the replacement is locally ready and a newly collected authoritative report
is accepted; explicit commit, restart/power-loss recovery, watchdog rollback,
and fixed staging/quarantine cleanup preserve the last-known-good binary;
- runner enrollment persists the canonical hostname, durable credential
rotation invalidates only the superseded live session after storage commits,
and uninstall performs an exact bearer self-revoke without exposing the
secret in argv;
- safe migration rejects effective unit overrides, requires a fresh post-start
registration timestamp and live helper protocol response, restores complete
state metadata and Proxmox markers on rollback, and disables rootful Docker
unless a usable collector-owned rootless socket exists.
This is a qualification foundation, not the Slice E ratchet. The generated
setup default remains the legacy/root profile. A clean disposable arm64 Ubuntu
systemd exercise now proves exact-working-tree install, migration, explicit and
automatic rollback, ordinary update isolation, typed helper health, fresh
registration, and continued reporting; its secret-free receipt is recorded in
`internal/records/secure-agent-runtime-systemd-receipt-2026-08-30.json`. The
repository still needs exact committed release-candidate reproduction, runner
rotation/revocation and typed action-receipt proof, representative Proxmox,
SMART, Docker and rootless Podman telemetry/action parity, appliance profiles,
and the external security review. Until those proofs are recorded, the safe
profile remains opt-in and provider degradation remains an explicit residual
rather than evidence that the candidate lane is complete.
## Target Architecture
### 1. API-Only Monitoring
@@ -0,0 +1,76 @@
# Secure agent runtime qualification foundation (2026-08-30)
## Scope
This slice closes implementation defects found while preparing a real Linux
systemd qualification of the optional collector/helper/action-runner split. It
also records a managed-development real-systemd qualification of the collector
and helper migration boundary. It does not flip the installer default and is
not exact committed release-candidate or representative live-provider proof.
## Landed runtime semantics
- Collector startup exercises `helper.health` over the versioned local
protocol. Once configured, helper failures omit SMART or Proxmox privileged
snapshots instead of falling back to local privileged collection.
- Helper-backed update activation persists a pending identity, active and
last-known-good digests, and a bounded rollback deadline. The replacement
process commits only after local readiness and acceptance of its newly
collected authoritative report. Interrupted, expired, or uncommitted state
recovers to last-known-good, and terminal transitions reap fixed staging and
quarantine artifacts.
- The action runner uses the canonical enrollment hostname. Durable rotation
invalidates exactly the superseded live organization/token/agent/hostname
session after token storage succeeds. Runner teardown attempts exact
bearer-authenticated self-revocation before removing local remediation state;
remote failure remains visible and does not stop local removal.
- Safe-profile apply rejects foreign effective fragments and all systemd
drop-ins before mutation. Commit requires the intended non-root unit, a live
helper protocol response, and a server registration timestamp newer than the
stopped legacy collector. Rollback restores full state-tree metadata and the
Proxmox registration markers touched by installation. Transient runtime
files that disappear while the collector is stopped do not prevent rollback
from restoring every surviving pre-migration entry. Rootful Docker is
disabled unless the collector owns a usable rootless runtime socket. Local
readiness and helper health receive a bounded startup window before the
separate authoritative registration proof. Installed collector binaries are
pinned to mode `0755` rather than inheriting `mktemp` permissions.
## Managed-development systemd proof
The disposable arm64 Colima profile `pulse-agent-qual` ran Ubuntu 24.04.4,
kernel 6.8.0-117, and systemd 255. The exact working-tree installer and lab
source are bound by the hashes in
`secure-agent-runtime-systemd-receipt-2026-08-30.json`; the receipt itself has
SHA-256 `c20aee566835ac88f6163085d2d559c3fa493274b1a8eae328ed39063af06e5e`.
The guarded lab passed all of these destructive scenarios from a clean VM:
- legacy root/command-capable install with rootful Docker enabled;
- read-only profile inspection and fail-closed rejection of a systemd drop-in;
- migration to a non-root `pulse-agent` collector with no ambient capabilities,
monitoring-only authority, a healthy typed helper, fresh server `lastSeen`,
explicit rootful-Docker degradation, and continued reporting;
- exact explicit rollback and automatic rollback when server freshness was
frozen, including stable binary, unit, credential-state, ownership, and mode
identity;
- ordinary update without implicit privilege migration, followed by a final
committed safe-profile migration.
The receipt is intentionally secret-free and contains source/artifact hashes,
host facts, resulting privilege posture, report timestamps, and the eight
scenario outcomes. This is managed-development proof because it exercised the
working tree before its eventual commit identity existed.
## Proof classification and residuals
The focused protocol, update, runner, API, and installer regressions plus the
managed systemd receipt are proof for these semantics. They are not a
substitute for exact committed release-candidate evidence, representative
provider/appliance qualification, or external review.
The safe profile therefore remains opt-in. The default may not ratchet until
the exact release candidate reproduces the systemd result and proves runner
rotation/revocation plus typed action receipts; representative Proxmox, SMART,
Docker and rootless Podman parity is recorded; supported appliance residuals
are owned; and the helper, update, action credential, and migration boundaries
complete external security review.
@@ -0,0 +1,69 @@
{
"schema_version": 1,
"completed_at": "2026-08-30T00:39:22.928744899Z",
"source_hashes": {
"scripts/install.sh": "0b557c9394f3f0741cbf4287e5ee16a88fe03e189678bddf754606458c4e276e",
"scripts/installtests/secure_runtime_systemd_lab_test.go": "cebaeca194bd2ef1a3754230026ab73cd7c8b28427c773dbc62c86f4cd69ba28"
},
"artifact_hashes": {
"collector_v1": "c9c775085178102d7fea5ecc899960a3e27ad7dc41c632a83f01e0f642942379",
"collector_v2": "2b52cbf20884d391acd7c072b7c4af011377ef1f19b97725a76ad18a3d1154fa",
"helper": "9d865a4adb9fdd5e70f36ee4021acefd2b5bfe1010739ab1dd916c701818032d"
},
"os_release": "PRETTY_NAME=\"Ubuntu 24.04.4 LTS\"\nNAME=\"Ubuntu\"\nVERSION_ID=\"24.04\"\nVERSION=\"24.04.4 LTS (Noble Numbat)\"\nVERSION_CODENAME=noble\nID=ubuntu\nID_LIKE=debian\nHOME_URL=\"https://www.ubuntu.com/\"\nSUPPORT_URL=\"https://help.ubuntu.com/\"\nBUG_REPORT_URL=\"https://bugs.launchpad.net/ubuntu/\"\nPRIVACY_POLICY_URL=\"https://www.ubuntu.com/legal/terms-and-policies/privacy-policy\"\nUBUNTU_CODENAME=noble\nLOGO=ubuntu-logo",
"kernel": "Linux 6.8.0-117-generic #117-Ubuntu SMP PREEMPT_DYNAMIC Thu May 7 17:26:37 UTC 2026 aarch64 GNU/Linux",
"systemd_version": "systemd 255 (255.4-1ubuntu8.15)",
"architecture": "arm64",
"collector_service_user": "pulse-agent",
"collector_process_uid": 996,
"collector_authority": "monitoring-only",
"ambient_capabilities_none": true,
"helper_protocol_healthy": true,
"state_identity_preserved": true,
"docker_degraded": true,
"report_count": 26,
"first_report_at": "2026-08-30T00:38:25.603885842Z",
"last_report_at": "2026-08-30T00:39:22.777779187Z",
"scenarios": [
{
"name": "legacy_root_command_capable_install",
"passed": true,
"detail": "root collector installed; docker_enabled=true"
},
{
"name": "read_only_inspect",
"passed": true,
"detail": "stable installer-owned files unchanged"
},
{
"name": "drop_in_fail_closed_rehearsal",
"passed": true,
"detail": "migration rejected before stable installer-owned files changed"
},
{
"name": "safe_profile_apply",
"passed": true,
"detail": "fresh server lastSeen, least-privilege identity, typed helper health"
},
{
"name": "explicit_safe_profile_rollback",
"passed": true,
"detail": "legacy binary, unit, authority, and service identity restored"
},
{
"name": "automatic_failure_rollback",
"passed": true,
"detail": "frozen lastSeen prevented commit and restored binary/unit/state identity"
},
{
"name": "ordinary_update_non_migration",
"passed": true,
"detail": "binary updated while root command-capable profile remained unchanged"
},
{
"name": "final_safe_profile_apply",
"passed": true,
"detail": "collector continued reporting after committed migration"
}
]
}
@@ -43,6 +43,24 @@ host-storage-cleanup, Proxmox guest lifecycle, and container lifecycle/update
operations. Generic shell, exec, unrestricted `read_file`, and deploy requests
are forbidden. Removing or disabling the runner leaves collector monitoring
and the helper unchanged.
When that helper socket is configured, collector startup must complete a
versioned `helper.health` request before the runtime may become ready. A
listening or active systemd socket alone is not helper health. Once the helper
boundary is selected, SMART and Proxmox LXC filesystem collection fail closed
to an omitted/degraded snapshot when the helper fails; the collector must not
silently retry the privileged read locally.
Helper-backed collector updates are a durable pending transaction rather than
a successful binary swap. The helper records activation intent before the
root-owned replacement, retains the last-known-good digest, and places the new
binary under a bounded rollback deadline. The replacement process must load
the private pending handoff, pass local readiness, and deliver a newly
collected authoritative primary report before it may issue the identity-bound
typed commit. Buffered or observer reports do not satisfy that floor. An
expired, interrupted, invalid, or uncommitted activation rolls back during
helper recovery or through the deadline watchdog; commit and rollback remove
their fixed staging/quarantine artifacts durably. The first accepted report
retains the previous-version update evidence from that handoff.
The runner's server transport currently reuses the combined agent command
WebSocket envelope as a migration boundary. That compatibility path may carry
@@ -53,6 +71,19 @@ have an action-runner migration path, live action-runner session parity is
qualified, and no supported client depends on collector command delivery.
Until those criteria are met, the combined collector command path is explicitly
legacy/full-trust compatibility, not part of the safe profile.
The runner service persists the same normalized canonical hostname used when
its credential was issued; it must not substitute the machine's incidental OS
hostname when the collector was enrolled under an override. Credential
rotation invalidates exactly the superseded organization/token/agent/hostname
session only after the replacement token inventory is durably stored. That
invalidation removes the session from dispatch, closes its transport, and
unblocks server-side waits; an already-started host mutation remains governed
by its typed receipt and best-effort cancellation semantics rather than being
described as rolled back. Runner uninstall attempts an authenticated
credential self-revoke before deleting local state. The delete route may
remove only the caller's exact host-bound action-runner record; an unreachable
server cannot prevent local runner removal and leaves an explicit operator
revocation residual.
Fresh installs carry an explicit local command-authority profile. The closed
values are `monitoring-only`, `command-capable`, and `legacy`. A
`monitoring-only` service may accept remote configuration that keeps commands
@@ -41,10 +41,22 @@ runtime role `action-runner`, binding version 1, and capability
non-conflicted, non-integration host in the request tenant. Issuing again
atomically replaces only the earlier action-runner record for that tenant and
agent ID; a persistence failure restores the complete token inventory and
returns no usable new secret. Monitoring credentials cannot call the issuance
route or authenticate an action-runner session, and action-runner credentials
cannot report, read agent configuration, or manage collectors. Unknown request
fields and ambiguous, mismatched, or conflicted host identities fail closed.
returns no usable new secret. Only after that durable replacement may the
server close the exact superseded live action-runner session, remove it from
new dispatch, and unblock its outstanding server waits. The created response
is non-cacheable.
`DELETE /api/agents/action-runner/credential` is the runner's narrowly scoped
self-revoke operation. It requires the current `agent:exec` bearer credential,
rejects browser/session authentication, accepts only `agentId` and `hostname`,
and deletes only when the caller token, organization, action-runner metadata,
and canonical host binding all match. The durable token inventory is restored
on persistence failure; after a successful delete, the exact admitted session
is invalidated and the route returns no credential material. Monitoring
credentials cannot call issuance or self-revoke or authenticate an
action-runner session, and action-runner credentials cannot report, read agent
configuration, or manage collectors. Unknown request fields and ambiguous,
mismatched, or conflicted host identities fail closed.
The API runtime is decomposed along production domain boundaries so Go can
compile and execute domain qualification packages concurrently. Shared tenant
@@ -130,6 +130,14 @@ solely to atomically replace the protocol-fixed `pulse-agent` target and its
last-known-good copy. The collector can select no privileged source, target,
path, command, or argument, and direct collector-owned replacement is not a
fallback.
Activation persists a pending identity and bounded rollback deadline before
the new binary is trusted. The replacement collector must pass local readiness
and have a fresh authoritative primary report accepted before it sends the
typed commit; helper startup/restart recovery and the watchdog restore the
last-known-good binary when activation is interrupted, expires, or never
commits. Commit and rollback durably reap the fixed staging and quarantine
artifacts. A socket-active check is insufficient: installer migration and the
collector both exercise the versioned helper health protocol.
The same supported Linux systemd profile can install `pulse-agent-runner` only
through the separate `--enable-action-runner` choice, a private token file,
@@ -138,7 +146,12 @@ configuration, credential, health record, and receipt database are root-owned
and independent from collector state. Activation is transactional and restores
the previous runner-only files if health does not become current; disable and
uninstall remove only remediation and leave monitoring running. The action
credential is never placed in argv or reused as the collector token.
credential is never placed in argv or reused as the collector token. The
installer persists the canonical enrollment hostname for runner admission and
uses a private curl configuration for best-effort exact self-revocation before
local teardown, so the bearer secret does not enter argv. Server unreachability
is warned explicitly but does not make local removal depend on the remote
control plane.
Safe-profile migration is never an ordinary update side effect.
`--safe-profile-inspect` is read-only and reports the current authority,
@@ -150,6 +163,16 @@ privilege or changing the independently enrolled runner. These operations fail
closed outside Linux systemd. Appliance, non-systemd, Windows, and macOS
profiles retain an explicitly named legacy/full-trust path until their service,
filesystem, update, helper, and runner boundaries have separate proof.
Apply also fails before mutation when the effective collector fragment differs
from the installer-owned unit or any systemd drop-in is present. Commit requires
the effective non-root/no-ambient-capability unit, a live helper protocol
response, and a registration `lastSeen` newer than the frozen legacy
collector's value. The rollback manifest restores all state-tree ownership and
modes plus the Proxmox registration markers the apply path can mutate. Rootful
Docker is an explicit migration degradation: it is disabled unless a
collector-owned, readable and writable rootless runtime socket is available;
the safe profile never restores Docker by adding the collector to a
root-equivalent group.
Release builds and archives carry both helper and runner binaries for the five
Linux targets (`amd64`, `arm64`, `armv7`, `armv6`, and `386`) with checksum,
@@ -53,6 +53,15 @@ shape, regular-file identity, symlink resistance, and byte ceiling before
copying into fixed root-owned staging. Activation and rollback then revalidate
the root boundary, perform atomic replacement, and durably bind the transition
identity before changing the root-owned collector binary.
Activation is not final authority: the helper records a bounded pending state
and last-known-good digest before replacement, and exposes an exact typed
commit. Only the replacement collector's local readiness plus an accepted new
authoritative report may request commit. Restart/power-loss recovery and the
deadline watchdog roll back an uncommitted activation, while strict private
handoff parsing and fixed artifact cleanup prevent a collector-selected target
or stale quarantine from widening that boundary. Configuring the helper also
requires a successful versioned health exchange at collector startup and
forbids privileged telemetry fallback into the collector process.
Remediation credentials belong only to the separately installed
`pulse-agent-runner`. They bind organization, canonical host identity, token
@@ -72,6 +81,14 @@ secret only after persistence succeeds, and restores the complete prior token
inventory if persistence fails. A successful rotation invalidates the previous
secret immediately; it never widens the collector credential or turns a
monitoring session into an action session.
That invalidation is exact and post-persistence: it matches organization,
token, canonical agent, hostname, runtime role, and typed capability before
closing the session, so a stale rotation cannot evict a replacement. The
runner may also delete only its own matching record using its bearer
credential; browser sessions and a caller-selected token ID are rejected, and
persistence failure restores the prior inventory. Installer teardown keeps the
secret in a private file/config boundary rather than argv and reports remote
revocation failure without retaining local remediation authority.
Own Pulse's canonical privacy disclosures, outbound usage-data boundary,
and the security-facing settings surfaces that expose authentication posture,
@@ -37,12 +37,15 @@ not storage-recovery authority inferred from the credential itself.
Runner credential rotation follows the shared token-inventory commit boundary:
failed persistence restores the complete prior inventory and returns no new
secret, while successful persistence replaces the previous organization/agent
binding and makes its secret unusable. That rollback protects restart-time
binding and invalidates only that superseded live session. Exact bearer
self-revocation uses the same durable boundary and cannot select another token,
organization, or host. That rollback protects restart-time
credential truth only; it is not a customer backup, recovery point, restore
operation, or storage-cleanup grant.
The helper-backed collector updater likewise retains only one fixed
last-known-good executable and an identity-bound activation receipt. That
binary rollback is agent lifecycle state, not a Pulse storage snapshot,
last-known-good executable and an identity-bound pending/commit receipt. A
startup recovery or deadline rollback of an uncommitted activation is agent
lifecycle state, not a Pulse storage snapshot,
recovery point, backup retention record, or authorization to read or mutate
customer storage/recovery data.
+47
View File
@@ -376,6 +376,53 @@ func (s *Server) connectionForOrganization(organizationID, agentID string) (*age
return nil, false
}
// InvalidateActionRunnerSession closes exactly the currently admitted typed
// action-runner session identified by admission. A stale rotation result must
// never evict a replacement session that has already registered for the same
// tenant and host identity.
func (s *Server) InvalidateActionRunnerSession(admission AgentAdmission) bool {
if s == nil {
return false
}
expected := AgentAdmission{
OrganizationID: normalizeOrganizationID(admission.OrganizationID),
TokenID: strings.TrimSpace(admission.TokenID),
AgentID: strings.TrimSpace(admission.AgentID),
Hostname: strings.TrimSpace(admission.Hostname),
RuntimeRole: strings.TrimSpace(admission.RuntimeRole),
ActionCapability: strings.TrimSpace(admission.ActionCapability),
}
if expected.TokenID == "" || expected.AgentID == "" || expected.Hostname == "" ||
expected.RuntimeRole != RuntimeRoleActionRunner || expected.ActionCapability != ActionCapabilityTypedV1 {
return false
}
key := agentSessionKey(expected.OrganizationID, expected.AgentID)
s.mu.Lock()
current, ok := s.agents[key]
if !ok || current == nil || !sameActionRunnerAdmission(current.admission, expected) {
s.mu.Unlock()
return false
}
delete(s.agents, key)
s.mu.Unlock()
current.signalDone()
if current.conn != nil {
_ = current.conn.Close()
}
return true
}
func sameActionRunnerAdmission(current, expected AgentAdmission) bool {
return normalizeOrganizationID(current.OrganizationID) == expected.OrganizationID &&
strings.TrimSpace(current.TokenID) == expected.TokenID &&
strings.TrimSpace(current.AgentID) == expected.AgentID &&
(strings.EqualFold(strings.TrimSpace(current.Hostname), expected.Hostname) ||
unifiedresources.HostnamesEquivalent(current.Hostname, expected.Hostname)) &&
strings.TrimSpace(current.RuntimeRole) == RuntimeRoleActionRunner &&
strings.TrimSpace(current.ActionCapability) == ActionCapabilityTypedV1
}
func requireLegacyFullTrustConnection(ac *agentConn, operation string) error {
if ac == nil {
return fmt.Errorf("agent connection is unavailable")
@@ -1239,6 +1239,73 @@ func TestRevokedAdmissionInvalidatesStaleSocketBeforeDispatch(t *testing.T) {
}
}
func TestInvalidateActionRunnerSessionClosesExactSessionAndUnblocksInflightDispatch(t *testing.T) {
admission := AgentAdmission{
OrganizationID: "org-a",
TokenID: "runner-token",
AgentID: "agent-a",
Hostname: "node.example",
RuntimeRole: RuntimeRoleActionRunner,
ActionCapability: ActionCapabilityTypedV1,
}
s := NewServerWithAdmissionValidator(func(token, _, _ string) (AgentAdmission, bool) {
return admission, token == admission.TokenID
}, func(candidate AgentAdmission) bool { return candidate == admission })
ts := newWSServer(t, s)
defer ts.Close()
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
wsWriteMessage(t, conn, mustNewMessage(t, MsgTypeAgentRegister, "", AgentRegisterPayload{
AgentID: admission.AgentID, Hostname: admission.Hostname, Token: admission.TokenID,
RuntimeRole: admission.RuntimeRole, ActionCapability: admission.ActionCapability,
OperationReceiptVersion: operationreceipt.ProtocolVersion,
}))
if ack := wsReadRegisteredPayload(t, conn); !ack.Success {
t.Fatalf("registration failed: %s", ack.Message)
}
ctx := WithOrganizationID(context.Background(), admission.OrganizationID)
result := make(chan error, 1)
go func() {
_, dispatchErr := s.ExecuteHostUpdate(ctx, admission.AgentID, HostUpdatePayload{
RequestID: "rotation-inflight", ActionID: "action-a", Operation: HostUpdateOperationInstall,
ExpectedInventoryHash: "sha256:" + strings.Repeat("a", 64), Timeout: 30,
})
result <- dispatchErr
}()
message, err := wsReadRawMessageWithTimeout(conn, 2*time.Second)
if err != nil || message.Type != MsgTypeHostUpdate {
t.Fatalf("in-flight dispatch = %#v, %v", message, err)
}
wrong := admission
wrong.TokenID = "replacement-token"
if s.InvalidateActionRunnerSession(wrong) {
t.Fatal("mismatched replacement identity evicted the current session")
}
if !s.InvalidateActionRunnerSession(admission) {
t.Fatal("exact action-runner admission was not invalidated")
}
select {
case dispatchErr := <-result:
if dispatchErr == nil || !strings.Contains(dispatchErr.Error(), "disconnected") {
t.Fatalf("in-flight dispatch error = %v", dispatchErr)
}
case <-time.After(2 * time.Second):
t.Fatal("in-flight dispatch did not unblock after session invalidation")
}
if _, err := s.ExecuteHostUpdate(ctx, admission.AgentID, HostUpdatePayload{
RequestID: "after-rotation", ActionID: "action-b", Operation: HostUpdateOperationInstall,
ExpectedInventoryHash: "sha256:" + strings.Repeat("b", 64), Timeout: 1,
}); err == nil || !strings.Contains(err.Error(), "not connected") {
t.Fatalf("new dispatch after invalidation = %v", err)
}
}
// registerCancelTestAgent registers agent "a1" over the websocket harness and
// returns after the registration ack has been read.
func registerCancelTestAgent(t *testing.T, s *Server, tsURL string) *cancelTestConn {
+1
View File
@@ -40,6 +40,7 @@ const (
OperationContainerInventory = "container.inventory"
OperationAgentUpdateStage = "agent_update.stage"
OperationAgentUpdateActivate = "agent_update.activate"
OperationAgentUpdateCommit = "agent_update.commit"
OperationAgentUpdateRollback = "agent_update.rollback"
OperationVersion1 = 1
)
+1
View File
@@ -33,6 +33,7 @@ type ContainerProvider interface {
type UpdateProvider interface {
Stage(context.Context, UpdateStageRequest) (UpdateStageResult, error)
Activate(context.Context, UpdateActivateRequest) (UpdateResult, error)
Commit(context.Context, UpdateCommitRequest) (UpdateResult, error)
Rollback(context.Context, UpdateRollbackRequest) (UpdateResult, error)
}
+14
View File
@@ -131,6 +131,20 @@ func NewRegistryWithProviders(smart SMARTProvider, proxmox ProxmoxProvider, prov
}
return marshalOperationResult(result)
})
registry.add(OperationAgentUpdateCommit, OperationVersion1, providers.Updates != nil, func(ctx context.Context, payload json.RawMessage) (json.RawMessage, *ResponseError) {
var request UpdateCommitRequest
if err := decodePayload(payload, &request); err != nil {
return nil, invalidPayloadError(err)
}
if providers.Updates == nil {
return nil, unavailableError("agent update provider is not configured")
}
result, err := providers.Updates.Commit(ctx, request)
if err != nil {
return nil, providerError(err)
}
return marshalOperationResult(result)
})
registry.add(OperationAgentUpdateRollback, OperationVersion1, providers.Updates != nil, func(ctx context.Context, payload json.RawMessage) (json.RawMessage, *ResponseError) {
var request UpdateRollbackRequest
if err := decodePayload(payload, &request); err != nil {
+17 -2
View File
@@ -34,6 +34,7 @@ func (f fakeContainerProvider) Inventory(ctx context.Context) (json.RawMessage,
type fakeUpdateProvider struct {
stage func(context.Context, UpdateStageRequest) (UpdateStageResult, error)
activate func(context.Context, UpdateActivateRequest) (UpdateResult, error)
commit func(context.Context, UpdateCommitRequest) (UpdateResult, error)
rollback func(context.Context, UpdateRollbackRequest) (UpdateResult, error)
}
@@ -45,6 +46,10 @@ func (f fakeUpdateProvider) Activate(ctx context.Context, request UpdateActivate
return f.activate(ctx, request)
}
func (f fakeUpdateProvider) Commit(ctx context.Context, request UpdateCommitRequest) (UpdateResult, error) {
return f.commit(ctx, request)
}
func (f fakeUpdateProvider) Rollback(ctx context.Context, request UpdateRollbackRequest) (UpdateResult, error) {
return f.rollback(ctx, request)
}
@@ -187,6 +192,7 @@ func TestServerDispatchesClosedContainerAndUpdateOperations(t *testing.T) {
stageCalled := false
activateCalled := false
rollbackCalled := false
commitCalled := false
updates := fakeUpdateProvider{
stage: func(_ context.Context, request UpdateStageRequest) (UpdateStageResult, error) {
stageCalled = request.ArtifactID == "release-1"
@@ -194,7 +200,11 @@ func TestServerDispatchesClosedContainerAndUpdateOperations(t *testing.T) {
},
activate: func(_ context.Context, request UpdateActivateRequest) (UpdateResult, error) {
activateCalled = request.ArtifactID == "release-1"
return UpdateResult{Action: "activated", ActivationID: "release-1:0123456789abcdef"}, nil
return UpdateResult{Action: "pending", ActivationID: "release-1:0123456789abcdef"}, nil
},
commit: func(_ context.Context, request UpdateCommitRequest) (UpdateResult, error) {
commitCalled = request.ActivationID == "release-1:0123456789abcdef"
return UpdateResult{Action: "committed", ActivationID: request.ActivationID}, nil
},
rollback: func(_ context.Context, request UpdateRollbackRequest) (UpdateResult, error) {
rollbackCalled = request.ActivationID == "release-1:0123456789abcdef"
@@ -222,13 +232,18 @@ func TestServerDispatchesClosedContainerAndUpdateOperations(t *testing.T) {
if response := exchange(t, server, activate); !response.Success || !activateCalled {
t.Fatalf("activate response=%#v called=%t", response, activateCalled)
}
commit := validRequest(OperationAgentUpdateCommit)
commit.Payload = json.RawMessage(`{"activationId":"release-1:0123456789abcdef","currentSha256":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}`)
if response := exchange(t, server, commit); !response.Success || !commitCalled {
t.Fatalf("commit response=%#v called=%t", response, commitCalled)
}
rollback := validRequest(OperationAgentUpdateRollback)
rollback.Payload = json.RawMessage(`{"activationId":"release-1:0123456789abcdef","currentSha256":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","rollbackSha256":"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"}`)
if response := exchange(t, server, rollback); !response.Success || !rollbackCalled {
t.Fatalf("rollback response=%#v called=%t", response, rollbackCalled)
}
for _, operation := range []string{OperationContainerInventory, OperationAgentUpdateStage, OperationAgentUpdateActivate, OperationAgentUpdateRollback} {
for _, operation := range []string{OperationContainerInventory, OperationAgentUpdateStage, OperationAgentUpdateActivate, OperationAgentUpdateCommit, OperationAgentUpdateRollback} {
request := validRequest(operation)
request.Payload = json.RawMessage(`{"path":"/tmp/attacker","args":["sh"]}`)
requireErrorCode(t, exchange(t, server, request), ErrorInvalidRequest)
+283 -50
View File
@@ -16,6 +16,12 @@ import (
const maxUpdateArtifactBytes = 100 * 1024 * 1024
const (
defaultUpdateRollbackWindow = 2 * time.Minute
minUpdateRollbackWindow = 5 * time.Second
maxUpdateRollbackWindow = 10 * time.Minute
)
type UpdateActivateRequest struct {
ArtifactID string `json:"artifactId"`
SHA256 string `json:"sha256"`
@@ -39,12 +45,18 @@ type UpdateRollbackRequest struct {
RollbackSHA256 string `json:"rollbackSha256"`
}
type UpdateCommitRequest struct {
ActivationID string `json:"activationId"`
CurrentSHA256 string `json:"currentSha256"`
}
type UpdateResult struct {
Action string `json:"action"`
ActivationID string `json:"activationId"`
ActiveSHA256 string `json:"activeSha256"`
RollbackSHA256 string `json:"rollbackSha256"`
DurableAt time.Time `json:"durableAt"`
Action string `json:"action"`
ActivationID string `json:"activationId"`
ActiveSHA256 string `json:"activeSha256"`
RollbackSHA256 string `json:"rollbackSha256"`
RollbackDeadline time.Time `json:"rollbackDeadline,omitempty"`
DurableAt time.Time `json:"durableAt"`
}
type UpdateActivatorConfig struct {
@@ -56,6 +68,8 @@ type UpdateActivatorConfig struct {
ValidateOwner func(*os.File) error
ValidateQuarantineOwner func(*os.File) error
Now func() time.Time
RollbackWindow time.Duration
ScheduleRollback func(time.Duration, func()) func()
}
type updateActivator struct {
@@ -69,14 +83,18 @@ type updateActivator struct {
validateOwner func(*os.File) error
validateQuarantineOwner func(*os.File) error
now func() time.Time
rollbackWindow time.Duration
scheduleRollback func(time.Duration, func()) func()
cancelRollback func()
}
type durableUpdateState struct {
Action string `json:"action"`
ActivationID string `json:"activationId"`
ActiveSHA256 string `json:"activeSha256"`
RollbackSHA256 string `json:"rollbackSha256"`
UpdatedAt time.Time `json:"updatedAt"`
Action string `json:"action"`
ActivationID string `json:"activationId"`
ActiveSHA256 string `json:"activeSha256"`
RollbackSHA256 string `json:"rollbackSha256"`
RollbackDeadline time.Time `json:"rollbackDeadline,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
}
func NewUpdateActivator(config UpdateActivatorConfig) (UpdateProvider, error) {
@@ -98,12 +116,31 @@ func NewUpdateActivator(config UpdateActivatorConfig) (UpdateProvider, error) {
if now == nil {
now = time.Now
}
return &updateActivator{
rollbackWindow := config.RollbackWindow
if rollbackWindow == 0 {
rollbackWindow = defaultUpdateRollbackWindow
}
if rollbackWindow < minUpdateRollbackWindow || rollbackWindow > maxUpdateRollbackWindow {
return nil, errors.New("update rollback window must be between 5 seconds and 10 minutes")
}
scheduleRollback := config.ScheduleRollback
if scheduleRollback == nil {
scheduleRollback = func(delay time.Duration, callback func()) func() {
timer := time.AfterFunc(delay, callback)
return func() { timer.Stop() }
}
}
activator := &updateActivator{
quarantineDir: config.QuarantineDir, stagingDir: config.StagingDir, targetPath: config.TargetPath,
rollbackPath: config.TargetPath + ".last-known-good", statePath: config.StatePath,
verifySignature: config.VerifySignature, validateOwner: config.ValidateOwner,
validateQuarantineOwner: config.ValidateQuarantineOwner, now: now,
}, nil
rollbackWindow: rollbackWindow, scheduleRollback: scheduleRollback,
}
if err := activator.recoverUncommittedLocked(); err != nil {
return nil, err
}
return activator, nil
}
// Stage promotes exactly one signed collector-downloaded artifact from the
@@ -186,17 +223,30 @@ func (u *updateActivator) Activate(ctx context.Context, request UpdateActivateRe
return UpdateResult{}, invalidArtifact("staged artifact signature is invalid")
}
activationID := request.ArtifactID + ":" + actual[:16]
if state, err := u.readState(); err == nil && state.ActivationID == activationID {
if state.Action != "activated" || !strings.EqualFold(state.ActiveSHA256, actual) {
return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "artifact identity has already completed a different update transition"}
if state, err := u.readState(); err == nil {
if state.Action == "preparing" || state.Action == "pending" {
if state.ActivationID != activationID {
return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "another agent update is still awaiting commit or rollback"}
}
if state.Action == "preparing" {
if err := u.recoverStateLocked(state); err != nil {
return UpdateResult{}, err
}
return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "interrupted activation was rolled back and cannot be replayed"}
}
if !strings.EqualFold(state.ActiveSHA256, actual) {
return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "artifact identity has already completed a different update transition"}
}
if err := u.validateInstalledState(state); err != nil {
return UpdateResult{}, err
}
u.schedulePendingRollbackLocked(state)
return updateResultFromState(state), nil
}
current, currentErr := u.readOwnedBounded(u.targetPath, maxUpdateArtifactBytes)
rollback, rollbackErr := u.readOwnedBounded(u.rollbackPath, maxUpdateArtifactBytes)
if currentErr != nil || rollbackErr != nil || !strings.EqualFold(sha256Hex(current), state.ActiveSHA256) || !strings.EqualFold(sha256Hex(rollback), state.RollbackSHA256) {
return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "durable activation no longer matches installed binaries"}
if state.ActivationID == activationID {
return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "artifact identity has already completed its update transition"}
}
return UpdateResult{Action: state.Action, ActivationID: state.ActivationID, ActiveSHA256: state.ActiveSHA256, RollbackSHA256: state.RollbackSHA256, DurableAt: state.UpdatedAt}, nil
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
} else if !errors.Is(err, os.ErrNotExist) {
return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "durable update state is invalid"}
}
if err := ctx.Err(); err != nil {
@@ -207,20 +257,76 @@ func (u *updateActivator) Activate(ctx context.Context, request UpdateActivateRe
return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "installed agent binary is unavailable or unsafe"}
}
rollbackDigest := sha256Hex(current)
deadline := u.now().UTC().Add(u.rollbackWindow)
preparing := durableUpdateState{
Action: "preparing", ActivationID: activationID, ActiveSHA256: actual,
RollbackSHA256: rollbackDigest, RollbackDeadline: deadline, UpdatedAt: u.now().UTC(),
}
if err := u.writeDurableState(preparing); err != nil {
return UpdateResult{}, &ProviderError{Code: ErrorInternal, Message: "persist activation intent"}
}
if err := u.installDurably(artifact, current); err != nil {
_ = u.recoverStateLocked(preparing)
return UpdateResult{}, &ProviderError{Code: ErrorInternal, Message: "activate staged agent binary"}
}
result := UpdateResult{
Action: "activated", ActivationID: activationID,
ActiveSHA256: actual, RollbackSHA256: rollbackDigest, DurableAt: u.now().UTC(),
pending := durableUpdateState{
Action: "pending", ActivationID: activationID,
ActiveSHA256: actual, RollbackSHA256: rollbackDigest,
RollbackDeadline: deadline, UpdatedAt: u.now().UTC(),
}
if err := u.writeState(result); err != nil {
// Restore the pre-activation binary if the durable receipt cannot
// commit. A failed operation must not leave an unjournaled activation.
_ = u.installDurably(current, artifact)
return UpdateResult{}, &ProviderError{Code: ErrorInternal, Message: "persist activation result"}
if err := u.writeDurableState(pending); err != nil {
if recoveryErr := u.recoverStateLocked(preparing); recoveryErr != nil {
return UpdateResult{}, &ProviderError{Code: ErrorInternal, Message: "persist pending activation and restore last-known-good binary"}
}
return UpdateResult{}, &ProviderError{Code: ErrorInternal, Message: "persist pending activation"}
}
return result, nil
u.schedulePendingRollbackLocked(pending)
return updateResultFromState(pending), nil
}
func (u *updateActivator) Commit(ctx context.Context, request UpdateCommitRequest) (UpdateResult, error) {
u.mu.Lock()
defer u.mu.Unlock()
if !validActivationID(request.ActivationID) || !validSHA256(request.CurrentSHA256) {
return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "commit identity is invalid"}
}
state, err := u.readState()
if err != nil {
return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "commit identity does not match a durable activation"}
}
if state.Action == "committed" && state.ActivationID == request.ActivationID && strings.EqualFold(state.ActiveSHA256, request.CurrentSHA256) {
return updateResultFromState(state), nil
}
if state.Action != "pending" || state.ActivationID != request.ActivationID || !strings.EqualFold(state.ActiveSHA256, request.CurrentSHA256) {
return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "commit identity does not match the pending activation"}
}
if !u.now().UTC().Before(state.RollbackDeadline) {
if err := u.recoverStateLocked(state); err != nil {
return UpdateResult{}, err
}
return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "activation deadline expired and the update was rolled back"}
}
if err := ctx.Err(); err != nil {
return UpdateResult{}, &ProviderError{Code: ErrorDeadlineExceeded, Message: "update commit deadline exceeded", Retryable: true}
}
if err := u.validateInstalledState(state); err != nil {
return UpdateResult{}, err
}
if err := u.removeStagedArtifact(artifactIDFromActivation(state.ActivationID)); err != nil {
return UpdateResult{}, &ProviderError{Code: ErrorInternal, Message: "remove committed update staging"}
}
pending := state
state.Action = "committed"
state.RollbackDeadline = time.Time{}
state.UpdatedAt = u.now().UTC()
if err := u.writeDurableState(state); err != nil {
if recoveryErr := u.recoverStateLocked(pending); recoveryErr != nil {
return UpdateResult{}, &ProviderError{Code: ErrorInternal, Message: "persist update commit and restore last-known-good binary"}
}
return UpdateResult{}, &ProviderError{Code: ErrorInternal, Message: "persist update commit"}
}
u.cancelPendingRollbackLocked()
return updateResultFromState(state), nil
}
func (u *updateActivator) Rollback(ctx context.Context, request UpdateRollbackRequest) (UpdateResult, error) {
@@ -230,34 +336,162 @@ func (u *updateActivator) Rollback(ctx context.Context, request UpdateRollbackRe
return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "rollback identity is invalid"}
}
state, err := u.readState()
if err != nil || state.Action != "activated" || state.ActivationID != request.ActivationID ||
if err != nil {
return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "rollback identity does not match a durable activation"}
}
if state.Action == "rolled_back" && state.ActivationID == request.ActivationID &&
strings.EqualFold(state.RollbackSHA256, request.CurrentSHA256) && strings.EqualFold(state.ActiveSHA256, request.RollbackSHA256) {
return updateResultFromState(state), nil
}
if (state.Action != "pending" && state.Action != "preparing") || state.ActivationID != request.ActivationID ||
!strings.EqualFold(state.ActiveSHA256, request.CurrentSHA256) ||
!strings.EqualFold(state.RollbackSHA256, request.RollbackSHA256) {
return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "rollback identity does not match the durable activation"}
}
current, err := u.readOwnedBounded(u.targetPath, maxUpdateArtifactBytes)
if err != nil || !strings.EqualFold(sha256Hex(current), state.ActiveSHA256) {
return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "installed agent identity changed after activation"}
}
rollback, err := u.readOwnedBounded(u.rollbackPath, maxUpdateArtifactBytes)
if err != nil || !strings.EqualFold(sha256Hex(rollback), state.RollbackSHA256) {
return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "last-known-good identity changed after activation"}
return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "rollback identity does not match the pending activation"}
}
if err := ctx.Err(); err != nil {
return UpdateResult{}, &ProviderError{Code: ErrorDeadlineExceeded, Message: "update rollback deadline exceeded", Retryable: true}
}
if err := u.installDurably(rollback, current); err != nil {
return UpdateResult{}, &ProviderError{Code: ErrorInternal, Message: "restore last-known-good agent binary"}
if err := u.recoverStateLocked(state); err != nil {
return UpdateResult{}, err
}
result := UpdateResult{
rolledBack, err := u.readState()
if err != nil {
return UpdateResult{}, &ProviderError{Code: ErrorInternal, Message: "read durable rollback result"}
}
return updateResultFromState(rolledBack), nil
}
func (u *updateActivator) recoverUncommittedLocked() error {
state, err := u.readState()
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return &ProviderError{Code: ErrorStateConflict, Message: "durable update state is invalid"}
}
if state.Action != "preparing" && state.Action != "pending" {
return nil
}
return u.recoverStateLocked(state)
}
func (u *updateActivator) recoverStateLocked(state durableUpdateState) error {
if state.Action != "preparing" && state.Action != "pending" {
return &ProviderError{Code: ErrorStateConflict, Message: "update state is not recoverable"}
}
current, err := u.readOwnedBounded(u.targetPath, maxUpdateArtifactBytes)
if err != nil {
return &ProviderError{Code: ErrorStateConflict, Message: "installed agent binary is unavailable during recovery"}
}
currentDigest := sha256Hex(current)
if strings.EqualFold(currentDigest, state.ActiveSHA256) {
rollback, rollbackErr := u.readOwnedBounded(u.rollbackPath, maxUpdateArtifactBytes)
if rollbackErr != nil || !strings.EqualFold(sha256Hex(rollback), state.RollbackSHA256) {
return &ProviderError{Code: ErrorStateConflict, Message: "last-known-good binary is unavailable during recovery"}
}
if err := u.installDurably(rollback, current); err != nil {
return &ProviderError{Code: ErrorInternal, Message: "restore last-known-good agent binary"}
}
} else if !strings.EqualFold(currentDigest, state.RollbackSHA256) {
return &ProviderError{Code: ErrorStateConflict, Message: "installed agent identity changed during pending activation"}
}
rolledBack := durableUpdateState{
Action: "rolled_back", ActivationID: state.ActivationID,
ActiveSHA256: state.RollbackSHA256, RollbackSHA256: state.ActiveSHA256, DurableAt: u.now().UTC(),
ActiveSHA256: state.RollbackSHA256, RollbackSHA256: state.ActiveSHA256,
UpdatedAt: u.now().UTC(),
}
if err := u.writeState(result); err != nil {
_ = u.installDurably(current, rollback)
return UpdateResult{}, &ProviderError{Code: ErrorInternal, Message: "persist rollback result"}
// Keep the durable state recoverable until the staged payload has also
// been removed. A crash anywhere before the terminal state write will
// replay this same bounded rollback and cleanup on helper startup.
if err := u.removeStagedArtifact(artifactIDFromActivation(state.ActivationID)); err != nil {
return &ProviderError{Code: ErrorInternal, Message: "remove rolled-back update staging"}
}
return result, nil
if err := u.writeDurableState(rolledBack); err != nil {
return &ProviderError{Code: ErrorInternal, Message: "persist rollback result"}
}
u.cancelPendingRollbackLocked()
return nil
}
func (u *updateActivator) validateInstalledState(state durableUpdateState) error {
current, currentErr := u.readOwnedBounded(u.targetPath, maxUpdateArtifactBytes)
rollback, rollbackErr := u.readOwnedBounded(u.rollbackPath, maxUpdateArtifactBytes)
if currentErr != nil || rollbackErr != nil ||
!strings.EqualFold(sha256Hex(current), state.ActiveSHA256) ||
!strings.EqualFold(sha256Hex(rollback), state.RollbackSHA256) {
return &ProviderError{Code: ErrorStateConflict, Message: "durable activation no longer matches installed binaries"}
}
return nil
}
func (u *updateActivator) schedulePendingRollbackLocked(state durableUpdateState) {
u.cancelPendingRollbackLocked()
delay := state.RollbackDeadline.Sub(u.now().UTC())
if delay < 0 {
delay = 0
}
u.cancelRollback = u.scheduleRollback(delay, func() {
u.mu.Lock()
defer u.mu.Unlock()
current, err := u.readState()
if err != nil || current.Action != "pending" || current.ActivationID != state.ActivationID {
return
}
_ = u.recoverStateLocked(current)
})
}
func (u *updateActivator) cancelPendingRollbackLocked() {
if u.cancelRollback != nil {
u.cancelRollback()
u.cancelRollback = nil
}
}
func updateResultFromState(state durableUpdateState) UpdateResult {
return UpdateResult{
Action: state.Action, ActivationID: state.ActivationID,
ActiveSHA256: state.ActiveSHA256, RollbackSHA256: state.RollbackSHA256,
RollbackDeadline: state.RollbackDeadline, DurableAt: state.UpdatedAt,
}
}
func artifactIDFromActivation(activationID string) string {
parts := strings.SplitN(activationID, ":", 2)
if len(parts) != 2 {
return ""
}
return parts[0]
}
func (u *updateActivator) removeStagedArtifact(artifactID string) error {
if !validArtifactID(artifactID) {
return errors.New("invalid staged artifact identity")
}
destination := filepath.Join(u.stagingDir, artifactID)
if _, err := os.Lstat(destination); errors.Is(err, os.ErrNotExist) {
return nil
} else if err != nil {
return err
}
if err := u.validateDirectory(destination); err != nil {
return err
}
for _, name := range []string{"pulse-agent", "pulse-agent.sig"} {
path := filepath.Join(destination, name)
info, err := os.Lstat(path)
if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return errors.New("unsafe staged artifact cleanup target")
}
if err := os.Remove(path); err != nil {
return err
}
}
if err := os.Remove(destination); err != nil {
return err
}
return syncDirectory(u.stagingDir)
}
func (u *updateActivator) validateDirectory(path string) error {
@@ -386,8 +620,7 @@ func (u *updateActivator) installDurably(active, lastKnownGood []byte) error {
return syncDirectory(targetDir)
}
func (u *updateActivator) writeState(result UpdateResult) error {
state := durableUpdateState{Action: result.Action, ActivationID: result.ActivationID, ActiveSHA256: result.ActiveSHA256, RollbackSHA256: result.RollbackSHA256, UpdatedAt: result.DurableAt}
func (u *updateActivator) writeDurableState(state durableUpdateState) error {
data, err := json.Marshal(state)
if err != nil {
return err
@@ -32,6 +32,7 @@ func testUpdateActivator(t *testing.T, verifier func([]byte, string) error) (Upd
ValidateOwner: func(*os.File) error { return nil },
ValidateQuarantineOwner: func(*os.File) error { return nil },
Now: func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC) },
ScheduleRollback: func(time.Duration, func()) func() { return func() {} },
})
if err != nil {
t.Fatal(err)
@@ -86,6 +87,9 @@ func TestUpdateActivationAndIdentityBoundRollbackAreDurable(t *testing.T) {
if activated.ActiveSHA256 != newDigest || activated.RollbackSHA256 != oldDigest || activated.ActivationID == "" {
t.Fatalf("activation result = %#v", activated)
}
if activated.Action != "pending" || activated.RollbackDeadline.IsZero() {
t.Fatalf("activation is not durably pending: %#v", activated)
}
if installed, _ := os.ReadFile(target); string(installed) != string(testELF("new-signed-binary")) {
t.Fatalf("installed binary = %q", installed)
}
@@ -115,6 +119,104 @@ func TestUpdateActivationAndIdentityBoundRollbackAreDurable(t *testing.T) {
}
}
func TestUpdateCommitClosesRollbackWindowAndCleansStaging(t *testing.T) {
provider, quarantine, target, _ := testUpdateActivator(t, func([]byte, string) error { return nil })
digest := stageUpdate(t, quarantine, "release-commit", testELF("committed"))
promoteUpdate(t, provider, "release-commit", digest)
activation, err := provider.Activate(context.Background(), UpdateActivateRequest{ArtifactID: "release-commit", SHA256: digest})
if err != nil {
t.Fatal(err)
}
committed, err := provider.Commit(context.Background(), UpdateCommitRequest{
ActivationID: activation.ActivationID, CurrentSHA256: activation.ActiveSHA256,
})
if err != nil {
t.Fatal(err)
}
if committed.Action != "committed" || committed.RollbackDeadline != (time.Time{}) {
t.Fatalf("commit result = %#v", committed)
}
if installed, _ := os.ReadFile(target); string(installed) != string(testELF("committed")) {
t.Fatalf("committed binary = %q", installed)
}
staged := filepath.Join(filepath.Dir(quarantine), "staging", "release-commit")
if _, err := os.Stat(staged); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("committed staging still exists: %v", err)
}
if retried, err := provider.Commit(context.Background(), UpdateCommitRequest{
ActivationID: activation.ActivationID, CurrentSHA256: activation.ActiveSHA256,
}); err != nil || retried.Action != "committed" {
t.Fatalf("idempotent commit = %#v, %v", retried, err)
}
if _, err := provider.Rollback(context.Background(), UpdateRollbackRequest{
ActivationID: activation.ActivationID, CurrentSHA256: activation.ActiveSHA256, RollbackSHA256: activation.RollbackSHA256,
}); err == nil {
t.Fatal("committed update remained rollback-eligible")
}
}
func TestUpdateActivatorRestartRollsBackUncommittedActivation(t *testing.T) {
provider, quarantine, target, state := testUpdateActivator(t, func([]byte, string) error { return nil })
digest := stageUpdate(t, quarantine, "release-restart", testELF("uncommitted"))
promoteUpdate(t, provider, "release-restart", digest)
activation, err := provider.Activate(context.Background(), UpdateActivateRequest{ArtifactID: "release-restart", SHA256: digest})
if err != nil {
t.Fatal(err)
}
root := filepath.Dir(quarantine)
recovered, err := NewUpdateActivator(UpdateActivatorConfig{
QuarantineDir: quarantine,
StagingDir: filepath.Join(root, "staging"),
TargetPath: target,
StatePath: state,
VerifySignature: func([]byte, string) error {
return nil
},
ValidateOwner: func(*os.File) error { return nil },
ValidateQuarantineOwner: func(*os.File) error { return nil },
Now: func() time.Time { return activation.RollbackDeadline.Add(-time.Second) },
ScheduleRollback: func(time.Duration, func()) func() { return func() {} },
})
if err != nil {
t.Fatalf("restart recovery: %v", err)
}
if installed, _ := os.ReadFile(target); string(installed) != string(testELF("old-signed-binary")) {
t.Fatalf("restart recovery installed binary = %q", installed)
}
result, err := recovered.Rollback(context.Background(), UpdateRollbackRequest{
ActivationID: activation.ActivationID, CurrentSHA256: activation.ActiveSHA256, RollbackSHA256: activation.RollbackSHA256,
})
if err != nil || result.Action != "rolled_back" {
t.Fatalf("durable restart result = %#v, %v", result, err)
}
}
func TestUpdateRollbackWatchdogRestoresUncommittedActivation(t *testing.T) {
provider, quarantine, target, _ := testUpdateActivator(t, func([]byte, string) error { return nil })
activator := provider.(*updateActivator)
var watchdog func()
activator.scheduleRollback = func(_ time.Duration, callback func()) func() {
watchdog = callback
return func() {}
}
digest := stageUpdate(t, quarantine, "release-watchdog", testELF("uncommitted"))
promoteUpdate(t, provider, "release-watchdog", digest)
activation, err := provider.Activate(context.Background(), UpdateActivateRequest{ArtifactID: "release-watchdog", SHA256: digest})
if err != nil || watchdog == nil {
t.Fatalf("Activate = %#v, %v; watchdog=%v", activation, err, watchdog != nil)
}
watchdog()
if installed, _ := os.ReadFile(target); string(installed) != string(testELF("old-signed-binary")) {
t.Fatalf("watchdog recovery installed binary = %q", installed)
}
result, err := provider.Rollback(context.Background(), UpdateRollbackRequest{
ActivationID: activation.ActivationID, CurrentSHA256: activation.ActiveSHA256, RollbackSHA256: activation.RollbackSHA256,
})
if err != nil || result.Action != "rolled_back" {
t.Fatalf("watchdog durable result = %#v, %v", result, err)
}
}
func TestUpdateActivationRejectsInvalidSignatureAndExecutable(t *testing.T) {
provider, staging, _, _ := testUpdateActivator(t, func([]byte, string) error { return errors.New("untrusted") })
digest := stageUpdate(t, staging, "bad-signature", testELF("binary"))
+32
View File
@@ -27,6 +27,38 @@ func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
return f(r)
}
func TestPrivilegedUpdateRollbackFailurePreservesPendingHandoff(t *testing.T) {
originalGOOS := runtimeGOOS
originalRestart := restartProcessFn
t.Cleanup(func() {
runtimeGOOS = originalGOOS
restartProcessFn = originalRestart
})
runtimeGOOS = goOSLinux
restartProcessFn = func(string) error { return errors.New("exec refused") }
binary := append([]byte{0x7f, 'E', 'L', 'F'}, bytes.Repeat([]byte("x"), 128)...)
sum := sha256.Sum256(binary)
digest := hex.EncodeToString(sum[:])
helper := &fakePrivilegedUpdate{root: t.TempDir(), rollbackErr: errors.New("helper unavailable")}
if err := os.Chmod(helper.root, 0o700); err != nil {
t.Fatal(err)
}
u := New(Config{PrivilegedUpdate: helper, Disabled: true, StateDir: helper.root, CurrentVersion: "1.0.0"})
u.selfTestFn = func(context.Context, string) error { return nil }
err := u.performPrivilegedUpdate(context.Background(), "/usr/local/bin/pulse-agent", bytes.NewReader(binary), int64(len(binary)), digest, "signed-update")
if err == nil || !strings.Contains(err.Error(), "typed helper rollback failed") {
t.Fatalf("performPrivilegedUpdate error = %v", err)
}
loaded, loadErr := LoadPendingPrivilegedUpdate(helper.root)
if loadErr != nil || loaded == nil || loaded.Activation.ActivationID != helper.activation.ActivationID ||
loaded.Activation.ActiveSHA256 != helper.activation.ActiveSHA256 ||
loaded.Activation.RollbackSHA256 != helper.activation.RollbackSHA256 {
t.Fatalf("failed-rollback handoff = %#v, %v", loaded, loadErr)
}
}
func TestUpdateRetryBackoffUsesOperationalDelays(t *testing.T) {
if updateRetryBaseDelay < time.Second {
t.Fatalf("updateRetryBaseDelay = %s, want at least 1s", updateRetryBaseDelay)
+178 -11
View File
@@ -4,8 +4,10 @@ import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
@@ -15,17 +17,24 @@ import (
)
const privilegedUpdateQuarantineDir = "/var/lib/pulse-agent/update-quarantine"
const pendingPrivilegedUpdateFile = ".pulse-agent-update-pending.json"
// PrivilegedUpdate is the updater's closed view of helper-backed activation.
// Implementations cannot select a target path, staging path, command, or URL.
type PrivilegedUpdate interface {
CreateQuarantinedArtifact() (artifactID string, file *os.File, cleanup func(), err error)
CreateQuarantinedArtifact() (artifactID string, file *os.File, cleanup func() error, err error)
WriteQuarantinedSignature(artifactID, signature string) error
Stage(context.Context, string, string) (agenthelper.UpdateStageResult, error)
Activate(context.Context, string, string) (agenthelper.UpdateResult, error)
Commit(context.Context, agenthelper.UpdateResult) (agenthelper.UpdateResult, error)
Rollback(context.Context, agenthelper.UpdateResult) (agenthelper.UpdateResult, error)
}
type PendingPrivilegedUpdate struct {
Activation agenthelper.UpdateResult `json:"activation"`
PreviousVersion string `json:"previousVersion"`
}
type privilegeHelperUpdate struct {
client *agenthelper.Client
quarantineDir string
@@ -39,28 +48,38 @@ func NewPrivilegeHelperUpdate(socketPath string) (PrivilegedUpdate, error) {
return &privilegeHelperUpdate{client: client, quarantineDir: privilegedUpdateQuarantineDir}, nil
}
func (p *privilegeHelperUpdate) CreateQuarantinedArtifact() (string, *os.File, func(), error) {
func (p *privilegeHelperUpdate) CreateQuarantinedArtifact() (string, *os.File, func() error, error) {
if err := validateCollectorQuarantineRoot(p.quarantineDir); err != nil {
return "", nil, func() {}, err
return "", nil, func() error { return nil }, err
}
var nonce [16]byte
if _, err := rand.Read(nonce[:]); err != nil {
return "", nil, func() {}, fmt.Errorf("create update artifact identity: %w", err)
return "", nil, func() error { return nil }, fmt.Errorf("create update artifact identity: %w", err)
}
artifactID := "pulse-agent-" + hex.EncodeToString(nonce[:])
artifactDir := filepath.Join(p.quarantineDir, artifactID)
if err := os.Mkdir(artifactDir, 0o700); err != nil {
return "", nil, func() {}, fmt.Errorf("create quarantined artifact directory: %w", err)
return "", nil, func() error { return nil }, fmt.Errorf("create quarantined artifact directory: %w", err)
}
cleanup := func() {
_ = os.Remove(filepath.Join(artifactDir, "pulse-agent"))
_ = os.Remove(filepath.Join(artifactDir, "pulse-agent.sig"))
_ = os.Remove(artifactDir)
cleanup := func() error {
var cleanupErr error
for _, name := range []string{"pulse-agent", "pulse-agent.sig"} {
if err := os.Remove(filepath.Join(artifactDir, name)); err != nil && !errors.Is(err, os.ErrNotExist) {
cleanupErr = errors.Join(cleanupErr, err)
}
}
if err := os.Remove(artifactDir); err != nil && !errors.Is(err, os.ErrNotExist) {
cleanupErr = errors.Join(cleanupErr, err)
}
if err := syncUpdateDirectory(p.quarantineDir); err != nil {
cleanupErr = errors.Join(cleanupErr, err)
}
return cleanupErr
}
file, err := os.OpenFile(filepath.Join(artifactDir, "pulse-agent"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
cleanup()
return "", nil, func() {}, fmt.Errorf("create quarantined agent binary: %w", err)
_ = cleanup()
return "", nil, func() error { return nil }, fmt.Errorf("create quarantined agent binary: %w", err)
}
return artifactID, file, cleanup, nil
}
@@ -104,6 +123,14 @@ func (p *privilegeHelperUpdate) Activate(ctx context.Context, artifactID, digest
return result, err
}
func (p *privilegeHelperUpdate) Commit(ctx context.Context, activation agenthelper.UpdateResult) (agenthelper.UpdateResult, error) {
var result agenthelper.UpdateResult
_, err := p.client.Call(ctx, agenthelper.OperationAgentUpdateCommit, agenthelper.OperationVersion1, 30*time.Second, agenthelper.UpdateCommitRequest{
ActivationID: activation.ActivationID, CurrentSHA256: activation.ActiveSHA256,
}, &result)
return result, err
}
func (p *privilegeHelperUpdate) Rollback(ctx context.Context, activation agenthelper.UpdateResult) (agenthelper.UpdateResult, error) {
var result agenthelper.UpdateResult
_, err := p.client.Call(ctx, agenthelper.OperationAgentUpdateRollback, agenthelper.OperationVersion1, 30*time.Second, agenthelper.UpdateRollbackRequest{
@@ -145,3 +172,143 @@ func syncUpdateDirectory(path string) error {
defer dir.Close()
return dir.Sync()
}
func PersistPendingPrivilegedUpdate(stateDir, previousVersion string, activation agenthelper.UpdateResult) error {
path, err := pendingPrivilegedUpdatePath(stateDir)
if err != nil {
return err
}
if err := validatePendingUpdateStateDir(stateDir); err != nil {
return err
}
previousVersion = strings.TrimSpace(previousVersion)
if previousVersion == "" || len(previousVersion) > 128 || strings.ContainsAny(previousVersion, "\x00\r\n") {
return errors.New("previous update version is invalid")
}
if !validPendingActivation(activation) {
return errors.New("pending helper activation is invalid")
}
data, err := json.Marshal(PendingPrivilegedUpdate{Activation: activation, PreviousVersion: previousVersion})
if err != nil {
return err
}
temp, err := os.CreateTemp(stateDir, ".pulse-agent-update-pending-*")
if err != nil {
return fmt.Errorf("create pending update handoff: %w", err)
}
tempPath := temp.Name()
defer os.Remove(tempPath)
if err := temp.Chmod(0o600); err != nil {
_ = temp.Close()
return err
}
if _, err := temp.Write(data); err != nil {
_ = temp.Close()
return err
}
if err := temp.Sync(); err != nil {
_ = temp.Close()
return err
}
if err := temp.Close(); err != nil {
return err
}
if err := os.Rename(tempPath, path); err != nil {
return err
}
return syncUpdateDirectory(stateDir)
}
func LoadPendingPrivilegedUpdate(stateDir string) (*PendingPrivilegedUpdate, error) {
path, err := pendingPrivilegedUpdatePath(stateDir)
if err != nil {
return nil, err
}
before, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
if err := validatePendingUpdateStateDir(stateDir); err != nil {
return nil, err
}
if before.Mode()&os.ModeSymlink != 0 || !before.Mode().IsRegular() || before.Mode().Perm()&0o077 != 0 {
return nil, errors.New("pending update handoff is not a private regular file")
}
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
after, err := file.Stat()
if err != nil || !os.SameFile(before, after) {
return nil, errors.New("pending update handoff identity changed")
}
decoder := json.NewDecoder(file)
decoder.DisallowUnknownFields()
var pending PendingPrivilegedUpdate
if err := decoder.Decode(&pending); err != nil {
return nil, fmt.Errorf("decode pending update handoff: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
if err != nil {
return nil, fmt.Errorf("decode pending update handoff trailing data: %w", err)
}
return nil, errors.New("pending update handoff contains trailing JSON")
}
if !validPendingActivation(pending.Activation) || strings.TrimSpace(pending.PreviousVersion) == "" {
return nil, errors.New("pending update handoff is invalid")
}
return &pending, nil
}
func ClearPendingPrivilegedUpdate(stateDir string) error {
path, err := pendingPrivilegedUpdatePath(stateDir)
if err != nil {
return err
}
if err := validatePendingUpdateStateDir(stateDir); err != nil {
return err
}
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return syncUpdateDirectory(stateDir)
}
func pendingPrivilegedUpdatePath(stateDir string) (string, error) {
if stateDir == "" || !filepath.IsAbs(stateDir) || filepath.Clean(stateDir) != stateDir {
return "", errors.New("pending update state directory must be clean and absolute")
}
return filepath.Join(stateDir, pendingPrivilegedUpdateFile), nil
}
func validatePendingUpdateStateDir(stateDir string) error {
info, err := os.Lstat(stateDir)
if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() || info.Mode().Perm()&0o077 != 0 {
return errors.New("pending update state directory must be a private real directory")
}
return nil
}
func validPendingActivation(activation agenthelper.UpdateResult) bool {
parts := strings.Split(activation.ActivationID, ":")
if activation.Action != "pending" || len(parts) != 2 || !validPrivilegedArtifactID(parts[0]) || len(parts[1]) != 16 {
return false
}
if _, err := hex.DecodeString(parts[1]); err != nil {
return false
}
for _, digest := range []string{activation.ActiveSHA256, activation.RollbackSHA256} {
if len(digest) != 64 {
return false
}
if _, err := hex.DecodeString(digest); err != nil {
return false
}
}
return !activation.RollbackDeadline.IsZero()
}
+93 -12
View File
@@ -17,19 +17,23 @@ import (
)
type fakePrivilegedUpdate struct {
root string
events []string
activation agenthelper.UpdateResult
root string
events []string
activation agenthelper.UpdateResult
rollbackErr error
}
func (f *fakePrivilegedUpdate) CreateQuarantinedArtifact() (string, *os.File, func(), error) {
func (f *fakePrivilegedUpdate) CreateQuarantinedArtifact() (string, *os.File, func() error, error) {
f.events = append(f.events, "quarantine")
dir := filepath.Join(f.root, "pulse-agent-0123456789abcdef0123456789abcdef")
if err := os.Mkdir(dir, 0o700); err != nil {
return "", nil, func() {}, err
return "", nil, func() error { return nil }, err
}
file, err := os.OpenFile(filepath.Join(dir, "pulse-agent"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
return filepath.Base(dir), file, func() { _ = os.RemoveAll(dir) }, err
return filepath.Base(dir), file, func() error {
f.events = append(f.events, "cleanup")
return os.RemoveAll(dir)
}, err
}
func (f *fakePrivilegedUpdate) WriteQuarantinedSignature(_ string, signature string) error {
@@ -44,12 +48,20 @@ func (f *fakePrivilegedUpdate) Stage(_ context.Context, artifactID, digest strin
func (f *fakePrivilegedUpdate) Activate(_ context.Context, _ string, digest string) (agenthelper.UpdateResult, error) {
f.events = append(f.events, "activate")
f.activation = agenthelper.UpdateResult{Action: "activated", ActivationID: "pulse-agent-0123456789abcdef0123456789abcdef:0123456789abcdef", ActiveSHA256: digest, RollbackSHA256: strings.Repeat("a", 64)}
f.activation = agenthelper.UpdateResult{Action: "pending", ActivationID: "pulse-agent-0123456789abcdef0123456789abcdef:0123456789abcdef", ActiveSHA256: digest, RollbackSHA256: strings.Repeat("a", 64), RollbackDeadline: time.Now().Add(time.Minute)}
return f.activation, nil
}
func (f *fakePrivilegedUpdate) Commit(_ context.Context, activation agenthelper.UpdateResult) (agenthelper.UpdateResult, error) {
f.events = append(f.events, "commit")
return agenthelper.UpdateResult{Action: "committed", ActivationID: activation.ActivationID, ActiveSHA256: activation.ActiveSHA256, RollbackSHA256: activation.RollbackSHA256}, nil
}
func (f *fakePrivilegedUpdate) Rollback(_ context.Context, activation agenthelper.UpdateResult) (agenthelper.UpdateResult, error) {
f.events = append(f.events, "rollback")
if f.rollbackErr != nil {
return agenthelper.UpdateResult{}, f.rollbackErr
}
if activation != f.activation {
return agenthelper.UpdateResult{}, errors.New("wrong activation identity")
}
@@ -70,7 +82,10 @@ func TestPrivilegedUpdateStagesActivatesAndRollsBackRestartFailure(t *testing.T)
sum := sha256.Sum256(binary)
digest := hex.EncodeToString(sum[:])
helper := &fakePrivilegedUpdate{root: t.TempDir()}
u := New(Config{PrivilegedUpdate: helper, Disabled: true})
if err := os.Chmod(helper.root, 0o700); err != nil {
t.Fatal(err)
}
u := New(Config{PrivilegedUpdate: helper, Disabled: true, StateDir: helper.root, CurrentVersion: "1.0.0"})
u.selfTestFn = func(_ context.Context, path string) error {
helper.events = append(helper.events, "self-test")
data, err := os.ReadFile(path)
@@ -84,7 +99,7 @@ func TestPrivilegedUpdateStagesActivatesAndRollsBackRestartFailure(t *testing.T)
if err == nil || !strings.Contains(err.Error(), "update rolled back") {
t.Fatalf("performPrivilegedUpdate error = %v", err)
}
want := []string{"quarantine", "self-test", "signature:signed-update", "stage", "activate", "rollback"}
want := []string{"quarantine", "self-test", "signature:signed-update", "stage", "activate", "cleanup", "rollback"}
if !reflect.DeepEqual(helper.events, want) {
t.Fatalf("helper events = %#v, want %#v", helper.events, want)
}
@@ -96,14 +111,80 @@ func TestPrivilegedUpdateFailsClosedBeforeActivation(t *testing.T) {
runtimeGOOS = goOSLinux
binary := append([]byte{0x7f, 'E', 'L', 'F'}, []byte("update")...)
helper := &fakePrivilegedUpdate{root: t.TempDir()}
u := New(Config{PrivilegedUpdate: helper, Disabled: true})
if err := os.Chmod(helper.root, 0o700); err != nil {
t.Fatal(err)
}
u := New(Config{PrivilegedUpdate: helper, Disabled: true, StateDir: helper.root, CurrentVersion: "1.0.0"})
u.selfTestFn = func(context.Context, string) error { return nil }
err := u.performPrivilegedUpdate(context.Background(), "/usr/local/bin/pulse-agent", bytes.NewReader(binary), int64(len(binary)), strings.Repeat("0", 64), "signed-update")
if err == nil || !strings.Contains(err.Error(), "checksum mismatch") {
t.Fatalf("performPrivilegedUpdate error = %v", err)
}
if !reflect.DeepEqual(helper.events, []string{"quarantine"}) {
t.Fatalf("helper events = %#v, want quarantine only", helper.events)
if !reflect.DeepEqual(helper.events, []string{"quarantine", "cleanup"}) {
t.Fatalf("helper events = %#v, want quarantine cleanup", helper.events)
}
}
func TestPendingPrivilegedUpdateHandoffIsDurableAndStrict(t *testing.T) {
stateDir := t.TempDir()
if err := os.Chmod(stateDir, 0o700); err != nil {
t.Fatal(err)
}
activation := agenthelper.UpdateResult{
Action: "pending",
ActivationID: "pulse-agent-0123456789abcdef0123456789abcdef:0123456789abcdef",
ActiveSHA256: strings.Repeat("a", 64),
RollbackSHA256: strings.Repeat("b", 64),
RollbackDeadline: time.Now().Add(time.Minute).UTC(),
DurableAt: time.Now().UTC(),
}
if err := PersistPendingPrivilegedUpdate(stateDir, " 1.0.0 ", activation); err != nil {
t.Fatal(err)
}
path := filepath.Join(stateDir, pendingPrivilegedUpdateFile)
if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("handoff mode=%v err=%v", info, err)
}
loaded, err := LoadPendingPrivilegedUpdate(stateDir)
if err != nil {
t.Fatal(err)
}
if loaded == nil || loaded.PreviousVersion != "1.0.0" || loaded.Activation != activation {
t.Fatalf("loaded handoff = %#v", loaded)
}
if err := ClearPendingPrivilegedUpdate(stateDir); err != nil {
t.Fatal(err)
}
if loaded, err := LoadPendingPrivilegedUpdate(stateDir); err != nil || loaded != nil {
t.Fatalf("cleared handoff = %#v, %v", loaded, err)
}
}
func TestPendingPrivilegedUpdateHandoffRejectsUnsafeState(t *testing.T) {
stateDir := t.TempDir()
if err := os.Chmod(stateDir, 0o700); err != nil {
t.Fatal(err)
}
path := filepath.Join(stateDir, pendingPrivilegedUpdateFile)
outside := filepath.Join(t.TempDir(), "outside")
if err := os.WriteFile(outside, []byte(`{"activation":{},"previousVersion":"1.0.0"}`), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, path); err != nil {
t.Fatal(err)
}
if _, err := LoadPendingPrivilegedUpdate(stateDir); err == nil {
t.Fatal("symlinked pending handoff accepted")
}
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
unsafe := `{"activation":{"action":"pending","activationId":"pulse-agent-0123456789abcdef0123456789abcdef:0123456789abcdef","activeSha256":"` + strings.Repeat("a", 64) + `","rollbackSha256":"` + strings.Repeat("b", 64) + `","rollbackDeadline":"2030-01-01T00:00:00Z"},"previousVersion":"1.0.0","unexpected":true}`
if err := os.WriteFile(path, []byte(unsafe), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadPendingPrivilegedUpdate(stateDir); err == nil {
t.Fatal("unknown handoff field accepted")
}
}
+30 -9
View File
@@ -1093,7 +1093,14 @@ func (u *Updater) performPrivilegedUpdate(ctx context.Context, execPath string,
if err != nil {
return fmt.Errorf("prepare fixed update quarantine: %w", err)
}
defer cleanup()
cleaned := false
defer func() {
if !cleaned {
if cleanupErr := cleanup(); cleanupErr != nil {
u.logger.Warn().Err(cleanupErr).Msg("failed to remove quarantined helper update artifact")
}
}
}()
artifactPath := file.Name()
hasher := sha256.New()
written, copyErr := io.Copy(file, io.TeeReader(io.LimitReader(body, maxBinarySizeBytes+1), hasher))
@@ -1147,22 +1154,36 @@ func (u *Updater) performPrivilegedUpdate(ctx context.Context, execPath string,
if err != nil {
return fmt.Errorf("activate signed update through typed helper: %w", err)
}
if activation.Action != "activated" || !strings.EqualFold(activation.ActiveSHA256, digest) || activation.ActivationID == "" || activation.RollbackSHA256 == "" {
return errors.New("typed helper returned an invalid activation result")
}
if err := restartProcessFn(execPath); err != nil {
rollbackActivation := func(cause error) error {
rollbackCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
rolledBack, rollbackErr := u.cfg.PrivilegedUpdate.Rollback(rollbackCtx, activation)
if rollbackErr != nil {
return errors.Join(fmt.Errorf("failed to restart after helper activation: %w", err), fmt.Errorf("typed helper rollback failed: %w", rollbackErr))
return errors.Join(cause, fmt.Errorf("typed helper rollback failed: %w", rollbackErr))
}
if rolledBack.Action != "rolled_back" || !strings.EqualFold(rolledBack.ActiveSHA256, activation.RollbackSHA256) {
return errors.Join(fmt.Errorf("failed to restart after helper activation: %w", err), errors.New("typed helper returned an invalid rollback result"))
return errors.Join(cause, errors.New("typed helper returned an invalid rollback result"))
}
return fmt.Errorf("failed to restart after helper activation; update rolled back: %w", err)
if clearErr := ClearPendingPrivilegedUpdate(u.cfg.StateDir); clearErr != nil {
return errors.Join(fmt.Errorf("%w; update rolled back", cause), fmt.Errorf("clear pending update handoff: %w", clearErr))
}
return fmt.Errorf("%w; update rolled back", cause)
}
return nil
if activation.Action != "pending" || !strings.EqualFold(activation.ActiveSHA256, digest) || activation.ActivationID == "" ||
activation.RollbackSHA256 == "" || activation.RollbackDeadline.IsZero() || !time.Now().Before(activation.RollbackDeadline) {
return rollbackActivation(errors.New("typed helper returned an invalid pending activation result"))
}
if err := PersistPendingPrivilegedUpdate(u.cfg.StateDir, u.cfg.CurrentVersion, activation); err != nil {
return rollbackActivation(fmt.Errorf("persist pending update handoff: %w", err))
}
if err := cleanup(); err != nil {
return rollbackActivation(fmt.Errorf("remove quarantined update after helper staging: %w", err))
}
cleaned = true
if err := restartProcessFn(execPath); err != nil {
return rollbackActivation(fmt.Errorf("failed to restart after helper activation: %w", err))
}
return rollbackActivation(errors.New("restart returned without replacing the current process"))
}
func (u *Updater) syncPersistentBinaryCopy(execPath, persistPath, platform string) {
+123 -2
View File
@@ -7,7 +7,10 @@ import (
"net/http"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/api/agentbinding"
"github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
@@ -30,6 +33,27 @@ type actionRunnerCredentialResponse struct {
ActionCapability string `json:"actionCapability"`
}
type actionRunnerCredentialSelfRevokeRequest struct {
AgentID string `json:"agentId"`
Hostname string `json:"hostname"`
}
func actionRunnerCredentialRoute(cfg *config.Config, issue, selfRevoke http.HandlerFunc) http.HandlerFunc {
issue = RequireAdmin(cfg, RequireScope(config.ScopeSettingsWrite, RequireScope(config.ScopeActionsExecute, issue)))
selfRevoke = RequireAuth(cfg, RequireScope(config.ScopeAgentExec, selfRevoke))
return func(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodPost:
issue(w, req)
case http.MethodDelete:
selfRevoke(w, req)
default:
w.Header().Set("Allow", http.MethodPost+", "+http.MethodDelete)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
}
// handleIssueActionRunnerCredential creates the separately scoped credential
// consumed by pulse-agent-runner. The route is operator-only; a monitoring
// collector credential cannot mint or upgrade itself into remediation
@@ -63,7 +87,7 @@ func (r *Router) handleIssueActionRunnerCredential(w http.ResponseWriter, req *h
http.Error(w, "Canonical monitored host identity not found", http.StatusNotFound)
return
}
rawToken, record, err := agenttokens.IssueActionRunnerAndPersist(r.config, r.persistence, agenttokens.ActionRunnerIssueOptions{
issued, err := agenttokens.IssueActionRunnerAndPersistDetailed(r.config, r.persistence, agenttokens.ActionRunnerIssueOptions{
TokenName: payload.Name,
OrgID: organizationID,
OwnerUserID: apiTokenOwnerUserIDForRequest(r.config, req),
@@ -78,12 +102,17 @@ func (r *Router) handleIssueActionRunnerCredential(w http.ResponseWriter, req *h
http.Error(w, "Failed to issue action runner credential", status)
return
}
record := issued.Record
for _, replaced := range issued.Replaced {
r.invalidateActionRunnerRecord(replaced)
}
LogAuditEventForTenant(organizationID, "action_runner_credential_issued", auth.GetUser(req.Context()), GetClientIP(req), req.URL.Path, true, "Issued host-bound typed action runner credential")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(actionRunnerCredentialResponse{
Token: rawToken,
Token: issued.Token,
TokenID: record.ID,
OrganizationID: record.OrgID,
AgentID: record.Metadata["bound_agent_id"],
@@ -93,6 +122,98 @@ func (r *Router) handleIssueActionRunnerCredential(w http.ResponseWriter, req *h
})
}
// handleSelfRevokeActionRunnerCredential lets the separately credentialed
// runner revoke only its own exact tenant/host binding. It cannot select a
// token ID or another host, and browser/session authentication is rejected.
func (r *Router) handleSelfRevokeActionRunnerCredential(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodDelete {
w.Header().Set("Allow", http.MethodDelete)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if r == nil || r.config == nil {
http.Error(w, "Action runner credential service unavailable", http.StatusServiceUnavailable)
return
}
caller := getAPITokenRecordFromRequest(req)
if caller == nil {
http.Error(w, "Action runner bearer credential required", http.StatusForbidden)
return
}
decoder := json.NewDecoder(io.LimitReader(req.Body, maxActionRunnerCredentialRequestBytes+1))
decoder.DisallowUnknownFields()
var payload actionRunnerCredentialSelfRevokeRequest
if err := decoder.Decode(&payload); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
organizationID := strings.TrimSpace(GetOrgID(req.Context()))
config.Mu.Lock()
previousTokens := append([]config.APITokenRecord(nil), r.config.APITokens...)
index := -1
var removed config.APITokenRecord
for candidateIndex := range r.config.APITokens {
candidate := &r.config.APITokens[candidateIndex]
if candidate.ID != caller.ID {
continue
}
orgs := candidate.GetBoundOrgs()
if len(orgs) != 1 || strings.TrimSpace(orgs[0]) != organizationID ||
!agentbinding.EvaluateActionRunner(candidate, payload.AgentID, payload.Hostname).Admit {
config.Mu.Unlock()
http.Error(w, "Action runner credential binding mismatch", http.StatusForbidden)
return
}
index = candidateIndex
removed = candidate.Clone()
break
}
if index < 0 {
config.Mu.Unlock()
http.Error(w, "Action runner credential not found", http.StatusUnauthorized)
return
}
r.config.APITokens = append(r.config.APITokens[:index], r.config.APITokens[index+1:]...)
r.config.SortAPITokens()
if r.persistence != nil {
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
r.config.APITokens = previousTokens
r.config.SortAPITokens()
config.Mu.Unlock()
http.Error(w, "Failed to revoke action runner credential", http.StatusInternalServerError)
return
}
}
config.Mu.Unlock()
r.invalidateActionRunnerRecord(removed)
LogAuditEventForTenant(organizationID, "action_runner_credential_revoked", auth.GetUser(req.Context()), GetClientIP(req), req.URL.Path, true, "Revoked exact host-bound typed action runner credential")
w.WriteHeader(http.StatusNoContent)
}
func (r *Router) invalidateActionRunnerRecord(record config.APITokenRecord) bool {
if r == nil || r.agentExecServer == nil {
return false
}
orgs := record.GetBoundOrgs()
if len(orgs) != 1 {
return false
}
return r.agentExecServer.InvalidateActionRunnerSession(agentexec.AgentAdmission{
OrganizationID: strings.TrimSpace(orgs[0]),
TokenID: strings.TrimSpace(record.ID),
AgentID: strings.TrimSpace(record.Metadata["bound_agent_id"]),
Hostname: strings.TrimSpace(record.Metadata["bound_hostname"]),
RuntimeRole: strings.TrimSpace(record.Metadata[agenttokens.RuntimeRoleMetadataKey]),
ActionCapability: strings.TrimSpace(record.Metadata[agenttokens.ActionCapabilityMetadataKey]),
})
}
func (r *Router) resolveActionRunnerHostIdentity(req *http.Request, requestedID, requestedHostname string) (string, string, bool) {
requestedID = strings.TrimSpace(requestedID)
requestedHostname = strings.TrimSpace(requestedHostname)
+156 -1
View File
@@ -2,16 +2,41 @@ package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/operationreceipt"
)
type actionRunnerFailingPersistenceFS struct{}
func (actionRunnerFailingPersistenceFS) ReadFile(name string) ([]byte, error) {
return os.ReadFile(name)
}
func (actionRunnerFailingPersistenceFS) WriteFile(string, []byte, os.FileMode) error {
return errors.New("injected action-runner persistence failure")
}
func (actionRunnerFailingPersistenceFS) Rename(oldPath, newPath string) error {
return os.Rename(oldPath, newPath)
}
func (actionRunnerFailingPersistenceFS) Remove(name string) error { return os.Remove(name) }
func (actionRunnerFailingPersistenceFS) Stat(name string) (os.FileInfo, error) {
return os.Stat(name)
}
func (actionRunnerFailingPersistenceFS) MkdirAll(path string, perm os.FileMode) error {
return os.MkdirAll(path, perm)
}
func newActionRunnerCredentialTestRouter(t *testing.T) (*Router, *config.Config, string) {
t.Helper()
cfg := &config.Config{DataPath: t.TempDir(), AuthUser: "admin", AuthPass: "$2a$10$dummy"}
@@ -29,6 +54,53 @@ func actionRunnerCredentialBody(hostID, hostname string) *bytes.Reader {
return bytes.NewReader(body)
}
func issueActionRunnerCredentialForTest(t *testing.T, router *Router, hostID, hostname string) actionRunnerCredentialResponse {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/agents/action-runner/credential", actionRunnerCredentialBody(hostID, hostname))
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "default"))
rec := httptest.NewRecorder()
router.handleIssueActionRunnerCredential(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("issue status = %d, body=%s", rec.Code, rec.Body.String())
}
var response actionRunnerCredentialResponse
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
t.Fatal(err)
}
return response
}
func connectActionRunnerCredentialForTest(t *testing.T, server *agentexec.Server, credential actionRunnerCredentialResponse) (*websocket.Conn, *httptest.Server) {
t.Helper()
ts := httptest.NewServer(http.HandlerFunc(server.HandleWebSocket))
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(ts.URL), wsHeadersForHTTP(t, ts.URL))
if err != nil {
ts.Close()
t.Fatal(err)
}
message, err := agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{
AgentID: credential.AgentID, Hostname: credential.Hostname, Token: credential.Token,
RuntimeRole: credential.RuntimeRole, ActionCapability: credential.ActionCapability,
OperationReceiptVersion: operationreceipt.ProtocolVersion,
})
if err != nil {
conn.Close()
ts.Close()
t.Fatal(err)
}
if err := conn.WriteJSON(message); err != nil {
conn.Close()
ts.Close()
t.Fatal(err)
}
if registered := readRegisteredPayload(t, conn); !registered.Success {
conn.Close()
ts.Close()
t.Fatalf("runner registration failed: %s", registered.Message)
}
return conn, ts
}
func TestIssueActionRunnerCredentialResolvesCanonicalMonitoredHost(t *testing.T) {
router, cfg, hostID := newActionRunnerCredentialTestRouter(t)
req := httptest.NewRequest(http.MethodPost, "/api/agents/action-runner/credential", actionRunnerCredentialBody(hostID, "HOST-1"))
@@ -106,11 +178,94 @@ func TestIssueActionRunnerCredentialRouteRotatesExistingHostBinding(t *testing.T
}
}
func TestIssueActionRunnerCredentialRotationClosesReplacedLiveSessionAfterPersistence(t *testing.T) {
router, _, hostID := newActionRunnerCredentialTestRouter(t)
router.agentExecServer = agentexec.NewServerWithAdmissionValidator(router.admitAgentExecToken, router.validateAgentExecSession)
first := issueActionRunnerCredentialForTest(t, router, hostID, "host-1.local")
conn, ts := connectActionRunnerCredentialForTest(t, router.agentExecServer, first)
defer conn.Close()
defer ts.Close()
if !router.agentExecServer.IsAgentConnectedForOrganization("default", hostID) {
t.Fatal("runner session was not connected")
}
second := issueActionRunnerCredentialForTest(t, router, hostID, "host-1.local")
if second.TokenID == first.TokenID {
t.Fatal("credential did not rotate")
}
if router.agentExecServer.IsAgentConnectedForOrganization("default", hostID) {
t.Fatal("replaced action-runner session remained connected")
}
}
func TestIssueActionRunnerCredentialPersistenceFailureKeepsPriorLiveSession(t *testing.T) {
router, cfg, hostID := newActionRunnerCredentialTestRouter(t)
router.agentExecServer = agentexec.NewServerWithAdmissionValidator(router.admitAgentExecToken, router.validateAgentExecSession)
first := issueActionRunnerCredentialForTest(t, router, hostID, "host-1.local")
conn, ts := connectActionRunnerCredentialForTest(t, router.agentExecServer, first)
defer conn.Close()
defer ts.Close()
router.persistence.SetFileSystem(actionRunnerFailingPersistenceFS{})
req := httptest.NewRequest(http.MethodPost, "/api/agents/action-runner/credential", actionRunnerCredentialBody(hostID, "host-1.local"))
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "default"))
rec := httptest.NewRecorder()
router.handleIssueActionRunnerCredential(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != first.TokenID {
t.Fatalf("prior credential was not restored: %#v", cfg.APITokens)
}
if !router.agentExecServer.IsAgentConnectedForOrganization("default", hostID) {
t.Fatal("failed persistence invalidated the prior live session")
}
}
func TestSelfRevokeActionRunnerCredentialRequiresExactBearerBindingAndClosesSession(t *testing.T) {
router, cfg, hostID := newActionRunnerCredentialTestRouter(t)
router.agentExecServer = agentexec.NewServerWithAdmissionValidator(router.admitAgentExecToken, router.validateAgentExecSession)
issued := issueActionRunnerCredentialForTest(t, router, hostID, "host-1.local")
conn, ts := connectActionRunnerCredentialForTest(t, router.agentExecServer, issued)
defer conn.Close()
defer ts.Close()
handler := actionRunnerCredentialRoute(cfg, router.handleIssueActionRunnerCredential, router.handleSelfRevokeActionRunnerCredential)
request := func(organizationID, agentID, hostname, token string) *httptest.ResponseRecorder {
t.Helper()
body, _ := json.Marshal(actionRunnerCredentialSelfRevokeRequest{AgentID: agentID, Hostname: hostname})
req := httptest.NewRequest(http.MethodDelete, "/api/agents/action-runner/credential", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, organizationID))
rec := httptest.NewRecorder()
handler(rec, req)
return rec
}
if rec := request("other", hostID, issued.Hostname, issued.Token); rec.Code != http.StatusForbidden {
t.Fatalf("mismatched organization status = %d, body=%s", rec.Code, rec.Body.String())
}
if rec := request("default", hostID, "other.example", issued.Token); rec.Code != http.StatusForbidden {
t.Fatalf("mismatched hostname status = %d, body=%s", rec.Code, rec.Body.String())
}
if len(cfg.APITokens) != 1 || !router.agentExecServer.IsAgentConnectedForOrganization("default", hostID) {
t.Fatal("binding mismatch changed credential or session state")
}
if rec := request("default", hostID, issued.Hostname, issued.Token); rec.Code != http.StatusNoContent {
t.Fatalf("self revoke status = %d, body=%s", rec.Code, rec.Body.String())
}
if len(cfg.APITokens) != 0 {
t.Fatalf("self-revoked credential remained stored: %#v", cfg.APITokens)
}
if router.agentExecServer.IsAgentConnectedForOrganization("default", hostID) {
t.Fatal("self-revoked session remained connected")
}
}
func TestActionRunnerCredentialRouteAuthSupportsAdminSessionAndScopedToken(t *testing.T) {
for _, mode := range []string{"api-token", "admin-session"} {
t.Run(mode, func(t *testing.T) {
router, cfg, hostID := newActionRunnerCredentialTestRouter(t)
handler := RequireAdmin(cfg, RequireScope(config.ScopeSettingsWrite, RequireScope(config.ScopeActionsExecute, router.handleIssueActionRunnerCredential)))
handler := actionRunnerCredentialRoute(cfg, router.handleIssueActionRunnerCredential, router.handleSelfRevokeActionRunnerCredential)
req := httptest.NewRequest(http.MethodPost, "/api/agents/action-runner/credential", actionRunnerCredentialBody(hostID, "host-1.local"))
switch mode {
case "api-token":
+41 -15
View File
@@ -55,6 +55,16 @@ type ActionRunnerIssueOptions struct {
Hostname string
}
// ActionRunnerIssueResult carries the newly issued credential together with
// the prior host-bound records it durably replaced. Callers use the replaced
// non-secret identities to invalidate only the superseded live sessions after
// persistence succeeds.
type ActionRunnerIssueResult struct {
Token string
Record *config.APITokenRecord
Replaced []config.APITokenRecord
}
func ProxmoxScopes(enableCommands bool) []string {
scopes := []string{
config.ScopeAgentReport,
@@ -109,7 +119,8 @@ func ParseCommandPolicyIntent(record *config.APITokenRecord) (bool, bool) {
}
func IssueAndPersist(cfg *config.Config, persistence *config.ConfigPersistence, opts IssueOptions) (string, *config.APITokenRecord, error) {
return issueAndPersistReplacing(cfg, persistence, opts, nil)
rawToken, record, _, err := issueAndPersistReplacing(cfg, persistence, opts, nil)
return rawToken, record, err
}
func issueAndPersistReplacing(
@@ -117,14 +128,14 @@ func issueAndPersistReplacing(
persistence *config.ConfigPersistence,
opts IssueOptions,
replace func(config.APITokenRecord) bool,
) (string, *config.APITokenRecord, error) {
) (string, *config.APITokenRecord, []config.APITokenRecord, error) {
if cfg == nil {
return "", nil, fmt.Errorf("config is required")
return "", nil, nil, fmt.Errorf("config is required")
}
rawToken, err := internalauth.GenerateAPIToken()
if err != nil {
return "", nil, fmt.Errorf("%w: %w", ErrGeneration, err)
return "", nil, nil, fmt.Errorf("%w: %w", ErrGeneration, err)
}
scopes := opts.Scopes
@@ -136,19 +147,19 @@ func issueAndPersistReplacing(
}
record, err := config.NewAPITokenRecord(rawToken, opts.TokenName, scopes)
if err != nil {
return "", nil, fmt.Errorf("%w: %w", ErrRecord, err)
return "", nil, nil, fmt.Errorf("%w: %w", ErrRecord, err)
}
record.OrgID = strings.TrimSpace(opts.OrgID)
setOwnerUserID(record, opts.OwnerUserID)
if err := mergeMetadata(record, opts.Metadata); err != nil {
return "", nil, fmt.Errorf("%w: %w", ErrRecord, err)
return "", nil, nil, fmt.Errorf("%w: %w", ErrRecord, err)
}
if record.Metadata == nil {
record.Metadata = make(map[string]string)
}
if err := normalizeCredentialKind(record); err != nil {
return "", nil, fmt.Errorf("%w: %w", ErrRecord, err)
return "", nil, nil, fmt.Errorf("%w: %w", ErrRecord, err)
}
record.Metadata[IssuedAtMetadataKey] = record.CreatedAt.UTC().Format(time.RFC3339)
@@ -156,12 +167,15 @@ func issueAndPersistReplacing(
defer config.Mu.Unlock()
previousTokens := append([]config.APITokenRecord(nil), cfg.APITokens...)
replaced := make([]config.APITokenRecord, 0, 1)
if replace == nil {
cfg.APITokens = append(cfg.APITokens, *record)
} else {
nextTokens := make([]config.APITokenRecord, 0, len(cfg.APITokens)+1)
for _, existing := range cfg.APITokens {
if !replace(existing) {
if replace(existing) {
replaced = append(replaced, existing.Clone())
} else {
nextTokens = append(nextTokens, existing)
}
}
@@ -175,38 +189,46 @@ func issueAndPersistReplacing(
// that the failed request never returned. Restore the full snapshot.
cfg.APITokens = previousTokens
cfg.SortAPITokens()
return "", nil, fmt.Errorf("%w: %w", ErrPersist, err)
return "", nil, nil, fmt.Errorf("%w: %w", ErrPersist, err)
}
}
return rawToken, record, nil
return rawToken, record, replaced, nil
}
// IssueActionRunnerAndPersist mints the host-bound credential used by the
// separate action runner. It fails closed on missing tenant/host identity and
// never grants collector report, lookup, configuration, or management scopes.
func IssueActionRunnerAndPersist(cfg *config.Config, persistence *config.ConfigPersistence, opts ActionRunnerIssueOptions) (string, *config.APITokenRecord, error) {
result, err := IssueActionRunnerAndPersistDetailed(cfg, persistence, opts)
return result.Token, result.Record, err
}
// IssueActionRunnerAndPersistDetailed is the rotation-aware form used by the
// API boundary. Replaced contains records only when the new credential was
// durably committed; persistence failure returns an empty result.
func IssueActionRunnerAndPersistDetailed(cfg *config.Config, persistence *config.ConfigPersistence, opts ActionRunnerIssueOptions) (ActionRunnerIssueResult, error) {
agentID := strings.TrimSpace(opts.AgentID)
hostname := unifiedresources.NormalizeFullHostname(opts.Hostname)
organizationID := strings.TrimSpace(opts.OrgID)
if organizationID == "" {
return "", nil, fmt.Errorf("%w: organization id is required", ErrRecord)
return ActionRunnerIssueResult{}, fmt.Errorf("%w: organization id is required", ErrRecord)
}
if agentID == "" {
return "", nil, fmt.Errorf("%w: canonical agent id is required", ErrRecord)
return ActionRunnerIssueResult{}, fmt.Errorf("%w: canonical agent id is required", ErrRecord)
}
if hostname == "" {
return "", nil, fmt.Errorf("%w: canonical hostname is required", ErrRecord)
return ActionRunnerIssueResult{}, fmt.Errorf("%w: canonical hostname is required", ErrRecord)
}
if len(agentID) > 128 || len(hostname) > 253 {
return "", nil, fmt.Errorf("%w: action runner identity exceeds maximum length", ErrRecord)
return ActionRunnerIssueResult{}, fmt.Errorf("%w: action runner identity exceeds maximum length", ErrRecord)
}
tokenName := strings.TrimSpace(opts.TokenName)
if tokenName == "" {
tokenName = "action-runner:" + hostname
}
return issueAndPersistReplacing(cfg, persistence, IssueOptions{
rawToken, record, replaced, err := issueAndPersistReplacing(cfg, persistence, IssueOptions{
TokenName: tokenName,
OrgID: organizationID,
OwnerUserID: opts.OwnerUserID,
@@ -224,6 +246,10 @@ func IssueActionRunnerAndPersist(cfg *config.Config, persistence *config.ConfigP
strings.TrimSpace(record.Metadata[CredentialKindMetadataKey]) == CredentialKindActionRunner &&
strings.TrimSpace(record.Metadata["bound_agent_id"]) == agentID
})
if err != nil {
return ActionRunnerIssueResult{}, err
}
return ActionRunnerIssueResult{Token: rawToken, Record: record, Replaced: replaced}, nil
}
func normalizeCredentialKind(record *config.APITokenRecord) error {
+22
View File
@@ -113,6 +113,28 @@ func TestIssueActionRunnerAndPersistReplacesMatchingBoundCredential(t *testing.T
}
}
func TestIssueActionRunnerAndPersistDetailedReturnsOnlyDurablyReplacedRecords(t *testing.T) {
cfg := &config.Config{DataPath: t.TempDir()}
_, prior, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{
OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example",
})
if err != nil {
t.Fatal(err)
}
result, err := IssueActionRunnerAndPersistDetailed(cfg, config.NewConfigPersistence(cfg.DataPath), ActionRunnerIssueOptions{
OrgID: "org-a", AgentID: "machine-123", Hostname: "renamed.example",
})
if err != nil {
t.Fatal(err)
}
if result.Token == "" || result.Record == nil || len(result.Replaced) != 1 || result.Replaced[0].ID != prior.ID {
t.Fatalf("detailed issue result = %#v", result)
}
if result.Record.ID == prior.ID || len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != result.Record.ID {
t.Fatalf("persisted replacement = %#v", cfg.APITokens)
}
}
func TestIssueActionRunnerAndPersistRestoresReplacedCredentialOnPersistenceFailure(t *testing.T) {
cfg := &config.Config{DataPath: t.TempDir()}
_, prior, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{
+1 -1
View File
@@ -55,7 +55,7 @@ func (r *Router) registerConfigSystemRoutes(updateHandlers *UpdateHandlers) {
r.mux.HandleFunc("/api/agents/docker/report", RequireAuth(r.config, RequireScope(config.ScopeDockerReport, r.dockerAgentHandlers.HandleReport)))
r.mux.HandleFunc("/api/agents/kubernetes/report", RequireAuth(r.config, RequireScope(config.ScopeKubernetesReport, r.kubernetesAgentHandlers.HandleReport)))
r.mux.HandleFunc("/api/agents/agent/report", RequireAuth(r.config, RequireScope(config.ScopeAgentReport, r.unifiedAgentHandlers.HandleReport)))
r.mux.HandleFunc("/api/agents/action-runner/credential", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, RequireScope(config.ScopeActionsExecute, r.handleIssueActionRunnerCredential))))
r.mux.HandleFunc("/api/agents/action-runner/credential", RequireAuth(r.config, actionRunnerCredentialRoute(r.config, r.handleIssueActionRunnerCredential, r.handleSelfRevokeActionRunnerCredential)))
r.mux.HandleFunc("/api/agents/host/report", wrapLegacyHostAlias("/api/agents/host/report", RequireAuth(r.config, RequireScope(config.ScopeAgentReport, r.unifiedAgentHandlers.HandleReport))))
r.mux.HandleFunc("/api/agents/agent/lookup", RequireAuth(r.config, RequireScope(config.ScopeAgentReport, r.unifiedAgentHandlers.HandleLookup)))
r.mux.HandleFunc("/api/agents/host/lookup", wrapLegacyHostAlias("/api/agents/host/lookup", RequireAuth(r.config, RequireScope(config.ScopeAgentReport, r.unifiedAgentHandlers.HandleLookup))))
@@ -2,6 +2,8 @@ package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
@@ -10,9 +12,35 @@ import (
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func TestSelfRevokeActionRunnerCredentialRejectsNonRunnerExecBearer(t *testing.T) {
router, cfg, hostID := newActionRunnerCredentialTestRouter(t)
raw := "legacy-exec-token-1234567890.12345678"
record, err := config.NewAPITokenRecord(raw, "legacy", []string{config.ScopeAgentExec})
if err != nil {
t.Fatal(err)
}
record.OrgID = "default"
record.Metadata = map[string]string{
agenttokens.RuntimeRoleMetadataKey: agenttokens.CredentialKindLegacyFullTrust,
"bound_agent_id": hostID,
"bound_hostname": "host-1.local",
}
cfg.APITokens = append(cfg.APITokens, *record)
body, _ := json.Marshal(actionRunnerCredentialSelfRevokeRequest{AgentID: hostID, Hostname: "host-1.local"})
req := httptest.NewRequest(http.MethodDelete, "/api/agents/action-runner/credential", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+raw)
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "default"))
rec := httptest.NewRecorder()
actionRunnerCredentialRoute(cfg, router.handleIssueActionRunnerCredential, router.handleSelfRevokeActionRunnerCredential)(rec, req)
if rec.Code != http.StatusForbidden || len(cfg.APITokens) != 1 {
t.Fatalf("legacy self revoke = status %d tokens %#v body=%s", rec.Code, cfg.APITokens, rec.Body.String())
}
}
func TestSecurityTokensCreateRollsBackCompleteInventoryWhenPersistenceFails(t *testing.T) {
now := time.Now().UTC()
tokens := []config.APITokenRecord{
+15 -11
View File
@@ -99,6 +99,13 @@ type Config struct {
// server upgrade within one report cycle instead of its next hourly check.
// Nil disables the hook; acks from observer destinations never invoke it.
OnServerVersion func(version string)
// OnPrimaryReportAccepted is called only after the current process's newly
// collected authoritative report is accepted. Buffered reports and observer
// acknowledgements do not satisfy this startup-health signal.
OnPrimaryReportAccepted func()
// UpdatedFromVersion is supplied by the durable helper-update handoff and
// is carried by the first report built by the replacement process.
UpdatedFromVersion string
Collector SystemCollector // Optional: override default system information collector (for testing)
// PrivilegedTelemetry routes the exceptional SMART and Proxmox LXC
@@ -400,7 +407,10 @@ func New(cfg Config) (*Agent, error) {
const bufferCapacity = 60
// Check if agent was recently auto-updated (only reported once per restart)
updatedFrom := updatedFromVersionFn()
updatedFrom := strings.TrimSpace(cfg.UpdatedFromVersion)
if updatedFrom == "" {
updatedFrom = updatedFromVersionFn()
}
if updatedFrom != "" {
logger.Info().
Str("previousVersion", updatedFrom).
@@ -854,6 +864,9 @@ func (a *Agent) deliverPrimaryReport(ctx context.Context, report agentshost.Repo
return nil
}
agenttarget.MarkDelivery("host", "primary", "primary", true)
if a.cfg.OnPrimaryReportAccepted != nil {
a.cfg.OnPrimaryReportAccepted()
}
// The server has counted every availability result in this report, so they
// must never be offered to it again.
@@ -1167,16 +1180,7 @@ func (a *Agent) buildReport(ctx context.Context) (agentshost.Report, error) {
// Proxmox VE exposes per-mount LXC usage only through the node-local pct
// CLI. Query running containers on an independent bounded deadline.
var proxmoxLXCData *agentshost.ProxmoxLXCInventory
if a.privilegedTelemetry != nil {
proxmoxLXCData, err = a.privilegedTelemetry.ProxmoxLXCFilesystems(ctx)
if err != nil {
a.logger.Debug().Err(err).Msg("Typed helper could not collect Proxmox LXC filesystems")
proxmoxLXCData = nil
}
} else {
proxmoxLXCData = a.collectProxmoxLXCFilesystems(ctx)
}
proxmoxLXCData := a.collectProxmoxLXCFilesystemsForReport(ctx)
// Collect S.M.A.R.T. disk data after topology owners. Enumeration and each
// device probe have their own deadlines; a single shared 10-second budget
+7 -1
View File
@@ -20,6 +20,7 @@ func TestAgent_deliverPrimaryReport_DrainsBufferedReportsBeforeCurrent(t *testin
var (
mu sync.Mutex
received []string
accepted atomic.Int32
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body io.Reader = r.Body
@@ -45,7 +46,9 @@ func TestAgent_deliverPrimaryReport_DrainsBufferedReportsBeforeCurrent(t *testin
defer server.Close()
a := &Agent{
cfg: Config{APIToken: "token"},
cfg: Config{APIToken: "token", OnPrimaryReportAccepted: func() {
accepted.Add(1)
}},
logger: zerolog.Nop(),
httpClient: server.Client(),
trimmedPulseURL: server.URL,
@@ -69,6 +72,9 @@ func TestAgent_deliverPrimaryReport_DrainsBufferedReportsBeforeCurrent(t *testin
t.Fatalf("received = %v, want %v", received, want)
}
}
if got := accepted.Load(); got != 1 {
t.Fatalf("accepted current-report callbacks = %d, want 1", got)
}
}
func TestAgent_flushBuffer_StopsOnFailureAndDoesNotDropReport(t *testing.T) {
+20
View File
@@ -1043,6 +1043,26 @@ func TestNew_CarriesUpdatedFromIntoFirstV6Report(t *testing.T) {
}
}
func TestNew_PrefersDurableHelperUpdatedFromHandoff(t *testing.T) {
mc := &mockCollector{
hostInfoFn: func(context.Context) (*gohost.InfoStat, error) {
return &gohost.InfoStat{Hostname: "upgraded-host", HostID: "machine-id-1", KernelArch: runtime.GOARCH}, nil
},
}
agent, err := New(Config{
APIToken: "token",
Collector: mc,
UpdatedFromVersion: "6.0.0",
updatedFromVersionFn: func() string { return "stale-local-marker" },
})
if err != nil {
t.Fatal(err)
}
if agent.updatedFrom != "6.0.0" {
t.Fatalf("updatedFrom = %q, want durable helper handoff", agent.updatedFrom)
}
}
func TestNew_RejectsInvalidPulseURL(t *testing.T) {
mc := &mockCollector{
hostInfoFn: func(context.Context) (*gohost.InfoStat, error) {
@@ -16,6 +16,7 @@ const privilegeHelperOperationDeadline = 30 * time.Second
// It intentionally exposes complete typed snapshots rather than commands,
// executable paths, VMIDs, device paths, or caller-selected arguments.
type PrivilegedTelemetry interface {
Health(context.Context) error
SMARTSnapshot(context.Context) ([]DiskSMART, error)
ProxmoxLXCFilesystems(context.Context) (*agentshost.ProxmoxLXCInventory, error)
}
@@ -40,6 +41,32 @@ func NewPrivilegeHelperTelemetry(socketPath string) (PrivilegedTelemetry, error)
return &privilegeHelperTelemetry{client: client}, nil
}
// Health proves that the configured socket is serving the exact typed-helper
// protocol expected by this collector. A listening systemd socket alone is
// not sufficient: socket activation can succeed while the helper binary is
// missing, incompatible, or unable to handle requests.
func (c *privilegeHelperTelemetry) Health(ctx context.Context) error {
var response agenthelper.HealthResult
_, err := c.client.Call(
ctx,
agenthelper.OperationHealth,
agenthelper.OperationVersion1,
privilegeHelperOperationDeadline,
struct{}{},
&response,
)
if err != nil {
return err
}
if response.Status != "ok" {
return errors.New("helper health response is not ok")
}
if response.ProtocolVersion != agenthelper.ProtocolVersion {
return errors.New("helper health protocol version does not match")
}
return nil
}
func (c *privilegeHelperTelemetry) SMARTSnapshot(ctx context.Context) ([]DiskSMART, error) {
var response struct {
Disks []DiskSMART `json:"disks"`
@@ -78,3 +105,19 @@ func (c *privilegeHelperTelemetry) ProxmoxLXCFilesystems(ctx context.Context) (*
}
return response.Inventory, nil
}
// collectProxmoxLXCFilesystemsForReport preserves the privilege boundary once
// a helper is configured. A helper failure must omit this best-effort snapshot
// rather than silently retrying the same collection in the unprivileged
// collector process (or widening that process's privileges to make it work).
func (a *Agent) collectProxmoxLXCFilesystemsForReport(ctx context.Context) *agentshost.ProxmoxLXCInventory {
if a.privilegedTelemetry == nil {
return a.collectProxmoxLXCFilesystems(ctx)
}
inventory, err := a.privilegedTelemetry.ProxmoxLXCFilesystems(ctx)
if err != nil {
a.logger.Debug().Err(err).Msg("Typed helper could not collect Proxmox LXC filesystems")
return nil
}
return inventory
}
@@ -2,15 +2,22 @@ package hostagent
import (
"context"
"encoding/binary"
"encoding/json"
"errors"
"io"
"net"
"os/exec"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agenthelper"
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
"github.com/rs/zerolog"
)
type fakePrivilegedTelemetry struct {
health error
smart []DiskSMART
smartErr error
proxmox *agentshost.ProxmoxLXCInventory
@@ -19,6 +26,10 @@ type fakePrivilegedTelemetry struct {
proxmoxCalls int
}
func (f *fakePrivilegedTelemetry) Health(context.Context) error {
return f.health
}
func (f *fakePrivilegedTelemetry) SMARTSnapshot(context.Context) ([]DiskSMART, error) {
f.smartCalls++
return f.smart, f.smartErr
@@ -29,6 +40,93 @@ func (f *fakePrivilegedTelemetry) ProxmoxLXCFilesystems(context.Context) (*agent
return f.proxmox, f.proxmoxErr
}
func testPrivilegeHelperTelemetry(t *testing.T, result agenthelper.HealthResult) *privilegeHelperTelemetry {
t.Helper()
client, err := agenthelper.NewClient(agenthelper.ClientConfig{
SocketPath: "/run/pulse-agent/helper.sock",
MaxDeadline: privilegeHelperOperationDeadline,
NewRequestID: func() (string, error) {
return "health-request", nil
},
DialContext: func(context.Context, string, string) (net.Conn, error) {
clientConn, serverConn := net.Pipe()
go func() {
defer serverConn.Close()
var header [4]byte
if _, readErr := io.ReadFull(serverConn, header[:]); readErr != nil {
return
}
requestBytes := make([]byte, binary.BigEndian.Uint32(header[:]))
if _, readErr := io.ReadFull(serverConn, requestBytes); readErr != nil {
return
}
var request agenthelper.Request
if unmarshalErr := json.Unmarshal(requestBytes, &request); unmarshalErr != nil {
return
}
resultBytes, marshalErr := json.Marshal(result)
if marshalErr != nil {
return
}
responseBytes, marshalErr := json.Marshal(agenthelper.Response{
ProtocolVersion: agenthelper.ProtocolVersion,
RequestID: request.RequestID,
Operation: request.Operation,
OperationVersion: request.OperationVersion,
Success: true,
Result: resultBytes,
})
if marshalErr != nil {
return
}
frame := make([]byte, 4+len(responseBytes))
binary.BigEndian.PutUint32(frame[:4], uint32(len(responseBytes)))
copy(frame[4:], responseBytes)
_, _ = serverConn.Write(frame)
}()
return clientConn, nil
},
})
if err != nil {
t.Fatalf("NewClient: %v", err)
}
return &privilegeHelperTelemetry{client: client}
}
func TestPrivilegeHelperHealthRequiresExactProtocolResponse(t *testing.T) {
tests := []struct {
name string
result agenthelper.HealthResult
wantErr bool
}{
{
name: "healthy",
result: agenthelper.HealthResult{Status: "ok", ProtocolVersion: agenthelper.ProtocolVersion},
},
{
name: "unhealthy status",
result: agenthelper.HealthResult{Status: "degraded", ProtocolVersion: agenthelper.ProtocolVersion},
wantErr: true,
},
{
name: "protocol mismatch",
result: agenthelper.HealthResult{Status: "ok", ProtocolVersion: agenthelper.ProtocolVersion + 1},
wantErr: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
telemetry := testPrivilegeHelperTelemetry(t, test.result)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
err := telemetry.Health(ctx)
if (err != nil) != test.wantErr {
t.Fatalf("Health error = %v, wantErr %v", err, test.wantErr)
}
})
}
}
func TestCollectSMARTDataUsesTypedHelperWithoutLocalFallback(t *testing.T) {
originalLookPath := zpoolLookPath
zpoolLookPath = func(string) (string, error) { return "", exec.ErrNotFound }
@@ -87,3 +185,30 @@ func TestCollectSMARTDataDoesNotWidenPrivilegeAfterHelperFailure(t *testing.T) {
t.Fatalf("local SMART fallback calls = %d, want 0", localCalls)
}
}
func TestCollectProxmoxLXCFilesystemsDoesNotWidenPrivilegeAfterHelperFailure(t *testing.T) {
localCalls := 0
collector := &mockCollector{
goos: "linux",
lookPathFn: func(string) (string, error) {
localCalls++
return "/usr/sbin/pct", nil
},
}
helper := &fakePrivilegedTelemetry{proxmoxErr: errors.New("helper unavailable")}
agent := &Agent{
collector: collector,
privilegedTelemetry: helper,
logger: zerolog.Nop(),
}
if got := agent.collectProxmoxLXCFilesystemsForReport(context.Background()); got != nil {
t.Fatalf("Proxmox inventory after helper failure = %+v, want nil", got)
}
if helper.proxmoxCalls != 1 {
t.Fatalf("helper Proxmox calls = %d, want 1", helper.proxmoxCalls)
}
if localCalls != 0 {
t.Fatalf("local Proxmox fallback calls = %d, want 0", localCalls)
}
}
+327 -9
View File
@@ -193,6 +193,8 @@ SAFE_PROFILE_COLLECTOR_UNIT="/etc/systemd/system/${AGENT_NAME}.service"
SAFE_PROFILE_TRANSACTION_DIR=""
SAFE_PROFILE_TRANSACTION_ACTIVE="false"
SAFE_PROFILE_TRANSACTION_COMMITTED="false"
SAFE_PROFILE_PRIOR_REGISTRATION_LAST_SEEN=""
AGENT_REGISTRATION_LAST_SEEN=""
SYSTEMD_ENV_LINES=""
SHELL_EXPORT_LINES=""
@@ -642,12 +644,14 @@ warn_agent_token_rejected() {
# 2 - the server rejected the credential (401, or 403 other than a stale
# hostname ownership match - actionable, permanent)
verify_agent_server_registration() {
local required_previous_last_seen="${1:-}"
local lookup_id="${AGENT_ID}"
local lookup_hostname="${HOSTNAME_OVERRIDE}"
local lookup_query=""
local lookup_out=""
local lookup_status=""
local lookup_body=""
local lookup_last_seen=""
# No -f: we need the response body AND the HTTP status even on 4xx so a
# rejected token (401/403) can be told apart from "not reported yet". The
# -w format appends "\n<http_code>" after the body.
@@ -694,7 +698,15 @@ verify_agent_server_registration() {
;;
esac
AGENT_REGISTRATION_LAST_SEEN=""
if echo "$lookup_body" | grep -q '"agent"[[:space:]]*:' && echo "$lookup_body" | grep -q '"id"[[:space:]]*:'; then
lookup_last_seen=$(printf '%s' "$lookup_body" | tr -d '\r\n' |
sed -n 's/.*"lastSeen"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
AGENT_REGISTRATION_LAST_SEEN="$lookup_last_seen"
if [[ -n "$required_previous_last_seen" ]] &&
[[ -z "$lookup_last_seen" || "$lookup_last_seen" == "$required_previous_last_seen" ]]; then
return 1
fi
return 0
fi
return 1
@@ -706,6 +718,7 @@ verify_agent_server_registration() {
# immediate lookup routinely misses a perfectly healthy registration (#1644).
# Return codes mirror verify_agent_server_registration.
verify_agent_server_registration_with_retry() {
local required_previous_last_seen="${1:-}"
local max_attempts=10
local interval=3
local attempt=0
@@ -716,7 +729,7 @@ verify_agent_server_registration_with_retry() {
fi
while [ $attempt -lt $max_attempts ]; do
verify_agent_server_registration
verify_agent_server_registration "$required_previous_last_seen"
reg_rc=$?
# 0 = confirmed; 2 = token rejected, which is definitive and will not
# change with more polling.
@@ -930,7 +943,81 @@ teardown_privileged_helper_service() {
systemctl reset-failed "${PRIVILEGED_HELPER_NAME}.service" 2>/dev/null || true
}
read_action_runner_env_value() {
local key="$1"
local value=""
[[ "$key" =~ ^[A-Z0-9_]+$ ]] || return 1
[[ -f "$ACTION_RUNNER_ENV_FILE" && ! -L "$ACTION_RUNNER_ENV_FILE" ]] || return 1
value=$(sed -n "s/^${key}=\"\(.*\)\"$/\1/p" "$ACTION_RUNNER_ENV_FILE" | tail -1)
# Values emitted by this installer escape backslashes and quotes. The
# revoke contract only needs URL, hostname, and an absolute state path;
# refuse an escaped value instead of evaluating shell syntax to recover it.
[[ -n "$value" && "$value" != *'\'* && "$value" != *$'\r'* && "$value" != *$'\n'* ]] || return 1
printf '%s\n' "$value"
}
revoke_action_runner_credential() {
local runner_url=""
local runner_hostname=""
local runner_agent_id_file=""
local runner_agent_id=""
local runner_token_file=""
local runner_token=""
local token_mode=""
local token_size=""
local curl_config=""
local payload=""
local -a revoke_args
runner_url=$(read_action_runner_env_value "PULSE_URL" || true)
runner_hostname=$(read_action_runner_env_value "PULSE_AGENT_RUNNER_HOSTNAME" || true)
runner_agent_id_file=$(read_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID_FILE" || true)
runner_token_file=$(read_action_runner_env_value "PULSE_AGENT_RUNNER_TOKEN_FILE" || true)
[[ "$runner_url" =~ ^https?://[^[:space:]]+$ && -n "$runner_hostname" ]] || return 1
[[ "$runner_agent_id_file" == /* && "$runner_agent_id_file" != *'/../'* &&
-f "$runner_agent_id_file" && ! -L "$runner_agent_id_file" ]] || return 1
[[ "$runner_token_file" == /* && "$runner_token_file" != *'/../'* &&
-f "$runner_token_file" && ! -L "$runner_token_file" ]] || return 1
runner_agent_id=$(head -1 "$runner_agent_id_file" 2>/dev/null || true)
(( ${#runner_agent_id} >= 1 && ${#runner_agent_id} <= 256 &&
${#runner_hostname} >= 1 && ${#runner_hostname} <= 253 )) || return 1
[[ "$runner_agent_id" =~ ^[A-Za-z0-9._:-]+$ &&
"$runner_hostname" =~ ^[A-Za-z0-9._:-]+$ ]] || return 1
token_mode=$(stat -c '%a' "$runner_token_file" 2>/dev/null || true)
token_size=$(wc -c < "$runner_token_file" 2>/dev/null | tr -d ' ' || true)
[[ "$token_mode" =~ ^[0-7]{3,4}$ && "$token_size" =~ ^[0-9]+$ ]] || return 1
(( (8#$token_mode & 8#077) == 0 && token_size >= 1 && token_size <= 4096 )) || return 1
runner_token=$(head -1 "$runner_token_file" 2>/dev/null || true)
[[ -n "$runner_token" && "$runner_token" != *$'\r'* && "$runner_token" != *$'\n'* ]] || return 1
curl_config=$(mktemp)
chmod 0600 "$curl_config"
printf 'header = "Authorization: Bearer %s"\nheader = "Content-Type: application/json"\n' \
"$runner_token" > "$curl_config"
runner_token=""
payload="{\"agentId\":\"${runner_agent_id}\",\"hostname\":\"${runner_hostname}\"}"
revoke_args=(--config "$curl_config" -fsS --connect-timeout 5 --max-time 10 -X DELETE --data-binary "$payload")
if grep -q '^PULSE_INSECURE="true"$' "$ACTION_RUNNER_ENV_FILE" 2>/dev/null; then
revoke_args+=(-k)
fi
if curl "${revoke_args[@]}" "${runner_url%/}/api/agents/action-runner/credential" >/dev/null 2>&1; then
rm -f "$curl_config"
return 0
fi
rm -f "$curl_config"
return 1
}
teardown_action_runner_service() {
local had_runner_config="false"
if [[ -f "$ACTION_RUNNER_ENV_FILE" || -f "$ACTION_RUNNER_TOKEN_FILE" ]]; then
had_runner_config="true"
fi
if revoke_action_runner_credential; then
log_info "Revoked the action-runner credential before removing local runner state."
elif [[ "$had_runner_config" == "true" ]]; then
log_warn "Could not self-revoke the action-runner credential; local runner removal will continue. Revoke the credential from Pulse if the server was unreachable."
fi
if command -v systemctl >/dev/null 2>&1; then
systemctl stop "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true
systemctl disable "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true
@@ -1206,6 +1293,7 @@ EOF
}
write_action_runner_config() {
local runner_hostname="$HOSTNAME_OVERRIDE"
if [[ -L "$ACTION_RUNNER_CONFIG_DIR" || -L "$ACTION_RUNNER_STATE_DIR" ||
-L "$ACTION_RUNNER_TOKEN_FILE" || -L "$ACTION_RUNNER_ENV_FILE" ]]; then
fail "Refusing unsafe symlink in the action-runner config or state boundary" "$EXIT_GENERAL"
@@ -1222,6 +1310,12 @@ write_action_runner_config() {
chmod 0600 "$ACTION_RUNNER_TOKEN_FILE"
ACTION_TOKEN=""
if [[ -z "$runner_hostname" ]]; then
runner_hostname=$(hostname 2>/dev/null || true)
fi
[[ -n "$runner_hostname" && "$runner_hostname" != *$'\r'* && "$runner_hostname" != *$'\n'* ]] ||
fail "Action runner requires a canonical hostname" "$EXIT_MISSING_ARGS"
: > "$ACTION_RUNNER_ENV_FILE"
chmod 0600 "$ACTION_RUNNER_ENV_FILE"
write_action_runner_env_value "PULSE_URL" "$PULSE_URL"
@@ -1229,6 +1323,7 @@ write_action_runner_config() {
write_action_runner_env_value "PULSE_AGENT_RUNNER_STATE_DIR" "$ACTION_RUNNER_STATE_DIR"
write_action_runner_env_value "PULSE_AGENT_RUNNER_HEALTH_FILE" "$ACTION_RUNNER_HEALTH_FILE"
write_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID_FILE" "${STATE_DIR%/}/agent-id"
write_action_runner_env_value "PULSE_AGENT_RUNNER_HOSTNAME" "$runner_hostname"
if [[ -n "$SERVER_FINGERPRINT" ]]; then
write_action_runner_env_value "PULSE_SERVER_FINGERPRINT" "$SERVER_FINGERPRINT"
fi
@@ -1415,6 +1510,31 @@ safe_profile_unit_property() {
esac
}
safe_profile_effective_unit_unoverridden() {
local fragment_path=""
local drop_in_paths=""
fragment_path=$(systemctl show "${AGENT_NAME}.service" --property FragmentPath --value 2>/dev/null) || return 1
drop_in_paths=$(systemctl show "${AGENT_NAME}.service" --property DropInPaths --value 2>/dev/null) || return 1
[[ "$fragment_path" == "$SAFE_PROFILE_COLLECTOR_UNIT" && -z "$drop_in_paths" ]]
}
safe_profile_verify_effective_target() {
local unit_user=""
local ambient=""
local exec_start=""
local environment=""
safe_profile_effective_unit_unoverridden || return 1
unit_user=$(safe_profile_unit_property User)
ambient=$(safe_profile_unit_property AmbientCapabilities)
exec_start=$(safe_profile_unit_property ExecStart)
environment=$(safe_profile_unit_property Environment)
[[ "$unit_user" == "$LEAST_PRIVILEGE_USER" ]] || return 1
[[ -z "$ambient" ]] || return 1
[[ "$exec_start" != *"--enable-commands"* ]] || return 1
[[ "$environment" == *"PULSE_AGENT_HELPER_SOCKET=${PRIVILEGED_HELPER_SOCKET_PATH}"* ]] || return 1
}
safe_profile_inspect() {
local unit_path="$SAFE_PROFILE_COLLECTOR_UNIT"
local binary_path="${INSTALL_DIR}/${BINARY_NAME}"
@@ -1427,10 +1547,16 @@ safe_profile_inspect() {
local binary_mode="missing"
local host="false" docker="false" kubernetes="false" proxmox="false"
local helper="false" commands="false" runner="false"
local fragment_path="unavailable" drop_in_paths="unavailable" unit_unoverridden="false"
if safe_profile_platform_supported; then supported="true"; fi
profile=$(safe_profile_detect_current_profile)
if [[ -f "$unit_path" ]]; then
fragment_path=$(safe_profile_unit_property FragmentPath)
fragment_path="${fragment_path:-unavailable}"
drop_in_paths=$(safe_profile_unit_property DropInPaths)
drop_in_paths="${drop_in_paths:-none}"
if safe_profile_effective_unit_unoverridden; then unit_unoverridden="true"; fi
unit_user=$(safe_profile_unit_property User)
unit_user="${unit_user:-root}"
ambient=$(safe_profile_unit_property AmbientCapabilities)
@@ -1455,6 +1581,9 @@ safe_profile_inspect() {
"Pulse safe-profile migration inspection (read-only)" \
"platform_supported=${supported}" \
"current_profile=${profile}" \
"unit_fragment_path=${fragment_path}" \
"unit_drop_in_paths=${drop_in_paths}" \
"unit_unoverridden=${unit_unoverridden}" \
"unit_user=${unit_user}" \
"unit_groups=${groups}" \
"ambient_capabilities=${ambient}" \
@@ -1516,6 +1645,69 @@ safe_profile_manifest_value() {
sed -n "s/^${key}=//p" "$manifest_file" 2>/dev/null | tail -1
}
safe_profile_snapshot_state_metadata() {
local metadata_file="${SAFE_PROFILE_TRANSACTION_DIR}/state-metadata.bin"
local path=""
local relative_path=""
local entry_type="file"
local uid="" gid="" mode=""
[[ -d "$STATE_DIR" && ! -L "$STATE_DIR" ]] ||
fail "Refusing unsafe safe-profile state directory: ${STATE_DIR}" "$EXIT_GENERAL"
: > "$metadata_file"
chmod 0600 "$metadata_file"
while IFS= read -r -d '' path; do
if [[ "$path" == "$STATE_DIR" ]]; then
relative_path="."
else
relative_path="${path#${STATE_DIR%/}/}"
fi
entry_type="file"
[[ -d "$path" && ! -L "$path" ]] && entry_type="directory"
[[ -L "$path" ]] && entry_type="symlink"
uid=$(stat -c '%u' "$path") || return 1
gid=$(stat -c '%g' "$path") || return 1
mode=$(stat -c '%a' "$path") || return 1
printf '%s\0%s\0%s\0%s\0%s\0' \
"$relative_path" \
"$uid" \
"$gid" \
"$mode" \
"$entry_type" >> "$metadata_file"
done < <(find "$STATE_DIR" -xdev -print0)
}
safe_profile_restore_state_metadata() {
local transaction_dir="$1"
local snapshot_state_dir="$2"
local metadata_file="${transaction_dir}/state-metadata.bin"
local relative_path="" uid="" gid="" mode="" entry_type="" destination=""
[[ -f "$metadata_file" && ! -L "$metadata_file" ]] || return 1
while IFS= read -r -d '' relative_path &&
IFS= read -r -d '' uid &&
IFS= read -r -d '' gid &&
IFS= read -r -d '' mode &&
IFS= read -r -d '' entry_type; do
if [[ "$relative_path" == "." ]]; then
destination="$snapshot_state_dir"
else
[[ -n "$relative_path" && "$relative_path" != /* &&
"/${relative_path}/" != *"/../"* ]] || return 1
destination="${snapshot_state_dir%/}/${relative_path}"
fi
# Runtime-owned state such as SQLite WAL/SHM files can legitimately
# disappear while the collector is stopped. Restore the identity of
# every surviving pre-migration entry without turning a vanished
# transient file into a rollback failure.
[[ -e "$destination" || -L "$destination" ]] || continue
chown -h "${uid}:${gid}" "$destination" || return 1
if [[ "$entry_type" != "symlink" ]]; then
chmod "$mode" "$destination" || return 1
fi
done < "$metadata_file"
}
safe_profile_begin_transaction() {
local unit_path="$SAFE_PROFILE_COLLECTOR_UNIT"
local prior_profile=""
@@ -1525,6 +1717,8 @@ safe_profile_begin_transaction() {
[[ -x "${INSTALL_DIR}/${BINARY_NAME}" && -f "$unit_path" ]] ||
fail "--safe-profile-apply requires an existing Linux systemd Pulse collector installation" "$EXIT_MISSING_ARGS"
safe_profile_effective_unit_unoverridden ||
fail "Refusing safe-profile migration while the collector has a different effective FragmentPath or any systemd drop-in override; consolidate the effective unit first" "$EXIT_GENERAL"
[[ ! -L "$SAFE_PROFILE_STATE_DIR" ]] ||
fail "Refusing symlinked safe-profile transaction directory: ${SAFE_PROFILE_STATE_DIR}" "$EXIT_GENERAL"
mkdir -p "$SAFE_PROFILE_STATE_DIR"
@@ -1539,7 +1733,7 @@ safe_profile_begin_transaction() {
prior_profile=$(safe_profile_detect_current_profile)
printf '%s\n' \
"FORMAT_VERSION=1" \
"FORMAT_VERSION=2" \
"PRIOR_PROFILE=${prior_profile}" \
"TARGET_PROFILE=typed-helper-monitoring-only" \
"STATE_DIR=${STATE_DIR}" \
@@ -1575,7 +1769,14 @@ 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 "${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
safe_profile_snapshot_entry "${STATE_DIR%/}/proxmox-pve-registration-blocked" proxmox-pve-registration-blocked PROXMOX_PVE_REGISTRATION_BLOCKED
safe_profile_snapshot_entry "${STATE_DIR%/}/proxmox-pbs-registration-blocked" proxmox-pbs-registration-blocked PROXMOX_PBS_REGISTRATION_BLOCKED
safe_profile_snapshot_entry "${STATE_DIR%/}/proxmox-detected-types" proxmox-detected-types PROXMOX_DETECTED_TYPES
safe_profile_snapshot_entry "${PRIVILEGED_HELPER_CREDENTIAL_DIR}/token" protected-token PROTECTED_TOKEN
safe_profile_snapshot_state_metadata
SAFE_PROFILE_TRANSACTION_ACTIVE="true"
SAFE_PROFILE_TRANSACTION_COMMITTED="false"
@@ -1604,6 +1805,7 @@ safe_profile_restore_transaction() {
local prior_profile=""
local docker_member="false"
local current_tmp=""
local format_version=""
SAFE_PROFILE_TRANSACTION_ACTIVE="false"
case "$transaction_dir" in
@@ -1611,7 +1813,8 @@ safe_profile_restore_transaction() {
*) log_error "Refusing untrusted safe-profile transaction path: ${transaction_dir}"; return 1 ;;
esac
[[ -d "$transaction_dir" && ! -L "$transaction_dir" && -f "$manifest_file" && ! -L "$manifest_file" ]] || return 1
[[ "$(safe_profile_manifest_value "$manifest_file" FORMAT_VERSION)" == "1" ]] || return 1
format_version=$(safe_profile_manifest_value "$manifest_file" FORMAT_VERSION)
[[ "$format_version" == "1" || "$format_version" == "2" ]] || 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)
@@ -1631,6 +1834,14 @@ 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
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
safe_profile_restore_entry "$transaction_dir" proxmox-pve-registration-blocked "${snapshot_state_dir%/}/proxmox-pve-registration-blocked" PROXMOX_PVE_REGISTRATION_BLOCKED
safe_profile_restore_entry "$transaction_dir" proxmox-pbs-registration-blocked "${snapshot_state_dir%/}/proxmox-pbs-registration-blocked" PROXMOX_PBS_REGISTRATION_BLOCKED
safe_profile_restore_entry "$transaction_dir" proxmox-detected-types "${snapshot_state_dir%/}/proxmox-detected-types" PROXMOX_DETECTED_TYPES
fi
safe_profile_restore_entry "$transaction_dir" protected-token "${PRIVILEGED_HELPER_CREDENTIAL_DIR}/token" PROTECTED_TOKEN
if [[ "$(safe_profile_manifest_value "$manifest_file" PROTECTED_DIR)" == "true" ]]; then
chown "$(safe_profile_manifest_value "$manifest_file" PROTECTED_DIR_UID):$(safe_profile_manifest_value "$manifest_file" PROTECTED_DIR_GID)" "$PRIVILEGED_HELPER_CREDENTIAL_DIR" || return 1
@@ -1638,6 +1849,9 @@ safe_profile_restore_transaction() {
else
rmdir "$PRIVILEGED_HELPER_CREDENTIAL_DIR" 2>/dev/null || true
fi
if [[ "$format_version" == "2" ]]; then
safe_profile_restore_state_metadata "$transaction_dir" "$snapshot_state_dir" || return 1
fi
rm -f "$PRIVILEGED_HELPER_SOCKET_PATH"
docker_member=$(safe_profile_manifest_value "$manifest_file" DOCKER_MEMBER)
@@ -1690,14 +1904,80 @@ safe_profile_remove_legacy_authority() {
fi
}
safe_profile_probe_helper_protocol() {
local request_id="installer-health-$$"
local request=""
local request_length=0
local response_file=""
local response_size=0
local response_length=0
local response_body=""
local header_bytes=""
local header_one=0 header_two=0 header_three=0 header_four=0
command -v runuser >/dev/null 2>&1 || return 1
id "$LEAST_PRIVILEGE_USER" >/dev/null 2>&1 || return 1
[[ -S "$PRIVILEGED_HELPER_SOCKET_PATH" ]] || return 1
request="{\"protocolVersion\":1,\"requestId\":\"${request_id}\",\"operation\":\"helper.health\",\"operationVersion\":1,\"deadlineMillis\":2000,\"payload\":{}}"
request_length=${#request}
[[ $request_length -gt 0 && $request_length -le 65536 ]] || return 1
response_file=$(mktemp "${SAFE_PROFILE_TRANSACTION_DIR}/.helper-health.XXXXXX") || return 1
chmod 0600 "$response_file"
if ! {
printf "\\$(printf '%03o' $((request_length / 16777216 % 256)))"
printf "\\$(printf '%03o' $((request_length / 65536 % 256)))"
printf "\\$(printf '%03o' $((request_length / 256 % 256)))"
printf "\\$(printf '%03o' $((request_length % 256)))"
printf '%s' "$request"
} | runuser -u "$LEAST_PRIVILEGE_USER" -- curl -sS --max-time 5 \
--unix-socket "$PRIVILEGED_HELPER_SOCKET_PATH" --upload-file - telnet://localhost > "$response_file"; then
rm -f "$response_file"
return 1
fi
response_size=$(wc -c < "$response_file" | tr -d ' ')
[[ "$response_size" =~ ^[0-9]+$ && $response_size -ge 5 ]] || { rm -f "$response_file"; return 1; }
header_bytes=$(od -An -tu1 -N4 "$response_file")
read -r header_one header_two header_three header_four <<< "$header_bytes"
response_length=$((header_one * 16777216 + header_two * 65536 + header_three * 256 + header_four))
[[ $response_length -gt 0 && $response_length -le 1048576 && $response_size -eq $((response_length + 4)) ]] || {
rm -f "$response_file"
return 1
}
response_body=$(dd if="$response_file" bs=1 skip=4 count="$response_length" 2>/dev/null)
rm -f "$response_file"
printf '%s' "$response_body" | grep -q '"protocolVersion"[[:space:]]*:[[:space:]]*1' || return 1
printf '%s' "$response_body" | grep -q "\"requestId\"[[:space:]]*:[[:space:]]*\"${request_id}\"" || return 1
printf '%s' "$response_body" | grep -q '"operation"[[:space:]]*:[[:space:]]*"helper.health"' || return 1
printf '%s' "$response_body" | grep -q '"operationVersion"[[:space:]]*:[[:space:]]*1' || return 1
printf '%s' "$response_body" | grep -q '"success"[[:space:]]*:[[:space:]]*true' || return 1
printf '%s' "$response_body" | grep -q '"status"[[:space:]]*:[[:space:]]*"ok"' || return 1
}
safe_profile_verify_declared_health() {
local health_url=""
local attempt=0
local local_health_ready="false"
health_url=$(resolve_agent_health_url || true)
[[ -n "$health_url" ]] || return 1
curl -sf --max-time 2 "$health_url" >/dev/null 2>&1 || return 1
systemctl is-active --quiet "${AGENT_NAME}.service" || return 1
systemctl is-active --quiet "${PRIVILEGED_HELPER_NAME}.socket" || return 1
verify_agent_server_registration_with_retry
# systemd can report the new units active before the collector readiness
# endpoint and socket-activated helper have finished starting. Give that
# local floor a bounded window, then perform the (separately retried)
# authoritative server-registration proof exactly once.
for ((attempt = 1; attempt <= 30; attempt++)); do
if curl -sf --max-time 2 "$health_url" >/dev/null 2>&1 &&
systemctl is-active --quiet "${AGENT_NAME}.service" &&
systemctl is-active --quiet "${PRIVILEGED_HELPER_NAME}.socket" &&
safe_profile_verify_effective_target &&
safe_profile_probe_helper_protocol; then
local_health_ready="true"
break
fi
sleep 1
done
[[ "$local_health_ready" == "true" ]] || return 1
[[ -n "$SAFE_PROFILE_PRIOR_REGISTRATION_LAST_SEEN" ]] || return 1
verify_agent_server_registration_with_retry "$SAFE_PROFILE_PRIOR_REGISTRATION_LAST_SEEN"
}
safe_profile_commit_transaction() {
@@ -2574,6 +2854,29 @@ discover_rootless_container_runtime() {
return 1
}
safe_profile_selected_rootless_runtime_usable() {
local collector_uid=""
local socket_uid=""
[[ -n "$ROOTLESS_RUNTIME_KIND" && -S "$ROOTLESS_RUNTIME_SOCKET_PATH" ]] || return 1
collector_uid=$(id -u "$LEAST_PRIVILEGE_USER" 2>/dev/null || true)
socket_uid=$(stat -c '%u' "$ROOTLESS_RUNTIME_SOCKET_PATH" 2>/dev/null || true)
[[ -n "$collector_uid" && "$socket_uid" == "$collector_uid" ]] || return 1
command -v runuser >/dev/null 2>&1 || return 1
runuser -u "$LEAST_PRIVILEGE_USER" -- test -r "$ROOTLESS_RUNTIME_SOCKET_PATH" || return 1
runuser -u "$LEAST_PRIVILEGE_USER" -- test -w "$ROOTLESS_RUNTIME_SOCKET_PATH" || return 1
}
safe_profile_apply_docker_degradation() {
[[ "$SAFE_PROFILE_ACTION" == "apply" && "$ENABLE_DOCKER" == "true" ]] || return 0
if safe_profile_selected_rootless_runtime_usable; then
log_info "Safe-profile migration preserved container monitoring through the collector-owned ${ROOTLESS_RUNTIME_KIND} socket: ${ROOTLESS_RUNTIME_SOCKET_PATH}"
return 0
fi
ENABLE_DOCKER="false"
DOCKER_EXPLICIT="true"
log_warn "Safe-profile migration disabled rootful Docker monitoring: the collector has no usable collector-owned rootless runtime. Container monitoring is an explicit migration degradation, not helper parity."
}
detect_kubernetes() {
# If user already specified a kubeconfig path, just verify it exists
if [[ -n "$KUBECONFIG_PATH" ]]; then
@@ -4169,6 +4472,8 @@ if [[ "$ENABLE_DOCKER" == "true" ]] && discover_rootless_container_runtime; then
fi
fi
safe_profile_apply_docker_degradation
finalize_plist_env_block
# --- Uninstall Logic ---
@@ -5023,7 +5328,7 @@ if [[ "$ACTION_RUNNER_ENABLED" == "true" ]]; then
download_verified_action_runner
fi
chmod +x "$TMP_BIN"
chmod 0755 "$TMP_BIN"
NEW_VERSION=$("$TMP_BIN" --version 2>/dev/null | head -1 || echo "unknown")
# Compare versions with any leading "v" stripped so the agent binary's "v6.0.4"
@@ -5079,6 +5384,19 @@ elif command -v systemctl >/dev/null 2>&1 && systemctl is-enabled --quiet "${AGE
systemctl stop "${AGENT_NAME}" 2>/dev/null || true
fi
if [[ "$SAFE_PROFILE_ACTION" == "apply" ]]; then
# Freeze the legacy collector before recording its server-side freshness
# marker. The replacement must advance this exact registration row after
# activation; an old row that merely still exists cannot commit migration.
stop_existing_agent_service || true
pkill -f "^${INSTALL_DIR}/${BINARY_NAME}([[:space:]]|$)" 2>/dev/null || true
AGENT_REGISTRATION_LAST_SEEN=""
if ! verify_agent_server_registration_with_retry || [[ -z "$AGENT_REGISTRATION_LAST_SEEN" ]]; then
fail "Safe-profile migration could not capture the stopped collector's server registration freshness marker; restoring the previous profile" "$EXIT_GENERAL"
fi
SAFE_PROFILE_PRIOR_REGISTRATION_LAST_SEEN="$AGENT_REGISTRATION_LAST_SEEN"
fi
if [[ "$UPDATE_ONLY" == "true" && "$UPGRADE_MODE" != "true" ]]; then
fail "No existing Pulse Agent installation found to update. Use the install command instead." "$EXIT_MISSING_ARGS"
fi
@@ -5093,7 +5411,7 @@ if [[ "$SAFE_PROFILE_ACTION" == "apply" ]]; then
mv "$SAFE_PROFILE_STAGED_COLLECTOR" "${INSTALL_DIR}/${BINARY_NAME}"
else
mv "$TMP_BIN" "${INSTALL_DIR}/${BINARY_NAME}"
chmod +x "${INSTALL_DIR}/${BINARY_NAME}"
chmod 0755 "${INSTALL_DIR}/${BINARY_NAME}"
fi
if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then
+85
View File
@@ -1802,6 +1802,23 @@ func TestInstallSHChecksDiskHeadroomBeforeDownload(t *testing.T) {
}
}
func TestInstallSHSetsAgentBinaryModeExplicitly(t *testing.T) {
content, err := os.ReadFile(repoFile("scripts", "install.sh"))
if err != nil {
t.Fatalf("read install.sh: %v", err)
}
script := string(content)
for _, want := range []string{
`chmod 0755 "$TMP_BIN"`,
`chmod 0755 "${INSTALL_DIR}/${BINARY_NAME}"`,
} {
if !strings.Contains(script, want) {
t.Fatalf("install.sh does not pin executable mode with %q", want)
}
}
}
// TestInstallSHWatchdogPathsUseRotatingAgentLog pins the logging half of issue
// #1617: the QNAP and Unraid watchdog loops must not shell-append agent stdout
// to an unrotated file on the RAM-backed root; the agent's own rotating writer
@@ -5780,6 +5797,10 @@ func TestInstallSHActionRunnerIsSeparateOptInLifecycle(t *testing.T) {
`write_action_runner_env_value "PULSE_AGENT_RUNNER_TOKEN_FILE" "$ACTION_RUNNER_TOKEN_FILE"`,
`write_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID_FILE" "${STATE_DIR%/}/agent-id"`,
`write_action_runner_env_value "PULSE_AGENT_RUNNER_HEALTH_FILE" "$ACTION_RUNNER_HEALTH_FILE"`,
`write_action_runner_env_value "PULSE_AGENT_RUNNER_HOSTNAME" "$runner_hostname"`,
`DELETE --data-binary "$payload"`,
`/api/agents/action-runner/credential`,
`Could not self-revoke the action-runner credential`,
`/download/${ACTION_RUNNER_BINARY_NAME}?${DOWNLOAD_QUERY}`,
`verify_download_signature "$TMP_ACTION_RUNNER_BIN" "$runner_signature"`,
`install -o root -g root -m 0755 "$TMP_ACTION_RUNNER_BIN"`,
@@ -5802,6 +5823,70 @@ func TestInstallSHActionRunnerIsSeparateOptInLifecycle(t *testing.T) {
}
}
func TestInstallSHActionRunnerSelfRevokeUsesPrivateCredential(t *testing.T) {
const token = "runner-secret-that-must-not-appear-in-output"
var gotAuthorization string
var gotMethod string
var gotBody map[string]string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuthorization = r.Header.Get("Authorization")
gotMethod = r.Method
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Errorf("decode revoke body: %v", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"success":true}`)
}))
defer server.Close()
root := t.TempDir()
envFile := filepath.Join(root, "runner.env")
tokenFile := filepath.Join(root, "token")
agentIDFile := filepath.Join(root, "agent-id")
mustWrite(t, tokenFile, token+"\n")
mustWrite(t, agentIDFile, "agent-secure-runtime\n")
if err := os.Chmod(tokenFile, 0o600); err != nil {
t.Fatal(err)
}
mustWrite(t, envFile, strings.Join([]string{
`PULSE_URL="` + server.URL + `"`,
`PULSE_AGENT_RUNNER_HOSTNAME="secure-runtime.lab"`,
`PULSE_AGENT_RUNNER_AGENT_ID_FILE="` + agentIDFile + `"`,
`PULSE_AGENT_RUNNER_TOKEN_FILE="` + tokenFile + `"`,
}, "\n")+"\n")
script := `
set -euo pipefail
ACTION_RUNNER_ENV_FILE="` + envFile + `"
stat() {
if [[ "$1" == "-c" && "$2" == "%a" ]]; then printf '600\n'; return 0; fi
command stat "$@"
}
` + extractInstallShellFunction(t, "read_action_runner_env_value") + `
` + extractInstallShellFunction(t, "revoke_action_runner_credential") + `
revoke_action_runner_credential
printf 'revoked\n'
`
out, err := exec.Command("bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("runner self-revoke: %v\n%s", err, out)
}
if strings.Contains(string(out), token) {
t.Fatalf("runner credential leaked in output: %s", out)
}
if gotMethod != http.MethodDelete {
t.Fatalf("revoke method = %q, want DELETE", gotMethod)
}
if gotAuthorization != "Bearer "+token {
t.Fatalf("authorization header mismatch")
}
if gotBody["agentId"] != "agent-secure-runtime" || gotBody["hostname"] != "secure-runtime.lab" {
t.Fatalf("revoke body = %#v", gotBody)
}
}
func TestInstallSHRendersHardenedActionRunnerUnit(t *testing.T) {
root := t.TempDir()
unitPath := filepath.Join(root, "pulse-agent-runner.service")
@@ -1,6 +1,10 @@
package installtests
import (
"encoding/binary"
"encoding/json"
"io"
"net"
"os"
"os/exec"
"path/filepath"
@@ -13,14 +17,18 @@ func safeProfileInspectFunctions(t *testing.T) string {
return extractInstallShellFunction(t, "safe_profile_platform_supported") + "\n" +
extractInstallShellFunction(t, "safe_profile_detect_current_profile") + "\n" +
extractInstallShellFunction(t, "safe_profile_unit_property") + "\n" +
extractInstallShellFunction(t, "safe_profile_effective_unit_unoverridden") + "\n" +
extractInstallShellFunction(t, "safe_profile_inspect")
}
func safeProfileTransactionFunctions(t *testing.T) string {
t.Helper()
return extractInstallShellFunction(t, "safe_profile_detect_current_profile") + "\n" +
extractInstallShellFunction(t, "safe_profile_effective_unit_unoverridden") + "\n" +
extractInstallShellFunction(t, "safe_profile_snapshot_entry") + "\n" +
extractInstallShellFunction(t, "safe_profile_manifest_value") + "\n" +
extractInstallShellFunction(t, "safe_profile_snapshot_state_metadata") + "\n" +
extractInstallShellFunction(t, "safe_profile_restore_state_metadata") + "\n" +
extractInstallShellFunction(t, "safe_profile_begin_transaction") + "\n" +
extractInstallShellFunction(t, "safe_profile_restore_entry") + "\n" +
extractInstallShellFunction(t, "safe_profile_restore_transaction") + "\n" +
@@ -52,7 +60,12 @@ log_error() { printf 'ERROR:%s\n' "$*" >&2; }
uname() { printf 'Linux\n'; }
systemctl() {
if [[ "$1" == show ]]; then
case "$4" in User) printf 'root\n' ;; AmbientCapabilities) printf 'CAP_SETUID CAP_SETGID\n' ;; esac
case "$4" in
User) printf 'root\n' ;;
AmbientCapabilities) printf 'CAP_SETUID CAP_SETGID\n' ;;
FragmentPath) printf '%s\n' "$SAFE_PROFILE_COLLECTOR_UNIT" ;;
DropInPaths) printf '\n' ;;
esac
fi
}
id() { if [[ "${1:-}" == -nG ]]; then printf 'root docker\n'; fi; return 0; }
@@ -65,6 +78,7 @@ safe_profile_inspect
}
for _, want := range []string{
"platform_supported=true", "current_profile=legacy-root-command-capable",
"unit_fragment_path=" + unit, "unit_drop_in_paths=none", "unit_unoverridden=true",
"unit_user=root", "unit_groups=root docker", "ambient_capabilities=CAP_SETUID CAP_SETGID",
"provider_docker=true", "provider_proxmox=true", "collector_commands=true",
"action_runner_independent=true", "target_profile=typed-helper-monitoring-only",
@@ -101,16 +115,22 @@ test "$SAFE_PROFILE_TRANSACTION_COMMITTED" = true
t.Run("failure rollback", func(t *testing.T) {
root := t.TempDir()
harness := safeProfileHarness(t, root, true) + `
printf 'transient-wal\n' > "$STATE_DIR/cache/runtime.db-wal"
chmod 0600 "$STATE_DIR/cache/runtime.db-wal"
safe_profile_begin_transaction
transaction="$SAFE_PROFILE_TRANSACTION_DIR"
rm -f "$STATE_DIR/cache/runtime.db-wal"
printf 'new-binary\n' > "$INSTALL_DIR/$BINARY_NAME"
printf '[Service]\nUser=pulse-agent\nEnvironment=PULSE_AGENT_HELPER_SOCKET=/run/pulse-agent/helper.sock\n' > "$SAFE_PROFILE_COLLECTOR_UNIT"
printf 'typed-helper\n' > "$PRIVILEGED_HELPER_BINARY_PATH"
printf 'helper-unit\n' > "$PRIVILEGED_HELPER_SERVICE_UNIT"
printf 'helper-socket\n' > "$PRIVILEGED_HELPER_SOCKET_UNIT"
rm -f "$STATE_DIR/token" "$STATE_DIR/runtime.token"
rm -f "$STATE_DIR/proxmox-registered" "$STATE_DIR/proxmox-pve-registered" "$STATE_DIR/proxmox-pbs-registered"
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"
chmod 0777 "$STATE_DIR" "$STATE_DIR/cache" "$STATE_DIR/cache/sample"
mkdir -p "$PRIVILEGED_HELPER_CREDENTIAL_DIR"
printf 'moved-monitoring-token\n' > "$PRIVILEGED_HELPER_CREDENTIAL_DIR/token"
printf 'runner-still-independent\n' > "$ACTION_RUNNER_SENTINEL"
@@ -121,6 +141,16 @@ 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"
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"
grep -q '^legacy-pve-blocked$' "$STATE_DIR/proxmox-pve-registration-blocked"
grep -q '^legacy-pbs-blocked$' "$STATE_DIR/proxmox-pbs-registration-blocked"
grep -q '^pve,pbs$' "$STATE_DIR/proxmox-detected-types"
test "$(stat -c '%a' "$STATE_DIR")" = 750
test "$(stat -c '%a' "$STATE_DIR/cache")" = 710
test "$(stat -c '%a' "$STATE_DIR/cache/sample")" = 640
test ! -e "$STATE_DIR/cache/runtime.db-wal"
test ! -e "$PRIVILEGED_HELPER_BINARY_PATH"
test ! -e "$PRIVILEGED_HELPER_SERVICE_UNIT"
test ! -e "$PRIVILEGED_HELPER_SOCKET_UNIT"
@@ -170,12 +200,35 @@ func TestSafeProfileApplyRequiresReadinessHelperAndRegistration(t *testing.T) {
set -euo pipefail
AGENT_NAME=pulse-agent
PRIVILEGED_HELPER_NAME=pulse-agent-helper
SAFE_PROFILE_PRIOR_REGISTRATION_LAST_SEEN=before
resolve_agent_health_url() { printf 'http://127.0.0.1:9191/readyz\n'; }
sleep() { :; }
curl() { return 0; }
systemctl() { return 0; }
verify_agent_server_registration_with_retry() { return 0; }
safe_profile_verify_effective_target() { return 0; }
safe_profile_probe_helper_protocol() { return 0; }
verify_agent_server_registration_with_retry() { [[ "${1:-}" == before ]]; }
` + gate + `
safe_profile_verify_declared_health
curl_attempts=0
helper_attempts=0
curl() {
curl_attempts=$((curl_attempts + 1))
(( curl_attempts >= 3 ))
}
safe_profile_probe_helper_protocol() {
helper_attempts=$((helper_attempts + 1))
(( helper_attempts >= 2 ))
}
safe_profile_verify_declared_health
test "$curl_attempts" = 4
test "$helper_attempts" = 2
safe_profile_probe_helper_protocol() { return 1; }
if safe_profile_verify_declared_health; then
echo 'helper protocol failure was accepted' >&2
exit 1
fi
safe_profile_probe_helper_protocol() { return 0; }
verify_agent_server_registration_with_retry() { return 1; }
if safe_profile_verify_declared_health; then
echo 'registration failure was accepted' >&2
@@ -187,6 +240,189 @@ fi
}
}
func TestSafeProfileFailsClosedOnEffectiveSystemdOverrides(t *testing.T) {
for _, tc := range []struct {
name, property, value string
}{
{name: "drop-in", property: "DropInPaths", value: "/etc/systemd/system/pulse-agent.service.d/override.conf"},
{name: "different fragment", property: "FragmentPath", value: "/usr/lib/systemd/system/pulse-agent.service"},
} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
harness := safeProfileHarness(t, root, false)
old := tc.property + `) printf '%s\n' "$SAFE_PROFILE_COLLECTOR_UNIT" ;;`
if tc.property == "DropInPaths" {
old = `DropInPaths) printf '\n' ;;`
}
replacement := tc.property + `) printf '%s\n' '` + tc.value + `' ;;`
harness = strings.Replace(harness, old, replacement, 1) + "\nsafe_profile_begin_transaction\n"
out, err := exec.Command("bash", "-c", harness).CombinedOutput()
if err == nil || !strings.Contains(string(out), "Refusing safe-profile migration") {
t.Fatalf("override was not rejected: err=%v\n%s", err, out)
}
})
}
}
func TestSafeProfileRegistrationMustAdvanceLastSeen(t *testing.T) {
functions := extractInstallShellFunction(t, "verify_agent_server_registration")
script := `
set -euo pipefail
AGENT_ID=agent-1
HOSTNAME_OVERRIDE=
PULSE_URL=https://pulse.example
INSECURE=false
CURL_CA_BUNDLE=
AGENT_REGISTRATION_LAST_SEEN=
url_encode() { printf '%s' "$1"; }
curl_with_pulse_token() { printf '%s\n200\n' "$LOOKUP_BODY"; }
` + functions + `
LOOKUP_BODY='{"agent":{"id":"agent-1","lastSeen":"2026-08-30T10:00:00Z"}}'
if verify_agent_server_registration '2026-08-30T10:00:00Z'; then
echo 'stale registration was accepted' >&2
exit 1
fi
LOOKUP_BODY='{"agent":{"id":"agent-1","lastSeen":"2026-08-30T10:00:31Z"}}'
verify_agent_server_registration '2026-08-30T10:00:00Z'
test "$AGENT_REGISTRATION_LAST_SEEN" = '2026-08-30T10:00:31Z'
`
if out, err := exec.Command("bash", "-c", script).CombinedOutput(); err != nil {
t.Fatalf("fresh registration rehearsal: %v\n%s", err, out)
}
}
func TestSafeProfileHelperProtocolProbeValidatesFramedHealth(t *testing.T) {
for _, success := range []bool{true, false} {
t.Run(map[bool]string{true: "healthy", false: "typed failure"}[success], func(t *testing.T) {
root, err := os.MkdirTemp("/tmp", "pulse-helper-probe-")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(root)
socketPath := filepath.Join(root, "helper.sock")
listener, err := net.Listen("unix", socketPath)
if err != nil {
t.Fatal(err)
}
defer listener.Close()
serverErr := make(chan error, 1)
go func() {
conn, acceptErr := listener.Accept()
if acceptErr != nil {
serverErr <- acceptErr
return
}
defer conn.Close()
var header [4]byte
if _, readErr := io.ReadFull(conn, header[:]); readErr != nil {
serverErr <- readErr
return
}
requestBody := make([]byte, binary.BigEndian.Uint32(header[:]))
if _, readErr := io.ReadFull(conn, requestBody); readErr != nil {
serverErr <- readErr
return
}
var request map[string]any
if decodeErr := json.Unmarshal(requestBody, &request); decodeErr != nil {
serverErr <- decodeErr
return
}
response, marshalErr := json.Marshal(map[string]any{
"protocolVersion": 1, "requestId": request["requestId"],
"operation": "helper.health", "operationVersion": 1,
"success": success, "result": map[string]any{"status": "ok", "protocolVersion": 1},
})
if marshalErr != nil {
serverErr <- marshalErr
return
}
binary.BigEndian.PutUint32(header[:], uint32(len(response)))
_, writeErr := conn.Write(append(header[:], response...))
serverErr <- writeErr
}()
fakeBin := filepath.Join(root, "bin")
mustMkdirAll(t, fakeBin)
runuser := filepath.Join(fakeBin, "runuser")
if err := os.WriteFile(runuser, []byte("#!/bin/sh\nwhile [ \"$#\" -gt 0 ] && [ \"$1\" != -- ]; do shift; done\nshift\nexec \"$@\"\n"), 0o755); err != nil {
t.Fatal(err)
}
script := `
set -euo pipefail
PATH="` + fakeBin + `:$PATH"
LEAST_PRIVILEGE_USER=pulse-agent
PRIVILEGED_HELPER_SOCKET_PATH="` + socketPath + `"
SAFE_PROFILE_TRANSACTION_DIR="` + root + `"
id() { return 0; }
` + extractInstallShellFunction(t, "safe_profile_probe_helper_protocol") + "\n"
if success {
script += "safe_profile_probe_helper_protocol\n"
} else {
script += "if safe_profile_probe_helper_protocol; then exit 91; fi\n"
}
out, runErr := exec.Command("bash", "-c", script).CombinedOutput()
if runErr != nil {
t.Fatalf("protocol probe: %v\n%s", runErr, out)
}
if err := <-serverErr; err != nil {
t.Fatal(err)
}
})
}
}
func TestSafeProfileExitTrapRestoresUncommittedTransaction(t *testing.T) {
root := t.TempDir()
harness := safeProfileHarness(t, root, true) + "\n" + extractInstallShellFunction(t, "cleanup") + `
TMP_FILES=()
trap cleanup EXIT
safe_profile_begin_transaction
printf 'uncommitted-binary\n' > "$INSTALL_DIR/$BINARY_NAME"
rm -f "$STATE_DIR/proxmox-registered"
printf 'runner-unchanged\n' > "$ACTION_RUNNER_SENTINEL"
exit 23
`
out, err := exec.Command("bash", "-c", harness).CombinedOutput()
if exitErr, ok := err.(*exec.ExitError); !ok || exitErr.ExitCode() != 23 {
t.Fatalf("exit trap status=%v\n%s", err, out)
}
assertFileBody(t, filepath.Join(root, "bin", "pulse-agent"), "old-binary\n")
assertFileBody(t, filepath.Join(root, "state", "proxmox-registered"), "legacy-generic\n")
assertFileBody(t, filepath.Join(root, "runner-sentinel"), "runner-unchanged\n")
}
func TestSafeProfileDockerDegradationRequiresCollectorOwnedRootlessRuntime(t *testing.T) {
function := extractInstallShellFunction(t, "safe_profile_apply_docker_degradation")
for _, usable := range []bool{true, false} {
script := `
set -euo pipefail
SAFE_PROFILE_ACTION=apply
ENABLE_DOCKER=true
DOCKER_EXPLICIT=false
ROOTLESS_RUNTIME_KIND=docker
ROOTLESS_RUNTIME_SOCKET_PATH=/run/user/991/docker.sock
log_info() { :; }
log_warn() { printf '%s\n' "$*"; }
safe_profile_selected_rootless_runtime_usable() { return ` + map[bool]string{true: "0", false: "1"}[usable] + `; }
` + function + `
safe_profile_apply_docker_degradation
printf 'enabled=%s explicit=%s\n' "$ENABLE_DOCKER" "$DOCKER_EXPLICIT"
`
out, err := exec.Command("bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("docker degradation: %v\n%s", err, out)
}
want := "enabled=false explicit=true"
if usable {
want = "enabled=true explicit=false"
}
if !strings.Contains(string(out), want) {
t.Fatalf("usable=%v output missing %q:\n%s", usable, want, out)
}
}
}
func safeProfileHarness(t *testing.T, root string, dockerMember bool) string {
t.Helper()
binDir := filepath.Join(root, "bin")
@@ -197,19 +433,36 @@ func safeProfileHarness(t *testing.T, root string, dockerMember bool) string {
expectedDir := filepath.Join(root, "expected")
mustMkdirAll(t, binDir, unitDir, helperDir, stateDir, expectedDir)
files := map[string]string{
filepath.Join(binDir, "pulse-agent"): "old-binary\n",
filepath.Join(unitDir, "pulse-agent.service"): "[Service]\nUser=root\nAmbientCapabilities=CAP_SETUID CAP_SETGID\nExecStart=/bin/pulse-agent --enable-commands\n",
filepath.Join(root, "sudoers"): "legacy sudo grant\n",
filepath.Join(helperDir, "smartctl"): "legacy smart wrapper\n",
filepath.Join(helperDir, "pct"): "legacy pct wrapper\n",
filepath.Join(stateDir, "token"): "monitoring-token\n",
filepath.Join(stateDir, "runtime.token"): "runtime-monitoring-token\n",
filepath.Join(stateDir, "agent-id"): "stable-agent-id\n",
filepath.Join(stateDir, "connection.env"): "PULSE_URL='https://pulse.example'\n",
filepath.Join(binDir, "pulse-agent"): "old-binary\n",
filepath.Join(unitDir, "pulse-agent.service"): "[Service]\nUser=root\nAmbientCapabilities=CAP_SETUID CAP_SETGID\nExecStart=/bin/pulse-agent --enable-commands\n",
filepath.Join(root, "sudoers"): "legacy sudo grant\n",
filepath.Join(helperDir, "smartctl"): "legacy smart wrapper\n",
filepath.Join(helperDir, "pct"): "legacy pct wrapper\n",
filepath.Join(stateDir, "token"): "monitoring-token\n",
filepath.Join(stateDir, "runtime.token"): "runtime-monitoring-token\n",
filepath.Join(stateDir, "agent-id"): "stable-agent-id\n",
filepath.Join(stateDir, "connection.env"): "PULSE_URL='https://pulse.example'\n",
filepath.Join(stateDir, "cache", "sample"): "cached-state\n",
filepath.Join(stateDir, "proxmox-registered"): "legacy-generic\n",
filepath.Join(stateDir, "proxmox-pve-registered"): "legacy-pve\n",
filepath.Join(stateDir, "proxmox-pbs-registered"): "legacy-pbs\n",
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",
}
for path, body := range files {
mustMkdirAll(t, filepath.Dir(path))
mustWrite(t, path, body)
}
if err := os.Chmod(stateDir, 0o750); err != nil {
t.Fatal(err)
}
if err := os.Chmod(filepath.Join(stateDir, "cache"), 0o710); err != nil {
t.Fatal(err)
}
if err := os.Chmod(filepath.Join(stateDir, "cache", "sample"), 0o640); err != nil {
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",
@@ -257,10 +510,36 @@ EXIT_MISSING_ARGS=2
log_info() { :; }
log_error() { printf 'ERROR:%s\n' "$*" >&2; }
fail() { printf 'FAIL:%s\n' "$1" >&2; return "${2:-1}"; }
systemctl() { case "${1:-}" in is-active|is-enabled) return 0 ;; *) return 0 ;; esac; }
systemctl() {
case "${1:-}" in
show)
case "${4:-}" in
FragmentPath) printf '%s\n' "$SAFE_PROFILE_COLLECTOR_UNIT" ;;
DropInPaths) printf '\n' ;;
esac
;;
is-active|is-enabled) return 0 ;;
esac
return 0
}
getent() { [[ "${1:-}" == group && "${2:-}" == docker ]]; }
id() { if [[ "${1:-}" == -nG ]]; then printf '` + membership + `\n'; fi; return 0; }
gpasswd() { printf 'gpasswd %s\n' "$*" >> "$CALL_LOG"; }
stat() {
if [[ "${1:-}" == -c ]]; then
if /usr/bin/stat -c "$2" "$3" 2>/dev/null; then
return
fi
case "$2" in
%u) /usr/bin/stat -f '%u' "$3" ;;
%g) /usr/bin/stat -f '%g' "$3" ;;
%a) /usr/bin/stat -f '%Lp' "$3" ;;
*) return 1 ;;
esac
return
fi
/usr/bin/stat "$@"
}
` + safeProfileTransactionFunctions(t) + "\n"
}
@@ -0,0 +1,979 @@
//go:build !windows
package installtests
// TestSecureRuntimeSystemdLab is intentionally excluded from ordinary test
// runs. It installs services and users into a disposable Linux systemd host.
// Build the release-shaped inputs on the host, then copy or mount them into a
// dedicated Colima profile before opting in:
//
// mkdir -p .lab-artifacts
// v1_ldflags="$(./scripts/release_ldflags.sh agent --version 6.2.0-lab.1)"
// v2_ldflags="$(./scripts/release_ldflags.sh agent --version 6.2.0-lab.2)"
// CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$v1_ldflags" -o .lab-artifacts/pulse-agent-v1 ./cmd/pulse-agent
// CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$v2_ldflags" -o .lab-artifacts/pulse-agent-v2 ./cmd/pulse-agent
// CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o .lab-artifacts/pulse-agent-helper ./cmd/pulse-agent-helper
// CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go test -c -o .lab-artifacts/installtests-linux-arm64.test ./scripts/installtests
// colima start pulse-agent-qual --activate=false --mount "$PWD:w"
// colima ssh -p pulse-agent-qual -- sudo sh -c \
// 'printf "%s\n" "PULSE_SECURE_RUNTIME_SYSTEMD_LAB=disposable-v1" > /etc/pulse-secure-runtime-lab'
// repo="$PWD"
// colima ssh -p pulse-agent-qual -- sudo env \
// PULSE_SECURE_RUNTIME_SYSTEMD_LAB=1 \
// PULSE_SECURE_RUNTIME_COLLECTOR_V1="$repo/.lab-artifacts/pulse-agent-v1" \
// PULSE_SECURE_RUNTIME_COLLECTOR_V2="$repo/.lab-artifacts/pulse-agent-v2" \
// PULSE_SECURE_RUNTIME_HELPER="$repo/.lab-artifacts/pulse-agent-helper" \
// PULSE_SECURE_RUNTIME_RECEIPT=/tmp/secure-runtime-receipt.json \
// sh -c 'cd "$1/scripts/installtests" && exec "$1/.lab-artifacts/installtests-linux-arm64.test" -test.run "^TestSecureRuntimeSystemdLab$" -test.count=1 -test.v' sh "$repo"
//
// Use the VM's GOARCH in place of arm64 when qualifying another architecture.
// The test never creates or deletes a VM; lifecycle remains a host-side choice.
import (
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
)
const (
secureRuntimeLabOptIn = "PULSE_SECURE_RUNTIME_SYSTEMD_LAB"
secureRuntimeLabMarkerPath = "/etc/pulse-secure-runtime-lab"
secureRuntimeLabMarkerValue = "PULSE_SECURE_RUNTIME_SYSTEMD_LAB=disposable-v1"
secureRuntimeLabToken = "8fa728ed4cbfa7466947e747627b51e2464ed3f69966ed6394e36a82d85d7d31"
secureRuntimeLabAgentID = "secure-runtime-systemd-lab"
secureRuntimeLabHostname = "pulse-secure-runtime-lab"
)
var secureRuntimeInstalledPaths = []string{
"/usr/local/bin/pulse-agent",
"/usr/local/lib/pulse-agent/pulse-agent-helper",
"/etc/systemd/system/pulse-agent.service",
"/etc/systemd/system/pulse-agent.service.d",
"/etc/systemd/system/pulse-agent-helper.service",
"/etc/systemd/system/pulse-agent-helper.socket",
"/var/lib/pulse-agent",
"/var/lib/pulse-agent-helper",
"/var/lib/pulse-agent-profile",
}
type secureRuntimeLabReport struct {
ReceivedAt time.Time
AgentID string
AgentVersion string
Hostname string
RunningAsRoot bool
ServiceUser string
Authority string
TypedHelper bool
CommandsEnabled bool
}
type secureRuntimeLabFixture struct {
mu sync.Mutex
collector []byte
helper []byte
serverVersion string
reports []secureRuntimeLabReport
lastSeen time.Time
freezeLastSeen bool
authFailures int
requestFailures []string
}
func (f *secureRuntimeLabFixture) setCollector(artifact []byte) {
f.mu.Lock()
defer f.mu.Unlock()
f.collector = append([]byte(nil), artifact...)
}
func (f *secureRuntimeLabFixture) setServerVersion(version string) {
f.mu.Lock()
defer f.mu.Unlock()
f.serverVersion = version
}
func (f *secureRuntimeLabFixture) setFrozen(frozen bool) {
f.mu.Lock()
defer f.mu.Unlock()
f.freezeLastSeen = frozen
}
func (f *secureRuntimeLabFixture) snapshot() ([]secureRuntimeLabReport, time.Time, int, []string) {
f.mu.Lock()
defer f.mu.Unlock()
return append([]secureRuntimeLabReport(nil), f.reports...), f.lastSeen, f.authFailures, append([]string(nil), f.requestFailures...)
}
func (f *secureRuntimeLabFixture) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/version":
f.mu.Lock()
version := f.serverVersion
f.mu.Unlock()
writeSecureRuntimeJSON(w, http.StatusOK, map[string]any{"version": version})
case r.URL.Path == "/download/pulse-agent":
f.serveArtifact(w, r, false)
case r.URL.Path == "/download/pulse-agent-helper":
f.serveArtifact(w, r, true)
case r.URL.Path == "/api/health":
writeSecureRuntimeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
case r.URL.Path == "/api/agents/agent/report":
f.handleReport(w, r)
case r.URL.Path == "/api/agents/docker/report":
if !f.authorized(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
writeSecureRuntimeJSON(w, http.StatusOK, map[string]any{"success": true})
case r.URL.Path == "/api/agents/agent/lookup":
f.handleLookup(w, r)
case strings.HasPrefix(r.URL.Path, "/api/agents/agent/") && strings.HasSuffix(r.URL.Path, "/config"):
if !f.authorized(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
writeSecureRuntimeJSON(w, http.StatusOK, map[string]any{
"success": true,
"agentId": secureRuntimeLabAgentID,
"config": map[string]any{"settings": map[string]any{}},
})
default:
http.NotFound(w, r)
}
}
func (f *secureRuntimeLabFixture) authorized(r *http.Request) bool {
bearer := r.Header.Get("Authorization")
valid := r.Header.Get("X-API-Token") == secureRuntimeLabToken &&
(bearer == "" || bearer == "Bearer "+secureRuntimeLabToken) &&
r.URL.Query().Get("token") == ""
if !valid {
f.mu.Lock()
f.authFailures++
f.requestFailures = append(f.requestFailures, r.Method+" "+r.URL.Path+": invalid credential transport")
f.mu.Unlock()
}
return valid
}
func (f *secureRuntimeLabFixture) serveArtifact(w http.ResponseWriter, r *http.Request, helper bool) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
f.mu.Lock()
artifact := append([]byte(nil), f.collector...)
if helper {
artifact = append([]byte(nil), f.helper...)
}
f.mu.Unlock()
sum := sha256.Sum256(artifact)
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("X-Checksum-Sha256", hex.EncodeToString(sum[:]))
w.Header().Set("Content-Length", strconv.Itoa(len(artifact)))
if r.Method == http.MethodGet {
_, _ = w.Write(artifact)
}
}
func (f *secureRuntimeLabFixture) handleReport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !f.authorized(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
body := io.Reader(http.MaxBytesReader(w, r.Body, 32<<20))
if r.Header.Get("Content-Encoding") == "gzip" {
compressed, err := gzip.NewReader(body)
if err != nil {
http.Error(w, "invalid compressed report", http.StatusBadRequest)
return
}
defer compressed.Close()
body = compressed
}
var payload struct {
Agent struct {
ID string `json:"id"`
Version string `json:"version"`
Hostname string `json:"hostname"`
CommandsEnabled bool `json:"commandsEnabled"`
Privilege *struct {
RunningAsRoot bool `json:"runningAsRoot"`
ServiceUser string `json:"serviceUser"`
Authority string `json:"commandAuthority"`
TypedHelper bool `json:"typedHelper"`
} `json:"privilege"`
} `json:"agent"`
Host struct {
Hostname string `json:"hostname"`
} `json:"host"`
}
if err := json.NewDecoder(body).Decode(&payload); err != nil {
http.Error(w, "invalid report", http.StatusBadRequest)
return
}
report := secureRuntimeLabReport{
ReceivedAt: time.Now().UTC(),
AgentID: strings.TrimSpace(payload.Agent.ID),
AgentVersion: strings.TrimSpace(payload.Agent.Version),
Hostname: strings.TrimSpace(payload.Agent.Hostname),
CommandsEnabled: payload.Agent.CommandsEnabled,
}
if report.Hostname == "" {
report.Hostname = strings.TrimSpace(payload.Host.Hostname)
}
if payload.Agent.Privilege != nil {
report.RunningAsRoot = payload.Agent.Privilege.RunningAsRoot
report.ServiceUser = payload.Agent.Privilege.ServiceUser
report.Authority = payload.Agent.Privilege.Authority
report.TypedHelper = payload.Agent.Privilege.TypedHelper
}
f.mu.Lock()
f.reports = append(f.reports, report)
if !f.freezeLastSeen {
f.lastSeen = report.ReceivedAt
}
serverVersion := f.serverVersion
f.mu.Unlock()
writeSecureRuntimeJSON(w, http.StatusOK, map[string]any{
"success": true,
"agentId": secureRuntimeLabAgentID,
"serverVersion": serverVersion,
})
}
func (f *secureRuntimeLabFixture) handleLookup(w http.ResponseWriter, r *http.Request) {
if !f.authorized(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
f.mu.Lock()
lastSeen := f.lastSeen
f.mu.Unlock()
if lastSeen.IsZero() {
writeSecureRuntimeJSON(w, http.StatusNotFound, map[string]any{"success": false})
return
}
writeSecureRuntimeJSON(w, http.StatusOK, map[string]any{
"success": true,
"agent": map[string]any{
"id": secureRuntimeLabAgentID,
"hostname": secureRuntimeLabHostname,
"lastSeen": lastSeen.Format(time.RFC3339Nano),
},
})
}
func writeSecureRuntimeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
type secureRuntimeFileIdentity struct {
Present bool
Mode os.FileMode
UID uint32
GID uint32
Hash string
}
type secureRuntimeStableIdentity map[string]secureRuntimeFileIdentity
func secureRuntimeStableSnapshot(t *testing.T) secureRuntimeStableIdentity {
t.Helper()
paths := []string{
"/usr/local/bin/pulse-agent",
"/etc/systemd/system/pulse-agent.service",
"/var/lib/pulse-agent/agent-id",
"/var/lib/pulse-agent/connection.env",
"/var/lib/pulse-agent/token",
"/var/lib/pulse-agent/runtime.token",
}
result := make(secureRuntimeStableIdentity, len(paths))
for _, path := range paths {
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
result[path] = secureRuntimeFileIdentity{}
continue
}
if err != nil {
t.Fatalf("inspect stable identity %s: %v", path, err)
}
if !info.Mode().IsRegular() {
t.Fatalf("stable identity path is not a regular file: %s (%s)", path, info.Mode())
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read stable identity %s: %v", path, err)
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
t.Fatalf("read ownership for %s", path)
}
sum := sha256.Sum256(content)
result[path] = secureRuntimeFileIdentity{
Present: true,
Mode: info.Mode().Perm(),
UID: stat.Uid,
GID: stat.Gid,
Hash: hex.EncodeToString(sum[:]),
}
}
return result
}
type secureRuntimeScenarioResult struct {
Name string `json:"name"`
Passed bool `json:"passed"`
Detail string `json:"detail,omitempty"`
}
type secureRuntimeLabReceipt struct {
SchemaVersion int `json:"schema_version"`
CompletedAt string `json:"completed_at"`
SourceHashes map[string]string `json:"source_hashes"`
ArtifactHashes map[string]string `json:"artifact_hashes"`
OSRelease string `json:"os_release"`
Kernel string `json:"kernel"`
SystemdVersion string `json:"systemd_version"`
Architecture string `json:"architecture"`
CollectorServiceUser string `json:"collector_service_user"`
CollectorProcessUID int `json:"collector_process_uid"`
CollectorAuthority string `json:"collector_authority"`
AmbientCapabilitiesNone bool `json:"ambient_capabilities_none"`
HelperProtocolHealthy bool `json:"helper_protocol_healthy"`
StateIdentityPreserved bool `json:"state_identity_preserved"`
DockerDegraded bool `json:"docker_degraded"`
ReportCount int `json:"report_count"`
FirstReportAt string `json:"first_report_at"`
LastReportAt string `json:"last_report_at"`
Scenarios []secureRuntimeScenarioResult `json:"scenarios"`
}
func TestSecureRuntimeSystemdLab(t *testing.T) {
if os.Getenv(secureRuntimeLabOptIn) != "1" {
t.Skip("set PULSE_SECURE_RUNTIME_SYSTEMD_LAB=1 only inside a disposable systemd VM")
}
secureRuntimeRequireDisposableHost(t)
collectorV1 := secureRuntimeReadArtifact(t, "PULSE_SECURE_RUNTIME_COLLECTOR_V1")
collectorV2 := secureRuntimeReadArtifact(t, "PULSE_SECURE_RUNTIME_COLLECTOR_V2")
helper := secureRuntimeReadArtifact(t, "PULSE_SECURE_RUNTIME_HELPER")
collectorV1Version := secureRuntimeArtifactVersion(t, "PULSE_SECURE_RUNTIME_COLLECTOR_V1")
collectorV2Version := secureRuntimeArtifactVersion(t, "PULSE_SECURE_RUNTIME_COLLECTOR_V2")
if collectorV1Version == collectorV2Version {
t.Fatalf("collector V1 and V2 must have distinct --version output, both reported %q", collectorV1Version)
}
installerPath, err := filepath.Abs(repoFile("scripts", "install.sh"))
if err != nil {
t.Fatalf("resolve installer path: %v", err)
}
installer := secureRuntimeReadFile(t, installerPath)
fixture := &secureRuntimeLabFixture{collector: collectorV1, helper: helper, serverVersion: collectorV1Version}
server := httptest.NewServer(fixture)
defer server.Close()
receipt := secureRuntimeLabReceipt{
SchemaVersion: 1,
SourceHashes: map[string]string{
"scripts/install.sh": secureRuntimeHash(installer),
"scripts/installtests/secure_runtime_systemd_lab_test.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("scripts", "installtests", "secure_runtime_systemd_lab_test.go"))),
},
ArtifactHashes: map[string]string{"collector_v1": secureRuntimeHash(collectorV1), "collector_v2": secureRuntimeHash(collectorV2), "helper": secureRuntimeHash(helper)},
Architecture: runtime.GOARCH,
}
receipt.OSRelease = strings.TrimSpace(string(secureRuntimeReadFile(t, "/etc/os-release")))
receipt.Kernel = secureRuntimeCommand(t, 10*time.Second, "uname", "-srvmo")
receipt.SystemdVersion = strings.SplitN(secureRuntimeCommand(t, 10*time.Second, "systemctl", "--version"), "\n", 2)[0]
pass := func(name, detail string) {
receipt.Scenarios = append(receipt.Scenarios, secureRuntimeScenarioResult{Name: name, Passed: true, Detail: detail})
}
initialArgs := []string{"--enable-commands", "--command-authority", "command-capable"}
dockerInitiallyAvailable := secureRuntimeRootfulDockerAvailable()
if dockerInitiallyAvailable {
initialArgs = append(initialArgs, "--enable-docker")
}
secureRuntimeRunInstaller(t, installerPath, server.URL, initialArgs...)
secureRuntimeWaitForReports(t, fixture, 1, 45*time.Second)
secureRuntimeAssertRootCommandProfile(t)
dockerInitiallyEnabled := secureRuntimeCollectorHasArgument("--enable-docker")
if dockerInitiallyAvailable && !dockerInitiallyEnabled {
t.Fatal("rootful Docker was available but the requested legacy profile did not enable Docker monitoring")
}
pass("legacy_root_command_capable_install", fmt.Sprintf("root collector installed; docker_enabled=%t", dockerInitiallyEnabled))
beforeInspect := secureRuntimeStableSnapshot(t)
inspectOutput := secureRuntimeRunInstaller(t, installerPath, server.URL, "--safe-profile-inspect")
if !strings.Contains(inspectOutput, "current_profile=legacy-root-command-capable") ||
!strings.Contains(inspectOutput, "target_profile=typed-helper-monitoring-only") {
t.Fatalf("read-only inspection did not describe legacy and target profiles:\n%s", inspectOutput)
}
if afterInspect := secureRuntimeStableSnapshot(t); !secureRuntimeIdentitiesEqual(beforeInspect, afterInspect) {
t.Fatal("--safe-profile-inspect mutated installer-owned stable files")
}
pass("read_only_inspect", "stable installer-owned files unchanged")
dropInDir := "/etc/systemd/system/pulse-agent.service.d"
dropInPath := filepath.Join(dropInDir, "secure-runtime-lab.conf")
if err := os.MkdirAll(dropInDir, 0o755); err != nil {
t.Fatalf("create systemd drop-in directory: %v", err)
}
if err := os.WriteFile(dropInPath, []byte("[Service]\nUser=root\n"), 0o644); err != nil {
t.Fatalf("write systemd drop-in: %v", err)
}
dropInPresent := true
t.Cleanup(func() {
if dropInPresent {
_ = os.Remove(dropInPath)
_ = os.Remove(dropInDir)
_ = exec.Command("systemctl", "daemon-reload").Run()
}
})
secureRuntimeCommand(t, 20*time.Second, "systemctl", "daemon-reload")
beforeDropIn := secureRuntimeStableSnapshot(t)
failedOutput, failedErr := secureRuntimeRunInstallerError(t, installerPath, server.URL, "--safe-profile-apply")
if failedErr == nil || !strings.Contains(failedOutput, "systemd drop-in override") {
t.Fatalf("safe apply did not fail closed on a systemd drop-in: err=%v\n%s", failedErr, failedOutput)
}
if afterDropIn := secureRuntimeStableSnapshot(t); !secureRuntimeIdentitiesEqual(beforeDropIn, afterDropIn) {
t.Fatal("drop-in rejection mutated installer-owned stable files")
}
if err := os.Remove(dropInPath); err != nil {
t.Fatalf("remove systemd drop-in: %v", err)
}
_ = os.Remove(dropInDir)
dropInPresent = false
secureRuntimeCommand(t, 20*time.Second, "systemctl", "daemon-reload")
pass("drop_in_fail_closed_rehearsal", "migration rejected before stable installer-owned files changed")
legacyBaseline := secureRuntimeStableSnapshot(t)
reportsBeforeApply, preApplyLastSeen, _, _ := fixture.snapshot()
fixture.setCollector(collectorV2)
applyOutput := secureRuntimeRunInstaller(t, installerPath, server.URL, "--safe-profile-apply")
secureRuntimeWaitForReports(t, fixture, len(reportsBeforeApply)+1, 45*time.Second)
_, postApplyLastSeen, _, _ := fixture.snapshot()
if !postApplyLastSeen.After(preApplyLastSeen) {
t.Fatalf("safe apply committed without fresh server registration: before=%s after=%s", preApplyLastSeen, postApplyLastSeen)
}
secureRuntimeAssertSafeProfile(t)
secureRuntimeAssertHelperProtocol(t)
dockerDegraded := dockerInitiallyEnabled && !secureRuntimeCollectorHasArgument("--enable-docker")
if dockerInitiallyEnabled && !secureRuntimeCollectorOwnedRootlessAvailable(t) {
if !dockerDegraded || !strings.Contains(applyOutput, "disabled rootful Docker monitoring") {
t.Fatalf("safe migration did not make rootful Docker degradation explicit:\n%s", applyOutput)
}
}
pass("safe_profile_apply", "fresh server lastSeen, least-privilege identity, typed helper health")
reportsBeforeRollback, _, _, _ := fixture.snapshot()
secureRuntimeRunInstaller(t, installerPath, server.URL, "--safe-profile-rollback")
secureRuntimeWaitForReports(t, fixture, len(reportsBeforeRollback)+1, 45*time.Second)
secureRuntimeAssertRootCommandProfile(t)
secureRuntimeWaitForStableIdentity(t, legacyBaseline, 10*time.Second, "explicit rollback")
pass("explicit_safe_profile_rollback", "legacy binary, unit, authority, and service identity restored")
automaticRollbackBaseline := secureRuntimeStableSnapshot(t)
fixture.setFrozen(true)
failedOutput, failedErr = secureRuntimeRunInstallerError(t, installerPath, server.URL, "--safe-profile-apply")
fixture.setFrozen(false)
if failedErr == nil || !strings.Contains(failedOutput, "restoring the previous profile") {
t.Fatalf("stale lastSeen safe apply unexpectedly succeeded: err=%v\n%s", failedErr, failedOutput)
}
secureRuntimeWaitForStableIdentity(t, automaticRollbackBaseline, 10*time.Second, "automatic failure rollback")
secureRuntimeAssertRootCommandProfile(t)
pass("automatic_failure_rollback", "frozen lastSeen prevented commit and restored binary/unit/state identity")
fixture.setCollector(collectorV2)
reportsBeforeUpdate, _, _, _ := fixture.snapshot()
secureRuntimeRunInstaller(t, installerPath, server.URL, "--update")
secureRuntimeWaitForReports(t, fixture, len(reportsBeforeUpdate)+1, 45*time.Second)
secureRuntimeAssertRootCommandProfile(t)
if got := secureRuntimeHash(secureRuntimeReadFile(t, "/usr/local/bin/pulse-agent")); got != secureRuntimeHash(collectorV2) {
t.Fatalf("ordinary update collector hash = %s, want v2 hash", got)
}
pass("ordinary_update_non_migration", "binary updated while root command-capable profile remained unchanged")
fixture.setServerVersion(collectorV2Version)
reportsBeforeFinalApply, preFinalLastSeen, _, _ := fixture.snapshot()
secureRuntimeRunInstaller(t, installerPath, server.URL, "--safe-profile-apply")
secureRuntimeWaitForReports(t, fixture, len(reportsBeforeFinalApply)+1, 45*time.Second)
_, finalLastSeen, authFailures, requestFailures := fixture.snapshot()
if !finalLastSeen.After(preFinalLastSeen) {
t.Fatalf("final safe apply did not advance server lastSeen: before=%s after=%s", preFinalLastSeen, finalLastSeen)
}
if authFailures != 0 || len(requestFailures) != 0 {
t.Fatalf("fixture observed credential/request failures: auth=%d failures=%v", authFailures, requestFailures)
}
secureRuntimeAssertSafeProfile(t)
secureRuntimeAssertHelperProtocol(t)
reportsBeforeContinuity, _, _, _ := fixture.snapshot()
secureRuntimeWaitForReports(t, fixture, len(reportsBeforeContinuity)+1, 20*time.Second)
pass("final_safe_profile_apply", "collector continued reporting after committed migration")
reports, _, _, _ := fixture.snapshot()
if len(reports) == 0 {
t.Fatal("no reports recorded")
}
for i, report := range reports {
if report.AgentID != secureRuntimeLabAgentID {
t.Fatalf("report %d changed collector identity: %q", i, report.AgentID)
}
if report.Hostname != secureRuntimeLabHostname {
t.Fatalf("report %d changed collector hostname: %q", i, report.Hostname)
}
}
latest := reports[len(reports)-1]
if latest.RunningAsRoot || latest.ServiceUser != "pulse-agent" || latest.Authority != "monitoring-only" || !latest.TypedHelper || latest.CommandsEnabled {
t.Fatalf("final report privilege posture = %+v", latest)
}
receipt.CompletedAt = time.Now().UTC().Format(time.RFC3339Nano)
receipt.CollectorServiceUser = secureRuntimeSystemdProperty(t, "User")
receipt.CollectorProcessUID = secureRuntimeCollectorProcessUID(t)
receipt.CollectorAuthority = latest.Authority
receipt.AmbientCapabilitiesNone = strings.TrimSpace(secureRuntimeSystemdProperty(t, "AmbientCapabilities")) == ""
receipt.HelperProtocolHealthy = true
receipt.StateIdentityPreserved = true
receipt.DockerDegraded = dockerDegraded
receipt.ReportCount = len(reports)
receipt.FirstReportAt = reports[0].ReceivedAt.Format(time.RFC3339Nano)
receipt.LastReportAt = reports[len(reports)-1].ReceivedAt.Format(time.RFC3339Nano)
secureRuntimeWriteReceipt(t, receipt)
}
func secureRuntimeRequireDisposableHost(t *testing.T) {
t.Helper()
if runtime.GOOS != "linux" {
t.Fatalf("destructive secure-runtime lab requires Linux, got %s", runtime.GOOS)
}
if os.Geteuid() != 0 {
t.Fatal("destructive secure-runtime lab requires EUID 0")
}
comm, err := os.ReadFile("/proc/1/comm")
if err != nil || strings.TrimSpace(string(comm)) != "systemd" {
t.Fatalf("destructive secure-runtime lab requires systemd as PID 1: comm=%q err=%v", strings.TrimSpace(string(comm)), err)
}
marker, err := os.ReadFile(secureRuntimeLabMarkerPath)
if err != nil || strings.TrimSpace(string(marker)) != secureRuntimeLabMarkerValue {
t.Fatalf("refusing host mutation: %s must contain exactly %q", secureRuntimeLabMarkerPath, secureRuntimeLabMarkerValue)
}
for _, path := range secureRuntimeInstalledPaths {
if _, err := os.Lstat(path); err == nil {
t.Fatalf("refusing already-installed host: %s exists", path)
} else if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("inspect existing Pulse path %s: %v", path, err)
}
}
if out, err := exec.Command("systemctl", "show", "pulse-agent.service", "--property=LoadState", "--value").CombinedOutput(); err == nil && strings.TrimSpace(string(out)) != "not-found" {
t.Fatalf("refusing host with loaded pulse-agent.service: %s", strings.TrimSpace(string(out)))
}
}
func secureRuntimeReadArtifact(t *testing.T, envName string) []byte {
t.Helper()
path := strings.TrimSpace(os.Getenv(envName))
if path == "" || !filepath.IsAbs(path) {
t.Fatalf("%s must name an absolute caller-built artifact path", envName)
}
info, err := os.Lstat(path)
if err != nil {
t.Fatalf("inspect %s: %v", envName, err)
}
if !info.Mode().IsRegular() || info.Mode()&0o111 == 0 {
t.Fatalf("%s must be a regular executable file: %s (%s)", envName, path, info.Mode())
}
artifact := secureRuntimeReadFile(t, path)
if len(artifact) < 4 || !bytes.Equal(artifact[:4], []byte{0x7f, 'E', 'L', 'F'}) {
t.Fatalf("%s is not an ELF executable: %s", envName, path)
}
return artifact
}
func secureRuntimeArtifactVersion(t *testing.T, envName string) string {
t.Helper()
path := strings.TrimSpace(os.Getenv(envName))
if path == "" || !filepath.IsAbs(path) {
t.Fatalf("%s must name an absolute caller-built artifact path", envName)
}
version := secureRuntimeCommand(t, 10*time.Second, path, "--version")
version = strings.TrimSpace(strings.SplitN(version, "\n", 2)[0])
if version == "" || version == "unknown" {
t.Fatalf("%s returned unusable --version output %q", envName, version)
}
return version
}
func secureRuntimeReadFile(t *testing.T, path string) []byte {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return data
}
func secureRuntimeHash(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func secureRuntimeRunInstaller(t *testing.T, installerPath, fixtureURL string, scenarioArgs ...string) string {
t.Helper()
out, err := secureRuntimeRunInstallerError(t, installerPath, fixtureURL, scenarioArgs...)
if err != nil {
t.Fatalf("installer %s failed: %v\n%s", strings.Join(scenarioArgs, " "), err, out)
}
return out
}
func secureRuntimeRunInstallerError(t *testing.T, installerPath, fixtureURL string, scenarioArgs ...string) (string, error) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
args := []string{installerPath}
switch {
case len(scenarioArgs) == 1 && scenarioArgs[0] == "--safe-profile-inspect":
args = append(args, scenarioArgs...)
case len(scenarioArgs) == 1 && scenarioArgs[0] == "--safe-profile-rollback":
args = append(args, scenarioArgs...)
default:
tokenFile := filepath.Join(t.TempDir(), "monitoring-token")
if err := os.WriteFile(tokenFile, []byte(secureRuntimeLabToken+"\n"), 0o600); err != nil {
t.Fatalf("write private fixture token: %v", err)
}
args = append(args,
"--url", fixtureURL,
"--token-file", tokenFile,
"--interval", "2s",
"--agent-id", secureRuntimeLabAgentID,
"--hostname", secureRuntimeLabHostname,
"--state-dir", "/var/lib/pulse-agent",
"--insecure",
"--non-interactive",
)
args = append(args, scenarioArgs...)
}
cmd := exec.CommandContext(ctx, "bash", args...)
cmd.Env = os.Environ()
out, err := cmd.CombinedOutput()
if ctx.Err() != nil {
return string(out), fmt.Errorf("installer timed out: %w", ctx.Err())
}
if bytes.Contains(out, []byte(secureRuntimeLabToken)) {
t.Fatal("installer output exposed the monitoring credential")
}
return string(out), err
}
func secureRuntimeWaitForReports(t *testing.T, fixture *secureRuntimeLabFixture, count int, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
reports, _, _, _ := fixture.snapshot()
if len(reports) >= count {
return
}
time.Sleep(200 * time.Millisecond)
}
reports, _, _, failures := fixture.snapshot()
t.Fatalf("timed out waiting for %d reports; got %d (fixture failures: %v)", count, len(reports), failures)
}
func secureRuntimeCommand(t *testing.T, timeout time.Duration, name string, args ...string) string {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
if err != nil {
t.Fatalf("%s %s: %v\n%s", name, strings.Join(args, " "), err, out)
}
return strings.TrimSpace(string(out))
}
func secureRuntimeSystemdProperty(t *testing.T, property string) string {
t.Helper()
return secureRuntimeCommand(t, 10*time.Second, "systemctl", "show", "pulse-agent.service", "--property="+property, "--value")
}
func secureRuntimeCollectorHasArgument(argument string) bool {
out, err := exec.Command("systemctl", "show", "pulse-agent.service", "--property=ExecStart", "--value").Output()
if err != nil {
return false
}
for _, field := range strings.Fields(string(out)) {
if strings.Trim(field, `{ };"`) == argument {
return true
}
}
return false
}
func secureRuntimeCollectorProcessUID(t *testing.T) int {
t.Helper()
pidText := secureRuntimeSystemdProperty(t, "MainPID")
pid, err := strconv.Atoi(strings.TrimSpace(pidText))
if err != nil || pid <= 1 {
t.Fatalf("invalid pulse-agent MainPID %q", pidText)
}
status := string(secureRuntimeReadFile(t, fmt.Sprintf("/proc/%d/status", pid)))
for _, line := range strings.Split(status, "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 && fields[0] == "Uid:" {
uid, err := strconv.Atoi(fields[1])
if err != nil {
t.Fatalf("parse collector UID from %q: %v", line, err)
}
return uid
}
}
t.Fatalf("collector process status omitted UID: %s", status)
return -1
}
func secureRuntimeAssertRootCommandProfile(t *testing.T) {
t.Helper()
if user := secureRuntimeSystemdProperty(t, "User"); user != "root" && user != "" {
t.Fatalf("legacy collector systemd User = %q, want root", user)
}
if !secureRuntimeCollectorHasArgument("--enable-commands") {
t.Fatal("legacy collector is not command-capable")
}
if uid := secureRuntimeCollectorProcessUID(t); uid != 0 {
t.Fatalf("legacy collector process UID = %d, want 0", uid)
}
secureRuntimeCommand(t, 10*time.Second, "systemctl", "is-active", "pulse-agent.service")
}
func secureRuntimeAssertSafeProfile(t *testing.T) {
t.Helper()
if user := secureRuntimeSystemdProperty(t, "User"); user != "pulse-agent" {
t.Fatalf("safe collector systemd User = %q, want pulse-agent", user)
}
if ambient := strings.TrimSpace(secureRuntimeSystemdProperty(t, "AmbientCapabilities")); ambient != "" {
t.Fatalf("safe collector AmbientCapabilities = %q, want none", ambient)
}
if secureRuntimeCollectorHasArgument("--enable-commands") {
t.Fatal("safe collector retained --enable-commands")
}
if environment := secureRuntimeSystemdProperty(t, "Environment"); !strings.Contains(environment, "PULSE_AGENT_HELPER_SOCKET=/run/pulse-agent/helper.sock") {
t.Fatalf("safe collector lacks typed-helper environment: %s", environment)
}
uidText := secureRuntimeCommand(t, 10*time.Second, "id", "-u", "pulse-agent")
wantUID, err := strconv.Atoi(uidText)
if err != nil {
t.Fatalf("parse pulse-agent UID %q: %v", uidText, err)
}
if uid := secureRuntimeCollectorProcessUID(t); uid != wantUID {
t.Fatalf("safe collector process UID = %d, want %d", uid, wantUID)
}
groups := strings.Fields(secureRuntimeCommand(t, 10*time.Second, "id", "-nG", "pulse-agent"))
for _, group := range groups {
if group == "docker" {
t.Fatal("safe collector retained rootful docker group membership")
}
}
for _, path := range []string{"/usr/local/bin/pulse-agent", "/usr/local/lib/pulse-agent/pulse-agent-helper"} {
identity := secureRuntimeStableFileIdentity(t, path)
if identity.UID != 0 || identity.GID != 0 || identity.Mode != 0o755 {
t.Fatalf("%s identity = uid:%d gid:%d mode:%#o, want root:root 0755", path, identity.UID, identity.GID, identity.Mode)
}
}
secureRuntimeCommand(t, 10*time.Second, "systemctl", "is-active", "pulse-agent.service")
secureRuntimeCommand(t, 10*time.Second, "systemctl", "is-active", "pulse-agent-helper.socket")
}
func secureRuntimeStableFileIdentity(t *testing.T, path string) secureRuntimeFileIdentity {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat %s: %v", path, err)
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
t.Fatalf("read ownership for %s", path)
}
return secureRuntimeFileIdentity{Present: true, Mode: info.Mode().Perm(), UID: stat.Uid, GID: stat.Gid}
}
func secureRuntimeAssertHelperProtocol(t *testing.T) {
t.Helper()
requestID := "secure-runtime-lab-health"
request, err := json.Marshal(map[string]any{
"protocolVersion": 1,
"requestId": requestID,
"operation": "helper.health",
"operationVersion": 1,
"deadlineMillis": 2000,
"payload": map[string]any{},
})
if err != nil {
t.Fatalf("marshal helper health request: %v", err)
}
frame := make([]byte, 4+len(request))
binary.BigEndian.PutUint32(frame[:4], uint32(len(request)))
copy(frame[4:], request)
requestHandle, err := os.CreateTemp("/tmp", "pulse-helper-health-*.frame")
if err != nil {
t.Fatalf("create helper health frame: %v", err)
}
requestFile := requestHandle.Name()
t.Cleanup(func() { _ = os.Remove(requestFile) })
if _, err := requestHandle.Write(frame); err != nil {
_ = requestHandle.Close()
t.Fatalf("write helper health frame: %v", err)
}
if err := requestHandle.Chmod(0o644); err != nil {
_ = requestHandle.Close()
t.Fatalf("make helper health frame collector-readable: %v", err)
}
if err := requestHandle.Close(); err != nil {
t.Fatalf("close helper health frame: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
output, err := exec.CommandContext(ctx, "runuser", "-u", "pulse-agent", "--", "curl", "-sS", "--max-time", "5", "--unix-socket", "/run/pulse-agent/helper.sock", "--upload-file", requestFile, "telnet://localhost").Output()
if err != nil {
t.Fatalf("typed helper protocol health: %v", err)
}
if len(output) < 5 {
t.Fatalf("typed helper returned short frame: %d bytes", len(output))
}
length := int(binary.BigEndian.Uint32(output[:4]))
if length != len(output)-4 {
t.Fatalf("typed helper frame length = %d, payload bytes = %d", length, len(output)-4)
}
var response struct {
ProtocolVersion int `json:"protocolVersion"`
RequestID string `json:"requestId"`
Operation string `json:"operation"`
OperationVersion int `json:"operationVersion"`
Success bool `json:"success"`
Result struct {
Status string `json:"status"`
} `json:"result"`
}
if err := json.Unmarshal(output[4:], &response); err != nil {
t.Fatalf("decode typed helper health: %v", err)
}
if response.ProtocolVersion != 1 || response.RequestID != requestID || response.Operation != "helper.health" || response.OperationVersion != 1 || !response.Success || response.Result.Status != "ok" {
t.Fatalf("typed helper health response failed correlation/health: %+v", response)
}
secureRuntimeCommand(t, 10*time.Second, "systemctl", "is-active", "pulse-agent-helper.service")
}
func secureRuntimeIdentitiesEqual(left, right secureRuntimeStableIdentity) bool {
if len(left) != len(right) {
return false
}
for path, identity := range left {
if right[path] != identity {
return false
}
}
return true
}
func secureRuntimeWaitForStableIdentity(t *testing.T, want secureRuntimeStableIdentity, timeout time.Duration, scenario string) {
t.Helper()
deadline := time.Now().Add(timeout)
var got secureRuntimeStableIdentity
for time.Now().Before(deadline) {
got = secureRuntimeStableSnapshot(t)
if secureRuntimeIdentitiesEqual(want, got) {
return
}
time.Sleep(100 * time.Millisecond)
}
t.Fatalf("%s did not converge to the pre-migration binary/unit/state identity: want=%#v got=%#v", scenario, want, got)
}
func secureRuntimeRootfulDockerAvailable() bool {
if info, err := os.Stat("/var/run/docker.sock"); err != nil || info.Mode()&os.ModeSocket == 0 {
return false
}
transport := &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock")
},
}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
response, err := client.Get("http://docker/_ping")
if err != nil {
return false
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, 16))
return err == nil && response.StatusCode == http.StatusOK && strings.TrimSpace(string(body)) == "OK"
}
func secureRuntimeCollectorOwnedRootlessAvailable(t *testing.T) bool {
t.Helper()
uidText, err := exec.Command("id", "-u", "pulse-agent").Output()
if err != nil {
return false
}
uid := strings.TrimSpace(string(uidText))
for _, path := range []string{filepath.Join("/run/user", uid, "docker.sock"), filepath.Join("/run/user", uid, "podman", "podman.sock")} {
if info, err := os.Stat(path); err == nil && info.Mode()&os.ModeSocket != 0 {
return true
}
}
return false
}
func secureRuntimeWriteReceipt(t *testing.T, receipt secureRuntimeLabReceipt) {
t.Helper()
path := strings.TrimSpace(os.Getenv("PULSE_SECURE_RUNTIME_RECEIPT"))
if path == "" {
return
}
if !filepath.IsAbs(path) {
t.Fatalf("PULSE_SECURE_RUNTIME_RECEIPT must be an absolute path: %s", path)
}
encoded, err := json.MarshalIndent(receipt, "", " ")
if err != nil {
t.Fatalf("marshal secure-runtime receipt: %v", err)
}
if bytes.Contains(encoded, []byte(secureRuntimeLabToken)) || bytes.Contains(bytes.ToLower(encoded), []byte("token")) {
t.Fatal("refusing to write a receipt containing credential material or token-labelled fields")
}
encoded = append(encoded, '\n')
temporary := path + ".tmp"
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("create receipt directory: %v", err)
}
if err := os.WriteFile(temporary, encoded, 0o600); err != nil {
t.Fatalf("write receipt temporary file: %v", err)
}
if err := os.Rename(temporary, path); err != nil {
t.Fatalf("publish receipt: %v", err)
}
}