mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
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:
+47
-1
@@ -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...")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user