diff --git a/sdk/agent_config.go b/sdk/agent_config.go new file mode 100644 index 00000000..60298f1e --- /dev/null +++ b/sdk/agent_config.go @@ -0,0 +1,154 @@ +package sdk + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/google/uuid" +) + +const ( + agentConfigPath = "/api/agent/config" + + // apiStatusErrorBodyLimit caps how much of an unexpected response body is kept in an + // APIStatusError. + apiStatusErrorBodyLimit = 4 << 10 +) + +var ( + // ErrRemoteConfigUnsupported is returned when the API answers 404 on a remote agent + // configuration route, meaning the API predates the feature. + ErrRemoteConfigUnsupported = errors.New("sdk: api does not support remote agent configuration") + + // ErrAgentAuthRequired is returned, without making a request, when the client has no agent + // credentials (Config.AgentAuth). The remote configuration routes accept agent JWTs only. + ErrAgentAuthRequired = errors.New("sdk: remote agent configuration requires agent credentials") +) + +// APIStatusError is returned for any non-2xx response that has no dedicated sentinel error. +// Callers map specific statuses (401, 403, 409, 413, ...) from StatusCode. +type APIStatusError struct { + // StatusCode is the HTTP status code of the response. + StatusCode int + // Body is the response body, truncated to at most 4 KiB. + Body string +} + +// Error implements error. It includes the status code and the (truncated) response body. +func (e *APIStatusError) Error() string { + if e.Body == "" { + return fmt.Sprintf("sdk: unexpected api response status code: %d", e.StatusCode) + } + return fmt.Sprintf("sdk: unexpected api response status code: %d: %s", e.StatusCode, e.Body) +} + +// AgentConfigResult is the outcome of AgentConfig.Get. +type AgentConfigResult struct { + // Document is the current overlay document; nil when NotModified is true. + Document *agentconfig.OverlayDocument + // NotModified is true when the API answered 304 to the presented If-None-Match. + NotModified bool + // ETag is the raw ETag response header. Store it and send it back verbatim as + // If-None-Match (R7); never construct one. On a 304 without an ETag header it is the + // If-None-Match value that was sent. + ETag string +} + +type agentConfigClient struct { + client *Client +} + +// Get fetches the agent's remote configuration overlay (GET /api/agent/config). +// +// When ifNoneMatch is non-empty it is sent verbatim as the If-None-Match header; pass the raw +// ETag of a previous result. A 200 returns the decoded document and its ETag, a 304 returns +// NotModified. A 404 yields ErrRemoteConfigUnsupported and any other non-2xx an +// *APIStatusError. Requires agent credentials, otherwise ErrAgentAuthRequired is returned +// without making a request. +func (a *agentConfigClient) Get(ctx context.Context, ifNoneMatch string) (*AgentConfigResult, error) { + if !a.client.hasAgentAuth() { + return nil, ErrAgentAuthRequired + } + + var headers http.Header + if ifNoneMatch != "" { + headers = http.Header{} + // Set the value directly so it is sent exactly as given. + headers["If-None-Match"] = []string{ifNoneMatch} + } + + resp, err := a.client.doRequestWithHeaders(ctx, http.MethodGet, agentConfigPath, nil, headers) + if err != nil { + return nil, err + } + defer closeResponseBody(resp, a.client.config.Logger) + + switch resp.StatusCode { + case http.StatusNotModified: + etag := resp.Header.Get("ETag") + if etag == "" { + etag = ifNoneMatch + } + return &AgentConfigResult{NotModified: true, ETag: etag}, nil + case http.StatusOK: + var body struct { + Data *agentconfig.OverlayDocument `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("sdk: decode agent config response: %w", err) + } + if body.Data == nil { + return nil, errors.New("sdk: agent config response missing data") + } + return &AgentConfigResult{Document: body.Data, ETag: resp.Header.Get("ETag")}, nil + case http.StatusNotFound: + return nil, ErrRemoteConfigUnsupported + default: + return nil, newAPIStatusError(resp) + } +} + +// Report submits this instance's configuration report +// (PUT /api/agent/instances//config-report). +// +// Any 2xx (normally 204) returns nil. A 404 yields ErrRemoteConfigUnsupported and any other +// non-2xx an *APIStatusError. There are no retries beyond the client's single 401 +// token-refresh retry; the agent owns the reporting cadence. Requires agent credentials, +// otherwise ErrAgentAuthRequired is returned without making a request. +func (a *agentConfigClient) Report(ctx context.Context, instanceID uuid.UUID, r agentconfig.Report) error { + if !a.client.hasAgentAuth() { + return ErrAgentAuthRequired + } + + path := fmt.Sprintf("/api/agent/instances/%s/config-report", instanceID) + resp, err := a.client.doJSONRequest(ctx, http.MethodPut, path, r) + if err != nil { + return err + } + defer closeResponseBody(resp, a.client.config.Logger) + + switch { + case resp.StatusCode >= 200 && resp.StatusCode < 300: + return nil + case resp.StatusCode == http.StatusNotFound: + return ErrRemoteConfigUnsupported + default: + return newAPIStatusError(resp) + } +} + +// newAPIStatusError builds an APIStatusError from resp, reading at most +// apiStatusErrorBodyLimit bytes of its body. +func newAPIStatusError(resp *http.Response) *APIStatusError { + e := &APIStatusError{StatusCode: resp.StatusCode} + if resp.Body != nil { + body, _ := io.ReadAll(io.LimitReader(resp.Body, apiStatusErrorBodyLimit)) + e.Body = string(body) + } + return e +} diff --git a/sdk/agent_config_test.go b/sdk/agent_config_test.go new file mode 100644 index 00000000..a8ef5de3 --- /dev/null +++ b/sdk/agent_config_test.go @@ -0,0 +1,375 @@ +package sdk + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/compliance-framework/api/sdk/types" + "github.com/google/uuid" +) + +const testAgentETag = `W/"r7-0b7f1c2e-3d4a-4b5c-8d9e-0f1a2b3c4d5e"` + +// agentConfigTestServer serves the agent token endpoint and delegates everything else to +// handler. It counts token fetches and non-token requests. +type agentConfigTestServer struct { + *httptest.Server + tokenRequests atomic.Int32 + requests atomic.Int32 +} + +func newAgentConfigTestServer(t *testing.T, handler http.HandlerFunc) *agentConfigTestServer { + t.Helper() + s := &agentConfigTestServer{} + s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/auth/agent/token" { + n := s.tokenRequests.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"access_token":"token-%d","token_type":"bearer","expires_in":3600}`, n) + return + } + s.requests.Add(1) + handler(w, r) + })) + t.Cleanup(s.Close) + return s +} + +func (s *agentConfigTestServer) client(withAuth bool) *Client { + cfg := &Config{BaseURL: s.URL} + if withAuth { + cfg.AgentAuth = &AgentAuthConfig{ClientID: "client-id", ClientSecret: "client-secret"} + } + return NewClient(s.Client(), cfg) +} + +func TestAgentConfigGetSendsIfNoneMatchVerbatimAndDecodes200(t *testing.T) { + var ( + gotMethod, gotPath, gotINM, gotAuth string + inmValues []string + ) + created := time.Date(2026, time.September, 30, 10, 0, 0, 0, time.UTC) + srv := newAgentConfigTestServer(t, func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + gotINM = r.Header.Get("If-None-Match") + inmValues = r.Header.Values("If-None-Match") + gotAuth = r.Header.Get("Authorization") + w.Header().Set("ETag", `"r8-11111111-1111-1111-1111-111111111111"`) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"data":{"revision":8,"overlay":{"plugins":{"ssh":{"config":{"port":"2222"}}}},"created-at":"2026-09-30T10:00:00Z"}}`) + }) + + res, err := srv.client(true).AgentConfig.Get(context.Background(), testAgentETag) + if err != nil { + t.Fatalf("get: %v", err) + } + if gotMethod != http.MethodGet || gotPath != "/api/agent/config" { + t.Fatalf("unexpected request %s %s", gotMethod, gotPath) + } + if gotINM != testAgentETag || len(inmValues) != 1 { + t.Fatalf("If-None-Match not sent verbatim: got %q (%v)", gotINM, inmValues) + } + if gotAuth != "Bearer token-1" { + t.Fatalf("unexpected Authorization %q", gotAuth) + } + if res.NotModified { + t.Fatal("expected NotModified=false") + } + if res.ETag != `"r8-11111111-1111-1111-1111-111111111111"` { + t.Fatalf("unexpected ETag %q", res.ETag) + } + if res.Document == nil || res.Document.Revision != 8 { + t.Fatalf("unexpected document %+v", res.Document) + } + if res.Document.CreatedAt == nil || !res.Document.CreatedAt.Equal(created) { + t.Fatalf("unexpected created-at %v", res.Document.CreatedAt) + } + var overlay map[string]any + if err := json.Unmarshal(res.Document.Overlay, &overlay); err != nil { + t.Fatalf("overlay not valid JSON: %v", err) + } + if _, ok := overlay["plugins"]; !ok { + t.Fatalf("overlay missing plugins: %s", res.Document.Overlay) + } +} + +func TestAgentConfigGetOmitsIfNoneMatchWhenEmpty(t *testing.T) { + var present bool + srv := newAgentConfigTestServer(t, func(w http.ResponseWriter, r *http.Request) { + _, present = r.Header["If-None-Match"] + w.Header().Set("ETag", `"r0-22222222-2222-2222-2222-222222222222"`) + _, _ = io.WriteString(w, `{"data":{"revision":0,"overlay":{}}}`) + }) + + res, err := srv.client(true).AgentConfig.Get(context.Background(), "") + if err != nil { + t.Fatalf("get: %v", err) + } + if present { + t.Fatal("If-None-Match must not be sent when empty") + } + if res.Document == nil || res.Document.Revision != 0 || res.Document.CreatedAt != nil { + t.Fatalf("unexpected document %+v", res.Document) + } +} + +func TestAgentConfigGetNotModified(t *testing.T) { + t.Run("with etag header", func(t *testing.T) { + srv := newAgentConfigTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("ETag", `"r7-server"`) + w.WriteHeader(http.StatusNotModified) + }) + res, err := srv.client(true).AgentConfig.Get(context.Background(), testAgentETag) + if err != nil { + t.Fatalf("get: %v", err) + } + if !res.NotModified || res.Document != nil || res.ETag != `"r7-server"` { + t.Fatalf("unexpected result %+v", res) + } + }) + t.Run("without etag header", func(t *testing.T) { + srv := newAgentConfigTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotModified) + }) + res, err := srv.client(true).AgentConfig.Get(context.Background(), testAgentETag) + if err != nil { + t.Fatalf("get: %v", err) + } + if !res.NotModified || res.Document != nil || res.ETag != testAgentETag { + t.Fatalf("unexpected result %+v", res) + } + }) +} + +func TestAgentConfigGetNotFoundIsUnsupported(t *testing.T) { + srv := newAgentConfigTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + res, err := srv.client(true).AgentConfig.Get(context.Background(), "") + if !errors.Is(err, ErrRemoteConfigUnsupported) { + t.Fatalf("expected ErrRemoteConfigUnsupported, got %v", err) + } + if res != nil { + t.Fatalf("expected nil result, got %+v", res) + } +} + +func TestAgentConfigGetOtherStatusIsAPIStatusError(t *testing.T) { + srv := newAgentConfigTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"errors":{"body":"boom"}}`+strings.Repeat("x", 8<<10)) + }) + _, err := srv.client(true).AgentConfig.Get(context.Background(), "") + var apiErr *APIStatusError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *APIStatusError, got %T %v", err, err) + } + if apiErr.StatusCode != http.StatusInternalServerError { + t.Fatalf("unexpected status %d", apiErr.StatusCode) + } + if !strings.HasPrefix(apiErr.Body, `{"errors":{"body":"boom"}}`) || len(apiErr.Body) != 4<<10 { + t.Fatalf("unexpected body (len %d): %.64q", len(apiErr.Body), apiErr.Body) + } + if !strings.Contains(err.Error(), "500") || !strings.Contains(err.Error(), "boom") { + t.Fatalf("error should include status and body: %q", err.Error()) + } +} + +func TestAgentConfigGetRetriesOnceOn401KeepingHeaders(t *testing.T) { + var ( + mu sync.Mutex + calls []struct{ auth, inm string } + ) + srv := newAgentConfigTestServer(t, func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + calls = append(calls, struct{ auth, inm string }{r.Header.Get("Authorization"), r.Header.Get("If-None-Match")}) + n := len(calls) + mu.Unlock() + if n == 1 { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusNotModified) + }) + + res, err := srv.client(true).AgentConfig.Get(context.Background(), testAgentETag) + if err != nil { + t.Fatalf("get: %v", err) + } + if !res.NotModified { + t.Fatalf("expected NotModified, got %+v", res) + } + if len(calls) != 2 { + t.Fatalf("expected 2 config requests, got %d", len(calls)) + } + if got := srv.tokenRequests.Load(); got != 2 { + t.Fatalf("expected 2 token requests, got %d", got) + } + if calls[0].auth != "Bearer token-1" || calls[1].auth != "Bearer token-2" { + t.Fatalf("unexpected Authorization headers %+v", calls) + } + for i, c := range calls { + if c.inm != testAgentETag { + t.Fatalf("request %d If-None-Match = %q, want %q", i, c.inm, testAgentETag) + } + } +} + +func TestAgentConfigRequiresAgentAuth(t *testing.T) { + srv := newAgentConfigTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + c := srv.client(false) + + if _, err := c.AgentConfig.Get(context.Background(), testAgentETag); !errors.Is(err, ErrAgentAuthRequired) { + t.Fatalf("Get: expected ErrAgentAuthRequired, got %v", err) + } + if err := c.AgentConfig.Report(context.Background(), uuid.New(), agentconfig.Report{}); !errors.Is(err, ErrAgentAuthRequired) { + t.Fatalf("Report: expected ErrAgentAuthRequired, got %v", err) + } + if n := srv.requests.Load() + srv.tokenRequests.Load(); n != 0 { + t.Fatalf("expected no HTTP requests, got %d", n) + } +} + +func TestAgentConfigReport(t *testing.T) { + instanceID := uuid.MustParse("33333333-3333-3333-3333-333333333333") + applied := int64(6) + attempted := int64(7) + report := agentconfig.Report{ + Hostname: "ip-10-0-1-12", + AgentVersion: "v0.9.0", + Mode: agentconfig.ModeApplySafe, + Daemon: true, + AppliedRevision: &applied, + AttemptedRevision: &attempted, + Status: agentconfig.StatusRejected, + Reason: agentconfig.ReasonUnsafeChanges, + Base: json.RawMessage(`{"api":{"url":"http://x"}}`), + Effective: json.RawMessage(`{"api":{"url":"http://x"}}`), + EffectiveDigest: "sha256:" + strings.Repeat("a", 64), + } + + t.Run("204", func(t *testing.T) { + var gotMethod, gotPath, gotContentType string + var gotBody map[string]any + srv := newAgentConfigTestServer(t, func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + gotContentType = r.Header.Get("Content-Type") + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Errorf("decode body: %v", err) + } + w.WriteHeader(http.StatusNoContent) + }) + if err := srv.client(true).AgentConfig.Report(context.Background(), instanceID, report); err != nil { + t.Fatalf("report: %v", err) + } + if gotMethod != http.MethodPut || gotPath != "/api/agent/instances/33333333-3333-3333-3333-333333333333/config-report" { + t.Fatalf("unexpected request %s %s", gotMethod, gotPath) + } + if gotContentType != "application/json" { + t.Fatalf("unexpected content type %q", gotContentType) + } + for _, key := range []string{"effective-digest", "applied-revision", "attempted-revision", "agent-version"} { + if _, ok := gotBody[key]; !ok { + t.Fatalf("report body missing kebab-case key %q: %v", key, gotBody) + } + } + if gotBody["applied-revision"] != float64(6) || gotBody["status"] != "rejected" { + t.Fatalf("unexpected report body %v", gotBody) + } + }) + + t.Run("404", func(t *testing.T) { + srv := newAgentConfigTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + err := srv.client(true).AgentConfig.Report(context.Background(), instanceID, report) + if !errors.Is(err, ErrRemoteConfigUnsupported) { + t.Fatalf("expected ErrRemoteConfigUnsupported, got %v", err) + } + }) + + t.Run("500", func(t *testing.T) { + srv := newAgentConfigTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, "internal") + }) + err := srv.client(true).AgentConfig.Report(context.Background(), instanceID, report) + var apiErr *APIStatusError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *APIStatusError, got %T %v", err, err) + } + if apiErr.StatusCode != http.StatusInternalServerError || apiErr.Body != "internal" { + t.Fatalf("unexpected error %+v", apiErr) + } + if n := srv.requests.Load(); n != 1 { + t.Fatalf("expected exactly 1 report request (no retries), got %d", n) + } + }) +} + +func TestHeartbeatConfigFieldsSerialization(t *testing.T) { + base := types.Heartbeat{ + UUID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + CreatedAt: time.Date(2026, time.April, 7, 12, 0, 0, 0, time.UTC), + } + + raw, err := json.Marshal(base) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(raw), "config_revision") || strings.Contains(string(raw), "config_digest") { + t.Fatalf("unset config fields must be omitted: %s", raw) + } + + rev := int64(0) + withCfg := base + withCfg.ConfigRevision = &rev + withCfg.ConfigDigest = "sha256:" + strings.Repeat("b", 64) + raw, err = json.Marshal(withCfg) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(raw), `"config_revision":0`) { + t.Fatalf("expected config_revision 0 to be present: %s", raw) + } + if !strings.Contains(string(raw), `"config_digest":"sha256:`) { + t.Fatalf("expected config_digest: %s", raw) + } +} + +func TestDoRequestWithHeadersCannotOverrideAuthorization(t *testing.T) { + var gotAuth, gotCustom string + srv := newAgentConfigTestServer(t, func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotCustom = r.Header.Get("X-Custom") + w.WriteHeader(http.StatusOK) + }) + headers := http.Header{} + headers.Set("Authorization", "Bearer attacker") + headers.Set("X-Custom", "yes") + + resp, err := srv.client(true).doRequestWithHeaders(context.Background(), http.MethodGet, "/api/test", nil, headers) + if err != nil { + t.Fatalf("request: %v", err) + } + closeResponseBody(resp, nil) + if gotAuth != "Bearer token-1" { + t.Fatalf("Authorization overridden: %q", gotAuth) + } + if gotCustom != "yes" { + t.Fatalf("custom header not sent: %q", gotCustom) + } +} diff --git a/sdk/client.go b/sdk/client.go index 44c1164b..cca87f76 100644 --- a/sdk/client.go +++ b/sdk/client.go @@ -49,6 +49,10 @@ type Client struct { Playback *playbackClient + // AgentConfig fetches the remote configuration overlay and submits instance config reports. + // Both routes require agent credentials (Config.AgentAuth). + AgentConfig *agentConfigClient + Artifact *artifactClient } @@ -70,6 +74,7 @@ func NewClient(client *http.Client, config *Config) *Client { c.SubjectTemplate = &subjectTemplateClient{client: c} c.Heartbeat = &heartbeatClient{client: c} c.Playback = &playbackClient{client: c} + c.AgentConfig = &agentConfigClient{client: c} c.Artifact = &artifactClient{client: c} return c @@ -100,8 +105,10 @@ func (c *Client) doRequest(ctx context.Context, method string, path string, body return c.doRequestWithHeaders(ctx, method, path, body, nil) } -// doRequestWithHeaders is doRequest with extra request headers, which override the default -// Content-Type. +// doRequestWithHeaders behaves like doRequest but also sends the given extra headers, which +// override the default Content-Type. The same headers are re-sent on the single 401 +// token-refresh retry. Authorization is managed by the client; an Authorization entry in +// headers is ignored. func (c *Client) doRequestWithHeaders(ctx context.Context, method string, path string, body []byte, headers http.Header) (*http.Response, error) { if !c.hasAgentAuth() { return c.executeRequest(ctx, method, path, body, "", headers) @@ -170,6 +177,9 @@ func (c *Client) executeRequest(ctx context.Context, method string, path string, return c.executeStreamingRequest(ctx, method, path, bytes.NewReader(body), authorization, headers) } +// executeStreamingRequest sends a single request. Extra headers are applied after Content-Type +// (so a caller may override it) but Authorization is always owned by the client: any +// Authorization entry in headers is dropped. func (c *Client) executeStreamingRequest(ctx context.Context, method string, path string, body io.Reader, authorization string, headers http.Header) (*http.Response, error) { path = strings.TrimPrefix(path, "/") url := strings.TrimSuffix(c.config.BaseURL, "/") @@ -179,7 +189,13 @@ func (c *Client) executeStreamingRequest(ctx context.Context, method string, pat } req.Header.Set("Content-Type", "application/json") for name, values := range headers { - req.Header[http.CanonicalHeaderKey(name)] = values + if http.CanonicalHeaderKey(name) == "Authorization" { + continue + } + req.Header.Del(name) + for _, v := range values { + req.Header.Add(name, v) + } } if authorization != "" { req.Header.Set("Authorization", authorization) diff --git a/sdk/types/types.go b/sdk/types/types.go index dd77575e..523d0f50 100644 --- a/sdk/types/types.go +++ b/sdk/types/types.go @@ -251,9 +251,15 @@ type SubjectTemplate struct { LabelSchema []SubjectTemplateLabelSchema `json:"label-schema"` } +// Heartbeat is the body of POST /api/agent/heartbeat. type Heartbeat struct { UUID uuid.UUID `json:"uuid"` CreatedAt time.Time `json:"created_at"` + // R45: the APPLIED revision (0 = running from the file only). Never null: new agents always set it + // (together with ConfigDigest) when mode != off. It is a pointer only so that absence (old agents, + // mode off) stays distinguishable server-side. + ConfigRevision *int64 `json:"config_revision,omitempty"` + ConfigDigest string `json:"config_digest,omitempty"` // Digest(effective); required when mode != off (R11) } // PolicyArtifacts are the digests of the artifacts a policy evaluation used, as returned by