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())) } }