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>
318 lines
12 KiB
Go
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()))
|
|
}
|
|
}
|