From b2c7e7ba809170519bea0488a8cb084ef1dbdb0c Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Thu, 9 Jul 2026 14:41:17 +0700 Subject: [PATCH] add host --- .env.example | 5 +- .gitignore | 1 + Dockerfile | 9 ++- README.md | 174 +++++++++++++++++++++---------------------- docker-compose.yml | 8 +- docker-entrypoint.sh | 43 +++++++++-- nginx/locations.conf | 160 +++++++++++++++++++++++++++++++++++++++ nginx/nginx.conf | 106 ++------------------------ ssl/.gitignore | 6 ++ ssl/.gitkeep | 0 10 files changed, 307 insertions(+), 205 deletions(-) create mode 100644 .gitignore create mode 100644 nginx/locations.conf create mode 100644 ssl/.gitignore create mode 100644 ssl/.gitkeep diff --git a/.env.example b/.env.example index 77a308e..9d43d82 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ -# Secret token — k8s pods must send this in X-Proxy-Token header +# Secret token — k8s pods must send this in X-Proxy-Token header (optional) +# If empty or unset — token auth is disabled (open mode, protected by IP allowlist only) # Generate with: openssl rand -hex 32 -PROXY_SECRET=change-me-generate-with-openssl-rand-hex-32 +PROXY_SECRET= # Allowed source CIDRs (comma-separated), leave empty to allow all (dev mode) # Example: "10.0.0.0/8,172.16.0.0/12" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index c674834..f78a0cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,17 +4,18 @@ FROM nginx:1.27-alpine # Remove default config RUN rm -f /etc/nginx/conf.d/default.conf -# Copy nginx config +# Copy nginx configs COPY nginx/nginx.conf /etc/nginx/nginx.conf +COPY nginx/locations.conf /etc/nginx/locations.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 +# Create directories for generated configs and optional SSL certs +RUN mkdir -p /etc/nginx/conf.d /etc/nginx/ssl -EXPOSE 8080 +EXPOSE 8080 8443 HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ CMD wget -qO- http://localhost:8080/health || exit 1 diff --git a/README.md b/README.md index 19d20dc..95051a5 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,35 @@ # 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.) +Production-ready nginx reverse proxy in Docker for forwarding requests from an internal Kubernetes cluster to external APIs (ElevenLabs, OpenAI, Telegram Bot API). ## Quick Start ### Prerequisites - Ubuntu 24.04 VM -- Port 8080 open **only** to k8s cluster CIDR (firewall rule) +- Port 8080 (HTTP) and optionally 8443 (HTTPS) open **only** to k8s cluster CIDR ### Installation ```bash -# On a fresh VM (as root): sudo bash scripts/install.sh ``` ### Configuration ```bash -# Edit environment variables: -vi /opt/proxy/proxy-vm/.env +vi .env -# Set your k8s cluster CIDR: +# Set your secret and cluster CIDR: +PROXY_SECRET= ALLOWED_CIDR=10.0.0.0/8,172.16.0.0/12 -# Restart to apply: -cd /opt/proxy/proxy-vm -docker compose restart +docker compose up -d --build ``` ### Verify ```bash -# Health check (no auth required): +# Health check (no auth): curl http://localhost:8080/health # Test with auth: @@ -41,20 +38,46 @@ curl -H "X-Proxy-Token: YOUR_SECRET" http://localhost:8080/elevenlabs/v1/voices --- +## Available Endpoints + +| Prefix | Upstream | +|--------------|-----------------------------| +| /elevenlabs/ | https://api.elevenlabs.io | +| /openai/ | https://api.openai.com | +| /telegram/ | https://api.telegram.org | +| /health | local health check (no auth)| + +--- + +## TLS / HTTPS + +To enable HTTPS on port 8443, place your certificate and key in the `ssl/` directory: + +```bash +mkdir -p ssl + +# Option A: self-signed cert +openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout ssl/tls.key \ + -out ssl/tls.crt \ + -subj "/CN=api-proxy" + +# Option B: copy existing certs +cp /path/to/your/cert.pem ssl/tls.crt +cp /path/to/your/key.pem ssl/tls.key + +docker compose up -d --build +``` + +The entrypoint auto-detects `ssl/tls.crt` and `ssl/tls.key`. If present — HTTPS is enabled on port 8443. If absent — only HTTP on 8080. + +--- + ## 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: +Add this block to `nginx/locations.conf`: ```nginx location /anthropic/ { @@ -65,21 +88,28 @@ location /anthropic/ { return 403 '{"error":"invalid_token"}'; } + set $anthropic_upstream https://api.anthropic.com; rewrite ^/anthropic/(.*) /$1 break; - proxy_pass https://anthropic_backend; + proxy_pass $anthropic_upstream; proxy_ssl_server_name on; proxy_ssl_name api.anthropic.com; + proxy_ssl_protocols TLSv1.2 TLSv1.3; 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-Forwarded-For ""; + proxy_set_header X-Real-IP ""; + proxy_set_header True-Client-IP ""; + proxy_set_header CF-Connecting-IP ""; + proxy_set_header X-Client-IP ""; + proxy_set_header Forwarded ""; + proxy_set_header Via ""; + proxy_set_header X-Proxy-Token ""; proxy_http_version 1.1; - proxy_buffering off; proxy_request_buffering off; proxy_read_timeout 120s; @@ -87,23 +117,18 @@ location /anthropic/ { } ``` -### 3. Rebuild and restart: - -```bash -cd /opt/proxy/proxy-vm -docker compose up -d --build -``` +Then rebuild: `docker compose up -d --build` --- ## K8s Side: How to Call the Proxy -### Environment Variables (in your Deployment/ConfigMap) +### Environment Variables ```yaml env: - name: PROXY_BASE_URL - value: "http://10.0.1.50:8080" # proxy VM internal IP + value: "http://10.0.1.50:8080" # or https://10.0.1.50:8443 - name: PROXY_SECRET valueFrom: secretKeyRef: @@ -111,27 +136,36 @@ env: key: token ``` -### TypeScript Example +### TypeScript Examples ```typescript -// In tts-worker, replace ELEVENLABS_BASE_URL: -// Before: https://api.elevenlabs.io -// After: http://:8080/elevenlabs - +// ElevenLabs TTS 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 + 'xi-api-key': process.env.ELEVENLABS_API_KEY, + 'X-Proxy-Token': process.env.PROXY_SECRET, }, body: JSON.stringify(payload), } ); -// Response is binary MP3, identical to direct ElevenLabs call const buffer = Buffer.from(await response.arrayBuffer()); + +// Telegram Bot API +const tgResponse = await fetch( + `${process.env.PROXY_BASE_URL}/telegram/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Proxy-Token': process.env.PROXY_SECRET, + }, + body: JSON.stringify({ chat_id: chatId, text: message }), + } +); ``` --- @@ -140,64 +174,26 @@ const buffer = Buffer.from(await response.arrayBuffer()); - [ ] `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) +- [ ] Port 8080/8443 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 -``` +- [ ] TLS certs have restricted permissions (`chmod 600 ssl/tls.key`) --- ## Monitoring -### Tail logs in JSON format - ```bash +# Tail logs docker compose logs -f proxy | jq . -``` -### Count requests per upstream per minute - -```bash +# Requests per upstream per minute docker compose logs --since=1m proxy --no-log-prefix \ | jq -r '.uri' \ | cut -d'/' -f2 \ | sort | uniq -c | sort -rn -``` -### Check container health - -```bash +# Container health docker inspect --format='{{.State.Health.Status}}' api-proxy ``` @@ -205,8 +201,8 @@ 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. +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. +2. Nginx checks the source IP against `ALLOWED_CIDR` and validates `X-Proxy-Token` — rejecting with 403 if either fails. +3. The prefix (`/elevenlabs/`, `/openai/`, `/telegram/`) is stripped via rewrite, and the request is forwarded over HTTPS to the upstream with SNI enabled. +4. `X-Proxy-Token` and all IP-leaking headers are removed before forwarding; API keys pass through unchanged. +5. The response streams back unbuffered to the pod — critical for binary audio data and long-polling (Telegram). diff --git a/docker-compose.yml b/docker-compose.yml index af756bf..4cccbba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,3 @@ -# proxy-vm/docker-compose.yml -version: "3.9" - services: proxy: build: . @@ -8,7 +5,12 @@ services: restart: unless-stopped ports: - "8080:8080" + - "8443:8443" env_file: .env + volumes: + # Mount TLS certs (optional — HTTPS disabled if files are absent) + # Place tls.crt and tls.key in ./ssl/ directory + - ./ssl:/etc/nginx/ssl:ro healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"] interval: 30s diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 86449e9..b078b6f 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -10,7 +10,6 @@ mkdir -p "${CONF_DIR}" 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; @@ -18,12 +17,11 @@ geo $allowed_ip { 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 + cidr=$(echo "${cidr}" | xargs) [ -n "${cidr}" ] && echo " ${cidr} 1;" done echo '}' @@ -35,10 +33,10 @@ fi AUTH_FILE="${CONF_DIR}/auth.conf" if [ -z "${PROXY_SECRET}" ]; then - echo "[entrypoint] WARNING: PROXY_SECRET is not set — all requests will be rejected!" + echo "[entrypoint] PROXY_SECRET is not set — token auth disabled (open mode)" cat > "${AUTH_FILE}" <<'MAP' map $http_x_proxy_token $auth_ok { - default 0; + default 1; } MAP else @@ -51,6 +49,39 @@ MAP echo "[entrypoint] Token auth configured" fi -# --- 3. Start nginx --- +# --- 3. Copy locations.conf --- +cp /etc/nginx/locations.conf "${CONF_DIR}/locations.conf" +echo "[entrypoint] Locations config copied" + +# --- 4. Generate HTTPS server block (if certs exist) --- +HTTPS_FILE="${CONF_DIR}/https_server.conf" +SSL_CERT="/etc/nginx/ssl/tls.crt" +SSL_KEY="/etc/nginx/ssl/tls.key" + +if [ -f "${SSL_CERT}" ] && [ -f "${SSL_KEY}" ]; then + cat > "${HTTPS_FILE}" < "${HTTPS_FILE}" + echo "[entrypoint] No TLS certs found at ${SSL_CERT} — HTTPS disabled" +fi + +# --- 5. Start nginx --- echo "[entrypoint] Starting nginx..." exec nginx -g 'daemon off;' diff --git a/nginx/locations.conf b/nginx/locations.conf new file mode 100644 index 0000000..cec19dd --- /dev/null +++ b/nginx/locations.conf @@ -0,0 +1,160 @@ +# proxy-vm/nginx/locations.conf +# Shared location blocks — included by both HTTP and HTTPS server blocks. + +# --- Health check (no auth) --- +location = /health { + access_log off; + default_type application/json; + return 200 '{"status":"ok","version":"1.0"}'; +} + +# --- ElevenLabs --- +location /elevenlabs/ { + if ($allowed_ip = 0) { + return 403 '{"error":"ip_not_allowed"}'; + } + if ($auth_ok = 0) { + return 403 '{"error":"invalid_token"}'; + } + + set $elevenlabs_upstream https://api.elevenlabs.io; + rewrite ^/elevenlabs/(.*) /$1 break; + + proxy_pass $elevenlabs_upstream; + proxy_ssl_server_name on; + proxy_ssl_name api.elevenlabs.io; + proxy_ssl_protocols TLSv1.2 TLSv1.3; + + proxy_set_header Host api.elevenlabs.io; + proxy_set_header Connection ""; + + proxy_set_header X-Forwarded-For ""; + proxy_set_header X-Real-IP ""; + proxy_set_header True-Client-IP ""; + proxy_set_header CF-Connecting-IP ""; + proxy_set_header X-Client-IP ""; + proxy_set_header Forwarded ""; + proxy_set_header Via ""; + + 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; +} + +# --- OpenAI --- +location /openai/ { + if ($allowed_ip = 0) { + return 403 '{"error":"ip_not_allowed"}'; + } + if ($auth_ok = 0) { + return 403 '{"error":"invalid_token"}'; + } + + set $openai_upstream https://api.openai.com; + rewrite ^/openai/(.*) /$1 break; + + proxy_pass $openai_upstream; + proxy_ssl_server_name on; + proxy_ssl_name api.openai.com; + proxy_ssl_protocols TLSv1.2 TLSv1.3; + + proxy_set_header Host api.openai.com; + proxy_set_header Connection ""; + + proxy_set_header X-Forwarded-For ""; + proxy_set_header X-Real-IP ""; + proxy_set_header True-Client-IP ""; + proxy_set_header CF-Connecting-IP ""; + proxy_set_header X-Client-IP ""; + proxy_set_header Forwarded ""; + proxy_set_header Via ""; + + 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; +} + +# --- Telegram Bot API --- +location /telegram/ { + if ($allowed_ip = 0) { + return 403 '{"error":"ip_not_allowed"}'; + } + if ($auth_ok = 0) { + return 403 '{"error":"invalid_token"}'; + } + + set $telegram_upstream https://api.telegram.org; + rewrite ^/telegram/(.*) /$1 break; + + proxy_pass $telegram_upstream; + proxy_ssl_server_name on; + proxy_ssl_name api.telegram.org; + proxy_ssl_protocols TLSv1.2 TLSv1.3; + + proxy_set_header Host api.telegram.org; + proxy_set_header Connection ""; + + proxy_set_header X-Forwarded-For ""; + proxy_set_header X-Real-IP ""; + proxy_set_header True-Client-IP ""; + proxy_set_header CF-Connecting-IP ""; + proxy_set_header X-Client-IP ""; + proxy_set_header Forwarded ""; + proxy_set_header Via ""; + + 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; +} + +# --- openrouter Bot API --- +location /openrouter/ { + if ($allowed_ip = 0) { + return 403 '{"error":"ip_not_allowed"}'; + } + + set $openrouter_upstream https://openrouter.ai/api/v1; + rewrite ^/openrouter/(.*) /$1 break; + + proxy_pass $openrouter_upstream; + proxy_ssl_server_name on; + proxy_ssl_name openrouter.ai; + proxy_ssl_protocols TLSv1.2 TLSv1.3; + + proxy_set_header Host openrouter.ai; + proxy_set_header Connection ""; + + proxy_set_header X-Forwarded-For ""; + proxy_set_header X-Real-IP ""; + proxy_set_header True-Client-IP ""; + proxy_set_header CF-Connecting-IP ""; + proxy_set_header X-Client-IP ""; + proxy_set_header Forwarded ""; + proxy_set_header Via ""; + + 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; +} + +# --- Catch-all --- +location / { + default_type application/json; + return 404 '{"error":"unknown_upstream","hint":"use /elevenlabs/, /openai/ or /telegram/" or /openrouter/}'; +} diff --git a/nginx/nginx.conf b/nginx/nginx.conf index f001d13..9851c4e 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -45,109 +45,13 @@ http { # --- Token auth --- include /etc/nginx/conf.d/auth.conf; + # --- HTTP server --- server { listen 8080; server_name _; - - # --- Health check (no auth) --- - location = /health { - access_log off; - default_type application/json; - return 200 '{"status":"ok","version":"1.0"}'; - } - - # --- ElevenLabs --- - location /elevenlabs/ { - # Auth checks - if ($allowed_ip = 0) { - return 403 '{"error":"ip_not_allowed"}'; - } - if ($auth_ok = 0) { - return 403 '{"error":"invalid_token"}'; - } - - # Variable forces runtime DNS resolution (not cached at startup) - set $elevenlabs_upstream https://api.elevenlabs.io; - - # Strip /elevenlabs/ prefix and proxy - rewrite ^/elevenlabs/(.*) /$1 break; - - proxy_pass $elevenlabs_upstream; - proxy_ssl_server_name on; - proxy_ssl_name api.elevenlabs.io; - proxy_ssl_protocols TLSv1.2 TLSv1.3; - - # Host header must match upstream for Cloudflare - proxy_set_header Host api.elevenlabs.io; - proxy_set_header Connection ""; - - # Scrub all headers that leak the original client IP - # Cloudflare reads these to determine "real" client geo - proxy_set_header X-Forwarded-For ""; - proxy_set_header X-Real-IP ""; - proxy_set_header True-Client-IP ""; - proxy_set_header CF-Connecting-IP ""; - proxy_set_header X-Client-IP ""; - proxy_set_header Forwarded ""; - proxy_set_header Via ""; - - # Remove proxy token before forwarding to upstream - proxy_set_header X-Proxy-Token ""; - - # HTTP/1.1 for keepalive - proxy_http_version 1.1; - - # Streaming / performance - proxy_buffering off; - proxy_request_buffering off; - proxy_read_timeout 120s; - proxy_send_timeout 120s; - } - - # --- OpenAI --- - location /openai/ { - if ($allowed_ip = 0) { - return 403 '{"error":"ip_not_allowed"}'; - } - if ($auth_ok = 0) { - return 403 '{"error":"invalid_token"}'; - } - - set $openai_upstream https://api.openai.com; - - rewrite ^/openai/(.*) /$1 break; - - proxy_pass $openai_upstream; - proxy_ssl_server_name on; - proxy_ssl_name api.openai.com; - proxy_ssl_protocols TLSv1.2 TLSv1.3; - - proxy_set_header Host api.openai.com; - proxy_set_header Connection ""; - - # Scrub all headers that leak the original client IP - proxy_set_header X-Forwarded-For ""; - proxy_set_header X-Real-IP ""; - proxy_set_header True-Client-IP ""; - proxy_set_header CF-Connecting-IP ""; - proxy_set_header X-Client-IP ""; - proxy_set_header Forwarded ""; - proxy_set_header Via ""; - - 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; - } - - # --- Catch-all --- - location / { - default_type application/json; - return 404 '{"error":"unknown_upstream","hint":"use /elevenlabs/ or /openai/"}'; - } + include /etc/nginx/conf.d/locations.conf; } + + # --- HTTPS server (generated at container start if certs exist) --- + include /etc/nginx/conf.d/https_server.conf; } diff --git a/ssl/.gitignore b/ssl/.gitignore new file mode 100644 index 0000000..27afd58 --- /dev/null +++ b/ssl/.gitignore @@ -0,0 +1,6 @@ +# Ignore actual TLS certificates — never commit to git +*.crt +*.key +*.pem +!.gitkeep +!.gitignore diff --git a/ssl/.gitkeep b/ssl/.gitkeep new file mode 100644 index 0000000..e69de29