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 }