diff --git a/README.md b/README.md index 996bc19..be8648c 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ - [Step 2. First-run wizard](#step-2-first-run-wizard) - [Step 3. Configure in the UI](#step-3-configure-in-the-ui) - [Website widget](#website-widget) - - [Calls relay (optional)](#calls-relay-optional) + - [Calls](#calls) - [External file storage (optional)](#external-file-storage-optional) - [Updating](#updating) - [Features](#features) @@ -58,7 +58,7 @@ The platform installs on your own server with a single command. Customer data st | **Server** | Linux, x86_64 | | **Software** | Docker with the Docker Compose plugin | | **Ports** | 80 and 443 open | -| **Calls relay (optional)** | A dedicated public IP, port 3478 and UDP range 49160–49999 | +| **Ports for calls** | 3478 (UDP and TCP) and the UDP range 49160–49999 | A domain is not needed to start. The installation opens by the server's IP address; the domain is set later in the settings. @@ -116,15 +116,16 @@ After creating a web widget, add one tag to your site: The chat opens in an isolated window on top of the site. -### Calls relay (optional) +### Calls -Audio and video calls run directly between browsers. If customers or employees sit behind strict NAT or a corporate firewall, enable the TURN relay: +Calls work right after the installation. Between browsers the conversation goes directly; when one side sits behind strict NAT or on a VPN it goes through the relay, which starts together with the stack on the same address. Nothing to configure: the relay addresses appear in **Settings → TURN for calls** on their own, derived from the installation address, and are only changed if you run your own server. -```bash -COMPOSE_PROFILES=calls CHATBALLS_CALL_TURN_REALM= CHATBALLS_TURN_EXTERNAL_IP= CHATBALLS_TURN_LISTENING_IP= docker compose up -d --wait -``` +Open on the firewall: -The relay listens on a dedicated IP so that port 443 does not conflict with the web gateway. The certificate for TURN over TLS is placed in the directory set by `CHATBALLS_TURN_CERTS_DIR`. TURN addresses are then entered in **Settings → Communication**. +- 3478/udp and 3478/tcp — the relay itself; +- 49160–49999/udp — the conversation ports (two per call). + +Networks that allow nothing but port 443 will not reach the relay on 3478. They need TURN over TLS on 443, which means a separate public address (443 on the main one belongs to the web gateway) or an external TURN service — its addresses go into the same settings. ### External file storage (optional) @@ -270,7 +271,7 @@ docker compose logs worker
Calls do not connect -Between browsers a call goes directly. If one side is behind strict NAT, a relay is needed: enable the `calls` profile and enter the TURN addresses in **Settings**. Check that port 3478 and the UDP range 49160–49999 are open on the firewall. Make sure the relay listens on a separate IP and does not overlap with the web gateway on port 443. +Between browsers a call goes directly; behind strict NAT and on a VPN it goes through the relay. Check that the `coturn` container runs (`docker compose ps coturn`) and that 3478/udp, 3478/tcp and the 49160–49999/udp range are open on the firewall. The addresses in **Settings → TURN for calls** must not be empty: they are derived from the installation address, so that address has to be set first.
diff --git a/README.ru.md b/README.ru.md index 0807406..6d639b9 100644 --- a/README.ru.md +++ b/README.ru.md @@ -32,7 +32,7 @@ - [Шаг 2. Мастер первого запуска](#шаг-2-мастер-первого-запуска) - [Шаг 3. Настройка в интерфейсе](#шаг-3-настройка-в-интерфейсе) - [Виджет на сайте](#виджет-на-сайте) - - [Звонки через relay (опционально)](#звонки-через-relay-опционально) + - [Звонки](#звонки) - [Внешнее хранилище файлов (опционально)](#внешнее-хранилище-файлов-опционально) - [Обновление](#обновление) - [Функции](#функции) @@ -58,7 +58,7 @@ Chatballs берёт на себя первую линию общения с к | **Сервер** | Linux, x86_64 | | **ПО** | Docker и плагин Docker Compose | | **Порты** | 80 и 443 открыты | -| **Relay для звонков (опционально)** | Выделенный публичный IP, порт 3478 и диапазон UDP 49160–49999 | +| **Порты для звонков** | 3478 (UDP и TCP) и диапазон UDP 49160–49999 | Домен на старте не нужен. Установка открывается по IP-адресу сервера, домен задаётся позже в настройках. @@ -116,15 +116,16 @@ docker compose up -d --wait Чат откроется в изолированном окне поверх сайта. -### Звонки через relay (опционально) +### Звонки -Аудио- и видеозвонки работают напрямую между браузерами. Если клиенты или сотрудники сидят за строгим NAT или корпоративным файрволом, включите TURN-relay: +Звонки работают сразу после установки. Между браузерами разговор идёт напрямую, а если одна из сторон за строгим NAT или в VPN — через relay, который поднимается вместе со стеком на том же адресе. Настраивать нечего: адреса relay появляются в **Настройки → TURN для звонков** сами, от адреса установки, и меняются только если вы ставите свой сервер. -```bash -COMPOSE_PROFILES=calls CHATBALLS_CALL_TURN_REALM=<домен> CHATBALLS_TURN_EXTERNAL_IP=<публичный IP> CHATBALLS_TURN_LISTENING_IP= docker compose up -d --wait -``` +На файрволе нужно открыть: -Relay слушает выделенный IP, чтобы порт 443 не конфликтовал с веб-шлюзом. Сертификат для TURN-over-TLS кладётся в каталог, заданный переменной `CHATBALLS_TURN_CERTS_DIR`. Адреса TURN затем указываются в **Настройки → Коммуникации**. +- 3478/udp и 3478/tcp — сам relay; +- 49160–49999/udp — порты разговоров (по два на звонок). + +Сети, где наружу разрешён только порт 443, relay на 3478 не пройдут. Для них нужен TURN-over-TLS на 443, а это отдельный публичный адрес (443 на основном занят веб-шлюзом) либо внешний TURN-сервис — его адреса вписываются в те же настройки. ### Внешнее хранилище файлов (опционально) @@ -270,7 +271,7 @@ docker compose logs worker
Звонки не соединяются -Между браузерами звонок идёт напрямую. Если одна из сторон за строгим NAT, нужен relay: включите профиль `calls` и укажите адреса TURN в **Настройках**. Проверьте, что на файрволе открыты порт 3478 и диапазон UDP 49160–49999. Убедитесь, что relay слушает отдельный IP и не пересекается с веб-шлюзом по порту 443. +Между браузерами звонок идёт напрямую, за строгим NAT и в VPN — через relay. Проверьте, что контейнер `coturn` работает (`docker compose ps coturn`), а на файрволе открыты 3478/udp, 3478/tcp и диапазон 49160–49999/udp. В **Настройки → TURN для звонков** адреса должны быть непустыми: они строятся от адреса установки, поэтому сначала должен быть задан сам адрес.
diff --git a/apps/backend/chatballs/calls/serializers.py b/apps/backend/chatballs/calls/serializers.py index e23053d..f21dc4b 100644 --- a/apps/backend/chatballs/calls/serializers.py +++ b/apps/backend/chatballs/calls/serializers.py @@ -2,7 +2,7 @@ from django.conf import settings from chatballs.calls.models import CallSession from chatballs.calls.turn import turn_credentials -from chatballs.identity.instance_settings import turn_config +from chatballs.identity.instance_settings import default_stun_urls, turn_config def _iso(value): @@ -67,8 +67,9 @@ def ice_servers_payload() -> list[dict]: # fallback с краткоживущими credentials. Генерируется на каждый запрос токена, # поэтому клиент всегда получает не истёкшие TURN credentials. servers: list[dict] = [] - if settings.CHATBALLS_CALL_STUN_URLS: - servers.append({"urls": list(settings.CHATBALLS_CALL_STUN_URLS)}) + stun_urls = list(settings.CHATBALLS_CALL_STUN_URLS) or default_stun_urls() + if stun_urls: + servers.append({"urls": stun_urls}) turn_urls, ttl = turn_config() if turn_urls and settings.CHATBALLS_CALL_TURN_SECRET: username, credential = turn_credentials(ttl_seconds=ttl) diff --git a/apps/backend/chatballs/calls/tests/test_turn.py b/apps/backend/chatballs/calls/tests/test_turn.py index e2fcb90..9eae4dd 100644 --- a/apps/backend/chatballs/calls/tests/test_turn.py +++ b/apps/backend/chatballs/calls/tests/test_turn.py @@ -3,10 +3,11 @@ import hashlib import hmac import time -from django.test import SimpleTestCase, override_settings +from django.test import SimpleTestCase, TestCase, override_settings from chatballs.calls.serializers import ice_servers_payload from chatballs.calls.turn import turn_credentials +from chatballs.identity.instance_settings import InstanceSettings, turn_config SECRET = "coturn-shared-secret" TURN_URLS = ["turn:example.com:3478?transport=udp", "turn:example.com:3478?transport=tcp"] @@ -71,3 +72,56 @@ class IceServersPayloadTests(SimpleTestCase): def test_turn_urls_without_secret_are_not_exposed(self) -> None: # Без секрета выдать рабочие credentials нельзя — TURN не отдаётся вовсе. self.assertEqual(ice_servers_payload(), []) + + +@override_settings( + CHATBALLS_CALL_STUN_URLS=[], + CHATBALLS_CALL_TURN_URLS=[], + CHATBALLS_CALL_TURN_SECRET=SECRET, + CHATBALLS_CALL_TURN_TTL_SECONDS=3600, +) +class TurnDefaultsTests(TestCase): + """Relay коробки: адреса берутся от адреса установки, без единой настройки.""" + + def _set_host(self, host: str) -> None: + row = InstanceSettings.load() + row.public_host = host + row.save() + + def test_addresses_come_from_the_installation_address(self) -> None: + self._set_host("crm.example.com") + urls, _ttl = turn_config() + self.assertEqual( + urls, + [ + "turn:crm.example.com:3478?transport=udp", + "turn:crm.example.com:3478?transport=tcp", + ], + ) + + def test_owner_addresses_win_over_the_defaults(self) -> None: + row = InstanceSettings.load() + row.public_host = "crm.example.com" + row.turn_urls = "turns:turn.example.net:5349?transport=tcp" + row.save() + urls, _ttl = turn_config() + self.assertEqual(urls, ["turns:turn.example.net:5349?transport=tcp"]) + + def test_ice_payload_offers_stun_and_turn_of_the_box(self) -> None: + self._set_host("crm.example.com") + servers = ice_servers_payload() + self.assertEqual(servers[0], {"urls": ["stun:crm.example.com:3478"]}) + self.assertEqual( + servers[1]["urls"], + [ + "turn:crm.example.com:3478?transport=udp", + "turn:crm.example.com:3478?transport=tcp", + ], + ) + self.assertEqual( + servers[1]["credential"], _expected_credential(servers[1]["username"]) + ) + + def test_without_the_installation_address_there_is_nothing_to_offer(self) -> None: + self._set_host("") + self.assertEqual(ice_servers_payload(), []) diff --git a/apps/backend/chatballs/identity/instance_settings.py b/apps/backend/chatballs/identity/instance_settings.py index facca76..b73875b 100644 --- a/apps/backend/chatballs/identity/instance_settings.py +++ b/apps/backend/chatballs/identity/instance_settings.py @@ -258,11 +258,39 @@ def email_from_address() -> str: return str(settings.DEFAULT_FROM_EMAIL) -def turn_config() -> tuple[list[str], int]: - """Адреса TURN и время жизни credentials из настроек установки. +# Порт relay в коробке: на нём coturn слушает и TURN, и STUN (compose.yaml). +TURN_PORT = 3478 - Пустой список означает «relay не настроен»: звонки идут напрямую и через - STUN. Переменная окружения, если задана, побеждает. + +def default_turn_urls(host: str = "") -> list[str]: + """Адреса relay, которые работают в коробке без единой настройки. + + Relay стоит на том же сервере и на том же адресе, что и сама установка, — + адрес известен с мастера первого запуска, и заставлять человека вписывать + его руками незачем. UDP идёт первым, TCP — запасным для сетей, где UDP + режут. + """ + host = host or public_host() + if not host: + return [] + return [ + f"turn:{host}:{TURN_PORT}?transport=udp", + f"turn:{host}:{TURN_PORT}?transport=tcp", + ] + + +def default_stun_urls(host: str = "") -> list[str]: + """STUN отдаёт тот же coturn на том же порту.""" + host = host or public_host() + return [f"stun:{host}:{TURN_PORT}"] if host else [] + + +def turn_config() -> tuple[list[str], int]: + """Адреса TURN и время жизни credentials. + + Порядок: переменная окружения (если её всё-таки задали), затем то, что + владелец вписал в «Настройки», затем адреса коробки по адресу установки. + Пустой список остаётся только там, где адрес установки ещё не известен. """ from django.conf import settings @@ -276,6 +304,6 @@ def turn_config() -> tuple[list[str], int]: # настройках процесса. return [], settings.CHATBALLS_CALL_TURN_TTL_SECONDS if row is None or not row.turn_urls.strip(): - return [], settings.CHATBALLS_CALL_TURN_TTL_SECONDS + return default_turn_urls(), settings.CHATBALLS_CALL_TURN_TTL_SECONDS urls = [line.strip() for line in row.turn_urls.splitlines() if line.strip()] return urls, row.turn_ttl_seconds or settings.CHATBALLS_CALL_TURN_TTL_SECONDS diff --git a/apps/backend/chatballs/identity/instance_views.py b/apps/backend/chatballs/identity/instance_views.py index 3a4a429..c51e2c7 100644 --- a/apps/backend/chatballs/identity/instance_views.py +++ b/apps/backend/chatballs/identity/instance_views.py @@ -17,6 +17,7 @@ from chatballs.i18n.languages import DEFAULT_LANGUAGE, LANGUAGES, normalize_lang from chatballs.identity.instance_access import InstanceSettingsPermission, IsInstanceAdmin from chatballs.identity.instance_settings import ( InstanceSettings, + default_turn_urls, email_connection, email_from_address, invalidate_cache, @@ -50,7 +51,13 @@ def instance_payload(row: InstanceSettings) -> dict: "turn": { # Секрет общий с coturn и лежит в томе секретов: наружу не отдаём # и в настройках не показываем — вводить его человеку не нужно. - "urls": [line for line in row.turn_urls.splitlines() if line.strip()], + # + # Адреса показываются действующие: пока владелец их не менял, это + # relay коробки на адресе установки. Пустое поле означало бы, что + # звонки через relay не работают, пока человек что-то впишет, — + # а они работают сразу после установки. + "urls": [line for line in row.turn_urls.splitlines() if line.strip()] + or default_turn_urls(), "ttlSeconds": row.turn_ttl_seconds, "secretReady": bool(settings.CHATBALLS_CALL_TURN_SECRET), }, diff --git a/compose.yaml b/compose.yaml index 4fae595..df4dc86 100644 --- a/compose.yaml +++ b/compose.yaml @@ -279,13 +279,21 @@ services: backend-platform: condition: service_healthy - # Coturn — медиа-relay для TURN fallback (SPEC-CHATBALLS-0013 §11). - # Опциональный profile `calls` (ADR-CHATBALLS-0028): включается через COMPOSE_PROFILES=calls. - # Host networking: relay использует широкий UDP-диапазон и реальный внешний IP, - # публикуется напрямую и не проходит через HTTP reverse proxy. credentials - # выдаёт backend по общему CHATBALLS_CALL_TURN_SECRET (static-auth-secret). + # Coturn — медиа-relay для звонков (SPEC-CHATBALLS-0013 §11). + # + # Поднимается вместе со всей установкой и слушает тот же адрес, что и веб: + # звонок через симметричный NAT или VPN без relay не соединяется в принципе, + # и превращать это в отдельную настройку значит выдавать клиенту заведомо + # неработающие звонки. Порт 3478 не спорит с 80 и 443, так что второй IP не + # нужен; TURN-over-TLS по умолчанию выключен — он требует сертификата и + # 443-го порта, то есть как раз выделенного адреса. Кому нужен TLS (сети, + # где наружу разрешён только 443), включает его отдельным compose-override. + # + # Host networking: relay использует широкий UDP-диапазон и реальный внешний + # IP, публикуется напрямую и не проходит через HTTP reverse proxy. + # credentials выдаёт backend по общему CHATBALLS_CALL_TURN_SECRET + # (static-auth-secret); адреса relay backend строит от адреса установки. coturn: - profiles: ["calls"] image: ${CHATBALLS_COTURN_IMAGE:-coturn/coturn:4.6} restart: unless-stopped network_mode: host @@ -299,40 +307,31 @@ services: # старт, человек его не вводит и не дублирует в двух местах. - -c - /run/chatballs/secrets/turnserver-secret.conf - - --realm=${CHATBALLS_CALL_TURN_REALM:-} - # Весь TURN живёт на выделенном публичном IP (отдельный порт/NIC), Caddy — на - # основном IP. Это освобождает 443 под TURN-over-TLS без конфликта с web. - - --listening-ip=${CHATBALLS_TURN_LISTENING_IP:-0.0.0.0} - - --relay-ip=${CHATBALLS_TURN_EXTERNAL_IP:-} - - --external-ip=${CHATBALLS_TURN_EXTERNAL_IP:-} - - --listening-port=${CHATBALLS_TURN_LISTENING_PORT:-3478} - # TURN-over-TLS на 443: проходит через VPN/строгие сети, где UDP и 3478 режут. - - --tls-listening-port=${CHATBALLS_TURN_TLS_PORT:-443} - - --cert=/etc/coturn/certs/fullchain.pem - - --pkey=/etc/coturn/certs/privkey.pem - - --min-port=${CHATBALLS_TURN_MIN_PORT:-49160} + # realm участвует только в digest-аутентификации; клиент узнаёт его от + # самого сервера, поэтому значение фиксированное. + - --realm=chatballs + - --listening-port=3478 + # TLS и DTLS выключены: без сертификата они всё равно не поднялись бы, а + # сертификат означает 443 и выделенный адрес. + - --no-tls + - --no-dtls + - --min-port=49160 # Relay-диапазон UDP (1 порт на media-endpoint; ~2 порта на звонок). # 49160-49999 = 840 портов ≈ 420 одновременных TURN-звонков на хосте. - # Расширяется через CHATBALLS_TURN_MIN_PORT/CHATBALLS_TURN_MAX_PORT; все порты диапазона - # должны быть открыты на firewall и не пересекаться с ephemeral-диапазоном ОС. - - --max-port=${CHATBALLS_TURN_MAX_PORT:-49999} + # Все порты диапазона должны быть открыты на firewall и не пересекаться + # с ephemeral-диапазоном ОС. + - --max-port=49999 # Запрет анонимного и внутрисетевого relay (SPEC §11: без пересечения с хостом). - --no-multicast-peers - --no-tcp-relay - --denied-peer-ip=10.0.0.0-10.255.255.255 - --denied-peer-ip=172.16.0.0-172.31.255.255 - --denied-peer-ip=192.168.0.0-192.168.255.255 - - --no-tlsv1 - - --no-tlsv1_1 volumes: - # LE-сертификат TURN-хоста (turns:) кладёт на хост renewal deploy-hook — - # это единственное место, где стек смотрит наружу файлом, и только при - # включённом профиле calls. Каталог задаётся явно. - chatballs-secrets:/run/chatballs/secrets:ro - - ${CHATBALLS_TURN_CERTS_DIR:-./data/coturn-certs}:/etc/coturn/certs:ro healthcheck: - # Allocation smoke: STUN binding к собственному listener на выделенном IP. - test: ["CMD", "turnutils_stunclient", "-p", "${CHATBALLS_TURN_LISTENING_PORT:-3478}", "${CHATBALLS_TURN_LISTENING_IP:-0.0.0.0}"] + # Allocation smoke: STUN binding к собственному listener. + test: ["CMD", "turnutils_stunclient", "-p", "3478", "127.0.0.1"] interval: 30s timeout: 5s retries: 5 diff --git a/deploy/cli/lib/common.sh b/deploy/cli/lib/common.sh index 334e154..5abf7f7 100644 --- a/deploy/cli/lib/common.sh +++ b/deploy/cli/lib/common.sh @@ -97,27 +97,6 @@ verify_release_checksums() { } } -validate_calls_network_boundary() { - # Профиль calls включают переменной окружения — тем же способом, каким её - # читает сам compose. Файла с конфигурацией у установки нет. - local web_ip turn_ip - web_ip="${CHATBALLS_WEB_LISTENING_IP:-}" - turn_ip="${CHATBALLS_TURN_LISTENING_IP:-}" - - [[ -n "$web_ip" ]] || { - log_err "CHATBALLS_WEB_LISTENING_IP is required for calls profile" - return 1 - } - [[ "$web_ip" != "0.0.0.0" ]] || { - log_err "CHATBALLS_WEB_LISTENING_IP cannot be 0.0.0.0 when calls profile uses TURN TLS on 443" - return 1 - } - [[ "$web_ip" != "$turn_ip" ]] || { - log_err "web and TURN listeners must use different public IP addresses" - return 1 - } -} - release_image_keys() { printf '%s\n' \ CHATBALLS_BACKEND_IMAGE \ diff --git a/deploy/cli/lib/deploy.sh b/deploy/cli/lib/deploy.sh index 7332a73..c8e5f75 100644 --- a/deploy/cli/lib/deploy.sh +++ b/deploy/cli/lib/deploy.sh @@ -24,10 +24,7 @@ cmd_deploy() { run_compose run --rm init || die "deploy: init (migrate) failed" 1 log "deploy: starting application services" - local app_services=(backend-app backend-platform backend-admin worker frontend gateway) - if profile_enabled calls; then - app_services+=(coturn) - fi + local app_services=(backend-app backend-platform backend-admin worker frontend gateway coturn) run_compose up -d "${app_services[@]}" || die "deploy: application start failed" 1 log "deploy: waiting for application health" @@ -36,9 +33,8 @@ cmd_deploy() { _wait_running backend-admin 30 || die "deploy: admin backend did not start" 1 _wait_running frontend 30 || die "deploy: frontend did not start" 1 _wait_running gateway 30 || die "deploy: gateway did not start" 1 - if profile_enabled calls; then - _wait_healthy coturn 60 || die "deploy: coturn did not become healthy" 1 - fi + # Relay поднимается вместе со всеми: звонки за NAT без него не соединяются. + _wait_healthy coturn 60 || die "deploy: coturn did not become healthy" 1 log "deploy: running smoke checks" _smoke || die "deploy: smoke checks failed" 1 @@ -57,10 +53,6 @@ _deploy_validate() { # Домены здесь не проверяются: установка отвечает по адресу сервера, а свой # домен владелец задаёт в «Настройках» — снаружи его знать неоткуда. - if profile_enabled calls; then - validate_calls_network_boundary || return 1 - fi - compose_config_validate >/dev/null 2>&1 || { log_err "compose config invalid" return 1 diff --git a/deploy/cli/lib/doctor.sh b/deploy/cli/lib/doctor.sh index d6de996..1b2d621 100644 --- a/deploy/cli/lib/doctor.sh +++ b/deploy/cli/lib/doctor.sh @@ -85,19 +85,12 @@ cmd_doctor() { # прежние проверки просто ругались на исправную установку — они читали # instance .env, которого у продукта нет. - if profile_enabled calls; then - local missing=0 k - for k in CHATBALLS_CALL_TURN_REALM CHATBALLS_TURN_EXTERNAL_IP CHATBALLS_TURN_LISTENING_IP; do - if [[ -z "${!k:-}" ]]; then - _doctor_report 0 "$k required for calls profile" - missing=1 - fi - done - if [[ "$missing" == "0" ]] && validate_calls_network_boundary; then - _doctor_report 1 "calls profile network boundary valid" - else - _doctor_report 0 "calls profile network boundary invalid" - fi + # Relay стоит на том же адресе, что и веб: порт 3478 не спорит с 80 и 443, + # поэтому проверять нечего, кроме того, что он поднялся. + if run_compose ps --status running --services 2>/dev/null | grep -qx coturn; then + _doctor_report 1 "calls relay (coturn) is running" + else + _doctor_report 0 "calls relay (coturn) is not running — calls behind NAT will fail" fi if { [[ -d "$inst/backups" ]] && [[ -w "$inst/backups" ]]; } || [[ -w "$inst" ]]; then