From d71001a2bfa7d6b40d6e779cebbb325758119d14 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sun, 20 Sep 2026 13:35:49 +0700 Subject: [PATCH] refactor: switch nginx to Caddy with automatic Let's Encrypt Nginx required manually managed certs on port 8443; TLS was effectively unused. Caddy issues and renews certificates for DOMAIN on its own. - Caddyfile replaces nginx.conf + locations.conf; upstreams are one `import api ` line each - routes wrapped in `route {}` so guards run before handle_path - access control (IP allowlist, X-Proxy-Token) generated from env into matchers.caddy / guard.caddy by the entrypoint, as before - listeners: 443 (+udp, HTTP/3) and 80 for ACME on DOMAIN, plus the unchanged plain-HTTP 8082 -> 8080 path for existing clients - caddy_data volume added: losing it re-issues certs and burns LE limits - scripts/migrate-host-nginx.sh backs up and retires the host nginx - deploy.sh points at the real prod layout (/srv/elevenlabs, compose v1) Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 6 + .gitignore | 3 +- Caddyfile | 70 ++++++++++++ Dockerfile | 19 +--- README.md | 164 ++++++++++++++------------- VERSION | 2 +- docker-compose.yml | 20 +++- docker-entrypoint.sh | 114 +++++++------------ nginx/locations.conf | 204 ---------------------------------- nginx/nginx.conf | 57 ---------- scripts/deploy.sh | 29 ++--- scripts/install.sh | 18 +-- scripts/migrate-host-nginx.sh | 43 +++++++ ssl/.gitignore | 6 - ssl/.gitkeep | 0 15 files changed, 289 insertions(+), 466 deletions(-) create mode 100644 Caddyfile delete mode 100644 nginx/locations.conf delete mode 100644 nginx/nginx.conf create mode 100755 scripts/migrate-host-nginx.sh delete mode 100644 ssl/.gitignore delete mode 100644 ssl/.gitkeep diff --git a/.env.example b/.env.example index 9d43d82..7e4f742 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,9 @@ +# Public domain served over HTTPS (Let's Encrypt certificate is issued for it) +DOMAIN=proxy.example.com + +# Email for the ACME account (expiry notices from Let's Encrypt) +ACME_EMAIL= + # 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 diff --git a/.gitignore b/.gitignore index 2eea525..3323b34 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.env \ No newline at end of file +.env +.DS_Store diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..c9c435f --- /dev/null +++ b/Caddyfile @@ -0,0 +1,70 @@ +# elevenlabs.io/Caddyfile +# Outbound API gateway. Two listeners, one shared route set: +# :8080 — plain HTTP (published as 8082 on the host, legacy clients) +# {$DOMAIN} — HTTPS 443 with automatic Let's Encrypt certificates +{ + email {$ACME_EMAIL} + admin off +} + +# --- Access control (generated by docker-entrypoint.sh from env) --- +# matchers.caddy: @denied_ip / @bad_token definitions (empty when disabled) +# guard.caddy: matching respond directives (empty when disabled) + +# --- Shared upstream request hygiene --- +(scrub) { + header_up Host {args[0]} + header_up -X-Forwarded-For + header_up -X-Forwarded-Proto + header_up -X-Forwarded-Host + header_up -X-Real-IP + header_up -True-Client-IP + header_up -CF-Connecting-IP + header_up -X-Client-IP + header_up -Forwarded + header_up -Via + header_up -X-Proxy-Token +} + +# (api) +(api) { + handle_path /{args[0]}/* { + rewrite * {args[2]}{uri} + reverse_proxy https://{args[1]} { + import scrub {args[1]} + flush_interval -1 + transport http { + dial_timeout 15s + response_header_timeout 300s + read_timeout 300s + } + } + } +} + +(routes) { + import /etc/caddy/matchers.caddy + + route { + # Health check — no auth, evaluated before the guards + respond /health `{"status":"ok","version":"2.0"}` 200 + + import /etc/caddy/guard.caddy + + import api elevenlabs api.elevenlabs.io "" + import api openai api.openai.com "" + import api telegram api.telegram.org "" + import api deepseek api.deepseek.com "" + import api openrouter openrouter.ai /api/v1 + + respond `{"error":"unknown_upstream","hint":"use /elevenlabs/, /openai/, /telegram/, /openrouter/ or /deepseek/"}` 404 + } +} + +{$DOMAIN} { + import routes +} + +:8080 { + import routes +} diff --git a/Dockerfile b/Dockerfile index f78a0cd..620b556 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,12 @@ -# proxy-vm/Dockerfile -FROM nginx:1.27-alpine +# elevenlabs.io/Dockerfile +FROM caddy:2.11.4-alpine -# Remove default config -RUN rm -f /etc/nginx/conf.d/default.conf - -# Copy nginx configs -COPY nginx/nginx.conf /etc/nginx/nginx.conf -COPY nginx/locations.conf /etc/nginx/locations.conf - -# Copy entrypoint script +COPY Caddyfile /etc/caddy/Caddyfile COPY docker-entrypoint.sh /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh -# Create directories for generated configs and optional SSL certs -RUN mkdir -p /etc/nginx/conf.d /etc/nginx/ssl - -EXPOSE 8080 8443 +# 80/443 — public HTTPS endpoint (ACME + TLS), 8080 — plain HTTP for legacy clients +EXPOSE 80 443 8080 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 3c15270..1e50f1b 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,16 @@ # 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, Telegram Bot API). +Production-ready Caddy reverse proxy in Docker for forwarding requests from an internal Kubernetes cluster to external APIs (ElevenLabs, OpenAI, Telegram Bot API, OpenRouter, DeepSeek). + +TLS certificates are issued and renewed automatically by Let's Encrypt — no certbot, no manual cert files. ## Quick Start ### Prerequisites - Ubuntu 24.04 VM -- Port 8080 (HTTP) and optionally 8443 (HTTPS) open **only** to k8s cluster CIDR +- Ports **80** and **443** free and reachable from the internet (required for the ACME HTTP-01 challenge) +- A DNS `A` record pointing your domain at the VM (e.g. `proxy.bildme.ru → 62.84.98.250`) +- Port **8082** (plain HTTP) open **only** to the k8s cluster CIDR ### Installation @@ -14,26 +18,36 @@ Production-ready nginx reverse proxy in Docker for forwarding requests from an i sudo bash scripts/install.sh ``` +If the VM already runs a host nginx on 80/443, free those ports first (backs up the config, then disables the service): + +```bash +sudo bash scripts/migrate-host-nginx.sh +``` + ### Configuration ```bash vi .env -# Set your secret and cluster CIDR: -PROXY_SECRET= +DOMAIN=proxy.bildme.ru +ACME_EMAIL=you@example.com +PROXY_SECRET= ALLOWED_CIDR=10.0.0.0/8,172.16.0.0/12 -docker compose up -d --build +docker-compose up -d ``` +`DOMAIN` and `ACME_EMAIL` are mandatory — the entrypoint refuses to start without them. + ### Verify ```bash # Health check (no auth): -curl http://localhost:8080/health +curl http://localhost:8082/health +curl https://proxy.bildme.ru/health # Test with auth: -curl -H "X-Proxy-Token: YOUR_SECRET" http://localhost:8080/elevenlabs/v1/voices +curl -H "X-Proxy-Token: YOUR_SECRET" https://proxy.bildme.ru/elevenlabs/v1/voices ``` --- @@ -49,29 +63,40 @@ curl -H "X-Proxy-Token: YOUR_SECRET" http://localhost:8080/elevenlabs/v1/voices | /deepseek/ | https://api.deepseek.com | | /health | local health check (no auth)| +## Listeners + +| Host port | Container port | Purpose | +|-----------|----------------|------------------------------------------------------| +| 443 (tcp+udp) | 443 | Public HTTPS endpoint for `DOMAIN`, HTTP/3 enabled | +| 80 | 80 | ACME HTTP-01 challenge + redirect to HTTPS | +| 8082 | 8080 | Plain HTTP for legacy in-cluster clients | + +Both listeners serve the same routes and the same access control. + --- ## TLS / HTTPS -To enable HTTPS on port 8443, place your certificate and key in the `ssl/` directory: +Caddy obtains a certificate for `DOMAIN` on first start and renews it automatically (~30 days before expiry). -```bash -mkdir -p ssl +Certificates and the ACME account key live in the `caddy_data` volume mounted at `/data`. **Never drop this volume** — recreating it re-issues certificates and burns the Let's Encrypt rate limit (5 failures/hour, 50 certs/week per domain). -# 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" +To rehearse against the staging CA, add to the global block of `Caddyfile`: -# 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 +```caddyfile +{ + email {$ACME_EMAIL} + acme_ca https://acme-staging-v02.api.letsencrypt.org/directory +} ``` -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. +Check the certificate state: + +```bash +docker exec api-proxy ls /data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/ +echo | openssl s_client -connect proxy.bildme.ru:443 -servername proxy.bildme.ru 2>/dev/null \ + | openssl x509 -noout -subject -issuer -dates +``` --- @@ -79,47 +104,19 @@ The entrypoint auto-detects `ssl/tls.crt` and `ssl/tls.key`. If present — HTTP Example: adding `api.anthropic.com` → `/anthropic/*` -Add this block to `nginx/locations.conf`: +Add one line to the `(routes)` snippet in `Caddyfile`: -```nginx -location /anthropic/ { - if ($allowed_ip = 0) { - return 403 '{"error":"ip_not_allowed"}'; - } - if ($auth_ok = 0) { - return 403 '{"error":"invalid_token"}'; - } - - set $anthropic_upstream https://api.anthropic.com; - rewrite ^/anthropic/(.*) /$1 break; - - 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 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; -} +```caddyfile +import api anthropic api.anthropic.com "" ``` -Then rebuild: `docker compose up -d --build` +Arguments: ` `. The third argument is prepended to the proxied path — `""` for a plain passthrough, `/api/v1` for OpenRouter-style bases. + +Update the catch-all hint in the same snippet, then rebuild: + +```bash +docker-compose up -d --build +``` --- @@ -130,7 +127,7 @@ Then rebuild: `docker compose up -d --build` ```yaml env: - name: PROXY_BASE_URL - value: "http://10.0.1.50:8080" # or https://10.0.1.50:8443 + value: "https://proxy.bildme.ru" # or http://:8082 inside the private network - name: PROXY_SECRET valueFrom: secretKeyRef: @@ -156,30 +153,30 @@ const response = await fetch( ); 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 }), - } -); +// DeepSeek (OpenAI-compatible) +const ds = await fetch(`${process.env.PROXY_BASE_URL}/deepseek/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${process.env.DEEPSEEK_API_KEY}`, + 'X-Proxy-Token': process.env.PROXY_SECRET, + }, + body: JSON.stringify({ model: 'deepseek-chat', messages }), +}); ``` +For OpenAI-compatible SDKs set `baseURL` to `https://proxy.bildme.ru/deepseek` and pass `X-Proxy-Token` as a default header. + --- ## Security Checklist - [ ] `PROXY_SECRET` generated via `openssl rand -hex 32` - [ ] `ALLOWED_CIDR` restricted to cluster CIDR only -- [ ] Port 8080/8443 closed to the public (only k8s CIDR in firewall/NSG) -- [ ] VM has no public IP or is behind NAT +- [ ] Port 8082 closed to the public (only k8s CIDR in firewall/NSG) — it carries API keys in cleartext +- [ ] Clients migrated to `https://DOMAIN`, then port 8082 retired +- [ ] `caddy_data` volume backed up (contains the ACME account key) - [ ] Logs rotate (Docker logging driver configured: 50m x 5 files) -- [ ] TLS certs have restricted permissions (`chmod 600 ssl/tls.key`) --- @@ -187,11 +184,11 @@ const tgResponse = await fetch( ```bash # Tail logs -docker compose logs -f proxy | jq . +docker-compose logs -f proxy | jq . # Requests per upstream per minute -docker compose logs --since=1m proxy --no-log-prefix \ - | jq -r '.uri' \ +docker-compose logs --since=1m proxy --no-log-prefix \ + | jq -r 'select(.logger=="http.log.access") | .request.uri' \ | cut -d'/' -f2 \ | sort | uniq -c | sort -rn @@ -203,8 +200,9 @@ 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. -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/`, `/openrouter/`, `/deepseek/`) 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). +1. A k8s pod sends a request to `https://proxy.bildme.ru/elevenlabs/v1/text-to-speech/...` (or `http://:8082/...`) with the `X-Proxy-Token` header and the original API key. +2. Caddy checks the source IP against `ALLOWED_CIDR` and validates `X-Proxy-Token` — rejecting with 403 if either fails. `/health` is answered before both guards. +3. `handle_path` strips the prefix (`/elevenlabs/`, `/openai/`, `/telegram/`, `/openrouter/`, `/deepseek/`) and the request is forwarded over HTTPS with the upstream host as both `Host` header and TLS SNI. +4. `X-Proxy-Token` and all IP-leaking headers (`X-Forwarded-*`, `X-Real-IP`, `True-Client-IP`, `CF-Connecting-IP`, `X-Client-IP`, `Forwarded`, `Via`) are removed before forwarding; API keys pass through unchanged. +5. Responses stream back unbuffered (`flush_interval -1`) — critical for binary audio, SSE token streams and Telegram long-polling. +6. Access control fragments (`matchers.caddy`, `guard.caddy`) are generated from environment variables at container start by `docker-entrypoint.sh`. diff --git a/VERSION b/VERSION index 6c6aa7c..0ea3a94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 \ No newline at end of file +0.2.0 diff --git a/docker-compose.yml b/docker-compose.yml index 4cccbba..6810b46 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,22 @@ services: proxy: + image: git.realmanual.ru/pub/elevenlabs.io/elevenlabs:latest build: . container_name: api-proxy restart: unless-stopped ports: - - "8080:8080" - - "8443:8443" + # Public HTTPS endpoint + ACME HTTP-01 challenge + - "80:80" + - "443:443" + - "443:443/udp" + # Legacy plain-HTTP endpoint (unchanged for existing clients) + - "8082:8080" 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 + # Let's Encrypt certificates and ACME account keys — MUST persist, + # otherwise every recreate re-issues certs and burns LE rate limits + - caddy_data:/data + - caddy_config:/config healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"] interval: 30s @@ -21,3 +27,7 @@ services: options: max-size: "50m" max-file: "5" + +volumes: + caddy_data: + caddy_config: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index b078b6f..1700ab0 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,87 +1,57 @@ #!/bin/sh -# proxy-vm/docker-entrypoint.sh -# Generates nginx config fragments from environment variables at container start. +# elevenlabs.io/docker-entrypoint.sh +# Generates Caddy access-control fragments from environment variables at container start. set -e -CONF_DIR="/etc/nginx/conf.d" -mkdir -p "${CONF_DIR}" +CONF_DIR="/etc/caddy" +MATCHERS="${CONF_DIR}/matchers.caddy" +GUARD="${CONF_DIR}/guard.caddy" -# --- 1. Generate IP allowlist (geo block) --- -ALLOWLIST_FILE="${CONF_DIR}/allowlist.conf" +: > "${MATCHERS}" +: > "${GUARD}" -if [ -z "${ALLOWED_CIDR}" ]; then - cat > "${ALLOWLIST_FILE}" <<'GEO' -geo $allowed_ip { - default 1; -} -GEO - echo "[entrypoint] ALLOWED_CIDR is empty — allowing all IPs (dev mode)" -else - { - echo 'geo $allowed_ip {' - echo ' default 0;' - echo "${ALLOWED_CIDR}" | tr ',' '\n' | while read -r cidr; do - cidr=$(echo "${cidr}" | xargs) - [ -n "${cidr}" ] && echo " ${cidr} 1;" - done - echo '}' - } > "${ALLOWLIST_FILE}" - echo "[entrypoint] IP allowlist configured: ${ALLOWED_CIDR}" +# --- 0. Required settings --- +if [ -z "${DOMAIN}" ]; then + echo "[entrypoint] FATAL: DOMAIN is not set (e.g. proxy.example.com)" >&2 + exit 1 +fi +if [ -z "${ACME_EMAIL}" ]; then + echo "[entrypoint] FATAL: ACME_EMAIL is not set — Let's Encrypt account needs it" >&2 + exit 1 fi -# --- 2. Generate token auth (map block) --- -AUTH_FILE="${CONF_DIR}/auth.conf" +# --- 1. IP allowlist --- +if [ -z "${ALLOWED_CIDR}" ]; then + echo "[entrypoint] ALLOWED_CIDR is empty — allowing all IPs (dev mode)" +else + CIDR_LIST=$(echo "${ALLOWED_CIDR}" | tr ',' ' ' | tr -s ' ') + cat >> "${MATCHERS}" <> "${GUARD}" < "${AUTH_FILE}" <<'MAP' -map $http_x_proxy_token $auth_ok { - default 1; -} -MAP else - cat > "${AUTH_FILE}" <> "${MATCHERS}" <> "${GUARD}" < "${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;' +# --- 3. Validate and start --- +caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile +echo "[entrypoint] Starting caddy (domain: ${DOMAIN})..." +exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile diff --git a/nginx/locations.conf b/nginx/locations.conf deleted file mode 100644 index 0bdd58f..0000000 --- a/nginx/locations.conf +++ /dev/null @@ -1,204 +0,0 @@ -# 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; - rewrite ^/openrouter/(.*) /api/v1/$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_buffer_size 16k; - proxy_buffers 4 16k; - proxy_busy_buffers_size 32k; - proxy_read_timeout 120s; - proxy_send_timeout 120s; -} - -# --- DeepSeek --- -location /deepseek/ { - if ($allowed_ip = 0) { - return 403 '{"error":"ip_not_allowed"}'; - } - if ($auth_ok = 0) { - return 403 '{"error":"invalid_token"}'; - } - - set $deepseek_upstream https://api.deepseek.com; - rewrite ^/deepseek/(.*) /$1 break; - - proxy_pass $deepseek_upstream; - proxy_ssl_server_name on; - proxy_ssl_name api.deepseek.com; - proxy_ssl_protocols TLSv1.2 TLSv1.3; - - proxy_set_header Host api.deepseek.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_buffer_size 16k; - proxy_buffers 4 16k; - proxy_busy_buffers_size 32k; - proxy_connect_timeout 15s; - proxy_read_timeout 300s; - proxy_send_timeout 120s; -} - -# --- Catch-all --- -location / { - default_type application/json; - return 404 '{"error":"unknown_upstream","hint":"use /elevenlabs/, /openai/, /telegram/, /openrouter/ or /deepseek/"}'; -} diff --git a/nginx/nginx.conf b/nginx/nginx.conf deleted file mode 100644 index 9851c4e..0000000 --- a/nginx/nginx.conf +++ /dev/null @@ -1,57 +0,0 @@ -# proxy-vm/nginx/nginx.conf -worker_processes auto; -error_log /dev/stderr warn; -pid /tmp/nginx.pid; - -events { - worker_connections 1024; -} - -http { - # --- JSON access log --- - log_format json_log escape=json - '{' - '"time":"$time_iso8601",' - '"remote_addr":"$remote_addr",' - '"method":"$request_method",' - '"uri":"$request_uri",' - '"status":$status,' - '"bytes_sent":$bytes_sent,' - '"request_time":$request_time,' - '"upstream_addr":"$upstream_addr",' - '"upstream_response_time":"$upstream_response_time"' - '}'; - - access_log /dev/stdout json_log; - - # --- Hash bucket size for long map keys (PROXY_SECRET) --- - map_hash_bucket_size 128; - - # --- Performance --- - sendfile on; - tcp_nopush on; - tcp_nodelay on; - keepalive_timeout 65; - client_max_body_size 50m; - gzip off; - - # --- DNS resolver (Google + Cloudflare, re-resolve every 30s) --- - resolver 8.8.8.8 1.1.1.1 valid=30s ipv6=off; - resolver_timeout 5s; - - # --- IP allowlist (generated at container start) --- - include /etc/nginx/conf.d/allowlist.conf; - - # --- Token auth --- - include /etc/nginx/conf.d/auth.conf; - - # --- HTTP server --- - server { - listen 8080; - server_name _; - 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/scripts/deploy.sh b/scripts/deploy.sh index 08dda28..bfbc62b 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -1,25 +1,26 @@ #!/usr/bin/env bash -# proxy-vm/scripts/deploy.sh -# Pull latest changes and redeploy containers. +# elevenlabs.io/scripts/deploy.sh +# Pull the latest image built by Gitea Actions and restart the proxy. set -euo pipefail -PROXY_DIR="/opt/proxy/proxy-vm" +PROXY_DIR="${PROXY_DIR:-/srv/elevenlabs}" cd "${PROXY_DIR}" -echo "=== [1/5] Pulling latest code ===" -git -C "$(dirname "${PROXY_DIR}")" pull +COMPOSE="docker-compose" +command -v docker-compose >/dev/null 2>&1 || COMPOSE="docker compose" -echo "=== [2/5] Building container ===" -docker compose build --no-cache +echo "=== [1/4] Pulling latest image ===" +${COMPOSE} pull -echo "=== [3/5] Restarting containers ===" -docker compose up -d --force-recreate +echo "=== [2/4] Recreating container ===" +${COMPOSE} up -d -echo "=== [4/5] Container status ===" -docker compose ps +echo "=== [3/4] Status ===" +${COMPOSE} ps -echo "=== [5/5] Recent logs ===" -docker compose logs --tail=20 proxy +echo "=== [4/4] Recent logs ===" +${COMPOSE} logs --tail=30 proxy echo "" -echo "Deploy complete." +echo "Health: curl -s http://localhost:8082/health" +echo "TLS: curl -s https://${DOMAIN:-proxy.bildme.ru}/health" diff --git a/scripts/install.sh b/scripts/install.sh index 7831608..42cabdc 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -3,7 +3,7 @@ # Bootstrap script for fresh Ubuntu 24.04 VM. set -euo pipefail -PROXY_DIR="/opt/proxy" +PROXY_DIR="/srv/elevenlabs" REPO_URL="https://github.com/YOUR_ORG/YOUR_REPO.git" # TODO: replace with actual repo echo "=== [1/6] Installing Docker and dependencies ===" @@ -25,7 +25,7 @@ else fi echo "=== [5/6] Setting up .env ===" -cd "${PROXY_DIR}/proxy-vm" +cd "${PROXY_DIR}" if [ ! -f .env ]; then cp .env.example .env # Generate a random PROXY_SECRET @@ -33,21 +33,21 @@ if [ ! -f .env ]; then sed -i "s/change-me-generate-with-openssl-rand-hex-32/${GENERATED_SECRET}/" .env echo "" echo ">>> .env created with auto-generated PROXY_SECRET" - echo ">>> Edit /opt/proxy/proxy-vm/.env to set ALLOWED_CIDR" + echo ">>> Edit ${PROXY_DIR}/.env to set DOMAIN, ACME_EMAIL and ALLOWED_CIDR" echo "" else echo ".env already exists, skipping..." fi echo "=== [6/6] Starting containers ===" -docker compose up -d --build +docker-compose up -d echo "" echo "==========================================" -echo " Proxy is running on port 8080" -echo " Health check: curl http://localhost:8080/health" +echo " Proxy is running on ports 80/443 (HTTPS) and 8082 (plain HTTP)" +echo " Health check: curl http://localhost:8082/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 " IMPORTANT: Edit ${PROXY_DIR}/.env" +echo " - Set DOMAIN, ACME_EMAIL and ALLOWED_CIDR" +echo " - Then run: docker-compose up -d" echo "==========================================" diff --git a/scripts/migrate-host-nginx.sh b/scripts/migrate-host-nginx.sh new file mode 100755 index 0000000..13de026 --- /dev/null +++ b/scripts/migrate-host-nginx.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# elevenlabs.io/scripts/migrate-host-nginx.sh +# Frees ports 80/443 on the host so the Caddy container can obtain Let's Encrypt certs. +# Backs up the whole host nginx configuration first, then stops and disables the service. +set -euo pipefail + +BACKUP_DIR="/root/nginx-backup-$(date +%Y%m%d-%H%M%S)" + +if ! systemctl is-active --quiet nginx; then + echo "Host nginx is not running — nothing to migrate." + exit 0 +fi + +echo "=== [1/5] Dumping host nginx config to ${BACKUP_DIR} ===" +mkdir -p "${BACKUP_DIR}" +nginx -T > "${BACKUP_DIR}/nginx-T.dump" 2>&1 || true +tar -czf "${BACKUP_DIR}/etc-nginx.tar.gz" /etc/nginx 2>/dev/null +cp -a /etc/letsencrypt "${BACKUP_DIR}/letsencrypt" 2>/dev/null || true +echo "Backup written." + +echo "=== [2/5] Virtual hosts currently served ===" +grep -hE '^\s*server_name' "${BACKUP_DIR}/nginx-T.dump" | sed 's/^[ \t]*//' | sort -u + +read -r -p "Stop and disable host nginx? These vhosts will go offline [y/N] " answer +case "${answer}" in + [yY]) ;; + *) echo "Aborted. Backup kept at ${BACKUP_DIR}"; exit 1 ;; +esac + +echo "=== [3/5] Stopping nginx ===" +systemctl disable --now nginx + +echo "=== [4/5] Certbot renewal hooks ===" +# Certbot keeps trying to renew certs through the nginx plugin — it would fail now. +systemctl disable --now certbot.timer 2>/dev/null || true +echo "certbot.timer disabled (Caddy manages its own certificates)." + +echo "=== [5/5] Port check ===" +ss -tlnp | grep -E ':(80|443)\s' && { echo "Ports 80/443 are still busy — inspect before starting Caddy."; exit 1; } +echo "Ports 80 and 443 are free." +echo "" +echo "Backup: ${BACKUP_DIR}" +echo "Next: cd /srv/elevenlabs && docker-compose pull && docker-compose up -d" diff --git a/ssl/.gitignore b/ssl/.gitignore deleted file mode 100644 index 27afd58..0000000 --- a/ssl/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -# Ignore actual TLS certificates — never commit to git -*.crt -*.key -*.pem -!.gitkeep -!.gitignore diff --git a/ssl/.gitkeep b/ssl/.gitkeep deleted file mode 100644 index e69de29..0000000