mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 21:47:20 +00:00
338 lines
11 KiB
Go
338 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"debug/buildinfo"
|
|
"encoding/hex"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"math"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"os/user"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/agenthelper"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/updatesignature"
|
|
)
|
|
|
|
const defaultSocketPath = "/run/pulse-agent/helper.sock"
|
|
|
|
const (
|
|
updateQuarantineDir = "/var/lib/pulse-agent/update-quarantine"
|
|
updateStagingDir = "/var/lib/pulse-agent-helper/update-staging"
|
|
agentBinaryPath = "/usr/local/bin/pulse-agent"
|
|
updateStatePath = "/var/lib/pulse-agent-helper/update-activation.json"
|
|
)
|
|
|
|
type commandConfig struct {
|
|
socketPath string
|
|
allowedUID int64
|
|
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()
|
|
|
|
containers, err := agenthelper.NewLocalContainerProvider([]agenthelper.ContainerEndpoint{
|
|
{Runtime: "docker", SocketPath: "/var/run/docker.sock", APIPath: "/v1.41/containers/json?all=1"},
|
|
// Podman's documented Docker-compatibility API preserves the bounded
|
|
// Docker list-summary schema decoded by LocalContainerProvider.
|
|
{Runtime: "podman", SocketPath: "/run/podman/podman.sock", APIPath: "/v1.40/containers/json?all=1"},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("configure container inventory: %w", err)
|
|
}
|
|
updates, err := agenthelper.NewUpdateActivator(agenthelper.UpdateActivatorConfig{
|
|
QuarantineDir: updateQuarantineDir,
|
|
StagingDir: updateStagingDir,
|
|
TargetPath: agentBinaryPath,
|
|
StatePath: updateStatePath,
|
|
VerifySignature: func(data []byte, signature string) error {
|
|
if !updatesignature.HasTrustedPublicKeys() {
|
|
return errors.New("helper build contains no trusted update keys")
|
|
}
|
|
return updatesignature.VerifyBytes(data, signature)
|
|
},
|
|
InspectVersion: inspectPulseAgentVersion,
|
|
ValidateCommitter: validatePulseAgentCommitter,
|
|
ValidateOwner: agenthelper.StrictRootOwnedFile,
|
|
ValidateQuarantineOwner: agenthelper.FileOwnedByUID(uid),
|
|
ReportRecoveryFailure: func(event agenthelper.UpdateRecoveryFailure) {
|
|
log.Printf(
|
|
"update_recovery=retry activation_id=%q retry_in_ms=%d error=%q",
|
|
event.ActivationID,
|
|
event.RetryIn.Milliseconds(),
|
|
event.Error,
|
|
)
|
|
},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("configure update activation: %w", err)
|
|
}
|
|
registry := agenthelper.NewRegistryWithProviders(localSMARTProvider{}, localProxmoxProvider{}, agenthelper.Providers{
|
|
Containers: containers,
|
|
Updates: updates,
|
|
})
|
|
server, err := agenthelper.NewServer(agenthelper.ServerConfig{
|
|
AllowedUID: uid,
|
|
PeerResolver: agenthelper.PlatformPeerResolver{},
|
|
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 inspectPulseAgentVersion(parent context.Context, path string) (string, error) {
|
|
info, err := buildinfo.ReadFile(path)
|
|
if err != nil || info.Path != "github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent" {
|
|
return "", errors.New("staged command is not the pulse-agent package")
|
|
}
|
|
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
|
|
defer cancel()
|
|
command := exec.CommandContext(ctx, path, "--version")
|
|
command.Env = []string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin"}
|
|
stdout, err := command.StdoutPipe()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := command.Start(); err != nil {
|
|
return "", err
|
|
}
|
|
output, readErr := io.ReadAll(io.LimitReader(stdout, 4097))
|
|
if len(output) > 4096 {
|
|
_ = command.Process.Kill()
|
|
}
|
|
waitErr := command.Wait()
|
|
if readErr != nil {
|
|
return "", readErr
|
|
}
|
|
if waitErr != nil {
|
|
return "", waitErr
|
|
}
|
|
if len(output) > 4096 {
|
|
return "", errors.New("pulse-agent version output exceeds the bounded size")
|
|
}
|
|
version := strings.TrimSpace(string(output))
|
|
if version == "" || len(version) > 128 || strings.ContainsAny(version, "\x00\r\n") {
|
|
return "", errors.New("pulse-agent version output is invalid")
|
|
}
|
|
return version, nil
|
|
}
|
|
|
|
func validatePulseAgentCommitter(ctx context.Context, activeDigest string) error {
|
|
peer, ok := agenthelper.PeerFromContext(ctx)
|
|
if !ok || peer.PID <= 1 {
|
|
return errors.New("helper peer process identity is unavailable")
|
|
}
|
|
file, err := os.Open(fmt.Sprintf("/proc/%d/exe", peer.PID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
data, err := io.ReadAll(io.LimitReader(file, 100*1024*1024+1))
|
|
if err != nil || len(data) > 100*1024*1024 {
|
|
return errors.New("helper peer executable exceeds the bounded agent size")
|
|
}
|
|
sum := sha256.Sum256(data)
|
|
if !strings.EqualFold(hex.EncodeToString(sum[:]), activeDigest) {
|
|
return errors.New("helper peer is not executing the pending agent binary")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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 || uidValue > math.MaxUint32 || gidValue < 0 || gidValue > math.MaxInt {
|
|
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
|
|
}
|