Separate agent remediation runtime

This commit is contained in:
Pulse Test
2026-08-29 23:47:00 +01:00
parent 1720fd635b
commit d607d5cf46
80 changed files with 6418 additions and 82 deletions
+5
View File
@@ -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 \
+36 -1
View File
@@ -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{},
+15
View File
@@ -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 {
+135
View File
@@ -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)
}
}
+46
View File
@@ -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)
}
}
+9
View File
@@ -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 {
+15
View File
@@ -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) {
+45 -6
View File
@@ -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.
+28 -5
View File
@@ -90,11 +90,34 @@ curl -fsSL http://<pulse-ip>: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 <private-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
@@ -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
@@ -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`
@@ -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
@@ -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`
@@ -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
@@ -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,
@@ -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
+176
View File
@@ -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
}
+211
View File
@@ -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)
}
}
+33
View File
@@ -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()
}
+273
View File
@@ -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
}
@@ -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
}
}
@@ -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)
}
}
+111 -4
View File
@@ -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 == "" {
@@ -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)
+56
View File
@@ -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
+178
View File
@@ -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")
}
@@ -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)
}
}
}
@@ -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")
}
}
@@ -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
}
}
@@ -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")
}
}
+6
View File
@@ -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
)
+17 -1
View File
@@ -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
+62
View File
@@ -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
}
+80
View File
@@ -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 {
+494
View File
@@ -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)
@@ -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")
}
}
+6
View File
@@ -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")
+147
View File
@@ -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()
}
@@ -0,0 +1,7 @@
//go:build !unix
package agentupdate
import "os"
func collectorQuarantineOwnedByCurrentUID(os.FileInfo) bool { return false }
@@ -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())
}
@@ -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)
}
}
+98
View File
@@ -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")
+122
View File
@@ -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
}
@@ -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())
}
}
}
+60 -2
View File
@@ -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)
}
+23
View File
@@ -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{}
+37
View File
@@ -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)
}
}
}
+106
View File
@@ -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])
}
+53
View File
@@ -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) {
+34
View File
@@ -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{
+94 -1
View File
@@ -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 {
+7
View File
@@ -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",
+2
View File
@@ -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
@@ -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{}
@@ -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",
@@ -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)
@@ -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))))
+60
View File
@@ -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{
+119
View File
@@ -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.
+63
View File
@@ -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) {
+41
View File
@@ -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
}
+139
View File
@@ -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()
}
@@ -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
}
+13
View File
@@ -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)
}
}
}
+70 -21
View File
@@ -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
}},
}}
}
+2
View File
@@ -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)
@@ -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")
}
}
@@ -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)
}
}
+8
View File
@@ -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)
+31
View File
@@ -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.
+874 -11
View File
File diff suppressed because it is too large Load Diff
@@ -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`,
+113 -1
View File
@@ -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) {
@@ -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)
}
}
@@ -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 \
+9
View File
@@ -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
+9
View File
@@ -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}" ;;
@@ -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",
@@ -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"], [])
+11 -2
View File
@@ -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.