syncova-backup/apps/agent/cmd/syncova-agent/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

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