syncova-backup/packages/agentregistry/service.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

318 lines
12 KiB
Go

package agentregistry
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
"github.com/syncova/syncova/packages/audit"
"github.com/syncova/syncova/packages/auth"
"github.com/syncova/syncova/packages/platform/crypto"
"github.com/syncova/syncova/packages/platform/logging"
)
// Service bündelt die Anwendungsfälle rund um Agents.
type Service struct {
// store ist die Ablage der Agent-Daten.
store Store
// auditRecorder protokolliert sicherheitsrelevante Vorgänge.
auditRecorder audit.Recorder
// logger protokolliert technische Fehler.
logger *slog.Logger
// timeSource liefert die aktuelle Zeit und ist in Tests ersetzbar.
timeSource func() time.Time
}
// NewService erzeugt den Agent-Dienst.
func NewService(store Store, auditRecorder audit.Recorder, baseLogger *slog.Logger) *Service {
return &Service{
store: store,
auditRecorder: auditRecorder,
logger: logging.WithComponent(baseLogger, "agents"),
timeSource: time.Now,
}
}
// IssueEnrollmentToken stellt ein einmaliges Aufnahme-Token aus.
//
// Der Klartext wird genau einmal zurückgegeben; gespeichert wird nur sein Hash.
// Damit erlaubt selbst ein Datenbankleck keine Aufnahme fremder Agents.
func (service *Service) IssueEnrollmentToken(issueContext context.Context, agentName string, validity time.Duration, actingUser auth.User, requestContext auth.RequestContext) (EnrollmentToken, error) {
normalizedName := strings.TrimSpace(agentName)
if normalizedName == "" {
return EnrollmentToken{}, errors.New("der name des agents darf nicht leer sein")
}
if validity <= 0 {
validity = defaultEnrollmentValidity
}
plainToken, tokenError := crypto.GenerateToken()
if tokenError != nil {
return EnrollmentToken{}, tokenError
}
expiresAt := service.timeSource().Add(validity).UTC()
tokenID, createError := service.store.CreateEnrollmentToken(issueContext,
crypto.HashToken(plainToken), normalizedName, expiresAt, &actingUser.ID)
if createError != nil {
return EnrollmentToken{}, createError
}
// Die Ausstellung ist ein sicherheitsrelevanter Vorgang: sie erlaubt die
// Aufnahme eines neuen Systems in die Backup-Infrastruktur.
service.recordAudit(issueContext, audit.Event{
UserID: &actingUser.ID,
ActorUsername: actingUser.Username,
Action: auditActionEnrollmentTokenIssued,
EntityType: "agent_enrollment_token",
EntityID: &tokenID,
Result: audit.ResultSuccess,
IPAddress: requestContext.IPAddress,
CorrelationID: requestContext.CorrelationID,
// Das Token selbst erscheint selbstverständlich nicht im Protokoll.
Details: map[string]any{"agent_name": normalizedName, "gueltig_bis": expiresAt.Format(time.RFC3339)},
})
return EnrollmentToken{
ID: tokenID,
Token: plainToken,
AgentName: normalizedName,
ExpiresAt: expiresAt,
}, nil
}
// Register nimmt einen Agent anhand seines Aufnahme-Tokens auf.
//
// Das Aufnahme-Token wird dabei eingelöst und ist danach wertlos. Der Agent
// erhält ein Betriebstoken, das ausschließlich agentspezifische Rechte trägt.
func (service *Service) Register(registerContext context.Context, registrationRequest RegistrationRequest) (RegistrationResult, error) {
if !registrationRequest.Platform.IsSupported() {
return RegistrationResult{}, fmt.Errorf("%w: %q", ErrUnsupportedPlatform, registrationRequest.Platform)
}
enrollmentHash := crypto.HashToken(registrationRequest.EnrollmentToken)
// Der vorgesehene Name wird vor dem Anlegen gelesen: der Agent bestimmt
// seinen Namen nicht selbst, sondern die ausstellende Administration.
agentName, peekError := service.store.PeekEnrollmentToken(registerContext, enrollmentHash)
if peekError != nil {
service.recordFailedRegistration(registerContext, registrationRequest, "ungültiges aufnahme-token")
return RegistrationResult{}, peekError
}
newAgent := Agent{
Name: agentName,
Hostname: strings.TrimSpace(registrationRequest.Hostname),
Platform: registrationRequest.Platform,
Architecture: registrationRequest.Architecture,
Version: registrationRequest.Version,
Status: AgentStatusActive,
RegisteredAt: service.timeSource().UTC(),
}
agentID, createError := service.store.CreateAgent(registerContext, newAgent)
if createError != nil {
return RegistrationResult{}, createError
}
// Das Einlösen erfolgt nach dem Anlegen und ist an die Bedingung geknüpft,
// dass das Token noch offen ist. Zwei gleichzeitige Registrierungen können
// damit nicht beide erfolgreich sein.
if _, consumeError := service.store.ConsumeEnrollmentToken(registerContext, enrollmentHash, agentID); consumeError != nil {
return RegistrationResult{}, consumeError
}
plainAgentToken, tokenError := crypto.GenerateToken()
if tokenError != nil {
return RegistrationResult{}, tokenError
}
if _, tokenCreateError := service.store.CreateAgentToken(registerContext, agentID, crypto.HashToken(plainAgentToken)); tokenCreateError != nil {
return RegistrationResult{}, tokenCreateError
}
registeredAgent, loadError := service.store.FindAgentByID(registerContext, agentID)
if loadError != nil {
return RegistrationResult{}, loadError
}
service.recordAudit(registerContext, audit.Event{
ActorUsername: "agent:" + agentName,
Action: auditActionAgentRegistered,
EntityType: "agent",
EntityID: &agentID,
Result: audit.ResultSuccess,
IPAddress: registrationRequest.IPAddress,
Details: map[string]any{
"hostname": newAgent.Hostname,
"platform": string(newAgent.Platform),
"version": newAgent.Version,
},
})
service.logger.Info("agent aufgenommen",
slog.String("agent_id", agentID.String()),
slog.String("name", agentName),
slog.String("hostname", newAgent.Hostname),
slog.String("platform", string(newAgent.Platform)))
return RegistrationResult{Agent: registeredAgent, AgentToken: plainAgentToken}, nil
}
// Authenticate prüft ein Betriebstoken und liefert den zugehörigen Agent.
//
// Der Weg entspricht der Benutzeranmeldung, führt aber ausdrücklich nicht zu
// einer Benutzersitzung: ein Agent bleibt ein Agent und erhält niemals
// Benutzerrechte (PROMPT.md §59).
func (service *Service) Authenticate(authenticateContext context.Context, agentToken string) (Agent, error) {
authenticatedAgent, lookupError := service.store.FindAgentByTokenHash(authenticateContext, crypto.HashToken(agentToken))
if lookupError != nil {
return Agent{}, lookupError
}
// Ein zwischenzeitlich gesperrter Agent verliert sofort den Zugriff.
if authenticatedAgent.Status != AgentStatusActive {
return Agent{}, ErrAgentRevoked
}
return authenticatedAgent, nil
}
// RecordHeartbeat vermerkt eine Lebendmeldung.
//
// Die Meldung ist die einzige Grundlage, auf der sich ein ausgefallener Agent
// erkennen lässt. Sie wird deshalb bei jedem Eingang fortgeschrieben.
func (service *Service) RecordHeartbeat(heartbeatContext context.Context, authenticatedAgent Agent, heartbeatRequest HeartbeatRequest) error {
return service.store.RecordHeartbeat(heartbeatContext, authenticatedAgent.ID,
heartbeatRequest.Version, heartbeatRequest.IPAddress)
}
// ListAgents liefert eine Seite von Agents.
func (service *Service) ListAgents(listContext context.Context, agentFilter AgentFilter) ([]Agent, int64, error) {
return service.store.ListAgents(listContext, agentFilter)
}
// GetAgent liest einen Agent.
func (service *Service) GetAgent(queryContext context.Context, agentID uuid.UUID) (Agent, error) {
return service.store.FindAgentByID(queryContext, agentID)
}
// RevokeAgent sperrt einen Agent dauerhaft.
//
// Sämtliche Tokens werden mit widerrufen: ein gesperrter Agent darf sich nicht
// über ein altes Token zurückmelden.
func (service *Service) RevokeAgent(revokeContext context.Context, agentID uuid.UUID, actingUser auth.User, requestContext auth.RequestContext) error {
revokedAgent, lookupError := service.store.FindAgentByID(revokeContext, agentID)
if lookupError != nil {
return lookupError
}
if revokeError := service.store.RevokeAgent(revokeContext, agentID); revokeError != nil {
return revokeError
}
service.recordAudit(revokeContext, audit.Event{
UserID: &actingUser.ID,
ActorUsername: actingUser.Username,
Action: auditActionAgentRevoked,
EntityType: "agent",
EntityID: &agentID,
Result: audit.ResultSuccess,
IPAddress: requestContext.IPAddress,
CorrelationID: requestContext.CorrelationID,
Details: map[string]any{"name": revokedAgent.Name, "hostname": revokedAgent.Hostname},
})
service.logger.Info("agent gesperrt",
slog.String("agent_id", agentID.String()),
slog.String("name", revokedAgent.Name))
return nil
}
// RotateCredentials tauscht das Betriebstoken eines Agents aus.
//
// Das bisherige Token wird ungültig. Der Agent muss das neue erhalten, sonst
// kann er sich nicht mehr melden — die Rotation ist deshalb ein bewusster
// Eingriff, kein Hintergrundvorgang (PROMPT.md §59).
func (service *Service) RotateCredentials(rotateContext context.Context, agentID uuid.UUID, actingUser auth.User, requestContext auth.RequestContext) (string, error) {
rotatedAgent, lookupError := service.store.FindAgentByID(rotateContext, agentID)
if lookupError != nil {
return "", lookupError
}
if rotatedAgent.Status == AgentStatusRevoked {
return "", ErrAgentRevoked
}
plainAgentToken, tokenError := crypto.GenerateToken()
if tokenError != nil {
return "", tokenError
}
if rotateError := service.store.RotateAgentToken(rotateContext, agentID, crypto.HashToken(plainAgentToken)); rotateError != nil {
return "", rotateError
}
service.recordAudit(rotateContext, audit.Event{
UserID: &actingUser.ID,
ActorUsername: actingUser.Username,
Action: auditActionAgentTokenRotated,
EntityType: "agent",
EntityID: &agentID,
Result: audit.ResultSuccess,
IPAddress: requestContext.IPAddress,
CorrelationID: requestContext.CorrelationID,
Details: map[string]any{"name": rotatedAgent.Name},
})
return plainAgentToken, nil
}
// Auditaktionen der Agent-Verwaltung.
const (
// auditActionEnrollmentTokenIssued meldet die Ausstellung eines Aufnahme-Tokens.
auditActionEnrollmentTokenIssued audit.Action = "AGENT_ENROLLMENT_TOKEN_ISSUED"
// auditActionAgentRegistered meldet die Aufnahme eines Agents.
auditActionAgentRegistered audit.Action = "AGENT_REGISTERED"
// auditActionAgentRegistrationFailed meldet einen gescheiterten Aufnahmeversuch.
auditActionAgentRegistrationFailed audit.Action = "AGENT_REGISTRATION_FAILED"
// auditActionAgentRevoked meldet die Sperre eines Agents.
auditActionAgentRevoked audit.Action = "AGENT_REVOKED"
// auditActionAgentTokenRotated meldet den Austausch eines Betriebstokens.
auditActionAgentTokenRotated audit.Action = "AGENT_TOKEN_ROTATED"
)
// recordFailedRegistration protokolliert einen gescheiterten Aufnahmeversuch.
//
// Wiederholte Fehlversuche deuten auf einen Angriff hin und müssen sichtbar
// sein (PROMPT.md §125).
func (service *Service) recordFailedRegistration(recordContext context.Context, registrationRequest RegistrationRequest, reason string) {
service.recordAudit(recordContext, audit.Event{
ActorUsername: "unbekannter agent",
Action: auditActionAgentRegistrationFailed,
Result: audit.ResultFailure,
IPAddress: registrationRequest.IPAddress,
Details: map[string]any{
"grund": reason,
"hostname": registrationRequest.Hostname,
"platform": string(registrationRequest.Platform),
},
})
}
// recordAudit schreibt ein Auditereignis und protokolliert Fehler.
func (service *Service) recordAudit(recordContext context.Context, auditEvent audit.Event) {
if recordError := service.auditRecorder.Record(recordContext, auditEvent); recordError != nil {
service.logger.Error("auditereignis konnte nicht geschrieben werden",
slog.String("action", string(auditEvent.Action)),
slog.String("error", recordError.Error()))
}
}