From 7eb5704d74ebb2d4dbf52cc3921b687f0147ef0a Mon Sep 17 00:00:00 2001 From: Lee Chapman Date: Sun, 1 Feb 2026 23:36:04 -0800 Subject: [PATCH 1/4] feat: Claude via backend proxy + cloudflared CI - Use /api/config to detect backend Claude (key in k8s secret) - ClaudeService supports useBackendKey when backend has key - Add publish-cloudflared job to GitLab CI - Add cloudflare/Dockerfile for tunnel image --- .gitlab-ci.yml | 28 ++++++++++++++++++++++++++++ cloudflare/Dockerfile | 6 ++++++ src/services/api.ts | 30 +++++++++++++++++++++++++++--- src/services/claude.ts | 28 ++++++++++++++-------------- 4 files changed, 75 insertions(+), 17 deletions(-) create mode 100644 cloudflare/Dockerfile diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4f0ce29..26b967e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -57,6 +57,34 @@ publish: tags: - kubernetes +publish-cloudflared: + stage: docker + image: + name: moby/buildkit:rootless + entrypoint: [""] + script: + - | + CLOUDFLARED_IMAGE="$HARBOR_REGISTRY/$HARBOR_PROJECT/cloudflared-tunnel:latest" + IMAGE_TAG="${CI_COMMIT_SHORT_SHA:-$CI_PIPELINE_ID}" + + OUTPUT_FLAGS="--output type=image,name=$CLOUDFLARED_IMAGE,push=true" + OUTPUT_FLAGS="$OUTPUT_FLAGS --output type=image,name=$HARBOR_REGISTRY/$HARBOR_PROJECT/cloudflared-tunnel:$IMAGE_TAG,push=true" + if [ -n "$CI_COMMIT_TAG" ]; then + OUTPUT_FLAGS="$OUTPUT_FLAGS --output type=image,name=$HARBOR_REGISTRY/$HARBOR_PROJECT/cloudflared-tunnel:$CI_COMMIT_TAG,push=true" + fi + + buildctl-daemonless.sh build \ + --frontend dockerfile.v0 \ + --local context=./cloudflare \ + --local dockerfile=./cloudflare/Dockerfile \ + $OUTPUT_FLAGS + + echo "Pushed cloudflared-tunnel to $CLOUDFLARED_IMAGE" + rules: + - if: '$CI_COMMIT_TAG' + tags: + - kubernetes + release: stage: release image: registry.gitlab.com/gitlab-org/cli:latest diff --git a/cloudflare/Dockerfile b/cloudflare/Dockerfile new file mode 100644 index 0000000..9fa93f1 --- /dev/null +++ b/cloudflare/Dockerfile @@ -0,0 +1,6 @@ +# Cloudflared with shell for k8s token injection +# Official image is distroless (no /bin/sh) - we need shell to pass token from secret +FROM harbor.dataknife.net/dockerhub/cloudflare/cloudflared:latest AS cf +FROM harbor.dataknife.net/dockerhub/library/alpine:3.19 +COPY --from=cf /usr/local/bin/cloudflared /usr/local/bin/cloudflared +ENTRYPOINT ["/bin/sh", "-c", "exec cloudflared --no-autoupdate tunnel run --token \"$TUNNEL_TOKEN\""] diff --git a/src/services/api.ts b/src/services/api.ts index 7c5d326..9178753 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -3,10 +3,15 @@ import { ClaudeService } from './claude' +interface ApiConfig { + claudeEnabled?: boolean +} + class HighCommandAPI { private baseUrl: string = '/api' // Use local proxy private mcpUrl: string = '/mcp' // Use local proxy private claudeService: ClaudeService + private configCache: ApiConfig | null = null constructor() { console.log('API Server:', this.baseUrl) @@ -14,6 +19,19 @@ class HighCommandAPI { this.claudeService = new ClaudeService() } + private async getConfig(): Promise { + if (this.configCache) return this.configCache + try { + const res = await fetch(`${this.baseUrl}/config`) + if (res.ok) { + this.configCache = await res.json() + } + } catch { + // Ignore - backend may not have /api/config + } + return this.configCache ?? {} + } + private async handleResponse(response: Response) { if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) @@ -23,14 +41,20 @@ class HighCommandAPI { async executeCommand(prompt: string): Promise { try { - // Use Claude with MCP tools if API key is available + // Use Claude if: (1) we have a local API key, or (2) backend has key in secret const claudeApiKey = import.meta.env.VITE_CLAUDE_API_KEY + const config = await this.getConfig() + const backendHasClaude = config.claudeEnabled === true + if (claudeApiKey) { return await this.claudeService.executeCommand(prompt) } + if (backendHasClaude) { + return await this.claudeService.executeCommand(prompt, { useBackendKey: true }) + } - // Fallback: use keyword matching if no Claude API key - console.warn('No Claude API key found, using basic keyword matching') + // Fallback: use keyword matching if no Claude available + console.warn('No Claude API key found (local or backend), using basic keyword matching') return await this.executeCommandWithKeywordMatching(prompt) } catch (error) { console.error('Command error:', error) diff --git a/src/services/claude.ts b/src/services/claude.ts index 689f86b..0c853f6 100644 --- a/src/services/claude.ts +++ b/src/services/claude.ts @@ -24,7 +24,7 @@ export class ClaudeService { constructor() { this.apiKey = import.meta.env.VITE_CLAUDE_API_KEY || '' if (!this.apiKey) { - console.warn('VITE_CLAUDE_API_KEY not set. Claude integration will not work.') + console.log('VITE_CLAUDE_API_KEY not set. Claude may work if backend has CLAUDE_API_KEY in secret.') } } @@ -106,9 +106,10 @@ export class ClaudeService { } } - async executeCommand(userMessage: string): Promise { - if (!this.apiKey) { - throw new Error('Claude API key not configured. Set VITE_CLAUDE_API_KEY environment variable.') + async executeCommand(userMessage: string, options?: { useBackendKey?: boolean }): Promise { + const useBackendKey = options?.useBackendKey ?? false + if (!this.apiKey && !useBackendKey) { + throw new Error('Claude API key not configured. Set VITE_CLAUDE_API_KEY or configure CLAUDE_API_KEY in the API.') } try { @@ -124,13 +125,16 @@ export class ClaudeService { input_schema: tool.inputSchema })) + const headers: Record = { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01' + } + if (this.apiKey) { + headers['x-api-key'] = this.apiKey + } const response = await fetch('/claude/messages', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': this.apiKey, - 'anthropic-version': '2023-06-01' - }, + headers, body: JSON.stringify({ model: 'claude-haiku-4-5', max_tokens: 1024, @@ -221,11 +225,7 @@ Use clear hierarchy with H2 (##) and H3 (###) headers. Always prioritize markdow const followUpResponse = await fetch('/claude/messages', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': this.apiKey, - 'anthropic-version': '2023-06-01' - }, + headers, body: JSON.stringify({ model: 'claude-haiku-4-5', max_tokens: 1024, From 978c1b4dc081db3126f73f99d781d5bfd757e0b4 Mon Sep 17 00:00:00 2001 From: Lee Chapman Date: Sun, 1 Feb 2026 23:37:45 -0800 Subject: [PATCH 2/4] chore: add full stack k8s and cloudflare config to repo - Add API deployments, HTTPRoute, Gateway, tunnel manifests - Add CLOUDFLARE_TUNNEL.md, api-secrets-example, gateway-tunnel-service - Add cloudflare/ config (Caddy, docker-compose, setup-tunnel) - Update k8s README for full stack --- cloudflare/Caddyfile | 21 ++ cloudflare/README.md | 232 ++++++++++++++++++++ cloudflare/config-caddy.yml | 12 + cloudflare/config.yml.example | 16 ++ cloudflare/docker-compose.yml | 11 + cloudflare/setup-tunnel.sh | 72 ++++++ k8s/CLOUDFLARE_TUNNEL.md | 85 +++++++ k8s/README.md | 35 +-- k8s/api-deployment-blue.yaml | 104 +++++++++ k8s/api-deployment-green.yaml | 104 +++++++++ k8s/api-pdb.yaml | 12 + k8s/api-secrets-example.yaml | 25 +++ k8s/api-service.yaml | 20 ++ k8s/cloudflared-tunnel-deployment.yaml | 52 +++++ k8s/cloudflared-tunnel-secrets-example.yaml | 17 ++ k8s/gateway-certificate.yaml | 18 ++ k8s/gateway-lb.yaml | 17 ++ k8s/gateway-tunnel-service.yaml | 18 ++ k8s/gateway.yaml | 35 +++ k8s/gatewayclass.yaml | 6 + k8s/httproute.yaml | 54 +++++ k8s/mcp-referencegrant.yaml | 14 ++ k8s/referencegrant.yaml | 14 ++ 23 files changed, 980 insertions(+), 14 deletions(-) create mode 100644 cloudflare/Caddyfile create mode 100644 cloudflare/README.md create mode 100644 cloudflare/config-caddy.yml create mode 100644 cloudflare/config.yml.example create mode 100644 cloudflare/docker-compose.yml create mode 100755 cloudflare/setup-tunnel.sh create mode 100644 k8s/CLOUDFLARE_TUNNEL.md create mode 100644 k8s/api-deployment-blue.yaml create mode 100644 k8s/api-deployment-green.yaml create mode 100644 k8s/api-pdb.yaml create mode 100644 k8s/api-secrets-example.yaml create mode 100644 k8s/api-service.yaml create mode 100644 k8s/cloudflared-tunnel-deployment.yaml create mode 100644 k8s/cloudflared-tunnel-secrets-example.yaml create mode 100644 k8s/gateway-certificate.yaml create mode 100644 k8s/gateway-lb.yaml create mode 100644 k8s/gateway-tunnel-service.yaml create mode 100644 k8s/gateway.yaml create mode 100644 k8s/gatewayclass.yaml create mode 100644 k8s/httproute.yaml create mode 100644 k8s/mcp-referencegrant.yaml create mode 100644 k8s/referencegrant.yaml diff --git a/cloudflare/Caddyfile b/cloudflare/Caddyfile new file mode 100644 index 0000000..6672bc4 --- /dev/null +++ b/cloudflare/Caddyfile @@ -0,0 +1,21 @@ +# Caddy reverse proxy for local High Command dev stack +# Matches k8s Gateway routing: / -> UI, /api -> API, /mcp -> MCP (with path rewrite) +# Run: caddy run --config Caddyfile +# Then: cloudflared tunnel --url http://localhost:8080 + +:8080 { + # API - pass through as-is + handle /api/* { + reverse_proxy localhost:5000 + } + + # MCP - handle_path strips /mcp prefix, so /mcp/messages -> /messages (matches MCP server) + handle_path /mcp/* { + reverse_proxy localhost:8000 + } + + # UI - everything else + handle { + reverse_proxy localhost:3000 + } +} diff --git a/cloudflare/README.md b/cloudflare/README.md new file mode 100644 index 0000000..a78f764 --- /dev/null +++ b/cloudflare/README.md @@ -0,0 +1,232 @@ +# Cloudflare Tunnel Setup for High Command + +This guide covers exposing the High Command site via Cloudflare Tunnel (cloudflared), so you can access it over the internet without opening firewall ports or exposing your origin IP. + +## Architecture + +High Command uses path-based routing (same as the k8s Gateway): + +| Path | Service | Local Port | +|--------|---------|------------| +| `/` | UI | 3000 | +| `/api` | API | 5000 | +| `/mcp` | MCP | 8000 | + +Cloudflare Tunnel supports path-based ingress rules, so we route traffic accordingly. + +## Prerequisites + +- **cloudflared** installed ([download](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/)) +- For custom domain (`hc.dataknife.ai`): domain on Cloudflare, nameservers pointed to Cloudflare +- Local services running: UI (3000), API (5000), MCP (8000) + +### Install cloudflared (Arch Linux) + +```bash +sudo pacman -S cloudflared +``` + +### Install cloudflared (Debian/Ubuntu) + +```bash +sudo mkdir -p /usr/share/keyrings +curl -fsSL https://pkg.cloudflare.com/cloudflare-public-v2.gpg | sudo tee /usr/share/keyrings/cloudflare-public-2.gpg > /dev/null +echo "deb [signed-by=/usr/share/keyrings/cloudflare-public-2.gpg] https://pkg.cloudflare.com/cloudflared any main" | sudo tee /etc/apt/sources.list.d/cloudflared.list +sudo apt-get update && sudo apt-get install cloudflared +``` + +--- + +## Option 1: Quick Tunnel (No Account, Dev/Testing) + +For a fast shareable URL without Cloudflare account or domain setup: + +```bash +# Start your local stack first, then: +cloudflared tunnel --url http://localhost:3000 +``` + +This gives you a random `*.trycloudflare.com` URL. **Limitations:** + +- UI only (no `/api` or `/mcp` routing) +- 200 concurrent request limit +- No SSE support (affects MCP chat) +- No custom domain + +--- + +## Option 2: Named Tunnel with Custom Domain + +For production use with `hc.dataknife.ai`: + +### 1. Authenticate + +```bash +cloudflared tunnel login +``` + +This opens a browser to select your Cloudflare account and zone. A `cert.pem` is saved to `~/.cloudflared/`. + +### 2. Create the Tunnel + +```bash +cloudflared tunnel create high-command +``` + +Note the tunnel UUID from the output. Credentials are saved to `~/.cloudflared/.json`. + +### 3. Configure DNS + +Route your hostname to the tunnel: + +```bash +cloudflared tunnel route dns high-command hc.dataknife.ai +``` + +This creates a CNAME record: `hc.dataknife.ai` → `.cfargotunnel.com`. + +### 4. Start Local Services + +Ensure all three services are running: + +```bash +# Terminal 1: API +cd high-command-api && make run # or: uvicorn src.app_readonly:app --host 0.0.0.0 --port 5000 + +# Terminal 2: MCP +cd high-command-mcp && MCP_TRANSPORT=http make run # or: python -m highcommand.server + +# Terminal 3: UI +cd high-command-ui && npm run dev # Vite dev server on 3000 +``` + +### 5. Generate Config and Run + +Run the setup script to generate `config.yml` from the example (replaces tunnel UUID and paths): + +```bash +./cloudflare/setup-tunnel.sh +``` + +Or manually: copy `config.yml.example` to `config.yml`, replace `TUNNEL_UUID` with your tunnel ID, and `YOUR_USERNAME` with your home path. + +Then run the tunnel: + +```bash +cloudflared tunnel --config cloudflare/config.yml run high-command +``` + +--- + +## Option 3: Tunnel to Kubernetes Gateway + +If High Command is already running in k8s with a LoadBalancer (e.g. `192.168.14.184` for `hc.dataknife.ai`), you can tunnel to that instead of localhost: + +1. Copy `config.yml` to `config-k8s.yml` +2. Replace `localhost` with your gateway IP/hostname in each `service` URL +3. Run: `cloudflared tunnel --config cloudflare/config-k8s.yml run high-command` + +This is useful when the tunnel runs on a different machine than the k8s cluster. + +--- + +## MCP Path Rewrite + +The k8s Gateway rewrites `/mcp/messages` → `/messages` for the MCP server. Cloudflared does not support path rewriting, so we have two choices: + +1. **Use the provided Caddy reverse proxy** (recommended for local dev): Caddy handles path rewrite and routing; cloudflared tunnels to Caddy. +2. **Tunnel directly with path rules**: The MCP server may accept `/mcp/messages` if configured. Check `high-command-mcp` for path handling. + +The `config.yml` uses direct path-based routing. If MCP fails, use the Caddy setup below. + +--- + +## Using Caddy as Local Reverse Proxy (Optional) + +For full k8s-like routing including MCP path rewrite, run Caddy in front: + +```bash +cd cloudflare && caddy run --config Caddyfile +``` + +Then tunnel to Caddy only: + +```bash +# Quick tunnel (no config): +cloudflared tunnel --url http://localhost:8080 +``` + +For a named tunnel with `config-caddy.yml`, replace `TUNNEL_UUID` and `YOUR_USERNAME` as in the main config, or run `setup-tunnel.sh` and manually edit the service URL to `http://localhost:8080`. + +--- + +## Option 4: Docker with Token (Remote Tunnel) + +For tunnels created in the [Cloudflare Zero Trust dashboard](https://one.dash.cloudflare.com/) (Networks → Tunnels), use the one-time token from the connector setup: + +```bash +docker run cloudflare/cloudflared:latest tunnel --no-autoupdate run --token +``` + +The token embeds tunnel credentials and routing. Ingress rules are configured in the dashboard, not in a local config file. Use `host.docker.internal` (or `host-gateway` on Linux) to reach services on the host: + +- **macOS/Windows**: `http://host.docker.internal:3000` for UI +- **Linux**: add `--add-host=host.docker.internal:host-gateway` and use `http://host.docker.internal:3000` + +Or use the host network when running on the same machine: + +```bash +docker run --network host cloudflare/cloudflared:latest tunnel --no-autoupdate run --token +``` + +With docker-compose (uses host network to reach localhost services): + +```bash +CLOUDFLARED_TOKEN=eyJ... docker compose -f cloudflare/docker-compose.yml up -d +``` + +Or create `cloudflare/.env` with `CLOUDFLARED_TOKEN=...` (add to `.gitignore`). + +--- + +## Run as a Service (Linux) + +```bash +sudo cloudflared service install +``` + +Config path is typically `/etc/cloudflared/config.yml`. Copy your config there and ensure `credentials-file` uses an absolute path. + +--- + +## Troubleshooting + +### Connectivity pre-checks + +```bash +cloudflared tunnel connectivity precheck +``` + +### Validate config + +```bash +cloudflared tunnel ingress validate +``` + +### Test which rule matches a URL + +```bash +cloudflared tunnel ingress rule https://hc.dataknife.ai/api/health +``` + +### Quick tunnel fails with "config exists" + +Rename or remove `~/.cloudflared/config.yml`; quick tunnels don't work when a config file exists. + +--- + +## Security Notes + +- **Cloudflare Access**: Consider protecting the tunnel with [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/policies/access/) (e.g. email OTP, GitHub) for non-public demos. +- **Origin validation**: Enable [application token validation](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/) so requests bypassing Access are rejected. +- **Secrets**: Never commit `cert.pem` or `*.json` credentials; they're in `.gitignore`. diff --git a/cloudflare/config-caddy.yml b/cloudflare/config-caddy.yml new file mode 100644 index 0000000..5deafff --- /dev/null +++ b/cloudflare/config-caddy.yml @@ -0,0 +1,12 @@ +# Cloudflare Tunnel config when using Caddy as reverse proxy +# Single entry point at localhost:8080 - Caddy handles /api, /mcp, / routing +# Run: caddy run --config Caddyfile (in another terminal) +# Then: cloudflared tunnel --config config-caddy.yml run high-command + +tunnel: TUNNEL_UUID +credentials-file: /home/YOUR_USERNAME/.cloudflared/TUNNEL_UUID.json + +ingress: + - hostname: hc.dataknife.ai + service: http://localhost:8080 + - service: http_status:404 diff --git a/cloudflare/config.yml.example b/cloudflare/config.yml.example new file mode 100644 index 0000000..e4eb777 --- /dev/null +++ b/cloudflare/config.yml.example @@ -0,0 +1,16 @@ +# Cloudflare Tunnel config for High Command +# Copy to config.yml and run setup-tunnel.sh, or replace placeholders manually + +tunnel: TUNNEL_UUID +credentials-file: /home/YOUR_USERNAME/.cloudflared/TUNNEL_UUID.json + +ingress: + - hostname: hc.dataknife.ai + path: /api/* + service: http://localhost:5000 + - hostname: hc.dataknife.ai + path: /mcp/* + service: http://localhost:8000 + - hostname: hc.dataknife.ai + service: http://localhost:3000 + - service: http_status:404 diff --git a/cloudflare/docker-compose.yml b/cloudflare/docker-compose.yml new file mode 100644 index 0000000..8f477d6 --- /dev/null +++ b/cloudflare/docker-compose.yml @@ -0,0 +1,11 @@ +# Cloudflare Tunnel via Docker (token-based, remote tunnel from Zero Trust dashboard) +# Set CLOUDFLARED_TOKEN or pass --token when creating the tunnel +# Ingress is configured in Cloudflare dashboard, not in local config + +services: + cloudflared: + image: cloudflare/cloudflared:latest + command: tunnel --no-autoupdate run --token ${CLOUDFLARED_TOKEN} + # Use host network so tunnel can reach localhost:3000, :5000, :8000 + network_mode: host + restart: unless-stopped diff --git a/cloudflare/setup-tunnel.sh b/cloudflare/setup-tunnel.sh new file mode 100755 index 0000000..b37162e --- /dev/null +++ b/cloudflare/setup-tunnel.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# One-time setup for High Command Cloudflare Tunnel +# Run from project root: ./cloudflare/setup-tunnel.sh + +set -e + +TUNNEL_NAME="high-command" +CONFIG_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLOUDFLARED_DIR="${HOME}/.cloudflared" + +echo "=== High Command Cloudflare Tunnel Setup ===" +echo + +# Check cloudflared +if ! command -v cloudflared &>/dev/null; then + echo "cloudflared not found. Install it first:" + echo " Arch: sudo pacman -S cloudflared" + echo " Debian: see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/" + exit 1 +fi + +# Login if no cert +if [[ ! -f "${CLOUDFLARED_DIR}/cert.pem" ]]; then + echo "Authenticating with Cloudflare..." + cloudflared tunnel login +fi + +# Create tunnel if it doesn't exist +if ! cloudflared tunnel list 2>/dev/null | grep -q "${TUNNEL_NAME}"; then + echo "Creating tunnel: ${TUNNEL_NAME}" + cloudflared tunnel create "${TUNNEL_NAME}" +fi + +# Get tunnel ID +TUNNEL_ID=$(cloudflared tunnel list 2>/dev/null | grep "${TUNNEL_NAME}" | awk '{print $1}') +if [[ -z "${TUNNEL_ID}" ]]; then + echo "Could not find tunnel ID. Run: cloudflared tunnel list" + exit 1 +fi + +CREDS_FILE="${CLOUDFLARED_DIR}/${TUNNEL_ID}.json" +if [[ ! -f "${CREDS_FILE}" ]]; then + echo "Credentials file not found: ${CREDS_FILE}" + exit 1 +fi + +echo +echo "Tunnel ID: ${TUNNEL_ID}" +echo "Credentials: ${CREDS_FILE}" +echo + +# Generate config from template +CONFIG_FILE="${CONFIG_DIR}/config.yml" +sed -e "s/TUNNEL_UUID/${TUNNEL_ID}/g" \ + -e "s|/home/YOUR_USERNAME|${HOME}|g" \ + "${CONFIG_DIR}/config.yml.example" > "${CONFIG_FILE}" +echo "Generated: ${CONFIG_FILE}" + +# Route DNS (optional - may fail if zone not on Cloudflare) +echo +read -p "Route hc.dataknife.ai to this tunnel? (y/n) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + cloudflared tunnel route dns "${TUNNEL_NAME}" hc.dataknife.ai + echo "DNS route created." +fi + +echo +echo "=== Setup complete ===" +echo "Start services (UI:3000, API:5000, MCP:8000), then run:" +echo " cloudflared tunnel --config ${CONFIG_FILE} run ${TUNNEL_NAME}" +echo diff --git a/k8s/CLOUDFLARE_TUNNEL.md b/k8s/CLOUDFLARE_TUNNEL.md new file mode 100644 index 0000000..a5b83e2 --- /dev/null +++ b/k8s/CLOUDFLARE_TUNNEL.md @@ -0,0 +1,85 @@ +# Cloudflare Tunnel in Kubernetes + +Expose High Command via Cloudflare Tunnel without port forwarding. The tunnel runs as a pod in the cluster and proxies traffic to internal services. + +## Prerequisites + +- Tunnel created in [Cloudflare Zero Trust](https://one.dash.cloudflare.com/) → Networks → Tunnels +- Connector token from the tunnel setup (Docker install step) + +## Deploy + +### 1. Create the secret with your tunnel token + +```bash +kubectl create secret generic cloudflared-tunnel-credentials \ + --from-literal=token='eyJhIjoi...' \ + -n high-command +``` + +Use the token from the Docker command in the Cloudflare dashboard: +`docker run cloudflare/cloudflared:latest tunnel run --token ` + +### 2. Deploy the tunnel and gateway alias + +```bash +kubectl apply -f gateway-tunnel-service.yaml +kubectl apply -f cloudflared-tunnel-deployment.yaml +``` + +### 3. Configure ingress in Cloudflare dashboard + +Route all traffic to the Envoy Gateway — it already handles path routing (`/api`, `/mcp`, `/`) and MCP path rewrite via the HTTPRoute. + +In Zero Trust → Networks → Tunnels → your tunnel → Public Hostname: + +| Public hostname | Path | Service | URL | +|-----------------|------|---------|-----| +| `hc.dataknife.ai` | `/` (or leave empty) | HTTPS | `https://high-command-gateway.high-command.svc.cluster.local:443` | + +**Additional application settings** (expand the section when adding the route): + +- **Origin Server Name**: `hc.dataknife.ai` — The Gateway's listener matches this hostname. Without it, cloudflared sends the internal k8s hostname as SNI and the Gateway resets the connection. +- **No TLS Verify**: Enable this. The Gateway's cert is for `hc.dataknife.ai`, but cloudflared connects to the internal k8s hostname — TLS verification would fail without it. + +## Verify + +```bash +kubectl get pods -n high-command -l app=cloudflared-tunnel +kubectl logs -n high-command -l app=cloudflared-tunnel -f +``` + +## Troubleshooting + +**Connection refused or TLS errors:** + +- Enable **No TLS Verify** in Additional application settings. +- Confirm the Gateway service name is correct (it may change if the Gateway is recreated). + +**API or MCP returns 404:** + +- The Gateway's HTTPRoute handles path routing. Verify the Gateway and HTTPRoute are healthy: `kubectl get gateway,httproute -n high-command`. + +**If the Envoy Gateway service was recreated** (e.g. after a Gateway update), the `high-command-gateway` ExternalName may point to a stale service. Update it: + +```bash +NEW_SVC=$(kubectl get svc -n envoy-gateway-system -o name | grep high-command | cut -d/ -f2) +kubectl patch svc high-command-gateway -n high-command -p "{\"spec\":{\"externalName\":\"${NEW_SVC}.envoy-gateway-system.svc.cluster.local\"}}" +``` + +## Image options + +**Default (Alpine)**: Uses `alpine:3.19` and installs cloudflared from Alpine edge/testing at startup. Works without building. + +**Custom (latest cloudflared)**: Build `cloudflare/Dockerfile` to get the official `cloudflare/cloudflared:latest` binary with a shell for token injection: +```bash +docker build -t harbor.dataknife.net/library/cloudflared-tunnel:latest cloudflare/ +docker push harbor.dataknife.net/library/cloudflared-tunnel:latest +``` +Then change the deployment image to `harbor.dataknife.net/library/cloudflared-tunnel:latest` and remove the `command` block. + +## Files + +- `cloudflared-tunnel-deployment.yaml` - Deployment (token from secret, `--no-autoupdate`) +- `cloudflared-tunnel-secrets-example.yaml` - Secret template (do not commit real token) +- `gateway-tunnel-service.yaml` - Stable alias to Envoy Gateway for tunnel routing diff --git a/k8s/README.md b/k8s/README.md index 7266ea4..68b1609 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -1,8 +1,8 @@ # Kubernetes Deployment Files -This directory contains Kubernetes manifests for deploying the High Command UI. +This directory contains Kubernetes manifests for deploying the High Command stack (UI, API, MCP, Gateway, Cloudflare Tunnel). -## Files +## UI Files - `ui-deployment-blue.yaml` - Blue deployment (active version) - `ui-deployment-green.yaml` - Green deployment (standby version) @@ -10,28 +10,35 @@ This directory contains Kubernetes manifests for deploying the High Command UI. - `ui-pdb.yaml` - Pod Disruption Budget for availability - `ui-ingress.yaml` - Ingress configuration for external access +## Full Stack Files + +- `api-deployment-blue.yaml`, `api-deployment-green.yaml` - API deployments +- `api-service.yaml`, `api-pdb.yaml` - API service +- `httproute.yaml` - Gateway API HTTPRoute (/api, /claude, /mcp, /) +- `gateway.yaml`, `gatewayclass.yaml`, `gateway-certificate.yaml` - Envoy Gateway +- `gateway-tunnel-service.yaml` - Alias for Cloudflare Tunnel → Gateway +- `cloudflared-tunnel-deployment.yaml` - Cloudflare Tunnel pod +- `mcp-service.yaml`, `mcp-referencegrant.yaml`, `referencegrant.yaml` - MCP routing + +See `CLOUDFLARE_TUNNEL.md` for tunnel setup. + ## Secrets **No secrets are stored in these files.** -If you need to use Claude integration, add the API key as a Kubernetes Secret: +**Claude (API key in backend):** Store the key in the API secret so the UI never sees it: ```bash -kubectl create secret generic high-command-ui-secrets \ - --from-literal=vite-claude-api-key='your-api-key-here' \ +kubectl create secret generic high-command-api-secrets \ + --from-literal=claude-api-key='sk-ant-api03-...' \ -n high-command ``` -Then update the deployments to reference the secret: +The API proxies `/claude/*` to Anthropic and adds the key server-side. -```yaml -env: -- name: VITE_CLAUDE_API_KEY - valueFrom: - secretKeyRef: - name: high-command-ui-secrets - key: vite-claude-api-key -``` +## Cloudflare + +The `../cloudflare/` folder contains the tunnel Dockerfile and local dev config (Caddy, docker-compose). GitLab CI builds the cloudflared-tunnel image from `cloudflare/Dockerfile`. ## Environment Variables diff --git a/k8s/api-deployment-blue.yaml b/k8s/api-deployment-blue.yaml new file mode 100644 index 0000000..b873cc1 --- /dev/null +++ b/k8s/api-deployment-blue.yaml @@ -0,0 +1,104 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: high-command-api-blue + namespace: high-command + labels: + app: high-command-api + version: blue +spec: + replicas: 3 + selector: + matchLabels: + app: high-command-api + version: blue + template: + metadata: + labels: + app: high-command-api + version: blue + spec: + containers: + - name: api + image: harbor.dataknife.net/library/high-command-api:v0.11 + imagePullPolicy: Always + env: + - name: MODE + value: "api" + - name: PYTHONUNBUFFERED + value: "1" + - name: LOG_LEVEL + value: "INFO" + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: high-command-api-secrets + key: database-url + - name: PORT + value: "5000" + - name: HOST + value: "0.0.0.0" + - name: HELLDIVERS_API_BASE + value: "https://api.helldivers2.dev/api/v1" + - name: HELLDIVERS_API_CLIENT_NAME + value: "High Command" + - name: HELLDIVERS_API_CONTACT + value: "lee@fullmetal.dev" + - name: CLAUDE_API_KEY + valueFrom: + secretKeyRef: + name: high-command-api-secrets + key: claude-api-key + optional: true + ports: + - name: http + containerPort: 5000 + protocol: TCP + livenessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 2 + resources: + requests: + cpu: "200m" + memory: "256Mi" + limits: + cpu: "1000m" + memory: "1Gi" + securityContext: + runAsNonRoot: true + runAsUser: 1000 + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: app + operator: In + values: + - high-command-api + topologyKey: kubernetes.io/hostname diff --git a/k8s/api-deployment-green.yaml b/k8s/api-deployment-green.yaml new file mode 100644 index 0000000..a80ba1c --- /dev/null +++ b/k8s/api-deployment-green.yaml @@ -0,0 +1,104 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: high-command-api-green + namespace: high-command + labels: + app: high-command-api + version: green +spec: + replicas: 3 + selector: + matchLabels: + app: high-command-api + version: green + template: + metadata: + labels: + app: high-command-api + version: green + spec: + containers: + - name: api + image: harbor.dataknife.net/library/high-command-api:v0.11 + imagePullPolicy: Always + env: + - name: MODE + value: "api" + - name: PYTHONUNBUFFERED + value: "1" + - name: LOG_LEVEL + value: "INFO" + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: high-command-api-secrets + key: database-url + - name: PORT + value: "5000" + - name: HOST + value: "0.0.0.0" + - name: HELLDIVERS_API_BASE + value: "https://api.helldivers2.dev/api/v1" + - name: HELLDIVERS_API_CLIENT_NAME + value: "High Command" + - name: HELLDIVERS_API_CONTACT + value: "lee@fullmetal.dev" + - name: CLAUDE_API_KEY + valueFrom: + secretKeyRef: + name: high-command-api-secrets + key: claude-api-key + optional: true + ports: + - name: http + containerPort: 5000 + protocol: TCP + livenessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 2 + resources: + requests: + cpu: "200m" + memory: "256Mi" + limits: + cpu: "1000m" + memory: "1Gi" + securityContext: + runAsNonRoot: true + runAsUser: 1000 + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: app + operator: In + values: + - high-command-api + topologyKey: kubernetes.io/hostname diff --git a/k8s/api-pdb.yaml b/k8s/api-pdb.yaml new file mode 100644 index 0000000..f271186 --- /dev/null +++ b/k8s/api-pdb.yaml @@ -0,0 +1,12 @@ +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: high-command-api-pdb + namespace: high-command + labels: + app: high-command-api +spec: + minAvailable: 2 + selector: + matchLabels: + app: high-command-api diff --git a/k8s/api-secrets-example.yaml b/k8s/api-secrets-example.yaml new file mode 100644 index 0000000..7d51492 --- /dev/null +++ b/k8s/api-secrets-example.yaml @@ -0,0 +1,25 @@ +# Claude API key for UI integration (optional) +# When set, the API proxies /claude/* to Anthropic and adds the key server-side. +# The key is never exposed to the browser. +# +# Create the secret (replace with your key from https://console.anthropic.com): +# kubectl create secret generic high-command-api-secrets \ +# --from-literal=claude-api-key='sk-ant-api03-...' \ +# -n high-command +# +# Restart API pods to pick up the secret: +# kubectl rollout restart deployment/high-command-api-blue -n high-command +# kubectl rollout restart deployment/high-command-api-green -n high-command +# +# If not using Claude, create an empty secret so the deployment succeeds: +# kubectl create secret generic high-command-api-secrets \ +# --from-literal=claude-api-key='' \ +# -n high-command +apiVersion: v1 +kind: Secret +metadata: + name: high-command-api-secrets + namespace: high-command +type: Opaque +stringData: + # claude-api-key: "sk-ant-api03-..." diff --git a/k8s/api-service.yaml b/k8s/api-service.yaml new file mode 100644 index 0000000..99e2e59 --- /dev/null +++ b/k8s/api-service.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Service +metadata: + name: high-command-api + namespace: high-command + labels: + app: high-command-api + annotations: + deployment.kubernetes.io/active-version: "green" +spec: + type: ClusterIP + selector: + app: high-command-api + version: green + ports: + - name: http + port: 80 + targetPort: http + protocol: TCP + sessionAffinity: None diff --git a/k8s/cloudflared-tunnel-deployment.yaml b/k8s/cloudflared-tunnel-deployment.yaml new file mode 100644 index 0000000..3b4e22a --- /dev/null +++ b/k8s/cloudflared-tunnel-deployment.yaml @@ -0,0 +1,52 @@ +# Cloudflare Tunnel - translates: +# docker run cloudflare/cloudflared:latest tunnel --no-autoupdate run --token +# +# Uses wrapper image (cloudflare/cloudflared + alpine shell) for token injection. +# Build: docker build -t harbor.dataknife.net/library/cloudflared-tunnel:latest cloudflare/ +# +# Token from secret (kubectl create secret generic cloudflared-tunnel-credentials +# --from-literal=token='' -n high-command) +# Ingress configured in Cloudflare Zero Trust dashboard +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cloudflared-tunnel + namespace: high-command + labels: + app: cloudflared-tunnel +spec: + replicas: 1 + selector: + matchLabels: + app: cloudflared-tunnel + template: + metadata: + labels: + app: cloudflared-tunnel + spec: + imagePullSecrets: + - name: harbor-registry-secret + containers: + - name: cloudflared + image: harbor.dataknife.net/library/cloudflared-tunnel:latest + imagePullPolicy: Always + env: + - name: TUNNEL_TOKEN + valueFrom: + secretKeyRef: + name: cloudflared-tunnel-credentials + key: token + resources: + requests: + cpu: "50m" + memory: "64Mi" + limits: + cpu: "200m" + memory: "128Mi" + securityContext: + runAsNonRoot: true + runAsUser: 65532 + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL diff --git a/k8s/cloudflared-tunnel-secrets-example.yaml b/k8s/cloudflared-tunnel-secrets-example.yaml new file mode 100644 index 0000000..4d7830f --- /dev/null +++ b/k8s/cloudflared-tunnel-secrets-example.yaml @@ -0,0 +1,17 @@ +# Cloudflare Tunnel credentials (token-based, from Zero Trust dashboard) +# Create with: kubectl create secret generic cloudflared-tunnel-credentials \ +# --from-literal=token='YOUR_TUNNEL_TOKEN' \ +# -n high-command +# +# Get the token from: Cloudflare Zero Trust → Networks → Tunnels → Create tunnel → Install connector +# The token is the value from: docker run cloudflare/cloudflared:latest tunnel run --token +--- +# Example structure (DO NOT commit real values): +apiVersion: v1 +kind: Secret +metadata: + name: cloudflared-tunnel-credentials + namespace: high-command +type: Opaque +stringData: + token: "REPLACE_WITH_YOUR_TUNNEL_TOKEN" diff --git a/k8s/gateway-certificate.yaml b/k8s/gateway-certificate.yaml new file mode 100644 index 0000000..6af274e --- /dev/null +++ b/k8s/gateway-certificate.yaml @@ -0,0 +1,18 @@ +# Certificate for High Command Gateway TLS (hc.dataknife.ai) +# cert-manager creates this secret and auto-renews via Let's Encrypt DNS-01 +# The Gateway references this secret in certificateRefs +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: high-command-gateway-https-dataknife-ai + namespace: high-command + labels: + app: high-command +spec: + secretName: high-command-gateway-https-dataknife-ai + issuerRef: + name: letsencrypt-dns01 + kind: ClusterIssuer + group: cert-manager.io + dnsNames: + - hc.dataknife.ai diff --git a/k8s/gateway-lb.yaml b/k8s/gateway-lb.yaml new file mode 100644 index 0000000..a0d8a17 --- /dev/null +++ b/k8s/gateway-lb.yaml @@ -0,0 +1,17 @@ +# Note: Envoy Gateway automatically creates services for Gateways +# This file documents the expected service configuration. +# The Envoy Gateway service will be created automatically when the Gateway resource is deployed. +# +# To configure MetalLB for the Gateway service, ensure: +# 1. MetalLB is installed in the metallb-system namespace +# 2. IPAddressPool and L2Advertisement are applied (see metallb-address-pool.yaml) +# 3. The Gateway resource has MetalLB annotations (see gateway.yaml) +# +# The Envoy Gateway controller service will be named something like: +# - envoy-gateway-system/gateway-envoy--- +# +# You may need to patch the automatically created service with MetalLB annotations: +# kubectl annotate service \ +# -n envoy-gateway-system \ +# metallb.universe.tf/address-pool=dataknife-ai-pool \ +# metallb.universe.tf/loadBalancerIPs=192.168.18.10 diff --git a/k8s/gateway-tunnel-service.yaml b/k8s/gateway-tunnel-service.yaml new file mode 100644 index 0000000..77d0f19 --- /dev/null +++ b/k8s/gateway-tunnel-service.yaml @@ -0,0 +1,18 @@ +# Stable alias for Cloudflare Tunnel to reach the Envoy Gateway. +# The tunnel uses this instead of the auto-generated Envoy service name (which includes a hash). +# If the Gateway is recreated and the Envoy service name changes, update externalName: +# kubectl get svc -n envoy-gateway-system | grep high-command +apiVersion: v1 +kind: Service +metadata: + name: high-command-gateway + namespace: high-command + labels: + app: high-command +spec: + type: ExternalName + externalName: envoy-high-command-high-command-gateway-dfdb1d4f.envoy-gateway-system.svc.cluster.local + ports: + - name: https + port: 443 + protocol: TCP diff --git a/k8s/gateway.yaml b/k8s/gateway.yaml new file mode 100644 index 0000000..0244dab --- /dev/null +++ b/k8s/gateway.yaml @@ -0,0 +1,35 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: high-command-gateway + namespace: high-command + labels: + app: high-command + annotations: + # Kube-VIP load balancer configuration for .ai domain + # IP 192.168.14.184 is assigned for hc.dataknife.ai + # Note: Kube-VIP annotation goes on the LoadBalancer service, not the Gateway + # The service is created by Envoy Gateway and will be annotated separately + # cert-manager: Certificate is managed by gateway-certificate.yaml (explicit Certificate) + # Uses letsencrypt-dns01 ClusterIssuer for auto-renewal + cert-manager.io/cluster-issuer: letsencrypt-dns01 +spec: + gatewayClassName: envoy + listeners: + # HTTPS listener for hc.dataknife.ai (dedicated cert via gateway-certificate.yaml) + # Internal traffic uses port 80, external uses port 443 via WAN port forward + - name: https-dataknife-ai + protocol: HTTPS + port: 443 + hostname: hc.dataknife.ai + tls: + mode: Terminate + # certificateRefs must be present, but cert-manager gateway-shim will manage the Secret + # cert-manager will create/update this Secret automatically based on the annotation + # Secret name pattern: - + # Note: namespace omitted when Secret is in same namespace as Gateway (per cert-manager docs) + certificateRefs: + - name: high-command-gateway-https-dataknife-ai + allowedRoutes: + namespaces: + from: Same diff --git a/k8s/gatewayclass.yaml b/k8s/gatewayclass.yaml new file mode 100644 index 0000000..016605a --- /dev/null +++ b/k8s/gatewayclass.yaml @@ -0,0 +1,6 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: GatewayClass +metadata: + name: envoy +spec: + controllerName: gateway.envoyproxy.io/gatewayclass-controller diff --git a/k8s/httproute.yaml b/k8s/httproute.yaml new file mode 100644 index 0000000..edbc1f7 --- /dev/null +++ b/k8s/httproute.yaml @@ -0,0 +1,54 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: high-command-httproute + namespace: high-command + labels: + app: high-command +spec: + parentRefs: + - name: high-command-gateway + sectionName: https-dataknife-ai + hostnames: + - hc.dataknife.ai + rules: + # Route /api and /claude to API service + - matches: + - path: + type: PathPrefix + value: /api + - path: + type: PathPrefix + value: /claude + backendRefs: + - name: high-command-api + port: 80 + weight: 100 + + # Route /mcp to MCP service with path rewrite + # Rewrite /mcp/messages -> /messages to match MCP server expectations + - matches: + - path: + type: PathPrefix + value: /mcp + filters: + - type: URLRewrite + urlRewrite: + path: + type: ReplacePrefixMatch + replacePrefixMatch: "" + backendRefs: + - name: high-command-mcp + namespace: mcp-servers + port: 8000 + weight: 100 + + # Route / to UI service (catch-all) + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: high-command-ui + port: 80 + weight: 100 diff --git a/k8s/mcp-referencegrant.yaml b/k8s/mcp-referencegrant.yaml new file mode 100644 index 0000000..50c8df6 --- /dev/null +++ b/k8s/mcp-referencegrant.yaml @@ -0,0 +1,14 @@ +apiVersion: gateway.networking.k8s.io/v1beta1 +kind: ReferenceGrant +metadata: + name: allow-high-command-mcp-access + namespace: mcp-servers +spec: + from: + - group: gateway.networking.k8s.io + kind: HTTPRoute + namespace: high-command + to: + - group: "" + kind: Service + name: high-command-mcp diff --git a/k8s/referencegrant.yaml b/k8s/referencegrant.yaml new file mode 100644 index 0000000..fc19137 --- /dev/null +++ b/k8s/referencegrant.yaml @@ -0,0 +1,14 @@ +apiVersion: gateway.networking.k8s.io/v1beta1 +kind: ReferenceGrant +metadata: + name: allow-cert-manager-secrets + namespace: cert-manager +spec: + from: + - group: gateway.networking.k8s.io + kind: Gateway + namespace: high-command + to: + - group: "" + kind: Secret + name: wildcard-dataknife-ai-tls From 714ce5797f1ff47d706e6809c72bfdb9ac14023b Mon Sep 17 00:00:00 2001 From: Lee Chapman Date: Sun, 1 Feb 2026 23:40:18 -0800 Subject: [PATCH 3/4] fix: remove hardcoded DATABASE_URL from API deployments Use secretKeyRef for DATABASE_URL instead of inline credentials. Rotate the exposed PostgreSQL password. --- k8s/api-secrets-example.yaml | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/k8s/api-secrets-example.yaml b/k8s/api-secrets-example.yaml index 7d51492..32d8b47 100644 --- a/k8s/api-secrets-example.yaml +++ b/k8s/api-secrets-example.yaml @@ -1,20 +1,18 @@ -# Claude API key for UI integration (optional) -# When set, the API proxies /claude/* to Anthropic and adds the key server-side. -# The key is never exposed to the browser. +# API secrets - DATABASE_URL required, claude-api-key optional +# Never commit real values. Create with kubectl: # -# Create the secret (replace with your key from https://console.anthropic.com): # kubectl create secret generic high-command-api-secrets \ +# --from-literal=database-url='postgresql://user:password@high-command-postgres-rw.high-command.svc.cluster.local:5432/highcommand' \ # --from-literal=claude-api-key='sk-ant-api03-...' \ # -n high-command # +# DATABASE_URL: Required. PostgreSQL connection string. +# claude-api-key: Optional. For Claude UI integration via backend proxy. +# # Restart API pods to pick up the secret: # kubectl rollout restart deployment/high-command-api-blue -n high-command # kubectl rollout restart deployment/high-command-api-green -n high-command # -# If not using Claude, create an empty secret so the deployment succeeds: -# kubectl create secret generic high-command-api-secrets \ -# --from-literal=claude-api-key='' \ -# -n high-command apiVersion: v1 kind: Secret metadata: @@ -22,4 +20,5 @@ metadata: namespace: high-command type: Opaque stringData: - # claude-api-key: "sk-ant-api03-..." + # database-url: "postgresql://user:password@host:5432/dbname" # REQUIRED + # claude-api-key: "sk-ant-api03-..." # Optional, for Claude UI From 742604de5fa34ad85784f86d72eb884ff1b2e0a7 Mon Sep 17 00:00:00 2001 From: Lee Chapman Date: Sun, 1 Feb 2026 23:46:18 -0800 Subject: [PATCH 4/4] chore: remove unused k8s and cloudflare files - Remove ui-ingress.yaml (Gateway handles ingress, not nginx) - Remove gateway-lb.yaml (doc only, Gateway uses Kube-VIP) - Remove cloudflare local dev files (config, Caddy, docker-compose, setup-tunnel) - Simplify cloudflare/README, k8s/README, CLOUDFLARE_TUNNEL.md --- cloudflare/Caddyfile | 21 --- cloudflare/README.md | 233 +--------------------------------- cloudflare/config-caddy.yml | 12 -- cloudflare/config.yml.example | 16 --- cloudflare/docker-compose.yml | 11 -- cloudflare/setup-tunnel.sh | 72 ----------- k8s/CLOUDFLARE_TUNNEL.md | 6 +- k8s/README.md | 19 +-- k8s/gateway-lb.yaml | 17 --- k8s/ui-ingress.yaml | 70 ---------- 10 files changed, 17 insertions(+), 460 deletions(-) delete mode 100644 cloudflare/Caddyfile delete mode 100644 cloudflare/config-caddy.yml delete mode 100644 cloudflare/config.yml.example delete mode 100644 cloudflare/docker-compose.yml delete mode 100755 cloudflare/setup-tunnel.sh delete mode 100644 k8s/gateway-lb.yaml delete mode 100644 k8s/ui-ingress.yaml diff --git a/cloudflare/Caddyfile b/cloudflare/Caddyfile deleted file mode 100644 index 6672bc4..0000000 --- a/cloudflare/Caddyfile +++ /dev/null @@ -1,21 +0,0 @@ -# Caddy reverse proxy for local High Command dev stack -# Matches k8s Gateway routing: / -> UI, /api -> API, /mcp -> MCP (with path rewrite) -# Run: caddy run --config Caddyfile -# Then: cloudflared tunnel --url http://localhost:8080 - -:8080 { - # API - pass through as-is - handle /api/* { - reverse_proxy localhost:5000 - } - - # MCP - handle_path strips /mcp prefix, so /mcp/messages -> /messages (matches MCP server) - handle_path /mcp/* { - reverse_proxy localhost:8000 - } - - # UI - everything else - handle { - reverse_proxy localhost:3000 - } -} diff --git a/cloudflare/README.md b/cloudflare/README.md index a78f764..7e2252a 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -1,232 +1,7 @@ -# Cloudflare Tunnel Setup for High Command +# Cloudflare Tunnel Image -This guide covers exposing the High Command site via Cloudflare Tunnel (cloudflared), so you can access it over the internet without opening firewall ports or exposing your origin IP. +Builds the cloudflared image for Kubernetes deployment. The official `cloudflare/cloudflared` image is distroless (no shell), so we use Alpine + shell to inject the tunnel token from a k8s secret at runtime. -## Architecture +**Deployment:** See `../k8s/CLOUDFLARE_TUNNEL.md` -High Command uses path-based routing (same as the k8s Gateway): - -| Path | Service | Local Port | -|--------|---------|------------| -| `/` | UI | 3000 | -| `/api` | API | 5000 | -| `/mcp` | MCP | 8000 | - -Cloudflare Tunnel supports path-based ingress rules, so we route traffic accordingly. - -## Prerequisites - -- **cloudflared** installed ([download](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/)) -- For custom domain (`hc.dataknife.ai`): domain on Cloudflare, nameservers pointed to Cloudflare -- Local services running: UI (3000), API (5000), MCP (8000) - -### Install cloudflared (Arch Linux) - -```bash -sudo pacman -S cloudflared -``` - -### Install cloudflared (Debian/Ubuntu) - -```bash -sudo mkdir -p /usr/share/keyrings -curl -fsSL https://pkg.cloudflare.com/cloudflare-public-v2.gpg | sudo tee /usr/share/keyrings/cloudflare-public-2.gpg > /dev/null -echo "deb [signed-by=/usr/share/keyrings/cloudflare-public-2.gpg] https://pkg.cloudflare.com/cloudflared any main" | sudo tee /etc/apt/sources.list.d/cloudflared.list -sudo apt-get update && sudo apt-get install cloudflared -``` - ---- - -## Option 1: Quick Tunnel (No Account, Dev/Testing) - -For a fast shareable URL without Cloudflare account or domain setup: - -```bash -# Start your local stack first, then: -cloudflared tunnel --url http://localhost:3000 -``` - -This gives you a random `*.trycloudflare.com` URL. **Limitations:** - -- UI only (no `/api` or `/mcp` routing) -- 200 concurrent request limit -- No SSE support (affects MCP chat) -- No custom domain - ---- - -## Option 2: Named Tunnel with Custom Domain - -For production use with `hc.dataknife.ai`: - -### 1. Authenticate - -```bash -cloudflared tunnel login -``` - -This opens a browser to select your Cloudflare account and zone. A `cert.pem` is saved to `~/.cloudflared/`. - -### 2. Create the Tunnel - -```bash -cloudflared tunnel create high-command -``` - -Note the tunnel UUID from the output. Credentials are saved to `~/.cloudflared/.json`. - -### 3. Configure DNS - -Route your hostname to the tunnel: - -```bash -cloudflared tunnel route dns high-command hc.dataknife.ai -``` - -This creates a CNAME record: `hc.dataknife.ai` → `.cfargotunnel.com`. - -### 4. Start Local Services - -Ensure all three services are running: - -```bash -# Terminal 1: API -cd high-command-api && make run # or: uvicorn src.app_readonly:app --host 0.0.0.0 --port 5000 - -# Terminal 2: MCP -cd high-command-mcp && MCP_TRANSPORT=http make run # or: python -m highcommand.server - -# Terminal 3: UI -cd high-command-ui && npm run dev # Vite dev server on 3000 -``` - -### 5. Generate Config and Run - -Run the setup script to generate `config.yml` from the example (replaces tunnel UUID and paths): - -```bash -./cloudflare/setup-tunnel.sh -``` - -Or manually: copy `config.yml.example` to `config.yml`, replace `TUNNEL_UUID` with your tunnel ID, and `YOUR_USERNAME` with your home path. - -Then run the tunnel: - -```bash -cloudflared tunnel --config cloudflare/config.yml run high-command -``` - ---- - -## Option 3: Tunnel to Kubernetes Gateway - -If High Command is already running in k8s with a LoadBalancer (e.g. `192.168.14.184` for `hc.dataknife.ai`), you can tunnel to that instead of localhost: - -1. Copy `config.yml` to `config-k8s.yml` -2. Replace `localhost` with your gateway IP/hostname in each `service` URL -3. Run: `cloudflared tunnel --config cloudflare/config-k8s.yml run high-command` - -This is useful when the tunnel runs on a different machine than the k8s cluster. - ---- - -## MCP Path Rewrite - -The k8s Gateway rewrites `/mcp/messages` → `/messages` for the MCP server. Cloudflared does not support path rewriting, so we have two choices: - -1. **Use the provided Caddy reverse proxy** (recommended for local dev): Caddy handles path rewrite and routing; cloudflared tunnels to Caddy. -2. **Tunnel directly with path rules**: The MCP server may accept `/mcp/messages` if configured. Check `high-command-mcp` for path handling. - -The `config.yml` uses direct path-based routing. If MCP fails, use the Caddy setup below. - ---- - -## Using Caddy as Local Reverse Proxy (Optional) - -For full k8s-like routing including MCP path rewrite, run Caddy in front: - -```bash -cd cloudflare && caddy run --config Caddyfile -``` - -Then tunnel to Caddy only: - -```bash -# Quick tunnel (no config): -cloudflared tunnel --url http://localhost:8080 -``` - -For a named tunnel with `config-caddy.yml`, replace `TUNNEL_UUID` and `YOUR_USERNAME` as in the main config, or run `setup-tunnel.sh` and manually edit the service URL to `http://localhost:8080`. - ---- - -## Option 4: Docker with Token (Remote Tunnel) - -For tunnels created in the [Cloudflare Zero Trust dashboard](https://one.dash.cloudflare.com/) (Networks → Tunnels), use the one-time token from the connector setup: - -```bash -docker run cloudflare/cloudflared:latest tunnel --no-autoupdate run --token -``` - -The token embeds tunnel credentials and routing. Ingress rules are configured in the dashboard, not in a local config file. Use `host.docker.internal` (or `host-gateway` on Linux) to reach services on the host: - -- **macOS/Windows**: `http://host.docker.internal:3000` for UI -- **Linux**: add `--add-host=host.docker.internal:host-gateway` and use `http://host.docker.internal:3000` - -Or use the host network when running on the same machine: - -```bash -docker run --network host cloudflare/cloudflared:latest tunnel --no-autoupdate run --token -``` - -With docker-compose (uses host network to reach localhost services): - -```bash -CLOUDFLARED_TOKEN=eyJ... docker compose -f cloudflare/docker-compose.yml up -d -``` - -Or create `cloudflare/.env` with `CLOUDFLARED_TOKEN=...` (add to `.gitignore`). - ---- - -## Run as a Service (Linux) - -```bash -sudo cloudflared service install -``` - -Config path is typically `/etc/cloudflared/config.yml`. Copy your config there and ensure `credentials-file` uses an absolute path. - ---- - -## Troubleshooting - -### Connectivity pre-checks - -```bash -cloudflared tunnel connectivity precheck -``` - -### Validate config - -```bash -cloudflared tunnel ingress validate -``` - -### Test which rule matches a URL - -```bash -cloudflared tunnel ingress rule https://hc.dataknife.ai/api/health -``` - -### Quick tunnel fails with "config exists" - -Rename or remove `~/.cloudflared/config.yml`; quick tunnels don't work when a config file exists. - ---- - -## Security Notes - -- **Cloudflare Access**: Consider protecting the tunnel with [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/policies/access/) (e.g. email OTP, GitHub) for non-public demos. -- **Origin validation**: Enable [application token validation](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/) so requests bypassing Access are rejected. -- **Secrets**: Never commit `cert.pem` or `*.json` credentials; they're in `.gitignore`. +**GitLab CI:** The `publish-cloudflared` job builds and pushes this image on tags. diff --git a/cloudflare/config-caddy.yml b/cloudflare/config-caddy.yml deleted file mode 100644 index 5deafff..0000000 --- a/cloudflare/config-caddy.yml +++ /dev/null @@ -1,12 +0,0 @@ -# Cloudflare Tunnel config when using Caddy as reverse proxy -# Single entry point at localhost:8080 - Caddy handles /api, /mcp, / routing -# Run: caddy run --config Caddyfile (in another terminal) -# Then: cloudflared tunnel --config config-caddy.yml run high-command - -tunnel: TUNNEL_UUID -credentials-file: /home/YOUR_USERNAME/.cloudflared/TUNNEL_UUID.json - -ingress: - - hostname: hc.dataknife.ai - service: http://localhost:8080 - - service: http_status:404 diff --git a/cloudflare/config.yml.example b/cloudflare/config.yml.example deleted file mode 100644 index e4eb777..0000000 --- a/cloudflare/config.yml.example +++ /dev/null @@ -1,16 +0,0 @@ -# Cloudflare Tunnel config for High Command -# Copy to config.yml and run setup-tunnel.sh, or replace placeholders manually - -tunnel: TUNNEL_UUID -credentials-file: /home/YOUR_USERNAME/.cloudflared/TUNNEL_UUID.json - -ingress: - - hostname: hc.dataknife.ai - path: /api/* - service: http://localhost:5000 - - hostname: hc.dataknife.ai - path: /mcp/* - service: http://localhost:8000 - - hostname: hc.dataknife.ai - service: http://localhost:3000 - - service: http_status:404 diff --git a/cloudflare/docker-compose.yml b/cloudflare/docker-compose.yml deleted file mode 100644 index 8f477d6..0000000 --- a/cloudflare/docker-compose.yml +++ /dev/null @@ -1,11 +0,0 @@ -# Cloudflare Tunnel via Docker (token-based, remote tunnel from Zero Trust dashboard) -# Set CLOUDFLARED_TOKEN or pass --token when creating the tunnel -# Ingress is configured in Cloudflare dashboard, not in local config - -services: - cloudflared: - image: cloudflare/cloudflared:latest - command: tunnel --no-autoupdate run --token ${CLOUDFLARED_TOKEN} - # Use host network so tunnel can reach localhost:3000, :5000, :8000 - network_mode: host - restart: unless-stopped diff --git a/cloudflare/setup-tunnel.sh b/cloudflare/setup-tunnel.sh deleted file mode 100755 index b37162e..0000000 --- a/cloudflare/setup-tunnel.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env bash -# One-time setup for High Command Cloudflare Tunnel -# Run from project root: ./cloudflare/setup-tunnel.sh - -set -e - -TUNNEL_NAME="high-command" -CONFIG_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CLOUDFLARED_DIR="${HOME}/.cloudflared" - -echo "=== High Command Cloudflare Tunnel Setup ===" -echo - -# Check cloudflared -if ! command -v cloudflared &>/dev/null; then - echo "cloudflared not found. Install it first:" - echo " Arch: sudo pacman -S cloudflared" - echo " Debian: see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/" - exit 1 -fi - -# Login if no cert -if [[ ! -f "${CLOUDFLARED_DIR}/cert.pem" ]]; then - echo "Authenticating with Cloudflare..." - cloudflared tunnel login -fi - -# Create tunnel if it doesn't exist -if ! cloudflared tunnel list 2>/dev/null | grep -q "${TUNNEL_NAME}"; then - echo "Creating tunnel: ${TUNNEL_NAME}" - cloudflared tunnel create "${TUNNEL_NAME}" -fi - -# Get tunnel ID -TUNNEL_ID=$(cloudflared tunnel list 2>/dev/null | grep "${TUNNEL_NAME}" | awk '{print $1}') -if [[ -z "${TUNNEL_ID}" ]]; then - echo "Could not find tunnel ID. Run: cloudflared tunnel list" - exit 1 -fi - -CREDS_FILE="${CLOUDFLARED_DIR}/${TUNNEL_ID}.json" -if [[ ! -f "${CREDS_FILE}" ]]; then - echo "Credentials file not found: ${CREDS_FILE}" - exit 1 -fi - -echo -echo "Tunnel ID: ${TUNNEL_ID}" -echo "Credentials: ${CREDS_FILE}" -echo - -# Generate config from template -CONFIG_FILE="${CONFIG_DIR}/config.yml" -sed -e "s/TUNNEL_UUID/${TUNNEL_ID}/g" \ - -e "s|/home/YOUR_USERNAME|${HOME}|g" \ - "${CONFIG_DIR}/config.yml.example" > "${CONFIG_FILE}" -echo "Generated: ${CONFIG_FILE}" - -# Route DNS (optional - may fail if zone not on Cloudflare) -echo -read -p "Route hc.dataknife.ai to this tunnel? (y/n) " -n 1 -r -echo -if [[ $REPLY =~ ^[Yy]$ ]]; then - cloudflared tunnel route dns "${TUNNEL_NAME}" hc.dataknife.ai - echo "DNS route created." -fi - -echo -echo "=== Setup complete ===" -echo "Start services (UI:3000, API:5000, MCP:8000), then run:" -echo " cloudflared tunnel --config ${CONFIG_FILE} run ${TUNNEL_NAME}" -echo diff --git a/k8s/CLOUDFLARE_TUNNEL.md b/k8s/CLOUDFLARE_TUNNEL.md index a5b83e2..29d0e15 100644 --- a/k8s/CLOUDFLARE_TUNNEL.md +++ b/k8s/CLOUDFLARE_TUNNEL.md @@ -67,16 +67,14 @@ NEW_SVC=$(kubectl get svc -n envoy-gateway-system -o name | grep high-command | kubectl patch svc high-command-gateway -n high-command -p "{\"spec\":{\"externalName\":\"${NEW_SVC}.envoy-gateway-system.svc.cluster.local\"}}" ``` -## Image options +## Image -**Default (Alpine)**: Uses `alpine:3.19` and installs cloudflared from Alpine edge/testing at startup. Works without building. +The deployment uses `cloudflare/Dockerfile` (Alpine + cloudflared binary, shell for token injection). GitLab CI builds and pushes on tags. To build manually: -**Custom (latest cloudflared)**: Build `cloudflare/Dockerfile` to get the official `cloudflare/cloudflared:latest` binary with a shell for token injection: ```bash docker build -t harbor.dataknife.net/library/cloudflared-tunnel:latest cloudflare/ docker push harbor.dataknife.net/library/cloudflared-tunnel:latest ``` -Then change the deployment image to `harbor.dataknife.net/library/cloudflared-tunnel:latest` and remove the `command` block. ## Files diff --git a/k8s/README.md b/k8s/README.md index 68b1609..7b02a48 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -2,13 +2,15 @@ This directory contains Kubernetes manifests for deploying the High Command stack (UI, API, MCP, Gateway, Cloudflare Tunnel). +## Architecture + +Traffic flow: **Cloudflare Tunnel** → **Envoy Gateway** → **HTTPRoute** → UI/API/MCP. No nginx Ingress. + ## UI Files -- `ui-deployment-blue.yaml` - Blue deployment (active version) -- `ui-deployment-green.yaml` - Green deployment (standby version) -- `ui-service.yaml` - Service to route traffic between blue/green -- `ui-pdb.yaml` - Pod Disruption Budget for availability -- `ui-ingress.yaml` - Ingress configuration for external access +- `ui-deployment-blue.yaml`, `ui-deployment-green.yaml` - Blue/green deployments +- `ui-service.yaml` - Service routing +- `ui-pdb.yaml` - Pod Disruption Budget ## Full Stack Files @@ -26,19 +28,20 @@ See `CLOUDFLARE_TUNNEL.md` for tunnel setup. **No secrets are stored in these files.** -**Claude (API key in backend):** Store the key in the API secret so the UI never sees it: +**API secrets** (required): `database-url` and optionally `claude-api-key`: ```bash kubectl create secret generic high-command-api-secrets \ + --from-literal=database-url='postgresql://user:password@high-command-postgres-rw.high-command.svc.cluster.local:5432/highcommand' \ --from-literal=claude-api-key='sk-ant-api03-...' \ -n high-command ``` -The API proxies `/claude/*` to Anthropic and adds the key server-side. +See `api-secrets-example.yaml` for details. ## Cloudflare -The `../cloudflare/` folder contains the tunnel Dockerfile and local dev config (Caddy, docker-compose). GitLab CI builds the cloudflared-tunnel image from `cloudflare/Dockerfile`. +The `../cloudflare/` folder contains the tunnel Dockerfile. GitLab CI builds the cloudflared-tunnel image from `cloudflare/Dockerfile`. ## Environment Variables diff --git a/k8s/gateway-lb.yaml b/k8s/gateway-lb.yaml deleted file mode 100644 index a0d8a17..0000000 --- a/k8s/gateway-lb.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# Note: Envoy Gateway automatically creates services for Gateways -# This file documents the expected service configuration. -# The Envoy Gateway service will be created automatically when the Gateway resource is deployed. -# -# To configure MetalLB for the Gateway service, ensure: -# 1. MetalLB is installed in the metallb-system namespace -# 2. IPAddressPool and L2Advertisement are applied (see metallb-address-pool.yaml) -# 3. The Gateway resource has MetalLB annotations (see gateway.yaml) -# -# The Envoy Gateway controller service will be named something like: -# - envoy-gateway-system/gateway-envoy--- -# -# You may need to patch the automatically created service with MetalLB annotations: -# kubectl annotate service \ -# -n envoy-gateway-system \ -# metallb.universe.tf/address-pool=dataknife-ai-pool \ -# metallb.universe.tf/loadBalancerIPs=192.168.18.10 diff --git a/k8s/ui-ingress.yaml b/k8s/ui-ingress.yaml deleted file mode 100644 index 428b95b..0000000 --- a/k8s/ui-ingress.yaml +++ /dev/null @@ -1,70 +0,0 @@ -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: high-command-ui - namespace: high-command - labels: - app: high-command-ui - annotations: - nginx.ingress.kubernetes.io/ssl-redirect: "true" - # Note: No rewrite-target for /api paths - API expects /api prefix - # rewrite-target only applies to UI root path via path-specific annotations - # Optional: Rate limiting - # nginx.ingress.kubernetes.io/limit-rps: "100" -spec: - ingressClassName: nginx - rules: - - host: hc.dataknife.ai - http: - paths: - - path: /api - pathType: Prefix - backend: - service: - name: high-command-api - port: - number: 80 - - path: /mcp - pathType: Prefix - backend: - service: - name: high-command-mcp - port: - number: 8000 - - path: / - pathType: Prefix - backend: - service: - name: high-command-ui - port: - number: 80 - - host: hc.dataknife.net - http: - paths: - - path: /api - pathType: Prefix - backend: - service: - name: high-command-api - port: - number: 80 - - path: /mcp - pathType: Prefix - backend: - service: - name: high-command-mcp - port: - number: 8000 - - path: / - pathType: Prefix - backend: - service: - name: high-command-ui - port: - number: 80 - # Uncomment for TLS (when certificates are ready) - # tls: - # - hosts: - # - hc.dataknife.ai - # - hc.dataknife.net - # secretName: high-command-ui-tls