fix: agent auth, heartbeat, and SSE all break after server restart
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m9s
CI / docker (push) Successful in 41s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 37s

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:
hsiegeln
2026-04-03 10:41:23 +02:00
parent 0632f1c6a8
commit 2bc3efad7f
3 changed files with 46 additions and 25 deletions

View File

@@ -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()) {

View File

@@ -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) {

View File

@@ -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());
}