Separate agent remediation runtime

This commit is contained in:
Pulse Test
2026-08-29 23:47:00 +01:00
parent 1720fd635b
commit d607d5cf46
80 changed files with 6418 additions and 82 deletions
+176
View File
@@ -0,0 +1,176 @@
package actionrunner
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/operationreceipt"
)
type Handler interface {
ValidatePayload(json.RawMessage) error
Execute(context.Context, Target, json.RawMessage) (Result, error)
}
type Config struct {
ReceiptPath string
Handlers map[string]map[int]Handler
}
type Runner struct {
store *operationreceipt.Store
handlers map[string]map[int]Handler
mu sync.Mutex
active map[string]activeAction
now func() time.Time
}
type activeAction struct {
identity operationreceipt.Identity
cancel context.CancelFunc
}
func Open(config Config) (*Runner, error) {
if strings.TrimSpace(config.ReceiptPath) == "" {
return nil, fmt.Errorf("action runner receipt path is required")
}
handlers := make(map[string]map[int]Handler, len(config.Handlers))
for operation, versions := range config.Handlers {
operation = strings.ToLower(strings.TrimSpace(operation))
copyVersions := make(map[int]Handler, len(versions))
for version, handler := range versions {
if !AllowedOperation(operation, version) || handler == nil {
return nil, fmt.Errorf("%w: %s v%d", ErrUnsupported, operation, version)
}
copyVersions[version] = handler
}
handlers[operation] = copyVersions
}
store, err := operationreceipt.Open(config.ReceiptPath, operationreceipt.Config{Validators: map[string]map[int]operationreceipt.TerminalValidator{ReceiptKind: {ReceiptVersion: ValidateTerminal}}})
if err != nil {
return nil, err
}
return &Runner{store: store, handlers: handlers, active: make(map[string]activeAction), now: time.Now}, nil
}
func (r *Runner) Close() error {
if r == nil {
return nil
}
r.mu.Lock()
for _, action := range r.active {
action.cancel()
}
r.active = make(map[string]activeAction)
r.mu.Unlock()
return r.store.Close()
}
func (r *Runner) Execute(ctx context.Context, session Session, request Request) (operationreceipt.Record, error) {
if r == nil || r.store == nil {
return operationreceipt.Record{}, fmt.Errorf("action runner is not initialized")
}
now := r.now().UTC()
if err := ValidateRequest(&request, now); err != nil {
return operationreceipt.Record{}, err
}
if err := authorize(session, request); err != nil {
return operationreceipt.Record{}, err
}
handler := r.handlers[request.Operation][request.OperationVersion]
if handler == nil {
return operationreceipt.Record{}, fmt.Errorf("%w: %s v%d", ErrUnsupported, request.Operation, request.OperationVersion)
}
if err := handler.ValidatePayload(request.Payload); err != nil {
return operationreceipt.Record{}, fmt.Errorf("invalid typed action payload: %w", err)
}
identity := ReceiptIdentity(request, session.TokenID)
record, admitted, err := r.store.Admit(identity)
if err != nil {
return operationreceipt.Record{}, err
}
if !admitted {
if record.State == operationreceipt.StateTerminal {
return record, nil
}
return operationreceipt.Record{}, ErrReplayInProgress
}
if _, err := r.store.MarkStarted(identity); err != nil {
return operationreceipt.Record{}, err
}
actionCtx, cancel := context.WithDeadline(ctx, request.Deadline)
r.mu.Lock()
r.active[request.AttemptID] = activeAction{identity: identity, cancel: cancel}
r.mu.Unlock()
defer func() {
cancel()
r.mu.Lock()
delete(r.active, request.AttemptID)
r.mu.Unlock()
}()
result, executeErr := handler.Execute(actionCtx, request.Target, append(json.RawMessage(nil), request.Payload...))
terminal := TerminalResult{ProtocolVersion: ProtocolVersion, AttemptID: request.AttemptID, ActionID: request.ActionID, Operation: request.Operation, OperationVersion: request.OperationVersion, RequestDigest: request.RequestDigest, Target: request.Target, Status: result.Status, ReasonCode: strings.TrimSpace(result.ReasonCode), Output: result.Output}
if executeErr != nil {
terminal.Output = nil
switch {
case errors.Is(actionCtx.Err(), context.DeadlineExceeded):
terminal.Status, terminal.ReasonCode = ResultDeadline, "deadline_exceeded"
case errors.Is(actionCtx.Err(), context.Canceled):
terminal.Status, terminal.ReasonCode = ResultCanceled, "canceled"
default:
terminal.Status, terminal.ReasonCode = ResultFailed, "execution_failed"
}
}
encoded, err := json.Marshal(terminal)
if err != nil {
return operationreceipt.Record{}, err
}
if err := ValidateTerminal(identity, encoded); err != nil {
return operationreceipt.Record{}, err
}
completed, err := r.store.Complete(identity, operationreceipt.TerminalEnvelope{Kind: ReceiptKind, Version: ReceiptVersion, Payload: encoded})
if err != nil {
return operationreceipt.Record{}, err
}
return completed, executeErr
}
func (r *Runner) Cancel(session Session, identity operationreceipt.Identity) error {
if strings.TrimSpace(session.TokenID) == "" || identity.AgentID != strings.TrimSpace(session.TokenID) || !session.Capabilities[ActionCapability] {
return ErrUnauthorized
}
r.mu.Lock()
defer r.mu.Unlock()
action, ok := r.active[identity.AttemptID]
if !ok {
return ErrNotActive
}
if action.identity != identity {
return operationreceipt.ErrBindingConflict
}
action.cancel()
return nil
}
func (r *Runner) Query(session Session, identity operationreceipt.Identity) (operationreceipt.QueryResult, error) {
if strings.TrimSpace(session.TokenID) == "" || identity.AgentID != strings.TrimSpace(session.TokenID) || !session.Capabilities[ActionCapability] {
return operationreceipt.QueryResult{}, ErrUnauthorized
}
return r.store.Query(identity)
}
func authorize(session Session, request Request) error {
if !boundedID.MatchString(strings.TrimSpace(session.OrganizationID)) || !boundedID.MatchString(strings.TrimSpace(session.HostID)) || !boundedID.MatchString(strings.TrimSpace(session.TokenID)) || !session.Capabilities[ActionCapability] {
return ErrUnauthorized
}
if session.OrganizationID != request.OrganizationID || session.HostID != request.HostID {
return ErrUnauthorized
}
return nil
}
+211
View File
@@ -0,0 +1,211 @@
package actionrunner
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/operationreceipt"
)
type testPayload struct {
Value string `json:"value"`
}
type testHandler struct {
started chan struct{}
}
func (h testHandler) ValidatePayload(data json.RawMessage) error {
var payload testPayload
return decodeStrict(data, &payload)
}
func (h testHandler) Execute(ctx context.Context, _ Target, data json.RawMessage) (Result, error) {
if h.started != nil {
close(h.started)
<-ctx.Done()
return Result{}, ctx.Err()
}
return Result{Status: ResultSucceeded, Output: append(json.RawMessage(nil), data...)}, nil
}
func openTestRunner(t *testing.T, handler Handler) *Runner {
t.Helper()
runner, err := Open(Config{ReceiptPath: filepath.Join(t.TempDir(), "receipts.db"), Handlers: map[string]map[int]Handler{"host.update": {1: handler}}})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = runner.Close() })
return runner
}
func testSession() Session {
return Session{OrganizationID: "org-1", HostID: "host-1", TokenID: "runner-token-1", Capabilities: map[string]bool{ActionCapability: true}}
}
func testRequest(t *testing.T) Request {
t.Helper()
request := Request{ProtocolVersion: ProtocolVersion, OrganizationID: "org-1", HostID: "host-1", AttemptID: "attempt-1", ActionID: "action-1", Operation: "host.update", OperationVersion: 1, Target: Target{Kind: "host", ID: "host-1"}, Deadline: time.Now().UTC().Add(time.Minute), Payload: json.RawMessage(`{"value":"bounded"}`)}
digest, err := RequestDigest(request)
if err != nil {
t.Fatal(err)
}
request.RequestDigest = digest
return request
}
func TestExecutePersistsAndReplaysBoundTerminalReceipt(t *testing.T) {
runner := openTestRunner(t, testHandler{})
session := testSession()
request := testRequest(t)
record, err := runner.Execute(context.Background(), session, request)
if err != nil {
t.Fatal(err)
}
if record.State != operationreceipt.StateTerminal || record.Identity != ReceiptIdentity(request, session.TokenID) {
t.Fatalf("unexpected receipt: %+v", record)
}
var result TerminalResult
if err := json.Unmarshal(record.Result, &result); err != nil {
t.Fatal(err)
}
if result.Status != ResultSucceeded || result.Target != request.Target {
t.Fatalf("unexpected result: %+v", result)
}
replayed, err := runner.Execute(context.Background(), session, request)
if err != nil || replayed.TerminalAt != record.TerminalAt {
t.Fatalf("replay = %+v, %v", replayed, err)
}
query, err := runner.Query(session, record.Identity)
if err != nil || query.Status != operationreceipt.QueryFoundTerminal {
t.Fatalf("query = %+v, %v", query, err)
}
}
func TestExecuteRejectsWrongSessionAndUnknownPayloadFields(t *testing.T) {
runner := openTestRunner(t, testHandler{})
request := testRequest(t)
wrong := testSession()
wrong.HostID = "host-2"
if _, err := runner.Execute(context.Background(), wrong, request); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("wrong session error = %v", err)
}
request.Payload = json.RawMessage(`{"value":"ok","command":"id"}`)
request.RequestDigest, _ = RequestDigest(request)
if _, err := runner.Execute(context.Background(), testSession(), request); err == nil || !strings.Contains(err.Error(), "unknown field") {
t.Fatalf("unknown payload field error = %v", err)
}
}
func TestDecodeRequestRejectsUnknownVersionShellReadFileAndTrailingJSON(t *testing.T) {
now := time.Now().UTC()
base := testRequest(t)
base.Deadline = now.Add(time.Minute)
base.RequestDigest, _ = RequestDigest(base)
encoded, _ := json.Marshal(base)
var raw map[string]any
_ = json.Unmarshal(encoded, &raw)
raw["unexpected"] = true
unknown, _ := json.Marshal(raw)
if _, err := DecodeRequest(unknown, now); err == nil || !strings.Contains(err.Error(), "unknown field") {
t.Fatalf("unknown-field error = %v", err)
}
if _, err := DecodeRequest(append(encoded, []byte(` {}`)...), now); err == nil || !strings.Contains(err.Error(), "trailing") {
t.Fatalf("trailing error = %v", err)
}
for _, operation := range []string{"shell", "exec", "execute_command", "read_file"} {
request := base
request.Operation = operation
request.RequestDigest, _ = RequestDigest(request)
data, _ := json.Marshal(request)
if _, err := DecodeRequest(data, now); !errors.Is(err, ErrUnsupported) {
t.Errorf("operation %q error = %v", operation, err)
}
}
badVersion := base
badVersion.OperationVersion = 2
badVersion.RequestDigest, _ = RequestDigest(badVersion)
data, _ := json.Marshal(badVersion)
if _, err := DecodeRequest(data, now); !errors.Is(err, ErrUnsupported) {
t.Fatalf("version error = %v", err)
}
}
func TestValidateRequestEnforcesTargetDeadlineAndOutputBounds(t *testing.T) {
now := time.Now().UTC()
request := testRequest(t)
request.Target.ID = "another-host"
request.RequestDigest, _ = RequestDigest(request)
if err := ValidateRequest(&request, now); err == nil || !strings.Contains(err.Error(), "bound host") {
t.Fatalf("target binding error = %v", err)
}
request = testRequest(t)
request.Deadline = now.Add(MaxOperationDeadline + time.Second)
request.RequestDigest, _ = RequestDigest(request)
if err := ValidateRequest(&request, now); err == nil || !strings.Contains(err.Error(), "deadline") {
t.Fatalf("deadline error = %v", err)
}
request = testRequest(t)
terminal := TerminalResult{ProtocolVersion: ProtocolVersion, AttemptID: request.AttemptID, ActionID: request.ActionID, Operation: request.Operation, OperationVersion: request.OperationVersion, RequestDigest: request.RequestDigest, Target: request.Target, Status: ResultSucceeded, Output: json.RawMessage(`"` + strings.Repeat("x", MaxResultBytes) + `"`)}
data, _ := json.Marshal(terminal)
if err := ValidateTerminal(ReceiptIdentity(request, testSession().TokenID), data); err == nil || !strings.Contains(err.Error(), "oversized") {
t.Fatalf("output bound error = %v", err)
}
}
func TestCancelRequiresExactCredentialAndProducesDurableCanceledReceipt(t *testing.T) {
started := make(chan struct{})
runner := openTestRunner(t, testHandler{started: started})
session := testSession()
request := testRequest(t)
type outcome struct {
record operationreceipt.Record
err error
}
done := make(chan outcome, 1)
go func() {
record, err := runner.Execute(context.Background(), session, request)
done <- outcome{record, err}
}()
<-started
identity := ReceiptIdentity(request, session.TokenID)
wrongIdentity := identity
wrongIdentity.ActionID = "other-action"
if err := runner.Cancel(session, wrongIdentity); !errors.Is(err, operationreceipt.ErrBindingConflict) {
t.Fatalf("wrong cancellation binding error = %v", err)
}
if err := runner.Cancel(session, identity); err != nil {
t.Fatal(err)
}
result := <-done
if !errors.Is(result.err, context.Canceled) || result.record.State != operationreceipt.StateTerminal {
t.Fatalf("canceled outcome = %+v, %v", result.record, result.err)
}
var terminal TerminalResult
if err := json.Unmarshal(result.record.Result, &terminal); err != nil {
t.Fatal(err)
}
if terminal.Status != ResultCanceled || terminal.ReasonCode != "canceled" {
t.Fatalf("terminal = %+v", terminal)
}
}
func TestRegistrationIsExplicitActionRunnerRole(t *testing.T) {
registration, err := testSession().Registration()
if err != nil {
t.Fatal(err)
}
if registration.RuntimeRole != "action-runner" {
t.Fatalf("runtime role = %q", registration.RuntimeRole)
}
collector := testSession()
collector.Capabilities = map[string]bool{}
if _, err := collector.Registration(); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("collector registration error = %v", err)
}
}
+33
View File
@@ -0,0 +1,33 @@
package actionrunner
import (
"context"
"github.com/rcourtman/pulse-go-rewrite/internal/hostagent"
)
// TransportConfig carries only the separate runner's connection credential,
// state, and TLS inputs. There is deliberately no monitoring/report config.
type TransportConfig = hostagent.ActionRunnerClientConfig
// Client is the action-runner-owned facade over the existing typed action
// codecs and executors. The hostagent implementation remains an internal
// compatibility detail while the collector continues its legacy migration.
type Client struct {
inner *hostagent.CommandClient
}
func NewClient(config TransportConfig, hostID, hostname, version string) *Client {
return &Client{inner: hostagent.NewActionRunnerClient(config, hostID, hostname, version)}
}
func (client *Client) Run(ctx context.Context) error {
return client.inner.Run(ctx)
}
func (client *Client) Close() error {
if client == nil || client.inner == nil {
return nil
}
return client.inner.Close()
}
+273
View File
@@ -0,0 +1,273 @@
package actionrunner
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"regexp"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/operationreceipt"
)
const (
ProtocolVersion = 1
ReceiptKind = "pulse.action_runner_result"
ReceiptVersion = 1
MaxRequestBytes = 64 << 10
MaxResultBytes = 32 << 10
MaxOperationDeadline = 30 * time.Minute
ActionCapability = "typed_actions.v1"
RuntimeRole = "action-runner"
)
var boundedID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
var (
ErrUnauthorized = errors.New("action runner session is not authorized")
ErrUnsupported = errors.New("action runner operation is unsupported")
ErrReplayInProgress = errors.New("action runner operation is already in progress")
ErrNotActive = errors.New("action runner operation is not active")
)
type Target struct {
Kind string `json:"kind"`
ID string `json:"id"`
}
type Request struct {
ProtocolVersion int `json:"protocol_version"`
OrganizationID string `json:"organization_id"`
HostID string `json:"host_id"`
AttemptID string `json:"attempt_id"`
ActionID string `json:"action_id"`
Operation string `json:"operation"`
OperationVersion int `json:"operation_version"`
RequestDigest string `json:"request_digest"`
Target Target `json:"target"`
Deadline time.Time `json:"deadline"`
Payload json.RawMessage `json:"payload"`
}
type Session struct {
OrganizationID string
HostID string
TokenID string
Capabilities map[string]bool
}
// Registration is the transport-neutral identity emitted when this runtime
// opens an action session. Collector registrations intentionally do not use
// this type, so the server can fail closed on runtime role.
type Registration struct {
RuntimeRole string `json:"runtime_role"`
OrganizationID string `json:"organization_id"`
HostID string `json:"host_id"`
}
func (session Session) Registration() (Registration, error) {
organizationID := strings.TrimSpace(session.OrganizationID)
hostID := strings.TrimSpace(session.HostID)
if !boundedID.MatchString(organizationID) || !boundedID.MatchString(hostID) || !boundedID.MatchString(strings.TrimSpace(session.TokenID)) || !session.Capabilities[ActionCapability] {
return Registration{}, ErrUnauthorized
}
return Registration{RuntimeRole: RuntimeRole, OrganizationID: organizationID, HostID: hostID}, nil
}
type ResultStatus string
const (
ResultSucceeded ResultStatus = "succeeded"
ResultRefused ResultStatus = "refused"
ResultFailed ResultStatus = "failed"
ResultCanceled ResultStatus = "canceled"
ResultDeadline ResultStatus = "deadline_exceeded"
)
type Result struct {
Status ResultStatus `json:"status"`
ReasonCode string `json:"reason_code,omitempty"`
Output json.RawMessage `json:"output,omitempty"`
}
type TerminalResult struct {
ProtocolVersion int `json:"protocol_version"`
AttemptID string `json:"attempt_id"`
ActionID string `json:"action_id"`
Operation string `json:"operation"`
OperationVersion int `json:"operation_version"`
RequestDigest string `json:"request_digest"`
Target Target `json:"target"`
Status ResultStatus `json:"status"`
ReasonCode string `json:"reason_code,omitempty"`
Output json.RawMessage `json:"output,omitempty"`
}
func DecodeRequest(data []byte, now time.Time) (Request, error) {
if len(data) > MaxRequestBytes {
return Request{}, fmt.Errorf("action request exceeds %d bytes", MaxRequestBytes)
}
var request Request
if err := decodeStrict(data, &request); err != nil {
return Request{}, err
}
if err := ValidateRequest(&request, now); err != nil {
return Request{}, err
}
return request, nil
}
func ValidateRequest(request *Request, now time.Time) error {
if request == nil {
return fmt.Errorf("action request is required")
}
request.OrganizationID = strings.TrimSpace(request.OrganizationID)
request.HostID = strings.TrimSpace(request.HostID)
request.AttemptID = strings.TrimSpace(request.AttemptID)
request.ActionID = strings.TrimSpace(request.ActionID)
request.Operation = strings.ToLower(strings.TrimSpace(request.Operation))
request.RequestDigest = strings.ToLower(strings.TrimSpace(request.RequestDigest))
request.Target.Kind = strings.ToLower(strings.TrimSpace(request.Target.Kind))
request.Target.ID = strings.TrimSpace(request.Target.ID)
request.Deadline = request.Deadline.UTC()
if request.ProtocolVersion != ProtocolVersion {
return fmt.Errorf("unsupported action protocol version %d", request.ProtocolVersion)
}
for label, value := range map[string]string{"organization_id": request.OrganizationID, "host_id": request.HostID, "attempt_id": request.AttemptID, "action_id": request.ActionID, "target.kind": request.Target.Kind, "target.id": request.Target.ID} {
if !boundedID.MatchString(value) {
return fmt.Errorf("invalid %s", label)
}
}
if !AllowedOperation(request.Operation, request.OperationVersion) {
return fmt.Errorf("%w: %s v%d", ErrUnsupported, request.Operation, request.OperationVersion)
}
switch {
case strings.HasPrefix(request.Operation, "host."):
if request.Target.Kind != "host" || request.Target.ID != request.HostID {
return fmt.Errorf("host action target does not match the bound host")
}
case strings.HasPrefix(request.Operation, "proxmox."):
if request.Target.Kind != "proxmox-guest" {
return fmt.Errorf("Proxmox action requires a Proxmox guest target")
}
case strings.HasPrefix(request.Operation, "container."):
if request.Target.Kind != "container" {
return fmt.Errorf("container action requires a container target")
}
}
if len(request.Payload) == 0 || len(request.Payload) > MaxRequestBytes || !json.Valid(request.Payload) {
return fmt.Errorf("action payload is empty, invalid, or oversized")
}
if request.Deadline.IsZero() || !request.Deadline.After(now.UTC()) || request.Deadline.After(now.UTC().Add(MaxOperationDeadline)) {
return fmt.Errorf("action deadline must be in the future and at most %s", MaxOperationDeadline)
}
digest, err := RequestDigest(*request)
if err != nil {
return err
}
if request.RequestDigest != digest {
return fmt.Errorf("action request digest mismatch")
}
return nil
}
func RequestDigest(request Request) (string, error) {
canonical := struct {
OrganizationID string `json:"organization_id"`
HostID string `json:"host_id"`
ActionID string `json:"action_id"`
Operation string `json:"operation"`
OperationVersion int `json:"operation_version"`
Target Target `json:"target"`
Payload json.RawMessage `json:"payload"`
}{request.OrganizationID, request.HostID, request.ActionID, request.Operation, request.OperationVersion, request.Target, request.Payload}
encoded, err := json.Marshal(canonical)
if err != nil {
return "", err
}
sum := sha256.Sum256(encoded)
return "sha256:" + hex.EncodeToString(sum[:]), nil
}
func AllowedOperation(operation string, version int) bool {
if version != 1 {
return false
}
switch operation {
case "host.update", "host.storage_cleanup",
"proxmox.guest.start", "proxmox.guest.stop", "proxmox.guest.shutdown", "proxmox.guest.reboot",
"container.start", "container.stop", "container.restart", "container.update":
return true
default:
return false
}
}
func ReceiptIdentity(request Request, tokenID string) operationreceipt.Identity {
return operationreceipt.Identity{AttemptID: request.AttemptID, ActionID: request.ActionID, OperationKind: request.Operation, OperationVersion: request.OperationVersion, RequestDigest: request.RequestDigest, AgentID: strings.TrimSpace(tokenID)}
}
func ValidateTerminal(identity operationreceipt.Identity, data json.RawMessage) error {
var result TerminalResult
if err := decodeStrict(data, &result); err != nil {
return err
}
if result.ProtocolVersion != ProtocolVersion || result.AttemptID != identity.AttemptID || result.ActionID != identity.ActionID || result.Operation != identity.OperationKind || result.OperationVersion != identity.OperationVersion || result.RequestDigest != identity.RequestDigest {
return operationreceipt.ErrBindingConflict
}
if !boundedID.MatchString(result.Target.Kind) || !boundedID.MatchString(result.Target.ID) {
return fmt.Errorf("invalid action result target")
}
switch result.Status {
case ResultSucceeded:
if result.ReasonCode != "" {
return fmt.Errorf("successful action cannot include a reason code")
}
case ResultRefused, ResultFailed, ResultCanceled, ResultDeadline:
if !validReasonCode(result.ReasonCode) {
return fmt.Errorf("non-success action requires a bounded reason code")
}
default:
return fmt.Errorf("unsupported action result status %q", result.Status)
}
if len(result.Output) > MaxResultBytes || (len(result.Output) > 0 && !json.Valid(result.Output)) {
return fmt.Errorf("action result output is invalid or oversized")
}
return nil
}
func validReasonCode(value string) bool {
if value == "" || len(value) > 64 {
return false
}
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' {
continue
}
return false
}
return true
}
func decodeStrict(data []byte, target any) error {
if len(bytes.TrimSpace(data)) == 0 {
return fmt.Errorf("action payload is empty")
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return fmt.Errorf("action payload contains trailing JSON")
}
return fmt.Errorf("action payload contains trailing data: %w", err)
}
return nil
}
@@ -0,0 +1,175 @@
package agentexec
import (
"bytes"
"encoding/json"
"fmt"
"io"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/operationreceipt"
)
const (
ProxmoxGuestLifecycleOperationVersion = 1
ProxmoxGuestLifecycleReceiptKind = "pulse.proxmox_guest_lifecycle_result"
ProxmoxGuestLifecycleReceiptVersion = 1
maxProxmoxGuestLifecycleBytes = 64 << 10
)
func DecodeProxmoxGuestLifecyclePayload(data []byte) (ProxmoxGuestLifecyclePayload, error) {
var payload ProxmoxGuestLifecyclePayload
if err := decodeStrictProxmoxLifecycle(data, &payload); err != nil {
return payload, err
}
return payload, ValidateProxmoxGuestLifecyclePayload(&payload)
}
func DecodeProxmoxGuestLifecycleResultPayload(data []byte) (ProxmoxGuestLifecycleResultPayload, error) {
var payload ProxmoxGuestLifecycleResultPayload
if err := decodeStrictProxmoxLifecycle(data, &payload); err != nil {
return payload, err
}
return payload, ValidateProxmoxGuestLifecycleResultPayload(&payload)
}
func decodeStrictProxmoxLifecycle(data []byte, target any) error {
if len(bytes.TrimSpace(data)) == 0 || len(data) > maxProxmoxGuestLifecycleBytes {
return fmt.Errorf("proxmox guest lifecycle payload is empty or oversized")
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
return fmt.Errorf("proxmox guest lifecycle payload contains trailing data")
}
return nil
}
func BindProxmoxGuestLifecyclePayload(payload *ProxmoxGuestLifecyclePayload) error {
if payload == nil {
return fmt.Errorf("proxmox guest lifecycle payload is required")
}
payload.OperationVersion = ProxmoxGuestLifecycleOperationVersion
digest, err := proxmoxGuestLifecycleRequestDigest(*payload)
if err != nil {
return err
}
payload.RequestDigest = digest
return nil
}
func proxmoxGuestLifecycleRequestDigest(payload ProxmoxGuestLifecyclePayload) (string, error) {
return operationreceipt.DigestCanonicalJSON(struct {
ActionID string `json:"action_id"`
Operation string `json:"operation"`
OperationVersion int `json:"operation_version"`
GuestKind string `json:"guest_kind"`
VMID int `json:"vmid"`
ExpectedStatus string `json:"expected_status"`
}{strings.TrimSpace(payload.ActionID), strings.ToLower(strings.TrimSpace(payload.Operation)), payload.OperationVersion, strings.ToLower(strings.TrimSpace(payload.GuestKind)), payload.VMID, strings.ToLower(strings.TrimSpace(payload.ExpectedStatus))})
}
func ValidateProxmoxGuestLifecyclePayload(payload *ProxmoxGuestLifecyclePayload) error {
if payload == nil {
return fmt.Errorf("proxmox guest lifecycle payload is required")
}
payload.RequestID = strings.TrimSpace(payload.RequestID)
payload.ActionID = strings.TrimSpace(payload.ActionID)
payload.Operation = strings.ToLower(strings.TrimSpace(payload.Operation))
payload.GuestKind = strings.ToLower(strings.TrimSpace(payload.GuestKind))
payload.ExpectedStatus = strings.ToLower(strings.TrimSpace(payload.ExpectedStatus))
if payload.RequestID == "" || len(payload.RequestID) > maxRequestIDLength || payload.ActionID == "" || len(payload.ActionID) > maxRequestIDLength {
return fmt.Errorf("invalid proxmox lifecycle request identity")
}
if payload.OperationVersion != ProxmoxGuestLifecycleOperationVersion || !isProxmoxGuestOperation(payload.Operation) {
return fmt.Errorf("unsupported proxmox guest lifecycle operation or version")
}
if payload.GuestKind != "vm" && payload.GuestKind != "ct" {
return fmt.Errorf("unsupported proxmox guest kind")
}
if payload.VMID < 1 || payload.VMID > 999999999 {
return fmt.Errorf("proxmox vmid must be a bounded positive integer")
}
if payload.ExpectedStatus != "running" && payload.ExpectedStatus != "stopped" {
return fmt.Errorf("expected proxmox guest status must be running or stopped")
}
want, err := proxmoxGuestLifecycleRequestDigest(*payload)
if err != nil || payload.RequestDigest != want {
return fmt.Errorf("proxmox guest lifecycle request digest mismatch")
}
if payload.Timeout < 0 || payload.Timeout > 300 {
return fmt.Errorf("proxmox guest lifecycle timeout must be between 0 and 300 seconds")
}
if payload.Timeout == 0 {
payload.Timeout = 180
}
return nil
}
func ValidateProxmoxGuestLifecycleResultPayload(result *ProxmoxGuestLifecycleResultPayload) error {
if result == nil {
return fmt.Errorf("proxmox guest lifecycle result is required")
}
result.RequestID = strings.TrimSpace(result.RequestID)
result.ActionID = strings.TrimSpace(result.ActionID)
result.Operation = strings.ToLower(strings.TrimSpace(result.Operation))
result.GuestKind = strings.ToLower(strings.TrimSpace(result.GuestKind))
result.RequestDigest = strings.TrimSpace(result.RequestDigest)
result.ExecutionPhase = strings.TrimSpace(result.ExecutionPhase)
result.ReasonCode = strings.TrimSpace(result.ReasonCode)
result.Error = strings.TrimSpace(result.Error)
if result.RequestID == "" || result.ActionID == "" || !isProxmoxGuestOperation(result.Operation) || result.OperationVersion != ProxmoxGuestLifecycleOperationVersion || (result.GuestKind != "vm" && result.GuestKind != "ct") || result.VMID < 1 || result.VMID > 999999999 || !hostUpdateInventoryHashPattern.MatchString(result.RequestDigest) {
return fmt.Errorf("invalid proxmox guest lifecycle result binding")
}
switch result.ExecutionPhase {
case ProxmoxGuestPhasePreflight, ProxmoxGuestPhaseMutate, ProxmoxGuestPhaseVerify, ProxmoxGuestPhaseComplete:
default:
return fmt.Errorf("unsupported proxmox guest lifecycle execution phase")
}
if len(result.Error) > 1024 || (result.ReasonCode != "" && !IsActionRefusalReasonCode(result.ReasonCode)) || (result.ReasonCode != "" && result.MutationStarted) || (result.MutationCompleted && !result.MutationStarted) {
return fmt.Errorf("invalid proxmox guest lifecycle result state")
}
for _, snapshot := range []*ProxmoxGuestLifecycleSnapshot{&result.Before, &result.After} {
snapshot.Status = strings.ToLower(strings.TrimSpace(snapshot.Status))
if snapshot.Status != "" && snapshot.Status != "running" && snapshot.Status != "stopped" {
return fmt.Errorf("invalid proxmox guest status")
}
if !snapshot.ObservedAt.IsZero() && snapshot.ObservedAt.Location() != time.UTC {
return fmt.Errorf("proxmox observation timestamp must be UTC")
}
}
if result.ReadbackRan && result.After.ObservedAt.IsZero() {
return fmt.Errorf("proxmox readback requires an observation")
}
return nil
}
func ValidateProxmoxGuestLifecycleResultForRequest(req ProxmoxGuestLifecyclePayload, result ProxmoxGuestLifecycleResultPayload) error {
if err := ValidateProxmoxGuestLifecyclePayload(&req); err != nil {
return err
}
if err := ValidateProxmoxGuestLifecycleResultPayload(&result); err != nil {
return err
}
if result.RequestID != req.RequestID || result.ActionID != req.ActionID || result.Operation != req.Operation || result.OperationVersion != req.OperationVersion || result.RequestDigest != req.RequestDigest || result.GuestKind != req.GuestKind || result.VMID != req.VMID {
return fmt.Errorf("proxmox guest lifecycle result identity mismatch")
}
return nil
}
func ProxmoxGuestLifecycleOperationIdentity(agentID string, payload ProxmoxGuestLifecyclePayload) operationreceipt.Identity {
return operationreceipt.Identity{AttemptID: payload.RequestID, ActionID: payload.ActionID, OperationKind: payload.Operation, OperationVersion: payload.OperationVersion, RequestDigest: payload.RequestDigest, AgentID: strings.TrimSpace(agentID)}
}
func isProxmoxGuestOperation(operation string) bool {
switch strings.ToLower(strings.TrimSpace(operation)) {
case "start", "stop", "shutdown", "reboot":
return true
default:
return false
}
}
@@ -0,0 +1,62 @@
package agentexec
import (
"encoding/json"
"strings"
"testing"
)
func boundProxmoxGuestLifecycle(t *testing.T) ProxmoxGuestLifecyclePayload {
t.Helper()
payload := ProxmoxGuestLifecyclePayload{RequestID: "attempt-1", ActionID: "action-1", Operation: "reboot", GuestKind: "vm", VMID: 101, ExpectedStatus: "running", Timeout: 30}
if err := BindProxmoxGuestLifecyclePayload(&payload); err != nil {
t.Fatal(err)
}
return payload
}
func TestProxmoxGuestLifecycleCodecRejectsArgumentInjectionUnknownFieldsAndVersions(t *testing.T) {
base := boundProxmoxGuestLifecycle(t)
encoded, _ := json.Marshal(base)
var object map[string]any
_ = json.Unmarshal(encoded, &object)
object["args"] = []string{"--", "sh", "-c", "id"}
hostile, _ := json.Marshal(object)
if _, err := DecodeProxmoxGuestLifecyclePayload(hostile); err == nil || !strings.Contains(err.Error(), "unknown field") {
t.Fatalf("argument injection error = %v", err)
}
for name, mutate := range map[string]func(*ProxmoxGuestLifecyclePayload){
"kind": func(p *ProxmoxGuestLifecyclePayload) { p.GuestKind = "vm;id" },
"operation": func(p *ProxmoxGuestLifecyclePayload) { p.Operation = "start --skiplock" },
"vmid": func(p *ProxmoxGuestLifecyclePayload) { p.VMID = -1 },
"version": func(p *ProxmoxGuestLifecyclePayload) { p.OperationVersion = 2 },
} {
t.Run(name, func(t *testing.T) {
payload := base
mutate(&payload)
if err := ValidateProxmoxGuestLifecyclePayload(&payload); err == nil {
t.Fatal("hostile payload was accepted")
}
})
}
if _, err := DecodeProxmoxGuestLifecyclePayload(append(encoded, []byte(` {}`)...)); err == nil || !strings.Contains(err.Error(), "trailing") {
t.Fatalf("trailing JSON error = %v", err)
}
}
func TestProxmoxGuestLifecycleResultIsRequestAndReceiptBound(t *testing.T) {
req := boundProxmoxGuestLifecycle(t)
result := ProxmoxGuestLifecycleResultPayload{
RequestID: req.RequestID, ActionID: req.ActionID, Operation: req.Operation,
OperationVersion: req.OperationVersion, RequestDigest: req.RequestDigest,
GuestKind: req.GuestKind, VMID: req.VMID, ExecutionPhase: ProxmoxGuestPhaseComplete,
MutationStarted: true, MutationCompleted: true,
}
if err := ValidateProxmoxGuestLifecycleResultForRequest(req, result); err != nil {
t.Fatal(err)
}
result.VMID++
if err := ValidateProxmoxGuestLifecycleResultForRequest(req, result); err == nil || !strings.Contains(err.Error(), "identity mismatch") {
t.Fatalf("mismatched receipt error = %v", err)
}
}
+111 -4
View File
@@ -8,6 +8,7 @@ import (
"net"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"time"
@@ -66,6 +67,7 @@ type Server struct {
pendingReqs map[string]chan CommandResultPayload // scoped request key -> response channel
pendingHostStorageCleanups map[string]chan HostStorageCleanupResultPayload // scoped request key -> typed storage-cleanup response
pendingHostUpdates map[string]chan HostUpdateResultPayload // scoped request key -> typed host-update response
pendingProxmoxGuestLifecycles map[string]chan ProxmoxGuestLifecycleResultPayload
pendingDockerContainerLifecycles map[string]chan DockerContainerLifecycleResultPayload
pendingDockerContainerUpdates map[string]chan DockerContainerUpdateResultPayload
pendingDockerContainerObservations map[string]chan DockerContainerObservationResultPayload
@@ -95,10 +97,12 @@ type organizationContextKey struct{}
// session. The raw bearer token is deliberately not retained after
// registration.
type AgentAdmission struct {
OrganizationID string
TokenID string
AgentID string
Hostname string
OrganizationID string
TokenID string
AgentID string
Hostname string
RuntimeRole string
ActionCapability string
}
// AgentRegistrationValidator authenticates and binds a registration to one
@@ -195,6 +199,7 @@ func NewServerWithAdmissionValidator(admit AgentRegistrationValidator, validateS
pendingReqs: make(map[string]chan CommandResultPayload),
pendingHostStorageCleanups: make(map[string]chan HostStorageCleanupResultPayload),
pendingHostUpdates: make(map[string]chan HostUpdateResultPayload),
pendingProxmoxGuestLifecycles: make(map[string]chan ProxmoxGuestLifecycleResultPayload),
pendingDockerContainerLifecycles: make(map[string]chan DockerContainerLifecycleResultPayload),
pendingDockerContainerUpdates: make(map[string]chan DockerContainerUpdateResultPayload),
pendingDockerContainerObservations: make(map[string]chan DockerContainerObservationResultPayload),
@@ -371,6 +376,16 @@ func (s *Server) connectionForOrganization(organizationID, agentID string) (*age
return nil, false
}
func requireLegacyFullTrustConnection(ac *agentConn, operation string) error {
if ac == nil {
return fmt.Errorf("agent connection is unavailable")
}
if strings.TrimSpace(ac.admission.RuntimeRole) == RuntimeRoleActionRunner {
return fmt.Errorf("%s is not available on typed action-runner sessions", operation)
}
return nil
}
func (s *Server) connectionForContext(ctx context.Context, agentID string) (*agentConn, bool) {
return s.connectionForOrganization(organizationIDFromContext(ctx), agentID)
}
@@ -442,6 +457,16 @@ func (s *Server) matchesPendingDockerUpdateOperationForSession(sessionKey, agent
return ok && expected.identity == actual && expected.subjectID == strings.ToLower(strings.TrimSpace(result.ContainerID))
}
func (s *Server) matchesPendingProxmoxGuestOperationForSession(sessionKey, agentID string, result ProxmoxGuestLifecycleResultPayload) bool {
key := pendingRequestKey(sessionKey, result.RequestID)
s.mu.RLock()
expected, ok := s.pendingHostOperations[key]
s.mu.RUnlock()
actual := operationreceipt.Identity{AttemptID: result.RequestID, ActionID: result.ActionID, OperationKind: result.Operation, OperationVersion: result.OperationVersion, RequestDigest: result.RequestDigest, AgentID: strings.TrimSpace(agentID)}
subject := result.GuestKind + ":" + strconv.Itoa(result.VMID)
return ok && expected.identity == actual && expected.subjectID == subject
}
func (s *Server) releasePendingHostOperation(key string) {
s.mu.Lock()
delete(s.pendingHostOperations, key)
@@ -935,6 +960,8 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
admission.TokenID = strings.TrimSpace(admission.TokenID)
admission.AgentID = strings.TrimSpace(admission.AgentID)
admission.Hostname = strings.TrimSpace(admission.Hostname)
admission.RuntimeRole = strings.TrimSpace(admission.RuntimeRole)
admission.ActionCapability = strings.TrimSpace(admission.ActionCapability)
if admission.AgentID == "" {
admission.AgentID = reg.AgentID
}
@@ -945,6 +972,19 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
!unifiedresources.HostnamesEquivalent(admission.Hostname, reg.Hostname) {
admitted = false
}
registrationRole := strings.TrimSpace(reg.RuntimeRole)
registrationCapability := strings.TrimSpace(reg.ActionCapability)
if admission.RuntimeRole == RuntimeRoleActionRunner {
if registrationRole != RuntimeRoleActionRunner ||
admission.ActionCapability != ActionCapabilityTypedV1 ||
registrationCapability != admission.ActionCapability {
admitted = false
}
} else if registrationRole == RuntimeRoleActionRunner || registrationCapability != "" {
// A legacy collector token cannot opt itself into the runner protocol by
// asserting registration fields that were not bound into its credential.
admitted = false
}
if !admitted {
log.Warn().Str("agent_id", reg.AgentID).Msg("Agent registration rejected: invalid token")
// Actionable message instead of a bare "Invalid token": the agent logs
@@ -976,6 +1016,8 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
Version: reg.Version,
Platform: reg.Platform,
Tags: reg.Tags,
RuntimeRole: admission.RuntimeRole,
ActionCapability: admission.ActionCapability,
ConnectedAt: time.Now(),
OperationReceiptVersion: reg.OperationReceiptVersion,
ActionPreflightVersion: reg.ActionPreflightVersion,
@@ -1305,6 +1347,27 @@ func (s *Server) readLoop(ac *agentConn) {
}
}
case MsgTypeProxmoxGuestLifecycleResult:
result, decodeErr := DecodeProxmoxGuestLifecycleResultPayload(msg.Payload)
if decodeErr != nil {
log.Warn().Err(decodeErr).Str("agent_id", ac.agent.AgentID).Msg("Dropping invalid Proxmox guest lifecycle result")
continue
}
if !s.matchesPendingProxmoxGuestOperationForSession(connectionSessionKey(ac), ac.agent.AgentID, result) {
log.Warn().Str("agent_id", ac.agent.AgentID).Str("request_id", result.RequestID).Msg("Dropping uncorrelated Proxmox guest lifecycle result")
continue
}
s.mu.RLock()
ch, ok := s.pendingProxmoxGuestLifecycles[pendingRequestKey(connectionSessionKey(ac), result.RequestID)]
s.mu.RUnlock()
if ok {
select {
case ch <- result:
default:
log.Warn().Str("agent_id", ac.agent.AgentID).Str("request_id", result.RequestID).Msg("Proxmox guest lifecycle result channel full, dropping")
}
}
case MsgTypeDockerContainerLifecycleResult:
result, decodeErr := DecodeDockerContainerLifecycleResultPayload(msg.Payload)
if decodeErr != nil {
@@ -1587,6 +1650,9 @@ func (s *Server) ExecuteCommand(ctx context.Context, agentID string, cmd Execute
Msg("Execute command requested for disconnected agent")
return nil, fmt.Errorf("agent %s not connected", agentID)
}
if err := requireLegacyFullTrustConnection(ac, "execute_command"); err != nil {
return nil, err
}
if err := s.authorizeCommandPayload(cmd); err != nil {
return nil, err
}
@@ -2027,6 +2093,41 @@ func (s *Server) ExecuteDockerContainerLifecycle(ctx context.Context, agentID st
})
}
// ExecuteProxmoxGuestLifecycle dispatches one closed Proxmox guest action to
// an action-runner session. The wire contract contains only guest kind, fixed
// lifecycle verb, numeric VMID, and request-bound before state.
func (s *Server) ExecuteProxmoxGuestLifecycle(ctx context.Context, agentID string, req ProxmoxGuestLifecyclePayload) (*ProxmoxGuestLifecycleResultPayload, error) {
if s == nil {
return nil, fmt.Errorf("agent execution server is unavailable")
}
agentID = strings.TrimSpace(agentID)
if agentID == "" {
return nil, fmt.Errorf("agent id is required")
}
if strings.TrimSpace(req.RequestID) == "" {
req.RequestID = uuid.NewString()
}
if err := BindProxmoxGuestLifecyclePayload(&req); err != nil {
return nil, err
}
if err := ValidateProxmoxGuestLifecyclePayload(&req); err != nil {
return nil, err
}
ac, ok := s.connectionForContext(ctx, agentID)
if !ok {
return nil, fmt.Errorf("agent %s not connected", agentID)
}
if ac.admission.RuntimeRole != RuntimeRoleActionRunner || ac.admission.ActionCapability != ActionCapabilityTypedV1 {
return nil, fmt.Errorf("Proxmox guest lifecycle requires a typed action-runner session")
}
identity := ProxmoxGuestLifecycleOperationIdentity(agentID, req)
return dispatchTypedDockerContainerOperation(ctx, s, agentID, req.RequestID, req.Timeout, identity, req.GuestKind+":"+strconv.Itoa(req.VMID),
MsgTypeProxmoxGuestLifecycle, req, s.pendingProxmoxGuestLifecycles, "Proxmox guest lifecycle",
func(result ProxmoxGuestLifecycleResultPayload) error {
return ValidateProxmoxGuestLifecycleResultForRequest(req, result)
})
}
// dispatchTypedDockerContainerOperation owns the shared skeleton for closed
// typed container dispatches: durable-receipt capability check, pending
// operation claim, single-flight request registration, send, and the bounded
@@ -2230,6 +2331,9 @@ func (s *Server) ReadFile(ctx context.Context, agentID string, req ReadFilePaylo
Msg("Read file requested for disconnected agent")
return nil, fmt.Errorf("agent %s not connected", agentID)
}
if err := requireLegacyFullTrustConnection(ac, "read_file"); err != nil {
return nil, err
}
readLog := log.With().
Str("agent_id", agentID).
@@ -2510,6 +2614,9 @@ func (s *Server) sendDeployCommand(ctx context.Context, agentID string, msgType
if !ok {
return fmt.Errorf("agent %s not connected", agentID)
}
if err := requireLegacyFullTrustConnection(ac, "deploy command"); err != nil {
return err
}
requestID = strings.TrimSpace(requestID)
if requestID == "" {
@@ -209,6 +209,88 @@ func TestHandleWebSocket_RegistrationSuccessAndDisconnectRemovesAgent(t *testing
waitFor(t, 2*time.Second, func() bool { return !s.IsAgentConnected("a1") })
}
func TestActionRunnerRegistrationRequiresCredentialBoundRuntimeRoleAndCapability(t *testing.T) {
admission := AgentAdmission{
OrganizationID: "org-a",
TokenID: "runner-token",
AgentID: "machine-a",
Hostname: "node.example",
RuntimeRole: RuntimeRoleActionRunner,
ActionCapability: ActionCapabilityTypedV1,
}
s := NewServerWithAdmissionValidator(func(token, _, _ string) (AgentAdmission, bool) {
return admission, token == admission.TokenID
}, func(candidate AgentAdmission) bool { return candidate == admission })
ts := newWSServer(t, s)
defer ts.Close()
register := func(role, capability string) (*websocket.Conn, RegisteredPayload) {
t.Helper()
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
if err != nil {
t.Fatalf("Dial: %v", err)
}
wsWriteMessage(t, conn, mustNewMessage(t, MsgTypeAgentRegister, "", AgentRegisterPayload{
AgentID: admission.AgentID, Hostname: admission.Hostname, Token: admission.TokenID,
RuntimeRole: role, ActionCapability: capability,
}))
return conn, wsReadRegisteredPayload(t, conn)
}
for _, invalid := range []struct{ role, capability string }{
{"", ""},
{RuntimeRoleActionRunner, ""},
{RuntimeRoleActionRunner, "shell.v1"},
{RuntimeRoleLegacyFullTrust, ActionCapabilityTypedV1},
} {
conn, ack := register(invalid.role, invalid.capability)
conn.Close()
if ack.Success {
t.Fatalf("unbound runner assertion admitted: role=%q capability=%q", invalid.role, invalid.capability)
}
}
conn, ack := register(RuntimeRoleActionRunner, ActionCapabilityTypedV1)
defer conn.Close()
if !ack.Success {
t.Fatalf("bound action runner registration rejected: %s", ack.Message)
}
connected := s.GetConnectedAgentsForOrganization("org-a")
if len(connected) != 1 || connected[0].RuntimeRole != RuntimeRoleActionRunner || connected[0].ActionCapability != ActionCapabilityTypedV1 {
t.Fatalf("connected action runner = %#v", connected)
}
ctx := WithOrganizationID(context.Background(), "org-a")
if _, err := s.ExecuteCommand(ctx, admission.AgentID, ExecuteCommandPayload{RequestID: "shell", Command: "true", TargetType: "agent", Trusted: true}); err == nil || !strings.Contains(err.Error(), "typed action-runner") {
t.Fatalf("action runner accepted arbitrary command: %v", err)
}
if _, err := s.ReadFile(ctx, admission.AgentID, ReadFilePayload{RequestID: "read", Path: "/etc/hosts", TargetType: "agent"}); err == nil || !strings.Contains(err.Error(), "typed action-runner") {
t.Fatalf("action runner accepted unrestricted read: %v", err)
}
if err := s.SendDeployCancel(ctx, admission.AgentID, DeployCancelPayload{RequestID: "deploy", JobID: "job"}); err == nil || !strings.Contains(err.Error(), "typed action-runner") {
t.Fatalf("action runner accepted deploy protocol: %v", err)
}
}
func TestLegacyCredentialCannotAssertActionRunnerRole(t *testing.T) {
s := NewServerWithAdmissionValidator(func(token, _, _ string) (AgentAdmission, bool) {
return AgentAdmission{TokenID: token, AgentID: "a1", Hostname: "host1", RuntimeRole: RuntimeRoleLegacyFullTrust}, token == "legacy"
}, nil)
ts := newWSServer(t, s)
defer ts.Close()
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
wsWriteMessage(t, conn, mustNewMessage(t, MsgTypeAgentRegister, "", AgentRegisterPayload{
AgentID: "a1", Hostname: "host1", Token: "legacy", RuntimeRole: RuntimeRoleActionRunner, ActionCapability: ActionCapabilityTypedV1,
}))
if ack := wsReadRegisteredPayload(t, conn); ack.Success {
t.Fatal("legacy credential self-promoted into action-runner role")
}
}
func TestHandleWebSocket_RejectsMissingOrigin(t *testing.T) {
s := NewServer(allowAllTestTokens)
ts := newWSServer(t, s)
+56
View File
@@ -11,12 +11,17 @@ import (
type MessageType string
const (
RuntimeRoleActionRunner = "action-runner"
RuntimeRoleLegacyFullTrust = "legacy-full-trust"
ActionCapabilityTypedV1 = "typed_actions.v1"
// Agent -> Server messages
MsgTypeAgentRegister MessageType = "agent_register"
MsgTypeAgentPing MessageType = "agent_ping"
MsgTypeCommandResult MessageType = "command_result"
MsgTypeHostStorageCleanupResult MessageType = "host_storage_cleanup_result"
MsgTypeHostUpdateResult MessageType = "host_update_result"
MsgTypeProxmoxGuestLifecycleResult MessageType = "proxmox_guest_lifecycle_result"
MsgTypeDockerContainerLifecycleResult MessageType = "docker_container_lifecycle_result"
MsgTypeDockerContainerUpdateResult MessageType = "docker_container_update_result"
MsgTypeDockerContainerObserveResult MessageType = "docker_container_observe_result"
@@ -30,6 +35,7 @@ const (
MsgTypeHostStorageCleanup MessageType = "host_storage_cleanup"
MsgTypeReadFile MessageType = "read_file"
MsgTypeHostUpdate MessageType = "host_update"
MsgTypeProxmoxGuestLifecycle MessageType = "proxmox_guest_lifecycle"
MsgTypeDockerContainerLifecycle MessageType = "docker_container_lifecycle"
MsgTypeDockerContainerUpdate MessageType = "docker_container_update"
MsgTypeDockerContainerObserve MessageType = "docker_container_observe"
@@ -96,6 +102,8 @@ type AgentRegisterPayload struct {
Platform string `json:"platform"` // "linux", "windows", "darwin"
Tags []string `json:"tags,omitempty"`
Token string `json:"token"` // API token for authentication
RuntimeRole string `json:"runtime_role,omitempty"`
ActionCapability string `json:"action_capability,omitempty"`
OperationReceiptVersion int `json:"operation_receipt_version,omitempty"`
ActionPreflightVersion int `json:"action_preflight_version,omitempty"`
DockerObservationVersion int `json:"docker_observation_version,omitempty"`
@@ -335,6 +343,52 @@ type DockerContainerUpdateOutcome struct {
Error string
}
// ProxmoxGuestLifecyclePayload is a closed guest lifecycle operation. GuestKind
// selects one fixed Proxmox tool, Operation selects one fixed verb, and VMID is
// numeric. No command text or caller-supplied argument vector crosses the wire.
type ProxmoxGuestLifecyclePayload struct {
RequestID string `json:"request_id"`
ActionID string `json:"action_id"`
Operation string `json:"operation"`
OperationVersion int `json:"operation_version"`
RequestDigest string `json:"request_digest"`
GuestKind string `json:"guest_kind"`
VMID int `json:"vmid"`
ExpectedStatus string `json:"expected_status"`
Timeout int `json:"timeout,omitempty"`
}
type ProxmoxGuestLifecycleSnapshot struct {
Status string `json:"status,omitempty"`
ObservedAt time.Time `json:"observed_at,omitempty"`
}
type ProxmoxGuestLifecycleResultPayload struct {
RequestID string `json:"request_id"`
ActionID string `json:"action_id"`
Operation string `json:"operation"`
OperationVersion int `json:"operation_version"`
RequestDigest string `json:"request_digest"`
GuestKind string `json:"guest_kind"`
VMID int `json:"vmid"`
ExecutionPhase string `json:"execution_phase"`
MutationStarted bool `json:"mutation_started"`
MutationCompleted bool `json:"mutation_completed"`
ReadbackRan bool `json:"readback_ran"`
Before ProxmoxGuestLifecycleSnapshot `json:"before"`
After ProxmoxGuestLifecycleSnapshot `json:"after"`
ReasonCode string `json:"reason_code,omitempty"`
Error string `json:"error,omitempty"`
Duration int64 `json:"duration_ms"`
}
const (
ProxmoxGuestPhasePreflight = "preflight"
ProxmoxGuestPhaseMutate = "mutate"
ProxmoxGuestPhaseVerify = "verify"
ProxmoxGuestPhaseComplete = "complete"
)
// HostUpdatePayload is the closed, typed host-package operation sent to a
// Unified Agent. It intentionally has no command or package-name fields: the
// agent owns the package-manager command catalog and always updates the whole
@@ -528,6 +582,8 @@ type ConnectedAgent struct {
Version string
Platform string
Tags []string
RuntimeRole string
ActionCapability string
ConnectedAt time.Time
OperationReceiptVersion int
ActionPreflightVersion int
+178
View File
@@ -0,0 +1,178 @@
package agenthelper
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"strings"
)
const (
maxContainerDaemonBytes = 8 * 1024 * 1024
maxContainerCount = 2048
maxContainerString = 1024
)
type ContainerEndpoint struct {
Runtime string
SocketPath string
APIPath string
}
type LocalContainerProvider struct {
endpoints []ContainerEndpoint
dial func(context.Context, string) (net.Conn, error)
}
type ContainerInventoryResult struct {
Runtimes []ContainerRuntimeSnapshot `json:"runtimes"`
}
type ContainerRuntimeSnapshot struct {
Runtime string `json:"runtime"`
Available bool `json:"available"`
Containers []ContainerSummary `json:"containers"`
ErrorCode string `json:"errorCode,omitempty"`
}
type ContainerSummary struct {
ID string `json:"id"`
Names []string `json:"names,omitempty"`
Image string `json:"image,omitempty"`
State string `json:"state,omitempty"`
Status string `json:"status,omitempty"`
Created int64 `json:"created,omitempty"`
}
type daemonContainer struct {
ID string `json:"Id"`
Names []string `json:"Names"`
Image string `json:"Image"`
State string `json:"State"`
Status string `json:"Status"`
Created int64 `json:"Created"`
}
func NewLocalContainerProvider(endpoints []ContainerEndpoint) (*LocalContainerProvider, error) {
if len(endpoints) == 0 || len(endpoints) > 2 {
return nil, errors.New("one or two fixed container endpoints are required")
}
seen := make(map[string]struct{}, len(endpoints))
for _, endpoint := range endpoints {
if endpoint.Runtime != "docker" && endpoint.Runtime != "podman" {
return nil, fmt.Errorf("unsupported container runtime %q", endpoint.Runtime)
}
if _, ok := seen[endpoint.Runtime]; ok {
return nil, fmt.Errorf("duplicate container runtime %q", endpoint.Runtime)
}
seen[endpoint.Runtime] = struct{}{}
if endpoint.SocketPath == "" || endpoint.SocketPath[0] != '/' || endpoint.APIPath == "" || endpoint.APIPath[0] != '/' {
return nil, errors.New("container endpoints must use fixed absolute socket and API paths")
}
if strings.Contains(endpoint.SocketPath, "..") || strings.Contains(endpoint.APIPath, "..") || strings.ContainsAny(endpoint.APIPath, "\r\n") {
return nil, errors.New("container endpoint contains an unsafe path")
}
}
return &LocalContainerProvider{endpoints: append([]ContainerEndpoint(nil), endpoints...), dial: dialFixedUnixSocket}, nil
}
func dialFixedUnixSocket(ctx context.Context, socketPath string) (net.Conn, error) {
info, err := os.Lstat(socketPath)
if err != nil {
return nil, err
}
if info.Mode()&os.ModeSocket == 0 || info.Mode()&os.ModeSymlink != 0 {
return nil, errors.New("container endpoint is not a Unix socket")
}
dialer := net.Dialer{}
return dialer.DialContext(ctx, "unix", socketPath)
}
func (p *LocalContainerProvider) Inventory(ctx context.Context) (json.RawMessage, error) {
result := ContainerInventoryResult{Runtimes: make([]ContainerRuntimeSnapshot, 0, len(p.endpoints))}
for _, endpoint := range p.endpoints {
snapshot := ContainerRuntimeSnapshot{Runtime: endpoint.Runtime, Containers: []ContainerSummary{}}
containers, err := p.inventoryEndpoint(ctx, endpoint)
if err != nil {
if ctx.Err() != nil {
return nil, &ProviderError{Code: ErrorDeadlineExceeded, Message: "container inventory deadline exceeded", Retryable: true}
}
snapshot.ErrorCode = ErrorProviderUnavailable
} else {
snapshot.Available = true
snapshot.Containers = containers
}
result.Runtimes = append(result.Runtimes, snapshot)
}
encoded, err := json.Marshal(result)
if err != nil {
return nil, err
}
return encoded, nil
}
func (p *LocalContainerProvider) inventoryEndpoint(ctx context.Context, endpoint ContainerEndpoint) ([]ContainerSummary, error) {
transport := &http.Transport{
DisableKeepAlives: true,
DialContext: func(dialCtx context.Context, _, _ string) (net.Conn, error) {
return p.dial(dialCtx, endpoint.SocketPath)
},
}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://local-helper"+endpoint.APIPath, nil)
if err != nil {
return nil, err
}
response, err := client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("container runtime returned HTTP %d", response.StatusCode)
}
limited := io.LimitReader(response.Body, maxContainerDaemonBytes+1)
data, err := io.ReadAll(limited)
if err != nil {
return nil, err
}
if len(data) > maxContainerDaemonBytes {
return nil, errors.New("container runtime response exceeds limit")
}
var raw []daemonContainer
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("decode container inventory: %w", err)
}
if len(raw) > maxContainerCount {
return nil, errors.New("container runtime count exceeds limit")
}
containers := make([]ContainerSummary, 0, len(raw))
for _, item := range raw {
if !boundedContainerText(item.ID) || !boundedContainerText(item.Image) || !boundedContainerText(item.State) || !boundedContainerText(item.Status) {
return nil, errors.New("container runtime field exceeds limit")
}
if len(item.Names) > 32 {
return nil, errors.New("container runtime names exceed limit")
}
for _, name := range item.Names {
if !boundedContainerText(name) {
return nil, errors.New("container runtime name exceeds limit")
}
}
containers = append(containers, ContainerSummary{
ID: item.ID, Names: append([]string(nil), item.Names...), Image: item.Image,
State: item.State, Status: item.Status, Created: item.Created,
})
}
return containers, nil
}
func boundedContainerText(value string) bool {
return len(value) <= maxContainerString && !strings.ContainsAny(value, "\x00\r\n")
}
@@ -0,0 +1,102 @@
package agenthelper
import (
"bufio"
"context"
"errors"
"fmt"
"net"
"net/http"
"strings"
"testing"
"time"
)
func TestContainerInventoryUsesOnlyFixedBoundedGET(t *testing.T) {
provider, err := NewLocalContainerProvider([]ContainerEndpoint{{
Runtime: "docker", SocketPath: "/fixed/docker.sock", APIPath: "/v1.41/containers/json?all=1",
}})
if err != nil {
t.Fatal(err)
}
provider.dial = func(_ context.Context, socket string) (net.Conn, error) {
if socket != "/fixed/docker.sock" {
t.Fatalf("dialed socket %q", socket)
}
server, client := net.Pipe()
go func() {
defer server.Close()
request, readErr := http.ReadRequest(bufio.NewReader(server))
if readErr != nil {
return
}
if request.Method != http.MethodGet || request.URL.RequestURI() != "/v1.41/containers/json?all=1" {
t.Errorf("daemon request = %s %s", request.Method, request.URL.RequestURI())
}
_, _ = fmt.Fprint(server, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n"+
`[{"Id":"abc","Names":["/pulse"],"Image":"pulse:1","State":"running","Status":"Up","Created":1,"Labels":{"ignored":"closed-output"}}]`)
}()
return client, nil
}
result, err := provider.Inventory(t.Context())
if err != nil {
t.Fatalf("Inventory: %v", err)
}
if strings.Contains(string(result), "ignored") || !strings.Contains(string(result), `"id":"abc"`) {
t.Fatalf("inventory did not project the closed schema: %s", result)
}
}
func TestContainerInventoryBoundsDaemonOutput(t *testing.T) {
provider, err := NewLocalContainerProvider([]ContainerEndpoint{{Runtime: "podman", SocketPath: "/fixed/podman.sock", APIPath: "/v4/libpod/containers/json?all=true"}})
if err != nil {
t.Fatal(err)
}
provider.dial = func(context.Context, string) (net.Conn, error) {
server, client := net.Pipe()
go func() {
defer server.Close()
_, _ = http.ReadRequest(bufio.NewReader(server))
_, _ = fmt.Fprint(server, "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n[\""+strings.Repeat("x", maxContainerDaemonBytes)+"\"]")
}()
return client, nil
}
result, err := provider.Inventory(t.Context())
if err != nil {
t.Fatalf("bounded endpoint failure should be a closed unavailable snapshot: %v", err)
}
if !strings.Contains(string(result), `"available":false`) || !strings.Contains(string(result), ErrorProviderUnavailable) {
t.Fatalf("oversized output was not bounded: %s", result)
}
}
func TestContainerInventoryHonorsDeadline(t *testing.T) {
provider, err := NewLocalContainerProvider([]ContainerEndpoint{{Runtime: "docker", SocketPath: "/fixed/docker.sock", APIPath: "/containers/json"}})
if err != nil {
t.Fatal(err)
}
provider.dial = func(ctx context.Context, _ string) (net.Conn, error) {
<-ctx.Done()
return nil, ctx.Err()
}
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
defer cancel()
_, err = provider.Inventory(ctx)
var typed *ProviderError
if !errors.As(err, &typed) || typed.Code != ErrorDeadlineExceeded {
t.Fatalf("deadline error = %T %v", err, err)
}
}
func TestContainerEndpointRejectsCallerSelectedProxyShapes(t *testing.T) {
for _, endpoint := range []ContainerEndpoint{
{Runtime: "containerd", SocketPath: "/run/x.sock", APIPath: "/containers"},
{Runtime: "docker", SocketPath: "relative.sock", APIPath: "/containers"},
{Runtime: "docker", SocketPath: "/run/../tmp/x.sock", APIPath: "/containers"},
{Runtime: "docker", SocketPath: "/run/x.sock", APIPath: "http://attacker/"},
} {
if _, err := NewLocalContainerProvider([]ContainerEndpoint{endpoint}); err == nil {
t.Fatalf("unsafe endpoint accepted: %#v", endpoint)
}
}
}
@@ -0,0 +1,22 @@
//go:build !unix
package agenthelper
import (
"errors"
"os"
)
func openFileNoFollow(string) (*os.File, error) {
return nil, errors.New("privileged update activation is supported only on Unix")
}
func StrictRootOwnedFile(*os.File) error {
return errors.New("root ownership validation is supported only on Unix")
}
func FileOwnedByUID(uint32) func(*os.File) error {
return func(*os.File) error {
return errors.New("file ownership validation is supported only on Unix")
}
}
@@ -0,0 +1,31 @@
//go:build unix
package agenthelper
import (
"errors"
"os"
"syscall"
)
func openFileNoFollow(path string) (*os.File, error) {
return os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW, 0)
}
func StrictRootOwnedFile(file *os.File) error {
return FileOwnedByUID(0)(file)
}
func FileOwnedByUID(uid uint32) func(*os.File) error {
return func(file *os.File) error {
info, err := file.Stat()
if err != nil {
return err
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok || stat.Uid != uid {
return errors.New("file ownership does not match the required UID")
}
return nil
}
}
@@ -0,0 +1,27 @@
//go:build unix
package agenthelper
import (
"os"
"path/filepath"
"testing"
)
func TestStrictRootOwnedFileRejectsUnprivilegedOwner(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("test process is root")
}
path := filepath.Join(t.TempDir(), "artifact")
if err := os.WriteFile(path, []byte("artifact"), 0o600); err != nil {
t.Fatal(err)
}
file, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
if err := StrictRootOwnedFile(file); err == nil {
t.Fatal("unprivileged artifact owner accepted")
}
}
+6
View File
@@ -26,6 +26,8 @@ const (
ErrorUnauthorizedPeer = "unauthorized_peer"
ErrorDeadlineExceeded = "deadline_exceeded"
ErrorProviderUnavailable = "provider_unavailable"
ErrorArtifactInvalid = "artifact_invalid"
ErrorStateConflict = "state_conflict"
ErrorResponseTooLarge = "response_too_large"
ErrorInternal = "internal_error"
)
@@ -35,6 +37,10 @@ const (
OperationCapabilities = "helper.capabilities"
OperationSMARTSnapshot = "smart.snapshot"
OperationProxmoxLXCFilesystems = "proxmox.lxc_filesystems"
OperationContainerInventory = "container.inventory"
OperationAgentUpdateStage = "agent_update.stage"
OperationAgentUpdateActivate = "agent_update.activate"
OperationAgentUpdateRollback = "agent_update.rollback"
OperationVersion1 = 1
)
+17 -1
View File
@@ -20,6 +20,22 @@ type ProxmoxProvider interface {
LXCFilesystems(context.Context) (json.RawMessage, error)
}
// ContainerProvider owns a complete, bounded inventory of the helper's fixed
// local Docker and Podman endpoints. The protocol supplies no socket, URL,
// daemon method, or query fields.
type ContainerProvider interface {
Inventory(context.Context) (json.RawMessage, error)
}
// UpdateProvider owns the fixed-target, root-owned binary activation
// transaction. Request values identify a pre-staged artifact; they are never
// interpreted as filesystem paths.
type UpdateProvider interface {
Stage(context.Context, UpdateStageRequest) (UpdateStageResult, error)
Activate(context.Context, UpdateActivateRequest) (UpdateResult, error)
Rollback(context.Context, UpdateRollbackRequest) (UpdateResult, error)
}
type ProviderError struct {
Code string
Message string
@@ -51,7 +67,7 @@ func providerError(err error) *ResponseError {
func stableProviderErrorCode(code string) string {
switch code {
case ErrorProviderUnavailable, ErrorDeadlineExceeded:
case ErrorProviderUnavailable, ErrorDeadlineExceeded, ErrorArtifactInvalid, ErrorStateConflict:
return code
default:
return ErrorInternal
+62
View File
@@ -45,7 +45,16 @@ type HealthResult struct {
type emptyOperationRequest struct{}
type Providers struct {
Containers ContainerProvider
Updates UpdateProvider
}
func NewRegistry(smart SMARTProvider, proxmox ProxmoxProvider) *Registry {
return NewRegistryWithProviders(smart, proxmox, Providers{})
}
func NewRegistryWithProviders(smart SMARTProvider, proxmox ProxmoxProvider, providers Providers) *Registry {
registry := &Registry{operations: make(map[operationKey]registeredOperation)}
registry.add(OperationHealth, OperationVersion1, true, func(_ context.Context, payload json.RawMessage) (json.RawMessage, *ResponseError) {
var request emptyOperationRequest
@@ -83,6 +92,59 @@ func NewRegistry(smart SMARTProvider, proxmox ProxmoxProvider) *Registry {
result, err := proxmox.LXCFilesystems(ctx)
return validateProviderResult(result, err)
})
registry.add(OperationContainerInventory, OperationVersion1, providers.Containers != nil, func(ctx context.Context, payload json.RawMessage) (json.RawMessage, *ResponseError) {
var request emptyOperationRequest
if err := decodePayload(payload, &request); err != nil {
return nil, invalidPayloadError(err)
}
if providers.Containers == nil {
return nil, unavailableError("container runtime inventory provider is not configured")
}
result, err := providers.Containers.Inventory(ctx)
return validateProviderResult(result, err)
})
registry.add(OperationAgentUpdateStage, OperationVersion1, providers.Updates != nil, func(ctx context.Context, payload json.RawMessage) (json.RawMessage, *ResponseError) {
var request UpdateStageRequest
if err := decodePayload(payload, &request); err != nil {
return nil, invalidPayloadError(err)
}
if providers.Updates == nil {
return nil, unavailableError("agent update provider is not configured")
}
result, err := providers.Updates.Stage(ctx, request)
if err != nil {
return nil, providerError(err)
}
return marshalOperationResult(result)
})
registry.add(OperationAgentUpdateActivate, OperationVersion1, providers.Updates != nil, func(ctx context.Context, payload json.RawMessage) (json.RawMessage, *ResponseError) {
var request UpdateActivateRequest
if err := decodePayload(payload, &request); err != nil {
return nil, invalidPayloadError(err)
}
if providers.Updates == nil {
return nil, unavailableError("agent update provider is not configured")
}
result, err := providers.Updates.Activate(ctx, request)
if err != nil {
return nil, providerError(err)
}
return marshalOperationResult(result)
})
registry.add(OperationAgentUpdateRollback, OperationVersion1, providers.Updates != nil, func(ctx context.Context, payload json.RawMessage) (json.RawMessage, *ResponseError) {
var request UpdateRollbackRequest
if err := decodePayload(payload, &request); err != nil {
return nil, invalidPayloadError(err)
}
if providers.Updates == nil {
return nil, unavailableError("agent update provider is not configured")
}
result, err := providers.Updates.Rollback(ctx, request)
if err != nil {
return nil, providerError(err)
}
return marshalOperationResult(result)
})
return registry
}
+80
View File
@@ -25,6 +25,30 @@ func (f fakeProxmoxProvider) LXCFilesystems(ctx context.Context) (json.RawMessag
return f(ctx)
}
type fakeContainerProvider func(context.Context) (json.RawMessage, error)
func (f fakeContainerProvider) Inventory(ctx context.Context) (json.RawMessage, error) {
return f(ctx)
}
type fakeUpdateProvider struct {
stage func(context.Context, UpdateStageRequest) (UpdateStageResult, error)
activate func(context.Context, UpdateActivateRequest) (UpdateResult, error)
rollback func(context.Context, UpdateRollbackRequest) (UpdateResult, error)
}
func (f fakeUpdateProvider) Stage(ctx context.Context, request UpdateStageRequest) (UpdateStageResult, error) {
return f.stage(ctx, request)
}
func (f fakeUpdateProvider) Activate(ctx context.Context, request UpdateActivateRequest) (UpdateResult, error) {
return f.activate(ctx, request)
}
func (f fakeUpdateProvider) Rollback(ctx context.Context, request UpdateRollbackRequest) (UpdateResult, error) {
return f.rollback(ctx, request)
}
func authorizedResolver(uid uint32) PeerResolver {
return PeerResolverFunc(func(net.Conn) (Peer, error) {
return Peer{UID: uid, GID: 2000, PID: 3000}, nil
@@ -158,6 +182,62 @@ func TestServerDispatchesTypedProvidersWithoutCallerArguments(t *testing.T) {
requireErrorCode(t, exchange(t, server, request), ErrorInvalidRequest)
}
func TestServerDispatchesClosedContainerAndUpdateOperations(t *testing.T) {
containerCalled := false
stageCalled := false
activateCalled := false
rollbackCalled := false
updates := fakeUpdateProvider{
stage: func(_ context.Context, request UpdateStageRequest) (UpdateStageResult, error) {
stageCalled = request.ArtifactID == "release-1"
return UpdateStageResult{Action: "staged", ArtifactID: request.ArtifactID, SHA256: request.SHA256}, nil
},
activate: func(_ context.Context, request UpdateActivateRequest) (UpdateResult, error) {
activateCalled = request.ArtifactID == "release-1"
return UpdateResult{Action: "activated", ActivationID: "release-1:0123456789abcdef"}, nil
},
rollback: func(_ context.Context, request UpdateRollbackRequest) (UpdateResult, error) {
rollbackCalled = request.ActivationID == "release-1:0123456789abcdef"
return UpdateResult{Action: "rolled_back", ActivationID: request.ActivationID}, nil
},
}
registry := NewRegistryWithProviders(nil, nil, Providers{
Containers: fakeContainerProvider(func(context.Context) (json.RawMessage, error) {
containerCalled = true
return json.RawMessage(`{"runtimes":[]}`), nil
}),
Updates: updates,
})
server := newTestServer(t, registry, authorizedResolver(1000), nil)
if response := exchange(t, server, validRequest(OperationContainerInventory)); !response.Success || !containerCalled {
t.Fatalf("container response=%#v called=%t", response, containerCalled)
}
stage := validRequest(OperationAgentUpdateStage)
stage.Payload = json.RawMessage(`{"artifactId":"release-1","sha256":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}`)
if response := exchange(t, server, stage); !response.Success || !stageCalled {
t.Fatalf("stage response=%#v called=%t", response, stageCalled)
}
activate := validRequest(OperationAgentUpdateActivate)
activate.Payload = json.RawMessage(`{"artifactId":"release-1","sha256":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}`)
if response := exchange(t, server, activate); !response.Success || !activateCalled {
t.Fatalf("activate response=%#v called=%t", response, activateCalled)
}
rollback := validRequest(OperationAgentUpdateRollback)
rollback.Payload = json.RawMessage(`{"activationId":"release-1:0123456789abcdef","currentSha256":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","rollbackSha256":"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"}`)
if response := exchange(t, server, rollback); !response.Success || !rollbackCalled {
t.Fatalf("rollback response=%#v called=%t", response, rollbackCalled)
}
for _, operation := range []string{OperationContainerInventory, OperationAgentUpdateStage, OperationAgentUpdateActivate, OperationAgentUpdateRollback} {
request := validRequest(operation)
request.Payload = json.RawMessage(`{"path":"/tmp/attacker","args":["sh"]}`)
requireErrorCode(t, exchange(t, server, request), ErrorInvalidRequest)
request = validRequest(operation)
request.OperationVersion = 2
requireErrorCode(t, exchange(t, server, request), ErrorUnsupportedOperation)
}
}
func TestServerRejectsEnvelopeAndRegistryViolations(t *testing.T) {
server := newTestServer(t, NewRegistry(nil, nil), authorizedResolver(1000), nil)
tests := []struct {
+494
View File
@@ -0,0 +1,494 @@
package agenthelper
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const maxUpdateArtifactBytes = 100 * 1024 * 1024
type UpdateActivateRequest struct {
ArtifactID string `json:"artifactId"`
SHA256 string `json:"sha256"`
}
type UpdateStageRequest struct {
ArtifactID string `json:"artifactId"`
SHA256 string `json:"sha256"`
}
type UpdateStageResult struct {
Action string `json:"action"`
ArtifactID string `json:"artifactId"`
SHA256 string `json:"sha256"`
DurableAt time.Time `json:"durableAt"`
}
type UpdateRollbackRequest struct {
ActivationID string `json:"activationId"`
CurrentSHA256 string `json:"currentSha256"`
RollbackSHA256 string `json:"rollbackSha256"`
}
type UpdateResult struct {
Action string `json:"action"`
ActivationID string `json:"activationId"`
ActiveSHA256 string `json:"activeSha256"`
RollbackSHA256 string `json:"rollbackSha256"`
DurableAt time.Time `json:"durableAt"`
}
type UpdateActivatorConfig struct {
QuarantineDir string
StagingDir string
TargetPath string
StatePath string
VerifySignature func([]byte, string) error
ValidateOwner func(*os.File) error
ValidateQuarantineOwner func(*os.File) error
Now func() time.Time
}
type updateActivator struct {
mu sync.Mutex
stagingDir string
quarantineDir string
targetPath string
rollbackPath string
statePath string
verifySignature func([]byte, string) error
validateOwner func(*os.File) error
validateQuarantineOwner func(*os.File) error
now func() time.Time
}
type durableUpdateState struct {
Action string `json:"action"`
ActivationID string `json:"activationId"`
ActiveSHA256 string `json:"activeSha256"`
RollbackSHA256 string `json:"rollbackSha256"`
UpdatedAt time.Time `json:"updatedAt"`
}
func NewUpdateActivator(config UpdateActivatorConfig) (UpdateProvider, error) {
if config.VerifySignature == nil || config.ValidateOwner == nil || config.ValidateQuarantineOwner == nil {
return nil, errors.New("signature, root-ownership, and quarantine-ownership validators are required")
}
for _, path := range []string{config.QuarantineDir, config.StagingDir, config.TargetPath, config.StatePath} {
if path == "" || !filepath.IsAbs(path) || filepath.Clean(path) != path {
return nil, errors.New("update helper paths must be clean and absolute")
}
}
if config.QuarantineDir == config.StagingDir || strings.HasPrefix(config.QuarantineDir, config.StagingDir+string(os.PathSeparator)) || strings.HasPrefix(config.StagingDir, config.QuarantineDir+string(os.PathSeparator)) {
return nil, errors.New("update quarantine and staging must be separate")
}
if filepath.Dir(config.TargetPath) == config.StagingDir || strings.HasPrefix(config.TargetPath, config.StagingDir+string(os.PathSeparator)) {
return nil, errors.New("update target must be outside staging")
}
now := config.Now
if now == nil {
now = time.Now
}
return &updateActivator{
quarantineDir: config.QuarantineDir, stagingDir: config.StagingDir, targetPath: config.TargetPath,
rollbackPath: config.TargetPath + ".last-known-good", statePath: config.StatePath,
verifySignature: config.VerifySignature, validateOwner: config.ValidateOwner,
validateQuarantineOwner: config.ValidateQuarantineOwner, now: now,
}, nil
}
// Stage promotes exactly one signed collector-downloaded artifact from the
// fixed quarantine into the helper's fixed root-owned staging tree. The
// request carries identity only: no path, command, URL, or copy destination is
// caller-controlled.
func (u *updateActivator) Stage(ctx context.Context, request UpdateStageRequest) (UpdateStageResult, error) {
u.mu.Lock()
defer u.mu.Unlock()
if !validArtifactID(request.ArtifactID) || !validSHA256(request.SHA256) {
return UpdateStageResult{}, invalidArtifact("artifact identity or digest is invalid")
}
if err := u.validateDirectoryWith(u.quarantineDir, u.validateQuarantineOwner); err != nil {
return UpdateStageResult{}, invalidArtifact("update quarantine root is invalid")
}
sourceDir := filepath.Join(u.quarantineDir, request.ArtifactID)
if err := u.validateDirectoryWith(sourceDir, u.validateQuarantineOwner); err != nil {
return UpdateStageResult{}, invalidArtifact("quarantined artifact directory is invalid")
}
artifact, err := u.readOwnedBoundedWith(filepath.Join(sourceDir, "pulse-agent"), maxUpdateArtifactBytes, u.validateQuarantineOwner)
if err != nil {
return UpdateStageResult{}, invalidArtifact("quarantined artifact is unavailable or unsafe")
}
if len(artifact) < 4 || artifact[0] != 0x7f || string(artifact[1:4]) != "ELF" {
return UpdateStageResult{}, invalidArtifact("quarantined artifact is not a Linux executable")
}
signature, err := u.readOwnedBoundedWith(filepath.Join(sourceDir, "pulse-agent.sig"), 4096, u.validateQuarantineOwner)
if err != nil {
return UpdateStageResult{}, invalidArtifact("quarantined signature is unavailable or unsafe")
}
actual := sha256Hex(artifact)
if !strings.EqualFold(actual, request.SHA256) {
return UpdateStageResult{}, invalidArtifact("quarantined artifact digest does not match request")
}
if err := u.verifySignature(artifact, strings.TrimSpace(string(signature))); err != nil {
return UpdateStageResult{}, invalidArtifact("quarantined artifact signature is invalid")
}
if err := ctx.Err(); err != nil {
return UpdateStageResult{}, &ProviderError{Code: ErrorDeadlineExceeded, Message: "update staging deadline exceeded", Retryable: true}
}
if err := u.validateDirectory(u.stagingDir); err != nil {
return UpdateStageResult{}, invalidArtifact("update staging root is invalid")
}
destination := filepath.Join(u.stagingDir, request.ArtifactID)
if err := u.installStagedArtifact(destination, artifact, signature); err != nil {
return UpdateStageResult{}, &ProviderError{Code: ErrorInternal, Message: "promote quarantined artifact into root staging"}
}
return UpdateStageResult{Action: "staged", ArtifactID: request.ArtifactID, SHA256: actual, DurableAt: u.now().UTC()}, nil
}
func (u *updateActivator) Activate(ctx context.Context, request UpdateActivateRequest) (UpdateResult, error) {
u.mu.Lock()
defer u.mu.Unlock()
if !validArtifactID(request.ArtifactID) || !validSHA256(request.SHA256) {
return UpdateResult{}, invalidArtifact("artifact identity or digest is invalid")
}
if err := u.validateDirectory(u.stagingDir); err != nil {
return UpdateResult{}, invalidArtifact("update staging root is invalid")
}
artifactDir := filepath.Join(u.stagingDir, request.ArtifactID)
if err := u.validateDirectory(artifactDir); err != nil {
return UpdateResult{}, invalidArtifact("staged artifact directory is invalid")
}
artifact, err := u.readOwnedBounded(filepath.Join(artifactDir, "pulse-agent"), maxUpdateArtifactBytes)
if err != nil {
return UpdateResult{}, invalidArtifact("staged artifact is unavailable or unsafe")
}
if len(artifact) < 4 || artifact[0] != 0x7f || string(artifact[1:4]) != "ELF" {
return UpdateResult{}, invalidArtifact("staged artifact is not a Linux executable")
}
signatureBytes, err := u.readOwnedBounded(filepath.Join(artifactDir, "pulse-agent.sig"), 4096)
if err != nil {
return UpdateResult{}, invalidArtifact("staged signature is unavailable or unsafe")
}
actual := sha256Hex(artifact)
if !strings.EqualFold(actual, request.SHA256) {
return UpdateResult{}, invalidArtifact("staged artifact digest does not match request")
}
if err := u.verifySignature(artifact, strings.TrimSpace(string(signatureBytes))); err != nil {
return UpdateResult{}, invalidArtifact("staged artifact signature is invalid")
}
activationID := request.ArtifactID + ":" + actual[:16]
if state, err := u.readState(); err == nil && state.ActivationID == activationID {
if state.Action != "activated" || !strings.EqualFold(state.ActiveSHA256, actual) {
return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "artifact identity has already completed a different update transition"}
}
current, currentErr := u.readOwnedBounded(u.targetPath, maxUpdateArtifactBytes)
rollback, rollbackErr := u.readOwnedBounded(u.rollbackPath, maxUpdateArtifactBytes)
if currentErr != nil || rollbackErr != nil || !strings.EqualFold(sha256Hex(current), state.ActiveSHA256) || !strings.EqualFold(sha256Hex(rollback), state.RollbackSHA256) {
return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "durable activation no longer matches installed binaries"}
}
return UpdateResult{Action: state.Action, ActivationID: state.ActivationID, ActiveSHA256: state.ActiveSHA256, RollbackSHA256: state.RollbackSHA256, DurableAt: state.UpdatedAt}, nil
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "durable update state is invalid"}
}
if err := ctx.Err(); err != nil {
return UpdateResult{}, &ProviderError{Code: ErrorDeadlineExceeded, Message: "update activation deadline exceeded", Retryable: true}
}
current, err := u.readOwnedBounded(u.targetPath, maxUpdateArtifactBytes)
if err != nil {
return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "installed agent binary is unavailable or unsafe"}
}
rollbackDigest := sha256Hex(current)
if err := u.installDurably(artifact, current); err != nil {
return UpdateResult{}, &ProviderError{Code: ErrorInternal, Message: "activate staged agent binary"}
}
result := UpdateResult{
Action: "activated", ActivationID: activationID,
ActiveSHA256: actual, RollbackSHA256: rollbackDigest, DurableAt: u.now().UTC(),
}
if err := u.writeState(result); err != nil {
// Restore the pre-activation binary if the durable receipt cannot
// commit. A failed operation must not leave an unjournaled activation.
_ = u.installDurably(current, artifact)
return UpdateResult{}, &ProviderError{Code: ErrorInternal, Message: "persist activation result"}
}
return result, nil
}
func (u *updateActivator) Rollback(ctx context.Context, request UpdateRollbackRequest) (UpdateResult, error) {
u.mu.Lock()
defer u.mu.Unlock()
if !validActivationID(request.ActivationID) || !validSHA256(request.CurrentSHA256) || !validSHA256(request.RollbackSHA256) {
return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "rollback identity is invalid"}
}
state, err := u.readState()
if err != nil || state.Action != "activated" || state.ActivationID != request.ActivationID ||
!strings.EqualFold(state.ActiveSHA256, request.CurrentSHA256) ||
!strings.EqualFold(state.RollbackSHA256, request.RollbackSHA256) {
return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "rollback identity does not match the durable activation"}
}
current, err := u.readOwnedBounded(u.targetPath, maxUpdateArtifactBytes)
if err != nil || !strings.EqualFold(sha256Hex(current), state.ActiveSHA256) {
return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "installed agent identity changed after activation"}
}
rollback, err := u.readOwnedBounded(u.rollbackPath, maxUpdateArtifactBytes)
if err != nil || !strings.EqualFold(sha256Hex(rollback), state.RollbackSHA256) {
return UpdateResult{}, &ProviderError{Code: ErrorStateConflict, Message: "last-known-good identity changed after activation"}
}
if err := ctx.Err(); err != nil {
return UpdateResult{}, &ProviderError{Code: ErrorDeadlineExceeded, Message: "update rollback deadline exceeded", Retryable: true}
}
if err := u.installDurably(rollback, current); err != nil {
return UpdateResult{}, &ProviderError{Code: ErrorInternal, Message: "restore last-known-good agent binary"}
}
result := UpdateResult{
Action: "rolled_back", ActivationID: state.ActivationID,
ActiveSHA256: state.RollbackSHA256, RollbackSHA256: state.ActiveSHA256, DurableAt: u.now().UTC(),
}
if err := u.writeState(result); err != nil {
_ = u.installDurably(current, rollback)
return UpdateResult{}, &ProviderError{Code: ErrorInternal, Message: "persist rollback result"}
}
return result, nil
}
func (u *updateActivator) validateDirectory(path string) error {
return u.validateDirectoryWith(path, u.validateOwner)
}
func (u *updateActivator) validateDirectoryWith(path string, validate func(*os.File) error) error {
file, err := openFileNoFollow(path)
if err != nil {
return err
}
defer file.Close()
if err := validate(file); err != nil {
return err
}
info, err := file.Stat()
if err != nil || !info.IsDir() || info.Mode().Perm()&0o022 != 0 {
return errors.New("directory is not root-owned and immutable to non-root callers")
}
return nil
}
func (u *updateActivator) readOwnedBounded(path string, limit int64) ([]byte, error) {
return u.readOwnedBoundedWith(path, limit, u.validateOwner)
}
func (u *updateActivator) readOwnedBoundedWith(path string, limit int64, validate func(*os.File) error) ([]byte, error) {
before, err := os.Lstat(path)
if err != nil {
return nil, err
}
if before.Mode()&os.ModeSymlink != 0 || !before.Mode().IsRegular() {
return nil, errors.New("unsafe file type")
}
file, err := openFileNoFollow(path)
if err != nil {
return nil, err
}
defer file.Close()
if err := validate(file); err != nil {
return nil, err
}
after, err := file.Stat()
if err != nil || !os.SameFile(before, after) || !after.Mode().IsRegular() || after.Mode().Perm()&0o022 != 0 {
return nil, errors.New("file identity or ownership changed")
}
data, err := io.ReadAll(io.LimitReader(file, limit+1))
if err != nil || int64(len(data)) > limit {
return nil, errors.New("file exceeds bounded size")
}
return data, nil
}
func (u *updateActivator) installStagedArtifact(destination string, artifact, signature []byte) error {
if info, err := os.Lstat(destination); err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return errors.New("unsafe staged artifact destination")
}
if err := u.validateDirectory(destination); err != nil {
return err
}
current, binaryErr := u.readOwnedBounded(filepath.Join(destination, "pulse-agent"), maxUpdateArtifactBytes)
currentSignature, signatureErr := u.readOwnedBounded(filepath.Join(destination, "pulse-agent.sig"), 4096)
if binaryErr == nil && signatureErr == nil && strings.EqualFold(sha256Hex(current), sha256Hex(artifact)) && string(currentSignature) == string(signature) {
return nil
}
return errors.New("staged artifact identity conflict")
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
tempDir, err := os.MkdirTemp(u.stagingDir, ".pulse-agent-stage-*")
if err != nil {
return err
}
defer os.RemoveAll(tempDir)
if err := os.Chmod(tempDir, 0o700); err != nil {
return err
}
binaryTemp, err := writeSyncedTemp(tempDir, ".pulse-agent-*", artifact, 0o600)
if err != nil {
return err
}
if err := os.Rename(binaryTemp, filepath.Join(tempDir, "pulse-agent")); err != nil {
return err
}
signatureTemp, err := writeSyncedTemp(tempDir, ".pulse-agent.sig-*", signature, 0o600)
if err != nil {
return err
}
if err := os.Rename(signatureTemp, filepath.Join(tempDir, "pulse-agent.sig")); err != nil {
return err
}
if err := syncDirectory(tempDir); err != nil {
return err
}
if err := os.Rename(tempDir, destination); err != nil {
return err
}
return syncDirectory(u.stagingDir)
}
func (u *updateActivator) installDurably(active, lastKnownGood []byte) error {
targetDir := filepath.Dir(u.targetPath)
if err := u.validateDirectory(targetDir); err != nil {
return err
}
rollbackTemp, err := writeSyncedTemp(targetDir, ".pulse-agent-lkg-*", lastKnownGood, 0o755)
if err != nil {
return err
}
defer os.Remove(rollbackTemp)
activeTemp, err := writeSyncedTemp(targetDir, ".pulse-agent-active-*", active, 0o755)
if err != nil {
return err
}
defer os.Remove(activeTemp)
if err := os.Rename(rollbackTemp, u.rollbackPath); err != nil {
return err
}
if err := syncDirectory(targetDir); err != nil {
return err
}
if err := os.Rename(activeTemp, u.targetPath); err != nil {
return err
}
return syncDirectory(targetDir)
}
func (u *updateActivator) writeState(result UpdateResult) error {
state := durableUpdateState{Action: result.Action, ActivationID: result.ActivationID, ActiveSHA256: result.ActiveSHA256, RollbackSHA256: result.RollbackSHA256, UpdatedAt: result.DurableAt}
data, err := json.Marshal(state)
if err != nil {
return err
}
dir := filepath.Dir(u.statePath)
if err := u.validateDirectory(dir); err != nil {
return err
}
temp, err := writeSyncedTemp(dir, ".pulse-agent-update-state-*", data, 0o600)
if err != nil {
return err
}
defer os.Remove(temp)
if err := os.Rename(temp, u.statePath); err != nil {
return err
}
return syncDirectory(dir)
}
func (u *updateActivator) readState() (durableUpdateState, error) {
data, err := u.readOwnedBounded(u.statePath, 16*1024)
if err != nil {
return durableUpdateState{}, err
}
var state durableUpdateState
if err := decodeStrict(data, &state); err != nil {
return durableUpdateState{}, err
}
return state, nil
}
func writeSyncedTemp(dir, pattern string, data []byte, mode os.FileMode) (string, error) {
file, err := os.CreateTemp(dir, pattern)
if err != nil {
return "", err
}
name := file.Name()
ok := false
defer func() {
_ = file.Close()
if !ok {
_ = os.Remove(name)
}
}()
if err := file.Chmod(mode); err != nil {
return "", err
}
if _, err := file.Write(data); err != nil {
return "", err
}
if err := file.Sync(); err != nil {
return "", err
}
if err := file.Close(); err != nil {
return "", err
}
ok = true
return name, nil
}
func syncDirectory(path string) error {
dir, err := os.Open(path)
if err != nil {
return err
}
defer dir.Close()
return dir.Sync()
}
func sha256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func validArtifactID(value string) bool {
if value == "" || len(value) > 96 {
return false
}
for _, char := range value {
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '-' || char == '_' || char == '.' {
continue
}
return false
}
return value != "." && value != ".." && !strings.Contains(value, "..")
}
func validActivationID(value string) bool {
parts := strings.Split(value, ":")
return len(parts) == 2 && validArtifactID(parts[0]) && len(parts[1]) == 16 && isLowerHex(parts[1])
}
func validSHA256(value string) bool { return len(value) == 64 && isLowerHex(strings.ToLower(value)) }
func isLowerHex(value string) bool {
_, err := hex.DecodeString(value)
return err == nil
}
func invalidArtifact(message string) error {
return &ProviderError{Code: ErrorArtifactInvalid, Message: message}
}
var _ UpdateProvider = (*updateActivator)(nil)
@@ -0,0 +1,217 @@
package agenthelper
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func testUpdateActivator(t *testing.T, verifier func([]byte, string) error) (UpdateProvider, string, string, string) {
t.Helper()
root := t.TempDir()
quarantine := filepath.Join(root, "quarantine")
staging := filepath.Join(root, "staging")
targetDir := filepath.Join(root, "bin")
stateDir := filepath.Join(root, "state")
for _, path := range []string{quarantine, staging, targetDir, stateDir} {
if err := os.Mkdir(path, 0o700); err != nil {
t.Fatal(err)
}
}
target := filepath.Join(targetDir, "pulse-agent")
if err := os.WriteFile(target, testELF("old-signed-binary"), 0o755); err != nil {
t.Fatal(err)
}
provider, err := NewUpdateActivator(UpdateActivatorConfig{
QuarantineDir: quarantine, StagingDir: staging, TargetPath: target, StatePath: filepath.Join(stateDir, "activation.json"),
VerifySignature: verifier,
ValidateOwner: func(*os.File) error { return nil },
ValidateQuarantineOwner: func(*os.File) error { return nil },
Now: func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatal(err)
}
return provider, quarantine, target, filepath.Join(stateDir, "activation.json")
}
func testELF(body string) []byte {
return append([]byte{0x7f, 'E', 'L', 'F'}, []byte(body)...)
}
func stageUpdate(t *testing.T, staging, identity string, binary []byte) string {
t.Helper()
dir := filepath.Join(staging, identity)
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "pulse-agent"), binary, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "pulse-agent.sig"), []byte("valid-signature"), 0o600); err != nil {
t.Fatal(err)
}
return sha256Hex(binary)
}
func promoteUpdate(t *testing.T, provider UpdateProvider, artifactID, digest string) {
t.Helper()
result, err := provider.Stage(context.Background(), UpdateStageRequest{ArtifactID: artifactID, SHA256: digest})
if err != nil {
t.Fatalf("Stage: %v", err)
}
if result.Action != "staged" || result.ArtifactID != artifactID || result.SHA256 != digest {
t.Fatalf("stage result = %#v", result)
}
}
func TestUpdateActivationAndIdentityBoundRollbackAreDurable(t *testing.T) {
provider, staging, target, state := testUpdateActivator(t, func(data []byte, signature string) error {
if string(data) != string(testELF("new-signed-binary")) || signature != "valid-signature" {
return errors.New("signature mismatch")
}
return nil
})
newDigest := stageUpdate(t, staging, "release-1", testELF("new-signed-binary"))
promoteUpdate(t, provider, "release-1", newDigest)
oldDigest := sha256Hex(testELF("old-signed-binary"))
activated, err := provider.Activate(context.Background(), UpdateActivateRequest{ArtifactID: "release-1", SHA256: newDigest})
if err != nil {
t.Fatalf("Activate: %v", err)
}
if activated.ActiveSHA256 != newDigest || activated.RollbackSHA256 != oldDigest || activated.ActivationID == "" {
t.Fatalf("activation result = %#v", activated)
}
if installed, _ := os.ReadFile(target); string(installed) != string(testELF("new-signed-binary")) {
t.Fatalf("installed binary = %q", installed)
}
if info, err := os.Stat(state); err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("durable state mode=%v err=%v", info, err)
}
retried, err := provider.Activate(context.Background(), UpdateActivateRequest{ArtifactID: "release-1", SHA256: newDigest})
if err != nil || retried != activated {
t.Fatalf("idempotent activation retry = %#v err=%v, want %#v", retried, err, activated)
}
rolledBack, err := provider.Rollback(context.Background(), UpdateRollbackRequest{
ActivationID: activated.ActivationID, CurrentSHA256: newDigest, RollbackSHA256: oldDigest,
})
if err != nil {
t.Fatalf("Rollback: %v", err)
}
if rolledBack.ActiveSHA256 != oldDigest || rolledBack.RollbackSHA256 != newDigest {
t.Fatalf("rollback result = %#v", rolledBack)
}
if installed, _ := os.ReadFile(target); string(installed) != string(testELF("old-signed-binary")) {
t.Fatalf("rolled-back binary = %q", installed)
}
if _, err := provider.Rollback(context.Background(), UpdateRollbackRequest{
ActivationID: rolledBack.ActivationID, CurrentSHA256: rolledBack.ActiveSHA256, RollbackSHA256: rolledBack.RollbackSHA256,
}); err == nil {
t.Fatal("completed rollback replay reactivated the rejected binary")
}
}
func TestUpdateActivationRejectsInvalidSignatureAndExecutable(t *testing.T) {
provider, staging, _, _ := testUpdateActivator(t, func([]byte, string) error { return errors.New("untrusted") })
digest := stageUpdate(t, staging, "bad-signature", testELF("binary"))
if _, err := provider.Stage(context.Background(), UpdateStageRequest{ArtifactID: "bad-signature", SHA256: digest}); err == nil {
t.Fatal("invalid artifact signature accepted")
}
provider, staging, _, _ = testUpdateActivator(t, func([]byte, string) error { return nil })
digest = stageUpdate(t, staging, "not-elf", []byte("signed but not executable"))
if _, err := provider.Stage(context.Background(), UpdateStageRequest{ArtifactID: "not-elf", SHA256: digest}); err == nil {
t.Fatal("non-ELF artifact accepted")
}
}
func TestUpdateActivationRejectsTraversalSymlinksAndDigestMismatch(t *testing.T) {
provider, staging, target, _ := testUpdateActivator(t, func([]byte, string) error { return nil })
validDigest := stageUpdate(t, staging, "release-1", testELF("new"))
for _, request := range []UpdateActivateRequest{
{ArtifactID: "../release-1", SHA256: validDigest},
{ArtifactID: "release/1", SHA256: validDigest},
{ArtifactID: "release-1", SHA256: strings.Repeat("0", 64)},
} {
if _, err := provider.Stage(context.Background(), UpdateStageRequest{ArtifactID: request.ArtifactID, SHA256: request.SHA256}); err == nil {
t.Fatalf("unsafe activation accepted: %#v", request)
}
}
outside := filepath.Join(filepath.Dir(staging), "outside")
if err := os.WriteFile(outside, []byte("attacker"), 0o600); err != nil {
t.Fatal(err)
}
symlinkDir := filepath.Join(staging, "symlink-release")
if err := os.Mkdir(symlinkDir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(symlinkDir, "pulse-agent")); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(symlinkDir, "pulse-agent.sig"), []byte("valid-signature"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := provider.Stage(context.Background(), UpdateStageRequest{ArtifactID: "symlink-release", SHA256: sha256Hex([]byte("attacker"))}); err == nil {
t.Fatal("symlink artifact accepted")
}
if err := os.Remove(target); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, target); err != nil {
t.Fatal(err)
}
promoteUpdate(t, provider, "release-1", validDigest)
if _, err := provider.Activate(context.Background(), UpdateActivateRequest{ArtifactID: "release-1", SHA256: validDigest}); err == nil {
t.Fatal("symlink install target accepted")
}
}
func TestUpdateActivationUsesOpenedBytesAcrossStagingSwap(t *testing.T) {
var stagingBinary, target string
provider, staging, resolvedTarget, _ := testUpdateActivator(t, func(data []byte, _ string) error {
if err := os.Remove(stagingBinary); err != nil {
return err
}
if err := os.Symlink(target, stagingBinary); err != nil {
return err
}
if string(data) != string(testELF("verified-bytes")) {
return errors.New("unexpected verified data")
}
return nil
})
target = resolvedTarget
digest := stageUpdate(t, staging, "release-swap", testELF("verified-bytes"))
stagingBinary = filepath.Join(staging, "release-swap", "pulse-agent")
promoteUpdate(t, provider, "release-swap", digest)
if _, err := provider.Activate(context.Background(), UpdateActivateRequest{ArtifactID: "release-swap", SHA256: digest}); err != nil {
t.Fatalf("Activate: %v", err)
}
if installed, _ := os.ReadFile(target); string(installed) != string(testELF("verified-bytes")) {
t.Fatalf("symlink swap changed activated bytes: %q", installed)
}
}
func TestUpdateRollbackRejectsWrongIdentityAndChangedBinary(t *testing.T) {
provider, staging, target, _ := testUpdateActivator(t, func([]byte, string) error { return nil })
digest := stageUpdate(t, staging, "release-1", testELF("new"))
promoteUpdate(t, provider, "release-1", digest)
result, err := provider.Activate(context.Background(), UpdateActivateRequest{ArtifactID: "release-1", SHA256: digest})
if err != nil {
t.Fatal(err)
}
bad := UpdateRollbackRequest{ActivationID: "release-2:" + result.ActivationID[len(result.ActivationID)-16:], CurrentSHA256: result.ActiveSHA256, RollbackSHA256: result.RollbackSHA256}
if _, err := provider.Rollback(context.Background(), bad); err == nil {
t.Fatal("wrong rollback activation identity accepted")
}
if err := os.WriteFile(target, []byte("changed-after-activation"), 0o755); err != nil {
t.Fatal(err)
}
if _, err := provider.Rollback(context.Background(), UpdateRollbackRequest{ActivationID: result.ActivationID, CurrentSHA256: result.ActiveSHA256, RollbackSHA256: result.RollbackSHA256}); err == nil {
t.Fatal("rollback accepted changed active binary")
}
}
+6
View File
@@ -1542,6 +1542,12 @@ type errorReader struct {
sent bool
}
func TestCoveragePrivilegeHelperUpdateRejectsMissingSocket(t *testing.T) {
if _, err := NewPrivilegeHelperUpdate(""); err == nil {
t.Fatal("expected an empty helper socket path to fail closed")
}
}
func (e *errorReader) Read(p []byte) (int, error) {
if e.sent {
return 0, errors.New("read fail")
+147
View File
@@ -0,0 +1,147 @@
package agentupdate
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agenthelper"
)
const privilegedUpdateQuarantineDir = "/var/lib/pulse-agent/update-quarantine"
// PrivilegedUpdate is the updater's closed view of helper-backed activation.
// Implementations cannot select a target path, staging path, command, or URL.
type PrivilegedUpdate interface {
CreateQuarantinedArtifact() (artifactID string, file *os.File, cleanup func(), err error)
WriteQuarantinedSignature(artifactID, signature string) error
Stage(context.Context, string, string) (agenthelper.UpdateStageResult, error)
Activate(context.Context, string, string) (agenthelper.UpdateResult, error)
Rollback(context.Context, agenthelper.UpdateResult) (agenthelper.UpdateResult, error)
}
type privilegeHelperUpdate struct {
client *agenthelper.Client
quarantineDir string
}
func NewPrivilegeHelperUpdate(socketPath string) (PrivilegedUpdate, error) {
client, err := agenthelper.NewClient(agenthelper.ClientConfig{SocketPath: socketPath, MaxDeadline: 30 * time.Second})
if err != nil {
return nil, err
}
return &privilegeHelperUpdate{client: client, quarantineDir: privilegedUpdateQuarantineDir}, nil
}
func (p *privilegeHelperUpdate) CreateQuarantinedArtifact() (string, *os.File, func(), error) {
if err := validateCollectorQuarantineRoot(p.quarantineDir); err != nil {
return "", nil, func() {}, err
}
var nonce [16]byte
if _, err := rand.Read(nonce[:]); err != nil {
return "", nil, func() {}, fmt.Errorf("create update artifact identity: %w", err)
}
artifactID := "pulse-agent-" + hex.EncodeToString(nonce[:])
artifactDir := filepath.Join(p.quarantineDir, artifactID)
if err := os.Mkdir(artifactDir, 0o700); err != nil {
return "", nil, func() {}, fmt.Errorf("create quarantined artifact directory: %w", err)
}
cleanup := func() {
_ = os.Remove(filepath.Join(artifactDir, "pulse-agent"))
_ = os.Remove(filepath.Join(artifactDir, "pulse-agent.sig"))
_ = os.Remove(artifactDir)
}
file, err := os.OpenFile(filepath.Join(artifactDir, "pulse-agent"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
cleanup()
return "", nil, func() {}, fmt.Errorf("create quarantined agent binary: %w", err)
}
return artifactID, file, cleanup, nil
}
func (p *privilegeHelperUpdate) WriteQuarantinedSignature(artifactID, signature string) error {
if !validPrivilegedArtifactID(artifactID) {
return errors.New("invalid quarantined artifact identity")
}
signature = strings.TrimSpace(signature)
if signature == "" || len(signature) > 4096 {
return errors.New("invalid quarantined update signature")
}
path := filepath.Join(p.quarantineDir, artifactID, "pulse-agent.sig")
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("create quarantined update signature: %w", err)
}
if _, err := file.WriteString(signature + "\n"); err != nil {
_ = file.Close()
return fmt.Errorf("write quarantined update signature: %w", err)
}
if err := file.Sync(); err != nil {
_ = file.Close()
return fmt.Errorf("sync quarantined update signature: %w", err)
}
if err := file.Close(); err != nil {
return fmt.Errorf("close quarantined update signature: %w", err)
}
return syncUpdateDirectory(filepath.Dir(path))
}
func (p *privilegeHelperUpdate) Stage(ctx context.Context, artifactID, digest string) (agenthelper.UpdateStageResult, error) {
var result agenthelper.UpdateStageResult
_, err := p.client.Call(ctx, agenthelper.OperationAgentUpdateStage, agenthelper.OperationVersion1, 30*time.Second, agenthelper.UpdateStageRequest{ArtifactID: artifactID, SHA256: digest}, &result)
return result, err
}
func (p *privilegeHelperUpdate) Activate(ctx context.Context, artifactID, digest string) (agenthelper.UpdateResult, error) {
var result agenthelper.UpdateResult
_, err := p.client.Call(ctx, agenthelper.OperationAgentUpdateActivate, agenthelper.OperationVersion1, 30*time.Second, agenthelper.UpdateActivateRequest{ArtifactID: artifactID, SHA256: digest}, &result)
return result, err
}
func (p *privilegeHelperUpdate) Rollback(ctx context.Context, activation agenthelper.UpdateResult) (agenthelper.UpdateResult, error) {
var result agenthelper.UpdateResult
_, err := p.client.Call(ctx, agenthelper.OperationAgentUpdateRollback, agenthelper.OperationVersion1, 30*time.Second, agenthelper.UpdateRollbackRequest{
ActivationID: activation.ActivationID, CurrentSHA256: activation.ActiveSHA256, RollbackSHA256: activation.RollbackSHA256,
}, &result)
return result, err
}
func validateCollectorQuarantineRoot(path string) error {
if path == "" || !filepath.IsAbs(path) || filepath.Clean(path) != path {
return errors.New("collector update quarantine path is invalid")
}
info, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("inspect collector update quarantine: %w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() || info.Mode().Perm() != 0o700 {
return errors.New("collector update quarantine must be a real 0700 directory")
}
if !collectorQuarantineOwnedByCurrentUID(info) {
return errors.New("collector update quarantine must be owned by the collector UID")
}
return nil
}
func validPrivilegedArtifactID(value string) bool {
if !strings.HasPrefix(value, "pulse-agent-") || len(value) != len("pulse-agent-")+32 {
return false
}
_, err := hex.DecodeString(strings.TrimPrefix(value, "pulse-agent-"))
return err == nil
}
func syncUpdateDirectory(path string) error {
dir, err := os.Open(path)
if err != nil {
return err
}
defer dir.Close()
return dir.Sync()
}
@@ -0,0 +1,7 @@
//go:build !unix
package agentupdate
import "os"
func collectorQuarantineOwnedByCurrentUID(os.FileInfo) bool { return false }
@@ -0,0 +1,13 @@
//go:build unix
package agentupdate
import (
"os"
"syscall"
)
func collectorQuarantineOwnedByCurrentUID(info os.FileInfo) bool {
stat, ok := info.Sys().(*syscall.Stat_t)
return ok && stat.Uid == uint32(os.Geteuid())
}
@@ -0,0 +1,109 @@
package agentupdate
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agenthelper"
)
type fakePrivilegedUpdate struct {
root string
events []string
activation agenthelper.UpdateResult
}
func (f *fakePrivilegedUpdate) CreateQuarantinedArtifact() (string, *os.File, func(), error) {
f.events = append(f.events, "quarantine")
dir := filepath.Join(f.root, "pulse-agent-0123456789abcdef0123456789abcdef")
if err := os.Mkdir(dir, 0o700); err != nil {
return "", nil, func() {}, err
}
file, err := os.OpenFile(filepath.Join(dir, "pulse-agent"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
return filepath.Base(dir), file, func() { _ = os.RemoveAll(dir) }, err
}
func (f *fakePrivilegedUpdate) WriteQuarantinedSignature(_ string, signature string) error {
f.events = append(f.events, "signature:"+signature)
return nil
}
func (f *fakePrivilegedUpdate) Stage(_ context.Context, artifactID, digest string) (agenthelper.UpdateStageResult, error) {
f.events = append(f.events, "stage")
return agenthelper.UpdateStageResult{Action: "staged", ArtifactID: artifactID, SHA256: digest, DurableAt: time.Now()}, nil
}
func (f *fakePrivilegedUpdate) Activate(_ context.Context, _ string, digest string) (agenthelper.UpdateResult, error) {
f.events = append(f.events, "activate")
f.activation = agenthelper.UpdateResult{Action: "activated", ActivationID: "pulse-agent-0123456789abcdef0123456789abcdef:0123456789abcdef", ActiveSHA256: digest, RollbackSHA256: strings.Repeat("a", 64)}
return f.activation, nil
}
func (f *fakePrivilegedUpdate) Rollback(_ context.Context, activation agenthelper.UpdateResult) (agenthelper.UpdateResult, error) {
f.events = append(f.events, "rollback")
if activation != f.activation {
return agenthelper.UpdateResult{}, errors.New("wrong activation identity")
}
return agenthelper.UpdateResult{Action: "rolled_back", ActivationID: activation.ActivationID, ActiveSHA256: activation.RollbackSHA256, RollbackSHA256: activation.ActiveSHA256}, nil
}
func TestPrivilegedUpdateStagesActivatesAndRollsBackRestartFailure(t *testing.T) {
originalGOOS := runtimeGOOS
originalRestart := restartProcessFn
t.Cleanup(func() {
runtimeGOOS = originalGOOS
restartProcessFn = originalRestart
})
runtimeGOOS = goOSLinux
restartProcessFn = func(string) error { return errors.New("exec refused") }
binary := append([]byte{0x7f, 'E', 'L', 'F'}, bytes.Repeat([]byte("x"), 128)...)
sum := sha256.Sum256(binary)
digest := hex.EncodeToString(sum[:])
helper := &fakePrivilegedUpdate{root: t.TempDir()}
u := New(Config{PrivilegedUpdate: helper, Disabled: true})
u.selfTestFn = func(_ context.Context, path string) error {
helper.events = append(helper.events, "self-test")
data, err := os.ReadFile(path)
if err != nil || !bytes.Equal(data, binary) {
t.Fatalf("self-test bytes mismatch: err=%v", err)
}
return nil
}
err := u.performPrivilegedUpdate(context.Background(), "/usr/local/bin/pulse-agent", bytes.NewReader(binary), int64(len(binary)), digest, "signed-update")
if err == nil || !strings.Contains(err.Error(), "update rolled back") {
t.Fatalf("performPrivilegedUpdate error = %v", err)
}
want := []string{"quarantine", "self-test", "signature:signed-update", "stage", "activate", "rollback"}
if !reflect.DeepEqual(helper.events, want) {
t.Fatalf("helper events = %#v, want %#v", helper.events, want)
}
}
func TestPrivilegedUpdateFailsClosedBeforeActivation(t *testing.T) {
originalGOOS := runtimeGOOS
t.Cleanup(func() { runtimeGOOS = originalGOOS })
runtimeGOOS = goOSLinux
binary := append([]byte{0x7f, 'E', 'L', 'F'}, []byte("update")...)
helper := &fakePrivilegedUpdate{root: t.TempDir()}
u := New(Config{PrivilegedUpdate: helper, Disabled: true})
u.selfTestFn = func(context.Context, string) error { return nil }
err := u.performPrivilegedUpdate(context.Background(), "/usr/local/bin/pulse-agent", bytes.NewReader(binary), int64(len(binary)), strings.Repeat("0", 64), "signed-update")
if err == nil || !strings.Contains(err.Error(), "checksum mismatch") {
t.Fatalf("performPrivilegedUpdate error = %v", err)
}
if !reflect.DeepEqual(helper.events, []string{"quarantine"}) {
t.Fatalf("helper events = %#v, want quarantine only", helper.events)
}
}
+98
View File
@@ -154,6 +154,11 @@ type Config struct {
// Disabled skips all update checks when true
Disabled bool
// PrivilegedUpdate selects the typed helper-backed Linux activation path.
// When configured, update installation never falls back to local executable
// replacement.
PrivilegedUpdate PrivilegedUpdate
}
// Updater handles automatic updates for Pulse agents.
@@ -936,6 +941,9 @@ func (u *Updater) performUpdateWithExecPathForVersion(ctx context.Context, execP
// Verify checksum if provided
checksumHeader := strings.TrimSpace(resp.Header.Get(checksumSHA256Header))
signatureHeaderValue := strings.TrimSpace(resp.Header.Get(signatureHeader))
if u.cfg.PrivilegedUpdate != nil {
return u.performPrivilegedUpdate(ctx, execPath, resp.Body, resp.ContentLength, checksumHeader, signatureHeaderValue)
}
// Resolve symlinks to get the real path for atomic rename
realExecPath, err := evalSymlinksFn(execPath)
@@ -1067,6 +1075,96 @@ func (u *Updater) performUpdateWithExecPathForVersion(ctx context.Context, execP
return restartProcessFn(execPath)
}
func (u *Updater) performPrivilegedUpdate(ctx context.Context, execPath string, body io.Reader, contentLength int64, checksumHeader, signature string) error {
if runtimeGOOS != goOSLinux {
return errors.New("typed privilege-helper updates are supported only on Linux")
}
if checksumHeader == "" {
return fmt.Errorf("server did not provide checksum header (%s); refusing helper-backed update", checksumSHA256Header)
}
if signature == "" {
return fmt.Errorf("server did not provide signature header (%s); refusing helper-backed update", signatureHeader)
}
if contentLength > maxBinarySizeBytes {
return fmt.Errorf("downloaded binary exceeds maximum size (%d bytes)", maxBinarySizeBytes)
}
artifactID, file, cleanup, err := u.cfg.PrivilegedUpdate.CreateQuarantinedArtifact()
if err != nil {
return fmt.Errorf("prepare fixed update quarantine: %w", err)
}
defer cleanup()
artifactPath := file.Name()
hasher := sha256.New()
written, copyErr := io.Copy(file, io.TeeReader(io.LimitReader(body, maxBinarySizeBytes+1), hasher))
if copyErr != nil {
_ = file.Close()
return fmt.Errorf("write quarantined agent binary: %w", copyErr)
}
if written > maxBinarySizeBytes {
_ = file.Close()
return fmt.Errorf("downloaded binary exceeds maximum size (%d bytes)", maxBinarySizeBytes)
}
if err := file.Sync(); err != nil {
_ = file.Close()
return fmt.Errorf("sync quarantined agent binary: %w", err)
}
if err := file.Close(); err != nil {
return fmt.Errorf("close quarantined agent binary: %w", err)
}
digest := hex.EncodeToString(hasher.Sum(nil))
if !strings.EqualFold(strings.TrimSpace(checksumHeader), digest) {
return fmt.Errorf("checksum mismatch: expected %s, got %s", strings.ToLower(strings.TrimSpace(checksumHeader)), digest)
}
if err := verifyBinaryMagic(artifactPath); err != nil {
return fmt.Errorf("downloaded file is not a valid executable: %w", err)
}
if updatesignature.HasTrustedPublicKeys() {
if err := updatesignature.VerifyFile(artifactPath, signature); err != nil {
return fmt.Errorf("signature verification failed: %w", err)
}
}
if err := chmodFn(artifactPath, 0o700); err != nil {
return fmt.Errorf("make quarantined binary executable for self-test: %w", err)
}
if err := u.selfTestFn(ctx, artifactPath); err != nil {
return err
}
if err := chmodFn(artifactPath, 0o600); err != nil {
return fmt.Errorf("restore quarantined binary mode: %w", err)
}
if err := u.cfg.PrivilegedUpdate.WriteQuarantinedSignature(artifactID, signature); err != nil {
return err
}
staged, err := u.cfg.PrivilegedUpdate.Stage(ctx, artifactID, digest)
if err != nil {
return fmt.Errorf("stage signed update through typed helper: %w", err)
}
if staged.Action != "staged" || staged.ArtifactID != artifactID || !strings.EqualFold(staged.SHA256, digest) {
return errors.New("typed helper returned an invalid staging result")
}
activation, err := u.cfg.PrivilegedUpdate.Activate(ctx, artifactID, digest)
if err != nil {
return fmt.Errorf("activate signed update through typed helper: %w", err)
}
if activation.Action != "activated" || !strings.EqualFold(activation.ActiveSHA256, digest) || activation.ActivationID == "" || activation.RollbackSHA256 == "" {
return errors.New("typed helper returned an invalid activation result")
}
if err := restartProcessFn(execPath); err != nil {
rollbackCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
rolledBack, rollbackErr := u.cfg.PrivilegedUpdate.Rollback(rollbackCtx, activation)
if rollbackErr != nil {
return errors.Join(fmt.Errorf("failed to restart after helper activation: %w", err), fmt.Errorf("typed helper rollback failed: %w", rollbackErr))
}
if rolledBack.Action != "rolled_back" || !strings.EqualFold(rolledBack.ActiveSHA256, activation.RollbackSHA256) {
return errors.Join(fmt.Errorf("failed to restart after helper activation: %w", err), errors.New("typed helper returned an invalid rollback result"))
}
return fmt.Errorf("failed to restart after helper activation; update rolled back: %w", err)
}
return nil
}
func (u *Updater) syncPersistentBinaryCopy(execPath, persistPath, platform string) {
u.logger.Debug().Str("path", persistPath).Str("platform", platform).Msg("updating persistent binary")
+122
View File
@@ -0,0 +1,122 @@
package api
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
const maxActionRunnerCredentialRequestBytes int64 = 16 << 10
type actionRunnerCredentialRequest struct {
AgentID string `json:"agentId"`
Hostname string `json:"hostname"`
Name string `json:"name,omitempty"`
}
type actionRunnerCredentialResponse struct {
Token string `json:"token"`
TokenID string `json:"tokenId"`
OrganizationID string `json:"organizationId"`
AgentID string `json:"agentId"`
Hostname string `json:"hostname"`
RuntimeRole string `json:"runtimeRole"`
ActionCapability string `json:"actionCapability"`
}
// handleIssueActionRunnerCredential creates the separately scoped credential
// consumed by pulse-agent-runner. The route is operator-only; a monitoring
// collector credential cannot mint or upgrade itself into remediation
// authority.
func (r *Router) handleIssueActionRunnerCredential(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if r == nil || r.config == nil {
http.Error(w, "Action runner credential service unavailable", http.StatusServiceUnavailable)
return
}
decoder := json.NewDecoder(io.LimitReader(req.Body, maxActionRunnerCredentialRequestBytes+1))
decoder.DisallowUnknownFields()
var payload actionRunnerCredentialRequest
if err := decoder.Decode(&payload); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
organizationID := strings.TrimSpace(GetOrgID(req.Context()))
canonicalAgentID, canonicalHostname, found := r.resolveActionRunnerHostIdentity(req, payload.AgentID, payload.Hostname)
if !found {
http.Error(w, "Canonical monitored host identity not found", http.StatusNotFound)
return
}
rawToken, record, err := agenttokens.IssueActionRunnerAndPersist(r.config, r.persistence, agenttokens.ActionRunnerIssueOptions{
TokenName: payload.Name,
OrgID: organizationID,
OwnerUserID: apiTokenOwnerUserIDForRequest(r.config, req),
AgentID: canonicalAgentID,
Hostname: canonicalHostname,
})
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, agenttokens.ErrRecord) {
status = http.StatusBadRequest
}
http.Error(w, "Failed to issue action runner credential", status)
return
}
LogAuditEventForTenant(organizationID, "action_runner_credential_issued", auth.GetUser(req.Context()), GetClientIP(req), req.URL.Path, true, "Issued host-bound typed action runner credential")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(actionRunnerCredentialResponse{
Token: rawToken,
TokenID: record.ID,
OrganizationID: record.OrgID,
AgentID: record.Metadata["bound_agent_id"],
Hostname: record.Metadata["bound_hostname"],
RuntimeRole: record.Metadata[agenttokens.RuntimeRoleMetadataKey],
ActionCapability: record.Metadata[agenttokens.ActionCapabilityMetadataKey],
})
}
func (r *Router) resolveActionRunnerHostIdentity(req *http.Request, requestedID, requestedHostname string) (string, string, bool) {
requestedID = strings.TrimSpace(requestedID)
requestedHostname = strings.TrimSpace(requestedHostname)
if r == nil || r.unifiedAgentHandlers == nil || req == nil || requestedID == "" || requestedHostname == "" {
return "", "", false
}
monitor := r.unifiedAgentHandlers.getMonitor(req.Context())
if monitor == nil {
return "", "", false
}
var matchedID, matchedHostname string
matches := 0
for _, host := range monitor.GetLiveHostsSnapshot() {
if strings.TrimSpace(host.ID) != requestedID ||
!unifiedresources.HostnamesEquivalent(host.Hostname, requestedHostname) ||
strings.TrimSpace(host.IntegrationSource) != "" || host.IdentityConflict != nil {
continue
}
matchedID = strings.TrimSpace(host.ID)
matchedHostname = unifiedresources.NormalizeFullHostname(host.Hostname)
matches++
}
if matches != 1 || matchedID == "" || matchedHostname == "" {
return "", "", false
}
return matchedID, matchedHostname, true
}
@@ -0,0 +1,138 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func newActionRunnerCredentialTestRouter(t *testing.T) (*Router, *config.Config, string) {
t.Helper()
cfg := &config.Config{DataPath: t.TempDir(), AuthUser: "admin", AuthPass: "$2a$10$dummy"}
handlers, monitor := newUnifiedAgentHandlers(t, cfg)
hostID := seedUnifiedAgentHost(t, monitor)
return &Router{
config: cfg,
persistence: config.NewConfigPersistence(cfg.DataPath),
unifiedAgentHandlers: handlers,
}, cfg, hostID
}
func actionRunnerCredentialBody(hostID, hostname string) *bytes.Reader {
body, _ := json.Marshal(actionRunnerCredentialRequest{AgentID: hostID, Hostname: hostname})
return bytes.NewReader(body)
}
func TestIssueActionRunnerCredentialResolvesCanonicalMonitoredHost(t *testing.T) {
router, cfg, hostID := newActionRunnerCredentialTestRouter(t)
req := httptest.NewRequest(http.MethodPost, "/api/agents/action-runner/credential", actionRunnerCredentialBody(hostID, "HOST-1"))
rec := httptest.NewRecorder()
router.handleIssueActionRunnerCredential(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var response actionRunnerCredentialResponse
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.Token == "" || response.TokenID == "" || response.AgentID != hostID || response.Hostname != "host-1.local" {
t.Fatalf("response = %#v", response)
}
if response.RuntimeRole != agenttokens.CredentialKindActionRunner || response.ActionCapability != agenttokens.ActionCapabilityTypedV1 {
t.Fatalf("response authority = %#v", response)
}
if len(cfg.APITokens) != 1 || cfg.APITokens[0].HasScope(config.ScopeAgentReport) || !cfg.APITokens[0].HasScope(config.ScopeAgentExec) {
t.Fatalf("persisted action credential = %#v", cfg.APITokens)
}
}
func TestIssueActionRunnerCredentialRejectsUnknownOrMismatchedHost(t *testing.T) {
for _, tc := range []struct {
name string
agentID string
hostname string
}{
{name: "unknown id", agentID: "missing", hostname: "host-1.local"},
{name: "mismatched hostname", agentID: "machine-1", hostname: "other.local"},
} {
t.Run(tc.name, func(t *testing.T) {
router, cfg, hostID := newActionRunnerCredentialTestRouter(t)
agentID := tc.agentID
if agentID == "machine-1" {
agentID = hostID
}
req := httptest.NewRequest(http.MethodPost, "/api/agents/action-runner/credential", actionRunnerCredentialBody(agentID, tc.hostname))
rec := httptest.NewRecorder()
router.handleIssueActionRunnerCredential(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
if len(cfg.APITokens) != 0 {
t.Fatalf("rejected identity minted tokens: %#v", cfg.APITokens)
}
})
}
}
func TestActionRunnerCredentialRouteAuthSupportsAdminSessionAndScopedToken(t *testing.T) {
for _, mode := range []string{"api-token", "admin-session"} {
t.Run(mode, func(t *testing.T) {
router, cfg, hostID := newActionRunnerCredentialTestRouter(t)
handler := RequireAdmin(cfg, RequireScope(config.ScopeSettingsWrite, RequireScope(config.ScopeActionsExecute, router.handleIssueActionRunnerCredential)))
req := httptest.NewRequest(http.MethodPost, "/api/agents/action-runner/credential", actionRunnerCredentialBody(hostID, "host-1.local"))
switch mode {
case "api-token":
raw := "runner-issuer-token-1234567890.12345678"
record, err := config.NewAPITokenRecord(raw, "runner issuer", []string{config.ScopeSettingsWrite, config.ScopeActionsExecute})
if err != nil {
t.Fatal(err)
}
cfg.APITokens = append(cfg.APITokens, *record)
req.Header.Set("Authorization", "Bearer "+raw)
case "admin-session":
resetSessionStoreForTests()
t.Cleanup(resetSessionStoreForTests)
InitSessionStore(t.TempDir())
sessionToken := generateSessionToken()
GetSessionStore().CreateSession(sessionToken, time.Hour, "browser", "127.0.0.1", "admin")
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken})
}
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
})
}
}
func TestActionRunnerCredentialCannotUseCollectorReportOrConfigScopes(t *testing.T) {
router, cfg, hostID := newActionRunnerCredentialTestRouter(t)
req := httptest.NewRequest(http.MethodPost, "/api/agents/action-runner/credential", actionRunnerCredentialBody(hostID, "host-1.local"))
rec := httptest.NewRecorder()
router.handleIssueActionRunnerCredential(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("issue status = %d, body=%s", rec.Code, rec.Body.String())
}
var issued actionRunnerCredentialResponse
if err := json.NewDecoder(rec.Body).Decode(&issued); err != nil {
t.Fatal(err)
}
for _, scope := range []string{config.ScopeAgentReport, config.ScopeAgentConfigRead} {
called := false
handler := RequireAuth(cfg, RequireScope(scope, func(http.ResponseWriter, *http.Request) { called = true }))
authReq := httptest.NewRequest(http.MethodGet, "/api/agents/agent/test", nil)
authReq.Header.Set("Authorization", "Bearer "+issued.Token)
authRec := httptest.NewRecorder()
handler(authRec, authReq)
if called || authRec.Code != http.StatusForbidden {
t.Fatalf("action credential scope %q = called=%v status=%d body=%s", scope, called, authRec.Code, authRec.Body.String())
}
}
}
+60 -2
View File
@@ -6,6 +6,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/api/agentbinding"
"github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rs/zerolog/log"
)
@@ -130,6 +131,40 @@ func (r *Router) admitAgentExecToken(token string, agentID string, hostname stri
if len(orgs) == 1 && strings.TrimSpace(orgs[0]) != "" {
organizationID = strings.TrimSpace(orgs[0])
}
runtimeRole := strings.TrimSpace(record.Metadata[agenttokens.RuntimeRoleMetadataKey])
if runtimeRole == agenttokens.CredentialKindMonitoringCollector {
config.Mu.Unlock()
log.Warn().Str("token_id", tokenID).Msg("Monitoring collector credential rejected by action listener")
return agentexec.AgentAdmission{}, false
}
if runtimeRole == agenttokens.CredentialKindActionRunner {
if len(orgs) != 1 {
config.Mu.Unlock()
log.Warn().Str("token_id", tokenID).Msg("Action runner credential requires one explicit organization binding")
return agentexec.AgentAdmission{}, false
}
decision := agentbinding.EvaluateActionRunner(record, requestedID, requestedHost)
if !decision.Admit {
config.Mu.Unlock()
log.Warn().Str("token_id", tokenID).Msg("Action runner credential binding or capability mismatch")
return agentexec.AgentAdmission{}, false
}
capability := strings.TrimSpace(record.Metadata[agenttokens.ActionCapabilityMetadataKey])
config.Mu.Unlock()
return agentexec.AgentAdmission{
OrganizationID: organizationID,
TokenID: tokenID,
AgentID: requestedID,
Hostname: requestedHost,
RuntimeRole: agentexec.RuntimeRoleActionRunner,
ActionCapability: capability,
}, true
}
if runtimeRole != "" && runtimeRole != agenttokens.CredentialKindLegacyFullTrust {
config.Mu.Unlock()
log.Warn().Str("token_id", tokenID).Str("runtime_role", runtimeRole).Msg("Agent exec token has unsupported runtime role")
return agentexec.AgentAdmission{}, false
}
boundID := strings.TrimSpace(record.Metadata["bound_agent_id"])
boundHost := strings.TrimSpace(record.Metadata["bound_hostname"])
@@ -148,6 +183,7 @@ func (r *Router) admitAgentExecToken(token string, agentID string, hostname stri
"bound_hostname",
"bound_at",
agentExecBindingVersionKey,
agenttokens.RuntimeRoleMetadataKey,
)
record.Metadata["bound_agent_id"] = requestedID
// A bound_hostname already written by the Proxmox auto-register
@@ -159,6 +195,7 @@ func (r *Router) admitAgentExecToken(token string, agentID string, hostname stri
}
record.Metadata["bound_at"] = time.Now().UTC().Format(time.RFC3339)
record.Metadata[agentExecBindingVersionKey] = agentExecBindingVersion
record.Metadata[agenttokens.RuntimeRoleMetadataKey] = agenttokens.CredentialKindLegacyFullTrust
if r.persistence != nil {
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
restoreAgentExecMetadata(record.Metadata, previousMetadata)
@@ -184,6 +221,7 @@ func (r *Router) admitAgentExecToken(token string, agentID string, hostname stri
TokenID: tokenID,
AgentID: requestedID,
Hostname: requestedHost,
RuntimeRole: agentexec.RuntimeRoleLegacyFullTrust,
}, true
case decision.legacyMigrate:
@@ -193,10 +231,12 @@ func (r *Router) admitAgentExecToken(token string, agentID string, hostname stri
"bound_agent_id",
"bound_at",
agentExecBindingVersionKey,
agenttokens.RuntimeRoleMetadataKey,
)
record.Metadata["bound_agent_id"] = requestedID
record.Metadata["bound_at"] = time.Now().UTC().Format(time.RFC3339)
record.Metadata[agentExecBindingVersionKey] = agentExecBindingVersion
record.Metadata[agenttokens.RuntimeRoleMetadataKey] = agenttokens.CredentialKindLegacyFullTrust
if r.persistence != nil {
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
restoreAgentExecMetadata(record.Metadata, previousMetadata)
@@ -220,6 +260,7 @@ func (r *Router) admitAgentExecToken(token string, agentID string, hostname stri
TokenID: tokenID,
AgentID: requestedID,
Hostname: requestedHost,
RuntimeRole: agentexec.RuntimeRoleLegacyFullTrust,
}, true
case decision.admit:
@@ -230,8 +271,12 @@ func (r *Router) admitAgentExecToken(token string, agentID string, hostname stri
"bound_hostname",
"bound_at",
agentExecBindingVersionKey,
agenttokens.RuntimeRoleMetadataKey,
)
metadataChanged := false
metadataChanged := runtimeRole == ""
if runtimeRole == "" {
record.Metadata[agenttokens.RuntimeRoleMetadataKey] = agenttokens.CredentialKindLegacyFullTrust
}
if decision.backfillID {
record.Metadata["bound_agent_id"] = requestedID
boundID = requestedID
@@ -271,6 +316,7 @@ func (r *Router) admitAgentExecToken(token string, agentID string, hostname stri
TokenID: tokenID,
AgentID: requestedID,
Hostname: requestedHost,
RuntimeRole: agentexec.RuntimeRoleLegacyFullTrust,
}, true
}
@@ -317,7 +363,19 @@ func (r *Router) validateAgentExecSession(admission agentexec.AgentAdmission) bo
if len(orgs) == 1 && strings.TrimSpace(orgs[0]) != "" {
organizationID = strings.TrimSpace(orgs[0])
}
return organizationID == strings.TrimSpace(admission.OrganizationID) &&
runtimeRole := strings.TrimSpace(record.Metadata[agenttokens.RuntimeRoleMetadataKey])
if runtimeRole == agenttokens.CredentialKindActionRunner {
return len(orgs) == 1 &&
strings.TrimSpace(admission.RuntimeRole) == agentexec.RuntimeRoleActionRunner &&
strings.TrimSpace(admission.ActionCapability) == agentexec.ActionCapabilityTypedV1 &&
organizationID == strings.TrimSpace(admission.OrganizationID) &&
agentbinding.EvaluateActionRunner(record, requestedID, requestedHost).Admit
}
if runtimeRole != agenttokens.CredentialKindLegacyFullTrust {
return false
}
return strings.TrimSpace(admission.RuntimeRole) == agentexec.RuntimeRoleLegacyFullTrust &&
organizationID == strings.TrimSpace(admission.OrganizationID) &&
strings.TrimSpace(record.Metadata["bound_agent_id"]) == requestedID &&
agentExecHostnamesMatch(strings.TrimSpace(record.Metadata["bound_hostname"]), requestedHost)
}
+23
View File
@@ -4,6 +4,7 @@ package agentbinding
import (
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
@@ -24,6 +25,28 @@ type Decision struct {
BackfillHost bool
}
// EvaluateActionRunner admits only a pre-bound, typed action credential whose
// tenant-independent host identity exactly matches the registering runner.
// Action credentials are never first-use rebound or legacy-migrated.
func EvaluateActionRunner(record *config.APITokenRecord, requestedID, requestedHost string) Decision {
if record == nil || !record.HasScope(config.ScopeAgentExec) {
return Decision{}
}
if strings.TrimSpace(record.Metadata[agenttokens.RuntimeRoleMetadataKey]) != agenttokens.CredentialKindActionRunner ||
strings.TrimSpace(record.Metadata[agenttokens.ActionCapabilityMetadataKey]) != agenttokens.ActionCapabilityTypedV1 ||
strings.TrimSpace(record.Metadata[agenttokens.ActionBindingVersionMetadataKey]) != agenttokens.ActionBindingVersion {
return Decision{}
}
boundID := strings.TrimSpace(record.Metadata["bound_agent_id"])
boundHost := strings.TrimSpace(record.Metadata["bound_hostname"])
requestedID = strings.TrimSpace(requestedID)
requestedHost = strings.TrimSpace(requestedHost)
if boundID == "" || boundHost == "" || requestedID == "" || requestedHost == "" {
return Decision{}
}
return Decision{Admit: boundID == requestedID && hostnamesMatch(boundHost, requestedHost)}
}
func Evaluate(record *config.APITokenRecord, requestedID, requestedHost string) Decision {
if record == nil {
return Decision{}
+37
View File
@@ -3,6 +3,7 @@ package agentbinding
import (
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
@@ -37,3 +38,39 @@ func TestCanBindInstallTokenRejectsUnsupportedIssuer(t *testing.T) {
t.Fatal("unsupported issuer admitted")
}
}
func TestEvaluateActionRunnerRequiresExactTypedPrebinding(t *testing.T) {
record := &config.APITokenRecord{
OrgID: "org-a",
Scopes: []string{config.ScopeAgentExec},
Metadata: map[string]string{
agenttokens.RuntimeRoleMetadataKey: agenttokens.CredentialKindActionRunner,
agenttokens.ActionCapabilityMetadataKey: agenttokens.ActionCapabilityTypedV1,
agenttokens.ActionBindingVersionMetadataKey: agenttokens.ActionBindingVersion,
"bound_agent_id": "machine-a",
"bound_hostname": "node.example",
},
}
if decision := EvaluateActionRunner(record, "machine-a", "NODE"); !decision.Admit || decision.FirstBind || decision.LegacyMigrate {
t.Fatalf("typed action runner decision = %+v", decision)
}
for _, mutate := range []func(map[string]string){
func(metadata map[string]string) {
metadata[agenttokens.RuntimeRoleMetadataKey] = agenttokens.CredentialKindMonitoringCollector
},
func(metadata map[string]string) { metadata[agenttokens.ActionCapabilityMetadataKey] = "shell.v1" },
func(metadata map[string]string) { metadata[agenttokens.ActionBindingVersionMetadataKey] = "2" },
func(metadata map[string]string) { metadata["bound_agent_id"] = "other" },
func(metadata map[string]string) { metadata["bound_hostname"] = "other.example" },
} {
clone := record.Clone()
clone.Metadata = make(map[string]string, len(record.Metadata))
for key, value := range record.Metadata {
clone.Metadata[key] = value
}
mutate(clone.Metadata)
if decision := EvaluateActionRunner(&clone, "machine-a", "node.example"); decision.Admit {
t.Fatalf("mismatched action runner admitted: metadata=%#v decision=%+v", clone.Metadata, decision)
}
}
}
+106
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
@@ -18,6 +19,15 @@ const (
CommandPolicyAppliedAgentIDMetadataKey = "command_policy_applied_agent_id"
CommandPolicyIntentEnabled = "enabled"
CommandPolicyIntentDisabled = "disabled"
RuntimeRoleMetadataKey = "runtime_role"
CredentialKindMetadataKey = RuntimeRoleMetadataKey
CredentialKindMonitoringCollector = "monitoring-collector"
CredentialKindActionRunner = "action-runner"
CredentialKindLegacyFullTrust = "legacy-full-trust"
ActionCapabilityMetadataKey = "agent_action_capability"
ActionCapabilityTypedV1 = "typed_actions.v1"
ActionBindingVersionMetadataKey = "action_runner_binding_version"
ActionBindingVersion = "1"
)
var (
@@ -34,6 +44,17 @@ type IssueOptions struct {
Scopes []string
}
// ActionRunnerIssueOptions binds a separately issued remediation credential to
// one tenant and one canonical host identity. The credential is intentionally
// unusable for collector report and configuration endpoints.
type ActionRunnerIssueOptions struct {
TokenName string
OrgID string
OwnerUserID string
AgentID string
Hostname string
}
func ProxmoxScopes(enableCommands bool) []string {
scopes := []string{
config.ScopeAgentReport,
@@ -60,6 +81,12 @@ func HostScopes(enableCommands bool) []string {
return scopes
}
// ActionRunnerScopes is the complete authority granted to the separate action
// runtime. Monitoring scopes must be added only by a distinct, explicit grant.
func ActionRunnerScopes() []string {
return []string{config.ScopeAgentExec}
}
func CommandPolicyIntent(enableCommands bool) string {
if enableCommands {
return CommandPolicyIntentEnabled
@@ -111,6 +138,9 @@ func IssueAndPersist(cfg *config.Config, persistence *config.ConfigPersistence,
if record.Metadata == nil {
record.Metadata = make(map[string]string)
}
if err := normalizeCredentialKind(record); err != nil {
return "", nil, fmt.Errorf("%w: %w", ErrRecord, err)
}
record.Metadata[IssuedAtMetadataKey] = record.CreatedAt.UTC().Format(time.RFC3339)
config.Mu.Lock()
@@ -133,6 +163,82 @@ func IssueAndPersist(cfg *config.Config, persistence *config.ConfigPersistence,
return rawToken, record, nil
}
// IssueActionRunnerAndPersist mints the host-bound credential used by the
// separate action runner. It fails closed on missing tenant/host identity and
// never grants collector report, lookup, configuration, or management scopes.
func IssueActionRunnerAndPersist(cfg *config.Config, persistence *config.ConfigPersistence, opts ActionRunnerIssueOptions) (string, *config.APITokenRecord, error) {
agentID := strings.TrimSpace(opts.AgentID)
hostname := unifiedresources.NormalizeFullHostname(opts.Hostname)
organizationID := strings.TrimSpace(opts.OrgID)
if organizationID == "" {
return "", nil, fmt.Errorf("%w: organization id is required", ErrRecord)
}
if agentID == "" {
return "", nil, fmt.Errorf("%w: canonical agent id is required", ErrRecord)
}
if hostname == "" {
return "", nil, fmt.Errorf("%w: canonical hostname is required", ErrRecord)
}
if len(agentID) > 128 || len(hostname) > 253 {
return "", nil, fmt.Errorf("%w: action runner identity exceeds maximum length", ErrRecord)
}
tokenName := strings.TrimSpace(opts.TokenName)
if tokenName == "" {
tokenName = "action-runner:" + hostname
}
return IssueAndPersist(cfg, persistence, IssueOptions{
TokenName: tokenName,
OrgID: organizationID,
OwnerUserID: opts.OwnerUserID,
Scopes: ActionRunnerScopes(),
Metadata: map[string]string{
CredentialKindMetadataKey: CredentialKindActionRunner,
ActionCapabilityMetadataKey: ActionCapabilityTypedV1,
ActionBindingVersionMetadataKey: ActionBindingVersion,
"bound_agent_id": agentID,
"bound_hostname": hostname,
"bound_at": time.Now().UTC().Format(time.RFC3339),
},
})
}
func normalizeCredentialKind(record *config.APITokenRecord) error {
if record == nil {
return nil
}
kind := strings.TrimSpace(record.Metadata[CredentialKindMetadataKey])
hasExec := record.HasScope(config.ScopeAgentExec)
switch kind {
case "":
if hasExec {
// Existing combined collector/command issuance remains available only
// as an explicit compatibility class while deployments migrate.
record.Metadata[CredentialKindMetadataKey] = CredentialKindLegacyFullTrust
} else {
record.Metadata[CredentialKindMetadataKey] = CredentialKindMonitoringCollector
}
case CredentialKindMonitoringCollector:
if hasExec {
return errors.New("monitoring collector credential cannot carry agent:exec")
}
case CredentialKindActionRunner:
if !hasExec {
return errors.New("action runner credential requires agent:exec")
}
if strings.TrimSpace(record.Metadata[ActionCapabilityMetadataKey]) != ActionCapabilityTypedV1 {
return errors.New("action runner credential requires the typed action capability")
}
case CredentialKindLegacyFullTrust:
if !hasExec {
return errors.New("legacy full-trust credential requires agent:exec")
}
default:
return fmt.Errorf("unsupported agent credential kind %q", kind)
}
return nil
}
func OwnerUserID(record config.APITokenRecord) string {
return strings.TrimSpace(record.Metadata[OwnerUserIDMetadataKey])
}
+53
View File
@@ -30,6 +30,59 @@ func TestIssueAndPersistInstallToken(t *testing.T) {
if !record.HasScope(config.ScopeAgentExec) {
t.Fatalf("commands-enabled host scopes = %v", record.Scopes)
}
if got := record.Metadata[RuntimeRoleMetadataKey]; got != CredentialKindLegacyFullTrust {
t.Fatalf("combined install runtime role = %q, want %q", got, CredentialKindLegacyFullTrust)
}
}
func TestIssueActionRunnerAndPersistIsHostBoundAndExecOnly(t *testing.T) {
cfg := &config.Config{DataPath: t.TempDir()}
raw, record, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{
OrgID: "org-a", AgentID: "machine-123", Hostname: " Node.EXAMPLE. ", OwnerUserID: "operator",
})
if err != nil {
t.Fatalf("IssueActionRunnerAndPersist: %v", err)
}
if raw == "" || record == nil {
t.Fatalf("issued action credential = (%q, %#v)", raw, record)
}
if len(record.Scopes) != 1 || record.Scopes[0] != config.ScopeAgentExec {
t.Fatalf("action credential scopes = %v, want only %q", record.Scopes, config.ScopeAgentExec)
}
if record.HasScope(config.ScopeAgentReport) || record.HasScope(config.ScopeAgentConfigRead) || record.HasScope(config.ScopeAgentManage) {
t.Fatalf("action credential inherited collector authority: %v", record.Scopes)
}
if record.OrgID != "org-a" || record.Metadata["bound_agent_id"] != "machine-123" || record.Metadata["bound_hostname"] != "node.example" {
t.Fatalf("action credential binding = org=%q metadata=%#v", record.OrgID, record.Metadata)
}
if record.Metadata[RuntimeRoleMetadataKey] != CredentialKindActionRunner ||
record.Metadata[ActionCapabilityMetadataKey] != ActionCapabilityTypedV1 ||
record.Metadata[ActionBindingVersionMetadataKey] != ActionBindingVersion {
t.Fatalf("action credential authority metadata = %#v", record.Metadata)
}
}
func TestIssueActionRunnerAndPersistRejectsIncompleteBinding(t *testing.T) {
for _, tc := range []ActionRunnerIssueOptions{
{AgentID: "machine", Hostname: "node"},
{OrgID: "org", Hostname: "node"},
{OrgID: "org", AgentID: "machine"},
} {
if raw, record, err := IssueActionRunnerAndPersist(&config.Config{}, nil, tc); !errors.Is(err, ErrRecord) || raw != "" || record != nil {
t.Fatalf("incomplete binding %#v = (%q, %#v, %v), want ErrRecord", tc, raw, record, err)
}
}
}
func TestIssueAndPersistRejectsMonitoringRoleWithExecAuthority(t *testing.T) {
_, _, err := IssueAndPersist(&config.Config{}, nil, IssueOptions{
TokenName: "invalid",
Scopes: []string{config.ScopeAgentReport, config.ScopeAgentExec},
Metadata: map[string]string{RuntimeRoleMetadataKey: CredentialKindMonitoringCollector},
})
if !errors.Is(err, ErrRecord) {
t.Fatalf("monitoring role with exec authority error = %v, want ErrRecord", err)
}
}
func TestProxmoxScopesRequireExplicitCommandAuthority(t *testing.T) {
+34
View File
@@ -10255,6 +10255,40 @@ func TestContract_AgentHelperDownloadIsLinuxOnlyAndSeparatelySigned(t *testing.T
}
}
func TestContract_AgentRunnerDownloadIsLinuxOnlyAndSeparatelySigned(t *testing.T) {
tempDir := t.TempDir()
binDir := filepath.Join(tempDir, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
binaryPath := filepath.Join(binDir, "pulse-agent-runner-linux-amd64")
if err := os.WriteFile(binaryPath, []byte("typed-runner"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(binaryPath+".sig", []byte("detached-signature"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(binaryPath+".sshsig", []byte("ssh-signature"), 0o644); err != nil {
t.Fatal(err)
}
router := &Router{projectRoot: tempDir, serverVersion: "6.5.0"}
request := httptest.NewRequest(http.MethodGet, "/download/pulse-agent-runner?arch=linux-amd64", nil)
response := httptest.NewRecorder()
router.handleDownloadAgentRunner(response, request)
if response.Code != http.StatusOK {
t.Fatalf("runner download status = %d: %s", response.Code, response.Body.String())
}
if response.Header().Get(checksumHeaderName) == "" || response.Header().Get(sshSignatureHeaderName) == "" {
t.Fatalf("runner download omitted signed-asset headers: %#v", response.Header())
}
request = httptest.NewRequest(http.MethodGet, "/download/pulse-agent-runner?arch=windows-amd64", nil)
response = httptest.NewRecorder()
router.handleDownloadAgentRunner(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("non-Linux runner status = %d, want %d", response.Code, http.StatusBadRequest)
}
}
func TestContract_SystemSettingsResponseJSONSnapshot(t *testing.T) {
payload := EmptySystemSettingsResponse()
payload.SystemSettings = config.SystemSettings{
+94 -1
View File
@@ -9,6 +9,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/actionlifecycle"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/operationreceipt"
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
@@ -52,6 +53,10 @@ type proxmoxGuestPostconditionObserver interface {
ObserveProxmoxGuest(context.Context, string, string, string, int, proxmoxGuestKind) (proxmoxGuestPostconditionObservation, error)
}
type proxmoxGuestLifecycleAgentCommander interface {
ExecuteProxmoxGuestLifecycle(context.Context, string, agentexec.ProxmoxGuestLifecyclePayload) (*agentexec.ProxmoxGuestLifecycleResultPayload, error)
}
func newProxmoxGuestActionExecutor(resources *ResourceHandlers, agents actionAgentCommander, observer proxmoxGuestPostconditionObserver) ActionExecutor {
if resources == nil || agents == nil {
return nil
@@ -59,10 +64,34 @@ func newProxmoxGuestActionExecutor(resources *ResourceHandlers, agents actionAge
return proxmoxGuestActionExecutor{resources: resources, agents: agents, observer: observer}
}
func (e proxmoxGuestActionExecutor) BindActionDispatch(ctx context.Context, record unified.ActionAuditRecord, attempt unified.ActionDispatchAttempt) (unified.ActionDispatchAttempt, error) {
record, err := unified.NormalizeActionAuditRecord(record)
if err != nil {
return unified.ActionDispatchAttempt{}, err
}
resource, kind, err := e.currentProxmoxGuestResource(ctx, record.Request.ResourceID, record.Request.CapabilityName)
if err != nil {
return unified.ActionDispatchAttempt{}, err
}
agentID, err := e.connectedProxmoxNodeCommandAgentID(ctx, resource)
if err != nil {
return unified.ActionDispatchAttempt{}, err
}
request, err := proxmoxGuestLifecycleRequest(attempt.ID, record.ID, string(kind), record.Request.CapabilityName, resource)
if err != nil {
return unified.ActionDispatchAttempt{}, err
}
return unified.BindActionDispatchAttempt(attempt, unified.ActionDispatchBinding{OperationKind: request.Operation, OperationVersion: request.OperationVersion, RequestDigest: request.RequestDigest, AgentID: agentID})
}
func (e proxmoxGuestActionExecutor) ActionHandlerNames() []string {
return []string{proxmoxVMLifecycleHandler, proxmoxCTLifecycleHandler}
}
func (e proxmoxGuestActionExecutor) ActionDispatchOperationKinds() []string {
return []string{"start", "stop", "shutdown", "reboot"}
}
func (e proxmoxGuestActionExecutor) ExecuteAction(ctx context.Context, record unified.ActionAuditRecord) (*unified.ExecutionResult, error) {
attempt, ok := actionlifecycle.DispatchAttemptFromContext(ctx)
if !ok || attempt.ActionID != record.ID {
@@ -93,8 +122,46 @@ func (e proxmoxGuestActionExecutor) ExecuteAction(ctx context.Context, record un
}
}
command := proxmoxGuestLifecycleCommand(kind, operation, vmid)
actionStartedAt := time.Now().UTC()
if typedAgents, ok := e.agents.(proxmoxGuestLifecycleAgentCommander); ok {
request, err := proxmoxGuestLifecycleRequest(attempt.ID, record.ID, string(kind), operation, resource)
if err != nil {
return nil, err
}
if attempt.OperationKind != "" && agentexec.ProxmoxGuestLifecycleOperationIdentity(agentID, request) != (operationreceipt.Identity{AttemptID: attempt.ID, ActionID: attempt.ActionID, OperationKind: attempt.OperationKind, OperationVersion: attempt.OperationVersion, RequestDigest: attempt.RequestDigest, AgentID: attempt.AgentID}) {
return nil, fmt.Errorf("Proxmox guest lifecycle dispatch binding drift")
}
result, err := typedAgents.ExecuteProxmoxGuestLifecycle(agentCommandContext(ctx), agentID, request)
if err != nil {
return nil, err
}
if result == nil {
return nil, fmt.Errorf("typed Proxmox guest lifecycle returned no result")
}
if err := agentexec.ValidateProxmoxGuestLifecycleResultForRequest(request, *result); err != nil {
return nil, fmt.Errorf("invalid typed Proxmox guest lifecycle result: %w", err)
}
succeeded := result.ExecutionPhase == agentexec.ProxmoxGuestPhaseComplete && result.MutationCompleted && result.Error == ""
exitCode := 0
if !succeeded {
exitCode = 1
}
output := ""
if result.ReadbackRan {
output = "status: " + result.After.Status
}
agentVerification := &unified.ActionVerificationResult{
Ran: result.ReadbackRan, Command: proxmoxGuestStatusCommand(kind, vmid), Output: output,
Success: succeeded, RanAt: result.After.ObservedAt,
}
independentAfter, independentEvaluation := e.observeProxmoxGuestPostcondition(ctx, record.Request.ResourceID, resource, kind, operation, independentBefore, actionStartedAt)
return proxmoxGuestExecutionResult(record.ID, record.Request.ResourceID, agentID, kind, operation, exitCode, output, result.Error, agentVerification, independentBefore, independentAfter, independentEvaluation, actionStartedAt)
}
// Legacy full-trust sessions retain the historical command boundary during
// migration. Typed action-runner sessions can never enter this fallback:
// the server rejects execute_command for that runtime role.
command := proxmoxGuestLifecycleCommand(kind, operation, vmid)
result, err := e.agents.ExecuteCommand(agentCommandContext(ctx), agentID, agentexec.ExecuteCommandPayload{
RequestID: attempt.ID,
Command: command,
@@ -117,6 +184,32 @@ func (e proxmoxGuestActionExecutor) ExecuteAction(ctx context.Context, record un
return proxmoxGuestExecutionResult(record.ID, record.Request.ResourceID, agentID, kind, operation, result.ExitCode, output, result.Error, agentVerification, independentBefore, independentAfter, independentEvaluation, actionStartedAt)
}
func proxmoxGuestLifecycleRequest(attemptID, actionID, kind, operation string, resource unified.Resource) (agentexec.ProxmoxGuestLifecyclePayload, error) {
expectedStatus := ""
if resource.Proxmox != nil {
expectedStatus = strings.ToLower(strings.TrimSpace(resource.Proxmox.RuntimeStatus))
}
if expectedStatus == "" {
switch resource.Status {
case unified.StatusOnline:
expectedStatus = "running"
case unified.StatusOffline:
expectedStatus = "stopped"
}
}
if expectedStatus != "running" && expectedStatus != "stopped" {
return agentexec.ProxmoxGuestLifecyclePayload{}, fmt.Errorf("Proxmox guest lifecycle requires a current running or stopped state")
}
request := agentexec.ProxmoxGuestLifecyclePayload{
RequestID: attemptID, ActionID: actionID, Operation: operation, GuestKind: kind,
VMID: resource.Proxmox.VMID, ExpectedStatus: expectedStatus, Timeout: proxmoxGuestLifecycleTimeout(operation),
}
if err := agentexec.BindProxmoxGuestLifecyclePayload(&request); err != nil {
return agentexec.ProxmoxGuestLifecyclePayload{}, err
}
return request, nil
}
func (e proxmoxGuestActionExecutor) CheckActionAvailable(ctx context.Context, req unified.ActionRequest, resource unified.Resource) unified.ResourceActionReadiness {
operation := strings.TrimSpace(req.CapabilityName)
if _, ok := findProxmoxLifecycleCapability(resource.Capabilities, operation); !ok {
+7
View File
@@ -290,6 +290,8 @@ var publicRouteAllowlist = []string{
"/install.sh",
"/install.ps1",
"/download/pulse-agent",
"/download/pulse-agent-helper",
"/download/pulse-agent-runner",
}
var bareRouteAllowlist = []string{
@@ -346,6 +348,8 @@ var bareRouteAllowlist = []string{
"/api/system/verify-temperature-ssh",
"/api/version",
"/download/pulse-agent",
"/download/pulse-agent-helper",
"/download/pulse-agent-runner",
"/install.ps1",
"/install.sh",
"/api/public/signup",
@@ -375,6 +379,7 @@ var allRouteAllowlist = []string{
"/api/logs/level",
"/api/agents/docker/report",
"/api/agents/kubernetes/report",
"/api/agents/action-runner/credential",
"/api/agents/agent/report",
"/api/agents/host/report",
"/api/agents/agent/lookup",
@@ -702,6 +707,8 @@ var allRouteAllowlist = []string{
"/install.sh",
"/install.ps1",
"/download/pulse-agent",
"/download/pulse-agent-helper",
"/download/pulse-agent-runner",
"/api/agent/version",
"/api/server/info",
"/ws",
+2
View File
@@ -4474,6 +4474,8 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
"/install.sh", // Unified agent installer
"/install.ps1", // Unified agent Windows installer
"/download/pulse-agent", // Unified agent binary
"/download/pulse-agent-helper", // Typed privileged helper binary
"/download/pulse-agent-runner", // Typed action runner binary
"/api/agent/capabilities", // Agent-paradigm discovery manifest; underlying capabilities keep their own auth scopes
"/api/agent/version", // Agent update checks need to work before auth
"/api/agent/ws", // Agent WebSocket has its own auth via registration
@@ -70,10 +70,14 @@ func setIntersection(a, b map[string]struct{}) map[string]struct{} {
var downloadRouteAllowlist = []string{
"/download/pulse-agent",
"/download/pulse-agent-helper",
"/download/pulse-agent-runner",
}
var publicDownloadAllowlist = []string{
"/download/pulse-agent",
"/download/pulse-agent-helper",
"/download/pulse-agent-runner",
}
var protectedDownloadAllowlist = []string{}
@@ -120,6 +120,8 @@ var publicPathsAllowlist = []string{
"/install.sh",
"/install.ps1",
"/download/pulse-agent",
"/download/pulse-agent-helper",
"/download/pulse-agent-runner",
"/api/agent/capabilities",
"/api/agent/version",
"/api/agent/ws",
@@ -650,6 +650,7 @@ func (r *Router) registerAuthSecurityInstallRoutes() {
r.mux.HandleFunc("/install.ps1", r.downloadLimiter.Middleware(r.handleDownloadUnifiedInstallScriptPS))
r.mux.HandleFunc("/download/pulse-agent", r.downloadLimiter.Middleware(r.handleDownloadUnifiedAgent))
r.mux.HandleFunc("/download/pulse-agent-helper", r.downloadLimiter.Middleware(r.handleDownloadAgentHelper))
r.mux.HandleFunc("/download/pulse-agent-runner", r.downloadLimiter.Middleware(r.handleDownloadAgentRunner))
r.mux.HandleFunc("/api/agent/version", r.handleAgentVersion)
r.mux.HandleFunc("/api/server/info", r.handleServerInfo)
@@ -55,6 +55,7 @@ func (r *Router) registerConfigSystemRoutes(updateHandlers *UpdateHandlers) {
r.mux.HandleFunc("/api/agents/docker/report", RequireAuth(r.config, RequireScope(config.ScopeDockerReport, r.dockerAgentHandlers.HandleReport)))
r.mux.HandleFunc("/api/agents/kubernetes/report", RequireAuth(r.config, RequireScope(config.ScopeKubernetesReport, r.kubernetesAgentHandlers.HandleReport)))
r.mux.HandleFunc("/api/agents/agent/report", RequireAuth(r.config, RequireScope(config.ScopeAgentReport, r.unifiedAgentHandlers.HandleReport)))
r.mux.HandleFunc("/api/agents/action-runner/credential", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, RequireScope(config.ScopeActionsExecute, r.handleIssueActionRunnerCredential))))
r.mux.HandleFunc("/api/agents/host/report", wrapLegacyHostAlias("/api/agents/host/report", RequireAuth(r.config, RequireScope(config.ScopeAgentReport, r.unifiedAgentHandlers.HandleReport))))
r.mux.HandleFunc("/api/agents/agent/lookup", RequireAuth(r.config, RequireScope(config.ScopeAgentReport, r.unifiedAgentHandlers.HandleLookup)))
r.mux.HandleFunc("/api/agents/host/lookup", wrapLegacyHostAlias("/api/agents/host/lookup", RequireAuth(r.config, RequireScope(config.ScopeAgentReport, r.unifiedAgentHandlers.HandleLookup))))
+60
View File
@@ -636,6 +636,66 @@ func TestAgentExecTokenBindingEnforced(t *testing.T) {
conn.Close()
}
func TestActionRunnerCredentialAdmissionBindsTenantHostRoleAndCapability(t *testing.T) {
cfg := &config.Config{DataPath: t.TempDir()}
rawToken, record, err := agenttokens.IssueActionRunnerAndPersist(cfg, nil, agenttokens.ActionRunnerIssueOptions{
OrgID: "org-a", AgentID: "machine-a", Hostname: "node.example",
})
if err != nil {
t.Fatal(err)
}
router := &Router{config: cfg}
admission, ok := router.admitAgentExecToken(rawToken, "machine-a", "NODE")
if !ok {
t.Fatal("typed action runner credential was rejected")
}
if admission.OrganizationID != "org-a" || admission.TokenID != record.ID ||
admission.AgentID != "machine-a" || admission.RuntimeRole != agentexec.RuntimeRoleActionRunner ||
admission.ActionCapability != agentexec.ActionCapabilityTypedV1 {
t.Fatalf("action admission = %#v", admission)
}
if !router.validateAgentExecSession(admission) {
t.Fatal("fresh action runner admission failed live session validation")
}
config.Mu.Lock()
cfg.APITokens[0].Metadata[agenttokens.ActionCapabilityMetadataKey] = "shell.v1"
config.Mu.Unlock()
if router.validateAgentExecSession(admission) {
t.Fatal("session stayed valid after action capability changed")
}
}
func TestActionRunnerCredentialAdmissionRejectsWrongIdentityAndUnboundOrganization(t *testing.T) {
for _, tc := range []struct {
name string
agentID string
hostname string
clearOrg bool
}{
{name: "wrong agent", agentID: "other", hostname: "node.example"},
{name: "wrong host", agentID: "machine-a", hostname: "other.example"},
{name: "missing organization binding", agentID: "machine-a", hostname: "node.example", clearOrg: true},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := &config.Config{DataPath: t.TempDir()}
rawToken, _, err := agenttokens.IssueActionRunnerAndPersist(cfg, nil, agenttokens.ActionRunnerIssueOptions{
OrgID: "org-a", AgentID: "machine-a", Hostname: "node.example",
})
if err != nil {
t.Fatal(err)
}
if tc.clearOrg {
cfg.APITokens[0].OrgID = ""
}
if _, ok := (&Router{config: cfg}).admitAgentExecToken(rawToken, tc.agentID, tc.hostname); ok {
t.Fatal("mismatched action credential was admitted")
}
})
}
}
func TestAgentExecTokenRejectsAmbiguousMultiOrganizationAuthority(t *testing.T) {
rawToken := "multi-org-agent-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentExec}, map[string]string{
+119
View File
@@ -184,6 +184,25 @@ func agentHelperLocalBuildCommand(normalized string) string {
return fmt.Sprintf("%s go build -o bin/pulse-agent-helper-%s ./cmd/pulse-agent-helper", strings.Join(env, " "), normalized)
}
func agentRunnerLocalBuildCommand(normalized string) string {
goos, goarch, ok := strings.Cut(strings.TrimSpace(normalized), "-")
if !ok || goos != "linux" || goarch == "" {
goos = "linux"
goarch = "amd64"
normalized = "linux-amd64"
}
env := []string{"CGO_ENABLED=0", "GOOS=" + goos}
switch goarch {
case "armv7":
env = append(env, "GOARCH=arm", "GOARM=7")
case "armv6":
env = append(env, "GOARCH=arm", "GOARM=6")
default:
env = append(env, "GOARCH="+goarch)
}
return fmt.Sprintf("%s go build -o bin/pulse-agent-runner-%s ./cmd/pulse-agent-runner", strings.Join(env, " "), normalized)
}
// handleDownloadUnifiedAgent serves the pulse-agent binary
func (r *Router) handleDownloadUnifiedAgent(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet && req.Method != http.MethodHead {
@@ -421,6 +440,106 @@ func (r *Router) proxyAgentHelperFromGitHub(w http.ResponseWriter, req *http.Req
)
}
// handleDownloadAgentRunner serves the separately signed Linux action runner.
// It is a distinct asset from both the monitoring collector and the local
// privilege helper so installer profiles cannot substitute one authority for
// another.
func (r *Router) handleDownloadAgentRunner(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet && req.Method != http.MethodHead {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
normalized := normalizeAgentHelperArch(strings.TrimSpace(req.URL.Query().Get("arch")))
if normalized == "" {
http.Error(w, "A supported Linux architecture is required", http.StatusBadRequest)
return
}
binaryName := "pulse-agent-runner-" + normalized
searchPaths := []string{
filepath.Join(pulseBinDir(), binaryName),
filepath.Join("/opt/pulse", binaryName),
filepath.Join("/app", binaryName),
filepath.Join(r.projectRoot, "bin", binaryName),
}
for _, candidate := range searchPaths {
info, err := os.Stat(candidate)
if err != nil || info.IsDir() {
continue
}
checksum, err := r.cachedSHA256(candidate, info)
if err != nil {
continue
}
signature, sigErr := readReleaseAssetSignature(candidate)
sshSignature, sshSigErr := readReleaseAssetSSHSignature(candidate)
if (sigErr != nil || sshSigErr != nil) && isPublishedReleaseAssetVersion(r.serverVersion) {
continue
}
file, err := os.Open(candidate)
if err != nil {
continue
}
defer file.Close()
w.Header().Set(checksumHeaderName, checksum)
if signature != "" {
w.Header().Set(signatureHeaderName, signature)
}
if sshSignature != "" {
w.Header().Set(sshSignatureHeaderName, sshSignature)
}
http.ServeContent(w, req, binaryName, info.ModTime(), file)
return
}
if !isPublishedReleaseAssetVersion(r.serverVersion) {
http.Error(w, "Agent runner binary not found for "+normalized+" in dev mode.\nBuild with:\n "+agentRunnerLocalBuildCommand(normalized), http.StatusNotFound)
return
}
r.proxyAgentRunnerFromGitHub(w, req, normalized)
}
func (r *Router) proxyAgentRunnerFromGitHub(w http.ResponseWriter, req *http.Request, normalized string) {
assetName := "pulse-agent-runner-" + normalized
assetURL, err := r.releaseAssetURL(assetName)
if err != nil {
http.Error(w, "Agent runner binary unavailable for current server build", http.StatusServiceUnavailable)
return
}
client := r.installScriptClient
if client == nil {
client = &http.Client{Timeout: 5 * time.Minute}
}
response, err := client.Get(assetURL)
if err != nil {
http.Error(w, "Failed to fetch agent runner binary", http.StatusServiceUnavailable)
return
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
http.Error(w, "Agent runner binary not found on GitHub", http.StatusNotFound)
return
}
content, checksum, err := readBinaryWithChecksum(response.Body)
if err != nil {
http.Error(w, "Failed to read agent runner binary", http.StatusInternalServerError)
return
}
signature, err := fetchReleaseAssetContent(req.Context(), client, assetURL+".sig", 16*1024)
if err != nil {
http.Error(w, "Failed to fetch agent runner binary signature", http.StatusServiceUnavailable)
return
}
sshSignature, err := fetchReleaseAssetContent(req.Context(), client, assetURL+".sshsig", 64*1024)
if err != nil {
http.Error(w, "Failed to fetch agent runner binary SSH signature", http.StatusServiceUnavailable)
return
}
serveProxiedAgentBinaryWithSignatures(w, content, checksum, strings.TrimSpace(string(signature)), encodeSSHSignatureForHeader(sshSignature), "github-proxy")
}
// validateUnifiedAgentBinary rejects a local agent binary that this server must
// not hand out. The endpoint checks catch a binary built against a superseded
// report contract; the version check catches one that is merely old.
+63
View File
@@ -1,6 +1,7 @@
package api
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
@@ -101,6 +102,68 @@ func TestDownloadAgentHelperProxiesPublishedSignedAsset(t *testing.T) {
}
}
func TestDownloadAgentRunnerRejectsUnsupportedArchitecture(t *testing.T) {
router := &Router{}
request := httptest.NewRequest(http.MethodGet, "/download/pulse-agent-runner?arch=darwin-arm64", nil)
response := httptest.NewRecorder()
router.handleDownloadAgentRunner(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", response.Code, http.StatusBadRequest)
}
}
func TestDownloadAgentRunnerServesSignedLocalLinuxBinary(t *testing.T) {
root := t.TempDir()
binDir := filepath.Join(root, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(binDir, "pulse-agent-runner-linux-amd64")
content := []byte("runner")
if err := os.WriteFile(path, content, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path+".sig", []byte("detached"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path+".sshsig", []byte("ssh-signature"), 0o644); err != nil {
t.Fatal(err)
}
router := &Router{projectRoot: root, serverVersion: "6.5.0"}
request := httptest.NewRequest(http.MethodGet, "/download/pulse-agent-runner?arch=linux-amd64", nil)
response := httptest.NewRecorder()
router.handleDownloadAgentRunner(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", response.Code, http.StatusOK, response.Body.String())
}
if got := response.Header().Get(checksumHeaderName); got == "" {
t.Fatal("missing checksum header")
}
if got := response.Header().Get(signatureHeaderName); got != "detached" {
t.Fatalf("signature = %q", got)
}
if got := response.Header().Get(sshSignatureHeaderName); got == "" {
t.Fatal("missing SSH signature header")
}
if !bytes.Equal(response.Body.Bytes(), content) {
t.Fatalf("body = %q", response.Body.Bytes())
}
}
func TestDownloadAgentRunnerMissingDevBinaryReturnsExactBuildCommand(t *testing.T) {
router := &Router{projectRoot: t.TempDir(), serverVersion: "dev"}
request := httptest.NewRequest(http.MethodGet, "/download/pulse-agent-runner?arch=linux-armv7", nil)
response := httptest.NewRecorder()
router.handleDownloadAgentRunner(response, request)
if response.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", response.Code, http.StatusNotFound)
}
want := "CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -o bin/pulse-agent-runner-linux-armv7 ./cmd/pulse-agent-runner"
if !strings.Contains(response.Body.String(), want) {
t.Fatalf("response missing build command %q: %s", want, response.Body.String())
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+41
View File
@@ -0,0 +1,41 @@
package dockeragent
import (
"fmt"
"net/http"
"strings"
"github.com/rs/zerolog"
)
// NewActionRuntime connects the separate action runner directly to the Docker
// or Podman API using the module's existing typed lifecycle/update managers.
// It configures no Pulse target, report loop, command poller, or generic daemon
// proxy; callers receive only the narrow Agent methods used by the typed bridge.
func NewActionRuntime(runtimeName string, logger *zerolog.Logger) (*Agent, error) {
runtimePreference, err := normalizeRuntime(runtimeName)
if err != nil {
return nil, err
}
if logger == nil {
nop := zerolog.Nop()
logger = &nop
}
client, info, runtimeKind, err := connectRuntimeFn(runtimePreference, logger)
if err != nil {
return nil, fmt.Errorf("connect action runtime: %w", err)
}
agent := &Agent{
docker: newSwappableDockerClient(client),
daemonHost: client.DaemonHost(),
daemonID: strings.TrimSpace(info.ID),
runtime: runtimeKind,
runtimePref: runtimePreference,
runtimeVer: strings.TrimSpace(info.ServerVersion),
logger: *logger,
httpClients: make(map[bool]*http.Client),
trustedHTTPClients: make(map[string]*http.Client),
}
agent.ensureAsyncLifecycle()
return agent, nil
}
+139
View File
@@ -0,0 +1,139 @@
package hostagent
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rs/zerolog"
)
const ActionRunnerRuntimeRole = agentexec.RuntimeRoleActionRunner
type ActionRunnerClientConfig struct {
PulseURL string
APIToken string
StateDir string
HealthPath string
InsecureSkipVerify bool
CACertPath string
ServerFingerprint string
Logger *zerolog.Logger
DockerContainerUpdater DockerContainerUpdater
DockerContainerLifecycleOperator DockerContainerLifecycleOperator
}
// NewActionRunnerClient constructs the separately credentialed, typed-only
// action transport. It does not create a collector, reporter, deploy client,
// arbitrary command executor, or unrestricted file reader.
func NewActionRunnerClient(config ActionRunnerClientConfig, agentID, hostname, version string) *CommandClient {
logger := config.Logger
if logger == nil {
nop := zerolog.Nop()
logger = &nop
}
lease := newPackageManagerLease()
client := NewCommandClient(Config{
PulseURL: config.PulseURL, APIToken: config.APIToken, StateDir: config.StateDir,
InsecureSkipVerify: config.InsecureSkipVerify, CACertPath: config.CACertPath,
ServerFingerprint: config.ServerFingerprint, Logger: logger,
packageUpdates: newPackageUpdateManager(runtime.GOOS, lease),
storageCleanup: newStorageCleanupManager(runtime.GOOS, lease),
DockerContainerUpdater: config.DockerContainerUpdater,
DockerContainerLifecycleOperator: config.DockerContainerLifecycleOperator,
}, agentID, hostname, runtime.GOOS, version)
client.actionRunnerOnly = true
client.runtimeRole = agentexec.RuntimeRoleActionRunner
client.actionCapability = agentexec.ActionCapabilityTypedV1
client.healthPath = strings.TrimSpace(config.HealthPath)
client.healthCapabilities = []string{
"host.storage_cleanup.v1",
"host.update.v1",
"proxmox.guest.lifecycle.v1",
agentexec.ActionCapabilityTypedV1,
}
if config.DockerContainerUpdater != nil && config.DockerContainerLifecycleOperator != nil {
client.healthCapabilities = append(client.healthCapabilities, "container.lifecycle.v1", "container.update.v1")
}
return client
}
func allowedActionRunnerMessage(message messageType) bool {
switch message {
case msgTypePong,
msgTypeHostStorageCleanup, msgTypeHostUpdate, msgTypeProxmoxGuestLifecycle,
msgTypeDockerContainerLifecycle, msgTypeDockerContainerUpdate,
msgTypeDockerContainerObserve, msgTypeActionPreflight,
msgTypeOperationQuery, msgTypeCancelCmd:
return true
default:
return false
}
}
type actionRunnerHealth struct {
Registered bool `json:"registered"`
RuntimeRole string `json:"runtime_role"`
Server string `json:"server"`
HostID string `json:"host_id"`
Hostname string `json:"hostname"`
Capabilities []string `json:"capabilities"`
RegisteredAt time.Time `json:"registered_at"`
}
func (c *CommandClient) writeActionRunnerHealth() error {
if c == nil || !c.actionRunnerOnly || strings.TrimSpace(c.healthPath) == "" {
return fmt.Errorf("action-runner health path is required")
}
capabilities := append([]string(nil), c.healthCapabilities...)
sort.Strings(capabilities)
health := actionRunnerHealth{
Registered: true, RuntimeRole: agentexec.RuntimeRoleActionRunner,
Server: c.pulseURL, HostID: c.agentID, Hostname: c.hostname,
Capabilities: capabilities, RegisteredAt: time.Now().UTC(),
}
encoded, err := json.Marshal(health)
if err != nil {
return err
}
dir := filepath.Dir(c.healthPath)
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
temp, err := os.CreateTemp(dir, ".health-*.tmp")
if err != nil {
return err
}
tempPath := temp.Name()
defer os.Remove(tempPath)
if err := temp.Chmod(0600); err != nil {
temp.Close()
return err
}
if _, err := temp.Write(encoded); err != nil {
temp.Close()
return err
}
if err := temp.Sync(); err != nil {
temp.Close()
return err
}
if err := temp.Close(); err != nil {
return err
}
if err := os.Rename(tempPath, c.healthPath); err != nil {
return err
}
directory, err := os.Open(dir)
if err != nil {
return err
}
defer directory.Close()
return directory.Sync()
}
@@ -0,0 +1,156 @@
package hostagent
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rs/zerolog"
)
func TestNewActionRunnerClientIsTypedOnlyAndEmitsExplicitRole(t *testing.T) {
logger := zerolog.Nop()
client := NewActionRunnerClient(ActionRunnerClientConfig{
PulseURL: "https://pulse.example", APIToken: "separate-secret",
StateDir: t.TempDir(), HealthPath: filepath.Join(t.TempDir(), "health.json"), Logger: &logger,
}, "agent-1", "host-1", "v1")
t.Cleanup(func() { _ = client.Close() })
if !client.actionRunnerOnly || client.runtimeRole != agentexec.RuntimeRoleActionRunner || client.actionCapability != agentexec.ActionCapabilityTypedV1 {
t.Fatalf("runner protocol ceiling not configured: %+v", client)
}
for _, message := range []messageType{msgTypeExecuteCmd, msgTypeReadFile, msgTypeDeployPreflight, msgTypeDeployInstall, msgTypeDeployCancel} {
if allowedActionRunnerMessage(message) {
t.Errorf("forbidden message %q was admitted", message)
}
}
for _, message := range []messageType{msgTypeHostUpdate, msgTypeHostStorageCleanup, msgTypeDockerContainerLifecycle, msgTypeDockerContainerUpdate, msgTypeOperationQuery, msgTypeCancelCmd} {
if !allowedActionRunnerMessage(message) {
t.Errorf("typed message %q was rejected", message)
}
}
}
func TestActionRunnerTransportRegistersRoleWritesHealthAndRejectsGenericExec(t *testing.T) {
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
registration := make(chan registerPayload, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
conn, err := upgrader.Upgrade(w, request, nil)
if err != nil {
return
}
defer conn.Close()
var message wsMessage
if err := conn.ReadJSON(&message); err != nil {
return
}
var payload registerPayload
if json.Unmarshal(message.Payload, &payload) != nil {
return
}
registration <- payload
ack, _ := json.Marshal(registeredPayload{Success: true})
_ = conn.WriteJSON(wsMessage{Type: msgTypeRegistered, Timestamp: time.Now(), Payload: ack})
forbidden, _ := json.Marshal(executeCommandPayload{RequestID: "r1", Command: "id", TargetType: "agent"})
_ = conn.WriteJSON(wsMessage{Type: msgTypeExecuteCmd, Timestamp: time.Now(), Payload: forbidden})
}))
defer server.Close()
dir := t.TempDir()
healthPath := filepath.Join(dir, "health.json")
logger := zerolog.Nop()
client := NewActionRunnerClient(ActionRunnerClientConfig{
PulseURL: server.URL, APIToken: "runner-token", StateDir: filepath.Join(dir, "state"),
HealthPath: healthPath, InsecureSkipVerify: true, Logger: &logger,
}, "agent-1", "host-1", "v1")
defer client.Close()
err := client.connectAndHandle(context.Background())
if err == nil || !strings.Contains(err.Error(), "forbidden message") {
t.Fatalf("transport error = %v", err)
}
payload := <-registration
if payload.RuntimeRole != agentexec.RuntimeRoleActionRunner || payload.ActionCapability != agentexec.ActionCapabilityTypedV1 || payload.Token != "runner-token" {
t.Fatalf("registration = %+v", payload)
}
data, err := os.ReadFile(healthPath)
if err != nil {
t.Fatal(err)
}
var health actionRunnerHealth
if json.Unmarshal(data, &health) != nil || !health.Registered || health.HostID != "agent-1" {
t.Fatalf("health = %s", data)
}
}
func TestActionRunnerHealthIsAtomicBoundedAndSecretFree(t *testing.T) {
dir := t.TempDir()
healthPath := filepath.Join(dir, "health.json")
logger := zerolog.Nop()
client := NewActionRunnerClient(ActionRunnerClientConfig{
PulseURL: "https://pulse.example", APIToken: "must-not-appear",
StateDir: filepath.Join(dir, "state"), HealthPath: healthPath, Logger: &logger,
}, "agent-1", "host-1", "v1")
t.Cleanup(func() { _ = client.Close() })
if err := client.writeActionRunnerHealth(); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(healthPath)
if err != nil {
t.Fatal(err)
}
if json.Valid(data) == false || string(data) == "" {
t.Fatalf("invalid health marker: %q", data)
}
if contains := string(data); contains == "must-not-appear" || jsonContains(data, "must-not-appear") {
t.Fatal("health marker leaked the action credential")
}
var health actionRunnerHealth
if err := json.Unmarshal(data, &health); err != nil {
t.Fatal(err)
}
if !health.Registered || health.RuntimeRole != agentexec.RuntimeRoleActionRunner || health.HostID != "agent-1" || health.Server != "https://pulse.example" || health.RegisteredAt.IsZero() {
t.Fatalf("health = %+v", health)
}
info, err := os.Stat(healthPath)
if err != nil || info.Mode().Perm() != 0600 {
t.Fatalf("health mode = %v, %v", info.Mode().Perm(), err)
}
matches, err := filepath.Glob(filepath.Join(dir, ".health-*.tmp"))
if err != nil || len(matches) != 0 {
t.Fatalf("temporary health files = %v, %v", matches, err)
}
}
func jsonContains(data []byte, value string) bool {
var decoded any
if json.Unmarshal(data, &decoded) != nil {
return false
}
return containsJSONValue(decoded, value)
}
func containsJSONValue(value any, secret string) bool {
switch typed := value.(type) {
case string:
return typed == secret
case []any:
for _, item := range typed {
if containsJSONValue(item, secret) {
return true
}
}
case map[string]any:
for _, item := range typed {
if containsJSONValue(item, secret) {
return true
}
}
}
return false
}
+13
View File
@@ -259,3 +259,16 @@ func TestCommandClient_handleCancelCommand_UnknownRequestIsNoOp(t *testing.T) {
default:
}
}
func TestCommandClientActionRunnerMessageCatalogRejectsGenericAuthority(t *testing.T) {
for _, message := range []messageType{msgTypeExecuteCmd, msgTypeReadFile, msgTypeDeployPreflight, msgTypeDeployInstall, msgTypeDeployCancel} {
if allowedActionRunnerMessage(message) {
t.Fatalf("action runner unexpectedly admitted generic message %q", message)
}
}
for _, message := range []messageType{msgTypeHostUpdate, msgTypeHostStorageCleanup, msgTypeProxmoxGuestLifecycle, msgTypeDockerContainerLifecycle} {
if !allowedActionRunnerMessage(message) {
t.Fatalf("action runner rejected typed message %q", message)
}
}
}
+70 -21
View File
@@ -113,6 +113,7 @@ type CommandClient struct {
storageCleanup *storageCleanupManager
dockerLifecycle dockerLifecycleManager
dockerUpdater DockerContainerUpdater
proxmoxGuestLifecycle *proxmoxGuestLifecycleManager
operationReceipts *operationreceipt.Store
operationReceiptErr error
operationReceiptCloseOnce sync.Once
@@ -132,6 +133,15 @@ type CommandClient struct {
// enforces unique in-flight request IDs on its side.
activeCommandsMu sync.Mutex
activeCommands map[string]context.CancelFunc
// actionRunnerOnly is an immutable constructor-selected protocol ceiling.
// It permits only the closed typed action families and their receipt,
// preflight, observation, cancellation, and liveness messages.
actionRunnerOnly bool
runtimeRole string
actionCapability string
healthPath string
healthCapabilities []string
}
// NewCommandClient creates a new command execution client
@@ -152,26 +162,27 @@ func NewCommandClient(cfg Config, agentID, hostname, platform, version string) *
Msg("Operation receipt store unavailable — Pulse will refuse reviewed actions for this agent until its state directory is writable")
}
return &CommandClient{
pulseURL: strings.TrimRight(cfg.PulseURL, "/"),
apiToken: cfg.APIToken,
agentID: agentID,
hostname: hostname,
platform: platform,
version: version,
stateDir: stateDir,
insecureSkipVerify: cfg.InsecureSkipVerify,
caCertPath: cfg.CACertPath,
serverFingerprint: cfg.ServerFingerprint,
deploySSHUser: cfg.DeploySSHUser,
commandPolicy: agentexec.DefaultPolicy(),
packageUpdates: cfg.packageUpdates,
storageCleanup: cfg.storageCleanup,
dockerLifecycle: newLocalDockerLifecycleManager(cfg.DockerContainerLifecycleOperator),
dockerUpdater: cfg.DockerContainerUpdater,
operationReceipts: receipts,
operationReceiptErr: receiptErr,
logger: logger,
done: make(chan struct{}),
pulseURL: strings.TrimRight(cfg.PulseURL, "/"),
apiToken: cfg.APIToken,
agentID: agentID,
hostname: hostname,
platform: platform,
version: version,
stateDir: stateDir,
insecureSkipVerify: cfg.InsecureSkipVerify,
caCertPath: cfg.CACertPath,
serverFingerprint: cfg.ServerFingerprint,
deploySSHUser: cfg.DeploySSHUser,
commandPolicy: agentexec.DefaultPolicy(),
packageUpdates: cfg.packageUpdates,
storageCleanup: cfg.storageCleanup,
dockerLifecycle: newLocalDockerLifecycleManager(cfg.DockerContainerLifecycleOperator),
dockerUpdater: cfg.DockerContainerUpdater,
proxmoxGuestLifecycle: newProxmoxGuestLifecycleManager(),
operationReceipts: receipts,
operationReceiptErr: receiptErr,
logger: logger,
done: make(chan struct{}),
}
}
@@ -201,6 +212,8 @@ const (
msgTypeHostStorageCleanupResult messageType = "host_storage_cleanup_result"
msgTypeHostUpdate messageType = "host_update"
msgTypeHostUpdateResult messageType = "host_update_result"
msgTypeProxmoxGuestLifecycle messageType = "proxmox_guest_lifecycle"
msgTypeProxmoxGuestLifecycleResult messageType = "proxmox_guest_lifecycle_result"
msgTypeDockerContainerLifecycle messageType = "docker_container_lifecycle"
msgTypeDockerContainerLifecycleResult messageType = "docker_container_lifecycle_result"
msgTypeDockerContainerUpdate messageType = "docker_container_update"
@@ -232,6 +245,8 @@ type registerPayload struct {
Platform string `json:"platform"`
Tags []string `json:"tags,omitempty"`
Token string `json:"token"`
RuntimeRole string `json:"runtime_role,omitempty"`
ActionCapability string `json:"action_capability,omitempty"`
OperationReceiptVersion int `json:"operation_receipt_version,omitempty"`
ActionPreflightVersion int `json:"action_preflight_version,omitempty"`
DockerObservationVersion int `json:"docker_observation_version,omitempty"`
@@ -403,6 +418,11 @@ func (c *CommandClient) connectAndHandle(ctx context.Context) error {
if err := c.waitForRegistration(conn); err != nil {
return fmt.Errorf("registration failed: %w", err)
}
if c.actionRunnerOnly {
if err := c.writeActionRunnerHealth(); err != nil {
return fmt.Errorf("write action-runner health: %w", err)
}
}
c.logger.Info().Msg("Connected and registered with Pulse command server")
@@ -465,6 +485,8 @@ func (c *CommandClient) sendRegistration(conn *websocket.Conn) error {
Version: c.version,
Platform: c.platform,
Token: c.apiToken,
RuntimeRole: c.runtimeRole,
ActionCapability: c.actionCapability,
OperationReceiptVersion: c.operationReceiptVersion(),
ActionPreflightVersion: agentexec.ActionPreflightProtocolVersion,
DockerObservationVersion: agentexec.DockerContainerObservationProtocolVersion,
@@ -565,6 +587,9 @@ func (c *CommandClient) handleMessages(ctx context.Context, conn *websocket.Conn
if err := conn.ReadJSON(&msg); err != nil {
return fmt.Errorf("read message: %w", err)
}
if c.actionRunnerOnly && !allowedActionRunnerMessage(msg.Type) {
return fmt.Errorf("action-runner rejected forbidden message type %q", msg.Type)
}
switch msg.Type {
case msgTypePong:
@@ -597,6 +622,14 @@ func (c *CommandClient) handleMessages(ctx context.Context, conn *websocket.Conn
}
go c.handleHostUpdate(ctx, conn, payload)
case msgTypeProxmoxGuestLifecycle:
payload, err := agentexec.DecodeProxmoxGuestLifecyclePayload(msg.Payload)
if err != nil {
c.logger.Warn().Err(err).Msg("Dropping invalid Proxmox guest lifecycle request")
continue
}
go c.handleProxmoxGuestLifecycle(ctx, conn, payload)
case msgTypeActionPreflight:
payload, err := agentexec.DecodeActionPreflightPayload(msg.Payload)
if err != nil {
@@ -829,7 +862,11 @@ func (c *CommandClient) beginHostAPTOperation(ctx context.Context, conn *websock
timeout = defaultTimeout
}
opCtx, cancel := context.WithTimeout(ctx, timeout)
return opCtx, cancel, true
c.registerActiveCommand(requestID, cancel)
return opCtx, func() {
c.unregisterActiveCommand(requestID)
cancel()
}, true
}
// completeHostAPTOperation persists the sanitized terminal receipt and sends
@@ -909,6 +946,8 @@ func (c *CommandClient) handleDockerContainerLifecycle(ctx context.Context, conn
timeout = 2 * time.Minute
}
operationCtx, cancel := context.WithTimeout(ctx, timeout)
c.registerActiveCommand(payload.RequestID, cancel)
defer c.unregisterActiveCommand(payload.RequestID)
defer cancel()
result := agentexec.DockerContainerLifecycleResultPayload{
RequestID: payload.RequestID, ActionID: payload.ActionID, Operation: payload.Operation,
@@ -1072,6 +1111,16 @@ func hostOperationReceiptConfig() operationreceipt.Config {
}
return nil
}},
agentexec.ProxmoxGuestLifecycleReceiptKind: {agentexec.ProxmoxGuestLifecycleReceiptVersion: func(identity operationreceipt.Identity, payload json.RawMessage) error {
result, err := agentexec.DecodeProxmoxGuestLifecycleResultPayload(payload)
if err != nil {
return err
}
if result.RequestID != identity.AttemptID || result.ActionID != identity.ActionID || result.Operation != identity.OperationKind || result.OperationVersion != identity.OperationVersion || result.RequestDigest != identity.RequestDigest {
return operationreceipt.ErrBindingConflict
}
return nil
}},
}}
}
+2
View File
@@ -44,6 +44,8 @@ func (c *CommandClient) handleDockerContainerUpdate(ctx context.Context, conn *w
timeout = 15 * time.Minute
}
operationCtx, cancel := context.WithTimeout(ctx, timeout)
c.registerActiveCommand(payload.RequestID, cancel)
defer c.unregisterActiveCommand(payload.RequestID)
defer cancel()
result := c.runDockerContainerUpdate(operationCtx, payload)
@@ -0,0 +1,152 @@
package hostagent
import (
"context"
"encoding/json"
"fmt"
"os/exec"
"strconv"
"strings"
"time"
"github.com/gorilla/websocket"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/operationreceipt"
)
type proxmoxGuestCommandRunner func(context.Context, string, ...string) ([]byte, error)
type proxmoxGuestLifecycleManager struct {
run proxmoxGuestCommandRunner
now func() time.Time
}
func newProxmoxGuestLifecycleManager() *proxmoxGuestLifecycleManager {
return &proxmoxGuestLifecycleManager{
run: func(ctx context.Context, name string, args ...string) ([]byte, error) {
return exec.CommandContext(ctx, name, args...).CombinedOutput()
},
now: time.Now,
}
}
func (m *proxmoxGuestLifecycleManager) Apply(ctx context.Context, req agentexec.ProxmoxGuestLifecyclePayload) (result agentexec.ProxmoxGuestLifecycleResultPayload) {
started := time.Now()
result = agentexec.ProxmoxGuestLifecycleResultPayload{
RequestID: req.RequestID, ActionID: req.ActionID, Operation: req.Operation,
OperationVersion: req.OperationVersion, RequestDigest: req.RequestDigest,
GuestKind: req.GuestKind, VMID: req.VMID, ExecutionPhase: agentexec.ProxmoxGuestPhasePreflight,
}
defer func() { result.Duration = time.Since(started).Milliseconds() }()
if err := agentexec.ValidateProxmoxGuestLifecyclePayload(&req); err != nil {
result.ReasonCode, result.Error = agentexec.ActionRefusalContractInvalid, "typed Proxmox lifecycle preflight refused"
return result
}
before, err := m.inspect(ctx, req.GuestKind, req.VMID)
result.Before = before
if err != nil {
result.ReasonCode, result.Error = agentexec.ActionRefusalTargetInspectionUnavailable, "Proxmox guest preflight status unavailable"
return result
}
if before.Status != req.ExpectedStatus {
result.ReasonCode, result.Error = agentexec.ActionRefusalTargetStateChanged, "Proxmox guest state changed before dispatch"
return result
}
result.ExecutionPhase, result.MutationStarted = agentexec.ProxmoxGuestPhaseMutate, true
tool := "qm"
if req.GuestKind == "ct" {
tool = "pct"
}
// This is the entire mutation catalog: fixed tool, fixed verb, decimal VMID.
if _, err := m.run(ctx, tool, req.Operation, strconv.Itoa(req.VMID)); err != nil {
result.Error = "Proxmox guest lifecycle mutation did not complete"
return result
}
result.MutationCompleted, result.ExecutionPhase = true, agentexec.ProxmoxGuestPhaseVerify
after, err := m.inspect(ctx, req.GuestKind, req.VMID)
result.After = after
if err != nil {
result.Error = "Proxmox guest postcondition status unavailable"
return result
}
result.ReadbackRan = true
if proxmoxGuestLifecyclePostcondition(req.Operation, after.Status) {
result.ExecutionPhase = agentexec.ProxmoxGuestPhaseComplete
return result
}
result.Error = "Proxmox guest postcondition contradicted the requested state"
return result
}
func (m *proxmoxGuestLifecycleManager) inspect(ctx context.Context, kind string, vmid int) (agentexec.ProxmoxGuestLifecycleSnapshot, error) {
tool := "qm"
if kind == "ct" {
tool = "pct"
}
out, err := m.run(ctx, tool, "status", strconv.Itoa(vmid))
if err != nil {
return agentexec.ProxmoxGuestLifecycleSnapshot{}, err
}
fields := strings.Fields(strings.ToLower(strings.TrimSpace(string(out))))
if len(fields) != 2 || fields[0] != "status:" || (fields[1] != "running" && fields[1] != "stopped") {
return agentexec.ProxmoxGuestLifecycleSnapshot{}, fmt.Errorf("unexpected Proxmox status response")
}
now := time.Now().UTC()
if m.now != nil {
now = m.now().UTC()
}
return agentexec.ProxmoxGuestLifecycleSnapshot{Status: fields[1], ObservedAt: now}, nil
}
func proxmoxGuestLifecyclePostcondition(operation, status string) bool {
if operation == "stop" || operation == "shutdown" {
return status == "stopped"
}
return status == "running"
}
func (c *CommandClient) handleProxmoxGuestLifecycle(ctx context.Context, conn *websocket.Conn, payload agentexec.ProxmoxGuestLifecyclePayload) {
identity := agentexec.ProxmoxGuestLifecycleOperationIdentity(c.agentID, payload)
record, admitted, err := c.admitOperation(identity)
if err != nil {
return
}
if !admitted {
if record.State == operationreceipt.StateTerminal {
var result agentexec.ProxmoxGuestLifecycleResultPayload
if json.Unmarshal(record.Result, &result) == nil {
c.sendProxmoxGuestLifecycleResult(conn, result)
}
}
return
}
if _, err := c.operationReceipts.MarkStarted(identity); err != nil {
return
}
operationCtx, cancel := context.WithTimeout(ctx, time.Duration(payload.Timeout)*time.Second)
c.registerActiveCommand(payload.RequestID, cancel)
defer c.unregisterActiveCommand(payload.RequestID)
defer cancel()
result := c.proxmoxGuestLifecycle.Apply(operationCtx, payload)
encoded, err := json.Marshal(result)
if err != nil {
return
}
if _, err := c.operationReceipts.Complete(identity, operationreceipt.TerminalEnvelope{Kind: agentexec.ProxmoxGuestLifecycleReceiptKind, Version: agentexec.ProxmoxGuestLifecycleReceiptVersion, Payload: encoded}); err != nil {
return
}
c.sendProxmoxGuestLifecycleResult(conn, result)
}
func (c *CommandClient) sendProxmoxGuestLifecycleResult(conn *websocket.Conn, result agentexec.ProxmoxGuestLifecycleResultPayload) {
encoded, err := json.Marshal(result)
if err != nil {
return
}
c.connMu.Lock()
err = conn.WriteJSON(wsMessage{Type: msgTypeProxmoxGuestLifecycleResult, ID: result.RequestID, Timestamp: time.Now(), Payload: encoded})
c.connMu.Unlock()
if err != nil {
c.logger.Error().Err(err).Str("request_id", result.RequestID).Msg("Failed to send Proxmox lifecycle result")
}
}
@@ -0,0 +1,134 @@
package hostagent
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/operationreceipt"
"github.com/rs/zerolog"
)
func boundProxmoxPayload(t *testing.T) agentexec.ProxmoxGuestLifecyclePayload {
t.Helper()
payload := agentexec.ProxmoxGuestLifecyclePayload{RequestID: "attempt-pve-1", ActionID: "action-pve-1", Operation: "shutdown", GuestKind: "ct", VMID: 141, ExpectedStatus: "running", Timeout: 30}
if err := agentexec.BindProxmoxGuestLifecyclePayload(&payload); err != nil {
t.Fatal(err)
}
return payload
}
func TestProxmoxGuestLifecycleExecutesOnlyFixedCatalogAndNumericVMID(t *testing.T) {
payload := boundProxmoxPayload(t)
manager := newProxmoxGuestLifecycleManager()
manager.now = func() time.Time { return time.Unix(100, 0).UTC() }
var calls [][]string
manager.run = func(_ context.Context, tool string, args ...string) ([]byte, error) {
calls = append(calls, append([]string{tool}, args...))
if len(calls) == 1 {
return []byte("status: running\n"), nil
}
if len(calls) == 2 {
return nil, nil
}
return []byte("status: stopped\n"), nil
}
result := manager.Apply(context.Background(), payload)
want := [][]string{{"pct", "status", "141"}, {"pct", "shutdown", "141"}, {"pct", "status", "141"}}
if !reflect.DeepEqual(calls, want) || result.ExecutionPhase != agentexec.ProxmoxGuestPhaseComplete || !result.MutationCompleted || !result.ReadbackRan {
t.Fatalf("calls=%v result=%+v", calls, result)
}
}
func TestProxmoxGuestLifecycleCancellationStopsMutationAndProducesBoundFailure(t *testing.T) {
payload := boundProxmoxPayload(t)
manager := newProxmoxGuestLifecycleManager()
started := make(chan struct{})
manager.run = func(ctx context.Context, _ string, args ...string) ([]byte, error) {
if args[0] == "status" {
return []byte("status: running"), nil
}
close(started)
<-ctx.Done()
return nil, ctx.Err()
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan agentexec.ProxmoxGuestLifecycleResultPayload, 1)
go func() { done <- manager.Apply(ctx, payload) }()
<-started
cancel()
result := <-done
if !result.MutationStarted || result.MutationCompleted || result.Error == "" {
t.Fatalf("canceled result = %+v", result)
}
}
func TestProxmoxGuestLifecycleTerminalReceiptReplaysWithoutSecondMutation(t *testing.T) {
payload := boundProxmoxPayload(t)
client := &CommandClient{agentID: "agent-pve", logger: zerolog.Nop(), activeCommands: make(map[string]context.CancelFunc)}
receipts, err := operationreceipt.Open(filepath.Join(t.TempDir(), "receipts.db"), hostOperationReceiptConfig())
if err != nil {
t.Fatal(err)
}
defer receipts.Close()
client.operationReceipts = receipts
client.proxmoxGuestLifecycle = newProxmoxGuestLifecycleManager()
mutations := 0
client.proxmoxGuestLifecycle.run = func(_ context.Context, _ string, args ...string) ([]byte, error) {
if args[0] == "status" {
if mutations == 0 {
return []byte("status: running"), nil
}
return []byte("status: stopped"), nil
}
if args[0] != "shutdown" || args[1] != strconv.Itoa(payload.VMID) {
return nil, errors.New("unexpected arguments")
}
mutations++
return nil, nil
}
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
serverConnections := make(chan *websocket.Conn)
release := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, upgradeErr := upgrader.Upgrade(w, r, nil)
if upgradeErr != nil {
return
}
serverConnections <- conn
<-release
_ = conn.Close()
}))
defer server.Close()
for attempt := 0; attempt < 2; attempt++ {
remote, _, dialErr := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
if dialErr != nil {
t.Fatal(dialErr)
}
serverConn := <-serverConnections
client.handleProxmoxGuestLifecycle(context.Background(), serverConn, payload)
var message wsMessage
if err := remote.ReadJSON(&message); err != nil {
t.Fatal(err)
}
var result agentexec.ProxmoxGuestLifecycleResultPayload
if message.Type != msgTypeProxmoxGuestLifecycleResult || json.Unmarshal(message.Payload, &result) != nil || !result.MutationCompleted {
t.Fatalf("attempt %d message=%+v result=%+v", attempt, message, result)
}
_ = remote.Close()
release <- struct{}{}
}
if mutations != 1 {
t.Fatalf("mutations=%d, want 1", mutations)
}
}