package httpapi import ( "context" "errors" "log/slog" "net/http" "time" "github.com/syncova/syncova/packages/agentregistry" "github.com/syncova/syncova/packages/platform/logging" ) // agentContextKeyType ist der private Typ des Context-Schlüssels für den Agent. type agentContextKeyType struct{} // agentContextKey speichert den angemeldeten Agent im Request-Context. var agentContextKey = agentContextKeyType{} // AuthenticatedAgentFromContext liest den angemeldeten Agent aus dem Context. func AuthenticatedAgentFromContext(currentContext context.Context) (agentregistry.Agent, bool) { authenticatedAgent, isPresent := currentContext.Value(agentContextKey).(agentregistry.Agent) return authenticatedAgent, isPresent } // agentHandler bedient die Agent-Endpunkte (SYNCOVA_API.md §6). type agentHandler struct { // agentService ist die Domänenlogik der Agent-Verwaltung. agentService *agentregistry.Service // logger protokolliert technische Fehler. logger *slog.Logger } // enrollmentTokenRequest ist der Rumpf von POST /agents/enrollment-tokens. type enrollmentTokenRequest struct { // AgentName ist der vorgesehene Name des aufzunehmenden Agents. AgentName string `json:"agent_name"` // ValidityMinutes ist die Gültigkeitsdauer in Minuten; 0 wählt den Standardwert. ValidityMinutes int `json:"validity_minutes"` } // agentRegistrationRequest ist der Rumpf von POST /agents/register. type agentRegistrationRequest struct { // EnrollmentToken ist das Aufnahme-Token. EnrollmentToken string `json:"enrollment_token"` // Hostname ist der Rechnername des Systems. Hostname string `json:"hostname"` // Platform ist das Betriebssystem. Platform string `json:"platform"` // Architecture ist die Rechnerarchitektur. Architecture string `json:"architecture"` // Version ist die Programmversion des Agents. Version string `json:"version"` } // agentHeartbeatRequest ist der Rumpf von POST /agents/heartbeat. type agentHeartbeatRequest struct { // Version ist die aktuelle Programmversion des Agents. Version string `json:"version"` } // handleIssueEnrollmentToken bedient POST /agents/enrollment-tokens. func (handler *agentHandler) handleIssueEnrollmentToken(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) var tokenPayload enrollmentTokenRequest if decodeError := decodeJSONBody(request, &tokenPayload); decodeError != nil { WriteError(responseWriter, request, requestLogger, decodeError) return } if tokenPayload.AgentName == "" { WriteError(responseWriter, request, requestLogger, NewValidationError("Der Name des Agents ist erforderlich.")) return } validity := time.Duration(tokenPayload.ValidityMinutes) * time.Minute enrollmentToken, issueError := handler.agentService.IssueEnrollmentToken(request.Context(), tokenPayload.AgentName, validity, actingUser, RequestContextFrom(request)) if issueError != nil { WriteError(responseWriter, request, requestLogger, translateAgentError(issueError)) return } // Das Token erscheint genau einmal in dieser Antwort und wird bewusst nicht // geloggt (PROMPT.md §12). WriteSuccess(responseWriter, request, http.StatusCreated, enrollmentToken) } // handleRegisterAgent bedient POST /agents/register. // // Der Endpunkt ist ohne Benutzeranmeldung erreichbar: ein sich aufnehmender // Agent besitzt noch kein Betriebstoken. Sein Nachweis ist das Aufnahme-Token. func (handler *agentHandler) handleRegisterAgent(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) var registrationPayload agentRegistrationRequest if decodeError := decodeJSONBody(request, ®istrationPayload); decodeError != nil { WriteError(responseWriter, request, requestLogger, decodeError) return } if registrationPayload.EnrollmentToken == "" { WriteError(responseWriter, request, requestLogger, NewValidationError("Das Aufnahme-Token ist erforderlich.")) return } registrationResult, registerError := handler.agentService.Register(request.Context(), agentregistry.RegistrationRequest{ EnrollmentToken: registrationPayload.EnrollmentToken, Hostname: registrationPayload.Hostname, Platform: agentregistry.Platform(registrationPayload.Platform), Architecture: registrationPayload.Architecture, Version: registrationPayload.Version, IPAddress: clientIPAddress(request), }) if registerError != nil { WriteError(responseWriter, request, requestLogger, translateAgentError(registerError)) return } WriteSuccess(responseWriter, request, http.StatusCreated, registrationResult) } // handleHeartbeat bedient POST /agents/heartbeat. // // Der Endpunkt wird vom Agent selbst aufgerufen und verlangt dessen // Betriebstoken, nicht die Sitzung eines Benutzers. func (handler *agentHandler) handleHeartbeat(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) authenticatedAgent, isAuthenticated := AuthenticatedAgentFromContext(request.Context()) if !isAuthenticated { WriteError(responseWriter, request, requestLogger, newUnauthenticatedError("Für diesen Zugriff ist ein Agent-Token erforderlich.")) return } var heartbeatPayload agentHeartbeatRequest if decodeError := decodeJSONBody(request, &heartbeatPayload); decodeError != nil { WriteError(responseWriter, request, requestLogger, decodeError) return } if heartbeatError := handler.agentService.RecordHeartbeat(request.Context(), authenticatedAgent, agentregistry.HeartbeatRequest{ Version: heartbeatPayload.Version, IPAddress: clientIPAddress(request), }); heartbeatError != nil { WriteError(responseWriter, request, requestLogger, translateAgentError(heartbeatError)) return } WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{ "acknowledged": true, "server_time": time.Now().UTC(), }) } // handleListAgents bedient GET /agents. func (handler *agentHandler) handleListAgents(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) page, pageSize := parsePagination(request) listedAgents, totalCount, listError := handler.agentService.ListAgents(request.Context(), agentregistry.AgentFilter{ Status: request.URL.Query().Get("status"), Platform: request.URL.Query().Get("platform"), Page: page, PageSize: pageSize, }) if listError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(listError)) return } WritePaginatedSuccess(responseWriter, request, listedAgents, PaginationMeta{ Page: page, PageSize: pageSize, Total: totalCount, }) } // handleGetAgent bedient GET /agents/{id}. func (handler *agentHandler) handleGetAgent(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) agentID, parseError := parsePathUUID(request, "id") if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } foundAgent, loadError := handler.agentService.GetAgent(request.Context(), agentID) if loadError != nil { WriteError(responseWriter, request, requestLogger, translateAgentError(loadError)) return } WriteSuccess(responseWriter, request, http.StatusOK, foundAgent) } // handleRevokeAgent bedient POST /agents/{id}/revoke. func (handler *agentHandler) handleRevokeAgent(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) agentID, parseError := parsePathUUID(request, "id") if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } if revokeError := handler.agentService.RevokeAgent(request.Context(), agentID, actingUser, RequestContextFrom(request)); revokeError != nil { WriteError(responseWriter, request, requestLogger, translateAgentError(revokeError)) return } WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{"status": "gesperrt"}) } // handleRotateCredentials bedient POST /agents/{id}/rotate-credentials. func (handler *agentHandler) handleRotateCredentials(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) agentID, parseError := parsePathUUID(request, "id") if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } newAgentToken, rotateError := handler.agentService.RotateCredentials(request.Context(), agentID, actingUser, RequestContextFrom(request)) if rotateError != nil { WriteError(responseWriter, request, requestLogger, translateAgentError(rotateError)) return } // Das neue Token erscheint genau einmal. Erreicht es den Agent nicht, kann // er sich nicht mehr melden und muss neu aufgenommen werden. WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{ "agent_token": newAgentToken, "hinweis": "Dieses Token wird nur einmal angezeigt. Es muss dem Agent übergeben werden, sonst kann er sich nicht mehr melden.", }) } // handleAgentHealth bedient GET /agents/{id}/health. func (handler *agentHandler) handleAgentHealth(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) agentID, parseError := parsePathUUID(request, "id") if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } foundAgent, loadError := handler.agentService.GetAgent(request.Context(), agentID) if loadError != nil { WriteError(responseWriter, request, requestLogger, translateAgentError(loadError)) return } currentTime := time.Now() isOffline := foundAgent.IsOffline(currentTime, allowedAgentSilence) // Der Zustand wird ehrlich benannt: ein stummer Agent bedeutet, dass von // diesem System keine Backups mehr kommen (PROMPT.md §38). agentStatus := "healthy" statusMessage := "Der Agent meldet sich regelmäßig." switch { case foundAgent.Status == agentregistry.AgentStatusRevoked: agentStatus = "revoked" statusMessage = "Der Agent ist gesperrt und sichert nichts mehr." case isOffline: agentStatus = "offline" statusMessage = "Der Agent hat sich zu lange nicht gemeldet. Von diesem System kommen derzeit keine Backups." } healthResponse := map[string]any{ "agent_id": foundAgent.ID, "status": agentStatus, "message": statusMessage, "version": foundAgent.Version, } if heartbeatAge, hasHeartbeat := foundAgent.HeartbeatAge(currentTime); hasHeartbeat { healthResponse["last_heartbeat_at"] = foundAgent.LastHeartbeatAt healthResponse["heartbeat_age_seconds"] = int64(heartbeatAge.Seconds()) } else { // Ein Agent ohne Meldung ist etwas anderes als einer mit alter Meldung. healthResponse["last_heartbeat_at"] = nil healthResponse["hinweis"] = "Dieser Agent hat sich seit seiner Aufnahme noch nie gemeldet." } WriteSuccess(responseWriter, request, http.StatusOK, healthResponse) } // allowedAgentSilence ist die Frist, nach der ein stummer Agent als offline gilt. // // Der Wert liegt deutlich über dem üblichen Meldeabstand, damit eine einzelne // ausgefallene Meldung noch keinen Alarm auslöst. const allowedAgentSilence = 15 * time.Minute // AgentAuthenticationMiddleware prüft das Betriebstoken eines Agents. // // Sie steht neben der Benutzeranmeldung, nicht darüber: ein Agent erhält // niemals Benutzerrechte (PROMPT.md §59). func AgentAuthenticationMiddleware(agentService *agentregistry.Service, baseLogger *slog.Logger) Middleware { return func(nextHandler http.Handler) http.Handler { return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), baseLogger) agentToken, extractError := extractBearerToken(request) if extractError != nil { WriteError(responseWriter, request, requestLogger, newUnauthenticatedError( "Für diesen Zugriff ist ein Agent-Token erforderlich.")) return } authenticatedAgent, authenticateError := agentService.Authenticate(request.Context(), agentToken) if authenticateError != nil { // Ob unbekannt, abgelaufen oder gesperrt: die Antwort ist dieselbe. WriteError(responseWriter, request, requestLogger, newUnauthenticatedError( "Das Agent-Token ist ungültig oder wurde widerrufen.")) return } enrichedContext := context.WithValue(request.Context(), agentContextKey, authenticatedAgent) nextHandler.ServeHTTP(responseWriter, request.WithContext(enrichedContext)) }) } } // translateAgentError übersetzt Domänenfehler in API-Antworten. func translateAgentError(domainError error) *APIError { switch { case errors.Is(domainError, agentregistry.ErrEnrollmentTokenInvalid): return &APIError{ StatusCode: http.StatusUnauthorized, Code: "ENROLLMENT_TOKEN_INVALID", Message: "Das Aufnahme-Token ist ungültig, abgelaufen oder wurde bereits verwendet.", } case errors.Is(domainError, agentregistry.ErrAgentTokenInvalid): return newUnauthenticatedError("Das Agent-Token ist ungültig oder wurde widerrufen.") case errors.Is(domainError, agentregistry.ErrAgentNotFound): return NewNotFoundError("Der Agent existiert nicht.") case errors.Is(domainError, agentregistry.ErrAgentRevoked): return &APIError{ StatusCode: http.StatusConflict, Code: "AGENT_REVOKED", Message: "Der Agent ist gesperrt.", } case errors.Is(domainError, agentregistry.ErrUnsupportedPlatform): return NewValidationError("Die angegebene Plattform wird nicht unterstützt (erlaubt: windows, linux, darwin).") default: return NewInternalError(domainError) } }