Exchange the bootstrap token before any agent module starts (#2520)

## Problem

An agent installed by the cluster deploy flow starts with `--enroll` and
a one-time bootstrap token. Only the host module exchanged that token
for the runtime token. Everything else `main.go` builds at startup kept
the bootstrap token for the whole first process lifetime:

- the remote config client (initial fetch and the one-minute refresh)
- the updater
- the Docker / Podman and Kubernetes modules and their primary report
targets

The bootstrap token carries only the enrollment scope and the server
removes it on exchange, so each of those failed with 401 until the
service restarted. On a freshly deployed Proxmox node this showed up as
a remote config 401 every minute. Those modules also kept the
pre-enrollment agent ID instead of the server-assigned one.

## Fix

`main.go` now enrolls once, before it builds any authenticated client:

- `hostagent.Enroll` is a standalone entry point over the existing
enrollment loop. Payload, headers, retry and backoff, status handling,
and `runtime.token` / `agent-id` persistence are unchanged.
- It runs after self-test and agent-ID preparation and before the first
remote config fetch. Every module is then built from the runtime token
and the canonical agent ID.
- The ID is persisted to the configured agent-ID file, and the host
module's own enrollment is switched off so there is no second exchange.
- A persisted runtime token is reused without a request or host
discovery. A `403` keeps the configured token as a non-bootstrap
credential. A permanent rejection stops startup before any module is
built.
- Windows is gated off and keeps its previous behaviour. Startup there
runs before the service control dispatcher, and an enrollment retry
could outlast its 30-second deadline. No Windows installer passes
`--enroll`.
- Enrollment and reporting now derive hostname, OS and architecture
through one `resolveHostIdentity`, so the runtime token is bound to the
hostname the agent reports under.

## Tests

- `TestRunEnrollsBeforeBuildingAuthenticatedModules`: enrollment happens
before any request to Pulse. Remote config, the updater, and the host,
Docker and Kubernetes configs and their targets all carry the runtime
token and canonical ID, and the agent-ID file is written. With the new
step disabled, this test fails.
- `TestRunStopsBeforeModulesWhenEnrollmentIsRejected`,
`TestRunWithoutEnrollDoesNotExchangeTokens` and
`TestRunLeavesEnrollmentToTheHostModuleWhenGatedOff`.
- `TestEnroll*` cases for `Enroll`: payload and identity, the
monitoring-only command ceiling, reuse of a persisted token (including
when host discovery fails), the 403 keep, the 409 rejection, and a
missing token.
- `TestEnrollPresentsTheIdentityNewReports` checks that enrollment
presents the hostname, OS and architecture that `New` reports, with
Proxmox detection and the architecture fallback.
- `go test ./cmd/pulse-agent ./internal/hostagent
./internal/remoteconfig` passes. `go vet` is clean for linux, windows
and freebsd.

## Not in this change

- The health listener still starts after enrollment, so `/readyz` is
unreachable while enrollment retries on network errors. The installer
only warns in that case. With the health listener enabled, its
registration check now always sees the runtime token. With it disabled,
the process-only check can still race enrollment.
- Distinguishing a deploy binding mismatch from a non-bootstrap token,
which both return 403.
- Two older durability gaps that this change doesn't make worse. If
writing a custom `--agent-id-file` fails, the next start has no fallback
to the canonical ID saved in the state directory. If persisting
`runtime.token` fails, enrollment still succeeds, and the next restart
is left with the spent bootstrap token.
This commit is contained in:
rcourtman
2026-10-05 17:05:17 +01:00
committed by GitHub
10 changed files with 722 additions and 30 deletions
+47 -1
View File
@@ -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...")
+228
View File
@@ -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
@@ -8187,6 +8187,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
@@ -1756,6 +1756,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",
@@ -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
+38 -19
View File
@@ -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)
}
+129 -9
View File
@@ -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")
+247
View File
@@ -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)
}
}
@@ -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",
@@ -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",