mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Harden secure agent runtime boundaries
This commit is contained in:
@@ -265,6 +265,9 @@ scripts/release_control/*
|
||||
!scripts/release_control/record_rc_to_ga_rehearsal.py
|
||||
!scripts/release_control/secure_runtime_attestation.py
|
||||
!scripts/release_control/secure_runtime_attestation_test.py
|
||||
!scripts/release_control/secure_runtime_attestation_v6.py
|
||||
!scripts/release_control/secure_runtime_attestation_v6_test.py
|
||||
!scripts/release_control/secure_runtime_source_manifest_v6.json
|
||||
!scripts/release_control/staged_commit_shape_guard.py
|
||||
!scripts/release_control/staged_commit_shape_guard_test.py
|
||||
!scripts/release_control/internal/
|
||||
|
||||
@@ -3,18 +3,23 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/actionrunner"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/collectorlifecycle"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/dockeragent"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
@@ -61,9 +66,18 @@ func loadConfig() (runtimeConfig, error) {
|
||||
}
|
||||
config.Hostname = hostname
|
||||
}
|
||||
parsed, err := url.Parse(config.PulseURL)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "https" && !(config.Insecure && parsed.Scheme == "http")) {
|
||||
return runtimeConfig{}, errors.New("PULSE_URL must be HTTPS unless PULSE_INSECURE=true explicitly permits HTTP")
|
||||
if err := actionrunner.ValidatePulseURL(config.PulseURL); err != nil {
|
||||
return runtimeConfig{}, fmt.Errorf("PULSE_URL must use HTTPS or an exact literal loopback host: %w", err)
|
||||
}
|
||||
normalizedURL, err := securityutil.NormalizePulseHTTPBaseURL(config.PulseURL)
|
||||
if err != nil {
|
||||
return runtimeConfig{}, fmt.Errorf("PULSE_URL must use HTTPS except for loopback local use: %w", err)
|
||||
}
|
||||
if normalizedURL.Scheme == "https" && config.Insecure && config.ServerFingerprint == "" {
|
||||
if config.CAFile == "" {
|
||||
return runtimeConfig{}, errors.New("generic insecure HTTPS is forbidden for the action runner; configure a trusted CA or exact server fingerprint")
|
||||
}
|
||||
config.Insecure = false
|
||||
}
|
||||
if _, err := readPrivateValue(config.TokenFile, "runner token"); err != nil {
|
||||
return runtimeConfig{}, err
|
||||
@@ -134,11 +148,66 @@ func run() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCredentialLifecycleCommand(command string, args []string) error {
|
||||
flags := flag.NewFlagSet(command, flag.ContinueOnError)
|
||||
flags.SetOutput(os.Stderr)
|
||||
pulseURL := flags.String("url", "", "Pulse server URL")
|
||||
tokenFile := flags.String("token-file", "", "private runner token file")
|
||||
agentID := flags.String("agent-id", "", "bound runner agent identity")
|
||||
hostname := flags.String("hostname", "", "bound runner hostname")
|
||||
caFile := flags.String("cacert", "", "custom CA certificate")
|
||||
serverFingerprint := flags.String("server-fingerprint", "", "pinned server certificate fingerprint")
|
||||
insecureLoopback := flags.Bool("insecure-loopback", false, "allow plaintext only for a loopback Pulse URL")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 || strings.TrimSpace(*pulseURL) == "" || strings.TrimSpace(*tokenFile) == "" {
|
||||
return errors.New("lifecycle command requires --url and --token-file")
|
||||
}
|
||||
normalizedHostname := ""
|
||||
if command == "revoke-credential" {
|
||||
if !actionrunner.IsValidBoundedID(strings.TrimSpace(*agentID)) {
|
||||
return errors.New("revoke command requires --agent-id and --hostname")
|
||||
}
|
||||
var err error
|
||||
normalizedHostname, err = normalizeRunnerHostname(*hostname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
normalizedURL, err := securityutil.NormalizePulseHTTPBaseURL(*pulseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("action-runner lifecycle URL: %w", err)
|
||||
}
|
||||
if *insecureLoopback && normalizedURL.Scheme != "http" {
|
||||
return errors.New("--insecure-loopback is valid only for a loopback HTTP URL")
|
||||
}
|
||||
token, err := readPrivateValue(*tokenFile, "runner token")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config := actionrunner.CredentialLifecycleConfig{
|
||||
PulseURL: normalizedURL.String(), APIToken: token,
|
||||
InsecureSkipVerify: *insecureLoopback, CACertPath: strings.TrimSpace(*caFile),
|
||||
ServerFingerprint: strings.TrimSpace(*serverFingerprint),
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
switch command {
|
||||
case "cancel-pending-credential":
|
||||
return actionrunner.CancelPendingCredential(ctx, config)
|
||||
case "revoke-credential":
|
||||
return actionrunner.RevokeCredential(ctx, config, strings.TrimSpace(*agentID), normalizedHostname)
|
||||
default:
|
||||
return fmt.Errorf("unknown action-runner command %q", command)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveRunnerAgentID(config runtimeConfig) (string, error) {
|
||||
if config.AgentID != "" {
|
||||
return config.AgentID, nil
|
||||
}
|
||||
agentID, err := readPrivateValue(config.AgentIDFile, "runner agent identity")
|
||||
agentID, err := collectorlifecycle.ReadAgentIDFile(config.AgentIDFile, dedicatedCollectorUID())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -148,6 +217,18 @@ func resolveRunnerAgentID(config runtimeConfig) (string, error) {
|
||||
return agentID, nil
|
||||
}
|
||||
|
||||
func dedicatedCollectorUID() *uint64 {
|
||||
account, err := user.Lookup("pulse-agent")
|
||||
if err != nil || account == nil {
|
||||
return nil
|
||||
}
|
||||
uid, err := strconv.ParseUint(account.Uid, 10, 32)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &uid
|
||||
}
|
||||
|
||||
func normalizeRunnerHostname(value string) (string, error) {
|
||||
hostname := strings.ToLower(strings.TrimSpace(value))
|
||||
hostname = strings.TrimRight(hostname, ".")
|
||||
@@ -175,22 +256,21 @@ func runnerHostnameLabelByte(value byte) bool {
|
||||
}
|
||||
|
||||
func readPrivateValue(path, label string) (string, error) {
|
||||
info, err := os.Lstat(path)
|
||||
value, err := collectorlifecycle.ReadPrivateValueFile(path, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s file: %w", label, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 {
|
||||
return "", fmt.Errorf("%s file must be regular and inaccessible to group/other", label)
|
||||
}
|
||||
value, err := os.ReadFile(path)
|
||||
if err != nil || strings.TrimSpace(string(value)) == "" {
|
||||
return "", fmt.Errorf("%s file is unreadable or empty", label)
|
||||
}
|
||||
return strings.TrimSpace(string(value)), nil
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
var err error
|
||||
if len(os.Args) > 1 && (os.Args[1] == "cancel-pending-credential" || os.Args[1] == "revoke-credential") {
|
||||
err = runCredentialLifecycleCommand(os.Args[1], os.Args[2:])
|
||||
} else {
|
||||
err = run()
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ func TestResolveRunnerAgentIDRejectsInvalidFileIdentity(t *testing.T) {
|
||||
if err := os.WriteFile(path, []byte("bad agent\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := resolveRunnerAgentID(runtimeConfig{AgentIDFile: path}); err == nil || !strings.Contains(err.Error(), "valid agent identity") {
|
||||
if _, err := resolveRunnerAgentID(runtimeConfig{AgentIDFile: path}); err == nil || !strings.Contains(err.Error(), "identity file is invalid") {
|
||||
t.Fatalf("resolveRunnerAgentID error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -146,3 +146,54 @@ func TestLoadConfigRejectsTokenInArgvEquivalentAndInsecureHTTPByDefault(t *testi
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigPlaintextPolicyIsLoopbackOnlyEvenWhenInsecure(t *testing.T) {
|
||||
for _, rawURL := range []string{"http://pulse.example.com:7655", "http://192.168.1.20:7655", "http://agent.localhost:7655"} {
|
||||
t.Run(rawURL, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("PULSE_URL", rawURL)
|
||||
t.Setenv("PULSE_AGENT_RUNNER_TOKEN_FILE", filepath.Join(dir, "token"))
|
||||
t.Setenv("PULSE_AGENT_RUNNER_STATE_DIR", filepath.Join(dir, "state"))
|
||||
t.Setenv("PULSE_AGENT_RUNNER_HEALTH_FILE", filepath.Join(dir, "state", "health.json"))
|
||||
t.Setenv("PULSE_AGENT_RUNNER_AGENT_ID", "agent-1")
|
||||
t.Setenv("PULSE_AGENT_RUNNER_ACTIVATION_NONCE", strings.Repeat("f", 32))
|
||||
t.Setenv("PULSE_INSECURE", "true")
|
||||
if _, err := loadConfig(); err == nil || !strings.Contains(err.Error(), "loopback") {
|
||||
t.Fatalf("loadConfig(%q) error = %v, want non-loopback plaintext rejection", rawURL, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("generic insecure HTTPS rejected", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
token := filepath.Join(dir, "token")
|
||||
if err := os.WriteFile(token, []byte("runner-secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PULSE_URL", "https://pulse.example")
|
||||
t.Setenv("PULSE_AGENT_RUNNER_TOKEN_FILE", token)
|
||||
t.Setenv("PULSE_AGENT_RUNNER_STATE_DIR", filepath.Join(dir, "state"))
|
||||
t.Setenv("PULSE_AGENT_RUNNER_HEALTH_FILE", filepath.Join(dir, "state", "health.json"))
|
||||
t.Setenv("PULSE_AGENT_RUNNER_AGENT_ID", "agent-1")
|
||||
t.Setenv("PULSE_AGENT_RUNNER_ACTIVATION_NONCE", strings.Repeat("f", 32))
|
||||
t.Setenv("PULSE_INSECURE", "true")
|
||||
if _, err := loadConfig(); err == nil || !strings.Contains(err.Error(), "generic insecure HTTPS") {
|
||||
t.Fatalf("generic insecure HTTPS error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
dir := t.TempDir()
|
||||
token := filepath.Join(dir, "token")
|
||||
if err := os.WriteFile(token, []byte("runner-secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PULSE_URL", "http://127.0.0.1:7655")
|
||||
t.Setenv("PULSE_AGENT_RUNNER_TOKEN_FILE", token)
|
||||
t.Setenv("PULSE_AGENT_RUNNER_STATE_DIR", filepath.Join(dir, "state"))
|
||||
t.Setenv("PULSE_AGENT_RUNNER_HEALTH_FILE", filepath.Join(dir, "state", "health.json"))
|
||||
t.Setenv("PULSE_AGENT_RUNNER_AGENT_ID", "agent-1")
|
||||
t.Setenv("PULSE_AGENT_RUNNER_ACTIVATION_NONCE", strings.Repeat("f", 32))
|
||||
t.Setenv("PULSE_INSECURE", "true")
|
||||
if _, err := loadConfig(); err != nil {
|
||||
t.Fatalf("loopback HTTP config rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func TestReadPrivateValueRejectsFIFOWithoutBlocking(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "runner.token")
|
||||
if err := unix.Mkfifo(path, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
started := time.Now()
|
||||
if _, err := readPrivateValue(path, "runner token"); err == nil {
|
||||
t.Fatal("FIFO was accepted as a private runner value")
|
||||
}
|
||||
if elapsed := time.Since(started); elapsed > time.Second {
|
||||
t.Fatalf("FIFO rejection blocked for %s", elapsed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/collectorlifecycle"
|
||||
)
|
||||
|
||||
const (
|
||||
collectorReduceAuthorityCommand = "collector-reduce-authority"
|
||||
collectorVerifyRegistrationCommand = "collector-verify-registration"
|
||||
collectorReadAgentIDCommand = "collector-read-agent-id"
|
||||
)
|
||||
|
||||
func isCollectorLifecycleCommand(command string) bool {
|
||||
return command == collectorReduceAuthorityCommand || command == collectorVerifyRegistrationCommand || command == collectorReadAgentIDCommand
|
||||
}
|
||||
|
||||
func runCollectorLifecycleCommand(ctx context.Context, command string, args []string, stdout, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet(command, flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
pulseURL := flags.String("url", "", "Pulse server URL")
|
||||
tokenFile := flags.String("token-file", "", "private collector token file")
|
||||
agentIDFile := flags.String("agent-id-file", "", "private collector agent identity file")
|
||||
tokenOwnerUID := flags.String("token-owner-uid", "", "dedicated collector UID allowed to own the token file")
|
||||
agentID := flags.String("agent-id", "", "collector agent identity")
|
||||
hostname := flags.String("hostname", "", "collector hostname")
|
||||
caFile := flags.String("cacert", "", "custom CA certificate bundle")
|
||||
serverFingerprint := flags.String("server-fingerprint", "", "exact Pulse server leaf certificate SHA-256 fingerprint")
|
||||
previousLastSeen := flags.String("previous-last-seen", "", "registration timestamp that the replacement must advance")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return errors.New("collector lifecycle command received unexpected positional arguments")
|
||||
}
|
||||
var allowedTokenOwnerUID *uint64
|
||||
if raw := strings.TrimSpace(*tokenOwnerUID); raw != "" {
|
||||
parsed, parseErr := strconv.ParseUint(raw, 10, 32)
|
||||
if parseErr != nil {
|
||||
return fmt.Errorf("parse --token-owner-uid: %w", parseErr)
|
||||
}
|
||||
allowedTokenOwnerUID = &parsed
|
||||
}
|
||||
if command == collectorReadAgentIDCommand {
|
||||
if strings.TrimSpace(*agentIDFile) == "" {
|
||||
return errors.New("collector-read-agent-id requires --agent-id-file")
|
||||
}
|
||||
identity, err := collectorlifecycle.ReadAgentIDFile(*agentIDFile, allowedTokenOwnerUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintln(stdout, identity)
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(*pulseURL) == "" || strings.TrimSpace(*tokenFile) == "" {
|
||||
return errors.New("collector lifecycle network command requires --url and --token-file")
|
||||
}
|
||||
client, err := collectorlifecycle.New(collectorlifecycle.Config{
|
||||
PulseURL: *pulseURL,
|
||||
TokenFile: *tokenFile,
|
||||
TokenOwnerUID: allowedTokenOwnerUID,
|
||||
CACertPath: *caFile,
|
||||
ServerFingerprint: *serverFingerprint,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
requestCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
switch command {
|
||||
case collectorReduceAuthorityCommand:
|
||||
if strings.TrimSpace(*previousLastSeen) != "" {
|
||||
return errors.New("--previous-last-seen is valid only for registration verification")
|
||||
}
|
||||
return client.ReduceAuthority(requestCtx, *agentID, *hostname)
|
||||
case collectorVerifyRegistrationCommand:
|
||||
var previous time.Time
|
||||
if raw := strings.TrimSpace(*previousLastSeen); raw != "" {
|
||||
previous, err = time.Parse(time.RFC3339Nano, raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse --previous-last-seen: %w", err)
|
||||
}
|
||||
}
|
||||
registration, err := client.VerifyRegistration(requestCtx, *agentID, *hostname, previous)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintln(stdout, registration.LastSeen.Format(time.RFC3339Nano))
|
||||
return err
|
||||
default:
|
||||
return fmt.Errorf("unknown collector lifecycle command %q", command)
|
||||
}
|
||||
}
|
||||
|
||||
func collectorLifecycleExitCode(err error) int {
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
if errors.Is(err, collectorlifecycle.ErrCredentialRejected) {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/collectorlifecycle"
|
||||
)
|
||||
|
||||
func TestCollectorLifecycleCommandReducesAuthorityWithFileBearer(t *testing.T) {
|
||||
const bearer = "file-only-collector-bearer"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodPost || request.URL.Path != "/api/agents/collector/reduce-authority" {
|
||||
t.Errorf("request = %s %s", request.Method, request.URL.Path)
|
||||
}
|
||||
if got := request.Header.Get("Authorization"); got != "Bearer "+bearer {
|
||||
t.Errorf("Authorization = %q", got)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
tokenFile := writeCollectorLifecycleToken(t, bearer)
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runCollectorLifecycleCommand(context.Background(), collectorReduceAuthorityCommand, []string{
|
||||
"--url", server.URL,
|
||||
"--token-file", tokenFile,
|
||||
"--token-owner-uid", collectorLifecycleTestOwnerUID(),
|
||||
"--agent-id", "agent-1",
|
||||
"--hostname", "host.local",
|
||||
}, &stdout, &stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("runCollectorLifecycleCommand: %v (stderr %q)", err, stderr.String())
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectorLifecycleCommandRejectsBearerArgument(t *testing.T) {
|
||||
const bearer = "must-not-enter-argv"
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runCollectorLifecycleCommand(context.Background(), collectorReduceAuthorityCommand, []string{
|
||||
"--url", "http://127.0.0.1:7655",
|
||||
"--token", bearer,
|
||||
"--agent-id", "agent-1",
|
||||
"--hostname", "host.local",
|
||||
}, &stdout, &stderr)
|
||||
if err == nil || !strings.Contains(err.Error(), "flag provided but not defined: -token") {
|
||||
t.Fatalf("error = %v, want raw bearer flag rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectorLifecycleCommandPrintsAuthoritativeLastSeen(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true,"agent":{"id":"agent-1","hostname":"host.local","lastSeen":"2026-08-30T12:00:01.123456789Z"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runCollectorLifecycleCommand(context.Background(), collectorVerifyRegistrationCommand, []string{
|
||||
"--url", server.URL,
|
||||
"--token-file", writeCollectorLifecycleToken(t, "collector-bearer"),
|
||||
"--token-owner-uid", collectorLifecycleTestOwnerUID(),
|
||||
"--agent-id", "agent-1",
|
||||
"--previous-last-seen", "2026-08-30T12:00:01Z",
|
||||
}, &stdout, &stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("runCollectorLifecycleCommand: %v (stderr %q)", err, stderr.String())
|
||||
}
|
||||
if got := strings.TrimSpace(stdout.String()); got != "2026-08-30T12:00:01.123456789Z" {
|
||||
t.Fatalf("stdout = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectorLifecycleCommandSafelyReadsAgentIdentity(t *testing.T) {
|
||||
identityFile := writeCollectorLifecycleToken(t, "agent-file-bound")
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runCollectorLifecycleCommand(context.Background(), collectorReadAgentIDCommand, []string{
|
||||
"--agent-id-file", identityFile,
|
||||
"--token-owner-uid", collectorLifecycleTestOwnerUID(),
|
||||
}, &stdout, &stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("runCollectorLifecycleCommand: %v (stderr %q)", err, stderr.String())
|
||||
}
|
||||
if got := strings.TrimSpace(stdout.String()); got != "agent-file-bound" {
|
||||
t.Fatalf("stdout = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectorLifecycleCommandExitCodeDistinguishesRejectedCredential(t *testing.T) {
|
||||
if got := collectorLifecycleExitCode(nil); got != 0 {
|
||||
t.Fatalf("nil exit code = %d", got)
|
||||
}
|
||||
if got := collectorLifecycleExitCode(collectorlifecycle.ErrRegistrationPending); got != 1 {
|
||||
t.Fatalf("pending exit code = %d", got)
|
||||
}
|
||||
if got := collectorLifecycleExitCode(errors.Join(errors.New("lookup failed"), collectorlifecycle.ErrCredentialRejected)); got != 2 {
|
||||
t.Fatalf("rejected exit code = %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func writeCollectorLifecycleToken(t *testing.T, bearer string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "collector.token")
|
||||
if err := os.WriteFile(path, []byte(bearer), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func collectorLifecycleTestOwnerUID() string { return strconv.Itoa(os.Geteuid()) }
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
func collectorLifecycleTestOwnerUID() string { return "" }
|
||||
@@ -258,6 +258,16 @@ func (m *multiValue) Set(value string) error {
|
||||
func main() {
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
if len(os.Args) > 1 && isCollectorLifecycleCommand(os.Args[1]) {
|
||||
err := runCollectorLifecycleCommand(ctx, os.Args[1], os.Args[2:], os.Stdout, os.Stderr)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
}
|
||||
if code := collectorLifecycleExitCode(err); code != 0 {
|
||||
os.Exit(code)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := run(ctx, os.Args[1:], os.Getenv); err != nil {
|
||||
if err == flag.ErrHelp {
|
||||
|
||||
+34
-1
@@ -164,17 +164,50 @@ collector-writable direct-replacement fallback.
|
||||
|
||||
Exceptional telemetry crosses `/run/pulse-agent/helper.sock` to a separate
|
||||
root process. The socket admits only the `pulse-agent` UID, and the helper has
|
||||
no Pulse URL, API token, or network namespace. Its protocol exposes bounded,
|
||||
no Pulse URL or API token and runs in a private network namespace rather than
|
||||
the host network namespace. Its protocol exposes bounded,
|
||||
versioned SMART, Proxmox LXC filesystem, and fixed-endpoint container summary
|
||||
snapshots, not a shell, executable path, device path, VMID, daemon endpoint,
|
||||
environment, or caller-selected arguments. The helper
|
||||
service keeps `PrivateNetwork=true`, `RestrictAddressFamilies=AF_UNIX`,
|
||||
`NoNewPrivileges=true`, `ProtectSystem=strict`, and `ProtectHome=true`.
|
||||
It also bounds the helper cgroup to `TasksMax=64`, `LimitNOFILE=256`, and
|
||||
`MemoryMax=256M`. These limits leave headroom for the typed helper and its
|
||||
short-lived platform tools while preventing an overridden or malformed request
|
||||
from consuming unbounded process, descriptor, or memory resources.
|
||||
`PrivateDevices` is intentionally not enabled because SMART needs the host
|
||||
block devices. If the helper is missing, incompatible, or rejects a request,
|
||||
only the affected telemetry disappears; the collector does not fall back to
|
||||
sudo, root, or a broader local command path.
|
||||
|
||||
The installer treats the effective systemd configuration as part of the safe
|
||||
profile boundary. The collector, helper service, helper socket, and an
|
||||
installed action runner must load from the installer-owned `FragmentPath` with
|
||||
no `DropInPaths`. Their effective executable, identity, environment-file,
|
||||
address-family, network, and filesystem-hardening properties must match the
|
||||
rendered profile. A healthy socket or process cannot override that check.
|
||||
Qualification of helper network isolation is deliberately narrower than a
|
||||
claim about every Linux network path: the guarded systemd lab must first reach
|
||||
a TCP canary on a non-loopback host interface from the host namespace, then
|
||||
show that a process entered into the live helper's network namespace cannot
|
||||
connect to the same canary. That proves the exercised helper namespace cannot
|
||||
reach that host-interface endpoint. It does not by itself qualify every Linux
|
||||
distribution, firewall configuration, or future systemd version.
|
||||
|
||||
The committed schema-v5 qualification contract remains immutable at its
|
||||
original ordered fifteen scenarios. The effective-unit, bounded-resource, and network-isolation
|
||||
requirements use a separate schema-v6 receipt and source manifest, so earlier
|
||||
evidence is not silently reinterpreted. A schema-v6 local lab remains
|
||||
committed-main, artifact-bound self-attestation. Release-candidate
|
||||
classification additionally requires the canonical remote RC tag and commit,
|
||||
an immutable signed GitHub Release packet, release-attested checksums, trusted
|
||||
hosted-builder SLSA provenance, and a signed build contract that binds every
|
||||
qualification artifact to its package, toolchain, exact build settings and
|
||||
ldflags, version, production update-key fingerprint, and release checksum.
|
||||
The current release workflow does not yet emit that complete secure-runtime
|
||||
build contract or the four-version qualification artifact set, so a local tag
|
||||
or VCS-stamped lab binary cannot upgrade the proof to RC status.
|
||||
|
||||
The typed-helper profile cannot be combined with `--grant-smart` or
|
||||
`--grant-pct`. The collector never joins the rootful Docker group. When no
|
||||
collector-owned rootless socket is available, the helper preserves only
|
||||
|
||||
@@ -99,6 +99,14 @@ without changing the product default:
|
||||
collector-owned rootless socket exists. Explicit or automatic rollback may
|
||||
restore the old root service identity, but it removes `--enable-commands`
|
||||
and cannot resurrect the reduced server-side authority;
|
||||
- the safe-profile declaration is bound to effective systemd state, not unit
|
||||
file contents or socket health alone. The collector, helper service, helper
|
||||
socket, and installed action runner must have the installer-owned
|
||||
`FragmentPath`, no `DropInPaths`, and the expected effective executable,
|
||||
identity, environment-file, address-family, network, and filesystem
|
||||
hardening properties. The typed helper is additionally bounded to 64 tasks,
|
||||
256 file descriptors, and 256 MiB of memory, and those effective limits are
|
||||
override-tested. Existing overrides fail closed;
|
||||
- the root action runner is networked and host-mutating by design. Its unit
|
||||
retains kernel/home/control-plane hardening but does not set the host
|
||||
filesystem read-only, because apt and Proxmox operations require their
|
||||
@@ -153,6 +161,41 @@ replacement admission closes the exact predecessor socket; successful
|
||||
activation durably replaces its secret; restart rejects the predecessor; and
|
||||
self-revoke survives a second restart. That code-level transport proof is not
|
||||
part of the systemd receipt or an exact release-candidate exercise. The
|
||||
committed schema-v5 contract added the three helper-update recovery scenarios
|
||||
and remains immutable at its original ordered fifteen-scenario definition.
|
||||
Schema v6 is a separate twenty-scenario contract. It adds effective helper
|
||||
service, helper resource-limit, helper socket, and runner override rejection
|
||||
plus the host-canary helper-network-namespace exercise, and binds those claims to
|
||||
`secure_runtime_source_manifest_v6.json`. It does not reinterpret schema-v4 or
|
||||
schema-v5 evidence.
|
||||
|
||||
Schema-v6 release-candidate classification does not trust a local ref or Go
|
||||
VCS stamp. Pulse release tags are workflow-created annotated tags rather than
|
||||
signed tags, so tag presence alone is not authority. RC classification
|
||||
requires the exact canonical remote tag object and peeled commit, the immutable
|
||||
GitHub Release and release attestation, release-attested `checksums.txt`, the
|
||||
portable hosted-assembly provenance from `build-release-candidate.yml`, a
|
||||
separate provenance bundle that binds every qualification binary to
|
||||
`compile-release-payload.yml` with self-hosted runners denied, and a signed
|
||||
secure-runtime build contract. That contract must bind every qualification
|
||||
artifact to the checksums and record both workflow identities, a
|
||||
GitHub-hosted-only compiler policy, the source commit, Go toolchain, package,
|
||||
target, `CGO_ENABLED`, `-trimpath`, `-buildvcs=false`, exact ldflags, version,
|
||||
and production update-key fingerprint. Hosted assembly of a payload emitted by
|
||||
a self-hosted compiler is not trusted compilation provenance. The current
|
||||
compiler workflow is self-hosted and the release workflow does not yet publish
|
||||
the compiler provenance, secure-runtime build contract, or full multi-version
|
||||
qualification artifact set. Therefore release-candidate classification fails
|
||||
closed, and current local lab evidence remains committed-main, artifact-bound,
|
||||
and self-attested even if a local tag exists.
|
||||
|
||||
Schema-v6 committed-main classification is likewise not caller-relative. The
|
||||
attester accepts only `origin/main`, requires the canonical Pulse origin URL,
|
||||
and compares the local remote-tracking commit with a fresh `refs/heads/main`
|
||||
query before proving ancestry. `HEAD`, a local branch, or an arbitrary ref can
|
||||
never receive the committed-main label.
|
||||
|
||||
The
|
||||
repository still needs a fresh exact committed release-candidate run,
|
||||
representative Proxmox, SMART, Docker and rootless Podman telemetry/action
|
||||
parity, appliance profiles, and the external security review. Until those
|
||||
@@ -197,10 +240,13 @@ The collector may not:
|
||||
### 3. Typed Privileged Helper
|
||||
|
||||
A small root-owned helper provides only the privileged operations that have a
|
||||
documented monitoring or update need. It has no Pulse credential and no
|
||||
outbound network access. The preferred transport is a root-owned local Unix
|
||||
socket with peer-credential validation and a versioned request/response
|
||||
schema.
|
||||
documented monitoring or update need. It has no Pulse credential and runs in a
|
||||
private network namespace restricted to `AF_UNIX`. The preferred transport is
|
||||
a root-owned local Unix socket with peer-credential validation and a versioned
|
||||
request/response schema. A qualification receipt may claim only the exercised
|
||||
network boundary: a host-reachable non-loopback TCP canary was unreachable
|
||||
from the live helper namespace. Broader platform-wide non-connectivity remains
|
||||
unqualified until exercised on that platform.
|
||||
|
||||
Initial operation families are expected to be:
|
||||
|
||||
@@ -355,7 +401,11 @@ Required proof:
|
||||
version and field
|
||||
- adversarial tests reject path traversal, argument injection, unknown ops,
|
||||
oversized output, timeout abuse, symlink swaps, and unauthorized peers
|
||||
- process/network proof shows the helper cannot make outbound connections
|
||||
- process/network proof first reaches a non-loopback host-interface TCP canary
|
||||
from the host namespace, then enters the live helper process's network
|
||||
namespace and proves that the same connection is denied. This is evidence
|
||||
for the exercised helper namespace and endpoint, not a universal claim about
|
||||
every Linux distribution, firewall, address family, or future systemd build
|
||||
- telemetry parity tests compare the safe profile with the current root
|
||||
baseline and classify every intentional difference
|
||||
- update tests prove signature identity, atomic activation, restart, health
|
||||
|
||||
@@ -116,22 +116,55 @@ identity file is present. Issuance and every runner boundary use the same
|
||||
bounded action-identity vocabulary. Credential
|
||||
rotation is a prepare/commit transaction. Issuance stores a ten-minute pending
|
||||
replacement beside the active predecessor; the pending transport may register
|
||||
but is not dispatchable. The runner first durably replaces a private health
|
||||
marker carrying the current installer-generated activation nonce, then calls
|
||||
the authenticated activation method. Activation atomically removes the exact
|
||||
predecessor set, removes the replacement expiry/pending state, and promotes the
|
||||
exact registered session. Failure before that commit leaves the predecessor
|
||||
valid, while persistence failure restores both records. The installer removes
|
||||
but is staged in a separate bounded session slot and is not dispatchable.
|
||||
Pending reconnect or flood traffic may replace only that pending slot; the
|
||||
active predecessor remains connected and dispatchable until commit. The runner
|
||||
first durably replaces a private pending
|
||||
health marker carrying the current installer-generated activation nonce, then
|
||||
calls the authenticated activation method. Activation atomically removes the
|
||||
exact predecessor set, removes the replacement expiry/pending state, and
|
||||
promotes the exact registered pending session while the token inventory lock
|
||||
serializes both decisions. The server persists the activated inventory before
|
||||
the bounded session-map swap and closes the displaced predecessor transport
|
||||
only after both locks are released. If the exact pending transport vanished or
|
||||
was replaced, the server durably restores the prior inventory and returns
|
||||
conflict; if that compensating persistence fails, memory remains aligned with
|
||||
the last known durable activated inventory and the response is indeterminate,
|
||||
never a false success. Only after a successful response may the runner replace the
|
||||
marker with activated state. Failure before that commit leaves the predecessor
|
||||
valid, while initial persistence failure or a successful compensating save
|
||||
restores both records. Installer rollback is
|
||||
authorized only by a bodyless, self-only cancellation that durably removes the
|
||||
exact still-pending replacement under the same inventory lock as activation. A
|
||||
committed replacement returns conflict; persistence or admission-tombstone
|
||||
uncertainty is never rollback authority. The installer removes
|
||||
the prior marker before restart, ignores wall-clock mtime as authority, and
|
||||
accepts only an activated marker whose nonce and canonical agent ID match the
|
||||
current attempt; rollback never restores a prior marker. An already-started
|
||||
current attempt; rollback never restores a prior marker. If readiness fails,
|
||||
the installer stops the replacement and restores the predecessor only after
|
||||
that atomic cancellation succeeds. Activation conflict, transport/TLS failure,
|
||||
persistence failure, or indeterminate cancellation retains the replacement
|
||||
with an explicit
|
||||
repair-required result, because restoring the potentially revoked predecessor
|
||||
would create a credential/runtime split. Before restart or predecessor-backup
|
||||
removal, that path atomically and durably rewrites the exact requested
|
||||
replacement bearer to the root-owned token file; failure leaves the runner
|
||||
stopped, does not restore the predecessor, and requires re-enrollment. An
|
||||
already-started
|
||||
host mutation remains governed by its typed receipt and best-effort
|
||||
cancellation semantics rather than being described as rolled back. Runner
|
||||
uninstall attempts an authenticated
|
||||
credential self-revoke before deleting local state. The delete route may
|
||||
remove only the caller's exact host-bound action-runner record; an unreachable
|
||||
server cannot prevent local runner removal and leaves an explicit operator
|
||||
revocation residual.
|
||||
uninstall stops and disables the runner, then requires an authenticated durable
|
||||
credential self-revoke before deleting any local artifact. The delete route may
|
||||
remove only the caller's exact host-bound action-runner record. An unreachable
|
||||
or untrusted server, missing credential, or persistence failure retains all
|
||||
root-only recovery material for retry or manual server-side revocation.
|
||||
Runner WS registration and activation, cancellation, and self-revocation HTTP
|
||||
calls require HTTPS/WSS with normal trust, a configured CA, or an enforced
|
||||
certificate-DER fingerprint. Plaintext HTTP/WS accepts only exact `localhost`,
|
||||
`127/8`, or `::1` literals (not `*.localhost`) and never
|
||||
inherits generic installer insecure or curl `-k` behavior. Bearer-bearing
|
||||
activation, cancellation, and self-revocation clients connect directly and do
|
||||
not inherit ambient HTTP proxy variables.
|
||||
Runner readiness is exposed through a bounded, secret-free health marker that
|
||||
is replaced atomically only after its contents reach stable storage. The
|
||||
marker distinguishes registered/pending from activated and carries only the
|
||||
@@ -375,9 +408,11 @@ installer download and the agent's subsequent Pulse TLS connection.
|
||||
5i. `internal/agenthelper/`
|
||||
5j. `internal/actionrunner/`
|
||||
5k. `internal/dockeragent/action_runtime.go`
|
||||
5l. `internal/collectorlifecycle/`
|
||||
6. `cmd/pulse-agent/main.go`
|
||||
6a. `cmd/pulse-agent-helper/main.go`
|
||||
6b. `cmd/pulse-agent-runner/main.go`
|
||||
6c. `cmd/pulse-agent/collector_lifecycle.go`
|
||||
7. `scripts/install.sh`
|
||||
8. `scripts/install.ps1`
|
||||
8a. `.github/workflows/unified-agent-native.yml`
|
||||
@@ -1541,6 +1576,17 @@ instead of lifecycle-local centered icon/text shells.
|
||||
|
||||
## Extension Points
|
||||
|
||||
Safe-profile installation and migration invoke the private lifecycle commands
|
||||
in `cmd/pulse-agent/collector_lifecycle.go`, backed by
|
||||
`internal/collectorlifecycle/`, for collector authority reduction and
|
||||
authoritative registration proof. These commands are installer-only lifecycle
|
||||
boundaries: they read the bearer from one bounded, no-follow descriptor owned
|
||||
by root or the configured collector UID; use system/custom CA trust or exact
|
||||
DER pinning; reject redirects and ambient proxies; and permit plaintext only
|
||||
for literal loopback hosts. Any new bearer-bearing collector transition must
|
||||
extend this client and its installer negative tests instead of adding a curl
|
||||
or generic insecure-transport path.
|
||||
|
||||
The authenticated runtime-display projection under shared `internal/api/` may
|
||||
carry the effective global `disableDockerUpdateActions` boolean so non-admin
|
||||
viewers render container updates read-only. This is API/settings presentation
|
||||
|
||||
@@ -53,12 +53,25 @@ deadline, not predecessor secrets.
|
||||
commit. It requires the current `agent:exec` bearer, exact organization,
|
||||
canonical agent/hostname binding, and an exact registered pending transport.
|
||||
A pending transport is visible to this commit proof but unavailable to action
|
||||
dispatch. The commit clears the pending expiry, durably removes only the
|
||||
server-recorded predecessor IDs, restores the complete inventory on persistence
|
||||
failure, promotes the exact replacement session, and invalidates exact stale
|
||||
sessions without allowing a caller-selected token ID. Repeating activation for
|
||||
an already active exact session is idempotent so a lost HTTP response can be
|
||||
reconciled safely.
|
||||
dispatch and cannot evict or interrupt the active predecessor. The commit
|
||||
requires durable token persistence, clears the pending expiry, removes only the
|
||||
server-recorded predecessor IDs, and atomically promotes the exact replacement
|
||||
session while invalidating exact stale sessions without allowing a
|
||||
caller-selected token ID. Persistence or promotion failure preserves one
|
||||
coherent durable inventory/session outcome; the route never reports success for
|
||||
an in-memory-only activation or a vanished pending connection. Repeating
|
||||
activation for an already active exact session is idempotent so a lost HTTP
|
||||
response can be reconciled safely.
|
||||
|
||||
`DELETE /api/agents/action-runner/credential/activation` is the only
|
||||
rollback-authorizing cancellation boundary for an installer-held replacement.
|
||||
It accepts no request body, authenticates the exact pending replacement bearer,
|
||||
and serializes with activation under the same durable token-inventory lock. A
|
||||
successful `204` durably removes that replacement and tombstones its exact
|
||||
admission so a pre-admitted or late transport cannot commit afterward. If
|
||||
activation has already won, the route returns conflict; persistence failure or
|
||||
any indeterminate state returns an error and must never authorize restoring the
|
||||
predecessor credential or files.
|
||||
|
||||
`DELETE /api/agents/action-runner/credential` is the runner's narrowly scoped
|
||||
self-revoke operation. It requires the current `agent:exec` bearer credential,
|
||||
|
||||
@@ -148,11 +148,29 @@ and the already selected typed helper profile. The runner binary, unit,
|
||||
configuration, credential, health record, and receipt database are root-owned
|
||||
and independent from collector state. Activation is transactional: the server
|
||||
keeps the prior credential valid while a bounded replacement registers without
|
||||
dispatch authority; the runner durably writes an installer-nonce-bound health
|
||||
marker and commits activation before the installer removes backups. The
|
||||
dispatch authority in a separate pending session slot. Pending reconnects can
|
||||
replace only that slot and cannot evict or interrupt the active dispatch
|
||||
transport. The runner durably writes an installer-nonce-bound health
|
||||
marker in pending state, commits activation, and then replaces the marker with
|
||||
activated state before the installer removes backups. Commit durably writes
|
||||
the activated token inventory and, under the same inventory transaction,
|
||||
performs a bounded exact pending-to-active session-map swap; displaced-socket
|
||||
cleanup happens only after transaction locks are released. A missing or
|
||||
superseded pending transport causes a durable inventory rollback and conflict.
|
||||
If that compensating save fails, memory follows the last known durable active
|
||||
inventory and activation is reported indeterminate so recovery retains repair
|
||||
material rather than claiming success. The
|
||||
installer deletes the prior marker before restart, does not trust filesystem
|
||||
timestamps, never restores a prior marker, and restores the previous runner-only
|
||||
files if the current nonce never reaches activated state. Disable and uninstall
|
||||
timestamps, and never restores a prior marker. If readiness fails, it stops the
|
||||
replacement and restores previous runner-only files only when a bodyless self-
|
||||
cancellation durably removes the exact pending replacement under the activation
|
||||
transaction lock. Activation conflict, transport/TLS failure, persistence
|
||||
failure, or admission-fence uncertainty retains the new credential/runtime and returns a
|
||||
repair-required failure rather than restoring a potentially revoked secret. It
|
||||
must first atomically and durably restore the exact requested replacement
|
||||
bearer to the root-only token file; if that write fails, the runner remains
|
||||
stopped and re-enrollment is required without predecessor restoration.
|
||||
Disable and uninstall
|
||||
remove only remediation and leave monitoring running. The action
|
||||
credential is never placed in argv or reused as the collector token. The
|
||||
installer persists the canonical enrollment hostname for runner admission. If
|
||||
@@ -163,10 +181,13 @@ file remains the fallback for later-generated IDs. The direct binding takes
|
||||
precedence consistently during runner startup, activation health, and
|
||||
self-revocation, and issuance rejects identities outside the runner's bounded
|
||||
action-identity vocabulary. The installer
|
||||
uses a private curl configuration for best-effort exact self-revocation before
|
||||
local teardown, so the bearer secret does not enter argv. Server unreachability
|
||||
is warned explicitly but does not make local removal depend on the remote
|
||||
control plane.
|
||||
uses the Go lifecycle client with a root-only token file for exact durable self-
|
||||
revocation before local teardown, so the bearer secret does not enter argv. If
|
||||
any runner artifact exists, server unreachability, TLS/auth failure, or missing
|
||||
credential stops and disables the runner but retains every artifact for retry or
|
||||
manual server-side revocation. Runner WS and lifecycle HTTP require HTTPS/WSS
|
||||
with system/custom CA trust or exact certificate-DER pinning; plaintext is
|
||||
loopback-only and generic installer insecure/curl `-k` is never inherited.
|
||||
The runner must retain a writable host filesystem because its closed protocol
|
||||
performs real package, storage, guest, and container mutations;
|
||||
`ProtectSystem=strict` is therefore forbidden for that unit. This exception
|
||||
|
||||
@@ -1299,6 +1299,7 @@
|
||||
"internal/agenthelper/",
|
||||
"internal/agenttarget/",
|
||||
"internal/agentupdate/",
|
||||
"internal/collectorlifecycle/",
|
||||
"internal/hostagent/",
|
||||
"scripts/intelligence_lab/"
|
||||
],
|
||||
@@ -1306,6 +1307,7 @@
|
||||
".github/workflows/unified-agent-native.yml",
|
||||
"cmd/pulse-agent-helper/main.go",
|
||||
"cmd/pulse-agent-runner/main.go",
|
||||
"cmd/pulse-agent/collector_lifecycle.go",
|
||||
"cmd/pulse-agent/main.go",
|
||||
"cmd/pulse-agent/service_windows.go",
|
||||
"frontend-modern/src/api/agentProfiles.ts",
|
||||
@@ -1394,6 +1396,25 @@
|
||||
"exact_files": [],
|
||||
"require_explicit_path_policy_coverage": true,
|
||||
"path_policies": [
|
||||
{
|
||||
"id": "collector-lifecycle-installer-transport",
|
||||
"label": "authenticated collector authority transition and registration proof",
|
||||
"match_prefixes": [
|
||||
"internal/collectorlifecycle/"
|
||||
],
|
||||
"match_files": [
|
||||
"cmd/pulse-agent/collector_lifecycle.go"
|
||||
],
|
||||
"allow_same_subsystem_tests": true,
|
||||
"test_prefixes": [
|
||||
"internal/collectorlifecycle/"
|
||||
],
|
||||
"exact_files": [
|
||||
"cmd/pulse-agent/collector_lifecycle_test.go",
|
||||
"internal/api/collector_authority_test.go",
|
||||
"scripts/installtests/install_sh_test.go"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "action-runner-runtime",
|
||||
"label": "separate typed action runner and durable receipt proof",
|
||||
@@ -1477,6 +1498,7 @@
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"cmd/pulse-agent/collector_lifecycle_test.go",
|
||||
"cmd/pulse-agent/main_test.go",
|
||||
"cmd/pulse-agent/observers_config_test.go",
|
||||
"scripts/installtests/agent_state_dir_lifecycle_test.go"
|
||||
@@ -1625,6 +1647,7 @@
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"internal/agentupdate/coverage_test.go",
|
||||
"internal/hostagent/action_runner_client_test.go",
|
||||
"internal/hostagent/agent_flushbuffer_test.go",
|
||||
"internal/hostagent/agent_metrics_test.go",
|
||||
"internal/hostagent/agent_new_test.go",
|
||||
|
||||
@@ -97,21 +97,47 @@ older unactivated replacements. Issuance, root-owned runtime configuration,
|
||||
session admission, typed payloads, and receipts share one bounded
|
||||
action-identity vocabulary, so an unsafe or unrepresentable host identity is
|
||||
rejected before any action credential is minted. The pending credential may authenticate one
|
||||
exact runner transport but cannot become dispatch authority. After the runner
|
||||
durably records its current activation nonce, its authenticated activation
|
||||
request atomically promotes the replacement and revokes the server-recorded
|
||||
predecessor set. A persistence failure restores both sides of the transition;
|
||||
exact runner transport in a separate bounded pending slot but cannot become
|
||||
dispatch authority. Pending reconnects replace only that slot; they cannot
|
||||
close or displace the active predecessor, which remains the dispatch target
|
||||
until commit. After the runner
|
||||
durably records its current activation nonce as pending, its authenticated
|
||||
activation request durably activates the replacement and revokes the
|
||||
server-recorded predecessor set, then atomically promotes the exact pending
|
||||
transport under the same token-inventory transaction; displaced transport
|
||||
cleanup occurs outside both locks and the local activated marker follows that
|
||||
commit. If the pending transport vanished or was superseded, a compensating
|
||||
durable save restores both sides of the transition and returns conflict. If the
|
||||
compensating save itself fails, memory remains aligned with the last known
|
||||
durable activated inventory and the response is indeterminate rather than a
|
||||
false success. An initial persistence failure also restores both sides;
|
||||
an unactivated replacement expires without revoking the predecessor.
|
||||
Installer recovery stops the replacement and may restore a predecessor only
|
||||
after a bodyless self-cancellation durably removes the exact pending credential
|
||||
under the activation transaction lock. Activation conflict or any transport,
|
||||
TLS, persistence, or admission-tombstone uncertainty retains the new
|
||||
credential/runtime and surfaces repair-required rather than reinstalling a
|
||||
secret that the server may already have revoked. Retention is claimed only
|
||||
after the exact requested replacement bearer is atomically and durably
|
||||
reinstalled with root-only ownership; failure keeps the runner stopped and
|
||||
requires re-enrollment without restoring the predecessor.
|
||||
That invalidation is exact and post-persistence: it matches organization,
|
||||
token, canonical agent, hostname, runtime role, and typed capability before
|
||||
closing the session, so a stale rotation cannot evict a replacement. Activation
|
||||
is idempotent for the exact live session so transport-level response loss is
|
||||
recoverable. The
|
||||
is idempotent for an already committed exact credential so transport-level
|
||||
response loss is recoverable without requiring a stale pending-session
|
||||
snapshot. The
|
||||
runner may also delete only its own matching record using its bearer
|
||||
credential; browser sessions and a caller-selected token ID are rejected, and
|
||||
persistence failure restores the prior inventory. Installer teardown keeps the
|
||||
secret in a private file/config boundary rather than argv and reports remote
|
||||
revocation failure without retaining local remediation authority.
|
||||
secret in a private file/config boundary rather than argv, stops and disables
|
||||
the runner first, and removes no artifact until self-revocation is durably
|
||||
confirmed. Runner WS and lifecycle HTTP use HTTPS/WSS with normal/custom CA
|
||||
trust or exact DER pinning; plaintext accepts only exact `localhost`, `127/8`,
|
||||
or `::1` literals, never resolver-controlled `*.localhost`, and curl `-k` never
|
||||
carries the runner bearer. These bearer-bearing lifecycle clients also disable
|
||||
ambient proxy discovery so an unconfigured `HTTP_PROXY`/`HTTPS_PROXY` cannot
|
||||
receive the credential.
|
||||
Safe-profile migration uses the authenticated
|
||||
`POST /api/agents/collector/reduce-authority` transition. The server accepts
|
||||
only the caller's exact organization, agent, and canonical hostname; rejects
|
||||
@@ -348,6 +374,15 @@ with missing, unknown, or unrelated scopes fail closed.
|
||||
|
||||
## Extension Points
|
||||
|
||||
The agent-lifecycle-owned `internal/collectorlifecycle/` package and
|
||||
`cmd/pulse-agent/collector_lifecycle.go` are the security boundary for
|
||||
bearer-authenticated safe-profile migration calls. They must preserve direct
|
||||
transport, redirect rejection, normal/custom CA or exact DER-leaf pin trust,
|
||||
literal-loopback-only plaintext, and single-open bounded credential-file
|
||||
validation with an explicit root-or-collector-owner matrix. Installer changes
|
||||
must not replace this boundary with curl `-k`, plaintext to a non-loopback
|
||||
host, proxy-aware transport, or a bearer supplied in argv.
|
||||
|
||||
Catalog edits in `frontend-modern/src/i18n/` that add or promote Patrol-trigger
|
||||
copy (such as an alert's primary "Have Patrol investigate" action) must stay
|
||||
non-disclosing: the manual Patrol trigger carries resource identity only —
|
||||
|
||||
@@ -40,14 +40,19 @@ the runner and session protocol. That fail-closed identity result cannot select,
|
||||
delete, restore, or reclassify storage/recovery state.
|
||||
Runner credential rotation follows the shared two-phase token-inventory commit
|
||||
boundary: issuance durably prepares a bounded, non-dispatchable replacement
|
||||
without removing the active predecessor; activation after durable runner health
|
||||
proof promotes the exact registered replacement and removes only its recorded
|
||||
predecessor set. Failed persistence restores the complete inventory, and an
|
||||
unactivated replacement expires without changing the predecessor. Exact bearer
|
||||
self-revocation uses the same durable boundary and cannot select another token,
|
||||
organization, or host. That rollback protects restart-time
|
||||
credential truth only; it is not a customer backup, recovery point, restore
|
||||
operation, or storage-cleanup grant.
|
||||
without removing or disconnecting the active predecessor; activation after
|
||||
durable runner health proof requires durable token persistence and atomically
|
||||
promotes the exact registered replacement while removing only its recorded
|
||||
predecessor set. The installer may restore predecessor files only after the
|
||||
server atomically cancels and durably removes the exact still-pending bearer;
|
||||
activation-winning, persistence-failure, and indeterminate outcomes retain the
|
||||
replacement instead. Failed persistence or session promotion preserves one
|
||||
coherent token/session outcome, and an unactivated replacement expires without
|
||||
changing the predecessor. Exact bearer self-revocation uses the same durable
|
||||
boundary and cannot select another token, organization, or host. These
|
||||
credential rollback rules protect restart-time authentication truth only; they
|
||||
are not a customer backup, recovery point, restore operation, or
|
||||
storage-cleanup grant.
|
||||
The adjacent collector authority-reduction route removes `agent:exec` and
|
||||
`agent:manage` only from the caller's exact host-bound credential and closes
|
||||
only its matching command session after durable persistence. This transition
|
||||
|
||||
@@ -164,17 +164,50 @@ collector-writable direct-replacement fallback.
|
||||
|
||||
Exceptional telemetry crosses `/run/pulse-agent/helper.sock` to a separate
|
||||
root process. The socket admits only the `pulse-agent` UID, and the helper has
|
||||
no Pulse URL, API token, or network namespace. Its protocol exposes bounded,
|
||||
no Pulse URL or API token and runs in a private network namespace rather than
|
||||
the host network namespace. Its protocol exposes bounded,
|
||||
versioned SMART, Proxmox LXC filesystem, and fixed-endpoint container summary
|
||||
snapshots, not a shell, executable path, device path, VMID, daemon endpoint,
|
||||
environment, or caller-selected arguments. The helper
|
||||
service keeps `PrivateNetwork=true`, `RestrictAddressFamilies=AF_UNIX`,
|
||||
`NoNewPrivileges=true`, `ProtectSystem=strict`, and `ProtectHome=true`.
|
||||
It also bounds the helper cgroup to `TasksMax=64`, `LimitNOFILE=256`, and
|
||||
`MemoryMax=256M`. These limits leave headroom for the typed helper and its
|
||||
short-lived platform tools while preventing an overridden or malformed request
|
||||
from consuming unbounded process, descriptor, or memory resources.
|
||||
`PrivateDevices` is intentionally not enabled because SMART needs the host
|
||||
block devices. If the helper is missing, incompatible, or rejects a request,
|
||||
only the affected telemetry disappears; the collector does not fall back to
|
||||
sudo, root, or a broader local command path.
|
||||
|
||||
The installer treats the effective systemd configuration as part of the safe
|
||||
profile boundary. The collector, helper service, helper socket, and an
|
||||
installed action runner must load from the installer-owned `FragmentPath` with
|
||||
no `DropInPaths`. Their effective executable, identity, environment-file,
|
||||
address-family, network, and filesystem-hardening properties must match the
|
||||
rendered profile. A healthy socket or process cannot override that check.
|
||||
Qualification of helper network isolation is deliberately narrower than a
|
||||
claim about every Linux network path: the guarded systemd lab must first reach
|
||||
a TCP canary on a non-loopback host interface from the host namespace, then
|
||||
show that a process entered into the live helper's network namespace cannot
|
||||
connect to the same canary. That proves the exercised helper namespace cannot
|
||||
reach that host-interface endpoint. It does not by itself qualify every Linux
|
||||
distribution, firewall configuration, or future systemd version.
|
||||
|
||||
The committed schema-v5 qualification contract remains immutable at its
|
||||
original ordered fifteen scenarios. The effective-unit, bounded-resource, and network-isolation
|
||||
requirements use a separate schema-v6 receipt and source manifest, so earlier
|
||||
evidence is not silently reinterpreted. A schema-v6 local lab remains
|
||||
committed-main, artifact-bound self-attestation. Release-candidate
|
||||
classification additionally requires the canonical remote RC tag and commit,
|
||||
an immutable signed GitHub Release packet, release-attested checksums, trusted
|
||||
hosted-builder SLSA provenance, and a signed build contract that binds every
|
||||
qualification artifact to its package, toolchain, exact build settings and
|
||||
ldflags, version, production update-key fingerprint, and release checksum.
|
||||
The current release workflow does not yet emit that complete secure-runtime
|
||||
build contract or the four-version qualification artifact set, so a local tag
|
||||
or VCS-stamped lab binary cannot upgrade the proof to RC status.
|
||||
|
||||
The typed-helper profile cannot be combined with `--grant-smart` or
|
||||
`--grant-pct`. The collector never joins the rootful Docker group. When no
|
||||
collector-owned rootless socket is available, the helper preserves only
|
||||
|
||||
@@ -9,6 +9,19 @@ import (
|
||||
// TransportConfig carries only the separate runner's connection credential,
|
||||
// state, and TLS inputs. There is deliberately no monitoring/report config.
|
||||
type TransportConfig = hostagent.ActionRunnerClientConfig
|
||||
type CredentialLifecycleConfig = hostagent.ActionRunnerCredentialLifecycleConfig
|
||||
|
||||
func ValidatePulseURL(raw string) error {
|
||||
return hostagent.ValidateActionRunnerPulseURL(raw)
|
||||
}
|
||||
|
||||
func CancelPendingCredential(ctx context.Context, config CredentialLifecycleConfig) error {
|
||||
return hostagent.CancelPendingActionRunnerCredential(ctx, config)
|
||||
}
|
||||
|
||||
func RevokeCredential(ctx context.Context, config CredentialLifecycleConfig, hostID, hostname string) error {
|
||||
return hostagent.RevokeActionRunnerCredential(ctx, config, hostID, hostname)
|
||||
}
|
||||
|
||||
// Client is the action-runner-owned facade over the existing typed action
|
||||
// codecs and executors. The hostagent implementation remains an internal
|
||||
|
||||
+180
-53
@@ -64,6 +64,7 @@ var hostStorageCleanupFingerprintPattern = regexp.MustCompile(`^sha256:[a-f0-9]{
|
||||
type Server struct {
|
||||
mu sync.RWMutex
|
||||
agents map[string]*agentConn // organizationID + agentID -> connection
|
||||
pendingActionRunners map[string]*agentConn // organizationID + agentID -> exact prepared runner transport
|
||||
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
|
||||
@@ -87,6 +88,7 @@ type Server struct {
|
||||
newCommandApprovalGrant func([]byte, string, ExecuteCommandPayload, time.Time, time.Duration) (*CommandApprovalGrant, error)
|
||||
now func() time.Time
|
||||
agentRegisteredNotifier func(AgentAdmission)
|
||||
actionRunnerAdmissionTombstones map[string]time.Time
|
||||
}
|
||||
|
||||
const defaultOrganizationID = "default"
|
||||
@@ -197,6 +199,8 @@ func NewServerWithAdmissionValidator(admit AgentRegistrationValidator, validateS
|
||||
|
||||
return &Server{
|
||||
agents: make(map[string]*agentConn),
|
||||
pendingActionRunners: make(map[string]*agentConn),
|
||||
actionRunnerAdmissionTombstones: make(map[string]time.Time),
|
||||
pendingReqs: make(map[string]chan CommandResultPayload),
|
||||
pendingHostStorageCleanups: make(map[string]chan HostStorageCleanupResultPayload),
|
||||
pendingHostUpdates: make(map[string]chan HostUpdateResultPayload),
|
||||
@@ -220,6 +224,60 @@ func NewServerWithAdmissionValidator(admit AgentRegistrationValidator, validateS
|
||||
}
|
||||
}
|
||||
|
||||
func actionRunnerAdmissionTombstoneKey(admission AgentAdmission) string {
|
||||
return strings.Join([]string{
|
||||
normalizeOrganizationID(admission.OrganizationID),
|
||||
strings.TrimSpace(admission.TokenID),
|
||||
strings.TrimSpace(admission.AgentID),
|
||||
unifiedresources.NormalizeFullHostname(admission.Hostname),
|
||||
strings.TrimSpace(admission.RuntimeRole),
|
||||
strings.TrimSpace(admission.ActionCapability),
|
||||
}, "\x00")
|
||||
}
|
||||
|
||||
// TombstoneActionRunnerAdmission prevents an already-admitted prepared socket
|
||||
// from registering after its credential has been durably cancelled. The
|
||||
// tombstone is exact and bounded to the preparation window.
|
||||
func (s *Server) TombstoneActionRunnerAdmission(admission AgentAdmission, until time.Time) bool {
|
||||
if s == nil || strings.TrimSpace(admission.TokenID) == "" || strings.TrimSpace(admission.AgentID) == "" ||
|
||||
strings.TrimSpace(admission.Hostname) == "" ||
|
||||
strings.TrimSpace(admission.RuntimeRole) != RuntimeRoleActionRunner ||
|
||||
strings.TrimSpace(admission.ActionCapability) != ActionCapabilityTypedV1 {
|
||||
return false
|
||||
}
|
||||
now := time.Now()
|
||||
if s.now != nil {
|
||||
now = s.now()
|
||||
}
|
||||
maximum := now.Add(10 * time.Minute)
|
||||
if !until.After(now) || until.After(maximum) {
|
||||
until = maximum
|
||||
}
|
||||
key := actionRunnerAdmissionTombstoneKey(admission)
|
||||
s.mu.Lock()
|
||||
if s.actionRunnerAdmissionTombstones == nil {
|
||||
s.actionRunnerAdmissionTombstones = make(map[string]time.Time)
|
||||
}
|
||||
for existingKey, expiry := range s.actionRunnerAdmissionTombstones {
|
||||
if !expiry.After(now) {
|
||||
delete(s.actionRunnerAdmissionTombstones, existingKey)
|
||||
}
|
||||
}
|
||||
s.actionRunnerAdmissionTombstones[key] = until
|
||||
sessionKey := agentSessionKey(admission.OrganizationID, admission.AgentID)
|
||||
var invalidated *agentConn
|
||||
if existing, ok := s.pendingActionRunners[sessionKey]; ok && actionRunnerAdmissionTombstoneKey(existing.admission) == key {
|
||||
delete(s.pendingActionRunners, sessionKey)
|
||||
invalidated = existing
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if invalidated != nil {
|
||||
invalidated.signalDone()
|
||||
_ = invalidated.conn.Close()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// WithOrganizationID scopes command-session lookup and dispatch to a tenant.
|
||||
// Empty values normalize to the single-tenant default for compatibility.
|
||||
func WithOrganizationID(ctx context.Context, organizationID string) context.Context {
|
||||
@@ -358,13 +416,9 @@ func (s *Server) connectionForOrganization(organizationID, agentID string) (*age
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
// A prepared action-runner credential may establish the authenticated
|
||||
// transport needed to commit activation, but it is not dispatch authority.
|
||||
// Promotion clears this bit only after the server durably revokes the prior
|
||||
// credential set.
|
||||
if ac.admission.ActivationPending {
|
||||
return nil, false
|
||||
}
|
||||
// Prepared action runners live only in pendingActionRunners. Membership in
|
||||
// the active map is the immutable dispatch-authority decision; do not read
|
||||
// or mutate admission state outside the server lock during promotion.
|
||||
if s.validateSession == nil || s.validateSession(ac.admission) {
|
||||
return ac, true
|
||||
}
|
||||
@@ -393,27 +447,52 @@ func (s *Server) HasActionRunnerSession(admission AgentAdmission) bool {
|
||||
}
|
||||
key := agentSessionKey(admission.OrganizationID, admission.AgentID)
|
||||
s.mu.RLock()
|
||||
current, ok := s.agents[key]
|
||||
current, ok := s.pendingActionRunners[key]
|
||||
s.mu.RUnlock()
|
||||
return ok && current != nil && sameActionRunnerAdmission(current.admission, admission) &&
|
||||
current.admission.ActivationPending == admission.ActivationPending
|
||||
return ok && current != nil && admission.ActivationPending && sameActionRunnerAdmission(current.admission, admission)
|
||||
}
|
||||
|
||||
// PromoteActionRunnerSession makes an exact prepared session dispatchable
|
||||
// after its credential activation has been durably committed.
|
||||
func (s *Server) PromoteActionRunnerSession(admission AgentAdmission) bool {
|
||||
// PromoteActionRunnerSessionForCommit performs only the bounded map mutation
|
||||
// needed by the credential transaction. Callers may invoke it while holding
|
||||
// config.Mu; they must run the returned cleanup only after releasing that lock.
|
||||
// This preserves the sole nested order config.Mu -> Server.mu and keeps socket
|
||||
// close/logging I/O outside both locks.
|
||||
func (s *Server) PromoteActionRunnerSessionForCommit(admission AgentAdmission) (func(), bool) {
|
||||
if s == nil {
|
||||
return false
|
||||
return nil, false
|
||||
}
|
||||
key := agentSessionKey(admission.OrganizationID, admission.AgentID)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
current, ok := s.agents[key]
|
||||
if !ok || current == nil || !sameActionRunnerAdmission(current.admission, admission) {
|
||||
return false
|
||||
pending, ok := s.pendingActionRunners[key]
|
||||
if !ok || pending == nil || !admission.ActivationPending || !sameActionRunnerAdmission(pending.admission, admission) {
|
||||
s.mu.Unlock()
|
||||
return nil, false
|
||||
}
|
||||
current.admission.ActivationPending = false
|
||||
return true
|
||||
delete(s.pendingActionRunners, key)
|
||||
displaced := s.agents[key]
|
||||
s.agents[key] = pending
|
||||
s.mu.Unlock()
|
||||
var cleanup func()
|
||||
if displaced != nil && displaced != pending {
|
||||
cleanup = func() {
|
||||
displaced.signalDone()
|
||||
if displaced.conn != nil {
|
||||
_ = displaced.conn.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
return cleanup, true
|
||||
}
|
||||
|
||||
// PromoteActionRunnerSession is the non-transactional compatibility wrapper.
|
||||
// Production activation uses PromoteActionRunnerSessionForCommit and defers
|
||||
// cleanup until after config.Mu has been released.
|
||||
func (s *Server) PromoteActionRunnerSession(admission AgentAdmission) bool {
|
||||
cleanup, promoted := s.PromoteActionRunnerSessionForCommit(admission)
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
return promoted
|
||||
}
|
||||
|
||||
// InvalidateActionRunnerSession closes exactly the currently admitted typed
|
||||
@@ -438,13 +517,18 @@ func (s *Server) InvalidateActionRunnerSession(admission AgentAdmission) bool {
|
||||
}
|
||||
key := agentSessionKey(expected.OrganizationID, expected.AgentID)
|
||||
s.mu.Lock()
|
||||
current, ok := s.agents[key]
|
||||
if !ok || current == nil || !sameActionRunnerAdmission(current.admission, expected) {
|
||||
s.mu.Unlock()
|
||||
var current *agentConn
|
||||
if active, ok := s.agents[key]; ok && active != nil && sameActionRunnerAdmission(active.admission, expected) {
|
||||
delete(s.agents, key)
|
||||
current = active
|
||||
} else if pending, ok := s.pendingActionRunners[key]; ok && pending != nil && sameActionRunnerAdmission(pending.admission, expected) {
|
||||
delete(s.pendingActionRunners, key)
|
||||
current = pending
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if current == nil {
|
||||
return false
|
||||
}
|
||||
delete(s.agents, key)
|
||||
s.mu.Unlock()
|
||||
|
||||
current.signalDone()
|
||||
if current.conn != nil {
|
||||
@@ -1186,6 +1270,25 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Register agent - after this point, other goroutines can access the connection
|
||||
s.mu.Lock()
|
||||
now := time.Now()
|
||||
if s.now != nil {
|
||||
now = s.now()
|
||||
}
|
||||
for key, expiry := range s.actionRunnerAdmissionTombstones {
|
||||
if !expiry.After(now) {
|
||||
delete(s.actionRunnerAdmissionTombstones, key)
|
||||
}
|
||||
}
|
||||
if expiry, cancelled := s.actionRunnerAdmissionTombstones[actionRunnerAdmissionTombstoneKey(admission)]; cancelled && expiry.After(now) {
|
||||
s.mu.Unlock()
|
||||
log.Warn().Str("agent_id", reg.AgentID).Msg("Action runner registration rejected: prepared credential was cancelled")
|
||||
rejectedMsg, err := NewMessage(MsgTypeRegistered, "", RegisteredPayload{Success: false, Message: "action runner credential preparation was cancelled"})
|
||||
if err == nil {
|
||||
_ = s.sendMessage(conn, rejectedMsg)
|
||||
}
|
||||
closeConn("Failed to close cancelled action runner registration")
|
||||
return
|
||||
}
|
||||
for key, existing := range s.agents {
|
||||
if key != ac.sessionKey &&
|
||||
normalizeOrganizationID(existing.admission.OrganizationID) == admission.OrganizationID &&
|
||||
@@ -1205,38 +1308,49 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Close existing connection if any
|
||||
if existing, ok := s.agents[ac.sessionKey]; ok {
|
||||
preparedActionRunnerRename := admission.ActivationPending &&
|
||||
admission.RuntimeRole == RuntimeRoleActionRunner &&
|
||||
existing.admission.RuntimeRole == RuntimeRoleActionRunner
|
||||
if !unifiedresources.HostnamesEquivalent(existing.agent.Hostname, ac.agent.Hostname) && !preparedActionRunnerRename {
|
||||
s.mu.Unlock()
|
||||
log.Warn().
|
||||
Str("organization_id", admission.OrganizationID).
|
||||
Str("agent_id", admission.AgentID).
|
||||
Str("connected_hostname", existing.agent.Hostname).
|
||||
Str("requested_hostname", admission.Hostname).
|
||||
Msg("Agent registration rejected: duplicate identity is already connected from another host")
|
||||
rejectedMsg, err := NewMessage(MsgTypeRegistered, "", RegisteredPayload{Success: false, Message: "agent identity is already connected from another host"})
|
||||
if err == nil {
|
||||
_ = s.sendMessage(conn, rejectedMsg)
|
||||
var replaced *agentConn
|
||||
if admission.ActivationPending && admission.RuntimeRole == RuntimeRoleActionRunner {
|
||||
// A prepared transport is staged separately. Reconnect/flood traffic can
|
||||
// replace only the one bounded pending slot and cannot evict or interrupt
|
||||
// the active dispatch session before durable activation.
|
||||
replaced = s.pendingActionRunners[ac.sessionKey]
|
||||
s.pendingActionRunners[ac.sessionKey] = ac
|
||||
} else {
|
||||
if existing, ok := s.agents[ac.sessionKey]; ok {
|
||||
if !unifiedresources.HostnamesEquivalent(existing.agent.Hostname, ac.agent.Hostname) {
|
||||
s.mu.Unlock()
|
||||
log.Warn().
|
||||
Str("organization_id", admission.OrganizationID).
|
||||
Str("agent_id", admission.AgentID).
|
||||
Str("connected_hostname", existing.agent.Hostname).
|
||||
Str("requested_hostname", admission.Hostname).
|
||||
Msg("Agent registration rejected: duplicate identity is already connected from another host")
|
||||
rejectedMsg, err := NewMessage(MsgTypeRegistered, "", RegisteredPayload{Success: false, Message: "agent identity is already connected from another host"})
|
||||
if err == nil {
|
||||
_ = s.sendMessage(conn, rejectedMsg)
|
||||
}
|
||||
closeConn("Failed to close duplicate agent identity connection")
|
||||
return
|
||||
}
|
||||
closeConn("Failed to close duplicate agent identity connection")
|
||||
return
|
||||
replaced = existing
|
||||
}
|
||||
s.agents[ac.sessionKey] = ac
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if replaced != nil && replaced != ac {
|
||||
log.Info().
|
||||
Str("organization_id", admission.OrganizationID).
|
||||
Str("agent_id", admission.AgentID).
|
||||
Str("hostname", admission.Hostname).
|
||||
Bool("activation_pending", admission.ActivationPending).
|
||||
Msg("Replacing existing agent connection")
|
||||
existing.signalDone()
|
||||
if err := existing.conn.Close(); err != nil {
|
||||
log.Debug().Err(err).Str("agent_id", admission.AgentID).Msg("Failed to close existing connection during reconnect")
|
||||
replaced.signalDone()
|
||||
if replaced.conn != nil {
|
||||
if err := replaced.conn.Close(); err != nil {
|
||||
log.Debug().Err(err).Str("agent_id", admission.AgentID).Msg("Failed to close existing connection during reconnect")
|
||||
}
|
||||
}
|
||||
}
|
||||
s.agents[ac.sessionKey] = ac
|
||||
s.mu.Unlock()
|
||||
|
||||
log.Info().
|
||||
Str("organization_id", admission.OrganizationID).
|
||||
@@ -1262,6 +1376,9 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
Msg("Failed to send registration ack")
|
||||
ac.writeMu.Unlock()
|
||||
s.mu.Lock()
|
||||
if existing, ok := s.pendingActionRunners[ac.sessionKey]; ok && existing == ac {
|
||||
delete(s.pendingActionRunners, ac.sessionKey)
|
||||
}
|
||||
if existing, ok := s.agents[ac.sessionKey]; ok && existing == ac {
|
||||
delete(s.agents, ac.sessionKey)
|
||||
}
|
||||
@@ -1290,15 +1407,21 @@ func (s *Server) readLoop(ac *agentConn) {
|
||||
agentID := ac.agent.AgentID
|
||||
sessionKey := connectionSessionKey(ac)
|
||||
s.mu.Lock()
|
||||
existing, sessionExists := s.agents[sessionKey]
|
||||
ownsSession := !sessionExists || existing == ac
|
||||
if sessionExists && existing == ac {
|
||||
wasActive := false
|
||||
ownsSession := false
|
||||
if existing, exists := s.agents[sessionKey]; exists && existing == ac {
|
||||
delete(s.agents, sessionKey)
|
||||
wasActive = true
|
||||
ownsSession = true
|
||||
}
|
||||
if existing, exists := s.pendingActionRunners[sessionKey]; exists && existing == ac {
|
||||
delete(s.pendingActionRunners, sessionKey)
|
||||
ownsSession = true
|
||||
}
|
||||
// Close all deploy progress subscriptions for this agent so
|
||||
// processPreflightProgress goroutines unblock and detect disconnect.
|
||||
var closeChs []chan DeployProgressPayload
|
||||
if ownsSession {
|
||||
if ownsSession && wasActive {
|
||||
prefix := sessionKey + "\x00"
|
||||
for key, ch := range s.deploySubs {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
@@ -1729,11 +1852,15 @@ func (s *Server) Shutdown() {
|
||||
close(s.shutdown)
|
||||
|
||||
s.mu.Lock()
|
||||
agents := make([]*agentConn, 0, len(s.agents))
|
||||
agents := make([]*agentConn, 0, len(s.agents)+len(s.pendingActionRunners))
|
||||
for _, ac := range s.agents {
|
||||
agents = append(agents, ac)
|
||||
}
|
||||
for _, ac := range s.pendingActionRunners {
|
||||
agents = append(agents, ac)
|
||||
}
|
||||
s.agents = make(map[string]*agentConn)
|
||||
s.pendingActionRunners = make(map[string]*agentConn)
|
||||
s.mu.Unlock()
|
||||
|
||||
for _, ac := range agents {
|
||||
|
||||
@@ -492,7 +492,7 @@ func TestPreparedActionRunnerSessionIsNotDispatchableUntilPromoted(t *testing.T)
|
||||
ActionCapability: ActionCapabilityTypedV1, ActivationPending: true,
|
||||
}
|
||||
key := agentSessionKey(admission.OrganizationID, admission.AgentID)
|
||||
s.agents[key] = &agentConn{admission: admission, agent: ConnectedAgent{AgentID: admission.AgentID}, done: make(chan struct{})}
|
||||
s.pendingActionRunners[key] = &agentConn{admission: admission, agent: ConnectedAgent{AgentID: admission.AgentID}, done: make(chan struct{})}
|
||||
if _, ok := s.connectionForOrganization("org-a", "agent-1"); ok {
|
||||
t.Fatal("prepared runner was dispatchable")
|
||||
}
|
||||
|
||||
@@ -272,6 +272,154 @@ func TestActionRunnerRegistrationRequiresCredentialBoundRuntimeRoleAndCapability
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparedActionRunnerReconnectCannotDisplaceActiveDispatchAndExactPromotionSwaps(t *testing.T) {
|
||||
active := AgentAdmission{
|
||||
OrganizationID: "org-a", TokenID: "active-token", AgentID: "machine-a", Hostname: "node.example",
|
||||
RuntimeRole: RuntimeRoleActionRunner, ActionCapability: ActionCapabilityTypedV1,
|
||||
}
|
||||
pendingOne := active
|
||||
pendingOne.TokenID = "pending-token-1"
|
||||
pendingOne.ActivationPending = true
|
||||
pendingTwo := pendingOne
|
||||
pendingTwo.TokenID = "pending-token-2"
|
||||
admissions := map[string]AgentAdmission{
|
||||
active.TokenID: active,
|
||||
pendingOne.TokenID: pendingOne,
|
||||
pendingTwo.TokenID: pendingTwo,
|
||||
}
|
||||
s := NewServerWithAdmissionValidator(func(token, _, _ string) (AgentAdmission, bool) {
|
||||
admission, ok := admissions[token]
|
||||
return admission, ok
|
||||
}, func(AgentAdmission) bool { return true })
|
||||
ts := newWSServer(t, s)
|
||||
defer ts.Close()
|
||||
register := func(admission AgentAdmission) *websocket.Conn {
|
||||
t.Helper()
|
||||
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wsWriteMessage(t, conn, mustNewMessage(t, MsgTypeAgentRegister, "", AgentRegisterPayload{
|
||||
AgentID: admission.AgentID, Hostname: admission.Hostname, Token: admission.TokenID,
|
||||
RuntimeRole: admission.RuntimeRole, ActionCapability: admission.ActionCapability,
|
||||
}))
|
||||
if ack := wsReadRegisteredPayload(t, conn); !ack.Success {
|
||||
conn.Close()
|
||||
t.Fatalf("registration for %s failed: %s", admission.TokenID, ack.Message)
|
||||
}
|
||||
return conn
|
||||
}
|
||||
requirePong := func(conn *websocket.Conn, label string) {
|
||||
t.Helper()
|
||||
wsWriteMessage(t, conn, mustNewMessage(t, MsgTypeAgentPing, "", nil))
|
||||
if msg := wsReadRawMessage(t, conn); msg.Type != MsgTypePong {
|
||||
t.Fatalf("%s message type = %q, want pong", label, msg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
activeConn := register(active)
|
||||
defer activeConn.Close()
|
||||
pendingOneConn := register(pendingOne)
|
||||
defer pendingOneConn.Close()
|
||||
|
||||
if current, ok := s.connectionForOrganization(active.OrganizationID, active.AgentID); !ok || current.admission.TokenID != active.TokenID {
|
||||
t.Fatalf("pending registration displaced active dispatch: %#v, %v", current, ok)
|
||||
}
|
||||
requirePong(activeConn, "active runner while replacement pending")
|
||||
if !s.HasActionRunnerSession(pendingOne) {
|
||||
t.Fatal("first exact pending transport was not staged")
|
||||
}
|
||||
|
||||
pendingTwoConn := register(pendingTwo)
|
||||
defer pendingTwoConn.Close()
|
||||
if _, err := wsReadRawMessageWithTimeout(pendingOneConn, 2*time.Second); err == nil {
|
||||
t.Fatal("replaced pending transport remained connected")
|
||||
}
|
||||
if current, ok := s.connectionForOrganization(active.OrganizationID, active.AgentID); !ok || current.admission.TokenID != active.TokenID {
|
||||
t.Fatalf("pending reconnect displaced active dispatch: %#v, %v", current, ok)
|
||||
}
|
||||
requirePong(activeConn, "active runner after pending reconnect")
|
||||
if s.HasActionRunnerSession(pendingOne) {
|
||||
t.Fatal("superseded pending transport remained promotable")
|
||||
}
|
||||
if !s.HasActionRunnerSession(pendingTwo) {
|
||||
t.Fatal("latest exact pending transport was not staged")
|
||||
}
|
||||
if cleanup, promoted := s.PromoteActionRunnerSessionForCommit(pendingOne); promoted || cleanup != nil {
|
||||
t.Fatal("stale Has/promote snapshot displaced the current transport")
|
||||
}
|
||||
cleanup, promoted := s.PromoteActionRunnerSessionForCommit(pendingTwo)
|
||||
if !promoted {
|
||||
t.Fatal("latest exact pending transport was not promoted")
|
||||
}
|
||||
if current, ok := s.connectionForOrganization(active.OrganizationID, active.AgentID); !ok || current.admission.TokenID != pendingTwo.TokenID {
|
||||
t.Fatalf("promotion did not atomically swap dispatch: %#v, %v", current, ok)
|
||||
}
|
||||
if cleanup == nil {
|
||||
t.Fatal("promotion did not return displaced active cleanup")
|
||||
}
|
||||
cleanup()
|
||||
if _, err := wsReadRawMessageWithTimeout(activeConn, 2*time.Second); err == nil {
|
||||
t.Fatal("displaced active transport remained connected after deferred cleanup")
|
||||
}
|
||||
requirePong(pendingTwoConn, "promoted runner")
|
||||
|
||||
s.mu.RLock()
|
||||
activeCount := len(s.agents)
|
||||
pendingCount := len(s.pendingActionRunners)
|
||||
s.mu.RUnlock()
|
||||
if activeCount != 1 || pendingCount != 0 {
|
||||
t.Fatalf("session maps after promotion = active %d pending %d", activeCount, pendingCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRunnerPromotionVersusDisconnectNeverRetainsDeadSession(t *testing.T) {
|
||||
for attempt := 0; attempt < 20; attempt++ {
|
||||
admission := AgentAdmission{
|
||||
OrganizationID: "org-a", TokenID: fmt.Sprintf("pending-%d", attempt), AgentID: "machine-a", Hostname: "node.example",
|
||||
RuntimeRole: RuntimeRoleActionRunner, ActionCapability: ActionCapabilityTypedV1, ActivationPending: true,
|
||||
}
|
||||
s := NewServerWithAdmissionValidator(func(token, _, _ string) (AgentAdmission, bool) {
|
||||
return admission, token == admission.TokenID
|
||||
}, func(AgentAdmission) bool { return true })
|
||||
ts := newWSServer(t, s)
|
||||
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
|
||||
if err != nil {
|
||||
ts.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
wsWriteMessage(t, conn, mustNewMessage(t, MsgTypeAgentRegister, "", AgentRegisterPayload{
|
||||
AgentID: admission.AgentID, Hostname: admission.Hostname, Token: admission.TokenID,
|
||||
RuntimeRole: admission.RuntimeRole, ActionCapability: admission.ActionCapability,
|
||||
}))
|
||||
if ack := wsReadRegisteredPayload(t, conn); !ack.Success {
|
||||
conn.Close()
|
||||
ts.Close()
|
||||
t.Fatalf("attempt %d registration failed: %s", attempt, ack.Message)
|
||||
}
|
||||
start := make(chan struct{})
|
||||
promotionDone := make(chan func(), 1)
|
||||
go func() {
|
||||
<-start
|
||||
cleanup, _ := s.PromoteActionRunnerSessionForCommit(admission)
|
||||
promotionDone <- cleanup
|
||||
}()
|
||||
close(start)
|
||||
_ = conn.Close()
|
||||
cleanup := <-promotionDone
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
waitFor(t, 2*time.Second, func() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
key := agentSessionKey(admission.OrganizationID, admission.AgentID)
|
||||
return s.agents[key] == nil && s.pendingActionRunners[key] == nil
|
||||
})
|
||||
ts.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyCredentialCannotAssertActionRunnerRole(t *testing.T) {
|
||||
s := NewServerWithAdmissionValidator(func(token, _, _ string) (AgentAdmission, bool) {
|
||||
return AgentAdmission{TokenID: token, AgentID: "a1", Hostname: "host1", RuntimeRole: RuntimeRoleLegacyFullTrust}, token == "legacy"
|
||||
@@ -1306,6 +1454,132 @@ func TestInvalidateActionRunnerSessionClosesExactSessionAndUnblocksInflightDispa
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRunnerAdmissionTombstoneFencesDelayedCanonicalRegistrationAndIsExact(t *testing.T) {
|
||||
cancelled := AgentAdmission{OrganizationID: "org-a", TokenID: "pending-token", AgentID: "agent-a", Hostname: "node.example", RuntimeRole: RuntimeRoleActionRunner, ActionCapability: ActionCapabilityTypedV1, ActivationPending: true}
|
||||
other := cancelled
|
||||
other.TokenID = "other-token"
|
||||
admitted := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
s := NewServerWithAdmissionValidator(func(token, _, _ string) (AgentAdmission, bool) {
|
||||
if token == cancelled.TokenID {
|
||||
close(admitted)
|
||||
<-release
|
||||
return cancelled, true
|
||||
}
|
||||
return other, token == other.TokenID
|
||||
}, func(AgentAdmission) bool { return true })
|
||||
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: cancelled.AgentID, Hostname: "NODE", Token: cancelled.TokenID, RuntimeRole: cancelled.RuntimeRole, ActionCapability: cancelled.ActionCapability}))
|
||||
select {
|
||||
case <-admitted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("registration did not reach pre-insertion admission point")
|
||||
}
|
||||
if !s.TombstoneActionRunnerAdmission(cancelled, time.Now().Add(time.Minute)) {
|
||||
t.Fatal("exact pending admission was not tombstoned")
|
||||
}
|
||||
close(release)
|
||||
if ack := wsReadRegisteredPayload(t, conn); ack.Success {
|
||||
t.Fatal("pre-admitted cancelled socket registered after durable cancellation")
|
||||
}
|
||||
otherConn, _, err := dialAgentExecWebSocket(t, ts.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer otherConn.Close()
|
||||
wsWriteMessage(t, otherConn, mustNewMessage(t, MsgTypeAgentRegister, "", AgentRegisterPayload{AgentID: other.AgentID, Hostname: "NODE", Token: other.TokenID, RuntimeRole: other.RuntimeRole, ActionCapability: other.ActionCapability}))
|
||||
if ack := wsReadRegisteredPayload(t, otherConn); !ack.Success {
|
||||
t.Fatalf("exact tombstone rejected another credential: %s", ack.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRunnerAdmissionTombstoneKeyIsolatesEveryBoundField(t *testing.T) {
|
||||
base := AgentAdmission{
|
||||
OrganizationID: "org-a", TokenID: "pending-token", AgentID: "agent-a", Hostname: "node.a.example",
|
||||
RuntimeRole: RuntimeRoleActionRunner, ActionCapability: ActionCapabilityTypedV1,
|
||||
}
|
||||
baseKey := actionRunnerAdmissionTombstoneKey(base)
|
||||
canonicalSpelling := base
|
||||
canonicalSpelling.Hostname = " NODE.A.EXAMPLE. "
|
||||
if got := actionRunnerAdmissionTombstoneKey(canonicalSpelling); got != baseKey {
|
||||
t.Fatalf("equivalent canonical hostname produced a distinct key: %q != %q", got, baseKey)
|
||||
}
|
||||
|
||||
mutations := map[string]func(*AgentAdmission){
|
||||
"organization": func(candidate *AgentAdmission) { candidate.OrganizationID = "org-b" },
|
||||
"token": func(candidate *AgentAdmission) { candidate.TokenID = "other-token" },
|
||||
"agent": func(candidate *AgentAdmission) { candidate.AgentID = "agent-b" },
|
||||
"full hostname": func(candidate *AgentAdmission) {
|
||||
// The short label is intentionally unchanged: separate FQDNs must
|
||||
// never share a cancellation fence.
|
||||
candidate.Hostname = "node.b.example"
|
||||
},
|
||||
"runtime role": func(candidate *AgentAdmission) { candidate.RuntimeRole = RuntimeRoleLegacyFullTrust },
|
||||
"capability": func(candidate *AgentAdmission) { candidate.ActionCapability = "typed_actions.v2" },
|
||||
}
|
||||
for name, mutate := range mutations {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
candidate := base
|
||||
mutate(&candidate)
|
||||
if got := actionRunnerAdmissionTombstoneKey(candidate); got == baseKey {
|
||||
t.Fatalf("%s change did not isolate tombstone key", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRunnerAdmissionTombstoneExpiresAndClosesCurrentExactSession(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
admission := AgentAdmission{OrganizationID: "org-a", TokenID: "pending-token", AgentID: "agent-a", Hostname: "node.example", RuntimeRole: RuntimeRoleActionRunner, ActionCapability: ActionCapabilityTypedV1, ActivationPending: true}
|
||||
s := NewServerWithAdmissionValidator(func(token, _, _ string) (AgentAdmission, bool) { return admission, token == admission.TokenID }, func(AgentAdmission) bool { return true })
|
||||
s.now = func() time.Time { return now }
|
||||
ts := newWSServer(t, s)
|
||||
defer ts.Close()
|
||||
register := func() *websocket.Conn {
|
||||
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wsWriteMessage(t, conn, mustNewMessage(t, MsgTypeAgentRegister, "", AgentRegisterPayload{AgentID: admission.AgentID, Hostname: admission.Hostname, Token: admission.TokenID, RuntimeRole: admission.RuntimeRole, ActionCapability: admission.ActionCapability}))
|
||||
return conn
|
||||
}
|
||||
current := register()
|
||||
defer current.Close()
|
||||
if ack := wsReadRegisteredPayload(t, current); !ack.Success {
|
||||
t.Fatalf("initial registration failed: %s", ack.Message)
|
||||
}
|
||||
if !s.TombstoneActionRunnerAdmission(admission, now.Add(time.Second)) {
|
||||
t.Fatal("current exact session was not tombstoned")
|
||||
}
|
||||
if s.IsAgentConnectedForOrganization(admission.OrganizationID, admission.AgentID) {
|
||||
t.Fatal("tombstoned current session remained connected")
|
||||
}
|
||||
if err := current.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := current.ReadMessage(); err == nil {
|
||||
t.Fatal("tombstoned current websocket remained open")
|
||||
}
|
||||
now = now.Add(2 * time.Second)
|
||||
afterExpiry := register()
|
||||
defer afterExpiry.Close()
|
||||
if ack := wsReadRegisteredPayload(t, afterExpiry); !ack.Success {
|
||||
t.Fatalf("expired tombstone rejected registration: %s", ack.Message)
|
||||
}
|
||||
s.mu.RLock()
|
||||
tombstoneCount := len(s.actionRunnerAdmissionTombstones)
|
||||
s.mu.RUnlock()
|
||||
if tombstoneCount != 0 {
|
||||
t.Fatalf("expired admission tombstone was not pruned: %d entries remain", tombstoneCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateAgentSessionRequiresExactLegacyAdmission(t *testing.T) {
|
||||
admission := AgentAdmission{
|
||||
OrganizationID: "org-a", TokenID: "collector-token", AgentID: "agent-a",
|
||||
|
||||
@@ -9,8 +9,12 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultMaxOperationTimeout = 30 * time.Second
|
||||
const responseWriteTimeout = 5 * time.Second
|
||||
const (
|
||||
defaultMaxConcurrentConnections = 16
|
||||
defaultMaxOperationTimeout = 30 * time.Second
|
||||
defaultPreFrameTimeout = 2 * time.Second
|
||||
responseWriteTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
type Peer struct {
|
||||
UID uint32
|
||||
@@ -54,19 +58,23 @@ type AuditEvent struct {
|
||||
type AuditHook func(AuditEvent)
|
||||
|
||||
type ServerConfig struct {
|
||||
AllowedUID uint32
|
||||
PeerResolver PeerResolver
|
||||
Registry *Registry
|
||||
MaxOperationTimeout time.Duration
|
||||
Audit AuditHook
|
||||
Now func() time.Time
|
||||
AllowedUID uint32
|
||||
PeerResolver PeerResolver
|
||||
Registry *Registry
|
||||
MaxConcurrentConnections int
|
||||
MaxOperationTimeout time.Duration
|
||||
PreFrameTimeout time.Duration
|
||||
Audit AuditHook
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
allowedUID uint32
|
||||
peerResolver PeerResolver
|
||||
registry *Registry
|
||||
connectionSlots chan struct{}
|
||||
maxOperationTimeout time.Duration
|
||||
preFrameTimeout time.Duration
|
||||
audit AuditHook
|
||||
now func() time.Time
|
||||
}
|
||||
@@ -78,13 +86,33 @@ func NewServer(config ServerConfig) (*Server, error) {
|
||||
if config.Registry == nil {
|
||||
return nil, errors.New("operation registry is required")
|
||||
}
|
||||
maxConnections := config.MaxConcurrentConnections
|
||||
if maxConnections < 0 {
|
||||
return nil, errors.New("maximum concurrent connections must not be negative")
|
||||
}
|
||||
if maxConnections == 0 {
|
||||
maxConnections = defaultMaxConcurrentConnections
|
||||
}
|
||||
maxTimeout := config.MaxOperationTimeout
|
||||
if maxTimeout <= 0 {
|
||||
if maxTimeout < 0 {
|
||||
return nil, errors.New("maximum operation timeout must not be negative")
|
||||
}
|
||||
if maxTimeout == 0 {
|
||||
maxTimeout = defaultMaxOperationTimeout
|
||||
}
|
||||
if maxTimeout < time.Millisecond {
|
||||
return nil, errors.New("maximum operation timeout must be at least 1ms")
|
||||
}
|
||||
preFrameTimeout := config.PreFrameTimeout
|
||||
if preFrameTimeout < 0 {
|
||||
return nil, errors.New("pre-frame timeout must not be negative")
|
||||
}
|
||||
if preFrameTimeout == 0 {
|
||||
preFrameTimeout = defaultPreFrameTimeout
|
||||
}
|
||||
if preFrameTimeout < time.Millisecond {
|
||||
return nil, errors.New("pre-frame timeout must be at least 1ms")
|
||||
}
|
||||
now := config.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
@@ -93,7 +121,9 @@ func NewServer(config ServerConfig) (*Server, error) {
|
||||
allowedUID: config.AllowedUID,
|
||||
peerResolver: config.PeerResolver,
|
||||
registry: config.Registry,
|
||||
connectionSlots: make(chan struct{}, maxConnections),
|
||||
maxOperationTimeout: maxTimeout,
|
||||
preFrameTimeout: preFrameTimeout,
|
||||
audit: config.Audit,
|
||||
now: now,
|
||||
}, nil
|
||||
@@ -112,11 +142,40 @@ func (s *Server) Serve(ctx context.Context, listener net.Listener) error {
|
||||
}
|
||||
return fmt.Errorf("accept helper connection: %w", err)
|
||||
}
|
||||
go s.HandleConnection(ctx, conn)
|
||||
if !s.tryAcquireConnection() {
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
go func() {
|
||||
defer s.releaseConnection()
|
||||
s.handleConnection(ctx, conn)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) HandleConnection(parent context.Context, conn net.Conn) {
|
||||
if !s.tryAcquireConnection() {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
defer s.releaseConnection()
|
||||
s.handleConnection(parent, conn)
|
||||
}
|
||||
|
||||
func (s *Server) tryAcquireConnection() bool {
|
||||
select {
|
||||
case s.connectionSlots <- struct{}{}:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) releaseConnection() {
|
||||
<-s.connectionSlots
|
||||
}
|
||||
|
||||
func (s *Server) handleConnection(parent context.Context, conn net.Conn) {
|
||||
defer conn.Close()
|
||||
started := s.now()
|
||||
event := AuditEvent{StartedAt: started}
|
||||
@@ -127,7 +186,8 @@ func (s *Server) HandleConnection(parent context.Context, conn net.Conn) {
|
||||
}
|
||||
}()
|
||||
|
||||
_ = conn.SetDeadline(started.Add(s.maxOperationTimeout))
|
||||
preFrameTimeout := min(s.preFrameTimeout, s.maxOperationTimeout)
|
||||
_ = conn.SetDeadline(started.Add(preFrameTimeout))
|
||||
peer, err := s.peerResolver.Resolve(conn)
|
||||
event.Peer = peer
|
||||
if err != nil || peer.UID != s.allowedUID {
|
||||
@@ -150,6 +210,7 @@ func (s *Server) HandleConnection(parent context.Context, conn net.Conn) {
|
||||
event.RequestID = request.RequestID
|
||||
event.Operation = request.Operation
|
||||
event.OperationVersion = request.OperationVersion
|
||||
_ = conn.SetDeadline(started.Add(s.maxOperationTimeout))
|
||||
|
||||
if validationError := s.validateRequest(request); validationError != nil {
|
||||
s.writeResponse(conn, &event, errorResponse(request, validationError))
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -318,6 +320,205 @@ func TestServerRejectsUnauthorizedPeer(t *testing.T) {
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestServerBoundsConcurrentConnectionsAndRecovers(t *testing.T) {
|
||||
const maxConnections = 2
|
||||
resolved := make(chan struct{}, maxConnections+1)
|
||||
audited := make(chan AuditEvent, maxConnections+1)
|
||||
server, err := NewServer(ServerConfig{
|
||||
AllowedUID: 1000,
|
||||
PeerResolver: PeerResolverFunc(func(net.Conn) (Peer, error) {
|
||||
resolved <- struct{}{}
|
||||
return Peer{UID: 1000, GID: 2000, PID: 3000}, nil
|
||||
}),
|
||||
Registry: NewRegistry(nil, nil),
|
||||
MaxConcurrentConnections: maxConnections,
|
||||
MaxOperationTimeout: 5 * time.Second,
|
||||
PreFrameTimeout: 5 * time.Second,
|
||||
Audit: func(event AuditEvent) { audited <- event },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
|
||||
socketDir, err := os.MkdirTemp("", "pah-")
|
||||
if err != nil {
|
||||
t.Fatalf("create short socket directory: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(socketDir) })
|
||||
socketPath := filepath.Join(socketDir, "helper.sock")
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
serveDone := make(chan error, 1)
|
||||
go func() { serveDone <- server.Serve(ctx, listener) }()
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
_ = listener.Close()
|
||||
if err := <-serveDone; err != nil {
|
||||
t.Errorf("Serve: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
held := make([]net.Conn, 0, maxConnections)
|
||||
for i := 0; i < maxConnections; i++ {
|
||||
conn, dialErr := net.Dial("unix", socketPath)
|
||||
if dialErr != nil {
|
||||
t.Fatalf("dial held connection %d: %v", i, dialErr)
|
||||
}
|
||||
held = append(held, conn)
|
||||
select {
|
||||
case <-resolved:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("held connection %d was not admitted", i)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for _, conn := range held {
|
||||
_ = conn.Close()
|
||||
}
|
||||
})
|
||||
|
||||
overload, err := net.Dial("unix", socketPath)
|
||||
if err != nil {
|
||||
t.Fatalf("dial overloaded connection: %v", err)
|
||||
}
|
||||
defer overload.Close()
|
||||
if err := overload.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
|
||||
t.Fatalf("set overload deadline: %v", err)
|
||||
}
|
||||
buffer := make([]byte, 1)
|
||||
if _, err := overload.Read(buffer); err == nil {
|
||||
t.Fatal("overloaded connection remained open")
|
||||
} else if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
t.Fatal("overloaded connection was not rejected promptly")
|
||||
}
|
||||
select {
|
||||
case <-resolved:
|
||||
t.Fatal("overloaded connection reached peer authentication")
|
||||
default:
|
||||
}
|
||||
|
||||
if err := held[0].Close(); err != nil {
|
||||
t.Fatalf("release held connection: %v", err)
|
||||
}
|
||||
select {
|
||||
case event := <-audited:
|
||||
if event.Success || event.ErrorCode != ErrorInvalidFrame {
|
||||
t.Fatalf("released connection audit = %#v", event)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("released connection was not cleaned up")
|
||||
}
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for len(server.connectionSlots) != maxConnections-1 && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if got := len(server.connectionSlots); got != maxConnections-1 {
|
||||
t.Fatalf("active connection slots = %d, want %d", got, maxConnections-1)
|
||||
}
|
||||
|
||||
client, err := NewClient(ClientConfig{
|
||||
SocketPath: socketPath,
|
||||
MaxDeadline: time.Second,
|
||||
NewRequestID: func() (string, error) { return "post-overload-health", nil },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
var health HealthResult
|
||||
if _, err := client.Call(t.Context(), OperationHealth, OperationVersion1, time.Second, nil, &health); err != nil {
|
||||
t.Fatalf("health after releasing connection slot: %v", err)
|
||||
}
|
||||
if health.Status != "ok" {
|
||||
t.Fatalf("health status = %q, want ok", health.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerAppliesShortPreFrameTimeout(t *testing.T) {
|
||||
audited := make(chan AuditEvent, 1)
|
||||
server, err := NewServer(ServerConfig{
|
||||
AllowedUID: 1000,
|
||||
PeerResolver: authorizedResolver(1000),
|
||||
Registry: NewRegistry(nil, nil),
|
||||
MaxOperationTimeout: time.Second,
|
||||
PreFrameTimeout: 25 * time.Millisecond,
|
||||
Audit: func(event AuditEvent) { audited <- event },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
serverConn, clientConn := net.Pipe()
|
||||
done := make(chan struct{})
|
||||
started := time.Now()
|
||||
go func() {
|
||||
server.HandleConnection(context.Background(), serverConn)
|
||||
close(done)
|
||||
}()
|
||||
payload, err := readFrame(clientConn, MaxResponseBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("read timeout response: %v", err)
|
||||
}
|
||||
response, err := DecodeResponse(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("decode timeout response: %v", err)
|
||||
}
|
||||
requireErrorCode(t, response, ErrorInvalidFrame)
|
||||
_ = clientConn.Close()
|
||||
<-done
|
||||
if elapsed := time.Since(started); elapsed >= 500*time.Millisecond {
|
||||
t.Fatalf("pre-frame timeout took %s, want less than 500ms", elapsed)
|
||||
}
|
||||
event := <-audited
|
||||
if event.RequestID != "" || event.Operation != "" || event.RequestBytes != 0 || event.ErrorCode != ErrorInvalidFrame {
|
||||
t.Fatalf("pre-frame audit exposed unexpected metadata: %#v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerValidatesConnectionLimitsAndTimeouts(t *testing.T) {
|
||||
base := ServerConfig{
|
||||
AllowedUID: 1000,
|
||||
PeerResolver: authorizedResolver(1000),
|
||||
Registry: NewRegistry(nil, nil),
|
||||
PreFrameTimeout: time.Second,
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*ServerConfig)
|
||||
}{
|
||||
{name: "negative connection limit", mutate: func(config *ServerConfig) { config.MaxConcurrentConnections = -1 }},
|
||||
{name: "negative operation timeout", mutate: func(config *ServerConfig) { config.MaxOperationTimeout = -1 }},
|
||||
{name: "sub-millisecond operation timeout", mutate: func(config *ServerConfig) { config.MaxOperationTimeout = time.Nanosecond }},
|
||||
{name: "negative pre-frame timeout", mutate: func(config *ServerConfig) { config.PreFrameTimeout = -1 }},
|
||||
{name: "sub-millisecond pre-frame timeout", mutate: func(config *ServerConfig) { config.PreFrameTimeout = time.Nanosecond }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
config := base
|
||||
test.mutate(&config)
|
||||
if _, err := NewServer(config); err == nil {
|
||||
t.Fatal("NewServer succeeded with invalid configuration")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
server, err := NewServer(ServerConfig{
|
||||
AllowedUID: 1000,
|
||||
PeerResolver: authorizedResolver(1000),
|
||||
Registry: NewRegistry(nil, nil),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer defaults: %v", err)
|
||||
}
|
||||
if cap(server.connectionSlots) != defaultMaxConcurrentConnections {
|
||||
t.Fatalf("default connection limit = %d, want %d", cap(server.connectionSlots), defaultMaxConcurrentConnections)
|
||||
}
|
||||
if server.preFrameTimeout != defaultPreFrameTimeout {
|
||||
t.Fatalf("default pre-frame timeout = %s, want %s", server.preFrameTimeout, defaultPreFrameTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerBoundsOperationDeadline(t *testing.T) {
|
||||
provider := fakeSMARTProvider(func(ctx context.Context) (json.RawMessage, error) {
|
||||
<-ctx.Done()
|
||||
|
||||
@@ -70,7 +70,7 @@ func (r *Router) handleIssueActionRunnerCredential(w http.ResponseWriter, req *h
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if r == nil || r.config == nil {
|
||||
if r == nil || r.config == nil || r.persistence == nil {
|
||||
http.Error(w, "Action runner credential service unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
@@ -132,15 +132,15 @@ func (r *Router) handleIssueActionRunnerCredential(w http.ResponseWriter, req *h
|
||||
|
||||
// handleActivateActionRunnerCredential commits a prepared rotation only after
|
||||
// the exact replacement runner has registered and durably written its local
|
||||
// activation proof. The runner calls this endpoint itself; no plaintext token
|
||||
// or caller-selected predecessor identity crosses the boundary.
|
||||
// pending proof. The runner calls this endpoint itself; no plaintext token or
|
||||
// caller-selected predecessor identity crosses the boundary.
|
||||
func (r *Router) handleActivateActionRunnerCredential(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPatch {
|
||||
w.Header().Set("Allow", http.MethodPatch)
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if r == nil || r.config == nil || r.agentExecServer == nil {
|
||||
if r == nil || r.config == nil || r.persistence == nil || r.agentExecServer == nil {
|
||||
http.Error(w, "Action runner activation service unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
@@ -166,7 +166,6 @@ func (r *Router) handleActivateActionRunnerCredential(w http.ResponseWriter, req
|
||||
http.Error(w, "Action runner credential binding mismatch", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
pending := strings.TrimSpace(caller.Metadata[agenttokens.ActionRunnerActivationPendingMetadataKey]) == "true"
|
||||
admission := agentexec.AgentAdmission{
|
||||
OrganizationID: organizationID,
|
||||
TokenID: strings.TrimSpace(caller.ID),
|
||||
@@ -174,34 +173,104 @@ func (r *Router) handleActivateActionRunnerCredential(w http.ResponseWriter, req
|
||||
Hostname: strings.TrimSpace(caller.Metadata["bound_hostname"]),
|
||||
RuntimeRole: agentexec.RuntimeRoleActionRunner,
|
||||
ActionCapability: agentexec.ActionCapabilityTypedV1,
|
||||
ActivationPending: pending,
|
||||
ActivationPending: true,
|
||||
}
|
||||
if !r.agentExecServer.HasActionRunnerSession(admission) {
|
||||
http.Error(w, "Exact action runner session is not registered", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
_, revoked, changed, err := agenttokens.ActivateActionRunnerAndPersist(
|
||||
var promotionCleanup func()
|
||||
_, revoked, changed, err := agenttokens.ActivateActionRunnerAndPersistWithPromotion(
|
||||
r.config, r.persistence, caller.ID, payload.AgentID, payload.Hostname,
|
||||
func() bool {
|
||||
cleanup, promoted := r.agentExecServer.PromoteActionRunnerSessionForCommit(admission)
|
||||
if promoted {
|
||||
promotionCleanup = cleanup
|
||||
}
|
||||
return promoted
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, agenttokens.ErrRecord) {
|
||||
status = http.StatusForbidden
|
||||
} else if errors.Is(err, agenttokens.ErrActionRunnerSessionUnavailable) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
http.Error(w, "Failed to activate action runner credential", status)
|
||||
return
|
||||
}
|
||||
if changed {
|
||||
if promotionCleanup != nil {
|
||||
promotionCleanup()
|
||||
}
|
||||
for _, previous := range revoked {
|
||||
r.invalidateActionRunnerRecord(previous)
|
||||
}
|
||||
admission.ActivationPending = false
|
||||
r.agentExecServer.PromoteActionRunnerSession(admission)
|
||||
LogAuditEventForTenant(organizationID, "action_runner_credential_activated", auth.GetUser(req.Context()), GetClientIP(req), req.URL.Path, true, "Activated host-bound typed action runner credential")
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleCancelPendingActionRunnerCredentialActivation is the sole authority
|
||||
// for installer rollback. It durably removes the exact still-pending
|
||||
// replacement under the same token-inventory lock as activation; a committed
|
||||
// credential returns conflict and can never authorize predecessor restore.
|
||||
func (r *Router) handleCancelPendingActionRunnerCredentialActivation(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodDelete {
|
||||
w.Header().Set("Allow", http.MethodDelete)
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if r == nil || r.config == nil || r.agentExecServer == nil {
|
||||
http.Error(w, "Action runner activation service unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if req.Body != nil {
|
||||
bodyPrefix, err := io.ReadAll(io.LimitReader(req.Body, 1))
|
||||
if err != nil || len(bodyPrefix) != 0 {
|
||||
http.Error(w, "Action runner activation cancellation accepts no request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
caller := getAPITokenRecordFromRequest(req)
|
||||
if caller == nil {
|
||||
http.Error(w, "Action runner bearer credential required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
organizationID := strings.TrimSpace(GetOrgID(req.Context()))
|
||||
agentID := strings.TrimSpace(caller.Metadata["bound_agent_id"])
|
||||
hostname := strings.TrimSpace(caller.Metadata["bound_hostname"])
|
||||
if len(caller.GetBoundOrgs()) != 1 || strings.TrimSpace(caller.GetBoundOrgs()[0]) != organizationID ||
|
||||
!agentbinding.EvaluateActionRunner(caller, agentID, hostname).Admit {
|
||||
http.Error(w, "Action runner credential binding mismatch", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
removed, err := agenttokens.CancelPendingActionRunnerAndPersist(r.config, r.persistence, caller.ID, organizationID, agentID, hostname)
|
||||
if err != nil {
|
||||
if errors.Is(err, agenttokens.ErrActionRunnerAlreadyActivated) {
|
||||
http.Error(w, "Action runner credential activation already committed", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Could not establish rollback-safe action runner credential state", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
admission := agentexec.AgentAdmission{
|
||||
OrganizationID: organizationID,
|
||||
TokenID: strings.TrimSpace(removed.ID),
|
||||
AgentID: strings.TrimSpace(removed.Metadata["bound_agent_id"]),
|
||||
Hostname: strings.TrimSpace(removed.Metadata["bound_hostname"]),
|
||||
RuntimeRole: strings.TrimSpace(removed.Metadata[agenttokens.RuntimeRoleMetadataKey]),
|
||||
ActionCapability: strings.TrimSpace(removed.Metadata[agenttokens.ActionCapabilityMetadataKey]),
|
||||
}
|
||||
until := time.Now().UTC().Add(agenttokens.ActionRunnerActivationWindow)
|
||||
if removed.ExpiresAt != nil && removed.ExpiresAt.After(time.Now()) {
|
||||
until = *removed.ExpiresAt
|
||||
}
|
||||
if !r.agentExecServer.TombstoneActionRunnerAdmission(admission, until) {
|
||||
http.Error(w, "Could not establish rollback-safe action runner admission state", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
LogAuditEventForTenant(organizationID, "action_runner_credential_activation_cancelled", auth.GetUser(req.Context()), GetClientIP(req), req.URL.Path, true, "Cancelled pending host-bound typed action runner credential")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleSelfRevokeActionRunnerCredential lets the separately credentialed
|
||||
// runner revoke only its own exact tenant/host binding. It cannot select a
|
||||
// token ID or another host, and browser/session authentication is rejected.
|
||||
@@ -211,7 +280,7 @@ func (r *Router) handleSelfRevokeActionRunnerCredential(w http.ResponseWriter, r
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if r == nil || r.config == nil {
|
||||
if r == nil || r.config == nil || r.persistence == nil {
|
||||
http.Error(w, "Action runner credential service unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
@@ -260,14 +329,12 @@ func (r *Router) handleSelfRevokeActionRunnerCredential(w http.ResponseWriter, r
|
||||
}
|
||||
r.config.APITokens = append(r.config.APITokens[:index], r.config.APITokens[index+1:]...)
|
||||
r.config.SortAPITokens()
|
||||
if r.persistence != nil {
|
||||
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
|
||||
r.config.APITokens = previousTokens
|
||||
r.config.SortAPITokens()
|
||||
config.Mu.Unlock()
|
||||
http.Error(w, "Failed to revoke action runner credential", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
|
||||
r.config.APITokens = previousTokens
|
||||
r.config.SortAPITokens()
|
||||
config.Mu.Unlock()
|
||||
http.Error(w, "Failed to revoke action runner credential", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
config.Mu.Unlock()
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -228,8 +229,8 @@ func TestIssueActionRunnerCredentialRotationCommitsOnlyAfterReplacementHealthHan
|
||||
pendingConn, pendingServer := connectActionRunnerCredentialForTest(t, router.agentExecServer, second)
|
||||
defer pendingConn.Close()
|
||||
defer pendingServer.Close()
|
||||
if router.agentExecServer.IsAgentConnectedForOrganization("default", hostID) {
|
||||
t.Fatal("pending replacement became dispatchable before activation")
|
||||
if !router.agentExecServer.IsAgentConnectedForOrganization("default", hostID) {
|
||||
t.Fatal("pending replacement interrupted active dispatch before activation")
|
||||
}
|
||||
if rec := requestActionRunnerActivationForTest(t, router, second); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("activation status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
@@ -270,6 +271,85 @@ func TestIssueActionRunnerCredentialPersistenceFailureKeepsPriorLiveSession(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelPendingActionRunnerCredentialActivationIsSelfOnlyAnd204IsRollbackAuthority(t *testing.T) {
|
||||
router, cfg, hostID := newActionRunnerCredentialTestRouter(t)
|
||||
router.agentExecServer = agentexec.NewServerWithAdmissionValidator(router.admitAgentExecToken, router.validateAgentExecSession)
|
||||
handler := RequireAuth(cfg, RequireScope(config.ScopeAgentExec, router.handleCancelPendingActionRunnerCredentialActivation))
|
||||
request := func(token, organizationID, body string) *httptest.ResponseRecorder {
|
||||
var reader io.Reader
|
||||
if body != "" {
|
||||
reader = strings.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/agents/action-runner/credential/activation", reader)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, organizationID))
|
||||
rec := httptest.NewRecorder()
|
||||
handler(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
pending := issueActionRunnerCredentialForTest(t, router, hostID, "host-1.local")
|
||||
if rec := request("", "default", ""); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("missing bearer status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := request(pending.Token, "other", ""); rec.Code == http.StatusNoContent {
|
||||
t.Fatal("cross-organization bearer returned rollback-authorizing 204")
|
||||
}
|
||||
if rec := request(pending.Token, "default", `{"agentId":"other","hostname":"other"}`); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("selector body status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for index := range cfg.APITokens {
|
||||
if cfg.APITokens[index].ID == pending.TokenID {
|
||||
cfg.APITokens[index].Scopes = append(cfg.APITokens[index].Scopes, config.ScopeSettingsWrite)
|
||||
}
|
||||
}
|
||||
if rec := request(pending.Token, "default", ""); rec.Code == http.StatusNoContent {
|
||||
t.Fatal("excess-scope runner bearer returned rollback-authorizing 204")
|
||||
}
|
||||
for index := range cfg.APITokens {
|
||||
if cfg.APITokens[index].ID == pending.TokenID {
|
||||
cfg.APITokens[index].Scopes = []string{config.ScopeAgentExec}
|
||||
}
|
||||
}
|
||||
legacyRaw := "legacy-cancel-token-1234567890.12345678"
|
||||
legacy, err := config.NewAPITokenRecord(legacyRaw, "legacy exec", []string{config.ScopeAgentExec})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy.OrgID = "default"
|
||||
legacy.Metadata = map[string]string{agenttokens.RuntimeRoleMetadataKey: agenttokens.CredentialKindLegacyFullTrust, "bound_agent_id": hostID, "bound_hostname": "host-1.local"}
|
||||
cfg.APITokens = append(cfg.APITokens, *legacy)
|
||||
if rec := request(legacyRaw, "default", ""); rec.Code == http.StatusNoContent {
|
||||
t.Fatal("wrong-role exec bearer returned rollback-authorizing 204")
|
||||
}
|
||||
for index := range cfg.APITokens {
|
||||
if cfg.APITokens[index].ID == legacy.ID {
|
||||
cfg.APITokens = append(cfg.APITokens[:index], cfg.APITokens[index+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if rec := request(pending.Token, "default", ""); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("pending cancel status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(cfg.APITokens) != 0 {
|
||||
t.Fatalf("cancelled pending credential remained: %#v", cfg.APITokens)
|
||||
}
|
||||
if rec := request(pending.Token, "default", ""); rec.Code == http.StatusNoContent {
|
||||
t.Fatal("removed credential incorrectly returned rollback-authorizing 204")
|
||||
}
|
||||
|
||||
active := issueActionRunnerCredentialForTest(t, router, hostID, "host-1.local")
|
||||
commitActionRunnerCredentialForTest(t, router, active)
|
||||
if rec := request(active.Token, "default", ""); rec.Code != http.StatusConflict {
|
||||
t.Fatalf("committed cancel status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != active.TokenID {
|
||||
t.Fatalf("committed cancel changed active credential: %#v", cfg.APITokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateActionRunnerCredentialPersistenceFailureKeepsBothCredentialsAndPendingSession(t *testing.T) {
|
||||
router, cfg, hostID := newActionRunnerCredentialTestRouter(t)
|
||||
router.agentExecServer = agentexec.NewServerWithAdmissionValidator(router.admitAgentExecToken, router.validateAgentExecSession)
|
||||
@@ -313,6 +393,26 @@ func TestActivateActionRunnerCredentialRequiresExactRegisteredSession(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateActionRunnerCredentialRequiresDurablePersistenceWithoutChangingTokenOrSession(t *testing.T) {
|
||||
router, cfg, hostID := newActionRunnerCredentialTestRouter(t)
|
||||
router.agentExecServer = agentexec.NewServerWithAdmissionValidator(router.admitAgentExecToken, router.validateAgentExecSession)
|
||||
issued := issueActionRunnerCredentialForTest(t, router, hostID, "host-1.local")
|
||||
pendingConn, pendingServer := connectActionRunnerCredentialForTest(t, router.agentExecServer, issued)
|
||||
defer pendingConn.Close()
|
||||
defer pendingServer.Close()
|
||||
router.persistence = nil
|
||||
if rec := requestActionRunnerActivationForTest(t, router, issued); rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("nil persistence activation status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != issued.TokenID || cfg.APITokens[0].ExpiresAt == nil || cfg.APITokens[0].Metadata[agenttokens.ActionRunnerActivationPendingMetadataKey] != "true" {
|
||||
t.Fatalf("nil persistence activation changed inventory = %#v", cfg.APITokens)
|
||||
}
|
||||
admission, ok := router.admitAgentExecToken(issued.Token, issued.AgentID, issued.Hostname)
|
||||
if !ok || !admission.ActivationPending || !router.agentExecServer.HasActionRunnerSession(admission) {
|
||||
t.Fatalf("nil persistence activation changed pending session = %#v, %v", admission, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfRevokeActionRunnerCredentialRequiresExactBearerBindingAndClosesSession(t *testing.T) {
|
||||
router, cfg, hostID := newActionRunnerCredentialTestRouter(t)
|
||||
router.agentExecServer = agentexec.NewServerWithAdmissionValidator(router.admitAgentExecToken, router.validateAgentExecSession)
|
||||
@@ -353,6 +453,36 @@ func TestSelfRevokeActionRunnerCredentialRequiresExactBearerBindingAndClosesSess
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfRevokeActionRunnerCredentialRequiresDurablePersistence(t *testing.T) {
|
||||
router, cfg, hostID := newActionRunnerCredentialTestRouter(t)
|
||||
issued := issueActionRunnerCredentialForTest(t, router, hostID, "host-1.local")
|
||||
request := func() *httptest.ResponseRecorder {
|
||||
body, _ := json.Marshal(actionRunnerCredentialSelfRevokeRequest{AgentID: issued.AgentID, Hostname: issued.Hostname})
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/agents/action-runner/credential", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+issued.Token)
|
||||
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "default"))
|
||||
rec := httptest.NewRecorder()
|
||||
actionRunnerCredentialRoute(cfg, router.handleIssueActionRunnerCredential, router.handleActivateActionRunnerCredential, router.handleSelfRevokeActionRunnerCredential)(rec, req)
|
||||
return rec
|
||||
}
|
||||
originalPersistence := router.persistence
|
||||
router.persistence = nil
|
||||
if rec := request(); rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("nil persistence status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != issued.TokenID {
|
||||
t.Fatalf("nil persistence mutated inventory: %#v", cfg.APITokens)
|
||||
}
|
||||
router.persistence = originalPersistence
|
||||
router.persistence.SetFileSystem(actionRunnerFailingPersistenceFS{})
|
||||
if rec := request(); rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("failed persistence status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != issued.TokenID {
|
||||
t.Fatalf("failed persistence mutated inventory: %#v", cfg.APITokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRunnerCredentialRouteAuthSupportsAdminSessionAndScopedToken(t *testing.T) {
|
||||
for _, mode := range []string{"api-token", "admin-session"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
@@ -587,14 +717,14 @@ func TestActionRunnerRotationProductionRouterTLSPersistenceRestart(t *testing.T)
|
||||
t.Fatal("rotation prepare rejected the first secret")
|
||||
}
|
||||
secondConn, registered := dial(server, second)
|
||||
if !registered.Success || router.agentExecServer.IsAgentConnectedForOrganization("default", hostID) {
|
||||
if !registered.Success || !router.agentExecServer.IsAgentConnectedForOrganization("default", hostID) {
|
||||
secondConn.Close()
|
||||
firstConn.Close()
|
||||
shutdown(router, monitor, server)
|
||||
t.Fatalf("prepared second registration = %#v", registered)
|
||||
}
|
||||
requireSocketClosed(firstConn, "superseded runner")
|
||||
activate(server, second)
|
||||
requireSocketClosed(firstConn, "superseded runner")
|
||||
if _, ok := cfg.ValidateAPIToken(first.Token); ok {
|
||||
secondConn.Close()
|
||||
firstConn.Close()
|
||||
|
||||
@@ -150,15 +150,17 @@ func (r *Router) admitAgentExecToken(token string, agentID string, hostname stri
|
||||
return agentexec.AgentAdmission{}, false
|
||||
}
|
||||
capability := strings.TrimSpace(record.Metadata[agenttokens.ActionCapabilityMetadataKey])
|
||||
canonicalHostname := strings.TrimSpace(record.Metadata["bound_hostname"])
|
||||
activationPending := strings.TrimSpace(record.Metadata[agenttokens.ActionRunnerActivationPendingMetadataKey]) == "true"
|
||||
config.Mu.Unlock()
|
||||
return agentexec.AgentAdmission{
|
||||
OrganizationID: organizationID,
|
||||
TokenID: tokenID,
|
||||
AgentID: requestedID,
|
||||
Hostname: requestedHost,
|
||||
Hostname: canonicalHostname,
|
||||
RuntimeRole: agentexec.RuntimeRoleActionRunner,
|
||||
ActionCapability: capability,
|
||||
ActivationPending: strings.TrimSpace(record.Metadata[agenttokens.ActionRunnerActivationPendingMetadataKey]) == "true",
|
||||
ActivationPending: activationPending,
|
||||
}, true
|
||||
}
|
||||
if runtimeRole != "" && runtimeRole != agenttokens.CredentialKindLegacyFullTrust {
|
||||
|
||||
@@ -35,9 +35,12 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrGeneration = errors.New("agent install token generation failed")
|
||||
ErrRecord = errors.New("agent install token record failed")
|
||||
ErrPersist = errors.New("agent install token persistence failed")
|
||||
ErrGeneration = errors.New("agent install token generation failed")
|
||||
ErrRecord = errors.New("agent install token record failed")
|
||||
ErrPersist = errors.New("agent install token persistence failed")
|
||||
ErrActionRunnerAlreadyActivated = errors.New("action runner credential activation already committed")
|
||||
ErrActionRunnerSessionUnavailable = errors.New("exact prepared action runner session unavailable")
|
||||
ErrActionRunnerActivationIndeterminate = errors.New("action runner credential activation is indeterminate")
|
||||
)
|
||||
|
||||
type IssueOptions struct {
|
||||
@@ -306,9 +309,29 @@ func IssueActionRunnerAndPersistDetailed(cfg *config.Config, persistence *config
|
||||
// ActivateActionRunnerAndPersist commits a prepared credential and revokes the
|
||||
// exact predecessor set in the same durable token-inventory transaction.
|
||||
func ActivateActionRunnerAndPersist(cfg *config.Config, persistence *config.ConfigPersistence, tokenID, agentID, hostname string) (*config.APITokenRecord, []config.APITokenRecord, bool, error) {
|
||||
return activateActionRunnerAndPersist(cfg, persistence, tokenID, agentID, hostname, false, nil)
|
||||
}
|
||||
|
||||
// ActivateActionRunnerAndPersistWithPromotion durably activates an action
|
||||
// runner credential only when the exact prepared transport can be promoted in
|
||||
// the same serialized transaction. The promotion callback must perform only a
|
||||
// bounded in-memory mutation: it runs while config.Mu is held and may acquire
|
||||
// the agent-exec server mutex, establishing the sole nested lock order
|
||||
// config.Mu -> agentexec.Server.mu. It must not close sockets, log, or wait.
|
||||
func ActivateActionRunnerAndPersistWithPromotion(cfg *config.Config, persistence *config.ConfigPersistence, tokenID, agentID, hostname string, promote func() bool) (*config.APITokenRecord, []config.APITokenRecord, bool, error) {
|
||||
return activateActionRunnerAndPersist(cfg, persistence, tokenID, agentID, hostname, true, promote)
|
||||
}
|
||||
|
||||
func activateActionRunnerAndPersist(cfg *config.Config, persistence *config.ConfigPersistence, tokenID, agentID, hostname string, requireDurablePromotion bool, promote func() bool) (*config.APITokenRecord, []config.APITokenRecord, bool, error) {
|
||||
if cfg == nil {
|
||||
return nil, nil, false, fmt.Errorf("%w: config is required", ErrRecord)
|
||||
}
|
||||
if persistence == nil {
|
||||
return nil, nil, false, fmt.Errorf("%w: durable persistence is required", ErrPersist)
|
||||
}
|
||||
if requireDurablePromotion && promote == nil {
|
||||
return nil, nil, false, fmt.Errorf("%w: prepared session promotion is required", ErrActionRunnerSessionUnavailable)
|
||||
}
|
||||
tokenID = strings.TrimSpace(tokenID)
|
||||
agentID = strings.TrimSpace(agentID)
|
||||
hostname = unifiedresources.NormalizeFullHostname(hostname)
|
||||
@@ -362,16 +385,90 @@ func ActivateActionRunnerAndPersist(cfg *config.Config, persistence *config.Conf
|
||||
}
|
||||
cfg.APITokens = nextTokens
|
||||
cfg.SortAPITokens()
|
||||
if persistence != nil {
|
||||
if err := persistence.SaveAPITokens(cfg.APITokens); err != nil {
|
||||
cfg.APITokens = previousTokens
|
||||
cfg.SortAPITokens()
|
||||
return nil, nil, false, fmt.Errorf("%w: %w", ErrPersist, err)
|
||||
if err := persistence.SaveAPITokens(cfg.APITokens); err != nil {
|
||||
cfg.APITokens = previousTokens
|
||||
cfg.SortAPITokens()
|
||||
return nil, nil, false, fmt.Errorf("%w: %w", ErrPersist, err)
|
||||
}
|
||||
if promote != nil && !promote() {
|
||||
if err := persistence.SaveAPITokens(previousTokens); err != nil {
|
||||
// The activation inventory was the last state known to reach durable
|
||||
// storage. Keep memory aligned with that state and force repair rather
|
||||
// than exposing pending memory against an active on-disk credential.
|
||||
return &activated, revoked, true, fmt.Errorf("%w: rollback persistence failed: %v", ErrActionRunnerActivationIndeterminate, err)
|
||||
}
|
||||
cfg.APITokens = previousTokens
|
||||
cfg.SortAPITokens()
|
||||
return nil, nil, false, ErrActionRunnerSessionUnavailable
|
||||
}
|
||||
return &activated, revoked, true, nil
|
||||
}
|
||||
|
||||
// CancelPendingActionRunnerAndPersist atomically makes a prepared replacement
|
||||
// unusable before an installer restores its predecessor. A status snapshot is
|
||||
// deliberately insufficient: only successful durable removal under the same
|
||||
// inventory lock used by activation is rollback authority.
|
||||
func CancelPendingActionRunnerAndPersist(cfg *config.Config, persistence *config.ConfigPersistence, tokenID, organizationID, agentID, hostname string) (*config.APITokenRecord, error) {
|
||||
if cfg == nil || persistence == nil {
|
||||
return nil, fmt.Errorf("%w: config and durable persistence are required", ErrPersist)
|
||||
}
|
||||
tokenID = strings.TrimSpace(tokenID)
|
||||
organizationID = strings.TrimSpace(organizationID)
|
||||
agentID = strings.TrimSpace(agentID)
|
||||
hostname = unifiedresources.NormalizeFullHostname(hostname)
|
||||
if tokenID == "" || organizationID == "" || agentID == "" || hostname == "" {
|
||||
return nil, fmt.Errorf("%w: complete cancellation identity is required", ErrRecord)
|
||||
}
|
||||
|
||||
config.Mu.Lock()
|
||||
defer config.Mu.Unlock()
|
||||
index := -1
|
||||
for candidateIndex := range cfg.APITokens {
|
||||
if strings.TrimSpace(cfg.APITokens[candidateIndex].ID) == tokenID {
|
||||
index = candidateIndex
|
||||
break
|
||||
}
|
||||
}
|
||||
if index < 0 {
|
||||
return nil, fmt.Errorf("%w: action runner credential not found", ErrRecord)
|
||||
}
|
||||
record := &cfg.APITokens[index]
|
||||
boundOrgs := record.GetBoundOrgs()
|
||||
if record.IsExpired() || len(boundOrgs) != 1 || strings.TrimSpace(boundOrgs[0]) != organizationID ||
|
||||
strings.TrimSpace(record.OrgID) != organizationID ||
|
||||
strings.TrimSpace(record.Metadata[CredentialKindMetadataKey]) != CredentialKindActionRunner ||
|
||||
strings.TrimSpace(record.Metadata[RuntimeRoleMetadataKey]) != CredentialKindActionRunner ||
|
||||
strings.TrimSpace(record.Metadata[ActionCapabilityMetadataKey]) != ActionCapabilityTypedV1 ||
|
||||
strings.TrimSpace(record.Metadata[ActionBindingVersionMetadataKey]) != ActionBindingVersion ||
|
||||
strings.TrimSpace(record.Metadata["bound_agent_id"]) != agentID ||
|
||||
!unifiedresources.HostnamesEquivalent(record.Metadata["bound_hostname"], hostname) {
|
||||
return nil, fmt.Errorf("%w: action runner cancellation binding mismatch", ErrRecord)
|
||||
}
|
||||
if err := internalauth.ValidateRoleScopes(CredentialKindActionRunner, record.Scopes); err != nil {
|
||||
return nil, fmt.Errorf("%w: action runner cancellation authority mismatch: %v", ErrRecord, err)
|
||||
}
|
||||
if strings.TrimSpace(record.Metadata[ActionRunnerActivationPendingMetadataKey]) != "true" {
|
||||
return nil, ErrActionRunnerAlreadyActivated
|
||||
}
|
||||
for _, replacedID := range strings.Split(record.Metadata[ActionRunnerReplacesTokenIDsMetadataKey], ",") {
|
||||
replacedID = strings.TrimSpace(replacedID)
|
||||
if replacedID == tokenID {
|
||||
return nil, fmt.Errorf("%w: invalid pending replacement structure", ErrRecord)
|
||||
}
|
||||
}
|
||||
|
||||
previousTokens := cloneAPITokenRecords(cfg.APITokens)
|
||||
removed := record.Clone()
|
||||
cfg.APITokens = append(cfg.APITokens[:index], cfg.APITokens[index+1:]...)
|
||||
cfg.SortAPITokens()
|
||||
if err := persistence.SaveAPITokens(cfg.APITokens); err != nil {
|
||||
cfg.APITokens = previousTokens
|
||||
cfg.SortAPITokens()
|
||||
return nil, fmt.Errorf("%w: %w", ErrPersist, err)
|
||||
}
|
||||
return &removed, nil
|
||||
}
|
||||
|
||||
func normalizeCredentialKind(record *config.APITokenRecord) error {
|
||||
if record == nil {
|
||||
return nil
|
||||
|
||||
@@ -4,13 +4,80 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
)
|
||||
|
||||
type actionRunnerTestFS struct {
|
||||
blockOnce sync.Once
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
writeErr error
|
||||
}
|
||||
|
||||
type actionRunnerFailAfterWritesFS struct {
|
||||
mu sync.Mutex
|
||||
writes int
|
||||
allowWrites int
|
||||
}
|
||||
|
||||
func (fs *actionRunnerFailAfterWritesFS) ReadFile(name string) ([]byte, error) {
|
||||
return os.ReadFile(name)
|
||||
}
|
||||
func (fs *actionRunnerFailAfterWritesFS) WriteFile(name string, data []byte, perm os.FileMode) error {
|
||||
fs.mu.Lock()
|
||||
fs.writes++
|
||||
writes := fs.writes
|
||||
fs.mu.Unlock()
|
||||
if writes > fs.allowWrites {
|
||||
return errors.New("injected rollback persistence failure")
|
||||
}
|
||||
return os.WriteFile(name, data, perm)
|
||||
}
|
||||
func (fs *actionRunnerFailAfterWritesFS) Rename(oldPath, newPath string) error {
|
||||
return os.Rename(oldPath, newPath)
|
||||
}
|
||||
func (fs *actionRunnerFailAfterWritesFS) Remove(name string) error { return os.Remove(name) }
|
||||
func (fs *actionRunnerFailAfterWritesFS) Stat(name string) (os.FileInfo, error) {
|
||||
return os.Stat(name)
|
||||
}
|
||||
func (fs *actionRunnerFailAfterWritesFS) MkdirAll(path string, perm os.FileMode) error {
|
||||
return os.MkdirAll(path, perm)
|
||||
}
|
||||
|
||||
func (fs *actionRunnerTestFS) ReadFile(name string) ([]byte, error) { return os.ReadFile(name) }
|
||||
func (fs *actionRunnerTestFS) WriteFile(name string, data []byte, perm os.FileMode) error {
|
||||
blocked := false
|
||||
fs.blockOnce.Do(func() {
|
||||
blocked = fs.entered != nil
|
||||
if blocked {
|
||||
close(fs.entered)
|
||||
}
|
||||
})
|
||||
if blocked {
|
||||
<-fs.release
|
||||
}
|
||||
if fs.writeErr != nil {
|
||||
return fs.writeErr
|
||||
}
|
||||
return os.WriteFile(name, data, perm)
|
||||
}
|
||||
func (fs *actionRunnerTestFS) Rename(oldPath, newPath string) error {
|
||||
return os.Rename(oldPath, newPath)
|
||||
}
|
||||
func (fs *actionRunnerTestFS) Remove(name string) error { return os.Remove(name) }
|
||||
func (fs *actionRunnerTestFS) Stat(name string) (os.FileInfo, error) {
|
||||
return os.Stat(name)
|
||||
}
|
||||
func (fs *actionRunnerTestFS) MkdirAll(path string, perm os.FileMode) error {
|
||||
return os.MkdirAll(path, perm)
|
||||
}
|
||||
|
||||
func TestIssueAndPersistInstallToken(t *testing.T) {
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
raw, record, err := IssueAndPersist(cfg, nil, IssueOptions{
|
||||
@@ -96,7 +163,7 @@ func TestIssueActionRunnerAndPersistPreparesRotationWithoutRevokingActiveCredent
|
||||
if err != nil {
|
||||
t.Fatalf("first IssueActionRunnerAndPersist: %v", err)
|
||||
}
|
||||
if _, _, changed, err := ActivateActionRunnerAndPersist(cfg, nil, first.ID, "machine-123", "node.example"); err != nil || !changed {
|
||||
if _, _, changed, err := ActivateActionRunnerAndPersist(cfg, config.NewConfigPersistence(cfg.DataPath), first.ID, "machine-123", "node.example"); err != nil || !changed {
|
||||
t.Fatalf("activate first credential = changed %v, error %v", changed, err)
|
||||
}
|
||||
_, otherHost, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{
|
||||
@@ -164,7 +231,7 @@ func TestActivateActionRunnerAndPersistAtomicallyPromotesAndRevokesPredecessor(t
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := ActivateActionRunnerAndPersist(cfg, nil, first.ID, "machine-123", "node.example"); err != nil {
|
||||
if _, _, _, err := ActivateActionRunnerAndPersist(cfg, config.NewConfigPersistence(cfg.DataPath), first.ID, "machine-123", "node.example"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondToken, second, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example"})
|
||||
@@ -187,18 +254,37 @@ func TestActivateActionRunnerAndPersistAtomicallyPromotesAndRevokesPredecessor(t
|
||||
if _, ok := cfg.ValidateAPIToken(secondToken); !ok {
|
||||
t.Fatal("activated replacement is not valid")
|
||||
}
|
||||
if _, revoked, changed, err := ActivateActionRunnerAndPersist(cfg, nil, second.ID, "machine-123", "node.example"); err != nil || changed || len(revoked) != 0 {
|
||||
if _, revoked, changed, err := ActivateActionRunnerAndPersist(cfg, config.NewConfigPersistence(cfg.DataPath), second.ID, "machine-123", "node.example"); err != nil || changed || len(revoked) != 0 {
|
||||
t.Fatalf("idempotent activation = revoked %#v, changed %v, error %v", revoked, changed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateActionRunnerAndPersistRequiresDurablePersistenceWithoutMutation(t *testing.T) {
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
_, pending, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{
|
||||
OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := cloneAPITokenRecords(cfg.APITokens)
|
||||
if activated, revoked, changed, err := ActivateActionRunnerAndPersist(
|
||||
cfg, nil, pending.ID, "machine-123", "node.example",
|
||||
); !errors.Is(err, ErrPersist) || activated != nil || revoked != nil || changed {
|
||||
t.Fatalf("nil persistence activation = activated %#v revoked %#v changed %v err %v", activated, revoked, changed, err)
|
||||
}
|
||||
if !reflect.DeepEqual(cfg.APITokens, before) {
|
||||
t.Fatalf("nil persistence mutated inventory: before %#v after %#v", before, cfg.APITokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateActionRunnerAndPersistFailureRestoresPendingAndActiveInventory(t *testing.T) {
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
_, first, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := ActivateActionRunnerAndPersist(cfg, nil, first.ID, "machine-123", "node.example"); err != nil {
|
||||
if _, _, _, err := ActivateActionRunnerAndPersist(cfg, config.NewConfigPersistence(cfg.DataPath), first.ID, "machine-123", "node.example"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, second, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example"})
|
||||
@@ -226,6 +312,245 @@ func TestActivateActionRunnerAndPersistFailureRestoresPendingAndActiveInventory(
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateActionRunnerAndPersistWithPromotionRollsBackWhenExactTransportVanished(t *testing.T) {
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
_, predecessor, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := ActivateActionRunnerAndPersist(cfg, config.NewConfigPersistence(cfg.DataPath), predecessor.ID, "machine-123", "node.example"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, pending, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
persistence := config.NewConfigPersistence(cfg.DataPath)
|
||||
promotionCalls := 0
|
||||
activated, revoked, changed, err := ActivateActionRunnerAndPersistWithPromotion(cfg, persistence, pending.ID, "machine-123", "node.example", func() bool {
|
||||
promotionCalls++
|
||||
return false
|
||||
})
|
||||
if !errors.Is(err, ErrActionRunnerSessionUnavailable) || activated != nil || revoked != nil || changed {
|
||||
t.Fatalf("vanished transport activation = activated %#v revoked %#v changed %v err %v", activated, revoked, changed, err)
|
||||
}
|
||||
if promotionCalls != 1 {
|
||||
t.Fatalf("promotion calls = %d, want 1", promotionCalls)
|
||||
}
|
||||
if len(cfg.APITokens) != 2 {
|
||||
t.Fatalf("rolled-back inventory = %#v", cfg.APITokens)
|
||||
}
|
||||
var foundPredecessor, foundPending bool
|
||||
for _, record := range cfg.APITokens {
|
||||
switch record.ID {
|
||||
case predecessor.ID:
|
||||
foundPredecessor = record.ExpiresAt == nil
|
||||
case pending.ID:
|
||||
foundPending = record.ExpiresAt != nil && record.Metadata[ActionRunnerActivationPendingMetadataKey] == "true"
|
||||
}
|
||||
}
|
||||
if !foundPredecessor || !foundPending {
|
||||
t.Fatalf("rollback did not restore predecessor and pending replacement: %#v", cfg.APITokens)
|
||||
}
|
||||
persisted, err := config.NewConfigPersistence(cfg.DataPath).LoadAPITokens()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(persisted) != 2 {
|
||||
t.Fatalf("durable rollback inventory = %#v", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateActionRunnerPromotionRollbackPersistenceFailureKeepsLastDurableActivation(t *testing.T) {
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
_, predecessor, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := ActivateActionRunnerAndPersist(cfg, config.NewConfigPersistence(cfg.DataPath), predecessor.ID, "machine-123", "node.example"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, pending, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
persistence := config.NewConfigPersistence(cfg.DataPath)
|
||||
// The pre-existing durable predecessor inventory causes activation to write
|
||||
// one backup plus the new token file. Permit both, then fail the compensating
|
||||
// rollback writes.
|
||||
persistence.SetFileSystem(&actionRunnerFailAfterWritesFS{allowWrites: 2})
|
||||
activated, revoked, changed, err := ActivateActionRunnerAndPersistWithPromotion(cfg, persistence, pending.ID, "machine-123", "node.example", func() bool { return false })
|
||||
if !errors.Is(err, ErrActionRunnerActivationIndeterminate) || activated == nil || activated.ID != pending.ID || len(revoked) != 1 || revoked[0].ID != predecessor.ID || !changed {
|
||||
t.Fatalf("rollback persistence failure = activated %#v revoked %#v changed %v err %v", activated, revoked, changed, err)
|
||||
}
|
||||
if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != pending.ID || cfg.APITokens[0].ExpiresAt != nil || cfg.APITokens[0].Metadata[ActionRunnerActivationPendingMetadataKey] != "" {
|
||||
t.Fatalf("memory diverged from last durable activation: %#v", cfg.APITokens)
|
||||
}
|
||||
persisted, err := config.NewConfigPersistence(cfg.DataPath).LoadAPITokens()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(persisted) != 1 || persisted[0].ID != pending.ID || persisted[0].ExpiresAt != nil || persisted[0].Metadata[ActionRunnerActivationPendingMetadataKey] != "" {
|
||||
t.Fatalf("last durable activation inventory = %#v", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelPendingActionRunnerAndPersistSerializesWithActivationInBothOrders(t *testing.T) {
|
||||
seed := func(t *testing.T) (*config.Config, *config.ConfigPersistence, *config.APITokenRecord, *config.APITokenRecord) {
|
||||
t.Helper()
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
_, first, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := ActivateActionRunnerAndPersist(cfg, config.NewConfigPersistence(cfg.DataPath), first.ID, "machine-123", "node.example"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, second, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cfg, config.NewConfigPersistence(cfg.DataPath), first, second
|
||||
}
|
||||
|
||||
t.Run("cancel wins", func(t *testing.T) {
|
||||
cfg, persistence, first, second := seed(t)
|
||||
removed, err := CancelPendingActionRunnerAndPersist(cfg, persistence, second.ID, "org-a", "machine-123", "NODE")
|
||||
if err != nil || removed == nil || removed.ID != second.ID {
|
||||
t.Fatalf("cancel = (%#v, %v)", removed, err)
|
||||
}
|
||||
if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != first.ID {
|
||||
t.Fatalf("cancelled inventory = %#v", cfg.APITokens)
|
||||
}
|
||||
if _, _, _, err := ActivateActionRunnerAndPersist(cfg, persistence, second.ID, "machine-123", "node.example"); !errors.Is(err, ErrRecord) {
|
||||
t.Fatalf("late activation error = %v, want ErrRecord", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("activation wins", func(t *testing.T) {
|
||||
cfg, persistence, _, second := seed(t)
|
||||
if _, _, changed, err := ActivateActionRunnerAndPersist(cfg, persistence, second.ID, "machine-123", "node.example"); err != nil || !changed {
|
||||
t.Fatalf("activation = changed %v, error %v", changed, err)
|
||||
}
|
||||
if removed, err := CancelPendingActionRunnerAndPersist(cfg, persistence, second.ID, "org-a", "machine-123", "node.example"); !errors.Is(err, ErrActionRunnerAlreadyActivated) || removed != nil {
|
||||
t.Fatalf("post-commit cancel = (%#v, %v)", removed, err)
|
||||
}
|
||||
if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != second.ID || cfg.APITokens[0].ExpiresAt != nil {
|
||||
t.Fatalf("activated inventory = %#v", cfg.APITokens)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCancelPendingActionRunnerAndPersistContendsUnderOneDurableTransaction(t *testing.T) {
|
||||
seed := func(t *testing.T) (*config.Config, *config.ConfigPersistence, *config.APITokenRecord, *config.APITokenRecord) {
|
||||
t.Helper()
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
_, first, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := ActivateActionRunnerAndPersist(cfg, config.NewConfigPersistence(cfg.DataPath), first.ID, "machine-123", "node.example"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, pending, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cfg, config.NewConfigPersistence(cfg.DataPath), first, pending
|
||||
}
|
||||
waitSignal := func(t *testing.T, signal <-chan struct{}, label string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-signal:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("timed out waiting for %s", label)
|
||||
}
|
||||
}
|
||||
waitErr := func(t *testing.T, result <-chan error, label string) error {
|
||||
t.Helper()
|
||||
select {
|
||||
case err := <-result:
|
||||
return err
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("timed out waiting for %s", label)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("cancel owns lock before activation", func(t *testing.T) {
|
||||
cfg, persistence, predecessor, pending := seed(t)
|
||||
fs := &actionRunnerTestFS{entered: make(chan struct{}), release: make(chan struct{})}
|
||||
persistence.SetFileSystem(fs)
|
||||
cancelResult := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := CancelPendingActionRunnerAndPersist(cfg, persistence, pending.ID, "org-a", "machine-123", "node.example")
|
||||
cancelResult <- err
|
||||
}()
|
||||
waitSignal(t, fs.entered, "cancel persistence")
|
||||
activateResult := make(chan error, 1)
|
||||
go func() {
|
||||
_, _, _, err := ActivateActionRunnerAndPersist(cfg, persistence, pending.ID, "machine-123", "node.example")
|
||||
activateResult <- err
|
||||
}()
|
||||
close(fs.release)
|
||||
if err := waitErr(t, cancelResult, "cancel result"); err != nil {
|
||||
t.Fatalf("cancel error = %v", err)
|
||||
}
|
||||
if err := waitErr(t, activateResult, "late activation result"); !errors.Is(err, ErrRecord) {
|
||||
t.Fatalf("late activation error = %v, want ErrRecord", err)
|
||||
}
|
||||
if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != predecessor.ID {
|
||||
t.Fatalf("cancel-wins inventory = %#v", cfg.APITokens)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("activation owns lock before cancel", func(t *testing.T) {
|
||||
cfg, persistence, predecessor, pending := seed(t)
|
||||
fs := &actionRunnerTestFS{entered: make(chan struct{}), release: make(chan struct{})}
|
||||
persistence.SetFileSystem(fs)
|
||||
activateResult := make(chan error, 1)
|
||||
go func() {
|
||||
_, _, _, err := ActivateActionRunnerAndPersist(cfg, persistence, pending.ID, "machine-123", "node.example")
|
||||
activateResult <- err
|
||||
}()
|
||||
waitSignal(t, fs.entered, "activation persistence")
|
||||
cancelResult := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := CancelPendingActionRunnerAndPersist(cfg, persistence, pending.ID, "org-a", "machine-123", "node.example")
|
||||
cancelResult <- err
|
||||
}()
|
||||
close(fs.release)
|
||||
if err := waitErr(t, activateResult, "activation result"); err != nil {
|
||||
t.Fatalf("activation error = %v", err)
|
||||
}
|
||||
if err := waitErr(t, cancelResult, "post-commit cancel result"); !errors.Is(err, ErrActionRunnerAlreadyActivated) {
|
||||
t.Fatalf("post-commit cancel error = %v, want ErrActionRunnerAlreadyActivated", err)
|
||||
}
|
||||
if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != pending.ID || cfg.APITokens[0].ExpiresAt != nil || cfg.APITokens[0].Metadata[ActionRunnerActivationPendingMetadataKey] != "" || cfg.APITokens[0].ID == predecessor.ID {
|
||||
t.Fatalf("activation-wins inventory = %#v", cfg.APITokens)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCancelPendingActionRunnerAndPersistFailureNeverAuthorizesRollback(t *testing.T) {
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
_, pending, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
persistence := config.NewConfigPersistence(t.TempDir())
|
||||
persistence.SetFileSystem(&actionRunnerTestFS{writeErr: errors.New("injected persistence failure")})
|
||||
if removed, err := CancelPendingActionRunnerAndPersist(cfg, persistence, pending.ID, "org-a", "machine-123", "node.example"); !errors.Is(err, ErrPersist) || removed != nil {
|
||||
t.Fatalf("persistence failure = (%#v, %v)", removed, err)
|
||||
}
|
||||
if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != pending.ID || cfg.APITokens[0].Metadata[ActionRunnerActivationPendingMetadataKey] != "true" {
|
||||
t.Fatalf("failed cancel changed inventory = %#v", cfg.APITokens)
|
||||
}
|
||||
if removed, err := CancelPendingActionRunnerAndPersist(cfg, nil, pending.ID, "org-a", "machine-123", "node.example"); !errors.Is(err, ErrPersist) || removed != nil {
|
||||
t.Fatalf("nil persistence = (%#v, %v)", removed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueActionRunnerAndPersistRestoresReplacedCredentialOnPersistenceFailure(t *testing.T) {
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
_, prior, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
||||
"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"
|
||||
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
||||
)
|
||||
|
||||
@@ -75,7 +76,7 @@ func (r *Router) handleReduceCollectorAuthority(w http.ResponseWriter, req *http
|
||||
boundHostname := strings.TrimSpace(candidate.Metadata["bound_hostname"])
|
||||
if len(orgs) != 1 || strings.TrimSpace(orgs[0]) != organizationID ||
|
||||
boundAgentID == "" || boundAgentID != payload.AgentID ||
|
||||
boundHostname == "" || !strings.EqualFold(boundHostname, payload.Hostname) {
|
||||
boundHostname == "" || !collectorAuthorityHostnamesEquivalent(boundHostname, payload.Hostname) {
|
||||
config.Mu.Unlock()
|
||||
http.Error(w, "Collector credential binding mismatch", http.StatusForbidden)
|
||||
return
|
||||
@@ -124,3 +125,9 @@ func (r *Router) handleReduceCollectorAuthority(w http.ResponseWriter, req *http
|
||||
LogAuditEventForTenant(organizationID, "collector_authority_reduced", caller.Name, GetClientIP(req), req.URL.Path, true, "Reduced collector credential to its exact monitoring scope allowlist")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func collectorAuthorityHostnamesEquivalent(bound, requested string) bool {
|
||||
bound = strings.TrimSpace(bound)
|
||||
requested = strings.TrimSpace(requested)
|
||||
return strings.EqualFold(bound, requested) || unifiedresources.HostnamesEquivalent(bound, requested)
|
||||
}
|
||||
|
||||
@@ -74,6 +74,30 @@ func TestReduceCollectorAuthorityRejectsCrossHostBinding(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectorAuthorityHostnamesEquivalent(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
bound string
|
||||
requested string
|
||||
want bool
|
||||
}{
|
||||
{name: "short to FQDN", bound: "node-a", requested: "node-a.example.test", want: true},
|
||||
{name: "FQDN to short", bound: "node-a.example.test", requested: "node-a", want: true},
|
||||
{name: "same FQDN case and trailing dot", bound: "Node-A.Example.Test.", requested: "node-a.example.test", want: true},
|
||||
{name: "distinct same-label FQDN", bound: "node-a.one.test", requested: "node-a.two.test", want: false},
|
||||
{name: "same IPv4 literal", bound: "192.0.2.10", requested: "192.0.2.10", want: true},
|
||||
{name: "distinct IPv4 literal", bound: "192.0.2.10", requested: "192.0.2.11", want: false},
|
||||
{name: "same IPv6 literal", bound: "2001:DB8::10", requested: "2001:db8::10", want: true},
|
||||
{name: "IP and hostname", bound: "192.0.2.10", requested: "node-a", want: false},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := collectorAuthorityHostnamesEquivalent(test.bound, test.requested); got != test.want {
|
||||
t.Fatalf("collectorAuthorityHostnamesEquivalent(%q, %q) = %v, want %v", test.bound, test.requested, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceCollectorAuthorityRejectsUnboundCredential(t *testing.T) {
|
||||
record := &config.APITokenRecord{ID: "token-a", OrgID: "org-a", Scopes: []string{config.ScopeAgentReport, config.ScopeAgentExec}}
|
||||
cfg := &config.Config{APITokens: []config.APITokenRecord{*record}}
|
||||
|
||||
@@ -380,6 +380,7 @@ var allRouteAllowlist = []string{
|
||||
"/api/agents/docker/report",
|
||||
"/api/agents/kubernetes/report",
|
||||
"/api/agents/action-runner/credential",
|
||||
"/api/agents/action-runner/credential/activation",
|
||||
"/api/agents/collector/reduce-authority",
|
||||
"/api/agents/agent/report",
|
||||
"/api/agents/host/report",
|
||||
|
||||
@@ -56,6 +56,7 @@ func (r *Router) registerConfigSystemRoutes(updateHandlers *UpdateHandlers) {
|
||||
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", RequireAuth(r.config, actionRunnerCredentialRoute(r.config, r.handleIssueActionRunnerCredential, r.handleActivateActionRunnerCredential, r.handleSelfRevokeActionRunnerCredential)))
|
||||
r.mux.HandleFunc("/api/agents/action-runner/credential/activation", RequireAuth(r.config, RequireScope(config.ScopeAgentExec, r.handleCancelPendingActionRunnerCredentialActivation)))
|
||||
r.mux.HandleFunc("/api/agents/collector/reduce-authority", RequireAuth(r.config, RequireScope(config.ScopeAgentReport, r.handleReduceCollectorAuthority)))
|
||||
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)))
|
||||
|
||||
@@ -737,7 +737,7 @@ func TestActionRunnerCredentialRotationRevokesPreviousSecretOnlyAtActivation(t *
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := agenttokens.ActivateActionRunnerAndPersist(cfg, nil, firstRecord.ID, "machine-a", "node.example"); err != nil {
|
||||
if _, _, _, err := agenttokens.ActivateActionRunnerAndPersist(cfg, config.NewConfigPersistence(cfg.DataPath), firstRecord.ID, "machine-a", "node.example"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router := &Router{config: cfg}
|
||||
@@ -760,7 +760,7 @@ func TestActionRunnerCredentialRotationRevokesPreviousSecretOnlyAtActivation(t *
|
||||
if admission, ok := router.admitAgentExecToken(secondToken, "machine-a", "renamed.example"); !ok || !admission.ActivationPending {
|
||||
t.Fatal("replacement action runner credential was rejected")
|
||||
}
|
||||
if _, revoked, changed, err := agenttokens.ActivateActionRunnerAndPersist(cfg, nil, secondRecord.ID, "machine-a", "renamed.example"); err != nil || !changed || len(revoked) != 1 || revoked[0].ID != firstRecord.ID {
|
||||
if _, revoked, changed, err := agenttokens.ActivateActionRunnerAndPersist(cfg, config.NewConfigPersistence(cfg.DataPath), secondRecord.ID, "machine-a", "renamed.example"); err != nil || !changed || len(revoked) != 1 || revoked[0].ID != firstRecord.ID {
|
||||
t.Fatalf("rotation activation = revoked %#v, changed %v, error %v", revoked, changed, err)
|
||||
}
|
||||
if _, ok := router.admitAgentExecToken(firstToken, "machine-a", "node.example"); ok {
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
package collectorlifecycle
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agenttls"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/securityutil"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRequestTimeout = 15 * time.Second
|
||||
maximumResponseBytes = 64 << 10
|
||||
maximumBearerBytes = 4 << 10
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrRegistrationPending means the server has not yet published a fresh
|
||||
// registration for the requested collector identity. Installers may retry.
|
||||
ErrRegistrationPending = errors.New("collector registration is not confirmed")
|
||||
// ErrCredentialRejected means retrying cannot repair the collector bearer.
|
||||
ErrCredentialRejected = errors.New("collector credential was rejected")
|
||||
loadSystemCertPool = x509.SystemCertPool
|
||||
)
|
||||
|
||||
// Config contains the complete trust and credential inputs for the narrow
|
||||
// installer lifecycle client. The bearer is deliberately accepted only as a
|
||||
// file path so it never needs to appear in process arguments or curl config.
|
||||
type Config struct {
|
||||
PulseURL string
|
||||
TokenFile string
|
||||
CACertPath string
|
||||
ServerFingerprint string
|
||||
// TokenOwnerUID permits the one dedicated collector account that may own a
|
||||
// migration-era runtime.token. Root is always trusted; no other UID is.
|
||||
TokenOwnerUID *uint64
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Client can only reduce the current collector's authority and inspect its
|
||||
// authoritative registration. It deliberately exposes no general request API.
|
||||
type Client struct {
|
||||
baseURL *url.URL
|
||||
bearer string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// Registration is the bounded server registration evidence consumed by the
|
||||
// safe-profile transaction.
|
||||
type Registration struct {
|
||||
AgentID string
|
||||
Hostname string
|
||||
LastSeen time.Time
|
||||
}
|
||||
|
||||
// New validates the destination before reading the bearer and constructs a
|
||||
// redirect-denying, system-CA/custom-CA/exact-leaf-pin-aware HTTP client.
|
||||
func New(config Config) (*Client, error) {
|
||||
baseURL, err := securityutil.NormalizePulseHTTPBaseURL(config.PulseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("validate collector lifecycle URL: %w", err)
|
||||
}
|
||||
if baseURL.Scheme == "http" && !exactLifecycleLoopbackHost(baseURL.Hostname()) {
|
||||
return nil, errors.New("collector lifecycle plaintext HTTP is allowed only for localhost, 127.0.0.1, or ::1")
|
||||
}
|
||||
if baseURL.Scheme == "http" && (strings.TrimSpace(config.CACertPath) != "" || strings.TrimSpace(config.ServerFingerprint) != "") {
|
||||
return nil, errors.New("collector lifecycle TLS trust options require an HTTPS URL")
|
||||
}
|
||||
bearer, err := readPrivateBearer(config.TokenFile, config.TokenOwnerUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tlsConfig, err := agenttls.NewClientTLSConfig(config.CACertPath, false, config.ServerFingerprint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("configure collector lifecycle TLS: %w", err)
|
||||
}
|
||||
if baseURL.Scheme == "https" && strings.TrimSpace(config.CACertPath) == "" && strings.TrimSpace(config.ServerFingerprint) == "" {
|
||||
roots, err := loadSystemCertPool()
|
||||
if err != nil || roots == nil {
|
||||
return nil, fmt.Errorf("load system certificate authorities: %w", err)
|
||||
}
|
||||
tlsConfig.RootCAs = roots
|
||||
}
|
||||
timeout := config.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultRequestTimeout
|
||||
}
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
bearer: bearer,
|
||||
http: &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{TLSClientConfig: tlsConfig},
|
||||
CheckRedirect: func(req *http.Request, _ []*http.Request) error {
|
||||
return fmt.Errorf("collector lifecycle server returned redirect to %s; use the final Pulse URL explicitly", req.URL)
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func exactLifecycleLoopbackHost(host string) bool {
|
||||
host = strings.TrimSpace(strings.Trim(host, "[]"))
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
// Close releases idle transport connections held by the lifecycle client.
|
||||
func (c *Client) Close() {
|
||||
if c != nil && c.http != nil {
|
||||
c.http.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
// ReduceAuthority durably removes execution and cross-host management scopes
|
||||
// from the exact bearer-bound collector. Only HTTP 204 is success authority.
|
||||
func (c *Client) ReduceAuthority(ctx context.Context, agentID, hostname string) error {
|
||||
agentID = strings.TrimSpace(agentID)
|
||||
hostname = strings.TrimSpace(hostname)
|
||||
if !validBoundedIdentity(agentID, 256) || !validBoundedIdentity(hostname, 253) {
|
||||
return errors.New("collector authority reduction requires a valid agent identity and hostname")
|
||||
}
|
||||
body, err := json.Marshal(map[string]string{"agentId": agentID, "hostname": hostname})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodPost, "/api/agents/collector/reduce-authority", bytes.NewReader(body), "application/json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("reduce collector authority: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maximumResponseBytes))
|
||||
if response.StatusCode != http.StatusNoContent {
|
||||
return fmt.Errorf("reduce collector authority: server returned %s", response.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyRegistration asks the authenticated server for the exact collector
|
||||
// registration. previousLastSeen, when non-zero, requires a later observation
|
||||
// so a stopped predecessor row cannot authorize a safe-profile commit.
|
||||
func (c *Client) VerifyRegistration(ctx context.Context, agentID, hostname string, previousLastSeen time.Time) (Registration, error) {
|
||||
agentID = strings.TrimSpace(agentID)
|
||||
hostname = strings.TrimSpace(hostname)
|
||||
query := url.Values{}
|
||||
switch {
|
||||
case validBoundedIdentity(agentID, 256):
|
||||
query.Set("id", agentID)
|
||||
case validBoundedIdentity(hostname, 253):
|
||||
query.Set("hostname", hostname)
|
||||
default:
|
||||
return Registration{}, errors.New("collector registration verification requires a valid agent identity or hostname")
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, "/api/agents/agent/lookup?"+query.Encode(), nil, "")
|
||||
if err != nil {
|
||||
return Registration{}, fmt.Errorf("verify collector registration: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
limited := io.LimitReader(response.Body, maximumResponseBytes+1)
|
||||
encoded, readErr := io.ReadAll(limited)
|
||||
if readErr != nil {
|
||||
return Registration{}, fmt.Errorf("read collector registration response: %w", readErr)
|
||||
}
|
||||
if len(encoded) > maximumResponseBytes {
|
||||
return Registration{}, errors.New("collector registration response exceeds the size limit")
|
||||
}
|
||||
switch response.StatusCode {
|
||||
case http.StatusUnauthorized:
|
||||
return Registration{}, fmt.Errorf("%w: server returned %s", ErrCredentialRejected, response.Status)
|
||||
case http.StatusForbidden:
|
||||
var failure struct {
|
||||
Code string `json:"code"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(encoded, &failure) == nil && (failure.Code == "agent_lookup_forbidden" || failure.Error.Code == "agent_lookup_forbidden") {
|
||||
return Registration{}, fmt.Errorf("%w: prior credential binding is still visible", ErrRegistrationPending)
|
||||
}
|
||||
return Registration{}, fmt.Errorf("%w: server returned %s", ErrCredentialRejected, response.Status)
|
||||
case http.StatusOK:
|
||||
// Continue below.
|
||||
default:
|
||||
return Registration{}, fmt.Errorf("%w: server returned %s", ErrRegistrationPending, response.Status)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Success bool `json:"success"`
|
||||
Agent struct {
|
||||
ID string `json:"id"`
|
||||
Hostname string `json:"hostname"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
} `json:"agent"`
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(encoded))
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
return Registration{}, fmt.Errorf("%w: invalid server response", ErrRegistrationPending)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return Registration{}, fmt.Errorf("%w: invalid trailing server response", ErrRegistrationPending)
|
||||
}
|
||||
payload.Agent.ID = strings.TrimSpace(payload.Agent.ID)
|
||||
payload.Agent.Hostname = strings.TrimSpace(payload.Agent.Hostname)
|
||||
if !payload.Success || !validBoundedIdentity(payload.Agent.ID, 256) || !validBoundedIdentity(payload.Agent.Hostname, 253) || payload.Agent.LastSeen.IsZero() {
|
||||
return Registration{}, fmt.Errorf("%w: incomplete server response", ErrRegistrationPending)
|
||||
}
|
||||
if agentID != "" && payload.Agent.ID != agentID {
|
||||
return Registration{}, fmt.Errorf("%w: server returned a different agent identity", ErrRegistrationPending)
|
||||
}
|
||||
if hostname != "" && !strings.EqualFold(payload.Agent.Hostname, hostname) && !unifiedresources.HostnamesEquivalent(payload.Agent.Hostname, hostname) {
|
||||
return Registration{}, fmt.Errorf("%w: server returned a different collector hostname", ErrRegistrationPending)
|
||||
}
|
||||
lastSeen := payload.Agent.LastSeen.UTC()
|
||||
if !previousLastSeen.IsZero() && !lastSeen.After(previousLastSeen.UTC()) {
|
||||
return Registration{}, fmt.Errorf("%w: registration freshness did not advance", ErrRegistrationPending)
|
||||
}
|
||||
return Registration{AgentID: payload.Agent.ID, Hostname: payload.Agent.Hostname, LastSeen: lastSeen}, nil
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, method, path string, body io.Reader, contentType string) (*http.Response, error) {
|
||||
if c == nil || c.baseURL == nil || c.http == nil || c.bearer == "" {
|
||||
return nil, errors.New("collector lifecycle client is not initialized")
|
||||
}
|
||||
target := strings.TrimRight(c.baseURL.String(), "/") + path
|
||||
request, err := http.NewRequestWithContext(ctx, method, target, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+c.bearer)
|
||||
if contentType != "" {
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
return c.http.Do(request)
|
||||
}
|
||||
|
||||
func readPrivateBearer(path string, tokenOwnerUID *uint64) (string, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return "", errors.New("collector lifecycle token file is required")
|
||||
}
|
||||
file, err := openCredentialFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open collector lifecycle token file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("inspect collector lifecycle token file descriptor: %w", err)
|
||||
}
|
||||
// The typed collector credential is root-owned 0640 with read access only
|
||||
// for its dedicated service group. Group write/execute and all other access
|
||||
// are forbidden; 0600 remains valid for legacy/root-only installs.
|
||||
if !info.Mode().IsRegular() || info.Mode().Perm()&0037 != 0 {
|
||||
return "", errors.New("collector lifecycle token file must be a private regular file")
|
||||
}
|
||||
if err := validateCredentialFileOwner(path, info, tokenOwnerUID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
encoded, err := io.ReadAll(io.LimitReader(file, maximumBearerBytes+1))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read collector lifecycle token file: %w", err)
|
||||
}
|
||||
if len(encoded) > maximumBearerBytes {
|
||||
return "", errors.New("collector lifecycle token file exceeds the size limit")
|
||||
}
|
||||
bearer := strings.TrimSpace(string(encoded))
|
||||
if bearer == "" || strings.ContainsAny(bearer, "\r\n") {
|
||||
return "", errors.New("collector lifecycle token file is empty or invalid")
|
||||
}
|
||||
return bearer, nil
|
||||
}
|
||||
|
||||
// ReadPrivateValueFile reads one bounded private local value through the same
|
||||
// single-open, no-follow, nonblocking descriptor boundary used for collector
|
||||
// credentials. Root is always an allowed owner; tokenOwnerUID may name the
|
||||
// one dedicated collector account for migration-era identity state.
|
||||
func ReadPrivateValueFile(path string, tokenOwnerUID *uint64) (string, error) {
|
||||
return readPrivateBearer(path, tokenOwnerUID)
|
||||
}
|
||||
|
||||
// ReadAgentIDFile resolves the identity that binds a separate action runner
|
||||
// while retaining the same single-open, no-follow owner and size checks used
|
||||
// for the collector credential.
|
||||
func ReadAgentIDFile(path string, tokenOwnerUID *uint64) (string, error) {
|
||||
identity, err := ReadPrivateValueFile(path, tokenOwnerUID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read collector agent identity: %w", err)
|
||||
}
|
||||
if !validBoundedIdentity(identity, 128) {
|
||||
return "", errors.New("collector agent identity file is invalid")
|
||||
}
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func validBoundedIdentity(value string, maximum int) bool {
|
||||
if value == "" || len(value) > maximum {
|
||||
return false
|
||||
}
|
||||
for index, character := range value {
|
||||
if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' {
|
||||
continue
|
||||
}
|
||||
if index > 0 && (character == '.' || character == '_' || character == ':' || character == '-') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
package collectorlifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testBearer = "collector-private-bearer"
|
||||
|
||||
func TestClientUsesSystemCA(t *testing.T) {
|
||||
server := newTLSServer(t, successfulReductionHandler(t))
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(server.Certificate())
|
||||
previousSystemCertPool := loadSystemCertPool
|
||||
loadSystemCertPool = func() (*x509.CertPool, error) { return pool, nil }
|
||||
t.Cleanup(func() { loadSystemCertPool = previousSystemCertPool })
|
||||
|
||||
client, err := New(Config{PulseURL: server.URL, TokenFile: writeToken(t), TokenOwnerUID: testTokenOwnerUID()})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
if err := client.ReduceAuthority(context.Background(), "agent-1", "host.local"); err != nil {
|
||||
t.Fatalf("ReduceAuthority with system CA: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientUsesCustomCA(t *testing.T) {
|
||||
server := newTLSServer(t, successfulReductionHandler(t))
|
||||
client, err := New(Config{
|
||||
PulseURL: server.URL, TokenFile: writeToken(t), TokenOwnerUID: testTokenOwnerUID(), CACertPath: writeServerCertificate(t, server),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
if err := client.ReduceAuthority(context.Background(), "agent-1", "host.local"); err != nil {
|
||||
t.Fatalf("ReduceAuthority with custom CA: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientUsesExactDERLeafPin(t *testing.T) {
|
||||
server := newTLSServer(t, successfulReductionHandler(t))
|
||||
fingerprint := sha256.Sum256(server.Certificate().Raw)
|
||||
client, err := New(Config{
|
||||
PulseURL: server.URL, TokenFile: writeToken(t), TokenOwnerUID: testTokenOwnerUID(), ServerFingerprint: hex.EncodeToString(fingerprint[:]),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
if err := client.ReduceAuthority(context.Background(), "agent-1", "host.local"); err != nil {
|
||||
t.Fatalf("ReduceAuthority with exact pin: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprintMismatchNeverAuthorizesHandler(t *testing.T) {
|
||||
var authorized atomic.Bool
|
||||
server := newTLSServer(t, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
if request.Header.Get("Authorization") != "" {
|
||||
authorized.Store(true)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
client, err := New(Config{
|
||||
PulseURL: server.URL, TokenFile: writeToken(t), TokenOwnerUID: testTokenOwnerUID(), ServerFingerprint: strings.Repeat("00", sha256.Size),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
if err := client.ReduceAuthority(context.Background(), "agent-1", "host.local"); err == nil || !strings.Contains(err.Error(), "fingerprint mismatch") {
|
||||
t.Fatalf("ReduceAuthority error = %v, want fingerprint mismatch", err)
|
||||
}
|
||||
if authorized.Load() {
|
||||
t.Fatal("mismatched TLS peer received the collector Authorization header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonLoopbackHTTPIsRejectedBeforeTokenRead(t *testing.T) {
|
||||
for _, rawURL := range []string{"http://192.0.2.10:7655", "http://agent.localhost:7655"} {
|
||||
_, err := New(Config{PulseURL: rawURL, TokenFile: filepath.Join(t.TempDir(), "missing")})
|
||||
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "http") {
|
||||
t.Fatalf("New(%q) error = %v, want non-loopback HTTP rejection", rawURL, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExactLifecycleLoopbackHost(t *testing.T) {
|
||||
for _, host := range []string{"localhost", "LOCALHOST", "127.0.0.1", "127.0.0.2", "::1", "::ffff:127.0.0.1"} {
|
||||
if !exactLifecycleLoopbackHost(host) {
|
||||
t.Errorf("exactLifecycleLoopbackHost(%q) = false", host)
|
||||
}
|
||||
}
|
||||
for _, host := range []string{"agent.localhost", "0.0.0.0", "192.0.2.10"} {
|
||||
if exactLifecycleLoopbackHost(host) {
|
||||
t.Errorf("exactLifecycleLoopbackHost(%q) = true", host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenFileRejectsSymlinkAndOversizeContent(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
target := filepath.Join(directory, "target.token")
|
||||
if err := os.WriteFile(target, []byte(testBearer), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(directory, "link.token")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := New(Config{PulseURL: "http://127.0.0.1:7655", TokenFile: link, TokenOwnerUID: testTokenOwnerUID()}); err == nil {
|
||||
t.Fatal("New accepted symlinked token file")
|
||||
}
|
||||
oversize := filepath.Join(directory, "oversize.token")
|
||||
if err := os.WriteFile(oversize, []byte(strings.Repeat("x", maximumBearerBytes+1)), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := New(Config{PulseURL: "http://127.0.0.1:7655", TokenFile: oversize, TokenOwnerUID: testTokenOwnerUID()}); err == nil || !strings.Contains(err.Error(), "size limit") {
|
||||
t.Fatalf("New oversize token error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopbackHTTPIsAccepted(t *testing.T) {
|
||||
server := httptest.NewServer(successfulReductionHandler(t))
|
||||
defer server.Close()
|
||||
client, err := New(Config{PulseURL: server.URL, TokenFile: writeToken(t), TokenOwnerUID: testTokenOwnerUID()})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
if err := client.ReduceAuthority(context.Background(), "agent-1", "host.local"); err != nil {
|
||||
t.Fatalf("ReduceAuthority over loopback HTTP: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredCollectorOwned0640TokenIsAccepted(t *testing.T) {
|
||||
server := httptest.NewServer(successfulReductionHandler(t))
|
||||
defer server.Close()
|
||||
tokenFile := writeToken(t)
|
||||
if err := os.Chmod(tokenFile, 0640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client, err := New(Config{PulseURL: server.URL, TokenFile: tokenFile, TokenOwnerUID: testTokenOwnerUID()})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
if err := client.ReduceAuthority(context.Background(), "agent-1", "host.local"); err != nil {
|
||||
t.Fatalf("ReduceAuthority with collector-owned 0640 token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLifecycleTransportNeverUsesEnvironmentProxy(t *testing.T) {
|
||||
var proxyRequests atomic.Int32
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
proxyRequests.Add(1)
|
||||
http.Error(w, "proxy must not be used", http.StatusBadGateway)
|
||||
}))
|
||||
defer proxy.Close()
|
||||
t.Setenv("HTTP_PROXY", proxy.URL)
|
||||
t.Setenv("HTTPS_PROXY", proxy.URL)
|
||||
server := httptest.NewServer(successfulReductionHandler(t))
|
||||
defer server.Close()
|
||||
client, err := New(Config{PulseURL: server.URL, TokenFile: writeToken(t), TokenOwnerUID: testTokenOwnerUID()})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
transport, ok := client.http.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("collector lifecycle transport = %T, want *http.Transport", client.http.Transport)
|
||||
}
|
||||
if transport.Proxy != nil {
|
||||
t.Fatal("collector lifecycle transport configured an environment proxy")
|
||||
}
|
||||
if err := client.ReduceAuthority(context.Background(), "agent-1", "host.local"); err != nil {
|
||||
t.Fatalf("ReduceAuthority: %v", err)
|
||||
}
|
||||
if proxyRequests.Load() != 0 {
|
||||
t.Fatalf("environment proxy received %d lifecycle requests", proxyRequests.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectIsRejectedWithoutAuthorizingDestination(t *testing.T) {
|
||||
var destinationAuthorized atomic.Bool
|
||||
destination := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
if request.Header.Get("Authorization") != "" {
|
||||
destinationAuthorized.Store(true)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer destination.Close()
|
||||
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
http.Redirect(w, request, destination.URL+"/forged-success", http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer source.Close()
|
||||
|
||||
client, err := New(Config{PulseURL: source.URL, TokenFile: writeToken(t), TokenOwnerUID: testTokenOwnerUID()})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
err = client.ReduceAuthority(context.Background(), "agent-1", "host.local")
|
||||
if err == nil || !strings.Contains(err.Error(), "returned redirect") {
|
||||
t.Fatalf("ReduceAuthority error = %v, want redirect rejection", err)
|
||||
}
|
||||
if destinationAuthorized.Load() {
|
||||
t.Fatal("redirect destination received the collector Authorization header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRegistrationRequiresFreshAuthenticatedEvidence(t *testing.T) {
|
||||
prior := time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
if got := request.Header.Get("Authorization"); got != "Bearer "+testBearer {
|
||||
t.Errorf("Authorization = %q", got)
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if request.URL.Query().Get("id") != "agent-1" {
|
||||
t.Errorf("lookup id = %q", request.URL.Query().Get("id"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true,"agent":{"id":"agent-1","hostname":"host.local","lastSeen":"2026-08-30T12:00:01Z"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := New(Config{PulseURL: server.URL, TokenFile: writeToken(t), TokenOwnerUID: testTokenOwnerUID()})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
registration, err := client.VerifyRegistration(context.Background(), "agent-1", "", prior)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyRegistration: %v", err)
|
||||
}
|
||||
if !registration.LastSeen.Equal(prior.Add(time.Second)) {
|
||||
t.Fatalf("LastSeen = %s", registration.LastSeen)
|
||||
}
|
||||
if _, err := client.VerifyRegistration(context.Background(), "agent-1", "", prior.Add(time.Second)); !errors.Is(err, ErrRegistrationPending) {
|
||||
t.Fatalf("stale VerifyRegistration error = %v, want ErrRegistrationPending", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRegistrationChecksBothBoundIdentities(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
responseHostname string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "short and FQDN are equivalent", responseHostname: "host-1.example.test"},
|
||||
{name: "different full hostname is rejected", responseHostname: "host-1.other.test", wantErr: true},
|
||||
{name: "different short hostname is rejected", responseHostname: "host-2", wantErr: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true,"agent":{"id":"agent-1","hostname":"` + test.responseHostname + `","lastSeen":"2026-08-30T12:00:01Z"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := New(Config{PulseURL: server.URL, TokenFile: writeToken(t), TokenOwnerUID: testTokenOwnerUID()})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
_, err = client.VerifyRegistration(context.Background(), "agent-1", "host-1.example.test", time.Time{})
|
||||
if test.wantErr && !errors.Is(err, ErrRegistrationPending) {
|
||||
t.Fatalf("VerifyRegistration error = %v, want ErrRegistrationPending", err)
|
||||
}
|
||||
if !test.wantErr && err != nil {
|
||||
t.Fatalf("VerifyRegistration: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRegistrationClassifiesCredentialRejection(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
code int
|
||||
body string
|
||||
want error
|
||||
}{
|
||||
{name: "unauthorized", code: http.StatusUnauthorized, want: ErrCredentialRejected},
|
||||
{name: "scope forbidden", code: http.StatusForbidden, body: `{"code":"forbidden"}`, want: ErrCredentialRejected},
|
||||
{name: "binding pending", code: http.StatusForbidden, body: `{"code":"agent_lookup_forbidden"}`, want: ErrRegistrationPending},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(test.code)
|
||||
_, _ = w.Write([]byte(test.body))
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := New(Config{PulseURL: server.URL, TokenFile: writeToken(t), TokenOwnerUID: testTokenOwnerUID()})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
_, err = client.VerifyRegistration(context.Background(), "agent-1", "", time.Time{})
|
||||
if !errors.Is(err, test.want) {
|
||||
t.Fatalf("VerifyRegistration error = %v, want %v", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func successfulReductionHandler(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodPost || request.URL.Path != "/api/agents/collector/reduce-authority" {
|
||||
t.Errorf("request = %s %s", request.Method, request.URL.Path)
|
||||
http.NotFound(w, request)
|
||||
return
|
||||
}
|
||||
if got := request.Header.Get("Authorization"); got != "Bearer "+testBearer {
|
||||
t.Errorf("Authorization = %q", got)
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
|
||||
func newTLSServer(t *testing.T, handler http.Handler) *httptest.Server {
|
||||
t.Helper()
|
||||
server := httptest.NewTLSServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
return server
|
||||
}
|
||||
|
||||
func writeServerCertificate(t *testing.T, server *httptest.Server) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "server-ca.pem")
|
||||
encoded := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw})
|
||||
if err := os.WriteFile(path, encoded, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func writeToken(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "collector.token")
|
||||
if err := os.WriteFile(path, []byte(testBearer), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//go:build !windows
|
||||
|
||||
package collectorlifecycle
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func openCredentialFile(path string) (*os.File, error) {
|
||||
// O_NONBLOCK prevents an attacker-controlled FIFO from hanging the root
|
||||
// installer before descriptor metadata can reject the non-regular object.
|
||||
fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.NewFile(uintptr(fd), path), nil
|
||||
}
|
||||
|
||||
func validateCredentialFileOwner(path string, info os.FileInfo, tokenOwnerUID *uint64) error {
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok || stat == nil {
|
||||
return errors.New("collector lifecycle token file owner is unavailable")
|
||||
}
|
||||
if !credentialFileOwnerAllowed(uint64(stat.Uid), tokenOwnerUID) {
|
||||
return fmt.Errorf("collector lifecycle token file %s is not owned by root or the configured collector identity", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func credentialFileOwnerAllowed(ownerUID uint64, tokenOwnerUID *uint64) bool {
|
||||
// Lifecycle commands run as root in production, so the effective-owner case
|
||||
// collapses to UID 0 there. Keeping it explicit also lets non-root package
|
||||
// tests and diagnostic invocations read only their own private files.
|
||||
return ownerUID == 0 || ownerUID == uint64(os.Geteuid()) || tokenOwnerUID != nil && ownerUID == *tokenOwnerUID
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//go:build !windows
|
||||
|
||||
package collectorlifecycle
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func TestCredentialFileOwnerAllowed(t *testing.T) {
|
||||
collectorUID := uint64(998)
|
||||
arbitraryUID := uint64(os.Geteuid()) + 1
|
||||
if arbitraryUID == collectorUID {
|
||||
arbitraryUID++
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
ownerUID uint64
|
||||
allowed *uint64
|
||||
want bool
|
||||
}{
|
||||
{name: "root owned", ownerUID: 0, want: true},
|
||||
{name: "root owned with collector configured", ownerUID: 0, allowed: &collectorUID, want: true},
|
||||
{name: "configured collector owned", ownerUID: 998, allowed: &collectorUID, want: true},
|
||||
{name: "effective owner", ownerUID: uint64(os.Geteuid()), want: true},
|
||||
{name: "arbitrary owner without collector", ownerUID: arbitraryUID, want: false},
|
||||
{name: "arbitrary owner with collector", ownerUID: arbitraryUID, allowed: &collectorUID, want: false},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := credentialFileOwnerAllowed(test.ownerUID, test.allowed); got != test.want {
|
||||
t.Fatalf("credentialFileOwnerAllowed(%d) = %v, want %v", test.ownerUID, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPrivateBearerRejectsFIFOWithoutBlocking(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "collector.token")
|
||||
if err := unix.Mkfifo(path, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ownerUID := uint64(os.Geteuid())
|
||||
started := time.Now()
|
||||
if _, err := readPrivateBearer(path, &ownerUID); err == nil || !strings.Contains(err.Error(), "private regular file") {
|
||||
t.Fatalf("readPrivateBearer FIFO error = %v", err)
|
||||
}
|
||||
if elapsed := time.Since(started); elapsed > time.Second {
|
||||
t.Fatalf("FIFO rejection blocked for %s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCredentialFileDescriptorCannotBeSymlinkSwapped(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
path := filepath.Join(directory, "collector.token")
|
||||
if err := os.WriteFile(path, []byte("original-bearer"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, err := openCredentialFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
if err := os.Rename(path, path+".original"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
attacker := filepath.Join(directory, "attacker.token")
|
||||
if err := os.WriteFile(attacker, []byte("attacker-bearer"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(attacker, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "original-bearer" {
|
||||
t.Fatalf("open descriptor read %q after path swap", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//go:build windows
|
||||
|
||||
package collectorlifecycle
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
internalsecurity "github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
|
||||
)
|
||||
|
||||
func openCredentialFile(path string) (*os.File, 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("collector lifecycle token file must not be a symlink or reparse point")
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
after, err := file.Stat()
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return nil, err
|
||||
}
|
||||
if !os.SameFile(before, after) {
|
||||
file.Close()
|
||||
return nil, errors.New("collector lifecycle token file changed while it was opened")
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func validateCredentialFileOwner(path string, info os.FileInfo, _ *uint64) error {
|
||||
if err := internalsecurity.ValidatePrivatePath(path, info); err != nil {
|
||||
return fmt.Errorf("collector lifecycle token file owner or DACL is not trusted: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !windows
|
||||
|
||||
package collectorlifecycle
|
||||
|
||||
import "os"
|
||||
|
||||
func testTokenOwnerUID() *uint64 {
|
||||
uid := uint64(os.Geteuid())
|
||||
return &uid
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build windows
|
||||
|
||||
package collectorlifecycle
|
||||
|
||||
func testTokenOwnerUID() *uint64 { return nil }
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -35,6 +37,135 @@ type ActionRunnerClientConfig struct {
|
||||
DockerContainerLifecycleOperator DockerContainerLifecycleOperator
|
||||
}
|
||||
|
||||
// ActionRunnerCredentialLifecycleConfig carries the exact non-browser inputs
|
||||
// used by installer recovery and uninstall. The bearer is read from a private
|
||||
// file by the runner command and is never passed in argv or to curl.
|
||||
type ActionRunnerCredentialLifecycleConfig struct {
|
||||
PulseURL string
|
||||
APIToken string
|
||||
InsecureSkipVerify bool
|
||||
CACertPath string
|
||||
ServerFingerprint string
|
||||
}
|
||||
|
||||
func normalizeActionRunnerHTTPBaseURL(raw string) (*url.URL, error) {
|
||||
parsed, err := securityutil.NormalizePulseHTTPBaseURL(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parsed.Scheme != "http" {
|
||||
return parsed, nil
|
||||
}
|
||||
hostname := strings.TrimSpace(strings.ToLower(parsed.Hostname()))
|
||||
if hostname == "localhost" {
|
||||
return parsed, nil
|
||||
}
|
||||
ip := net.ParseIP(hostname)
|
||||
if ip == nil || !ip.IsLoopback() {
|
||||
return nil, fmt.Errorf("plaintext action-runner URL requires a literal loopback host")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// ValidateActionRunnerPulseURL enforces the runner's stricter plaintext
|
||||
// boundary. Unlike generic URL handling, *.localhost is not accepted because
|
||||
// a resolver can direct that name away from the local host.
|
||||
func ValidateActionRunnerPulseURL(raw string) error {
|
||||
_, err := normalizeActionRunnerHTTPBaseURL(raw)
|
||||
return err
|
||||
}
|
||||
|
||||
func effectiveActionRunnerInsecureMode(pulseURL string, insecure bool, caCertPath, serverFingerprint string) (bool, error) {
|
||||
parsed, err := normalizeActionRunnerHTTPBaseURL(pulseURL)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if parsed.Scheme != "https" || !insecure {
|
||||
return insecure, nil
|
||||
}
|
||||
if strings.TrimSpace(serverFingerprint) != "" {
|
||||
return true, nil // agenttls replaces insecure verification with the exact DER pin.
|
||||
}
|
||||
if strings.TrimSpace(caCertPath) != "" {
|
||||
return false, nil // custom CA verification must remain enabled.
|
||||
}
|
||||
return false, fmt.Errorf("generic insecure HTTPS is forbidden for the action runner")
|
||||
}
|
||||
|
||||
// newActionRunnerHTTPClient deliberately bypasses ambient proxy variables.
|
||||
// Runner bearers are host-bound control-plane credentials and must never be
|
||||
// disclosed to an operator-unconfigured HTTP_PROXY intermediary.
|
||||
func newActionRunnerHTTPClient(caCertPath string, insecureSkipVerify bool, serverFingerprint string) (*http.Client, error) {
|
||||
client, err := newAgentHTTPClient(caCertPath, insecureSkipVerify, serverFingerprint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transport, ok := client.Transport.(*http.Transport)
|
||||
if !ok || transport == nil {
|
||||
return nil, fmt.Errorf("action-runner HTTP transport is unavailable")
|
||||
}
|
||||
direct := transport.Clone()
|
||||
direct.Proxy = nil
|
||||
client.Transport = direct
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func performActionRunnerCredentialLifecycle(ctx context.Context, config ActionRunnerCredentialLifecycleConfig, method, path, agentID, hostname string, includeBindingBody bool) error {
|
||||
baseURL, err := normalizeActionRunnerHTTPBaseURL(config.PulseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate action-runner lifecycle URL: %w", err)
|
||||
}
|
||||
effectiveInsecure, err := effectiveActionRunnerInsecureMode(config.PulseURL, config.InsecureSkipVerify, config.CACertPath, config.ServerFingerprint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate action-runner lifecycle TLS mode: %w", err)
|
||||
}
|
||||
token := strings.TrimSpace(config.APIToken)
|
||||
if token == "" || strings.ContainsAny(token, "\r\n") {
|
||||
return fmt.Errorf("action-runner lifecycle bearer is invalid")
|
||||
}
|
||||
var body io.Reader
|
||||
if includeBindingBody {
|
||||
encoded, err := json.Marshal(map[string]string{"agentId": strings.TrimSpace(agentID), "hostname": strings.TrimSpace(hostname)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = bytes.NewReader(encoded)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(baseURL.String(), "/")+path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
if includeBindingBody {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
client, err := newActionRunnerHTTPClient(config.CACertPath, effectiveInsecure, config.ServerFingerprint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build action-runner lifecycle client: %w", err)
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("action-runner lifecycle request: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
|
||||
if response.StatusCode != http.StatusNoContent {
|
||||
return fmt.Errorf("action-runner lifecycle request: server returned %s", response.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelPendingActionRunnerCredential durably cancels a prepared replacement.
|
||||
// Only a nil result (HTTP 204) is safe authority to restore a predecessor.
|
||||
func CancelPendingActionRunnerCredential(ctx context.Context, config ActionRunnerCredentialLifecycleConfig) error {
|
||||
return performActionRunnerCredentialLifecycle(ctx, config, http.MethodDelete, "/api/agents/action-runner/credential/activation", "", "", false)
|
||||
}
|
||||
|
||||
// RevokeActionRunnerCredential removes the exact active runner credential.
|
||||
func RevokeActionRunnerCredential(ctx context.Context, config ActionRunnerCredentialLifecycleConfig, agentID, hostname string) error {
|
||||
return performActionRunnerCredentialLifecycle(ctx, config, http.MethodDelete, "/api/agents/action-runner/credential", agentID, hostname, true)
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -96,6 +227,13 @@ type actionRunnerHealth struct {
|
||||
RegisteredAt time.Time `json:"registered_at"`
|
||||
}
|
||||
|
||||
func (c *CommandClient) persistActionRunnerHealth(activated bool) error {
|
||||
if c != nil && c.actionHealthWriter != nil {
|
||||
return c.actionHealthWriter(activated)
|
||||
}
|
||||
return c.writeActionRunnerHealth(activated)
|
||||
}
|
||||
|
||||
func (c *CommandClient) writeActionRunnerHealth(activated bool) error {
|
||||
if c == nil || !c.actionRunnerOnly || strings.TrimSpace(c.healthPath) == "" {
|
||||
return fmt.Errorf("action-runner health path is required")
|
||||
@@ -167,13 +305,21 @@ func (c *CommandClient) activateActionRunnerCredential(ctx context.Context) erro
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPatch, strings.TrimRight(c.pulseURL, "/")+"/api/agents/action-runner/credential", bytes.NewReader(body))
|
||||
baseURL, err := securityutil.NormalizePulseHTTPBaseURL(c.pulseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate action-runner activation URL: %w", err)
|
||||
}
|
||||
effectiveInsecure, err := effectiveActionRunnerInsecureMode(c.pulseURL, c.insecureSkipVerify, c.caCertPath, c.serverFingerprint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate action-runner activation TLS mode: %w", err)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPatch, strings.TrimRight(baseURL.String(), "/")+"/api/agents/action-runner/credential", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+c.apiToken)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
client, err := newAgentHTTPClient(c.caCertPath, c.insecureSkipVerify, c.serverFingerprint)
|
||||
client, err := newActionRunnerHTTPClient(c.caCertPath, effectiveInsecure, c.serverFingerprint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build action-runner activation client: %w", err)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,16 @@ package hostagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -40,6 +49,254 @@ func TestNewActionRunnerClientIsTypedOnlyAndEmitsExplicitRole(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRunnerTransportRequiresTLSExceptLoopback(t *testing.T) {
|
||||
logger := zerolog.Nop()
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
url string
|
||||
wantURL string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "public plaintext rejected", url: "http://pulse.example.com:7655", wantErr: true},
|
||||
{name: "private LAN plaintext rejected", url: "http://192.168.1.20:7655", wantErr: true},
|
||||
{name: "localhost subdomain plaintext rejected", url: "http://agent.localhost:7655", wantErr: true},
|
||||
{name: "loopback plaintext accepted", url: "http://127.0.0.1:7655", wantURL: "ws://127.0.0.1:7655/api/agent/ws"},
|
||||
{name: "HTTPS accepted", url: "https://pulse.example.com:7655", wantURL: "wss://pulse.example.com:7655/api/agent/ws"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
client := NewActionRunnerClient(ActionRunnerClientConfig{
|
||||
PulseURL: test.url, APIToken: "runner-token", StateDir: t.TempDir(),
|
||||
HealthPath: filepath.Join(t.TempDir(), "health.json"), ActivationNonce: strings.Repeat("a", 32), Logger: &logger,
|
||||
}, "agent-1", "host-1", "v1")
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
got, err := client.buildWebSocketURL()
|
||||
if test.wantErr {
|
||||
if err == nil || !strings.Contains(err.Error(), "loopback") {
|
||||
t.Fatalf("buildWebSocketURL() = %q, %v; want plaintext rejection", got, err)
|
||||
}
|
||||
if err := client.activateActionRunnerCredential(context.Background()); err == nil || !strings.Contains(err.Error(), "loopback") {
|
||||
t.Fatalf("activation URL error = %v, want plaintext rejection", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || got != test.wantURL {
|
||||
t.Fatalf("buildWebSocketURL() = %q, %v; want %q", got, err, test.wantURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := CancelPendingActionRunnerCredential(context.Background(), ActionRunnerCredentialLifecycleConfig{
|
||||
PulseURL: "http://agent.localhost:7655", APIToken: "runner-token",
|
||||
}); err == nil || !strings.Contains(err.Error(), "literal loopback") {
|
||||
t.Fatalf("localhost subdomain lifecycle error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRunnerCredentialHTTPSHonorsCAAndRejectsFingerprintMismatch(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodPatch {
|
||||
http.Error(w, "wrong method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
caFile := filepath.Join(t.TempDir(), "ca.pem")
|
||||
certificate := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw})
|
||||
if err := os.WriteFile(caFile, certificate, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := zerolog.Nop()
|
||||
client := NewActionRunnerClient(ActionRunnerClientConfig{
|
||||
PulseURL: server.URL, APIToken: "runner-token", StateDir: t.TempDir(), CACertPath: caFile,
|
||||
HealthPath: filepath.Join(t.TempDir(), "health.json"), ActivationNonce: strings.Repeat("b", 32), Logger: &logger,
|
||||
}, "agent-1", "host-1", "v1")
|
||||
defer client.Close()
|
||||
if err := client.activateActionRunnerCredential(context.Background()); err != nil {
|
||||
t.Fatalf("CA-authenticated HTTPS activation: %v", err)
|
||||
}
|
||||
|
||||
mismatch := NewActionRunnerClient(ActionRunnerClientConfig{
|
||||
PulseURL: server.URL, APIToken: "runner-token", StateDir: t.TempDir(), InsecureSkipVerify: true,
|
||||
ServerFingerprint: strings.Repeat("00", 32), HealthPath: filepath.Join(t.TempDir(), "health.json"),
|
||||
ActivationNonce: strings.Repeat("c", 32), Logger: &logger,
|
||||
}, "agent-1", "host-1", "v1")
|
||||
defer mismatch.Close()
|
||||
if err := mismatch.activateActionRunnerCredential(context.Background()); err == nil || !strings.Contains(strings.ToLower(err.Error()), "fingerprint") {
|
||||
t.Fatalf("fingerprint mismatch error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRunnerLifecycleTransportEnforcesTrustLoopbackAndRedirectBoundary(t *testing.T) {
|
||||
const token = "runner-lifecycle-token"
|
||||
authorizedRequests := 0
|
||||
tlsServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
if request.Header.Get("Authorization") != "Bearer "+token {
|
||||
t.Errorf("authorization = %q", request.Header.Get("Authorization"))
|
||||
}
|
||||
authorizedRequests++
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer tlsServer.Close()
|
||||
caFile := filepath.Join(t.TempDir(), "ca.pem")
|
||||
if err := os.WriteFile(caFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: tlsServer.Certificate().Raw}), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := ActionRunnerCredentialLifecycleConfig{PulseURL: tlsServer.URL, APIToken: token, CACertPath: caFile}
|
||||
if err := CancelPendingActionRunnerCredential(context.Background(), base); err != nil {
|
||||
t.Fatalf("custom CA cancel: %v", err)
|
||||
}
|
||||
fingerprintBytes := sha256.Sum256(tlsServer.Certificate().Raw)
|
||||
pinned := ActionRunnerCredentialLifecycleConfig{PulseURL: tlsServer.URL, APIToken: token, ServerFingerprint: hex.EncodeToString(fingerprintBytes[:])}
|
||||
if err := RevokeActionRunnerCredential(context.Background(), pinned, "agent-1", "host.local"); err != nil {
|
||||
t.Fatalf("exact fingerprint revoke: %v", err)
|
||||
}
|
||||
beforeMismatch := authorizedRequests
|
||||
mismatch := ActionRunnerCredentialLifecycleConfig{PulseURL: tlsServer.URL, APIToken: token, ServerFingerprint: strings.Repeat("00", 32)}
|
||||
if err := CancelPendingActionRunnerCredential(context.Background(), mismatch); err == nil || !strings.Contains(strings.ToLower(err.Error()), "fingerprint") {
|
||||
t.Fatalf("mismatched fingerprint error = %v", err)
|
||||
}
|
||||
if authorizedRequests != beforeMismatch {
|
||||
t.Fatal("mismatched pin transmitted Authorization to the handler")
|
||||
}
|
||||
if err := CancelPendingActionRunnerCredential(context.Background(), ActionRunnerCredentialLifecycleConfig{PulseURL: "http://192.0.2.10:7655", APIToken: token}); err == nil || !strings.Contains(err.Error(), "loopback") {
|
||||
t.Fatalf("non-loopback HTTP error = %v", err)
|
||||
}
|
||||
|
||||
loopback := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
if request.Header.Get("Authorization") != "Bearer "+token {
|
||||
t.Errorf("loopback authorization = %q", request.Header.Get("Authorization"))
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer loopback.Close()
|
||||
if err := CancelPendingActionRunnerCredential(context.Background(), ActionRunnerCredentialLifecycleConfig{PulseURL: loopback.URL, APIToken: token}); err != nil {
|
||||
t.Fatalf("loopback HTTP cancel: %v", err)
|
||||
}
|
||||
|
||||
redirectTargetAuthorization := ""
|
||||
redirectTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
redirectTargetAuthorization = request.Header.Get("Authorization")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer redirectTarget.Close()
|
||||
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
http.Redirect(w, request, redirectTarget.URL, http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer redirector.Close()
|
||||
err := CancelPendingActionRunnerCredential(context.Background(), ActionRunnerCredentialLifecycleConfig{PulseURL: redirector.URL, APIToken: token})
|
||||
if err == nil || !strings.Contains(err.Error(), "redirect") {
|
||||
t.Fatalf("redirect error = %v", err)
|
||||
}
|
||||
if redirectTargetAuthorization != "" {
|
||||
t.Fatalf("redirect target received bearer %q", redirectTargetAuthorization)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRunnerBearerHTTPBypassesAmbientProxy(t *testing.T) {
|
||||
const token = "runner-proxy-sensitive-token"
|
||||
proxyRequests := 0
|
||||
proxyAuthorization := ""
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
proxyRequests++
|
||||
proxyAuthorization = request.Header.Get("Authorization")
|
||||
http.Error(w, "proxy must not be used", http.StatusBadGateway)
|
||||
}))
|
||||
defer proxy.Close()
|
||||
for _, key := range []string{"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"} {
|
||||
t.Setenv(key, proxy.URL)
|
||||
}
|
||||
t.Setenv("NO_PROXY", "")
|
||||
t.Setenv("no_proxy", "")
|
||||
|
||||
targetRequests := 0
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
targetRequests++
|
||||
if request.Header.Get("Authorization") != "Bearer "+token {
|
||||
t.Errorf("target authorization = %q", request.Header.Get("Authorization"))
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer target.Close()
|
||||
pulseURL := strings.Replace(target.URL, "127.0.0.1", "localhost", 1)
|
||||
if pulseURL == target.URL {
|
||||
t.Fatalf("unexpected httptest URL %q", target.URL)
|
||||
}
|
||||
|
||||
directClient, err := newActionRunnerHTTPClient("", false, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transport, ok := directClient.Transport.(*http.Transport)
|
||||
if !ok || transport.Proxy != nil {
|
||||
t.Fatalf("action-runner transport proxy configured = %t, want false", transport.Proxy != nil)
|
||||
}
|
||||
config := ActionRunnerCredentialLifecycleConfig{PulseURL: pulseURL, APIToken: token}
|
||||
if err := CancelPendingActionRunnerCredential(context.Background(), config); err != nil {
|
||||
t.Fatalf("direct cancellation: %v", err)
|
||||
}
|
||||
if err := RevokeActionRunnerCredential(context.Background(), config, "agent-1", "runner.localhost"); err != nil {
|
||||
t.Fatalf("direct self-revoke: %v", err)
|
||||
}
|
||||
logger := zerolog.Nop()
|
||||
runner := NewActionRunnerClient(ActionRunnerClientConfig{
|
||||
PulseURL: pulseURL, APIToken: token, StateDir: t.TempDir(),
|
||||
HealthPath: filepath.Join(t.TempDir(), "health.json"), ActivationNonce: strings.Repeat("a", 32), Logger: &logger,
|
||||
}, "agent-1", "runner.localhost", "v1")
|
||||
defer runner.Close()
|
||||
if err := runner.activateActionRunnerCredential(context.Background()); err != nil {
|
||||
t.Fatalf("direct activation: %v", err)
|
||||
}
|
||||
if proxyRequests != 0 || proxyAuthorization != "" {
|
||||
t.Fatalf("ambient proxy saw %d requests and authorization %q", proxyRequests, proxyAuthorization)
|
||||
}
|
||||
if targetRequests != 3 {
|
||||
t.Fatalf("direct target requests = %d, want cancel, revoke, and activate", targetRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRunnerWebSocketCustomCADisablesRawInsecureBypass(t *testing.T) {
|
||||
targetRequests := 0
|
||||
target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
targetRequests++
|
||||
http.Error(w, "TLS verification should fail before HTTP", http.StatusInternalServerError)
|
||||
}))
|
||||
defer target.Close()
|
||||
caFile := filepath.Join(t.TempDir(), "unrelated-ca.pem")
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "unrelated-action-runner-test-ca"},
|
||||
NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour), IsCA: true, BasicConstraintsValid: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
certificate, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(caFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate}), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := zerolog.Nop()
|
||||
runner := NewActionRunnerClient(ActionRunnerClientConfig{
|
||||
PulseURL: target.URL, APIToken: "runner-token", StateDir: t.TempDir(),
|
||||
HealthPath: filepath.Join(t.TempDir(), "health.json"), ActivationNonce: strings.Repeat("a", 32),
|
||||
InsecureSkipVerify: true, CACertPath: caFile, Logger: &logger,
|
||||
}, "agent-1", "host.local", "v1")
|
||||
defer runner.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
err = runner.connectAndHandle(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("websocket connection succeeded with an unrelated custom CA and raw insecure flag")
|
||||
}
|
||||
if targetRequests != 0 {
|
||||
t.Fatalf("raw insecure bypass reached target handler %d times", targetRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRunnerTransportRegistersRoleWritesHealthAndRejectsGenericExec(t *testing.T) {
|
||||
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||
registration := make(chan registerPayload, 1)
|
||||
@@ -185,6 +442,64 @@ func TestActionRunnerActivationFailureLeavesOnlyPendingHealthProof(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRunnerPostCommitHealthFailureKeepsPendingProof(t *testing.T) {
|
||||
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||
activationCommitted := make(chan struct{}, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
if request.Method == http.MethodPatch {
|
||||
activationCommitted <- struct{}{}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
conn, err := upgrader.Upgrade(w, request, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
var message wsMessage
|
||||
if conn.ReadJSON(&message) != nil {
|
||||
return
|
||||
}
|
||||
ack, _ := json.Marshal(registeredPayload{Success: true})
|
||||
_ = conn.WriteJSON(wsMessage{Type: msgTypeRegistered, Timestamp: time.Now(), Payload: ack})
|
||||
_, _, _ = conn.ReadMessage()
|
||||
}))
|
||||
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, ActivationNonce: strings.Repeat("e", 32), InsecureSkipVerify: true, Logger: &logger,
|
||||
}, "agent-1", "host-1", "v1")
|
||||
defer client.Close()
|
||||
client.actionHealthWriter = func(activated bool) error {
|
||||
if activated {
|
||||
return errors.New("injected activated health replacement failure")
|
||||
}
|
||||
return client.writeActionRunnerHealth(false)
|
||||
}
|
||||
if err := client.connectAndHandle(context.Background()); err == nil || !strings.Contains(err.Error(), "injected activated health replacement failure") {
|
||||
t.Fatalf("post-commit health error = %v", err)
|
||||
}
|
||||
select {
|
||||
case <-activationCommitted:
|
||||
default:
|
||||
t.Fatal("credential activation did not commit before the injected health failure")
|
||||
}
|
||||
data, err := os.ReadFile(healthPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var health actionRunnerHealth
|
||||
if err := json.Unmarshal(data, &health); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !health.Registered || health.Activated || health.ActivationNonce != strings.Repeat("e", 32) {
|
||||
t.Fatalf("post-commit failed health marker = %+v", health)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonContains(data []byte, value string) bool {
|
||||
var decoded any
|
||||
if json.Unmarshal(data, &decoded) != nil {
|
||||
|
||||
@@ -143,6 +143,7 @@ type CommandClient struct {
|
||||
healthPath string
|
||||
healthCapabilities []string
|
||||
actionActivationNonce string
|
||||
actionHealthWriter func(bool) error
|
||||
}
|
||||
|
||||
// NewCommandClient creates a new command execution client
|
||||
@@ -369,7 +370,14 @@ func (c *CommandClient) connectAndHandle(ctx context.Context) error {
|
||||
c.logger.Debug().Str("url", wsURL).Msg("Connecting to Pulse command server")
|
||||
|
||||
// Create dialer with TLS config
|
||||
tlsConfig, err := agenttls.NewClientTLSConfig(c.caCertPath, c.insecureSkipVerify, c.serverFingerprint)
|
||||
effectiveInsecure := c.insecureSkipVerify
|
||||
if c.actionRunnerOnly {
|
||||
effectiveInsecure, err = effectiveActionRunnerInsecureMode(c.pulseURL, c.insecureSkipVerify, c.caCertPath, c.serverFingerprint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate action-runner websocket TLS mode: %w", err)
|
||||
}
|
||||
}
|
||||
tlsConfig, err := agenttls.NewClientTLSConfig(c.caCertPath, effectiveInsecure, c.serverFingerprint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build websocket TLS config: %w", err)
|
||||
}
|
||||
@@ -420,13 +428,13 @@ func (c *CommandClient) connectAndHandle(ctx context.Context) error {
|
||||
return fmt.Errorf("registration failed: %w", err)
|
||||
}
|
||||
if c.actionRunnerOnly {
|
||||
if err := c.writeActionRunnerHealth(false); err != nil {
|
||||
if err := c.persistActionRunnerHealth(false); err != nil {
|
||||
return fmt.Errorf("write action-runner health: %w", err)
|
||||
}
|
||||
if err := c.activateActionRunnerCredential(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.writeActionRunnerHealth(true); err != nil {
|
||||
if err := c.persistActionRunnerHealth(true); err != nil {
|
||||
return fmt.Errorf("write activated action-runner health: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -460,9 +468,14 @@ func (c *CommandClient) connectAndHandle(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (c *CommandClient) buildWebSocketURL() (string, error) {
|
||||
parsed, err := securityutil.NormalizePulseWebSocketBaseURLWithOptions(c.pulseURL, securityutil.PulseURLValidationOptions{
|
||||
AllowLocalNetworkHTTP: true,
|
||||
})
|
||||
options := securityutil.PulseURLValidationOptions{AllowLocalNetworkHTTP: true}
|
||||
if c.actionRunnerOnly {
|
||||
options = securityutil.PulseURLValidationOptions{}
|
||||
if _, err := effectiveActionRunnerInsecureMode(c.pulseURL, c.insecureSkipVerify, c.caCertPath, c.serverFingerprint); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
parsed, err := securityutil.NormalizePulseWebSocketBaseURLWithOptions(c.pulseURL, options)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -480,9 +493,14 @@ func (c *CommandClient) buildWebSocketURL() (string, error) {
|
||||
}
|
||||
|
||||
func (c *CommandClient) buildWebSocketOrigin() (string, error) {
|
||||
return securityutil.HTTPOriginForWebSocketBaseURLWithOptions(c.pulseURL, securityutil.PulseURLValidationOptions{
|
||||
AllowLocalNetworkHTTP: true,
|
||||
})
|
||||
options := securityutil.PulseURLValidationOptions{AllowLocalNetworkHTTP: true}
|
||||
if c.actionRunnerOnly {
|
||||
options = securityutil.PulseURLValidationOptions{}
|
||||
if _, err := effectiveActionRunnerInsecureMode(c.pulseURL, c.insecureSkipVerify, c.caCertPath, c.serverFingerprint); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return securityutil.HTTPOriginForWebSocketBaseURLWithOptions(c.pulseURL, options)
|
||||
}
|
||||
|
||||
func (c *CommandClient) sendRegistration(conn *websocket.Conn) error {
|
||||
|
||||
+518
-148
@@ -642,6 +642,105 @@ warn_agent_token_rejected() {
|
||||
log_warn "Re-run the full agent install command from the Pulse UI to mint a fresh token. The agent will keep reporting 401/403 until the credential is replaced."
|
||||
}
|
||||
|
||||
# Resolve the newly downloaded/installed pulse-agent command surface used for
|
||||
# authenticated installer lifecycle operations. Safe-profile preflight runs
|
||||
# after download but before replacement, so it must prefer the new binary over
|
||||
# an older installed agent that does not yet expose these commands.
|
||||
collector_lifecycle_binary() {
|
||||
if [[ -n "${COLLECTOR_LIFECYCLE_BINARY_PATH:-}" && -x "$COLLECTOR_LIFECYCLE_BINARY_PATH" ]]; then
|
||||
printf '%s\n' "$COLLECTOR_LIFECYCLE_BINARY_PATH"
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "${TMP_BIN:-}" && -x "$TMP_BIN" ]]; then
|
||||
printf '%s\n' "$TMP_BIN"
|
||||
return 0
|
||||
fi
|
||||
if [[ -x "${INSTALL_DIR%/}/${BINARY_NAME}" ]]; then
|
||||
printf '%s\n' "${INSTALL_DIR%/}/${BINARY_NAME}"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
resolve_safe_profile_hostname() {
|
||||
local resolved_hostname="${HOSTNAME_OVERRIDE:-}"
|
||||
if [[ -z "$resolved_hostname" ]]; then
|
||||
resolved_hostname=$(hostname -f 2>/dev/null || true)
|
||||
fi
|
||||
if [[ -z "$resolved_hostname" ]]; then
|
||||
resolved_hostname=$(hostname 2>/dev/null || true)
|
||||
fi
|
||||
resolved_hostname=$(printf '%s' "$resolved_hostname" | tr '[:upper:]' '[:lower:]')
|
||||
resolved_hostname="${resolved_hostname%.}"
|
||||
[[ ${#resolved_hostname} -ge 1 && ${#resolved_hostname} -le 253 &&
|
||||
"$resolved_hostname" =~ ^[a-z0-9][a-z0-9._:-]*$ ]] || return 1
|
||||
HOSTNAME_OVERRIDE="$resolved_hostname"
|
||||
}
|
||||
|
||||
# Select the bearer actually used by the collector without copying it through
|
||||
# argv. Enrolled runtime state wins over the bootstrap token. PULSE_TOKEN-only
|
||||
# installs get a root-only temporary file that is removed after each command.
|
||||
prepare_collector_lifecycle_token_file() {
|
||||
local candidate=""
|
||||
local temp_token=""
|
||||
|
||||
COLLECTOR_LIFECYCLE_TOKEN_FILE=""
|
||||
COLLECTOR_LIFECYCLE_TEMP_TOKEN_FILE=""
|
||||
for candidate in \
|
||||
"${STATE_DIR%/}/runtime.token" \
|
||||
"${RUNTIME_TOKEN_FILE:-}" \
|
||||
"${STATE_DIR%/}/token"; do
|
||||
[[ -n "$candidate" ]] || continue
|
||||
if [[ -s "$candidate" && -f "$candidate" && ! -L "$candidate" ]]; then
|
||||
COLLECTOR_LIFECYCLE_TOKEN_FILE="$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
[[ -n "$PULSE_TOKEN" && "$PULSE_TOKEN" != *$'\r'* && "$PULSE_TOKEN" != *$'\n'* ]] || return 1
|
||||
temp_token=$(mktemp) || return 1
|
||||
chmod 0600 "$temp_token" || { rm -f "$temp_token"; return 1; }
|
||||
if ! printf '%s' "$PULSE_TOKEN" > "$temp_token"; then
|
||||
rm -f "$temp_token"
|
||||
return 1
|
||||
fi
|
||||
COLLECTOR_LIFECYCLE_TOKEN_FILE="$temp_token"
|
||||
COLLECTOR_LIFECYCLE_TEMP_TOKEN_FILE="$temp_token"
|
||||
return 0
|
||||
}
|
||||
|
||||
run_collector_lifecycle_command() {
|
||||
local command_name="$1"
|
||||
shift
|
||||
local lifecycle_binary=""
|
||||
local collector_uid=""
|
||||
local lifecycle_rc=1
|
||||
local -a lifecycle_args
|
||||
|
||||
lifecycle_binary=$(collector_lifecycle_binary) || return 1
|
||||
prepare_collector_lifecycle_token_file || return 1
|
||||
lifecycle_args=("$command_name" --url "$PULSE_URL" --token-file "$COLLECTOR_LIFECYCLE_TOKEN_FILE")
|
||||
collector_uid=$(id -u "$LEAST_PRIVILEGE_USER" 2>/dev/null || true)
|
||||
if [[ "$collector_uid" =~ ^[0-9]+$ ]]; then
|
||||
lifecycle_args+=(--token-owner-uid "$collector_uid")
|
||||
fi
|
||||
[[ -n "$CURL_CA_BUNDLE" ]] && lifecycle_args+=(--cacert "$CURL_CA_BUNDLE")
|
||||
[[ -n "$SERVER_FINGERPRINT" ]] && lifecycle_args+=(--server-fingerprint "$SERVER_FINGERPRINT")
|
||||
lifecycle_args+=("$@")
|
||||
|
||||
if "$lifecycle_binary" "${lifecycle_args[@]}"; then
|
||||
lifecycle_rc=0
|
||||
else
|
||||
lifecycle_rc=$?
|
||||
fi
|
||||
if [[ -n "$COLLECTOR_LIFECYCLE_TEMP_TOKEN_FILE" ]]; then
|
||||
rm -f "$COLLECTOR_LIFECYCLE_TEMP_TOKEN_FILE"
|
||||
fi
|
||||
COLLECTOR_LIFECYCLE_TOKEN_FILE=""
|
||||
COLLECTOR_LIFECYCLE_TEMP_TOKEN_FILE=""
|
||||
return "$lifecycle_rc"
|
||||
}
|
||||
|
||||
# verify_agent_server_registration returns:
|
||||
# 0 - the server confirmed this agent's registration
|
||||
# 1 - registration not confirmed yet (transient: agent not reported, network)
|
||||
@@ -651,15 +750,9 @@ verify_agent_server_registration() {
|
||||
local required_previous_last_seen="${1:-}"
|
||||
local lookup_id="${AGENT_ID}"
|
||||
local lookup_hostname="${HOSTNAME_OVERRIDE}"
|
||||
local lookup_query=""
|
||||
local lookup_out=""
|
||||
local lookup_status=""
|
||||
local lookup_body=""
|
||||
local lookup_last_seen=""
|
||||
# No -f: we need the response body AND the HTTP status even on 4xx so a
|
||||
# rejected token (401/403) can be told apart from "not reported yet". The
|
||||
# -w format appends "\n<http_code>" after the body.
|
||||
local lookup_args=(-sSL --connect-timeout 5 --max-time 10 -w $'\n%{http_code}')
|
||||
local lookup_rc=1
|
||||
local -a lookup_args=(collector-verify-registration)
|
||||
|
||||
if [[ -z "$PULSE_URL" ]]; then
|
||||
return 1
|
||||
@@ -674,45 +767,26 @@ verify_agent_server_registration() {
|
||||
lookup_hostname=$(hostname 2>/dev/null || true)
|
||||
fi
|
||||
if [[ -n "$lookup_id" ]]; then
|
||||
lookup_query="id=$(url_encode "$lookup_id")"
|
||||
elif [[ -n "$lookup_hostname" ]]; then
|
||||
lookup_query="hostname=$(url_encode "$lookup_hostname")"
|
||||
else
|
||||
lookup_args+=(--agent-id "$lookup_id")
|
||||
fi
|
||||
if [[ -n "$lookup_hostname" ]]; then
|
||||
lookup_args+=(--hostname "$lookup_hostname")
|
||||
fi
|
||||
if [[ ${#lookup_args[@]} -eq 1 ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "$INSECURE" == "true" ]]; then lookup_args+=(-k); fi
|
||||
if [[ -n "$CURL_CA_BUNDLE" ]]; then lookup_args+=(--cacert "$CURL_CA_BUNDLE"); fi
|
||||
|
||||
lookup_out=$(curl_with_pulse_token "${lookup_args[@]}" "${PULSE_URL}/api/agents/agent/lookup?${lookup_query}" 2>/dev/null || true)
|
||||
lookup_status="${lookup_out##*$'\n'}"
|
||||
lookup_body="${lookup_out%$'\n'*}"
|
||||
|
||||
# Authentication failure and missing reporting scope are definitive. A
|
||||
# lookup can instead hit the previous registration during first-use token
|
||||
# binding; agent_lookup_forbidden is transient until the new ownership is
|
||||
# visible, so keep polling rather than falsely condemning the fresh token.
|
||||
case "$lookup_status" in
|
||||
401) return 2 ;;
|
||||
403)
|
||||
if echo "$lookup_body" | grep -q '"code"[[:space:]]*:[[:space:]]*"agent_lookup_forbidden"'; then
|
||||
return 1
|
||||
fi
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
|
||||
[[ -n "$required_previous_last_seen" ]] && lookup_args+=(--previous-last-seen "$required_previous_last_seen")
|
||||
AGENT_REGISTRATION_LAST_SEEN=""
|
||||
if echo "$lookup_body" | grep -q '"agent"[[:space:]]*:' && echo "$lookup_body" | grep -q '"id"[[:space:]]*:'; then
|
||||
lookup_last_seen=$(printf '%s' "$lookup_body" | tr -d '\r\n' |
|
||||
sed -n 's/.*"lastSeen"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
||||
if lookup_last_seen=$(run_collector_lifecycle_command "${lookup_args[@]}" 2>/dev/null); then
|
||||
lookup_rc=0
|
||||
else
|
||||
lookup_rc=$?
|
||||
fi
|
||||
if [[ $lookup_rc -eq 0 && -n "$lookup_last_seen" ]]; then
|
||||
AGENT_REGISTRATION_LAST_SEEN="$lookup_last_seen"
|
||||
if [[ -n "$required_previous_last_seen" ]] &&
|
||||
[[ -z "$lookup_last_seen" || "$lookup_last_seen" == "$required_previous_last_seen" ]]; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
[[ $lookup_rc -eq 2 ]] && return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -966,20 +1040,26 @@ revoke_action_runner_credential() {
|
||||
local runner_agent_id_direct=""
|
||||
local runner_agent_id_file=""
|
||||
local runner_agent_id=""
|
||||
local runner_token_file=""
|
||||
local runner_token=""
|
||||
local token_mode=""
|
||||
local token_size=""
|
||||
local curl_config=""
|
||||
local payload=""
|
||||
local -a revoke_args
|
||||
local runner_token_file=""
|
||||
local runner_server_fingerprint=""
|
||||
local runner_ca_file=""
|
||||
local runner_insecure=""
|
||||
local lifecycle_binary=""
|
||||
local collector_uid=""
|
||||
local -a identity_args
|
||||
local -a revoke_args
|
||||
|
||||
runner_url=$(read_action_runner_env_value "PULSE_URL" || true)
|
||||
runner_hostname=$(read_action_runner_env_value "PULSE_AGENT_RUNNER_HOSTNAME" || true)
|
||||
runner_agent_id_direct=$(read_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID" || true)
|
||||
runner_agent_id_file=$(read_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID_FILE" || true)
|
||||
runner_token_file=$(read_action_runner_env_value "PULSE_AGENT_RUNNER_TOKEN_FILE" || true)
|
||||
[[ "$runner_url" =~ ^https?://[^[:space:]]+$ && -n "$runner_hostname" ]] || return 1
|
||||
runner_server_fingerprint=$(read_action_runner_env_value "PULSE_SERVER_FINGERPRINT" || true)
|
||||
runner_ca_file=$(read_action_runner_env_value "SSL_CERT_FILE" || true)
|
||||
runner_insecure=$(read_action_runner_env_value "PULSE_INSECURE" || true)
|
||||
action_runner_url_transport_allowed "$runner_url" || return 1
|
||||
[[ -n "$runner_hostname" ]] || return 1
|
||||
[[ -x "$ACTION_RUNNER_BINARY_PATH" ]] || return 1
|
||||
[[ "$runner_token_file" == /* && "$runner_token_file" != *'/../'* &&
|
||||
-f "$runner_token_file" && ! -L "$runner_token_file" ]] || return 1
|
||||
if [[ -n "$runner_agent_id_direct" ]]; then
|
||||
@@ -987,95 +1067,63 @@ revoke_action_runner_credential() {
|
||||
else
|
||||
[[ "$runner_agent_id_file" == /* && "$runner_agent_id_file" != *'/../'* &&
|
||||
-f "$runner_agent_id_file" && ! -L "$runner_agent_id_file" ]] || return 1
|
||||
runner_agent_id=$(head -1 "$runner_agent_id_file" 2>/dev/null || true)
|
||||
lifecycle_binary=$(collector_lifecycle_binary) || return 1
|
||||
identity_args=(collector-read-agent-id --agent-id-file "$runner_agent_id_file")
|
||||
collector_uid=$(id -u "$LEAST_PRIVILEGE_USER" 2>/dev/null || true)
|
||||
if [[ "$collector_uid" =~ ^[0-9]+$ ]]; then
|
||||
identity_args+=(--token-owner-uid "$collector_uid")
|
||||
fi
|
||||
runner_agent_id=$("$lifecycle_binary" "${identity_args[@]}" 2>/dev/null || true)
|
||||
fi
|
||||
(( ${#runner_agent_id} >= 1 && ${#runner_agent_id} <= 128 &&
|
||||
${#runner_hostname} >= 1 && ${#runner_hostname} <= 253 )) || return 1
|
||||
[[ "$runner_agent_id" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]*$ &&
|
||||
"$runner_hostname" =~ ^[A-Za-z0-9._:-]+$ ]] || return 1
|
||||
token_mode=$(stat -c '%a' "$runner_token_file" 2>/dev/null || true)
|
||||
token_size=$(wc -c < "$runner_token_file" 2>/dev/null | tr -d ' ' || true)
|
||||
[[ "$token_mode" =~ ^[0-7]{3,4}$ && "$token_size" =~ ^[0-9]+$ ]] || return 1
|
||||
(( (8#$token_mode & 8#077) == 0 && token_size >= 1 && token_size <= 4096 )) || return 1
|
||||
runner_token=$(head -1 "$runner_token_file" 2>/dev/null || true)
|
||||
[[ -n "$runner_token" && "$runner_token" != *$'\r'* && "$runner_token" != *$'\n'* ]] || return 1
|
||||
|
||||
curl_config=$(mktemp)
|
||||
chmod 0600 "$curl_config"
|
||||
printf 'header = "Authorization: Bearer %s"\nheader = "Content-Type: application/json"\n' \
|
||||
"$runner_token" > "$curl_config"
|
||||
runner_token=""
|
||||
payload="{\"agentId\":\"${runner_agent_id}\",\"hostname\":\"${runner_hostname}\"}"
|
||||
revoke_args=(--config "$curl_config" -fsS --connect-timeout 5 --max-time 10 -X DELETE --data-binary "$payload")
|
||||
if grep -q '^PULSE_INSECURE="true"$' "$ACTION_RUNNER_ENV_FILE" 2>/dev/null; then
|
||||
revoke_args+=(-k)
|
||||
fi
|
||||
if curl "${revoke_args[@]}" "${runner_url%/}/api/agents/action-runner/credential" >/dev/null 2>&1; then
|
||||
rm -f "$curl_config"
|
||||
return 0
|
||||
fi
|
||||
rm -f "$curl_config"
|
||||
return 1
|
||||
revoke_args=(revoke-credential --url "$runner_url" --token-file "$runner_token_file" --agent-id "$runner_agent_id" --hostname "$runner_hostname")
|
||||
[[ -n "$runner_ca_file" ]] && revoke_args+=(--cacert "$runner_ca_file")
|
||||
[[ -n "$runner_server_fingerprint" ]] && revoke_args+=(--server-fingerprint "$runner_server_fingerprint")
|
||||
if action_runner_url_uses_loopback_http "$runner_url" && [[ "$runner_insecure" == "true" ]]; then
|
||||
revoke_args+=(--insecure-loopback)
|
||||
fi
|
||||
"$ACTION_RUNNER_BINARY_PATH" "${revoke_args[@]}"
|
||||
}
|
||||
|
||||
reduce_safe_profile_collector_authority() {
|
||||
local active_token_file="${STATE_DIR%/}/runtime.token"
|
||||
local active_token=""
|
||||
local curl_config=""
|
||||
local payload=""
|
||||
local -a reduce_args
|
||||
|
||||
if [[ ! -s "$active_token_file" || -L "$active_token_file" ]]; then
|
||||
active_token_file=""
|
||||
active_token="$PULSE_TOKEN"
|
||||
else
|
||||
active_token=$(head -1 "$active_token_file" 2>/dev/null || true)
|
||||
fi
|
||||
[[ -n "$active_token" && "$active_token" != *$'\r'* && "$active_token" != *$'\n'* ]] || return 1
|
||||
[[ -n "$AGENT_ID" && ${#AGENT_ID} -le 256 && "$AGENT_ID" =~ ^[A-Za-z0-9._:-]+$ ]] || return 1
|
||||
[[ -n "$HOSTNAME_OVERRIDE" && ${#HOSTNAME_OVERRIDE} -le 253 && "$HOSTNAME_OVERRIDE" =~ ^[A-Za-z0-9._:-]+$ ]] || return 1
|
||||
[[ "$PULSE_URL" =~ ^https?://[^[:space:]]+$ ]] || return 1
|
||||
|
||||
curl_config=$(mktemp)
|
||||
chmod 0600 "$curl_config"
|
||||
printf 'header = "Authorization: Bearer %s"\nheader = "Content-Type: application/json"\n' \
|
||||
"$active_token" > "$curl_config"
|
||||
active_token=""
|
||||
payload="{\"agentId\":\"${AGENT_ID}\",\"hostname\":\"${HOSTNAME_OVERRIDE}\"}"
|
||||
reduce_args=(--config "$curl_config" -fsS --connect-timeout 5 --max-time 15 -X POST --data-binary "$payload")
|
||||
if [[ "$INSECURE" == "true" ]]; then
|
||||
reduce_args+=(-k)
|
||||
elif [[ -n "$CURL_CA_BUNDLE" ]]; then
|
||||
reduce_args+=(--cacert "$CURL_CA_BUNDLE")
|
||||
fi
|
||||
if curl "${reduce_args[@]}" "${PULSE_URL%/}/api/agents/collector/reduce-authority" >/dev/null 2>&1; then
|
||||
rm -f "$curl_config"
|
||||
if run_collector_lifecycle_command collector-reduce-authority \
|
||||
--agent-id "$AGENT_ID" --hostname "$HOSTNAME_OVERRIDE" >/dev/null 2>&1; then
|
||||
log_info "Durably removed execution and cross-host management scopes from the collector credential before migration."
|
||||
return 0
|
||||
fi
|
||||
rm -f "$curl_config"
|
||||
return 1
|
||||
}
|
||||
|
||||
teardown_action_runner_service() {
|
||||
local had_runner_config="false"
|
||||
if [[ -f "$ACTION_RUNNER_ENV_FILE" || -f "$ACTION_RUNNER_TOKEN_FILE" ]]; then
|
||||
had_runner_config="true"
|
||||
fi
|
||||
if revoke_action_runner_credential; then
|
||||
log_info "Revoked the action-runner credential before removing local runner state."
|
||||
elif [[ "$had_runner_config" == "true" ]]; then
|
||||
log_warn "Could not self-revoke the action-runner credential; local runner removal will continue. Revoke the credential from Pulse if the server was unreachable."
|
||||
fi
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl stop "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true
|
||||
systemctl disable "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true
|
||||
rm -f "$ACTION_RUNNER_SERVICE_UNIT"
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
systemctl reset-failed "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$ACTION_RUNNER_BINARY_PATH"
|
||||
rm -rf "$ACTION_RUNNER_CONFIG_DIR" "$ACTION_RUNNER_STATE_DIR"
|
||||
local had_runner_artifact="false"
|
||||
if [[ -e "$ACTION_RUNNER_BINARY_PATH" || -e "$ACTION_RUNNER_SERVICE_UNIT" ||
|
||||
-e "$ACTION_RUNNER_CONFIG_DIR" || -e "$ACTION_RUNNER_STATE_DIR" ]]; then
|
||||
had_runner_artifact="true"
|
||||
fi
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl stop "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true
|
||||
systemctl disable "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true
|
||||
fi
|
||||
if [[ "$had_runner_artifact" == "true" ]]; then
|
||||
if revoke_action_runner_credential; then
|
||||
log_info "Revoked the action-runner credential before removing local runner recovery material."
|
||||
else
|
||||
log_error "Could not confirm action-runner credential revocation. The runner is stopped and disabled; every local artifact was retained for a safe retry or manual server-side revoke."
|
||||
fail "Action runner removal requires a successful credential revocation; retry with the exact root-only credential, or revoke it in Pulse before manual cleanup" "$EXIT_GENERAL"
|
||||
fi
|
||||
fi
|
||||
rm -f "$ACTION_RUNNER_SERVICE_UNIT"
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
systemctl reset-failed "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$ACTION_RUNNER_BINARY_PATH"
|
||||
rm -rf "$ACTION_RUNNER_CONFIG_DIR" "$ACTION_RUNNER_STATE_DIR"
|
||||
}
|
||||
|
||||
teardown_openrc_agent_service() {
|
||||
@@ -1299,6 +1347,9 @@ ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
SystemCallArchitectures=native
|
||||
TasksMax=64
|
||||
LimitNOFILE=256
|
||||
MemoryMax=256M
|
||||
ReadOnlyPaths=${PRIVILEGED_HELPER_UPDATE_QUARANTINE_DIR}
|
||||
ReadWritePaths=${PRIVILEGED_HELPER_STATE_DIR} /usr/local/bin
|
||||
EOF
|
||||
@@ -1343,12 +1394,19 @@ EOF
|
||||
|
||||
write_action_runner_config() {
|
||||
local runner_hostname="$HOSTNAME_OVERRIDE"
|
||||
local runner_agent_id=""
|
||||
if [[ -L "$ACTION_RUNNER_CONFIG_DIR" || -L "$ACTION_RUNNER_STATE_DIR" ||
|
||||
-L "$ACTION_RUNNER_TOKEN_FILE" || -L "$ACTION_RUNNER_ENV_FILE" ]]; then
|
||||
fail "Refusing unsafe symlink in the action-runner config or state boundary" "$EXIT_GENERAL"
|
||||
fi
|
||||
install -d -o root -g root -m 0700 "$ACTION_RUNNER_CONFIG_DIR"
|
||||
install -d -o root -g root -m 0700 "$ACTION_RUNNER_STATE_DIR"
|
||||
action_runner_url_transport_allowed "$PULSE_URL" ||
|
||||
fail "Action runner requires HTTPS/WSS; plaintext HTTP/WS is allowed only for loopback local use" "$EXIT_MISSING_ARGS"
|
||||
if [[ "$INSECURE" == "true" && "$PULSE_URL" =~ ^[Hh][Tt][Tt][Pp][Ss]:// &&
|
||||
-z "$CURL_CA_BUNDLE" && -z "$SERVER_FINGERPRINT" ]]; then
|
||||
fail "Action runner refuses generic insecure HTTPS; configure a trusted CA bundle or exact server fingerprint" "$EXIT_MISSING_ARGS"
|
||||
fi
|
||||
|
||||
if [[ -n "$ACTION_TOKEN" ]]; then
|
||||
printf '%s\n' "$ACTION_TOKEN" > "$ACTION_RUNNER_TOKEN_FILE"
|
||||
@@ -1366,6 +1424,9 @@ write_action_runner_config() {
|
||||
fail "Action runner requires a canonical hostname" "$EXIT_MISSING_ARGS"
|
||||
[[ "$ACTION_RUNNER_ACTIVATION_NONCE" =~ ^[a-f0-9]{64}$ ]] ||
|
||||
fail "Action runner requires a fresh activation nonce" "$EXIT_GENERAL"
|
||||
runner_agent_id=$(resolve_action_runner_agent_id) ||
|
||||
fail "Action runner requires a safely resolved canonical collector identity" "$EXIT_MISSING_ARGS"
|
||||
AGENT_ID="$runner_agent_id"
|
||||
|
||||
: > "$ACTION_RUNNER_ENV_FILE"
|
||||
chmod 0600 "$ACTION_RUNNER_ENV_FILE"
|
||||
@@ -1374,12 +1435,7 @@ write_action_runner_config() {
|
||||
write_action_runner_env_value "PULSE_AGENT_RUNNER_STATE_DIR" "$ACTION_RUNNER_STATE_DIR"
|
||||
write_action_runner_env_value "PULSE_AGENT_RUNNER_HEALTH_FILE" "$ACTION_RUNNER_HEALTH_FILE"
|
||||
write_action_runner_env_value "PULSE_AGENT_RUNNER_ACTIVATION_NONCE" "$ACTION_RUNNER_ACTIVATION_NONCE"
|
||||
if [[ -n "$AGENT_ID" ]]; then
|
||||
[[ ${#AGENT_ID} -le 128 && "$AGENT_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]*$ ]] ||
|
||||
fail "Action runner requires a valid canonical agent identity" "$EXIT_MISSING_ARGS"
|
||||
write_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID" "$AGENT_ID"
|
||||
fi
|
||||
write_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID_FILE" "${STATE_DIR%/}/agent-id"
|
||||
write_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID" "$runner_agent_id"
|
||||
write_action_runner_env_value "PULSE_AGENT_RUNNER_HOSTNAME" "$runner_hostname"
|
||||
if [[ -n "$SERVER_FINGERPRINT" ]]; then
|
||||
write_action_runner_env_value "PULSE_SERVER_FINGERPRINT" "$SERVER_FINGERPRINT"
|
||||
@@ -1387,7 +1443,7 @@ write_action_runner_config() {
|
||||
if [[ -n "$CURL_CA_BUNDLE" ]]; then
|
||||
write_action_runner_env_value "SSL_CERT_FILE" "$CURL_CA_BUNDLE"
|
||||
fi
|
||||
if [[ "$INSECURE" == "true" ]]; then
|
||||
if [[ "$INSECURE" == "true" ]] && action_runner_url_uses_loopback_http "$PULSE_URL"; then
|
||||
write_action_runner_env_value "PULSE_INSECURE" "true"
|
||||
fi
|
||||
chown root:root "$ACTION_RUNNER_ENV_FILE"
|
||||
@@ -1420,6 +1476,128 @@ action_runner_health_matches_activation() {
|
||||
[[ "$health_agent_id" == "$expected_agent_id" && "$health_activation_nonce" == "$expected_nonce" ]]
|
||||
}
|
||||
|
||||
# Print pending or active for the exact installed runner credential. Failure is
|
||||
# intentionally indeterminate: callers must not restore a predecessor because
|
||||
# an unreachable server may already have committed and revoked it.
|
||||
action_runner_url_uses_loopback_http() {
|
||||
local raw_url="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
|
||||
local authority=""
|
||||
local host=""
|
||||
local octet=""
|
||||
local -a octets
|
||||
|
||||
[[ "$raw_url" =~ ^http://[^[:space:]]+$ ]] || return 1
|
||||
authority="${raw_url#http://}"
|
||||
authority="${authority%%/*}"
|
||||
[[ -n "$authority" && "$authority" != *'@'* ]] || return 1
|
||||
if [[ "$authority" == \[* ]]; then
|
||||
host="${authority#\[}"
|
||||
host="${host%%\]*}"
|
||||
[[ "$authority" == "[${host}]" || "$authority" == "[${host}]:"* ]] || return 1
|
||||
[[ "$host" == "::1" ]]
|
||||
return
|
||||
fi
|
||||
[[ "$authority" != *:*:* ]] || return 1
|
||||
host="${authority%%:*}"
|
||||
host="${host%.}"
|
||||
if [[ "$host" == "localhost" ]]; then
|
||||
return 0
|
||||
fi
|
||||
[[ "$host" =~ ^127\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]] || return 1
|
||||
IFS='.' read -r -a octets <<< "$host"
|
||||
for octet in "${octets[@]}"; do
|
||||
(( 10#$octet <= 255 )) || return 1
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
action_runner_url_transport_allowed() {
|
||||
local raw_url="$1"
|
||||
local lower_url="$(printf '%s' "$raw_url" | tr '[:upper:]' '[:lower:]')"
|
||||
if [[ "$lower_url" =~ ^https://[^[:space:]]+$ && "$lower_url" != *'@'* ]]; then
|
||||
return 0
|
||||
fi
|
||||
action_runner_url_uses_loopback_http "$raw_url"
|
||||
}
|
||||
|
||||
# Atomically cancel the exact pending replacement. Only the runner command's
|
||||
# zero exit (server HTTP 204) authorizes predecessor restore. The bearer is
|
||||
# written to a private temporary file and never appears in argv or curl.
|
||||
cancel_pending_action_runner_credential() {
|
||||
local expected_agent_id="$1"
|
||||
local expected_hostname="$2"
|
||||
local replacement_token="$3"
|
||||
local token_tmp=""
|
||||
local old_umask=""
|
||||
local -a cancel_args
|
||||
|
||||
[[ -x "$ACTION_RUNNER_BINARY_PATH" && -d "$ACTION_RUNNER_CONFIG_DIR" && ! -L "$ACTION_RUNNER_CONFIG_DIR" ]] || return 1
|
||||
[[ -n "$expected_agent_id" && -n "$expected_hostname" && -n "$replacement_token" ]] || return 1
|
||||
action_runner_url_transport_allowed "$PULSE_URL" || return 1
|
||||
old_umask=$(umask)
|
||||
umask 077
|
||||
token_tmp=$(mktemp "${ACTION_RUNNER_CONFIG_DIR%/}/.cancel-token.XXXXXX") || {
|
||||
umask "$old_umask"
|
||||
return 1
|
||||
}
|
||||
if ! printf '%s\n' "$replacement_token" > "$token_tmp" ||
|
||||
! chown root:root "$token_tmp" || ! chmod 0600 "$token_tmp" || ! sync -f "$token_tmp"; then
|
||||
rm -f "$token_tmp"
|
||||
umask "$old_umask"
|
||||
return 1
|
||||
fi
|
||||
umask "$old_umask"
|
||||
cancel_args=(cancel-pending-credential --url "$PULSE_URL" --token-file "$token_tmp")
|
||||
[[ -n "${CURL_CA_BUNDLE:-}" ]] && cancel_args+=(--cacert "$CURL_CA_BUNDLE")
|
||||
[[ -n "${SERVER_FINGERPRINT:-}" ]] && cancel_args+=(--server-fingerprint "$SERVER_FINGERPRINT")
|
||||
action_runner_url_uses_loopback_http "$PULSE_URL" && cancel_args+=(--insecure-loopback)
|
||||
if "$ACTION_RUNNER_BINARY_PATH" "${cancel_args[@]}"; then
|
||||
rm -f "$token_tmp"
|
||||
return 0
|
||||
fi
|
||||
rm -f "$token_tmp"
|
||||
return 1
|
||||
}
|
||||
|
||||
persist_action_runner_replacement_token() {
|
||||
local replacement_token="$1"
|
||||
local token_dir="$(dirname "$ACTION_RUNNER_TOKEN_FILE")"
|
||||
local token_tmp=""
|
||||
local old_umask=""
|
||||
local token_owner=""
|
||||
local token_mode=""
|
||||
|
||||
[[ -n "$replacement_token" && "$replacement_token" != *$'\r'* && "$replacement_token" != *$'\n'* ]] || return 1
|
||||
[[ "$ACTION_RUNNER_TOKEN_FILE" == "${ACTION_RUNNER_CONFIG_DIR%/}/"* &&
|
||||
-d "$token_dir" && ! -L "$token_dir" ]] || return 1
|
||||
[[ ! -e "$ACTION_RUNNER_TOKEN_FILE" || ( -f "$ACTION_RUNNER_TOKEN_FILE" && ! -L "$ACTION_RUNNER_TOKEN_FILE" ) ]] || return 1
|
||||
old_umask=$(umask)
|
||||
umask 077
|
||||
token_tmp=$(mktemp "${token_dir}/.replacement-token.XXXXXX") || {
|
||||
umask "$old_umask"
|
||||
return 1
|
||||
}
|
||||
if ! printf '%s\n' "$replacement_token" > "$token_tmp" ||
|
||||
! chown root:root "$token_tmp" ||
|
||||
! chmod 0600 "$token_tmp" ||
|
||||
! sync -f "$token_tmp" ||
|
||||
! mv -f "$token_tmp" "$ACTION_RUNNER_TOKEN_FILE"; then
|
||||
rm -f "$token_tmp"
|
||||
umask "$old_umask"
|
||||
return 1
|
||||
fi
|
||||
token_tmp=""
|
||||
if ! sync -f "$token_dir"; then
|
||||
umask "$old_umask"
|
||||
return 1
|
||||
fi
|
||||
umask "$old_umask"
|
||||
token_owner=$(stat -c '%u' "$ACTION_RUNNER_TOKEN_FILE" 2>/dev/null || true)
|
||||
token_mode=$(stat -c '%a' "$ACTION_RUNNER_TOKEN_FILE" 2>/dev/null || true)
|
||||
[[ "$token_owner" == "0" && "$token_mode" == "600" &&
|
||||
-f "$ACTION_RUNNER_TOKEN_FILE" && ! -L "$ACTION_RUNNER_TOKEN_FILE" ]]
|
||||
}
|
||||
|
||||
write_action_runner_env_value() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
@@ -1433,8 +1611,23 @@ write_action_runner_env_value() {
|
||||
|
||||
resolve_action_runner_agent_id() {
|
||||
local agent_id="${AGENT_ID:-}"
|
||||
if [[ -z "$agent_id" && -s "${STATE_DIR%/}/agent-id" && ! -L "${STATE_DIR%/}/agent-id" ]]; then
|
||||
agent_id=$(head -1 "${STATE_DIR%/}/agent-id" 2>/dev/null || true)
|
||||
local persisted_agent_id=""
|
||||
local lifecycle_binary=""
|
||||
local collector_uid=""
|
||||
local -a identity_args
|
||||
|
||||
if [[ -z "$agent_id" && -f "${ACTION_RUNNER_ENV_FILE:-}" && ! -L "${ACTION_RUNNER_ENV_FILE:-}" ]]; then
|
||||
agent_id=$(read_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID" 2>/dev/null || true)
|
||||
fi
|
||||
if [[ -z "$agent_id" ]]; then
|
||||
lifecycle_binary=$(collector_lifecycle_binary) || return 1
|
||||
identity_args=(collector-read-agent-id --agent-id-file "${STATE_DIR%/}/agent-id")
|
||||
collector_uid=$(id -u "$LEAST_PRIVILEGE_USER" 2>/dev/null || true)
|
||||
if [[ "$collector_uid" =~ ^[0-9]+$ ]]; then
|
||||
identity_args+=(--token-owner-uid "$collector_uid")
|
||||
fi
|
||||
persisted_agent_id=$("$lifecycle_binary" "${identity_args[@]}" 2>/dev/null || true)
|
||||
agent_id="$persisted_agent_id"
|
||||
fi
|
||||
[[ ${#agent_id} -ge 1 && ${#agent_id} -le 128 && "$agent_id" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]*$ ]] || return 1
|
||||
printf '%s\n' "$agent_id"
|
||||
@@ -1450,6 +1643,18 @@ provision_action_runner() {
|
||||
local runner_active="false"
|
||||
local apply_succeeded="false"
|
||||
local activation_nonce=""
|
||||
local credential_replacement_requested="false"
|
||||
local replacement_action_token=""
|
||||
local expected_agent_id=""
|
||||
local expected_hostname="$HOSTNAME_OVERRIDE"
|
||||
|
||||
if [[ -n "$ACTION_TOKEN" ]]; then
|
||||
credential_replacement_requested="true"
|
||||
replacement_action_token="$ACTION_TOKEN"
|
||||
fi
|
||||
if [[ -z "$expected_hostname" ]]; then
|
||||
expected_hostname=$(hostname 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [[ -d "$ACTION_RUNNER_STATE_DIR" ]]; then
|
||||
had_state_dir="true"
|
||||
@@ -1483,6 +1688,7 @@ provision_action_runner() {
|
||||
chmod 0644 "${ACTION_RUNNER_SERVICE_UNIT}.new"
|
||||
mv "${ACTION_RUNNER_SERVICE_UNIT}.new" "$ACTION_RUNNER_SERVICE_UNIT"
|
||||
systemctl daemon-reload
|
||||
action_runner_verify_effective_target
|
||||
systemctl enable "${ACTION_RUNNER_NAME}.service"
|
||||
systemctl restart "${ACTION_RUNNER_NAME}.service"
|
||||
); then
|
||||
@@ -1505,10 +1711,35 @@ provision_action_runner() {
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ "$runner_active" != "true" ]]; then
|
||||
log_error "New action runner did not become healthy; rolling back runner-only files while leaving monitoring active."
|
||||
systemctl stop "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true
|
||||
systemctl disable "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true
|
||||
if [[ "$runner_active" != "true" ]]; then
|
||||
systemctl stop "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true
|
||||
if [[ "$credential_replacement_requested" == "true" ]]; then
|
||||
expected_agent_id=$(resolve_action_runner_agent_id || true)
|
||||
if ! cancel_pending_action_runner_credential "$expected_agent_id" "$expected_hostname" "$replacement_action_token"; then
|
||||
log_error "The server did not durably confirm cancellation of the pending action-runner credential. The predecessor cannot be restored because activation may already be committed."
|
||||
if ! persist_action_runner_replacement_token "$replacement_action_token"; then
|
||||
replacement_action_token=""
|
||||
log_error "Could not durably persist the exact replacement action-runner credential. The runner remains stopped, the predecessor was not restored, and action-runner re-enrollment is required."
|
||||
ACTION_RUNNER_ACTIVATION_NONCE=""
|
||||
fail "Action runner credential recovery requires re-enrollment; no predecessor credential was restored" "$EXIT_GENERAL"
|
||||
fi
|
||||
log_error "The exact replacement credential and runtime were retained durably; repair is required."
|
||||
replacement_action_token=""
|
||||
rm -f "${ACTION_RUNNER_BINARY_PATH}.new" "${ACTION_RUNNER_SERVICE_UNIT}.new"
|
||||
rm -f \
|
||||
"${ACTION_RUNNER_BINARY_PATH}${backup_suffix}" \
|
||||
"${ACTION_RUNNER_SERVICE_UNIT}${backup_suffix}" \
|
||||
"${ACTION_RUNNER_ENV_FILE}${backup_suffix}" \
|
||||
"${ACTION_RUNNER_TOKEN_FILE}${backup_suffix}"
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
systemctl enable --now "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true
|
||||
ACTION_RUNNER_ACTIVATION_NONCE=""
|
||||
fail "Action runner activation requires repair; the new credential and runtime were retained and the previous credential was not restored" "$EXIT_GENERAL"
|
||||
fi
|
||||
replacement_action_token=""
|
||||
fi
|
||||
log_error "New action runner did not become healthy before server activation committed; rolling back runner-only files while leaving monitoring active."
|
||||
systemctl disable "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true
|
||||
rm -f "${ACTION_RUNNER_BINARY_PATH}.new" "${ACTION_RUNNER_SERVICE_UNIT}.new"
|
||||
rm -f "$ACTION_RUNNER_HEALTH_FILE"
|
||||
for path in "$ACTION_RUNNER_BINARY_PATH" "$ACTION_RUNNER_SERVICE_UNIT" "$ACTION_RUNNER_ENV_FILE" "$ACTION_RUNNER_TOKEN_FILE"; do
|
||||
@@ -1531,6 +1762,7 @@ provision_action_runner() {
|
||||
fail "Action runner activation failed and its previous installation was restored; collector monitoring was not stopped or removed" "$EXIT_GENERAL"
|
||||
fi
|
||||
|
||||
replacement_action_token=""
|
||||
rm -f \
|
||||
"${ACTION_RUNNER_BINARY_PATH}${backup_suffix}" \
|
||||
"${ACTION_RUNNER_SERVICE_UNIT}${backup_suffix}" \
|
||||
@@ -1593,29 +1825,146 @@ safe_profile_unit_property() {
|
||||
esac
|
||||
}
|
||||
|
||||
safe_profile_effective_unit_unoverridden() {
|
||||
systemd_effective_unit_property() {
|
||||
local unit_name="$1"
|
||||
local property="$2"
|
||||
systemctl show "$unit_name" --property "$property" --value 2>/dev/null
|
||||
}
|
||||
|
||||
systemd_effective_unit_unoverridden() {
|
||||
local unit_name="$1"
|
||||
local expected_fragment="$2"
|
||||
local fragment_path=""
|
||||
local drop_in_paths=""
|
||||
fragment_path=$(systemctl show "${AGENT_NAME}.service" --property FragmentPath --value 2>/dev/null) || return 1
|
||||
drop_in_paths=$(systemctl show "${AGENT_NAME}.service" --property DropInPaths --value 2>/dev/null) || return 1
|
||||
[[ "$fragment_path" == "$SAFE_PROFILE_COLLECTOR_UNIT" && -z "$drop_in_paths" ]]
|
||||
fragment_path=$(systemd_effective_unit_property "$unit_name" FragmentPath) || return 1
|
||||
drop_in_paths=$(systemd_effective_unit_property "$unit_name" DropInPaths) || return 1
|
||||
[[ "$fragment_path" == "$expected_fragment" && -z "$drop_in_paths" ]]
|
||||
}
|
||||
|
||||
systemd_effective_exec_argv() {
|
||||
local unit_name="$1"
|
||||
local exec_start=""
|
||||
local argv=""
|
||||
exec_start=$(systemd_effective_unit_property "$unit_name" ExecStart) || return 1
|
||||
[[ "$exec_start" == *"argv[]="* ]] || return 1
|
||||
argv="${exec_start#*argv[]=}"
|
||||
argv="${argv%% ;*}"
|
||||
printf '%s\n' "$argv"
|
||||
}
|
||||
|
||||
systemd_effective_exec_exact() {
|
||||
local unit_name="$1"
|
||||
local expected_binary="$2"
|
||||
local argv=""
|
||||
argv=$(systemd_effective_exec_argv "$unit_name") || return 1
|
||||
[[ "$argv" == "$expected_binary" ]]
|
||||
}
|
||||
|
||||
systemd_effective_words_equal() {
|
||||
local unit_name="$1"
|
||||
local property="$2"
|
||||
shift 2
|
||||
local actual=""
|
||||
local expected=""
|
||||
actual=$(systemd_effective_unit_property "$unit_name" "$property") || return 1
|
||||
actual=$(printf '%s\n' "$actual" | tr '[:space:]' '\n' | sed '/^$/d' | LC_ALL=C sort -u | tr '\n' ' ' | sed 's/[[:space:]]*$//')
|
||||
expected=$(printf '%s\n' "$@" | LC_ALL=C sort -u | tr '\n' ' ' | sed 's/[[:space:]]*$//')
|
||||
[[ "$actual" == "$expected" ]]
|
||||
}
|
||||
|
||||
systemd_effective_common_hardening() {
|
||||
local unit_name="$1"
|
||||
[[ "$(systemd_effective_unit_property "$unit_name" UMask)" == "0077" ]] || return 1
|
||||
[[ "$(systemd_effective_unit_property "$unit_name" NoNewPrivileges)" == "yes" ]] || return 1
|
||||
[[ "$(systemd_effective_unit_property "$unit_name" PrivateTmp)" == "yes" ]] || return 1
|
||||
[[ "$(systemd_effective_unit_property "$unit_name" PrivateDevices)" == "no" ]] || return 1
|
||||
[[ "$(systemd_effective_unit_property "$unit_name" ProtectKernelTunables)" == "yes" ]] || return 1
|
||||
[[ "$(systemd_effective_unit_property "$unit_name" ProtectKernelModules)" == "yes" ]] || return 1
|
||||
[[ "$(systemd_effective_unit_property "$unit_name" ProtectControlGroups)" == "yes" ]] || return 1
|
||||
[[ "$(systemd_effective_unit_property "$unit_name" LockPersonality)" == "yes" ]] || return 1
|
||||
[[ "$(systemd_effective_unit_property "$unit_name" RestrictSUIDSGID)" == "yes" ]] || return 1
|
||||
[[ "$(systemd_effective_unit_property "$unit_name" SystemCallArchitectures)" == "native" ]] || return 1
|
||||
}
|
||||
|
||||
safe_profile_verify_helper_effective_target() {
|
||||
local helper_service="${PRIVILEGED_HELPER_NAME}.service"
|
||||
local helper_socket="${PRIVILEGED_HELPER_NAME}.socket"
|
||||
local listen=""
|
||||
local environment_files=""
|
||||
|
||||
systemd_effective_unit_unoverridden "$helper_service" "$PRIVILEGED_HELPER_SERVICE_UNIT" || return 1
|
||||
systemd_effective_unit_unoverridden "$helper_socket" "$PRIVILEGED_HELPER_SOCKET_UNIT" || return 1
|
||||
systemd_effective_exec_exact "$helper_service" "$PRIVILEGED_HELPER_BINARY_PATH" || return 1
|
||||
[[ "$(systemd_effective_unit_property "$helper_service" User)" == "root" ]] || return 1
|
||||
[[ "$(systemd_effective_unit_property "$helper_service" Group)" == "root" ]] || return 1
|
||||
[[ -z "$(systemd_effective_unit_property "$helper_service" AmbientCapabilities)" ]] || return 1
|
||||
systemd_effective_common_hardening "$helper_service" || return 1
|
||||
[[ "$(systemd_effective_unit_property "$helper_service" PrivateNetwork)" == "yes" ]] || return 1
|
||||
systemd_effective_words_equal "$helper_service" RestrictAddressFamilies AF_UNIX || return 1
|
||||
[[ "$(systemd_effective_unit_property "$helper_service" ProtectSystem)" == "strict" ]] || return 1
|
||||
[[ "$(systemd_effective_unit_property "$helper_service" ProtectHome)" == "yes" ]] || return 1
|
||||
[[ "$(systemd_effective_unit_property "$helper_service" TasksMax)" == "64" ]] || return 1
|
||||
[[ "$(systemd_effective_unit_property "$helper_service" LimitNOFILE)" == "256" ]] || return 1
|
||||
[[ "$(systemd_effective_unit_property "$helper_service" MemoryMax)" == "268435456" ]] || return 1
|
||||
[[ -z "$(systemd_effective_unit_property "$helper_service" Environment)" ]] || return 1
|
||||
environment_files=$(systemd_effective_unit_property "$helper_service" EnvironmentFiles) || return 1
|
||||
[[ -z "$environment_files" ]] || return 1
|
||||
systemd_effective_words_equal "$helper_service" ReadOnlyPaths "$PRIVILEGED_HELPER_UPDATE_QUARANTINE_DIR" || return 1
|
||||
systemd_effective_words_equal "$helper_service" ReadWritePaths "$PRIVILEGED_HELPER_STATE_DIR" /usr/local/bin || return 1
|
||||
|
||||
[[ "$(systemd_effective_unit_property "$helper_socket" SocketUser)" == "root" ]] || return 1
|
||||
[[ "$(systemd_effective_unit_property "$helper_socket" SocketGroup)" == "$LEAST_PRIVILEGE_USER" ]] || return 1
|
||||
[[ "$(systemd_effective_unit_property "$helper_socket" SocketMode)" == "0660" ]] || return 1
|
||||
[[ "$(systemd_effective_unit_property "$helper_socket" DirectoryMode)" == "0755" ]] || return 1
|
||||
[[ "$(systemd_effective_unit_property "$helper_socket" RemoveOnStop)" == "yes" ]] || return 1
|
||||
listen=$(systemd_effective_unit_property "$helper_socket" Listen) || return 1
|
||||
[[ "$listen" == *"${PRIVILEGED_HELPER_SOCKET_PATH}"* && "$listen" == *"Stream"* ]] || return 1
|
||||
}
|
||||
|
||||
action_runner_verify_effective_target() {
|
||||
local runner_service="${ACTION_RUNNER_NAME}.service"
|
||||
local environment_files=""
|
||||
|
||||
systemd_effective_unit_unoverridden "$runner_service" "$ACTION_RUNNER_SERVICE_UNIT" || return 1
|
||||
systemd_effective_exec_exact "$runner_service" "$ACTION_RUNNER_BINARY_PATH" || return 1
|
||||
[[ "$(systemd_effective_unit_property "$runner_service" User)" == "root" ]] || return 1
|
||||
[[ "$(systemd_effective_unit_property "$runner_service" Group)" == "root" ]] || return 1
|
||||
[[ -z "$(systemd_effective_unit_property "$runner_service" AmbientCapabilities)" ]] || return 1
|
||||
systemd_effective_common_hardening "$runner_service" || return 1
|
||||
[[ "$(systemd_effective_unit_property "$runner_service" PrivateNetwork)" == "no" ]] || return 1
|
||||
systemd_effective_words_equal "$runner_service" RestrictAddressFamilies AF_UNIX AF_INET AF_INET6 || return 1
|
||||
[[ "$(systemd_effective_unit_property "$runner_service" ProtectSystem)" == "no" ]] || return 1
|
||||
[[ "$(systemd_effective_unit_property "$runner_service" ProtectHome)" == "yes" ]] || return 1
|
||||
systemd_effective_words_equal "$runner_service" ReadWritePaths "$ACTION_RUNNER_STATE_DIR" || return 1
|
||||
environment_files=$(systemd_effective_unit_property "$runner_service" EnvironmentFiles) || return 1
|
||||
[[ "$environment_files" == "$ACTION_RUNNER_ENV_FILE (ignore_errors=no)" ]]
|
||||
}
|
||||
|
||||
safe_profile_effective_unit_unoverridden() {
|
||||
systemd_effective_unit_unoverridden "${AGENT_NAME}.service" "$SAFE_PROFILE_COLLECTOR_UNIT"
|
||||
}
|
||||
|
||||
safe_profile_verify_effective_target() {
|
||||
local unit_user=""
|
||||
local ambient=""
|
||||
local exec_start=""
|
||||
local exec_argv=""
|
||||
local environment=""
|
||||
|
||||
safe_profile_effective_unit_unoverridden || return 1
|
||||
unit_user=$(safe_profile_unit_property User)
|
||||
ambient=$(safe_profile_unit_property AmbientCapabilities)
|
||||
exec_start=$(safe_profile_unit_property ExecStart)
|
||||
exec_argv=$(systemd_effective_exec_argv "${AGENT_NAME}.service") || return 1
|
||||
environment=$(safe_profile_unit_property Environment)
|
||||
[[ "$unit_user" == "$LEAST_PRIVILEGE_USER" ]] || return 1
|
||||
[[ -z "$ambient" ]] || return 1
|
||||
[[ "$exec_start" != *"--enable-commands"* ]] || return 1
|
||||
systemd_effective_common_hardening "${AGENT_NAME}.service" || return 1
|
||||
[[ "$exec_argv" == "${INSTALL_DIR}/${BINARY_NAME}" || "$exec_argv" == "${INSTALL_DIR}/${BINARY_NAME} "* ]] || return 1
|
||||
[[ "$exec_argv" != *"--enable-commands"* ]] || return 1
|
||||
[[ "$environment" == *"PULSE_AGENT_HELPER_SOCKET=${PRIVILEGED_HELPER_SOCKET_PATH}"* ]] || return 1
|
||||
safe_profile_verify_helper_effective_target || return 1
|
||||
if [[ -e "$ACTION_RUNNER_SERVICE_UNIT" ]]; then
|
||||
action_runner_verify_effective_target || return 1
|
||||
fi
|
||||
}
|
||||
|
||||
safe_profile_inspect() {
|
||||
@@ -4870,9 +5219,8 @@ fi
|
||||
if [[ "$SAFE_PROFILE_ACTION" == "apply" ]]; then
|
||||
safe_profile_platform_supported ||
|
||||
fail "Safe-profile migration is supported only on standard Linux systemd hosts; no broader-privilege fallback was applied" "$EXIT_MISSING_ARGS"
|
||||
safe_profile_begin_transaction
|
||||
reduce_safe_profile_collector_authority ||
|
||||
fail "Safe-profile migration could not durably remove command and cross-host management authority from the existing collector credential; no privilege change was retained" "$EXIT_AUTH_REJECTED"
|
||||
resolve_safe_profile_hostname ||
|
||||
fail "Safe-profile migration could not resolve a canonical local hostname" "$EXIT_MISSING_ARGS"
|
||||
fi
|
||||
|
||||
# Create the dedicated service account for the least-privilege profile and
|
||||
@@ -5051,6 +5399,9 @@ provision_typed_privileged_helper() {
|
||||
append_service_env "PULSE_AGENT_HELPER_SOCKET" "$PRIVILEGED_HELPER_SOCKET_PATH"
|
||||
|
||||
systemctl daemon-reload
|
||||
if ! safe_profile_verify_helper_effective_target; then
|
||||
fail "Refusing typed-helper activation because the effective helper service or socket differs from the installer-owned safe profile" "$EXIT_GENERAL"
|
||||
fi
|
||||
if ! systemctl enable --now "${PRIVILEGED_HELPER_NAME}.socket"; then
|
||||
fail "Failed to enable the typed privileged helper socket" "$EXIT_GENERAL"
|
||||
fi
|
||||
@@ -5433,7 +5784,26 @@ if [[ "$ACTION_RUNNER_ENABLED" == "true" ]]; then
|
||||
fi
|
||||
|
||||
chmod 0755 "$TMP_BIN"
|
||||
NEW_VERSION=$("$TMP_BIN" --version 2>/dev/null | head -1 || echo "unknown")
|
||||
if [[ "$SAFE_PROFILE_ACTION" == "apply" ]]; then
|
||||
# The irreversible server-side reduction must run only through the staged
|
||||
# binary whose checksum/signature have just been verified. The installed
|
||||
# predecessor may not expose the authenticated lifecycle commands yet.
|
||||
# Snapshot first, then reduce, before stopping or replacing any local
|
||||
# runtime so a failed reduction leaves the legacy install untouched.
|
||||
safe_profile_begin_transaction
|
||||
SAFE_PROFILE_STAGED_COLLECTOR="${INSTALL_DIR}/.${BINARY_NAME}.safe-profile-new.$$"
|
||||
TMP_FILES+=("$SAFE_PROFILE_STAGED_COLLECTOR")
|
||||
install -o root -g root -m 0755 "$TMP_BIN" "$SAFE_PROFILE_STAGED_COLLECTOR" ||
|
||||
fail "Safe-profile migration could not stage the verified collector on the installation filesystem" "$EXIT_GENERAL"
|
||||
COLLECTOR_LIFECYCLE_BINARY_PATH="$SAFE_PROFILE_STAGED_COLLECTOR"
|
||||
reduce_safe_profile_collector_authority ||
|
||||
fail "Safe-profile migration could not durably remove command and cross-host management authority from the existing collector credential; no privilege change was retained" "$EXIT_AUTH_REJECTED"
|
||||
fi
|
||||
VERSION_PROBE_BINARY="$TMP_BIN"
|
||||
if [[ "$SAFE_PROFILE_ACTION" == "apply" ]]; then
|
||||
VERSION_PROBE_BINARY="$SAFE_PROFILE_STAGED_COLLECTOR"
|
||||
fi
|
||||
NEW_VERSION=$("$VERSION_PROBE_BINARY" --version 2>/dev/null | head -1 || echo "unknown")
|
||||
|
||||
# Compare versions with any leading "v" stripped so the agent binary's "v6.0.4"
|
||||
# and the server /api/version "6.0.4" are treated as equal. Only a genuine
|
||||
@@ -5509,10 +5879,10 @@ fi
|
||||
log_info "Installing binary to ${INSTALL_DIR}/${BINARY_NAME}..."
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
if [[ "$SAFE_PROFILE_ACTION" == "apply" ]]; then
|
||||
SAFE_PROFILE_STAGED_COLLECTOR="${INSTALL_DIR}/.${BINARY_NAME}.safe-profile-new.$$"
|
||||
TMP_FILES+=("$SAFE_PROFILE_STAGED_COLLECTOR")
|
||||
install -o root -g root -m 0755 "$TMP_BIN" "$SAFE_PROFILE_STAGED_COLLECTOR"
|
||||
[[ -x "$SAFE_PROFILE_STAGED_COLLECTOR" && -f "$SAFE_PROFILE_STAGED_COLLECTOR" ]] ||
|
||||
fail "Verified safe-profile collector staging artifact is unavailable" "$EXIT_GENERAL"
|
||||
mv "$SAFE_PROFILE_STAGED_COLLECTOR" "${INSTALL_DIR}/${BINARY_NAME}"
|
||||
COLLECTOR_LIFECYCLE_BINARY_PATH="${INSTALL_DIR}/${BINARY_NAME}"
|
||||
else
|
||||
mv "$TMP_BIN" "${INSTALL_DIR}/${BINARY_NAME}"
|
||||
chmod 0755 "${INSTALL_DIR}/${BINARY_NAME}"
|
||||
|
||||
@@ -2,6 +2,7 @@ package installtests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -2745,6 +2746,28 @@ func extractInstallShellFunction(t *testing.T, name string) string {
|
||||
return string(match)
|
||||
}
|
||||
|
||||
func extractCollectorLifecycleShellFunctions(t *testing.T, includeVerify bool) string {
|
||||
t.Helper()
|
||||
functions := extractInstallShellFunction(t, "collector_lifecycle_binary") + "\n" +
|
||||
extractInstallShellFunction(t, "prepare_collector_lifecycle_token_file") + "\n" +
|
||||
extractInstallShellFunction(t, "run_collector_lifecycle_command")
|
||||
if includeVerify {
|
||||
functions += "\n" + extractInstallShellFunction(t, "verify_agent_server_registration")
|
||||
}
|
||||
return functions
|
||||
}
|
||||
|
||||
func buildPulseAgentLifecycleBinary(t *testing.T) string {
|
||||
t.Helper()
|
||||
binary := filepath.Join(t.TempDir(), "pulse-agent")
|
||||
build := exec.Command("go", "build", "-o", binary, "./cmd/pulse-agent")
|
||||
build.Dir = filepath.Dir(repoFile("go.mod"))
|
||||
if output, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("build pulse-agent lifecycle binary: %v\n%s", err, output)
|
||||
}
|
||||
return binary
|
||||
}
|
||||
|
||||
func extractRootInstallShellFunction(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -5082,9 +5105,8 @@ func TestSetupAutoUpdatesPreservesRCChannelWhenUpdatingExistingConfig(t *testing
|
||||
// transient "agent has not reported yet" state, so the installer can surface an
|
||||
// actionable error instead of leaving a silent 401 loop behind (issue #1515).
|
||||
func TestInstallSHVerifyAgentServerRegistrationDetectsRejectedToken(t *testing.T) {
|
||||
urlEncode := extractInstallShellFunction(t, "url_encode")
|
||||
curlWithPulseToken := extractInstallShellFunction(t, "curl_with_pulse_token")
|
||||
verifyFn := extractInstallShellFunction(t, "verify_agent_server_registration")
|
||||
pulseAgent := buildPulseAgentLifecycleBinary(t)
|
||||
verifyFns := extractCollectorLifecycleShellFunctions(t, true)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -5095,13 +5117,14 @@ func TestInstallSHVerifyAgentServerRegistrationDetectsRejectedToken(t *testing.T
|
||||
{"rejected token 401", http.StatusUnauthorized, `{"error":"Authentication required"}`, "rc=2"},
|
||||
{"rejected token 403", http.StatusForbidden, `{"error":"missing_scope"}`, "rc=2"},
|
||||
{"previous hostname owner during binding", http.StatusForbidden, `{"error":{"code":"agent_lookup_forbidden","message":"Agent does not belong to this API token"}}`, "rc=1"},
|
||||
{"registered", http.StatusOK, `{"success":true,"agent":{"id":"agent-omv"}}`, "rc=0"},
|
||||
{"registered", http.StatusOK, `{"success":true,"agent":{"id":"agent-omv","hostname":"omv","lastSeen":"2026-08-30T12:00:01Z"}}`, "rc=0"},
|
||||
{"not reported yet", http.StatusNotFound, `{"error":"agent_not_found"}`, "rc=1"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
stateDir := t.TempDir()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/agents/agent/lookup") {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
@@ -5115,13 +5138,16 @@ func TestInstallSHVerifyAgentServerRegistrationDetectsRejectedToken(t *testing.T
|
||||
script := `
|
||||
PULSE_URL="` + server.URL + `"
|
||||
PULSE_TOKEN="stale-token"
|
||||
STATE_DIR="` + stateDir + `"
|
||||
RUNTIME_TOKEN_FILE=""
|
||||
COLLECTOR_LIFECYCLE_BINARY_PATH="` + pulseAgent + `"
|
||||
LEAST_PRIVILEGE_USER="$(id -un)"
|
||||
SERVER_FINGERPRINT=""
|
||||
AGENT_ID=""
|
||||
HOSTNAME_OVERRIDE="omv"
|
||||
INSECURE="false"
|
||||
CURL_CA_BUNDLE=""
|
||||
` + curlWithPulseToken + `
|
||||
` + urlEncode + `
|
||||
` + verifyFn + `
|
||||
` + verifyFns + `
|
||||
verify_agent_server_registration
|
||||
echo "rc=$?"
|
||||
`
|
||||
@@ -5137,9 +5163,8 @@ func TestInstallSHVerifyAgentServerRegistrationDetectsRejectedToken(t *testing.T
|
||||
}
|
||||
|
||||
func TestInstallSHVerifyAgentServerRegistrationPrefersCanonicalAgentID(t *testing.T) {
|
||||
urlEncode := extractInstallShellFunction(t, "url_encode")
|
||||
curlWithPulseToken := extractInstallShellFunction(t, "curl_with_pulse_token")
|
||||
verifyFn := extractInstallShellFunction(t, "verify_agent_server_registration")
|
||||
pulseAgent := buildPulseAgentLifecycleBinary(t)
|
||||
verifyFns := extractCollectorLifecycleShellFunctions(t, true)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Query().Get("id"); got != "agent-current" {
|
||||
@@ -5148,20 +5173,23 @@ func TestInstallSHVerifyAgentServerRegistrationPrefersCanonicalAgentID(t *testin
|
||||
if got := r.URL.Query().Get("hostname"); got != "" {
|
||||
t.Errorf("hostname lookup = %q, want ID-only lookup", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"success":true,"agent":{"id":"agent-current"}}`))
|
||||
_, _ = w.Write([]byte(`{"success":true,"agent":{"id":"agent-current","hostname":"hostname-owned-by-previous-token","lastSeen":"2026-08-30T12:00:01Z"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
script := `
|
||||
PULSE_URL="` + server.URL + `"
|
||||
PULSE_TOKEN="install-token"
|
||||
STATE_DIR="` + t.TempDir() + `"
|
||||
RUNTIME_TOKEN_FILE=""
|
||||
COLLECTOR_LIFECYCLE_BINARY_PATH="` + pulseAgent + `"
|
||||
LEAST_PRIVILEGE_USER="$(id -un)"
|
||||
SERVER_FINGERPRINT=""
|
||||
AGENT_ID="agent-current"
|
||||
HOSTNAME_OVERRIDE="hostname-owned-by-previous-token"
|
||||
INSECURE="false"
|
||||
CURL_CA_BUNDLE=""
|
||||
` + curlWithPulseToken + `
|
||||
` + urlEncode + `
|
||||
` + verifyFn + `
|
||||
` + verifyFns + `
|
||||
verify_agent_server_registration
|
||||
echo "rc=$?"
|
||||
`
|
||||
@@ -5181,9 +5209,8 @@ func TestInstallSHVerifyAgentServerRegistrationPrefersCanonicalAgentID(t *testin
|
||||
// registration (issue #1644). A rejected token still short-circuits because
|
||||
// more polling cannot change a definitive 401/403.
|
||||
func TestInstallSHRegistrationRetryWindowOutlastsFirstReportCycle(t *testing.T) {
|
||||
urlEncode := extractInstallShellFunction(t, "url_encode")
|
||||
curlWithPulseToken := extractInstallShellFunction(t, "curl_with_pulse_token")
|
||||
verifyFn := extractInstallShellFunction(t, "verify_agent_server_registration")
|
||||
pulseAgent := buildPulseAgentLifecycleBinary(t)
|
||||
verifyFns := extractCollectorLifecycleShellFunctions(t, true)
|
||||
retryFn := extractInstallShellFunction(t, "verify_agent_server_registration_with_retry")
|
||||
|
||||
verifyStarted := extractInstallShellFunction(t, "verify_agent_started")
|
||||
@@ -5220,7 +5247,7 @@ func TestInstallSHRegistrationRetryWindowOutlastsFirstReportCycle(t *testing.T)
|
||||
}
|
||||
w.WriteHeader(tc.statuses[idx])
|
||||
if tc.statuses[idx] == http.StatusOK {
|
||||
_, _ = w.Write([]byte(`{"success":true,"agent":{"id":"agent-1644"}}`))
|
||||
_, _ = w.Write([]byte(`{"success":true,"agent":{"id":"agent-1644","hostname":"pve-1644","lastSeen":"2026-08-30T12:00:01Z"}}`))
|
||||
} else {
|
||||
_, _ = w.Write([]byte(`{"error":"agent_not_found"}`))
|
||||
}
|
||||
@@ -5230,14 +5257,17 @@ func TestInstallSHRegistrationRetryWindowOutlastsFirstReportCycle(t *testing.T)
|
||||
script := `
|
||||
PULSE_URL="` + server.URL + `"
|
||||
PULSE_TOKEN="install-token"
|
||||
STATE_DIR="` + t.TempDir() + `"
|
||||
RUNTIME_TOKEN_FILE=""
|
||||
COLLECTOR_LIFECYCLE_BINARY_PATH="` + pulseAgent + `"
|
||||
LEAST_PRIVILEGE_USER="$(id -un)"
|
||||
SERVER_FINGERPRINT=""
|
||||
AGENT_ID=""
|
||||
HOSTNAME_OVERRIDE="pve-1644"
|
||||
INSECURE="false"
|
||||
CURL_CA_BUNDLE=""
|
||||
sleep() { :; }
|
||||
` + curlWithPulseToken + `
|
||||
` + urlEncode + `
|
||||
` + verifyFn + `
|
||||
` + verifyFns + `
|
||||
` + retryFn + `
|
||||
verify_agent_server_registration_with_retry
|
||||
echo "rc=$?"
|
||||
@@ -5952,14 +5982,15 @@ func TestInstallSHActionRunnerIsSeparateOptInLifecycle(t *testing.T) {
|
||||
`The action runner must use a separate credential from the collector token`,
|
||||
`Preserving existing separately enabled action-runner profile`,
|
||||
`write_action_runner_env_value "PULSE_AGENT_RUNNER_TOKEN_FILE" "$ACTION_RUNNER_TOKEN_FILE"`,
|
||||
`write_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID" "$AGENT_ID"`,
|
||||
`write_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID_FILE" "${STATE_DIR%/}/agent-id"`,
|
||||
`write_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID" "$runner_agent_id"`,
|
||||
`collector-read-agent-id --agent-id-file "${STATE_DIR%/}/agent-id"`,
|
||||
`identity_args=(collector-read-agent-id --agent-id-file "$runner_agent_id_file")`,
|
||||
`write_action_runner_env_value "PULSE_AGENT_RUNNER_HEALTH_FILE" "$ACTION_RUNNER_HEALTH_FILE"`,
|
||||
`write_action_runner_env_value "PULSE_AGENT_RUNNER_ACTIVATION_NONCE" "$ACTION_RUNNER_ACTIVATION_NONCE"`,
|
||||
`write_action_runner_env_value "PULSE_AGENT_RUNNER_HOSTNAME" "$runner_hostname"`,
|
||||
`DELETE --data-binary "$payload"`,
|
||||
`/api/agents/action-runner/credential`,
|
||||
`Could not self-revoke the action-runner credential`,
|
||||
`revoke-credential --url "$runner_url" --token-file "$runner_token_file"`,
|
||||
`cancel-pending-credential --url "$PULSE_URL" --token-file "$token_tmp"`,
|
||||
`Action runner removal requires a successful credential revocation`,
|
||||
`/download/${ACTION_RUNNER_BINARY_NAME}?${DOWNLOAD_QUERY}`,
|
||||
`verify_download_signature "$TMP_ACTION_RUNNER_BIN" "$runner_signature"`,
|
||||
`install -o root -g root -m 0755 "$TMP_ACTION_RUNNER_BIN"`,
|
||||
@@ -5967,6 +5998,11 @@ func TestInstallSHActionRunnerIsSeparateOptInLifecycle(t *testing.T) {
|
||||
`ACTION_TOKEN=""`,
|
||||
`action_runner_health_matches_activation "$expected_agent_id" "$activation_nonce"`,
|
||||
`[[ "$health_agent_id" == "$expected_agent_id" && "$health_activation_nonce" == "$expected_nonce" ]]`,
|
||||
`cancel_pending_action_runner_credential "$expected_agent_id" "$expected_hostname" "$replacement_action_token"`,
|
||||
`persist_action_runner_replacement_token "$replacement_action_token"`,
|
||||
`if ! cancel_pending_action_runner_credential`,
|
||||
`The exact replacement credential and runtime were retained durably`,
|
||||
`did not durably confirm cancellation`,
|
||||
`rolling back runner-only files while leaving monitoring active`,
|
||||
`Pulse action runner removed. Collector monitoring was left installed and running.`,
|
||||
} {
|
||||
@@ -5978,6 +6014,9 @@ func TestInstallSHActionRunnerIsSeparateOptInLifecycle(t *testing.T) {
|
||||
if strings.Contains(teardown, "teardown_systemd_agent_service") || strings.Contains(teardown, `rm -f "${INSTALL_DIR}/${BINARY_NAME}"`) {
|
||||
t.Fatalf("runner teardown must not remove the monitoring collector:\n%s", teardown)
|
||||
}
|
||||
if strings.Contains(script, `head -1 "$runner_agent_id_file"`) {
|
||||
t.Fatal("legacy runner removal must not path-read the collector-owned identity file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSHActionRunnerConfigBindsKnownAgentIDImmediately(t *testing.T) {
|
||||
@@ -6005,6 +6044,9 @@ fail() { printf '%s\n' "$1" >&2; exit "$2"; }
|
||||
install() { local destination="${!#}"; mkdir -p "$destination"; chmod 0700 "$destination"; }
|
||||
chown() { return 0; }
|
||||
` + extractInstallShellFunction(t, "write_action_runner_env_value") + `
|
||||
` + extractInstallShellFunction(t, "action_runner_url_uses_loopback_http") + `
|
||||
` + extractInstallShellFunction(t, "action_runner_url_transport_allowed") + `
|
||||
` + extractInstallShellFunction(t, "resolve_action_runner_agent_id") + `
|
||||
` + extractInstallShellFunction(t, "write_action_runner_config") + `
|
||||
write_action_runner_config
|
||||
`
|
||||
@@ -6021,6 +6063,44 @@ write_action_runner_config
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSHActionRunnerRejectsGenericInsecureHTTPSBeforeCredentialWrite(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
tokenPath := filepath.Join(root, "config", "token")
|
||||
script := `
|
||||
set -u
|
||||
ACTION_RUNNER_CONFIG_DIR="` + filepath.Join(root, "config") + `"
|
||||
ACTION_RUNNER_STATE_DIR="` + filepath.Join(root, "state") + `"
|
||||
ACTION_RUNNER_TOKEN_FILE="` + tokenPath + `"
|
||||
ACTION_RUNNER_ENV_FILE="` + filepath.Join(root, "config", "runner.env") + `"
|
||||
ACTION_RUNNER_HEALTH_FILE="` + filepath.Join(root, "state", "health.json") + `"
|
||||
ACTION_RUNNER_ACTIVATION_NONCE="` + strings.Repeat("a", 64) + `"
|
||||
STATE_DIR="` + filepath.Join(root, "collector") + `"
|
||||
HOSTNAME_OVERRIDE="host.local"
|
||||
AGENT_ID="agent-1"
|
||||
ACTION_TOKEN="must-not-be-written"
|
||||
PULSE_URL="https://pulse.example"
|
||||
SERVER_FINGERPRINT=""
|
||||
CURL_CA_BUNDLE=""
|
||||
INSECURE="true"
|
||||
EXIT_GENERAL=1
|
||||
EXIT_MISSING_ARGS=2
|
||||
fail() { printf '%s\n' "$1" >&2; exit "$2"; }
|
||||
install() { local destination="${!#}"; mkdir -p "$destination"; chmod 0700 "$destination"; }
|
||||
` + extractInstallShellFunction(t, "write_action_runner_env_value") + `
|
||||
` + extractInstallShellFunction(t, "action_runner_url_uses_loopback_http") + `
|
||||
` + extractInstallShellFunction(t, "action_runner_url_transport_allowed") + `
|
||||
` + extractInstallShellFunction(t, "write_action_runner_config") + `
|
||||
write_action_runner_config
|
||||
`
|
||||
out, err := exec.Command("bash", "-c", script).CombinedOutput()
|
||||
if err == nil || !strings.Contains(string(out), "refuses generic insecure HTTPS") {
|
||||
t.Fatalf("generic insecure HTTPS was not rejected: %v\n%s", err, out)
|
||||
}
|
||||
if _, statErr := os.Stat(tokenPath); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("runner credential was written before transport rejection: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSHActionRunnerDirectIdentityOverridesStaleCollectorFile(t *testing.T) {
|
||||
stateDir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(stateDir, "agent-id"), "stale-agent-id\n")
|
||||
@@ -6040,6 +6120,108 @@ resolve_action_runner_agent_id
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSHActionRunnerPersistsResolvedIdentityOutsideCollectorState(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
stateDir := filepath.Join(root, "collector-state")
|
||||
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identityPath := filepath.Join(stateDir, "agent-id")
|
||||
if err := os.WriteFile(identityPath, []byte("agent-canonical\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pulseAgent := buildPulseAgentLifecycleBinary(t)
|
||||
envFile := filepath.Join(root, "runner-config", "runner.env")
|
||||
script := `
|
||||
set -euo pipefail
|
||||
COLLECTOR_LIFECYCLE_BINARY_PATH="` + pulseAgent + `"
|
||||
LEAST_PRIVILEGE_USER="$(id -un)"
|
||||
INSTALL_DIR="` + root + `"
|
||||
BINARY_NAME="legacy-agent"
|
||||
TMP_BIN=""
|
||||
ACTION_RUNNER_CONFIG_DIR="` + filepath.Join(root, "runner-config") + `"
|
||||
ACTION_RUNNER_STATE_DIR="` + filepath.Join(root, "runner-state") + `"
|
||||
ACTION_RUNNER_TOKEN_FILE="` + filepath.Join(root, "runner-config", "token") + `"
|
||||
ACTION_RUNNER_ENV_FILE="` + envFile + `"
|
||||
ACTION_RUNNER_HEALTH_FILE="` + filepath.Join(root, "runner-state", "health.json") + `"
|
||||
ACTION_RUNNER_ACTIVATION_NONCE="` + strings.Repeat("a", 64) + `"
|
||||
STATE_DIR="` + stateDir + `"
|
||||
HOSTNAME_OVERRIDE="secure-runtime.lab"
|
||||
AGENT_ID=""
|
||||
ACTION_TOKEN="runner-secret"
|
||||
PULSE_URL="https://pulse.example"
|
||||
SERVER_FINGERPRINT=""
|
||||
CURL_CA_BUNDLE=""
|
||||
INSECURE="false"
|
||||
EXIT_GENERAL=1
|
||||
EXIT_MISSING_ARGS=2
|
||||
fail() { printf '%s\n' "$1" >&2; exit "$2"; }
|
||||
install() { local destination="${!#}"; mkdir -p "$destination"; chmod 0700 "$destination"; }
|
||||
chown() { return 0; }
|
||||
` + extractInstallShellFunction(t, "collector_lifecycle_binary") + `
|
||||
` + extractInstallShellFunction(t, "read_action_runner_env_value") + `
|
||||
` + extractInstallShellFunction(t, "resolve_action_runner_agent_id") + `
|
||||
` + extractInstallShellFunction(t, "write_action_runner_env_value") + `
|
||||
` + extractInstallShellFunction(t, "action_runner_url_uses_loopback_http") + `
|
||||
` + extractInstallShellFunction(t, "action_runner_url_transport_allowed") + `
|
||||
` + extractInstallShellFunction(t, "write_action_runner_config") + `
|
||||
write_action_runner_config
|
||||
printf 'attacker-rewrite\n' > "${STATE_DIR}/agent-id"
|
||||
grep '^PULSE_AGENT_RUNNER_AGENT_ID="agent-canonical"$' "$ACTION_RUNNER_ENV_FILE"
|
||||
if grep -q 'PULSE_AGENT_RUNNER_AGENT_ID_FILE' "$ACTION_RUNNER_ENV_FILE"; then exit 9; fi
|
||||
`
|
||||
out, err := exec.Command("bash", "-c", script).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("persist action-runner identity: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSHSafeProfileLifecycleUsesVerifiedInstallFilesystemStage(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
legacy := filepath.Join(root, "pulse-agent")
|
||||
tmpSource := filepath.Join(root, "download.tmp")
|
||||
staged := filepath.Join(root, ".pulse-agent.safe-profile-new")
|
||||
logPath := filepath.Join(root, "binary.log")
|
||||
mustWrite(t, legacy, "#!/bin/sh\nprintf 'legacy:%s\\n' \"$*\" >> \""+logPath+"\"\nexit 91\n")
|
||||
mustWrite(t, tmpSource, "verified bytes that cannot execute on a noexec mount\n")
|
||||
if err := os.Chmod(tmpSource, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, staged, "#!/bin/sh\nprintf 'staged:%s\\n' \"$*\" >> \""+logPath+"\"\nexit 0\n")
|
||||
script := `
|
||||
set -euo pipefail
|
||||
INSTALL_DIR="` + root + `"
|
||||
BINARY_NAME="pulse-agent"
|
||||
TMP_BIN="` + tmpSource + `"
|
||||
COLLECTOR_LIFECYCLE_BINARY_PATH="` + staged + `"
|
||||
` + extractInstallShellFunction(t, "collector_lifecycle_binary") + `
|
||||
resolved=$(collector_lifecycle_binary)
|
||||
[[ "$resolved" == "` + staged + `" ]]
|
||||
"$resolved" collector-reduce-authority
|
||||
`
|
||||
if out, err := exec.Command("bash", "-c", script).CombinedOutput(); err != nil {
|
||||
t.Fatalf("select staged lifecycle binary: %v\n%s", err, out)
|
||||
}
|
||||
logData, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(logData), "legacy:") || !strings.Contains(string(logData), "staged:collector-reduce-authority") {
|
||||
t.Fatalf("lifecycle binary selection log: %s", logData)
|
||||
}
|
||||
installScript, err := os.ReadFile(repoFile("scripts", "install.sh"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := string(installScript)
|
||||
stageAt := strings.Index(source, `install -o root -g root -m 0755 "$TMP_BIN" "$SAFE_PROFILE_STAGED_COLLECTOR"`)
|
||||
reduceAt := strings.Index(source, `reduce_safe_profile_collector_authority ||`)
|
||||
moveAt := strings.Index(source, `mv "$SAFE_PROFILE_STAGED_COLLECTOR" "${INSTALL_DIR}/${BINARY_NAME}"`)
|
||||
if stageAt < 0 || reduceAt <= stageAt || moveAt <= reduceAt {
|
||||
t.Fatalf("safe-profile staged lifecycle ordering invalid: stage=%d reduce=%d move=%d", stageAt, reduceAt, moveAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSHActionRunnerReadinessRequiresCurrentActivationNonce(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
healthPath := filepath.Join(root, "health.json")
|
||||
@@ -6095,6 +6277,250 @@ action_runner_health_matches_activation "agent-1" "` + nonce + `"
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSHActionRunnerPostCommitReadinessFailureRetainsReplacement(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
cancelBody string
|
||||
persistFailure bool
|
||||
rollback bool
|
||||
tokenWriteBody string
|
||||
wantToken string
|
||||
}{
|
||||
{
|
||||
name: "durable pending cancellation authorizes rollback", cancelBody: `[[ "$3" == "new-runner-token" ]] || return 1; return 0`, rollback: true,
|
||||
tokenWriteBody: `printf 'new-runner-token\n' > "$ACTION_RUNNER_TOKEN_FILE"`, wantToken: "old:token\n",
|
||||
},
|
||||
{
|
||||
name: "activation committed or cancel indeterminate", cancelBody: `[[ "$3" == "new-runner-token" ]] || return 1; return 1`,
|
||||
tokenWriteBody: `printf 'new-runner-token\n' > "$ACTION_RUNNER_TOKEN_FILE"`, wantToken: "new-runner-token\n",
|
||||
},
|
||||
{
|
||||
name: "replacement token durable write failure", cancelBody: "return 1", persistFailure: true,
|
||||
tokenWriteBody: `: > "$ACTION_RUNNER_TOKEN_FILE"`, wantToken: "",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
testInstallSHActionRunnerReadinessFailureRetainsReplacement(t, test.cancelBody, test.persistFailure, test.rollback, test.tokenWriteBody, test.wantToken)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testInstallSHActionRunnerReadinessFailureRetainsReplacement(t *testing.T, cancelBody string, persistFailure, rollback bool, tokenWriteBody, wantToken string) {
|
||||
t.Helper()
|
||||
cancelFunction := `cancel_pending_action_runner_credential() { ` + cancelBody + `; }`
|
||||
persistFunction := extractInstallShellFunction(t, "persist_action_runner_replacement_token")
|
||||
if persistFailure {
|
||||
persistFunction = `persist_action_runner_replacement_token() { return 1; }`
|
||||
}
|
||||
root := t.TempDir()
|
||||
binPath := filepath.Join(root, "bin", "pulse-agent-runner")
|
||||
unitPath := filepath.Join(root, "systemd", "pulse-agent-runner.service")
|
||||
envPath := filepath.Join(root, "config", "runner.env")
|
||||
tokenPath := filepath.Join(root, "config", "token")
|
||||
stateDir := filepath.Join(root, "state")
|
||||
for _, path := range []string{binPath, unitPath, envPath, tokenPath} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, path, "old:"+filepath.Base(path)+"\n")
|
||||
}
|
||||
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newBinary := filepath.Join(root, "new-runner")
|
||||
mustWrite(t, newBinary, "new:runner\n")
|
||||
serviceLog := filepath.Join(root, "systemctl.log")
|
||||
|
||||
script := `
|
||||
set -u
|
||||
ACTION_RUNNER_NAME="pulse-agent-runner"
|
||||
ACTION_RUNNER_BINARY_PATH="` + binPath + `"
|
||||
ACTION_RUNNER_SERVICE_UNIT="` + unitPath + `"
|
||||
ACTION_RUNNER_ENV_FILE="` + envPath + `"
|
||||
ACTION_RUNNER_TOKEN_FILE="` + tokenPath + `"
|
||||
ACTION_RUNNER_HEALTH_FILE="` + filepath.Join(stateDir, "health.json") + `"
|
||||
ACTION_RUNNER_STATE_DIR="` + stateDir + `"
|
||||
ACTION_RUNNER_CONFIG_DIR="` + filepath.Dir(tokenPath) + `"
|
||||
PRIVILEGE_HELPER_DIR="` + filepath.Join(root, "helper") + `"
|
||||
TMP_ACTION_RUNNER_BIN="` + newBinary + `"
|
||||
ACTION_TOKEN="new-runner-token"
|
||||
ACTION_RUNNER_ACTIVATION_NONCE=""
|
||||
HOSTNAME_OVERRIDE="host-1.local"
|
||||
STATE_DIR="` + filepath.Join(root, "collector-state") + `"
|
||||
PULSE_URL="http://127.0.0.1:7655"
|
||||
INSECURE="true"
|
||||
CURL_CA_BUNDLE=""
|
||||
EXIT_GENERAL=1
|
||||
generate_action_runner_activation_nonce() { printf '%064d\n' 0; }
|
||||
restore_selinux_contexts() { return 0; }
|
||||
write_action_runner_config() {
|
||||
` + tokenWriteBody + `
|
||||
printf 'new:env\n' > "$ACTION_RUNNER_ENV_FILE"
|
||||
}
|
||||
render_action_runner_service_unit() { printf 'new:unit\n' > "$1"; }
|
||||
install() {
|
||||
local source="${@: -2:1}"
|
||||
local destination="${@: -1}"
|
||||
mkdir -p "$(dirname "$destination")"
|
||||
cp "$source" "$destination"
|
||||
}
|
||||
chown() { return 0; }
|
||||
chmod() { return 0; }
|
||||
stat() {
|
||||
case "$1:$2" in
|
||||
'-c:%u') printf '0\n' ;;
|
||||
'-c:%a') printf '600\n' ;;
|
||||
*) command stat "$@" ;;
|
||||
esac
|
||||
}
|
||||
systemctl() {
|
||||
printf '%s\n' "$*" >> "` + serviceLog + `"
|
||||
return 0
|
||||
}
|
||||
action_runner_health_matches_activation() { return 1; }
|
||||
resolve_action_runner_agent_id() { printf 'agent-1\n'; }
|
||||
` + cancelFunction + `
|
||||
sleep() { return 0; }
|
||||
log_error() { printf 'ERROR: %s\n' "$1" >&2; }
|
||||
log_info() { printf 'INFO: %s\n' "$1"; }
|
||||
fail() { printf 'FAIL: %s\n' "$1" >&2; exit "$2"; }
|
||||
` + persistFunction + `
|
||||
` + extractInstallShellFunction(t, "provision_action_runner") + `
|
||||
provision_action_runner
|
||||
`
|
||||
out, err := exec.Command("bash", "-c", script).CombinedOutput()
|
||||
if err == nil {
|
||||
t.Fatalf("post-commit readiness failure unexpectedly succeeded:\n%s", out)
|
||||
}
|
||||
if rollback {
|
||||
if !strings.Contains(string(out), "rolling back runner-only files") || strings.Contains(string(out), "retained durably") {
|
||||
t.Fatalf("204 cancellation did not exclusively authorize predecessor restore:\n%s", out)
|
||||
}
|
||||
} else if persistFailure {
|
||||
if strings.Contains(string(out), "retained durably") || !strings.Contains(string(out), "re-enrollment") {
|
||||
t.Fatalf("durable token failure did not fail closed for re-enrollment:\n%s", out)
|
||||
}
|
||||
} else if !strings.Contains(string(out), "replacement credential and runtime were retained durably") || !strings.Contains(string(out), "repair") {
|
||||
t.Fatalf("missing repair-required result:\n%s", out)
|
||||
}
|
||||
if !rollback && !strings.Contains(string(out), "did not durably confirm cancellation") {
|
||||
t.Fatalf("missing atomic cancellation diagnostic:\n%s", out)
|
||||
}
|
||||
wantBinary, wantUnit, wantEnv := "new:runner\n", "new:unit\n", "new:env\n"
|
||||
if rollback {
|
||||
wantBinary, wantUnit, wantEnv = "old:pulse-agent-runner\n", "old:pulse-agent-runner.service\n", "old:runner.env\n"
|
||||
}
|
||||
for path, want := range map[string]string{
|
||||
binPath: wantBinary,
|
||||
unitPath: wantUnit,
|
||||
envPath: wantEnv,
|
||||
tokenPath: wantToken,
|
||||
} {
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read retained %s: %v", path, readErr)
|
||||
}
|
||||
if string(data) != want {
|
||||
t.Fatalf("retained %s = %q, want %q", path, data, want)
|
||||
}
|
||||
}
|
||||
backups, err := filepath.Glob(filepath.Join(root, "**", "*.pulse-install-backup.*"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if persistFailure && len(backups) == 0 {
|
||||
t.Fatal("durable token failure removed every predecessor backup before repair")
|
||||
}
|
||||
if !persistFailure && len(backups) != 0 {
|
||||
t.Fatalf("revoked predecessor backups retained: %v", backups)
|
||||
}
|
||||
serviceCalls, err := os.ReadFile(serviceLog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(serviceCalls), "stop pulse-agent-runner.service") {
|
||||
t.Fatalf("replacement was not stopped before classification: %s", serviceCalls)
|
||||
}
|
||||
if persistFailure && strings.Contains(string(serviceCalls), "enable --now pulse-agent-runner.service") {
|
||||
t.Fatalf("runner restarted without a durably retained replacement credential: %s", serviceCalls)
|
||||
}
|
||||
if !persistFailure && !strings.Contains(string(serviceCalls), "enable --now pulse-agent-runner.service") {
|
||||
t.Fatalf("replacement was not stopped for classification and restarted for repair: %s", serviceCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSHActionRunnerAtomicCancelUsesPrivateGoTransportAndRequires204(t *testing.T) {
|
||||
const token = "runner-cancel-secret"
|
||||
root := t.TempDir()
|
||||
runnerBinary := filepath.Join(root, "pulse-agent-runner")
|
||||
build := exec.Command("go", "build", "-o", runnerBinary, "./cmd/pulse-agent-runner")
|
||||
build.Dir = filepath.Dir(repoFile("go.mod"))
|
||||
if output, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("build action runner: %v\n%s", err, output)
|
||||
}
|
||||
caSource := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
defer caSource.Close()
|
||||
caFile := filepath.Join(root, "runner-ca.pem")
|
||||
if err := os.WriteFile(caFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caSource.Certificate().Raw}), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
statusCode int
|
||||
wantOK bool
|
||||
}{{"durable cancellation", http.StatusNoContent, true}, {"activation committed", http.StatusConflict, false}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var gotAuthorization, gotMethod, gotBody string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuthorization = r.Header.Get("Authorization")
|
||||
gotMethod = r.Method
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
gotBody = string(body)
|
||||
w.WriteHeader(tc.statusCode)
|
||||
}))
|
||||
defer server.Close()
|
||||
configDir := filepath.Join(root, strings.ReplaceAll(tc.name, " ", "-"))
|
||||
if err := os.MkdirAll(configDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
argsLog := filepath.Join(configDir, "args.log")
|
||||
wrapper := filepath.Join(configDir, "runner-wrapper")
|
||||
mustWrite(t, wrapper, "#!/bin/bash\nprintf '%s\\n' \"$@\" > \""+argsLog+"\"\nexec \""+runnerBinary+"\" \"$@\"\n")
|
||||
if err := os.Chmod(wrapper, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script := `
|
||||
set -euo pipefail
|
||||
ACTION_RUNNER_BINARY_PATH="` + wrapper + `"
|
||||
ACTION_RUNNER_CONFIG_DIR="` + configDir + `"
|
||||
PULSE_URL="` + server.URL + `"
|
||||
CURL_CA_BUNDLE="` + caFile + `"
|
||||
SERVER_FINGERPRINT="` + strings.Repeat("ab", 32) + `"
|
||||
chown() { return 0; }
|
||||
sync() { return 0; }
|
||||
` + extractInstallShellFunction(t, "action_runner_url_uses_loopback_http") + `
|
||||
` + extractInstallShellFunction(t, "action_runner_url_transport_allowed") + `
|
||||
` + extractInstallShellFunction(t, "cancel_pending_action_runner_credential") + `
|
||||
cancel_pending_action_runner_credential "agent-1" "host-1.local" "` + token + `"
|
||||
`
|
||||
out, err := exec.Command("bash", "-c", script).CombinedOutput()
|
||||
if (err == nil) != tc.wantOK {
|
||||
t.Fatalf("cancel result error=%v, want success=%v\n%s", err, tc.wantOK, out)
|
||||
}
|
||||
if gotAuthorization != "Bearer "+token || gotMethod != http.MethodDelete || gotBody != "" {
|
||||
t.Fatalf("cancel request auth=%q method=%q body=%q", gotAuthorization, gotMethod, gotBody)
|
||||
}
|
||||
if strings.Contains(string(out), token) {
|
||||
t.Fatalf("cancel credential leaked in output: %s", out)
|
||||
}
|
||||
args, readErr := os.ReadFile(argsLog)
|
||||
if readErr != nil || !strings.Contains(string(args), "--cacert\n"+caFile) || !strings.Contains(string(args), "--server-fingerprint\n"+strings.Repeat("ab", 32)) {
|
||||
t.Fatalf("runner lifecycle CA/pin args = %q, error=%v", args, readErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSHActionRunnerSelfRevokeUsesPrivateCredential(t *testing.T) {
|
||||
const token = "runner-secret-that-must-not-appear-in-output"
|
||||
var gotAuthorization string
|
||||
@@ -6108,12 +6534,17 @@ func TestInstallSHActionRunnerSelfRevokeUsesPrivateCredential(t *testing.T) {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"success":true}`)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
root := t.TempDir()
|
||||
runnerBinary := filepath.Join(root, "pulse-agent-runner")
|
||||
build := exec.Command("go", "build", "-o", runnerBinary, "./cmd/pulse-agent-runner")
|
||||
build.Dir = filepath.Dir(repoFile("go.mod"))
|
||||
if output, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("build action runner: %v\n%s", err, output)
|
||||
}
|
||||
envFile := filepath.Join(root, "runner.env")
|
||||
tokenFile := filepath.Join(root, "token")
|
||||
mustWrite(t, tokenFile, token+"\n")
|
||||
@@ -6126,16 +6557,20 @@ func TestInstallSHActionRunnerSelfRevokeUsesPrivateCredential(t *testing.T) {
|
||||
`PULSE_AGENT_RUNNER_AGENT_ID="agent-secure-runtime"`,
|
||||
`PULSE_AGENT_RUNNER_AGENT_ID_FILE="` + filepath.Join(root, "missing-agent-id") + `"`,
|
||||
`PULSE_AGENT_RUNNER_TOKEN_FILE="` + tokenFile + `"`,
|
||||
`PULSE_INSECURE="true"`,
|
||||
}, "\n")+"\n")
|
||||
|
||||
script := `
|
||||
set -euo pipefail
|
||||
ACTION_RUNNER_ENV_FILE="` + envFile + `"
|
||||
ACTION_RUNNER_BINARY_PATH="` + runnerBinary + `"
|
||||
stat() {
|
||||
if [[ "$1" == "-c" && "$2" == "%a" ]]; then printf '600\n'; return 0; fi
|
||||
command stat "$@"
|
||||
}
|
||||
` + extractInstallShellFunction(t, "read_action_runner_env_value") + `
|
||||
` + extractInstallShellFunction(t, "action_runner_url_uses_loopback_http") + `
|
||||
` + extractInstallShellFunction(t, "action_runner_url_transport_allowed") + `
|
||||
` + extractInstallShellFunction(t, "revoke_action_runner_credential") + `
|
||||
revoke_action_runner_credential
|
||||
printf 'revoked\n'
|
||||
@@ -6158,6 +6593,58 @@ printf 'revoked\n'
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSHActionRunnerUninstallRetainsRecoveryMaterialWhenRevokeIsUnconfirmed(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
paths := []string{
|
||||
filepath.Join(root, "bin", "pulse-agent-runner"),
|
||||
filepath.Join(root, "systemd", "pulse-agent-runner.service"),
|
||||
filepath.Join(root, "config", "runner.env"),
|
||||
filepath.Join(root, "config", "token"),
|
||||
filepath.Join(root, "state", "health.json"),
|
||||
}
|
||||
for _, path := range paths {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, path, "retained\n")
|
||||
}
|
||||
serviceLog := filepath.Join(root, "systemctl.log")
|
||||
script := `
|
||||
set -u
|
||||
ACTION_RUNNER_NAME="pulse-agent-runner"
|
||||
ACTION_RUNNER_BINARY_PATH="` + paths[0] + `"
|
||||
ACTION_RUNNER_SERVICE_UNIT="` + paths[1] + `"
|
||||
ACTION_RUNNER_ENV_FILE="` + paths[2] + `"
|
||||
ACTION_RUNNER_TOKEN_FILE="` + paths[3] + `"
|
||||
ACTION_RUNNER_CONFIG_DIR="` + filepath.Dir(paths[3]) + `"
|
||||
ACTION_RUNNER_STATE_DIR="` + filepath.Dir(paths[4]) + `"
|
||||
EXIT_GENERAL=1
|
||||
systemctl() { printf '%s\n' "$*" >> "` + serviceLog + `"; return 0; }
|
||||
revoke_action_runner_credential() { return 1; }
|
||||
log_error() { printf 'ERROR: %s\n' "$1" >&2; }
|
||||
log_info() { printf 'INFO: %s\n' "$1"; }
|
||||
fail() { printf 'FAIL: %s\n' "$1" >&2; exit "$2"; }
|
||||
` + extractInstallShellFunction(t, "teardown_action_runner_service") + `
|
||||
teardown_action_runner_service
|
||||
`
|
||||
out, err := exec.Command("bash", "-c", script).CombinedOutput()
|
||||
if err == nil || !strings.Contains(string(out), "successful credential revocation") || !strings.Contains(string(out), "retained for a safe retry") {
|
||||
t.Fatalf("unconfirmed revoke did not fail closed: %v\n%s", err, out)
|
||||
}
|
||||
for _, path := range paths {
|
||||
if _, statErr := os.Stat(path); statErr != nil {
|
||||
t.Fatalf("recovery material %s was removed: %v", path, statErr)
|
||||
}
|
||||
}
|
||||
serviceCalls, readErr := os.ReadFile(serviceLog)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if !strings.Contains(string(serviceCalls), "stop pulse-agent-runner.service") || !strings.Contains(string(serviceCalls), "disable pulse-agent-runner.service") {
|
||||
t.Fatalf("runner was not stopped and disabled before failed revoke: %s", serviceCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSHSafeProfileDurablyReducesCollectorAuthority(t *testing.T) {
|
||||
const token = "collector-secret-that-must-not-appear-in-output"
|
||||
var gotAuthorization string
|
||||
@@ -6176,18 +6663,31 @@ func TestInstallSHSafeProfileDurablyReducesCollectorAuthority(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
stateDir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(stateDir, "runtime.token"), token+"\n")
|
||||
tokenPath := filepath.Join(stateDir, "runtime.token")
|
||||
mustWrite(t, tokenPath, token+"\n")
|
||||
if err := os.Chmod(tokenPath, 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pulseAgent := buildPulseAgentLifecycleBinary(t)
|
||||
script := `
|
||||
set -euo pipefail
|
||||
STATE_DIR="` + stateDir + `"
|
||||
RUNTIME_TOKEN_FILE=""
|
||||
COLLECTOR_LIFECYCLE_BINARY_PATH="` + pulseAgent + `"
|
||||
LEAST_PRIVILEGE_USER="$(id -un)"
|
||||
PULSE_TOKEN=""
|
||||
AGENT_ID="agent-secure-runtime"
|
||||
HOSTNAME_OVERRIDE="secure-runtime.lab"
|
||||
HOSTNAME_OVERRIDE=""
|
||||
PULSE_URL="` + server.URL + `"
|
||||
INSECURE="false"
|
||||
CURL_CA_BUNDLE=""
|
||||
SERVER_FINGERPRINT=""
|
||||
hostname() { printf 'Secure-Runtime.Lab.\n'; }
|
||||
log_info() { printf '%s\n' "$*"; }
|
||||
` + extractCollectorLifecycleShellFunctions(t, false) + `
|
||||
` + extractInstallShellFunction(t, "resolve_safe_profile_hostname") + `
|
||||
` + extractInstallShellFunction(t, "reduce_safe_profile_collector_authority") + `
|
||||
resolve_safe_profile_hostname
|
||||
reduce_safe_profile_collector_authority
|
||||
`
|
||||
out, err := exec.Command("bash", "-c", script).CombinedOutput()
|
||||
@@ -6205,6 +6705,69 @@ reduce_safe_profile_collector_authority
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSHCollectorLifecycleRejectsActiveMITMForgedSuccess(t *testing.T) {
|
||||
const token = "collector-mitm-secret"
|
||||
var authorizationSeen bool
|
||||
mitm := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
authorizationSeen = true
|
||||
}
|
||||
if r.URL.Path == "/api/agents/collector/reduce-authority" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"success":true,"agent":{"id":"agent-secure-runtime","hostname":"secure-runtime.lab","lastSeen":"2026-08-30T12:00:02Z"}}`))
|
||||
}))
|
||||
defer mitm.Close()
|
||||
|
||||
stateDir := t.TempDir()
|
||||
tokenPath := filepath.Join(stateDir, "runtime.token")
|
||||
mustWrite(t, tokenPath, token)
|
||||
if err := os.Chmod(tokenPath, 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pulseAgent := buildPulseAgentLifecycleBinary(t)
|
||||
script := `
|
||||
set -u
|
||||
STATE_DIR="` + stateDir + `"
|
||||
RUNTIME_TOKEN_FILE=""
|
||||
COLLECTOR_LIFECYCLE_BINARY_PATH="` + pulseAgent + `"
|
||||
LEAST_PRIVILEGE_USER="$(id -un)"
|
||||
PULSE_TOKEN=""
|
||||
AGENT_ID="agent-secure-runtime"
|
||||
HOSTNAME_OVERRIDE="secure-runtime.lab"
|
||||
PULSE_URL="` + mitm.URL + `"
|
||||
INSECURE="true"
|
||||
CURL_CA_BUNDLE=""
|
||||
SERVER_FINGERPRINT="` + strings.Repeat("00", 32) + `"
|
||||
log_info() { printf '%s\n' "$*"; }
|
||||
` + extractCollectorLifecycleShellFunctions(t, true) + `
|
||||
` + extractInstallShellFunction(t, "reduce_safe_profile_collector_authority") + `
|
||||
if reduce_safe_profile_collector_authority; then
|
||||
printf 'forged reduction accepted\n'
|
||||
exit 3
|
||||
fi
|
||||
if verify_agent_server_registration "2026-08-30T12:00:01Z"; then
|
||||
printf 'forged registration accepted\n'
|
||||
exit 4
|
||||
fi
|
||||
printf 'forged success rejected\n'
|
||||
`
|
||||
out, err := exec.Command("bash", "-c", script).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("MITM rejection script: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(string(out), "forged success rejected") {
|
||||
t.Fatalf("missing forged-success rejection: %s", out)
|
||||
}
|
||||
if strings.Contains(string(out), token) {
|
||||
t.Fatalf("collector bearer leaked in output: %s", out)
|
||||
}
|
||||
if authorizationSeen {
|
||||
t.Fatal("MITM handler received the collector Authorization header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSHRendersHardenedActionRunnerUnit(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
unitPath := filepath.Join(root, "pulse-agent-runner.service")
|
||||
|
||||
@@ -17,6 +17,8 @@ func safeProfileInspectFunctions(t *testing.T) string {
|
||||
return extractInstallShellFunction(t, "safe_profile_platform_supported") + "\n" +
|
||||
extractInstallShellFunction(t, "safe_profile_detect_current_profile") + "\n" +
|
||||
extractInstallShellFunction(t, "safe_profile_unit_property") + "\n" +
|
||||
extractInstallShellFunction(t, "systemd_effective_unit_property") + "\n" +
|
||||
extractInstallShellFunction(t, "systemd_effective_unit_unoverridden") + "\n" +
|
||||
extractInstallShellFunction(t, "safe_profile_effective_unit_unoverridden") + "\n" +
|
||||
extractInstallShellFunction(t, "safe_profile_inspect")
|
||||
}
|
||||
@@ -24,6 +26,8 @@ func safeProfileInspectFunctions(t *testing.T) string {
|
||||
func safeProfileTransactionFunctions(t *testing.T) string {
|
||||
t.Helper()
|
||||
return extractInstallShellFunction(t, "safe_profile_detect_current_profile") + "\n" +
|
||||
extractInstallShellFunction(t, "systemd_effective_unit_property") + "\n" +
|
||||
extractInstallShellFunction(t, "systemd_effective_unit_unoverridden") + "\n" +
|
||||
extractInstallShellFunction(t, "safe_profile_effective_unit_unoverridden") + "\n" +
|
||||
extractInstallShellFunction(t, "safe_profile_snapshot_entry") + "\n" +
|
||||
extractInstallShellFunction(t, "safe_profile_manifest_value") + "\n" +
|
||||
@@ -36,6 +40,21 @@ func safeProfileTransactionFunctions(t *testing.T) string {
|
||||
extractInstallShellFunction(t, "safe_profile_commit_transaction")
|
||||
}
|
||||
|
||||
func safeProfileEffectiveSystemdFunctions(t *testing.T) string {
|
||||
t.Helper()
|
||||
return extractInstallShellFunction(t, "safe_profile_unit_property") + "\n" +
|
||||
extractInstallShellFunction(t, "systemd_effective_unit_property") + "\n" +
|
||||
extractInstallShellFunction(t, "systemd_effective_unit_unoverridden") + "\n" +
|
||||
extractInstallShellFunction(t, "systemd_effective_exec_argv") + "\n" +
|
||||
extractInstallShellFunction(t, "systemd_effective_exec_exact") + "\n" +
|
||||
extractInstallShellFunction(t, "systemd_effective_words_equal") + "\n" +
|
||||
extractInstallShellFunction(t, "systemd_effective_common_hardening") + "\n" +
|
||||
extractInstallShellFunction(t, "safe_profile_verify_helper_effective_target") + "\n" +
|
||||
extractInstallShellFunction(t, "action_runner_verify_effective_target") + "\n" +
|
||||
extractInstallShellFunction(t, "safe_profile_effective_unit_unoverridden") + "\n" +
|
||||
extractInstallShellFunction(t, "safe_profile_verify_effective_target")
|
||||
}
|
||||
|
||||
func TestSafeProfileInspectIsReadOnlyAndReportsDifferences(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
binDir := filepath.Join(root, "bin")
|
||||
@@ -271,6 +290,104 @@ func TestSafeProfileFailsClosedOnEffectiveSystemdOverrides(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeProfileValidatesEveryEffectiveSystemdBoundary(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name, unit, property, value string
|
||||
}{
|
||||
{name: "collector drop-in", unit: "pulse-agent.service", property: "DropInPaths", value: "/etc/systemd/system/pulse-agent.service.d/override.conf"},
|
||||
{name: "collector executable", unit: "pulse-agent.service", property: "ExecStart", value: "{ path=/tmp/collector ; argv[]=/tmp/collector ; }"},
|
||||
{name: "collector hardening", unit: "pulse-agent.service", property: "NoNewPrivileges", value: "no"},
|
||||
{name: "helper service fragment", unit: "pulse-agent-helper.service", property: "FragmentPath", value: "/usr/lib/systemd/system/pulse-agent-helper.service"},
|
||||
{name: "helper service drop-in", unit: "pulse-agent-helper.service", property: "DropInPaths", value: "/etc/systemd/system/pulse-agent-helper.service.d/override.conf"},
|
||||
{name: "helper executable", unit: "pulse-agent-helper.service", property: "ExecStart", value: "{ path=/tmp/helper ; argv[]=/tmp/helper ; }"},
|
||||
{name: "helper private network", unit: "pulse-agent-helper.service", property: "PrivateNetwork", value: "no"},
|
||||
{name: "helper common hardening", unit: "pulse-agent-helper.service", property: "ProtectKernelModules", value: "no"},
|
||||
{name: "helper task limit", unit: "pulse-agent-helper.service", property: "TasksMax", value: "infinity"},
|
||||
{name: "helper descriptor limit", unit: "pulse-agent-helper.service", property: "LimitNOFILE", value: "1048576"},
|
||||
{name: "helper memory limit", unit: "pulse-agent-helper.service", property: "MemoryMax", value: "infinity"},
|
||||
{name: "helper address families", unit: "pulse-agent-helper.service", property: "RestrictAddressFamilies", value: "AF_UNIX AF_INET"},
|
||||
{name: "helper environment", unit: "pulse-agent-helper.service", property: "Environment", value: "PULSE_URL=https://attacker.invalid"},
|
||||
{name: "helper writable paths", unit: "pulse-agent-helper.service", property: "ReadWritePaths", value: "/"},
|
||||
{name: "helper socket fragment", unit: "pulse-agent-helper.socket", property: "FragmentPath", value: "/usr/lib/systemd/system/pulse-agent-helper.socket"},
|
||||
{name: "helper socket drop-in", unit: "pulse-agent-helper.socket", property: "DropInPaths", value: "/etc/systemd/system/pulse-agent-helper.socket.d/override.conf"},
|
||||
{name: "helper socket mode", unit: "pulse-agent-helper.socket", property: "SocketMode", value: "0666"},
|
||||
{name: "helper socket target", unit: "pulse-agent-helper.socket", property: "Listen", value: "/tmp/attacker.sock (Stream)"},
|
||||
{name: "runner fragment", unit: "pulse-agent-runner.service", property: "FragmentPath", value: "/usr/lib/systemd/system/pulse-agent-runner.service"},
|
||||
{name: "runner drop-in", unit: "pulse-agent-runner.service", property: "DropInPaths", value: "/etc/systemd/system/pulse-agent-runner.service.d/override.conf"},
|
||||
{name: "runner executable", unit: "pulse-agent-runner.service", property: "ExecStart", value: "{ path=/tmp/runner ; argv[]=/tmp/runner ; }"},
|
||||
{name: "runner environment file", unit: "pulse-agent-runner.service", property: "EnvironmentFiles", value: "/tmp/attacker.env (ignore_errors=no)"},
|
||||
{name: "runner address families", unit: "pulse-agent-runner.service", property: "RestrictAddressFamilies", value: "AF_UNIX AF_INET AF_INET6 AF_NETLINK"},
|
||||
{name: "runner common hardening", unit: "pulse-agent-runner.service", property: "LockPersonality", value: "no"},
|
||||
{name: "runner filesystem", unit: "pulse-agent-runner.service", property: "ProtectSystem", value: "strict"},
|
||||
}
|
||||
|
||||
functions := safeProfileEffectiveSystemdFunctions(t)
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
script := effectiveSystemdHarness(t, testCase.unit, testCase.property, testCase.value) + "\n" + functions + `
|
||||
if safe_profile_verify_effective_target; then
|
||||
echo 'unsafe effective systemd profile was accepted' >&2
|
||||
exit 1
|
||||
fi
|
||||
`
|
||||
if out, err := exec.Command("bash", "-c", script).CombinedOutput(); err != nil {
|
||||
t.Fatalf("effective override rehearsal: %v\n%s", err, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("canonical profile", func(t *testing.T) {
|
||||
script := effectiveSystemdHarness(t, "", "", "") + "\n" + functions + "\nsafe_profile_verify_effective_target\n"
|
||||
if out, err := exec.Command("bash", "-c", script).CombinedOutput(); err != nil {
|
||||
t.Fatalf("canonical effective profile rejected: %v\n%s", err, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestActionRunnerProvisionChecksEffectiveUnitBeforeStart(t *testing.T) {
|
||||
provision := extractInstallShellFunction(t, "provision_action_runner")
|
||||
validation := strings.Index(provision, "action_runner_verify_effective_target")
|
||||
enable := strings.Index(provision, `systemctl enable "${ACTION_RUNNER_NAME}.service"`)
|
||||
restart := strings.Index(provision, `systemctl restart "${ACTION_RUNNER_NAME}.service"`)
|
||||
if validation < 0 || enable < 0 || restart < 0 || validation > enable || validation > restart {
|
||||
t.Fatal("action-runner effective systemd validation does not precede service enable/restart")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedHelperProvisionChecksEffectiveUnitsBeforeSocketActivation(t *testing.T) {
|
||||
provision := extractInstallShellFunction(t, "provision_typed_privileged_helper")
|
||||
validation := strings.Index(provision, "safe_profile_verify_helper_effective_target")
|
||||
enable := strings.Index(provision, `systemctl enable --now "${PRIVILEGED_HELPER_NAME}.socket"`)
|
||||
if validation < 0 || enable < 0 || validation > enable {
|
||||
t.Fatal("typed-helper effective systemd validation does not precede socket activation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedHelperUnitRendersBoundedResources(t *testing.T) {
|
||||
unitPath := filepath.Join(t.TempDir(), "pulse-agent-helper.service")
|
||||
render := extractInstallShellFunction(t, "render_privileged_helper_service_unit")
|
||||
script := `
|
||||
set -euo pipefail
|
||||
PRIVILEGED_HELPER_NAME=pulse-agent-helper
|
||||
PRIVILEGED_HELPER_UPDATE_QUARANTINE_DIR=/var/lib/pulse-agent/update-quarantine
|
||||
PRIVILEGED_HELPER_STATE_DIR=/var/lib/pulse-agent-helper
|
||||
` + render + `
|
||||
render_privileged_helper_service_unit "` + unitPath + `" /usr/local/lib/pulse-agent/pulse-agent-helper
|
||||
`
|
||||
if out, err := exec.Command("bash", "-c", script).CombinedOutput(); err != nil {
|
||||
t.Fatalf("render typed-helper unit: %v\n%s", err, out)
|
||||
}
|
||||
content, err := os.ReadFile(unitPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, directive := range []string{"TasksMax=64", "LimitNOFILE=256", "MemoryMax=256M"} {
|
||||
if !strings.Contains(string(content), directive+"\n") {
|
||||
t.Errorf("typed-helper unit omitted %s", directive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeProfileRegistrationMustAdvanceLastSeen(t *testing.T) {
|
||||
functions := extractInstallShellFunction(t, "verify_agent_server_registration")
|
||||
script := `
|
||||
@@ -278,18 +395,29 @@ set -euo pipefail
|
||||
AGENT_ID=agent-1
|
||||
HOSTNAME_OVERRIDE=
|
||||
PULSE_URL=https://pulse.example
|
||||
INSECURE=false
|
||||
CURL_CA_BUNDLE=
|
||||
AGENT_REGISTRATION_LAST_SEEN=
|
||||
url_encode() { printf '%s' "$1"; }
|
||||
curl_with_pulse_token() { printf '%s\n200\n' "$LOOKUP_BODY"; }
|
||||
LOOKUP_LAST_SEEN=
|
||||
LOOKUP_RC=0
|
||||
run_collector_lifecycle_command() {
|
||||
test "$1" = collector-verify-registration
|
||||
shift
|
||||
test "$1" = --agent-id
|
||||
test "$2" = agent-1
|
||||
test "$3" = --previous-last-seen
|
||||
test "$4" = '2026-08-30T10:00:00Z'
|
||||
if [[ "$LOOKUP_RC" -ne 0 ]]; then
|
||||
return "$LOOKUP_RC"
|
||||
fi
|
||||
printf '%s\n' "$LOOKUP_LAST_SEEN"
|
||||
}
|
||||
` + functions + `
|
||||
LOOKUP_BODY='{"agent":{"id":"agent-1","lastSeen":"2026-08-30T10:00:00Z"}}'
|
||||
LOOKUP_RC=1
|
||||
if verify_agent_server_registration '2026-08-30T10:00:00Z'; then
|
||||
echo 'stale registration was accepted' >&2
|
||||
exit 1
|
||||
fi
|
||||
LOOKUP_BODY='{"agent":{"id":"agent-1","lastSeen":"2026-08-30T10:00:31Z"}}'
|
||||
LOOKUP_RC=0
|
||||
LOOKUP_LAST_SEEN='2026-08-30T10:00:31Z'
|
||||
verify_agent_server_registration '2026-08-30T10:00:00Z'
|
||||
test "$AGENT_REGISTRATION_LAST_SEEN" = '2026-08-30T10:00:31Z'
|
||||
`
|
||||
@@ -558,6 +686,97 @@ stat() {
|
||||
` + safeProfileTransactionFunctions(t) + "\n"
|
||||
}
|
||||
|
||||
func effectiveSystemdHarness(t *testing.T, overrideUnit, overrideProperty, overrideValue string) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
collectorUnit := filepath.Join(root, "pulse-agent.service")
|
||||
helperServiceUnit := filepath.Join(root, "pulse-agent-helper.service")
|
||||
helperSocketUnit := filepath.Join(root, "pulse-agent-helper.socket")
|
||||
runnerUnit := filepath.Join(root, "pulse-agent-runner.service")
|
||||
for _, path := range []string{collectorUnit, helperServiceUnit, helperSocketUnit, runnerUnit} {
|
||||
mustWrite(t, path, "fixture unit\n")
|
||||
}
|
||||
return `
|
||||
set -euo pipefail
|
||||
AGENT_NAME=pulse-agent
|
||||
BINARY_NAME=pulse-agent
|
||||
INSTALL_DIR=/usr/local/bin
|
||||
LEAST_PRIVILEGE_USER=pulse-agent
|
||||
SAFE_PROFILE_COLLECTOR_UNIT="` + collectorUnit + `"
|
||||
PRIVILEGED_HELPER_NAME=pulse-agent-helper
|
||||
PRIVILEGED_HELPER_SERVICE_UNIT="` + helperServiceUnit + `"
|
||||
PRIVILEGED_HELPER_SOCKET_UNIT="` + helperSocketUnit + `"
|
||||
PRIVILEGED_HELPER_BINARY_PATH=/usr/local/lib/pulse-agent/pulse-agent-helper
|
||||
PRIVILEGED_HELPER_SOCKET_PATH=/run/pulse-agent/helper.sock
|
||||
PRIVILEGED_HELPER_UPDATE_QUARANTINE_DIR=/var/lib/pulse-agent/update-quarantine
|
||||
PRIVILEGED_HELPER_STATE_DIR=/var/lib/pulse-agent-helper
|
||||
ACTION_RUNNER_NAME=pulse-agent-runner
|
||||
ACTION_RUNNER_SERVICE_UNIT="` + runnerUnit + `"
|
||||
ACTION_RUNNER_BINARY_PATH=/usr/local/lib/pulse-agent/pulse-agent-runner
|
||||
ACTION_RUNNER_ENV_FILE=/etc/pulse-agent-runner/runner.env
|
||||
ACTION_RUNNER_STATE_DIR=/var/lib/pulse-agent-runner
|
||||
OVERRIDE_UNIT='` + overrideUnit + `'
|
||||
OVERRIDE_PROPERTY='` + overrideProperty + `'
|
||||
OVERRIDE_VALUE='` + overrideValue + `'
|
||||
systemctl() {
|
||||
[[ "${1:-}" == show ]]
|
||||
local unit="${2:-}"
|
||||
local property="${4:-}"
|
||||
if [[ "$unit" == "$OVERRIDE_UNIT" && "$property" == "$OVERRIDE_PROPERTY" ]]; then
|
||||
printf '%s\n' "$OVERRIDE_VALUE"
|
||||
return 0
|
||||
fi
|
||||
case "$unit:$property" in
|
||||
pulse-agent.service:FragmentPath) printf '%s\n' "$SAFE_PROFILE_COLLECTOR_UNIT" ;;
|
||||
pulse-agent.service:DropInPaths|pulse-agent.service:AmbientCapabilities) printf '\n' ;;
|
||||
pulse-agent.service:User) printf 'pulse-agent\n' ;;
|
||||
pulse-agent.service:ExecStart) printf '{ path=/usr/local/bin/pulse-agent ; argv[]=/usr/local/bin/pulse-agent --enable-host --command-authority monitoring-only ; }\n' ;;
|
||||
pulse-agent.service:Environment) printf 'PULSE_AGENT_HELPER_SOCKET=/run/pulse-agent/helper.sock\n' ;;
|
||||
pulse-agent.service:UMask) printf '0077\n' ;;
|
||||
pulse-agent.service:NoNewPrivileges|pulse-agent.service:PrivateTmp|pulse-agent.service:ProtectKernelTunables|pulse-agent.service:ProtectKernelModules|pulse-agent.service:ProtectControlGroups|pulse-agent.service:LockPersonality|pulse-agent.service:RestrictSUIDSGID) printf 'yes\n' ;;
|
||||
pulse-agent.service:PrivateDevices) printf 'no\n' ;;
|
||||
pulse-agent.service:SystemCallArchitectures) printf 'native\n' ;;
|
||||
pulse-agent-helper.service:FragmentPath) printf '%s\n' "$PRIVILEGED_HELPER_SERVICE_UNIT" ;;
|
||||
pulse-agent-helper.service:DropInPaths|pulse-agent-helper.service:AmbientCapabilities|pulse-agent-helper.service:Environment|pulse-agent-helper.service:EnvironmentFiles) printf '\n' ;;
|
||||
pulse-agent-helper.service:ExecStart) printf '{ path=/usr/local/lib/pulse-agent/pulse-agent-helper ; argv[]=/usr/local/lib/pulse-agent/pulse-agent-helper ; }\n' ;;
|
||||
pulse-agent-helper.service:User|pulse-agent-helper.service:Group) printf 'root\n' ;;
|
||||
pulse-agent-helper.service:UMask) printf '0077\n' ;;
|
||||
pulse-agent-helper.service:NoNewPrivileges|pulse-agent-helper.service:PrivateTmp|pulse-agent-helper.service:ProtectKernelTunables|pulse-agent-helper.service:ProtectKernelModules|pulse-agent-helper.service:ProtectControlGroups|pulse-agent-helper.service:LockPersonality|pulse-agent-helper.service:RestrictSUIDSGID|pulse-agent-helper.service:PrivateNetwork|pulse-agent-helper.service:ProtectHome) printf 'yes\n' ;;
|
||||
pulse-agent-helper.service:PrivateDevices) printf 'no\n' ;;
|
||||
pulse-agent-helper.service:SystemCallArchitectures) printf 'native\n' ;;
|
||||
pulse-agent-helper.service:ProtectSystem) printf 'strict\n' ;;
|
||||
pulse-agent-helper.service:RestrictAddressFamilies) printf 'AF_UNIX\n' ;;
|
||||
pulse-agent-helper.service:TasksMax) printf '64\n' ;;
|
||||
pulse-agent-helper.service:LimitNOFILE) printf '256\n' ;;
|
||||
pulse-agent-helper.service:MemoryMax) printf '268435456\n' ;;
|
||||
pulse-agent-helper.service:ReadOnlyPaths) printf '/var/lib/pulse-agent/update-quarantine\n' ;;
|
||||
pulse-agent-helper.service:ReadWritePaths) printf '/usr/local/bin /var/lib/pulse-agent-helper\n' ;;
|
||||
pulse-agent-helper.socket:FragmentPath) printf '%s\n' "$PRIVILEGED_HELPER_SOCKET_UNIT" ;;
|
||||
pulse-agent-helper.socket:DropInPaths) printf '\n' ;;
|
||||
pulse-agent-helper.socket:SocketUser) printf 'root\n' ;;
|
||||
pulse-agent-helper.socket:SocketGroup) printf 'pulse-agent\n' ;;
|
||||
pulse-agent-helper.socket:SocketMode) printf '0660\n' ;;
|
||||
pulse-agent-helper.socket:DirectoryMode) printf '0755\n' ;;
|
||||
pulse-agent-helper.socket:RemoveOnStop) printf 'yes\n' ;;
|
||||
pulse-agent-helper.socket:Listen) printf '/run/pulse-agent/helper.sock (Stream)\n' ;;
|
||||
pulse-agent-runner.service:FragmentPath) printf '%s\n' "$ACTION_RUNNER_SERVICE_UNIT" ;;
|
||||
pulse-agent-runner.service:DropInPaths|pulse-agent-runner.service:AmbientCapabilities) printf '\n' ;;
|
||||
pulse-agent-runner.service:ExecStart) printf '{ path=/usr/local/lib/pulse-agent/pulse-agent-runner ; argv[]=/usr/local/lib/pulse-agent/pulse-agent-runner ; }\n' ;;
|
||||
pulse-agent-runner.service:User|pulse-agent-runner.service:Group) printf 'root\n' ;;
|
||||
pulse-agent-runner.service:UMask) printf '0077\n' ;;
|
||||
pulse-agent-runner.service:NoNewPrivileges|pulse-agent-runner.service:PrivateTmp|pulse-agent-runner.service:ProtectKernelTunables|pulse-agent-runner.service:ProtectKernelModules|pulse-agent-runner.service:ProtectControlGroups|pulse-agent-runner.service:LockPersonality|pulse-agent-runner.service:RestrictSUIDSGID|pulse-agent-runner.service:ProtectHome) printf 'yes\n' ;;
|
||||
pulse-agent-runner.service:PrivateDevices) printf 'no\n' ;;
|
||||
pulse-agent-runner.service:SystemCallArchitectures) printf 'native\n' ;;
|
||||
pulse-agent-runner.service:PrivateNetwork|pulse-agent-runner.service:ProtectSystem) printf 'no\n' ;;
|
||||
pulse-agent-runner.service:RestrictAddressFamilies) printf 'AF_INET6 AF_UNIX AF_INET\n' ;;
|
||||
pulse-agent-runner.service:ReadWritePaths) printf '/var/lib/pulse-agent-runner\n' ;;
|
||||
pulse-agent-runner.service:EnvironmentFiles) printf '/etc/pulse-agent-runner/runner.env (ignore_errors=no)\n' ;;
|
||||
*) printf '\n' ;;
|
||||
esac
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
func mustMkdirAll(t *testing.T, dirs ...string) {
|
||||
t.Helper()
|
||||
for _, dir := range dirs {
|
||||
|
||||
@@ -43,9 +43,9 @@ package installtests
|
||||
// PULSE_SECURE_RUNTIME_HELPER="$repo/.lab-artifacts/pulse-agent-helper" \
|
||||
// PULSE_SECURE_RUNTIME_RUNNER="$repo/.lab-artifacts/pulse-agent-runner" \
|
||||
// PULSE_SECURE_RUNTIME_RECEIPT=/tmp/secure-runtime-receipt.json \
|
||||
// PULSE_SECURE_RUNTIME_RECEIPT_RECORD_PATH=docs/release-control/v6/internal/records/secure-agent-runtime-systemd-receipt-v5.json \
|
||||
// PULSE_SECURE_RUNTIME_RECEIPT_RECORD_PATH=docs/release-control/v6/internal/records/secure-agent-runtime-systemd-receipt-v6.json \
|
||||
// PULSE_SECURE_RUNTIME_TRANSCRIPT=/tmp/secure-runtime-transcript.jsonl \
|
||||
// PULSE_SECURE_RUNTIME_TRANSCRIPT_RECORD_PATH=docs/release-control/v6/internal/records/secure-agent-runtime-systemd-transcript-v5.jsonl \
|
||||
// PULSE_SECURE_RUNTIME_TRANSCRIPT_RECORD_PATH=docs/release-control/v6/internal/records/secure-agent-runtime-systemd-transcript-v6.jsonl \
|
||||
// sh -c 'cd "$1/scripts/installtests" && exec "$1/.lab-artifacts/installtests-linux-arm64.test" -test.run "^TestSecureRuntimeSystemdLab$" -test.count=1 -test.v' sh "$repo"
|
||||
//
|
||||
// Use the VM's GOARCH in place of arm64 when qualifying another architecture.
|
||||
@@ -70,6 +70,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -129,6 +130,12 @@ var secureRuntimeScenarioClaims = map[string][]string{
|
||||
"legacy_root_command_capable_install": {"legacy_root_command_authority_observed"},
|
||||
"read_only_inspect": {"inspection_left_stable_files_unchanged"},
|
||||
"drop_in_fail_closed_rehearsal": {"drop_in_rejected_before_mutation"},
|
||||
"helper_service_override_rejection": {"helper_service_effective_override_detected"},
|
||||
"helper_resource_limit_override_rejection": {
|
||||
"helper_resource_limits_enforced",
|
||||
"helper_resource_limit_override_detected",
|
||||
},
|
||||
"helper_socket_override_rejection": {"helper_socket_effective_override_detected"},
|
||||
"safe_profile_apply": {
|
||||
"collector_non_root",
|
||||
"collector_monitoring_only",
|
||||
@@ -155,7 +162,12 @@ var secureRuntimeScenarioClaims = map[string][]string{
|
||||
"prior_active_binary_restored_from_rollback_slot",
|
||||
"collector_reporting_resumed_after_helper_recovery",
|
||||
},
|
||||
"separate_action_runner_install": {"action_runner_registered_separately"},
|
||||
"separate_action_runner_install": {"action_runner_registered_separately"},
|
||||
"action_runner_override_rejection": {"action_runner_effective_override_detected"},
|
||||
"helper_network_namespace_isolation": {
|
||||
"helper_host_interface_tcp_denied",
|
||||
"helper_network_namespace_isolated",
|
||||
},
|
||||
"typed_action_receipt": {
|
||||
"typed_mutation_verified",
|
||||
"terminal_receipt_replayed",
|
||||
@@ -225,7 +237,14 @@ func (f *secureRuntimeLabFixture) admitActionRunner(secret, agentID, hostname st
|
||||
func (f *secureRuntimeLabFixture) validateActionRunnerSession(admission agentexec.AgentAdmission) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return !f.actionRevoked && admission == f.actionAdmissionLocked()
|
||||
expected := f.actionAdmissionLocked()
|
||||
return !f.actionRevoked &&
|
||||
admission.OrganizationID == expected.OrganizationID &&
|
||||
admission.TokenID == expected.TokenID &&
|
||||
admission.AgentID == expected.AgentID &&
|
||||
admission.Hostname == expected.Hostname &&
|
||||
admission.RuntimeRole == expected.RuntimeRole &&
|
||||
admission.ActionCapability == expected.ActionCapability
|
||||
}
|
||||
|
||||
func (f *secureRuntimeLabFixture) actionAdmissionLocked() agentexec.AgentAdmission {
|
||||
@@ -921,6 +940,21 @@ func TestSecureRuntimeSourceManifestCoversTransitiveProviders(t *testing.T) {
|
||||
"internal/securityutil/secure_storage_dir.go",
|
||||
"pkg/auth/scopes.go",
|
||||
"pkg/securityutil/httpurl.go",
|
||||
"scripts/release_control/secure_runtime_attestation.py",
|
||||
"scripts/release_control/secure_runtime_attestation_v6.py",
|
||||
".github/workflows/build-release-candidate.yml",
|
||||
".github/workflows/compile-release-payload.yml",
|
||||
".github/workflows/create-release.yml",
|
||||
"scripts/build-release-binaries.sh",
|
||||
"scripts/build-release.sh",
|
||||
"scripts/release_asset_common.sh",
|
||||
"scripts/release_build_targets.sh",
|
||||
"scripts/release_candidate_manifest.py",
|
||||
"scripts/release_ldflags.sh",
|
||||
"scripts/release_update_key.go",
|
||||
"scripts/require-safe-gh-attestation.sh",
|
||||
"scripts/validate-release.sh",
|
||||
"scripts/verify-github-release-integrity.sh",
|
||||
} {
|
||||
if _, ok := hashes[required]; !ok {
|
||||
t.Fatalf("secure-runtime source manifest omitted transitive boundary source %s", required)
|
||||
@@ -1016,7 +1050,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) {
|
||||
startedAt := time.Now().UTC()
|
||||
sourceManifest, sourceHashes := secureRuntimeLoadSourceBoundary(t, runtime.GOARCH)
|
||||
receipt := secureRuntimeLabReceipt{
|
||||
SchemaVersion: 5,
|
||||
SchemaVersion: 6,
|
||||
RecordPath: recordPath,
|
||||
StartedAt: startedAt.Format(time.RFC3339Nano),
|
||||
SourceManifest: sourceManifest,
|
||||
@@ -1181,6 +1215,30 @@ func TestSecureRuntimeSystemdLab(t *testing.T) {
|
||||
secureRuntimeWaitForReports(t, fixture, len(reportsBeforeContinuity)+1, 20*time.Second)
|
||||
pass("final_safe_profile_apply", "collector continued reporting after committed migration", map[string]any{"collector_service_user": "pulse-agent", "continuity_report_observed": true})
|
||||
|
||||
helperServiceRejection := secureRuntimeExerciseUnitOverrideDetection(t,
|
||||
"pulse-agent-helper.service", "Service", "PrivateNetwork=false", false)
|
||||
pass("helper_service_override_rejection", "effective helper service validation rejected an existing systemd drop-in", map[string]any{
|
||||
"override_directive": "PrivateNetwork=false",
|
||||
"validation_error": helperServiceRejection,
|
||||
})
|
||||
helperResourceRejection := secureRuntimeExerciseUnitOverrideDetection(t,
|
||||
"pulse-agent-helper.service", "Service", "TasksMax=infinity", false)
|
||||
pass("helper_resource_limit_override_rejection", "effective helper service validation enforced bounded task, descriptor, and memory resources and rejected an unbounded task override", map[string]any{
|
||||
"override_directive": "TasksMax=infinity",
|
||||
"tasks_max": "64",
|
||||
"limit_nofile": "256",
|
||||
"memory_max_bytes": "268435456",
|
||||
"validation_error": helperResourceRejection,
|
||||
})
|
||||
helperSocketRejection := secureRuntimeExerciseUnitOverrideDetection(t,
|
||||
"pulse-agent-helper.socket", "Socket", "SocketMode=0666", false)
|
||||
pass("helper_socket_override_rejection", "effective helper socket validation rejected an existing systemd drop-in", map[string]any{
|
||||
"override_directive": "SocketMode=0666",
|
||||
"validation_error": helperSocketRejection,
|
||||
})
|
||||
helperNetworkObservations := secureRuntimeAssertHelperOutboundNetworkDenied(t)
|
||||
pass("helper_network_namespace_isolation", "the helper network namespace could not establish TCP to a host-interface canary that was reachable from the host namespace", helperNetworkObservations)
|
||||
|
||||
collectorV2SHA256 := secureRuntimeHash(collectorV2)
|
||||
collectorV3SHA256 := secureRuntimeHash(collectorV3)
|
||||
collectorV4SHA256 := secureRuntimeHash(collectorV4)
|
||||
@@ -1292,6 +1350,12 @@ func TestSecureRuntimeSystemdLab(t *testing.T) {
|
||||
}
|
||||
secureRuntimeWaitForReports(t, fixture, len(reportsBeforeRunner)+1, 20*time.Second)
|
||||
pass("separate_action_runner_install", "root action runner registered and activated independently while the collector remained non-root and reporting", map[string]any{"runner_service_user": "root", "collector_service_user": "pulse-agent", "fixture_activation_requests": 1})
|
||||
runnerOverrideRejection := secureRuntimeExerciseUnitOverrideDetection(t,
|
||||
"pulse-agent-runner.service", "Service", "EnvironmentFile=-/tmp/unsafe-runner.env", true)
|
||||
pass("action_runner_override_rejection", "effective action-runner validation rejected an existing systemd drop-in", map[string]any{
|
||||
"override_directive": "EnvironmentFile=-/tmp/unsafe-runner.env",
|
||||
"validation_error": runnerOverrideRejection,
|
||||
})
|
||||
|
||||
actionContext, cancelAction := context.WithTimeout(agentexec.WithOrganizationID(context.Background(), secureRuntimeLabOrgID), 30*time.Second)
|
||||
defer cancelAction()
|
||||
@@ -1645,13 +1709,13 @@ func secureRuntimeLoadSourceBoundary(t *testing.T, targetArch string) (secureRun
|
||||
if err != nil {
|
||||
t.Fatalf("resolve repository root: %v", err)
|
||||
}
|
||||
manifestRelative := "scripts/release_control/secure_runtime_source_manifest_v5.json"
|
||||
manifestRelative := "scripts/release_control/secure_runtime_source_manifest_v6.json"
|
||||
manifestRaw := secureRuntimeReadFile(t, filepath.Join(repoRoot, filepath.FromSlash(manifestRelative)))
|
||||
var manifest secureRuntimeSourceManifest
|
||||
if err := json.Unmarshal(manifestRaw, &manifest); err != nil {
|
||||
t.Fatalf("decode secure-runtime source manifest: %v", err)
|
||||
}
|
||||
if manifest.SchemaVersion != 1 || manifest.ManifestID != "secure-runtime-linux-v5" || manifest.TargetOS != "linux" {
|
||||
if manifest.SchemaVersion != 1 || manifest.ManifestID != "secure-runtime-linux-v6" || manifest.TargetOS != "linux" {
|
||||
t.Fatalf("unsupported secure-runtime source manifest: %+v", manifest)
|
||||
}
|
||||
sourceHashes := make(map[string]string)
|
||||
@@ -1850,17 +1914,227 @@ func secureRuntimeCommand(t *testing.T, timeout time.Duration, name string, args
|
||||
|
||||
func secureRuntimeSystemdProperty(t *testing.T, property string) string {
|
||||
t.Helper()
|
||||
return secureRuntimeCommand(t, 10*time.Second, "systemctl", "show", "pulse-agent.service", "--property="+property, "--value")
|
||||
return secureRuntimeUnitProperty(t, "pulse-agent.service", property)
|
||||
}
|
||||
|
||||
func secureRuntimeActionRunnerSystemdProperty(t *testing.T, property string) string {
|
||||
t.Helper()
|
||||
return secureRuntimeCommand(t, 10*time.Second, "systemctl", "show", "pulse-agent-runner.service", "--property="+property, "--value")
|
||||
return secureRuntimeUnitProperty(t, "pulse-agent-runner.service", property)
|
||||
}
|
||||
|
||||
func secureRuntimeUnitProperty(t *testing.T, unit, property string) string {
|
||||
t.Helper()
|
||||
return secureRuntimeCommand(t, 10*time.Second, "systemctl", "show", unit, "--property="+property, "--value")
|
||||
}
|
||||
|
||||
func secureRuntimeReadUnitProperty(unit, property string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, "systemctl", "show", unit, "--property="+property, "--value").CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("systemctl show %s %s: %w: %s", unit, property, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
func secureRuntimeCheckUnitProperty(unit, property, expected string) error {
|
||||
actual, err := secureRuntimeReadUnitProperty(unit, property)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if actual != expected {
|
||||
return fmt.Errorf("%s effective %s = %q, want %q", unit, property, actual, expected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func secureRuntimeCheckUnitWordSet(unit, property string, expected ...string) error {
|
||||
actual, err := secureRuntimeReadUnitProperty(unit, property)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actualWords := strings.Fields(actual)
|
||||
expectedWords := append([]string(nil), expected...)
|
||||
sort.Strings(actualWords)
|
||||
sort.Strings(expectedWords)
|
||||
if strings.Join(actualWords, "\x00") != strings.Join(expectedWords, "\x00") {
|
||||
return fmt.Errorf("%s effective %s words = %q, want %q", unit, property, actualWords, expectedWords)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func secureRuntimeExecStartArgv(value string) []string {
|
||||
const marker = "argv[]="
|
||||
start := strings.Index(value, marker)
|
||||
if start < 0 {
|
||||
return nil
|
||||
}
|
||||
value = value[start+len(marker):]
|
||||
if end := strings.Index(value, " ;"); end >= 0 {
|
||||
value = value[:end]
|
||||
}
|
||||
return strings.Fields(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func secureRuntimeCheckExactExecStart(unit, expectedBinary string) error {
|
||||
actual, err := secureRuntimeReadUnitProperty(unit, "ExecStart")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
argv := secureRuntimeExecStartArgv(actual)
|
||||
if len(argv) != 1 || argv[0] != expectedBinary {
|
||||
return fmt.Errorf("%s effective ExecStart argv = %q, want only %q", unit, argv, expectedBinary)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func secureRuntimeCheckInstallerUnitBoundary(unit, expectedFragment string) error {
|
||||
if err := secureRuntimeCheckUnitProperty(unit, "FragmentPath", expectedFragment); err != nil {
|
||||
return err
|
||||
}
|
||||
return secureRuntimeCheckUnitProperty(unit, "DropInPaths", "")
|
||||
}
|
||||
|
||||
func secureRuntimeCheckSafeProfileSystemd(includeRunner bool) error {
|
||||
for _, check := range []struct {
|
||||
unit, property, expected string
|
||||
}{
|
||||
{"pulse-agent.service", "FragmentPath", "/etc/systemd/system/pulse-agent.service"},
|
||||
{"pulse-agent.service", "DropInPaths", ""},
|
||||
{"pulse-agent.service", "User", "pulse-agent"},
|
||||
{"pulse-agent.service", "AmbientCapabilities", ""},
|
||||
{"pulse-agent.service", "UMask", "0077"},
|
||||
{"pulse-agent.service", "NoNewPrivileges", "yes"},
|
||||
{"pulse-agent.service", "PrivateTmp", "yes"},
|
||||
{"pulse-agent.service", "PrivateDevices", "no"},
|
||||
{"pulse-agent.service", "ProtectKernelTunables", "yes"},
|
||||
{"pulse-agent.service", "ProtectKernelModules", "yes"},
|
||||
{"pulse-agent.service", "ProtectControlGroups", "yes"},
|
||||
{"pulse-agent.service", "LockPersonality", "yes"},
|
||||
{"pulse-agent.service", "RestrictSUIDSGID", "yes"},
|
||||
{"pulse-agent.service", "SystemCallArchitectures", "native"},
|
||||
{"pulse-agent-helper.service", "FragmentPath", "/etc/systemd/system/pulse-agent-helper.service"},
|
||||
{"pulse-agent-helper.service", "DropInPaths", ""},
|
||||
{"pulse-agent-helper.service", "User", "root"},
|
||||
{"pulse-agent-helper.service", "Group", "root"},
|
||||
{"pulse-agent-helper.service", "AmbientCapabilities", ""},
|
||||
{"pulse-agent-helper.service", "UMask", "0077"},
|
||||
{"pulse-agent-helper.service", "NoNewPrivileges", "yes"},
|
||||
{"pulse-agent-helper.service", "PrivateTmp", "yes"},
|
||||
{"pulse-agent-helper.service", "PrivateDevices", "no"},
|
||||
{"pulse-agent-helper.service", "PrivateNetwork", "yes"},
|
||||
{"pulse-agent-helper.service", "ProtectSystem", "strict"},
|
||||
{"pulse-agent-helper.service", "ProtectHome", "yes"},
|
||||
{"pulse-agent-helper.service", "ProtectKernelTunables", "yes"},
|
||||
{"pulse-agent-helper.service", "ProtectKernelModules", "yes"},
|
||||
{"pulse-agent-helper.service", "ProtectControlGroups", "yes"},
|
||||
{"pulse-agent-helper.service", "LockPersonality", "yes"},
|
||||
{"pulse-agent-helper.service", "RestrictSUIDSGID", "yes"},
|
||||
{"pulse-agent-helper.service", "SystemCallArchitectures", "native"},
|
||||
{"pulse-agent-helper.service", "TasksMax", "64"},
|
||||
{"pulse-agent-helper.service", "LimitNOFILE", "256"},
|
||||
{"pulse-agent-helper.service", "MemoryMax", "268435456"},
|
||||
{"pulse-agent-helper.socket", "FragmentPath", "/etc/systemd/system/pulse-agent-helper.socket"},
|
||||
{"pulse-agent-helper.socket", "DropInPaths", ""},
|
||||
{"pulse-agent-helper.socket", "SocketUser", "root"},
|
||||
{"pulse-agent-helper.socket", "SocketGroup", "pulse-agent"},
|
||||
{"pulse-agent-helper.socket", "SocketMode", "0660"},
|
||||
{"pulse-agent-helper.socket", "DirectoryMode", "0755"},
|
||||
{"pulse-agent-helper.socket", "RemoveOnStop", "yes"},
|
||||
} {
|
||||
if err := secureRuntimeCheckUnitProperty(check.unit, check.property, check.expected); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
collectorExec, err := secureRuntimeReadUnitProperty("pulse-agent.service", "ExecStart")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
collectorArgv := secureRuntimeExecStartArgv(collectorExec)
|
||||
if len(collectorArgv) == 0 || collectorArgv[0] != "/usr/local/bin/pulse-agent" {
|
||||
return fmt.Errorf("pulse-agent.service effective ExecStart argv = %q", collectorArgv)
|
||||
}
|
||||
for _, argument := range collectorArgv[1:] {
|
||||
if argument == "--enable-commands" {
|
||||
return errors.New("pulse-agent.service effective ExecStart retained --enable-commands")
|
||||
}
|
||||
}
|
||||
collectorEnvironment, err := secureRuntimeReadUnitProperty("pulse-agent.service", "Environment")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(collectorEnvironment, "PULSE_AGENT_HELPER_SOCKET=/run/pulse-agent/helper.sock") {
|
||||
return fmt.Errorf("pulse-agent.service effective Environment lacks the typed-helper socket: %q", collectorEnvironment)
|
||||
}
|
||||
if err := secureRuntimeCheckExactExecStart("pulse-agent-helper.service", "/usr/local/lib/pulse-agent/pulse-agent-helper"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := secureRuntimeCheckUnitWordSet("pulse-agent-helper.service", "RestrictAddressFamilies", "AF_UNIX"); err != nil {
|
||||
return err
|
||||
}
|
||||
listen, err := secureRuntimeReadUnitProperty("pulse-agent-helper.socket", "Listen")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(listen, "/run/pulse-agent/helper.sock") || !strings.Contains(listen, "Stream") {
|
||||
return fmt.Errorf("pulse-agent-helper.socket effective Listen = %q", listen)
|
||||
}
|
||||
if !includeRunner {
|
||||
return nil
|
||||
}
|
||||
for _, check := range []struct {
|
||||
property, expected string
|
||||
}{
|
||||
{"FragmentPath", "/etc/systemd/system/pulse-agent-runner.service"},
|
||||
{"DropInPaths", ""},
|
||||
{"User", "root"},
|
||||
{"Group", "root"},
|
||||
{"AmbientCapabilities", ""},
|
||||
{"UMask", "0077"},
|
||||
{"NoNewPrivileges", "yes"},
|
||||
{"PrivateTmp", "yes"},
|
||||
{"PrivateDevices", "no"},
|
||||
{"PrivateNetwork", "no"},
|
||||
{"ProtectHome", "yes"},
|
||||
{"ProtectSystem", "no"},
|
||||
{"ProtectKernelTunables", "yes"},
|
||||
{"ProtectKernelModules", "yes"},
|
||||
{"ProtectControlGroups", "yes"},
|
||||
{"LockPersonality", "yes"},
|
||||
{"RestrictSUIDSGID", "yes"},
|
||||
{"SystemCallArchitectures", "native"},
|
||||
} {
|
||||
if err := secureRuntimeCheckUnitProperty("pulse-agent-runner.service", check.property, check.expected); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := secureRuntimeCheckExactExecStart("pulse-agent-runner.service", "/usr/local/lib/pulse-agent/pulse-agent-runner"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := secureRuntimeCheckUnitWordSet("pulse-agent-runner.service", "RestrictAddressFamilies", "AF_UNIX", "AF_INET", "AF_INET6"); err != nil {
|
||||
return err
|
||||
}
|
||||
environmentFiles, err := secureRuntimeReadUnitProperty("pulse-agent-runner.service", "EnvironmentFiles")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if environmentFiles != "/etc/pulse-agent-runner/runner.env (ignore_errors=no)" {
|
||||
return fmt.Errorf("pulse-agent-runner.service effective EnvironmentFiles = %q", environmentFiles)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func secureRuntimeAssertSafeProfileSystemd(t *testing.T, includeRunner bool) {
|
||||
t.Helper()
|
||||
if err := secureRuntimeCheckSafeProfileSystemd(includeRunner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func secureRuntimeAssertActionRunnerInstalled(t *testing.T) {
|
||||
t.Helper()
|
||||
secureRuntimeCommand(t, 10*time.Second, "systemctl", "is-active", "pulse-agent-runner.service")
|
||||
secureRuntimeAssertSafeProfileSystemd(t, true)
|
||||
if user := secureRuntimeActionRunnerSystemdProperty(t, "User"); user != "root" {
|
||||
t.Fatalf("action-runner systemd User = %q, want root", user)
|
||||
}
|
||||
@@ -1961,6 +2235,7 @@ func secureRuntimeAssertRootMonitoringProfile(t *testing.T) {
|
||||
|
||||
func secureRuntimeAssertSafeProfile(t *testing.T) {
|
||||
t.Helper()
|
||||
secureRuntimeAssertSafeProfileSystemd(t, false)
|
||||
if user := secureRuntimeSystemdProperty(t, "User"); user != "pulse-agent" {
|
||||
t.Fatalf("safe collector systemd User = %q, want pulse-agent", user)
|
||||
}
|
||||
@@ -1997,6 +2272,135 @@ func secureRuntimeAssertSafeProfile(t *testing.T) {
|
||||
secureRuntimeCommand(t, 10*time.Second, "systemctl", "is-active", "pulse-agent-helper.socket")
|
||||
}
|
||||
|
||||
func secureRuntimeExerciseUnitOverrideDetection(t *testing.T, unit, section, directive string, includeRunner bool) string {
|
||||
t.Helper()
|
||||
dropInDir := filepath.Join("/etc/systemd/system", unit+".d")
|
||||
dropInPath := filepath.Join(dropInDir, "secure-runtime-adversarial.conf")
|
||||
if err := os.MkdirAll(dropInDir, 0o755); err != nil {
|
||||
t.Fatalf("create %s drop-in directory: %v", unit, err)
|
||||
}
|
||||
if err := os.WriteFile(dropInPath, []byte("["+section+"]\n"+directive+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write %s adversarial drop-in: %v", unit, err)
|
||||
}
|
||||
removed := false
|
||||
t.Cleanup(func() {
|
||||
if !removed {
|
||||
_ = os.Remove(dropInPath)
|
||||
_ = os.Remove(dropInDir)
|
||||
_ = exec.Command("systemctl", "daemon-reload").Run()
|
||||
}
|
||||
})
|
||||
secureRuntimeCommand(t, 20*time.Second, "systemctl", "daemon-reload")
|
||||
err := secureRuntimeCheckSafeProfileSystemd(includeRunner)
|
||||
if err == nil {
|
||||
t.Fatalf("effective systemd validation accepted %s drop-in %q", unit, directive)
|
||||
}
|
||||
dropIns, propertyErr := secureRuntimeReadUnitProperty(unit, "DropInPaths")
|
||||
if propertyErr != nil {
|
||||
t.Fatal(propertyErr)
|
||||
}
|
||||
if !strings.Contains(dropIns, dropInPath) {
|
||||
t.Fatalf("%s effective DropInPaths = %q, want %s", unit, dropIns, dropInPath)
|
||||
}
|
||||
if err := os.Remove(dropInPath); err != nil {
|
||||
t.Fatalf("remove %s adversarial drop-in: %v", unit, err)
|
||||
}
|
||||
_ = os.Remove(dropInDir)
|
||||
removed = true
|
||||
secureRuntimeCommand(t, 20*time.Second, "systemctl", "daemon-reload")
|
||||
secureRuntimeAssertSafeProfileSystemd(t, includeRunner)
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func secureRuntimeNonLoopbackIPv4(t *testing.T) string {
|
||||
t.Helper()
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
t.Fatalf("list host interfaces: %v", err)
|
||||
}
|
||||
for _, networkInterface := range interfaces {
|
||||
if networkInterface.Flags&net.FlagUp == 0 || networkInterface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
addresses, err := networkInterface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, address := range addresses {
|
||||
var ip net.IP
|
||||
switch typed := address.(type) {
|
||||
case *net.IPNet:
|
||||
ip = typed.IP
|
||||
case *net.IPAddr:
|
||||
ip = typed.IP
|
||||
}
|
||||
if ipv4 := ip.To4(); ipv4 != nil && !ipv4.IsLoopback() {
|
||||
return ipv4.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatal("disposable systemd host has no non-loopback IPv4 address for the helper network canary")
|
||||
return ""
|
||||
}
|
||||
|
||||
func secureRuntimeAssertHelperOutboundNetworkDenied(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
secureRuntimeAssertHelperProtocol(t)
|
||||
hostIP := secureRuntimeNonLoopbackIPv4(t)
|
||||
listener, err := net.Listen("tcp4", "0.0.0.0:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen for helper network-isolation canary: %v", err)
|
||||
}
|
||||
server := &http.Server{Handler: http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
})}
|
||||
serveDone := make(chan error, 1)
|
||||
go func() { serveDone <- server.Serve(listener) }()
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(ctx)
|
||||
<-serveDone
|
||||
})
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
canaryURL := fmt.Sprintf("http://%s/secure-runtime-network-canary", net.JoinHostPort(hostIP, strconv.Itoa(port)))
|
||||
hostClient := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
Proxy: nil,
|
||||
},
|
||||
}
|
||||
response, err := hostClient.Get(canaryURL)
|
||||
if err != nil {
|
||||
t.Fatalf("host network could not reach its canary %s: %v", canaryURL, err)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
if response.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("host network canary status = %s", response.Status)
|
||||
}
|
||||
mainPID := secureRuntimeUnitProperty(t, "pulse-agent-helper.service", "MainPID")
|
||||
if mainPID == "" || mainPID == "0" {
|
||||
t.Fatalf("typed helper has no live MainPID: %q", mainPID)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx,
|
||||
"nsenter", "--target", mainPID, "--net", "--",
|
||||
"curl", "--noproxy", "*", "-fsS", "--connect-timeout", "2", "--max-time", "3", canaryURL,
|
||||
).CombinedOutput()
|
||||
secureRuntimeAssertNoCredentialExposure(t, out)
|
||||
secureRuntimeRecordCommandOutput("helper namespace outbound TCP canary", out)
|
||||
if err == nil {
|
||||
t.Fatalf("helper network namespace reached host-interface canary %s", canaryURL)
|
||||
}
|
||||
return map[string]any{
|
||||
"canary_scope": "host-interface-tcp",
|
||||
"host_canary_reachable": true,
|
||||
"helper_namespace_connection": "denied",
|
||||
"helper_main_pid": mainPID,
|
||||
}
|
||||
}
|
||||
|
||||
func secureRuntimeStableFileIdentity(t *testing.T, path string) secureRuntimeFileIdentity {
|
||||
t.Helper()
|
||||
info, err := os.Stat(path)
|
||||
|
||||
@@ -768,6 +768,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"internal/agentupdate/coverage_test.go",
|
||||
"internal/hostagent/action_runner_client_test.go",
|
||||
"internal/hostagent/agent_flushbuffer_test.go",
|
||||
"internal/hostagent/agent_metrics_test.go",
|
||||
"internal/hostagent/agent_new_test.go",
|
||||
@@ -2430,6 +2431,7 @@ None yet.
|
||||
"frontend-modern/src/components/Alerts/__tests__/ThresholdsTable.test.tsx",
|
||||
"frontend-modern/src/features/alerts/AlertDeliveryHealthCard.test.tsx",
|
||||
"frontend-modern/src/features/alerts/__tests__/AlertDeadManDestinationSection.test.tsx",
|
||||
"frontend-modern/src/features/alerts/__tests__/AlertIncidentSynthesisSummary.test.tsx",
|
||||
"frontend-modern/src/features/alerts/__tests__/AlertIntentPolicyPanel.test.tsx",
|
||||
"frontend-modern/src/features/alerts/__tests__/OverviewTab.emptystate.test.tsx",
|
||||
"frontend-modern/src/features/alerts/__tests__/OverviewTab.timelineerror.test.tsx",
|
||||
@@ -2440,6 +2442,7 @@ None yet.
|
||||
"frontend-modern/src/features/alerts/__tests__/useAlertDestinationsState.test.tsx",
|
||||
"frontend-modern/src/features/alerts/__tests__/useAlertDestinationsTabState.test.tsx",
|
||||
"frontend-modern/src/features/alerts/__tests__/useAlertOverridesState.test.tsx",
|
||||
"frontend-modern/src/features/alerts/__tests__/useAlertOverviewState.test.tsx",
|
||||
"frontend-modern/src/features/alerts/identity.test.ts",
|
||||
"frontend-modern/src/features/alerts/thresholds/__tests__/helpers.test.ts",
|
||||
"frontend-modern/src/features/alerts/thresholds/hooks/__tests__/truenasThresholdPersistence.test.tsx",
|
||||
|
||||
@@ -0,0 +1,870 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify schema-v6 secure-runtime systemd evidence and provenance."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import binascii
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Iterator, Sequence
|
||||
|
||||
import secure_runtime_attestation as v5
|
||||
|
||||
|
||||
RECEIPT_SCHEMA_VERSION = 6
|
||||
ATTESTATION_SCHEMA_VERSION = 6
|
||||
SOURCE_MANIFEST_PATH = "scripts/release_control/secure_runtime_source_manifest_v6.json"
|
||||
SOURCE_MANIFEST_SCHEMA_VERSION = 1
|
||||
SOURCE_MANIFEST_ID = "secure-runtime-linux-v6"
|
||||
ATTESTATION_TOOL_PATH = "scripts/release_control/secure_runtime_attestation_v6.py"
|
||||
CANONICAL_REPOSITORY = "rcourtman/Pulse"
|
||||
CANONICAL_ORIGIN_URL = "https://github.com/rcourtman/Pulse.git"
|
||||
CANONICAL_MAIN_REF = "origin/main"
|
||||
ASSEMBLY_SIGNER_WORKFLOW = "github.com/rcourtman/Pulse/.github/workflows/build-release-candidate.yml"
|
||||
COMPILER_SIGNER_WORKFLOW = "github.com/rcourtman/Pulse/.github/workflows/compile-release-payload.yml"
|
||||
RELEASE_TAG_RE = re.compile(r"^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[1-9][0-9]*$")
|
||||
UPDATE_KEY_FINGERPRINT_RE = re.compile(r"^SHA256:[A-Za-z0-9+/]{43}=$")
|
||||
GO_VERSION_RE = re.compile(r"^go1\.[0-9]+(?:\.[0-9]+)?$")
|
||||
BUILD_CONTRACT_NAME = "secure-runtime-build-contract-v1.json"
|
||||
CHECKSUMS_NAME = "checksums.txt"
|
||||
ASSEMBLY_PROVENANCE_NAME = "release-build-provenance.sigstore.json"
|
||||
COMPILER_PROVENANCE_NAME = "secure-runtime-compiler-provenance.sigstore.json"
|
||||
|
||||
REQUIRED_SCENARIOS = (
|
||||
"legacy_root_command_capable_install",
|
||||
"read_only_inspect",
|
||||
"drop_in_fail_closed_rehearsal",
|
||||
"safe_profile_apply",
|
||||
"explicit_safe_profile_rollback",
|
||||
"automatic_failure_rollback",
|
||||
"ordinary_update_non_migration",
|
||||
"final_safe_profile_apply",
|
||||
"helper_service_override_rejection",
|
||||
"helper_resource_limit_override_rejection",
|
||||
"helper_socket_override_rejection",
|
||||
"helper_network_namespace_isolation",
|
||||
"helper_update_authoritative_commit",
|
||||
"helper_update_watchdog_rollback",
|
||||
"helper_update_interrupted_recovery",
|
||||
"separate_action_runner_install",
|
||||
"action_runner_override_rejection",
|
||||
"typed_action_receipt",
|
||||
"action_runner_credential_rotation",
|
||||
"action_runner_self_revoke",
|
||||
)
|
||||
SCENARIO_REQUIRED_CLAIMS = {
|
||||
**v5.SCENARIO_REQUIRED_CLAIMS,
|
||||
"helper_service_override_rejection": {"helper_service_effective_override_detected"},
|
||||
"helper_resource_limit_override_rejection": {
|
||||
"helper_resource_limits_enforced",
|
||||
"helper_resource_limit_override_detected",
|
||||
},
|
||||
"helper_socket_override_rejection": {"helper_socket_effective_override_detected"},
|
||||
"helper_network_namespace_isolation": {
|
||||
"helper_host_interface_tcp_denied",
|
||||
"helper_network_namespace_isolated",
|
||||
},
|
||||
"action_runner_override_rejection": {"action_runner_effective_override_detected"},
|
||||
}
|
||||
SCENARIO_REQUIRED_OBSERVATIONS = {
|
||||
**v5.SCENARIO_REQUIRED_OBSERVATIONS,
|
||||
"helper_service_override_rejection": {"override_directive": "PrivateNetwork=false"},
|
||||
"helper_resource_limit_override_rejection": {
|
||||
"override_directive": "TasksMax=infinity",
|
||||
"tasks_max": "64",
|
||||
"limit_nofile": "256",
|
||||
"memory_max_bytes": "268435456",
|
||||
},
|
||||
"helper_socket_override_rejection": {"override_directive": "SocketMode=0666"},
|
||||
"helper_network_namespace_isolation": {
|
||||
"canary_scope": "host-interface-tcp",
|
||||
"host_canary_reachable": True,
|
||||
"helper_namespace_connection": "denied",
|
||||
},
|
||||
"action_runner_override_rejection": {
|
||||
"override_directive": "EnvironmentFile=-/tmp/unsafe-runner.env"
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def v6_contract() -> Iterator[None]:
|
||||
replacements = {
|
||||
"RECEIPT_SCHEMA_VERSION": RECEIPT_SCHEMA_VERSION,
|
||||
"SOURCE_MANIFEST_PATH": SOURCE_MANIFEST_PATH,
|
||||
"SOURCE_MANIFEST_SCHEMA_VERSION": SOURCE_MANIFEST_SCHEMA_VERSION,
|
||||
"SOURCE_MANIFEST_ID": SOURCE_MANIFEST_ID,
|
||||
"REQUIRED_SCENARIOS": REQUIRED_SCENARIOS,
|
||||
"SCENARIO_REQUIRED_CLAIMS": SCENARIO_REQUIRED_CLAIMS,
|
||||
"SCENARIO_REQUIRED_OBSERVATIONS": SCENARIO_REQUIRED_OBSERVATIONS,
|
||||
}
|
||||
previous = {name: getattr(v5, name) for name in replacements}
|
||||
try:
|
||||
for name, value in replacements.items():
|
||||
setattr(v5, name, value)
|
||||
yield
|
||||
finally:
|
||||
for name, value in previous.items():
|
||||
setattr(v5, name, value)
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
except OSError as exc:
|
||||
raise v5.AttestationError(f"unable to read {path}: {exc}") from exc
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def load_json_object(path: Path, label: str) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise v5.AttestationError(f"unable to read {label} {path}: {exc}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise v5.AttestationError(f"{label} must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def run_checked(command: Sequence[str], *, cwd: Path, label: str) -> subprocess.CompletedProcess[bytes]:
|
||||
try:
|
||||
return subprocess.run(command, cwd=cwd, check=True, capture_output=True)
|
||||
except (OSError, subprocess.CalledProcessError) as exc:
|
||||
detail = ""
|
||||
if isinstance(exc, subprocess.CalledProcessError):
|
||||
detail = exc.stderr.decode("utf-8", errors="replace").strip()
|
||||
suffix = f": {detail}" if detail else ""
|
||||
raise v5.AttestationError(f"{label} failed{suffix}") from exc
|
||||
|
||||
|
||||
def parse_remote_refs(raw: bytes) -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
for line in raw.decode("ascii", errors="strict").splitlines():
|
||||
fields = line.split("\t")
|
||||
if len(fields) != 2 or not v5.COMMIT_RE.fullmatch(fields[0]):
|
||||
raise v5.AttestationError("remote ref query returned malformed output")
|
||||
if fields[1] in result:
|
||||
raise v5.AttestationError("remote ref query returned duplicate refs")
|
||||
result[fields[1]] = fields[0]
|
||||
return result
|
||||
|
||||
|
||||
def verify_canonical_main_identity(checkout: Path, main_ref: str) -> str:
|
||||
if main_ref != CANONICAL_MAIN_REF:
|
||||
raise v5.AttestationError("committed-main classification requires canonical origin/main")
|
||||
origin = v5.run_git(checkout, "remote", "get-url", "origin").stdout.decode().strip()
|
||||
if origin != CANONICAL_ORIGIN_URL:
|
||||
raise v5.AttestationError("origin remote URL is not the canonical Pulse repository URL")
|
||||
local_main = v5.resolve_commit(checkout, CANONICAL_MAIN_REF, "canonical origin/main")
|
||||
remote = v5.run_git(checkout, "ls-remote", "origin", "refs/heads/main")
|
||||
remote_refs = parse_remote_refs(remote.stdout)
|
||||
if remote_refs != {"refs/heads/main": local_main}:
|
||||
raise v5.AttestationError("canonical origin/main does not match the remote main commit")
|
||||
return local_main
|
||||
|
||||
|
||||
def verify_release_candidate_tag_identity(
|
||||
checkout: Path,
|
||||
qualified_commit: str,
|
||||
tag: str,
|
||||
repository: str,
|
||||
) -> dict[str, str]:
|
||||
if not RELEASE_TAG_RE.fullmatch(tag):
|
||||
raise v5.AttestationError("release candidate identity must be an exact vX.Y.Z-rc.N tag")
|
||||
if repository != CANONICAL_REPOSITORY:
|
||||
raise v5.AttestationError("release candidate repository is not the canonical Pulse repository")
|
||||
origin = v5.run_git(checkout, "remote", "get-url", "origin").stdout.decode().strip()
|
||||
if origin != CANONICAL_ORIGIN_URL:
|
||||
raise v5.AttestationError("origin remote URL is not the canonical Pulse repository URL")
|
||||
ref = f"refs/tags/{tag}"
|
||||
local_object = v5.run_git(checkout, "rev-parse", "--verify", ref).stdout.decode().strip()
|
||||
if not v5.COMMIT_RE.fullmatch(local_object):
|
||||
raise v5.AttestationError("release candidate tag object is invalid")
|
||||
object_type = v5.run_git(checkout, "cat-file", "-t", ref).stdout.decode().strip()
|
||||
if object_type != "tag":
|
||||
raise v5.AttestationError("release candidate tag must be an annotated tag, not a lightweight ref")
|
||||
tag_body = v5.run_git(checkout, "cat-file", "tag", ref).stdout.decode("utf-8", errors="strict")
|
||||
if (
|
||||
f"\ntag {tag}\n" not in tag_body
|
||||
or not tag_body.endswith(f"\n\nRelease {tag}\n")
|
||||
or "BEGIN PGP SIGNATURE" in tag_body
|
||||
or "BEGIN SSH SIGNATURE" in tag_body
|
||||
):
|
||||
raise v5.AttestationError(
|
||||
"release candidate tag object is not the canonical unsigned workflow tag; tag signatures are not authority"
|
||||
)
|
||||
peeled_commit = v5.resolve_commit(checkout, ref, "release candidate tag")
|
||||
if peeled_commit != qualified_commit:
|
||||
raise v5.AttestationError("release candidate tag does not resolve to the qualified commit")
|
||||
remote = v5.run_git(
|
||||
checkout,
|
||||
"ls-remote",
|
||||
"--tags",
|
||||
"origin",
|
||||
ref,
|
||||
f"{ref}^{{}}",
|
||||
)
|
||||
remote_refs = parse_remote_refs(remote.stdout)
|
||||
if remote_refs != {ref: local_object, f"{ref}^{{}}": qualified_commit}:
|
||||
raise v5.AttestationError("remote release candidate tag object or peeled commit does not match locally")
|
||||
return {
|
||||
"tag": tag,
|
||||
"tag_object": local_object,
|
||||
"peeled_commit": qualified_commit,
|
||||
"origin_url": origin,
|
||||
"tag_authority": "immutable-signed-github-release-packet",
|
||||
}
|
||||
|
||||
|
||||
def parse_checksums(path: Path) -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
try:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
except OSError as exc:
|
||||
raise v5.AttestationError(f"unable to read release checksums: {exc}") from exc
|
||||
for line_number, line in enumerate(lines, 1):
|
||||
match = re.fullmatch(r"([0-9a-f]{64}) ([A-Za-z0-9][A-Za-z0-9._-]*)", line)
|
||||
if not match:
|
||||
raise v5.AttestationError(f"release checksums line {line_number} is not canonical")
|
||||
digest, name = match.groups()
|
||||
if name in result:
|
||||
raise v5.AttestationError(f"release checksums contain duplicate asset {name}")
|
||||
result[name] = digest
|
||||
if not result:
|
||||
raise v5.AttestationError("release checksums are empty")
|
||||
return result
|
||||
|
||||
|
||||
def require_canonical_sidecar(path: Path, expected_name: str) -> Path:
|
||||
if path.name != expected_name:
|
||||
raise v5.AttestationError(f"release sidecar must be a regular {expected_name} file")
|
||||
try:
|
||||
path_stat = path.lstat()
|
||||
except OSError as exc:
|
||||
raise v5.AttestationError(f"unable to inspect release sidecar {path}: {exc}") from exc
|
||||
if stat.S_ISLNK(path_stat.st_mode) or not stat.S_ISREG(path_stat.st_mode):
|
||||
raise v5.AttestationError(f"release sidecar must be a regular {expected_name} file")
|
||||
return Path(os.path.abspath(path))
|
||||
|
||||
|
||||
def copy_immutable_input(source: Path, destination: Path, label: str) -> str:
|
||||
source = Path(os.path.abspath(source))
|
||||
try:
|
||||
source_lstat = source.lstat()
|
||||
except OSError as exc:
|
||||
raise v5.AttestationError(f"{label} changed before it could be copied") from exc
|
||||
if stat.S_ISLNK(source_lstat.st_mode) or not stat.S_ISREG(source_lstat.st_mode):
|
||||
raise v5.AttestationError(f"{label} must be a regular file")
|
||||
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
source_fd = os.open(source, flags)
|
||||
except OSError as exc:
|
||||
raise v5.AttestationError(f"unable to open {label} {source}: {exc}") from exc
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
opened_stat = os.fstat(source_fd)
|
||||
if (
|
||||
not stat.S_ISREG(opened_stat.st_mode)
|
||||
or (opened_stat.st_dev, opened_stat.st_ino) != (source_lstat.st_dev, source_lstat.st_ino)
|
||||
):
|
||||
raise v5.AttestationError(f"{label} changed before it could be copied")
|
||||
try:
|
||||
with os.fdopen(source_fd, "rb", closefd=False) as source_handle, destination.open("xb") as target:
|
||||
for chunk in iter(lambda: source_handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
target.write(chunk)
|
||||
target.flush()
|
||||
os.fsync(target.fileno())
|
||||
except OSError as exc:
|
||||
raise v5.AttestationError(f"unable to snapshot {label}: {exc}") from exc
|
||||
completed_stat = os.fstat(source_fd)
|
||||
try:
|
||||
path_after = source.lstat()
|
||||
except OSError as exc:
|
||||
raise v5.AttestationError(f"{label} changed while it was copied") from exc
|
||||
stable_identity = (opened_stat.st_dev, opened_stat.st_ino, opened_stat.st_size, opened_stat.st_mtime_ns)
|
||||
if (
|
||||
stable_identity
|
||||
!= (completed_stat.st_dev, completed_stat.st_ino, completed_stat.st_size, completed_stat.st_mtime_ns)
|
||||
or (path_after.st_dev, path_after.st_ino) != (opened_stat.st_dev, opened_stat.st_ino)
|
||||
or stat.S_ISLNK(path_after.st_mode)
|
||||
):
|
||||
raise v5.AttestationError(f"{label} changed while it was copied")
|
||||
finally:
|
||||
os.close(source_fd)
|
||||
destination.chmod(0o400)
|
||||
copied_digest = sha256_file(destination)
|
||||
if copied_digest != digest.hexdigest():
|
||||
raise v5.AttestationError(f"{label} snapshot digest is inconsistent")
|
||||
return copied_digest
|
||||
|
||||
|
||||
def copy_release_sidecar(source: Path, destination: Path, expected_name: str) -> str:
|
||||
source = require_canonical_sidecar(source, expected_name)
|
||||
return copy_immutable_input(source, destination, f"release sidecar {expected_name}")
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def immutable_release_sidecar_snapshot(
|
||||
checksums_path: Path,
|
||||
assembly_provenance_path: Path,
|
||||
compiler_provenance_path: Path,
|
||||
build_contract_path: Path,
|
||||
) -> Iterator[tuple[dict[str, Path], dict[str, str]]]:
|
||||
sources = {
|
||||
CHECKSUMS_NAME: checksums_path,
|
||||
ASSEMBLY_PROVENANCE_NAME: assembly_provenance_path,
|
||||
COMPILER_PROVENANCE_NAME: compiler_provenance_path,
|
||||
BUILD_CONTRACT_NAME: build_contract_path,
|
||||
}
|
||||
with tempfile.TemporaryDirectory(prefix="pulse-secure-runtime-release-") as temporary:
|
||||
snapshot_root = Path(temporary)
|
||||
snapshot_root.chmod(0o700)
|
||||
snapshots: dict[str, Path] = {}
|
||||
digests: dict[str, str] = {}
|
||||
for name, source in sources.items():
|
||||
snapshot = snapshot_root / name
|
||||
digests[name] = copy_release_sidecar(source, snapshot, name)
|
||||
snapshots[name] = snapshot
|
||||
yield snapshots, digests
|
||||
|
||||
|
||||
def verify_release_sidecar_snapshot_unchanged(
|
||||
snapshots: dict[str, Path], expected_digests: dict[str, str]
|
||||
) -> None:
|
||||
for name, path in snapshots.items():
|
||||
if sha256_file(path) != expected_digests[name]:
|
||||
raise v5.AttestationError(f"private release sidecar snapshot {name} changed during verification")
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def immutable_artifact_snapshot(
|
||||
artifacts: dict[str, Path],
|
||||
) -> Iterator[tuple[dict[str, Path], dict[str, str]]]:
|
||||
if set(artifacts) != set(v5.ARTIFACT_ARGUMENTS):
|
||||
raise v5.AttestationError("qualification artifact set is incomplete")
|
||||
with tempfile.TemporaryDirectory(prefix="pulse-secure-runtime-artifacts-") as temporary:
|
||||
snapshot_root = Path(temporary)
|
||||
snapshot_root.chmod(0o700)
|
||||
snapshots: dict[str, Path] = {}
|
||||
digests: dict[str, str] = {}
|
||||
for name in v5.ARTIFACT_ARGUMENTS:
|
||||
snapshot = snapshot_root / name
|
||||
digests[name] = copy_immutable_input(
|
||||
artifacts[name], snapshot, f"qualification artifact {name}"
|
||||
)
|
||||
snapshots[name] = snapshot
|
||||
yield snapshots, digests
|
||||
for name, path in snapshots.items():
|
||||
if sha256_file(path) != digests[name]:
|
||||
raise v5.AttestationError(
|
||||
f"private qualification artifact snapshot {name} changed during verification"
|
||||
)
|
||||
|
||||
|
||||
def verify_release_build_contract(
|
||||
*,
|
||||
path: Path,
|
||||
tag: str,
|
||||
qualified_commit: str,
|
||||
repository: str,
|
||||
expected_update_key_fingerprint: str,
|
||||
receipt: dict[str, Any],
|
||||
artifact_hashes: dict[str, str],
|
||||
checksums: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
contract = load_json_object(path, "secure-runtime build contract")
|
||||
if contract.get("schema_version") != 1:
|
||||
raise v5.AttestationError("secure-runtime build contract schema_version must be 1")
|
||||
expected_version = tag[1:]
|
||||
expected_identity = {
|
||||
"repository": repository,
|
||||
"assembly_signer_workflow": ASSEMBLY_SIGNER_WORKFLOW,
|
||||
"compiler_signer_workflow": COMPILER_SIGNER_WORKFLOW,
|
||||
"compiler_runner_trust": "github-hosted-deny-self-hosted",
|
||||
"tag": tag,
|
||||
"version": expected_version,
|
||||
"source_sha": qualified_commit,
|
||||
"update_key_fingerprint": expected_update_key_fingerprint,
|
||||
}
|
||||
for key, expected in expected_identity.items():
|
||||
if contract.get(key) != expected:
|
||||
raise v5.AttestationError(f"secure-runtime build contract {key} does not match the release")
|
||||
if not UPDATE_KEY_FINGERPRINT_RE.fullmatch(expected_update_key_fingerprint):
|
||||
raise v5.AttestationError("expected release update-key fingerprint is invalid")
|
||||
update_public_keys = contract.get("update_public_keys")
|
||||
if not isinstance(update_public_keys, str) or "," in update_public_keys:
|
||||
raise v5.AttestationError("secure-runtime build contract must bind one release update public key")
|
||||
try:
|
||||
update_public_key = base64.b64decode(update_public_keys, validate=True)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise v5.AttestationError("secure-runtime build contract update public key is invalid") from exc
|
||||
if len(update_public_key) != 32:
|
||||
raise v5.AttestationError("secure-runtime build contract update public key is not raw Ed25519")
|
||||
derived_fingerprint = "SHA256:" + base64.b64encode(hashlib.sha256(update_public_key).digest()).decode("ascii")
|
||||
if derived_fingerprint != expected_update_key_fingerprint:
|
||||
raise v5.AttestationError("secure-runtime build contract update public key fingerprint does not match")
|
||||
if checksums.get(BUILD_CONTRACT_NAME) != sha256_file(path):
|
||||
raise v5.AttestationError("secure-runtime build contract is not digest-bound by release checksums")
|
||||
artifacts = contract.get("artifacts")
|
||||
if not isinstance(artifacts, dict) or set(artifacts) != set(v5.ARTIFACT_ARGUMENTS):
|
||||
raise v5.AttestationError("secure-runtime build contract artifact set is incomplete")
|
||||
receipt_versions = receipt.get("artifact_versions")
|
||||
if not isinstance(receipt_versions, dict):
|
||||
raise v5.AttestationError("receipt artifact_versions are unavailable")
|
||||
verified: dict[str, Any] = {}
|
||||
seen_release_assets: set[str] = set()
|
||||
for name, expected_package in v5.EXPECTED_ARTIFACT_PACKAGES.items():
|
||||
entry = artifacts[name]
|
||||
if not isinstance(entry, dict):
|
||||
raise v5.AttestationError(f"secure-runtime build contract artifact {name} is invalid")
|
||||
release_asset = entry.get("release_asset")
|
||||
if (
|
||||
not isinstance(release_asset, str)
|
||||
or PurePosixPath(release_asset).name != release_asset
|
||||
or release_asset in seen_release_assets
|
||||
):
|
||||
raise v5.AttestationError(f"secure-runtime build contract release asset for {name} is invalid")
|
||||
seen_release_assets.add(release_asset)
|
||||
if entry.get("sha256") != artifact_hashes[name] or checksums.get(release_asset) != artifact_hashes[name]:
|
||||
raise v5.AttestationError(f"artifact {name} is not bound to the signed release checksums")
|
||||
build = entry.get("build")
|
||||
if not isinstance(build, dict):
|
||||
raise v5.AttestationError(f"artifact {name} has no exact build contract")
|
||||
required_build = {
|
||||
"tool": "go build",
|
||||
"package": expected_package,
|
||||
"target_os": "linux",
|
||||
"target_arch": receipt.get("architecture"),
|
||||
"cgo_enabled": 0,
|
||||
"trimpath": True,
|
||||
"buildvcs": False,
|
||||
"build_args": ["-buildvcs=false", "-trimpath"],
|
||||
"update_key_fingerprint": expected_update_key_fingerprint,
|
||||
}
|
||||
for key, expected in required_build.items():
|
||||
if build.get(key) != expected:
|
||||
raise v5.AttestationError(f"artifact {name} build field {key} does not match the hosted compiler contract")
|
||||
if not GO_VERSION_RE.fullmatch(str(build.get("go_version", ""))):
|
||||
raise v5.AttestationError(f"artifact {name} Go toolchain is invalid")
|
||||
artifact_version = build.get("version")
|
||||
if not isinstance(artifact_version, str) or not artifact_version:
|
||||
raise v5.AttestationError(f"artifact {name} version is invalid")
|
||||
normalized_artifact_version = artifact_version.removeprefix("v")
|
||||
if name.startswith("collector_v"):
|
||||
receipt_version = receipt_versions.get(name)
|
||||
if (
|
||||
not isinstance(receipt_version, str)
|
||||
or normalized_artifact_version != receipt_version.removeprefix("v")
|
||||
):
|
||||
raise v5.AttestationError(f"artifact {name} version does not match the receipt")
|
||||
elif normalized_artifact_version != expected_version:
|
||||
raise v5.AttestationError(f"artifact {name} version does not match the release")
|
||||
expected_ldflags = ""
|
||||
if name != "runner":
|
||||
embedded_version = artifact_version if artifact_version.startswith("v") else f"v{artifact_version}"
|
||||
expected_ldflags = (
|
||||
f"-s -w -X main.Version={embedded_version} "
|
||||
"-X github.com/rcourtman/pulse-go-rewrite/internal/updatesignature."
|
||||
f"EmbeddedTrustedPublicKeys={update_public_keys}"
|
||||
)
|
||||
if build.get("ldflags") != expected_ldflags:
|
||||
raise v5.AttestationError(f"artifact {name} ldflags do not match the canonical release invocation")
|
||||
ldflags_sha256 = hashlib.sha256(expected_ldflags.encode()).hexdigest()
|
||||
if build.get("ldflags_sha256") != ldflags_sha256:
|
||||
raise v5.AttestationError(f"artifact {name} ldflags digest is invalid")
|
||||
verified[name] = {
|
||||
"release_asset": release_asset,
|
||||
"sha256": artifact_hashes[name],
|
||||
"package": expected_package,
|
||||
"go_version": build["go_version"],
|
||||
"buildvcs": False,
|
||||
"trimpath": True,
|
||||
"ldflags_sha256": ldflags_sha256,
|
||||
"version": artifact_version,
|
||||
"update_key_fingerprint": expected_update_key_fingerprint,
|
||||
}
|
||||
return verified
|
||||
|
||||
|
||||
def verify_release_candidate_packet(
|
||||
*,
|
||||
checkout: Path,
|
||||
qualified_commit: str,
|
||||
tag: str,
|
||||
repository: str,
|
||||
release_id: str,
|
||||
checksums_path: Path,
|
||||
assembly_provenance_path: Path,
|
||||
compiler_provenance_path: Path,
|
||||
build_contract_path: Path,
|
||||
expected_update_key_fingerprint: str,
|
||||
receipt: dict[str, Any],
|
||||
artifacts: dict[str, Path],
|
||||
artifact_hashes: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
tag_identity = verify_release_candidate_tag_identity(checkout, qualified_commit, tag, repository)
|
||||
if not re.fullmatch(r"[1-9][0-9]*", release_id):
|
||||
raise v5.AttestationError("release id must be a positive GitHub release id")
|
||||
integrity_script = checkout / "scripts/verify-github-release-integrity.sh"
|
||||
run_checked(
|
||||
[str(integrity_script), tag, repository, release_id, qualified_commit],
|
||||
cwd=checkout,
|
||||
label="immutable GitHub release integrity verification",
|
||||
)
|
||||
with immutable_release_sidecar_snapshot(
|
||||
checksums_path,
|
||||
assembly_provenance_path,
|
||||
compiler_provenance_path,
|
||||
build_contract_path,
|
||||
) as (snapshots, snapshot_digests):
|
||||
checksums_snapshot = snapshots[CHECKSUMS_NAME]
|
||||
assembly_provenance_snapshot = snapshots[ASSEMBLY_PROVENANCE_NAME]
|
||||
compiler_provenance_snapshot = snapshots[COMPILER_PROVENANCE_NAME]
|
||||
build_contract_snapshot = snapshots[BUILD_CONTRACT_NAME]
|
||||
for sidecar in snapshots.values():
|
||||
run_checked(
|
||||
[
|
||||
"gh",
|
||||
"release",
|
||||
"verify-asset",
|
||||
tag,
|
||||
str(sidecar),
|
||||
"--repo",
|
||||
repository,
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
cwd=checkout,
|
||||
label=f"release attestation verification for {sidecar.name}",
|
||||
)
|
||||
run_checked(
|
||||
[
|
||||
"gh",
|
||||
"attestation",
|
||||
"verify",
|
||||
str(checksums_snapshot),
|
||||
"--repo",
|
||||
repository,
|
||||
"--signer-workflow",
|
||||
ASSEMBLY_SIGNER_WORKFLOW,
|
||||
"--source-digest",
|
||||
qualified_commit,
|
||||
"--deny-self-hosted-runners",
|
||||
"--predicate-type",
|
||||
"https://slsa.dev/provenance/v1",
|
||||
"--bundle",
|
||||
str(assembly_provenance_snapshot),
|
||||
],
|
||||
cwd=checkout,
|
||||
label="hosted candidate-assembly provenance verification",
|
||||
)
|
||||
for artifact_name, artifact_path in artifacts.items():
|
||||
run_checked(
|
||||
[
|
||||
"gh",
|
||||
"attestation",
|
||||
"verify",
|
||||
str(artifact_path),
|
||||
"--repo",
|
||||
repository,
|
||||
"--signer-workflow",
|
||||
COMPILER_SIGNER_WORKFLOW,
|
||||
"--source-digest",
|
||||
qualified_commit,
|
||||
"--deny-self-hosted-runners",
|
||||
"--predicate-type",
|
||||
"https://slsa.dev/provenance/v1",
|
||||
"--bundle",
|
||||
str(compiler_provenance_snapshot),
|
||||
],
|
||||
cwd=checkout,
|
||||
label=f"hosted compiler provenance verification for {artifact_name}",
|
||||
)
|
||||
verify_release_sidecar_snapshot_unchanged(snapshots, snapshot_digests)
|
||||
checksums = parse_checksums(checksums_snapshot)
|
||||
build_identity = verify_release_build_contract(
|
||||
path=build_contract_snapshot,
|
||||
tag=tag,
|
||||
qualified_commit=qualified_commit,
|
||||
repository=repository,
|
||||
expected_update_key_fingerprint=expected_update_key_fingerprint,
|
||||
receipt=receipt,
|
||||
artifact_hashes=artifact_hashes,
|
||||
checksums=checksums,
|
||||
)
|
||||
verify_release_sidecar_snapshot_unchanged(snapshots, snapshot_digests)
|
||||
return {
|
||||
**tag_identity,
|
||||
"release_id": release_id,
|
||||
"checksums_sha256": snapshot_digests[CHECKSUMS_NAME],
|
||||
"assembly_provenance_sha256": snapshot_digests[ASSEMBLY_PROVENANCE_NAME],
|
||||
"compiler_provenance_sha256": snapshot_digests[COMPILER_PROVENANCE_NAME],
|
||||
"build_contract_sha256": snapshot_digests[BUILD_CONTRACT_NAME],
|
||||
"assembly_signer_workflow": ASSEMBLY_SIGNER_WORKFLOW,
|
||||
"compiler_signer_workflow": COMPILER_SIGNER_WORKFLOW,
|
||||
"compiler_runner_trust": "github-hosted-deny-self-hosted",
|
||||
"build_identity": build_identity,
|
||||
"update_key_fingerprint": expected_update_key_fingerprint,
|
||||
}
|
||||
|
||||
|
||||
def _create_attestation_with_snapshotted_artifacts(
|
||||
*,
|
||||
checkout: Path,
|
||||
commit: str,
|
||||
main_ref: str,
|
||||
receipt_path: Path,
|
||||
receipt_record_path: str,
|
||||
transcript_path: Path,
|
||||
artifacts: dict[str, Path],
|
||||
elapsed_seconds: float,
|
||||
release_candidate_tag: str | None = None,
|
||||
release_repository: str | None = None,
|
||||
release_id: str | None = None,
|
||||
release_checksums_path: Path | None = None,
|
||||
release_assembly_provenance_path: Path | None = None,
|
||||
release_compiler_provenance_path: Path | None = None,
|
||||
release_build_contract_path: Path | None = None,
|
||||
expected_release_update_key_fingerprint: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
checkout = checkout.resolve()
|
||||
qualified_commit = v5.resolve_commit(checkout, commit, "qualified commit")
|
||||
if commit != qualified_commit:
|
||||
raise v5.AttestationError("qualified commit must be the full canonical commit SHA")
|
||||
v5.require_detached_clean_checkout(checkout, qualified_commit)
|
||||
main_commit = verify_canonical_main_identity(checkout, main_ref)
|
||||
v5.require_ancestor(checkout, qualified_commit, CANONICAL_MAIN_REF)
|
||||
with v6_contract():
|
||||
receipt, receipt_bytes = v5.load_receipt(receipt_path)
|
||||
record_path = v5.canonical_repo_path(receipt_record_path, "receipt record path")
|
||||
if receipt.get("record_path") != record_path:
|
||||
raise v5.AttestationError("receipt record path is not bound inside the supplied receipt")
|
||||
transcript_events, transcript_bytes = v5.load_and_verify_transcript(transcript_path, receipt)
|
||||
v5.verify_scenarios(receipt, transcript_events)
|
||||
v5.verify_runtime_claims(receipt)
|
||||
source_hashes, source_manifest = v5.verify_source_hashes(checkout, qualified_commit, receipt)
|
||||
attestation_tool_hash = sha256_file(Path(__file__))
|
||||
if source_hashes.get(ATTESTATION_TOOL_PATH) != attestation_tool_hash:
|
||||
raise v5.AttestationError("executing schema-v6 attestation tool does not match the qualified commit")
|
||||
artifact_hashes = v5.verify_artifacts(receipt, artifacts)
|
||||
if not isinstance(elapsed_seconds, (float, int)) or not math.isfinite(elapsed_seconds) or elapsed_seconds <= 0:
|
||||
raise v5.AttestationError("elapsed seconds must be positive")
|
||||
|
||||
release_arguments = (
|
||||
release_repository,
|
||||
release_id,
|
||||
release_checksums_path,
|
||||
release_assembly_provenance_path,
|
||||
release_compiler_provenance_path,
|
||||
release_build_contract_path,
|
||||
expected_release_update_key_fingerprint,
|
||||
)
|
||||
release_packet: dict[str, Any] | None = None
|
||||
if release_candidate_tag is None:
|
||||
if any(value is not None for value in release_arguments):
|
||||
raise v5.AttestationError("release packet inputs require --release-candidate-tag")
|
||||
artifact_build_identity = v5.verify_artifact_build_identity(receipt, artifacts, qualified_commit)
|
||||
classification = "committed-main-artifact-bound-self-attested-systemd"
|
||||
else:
|
||||
if any(value is None for value in release_arguments):
|
||||
raise v5.AttestationError(
|
||||
"release-candidate classification requires repository, release id, signed checksums, hosted assembly and compiler provenance, build contract, and update-key fingerprint"
|
||||
)
|
||||
release_packet = verify_release_candidate_packet(
|
||||
checkout=checkout,
|
||||
qualified_commit=qualified_commit,
|
||||
tag=release_candidate_tag,
|
||||
repository=str(release_repository),
|
||||
release_id=str(release_id),
|
||||
checksums_path=Path(release_checksums_path),
|
||||
assembly_provenance_path=Path(release_assembly_provenance_path),
|
||||
compiler_provenance_path=Path(release_compiler_provenance_path),
|
||||
build_contract_path=Path(release_build_contract_path),
|
||||
expected_update_key_fingerprint=str(expected_release_update_key_fingerprint),
|
||||
receipt=receipt,
|
||||
artifacts=artifacts,
|
||||
artifact_hashes=artifact_hashes,
|
||||
)
|
||||
artifact_build_identity = release_packet["build_identity"]
|
||||
classification = "release-candidate-hosted-compiler-chain-artifact-bound-self-attested-systemd"
|
||||
|
||||
return {
|
||||
"schema_version": ATTESTATION_SCHEMA_VERSION,
|
||||
"attestation_tool": ATTESTATION_TOOL_PATH,
|
||||
"attestation_tool_sha256": attestation_tool_hash,
|
||||
"proof_classification": classification,
|
||||
"qualified_commit": qualified_commit,
|
||||
"qualified_ref_at_run": release_candidate_tag or qualified_commit,
|
||||
"main_ref_verified": main_ref,
|
||||
"main_ref_commit_at_attestation": main_commit,
|
||||
"qualified_commit_reachable_from_main": True,
|
||||
"build_checkout": "detached-worktree",
|
||||
"build_checkout_clean_except_lab_artifacts": True,
|
||||
"disposable_vm_guard_receipt_claim_validated": True,
|
||||
"execution_receipt_authentication": "none-secret-free-self-attestation",
|
||||
"receipt": {
|
||||
"record_path": record_path,
|
||||
"sha256": v5.sha256_bytes(receipt_bytes),
|
||||
"path_bound_inside_receipt": True,
|
||||
},
|
||||
"transcript": {
|
||||
"record_path": receipt["transcript"]["record_path"],
|
||||
"sha256": v5.sha256_bytes(transcript_bytes),
|
||||
"event_count": len(transcript_events),
|
||||
"scenario_event_count": sum(event.get("kind") == "scenario_result" for event in transcript_events),
|
||||
"command_output_event_count": sum(event.get("kind") == "command_output" for event in transcript_events),
|
||||
"format": "jsonl-v1",
|
||||
},
|
||||
"source_manifest": source_manifest,
|
||||
"source_hashes_match_commit": True,
|
||||
"source_hashes": source_hashes,
|
||||
"artifact_hashes_match_receipt": True,
|
||||
"artifact_hashes": artifact_hashes,
|
||||
"artifact_build_identity": artifact_build_identity,
|
||||
"release_packet": release_packet,
|
||||
"host": {
|
||||
"os": v5._os_name(receipt.get("os_release")),
|
||||
"kernel": receipt.get("kernel"),
|
||||
"systemd": receipt.get("systemd_version"),
|
||||
"architecture": receipt.get("architecture"),
|
||||
},
|
||||
"scenario_count": len(REQUIRED_SCENARIOS),
|
||||
"all_scenarios_passed": True,
|
||||
"test_elapsed_seconds": float(elapsed_seconds),
|
||||
"default_changed": False,
|
||||
"residual_proof": (
|
||||
["representative-provider-and-appliance", "external-security-review"]
|
||||
if release_packet
|
||||
else [
|
||||
"exact-release-candidate-hosted-compiler-chain-artifacts",
|
||||
"representative-provider-and-appliance",
|
||||
"external-security-review",
|
||||
]
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def create_attestation(
|
||||
*,
|
||||
checkout: Path,
|
||||
commit: str,
|
||||
main_ref: str,
|
||||
receipt_path: Path,
|
||||
receipt_record_path: str,
|
||||
transcript_path: Path,
|
||||
artifacts: dict[str, Path],
|
||||
elapsed_seconds: float,
|
||||
release_candidate_tag: str | None = None,
|
||||
release_repository: str | None = None,
|
||||
release_id: str | None = None,
|
||||
release_checksums_path: Path | None = None,
|
||||
release_assembly_provenance_path: Path | None = None,
|
||||
release_compiler_provenance_path: Path | None = None,
|
||||
release_build_contract_path: Path | None = None,
|
||||
expected_release_update_key_fingerprint: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
# Hash, inspect, and externally verify the same private artifact bytes.
|
||||
# Caller-owned paths can otherwise be swapped between receipt hashing,
|
||||
# Go build-identity inspection, and compiler provenance verification.
|
||||
with immutable_artifact_snapshot(artifacts) as (snapshots, _):
|
||||
return _create_attestation_with_snapshotted_artifacts(
|
||||
checkout=checkout,
|
||||
commit=commit,
|
||||
main_ref=main_ref,
|
||||
receipt_path=receipt_path,
|
||||
receipt_record_path=receipt_record_path,
|
||||
transcript_path=transcript_path,
|
||||
artifacts=snapshots,
|
||||
elapsed_seconds=elapsed_seconds,
|
||||
release_candidate_tag=release_candidate_tag,
|
||||
release_repository=release_repository,
|
||||
release_id=release_id,
|
||||
release_checksums_path=release_checksums_path,
|
||||
release_assembly_provenance_path=release_assembly_provenance_path,
|
||||
release_compiler_provenance_path=release_compiler_provenance_path,
|
||||
release_build_contract_path=release_build_contract_path,
|
||||
expected_release_update_key_fingerprint=expected_release_update_key_fingerprint,
|
||||
)
|
||||
|
||||
|
||||
def parse_args(argv: Sequence[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--checkout", type=Path, required=True)
|
||||
parser.add_argument("--commit", required=True)
|
||||
parser.add_argument("--main-ref", default=CANONICAL_MAIN_REF)
|
||||
parser.add_argument("--receipt", type=Path, required=True)
|
||||
parser.add_argument("--receipt-record-path", required=True)
|
||||
parser.add_argument("--transcript", type=Path, required=True)
|
||||
parser.add_argument("--collector-v1", type=Path, required=True)
|
||||
parser.add_argument("--collector-v2", type=Path, required=True)
|
||||
parser.add_argument("--collector-v3", type=Path, required=True)
|
||||
parser.add_argument("--collector-v4", type=Path, required=True)
|
||||
parser.add_argument("--helper", type=Path, required=True)
|
||||
parser.add_argument("--runner", type=Path, required=True)
|
||||
parser.add_argument("--elapsed-seconds", type=float, required=True)
|
||||
parser.add_argument("--release-candidate-tag")
|
||||
parser.add_argument("--release-repository")
|
||||
parser.add_argument("--release-id")
|
||||
parser.add_argument("--release-checksums", type=Path)
|
||||
parser.add_argument("--release-assembly-provenance", type=Path)
|
||||
parser.add_argument("--release-compiler-provenance", type=Path)
|
||||
parser.add_argument("--release-build-contract", type=Path)
|
||||
parser.add_argument("--expected-release-update-key-fingerprint")
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = parse_args(sys.argv[1:] if argv is None else argv)
|
||||
artifacts = {
|
||||
"collector_v1": args.collector_v1,
|
||||
"collector_v2": args.collector_v2,
|
||||
"collector_v3": args.collector_v3,
|
||||
"collector_v4": args.collector_v4,
|
||||
"helper": args.helper,
|
||||
"runner": args.runner,
|
||||
}
|
||||
try:
|
||||
attestation = create_attestation(
|
||||
checkout=args.checkout,
|
||||
commit=args.commit,
|
||||
main_ref=args.main_ref,
|
||||
receipt_path=args.receipt,
|
||||
receipt_record_path=args.receipt_record_path,
|
||||
transcript_path=args.transcript,
|
||||
artifacts=artifacts,
|
||||
elapsed_seconds=args.elapsed_seconds,
|
||||
release_candidate_tag=args.release_candidate_tag,
|
||||
release_repository=args.release_repository,
|
||||
release_id=args.release_id,
|
||||
release_checksums_path=args.release_checksums,
|
||||
release_assembly_provenance_path=args.release_assembly_provenance,
|
||||
release_compiler_provenance_path=args.release_compiler_provenance,
|
||||
release_build_contract_path=args.release_build_contract,
|
||||
expected_release_update_key_fingerprint=args.expected_release_update_key_fingerprint,
|
||||
)
|
||||
args.output.write_text(json.dumps(attestation, indent=2) + "\n", encoding="utf-8")
|
||||
except v5.AttestationError as exc:
|
||||
print(f"secure runtime schema-v6 attestation failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"secure runtime schema-v6 attestation passed: {args.output}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,512 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import secure_runtime_attestation as v5
|
||||
from secure_runtime_attestation_v6 import (
|
||||
ATTESTATION_SCHEMA_VERSION,
|
||||
ASSEMBLY_PROVENANCE_NAME,
|
||||
ASSEMBLY_SIGNER_WORKFLOW,
|
||||
BUILD_CONTRACT_NAME,
|
||||
CANONICAL_MAIN_REF,
|
||||
CANONICAL_ORIGIN_URL,
|
||||
CANONICAL_REPOSITORY,
|
||||
CHECKSUMS_NAME,
|
||||
COMPILER_PROVENANCE_NAME,
|
||||
COMPILER_SIGNER_WORKFLOW,
|
||||
RECEIPT_SCHEMA_VERSION,
|
||||
REQUIRED_SCENARIOS,
|
||||
SCENARIO_REQUIRED_CLAIMS,
|
||||
SCENARIO_REQUIRED_OBSERVATIONS,
|
||||
SOURCE_MANIFEST_ID,
|
||||
SOURCE_MANIFEST_PATH,
|
||||
copy_release_sidecar,
|
||||
create_attestation,
|
||||
immutable_artifact_snapshot,
|
||||
parse_args,
|
||||
verify_release_build_contract,
|
||||
verify_canonical_main_identity,
|
||||
verify_release_candidate_packet,
|
||||
verify_release_candidate_tag_identity,
|
||||
)
|
||||
|
||||
|
||||
class SecureRuntimeAttestationV6Test(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temporary.name)
|
||||
self.tag = "v6.5.0-rc.1"
|
||||
self.commit = "b" * 40
|
||||
self.tag_object = "a" * 40
|
||||
self.update_public_keys = base64.b64encode(bytes(range(32))).decode("ascii")
|
||||
self.fingerprint = "SHA256:" + base64.b64encode(
|
||||
hashlib.sha256(bytes(range(32))).digest()
|
||||
).decode("ascii")
|
||||
self.receipt = {
|
||||
"architecture": "arm64",
|
||||
"artifact_versions": {
|
||||
"collector_v1": "v6.5.0-lab.1",
|
||||
"collector_v2": "v6.5.0-lab.2",
|
||||
"collector_v3": "v6.5.0-lab.3",
|
||||
"collector_v4": "v6.5.0-lab.4",
|
||||
},
|
||||
}
|
||||
self.artifact_hashes = {
|
||||
name: hashlib.sha256(name.encode()).hexdigest()
|
||||
for name in v5.ARTIFACT_ARGUMENTS
|
||||
}
|
||||
self.artifacts = {}
|
||||
for name in v5.ARTIFACT_ARGUMENTS:
|
||||
path = self.root / f"artifact-{name}"
|
||||
path.write_bytes(name.encode())
|
||||
self.artifacts[name] = path
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temporary.cleanup()
|
||||
|
||||
def build_contract(self) -> dict:
|
||||
artifacts = {}
|
||||
for name, package in v5.EXPECTED_ARTIFACT_PACKAGES.items():
|
||||
version = self.receipt["artifact_versions"].get(name, self.tag[1:])
|
||||
ldflags = ""
|
||||
if name != "runner":
|
||||
embedded_version = version if version.startswith("v") else f"v{version}"
|
||||
ldflags = (
|
||||
f"-s -w -X main.Version={embedded_version} "
|
||||
"-X github.com/rcourtman/pulse-go-rewrite/internal/updatesignature."
|
||||
f"EmbeddedTrustedPublicKeys={self.update_public_keys}"
|
||||
)
|
||||
artifacts[name] = {
|
||||
"release_asset": f"pulse-secure-runtime-{name}-linux-arm64",
|
||||
"sha256": self.artifact_hashes[name],
|
||||
"build": {
|
||||
"tool": "go build",
|
||||
"package": package,
|
||||
"target_os": "linux",
|
||||
"target_arch": "arm64",
|
||||
"cgo_enabled": 0,
|
||||
"trimpath": True,
|
||||
"buildvcs": False,
|
||||
"build_args": ["-buildvcs=false", "-trimpath"],
|
||||
"go_version": "go1.25.1",
|
||||
"ldflags": ldflags,
|
||||
"ldflags_sha256": hashlib.sha256(ldflags.encode()).hexdigest(),
|
||||
"version": version,
|
||||
"update_key_fingerprint": self.fingerprint,
|
||||
},
|
||||
}
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"repository": CANONICAL_REPOSITORY,
|
||||
"assembly_signer_workflow": ASSEMBLY_SIGNER_WORKFLOW,
|
||||
"compiler_signer_workflow": COMPILER_SIGNER_WORKFLOW,
|
||||
"compiler_runner_trust": "github-hosted-deny-self-hosted",
|
||||
"tag": self.tag,
|
||||
"version": self.tag[1:],
|
||||
"source_sha": self.commit,
|
||||
"update_key_fingerprint": self.fingerprint,
|
||||
"update_public_keys": self.update_public_keys,
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
|
||||
def write_contract_and_checksums(self, contract: dict | None = None) -> tuple[Path, Path, dict[str, str]]:
|
||||
contract_path = self.root / BUILD_CONTRACT_NAME
|
||||
contract_path.write_text(json.dumps(contract or self.build_contract(), sort_keys=True), encoding="utf-8")
|
||||
checksums = {
|
||||
entry["release_asset"]: entry["sha256"]
|
||||
for entry in (contract or self.build_contract())["artifacts"].values()
|
||||
}
|
||||
checksums[BUILD_CONTRACT_NAME] = hashlib.sha256(contract_path.read_bytes()).hexdigest()
|
||||
checksums_path = self.root / CHECKSUMS_NAME
|
||||
checksums_path.write_text(
|
||||
"".join(f"{digest} {name}\n" for name, digest in sorted(checksums.items())),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return contract_path, checksums_path, checksums
|
||||
|
||||
def git_result(self, *arguments: str) -> subprocess.CompletedProcess[bytes]:
|
||||
command = tuple(arguments)
|
||||
ref = f"refs/tags/{self.tag}"
|
||||
if command == ("remote", "get-url", "origin"):
|
||||
output = CANONICAL_ORIGIN_URL + "\n"
|
||||
elif command == ("rev-parse", "--verify", ref):
|
||||
output = self.tag_object + "\n"
|
||||
elif command == ("cat-file", "-t", ref):
|
||||
output = "tag\n"
|
||||
elif command == ("cat-file", "tag", ref):
|
||||
output = (
|
||||
f"object {self.commit}\ntype commit\ntag {self.tag}\n"
|
||||
"tagger github-actions[bot] <github-actions[bot]@users.noreply.github.com> 1 +0000\n"
|
||||
f"\nRelease {self.tag}\n"
|
||||
)
|
||||
elif command == ("rev-parse", "--verify", f"{ref}^{{commit}}"):
|
||||
output = self.commit + "\n"
|
||||
elif command == ("ls-remote", "--tags", "origin", ref, f"{ref}^{{}}"):
|
||||
output = f"{self.tag_object}\t{ref}\n{self.commit}\t{ref}^{{}}\n"
|
||||
else:
|
||||
raise AssertionError(f"unexpected git call {command}")
|
||||
return subprocess.CompletedProcess(command, 0, output.encode(), b"")
|
||||
|
||||
def test_v6_is_a_new_contract_without_redefining_v5(self) -> None:
|
||||
self.assertEqual(RECEIPT_SCHEMA_VERSION, 6)
|
||||
self.assertEqual(ATTESTATION_SCHEMA_VERSION, 6)
|
||||
self.assertEqual(SOURCE_MANIFEST_ID, "secure-runtime-linux-v6")
|
||||
self.assertTrue(SOURCE_MANIFEST_PATH.endswith("_v6.json"))
|
||||
self.assertEqual(v5.RECEIPT_SCHEMA_VERSION, 5)
|
||||
self.assertEqual(len(v5.REQUIRED_SCENARIOS), 15)
|
||||
self.assertEqual(len(REQUIRED_SCENARIOS), 20)
|
||||
|
||||
def test_v6_requires_override_and_helper_namespace_claims(self) -> None:
|
||||
self.assertEqual(
|
||||
SCENARIO_REQUIRED_CLAIMS["helper_network_namespace_isolation"],
|
||||
{"helper_host_interface_tcp_denied", "helper_network_namespace_isolated"},
|
||||
)
|
||||
self.assertEqual(
|
||||
SCENARIO_REQUIRED_OBSERVATIONS["helper_network_namespace_isolation"],
|
||||
{
|
||||
"canary_scope": "host-interface-tcp",
|
||||
"host_canary_reachable": True,
|
||||
"helper_namespace_connection": "denied",
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
SCENARIO_REQUIRED_OBSERVATIONS["helper_resource_limit_override_rejection"],
|
||||
{
|
||||
"override_directive": "TasksMax=infinity",
|
||||
"tasks_max": "64",
|
||||
"limit_nofile": "256",
|
||||
"memory_max_bytes": "268435456",
|
||||
},
|
||||
)
|
||||
|
||||
def test_v6_manifest_binds_the_trusted_candidate_build_and_release_pipeline(self) -> None:
|
||||
manifest = json.loads((Path(__file__).resolve().parents[2] / SOURCE_MANIFEST_PATH).read_text())
|
||||
exact_paths = set(manifest["exact_paths"])
|
||||
recursive_roots = set(manifest["recursive_roots"])
|
||||
required = {
|
||||
".github/workflows/build-release-candidate.yml",
|
||||
".github/workflows/compile-release-payload.yml",
|
||||
".github/workflows/create-release.yml",
|
||||
"scripts/build-release-binaries.sh",
|
||||
"scripts/build-release.sh",
|
||||
"scripts/release_asset_common.sh",
|
||||
"scripts/release_build_targets.sh",
|
||||
"scripts/release_candidate_manifest.py",
|
||||
"scripts/release_ldflags.sh",
|
||||
"scripts/release_update_key.go",
|
||||
"scripts/require-safe-gh-attestation.sh",
|
||||
"scripts/validate-release.sh",
|
||||
"scripts/verify-github-release-integrity.sh",
|
||||
}
|
||||
self.assertEqual(required - exact_paths, set())
|
||||
self.assertEqual(
|
||||
{"internal/collectorlifecycle", "pkg/tlsutil"} - recursive_roots,
|
||||
set(),
|
||||
)
|
||||
|
||||
def test_release_sidecar_snapshot_rejects_symlink_before_resolution(self) -> None:
|
||||
source = self.root / "source-checksums.txt"
|
||||
source.write_text("contents\n", encoding="utf-8")
|
||||
symlink = self.root / CHECKSUMS_NAME
|
||||
symlink.symlink_to(source)
|
||||
with self.assertRaisesRegex(v5.AttestationError, "regular checksums.txt"):
|
||||
copy_release_sidecar(symlink, self.root / "copy" / CHECKSUMS_NAME, CHECKSUMS_NAME)
|
||||
|
||||
def test_release_sidecar_snapshot_rejects_source_path_swap(self) -> None:
|
||||
source = self.root / CHECKSUMS_NAME
|
||||
source.write_text("original\n", encoding="utf-8")
|
||||
destination_root = self.root / "private"
|
||||
destination_root.mkdir(mode=0o700)
|
||||
real_fstat = os.fstat
|
||||
calls = 0
|
||||
|
||||
def swap_after_open(file_descriptor):
|
||||
nonlocal calls
|
||||
result = real_fstat(file_descriptor)
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
source.rename(self.root / "original-checksums.txt")
|
||||
source.write_text("replacement\n", encoding="utf-8")
|
||||
return result
|
||||
|
||||
with (
|
||||
mock.patch("secure_runtime_attestation_v6.os.fstat", side_effect=swap_after_open),
|
||||
self.assertRaisesRegex(v5.AttestationError, "changed while it was copied"),
|
||||
):
|
||||
copy_release_sidecar(source, destination_root / CHECKSUMS_NAME, CHECKSUMS_NAME)
|
||||
|
||||
def test_create_attestation_uses_private_immutable_artifact_copies(self) -> None:
|
||||
observed: dict[str, bytes] = {}
|
||||
|
||||
def inspect_snapshots(**kwargs):
|
||||
for name, path in kwargs["artifacts"].items():
|
||||
self.assertNotEqual(path, self.artifacts[name])
|
||||
observed[name] = path.read_bytes()
|
||||
self.artifacts[name].write_bytes(b"swapped-after-snapshot")
|
||||
self.assertEqual(path.read_bytes(), name.encode())
|
||||
return {"proof_classification": "test"}
|
||||
|
||||
with mock.patch(
|
||||
"secure_runtime_attestation_v6._create_attestation_with_snapshotted_artifacts",
|
||||
side_effect=inspect_snapshots,
|
||||
):
|
||||
result = create_attestation(
|
||||
checkout=self.root,
|
||||
commit=self.commit,
|
||||
main_ref=CANONICAL_MAIN_REF,
|
||||
receipt_path=self.root / "receipt.json",
|
||||
receipt_record_path="record.json",
|
||||
transcript_path=self.root / "transcript.jsonl",
|
||||
artifacts=self.artifacts,
|
||||
elapsed_seconds=1,
|
||||
)
|
||||
self.assertEqual(result["proof_classification"], "test")
|
||||
self.assertEqual(observed, {name: name.encode() for name in v5.ARTIFACT_ARGUMENTS})
|
||||
|
||||
def test_private_artifact_snapshot_mutation_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(v5.AttestationError, "snapshot collector_v1 changed"):
|
||||
with immutable_artifact_snapshot(self.artifacts) as (snapshots, _):
|
||||
snapshots["collector_v1"].chmod(0o600)
|
||||
snapshots["collector_v1"].write_bytes(b"mutated")
|
||||
|
||||
def test_rejects_head_branch_and_non_rc_release_identities(self) -> None:
|
||||
for value in ("HEAD", "main", "refs/heads/main", "refs/tags/v6.5.0-rc.1", "v6.5.0"):
|
||||
with self.subTest(value=value), self.assertRaisesRegex(v5.AttestationError, "exact vX.Y.Z-rc.N"):
|
||||
verify_release_candidate_tag_identity(self.root, self.commit, value, CANONICAL_REPOSITORY)
|
||||
|
||||
def test_committed_main_identity_requires_canonical_remote_main(self) -> None:
|
||||
def canonical(_checkout, *args, **_kwargs):
|
||||
command = tuple(args)
|
||||
if command == ("remote", "get-url", "origin"):
|
||||
output = CANONICAL_ORIGIN_URL + "\n"
|
||||
elif command == ("rev-parse", "--verify", f"{CANONICAL_MAIN_REF}^{{commit}}"):
|
||||
output = self.commit + "\n"
|
||||
elif command == ("ls-remote", "origin", "refs/heads/main"):
|
||||
output = f"{self.commit}\trefs/heads/main\n"
|
||||
else:
|
||||
raise AssertionError(f"unexpected git call {command}")
|
||||
return subprocess.CompletedProcess(args, 0, output.encode(), b"")
|
||||
|
||||
with mock.patch.object(v5, "run_git", side_effect=canonical):
|
||||
self.assertEqual(
|
||||
verify_canonical_main_identity(self.root, CANONICAL_MAIN_REF),
|
||||
self.commit,
|
||||
)
|
||||
for caller_ref in ("HEAD", "main", "refs/heads/main", "scratch"):
|
||||
with self.subTest(caller_ref=caller_ref), self.assertRaisesRegex(
|
||||
v5.AttestationError, "canonical origin/main"
|
||||
):
|
||||
verify_canonical_main_identity(self.root, caller_ref)
|
||||
|
||||
def moved_remote(_checkout, *args, **kwargs):
|
||||
result = canonical(_checkout, *args, **kwargs)
|
||||
if tuple(args) == ("ls-remote", "origin", "refs/heads/main"):
|
||||
return subprocess.CompletedProcess(args, 0, f"{'c' * 40}\trefs/heads/main\n".encode(), b"")
|
||||
return result
|
||||
|
||||
with (
|
||||
mock.patch.object(v5, "run_git", side_effect=moved_remote),
|
||||
self.assertRaisesRegex(v5.AttestationError, "does not match the remote main commit"),
|
||||
):
|
||||
verify_canonical_main_identity(self.root, CANONICAL_MAIN_REF)
|
||||
|
||||
def test_accepts_canonical_annotated_tag_only_as_release_packet_identity(self) -> None:
|
||||
with mock.patch.object(v5, "run_git", side_effect=lambda _checkout, *args, **_kwargs: self.git_result(*args)):
|
||||
identity = verify_release_candidate_tag_identity(
|
||||
self.root, self.commit, self.tag, CANONICAL_REPOSITORY
|
||||
)
|
||||
self.assertEqual(identity["tag_object"], self.tag_object)
|
||||
self.assertEqual(identity["peeled_commit"], self.commit)
|
||||
self.assertEqual(identity["tag_authority"], "immutable-signed-github-release-packet")
|
||||
|
||||
def test_rejects_wrong_origin_lightweight_moved_and_signed_tag_objects(self) -> None:
|
||||
base = lambda _checkout, *args, **_kwargs: self.git_result(*args)
|
||||
cases = {
|
||||
"wrong origin": (("remote", "get-url", "origin"), b"https://github.com/attacker/Pulse.git\n", "origin remote"),
|
||||
"lightweight": (("cat-file", "-t", f"refs/tags/{self.tag}"), b"commit\n", "annotated tag"),
|
||||
"moved": (
|
||||
("ls-remote", "--tags", "origin", f"refs/tags/{self.tag}", f"refs/tags/{self.tag}^{{}}"),
|
||||
f"{'c' * 40}\trefs/tags/{self.tag}\n{self.commit}\trefs/tags/{self.tag}^{{}}\n".encode(),
|
||||
"does not match locally",
|
||||
),
|
||||
"wrong-key signed tag": (
|
||||
("cat-file", "tag", f"refs/tags/{self.tag}"),
|
||||
(
|
||||
f"object {self.commit}\ntype commit\ntag {self.tag}\ntagger attacker <a@example.net> 1 +0000\n\n"
|
||||
f"Release {self.tag}\n-----BEGIN PGP SIGNATURE-----\nwrong-key\n"
|
||||
).encode(),
|
||||
"tag signatures are not authority",
|
||||
),
|
||||
}
|
||||
for name, (target_call, replacement, message) in cases.items():
|
||||
def dispatch(_checkout, *args, **kwargs):
|
||||
if tuple(args) == target_call:
|
||||
return subprocess.CompletedProcess(args, 0, replacement, b"")
|
||||
return base(_checkout, *args, **kwargs)
|
||||
|
||||
with self.subTest(name=name), mock.patch.object(v5, "run_git", side_effect=dispatch):
|
||||
with self.assertRaisesRegex(v5.AttestationError, message):
|
||||
verify_release_candidate_tag_identity(
|
||||
self.root, self.commit, self.tag, CANONICAL_REPOSITORY
|
||||
)
|
||||
|
||||
def test_accepts_buildvcs_stripped_artifacts_only_with_exact_signed_contract(self) -> None:
|
||||
contract_path, _, checksums = self.write_contract_and_checksums()
|
||||
verified = verify_release_build_contract(
|
||||
path=contract_path,
|
||||
tag=self.tag,
|
||||
qualified_commit=self.commit,
|
||||
repository=CANONICAL_REPOSITORY,
|
||||
expected_update_key_fingerprint=self.fingerprint,
|
||||
receipt=self.receipt,
|
||||
artifact_hashes=self.artifact_hashes,
|
||||
checksums=checksums,
|
||||
)
|
||||
self.assertEqual(set(verified), set(v5.ARTIFACT_ARGUMENTS))
|
||||
self.assertTrue(all(item["buildvcs"] is False for item in verified.values()))
|
||||
self.assertNotIn("main.Version=vv", self.build_contract()["artifacts"]["collector_v1"]["build"]["ldflags"])
|
||||
|
||||
def test_rejects_local_vcs_stamped_or_wrong_key_build_contract(self) -> None:
|
||||
for name, mutate, message in (
|
||||
(
|
||||
"vcs stamped",
|
||||
lambda contract: contract["artifacts"]["collector_v4"]["build"].__setitem__("buildvcs", True),
|
||||
"build field buildvcs",
|
||||
),
|
||||
(
|
||||
"wrong update key",
|
||||
lambda contract: contract.__setitem__("update_key_fingerprint", "SHA256:" + "B" * 43 + "="),
|
||||
"update_key_fingerprint",
|
||||
),
|
||||
(
|
||||
"missing ldflags",
|
||||
lambda contract: contract["artifacts"]["helper"]["build"].__setitem__("ldflags_sha256", "0" * 64),
|
||||
"ldflags digest",
|
||||
),
|
||||
):
|
||||
contract = self.build_contract()
|
||||
mutate(contract)
|
||||
contract_path, _, checksums = self.write_contract_and_checksums(contract)
|
||||
with self.subTest(name=name), self.assertRaisesRegex(v5.AttestationError, message):
|
||||
verify_release_build_contract(
|
||||
path=contract_path,
|
||||
tag=self.tag,
|
||||
qualified_commit=self.commit,
|
||||
repository=CANONICAL_REPOSITORY,
|
||||
expected_update_key_fingerprint=self.fingerprint,
|
||||
receipt=self.receipt,
|
||||
artifact_hashes=self.artifact_hashes,
|
||||
checksums=checksums,
|
||||
)
|
||||
|
||||
def test_release_packet_requires_hosted_assembly_and_compiler_provenance(self) -> None:
|
||||
contract_path, checksums_path, _ = self.write_contract_and_checksums()
|
||||
assembly_provenance_path = self.root / ASSEMBLY_PROVENANCE_NAME
|
||||
assembly_provenance_path.write_text("{}\n", encoding="utf-8")
|
||||
compiler_provenance_path = self.root / COMPILER_PROVENANCE_NAME
|
||||
compiler_provenance_path.write_text("{}\n", encoding="utf-8")
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def record(command, **_kwargs):
|
||||
calls.append(list(command))
|
||||
return subprocess.CompletedProcess(command, 0, b"{}\n", b"")
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"secure_runtime_attestation_v6.verify_release_candidate_tag_identity",
|
||||
return_value={
|
||||
"tag": self.tag,
|
||||
"tag_object": self.tag_object,
|
||||
"peeled_commit": self.commit,
|
||||
"origin_url": CANONICAL_ORIGIN_URL,
|
||||
"tag_authority": "immutable-signed-github-release-packet",
|
||||
},
|
||||
),
|
||||
mock.patch("secure_runtime_attestation_v6.subprocess.run", side_effect=record),
|
||||
):
|
||||
packet = verify_release_candidate_packet(
|
||||
checkout=self.root,
|
||||
qualified_commit=self.commit,
|
||||
tag=self.tag,
|
||||
repository=CANONICAL_REPOSITORY,
|
||||
release_id="12345",
|
||||
checksums_path=checksums_path,
|
||||
assembly_provenance_path=assembly_provenance_path,
|
||||
compiler_provenance_path=compiler_provenance_path,
|
||||
build_contract_path=contract_path,
|
||||
expected_update_key_fingerprint=self.fingerprint,
|
||||
receipt=self.receipt,
|
||||
artifacts=self.artifacts,
|
||||
artifact_hashes=self.artifact_hashes,
|
||||
)
|
||||
self.assertEqual(packet["assembly_signer_workflow"], ASSEMBLY_SIGNER_WORKFLOW)
|
||||
self.assertEqual(packet["compiler_signer_workflow"], COMPILER_SIGNER_WORKFLOW)
|
||||
self.assertEqual(packet["compiler_runner_trust"], "github-hosted-deny-self-hosted")
|
||||
self.assertTrue(any(Path(call[0]).name == "verify-github-release-integrity.sh" for call in calls))
|
||||
self.assertEqual(sum(call[:3] == ["gh", "release", "verify-asset"] for call in calls), 4)
|
||||
provenance_calls = [call for call in calls if call[:3] == ["gh", "attestation", "verify"]]
|
||||
self.assertEqual(len(provenance_calls), 1 + len(v5.ARTIFACT_ARGUMENTS))
|
||||
self.assertTrue(all("--deny-self-hosted-runners" in call for call in provenance_calls))
|
||||
self.assertIn(ASSEMBLY_SIGNER_WORKFLOW, provenance_calls[0])
|
||||
self.assertTrue(all(COMPILER_SIGNER_WORKFLOW in call for call in provenance_calls[1:]))
|
||||
|
||||
def test_release_packet_rejects_private_snapshot_swap_during_verification(self) -> None:
|
||||
contract_path, checksums_path, _ = self.write_contract_and_checksums()
|
||||
assembly_provenance_path = self.root / ASSEMBLY_PROVENANCE_NAME
|
||||
assembly_provenance_path.write_text("{}\n", encoding="utf-8")
|
||||
compiler_provenance_path = self.root / COMPILER_PROVENANCE_NAME
|
||||
compiler_provenance_path.write_text("{}\n", encoding="utf-8")
|
||||
|
||||
def mutate_snapshot(command, **_kwargs):
|
||||
if command[:3] == ["gh", "release", "verify-asset"] and Path(command[4]).name == CHECKSUMS_NAME:
|
||||
snapshot = Path(command[4])
|
||||
snapshot.chmod(0o600)
|
||||
snapshot.write_text("swapped\n", encoding="utf-8")
|
||||
return subprocess.CompletedProcess(command, 0, b"{}\n", b"")
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"secure_runtime_attestation_v6.verify_release_candidate_tag_identity",
|
||||
return_value={"tag": self.tag},
|
||||
),
|
||||
mock.patch("secure_runtime_attestation_v6.subprocess.run", side_effect=mutate_snapshot),
|
||||
self.assertRaisesRegex(v5.AttestationError, "changed during verification"),
|
||||
):
|
||||
verify_release_candidate_packet(
|
||||
checkout=self.root,
|
||||
qualified_commit=self.commit,
|
||||
tag=self.tag,
|
||||
repository=CANONICAL_REPOSITORY,
|
||||
release_id="12345",
|
||||
checksums_path=checksums_path,
|
||||
assembly_provenance_path=assembly_provenance_path,
|
||||
compiler_provenance_path=compiler_provenance_path,
|
||||
build_contract_path=contract_path,
|
||||
expected_update_key_fingerprint=self.fingerprint,
|
||||
receipt=self.receipt,
|
||||
artifacts=self.artifacts,
|
||||
artifact_hashes=self.artifact_hashes,
|
||||
)
|
||||
|
||||
def test_cli_does_not_accept_v5_release_candidate_ref_shortcut(self) -> None:
|
||||
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
|
||||
parse_args(["--release-candidate-ref", self.tag])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"manifest_id": "secure-runtime-linux-v6",
|
||||
"target_os": "linux",
|
||||
"description": "Production source boundary for the secure collector, typed helper, action runner, effective systemd profile enforcement, signed update trust and build inputs, control-plane admission, and schema-v6 qualification harness.",
|
||||
"exact_paths": [
|
||||
".github/workflows/build-release-candidate.yml",
|
||||
".github/workflows/compile-release-payload.yml",
|
||||
".github/workflows/create-release.yml",
|
||||
"VERSION",
|
||||
"go.mod",
|
||||
"go.sum",
|
||||
"internal/models/converters.go",
|
||||
"internal/models/models.go",
|
||||
"internal/models/models_frontend.go",
|
||||
"internal/monitoring/monitor.go",
|
||||
"internal/monitoring/monitor_agents.go",
|
||||
"internal/unifiedresources/adapters.go",
|
||||
"internal/unifiedresources/types.go",
|
||||
"internal/unifiedresources/views.go",
|
||||
"pkg/agents/docker/report.go",
|
||||
"scripts/install.sh",
|
||||
"scripts/install.ps1",
|
||||
"scripts/install-container-agent.sh",
|
||||
"scripts/install-docker.sh",
|
||||
"scripts/install-mcp.sh",
|
||||
"scripts/install-mcp.ps1",
|
||||
"scripts/installtests/secure_runtime_systemd_lab_test.go",
|
||||
"scripts/backfill-release-assets.sh",
|
||||
"scripts/build-release-binaries.sh",
|
||||
"scripts/build-release.sh",
|
||||
"scripts/check-github-release-immutability.sh",
|
||||
"scripts/package-helm-chart.sh",
|
||||
"scripts/prepare-release-container-context.sh",
|
||||
"scripts/pulse-auto-update.sh",
|
||||
"scripts/release_asset_common.sh",
|
||||
"scripts/release_build_targets.sh",
|
||||
"scripts/release_candidate_manifest.py",
|
||||
"scripts/release_control/secure_runtime_attestation.py",
|
||||
"scripts/release_control/secure_runtime_attestation_v6.py",
|
||||
"scripts/release_control/secure_runtime_source_manifest_v6.json",
|
||||
"scripts/release_ldflags.sh",
|
||||
"scripts/release_update_key.go",
|
||||
"scripts/render_installers.go",
|
||||
"scripts/require-safe-gh-attestation.sh",
|
||||
"scripts/validate-published-release.sh",
|
||||
"scripts/validate-release.sh",
|
||||
"scripts/verify-github-release-integrity.sh"
|
||||
],
|
||||
"recursive_roots": [
|
||||
"cmd/pulse-agent",
|
||||
"cmd/pulse-agent-helper",
|
||||
"cmd/pulse-agent-runner",
|
||||
"internal/actionrunner",
|
||||
"internal/agentexec",
|
||||
"internal/agenthelper",
|
||||
"internal/agenttls",
|
||||
"internal/agentupdate",
|
||||
"internal/api",
|
||||
"internal/collectorlifecycle",
|
||||
"internal/config",
|
||||
"internal/dockeragent",
|
||||
"internal/hostagent",
|
||||
"internal/operationreceipt",
|
||||
"internal/securityutil",
|
||||
"internal/updatesignature",
|
||||
"pkg/auth",
|
||||
"pkg/securityutil",
|
||||
"pkg/tlsutil"
|
||||
],
|
||||
"include_suffixes": [
|
||||
".go",
|
||||
".tmpl"
|
||||
],
|
||||
"exclude_suffixes": [
|
||||
"_test.go"
|
||||
]
|
||||
}
|
||||
@@ -4652,6 +4652,7 @@ class SubsystemLookupTest(unittest.TestCase):
|
||||
match["verification_requirement"]["exact_files"],
|
||||
[
|
||||
"internal/agentupdate/coverage_test.go",
|
||||
"internal/hostagent/action_runner_client_test.go",
|
||||
"internal/hostagent/agent_flushbuffer_test.go",
|
||||
"internal/hostagent/agent_metrics_test.go",
|
||||
"internal/hostagent/agent_new_test.go",
|
||||
|
||||
Reference in New Issue
Block a user