mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Separate agent remediation runtime
This commit is contained in:
@@ -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 \
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))))
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}},
|
||||
}}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
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`,
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"], [])
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user