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