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>
354 lines
11 KiB
Go
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"
|
|
}
|