fix(agent): warn when the server overrides a presented agent ID

Host identity continuity deliberately keeps a known machine on its
enrolled ID, so a custom --agent-id or hand-edited agent-id state file
is silently ignored for an already-known host, and the acknowledged ID
then overwrites the state file within one report cycle. That looked
like corruption from the operator's seat. Warn once per resolved
identity with both IDs and the supported path to a fresh enrollment
(remove the host in Pulse first).

Refs #1739

Contract-Neutral: diagnostic logging only in hostagent report ack path, refs #1739
This commit is contained in:
rcourtman
2026-08-18 06:25:20 +01:00
parent 3088f52dc0
commit ff0c9ea4d8
2 changed files with 59 additions and 0 deletions
+24
View File
@@ -181,6 +181,11 @@ type Agent struct {
availability *availabilityProbeModule
reportStreamID string
reportSequence atomic.Uint64
// canonicalIDWarned holds the last server-acknowledged agent ID a
// mismatch warning was emitted for, so the warning fires once per
// resolved identity instead of on every report cycle. Only the
// report/enroll ack paths touch it, which run sequentially.
canonicalIDWarned string
// libreHardwareMonitorEndpoint is an unexported test seam. Production
// collection always uses the fixed loopback URL when this is empty.
libreHardwareMonitorEndpoint string
@@ -1372,6 +1377,7 @@ func (a *Agent) sendReportToDestination(ctx context.Context, report agentshost.R
// Persist the server-acknowledged agent ID so uninstall can deregister.
canonicalAgentID := strings.TrimSpace(reportResp.AgentID)
if canonicalAgentID != "" {
a.warnCanonicalIDMismatch(canonicalAgentID)
a.persistAgentID(canonicalAgentID)
}
@@ -1383,6 +1389,24 @@ func (a *Agent) sendReportToDestination(ctx context.Context, report agentshost.R
return nil
}
// warnCanonicalIDMismatch surfaces the case where the server resolved this
// agent to a different identity than the one it presented. Host identity
// continuity deliberately keeps a known machine on its enrolled ID even when
// the operator passes --agent-id or hand-edits the agent-id state file
// (#1739), and the acknowledged ID then overwrites that file for uninstall
// deregistration. Without this warning the override is ignored silently and
// the file rewrite looks like corruption. Warns once per resolved identity.
func (a *Agent) warnCanonicalIDMismatch(canonicalAgentID string) {
if canonicalAgentID == a.agentID || canonicalAgentID == a.canonicalIDWarned {
return
}
a.canonicalIDWarned = canonicalAgentID
a.logger.Warn().
Str("presented_agent_id", a.agentID).
Str("server_agent_id", canonicalAgentID).
Msg("Server resolved this agent to its enrolled identity and the presented agent ID was not adopted. Host identity continuity keeps a known machine on its existing ID. To enroll under a new ID, remove the host in Pulse first, then restart the agent.")
}
// persistAgentID writes the server-assigned agent ID to the state directory.
// This file is read by the uninstall script to deregister the agent from the server.
// Errors are debug-logged, never fatal — same resilience pattern as proxmox_setup.go.
+35
View File
@@ -142,3 +142,38 @@ func TestPersistAgentID_MkdirAllPermissions(t *testing.T) {
t.Fatalf("MkdirAll perm = %o, want %o", gotPerm, 0700)
}
}
func TestWarnCanonicalIDMismatch_WarnsOncePerResolvedIdentity(t *testing.T) {
var logs []string
sink := zerolog.New(writerFunc(func(p []byte) (int, error) {
logs = append(logs, string(p))
return len(p), nil
}))
a := &Agent{
logger: sink,
agentID: "custom-id",
}
a.warnCanonicalIDMismatch("machine-id-1")
a.warnCanonicalIDMismatch("machine-id-1")
if len(logs) != 1 {
t.Fatalf("expected 1 warning for repeated identical mismatch, got %d", len(logs))
}
a.warnCanonicalIDMismatch("machine-id-2")
if len(logs) != 2 {
t.Fatalf("expected a new warning for a different resolved identity, got %d", len(logs))
}
// Matching identity never warns and resets nothing.
a.agentID = "machine-id-2"
a.warnCanonicalIDMismatch("machine-id-2")
if len(logs) != 2 {
t.Fatalf("expected no warning when acknowledged ID matches, got %d", len(logs))
}
}
type writerFunc func(p []byte) (int, error)
func (w writerFunc) Write(p []byte) (int, error) { return w(p) }