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>
389 lines
13 KiB
Go
389 lines
13 KiB
Go
// Command syncova-agent sichert ein System auf Anweisung des Control Servers.
|
|
//
|
|
// Der Agent läuft als Dienst: unter Windows als Windows-Dienst, unter Linux als
|
|
// systemd-Unit. Der fachliche Kern ist plattformunabhängig; die Dienstanbindung
|
|
// liegt in den Dateien service_windows.go und service_unix.go.
|
|
//
|
|
// Aufruf:
|
|
//
|
|
// syncova-agent register --server <url> --token <aufnahme-token>
|
|
// syncova-agent run
|
|
// syncova-agent discover --path <pfad> [--exclude <muster>]
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"github.com/syncova/syncova/packages/agent"
|
|
"github.com/syncova/syncova/packages/platform/logging"
|
|
)
|
|
|
|
// serviceName benennt den Agent in Logs und beim Betriebssystem.
|
|
const serviceName = "syncova-agent"
|
|
|
|
// buildVersion wird beim Bauen über -ldflags gesetzt.
|
|
var buildVersion = "0.1.0-dev"
|
|
|
|
// agentStateFileName ist die Ablage des Betriebstokens.
|
|
const agentStateFileName = "agent-state.json"
|
|
|
|
// agentState ist der dauerhaft gespeicherte Zustand des Agents.
|
|
type agentState struct {
|
|
// ServerBaseURL ist die Adresse des Control Servers.
|
|
ServerBaseURL string `json:"server_base_url"`
|
|
// AgentToken ist das Betriebstoken.
|
|
//
|
|
// Es liegt im Klartext auf der Platte, weil der Agent es bei jedem Start
|
|
// braucht und niemand zur Eingabe bereitsteht. Die Datei erhält deshalb
|
|
// möglichst enge Rechte; ein Angreifer mit Lesezugriff darauf hat ohnehin
|
|
// bereits Zugriff auf das zu sichernde System.
|
|
AgentToken string `json:"agent_token"`
|
|
// AgentID ist der Bezeichner des Agents beim Server.
|
|
AgentID string `json:"agent_id"`
|
|
// RegisteredAt ist der Zeitpunkt der Aufnahme.
|
|
RegisteredAt time.Time `json:"registered_at"`
|
|
}
|
|
|
|
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 {
|
|
if len(os.Args) < 2 {
|
|
return errors.New(usageText())
|
|
}
|
|
|
|
commandArguments := os.Args[2:]
|
|
|
|
switch requestedCommand := os.Args[1]; requestedCommand {
|
|
case "register":
|
|
return runRegister(commandArguments)
|
|
case "run":
|
|
return runService(commandArguments)
|
|
case "discover":
|
|
return runDiscover(commandArguments)
|
|
case "backup":
|
|
return runBackupCommand(commandArguments)
|
|
case "restore":
|
|
return runRestoreCommand(commandArguments)
|
|
case "version", "--version", "-version":
|
|
fmt.Printf("%s %s (%s)\n", serviceName, buildVersion, agent.CollectSystemInformation().Platform)
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("unbekanntes Kommando %q\n\n%s", requestedCommand, usageText())
|
|
}
|
|
}
|
|
|
|
// usageText beschreibt die Verwendung.
|
|
func usageText() string {
|
|
return `Verwendung:
|
|
syncova-agent register --server <url> --token <aufnahme-token> Nimmt den Agent auf
|
|
syncova-agent run [--state <pfad>] Startet den Agent
|
|
syncova-agent discover --path <pfad> [--exclude <muster>] Zeigt die zu sichernden Dateien
|
|
syncova-agent backup --repository <pfad> --path <pfad> Sichert ein Verzeichnis
|
|
syncova-agent backup --repository <pfad> --path <pfad> --incremental
|
|
Sichert nur die Änderungen
|
|
syncova-agent backup ... --bandwidth 50MB Begrenzt den Lesedurchsatz
|
|
syncova-agent restore --repository <pfad> --id <backup> --target <pfad>
|
|
Stellt ein Backup wieder her
|
|
syncova-agent version Zeigt die Version
|
|
|
|
Der Schlüssel für verschlüsselte Backups kommt aus SYNCOVA_ENCRYPTION_KEYS.`
|
|
}
|
|
|
|
// defaultStateDirectory liefert das Standardverzeichnis für den Agent-Zustand.
|
|
func defaultStateDirectory() string {
|
|
// Der Zustand gehört neben das Programm, damit er einem Dienstkonto ohne
|
|
// Benutzerprofil zugänglich ist.
|
|
executablePath, executableError := os.Executable()
|
|
if executableError != nil {
|
|
return "."
|
|
}
|
|
|
|
return filepath.Dir(executablePath)
|
|
}
|
|
|
|
// runRegister nimmt den Agent am Control Server auf.
|
|
func runRegister(commandArguments []string) error {
|
|
commandFlags := flag.NewFlagSet("register", flag.ContinueOnError)
|
|
serverURL := commandFlags.String("server", "", "Adresse des Control Servers")
|
|
enrollmentToken := commandFlags.String("token", "", "Aufnahme-Token")
|
|
statePath := commandFlags.String("state", "", "Ablageort des Agent-Zustands")
|
|
|
|
if parseError := commandFlags.Parse(commandArguments); parseError != nil {
|
|
return parseError
|
|
}
|
|
|
|
if *serverURL == "" || *enrollmentToken == "" {
|
|
return errors.New("--server und --token sind erforderlich")
|
|
}
|
|
|
|
stateFilePath := resolveStatePath(*statePath)
|
|
|
|
// Eine bestehende Aufnahme wird nicht überschrieben: der Agent verlöre
|
|
// sonst sein Token und wäre für den Server ein neues, unbekanntes System.
|
|
if _, statError := os.Stat(stateFilePath); statError == nil {
|
|
return fmt.Errorf("der agent ist bereits registriert (%s). "+
|
|
"Für eine erneute Aufnahme die Datei entfernen", stateFilePath)
|
|
}
|
|
|
|
agentLogger := logging.New(os.Stderr, logging.Options{
|
|
ServiceName: serviceName, Level: "info", Format: "text",
|
|
})
|
|
|
|
agentClient := agent.NewClient(agent.ClientOptions{
|
|
ServerBaseURL: strings.TrimRight(*serverURL, "/"),
|
|
AgentVersion: buildVersion,
|
|
})
|
|
|
|
agentRunner := agent.NewRunner(agentClient, 0, agentLogger)
|
|
|
|
registerContext, cancelRegister := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancelRegister()
|
|
|
|
if _, registerError := agentRunner.RegisterIfNeeded(registerContext, *enrollmentToken); registerError != nil {
|
|
return registerError
|
|
}
|
|
|
|
systemInformation := agent.CollectSystemInformation()
|
|
|
|
if writeError := writeAgentState(stateFilePath, agentState{
|
|
ServerBaseURL: strings.TrimRight(*serverURL, "/"),
|
|
AgentToken: agentClient.AgentToken(),
|
|
RegisteredAt: time.Now().UTC(),
|
|
}); writeError != nil {
|
|
return writeError
|
|
}
|
|
|
|
fmt.Printf("Der Agent wurde aufgenommen.\n\n")
|
|
fmt.Printf(" Rechner: %s (%s/%s)\n", systemInformation.Hostname, systemInformation.Platform, systemInformation.Architecture)
|
|
fmt.Printf(" Server: %s\n", *serverURL)
|
|
fmt.Printf(" Zustand: %s\n", stateFilePath)
|
|
fmt.Printf("\nDer Agent kann jetzt als Dienst gestartet werden.\n")
|
|
|
|
return nil
|
|
}
|
|
|
|
// runService startet den Agent.
|
|
func runService(commandArguments []string) error {
|
|
commandFlags := flag.NewFlagSet("run", flag.ContinueOnError)
|
|
statePath := commandFlags.String("state", "", "Ablageort des Agent-Zustands")
|
|
heartbeatSeconds := commandFlags.Int("heartbeat-seconds", 0, "Abstand der Lebendmeldungen in Sekunden")
|
|
|
|
if parseError := commandFlags.Parse(commandArguments); parseError != nil {
|
|
return parseError
|
|
}
|
|
|
|
stateFilePath := resolveStatePath(*statePath)
|
|
|
|
loadedState, loadError := readAgentState(stateFilePath)
|
|
if loadError != nil {
|
|
return fmt.Errorf("der agent ist nicht registriert (%s): %w. "+
|
|
"Bitte zuerst 'syncova-agent register' ausführen", stateFilePath, loadError)
|
|
}
|
|
|
|
agentLogger := logging.New(os.Stdout, logging.Options{
|
|
ServiceName: serviceName, Level: "info", Format: "text",
|
|
})
|
|
|
|
agentClient := agent.NewClient(agent.ClientOptions{
|
|
ServerBaseURL: loadedState.ServerBaseURL,
|
|
AgentToken: loadedState.AgentToken,
|
|
AgentVersion: buildVersion,
|
|
})
|
|
|
|
agentRunner := agent.NewRunner(agentClient,
|
|
time.Duration(*heartbeatSeconds)*time.Second, agentLogger)
|
|
|
|
// Die Auftragsausführung wird eingeschaltet, sobald der Agent sie leisten
|
|
// kann (Phase 5).
|
|
//
|
|
// Ohne Schlüsselmaterial bleibt sie eingeschaltet, aber ein Auftrag mit
|
|
// verlangter Verschlüsselung wird abgelehnt statt unverschlüsselt
|
|
// ausgeführt: Ein Backup, das der Server für verschlüsselt hält und das es
|
|
// nicht ist, wäre eine Zusicherung ins Leere.
|
|
agentSecretStore, secretStoreError := buildSecretStoreFromEnvironment()
|
|
if secretStoreError != nil {
|
|
return fmt.Errorf("das schluesselmaterial des agenten ist unbrauchbar: %w", secretStoreError)
|
|
}
|
|
|
|
if agentSecretStore == nil {
|
|
agentLogger.Warn("auf diesem agenten ist kein schluesselmaterial eingerichtet; " +
|
|
"auftraege mit verschluesselung werden abgelehnt")
|
|
}
|
|
|
|
agentRunner.EnableTaskExecution(
|
|
agent.NewTaskExecutor(agentSecretStore, agentClient, agentLogger), 0)
|
|
|
|
// SIGINT und SIGTERM beenden den Agent geordnet. Der Windows-Dienst nutzt
|
|
// denselben Weg über seinen eigenen Abbruchkanal.
|
|
runContext, stopSignalListener := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stopSignalListener()
|
|
|
|
return runUnderServiceManager(runContext, agentRunner, agentLogger)
|
|
}
|
|
|
|
// runDiscover zeigt die zu sichernden Dateien.
|
|
//
|
|
// Das Kommando dient der Kontrolle vor einem Backup: es beantwortet die Frage,
|
|
// was tatsächlich erfasst würde — einschließlich der Probleme.
|
|
func runDiscover(commandArguments []string) error {
|
|
commandFlags := flag.NewFlagSet("discover", flag.ContinueOnError)
|
|
sourcePath := commandFlags.String("path", "", "Zu erfassendes Verzeichnis")
|
|
excludeList := commandFlags.String("exclude", "", "Auszuschließende Muster, kommasepariert")
|
|
includeList := commandFlags.String("include", "", "Einzuschließende Muster, kommasepariert")
|
|
showFiles := commandFlags.Bool("list", false, "Alle erfassten Dateien auflisten")
|
|
|
|
if parseError := commandFlags.Parse(commandArguments); parseError != nil {
|
|
return parseError
|
|
}
|
|
|
|
if *sourcePath == "" {
|
|
return errors.New("--path ist erforderlich")
|
|
}
|
|
|
|
discoveryResult, discoveryError := agent.Discover(*sourcePath, agent.DiscoveryOptions{
|
|
IncludePatterns: splitPatternList(*includeList),
|
|
ExcludePatterns: splitPatternList(*excludeList),
|
|
})
|
|
|
|
if discoveryError != nil {
|
|
return discoveryError
|
|
}
|
|
|
|
fmt.Printf(" Dateien: %d\n", discoveryResult.FileCount())
|
|
fmt.Printf(" Gesamtgröße: %s\n", formatBytes(discoveryResult.TotalBytes))
|
|
fmt.Printf(" Übergangen: %d (durch Muster)\n", discoveryResult.SkippedByPattern)
|
|
|
|
if *showFiles {
|
|
fmt.Println()
|
|
outputTable := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(outputTable, "GRÖSSE\tRECHTE\tPFAD")
|
|
|
|
for _, discoveredEntry := range discoveryResult.Entries {
|
|
fmt.Fprintf(outputTable, "%s\t%s\t%s\n",
|
|
formatBytes(discoveredEntry.SizeBytes), discoveredEntry.Mode, discoveredEntry.RelativePath)
|
|
}
|
|
|
|
_ = outputTable.Flush()
|
|
}
|
|
|
|
// Probleme werden ausdrücklich benannt: ein Backup mit übergangenen Dateien
|
|
// ist ein Teilfehler, kein Erfolg (PROMPT.md §140).
|
|
if discoveryResult.HasProblems() {
|
|
fmt.Printf("\n %d Probleme (davon %d Rechtefehler):\n",
|
|
len(discoveryResult.Problems), discoveryResult.PermissionProblemCount())
|
|
|
|
for problemIndex, discoveryProblem := range discoveryResult.Problems {
|
|
if problemIndex >= 10 {
|
|
fmt.Printf(" ... und %d weitere\n", len(discoveryResult.Problems)-10)
|
|
break
|
|
}
|
|
|
|
fmt.Printf(" %s\n %s\n", discoveryProblem.Path, discoveryProblem.Reason)
|
|
}
|
|
|
|
fmt.Println("\nEin Backup dieser Auswahl wäre unvollständig.")
|
|
|
|
return errors.New("die erfassung meldete probleme")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// resolveStatePath ermittelt den Ablageort des Agent-Zustands.
|
|
func resolveStatePath(providedPath string) string {
|
|
if providedPath != "" {
|
|
return providedPath
|
|
}
|
|
|
|
return filepath.Join(defaultStateDirectory(), agentStateFileName)
|
|
}
|
|
|
|
// writeAgentState legt den Agent-Zustand ab.
|
|
func writeAgentState(stateFilePath string, stateToWrite agentState) error {
|
|
encodedState, marshalError := json.MarshalIndent(stateToWrite, "", " ")
|
|
if marshalError != nil {
|
|
return fmt.Errorf("der agent-zustand konnte nicht erzeugt werden: %w", marshalError)
|
|
}
|
|
|
|
// Die Datei enthält das Betriebstoken und erhält deshalb die engstmöglichen
|
|
// Rechte: nur der Eigentümer darf lesen.
|
|
if writeError := os.WriteFile(stateFilePath, append(encodedState, '\n'), 0o600); writeError != nil {
|
|
return fmt.Errorf("der agent-zustand konnte nicht abgelegt werden: %w", writeError)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// readAgentState liest den Agent-Zustand.
|
|
func readAgentState(stateFilePath string) (agentState, error) {
|
|
rawState, readError := os.ReadFile(stateFilePath)
|
|
if readError != nil {
|
|
return agentState{}, readError
|
|
}
|
|
|
|
var loadedState agentState
|
|
if unmarshalError := json.Unmarshal(rawState, &loadedState); unmarshalError != nil {
|
|
return agentState{}, fmt.Errorf("der agent-zustand ist unlesbar: %w", unmarshalError)
|
|
}
|
|
|
|
if loadedState.AgentToken == "" || loadedState.ServerBaseURL == "" {
|
|
return agentState{}, errors.New("dem agent-zustand fehlen token oder serveradresse")
|
|
}
|
|
|
|
return loadedState, nil
|
|
}
|
|
|
|
// splitPatternList zerlegt eine kommaseparierte Musterliste.
|
|
func splitPatternList(patternList string) []string {
|
|
if strings.TrimSpace(patternList) == "" {
|
|
return nil
|
|
}
|
|
|
|
var patterns []string
|
|
for _, rawPattern := range strings.Split(patternList, ",") {
|
|
trimmedPattern := strings.TrimSpace(rawPattern)
|
|
if trimmedPattern != "" {
|
|
patterns = append(patterns, trimmedPattern)
|
|
}
|
|
}
|
|
|
|
return patterns
|
|
}
|
|
|
|
// formatBytes stellt eine Bytezahl lesbar dar.
|
|
func formatBytes(byteCount int64) string {
|
|
const unitStep = 1024
|
|
|
|
if byteCount < unitStep {
|
|
return fmt.Sprintf("%d B", byteCount)
|
|
}
|
|
|
|
currentValue := float64(byteCount)
|
|
for _, unitName := range []string{"KiB", "MiB", "GiB", "TiB"} {
|
|
currentValue /= unitStep
|
|
|
|
if currentValue < unitStep {
|
|
return fmt.Sprintf("%.1f %s", currentValue, unitName)
|
|
}
|
|
}
|
|
|
|
return fmt.Sprintf("%.1f PiB", currentValue/unitStep)
|
|
}
|