syncova-backup/apps/api/cmd/syncova-admin/main.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

354 lines
11 KiB
Go

// 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 <name> [--email <adresse>]
// syncova-admin generate-key
// syncova-admin reset-password --username <name>
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 <name> Legt den ersten Administrator an
syncova-admin reset-password --username <name> 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"
}