Add typed agent privilege helper

This commit is contained in:
Pulse Test 2026-08-29 22:49:28 +01:00
parent 3099085475
commit 6d4ee48000
44 changed files with 3267 additions and 22 deletions

View file

@ -1111,6 +1111,11 @@ jobs:
release/pulse-agent-linux-armv7 \
release/pulse-agent-linux-armv6 \
release/pulse-agent-linux-386 \
release/pulse-agent-helper-linux-amd64 \
release/pulse-agent-helper-linux-arm64 \
release/pulse-agent-helper-linux-armv7 \
release/pulse-agent-helper-linux-armv6 \
release/pulse-agent-helper-linux-386 \
release/pulse-agent-freebsd-amd64 \
release/pulse-agent-freebsd-arm64 \
release/pulse-agent-windows-amd64.exe \

View file

@ -0,0 +1,226 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"net"
"os"
"os/signal"
"os/user"
"path/filepath"
"runtime"
"strconv"
"strings"
"syscall"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agenthelper"
)
const defaultSocketPath = "/run/pulse-agent/helper.sock"
type commandConfig struct {
socketPath string
allowedUID int64
socketGID int64
maxDeadline time.Duration
}
func main() {
if err := run(os.Args[1:]); err != nil {
log.Printf("pulse-agent-helper: %v", err)
os.Exit(1)
}
}
func run(args []string) error {
if runtime.GOOS != "linux" {
return errors.New("helper is supported only on Linux")
}
if os.Geteuid() != 0 {
return errors.New("helper must run as root")
}
config, err := parseFlags(args)
if err != nil {
return err
}
uid, gid, err := resolveIdentity(config.allowedUID, config.socketGID)
if err != nil {
return err
}
listener, cleanup, err := openListener(config.socketPath, gid)
if err != nil {
return err
}
defer cleanup()
registry := agenthelper.NewRegistry(localSMARTProvider{}, localProxmoxProvider{})
server, err := agenthelper.NewServer(agenthelper.ServerConfig{
AllowedUID: uid,
PeerResolver: agenthelper.PlatformPeerResolver{},
Registry: registry,
MaxOperationTimeout: config.maxDeadline,
Audit: func(event agenthelper.AuditEvent) {
log.Printf(
"request_id=%q operation=%q operation_version=%d peer_uid=%d peer_pid=%d success=%t error_code=%q duration_ms=%d request_bytes=%d response_bytes=%d",
event.RequestID,
event.Operation,
event.OperationVersion,
event.Peer.UID,
event.Peer.PID,
event.Success,
event.ErrorCode,
event.Duration.Milliseconds(),
event.RequestBytes,
event.ResponseBytes,
)
},
})
if err != nil {
return err
}
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
go func() {
<-ctx.Done()
_ = listener.Close()
}()
return server.Serve(ctx, listener)
}
func parseFlags(args []string) (commandConfig, error) {
set := flag.NewFlagSet("pulse-agent-helper", flag.ContinueOnError)
set.SetOutput(os.Stderr)
config := commandConfig{}
set.StringVar(&config.socketPath, "socket", defaultSocketPath, "fixed local Unix socket path used when systemd socket activation is absent")
set.Int64Var(&config.allowedUID, "allowed-uid", -1, "authorized collector UID (defaults to the pulse-agent account)")
set.Int64Var(&config.socketGID, "socket-gid", -1, "Unix socket group ID (defaults to the pulse-agent account group)")
set.DurationVar(&config.maxDeadline, "max-deadline", 30*time.Second, "maximum accepted operation deadline")
if err := set.Parse(args); err != nil {
return commandConfig{}, err
}
if set.NArg() != 0 {
return commandConfig{}, errors.New("positional arguments are not supported")
}
if !filepath.IsAbs(config.socketPath) || filepath.Clean(config.socketPath) != config.socketPath {
return commandConfig{}, errors.New("socket must be a clean absolute path")
}
if config.maxDeadline < time.Millisecond || config.maxDeadline > 5*time.Minute {
return commandConfig{}, errors.New("max-deadline must be between 1ms and 5m")
}
if config.allowedUID < -1 || config.socketGID < -1 {
return commandConfig{}, errors.New("UID and GID must be -1 or non-negative")
}
if config.allowedUID > int64(^uint32(0)) || config.socketGID > int64(^uint32(0)) {
return commandConfig{}, errors.New("UID and GID must fit in uint32")
}
return config, nil
}
func resolveIdentity(uidValue, gidValue int64) (uint32, int, error) {
var account *user.User
if uidValue < 0 || gidValue < 0 {
resolved, err := user.Lookup("pulse-agent")
if err != nil {
return 0, 0, fmt.Errorf("look up pulse-agent account: %w", err)
}
account = resolved
}
if uidValue < 0 {
parsed, err := strconv.ParseUint(account.Uid, 10, 32)
if err != nil {
return 0, 0, fmt.Errorf("parse pulse-agent UID: %w", err)
}
uidValue = int64(parsed)
}
if gidValue < 0 {
parsed, err := strconv.ParseUint(account.Gid, 10, 32)
if err != nil {
return 0, 0, fmt.Errorf("parse pulse-agent GID: %w", err)
}
gidValue = int64(parsed)
}
if uidValue < 0 || gidValue < 0 || gidValue > int64(^uint(0)>>1) {
return 0, 0, errors.New("resolved UID or GID is invalid")
}
return uint32(uidValue), int(gidValue), nil
}
func openListener(socketPath string, socketGID int) (net.Listener, func(), error) {
if listener, ok, err := inheritedSystemdListener(); err != nil {
return nil, func() {}, err
} else if ok {
return listener, func() { _ = listener.Close() }, nil
}
if err := os.MkdirAll(filepath.Dir(socketPath), 0o750); err != nil {
return nil, func() {}, fmt.Errorf("create socket directory: %w", err)
}
if info, err := os.Lstat(socketPath); err == nil {
if info.Mode()&os.ModeSocket == 0 {
return nil, func() {}, fmt.Errorf("refusing to replace non-socket path %s", socketPath)
}
if err := os.Remove(socketPath); err != nil {
return nil, func() {}, fmt.Errorf("remove stale socket: %w", err)
}
} else if !errors.Is(err, os.ErrNotExist) {
return nil, func() {}, fmt.Errorf("inspect socket path: %w", err)
}
listener, err := net.Listen("unix", socketPath)
if err != nil {
return nil, func() {}, fmt.Errorf("listen on helper socket: %w", err)
}
cleanup := func() {
_ = listener.Close()
if info, err := os.Lstat(socketPath); err == nil && info.Mode()&os.ModeSocket != 0 {
_ = os.Remove(socketPath)
}
}
if err := os.Chown(socketPath, 0, socketGID); err != nil {
cleanup()
return nil, func() {}, fmt.Errorf("set helper socket ownership: %w", err)
}
if err := os.Chmod(socketPath, 0o660); err != nil {
cleanup()
return nil, func() {}, fmt.Errorf("set helper socket mode: %w", err)
}
return listener, cleanup, nil
}
func inheritedSystemdListener() (net.Listener, bool, error) {
pidText := strings.TrimSpace(os.Getenv("LISTEN_PID"))
fdsText := strings.TrimSpace(os.Getenv("LISTEN_FDS"))
if pidText == "" && fdsText == "" {
return nil, false, nil
}
pid, err := strconv.Atoi(pidText)
if err != nil || pid != os.Getpid() {
return nil, false, errors.New("LISTEN_PID does not identify this helper process")
}
fds, err := strconv.Atoi(fdsText)
if err != nil || fds != 1 {
return nil, false, errors.New("helper requires exactly one inherited systemd socket")
}
file := os.NewFile(uintptr(3), "systemd-helper-socket")
if file == nil {
return nil, false, errors.New("systemd socket file descriptor is unavailable")
}
defer file.Close()
listener, err := net.FileListener(file)
if err != nil {
return nil, false, fmt.Errorf("open inherited systemd socket: %w", err)
}
if _, ok := listener.(*net.UnixListener); !ok {
_ = listener.Close()
return nil, false, errors.New("inherited systemd listener is not a Unix socket")
}
return listener, true, nil
}

View file

@ -0,0 +1,135 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agenthelper"
)
func TestParseFlagsAcceptsOnlyLocalHelperConfiguration(t *testing.T) {
config, err := parseFlags([]string{
"--socket", "/run/pulse-agent/custom-helper.sock",
"--allowed-uid", "1001",
"--socket-gid", "1002",
"--max-deadline", "45s",
})
if err != nil {
t.Fatalf("parseFlags: %v", err)
}
if config.socketPath != "/run/pulse-agent/custom-helper.sock" ||
config.allowedUID != 1001 || config.socketGID != 1002 || config.maxDeadline != 45*time.Second {
t.Fatalf("config = %#v", config)
}
for _, forbidden := range [][]string{
{"--url", "https://pulse.example"},
{"--token", "secret"},
{"--listen", "tcp://127.0.0.1:9999"},
{"positional"},
} {
if _, err := parseFlags(forbidden); err == nil {
t.Fatalf("network/credential/positional flags accepted: %v", forbidden)
}
}
}
func TestParseFlagsRejectsUnsafeValues(t *testing.T) {
tests := [][]string{
{"--socket", "relative.sock"},
{"--socket", "/run/../tmp/helper.sock"},
{"--max-deadline", "0s"},
{"--max-deadline", "1ns"},
{"--max-deadline", "6m"},
{"--allowed-uid", "-2"},
{"--socket-gid", "-2"},
{"--allowed-uid", "4294967296"},
{"--socket-gid", "4294967296"},
}
for _, args := range tests {
if _, err := parseFlags(args); err == nil {
t.Fatalf("unsafe flags accepted: %v", args)
}
}
}
func TestResolveIdentityUsesExplicitNumericValues(t *testing.T) {
uid, gid, err := resolveIdentity(1001, 1002)
if err != nil {
t.Fatalf("resolveIdentity: %v", err)
}
if uid != 1001 || gid != 1002 {
t.Fatalf("uid=%d gid=%d", uid, gid)
}
}
func TestInheritedSystemdListenerRejectsMalformedActivation(t *testing.T) {
t.Setenv("LISTEN_PID", "")
t.Setenv("LISTEN_FDS", "")
if listener, ok, err := inheritedSystemdListener(); err != nil || ok || listener != nil {
t.Fatalf("no activation = listener=%v ok=%t err=%v", listener, ok, err)
}
t.Setenv("LISTEN_PID", "not-a-pid")
t.Setenv("LISTEN_FDS", "1")
if _, _, err := inheritedSystemdListener(); err == nil {
t.Fatal("malformed LISTEN_PID accepted")
}
t.Setenv("LISTEN_PID", strings.TrimSpace(strings.Repeat("0", 1)))
t.Setenv("LISTEN_FDS", "2")
if _, _, err := inheritedSystemdListener(); err == nil {
t.Fatal("foreign LISTEN_PID accepted")
}
}
func TestOpenListenerRefusesToReplaceNonSocket(t *testing.T) {
t.Setenv("LISTEN_PID", "")
t.Setenv("LISTEN_FDS", "")
path := filepath.Join(t.TempDir(), "helper.sock")
if err := os.WriteFile(path, []byte("do-not-replace"), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := openListener(path, os.Getgid()); err == nil {
t.Fatal("regular file at socket path was replaced")
}
contents, err := os.ReadFile(path)
if err != nil || string(contents) != "do-not-replace" {
t.Fatalf("protected file changed: contents=%q err=%v", contents, err)
}
}
func TestCommandRegistryWiresTypedLocalProviders(t *testing.T) {
var _ agenthelper.SMARTProvider = localSMARTProvider{}
var _ agenthelper.ProxmoxProvider = localProxmoxProvider{}
registry := agenthelper.NewRegistry(localSMARTProvider{}, localProxmoxProvider{})
// Registry availability and strict empty request schemas are exercised
// through the public protocol by the internal server suite.
if registry == nil {
t.Fatal("registry is nil")
}
}
func TestLocalProxmoxProviderAlwaysReturnsValidJSON(t *testing.T) {
result, err := (localProxmoxProvider{}).LXCFilesystems(t.Context())
if err != nil {
t.Fatalf("LXCFilesystems: %v", err)
}
if !json.Valid(result) {
t.Fatalf("invalid provider JSON: %q", result)
}
}
func TestRunFailsClosedOutsideLinux(t *testing.T) {
if runtime.GOOS == "linux" {
t.Skip("non-Linux fail-closed assertion")
}
if err := run(nil); err == nil || !strings.Contains(err.Error(), "only on Linux") {
t.Fatalf("run error = %v", err)
}
}

View file

@ -0,0 +1,46 @@
package main
import (
"context"
"encoding/json"
"fmt"
"github.com/rcourtman/pulse-go-rewrite/internal/hostagent"
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
)
// localSMARTProvider reuses the canonical SMART collector inside the
// network-isolated helper process. Its protocol request has no path or
// argument fields; device discovery and command construction remain entirely
// provider-owned.
type localSMARTProvider struct{}
func (localSMARTProvider) Snapshot(ctx context.Context) (json.RawMessage, error) {
disks, err := hostagent.CollectSMARTLocal(ctx, nil)
if err != nil {
return nil, fmt.Errorf("collect SMART snapshot: %w", err)
}
result, err := json.Marshal(struct {
Disks []hostagent.DiskSMART `json:"disks"`
}{Disks: disks})
if err != nil {
return nil, fmt.Errorf("encode SMART snapshot: %w", err)
}
return result, nil
}
// localProxmoxProvider runs the existing bounded node-local inventory as one
// typed operation. The collector cannot select a VMID, filesystem path,
// command, or pct argument across the helper boundary.
type localProxmoxProvider struct{}
func (localProxmoxProvider) LXCFilesystems(ctx context.Context) (json.RawMessage, error) {
inventory := hostagent.CollectProxmoxLXCFilesystemsLocal(ctx)
result, err := json.Marshal(struct {
Inventory *agentshost.ProxmoxLXCInventory `json:"inventory"`
}{Inventory: inventory})
if err != nil {
return nil, fmt.Errorf("encode Proxmox LXC filesystem inventory: %w", err)
}
return result, nil
}

View file

@ -98,6 +98,7 @@ var (
newHostAgent func(hostagent.Config) (Runnable, error) = func(c hostagent.Config) (Runnable, error) {
return hostagent.New(c)
}
newPrivilegeHelperTelemetry = hostagent.NewPrivilegeHelperTelemetry
newUpdater func(agentupdate.Config) *agentupdate.Updater = agentupdate.New
lookPath = exec.LookPath
runAsWindowsServiceFunc = runAsWindowsService
@ -405,6 +406,10 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
// 8. Start Host Agent (if enabled)
if cfg.EnableHost {
privilegedTelemetry, err := newPrivilegeHelperTelemetry(os.Getenv("PULSE_AGENT_HELPER_SOCKET"))
if err != nil {
return fmt.Errorf("configure typed privilege helper: %w", err)
}
hostCfg := hostagent.Config{
PulseURL: cfg.PulseURL,
APIToken: cfg.APIToken,
@ -435,6 +440,7 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
AppliedConfig: cfg.AppliedConfig,
ModuleStatus: runtimeStatus.moduleStatuses,
Observers: hostObserverTargets(cfg.Observers),
PrivilegedTelemetry: privilegedTelemetry,
DockerContainerUpdater: dockerUpdaterBridge,
DockerContainerLifecycleOperator: dockerUpdaterBridge,

View file

@ -1738,6 +1738,41 @@ func TestRun_PassesStateDirToUpdaterAndHostAgent(t *testing.T) {
}
}
func TestRunConfiguresTypedPrivilegeHelperFromInstallerEnvironment(t *testing.T) {
originalHelper := newPrivilegeHelperTelemetry
originalHost := newHostAgent
defer func() {
newPrivilegeHelperTelemetry = originalHelper
newHostAgent = originalHost
}()
const socketPath = "/run/pulse-agent/helper.sock"
t.Setenv("PULSE_AGENT_HELPER_SOCKET", socketPath)
configuredPath := ""
newPrivilegeHelperTelemetry = func(path string) (hostagent.PrivilegedTelemetry, error) {
configuredPath = path
return nil, nil
}
newHostAgent = func(hostagent.Config) (Runnable, error) {
return &mockRunnable{}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
err := run(ctx, []string{
"-token", "deadbeef",
"-enable-docker=false",
"-enable-kubernetes=false",
"-health-addr", "",
}, func(string) string { return "" })
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("run returned error: %v", err)
}
if configuredPath != socketPath {
t.Fatalf("helper socket path = %q, want %q", configuredPath, socketPath)
}
}
func TestRun_AgentFailure(t *testing.T) {
origDocker := newDockerAgent
defer func() {

View file

@ -143,6 +143,45 @@ user and token.
## Least-Privilege Agent Profile
For a standard Linux systemd host, the stronger opt-in profile is:
```bash
install.sh --least-privilege --enable-privileged-helper ...
```
This profile keeps the networked collector unprivileged and keeps both the
collector and helper binaries root-owned. Its installer token lives under the
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.
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
no Pulse URL, API token, or network namespace. Its protocol exposes bounded,
versioned SMART and Proxmox LXC filesystem snapshots, not a shell, executable
path, device path, VMID, environment, or caller-selected arguments. The helper
service keeps `PrivateNetwork=true`, `RestrictAddressFamilies=AF_UNIX`,
`NoNewPrivileges=true`, `ProtectSystem=strict`, and `ProtectHome=true`.
`PrivateDevices` is intentionally not enabled because SMART needs the host
block devices. If the helper is missing, incompatible, or rejects a request,
only the affected telemetry disappears; the collector does not fall back to
sudo, root, or a broader local command path.
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.
The older `--least-privilege` profile without `--enable-privileged-helper`
remains available for compatibility. Its trade-offs are documented below.
On standard Linux systemd hosts, `install.sh --least-privilege` is a supported
alternative to the root profile. It runs the service as a dedicated
`pulse-agent` system user (nologin shell, owning only its state directory and
@ -176,9 +215,10 @@ platforms (TrueNAS, Synology, QNAP, Unraid) and non-systemd init systems keep
the root profile; the installer refuses `--least-privilege` there rather than
silently falling back to root.
`--update` preserves an existing least-privilege profile and its grants
without the flags being repeated. Uninstall removes the sudoers file and
helpers; the inert system user is left behind deliberately.
`--update` preserves either installed least-privilege profile without the flags
being repeated. Uninstall removes the typed helper socket/service and its
installer credential directory, or the legacy sudoers file and wrappers; the
inert system user is left behind deliberately.
## Supply-Chain Boundary

View file

@ -90,6 +90,12 @@ 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).
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
workloads under that host. Collection uses libvirt's bounded bulk statistics

View file

@ -6644,6 +6644,41 @@ server can present the profile descriptively. Uninstall removes the sudoers
file and helpers. `scripts/installtests/install_sh_test.go`
(`TestInstallSHLeastPrivilegeProfile`) pins the profile's invariants.
### Typed local privilege helper boundary
`cmd/pulse-agent-helper/main.go` and `internal/agenthelper/` own the Linux
collector's replacement for command-shaped sudo wrappers. The helper is a
standalone root process with no Pulse URL, API credential, outbound transport,
or generic command/path operation. It accepts exactly one protocol-v1 request
per local Unix-socket connection, uses a four-byte big-endian JSON frame with a
64 KiB request ceiling and 10 MiB response ceiling, rejects unknown fields,
trailing JSON, unsupported versions, and expired deadlines, and returns typed
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
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
size metadata only, never collected payloads or secrets.
The packaged service listens on `/run/pulse-agent/helper.sock` with
`root:pulse-agent` ownership and mode `0660`. Its systemd sandbox is root-owned,
networkless (`PrivateNetwork=true`, `RestrictAddressFamilies=AF_UNIX`), and
retains `NoNewPrivileges=true`, `ProtectSystem=strict`, and `ProtectHome=true`.
It must not use `PrivateDevices=true`, because SMART collection requires access
to host block devices. Helper absence, incompatibility, or an unavailable
operation degrades only the affected telemetry and must never reactivate a sudo
wrapper, Docker-group membership, ambient capability, or root collector.
The rate-limited public `/download/pulse-agent-helper?arch=linux-*` endpoint is
the only server delivery path: it rejects non-Linux targets, serves the exact
separate helper artifact with checksum plus detached signatures, and requires a
published signed release asset when no local binary exists.
### Command dispatch is context-honest and abandoned executions are canceled
The agent command transport now refuses to dispatch work its caller has

View file

@ -20,6 +20,15 @@
## Purpose
The Unified Agent helper artifact has a distinct rate-limited public download
contract at `GET|HEAD /download/pulse-agent-helper?arch=linux-*`. The route
rejects missing and non-Linux architectures, never substitutes the collector
binary, and returns the same checksum, Ed25519 signature, and SSH signature
headers used by the signed agent installer boundary. Development builds fail
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 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

View file

@ -110,6 +110,19 @@ PVE profile; fresh monitoring PVE, PBS/PMG, and least-privilege units retain no
such ambient capabilities. Windows service replacement recovers the same
authority marker from the existing service command line before reinstalling it.
The safe Linux collector profile packages `pulse-agent-helper` as a separate
root-owned binary and activates it through a root-owned systemd socket/service
pair. `/run/pulse-agent/helper.sock` is `root:pulse-agent` mode `0660`; the
service has no Pulse URL or token and is sandboxed with `PrivateNetwork=true`,
`RestrictAddressFamilies=AF_UNIX`, `NoNewPrivileges=true`,
`ProtectSystem=strict`, and `ProtectHome=true`. `PrivateDevices=true` is
forbidden because it would hide the block devices required for SMART telemetry.
The collector binary and unit remain root-owned and non-writable by the service
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.
Published exact-version install and rollback guidance must preserve the server
and Unified Agent installer boundary. Supported systemd and Proxmox LXC
deployments use the signed `/bin/update --version vX.Y.Z` server helper;

View file

@ -1295,6 +1295,7 @@
"contract": "docs/release-control/v6/internal/subsystems/agent-lifecycle.md",
"owned_prefixes": [
"internal/agentexec/",
"internal/agenthelper/",
"internal/agenttarget/",
"internal/agentupdate/",
"internal/hostagent/",
@ -1302,6 +1303,7 @@
],
"owned_files": [
".github/workflows/unified-agent-native.yml",
"cmd/pulse-agent-helper/main.go",
"cmd/pulse-agent/main.go",
"cmd/pulse-agent/service_windows.go",
"frontend-modern/src/api/agentProfiles.ts",
@ -1389,6 +1391,23 @@
"exact_files": [],
"require_explicit_path_policy_coverage": true,
"path_policies": [
{
"id": "agent-privilege-helper-runtime",
"label": "typed no-network agent privilege helper proof",
"match_prefixes": [
"internal/agenthelper/"
],
"match_files": [
"cmd/pulse-agent-helper/main.go"
],
"allow_same_subsystem_tests": true,
"test_prefixes": [
"internal/agenthelper/"
],
"exact_files": [
"cmd/pulse-agent-helper/main_test.go"
]
},
{
"id": "patrol-autonomy-colima-real-lab",
"label": "RG-06 server-owned limited-unattended-autonomy Colima proof",
@ -1590,6 +1609,7 @@
"internal/hostagent/issue1595_sas_collection_test.go",
"internal/hostagent/observer_delivery_test.go",
"internal/hostagent/package_updates_test.go",
"internal/hostagent/privilege_helper_client_test.go",
"internal/hostagent/send_report_test.go",
"internal/hostagent/smartctl_standby_guard_test.go",
"internal/hostagent/storage_cleanup_test.go",

View file

@ -36,6 +36,15 @@ independent ceiling: a credential with `agent:exec` cannot promote a service
installed as `monitoring-only`, and Agent Doctor must warn when such excess
scope remains attached to that runtime.
Privileged host telemetry crosses only the local typed helper boundary owned by
`internal/agenthelper/` and `cmd/pulse-agent-helper/main.go`. That root process
has no network or Pulse credential, admits only the configured collector UID
through Linux peer credentials, and exposes named versioned collection
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.
Own Pulse's canonical privacy disclosures, outbound usage-data boundary,
and the security-facing settings surfaces that expose authentication posture,
token-management visibility, and privacy controls to operators. Customer-facing

View file

@ -21,6 +21,11 @@
## Purpose
The shared `internal/api/` helper-binary download route is isolated from
storage and recovery semantics. `/download/pulse-agent-helper` serves a signed
Linux runtime artifact only; it cannot read, mutate, restore, export, or select
storage state, recovery timelines, backup providers, or repository scope.
First-run authentication always writes the canonical `.env` persistence
artifact before runtime state changes. Root systemd installation may also
write a service override, but reload, restart, backup, and recovery remain

View file

@ -0,0 +1,210 @@
package agenthelper
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net"
"path/filepath"
"strings"
"time"
)
type DialContextFunc func(context.Context, string, string) (net.Conn, error)
type RequestIDFunc func() (string, error)
type ClientConfig struct {
SocketPath string
MaxDeadline time.Duration
DialContext DialContextFunc
NewRequestID RequestIDFunc
}
type Client struct {
socketPath string
maxDeadline time.Duration
dialContext DialContextFunc
newRequestID RequestIDFunc
}
type RemoteError struct {
RequestID string
Code string
Message string
Retryable bool
}
func (e *RemoteError) Error() string {
if e == nil {
return ""
}
return fmt.Sprintf("helper operation failed (%s): %s", e.Code, e.Message)
}
func NewClient(config ClientConfig) (*Client, error) {
path := strings.TrimSpace(config.SocketPath)
if path == "" || !filepath.IsAbs(path) || filepath.Clean(path) != path {
return nil, errors.New("helper socket path must be a clean absolute path")
}
maxDeadline := config.MaxDeadline
if maxDeadline <= 0 {
maxDeadline = defaultMaxOperationTimeout
}
if maxDeadline < time.Millisecond {
return nil, errors.New("maximum helper deadline must be at least 1ms")
}
dial := config.DialContext
if dial == nil {
dialer := &net.Dialer{}
dial = dialer.DialContext
}
requestID := config.NewRequestID
if requestID == nil {
requestID = randomRequestID
}
return &Client{
socketPath: path,
maxDeadline: maxDeadline,
dialContext: dial,
newRequestID: requestID,
}, nil
}
// Call performs exactly one typed operation over one Unix-socket connection.
// It returns the generated request ID even when the helper returns a typed
// remote error so callers can correlate local and helper audit records.
func (c *Client) Call(
ctx context.Context,
operation string,
operationVersion int,
deadline time.Duration,
payload any,
result any,
) (string, error) {
if strings.TrimSpace(operation) == "" || operationVersion < 1 {
return "", errors.New("helper operation and positive version are required")
}
if deadline <= 0 || deadline > c.maxDeadline {
return "", errors.New("helper operation deadline is outside the allowed range")
}
if contextDeadline, ok := ctx.Deadline(); ok {
remaining := time.Until(contextDeadline)
if remaining <= 0 {
return "", ctx.Err()
}
if remaining < deadline {
deadline = remaining
}
}
deadlineMillis := deadline.Milliseconds()
if deadlineMillis < 1 {
deadlineMillis = 1
}
requestID, err := c.newRequestID()
if err != nil {
return "", fmt.Errorf("create helper request ID: %w", err)
}
if !validRequestID(requestID) {
return "", errors.New("generated helper request ID is invalid")
}
payloadJSON := json.RawMessage(`{}`)
if payload != nil {
encoded, err := json.Marshal(payload)
if err != nil {
return requestID, fmt.Errorf("encode helper operation payload: %w", err)
}
payloadJSON = encoded
}
request := Request{
ProtocolVersion: ProtocolVersion,
RequestID: requestID,
Operation: operation,
OperationVersion: operationVersion,
DeadlineMillis: deadlineMillis,
Payload: payloadJSON,
}
framed, err := EncodeRequestFrame(request)
if err != nil {
return requestID, err
}
conn, err := c.dialContext(ctx, "unix", c.socketPath)
if err != nil {
return requestID, fmt.Errorf("connect to helper Unix socket: %w", err)
}
defer conn.Close()
operationDeadline := time.Now().Add(deadline)
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(operationDeadline) {
operationDeadline = contextDeadline
}
if err := conn.SetDeadline(operationDeadline); err != nil {
return requestID, fmt.Errorf("set helper connection deadline: %w", err)
}
if _, err := writeAll(conn, framed); err != nil {
return requestID, fmt.Errorf("write helper request: %w", err)
}
responseFrame, err := ReadResponseFrame(conn)
if err != nil {
return requestID, fmt.Errorf("read helper response: %w", err)
}
response, err := DecodeResponse(responseFrame)
if err != nil {
return requestID, fmt.Errorf("decode helper response: %w", err)
}
if err := validateResponseCorrelation(request, response); err != nil {
return requestID, err
}
if !response.Success {
return requestID, &RemoteError{
RequestID: requestID,
Code: response.Error.Code,
Message: response.Error.Message,
Retryable: response.Error.Retryable,
}
}
if result != nil {
if len(response.Result) == 0 {
return requestID, errors.New("successful helper response contains no result")
}
if err := decodeStrict(response.Result, result); err != nil {
return requestID, fmt.Errorf("decode helper operation result: %w", err)
}
}
return requestID, nil
}
func validateResponseCorrelation(request Request, response Response) error {
if response.ProtocolVersion != ProtocolVersion {
return errors.New("helper response protocol version does not match")
}
if response.RequestID != request.RequestID ||
response.Operation != request.Operation ||
response.OperationVersion != request.OperationVersion {
return errors.New("helper response correlation does not match request")
}
if response.Success {
if response.Error != nil {
return errors.New("successful helper response contains an error")
}
return nil
}
if response.Error == nil || strings.TrimSpace(response.Error.Code) == "" {
return errors.New("failed helper response contains no typed error")
}
return nil
}
func randomRequestID() (string, error) {
var value [16]byte
if _, err := rand.Read(value[:]); err != nil {
return "", err
}
return hex.EncodeToString(value[:]), nil
}

View file

@ -0,0 +1,183 @@
package agenthelper
import (
"context"
"encoding/json"
"errors"
"net"
"testing"
"time"
)
func pipeDial(handler func(net.Conn)) DialContextFunc {
return func(context.Context, string, string) (net.Conn, error) {
client, server := net.Pipe()
go handler(server)
return client, nil
}
}
func newPipeClient(t *testing.T, handler func(net.Conn)) *Client {
t.Helper()
client, err := NewClient(ClientConfig{
SocketPath: "/run/pulse-agent/helper.sock",
MaxDeadline: time.Second,
DialContext: pipeDial(handler),
NewRequestID: func() (string, error) { return "client-request", nil },
})
if err != nil {
t.Fatalf("NewClient: %v", err)
}
return client
}
func serveClientResponse(t *testing.T, conn net.Conn, mutate func(*Response)) {
t.Helper()
defer conn.Close()
frame, err := readFrame(conn, MaxRequestBytes)
if err != nil {
t.Errorf("read request: %v", err)
return
}
request, err := decodeRequest(frame)
if err != nil {
t.Errorf("decode request: %v", err)
return
}
response := Response{
ProtocolVersion: ProtocolVersion,
RequestID: request.RequestID,
Operation: request.Operation,
OperationVersion: request.OperationVersion,
Success: true,
Result: json.RawMessage(`{"status":"ok"}`),
}
if mutate != nil {
mutate(&response)
}
if _, err := writeFrame(conn, response, MaxResponseBytes); err != nil {
t.Errorf("write response: %v", err)
}
}
func TestClientCallUsesUnixFramingAndStrictCorrelation(t *testing.T) {
client := newPipeClient(t, func(conn net.Conn) { serveClientResponse(t, conn, nil) })
var result struct {
Status string `json:"status"`
}
requestID, err := client.Call(context.Background(), OperationHealth, 1, time.Second, struct{}{}, &result)
if err != nil {
t.Fatalf("Call: %v", err)
}
if requestID != "client-request" || result.Status != "ok" {
t.Fatalf("requestID=%q result=%#v", requestID, result)
}
}
func TestClientReturnsTypedRemoteError(t *testing.T) {
client := newPipeClient(t, func(conn net.Conn) {
serveClientResponse(t, conn, func(response *Response) {
response.Success = false
response.Result = nil
response.Error = &ResponseError{Code: ErrorProviderUnavailable, Message: "not installed", Retryable: false}
})
})
requestID, err := client.Call(context.Background(), OperationSMARTSnapshot, 1, time.Second, nil, nil)
if requestID != "client-request" {
t.Fatalf("request ID = %q", requestID)
}
var remote *RemoteError
if !errors.As(err, &remote) || remote.Code != ErrorProviderUnavailable || remote.RequestID != requestID {
t.Fatalf("error = %#v", err)
}
}
func TestClientRejectsMismatchedAndMalformedResponses(t *testing.T) {
tests := []struct {
name string
mutate func(*Response)
}{
{name: "protocol", mutate: func(r *Response) { r.ProtocolVersion = 2 }},
{name: "request id", mutate: func(r *Response) { r.RequestID = "other" }},
{name: "operation", mutate: func(r *Response) { r.Operation = OperationCapabilities }},
{name: "version", mutate: func(r *Response) { r.OperationVersion = 2 }},
{name: "success with error", mutate: func(r *Response) { r.Error = &ResponseError{Code: ErrorInternal} }},
{name: "failure without error", mutate: func(r *Response) { r.Success = false; r.Result = nil }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
client := newPipeClient(t, func(conn net.Conn) { serveClientResponse(t, conn, test.mutate) })
if _, err := client.Call(context.Background(), OperationHealth, 1, time.Second, nil, nil); err == nil {
t.Fatal("malformed response accepted")
}
})
}
}
func TestClientRejectsInvalidConfigurationAndCalls(t *testing.T) {
for _, path := range []string{"", "relative.sock", "/run/../tmp/helper.sock"} {
if _, err := NewClient(ClientConfig{SocketPath: path}); err == nil {
t.Fatalf("invalid socket path accepted: %q", path)
}
}
client, err := NewClient(ClientConfig{
SocketPath: "/run/pulse-agent/helper.sock",
MaxDeadline: time.Second,
DialContext: func(context.Context, string, string) (net.Conn, error) {
return nil, errors.New("must not dial")
},
NewRequestID: func() (string, error) { return "request", nil },
})
if err != nil {
t.Fatal(err)
}
for _, call := range []struct {
operation string
version int
deadline time.Duration
}{
{operation: "", version: 1, deadline: time.Second},
{operation: OperationHealth, version: 0, deadline: time.Second},
{operation: OperationHealth, version: 1, deadline: 0},
{operation: OperationHealth, version: 1, deadline: 2 * time.Second},
} {
if _, err := client.Call(context.Background(), call.operation, call.version, call.deadline, nil, nil); err == nil {
t.Fatalf("invalid call accepted: %#v", call)
}
}
}
func TestClientRejectsUnsafeGeneratedRequestID(t *testing.T) {
client, err := NewClient(ClientConfig{
SocketPath: "/run/pulse-agent/helper.sock",
MaxDeadline: time.Second,
DialContext: func(context.Context, string, string) (net.Conn, error) { return nil, errors.New("must not dial") },
NewRequestID: func() (string, error) { return "forged\nrequest", nil },
})
if err != nil {
t.Fatal(err)
}
if _, err := client.Call(context.Background(), OperationHealth, 1, time.Second, nil, nil); err == nil {
t.Fatal("unsafe request ID accepted")
}
}
func TestClientHardcodesUnixTransport(t *testing.T) {
var network, address string
client, err := NewClient(ClientConfig{
SocketPath: "/run/pulse-agent/helper.sock",
MaxDeadline: time.Second,
DialContext: func(_ context.Context, gotNetwork, gotAddress string) (net.Conn, error) {
network, address = gotNetwork, gotAddress
return nil, errors.New("stop")
},
NewRequestID: func() (string, error) { return "request", nil },
})
if err != nil {
t.Fatal(err)
}
_, _ = client.Call(context.Background(), OperationHealth, 1, time.Second, nil, nil)
if network != "unix" || address != "/run/pulse-agent/helper.sock" {
t.Fatalf("dialed network=%q address=%q", network, address)
}
}

View file

@ -0,0 +1,40 @@
//go:build linux
package agenthelper
import (
"errors"
"fmt"
"net"
"syscall"
)
type PlatformPeerResolver struct{}
func (PlatformPeerResolver) Resolve(conn net.Conn) (Peer, error) {
unixConn, ok := conn.(*net.UnixConn)
if !ok {
return Peer{}, errors.New("peer credential resolution requires a Unix connection")
}
raw, err := unixConn.SyscallConn()
if err != nil {
return Peer{}, fmt.Errorf("access Unix connection: %w", err)
}
var (
credentials *syscall.Ucred
controlErr error
)
if err := raw.Control(func(fd uintptr) {
credentials, controlErr = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED)
}); err != nil {
return Peer{}, fmt.Errorf("inspect Unix connection: %w", err)
}
if controlErr != nil {
return Peer{}, fmt.Errorf("resolve SO_PEERCRED: %w", controlErr)
}
if credentials == nil {
return Peer{}, errors.New("SO_PEERCRED returned no credentials")
}
return Peer{UID: credentials.Uid, GID: credentials.Gid, PID: credentials.Pid}, nil
}

View file

@ -0,0 +1,56 @@
//go:build linux
package agenthelper
import (
"net"
"os"
"path/filepath"
"testing"
)
func TestPlatformPeerResolverReadsSOPEERCRED(t *testing.T) {
path := filepath.Join(t.TempDir(), "peer.sock")
listener, err := net.Listen("unix", path)
if err != nil {
t.Fatalf("listen: %v", err)
}
defer listener.Close()
result := make(chan struct {
peer Peer
err error
}, 1)
go func() {
conn, err := listener.Accept()
if err != nil {
result <- struct {
peer Peer
err error
}{err: err}
return
}
defer conn.Close()
peer, err := (PlatformPeerResolver{}).Resolve(conn)
result <- struct {
peer Peer
err error
}{peer: peer, err: err}
}()
client, err := net.Dial("unix", path)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer client.Close()
resolved := <-result
if resolved.err != nil {
t.Fatalf("Resolve: %v", resolved.err)
}
if resolved.peer.UID != uint32(os.Geteuid()) || resolved.peer.GID != uint32(os.Getegid()) {
t.Fatalf("peer = %#v, current uid=%d gid=%d", resolved.peer, os.Geteuid(), os.Getegid())
}
if resolved.peer.PID != int32(os.Getpid()) {
t.Fatalf("peer pid = %d, want %d", resolved.peer.PID, os.Getpid())
}
}

View file

@ -0,0 +1,14 @@
//go:build !linux
package agenthelper
import (
"errors"
"net"
)
type PlatformPeerResolver struct{}
func (PlatformPeerResolver) Resolve(net.Conn) (Peer, error) {
return Peer{}, errors.New("peer credentials are supported only on Linux")
}

View file

@ -0,0 +1,166 @@
package agenthelper
import (
"bytes"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
)
const (
ProtocolVersion = 1
MaxRequestBytes uint32 = 64 * 1024
MaxResponseBytes uint32 = 10 * 1024 * 1024
)
const (
ErrorInvalidFrame = "invalid_frame"
ErrorInvalidRequest = "invalid_request"
ErrorUnsupportedProtocol = "unsupported_protocol"
ErrorUnknownOperation = "unknown_operation"
ErrorUnsupportedOperation = "unsupported_operation_version"
ErrorUnauthorizedPeer = "unauthorized_peer"
ErrorDeadlineExceeded = "deadline_exceeded"
ErrorProviderUnavailable = "provider_unavailable"
ErrorResponseTooLarge = "response_too_large"
ErrorInternal = "internal_error"
)
const (
OperationHealth = "helper.health"
OperationCapabilities = "helper.capabilities"
OperationSMARTSnapshot = "smart.snapshot"
OperationProxmoxLXCFilesystems = "proxmox.lxc_filesystems"
OperationVersion1 = 1
)
// Request is the common envelope for one local helper operation. Payload is
// decoded again against the selected operation's exact schema.
type Request struct {
ProtocolVersion int `json:"protocolVersion"`
RequestID string `json:"requestId"`
Operation string `json:"operation"`
OperationVersion int `json:"operationVersion"`
DeadlineMillis int64 `json:"deadlineMillis"`
Payload json.RawMessage `json:"payload,omitempty"`
}
type Response struct {
ProtocolVersion int `json:"protocolVersion"`
RequestID string `json:"requestId,omitempty"`
Operation string `json:"operation,omitempty"`
OperationVersion int `json:"operationVersion,omitempty"`
Success bool `json:"success"`
Result json.RawMessage `json:"result,omitempty"`
Error *ResponseError `json:"error,omitempty"`
}
type ResponseError struct {
Code string `json:"code"`
Message string `json:"message"`
Retryable bool `json:"retryable"`
}
type frameError struct {
message string
}
func (e *frameError) Error() string { return e.message }
func readFrame(r io.Reader, limit uint32) ([]byte, error) {
var header [4]byte
if _, err := io.ReadFull(r, header[:]); err != nil {
return nil, &frameError{message: fmt.Sprintf("read frame length: %v", err)}
}
length := binary.BigEndian.Uint32(header[:])
if length == 0 {
return nil, &frameError{message: "frame payload is empty"}
}
if length > limit {
return nil, &frameError{message: fmt.Sprintf("frame payload exceeds %d bytes", limit)}
}
payload := make([]byte, length)
if _, err := io.ReadFull(r, payload); err != nil {
return nil, &frameError{message: fmt.Sprintf("read frame payload: %v", err)}
}
return payload, nil
}
func marshalFrame(value any, limit uint32) ([]byte, error) {
payload, err := json.Marshal(value)
if err != nil {
return nil, fmt.Errorf("marshal frame: %w", err)
}
if len(payload) == 0 || uint64(len(payload)) > uint64(limit) {
return nil, &frameError{message: fmt.Sprintf("frame payload exceeds %d bytes", limit)}
}
framed := make([]byte, 4+len(payload))
binary.BigEndian.PutUint32(framed[:4], uint32(len(payload)))
copy(framed[4:], payload)
return framed, nil
}
func writeFrame(w io.Writer, value any, limit uint32) (int, error) {
framed, err := marshalFrame(value, limit)
if err != nil {
return 0, err
}
written, err := writeAll(w, framed)
if err != nil {
return written, fmt.Errorf("write frame: %w", err)
}
return written, nil
}
func writeAll(w io.Writer, payload []byte) (int, error) {
total := 0
for total < len(payload) {
written, err := w.Write(payload[total:])
total += written
if err != nil {
return total, err
}
if written == 0 {
return total, io.ErrShortWrite
}
}
return total, nil
}
func decodeStrict(data []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
var trailing any
err := decoder.Decode(&trailing)
if !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("trailing JSON value is not allowed")
}
return fmt.Errorf("decode trailing JSON: %w", err)
}
return nil
}
func decodeRequest(data []byte) (Request, error) {
var request Request
if err := decodeStrict(data, &request); err != nil {
return Request{}, err
}
return request, nil
}
func decodePayload(raw json.RawMessage, target any) error {
if len(raw) == 0 {
raw = json.RawMessage(`{}`)
}
return decodeStrict(raw, target)
}

View file

@ -0,0 +1,73 @@
package agenthelper
import (
"bytes"
"encoding/binary"
"strings"
"testing"
)
func TestFrameRoundTripUsesBigEndianLength(t *testing.T) {
request := Request{
ProtocolVersion: ProtocolVersion,
RequestID: "request-1",
Operation: OperationHealth,
OperationVersion: OperationVersion1,
DeadlineMillis: 1000,
Payload: []byte(`{}`),
}
framed, err := marshalFrame(request, MaxRequestBytes)
if err != nil {
t.Fatalf("marshalFrame: %v", err)
}
if got := binary.BigEndian.Uint32(framed[:4]); got != uint32(len(framed)-4) {
t.Fatalf("frame length = %d, want %d", got, len(framed)-4)
}
payload, err := readFrame(bytes.NewReader(framed), MaxRequestBytes)
if err != nil {
t.Fatalf("readFrame: %v", err)
}
decoded, err := decodeRequest(payload)
if err != nil {
t.Fatalf("decodeRequest: %v", err)
}
if decoded.RequestID != request.RequestID || decoded.Operation != request.Operation {
t.Fatalf("decoded request = %#v", decoded)
}
}
func TestReadFrameRejectsInvalidLengthsAndTruncation(t *testing.T) {
tests := []struct {
name string
data []byte
}{
{name: "empty", data: []byte{0, 0, 0, 0}},
{name: "oversized", data: []byte{0, 1, 0, 1}},
{name: "truncated", data: []byte{0, 0, 0, 4, '{', '}'}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, err := readFrame(bytes.NewReader(test.data), MaxRequestBytes); err == nil {
t.Fatal("invalid frame accepted")
}
})
}
}
func TestDecodeStrictRejectsUnknownFieldAndTrailingJSON(t *testing.T) {
for _, raw := range []string{
`{"protocolVersion":1,"requestId":"r","operation":"helper.health","operationVersion":1,"deadlineMillis":1,"unknown":true}`,
`{"protocolVersion":1,"requestId":"r","operation":"helper.health","operationVersion":1,"deadlineMillis":1}{}`,
} {
if _, err := decodeRequest([]byte(raw)); err == nil {
t.Fatalf("invalid request accepted: %s", raw)
}
}
}
func TestMarshalFrameEnforcesLimit(t *testing.T) {
value := strings.Repeat("x", 128)
if _, err := marshalFrame(value, 32); err == nil {
t.Fatal("oversized marshaled frame accepted")
}
}

View file

@ -0,0 +1,59 @@
package agenthelper
import (
"context"
"encoding/json"
"errors"
)
// SMARTProvider owns the privileged implementation of a complete SMART
// snapshot. The protocol deliberately supplies no caller-selected path or
// command arguments.
type SMARTProvider interface {
Snapshot(context.Context) (json.RawMessage, error)
}
// ProxmoxProvider owns the privileged implementation of the bounded LXC
// filesystem inventory. The protocol deliberately supplies no VMID, path, or
// pct arguments.
type ProxmoxProvider interface {
LXCFilesystems(context.Context) (json.RawMessage, error)
}
type ProviderError struct {
Code string
Message string
Retryable bool
}
func (e *ProviderError) Error() string {
if e == nil {
return ""
}
return e.Message
}
func providerError(err error) *ResponseError {
if err == nil {
return nil
}
var typed *ProviderError
if errors.As(err, &typed) {
code := stableProviderErrorCode(typed.Code)
message := typed.Message
if message == "" {
message = "helper provider failed"
}
return &ResponseError{Code: code, Message: message, Retryable: typed.Retryable}
}
return &ResponseError{Code: ErrorInternal, Message: "helper provider failed"}
}
func stableProviderErrorCode(code string) string {
switch code {
case ErrorProviderUnavailable, ErrorDeadlineExceeded:
return code
default:
return ErrorInternal
}
}

View file

@ -0,0 +1,151 @@
package agenthelper
import (
"context"
"encoding/json"
"sort"
)
type operationHandler func(context.Context, json.RawMessage) (json.RawMessage, *ResponseError)
type operationKey struct {
name string
version int
}
type registeredOperation struct {
name string
version int
available bool
handle operationHandler
}
// Registry is a closed registry of the helper operations Pulse defines. It
// intentionally has no public Register method: a caller cannot add a generic
// exec, path-read, or daemon-proxy primitive at runtime.
type Registry struct {
operations map[operationKey]registeredOperation
}
type Capability struct {
Operation string `json:"operation"`
Version int `json:"version"`
Available bool `json:"available"`
}
type CapabilitiesResult struct {
ProtocolVersion int `json:"protocolVersion"`
Operations []Capability `json:"operations"`
}
type HealthResult struct {
Status string `json:"status"`
ProtocolVersion int `json:"protocolVersion"`
}
type emptyOperationRequest struct{}
func NewRegistry(smart SMARTProvider, proxmox ProxmoxProvider) *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
if err := decodePayload(payload, &request); err != nil {
return nil, invalidPayloadError(err)
}
return marshalOperationResult(HealthResult{Status: "ok", ProtocolVersion: ProtocolVersion})
})
registry.add(OperationCapabilities, OperationVersion1, true, func(_ context.Context, payload json.RawMessage) (json.RawMessage, *ResponseError) {
var request emptyOperationRequest
if err := decodePayload(payload, &request); err != nil {
return nil, invalidPayloadError(err)
}
return marshalOperationResult(registry.capabilities())
})
registry.add(OperationSMARTSnapshot, OperationVersion1, smart != 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 smart == nil {
return nil, unavailableError("SMART snapshot provider is not configured")
}
result, err := smart.Snapshot(ctx)
return validateProviderResult(result, err)
})
registry.add(OperationProxmoxLXCFilesystems, OperationVersion1, proxmox != 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 proxmox == nil {
return nil, unavailableError("Proxmox LXC filesystem provider is not configured")
}
result, err := proxmox.LXCFilesystems(ctx)
return validateProviderResult(result, err)
})
return registry
}
func (r *Registry) add(name string, version int, available bool, handler operationHandler) {
r.operations[operationKey{name: name, version: version}] = registeredOperation{
name: name, version: version, available: available, handle: handler,
}
}
func (r *Registry) lookup(name string, version int) (registeredOperation, bool) {
operation, ok := r.operations[operationKey{name: name, version: version}]
return operation, ok
}
func (r *Registry) hasName(name string) bool {
for key := range r.operations {
if key.name == name {
return true
}
}
return false
}
func (r *Registry) capabilities() CapabilitiesResult {
capabilities := make([]Capability, 0, len(r.operations))
for _, operation := range r.operations {
capabilities = append(capabilities, Capability{
Operation: operation.name,
Version: operation.version,
Available: operation.available,
})
}
sort.Slice(capabilities, func(i, j int) bool {
if capabilities[i].Operation == capabilities[j].Operation {
return capabilities[i].Version < capabilities[j].Version
}
return capabilities[i].Operation < capabilities[j].Operation
})
return CapabilitiesResult{ProtocolVersion: ProtocolVersion, Operations: capabilities}
}
func marshalOperationResult(value any) (json.RawMessage, *ResponseError) {
result, err := json.Marshal(value)
if err != nil {
return nil, &ResponseError{Code: ErrorInternal, Message: "encode helper result"}
}
return result, nil
}
func validateProviderResult(result json.RawMessage, err error) (json.RawMessage, *ResponseError) {
if err != nil {
return nil, providerError(err)
}
if len(result) == 0 || !json.Valid(result) {
return nil, &ResponseError{Code: ErrorInternal, Message: "helper provider returned invalid JSON"}
}
return result, nil
}
func invalidPayloadError(err error) *ResponseError {
return &ResponseError{Code: ErrorInvalidRequest, Message: "invalid operation payload: " + err.Error()}
}
func unavailableError(message string) *ResponseError {
return &ResponseError{Code: ErrorProviderUnavailable, Message: message, Retryable: false}
}

View file

@ -0,0 +1,276 @@
package agenthelper
import (
"context"
"errors"
"fmt"
"net"
"strings"
"time"
)
const defaultMaxOperationTimeout = 30 * time.Second
const responseWriteTimeout = 5 * time.Second
type Peer struct {
UID uint32
GID uint32
PID int32
}
type PeerResolver interface {
Resolve(net.Conn) (Peer, error)
}
type PeerResolverFunc func(net.Conn) (Peer, error)
func (f PeerResolverFunc) Resolve(conn net.Conn) (Peer, error) { return f(conn) }
// AuditEvent intentionally contains only transport and operation metadata.
// Request payloads and result data never reach the audit hook.
type AuditEvent struct {
RequestID string
Operation string
OperationVersion int
Peer Peer
StartedAt time.Time
Duration time.Duration
Success bool
ErrorCode string
RequestBytes int
ResponseBytes int
}
type AuditHook func(AuditEvent)
type ServerConfig struct {
AllowedUID uint32
PeerResolver PeerResolver
Registry *Registry
MaxOperationTimeout time.Duration
Audit AuditHook
Now func() time.Time
}
type Server struct {
allowedUID uint32
peerResolver PeerResolver
registry *Registry
maxOperationTimeout time.Duration
audit AuditHook
now func() time.Time
}
func NewServer(config ServerConfig) (*Server, error) {
if config.PeerResolver == nil {
return nil, errors.New("peer resolver is required")
}
if config.Registry == nil {
return nil, errors.New("operation registry is required")
}
maxTimeout := config.MaxOperationTimeout
if maxTimeout <= 0 {
maxTimeout = defaultMaxOperationTimeout
}
if maxTimeout < time.Millisecond {
return nil, errors.New("maximum operation timeout must be at least 1ms")
}
now := config.Now
if now == nil {
now = time.Now
}
return &Server{
allowedUID: config.AllowedUID,
peerResolver: config.PeerResolver,
registry: config.Registry,
maxOperationTimeout: maxTimeout,
audit: config.Audit,
now: now,
}, nil
}
func (s *Server) Serve(ctx context.Context, listener net.Listener) error {
for {
conn, err := listener.Accept()
if err != nil {
if ctx.Err() != nil {
return nil
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Temporary() {
continue
}
return fmt.Errorf("accept helper connection: %w", err)
}
go s.HandleConnection(ctx, conn)
}
}
func (s *Server) HandleConnection(parent context.Context, conn net.Conn) {
defer conn.Close()
started := s.now()
event := AuditEvent{StartedAt: started}
defer func() {
event.Duration = s.now().Sub(started)
if s.audit != nil {
s.audit(event)
}
}()
_ = conn.SetDeadline(started.Add(s.maxOperationTimeout))
peer, err := s.peerResolver.Resolve(conn)
event.Peer = peer
if err != nil || peer.UID != s.allowedUID {
s.writeError(conn, &event, Request{}, ErrorUnauthorizedPeer, "local peer is not authorized", false)
return
}
frame, err := readFrame(conn, MaxRequestBytes)
if err != nil {
s.writeError(conn, &event, Request{}, ErrorInvalidFrame, "invalid request frame", false)
return
}
event.RequestBytes = len(frame)
request, err := decodeRequest(frame)
if err != nil {
s.writeError(conn, &event, Request{}, ErrorInvalidRequest, "invalid request envelope: "+err.Error(), false)
return
}
event.RequestID = request.RequestID
event.Operation = request.Operation
event.OperationVersion = request.OperationVersion
if validationError := s.validateRequest(request); validationError != nil {
s.writeResponse(conn, &event, errorResponse(request, validationError))
return
}
operation, ok := s.registry.lookup(request.Operation, request.OperationVersion)
if !ok {
if s.registry.hasName(request.Operation) {
s.writeError(conn, &event, request, ErrorUnsupportedOperation, "operation version is not supported", false)
} else {
s.writeError(conn, &event, request, ErrorUnknownOperation, "operation is not supported", false)
}
return
}
operationContext, cancel := context.WithTimeout(parent, time.Duration(request.DeadlineMillis)*time.Millisecond)
defer cancel()
result, operationError := operation.handle(operationContext, request.Payload)
if operationError == nil && operationContext.Err() != nil {
operationError = &ResponseError{Code: ErrorDeadlineExceeded, Message: "operation deadline exceeded", Retryable: true}
}
if operationError != nil && errors.Is(operationContext.Err(), context.DeadlineExceeded) {
operationError = &ResponseError{Code: ErrorDeadlineExceeded, Message: "operation deadline exceeded", Retryable: true}
}
response := Response{
ProtocolVersion: ProtocolVersion,
RequestID: request.RequestID,
Operation: request.Operation,
OperationVersion: request.OperationVersion,
Success: operationError == nil,
Result: result,
Error: operationError,
}
s.writeResponse(conn, &event, response)
}
func (s *Server) validateRequest(request Request) *ResponseError {
if request.ProtocolVersion != ProtocolVersion {
return &ResponseError{Code: ErrorUnsupportedProtocol, Message: "protocol version is not supported"}
}
if !validRequestID(request.RequestID) {
return &ResponseError{Code: ErrorInvalidRequest, Message: "requestId must contain 1 to 128 safe characters"}
}
if strings.TrimSpace(request.Operation) == "" {
return &ResponseError{Code: ErrorInvalidRequest, Message: "operation is required"}
}
if request.OperationVersion < 1 {
return &ResponseError{Code: ErrorInvalidRequest, Message: "operationVersion must be positive"}
}
if request.DeadlineMillis < 1 || request.DeadlineMillis > s.maxOperationTimeout.Milliseconds() {
return &ResponseError{Code: ErrorInvalidRequest, Message: "deadlineMillis is outside the allowed range"}
}
return nil
}
func validRequestID(value string) bool {
if value == "" || len(value) > 128 || strings.TrimSpace(value) != value {
return false
}
for _, char := range value {
if (char >= 'a' && char <= 'z') ||
(char >= 'A' && char <= 'Z') ||
(char >= '0' && char <= '9') ||
char == '-' || char == '_' || char == '.' || char == ':' {
continue
}
return false
}
return true
}
func (s *Server) writeError(conn net.Conn, event *AuditEvent, request Request, code, message string, retryable bool) {
s.writeResponse(conn, event, errorResponse(request, &ResponseError{Code: code, Message: message, Retryable: retryable}))
}
func errorResponse(request Request, responseError *ResponseError) Response {
return Response{
ProtocolVersion: ProtocolVersion,
RequestID: request.RequestID,
Operation: request.Operation,
OperationVersion: request.OperationVersion,
Success: false,
Error: responseError,
}
}
func (s *Server) writeResponse(conn net.Conn, event *AuditEvent, response Response) {
_ = conn.SetWriteDeadline(s.now().Add(responseWriteTimeout))
framed, err := marshalFrame(response, MaxResponseBytes)
if err != nil {
response = errorResponse(Request{
RequestID: response.RequestID,
Operation: response.Operation,
OperationVersion: response.OperationVersion,
}, &ResponseError{Code: ErrorResponseTooLarge, Message: "operation response exceeds the allowed size"})
framed, err = marshalFrame(response, MaxResponseBytes)
if err != nil {
event.Success = false
event.ErrorCode = ErrorInternal
return
}
}
written, writeErr := writeAll(conn, framed)
event.ResponseBytes = max(written-4, 0)
event.Success = response.Success && writeErr == nil
if response.Error != nil {
event.ErrorCode = response.Error.Code
} else if writeErr != nil {
event.ErrorCode = ErrorInternal
}
}
// DecodeResponse is the client-side strict response decoder.
func DecodeResponse(data []byte) (Response, error) {
var response Response
if err := decodeStrict(data, &response); err != nil {
return Response{}, err
}
return response, nil
}
// EncodeRequestFrame is exposed for the collector-side client package that
// will be wired in a later bounded slice.
func EncodeRequestFrame(request Request) ([]byte, error) {
return marshalFrame(request, MaxRequestBytes)
}
// ReadResponseFrame reads one bounded helper response.
func ReadResponseFrame(reader interface{ Read([]byte) (int, error) }) ([]byte, error) {
return readFrame(reader, MaxResponseBytes)
}

View file

@ -0,0 +1,308 @@
package agenthelper
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"net"
"strings"
"sync"
"testing"
"time"
)
type fakeSMARTProvider func(context.Context) (json.RawMessage, error)
func (f fakeSMARTProvider) Snapshot(ctx context.Context) (json.RawMessage, error) {
return f(ctx)
}
type fakeProxmoxProvider func(context.Context) (json.RawMessage, error)
func (f fakeProxmoxProvider) LXCFilesystems(ctx context.Context) (json.RawMessage, error) {
return f(ctx)
}
func authorizedResolver(uid uint32) PeerResolver {
return PeerResolverFunc(func(net.Conn) (Peer, error) {
return Peer{UID: uid, GID: 2000, PID: 3000}, nil
})
}
func newTestServer(t *testing.T, registry *Registry, resolver PeerResolver, audit AuditHook) *Server {
t.Helper()
server, err := NewServer(ServerConfig{
AllowedUID: 1000,
PeerResolver: resolver,
Registry: registry,
MaxOperationTimeout: 100 * time.Millisecond,
Audit: audit,
})
if err != nil {
t.Fatalf("NewServer: %v", err)
}
return server
}
func validRequest(operation string) Request {
return Request{
ProtocolVersion: ProtocolVersion,
RequestID: "request-1",
Operation: operation,
OperationVersion: OperationVersion1,
DeadlineMillis: 50,
Payload: json.RawMessage(`{}`),
}
}
func exchangeRequest(t *testing.T, server *Server, framed []byte) Response {
t.Helper()
serverConn, clientConn := net.Pipe()
done := make(chan struct{})
go func() {
server.HandleConnection(context.Background(), serverConn)
close(done)
}()
if _, err := writeAll(clientConn, framed); err != nil {
t.Fatalf("write request: %v", err)
}
payload, err := readFrame(clientConn, MaxResponseBytes)
if err != nil {
t.Fatalf("read response: %v", err)
}
response, err := DecodeResponse(payload)
if err != nil {
t.Fatalf("DecodeResponse: %v", err)
}
_ = clientConn.Close()
<-done
return response
}
func exchange(t *testing.T, server *Server, request Request) Response {
t.Helper()
framed, err := EncodeRequestFrame(request)
if err != nil {
t.Fatalf("EncodeRequestFrame: %v", err)
}
return exchangeRequest(t, server, framed)
}
func requireErrorCode(t *testing.T, response Response, code string) {
t.Helper()
if response.Success || response.Error == nil || response.Error.Code != code {
t.Fatalf("response = %#v, want error %q", response, code)
}
}
func TestServerHealthAndCapabilities(t *testing.T) {
smart := fakeSMARTProvider(func(context.Context) (json.RawMessage, error) {
return json.RawMessage(`{"disks":[]}`), nil
})
server := newTestServer(t, NewRegistry(smart, nil), authorizedResolver(1000), nil)
health := exchange(t, server, validRequest(OperationHealth))
if !health.Success {
t.Fatalf("health response = %#v", health)
}
var healthResult HealthResult
if err := decodeStrict(health.Result, &healthResult); err != nil || healthResult.Status != "ok" {
t.Fatalf("health result = %#v, err=%v", healthResult, err)
}
capabilities := exchange(t, server, validRequest(OperationCapabilities))
if !capabilities.Success {
t.Fatalf("capabilities response = %#v", capabilities)
}
var result CapabilitiesResult
if err := decodeStrict(capabilities.Result, &result); err != nil {
t.Fatalf("decode capabilities: %v", err)
}
availability := make(map[string]bool)
for _, capability := range result.Operations {
availability[capability.Operation] = capability.Available
}
if !availability[OperationSMARTSnapshot] || availability[OperationProxmoxLXCFilesystems] {
t.Fatalf("provider availability = %#v", availability)
}
}
func TestServerDispatchesTypedProvidersWithoutCallerArguments(t *testing.T) {
smartCalled := false
proxmoxCalled := false
registry := NewRegistry(
fakeSMARTProvider(func(context.Context) (json.RawMessage, error) {
smartCalled = true
return json.RawMessage(`{"disks":[{"device":"sda"}]}`), nil
}),
fakeProxmoxProvider(func(context.Context) (json.RawMessage, error) {
proxmoxCalled = true
return json.RawMessage(`{"containers":[]}`), nil
}),
)
server := newTestServer(t, registry, authorizedResolver(1000), nil)
if response := exchange(t, server, validRequest(OperationSMARTSnapshot)); !response.Success || !smartCalled {
t.Fatalf("SMART response = %#v called=%t", response, smartCalled)
}
if response := exchange(t, server, validRequest(OperationProxmoxLXCFilesystems)); !response.Success || !proxmoxCalled {
t.Fatalf("Proxmox response = %#v called=%t", response, proxmoxCalled)
}
request := validRequest(OperationSMARTSnapshot)
request.Payload = json.RawMessage(`{"path":"/dev/sda"}`)
requireErrorCode(t, exchange(t, server, request), ErrorInvalidRequest)
request = validRequest(OperationProxmoxLXCFilesystems)
request.Payload = json.RawMessage(`{"args":["exec","100"]}`)
requireErrorCode(t, exchange(t, server, request), ErrorInvalidRequest)
}
func TestServerRejectsEnvelopeAndRegistryViolations(t *testing.T) {
server := newTestServer(t, NewRegistry(nil, nil), authorizedResolver(1000), nil)
tests := []struct {
name string
mutate func(*Request)
code string
}{
{name: "protocol", mutate: func(r *Request) { r.ProtocolVersion = 2 }, code: ErrorUnsupportedProtocol},
{name: "unknown operation", mutate: func(r *Request) { r.Operation = "host.exec" }, code: ErrorUnknownOperation},
{name: "operation version", mutate: func(r *Request) { r.OperationVersion = 2 }, code: ErrorUnsupportedOperation},
{name: "missing request id", mutate: func(r *Request) { r.RequestID = "" }, code: ErrorInvalidRequest},
{name: "unsafe request id", mutate: func(r *Request) { r.RequestID = "request\nforged" }, code: ErrorInvalidRequest},
{name: "zero deadline", mutate: func(r *Request) { r.DeadlineMillis = 0 }, code: ErrorInvalidRequest},
{name: "excessive deadline", mutate: func(r *Request) { r.DeadlineMillis = 101 }, code: ErrorInvalidRequest},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
request := validRequest(OperationHealth)
test.mutate(&request)
requireErrorCode(t, exchange(t, server, request), test.code)
})
}
}
func TestServerRejectsStrictJSONViolations(t *testing.T) {
server := newTestServer(t, NewRegistry(nil, nil), authorizedResolver(1000), nil)
for _, raw := range []string{
`{"protocolVersion":1,"requestId":"r","operation":"helper.health","operationVersion":1,"deadlineMillis":50,"unexpected":true}`,
`{"protocolVersion":1,"requestId":"r","operation":"helper.health","operationVersion":1,"deadlineMillis":50}{}`,
} {
framed := make([]byte, 4+len(raw))
binary.BigEndian.PutUint32(framed[:4], uint32(len(raw)))
copy(framed[4:], raw)
requireErrorCode(t, exchangeRequest(t, server, framed), ErrorInvalidRequest)
}
}
func TestServerRejectsOversizedFrameBeforeReadingPayload(t *testing.T) {
server := newTestServer(t, NewRegistry(nil, nil), authorizedResolver(1000), nil)
framed := make([]byte, 4)
binary.BigEndian.PutUint32(framed, MaxRequestBytes+1)
requireErrorCode(t, exchangeRequest(t, server, framed), ErrorInvalidFrame)
}
func TestServerRejectsUnauthorizedPeer(t *testing.T) {
server := newTestServer(t, NewRegistry(nil, nil), authorizedResolver(1001), nil)
serverConn, clientConn := net.Pipe()
done := make(chan struct{})
go func() {
server.HandleConnection(context.Background(), serverConn)
close(done)
}()
payload, err := readFrame(clientConn, MaxResponseBytes)
if err != nil {
t.Fatalf("read unauthorized response: %v", err)
}
response, err := DecodeResponse(payload)
if err != nil {
t.Fatalf("decode unauthorized response: %v", err)
}
requireErrorCode(t, response, ErrorUnauthorizedPeer)
_ = clientConn.Close()
<-done
}
func TestServerBoundsOperationDeadline(t *testing.T) {
provider := fakeSMARTProvider(func(ctx context.Context) (json.RawMessage, error) {
<-ctx.Done()
return nil, ctx.Err()
})
server := newTestServer(t, NewRegistry(provider, nil), authorizedResolver(1000), nil)
request := validRequest(OperationSMARTSnapshot)
request.DeadlineMillis = 5
response := exchange(t, server, request)
requireErrorCode(t, response, ErrorDeadlineExceeded)
if !response.Error.Retryable {
t.Fatal("deadline error must be retryable")
}
}
func TestServerReplacesOversizedResponseWithTypedError(t *testing.T) {
oversized := json.RawMessage(`{"data":"` + strings.Repeat("x", int(MaxResponseBytes)) + `"}`)
server := newTestServer(t, NewRegistry(fakeSMARTProvider(func(context.Context) (json.RawMessage, error) {
return oversized, nil
}), nil), authorizedResolver(1000), nil)
requireErrorCode(t, exchange(t, server, validRequest(OperationSMARTSnapshot)), ErrorResponseTooLarge)
}
func TestServerMapsProviderErrorsAndInvalidResults(t *testing.T) {
tests := []struct {
name string
provider fakeSMARTProvider
code string
}{
{name: "typed", provider: func(context.Context) (json.RawMessage, error) {
return nil, &ProviderError{Code: ErrorProviderUnavailable, Message: "not configured"}
}, code: ErrorProviderUnavailable},
{name: "untyped", provider: func(context.Context) (json.RawMessage, error) {
return nil, errors.New("secret provider detail")
}, code: ErrorInternal},
{name: "invalid JSON", provider: func(context.Context) (json.RawMessage, error) {
return json.RawMessage(`not-json`), nil
}, code: ErrorInternal},
{name: "unstable provider code", provider: func(context.Context) (json.RawMessage, error) {
return nil, &ProviderError{Code: "invented_provider_code", Message: "bounded"}
}, code: ErrorInternal},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := newTestServer(t, NewRegistry(test.provider, nil), authorizedResolver(1000), nil)
response := exchange(t, server, validRequest(OperationSMARTSnapshot))
requireErrorCode(t, response, test.code)
if strings.Contains(response.Error.Message, "secret provider detail") {
t.Fatal("untyped provider detail escaped helper boundary")
}
})
}
}
func TestAuditHookContainsMetadataOnly(t *testing.T) {
var (
mu sync.Mutex
events []AuditEvent
)
server := newTestServer(t, NewRegistry(fakeSMARTProvider(func(context.Context) (json.RawMessage, error) {
return json.RawMessage(`{"secret":"must-not-be-audited"}`), nil
}), nil), authorizedResolver(1000), func(event AuditEvent) {
mu.Lock()
defer mu.Unlock()
events = append(events, event)
})
response := exchange(t, server, validRequest(OperationSMARTSnapshot))
if !response.Success {
t.Fatalf("response = %#v", response)
}
mu.Lock()
defer mu.Unlock()
if len(events) != 1 || events[0].RequestID != "request-1" || !events[0].Success {
t.Fatalf("audit events = %#v", events)
}
encoded, err := json.Marshal(events[0])
if err != nil {
t.Fatal(err)
}
if bytes.Contains(encoded, []byte("must-not-be-audited")) {
t.Fatal("result data reached audit metadata")
}
}

View file

@ -10220,6 +10220,41 @@ func TestContract_DownloadInstallScriptReleaseAssetIncludesSignatureHeader(t *te
}
}
func TestContract_AgentHelperDownloadIsLinuxOnlyAndSeparatelySigned(t *testing.T) {
router, tempDir := setupUnifiedAgentRouter(t)
router.serverVersion = "v6.0.0"
binaryPath := filepath.Join(tempDir, "bin", "pulse-agent-helper-linux-amd64")
if err := os.WriteFile(binaryPath, []byte("helper"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(binaryPath+".sig", []byte("helper-signature"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(binaryPath+".sshsig", []byte("helper-ssh-signature"), 0o644); err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/download/pulse-agent-helper?arch=linux-amd64", nil)
response := httptest.NewRecorder()
router.handleDownloadAgentHelper(response, request)
if response.Code != http.StatusOK {
t.Fatalf("helper download status = %d, body = %s", response.Code, response.Body.String())
}
if got := response.Header().Get(signatureHeaderName); got != "helper-signature" {
t.Fatalf("helper signature = %q", got)
}
if got := response.Header().Get(sshSignatureHeaderName); got != encodedTestSSHSignature("helper-ssh-signature") {
t.Fatalf("helper SSH signature = %q", got)
}
request = httptest.NewRequest(http.MethodGet, "/download/pulse-agent-helper?arch=windows-amd64", nil)
response = httptest.NewRecorder()
router.handleDownloadAgentHelper(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("non-Linux helper status = %d, want %d", response.Code, http.StatusBadRequest)
}
}
func TestContract_SystemSettingsResponseJSONSnapshot(t *testing.T) {
payload := EmptySystemSettingsResponse()
payload.SystemSettings = config.SystemSettings{

View file

@ -649,6 +649,7 @@ func (r *Router) registerAuthSecurityInstallRoutes() {
r.mux.HandleFunc("/install.sh", r.downloadLimiter.Middleware(r.handleDownloadUnifiedInstallScript))
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("/api/agent/version", r.handleAgentVersion)
r.mux.HandleFunc("/api/server/info", r.handleServerInfo)

View file

@ -157,6 +157,33 @@ func unifiedAgentLocalBuildCommand(normalized string) string {
return fmt.Sprintf("%s go build -o bin/pulse-agent-%s ./cmd/pulse-agent", strings.Join(env, " "), normalized)
}
func normalizeAgentHelperArch(arch string) string {
normalized := normalizeUnifiedAgentArch(arch)
if !strings.HasPrefix(normalized, "linux-") {
return ""
}
return normalized
}
func agentHelperLocalBuildCommand(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-helper-%s ./cmd/pulse-agent-helper", 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 {
@ -285,6 +312,115 @@ func (r *Router) handleDownloadUnifiedAgent(w http.ResponseWriter, req *http.Req
http.Error(w, "Agent binary not found. Specify ?arch=linux-amd64 (or your architecture)", http.StatusNotFound)
}
// handleDownloadAgentHelper serves the separately signed Linux privilege
// helper. The helper is never substituted with the networked collector binary
// and unsupported operating systems fail closed.
func (r *Router) handleDownloadAgentHelper(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")
archParam := strings.TrimSpace(req.URL.Query().Get("arch"))
normalized := normalizeAgentHelperArch(archParam)
if normalized == "" {
http.Error(w, "A supported Linux architecture is required", http.StatusBadRequest)
return
}
binaryName := "pulse-agent-helper-" + 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 helper binary not found for "+normalized+" in dev mode.\nBuild with:\n "+agentHelperLocalBuildCommand(normalized), http.StatusNotFound)
return
}
r.proxyAgentHelperFromGitHub(w, req, normalized)
}
func (r *Router) proxyAgentHelperFromGitHub(w http.ResponseWriter, req *http.Request, normalized string) {
assetName := "pulse-agent-helper-" + normalized
assetURL, err := r.releaseAssetURL(assetName)
if err != nil {
http.Error(w, "Agent helper 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 helper binary", http.StatusServiceUnavailable)
return
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
http.Error(w, "Agent helper binary not found on GitHub", http.StatusNotFound)
return
}
content, checksum, err := readBinaryWithChecksum(response.Body)
if err != nil {
http.Error(w, "Failed to read agent helper binary", http.StatusInternalServerError)
return
}
signature, err := fetchReleaseAssetContent(req.Context(), client, assetURL+".sig", 16*1024)
if err != nil {
http.Error(w, "Failed to fetch agent helper 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 helper 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.

View file

@ -1,14 +1,106 @@
package api
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestDownloadAgentHelperRejectsUnsupportedArchitecture(t *testing.T) {
router := &Router{}
request := httptest.NewRequest(http.MethodGet, "/download/pulse-agent-helper?arch=darwin-arm64", nil)
response := httptest.NewRecorder()
router.handleDownloadAgentHelper(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", response.Code, http.StatusBadRequest)
}
}
func TestDownloadAgentHelperServesSignedLocalLinuxBinary(t *testing.T) {
router, root := setupUnifiedAgentRouter(t)
router.serverVersion = "v6.0.0"
content := []byte("linux helper")
path := filepath.Join(root, "bin", "pulse-agent-helper-linux-amd64")
if err := os.WriteFile(path, content, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path+".sig", []byte("helper-signature"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path+".sshsig", []byte("helper-ssh-signature"), 0o644); err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/download/pulse-agent-helper?arch=linux-amd64", nil)
response := httptest.NewRecorder()
router.handleDownloadAgentHelper(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
wantChecksum := sha256.Sum256(content)
if got := response.Header().Get(checksumHeaderName); got != hex.EncodeToString(wantChecksum[:]) {
t.Fatalf("checksum = %q", got)
}
if got := response.Header().Get(signatureHeaderName); got != "helper-signature" {
t.Fatalf("signature = %q", got)
}
if got := response.Header().Get(sshSignatureHeaderName); got != encodedTestSSHSignature("helper-ssh-signature") {
t.Fatalf("SSH signature = %q", got)
}
}
func TestDownloadAgentHelperMissingDevBinaryReturnsExactBuildCommand(t *testing.T) {
router, _ := setupUnifiedAgentRouter(t)
router.serverVersion = "dev"
request := httptest.NewRequest(http.MethodGet, "/download/pulse-agent-helper?arch=linux-armv7", nil)
response := httptest.NewRecorder()
router.handleDownloadAgentHelper(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-helper-linux-armv7 ./cmd/pulse-agent-helper"
if !strings.Contains(response.Body.String(), want) {
t.Fatalf("body missing build command: %s", response.Body.String())
}
}
func TestDownloadAgentHelperProxiesPublishedSignedAsset(t *testing.T) {
router, _ := setupUnifiedAgentRouter(t)
router.serverVersion = "v6.0.0-rc.1"
assetURL := "https://github.com/rcourtman/Pulse/releases/download/v6.0.0-rc.1/pulse-agent-helper-linux-amd64"
router.installScriptClient = newTestInstallScriptClientSequence(t, []expectedHTTPExchange{
{Method: http.MethodGet, URL: assetURL, Status: http.StatusOK, Body: "helper"},
{Method: http.MethodGet, URL: assetURL + ".sig", Status: http.StatusOK, Body: "helper-signature"},
{Method: http.MethodGet, URL: assetURL + ".sshsig", Status: http.StatusOK, Body: "helper-ssh-signature"},
})
request := httptest.NewRequest(http.MethodGet, "/download/pulse-agent-helper?arch=linux-amd64", nil)
response := httptest.NewRecorder()
router.handleDownloadAgentHelper(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
if got := response.Header().Get("X-Served-From"); got != "github-proxy" {
t.Fatalf("served from = %q", got)
}
if got := response.Header().Get(signatureHeaderName); got != "helper-signature" {
t.Fatalf("signature = %q", got)
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {

View file

@ -101,6 +101,11 @@ type Config struct {
OnServerVersion func(version string)
Collector SystemCollector // Optional: override default system information collector (for testing)
// PrivilegedTelemetry routes the exceptional SMART and Proxmox LXC
// filesystem snapshots through the typed no-network helper. When present,
// helper failure degrades those snapshots and never falls back to a broader
// local privilege path.
PrivilegedTelemetry PrivilegedTelemetry
// DockerContainerUpdater bridges typed container update operations to the
// Docker / Podman module when the unified agent runs both. Nil when the
@ -179,6 +184,7 @@ type Agent struct {
commandAuthorityProfile CommandAuthorityProfile
commandAuthorityWarningSeen bool
collector SystemCollector
privilegedTelemetry PrivilegedTelemetry
newCommandClient func(Config, string, string, string, string) *CommandClient
runCommandClient func(*CommandClient, context.Context) error
packageUpdates *packageUpdateManager
@ -432,6 +438,7 @@ func New(cfg Config) (*Agent, error) {
reportBuffer: utils.New[agentshost.Report](bufferCapacity),
observerReporters: observerReporters,
collector: collector,
privilegedTelemetry: cfg.PrivilegedTelemetry,
newCommandClient: newCommandClientFn,
runCommandClient: runCommandClientFn,
commandAuthorityProfile: commandAuthorityProfile,
@ -1160,7 +1167,16 @@ func (a *Agent) buildReport(ctx context.Context) (agentshost.Report, error) {
// Proxmox VE exposes per-mount LXC usage only through the node-local pct
// CLI. Query running containers on an independent bounded deadline.
proxmoxLXCData := a.collectProxmoxLXCFilesystems(ctx)
var proxmoxLXCData *agentshost.ProxmoxLXCInventory
if a.privilegedTelemetry != nil {
proxmoxLXCData, err = a.privilegedTelemetry.ProxmoxLXCFilesystems(ctx)
if err != nil {
a.logger.Debug().Err(err).Msg("Typed helper could not collect Proxmox LXC filesystems")
proxmoxLXCData = nil
}
} else {
proxmoxLXCData = a.collectProxmoxLXCFilesystems(ctx)
}
// Collect S.M.A.R.T. disk data after topology owners. Enumeration and each
// device probe have their own deadlines; a single shared 10-second budget
@ -2198,7 +2214,24 @@ func (a *Agent) collectSMARTData(ctx context.Context, diskExclude []string, unra
return nil
}
smartData, err := a.collector.SMARTLocal(ctx, diskExclude, unraid)
var smartData []DiskSMART
var err error
if a.privilegedTelemetry != nil {
smartData, err = a.privilegedTelemetry.SMARTSnapshot(ctx)
if err == nil && len(diskExclude) > 0 {
filtered := smartData[:0]
for _, disk := range smartData {
deviceName := filepath.Base(disk.Device)
if matchesDeviceExclude(deviceName, disk.Device, diskExclude) {
continue
}
filtered = append(filtered, disk)
}
smartData = filtered
}
} else {
smartData, err = a.collector.SMARTLocal(ctx, diskExclude, unraid)
}
if err != nil {
a.logger.Debug().Err(err).Msg("Failed to collect S.M.A.R.T. data (smartctl may not be installed)")
return nil

View file

@ -0,0 +1,80 @@
package hostagent
import (
"context"
"errors"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agenthelper"
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
)
const privilegeHelperOperationDeadline = 30 * time.Second
// PrivilegedTelemetry is the collector-side view of the no-network helper.
// It intentionally exposes complete typed snapshots rather than commands,
// executable paths, VMIDs, device paths, or caller-selected arguments.
type PrivilegedTelemetry interface {
SMARTSnapshot(context.Context) ([]DiskSMART, error)
ProxmoxLXCFilesystems(context.Context) (*agentshost.ProxmoxLXCInventory, error)
}
type privilegeHelperTelemetry struct {
client *agenthelper.Client
}
// NewPrivilegeHelperTelemetry creates a local-only client for the fixed helper
// socket selected by the installer. An empty path disables the helper.
func NewPrivilegeHelperTelemetry(socketPath string) (PrivilegedTelemetry, error) {
if strings.TrimSpace(socketPath) == "" {
return nil, nil
}
client, err := agenthelper.NewClient(agenthelper.ClientConfig{
SocketPath: socketPath,
MaxDeadline: privilegeHelperOperationDeadline,
})
if err != nil {
return nil, err
}
return &privilegeHelperTelemetry{client: client}, nil
}
func (c *privilegeHelperTelemetry) SMARTSnapshot(ctx context.Context) ([]DiskSMART, error) {
var response struct {
Disks []DiskSMART `json:"disks"`
}
_, err := c.client.Call(
ctx,
agenthelper.OperationSMARTSnapshot,
agenthelper.OperationVersion1,
privilegeHelperOperationDeadline,
struct{}{},
&response,
)
if err != nil {
return nil, err
}
return response.Disks, nil
}
func (c *privilegeHelperTelemetry) ProxmoxLXCFilesystems(ctx context.Context) (*agentshost.ProxmoxLXCInventory, error) {
var response struct {
Inventory *agentshost.ProxmoxLXCInventory `json:"inventory"`
}
_, err := c.client.Call(
ctx,
agenthelper.OperationProxmoxLXCFilesystems,
agenthelper.OperationVersion1,
privilegeHelperOperationDeadline,
struct{}{},
&response,
)
if err != nil {
return nil, err
}
if response.Inventory == nil {
return nil, errors.New("helper returned no Proxmox LXC filesystem inventory")
}
return response.Inventory, nil
}

View file

@ -0,0 +1,89 @@
package hostagent
import (
"context"
"errors"
"os/exec"
"testing"
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
"github.com/rs/zerolog"
)
type fakePrivilegedTelemetry struct {
smart []DiskSMART
smartErr error
proxmox *agentshost.ProxmoxLXCInventory
proxmoxErr error
smartCalls int
proxmoxCalls int
}
func (f *fakePrivilegedTelemetry) SMARTSnapshot(context.Context) ([]DiskSMART, error) {
f.smartCalls++
return f.smart, f.smartErr
}
func (f *fakePrivilegedTelemetry) ProxmoxLXCFilesystems(context.Context) (*agentshost.ProxmoxLXCInventory, error) {
f.proxmoxCalls++
return f.proxmox, f.proxmoxErr
}
func TestCollectSMARTDataUsesTypedHelperWithoutLocalFallback(t *testing.T) {
originalLookPath := zpoolLookPath
zpoolLookPath = func(string) (string, error) { return "", exec.ErrNotFound }
t.Cleanup(func() { zpoolLookPath = originalLookPath })
localCalls := 0
collector := &mockCollector{
smartLocalFn: func(context.Context, []string, *agentshost.UnraidStorage) ([]DiskSMART, error) {
localCalls++
return nil, errors.New("local SMART must not run")
},
}
helper := &fakePrivilegedTelemetry{smart: []DiskSMART{
{Device: "/dev/sda", Health: "PASSED"},
{Device: "/dev/sdb", Health: "PASSED"},
}}
agent := &Agent{
collector: collector,
privilegedTelemetry: helper,
logger: zerolog.Nop(),
}
got := agent.collectSMARTData(context.Background(), []string{"sdb"}, nil)
if localCalls != 0 {
t.Fatalf("local SMART calls = %d, want 0", localCalls)
}
if helper.smartCalls != 1 {
t.Fatalf("helper SMART calls = %d, want 1", helper.smartCalls)
}
if len(got) != 1 || got[0].Device != "/dev/sda" {
t.Fatalf("filtered helper SMART = %+v", got)
}
}
func TestCollectSMARTDataDoesNotWidenPrivilegeAfterHelperFailure(t *testing.T) {
localCalls := 0
collector := &mockCollector{
smartLocalFn: func(context.Context, []string, *agentshost.UnraidStorage) ([]DiskSMART, error) {
localCalls++
return []DiskSMART{{Device: "/dev/fallback"}}, nil
},
}
agent := &Agent{
collector: collector,
privilegedTelemetry: &fakePrivilegedTelemetry{
smartErr: errors.New("helper unavailable"),
},
logger: zerolog.Nop(),
}
if got := agent.collectSMARTData(context.Background(), nil, nil); got != nil {
t.Fatalf("SMART after helper failure = %+v, want nil", got)
}
if localCalls != 0 {
t.Fatalf("local SMART fallback calls = %d, want 0", localCalls)
}
}

View file

@ -16,6 +16,7 @@ import (
"unicode/utf8"
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
"github.com/rs/zerolog"
)
// resolvePctPath mirrors resolveSmartctlPath: PULSE_PCT_PATH lets a
@ -76,6 +77,19 @@ type proxmoxLXCConfigMount struct {
Path string
}
// CollectProxmoxLXCFilesystemsLocal runs the bounded, node-local Proxmox LXC
// filesystem collector with the production system collector. It exists for
// the root-only typed helper, whose caller cannot supply VMIDs, paths, command
// names, or arguments.
func CollectProxmoxLXCFilesystemsLocal(ctx context.Context) *agentshost.ProxmoxLXCInventory {
logger := zerolog.Nop()
agent := &Agent{
collector: NewDefaultCollector(),
logger: logger,
}
return agent.collectProxmoxLXCFilesystems(ctx)
}
// collectProxmoxLXCFilesystems auto-detects the local Proxmox Container
// Toolkit. It lists node-local guests, then resolves per-mount usage only for
// containers that the same list reported as running. Collection is read-only

View file

@ -105,7 +105,7 @@ frontend_pid=$!
if [[ "${PROFILE}" == "full" ]]; then
echo "Building frontend bundle concurrently with the release binary matrix."
else
echo "Using Pro packaging profile: build the required frontend embed locally; transfer public Unified Agent binaries only."
echo "Using Pro packaging profile: build the required frontend embed locally; transfer public agent-side binaries only."
fi
export CGO_ENABLED=0
@ -140,6 +140,10 @@ for target in "${PULSE_RELEASE_AGENT_TARGETS[@]}"; do
task_targets+=("${target}")
fi
done
for target in "${PULSE_RELEASE_AGENT_HELPER_TARGETS[@]}"; do
task_components+=(agent-helper)
task_targets+=("${target}")
done
if [[ "${PROFILE}" == "full" ]]; then
for target in "${PULSE_RELEASE_SERVER_TARGETS[@]}"; do
task_components+=(server)
@ -163,6 +167,10 @@ build_one() {
package=./cmd/pulse-agent
ldflags="${agent_ldflags}"
;;
agent-helper)
package=./cmd/pulse-agent-helper
ldflags=""
;;
mcp)
package=./cmd/pulse-mcp
ldflags=""

View file

@ -126,6 +126,7 @@ agent_ldflags="$(./scripts/release_ldflags.sh agent --version "v${VERSION}" "${u
# Build unified agents for every supported platform/architecture
echo "Building unified agents for all platforms..."
agent_build_order=("${PULSE_RELEASE_AGENT_TARGETS[@]}")
agent_helper_build_order=("${PULSE_RELEASE_AGENT_HELPER_TARGETS[@]}")
if [[ -n "${compiled_payload_dir:-}" ]]; then
test -d "${compiled_payload_dir}/binaries" || {
@ -143,6 +144,16 @@ else
-o "${output_path}" \
./cmd/pulse-agent
done
echo "Building privileged agent helpers for Linux..."
for target in "${agent_helper_build_order[@]}"; do
build_env="$(pulse_release_target_env "${target}")"
output_path="${BUILD_DIR}/$(pulse_release_binary_filename agent-helper "${target}")"
env ${build_env} go build \
"${release_go_build_args[@]}" \
-o "${output_path}" \
./cmd/pulse-agent-helper
done
fi
# Platform-native signing jobs may supply replacement desktop binaries. They
@ -231,6 +242,12 @@ for target in "${agent_build_order[@]}"; do
exit 1
}
done
for target in "${agent_helper_build_order[@]}"; do
test -f "${BUILD_DIR}/$(pulse_release_binary_filename agent-helper "${target}")" || {
echo "Error: release payload is missing agent helper 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
@ -298,6 +315,9 @@ for build_name in "${build_order[@]}"; do
cp "$BUILD_DIR/pulse-$build_name" "$universal_dir/bin/pulse-${build_name}"
cp "$BUILD_DIR/pulse-agent-$build_name" "$universal_dir/bin/pulse-agent-${build_name}"
done
for target in "${agent_helper_build_order[@]}"; do
cp "$BUILD_DIR/pulse-agent-helper-${target}" "$universal_dir/bin/pulse-agent-helper-${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"
@ -379,6 +399,11 @@ zip -j "$RELEASE_DIR/pulse-agent-v${VERSION}-windows-amd64.zip" "$BUILD_DIR/puls
zip -j "$RELEASE_DIR/pulse-agent-v${VERSION}-windows-arm64.zip" "$BUILD_DIR/pulse-agent-windows-arm64.exe"
zip -j "$RELEASE_DIR/pulse-agent-v${VERSION}-windows-386.zip" "$BUILD_DIR/pulse-agent-windows-386.exe"
# Package standalone privileged agent helpers (Linux only).
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 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.
@ -413,6 +438,11 @@ cp "$BUILD_DIR/pulse-agent-windows-386.exe" "$RELEASE_DIR/"
cp "$BUILD_DIR/pulse-agent-freebsd-amd64" "$RELEASE_DIR/"
cp "$BUILD_DIR/pulse-agent-freebsd-arm64" "$RELEASE_DIR/"
# Copy bare privileged helper binaries for installer/download compatibility.
for target in "${agent_helper_build_order[@]}"; do
cp "$BUILD_DIR/pulse-agent-helper-${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.

View file

@ -27,6 +27,7 @@
# --observers-file <path> Report to additional observer Pulse instances
# --enable-commands Enable Pulse command execution on agent (disabled by default; required for Patrol actions and Proxmox LXC Docker inventory)
# --least-privilege Run the agent as the dedicated 'pulse-agent' system user instead of root (Linux systemd only; SMART, Proxmox LXC filesystems, and command execution need root or an explicit grant)
# --enable-privileged-helper Install the typed root helper for an explicitly selected least-privilege Linux systemd profile
# --grant-smart With --least-privilege: allow SMART collection through an exact-command sudoers grant for smartctl
# --grant-pct With --least-privilege: allow Proxmox LXC filesystem capacity through a sudoers grant restricted to 'pct list' and 'pct df'
# --health-addr <addr> Health/metrics listener address (default: 127.0.0.1:9191, use "" to disable)
@ -135,6 +136,17 @@ SERVICE_USER="root"
LEAST_PRIVILEGE_USER="pulse-agent"
PRIVILEGE_HELPER_DIR="/usr/local/lib/pulse-agent"
PRIVILEGE_SUDOERS_FILE="/etc/sudoers.d/pulse-agent"
PRIVILEGED_HELPER_ENABLED="false"
PRIVILEGED_HELPER_EXPLICIT="false"
PRIVILEGED_HELPER_NAME="pulse-agent-helper"
PRIVILEGED_HELPER_BINARY_NAME="pulse-agent-helper"
PRIVILEGED_HELPER_BINARY_PATH="${PRIVILEGE_HELPER_DIR}/${PRIVILEGED_HELPER_BINARY_NAME}"
PRIVILEGED_HELPER_SERVICE_UNIT="/etc/systemd/system/${PRIVILEGED_HELPER_NAME}.service"
PRIVILEGED_HELPER_SOCKET_UNIT="/etc/systemd/system/${PRIVILEGED_HELPER_NAME}.socket"
PRIVILEGED_HELPER_SOCKET_DIR="/run/pulse-agent"
PRIVILEGED_HELPER_SOCKET_PATH="${PRIVILEGED_HELPER_SOCKET_DIR}/helper.sock"
PRIVILEGED_HELPER_CREDENTIAL_DIR="/etc/pulse-agent"
TMP_HELPER_BIN=""
SYSTEMD_ENV_LINES=""
SHELL_EXPORT_LINES=""
@ -502,6 +514,8 @@ Options:
--enable-commands Enable Pulse command execution (disabled by default; required for Patrol actions and Proxmox LXC Docker inventory)
--command-authority <profile> Local command ceiling: monitoring-only, command-capable, or legacy
--least-privilege Run the agent as the 'pulse-agent' system user instead of root (Linux systemd only)
--enable-privileged-helper Install the typed root helper (requires --least-privilege on standard Linux systemd)
--disable-privileged-helper Remove/disable an existing typed helper profile during this install
--grant-smart With --least-privilege: exact-command sudoers grant so SMART collection keeps working
--grant-pct With --least-privilege: sudoers grant restricted to 'pct list'/'pct df' so Proxmox LXC filesystem capacity keeps working
--health-addr <addr> Health/metrics listener address (default: 127.0.0.1:9191; use "" to disable)
@ -535,12 +549,18 @@ restore_selinux_contexts() {
if command -v restorecon >/dev/null 2>&1; then
log_info "Restoring SELinux contexts for installed binaries..."
restorecon -v "${INSTALL_DIR}/${BINARY_NAME}" >/dev/null 2>&1 || true
if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then
restorecon -v "$PRIVILEGED_HELPER_BINARY_PATH" >/dev/null 2>&1 || true
fi
log_info "SELinux context restored"
else
# Fallback to chcon if restorecon isn't available
if command -v chcon >/dev/null 2>&1; then
log_info "Setting SELinux context for installed binary..."
chcon -t bin_t "${INSTALL_DIR}/${BINARY_NAME}" 2>/dev/null || true
if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then
chcon -t bin_t "$PRIVILEGED_HELPER_BINARY_PATH" 2>/dev/null || true
fi
fi
fi
}
@ -837,6 +857,20 @@ teardown_systemd_agent_service() {
systemctl daemon-reload 2>/dev/null || true
}
teardown_privileged_helper_service() {
if ! command -v systemctl >/dev/null 2>&1; then
return 0
fi
systemctl stop "${PRIVILEGED_HELPER_NAME}.socket" 2>/dev/null || true
systemctl stop "${PRIVILEGED_HELPER_NAME}.service" 2>/dev/null || true
systemctl disable "${PRIVILEGED_HELPER_NAME}.socket" 2>/dev/null || true
rm -f "$PRIVILEGED_HELPER_SOCKET_UNIT" "$PRIVILEGED_HELPER_SERVICE_UNIT"
rm -f "$PRIVILEGED_HELPER_SOCKET_PATH"
rm -rf "$PRIVILEGED_HELPER_CREDENTIAL_DIR"
systemctl daemon-reload 2>/dev/null || true
systemctl reset-failed "${PRIVILEGED_HELPER_NAME}.service" 2>/dev/null || true
}
teardown_openrc_agent_service() {
local init_path="${1:-/etc/init.d/${AGENT_NAME}}"
rc-service "${AGENT_NAME}" stop 2>/dev/null || true
@ -1010,6 +1044,57 @@ ensure_freebsd_agent_enabled() {
apply_freebsd_agent_enablement
}
render_privileged_helper_socket_unit() {
local unit_path="$1"
cat > "$unit_path" <<EOF
[Unit]
Description=Pulse Agent typed privileged helper socket
[Socket]
ListenStream=${PRIVILEGED_HELPER_SOCKET_PATH}
SocketUser=root
SocketGroup=${LEAST_PRIVILEGE_USER}
SocketMode=0660
DirectoryMode=0755
RemoveOnStop=true
[Install]
WantedBy=sockets.target
EOF
}
render_privileged_helper_service_unit() {
local unit_path="$1"
local helper_binary="$2"
cat > "$unit_path" <<EOF
[Unit]
Description=Pulse Agent typed privileged helper
Requires=${PRIVILEGED_HELPER_NAME}.socket
After=${PRIVILEGED_HELPER_NAME}.socket
[Service]
Type=simple
ExecStart=${helper_binary}
User=root
Group=root
UMask=0077
NoNewPrivileges=true
PrivateNetwork=true
RestrictAddressFamilies=AF_UNIX
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
LockPersonality=true
RestrictSUIDSGID=true
SystemCallArchitectures=native
EOF
}
render_systemd_agent_unit() {
local unit_path="$1"
local exec_path="$2"
@ -2024,6 +2109,11 @@ build_exec_arg_items() {
if [[ -n "$OBSERVERS_FILE" ]]; then EXEC_ARG_ITEMS+=(--observers-file "$OBSERVERS_FILE"); fi
if [[ "$ENABLE_COMMANDS" == "true" ]]; then EXEC_ARG_ITEMS+=(--enable-commands); fi
EXEC_ARG_ITEMS+=(--command-authority "${COMMAND_AUTHORITY:-legacy}")
if [[ "${PRIVILEGED_HELPER_ENABLED:-false}" == "true" ]]; then
# The collector cannot replace its root-owned binary. Keep in-process
# updates off until signed activation moves behind the typed helper.
EXEC_ARG_ITEMS+=(--disable-auto-update)
fi
if [[ "$HEALTH_ADDR_SET" == "true" ]]; then EXEC_ARG_ITEMS+=(--health-addr "$HEALTH_ADDR"); fi
if [[ "$ENROLL" == "true" ]]; then EXEC_ARG_ITEMS+=(--enroll); fi
if [[ "$KUBE_INCLUDE_ALL_PODS" == "true" ]]; then EXEC_ARG_ITEMS+=(--kube-include-all-pods); fi
@ -3032,6 +3122,8 @@ while [[ $# -gt 0 ]]; do
--enable-commands) ENABLE_COMMANDS="true"; shift ;;
--command-authority) COMMAND_AUTHORITY="$2"; COMMAND_AUTHORITY_SOURCE="explicit"; shift 2 ;;
--least-privilege) LEAST_PRIVILEGE="true"; shift ;;
--enable-privileged-helper) PRIVILEGED_HELPER_ENABLED="true"; PRIVILEGED_HELPER_EXPLICIT="true"; shift ;;
--disable-privileged-helper) PRIVILEGED_HELPER_ENABLED="false"; PRIVILEGED_HELPER_EXPLICIT="true"; shift ;;
--grant-smart) GRANT_SMART="true"; shift ;;
--grant-pct) GRANT_PCT="true"; shift ;;
--health-addr) HEALTH_ADDR="$2"; HEALTH_ADDR_SET="true"; shift 2 ;;
@ -3096,6 +3188,18 @@ fi
if [[ "$LEAST_PRIVILEGE" == "true" && "$ENABLE_COMMANDS" == "true" ]]; then
fail "--least-privilege and --enable-commands are mutually exclusive: governed command execution requires the root profile" "$EXIT_MISSING_ARGS"
fi
if [[ "$PRIVILEGED_HELPER_EXPLICIT" != "true" ]] &&
{ [[ -f "$PRIVILEGED_HELPER_SOCKET_UNIT" ]] || [[ -f "$PRIVILEGED_HELPER_SERVICE_UNIT" ]]; }; then
PRIVILEGED_HELPER_ENABLED="true"
LEAST_PRIVILEGE="true"
log_info "Preserving existing typed privileged-helper profile"
fi
if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" && "$LEAST_PRIVILEGE" != "true" ]]; then
fail "--enable-privileged-helper requires --least-privilege" "$EXIT_MISSING_ARGS"
fi
if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" && ( "$GRANT_SMART" == "true" || "$GRANT_PCT" == "true" ) ]]; then
fail "--enable-privileged-helper cannot be combined with legacy --grant-smart/--grant-pct sudo paths" "$EXIT_MISSING_ARGS"
fi
# --- Check Root ---
if [[ $EUID -ne 0 && "$PREFLIGHT_ONLY" != "true" ]]; then
@ -3399,6 +3503,7 @@ if [[ "$UNINSTALL" == "true" ]]; then
# Systemd - unified agent
if command -v systemctl >/dev/null 2>&1; then
teardown_privileged_helper_service
teardown_systemd_agent_service
fi
@ -3599,9 +3704,19 @@ if [[ "$LEAST_PRIVILEGE" == "true" && "$UNINSTALL" != "true" ]]; then
fi
fi
if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" && "$UNINSTALL" != "true" ]]; then
if [[ "$(uname -s)" != "Linux" ]] || ! command -v systemctl >/dev/null 2>&1 ||
is_truenas || [[ -d /usr/syno ]] || [[ -f /etc/unraid-version ]] ||
[[ -d /boot/config/plugins ]] || [[ -x /sbin/getcfg ]]; then
fail "--enable-privileged-helper is supported only on standard Linux systemd hosts; no broader-privilege fallback was applied" "$EXIT_MISSING_ARGS"
fi
fi
# Create the dedicated service account for the least-privilege profile and
# hand it the state directory plus the agent binary (self-update swaps the
# binary in place, so the service user must own it).
# hand it the mutable state directory. The legacy least-privilege profile also
# owns its agent binary so its in-process updater keeps working. The typed
# helper profile instead keeps both executables root-owned and disables direct
# collector activation until the helper owns that operation.
provision_least_privilege_user() {
if ! id -u "$LEAST_PRIVILEGE_USER" >/dev/null 2>&1; then
if ! command -v useradd >/dev/null 2>&1; then
@ -3622,12 +3737,45 @@ provision_least_privilege_user() {
# Docker/Podman socket reads need group membership, not root. Auto-detect
# mirrors the module default: only an explicit --disable-docker skips it.
if [[ "$ENABLE_DOCKER" != "false" ]] && getent group docker >/dev/null 2>&1; then
if [[ "$PRIVILEGED_HELPER_ENABLED" != "true" && "$ENABLE_DOCKER" != "false" ]] && getent group docker >/dev/null 2>&1; then
usermod -aG docker "$LEAST_PRIVILEGE_USER" 2>/dev/null || true
fi
chown -R "${LEAST_PRIVILEGE_USER}:${LEAST_PRIVILEGE_USER}" "$STATE_DIR" 2>/dev/null || true
if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then
chown root:root "${INSTALL_DIR}/${BINARY_NAME}" 2>/dev/null || true
chmod 0755 "${INSTALL_DIR}/${BINARY_NAME}"
else
chown "${LEAST_PRIVILEGE_USER}:${LEAST_PRIVILEGE_USER}" "${INSTALL_DIR}/${BINARY_NAME}" 2>/dev/null || true
fi
}
# Keep the installer token in a root-owned directory outside the service
# account's write authority while leaving runtime state mutable by the
# collector. The enrolled runtime token remains collector-owned because the
# agent rotates it; it is monitoring-only and carries no execution scope.
protect_typed_profile_credentials() {
local legacy_token="${STATE_DIR}/token"
if [[ -L "$PRIVILEGED_HELPER_CREDENTIAL_DIR" || ! -d "$PRIVILEGED_HELPER_CREDENTIAL_DIR" ]]; then
fail "Refusing unsafe typed-profile credential directory: ${PRIVILEGED_HELPER_CREDENTIAL_DIR}" "$EXIT_GENERAL"
fi
chown "root:${LEAST_PRIVILEGE_USER}" "$PRIVILEGED_HELPER_CREDENTIAL_DIR" ||
fail "Failed to protect typed-profile credential directory ownership" "$EXIT_GENERAL"
chmod 0750 "$PRIVILEGED_HELPER_CREDENTIAL_DIR" ||
fail "Failed to protect typed-profile credential directory mode" "$EXIT_GENERAL"
if [[ -L "$RUNTIME_TOKEN_FILE" || ! -f "$RUNTIME_TOKEN_FILE" ]]; then
fail "Refusing unsafe typed-profile credential path: ${RUNTIME_TOKEN_FILE}" "$EXIT_GENERAL"
fi
chown "root:${LEAST_PRIVILEGE_USER}" "$RUNTIME_TOKEN_FILE" ||
fail "Failed to protect typed-profile credential ownership: ${RUNTIME_TOKEN_FILE}" "$EXIT_GENERAL"
chmod 0640 "$RUNTIME_TOKEN_FILE" ||
fail "Failed to protect typed-profile credential mode: ${RUNTIME_TOKEN_FILE}" "$EXIT_GENERAL"
if [[ "$legacy_token" != "$RUNTIME_TOKEN_FILE" ]]; then
rm -f "$legacy_token"
fi
}
# Write one privilege helper: a root-owned wrapper that execs the real binary
@ -3701,6 +3849,35 @@ provision_privilege_helpers() {
log_info "Installed scoped sudoers grants at ${PRIVILEGE_SUDOERS_FILE}"
}
verify_privileged_helper_socket() {
local helper_gid socket_owner socket_mode
helper_gid=$(id -g "$LEAST_PRIVILEGE_USER")
if [[ ! -S "$PRIVILEGED_HELPER_SOCKET_PATH" ]]; then
fail "Typed privileged helper socket was not created at ${PRIVILEGED_HELPER_SOCKET_PATH}" "$EXIT_GENERAL"
fi
socket_owner=$(stat -c '%u:%g' "$PRIVILEGED_HELPER_SOCKET_PATH" 2>/dev/null || true)
socket_mode=$(stat -c '%a' "$PRIVILEGED_HELPER_SOCKET_PATH" 2>/dev/null || true)
if [[ "$socket_owner" != "0:${helper_gid}" || "$socket_mode" != "660" ]]; then
fail "Typed privileged helper socket has unsafe ownership or mode (${socket_owner:-unknown} ${socket_mode:-unknown}); expected root:${LEAST_PRIVILEGE_USER} 0660" "$EXIT_GENERAL"
fi
}
provision_typed_privileged_helper() {
render_privileged_helper_socket_unit "$PRIVILEGED_HELPER_SOCKET_UNIT"
render_privileged_helper_service_unit "$PRIVILEGED_HELPER_SERVICE_UNIT" "$PRIVILEGED_HELPER_BINARY_PATH"
chown root:root "$PRIVILEGED_HELPER_SOCKET_UNIT" "$PRIVILEGED_HELPER_SERVICE_UNIT"
chmod 0644 "$PRIVILEGED_HELPER_SOCKET_UNIT" "$PRIVILEGED_HELPER_SERVICE_UNIT"
append_service_env "PULSE_AGENT_HELPER_SOCKET" "$PRIVILEGED_HELPER_SOCKET_PATH"
systemctl daemon-reload
if ! systemctl enable --now "${PRIVILEGED_HELPER_NAME}.socket"; then
fail "Failed to enable the typed privileged helper socket" "$EXIT_GENERAL"
fi
verify_privileged_helper_socket
log_info "Typed privileged helper socket active at ${PRIVILEGED_HELPER_SOCKET_PATH} (root:${LEAST_PRIVILEGE_USER} 0660)"
}
if [[ "$(uname -s)" == "Linux" ]] && is_truenas; then
TRUENAS=true
INSTALL_DIR="$TRUENAS_STATE_DIR"
@ -3807,6 +3984,23 @@ if [[ "$PREFLIGHT_ONLY" == "true" ]]; then
json_event "preflight" "agent_download_unavailable" "Agent binary unavailable for ${PF_ARCH_PARAM}" "$EXIT_DOWNLOAD_FAILED"
PREFLIGHT_EXIT="$EXIT_DOWNLOAD_FAILED"
fi
if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then
: > "$PREFLIGHT_HEADERS"
HELPER_DOWNLOAD_CHECK_URL="${PULSE_URL}/download/${PRIVILEGED_HELPER_BINARY_NAME}?arch=${PF_ARCH_PARAM}"
if curl "${CURL_DOWNLOAD_CHECK_ARGS[@]}" "$HELPER_DOWNLOAD_CHECK_URL"; then
PREFLIGHT_HELPER_SHA=$(final_response_header_value "$PREFLIGHT_HEADERS" "X-Checksum-Sha256" || true)
if [[ -n "$PREFLIGHT_HELPER_SHA" ]]; then
json_event "preflight" "helper_download_available" "Typed helper binary available for ${PF_ARCH_PARAM}"
else
json_event "preflight" "helper_download_checksum_missing" "Typed helper download did not include checksum header" "$EXIT_CHECKSUM_FAILED"
PREFLIGHT_EXIT="$EXIT_CHECKSUM_FAILED"
fi
else
json_event "preflight" "helper_download_unavailable" "Typed helper binary unavailable for ${PF_ARCH_PARAM}" "$EXIT_DOWNLOAD_FAILED"
PREFLIGHT_EXIT="$EXIT_DOWNLOAD_FAILED"
fi
fi
fi
# Output summary
@ -3903,6 +4097,53 @@ read_magic_hex() {
return 1
fi
}
download_verified_privileged_helper() {
local helper_headers helper_url helper_magic helper_expected_sha helper_signature helper_actual_sha
local -a helper_curl_args
TMP_HELPER_BIN=$(mktemp)
helper_headers=$(mktemp)
TMP_FILES+=("$TMP_HELPER_BIN" "$helper_headers")
helper_curl_args=(-fsSL --connect-timeout 30 --max-time 300 -D "$helper_headers" -o "$TMP_HELPER_BIN")
if [[ "$INSECURE" == "true" ]]; then helper_curl_args+=(-k); fi
if [[ -n "$CURL_CA_BUNDLE" ]]; then helper_curl_args+=(--cacert "$CURL_CA_BUNDLE"); fi
helper_url="${PULSE_URL}/download/${PRIVILEGED_HELPER_BINARY_NAME}?${DOWNLOAD_QUERY}"
log_info "Downloading typed privileged helper from ${helper_url}..."
if ! curl "${helper_curl_args[@]}" "$helper_url"; then
fail "Typed privileged helper download failed; the safe profile was not installed." "$EXIT_DOWNLOAD_FAILED"
fi
if [[ ! -s "$TMP_HELPER_BIN" ]]; then
fail "Downloaded typed privileged helper is empty; the safe profile was not installed." "$EXIT_DOWNLOAD_FAILED"
fi
if helper_magic=$(read_magic_hex "$TMP_HELPER_BIN"); then
if [[ "$helper_magic" != "7f454c46" ]]; then
fail "Downloaded typed privileged helper is not a Linux ELF executable." "$EXIT_DOWNLOAD_FAILED"
fi
else
log_warn "No od/hexdump/xxd available to inspect the typed helper; relying on checksum verification."
fi
helper_expected_sha=$(final_response_header_value "$helper_headers" "X-Checksum-Sha256" || true)
helper_signature=$(final_response_header_value "$helper_headers" "X-Signature-SSHSIG" || true)
if [[ -z "$helper_expected_sha" ]]; then
fail "Typed privileged helper download omitted its checksum; refusing install." "$EXIT_CHECKSUM_FAILED"
fi
if has_pinned_installer_signature_key && [[ -z "$helper_signature" ]]; then
fail "Typed privileged helper download omitted its signature; refusing install." "$EXIT_SIGNATURE_FAILED"
fi
helper_actual_sha=$(sha256sum "$TMP_HELPER_BIN" 2>/dev/null | awk '{print $1}' || shasum -a 256 "$TMP_HELPER_BIN" 2>/dev/null | awk '{print $1}')
if [[ -z "$helper_actual_sha" ]]; then
fail "Could not compute typed privileged helper checksum." "$EXIT_CHECKSUM_FAILED"
fi
if [[ "$helper_actual_sha" != "$helper_expected_sha" ]]; then
fail "Typed privileged helper checksum verification failed." "$EXIT_CHECKSUM_FAILED"
fi
verify_download_signature "$TMP_HELPER_BIN" "$helper_signature"
chmod 0755 "$TMP_HELPER_BIN"
log_info "Typed privileged helper binary verified"
}
if [[ "$OS" == "linux" || "$OS" == "freebsd" ]]; then
if MAGIC=$(read_magic_hex "$TMP_BIN"); then
if [[ "$MAGIC" != "7f454c46" ]]; then
@ -3945,6 +4186,10 @@ log_info "Binary checksum verified"
verify_download_signature "$TMP_BIN" "$SSH_SIGNATURE_HEADER"
if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then
download_verified_privileged_helper
fi
chmod +x "$TMP_BIN"
NEW_VERSION=$("$TMP_BIN" --version 2>/dev/null | head -1 || echo "unknown")
@ -4011,6 +4256,16 @@ mkdir -p "$INSTALL_DIR"
mv "$TMP_BIN" "${INSTALL_DIR}/${BINARY_NAME}"
chmod +x "${INSTALL_DIR}/${BINARY_NAME}"
if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then
mkdir -p "$PRIVILEGE_HELPER_DIR"
chown root:root "$PRIVILEGE_HELPER_DIR"
chmod 0755 "$PRIVILEGE_HELPER_DIR"
mv "$TMP_HELPER_BIN" "$PRIVILEGED_HELPER_BINARY_PATH"
TMP_HELPER_BIN=""
chown root:root "$PRIVILEGED_HELPER_BINARY_PATH"
chmod 0755 "$PRIVILEGED_HELPER_BINARY_PATH"
fi
if [[ "$UPGRADE_MODE" == "true" ]]; then
log_info "Binary upgraded successfully. Updating service configuration..."
fi
@ -4643,15 +4898,30 @@ if command -v systemctl >/dev/null 2>&1; then
fi
fi
if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then
ensure_runtime_token_file "$PRIVILEGED_HELPER_CREDENTIAL_DIR"
else
ensure_runtime_token_file "$STATE_DIR"
fi
clear_proxmox_state_if_needed
if [[ "$PRIVILEGED_HELPER_EXPLICIT" == "true" && "$PRIVILEGED_HELPER_ENABLED" != "true" ]]; then
teardown_privileged_helper_service
rm -f "$PRIVILEGED_HELPER_BINARY_PATH"
fi
if [[ "$LEAST_PRIVILEGE" == "true" ]]; then
SERVICE_USER="$LEAST_PRIVILEGE_USER"
provision_least_privilege_user
if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then
protect_typed_profile_credentials
provision_typed_privileged_helper
log_info "Typed-helper collector profile: service runs as ${SERVICE_USER}; binaries and credential files remain root-owned while mutable state remains ${SERVICE_USER}-owned."
else
provision_privilege_helpers
log_info "Least-privilege profile: service runs as ${SERVICE_USER}. SMART $( [[ "$GRANT_SMART" == "true" ]] && echo "via scoped sudo helper" || echo "unavailable without --grant-smart" ); Proxmox LXC filesystems $( [[ "$GRANT_PCT" == "true" ]] && echo "via scoped sudo helper" || echo "unavailable without --grant-pct" )."
fi
fi
# Build command line args with --token-file instead of the raw token.
build_exec_args
@ -4666,7 +4936,11 @@ if command -v systemctl >/dev/null 2>&1; then
restart_systemd_agent_service
complete_installation_flow "$STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "journalctl -u ${AGENT_NAME} --no-pager -n 20"
if [[ "$UPGRADE_MODE" != "true" && -n "$PULSE_TOKEN" ]]; then
log_info "Token file: $TOKEN_FILE (mode 600, root only)"
if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then
log_info "Token file: ${RUNTIME_TOKEN_FILE} (mode 640, root:${LEAST_PRIVILEGE_USER})"
else
log_info "Token file: ${RUNTIME_TOKEN_FILE} (mode 600, root only)"
fi
fi
exit 0
fi

View file

@ -161,7 +161,7 @@ func TestProPackagingBuildsFrontendEmbedWithoutTransferringBundle(t *testing.T)
`finish_frontend`,
`wait -n -p completed_pid "${active_pids[@]}"`,
`completed release compilation child is not in the active task set`,
`transfer public Unified Agent binaries only`,
`transfer public agent-side binaries only`,
} {
if !strings.Contains(script, needle) {
t.Fatalf("build-release-binaries.sh missing Pro frontend embed contract: %s", needle)
@ -184,6 +184,133 @@ func TestProPackagingBuildsFrontendEmbedWithoutTransferringBundle(t *testing.T)
}
}
func TestBuildReleasePackagesPulseAgentHelperForLinux(t *testing.T) {
targetScriptPath := repoFile("scripts", "release_build_targets.sh")
targetCmd := exec.Command("bash", "-c", `
source "$1"
for target in "${PULSE_RELEASE_AGENT_TARGETS[@]}"; do
if [[ "${target}" == linux-* ]]; then
printf 'agent:%s\n' "${target}"
fi
done
for target in "${PULSE_RELEASE_AGENT_HELPER_TARGETS[@]}"; do
printf 'helper:%s:%s\n' "${target}" "$(pulse_release_binary_filename agent-helper "${target}")"
done
`, "pulse-agent-helper-target-test", targetScriptPath)
targetOutput, err := targetCmd.CombinedOutput()
if err != nil {
t.Fatalf("inspect release helper target matrix: %v\n%s", err, targetOutput)
}
var linuxAgentTargets []string
var helperTargets []string
for _, line := range strings.Split(strings.TrimSpace(string(targetOutput)), "\n") {
switch {
case strings.HasPrefix(line, "agent:"):
linuxAgentTargets = append(linuxAgentTargets, strings.TrimPrefix(line, "agent:"))
case strings.HasPrefix(line, "helper:"):
parts := strings.Split(line, ":")
if len(parts) != 3 {
t.Fatalf("unexpected helper target output %q", line)
}
helperTargets = append(helperTargets, parts[1])
wantFilename := "pulse-agent-helper-" + parts[1]
if parts[2] != wantFilename {
t.Fatalf("helper 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)
}
buildBytes, err := os.ReadFile(repoFile("scripts", "build-release.sh"))
if err != nil {
t.Fatalf("read build-release.sh: %v", err)
}
compileBytes, err := os.ReadFile(repoFile("scripts", "build-release-binaries.sh"))
if err != nil {
t.Fatalf("read build-release-binaries.sh: %v", err)
}
commonBytes, err := os.ReadFile(repoFile("scripts", "release_asset_common.sh"))
if err != nil {
t.Fatalf("read release_asset_common.sh: %v", err)
}
workflowBytes, err := os.ReadFile(repoFile(".github", "workflows", "create-release.yml"))
if err != nil {
t.Fatalf("read create-release.yml: %v", err)
}
buildScript := string(buildBytes)
compileScript := string(compileBytes)
commonScript := string(commonBytes)
workflow := string(workflowBytes)
for _, needle := range []string{
`agent_helper_build_order=("${PULSE_RELEASE_AGENT_HELPER_TARGETS[@]}")`,
`output_path="${BUILD_DIR}/$(pulse_release_binary_filename agent-helper "${target}")"`,
`./cmd/pulse-agent-helper`,
`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/"`,
} {
if !strings.Contains(buildScript, needle) {
t.Fatalf("build-release.sh missing agent helper release wiring: %s", needle)
}
}
for _, needle := range []string{
`for target in "${PULSE_RELEASE_AGENT_HELPER_TARGETS[@]}"; do`,
`task_components+=(agent-helper)`,
`package=./cmd/pulse-agent-helper`,
} {
if !strings.Contains(compileScript, needle) {
t.Fatalf("build-release-binaries.sh missing agent helper 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}"`,
`dest="${staging_dir}/bin/pulse-agent-helper-${target}"`,
`if compgen -G "pulse-agent-helper-linux-*" > /dev/null; then`,
} {
if !strings.Contains(commonScript, needle) {
t.Fatalf("release_asset_common.sh missing agent helper 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 {
t.Fatal("server archives must stage helper binaries before signing their bin payload")
}
releaseDir := t.TempDir()
helperAsset := "pulse-agent-helper-linux-amd64"
if err := os.WriteFile(filepath.Join(releaseDir, helperAsset), []byte("helper"), 0o755); err != nil {
t.Fatalf("write helper checksum fixture: %v", err)
}
checksumCmd := exec.Command("bash", "-c", `source "$1"; pulse_release_collect_checksum_files "$2"`, "pulse-agent-helper-checksum-test", repoFile("scripts", "release_asset_common.sh"), releaseDir)
checksumOutput, err := checksumCmd.CombinedOutput()
if err != nil {
t.Fatalf("collect helper release checksum files: %v\n%s", err, checksumOutput)
}
if !strings.Contains(string(checksumOutput), helperAsset) {
t.Fatalf("helper release asset missing from checksum/signature input:\n%s", checksumOutput)
}
for _, target := range helperTargets {
asset := "release/pulse-agent-helper-" + target
if !strings.Contains(workflow, asset) {
t.Fatalf("create-release.yml missing bare helper upload: %s", asset)
}
}
for _, signatureGlob := range []string{
`release_upload_with_retry "${TAG}" release/*.sig --clobber`,
`release_upload_with_retry "${TAG}" release/*.sshsig --clobber`,
} {
if !strings.Contains(workflow, signatureGlob) {
t.Fatalf("create-release.yml missing helper-compatible signature upload: %s", signatureGlob)
}
}
}
func TestReleaseContainerTargetsConsumeImmutableCandidate(t *testing.T) {
dockerfileBytes, err := os.ReadFile(repoFile("Dockerfile"))
if err != nil {
@ -273,6 +400,12 @@ func TestReleaseContainerContextTreatsServerSignaturesAsArchitectureBound(t *tes
"scripts/install.sh.sshsig": "shared-installer-sshsig",
"VERSION": version,
}
for _, helperTarget := range []string{"linux-amd64", "linux-arm64", "linux-armv7", "linux-armv6", "linux-386"} {
name := "bin/pulse-agent-helper-" + helperTarget
files[name] = "shared-helper-" + helperTarget
files[name+".sig"] = "shared-helper-signature-" + helperTarget
files[name+".sshsig"] = "shared-helper-ssh-signature-" + helperTarget
}
if driftUniversalPayload {
files["scripts/install.sh"] = "drifted-agent-installer"
}
@ -597,6 +730,11 @@ func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) {
`release/pulse-agent-linux-armv7`,
`release/pulse-agent-linux-armv6`,
`release/pulse-agent-linux-386`,
`release/pulse-agent-helper-linux-amd64`,
`release/pulse-agent-helper-linux-arm64`,
`release/pulse-agent-helper-linux-armv7`,
`release/pulse-agent-helper-linux-armv6`,
`release/pulse-agent-helper-linux-386`,
`release/pulse-agent-freebsd-amd64`,
`release/pulse-agent-freebsd-arm64`,
`release/pulse-agent-windows-amd64.exe`,

View file

@ -5630,3 +5630,151 @@ func TestInstallSHLeastPrivilegeProfile(t *testing.T) {
t.Fatal("install.sh must document that the pct grant excludes pct exec")
}
}
func TestInstallSHTypedPrivilegedHelperUnits(t *testing.T) {
root := t.TempDir()
socketUnit := filepath.Join(root, "pulse-agent-helper.socket")
serviceUnit := filepath.Join(root, "pulse-agent-helper.service")
script := `
set -euo pipefail
PRIVILEGED_HELPER_NAME="pulse-agent-helper"
PRIVILEGED_HELPER_SOCKET_PATH="/run/pulse-agent/helper.sock"
LEAST_PRIVILEGE_USER="pulse-agent"
` + extractInstallShellFunction(t, "render_privileged_helper_socket_unit") + `
` + extractInstallShellFunction(t, "render_privileged_helper_service_unit") + `
render_privileged_helper_socket_unit "` + socketUnit + `"
render_privileged_helper_service_unit "` + serviceUnit + `" "/usr/local/lib/pulse-agent/pulse-agent-helper"
`
if out, err := exec.Command("bash", "-c", script).CombinedOutput(); err != nil {
t.Fatalf("render helper units: %v\n%s", err, out)
}
socketBytes, err := os.ReadFile(socketUnit)
if err != nil {
t.Fatal(err)
}
socket := string(socketBytes)
for _, required := range []string{
"ListenStream=/run/pulse-agent/helper.sock",
"SocketUser=root",
"SocketGroup=pulse-agent",
"SocketMode=0660",
"DirectoryMode=0755",
"RemoveOnStop=true",
} {
if !strings.Contains(socket, required) {
t.Fatalf("typed helper socket unit missing %q:\n%s", required, socket)
}
}
serviceBytes, err := os.ReadFile(serviceUnit)
if err != nil {
t.Fatal(err)
}
service := string(serviceBytes)
for _, required := range []string{
"ExecStart=/usr/local/lib/pulse-agent/pulse-agent-helper",
"User=root",
"Group=root",
"NoNewPrivileges=true",
"PrivateNetwork=true",
"RestrictAddressFamilies=AF_UNIX",
"ProtectSystem=strict",
"ProtectHome=true",
} {
if !strings.Contains(service, required) {
t.Fatalf("typed helper service unit missing %q:\n%s", required, service)
}
}
if strings.Contains(service, "PrivateDevices") {
t.Fatalf("typed helper service must retain host device visibility for SMART:\n%s", service)
}
}
func TestInstallSHTypedPrivilegedHelperProfileIsOptInAndFailClosed(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_ENABLED="false"`,
`--enable-privileged-helper) PRIVILEGED_HELPER_ENABLED="true"; PRIVILEGED_HELPER_EXPLICIT="true"; shift ;;`,
`--disable-privileged-helper) PRIVILEGED_HELPER_ENABLED="false"; PRIVILEGED_HELPER_EXPLICIT="true"; shift ;;`,
`--enable-privileged-helper requires --least-privilege`,
`--enable-privileged-helper cannot be combined with legacy --grant-smart/--grant-pct sudo paths`,
`--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"`,
`protect_typed_profile_credentials`,
`PRIVILEGED_HELPER_CREDENTIAL_DIR="/etc/pulse-agent"`,
`chown "root:${LEAST_PRIVILEGE_USER}" "$PRIVILEGED_HELPER_CREDENTIAL_DIR"`,
`chmod 0750 "$PRIVILEGED_HELPER_CREDENTIAL_DIR"`,
`chown "root:${LEAST_PRIVILEGE_USER}" "$RUNTIME_TOKEN_FILE"`,
`chmod 0640 "$RUNTIME_TOKEN_FILE"`,
`if [[ "$PRIVILEGED_HELPER_ENABLED" != "true" && "$ENABLE_DOCKER" != "false" ]]`,
`/download/${PRIVILEGED_HELPER_BINARY_NAME}?${DOWNLOAD_QUERY}`,
`verify_download_signature "$TMP_HELPER_BIN" "$helper_signature"`,
`teardown_privileged_helper_service`,
`socket_owner=$(stat -c '%u:%g' "$PRIVILEGED_HELPER_SOCKET_PATH"`,
`socket_mode=$(stat -c '%a' "$PRIVILEGED_HELPER_SOCKET_PATH"`,
} {
if !strings.Contains(script, required) {
t.Fatalf("install.sh missing typed-helper invariant: %s", required)
}
}
}
func TestInstallSHTypedPrivilegedHelperProtectsCredentialsAfterStateChown(t *testing.T) {
stateDir := t.TempDir()
credentialDir := t.TempDir()
legacyToken := filepath.Join(stateDir, "token")
protectedToken := filepath.Join(credentialDir, "token")
for _, path := range []string{legacyToken, protectedToken} {
if err := os.WriteFile(path, []byte("secret"), 0600); err != nil {
t.Fatal(err)
}
}
callLog := filepath.Join(t.TempDir(), "calls")
script := `
set -euo pipefail
STATE_DIR="` + stateDir + `"
PRIVILEGED_HELPER_CREDENTIAL_DIR="` + credentialDir + `"
RUNTIME_TOKEN_FILE="` + protectedToken + `"
LEAST_PRIVILEGE_USER="pulse-agent"
EXIT_GENERAL=1
CALL_LOG="` + callLog + `"
fail() { printf 'fail:%s\n' "$1" >> "$CALL_LOG"; return "$2"; }
chown() { printf 'chown:%s:%s\n' "$1" "$2" >> "$CALL_LOG"; }
chmod() { printf 'chmod:%s:%s\n' "$1" "$2" >> "$CALL_LOG"; }
rm() { printf 'rm:%s\n' "$2" >> "$CALL_LOG"; }
` + extractInstallShellFunction(t, "protect_typed_profile_credentials") + `
protect_typed_profile_credentials
`
if out, err := exec.Command("bash", "-c", script).CombinedOutput(); err != nil {
t.Fatalf("protect typed-profile credentials: %v\n%s", err, out)
}
logBytes, err := os.ReadFile(callLog)
if err != nil {
t.Fatal(err)
}
log := string(logBytes)
for _, want := range []string{
"chown:root:pulse-agent:" + credentialDir,
"chmod:0750:" + credentialDir,
"chown:root:pulse-agent:" + protectedToken,
"chmod:0640:" + protectedToken,
"rm:" + legacyToken,
} {
if !strings.Contains(log, want) {
t.Fatalf("credential protection log missing %q:\n%s", want, log)
}
}
}

View file

@ -117,6 +117,15 @@ for arch in amd64 arm64; do
exit 1
fi
done
for helper_target in linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-386; do
for suffix in "" .sig .sshsig; do
required="bin/pulse-agent-helper-${helper_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 \

View file

@ -139,6 +139,10 @@ pulse_release_stage_server_archive() {
echo "Error: PULSE_RELEASE_AGENT_TARGETS is empty." >&2
return 1
fi
if [[ ${#PULSE_RELEASE_AGENT_HELPER_TARGETS[@]} -eq 0 ]]; then
echo "Error: PULSE_RELEASE_AGENT_HELPER_TARGETS is empty." >&2
return 1
fi
rm -rf "${staging_dir}"
mkdir -p "${staging_dir}/bin" "${staging_dir}/scripts"
@ -153,6 +157,11 @@ pulse_release_stage_server_archive() {
fi
install -m 0755 "${src}" "${dest}"
done
for target in "${PULSE_RELEASE_AGENT_HELPER_TARGETS[@]}"; do
src="${agent_binary_dir}/pulse-agent-helper-${target}"
dest="${staging_dir}/bin/pulse-agent-helper-${target}"
install -m 0755 "${src}" "${dest}"
done
(
cd "${staging_dir}/bin"
ln -sf pulse-agent-windows-amd64.exe pulse-agent-windows-amd64
@ -233,6 +242,9 @@ pulse_release_collect_checksum_files() {
if compgen -G "pulse-agent-linux-*" > /dev/null; then
checksum_files+=( pulse-agent-linux-* )
fi
if compgen -G "pulse-agent-helper-linux-*" > /dev/null; then
checksum_files+=( pulse-agent-helper-linux-* )
fi
if compgen -G "pulse-agent-freebsd-*" > /dev/null; then
checksum_files+=( pulse-agent-freebsd-* )
fi

View file

@ -18,6 +18,14 @@ PULSE_RELEASE_AGENT_TARGETS=(
windows-386
)
PULSE_RELEASE_AGENT_HELPER_TARGETS=(
linux-amd64
linux-arm64
linux-armv7
linux-armv6
linux-386
)
PULSE_RELEASE_SERVER_TARGETS=(
linux-amd64
linux-arm64
@ -59,6 +67,7 @@ pulse_release_binary_filename() {
case "${component}" in
agent) filename="pulse-agent-${target}" ;;
agent-helper) filename="pulse-agent-helper-${target}" ;;
mcp) filename="pulse-mcp-${target}" ;;
server) filename="pulse-${target}" ;;
control-plane) filename="pulse-control-plane-${target}" ;;

View file

@ -745,6 +745,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
"internal/hostagent/issue1595_sas_collection_test.go",
"internal/hostagent/observer_delivery_test.go",
"internal/hostagent/package_updates_test.go",
"internal/hostagent/privilege_helper_client_test.go",
"internal/hostagent/send_report_test.go",
"internal/hostagent/smartctl_standby_guard_test.go",
"internal/hostagent/storage_cleanup_test.go",

View file

@ -4631,6 +4631,7 @@ class SubsystemLookupTest(unittest.TestCase):
"internal/hostagent/issue1595_sas_collection_test.go",
"internal/hostagent/observer_delivery_test.go",
"internal/hostagent/package_updates_test.go",
"internal/hostagent/privilege_helper_client_test.go",
"internal/hostagent/send_report_test.go",
"internal/hostagent/smartctl_standby_guard_test.go",
"internal/hostagent/storage_cleanup_test.go",

View file

@ -254,7 +254,7 @@ if [ "$SKIP_DOCKER" = false ]; then
# Validate all required binaries exist and are non-empty
info "Checking downloadable binaries in /opt/pulse/bin/..."
docker run --rm --entrypoint /bin/sh "$IMAGE" -c 'set -euo pipefail; cd /opt/pulse/bin; required="pulse pulse-agent-linux-amd64 pulse-agent-linux-arm64 pulse-agent-linux-armv7 pulse-agent-linux-armv6 pulse-agent-linux-386 pulse-agent-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-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
@ -536,9 +536,17 @@ unified_agent_entries=(
./bin/pulse-agent-freebsd-amd64
./bin/pulse-agent-freebsd-arm64
)
privileged_helper_entries=(
./bin/pulse-agent-helper-linux-amd64
./bin/pulse-agent-helper-linux-arm64
./bin/pulse-agent-helper-linux-armv7
./bin/pulse-agent-helper-linux-armv6
./bin/pulse-agent-helper-linux-386
)
platform_tar_entries=(
./bin/pulse
"${unified_agent_entries[@]}"
"${privileged_helper_entries[@]}"
./scripts/install-container-agent.sh
./scripts/install-docker.sh
./scripts/install.sh
@ -553,7 +561,8 @@ validate_universal_tarball() {
check_tar_entries_nonempty \
"pulse-v${PULSE_VERSION}.tar.gz" \
./VERSION \
"${unified_agent_entries[@]}"
"${unified_agent_entries[@]}" \
"${privileged_helper_entries[@]}"
}
# Each archive previously underwent up to four complete gzip scans in series.