diff --git a/.env.example b/.env.example index 78971ad..5357ad7 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,16 @@ TURN_STATIC_AUTH_SECRET=change-me-turn-secret # скриптом deploy/render-templates.sh (вызывается install.sh). TURN_EXTERNAL_IP=127.0.0.1 +# TURN over TLS (5349) — единственный переключатель во всём проекте: пусто = +# TLS выключен везде (dev-дефолт, как ниже), непустое значение = coturn +# слушает TLS на 5349 (сертификат смонтирован из /etc/letsencrypt через +# coturn-certs-init, docker-compose.yml) И LiveKit объявляет клиентам запись +# protocol: tls. ⚠️ Обязан быть ДОМЕНОМ сертификата (например, vidconf.ru — +# тем же, что и NGINX_CERT_NAME), а НЕ IP-адресом, в отличие от +# TURN_EXTERNAL_IP выше: браузер проверяет TLS-сертификат TURN-сервера по +# имени хоста, а Let's Encrypt выписывает сертификат на домен. +TURN_TLS_HOST= + # --- Nginx: TLS (443) + список доменов — deploy/nginx/nginx.conf.template --- # Домены, которые обслуживает nginx (через пробел, все — в server_name). NGINX_SERVER_NAMES=example.com www.example.com diff --git a/deploy/coturn/turnserver.conf.template b/deploy/coturn/turnserver.conf.template index 1e6232d..b2c8e54 100644 --- a/deploy/coturn/turnserver.conf.template +++ b/deploy/coturn/turnserver.conf.template @@ -12,8 +12,8 @@ # затрутся при следующем рендере. listening-port=3478 -# Установить 5349 в проде для TURN over TLS, если будут смонтированы -# реальные TLS-сертификаты (см. закомментированные cert/pkey ниже). +# TLS-порт объявлен всегда; реально слушать TLS coturn начинает только когда +# заданы cert/pkey ниже (блок TLS-CERT) — без них строка ничего не включает. tls-listening-port=5349 # Диапазон relay-портов для TURN-аллокаций. @@ -31,10 +31,15 @@ fingerprint # Без CLI/telnet admin-интерфейса в этой поставке. no-cli -# Раскомментировать и смонтировать реальные сертификаты, чтобы включить -# TURN over TLS на 443: -# cert=/etc/coturn/certs/cert.pem -# pkey=/etc/coturn/certs/key.pem +# Блок ниже рендерится ТОЛЬКО когда в .env задан TURN_TLS_HOST (см. +# render-templates.sh) — тогда coturn-certs-init (docker-compose.yml) уже +# скопировал fullchain/privkey из /etc/letsencrypt в volume coturn-certs. +# Без TURN_TLS_HOST маркеры и всё, что между ними, вырезаются целиком — +# в файле не остаётся ни следа cert/pkey, а не просто закомментированных строк. +# BEGIN-TLS-CERT +cert=/etc/coturn/certs/cert.pem +pkey=/etc/coturn/certs/key.pem +# END-TLS-CERT log-file=stdout simple-log diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 88ffe49..09eb8c3 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -432,6 +432,49 @@ services: profiles: ["media"] logging: *default-logging + # Образ coturn/coturn — Dockerfile прописывает `USER nobody:nogroup`, и это + # НЕ runtime-привилегия, которую можно сбросить: Docker exec'ает entrypoint + # сразу от этого uid, root-фазы внутри контейнера нет вовсе (в отличие от + # официального образа nginx, который стартует entrypoint от root и только + # nginx-воркеры позже понижают права по директиве в конфиге — см. + # deploy/nginx/docker-entrypoint-certs.sh). Значит coturn физически не может + # сам прочитать приватный ключ Let's Encrypt (root:root, обычно 0600) — + # никакой volume-опцией это не обойти, не ослабляя права на ключ на хосте. + # + # Решение — по образцу уже существующего `recordings-init`/`llm-models-init` + # в этом файле: отдельный init-контейнер (busybox, дефолтный root) читает + # /etc/letsencrypt (той же ro-монтировкой, что и у nginx) и копирует + # fullchain/privkey в СВОЙ volume под правами 644 — это копия, а не + # оригинал, оригинальный ключ на хосте прав не меняет. Копия достаточно + # открыта, чтобы её прочитал nobody:nogroup внутри coturn. + # + # Если /etc/letsencrypt/live/<домен> не существует (dev, нет реальных + # сертификатов) — команда ниже просто ничего не копирует и завершается + # успешно; coturn стартует как раньше, без TLS (см. TURN_TLS_HOST в + # render-templates.sh — вторая половина того же переключателя). + coturn-certs-init: + image: busybox:1.36 + command: > + sh -c ' + SRC="/etc/letsencrypt/live/$$NGINX_CERT_NAME"; + if [ -f "$$SRC/fullchain.pem" ] && [ -f "$$SRC/privkey.pem" ]; then + cp "$$SRC/fullchain.pem" /certs/cert.pem; + cp "$$SRC/privkey.pem" /certs/key.pem; + chmod 644 /certs/cert.pem /certs/key.pem; + echo "[coturn-certs-init] сертификат $$SRC скопирован в volume coturn-certs"; + else + echo "[coturn-certs-init] $$SRC не найден — TLS для coturn не настроен (норма для dev без TURN_TLS_HOST)"; + fi + ' + environment: + NGINX_CERT_NAME: ${NGINX_CERT_NAME:?NGINX_CERT_NAME не задан в .env} + volumes: + - /etc/letsencrypt:/etc/letsencrypt:ro + - coturn-certs:/certs + restart: "no" + profiles: ["media"] + logging: *default-logging + coturn: image: coturn/coturn:latest restart: unless-stopped @@ -444,6 +487,10 @@ services: # (deploy/render-templates.sh, вызывается install.sh). volumes: - ./coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro + - coturn-certs:/etc/coturn/certs:ro + depends_on: + coturn-certs-init: + condition: service_completed_successfully network_mode: host # Образ coturn/coturn — минимальный (debian-slim), в нём нет pgrep/ps/nc/ # curl/wget, поэтому проверка процесса по имени не работает @@ -887,6 +934,11 @@ volumes: # Загруженные пользователями файлы (аватары) — общий том между # backend (запись при загрузке) и nginx (раздача статики, `location /media/`). media: + # Копия fullchain/privkey Let's Encrypt под правами 644 для coturn + # (nobody:nogroup) — источник в /etc/letsencrypt не трогаем, см. + # coturn-certs-init выше. Обновляется при каждом перезапуске + # coturn-certs-init (deploy-hook certbot делает это при продлении). + coturn-certs: # Метрики Prometheus (профиль `monitoring`) — переживают пересоздание контейнера. prometheus_data: # Дашборды/настройки Grafana (профиль `monitoring`) — переживают пересоздание. diff --git a/deploy/livekit/livekit.yaml.template b/deploy/livekit/livekit.yaml.template index 93cdaef..e3e3e3a 100644 --- a/deploy/livekit/livekit.yaml.template +++ b/deploy/livekit/livekit.yaml.template @@ -54,10 +54,24 @@ rtc: # оба рендерятся из одного TURN_STATIC_AUTH_SECRET (deploy/render-templates.sh). # Логин/пароль LiveKit генерирует сам по механизму TURN REST API. # - # UDP и TCP на 3478 — оба порта уже открыты в ufw. TLS (5349) намеренно не - # объявляем: в turnserver.conf сертификаты не смонтированы, и анонс - # неработающего `turns:` заставил бы клиента впустую ждать таймаута, - # прежде чем перейти к рабочему кандидату. + # UDP и TCP на 3478 — оба порта уже открыты в ufw. + # + # TLS (5349) объявляется ТОЛЬКО когда в .env задан TURN_TLS_HOST (см. + # render-templates.sh) — до тех пор блок между маркерами вырезается + # целиком, и клиент его не увидит вовсе. Это осознанно: анонс + # неработающего `turns:` (без смонтированных в coturn сертификатов) + # заставил бы клиента впустую ждать TLS-таймаута, прежде чем перейти + # к рабочему кандидату — именно так это и стояло здесь до включения TLS. + # + # Хост для TLS-записи обязан быть ДОМЕНОМ, а не IP (в отличие от udp/tcp + # выше): браузер проверяет TLS-сертификат TURN-сервера по имени хоста, + # а сертификат Let's Encrypt выписан на домен, не на IP — с IP в host + # TLS-хендшейк упадёт на проверке имени, и это будет выглядеть как ещё + # один вариант «coturn healthy, но relay не работает». + # + # TLS-запись стоит ПОСЛЕДНЕЙ: клиент перебирает кандидатов по порядку, + # а TLS через TCP дороже прямого UDP — она должна быть фолбэком, а не + # выбираться первой. turn_servers: - host: ${TURN_EXTERNAL_IP} port: 3478 @@ -69,6 +83,13 @@ rtc: protocol: tcp secret: ${TURN_STATIC_AUTH_SECRET} ttl: 14400 + # BEGIN-TLS-TURN + - host: ${TURN_TLS_HOST} + port: 5349 + protocol: tls + secret: ${TURN_STATIC_AUTH_SECRET} + ttl: 14400 + # END-TLS-TURN # Redis обязателен для сервиса egress (см. deploy/egress/) — он использует # его как pub/sub и key-value хранилище состояния запущенных записей; diff --git a/deploy/render-templates.sh b/deploy/render-templates.sh index 067a96b..00bc73e 100755 --- a/deploy/render-templates.sh +++ b/deploy/render-templates.sh @@ -23,13 +23,27 @@ fi # значения вроде `SMTP_FROM=VidConf `, где `<` — # валидный литерал для docker-compose/pydantic, но невалидный bash-синтаксис # (интерпретируется как редирект) при попытке `source` файла целиком. +# +# `|| true` в конце обязателен: под `set -e -o pipefail` (см. выше) сборка +# `"$(env_var VAR)"` для ключа, которого в файле нет ВООБЩЕ (не просто +# пустое значение, а отсутствующая строка) иначе завершает весь скрипт +# ошибкой grep ДО того, как сработает дружелюбная проверка `:?` ниже — +# найдено на TURN_TLS_HOST (новый необязательный ключ, есть не во всех +# существующих .env). env_var() { - grep -E "^${1}=" "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2- + grep -E "^${1}=" "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2- || true } TURN_STATIC_AUTH_SECRET="$(env_var TURN_STATIC_AUTH_SECRET)" TURN_REALM="$(env_var TURN_REALM)" TURN_EXTERNAL_IP="$(env_var TURN_EXTERNAL_IP)" +# TURN_TLS_HOST — единственный переключатель TURN over TLS (5349) во всём +# проекте: непустой = TLS смонтирован и объявляется клиентам, пустой = TLS +# отсутствует везде (dev по умолчанию). Поэтому НЕ обязателен (без `:?`) — +# в отличие от TURN_EXTERNAL_IP, который должен быть IP хоста, TURN_TLS_HOST +# обязан быть ДОМЕНОМ сертификата (иначе браузер не пройдёт TLS-валидацию +# по имени хоста для `turns:`, см. комментарий в livekit.yaml.template). +TURN_TLS_HOST="$(env_var TURN_TLS_HOST)" 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)" @@ -43,17 +57,31 @@ REDIS_PASSWORD="$(env_var REDIS_PASSWORD)" : "${LIVEKIT_USE_EXTERNAL_IP:?LIVEKIT_USE_EXTERNAL_IP не задан в .env (true/false)}" : "${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env (redis запускается с --requirepass, см. docker-compose.yml)}" -export TURN_STATIC_AUTH_SECRET TURN_REALM TURN_EXTERNAL_IP LIVEKIT_API_KEY LIVEKIT_NODE_IP LIVEKIT_USE_EXTERNAL_IP REDIS_PASSWORD +export TURN_STATIC_AUTH_SECRET TURN_REALM TURN_EXTERNAL_IP TURN_TLS_HOST LIVEKIT_API_KEY LIVEKIT_NODE_IP LIVEKIT_USE_EXTERNAL_IP REDIS_PASSWORD + +# Вырезает блок между парой маркеров-комментариев (не только их самих), если +# TURN_TLS_HOST пуст — так рендер отражает реальное наличие TLS-сертификатов, +# а не просто закомментированные "на будущее" строки. +strip_tls_block_if_disabled() { + local file="$1" begin_marker="$2" end_marker="$3" + if [ -z "$TURN_TLS_HOST" ]; then + sed -i.bak "/${begin_marker}/,/${end_marker}/d" "$file" && rm -f "$file.bak" + else + sed -i.bak "/${begin_marker}/d; /${end_marker}/d" "$file" && rm -f "$file.bak" + fi +} 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 готов" +strip_tls_block_if_disabled "$SCRIPT_DIR/coturn/turnserver.conf" "# BEGIN-TLS-CERT" "# END-TLS-CERT" +echo "[render] deploy/coturn/turnserver.conf готов$([ -n "$TURN_TLS_HOST" ] && echo " (TLS включён)" || echo " (TLS выключен — TURN_TLS_HOST пуст)")" # TURN_EXTERNAL_IP и TURN_STATIC_AUTH_SECRET нужны и здесь: с 0.0.14 LiveKit # анонсирует клиентам внешний coturn (секция `rtc.turn_servers`), и секрет # обязан совпадать с `static-auth-secret` в turnserver.conf выше. -envsubst '${LIVEKIT_USE_EXTERNAL_IP} ${LIVEKIT_NODE_IP} ${LIVEKIT_API_KEY} ${REDIS_PASSWORD} ${TURN_EXTERNAL_IP} ${TURN_STATIC_AUTH_SECRET}' \ +envsubst '${LIVEKIT_USE_EXTERNAL_IP} ${LIVEKIT_NODE_IP} ${LIVEKIT_API_KEY} ${REDIS_PASSWORD} ${TURN_EXTERNAL_IP} ${TURN_STATIC_AUTH_SECRET} ${TURN_TLS_HOST}' \ < "$SCRIPT_DIR/livekit/livekit.yaml.template" > "$SCRIPT_DIR/livekit/livekit.yaml" +strip_tls_block_if_disabled "$SCRIPT_DIR/livekit/livekit.yaml" "# BEGIN-TLS-TURN" "# END-TLS-TURN" echo "[render] deploy/livekit/livekit.yaml готов" envsubst '${REDIS_PASSWORD}' \ diff --git a/install.sh b/install.sh index bad8333..9db6d56 100755 --- a/install.sh +++ b/install.sh @@ -412,6 +412,10 @@ ensure_default NGINX_CERT_NAME "localhost" ensure_default LIVEKIT_USE_EXTERNAL_IP "false" ensure_default LIVEKIT_NODE_IP "127.0.0.1" ensure_default TURN_EXTERNAL_IP "127.0.0.1" +# Пусто = TURN over TLS выключен (см. docs/deploy/DEPLOYMENT.md §8) — не +# генерируем и не требуем здесь, только гарантируем, что ключ явно есть в +# .env (для discoverability), а не отсутствует молча. +ensure_default TURN_TLS_HOST "" # Профили compose и модели по пресету. GPU-профили — ТОЛЬКО для пресета 5 # (max): в текущей матрице уровней (backend/services/ai_tiers.py, ADR-004)