fix: agent auth, heartbeat, and SSE all break after server restart
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<Void> 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()) {
|
||||
|
||||
@@ -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.
|
||||
* <p>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<GrantedAuthority> 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<String> roles = result.roles();
|
||||
if (roles.isEmpty()) {
|
||||
roles = List.of("AGENT");
|
||||
}
|
||||
List<GrantedAuthority> 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<String> roles = result.roles();
|
||||
if (!subject.startsWith("user:") && roles.isEmpty()) {
|
||||
roles = List.of("AGENT");
|
||||
}
|
||||
List<GrantedAuthority> 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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user