fix(agent-pod): remote tui session for multi-agent
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
# API Auth Layer — Authentik service account + Kong JWT (model invoke)
|
||||
|
||||
Protect the model API (`api.riotpiao.com/*`, Kong OSS 3.9) so only an Authentik
|
||||
service account holding a valid **client_credentials** JWT can invoke the KServe
|
||||
models. "Invoke role" = **possession of a JWT from the dedicated model-invoke
|
||||
OAuth2 provider** (only the service account can obtain one).
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
service account ── client_credentials ──▶ Authentik token endpoint
|
||||
(client_id + secret) https://authentik.riotpiao.com/application/o/token/
|
||||
│
|
||||
▼ RS256 JWT (iss = https://authentik.riotpiao.com/application/o/model-invoke/)
|
||||
client ── Authorization: Bearer <jwt> ──▶ Kong (api.riotpiao.com/*)
|
||||
jwt plugin: verify RS256 sig via Authentik JWKS,
|
||||
check iss/exp → map to KongConsumer → allow
|
||||
▼
|
||||
KServe model (reasoning / ornith / ...)
|
||||
```
|
||||
|
||||
Kong OSS has no enterprise `openid-connect` plugin, so we use the built-in
|
||||
**`jwt`** plugin: it validates an RS256 signature against a public key we pin on
|
||||
a KongConsumer, keyed by the token's `iss`.
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. Authentik (k8s/infra/iam/scripts/authentik-provision.py)
|
||||
- New **service account** user `model-invoker` (type `service_account`, no
|
||||
password; Authentik issues an app-password/token for M2M).
|
||||
- New **OAuth2 provider + application** `model-invoke`:
|
||||
- `client_type: confidential`, `grant_types: ["client_credentials"]`
|
||||
- signing key = existing RS256 keypair (same as other providers)
|
||||
- mappings: `openid` (+ optionally a static `invoke` scope) — no user scopes
|
||||
needed for M2M.
|
||||
- Client secret written to k8s Secret `api/model-invoke-oidc`
|
||||
(keys `client-id`, `client-secret`), labelled for whoever consumes it.
|
||||
- Bind the service account so it (and only it) can use the provider.
|
||||
|
||||
### 2. Kong (k8s/apps/api/, new file `model-auth.yaml`)
|
||||
- **KongConsumer** `model-invoker` (ns api).
|
||||
- **`jwt` credential** on that consumer (a Secret of type
|
||||
`konghq.com/v1/credential`):
|
||||
- `algorithm: RS256`
|
||||
- `key` = the token `iss` → `https://authentik.riotpiao.com/application/o/model-invoke/`
|
||||
- `rsa_public_key` = the PEM public key of Authentik's `model-invoke` signing
|
||||
cert (fetched from Authentik JWKS / cert, stored in git or ksops).
|
||||
- **KongPlugin** `jwt-auth` (`plugin: jwt`, `config.claims_to_verify: [exp]`).
|
||||
|
||||
### 3. Wire onto model routes (k8s/apps/api/llm-routes.yaml)
|
||||
- Add `jwt-auth` to each model Ingress's `konghq.com/plugins` annotation
|
||||
(currently e.g. `llm-rewrite-reasoning`) → becomes
|
||||
`llm-rewrite-reasoning,jwt-auth`.
|
||||
- Leave `/models` list route open OR protect too (decision).
|
||||
|
||||
## Client usage (after build)
|
||||
```bash
|
||||
TOKEN=$(curl -s https://authentik.riotpiao.com/application/o/token/ \
|
||||
-d grant_type=client_credentials \
|
||||
-d client_id=model-invoke \
|
||||
-d client_secret=<secret> \
|
||||
-d scope=openid | jq -r .access_token)
|
||||
|
||||
curl https://api.riotpiao.com/v1/chat/completions \
|
||||
-H "Authorization: Bearer $TOKEN" -d '{...}'
|
||||
```
|
||||
|
||||
## Test plan
|
||||
1. No token → Kong returns 401.
|
||||
2. Valid client_credentials token → 200, model responds.
|
||||
3. Expired/garbage token → 401.
|
||||
4. Confirm the `/models` route behaviour matches the decision.
|
||||
|
||||
## Open items / risks
|
||||
- Authentik `client_credentials` for a *service account* may require an
|
||||
**app-password / JWT-assertion** flow rather than plain client_secret POST —
|
||||
verify Authentik 2026.x M2M exactly (client_credentials with client_secret vs
|
||||
the SA token). Adjust step 1 accordingly before wiring Kong.
|
||||
- Pinning `rsa_public_key`: Authentik key rotation would break it — document a
|
||||
rotation runbook, or have the provision script re-export the cert PEM into the
|
||||
Kong credential on each run (keeps them in sync, same idea as ksops secrets).
|
||||
- Kong `jwt` maps token→consumer by the `iss`=`key` match; ensure the provider's
|
||||
issuer is stable.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Cluster-wide Kong Prometheus plugin -- `global: "true"` label makes the
|
||||
# ingress controller apply it to every route on this Kong instance, so all
|
||||
# five LLM routes (ornith/reasoning/qwen/embeddings/rerank) get RED metrics
|
||||
# without touching llm-routes.yaml. Scraped via kong-values.yaml's
|
||||
# serviceMonitor (status listener, already on by chart default at :8100).
|
||||
apiVersion: configuration.konghq.com/v1
|
||||
kind: KongClusterPlugin
|
||||
metadata:
|
||||
name: prometheus
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: kong
|
||||
labels:
|
||||
global: "true"
|
||||
plugin: prometheus
|
||||
config:
|
||||
status_code_metrics: true
|
||||
latency_metrics: true
|
||||
bandwidth_metrics: true
|
||||
upstream_health_metrics: true
|
||||
@@ -57,6 +57,17 @@ env:
|
||||
# nginx Ingress in ingress.yaml; both hops have to be unbuffered or the
|
||||
# buffered one dominates.
|
||||
nginx_proxy_proxy_buffering: "off"
|
||||
# Any plugin that rewrites the request body — request-transformer on the
|
||||
# llm-chat-* routes — reads it through `kong.request.get_body()`, and that
|
||||
# returns nothing once nginx has spilled the body past
|
||||
# client_body_buffer_size into a temp file. The plugin then re-serializes a
|
||||
# body with no `messages`, and the upstream answers
|
||||
# HTTP 400 {"error":{"message":"[] is too short - 'messages'"}}
|
||||
# Measured on /v1/ornith/chat/completions: 10588 B -> 200, 11088 B -> 400.
|
||||
# An agent request carrying tool schemas clears that in one turn, so the
|
||||
# buffer has to hold a whole conversation, not a chat message.
|
||||
nginx_http_client_body_buffer_size: "16m"
|
||||
nginx_http_client_max_body_size: "16m"
|
||||
|
||||
ingressController:
|
||||
enabled: true
|
||||
@@ -107,6 +118,16 @@ podDisruptionBudget:
|
||||
enabled: true
|
||||
minAvailable: 1
|
||||
|
||||
# Status listener (metrics/health) is on by default at :8100 (chart default,
|
||||
# verified via `helm show values`). This just wires the ServiceMonitor the
|
||||
# chart already knows how to generate for it, so kong_http_requests_total /
|
||||
# kong_latency_* / kong_bandwidth_bytes land in Prometheus. Paired with the
|
||||
# cluster-wide `prometheus` KongClusterPlugin in kong-metrics.yaml.
|
||||
serviceMonitor:
|
||||
enabled: true
|
||||
labels:
|
||||
release: kube-prometheus-stack
|
||||
|
||||
# Spread the two replicas across nodes; `ScheduleAnyway` so a single-node
|
||||
# situation degrades to co-location instead of leaving a pod Pending.
|
||||
topologySpreadConstraints:
|
||||
|
||||
@@ -6,7 +6,9 @@ kind: Kustomization
|
||||
# or it is silently dropped with no error and no drift shown.
|
||||
resources:
|
||||
- ingress.yaml
|
||||
- kong-metrics.yaml
|
||||
- llm-routes.yaml
|
||||
- model-auth.yaml
|
||||
# No top-level `namespace:` transformer on purpose: ingress.yaml sets its own
|
||||
# namespace, and the transformer rewrites metadata.namespace on every resource
|
||||
# it builds, which is a trap for anything cross-namespace added later.
|
||||
|
||||
@@ -66,7 +66,7 @@ metadata:
|
||||
name: llm-models
|
||||
namespace: llm-serving
|
||||
annotations:
|
||||
konghq.com/plugins: llm-models-list
|
||||
konghq.com/plugins: llm-models-list,model-key-auth
|
||||
konghq.com/strip-path: "false"
|
||||
konghq.com/methods: "GET"
|
||||
spec:
|
||||
@@ -110,7 +110,7 @@ metadata:
|
||||
name: llm-chat-reasoning
|
||||
namespace: llm-serving
|
||||
annotations:
|
||||
konghq.com/plugins: llm-rewrite-reasoning
|
||||
konghq.com/plugins: llm-rewrite-reasoning,model-key-auth
|
||||
konghq.com/strip-path: "false"
|
||||
konghq.com/methods: "POST"
|
||||
konghq.com/connect-timeout: "10000"
|
||||
@@ -152,7 +152,7 @@ metadata:
|
||||
name: llm-chat-ornith
|
||||
namespace: llm-serving
|
||||
annotations:
|
||||
konghq.com/plugins: llm-rewrite-ornith
|
||||
konghq.com/plugins: llm-rewrite-ornith,model-key-auth
|
||||
konghq.com/strip-path: "false"
|
||||
konghq.com/methods: "POST"
|
||||
konghq.com/connect-timeout: "10000"
|
||||
@@ -197,7 +197,7 @@ metadata:
|
||||
name: llm-chat-qwen
|
||||
namespace: llm-serving
|
||||
annotations:
|
||||
konghq.com/plugins: llm-rewrite-qwen
|
||||
konghq.com/plugins: llm-rewrite-qwen,model-key-auth
|
||||
konghq.com/strip-path: "false"
|
||||
konghq.com/methods: "POST"
|
||||
konghq.com/connect-timeout: "10000"
|
||||
@@ -267,7 +267,7 @@ metadata:
|
||||
name: llm-rerank
|
||||
namespace: llm-serving
|
||||
annotations:
|
||||
konghq.com/plugins: llm-rewrite-rerank
|
||||
konghq.com/plugins: llm-rewrite-rerank,model-key-auth
|
||||
konghq.com/strip-path: "false"
|
||||
konghq.com/methods: "POST"
|
||||
konghq.com/connect-timeout: "10000"
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# API auth layer — Kong key-auth on the model routes.
|
||||
#
|
||||
# The model API (api.riotpiao.com/v1/...) requires a static API key, presented
|
||||
# OpenAI-style as `Authorization: Bearer <key>` (or `apikey: <key>`). The key
|
||||
# lives in the ksops-managed Secret model-invoke-apikey (labelled
|
||||
# konghq.com/credential: key-auth) and is bound to the KongConsumer below.
|
||||
#
|
||||
# Issue the key to rock; use it as the OpenAI SDK api_key. Rotate by updating the
|
||||
# ksops secret. This is self-contained in Kong — the invoke path does not depend
|
||||
# on an Authentik token (Authentik still fronts every *human* dashboard SSO).
|
||||
---
|
||||
apiVersion: configuration.konghq.com/v1
|
||||
kind: KongConsumer
|
||||
metadata:
|
||||
name: model-invoker
|
||||
namespace: api
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: kong
|
||||
username: model-invoker
|
||||
credentials:
|
||||
- model-invoke-apikey
|
||||
---
|
||||
# key-auth: require the API key on the model routes. key_in_header accepts the
|
||||
# `apikey` header; key_in_bearer accepts `Authorization: Bearer <key>` so any
|
||||
# OpenAI-compatible SDK (api_key=..., base_url=https://api.riotpiao.com/v1) works
|
||||
# unchanged.
|
||||
#
|
||||
# Namespace `llm-serving`, not `api`: the ingress controller resolves a
|
||||
# `konghq.com/plugins` annotation against the annotated object's OWN namespace,
|
||||
# and all five model routes in llm-routes.yaml live in llm-serving. While this
|
||||
# sat in `api` the reference dangled, the plugin never bound, and every model
|
||||
# route served traffic with no key at all — verified: an unauthenticated
|
||||
# /v1/models and /v1/ornith/chat/completions both returned 200. A dangling
|
||||
# plugin reference is silent; it fails open, so re-test without a key after any
|
||||
# move rather than trusting that the object exists.
|
||||
apiVersion: configuration.konghq.com/v1
|
||||
kind: KongPlugin
|
||||
metadata:
|
||||
name: model-key-auth
|
||||
namespace: llm-serving
|
||||
plugin: key-auth
|
||||
config:
|
||||
key_names:
|
||||
- apikey
|
||||
- authorization
|
||||
key_in_header: true
|
||||
key_in_query: false
|
||||
key_in_body: false
|
||||
hide_credentials: true
|
||||
Reference in New Issue
Block a user