From c144d0967f3a0b00035b2b460d33ba38d77bcfa1 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 24 Oct 2026 14:20:52 +0700 Subject: [PATCH] feat: add header key auth as alternative to IP allowlist --- .env.example | 6 ++++++ Caddyfile | 2 ++ README.md | 8 +++++--- docker-entrypoint.sh | 39 +++++++++++++++++++++++++++++++++++---- 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 7e4f742..a0a4609 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,9 @@ 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" ALLOWED_CIDR= + +# API key header: a request carrying this header bypasses the IP allowlist (IP OR key). +# Set both or neither. Name: [A-Za-z0-9-], value: [A-Za-z0-9._~+/=-]. Only matters if ALLOWED_CIDR is set. +# Generate value with: openssl rand -hex 32 +AUTH_HEADER_NAME= +AUTH_HEADER_VALUE= diff --git a/Caddyfile b/Caddyfile index c9c435f..c7f5949 100644 --- a/Caddyfile +++ b/Caddyfile @@ -10,6 +10,7 @@ # --- 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) +# scrub_extra.caddy: strips the optional auth key header from upstream requests # --- Shared upstream request hygiene --- (scrub) { @@ -24,6 +25,7 @@ header_up -Forwarded header_up -Via header_up -X-Proxy-Token + import /etc/caddy/scrub_extra.caddy } # (api) diff --git a/README.md b/README.md index 030cd40..66d646a 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ DOMAIN=proxy.bildme.ru ACME_EMAIL=you@example.com PROXY_SECRET= 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= docker compose up -d ``` @@ -207,7 +209,7 @@ option.WithHeader("X-Proxy-Token", os.Getenv("PROXY_SECRET")), ### `403 {"error":"ip_not_allowed"}` -The caller's source IP is not in `ALLOWED_CIDR`. Check the effective list: +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 @@ -240,8 +242,8 @@ 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://: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. +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` 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. +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`. diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 1700ab0..3984d28 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -6,9 +6,11 @@ set -e CONF_DIR="/etc/caddy" MATCHERS="${CONF_DIR}/matchers.caddy" GUARD="${CONF_DIR}/guard.caddy" +SCRUB_EXTRA="${CONF_DIR}/scrub_extra.caddy" : > "${MATCHERS}" : > "${GUARD}" +: > "${SCRUB_EXTRA}" # --- 0. Required settings --- if [ -z "${DOMAIN}" ]; then @@ -20,13 +22,42 @@ if [ -z "${ACME_EMAIL}" ]; then exit 1 fi -# --- 1. IP allowlist --- +# --- 1. API key header (optional bypass of the IP allowlist) --- +KEY_MATCH="" +if [ -n "${AUTH_HEADER_NAME}" ] || [ -n "${AUTH_HEADER_VALUE}" ]; then + if [ -z "${AUTH_HEADER_NAME}" ] || [ -z "${AUTH_HEADER_VALUE}" ]; then + echo "[entrypoint] FATAL: AUTH_HEADER_NAME and AUTH_HEADER_VALUE must be set together" >&2 + exit 1 + fi + case "${AUTH_HEADER_NAME}" in + *[!A-Za-z0-9-]*) + echo "[entrypoint] FATAL: AUTH_HEADER_NAME may contain only [A-Za-z0-9-]" >&2 + exit 1 ;; + esac + case "${AUTH_HEADER_VALUE}" in + *[!A-Za-z0-9._~+/=-]*) + echo "[entrypoint] FATAL: AUTH_HEADER_VALUE may contain only [A-Za-z0-9._~+/=-]" >&2 + exit 1 ;; + esac + KEY_MATCH="not header ${AUTH_HEADER_NAME} \"${AUTH_HEADER_VALUE}\"" + echo "# strip the key header before it reaches upstreams" > "${SCRUB_EXTRA}" + echo "header_up -${AUTH_HEADER_NAME}" >> "${SCRUB_EXTRA}" + echo "[entrypoint] Header key auth configured (header: ${AUTH_HEADER_NAME})" +fi + +# --- 2. IP allowlist (a valid key header also grants access) --- if [ -z "${ALLOWED_CIDR}" ]; then echo "[entrypoint] ALLOWED_CIDR is empty — allowing all IPs (dev mode)" + if [ -n "${KEY_MATCH}" ]; then + echo "[entrypoint] WARNING: header key has no effect while ALLOWED_CIDR is empty" >&2 + fi else CIDR_LIST=$(echo "${ALLOWED_CIDR}" | tr ',' ' ' | tr -s ' ') cat >> "${MATCHERS}" <> "${GUARD}" <