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>
303 lines
10 KiB
Go
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, ®istrationResponse, 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
|
|
}
|