diff --git a/cmd/fleet-mcp/README.md b/cmd/fleet-mcp/README.md index e53444b9545..7887b943afb 100644 --- a/cmd/fleet-mcp/README.md +++ b/cmd/fleet-mcp/README.md @@ -274,7 +274,7 @@ The MCP holds the operator's `FLEET_API_KEY` and acts with exactly that token's - **TLS skip-verify hard-gated to localhost.** `FLEET_TLS_SKIP_VERIFY=true` paired with a non-loopback `FLEET_BASE_URL` makes the binary refuse to start (`logrus.Fatalf`) — copying a dev `.env` to a remote deploy can no longer expose the Fleet token to an on-path attacker. - **Strong `MCP_AUTH_TOKEN` enforced.** The server refuses to start if `MCP_AUTH_TOKEN` is shorter than 32 characters — a high-entropy token is the real defense against bearer brute force (`openssl rand -hex 32` satisfies it). - **Rate limiting / DoS protection belongs upstream.** The MCP runs no limiter of its own — Fleet typically runs behind a reverse proxy / edge that terminates TLS (above) and throttles requests, which is where per-client rate limiting belongs (Render's edge; or a reverse proxy / WAF such as nginx / Caddy / Cloudflare). The strong `MCP_AUTH_TOKEN` is the brute-force defense, and failed-auth requests are rejected by the MCP before they ever reach Fleet. -- **API-only token required.** At startup the MCP calls `GET /api/v1/fleet/me` and **refuses to start unless `FLEET_API_KEY` belongs to an API-only Fleet user**. An API-only user has no UI session, its own audit identity, and — via Fleet's per-user role/team scoping — can be locked down to exactly the endpoints (and teams/fleets) the MCP needs, adding a Fleet-side authorization boundary on top of the MCP's bearer auth. Fails closed: if `/me` can't confirm the principal (Fleet unreachable or token invalid) the MCP won't start. To create one with the right access, see Fleet's docs: [Create an API-only user](https://fleetdm.com/docs/rest-api/rest-api#create-api-only-user), [the endpoints an API-only user can reach](https://fleetdm.com/docs/rest-api/rest-api#list-api-endpoints-for-api-only-user-permissions), and [Using `fleetctl` with an API-only user](https://fleetdm.com/guides/fleetctl#using-fleetctl-with-an-api-only-user). +- **API-only token required.** At startup the MCP calls `GET /api/v1/fleet/me` and **refuses to start unless `FLEET_API_KEY` belongs to an API-only Fleet user**. An API-only user has no UI session, its own audit identity, and — via Fleet's per-user role/team scoping — can be locked down to exactly the endpoints (and teams/fleets) the MCP needs, adding a Fleet-side authorization boundary on top of the MCP's bearer auth. Fails closed: if `/me` can't confirm the principal (Fleet unreachable or token invalid) the MCP won't start. To create one with the right access, see Fleet's docs: [Create an API-only user](https://fleetdm.com/docs/rest-api/rest-api#create-api-only-user), [the endpoints an API-only user can reach](https://fleetdm.com/docs/rest-api/rest-api#list-api-endpoints-for-api-only-user-permissions), and [Using `fleetctl` with an API-only user](https://fleetdm.com/guides/fleetctl#using-fleetctl-with-an-api-only-user). If you restrict the user to specific endpoints, allowlist the routes in [Required Fleet API endpoints](#required-fleet-api-endpoints); a startup self-check probes each route and logs any that come back 403. - **Read vs run = the token's Fleet role.** The MCP acts with exactly the role of `FLEET_API_KEY` (it has no mode of its own), and Fleet enforces it. The read-only tools work with an **observer** token; the one mutating tool, `run_live_query`, needs **observer_plus** (or admin / maintainer / technician) per Fleet's RBAC — an observer token gets a `403`. **No maintainer or admin role is required.** Recommended: an API-only user with the **lowest role** your tools need — **observer** for a read-only deployment, **observer_plus** if you use `run_live_query`. The MCP doesn't infer or log these rights — it only enforces the API-only requirement above. - **Live queries run arbitrary osquery (scope the token).** `run_live_query` dispatches arbitrary osquery to devices — exactly like Fleet's own UI / `fleetctl` / live-query REST API, which the MCP proxies. Tables such as `curl` / `curl_certificate` / `carves` can make outbound requests or exfiltrate file content from a managed device (e.g. cloud-metadata credentials). **This is a Fleet/osquery capability, not specific to the MCP** — the MCP does not (and should not) special-case it. The MCP-layer control is a **least-privilege `FLEET_API_KEY`** (an observer token → Fleet rejects live queries entirely). To remove the capability everywhere (UI, `fleetctl`, scheduled, MCP), disable the tables on the osquery agent: `--disable_tables=curl,curl_certificate,carves,yara,yara_events` via fleetd/orbit agent options. - **Body size cap on SSE transport.** `http.MaxBytesReader` caps every incoming request body at 1 MiB. Hostile clients cannot OOM the MCP via oversized JSON-RPC payloads. @@ -284,6 +284,50 @@ The MCP holds the operator's `FLEET_API_KEY` and acts with exactly that token's - **CVE / policy / per_page input validation at the MCP layer.** `cve_id` must match `^CVE-\d{4}-\d{4,}$`, `policy_id` must be a positive integer, `per_page` is clamped to 200. Malformed inputs get a usable error message before any Fleet API call. - **Context propagation end-to-end.** Every FleetClient method takes `ctx context.Context`; MCP handler cancellation propagates through to in-flight Fleet API calls, including between iterations of fan-out paths (CVE compose, label intersection). A cancelled MCP request stops the whole fan-out instead of running every remaining HTTP call to completion. +## Required Fleet API endpoints + +When the API-only user behind `FLEET_API_KEY` is restricted to specific endpoints (see [the endpoints an API-only user can reach](https://fleetdm.com/docs/rest-api/rest-api#list-api-endpoints-for-api-only-user-permissions)), the allowlist must include every route below. This list is generated from `requiredEndpoints` in `startup_check.go`, which is the source of truth; a package test fails if the toolset starts calling a Fleet API route that isn't in that list. + +| Method | Endpoint | Used by | +|---|---|---| +| GET | `/api/v1/fleet/me` | startup API-only verification | +| GET | `/api/v1/fleet/hosts` | `get_endpoints`, `get_host` (substring resolver), `run_live_query` targeting, `get_vulnerability_hosts`, `get_policy_hosts` | +| GET | `/api/v1/fleet/hosts/count` | `get_endpoints` (scoped `total`) | +| GET | `/api/v1/fleet/hosts/:id` | `get_host`, `get_host_policies`, `get_host_users`, label-listing enrichment | +| GET | `/api/v1/fleet/hosts/:id/software` | `get_software` (per-host mode) | +| GET | `/api/v1/fleet/hosts/identifier/:identifier` | `get_host` / `get_host_policies` identifier fallback | +| GET | `/api/v1/fleet/host_summary` | `get_aggregate_platforms` | +| GET | `/api/v1/fleet/labels` | `get_labels`, label/platform name resolution | +| GET | `/api/v1/fleet/labels/:id/hosts` | label- and platform-filtered host listings | +| GET | `/api/v1/fleet/fleets` | `get_fleets`, fleet name resolution | +| GET | `/api/v1/fleet/fleets/:id/policies` | `get_policies` (per-fleet) | +| GET | `/api/v1/fleet/fleets/:fleet_id/policies/:policy_id` | `get_policy_compliance` (fleet-scoped) | +| GET | `/api/v1/fleet/global/policies` | `get_policies` | +| GET | `/api/v1/fleet/global/policies/:id` | `get_policy_compliance` | +| GET | `/api/v1/fleet/reports` | `get_queries` | +| POST | `/api/v1/fleet/reports/run` | `run_live_query` (multi-host campaign) | +| GET | `/api/v1/fleet/software/titles` | `get_software` (cross-host mode), `get_vulnerability_impact`, `get_vulnerability_hosts` | +| GET | `/api/v1/fleet/software/titles/:id` | CVE-to-vulnerable-version lookup | +| POST | `/api/v1/fleet/hosts/:id/query` | `run_live_query` (single host) | + +Not in the allowlist: + +- `GET /api/v1/fleet/results/websocket` is also used (live query result streaming) but is a raw handler that is **not** subject to endpoint restrictions, so it needs no allowlist entry. +- The developer-only `-seed` mode additionally calls `POST /api/v1/fleet/reports` to create saved queries. Normal MCP serving never does. + +### Startup self-check + +After the API-only verification, the server probes every route above with the configured token and logs a warning for each one that returns HTTP 403, plus a summary: + +``` +level=warning msg="startup self-check: GET /api/v1/fleet/software/titles returned HTTP 403 — blocked by the API-only user's endpoint restrictions or the token's role; MCP tools that call it will fail" +level=warning msg="startup self-check: 1 of 19 required Fleet API endpoints blocked for this token; update the API-only user's endpoint allowlist ..." +``` + +The check runs in the background so readiness never waits on it: `/healthz` (SSE) and the stdio JSON-RPC stream come up immediately, and the probe results land in the logs within seconds (30s cap on a slow Fleet). + +This catches a stale allowlist at deploy time instead of surfacing as opaque `403` tool failures for end users. The check is non-fatal: the server still starts, since the remaining tools keep working and a 403 can also reflect an intentional role limit rather than allowlist drift. Probes are side-effect free: path IDs use `0` / a sentinel identifier that never exists, and the two POST probes send intentionally invalid bodies that Fleet rejects during validation before any query or campaign is created. + ## Deploying to Render `cmd/fleet-mcp/render.yaml` is a standalone Render Blueprint, separate from the root `render.yaml` used by the main Fleet service. @@ -306,6 +350,7 @@ The MCP holds the operator's `FLEET_API_KEY` and acts with exactly that token's ``` cmd/fleet-mcp/ main.go # entrypoint, flag parsing, transport selection, http.Server timeouts, body-size cap + startup_check.go # requiredEndpoints list + startup self-check that probes each route and warns on 403 config.go # env-var loading auth.go # bearer-auth middleware (SSE) route_guard.go # SSE route allow-list @@ -333,7 +378,8 @@ Tunables (env vars) for the schema layer: 2. Pick the right domain file (`mcp_tools_hosts.go`, `mcp_tools_queries.go`, `mcp_tools_policies.go`, or `mcp_tools_inventory.go`) and add a `register` function. 3. Wire the new register function into the matching `registerTools` orchestrator at the top of the same file. 4. Always set `readOnly` / `destructive` / `idempotent` annotations so Claude Desktop can advertise it. -5. Build and run the smoke test from the [Smoke-test stdio mode](#smoke-test-stdio-mode-without-claude-desktop) section. +5. If the tool calls a Fleet API route not already used by the package, add it to `requiredEndpoints` in `startup_check.go` and to the [Required Fleet API endpoints](#required-fleet-api-endpoints) table — `TestRequiredEndpointsCoverSourcePaths` fails until the code list is updated. +6. Build and run the smoke test from the [Smoke-test stdio mode](#smoke-test-stdio-mode-without-claude-desktop) section. ### Operational learnings diff --git a/cmd/fleet-mcp/main.go b/cmd/fleet-mcp/main.go index 1391a1e8f9a..9ed68fa2afd 100644 --- a/cmd/fleet-mcp/main.go +++ b/cmd/fleet-mcp/main.go @@ -106,6 +106,11 @@ func main() { return } + // Diagnostic-only, so it runs concurrently with serving: readiness + // (/healthz on SSE, stdin on stdio) must not wait up to + // startupCheckTimeout on a slow Fleet. + go verifyRequiredEndpoints(ctx, fleetClient) + mcpServer := SetupMCPServer(config, fleetClient) if *transport == "stdio" { diff --git a/cmd/fleet-mcp/startup_check.go b/cmd/fleet-mcp/startup_check.go new file mode 100644 index 00000000000..7285ffa5046 --- /dev/null +++ b/cmd/fleet-mcp/startup_check.go @@ -0,0 +1,131 @@ +package main + +import ( + "context" + "io" + "net/http" + "time" + + "github.com/sirupsen/logrus" +) + +// startupCheckTimeout bounds the whole endpoint self-check. Fleet +// reachability was already proven by requireAPIOnlyUser, so a slow or +// wedged Fleet should degrade to a logged warning, not a hung startup. +const startupCheckTimeout = 30 * time.Second + +// requiredEndpoint is one Fleet API route the MCP toolset depends on. +type requiredEndpoint struct { + method string + // route is the route shape with path params as :name — the form an + // operator puts in an API-only user's endpoint allowlist, and the form + // documented in README.md ("Required Fleet API endpoints"). + route string + // probe is a concrete, side-effect-free instantiation of route. Path + // params use ID 0 or a sentinel identifier that never exists, so GET + // probes return 404 and POST probes fail Fleet-side validation before + // anything is created or executed. Any response other than 403 proves + // the route is reachable with the configured token. + probe string + // body is sent on POST probes. It is intentionally invalid (empty + // query, nonexistent host) so Fleet rejects the request during + // validation: POST /reports/run with no query/query_id errors before a + // campaign or query row is created, and POST /hosts/0/query fails the + // host lookup before the query is examined. + body interface{} +} + +// requiredEndpoints is the source of truth for which Fleet API routes the +// MCP needs. Deployments that lock the API-only user down with endpoint +// restrictions must allowlist exactly these routes; verifyRequiredEndpoints +// probes each at startup so a stale allowlist is caught at deploy time +// instead of by end users hitting opaque 403s. +// +// GET /api/v1/fleet/results/websocket is also used (live query result +// streaming) but is a raw handler not subject to endpoint restrictions, so +// it is not probed here. +// +// Keep this list, the README section, and the MCP toolset in sync — the +// TestRequiredEndpointsCoverSourcePaths test fails when a Fleet API path +// used by this package is missing from the list. +var requiredEndpoints = []requiredEndpoint{ + // /me can never be observed as blocked here (requireAPIOnlyUser already + // fatals if it fails), but it stays in the list because it belongs in + // the operator's allowlist all the same. + {method: "GET", route: "/api/v1/fleet/me", probe: "/api/v1/fleet/me"}, + {method: "GET", route: "/api/v1/fleet/hosts", probe: "/api/v1/fleet/hosts?per_page=1"}, + {method: "GET", route: "/api/v1/fleet/hosts/count", probe: "/api/v1/fleet/hosts/count"}, + {method: "GET", route: "/api/v1/fleet/hosts/:id", probe: "/api/v1/fleet/hosts/0"}, + {method: "GET", route: "/api/v1/fleet/hosts/:id/software", probe: "/api/v1/fleet/hosts/0/software?per_page=1"}, + {method: "GET", route: "/api/v1/fleet/hosts/identifier/:identifier", probe: "/api/v1/fleet/hosts/identifier/fleet-mcp-startup-probe"}, + {method: "GET", route: "/api/v1/fleet/host_summary", probe: "/api/v1/fleet/host_summary"}, + {method: "GET", route: "/api/v1/fleet/labels", probe: "/api/v1/fleet/labels"}, + {method: "GET", route: "/api/v1/fleet/labels/:id/hosts", probe: "/api/v1/fleet/labels/0/hosts?per_page=1"}, + {method: "GET", route: "/api/v1/fleet/fleets", probe: "/api/v1/fleet/fleets"}, + {method: "GET", route: "/api/v1/fleet/fleets/:id/policies", probe: "/api/v1/fleet/fleets/0/policies"}, + {method: "GET", route: "/api/v1/fleet/fleets/:fleet_id/policies/:policy_id", probe: "/api/v1/fleet/fleets/0/policies/0"}, + {method: "GET", route: "/api/v1/fleet/global/policies", probe: "/api/v1/fleet/global/policies"}, + {method: "GET", route: "/api/v1/fleet/global/policies/:id", probe: "/api/v1/fleet/global/policies/0"}, + {method: "GET", route: "/api/v1/fleet/reports", probe: "/api/v1/fleet/reports?per_page=1"}, + {method: "POST", route: "/api/v1/fleet/reports/run", probe: "/api/v1/fleet/reports/run", body: struct{}{}}, + {method: "GET", route: "/api/v1/fleet/software/titles", probe: "/api/v1/fleet/software/titles?per_page=1"}, + {method: "GET", route: "/api/v1/fleet/software/titles/:id", probe: "/api/v1/fleet/software/titles/0"}, + {method: "POST", route: "/api/v1/fleet/hosts/:id/query", probe: "/api/v1/fleet/hosts/0/query", body: struct{}{}}, +} + +// verifyEndpointAccess probes every required endpoint with the configured +// token. It returns the "METHOD route" strings that responded 403 (blocked +// by the API-only user's endpoint restrictions or by the token's role) and +// the count of endpoints whose probe failed at the transport level — those +// are unverifiable, not blocked, so the caller must not report an all-clear +// for them. +func verifyEndpointAccess(ctx context.Context, fc *FleetClient) (blocked []string, unverified int) { + ctx, cancel := context.WithTimeout(ctx, startupCheckTimeout) + defer cancel() + + for i, e := range requiredEndpoints { + if ctx.Err() != nil { + logrus.Warnf("startup self-check: aborted (%v); skipping the remaining %d endpoints", ctx.Err(), len(requiredEndpoints)-i) + unverified += len(requiredEndpoints) - i + break + } + resp, err := fc.makeFleetRequest(ctx, e.method, e.probe, e.body) + if err != nil { + logrus.Warnf("startup self-check: could not probe %s %s: %v", e.method, e.route, err) + unverified++ + continue + } + // Drain a bounded amount so the connection can be reused. + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + resp.Body.Close() + if resp.StatusCode == http.StatusForbidden { + blocked = append(blocked, e.method+" "+e.route) + continue + } + logrus.Debugf("startup self-check: %s %s reachable (HTTP %d)", e.method, e.route, resp.StatusCode) + } + return blocked, unverified +} + +// verifyRequiredEndpoints runs the startup endpoint self-check and logs the +// outcome. Non-fatal by design: a 403 usually means the API-only user's +// endpoint allowlist drifted behind the MCP toolset (or the token's role is +// below what a tool needs, e.g. observer vs run_live_query), and the +// remaining tools still work — so surface it loudly at deploy time and keep +// serving. Runs from a goroutine in main; it only logs and touches no shared +// state beyond the concurrency-safe FleetClient. +func verifyRequiredEndpoints(ctx context.Context, fc *FleetClient) { + blocked, unverified := verifyEndpointAccess(ctx, fc) + for _, b := range blocked { + logrus.Warnf("startup self-check: %s returned HTTP 403 — blocked by the API-only user's endpoint restrictions or the token's role; MCP tools that call it will fail", b) + } + if len(blocked) > 0 { + logrus.Warnf("startup self-check: %d of %d required Fleet API endpoints blocked for this token; update the API-only user's endpoint allowlist to match the \"Required Fleet API endpoints\" section of cmd/fleet-mcp/README.md", len(blocked), len(requiredEndpoints)) + } + if unverified > 0 { + logrus.Warnf("startup self-check: incomplete — %d of %d required Fleet API endpoints could not be probed (see warnings above); their allowlist status is unknown", unverified, len(requiredEndpoints)) + } + if len(blocked) == 0 && unverified == 0 { + logrus.Infof("startup self-check: all %d required Fleet API endpoints are reachable with the configured token", len(requiredEndpoints)) + } +} diff --git a/cmd/fleet-mcp/startup_check_test.go b/cmd/fleet-mcp/startup_check_test.go new file mode 100644 index 00000000000..27de619fe3d --- /dev/null +++ b/cmd/fleet-mcp/startup_check_test.go @@ -0,0 +1,193 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + "testing" +) + +func TestVerifyEndpointAccess_AllReachable(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 404 (nonexistent probe IDs) still proves the route is reachable. + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + fc := newTestClient(srv.URL) + blocked, unverified := verifyEndpointAccess(t.Context(), fc) + if len(blocked) != 0 { + t.Errorf("expected no blocked endpoints, got %v", blocked) + } + if unverified != 0 { + t.Errorf("expected 0 unverified endpoints, got %d", unverified) + } +} + +func TestVerifyEndpointAccess_CountsUnverifiable(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + srv.Close() // unreachable Fleet: every probe fails at the transport level + + fc := newTestClient(srv.URL) + blocked, unverified := verifyEndpointAccess(t.Context(), fc) + if len(blocked) != 0 { + t.Errorf("expected no blocked endpoints, got %v", blocked) + } + if unverified != len(requiredEndpoints) { + t.Errorf("expected %d unverified endpoints, got %d", len(requiredEndpoints), unverified) + } +} + +func TestVerifyEndpointAccess_FlagsBlockedEndpoints(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/api/v1/fleet/software/titles"), + r.Method == "POST" && r.URL.Path == "/api/v1/fleet/reports/run": + w.WriteHeader(http.StatusForbidden) + default: + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + } + })) + defer srv.Close() + + fc := newTestClient(srv.URL) + blocked, unverified := verifyEndpointAccess(t.Context(), fc) + if unverified != 0 { + t.Errorf("expected 0 unverified endpoints, got %d", unverified) + } + want := []string{ + "POST /api/v1/fleet/reports/run", + "GET /api/v1/fleet/software/titles", + "GET /api/v1/fleet/software/titles/:id", + } + slices.Sort(blocked) + slices.Sort(want) + if !slices.Equal(blocked, want) { + t.Errorf("blocked endpoints mismatch:\n got %v\nwant %v", blocked, want) + } +} + +func TestRequiredEndpoints_ProbesAreSideEffectFree(t *testing.T) { + t.Parallel() + // Every POST probe must carry a body that fails Fleet-side validation + // before anything is created or executed: no query SQL, no real + // host/query IDs in the path. + realIDRe := regexp.MustCompile(`^[1-9][0-9]*$`) + for _, e := range requiredEndpoints { + if e.method == "GET" { + continue + } + if e.body == nil { + t.Errorf("POST probe %s must send a JSON body", e.route) + } + path, _, _ := strings.Cut(e.probe, "?") + for _, seg := range strings.Split(path, "/") { + if realIDRe.MatchString(seg) { + t.Errorf("POST probe %s must not reference a potentially real ID (segment %q)", e.route, seg) + } + } + } +} + +// normalizeAPIPath reduces a Fleet API path to a comparable shape: the query +// string is dropped and any path segment that is a fmt verb (%d, %s), a +// :param placeholder, or a bare number is replaced with "*". +func normalizeAPIPath(path string) string { + numRe := regexp.MustCompile(`^[0-9]+$`) + path, _, _ = strings.Cut(path, "?") + segs := strings.Split(path, "/") + for i, s := range segs { + if strings.Contains(s, "%") || strings.HasPrefix(s, ":") || numRe.MatchString(s) { + segs[i] = "*" + } + } + return strings.Join(segs, "/") +} + +// TestRequiredEndpointsCoverSourcePaths fails when a Fleet API path used +// anywhere in this package is missing from requiredEndpoints — the drift +// this startup check exists to prevent. When it fails, add the new route to +// requiredEndpoints in startup_check.go AND to the "Required Fleet API +// endpoints" section of README.md. +func TestRequiredEndpointsCoverSourcePaths(t *testing.T) { + t.Parallel() + + // Paths not subject to (or deliberately kept out of) the allowlist: + // results/websocket is a raw handler that endpoint restrictions don't + // apply to; POST /reports is only called by the developer-only -seed + // mode, never during MCP serving. + exemptPaths := map[string]struct{}{ + "/api/v1/fleet/results/websocket": {}, + } + exemptMethodPaths := map[string]struct{}{ + "POST /api/v1/fleet/reports": {}, + } + + paramRe := regexp.MustCompile(`:[A-Za-z_]+`) + covered := make(map[string]struct{}, len(requiredEndpoints)) + coveredMethod := make(map[string]struct{}, len(requiredEndpoints)) + for _, e := range requiredEndpoints { + covered[normalizeAPIPath(e.route)] = struct{}{} + coveredMethod[e.method+" "+normalizeAPIPath(e.route)] = struct{}{} + // The probe must exercise the exact route it claims to verify. + probePath, _, _ := strings.Cut(e.probe, "?") + routePattern := regexp.MustCompile("^" + paramRe.ReplaceAllString(regexp.QuoteMeta(e.route), `[^/]+`) + "$") + if !routePattern.MatchString(probePath) { + t.Errorf("probe %q does not match route %s", e.probe, e.route) + } + } + + litRe := regexp.MustCompile(`"(/api/v1/fleet/[^"]*)"`) + // Method-aware variant for call sites where the method and path literal + // share a line, e.g. makeFleetRequest(ctx, "POST", "/api/v1/fleet/...", + // or with the path wrapped in fmt.Sprintf. + callRe := regexp.MustCompile(`makeFleetRequest\(ctx, "(GET|POST|PUT|PATCH|DELETE)",\s*(?:fmt\.Sprintf\()?"(/api/v1/fleet/[^"]*)"`) + files, err := filepath.Glob("*.go") + if err != nil { + t.Fatal(err) + } + for _, f := range files { + // startup_check.go defines the list itself; its probe literals are + // not additional API usage. + if strings.HasSuffix(f, "_test.go") || f == "startup_check.go" { + continue + } + src, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + for _, m := range litRe.FindAllStringSubmatch(string(src), -1) { + lit := m[1] + norm := normalizeAPIPath(lit) + if _, ok := exemptPaths[norm]; ok { + continue + } + if _, ok := covered[norm]; !ok { + t.Errorf("%s uses Fleet API path %q which is not listed in requiredEndpoints (startup_check.go); add it there and to the README endpoint list", f, lit) + } + } + for _, m := range callRe.FindAllStringSubmatch(string(src), -1) { + method, lit := m[1], m[2] + norm := normalizeAPIPath(lit) + if _, ok := exemptPaths[norm]; ok { + continue + } + if _, ok := exemptMethodPaths[method+" "+norm]; ok { + continue + } + if _, ok := coveredMethod[method+" "+norm]; !ok { + t.Errorf("%s calls %s %s which is not listed (with that method) in requiredEndpoints (startup_check.go); add it there and to the README endpoint list", f, method, lit) + } + } + } +}