diff --git a/README.md b/README.md index 42bbaab..a20d53c 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,16 @@ Catalog — see [Development](#development) for what that needs. ```mermaid flowchart LR - Req["incoming request"] --> Auth["auth
identify(): key digest lookup"] - Auth -->|"no match"| E401["401 invalid_api_key"] - Auth -->|"user, group"| Route["route
ServeHTTP dispatch"] - Route --> Unified["unified
/v1/chat/completions
/v1/embeddings
/v1/models"] - Route --> MCP["MCP/A2A proxy
/mcp/name/...
/a2a/name/..."] - Route --> Passthrough["native passthrough
/providerName/..."] - Route -->|"no route matches"| Fallback["passthroughUnknown ? next : 404"] + Req["incoming request"] --> Route{"route
ServeHTTP: path+method dispatch"} + Route -->|"a recognized route:
/v1/models, /v1/chat/completions,
/v1/embeddings, /v1/mcp/servers,
/v1/agents, /mcp/name/...,
/a2a/name/..., or /providerName/...
for a known provider"| Auth["auth (per branch)
identify(): key digest lookup"] + Route -->|"no route matches"| Fallback["passthroughUnknown ? next : 404
never calls identify()"] + Auth -->|"no match"| E401["401 authentication_error"] + Auth -->|"user, group"| Branch{"branch"} + Branch --> Models["GET /v1/models"] + Branch --> Unified["POST /v1/chat/completions
POST /v1/embeddings"] + Branch --> Registry["GET /v1/mcp/servers, /v1/agents"] + Branch --> MCP["MCP/A2A proxy"] + Branch --> Passthrough["native passthrough"] Unified --> Resolve["resolve model
registry.resolve"] Resolve -->|"unknown/denied"| E404["404 / 403"] Resolve --> Limit1["limiter.checkAndCount"] @@ -55,6 +58,8 @@ flowchart LR Exec --> Account["account: usage, cost"] Account --> Resp["respond to client"] ExecProxy --> Resp + Models --> Resp + Registry --> Resp ``` Every route the plugin recognises is handled entirely inside the @@ -120,11 +125,14 @@ http: > Write an "allow everything" group as `providers: []` (an explicit empty > list), not a bare `{}`. See the note at the top of -> [`examples/kubernetes.yaml`](examples/kubernetes.yaml) — an empty nested -> object can fail to round-trip through Traefik's own dynamic-config -> decoder with `expected a map, got 'string'`; an explicit empty list -> means the same thing (all values allowed) and decodes correctly. Verified -> against this exact plugin build in `examples/docker-compose.yml`. +> [`examples/kubernetes.yaml`](examples/kubernetes.yaml) — confirmed +> against Traefik's **file provider**, twice, with this exact plugin build: +> a bare `default: {}` fails plugin construction with +> `expected a map, got 'string'`; `default: {providers: []}` loads cleanly. +> Not tested against the Docker/ECS label providers or the Kubernetes CRD +> provider — no claim either way for those. An explicit empty list means +> the same thing as an empty object (all values allowed), so writing it +> this way costs nothing even where the bug does not apply. Attach the middleware to a router the same way as any other Traefik middleware. The router's backing service is never actually reached unless @@ -406,11 +414,17 @@ kept — a bad edit to the file never breaks already-authenticated traffic. stripped before any upstream or target request and never reaches a provider, MCP server, or A2A agent. - Neither kind of key is ever written to a log line — the logging helpers - (`logger.go`) carry an explicit "never log key material" contract, and - error types (`providerHTTPError`, etc.) deliberately omit request/response - bodies that could carry one from ending up in a log line by accident. -- Auth events are logged without key material: a lookup miss is a plain - 401 with no indication of which key was tried. + (`logger.go`) carry an explicit "never log key material" contract. + `providerHTTPError`'s `Error()` string (`providers.go`) separately omits + the upstream response body it still holds as a struct field, so a future + caller that ever formats the error bare (`%v`) cannot accidentally echo + provider response content into a log line; today's only caller + (`writeProviderUpstreamError`) reads that body directly and puts it in + the client-facing response, never in a log. +- **There is no auth-event logging at all** — `auth.go` has zero logging + calls of any kind. A key that fails lookup gets a plain 401 JSON + envelope and nothing else happens; there is no log line, successful or + failed, for a request to appear in. ## Known limitations @@ -452,19 +466,19 @@ kept — a bad edit to the file never breaks already-authenticated traffic. Go's `Transport` auto-negotiates; an upstream that used a different encoding on its own initiative (never observed against OpenAI, Anthropic, or Gemini) would pass through unmodified instead. -- Malformed-path rejects (a `..` traversal segment in a passthrough or - MCP/A2A rest-path) are checked, and rejected, before the rate limiter - runs — they never consume quota. ## Kubernetes See [`examples/kubernetes.yaml`](examples/kubernetes.yaml) for a complete, -YAML-validated example manifest: a `Secret` holding the users file, a -`Middleware` custom resource carrying the plugin's own configuration -(validated against the real `Config` struct — every field in that example -round-trips through `New()` cleanly), an `IngressRoute` wiring it to a -host, and a Traefik Helm values snippet for loading the plugin and wiring -provider keys in from Secrets via environment variables. +YAML-validated example manifest that `kubectl apply -f` runs as-is: a +`Secret` holding the users file, a `Middleware` custom resource carrying +the plugin's own configuration (validated against the real `Config` +struct — every field in that example round-trips through `New()` +cleanly), a dummy backing `Deployment`/`Service` (Traefik requires a real +router backend even though this middleware never actually reaches it — +see [Quickstart](#quickstart)), an `IngressRoute` wiring it all to a +host, and a Traefik Helm values snippet for loading the plugin and +wiring provider keys in from Secrets via environment variables. The short version: diff --git a/examples/kubernetes.yaml b/examples/kubernetes.yaml index 13b23e2..6acc4f7 100644 --- a/examples/kubernetes.yaml +++ b/examples/kubernetes.yaml @@ -10,12 +10,15 @@ # see the Helm values snippet at the bottom of this file. # # The empty-map gotcha: write an empty group as `providers: []`, never a -# bare `{}`. Traefik's own dynamic-config decode pipeline (which the -# Kubernetes CRD provider funnels plugin config through, same as the file -# provider) can fail to round-trip a truly empty nested object with -# "expected a map, got 'string'" — verified against this exact shape in -# examples/docker-compose.yml. An explicit empty list decodes correctly -# and means the same thing (all values allowed). +# bare `{}`. Confirmed against Traefik's file provider, twice, with this +# exact plugin build (examples/docker-compose.yml, plus an isolated +# minimal repro outside that demo's compose-specific config mechanism): a +# bare `default: {}` fails plugin construction with +# "expected a map, got 'string'"; an explicit empty list decodes +# correctly and means the same thing (all values allowed). Whether the +# Kubernetes CRD provider used below hits the same decode path as the +# file provider is NOT verified — this file uses the empty-list form +# defensively, not because the CRD path is confirmed affected. apiVersion: v1 kind: Secret @@ -97,6 +100,46 @@ spec: url: http://summarizer.agents.svc:8080 passthroughUnknown: false +--- +# Traefik requires a real backing Service for a router even though this +# middleware short-circuits every path it recognizes; whoami (the same +# image already exercised in ../examples/docker-compose.yml) is a small, +# always-up dummy, only ever reached via passthroughUnknown (default +# false, which 404s instead). Any always-up internal service works here. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: llmgateway-dummy-backend + namespace: traefik +spec: + replicas: 1 + selector: + matchLabels: + app: llmgateway-dummy-backend + template: + metadata: + labels: + app: llmgateway-dummy-backend + spec: + containers: + - name: whoami + image: traefik/whoami:v1.10 + ports: + - containerPort: 80 + +--- +apiVersion: v1 +kind: Service +metadata: + name: llmgateway-dummy-backend + namespace: traefik +spec: + selector: + app: llmgateway-dummy-backend + ports: + - port: 80 + targetPort: 80 + --- apiVersion: traefik.io/v1alpha1 kind: IngressRoute @@ -111,11 +154,6 @@ spec: kind: Rule middlewares: - name: llmgateway - # Traefik requires a real backing Service for a router even though - # this middleware short-circuits every path it recognizes; a small - # dummy such as traefik/whoami (see ../examples/docker-compose.yml) - # or any always-up internal service works, since it is only ever - # reached via passthroughUnknown (default false, which 404s instead). services: - name: llmgateway-dummy-backend port: 80