Make agent command-exec token rejection actionable, not a silent 'Invalid token'

An agent enrolled for metrics but whose token the server doesn't recognise (or
that lacks the agent:exec scope, or is bound to a different agent) was rejected
on the command-exec WebSocket with a bare 'Invalid token' and — for the
token-not-found case — no server log at all. The agent then retried forever,
logging only 'Invalid token', so the operator had no signal that discovery
deep-scan was failing or why. (Confirmed live: delly/minipc agents pointed at a
backend that didn't recognise their token retried thousands of times; discovery
abstained for every guest as a result.)

- agentexec/server.go: the registration-rejection message the agent logs
  verbatim now says 'agent token not authorized for command execution — re-run
  the agent installer to enroll an agent:exec-scoped token'.
- api/agent_exec_token_binding.go: the previously-silent token-not-recognised
  branch now logs the specific reason with the agent hostname.

Contract-neutral: same rejection behaviour, just legible. Regression test:
TestHandleWebSocket_RejectionMessageIsActionable. Verified live end-to-end.
This commit is contained in:
rcourtman
2026-06-09 17:55:09 +01:00
parent 893465f55a
commit 61c8e8ca00
3 changed files with 47 additions and 1 deletions
+6 -1
View File
@@ -433,7 +433,12 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
// Validate token
if !s.validateToken(reg.Token, reg.AgentID, reg.Hostname) {
log.Warn().Str("agent_id", reg.AgentID).Msg("Agent registration rejected: invalid token")
rejectedMsg, err := NewMessage(MsgTypeRegistered, "", RegisteredPayload{Success: false, Message: "Invalid token"})
// Actionable message instead of a bare "Invalid token": the agent logs
// this verbatim, and the dominant causes (token not recognised, or not
// bound to this agent) are both fixed by re-enrolling, while a token
// that exists but lacks the scope is named explicitly. Avoids the silent
// retry loop that previously gave operators nothing to act on.
rejectedMsg, err := NewMessage(MsgTypeRegistered, "", RegisteredPayload{Success: false, Message: "agent token not authorized for command execution — re-run the agent installer to enroll an agent:exec-scoped token"})
if err != nil {
log.Warn().Err(err).Str("agent_id", reg.AgentID).Msg("Failed to encode rejection message")
conn.Close()
@@ -173,6 +173,39 @@ func TestHandleWebSocket_InvalidTokenRejectionSendFailure(t *testing.T) {
waitFor(t, 2*time.Second, func() bool { return !s.IsAgentConnected("a1") })
}
func TestHandleWebSocket_RejectionMessageIsActionable(t *testing.T) {
// The agent logs the rejection message verbatim, so it must tell the operator
// how to fix it (re-enroll / agent:exec scope) rather than the opaque
// "Invalid token" that previously left a stale-enrollment agent retrying
// forever with no actionable signal.
s := NewServer(func(string, string, string) bool { return false })
ts := newWSServer(t, s)
defer ts.Close()
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer conn.Close()
wsWriteMessage(t, conn, mustNewMessage(t, MsgTypeAgentRegister, "", AgentRegisterPayload{
AgentID: "a1",
Hostname: "host1",
Token: "bad",
}))
payload := wsReadRegisteredPayload(t, conn)
if payload.Success {
t.Fatalf("expected registration to be rejected")
}
if payload.Message == "Invalid token" {
t.Fatalf("rejection message must not be the bare opaque 'Invalid token'")
}
if !strings.Contains(payload.Message, "re-run the agent installer") {
t.Fatalf("rejection message should point the operator at re-enrollment, got: %q", payload.Message)
}
}
func TestHandleWebSocket_RegistrationAckSendFailure(t *testing.T) {
origWriteTextMessage := writeTextMessage
t.Cleanup(func() { writeTextMessage = origWriteTextMessage })
+8
View File
@@ -25,6 +25,14 @@ func (r *Router) validateAgentExecToken(token string, agentID string, hostname s
record, ok := r.config.ValidateAPIToken(token)
if !ok {
config.Mu.Unlock()
// This is the branch a stale-enrollment agent hits: it holds a token
// from a prior install that this server no longer recognises. It was
// previously the only rejection path with no log, which made a looping
// "Invalid token" agent impossible to diagnose without reading source.
log.Warn().
Str("agent_id", requestedID).
Str("hostname", requestedHost).
Msg("Agent exec token not recognized by this server — re-run the agent installer to re-enroll this agent")
return false
}