commit 6dc514d51945a70dd96a0af82092b1d81807563f Author: Vassiliy Yegorov Date: Sat Mar 21 10:45:42 2026 +0700 init diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..f681fff --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(chmod +x /Users/vasyansk/Developers/MyProject/IaaC/Realmanual/elevenlabs.io/proxy-vm/scripts/install.sh /Users/vasyansk/Developers/MyProject/IaaC/Realmanual/elevenlabs.io/proxy-vm/scripts/deploy.sh /Users/vasyansk/Developers/MyProject/IaaC/Realmanual/elevenlabs.io/proxy-vm/docker-entrypoint.sh)" + ] + } +} diff --git a/.gitea/build.yaml b/.gitea/build.yaml new file mode 100644 index 0000000..f7ba288 --- /dev/null +++ b/.gitea/build.yaml @@ -0,0 +1,38 @@ +name: Build Admin +on: + push: + branches: [main, master] + +env: + REGISTRY: git.realmanual.ru + IMAGE_PREFIX: ${{ gitea.repository }} + +permissions: + contents: read + packages: write + +jobs: + build: + name: Build image + runs-on: ubuntu-22.04 + container: catthehacker/ubuntu:act-latest + steps: + - uses: actions/checkout@v3 + - name: Read Version + id: version + run: echo "VERSION=$(cat admin/VERSION)" >> $GITHUB_OUTPUT + - name: Log in to Gitea Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.TOKEN }} + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ./admin/ + file: ./admin/Dockerfile.admin + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/elevenlabs:${{ steps.version.outputs.VERSION }} + ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/elevenlabs:latest diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6c6aa7c --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 \ No newline at end of file diff --git a/proxy-vm/.env.example b/proxy-vm/.env.example new file mode 100644 index 0000000..77a308e --- /dev/null +++ b/proxy-vm/.env.example @@ -0,0 +1,7 @@ +# Secret token — k8s pods must send this in X-Proxy-Token header +# Generate with: openssl rand -hex 32 +PROXY_SECRET=change-me-generate-with-openssl-rand-hex-32 + +# Allowed source CIDRs (comma-separated), leave empty to allow all (dev mode) +# Example: "10.0.0.0/8,172.16.0.0/12" +ALLOWED_CIDR= diff --git a/proxy-vm/Dockerfile b/proxy-vm/Dockerfile new file mode 100644 index 0000000..c674834 --- /dev/null +++ b/proxy-vm/Dockerfile @@ -0,0 +1,22 @@ +# proxy-vm/Dockerfile +FROM nginx:1.27-alpine + +# Remove default config +RUN rm -f /etc/nginx/conf.d/default.conf + +# Copy nginx config +COPY nginx/nginx.conf /etc/nginx/nginx.conf + +# Copy entrypoint script +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh + +# Create conf.d directory for generated configs +RUN mkdir -p /etc/nginx/conf.d + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD wget -qO- http://localhost:8080/health || exit 1 + +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/proxy-vm/README.md b/proxy-vm/README.md new file mode 100644 index 0000000..19d20dc --- /dev/null +++ b/proxy-vm/README.md @@ -0,0 +1,212 @@ +# API Proxy — HTTPS Reverse Proxy + +Production-ready nginx reverse proxy in Docker for forwarding requests from an internal Kubernetes cluster to external APIs (ElevenLabs, OpenAI, etc.) + +## Quick Start + +### Prerequisites +- Ubuntu 24.04 VM +- Port 8080 open **only** to k8s cluster CIDR (firewall rule) + +### Installation + +```bash +# On a fresh VM (as root): +sudo bash scripts/install.sh +``` + +### Configuration + +```bash +# Edit environment variables: +vi /opt/proxy/proxy-vm/.env + +# Set your k8s cluster CIDR: +ALLOWED_CIDR=10.0.0.0/8,172.16.0.0/12 + +# Restart to apply: +cd /opt/proxy/proxy-vm +docker compose restart +``` + +### Verify + +```bash +# Health check (no auth required): +curl http://localhost:8080/health + +# Test with auth: +curl -H "X-Proxy-Token: YOUR_SECRET" http://localhost:8080/elevenlabs/v1/voices +``` + +--- + +## How to Add a New Upstream + +Example: adding `api.anthropic.com` → `/anthropic/*` + +### 1. Add upstream block in `nginx/nginx.conf`: + +```nginx +upstream anthropic_backend { + server api.anthropic.com:443; + keepalive 32; +} +``` + +### 2. Add location block in the `server` section: + +```nginx +location /anthropic/ { + if ($allowed_ip = 0) { + return 403 '{"error":"ip_not_allowed"}'; + } + if ($auth_ok = 0) { + return 403 '{"error":"invalid_token"}'; + } + + rewrite ^/anthropic/(.*) /$1 break; + + proxy_pass https://anthropic_backend; + proxy_ssl_server_name on; + proxy_ssl_name api.anthropic.com; + + proxy_set_header Host api.anthropic.com; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_set_header X-Proxy-Token ""; + + proxy_http_version 1.1; + + proxy_buffering off; + proxy_request_buffering off; + proxy_read_timeout 120s; + proxy_send_timeout 120s; +} +``` + +### 3. Rebuild and restart: + +```bash +cd /opt/proxy/proxy-vm +docker compose up -d --build +``` + +--- + +## K8s Side: How to Call the Proxy + +### Environment Variables (in your Deployment/ConfigMap) + +```yaml +env: + - name: PROXY_BASE_URL + value: "http://10.0.1.50:8080" # proxy VM internal IP + - name: PROXY_SECRET + valueFrom: + secretKeyRef: + name: api-proxy + key: token +``` + +### TypeScript Example + +```typescript +// In tts-worker, replace ELEVENLABS_BASE_URL: +// Before: https://api.elevenlabs.io +// After: http://:8080/elevenlabs + +const response = await fetch( + `${process.env.PROXY_BASE_URL}/elevenlabs/v1/text-to-speech/${voiceId}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'xi-api-key': process.env.ELEVENLABS_API_KEY, // passed through to upstream + 'X-Proxy-Token': process.env.PROXY_SECRET, // validated by proxy + }, + body: JSON.stringify(payload), + } +); +// Response is binary MP3, identical to direct ElevenLabs call +const buffer = Buffer.from(await response.arrayBuffer()); +``` + +--- + +## Security Checklist + +- [ ] `PROXY_SECRET` generated via `openssl rand -hex 32` +- [ ] `ALLOWED_CIDR` restricted to cluster CIDR only +- [ ] Port 8080 closed to the public (only k8s CIDR in firewall/NSG) +- [ ] VM has no public IP or is behind NAT +- [ ] Logs rotate (Docker logging driver configured: 50m x 5 files) + +--- + +## Adding TLS (Self-Signed) + +If you need HTTPS between k8s and the proxy: + +```bash +# Generate self-signed cert: +openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout /opt/proxy/proxy-vm/nginx/ssl/proxy.key \ + -out /opt/proxy/proxy-vm/nginx/ssl/proxy.crt \ + -subj "/CN=api-proxy" +``` + +Then in `nginx.conf`, change the server block: + +```nginx +server { + listen 8443 ssl; + ssl_certificate /etc/nginx/ssl/proxy.crt; + ssl_certificate_key /etc/nginx/ssl/proxy.key; + # ... rest of config +} +``` + +Mount the certs in `docker-compose.yml`: + +```yaml +volumes: + - ./nginx/ssl:/etc/nginx/ssl:ro +``` + +--- + +## Monitoring + +### Tail logs in JSON format + +```bash +docker compose logs -f proxy | jq . +``` + +### Count requests per upstream per minute + +```bash +docker compose logs --since=1m proxy --no-log-prefix \ + | jq -r '.uri' \ + | cut -d'/' -f2 \ + | sort | uniq -c | sort -rn +``` + +### Check container health + +```bash +docker inspect --format='{{.State.Health.Status}}' api-proxy +``` + +--- + +## How It Works + +1. A k8s pod sends an HTTP request to `http://:8080/elevenlabs/v1/text-to-speech/...` with the `X-Proxy-Token` header and the original API key (`xi-api-key`). +2. Nginx checks the source IP against the configured `ALLOWED_CIDR` geo block and validates the `X-Proxy-Token` against `PROXY_SECRET` — rejecting with 403 if either fails. +3. The `/elevenlabs/` prefix is stripped via rewrite, and the request is forwarded over HTTPS to `api.elevenlabs.io` with SNI enabled and keepalive connections. +4. The `X-Proxy-Token` header is removed before forwarding; all other headers (including `xi-api-key`) pass through unchanged. +5. The response streams back unbuffered to the pod — critical for binary audio data from ElevenLabs TTS. diff --git a/proxy-vm/docker-compose.yml b/proxy-vm/docker-compose.yml new file mode 100644 index 0000000..af756bf --- /dev/null +++ b/proxy-vm/docker-compose.yml @@ -0,0 +1,21 @@ +# proxy-vm/docker-compose.yml +version: "3.9" + +services: + proxy: + build: . + container_name: api-proxy + restart: unless-stopped + ports: + - "8080:8080" + env_file: .env + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"] + interval: 30s + timeout: 5s + retries: 3 + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" diff --git a/proxy-vm/docker-entrypoint.sh b/proxy-vm/docker-entrypoint.sh new file mode 100755 index 0000000..86449e9 --- /dev/null +++ b/proxy-vm/docker-entrypoint.sh @@ -0,0 +1,56 @@ +#!/bin/sh +# proxy-vm/docker-entrypoint.sh +# Generates nginx config fragments from environment variables at container start. +set -e + +CONF_DIR="/etc/nginx/conf.d" +mkdir -p "${CONF_DIR}" + +# --- 1. Generate IP allowlist (geo block) --- +ALLOWLIST_FILE="${CONF_DIR}/allowlist.conf" + +if [ -z "${ALLOWED_CIDR}" ]; then + # Dev mode: allow all IPs + cat > "${ALLOWLIST_FILE}" <<'GEO' +geo $allowed_ip { + default 1; +} +GEO + echo "[entrypoint] ALLOWED_CIDR is empty — allowing all IPs (dev mode)" +else + # Build geo block from comma-separated CIDRs + { + echo 'geo $allowed_ip {' + echo ' default 0;' + echo "${ALLOWED_CIDR}" | tr ',' '\n' | while read -r cidr; do + cidr=$(echo "${cidr}" | xargs) # trim whitespace + [ -n "${cidr}" ] && echo " ${cidr} 1;" + done + echo '}' + } > "${ALLOWLIST_FILE}" + echo "[entrypoint] IP allowlist configured: ${ALLOWED_CIDR}" +fi + +# --- 2. Generate token auth (map block) --- +AUTH_FILE="${CONF_DIR}/auth.conf" + +if [ -z "${PROXY_SECRET}" ]; then + echo "[entrypoint] WARNING: PROXY_SECRET is not set — all requests will be rejected!" + cat > "${AUTH_FILE}" <<'MAP' +map $http_x_proxy_token $auth_ok { + default 0; +} +MAP +else + cat > "${AUTH_FILE}" <>> .env created with auto-generated PROXY_SECRET" + echo ">>> Edit /opt/proxy/proxy-vm/.env to set ALLOWED_CIDR" + echo "" +else + echo ".env already exists, skipping..." +fi + +echo "=== [6/6] Starting containers ===" +docker compose up -d --build + +echo "" +echo "==========================================" +echo " Proxy is running on port 8080" +echo " Health check: curl http://localhost:8080/health" +echo "" +echo " IMPORTANT: Edit ${PROXY_DIR}/proxy-vm/.env" +echo " - Set ALLOWED_CIDR to your k8s cluster CIDR" +echo " - Then run: docker compose restart" +echo "=========================================="