// 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 --token // syncova-agent run // syncova-agent discover --path [--exclude ] 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 --token Nimmt den Agent auf syncova-agent run [--state ] Startet den Agent syncova-agent discover --path [--exclude ] Zeigt die zu sichernden Dateien syncova-agent backup --repository --path Sichert ein Verzeichnis syncova-agent backup --repository --path --incremental Sichert nur die Änderungen syncova-agent backup ... --bandwidth 50MB Begrenzt den Lesedurchsatz syncova-agent restore --repository --id --target 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) }