refactor: switch nginx to Caddy with automatic Let's Encrypt
Build Admin / Build image (push) Successful in 13s
Build Admin / Build image (push) Successful in 13s
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 <prefix> <host> <path-prefix>` 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) <noreply@anthropic.com>
This commit is contained in:
15 files changed
+283
-460
No files matched your search
@@ -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
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
.env
|
||||
.DS_Store
|
||||
@@ -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) <prefix> <upstream-host> <upstream-path-prefix>
|
||||
(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
|
||||
}
|
||||
+5
-14
@@ -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
|
||||
|
||||
@@ -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=<generated-token>
|
||||
DOMAIN=proxy.bildme.ru
|
||||
ACME_EMAIL=you@example.com
|
||||
PROXY_SECRET=<openssl rand -hex 32>
|
||||
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: `<url-prefix> <upstream-host> <upstream-path-prefix>`. 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://<vm-ip>: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`,
|
||||
{
|
||||
// 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({ chat_id: chatId, text: message }),
|
||||
}
|
||||
);
|
||||
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://<proxy-vm>: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://<vm-ip>: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`.
|
||||
+15
-5
@@ -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:
|
||||
+42
-72
@@ -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}" <<MATCH
|
||||
@denied_ip not remote_ip ${CIDR_LIST}
|
||||
MATCH
|
||||
cat >> "${GUARD}" <<GUARDRULE
|
||||
respond @denied_ip \`{"error":"ip_not_allowed"}\` 403 {
|
||||
close
|
||||
}
|
||||
GUARDRULE
|
||||
echo "[entrypoint] IP allowlist configured: ${CIDR_LIST}"
|
||||
fi
|
||||
|
||||
# --- 2. Token auth ---
|
||||
if [ -z "${PROXY_SECRET}" ]; then
|
||||
echo "[entrypoint] PROXY_SECRET is not set — token auth disabled (open mode)"
|
||||
cat > "${AUTH_FILE}" <<'MAP'
|
||||
map $http_x_proxy_token $auth_ok {
|
||||
default 1;
|
||||
}
|
||||
MAP
|
||||
else
|
||||
cat > "${AUTH_FILE}" <<MAP
|
||||
map \$http_x_proxy_token \$auth_ok {
|
||||
default 0;
|
||||
"${PROXY_SECRET}" 1;
|
||||
cat >> "${MATCHERS}" <<MATCH
|
||||
@bad_token not header X-Proxy-Token "${PROXY_SECRET}"
|
||||
MATCH
|
||||
cat >> "${GUARD}" <<GUARDRULE
|
||||
respond @bad_token \`{"error":"invalid_token"}\` 403 {
|
||||
close
|
||||
}
|
||||
MAP
|
||||
GUARDRULE
|
||||
echo "[entrypoint] Token auth configured"
|
||||
fi
|
||||
|
||||
# --- 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
|
||||
server {
|
||||
listen 8443 ssl;
|
||||
server_name _;
|
||||
|
||||
ssl_certificate ${SSL_CERT};
|
||||
ssl_certificate_key ${SSL_KEY};
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
include /etc/nginx/conf.d/locations.conf;
|
||||
}
|
||||
HTTPS
|
||||
echo "[entrypoint] HTTPS enabled on port 8443 (cert: ${SSL_CERT})"
|
||||
else
|
||||
# Empty file so nginx include does not fail
|
||||
: > "${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
|
||||
@@ -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/"}';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+15
-14
@@ -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"
|
||||
+9
-9
@@ -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 "=========================================="
|
||||
Executable
+43
@@ -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"
|
||||
@@ -1,6 +0,0 @@
|
||||
# Ignore actual TLS certificates — never commit to git
|
||||
*.crt
|
||||
*.key
|
||||
*.pem
|
||||
!.gitkeep
|
||||
!.gitignore
|
||||
Whitespace-only changes.
Reference in new issue
Block a user