From 9d4ca81271454901bda22d08a1f093ad1537a443 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:01:15 +0100 Subject: [PATCH 1/2] Exchange the bootstrap token before any agent module starts A deployed agent's first run gave the remote config client, the updater, and the Docker / Podman and Kubernetes modules the bootstrap token, which only the host module exchanged. Pulse revokes that token on exchange, so those clients failed with 401 until the service restarted. Enroll once at startup and build every module from the runtime token and the server-assigned agent ID. --- cmd/pulse-agent/main.go | 48 +++- cmd/pulse-agent/main_test.go | 228 ++++++++++++++++ .../v6/internal/subsystems/agent-lifecycle.md | 29 ++ .../v6/internal/subsystems/registry.json | 1 + .../internal/subsystems/security-privacy.md | 2 +- internal/hostagent/agent.go | 57 ++-- internal/hostagent/enroll.go | 138 +++++++++- internal/hostagent/enroll_test.go | 247 ++++++++++++++++++ 8 files changed, 720 insertions(+), 30 deletions(-) diff --git a/cmd/pulse-agent/main.go b/cmd/pulse-agent/main.go index bee626acb5..1822a73141 100644 --- a/cmd/pulse-agent/main.go +++ b/cmd/pulse-agent/main.go @@ -106,6 +106,12 @@ var ( newUpdater func(agentupdate.Config) *agentupdate.Updater = agentupdate.New lookPath = exec.LookPath runAsWindowsServiceFunc = runAsWindowsService + enrollAgent = hostagent.Enroll + + // enrollBeforeModuleStartup is false on Windows, where startup runs before + // the service control dispatcher and an enrollment retry could outlast its + // 30-second deadline. Windows keeps its previous enrollment behaviour. + enrollBeforeModuleStartup = runtime.GOOS != "windows" // For testing retryInitialDelay = 5 * time.Second @@ -379,10 +385,50 @@ func run(ctx context.Context, args []string, getenv func(string) string) error { } } + // 2c. Exchange the bootstrap token before anything authenticates with it. + // A bootstrap token carries only the enrollment scope and the server + // revokes it on exchange, so the remote config client, the updater, and + // the host, Docker / Podman, and Kubernetes modules built below must all + // start with the runtime token and the server-assigned agent ID. A restart + // already loads both from the state directory; the first run must too. + if cfg.Enroll && enrollBeforeModuleStartup { + enrollment, err := enrollAgent(ctx, hostagent.EnrollmentConfig{ + PulseURL: cfg.PulseURL, + APIToken: cfg.APIToken, + StateDir: cfg.StateDir, + HostnameOverride: cfg.HostnameOverride, + AgentVersion: Version, + EnableCommands: cfg.EnableCommands, + CommandAuthorityProfile: cfg.CommandAuthorityProfile, + InsecureSkipVerify: cfg.InsecureSkipVerify, + CACertPath: cfg.CACertPath, + ServerFingerprint: cfg.ServerFingerprint, + Logger: &logger, + }) + if err != nil { + return fmt.Errorf("enrollment failed: %w", err) + } + cfg.APIToken = enrollment.APIToken + if enrollment.AgentID != "" && enrollment.AgentID != cfg.AgentID { + cfg.AgentID = enrollment.AgentID + if cfg.AgentIDFile != "" { + if err := writeAgentIDFile(cfg.AgentIDFile, cfg.AgentID); err != nil { + logger.Warn(). + Err(err). + Str("path", cfg.AgentIDFile). + Msg("Failed to persist enrolled agent ID; ID will be re-derived on next start") + } + } + } + // Enrollment is settled for this process; the host module must not + // attempt a second exchange. + cfg.Enroll = false + } + var remoteConfigClient *remoteconfig.Client var remoteConfigAppliers []RemoteConfigApplier - // 2c. Fetch Remote Config + // 2d. Fetch Remote Config // Only if we have enough info to contact server if cfg.PulseURL != "" && cfg.APIToken != "" && cfg.AgentID != "" { logger.Debug().Msg("Fetching remote configuration...") diff --git a/cmd/pulse-agent/main_test.go b/cmd/pulse-agent/main_test.go index 263c0f97dc..7dc0380157 100644 --- a/cmd/pulse-agent/main_test.go +++ b/cmd/pulse-agent/main_test.go @@ -13,6 +13,7 @@ import ( "reflect" "runtime" "strings" + "sync" "sync/atomic" "testing" "time" @@ -1750,6 +1751,233 @@ func TestRun_PassesStateDirToUpdaterAndHostAgent(t *testing.T) { } } +// A deployed agent's first run must exchange its bootstrap token before it +// builds anything that authenticates. The server revokes the bootstrap token +// on exchange, so a module started with it keeps failing until a restart; the +// remote config refresh did exactly that every minute on a freshly deployed +// cluster node. +func TestRunEnrollsBeforeBuildingAuthenticatedModules(t *testing.T) { + origEnroll, origUpdater, origHost := enrollAgent, newUpdater, newHostAgent + origDocker, origKube, origLook := newDockerAgent, newKubeAgent, lookPath + origGate := enrollBeforeModuleStartup + defer func() { + enrollAgent, newUpdater, newHostAgent = origEnroll, origUpdater, origHost + newDockerAgent, newKubeAgent, lookPath = origDocker, origKube, origLook + enrollBeforeModuleStartup = origGate + }() + enrollBeforeModuleStartup = true + + var mu sync.Mutex + var events []string + record := func(event string) { + mu.Lock() + defer mu.Unlock() + events = append(events, event) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + record(r.URL.Path + " token=" + r.Header.Get("X-API-Token")) + if r.URL.Path == "/api/agents/agent/canonical-agent/config" { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"success":true,"agentId":"canonical-agent","config":{}}`) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + stateDir := t.TempDir() + var enrollCfg hostagent.EnrollmentConfig + enrollAgent = func(_ context.Context, cfg hostagent.EnrollmentConfig) (hostagent.EnrollmentResult, error) { + record("enroll") + enrollCfg = cfg + return hostagent.EnrollmentResult{APIToken: "runtime-tok", AgentID: "canonical-agent"}, nil + } + var updaterCfg agentupdate.Config + newUpdater = func(cfg agentupdate.Config) *agentupdate.Updater { + updaterCfg = cfg + return agentupdate.New(agentupdate.Config{ + PulseURL: "https://pulse.example.com", + AgentName: cfg.AgentName, + CurrentVersion: "1.0.0", + StateDir: cfg.StateDir, + Disabled: true, + }) + } + var hostCfg hostagent.Config + newHostAgent = func(cfg hostagent.Config) (Runnable, error) { + hostCfg = cfg + return &mockRunnable{}, nil + } + var dockerCfg dockeragent.Config + newDockerAgent = func(cfg dockeragent.Config) (RunnableCloser, error) { + dockerCfg = cfg + return &mockRunnableCloser{}, nil + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var kubeCfg kubernetesagent.Config + newKubeAgent = func(cfg kubernetesagent.Config) (Runnable, error) { + kubeCfg = cfg + cancel() // the last module is built; nothing else needs to run + return &mockRunnable{}, nil + } + lookPath = func(string) (string, error) { return "", os.ErrNotExist } + + err := run(ctx, []string{ + "-url", server.URL, + "-token", "bootstrap-tok", + "-enroll", + "-hostname", "delly2", + "-state-dir", stateDir, + "-enable-host", "-enable-docker", "-enable-kubernetes", + "-health-addr", "", + }, func(string) string { return "" }) + if err != nil && !errors.Is(err, context.Canceled) { + t.Fatalf("run() error = %v", err) + } + + if enrollCfg.APIToken != "bootstrap-tok" || enrollCfg.HostnameOverride != "delly2" || enrollCfg.StateDir != stateDir { + t.Fatalf("enrollment config = %+v, want the bootstrap token, hostname override, and state dir", enrollCfg) + } + mu.Lock() + seen := append([]string(nil), events...) + mu.Unlock() + if len(seen) == 0 || seen[0] != "enroll" { + t.Fatalf("events = %q, want enrollment before any request to Pulse", seen) + } + fetchedConfig := false + for _, event := range seen[1:] { + if !strings.HasSuffix(event, "token=runtime-tok") { + t.Fatalf("request %q did not use the runtime token", event) + } + if strings.HasPrefix(event, "/api/agents/agent/canonical-agent/config ") { + fetchedConfig = true + } + } + if !fetchedConfig { + t.Fatalf("events = %q, want a remote config fetch for the canonical agent ID", seen) + } + + if updaterCfg.APIToken != "runtime-tok" { + t.Fatalf("updater token = %q, want the runtime token", updaterCfg.APIToken) + } + if hostCfg.APIToken != "runtime-tok" || hostCfg.AgentID != "canonical-agent" || hostCfg.Enroll { + t.Fatalf("host config token=%q agentID=%q enroll=%v, want the enrolled credentials and no second exchange", hostCfg.APIToken, hostCfg.AgentID, hostCfg.Enroll) + } + if dockerCfg.APIToken != "runtime-tok" || dockerCfg.AgentID != "canonical-agent" || len(dockerCfg.Targets) == 0 || dockerCfg.Targets[0].Token != "runtime-tok" { + t.Fatalf("docker config token=%q agentID=%q targets=%+v, want the enrolled credentials", dockerCfg.APIToken, dockerCfg.AgentID, dockerCfg.Targets) + } + if kubeCfg.APIToken != "runtime-tok" || kubeCfg.AgentID != "canonical-agent" || len(kubeCfg.Targets) == 0 || kubeCfg.Targets[0].Token != "runtime-tok" { + t.Fatalf("kubernetes config token=%q agentID=%q targets=%+v, want the enrolled credentials", kubeCfg.APIToken, kubeCfg.AgentID, kubeCfg.Targets) + } + if data, err := os.ReadFile(filepath.Join(stateDir, "agent-id")); err != nil || strings.TrimSpace(string(data)) != "canonical-agent" { + t.Fatalf("agent-id file = %q (err %v), want the canonical agent ID for the next start", data, err) + } +} + +// A rejected bootstrap token must stop startup before any module is built +// with a credential the server will never accept. +func TestRunStopsBeforeModulesWhenEnrollmentIsRejected(t *testing.T) { + origEnroll, origUpdater, origHost := enrollAgent, newUpdater, newHostAgent + origGate := enrollBeforeModuleStartup + defer func() { + enrollAgent, newUpdater, newHostAgent = origEnroll, origUpdater, origHost + enrollBeforeModuleStartup = origGate + }() + enrollBeforeModuleStartup = true + + enrollAgent = func(context.Context, hostagent.EnrollmentConfig) (hostagent.EnrollmentResult, error) { + return hostagent.EnrollmentResult{}, errors.New("bootstrap token already consumed (409 Conflict)") + } + newUpdater = func(agentupdate.Config) *agentupdate.Updater { + t.Fatal("updater built after a rejected enrollment") + return nil + } + newHostAgent = func(hostagent.Config) (Runnable, error) { + t.Fatal("host module built after a rejected enrollment") + return nil, nil + } + + err := run(context.Background(), []string{ + "-url", "http://127.0.0.1:1", + "-token", "spent-bootstrap-tok", + "-enroll", + "-state-dir", t.TempDir(), + "-enable-docker=false", "-enable-kubernetes=false", + "-health-addr", "", + }, func(string) string { return "" }) + if err == nil || !strings.Contains(err.Error(), "enrollment failed") { + t.Fatalf("run() error = %v, want the enrollment failure", err) + } +} + +// Windows startup runs before the service control dispatcher, so an +// enrollment retry there could outlast the dispatcher's deadline. With the +// gate off, the host module still receives --enroll and enrolls for itself. +func TestRunLeavesEnrollmentToTheHostModuleWhenGatedOff(t *testing.T) { + origEnroll, origHost, origGate := enrollAgent, newHostAgent, enrollBeforeModuleStartup + defer func() { enrollAgent, newHostAgent, enrollBeforeModuleStartup = origEnroll, origHost, origGate }() + enrollBeforeModuleStartup = false + + enrollAgent = func(context.Context, hostagent.EnrollmentConfig) (hostagent.EnrollmentResult, error) { + t.Fatal("enrollment ran before module startup with the gate off") + return hostagent.EnrollmentResult{}, nil + } + var hostCfg hostagent.Config + newHostAgent = func(cfg hostagent.Config) (Runnable, error) { + hostCfg = cfg + return &mockRunnable{}, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + err := run(ctx, []string{ + "-token", "bootstrap-tok", + "-enroll", + "-state-dir", t.TempDir(), + "-enable-docker=false", "-enable-kubernetes=false", + "-disable-auto-update", + "-health-addr", "", + }, func(string) string { return "" }) + if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("run() error = %v", err) + } + if !hostCfg.Enroll || hostCfg.APIToken != "bootstrap-tok" { + t.Fatalf("host config enroll=%v token=%q, want the host module to enroll with the bootstrap token", hostCfg.Enroll, hostCfg.APIToken) + } +} + +func TestRunWithoutEnrollDoesNotExchangeTokens(t *testing.T) { + origEnroll, origHost := enrollAgent, newHostAgent + defer func() { enrollAgent, newHostAgent = origEnroll, origHost }() + + enrollAgent = func(context.Context, hostagent.EnrollmentConfig) (hostagent.EnrollmentResult, error) { + t.Fatal("enrollment ran without --enroll") + return hostagent.EnrollmentResult{}, nil + } + var hostCfg hostagent.Config + newHostAgent = func(cfg hostagent.Config) (Runnable, error) { + hostCfg = cfg + return &mockRunnable{}, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + err := run(ctx, []string{ + "-token", "manual-tok", + "-state-dir", t.TempDir(), + "-enable-docker=false", "-enable-kubernetes=false", + "-disable-auto-update", + "-health-addr", "", + }, func(string) string { return "" }) + if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("run() error = %v", err) + } + if hostCfg.APIToken != "manual-tok" { + t.Fatalf("host token = %q, want the configured token unchanged", hostCfg.APIToken) + } +} + func TestRunConfiguresTypedPrivilegeHelperFromInstallerEnvironment(t *testing.T) { originalHelper := newPrivilegeHelperTelemetry originalUpdate := newPrivilegeHelperUpdate diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 469e7d2737..49d24c606b 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -8157,6 +8157,35 @@ prior live inventory. `TestHandleEnroll_Success`, `internal/api/deploy_handlers_test.go` pin the durable replacement and rollback paths. +### The agent enrolls before it builds any authenticated client + +The agent half of that swap must finish before anything authenticates. A +deploy bootstrap token carries only the enrollment scope and Pulse removes it +on exchange, so a client built from it fails until the process restarts. +`cmd/pulse-agent/main.go` therefore runs `hostagent.Enroll` from +`internal/hostagent/enroll.go` immediately after agent-ID preparation and +before the first remote config fetch. It then builds the remote config client, +the updater, and the host, Docker / Podman, and Kubernetes modules and their +primary report targets from the runtime token and the server-assigned agent +ID, persists that ID to the configured agent-ID file, and turns off the host +module's own enrollment so no second exchange is attempted. Enrolling inside +the host module alone left every other client on the spent bootstrap token for +the whole first process lifetime of a deployed agent. A persisted runtime +token is reused without a request or host discovery, a `403` keeps the +configured token as a non-bootstrap credential, and a permanent rejection +stops startup before any module is built. Windows startup runs before the +service control dispatcher, whose deadline an enrollment retry could outlast, +so the step is gated off there and Windows keeps its previous enrollment +behaviour. Enrollment and reporting derive hostname, OS, and +architecture through one `resolveHostIdentity`, so the runtime token is bound +to the hostname the agent then reports under. +`TestRunEnrollsBeforeBuildingAuthenticatedModules`, +`TestRunStopsBeforeModulesWhenEnrollmentIsRejected`, and +`TestRunLeavesEnrollmentToTheHostModuleWhenGatedOff` in +`cmd/pulse-agent/main_test.go`, and the `TestEnroll*` cases for `Enroll` in +`internal/hostagent/enroll_test.go`, including +`TestEnrollPresentsTheIdentityNewReports`, pin that order and outcome. + ### Alert push routing does not create an agent lifecycle channel The shared API router may classify an external-probe outage for specialized diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 609d75e1e7..6e4658be3c 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -1746,6 +1746,7 @@ "internal/hostagent/commands_host_update_test.go", "internal/hostagent/commands_storage_cleanup_test.go", "internal/hostagent/docker_lifecycle_test.go", + "internal/hostagent/enroll_test.go", "internal/hostagent/issue1595_sas_collection_test.go", "internal/hostagent/observer_delivery_test.go", "internal/hostagent/package_updates_test.go", diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 1d7450ca4a..4c50bb5e03 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -673,7 +673,7 @@ the `white_label` branding entitlement. auth-env reloads, hosted entitlement refresh origins, and pinned-fingerprint TLS clients keep one fail-closed security floor. 9. Change operator-facing Resource Privacy/Data Handling posture through `frontend-modern/src/components/Settings/DataHandlingPanel.tsx` and `frontend-modern/src/components/Settings/dataHandlingPanelModel.ts` together so resource classification, handling-boundary, redaction copy, and the route-backed/hidden-sidebar presentation stay governed as a trust surface. The panel must consume the compact backend-owned policy posture through `frontend-modern/src/hooks/useResourceStats.ts` and `ResourceAPI.getStats()`; it must not hydrate and adapt every unified-resource row merely to display aggregate privacy counts. -10. Change inside-guest runtime collection boundaries through `docs/AGENT_SECURITY.md`, `docs/UNIFIED_AGENT.md`, `cmd/pulse-agent/main.go`, `internal/api/router.go`, and `internal/config/config.go` together. Docker / Podman inventory inside a VM or LXC may come from a guest-local `pulse-agent` module or explicitly reported guest data; LXC Docker inventory may also be collected by a Proxmox host agent only through explicit server opt-in, with optional VMID allowlisting and a minimal summary command set that avoids `docker inspect`, environment, mount, file, command, and process collection. Local Unified Agent Docker / Podman disables must not be reversed by remote profile configuration, and self-test/update preflight that needs the live runtime token must pass it through a short-lived token file rather than argv. The `--enable-docker` help line is part of that operator privacy control, so it must remain "Enable Docker / Podman Agent module" instead of exposing internal collection-module wording. The `--enable-commands` help line and installer disclosure must identify Pulse command execution as disabled by default and required for Patrol actions or the explicit Proxmox LXC Docker inventory path, not as implicit guest access. +10. Change inside-guest runtime collection boundaries through `docs/AGENT_SECURITY.md`, `docs/UNIFIED_AGENT.md`, `cmd/pulse-agent/main.go`, `internal/api/router.go`, and `internal/config/config.go` together. Docker / Podman inventory inside a VM or LXC may come from a guest-local `pulse-agent` module or explicitly reported guest data; LXC Docker inventory may also be collected by a Proxmox host agent only through explicit server opt-in, with optional VMID allowlisting and a minimal summary command set that avoids `docker inspect`, environment, mount, file, command, and process collection. Local Unified Agent Docker / Podman disables must not be reversed by remote profile configuration, and self-test/update preflight that needs the live runtime token must pass it through a short-lived token file rather than argv. A deploy bootstrap token is exchanged before the agent builds any authenticated client, so no module ever holds the enrollment-only credential Pulse revokes on exchange. The `--enable-docker` help line is part of that operator privacy control, so it must remain "Enable Docker / Podman Agent module" instead of exposing internal collection-module wording. The `--enable-commands` help line and installer disclosure must identify Pulse command execution as disabled by default and required for Patrol actions or the explicit Proxmox LXC Docker inventory path, not as implicit guest access. Helper-backed update recovery is part of the same local executable trust boundary. On startup, a pending update handoff must be compared with the SHA-256 identity of `/proc/self/exe`: the candidate identity may proceed diff --git a/internal/hostagent/agent.go b/internal/hostagent/agent.go index aa8eae7e53..72d4c31c7d 100644 --- a/internal/hostagent/agent.go +++ b/internal/hostagent/agent.go @@ -330,13 +330,8 @@ func New(cfg Config) (*Agent, error) { return nil, fmt.Errorf("fetch host info: %w", err) } - hostname := strings.TrimSpace(cfg.HostnameOverride) - if hostname == "" { - hostname = strings.TrimSpace(info.Hostname) - } - if hostname == "" { - hostname = "unknown-host" - } + identity := resolveHostIdentity(collector, info, cfg.HostnameOverride) + hostname := identity.hostname displayName := hostname @@ -351,22 +346,12 @@ func New(cfg Config) (*Agent, error) { } platform := normaliseRuntimePlatform(collector.GOOS(), info.Platform) - // Use Platform (specific distro like "ubuntu") over PlatformFamily (distro family like "debian") - // This ensures Ubuntu shows as "ubuntu 24.04" not "debian 24.04" (refs #927) - osName := strings.TrimSpace(info.Platform) - if osName == "" { - osName = strings.TrimSpace(info.PlatformFamily) - } - osVersion := strings.TrimSpace(info.PlatformVersion) - osName, osVersion = resolveHostOSIdentity(collector, osName, osVersion) + osName, osVersion := identity.osName, identity.osVersion if profile, ok := platformsupport.AgentHostProfileForIdentity(osName, platform); ok { platform = platformsupport.NormalizeRuntimePlatformForAgentHostProfile(profile.ID, platform) } kernelVersion := strings.TrimSpace(info.KernelVersion) - arch := strings.TrimSpace(info.KernelArch) - if arch == "" { - arch = runtime.GOARCH - } + arch := identity.arch client, err := newAgentHTTPClient(cfg.CACertPath, cfg.InsecureSkipVerify, cfg.ServerFingerprint) if err != nil { return nil, fmt.Errorf("invalid TLS configuration: %w", err) @@ -503,6 +488,40 @@ func (a *Agent) nextReportSequenceID() string { return agentshost.FormatReportSequenceID(a.reportStreamID, a.reportSequence.Add(1)) } +// hostIdentity is how the agent names its host to Pulse. Enrollment and +// reporting both derive it here, so the server binds the runtime token to the +// same hostname the agent then reports under. +type hostIdentity struct { + hostname string + osName string + osVersion string + arch string +} + +func resolveHostIdentity(collector SystemCollector, info *gohost.InfoStat, hostnameOverride string) hostIdentity { + hostname := strings.TrimSpace(hostnameOverride) + if hostname == "" { + hostname = strings.TrimSpace(info.Hostname) + } + if hostname == "" { + hostname = "unknown-host" + } + + // Use Platform (specific distro like "ubuntu") over PlatformFamily (distro family like "debian") + // This ensures Ubuntu shows as "ubuntu 24.04" not "debian 24.04" (refs #927) + osName := strings.TrimSpace(info.Platform) + if osName == "" { + osName = strings.TrimSpace(info.PlatformFamily) + } + osName, osVersion := resolveHostOSIdentity(collector, osName, strings.TrimSpace(info.PlatformVersion)) + + arch := strings.TrimSpace(info.KernelArch) + if arch == "" { + arch = runtime.GOARCH + } + return hostIdentity{hostname: hostname, osName: osName, osVersion: osVersion, arch: arch} +} + func agentControlPlaneProxy(request *http.Request) (*url.URL, error) { return resolveAgentControlPlaneProxy(request, http.ProxyFromEnvironment) } diff --git a/internal/hostagent/enroll.go b/internal/hostagent/enroll.go index 98006a5512..3cc5ed3e0c 100644 --- a/internal/hostagent/enroll.go +++ b/internal/hostagent/enroll.go @@ -11,6 +11,8 @@ import ( "path/filepath" "strings" "time" + + "github.com/rs/zerolog" ) const ( @@ -42,6 +44,129 @@ type enrollResponse struct { ReportInterval string `json:"reportInterval"` } +// EnrollmentConfig is what a process needs to exchange its bootstrap token +// before it builds any client that authenticates with it. +type EnrollmentConfig struct { + PulseURL string + APIToken string + StateDir string + HostnameOverride string + AgentVersion string + EnableCommands bool + CommandAuthorityProfile CommandAuthorityProfile + InsecureSkipVerify bool + CACertPath string + ServerFingerprint string + Logger *zerolog.Logger + Collector SystemCollector // Optional: override default system information collector (for testing) +} + +// EnrollmentResult carries the credentials every agent module must use once +// enrollment has run. +type EnrollmentResult struct { + // APIToken is the runtime token, or the configured token unchanged when + // the server reports it is not a bootstrap token. + APIToken string + // AgentID is the server-assigned canonical agent ID. It is empty when no + // exchange happened in this call or the server assigned none. + AgentID string +} + +// Enroll exchanges a bootstrap token for a runtime token, or loads the runtime +// token an earlier run persisted. A process must call it before it builds any +// authenticated client: a bootstrap token carries only the enrollment scope and +// the server revokes it on exchange, so a client built with it keeps failing +// until the process restarts. +func Enroll(ctx context.Context, cfg EnrollmentConfig) (EnrollmentResult, error) { + token := strings.TrimSpace(cfg.APIToken) + if token == "" { + return EnrollmentResult{}, fmt.Errorf("api token is required when enrollment is enabled") + } + commandAuthorityProfile, err := NormalizeCommandAuthorityProfile(string(cfg.CommandAuthorityProfile)) + if err != nil { + return EnrollmentResult{}, err + } + enableCommands := cfg.EnableCommands + if effectiveCommands, accepted := ResolveCommandAuthority(commandAuthorityProfile, enableCommands); !accepted { + enableCommands = effectiveCommands + } + + pulseURL := strings.TrimSpace(cfg.PulseURL) + if pulseURL == "" { + pulseURL = "http://localhost:7655" + } + pulseURL, err = normalizePulseURL(pulseURL) + if err != nil { + return EnrollmentResult{}, fmt.Errorf("invalid pulse URL: %w", err) + } + stateDir := strings.TrimSpace(cfg.StateDir) + if stateDir == "" { + stateDir = defaultStateDir + } + logger := zerolog.Nop() + if cfg.Logger != nil { + logger = cfg.Logger.With().Str("component", "agent").Logger() + } + agentVersion := cfg.AgentVersion + if agentVersion == "" { + agentVersion = Version + } + collector := cfg.Collector + if collector == nil { + collector = &defaultCollector{} + } + // A restart reuses the saved runtime token. That needs no request and no + // host discovery, so a host-information failure cannot block a process + // that is already enrolled. + if persisted := persistedRuntimeToken(collector, stateDir); persisted != "" { + logger.Info().Msg("Found existing runtime token, skipping enrollment") + return EnrollmentResult{APIToken: persisted}, nil + } + + infoCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + info, err := collector.HostInfo(infoCtx) + cancel() + if err != nil { + return EnrollmentResult{}, fmt.Errorf("fetch host info: %w", err) + } + identity := resolveHostIdentity(collector, info, cfg.HostnameOverride) + + client, err := newAgentHTTPClient(cfg.CACertPath, cfg.InsecureSkipVerify, cfg.ServerFingerprint) + if err != nil { + return EnrollmentResult{}, fmt.Errorf("invalid TLS configuration: %w", err) + } + + a := &Agent{ + cfg: Config{APIToken: token, EnableCommands: enableCommands}, + logger: logger, + httpClient: client, + hostname: identity.hostname, + osName: identity.osName, + architecture: identity.arch, + agentVersion: agentVersion, + stateDir: stateDir, + trimmedPulseURL: pulseURL, + collector: collector, + } + if err := a.runEnrollmentLoop(ctx); err != nil { + return EnrollmentResult{}, err + } + return EnrollmentResult{ + APIToken: a.cfg.APIToken, + AgentID: strings.TrimSpace(a.cfg.AgentID), + }, nil +} + +// persistedRuntimeToken returns the runtime token an earlier enrollment saved +// in stateDir, or "" when there is none. +func persistedRuntimeToken(collector SystemCollector, stateDir string) string { + data, err := collector.ReadFile(filepath.Join(stateDir, runtimeTokenFile)) + if err != nil { + return "" + } + return string(bytes.TrimSpace(data)) +} + // runEnrollmentLoop checks for an existing runtime token or performs enrollment // with exponential backoff. On success, updates a.cfg.APIToken in memory and // persists the runtime token to disk. @@ -54,16 +179,11 @@ type enrollResponse struct { // - 400/404: invalid request or target not found — permanent failure, no retry // - 5xx/network errors: retry with exponential backoff func (a *Agent) runEnrollmentLoop(ctx context.Context) error { - tokenPath := filepath.Join(a.stateDir, runtimeTokenFile) - // Check if we already have a persisted runtime token from a previous enrollment. - if data, err := a.collector.ReadFile(tokenPath); err == nil { - token := string(bytes.TrimSpace(data)) - if token != "" { - a.logger.Info().Msg("Found existing runtime token, skipping enrollment") - a.cfg.APIToken = token - return nil - } + if token := persistedRuntimeToken(a.collector, a.stateDir); token != "" { + a.logger.Info().Msg("Found existing runtime token, skipping enrollment") + a.cfg.APIToken = token + return nil } a.logger.Info().Msg("Starting enrollment to exchange bootstrap token for runtime token") diff --git a/internal/hostagent/enroll_test.go b/internal/hostagent/enroll_test.go index 696b548e52..9aa95ea0e9 100644 --- a/internal/hostagent/enroll_test.go +++ b/internal/hostagent/enroll_test.go @@ -3,17 +3,20 @@ package hostagent import ( "context" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" + "runtime" "strings" "sync/atomic" "testing" "time" "github.com/rs/zerolog" + gohost "github.com/shirou/gopsutil/v4/host" ) // realFSCollector returns a mockCollector wired to real filesystem operations. @@ -409,3 +412,247 @@ func TestEnroll_MissingRuntimeToken(t *testing.T) { t.Fatal("expected error for missing runtimeToken") } } + +func enrollmentTestCollector(hostname string) *mockCollector { + collector := realFSCollector() + collector.goos = "darwin" // keep OS identity on HostInfo, not the test machine's files + collector.hostInfoFn = func(context.Context) (*gohost.InfoStat, error) { + return &gohost.InfoStat{Hostname: hostname, Platform: "debian", KernelArch: "x86_64"}, nil + } + return collector +} + +// A process that enrolls before building its modules must get back the +// runtime token and canonical agent ID, persisted where a restart finds them, +// and must present the same host identity New would report under. +func TestEnrollExchangesBootstrapTokenBeforeModulesStart(t *testing.T) { + var gotPayload enrollPayload + var gotAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != enrollEndpoint || r.Method != http.MethodPost { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + gotAuth = r.Header.Get("Authorization") + if err := json.NewDecoder(r.Body).Decode(&gotPayload); err != nil { + t.Fatalf("decode request: %v", err) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(enrollResponse{AgentID: "canonical-delly2", RuntimeToken: "runtime-tok"}) + })) + defer server.Close() + + stateDir := t.TempDir() + logger := zerolog.New(zerolog.NewTestWriter(t)) + result, err := Enroll(context.Background(), EnrollmentConfig{ + PulseURL: server.URL, + APIToken: "bootstrap-tok", + StateDir: stateDir, + HostnameOverride: "delly2", + AgentVersion: "6.5.0", + EnableCommands: true, + CommandAuthorityProfile: CommandAuthorityCommandCapable, + Logger: &logger, + Collector: enrollmentTestCollector("ignored-by-override"), + }) + if err != nil { + t.Fatalf("Enroll() error = %v", err) + } + if result.APIToken != "runtime-tok" || result.AgentID != "canonical-delly2" { + t.Fatalf("Enroll() = %+v, want runtime token and canonical agent ID", result) + } + if gotAuth != "Bearer bootstrap-tok" { + t.Fatalf("enrollment authenticated with %q, want the bootstrap token", gotAuth) + } + want := enrollPayload{Hostname: "delly2", OS: "debian", Arch: "x86_64", AgentVersion: "6.5.0", CommandsEnabled: true} + if gotPayload != want { + t.Fatalf("enrollment payload = %+v, want %+v", gotPayload, want) + } + for file, want := range map[string]string{runtimeTokenFile: "runtime-tok", "agent-id": "canonical-delly2"} { + data, err := os.ReadFile(filepath.Join(stateDir, file)) + if err != nil || string(data) != want { + t.Fatalf("%s = %q (err %v), want %q", file, data, err, want) + } + } +} + +// A monitoring-only install must not ask the server for command authority +// during enrollment, matching the ceiling New applies. +func TestEnrollHonoursMonitoringOnlyCommandCeiling(t *testing.T) { + var gotPayload enrollPayload + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&gotPayload) + json.NewEncoder(w).Encode(enrollResponse{RuntimeToken: "runtime-tok"}) + })) + defer server.Close() + + if _, err := Enroll(context.Background(), EnrollmentConfig{ + PulseURL: server.URL, + APIToken: "bootstrap-tok", + StateDir: t.TempDir(), + EnableCommands: true, + CommandAuthorityProfile: CommandAuthorityMonitoringOnly, + Collector: enrollmentTestCollector("pve-3"), + }); err != nil { + t.Fatalf("Enroll() error = %v", err) + } + if gotPayload.CommandsEnabled { + t.Fatal("monitoring-only enrollment requested command authority") + } + if gotPayload.Hostname != "pve-3" { + t.Fatalf("enrollment hostname = %q, want the discovered hostname", gotPayload.Hostname) + } +} + +// A restart with a persisted runtime token must not contact the server again. +func TestEnrollReusesPersistedRuntimeToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("persisted runtime token must not trigger an enrollment request") + })) + defer server.Close() + + stateDir := t.TempDir() + if err := os.WriteFile(filepath.Join(stateDir, runtimeTokenFile), []byte("persisted-runtime-tok\n"), 0600); err != nil { + t.Fatal(err) + } + result, err := Enroll(context.Background(), EnrollmentConfig{ + PulseURL: server.URL, + APIToken: "bootstrap-tok", + StateDir: stateDir, + Collector: enrollmentTestCollector("pve-3"), + }) + if err != nil { + t.Fatalf("Enroll() error = %v", err) + } + if result.APIToken != "persisted-runtime-tok" || result.AgentID != "" { + t.Fatalf("Enroll() = %+v, want the persisted token and no new agent ID", result) + } +} + +func TestEnrollKeepsTokenTheServerSaysIsNotABootstrapToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "Forbidden", http.StatusForbidden) + })) + defer server.Close() + + result, err := Enroll(context.Background(), EnrollmentConfig{ + PulseURL: server.URL, + APIToken: "manual-install-tok", + StateDir: t.TempDir(), + Collector: enrollmentTestCollector("pve-3"), + }) + if err != nil { + t.Fatalf("Enroll() error = %v", err) + } + if result.APIToken != "manual-install-tok" || result.AgentID != "" { + t.Fatalf("Enroll() = %+v, want the original token unchanged", result) + } +} + +func TestEnrollReturnsPermanentRejection(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "token already consumed", http.StatusConflict) + })) + defer server.Close() + + _, err := Enroll(context.Background(), EnrollmentConfig{ + PulseURL: server.URL, + APIToken: "spent-bootstrap-tok", + StateDir: t.TempDir(), + Collector: enrollmentTestCollector("pve-3"), + }) + if err == nil || !strings.Contains(err.Error(), "409") { + t.Fatalf("Enroll() error = %v, want the permanent 409 rejection", err) + } +} + +func TestEnrollRequiresToken(t *testing.T) { + if _, err := Enroll(context.Background(), EnrollmentConfig{PulseURL: "http://127.0.0.1:7655"}); err == nil { + t.Fatal("Enroll() without a token succeeded") + } +} + +// A restart that already holds a runtime token must not depend on host +// discovery: Docker- or Kubernetes-only agents never needed it to start. +func TestEnrollReusesPersistedRuntimeTokenWithoutHostDiscovery(t *testing.T) { + stateDir := t.TempDir() + if err := os.WriteFile(filepath.Join(stateDir, runtimeTokenFile), []byte("persisted-runtime-tok"), 0600); err != nil { + t.Fatal(err) + } + collector := realFSCollector() + collector.hostInfoFn = func(context.Context) (*gohost.InfoStat, error) { + return nil, errors.New("host information unavailable") + } + + result, err := Enroll(context.Background(), EnrollmentConfig{ + PulseURL: "http://127.0.0.1:1", + APIToken: "bootstrap-tok", + StateDir: stateDir, + Collector: collector, + }) + if err != nil { + t.Fatalf("Enroll() error = %v, want the persisted token without host discovery", err) + } + if result.APIToken != "persisted-runtime-tok" { + t.Fatalf("Enroll() token = %q, want the persisted runtime token", result.APIToken) + } +} + +// The server binds the runtime token to the hostname presented at +// enrollment, so Enroll must present exactly the identity New reports under, +// including Linux distribution detection and the architecture fallback. +func TestEnrollPresentsTheIdentityNewReports(t *testing.T) { + collector := &mockCollector{ + hostInfoFn: func(context.Context) (*gohost.InfoStat, error) { + return &gohost.InfoStat{ + Hostname: " pve-host ", + HostID: "hid", + Platform: "debian", + PlatformFamily: "debian", + PlatformVersion: "13.4", + }, nil + }, + statFn: func(name string) (os.FileInfo, error) { + if name == "/etc/pve" { + return nil, nil + } + return nil, os.ErrNotExist + }, + lookPathFn: func(file string) (string, error) { + if file == "pveversion" { + return "/usr/bin/pveversion", nil + } + return "", os.ErrNotExist + }, + commandCombinedOutputFn: func(context.Context, string, ...string) (string, error) { + return "pve-manager/9.1.9/ee7bad0a3d1546c9 (running kernel: 7.0.0-3-pve)", nil + }, + } + agent, err := New(Config{APIToken: "token", HostnameOverride: " ", LogLevel: zerolog.InfoLevel, Collector: collector}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + var gotPayload enrollPayload + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&gotPayload) + json.NewEncoder(w).Encode(enrollResponse{RuntimeToken: "runtime-tok"}) + })) + defer server.Close() + if _, err := Enroll(context.Background(), EnrollmentConfig{ + PulseURL: server.URL, + APIToken: "bootstrap-tok", + StateDir: t.TempDir(), + HostnameOverride: " ", + Collector: collector, + }); err != nil { + t.Fatalf("Enroll() error = %v", err) + } + + if gotPayload.Hostname != agent.hostname || gotPayload.OS != agent.osName || gotPayload.Arch != agent.architecture { + t.Fatalf("enrollment identity = %q/%q/%q, New reports %q/%q/%q", + gotPayload.Hostname, gotPayload.OS, gotPayload.Arch, agent.hostname, agent.osName, agent.architecture) + } + if agent.hostname != "pve-host" || agent.osName != proxmoxPVEOSName || agent.architecture != runtime.GOARCH { + t.Fatalf("New identity = %q/%q/%q, want the trimmed hostname, Proxmox VE, and the runtime architecture", agent.hostname, agent.osName, agent.architecture) + } +} From ba4c0c5299e3d76223da1122ba847b0849d5142b Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:17:26 +0100 Subject: [PATCH 2/2] Expect the enrollment tests in the unified agent runtime proof list --- scripts/release_control/canonical_completion_guard_test.py | 1 + scripts/release_control/subsystem_lookup_test.py | 1 + 2 files changed, 2 insertions(+) diff --git a/scripts/release_control/canonical_completion_guard_test.py b/scripts/release_control/canonical_completion_guard_test.py index 3b2103f2e9..0202ab2631 100644 --- a/scripts/release_control/canonical_completion_guard_test.py +++ b/scripts/release_control/canonical_completion_guard_test.py @@ -797,6 +797,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase): "internal/hostagent/commands_host_update_test.go", "internal/hostagent/commands_storage_cleanup_test.go", "internal/hostagent/docker_lifecycle_test.go", + "internal/hostagent/enroll_test.go", "internal/hostagent/issue1595_sas_collection_test.go", "internal/hostagent/observer_delivery_test.go", "internal/hostagent/package_updates_test.go", diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 8521b32545..28aeddf79d 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -4715,6 +4715,7 @@ class SubsystemLookupTest(unittest.TestCase): "internal/hostagent/commands_host_update_test.go", "internal/hostagent/commands_storage_cleanup_test.go", "internal/hostagent/docker_lifecycle_test.go", + "internal/hostagent/enroll_test.go", "internal/hostagent/issue1595_sas_collection_test.go", "internal/hostagent/observer_delivery_test.go", "internal/hostagent/package_updates_test.go",