From 2bc3efad7f543d85f36cfeb9f6d72dd23e38a323 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:41:23 +0200 Subject: [PATCH] fix: agent auth, heartbeat, and SSE all break after server restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related issues caused by in-memory agent registry being empty after server restart: 1. JwtAuthenticationFilter rejected valid agent JWTs if agent wasn't in registry — now authenticates any valid JWT regardless 2. Heartbeat returned 404 for unknown agents — now auto-registers the agent from JWT claims (subject, application) 3. SSE endpoint returned 404 — same auto-registration fix JWT validation result is stored as a request attribute so downstream controllers can extract the application claim for auto-registration. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../AgentRegistrationController.java | 20 ++++++++++--- .../app/controller/AgentSseController.java | 22 ++++++++++++-- .../app/security/JwtAuthenticationFilter.java | 29 +++++++------------ 3 files changed, 46 insertions(+), 25 deletions(-) diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java index 53dea12c..a69481ea 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java @@ -9,6 +9,7 @@ import com.cameleer3.server.app.dto.AgentRegistrationResponse; import com.cameleer3.server.app.dto.ErrorResponse; import com.cameleer3.common.model.HeartbeatRequest; import com.cameleer3.server.app.security.BootstrapTokenValidator; +import com.cameleer3.server.app.security.JwtAuthenticationFilter; import com.cameleer3.server.core.admin.AuditCategory; import com.cameleer3.server.core.admin.AuditResult; import com.cameleer3.server.core.admin.AuditService; @@ -195,14 +196,25 @@ public class AgentRegistrationController { @PostMapping("/{id}/heartbeat") @Operation(summary = "Agent heartbeat ping", - description = "Updates the agent's last heartbeat timestamp") + description = "Updates the agent's last heartbeat timestamp. Auto-registers the agent if not in registry (e.g. after server restart).") @ApiResponse(responseCode = "200", description = "Heartbeat accepted") - @ApiResponse(responseCode = "404", description = "Agent not registered") public ResponseEntity heartbeat(@PathVariable String id, - @RequestBody(required = false) HeartbeatRequest request) { + @RequestBody(required = false) HeartbeatRequest request, + HttpServletRequest httpRequest) { boolean found = registryService.heartbeat(id); if (!found) { - return ResponseEntity.notFound().build(); + // Auto-heal: re-register agent from JWT claims after server restart + var jwtResult = (JwtService.JwtValidationResult) httpRequest.getAttribute( + JwtAuthenticationFilter.JWT_RESULT_ATTR); + if (jwtResult != null) { + String application = jwtResult.application() != null ? jwtResult.application() : "default"; + registryService.register(id, id, application, "unknown", + List.of(), Map.of()); + registryService.heartbeat(id); + log.info("Auto-registered agent {} (app={}) from heartbeat after server restart", id, application); + } else { + return ResponseEntity.notFound().build(); + } } if (request != null && request.getRouteStates() != null && !request.getRouteStates().isEmpty()) { diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentSseController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentSseController.java index f6598e66..4c568a7e 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentSseController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentSseController.java @@ -1,12 +1,15 @@ package com.cameleer3.server.app.controller; import com.cameleer3.server.app.agent.SseConnectionManager; +import com.cameleer3.server.app.security.JwtAuthenticationFilter; import com.cameleer3.server.core.agent.AgentInfo; import com.cameleer3.server.core.agent.AgentRegistryService; +import com.cameleer3.server.core.security.JwtService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; @@ -19,6 +22,9 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import java.util.List; +import java.util.Map; + /** * SSE endpoint for real-time event streaming to agents. *

@@ -47,15 +53,25 @@ public class AgentSseController { + "Commands (config-update, deep-trace, replay) are pushed as events. " + "Ping keepalive comments sent every 15 seconds.") @ApiResponse(responseCode = "200", description = "SSE stream opened") - @ApiResponse(responseCode = "404", description = "Agent not registered") + @ApiResponse(responseCode = "404", description = "Agent not registered and cannot be auto-registered") public SseEmitter events( @PathVariable String id, @Parameter(description = "Last received event ID (no replay, acknowledged only)") - @RequestHeader(value = "Last-Event-ID", required = false) String lastEventId) { + @RequestHeader(value = "Last-Event-ID", required = false) String lastEventId, + HttpServletRequest httpRequest) { AgentInfo agent = registryService.findById(id); if (agent == null) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Agent not found: " + id); + // Auto-heal: re-register agent from JWT claims after server restart + var jwtResult = (JwtService.JwtValidationResult) httpRequest.getAttribute( + JwtAuthenticationFilter.JWT_RESULT_ATTR); + if (jwtResult != null) { + String application = jwtResult.application() != null ? jwtResult.application() : "default"; + registryService.register(id, id, application, "unknown", List.of(), Map.of()); + log.info("Auto-registered agent {} (app={}) from SSE connect after server restart", id, application); + } else { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Agent not found: " + id); + } } if (lastEventId != null) { diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java index c3620c8c..1637c570 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java @@ -32,6 +32,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilter.class); private static final String BEARER_PREFIX = "Bearer "; + public static final String JWT_RESULT_ATTR = "cameleer.jwt.result"; private final JwtService jwtService; private final AgentRegistryService agentRegistryService; @@ -52,25 +53,17 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { JwtValidationResult result = jwtService.validateAccessToken(token); String subject = result.subject(); - if (subject.startsWith("user:")) { - // UI user token — authenticate with roles from JWT - List authorities = toAuthorities(result.roles()); - UsernamePasswordAuthenticationToken auth = - new UsernamePasswordAuthenticationToken(subject, null, authorities); - SecurityContextHolder.getContext().setAuthentication(auth); - } else if (agentRegistryService.findById(subject) != null) { - // Agent token — use roles from JWT, default to AGENT if empty - List roles = result.roles(); - if (roles.isEmpty()) { - roles = List.of("AGENT"); - } - List authorities = toAuthorities(roles); - UsernamePasswordAuthenticationToken auth = - new UsernamePasswordAuthenticationToken(subject, null, authorities); - SecurityContextHolder.getContext().setAuthentication(auth); - } else { - log.debug("JWT valid but agent not found: {}", subject); + // Authenticate any valid JWT — agent registry is not authoritative + // (agents may hold valid tokens after server restart clears the in-memory registry) + List roles = result.roles(); + if (!subject.startsWith("user:") && roles.isEmpty()) { + roles = List.of("AGENT"); } + List authorities = toAuthorities(roles); + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken(subject, null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + request.setAttribute(JWT_RESULT_ATTR, result); } catch (Exception e) { log.debug("JWT validation failed: {}", e.getMessage()); }