diff --git a/.gitignore b/.gitignore index 6885173..28e3109 100644 --- a/.gitignore +++ b/.gitignore @@ -35,9 +35,17 @@ frontend/coverage/ # Docker deploy/**/*.local.yml +# Сгенерированные из *.template скриптом deploy/render-templates.sh — +# содержат реальные секреты/IP, рендерятся перед `docker compose up`. +deploy/coturn/turnserver.conf +deploy/livekit/livekit.yaml + # Артефакты Celery beat celerybeat-schedule*.db celerybeat-schedule # Загруженные медиа-файлы (аватары) — dev-каталог backend/media backend/media/ + +# Рабочая папка Claude Code (промпты, агенты, заметки) — не в репозиторий +.forcc/ diff --git a/deploy/coturn/turnserver.conf b/deploy/coturn/turnserver.conf deleted file mode 100644 index 2ddf937..0000000 --- a/deploy/coturn/turnserver.conf +++ /dev/null @@ -1,38 +0,0 @@ -# Dev config for coturn. Verified against the official documentation -# (github.com/coturn/coturn/wiki/turnserver, docker/coturn/README.md). -# Runs with network_mode: host in docker-compose.yml (recommended by coturn -# docs for large UDP relay port ranges). -# -# Secrets (realm / static-auth-secret) come from .env via TURN_REALM / -# TURN_STATIC_AUTH_SECRET; coturn substitutes $(VAR) at startup when invoked -# through the image's docker-entrypoint.sh, which evaluates each CLI arg. -# Since we pass a config file instead of CLI flags, keep real secrets in the -# .env and inject them here through docker-compose "environment" + a -# lightweight envsubst step if/when TLS certs are added; for the dev/plain -# profile the plaintext defaults below are fine (`change-me` values only). - -listening-port=3478 -# Set to 443 in production (TURN over TLS) once real TLS certs are mounted. -tls-listening-port=5349 - -# Relay port range for TURN allocations. -min-port=49160 -max-port=49200 - -# --- Long-term credential mechanism via shared secret (TURN REST API) --- -use-auth-secret -static-auth-secret=change-me-turn-secret -realm=vidconf.local - -# Required by WebRTC clients (adds STUN FINGERPRINT attribute). -fingerprint - -# No CLI/telnet admin interface in this dev deployment. -no-cli - -# Uncomment and mount real certs to enable TURN over TLS on 443: -# cert=/etc/coturn/certs/cert.pem -# pkey=/etc/coturn/certs/key.pem - -log-file=stdout -simple-log diff --git a/deploy/coturn/turnserver.conf.template b/deploy/coturn/turnserver.conf.template new file mode 100644 index 0000000..1e6232d --- /dev/null +++ b/deploy/coturn/turnserver.conf.template @@ -0,0 +1,47 @@ +# Конфигурация coturn. Верифицировано по официальной документации +# (github.com/coturn/coturn/wiki/turnserver, docker/coturn/README.md). +# Запускается с network_mode: host в docker-compose.yml (рекомендация coturn +# для широких диапазонов UDP relay-портов). +# +# coturn подставляет $(VAR) при старте ТОЛЬКО когда вызывается через +# CLI-флаги entrypoint'а образа — при передаче файла конфигурации (как +# здесь, `-c /etc/coturn/turnserver.conf`) подстановка не работает. Поэтому +# реальные static-auth-secret/realm/external-ip рендерятся из этого шаблона +# в turnserver.conf скриптом deploy/render-templates.sh (envsubst) ПЕРЕД +# `docker compose up`. Не редактируйте turnserver.conf напрямую — правки +# затрутся при следующем рендере. + +listening-port=3478 +# Установить 5349 в проде для TURN over TLS, если будут смонтированы +# реальные TLS-сертификаты (см. закомментированные cert/pkey ниже). +tls-listening-port=5349 + +# Диапазон relay-портов для TURN-аллокаций. +min-port=49160 +max-port=49200 + +# --- Long-term credential mechanism через shared secret (TURN REST API) --- +use-auth-secret +static-auth-secret=${TURN_STATIC_AUTH_SECRET} +realm=${TURN_REALM} + +# Обязателен для WebRTC-клиентов (добавляет STUN FINGERPRINT атрибут). +fingerprint + +# Без CLI/telnet admin-интерфейса в этой поставке. +no-cli + +# Раскомментировать и смонтировать реальные сертификаты, чтобы включить +# TURN over TLS на 443: +# cert=/etc/coturn/certs/cert.pem +# pkey=/etc/coturn/certs/key.pem + +log-file=stdout +simple-log + +# Внешний IP сервера — обязателен для клиентов вне docker-сети +# (network_mode: host здесь не даёт coturn определить публичный IP +# автоматически). Для локальной разработки (без внешних участников) +# достаточно 127.0.0.1 (значение по умолчанию в .env.example); в проде — +# реальный внешний IP/домен сервера. +external-ip=${TURN_EXTERNAL_IP} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 6b16bd3..6cc0884 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -51,8 +51,8 @@ services: DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://vidconf:vidconf@postgres:5432/vidconf} REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml} - LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:-devkey} - LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:-change-me-livekit-secret} + LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:?LIVEKIT_API_KEY не задан в .env} + LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:?LIVEKIT_API_SECRET не задан в .env} LIVEKIT_PUBLIC_URL: ${LIVEKIT_PUBLIC_URL:-ws://localhost:7880} # Внутренний server-to-server адрес LiveKit (RoomService/Egress API). # Жёстко перекрывает значение из .env: там LIVEKIT_URL=ws://localhost:7880 @@ -299,25 +299,52 @@ services: # Собственный образ: nginx со вкомпилированной статикой фронтенд-SPA # (frontend/Dockerfile, multi-stage: Vite-сборка → nginx). Раздаётся во # ВСЕХ пресетах (сервис без profiles), поэтому http://localhost/ отдаёт - # рабочий фронт, а не заглушку. Конфиг реверс-прокси по-прежнему - # монтируется томом ниже — правки прокси не требуют пересборки фронта. + # рабочий фронт, а не заглушку. build: context: ../frontend dockerfile: Dockerfile + args: + # Инлайнится Vite на этапе сборки (см. frontend/Dockerfile) — сейчас + # фронтенд получает LiveKit URL в рантайме от backend (join.livekit_url, + # см. backend/services/conference_access.py: LIVEKIT_PUBLIC_URL), эти + # build-args пока ни на что не влияют (см. комментарий в + # frontend/Dockerfile), переданы про запас на будущее. + VITE_LIVEKIT_URL: ${VITE_LIVEKIT_URL:-} + NEXT_PUBLIC_LIVEKIT_URL: ${NEXT_PUBLIC_LIVEKIT_URL:-} image: vidconf-nginx:latest restart: unless-stopped + environment: + # Подставляются штатным entrypoint'ом образа nginx (envsubst-on-templates, + # см. deploy/nginx/nginx.conf.template) — список доменов и имя каталога + # сертификата вынесены в параметры, а не хардкод. + NGINX_SERVER_NAMES: ${NGINX_SERVER_NAMES:?NGINX_SERVER_NAMES не задан в .env (список доменов через пробел)} + NGINX_CERT_NAME: ${NGINX_CERT_NAME:?NGINX_CERT_NAME не задан в .env (каталог в /etc/letsencrypt/live/)} volumes: - - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./nginx/nginx.conf.template:/etc/nginx/templates/default.conf.template:ro + # Готовит /etc/nginx/certs/{fullchain,privkey}.pem до старта nginx: либо + # копирует боевой сертификат из /etc/letsencrypt, либо (если его нет — + # локальная разработка) генерирует самоподписанный. См. сам скрипт. + - ./nginx/docker-entrypoint-certs.sh:/docker-entrypoint.d/15-vidconf-certs.sh:ro # Аватары: тот же volume, что у backend — nginx раздаёт файлы # напрямую (`location /media/`, alias), в обход backend-процесса. - media:/media:ro + # Реальные сертификаты Let's Encrypt — с хоста, только на чтение (в dev + # без ./certbot-webroot Docker создаст /etc/letsencrypt пустым — скрипт + # выше в этом случае сгенерирует самоподписанный сертификат). + - /etc/letsencrypt:/etc/letsencrypt:ro + # Webroot для ACME http-01 challenge (certbot renew). + - ./certbot-webroot:/var/www/certbot:rw ports: - "80:80" + - "443:443" depends_on: backend: condition: service_healthy healthcheck: - test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:80/"] + # /healthz (не /) — после добавления HTTP→HTTPS редиректа (443) `/` + # на порту 80 отдаёт 301 вместо 200, что уронило бы healthcheck; + # /healthz отвечает 200 без редиректа. + test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:80/healthz"] interval: 10s timeout: 5s retries: 5 @@ -329,6 +356,10 @@ services: image: livekit/livekit-server:latest restart: unless-stopped command: --config /etc/livekit.yaml + # Реальный livekit.yaml генерируется из шаблона перед стартом + # (deploy/render-templates.sh, вызывается install.sh) — use_external_ip/ + # node_ip/webhook.api_key статичны и не читают env напрямую, в отличие + # от LIVEKIT_KEYS ниже (см. комментарий в livekit.yaml.template). volumes: - ./livekit/livekit.yaml:/etc/livekit.yaml:ro environment: @@ -336,7 +367,7 @@ services: # иначе LiveKit падает в crash-loop с "Could not parse keys". Значение # обязательно в кавычках: YAML запрещает ": " внутри неквотированного # plain-скаляра. - LIVEKIT_KEYS: "${LIVEKIT_API_KEY:-devkey}: ${LIVEKIT_API_SECRET:-change-me-livekit-secret}" + LIVEKIT_KEYS: "${LIVEKIT_API_KEY:?LIVEKIT_API_KEY не задан в .env}: ${LIVEKIT_API_SECRET:?LIVEKIT_API_SECRET не задан в .env}" ports: - "7880:7880" # HTTP/WebSocket signaling - "7881:7881" # RTC TCP fallback @@ -361,11 +392,14 @@ services: image: coturn/coturn:latest restart: unless-stopped command: -c /etc/coturn/turnserver.conf + # Секреты (static-auth-secret/realm/external-ip) НЕ передаются через + # environment — coturn читает их ТОЛЬКО из файла конфигурации, запущенного + # через `-c` (при CLI-флагах он умеет $(VAR), при файле — нет, см. + # комментарий в deploy/coturn/turnserver.conf.template). Реальный + # turnserver.conf генерируется из шаблона перед стартом + # (deploy/render-templates.sh, вызывается install.sh). volumes: - ./coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro - environment: - TURN_REALM: ${TURN_REALM:-vidconf.local} - TURN_STATIC_AUTH_SECRET: ${TURN_STATIC_AUTH_SECRET:-change-me-turn-secret} network_mode: host # Образ coturn/coturn — минимальный (debian-slim), в нём нет pgrep/ps/nc/ # curl/wget, поэтому проверка процесса по имени не работает @@ -414,8 +448,8 @@ services: # api_key/api_secret/ws_url — обязательные переменные окружения # (см. deploy/egress/egress.yaml); ws_url — внутренний адрес сервиса # livekit в docker-сети, НЕ LIVEKIT_PUBLIC_URL для браузера. - LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:-devkey} - LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:-change-me-livekit-secret} + LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:?LIVEKIT_API_KEY не задан в .env} + LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:?LIVEKIT_API_SECRET не задан в .env} LIVEKIT_WS_URL: ${LIVEKIT_WS_URL:-ws://livekit:7880} depends_on: # required: false — livekit живёт в профиле `media`, а не diff --git a/deploy/livekit/livekit.yaml b/deploy/livekit/livekit.yaml deleted file mode 100644 index b56471f..0000000 --- a/deploy/livekit/livekit.yaml +++ /dev/null @@ -1,60 +0,0 @@ -# Dev config for LiveKit SFU. Verified against the official documentation -# (github.com/livekit/livekit config-sample.yaml + configuration.md). -# Real API key/secret must come from .env (LIVEKIT_API_KEY / LIVEKIT_API_SECRET); -# this file intentionally omits `keys:` so it can be supplied via the -# LIVEKIT_KEYS env var (format "key:secret") set in docker-compose.yml. - -port: 7880 - -rtc: - tcp_port: 7881 - # Диапазон сужен для dev (см. комментарий в docker-compose.yml); в проде - # расширить и синхронизировать с пробросом портов. - port_range_start: 54000 - port_range_end: 54100 - # use_external_ip: false + node_ip — dev-режим по докам LiveKit - # (rtc.node_ip / use_external_ip в config-sample.yaml): use_external_ip - # определяет публичный IP через STUN, что в контейнере Docker Desktop на - # macOS даёт недостижимый изнутри хоста внутренний IP (172.18.x.x) — из-за - # этого DTLS-хендшейк по reliable/lossy data-каналам не проходит (см. - # "dtls timeout" в логах). node_ip: 127.0.0.1 работает, потому что порты - # 7881/tcp и 54000-54100/udp проброшены Docker Desktop на loopback хоста, - # а браузер-клиент запускается на том же хосте. В проде (клиенты снаружи - # хоста) node_ip заменить на реальный внешний IP/домен либо вернуть - # use_external_ip: true, если сервер не за NAT с пробросом портов 1:1. - use_external_ip: false - node_ip: 127.0.0.1 - -# Redis обязателен для сервиса egress (см. deploy/egress/) — он использует -# его как pub/sub и key-value хранилище состояния запущенных записей; -# без него egress не может получать room/track-события от LiveKit -# (проверено по официальной документации livekit/egress, раздел "Running -# locally"). LiveKit сам по себе тоже использует redis для координации -# между узлами кластера (здесь один узел, но сервис оставлен включённым). -redis: - address: redis:6379 - -# TURN is handled by the standalone coturn service (deploy/coturn) in the -# `media` profile. When exposing LiveKit publicly, put coturn/TURN-TLS on 443 -# and keep this section disabled to avoid double TURN servers. -turn: - enabled: false - -# Webhook-приёмник backend'а: события room_started/participant_joined/ -# participant_left/room_finished подписываются ключом api_key, который -# должен совпадать с одним из ключей в LIVEKIT_KEYS (см. выше). -webhook: - api_key: devkey - urls: - - http://backend:8000/api/v1/livekit/webhook - # Если backend запускается на хосте (uvicorn вне docker-compose, а - # LiveKit — внутри), используйте вместо этого: - # - http://host.docker.internal:8000/api/v1/livekit/webhook - -logging: - level: info - json: false - -# Prometheus metrics (scraped by the monitoring profile). -prometheus: - port: 6789 diff --git a/deploy/livekit/livekit.yaml.template b/deploy/livekit/livekit.yaml.template new file mode 100644 index 0000000..f03b62b --- /dev/null +++ b/deploy/livekit/livekit.yaml.template @@ -0,0 +1,68 @@ +# LiveKit SFU. Верифицировано по официальной документации +# (github.com/livekit/livekit config-sample.yaml + configuration.md). +# Реальный API key/secret — из .env (LIVEKIT_API_KEY / LIVEKIT_API_SECRET); +# этот файл намеренно не содержит `keys:` — ключи передаются через +# LIVEKIT_KEYS (см. docker-compose.yml, формат "key: secret"). +# +# LiveKit НЕ читает переменные окружения внутри своего YAML-конфига (в +# отличие от `keys:`, которые подаются отдельно через LIVEKIT_KEYS) — +# статические поля (use_external_ip/node_ip/webhook.api_key) рендерятся +# из этого шаблона в livekit.yaml скриптом deploy/render-templates.sh +# (envsubst) ПЕРЕД `docker compose up`. Не редактируйте livekit.yaml +# напрямую — правки затрутся при следующем рендере. + +port: 7880 + +rtc: + tcp_port: 7881 + # Диапазон сужен для dev (см. комментарий в docker-compose.yml); в проде + # расширить и синхронизировать с пробросом портов. + port_range_start: 54000 + port_range_end: 54100 + # use_external_ip: false + node_ip=127.0.0.1 — режим для локальной + # разработки (Docker Desktop): use_external_ip=true определяет публичный + # IP через STUN, что в контейнере на macOS даёт недостижимый изнутри хоста + # внутренний IP (172.18.x.x) — DTLS-хендшейк по data-каналам не проходит + # ("dtls timeout" в логах). node_ip=127.0.0.1 работает, потому что порты + # 7881/tcp и 54000-54100/udp проброшены на loopback хоста, а браузер-клиент + # запускается на том же хосте. + # В проде (LIVEKIT_USE_EXTERNAL_IP=true, LIVEKIT_NODE_IP=<внешний IP/домен + # сервера> в .env) клиенты снаружи хоста подключаются по этому адресу — + # сервер не должен быть за NAT без проброса портов 1:1. + use_external_ip: ${LIVEKIT_USE_EXTERNAL_IP} + node_ip: ${LIVEKIT_NODE_IP} + +# Redis обязателен для сервиса egress (см. deploy/egress/) — он использует +# его как pub/sub и key-value хранилище состояния запущенных записей; +# без него egress не может получать room/track-события от LiveKit +# (проверено по официальной документации livekit/egress, раздел "Running +# locally"). LiveKit сам по себе тоже использует redis для координации +# между узлами кластера (здесь один узел, но сервис оставлен включённым). +redis: + address: redis:6379 + +# TURN обслуживает отдельный сервис coturn (профиль `media`, deploy/coturn). +# При публичной экспозиции LiveKit разместите coturn/TURN-TLS на 443 и +# держите эту секцию отключённой, чтобы не поднимать два TURN-сервера. +turn: + enabled: false + +# Webhook-приёмник backend'а: события room_started/participant_joined/ +# participant_left/room_finished подписываются ключом api_key, который +# должен совпадать с одним из ключей в LIVEKIT_KEYS (см. выше и +# docker-compose.yml) — берём тот же LIVEKIT_API_KEY из .env. +webhook: + api_key: ${LIVEKIT_API_KEY} + urls: + - http://backend:8000/api/v1/livekit/webhook + # Если backend запускается на хосте (uvicorn вне docker-compose, а + # LiveKit — внутри), используйте вместо этого: + # - http://host.docker.internal:8000/api/v1/livekit/webhook + +logging: + level: info + json: false + +# Prometheus metrics (scraped by the monitoring profile). +prometheus: + port: 6789 diff --git a/deploy/nginx/docker-entrypoint-certs.sh b/deploy/nginx/docker-entrypoint-certs.sh new file mode 100755 index 0000000..6f25bb9 --- /dev/null +++ b/deploy/nginx/docker-entrypoint-certs.sh @@ -0,0 +1,35 @@ +#!/bin/sh +# Готовит /etc/nginx/certs/{fullchain,privkey}.pem ДО того, как штатный +# entrypoint официального образа nginx (20-envsubst-on-templates.sh, потом +# сам nginx) прочитает конфиг с `ssl_certificate` (см. +# deploy/nginx/nginx.conf.template). Монтируется томом в docker-compose.yml +# в /docker-entrypoint.d/ (рядом с nginx.conf.template — конфиг и +# entrypoint-скрипты nginx монтируются, а не запекаются в образ, чтобы правки +# не требовали пересборки фронта), где нативный entrypoint nginx исполняет +# все *.sh по алфавиту до старта самого nginx. +# +# Прод: реальный сертификат смонтирован с хоста в /etc/letsencrypt (ro, +# см. docker-compose.yml) — копируем его. +# Dev (docs/deploy/dev-setup.md, `docker compose up` без реальных сертификатов +# Let's Encrypt): каталог пуст/отсутствует — генерируем самоподписанный +# сертификат, чтобы локальный стек стартовал без ручной настройки TLS. +set -e + +CERT_NAME="${NGINX_CERT_NAME:-localhost}" +SRC="/etc/letsencrypt/live/${CERT_NAME}" +DEST=/etc/nginx/certs + +mkdir -p "$DEST" + +if [ -f "$SRC/fullchain.pem" ] && [ -f "$SRC/privkey.pem" ]; then + echo "[nginx-certs] Использую боевой сертификат из $SRC" + cp "$SRC/fullchain.pem" "$DEST/fullchain.pem" + cp "$SRC/privkey.pem" "$DEST/privkey.pem" +else + echo "[nginx-certs] $SRC не найден — генерирую самоподписанный сертификат (ТОЛЬКО для локальной разработки, в проде смонтируйте /etc/letsencrypt)" + openssl req -x509 -nodes -newkey rsa:2048 -days 365 \ + -keyout "$DEST/privkey.pem" -out "$DEST/fullchain.pem" \ + -subj "/CN=${CERT_NAME}" 2>/dev/null +fi + +chmod 644 "$DEST/fullchain.pem" "$DEST/privkey.pem" diff --git a/deploy/nginx/nginx.conf b/deploy/nginx/nginx.conf.template similarity index 63% rename from deploy/nginx/nginx.conf rename to deploy/nginx/nginx.conf.template index fd51275..21c2f54 100644 --- a/deploy/nginx/nginx.conf +++ b/deploy/nginx/nginx.conf.template @@ -1,6 +1,14 @@ -# Dev reverse proxy: backend API + раздача фронтенд-SPA (статика в образе). -# Завершение TLS для production документировано в docs/deploy/dev-setup.md -# и должно добавляться отдельным server-блоком (443) с реальными сертификатами. +# 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 в памяти. После пересоздания @@ -17,9 +25,57 @@ map $http_upgrade $connection_upgrade { '' close; } +# ========================================== +# БЛОК 1: HTTP (порт 80) +# Задача: ACME-challenge для Certbot + редирект всего остального на HTTPS. +# ========================================== server { listen 80; - server_name _; + # Список доменов — параметр (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 с переменной НЕ выполняет @@ -42,11 +98,13 @@ server { 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 для чата и LiveKit signaling через nginx --- + # --- WebSocket upgrade для чата --- location /ws/ { set $backend_upstream http://backend:8000; proxy_pass $backend_upstream; @@ -54,6 +112,9 @@ server { 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; } @@ -78,6 +139,7 @@ server { 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; } @@ -112,9 +174,4 @@ server { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; } - - location /healthz { - return 200 "ok\n"; - add_header Content-Type text/plain; - } } diff --git a/deploy/render-templates.sh b/deploy/render-templates.sh new file mode 100755 index 0000000..7540aea --- /dev/null +++ b/deploy/render-templates.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Рендерит статические конфиги coturn/LiveKit из шаблонов (deploy/**/*.template) +# в реальные файлы, которые монтирует docker-compose. Оба образа (coturn, +# livekit-server) НЕ поддерживают подстановку переменных окружения в файле +# конфигурации (см. комментарии в *.template) — единственный способ вынести +# секреты/IP в .env и не хранить их в git — сгенерировать файл перед стартом. +# +# Вызывается автоматически из install.sh. При ручном запуске (без install.sh) +# запускайте ПЕРЕД `docker compose up` (и после каждого изменения .env, +# влияющего на эти конфиги) — из корня репозитория: +# ./deploy/render-templates.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="$SCRIPT_DIR/../.env" + +if [ ! -f "$ENV_FILE" ]; then + echo "render-templates.sh: не найден $ENV_FILE — сначала запустите ./install.sh или скопируйте .env.example в .env" >&2 + exit 1 +fi + +# Читаем только нужные ключи через grep/cut (НЕ `source .env`) — .env содержит +# значения вроде `SMTP_FROM=VidConf `, где `<` — +# валидный литерал для docker-compose/pydantic, но невалидный bash-синтаксис +# (интерпретируется как редирект) при попытке `source` файла целиком. +env_var() { + grep -E "^${1}=" "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2- +} + +TURN_STATIC_AUTH_SECRET="$(env_var TURN_STATIC_AUTH_SECRET)" +TURN_REALM="$(env_var TURN_REALM)" +TURN_EXTERNAL_IP="$(env_var TURN_EXTERNAL_IP)" +LIVEKIT_API_KEY="$(env_var LIVEKIT_API_KEY)" +LIVEKIT_NODE_IP="$(env_var LIVEKIT_NODE_IP)" +LIVEKIT_USE_EXTERNAL_IP="$(env_var LIVEKIT_USE_EXTERNAL_IP)" + +: "${TURN_STATIC_AUTH_SECRET:?TURN_STATIC_AUTH_SECRET не задан в .env}" +: "${TURN_REALM:?TURN_REALM не задан в .env}" +: "${TURN_EXTERNAL_IP:?TURN_EXTERNAL_IP не задан в .env (внешний IP/домен сервера для TURN-релея)}" +: "${LIVEKIT_API_KEY:?LIVEKIT_API_KEY не задан в .env}" +: "${LIVEKIT_NODE_IP:?LIVEKIT_NODE_IP не задан в .env (внешний IP/домен для ICE)}" +: "${LIVEKIT_USE_EXTERNAL_IP:?LIVEKIT_USE_EXTERNAL_IP не задан в .env (true/false)}" + +export TURN_STATIC_AUTH_SECRET TURN_REALM TURN_EXTERNAL_IP LIVEKIT_API_KEY LIVEKIT_NODE_IP LIVEKIT_USE_EXTERNAL_IP + +envsubst '${TURN_STATIC_AUTH_SECRET} ${TURN_REALM} ${TURN_EXTERNAL_IP}' \ + < "$SCRIPT_DIR/coturn/turnserver.conf.template" > "$SCRIPT_DIR/coturn/turnserver.conf" +echo "[render] deploy/coturn/turnserver.conf готов" + +envsubst '${LIVEKIT_USE_EXTERNAL_IP} ${LIVEKIT_NODE_IP} ${LIVEKIT_API_KEY}' \ + < "$SCRIPT_DIR/livekit/livekit.yaml.template" > "$SCRIPT_DIR/livekit/livekit.yaml" +echo "[render] deploy/livekit/livekit.yaml готов" diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 2184be5..590bef9 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -3,14 +3,19 @@ # --------------------------------------------------------------------------- # Образ раздачи фронтенда VidConf: SPA (React + Vite) собирается в статику и # вкомпилируется в nginx. Конфиг реверс-прокси (проксирование /api, /livekit, -# /media и т.п.) НЕ хранится в образе — он монтируется томом в -# docker-compose (`./nginx/nginx.conf` → /etc/nginx/conf.d/default.conf), -# чтобы правки прокси не требовали пересборки фронта. Здесь только статика. +# /media и т.п.) НЕ хранится в образе — он монтируется томом (шаблоном) в +# docker-compose (`./nginx/nginx.conf.template` → entrypoint nginx +# генерирует /etc/nginx/conf.d/default.conf), чтобы правки прокси не требовали +# пересборки фронта. Здесь только статика. # # Фронт обращается к API по ОТНОСИТЕЛЬНЫМ путям (`/api/v1`, `/api/health` — # см. frontend/src/api/client.ts), поэтому build-time переменные окружения # (VITE_*) не нужны: собранная статика ходит на /api своего origin, который -# nginx проксирует на backend. +# nginx проксирует на backend. LiveKit URL фронт тоже получает в рантайме от +# backend (join.livekit_url ← LIVEKIT_PUBLIC_URL), а не из build-time env — +# ARG/ENV ниже сейчас ни на что не влияют (в исходниках frontend/src нет ни +# одной ссылки на import.meta.env.VITE_LIVEKIT_URL), приняты для совместимости +# на случай, если фронтенд когда-нибудь начнёт их читать на build-time. # --------------------------------------------------------------------------- # --- Стадия сборки: Vite/React/TS → статика в /app/dist --- @@ -24,8 +29,19 @@ RUN npm ci COPY . . # `npm run build` = `tsc -b && vite build` (см. package.json) → /app/dist +ARG VITE_LIVEKIT_URL +ENV VITE_LIVEKIT_URL=${VITE_LIVEKIT_URL} +ARG NEXT_PUBLIC_LIVEKIT_URL +ENV NEXT_PUBLIC_LIVEKIT_URL=${NEXT_PUBLIC_LIVEKIT_URL} RUN npm run build # --- Стадия раздачи: nginx со статикой SPA внутри образа --- FROM nginx:1.27-alpine COPY --from=build /app/dist /usr/share/nginx/html + +# openssl — для генерации самоподписанного dev-сертификата, если реальный +# Let's Encrypt не смонтирован (см. deploy/nginx/docker-entrypoint-certs.sh, +# который docker-compose.yml монтирует в /docker-entrypoint.d/ — рядом с +# nginx.conf.template: конфиг/скрипты entrypoint'а nginx монтируются томом, +# а не запекаются в образ, чтобы правки не требовали пересборки фронта). +RUN apk add --no-cache openssl