✨ feat(calls): relay работает сразу после установки, без настройки

Звонок через симметричный NAT или VPN без relay не соединяется — значит relay должен стоять из коробки, а не быть отдельным профилем с выделенным IP и сертификатом. Coturn поднимается вместе со стеком на том же адресе: порт 3478 не спорит с 80 и 443, TURN-over-TLS выключен, потому что он и требовал второго адреса.

Адреса relay и STUN считаются от адреса установки и появляются в настройках сами; вписанное владельцем по-прежнему побеждает. Deploy и doctor больше не требуют второго публичного адреса, README обоих языков переписан: вместо «опционально, выделенный IP» — какие порты открыть.

Проверено: тесты TURN (включая четыре новых на автонастройку), тесты API звонков и адреса установки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 5 committed 2026-09-15 06:38:47 +03:00
1 parent 1de6239eb4
commit d9bc540bc3
10 files changed
+157 -102

No files matched your search

+10 -9
View File
@@ -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=<domain> CHATBALLS_TURN_EXTERNAL_IP=<public IP> CHATBALLS_TURN_LISTENING_IP=<IP for TURN> 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
<details>
<summary><strong>Calls do not connect</strong></summary>
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.
</details>
<details>
+10 -9
View File
@@ -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=<IP для TURN> 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
<details>
<summary><strong>Звонки не соединяются</strong></summary>
Между браузерами звонок идёт напрямую. Если одна из сторон за строгим 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 для звонков** адреса должны быть непустыми: они строятся от адреса установки, поэтому сначала должен быть задан сам адрес.
</details>
<details>
+4 -3
View File
@@ -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)
@@ -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(), [])
@@ -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
@@ -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),
},
+28 -29
View File
@@ -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
-21
View File
@@ -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 \
+3 -11
View File
@@ -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
+6 -13
View File
@@ -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