deploy: harden nginx/coturn/livekit configs (TLS on 443, templates, required secrets)

Переносит рабочие правки продакшен-сервера в репозиторий воспроизводимо:

- nginx: HTTPS-блок на 443 (TLS1.2/1.3, http->https redirect, ACME
  webroot, X-Forwarded-Proto) добавлен в nginx.conf.template. Список
  доменов и имя каталога сертификата — параметры (NGINX_SERVER_NAMES,
  NGINX_CERT_NAME), не хардкод. Рендерится штатным entrypoint'ом образа
  nginx (envsubst-on-templates).
- Самоподписанный dev-сертификат генерируется на старте контейнера
  (docker-entrypoint-certs.sh), если реальный Let's Encrypt не смонтирован
  — иначе `docker compose up` без профиля ломался бы локально без
  реальных сертификатов.
- healthcheck nginx переключён на /healthz (без TLS-редиректа), иначе
  http://127.0.0.1:80/ после добавления 443-редиректа всегда unhealthy.
- coturn/turnserver.conf и livekit/livekit.yaml переведены в *.template —
  оба образа не читают env для статических полей (static-auth-secret,
  use_external_ip/node_ip, webhook.api_key), поэтому реальные файлы
  генерируются перед стартом стека скриптом deploy/render-templates.sh
  (вызывается install.sh). Секреты/IP — только в .env, не в git.
- docker-compose.yml: LIVEKIT_API_KEY/SECRET везде (backend, livekit,
  egress) через ${VAR:?} без небезопасных дефолтов; порт 443 + монтирование
  /etc/letsencrypt (ro) и certbot-webroot у nginx; build-args
  VITE_LIVEKIT_URL/NEXT_PUBLIC_LIVEKIT_URL для frontend (сейчас не
  используются кодом, оставлены про запас).
This commit is contained in:
2026-07-25 21:57:46 +03:00
parent 896455381a
commit 79003eb56c
10 changed files with 343 additions and 124 deletions

View File

@@ -0,0 +1,177 @@
# Reverse proxy: backend API + раздача фронтенд-SPA (статика в образе) +
# LiveKit SFU signaling. TLS-терминация — здесь же (см. блок HTTPS ниже),
# сертификаты берутся из /etc/letsencrypt (см. docker-compose.yml, volume ro).
#
# Шаблон рендерится штатным entrypoint-скриптом официального образа nginx
# (docker-entrypoint.d/20-envsubst-on-templates.sh): при старте контейнера
# envsubst подставляет ТОЛЬКО переменные, реально заданные в окружении
# контейнера (см. `environment:` сервиса nginx в docker-compose.yml), поэтому
# нативные nginx-переменные ($scheme, $host, $http_upgrade, $remote_addr,
# $connection_upgrade и т.п.) остаются нетронутыми — с ними коллизий по
# именам нет. Результат пишется в /etc/nginx/conf.d/default.conf.
#
# ВАЖНО (stale DNS): статический `upstream { server backend:8000; }` nginx
# резолвит один раз при старте/reload и держит IP в памяти. После пересоздания
# контейнера backend (`docker compose up -d --force-recreate backend`) Docker
# выдаёт ему новый IP, а nginx продолжает стучаться по старому → 502, пока
# nginx не перезапустят. Чтобы резолвить имя заново на каждый запрос,
# используем embedded DNS Docker (127.0.0.11) через directive `resolver` и
# ПЕРЕМЕННУЮ в proxy_pass — переменные nginx не кэшируются на старте и
# резолвятся заново по истечении `valid=`.
resolver 127.0.0.11 valid=10s ipv6=off;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# ==========================================
# БЛОК 1: HTTP (порт 80)
# Задача: ACME-challenge для Certbot + редирект всего остального на HTTPS.
# ==========================================
server {
listen 80;
# Список доменов — параметр (NGINX_SERVER_NAMES в .env), не хардкод.
server_name ${NGINX_SERVER_NAMES};
# Раздача файлов для (авто)обновления Certbot — ОБЯЗАТЕЛЬНО оставить
# для работы webroot-плагина.
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Healthcheck контейнера (см. docker-compose.yml) ходит сюда напрямую по
# HTTP — редирект ниже иначе увёл бы его на HTTPS и ломал бы wget без -L.
location /healthz {
return 200 "ok\n";
add_header Content-Type text/plain;
}
# Жёсткий редирект всего остального на HTTPS.
location / {
return 301 https://$host$request_uri;
}
}
# ==========================================
# БЛОК 2: HTTPS (порт 443)
# Задача: вся основная логика приложения — API, фронтенд, WebSocket, LiveKit.
# ==========================================
server {
listen 443 ssl;
server_name ${NGINX_SERVER_NAMES};
# Путь СТАТИЧЕН (не envsubst) — реальный источник переменный:
# /docker-entrypoint.d/15-vidconf-certs.sh (запускается раньше рендера
# этого шаблона) копирует сюда боевой сертификат из
# /etc/letsencrypt/live/${NGINX_CERT_NAME}, если он смонтирован с хоста
# (см. docker-compose.yml), иначе генерирует самоподписанный — это
# позволяет `docker compose up` стартовать локально (dev) БЕЗ реальных
# сертификатов Let's Encrypt (см. docs/deploy/dev-setup.md).
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# --- Backend API ---
# Проброс через переменную: proxy_pass с переменной НЕ выполняет
# автоматическую подстановку URI (в отличие от статического
# `proxy_pass http://upstream/prefix/;`), поэтому URI-часть в proxy_pass
# не указываем — nginx передаёт исходный URI запроса как есть (path +
# query), что и сохраняет прежний маппинг /api/ -> backend:8000/api/
# (префикс совпадает 1-в-1, подмены не требовалось и раньше).
# Upgrade-заголовки нужны и обычным HTTP-запросам (без `Upgrade` в запросе
# `map $http_upgrade $connection_upgrade` выше подставляет `close`, что не
# ломает keep-alive обычных ответов), и WS-эндпоинту чата конференции
# (`WS /api/v1/conferences/{id}/chat`) — он живёт под тем же
# префиксом /api/, а не под отдельным /ws/.
location /api/ {
set $backend_upstream http://backend:8000;
proxy_pass $backend_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# $scheme здесь равен 'https' — критично, чтобы бэкенд (FastAPI)
# генерировал корректные абсолютные ссылки и принимал Secure-куки.
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
}
# --- WebSocket upgrade для чата ---
location /ws/ {
set $backend_upstream http://backend:8000;
proxy_pass $backend_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
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_read_timeout 3600s;
}
# --- LiveKit SFU signaling (WebSocket) ---
# Доступен только когда стек запущен с профилем `media`
# (см. docker-compose.yml) — без него resolver вернёт NXDOMAIN на первый
# запрос к этому location, что ожидаемо.
# Здесь префикс /livekit/ нужно СНЯТЬ (как раньше делал
# `proxy_pass http://livekit_upstream/;`). С переменной в proxy_pass
# автоматическая подмена недоступна, поэтому переписываем URI явно через
# rewrite ... break — nginx передаст на upstream уже переписанный $uri.
# ВАЖНО: `set` должен идти ДО `rewrite ... break` — break прерывает
# выполнение всех последующих директив модуля rewrite (включая set) в
# этом location, иначе переменная останется неинициализированной.
location /livekit/ {
set $livekit_upstream http://livekit:7880;
rewrite ^/livekit/(.*)$ /$1 break;
proxy_pass $livekit_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
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_read_timeout 3600s;
}
# --- Метрики backend: `GET /metrics` без авторизации
# внутри приложения (backend/api/metrics.py) — снаружи периметра нарочно
# закрываем явным запретом. Это ОБЯЗАТЕЛЬНО именно exact-match
# (`location = /metrics`): ниже есть catch-all `location /` (раздача
# фронтенд-SPA), который иначе отдал бы на /metrics файл фронта (или
# index.html через try_files). Exact-match в nginx приоритетнее любого
# prefix-location, поэтому 403 срабатывает раньше SPA-раздачи.
# Prometheus (профиль compose `monitoring`) ходит в backend НАПРЯМУЮ по
# внутренней docker-сети (`backend:8000/metrics`), минуя nginx.
location = /metrics {
return 403;
}
# --- Медиа (аватары): раздача напрямую из volume, в обход backend. ---
location /media/ {
alias /media/;
autoindex off;
}
# --- Frontend SPA (React + Vite): статика вкомпилирована в образ nginx
# (frontend/Dockerfile) в /usr/share/nginx/html. `try_files` с
# history-fallback на /index.html нужен для клиентского роутинга
# react-router (deep-ссылки вида /conferences/<id>, /admin/settings —
# при перезагрузке страницы отдаётся index.html, дальше роутит SPA).
# Более специфичные location выше (/api/, /media/, = /metrics, /livekit/,
# /ws/) матчатся раньше этого catch-all, поэтому API и метрики не
# перехватываются фронтом.
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
}