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>
369 lines
14 KiB
Go
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, ®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)
|
|
}
|
|
}
|