syncova-backup/apps/api/internal/httpapi/agent_handler.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

369 lines
14 KiB
Go

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, &registrationPayload); 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)
}
}