// Command syncova-admin richtet den ersten Administrator ein und erzeugt Schlüssel. // // Die Erstinbetriebnahme läuft bewusst über ein Kommando auf dem Server und // nicht über die Weboberfläche: ein vorkonfiguriertes Standardkonto wäre eine // bekannte Schwachstelle jeder Installation (PROMPT.md §120, §122). // // Aufruf: // // syncova-admin create-admin --username [--email ] // syncova-admin generate-key // syncova-admin reset-password --username package main import ( "bufio" "context" "errors" "flag" "fmt" "os" "strings" "syscall" "time" "github.com/syncova/syncova/packages/audit" "github.com/syncova/syncova/packages/auth" "github.com/syncova/syncova/packages/platform/config" "github.com/syncova/syncova/packages/platform/crypto" "github.com/syncova/syncova/packages/platform/database" "github.com/syncova/syncova/packages/platform/logging" "golang.org/x/term" ) // serviceName benennt das Kommando in den Logs. const serviceName = "syncova-admin" // commandTimeout begrenzt die Laufzeit einer Datenbankoperation. const commandTimeout = 30 * time.Second // buildVersion wird beim Bauen über -ldflags gesetzt. // // Der Vorgabewert gilt nur für einen Bau von Hand; das Auslieferungspaket // brennt die tatsächliche Fassung ein. var buildVersion = "0.1.0-dev" func main() { if runError := run(); runError != nil { fmt.Fprintf(os.Stderr, "%s: %v\n", serviceName, runError) os.Exit(1) } } // run wertet das Unterkommando aus. func run() error { // Die Versionsabfrage steht vor dem Laden der Konfiguration: Wer wissen // will, welche Fassung auf einem Server liegt, hat in dem Moment womöglich // keine Datenbank — etwa auf einem frisch ausgepackten Paket oder mitten in // einer Störung. if len(os.Args) > 1 && isVersionArgument(os.Args[1]) { fmt.Printf("%s %s\n", serviceName, buildVersion) return nil } if len(os.Args) < 2 { return printUsage() } switch requestedCommand := os.Args[1]; requestedCommand { case "generate-key": // Die Schlüsselerzeugung braucht weder Konfiguration noch Datenbank. return runGenerateKey() case "create-admin": return runCreateAdmin(os.Args[2:]) case "reset-password": return runResetPassword(os.Args[2:]) default: return fmt.Errorf("unbekanntes Kommando %q\n\n%s", requestedCommand, usageText()) } } // usageText beschreibt die Verwendung des Kommandos. func usageText() string { return `Verwendung: syncova-admin generate-key Erzeugt einen neuen Verschlüsselungsschlüssel syncova-admin create-admin --username Legt den ersten Administrator an syncova-admin reset-password --username Setzt ein Passwort zurück` } // printUsage gibt die Verwendung aus. func printUsage() error { return errors.New(usageText()) } // runGenerateKey erzeugt einen Verschlüsselungsschlüssel. func runGenerateKey() error { generatedKey, keyError := crypto.GenerateMasterKey() if keyError != nil { return keyError } fmt.Println("Ein neuer Verschlüsselungsschlüssel wurde erzeugt.") fmt.Println() fmt.Printf("SYNCOVA_ENCRYPTION_KEYS=v1:%s\n", generatedKey) fmt.Println() fmt.Println("Wichtig: Ohne diesen Schlüssel sind verschlüsselte Daten (z. B. MFA-Secrets)") fmt.Println("dauerhaft unlesbar. Er gehört sicher verwahrt und darf nicht verloren gehen.") return nil } // adminEnvironment bündelt die für Datenbankkommandos nötigen Bestandteile. type adminEnvironment struct { // authService ist die Domänenlogik der Identitätsverwaltung. authService *auth.Service // repository ist die Datenzugriffsschicht. repository *auth.Repository // databasePool ist der Verbindungspool; er muss geschlossen werden. databasePool *database.Pool } // buildAdminEnvironment lädt Konfiguration und baut die Dienste auf. func buildAdminEnvironment(setupContext context.Context) (*adminEnvironment, error) { serviceConfig, configError := config.Load(serviceName) if configError != nil { return nil, configError } commandLogger := logging.New(os.Stderr, logging.Options{ ServiceName: serviceConfig.ServiceName, Level: serviceConfig.Logging.Level, Format: serviceConfig.Logging.Format, }) databasePool, databaseError := database.Connect(setupContext, serviceConfig.Database, commandLogger) if databaseError != nil { return nil, databaseError } secretStore, secretStoreError := crypto.NewLocalSecretStore( serviceConfig.Encryption.Keys(), serviceConfig.Encryption.CurrentKeyVersion) if secretStoreError != nil { databasePool.Close() return nil, fmt.Errorf("die verschlüsselung konnte nicht eingerichtet werden: %w", secretStoreError) } auditRecorder := audit.NewPostgresRecorder(databasePool.Connections(), commandLogger) authRepository := auth.NewRepository(databasePool.Connections()) return &adminEnvironment{ authService: auth.NewService(authRepository, secretStore, auditRecorder, serviceConfig.Auth, commandLogger), repository: authRepository, databasePool: databasePool, }, nil } // runCreateAdmin legt den ersten Administrator an. func runCreateAdmin(commandArguments []string) error { commandFlags := flag.NewFlagSet("create-admin", flag.ContinueOnError) username := commandFlags.String("username", "", "Anmeldename des Administrators") email := commandFlags.String("email", "", "Mailadresse (optional)") if parseError := commandFlags.Parse(commandArguments); parseError != nil { return parseError } if *username == "" { return errors.New("--username ist erforderlich") } commandContext, cancelCommandContext := context.WithTimeout(context.Background(), commandTimeout) defer cancelCommandContext() adminEnvironment, environmentError := buildAdminEnvironment(commandContext) if environmentError != nil { return environmentError } defer adminEnvironment.databasePool.Close() // Ein zweiter Administrator wird über die Oberfläche angelegt. Dieses // Kommando dient ausschließlich der Erstinbetriebnahme. existingAdministrators, countError := adminEnvironment.repository.CountAdministrators(commandContext, nil) if countError != nil { return countError } if existingAdministrators > 0 { return fmt.Errorf("es existieren bereits %d Administratoren. "+ "Weitere Benutzer werden über die Oberfläche oder die API angelegt", existingAdministrators) } password, passwordError := readPasswordTwice() if passwordError != nil { return passwordError } // Das Anlegen erfolgt im Namen des Systems: es gibt noch keinen handelnden Benutzer. systemActor := auth.User{Username: "system (erstinbetriebnahme)"} createdUser, createError := adminEnvironment.authService.CreateUser(commandContext, auth.CreateUserRequest{ Username: *username, Email: *email, Password: password, RoleNames: []string{"super_administrator"}, }, systemActor, auth.RequestContext{IPAddress: "", UserAgent: serviceName}) if createError != nil { return createError } fmt.Println() fmt.Printf("Der Administrator %q wurde angelegt.\n", createdUser.Username) fmt.Println() fmt.Println("Nächste Schritte:") fmt.Println(" 1. An der Weboberfläche anmelden.") fmt.Println(" 2. Unter Sicherheit einen zweiten Faktor einrichten (dringend empfohlen).") fmt.Println(" 3. Weitere Benutzer mit passenden Rollen anlegen.") return nil } // runResetPassword setzt das Passwort eines Benutzers zurück. // // Der Weg dient dem Fall, dass sich niemand mehr anmelden kann. func runResetPassword(commandArguments []string) error { commandFlags := flag.NewFlagSet("reset-password", flag.ContinueOnError) username := commandFlags.String("username", "", "Anmeldename des Benutzers") if parseError := commandFlags.Parse(commandArguments); parseError != nil { return parseError } if *username == "" { return errors.New("--username ist erforderlich") } commandContext, cancelCommandContext := context.WithTimeout(context.Background(), commandTimeout) defer cancelCommandContext() adminEnvironment, environmentError := buildAdminEnvironment(commandContext) if environmentError != nil { return environmentError } defer adminEnvironment.databasePool.Close() targetUser, lookupError := adminEnvironment.repository.FindUserByUsername(commandContext, *username) if lookupError != nil { return lookupError } password, passwordError := readPasswordTwice() if passwordError != nil { return passwordError } systemActor := auth.User{Username: "system (kommandozeile)"} if _, updateError := adminEnvironment.authService.UpdateUser(commandContext, targetUser.ID, auth.UpdateUserRequest{ Password: &password, // Ein gesperrtes Konto wird beim Zurücksetzen wieder freigegeben. Status: pointerTo(auth.UserStatusActive), }, systemActor, auth.RequestContext{UserAgent: serviceName}); updateError != nil { return updateError } fmt.Println() fmt.Printf("Das Passwort von %q wurde geändert.\n", targetUser.Username) fmt.Println("Alle bestehenden Sitzungen dieses Kontos wurden beendet.") return nil } // readPasswordTwice liest ein Passwort zweimal von der Konsole. // // Die Eingabe erfolgt verdeckt, damit das Passwort weder auf dem Bildschirm // noch in der Shell-Historie erscheint. func readPasswordTwice() (string, error) { fmt.Print("Passwort: ") firstEntry, firstError := readHiddenInput() if firstError != nil { return "", firstError } fmt.Println() // Die Stärke wird vor der Wiederholung geprüft, damit ein zu schwaches // Passwort nicht zweimal eingegeben werden muss. if strengthError := auth.ValidatePasswordStrength(firstEntry); strengthError != nil { return "", strengthError } fmt.Print("Passwort wiederholen: ") secondEntry, secondError := readHiddenInput() if secondError != nil { return "", secondError } fmt.Println() if firstEntry != secondEntry { return "", errors.New("die beiden Eingaben stimmen nicht überein") } return firstEntry, nil } // standardInputReader liest die Standardeingabe außerhalb eines Terminals. // // Der Reader ist paketweit, weil ein gepufferter Reader mehr Daten aus der // Standardeingabe zieht als die angeforderte Zeile. Ein zweiter Reader fände // die bereits gepufferten Zeilen nicht mehr vor und liefe sofort auf EOF — // die Abfrage der Passwortwiederholung schlüge in jedem Skript fehl. var standardInputReader *bufio.Reader // readHiddenInput liest eine Zeile ohne Bildschirmausgabe. func readHiddenInput() (string, error) { // Bei einem Terminal wird die Eingabe verdeckt gelesen. if term.IsTerminal(int(syscall.Stdin)) { enteredBytes, readError := term.ReadPassword(int(syscall.Stdin)) if readError != nil { return "", fmt.Errorf("die eingabe konnte nicht gelesen werden: %w", readError) } return string(enteredBytes), nil } // Ohne Terminal (etwa in einem Skript) wird von der Standardeingabe gelesen. if standardInputReader == nil { standardInputReader = bufio.NewReader(os.Stdin) } enteredLine, readError := standardInputReader.ReadString('\n') if readError != nil && enteredLine == "" { return "", fmt.Errorf("die eingabe konnte nicht gelesen werden: %w", readError) } return strings.TrimRight(enteredLine, "\r\n"), nil } // pointerTo liefert einen Zeiger auf den übergebenen Wert. func pointerTo[ValueType any](value ValueType) *ValueType { return &value } // isVersionArgument erkennt eine Versionsabfrage. // // Drei Schreibweisen, weil sich niemand merkt, welche ein bestimmtes Programm // erwartet — und weil eine Fehlermeldung auf "--version" der denkbar // schlechteste erste Eindruck ist. func isVersionArgument(argument string) bool { return argument == "version" || argument == "--version" || argument == "-version" }