The source headers, LICENSE files, and license metadata had drifted apart. Align the entire project to MIT: - Convert every source-file header to the MIT text across all comment styles (Go, TS, TSX, JS, MJS, SQL, CSS, GraphQL, shell), including SPDX-License-Identifier tags - Set the root and cookie-banner LICENSE files to the MIT text with a "MIT License" title line - Switch the package.json license fields, Docker image label, and cookie-banner README to MIT - Update docs and the genmodels header generator accordingly - Normalize copyright lines to a single format (Copyright (c) <year(s)> Probo Inc <hello@probo.com>.): unify the hello@getprobo.com and hello@probo.inc emails to hello@probo.com and the comma-separated years to a hyphenated range Genuine third-party references are intentionally left untouched: the Lucide icon attributions (Lucide is ISC) and the trivy dependency license allowlist. Signed-off-by: Sacha Al Himdani <sacha@probo.com>
246 lines
5.9 KiB
Go
246 lines
5.9 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// SOFTWARE.
|
|
|
|
package deviceagent
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.gearno.de/kit/httpclient"
|
|
)
|
|
|
|
type (
|
|
// Client calls the /api/agent/v1 REST API.
|
|
Client struct {
|
|
ServerURL string
|
|
APIKey string
|
|
UserAgent string
|
|
HTTP *http.Client
|
|
}
|
|
)
|
|
|
|
// NewClient creates an API client.
|
|
func NewClient(serverURL, apiKey, userAgent string) *Client {
|
|
httpClient := httpclient.DefaultPooledClient()
|
|
httpClient.Timeout = 30 * time.Second
|
|
|
|
return &Client{
|
|
ServerURL: strings.TrimRight(serverURL, "/"),
|
|
APIKey: apiKey,
|
|
UserAgent: userAgent,
|
|
HTTP: httpClient,
|
|
}
|
|
}
|
|
|
|
type (
|
|
EnrollRequest struct {
|
|
EnrollmentToken string `json:"enrollment_token"`
|
|
HardwareUUID string `json:"hardware_uuid"`
|
|
SerialNumber *string `json:"serial_number,omitempty"`
|
|
Hostname string `json:"hostname"`
|
|
Platform string `json:"platform"`
|
|
OSVersion string `json:"os_version"`
|
|
AgentVersion string `json:"agent_version"`
|
|
}
|
|
|
|
EnrollResponse struct {
|
|
DeviceID string `json:"device_id"`
|
|
APIKey string `json:"api_key"`
|
|
HeartbeatSeconds int `json:"heartbeat_interval_seconds"`
|
|
PostureSeconds int `json:"posture_interval_seconds"`
|
|
ServerTime string `json:"server_time"`
|
|
}
|
|
|
|
HeartbeatRequest struct {
|
|
AgentVersion string `json:"agent_version,omitempty"`
|
|
Hostname string `json:"hostname,omitempty"`
|
|
OSVersion string `json:"os_version,omitempty"`
|
|
UptimeSec int64 `json:"uptime_seconds,omitempty"`
|
|
}
|
|
|
|
HeartbeatResponse struct {
|
|
HeartbeatSeconds int `json:"heartbeat_interval_seconds"`
|
|
PostureSeconds int `json:"posture_interval_seconds"`
|
|
ServerTime string `json:"server_time"`
|
|
}
|
|
|
|
PostureResultPayload struct {
|
|
CheckKey string `json:"check_key"`
|
|
Status string `json:"status"`
|
|
Evidence json.RawMessage `json:"evidence,omitempty"`
|
|
ObservedAt time.Time `json:"observed_at"`
|
|
}
|
|
|
|
PosturesRequest struct {
|
|
Results []PostureResultPayload `json:"results"`
|
|
}
|
|
)
|
|
|
|
// Enroll exchanges an enrollment token for a device key.
|
|
func (c *Client) Enroll(ctx context.Context, req EnrollRequest) (*EnrollResponse, error) {
|
|
var resp EnrollResponse
|
|
if err := c.do(
|
|
ctx,
|
|
http.MethodPost,
|
|
"/api/agent/v1/enroll",
|
|
false,
|
|
req,
|
|
&resp,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &resp, nil
|
|
}
|
|
|
|
// Heartbeat sends a periodic device heartbeat.
|
|
func (c *Client) Heartbeat(ctx context.Context, req HeartbeatRequest) (*HeartbeatResponse, error) {
|
|
var resp HeartbeatResponse
|
|
if err := c.do(
|
|
ctx,
|
|
http.MethodPost,
|
|
"/api/agent/v1/heartbeat",
|
|
true,
|
|
req,
|
|
&resp,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &resp, nil
|
|
}
|
|
|
|
// PushPostures sends posture check results.
|
|
func (c *Client) PushPostures(ctx context.Context, results []PostureResultPayload) error {
|
|
if len(results) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return c.do(
|
|
ctx,
|
|
http.MethodPost,
|
|
"/api/agent/v1/postures",
|
|
true,
|
|
PosturesRequest{Results: results},
|
|
nil,
|
|
)
|
|
}
|
|
|
|
// Unenroll asks the server to revoke the device.
|
|
func (c *Client) Unenroll(ctx context.Context) error {
|
|
return c.do(
|
|
ctx,
|
|
http.MethodPost,
|
|
"/api/agent/v1/unenroll",
|
|
true,
|
|
nil,
|
|
nil,
|
|
)
|
|
}
|
|
|
|
// HTTPError captures a non-2xx API response.
|
|
type HTTPError struct {
|
|
StatusCode int
|
|
Body string
|
|
}
|
|
|
|
func (e *HTTPError) Error() string {
|
|
return fmt.Sprintf("agent api: %d %s", e.StatusCode, e.Body)
|
|
}
|
|
|
|
// IsUnauthorized reports whether err is an API 401.
|
|
func IsUnauthorized(err error) bool {
|
|
var herr *HTTPError
|
|
if !errors.As(err, &herr) {
|
|
return false
|
|
}
|
|
|
|
return herr.StatusCode == http.StatusUnauthorized
|
|
}
|
|
|
|
func (c *Client) do(
|
|
ctx context.Context,
|
|
method, path string,
|
|
authed bool,
|
|
in any,
|
|
out any,
|
|
) error {
|
|
url := c.ServerURL + path
|
|
|
|
var body io.Reader
|
|
|
|
if in != nil {
|
|
buf, err := json.Marshal(in)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot marshal request: %w", err)
|
|
}
|
|
|
|
body = bytes.NewReader(buf)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot build request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("User-Agent", c.UserAgent)
|
|
|
|
if authed {
|
|
if c.APIKey == "" {
|
|
return errors.New("agent client: no api key set")
|
|
}
|
|
|
|
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
|
}
|
|
|
|
resp, err := c.HTTP.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot perform request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
buf, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
return &HTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(buf))}
|
|
}
|
|
|
|
if out == nil {
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
return nil
|
|
}
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
|
return fmt.Errorf("cannot decode response: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|