syncova-backup/packages/agent/client.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

303 lines
10 KiB
Go

package agent
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
)
// ErrNotRegistered meldet einen noch nicht aufgenommenen Agent.
var ErrNotRegistered = errors.New("der agent ist noch nicht registriert")
// ErrServerUnreachable meldet einen nicht erreichbaren Control Server.
//
// Der Fehler ist gesondert ausgewiesen, weil er den Regelfall einer
// Netzunterbrechung darstellt und anders behandelt wird als eine Ablehnung
// durch den Server: bei ihm lohnt ein erneuter Versuch.
var ErrServerUnreachable = errors.New("der control server ist nicht erreichbar")
// ErrTokenRejected meldet ein vom Server abgelehntes Token.
//
// Anders als eine Netzunterbrechung behebt sich das nicht von selbst: der Agent
// wurde gesperrt oder sein Token gewechselt.
var ErrTokenRejected = errors.New("der control server hat das agent-token abgelehnt")
// Client spricht mit dem Control Server.
type Client struct {
// serverBaseURL ist die Adresse des Control Servers.
serverBaseURL string
// httpClient führt die Anfragen aus.
httpClient *http.Client
// agentToken ist das Betriebstoken; leer, solange nicht registriert.
agentToken string
// agentVersion ist die Programmversion des Agents.
agentVersion string
}
// ClientOptions steuern den Aufbau eines Clients.
type ClientOptions struct {
// ServerBaseURL ist die Adresse des Control Servers.
ServerBaseURL string
// AgentToken ist ein bereits vorhandenes Betriebstoken.
AgentToken string
// AgentVersion ist die Programmversion des Agents.
AgentVersion string
// RequestTimeout begrenzt die Dauer einer einzelnen Anfrage.
RequestTimeout time.Duration
}
// defaultRequestTimeout begrenzt eine einzelne Anfrage.
//
// Ohne Begrenzung bliebe der Agent bei einem hängenden Server dauerhaft
// blockiert und meldete sich nie wieder.
const defaultRequestTimeout = 30 * time.Second
// NewClient erzeugt einen Client für den Control Server.
func NewClient(clientOptions ClientOptions) *Client {
requestTimeout := clientOptions.RequestTimeout
if requestTimeout <= 0 {
requestTimeout = defaultRequestTimeout
}
return &Client{
serverBaseURL: clientOptions.ServerBaseURL,
httpClient: &http.Client{Timeout: requestTimeout},
agentToken: clientOptions.AgentToken,
agentVersion: clientOptions.AgentVersion,
}
}
// AgentToken liefert das aktuelle Betriebstoken.
func (client *Client) AgentToken() string {
return client.agentToken
}
// registrationPayload ist der Rumpf der Registrierungsanfrage.
type registrationPayload struct {
// EnrollmentToken ist das Aufnahme-Token.
EnrollmentToken string `json:"enrollment_token"`
// Hostname ist der Rechnername des Systems.
Hostname string `json:"hostname"`
// Platform ist das Betriebssystem.
Platform string `json:"platform"`
// Architecture ist die Rechnerarchitektur.
Architecture string `json:"architecture"`
// Version ist die Programmversion des Agents.
Version string `json:"version"`
}
// RegistrationResponse ist die Antwort auf eine Registrierung.
type RegistrationResponse struct {
// Agent beschreibt den angelegten Agent.
Agent struct {
// ID ist der Bezeichner des Agents.
ID string `json:"id"`
// Name ist die vom Server vergebene Bezeichnung.
Name string `json:"name"`
} `json:"agent"`
// AgentToken ist das Betriebstoken im Klartext.
AgentToken string `json:"agent_token"`
}
// Register nimmt den Agent mit einem Aufnahme-Token auf.
//
// Das erhaltene Betriebstoken wird im Client hinterlegt und muss vom Aufrufer
// dauerhaft gesichert werden: der Server gibt es kein zweites Mal heraus.
func (client *Client) Register(registerContext context.Context, enrollmentToken string, systemInformation SystemInformation) (RegistrationResponse, error) {
requestPayload := registrationPayload{
EnrollmentToken: enrollmentToken,
Hostname: systemInformation.Hostname,
Platform: systemInformation.Platform,
Architecture: systemInformation.Architecture,
Version: client.agentVersion,
}
var registrationResponse RegistrationResponse
if requestError := client.performRequest(registerContext, http.MethodPost,
"/api/v1/agents/register", requestPayload, &registrationResponse, false); requestError != nil {
return RegistrationResponse{}, requestError
}
client.agentToken = registrationResponse.AgentToken
return registrationResponse, nil
}
// heartbeatPayload ist der Rumpf einer Lebendmeldung.
type heartbeatPayload struct {
// Version ist die aktuelle Programmversion des Agents.
Version string `json:"version"`
}
// SendHeartbeat meldet den Agent als lebendig.
func (client *Client) SendHeartbeat(heartbeatContext context.Context) error {
if client.agentToken == "" {
return ErrNotRegistered
}
var heartbeatResponse map[string]any
return client.performRequest(heartbeatContext, http.MethodPost,
"/api/v1/agents/heartbeat", heartbeatPayload{Version: client.agentVersion}, &heartbeatResponse, true)
}
// apiEnvelope ist die Antworthülle des Servers.
type apiEnvelope struct {
// Data trägt die Nutzlast.
Data json.RawMessage `json:"data"`
// Error beschreibt einen Fehler.
Error *struct {
// Code ist der maschinenlesbare Fehlercode.
Code string `json:"code"`
// Message erklärt den Fehler.
Message string `json:"message"`
} `json:"error"`
}
// performRequest führt eine Anfrage gegen den Control Server aus.
func (client *Client) performRequest(requestContext context.Context, httpMethod string, endpointPath string, requestPayload any, responseTarget any, requiresToken bool) error {
encodedPayload, marshalError := json.Marshal(requestPayload)
if marshalError != nil {
return fmt.Errorf("die anfrage konnte nicht erzeugt werden: %w", marshalError)
}
httpRequest, requestError := http.NewRequestWithContext(requestContext, httpMethod,
client.serverBaseURL+endpointPath, bytes.NewReader(encodedPayload))
if requestError != nil {
return fmt.Errorf("die anfrage konnte nicht vorbereitet werden: %w", requestError)
}
httpRequest.Header.Set("Content-Type", "application/json")
httpRequest.Header.Set("Accept", "application/json")
if requiresToken {
httpRequest.Header.Set("Authorization", "Bearer "+client.agentToken)
}
httpResponse, responseError := client.httpClient.Do(httpRequest)
if responseError != nil {
// Ein Netzfehler behebt sich möglicherweise von selbst und wird deshalb
// gesondert gemeldet.
return fmt.Errorf("%w: %v", ErrServerUnreachable, responseError)
}
defer func() { _ = httpResponse.Body.Close() }()
responseBody, readError := io.ReadAll(io.LimitReader(httpResponse.Body, maximumResponseBytes))
if readError != nil {
return fmt.Errorf("%w: die antwort konnte nicht gelesen werden", ErrServerUnreachable)
}
// Eine Antwort ohne Inhalt ist kein Fehler.
//
// Der Server nutzt 204 fuer „nichts zu tun" und fuer „angenommen, keine
// Rueckgabe". Ohne diesen Zweig scheiterte jeder solche Aufruf an einem
// leeren JSON-Rumpf — und der haeufigste Fall des Agenten, die Frage nach
// einem Auftrag, saehe wie eine Stoerung aus.
if httpResponse.StatusCode == http.StatusNoContent || len(responseBody) == 0 {
return ErrNoContent
}
var responseEnvelope apiEnvelope
if unmarshalError := json.Unmarshal(responseBody, &responseEnvelope); unmarshalError != nil {
return fmt.Errorf("die antwort des servers war unverständlich (HTTP %d)", httpResponse.StatusCode)
}
if responseEnvelope.Error != nil {
// Ein abgelehntes Token behebt sich nicht durch Warten: der Agent wurde
// gesperrt oder sein Token gewechselt.
if httpResponse.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("%w: %s", ErrTokenRejected, responseEnvelope.Error.Message)
}
return fmt.Errorf("der server meldete einen fehler: %s (%s)",
responseEnvelope.Error.Message, responseEnvelope.Error.Code)
}
if responseTarget != nil && len(responseEnvelope.Data) > 0 {
if unmarshalError := json.Unmarshal(responseEnvelope.Data, responseTarget); unmarshalError != nil {
return fmt.Errorf("die antwort des servers konnte nicht gedeutet werden: %w", unmarshalError)
}
}
return nil
}
// maximumResponseBytes begrenzt die gelesene Antwortgröße.
//
// Ohne Grenze könnte ein fehlerhafter oder feindlicher Server den Speicher des
// Agents erschöpfen.
const maximumResponseBytes = 4 * 1024 * 1024
// ErrNoContent meldet eine Antwort ohne Inhalt.
//
// Kein Fehlerfall, sondern eine Aussage: Fuer den Agenten liegt gerade nichts
// an. Er fragt regelmaessig, und dieser Fall ist der Normalfall.
var ErrNoContent = errors.New("der server hat keinen inhalt geliefert")
// ClaimTask holt den naechsten Auftrag ab.
//
// Liefert ErrNoContent, wenn nichts anliegt.
func (client *Client) ClaimTask(claimContext context.Context) (*ClaimedTask, error) {
if client.agentToken == "" {
return nil, ErrNotRegistered
}
var claimedTask ClaimedTask
requestError := client.performRequest(claimContext, http.MethodPost,
"/api/v1/agents/tasks/claim", struct{}{}, &claimedTask, true)
if requestError != nil {
return nil, requestError
}
return &claimedTask, nil
}
// ReportProgress meldet Fortschritt und Lebenszeichen.
//
// Beides in einem Aufruf: Der Fortschritt **ist** das Lebenszeichen. Bleibt er
// aus, gibt der Server den Auftrag nach einer Frist als gescheitert frei.
func (client *Client) ReportProgress(progressContext context.Context, taskIdentifier string,
bytesProcessed int64, filesProcessed int64) error {
if client.agentToken == "" {
return ErrNotRegistered
}
progressPayload := struct {
BytesProcessed int64 `json:"bytes_processed"`
FilesProcessed int64 `json:"files_processed"`
}{BytesProcessed: bytesProcessed, FilesProcessed: filesProcessed}
requestError := client.performRequest(progressContext, http.MethodPost,
"/api/v1/agents/tasks/"+taskIdentifier+"/progress", progressPayload, nil, true)
// Eine leere Antwort ist hier der Erfolgsfall.
if errors.Is(requestError, ErrNoContent) {
return nil
}
return requestError
}
// ReportResult meldet das Ergebnis eines Auftrags.
func (client *Client) ReportResult(resultContext context.Context, taskIdentifier string,
taskResult TaskResultPayload) error {
if client.agentToken == "" {
return ErrNotRegistered
}
requestError := client.performRequest(resultContext, http.MethodPost,
"/api/v1/agents/tasks/"+taskIdentifier+"/result", taskResult, nil, true)
if errors.Is(requestError, ErrNoContent) {
return nil
}
return requestError
}