Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .claude/skills/verify/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 <ip> ...\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.
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)

Expand Down Expand Up @@ -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

Expand Down
18 changes: 12 additions & 6 deletions CHANGELOG.md

Large diffs are not rendered by default.

35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/
Expand Down
6 changes: 6 additions & 0 deletions docs/content/concepts/01-tls-interception.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 3 additions & 8 deletions docs/content/concepts/02-credential-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
18 changes: 17 additions & 1 deletion docs/content/concepts/04-network-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 5 additions & 4 deletions docs/content/concepts/05-mcp-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
9 changes: 8 additions & 1 deletion docs/content/concepts/06-observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 |

Expand Down
Loading
Loading