fix(webrtc): use public STUN by default (#427)

## Summary

- disable the built-in SyncTV STUN server by default across application,
Compose, image, environment, and Helm configuration
- default `webrtc.externalIceServers` to Cloudflare and Google public
STUN endpoints while preserving persisted overrides and explicit `[]`
- update deployment validation, tests, examples, and English/Chinese
documentation

Tailscale is not included because it publishes STUN endpoints through
its dynamic DERP map and does not provide a stable general-purpose
hostname suitable for this default.

## Testing

- `cargo fmt --all -- --check`
- `cargo test -p synctv-core --test global_settings_registry_tests`
- `cargo test -p synctv-core
test_runtime_settings_default_to_public_stun_servers`
- `cargo test -p synctv default_webrtc_config_disables_builtin_stun`
- `cargo check --workspace --all-targets`
- `bash scripts/validate-helm.sh`
- Compose configuration rendering
- `npm run validate:content`
- `npm run check`
- `git diff --check`
pull/428/head
zijiren 1 month ago committed by GitHub
parent 8711158d60
commit 96d22abe99
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -236,7 +236,9 @@ SYNCTV_WEBRTC_LOGGING_COLOR=auto
SYNCTV_WEBRTC_MODE=peer_to_peer
SYNCTV_WEBRTC_STUN_PORT=3478
SYNCTV_WEBRTC_ENABLE_BUILTIN_STUN=true
SYNCTV_WEBRTC_ENABLE_BUILTIN_STUN=false
# Clients use Cloudflare and Google public STUN servers by default. Enable the
# built-in server only when this deployment should provide STUN.
# Built-in STUN needs a client-reachable public ip:port or DNS name:port.
# Do not set this to a Pod IP or ClusterIP Service IP.
# SYNCTV_WEBRTC_STUN_EXTERNAL_ADDR=stun.example.com:3478

@ -110,8 +110,7 @@ RUN command -v synctv && synctv --version
# 50051: dedicated cluster gRPC
# 9090: internal Prometheus metrics listener
# 1935: RTMP (livestream)
# 3478/udp: STUN (WebRTC)
EXPOSE 8080 8081 50051 9090 1935 3478/udp
EXPOSE 8080 8081 50051 9090 1935
# Health check against the HTTP health endpoint
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \

@ -47,7 +47,6 @@ services:
ports:
- "8080:8080"
- "1935:1935"
- "3478:3478/udp"
depends_on:
postgres:
condition: service_healthy

@ -395,7 +395,7 @@ media_providers:
webrtc:
mode: "peer_to_peer"
enable_builtin_stun: true
enable_builtin_stun: false
stun_port: 3478
stun_host: "0.0.0.0"
stun_external_addr: ""

@ -42,7 +42,7 @@ webrtc:
STUN 帮助客户端发现自己的公网映射地址,是 NAT 穿透的一部分。
关闭场景
仅当此 SyncTV 部署需要提供 STUN且客户端能够访问其 UDP 端口时开启。以下场景保持关闭
- 你使用外部 STUN/TURN。
- 当前部署环境不能暴露 UDP 端口。
@ -52,9 +52,9 @@ STUN 帮助客户端发现自己的公网映射地址,是 NAT 穿透的一部
默认值:`3478`。
这是 STUN 常用端口。
这是 STUN 常用端口,仅在启用内置 STUN 时监听
Kubernetes 或云服务器上要确保 UDP 3478 能被客户端访问。
启用内置 STUN 后,Kubernetes 或云服务器上要确保 UDP 3478 能被客户端访问。
## `webrtc.stun_host`
@ -103,11 +103,19 @@ webrtc:
webrtc.external_ice_servers
```
值是 ICE server 对象数组,例如:
默认使用 Cloudflare 和 Google 的公开 STUN 服务:
```json
[
{ "urls": ["stun:stun.cloudflare.com:3478"] },
{ "urls": ["stun:stun.l.google.com:19302"] }
]
```
修改此运行时设置会替换默认列表,设置为 `[]` 可禁用全部外部 ICE server。TURN server 使用相同的数据格式:
```json
[
{ "urls": ["stun:stun.l.google.com:19302"] },
{
"urls": ["turn:turn.example.com:3478"],
"username": "user",
@ -139,7 +147,7 @@ webrtc.external_ice_servers
如果 WebRTC 连接失败,按顺序检查:
1. 客户端是否能访问 SyncTV HTTP/gRPC API。
2. UDP 3478 是否开放。
2. 启用内置 STUN 时,UDP 3478 是否开放。
3. `stun_external_addr` 是否是客户端可达地址。
这个地址不能是 Pod IP、ClusterIP、回环地址或私网地址除非你完全使用外部 ICE/TURN 并关闭内置 STUN。
4. 反向代理是否只代理了 HTTP而没有开放 UDP。

@ -395,7 +395,7 @@ media_providers:
webrtc:
mode: "peer_to_peer"
enable_builtin_stun: true
enable_builtin_stun: false
stun_port: 3478
stun_host: "0.0.0.0"
stun_external_addr: ""

@ -33,11 +33,11 @@ webrtc:
## Built-In STUN
`webrtc.enable_builtin_stun` default: `true`.
`webrtc.enable_builtin_stun` default: `false`.
STUN helps clients discover their public mapped address for NAT traversal.
Disable it when:
Enable the built-in server only when this SyncTV deployment should provide STUN and clients can reach its UDP port. Keep it disabled when:
- You use external STUN/TURN infrastructure.
- The deployment cannot expose UDP.
@ -92,11 +92,19 @@ External ICE servers are managed through runtime settings, not static YAML:
webrtc.external_ice_servers
```
Value format:
The default uses public STUN services from Cloudflare and Google:
```json
[
{ "urls": ["stun:stun.cloudflare.com:3478"] },
{ "urls": ["stun:stun.l.google.com:19302"] }
]
```
Set this runtime setting to replace the defaults. Use `[]` to disable all external ICE servers. TURN servers use the same value format:
```json
[
{ "urls": ["stun:stun.l.google.com:19302"] },
{
"urls": ["turn:turn.example.com:3478"],
"username": "user",

@ -79,7 +79,7 @@ The HTTP API is available at `http://localhost:8080`. On the first startup with
Docker Compose loads the required `.env.postgres`, `.env.redis`, and `.env.synctv` files through `env_file`: PostgreSQL reads database variables, Redis reads `REDIS_PASSWORD`, and SyncTV reads `SYNCTV_*` application settings. The Compose file also fixes `SYNCTV_DATA_DIR` to `/data` so runtime data stays on the named volume. Reuse the same files across restarts, new shells, host reboots, and upgrades. A missing file causes `docker compose config` to fail immediately.
The production Compose file uses the `latest` image and fixed host ports for HTTP 8080, RTMP 1935, and STUN 3478/udp. Container-only health port `8081` and metrics port `9090` are not published by the default Compose file.
The production Compose file uses the `latest` image and fixed host ports for HTTP 8080 and RTMP 1935. The built-in STUN server is disabled by default. Container-only health port `8081` and metrics port `9090` are not published by the default Compose file.
## Volumes
@ -106,10 +106,11 @@ Host mappings and container listeners are managed separately:
| --- | --- | --- |
| `8080` | `8080` | HTTP REST + public gRPC |
| `1935` | `1935` | RTMP |
| `3478/udp` | `3478/udp` | STUN |
| No host mapping | `8081` | liveness/readiness for the container healthcheck |
| No host mapping | `9090` | Prometheus metrics for a collector on the container network |
To host the built-in STUN server, set `SYNCTV_WEBRTC_ENABLE_BUILTIN_STUN=true`, configure a client-reachable `SYNCTV_WEBRTC_STUN_EXTERNAL_ADDR`, and add `3478:3478/udp` to the SyncTV service's `ports` list.
When a host port is already in use, change the host mapping in the Compose file and run `docker compose config` again.
## Backups

@ -344,7 +344,7 @@ file_storage:
| `media_providers.emby.request_timeout_seconds` | seconds | `30` | Local Emby/Jellyfin provider request timeout |
| `media_providers.emby.connect_timeout_seconds` | seconds | `10` | Local Emby/Jellyfin provider connection timeout |
| `webrtc.mode` | `signaling_only` or `peer_to_peer` | `peer_to_peer` | WebRTC operating mode |
| `webrtc.enable_builtin_stun` | boolean | `true` | Enable built-in STUN |
| `webrtc.enable_builtin_stun` | boolean | `false` | Enable built-in STUN |
| `webrtc.stun_port` | port | `3478` | STUN port |
| `webrtc.stun_host` | IP/listen address | `0.0.0.0` | STUN listen address |
| `webrtc.stun_external_addr` | string | `""` | STUN external address announced to clients |

@ -189,10 +189,10 @@ Optional configuration is updated through its FieldMask path, for example `{"set
| Key | Type | Default | Validation | Meaning |
| --- | --- | --- | --- | --- |
| `webrtc.externalIceServers` | JSON/string structure | `[]` | Valid ICE server array | External ICE servers returned to native clients |
| `webrtc.externalIceServers` | JSON/string structure | Cloudflare and Google public STUN | Valid ICE server array | External ICE servers returned to native clients |
| `webrtc.maxVoiceParticipantsPerRoom` | u32 | `8` | `2..=32` | Maximum simultaneous voice Mesh participants per room |
The external list is empty by default. When built-in STUN is enabled and has a client-reachable advertised address, the server adds it to the ICE servers returned to clients. Built-in STUN listener configuration is startup configuration. See [WebRTC Configuration](../../configuration/webrtc/).
The default value is `[{"urls":["stun:stun.cloudflare.com:3478"]},{"urls":["stun:stun.l.google.com:19302"]}]`. Updating the setting replaces this list; use `[]` to disable all external ICE servers. When built-in STUN is enabled and has a client-reachable advertised address, the server also adds it to the ICE servers returned to clients. Built-in STUN listener configuration is startup configuration. See [WebRTC Configuration](../../configuration/webrtc/).
The voice limit is read dynamically for every join. Lowering it blocks subsequent joins while preserving existing calls; leaving or disconnecting releases a slot immediately. Multi-replica deployments coordinate room slots atomically through Redis.

@ -79,7 +79,7 @@ HTTP API 使用 `http://localhost:8080`。首次启动数据库为空时root
Docker Compose 通过 `env_file` 分别加载必需的 `.env.postgres`、`.env.redis` 和 `.env.synctv`PostgreSQL 读取数据库变量Redis 读取 `REDIS_PASSWORD`SyncTV 读取 `SYNCTV_*` 应用配置。Compose 文件另外固定将 `SYNCTV_DATA_DIR` 设置为 `/data`,让运行数据写入命名卷。重启、换 shell、服务器重启或升级时都应复用同一组环境文件。缺少任一文件时`docker compose config` 会直接失败。
生产 Compose 文件固定使用 `latest` 镜像以及 HTTP 8080、RTMP 1935、STUN 3478/udp 宿主机端口。容器内的健康端口 `8081`、metrics 端口 `9090` 不会被默认 Compose 文件映射到宿主机。
生产 Compose 文件固定使用 `latest` 镜像以及 HTTP 8080、RTMP 1935 宿主机端口。内置 STUN 默认关闭。容器内的健康端口 `8081`、metrics 端口 `9090` 不会被默认 Compose 文件映射到宿主机。
## 数据卷
@ -106,10 +106,11 @@ environment:
| --- | --- | --- |
| `8080` | `8080` | HTTP REST + public gRPC |
| `1935` | `1935` | RTMP |
| `3478/udp` | `3478/udp` | STUN |
| 无宿主机映射 | `8081` | liveness/readiness供容器 healthcheck 使用 |
| 无宿主机映射 | `9090` | Prometheus metrics供容器网络内的 Prometheus 抓取 |
需要自行提供内置 STUN 时,在 `.env.synctv` 中设置 `SYNCTV_WEBRTC_ENABLE_BUILTIN_STUN=true` 和客户端可达的 `SYNCTV_WEBRTC_STUN_EXTERNAL_ADDR`,并在 SyncTV 服务的 `ports` 列表中添加 `3478:3478/udp`。
宿主机端口冲突时,修改 Compose 文件中的宿主机端口映射并重新执行 `docker compose config`。
## 备份

@ -344,7 +344,7 @@ file_storage:
| `media_providers.emby.request_timeout_seconds` | 秒数 | `30` | 本地 Emby/Jellyfin provider 请求总超时 |
| `media_providers.emby.connect_timeout_seconds` | 秒数 | `10` | 本地 Emby/Jellyfin provider 建连超时 |
| `webrtc.mode` | `signaling_only` 或 `peer_to_peer` | `peer_to_peer` | WebRTC 工作模式 |
| `webrtc.enable_builtin_stun` | 布尔值 | `true` | 是否启用内置 STUN |
| `webrtc.enable_builtin_stun` | 布尔值 | `false` | 是否启用内置 STUN |
| `webrtc.stun_port` | 端口 | `3478` | STUN 端口 |
| `webrtc.stun_host` | IP/监听地址 | `0.0.0.0` | STUN 监听地址 |
| `webrtc.stun_external_addr` | 字符串 | `""` | 对客户端公布的 STUN 外部地址 |

@ -189,10 +189,10 @@ SOCKS5 代理 URL 只包含 scheme、host 和 port。代理认证信息位于 `s
| Key | 类型 | 默认值 | 校验 | 说明 |
| --- | --- | --- | --- | --- |
| `webrtc.externalIceServers` | JSON/字符串结构 | `[]` | 有效 ICE server 数组 | 返回给原生客户端的外部 ICE servers |
| `webrtc.externalIceServers` | JSON/字符串结构 | Cloudflare 和 Google 公开 STUN | 有效 ICE server 数组 | 返回给原生客户端的外部 ICE servers |
| `webrtc.maxVoiceParticipantsPerRoom` | u32 | `8` | `2..=32` | 每个房间同时加入语音通话的 Mesh 参与者上限 |
外部列表默认值为空。内置 STUN 启用且具有客户端可达的公布地址时,服务端会将它加入返回给客户端的 ICE servers。内置 STUN 监听端口、host、candidate 过滤等启动配置见 [WebRTC 配置](../../configuration/webrtc/)。
默认值为 `[{"urls":["stun:stun.cloudflare.com:3478"]},{"urls":["stun:stun.l.google.com:19302"]}]`。更新此设置会替换该列表;设置为 `[]` 可禁用全部外部 ICE server。内置 STUN 启用且具有客户端可达的公布地址时,服务端会将它加入返回给客户端的 ICE servers。内置 STUN 监听端口、host、candidate 过滤等启动配置见 [WebRTC 配置](../../configuration/webrtc/)。
语音人数上限在每次加入时动态读取。降低上限会阻止后续参与者加入,当前通话连接保持;离开或断开连接会立即释放席位。多副本部署通过 Redis 原子协调房间席位。

@ -568,7 +568,7 @@ alerting:
| HTTP API: 8080 |
| gRPC: 8080 |
| RTMP: 1935 |
| STUN: 3478/udp |
| STUN: 3478/udp | (optional)
+----+----------+------+
^ ^
| |

@ -565,7 +565,7 @@ config:
webrtc:
## Allowed values: signaling_only, peer_to_peer
mode: "peer_to_peer"
enableBuiltinStun: true
enableBuiltinStun: false
stunPort: 3478
stunHost: "0.0.0.0"
## Public client-reachable STUN address, e.g. "stun.example.com:3478"

@ -570,6 +570,7 @@ assert_pdb_field_absent "$tmp_dir/pdb-legacy-min-available.yaml" maxUnavailable
if helm template synctv "$chart_dir" \
--namespace "$namespace" \
--set config.webrtc.enableBuiltinStun=true \
--set stunService.enabled=true \
--set config.webrtc.stunExternalAddr=203.0.113.10:3478 \
>"$tmp_dir/clusterip-stun.yaml" 2>"$tmp_dir/clusterip-stun.err"; then
@ -578,6 +579,7 @@ fi
helm template synctv "$chart_dir" \
--namespace "$namespace" \
--set config.webrtc.enableBuiltinStun=true \
--set stunService.enabled=true \
--set stunService.type=LoadBalancer \
--set config.webrtc.stunExternalAddr=203.0.113.10:3478 \

@ -50,7 +50,7 @@ impl Default for Config {
server: ServerConfig::default(),
webrtc: WebRTCConfig {
mode: WebRTCMode::PeerToPeer,
enable_builtin_stun: true,
enable_builtin_stun: false,
stun_port: 3478,
stun_host: "0.0.0.0".to_string(),
stun_external_addr: String::new(),

@ -321,7 +321,7 @@ setting!(
ExternalIceServersSetting,
IceServerList,
"webrtc.external_ice_servers",
IceServerList::new()
IceServerList::public_stun_defaults()
);
setting!(
MaxVoiceParticipantsPerRoomSetting,
@ -1468,6 +1468,27 @@ mod tests {
setting
}
#[test]
fn test_runtime_settings_default_to_public_stun_servers() {
let store = RuntimeSettingsStore::new_for_tests();
let settings = ok(store.runtime_settings(), "runtime settings should load");
assert_eq!(
settings.webrtc.external_ice_servers,
IceServerList::public_stun_defaults()
);
ok(
store
.webrtc
.external_ice_servers
.set_for_test(&IceServerList::new()),
"external ICE servers should update",
);
let settings = ok(store.runtime_settings(), "runtime settings should reload");
assert!(settings.webrtc.external_ice_servers.0.is_empty());
}
#[test]
fn test_runtime_settings_reads_complete_storage_generations() {
let store = Arc::new(RuntimeSettingsStore::new_for_tests());

@ -320,6 +320,14 @@ impl IceServerList {
pub fn new() -> Self {
Self(Vec::new())
}
#[must_use]
pub fn public_stun_defaults() -> Self {
Self(vec![
ConfiguredIceServer::new(vec!["stun:stun.cloudflare.com:3478".to_string()]),
ConfiguredIceServer::new(vec!["stun:stun.l.google.com:19302".to_string()]),
])
}
}
impl Default for IceServerList {

@ -35,10 +35,15 @@ fn test_room_password_policy_parse_and_display() {
}
#[test]
fn test_external_ice_server_list_defaults_empty_and_accepts_custom_servers() {
fn test_external_ice_server_list_supports_public_defaults_and_custom_servers() {
let list = IceServerList::new();
assert!(list.0.is_empty());
let defaults = IceServerList::public_stun_defaults();
assert_eq!(defaults.0.len(), 2);
assert_eq!(defaults.0[0].urls, ["stun:stun.cloudflare.com:3478"]);
assert_eq!(defaults.0[1].urls, ["stun:stun.l.google.com:19302"]);
let custom = IceServerList(vec![
ConfiguredIceServer::new(vec!["stun:custom1.example.com:19302".to_string()]),
ConfiguredIceServer::new(vec![

@ -775,13 +775,14 @@ webauthn:
#
# WebRTC supports 2 operation modes:
# - signaling_only: Pure signaling relay without ICE bootstrap
# - peer_to_peer: P2P with built-in STUN plus optional external ICE servers
# - peer_to_peer: P2P with public ICE servers and optional built-in STUN
webrtc:
mode: "peer_to_peer"
# --- Built-in STUN Server ---
enable_builtin_stun: true
# Disabled by default. Enable only when this deployment should host STUN.
enable_builtin_stun: false
stun_port: 3478
stun_host: "0.0.0.0"
# External address for STUN reflexive candidates. In K8s/NAT, set this to
@ -801,7 +802,7 @@ webrtc:
# --- External ICE Servers ---
# Configured dynamically via the settings API (no restart required):
# "webrtc.external_ice_servers" - JSON array of ICE server objects, e.g.:
# [{"urls":["stun:stun.example.com:3478"]},{"urls":["turn:turn.example.com:3478"],"username":"user","credential":"pass"}]
# [{"urls":["stun:stun.cloudflare.com:3478"]},{"urls":["stun:stun.l.google.com:19302"]}]
# ============================================================================
# Dynamic ICE Servers Configuration
@ -811,7 +812,8 @@ webrtc:
#
# Setting key: "webrtc.external_ice_servers"
# Value: JSON array of ICE server objects
# Default: [] (the built-in STUN server is used)
# Default: Cloudflare and Google public STUN servers shown above.
# Set [] to disable all external ICE servers.
#
# Changes take effect immediately without restarting the server.

@ -2101,10 +2101,7 @@ mod tests {
chat: crate::app_config::ChatConfig::default(),
webauthn: WebAuthnConfig::default(),
media_providers: MediaProvidersConfig::default(),
webrtc: WebRTCConfig {
stun_external_addr: "203.0.113.1:3478".to_string(),
..WebRTCConfig::default()
},
webrtc: WebRTCConfig::default(),
connection_limits: ConnectionLimitsConfig::default(),
bootstrap: BootstrapConfig {
create_root_user: false,

@ -1316,7 +1316,7 @@ impl Default for WebRTCConfig {
fn default() -> Self {
Self {
mode: WebRTCMode::PeerToPeer,
enable_builtin_stun: true,
enable_builtin_stun: false,
stun_port: 3478,
stun_host: "0.0.0.0".to_string(),
stun_external_addr: String::new(),
@ -1791,3 +1791,13 @@ impl Default for AppConfig {
}
}
}
#[cfg(test)]
mod tests {
use super::WebRTCConfig;
#[test]
fn default_webrtc_config_disables_builtin_stun() {
assert!(!WebRTCConfig::default().enable_builtin_stun);
}
}

@ -155,10 +155,7 @@ fn cluster_test_config() -> Config {
chat: ChatConfig::default(),
webauthn: WebAuthnConfig::default(),
media_providers: MediaProvidersConfig::default(),
webrtc: WebRTCConfig {
stun_external_addr: "203.0.113.1:3478".to_string(),
..WebRTCConfig::default()
},
webrtc: WebRTCConfig::default(),
connection_limits: ConnectionLimitsConfig::default(),
bootstrap: BootstrapConfig {
create_root_user: true,

Loading…
Cancel
Save