250 lines
8.3 KiB
Markdown
250 lines
8.3 KiB
Markdown
# API Proxy — HTTPS Reverse Proxy
|
|
|
|
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
|
|
|
|
> Requires Docker Compose **v2** (`docker compose`). The legacy `docker-compose` 1.29.x fails on Docker 28 with `KeyError: 'ContainerConfig'`.
|
|
>
|
|
> `apt-get install -y docker-compose-v2`
|
|
|
|
### Prerequisites
|
|
- Ubuntu 24.04 VM
|
|
- 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
|
|
|
|
```bash
|
|
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
|
|
|
|
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
|
|
AUTH_HEADER_NAME=X-Api-Key # optional: access from any IP by header
|
|
AUTH_HEADER_VALUE=<openssl rand -hex 32>
|
|
|
|
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:8082/health
|
|
curl https://proxy.bildme.ru/health
|
|
|
|
# Test with auth:
|
|
curl -H "X-Proxy-Token: YOUR_SECRET" https://proxy.bildme.ru/elevenlabs/v1/voices
|
|
```
|
|
|
|
---
|
|
|
|
## Available Endpoints
|
|
|
|
| Prefix | Upstream |
|
|
|--------------|-----------------------------|
|
|
| /elevenlabs/ | https://api.elevenlabs.io |
|
|
| /openai/ | https://api.openai.com |
|
|
| /telegram/ | https://api.telegram.org |
|
|
| /openrouter/ | https://openrouter.ai/api/v1|
|
|
| /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
|
|
|
|
Caddy obtains a certificate for `DOMAIN` on first start and renews it automatically (~30 days before expiry).
|
|
|
|
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).
|
|
|
|
To rehearse against the staging CA, add to the global block of `Caddyfile`:
|
|
|
|
```caddyfile
|
|
{
|
|
email {$ACME_EMAIL}
|
|
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
|
|
}
|
|
```
|
|
|
|
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
|
|
```
|
|
|
|
---
|
|
|
|
## How to Add a New Upstream
|
|
|
|
Example: adding `api.anthropic.com` → `/anthropic/*`
|
|
|
|
Add one line to the `(routes)` snippet in `Caddyfile`:
|
|
|
|
```caddyfile
|
|
import api anthropic api.anthropic.com ""
|
|
```
|
|
|
|
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
|
|
```
|
|
|
|
---
|
|
|
|
## K8s Side: How to Call the Proxy
|
|
|
|
### Environment Variables
|
|
|
|
```yaml
|
|
env:
|
|
- name: PROXY_BASE_URL
|
|
value: "https://proxy.bildme.ru" # or http://<vm-ip>:8082 inside the private network
|
|
- name: PROXY_SECRET
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: api-proxy
|
|
key: token
|
|
```
|
|
|
|
### TypeScript Examples
|
|
|
|
```typescript
|
|
// 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,
|
|
'X-Proxy-Token': process.env.PROXY_SECRET,
|
|
},
|
|
body: JSON.stringify(payload),
|
|
}
|
|
);
|
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
|
|
// 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 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)
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### `403 {"error":"invalid_token"}` on every path
|
|
|
|
The proxy answered, not the upstream — `X-Proxy-Token` is missing or wrong. The body is the proxy's own format; a real upstream error looks different (DeepSeek: `{"error":{"message":...,"type":...}}`).
|
|
|
|
The guard applies to **every** prefix. Before v0.2.0 `/openrouter/` was the one route without a token check — that exception is gone, so clients that relied on it must send the header too.
|
|
|
|
```bash
|
|
curl -H "X-Proxy-Token: $PROXY_SECRET" \
|
|
-H "Authorization: Bearer $DEEPSEEK_API_KEY" \
|
|
https://proxy.bildme.ru/deepseek/user/balance
|
|
```
|
|
|
|
For OpenAI-compatible SDKs put it in the default headers, next to `baseURL`:
|
|
|
|
```go
|
|
option.WithBaseURL("https://proxy.bildme.ru/deepseek"),
|
|
option.WithHeader("X-Proxy-Token", os.Getenv("PROXY_SECRET")),
|
|
```
|
|
|
|
### `403 {"error":"ip_not_allowed"}`
|
|
|
|
The caller's source IP is not in `ALLOWED_CIDR` and the request has no valid `AUTH_HEADER_NAME: AUTH_HEADER_VALUE` header. Check the effective list:
|
|
|
|
```bash
|
|
docker exec api-proxy cat /etc/caddy/matchers.caddy
|
|
```
|
|
|
|
### Upstream errors pass through unchanged
|
|
|
|
`401`, `404` or `5xx` with an upstream-shaped JSON body means the proxy did its job and the upstream rejected the request — check the API key, not the proxy.
|
|
|
|
---
|
|
|
|
## Monitoring
|
|
|
|
```bash
|
|
# Tail logs
|
|
docker compose logs -f proxy | jq .
|
|
|
|
# Requests per upstream per minute
|
|
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
|
|
|
|
# Container health
|
|
docker inspect --format='{{.State.Health.Status}}' api-proxy
|
|
```
|
|
|
|
---
|
|
|
|
## How It Works
|
|
|
|
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 lets the request through if the source IP is in `ALLOWED_CIDR` **or** it carries the `AUTH_HEADER_NAME: AUTH_HEADER_VALUE` header, then validates `X-Proxy-Token` (if `PROXY_SECRET` is set) — rejecting with 403 on failure. `/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`, the `AUTH_HEADER_NAME` header 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`.
|