diff --git a/deploy/caddy/Caddyfile.tpl b/deploy/caddy/Caddyfile.tpl index 760d460..524eb5a 100644 --- a/deploy/caddy/Caddyfile.tpl +++ b/deploy/caddy/Caddyfile.tpl @@ -55,10 +55,4 @@ ${DOMAIN} { } } -${FILES_DOMAIN} { - encode zstd gzip - import security_headers - handle { - reverse_proxy app:8080 - } -} +${GLCHAT_FILES_BLOCK} \ No newline at end of file diff --git a/deploy/caddy/external-proxy.conf.tpl b/deploy/caddy/external-proxy.conf.tpl index 2e25d84..32681fd 100644 --- a/deploy/caddy/external-proxy.conf.tpl +++ b/deploy/caddy/external-proxy.conf.tpl @@ -1,7 +1,8 @@ # Сгенерировано deploy/install.sh — не редактируйте вручную. # Режим: внешний reverse proxy (--external-proxy). Собственный Caddy не запускается: # блок ниже нужно вставить в конфиг вашего прокси (например, Caddyfile) и перезагрузить его. -# Приложение и LiveKit слушают только 127.0.0.1. +# Приложение и LiveKit слушают ${APP_BIND}:${APP_PORT} и ${LIVEKIT_BIND}:${LIVEKIT_WS_PORT} +# (по умолчанию 127.0.0.1 — в этом случае прокси и стек на одном хосте). { email ${ACME_EMAIL} } @@ -31,7 +32,7 @@ ${DOMAIN} { # WebSocket Gateway (фаза 1): длинные соединения без таймаутов. @gateway path /gateway handle @gateway { - reverse_proxy 127.0.0.1:${APP_PORT} { + reverse_proxy ${APP_BIND}:${APP_PORT} { transport http { read_timeout 1h write_timeout 1h @@ -42,7 +43,7 @@ ${DOMAIN} { # LiveKit сигналинг (WebSocket). @rtc path /rtc* handle @rtc { - reverse_proxy 127.0.0.1:${LIVEKIT_WS_PORT} { + reverse_proxy ${LIVEKIT_BIND}:${LIVEKIT_WS_PORT} { transport http { read_timeout 1h write_timeout 1h @@ -52,16 +53,9 @@ ${DOMAIN} { # API и статика клиента. handle { - reverse_proxy 127.0.0.1:${APP_PORT} + reverse_proxy ${APP_BIND}:${APP_PORT} } } -# Пользовательский контент — отдельный origin (изоляция cookies/CSP). -${FILES_DOMAIN} { - encode zstd gzip - import glchat_security_headers - handle { - reverse_proxy 127.0.0.1:${APP_PORT} - } -} +${GLCHAT_FILES_BLOCK} # --- glchat: конец блока -------------------------------------------------- diff --git a/deploy/compose.yaml.tpl b/deploy/compose.yaml.tpl index 5e38e5e..0ccffbc 100644 --- a/deploy/compose.yaml.tpl +++ b/deploy/compose.yaml.tpl @@ -76,15 +76,19 @@ services: LIVEKIT_KEYS: "${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}" ports: - "${GLCHAT_LIVEKIT_BIND}:${LIVEKIT_WS_PORT}:7880" - # Медиапорты: тот же адрес привязки (для инстанса за NAT это адрес туннеля). - - "${GLCHAT_LIVEKIT_BIND}:${LIVEKIT_TCP_PORT}:7881" - - "${GLCHAT_LIVEKIT_BIND}:${LIVEKIT_UDP_PORT}:7882/udp" - # TURN: UDP и TURN/TLS. На хосте без белого IP эти порты доступны только - # из туннеля (внешний прокси/релей публикует их на своём адресе). - - "${GLCHAT_LIVEKIT_BIND}:${TURN_UDP_PORT}:${TURN_UDP_PORT}/udp" - - "${GLCHAT_LIVEKIT_BIND}:${TURN_TLS_PORT}:${TURN_TLS_PORT}/tcp" - # Диапазон релея TURN (медиа через relay). - - "${GLCHAT_LIVEKIT_BIND}:${TURN_RELAY_START}-${TURN_RELAY_END}:${TURN_RELAY_START}-${TURN_RELAY_END}/udp" + # Медиапорты: по умолчанию тот же адрес привязки (инстанс за NAT + # публикует их на адресе туннеля). Когда прокси стоит на том же хосте, + # что и стек, адреса расходятся: сигналинг отдаётся наружу через прокси и + # публикуется только для него, а медиапорты обязаны быть доступны + # клиентам из интернета — GLCHAT_LIVEKIT_MEDIA_BIND (по умолчанию равен + # адресу привязки, то есть поведение прежнее). + - "${GLCHAT_LIVEKIT_MEDIA_BIND}:${LIVEKIT_TCP_PORT}:7881" + - "${GLCHAT_LIVEKIT_MEDIA_BIND}:${LIVEKIT_UDP_PORT}:7882/udp" + # TURN: UDP, TURN/TLS и диапазон релея. Строки подставляет установщик + # (GLCHAT_TURN_PORTS): при --no-turn встроенного TURN в стеке нет, и порты + # не публикуются вовсе — иначе занятый на хосте порт (например, чужим + # coturn) ломает запуск стека, а наружу открывались бы лишние порты. +${GLCHAT_TURN_PORTS} volumes: - ./livekit/livekit.yaml:/etc/livekit.yaml:ro - ${GLCHAT_TURN_CERT_DIR}:/etc/certs:ro diff --git a/deploy/install.sh b/deploy/install.sh index aa47eae..ee8736a 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -78,6 +78,11 @@ APP_BIND_ADDR="${APP_BIND_ADDR:-127.0.0.1}" ALLOW_PORTS="${ALLOW_PORTS:-}" ALLOW_FROM="${ALLOW_FROM:-10.77.0.0/24}" LIVEKIT_BIND_ADDR="${LIVEKIT_BIND_ADDR:-127.0.0.1}" +# Адрес публикации медиапортов LiveKit (TCP/UDP): по умолчанию совпадает с +# адресом привязки сигналинга. Разделять нужно, когда прокси стоит на том же +# хосте: сигналинг публикуется только для прокси, а медиа обязано быть +# доступно клиентам из интернета (AGENT.md 10.4, 10.13). +LIVEKIT_MEDIA_BIND_ADDR="${LIVEKIT_MEDIA_BIND_ADDR:-}" TURN_CERT_REMOTE="${TURN_CERT_REMOTE:-}" TURN_CERT_REMOTE_CRT="${TURN_CERT_REMOTE_CRT:-}" TURN_CERT_REMOTE_KEY="${TURN_CERT_REMOTE_KEY:-}" @@ -189,7 +194,11 @@ install.sh — установка glchat (AGENT.md 10). --allow-port <порт/прото> разрешить порт только из подсети --allow-from (можно указывать несколько раз), например 8080/tcp --allow-from источник для --allow-port (по умолчанию 10.77.0.0/24) - --livekit-bind-addr адрес LiveKit (по умолчанию 127.0.0.1) + --livekit-bind-addr адрес публикации LiveKit WS (по умолчанию 127.0.0.1) + --livekit-media-bind-addr адрес публикации медиапортов LiveKit TCP/UDP + (по умолчанию — как у WS; отдельный адрес нужен, + когда прокси стоит на том же хосте: WS публикуется + только для прокси, медиа — для клиентов из интернета) --turn-cert-remote тянуть сертификат TURN/TLS с внешнего узла по SSH --turn-cert-remote-crt <путь> путь к сертификату на внешнем узле --turn-cert-remote-key <путь> путь к ключу на внешнем узле @@ -254,6 +263,7 @@ parse_args() { --allow-port) ALLOW_PORTS="${ALLOW_PORTS} ${2:?}"; shift 2 ;; --allow-from) ALLOW_FROM="${2:?}"; shift 2 ;; --livekit-bind-addr) LIVEKIT_BIND_ADDR="${2:?}"; shift 2 ;; + --livekit-media-bind-addr) LIVEKIT_MEDIA_BIND_ADDR="${2:?}"; shift 2 ;; --turn-cert-remote) TURN_CERT_REMOTE="${2:?}"; shift 2 ;; --turn-cert-remote-crt) TURN_CERT_REMOTE_CRT="${2:?}"; shift 2 ;; --turn-cert-remote-key) TURN_CERT_REMOTE_KEY="${2:?}"; shift 2 ;; @@ -430,11 +440,49 @@ resolve_derived_values() { fi GLCHAT_APP_BIND="${APP_BIND_ADDR}" GLCHAT_LIVEKIT_BIND="${LIVEKIT_BIND_ADDR}" + # Пустое значение — прежнее поведение: медиа публикуется там же, где сигналинг. + [ -n "$LIVEKIT_MEDIA_BIND_ADDR" ] || LIVEKIT_MEDIA_BIND_ADDR="$LIVEKIT_BIND_ADDR" + GLCHAT_LIVEKIT_MEDIA_BIND="${LIVEKIT_MEDIA_BIND_ADDR}" + # Порты TURN публикуются только при включённом TURN. С --no-turn встроенного + # TURN в стеке нет: публикация занимала бы порты хоста (3478/udp и диапазон + # релея) и ломала запуск, если их держит чужой сервис — например, отдельный + # coturn на том же VPS (найдено при установке на VPS 27.09.2026), а наружу + # открывались бы порты, за которыми ничего не слушает. + if [ "$TURN_ENABLED" = "true" ]; then + GLCHAT_TURN_PORTS=" # TURN: UDP, TURN/TLS и диапазон релея (медиа через relay). + - \"${LIVEKIT_MEDIA_BIND_ADDR}:${TURN_UDP_PORT}:${TURN_UDP_PORT}/udp\" + - \"${LIVEKIT_MEDIA_BIND_ADDR}:${TURN_TLS_PORT}:${TURN_TLS_PORT}/tcp\" + - \"${LIVEKIT_MEDIA_BIND_ADDR}:${TURN_RELAY_START}-${TURN_RELAY_END}:${TURN_RELAY_START}-${TURN_RELAY_END}/udp\"" + else + GLCHAT_TURN_PORTS=" # TURN выключен (--no-turn): порты 3478/5349 и диапазон релея не публикуются" + fi + # Блок пользовательского контента (files.<домен>) в конфиг прокси попадает + # только когда для него есть отдельный домен. Иначе в Caddyfile оказывались + # два блока с одним и тем же адресом, и Caddy отказывался стартовать + # («ambiguous site definition») — найдено при установке с --no-files-subdomain + # на VPS 27.09.2026. Адрес апстрима зависит от режима: во встроенном прокси + # это имя контейнера, во внешнем — адрес привязки приложения. + if [ "$PROXY_MODE" = "embedded" ]; then + files_upstream="app:8080" + else + files_upstream="${APP_BIND_ADDR}:${APP_PORT}" + fi + if [ "$FILES_DOMAIN_RESOLVED" != "$DOMAIN" ]; then + GLCHAT_FILES_BLOCK="$(printf '# Пользовательский контент — отдельный origin (изоляция cookies/CSP).\n%s {\n\tencode zstd gzip\n\timport glchat_security_headers\n\thandle {\n\t\treverse_proxy %s\n\t}\n}' \ + "${FILES_DOMAIN_RESOLVED}" "${files_upstream}")" + if [ "$PROXY_MODE" = "embedded" ]; then + # Во встроенном Caddy сниппет заголовков называется иначе. + GLCHAT_FILES_BLOCK="${GLCHAT_FILES_BLOCK/import glchat_security_headers/import security_headers}" + fi + else + GLCHAT_FILES_BLOCK="# Отдельного домена для пользовательского контента нет +# (--no-files-subdomain): файлы отдаются с основного домена по /files/*." + fi export FILES_DOMAIN_RESOLVED CADDY_PROFILE LIVEKIT_PUBLISH TURN_EXTERNAL_TLS \ - livekit_extra_codecs \ + livekit_extra_codecs GLCHAT_TURN_PORTS GLCHAT_FILES_BLOCK \ PROFILE_LIVEKIT_DISABLE_SIMULCAST PROFILE_LIVEKIT_USE_EXTERNAL_IP \ GLCHAT_DATA_DIR GLCHAT_UPDATES_DIR GLCHAT_CADDY_PROFILE GLCHAT_TURN_CERT_DIR \ - GLCHAT_APP_BIND GLCHAT_LIVEKIT_BIND TLS_ENABLED \ + GLCHAT_APP_BIND GLCHAT_LIVEKIT_BIND GLCHAT_LIVEKIT_MEDIA_BIND TLS_ENABLED \ TURN_DOMAIN_RESOLVED TURN_CERT_DIR TURN_RELAY_HOST_RESOLVED TURN_RELAY_TLS_HOST \ TURN_RELAY_USER TURN_RELAY_PASSWORD } @@ -521,6 +569,7 @@ print_plan() { if [ "$PROXY_MODE" = "external" ]; then printf ' %-18s %-10s %s\n' "app (API+web)" "${APP_BIND_ADDR}:${APP_PORT}" "внутренний, наружу не публикуется" printf ' %-18s %-10s %s\n' "LiveKit WS" "${LIVEKIT_BIND_ADDR}:${LIVEKIT_WS_PORT}" "внутренний" + printf ' %-18s %-10s %s\n' "LiveKit медиа" "${LIVEKIT_MEDIA_BIND_ADDR}:${LIVEKIT_TCP_PORT}/${LIVEKIT_UDP_PORT}" "TCP+UDP для клиентов" printf ' %-18s %-10s %s\n' "80/443" "-" "обслуживает внешний прокси (не меняем)" else printf ' %-18s %-10s %s\n' "HTTP" "${HTTP_PORT}/tcp" "ACME + редирект на HTTPS" @@ -851,6 +900,9 @@ GLCHAT_CADDY_PROFILE=@CADDY_PROFILE@ GLCHAT_TURN_CERT_DIR=@GLCHAT_TURN_CERT_DIR@ GLCHAT_APP_BIND=@GLCHAT_APP_BIND@ GLCHAT_LIVEKIT_BIND=@GLCHAT_LIVEKIT_BIND@ +# Адрес публикации медиапортов LiveKit: обычно совпадает с адресом WS, но при +# прокси на том же хосте отличается (WS — только для прокси, медиа — в интернет). +GLCHAT_LIVEKIT_MEDIA_BIND=@GLCHAT_LIVEKIT_MEDIA_BIND@ # --- порты --- APP_PORT=@APP_PORT@ @@ -974,6 +1026,7 @@ CADDY_PROFILE=${CADDY_PROFILE} GLCHAT_TURN_CERT_DIR=${TURN_CERT_DIR} GLCHAT_APP_BIND=${APP_BIND_ADDR} GLCHAT_LIVEKIT_BIND=${LIVEKIT_BIND_ADDR} +GLCHAT_LIVEKIT_MEDIA_BIND=${LIVEKIT_MEDIA_BIND_ADDR} APP_PORT=${APP_PORT} LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT} LIVEKIT_TCP_PORT=${LIVEKIT_TCP_PORT} @@ -1258,6 +1311,8 @@ render_all_templates() { "GLCHAT_CADDY_PROFILE=${CADDY_PROFILE}" \ "GLCHAT_TURN_CERT_DIR=${TURN_CERT_DIR}" \ "GLCHAT_APP_BIND=${APP_BIND_ADDR}" "GLCHAT_LIVEKIT_BIND=${LIVEKIT_BIND_ADDR}" \ + "GLCHAT_LIVEKIT_MEDIA_BIND=${LIVEKIT_MEDIA_BIND_ADDR}" \ + "GLCHAT_TURN_PORTS=${GLCHAT_TURN_PORTS}" \ "TURN_UDP_PORT=${TURN_UDP_PORT}" "TURN_TLS_PORT=${TURN_TLS_PORT}" \ "TURN_RELAY_START=${TURN_RELAY_START}" "TURN_RELAY_END=${TURN_RELAY_END}" \ "LIVEKIT_API_KEY=${LIVEKIT_API_KEY}" "LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}" \ @@ -1266,11 +1321,13 @@ render_all_templates() { if [ "$PROXY_MODE" = "external" ]; then render_template "${DEPLOY_DIR}/caddy/external-proxy.conf.tpl" "${TARGET_DIR}/external-proxy.conf" \ "DOMAIN=${DOMAIN}" "FILES_DOMAIN=${FILES_DOMAIN_RESOLVED}" "ACME_EMAIL=${ACME_EMAIL}" \ - "APP_PORT=${APP_PORT}" "LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}" + "APP_PORT=${APP_PORT}" "LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}" \ + "APP_BIND=${APP_BIND_ADDR}" "LIVEKIT_BIND=${LIVEKIT_BIND_ADDR}" \ + "GLCHAT_FILES_BLOCK=${GLCHAT_FILES_BLOCK}" else render_template "${DEPLOY_DIR}/caddy/Caddyfile.tpl" "${TARGET_DIR}/caddy/Caddyfile" \ "DOMAIN=${DOMAIN}" "FILES_DOMAIN=${FILES_DOMAIN_RESOLVED}" "ACME_EMAIL=${ACME_EMAIL}" \ - "PROXY_MODE=${PROXY_MODE}" + "PROXY_MODE=${PROXY_MODE}" "GLCHAT_FILES_BLOCK=${GLCHAT_FILES_BLOCK}" fi render_template "${DEPLOY_DIR}/livekit/livekit.yaml.tpl" "${TARGET_DIR}/livekit/livekit.yaml" \ diff --git a/web/e2e/support.ts b/web/e2e/support.ts index 0fcf090..38c5115 100644 --- a/web/e2e/support.ts +++ b/web/e2e/support.ts @@ -63,12 +63,22 @@ export async function postWithRetry( api: APIRequestContext, path: string, data: Record, - attempts = 6, + attempts = 8, ): Promise>> { let response = await api.post(path, { data }); for (let attempt = 1; attempt < attempts && response.status() === 429; attempt += 1) { - const payload = (await response.json().catch(() => ({}))) as { retry_after_ms?: number }; - const wait = Math.min(Math.max(payload.retry_after_ms ?? 1_000, 500), 20_000); + // Пауза лежит в конверте ошибки (§8.5: {"error":{"retry_after_ms":…}}), а не + // в корне тела: без `error` хелпер ждал фиксированную секунду и сдавался + // раньше, чем сервер разрешал повтор — на свежем инстансе регистрация + // пробных аккаунтов из-за этого падала (VPS, 27.09.2026). + const payload = (await response.json().catch(() => ({}))) as { + retry_after_ms?: number; + error?: { retry_after_ms?: number }; + }; + const wait = Math.min( + Math.max(payload.error?.retry_after_ms ?? payload.retry_after_ms ?? 1_000, 500), + 20_000, + ); await new Promise((resolve) => setTimeout(resolve, wait + 200)); response = await api.post(path, { data }); } @@ -359,9 +369,16 @@ export async function repeat429( ): Promise { let response = await request(); for (let attempt = 1; attempt < attempts && response.status() === 429; attempt += 1) { - const payload = (await response.json().catch(() => ({}))) as { retry_after_ms?: number }; + const payload = (await response.json().catch(() => ({}))) as { + retry_after_ms?: number; + error?: { retry_after_ms?: number }; + }; // Ждём столько, сколько просит сервер: лимит — 120 запросов в минуту. - const wait = Math.min(Math.max(payload.retry_after_ms ?? 1_000, 1_000), 30_000); + // Пауза лежит в конверте ошибки (§8.5), поэтому читаем и `error`. + const wait = Math.min( + Math.max(payload.error?.retry_after_ms ?? payload.retry_after_ms ?? 1_000, 1_000), + 30_000, + ); await new Promise((resolve) => setTimeout(resolve, wait + 250)); response = await request(); } diff --git a/web/e2e/voice.spec.ts b/web/e2e/voice.spec.ts index 0d8326c..4b0644b 100644 --- a/web/e2e/voice.spec.ts +++ b/web/e2e/voice.spec.ts @@ -177,7 +177,12 @@ test.describe('голос: два клиента в одной комнате', await session.page .getByRole('link', { name: `Открыть сервер ${fixture.guildName}`, exact: true }) .click(); - await session.page.getByRole('link', { name: /Открыть комнату Голос/u }).click(); + // Имя комнаты — точно: пока сайдбар дорисовывает новый сервер, в нём + // могут быть видны комнаты предыдущего (например, «Голос A»/«Голос B» + // служебных проверок), и нечёткий локатор ловил бы две ссылки. + await session.page + .getByRole('link', { name: 'Открыть комнату Голос', exact: true }) + .click(); await joinVoice(session.page); }