diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md new file mode 100644 index 0000000..e74f617 --- /dev/null +++ b/.claude/skills/verify/SKILL.md @@ -0,0 +1,28 @@ +--- +name: verify +description: Build, launch, and drive gatekeeper locally to verify proxy changes end-to-end +--- + +# Verifying gatekeeper locally + +## Build & launch + +```bash +go build -o /tmp/gk/gatekeeper ./cmd/gatekeeper/ +cp examples/ca.crt examples/ca.key /tmp/gk/ # checked-in example CA, valid to 2027 +# config: proxy {host: 127.0.0.1, port: 19080}, tls {ca_cert, ca_key}, +# network {policy: permissive}, log {format: json} +/tmp/gk/gatekeeper --config config.yaml > gk.log 2>&1 & +``` + +## Drive + +- Health: `curl http://127.0.0.1:19080/healthz` → `{"status":"ok"}` +- CONNECT-intercepted HTTPS (the ~99.9% path): `curl --cacert ca.crt --proxy http://127.0.0.1:19080 https://api.github.com/zen` +- Canonical request log: JSON lines in gk.log with `msg":"request"` — fields `client_ip`, `http_host`, `proxy_type`, `http_status`. Grep `client_ip` to skip noise. + +## Gotchas + +- gk.log fills with OTLP export retries (`localhost:4318 connection refused`, one INFO line/sec) because cmd/gatekeeper registers OTel exporters unconditionally. Grep around it. +- To simulate a PROXY-protocol LB (GCP TCP Proxy), a ~30-line Python splice shim that prepends `PROXY TCP4 ...\r\n` before relaying to the gatekeeper port works with plain curl pointed at the shim. +- examples/gen-ca.sh can emit a CA that Go rejects (duplicate basicConstraints) on newer OpenSSL — prefer the checked-in examples/ca.crt. diff --git a/AGENTS.md b/AGENTS.md index 401f96d..258fe40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,11 +27,13 @@ proxy/ Core TLS-intercepting proxy engine mcp.go MCP relay handler (SSE streaming, tool credential injection) llmpolicy.go LLM response policy evaluation via Keep relay.go HTTP relay for non-CONNECT requests + stream.go Chunked, per-write-flushed response streaming (SSE and other incremental bodies) postgres.go Postgres data-plane listener (TLS termination, run-token auth, SCRAM upstream, message relay) otel.go OpenTelemetry handler wrapper, metrics instruments, span helpers server.go Proxy server lifecycle (start/stop/listen) gatekeeper.go Standalone server wiring (config → proxy + credential sources) +gatekeeper_tokenexchange.go Token-exchange credential resolver wiring (config → CredentialResolver, proxy-auth subject/actor extraction) config.go YAML config parsing (proxy, TLS, credentials, network, log) config_credential.go Credential source resolution (maps source config to backends) @@ -46,6 +48,8 @@ credentialsource/ Pluggable credential backends githubapp.go GitHub App installation token source tokenexchange.go RFC 8693 token exchange source neon.go Neon endpoint parsing + per-branch Postgres password resolver + httputil.go Shared bounded token-response reader (readTokenResponse) + jwt.go Shared RS256 JWT signing (signRS256JWT) cmd/gatekeeper/ CLI entry point (--config flag) @@ -81,7 +85,7 @@ OTel integration uses a callback-based architecture — the proxy core (`proxy/p - **`proxy.OTelHandler`** wraps the proxy as HTTP middleware, creating root spans and recording request duration/count metrics. Its `statusRecorder` implements `http.Hijacker` so CONNECT requests still work after hijack. - **Request/policy loggers** (set in `gatekeeper.go`) attach span events and record credential injection/policy denial metrics via exported functions `proxy.RecordCredentialInjection` and `proxy.RecordPolicyDenial`. - **slog bridge** — `gatekeeper.go` uses a `multiHandler` to fan out log records to both the configured slog handler and `otelslog.NewHandler`, correlating logs with trace context. -- **Provider setup** — `cmd/gatekeeper/main.go` creates OTLP HTTP exporters for traces, metrics, and logs, registering them as global providers. All configuration is via standard `OTEL_*` env vars (no YAML knobs). +- **Provider setup** — `cmd/gatekeeper/main.go` creates OTLP HTTP exporters for traces, metrics, and logs, registering them as global providers, unless the standard `OTEL_SDK_DISABLED=true` env var is set (case-insensitive), in which case no exporters or providers are created at all. A dedicated OTel error handler (`logOTelError`, `main.go:31-54`) logs SDK/export failures — e.g. no collector reachable — at DEBUG instead of letting them fall through to the OTel SDK's default handler, which routes through the standard `log` package and, once `slog.SetDefault` rewires it, would otherwise flood the canonical request log at INFO. All configuration is via standard `OTEL_*` env vars (no YAML knobs). ## Development Commands diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d96877..fb90e66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,34 +4,40 @@ Gatekeeper is a standalone credential-injecting TLS-intercepting proxy. It trans Gatekeeper is pre-1.0. The configuration schema and credential source interface may change between minor versions. +## v0.17.1 — 2026-07-15 + +### Fixed + +- **Getting-started docs no longer fail as written; reference docs catch up to shipped behavior** — a docs-correctness audit reproduced two getting-started failures live against a built binary. Quick start's `cd examples && ./gen-ca.sh` step left the working directory inside `examples/`, so the following step's `ca_cert: examples/ca.crt` resolved to the nonexistent `examples/examples/ca.crt`; the guide now writes `gatekeeper.yaml` with CA paths relative to `examples/`. The installation guide's "verify" step, `gatekeeper --config /dev/null`, was documented as exiting with a config error — an empty YAML file actually unmarshals to a valid zero-value config, so the binary started and blocked forever instead of exiting; the verify step now uses a command that actually exits. `examples/gen-ca.sh` itself is rewritten to use an explicit OpenSSL config file instead of `-addext`, since Homebrew OpenSSL 1.1.1 folded a duplicate `basicConstraints` extension onto the certificate (Go rejected the result with `x509: certificate contains duplicate extension with OID "2.5.29.19"`) and macOS LibreSSL 3.3.6 emitted EC parameters Go's `x509.ParseCertificate` rejected with `x509: invalid ECDSA parameters` — both toolchains now produce a certificate Go accepts. Reference and concept docs are corrected to match code: LLM-policy denials return HTTP 400, not 200; a credential's `grant: claude` gets tie-break treatment during header injection (auto-injection prefers a non-`claude` grant, placeholder selection prefers `claude`) rather than being purely cosmetic as documented; token-exchange cache TTL is hard-capped at 1 minute, not defaulted to 5 minutes when `expires_in` is absent; credential host matching strips the port unconditionally with no 80/443 default (that semantics belongs to network policy, a separate matcher, and the port-bearing example host in the old doc could not actually be configured as a credential key); and MCP SSE responses are streamed through a 4096-byte read loop with a flush after every chunk, not `io.Copy`. Newly documented: HTTP-scope Keep policy enforcement (403 with `X-Moat-Blocked: keep-policy`, distinct from network-policy's 407 denials), 401/403-triggered credential invalidation and its 10s per-key re-exchange cooldown, the `OTEL_SDK_DISABLED` env var, and daemon mode's token-embedded MCP relay path (`/mcp/{token}/{server-name}`) + ## v0.17.0 — 2026-07-14 ### Added -- **`network.proxy_protocol` recovers the real client IP behind a TCP load balancer** — when gatekeeper runs behind a TCP-terminating load balancer (e.g. GCP's global TCP Proxy LB), the load balancer terminates the client TCP connection and dials gatekeeper from its own front-end IP range (GCP: `35.191.0.0/16`), so the `client_ip` request-log attribute always showed the load balancer's hop, never the actual client, no matter how faithfully client addresses were captured elsewhere. When `network.proxy_protocol` is set to `true` (default `false`), the proxy listener is wrapped with [`github.com/pires/go-proxyproto`](https://github.com/pires/go-proxyproto) and parses a leading PROXY protocol v1/v2 header — as prepended by the load balancer — using its advertised source address as the accepted connection's remote address before `http.Server` (and therefore every request-logging path, including CONNECT-intercepted inner requests, which log from the outer tunnel-opening request's address) ever sees it. The connection policy is pinned to fail-open (`USE`, not go-proxyproto's spec-conformant `REQUIRE` default): a connection that opens with no PROXY header, or that doesn't send one within a 10s read timeout, falls back to the raw TCP peer address instead of being rejected, so load balancer health checks and direct probes of the port keep working exactly as before. Because the header is honored from any peer, a client that can reach the listener directly (bypassing the load balancer) can forge its own logged `client_ip` by prepending a PROXY header — enable this only when the port is reachable solely through the load balancer, and never use `client_ip` for security decisions. The postgres data-plane listener is unaffected; it is a separate listener and port. A connection that opens with something that looks like a PROXY header but fails to parse (a malformed v1 line, a truncated v2 binary header, and so on) is still dropped, but now logs a single DEBUG line naming the raw peer address and the parse error, rather than vanishing without a trace +- **`network.proxy_protocol` recovers the real client IP behind a TCP load balancer** — when gatekeeper runs behind a TCP-terminating load balancer (e.g. GCP's global TCP Proxy LB), the load balancer terminates the client TCP connection and dials gatekeeper from its own front-end IP range (GCP: `35.191.0.0/16`), so the `client_ip` request-log attribute always showed the load balancer's hop, never the actual client, no matter how faithfully client addresses were captured elsewhere. When `network.proxy_protocol` is set to `true` (default `false`), the proxy listener is wrapped with [`github.com/pires/go-proxyproto`](https://github.com/pires/go-proxyproto) and parses a leading PROXY protocol v1/v2 header — as prepended by the load balancer — using its advertised source address as the accepted connection's remote address before `http.Server` (and therefore every request-logging path, including CONNECT-intercepted inner requests, which log from the outer tunnel-opening request's address) ever sees it. The connection policy is pinned to fail-open (`USE`, not go-proxyproto's spec-conformant `REQUIRE` default): a connection that opens with no PROXY header, or that doesn't send one within a 10s read timeout, falls back to the raw TCP peer address instead of being rejected, so load balancer health checks and direct probes of the port keep working exactly as before. Because the header is honored from any peer, a client that can reach the listener directly (bypassing the load balancer) can forge its own logged `client_ip` by prepending a PROXY header — enable this only when the port is reachable solely through the load balancer, and never use `client_ip` for security decisions. The postgres data-plane listener is unaffected; it is a separate listener and port. A connection that opens with something that looks like a PROXY header but fails to parse (a malformed v1 line, a truncated v2 binary header, and so on) is still dropped, but now logs a single DEBUG line naming the raw peer address and the parse error, rather than vanishing without a trace ([#46](https://github.com/majorcontext/gatekeeper/pull/46)) ### Fixed -- **OTel export failures no longer drown the request log when no collector is reachable** — `cmd/gatekeeper` unconditionally created OTLP HTTP exporters and registered them as global providers, and never installed an OTel error handler. The OTel SDK's default handler routes SDK/export errors through the standard library `log` package, which gatekeeper's `slog.SetDefault` call rewires to the configured slog handler at INFO level (documented `log/slog` behavior) — so every failed export attempt logged an INFO line, once per batch interval, with no backoff: `{"level":"INFO","msg":"Post \"https://localhost:4318/v1/logs\": dial tcp [::1]:4318: connect: connection refused"}`. Two changes: the standard `OTEL_SDK_DISABLED=true` env var now suppresses OTel entirely (no exporters, no providers, no OTel-related log lines), and a dedicated error handler now logs export/SDK failures at DEBUG instead of the SDK default, so they're observable with debug logging on but no longer flood the default `info` level. Default behavior (no env vars set) is unchanged — exporters still target the standard OTLP endpoint and traces/metrics/logs still flow when a collector is present +- **OTel export failures no longer drown the request log when no collector is reachable** — `cmd/gatekeeper` unconditionally created OTLP HTTP exporters and registered them as global providers, and never installed an OTel error handler. The OTel SDK's default handler routes SDK/export errors through the standard library `log` package, which gatekeeper's `slog.SetDefault` call rewires to the configured slog handler at INFO level (documented `log/slog` behavior) — so every failed export attempt logged an INFO line, once per batch interval, with no backoff: `{"level":"INFO","msg":"Post \"https://localhost:4318/v1/logs\": dial tcp [::1]:4318: connect: connection refused"}`. Two changes: the standard `OTEL_SDK_DISABLED=true` env var now suppresses OTel entirely (no exporters, no providers, no OTel-related log lines), and a dedicated error handler now logs export/SDK failures at DEBUG instead of the SDK default, so they're observable with debug logging on but no longer flood the default `info` level. Default behavior (no env vars set) is unchanged — exporters still target the standard OTLP endpoint and traces/metrics/logs still flow when a collector is present ([#47](https://github.com/majorcontext/gatekeeper/pull/47)) ## v0.16.0 — 2026-07-13 ### Added -- **Client addresses are now captured on every request path** — gatekeeper runs as a standalone service that remote clients connect to over the network, so a proxy-auth token or run ID alone doesn't tell an operator which network peer actually sent a request; nothing in the audit trail recorded it. `RequestLogData` gains a `ClientAddr` field (the listener-observed `ip:port`), populated on plain HTTP requests (`r.RemoteAddr`), intercepted CONNECT traffic (attributed to the client that opened the tunnel — not the hijacked, TLS-terminated connection carrying the individual inner requests, which has no meaningful remote address of its own), the `/relay/{name}` and `/mcp/{server}` relay paths (`r.RemoteAddr`; both previously emitted no canonical log line at all — the "relay" and "mcp" `RequestType` values existed only in the field's doc comment, unused, until this change wired up minimal completion logging for them), and postgres data-plane connections (the client `net.Conn`'s `RemoteAddr()`, since libpq speaks no HTTP). The canonical log line emits a corresponding `client_ip` attribute — the host portion of `ClientAddr` split via `net.SplitHostPort`, falling back to the raw value if it carries no port — following the same non-empty-gated style as `run_id` and `user_id` -- **The new relay/MCP canonical log lines now carry precise stream semantics and complete policy coverage** — `Err` on a streamed `/relay/{name}` or `/mcp/{server}` log line means exactly one thing: the upstream failed while the client was still connected; a client canceling mid-stream no longer escalates the line to ERROR, and `ResponseSize` is the actual bytes delivered to the client rather than the upstream `Content-Length` (which is `-1` for a stream). An unknown-relay-name or unknown-MCP-server 404 is a client/config mistake, not a failure, and logs at WARN purely from its status code. `handleMCPRelay`'s Keep-policy block — invalid-JSON fail-closed denial, evaluation errors, policy-deny, and redaction failures — previously only recorded a policy-log entry; every exit there now also emits a canonical request log line (`Denied`, `DenyReason`) so MCP tool-call denials show up in the same audit trail as every other request. Finally, streamed responses proxied through the OTel-wrapped standalone server now actually flush per chunk: `statusRecorder` didn't implement `http.Flusher`, so the relay and MCP streaming loops' `w.(http.Flusher)` check silently failed in production and SSE responses sat buffered in `net/http` until the stream ended +- **Client addresses are now captured on every request path** — gatekeeper runs as a standalone service that remote clients connect to over the network, so a proxy-auth token or run ID alone doesn't tell an operator which network peer actually sent a request; nothing in the audit trail recorded it. `RequestLogData` gains a `ClientAddr` field (the listener-observed `ip:port`), populated on plain HTTP requests (`r.RemoteAddr`), intercepted CONNECT traffic (attributed to the client that opened the tunnel — not the hijacked, TLS-terminated connection carrying the individual inner requests, which has no meaningful remote address of its own), the `/relay/{name}` and `/mcp/{server}` relay paths (`r.RemoteAddr`; both previously emitted no canonical log line at all — the "relay" and "mcp" `RequestType` values existed only in the field's doc comment, unused, until this change wired up minimal completion logging for them), and postgres data-plane connections (the client `net.Conn`'s `RemoteAddr()`, since libpq speaks no HTTP). The canonical log line emits a corresponding `client_ip` attribute — the host portion of `ClientAddr` split via `net.SplitHostPort`, falling back to the raw value if it carries no port — following the same non-empty-gated style as `run_id` and `user_id` ([#45](https://github.com/majorcontext/gatekeeper/pull/45)) +- **The new relay/MCP canonical log lines now carry precise stream semantics and complete policy coverage** — `Err` on a streamed `/relay/{name}` or `/mcp/{server}` log line means exactly one thing: the upstream failed while the client was still connected; a client canceling mid-stream no longer escalates the line to ERROR, and `ResponseSize` is the actual bytes delivered to the client rather than the upstream `Content-Length` (which is `-1` for a stream). An unknown-relay-name or unknown-MCP-server 404 is a client/config mistake, not a failure, and logs at WARN purely from its status code. `handleMCPRelay`'s Keep-policy block — invalid-JSON fail-closed denial, evaluation errors, policy-deny, and redaction failures — previously only recorded a policy-log entry; every exit there now also emits a canonical request log line (`Denied`, `DenyReason`) so MCP tool-call denials show up in the same audit trail as every other request. Finally, streamed responses proxied through the OTel-wrapped standalone server now actually flush per chunk: `statusRecorder` didn't implement `http.Flusher`, so the relay and MCP streaming loops' `w.(http.Flusher)` check silently failed in production and SSE responses sat buffered in `net/http` until the stream ended ([#45](https://github.com/majorcontext/gatekeeper/pull/45)) ## v0.15.3 — 2026-07-13 ### Fixed -- **Post-release fixes to the v0.15.2 host-key lookup** — a review of the shipped lookup surfaced four defects, all fixed here. *Request logs no longer capture resolver-consumed subject headers*: v0.15.2's plain-HTTP handler snapshotted request headers for logging before the credential resolver ran, so a subject-identity token the resolver removes (e.g. a token-exchange subject header) was written verbatim to request logs; the forwarded-request snapshot now happens after resolution, and policy-denial and resolution-failure logs — which by design run before any resolver — redact the headers a matching resolver declared at registration. *Outranked resolvers with declared side effects no longer stall requests*: when a better-matched static credential decides a request, v0.15.2 still ran the wildcard-matched resolver synchronously and discarded its result — an unrelated IdP outage added a full STS timeout to every request on the static-credential host. Resolvers registered through the new `SetCredentialResolverWithStripHeaders` declare the request headers they remove; when outranked, the proxy skips such a resolver and strips the declared headers itself (the built-in token-exchange wiring declares its `subject_header`). Resolvers registered through the unchanged `SetCredentialResolver` API keep the v0.15.2 behavior — they still run when outranked, with credentials discarded and errors non-fatal — because the proxy cannot know what request-mutating side effects an undeclared resolver has, and silently skipping one could leak the very headers it strips. *Port-pinned keys fire regardless of how the client or config spells the target*: the plain-HTTP path passed the request host with a port only when the client literally included one, and the relay path passed the target URL's host verbatim, so a `:80`/`:443`-pinned key matched only when the port was written out; both paths now make the scheme-default port explicit before lookup, and a bracketed portless IPv6 lookup host (`[::1]`) matches keys stored in canonical form (`::1`). *In-map and cross-map ranking share one specificity comparator*: verbatim beats case-fold and a port-pinned key beats a port-less one in every tier, cross-map included, so the two selection paths cannot disagree (port-pinned keys remain expressible only via embedder-built `RunContextData` maps and direct map construction — the credential setters reject `:` in hosts) +- **Post-release fixes to the v0.15.2 host-key lookup** — a review of the shipped lookup surfaced four defects, all fixed here. *Request logs no longer capture resolver-consumed subject headers*: v0.15.2's plain-HTTP handler snapshotted request headers for logging before the credential resolver ran, so a subject-identity token the resolver removes (e.g. a token-exchange subject header) was written verbatim to request logs; the forwarded-request snapshot now happens after resolution, and policy-denial and resolution-failure logs — which by design run before any resolver — redact the headers a matching resolver declared at registration. *Outranked resolvers with declared side effects no longer stall requests*: when a better-matched static credential decides a request, v0.15.2 still ran the wildcard-matched resolver synchronously and discarded its result — an unrelated IdP outage added a full STS timeout to every request on the static-credential host. Resolvers registered through the new `SetCredentialResolverWithStripHeaders` declare the request headers they remove; when outranked, the proxy skips such a resolver and strips the declared headers itself (the built-in token-exchange wiring declares its `subject_header`). Resolvers registered through the unchanged `SetCredentialResolver` API keep the v0.15.2 behavior — they still run when outranked, with credentials discarded and errors non-fatal — because the proxy cannot know what request-mutating side effects an undeclared resolver has, and silently skipping one could leak the very headers it strips. *Port-pinned keys fire regardless of how the client or config spells the target*: the plain-HTTP path passed the request host with a port only when the client literally included one, and the relay path passed the target URL's host verbatim, so a `:80`/`:443`-pinned key matched only when the port was written out; both paths now make the scheme-default port explicit before lookup, and a bracketed portless IPv6 lookup host (`[::1]`) matches keys stored in canonical form (`::1`). *In-map and cross-map ranking share one specificity comparator*: verbatim beats case-fold and a port-pinned key beats a port-less one in every tier, cross-map included, so the two selection paths cannot disagree (port-pinned keys remain expressible only via embedder-built `RunContextData` maps and direct map construction — the credential setters reject `:` in hosts) ([#44](https://github.com/majorcontext/gatekeeper/pull/44)) ## v0.15.2 — 2026-07-13 ### Fixed -- **Wildcard credential hosts now actually match subdomains** — every host-keyed lookup resolved the request host by exact string key (with only a host:port-stripping fallback), so an entry whose `host` was a wildcard like `*.box.example.com` only ever matched a request whose host was the literal string `*.box.example.com` — it never fired for a real subdomain such as `alpha.box.example.com`. The failure was silent: registration succeeded, wildcard semantics are documented for network allow patterns (which use a separate matcher), and an operator configuring a wildcard credential host simply got no injection and no error. All host-keyed lookups now share one matcher: after the exact-key lookups miss, wildcard keys are matched against the port-stripped request host using the same suffix rule as allow patterns — `*.example.com` matches any subdomain at any depth (`api.example.com`, `a.b.example.com`) but never the apex `example.com` itself. This covers static credentials, dynamic credential resolvers, per-run `RunContextData` credentials (the moat daemon path), extra headers, remove-headers, token substitutions, and response transformers, so a wildcard host key behaves consistently across every feature that accepts one. Precedence is deterministic: an exact key beats a wildcard key, and between wildcards a longer domain part wins (a port suffix does not count toward specificity), then a port-pinned key beats a port-less one — including across maps, so a resolver registered under `*.example.com` shadows neither a static credential configured for `api.example.com` specifically nor one under `*.api.example.com`; a resolver outranked this way still runs for its request-mutating side effects (e.g. stripping subject-identity headers), its result and any error simply do not decide the injected credential. Exact matching is case-insensitive for both port-less and port-bearing keys (a mixed-case request host cannot skip its exact entry and fall through to a wildcard); within the case-insensitive tier a key matching the full `host:port` beats one matching only the bare host — the same order as the case-sensitive lookups, so request-host casing cannot flip which credential is sent — and any remaining tie between case-variant keys goes to the lexicographically smallest key rather than depending on map iteration order. Empty entries keep their historical, per-map semantics: an empty credentials entry falls through to the next tier (`len>0` gating, as before this release), while an explicit empty or nil entry in the companion maps (extra headers, remove-headers, token substitutions, response transformers) — and a nil entry planted by `SetCredentialResolver(host, nil)` — still matches and terminates the lookup, so opt-outs for a specific `host:port` keep suppressing broader keys, including wildcards. Within the case-insensitive tier and across maps, a verbatim-cased key outranks a case-fold match, so a resolver registered under a case-variant of a host cannot shadow a static credential registered under the host's exact casing. Port-pinned keys (`*.internal.example.com:8443`; the credential setters reject `:` in hosts, but such keys arise via embedder-built `RunContextData` maps and the substitution/remove-header setters, which do not validate hosts) match subdomains on exactly that port, and the CONNECT-interception and plain-HTTP paths now pass the port-bearing request target to every host-keyed lookup so those keys actually fire there. Plain-HTTP requests are also checked against network policy *before* credential resolution — matching the interception path — so a policy-denied client can no longer trigger a resolver's external side effects (e.g. token-exchange round trips against an IdP) for fabricated wildcard-matched hosts, and receives the policy denial rather than a resolver error. Motivating case: injecting a Cloudflare Access service token across dynamically-created per-host subdomains under a `*.box.` wildcard +- **Wildcard credential hosts now actually match subdomains** — every host-keyed lookup resolved the request host by exact string key (with only a host:port-stripping fallback), so an entry whose `host` was a wildcard like `*.box.example.com` only ever matched a request whose host was the literal string `*.box.example.com` — it never fired for a real subdomain such as `alpha.box.example.com`. The failure was silent: registration succeeded, wildcard semantics are documented for network allow patterns (which use a separate matcher), and an operator configuring a wildcard credential host simply got no injection and no error. All host-keyed lookups now share one matcher: after the exact-key lookups miss, wildcard keys are matched against the port-stripped request host using the same suffix rule as allow patterns — `*.example.com` matches any subdomain at any depth (`api.example.com`, `a.b.example.com`) but never the apex `example.com` itself. This covers static credentials, dynamic credential resolvers, per-run `RunContextData` credentials (the moat daemon path), extra headers, remove-headers, token substitutions, and response transformers, so a wildcard host key behaves consistently across every feature that accepts one. Precedence is deterministic: an exact key beats a wildcard key, and between wildcards a longer domain part wins (a port suffix does not count toward specificity), then a port-pinned key beats a port-less one — including across maps, so a resolver registered under `*.example.com` shadows neither a static credential configured for `api.example.com` specifically nor one under `*.api.example.com`; a resolver outranked this way still runs for its request-mutating side effects (e.g. stripping subject-identity headers), its result and any error simply do not decide the injected credential. Exact matching is case-insensitive for both port-less and port-bearing keys (a mixed-case request host cannot skip its exact entry and fall through to a wildcard); within the case-insensitive tier a key matching the full `host:port` beats one matching only the bare host — the same order as the case-sensitive lookups, so request-host casing cannot flip which credential is sent — and any remaining tie between case-variant keys goes to the lexicographically smallest key rather than depending on map iteration order. Empty entries keep their historical, per-map semantics: an empty credentials entry falls through to the next tier (`len>0` gating, as before this release), while an explicit empty or nil entry in the companion maps (extra headers, remove-headers, token substitutions, response transformers) — and a nil entry planted by `SetCredentialResolver(host, nil)` — still matches and terminates the lookup, so opt-outs for a specific `host:port` keep suppressing broader keys, including wildcards. Within the case-insensitive tier and across maps, a verbatim-cased key outranks a case-fold match, so a resolver registered under a case-variant of a host cannot shadow a static credential registered under the host's exact casing. Port-pinned keys (`*.internal.example.com:8443`; the credential setters reject `:` in hosts, but such keys arise via embedder-built `RunContextData` maps and the substitution/remove-header setters, which do not validate hosts) match subdomains on exactly that port, and the CONNECT-interception and plain-HTTP paths now pass the port-bearing request target to every host-keyed lookup so those keys actually fire there. Plain-HTTP requests are also checked against network policy *before* credential resolution — matching the interception path — so a policy-denied client can no longer trigger a resolver's external side effects (e.g. token-exchange round trips against an IdP) for fabricated wildcard-matched hosts, and receives the policy denial rather than a resolver error. Motivating case: injecting a Cloudflare Access service token across dynamically-created per-host subdomains under a `*.box.` wildcard ([#43](https://github.com/majorcontext/gatekeeper/pull/43)) ## v0.15.1 — 2026-07-09 diff --git a/README.md b/README.md index 16c06d2..58e936f 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ A credential-injecting TLS-intercepting proxy. Route HTTPS traffic through Gatekeeper and it transparently injects authentication headers based on hostname matching. Clients never see raw credentials. +Full documentation: [docs/README.md](docs/README.md). + ```bash # Start the proxy gatekeeper --config gatekeeper.yaml @@ -22,6 +24,15 @@ go install github.com/majorcontext/gatekeeper/cmd/gatekeeper@latest **Requires:** Go 1.25+ +Or pull the published Docker image: + +```bash +docker pull ghcr.io/majorcontext/gatekeeper:latest + +docker run --rm -v ./gatekeeper.yaml:/etc/gatekeeper/gatekeeper.yaml \ + ghcr.io/majorcontext/gatekeeper --config /etc/gatekeeper/gatekeeper.yaml +``` + ## How it works 1. Client sends `CONNECT host:443` through the proxy @@ -131,6 +142,30 @@ Policies: `permissive` (allow all), `strict` (deny all, allow listed). Behind a TCP-terminating load balancer (e.g. GCP's global TCP Proxy LB), every connection's peer address is the load balancer's, not the real client — `network.proxy_protocol: true` parses a PROXY protocol v1/v2 header from the LB and uses its advertised source as the `client_ip` recorded in request logs. It's fail-open (headerless connections, like LB health checks, fall back to the raw peer address) and should only be enabled when the port is reachable solely through the load balancer, since the header is otherwise forgeable by any direct client. +## MCP relay + +For MCP clients that can't route through `HTTP_PROXY`, Gatekeeper relays Model Context Protocol requests directly, injecting credentials and streaming SSE responses: + +``` +POST http://127.0.0.1:9080/mcp/context7/v1/endpoint +``` + +Gatekeeper resolves the credential configured for the `context7` MCP server and injects it before forwarding to the real server. MCP servers are registered via `MCPServerConfig` in the Go library — not exposed in `gatekeeper.yaml` — which is how [Moat](https://github.com/majorcontext/moat)'s daemon layer wires it up per run. + +## LLM policy + +Gatekeeper can evaluate Anthropic API responses against [Keep](https://github.com/majorcontext/keep) policy rules before they reach the client, blocking a response that violates a rule instead of forwarding it. Like MCP relay, this is configured via `RunContextData.KeepEngines` in the Go library, not `gatekeeper.yaml` — it's primarily used through Moat's daemon layer. + +## Host gateway + +A synthetic hostname used inside a sandboxed container can be mapped to the real host machine's IP, so containers reach host services without relying on `host.docker.internal`: + +``` +container ──▶ moat-host-gateway:8080 ──▶ gatekeeper ──▶ {HostGatewayIP}:8080 +``` + +Set via `RunContextData.HostGateway`/`HostGatewayIP` in the Go library. Destination ports must be explicitly listed in `AllowedHostPorts` — traffic to unlisted ports is denied. + ## Postgres data plane Gatekeeper can run a second listener that speaks the Postgres wire protocol, letting a sandboxed client connect to arbitrary Neon databases without any database secret ever entering the sandbox. The only credential the client holds is a run-scoped token, which is useless outside Gatekeeper. diff --git a/docs/README.md b/docs/README.md index a23dca0..44826da 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,6 +27,7 @@ - [GCP Secret Manager](./content/guides/04-gcp-secret-manager.md) — Fetch credentials from Google Cloud Secret Manager - [GitHub App Tokens](./content/guides/05-github-app-tokens.md) — Auto-refreshing short-lived GitHub installation tokens - [Token Exchange](./content/guides/06-token-exchange.md) — Per-user credential resolution via RFC 8693 + - [Token Exchange Endpoint](./token-exchange-endpoint.md) — STS-implementer contract: wire format, auth, and caching semantics for the endpoint gatekeeper calls - [Network Lockdown](./content/guides/07-network-lockdown.md) — Restrict proxy traffic to specific hosts - [OpenTelemetry](./content/guides/08-opentelemetry.md) — Distributed tracing, metrics, and logs - [Go Library](./content/guides/09-go-library.md) — Embed the proxy engine in a custom Go application @@ -48,6 +49,7 @@ docs/ README.md # This file STYLE-GUIDE.md # Writing guidelines + token-exchange-endpoint.md # STS-implementer contract for the token-exchange credential source content/ # User-facing documentation getting-started/ concepts/ diff --git a/docs/content/concepts/01-tls-interception.md b/docs/content/concepts/01-tls-interception.md index 10c37e4..c912c3d 100644 --- a/docs/content/concepts/01-tls-interception.md +++ b/docs/content/concepts/01-tls-interception.md @@ -60,6 +60,12 @@ When gatekeeper intercepts a CONNECT tunnel for a host, `CA.GenerateCert` create The CA supports RSA, EC, and Ed25519 private keys via PKCS1, PKCS8, and SEC 1 formats. +## ALPN Negotiation + +The client-facing TLS handshake advertises `h2` before `http/1.1` in its ALPN `NextProtos` list, so gatekeeper prefers HTTP/2 with clients that support it and falls back to HTTP/1.1 otherwise. The negotiated protocol determines which transport gatekeeper uses to reach the upstream server: when the client negotiated `h2`, gatekeeper forwards over an `http2.Transport`, since an h2 request cannot be round-tripped through an HTTP/1.1 transport without framing errors; otherwise it forwards over a standard `http.Transport`. This matters for gRPC clients, which require h2 end-to-end — gatekeeper's `http2.Transport` never falls back to HTTP/1.1, so if the upstream only speaks HTTP/1.1, a connection from an h2 client to that upstream fails. + +WebSocket upgrades follow a related but separate path through TLS interception — see [WebSockets](../guides/10-websockets.md). + ## Why the Client Must Trust the CA The dynamically generated certificates are not signed by a public CA. Clients reject them unless they explicitly trust gatekeeper's CA certificate. In container environments, the CA certificate is mounted into the container's trust store (e.g., `/etc/ssl/certs/`). Without this, every HTTPS request through the proxy fails with a certificate verification error. diff --git a/docs/content/concepts/02-credential-injection.md b/docs/content/concepts/02-credential-injection.md index 1a46a05..f38d063 100644 --- a/docs/content/concepts/02-credential-injection.md +++ b/docs/content/concepts/02-credential-injection.md @@ -10,21 +10,16 @@ Gatekeeper injects authentication headers into proxied HTTP requests based on ho ## Host Matching -Each credential is configured with a `host` pattern. When gatekeeper intercepts a request, it matches the target hostname against configured patterns to determine which credentials to inject. +Each credential is configured with a `host` pattern. When gatekeeper intercepts a request, it looks up credentials for the target hostname, stripping any port from the request host unconditionally before comparing — there is no notion of a default or matched port in credential lookup. Matching rules: | Pattern | Matches | Does Not Match | |---|---|---| -| `api.github.com` | `api.github.com` | `github.com`, `foo.api.github.com` | +| `api.github.com` | `api.github.com`, `api.github.com:443` (port stripped before comparison) | `github.com`, `foo.api.github.com` | | `*.github.com` | `api.github.com`, `foo.bar.github.com` | `github.com` | -| `api.example.com:8080` | `api.example.com:8080` | `api.example.com:443` | -Port handling: - -- Patterns without an explicit port match only ports 80 and 443 (the standard HTTP/HTTPS ports). -- Patterns with an explicit port match that port exactly. -- Port numbers are stripped from the request host before hostname comparison — `api.github.com:443` matches a pattern for `api.github.com`. +A `host` pattern cannot contain a port — `credentials[].host` is validated and any value containing `:` is rejected (silently dropped, logged at debug level), so a pattern like `api.example.com:8080` can never be configured. This differs from network policy's `allow` patterns, which do support an explicit port and default unported patterns to matching only ports 80 and 443 — that port-aware matching is specific to network policy and does not apply to credential host matching. See [Network Policy](./04-network-policy.md). Host comparison is case-insensitive. `API.GitHub.com` matches `api.github.com`. diff --git a/docs/content/concepts/04-network-policy.md b/docs/content/concepts/04-network-policy.md index f98f653..080e81f 100644 --- a/docs/content/concepts/04-network-policy.md +++ b/docs/content/concepts/04-network-policy.md @@ -69,6 +69,22 @@ When gatekeeper has path-level rules (configured via `RequestChecker`), it evalu When a request targets a host gateway address (synthetic hostname or loopback), gatekeeper applies a separate check: the destination port must be in the run's `AllowedHostPorts` list. This prevents containers from reaching arbitrary services on the host machine. See [Host Gateway](./07-host-gateway.md) for details. +## HTTP-Scope Keep Policy + +On intercepted requests, a second, distinct policy layer runs after network policy allows the request: if the run's `RunContextData.KeepEngines` has an entry keyed `"http"`, gatekeeper evaluates that Keep engine against the request before forwarding it. Unlike network policy, this check can inspect the request body — the engine's rules are evaluated against a parsed HTTP call (method, host, headers, and body), not just the host. + +This layer is engine-driven: the `"http"` engine comes from an embedder's `RunContextData` (moat's daemon layer compiles Keep rule files into engines and attaches them per run), not from `gatekeeper.yaml`. Standalone gatekeeper has no YAML knob for it. + +Body inspection fails closed — if the body can't be parsed or evaluation errors, the request is denied rather than passed through. A denial returns `403 Forbidden` with `X-Moat-Blocked: keep-policy` (distinct from network policy's `407`/`request-rule` denials) and a plaintext body describing the host and, for body-inspection failures, that the body couldn't be inspected. + ## Blocked Response Format -Blocked requests receive a `407 Proxy Authentication Required` response with a `Proxy-Authenticate: Moat-Policy` header and a plaintext body explaining which host was denied. The `X-Moat-Blocked` header indicates the denial reason (`request-rule` for network policy, `host-service` for host gateway blocks). +Blocked requests receive one of two denial styles depending on which layer denied them: + +| Layer | Status | `X-Moat-Blocked` | +|---|---|---| +| Network policy (host/path rules) | `407 Proxy Authentication Required` with `Proxy-Authenticate: Moat-Policy` | `request-rule` | +| Host gateway | `407 Proxy Authentication Required` with `Proxy-Authenticate: Moat-Policy` | `host-service` | +| HTTP-scope Keep policy | `403 Forbidden` | `keep-policy` | + +Each response carries a plaintext body explaining which host was denied. diff --git a/docs/content/concepts/05-mcp-relay.md b/docs/content/concepts/05-mcp-relay.md index 2de62be..bad9f46 100644 --- a/docs/content/concepts/05-mcp-relay.md +++ b/docs/content/concepts/05-mcp-relay.md @@ -36,12 +36,13 @@ A relay request follows this path: A request to `/mcp/context7/v1/endpoint` forwards to `https://mcp.context7.com/mcp/v1/endpoint` with the `Authorization` header set to the resolved credential. -## SSE Streaming +## Daemon-Mode Token-Embedded Path + +The relay path above (`/mcp/{server-name}`) relies on `Proxy-Authorization` to resolve run context, which requires the request to go through the proxy mechanism. When gatekeeper runs with a `ContextResolver` (daemon mode — moat's per-run registration, not standalone `gatekeeper.yaml` mode) and a request arrives directly rather than proxied, gatekeeper also serves `/mcp/{token}/{server-name}[/path]`: the run's proxy auth token is embedded in the URL itself, since a direct request carries no `Proxy-Authorization` header. Gatekeeper extracts the token, resolves it to run context, strips the token from the path, and dispatches to the same relay handling described above. This form only exists in daemon mode — standalone gatekeeper has no `ContextResolver` and does not serve it. -MCP uses Server-Sent Events (SSE) for streaming responses. Gatekeeper supports this by flushing response data incrementally: +## SSE Streaming -- After writing response headers, gatekeeper calls `Flush()` on the `http.ResponseWriter` if it implements `http.Flusher`. -- The response body is streamed with `io.Copy`, delivering events to the client as they arrive from the upstream server. +MCP uses Server-Sent Events (SSE) for streaming responses. Gatekeeper supports this with a per-chunk flush loop (`streamResponseBody`), not `io.Copy`'s buffered copy: it reads the upstream body in 4096-byte chunks and, after writing each chunk to the client, calls `Flush()` on the `http.ResponseWriter` if it implements `http.Flusher` — so events reach the client as they arrive rather than waiting for a larger buffer to fill. The relay HTTP client has no client-level timeout — MCP SSE streams are long-lived connections that may remain open indefinitely. diff --git a/docs/content/concepts/06-observability.md b/docs/content/concepts/06-observability.md index a315db5..92a1063 100644 --- a/docs/content/concepts/06-observability.md +++ b/docs/content/concepts/06-observability.md @@ -37,6 +37,8 @@ For each request, the handler: The `statusRecorder` implements `http.Hijacker` by delegating to the underlying writer. This is critical — CONNECT requests call `Hijack()` to take over the raw connection, and the OTel wrapper must not break this. +`OTelHandler` wraps only the HTTP proxy listener. The Postgres data-plane listener (see [Postgres Data Plane](./08-postgres-data-plane.md)) is a separate `net.Listener` that this middleware never sees, so it produces no `proxy.request`-family spans or `proxy.request.duration`/`proxy.request.count` metrics — `postgres` connections are observable only through the canonical log line below. + ## Canonical Log Lines Gatekeeper emits one wide structured log entry per request at completion. Each log line contains all request context in a single record: @@ -49,12 +51,17 @@ Gatekeeper emits one wide structured log entry per request at completion. Each l | `http_path` | Request path | | `http_status` | Response status code | | `duration_ms` | Request duration in milliseconds | -| `proxy_type` | Request classification (`http`, `connect`, `mcp`, `relay`) | +| `proxy_type` | Request classification (`http`, `connect`, `mcp`, `relay`, `postgres`) | | `credential_injected` | Whether any credential was injected | | `injected_headers` | Comma-separated list of injected header names | | `grants` | Comma-separated list of grant names used | | `denied` | Whether the request was denied by policy | | `deny_reason` | Denial reason (e.g., `Host not in allow list: example.com`) | +| `request_size` | Content-Length of the request body, when known. Omitted (not zero) when unknown — always omitted for postgres connections, which report size in `request_messages` instead. | +| `response_size` | Bytes delivered to the client (streaming paths) or response Content-Length, when known. Omitted when unknown — always omitted for postgres connections. | +| `request_messages` | Postgres protocol messages relayed client→upstream. Present only for postgres connections. | +| `response_messages` | Postgres protocol messages relayed upstream→client. Present only for postgres connections. | +| `error` | Error message, when the request ended in an error | | `run_id` | Per-run identifier (daemon mode) | | `user_id` | User ID from proxy auth username | diff --git a/docs/content/getting-started/01-introduction.md b/docs/content/getting-started/01-introduction.md index b411bd0..4f9bd53 100644 --- a/docs/content/getting-started/01-introduction.md +++ b/docs/content/getting-started/01-introduction.md @@ -14,7 +14,10 @@ Gatekeeper is a standalone credential-injecting TLS-intercepting proxy. It sits - **TLS interception** — Man-in-the-middle proxy with per-host certificate generation from a configured CA. The proxy terminates TLS, reads plaintext requests, injects credentials, and forwards to the real server. - **Multiple credential sources** — Pluggable backend system. Environment variables, static values, and host commands (`process`) for development and local helpers. AWS Secrets Manager, GCP Secret Manager, and GitHub App tokens for production. RFC 8693 token exchange for multi-user deployments. - **Network policy** — Allow or deny traffic by host pattern. `permissive` mode allows all traffic. `strict` mode denies all traffic except explicitly allowed hosts. +- **Postgres data plane** — A second listener that speaks the Postgres wire protocol, authenticating clients with a run token and resolving the real upstream password (a static value or a per-branch Neon password) so the database secret never reaches the client. - **MCP relay** — Forward Model Context Protocol requests to upstream servers with credential injection and SSE streaming. +- **LLM policy** — Evaluate Anthropic API responses against Keep policy rules, blocking denied responses before they reach the client. +- **Host gateway** — Map a synthetic hostname used inside containers to the host machine's IP address, so containers can reach host services through the same credential injection and network policy as any other host. - **Observability** — OpenTelemetry traces, metrics, and logs. Canonical log lines per request. Configured entirely via standard `OTEL_*` environment variables. ## How it works diff --git a/docs/content/getting-started/02-installation.md b/docs/content/getting-started/02-installation.md index 339f949..9df5540 100644 --- a/docs/content/getting-started/02-installation.md +++ b/docs/content/getting-started/02-installation.md @@ -48,7 +48,13 @@ docker run --rm -v ./gatekeeper.yaml:/etc/gatekeeper/gatekeeper.yaml \ ## Verify ```bash -gatekeeper --config /dev/null +gatekeeper ``` -The binary starts and exits with a config error, confirming it is installed correctly. +Gatekeeper requires a config file and refuses to start without one. With no `--config` flag and no `GATEKEEPER_CONFIG` environment variable set, the binary exits immediately: + +```text +error: --config or GATEKEEPER_CONFIG required +``` + +An exit status of `1` with this message confirms the binary is installed and runs. diff --git a/docs/content/getting-started/03-quick-start.md b/docs/content/getting-started/03-quick-start.md index 0d9d108..c2b5427 100644 --- a/docs/content/getting-started/03-quick-start.md +++ b/docs/content/getting-started/03-quick-start.md @@ -16,13 +16,13 @@ Start a credential-injecting proxy in under five minutes. ## Step 1: Generate a CA certificate -The proxy needs a CA to sign per-host TLS certificates. Use the included script: +The proxy needs a CA to sign per-host TLS certificates. From the repository root, run the included script: ```bash -cd examples && ./gen-ca.sh +./examples/gen-ca.sh ``` -This creates `ca.crt` and `ca.key` in the `examples/` directory. +This creates `ca.crt` and `ca.key` in the `examples/` directory. The remaining steps assume commands run from the repository root, so the config and `curl` examples below reference `examples/ca.crt`. ## Step 2: Write a minimal config @@ -88,7 +88,7 @@ Gatekeeper intercepts the connection, injects the `Authorization: Bearer sk-xxxx The proxy logs each request with credential injection details: ```text -level=INFO msg=request http_method=GET http_host=api.example.com http_path=/v1/resource http_status=200 duration_ms=142 credential_injected=true injected_headers=Authorization grants=example-api +level=INFO msg=request request_id=req_01h455vb4pex5vsknk084sn02q http_method=GET http_host=api.example.com http_path=/v1/resource http_status=200 duration_ms=142 proxy_type=connect client_ip=127.0.0.1 credential_injected=true injected_headers=authorization grants=example-api ``` The `credential_injected=true` and `grants=example-api` fields confirm the proxy injected the credential. diff --git a/docs/content/guides/01-ca-setup.md b/docs/content/guides/01-ca-setup.md index 522ed1a..291884f 100644 --- a/docs/content/guides/01-ca-setup.md +++ b/docs/content/guides/01-ca-setup.md @@ -26,7 +26,9 @@ This creates two files: - `ca.crt` — the CA certificate (distribute to clients) - `ca.key` — the CA private key (keep private, permissions set to `0600`) -The generated CA uses an EC P-256 key, valid for 365 days, with `CA:TRUE` and `keyCertSign` constraints. +The generated CA uses an EC P-256 key, valid for 365 days, with `CA:TRUE, pathlen:0` and `keyCertSign` constraints. + +> **Note:** The script generates the certificate from an explicit OpenSSL config file (rather than `-addext`) and splits EC key generation into `openssl ecparam` + `openssl req -new -x509` steps. Both choices avoid toolchain-specific failures: some OpenSSL builds duplicate the `basicConstraints` extension when it's supplied via `-addext` alongside a default config that already defines `req_extensions`, which Go's `x509` parser rejects; LibreSSL (the `openssl` on a stock macOS install) mishandles the single-step `openssl req -newkey ec -pkeyopt ...` form, emitting EC parameters that Go's `x509` parser rejects with "invalid ECDSA parameters" when gatekeeper loads the CA. ## Trust the CA diff --git a/docs/content/guides/02-environment-credentials.md b/docs/content/guides/02-environment-credentials.md index 8c5d756..41324f7 100644 --- a/docs/content/guides/02-environment-credentials.md +++ b/docs/content/guides/02-environment-credentials.md @@ -53,7 +53,15 @@ export GITHUB_TOKEN="ghp_xxxxxxxxxxxxxxxxxxxx" gatekeeper --config gatekeeper.yaml ``` -Gatekeeper resolves the credential at startup. For `Authorization` headers, the auth scheme is auto-detected from the token prefix (`ghp_` maps to `token`, `github_pat_` to `Bearer`). Override with the `prefix` field if needed. +Gatekeeper resolves the credential at startup. For `Authorization` headers, the auth scheme is auto-detected from the token prefix: + +| Token prefix | Auth scheme | +|---|---| +| `ghp_`, `ghs_` | `token` | +| `gho_`, `github_pat_` | `Bearer` | +| any other value | `Bearer` | + +Override with the `prefix` field if needed. ## Make a Request @@ -70,7 +78,7 @@ The proxy intercepts the TLS connection, injects the `Authorization` header, and Check the proxy log output. A successful injection produces a line like: ```text -level=INFO msg=request http_method=GET http_host=api.github.com http_status=200 credential_injected=true injected_headers=Authorization grants=github +level=INFO msg=request http_method=GET http_host=api.github.com http_status=200 credential_injected=true injected_headers=authorization grants=github ``` ## Next Steps diff --git a/docs/content/guides/06-token-exchange.md b/docs/content/guides/06-token-exchange.md index fcdc4dd..d29260d 100644 --- a/docs/content/guides/06-token-exchange.md +++ b/docs/content/guides/06-token-exchange.md @@ -227,3 +227,4 @@ The proxy log shows credential injection with the grant name. - [Network Lockdown](./07-network-lockdown.md) — combine token exchange with strict network policy - [OpenTelemetry](./08-opentelemetry.md) — trace token exchange calls end-to-end +- [STS Endpoint Implementation Notes](../../token-exchange-endpoint.md) — the full wire contract and implementation checklist for building an STS endpoint diff --git a/docs/content/guides/08-opentelemetry.md b/docs/content/guides/08-opentelemetry.md index efbcbce..324c158 100644 --- a/docs/content/guides/08-opentelemetry.md +++ b/docs/content/guides/08-opentelemetry.md @@ -32,6 +32,14 @@ export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer " Gatekeeper creates OTLP HTTP exporters for traces, metrics, and logs and registers them as global providers. +To disable the OTel SDK entirely, set `OTEL_SDK_DISABLED=true` (case-insensitive; any other value, or leaving it unset, keeps the SDK enabled): + +```bash +export OTEL_SDK_DISABLED=true +``` + +With the SDK disabled, gatekeeper skips exporter setup and provider registration -- no traces, metrics, or logs are emitted, and no connection to a collector is attempted. + ## Traces Every proxy request creates a span. Span names reflect the request type: @@ -92,6 +100,12 @@ Make a request through the proxy, then open `http://localhost:16686` to view tra After sending requests through the proxy, confirm traces and metrics are arriving at the collector. In Jaeger, search for service `gatekeeper` and look for `proxy.request` spans with `credential_injected=true` events. +## Troubleshooting + +Export failures (for example, no collector listening at the configured `OTEL_EXPORTER_OTLP_ENDPOINT`) log at `DEBUG` level rather than `INFO`, so they don't drown canonical request log lines when the log level is `info`. If traces or metrics aren't arriving and nothing appears in gatekeeper's logs, set `log.level: debug` (or run with a debug-level slog handler) to surface the underlying export errors. + +> **Note:** Without a registered OTel error handler, export errors fall through to the standard library `log` package, which gatekeeper's logging setup rewires through the configured slog handler at `INFO` level. Gatekeeper registers its own handler so these errors log at `DEBUG` instead. + ## Next Steps - [Go Library](./09-go-library.md) — embed gatekeeper in a Go application with custom instrumentation diff --git a/docs/content/guides/09-go-library.md b/docs/content/guides/09-go-library.md index afd3b6c..d9ca142 100644 --- a/docs/content/guides/09-go-library.md +++ b/docs/content/guides/09-go-library.md @@ -69,6 +69,7 @@ func main() { | `SetCA(ca)` | Set the CA for TLS interception | | `SetCredentialWithGrant(host, header, value, grant)` | Set a static credential for a host | | `SetCredentialResolver(host, resolver)` | Set a dynamic per-request resolver | +| `SetCredentialResolverWithStripHeaders(host, resolver, headers...)` | Set a dynamic resolver that also removes request headers it consumes; prefer this over `SetCredentialResolver` when the resolver reads a subject-identity or other header that must not reach the upstream, since the proxy strips those headers even when a better-matched static credential skips calling the resolver | | `SetNetworkPolicy(policy, allows, grants)` | Configure network allow/deny | | `SetAuthToken(token)` | Require proxy authentication | | `SetContextResolver(fn)` | Map proxy auth tokens to per-caller contexts | diff --git a/docs/content/reference/02-config-file.md b/docs/content/reference/02-config-file.md index fbb28b2..a51e1c7 100644 --- a/docs/content/reference/02-config-file.md +++ b/docs/content/reference/02-config-file.md @@ -269,7 +269,7 @@ When set to `"basic"`, the credential is encoded as HTTP Basic authentication: ` ### credentials[].grant -Label for logging and metrics. Does not affect credential injection behavior. +Label for logging and metrics. ```yaml credentials: @@ -283,6 +283,8 @@ credentials: Grant names appear in the `grants` field of canonical request log lines and in OTel span attributes. +Grant names normally have no effect on which credential is injected. The one exception: when multiple credentials targeting the same host share a header name, gatekeeper breaks the tie by grant name. If the client sent a placeholder value in that header, the credential with `grant: claude` wins (letting the client explicitly opt into that grant). Otherwise, for unprompted auto-injection, a non-`claude` grant wins over `claude`, so `claude` is only injected when the client explicitly requests it via a placeholder. This tie-break only matters when two or more credentials collide on the same host and header; with no collision, `grant` is purely a logging label. + ### credentials[].source Determines where the credential value comes from. See [Credential sources](./03-credential-sources.md) for all source types and their fields. @@ -299,7 +301,7 @@ credentials: - **Required:** Yes - **Default:** — -The `type` field selects the source backend. Each type accepts different fields. Extraneous fields for the selected type cause a validation error. +The `type` field selects the credential source. Each type accepts different fields. Extraneous fields for the selected type cause a validation error. ### credentials[].postgres diff --git a/docs/content/reference/03-credential-sources.md b/docs/content/reference/03-credential-sources.md index 352d911..eac6cac 100644 --- a/docs/content/reference/03-credential-sources.md +++ b/docs/content/reference/03-credential-sources.md @@ -450,4 +450,14 @@ OAuth token type URI for the actor token. - **Required:** No - **Default:** `"urn:ietf:params:oauth:token-type:access_token"` -Exchanged tokens are cached per subject (and actor, if present) using the TTL from the STS `expires_in` response field. If the STS does not return `expires_in`, a default TTL of 5 minutes is used. Concurrent requests for the same subject are coalesced into a single STS call. +Exchanged tokens are cached per subject (and actor, if present) using the TTL from the STS `expires_in` response field, but the cached TTL is always capped at 1 minute regardless of what the STS advertises — a longer `expires_in` only means the token may remain valid that long, not that it stays valid, since the upstream credential behind the exchange can be revoked or rotated without gatekeeper's knowledge. If the STS does not return `expires_in` (or returns a non-positive value), the same 1-minute cap is used as the TTL. Concurrent requests for the same subject are coalesced into a single STS call via singleflight, so the 1-minute cap does not translate into an STS call per request. + +See [Credential invalidation](#credential-invalidation) below for how gatekeeper evicts a cached token before its TTL expires. + +## Credential invalidation + +When the upstream server rejects a forwarded request with `401 Unauthorized` or `403 Forbidden`, gatekeeper invalidates every credential that was injected into that request, so the next request re-resolves rather than replaying a credential the destination has already refused. This applies to any credential source that supports invalidation, not just `token-exchange`. Statuses other than 401/403 are left alone — a 5xx says nothing about the credential's validity, and a 401/403 also covers cases unrelated to a stale credential (rate limits, missing repo access), not only rotation. + +For `token-exchange`, invalidation evicts the cached token for that subject (and actor) so the next request performs a fresh exchange. Evictions are rate-limited per subject/actor key to a 10-second cooldown: repeated 401/403 responses for the same key within 10 seconds are no-ops after the first eviction. This bounds how often a client that is looping on a failing, non-idempotent request (e.g., a repeated `git push`) can force STS calls; the trade-off is that a genuinely rotated credential can take up to 10 seconds to be picked up after the first eviction. + +Invalidation is evict-only — the request that triggered it is not retried by gatekeeper. diff --git a/docs/content/reference/04-environment.md b/docs/content/reference/04-environment.md index 4531b62..1f69e7e 100644 --- a/docs/content/reference/04-environment.md +++ b/docs/content/reference/04-environment.md @@ -71,6 +71,7 @@ Gatekeeper initializes OTLP HTTP exporters for traces, metrics, and logs. All co | `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | Override endpoint for logs only | | `OTEL_RESOURCE_ATTRIBUTES` | Additional resource attributes (e.g., `deployment.environment=production`) | | `OTEL_SERVICE_NAME` | Override the service name (default: `gatekeeper`) | +| `OTEL_SDK_DISABLED` | When exactly `true` (case-insensitive; any other value is treated as unset), disables the OTel SDK entirely — no exporters are created and no traces, metrics, or logs are emitted. | Gatekeeper registers the following OTel resource attributes at startup: diff --git a/docs/content/reference/05-llm-policy.md b/docs/content/reference/05-llm-policy.md index 5d40c29..a01bb42 100644 --- a/docs/content/reference/05-llm-policy.md +++ b/docs/content/reference/05-llm-policy.md @@ -10,7 +10,7 @@ Gatekeeper evaluates Anthropic API responses against [Keep](https://github.com/m ## Overview -LLM policy evaluation is configured through `RunContextData.KeepEngines` — a map of hostnames to Keep engine instances. When a proxied response matches a host with a Keep engine, gatekeeper buffers the response body, evaluates it against the engine's rules, and either forwards or blocks the response. +`RunContextData.KeepEngines` is a map keyed by fixed scope names, not hostnames: `"llm-gateway"` for LLM policy, `"http"` for the HTTP-scope Keep policy applied to intercepted requests (see [Network Policy](../concepts/04-network-policy.md)), and `"mcp-"` for per-MCP-server tool-call policy. LLM policy evaluation looks up `KeepEngines["llm-gateway"]` and only runs when the response's request host is `api.anthropic.com` — that host is hardcoded, not derived from a hostname-keyed lookup. When that engine is set and the host matches, gatekeeper buffers the response body, evaluates it against the engine's rules, and either forwards or blocks the response. This feature is primarily used through moat's configuration layer, which compiles Keep rule files into engines and attaches them to per-run context. Standalone gatekeeper does not expose Keep configuration in `gatekeeper.yaml`. @@ -18,7 +18,7 @@ This feature is primarily used through moat's configuration layer, which compile ## Evaluation flow -1. The proxy intercepts an HTTPS response from a host that has a Keep engine configured (e.g., `api.anthropic.com`). +1. The proxy intercepts an HTTPS response from `api.anthropic.com` — the only host LLM policy evaluates — when `RunContextData.KeepEngines["llm-gateway"]` is set. 2. The response body is buffered up to 10 MB (`maxLLMResponseSize`). Responses exceeding this limit are denied (fail-closed). 3. If the response is gzip-compressed, it is decompressed for evaluation. 4. The response is evaluated based on its `Content-Type`: @@ -41,7 +41,7 @@ All evaluation errors result in denial: ## Denied response format -When a response is denied, the client receives an HTTP 200 with a JSON body matching the Keep LLM gateway format: +When a response is denied, the client receives an HTTP 400 with a JSON body matching the Keep LLM gateway format: ```json { diff --git a/docs/token-exchange-endpoint.md b/docs/token-exchange-endpoint.md index 1af222f..5d46c45 100644 --- a/docs/token-exchange-endpoint.md +++ b/docs/token-exchange-endpoint.md @@ -1,5 +1,7 @@ # Implementing an RFC 8693 Token Exchange Endpoint for Gatekeeper +This document is the STS-implementer contract: authentication, wire format, and caching semantics for the endpoint gatekeeper calls. For configuring gatekeeper itself as the token-exchange client — subject/actor modes, YAML fields, actor-token forwarding — see the [Token Exchange guide](content/guides/06-token-exchange.md). + ## Context Gatekeeper is a credential-injecting TLS proxy. It can be configured with a `token-exchange` credential source that dynamically resolves per-user OAuth tokens by calling an external Security Token Service (STS) endpoint using the [RFC 8693 OAuth 2.0 Token Exchange](https://datatracker.ietf.org/doc/html/rfc8693) protocol. @@ -91,6 +93,7 @@ Gatekeeper caches tokens per `(subject_token, actor_token)` pair within each cre - If `expires_in` is `0` or omitted, the cap is used. - There is no proactive refresh or sliding window. Expired entries are replaced on the next request. - A `401` or `403` from the destination drops the cache entry, so the next request exchanges afresh. Evictions are rate-limited to one per key per 10 seconds. +- Concurrent requests that share a cache-miss key are coalesced with `singleflight.Group` — only one exchange call reaches your endpoint per key at a time; the other callers block on the same in-flight call and share its result. You do not need your own request-level locking or idempotency handling to survive a burst of simultaneous requests for the same subject, though duplicate exchanges can still happen across separate cache keys (e.g. different actor tokens) or after a cache entry has expired and a new one hasn't populated yet. The cap bounds how long a rotated or revoked upstream credential keeps being injected. Because of it, an `expires_in` above 60 seconds does not reduce STS request volume — size the endpoint for roughly one exchange per subject per minute. diff --git a/examples/gen-ca.sh b/examples/gen-ca.sh index 814f5a4..a8e8698 100755 --- a/examples/gen-ca.sh +++ b/examples/gen-ca.sh @@ -11,12 +11,38 @@ if [ -f ca.crt ] && [ -f ca.key ]; then exit 0 fi -openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \ - -days 365 -nodes \ - -keyout ca.key -out ca.crt \ - -subj "/CN=Gate Keeper Example CA" \ - -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \ - -addext "keyUsage=critical,keyCertSign,cRLSign" \ +# An explicit [v3_ca] section (referenced via -config/-extensions) is used +# instead of -addext. Some OpenSSL builds (e.g. Homebrew OpenSSL 1.1.1) merge +# -addext extensions with the default config's own req_extensions, producing +# a certificate with a duplicate basicConstraints extension that Go's x509 +# parser rejects ("certificate contains duplicate extension"). A self- +# contained config file has no default extensions to collide with. +openssl_config=$(mktemp) +trap 'rm -f "$openssl_config"' EXIT + +cat > "$openssl_config" <<'EOF' +[req] +distinguished_name = req_distinguished_name +x509_extensions = v3_ca +prompt = no + +[req_distinguished_name] +CN = Gate Keeper Example CA + +[v3_ca] +basicConstraints = critical,CA:TRUE,pathlen:0 +keyUsage = critical,keyCertSign,cRLSign +EOF + +# EC key generation is split into two steps (ecparam + req) rather than +# `openssl req -newkey ec -pkeyopt ...` because LibreSSL (the OpenSSL variant +# shipped as macOS system /usr/bin/openssl) mishandles the one-step form, +# emitting EC parameters that Go's x509 parser rejects with "invalid ECDSA +# parameters" when gatekeeper loads the CA. +openssl ecparam -name prime256v1 -genkey -noout -out ca.key +openssl req -new -x509 -key ca.key -days 365 \ + -out ca.crt \ + -config "$openssl_config" -extensions v3_ca \ 2>/dev/null chmod 0600 ca.key