From d607d5cf4616e52b2fc7a576846dbe7204e9a7cf Mon Sep 17 00:00:00 2001 From: Pulse Test Date: Sat, 29 Aug 2026 23:47:00 +0100 Subject: [PATCH] Separate agent remediation runtime --- .github/workflows/create-release.yml | 5 + cmd/pulse-agent-helper/main.go | 37 +- cmd/pulse-agent-helper/main_test.go | 15 + cmd/pulse-agent-runner/main.go | 135 +++ cmd/pulse-agent-runner/main_test.go | 46 + cmd/pulse-agent/main.go | 9 + cmd/pulse-agent/main_test.go | 15 + docs/AGENT_SECURITY.md | 51 +- docs/UNIFIED_AGENT.md | 33 +- .../v6/internal/subsystems/agent-lifecycle.md | 36 +- .../v6/internal/subsystems/api-contracts.md | 14 + .../subsystems/deployment-installability.md | 38 + .../subsystems/performance-and-scalability.md | 6 + .../v6/internal/subsystems/registry.json | 77 +- .../internal/subsystems/security-privacy.md | 21 + .../internal/subsystems/storage-recovery.md | 5 + internal/actionrunner/runner.go | 176 ++++ internal/actionrunner/runner_test.go | 211 +++++ internal/actionrunner/transport.go | 33 + internal/actionrunner/types.go | 273 ++++++ .../proxmox_guest_lifecycle_codec.go | 175 ++++ .../proxmox_guest_lifecycle_codec_test.go | 62 ++ internal/agentexec/server.go | 115 ++- internal/agentexec/server_websocket_test.go | 82 ++ internal/agentexec/types.go | 56 ++ internal/agenthelper/container_inventory.go | 178 ++++ .../agenthelper/container_inventory_test.go | 102 ++ internal/agenthelper/file_security_other.go | 22 + internal/agenthelper/file_security_unix.go | 31 + .../agenthelper/file_security_unix_test.go | 27 + internal/agenthelper/protocol.go | 6 + internal/agenthelper/providers.go | 18 +- internal/agenthelper/registry.go | 62 ++ internal/agenthelper/server_test.go | 80 ++ internal/agenthelper/update_activation.go | 494 ++++++++++ .../agenthelper/update_activation_test.go | 217 +++++ internal/agentupdate/coverage_test.go | 6 + internal/agentupdate/privileged_update.go | 147 +++ .../privileged_update_owner_other.go | 7 + .../privileged_update_owner_unix.go | 13 + .../agentupdate/privileged_update_test.go | 109 +++ internal/agentupdate/update.go | 98 ++ internal/api/action_runner_credentials.go | 122 +++ .../api/action_runner_credentials_test.go | 138 +++ internal/api/agent_exec_token_binding.go | 62 +- internal/api/agentbinding/policy.go | 23 + internal/api/agentbinding/policy_test.go | 37 + internal/api/agenttokens/install.go | 106 +++ internal/api/agenttokens/install_test.go | 53 ++ internal/api/contract_test.go | 34 + internal/api/proxmox_guest_action_executor.go | 95 +- internal/api/route_inventory_test.go | 7 + internal/api/router.go | 2 + .../api/router_download_inventory_test.go | 4 + .../api/router_public_paths_inventory_test.go | 2 + internal/api/router_routes_auth_security.go | 1 + internal/api/router_routes_registration.go | 1 + internal/api/security_regression_test.go | 60 ++ internal/api/unified_agent.go | 119 +++ internal/api/unified_agent_more_test.go | 63 ++ internal/dockeragent/action_runtime.go | 41 + internal/hostagent/action_runner_client.go | 139 +++ .../hostagent/action_runner_client_test.go | 156 +++ internal/hostagent/command_client_test.go | 13 + internal/hostagent/commands.go | 91 +- internal/hostagent/docker_update.go | 2 + internal/hostagent/proxmox_guest_lifecycle.go | 152 +++ .../hostagent/proxmox_guest_lifecycle_test.go | 134 +++ scripts/build-release-binaries.sh | 8 + scripts/build-release.sh | 31 + scripts/install.sh | 885 +++++++++++++++++- .../installtests/build_release_assets_test.go | 65 ++ scripts/installtests/install_sh_test.go | 114 ++- .../safe_profile_migration_test.go | 289 ++++++ scripts/prepare-release-container-context.sh | 9 + scripts/release_asset_common.sh | 9 + scripts/release_build_targets.sh | 9 + .../canonical_completion_guard_test.py | 35 + .../release_control/subsystem_lookup_test.py | 33 + scripts/validate-release.sh | 13 +- 80 files changed, 6418 insertions(+), 82 deletions(-) create mode 100644 cmd/pulse-agent-runner/main.go create mode 100644 cmd/pulse-agent-runner/main_test.go create mode 100644 internal/actionrunner/runner.go create mode 100644 internal/actionrunner/runner_test.go create mode 100644 internal/actionrunner/transport.go create mode 100644 internal/actionrunner/types.go create mode 100644 internal/agentexec/proxmox_guest_lifecycle_codec.go create mode 100644 internal/agentexec/proxmox_guest_lifecycle_codec_test.go create mode 100644 internal/agenthelper/container_inventory.go create mode 100644 internal/agenthelper/container_inventory_test.go create mode 100644 internal/agenthelper/file_security_other.go create mode 100644 internal/agenthelper/file_security_unix.go create mode 100644 internal/agenthelper/file_security_unix_test.go create mode 100644 internal/agenthelper/update_activation.go create mode 100644 internal/agenthelper/update_activation_test.go create mode 100644 internal/agentupdate/privileged_update.go create mode 100644 internal/agentupdate/privileged_update_owner_other.go create mode 100644 internal/agentupdate/privileged_update_owner_unix.go create mode 100644 internal/agentupdate/privileged_update_test.go create mode 100644 internal/api/action_runner_credentials.go create mode 100644 internal/api/action_runner_credentials_test.go create mode 100644 internal/dockeragent/action_runtime.go create mode 100644 internal/hostagent/action_runner_client.go create mode 100644 internal/hostagent/action_runner_client_test.go create mode 100644 internal/hostagent/proxmox_guest_lifecycle.go create mode 100644 internal/hostagent/proxmox_guest_lifecycle_test.go create mode 100644 scripts/installtests/safe_profile_migration_test.go diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index c2154d137..06edacd56 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -1116,6 +1116,11 @@ jobs: release/pulse-agent-helper-linux-armv7 \ release/pulse-agent-helper-linux-armv6 \ release/pulse-agent-helper-linux-386 \ + release/pulse-agent-runner-linux-amd64 \ + release/pulse-agent-runner-linux-arm64 \ + release/pulse-agent-runner-linux-armv7 \ + release/pulse-agent-runner-linux-armv6 \ + release/pulse-agent-runner-linux-386 \ release/pulse-agent-freebsd-amd64 \ release/pulse-agent-freebsd-arm64 \ release/pulse-agent-windows-amd64.exe \ diff --git a/cmd/pulse-agent-helper/main.go b/cmd/pulse-agent-helper/main.go index 21255162b..7976ba5ad 100644 --- a/cmd/pulse-agent-helper/main.go +++ b/cmd/pulse-agent-helper/main.go @@ -19,10 +19,18 @@ import ( "time" "github.com/rcourtman/pulse-go-rewrite/internal/agenthelper" + "github.com/rcourtman/pulse-go-rewrite/internal/updatesignature" ) const defaultSocketPath = "/run/pulse-agent/helper.sock" +const ( + updateQuarantineDir = "/var/lib/pulse-agent/update-quarantine" + updateStagingDir = "/var/lib/pulse-agent-helper/update-staging" + agentBinaryPath = "/usr/local/bin/pulse-agent" + updateStatePath = "/var/lib/pulse-agent-helper/update-activation.json" +) + type commandConfig struct { socketPath string allowedUID int64 @@ -60,7 +68,34 @@ func run(args []string) error { } defer cleanup() - registry := agenthelper.NewRegistry(localSMARTProvider{}, localProxmoxProvider{}) + containers, err := agenthelper.NewLocalContainerProvider([]agenthelper.ContainerEndpoint{ + {Runtime: "docker", SocketPath: "/var/run/docker.sock", APIPath: "/v1.41/containers/json?all=1"}, + {Runtime: "podman", SocketPath: "/run/podman/podman.sock", APIPath: "/v4.0.0/libpod/containers/json?all=true"}, + }) + if err != nil { + return fmt.Errorf("configure container inventory: %w", err) + } + updates, err := agenthelper.NewUpdateActivator(agenthelper.UpdateActivatorConfig{ + QuarantineDir: updateQuarantineDir, + StagingDir: updateStagingDir, + TargetPath: agentBinaryPath, + StatePath: updateStatePath, + VerifySignature: func(data []byte, signature string) error { + if !updatesignature.HasTrustedPublicKeys() { + return errors.New("helper build contains no trusted update keys") + } + return updatesignature.VerifyBytes(data, signature) + }, + ValidateOwner: agenthelper.StrictRootOwnedFile, + ValidateQuarantineOwner: agenthelper.FileOwnedByUID(uid), + }) + if err != nil { + return fmt.Errorf("configure update activation: %w", err) + } + registry := agenthelper.NewRegistryWithProviders(localSMARTProvider{}, localProxmoxProvider{}, agenthelper.Providers{ + Containers: containers, + Updates: updates, + }) server, err := agenthelper.NewServer(agenthelper.ServerConfig{ AllowedUID: uid, PeerResolver: agenthelper.PlatformPeerResolver{}, diff --git a/cmd/pulse-agent-helper/main_test.go b/cmd/pulse-agent-helper/main_test.go index de5b518b0..81c0d75a9 100644 --- a/cmd/pulse-agent-helper/main_test.go +++ b/cmd/pulse-agent-helper/main_test.go @@ -128,6 +128,21 @@ func TestCommandRegistryWiresTypedLocalProviders(t *testing.T) { } } +func TestFixedPrivilegedEndpointsAreNotCallerConfigurable(t *testing.T) { + for _, args := range [][]string{ + {"--docker-socket", "/tmp/attacker.sock"}, + {"--staging-dir", "/tmp/staged"}, + {"--target", "/tmp/pulse-agent"}, + } { + if _, err := parseFlags(args); err == nil { + t.Fatalf("caller-selected privileged endpoint accepted: %v", args) + } + } + if updateStagingDir != "/var/lib/pulse-agent-helper/update-staging" || agentBinaryPath != "/usr/local/bin/pulse-agent" { + t.Fatal("update activation paths are not the fixed installer contract") + } +} + func TestLocalProxmoxProviderAlwaysReturnsValidJSON(t *testing.T) { result, err := (localProxmoxProvider{}).LXCFilesystems(t.Context()) if err != nil { diff --git a/cmd/pulse-agent-runner/main.go b/cmd/pulse-agent-runner/main.go new file mode 100644 index 000000000..6e1ec8325 --- /dev/null +++ b/cmd/pulse-agent-runner/main.go @@ -0,0 +1,135 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "os/signal" + "path/filepath" + "runtime" + "strings" + "syscall" + + "github.com/rcourtman/pulse-go-rewrite/internal/actionrunner" + "github.com/rcourtman/pulse-go-rewrite/internal/dockeragent" + "github.com/rs/zerolog" +) + +var version = "dev" + +type runtimeConfig struct { + PulseURL string + TokenFile string + StateDir string + HealthFile string + AgentIDFile string + ServerFingerprint string + CAFile string + Insecure bool +} + +func loadConfig() (runtimeConfig, error) { + config := runtimeConfig{ + PulseURL: strings.TrimSpace(os.Getenv("PULSE_URL")), + TokenFile: strings.TrimSpace(os.Getenv("PULSE_AGENT_RUNNER_TOKEN_FILE")), + 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")), + 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"), + } + 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") + } + 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") + } + if _, err := readPrivateValue(config.TokenFile, "runner token"); err != nil { + return runtimeConfig{}, err + } + if err := os.MkdirAll(config.StateDir, 0700); err != nil { + return runtimeConfig{}, fmt.Errorf("runner state directory: %w", err) + } + resolvedState, err := filepath.Abs(config.StateDir) + if err != nil { + return runtimeConfig{}, err + } + config.StateDir = resolvedState + resolvedHealth, err := filepath.Abs(config.HealthFile) + if err != nil { + return runtimeConfig{}, err + } + if filepath.Dir(resolvedHealth) != resolvedState || filepath.Base(resolvedHealth) != "health.json" { + return runtimeConfig{}, errors.New("action-runner health file must be health.json inside PULSE_AGENT_RUNNER_STATE_DIR") + } + config.HealthFile = resolvedHealth + return config, nil +} + +func run() error { + config, err := loadConfig() + if err != nil { + return err + } + token, err := readPrivateValue(config.TokenFile, "runner token") + if err != nil { + return err + } + agentID, err := readPrivateValue(config.AgentIDFile, "runner agent identity") + if err != nil { + return err + } + hostname, err := os.Hostname() + if err != nil || strings.TrimSpace(hostname) == "" { + return errors.New("determine action-runner hostname") + } + logger := zerolog.New(os.Stderr).With().Timestamp().Str("component", "action-runner").Logger() + transportConfig := actionrunner.TransportConfig{ + PulseURL: config.PulseURL, APIToken: token, StateDir: config.StateDir, + HealthPath: config.HealthFile, InsecureSkipVerify: config.Insecure, + CACertPath: config.CAFile, ServerFingerprint: config.ServerFingerprint, + Logger: &logger, + } + containerRuntime, runtimeErr := dockeragent.NewActionRuntime(strings.TrimSpace(os.Getenv("PULSE_AGENT_RUNNER_CONTAINER_RUNTIME")), &logger) + if runtimeErr != nil { + logger.Warn().Err(runtimeErr).Msg("Docker/Podman action capability unavailable") + } else { + defer containerRuntime.Close() + transportConfig.DockerContainerLifecycleOperator = containerRuntime + transportConfig.DockerContainerUpdater = containerRuntime + } + client := actionrunner.NewClient(transportConfig, agentID, hostname, version+"-"+runtime.GOOS+"-"+runtime.GOARCH) + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + defer client.Close() + if err := client.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { + return err + } + return nil +} + +func readPrivateValue(path, label string) (string, error) { + info, err := os.Lstat(path) + if err != nil { + return "", fmt.Errorf("%s file: %w", label, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 { + return "", fmt.Errorf("%s file must be regular and inaccessible to group/other", label) + } + value, err := os.ReadFile(path) + if err != nil || strings.TrimSpace(string(value)) == "" { + return "", fmt.Errorf("%s file is unreadable or empty", label) + } + return strings.TrimSpace(string(value)), nil +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/pulse-agent-runner/main_test.go b/cmd/pulse-agent-runner/main_test.go new file mode 100644 index 000000000..65b6908b1 --- /dev/null +++ b/cmd/pulse-agent-runner/main_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadConfigUsesDedicatedEnvironmentAndPrivateTokenFile(t *testing.T) { + dir := t.TempDir() + token := filepath.Join(dir, "runner.token") + agentID := filepath.Join(dir, "agent-id") + if err := os.WriteFile(token, []byte("secret\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(agentID, []byte("agent-1\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", agentID) + 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 { + t.Fatalf("config = %+v", config) + } +} + +func TestLoadConfigRejectsTokenInArgvEquivalentAndInsecureHTTPByDefault(t *testing.T) { + dir := t.TempDir() + t.Setenv("PULSE_URL", "http://pulse.example") + t.Setenv("PULSE_AGENT_RUNNER_TOKEN_FILE", filepath.Join(dir, "missing")) + 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")) + _, err := loadConfig() + if err == nil || !strings.Contains(err.Error(), "HTTPS") { + t.Fatalf("error = %v", err) + } +} diff --git a/cmd/pulse-agent/main.go b/cmd/pulse-agent/main.go index e510c9d17..efb0c4e6b 100644 --- a/cmd/pulse-agent/main.go +++ b/cmd/pulse-agent/main.go @@ -99,6 +99,7 @@ var ( return hostagent.New(c) } newPrivilegeHelperTelemetry = hostagent.NewPrivilegeHelperTelemetry + newPrivilegeHelperUpdate = agentupdate.NewPrivilegeHelperUpdate newUpdater func(agentupdate.Config) *agentupdate.Updater = agentupdate.New lookPath = exec.LookPath runAsWindowsServiceFunc = runAsWindowsService @@ -381,6 +382,13 @@ 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 != "" { + privilegedUpdate, err = newPrivilegeHelperUpdate(helperSocket) + if err != nil { + return fmt.Errorf("configure typed privilege-helper updates: %w", err) + } + } updater := newUpdater(agentupdate.Config{ PulseURL: cfg.PulseURL, APIToken: cfg.APIToken, @@ -393,6 +401,7 @@ func run(ctx context.Context, args []string, getenv func(string) string) error { ServerFingerprint: cfg.ServerFingerprint, Logger: &logger, Disabled: cfg.DisableAutoUpdate, + PrivilegedUpdate: privilegedUpdate, }) g.Go(func() error { diff --git a/cmd/pulse-agent/main_test.go b/cmd/pulse-agent/main_test.go index 259376aa7..d7181365c 100644 --- a/cmd/pulse-agent/main_test.go +++ b/cmd/pulse-agent/main_test.go @@ -1740,19 +1740,31 @@ func TestRun_PassesStateDirToUpdaterAndHostAgent(t *testing.T) { func TestRunConfiguresTypedPrivilegeHelperFromInstallerEnvironment(t *testing.T) { originalHelper := newPrivilegeHelperTelemetry + originalUpdate := newPrivilegeHelperUpdate + originalUpdater := newUpdater originalHost := newHostAgent defer func() { newPrivilegeHelperTelemetry = originalHelper + newPrivilegeHelperUpdate = originalUpdate + newUpdater = originalUpdater newHostAgent = originalHost }() const socketPath = "/run/pulse-agent/helper.sock" t.Setenv("PULSE_AGENT_HELPER_SOCKET", socketPath) configuredPath := "" + configuredUpdatePath := "" newPrivilegeHelperTelemetry = func(path string) (hostagent.PrivilegedTelemetry, error) { configuredPath = path return nil, nil } + newPrivilegeHelperUpdate = func(path string) (agentupdate.PrivilegedUpdate, error) { + configuredUpdatePath = path + return nil, nil + } + newUpdater = func(agentupdate.Config) *agentupdate.Updater { + return agentupdate.New(agentupdate.Config{Disabled: true}) + } newHostAgent = func(hostagent.Config) (Runnable, error) { return &mockRunnable{}, nil } @@ -1771,6 +1783,9 @@ func TestRunConfiguresTypedPrivilegeHelperFromInstallerEnvironment(t *testing.T) if configuredPath != socketPath { t.Fatalf("helper socket path = %q, want %q", configuredPath, socketPath) } + if configuredUpdatePath != socketPath { + t.Fatalf("helper update socket path = %q, want %q", configuredUpdatePath, socketPath) + } } func TestRun_AgentFailure(t *testing.T) { diff --git a/docs/AGENT_SECURITY.md b/docs/AGENT_SECURITY.md index 5dd848d62..2fc1d4ed9 100644 --- a/docs/AGENT_SECURITY.md +++ b/docs/AGENT_SECURITY.md @@ -155,9 +155,12 @@ root-owned `/etc/pulse-agent` directory with `root:pulse-agent` group-read access; mutable identity, buffering, and enrolled monitoring-token state remain under the collector-owned state directory. The collector is not added to the rootful Docker group and cannot enable command execution. Automatic binary -replacement is disabled until signed update activation moves behind a separate -typed transaction, so operators must use an explicit signed installer update -for this opt-in profile. +replacement uses a separate typed transaction: the collector downloads and +self-tests a signed artifact inside its fixed quarantine, while the helper +revalidates ownership, digest, ELF shape, and signature before promoting it to +root-only staging and atomically activating it. A failed process restart asks +the helper to restore the identity-bound last-known-good binary; there is no +collector-writable direct-replacement fallback. Exceptional telemetry crosses `/run/pulse-agent/helper.sock` to a separate root process. The socket admits only the `pulse-agent` UID, and the helper has @@ -175,9 +178,45 @@ The typed-helper profile cannot be combined with `--grant-smart` or `--grant-pct`. Rootful Docker-socket monitoring is also unavailable because membership in the Docker group is root-equivalent; API monitoring or a separately scoped rootless runtime socket is required instead. The profile is -currently explicit rather than the installer default while migration, -rollback, update activation, appliance qualification, and the separate action -runner are completed. +currently explicit rather than the installer default. Its inspect, apply, and +rollback transaction is implemented for Linux systemd, but representative +live-host migration and helper update staging/activation/rollback exercises, +container-runtime parity, and appliance qualification are still required +before the profile can become the general default. + +Monitoring never implies remediation. On the supported Linux systemd profile, +an operator may separately enroll the typed action runner: + +```bash +install.sh --least-privilege --enable-privileged-helper \ + --enable-action-runner --action-token-file /root/pulse-runner.token ... +``` + +Create that token through the authenticated action-runner credential endpoint +for the exact monitored host; do not reuse the collector token. The installer +keeps the runner binary, service, credential, health record, and receipts +root-owned. Disabling or uninstalling the runner leaves monitoring active. +The safe runner accepts only versioned host update/storage-cleanup, Proxmox +guest lifecycle, and container lifecycle/update requests. Generic shell, +`exec`, unrestricted `read_file`, and deploy operations are rejected. + +The existing combined collector command path remains available only as the +explicit legacy/full-trust migration profile. It is not part of the typed +helper/runner security claim and will remain until supported command-enabled +installs have a runner enrollment path and live action-session parity has been +qualified. + +Safe-profile conversion is always deliberate: + +```bash +install.sh --safe-profile-inspect ... +install.sh --safe-profile-apply ... +install.sh --safe-profile-rollback ... +``` + +Inspection makes no changes. Apply snapshots collector/helper files and +identity before switching profiles, and rollback restores that snapshot. The +separately installed action runner is not changed by either operation. The older `--least-privilege` profile without `--enable-privileged-helper` remains available for compatibility. Its trade-offs are documented below. diff --git a/docs/UNIFIED_AGENT.md b/docs/UNIFIED_AGENT.md index 7863cf660..6f4bda0ac 100644 --- a/docs/UNIFIED_AGENT.md +++ b/docs/UNIFIED_AGENT.md @@ -90,11 +90,34 @@ curl -fsSL http://:7655/install.sh | \ - **Auto-Update**: Automatically updates when a new version is released - **Multi-Platform**: Linux, macOS, Windows support -The opt-in Linux typed-helper collector profile deliberately disables -in-process auto-update because its collector binary is root-owned and not -writable by the service account. Update that profile through the signed -installer until update activation is moved behind its own typed privileged -transaction. See [Agent Security](AGENT_SECURITY.md#least-privilege-agent-profile). +The opt-in Linux typed-helper collector profile keeps auto-update enabled +without making its root-owned binary writable by the service account. The +collector downloads and self-tests the signed artifact in a fixed +collector-owned quarantine; the no-network helper revalidates it, promotes it +to root-only staging, atomically activates it, and owns identity-bound rollback +if restart fails. There is no fallback to direct unprivileged replacement. +This transaction is covered by unit, race, release-build, and installer +contract tests; live-host restart/health and rollback qualification remains +required before the profile becomes the general default. +See [Agent Security](AGENT_SECURITY.md#least-privilege-agent-profile). + +On Linux systemd, the safe monitoring profile and remediation lifecycle are +separate install choices. `--least-privilege --enable-privileged-helper` +selects the opt-in unprivileged collector and no-network helper. Adding +`--enable-action-runner --action-token-file ` installs the +root-owned runner with a separately issued, host-bound action credential. +`--disable-action-runner` and `--uninstall-action-runner` remove remediation +without removing the collector. The runner accepts only the documented typed +host, Proxmox guest, and container operations; shell, generic exec, +unrestricted file reads, and deploy requests remain forbidden. + +Use `--safe-profile-inspect` to report the current profile and calculated +differences without changing the host. `--safe-profile-apply` performs the +explicit collector/helper migration and retains a rollback snapshot; +`--safe-profile-rollback` restores it. These commands are proven only for +Linux systemd and fail closed elsewhere. Appliance, non-systemd, Windows, and +macOS installs remain explicit legacy/full-trust profiles until their own +runtime and migration boundaries are qualified. On Linux, the host module automatically checks for `virsh`. When the agent can open the default libvirt connection read-only, defined domains appear as VM diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 8f39fd841..ba39e9f75 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -34,6 +34,25 @@ management, and fleet control surfaces. Pulse v6 has one host-installed infrastructure agent binary, `pulse-agent`; host, Docker / Podman, Kubernetes, Proxmox-local, and other node-local telemetry are modules inside that binary, not separate customer-facing agent products. +On supported Linux systemd hosts, the opt-in safe runtime is a root-owned, +unprivileged monitoring collector plus the no-network typed helper, with +remediation installed only as the separate root-owned `pulse-agent-runner`. +The runner has its own host-bound credential and state, registers with the +explicit `action-runner` role, and admits only protocol-v1 typed host-update, +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. + +The runner's server transport currently reuses the combined agent command +WebSocket envelope as a migration boundary. That compatibility path may carry +only the closed typed action set after runtime-role, organization, canonical +host, token, capability, target, digest, deadline, replay, cancellation, and +receipt validation. It must be removed once supported command-capable installs +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. 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 @@ -252,7 +271,12 @@ installer download and the agent's subsequent Pulse TLS connection. 5f. `pkg/agents/docker/report_limits.go` 5g. `internal/hostagent/xcpng.go` 5h. `internal/hostagent/proxmox_lxc_filesystems.go` + 5i. `internal/agenthelper/` + 5j. `internal/actionrunner/` + 5k. `internal/dockeragent/action_runtime.go` 6. `cmd/pulse-agent/main.go` + 6a. `cmd/pulse-agent-helper/main.go` + 6b. `cmd/pulse-agent-runner/main.go` 7. `scripts/install.sh` 8. `scripts/install.ps1` 8a. `.github/workflows/unified-agent-native.yml` @@ -6669,9 +6693,15 @@ errors correlated by request ID. Admission is local and fail closed: Linux peer credentials must resolve to the configured collector UID before dispatch. The registry exposes only named, versioned operations whose providers own all executable paths and arguments. -The first collection operations are `smart.snapshot.v1` and -`proxmox.lxc_filesystems.v1`; callers cannot supply a binary path, arbitrary -filesystem path, environment, or command arguments. A health/capabilities +The first collection operations are `smart.snapshot.v1`, +`proxmox.lxc_filesystems.v1`, and the bounded fixed-endpoint +`container.inventory.v1`; callers cannot supply a binary path, arbitrary +filesystem path, daemon endpoint, environment, or command arguments. The +`agent_update.activate.v1` and `agent_update.rollback.v1` families accept only +fixed-root, regular, owned, digest-bound update artifacts and produce durable +activation identity around an atomic swap. Their collector staging, +restart/health, and live rollback integration remains qualification work, so +the safe collector must not claim automatic update parity yet. A health/capabilities operation reports protocol and operation availability without widening the allow-list. Provider execution inherits a bounded request context. Audit hooks receive operation, request ID, peer identity, duration, outcome, and response diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 1d11583d5..58913640d 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -29,6 +29,19 @@ with an exact local build command when the helper is absent; published builds may proxy only the exact versioned `pulse-agent-helper-linux-*` release asset and its two signature sidecars. +The action runner has a parallel Linux-only, rate-limited signed-binary +contract at `GET|HEAD /download/pulse-agent-runner?arch=linux-*`; it never +falls back to a collector or helper asset. `POST +/api/agents/action-runner/credential` is an admin and `actions:execute` +operation that accepts one canonical monitored host identity and returns a +new, separately persisted `agent:exec` credential bound to organization, +agent ID, normalized hostname, runtime role `action-runner`, and capability +`typed_actions.v1`. Monitoring credentials cannot call the issuance route or +authenticate an action-runner session, and action-runner credentials are not +collector report/config credentials. Unknown request fields, ambiguous or +conflicted host identities, and persistence failures fail before any usable +credential is returned. + The API runtime is decomposed along production domain boundaries so Go can compile and execute domain qualification packages concurrently. Shared tenant identity and scope enforcement live in `internal/api/apicontext/` and @@ -311,6 +324,7 @@ the current command-enabled boolean. 81. `internal/api/agent_exec_token_binding.go` 81a. `internal/api/agentbinding/policy.go` 81b. `internal/api/agenttokens/install.go` + 81c. `internal/api/action_runner_credentials.go` 72a. `cmd/pulse-mcp/main.go` 72b. `cmd/pulse-mcp/README.md` 72c. `cmd/agent-probe/main.go` diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index e82c24383..0a30cd407 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -122,6 +122,44 @@ account, while mutable state is confined to the collector-owned state directory. Ordinary updates preserve an installed profile and may not migrate a legacy root/full-trust service implicitly; safe-profile migration, health verification, and rollback are explicit transactions. +The typed-helper profile keeps automatic updates behind a fixed filesystem +transaction: `/var/lib/pulse-agent/update-quarantine` is collector-owned and +read-only to the helper sandbox, `/var/lib/pulse-agent-helper` is root-only +activation state/staging, and the helper has write access to `/usr/local/bin` +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. + +The same supported Linux systemd profile can install `pulse-agent-runner` only +through the separate `--enable-action-runner` choice, a private token file, +and the already selected typed helper profile. The runner binary, unit, +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. + +Safe-profile migration is never an ordinary update side effect. +`--safe-profile-inspect` is read-only and reports the current authority, +platform support, provider differences, and unchanged action-runner state; +`--safe-profile-apply` snapshots the collector/helper files and identity before +installing the typed-helper monitoring-only profile; and +`--safe-profile-rollback` restores that committed snapshot without broadening +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. + +Release builds and archives carry both helper and runner binaries for the five +Linux targets (`amd64`, `arm64`, `armv7`, `armv6`, and `386`) with checksum, +Ed25519, and SSH signature sidecars. Exact archive/container-context validation +must prove those assets rather than inferring them from collector packaging. +This packaging proof does not establish live platform qualification: helper +update staging/activation/restart/rollback on a real systemd host, real +Docker/Podman and Proxmox action execution, systemd migration rehearsal, and +appliance support remain residual qualification work and must not be described +as complete. Published exact-version install and rollback guidance must preserve the server and Unified Agent installer boundary. Supported systemd and Proxmox LXC diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 85620b329..ab15ce31c 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -30,6 +30,12 @@ It must aggregate to one alert and at most one mobile notification per agent outage, retain only bounded target identifiers needed for diagnosis, and never start a goroutine, timer, or notification lifecycle per target. +The public helper and action-runner download routes reuse the bounded release +asset cache/proxy path in `internal/api/router.go`. They may add no background +poller, per-agent goroutine, inventory scan, or action-session lookup to normal +request routing; local assets are streamed once and published fallbacks retain +the existing bounded HTTP client and artifact-size checks. + ## Canonical Files 1. `pkg/metrics/store.go` diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index b848cda4f..912675955 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -1294,6 +1294,7 @@ "lane": "L16", "contract": "docs/release-control/v6/internal/subsystems/agent-lifecycle.md", "owned_prefixes": [ + "internal/actionrunner/", "internal/agentexec/", "internal/agenthelper/", "internal/agenttarget/", @@ -1304,6 +1305,7 @@ "owned_files": [ ".github/workflows/unified-agent-native.yml", "cmd/pulse-agent-helper/main.go", + "cmd/pulse-agent-runner/main.go", "cmd/pulse-agent/main.go", "cmd/pulse-agent/service_windows.go", "frontend-modern/src/api/agentProfiles.ts", @@ -1367,6 +1369,7 @@ "internal/api/configapi/setup_script_render.go", "internal/api/unified_agent.go", "internal/config/host_continuity.go", + "internal/dockeragent/action_runtime.go", "internal/dockeragent/agent.go", "internal/dockeragent/container_update.go", "internal/dockeragent/container_update_typed.go", @@ -1392,7 +1395,27 @@ "require_explicit_path_policy_coverage": true, "path_policies": [ { - "id": "agent-privilege-helper-runtime", + "id": "action-runner-runtime", + "label": "separate typed action runner and durable receipt proof", + "match_prefixes": [ + "internal/actionrunner/" + ], + "match_files": [ + "cmd/pulse-agent-runner/main.go", + "internal/dockeragent/action_runtime.go" + ], + "allow_same_subsystem_tests": true, + "test_prefixes": [ + "internal/actionrunner/" + ], + "exact_files": [ + "cmd/pulse-agent-runner/main_test.go", + "internal/agentexec/server_websocket_test.go", + "internal/hostagent/action_runner_client_test.go" + ] + }, + { + "id": "agent-privilege-helper-runtime", "label": "typed no-network agent privilege helper proof", "match_prefixes": [ "internal/agenthelper/" @@ -1404,9 +1427,11 @@ "test_prefixes": [ "internal/agenthelper/" ], - "exact_files": [ - "cmd/pulse-agent-helper/main_test.go" - ] + "exact_files": [ + "cmd/pulse-agent-helper/main_test.go", + "internal/agenthelper/container_inventory_test.go", + "internal/agenthelper/update_activation_test.go" + ] }, { "id": "patrol-autonomy-colima-real-lab", @@ -1626,10 +1651,11 @@ ], "allow_same_subsystem_tests": false, "test_prefixes": [], - "exact_files": [ - "scripts/installtests/agent_state_dir_lifecycle_test.go", - "scripts/installtests/install_sh_test.go" - ] + "exact_files": [ + "scripts/installtests/agent_state_dir_lifecycle_test.go", + "scripts/installtests/install_sh_test.go", + "scripts/installtests/safe_profile_migration_test.go" + ] }, { "id": "windows-agent-installer-runtime", @@ -3073,9 +3099,10 @@ "test_prefixes": [ "frontend-modern/src/api/__tests__/" ], - "exact_files": [ - "frontend-modern/src/types/api.ts", - "internal/api/ai_handlers_more_test.go", + "exact_files": [ + "frontend-modern/src/types/api.ts", + "internal/api/action_runner_credentials_test.go", + "internal/api/ai_handlers_more_test.go", "internal/api/ai_handlers_patrol_actions_additional_test.go", "internal/api/alerting/external_probe_notifications_test.go", "internal/api/audit_handlers_test.go", @@ -4610,8 +4637,8 @@ "scripts/release_control/release_candidate_manifest_test.py" ] }, - { - "id": "release-build-metadata-runtime", + { + "id": "release-build-metadata-runtime", "label": "release build metadata proof", "match_prefixes": [], "match_files": [ @@ -4631,11 +4658,12 @@ ], "allow_same_subsystem_tests": false, "test_prefixes": [], - "exact_files": [ - "pulse-enterprise:scripts/validate-pro-release-line_test.sh", - "scripts/installtests/backfill_release_assets_test.go", - "scripts/installtests/build_release_assets_test.go", - "scripts/installtests/release_ldflags_test.go" + "exact_files": [ + "pulse-enterprise:scripts/validate-pro-release-line_test.sh", + "scripts/installtests/backfill_release_assets_test.go", + "scripts/installtests/build_release_assets_test.go", + "scripts/installtests/release_ldflags_test.go", + "scripts/installtests/safe_profile_migration_test.go" ] }, { @@ -4849,8 +4877,8 @@ "tests/integration/tests/16-dev-runtime-recovery.spec.ts" ] }, - { - "id": "shell-installer-runtime", + { + "id": "shell-installer-runtime", "label": "shell installer runtime proof", "match_prefixes": [], "match_files": [ @@ -4858,10 +4886,11 @@ ], "allow_same_subsystem_tests": false, "test_prefixes": [], - "exact_files": [ - "scripts/installtests/agent_state_dir_lifecycle_test.go", - "scripts/installtests/install_sh_test.go" - ] + "exact_files": [ + "scripts/installtests/agent_state_dir_lifecycle_test.go", + "scripts/installtests/install_sh_test.go", + "scripts/installtests/safe_profile_migration_test.go" + ] } ], "match_files": null diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 5978bf1e3..573d9bdbc 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -44,6 +44,27 @@ operations rather than command, path, argument, environment, or shell input. Protocol decoding is bounded and strict, request deadlines are mandatory, and audit records contain metadata only. SMART and Proxmox LXC filesystem payloads remain local collector data and must never be copied into helper audit output. +Container inventory is a bounded helper-owned projection of fixed +Docker/Podman daemon endpoints, never a daemon proxy. Update staging accepts +only an artifact identity and digest from the fixed collector-owned quarantine; +the request cannot select a source, destination, target, URL, command, or +argument. The helper revalidates the quarantine owner, signature, digest, ELF +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. + +Remediation credentials belong only to the separately installed +`pulse-agent-runner`. They bind organization, canonical host identity, token +record, runtime role, and `typed_actions.v1`; monitoring credentials cannot be +upgraded in place or accepted on the action session. The runner's closed +protocol permits only the enumerated typed host, Proxmox guest, and container +operations with strict payloads, target binding, deadlines, request digests, +replay protection, cancellation, and durable terminal receipts. Generic +shell/exec, unrestricted `read_file`, deploy, and trusted-origin bypasses are +forbidden. The legacy combined command channel remains a disclosed full-trust +migration boundary only until runner enrollment and live session parity are +qualified; it is not safe-profile authority. Own Pulse's canonical privacy disclosures, outbound usage-data boundary, and the security-facing settings surfaces that expose authentication posture, diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index d58ad27d3..e3fe251fa 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -34,6 +34,11 @@ preserves collector identity and installation files only; it must not rewrite storage/recovery evidence or reinterpret a restored legacy collector profile as recovery success. Typed `host.storage_cleanup` remains governed remediation, not storage-recovery authority inferred from the credential itself. +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, +recovery point, backup retention record, or authorization to read or mutate +customer storage/recovery data. First-run authentication always writes the canonical `.env` persistence artifact before runtime state changes. Root systemd installation may also diff --git a/internal/actionrunner/runner.go b/internal/actionrunner/runner.go new file mode 100644 index 000000000..705287b97 --- /dev/null +++ b/internal/actionrunner/runner.go @@ -0,0 +1,176 @@ +package actionrunner + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/operationreceipt" +) + +type Handler interface { + ValidatePayload(json.RawMessage) error + Execute(context.Context, Target, json.RawMessage) (Result, error) +} + +type Config struct { + ReceiptPath string + Handlers map[string]map[int]Handler +} + +type Runner struct { + store *operationreceipt.Store + handlers map[string]map[int]Handler + mu sync.Mutex + active map[string]activeAction + now func() time.Time +} + +type activeAction struct { + identity operationreceipt.Identity + cancel context.CancelFunc +} + +func Open(config Config) (*Runner, error) { + if strings.TrimSpace(config.ReceiptPath) == "" { + return nil, fmt.Errorf("action runner receipt path is required") + } + handlers := make(map[string]map[int]Handler, len(config.Handlers)) + for operation, versions := range config.Handlers { + operation = strings.ToLower(strings.TrimSpace(operation)) + copyVersions := make(map[int]Handler, len(versions)) + for version, handler := range versions { + if !AllowedOperation(operation, version) || handler == nil { + return nil, fmt.Errorf("%w: %s v%d", ErrUnsupported, operation, version) + } + copyVersions[version] = handler + } + handlers[operation] = copyVersions + } + store, err := operationreceipt.Open(config.ReceiptPath, operationreceipt.Config{Validators: map[string]map[int]operationreceipt.TerminalValidator{ReceiptKind: {ReceiptVersion: ValidateTerminal}}}) + if err != nil { + return nil, err + } + return &Runner{store: store, handlers: handlers, active: make(map[string]activeAction), now: time.Now}, nil +} + +func (r *Runner) Close() error { + if r == nil { + return nil + } + r.mu.Lock() + for _, action := range r.active { + action.cancel() + } + r.active = make(map[string]activeAction) + r.mu.Unlock() + return r.store.Close() +} + +func (r *Runner) Execute(ctx context.Context, session Session, request Request) (operationreceipt.Record, error) { + if r == nil || r.store == nil { + return operationreceipt.Record{}, fmt.Errorf("action runner is not initialized") + } + now := r.now().UTC() + if err := ValidateRequest(&request, now); err != nil { + return operationreceipt.Record{}, err + } + if err := authorize(session, request); err != nil { + return operationreceipt.Record{}, err + } + handler := r.handlers[request.Operation][request.OperationVersion] + if handler == nil { + return operationreceipt.Record{}, fmt.Errorf("%w: %s v%d", ErrUnsupported, request.Operation, request.OperationVersion) + } + if err := handler.ValidatePayload(request.Payload); err != nil { + return operationreceipt.Record{}, fmt.Errorf("invalid typed action payload: %w", err) + } + identity := ReceiptIdentity(request, session.TokenID) + record, admitted, err := r.store.Admit(identity) + if err != nil { + return operationreceipt.Record{}, err + } + if !admitted { + if record.State == operationreceipt.StateTerminal { + return record, nil + } + return operationreceipt.Record{}, ErrReplayInProgress + } + if _, err := r.store.MarkStarted(identity); err != nil { + return operationreceipt.Record{}, err + } + actionCtx, cancel := context.WithDeadline(ctx, request.Deadline) + r.mu.Lock() + r.active[request.AttemptID] = activeAction{identity: identity, cancel: cancel} + r.mu.Unlock() + defer func() { + cancel() + r.mu.Lock() + delete(r.active, request.AttemptID) + r.mu.Unlock() + }() + + result, executeErr := handler.Execute(actionCtx, request.Target, append(json.RawMessage(nil), request.Payload...)) + terminal := TerminalResult{ProtocolVersion: ProtocolVersion, AttemptID: request.AttemptID, ActionID: request.ActionID, Operation: request.Operation, OperationVersion: request.OperationVersion, RequestDigest: request.RequestDigest, Target: request.Target, Status: result.Status, ReasonCode: strings.TrimSpace(result.ReasonCode), Output: result.Output} + if executeErr != nil { + terminal.Output = nil + switch { + case errors.Is(actionCtx.Err(), context.DeadlineExceeded): + terminal.Status, terminal.ReasonCode = ResultDeadline, "deadline_exceeded" + case errors.Is(actionCtx.Err(), context.Canceled): + terminal.Status, terminal.ReasonCode = ResultCanceled, "canceled" + default: + terminal.Status, terminal.ReasonCode = ResultFailed, "execution_failed" + } + } + encoded, err := json.Marshal(terminal) + if err != nil { + return operationreceipt.Record{}, err + } + if err := ValidateTerminal(identity, encoded); err != nil { + return operationreceipt.Record{}, err + } + completed, err := r.store.Complete(identity, operationreceipt.TerminalEnvelope{Kind: ReceiptKind, Version: ReceiptVersion, Payload: encoded}) + if err != nil { + return operationreceipt.Record{}, err + } + return completed, executeErr +} + +func (r *Runner) Cancel(session Session, identity operationreceipt.Identity) error { + if strings.TrimSpace(session.TokenID) == "" || identity.AgentID != strings.TrimSpace(session.TokenID) || !session.Capabilities[ActionCapability] { + return ErrUnauthorized + } + r.mu.Lock() + defer r.mu.Unlock() + action, ok := r.active[identity.AttemptID] + if !ok { + return ErrNotActive + } + if action.identity != identity { + return operationreceipt.ErrBindingConflict + } + action.cancel() + return nil +} + +func (r *Runner) Query(session Session, identity operationreceipt.Identity) (operationreceipt.QueryResult, error) { + if strings.TrimSpace(session.TokenID) == "" || identity.AgentID != strings.TrimSpace(session.TokenID) || !session.Capabilities[ActionCapability] { + return operationreceipt.QueryResult{}, ErrUnauthorized + } + return r.store.Query(identity) +} + +func authorize(session Session, request Request) error { + if !boundedID.MatchString(strings.TrimSpace(session.OrganizationID)) || !boundedID.MatchString(strings.TrimSpace(session.HostID)) || !boundedID.MatchString(strings.TrimSpace(session.TokenID)) || !session.Capabilities[ActionCapability] { + return ErrUnauthorized + } + if session.OrganizationID != request.OrganizationID || session.HostID != request.HostID { + return ErrUnauthorized + } + return nil +} diff --git a/internal/actionrunner/runner_test.go b/internal/actionrunner/runner_test.go new file mode 100644 index 000000000..7d0fc026d --- /dev/null +++ b/internal/actionrunner/runner_test.go @@ -0,0 +1,211 @@ +package actionrunner + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/operationreceipt" +) + +type testPayload struct { + Value string `json:"value"` +} + +type testHandler struct { + started chan struct{} +} + +func (h testHandler) ValidatePayload(data json.RawMessage) error { + var payload testPayload + return decodeStrict(data, &payload) +} + +func (h testHandler) Execute(ctx context.Context, _ Target, data json.RawMessage) (Result, error) { + if h.started != nil { + close(h.started) + <-ctx.Done() + return Result{}, ctx.Err() + } + return Result{Status: ResultSucceeded, Output: append(json.RawMessage(nil), data...)}, nil +} + +func openTestRunner(t *testing.T, handler Handler) *Runner { + t.Helper() + runner, err := Open(Config{ReceiptPath: filepath.Join(t.TempDir(), "receipts.db"), Handlers: map[string]map[int]Handler{"host.update": {1: handler}}}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = runner.Close() }) + return runner +} + +func testSession() Session { + return Session{OrganizationID: "org-1", HostID: "host-1", TokenID: "runner-token-1", Capabilities: map[string]bool{ActionCapability: true}} +} + +func testRequest(t *testing.T) Request { + t.Helper() + request := Request{ProtocolVersion: ProtocolVersion, OrganizationID: "org-1", HostID: "host-1", AttemptID: "attempt-1", ActionID: "action-1", Operation: "host.update", OperationVersion: 1, Target: Target{Kind: "host", ID: "host-1"}, Deadline: time.Now().UTC().Add(time.Minute), Payload: json.RawMessage(`{"value":"bounded"}`)} + digest, err := RequestDigest(request) + if err != nil { + t.Fatal(err) + } + request.RequestDigest = digest + return request +} + +func TestExecutePersistsAndReplaysBoundTerminalReceipt(t *testing.T) { + runner := openTestRunner(t, testHandler{}) + session := testSession() + request := testRequest(t) + record, err := runner.Execute(context.Background(), session, request) + if err != nil { + t.Fatal(err) + } + if record.State != operationreceipt.StateTerminal || record.Identity != ReceiptIdentity(request, session.TokenID) { + t.Fatalf("unexpected receipt: %+v", record) + } + var result TerminalResult + if err := json.Unmarshal(record.Result, &result); err != nil { + t.Fatal(err) + } + if result.Status != ResultSucceeded || result.Target != request.Target { + t.Fatalf("unexpected result: %+v", result) + } + replayed, err := runner.Execute(context.Background(), session, request) + if err != nil || replayed.TerminalAt != record.TerminalAt { + t.Fatalf("replay = %+v, %v", replayed, err) + } + query, err := runner.Query(session, record.Identity) + if err != nil || query.Status != operationreceipt.QueryFoundTerminal { + t.Fatalf("query = %+v, %v", query, err) + } +} + +func TestExecuteRejectsWrongSessionAndUnknownPayloadFields(t *testing.T) { + runner := openTestRunner(t, testHandler{}) + request := testRequest(t) + wrong := testSession() + wrong.HostID = "host-2" + if _, err := runner.Execute(context.Background(), wrong, request); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("wrong session error = %v", err) + } + request.Payload = json.RawMessage(`{"value":"ok","command":"id"}`) + request.RequestDigest, _ = RequestDigest(request) + if _, err := runner.Execute(context.Background(), testSession(), request); err == nil || !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("unknown payload field error = %v", err) + } +} + +func TestDecodeRequestRejectsUnknownVersionShellReadFileAndTrailingJSON(t *testing.T) { + now := time.Now().UTC() + base := testRequest(t) + base.Deadline = now.Add(time.Minute) + base.RequestDigest, _ = RequestDigest(base) + encoded, _ := json.Marshal(base) + var raw map[string]any + _ = json.Unmarshal(encoded, &raw) + raw["unexpected"] = true + unknown, _ := json.Marshal(raw) + if _, err := DecodeRequest(unknown, now); err == nil || !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("unknown-field error = %v", err) + } + if _, err := DecodeRequest(append(encoded, []byte(` {}`)...), now); err == nil || !strings.Contains(err.Error(), "trailing") { + t.Fatalf("trailing error = %v", err) + } + for _, operation := range []string{"shell", "exec", "execute_command", "read_file"} { + request := base + request.Operation = operation + request.RequestDigest, _ = RequestDigest(request) + data, _ := json.Marshal(request) + if _, err := DecodeRequest(data, now); !errors.Is(err, ErrUnsupported) { + t.Errorf("operation %q error = %v", operation, err) + } + } + badVersion := base + badVersion.OperationVersion = 2 + badVersion.RequestDigest, _ = RequestDigest(badVersion) + data, _ := json.Marshal(badVersion) + if _, err := DecodeRequest(data, now); !errors.Is(err, ErrUnsupported) { + t.Fatalf("version error = %v", err) + } +} + +func TestValidateRequestEnforcesTargetDeadlineAndOutputBounds(t *testing.T) { + now := time.Now().UTC() + request := testRequest(t) + request.Target.ID = "another-host" + request.RequestDigest, _ = RequestDigest(request) + if err := ValidateRequest(&request, now); err == nil || !strings.Contains(err.Error(), "bound host") { + t.Fatalf("target binding error = %v", err) + } + request = testRequest(t) + request.Deadline = now.Add(MaxOperationDeadline + time.Second) + request.RequestDigest, _ = RequestDigest(request) + if err := ValidateRequest(&request, now); err == nil || !strings.Contains(err.Error(), "deadline") { + t.Fatalf("deadline error = %v", err) + } + request = testRequest(t) + terminal := TerminalResult{ProtocolVersion: ProtocolVersion, AttemptID: request.AttemptID, ActionID: request.ActionID, Operation: request.Operation, OperationVersion: request.OperationVersion, RequestDigest: request.RequestDigest, Target: request.Target, Status: ResultSucceeded, Output: json.RawMessage(`"` + strings.Repeat("x", MaxResultBytes) + `"`)} + data, _ := json.Marshal(terminal) + if err := ValidateTerminal(ReceiptIdentity(request, testSession().TokenID), data); err == nil || !strings.Contains(err.Error(), "oversized") { + t.Fatalf("output bound error = %v", err) + } +} + +func TestCancelRequiresExactCredentialAndProducesDurableCanceledReceipt(t *testing.T) { + started := make(chan struct{}) + runner := openTestRunner(t, testHandler{started: started}) + session := testSession() + request := testRequest(t) + type outcome struct { + record operationreceipt.Record + err error + } + done := make(chan outcome, 1) + go func() { + record, err := runner.Execute(context.Background(), session, request) + done <- outcome{record, err} + }() + <-started + identity := ReceiptIdentity(request, session.TokenID) + wrongIdentity := identity + wrongIdentity.ActionID = "other-action" + if err := runner.Cancel(session, wrongIdentity); !errors.Is(err, operationreceipt.ErrBindingConflict) { + t.Fatalf("wrong cancellation binding error = %v", err) + } + if err := runner.Cancel(session, identity); err != nil { + t.Fatal(err) + } + result := <-done + if !errors.Is(result.err, context.Canceled) || result.record.State != operationreceipt.StateTerminal { + t.Fatalf("canceled outcome = %+v, %v", result.record, result.err) + } + var terminal TerminalResult + if err := json.Unmarshal(result.record.Result, &terminal); err != nil { + t.Fatal(err) + } + if terminal.Status != ResultCanceled || terminal.ReasonCode != "canceled" { + t.Fatalf("terminal = %+v", terminal) + } +} + +func TestRegistrationIsExplicitActionRunnerRole(t *testing.T) { + registration, err := testSession().Registration() + if err != nil { + t.Fatal(err) + } + if registration.RuntimeRole != "action-runner" { + t.Fatalf("runtime role = %q", registration.RuntimeRole) + } + collector := testSession() + collector.Capabilities = map[string]bool{} + if _, err := collector.Registration(); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("collector registration error = %v", err) + } +} diff --git a/internal/actionrunner/transport.go b/internal/actionrunner/transport.go new file mode 100644 index 000000000..1d6bf87f7 --- /dev/null +++ b/internal/actionrunner/transport.go @@ -0,0 +1,33 @@ +package actionrunner + +import ( + "context" + + "github.com/rcourtman/pulse-go-rewrite/internal/hostagent" +) + +// TransportConfig carries only the separate runner's connection credential, +// state, and TLS inputs. There is deliberately no monitoring/report config. +type TransportConfig = hostagent.ActionRunnerClientConfig + +// Client is the action-runner-owned facade over the existing typed action +// codecs and executors. The hostagent implementation remains an internal +// compatibility detail while the collector continues its legacy migration. +type Client struct { + inner *hostagent.CommandClient +} + +func NewClient(config TransportConfig, hostID, hostname, version string) *Client { + return &Client{inner: hostagent.NewActionRunnerClient(config, hostID, hostname, version)} +} + +func (client *Client) Run(ctx context.Context) error { + return client.inner.Run(ctx) +} + +func (client *Client) Close() error { + if client == nil || client.inner == nil { + return nil + } + return client.inner.Close() +} diff --git a/internal/actionrunner/types.go b/internal/actionrunner/types.go new file mode 100644 index 000000000..9ce07a9d1 --- /dev/null +++ b/internal/actionrunner/types.go @@ -0,0 +1,273 @@ +package actionrunner + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "regexp" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/operationreceipt" +) + +const ( + ProtocolVersion = 1 + ReceiptKind = "pulse.action_runner_result" + ReceiptVersion = 1 + MaxRequestBytes = 64 << 10 + MaxResultBytes = 32 << 10 + MaxOperationDeadline = 30 * time.Minute + ActionCapability = "typed_actions.v1" + RuntimeRole = "action-runner" +) + +var boundedID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`) + +var ( + ErrUnauthorized = errors.New("action runner session is not authorized") + ErrUnsupported = errors.New("action runner operation is unsupported") + ErrReplayInProgress = errors.New("action runner operation is already in progress") + ErrNotActive = errors.New("action runner operation is not active") +) + +type Target struct { + Kind string `json:"kind"` + ID string `json:"id"` +} + +type Request struct { + ProtocolVersion int `json:"protocol_version"` + OrganizationID string `json:"organization_id"` + HostID string `json:"host_id"` + AttemptID string `json:"attempt_id"` + ActionID string `json:"action_id"` + Operation string `json:"operation"` + OperationVersion int `json:"operation_version"` + RequestDigest string `json:"request_digest"` + Target Target `json:"target"` + Deadline time.Time `json:"deadline"` + Payload json.RawMessage `json:"payload"` +} + +type Session struct { + OrganizationID string + HostID string + TokenID string + Capabilities map[string]bool +} + +// Registration is the transport-neutral identity emitted when this runtime +// opens an action session. Collector registrations intentionally do not use +// this type, so the server can fail closed on runtime role. +type Registration struct { + RuntimeRole string `json:"runtime_role"` + OrganizationID string `json:"organization_id"` + HostID string `json:"host_id"` +} + +func (session Session) Registration() (Registration, error) { + organizationID := strings.TrimSpace(session.OrganizationID) + hostID := strings.TrimSpace(session.HostID) + if !boundedID.MatchString(organizationID) || !boundedID.MatchString(hostID) || !boundedID.MatchString(strings.TrimSpace(session.TokenID)) || !session.Capabilities[ActionCapability] { + return Registration{}, ErrUnauthorized + } + return Registration{RuntimeRole: RuntimeRole, OrganizationID: organizationID, HostID: hostID}, nil +} + +type ResultStatus string + +const ( + ResultSucceeded ResultStatus = "succeeded" + ResultRefused ResultStatus = "refused" + ResultFailed ResultStatus = "failed" + ResultCanceled ResultStatus = "canceled" + ResultDeadline ResultStatus = "deadline_exceeded" +) + +type Result struct { + Status ResultStatus `json:"status"` + ReasonCode string `json:"reason_code,omitempty"` + Output json.RawMessage `json:"output,omitempty"` +} + +type TerminalResult struct { + ProtocolVersion int `json:"protocol_version"` + AttemptID string `json:"attempt_id"` + ActionID string `json:"action_id"` + Operation string `json:"operation"` + OperationVersion int `json:"operation_version"` + RequestDigest string `json:"request_digest"` + Target Target `json:"target"` + Status ResultStatus `json:"status"` + ReasonCode string `json:"reason_code,omitempty"` + Output json.RawMessage `json:"output,omitempty"` +} + +func DecodeRequest(data []byte, now time.Time) (Request, error) { + if len(data) > MaxRequestBytes { + return Request{}, fmt.Errorf("action request exceeds %d bytes", MaxRequestBytes) + } + var request Request + if err := decodeStrict(data, &request); err != nil { + return Request{}, err + } + if err := ValidateRequest(&request, now); err != nil { + return Request{}, err + } + return request, nil +} + +func ValidateRequest(request *Request, now time.Time) error { + if request == nil { + return fmt.Errorf("action request is required") + } + request.OrganizationID = strings.TrimSpace(request.OrganizationID) + request.HostID = strings.TrimSpace(request.HostID) + request.AttemptID = strings.TrimSpace(request.AttemptID) + request.ActionID = strings.TrimSpace(request.ActionID) + request.Operation = strings.ToLower(strings.TrimSpace(request.Operation)) + request.RequestDigest = strings.ToLower(strings.TrimSpace(request.RequestDigest)) + request.Target.Kind = strings.ToLower(strings.TrimSpace(request.Target.Kind)) + request.Target.ID = strings.TrimSpace(request.Target.ID) + request.Deadline = request.Deadline.UTC() + if request.ProtocolVersion != ProtocolVersion { + return fmt.Errorf("unsupported action protocol version %d", request.ProtocolVersion) + } + for label, value := range map[string]string{"organization_id": request.OrganizationID, "host_id": request.HostID, "attempt_id": request.AttemptID, "action_id": request.ActionID, "target.kind": request.Target.Kind, "target.id": request.Target.ID} { + if !boundedID.MatchString(value) { + return fmt.Errorf("invalid %s", label) + } + } + if !AllowedOperation(request.Operation, request.OperationVersion) { + return fmt.Errorf("%w: %s v%d", ErrUnsupported, request.Operation, request.OperationVersion) + } + switch { + case strings.HasPrefix(request.Operation, "host."): + if request.Target.Kind != "host" || request.Target.ID != request.HostID { + return fmt.Errorf("host action target does not match the bound host") + } + case strings.HasPrefix(request.Operation, "proxmox."): + if request.Target.Kind != "proxmox-guest" { + return fmt.Errorf("Proxmox action requires a Proxmox guest target") + } + case strings.HasPrefix(request.Operation, "container."): + if request.Target.Kind != "container" { + return fmt.Errorf("container action requires a container target") + } + } + if len(request.Payload) == 0 || len(request.Payload) > MaxRequestBytes || !json.Valid(request.Payload) { + return fmt.Errorf("action payload is empty, invalid, or oversized") + } + if request.Deadline.IsZero() || !request.Deadline.After(now.UTC()) || request.Deadline.After(now.UTC().Add(MaxOperationDeadline)) { + return fmt.Errorf("action deadline must be in the future and at most %s", MaxOperationDeadline) + } + digest, err := RequestDigest(*request) + if err != nil { + return err + } + if request.RequestDigest != digest { + return fmt.Errorf("action request digest mismatch") + } + return nil +} + +func RequestDigest(request Request) (string, error) { + canonical := struct { + OrganizationID string `json:"organization_id"` + HostID string `json:"host_id"` + ActionID string `json:"action_id"` + Operation string `json:"operation"` + OperationVersion int `json:"operation_version"` + Target Target `json:"target"` + Payload json.RawMessage `json:"payload"` + }{request.OrganizationID, request.HostID, request.ActionID, request.Operation, request.OperationVersion, request.Target, request.Payload} + encoded, err := json.Marshal(canonical) + if err != nil { + return "", err + } + sum := sha256.Sum256(encoded) + return "sha256:" + hex.EncodeToString(sum[:]), nil +} + +func AllowedOperation(operation string, version int) bool { + if version != 1 { + return false + } + switch operation { + case "host.update", "host.storage_cleanup", + "proxmox.guest.start", "proxmox.guest.stop", "proxmox.guest.shutdown", "proxmox.guest.reboot", + "container.start", "container.stop", "container.restart", "container.update": + return true + default: + return false + } +} + +func ReceiptIdentity(request Request, tokenID string) operationreceipt.Identity { + return operationreceipt.Identity{AttemptID: request.AttemptID, ActionID: request.ActionID, OperationKind: request.Operation, OperationVersion: request.OperationVersion, RequestDigest: request.RequestDigest, AgentID: strings.TrimSpace(tokenID)} +} + +func ValidateTerminal(identity operationreceipt.Identity, data json.RawMessage) error { + var result TerminalResult + if err := decodeStrict(data, &result); err != nil { + return err + } + if result.ProtocolVersion != ProtocolVersion || result.AttemptID != identity.AttemptID || result.ActionID != identity.ActionID || result.Operation != identity.OperationKind || result.OperationVersion != identity.OperationVersion || result.RequestDigest != identity.RequestDigest { + return operationreceipt.ErrBindingConflict + } + if !boundedID.MatchString(result.Target.Kind) || !boundedID.MatchString(result.Target.ID) { + return fmt.Errorf("invalid action result target") + } + switch result.Status { + case ResultSucceeded: + if result.ReasonCode != "" { + return fmt.Errorf("successful action cannot include a reason code") + } + case ResultRefused, ResultFailed, ResultCanceled, ResultDeadline: + if !validReasonCode(result.ReasonCode) { + return fmt.Errorf("non-success action requires a bounded reason code") + } + default: + return fmt.Errorf("unsupported action result status %q", result.Status) + } + if len(result.Output) > MaxResultBytes || (len(result.Output) > 0 && !json.Valid(result.Output)) { + return fmt.Errorf("action result output is invalid or oversized") + } + return nil +} + +func validReasonCode(value string) bool { + if value == "" || len(value) > 64 { + return false + } + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' { + continue + } + return false + } + return true +} + +func decodeStrict(data []byte, target any) error { + if len(bytes.TrimSpace(data)) == 0 { + return fmt.Errorf("action payload is empty") + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + return fmt.Errorf("action payload contains trailing JSON") + } + return fmt.Errorf("action payload contains trailing data: %w", err) + } + return nil +} diff --git a/internal/agentexec/proxmox_guest_lifecycle_codec.go b/internal/agentexec/proxmox_guest_lifecycle_codec.go new file mode 100644 index 000000000..7e019a42b --- /dev/null +++ b/internal/agentexec/proxmox_guest_lifecycle_codec.go @@ -0,0 +1,175 @@ +package agentexec + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/operationreceipt" +) + +const ( + ProxmoxGuestLifecycleOperationVersion = 1 + ProxmoxGuestLifecycleReceiptKind = "pulse.proxmox_guest_lifecycle_result" + ProxmoxGuestLifecycleReceiptVersion = 1 + maxProxmoxGuestLifecycleBytes = 64 << 10 +) + +func DecodeProxmoxGuestLifecyclePayload(data []byte) (ProxmoxGuestLifecyclePayload, error) { + var payload ProxmoxGuestLifecyclePayload + if err := decodeStrictProxmoxLifecycle(data, &payload); err != nil { + return payload, err + } + return payload, ValidateProxmoxGuestLifecyclePayload(&payload) +} + +func DecodeProxmoxGuestLifecycleResultPayload(data []byte) (ProxmoxGuestLifecycleResultPayload, error) { + var payload ProxmoxGuestLifecycleResultPayload + if err := decodeStrictProxmoxLifecycle(data, &payload); err != nil { + return payload, err + } + return payload, ValidateProxmoxGuestLifecycleResultPayload(&payload) +} + +func decodeStrictProxmoxLifecycle(data []byte, target any) error { + if len(bytes.TrimSpace(data)) == 0 || len(data) > maxProxmoxGuestLifecycleBytes { + return fmt.Errorf("proxmox guest lifecycle payload is empty or oversized") + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return fmt.Errorf("proxmox guest lifecycle payload contains trailing data") + } + return nil +} + +func BindProxmoxGuestLifecyclePayload(payload *ProxmoxGuestLifecyclePayload) error { + if payload == nil { + return fmt.Errorf("proxmox guest lifecycle payload is required") + } + payload.OperationVersion = ProxmoxGuestLifecycleOperationVersion + digest, err := proxmoxGuestLifecycleRequestDigest(*payload) + if err != nil { + return err + } + payload.RequestDigest = digest + return nil +} + +func proxmoxGuestLifecycleRequestDigest(payload ProxmoxGuestLifecyclePayload) (string, error) { + return operationreceipt.DigestCanonicalJSON(struct { + ActionID string `json:"action_id"` + Operation string `json:"operation"` + OperationVersion int `json:"operation_version"` + GuestKind string `json:"guest_kind"` + VMID int `json:"vmid"` + ExpectedStatus string `json:"expected_status"` + }{strings.TrimSpace(payload.ActionID), strings.ToLower(strings.TrimSpace(payload.Operation)), payload.OperationVersion, strings.ToLower(strings.TrimSpace(payload.GuestKind)), payload.VMID, strings.ToLower(strings.TrimSpace(payload.ExpectedStatus))}) +} + +func ValidateProxmoxGuestLifecyclePayload(payload *ProxmoxGuestLifecyclePayload) error { + if payload == nil { + return fmt.Errorf("proxmox guest lifecycle payload is required") + } + payload.RequestID = strings.TrimSpace(payload.RequestID) + payload.ActionID = strings.TrimSpace(payload.ActionID) + payload.Operation = strings.ToLower(strings.TrimSpace(payload.Operation)) + payload.GuestKind = strings.ToLower(strings.TrimSpace(payload.GuestKind)) + payload.ExpectedStatus = strings.ToLower(strings.TrimSpace(payload.ExpectedStatus)) + if payload.RequestID == "" || len(payload.RequestID) > maxRequestIDLength || payload.ActionID == "" || len(payload.ActionID) > maxRequestIDLength { + return fmt.Errorf("invalid proxmox lifecycle request identity") + } + if payload.OperationVersion != ProxmoxGuestLifecycleOperationVersion || !isProxmoxGuestOperation(payload.Operation) { + return fmt.Errorf("unsupported proxmox guest lifecycle operation or version") + } + if payload.GuestKind != "vm" && payload.GuestKind != "ct" { + return fmt.Errorf("unsupported proxmox guest kind") + } + if payload.VMID < 1 || payload.VMID > 999999999 { + return fmt.Errorf("proxmox vmid must be a bounded positive integer") + } + if payload.ExpectedStatus != "running" && payload.ExpectedStatus != "stopped" { + return fmt.Errorf("expected proxmox guest status must be running or stopped") + } + want, err := proxmoxGuestLifecycleRequestDigest(*payload) + if err != nil || payload.RequestDigest != want { + return fmt.Errorf("proxmox guest lifecycle request digest mismatch") + } + if payload.Timeout < 0 || payload.Timeout > 300 { + return fmt.Errorf("proxmox guest lifecycle timeout must be between 0 and 300 seconds") + } + if payload.Timeout == 0 { + payload.Timeout = 180 + } + return nil +} + +func ValidateProxmoxGuestLifecycleResultPayload(result *ProxmoxGuestLifecycleResultPayload) error { + if result == nil { + return fmt.Errorf("proxmox guest lifecycle result is required") + } + result.RequestID = strings.TrimSpace(result.RequestID) + result.ActionID = strings.TrimSpace(result.ActionID) + result.Operation = strings.ToLower(strings.TrimSpace(result.Operation)) + result.GuestKind = strings.ToLower(strings.TrimSpace(result.GuestKind)) + result.RequestDigest = strings.TrimSpace(result.RequestDigest) + result.ExecutionPhase = strings.TrimSpace(result.ExecutionPhase) + result.ReasonCode = strings.TrimSpace(result.ReasonCode) + result.Error = strings.TrimSpace(result.Error) + if result.RequestID == "" || result.ActionID == "" || !isProxmoxGuestOperation(result.Operation) || result.OperationVersion != ProxmoxGuestLifecycleOperationVersion || (result.GuestKind != "vm" && result.GuestKind != "ct") || result.VMID < 1 || result.VMID > 999999999 || !hostUpdateInventoryHashPattern.MatchString(result.RequestDigest) { + return fmt.Errorf("invalid proxmox guest lifecycle result binding") + } + switch result.ExecutionPhase { + case ProxmoxGuestPhasePreflight, ProxmoxGuestPhaseMutate, ProxmoxGuestPhaseVerify, ProxmoxGuestPhaseComplete: + default: + return fmt.Errorf("unsupported proxmox guest lifecycle execution phase") + } + if len(result.Error) > 1024 || (result.ReasonCode != "" && !IsActionRefusalReasonCode(result.ReasonCode)) || (result.ReasonCode != "" && result.MutationStarted) || (result.MutationCompleted && !result.MutationStarted) { + return fmt.Errorf("invalid proxmox guest lifecycle result state") + } + for _, snapshot := range []*ProxmoxGuestLifecycleSnapshot{&result.Before, &result.After} { + snapshot.Status = strings.ToLower(strings.TrimSpace(snapshot.Status)) + if snapshot.Status != "" && snapshot.Status != "running" && snapshot.Status != "stopped" { + return fmt.Errorf("invalid proxmox guest status") + } + if !snapshot.ObservedAt.IsZero() && snapshot.ObservedAt.Location() != time.UTC { + return fmt.Errorf("proxmox observation timestamp must be UTC") + } + } + if result.ReadbackRan && result.After.ObservedAt.IsZero() { + return fmt.Errorf("proxmox readback requires an observation") + } + return nil +} + +func ValidateProxmoxGuestLifecycleResultForRequest(req ProxmoxGuestLifecyclePayload, result ProxmoxGuestLifecycleResultPayload) error { + if err := ValidateProxmoxGuestLifecyclePayload(&req); err != nil { + return err + } + if err := ValidateProxmoxGuestLifecycleResultPayload(&result); err != nil { + return err + } + if result.RequestID != req.RequestID || result.ActionID != req.ActionID || result.Operation != req.Operation || result.OperationVersion != req.OperationVersion || result.RequestDigest != req.RequestDigest || result.GuestKind != req.GuestKind || result.VMID != req.VMID { + return fmt.Errorf("proxmox guest lifecycle result identity mismatch") + } + return nil +} + +func ProxmoxGuestLifecycleOperationIdentity(agentID string, payload ProxmoxGuestLifecyclePayload) operationreceipt.Identity { + return operationreceipt.Identity{AttemptID: payload.RequestID, ActionID: payload.ActionID, OperationKind: payload.Operation, OperationVersion: payload.OperationVersion, RequestDigest: payload.RequestDigest, AgentID: strings.TrimSpace(agentID)} +} + +func isProxmoxGuestOperation(operation string) bool { + switch strings.ToLower(strings.TrimSpace(operation)) { + case "start", "stop", "shutdown", "reboot": + return true + default: + return false + } +} diff --git a/internal/agentexec/proxmox_guest_lifecycle_codec_test.go b/internal/agentexec/proxmox_guest_lifecycle_codec_test.go new file mode 100644 index 000000000..50420cc99 --- /dev/null +++ b/internal/agentexec/proxmox_guest_lifecycle_codec_test.go @@ -0,0 +1,62 @@ +package agentexec + +import ( + "encoding/json" + "strings" + "testing" +) + +func boundProxmoxGuestLifecycle(t *testing.T) ProxmoxGuestLifecyclePayload { + t.Helper() + payload := ProxmoxGuestLifecyclePayload{RequestID: "attempt-1", ActionID: "action-1", Operation: "reboot", GuestKind: "vm", VMID: 101, ExpectedStatus: "running", Timeout: 30} + if err := BindProxmoxGuestLifecyclePayload(&payload); err != nil { + t.Fatal(err) + } + return payload +} + +func TestProxmoxGuestLifecycleCodecRejectsArgumentInjectionUnknownFieldsAndVersions(t *testing.T) { + base := boundProxmoxGuestLifecycle(t) + encoded, _ := json.Marshal(base) + var object map[string]any + _ = json.Unmarshal(encoded, &object) + object["args"] = []string{"--", "sh", "-c", "id"} + hostile, _ := json.Marshal(object) + if _, err := DecodeProxmoxGuestLifecyclePayload(hostile); err == nil || !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("argument injection error = %v", err) + } + for name, mutate := range map[string]func(*ProxmoxGuestLifecyclePayload){ + "kind": func(p *ProxmoxGuestLifecyclePayload) { p.GuestKind = "vm;id" }, + "operation": func(p *ProxmoxGuestLifecyclePayload) { p.Operation = "start --skiplock" }, + "vmid": func(p *ProxmoxGuestLifecyclePayload) { p.VMID = -1 }, + "version": func(p *ProxmoxGuestLifecyclePayload) { p.OperationVersion = 2 }, + } { + t.Run(name, func(t *testing.T) { + payload := base + mutate(&payload) + if err := ValidateProxmoxGuestLifecyclePayload(&payload); err == nil { + t.Fatal("hostile payload was accepted") + } + }) + } + if _, err := DecodeProxmoxGuestLifecyclePayload(append(encoded, []byte(` {}`)...)); err == nil || !strings.Contains(err.Error(), "trailing") { + t.Fatalf("trailing JSON error = %v", err) + } +} + +func TestProxmoxGuestLifecycleResultIsRequestAndReceiptBound(t *testing.T) { + req := boundProxmoxGuestLifecycle(t) + result := ProxmoxGuestLifecycleResultPayload{ + RequestID: req.RequestID, ActionID: req.ActionID, Operation: req.Operation, + OperationVersion: req.OperationVersion, RequestDigest: req.RequestDigest, + GuestKind: req.GuestKind, VMID: req.VMID, ExecutionPhase: ProxmoxGuestPhaseComplete, + MutationStarted: true, MutationCompleted: true, + } + if err := ValidateProxmoxGuestLifecycleResultForRequest(req, result); err != nil { + t.Fatal(err) + } + result.VMID++ + if err := ValidateProxmoxGuestLifecycleResultForRequest(req, result); err == nil || !strings.Contains(err.Error(), "identity mismatch") { + t.Fatalf("mismatched receipt error = %v", err) + } +} diff --git a/internal/agentexec/server.go b/internal/agentexec/server.go index 87c37c4ce..3f627c734 100644 --- a/internal/agentexec/server.go +++ b/internal/agentexec/server.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "regexp" + "strconv" "strings" "sync" "time" @@ -66,6 +67,7 @@ type Server struct { pendingReqs map[string]chan CommandResultPayload // scoped request key -> response channel pendingHostStorageCleanups map[string]chan HostStorageCleanupResultPayload // scoped request key -> typed storage-cleanup response pendingHostUpdates map[string]chan HostUpdateResultPayload // scoped request key -> typed host-update response + pendingProxmoxGuestLifecycles map[string]chan ProxmoxGuestLifecycleResultPayload pendingDockerContainerLifecycles map[string]chan DockerContainerLifecycleResultPayload pendingDockerContainerUpdates map[string]chan DockerContainerUpdateResultPayload pendingDockerContainerObservations map[string]chan DockerContainerObservationResultPayload @@ -95,10 +97,12 @@ type organizationContextKey struct{} // session. The raw bearer token is deliberately not retained after // registration. type AgentAdmission struct { - OrganizationID string - TokenID string - AgentID string - Hostname string + OrganizationID string + TokenID string + AgentID string + Hostname string + RuntimeRole string + ActionCapability string } // AgentRegistrationValidator authenticates and binds a registration to one @@ -195,6 +199,7 @@ func NewServerWithAdmissionValidator(admit AgentRegistrationValidator, validateS pendingReqs: make(map[string]chan CommandResultPayload), pendingHostStorageCleanups: make(map[string]chan HostStorageCleanupResultPayload), pendingHostUpdates: make(map[string]chan HostUpdateResultPayload), + pendingProxmoxGuestLifecycles: make(map[string]chan ProxmoxGuestLifecycleResultPayload), pendingDockerContainerLifecycles: make(map[string]chan DockerContainerLifecycleResultPayload), pendingDockerContainerUpdates: make(map[string]chan DockerContainerUpdateResultPayload), pendingDockerContainerObservations: make(map[string]chan DockerContainerObservationResultPayload), @@ -371,6 +376,16 @@ func (s *Server) connectionForOrganization(organizationID, agentID string) (*age return nil, false } +func requireLegacyFullTrustConnection(ac *agentConn, operation string) error { + if ac == nil { + return fmt.Errorf("agent connection is unavailable") + } + if strings.TrimSpace(ac.admission.RuntimeRole) == RuntimeRoleActionRunner { + return fmt.Errorf("%s is not available on typed action-runner sessions", operation) + } + return nil +} + func (s *Server) connectionForContext(ctx context.Context, agentID string) (*agentConn, bool) { return s.connectionForOrganization(organizationIDFromContext(ctx), agentID) } @@ -442,6 +457,16 @@ func (s *Server) matchesPendingDockerUpdateOperationForSession(sessionKey, agent return ok && expected.identity == actual && expected.subjectID == strings.ToLower(strings.TrimSpace(result.ContainerID)) } +func (s *Server) matchesPendingProxmoxGuestOperationForSession(sessionKey, agentID string, result ProxmoxGuestLifecycleResultPayload) bool { + key := pendingRequestKey(sessionKey, result.RequestID) + s.mu.RLock() + expected, ok := s.pendingHostOperations[key] + s.mu.RUnlock() + actual := operationreceipt.Identity{AttemptID: result.RequestID, ActionID: result.ActionID, OperationKind: result.Operation, OperationVersion: result.OperationVersion, RequestDigest: result.RequestDigest, AgentID: strings.TrimSpace(agentID)} + subject := result.GuestKind + ":" + strconv.Itoa(result.VMID) + return ok && expected.identity == actual && expected.subjectID == subject +} + func (s *Server) releasePendingHostOperation(key string) { s.mu.Lock() delete(s.pendingHostOperations, key) @@ -935,6 +960,8 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) { admission.TokenID = strings.TrimSpace(admission.TokenID) admission.AgentID = strings.TrimSpace(admission.AgentID) admission.Hostname = strings.TrimSpace(admission.Hostname) + admission.RuntimeRole = strings.TrimSpace(admission.RuntimeRole) + admission.ActionCapability = strings.TrimSpace(admission.ActionCapability) if admission.AgentID == "" { admission.AgentID = reg.AgentID } @@ -945,6 +972,19 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) { !unifiedresources.HostnamesEquivalent(admission.Hostname, reg.Hostname) { admitted = false } + registrationRole := strings.TrimSpace(reg.RuntimeRole) + registrationCapability := strings.TrimSpace(reg.ActionCapability) + if admission.RuntimeRole == RuntimeRoleActionRunner { + if registrationRole != RuntimeRoleActionRunner || + admission.ActionCapability != ActionCapabilityTypedV1 || + registrationCapability != admission.ActionCapability { + admitted = false + } + } else if registrationRole == RuntimeRoleActionRunner || registrationCapability != "" { + // A legacy collector token cannot opt itself into the runner protocol by + // asserting registration fields that were not bound into its credential. + admitted = false + } if !admitted { log.Warn().Str("agent_id", reg.AgentID).Msg("Agent registration rejected: invalid token") // Actionable message instead of a bare "Invalid token": the agent logs @@ -976,6 +1016,8 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) { Version: reg.Version, Platform: reg.Platform, Tags: reg.Tags, + RuntimeRole: admission.RuntimeRole, + ActionCapability: admission.ActionCapability, ConnectedAt: time.Now(), OperationReceiptVersion: reg.OperationReceiptVersion, ActionPreflightVersion: reg.ActionPreflightVersion, @@ -1305,6 +1347,27 @@ func (s *Server) readLoop(ac *agentConn) { } } + case MsgTypeProxmoxGuestLifecycleResult: + result, decodeErr := DecodeProxmoxGuestLifecycleResultPayload(msg.Payload) + if decodeErr != nil { + log.Warn().Err(decodeErr).Str("agent_id", ac.agent.AgentID).Msg("Dropping invalid Proxmox guest lifecycle result") + continue + } + if !s.matchesPendingProxmoxGuestOperationForSession(connectionSessionKey(ac), ac.agent.AgentID, result) { + log.Warn().Str("agent_id", ac.agent.AgentID).Str("request_id", result.RequestID).Msg("Dropping uncorrelated Proxmox guest lifecycle result") + continue + } + s.mu.RLock() + ch, ok := s.pendingProxmoxGuestLifecycles[pendingRequestKey(connectionSessionKey(ac), result.RequestID)] + s.mu.RUnlock() + if ok { + select { + case ch <- result: + default: + log.Warn().Str("agent_id", ac.agent.AgentID).Str("request_id", result.RequestID).Msg("Proxmox guest lifecycle result channel full, dropping") + } + } + case MsgTypeDockerContainerLifecycleResult: result, decodeErr := DecodeDockerContainerLifecycleResultPayload(msg.Payload) if decodeErr != nil { @@ -1587,6 +1650,9 @@ func (s *Server) ExecuteCommand(ctx context.Context, agentID string, cmd Execute Msg("Execute command requested for disconnected agent") return nil, fmt.Errorf("agent %s not connected", agentID) } + if err := requireLegacyFullTrustConnection(ac, "execute_command"); err != nil { + return nil, err + } if err := s.authorizeCommandPayload(cmd); err != nil { return nil, err } @@ -2027,6 +2093,41 @@ func (s *Server) ExecuteDockerContainerLifecycle(ctx context.Context, agentID st }) } +// ExecuteProxmoxGuestLifecycle dispatches one closed Proxmox guest action to +// an action-runner session. The wire contract contains only guest kind, fixed +// lifecycle verb, numeric VMID, and request-bound before state. +func (s *Server) ExecuteProxmoxGuestLifecycle(ctx context.Context, agentID string, req ProxmoxGuestLifecyclePayload) (*ProxmoxGuestLifecycleResultPayload, error) { + if s == nil { + return nil, fmt.Errorf("agent execution server is unavailable") + } + agentID = strings.TrimSpace(agentID) + if agentID == "" { + return nil, fmt.Errorf("agent id is required") + } + if strings.TrimSpace(req.RequestID) == "" { + req.RequestID = uuid.NewString() + } + if err := BindProxmoxGuestLifecyclePayload(&req); err != nil { + return nil, err + } + if err := ValidateProxmoxGuestLifecyclePayload(&req); err != nil { + return nil, err + } + ac, ok := s.connectionForContext(ctx, agentID) + if !ok { + return nil, fmt.Errorf("agent %s not connected", agentID) + } + if ac.admission.RuntimeRole != RuntimeRoleActionRunner || ac.admission.ActionCapability != ActionCapabilityTypedV1 { + return nil, fmt.Errorf("Proxmox guest lifecycle requires a typed action-runner session") + } + identity := ProxmoxGuestLifecycleOperationIdentity(agentID, req) + return dispatchTypedDockerContainerOperation(ctx, s, agentID, req.RequestID, req.Timeout, identity, req.GuestKind+":"+strconv.Itoa(req.VMID), + MsgTypeProxmoxGuestLifecycle, req, s.pendingProxmoxGuestLifecycles, "Proxmox guest lifecycle", + func(result ProxmoxGuestLifecycleResultPayload) error { + return ValidateProxmoxGuestLifecycleResultForRequest(req, result) + }) +} + // dispatchTypedDockerContainerOperation owns the shared skeleton for closed // typed container dispatches: durable-receipt capability check, pending // operation claim, single-flight request registration, send, and the bounded @@ -2230,6 +2331,9 @@ func (s *Server) ReadFile(ctx context.Context, agentID string, req ReadFilePaylo Msg("Read file requested for disconnected agent") return nil, fmt.Errorf("agent %s not connected", agentID) } + if err := requireLegacyFullTrustConnection(ac, "read_file"); err != nil { + return nil, err + } readLog := log.With(). Str("agent_id", agentID). @@ -2510,6 +2614,9 @@ func (s *Server) sendDeployCommand(ctx context.Context, agentID string, msgType if !ok { return fmt.Errorf("agent %s not connected", agentID) } + if err := requireLegacyFullTrustConnection(ac, "deploy command"); err != nil { + return err + } requestID = strings.TrimSpace(requestID) if requestID == "" { diff --git a/internal/agentexec/server_websocket_test.go b/internal/agentexec/server_websocket_test.go index e4754f2cb..334a029e9 100644 --- a/internal/agentexec/server_websocket_test.go +++ b/internal/agentexec/server_websocket_test.go @@ -209,6 +209,88 @@ func TestHandleWebSocket_RegistrationSuccessAndDisconnectRemovesAgent(t *testing waitFor(t, 2*time.Second, func() bool { return !s.IsAgentConnected("a1") }) } +func TestActionRunnerRegistrationRequiresCredentialBoundRuntimeRoleAndCapability(t *testing.T) { + admission := AgentAdmission{ + OrganizationID: "org-a", + TokenID: "runner-token", + AgentID: "machine-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() + + register := func(role, capability string) (*websocket.Conn, RegisteredPayload) { + t.Helper() + conn, _, err := dialAgentExecWebSocket(t, ts.URL) + if err != nil { + t.Fatalf("Dial: %v", err) + } + wsWriteMessage(t, conn, mustNewMessage(t, MsgTypeAgentRegister, "", AgentRegisterPayload{ + AgentID: admission.AgentID, Hostname: admission.Hostname, Token: admission.TokenID, + RuntimeRole: role, ActionCapability: capability, + })) + return conn, wsReadRegisteredPayload(t, conn) + } + + for _, invalid := range []struct{ role, capability string }{ + {"", ""}, + {RuntimeRoleActionRunner, ""}, + {RuntimeRoleActionRunner, "shell.v1"}, + {RuntimeRoleLegacyFullTrust, ActionCapabilityTypedV1}, + } { + conn, ack := register(invalid.role, invalid.capability) + conn.Close() + if ack.Success { + t.Fatalf("unbound runner assertion admitted: role=%q capability=%q", invalid.role, invalid.capability) + } + } + + conn, ack := register(RuntimeRoleActionRunner, ActionCapabilityTypedV1) + defer conn.Close() + if !ack.Success { + t.Fatalf("bound action runner registration rejected: %s", ack.Message) + } + connected := s.GetConnectedAgentsForOrganization("org-a") + if len(connected) != 1 || connected[0].RuntimeRole != RuntimeRoleActionRunner || connected[0].ActionCapability != ActionCapabilityTypedV1 { + t.Fatalf("connected action runner = %#v", connected) + } + + ctx := WithOrganizationID(context.Background(), "org-a") + if _, err := s.ExecuteCommand(ctx, admission.AgentID, ExecuteCommandPayload{RequestID: "shell", Command: "true", TargetType: "agent", Trusted: true}); err == nil || !strings.Contains(err.Error(), "typed action-runner") { + t.Fatalf("action runner accepted arbitrary command: %v", err) + } + if _, err := s.ReadFile(ctx, admission.AgentID, ReadFilePayload{RequestID: "read", Path: "/etc/hosts", TargetType: "agent"}); err == nil || !strings.Contains(err.Error(), "typed action-runner") { + t.Fatalf("action runner accepted unrestricted read: %v", err) + } + if err := s.SendDeployCancel(ctx, admission.AgentID, DeployCancelPayload{RequestID: "deploy", JobID: "job"}); err == nil || !strings.Contains(err.Error(), "typed action-runner") { + t.Fatalf("action runner accepted deploy protocol: %v", err) + } +} + +func TestLegacyCredentialCannotAssertActionRunnerRole(t *testing.T) { + s := NewServerWithAdmissionValidator(func(token, _, _ string) (AgentAdmission, bool) { + return AgentAdmission{TokenID: token, AgentID: "a1", Hostname: "host1", RuntimeRole: RuntimeRoleLegacyFullTrust}, token == "legacy" + }, nil) + 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: "a1", Hostname: "host1", Token: "legacy", RuntimeRole: RuntimeRoleActionRunner, ActionCapability: ActionCapabilityTypedV1, + })) + if ack := wsReadRegisteredPayload(t, conn); ack.Success { + t.Fatal("legacy credential self-promoted into action-runner role") + } +} + func TestHandleWebSocket_RejectsMissingOrigin(t *testing.T) { s := NewServer(allowAllTestTokens) ts := newWSServer(t, s) diff --git a/internal/agentexec/types.go b/internal/agentexec/types.go index 286a297c6..966d1043b 100644 --- a/internal/agentexec/types.go +++ b/internal/agentexec/types.go @@ -11,12 +11,17 @@ import ( type MessageType string const ( + RuntimeRoleActionRunner = "action-runner" + RuntimeRoleLegacyFullTrust = "legacy-full-trust" + ActionCapabilityTypedV1 = "typed_actions.v1" + // Agent -> Server messages MsgTypeAgentRegister MessageType = "agent_register" MsgTypeAgentPing MessageType = "agent_ping" MsgTypeCommandResult MessageType = "command_result" MsgTypeHostStorageCleanupResult MessageType = "host_storage_cleanup_result" MsgTypeHostUpdateResult MessageType = "host_update_result" + MsgTypeProxmoxGuestLifecycleResult MessageType = "proxmox_guest_lifecycle_result" MsgTypeDockerContainerLifecycleResult MessageType = "docker_container_lifecycle_result" MsgTypeDockerContainerUpdateResult MessageType = "docker_container_update_result" MsgTypeDockerContainerObserveResult MessageType = "docker_container_observe_result" @@ -30,6 +35,7 @@ const ( MsgTypeHostStorageCleanup MessageType = "host_storage_cleanup" MsgTypeReadFile MessageType = "read_file" MsgTypeHostUpdate MessageType = "host_update" + MsgTypeProxmoxGuestLifecycle MessageType = "proxmox_guest_lifecycle" MsgTypeDockerContainerLifecycle MessageType = "docker_container_lifecycle" MsgTypeDockerContainerUpdate MessageType = "docker_container_update" MsgTypeDockerContainerObserve MessageType = "docker_container_observe" @@ -96,6 +102,8 @@ type AgentRegisterPayload struct { Platform string `json:"platform"` // "linux", "windows", "darwin" Tags []string `json:"tags,omitempty"` Token string `json:"token"` // API token for authentication + RuntimeRole string `json:"runtime_role,omitempty"` + ActionCapability string `json:"action_capability,omitempty"` OperationReceiptVersion int `json:"operation_receipt_version,omitempty"` ActionPreflightVersion int `json:"action_preflight_version,omitempty"` DockerObservationVersion int `json:"docker_observation_version,omitempty"` @@ -335,6 +343,52 @@ type DockerContainerUpdateOutcome struct { Error string } +// ProxmoxGuestLifecyclePayload is a closed guest lifecycle operation. GuestKind +// selects one fixed Proxmox tool, Operation selects one fixed verb, and VMID is +// numeric. No command text or caller-supplied argument vector crosses the wire. +type ProxmoxGuestLifecyclePayload struct { + RequestID string `json:"request_id"` + ActionID string `json:"action_id"` + Operation string `json:"operation"` + OperationVersion int `json:"operation_version"` + RequestDigest string `json:"request_digest"` + GuestKind string `json:"guest_kind"` + VMID int `json:"vmid"` + ExpectedStatus string `json:"expected_status"` + Timeout int `json:"timeout,omitempty"` +} + +type ProxmoxGuestLifecycleSnapshot struct { + Status string `json:"status,omitempty"` + ObservedAt time.Time `json:"observed_at,omitempty"` +} + +type ProxmoxGuestLifecycleResultPayload struct { + RequestID string `json:"request_id"` + ActionID string `json:"action_id"` + Operation string `json:"operation"` + OperationVersion int `json:"operation_version"` + RequestDigest string `json:"request_digest"` + GuestKind string `json:"guest_kind"` + VMID int `json:"vmid"` + ExecutionPhase string `json:"execution_phase"` + MutationStarted bool `json:"mutation_started"` + MutationCompleted bool `json:"mutation_completed"` + ReadbackRan bool `json:"readback_ran"` + Before ProxmoxGuestLifecycleSnapshot `json:"before"` + After ProxmoxGuestLifecycleSnapshot `json:"after"` + ReasonCode string `json:"reason_code,omitempty"` + Error string `json:"error,omitempty"` + Duration int64 `json:"duration_ms"` +} + +const ( + ProxmoxGuestPhasePreflight = "preflight" + ProxmoxGuestPhaseMutate = "mutate" + ProxmoxGuestPhaseVerify = "verify" + ProxmoxGuestPhaseComplete = "complete" +) + // HostUpdatePayload is the closed, typed host-package operation sent to a // Unified Agent. It intentionally has no command or package-name fields: the // agent owns the package-manager command catalog and always updates the whole @@ -528,6 +582,8 @@ type ConnectedAgent struct { Version string Platform string Tags []string + RuntimeRole string + ActionCapability string ConnectedAt time.Time OperationReceiptVersion int ActionPreflightVersion int diff --git a/internal/agenthelper/container_inventory.go b/internal/agenthelper/container_inventory.go new file mode 100644 index 000000000..74fda3d98 --- /dev/null +++ b/internal/agenthelper/container_inventory.go @@ -0,0 +1,178 @@ +package agenthelper + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "strings" +) + +const ( + maxContainerDaemonBytes = 8 * 1024 * 1024 + maxContainerCount = 2048 + maxContainerString = 1024 +) + +type ContainerEndpoint struct { + Runtime string + SocketPath string + APIPath string +} + +type LocalContainerProvider struct { + endpoints []ContainerEndpoint + dial func(context.Context, string) (net.Conn, error) +} + +type ContainerInventoryResult struct { + Runtimes []ContainerRuntimeSnapshot `json:"runtimes"` +} + +type ContainerRuntimeSnapshot struct { + Runtime string `json:"runtime"` + Available bool `json:"available"` + Containers []ContainerSummary `json:"containers"` + ErrorCode string `json:"errorCode,omitempty"` +} + +type ContainerSummary struct { + ID string `json:"id"` + Names []string `json:"names,omitempty"` + Image string `json:"image,omitempty"` + State string `json:"state,omitempty"` + Status string `json:"status,omitempty"` + Created int64 `json:"created,omitempty"` +} + +type daemonContainer struct { + ID string `json:"Id"` + Names []string `json:"Names"` + Image string `json:"Image"` + State string `json:"State"` + Status string `json:"Status"` + Created int64 `json:"Created"` +} + +func NewLocalContainerProvider(endpoints []ContainerEndpoint) (*LocalContainerProvider, error) { + if len(endpoints) == 0 || len(endpoints) > 2 { + return nil, errors.New("one or two fixed container endpoints are required") + } + seen := make(map[string]struct{}, len(endpoints)) + for _, endpoint := range endpoints { + if endpoint.Runtime != "docker" && endpoint.Runtime != "podman" { + return nil, fmt.Errorf("unsupported container runtime %q", endpoint.Runtime) + } + if _, ok := seen[endpoint.Runtime]; ok { + return nil, fmt.Errorf("duplicate container runtime %q", endpoint.Runtime) + } + seen[endpoint.Runtime] = struct{}{} + if endpoint.SocketPath == "" || endpoint.SocketPath[0] != '/' || endpoint.APIPath == "" || endpoint.APIPath[0] != '/' { + return nil, errors.New("container endpoints must use fixed absolute socket and API paths") + } + if strings.Contains(endpoint.SocketPath, "..") || strings.Contains(endpoint.APIPath, "..") || strings.ContainsAny(endpoint.APIPath, "\r\n") { + return nil, errors.New("container endpoint contains an unsafe path") + } + } + return &LocalContainerProvider{endpoints: append([]ContainerEndpoint(nil), endpoints...), dial: dialFixedUnixSocket}, nil +} + +func dialFixedUnixSocket(ctx context.Context, socketPath string) (net.Conn, error) { + info, err := os.Lstat(socketPath) + if err != nil { + return nil, err + } + if info.Mode()&os.ModeSocket == 0 || info.Mode()&os.ModeSymlink != 0 { + return nil, errors.New("container endpoint is not a Unix socket") + } + dialer := net.Dialer{} + return dialer.DialContext(ctx, "unix", socketPath) +} + +func (p *LocalContainerProvider) Inventory(ctx context.Context) (json.RawMessage, error) { + result := ContainerInventoryResult{Runtimes: make([]ContainerRuntimeSnapshot, 0, len(p.endpoints))} + for _, endpoint := range p.endpoints { + snapshot := ContainerRuntimeSnapshot{Runtime: endpoint.Runtime, Containers: []ContainerSummary{}} + containers, err := p.inventoryEndpoint(ctx, endpoint) + if err != nil { + if ctx.Err() != nil { + return nil, &ProviderError{Code: ErrorDeadlineExceeded, Message: "container inventory deadline exceeded", Retryable: true} + } + snapshot.ErrorCode = ErrorProviderUnavailable + } else { + snapshot.Available = true + snapshot.Containers = containers + } + result.Runtimes = append(result.Runtimes, snapshot) + } + encoded, err := json.Marshal(result) + if err != nil { + return nil, err + } + return encoded, nil +} + +func (p *LocalContainerProvider) inventoryEndpoint(ctx context.Context, endpoint ContainerEndpoint) ([]ContainerSummary, error) { + transport := &http.Transport{ + DisableKeepAlives: true, + DialContext: func(dialCtx context.Context, _, _ string) (net.Conn, error) { + return p.dial(dialCtx, endpoint.SocketPath) + }, + } + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport} + request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://local-helper"+endpoint.APIPath, nil) + if err != nil { + return nil, err + } + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("container runtime returned HTTP %d", response.StatusCode) + } + limited := io.LimitReader(response.Body, maxContainerDaemonBytes+1) + data, err := io.ReadAll(limited) + if err != nil { + return nil, err + } + if len(data) > maxContainerDaemonBytes { + return nil, errors.New("container runtime response exceeds limit") + } + var raw []daemonContainer + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("decode container inventory: %w", err) + } + if len(raw) > maxContainerCount { + return nil, errors.New("container runtime count exceeds limit") + } + containers := make([]ContainerSummary, 0, len(raw)) + for _, item := range raw { + if !boundedContainerText(item.ID) || !boundedContainerText(item.Image) || !boundedContainerText(item.State) || !boundedContainerText(item.Status) { + return nil, errors.New("container runtime field exceeds limit") + } + if len(item.Names) > 32 { + return nil, errors.New("container runtime names exceed limit") + } + for _, name := range item.Names { + if !boundedContainerText(name) { + return nil, errors.New("container runtime name exceeds limit") + } + } + containers = append(containers, ContainerSummary{ + ID: item.ID, Names: append([]string(nil), item.Names...), Image: item.Image, + State: item.State, Status: item.Status, Created: item.Created, + }) + } + return containers, nil +} + +func boundedContainerText(value string) bool { + return len(value) <= maxContainerString && !strings.ContainsAny(value, "\x00\r\n") +} diff --git a/internal/agenthelper/container_inventory_test.go b/internal/agenthelper/container_inventory_test.go new file mode 100644 index 000000000..d63cca040 --- /dev/null +++ b/internal/agenthelper/container_inventory_test.go @@ -0,0 +1,102 @@ +package agenthelper + +import ( + "bufio" + "context" + "errors" + "fmt" + "net" + "net/http" + "strings" + "testing" + "time" +) + +func TestContainerInventoryUsesOnlyFixedBoundedGET(t *testing.T) { + provider, err := NewLocalContainerProvider([]ContainerEndpoint{{ + Runtime: "docker", SocketPath: "/fixed/docker.sock", APIPath: "/v1.41/containers/json?all=1", + }}) + if err != nil { + t.Fatal(err) + } + provider.dial = func(_ context.Context, socket string) (net.Conn, error) { + if socket != "/fixed/docker.sock" { + t.Fatalf("dialed socket %q", socket) + } + server, client := net.Pipe() + go func() { + defer server.Close() + request, readErr := http.ReadRequest(bufio.NewReader(server)) + if readErr != nil { + return + } + if request.Method != http.MethodGet || request.URL.RequestURI() != "/v1.41/containers/json?all=1" { + t.Errorf("daemon request = %s %s", request.Method, request.URL.RequestURI()) + } + _, _ = fmt.Fprint(server, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n"+ + `[{"Id":"abc","Names":["/pulse"],"Image":"pulse:1","State":"running","Status":"Up","Created":1,"Labels":{"ignored":"closed-output"}}]`) + }() + return client, nil + } + result, err := provider.Inventory(t.Context()) + if err != nil { + t.Fatalf("Inventory: %v", err) + } + if strings.Contains(string(result), "ignored") || !strings.Contains(string(result), `"id":"abc"`) { + t.Fatalf("inventory did not project the closed schema: %s", result) + } +} + +func TestContainerInventoryBoundsDaemonOutput(t *testing.T) { + provider, err := NewLocalContainerProvider([]ContainerEndpoint{{Runtime: "podman", SocketPath: "/fixed/podman.sock", APIPath: "/v4/libpod/containers/json?all=true"}}) + if err != nil { + t.Fatal(err) + } + provider.dial = func(context.Context, string) (net.Conn, error) { + server, client := net.Pipe() + go func() { + defer server.Close() + _, _ = http.ReadRequest(bufio.NewReader(server)) + _, _ = fmt.Fprint(server, "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n[\""+strings.Repeat("x", maxContainerDaemonBytes)+"\"]") + }() + return client, nil + } + result, err := provider.Inventory(t.Context()) + if err != nil { + t.Fatalf("bounded endpoint failure should be a closed unavailable snapshot: %v", err) + } + if !strings.Contains(string(result), `"available":false`) || !strings.Contains(string(result), ErrorProviderUnavailable) { + t.Fatalf("oversized output was not bounded: %s", result) + } +} + +func TestContainerInventoryHonorsDeadline(t *testing.T) { + provider, err := NewLocalContainerProvider([]ContainerEndpoint{{Runtime: "docker", SocketPath: "/fixed/docker.sock", APIPath: "/containers/json"}}) + if err != nil { + t.Fatal(err) + } + provider.dial = func(ctx context.Context, _ string) (net.Conn, error) { + <-ctx.Done() + return nil, ctx.Err() + } + ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond) + defer cancel() + _, err = provider.Inventory(ctx) + var typed *ProviderError + if !errors.As(err, &typed) || typed.Code != ErrorDeadlineExceeded { + t.Fatalf("deadline error = %T %v", err, err) + } +} + +func TestContainerEndpointRejectsCallerSelectedProxyShapes(t *testing.T) { + for _, endpoint := range []ContainerEndpoint{ + {Runtime: "containerd", SocketPath: "/run/x.sock", APIPath: "/containers"}, + {Runtime: "docker", SocketPath: "relative.sock", APIPath: "/containers"}, + {Runtime: "docker", SocketPath: "/run/../tmp/x.sock", APIPath: "/containers"}, + {Runtime: "docker", SocketPath: "/run/x.sock", APIPath: "http://attacker/"}, + } { + if _, err := NewLocalContainerProvider([]ContainerEndpoint{endpoint}); err == nil { + t.Fatalf("unsafe endpoint accepted: %#v", endpoint) + } + } +} diff --git a/internal/agenthelper/file_security_other.go b/internal/agenthelper/file_security_other.go new file mode 100644 index 000000000..b50acdcc8 --- /dev/null +++ b/internal/agenthelper/file_security_other.go @@ -0,0 +1,22 @@ +//go:build !unix + +package agenthelper + +import ( + "errors" + "os" +) + +func openFileNoFollow(string) (*os.File, error) { + return nil, errors.New("privileged update activation is supported only on Unix") +} + +func StrictRootOwnedFile(*os.File) error { + return errors.New("root ownership validation is supported only on Unix") +} + +func FileOwnedByUID(uint32) func(*os.File) error { + return func(*os.File) error { + return errors.New("file ownership validation is supported only on Unix") + } +} diff --git a/internal/agenthelper/file_security_unix.go b/internal/agenthelper/file_security_unix.go new file mode 100644 index 000000000..de884687b --- /dev/null +++ b/internal/agenthelper/file_security_unix.go @@ -0,0 +1,31 @@ +//go:build unix + +package agenthelper + +import ( + "errors" + "os" + "syscall" +) + +func openFileNoFollow(path string) (*os.File, error) { + return os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW, 0) +} + +func StrictRootOwnedFile(file *os.File) error { + return FileOwnedByUID(0)(file) +} + +func FileOwnedByUID(uid uint32) func(*os.File) error { + return func(file *os.File) error { + info, err := file.Stat() + if err != nil { + return err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || stat.Uid != uid { + return errors.New("file ownership does not match the required UID") + } + return nil + } +} diff --git a/internal/agenthelper/file_security_unix_test.go b/internal/agenthelper/file_security_unix_test.go new file mode 100644 index 000000000..e379c03a6 --- /dev/null +++ b/internal/agenthelper/file_security_unix_test.go @@ -0,0 +1,27 @@ +//go:build unix + +package agenthelper + +import ( + "os" + "path/filepath" + "testing" +) + +func TestStrictRootOwnedFileRejectsUnprivilegedOwner(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("test process is root") + } + path := filepath.Join(t.TempDir(), "artifact") + if err := os.WriteFile(path, []byte("artifact"), 0o600); err != nil { + t.Fatal(err) + } + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + if err := StrictRootOwnedFile(file); err == nil { + t.Fatal("unprivileged artifact owner accepted") + } +} diff --git a/internal/agenthelper/protocol.go b/internal/agenthelper/protocol.go index b56dd313d..1945d6a3f 100644 --- a/internal/agenthelper/protocol.go +++ b/internal/agenthelper/protocol.go @@ -26,6 +26,8 @@ const ( ErrorUnauthorizedPeer = "unauthorized_peer" ErrorDeadlineExceeded = "deadline_exceeded" ErrorProviderUnavailable = "provider_unavailable" + ErrorArtifactInvalid = "artifact_invalid" + ErrorStateConflict = "state_conflict" ErrorResponseTooLarge = "response_too_large" ErrorInternal = "internal_error" ) @@ -35,6 +37,10 @@ const ( OperationCapabilities = "helper.capabilities" OperationSMARTSnapshot = "smart.snapshot" OperationProxmoxLXCFilesystems = "proxmox.lxc_filesystems" + OperationContainerInventory = "container.inventory" + OperationAgentUpdateStage = "agent_update.stage" + OperationAgentUpdateActivate = "agent_update.activate" + OperationAgentUpdateRollback = "agent_update.rollback" OperationVersion1 = 1 ) diff --git a/internal/agenthelper/providers.go b/internal/agenthelper/providers.go index 0acb8db07..972194ca5 100644 --- a/internal/agenthelper/providers.go +++ b/internal/agenthelper/providers.go @@ -20,6 +20,22 @@ type ProxmoxProvider interface { LXCFilesystems(context.Context) (json.RawMessage, error) } +// ContainerProvider owns a complete, bounded inventory of the helper's fixed +// local Docker and Podman endpoints. The protocol supplies no socket, URL, +// daemon method, or query fields. +type ContainerProvider interface { + Inventory(context.Context) (json.RawMessage, error) +} + +// UpdateProvider owns the fixed-target, root-owned binary activation +// transaction. Request values identify a pre-staged artifact; they are never +// interpreted as filesystem paths. +type UpdateProvider interface { + Stage(context.Context, UpdateStageRequest) (UpdateStageResult, error) + Activate(context.Context, UpdateActivateRequest) (UpdateResult, error) + Rollback(context.Context, UpdateRollbackRequest) (UpdateResult, error) +} + type ProviderError struct { Code string Message string @@ -51,7 +67,7 @@ func providerError(err error) *ResponseError { func stableProviderErrorCode(code string) string { switch code { - case ErrorProviderUnavailable, ErrorDeadlineExceeded: + case ErrorProviderUnavailable, ErrorDeadlineExceeded, ErrorArtifactInvalid, ErrorStateConflict: return code default: return ErrorInternal diff --git a/internal/agenthelper/registry.go b/internal/agenthelper/registry.go index 8255a945a..a02bd7f55 100644 --- a/internal/agenthelper/registry.go +++ b/internal/agenthelper/registry.go @@ -45,7 +45,16 @@ type HealthResult struct { type emptyOperationRequest struct{} +type Providers struct { + Containers ContainerProvider + Updates UpdateProvider +} + func NewRegistry(smart SMARTProvider, proxmox ProxmoxProvider) *Registry { + return NewRegistryWithProviders(smart, proxmox, Providers{}) +} + +func NewRegistryWithProviders(smart SMARTProvider, proxmox ProxmoxProvider, providers Providers) *Registry { registry := &Registry{operations: make(map[operationKey]registeredOperation)} registry.add(OperationHealth, OperationVersion1, true, func(_ context.Context, payload json.RawMessage) (json.RawMessage, *ResponseError) { var request emptyOperationRequest @@ -83,6 +92,59 @@ func NewRegistry(smart SMARTProvider, proxmox ProxmoxProvider) *Registry { result, err := proxmox.LXCFilesystems(ctx) return validateProviderResult(result, err) }) + registry.add(OperationContainerInventory, OperationVersion1, providers.Containers != nil, func(ctx context.Context, payload json.RawMessage) (json.RawMessage, *ResponseError) { + var request emptyOperationRequest + if err := decodePayload(payload, &request); err != nil { + return nil, invalidPayloadError(err) + } + if providers.Containers == nil { + return nil, unavailableError("container runtime inventory provider is not configured") + } + result, err := providers.Containers.Inventory(ctx) + return validateProviderResult(result, err) + }) + registry.add(OperationAgentUpdateStage, OperationVersion1, providers.Updates != nil, func(ctx context.Context, payload json.RawMessage) (json.RawMessage, *ResponseError) { + var request UpdateStageRequest + 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.Stage(ctx, request) + if err != nil { + return nil, providerError(err) + } + return marshalOperationResult(result) + }) + registry.add(OperationAgentUpdateActivate, OperationVersion1, providers.Updates != nil, func(ctx context.Context, payload json.RawMessage) (json.RawMessage, *ResponseError) { + var request UpdateActivateRequest + 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.Activate(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 { + return nil, invalidPayloadError(err) + } + if providers.Updates == nil { + return nil, unavailableError("agent update provider is not configured") + } + result, err := providers.Updates.Rollback(ctx, request) + if err != nil { + return nil, providerError(err) + } + return marshalOperationResult(result) + }) return registry } diff --git a/internal/agenthelper/server_test.go b/internal/agenthelper/server_test.go index d96e5a38f..9d75afd2c 100644 --- a/internal/agenthelper/server_test.go +++ b/internal/agenthelper/server_test.go @@ -25,6 +25,30 @@ func (f fakeProxmoxProvider) LXCFilesystems(ctx context.Context) (json.RawMessag return f(ctx) } +type fakeContainerProvider func(context.Context) (json.RawMessage, error) + +func (f fakeContainerProvider) Inventory(ctx context.Context) (json.RawMessage, error) { + return f(ctx) +} + +type fakeUpdateProvider struct { + stage func(context.Context, UpdateStageRequest) (UpdateStageResult, error) + activate func(context.Context, UpdateActivateRequest) (UpdateResult, error) + rollback func(context.Context, UpdateRollbackRequest) (UpdateResult, error) +} + +func (f fakeUpdateProvider) Stage(ctx context.Context, request UpdateStageRequest) (UpdateStageResult, error) { + return f.stage(ctx, request) +} + +func (f fakeUpdateProvider) Activate(ctx context.Context, request UpdateActivateRequest) (UpdateResult, error) { + return f.activate(ctx, request) +} + +func (f fakeUpdateProvider) Rollback(ctx context.Context, request UpdateRollbackRequest) (UpdateResult, error) { + return f.rollback(ctx, request) +} + func authorizedResolver(uid uint32) PeerResolver { return PeerResolverFunc(func(net.Conn) (Peer, error) { return Peer{UID: uid, GID: 2000, PID: 3000}, nil @@ -158,6 +182,62 @@ func TestServerDispatchesTypedProvidersWithoutCallerArguments(t *testing.T) { requireErrorCode(t, exchange(t, server, request), ErrorInvalidRequest) } +func TestServerDispatchesClosedContainerAndUpdateOperations(t *testing.T) { + containerCalled := false + stageCalled := false + activateCalled := false + rollbackCalled := false + updates := fakeUpdateProvider{ + stage: func(_ context.Context, request UpdateStageRequest) (UpdateStageResult, error) { + stageCalled = request.ArtifactID == "release-1" + return UpdateStageResult{Action: "staged", ArtifactID: request.ArtifactID, SHA256: request.SHA256}, nil + }, + activate: func(_ context.Context, request UpdateActivateRequest) (UpdateResult, error) { + activateCalled = request.ArtifactID == "release-1" + return UpdateResult{Action: "activated", ActivationID: "release-1:0123456789abcdef"}, nil + }, + rollback: func(_ context.Context, request UpdateRollbackRequest) (UpdateResult, error) { + rollbackCalled = request.ActivationID == "release-1:0123456789abcdef" + return UpdateResult{Action: "rolled_back", ActivationID: request.ActivationID}, nil + }, + } + registry := NewRegistryWithProviders(nil, nil, Providers{ + Containers: fakeContainerProvider(func(context.Context) (json.RawMessage, error) { + containerCalled = true + return json.RawMessage(`{"runtimes":[]}`), nil + }), + Updates: updates, + }) + server := newTestServer(t, registry, authorizedResolver(1000), nil) + if response := exchange(t, server, validRequest(OperationContainerInventory)); !response.Success || !containerCalled { + t.Fatalf("container response=%#v called=%t", response, containerCalled) + } + stage := validRequest(OperationAgentUpdateStage) + stage.Payload = json.RawMessage(`{"artifactId":"release-1","sha256":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}`) + if response := exchange(t, server, stage); !response.Success || !stageCalled { + t.Fatalf("stage response=%#v called=%t", response, stageCalled) + } + activate := validRequest(OperationAgentUpdateActivate) + activate.Payload = json.RawMessage(`{"artifactId":"release-1","sha256":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}`) + if response := exchange(t, server, activate); !response.Success || !activateCalled { + t.Fatalf("activate response=%#v called=%t", response, activateCalled) + } + 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} { + request := validRequest(operation) + request.Payload = json.RawMessage(`{"path":"/tmp/attacker","args":["sh"]}`) + requireErrorCode(t, exchange(t, server, request), ErrorInvalidRequest) + request = validRequest(operation) + request.OperationVersion = 2 + requireErrorCode(t, exchange(t, server, request), ErrorUnsupportedOperation) + } +} + func TestServerRejectsEnvelopeAndRegistryViolations(t *testing.T) { server := newTestServer(t, NewRegistry(nil, nil), authorizedResolver(1000), nil) tests := []struct { diff --git a/internal/agenthelper/update_activation.go b/internal/agenthelper/update_activation.go new file mode 100644 index 000000000..6c5f0f303 --- /dev/null +++ b/internal/agenthelper/update_activation.go @@ -0,0 +1,494 @@ +package agenthelper + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const maxUpdateArtifactBytes = 100 * 1024 * 1024 + +type UpdateActivateRequest struct { + ArtifactID string `json:"artifactId"` + SHA256 string `json:"sha256"` +} + +type UpdateStageRequest struct { + ArtifactID string `json:"artifactId"` + SHA256 string `json:"sha256"` +} + +type UpdateStageResult struct { + Action string `json:"action"` + ArtifactID string `json:"artifactId"` + SHA256 string `json:"sha256"` + DurableAt time.Time `json:"durableAt"` +} + +type UpdateRollbackRequest struct { + ActivationID string `json:"activationId"` + CurrentSHA256 string `json:"currentSha256"` + RollbackSHA256 string `json:"rollbackSha256"` +} + +type UpdateResult struct { + Action string `json:"action"` + ActivationID string `json:"activationId"` + ActiveSHA256 string `json:"activeSha256"` + RollbackSHA256 string `json:"rollbackSha256"` + DurableAt time.Time `json:"durableAt"` +} + +type UpdateActivatorConfig struct { + QuarantineDir string + StagingDir string + TargetPath string + StatePath string + VerifySignature func([]byte, string) error + ValidateOwner func(*os.File) error + ValidateQuarantineOwner func(*os.File) error + Now func() time.Time +} + +type updateActivator struct { + mu sync.Mutex + stagingDir string + quarantineDir string + targetPath string + rollbackPath string + statePath string + verifySignature func([]byte, string) error + validateOwner func(*os.File) error + validateQuarantineOwner func(*os.File) error + now func() time.Time +} + +type durableUpdateState struct { + Action string `json:"action"` + ActivationID string `json:"activationId"` + ActiveSHA256 string `json:"activeSha256"` + RollbackSHA256 string `json:"rollbackSha256"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func NewUpdateActivator(config UpdateActivatorConfig) (UpdateProvider, error) { + if config.VerifySignature == nil || config.ValidateOwner == nil || config.ValidateQuarantineOwner == nil { + return nil, errors.New("signature, root-ownership, and quarantine-ownership validators are required") + } + for _, path := range []string{config.QuarantineDir, config.StagingDir, config.TargetPath, config.StatePath} { + if path == "" || !filepath.IsAbs(path) || filepath.Clean(path) != path { + return nil, errors.New("update helper paths must be clean and absolute") + } + } + if config.QuarantineDir == config.StagingDir || strings.HasPrefix(config.QuarantineDir, config.StagingDir+string(os.PathSeparator)) || strings.HasPrefix(config.StagingDir, config.QuarantineDir+string(os.PathSeparator)) { + return nil, errors.New("update quarantine and staging must be separate") + } + if filepath.Dir(config.TargetPath) == config.StagingDir || strings.HasPrefix(config.TargetPath, config.StagingDir+string(os.PathSeparator)) { + return nil, errors.New("update target must be outside staging") + } + now := config.Now + if now == nil { + now = time.Now + } + return &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 +} + +// Stage promotes exactly one signed collector-downloaded artifact from the +// fixed quarantine into the helper's fixed root-owned staging tree. The +// request carries identity only: no path, command, URL, or copy destination is +// caller-controlled. +func (u *updateActivator) Stage(ctx context.Context, request UpdateStageRequest) (UpdateStageResult, error) { + u.mu.Lock() + defer u.mu.Unlock() + if !validArtifactID(request.ArtifactID) || !validSHA256(request.SHA256) { + return UpdateStageResult{}, invalidArtifact("artifact identity or digest is invalid") + } + if err := u.validateDirectoryWith(u.quarantineDir, u.validateQuarantineOwner); err != nil { + return UpdateStageResult{}, invalidArtifact("update quarantine root is invalid") + } + sourceDir := filepath.Join(u.quarantineDir, request.ArtifactID) + if err := u.validateDirectoryWith(sourceDir, u.validateQuarantineOwner); err != nil { + return UpdateStageResult{}, invalidArtifact("quarantined artifact directory is invalid") + } + artifact, err := u.readOwnedBoundedWith(filepath.Join(sourceDir, "pulse-agent"), maxUpdateArtifactBytes, u.validateQuarantineOwner) + if err != nil { + return UpdateStageResult{}, invalidArtifact("quarantined artifact is unavailable or unsafe") + } + if len(artifact) < 4 || artifact[0] != 0x7f || string(artifact[1:4]) != "ELF" { + return UpdateStageResult{}, invalidArtifact("quarantined artifact is not a Linux executable") + } + signature, err := u.readOwnedBoundedWith(filepath.Join(sourceDir, "pulse-agent.sig"), 4096, u.validateQuarantineOwner) + if err != nil { + return UpdateStageResult{}, invalidArtifact("quarantined signature is unavailable or unsafe") + } + actual := sha256Hex(artifact) + if !strings.EqualFold(actual, request.SHA256) { + return UpdateStageResult{}, invalidArtifact("quarantined artifact digest does not match request") + } + if err := u.verifySignature(artifact, strings.TrimSpace(string(signature))); err != nil { + return UpdateStageResult{}, invalidArtifact("quarantined artifact signature is invalid") + } + if err := ctx.Err(); err != nil { + return UpdateStageResult{}, &ProviderError{Code: ErrorDeadlineExceeded, Message: "update staging deadline exceeded", Retryable: true} + } + if err := u.validateDirectory(u.stagingDir); err != nil { + return UpdateStageResult{}, invalidArtifact("update staging root is invalid") + } + destination := filepath.Join(u.stagingDir, request.ArtifactID) + if err := u.installStagedArtifact(destination, artifact, signature); err != nil { + return UpdateStageResult{}, &ProviderError{Code: ErrorInternal, Message: "promote quarantined artifact into root staging"} + } + return UpdateStageResult{Action: "staged", ArtifactID: request.ArtifactID, SHA256: actual, DurableAt: u.now().UTC()}, nil +} + +func (u *updateActivator) Activate(ctx context.Context, request UpdateActivateRequest) (UpdateResult, error) { + u.mu.Lock() + defer u.mu.Unlock() + if !validArtifactID(request.ArtifactID) || !validSHA256(request.SHA256) { + return UpdateResult{}, invalidArtifact("artifact identity or digest is invalid") + } + if err := u.validateDirectory(u.stagingDir); err != nil { + return UpdateResult{}, invalidArtifact("update staging root is invalid") + } + artifactDir := filepath.Join(u.stagingDir, request.ArtifactID) + if err := u.validateDirectory(artifactDir); err != nil { + return UpdateResult{}, invalidArtifact("staged artifact directory is invalid") + } + artifact, err := u.readOwnedBounded(filepath.Join(artifactDir, "pulse-agent"), maxUpdateArtifactBytes) + if err != nil { + return UpdateResult{}, invalidArtifact("staged artifact is unavailable or unsafe") + } + if len(artifact) < 4 || artifact[0] != 0x7f || string(artifact[1:4]) != "ELF" { + return UpdateResult{}, invalidArtifact("staged artifact is not a Linux executable") + } + signatureBytes, err := u.readOwnedBounded(filepath.Join(artifactDir, "pulse-agent.sig"), 4096) + if err != nil { + return UpdateResult{}, invalidArtifact("staged signature is unavailable or unsafe") + } + actual := sha256Hex(artifact) + if !strings.EqualFold(actual, request.SHA256) { + return UpdateResult{}, invalidArtifact("staged artifact digest does not match request") + } + if err := u.verifySignature(artifact, strings.TrimSpace(string(signatureBytes))); err != nil { + 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"} + } + 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"} + } + 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) { + return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "durable update state is invalid"} + } + if err := ctx.Err(); err != nil { + return UpdateResult{}, &ProviderError{Code: ErrorDeadlineExceeded, Message: "update activation deadline exceeded", Retryable: true} + } + current, err := u.readOwnedBounded(u.targetPath, maxUpdateArtifactBytes) + if err != nil { + return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "installed agent binary is unavailable or unsafe"} + } + rollbackDigest := sha256Hex(current) + if err := u.installDurably(artifact, current); err != nil { + return UpdateResult{}, &ProviderError{Code: ErrorInternal, Message: "activate staged agent binary"} + } + result := UpdateResult{ + Action: "activated", ActivationID: activationID, + ActiveSHA256: actual, RollbackSHA256: rollbackDigest, DurableAt: 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"} + } + return result, nil +} + +func (u *updateActivator) Rollback(ctx context.Context, request UpdateRollbackRequest) (UpdateResult, error) { + u.mu.Lock() + defer u.mu.Unlock() + if !validActivationID(request.ActivationID) || !validSHA256(request.CurrentSHA256) || !validSHA256(request.RollbackSHA256) { + return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "rollback identity is invalid"} + } + state, err := u.readState() + if err != nil || state.Action != "activated" || 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"} + } + 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"} + } + result := UpdateResult{ + Action: "rolled_back", ActivationID: state.ActivationID, + ActiveSHA256: state.RollbackSHA256, RollbackSHA256: state.ActiveSHA256, DurableAt: u.now().UTC(), + } + if err := u.writeState(result); err != nil { + _ = u.installDurably(current, rollback) + return UpdateResult{}, &ProviderError{Code: ErrorInternal, Message: "persist rollback result"} + } + return result, nil +} + +func (u *updateActivator) validateDirectory(path string) error { + return u.validateDirectoryWith(path, u.validateOwner) +} + +func (u *updateActivator) validateDirectoryWith(path string, validate func(*os.File) error) error { + file, err := openFileNoFollow(path) + if err != nil { + return err + } + defer file.Close() + if err := validate(file); err != nil { + return err + } + info, err := file.Stat() + if err != nil || !info.IsDir() || info.Mode().Perm()&0o022 != 0 { + return errors.New("directory is not root-owned and immutable to non-root callers") + } + return nil +} + +func (u *updateActivator) readOwnedBounded(path string, limit int64) ([]byte, error) { + return u.readOwnedBoundedWith(path, limit, u.validateOwner) +} + +func (u *updateActivator) readOwnedBoundedWith(path string, limit int64, validate func(*os.File) error) ([]byte, error) { + before, err := os.Lstat(path) + if err != nil { + return nil, err + } + if before.Mode()&os.ModeSymlink != 0 || !before.Mode().IsRegular() { + return nil, errors.New("unsafe file type") + } + file, err := openFileNoFollow(path) + if err != nil { + return nil, err + } + defer file.Close() + if err := validate(file); err != nil { + return nil, err + } + after, err := file.Stat() + if err != nil || !os.SameFile(before, after) || !after.Mode().IsRegular() || after.Mode().Perm()&0o022 != 0 { + return nil, errors.New("file identity or ownership changed") + } + data, err := io.ReadAll(io.LimitReader(file, limit+1)) + if err != nil || int64(len(data)) > limit { + return nil, errors.New("file exceeds bounded size") + } + return data, nil +} + +func (u *updateActivator) installStagedArtifact(destination string, artifact, signature []byte) error { + if info, err := os.Lstat(destination); err == nil { + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return errors.New("unsafe staged artifact destination") + } + if err := u.validateDirectory(destination); err != nil { + return err + } + current, binaryErr := u.readOwnedBounded(filepath.Join(destination, "pulse-agent"), maxUpdateArtifactBytes) + currentSignature, signatureErr := u.readOwnedBounded(filepath.Join(destination, "pulse-agent.sig"), 4096) + if binaryErr == nil && signatureErr == nil && strings.EqualFold(sha256Hex(current), sha256Hex(artifact)) && string(currentSignature) == string(signature) { + return nil + } + return errors.New("staged artifact identity conflict") + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + tempDir, err := os.MkdirTemp(u.stagingDir, ".pulse-agent-stage-*") + if err != nil { + return err + } + defer os.RemoveAll(tempDir) + if err := os.Chmod(tempDir, 0o700); err != nil { + return err + } + binaryTemp, err := writeSyncedTemp(tempDir, ".pulse-agent-*", artifact, 0o600) + if err != nil { + return err + } + if err := os.Rename(binaryTemp, filepath.Join(tempDir, "pulse-agent")); err != nil { + return err + } + signatureTemp, err := writeSyncedTemp(tempDir, ".pulse-agent.sig-*", signature, 0o600) + if err != nil { + return err + } + if err := os.Rename(signatureTemp, filepath.Join(tempDir, "pulse-agent.sig")); err != nil { + return err + } + if err := syncDirectory(tempDir); err != nil { + return err + } + if err := os.Rename(tempDir, destination); err != nil { + return err + } + return syncDirectory(u.stagingDir) +} + +func (u *updateActivator) installDurably(active, lastKnownGood []byte) error { + targetDir := filepath.Dir(u.targetPath) + if err := u.validateDirectory(targetDir); err != nil { + return err + } + rollbackTemp, err := writeSyncedTemp(targetDir, ".pulse-agent-lkg-*", lastKnownGood, 0o755) + if err != nil { + return err + } + defer os.Remove(rollbackTemp) + activeTemp, err := writeSyncedTemp(targetDir, ".pulse-agent-active-*", active, 0o755) + if err != nil { + return err + } + defer os.Remove(activeTemp) + if err := os.Rename(rollbackTemp, u.rollbackPath); err != nil { + return err + } + if err := syncDirectory(targetDir); err != nil { + return err + } + if err := os.Rename(activeTemp, u.targetPath); err != nil { + return err + } + 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} + data, err := json.Marshal(state) + if err != nil { + return err + } + dir := filepath.Dir(u.statePath) + if err := u.validateDirectory(dir); err != nil { + return err + } + temp, err := writeSyncedTemp(dir, ".pulse-agent-update-state-*", data, 0o600) + if err != nil { + return err + } + defer os.Remove(temp) + if err := os.Rename(temp, u.statePath); err != nil { + return err + } + return syncDirectory(dir) +} + +func (u *updateActivator) readState() (durableUpdateState, error) { + data, err := u.readOwnedBounded(u.statePath, 16*1024) + if err != nil { + return durableUpdateState{}, err + } + var state durableUpdateState + if err := decodeStrict(data, &state); err != nil { + return durableUpdateState{}, err + } + return state, nil +} + +func writeSyncedTemp(dir, pattern string, data []byte, mode os.FileMode) (string, error) { + file, err := os.CreateTemp(dir, pattern) + if err != nil { + return "", err + } + name := file.Name() + ok := false + defer func() { + _ = file.Close() + if !ok { + _ = os.Remove(name) + } + }() + if err := file.Chmod(mode); err != nil { + return "", err + } + if _, err := file.Write(data); err != nil { + return "", err + } + if err := file.Sync(); err != nil { + return "", err + } + if err := file.Close(); err != nil { + return "", err + } + ok = true + return name, nil +} + +func syncDirectory(path string) error { + dir, err := os.Open(path) + if err != nil { + return err + } + defer dir.Close() + return dir.Sync() +} + +func sha256Hex(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +func validArtifactID(value string) bool { + if value == "" || len(value) > 96 { + return false + } + for _, char := range value { + if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '-' || char == '_' || char == '.' { + continue + } + return false + } + return value != "." && value != ".." && !strings.Contains(value, "..") +} + +func validActivationID(value string) bool { + parts := strings.Split(value, ":") + return len(parts) == 2 && validArtifactID(parts[0]) && len(parts[1]) == 16 && isLowerHex(parts[1]) +} + +func validSHA256(value string) bool { return len(value) == 64 && isLowerHex(strings.ToLower(value)) } + +func isLowerHex(value string) bool { + _, err := hex.DecodeString(value) + return err == nil +} + +func invalidArtifact(message string) error { + return &ProviderError{Code: ErrorArtifactInvalid, Message: message} +} + +var _ UpdateProvider = (*updateActivator)(nil) diff --git a/internal/agenthelper/update_activation_test.go b/internal/agenthelper/update_activation_test.go new file mode 100644 index 000000000..c711dd549 --- /dev/null +++ b/internal/agenthelper/update_activation_test.go @@ -0,0 +1,217 @@ +package agenthelper + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func testUpdateActivator(t *testing.T, verifier func([]byte, string) error) (UpdateProvider, string, string, string) { + t.Helper() + root := t.TempDir() + quarantine := filepath.Join(root, "quarantine") + staging := filepath.Join(root, "staging") + targetDir := filepath.Join(root, "bin") + stateDir := filepath.Join(root, "state") + for _, path := range []string{quarantine, staging, targetDir, stateDir} { + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + } + target := filepath.Join(targetDir, "pulse-agent") + if err := os.WriteFile(target, testELF("old-signed-binary"), 0o755); err != nil { + t.Fatal(err) + } + provider, err := NewUpdateActivator(UpdateActivatorConfig{ + QuarantineDir: quarantine, StagingDir: staging, TargetPath: target, StatePath: filepath.Join(stateDir, "activation.json"), + VerifySignature: verifier, + 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) }, + }) + if err != nil { + t.Fatal(err) + } + return provider, quarantine, target, filepath.Join(stateDir, "activation.json") +} + +func testELF(body string) []byte { + return append([]byte{0x7f, 'E', 'L', 'F'}, []byte(body)...) +} + +func stageUpdate(t *testing.T, staging, identity string, binary []byte) string { + t.Helper() + dir := filepath.Join(staging, identity) + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "pulse-agent"), binary, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "pulse-agent.sig"), []byte("valid-signature"), 0o600); err != nil { + t.Fatal(err) + } + return sha256Hex(binary) +} + +func promoteUpdate(t *testing.T, provider UpdateProvider, artifactID, digest string) { + t.Helper() + result, err := provider.Stage(context.Background(), UpdateStageRequest{ArtifactID: artifactID, SHA256: digest}) + if err != nil { + t.Fatalf("Stage: %v", err) + } + if result.Action != "staged" || result.ArtifactID != artifactID || result.SHA256 != digest { + t.Fatalf("stage result = %#v", result) + } +} + +func TestUpdateActivationAndIdentityBoundRollbackAreDurable(t *testing.T) { + provider, staging, target, state := testUpdateActivator(t, func(data []byte, signature string) error { + if string(data) != string(testELF("new-signed-binary")) || signature != "valid-signature" { + return errors.New("signature mismatch") + } + return nil + }) + newDigest := stageUpdate(t, staging, "release-1", testELF("new-signed-binary")) + promoteUpdate(t, provider, "release-1", newDigest) + oldDigest := sha256Hex(testELF("old-signed-binary")) + activated, err := provider.Activate(context.Background(), UpdateActivateRequest{ArtifactID: "release-1", SHA256: newDigest}) + if err != nil { + t.Fatalf("Activate: %v", err) + } + if activated.ActiveSHA256 != newDigest || activated.RollbackSHA256 != oldDigest || activated.ActivationID == "" { + t.Fatalf("activation result = %#v", activated) + } + if installed, _ := os.ReadFile(target); string(installed) != string(testELF("new-signed-binary")) { + t.Fatalf("installed binary = %q", installed) + } + if info, err := os.Stat(state); err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("durable state mode=%v err=%v", info, err) + } + retried, err := provider.Activate(context.Background(), UpdateActivateRequest{ArtifactID: "release-1", SHA256: newDigest}) + if err != nil || retried != activated { + t.Fatalf("idempotent activation retry = %#v err=%v, want %#v", retried, err, activated) + } + rolledBack, err := provider.Rollback(context.Background(), UpdateRollbackRequest{ + ActivationID: activated.ActivationID, CurrentSHA256: newDigest, RollbackSHA256: oldDigest, + }) + if err != nil { + t.Fatalf("Rollback: %v", err) + } + if rolledBack.ActiveSHA256 != oldDigest || rolledBack.RollbackSHA256 != newDigest { + t.Fatalf("rollback result = %#v", rolledBack) + } + if installed, _ := os.ReadFile(target); string(installed) != string(testELF("old-signed-binary")) { + t.Fatalf("rolled-back binary = %q", installed) + } + if _, err := provider.Rollback(context.Background(), UpdateRollbackRequest{ + ActivationID: rolledBack.ActivationID, CurrentSHA256: rolledBack.ActiveSHA256, RollbackSHA256: rolledBack.RollbackSHA256, + }); err == nil { + t.Fatal("completed rollback replay reactivated the rejected binary") + } +} + +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")) + if _, err := provider.Stage(context.Background(), UpdateStageRequest{ArtifactID: "bad-signature", SHA256: digest}); err == nil { + t.Fatal("invalid artifact signature accepted") + } + + provider, staging, _, _ = testUpdateActivator(t, func([]byte, string) error { return nil }) + digest = stageUpdate(t, staging, "not-elf", []byte("signed but not executable")) + if _, err := provider.Stage(context.Background(), UpdateStageRequest{ArtifactID: "not-elf", SHA256: digest}); err == nil { + t.Fatal("non-ELF artifact accepted") + } +} + +func TestUpdateActivationRejectsTraversalSymlinksAndDigestMismatch(t *testing.T) { + provider, staging, target, _ := testUpdateActivator(t, func([]byte, string) error { return nil }) + validDigest := stageUpdate(t, staging, "release-1", testELF("new")) + for _, request := range []UpdateActivateRequest{ + {ArtifactID: "../release-1", SHA256: validDigest}, + {ArtifactID: "release/1", SHA256: validDigest}, + {ArtifactID: "release-1", SHA256: strings.Repeat("0", 64)}, + } { + if _, err := provider.Stage(context.Background(), UpdateStageRequest{ArtifactID: request.ArtifactID, SHA256: request.SHA256}); err == nil { + t.Fatalf("unsafe activation accepted: %#v", request) + } + } + outside := filepath.Join(filepath.Dir(staging), "outside") + if err := os.WriteFile(outside, []byte("attacker"), 0o600); err != nil { + t.Fatal(err) + } + symlinkDir := filepath.Join(staging, "symlink-release") + if err := os.Mkdir(symlinkDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(symlinkDir, "pulse-agent")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(symlinkDir, "pulse-agent.sig"), []byte("valid-signature"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := provider.Stage(context.Background(), UpdateStageRequest{ArtifactID: "symlink-release", SHA256: sha256Hex([]byte("attacker"))}); err == nil { + t.Fatal("symlink artifact accepted") + } + if err := os.Remove(target); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, target); err != nil { + t.Fatal(err) + } + promoteUpdate(t, provider, "release-1", validDigest) + if _, err := provider.Activate(context.Background(), UpdateActivateRequest{ArtifactID: "release-1", SHA256: validDigest}); err == nil { + t.Fatal("symlink install target accepted") + } +} + +func TestUpdateActivationUsesOpenedBytesAcrossStagingSwap(t *testing.T) { + var stagingBinary, target string + provider, staging, resolvedTarget, _ := testUpdateActivator(t, func(data []byte, _ string) error { + if err := os.Remove(stagingBinary); err != nil { + return err + } + if err := os.Symlink(target, stagingBinary); err != nil { + return err + } + if string(data) != string(testELF("verified-bytes")) { + return errors.New("unexpected verified data") + } + return nil + }) + target = resolvedTarget + digest := stageUpdate(t, staging, "release-swap", testELF("verified-bytes")) + stagingBinary = filepath.Join(staging, "release-swap", "pulse-agent") + promoteUpdate(t, provider, "release-swap", digest) + if _, err := provider.Activate(context.Background(), UpdateActivateRequest{ArtifactID: "release-swap", SHA256: digest}); err != nil { + t.Fatalf("Activate: %v", err) + } + if installed, _ := os.ReadFile(target); string(installed) != string(testELF("verified-bytes")) { + t.Fatalf("symlink swap changed activated bytes: %q", installed) + } +} + +func TestUpdateRollbackRejectsWrongIdentityAndChangedBinary(t *testing.T) { + provider, staging, target, _ := testUpdateActivator(t, func([]byte, string) error { return nil }) + digest := stageUpdate(t, staging, "release-1", testELF("new")) + promoteUpdate(t, provider, "release-1", digest) + result, err := provider.Activate(context.Background(), UpdateActivateRequest{ArtifactID: "release-1", SHA256: digest}) + if err != nil { + t.Fatal(err) + } + bad := UpdateRollbackRequest{ActivationID: "release-2:" + result.ActivationID[len(result.ActivationID)-16:], CurrentSHA256: result.ActiveSHA256, RollbackSHA256: result.RollbackSHA256} + if _, err := provider.Rollback(context.Background(), bad); err == nil { + t.Fatal("wrong rollback activation identity accepted") + } + if err := os.WriteFile(target, []byte("changed-after-activation"), 0o755); err != nil { + t.Fatal(err) + } + if _, err := provider.Rollback(context.Background(), UpdateRollbackRequest{ActivationID: result.ActivationID, CurrentSHA256: result.ActiveSHA256, RollbackSHA256: result.RollbackSHA256}); err == nil { + t.Fatal("rollback accepted changed active binary") + } +} diff --git a/internal/agentupdate/coverage_test.go b/internal/agentupdate/coverage_test.go index 70b37caa4..34f2a6c14 100644 --- a/internal/agentupdate/coverage_test.go +++ b/internal/agentupdate/coverage_test.go @@ -1542,6 +1542,12 @@ type errorReader struct { sent bool } +func TestCoveragePrivilegeHelperUpdateRejectsMissingSocket(t *testing.T) { + if _, err := NewPrivilegeHelperUpdate(""); err == nil { + t.Fatal("expected an empty helper socket path to fail closed") + } +} + func (e *errorReader) Read(p []byte) (int, error) { if e.sent { return 0, errors.New("read fail") diff --git a/internal/agentupdate/privileged_update.go b/internal/agentupdate/privileged_update.go new file mode 100644 index 000000000..eb1ea7af2 --- /dev/null +++ b/internal/agentupdate/privileged_update.go @@ -0,0 +1,147 @@ +package agentupdate + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/agenthelper" +) + +const privilegedUpdateQuarantineDir = "/var/lib/pulse-agent/update-quarantine" + +// 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) + WriteQuarantinedSignature(artifactID, signature string) error + Stage(context.Context, string, string) (agenthelper.UpdateStageResult, error) + Activate(context.Context, string, string) (agenthelper.UpdateResult, error) + Rollback(context.Context, agenthelper.UpdateResult) (agenthelper.UpdateResult, error) +} + +type privilegeHelperUpdate struct { + client *agenthelper.Client + quarantineDir string +} + +func NewPrivilegeHelperUpdate(socketPath string) (PrivilegedUpdate, error) { + client, err := agenthelper.NewClient(agenthelper.ClientConfig{SocketPath: socketPath, MaxDeadline: 30 * time.Second}) + if err != nil { + return nil, err + } + return &privilegeHelperUpdate{client: client, quarantineDir: privilegedUpdateQuarantineDir}, nil +} + +func (p *privilegeHelperUpdate) CreateQuarantinedArtifact() (string, *os.File, func(), error) { + if err := validateCollectorQuarantineRoot(p.quarantineDir); err != nil { + return "", nil, func() {}, err + } + var nonce [16]byte + if _, err := rand.Read(nonce[:]); err != nil { + return "", nil, func() {}, 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) + } + cleanup := func() { + _ = os.Remove(filepath.Join(artifactDir, "pulse-agent")) + _ = os.Remove(filepath.Join(artifactDir, "pulse-agent.sig")) + _ = os.Remove(artifactDir) + } + 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) + } + return artifactID, file, cleanup, nil +} + +func (p *privilegeHelperUpdate) WriteQuarantinedSignature(artifactID, signature string) error { + if !validPrivilegedArtifactID(artifactID) { + return errors.New("invalid quarantined artifact identity") + } + signature = strings.TrimSpace(signature) + if signature == "" || len(signature) > 4096 { + return errors.New("invalid quarantined update signature") + } + path := filepath.Join(p.quarantineDir, artifactID, "pulse-agent.sig") + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("create quarantined update signature: %w", err) + } + if _, err := file.WriteString(signature + "\n"); err != nil { + _ = file.Close() + return fmt.Errorf("write quarantined update signature: %w", err) + } + if err := file.Sync(); err != nil { + _ = file.Close() + return fmt.Errorf("sync quarantined update signature: %w", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close quarantined update signature: %w", err) + } + return syncUpdateDirectory(filepath.Dir(path)) +} + +func (p *privilegeHelperUpdate) Stage(ctx context.Context, artifactID, digest string) (agenthelper.UpdateStageResult, error) { + var result agenthelper.UpdateStageResult + _, err := p.client.Call(ctx, agenthelper.OperationAgentUpdateStage, agenthelper.OperationVersion1, 30*time.Second, agenthelper.UpdateStageRequest{ArtifactID: artifactID, SHA256: digest}, &result) + return result, err +} + +func (p *privilegeHelperUpdate) Activate(ctx context.Context, artifactID, digest string) (agenthelper.UpdateResult, error) { + var result agenthelper.UpdateResult + _, err := p.client.Call(ctx, agenthelper.OperationAgentUpdateActivate, agenthelper.OperationVersion1, 30*time.Second, agenthelper.UpdateActivateRequest{ArtifactID: artifactID, SHA256: digest}, &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{ + ActivationID: activation.ActivationID, CurrentSHA256: activation.ActiveSHA256, RollbackSHA256: activation.RollbackSHA256, + }, &result) + return result, err +} + +func validateCollectorQuarantineRoot(path string) error { + if path == "" || !filepath.IsAbs(path) || filepath.Clean(path) != path { + return errors.New("collector update quarantine path is invalid") + } + info, err := os.Lstat(path) + if err != nil { + return fmt.Errorf("inspect collector update quarantine: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() || info.Mode().Perm() != 0o700 { + return errors.New("collector update quarantine must be a real 0700 directory") + } + if !collectorQuarantineOwnedByCurrentUID(info) { + return errors.New("collector update quarantine must be owned by the collector UID") + } + return nil +} + +func validPrivilegedArtifactID(value string) bool { + if !strings.HasPrefix(value, "pulse-agent-") || len(value) != len("pulse-agent-")+32 { + return false + } + _, err := hex.DecodeString(strings.TrimPrefix(value, "pulse-agent-")) + return err == nil +} + +func syncUpdateDirectory(path string) error { + dir, err := os.Open(path) + if err != nil { + return err + } + defer dir.Close() + return dir.Sync() +} diff --git a/internal/agentupdate/privileged_update_owner_other.go b/internal/agentupdate/privileged_update_owner_other.go new file mode 100644 index 000000000..9a2316acb --- /dev/null +++ b/internal/agentupdate/privileged_update_owner_other.go @@ -0,0 +1,7 @@ +//go:build !unix + +package agentupdate + +import "os" + +func collectorQuarantineOwnedByCurrentUID(os.FileInfo) bool { return false } diff --git a/internal/agentupdate/privileged_update_owner_unix.go b/internal/agentupdate/privileged_update_owner_unix.go new file mode 100644 index 000000000..ecf7309c7 --- /dev/null +++ b/internal/agentupdate/privileged_update_owner_unix.go @@ -0,0 +1,13 @@ +//go:build unix + +package agentupdate + +import ( + "os" + "syscall" +) + +func collectorQuarantineOwnedByCurrentUID(info os.FileInfo) bool { + stat, ok := info.Sys().(*syscall.Stat_t) + return ok && stat.Uid == uint32(os.Geteuid()) +} diff --git a/internal/agentupdate/privileged_update_test.go b/internal/agentupdate/privileged_update_test.go new file mode 100644 index 000000000..451da99ef --- /dev/null +++ b/internal/agentupdate/privileged_update_test.go @@ -0,0 +1,109 @@ +package agentupdate + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/agenthelper" +) + +type fakePrivilegedUpdate struct { + root string + events []string + activation agenthelper.UpdateResult +} + +func (f *fakePrivilegedUpdate) CreateQuarantinedArtifact() (string, *os.File, func(), 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 + } + 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 +} + +func (f *fakePrivilegedUpdate) WriteQuarantinedSignature(_ string, signature string) error { + f.events = append(f.events, "signature:"+signature) + return nil +} + +func (f *fakePrivilegedUpdate) Stage(_ context.Context, artifactID, digest string) (agenthelper.UpdateStageResult, error) { + f.events = append(f.events, "stage") + return agenthelper.UpdateStageResult{Action: "staged", ArtifactID: artifactID, SHA256: digest, DurableAt: time.Now()}, nil +} + +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)} + return f.activation, nil +} + +func (f *fakePrivilegedUpdate) Rollback(_ context.Context, activation agenthelper.UpdateResult) (agenthelper.UpdateResult, error) { + f.events = append(f.events, "rollback") + if activation != f.activation { + return agenthelper.UpdateResult{}, errors.New("wrong activation identity") + } + return agenthelper.UpdateResult{Action: "rolled_back", ActivationID: activation.ActivationID, ActiveSHA256: activation.RollbackSHA256, RollbackSHA256: activation.ActiveSHA256}, nil +} + +func TestPrivilegedUpdateStagesActivatesAndRollsBackRestartFailure(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()} + u := New(Config{PrivilegedUpdate: helper, Disabled: true}) + u.selfTestFn = func(_ context.Context, path string) error { + helper.events = append(helper.events, "self-test") + data, err := os.ReadFile(path) + if err != nil || !bytes.Equal(data, binary) { + t.Fatalf("self-test bytes mismatch: err=%v", err) + } + 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(), "update rolled back") { + t.Fatalf("performPrivilegedUpdate error = %v", err) + } + want := []string{"quarantine", "self-test", "signature:signed-update", "stage", "activate", "rollback"} + if !reflect.DeepEqual(helper.events, want) { + t.Fatalf("helper events = %#v, want %#v", helper.events, want) + } +} + +func TestPrivilegedUpdateFailsClosedBeforeActivation(t *testing.T) { + originalGOOS := runtimeGOOS + t.Cleanup(func() { runtimeGOOS = originalGOOS }) + runtimeGOOS = goOSLinux + binary := append([]byte{0x7f, 'E', 'L', 'F'}, []byte("update")...) + helper := &fakePrivilegedUpdate{root: t.TempDir()} + u := New(Config{PrivilegedUpdate: helper, Disabled: true}) + 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) + } +} diff --git a/internal/agentupdate/update.go b/internal/agentupdate/update.go index f4498f3af..84540bfa7 100644 --- a/internal/agentupdate/update.go +++ b/internal/agentupdate/update.go @@ -154,6 +154,11 @@ type Config struct { // Disabled skips all update checks when true Disabled bool + + // PrivilegedUpdate selects the typed helper-backed Linux activation path. + // When configured, update installation never falls back to local executable + // replacement. + PrivilegedUpdate PrivilegedUpdate } // Updater handles automatic updates for Pulse agents. @@ -936,6 +941,9 @@ func (u *Updater) performUpdateWithExecPathForVersion(ctx context.Context, execP // Verify checksum if provided checksumHeader := strings.TrimSpace(resp.Header.Get(checksumSHA256Header)) signatureHeaderValue := strings.TrimSpace(resp.Header.Get(signatureHeader)) + if u.cfg.PrivilegedUpdate != nil { + return u.performPrivilegedUpdate(ctx, execPath, resp.Body, resp.ContentLength, checksumHeader, signatureHeaderValue) + } // Resolve symlinks to get the real path for atomic rename realExecPath, err := evalSymlinksFn(execPath) @@ -1067,6 +1075,96 @@ func (u *Updater) performUpdateWithExecPathForVersion(ctx context.Context, execP return restartProcessFn(execPath) } +func (u *Updater) performPrivilegedUpdate(ctx context.Context, execPath string, body io.Reader, contentLength int64, checksumHeader, signature string) error { + if runtimeGOOS != goOSLinux { + return errors.New("typed privilege-helper updates are supported only on Linux") + } + if checksumHeader == "" { + return fmt.Errorf("server did not provide checksum header (%s); refusing helper-backed update", checksumSHA256Header) + } + if signature == "" { + return fmt.Errorf("server did not provide signature header (%s); refusing helper-backed update", signatureHeader) + } + if contentLength > maxBinarySizeBytes { + return fmt.Errorf("downloaded binary exceeds maximum size (%d bytes)", maxBinarySizeBytes) + } + + artifactID, file, cleanup, err := u.cfg.PrivilegedUpdate.CreateQuarantinedArtifact() + if err != nil { + return fmt.Errorf("prepare fixed update quarantine: %w", err) + } + defer cleanup() + artifactPath := file.Name() + hasher := sha256.New() + written, copyErr := io.Copy(file, io.TeeReader(io.LimitReader(body, maxBinarySizeBytes+1), hasher)) + if copyErr != nil { + _ = file.Close() + return fmt.Errorf("write quarantined agent binary: %w", copyErr) + } + if written > maxBinarySizeBytes { + _ = file.Close() + return fmt.Errorf("downloaded binary exceeds maximum size (%d bytes)", maxBinarySizeBytes) + } + if err := file.Sync(); err != nil { + _ = file.Close() + return fmt.Errorf("sync quarantined agent binary: %w", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close quarantined agent binary: %w", err) + } + digest := hex.EncodeToString(hasher.Sum(nil)) + if !strings.EqualFold(strings.TrimSpace(checksumHeader), digest) { + return fmt.Errorf("checksum mismatch: expected %s, got %s", strings.ToLower(strings.TrimSpace(checksumHeader)), digest) + } + if err := verifyBinaryMagic(artifactPath); err != nil { + return fmt.Errorf("downloaded file is not a valid executable: %w", err) + } + if updatesignature.HasTrustedPublicKeys() { + if err := updatesignature.VerifyFile(artifactPath, signature); err != nil { + return fmt.Errorf("signature verification failed: %w", err) + } + } + if err := chmodFn(artifactPath, 0o700); err != nil { + return fmt.Errorf("make quarantined binary executable for self-test: %w", err) + } + if err := u.selfTestFn(ctx, artifactPath); err != nil { + return err + } + if err := chmodFn(artifactPath, 0o600); err != nil { + return fmt.Errorf("restore quarantined binary mode: %w", err) + } + if err := u.cfg.PrivilegedUpdate.WriteQuarantinedSignature(artifactID, signature); err != nil { + return err + } + staged, err := u.cfg.PrivilegedUpdate.Stage(ctx, artifactID, digest) + if err != nil { + return fmt.Errorf("stage signed update through typed helper: %w", err) + } + if staged.Action != "staged" || staged.ArtifactID != artifactID || !strings.EqualFold(staged.SHA256, digest) { + return errors.New("typed helper returned an invalid staging result") + } + activation, err := u.cfg.PrivilegedUpdate.Activate(ctx, artifactID, digest) + 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 { + 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)) + } + 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 fmt.Errorf("failed to restart after helper activation; update rolled back: %w", err) + } + return nil +} + func (u *Updater) syncPersistentBinaryCopy(execPath, persistPath, platform string) { u.logger.Debug().Str("path", persistPath).Str("platform", platform).Msg("updating persistent binary") diff --git a/internal/api/action_runner_credentials.go b/internal/api/action_runner_credentials.go new file mode 100644 index 000000000..07406aec3 --- /dev/null +++ b/internal/api/action_runner_credentials.go @@ -0,0 +1,122 @@ +package api + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + "github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" + "github.com/rcourtman/pulse-go-rewrite/pkg/auth" +) + +const maxActionRunnerCredentialRequestBytes int64 = 16 << 10 + +type actionRunnerCredentialRequest struct { + AgentID string `json:"agentId"` + Hostname string `json:"hostname"` + Name string `json:"name,omitempty"` +} + +type actionRunnerCredentialResponse struct { + Token string `json:"token"` + TokenID string `json:"tokenId"` + OrganizationID string `json:"organizationId"` + AgentID string `json:"agentId"` + Hostname string `json:"hostname"` + RuntimeRole string `json:"runtimeRole"` + ActionCapability string `json:"actionCapability"` +} + +// 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 +// authority. +func (r *Router) handleIssueActionRunnerCredential(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + 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 + } + + decoder := json.NewDecoder(io.LimitReader(req.Body, maxActionRunnerCredentialRequestBytes+1)) + decoder.DisallowUnknownFields() + var payload actionRunnerCredentialRequest + 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())) + canonicalAgentID, canonicalHostname, found := r.resolveActionRunnerHostIdentity(req, payload.AgentID, payload.Hostname) + if !found { + http.Error(w, "Canonical monitored host identity not found", http.StatusNotFound) + return + } + rawToken, record, err := agenttokens.IssueActionRunnerAndPersist(r.config, r.persistence, agenttokens.ActionRunnerIssueOptions{ + TokenName: payload.Name, + OrgID: organizationID, + OwnerUserID: apiTokenOwnerUserIDForRequest(r.config, req), + AgentID: canonicalAgentID, + Hostname: canonicalHostname, + }) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, agenttokens.ErrRecord) { + status = http.StatusBadRequest + } + http.Error(w, "Failed to issue action runner credential", status) + return + } + + 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("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(actionRunnerCredentialResponse{ + Token: rawToken, + TokenID: record.ID, + OrganizationID: record.OrgID, + AgentID: record.Metadata["bound_agent_id"], + Hostname: record.Metadata["bound_hostname"], + RuntimeRole: record.Metadata[agenttokens.RuntimeRoleMetadataKey], + ActionCapability: 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) + if r == nil || r.unifiedAgentHandlers == nil || req == nil || requestedID == "" || requestedHostname == "" { + return "", "", false + } + monitor := r.unifiedAgentHandlers.getMonitor(req.Context()) + if monitor == nil { + return "", "", false + } + var matchedID, matchedHostname string + matches := 0 + for _, host := range monitor.GetLiveHostsSnapshot() { + if strings.TrimSpace(host.ID) != requestedID || + !unifiedresources.HostnamesEquivalent(host.Hostname, requestedHostname) || + strings.TrimSpace(host.IntegrationSource) != "" || host.IdentityConflict != nil { + continue + } + matchedID = strings.TrimSpace(host.ID) + matchedHostname = unifiedresources.NormalizeFullHostname(host.Hostname) + matches++ + } + if matches != 1 || matchedID == "" || matchedHostname == "" { + return "", "", false + } + return matchedID, matchedHostname, true +} diff --git a/internal/api/action_runner_credentials_test.go b/internal/api/action_runner_credentials_test.go new file mode 100644 index 000000000..437349202 --- /dev/null +++ b/internal/api/action_runner_credentials_test.go @@ -0,0 +1,138 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens" + "github.com/rcourtman/pulse-go-rewrite/internal/config" +) + +func newActionRunnerCredentialTestRouter(t *testing.T) (*Router, *config.Config, string) { + t.Helper() + cfg := &config.Config{DataPath: t.TempDir(), AuthUser: "admin", AuthPass: "$2a$10$dummy"} + handlers, monitor := newUnifiedAgentHandlers(t, cfg) + hostID := seedUnifiedAgentHost(t, monitor) + return &Router{ + config: cfg, + persistence: config.NewConfigPersistence(cfg.DataPath), + unifiedAgentHandlers: handlers, + }, cfg, hostID +} + +func actionRunnerCredentialBody(hostID, hostname string) *bytes.Reader { + body, _ := json.Marshal(actionRunnerCredentialRequest{AgentID: hostID, Hostname: hostname}) + return bytes.NewReader(body) +} + +func TestIssueActionRunnerCredentialResolvesCanonicalMonitoredHost(t *testing.T) { + router, cfg, hostID := newActionRunnerCredentialTestRouter(t) + req := httptest.NewRequest(http.MethodPost, "/api/agents/action-runner/credential", actionRunnerCredentialBody(hostID, "HOST-1")) + rec := httptest.NewRecorder() + router.handleIssueActionRunnerCredential(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + var response actionRunnerCredentialResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.Token == "" || response.TokenID == "" || response.AgentID != hostID || response.Hostname != "host-1.local" { + t.Fatalf("response = %#v", response) + } + if response.RuntimeRole != agenttokens.CredentialKindActionRunner || response.ActionCapability != agenttokens.ActionCapabilityTypedV1 { + t.Fatalf("response authority = %#v", response) + } + if len(cfg.APITokens) != 1 || cfg.APITokens[0].HasScope(config.ScopeAgentReport) || !cfg.APITokens[0].HasScope(config.ScopeAgentExec) { + t.Fatalf("persisted action credential = %#v", cfg.APITokens) + } +} + +func TestIssueActionRunnerCredentialRejectsUnknownOrMismatchedHost(t *testing.T) { + for _, tc := range []struct { + name string + agentID string + hostname string + }{ + {name: "unknown id", agentID: "missing", hostname: "host-1.local"}, + {name: "mismatched hostname", agentID: "machine-1", hostname: "other.local"}, + } { + t.Run(tc.name, func(t *testing.T) { + router, cfg, hostID := newActionRunnerCredentialTestRouter(t) + agentID := tc.agentID + if agentID == "machine-1" { + agentID = hostID + } + req := httptest.NewRequest(http.MethodPost, "/api/agents/action-runner/credential", actionRunnerCredentialBody(agentID, tc.hostname)) + rec := httptest.NewRecorder() + router.handleIssueActionRunnerCredential(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + if len(cfg.APITokens) != 0 { + t.Fatalf("rejected identity minted tokens: %#v", cfg.APITokens) + } + }) + } +} + +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))) + req := httptest.NewRequest(http.MethodPost, "/api/agents/action-runner/credential", actionRunnerCredentialBody(hostID, "host-1.local")) + switch mode { + case "api-token": + raw := "runner-issuer-token-1234567890.12345678" + record, err := config.NewAPITokenRecord(raw, "runner issuer", []string{config.ScopeSettingsWrite, config.ScopeActionsExecute}) + if err != nil { + t.Fatal(err) + } + cfg.APITokens = append(cfg.APITokens, *record) + req.Header.Set("Authorization", "Bearer "+raw) + case "admin-session": + resetSessionStoreForTests() + t.Cleanup(resetSessionStoreForTests) + InitSessionStore(t.TempDir()) + sessionToken := generateSessionToken() + GetSessionStore().CreateSession(sessionToken, time.Hour, "browser", "127.0.0.1", "admin") + req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken}) + } + rec := httptest.NewRecorder() + handler(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + }) + } +} + +func TestActionRunnerCredentialCannotUseCollectorReportOrConfigScopes(t *testing.T) { + router, cfg, hostID := newActionRunnerCredentialTestRouter(t) + req := httptest.NewRequest(http.MethodPost, "/api/agents/action-runner/credential", actionRunnerCredentialBody(hostID, "host-1.local")) + 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 issued actionRunnerCredentialResponse + if err := json.NewDecoder(rec.Body).Decode(&issued); err != nil { + t.Fatal(err) + } + for _, scope := range []string{config.ScopeAgentReport, config.ScopeAgentConfigRead} { + called := false + handler := RequireAuth(cfg, RequireScope(scope, func(http.ResponseWriter, *http.Request) { called = true })) + authReq := httptest.NewRequest(http.MethodGet, "/api/agents/agent/test", nil) + authReq.Header.Set("Authorization", "Bearer "+issued.Token) + authRec := httptest.NewRecorder() + handler(authRec, authReq) + if called || authRec.Code != http.StatusForbidden { + t.Fatalf("action credential scope %q = called=%v status=%d body=%s", scope, called, authRec.Code, authRec.Body.String()) + } + } +} diff --git a/internal/api/agent_exec_token_binding.go b/internal/api/agent_exec_token_binding.go index 9a5fec090..765b67746 100644 --- a/internal/api/agent_exec_token_binding.go +++ b/internal/api/agent_exec_token_binding.go @@ -6,6 +6,7 @@ import ( "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/rs/zerolog/log" ) @@ -130,6 +131,40 @@ func (r *Router) admitAgentExecToken(token string, agentID string, hostname stri if len(orgs) == 1 && strings.TrimSpace(orgs[0]) != "" { organizationID = strings.TrimSpace(orgs[0]) } + runtimeRole := strings.TrimSpace(record.Metadata[agenttokens.RuntimeRoleMetadataKey]) + if runtimeRole == agenttokens.CredentialKindMonitoringCollector { + config.Mu.Unlock() + log.Warn().Str("token_id", tokenID).Msg("Monitoring collector credential rejected by action listener") + return agentexec.AgentAdmission{}, false + } + if runtimeRole == agenttokens.CredentialKindActionRunner { + if len(orgs) != 1 { + config.Mu.Unlock() + log.Warn().Str("token_id", tokenID).Msg("Action runner credential requires one explicit organization binding") + return agentexec.AgentAdmission{}, false + } + decision := agentbinding.EvaluateActionRunner(record, requestedID, requestedHost) + if !decision.Admit { + config.Mu.Unlock() + log.Warn().Str("token_id", tokenID).Msg("Action runner credential binding or capability mismatch") + return agentexec.AgentAdmission{}, false + } + capability := strings.TrimSpace(record.Metadata[agenttokens.ActionCapabilityMetadataKey]) + config.Mu.Unlock() + return agentexec.AgentAdmission{ + OrganizationID: organizationID, + TokenID: tokenID, + AgentID: requestedID, + Hostname: requestedHost, + RuntimeRole: agentexec.RuntimeRoleActionRunner, + ActionCapability: capability, + }, true + } + if runtimeRole != "" && runtimeRole != agenttokens.CredentialKindLegacyFullTrust { + config.Mu.Unlock() + log.Warn().Str("token_id", tokenID).Str("runtime_role", runtimeRole).Msg("Agent exec token has unsupported runtime role") + return agentexec.AgentAdmission{}, false + } boundID := strings.TrimSpace(record.Metadata["bound_agent_id"]) boundHost := strings.TrimSpace(record.Metadata["bound_hostname"]) @@ -148,6 +183,7 @@ func (r *Router) admitAgentExecToken(token string, agentID string, hostname stri "bound_hostname", "bound_at", agentExecBindingVersionKey, + agenttokens.RuntimeRoleMetadataKey, ) record.Metadata["bound_agent_id"] = requestedID // A bound_hostname already written by the Proxmox auto-register @@ -159,6 +195,7 @@ func (r *Router) admitAgentExecToken(token string, agentID string, hostname stri } record.Metadata["bound_at"] = time.Now().UTC().Format(time.RFC3339) record.Metadata[agentExecBindingVersionKey] = agentExecBindingVersion + record.Metadata[agenttokens.RuntimeRoleMetadataKey] = agenttokens.CredentialKindLegacyFullTrust if r.persistence != nil { if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil { restoreAgentExecMetadata(record.Metadata, previousMetadata) @@ -184,6 +221,7 @@ func (r *Router) admitAgentExecToken(token string, agentID string, hostname stri TokenID: tokenID, AgentID: requestedID, Hostname: requestedHost, + RuntimeRole: agentexec.RuntimeRoleLegacyFullTrust, }, true case decision.legacyMigrate: @@ -193,10 +231,12 @@ func (r *Router) admitAgentExecToken(token string, agentID string, hostname stri "bound_agent_id", "bound_at", agentExecBindingVersionKey, + agenttokens.RuntimeRoleMetadataKey, ) record.Metadata["bound_agent_id"] = requestedID record.Metadata["bound_at"] = time.Now().UTC().Format(time.RFC3339) record.Metadata[agentExecBindingVersionKey] = agentExecBindingVersion + record.Metadata[agenttokens.RuntimeRoleMetadataKey] = agenttokens.CredentialKindLegacyFullTrust if r.persistence != nil { if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil { restoreAgentExecMetadata(record.Metadata, previousMetadata) @@ -220,6 +260,7 @@ func (r *Router) admitAgentExecToken(token string, agentID string, hostname stri TokenID: tokenID, AgentID: requestedID, Hostname: requestedHost, + RuntimeRole: agentexec.RuntimeRoleLegacyFullTrust, }, true case decision.admit: @@ -230,8 +271,12 @@ func (r *Router) admitAgentExecToken(token string, agentID string, hostname stri "bound_hostname", "bound_at", agentExecBindingVersionKey, + agenttokens.RuntimeRoleMetadataKey, ) - metadataChanged := false + metadataChanged := runtimeRole == "" + if runtimeRole == "" { + record.Metadata[agenttokens.RuntimeRoleMetadataKey] = agenttokens.CredentialKindLegacyFullTrust + } if decision.backfillID { record.Metadata["bound_agent_id"] = requestedID boundID = requestedID @@ -271,6 +316,7 @@ func (r *Router) admitAgentExecToken(token string, agentID string, hostname stri TokenID: tokenID, AgentID: requestedID, Hostname: requestedHost, + RuntimeRole: agentexec.RuntimeRoleLegacyFullTrust, }, true } @@ -317,7 +363,19 @@ func (r *Router) validateAgentExecSession(admission agentexec.AgentAdmission) bo if len(orgs) == 1 && strings.TrimSpace(orgs[0]) != "" { organizationID = strings.TrimSpace(orgs[0]) } - return organizationID == strings.TrimSpace(admission.OrganizationID) && + runtimeRole := strings.TrimSpace(record.Metadata[agenttokens.RuntimeRoleMetadataKey]) + if runtimeRole == agenttokens.CredentialKindActionRunner { + return len(orgs) == 1 && + strings.TrimSpace(admission.RuntimeRole) == agentexec.RuntimeRoleActionRunner && + strings.TrimSpace(admission.ActionCapability) == agentexec.ActionCapabilityTypedV1 && + organizationID == strings.TrimSpace(admission.OrganizationID) && + agentbinding.EvaluateActionRunner(record, requestedID, requestedHost).Admit + } + if runtimeRole != agenttokens.CredentialKindLegacyFullTrust { + return false + } + return strings.TrimSpace(admission.RuntimeRole) == agentexec.RuntimeRoleLegacyFullTrust && + organizationID == strings.TrimSpace(admission.OrganizationID) && strings.TrimSpace(record.Metadata["bound_agent_id"]) == requestedID && agentExecHostnamesMatch(strings.TrimSpace(record.Metadata["bound_hostname"]), requestedHost) } diff --git a/internal/api/agentbinding/policy.go b/internal/api/agentbinding/policy.go index e686d5ec4..b1aab351f 100644 --- a/internal/api/agentbinding/policy.go +++ b/internal/api/agentbinding/policy.go @@ -4,6 +4,7 @@ package agentbinding import ( "strings" + "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" ) @@ -24,6 +25,28 @@ type Decision struct { BackfillHost bool } +// EvaluateActionRunner admits only a pre-bound, typed action credential whose +// tenant-independent host identity exactly matches the registering runner. +// Action credentials are never first-use rebound or legacy-migrated. +func EvaluateActionRunner(record *config.APITokenRecord, requestedID, requestedHost string) Decision { + if record == nil || !record.HasScope(config.ScopeAgentExec) { + return Decision{} + } + if strings.TrimSpace(record.Metadata[agenttokens.RuntimeRoleMetadataKey]) != agenttokens.CredentialKindActionRunner || + strings.TrimSpace(record.Metadata[agenttokens.ActionCapabilityMetadataKey]) != agenttokens.ActionCapabilityTypedV1 || + strings.TrimSpace(record.Metadata[agenttokens.ActionBindingVersionMetadataKey]) != agenttokens.ActionBindingVersion { + return Decision{} + } + boundID := strings.TrimSpace(record.Metadata["bound_agent_id"]) + boundHost := strings.TrimSpace(record.Metadata["bound_hostname"]) + requestedID = strings.TrimSpace(requestedID) + requestedHost = strings.TrimSpace(requestedHost) + if boundID == "" || boundHost == "" || requestedID == "" || requestedHost == "" { + return Decision{} + } + return Decision{Admit: boundID == requestedID && hostnamesMatch(boundHost, requestedHost)} +} + func Evaluate(record *config.APITokenRecord, requestedID, requestedHost string) Decision { if record == nil { return Decision{} diff --git a/internal/api/agentbinding/policy_test.go b/internal/api/agentbinding/policy_test.go index c70dba2ff..9a2aa11d3 100644 --- a/internal/api/agentbinding/policy_test.go +++ b/internal/api/agentbinding/policy_test.go @@ -3,6 +3,7 @@ package agentbinding import ( "testing" + "github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens" "github.com/rcourtman/pulse-go-rewrite/internal/config" ) @@ -37,3 +38,39 @@ func TestCanBindInstallTokenRejectsUnsupportedIssuer(t *testing.T) { t.Fatal("unsupported issuer admitted") } } + +func TestEvaluateActionRunnerRequiresExactTypedPrebinding(t *testing.T) { + record := &config.APITokenRecord{ + OrgID: "org-a", + Scopes: []string{config.ScopeAgentExec}, + Metadata: map[string]string{ + agenttokens.RuntimeRoleMetadataKey: agenttokens.CredentialKindActionRunner, + agenttokens.ActionCapabilityMetadataKey: agenttokens.ActionCapabilityTypedV1, + agenttokens.ActionBindingVersionMetadataKey: agenttokens.ActionBindingVersion, + "bound_agent_id": "machine-a", + "bound_hostname": "node.example", + }, + } + if decision := EvaluateActionRunner(record, "machine-a", "NODE"); !decision.Admit || decision.FirstBind || decision.LegacyMigrate { + t.Fatalf("typed action runner decision = %+v", decision) + } + for _, mutate := range []func(map[string]string){ + func(metadata map[string]string) { + metadata[agenttokens.RuntimeRoleMetadataKey] = agenttokens.CredentialKindMonitoringCollector + }, + func(metadata map[string]string) { metadata[agenttokens.ActionCapabilityMetadataKey] = "shell.v1" }, + func(metadata map[string]string) { metadata[agenttokens.ActionBindingVersionMetadataKey] = "2" }, + func(metadata map[string]string) { metadata["bound_agent_id"] = "other" }, + func(metadata map[string]string) { metadata["bound_hostname"] = "other.example" }, + } { + clone := record.Clone() + clone.Metadata = make(map[string]string, len(record.Metadata)) + for key, value := range record.Metadata { + clone.Metadata[key] = value + } + mutate(clone.Metadata) + if decision := EvaluateActionRunner(&clone, "machine-a", "node.example"); decision.Admit { + t.Fatalf("mismatched action runner admitted: metadata=%#v decision=%+v", clone.Metadata, decision) + } + } +} diff --git a/internal/api/agenttokens/install.go b/internal/api/agenttokens/install.go index d18f0effd..57b40f7f5 100644 --- a/internal/api/agenttokens/install.go +++ b/internal/api/agenttokens/install.go @@ -8,6 +8,7 @@ import ( "time" "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth" ) @@ -18,6 +19,15 @@ const ( CommandPolicyAppliedAgentIDMetadataKey = "command_policy_applied_agent_id" CommandPolicyIntentEnabled = "enabled" CommandPolicyIntentDisabled = "disabled" + RuntimeRoleMetadataKey = "runtime_role" + CredentialKindMetadataKey = RuntimeRoleMetadataKey + CredentialKindMonitoringCollector = "monitoring-collector" + CredentialKindActionRunner = "action-runner" + CredentialKindLegacyFullTrust = "legacy-full-trust" + ActionCapabilityMetadataKey = "agent_action_capability" + ActionCapabilityTypedV1 = "typed_actions.v1" + ActionBindingVersionMetadataKey = "action_runner_binding_version" + ActionBindingVersion = "1" ) var ( @@ -34,6 +44,17 @@ type IssueOptions struct { Scopes []string } +// ActionRunnerIssueOptions binds a separately issued remediation credential to +// one tenant and one canonical host identity. The credential is intentionally +// unusable for collector report and configuration endpoints. +type ActionRunnerIssueOptions struct { + TokenName string + OrgID string + OwnerUserID string + AgentID string + Hostname string +} + func ProxmoxScopes(enableCommands bool) []string { scopes := []string{ config.ScopeAgentReport, @@ -60,6 +81,12 @@ func HostScopes(enableCommands bool) []string { return scopes } +// ActionRunnerScopes is the complete authority granted to the separate action +// runtime. Monitoring scopes must be added only by a distinct, explicit grant. +func ActionRunnerScopes() []string { + return []string{config.ScopeAgentExec} +} + func CommandPolicyIntent(enableCommands bool) string { if enableCommands { return CommandPolicyIntentEnabled @@ -111,6 +138,9 @@ func IssueAndPersist(cfg *config.Config, persistence *config.ConfigPersistence, if record.Metadata == nil { record.Metadata = make(map[string]string) } + if err := normalizeCredentialKind(record); err != nil { + return "", nil, fmt.Errorf("%w: %w", ErrRecord, err) + } record.Metadata[IssuedAtMetadataKey] = record.CreatedAt.UTC().Format(time.RFC3339) config.Mu.Lock() @@ -133,6 +163,82 @@ func IssueAndPersist(cfg *config.Config, persistence *config.ConfigPersistence, return rawToken, record, 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) { + 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) + } + if agentID == "" { + return "", nil, fmt.Errorf("%w: canonical agent id is required", ErrRecord) + } + if hostname == "" { + return "", nil, 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) + } + + tokenName := strings.TrimSpace(opts.TokenName) + if tokenName == "" { + tokenName = "action-runner:" + hostname + } + return IssueAndPersist(cfg, persistence, IssueOptions{ + TokenName: tokenName, + OrgID: organizationID, + OwnerUserID: opts.OwnerUserID, + Scopes: ActionRunnerScopes(), + Metadata: map[string]string{ + CredentialKindMetadataKey: CredentialKindActionRunner, + ActionCapabilityMetadataKey: ActionCapabilityTypedV1, + ActionBindingVersionMetadataKey: ActionBindingVersion, + "bound_agent_id": agentID, + "bound_hostname": hostname, + "bound_at": time.Now().UTC().Format(time.RFC3339), + }, + }) +} + +func normalizeCredentialKind(record *config.APITokenRecord) error { + if record == nil { + return nil + } + kind := strings.TrimSpace(record.Metadata[CredentialKindMetadataKey]) + hasExec := record.HasScope(config.ScopeAgentExec) + switch kind { + case "": + if hasExec { + // Existing combined collector/command issuance remains available only + // as an explicit compatibility class while deployments migrate. + record.Metadata[CredentialKindMetadataKey] = CredentialKindLegacyFullTrust + } else { + record.Metadata[CredentialKindMetadataKey] = CredentialKindMonitoringCollector + } + case CredentialKindMonitoringCollector: + if hasExec { + return errors.New("monitoring collector credential cannot carry agent:exec") + } + case CredentialKindActionRunner: + if !hasExec { + return errors.New("action runner credential requires agent:exec") + } + if strings.TrimSpace(record.Metadata[ActionCapabilityMetadataKey]) != ActionCapabilityTypedV1 { + return errors.New("action runner credential requires the typed action capability") + } + case CredentialKindLegacyFullTrust: + if !hasExec { + return errors.New("legacy full-trust credential requires agent:exec") + } + default: + return fmt.Errorf("unsupported agent credential kind %q", kind) + } + return nil +} + func OwnerUserID(record config.APITokenRecord) string { return strings.TrimSpace(record.Metadata[OwnerUserIDMetadataKey]) } diff --git a/internal/api/agenttokens/install_test.go b/internal/api/agenttokens/install_test.go index 6cbfb6eae..3a121863c 100644 --- a/internal/api/agenttokens/install_test.go +++ b/internal/api/agenttokens/install_test.go @@ -30,6 +30,59 @@ func TestIssueAndPersistInstallToken(t *testing.T) { if !record.HasScope(config.ScopeAgentExec) { t.Fatalf("commands-enabled host scopes = %v", record.Scopes) } + if got := record.Metadata[RuntimeRoleMetadataKey]; got != CredentialKindLegacyFullTrust { + t.Fatalf("combined install runtime role = %q, want %q", got, CredentialKindLegacyFullTrust) + } +} + +func TestIssueActionRunnerAndPersistIsHostBoundAndExecOnly(t *testing.T) { + cfg := &config.Config{DataPath: t.TempDir()} + raw, record, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{ + OrgID: "org-a", AgentID: "machine-123", Hostname: " Node.EXAMPLE. ", OwnerUserID: "operator", + }) + if err != nil { + t.Fatalf("IssueActionRunnerAndPersist: %v", err) + } + if raw == "" || record == nil { + t.Fatalf("issued action credential = (%q, %#v)", raw, record) + } + if len(record.Scopes) != 1 || record.Scopes[0] != config.ScopeAgentExec { + t.Fatalf("action credential scopes = %v, want only %q", record.Scopes, config.ScopeAgentExec) + } + if record.HasScope(config.ScopeAgentReport) || record.HasScope(config.ScopeAgentConfigRead) || record.HasScope(config.ScopeAgentManage) { + t.Fatalf("action credential inherited collector authority: %v", record.Scopes) + } + if record.OrgID != "org-a" || record.Metadata["bound_agent_id"] != "machine-123" || record.Metadata["bound_hostname"] != "node.example" { + t.Fatalf("action credential binding = org=%q metadata=%#v", record.OrgID, record.Metadata) + } + if record.Metadata[RuntimeRoleMetadataKey] != CredentialKindActionRunner || + record.Metadata[ActionCapabilityMetadataKey] != ActionCapabilityTypedV1 || + record.Metadata[ActionBindingVersionMetadataKey] != ActionBindingVersion { + t.Fatalf("action credential authority metadata = %#v", record.Metadata) + } +} + +func TestIssueActionRunnerAndPersistRejectsIncompleteBinding(t *testing.T) { + for _, tc := range []ActionRunnerIssueOptions{ + {AgentID: "machine", Hostname: "node"}, + {OrgID: "org", Hostname: "node"}, + {OrgID: "org", AgentID: "machine"}, + } { + if raw, record, err := IssueActionRunnerAndPersist(&config.Config{}, nil, tc); !errors.Is(err, ErrRecord) || raw != "" || record != nil { + t.Fatalf("incomplete binding %#v = (%q, %#v, %v), want ErrRecord", tc, raw, record, err) + } + } +} + +func TestIssueAndPersistRejectsMonitoringRoleWithExecAuthority(t *testing.T) { + _, _, err := IssueAndPersist(&config.Config{}, nil, IssueOptions{ + TokenName: "invalid", + Scopes: []string{config.ScopeAgentReport, config.ScopeAgentExec}, + Metadata: map[string]string{RuntimeRoleMetadataKey: CredentialKindMonitoringCollector}, + }) + if !errors.Is(err, ErrRecord) { + t.Fatalf("monitoring role with exec authority error = %v, want ErrRecord", err) + } } func TestProxmoxScopesRequireExplicitCommandAuthority(t *testing.T) { diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index d75cea0cb..aa2527ddd 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -10255,6 +10255,40 @@ func TestContract_AgentHelperDownloadIsLinuxOnlyAndSeparatelySigned(t *testing.T } } +func TestContract_AgentRunnerDownloadIsLinuxOnlyAndSeparatelySigned(t *testing.T) { + tempDir := t.TempDir() + binDir := filepath.Join(tempDir, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + binaryPath := filepath.Join(binDir, "pulse-agent-runner-linux-amd64") + if err := os.WriteFile(binaryPath, []byte("typed-runner"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(binaryPath+".sig", []byte("detached-signature"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(binaryPath+".sshsig", []byte("ssh-signature"), 0o644); err != nil { + t.Fatal(err) + } + router := &Router{projectRoot: tempDir, serverVersion: "6.5.0"} + request := httptest.NewRequest(http.MethodGet, "/download/pulse-agent-runner?arch=linux-amd64", nil) + response := httptest.NewRecorder() + router.handleDownloadAgentRunner(response, request) + if response.Code != http.StatusOK { + t.Fatalf("runner download status = %d: %s", response.Code, response.Body.String()) + } + if response.Header().Get(checksumHeaderName) == "" || response.Header().Get(sshSignatureHeaderName) == "" { + t.Fatalf("runner download omitted signed-asset headers: %#v", response.Header()) + } + request = httptest.NewRequest(http.MethodGet, "/download/pulse-agent-runner?arch=windows-amd64", nil) + response = httptest.NewRecorder() + router.handleDownloadAgentRunner(response, request) + if response.Code != http.StatusBadRequest { + t.Fatalf("non-Linux runner status = %d, want %d", response.Code, http.StatusBadRequest) + } +} + func TestContract_SystemSettingsResponseJSONSnapshot(t *testing.T) { payload := EmptySystemSettingsResponse() payload.SystemSettings = config.SystemSettings{ diff --git a/internal/api/proxmox_guest_action_executor.go b/internal/api/proxmox_guest_action_executor.go index 6587e4edb..c778076bf 100644 --- a/internal/api/proxmox_guest_action_executor.go +++ b/internal/api/proxmox_guest_action_executor.go @@ -9,6 +9,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/actionlifecycle" "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" + "github.com/rcourtman/pulse-go-rewrite/internal/operationreceipt" unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" ) @@ -52,6 +53,10 @@ type proxmoxGuestPostconditionObserver interface { ObserveProxmoxGuest(context.Context, string, string, string, int, proxmoxGuestKind) (proxmoxGuestPostconditionObservation, error) } +type proxmoxGuestLifecycleAgentCommander interface { + ExecuteProxmoxGuestLifecycle(context.Context, string, agentexec.ProxmoxGuestLifecyclePayload) (*agentexec.ProxmoxGuestLifecycleResultPayload, error) +} + func newProxmoxGuestActionExecutor(resources *ResourceHandlers, agents actionAgentCommander, observer proxmoxGuestPostconditionObserver) ActionExecutor { if resources == nil || agents == nil { return nil @@ -59,10 +64,34 @@ func newProxmoxGuestActionExecutor(resources *ResourceHandlers, agents actionAge return proxmoxGuestActionExecutor{resources: resources, agents: agents, observer: observer} } +func (e proxmoxGuestActionExecutor) BindActionDispatch(ctx context.Context, record unified.ActionAuditRecord, attempt unified.ActionDispatchAttempt) (unified.ActionDispatchAttempt, error) { + record, err := unified.NormalizeActionAuditRecord(record) + if err != nil { + return unified.ActionDispatchAttempt{}, err + } + resource, kind, err := e.currentProxmoxGuestResource(ctx, record.Request.ResourceID, record.Request.CapabilityName) + if err != nil { + return unified.ActionDispatchAttempt{}, err + } + agentID, err := e.connectedProxmoxNodeCommandAgentID(ctx, resource) + if err != nil { + return unified.ActionDispatchAttempt{}, err + } + request, err := proxmoxGuestLifecycleRequest(attempt.ID, record.ID, string(kind), record.Request.CapabilityName, resource) + if err != nil { + return unified.ActionDispatchAttempt{}, err + } + return unified.BindActionDispatchAttempt(attempt, unified.ActionDispatchBinding{OperationKind: request.Operation, OperationVersion: request.OperationVersion, RequestDigest: request.RequestDigest, AgentID: agentID}) +} + func (e proxmoxGuestActionExecutor) ActionHandlerNames() []string { return []string{proxmoxVMLifecycleHandler, proxmoxCTLifecycleHandler} } +func (e proxmoxGuestActionExecutor) ActionDispatchOperationKinds() []string { + return []string{"start", "stop", "shutdown", "reboot"} +} + func (e proxmoxGuestActionExecutor) ExecuteAction(ctx context.Context, record unified.ActionAuditRecord) (*unified.ExecutionResult, error) { attempt, ok := actionlifecycle.DispatchAttemptFromContext(ctx) if !ok || attempt.ActionID != record.ID { @@ -93,8 +122,46 @@ func (e proxmoxGuestActionExecutor) ExecuteAction(ctx context.Context, record un } } - command := proxmoxGuestLifecycleCommand(kind, operation, vmid) actionStartedAt := time.Now().UTC() + if typedAgents, ok := e.agents.(proxmoxGuestLifecycleAgentCommander); ok { + request, err := proxmoxGuestLifecycleRequest(attempt.ID, record.ID, string(kind), operation, resource) + if err != nil { + return nil, err + } + if attempt.OperationKind != "" && agentexec.ProxmoxGuestLifecycleOperationIdentity(agentID, request) != (operationreceipt.Identity{AttemptID: attempt.ID, ActionID: attempt.ActionID, OperationKind: attempt.OperationKind, OperationVersion: attempt.OperationVersion, RequestDigest: attempt.RequestDigest, AgentID: attempt.AgentID}) { + return nil, fmt.Errorf("Proxmox guest lifecycle dispatch binding drift") + } + result, err := typedAgents.ExecuteProxmoxGuestLifecycle(agentCommandContext(ctx), agentID, request) + if err != nil { + return nil, err + } + if result == nil { + return nil, fmt.Errorf("typed Proxmox guest lifecycle returned no result") + } + if err := agentexec.ValidateProxmoxGuestLifecycleResultForRequest(request, *result); err != nil { + return nil, fmt.Errorf("invalid typed Proxmox guest lifecycle result: %w", err) + } + succeeded := result.ExecutionPhase == agentexec.ProxmoxGuestPhaseComplete && result.MutationCompleted && result.Error == "" + exitCode := 0 + if !succeeded { + exitCode = 1 + } + output := "" + if result.ReadbackRan { + output = "status: " + result.After.Status + } + agentVerification := &unified.ActionVerificationResult{ + Ran: result.ReadbackRan, Command: proxmoxGuestStatusCommand(kind, vmid), Output: output, + Success: succeeded, RanAt: result.After.ObservedAt, + } + independentAfter, independentEvaluation := e.observeProxmoxGuestPostcondition(ctx, record.Request.ResourceID, resource, kind, operation, independentBefore, actionStartedAt) + return proxmoxGuestExecutionResult(record.ID, record.Request.ResourceID, agentID, kind, operation, exitCode, output, result.Error, agentVerification, independentBefore, independentAfter, independentEvaluation, actionStartedAt) + } + + // Legacy full-trust sessions retain the historical command boundary during + // migration. Typed action-runner sessions can never enter this fallback: + // the server rejects execute_command for that runtime role. + command := proxmoxGuestLifecycleCommand(kind, operation, vmid) result, err := e.agents.ExecuteCommand(agentCommandContext(ctx), agentID, agentexec.ExecuteCommandPayload{ RequestID: attempt.ID, Command: command, @@ -117,6 +184,32 @@ func (e proxmoxGuestActionExecutor) ExecuteAction(ctx context.Context, record un return proxmoxGuestExecutionResult(record.ID, record.Request.ResourceID, agentID, kind, operation, result.ExitCode, output, result.Error, agentVerification, independentBefore, independentAfter, independentEvaluation, actionStartedAt) } +func proxmoxGuestLifecycleRequest(attemptID, actionID, kind, operation string, resource unified.Resource) (agentexec.ProxmoxGuestLifecyclePayload, error) { + expectedStatus := "" + if resource.Proxmox != nil { + expectedStatus = strings.ToLower(strings.TrimSpace(resource.Proxmox.RuntimeStatus)) + } + if expectedStatus == "" { + switch resource.Status { + case unified.StatusOnline: + expectedStatus = "running" + case unified.StatusOffline: + expectedStatus = "stopped" + } + } + if expectedStatus != "running" && expectedStatus != "stopped" { + return agentexec.ProxmoxGuestLifecyclePayload{}, fmt.Errorf("Proxmox guest lifecycle requires a current running or stopped state") + } + request := agentexec.ProxmoxGuestLifecyclePayload{ + RequestID: attemptID, ActionID: actionID, Operation: operation, GuestKind: kind, + VMID: resource.Proxmox.VMID, ExpectedStatus: expectedStatus, Timeout: proxmoxGuestLifecycleTimeout(operation), + } + if err := agentexec.BindProxmoxGuestLifecyclePayload(&request); err != nil { + return agentexec.ProxmoxGuestLifecyclePayload{}, err + } + return request, nil +} + func (e proxmoxGuestActionExecutor) CheckActionAvailable(ctx context.Context, req unified.ActionRequest, resource unified.Resource) unified.ResourceActionReadiness { operation := strings.TrimSpace(req.CapabilityName) if _, ok := findProxmoxLifecycleCapability(resource.Capabilities, operation); !ok { diff --git a/internal/api/route_inventory_test.go b/internal/api/route_inventory_test.go index 7e5143c6e..a30bc0aef 100644 --- a/internal/api/route_inventory_test.go +++ b/internal/api/route_inventory_test.go @@ -290,6 +290,8 @@ var publicRouteAllowlist = []string{ "/install.sh", "/install.ps1", "/download/pulse-agent", + "/download/pulse-agent-helper", + "/download/pulse-agent-runner", } var bareRouteAllowlist = []string{ @@ -346,6 +348,8 @@ var bareRouteAllowlist = []string{ "/api/system/verify-temperature-ssh", "/api/version", "/download/pulse-agent", + "/download/pulse-agent-helper", + "/download/pulse-agent-runner", "/install.ps1", "/install.sh", "/api/public/signup", @@ -375,6 +379,7 @@ var allRouteAllowlist = []string{ "/api/logs/level", "/api/agents/docker/report", "/api/agents/kubernetes/report", + "/api/agents/action-runner/credential", "/api/agents/agent/report", "/api/agents/host/report", "/api/agents/agent/lookup", @@ -702,6 +707,8 @@ var allRouteAllowlist = []string{ "/install.sh", "/install.ps1", "/download/pulse-agent", + "/download/pulse-agent-helper", + "/download/pulse-agent-runner", "/api/agent/version", "/api/server/info", "/ws", diff --git a/internal/api/router.go b/internal/api/router.go index 1fdd5ee52..fb9cc015a 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -4474,6 +4474,8 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { "/install.sh", // Unified agent installer "/install.ps1", // Unified agent Windows installer "/download/pulse-agent", // Unified agent binary + "/download/pulse-agent-helper", // Typed privileged helper binary + "/download/pulse-agent-runner", // Typed action runner binary "/api/agent/capabilities", // Agent-paradigm discovery manifest; underlying capabilities keep their own auth scopes "/api/agent/version", // Agent update checks need to work before auth "/api/agent/ws", // Agent WebSocket has its own auth via registration diff --git a/internal/api/router_download_inventory_test.go b/internal/api/router_download_inventory_test.go index 0a08388e5..70570dafc 100644 --- a/internal/api/router_download_inventory_test.go +++ b/internal/api/router_download_inventory_test.go @@ -70,10 +70,14 @@ func setIntersection(a, b map[string]struct{}) map[string]struct{} { var downloadRouteAllowlist = []string{ "/download/pulse-agent", + "/download/pulse-agent-helper", + "/download/pulse-agent-runner", } var publicDownloadAllowlist = []string{ "/download/pulse-agent", + "/download/pulse-agent-helper", + "/download/pulse-agent-runner", } var protectedDownloadAllowlist = []string{} diff --git a/internal/api/router_public_paths_inventory_test.go b/internal/api/router_public_paths_inventory_test.go index 87cfd76c8..abc65bd3a 100644 --- a/internal/api/router_public_paths_inventory_test.go +++ b/internal/api/router_public_paths_inventory_test.go @@ -120,6 +120,8 @@ var publicPathsAllowlist = []string{ "/install.sh", "/install.ps1", "/download/pulse-agent", + "/download/pulse-agent-helper", + "/download/pulse-agent-runner", "/api/agent/capabilities", "/api/agent/version", "/api/agent/ws", diff --git a/internal/api/router_routes_auth_security.go b/internal/api/router_routes_auth_security.go index dc70a2a08..14f433891 100644 --- a/internal/api/router_routes_auth_security.go +++ b/internal/api/router_routes_auth_security.go @@ -650,6 +650,7 @@ func (r *Router) registerAuthSecurityInstallRoutes() { r.mux.HandleFunc("/install.ps1", r.downloadLimiter.Middleware(r.handleDownloadUnifiedInstallScriptPS)) r.mux.HandleFunc("/download/pulse-agent", r.downloadLimiter.Middleware(r.handleDownloadUnifiedAgent)) r.mux.HandleFunc("/download/pulse-agent-helper", r.downloadLimiter.Middleware(r.handleDownloadAgentHelper)) + r.mux.HandleFunc("/download/pulse-agent-runner", r.downloadLimiter.Middleware(r.handleDownloadAgentRunner)) r.mux.HandleFunc("/api/agent/version", r.handleAgentVersion) r.mux.HandleFunc("/api/server/info", r.handleServerInfo) diff --git a/internal/api/router_routes_registration.go b/internal/api/router_routes_registration.go index 76565ed23..38b31bdb5 100644 --- a/internal/api/router_routes_registration.go +++ b/internal/api/router_routes_registration.go @@ -55,6 +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/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)))) diff --git a/internal/api/security_regression_test.go b/internal/api/security_regression_test.go index b1e5e0163..7980ff11f 100644 --- a/internal/api/security_regression_test.go +++ b/internal/api/security_regression_test.go @@ -636,6 +636,66 @@ func TestAgentExecTokenBindingEnforced(t *testing.T) { conn.Close() } +func TestActionRunnerCredentialAdmissionBindsTenantHostRoleAndCapability(t *testing.T) { + cfg := &config.Config{DataPath: t.TempDir()} + rawToken, record, err := agenttokens.IssueActionRunnerAndPersist(cfg, nil, agenttokens.ActionRunnerIssueOptions{ + OrgID: "org-a", AgentID: "machine-a", Hostname: "node.example", + }) + if err != nil { + t.Fatal(err) + } + router := &Router{config: cfg} + + admission, ok := router.admitAgentExecToken(rawToken, "machine-a", "NODE") + if !ok { + t.Fatal("typed action runner credential was rejected") + } + if admission.OrganizationID != "org-a" || admission.TokenID != record.ID || + admission.AgentID != "machine-a" || admission.RuntimeRole != agentexec.RuntimeRoleActionRunner || + admission.ActionCapability != agentexec.ActionCapabilityTypedV1 { + t.Fatalf("action admission = %#v", admission) + } + if !router.validateAgentExecSession(admission) { + t.Fatal("fresh action runner admission failed live session validation") + } + + config.Mu.Lock() + cfg.APITokens[0].Metadata[agenttokens.ActionCapabilityMetadataKey] = "shell.v1" + config.Mu.Unlock() + if router.validateAgentExecSession(admission) { + t.Fatal("session stayed valid after action capability changed") + } +} + +func TestActionRunnerCredentialAdmissionRejectsWrongIdentityAndUnboundOrganization(t *testing.T) { + for _, tc := range []struct { + name string + agentID string + hostname string + clearOrg bool + }{ + {name: "wrong agent", agentID: "other", hostname: "node.example"}, + {name: "wrong host", agentID: "machine-a", hostname: "other.example"}, + {name: "missing organization binding", agentID: "machine-a", hostname: "node.example", clearOrg: true}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := &config.Config{DataPath: t.TempDir()} + rawToken, _, err := agenttokens.IssueActionRunnerAndPersist(cfg, nil, agenttokens.ActionRunnerIssueOptions{ + OrgID: "org-a", AgentID: "machine-a", Hostname: "node.example", + }) + if err != nil { + t.Fatal(err) + } + if tc.clearOrg { + cfg.APITokens[0].OrgID = "" + } + if _, ok := (&Router{config: cfg}).admitAgentExecToken(rawToken, tc.agentID, tc.hostname); ok { + t.Fatal("mismatched action credential was admitted") + } + }) + } +} + func TestAgentExecTokenRejectsAmbiguousMultiOrganizationAuthority(t *testing.T) { rawToken := "multi-org-agent-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeAgentExec}, map[string]string{ diff --git a/internal/api/unified_agent.go b/internal/api/unified_agent.go index 3dcd32645..130570f38 100644 --- a/internal/api/unified_agent.go +++ b/internal/api/unified_agent.go @@ -184,6 +184,25 @@ func agentHelperLocalBuildCommand(normalized string) string { return fmt.Sprintf("%s go build -o bin/pulse-agent-helper-%s ./cmd/pulse-agent-helper", strings.Join(env, " "), normalized) } +func agentRunnerLocalBuildCommand(normalized string) string { + goos, goarch, ok := strings.Cut(strings.TrimSpace(normalized), "-") + if !ok || goos != "linux" || goarch == "" { + goos = "linux" + goarch = "amd64" + normalized = "linux-amd64" + } + env := []string{"CGO_ENABLED=0", "GOOS=" + goos} + switch goarch { + case "armv7": + env = append(env, "GOARCH=arm", "GOARM=7") + case "armv6": + env = append(env, "GOARCH=arm", "GOARM=6") + default: + env = append(env, "GOARCH="+goarch) + } + return fmt.Sprintf("%s go build -o bin/pulse-agent-runner-%s ./cmd/pulse-agent-runner", strings.Join(env, " "), normalized) +} + // handleDownloadUnifiedAgent serves the pulse-agent binary func (r *Router) handleDownloadUnifiedAgent(w http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet && req.Method != http.MethodHead { @@ -421,6 +440,106 @@ func (r *Router) proxyAgentHelperFromGitHub(w http.ResponseWriter, req *http.Req ) } +// handleDownloadAgentRunner serves the separately signed Linux action runner. +// It is a distinct asset from both the monitoring collector and the local +// privilege helper so installer profiles cannot substitute one authority for +// another. +func (r *Router) handleDownloadAgentRunner(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet && req.Method != http.MethodHead { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") + + normalized := normalizeAgentHelperArch(strings.TrimSpace(req.URL.Query().Get("arch"))) + if normalized == "" { + http.Error(w, "A supported Linux architecture is required", http.StatusBadRequest) + return + } + binaryName := "pulse-agent-runner-" + normalized + searchPaths := []string{ + filepath.Join(pulseBinDir(), binaryName), + filepath.Join("/opt/pulse", binaryName), + filepath.Join("/app", binaryName), + filepath.Join(r.projectRoot, "bin", binaryName), + } + for _, candidate := range searchPaths { + info, err := os.Stat(candidate) + if err != nil || info.IsDir() { + continue + } + checksum, err := r.cachedSHA256(candidate, info) + if err != nil { + continue + } + signature, sigErr := readReleaseAssetSignature(candidate) + sshSignature, sshSigErr := readReleaseAssetSSHSignature(candidate) + if (sigErr != nil || sshSigErr != nil) && isPublishedReleaseAssetVersion(r.serverVersion) { + continue + } + file, err := os.Open(candidate) + if err != nil { + continue + } + defer file.Close() + w.Header().Set(checksumHeaderName, checksum) + if signature != "" { + w.Header().Set(signatureHeaderName, signature) + } + if sshSignature != "" { + w.Header().Set(sshSignatureHeaderName, sshSignature) + } + http.ServeContent(w, req, binaryName, info.ModTime(), file) + return + } + if !isPublishedReleaseAssetVersion(r.serverVersion) { + http.Error(w, "Agent runner binary not found for "+normalized+" in dev mode.\nBuild with:\n "+agentRunnerLocalBuildCommand(normalized), http.StatusNotFound) + return + } + r.proxyAgentRunnerFromGitHub(w, req, normalized) +} + +func (r *Router) proxyAgentRunnerFromGitHub(w http.ResponseWriter, req *http.Request, normalized string) { + assetName := "pulse-agent-runner-" + normalized + assetURL, err := r.releaseAssetURL(assetName) + if err != nil { + http.Error(w, "Agent runner binary unavailable for current server build", http.StatusServiceUnavailable) + return + } + client := r.installScriptClient + if client == nil { + client = &http.Client{Timeout: 5 * time.Minute} + } + response, err := client.Get(assetURL) + if err != nil { + http.Error(w, "Failed to fetch agent runner binary", http.StatusServiceUnavailable) + return + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + http.Error(w, "Agent runner binary not found on GitHub", http.StatusNotFound) + return + } + content, checksum, err := readBinaryWithChecksum(response.Body) + if err != nil { + http.Error(w, "Failed to read agent runner binary", http.StatusInternalServerError) + return + } + signature, err := fetchReleaseAssetContent(req.Context(), client, assetURL+".sig", 16*1024) + if err != nil { + http.Error(w, "Failed to fetch agent runner binary signature", http.StatusServiceUnavailable) + return + } + sshSignature, err := fetchReleaseAssetContent(req.Context(), client, assetURL+".sshsig", 64*1024) + if err != nil { + http.Error(w, "Failed to fetch agent runner binary SSH signature", http.StatusServiceUnavailable) + return + } + serveProxiedAgentBinaryWithSignatures(w, content, checksum, strings.TrimSpace(string(signature)), encodeSSHSignatureForHeader(sshSignature), "github-proxy") +} + // validateUnifiedAgentBinary rejects a local agent binary that this server must // not hand out. The endpoint checks catch a binary built against a superseded // report contract; the version check catches one that is merely old. diff --git a/internal/api/unified_agent_more_test.go b/internal/api/unified_agent_more_test.go index 8a40c18c4..a7d60566c 100644 --- a/internal/api/unified_agent_more_test.go +++ b/internal/api/unified_agent_more_test.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "crypto/sha256" "encoding/hex" "fmt" @@ -101,6 +102,68 @@ func TestDownloadAgentHelperProxiesPublishedSignedAsset(t *testing.T) { } } +func TestDownloadAgentRunnerRejectsUnsupportedArchitecture(t *testing.T) { + router := &Router{} + request := httptest.NewRequest(http.MethodGet, "/download/pulse-agent-runner?arch=darwin-arm64", nil) + response := httptest.NewRecorder() + router.handleDownloadAgentRunner(response, request) + if response.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", response.Code, http.StatusBadRequest) + } +} + +func TestDownloadAgentRunnerServesSignedLocalLinuxBinary(t *testing.T) { + root := t.TempDir() + binDir := filepath.Join(root, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(binDir, "pulse-agent-runner-linux-amd64") + content := []byte("runner") + if err := os.WriteFile(path, content, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path+".sig", []byte("detached"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path+".sshsig", []byte("ssh-signature"), 0o644); err != nil { + t.Fatal(err) + } + router := &Router{projectRoot: root, serverVersion: "6.5.0"} + request := httptest.NewRequest(http.MethodGet, "/download/pulse-agent-runner?arch=linux-amd64", nil) + response := httptest.NewRecorder() + router.handleDownloadAgentRunner(response, request) + if response.Code != http.StatusOK { + t.Fatalf("status = %d, want %d: %s", response.Code, http.StatusOK, response.Body.String()) + } + if got := response.Header().Get(checksumHeaderName); got == "" { + t.Fatal("missing checksum header") + } + if got := response.Header().Get(signatureHeaderName); got != "detached" { + t.Fatalf("signature = %q", got) + } + if got := response.Header().Get(sshSignatureHeaderName); got == "" { + t.Fatal("missing SSH signature header") + } + if !bytes.Equal(response.Body.Bytes(), content) { + t.Fatalf("body = %q", response.Body.Bytes()) + } +} + +func TestDownloadAgentRunnerMissingDevBinaryReturnsExactBuildCommand(t *testing.T) { + router := &Router{projectRoot: t.TempDir(), serverVersion: "dev"} + request := httptest.NewRequest(http.MethodGet, "/download/pulse-agent-runner?arch=linux-armv7", nil) + response := httptest.NewRecorder() + router.handleDownloadAgentRunner(response, request) + if response.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", response.Code, http.StatusNotFound) + } + want := "CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -o bin/pulse-agent-runner-linux-armv7 ./cmd/pulse-agent-runner" + if !strings.Contains(response.Body.String(), want) { + t.Fatalf("response missing build command %q: %s", want, response.Body.String()) + } +} + type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { diff --git a/internal/dockeragent/action_runtime.go b/internal/dockeragent/action_runtime.go new file mode 100644 index 000000000..433515d65 --- /dev/null +++ b/internal/dockeragent/action_runtime.go @@ -0,0 +1,41 @@ +package dockeragent + +import ( + "fmt" + "net/http" + "strings" + + "github.com/rs/zerolog" +) + +// NewActionRuntime connects the separate action runner directly to the Docker +// or Podman API using the module's existing typed lifecycle/update managers. +// It configures no Pulse target, report loop, command poller, or generic daemon +// proxy; callers receive only the narrow Agent methods used by the typed bridge. +func NewActionRuntime(runtimeName string, logger *zerolog.Logger) (*Agent, error) { + runtimePreference, err := normalizeRuntime(runtimeName) + if err != nil { + return nil, err + } + if logger == nil { + nop := zerolog.Nop() + logger = &nop + } + client, info, runtimeKind, err := connectRuntimeFn(runtimePreference, logger) + if err != nil { + return nil, fmt.Errorf("connect action runtime: %w", err) + } + agent := &Agent{ + docker: newSwappableDockerClient(client), + daemonHost: client.DaemonHost(), + daemonID: strings.TrimSpace(info.ID), + runtime: runtimeKind, + runtimePref: runtimePreference, + runtimeVer: strings.TrimSpace(info.ServerVersion), + logger: *logger, + httpClients: make(map[bool]*http.Client), + trustedHTTPClients: make(map[string]*http.Client), + } + agent.ensureAsyncLifecycle() + return agent, nil +} diff --git a/internal/hostagent/action_runner_client.go b/internal/hostagent/action_runner_client.go new file mode 100644 index 000000000..4b7492ace --- /dev/null +++ b/internal/hostagent/action_runner_client.go @@ -0,0 +1,139 @@ +package hostagent + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" + "github.com/rs/zerolog" +) + +const ActionRunnerRuntimeRole = agentexec.RuntimeRoleActionRunner + +type ActionRunnerClientConfig struct { + PulseURL string + APIToken string + StateDir string + HealthPath string + InsecureSkipVerify bool + CACertPath string + ServerFingerprint string + Logger *zerolog.Logger + DockerContainerUpdater DockerContainerUpdater + DockerContainerLifecycleOperator DockerContainerLifecycleOperator +} + +// NewActionRunnerClient constructs the separately credentialed, typed-only +// action transport. It does not create a collector, reporter, deploy client, +// arbitrary command executor, or unrestricted file reader. +func NewActionRunnerClient(config ActionRunnerClientConfig, agentID, hostname, version string) *CommandClient { + logger := config.Logger + if logger == nil { + nop := zerolog.Nop() + logger = &nop + } + lease := newPackageManagerLease() + client := NewCommandClient(Config{ + PulseURL: config.PulseURL, APIToken: config.APIToken, StateDir: config.StateDir, + InsecureSkipVerify: config.InsecureSkipVerify, CACertPath: config.CACertPath, + ServerFingerprint: config.ServerFingerprint, Logger: logger, + packageUpdates: newPackageUpdateManager(runtime.GOOS, lease), + storageCleanup: newStorageCleanupManager(runtime.GOOS, lease), + DockerContainerUpdater: config.DockerContainerUpdater, + DockerContainerLifecycleOperator: config.DockerContainerLifecycleOperator, + }, agentID, hostname, runtime.GOOS, version) + client.actionRunnerOnly = true + client.runtimeRole = agentexec.RuntimeRoleActionRunner + client.actionCapability = agentexec.ActionCapabilityTypedV1 + client.healthPath = strings.TrimSpace(config.HealthPath) + client.healthCapabilities = []string{ + "host.storage_cleanup.v1", + "host.update.v1", + "proxmox.guest.lifecycle.v1", + agentexec.ActionCapabilityTypedV1, + } + if config.DockerContainerUpdater != nil && config.DockerContainerLifecycleOperator != nil { + client.healthCapabilities = append(client.healthCapabilities, "container.lifecycle.v1", "container.update.v1") + } + return client +} + +func allowedActionRunnerMessage(message messageType) bool { + switch message { + case msgTypePong, + msgTypeHostStorageCleanup, msgTypeHostUpdate, msgTypeProxmoxGuestLifecycle, + msgTypeDockerContainerLifecycle, msgTypeDockerContainerUpdate, + msgTypeDockerContainerObserve, msgTypeActionPreflight, + msgTypeOperationQuery, msgTypeCancelCmd: + return true + default: + return false + } +} + +type actionRunnerHealth struct { + Registered bool `json:"registered"` + RuntimeRole string `json:"runtime_role"` + Server string `json:"server"` + HostID string `json:"host_id"` + Hostname string `json:"hostname"` + Capabilities []string `json:"capabilities"` + RegisteredAt time.Time `json:"registered_at"` +} + +func (c *CommandClient) writeActionRunnerHealth() error { + if c == nil || !c.actionRunnerOnly || strings.TrimSpace(c.healthPath) == "" { + return fmt.Errorf("action-runner health path is required") + } + capabilities := append([]string(nil), c.healthCapabilities...) + sort.Strings(capabilities) + health := actionRunnerHealth{ + Registered: true, RuntimeRole: agentexec.RuntimeRoleActionRunner, + Server: c.pulseURL, HostID: c.agentID, Hostname: c.hostname, + Capabilities: capabilities, RegisteredAt: time.Now().UTC(), + } + encoded, err := json.Marshal(health) + if err != nil { + return err + } + dir := filepath.Dir(c.healthPath) + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + temp, err := os.CreateTemp(dir, ".health-*.tmp") + if err != nil { + return err + } + tempPath := temp.Name() + defer os.Remove(tempPath) + if err := temp.Chmod(0600); err != nil { + temp.Close() + return err + } + if _, err := temp.Write(encoded); 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, c.healthPath); err != nil { + return err + } + directory, err := os.Open(dir) + if err != nil { + return err + } + defer directory.Close() + return directory.Sync() +} diff --git a/internal/hostagent/action_runner_client_test.go b/internal/hostagent/action_runner_client_test.go new file mode 100644 index 000000000..ae1762a9c --- /dev/null +++ b/internal/hostagent/action_runner_client_test.go @@ -0,0 +1,156 @@ +package hostagent + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" + "github.com/rs/zerolog" +) + +func TestNewActionRunnerClientIsTypedOnlyAndEmitsExplicitRole(t *testing.T) { + logger := zerolog.Nop() + client := NewActionRunnerClient(ActionRunnerClientConfig{ + PulseURL: "https://pulse.example", APIToken: "separate-secret", + StateDir: t.TempDir(), HealthPath: filepath.Join(t.TempDir(), "health.json"), Logger: &logger, + }, "agent-1", "host-1", "v1") + t.Cleanup(func() { _ = client.Close() }) + if !client.actionRunnerOnly || client.runtimeRole != agentexec.RuntimeRoleActionRunner || client.actionCapability != agentexec.ActionCapabilityTypedV1 { + t.Fatalf("runner protocol ceiling not configured: %+v", client) + } + for _, message := range []messageType{msgTypeExecuteCmd, msgTypeReadFile, msgTypeDeployPreflight, msgTypeDeployInstall, msgTypeDeployCancel} { + if allowedActionRunnerMessage(message) { + t.Errorf("forbidden message %q was admitted", message) + } + } + for _, message := range []messageType{msgTypeHostUpdate, msgTypeHostStorageCleanup, msgTypeDockerContainerLifecycle, msgTypeDockerContainerUpdate, msgTypeOperationQuery, msgTypeCancelCmd} { + if !allowedActionRunnerMessage(message) { + t.Errorf("typed message %q was rejected", message) + } + } +} + +func TestActionRunnerTransportRegistersRoleWritesHealthAndRejectsGenericExec(t *testing.T) { + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + registration := make(chan registerPayload, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + conn, err := upgrader.Upgrade(w, request, nil) + if err != nil { + return + } + defer conn.Close() + var message wsMessage + if err := conn.ReadJSON(&message); err != nil { + return + } + var payload registerPayload + if json.Unmarshal(message.Payload, &payload) != nil { + return + } + registration <- payload + ack, _ := json.Marshal(registeredPayload{Success: true}) + _ = conn.WriteJSON(wsMessage{Type: msgTypeRegistered, Timestamp: time.Now(), Payload: ack}) + forbidden, _ := json.Marshal(executeCommandPayload{RequestID: "r1", Command: "id", TargetType: "agent"}) + _ = conn.WriteJSON(wsMessage{Type: msgTypeExecuteCmd, Timestamp: time.Now(), Payload: forbidden}) + })) + defer server.Close() + dir := t.TempDir() + healthPath := filepath.Join(dir, "health.json") + logger := zerolog.Nop() + client := NewActionRunnerClient(ActionRunnerClientConfig{ + PulseURL: server.URL, APIToken: "runner-token", StateDir: filepath.Join(dir, "state"), + HealthPath: healthPath, InsecureSkipVerify: true, Logger: &logger, + }, "agent-1", "host-1", "v1") + defer client.Close() + err := client.connectAndHandle(context.Background()) + if err == nil || !strings.Contains(err.Error(), "forbidden message") { + t.Fatalf("transport error = %v", err) + } + payload := <-registration + if payload.RuntimeRole != agentexec.RuntimeRoleActionRunner || payload.ActionCapability != agentexec.ActionCapabilityTypedV1 || payload.Token != "runner-token" { + t.Fatalf("registration = %+v", payload) + } + data, err := os.ReadFile(healthPath) + if err != nil { + t.Fatal(err) + } + var health actionRunnerHealth + if json.Unmarshal(data, &health) != nil || !health.Registered || health.HostID != "agent-1" { + t.Fatalf("health = %s", data) + } +} + +func TestActionRunnerHealthIsAtomicBoundedAndSecretFree(t *testing.T) { + dir := t.TempDir() + healthPath := filepath.Join(dir, "health.json") + logger := zerolog.Nop() + client := NewActionRunnerClient(ActionRunnerClientConfig{ + PulseURL: "https://pulse.example", APIToken: "must-not-appear", + StateDir: filepath.Join(dir, "state"), HealthPath: healthPath, Logger: &logger, + }, "agent-1", "host-1", "v1") + t.Cleanup(func() { _ = client.Close() }) + if err := client.writeActionRunnerHealth(); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(healthPath) + if err != nil { + t.Fatal(err) + } + if json.Valid(data) == false || string(data) == "" { + t.Fatalf("invalid health marker: %q", data) + } + if contains := string(data); contains == "must-not-appear" || jsonContains(data, "must-not-appear") { + t.Fatal("health marker leaked the action credential") + } + var health actionRunnerHealth + if err := json.Unmarshal(data, &health); err != nil { + t.Fatal(err) + } + if !health.Registered || health.RuntimeRole != agentexec.RuntimeRoleActionRunner || health.HostID != "agent-1" || health.Server != "https://pulse.example" || health.RegisteredAt.IsZero() { + t.Fatalf("health = %+v", health) + } + info, err := os.Stat(healthPath) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatalf("health mode = %v, %v", info.Mode().Perm(), err) + } + matches, err := filepath.Glob(filepath.Join(dir, ".health-*.tmp")) + if err != nil || len(matches) != 0 { + t.Fatalf("temporary health files = %v, %v", matches, err) + } +} + +func jsonContains(data []byte, value string) bool { + var decoded any + if json.Unmarshal(data, &decoded) != nil { + return false + } + return containsJSONValue(decoded, value) +} + +func containsJSONValue(value any, secret string) bool { + switch typed := value.(type) { + case string: + return typed == secret + case []any: + for _, item := range typed { + if containsJSONValue(item, secret) { + return true + } + } + case map[string]any: + for _, item := range typed { + if containsJSONValue(item, secret) { + return true + } + } + } + return false +} diff --git a/internal/hostagent/command_client_test.go b/internal/hostagent/command_client_test.go index e444a0fb0..10b8be113 100644 --- a/internal/hostagent/command_client_test.go +++ b/internal/hostagent/command_client_test.go @@ -259,3 +259,16 @@ func TestCommandClient_handleCancelCommand_UnknownRequestIsNoOp(t *testing.T) { default: } } + +func TestCommandClientActionRunnerMessageCatalogRejectsGenericAuthority(t *testing.T) { + for _, message := range []messageType{msgTypeExecuteCmd, msgTypeReadFile, msgTypeDeployPreflight, msgTypeDeployInstall, msgTypeDeployCancel} { + if allowedActionRunnerMessage(message) { + t.Fatalf("action runner unexpectedly admitted generic message %q", message) + } + } + for _, message := range []messageType{msgTypeHostUpdate, msgTypeHostStorageCleanup, msgTypeProxmoxGuestLifecycle, msgTypeDockerContainerLifecycle} { + if !allowedActionRunnerMessage(message) { + t.Fatalf("action runner rejected typed message %q", message) + } + } +} diff --git a/internal/hostagent/commands.go b/internal/hostagent/commands.go index 1ebeddecd..8f883648d 100644 --- a/internal/hostagent/commands.go +++ b/internal/hostagent/commands.go @@ -113,6 +113,7 @@ type CommandClient struct { storageCleanup *storageCleanupManager dockerLifecycle dockerLifecycleManager dockerUpdater DockerContainerUpdater + proxmoxGuestLifecycle *proxmoxGuestLifecycleManager operationReceipts *operationreceipt.Store operationReceiptErr error operationReceiptCloseOnce sync.Once @@ -132,6 +133,15 @@ type CommandClient struct { // enforces unique in-flight request IDs on its side. activeCommandsMu sync.Mutex activeCommands map[string]context.CancelFunc + + // actionRunnerOnly is an immutable constructor-selected protocol ceiling. + // It permits only the closed typed action families and their receipt, + // preflight, observation, cancellation, and liveness messages. + actionRunnerOnly bool + runtimeRole string + actionCapability string + healthPath string + healthCapabilities []string } // NewCommandClient creates a new command execution client @@ -152,26 +162,27 @@ func NewCommandClient(cfg Config, agentID, hostname, platform, version string) * Msg("Operation receipt store unavailable — Pulse will refuse reviewed actions for this agent until its state directory is writable") } return &CommandClient{ - pulseURL: strings.TrimRight(cfg.PulseURL, "/"), - apiToken: cfg.APIToken, - agentID: agentID, - hostname: hostname, - platform: platform, - version: version, - stateDir: stateDir, - insecureSkipVerify: cfg.InsecureSkipVerify, - caCertPath: cfg.CACertPath, - serverFingerprint: cfg.ServerFingerprint, - deploySSHUser: cfg.DeploySSHUser, - commandPolicy: agentexec.DefaultPolicy(), - packageUpdates: cfg.packageUpdates, - storageCleanup: cfg.storageCleanup, - dockerLifecycle: newLocalDockerLifecycleManager(cfg.DockerContainerLifecycleOperator), - dockerUpdater: cfg.DockerContainerUpdater, - operationReceipts: receipts, - operationReceiptErr: receiptErr, - logger: logger, - done: make(chan struct{}), + pulseURL: strings.TrimRight(cfg.PulseURL, "/"), + apiToken: cfg.APIToken, + agentID: agentID, + hostname: hostname, + platform: platform, + version: version, + stateDir: stateDir, + insecureSkipVerify: cfg.InsecureSkipVerify, + caCertPath: cfg.CACertPath, + serverFingerprint: cfg.ServerFingerprint, + deploySSHUser: cfg.DeploySSHUser, + commandPolicy: agentexec.DefaultPolicy(), + packageUpdates: cfg.packageUpdates, + storageCleanup: cfg.storageCleanup, + dockerLifecycle: newLocalDockerLifecycleManager(cfg.DockerContainerLifecycleOperator), + dockerUpdater: cfg.DockerContainerUpdater, + proxmoxGuestLifecycle: newProxmoxGuestLifecycleManager(), + operationReceipts: receipts, + operationReceiptErr: receiptErr, + logger: logger, + done: make(chan struct{}), } } @@ -201,6 +212,8 @@ const ( msgTypeHostStorageCleanupResult messageType = "host_storage_cleanup_result" msgTypeHostUpdate messageType = "host_update" msgTypeHostUpdateResult messageType = "host_update_result" + msgTypeProxmoxGuestLifecycle messageType = "proxmox_guest_lifecycle" + msgTypeProxmoxGuestLifecycleResult messageType = "proxmox_guest_lifecycle_result" msgTypeDockerContainerLifecycle messageType = "docker_container_lifecycle" msgTypeDockerContainerLifecycleResult messageType = "docker_container_lifecycle_result" msgTypeDockerContainerUpdate messageType = "docker_container_update" @@ -232,6 +245,8 @@ type registerPayload struct { Platform string `json:"platform"` Tags []string `json:"tags,omitempty"` Token string `json:"token"` + RuntimeRole string `json:"runtime_role,omitempty"` + ActionCapability string `json:"action_capability,omitempty"` OperationReceiptVersion int `json:"operation_receipt_version,omitempty"` ActionPreflightVersion int `json:"action_preflight_version,omitempty"` DockerObservationVersion int `json:"docker_observation_version,omitempty"` @@ -403,6 +418,11 @@ func (c *CommandClient) connectAndHandle(ctx context.Context) error { if err := c.waitForRegistration(conn); err != nil { return fmt.Errorf("registration failed: %w", err) } + if c.actionRunnerOnly { + if err := c.writeActionRunnerHealth(); err != nil { + return fmt.Errorf("write action-runner health: %w", err) + } + } c.logger.Info().Msg("Connected and registered with Pulse command server") @@ -465,6 +485,8 @@ func (c *CommandClient) sendRegistration(conn *websocket.Conn) error { Version: c.version, Platform: c.platform, Token: c.apiToken, + RuntimeRole: c.runtimeRole, + ActionCapability: c.actionCapability, OperationReceiptVersion: c.operationReceiptVersion(), ActionPreflightVersion: agentexec.ActionPreflightProtocolVersion, DockerObservationVersion: agentexec.DockerContainerObservationProtocolVersion, @@ -565,6 +587,9 @@ func (c *CommandClient) handleMessages(ctx context.Context, conn *websocket.Conn if err := conn.ReadJSON(&msg); err != nil { return fmt.Errorf("read message: %w", err) } + if c.actionRunnerOnly && !allowedActionRunnerMessage(msg.Type) { + return fmt.Errorf("action-runner rejected forbidden message type %q", msg.Type) + } switch msg.Type { case msgTypePong: @@ -597,6 +622,14 @@ func (c *CommandClient) handleMessages(ctx context.Context, conn *websocket.Conn } go c.handleHostUpdate(ctx, conn, payload) + case msgTypeProxmoxGuestLifecycle: + payload, err := agentexec.DecodeProxmoxGuestLifecyclePayload(msg.Payload) + if err != nil { + c.logger.Warn().Err(err).Msg("Dropping invalid Proxmox guest lifecycle request") + continue + } + go c.handleProxmoxGuestLifecycle(ctx, conn, payload) + case msgTypeActionPreflight: payload, err := agentexec.DecodeActionPreflightPayload(msg.Payload) if err != nil { @@ -829,7 +862,11 @@ func (c *CommandClient) beginHostAPTOperation(ctx context.Context, conn *websock timeout = defaultTimeout } opCtx, cancel := context.WithTimeout(ctx, timeout) - return opCtx, cancel, true + c.registerActiveCommand(requestID, cancel) + return opCtx, func() { + c.unregisterActiveCommand(requestID) + cancel() + }, true } // completeHostAPTOperation persists the sanitized terminal receipt and sends @@ -909,6 +946,8 @@ func (c *CommandClient) handleDockerContainerLifecycle(ctx context.Context, conn timeout = 2 * time.Minute } operationCtx, cancel := context.WithTimeout(ctx, timeout) + c.registerActiveCommand(payload.RequestID, cancel) + defer c.unregisterActiveCommand(payload.RequestID) defer cancel() result := agentexec.DockerContainerLifecycleResultPayload{ RequestID: payload.RequestID, ActionID: payload.ActionID, Operation: payload.Operation, @@ -1072,6 +1111,16 @@ func hostOperationReceiptConfig() operationreceipt.Config { } return nil }}, + agentexec.ProxmoxGuestLifecycleReceiptKind: {agentexec.ProxmoxGuestLifecycleReceiptVersion: func(identity operationreceipt.Identity, payload json.RawMessage) error { + result, err := agentexec.DecodeProxmoxGuestLifecycleResultPayload(payload) + if err != nil { + return err + } + if result.RequestID != identity.AttemptID || result.ActionID != identity.ActionID || result.Operation != identity.OperationKind || result.OperationVersion != identity.OperationVersion || result.RequestDigest != identity.RequestDigest { + return operationreceipt.ErrBindingConflict + } + return nil + }}, }} } diff --git a/internal/hostagent/docker_update.go b/internal/hostagent/docker_update.go index cd72f2443..7d4ba7e27 100644 --- a/internal/hostagent/docker_update.go +++ b/internal/hostagent/docker_update.go @@ -44,6 +44,8 @@ func (c *CommandClient) handleDockerContainerUpdate(ctx context.Context, conn *w timeout = 15 * time.Minute } operationCtx, cancel := context.WithTimeout(ctx, timeout) + c.registerActiveCommand(payload.RequestID, cancel) + defer c.unregisterActiveCommand(payload.RequestID) defer cancel() result := c.runDockerContainerUpdate(operationCtx, payload) diff --git a/internal/hostagent/proxmox_guest_lifecycle.go b/internal/hostagent/proxmox_guest_lifecycle.go new file mode 100644 index 000000000..df8f39777 --- /dev/null +++ b/internal/hostagent/proxmox_guest_lifecycle.go @@ -0,0 +1,152 @@ +package hostagent + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "strconv" + "strings" + "time" + + "github.com/gorilla/websocket" + "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" + "github.com/rcourtman/pulse-go-rewrite/internal/operationreceipt" +) + +type proxmoxGuestCommandRunner func(context.Context, string, ...string) ([]byte, error) + +type proxmoxGuestLifecycleManager struct { + run proxmoxGuestCommandRunner + now func() time.Time +} + +func newProxmoxGuestLifecycleManager() *proxmoxGuestLifecycleManager { + return &proxmoxGuestLifecycleManager{ + run: func(ctx context.Context, name string, args ...string) ([]byte, error) { + return exec.CommandContext(ctx, name, args...).CombinedOutput() + }, + now: time.Now, + } +} + +func (m *proxmoxGuestLifecycleManager) Apply(ctx context.Context, req agentexec.ProxmoxGuestLifecyclePayload) (result agentexec.ProxmoxGuestLifecycleResultPayload) { + started := time.Now() + result = agentexec.ProxmoxGuestLifecycleResultPayload{ + RequestID: req.RequestID, ActionID: req.ActionID, Operation: req.Operation, + OperationVersion: req.OperationVersion, RequestDigest: req.RequestDigest, + GuestKind: req.GuestKind, VMID: req.VMID, ExecutionPhase: agentexec.ProxmoxGuestPhasePreflight, + } + defer func() { result.Duration = time.Since(started).Milliseconds() }() + if err := agentexec.ValidateProxmoxGuestLifecyclePayload(&req); err != nil { + result.ReasonCode, result.Error = agentexec.ActionRefusalContractInvalid, "typed Proxmox lifecycle preflight refused" + return result + } + before, err := m.inspect(ctx, req.GuestKind, req.VMID) + result.Before = before + if err != nil { + result.ReasonCode, result.Error = agentexec.ActionRefusalTargetInspectionUnavailable, "Proxmox guest preflight status unavailable" + return result + } + if before.Status != req.ExpectedStatus { + result.ReasonCode, result.Error = agentexec.ActionRefusalTargetStateChanged, "Proxmox guest state changed before dispatch" + return result + } + result.ExecutionPhase, result.MutationStarted = agentexec.ProxmoxGuestPhaseMutate, true + tool := "qm" + if req.GuestKind == "ct" { + tool = "pct" + } + // This is the entire mutation catalog: fixed tool, fixed verb, decimal VMID. + if _, err := m.run(ctx, tool, req.Operation, strconv.Itoa(req.VMID)); err != nil { + result.Error = "Proxmox guest lifecycle mutation did not complete" + return result + } + result.MutationCompleted, result.ExecutionPhase = true, agentexec.ProxmoxGuestPhaseVerify + after, err := m.inspect(ctx, req.GuestKind, req.VMID) + result.After = after + if err != nil { + result.Error = "Proxmox guest postcondition status unavailable" + return result + } + result.ReadbackRan = true + if proxmoxGuestLifecyclePostcondition(req.Operation, after.Status) { + result.ExecutionPhase = agentexec.ProxmoxGuestPhaseComplete + return result + } + result.Error = "Proxmox guest postcondition contradicted the requested state" + return result +} + +func (m *proxmoxGuestLifecycleManager) inspect(ctx context.Context, kind string, vmid int) (agentexec.ProxmoxGuestLifecycleSnapshot, error) { + tool := "qm" + if kind == "ct" { + tool = "pct" + } + out, err := m.run(ctx, tool, "status", strconv.Itoa(vmid)) + if err != nil { + return agentexec.ProxmoxGuestLifecycleSnapshot{}, err + } + fields := strings.Fields(strings.ToLower(strings.TrimSpace(string(out)))) + if len(fields) != 2 || fields[0] != "status:" || (fields[1] != "running" && fields[1] != "stopped") { + return agentexec.ProxmoxGuestLifecycleSnapshot{}, fmt.Errorf("unexpected Proxmox status response") + } + now := time.Now().UTC() + if m.now != nil { + now = m.now().UTC() + } + return agentexec.ProxmoxGuestLifecycleSnapshot{Status: fields[1], ObservedAt: now}, nil +} + +func proxmoxGuestLifecyclePostcondition(operation, status string) bool { + if operation == "stop" || operation == "shutdown" { + return status == "stopped" + } + return status == "running" +} + +func (c *CommandClient) handleProxmoxGuestLifecycle(ctx context.Context, conn *websocket.Conn, payload agentexec.ProxmoxGuestLifecyclePayload) { + identity := agentexec.ProxmoxGuestLifecycleOperationIdentity(c.agentID, payload) + record, admitted, err := c.admitOperation(identity) + if err != nil { + return + } + if !admitted { + if record.State == operationreceipt.StateTerminal { + var result agentexec.ProxmoxGuestLifecycleResultPayload + if json.Unmarshal(record.Result, &result) == nil { + c.sendProxmoxGuestLifecycleResult(conn, result) + } + } + return + } + if _, err := c.operationReceipts.MarkStarted(identity); err != nil { + return + } + operationCtx, cancel := context.WithTimeout(ctx, time.Duration(payload.Timeout)*time.Second) + c.registerActiveCommand(payload.RequestID, cancel) + defer c.unregisterActiveCommand(payload.RequestID) + defer cancel() + result := c.proxmoxGuestLifecycle.Apply(operationCtx, payload) + encoded, err := json.Marshal(result) + if err != nil { + return + } + if _, err := c.operationReceipts.Complete(identity, operationreceipt.TerminalEnvelope{Kind: agentexec.ProxmoxGuestLifecycleReceiptKind, Version: agentexec.ProxmoxGuestLifecycleReceiptVersion, Payload: encoded}); err != nil { + return + } + c.sendProxmoxGuestLifecycleResult(conn, result) +} + +func (c *CommandClient) sendProxmoxGuestLifecycleResult(conn *websocket.Conn, result agentexec.ProxmoxGuestLifecycleResultPayload) { + encoded, err := json.Marshal(result) + if err != nil { + return + } + c.connMu.Lock() + err = conn.WriteJSON(wsMessage{Type: msgTypeProxmoxGuestLifecycleResult, ID: result.RequestID, Timestamp: time.Now(), Payload: encoded}) + c.connMu.Unlock() + if err != nil { + c.logger.Error().Err(err).Str("request_id", result.RequestID).Msg("Failed to send Proxmox lifecycle result") + } +} diff --git a/internal/hostagent/proxmox_guest_lifecycle_test.go b/internal/hostagent/proxmox_guest_lifecycle_test.go new file mode 100644 index 000000000..63c38ccb9 --- /dev/null +++ b/internal/hostagent/proxmox_guest_lifecycle_test.go @@ -0,0 +1,134 @@ +package hostagent + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" + "github.com/rcourtman/pulse-go-rewrite/internal/operationreceipt" + "github.com/rs/zerolog" +) + +func boundProxmoxPayload(t *testing.T) agentexec.ProxmoxGuestLifecyclePayload { + t.Helper() + payload := agentexec.ProxmoxGuestLifecyclePayload{RequestID: "attempt-pve-1", ActionID: "action-pve-1", Operation: "shutdown", GuestKind: "ct", VMID: 141, ExpectedStatus: "running", Timeout: 30} + if err := agentexec.BindProxmoxGuestLifecyclePayload(&payload); err != nil { + t.Fatal(err) + } + return payload +} + +func TestProxmoxGuestLifecycleExecutesOnlyFixedCatalogAndNumericVMID(t *testing.T) { + payload := boundProxmoxPayload(t) + manager := newProxmoxGuestLifecycleManager() + manager.now = func() time.Time { return time.Unix(100, 0).UTC() } + var calls [][]string + manager.run = func(_ context.Context, tool string, args ...string) ([]byte, error) { + calls = append(calls, append([]string{tool}, args...)) + if len(calls) == 1 { + return []byte("status: running\n"), nil + } + if len(calls) == 2 { + return nil, nil + } + return []byte("status: stopped\n"), nil + } + result := manager.Apply(context.Background(), payload) + want := [][]string{{"pct", "status", "141"}, {"pct", "shutdown", "141"}, {"pct", "status", "141"}} + if !reflect.DeepEqual(calls, want) || result.ExecutionPhase != agentexec.ProxmoxGuestPhaseComplete || !result.MutationCompleted || !result.ReadbackRan { + t.Fatalf("calls=%v result=%+v", calls, result) + } +} + +func TestProxmoxGuestLifecycleCancellationStopsMutationAndProducesBoundFailure(t *testing.T) { + payload := boundProxmoxPayload(t) + manager := newProxmoxGuestLifecycleManager() + started := make(chan struct{}) + manager.run = func(ctx context.Context, _ string, args ...string) ([]byte, error) { + if args[0] == "status" { + return []byte("status: running"), nil + } + close(started) + <-ctx.Done() + return nil, ctx.Err() + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan agentexec.ProxmoxGuestLifecycleResultPayload, 1) + go func() { done <- manager.Apply(ctx, payload) }() + <-started + cancel() + result := <-done + if !result.MutationStarted || result.MutationCompleted || result.Error == "" { + t.Fatalf("canceled result = %+v", result) + } +} + +func TestProxmoxGuestLifecycleTerminalReceiptReplaysWithoutSecondMutation(t *testing.T) { + payload := boundProxmoxPayload(t) + client := &CommandClient{agentID: "agent-pve", logger: zerolog.Nop(), activeCommands: make(map[string]context.CancelFunc)} + receipts, err := operationreceipt.Open(filepath.Join(t.TempDir(), "receipts.db"), hostOperationReceiptConfig()) + if err != nil { + t.Fatal(err) + } + defer receipts.Close() + client.operationReceipts = receipts + client.proxmoxGuestLifecycle = newProxmoxGuestLifecycleManager() + mutations := 0 + client.proxmoxGuestLifecycle.run = func(_ context.Context, _ string, args ...string) ([]byte, error) { + if args[0] == "status" { + if mutations == 0 { + return []byte("status: running"), nil + } + return []byte("status: stopped"), nil + } + if args[0] != "shutdown" || args[1] != strconv.Itoa(payload.VMID) { + return nil, errors.New("unexpected arguments") + } + mutations++ + return nil, nil + } + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + serverConnections := make(chan *websocket.Conn) + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, upgradeErr := upgrader.Upgrade(w, r, nil) + if upgradeErr != nil { + return + } + serverConnections <- conn + <-release + _ = conn.Close() + })) + defer server.Close() + for attempt := 0; attempt < 2; attempt++ { + remote, _, dialErr := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil) + if dialErr != nil { + t.Fatal(dialErr) + } + serverConn := <-serverConnections + client.handleProxmoxGuestLifecycle(context.Background(), serverConn, payload) + var message wsMessage + if err := remote.ReadJSON(&message); err != nil { + t.Fatal(err) + } + var result agentexec.ProxmoxGuestLifecycleResultPayload + if message.Type != msgTypeProxmoxGuestLifecycleResult || json.Unmarshal(message.Payload, &result) != nil || !result.MutationCompleted { + t.Fatalf("attempt %d message=%+v result=%+v", attempt, message, result) + } + _ = remote.Close() + release <- struct{}{} + } + if mutations != 1 { + t.Fatalf("mutations=%d, want 1", mutations) + } +} diff --git a/scripts/build-release-binaries.sh b/scripts/build-release-binaries.sh index f2a10f25b..a098198bb 100755 --- a/scripts/build-release-binaries.sh +++ b/scripts/build-release-binaries.sh @@ -144,6 +144,10 @@ for target in "${PULSE_RELEASE_AGENT_HELPER_TARGETS[@]}"; do task_components+=(agent-helper) task_targets+=("${target}") done +for target in "${PULSE_RELEASE_AGENT_RUNNER_TARGETS[@]}"; do + task_components+=(agent-runner) + task_targets+=("${target}") +done if [[ "${PROFILE}" == "full" ]]; then for target in "${PULSE_RELEASE_SERVER_TARGETS[@]}"; do task_components+=(server) @@ -169,6 +173,10 @@ build_one() { ;; agent-helper) package=./cmd/pulse-agent-helper + ldflags="${agent_ldflags}" + ;; + agent-runner) + package=./cmd/pulse-agent-runner ldflags="" ;; mcp) diff --git a/scripts/build-release.sh b/scripts/build-release.sh index b97ea9ffc..102ff0d24 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -127,6 +127,7 @@ agent_ldflags="$(./scripts/release_ldflags.sh agent --version "v${VERSION}" "${u echo "Building unified agents for all platforms..." agent_build_order=("${PULSE_RELEASE_AGENT_TARGETS[@]}") agent_helper_build_order=("${PULSE_RELEASE_AGENT_HELPER_TARGETS[@]}") +agent_runner_build_order=("${PULSE_RELEASE_AGENT_RUNNER_TARGETS[@]}") if [[ -n "${compiled_payload_dir:-}" ]]; then test -d "${compiled_payload_dir}/binaries" || { @@ -150,10 +151,21 @@ else build_env="$(pulse_release_target_env "${target}")" output_path="${BUILD_DIR}/$(pulse_release_binary_filename agent-helper "${target}")" env ${build_env} go build \ + -ldflags="${agent_ldflags}" \ "${release_go_build_args[@]}" \ -o "${output_path}" \ ./cmd/pulse-agent-helper done + + echo "Building action runners for Linux..." + for target in "${agent_runner_build_order[@]}"; do + build_env="$(pulse_release_target_env "${target}")" + output_path="${BUILD_DIR}/$(pulse_release_binary_filename agent-runner "${target}")" + env ${build_env} go build \ + "${release_go_build_args[@]}" \ + -o "${output_path}" \ + ./cmd/pulse-agent-runner + done fi # Platform-native signing jobs may supply replacement desktop binaries. They @@ -248,6 +260,12 @@ for target in "${agent_helper_build_order[@]}"; do exit 1 } done +for target in "${agent_runner_build_order[@]}"; do + test -f "${BUILD_DIR}/$(pulse_release_binary_filename agent-runner "${target}")" || { + echo "Error: release payload is missing agent runner binary for ${target}." >&2 + exit 1 + } +done for target in "${build_order[@]}"; do test -f "${BUILD_DIR}/$(pulse_release_binary_filename server "${target}")" || { echo "Error: release payload is missing server binary for ${target}." >&2 @@ -318,6 +336,9 @@ done for target in "${agent_helper_build_order[@]}"; do cp "$BUILD_DIR/pulse-agent-helper-${target}" "$universal_dir/bin/pulse-agent-helper-${target}" done +for target in "${agent_runner_build_order[@]}"; do + cp "$BUILD_DIR/pulse-agent-runner-${target}" "$universal_dir/bin/pulse-agent-runner-${target}" +done cp "scripts/install-container-agent.sh" "$universal_dir/scripts/install-container-agent.sh" cp "scripts/install-docker.sh" "$universal_dir/scripts/install-docker.sh" @@ -404,6 +425,11 @@ for target in "${agent_helper_build_order[@]}"; do tar -czf "$RELEASE_DIR/pulse-agent-helper-v${VERSION}-${target}.tar.gz" -C "$BUILD_DIR" "pulse-agent-helper-${target}" done +# Package the separately enabled action runner (Linux only). +for target in "${agent_runner_build_order[@]}"; do + tar -czf "$RELEASE_DIR/pulse-agent-runner-v${VERSION}-${target}.tar.gz" -C "$BUILD_DIR" "pulse-agent-runner-${target}" +done + # Package standalone pulse-mcp binaries (all platforms). Mirrors # the pulse-agent packaging shape exactly so the release-asset # upload step does not need per-binary special cases. @@ -443,6 +469,11 @@ for target in "${agent_helper_build_order[@]}"; do cp "$BUILD_DIR/pulse-agent-helper-${target}" "$RELEASE_DIR/" done +# Copy bare action runner binaries for the signed installer download endpoint. +for target in "${agent_runner_build_order[@]}"; do + cp "$BUILD_DIR/pulse-agent-runner-${target}" "$RELEASE_DIR/" +done + # Copy bare pulse-mcp binaries for /releases/latest/download/ redirect # compatibility. The install-mcp.sh installer fetches these directly from # the GitHub Releases endpoint without needing a versioned URL. diff --git a/scripts/install.sh b/scripts/install.sh index c47682429..52eff1b57 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -28,9 +28,16 @@ # --enable-commands Enable Pulse command execution on agent (disabled by default; required for Patrol actions and Proxmox LXC Docker inventory) # --least-privilege Run the agent as the dedicated 'pulse-agent' system user instead of root (Linux systemd only; SMART, Proxmox LXC filesystems, and command execution need root or an explicit grant) # --enable-privileged-helper Install the typed root helper for an explicitly selected least-privilege Linux systemd profile +# --enable-action-runner Install the separately credentialed typed remediation runner (requires the typed-helper profile) +# --disable-action-runner Disable and remove the action runner while leaving monitoring active +# --uninstall-action-runner Remove only the action runner while leaving monitoring active +# --action-token-file Read the separate action-runner credential from a file # --grant-smart With --least-privilege: allow SMART collection through an exact-command sudoers grant for smartctl # --grant-pct With --least-privilege: allow Proxmox LXC filesystem capacity through a sudoers grant restricted to 'pct list' and 'pct df' # --health-addr Health/metrics listener address (default: 127.0.0.1:9191, use "" to disable) +# --safe-profile-inspect Report the effective collector privilege profile and migration differences without changing the host +# --safe-profile-apply Explicitly migrate an existing Linux systemd collector to the typed-helper monitoring-only profile +# --safe-profile-rollback Restore the collector/helper snapshot retained by the last successful safe-profile migration # --update Update an existing agent using saved connection state # --uninstall Remove the agent # @@ -51,6 +58,14 @@ main() { TMP_FILES=() # shellcheck disable=SC2317 # Invoked by trap, not directly cleanup() { + if [[ "${SAFE_PROFILE_TRANSACTION_ACTIVE:-false}" == "true" && + "${SAFE_PROFILE_TRANSACTION_COMMITTED:-false}" != "true" && + -n "${SAFE_PROFILE_TRANSACTION_DIR:-}" ]] && + declare -F safe_profile_restore_transaction >/dev/null 2>&1; then + log_error "Safe-profile migration did not commit; restoring the previous collector/helper profile." + safe_profile_restore_transaction "$SAFE_PROFILE_TRANSACTION_DIR" "automatic-failure" || + log_error "Automatic safe-profile rollback failed; run --safe-profile-rollback before retrying." + fi # Use ${arr[@]+"${arr[@]}"} for bash 3.2 compatibility with set -u for f in ${TMP_FILES[@]+"${TMP_FILES[@]}"}; do rm -f "$f" 2>/dev/null || true @@ -146,8 +161,39 @@ PRIVILEGED_HELPER_SOCKET_UNIT="/etc/systemd/system/${PRIVILEGED_HELPER_NAME}.soc PRIVILEGED_HELPER_SOCKET_DIR="/run/pulse-agent" PRIVILEGED_HELPER_SOCKET_PATH="${PRIVILEGED_HELPER_SOCKET_DIR}/helper.sock" PRIVILEGED_HELPER_CREDENTIAL_DIR="/etc/pulse-agent" +PRIVILEGED_HELPER_STATE_DIR="/var/lib/pulse-agent-helper" +PRIVILEGED_HELPER_UPDATE_STAGING_DIR="${PRIVILEGED_HELPER_STATE_DIR}/update-staging" +PRIVILEGED_HELPER_UPDATE_QUARANTINE_DIR="/var/lib/pulse-agent/update-quarantine" TMP_HELPER_BIN="" +# Typed remediation is a separate root service and credential lifecycle. It is +# never inferred from the collector profile or from the collector token. +ACTION_RUNNER_ENABLED="false" +ACTION_RUNNER_EXPLICIT="false" +UNINSTALL_ACTION_RUNNER="false" +ACTION_RUNNER_NAME="pulse-agent-runner" +ACTION_RUNNER_BINARY_NAME="pulse-agent-runner" +ACTION_RUNNER_BINARY_PATH="${PRIVILEGE_HELPER_DIR}/${ACTION_RUNNER_BINARY_NAME}" +ACTION_RUNNER_SERVICE_UNIT="/etc/systemd/system/${ACTION_RUNNER_NAME}.service" +ACTION_RUNNER_CONFIG_DIR="/etc/pulse-agent-runner" +ACTION_RUNNER_ENV_FILE="${ACTION_RUNNER_CONFIG_DIR}/runner.env" +ACTION_RUNNER_TOKEN_FILE="${ACTION_RUNNER_CONFIG_DIR}/token" +ACTION_RUNNER_STATE_DIR="/var/lib/pulse-agent-runner" +ACTION_RUNNER_HEALTH_FILE="${ACTION_RUNNER_STATE_DIR}/health.json" +ACTION_TOKEN="" +ACTION_TOKEN_FILE_PATH="" +TMP_ACTION_RUNNER_BIN="" + +# Explicit safe-profile migration lifecycle. Ordinary --update deliberately +# leaves these unset and therefore never enters a migration transaction. +SAFE_PROFILE_ACTION="" +SAFE_PROFILE_STATE_DIR="/var/lib/pulse-agent-profile" +SAFE_PROFILE_CURRENT_FILE="${SAFE_PROFILE_STATE_DIR}/current.env" +SAFE_PROFILE_COLLECTOR_UNIT="/etc/systemd/system/${AGENT_NAME}.service" +SAFE_PROFILE_TRANSACTION_DIR="" +SAFE_PROFILE_TRANSACTION_ACTIVE="false" +SAFE_PROFILE_TRANSACTION_COMMITTED="false" + SYSTEMD_ENV_LINES="" SHELL_EXPORT_LINES="" PLIST_ENV_ENTRIES="" @@ -516,9 +562,16 @@ Options: --least-privilege Run the agent as the 'pulse-agent' system user instead of root (Linux systemd only) --enable-privileged-helper Install the typed root helper (requires --least-privilege on standard Linux systemd) --disable-privileged-helper Remove/disable an existing typed helper profile during this install + --enable-action-runner Install the separate typed remediation service (requires --least-privilege and --enable-privileged-helper) + --disable-action-runner Remove the action runner during this install; monitoring remains active + --uninstall-action-runner Remove only the action runner and exit; monitoring remains active + --action-token-file Read the separate action credential from a private file (required on first enable; never placed in argv) --grant-smart With --least-privilege: exact-command sudoers grant so SMART collection keeps working --grant-pct With --least-privilege: sudoers grant restricted to 'pct list'/'pct df' so Proxmox LXC filesystem capacity keeps working --health-addr Health/metrics listener address (default: 127.0.0.1:9191; use "" to disable) + --safe-profile-inspect Read-only report of current authority, providers, platform support, and calculated migration differences + --safe-profile-apply Explicitly migrate an existing Linux systemd collector to typed-helper monitoring-only (never implied by --update) + --safe-profile-rollback Restore the prior collector/helper snapshot from the last committed safe-profile migration --enroll Exchange bootstrap token for runtime token (deploy wizard) --update Update an existing agent using saved connection state --uninstall Remove the agent @@ -552,6 +605,9 @@ restore_selinux_contexts() { if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then restorecon -v "$PRIVILEGED_HELPER_BINARY_PATH" >/dev/null 2>&1 || true fi + if [[ "$ACTION_RUNNER_ENABLED" == "true" ]]; then + restorecon -v "$ACTION_RUNNER_BINARY_PATH" >/dev/null 2>&1 || true + fi log_info "SELinux context restored" else # Fallback to chcon if restorecon isn't available @@ -561,6 +617,9 @@ restore_selinux_contexts() { if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then chcon -t bin_t "$PRIVILEGED_HELPER_BINARY_PATH" 2>/dev/null || true fi + if [[ "$ACTION_RUNNER_ENABLED" == "true" ]]; then + chcon -t bin_t "$ACTION_RUNNER_BINARY_PATH" 2>/dev/null || true + fi fi fi } @@ -871,6 +930,18 @@ teardown_privileged_helper_service() { systemctl reset-failed "${PRIVILEGED_HELPER_NAME}.service" 2>/dev/null || true } +teardown_action_runner_service() { + 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 + rm -f "$ACTION_RUNNER_SERVICE_UNIT" + systemctl daemon-reload 2>/dev/null || true + systemctl reset-failed "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true + fi + rm -f "$ACTION_RUNNER_BINARY_PATH" + rm -rf "$ACTION_RUNNER_CONFIG_DIR" "$ACTION_RUNNER_STATE_DIR" +} + teardown_openrc_agent_service() { local init_path="${1:-/etc/init.d/${AGENT_NAME}}" rc-service "${AGENT_NAME}" stop 2>/dev/null || true @@ -1092,9 +1163,573 @@ ProtectControlGroups=true LockPersonality=true RestrictSUIDSGID=true SystemCallArchitectures=native +ReadOnlyPaths=${PRIVILEGED_HELPER_UPDATE_QUARANTINE_DIR} +ReadWritePaths=${PRIVILEGED_HELPER_STATE_DIR} /usr/local/bin EOF } +render_action_runner_service_unit() { + local unit_path="$1" + local runner_binary="$2" + + cat > "$unit_path" < "$ACTION_RUNNER_TOKEN_FILE" + elif [[ ! -s "$ACTION_RUNNER_TOKEN_FILE" ]]; then + fail "--enable-action-runner requires a separate --action-token-file on first install" "$EXIT_MISSING_ARGS" + fi + chown root:root "$ACTION_RUNNER_TOKEN_FILE" + chmod 0600 "$ACTION_RUNNER_TOKEN_FILE" + ACTION_TOKEN="" + + : > "$ACTION_RUNNER_ENV_FILE" + chmod 0600 "$ACTION_RUNNER_ENV_FILE" + write_action_runner_env_value "PULSE_URL" "$PULSE_URL" + write_action_runner_env_value "PULSE_AGENT_RUNNER_TOKEN_FILE" "$ACTION_RUNNER_TOKEN_FILE" + 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" + if [[ -n "$SERVER_FINGERPRINT" ]]; then + write_action_runner_env_value "PULSE_SERVER_FINGERPRINT" "$SERVER_FINGERPRINT" + fi + if [[ -n "$CURL_CA_BUNDLE" ]]; then + write_action_runner_env_value "SSL_CERT_FILE" "$CURL_CA_BUNDLE" + fi + if [[ "$INSECURE" == "true" ]]; then + write_action_runner_env_value "PULSE_INSECURE" "true" + fi + chown root:root "$ACTION_RUNNER_ENV_FILE" +} + +write_action_runner_env_value() { + local key="$1" + local value="$2" + if [[ "$value" == *$'\r'* || "$value" == *$'\n'* ]]; then + fail "Refusing newline in action-runner environment value for ${key}" "$EXIT_MISSING_ARGS" + fi + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + printf '%s="%s"\n' "$key" "$value" >> "$ACTION_RUNNER_ENV_FILE" +} + +provision_action_runner() { + local backup_suffix=".pulse-install-backup.$$" + local path="" + local had_binary="false" + local had_unit="false" + local had_state_dir="false" + local had_config_dir="false" + local runner_active="false" + local apply_succeeded="false" + local activation_started_at="" + + if [[ -d "$ACTION_RUNNER_STATE_DIR" ]]; then + had_state_dir="true" + fi + if [[ -d "$ACTION_RUNNER_CONFIG_DIR" ]]; then + had_config_dir="true" + fi + for path in "$ACTION_RUNNER_BINARY_PATH" "$ACTION_RUNNER_SERVICE_UNIT" "$ACTION_RUNNER_ENV_FILE" "$ACTION_RUNNER_TOKEN_FILE" "$ACTION_RUNNER_HEALTH_FILE"; do + if [[ -e "$path" ]]; then + cp -a "$path" "${path}${backup_suffix}" + case "$path" in + "$ACTION_RUNNER_BINARY_PATH") had_binary="true" ;; + "$ACTION_RUNNER_SERVICE_UNIT") had_unit="true" ;; + esac + fi + done + + activation_started_at=$(date +%s) + if ( + set -e + mkdir -p "$PRIVILEGE_HELPER_DIR" + install -o root -g root -m 0755 "$TMP_ACTION_RUNNER_BIN" "${ACTION_RUNNER_BINARY_PATH}.new" + mv "${ACTION_RUNNER_BINARY_PATH}.new" "$ACTION_RUNNER_BINARY_PATH" + restore_selinux_contexts + write_action_runner_config + render_action_runner_service_unit "${ACTION_RUNNER_SERVICE_UNIT}.new" "$ACTION_RUNNER_BINARY_PATH" + chown root:root "${ACTION_RUNNER_SERVICE_UNIT}.new" + chmod 0644 "${ACTION_RUNNER_SERVICE_UNIT}.new" + mv "${ACTION_RUNNER_SERVICE_UNIT}.new" "$ACTION_RUNNER_SERVICE_UNIT" + systemctl daemon-reload + systemctl enable "${ACTION_RUNNER_NAME}.service" + systemctl restart "${ACTION_RUNNER_NAME}.service" + ); then + apply_succeeded="true" + fi + ACTION_TOKEN="" + + if [[ "$apply_succeeded" == "true" ]]; then + local attempt=0 + while [[ "$attempt" -lt 30 ]]; do + local expected_agent_id="${AGENT_ID}" + local health_agent_id="" + local health_mtime="" + local health_owner="" + local health_mode="" + if [[ -s "${STATE_DIR%/}/agent-id" ]]; then + expected_agent_id=$(head -1 "${STATE_DIR%/}/agent-id" 2>/dev/null || true) + fi + if [[ -n "$expected_agent_id" && -f "$ACTION_RUNNER_HEALTH_FILE" && ! -L "$ACTION_RUNNER_HEALTH_FILE" ]]; then + health_mtime=$(stat -c '%Y' "$ACTION_RUNNER_HEALTH_FILE" 2>/dev/null || true) + health_owner=$(stat -c '%u' "$ACTION_RUNNER_HEALTH_FILE" 2>/dev/null || true) + health_mode=$(stat -c '%a' "$ACTION_RUNNER_HEALTH_FILE" 2>/dev/null || true) + health_agent_id=$(sed -n 's/.*"host_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$ACTION_RUNNER_HEALTH_FILE" | head -1) + fi + if systemctl is-active --quiet "${ACTION_RUNNER_NAME}.service" && + [[ "$health_mtime" =~ ^[0-9]+$ ]] && [[ "$health_mtime" -ge "$activation_started_at" ]] && + [[ "$health_owner" == "0" ]] && [[ "$health_mode" =~ ^(400|600)$ ]] && + grep -Eq '"registered"[[:space:]]*:[[:space:]]*true([[:space:],}]|$)' "$ACTION_RUNNER_HEALTH_FILE" 2>/dev/null && + [[ "$health_agent_id" == "$expected_agent_id" ]]; then + runner_active="true" + break + fi + attempt=$((attempt + 1)) + sleep 1 + done + fi + + if [[ "$runner_active" != "true" ]]; then + log_error "New action runner did not become healthy; rolling back runner-only files while leaving monitoring active." + systemctl stop "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true + systemctl disable "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true + rm -f "${ACTION_RUNNER_BINARY_PATH}.new" "${ACTION_RUNNER_SERVICE_UNIT}.new" + for path in "$ACTION_RUNNER_BINARY_PATH" "$ACTION_RUNNER_SERVICE_UNIT" "$ACTION_RUNNER_ENV_FILE" "$ACTION_RUNNER_TOKEN_FILE" "$ACTION_RUNNER_HEALTH_FILE"; do + rm -f "$path" + if [[ -e "${path}${backup_suffix}" ]]; then + mv "${path}${backup_suffix}" "$path" + fi + done + if [[ "$had_state_dir" != "true" ]]; then + rm -rf "$ACTION_RUNNER_STATE_DIR" + fi + if [[ "$had_config_dir" != "true" ]]; then + rm -rf "$ACTION_RUNNER_CONFIG_DIR" + fi + systemctl daemon-reload 2>/dev/null || true + if [[ "$had_unit" == "true" && "$had_binary" == "true" ]]; then + systemctl enable --now "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true + fi + fail "Action runner activation failed and its previous installation was restored; collector monitoring was not stopped or removed" "$EXIT_GENERAL" + fi + + rm -f \ + "${ACTION_RUNNER_BINARY_PATH}${backup_suffix}" \ + "${ACTION_RUNNER_SERVICE_UNIT}${backup_suffix}" \ + "${ACTION_RUNNER_ENV_FILE}${backup_suffix}" \ + "${ACTION_RUNNER_TOKEN_FILE}${backup_suffix}" \ + "${ACTION_RUNNER_HEALTH_FILE}${backup_suffix}" + TMP_ACTION_RUNNER_BIN="" + log_info "Typed action runner enabled as a separate root service with its own credential; collector monitoring remains independently active." +} + +# --- Safe collector profile migration transaction --- +# These operations intentionally cover only the collector and typed helper. +# The independently installed action runner has its own lifecycle and is never +# snapshotted, stopped, rewritten, or restored here. +safe_profile_platform_supported() { + [[ "$(uname -s 2>/dev/null || true)" == "Linux" ]] || return 1 + command -v systemctl >/dev/null 2>&1 || return 1 + [[ ! -d /usr/syno ]] || return 1 + [[ ! -f /etc/unraid-version ]] || return 1 + [[ ! -d /boot/config/plugins ]] || return 1 + [[ ! -x /sbin/getcfg ]] || return 1 + [[ ! -f /etc/truenas-version ]] || return 1 + [[ ! -d /data/ix-applications ]] || return 1 + [[ ! -d /etc/ix-apps.d ]] || return 1 + [[ ! -d /etc/ix.rc.d ]] || return 1 + if declare -F is_truenas >/dev/null 2>&1 && is_truenas; then + return 1 + fi + return 0 +} + +safe_profile_detect_current_profile() { + local unit_path="$SAFE_PROFILE_COLLECTOR_UNIT" + if [[ ! -f "$unit_path" ]]; then + printf 'absent\n' + elif grep -q "^User=${LEAST_PRIVILEGE_USER}$" "$unit_path" 2>/dev/null && + grep -q 'PULSE_AGENT_HELPER_SOCKET=' "$unit_path" 2>/dev/null; then + printf 'typed-helper-monitoring-only\n' + elif grep -q "^User=${LEAST_PRIVILEGE_USER}$" "$unit_path" 2>/dev/null; then + printf 'legacy-least-privilege\n' + elif grep -Eq -- '(^|[[:space:]])--enable-commands([[:space:]]|$)' "$unit_path" 2>/dev/null; then + printf 'legacy-root-command-capable\n' + else + printf 'legacy-root-monitoring\n' + fi +} + +safe_profile_unit_property() { + local property="$1" + local unit_path="$SAFE_PROFILE_COLLECTOR_UNIT" + local value="" + value=$(systemctl show "${AGENT_NAME}.service" --property "$property" --value 2>/dev/null || true) + if [[ -n "$value" ]]; then + printf '%s\n' "$value" + return 0 + fi + case "$property" in + User) sed -n 's/^User=//p' "$unit_path" 2>/dev/null | tail -1 ;; + AmbientCapabilities) sed -n 's/^AmbientCapabilities=//p' "$unit_path" 2>/dev/null | tail -1 ;; + esac +} + +safe_profile_inspect() { + local unit_path="$SAFE_PROFILE_COLLECTOR_UNIT" + local binary_path="${INSTALL_DIR}/${BINARY_NAME}" + local supported="false" + local profile="" + local unit_user="root" + local groups="unavailable" + local ambient="none" + local binary_owner="missing" + local binary_mode="missing" + local host="false" docker="false" kubernetes="false" proxmox="false" + local helper="false" commands="false" runner="false" + + if safe_profile_platform_supported; then supported="true"; fi + profile=$(safe_profile_detect_current_profile) + if [[ -f "$unit_path" ]]; then + unit_user=$(safe_profile_unit_property User) + unit_user="${unit_user:-root}" + ambient=$(safe_profile_unit_property AmbientCapabilities) + ambient="${ambient:-none}" + if id "$unit_user" >/dev/null 2>&1; then + groups=$(id -nG "$unit_user" 2>/dev/null || printf 'unavailable') + fi + grep -Eq -- '(^|[[:space:]])--disable-host([[:space:]]|$)' "$unit_path" 2>/dev/null || host="true" + grep -Eq -- '(^|[[:space:]])--enable-docker([[:space:]]|$)' "$unit_path" 2>/dev/null && docker="true" + grep -Eq -- '(^|[[:space:]])--enable-kubernetes([[:space:]]|$)' "$unit_path" 2>/dev/null && kubernetes="true" + grep -Eq -- '(^|[[:space:]])--enable-proxmox([[:space:]]|$)' "$unit_path" 2>/dev/null && proxmox="true" + grep -Eq -- '(^|[[:space:]])--enable-commands([[:space:]]|$)' "$unit_path" 2>/dev/null && commands="true" + grep -q 'PULSE_AGENT_HELPER_SOCKET=' "$unit_path" 2>/dev/null && helper="true" + fi + if [[ -e "$binary_path" && ! -L "$binary_path" ]]; then + binary_owner=$(stat -c '%U:%G' "$binary_path" 2>/dev/null || printf 'unknown') + binary_mode=$(stat -c '%a' "$binary_path" 2>/dev/null || printf 'unknown') + fi + if [[ -f "$ACTION_RUNNER_SERVICE_UNIT" ]]; then runner="true"; fi + + printf '%s\n' \ + "Pulse safe-profile migration inspection (read-only)" \ + "platform_supported=${supported}" \ + "current_profile=${profile}" \ + "unit_user=${unit_user}" \ + "unit_groups=${groups}" \ + "ambient_capabilities=${ambient}" \ + "collector_binary_owner=${binary_owner}" \ + "collector_binary_mode=${binary_mode}" \ + "provider_host=${host}" \ + "provider_docker=${docker}" \ + "provider_kubernetes=${kubernetes}" \ + "provider_proxmox=${proxmox}" \ + "typed_helper=${helper}" \ + "collector_commands=${commands}" \ + "action_runner_independent=${runner}" \ + "target_profile=typed-helper-monitoring-only" \ + "target_unit_user=${LEAST_PRIVILEGE_USER}" \ + "target_groups=no-rootful-docker-group" \ + "target_ambient_capabilities=none" \ + "target_binary_owner=root:root" \ + "target_commands=false" \ + "target_smart=typed-helper" \ + "target_proxmox_filesystems=typed-helper" \ + "target_action_runner=unchanged" + if [[ "$docker" == "true" ]]; then + printf '%s\n' "degraded_docker=rootful daemon access is removed unless an independently usable rootless socket is configured" + else + printf '%s\n' "degraded_docker=none (Docker provider is not enabled in the current unit)" + fi + if [[ "$commands" == "true" ]]; then + printf '%s\n' "degraded_actions=collector command authority is removed; remediation requires the separately enrolled action runner" + else + printf '%s\n' "degraded_actions=none (collector command authority is already disabled)" + fi + if [[ "$supported" != "true" ]]; then + log_error "Safe-profile migration is unsupported on this platform; no root or broader-privilege fallback is available." + return 1 + fi +} + +safe_profile_snapshot_entry() { + local source_path="$1" + local snapshot_name="$2" + local manifest_key="$3" + local manifest_file="${SAFE_PROFILE_TRANSACTION_DIR}/manifest.env" + if [[ -L "$source_path" ]]; then + fail "Refusing safe-profile migration across symlinked ${source_path}" "$EXIT_GENERAL" + fi + if [[ -e "$source_path" ]]; then + [[ -f "$source_path" ]] || + fail "Refusing non-regular safe-profile migration artifact: ${source_path}" "$EXIT_GENERAL" + cp -a "$source_path" "${SAFE_PROFILE_TRANSACTION_DIR}/${snapshot_name}" + printf '%s=true\n' "$manifest_key" >> "$manifest_file" + else + printf '%s=false\n' "$manifest_key" >> "$manifest_file" + fi +} + +safe_profile_manifest_value() { + local manifest_file="$1" + local key="$2" + sed -n "s/^${key}=//p" "$manifest_file" 2>/dev/null | tail -1 +} + +safe_profile_begin_transaction() { + local unit_path="$SAFE_PROFILE_COLLECTOR_UNIT" + local prior_profile="" + local transaction_id="" + local manifest_file="" + local docker_member="false" + + [[ -x "${INSTALL_DIR}/${BINARY_NAME}" && -f "$unit_path" ]] || + fail "--safe-profile-apply requires an existing Linux systemd Pulse collector installation" "$EXIT_MISSING_ARGS" + [[ ! -L "$SAFE_PROFILE_STATE_DIR" ]] || + fail "Refusing symlinked safe-profile transaction directory: ${SAFE_PROFILE_STATE_DIR}" "$EXIT_GENERAL" + mkdir -p "$SAFE_PROFILE_STATE_DIR" + chmod 0700 "$SAFE_PROFILE_STATE_DIR" + transaction_id="transaction-$(date -u +%Y%m%dT%H%M%SZ)-$$" + SAFE_PROFILE_TRANSACTION_DIR="${SAFE_PROFILE_STATE_DIR}/${transaction_id}" + mkdir "$SAFE_PROFILE_TRANSACTION_DIR" + chmod 0700 "$SAFE_PROFILE_TRANSACTION_DIR" + manifest_file="${SAFE_PROFILE_TRANSACTION_DIR}/manifest.env" + : > "$manifest_file" + chmod 0600 "$manifest_file" + + prior_profile=$(safe_profile_detect_current_profile) + printf '%s\n' \ + "FORMAT_VERSION=1" \ + "PRIOR_PROFILE=${prior_profile}" \ + "TARGET_PROFILE=typed-helper-monitoring-only" \ + "STATE_DIR=${STATE_DIR}" \ + "COLLECTOR_ACTIVE=$(systemctl is-active --quiet "${AGENT_NAME}.service" 2>/dev/null && printf true || printf false)" \ + "COLLECTOR_ENABLED=$(systemctl is-enabled --quiet "${AGENT_NAME}.service" 2>/dev/null && printf true || printf false)" \ + "HELPER_ACTIVE=$(systemctl is-active --quiet "${PRIVILEGED_HELPER_NAME}.socket" 2>/dev/null && printf true || printf false)" \ + "HELPER_ENABLED=$(systemctl is-enabled --quiet "${PRIVILEGED_HELPER_NAME}.socket" 2>/dev/null && printf true || printf false)" >> "$manifest_file" + if id -nG "$LEAST_PRIVILEGE_USER" 2>/dev/null | tr ' ' '\n' | grep -qx docker; then + docker_member="true" + fi + printf 'DOCKER_MEMBER=%s\n' "$docker_member" >> "$manifest_file" + if [[ -L "$PRIVILEGED_HELPER_CREDENTIAL_DIR" ]]; then + fail "Refusing symlinked safe-profile credential directory: ${PRIVILEGED_HELPER_CREDENTIAL_DIR}" "$EXIT_GENERAL" + elif [[ -d "$PRIVILEGED_HELPER_CREDENTIAL_DIR" ]]; then + printf '%s\n' \ + "PROTECTED_DIR=true" \ + "PROTECTED_DIR_UID=$(stat -c '%u' "$PRIVILEGED_HELPER_CREDENTIAL_DIR")" \ + "PROTECTED_DIR_GID=$(stat -c '%g' "$PRIVILEGED_HELPER_CREDENTIAL_DIR")" \ + "PROTECTED_DIR_MODE=$(stat -c '%a' "$PRIVILEGED_HELPER_CREDENTIAL_DIR")" >> "$manifest_file" + else + printf 'PROTECTED_DIR=false\n' >> "$manifest_file" + fi + + safe_profile_snapshot_entry "${INSTALL_DIR}/${BINARY_NAME}" collector-binary COLLECTOR_BINARY + safe_profile_snapshot_entry "$unit_path" collector-unit COLLECTOR_UNIT + safe_profile_snapshot_entry "$PRIVILEGED_HELPER_BINARY_PATH" helper-binary HELPER_BINARY + safe_profile_snapshot_entry "$PRIVILEGED_HELPER_SERVICE_UNIT" helper-service-unit HELPER_SERVICE_UNIT + safe_profile_snapshot_entry "$PRIVILEGED_HELPER_SOCKET_UNIT" helper-socket-unit HELPER_SOCKET_UNIT + safe_profile_snapshot_entry "$PRIVILEGE_SUDOERS_FILE" legacy-sudoers LEGACY_SUDOERS + safe_profile_snapshot_entry "${PRIVILEGE_HELPER_DIR}/smartctl" legacy-smartctl-wrapper LEGACY_SMARTCTL_WRAPPER + safe_profile_snapshot_entry "${PRIVILEGE_HELPER_DIR}/pct" legacy-pct-wrapper LEGACY_PCT_WRAPPER + safe_profile_snapshot_entry "${STATE_DIR%/}/token" state-token STATE_TOKEN + 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 "${PRIVILEGED_HELPER_CREDENTIAL_DIR}/token" protected-token PROTECTED_TOKEN + + SAFE_PROFILE_TRANSACTION_ACTIVE="true" + SAFE_PROFILE_TRANSACTION_COMMITTED="false" + log_info "Snapshotted ${prior_profile} collector/helper profile before migration." +} + +safe_profile_restore_entry() { + local transaction_dir="$1" + local snapshot_name="$2" + local destination="$3" + local manifest_key="$4" + local present="" + present=$(safe_profile_manifest_value "${transaction_dir}/manifest.env" "$manifest_key") + rm -f "$destination" + if [[ "$present" == "true" ]]; then + mkdir -p "$(dirname "$destination")" + cp -a "${transaction_dir}/${snapshot_name}" "$destination" + fi +} + +safe_profile_restore_transaction() { + local transaction_dir="$1" + local reason="${2:-explicit}" + local manifest_file="${transaction_dir}/manifest.env" + local snapshot_state_dir="" + local prior_profile="" + local docker_member="false" + local current_tmp="" + + SAFE_PROFILE_TRANSACTION_ACTIVE="false" + case "$transaction_dir" in + "${SAFE_PROFILE_STATE_DIR}"/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 + 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) + + systemctl stop "${AGENT_NAME}.service" 2>/dev/null || true + systemctl stop "${PRIVILEGED_HELPER_NAME}.socket" 2>/dev/null || true + systemctl stop "${PRIVILEGED_HELPER_NAME}.service" 2>/dev/null || true + safe_profile_restore_entry "$transaction_dir" collector-binary "${INSTALL_DIR}/${BINARY_NAME}" COLLECTOR_BINARY + safe_profile_restore_entry "$transaction_dir" collector-unit "$SAFE_PROFILE_COLLECTOR_UNIT" COLLECTOR_UNIT + safe_profile_restore_entry "$transaction_dir" helper-binary "$PRIVILEGED_HELPER_BINARY_PATH" HELPER_BINARY + safe_profile_restore_entry "$transaction_dir" helper-service-unit "$PRIVILEGED_HELPER_SERVICE_UNIT" HELPER_SERVICE_UNIT + safe_profile_restore_entry "$transaction_dir" helper-socket-unit "$PRIVILEGED_HELPER_SOCKET_UNIT" HELPER_SOCKET_UNIT + safe_profile_restore_entry "$transaction_dir" legacy-sudoers "$PRIVILEGE_SUDOERS_FILE" LEGACY_SUDOERS + safe_profile_restore_entry "$transaction_dir" legacy-smartctl-wrapper "${PRIVILEGE_HELPER_DIR}/smartctl" LEGACY_SMARTCTL_WRAPPER + safe_profile_restore_entry "$transaction_dir" legacy-pct-wrapper "${PRIVILEGE_HELPER_DIR}/pct" LEGACY_PCT_WRAPPER + safe_profile_restore_entry "$transaction_dir" state-token "${snapshot_state_dir%/}/token" STATE_TOKEN + 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 + 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 + chmod "$(safe_profile_manifest_value "$manifest_file" PROTECTED_DIR_MODE)" "$PRIVILEGED_HELPER_CREDENTIAL_DIR" || return 1 + else + rmdir "$PRIVILEGED_HELPER_CREDENTIAL_DIR" 2>/dev/null || true + fi + rm -f "$PRIVILEGED_HELPER_SOCKET_PATH" + + docker_member=$(safe_profile_manifest_value "$manifest_file" DOCKER_MEMBER) + if getent group docker >/dev/null 2>&1 && id "$LEAST_PRIVILEGE_USER" >/dev/null 2>&1; then + if [[ "$docker_member" == "true" ]]; then + gpasswd -a "$LEAST_PRIVILEGE_USER" docker >/dev/null 2>&1 || return 1 + else + gpasswd -d "$LEAST_PRIVILEGE_USER" docker >/dev/null 2>&1 || true + fi + fi + + systemctl daemon-reload 2>/dev/null || return 1 + if [[ "$(safe_profile_manifest_value "$manifest_file" HELPER_ENABLED)" == "true" ]]; then + systemctl enable "${PRIVILEGED_HELPER_NAME}.socket" >/dev/null 2>&1 || return 1 + else + systemctl disable "${PRIVILEGED_HELPER_NAME}.socket" >/dev/null 2>&1 || true + fi + if [[ "$(safe_profile_manifest_value "$manifest_file" HELPER_ACTIVE)" == "true" ]]; then + systemctl start "${PRIVILEGED_HELPER_NAME}.socket" >/dev/null 2>&1 || return 1 + fi + if [[ "$(safe_profile_manifest_value "$manifest_file" COLLECTOR_ENABLED)" == "true" ]]; then + systemctl enable "${AGENT_NAME}.service" >/dev/null 2>&1 || return 1 + else + systemctl disable "${AGENT_NAME}.service" >/dev/null 2>&1 || true + fi + if [[ "$(safe_profile_manifest_value "$manifest_file" COLLECTOR_ACTIVE)" == "true" ]]; then + systemctl start "${AGENT_NAME}.service" >/dev/null 2>&1 || return 1 + fi + + mkdir -p "$SAFE_PROFILE_STATE_DIR" + chmod 0700 "$SAFE_PROFILE_STATE_DIR" + current_tmp=$(mktemp "${SAFE_PROFILE_STATE_DIR}/.current.XXXXXX") + chmod 0600 "$current_tmp" + printf '%s\n' \ + "FORMAT_VERSION=1" \ + "CURRENT_PROFILE=${prior_profile}" \ + "PREVIOUS_PROFILE=typed-helper-monitoring-only" \ + "LAST_ROLLBACK_TRANSACTION=${transaction_dir}" \ + "ROLLBACK_REASON=${reason}" > "$current_tmp" + mv "$current_tmp" "$SAFE_PROFILE_CURRENT_FILE" + SAFE_PROFILE_TRANSACTION_COMMITTED="false" + log_info "Restored collector/helper profile ${prior_profile}; the action runner was left unchanged." +} + +safe_profile_remove_legacy_authority() { + rm -f "$PRIVILEGE_SUDOERS_FILE" + rm -f "${PRIVILEGE_HELPER_DIR}/smartctl" "${PRIVILEGE_HELPER_DIR}/pct" + if getent group docker >/dev/null 2>&1 && id "$LEAST_PRIVILEGE_USER" >/dev/null 2>&1; then + gpasswd -d "$LEAST_PRIVILEGE_USER" docker >/dev/null 2>&1 || true + fi +} + +safe_profile_verify_declared_health() { + local health_url="" + 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 +} + +safe_profile_commit_transaction() { + local current_tmp="" + local prior_profile="" + [[ "$SAFE_PROFILE_TRANSACTION_ACTIVE" == "true" && -n "$SAFE_PROFILE_TRANSACTION_DIR" ]] || return 1 + prior_profile=$(safe_profile_manifest_value "${SAFE_PROFILE_TRANSACTION_DIR}/manifest.env" PRIOR_PROFILE) + current_tmp=$(mktemp "${SAFE_PROFILE_STATE_DIR}/.current.XXXXXX") + chmod 0600 "$current_tmp" + printf '%s\n' \ + "FORMAT_VERSION=1" \ + "PRIOR_PROFILE=${prior_profile}" \ + "CURRENT_PROFILE=typed-helper-monitoring-only" \ + "TRANSACTION_DIR=${SAFE_PROFILE_TRANSACTION_DIR}" \ + "COMMITTED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$current_tmp" + mv "$current_tmp" "$SAFE_PROFILE_CURRENT_FILE" + SAFE_PROFILE_TRANSACTION_COMMITTED="true" + SAFE_PROFILE_TRANSACTION_ACTIVE="false" + log_info "Committed typed-helper monitoring-only profile; rollback snapshot retained at ${SAFE_PROFILE_TRANSACTION_DIR}." +} + +safe_profile_rollback_last() { + local transaction_dir="" + [[ -f "$SAFE_PROFILE_CURRENT_FILE" && ! -L "$SAFE_PROFILE_CURRENT_FILE" ]] || + fail "No committed safe-profile migration is available to roll back" "$EXIT_MISSING_ARGS" + transaction_dir=$(safe_profile_manifest_value "$SAFE_PROFILE_CURRENT_FILE" TRANSACTION_DIR) + [[ -n "$transaction_dir" ]] || + fail "The current profile record has no active migration snapshot to roll back" "$EXIT_MISSING_ARGS" + safe_profile_restore_transaction "$transaction_dir" "explicit-operator-rollback" || + fail "Safe-profile rollback failed closed; the action runner was not changed" "$EXIT_GENERAL" +} + render_systemd_agent_unit() { local unit_path="$1" local exec_path="$2" @@ -2109,11 +2744,6 @@ build_exec_arg_items() { if [[ -n "$OBSERVERS_FILE" ]]; then EXEC_ARG_ITEMS+=(--observers-file "$OBSERVERS_FILE"); fi if [[ "$ENABLE_COMMANDS" == "true" ]]; then EXEC_ARG_ITEMS+=(--enable-commands); fi EXEC_ARG_ITEMS+=(--command-authority "${COMMAND_AUTHORITY:-legacy}") - if [[ "${PRIVILEGED_HELPER_ENABLED:-false}" == "true" ]]; then - # The collector cannot replace its root-owned binary. Keep in-process - # updates off until signed activation moves behind the typed helper. - EXEC_ARG_ITEMS+=(--disable-auto-update) - fi if [[ "$HEALTH_ADDR_SET" == "true" ]]; then EXEC_ARG_ITEMS+=(--health-addr "$HEALTH_ADDR"); fi if [[ "$ENROLL" == "true" ]]; then EXEC_ARG_ITEMS+=(--enroll); fi if [[ "$KUBE_INCLUDE_ALL_PODS" == "true" ]]; then EXEC_ARG_ITEMS+=(--kube-include-all-pods); fi @@ -3124,9 +3754,16 @@ while [[ $# -gt 0 ]]; do --least-privilege) LEAST_PRIVILEGE="true"; shift ;; --enable-privileged-helper) PRIVILEGED_HELPER_ENABLED="true"; PRIVILEGED_HELPER_EXPLICIT="true"; shift ;; --disable-privileged-helper) PRIVILEGED_HELPER_ENABLED="false"; PRIVILEGED_HELPER_EXPLICIT="true"; shift ;; + --enable-action-runner) ACTION_RUNNER_ENABLED="true"; ACTION_RUNNER_EXPLICIT="true"; shift ;; + --disable-action-runner) ACTION_RUNNER_ENABLED="false"; ACTION_RUNNER_EXPLICIT="true"; shift ;; + --uninstall-action-runner) UNINSTALL_ACTION_RUNNER="true"; shift ;; + --action-token-file) ACTION_TOKEN_FILE_PATH="$2"; shift 2 ;; --grant-smart) GRANT_SMART="true"; shift ;; --grant-pct) GRANT_PCT="true"; shift ;; --health-addr) HEALTH_ADDR="$2"; HEALTH_ADDR_SET="true"; shift 2 ;; + --safe-profile-inspect) SAFE_PROFILE_ACTION="inspect"; shift ;; + --safe-profile-apply) SAFE_PROFILE_ACTION="apply"; shift ;; + --safe-profile-rollback) SAFE_PROFILE_ACTION="rollback"; shift ;; --enroll) ENROLL="true"; shift ;; --update) UPDATE_ONLY="true"; shift ;; --uninstall) UNINSTALL="true"; shift ;; @@ -3147,6 +3784,37 @@ while [[ $# -gt 0 ]]; do esac done +case "$SAFE_PROFILE_ACTION" in + "") ;; + inspect) + if [[ "$UPDATE_ONLY" == "true" || "$UNINSTALL" == "true" || "$UNINSTALL_ACTION_RUNNER" == "true" ]]; then + fail "--safe-profile-inspect is a standalone read-only action" "$EXIT_MISSING_ARGS" + fi + ;; + apply) + if [[ "$UPDATE_ONLY" == "true" || "$UNINSTALL" == "true" || "$UNINSTALL_ACTION_RUNNER" == "true" || + "$ACTION_RUNNER_EXPLICIT" == "true" ]]; then + fail "--safe-profile-apply is an explicit collector/helper transaction and cannot be combined with another lifecycle action" "$EXIT_MISSING_ARGS" + fi + UPDATE_ONLY="true" + LEAST_PRIVILEGE="true" + PRIVILEGED_HELPER_ENABLED="true" + PRIVILEGED_HELPER_EXPLICIT="true" + ENABLE_COMMANDS="false" + COMMAND_AUTHORITY="monitoring-only" + COMMAND_AUTHORITY_SOURCE="explicit" + GRANT_SMART="false" + GRANT_PCT="false" + ;; + rollback) + if [[ "$UPDATE_ONLY" == "true" || "$UNINSTALL" == "true" || "$UNINSTALL_ACTION_RUNNER" == "true" || + "$ACTION_RUNNER_EXPLICIT" == "true" ]]; then + fail "--safe-profile-rollback is a standalone collector/helper lifecycle action" "$EXIT_MISSING_ARGS" + fi + ;; + *) fail "Internal safe-profile action is invalid" "$EXIT_GENERAL" ;; +esac + discover_state_dir_from_saved_installer "$0" || true if [[ -z "$STATE_DIR" || "$STATE_DIR" != /* || "$STATE_DIR" == "/" || @@ -3178,6 +3846,25 @@ if [[ -n "$TOKEN_FILE_PATH" ]]; then fi fi +if [[ -n "$ACTION_TOKEN_FILE_PATH" ]]; then + if [[ ! -f "$ACTION_TOKEN_FILE_PATH" || -L "$ACTION_TOKEN_FILE_PATH" ]]; then + fail "Action token file must be a regular non-symlink file: ${ACTION_TOKEN_FILE_PATH}" "$EXIT_MISSING_ARGS" + fi + ACTION_TOKEN_MODE=$(stat -c '%a' "$ACTION_TOKEN_FILE_PATH" 2>/dev/null || true) + if [[ ! "$ACTION_TOKEN_MODE" =~ ^[0-7]{3,4}$ ]] || (( (8#$ACTION_TOKEN_MODE & 8#077) != 0 )); then + fail "Action token file must be inaccessible to group and other users (mode 0600 or stricter)" "$EXIT_MISSING_ARGS" + fi + ACTION_TOKEN_SIZE=$(wc -c < "$ACTION_TOKEN_FILE_PATH" 2>/dev/null | tr -d ' ' || true) + if [[ ! "$ACTION_TOKEN_SIZE" =~ ^[0-9]+$ || "$ACTION_TOKEN_SIZE" -lt 1 || "$ACTION_TOKEN_SIZE" -gt 4096 ]]; then + fail "Action token file must contain between 1 and 4096 bytes" "$EXIT_MISSING_ARGS" + fi + ACTION_TOKEN=$(cat "$ACTION_TOKEN_FILE_PATH") + if [[ -z "$ACTION_TOKEN" || "$ACTION_TOKEN" == *$'\r'* || "$ACTION_TOKEN" == *$'\n'* ]]; then + fail "Action token file must contain one non-empty token value" "$EXIT_MISSING_ARGS" + fi + ACTION_TOKEN_FILE_PATH="" +fi + if [[ -n "$PROXMOX_TYPE" && "$PROXMOX_TYPE" != "pve" && "$PROXMOX_TYPE" != "pbs" ]]; then fail "Invalid --proxmox-type value: ${PROXMOX_TYPE} (expected 'pve' or 'pbs')" fi @@ -3200,11 +3887,56 @@ fi if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" && ( "$GRANT_SMART" == "true" || "$GRANT_PCT" == "true" ) ]]; then fail "--enable-privileged-helper cannot be combined with legacy --grant-smart/--grant-pct sudo paths" "$EXIT_MISSING_ARGS" fi +if [[ "$ACTION_RUNNER_EXPLICIT" != "true" && "$UNINSTALL" != "true" && + "$SAFE_PROFILE_ACTION" != "apply" && + "$UNINSTALL_ACTION_RUNNER" != "true" && -f "$ACTION_RUNNER_SERVICE_UNIT" ]]; then + ACTION_RUNNER_ENABLED="true" + log_info "Preserving existing separately enabled action-runner profile" +fi +if [[ "$ACTION_RUNNER_ENABLED" == "true" && + ( "$LEAST_PRIVILEGE" != "true" || "$PRIVILEGED_HELPER_ENABLED" != "true" ) ]]; then + fail "--enable-action-runner requires the safe --least-privilege --enable-privileged-helper collector profile" "$EXIT_MISSING_ARGS" +fi +if [[ "$ACTION_RUNNER_ENABLED" == "true" && "$ENABLE_COMMANDS" == "true" ]]; then + fail "--enable-action-runner cannot be combined with legacy collector --enable-commands" "$EXIT_MISSING_ARGS" +fi +if [[ -n "$ACTION_TOKEN" && "$ACTION_RUNNER_ENABLED" != "true" ]]; then + fail "--action-token-file requires --enable-action-runner (or an existing preserved runner profile)" "$EXIT_MISSING_ARGS" +fi +if [[ "$ACTION_RUNNER_ENABLED" == "true" && "$PREFLIGHT_ONLY" != "true" && + -z "$ACTION_TOKEN" && ! -s "$ACTION_RUNNER_TOKEN_FILE" ]]; then + fail "--enable-action-runner requires a separate --action-token-file on first install" "$EXIT_MISSING_ARGS" +fi +if [[ -n "$ACTION_TOKEN" && -n "$PULSE_TOKEN" && "$ACTION_TOKEN" == "$PULSE_TOKEN" ]]; then + fail "The action runner must use a separate credential from the collector token" "$EXIT_MISSING_ARGS" +fi # --- Check Root --- if [[ $EUID -ne 0 && "$PREFLIGHT_ONLY" != "true" ]]; then - echo "This script must be run as root. Please use sudo." - exit 1 + if [[ "$SAFE_PROFILE_ACTION" != "inspect" ]]; then + echo "This script must be run as root. Please use sudo." + exit 1 + fi +fi + +if [[ "$SAFE_PROFILE_ACTION" == "inspect" ]]; then + safe_profile_inspect + exit $? +fi +if [[ "$SAFE_PROFILE_ACTION" == "rollback" ]]; then + safe_profile_platform_supported || + fail "Safe-profile rollback is supported only on standard Linux systemd hosts; no broader-privilege fallback was applied" "$EXIT_MISSING_ARGS" + safe_profile_rollback_last + exit 0 +fi + +if [[ "$UNINSTALL_ACTION_RUNNER" == "true" ]]; then + if [[ "$UNINSTALL" == "true" || "$UPDATE_ONLY" == "true" || "$ACTION_RUNNER_ENABLED" == "true" ]]; then + fail "--uninstall-action-runner is a standalone runner-only lifecycle action" "$EXIT_MISSING_ARGS" + fi + teardown_action_runner_service + log_info "Pulse action runner removed. Collector monitoring was left installed and running." + exit 0 fi # --- URL Normalization --- @@ -3246,6 +3978,21 @@ if [[ "$UPDATE_ONLY" == "true" || "$UNINSTALL" == "true" ]]; then fi fi +if [[ -n "$ACTION_TOKEN" && -n "$PULSE_TOKEN" && "$ACTION_TOKEN" == "$PULSE_TOKEN" ]]; then + fail "The action runner must use a separate credential from the collector token" "$EXIT_MISSING_ARGS" +fi + +if [[ "$SAFE_PROFILE_ACTION" == "apply" ]]; then + # Recovered legacy service arguments may include command authority. The + # explicit migration always lowers the collector ceiling and never carries + # command execution or sudo grants into the safe profile. + ENABLE_COMMANDS="false" + COMMAND_AUTHORITY="monitoring-only" + COMMAND_AUTHORITY_SOURCE="explicit" + GRANT_SMART="false" + GRANT_PCT="false" +fi + resolve_command_authority_profile if [[ -z "$STATE_DIR" || "$STATE_DIR" != /* || "$STATE_DIR" == "/" || @@ -3503,6 +4250,7 @@ if [[ "$UNINSTALL" == "true" ]]; then # Systemd - unified agent if command -v systemctl >/dev/null 2>&1; then + teardown_action_runner_service teardown_privileged_helper_service teardown_systemd_agent_service fi @@ -3712,6 +4460,12 @@ if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" && "$UNINSTALL" != "true" ]]; then fi fi +if [[ "$SAFE_PROFILE_ACTION" == "apply" ]]; then + safe_profile_platform_supported || + fail "Safe-profile migration is supported only on standard Linux systemd hosts; no broader-privilege fallback was applied" "$EXIT_MISSING_ARGS" + safe_profile_begin_transaction +fi + # Create the dedicated service account for the least-privilege profile and # hand it the mutable state directory. The legacy least-privilege profile also # owns its agent binary so its in-process updater keeps working. The typed @@ -3864,6 +4618,23 @@ verify_privileged_helper_socket() { } provision_typed_privileged_helper() { + # The collector may write only into its quarantine. The root helper may + # read that tree, but activation and rollback state live in a separate + # root-only boundary. The helper's protocol fixes the sole activation + # target at /usr/local/bin/pulse-agent. + install -d -o "$LEAST_PRIVILEGE_USER" -g "$LEAST_PRIVILEGE_USER" -m 0700 \ + "$PRIVILEGED_HELPER_UPDATE_QUARANTINE_DIR" + install -d -o root -g root -m 0700 \ + "$PRIVILEGED_HELPER_STATE_DIR" "$PRIVILEGED_HELPER_UPDATE_STAGING_DIR" + if [[ -L "${INSTALL_DIR}/${BINARY_NAME}" || ! -f "${INSTALL_DIR}/${BINARY_NAME}" ]]; then + fail "Typed privileged-helper updates require a regular installed agent binary" "$EXIT_GENERAL" + fi + if [[ "${INSTALL_DIR}/${BINARY_NAME}" != "/usr/local/bin/pulse-agent" ]]; then + fail "Typed privileged-helper updates require the fixed /usr/local/bin/pulse-agent target" "$EXIT_GENERAL" + fi + chown root:root "${INSTALL_DIR}/${BINARY_NAME}" + chmod 0755 "${INSTALL_DIR}/${BINARY_NAME}" + render_privileged_helper_socket_unit "$PRIVILEGED_HELPER_SOCKET_UNIT" render_privileged_helper_service_unit "$PRIVILEGED_HELPER_SERVICE_UNIT" "$PRIVILEGED_HELPER_BINARY_PATH" chown root:root "$PRIVILEGED_HELPER_SOCKET_UNIT" "$PRIVILEGED_HELPER_SERVICE_UNIT" @@ -4001,6 +4772,22 @@ if [[ "$PREFLIGHT_ONLY" == "true" ]]; then PREFLIGHT_EXIT="$EXIT_DOWNLOAD_FAILED" fi fi + if [[ "$ACTION_RUNNER_ENABLED" == "true" ]]; then + : > "$PREFLIGHT_HEADERS" + RUNNER_DOWNLOAD_CHECK_URL="${PULSE_URL}/download/${ACTION_RUNNER_BINARY_NAME}?arch=${PF_ARCH_PARAM}" + if curl "${CURL_DOWNLOAD_CHECK_ARGS[@]}" "$RUNNER_DOWNLOAD_CHECK_URL"; then + PREFLIGHT_RUNNER_SHA=$(final_response_header_value "$PREFLIGHT_HEADERS" "X-Checksum-Sha256" || true) + if [[ -n "$PREFLIGHT_RUNNER_SHA" ]]; then + json_event "preflight" "runner_download_available" "Typed action runner binary available for ${PF_ARCH_PARAM}" + else + json_event "preflight" "runner_download_checksum_missing" "Typed action runner download did not include checksum header" "$EXIT_CHECKSUM_FAILED" + PREFLIGHT_EXIT="$EXIT_CHECKSUM_FAILED" + fi + else + json_event "preflight" "runner_download_unavailable" "Typed action runner binary unavailable for ${PF_ARCH_PARAM}" "$EXIT_DOWNLOAD_FAILED" + PREFLIGHT_EXIT="$EXIT_DOWNLOAD_FAILED" + fi + fi fi # Output summary @@ -4144,6 +4931,49 @@ download_verified_privileged_helper() { chmod 0755 "$TMP_HELPER_BIN" log_info "Typed privileged helper binary verified" } + +download_verified_action_runner() { + local runner_headers runner_url runner_magic runner_expected_sha runner_signature runner_actual_sha + local -a runner_curl_args + + TMP_ACTION_RUNNER_BIN=$(mktemp) + runner_headers=$(mktemp) + TMP_FILES+=("$TMP_ACTION_RUNNER_BIN" "$runner_headers") + runner_curl_args=(-fsSL --connect-timeout 30 --max-time 300 -D "$runner_headers" -o "$TMP_ACTION_RUNNER_BIN") + if [[ "$INSECURE" == "true" ]]; then runner_curl_args+=(-k); fi + if [[ -n "$CURL_CA_BUNDLE" ]]; then runner_curl_args+=(--cacert "$CURL_CA_BUNDLE"); fi + + runner_url="${PULSE_URL}/download/${ACTION_RUNNER_BINARY_NAME}?${DOWNLOAD_QUERY}" + log_info "Downloading typed action runner from ${runner_url}..." + if ! curl "${runner_curl_args[@]}" "$runner_url"; then + fail "Typed action runner download failed; the existing runner and collector were not changed." "$EXIT_DOWNLOAD_FAILED" + fi + if [[ ! -s "$TMP_ACTION_RUNNER_BIN" ]]; then + fail "Downloaded typed action runner is empty; the existing runner and collector were not changed." "$EXIT_DOWNLOAD_FAILED" + fi + if runner_magic=$(read_magic_hex "$TMP_ACTION_RUNNER_BIN"); then + if [[ "$runner_magic" != "7f454c46" ]]; then + fail "Downloaded typed action runner is not a Linux ELF executable." "$EXIT_DOWNLOAD_FAILED" + fi + else + log_warn "No od/hexdump/xxd available to inspect the typed action runner; relying on checksum verification." + fi + runner_expected_sha=$(final_response_header_value "$runner_headers" "X-Checksum-Sha256" || true) + runner_signature=$(final_response_header_value "$runner_headers" "X-Signature-SSHSIG" || true) + if [[ -z "$runner_expected_sha" ]]; then + fail "Typed action runner download omitted its checksum; refusing install." "$EXIT_CHECKSUM_FAILED" + fi + if has_pinned_installer_signature_key && [[ -z "$runner_signature" ]]; then + fail "Typed action runner download omitted its signature; refusing install." "$EXIT_SIGNATURE_FAILED" + fi + runner_actual_sha=$(sha256sum "$TMP_ACTION_RUNNER_BIN" 2>/dev/null | awk '{print $1}' || shasum -a 256 "$TMP_ACTION_RUNNER_BIN" 2>/dev/null | awk '{print $1}') + if [[ -z "$runner_actual_sha" || "$runner_actual_sha" != "$runner_expected_sha" ]]; then + fail "Typed action runner checksum verification failed." "$EXIT_CHECKSUM_FAILED" + fi + verify_download_signature "$TMP_ACTION_RUNNER_BIN" "$runner_signature" + chmod 0755 "$TMP_ACTION_RUNNER_BIN" + log_info "Typed action runner binary verified" +} if [[ "$OS" == "linux" || "$OS" == "freebsd" ]]; then if MAGIC=$(read_magic_hex "$TMP_BIN"); then if [[ "$MAGIC" != "7f454c46" ]]; then @@ -4189,6 +5019,9 @@ verify_download_signature "$TMP_BIN" "$SSH_SIGNATURE_HEADER" if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then download_verified_privileged_helper fi +if [[ "$ACTION_RUNNER_ENABLED" == "true" ]]; then + download_verified_action_runner +fi chmod +x "$TMP_BIN" NEW_VERSION=$("$TMP_BIN" --version 2>/dev/null | head -1 || echo "unknown") @@ -4253,14 +5086,28 @@ fi # Install Binary log_info "Installing binary to ${INSTALL_DIR}/${BINARY_NAME}..." mkdir -p "$INSTALL_DIR" -mv "$TMP_BIN" "${INSTALL_DIR}/${BINARY_NAME}" -chmod +x "${INSTALL_DIR}/${BINARY_NAME}" +if [[ "$SAFE_PROFILE_ACTION" == "apply" ]]; then + SAFE_PROFILE_STAGED_COLLECTOR="${INSTALL_DIR}/.${BINARY_NAME}.safe-profile-new.$$" + TMP_FILES+=("$SAFE_PROFILE_STAGED_COLLECTOR") + install -o root -g root -m 0755 "$TMP_BIN" "$SAFE_PROFILE_STAGED_COLLECTOR" + mv "$SAFE_PROFILE_STAGED_COLLECTOR" "${INSTALL_DIR}/${BINARY_NAME}" +else + mv "$TMP_BIN" "${INSTALL_DIR}/${BINARY_NAME}" + chmod +x "${INSTALL_DIR}/${BINARY_NAME}" +fi if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then mkdir -p "$PRIVILEGE_HELPER_DIR" chown root:root "$PRIVILEGE_HELPER_DIR" chmod 0755 "$PRIVILEGE_HELPER_DIR" - mv "$TMP_HELPER_BIN" "$PRIVILEGED_HELPER_BINARY_PATH" + if [[ "$SAFE_PROFILE_ACTION" == "apply" ]]; then + SAFE_PROFILE_STAGED_HELPER="${PRIVILEGED_HELPER_BINARY_PATH}.safe-profile-new.$$" + TMP_FILES+=("$SAFE_PROFILE_STAGED_HELPER") + install -o root -g root -m 0755 "$TMP_HELPER_BIN" "$SAFE_PROFILE_STAGED_HELPER" + mv "$SAFE_PROFILE_STAGED_HELPER" "$PRIVILEGED_HELPER_BINARY_PATH" + else + mv "$TMP_HELPER_BIN" "$PRIVILEGED_HELPER_BINARY_PATH" + fi TMP_HELPER_BIN="" chown root:root "$PRIVILEGED_HELPER_BINARY_PATH" chmod 0755 "$PRIVILEGED_HELPER_BINARY_PATH" @@ -4888,7 +5735,7 @@ if command -v systemctl >/dev/null 2>&1; then LEAST_PRIVILEGE="true" log_info "Preserving existing least-privilege profile (User=${LEAST_PRIVILEGE_USER})" fi - if [[ "$LEAST_PRIVILEGE" == "true" ]]; then + if [[ "$LEAST_PRIVILEGE" == "true" && "$SAFE_PROFILE_ACTION" != "apply" ]]; then if [[ "$GRANT_SMART" != "true" ]] && grep -q "PULSE_SMARTCTL_PATH=${PRIVILEGE_HELPER_DIR}/" "$UNIT"; then GRANT_SMART="true" fi @@ -4916,6 +5763,9 @@ if command -v systemctl >/dev/null 2>&1; then if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then protect_typed_profile_credentials provision_typed_privileged_helper + if [[ "$SAFE_PROFILE_ACTION" == "apply" ]]; then + safe_profile_remove_legacy_authority + fi log_info "Typed-helper collector profile: service runs as ${SERVICE_USER}; binaries and credential files remain root-owned while mutable state remains ${SERVICE_USER}-owned." else provision_privilege_helpers @@ -4934,6 +5784,19 @@ if command -v systemctl >/dev/null 2>&1; then restore_selinux_contexts restart_systemd_agent_service + if [[ "$SAFE_PROFILE_ACTION" == "apply" ]]; then + if ! safe_profile_verify_declared_health; then + fail "Safe-profile collector did not satisfy local readiness, helper availability, and server registration; restoring the previous profile" "$EXIT_GENERAL" + fi + safe_profile_commit_transaction || + fail "Safe-profile health passed but its atomic profile record could not be committed; restoring the previous profile" "$EXIT_GENERAL" + fi + if [[ "$ACTION_RUNNER_EXPLICIT" == "true" && "$ACTION_RUNNER_ENABLED" != "true" ]]; then + teardown_action_runner_service + log_info "Typed action runner removed; collector monitoring remains active." + elif [[ "$ACTION_RUNNER_ENABLED" == "true" ]]; then + provision_action_runner + fi complete_installation_flow "$STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "journalctl -u ${AGENT_NAME} --no-pager -n 20" if [[ "$UPGRADE_MODE" != "true" && -n "$PULSE_TOKEN" ]]; then if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 69a9850a1..4a4c5e29e 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -196,6 +196,9 @@ done for target in "${PULSE_RELEASE_AGENT_HELPER_TARGETS[@]}"; do printf 'helper:%s:%s\n' "${target}" "$(pulse_release_binary_filename agent-helper "${target}")" done +for target in "${PULSE_RELEASE_AGENT_RUNNER_TARGETS[@]}"; do + printf 'runner:%s:%s\n' "${target}" "$(pulse_release_binary_filename agent-runner "${target}")" +done `, "pulse-agent-helper-target-test", targetScriptPath) targetOutput, err := targetCmd.CombinedOutput() if err != nil { @@ -204,6 +207,7 @@ done var linuxAgentTargets []string var helperTargets []string + var runnerTargets []string for _, line := range strings.Split(strings.TrimSpace(string(targetOutput)), "\n") { switch { case strings.HasPrefix(line, "agent:"): @@ -218,11 +222,24 @@ done if parts[2] != wantFilename { t.Fatalf("helper target %s filename = %s, want %s", parts[1], parts[2], wantFilename) } + case strings.HasPrefix(line, "runner:"): + parts := strings.Split(line, ":") + if len(parts) != 3 { + t.Fatalf("unexpected runner target output %q", line) + } + runnerTargets = append(runnerTargets, parts[1]) + wantFilename := "pulse-agent-runner-" + parts[1] + if parts[2] != wantFilename { + t.Fatalf("runner target %s filename = %s, want %s", parts[1], parts[2], wantFilename) + } } } if got, want := strings.Join(helperTargets, ","), strings.Join(linuxAgentTargets, ","); got != want { t.Fatalf("helper target matrix = %s, want Linux Unified Agent matrix %s", got, want) } + if got, want := strings.Join(runnerTargets, ","), strings.Join(linuxAgentTargets, ","); got != want { + t.Fatalf("runner target matrix = %s, want Linux Unified Agent matrix %s", got, want) + } buildBytes, err := os.ReadFile(repoFile("scripts", "build-release.sh")) if err != nil { @@ -249,6 +266,7 @@ done `agent_helper_build_order=("${PULSE_RELEASE_AGENT_HELPER_TARGETS[@]}")`, `output_path="${BUILD_DIR}/$(pulse_release_binary_filename agent-helper "${target}")"`, `./cmd/pulse-agent-helper`, + `-ldflags="${agent_ldflags}"`, `cp "$BUILD_DIR/pulse-agent-helper-${target}" "$universal_dir/bin/pulse-agent-helper-${target}"`, `tar -czf "$RELEASE_DIR/pulse-agent-helper-v${VERSION}-${target}.tar.gz" -C "$BUILD_DIR" "pulse-agent-helper-${target}"`, `cp "$BUILD_DIR/pulse-agent-helper-${target}" "$RELEASE_DIR/"`, @@ -257,6 +275,18 @@ done t.Fatalf("build-release.sh missing agent helper release wiring: %s", needle) } } + for _, needle := range []string{ + `agent_runner_build_order=("${PULSE_RELEASE_AGENT_RUNNER_TARGETS[@]}")`, + `output_path="${BUILD_DIR}/$(pulse_release_binary_filename agent-runner "${target}")"`, + `./cmd/pulse-agent-runner`, + `cp "$BUILD_DIR/pulse-agent-runner-${target}" "$universal_dir/bin/pulse-agent-runner-${target}"`, + `tar -czf "$RELEASE_DIR/pulse-agent-runner-v${VERSION}-${target}.tar.gz" -C "$BUILD_DIR" "pulse-agent-runner-${target}"`, + `cp "$BUILD_DIR/pulse-agent-runner-${target}" "$RELEASE_DIR/"`, + } { + if !strings.Contains(buildScript, needle) { + t.Fatalf("build-release.sh missing agent runner release wiring: %s", needle) + } + } for _, needle := range []string{ `for target in "${PULSE_RELEASE_AGENT_HELPER_TARGETS[@]}"; do`, `task_components+=(agent-helper)`, @@ -266,6 +296,15 @@ done t.Fatalf("build-release-binaries.sh missing agent helper compilation wiring: %s", needle) } } + for _, needle := range []string{ + `for target in "${PULSE_RELEASE_AGENT_RUNNER_TARGETS[@]}"; do`, + `task_components+=(agent-runner)`, + `package=./cmd/pulse-agent-runner`, + } { + if !strings.Contains(compileScript, needle) { + t.Fatalf("build-release-binaries.sh missing action runner compilation wiring: %s", needle) + } + } for _, needle := range []string{ `if [[ ${#PULSE_RELEASE_AGENT_HELPER_TARGETS[@]} -eq 0 ]]; then`, `src="${agent_binary_dir}/pulse-agent-helper-${target}"`, @@ -276,6 +315,15 @@ done t.Fatalf("release_asset_common.sh missing agent helper packaging wiring: %s", needle) } } + for _, needle := range []string{ + `if [[ ${#PULSE_RELEASE_AGENT_RUNNER_TARGETS[@]} -eq 0 ]]; then`, + `src="${agent_binary_dir}/pulse-agent-runner-${target}"`, + `dest="${staging_dir}/bin/pulse-agent-runner-${target}"`, + } { + if !strings.Contains(commonScript, needle) { + t.Fatalf("release_asset_common.sh missing action runner packaging wiring: %s", needle) + } + } helperStage := strings.Index(commonScript, `src="${agent_binary_dir}/pulse-agent-helper-${target}"`) binSigning := strings.Index(commonScript, `pulse_release_sign_directory_assets "${staging_dir}/bin"`) if helperStage < 0 || binSigning < 0 || helperStage > binSigning { @@ -301,6 +349,12 @@ done t.Fatalf("create-release.yml missing bare helper upload: %s", asset) } } + for _, target := range runnerTargets { + asset := "release/pulse-agent-runner-" + target + if !strings.Contains(workflow, asset) { + t.Fatalf("create-release.yml missing bare action runner upload: %s", asset) + } + } for _, signatureGlob := range []string{ `release_upload_with_retry "${TAG}" release/*.sig --clobber`, `release_upload_with_retry "${TAG}" release/*.sshsig --clobber`, @@ -406,6 +460,12 @@ func TestReleaseContainerContextTreatsServerSignaturesAsArchitectureBound(t *tes files[name+".sig"] = "shared-helper-signature-" + helperTarget files[name+".sshsig"] = "shared-helper-ssh-signature-" + helperTarget } + for _, runnerTarget := range []string{"linux-amd64", "linux-arm64", "linux-armv7", "linux-armv6", "linux-386"} { + name := "bin/pulse-agent-runner-" + runnerTarget + files[name] = "shared-runner-" + runnerTarget + files[name+".sig"] = "shared-runner-signature-" + runnerTarget + files[name+".sshsig"] = "shared-runner-ssh-signature-" + runnerTarget + } if driftUniversalPayload { files["scripts/install.sh"] = "drifted-agent-installer" } @@ -735,6 +795,11 @@ func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) { `release/pulse-agent-helper-linux-armv7`, `release/pulse-agent-helper-linux-armv6`, `release/pulse-agent-helper-linux-386`, + `release/pulse-agent-runner-linux-amd64`, + `release/pulse-agent-runner-linux-arm64`, + `release/pulse-agent-runner-linux-armv7`, + `release/pulse-agent-runner-linux-armv6`, + `release/pulse-agent-runner-linux-386`, `release/pulse-agent-freebsd-amd64`, `release/pulse-agent-freebsd-arm64`, `release/pulse-agent-windows-amd64.exe`, diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index bb615ef06..bf184cc4e 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -5640,6 +5640,8 @@ func TestInstallSHTypedPrivilegedHelperUnits(t *testing.T) { set -euo pipefail PRIVILEGED_HELPER_NAME="pulse-agent-helper" PRIVILEGED_HELPER_SOCKET_PATH="/run/pulse-agent/helper.sock" + PRIVILEGED_HELPER_UPDATE_QUARANTINE_DIR="/var/lib/pulse-agent/update-quarantine" + PRIVILEGED_HELPER_STATE_DIR="/var/lib/pulse-agent-helper" LEAST_PRIVILEGE_USER="pulse-agent" ` + extractInstallShellFunction(t, "render_privileged_helper_socket_unit") + ` ` + extractInstallShellFunction(t, "render_privileged_helper_service_unit") + ` @@ -5682,6 +5684,8 @@ func TestInstallSHTypedPrivilegedHelperUnits(t *testing.T) { "RestrictAddressFamilies=AF_UNIX", "ProtectSystem=strict", "ProtectHome=true", + "ReadOnlyPaths=/var/lib/pulse-agent/update-quarantine", + "ReadWritePaths=/var/lib/pulse-agent-helper /usr/local/bin", } { if !strings.Contains(service, required) { t.Fatalf("typed helper service unit missing %q:\n%s", required, service) @@ -5708,7 +5712,6 @@ func TestInstallSHTypedPrivilegedHelperProfileIsOptInAndFailClosed(t *testing.T) `--enable-privileged-helper is supported only on standard Linux systemd hosts; no broader-privilege fallback was applied`, `Preserving existing typed privileged-helper profile`, `PULSE_AGENT_HELPER_SOCKET`, - `EXEC_ARG_ITEMS+=(--disable-auto-update)`, `chown root:root "${INSTALL_DIR}/${BINARY_NAME}"`, `chown root:root "$PRIVILEGED_HELPER_BINARY_PATH"`, `chown -R "${LEAST_PRIVILEGE_USER}:${LEAST_PRIVILEGE_USER}" "$STATE_DIR"`, @@ -5729,6 +5732,115 @@ func TestInstallSHTypedPrivilegedHelperProfileIsOptInAndFailClosed(t *testing.T) t.Fatalf("install.sh missing typed-helper invariant: %s", required) } } + if strings.Contains(script, `EXEC_ARG_ITEMS+=(--disable-auto-update)`) { + t.Fatal("typed helper profile must keep updater enabled for signed helper-backed activation") + } +} + +func TestInstallSHTypedPrivilegeHelperUpdateFilesystemBoundary(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 _, required := range []string{ + `PRIVILEGED_HELPER_STATE_DIR="/var/lib/pulse-agent-helper"`, + `PRIVILEGED_HELPER_UPDATE_STAGING_DIR="${PRIVILEGED_HELPER_STATE_DIR}/update-staging"`, + `PRIVILEGED_HELPER_UPDATE_QUARANTINE_DIR="/var/lib/pulse-agent/update-quarantine"`, + `ReadOnlyPaths=${PRIVILEGED_HELPER_UPDATE_QUARANTINE_DIR}`, + `ReadWritePaths=${PRIVILEGED_HELPER_STATE_DIR} /usr/local/bin`, + `install -d -o "$LEAST_PRIVILEGE_USER" -g "$LEAST_PRIVILEGE_USER" -m 0700`, + `install -d -o root -g root -m 0700`, + `Typed privileged-helper updates require the fixed /usr/local/bin/pulse-agent target`, + } { + if !strings.Contains(script, required) { + t.Fatalf("install.sh missing typed helper update boundary %q", required) + } + } +} + +func TestInstallSHActionRunnerIsSeparateOptInLifecycle(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 _, required := range []string{ + `ACTION_RUNNER_ENABLED="false"`, + `ACTION_RUNNER_NAME="pulse-agent-runner"`, + `ACTION_RUNNER_TOKEN_FILE="${ACTION_RUNNER_CONFIG_DIR}/token"`, + `--enable-action-runner) ACTION_RUNNER_ENABLED="true"; ACTION_RUNNER_EXPLICIT="true"; shift ;;`, + `--disable-action-runner) ACTION_RUNNER_ENABLED="false"; ACTION_RUNNER_EXPLICIT="true"; shift ;;`, + `--uninstall-action-runner) UNINSTALL_ACTION_RUNNER="true"; shift ;;`, + `--enable-action-runner requires the safe --least-privilege --enable-privileged-helper collector profile`, + `--enable-action-runner cannot be combined with legacy collector --enable-commands`, + `--action-token-file requires --enable-action-runner (or an existing preserved runner profile)`, + `The action runner must use a separate credential from the collector token`, + `Preserving existing separately enabled action-runner profile`, + `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"`, + `/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"`, + `chmod 0600 "$ACTION_RUNNER_TOKEN_FILE"`, + `ACTION_TOKEN=""`, + `health_mtime=$(stat -c '%Y' "$ACTION_RUNNER_HEALTH_FILE"`, + `"registered"[[:space:]]*:[[:space:]]*true`, + `"host_id"[[:space:]]*:[[:space:]]*`, + `[[ "$health_agent_id" == "$expected_agent_id" ]]`, + `rolling back runner-only files while leaving monitoring active`, + `Pulse action runner removed. Collector monitoring was left installed and running.`, + } { + if !strings.Contains(script, required) { + t.Fatalf("install.sh missing action-runner invariant: %s", required) + } + } + teardown := extractInstallShellFunction(t, "teardown_action_runner_service") + if strings.Contains(teardown, "teardown_systemd_agent_service") || strings.Contains(teardown, `rm -f "${INSTALL_DIR}/${BINARY_NAME}"`) { + t.Fatalf("runner teardown must not remove the monitoring collector:\n%s", teardown) + } +} + +func TestInstallSHRendersHardenedActionRunnerUnit(t *testing.T) { + root := t.TempDir() + unitPath := filepath.Join(root, "pulse-agent-runner.service") + script := ` + set -euo pipefail + ACTION_RUNNER_ENV_FILE="/etc/pulse-agent-runner/runner.env" + ACTION_RUNNER_STATE_DIR="/var/lib/pulse-agent-runner" +` + extractInstallShellFunction(t, "render_action_runner_service_unit") + ` + render_action_runner_service_unit "` + unitPath + `" "/usr/local/lib/pulse-agent/pulse-agent-runner" + ` + if out, err := exec.Command("bash", "-c", script).CombinedOutput(); err != nil { + t.Fatalf("render action runner unit: %v\n%s", err, out) + } + content, err := os.ReadFile(unitPath) + if err != nil { + t.Fatal(err) + } + unit := string(content) + for _, required := range []string{ + "ExecStart=/usr/local/lib/pulse-agent/pulse-agent-runner", + "EnvironmentFile=/etc/pulse-agent-runner/runner.env", + "User=root", + "Group=root", + "NoNewPrivileges=true", + "ProtectHome=true", + "ProtectSystem=strict", + "ProtectKernelTunables=true", + "ProtectKernelModules=true", + "ProtectControlGroups=true", + "RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6", + "ReadWritePaths=/var/lib/pulse-agent-runner", + } { + if !strings.Contains(unit, required) { + t.Fatalf("action runner unit missing %q:\n%s", required, unit) + } + } + if strings.Contains(unit, "PrivateNetwork=true") { + t.Fatalf("networked action runner cannot use the helper's private-network sandbox:\n%s", unit) + } } func TestInstallSHTypedPrivilegedHelperProtectsCredentialsAfterStateChown(t *testing.T) { diff --git a/scripts/installtests/safe_profile_migration_test.go b/scripts/installtests/safe_profile_migration_test.go new file mode 100644 index 000000000..1005d13ee --- /dev/null +++ b/scripts/installtests/safe_profile_migration_test.go @@ -0,0 +1,289 @@ +package installtests + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func safeProfileInspectFunctions(t *testing.T) string { + t.Helper() + 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_inspect") +} + +func safeProfileTransactionFunctions(t *testing.T) string { + t.Helper() + return extractInstallShellFunction(t, "safe_profile_detect_current_profile") + "\n" + + extractInstallShellFunction(t, "safe_profile_snapshot_entry") + "\n" + + extractInstallShellFunction(t, "safe_profile_manifest_value") + "\n" + + extractInstallShellFunction(t, "safe_profile_begin_transaction") + "\n" + + extractInstallShellFunction(t, "safe_profile_restore_entry") + "\n" + + extractInstallShellFunction(t, "safe_profile_restore_transaction") + "\n" + + extractInstallShellFunction(t, "safe_profile_commit_transaction") +} + +func TestSafeProfileInspectIsReadOnlyAndReportsDifferences(t *testing.T) { + root := t.TempDir() + binDir := filepath.Join(root, "bin") + unitDir := filepath.Join(root, "systemd") + mustMkdirAll(t, binDir, unitDir) + binary := filepath.Join(binDir, "pulse-agent") + unit := filepath.Join(unitDir, "pulse-agent.service") + runner := filepath.Join(unitDir, "pulse-agent-runner.service") + unitBody := "[Service]\nUser=root\nAmbientCapabilities=CAP_SETUID CAP_SETGID\nExecStart=" + binary + " --enable-host --enable-docker --enable-proxmox --enable-commands\n" + mustWrite(t, binary, "collector-before\n") + mustWrite(t, unit, unitBody) + mustWrite(t, runner, "runner-independent\n") + + harness := ` +set -euo pipefail +AGENT_NAME=pulse-agent +BINARY_NAME=pulse-agent +INSTALL_DIR="` + binDir + `" +LEAST_PRIVILEGE_USER=pulse-agent +SAFE_PROFILE_COLLECTOR_UNIT="` + unit + `" +ACTION_RUNNER_SERVICE_UNIT="` + runner + `" +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 + fi +} +id() { if [[ "${1:-}" == -nG ]]; then printf 'root docker\n'; fi; return 0; } +` + safeProfileInspectFunctions(t) + ` +safe_profile_inspect +` + out, err := exec.Command("bash", "-c", harness).CombinedOutput() + if err != nil { + t.Fatalf("inspect: %v\n%s", err, out) + } + for _, want := range []string{ + "platform_supported=true", "current_profile=legacy-root-command-capable", + "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", + "target_groups=no-rootful-docker-group", "degraded_docker=rootful daemon access is removed", + "degraded_actions=collector command authority is removed", + } { + if !strings.Contains(string(out), want) { + t.Fatalf("inspect output missing %q:\n%s", want, out) + } + } + assertFileBody(t, binary, "collector-before\n") + assertFileBody(t, unit, unitBody) + assertFileBody(t, runner, "runner-independent\n") +} + +func TestSafeProfileTransactionCommitAndFailureRollback(t *testing.T) { + t.Run("commit", func(t *testing.T) { + root := t.TempDir() + harness := safeProfileHarness(t, root, false) + ` +safe_profile_begin_transaction +safe_profile_commit_transaction +grep -q '^PRIOR_PROFILE=legacy-root-command-capable$' "$SAFE_PROFILE_CURRENT_FILE" +grep -q '^CURRENT_PROFILE=typed-helper-monitoring-only$' "$SAFE_PROFILE_CURRENT_FILE" +grep -q "^TRANSACTION_DIR=${SAFE_PROFILE_TRANSACTION_DIR}$" "$SAFE_PROFILE_CURRENT_FILE" +test -f "${SAFE_PROFILE_TRANSACTION_DIR}/collector-binary" +test "$SAFE_PROFILE_TRANSACTION_ACTIVE" = false +test "$SAFE_PROFILE_TRANSACTION_COMMITTED" = true +` + if out, err := exec.Command("bash", "-c", harness).CombinedOutput(); err != nil { + t.Fatalf("commit rehearsal: %v\n%s", err, out) + } + }) + + t.Run("failure rollback", func(t *testing.T) { + root := t.TempDir() + harness := safeProfileHarness(t, root, true) + ` +safe_profile_begin_transaction +transaction="$SAFE_PROFILE_TRANSACTION_DIR" +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" +printf 'changed-agent-id\n' > "$STATE_DIR/agent-id" +printf 'changed-connection\n' > "$STATE_DIR/connection.env" +mkdir -p "$PRIVILEGED_HELPER_CREDENTIAL_DIR" +printf 'moved-monitoring-token\n' > "$PRIVILEGED_HELPER_CREDENTIAL_DIR/token" +printf 'runner-still-independent\n' > "$ACTION_RUNNER_SENTINEL" +safe_profile_restore_transaction "$transaction" automatic-failure +cmp "$INSTALL_DIR/$BINARY_NAME" "$EXPECTED_DIR/collector-binary" +cmp "$SAFE_PROFILE_COLLECTOR_UNIT" "$EXPECTED_DIR/collector-unit" +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" +test ! -e "$PRIVILEGED_HELPER_BINARY_PATH" +test ! -e "$PRIVILEGED_HELPER_SERVICE_UNIT" +test ! -e "$PRIVILEGED_HELPER_SOCKET_UNIT" +test ! -e "$PRIVILEGED_HELPER_CREDENTIAL_DIR/token" +grep -q '^legacy sudo grant$' "$PRIVILEGE_SUDOERS_FILE" +grep -q '^legacy smart wrapper$' "$PRIVILEGE_HELPER_DIR/smartctl" +grep -q '^legacy pct wrapper$' "$PRIVILEGE_HELPER_DIR/pct" +grep -q '^runner-still-independent$' "$ACTION_RUNNER_SENTINEL" +grep -q '^CURRENT_PROFILE=legacy-root-command-capable$' "$SAFE_PROFILE_CURRENT_FILE" +grep -q '^gpasswd -a pulse-agent docker$' "$CALL_LOG" +` + if out, err := exec.Command("bash", "-c", harness).CombinedOutput(); err != nil { + t.Fatalf("failure rollback rehearsal: %v\n%s", err, out) + } + }) +} + +func TestSafeProfileMigrationIsExplicitAndRunnerIndependent(t *testing.T) { + content, err := os.ReadFile(repoFile("scripts", "install.sh")) + if err != nil { + t.Fatal(err) + } + script := string(content) + for _, want := range []string{ + `--safe-profile-inspect) SAFE_PROFILE_ACTION="inspect"`, + `--safe-profile-apply) SAFE_PROFILE_ACTION="apply"`, + `--safe-profile-rollback) SAFE_PROFILE_ACTION="rollback"`, + `# Explicit safe-profile migration lifecycle. Ordinary --update deliberately`, + `safe_profile_verify_declared_health`, `safe_profile_commit_transaction`, + `"$SAFE_PROFILE_ACTION" != "apply"`, `target_action_runner=unchanged`, + } { + if !strings.Contains(script, want) { + t.Fatalf("installer missing migration invariant %q", want) + } + } + rollback := extractInstallShellFunction(t, "safe_profile_restore_transaction") + for _, forbidden := range []string{"ACTION_RUNNER_BINARY_PATH", "ACTION_RUNNER_SERVICE_UNIT", "teardown_action_runner_service", "provision_action_runner"} { + if strings.Contains(rollback, forbidden) { + t.Fatalf("collector rollback touched independent runner through %q", forbidden) + } + } +} + +func TestSafeProfileApplyRequiresReadinessHelperAndRegistration(t *testing.T) { + gate := extractInstallShellFunction(t, "safe_profile_verify_declared_health") + script := ` +set -euo pipefail +AGENT_NAME=pulse-agent +PRIVILEGED_HELPER_NAME=pulse-agent-helper +resolve_agent_health_url() { printf 'http://127.0.0.1:9191/readyz\n'; } +curl() { return 0; } +systemctl() { return 0; } +verify_agent_server_registration_with_retry() { return 0; } +` + gate + ` +safe_profile_verify_declared_health +verify_agent_server_registration_with_retry() { return 1; } +if safe_profile_verify_declared_health; then + echo 'registration failure was accepted' >&2 + exit 1 +fi +` + if out, err := exec.Command("bash", "-c", script).CombinedOutput(); err != nil { + t.Fatalf("health gate rehearsal: %v\n%s", err, out) + } +} + +func safeProfileHarness(t *testing.T, root string, dockerMember bool) string { + t.Helper() + binDir := filepath.Join(root, "bin") + unitDir := filepath.Join(root, "systemd") + helperDir := filepath.Join(root, "helper") + stateDir := filepath.Join(root, "state") + credentialDir := filepath.Join(root, "credential") + 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", + } + for path, body := range files { + mustWrite(t, path, body) + } + for source, name := range map[string]string{ + filepath.Join(binDir, "pulse-agent"): "collector-binary", + filepath.Join(unitDir, "pulse-agent.service"): "collector-unit", + filepath.Join(stateDir, "token"): "state-token", + filepath.Join(stateDir, "runtime.token"): "runtime-token", + filepath.Join(stateDir, "agent-id"): "agent-id", + filepath.Join(stateDir, "connection.env"): "connection-env", + } { + body, err := os.ReadFile(source) + if err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(expectedDir, name), string(body)) + } + membership := "pulse-agent" + if dockerMember { + membership = "pulse-agent docker" + } + return ` +set -euo pipefail +AGENT_NAME=pulse-agent +BINARY_NAME=pulse-agent +INSTALL_DIR="` + binDir + `" +LEAST_PRIVILEGE_USER=pulse-agent +PRIVILEGE_HELPER_DIR="` + helperDir + `" +PRIVILEGE_SUDOERS_FILE="` + filepath.Join(root, "sudoers") + `" +PRIVILEGED_HELPER_BINARY_PATH="` + filepath.Join(helperDir, "pulse-agent-helper") + `" +PRIVILEGED_HELPER_SERVICE_UNIT="` + filepath.Join(unitDir, "pulse-agent-helper.service") + `" +PRIVILEGED_HELPER_SOCKET_UNIT="` + filepath.Join(unitDir, "pulse-agent-helper.socket") + `" +PRIVILEGED_HELPER_SOCKET_PATH="` + filepath.Join(root, "run", "helper.sock") + `" +PRIVILEGED_HELPER_NAME=pulse-agent-helper +PRIVILEGED_HELPER_CREDENTIAL_DIR="` + credentialDir + `" +SAFE_PROFILE_COLLECTOR_UNIT="` + filepath.Join(unitDir, "pulse-agent.service") + `" +SAFE_PROFILE_STATE_DIR="` + filepath.Join(root, "profile") + `" +SAFE_PROFILE_CURRENT_FILE="${SAFE_PROFILE_STATE_DIR}/current.env" +SAFE_PROFILE_TRANSACTION_DIR="" +SAFE_PROFILE_TRANSACTION_ACTIVE=false +SAFE_PROFILE_TRANSACTION_COMMITTED=false +STATE_DIR="` + stateDir + `" +ACTION_RUNNER_SENTINEL="` + filepath.Join(root, "runner-sentinel") + `" +EXPECTED_DIR="` + expectedDir + `" +CALL_LOG="` + filepath.Join(root, "calls.log") + `" +EXIT_GENERAL=1 +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; } +getent() { [[ "${1:-}" == group && "${2:-}" == docker ]]; } +id() { if [[ "${1:-}" == -nG ]]; then printf '` + membership + `\n'; fi; return 0; } +gpasswd() { printf 'gpasswd %s\n' "$*" >> "$CALL_LOG"; } +` + safeProfileTransactionFunctions(t) + "\n" +} + +func mustMkdirAll(t *testing.T, dirs ...string) { + t.Helper() + for _, dir := range dirs { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } +} + +func mustWrite(t *testing.T, path, body string) { + t.Helper() + if err := os.WriteFile(path, []byte(body), 0o755); err != nil { + t.Fatal(err) + } +} + +func assertFileBody(t *testing.T, path, want string) { + t.Helper() + body, err := os.ReadFile(path) + if err != nil || string(body) != want { + t.Fatalf("%s body=%q want=%q err=%v", path, body, want, err) + } +} diff --git a/scripts/prepare-release-container-context.sh b/scripts/prepare-release-container-context.sh index 070628583..7dd67e7ad 100755 --- a/scripts/prepare-release-container-context.sh +++ b/scripts/prepare-release-container-context.sh @@ -126,6 +126,15 @@ for arch in amd64 arm64; do fi done done + for runner_target in linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-386; do + for suffix in "" .sig .sshsig; do + required="bin/pulse-agent-runner-${runner_target}${suffix}" + if [[ ! -f "${output_dir}/${arch}/${required}" ]]; then + echo "Error: ${archive} is missing candidate container input ${required}." >&2 + exit 1 + fi + done + done done if ! diff -qr \ diff --git a/scripts/release_asset_common.sh b/scripts/release_asset_common.sh index 8f3c54a6b..041bae818 100644 --- a/scripts/release_asset_common.sh +++ b/scripts/release_asset_common.sh @@ -143,6 +143,10 @@ pulse_release_stage_server_archive() { echo "Error: PULSE_RELEASE_AGENT_HELPER_TARGETS is empty." >&2 return 1 fi + if [[ ${#PULSE_RELEASE_AGENT_RUNNER_TARGETS[@]} -eq 0 ]]; then + echo "Error: PULSE_RELEASE_AGENT_RUNNER_TARGETS is empty." >&2 + return 1 + fi rm -rf "${staging_dir}" mkdir -p "${staging_dir}/bin" "${staging_dir}/scripts" @@ -162,6 +166,11 @@ pulse_release_stage_server_archive() { dest="${staging_dir}/bin/pulse-agent-helper-${target}" install -m 0755 "${src}" "${dest}" done + for target in "${PULSE_RELEASE_AGENT_RUNNER_TARGETS[@]}"; do + src="${agent_binary_dir}/pulse-agent-runner-${target}" + dest="${staging_dir}/bin/pulse-agent-runner-${target}" + install -m 0755 "${src}" "${dest}" + done ( cd "${staging_dir}/bin" ln -sf pulse-agent-windows-amd64.exe pulse-agent-windows-amd64 diff --git a/scripts/release_build_targets.sh b/scripts/release_build_targets.sh index 56b9ccbe3..becadc9a1 100755 --- a/scripts/release_build_targets.sh +++ b/scripts/release_build_targets.sh @@ -26,6 +26,14 @@ PULSE_RELEASE_AGENT_HELPER_TARGETS=( linux-386 ) +PULSE_RELEASE_AGENT_RUNNER_TARGETS=( + linux-amd64 + linux-arm64 + linux-armv7 + linux-armv6 + linux-386 +) + PULSE_RELEASE_SERVER_TARGETS=( linux-amd64 linux-arm64 @@ -68,6 +76,7 @@ pulse_release_binary_filename() { case "${component}" in agent) filename="pulse-agent-${target}" ;; agent-helper) filename="pulse-agent-helper-${target}" ;; + agent-runner) filename="pulse-agent-runner-${target}" ;; mcp) filename="pulse-mcp-${target}" ;; server) filename="pulse-${target}" ;; control-plane) filename="pulse-control-plane-${target}" ;; diff --git a/scripts/release_control/canonical_completion_guard_test.py b/scripts/release_control/canonical_completion_guard_test.py index 8e787d83b..fe36cd7e6 100644 --- a/scripts/release_control/canonical_completion_guard_test.py +++ b/scripts/release_control/canonical_completion_guard_test.py @@ -585,6 +585,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase): "exact_files": [ "scripts/installtests/agent_state_dir_lifecycle_test.go", "scripts/installtests/install_sh_test.go", + "scripts/installtests/safe_profile_migration_test.go", ], } ], @@ -610,6 +611,39 @@ class CanonicalCompletionGuardTest(unittest.TestCase): "exact_files": [ "scripts/installtests/agent_state_dir_lifecycle_test.go", "scripts/installtests/install_sh_test.go", + "scripts/installtests/safe_profile_migration_test.go", + ], + } + ], + ) + + def test_action_runner_runtime_uses_separate_typed_runner_policy(self): + required = infer_impacted_subsystems( + [ + "cmd/pulse-agent-runner/main.go", + "internal/actionrunner/runner.go", + "internal/dockeragent/action_runtime.go", + ] + ) + self.assertEqual(set(required), {"agent-lifecycle"}) + lifecycle = required["agent-lifecycle"] + self.assertEqual( + lifecycle["verification_requirements"], + [ + { + "id": "action-runner-runtime", + "label": "separate typed action runner and durable receipt proof", + "touched_runtime_files": [ + "cmd/pulse-agent-runner/main.go", + "internal/actionrunner/runner.go", + "internal/dockeragent/action_runtime.go", + ], + "allow_same_subsystem_tests": True, + "test_prefixes": ["internal/actionrunner/"], + "exact_files": [ + "cmd/pulse-agent-runner/main_test.go", + "internal/agentexec/server_websocket_test.go", + "internal/hostagent/action_runner_client_test.go", ], } ], @@ -1278,6 +1312,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase): "test_prefixes": ["frontend-modern/src/api/__tests__/"], "exact_files": [ "frontend-modern/src/types/api.ts", + "internal/api/action_runner_credentials_test.go", "internal/api/ai_handlers_more_test.go", "internal/api/ai_handlers_patrol_actions_additional_test.go", "internal/api/alerting/external_probe_notifications_test.go", diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index f0ebe16ec..1cd7ab08d 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -1441,6 +1441,7 @@ class SubsystemLookupTest(unittest.TestCase): [ "scripts/installtests/agent_state_dir_lifecycle_test.go", "scripts/installtests/install_sh_test.go", + "scripts/installtests/safe_profile_migration_test.go", ], ) @@ -1459,6 +1460,7 @@ class SubsystemLookupTest(unittest.TestCase): [ "scripts/installtests/agent_state_dir_lifecycle_test.go", "scripts/installtests/install_sh_test.go", + "scripts/installtests/safe_profile_migration_test.go", ], ) @@ -3511,6 +3513,37 @@ class SubsystemLookupTest(unittest.TestCase): ], ) + def test_lookup_paths_assigns_separate_action_runner_to_agent_lifecycle(self) -> None: + result = lookup_paths( + [ + "cmd/pulse-agent-runner/main.go", + "internal/actionrunner/runner.go", + "internal/dockeragent/action_runtime.go", + ] + ) + self.assertEqual(result["unowned_runtime_files"], []) + self.assertEqual( + {item["subsystem"] for item in result["impacted_subsystems"]}, + {"agent-lifecycle"}, + ) + for file_entry in result["files"]: + self.assertEqual(file_entry["classification"], "runtime") + self.assertEqual(len(file_entry["matches"]), 1) + match = file_entry["matches"][0] + self.assertEqual(match["subsystem"], "agent-lifecycle") + self.assertEqual( + match["verification_requirement"]["id"], + "action-runner-runtime", + ) + self.assertEqual( + match["verification_requirement"]["exact_files"], + [ + "cmd/pulse-agent-runner/main_test.go", + "internal/agentexec/server_websocket_test.go", + "internal/hostagent/action_runner_client_test.go", + ], + ) + def test_lookup_paths_reports_windows_installer_as_shared_boundary(self) -> None: result = lookup_paths(["scripts/install.ps1"]) self.assertEqual(result["unowned_runtime_files"], []) diff --git a/scripts/validate-release.sh b/scripts/validate-release.sh index 0fec4e896..2a58f0451 100755 --- a/scripts/validate-release.sh +++ b/scripts/validate-release.sh @@ -254,7 +254,7 @@ if [ "$SKIP_DOCKER" = false ]; then # Validate all required binaries exist and are non-empty info "Checking downloadable binaries in /opt/pulse/bin/..." - docker run --rm --entrypoint /bin/sh "$IMAGE" -c 'set -euo pipefail; cd /opt/pulse/bin; required="pulse pulse-agent-linux-amd64 pulse-agent-linux-arm64 pulse-agent-linux-armv7 pulse-agent-linux-armv6 pulse-agent-linux-386 pulse-agent-helper-linux-amd64 pulse-agent-helper-linux-arm64 pulse-agent-helper-linux-armv7 pulse-agent-helper-linux-armv6 pulse-agent-helper-linux-386 pulse-agent-darwin-amd64 pulse-agent-darwin-arm64 pulse-agent-windows-amd64.exe pulse-agent-windows-amd64 pulse-agent-windows-arm64.exe pulse-agent-windows-arm64 pulse-agent-windows-386.exe pulse-agent-windows-386 pulse-agent-freebsd-amd64 pulse-agent-freebsd-arm64"; for f in $required; do [ -e "$f" ] || { echo "missing binary $f" >&2; exit 1; }; [ -s "$f" ] || { echo "empty binary $f" >&2; exit 1; }; done; [ "$(readlink pulse-agent-windows-amd64)" = "pulse-agent-windows-amd64.exe" ] || { echo "unified agent windows amd64 symlink broken" >&2; exit 1; }; [ "$(readlink pulse-agent-windows-arm64)" = "pulse-agent-windows-arm64.exe" ] || { echo "unified agent windows arm64 symlink broken" >&2; exit 1; }; [ "$(readlink pulse-agent-windows-386)" = "pulse-agent-windows-386.exe" ] || { echo "unified agent windows 386 symlink broken" >&2; exit 1; }; echo "All binaries present"' || { error "Binary validation failed"; exit 1; } + docker run --rm --entrypoint /bin/sh "$IMAGE" -c 'set -euo pipefail; cd /opt/pulse/bin; required="pulse pulse-agent-linux-amd64 pulse-agent-linux-arm64 pulse-agent-linux-armv7 pulse-agent-linux-armv6 pulse-agent-linux-386 pulse-agent-helper-linux-amd64 pulse-agent-helper-linux-arm64 pulse-agent-helper-linux-armv7 pulse-agent-helper-linux-armv6 pulse-agent-helper-linux-386 pulse-agent-runner-linux-amd64 pulse-agent-runner-linux-arm64 pulse-agent-runner-linux-armv7 pulse-agent-runner-linux-armv6 pulse-agent-runner-linux-386 pulse-agent-darwin-amd64 pulse-agent-darwin-arm64 pulse-agent-windows-amd64.exe pulse-agent-windows-amd64 pulse-agent-windows-arm64.exe pulse-agent-windows-arm64 pulse-agent-windows-386.exe pulse-agent-windows-386 pulse-agent-freebsd-amd64 pulse-agent-freebsd-arm64"; for f in $required; do [ -e "$f" ] || { echo "missing binary $f" >&2; exit 1; }; [ -s "$f" ] || { echo "empty binary $f" >&2; exit 1; }; done; [ "$(readlink pulse-agent-windows-amd64)" = "pulse-agent-windows-amd64.exe" ] || { echo "unified agent windows amd64 symlink broken" >&2; exit 1; }; [ "$(readlink pulse-agent-windows-arm64)" = "pulse-agent-windows-arm64.exe" ] || { echo "unified agent windows arm64 symlink broken" >&2; exit 1; }; [ "$(readlink pulse-agent-windows-386)" = "pulse-agent-windows-386.exe" ] || { echo "unified agent windows 386 symlink broken" >&2; exit 1; }; echo "All binaries present"' || { error "Binary validation failed"; exit 1; } success "All downloadable binaries present" # Validate the arch-resolved /usr/local/bin/pulse-agent symlink. The helm @@ -543,10 +543,18 @@ privileged_helper_entries=( ./bin/pulse-agent-helper-linux-armv6 ./bin/pulse-agent-helper-linux-386 ) +action_runner_entries=( + ./bin/pulse-agent-runner-linux-amd64 + ./bin/pulse-agent-runner-linux-arm64 + ./bin/pulse-agent-runner-linux-armv7 + ./bin/pulse-agent-runner-linux-armv6 + ./bin/pulse-agent-runner-linux-386 +) platform_tar_entries=( ./bin/pulse "${unified_agent_entries[@]}" "${privileged_helper_entries[@]}" + "${action_runner_entries[@]}" ./scripts/install-container-agent.sh ./scripts/install-docker.sh ./scripts/install.sh @@ -562,7 +570,8 @@ validate_universal_tarball() { "pulse-v${PULSE_VERSION}.tar.gz" \ ./VERSION \ "${unified_agent_entries[@]}" \ - "${privileged_helper_entries[@]}" + "${privileged_helper_entries[@]}" \ + "${action_runner_entries[@]}" } # Each archive previously underwent up to four complete gzip scans in series.