mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Add typed agent privilege helper
This commit is contained in:
parent
3099085475
commit
6d4ee48000
44 changed files with 3267 additions and 22 deletions
5
.github/workflows/create-release.yml
vendored
5
.github/workflows/create-release.yml
vendored
|
|
@ -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 \
|
||||
|
|
|
|||
226
cmd/pulse-agent-helper/main.go
Normal file
226
cmd/pulse-agent-helper/main.go
Normal 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
|
||||
}
|
||||
135
cmd/pulse-agent-helper/main_test.go
Normal file
135
cmd/pulse-agent-helper/main_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
46
cmd/pulse-agent-helper/providers.go
Normal file
46
cmd/pulse-agent-helper/providers.go
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
210
internal/agenthelper/client.go
Normal file
210
internal/agenthelper/client.go
Normal 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
|
||||
}
|
||||
183
internal/agenthelper/client_test.go
Normal file
183
internal/agenthelper/client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
40
internal/agenthelper/peercred_linux.go
Normal file
40
internal/agenthelper/peercred_linux.go
Normal 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
|
||||
}
|
||||
56
internal/agenthelper/peercred_linux_test.go
Normal file
56
internal/agenthelper/peercred_linux_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
14
internal/agenthelper/peercred_other.go
Normal file
14
internal/agenthelper/peercred_other.go
Normal 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")
|
||||
}
|
||||
166
internal/agenthelper/protocol.go
Normal file
166
internal/agenthelper/protocol.go
Normal 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)
|
||||
}
|
||||
73
internal/agenthelper/protocol_test.go
Normal file
73
internal/agenthelper/protocol_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
59
internal/agenthelper/providers.go
Normal file
59
internal/agenthelper/providers.go
Normal 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
|
||||
}
|
||||
}
|
||||
151
internal/agenthelper/registry.go
Normal file
151
internal/agenthelper/registry.go
Normal 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}
|
||||
}
|
||||
276
internal/agenthelper/server.go
Normal file
276
internal/agenthelper/server.go
Normal 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)
|
||||
}
|
||||
308
internal/agenthelper/server_test.go
Normal file
308
internal/agenthelper/server_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
80
internal/hostagent/privilege_helper_client.go
Normal file
80
internal/hostagent/privilege_helper_client.go
Normal 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
|
||||
}
|
||||
89
internal/hostagent/privilege_helper_client_test.go
Normal file
89
internal/hostagent/privilege_helper_client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=""
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}" ;;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue