Skip to content

Commit 9bf159d

Browse files
author
shijiashuai
committed
fix: harden signaling and modularize frontend
1 parent 72bafb1 commit 9bf159d

File tree

13 files changed

+1744
-783
lines changed

13 files changed

+1744
-783
lines changed

README.md

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ A minimal WebRTC demo project built with Go, providing a WebSocket signaling ser
1515

1616
| Feature | Description |
1717
|:--------|:------------|
18-
| **WebSocket Signaling** | Gorilla WebSocket for Offer/Answer/ICE Candidate relay within rooms, with heartbeat keep-alive |
18+
| **WebSocket Signaling** | Gorilla WebSocket for Offer/Answer/ICE Candidate relay within rooms, with heartbeat, join acknowledgement, and explicit hangup |
1919
| **Media Controls** | Mute/unmute, camera on/off, screen sharing (`getDisplayMedia`) |
2020
| **DataChannel** | Peer-to-peer text chat without server relay |
2121
| **Local Recording** | MediaRecorder captures audio/video streams, exports `.webm` for download |
2222
| **Multi-party Mesh** | Room member list broadcast, multi-PeerConnection management, grid video layout |
23-
| **Security** | Origin validation whitelist, room/client limits, auto-reconnection |
23+
| **Security** | Origin validation whitelist, identity binding, duplicate ID rejection, room/client limits, auto-reconnection |
2424
| **Docker** | Multi-stage Dockerfile, Go compilation + static frontend packaging |
2525

2626
## Architecture
@@ -75,6 +75,24 @@ docker run --rm -p 8080:8080 webrtc
7575
|:---------|:------------|:--------|
7676
| `ADDR` | HTTP listen address | `:8080` |
7777
| `WS_ALLOWED_ORIGINS` | Comma-separated allowed origins; set to `*` for all | `localhost` |
78+
| `RTC_CONFIG_JSON` | JSON object passed to the browser as `window.__APP_CONFIG__.rtcConfig` for custom ICE/TURN config | built-in public STUN |
79+
80+
Example `RTC_CONFIG_JSON`:
81+
82+
```json
83+
{
84+
"iceServers": [
85+
{ "urls": ["stun:stun.l.google.com:19302"] },
86+
{
87+
"urls": ["turn:turn.example.com:3478"],
88+
"username": "demo-user",
89+
"credential": "demo-password"
90+
}
91+
]
92+
}
93+
```
94+
95+
Health check endpoint: `GET /healthz`
7896

7997
## Project Structure
8098

@@ -119,6 +137,13 @@ webrtc/
119137
- [Roadmap](ROADMAP.md) — Development plan & progress tracking
120138
- [Contributing](CONTRIBUTING.md) — Development workflow & code standards
121139

140+
## Current Guardrails
141+
142+
- The signaling server now binds each WebSocket connection to a single client ID and room membership, instead of trusting later messages blindly.
143+
- Duplicate client IDs in the same room are rejected instead of replacing an existing connection.
144+
- WebSocket connections use read limits, deadlines, pong handling, and server-driven ping frames.
145+
- The browser uses perfect-negotiation style collision handling and explicit `hangup` signaling for more stable multi-peer Mesh calls.
146+
122147
## Roadmap
123148

124149
- [x] 1-on-1 call with status display, error handling, heartbeat

cmd/server/main.go

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ package main
22

33
import (
44
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
58
"log"
69
"net/http"
710
"os"
@@ -13,6 +16,10 @@ import (
1316
sig "lessup/webrtc/internal/signal"
1417
)
1518

19+
type appConfig struct {
20+
RTCConfig json.RawMessage `json:"rtcConfig,omitempty"`
21+
}
22+
1623
func parseOrigins(raw string) (origins []string, allowAll bool) {
1724
if raw == "" {
1825
return nil, false
@@ -30,13 +37,40 @@ func parseOrigins(raw string) (origins []string, allowAll bool) {
3037
return origins, false
3138
}
3239

40+
func loadAppConfig() appConfig {
41+
raw := strings.TrimSpace(os.Getenv("RTC_CONFIG_JSON"))
42+
if raw == "" {
43+
return appConfig{}
44+
}
45+
if !json.Valid([]byte(raw)) {
46+
log.Printf("server: ignoring invalid RTC_CONFIG_JSON")
47+
return appConfig{}
48+
}
49+
return appConfig{RTCConfig: json.RawMessage(raw)}
50+
}
51+
52+
func configJSHandler(cfg appConfig) http.HandlerFunc {
53+
payload, err := json.Marshal(cfg)
54+
if err != nil {
55+
log.Printf("server: marshal config failed: %v", err)
56+
payload = []byte("{}")
57+
}
58+
body := fmt.Sprintf("window.__APP_CONFIG__ = %s;\n", payload)
59+
60+
return func(w http.ResponseWriter, _ *http.Request) {
61+
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
62+
_, _ = io.WriteString(w, body)
63+
}
64+
}
65+
3366
func main() {
3467
addr := ":8080"
3568
if v := os.Getenv("ADDR"); v != "" {
3669
addr = v
3770
}
3871

3972
wsAllowed, wsAllowAll := parseOrigins(os.Getenv("WS_ALLOWED_ORIGINS"))
73+
appCfg := loadAppConfig()
4074

4175
hub := sig.NewHubWithOptions(sig.Options{
4276
AllowedOrigins: wsAllowed,
@@ -45,9 +79,21 @@ func main() {
4579

4680
mux := http.NewServeMux()
4781
mux.HandleFunc("/ws", hub.HandleWS)
82+
mux.HandleFunc("/config.js", configJSHandler(appCfg))
83+
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
84+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
85+
_, _ = io.WriteString(w, "ok\n")
86+
})
4887
mux.Handle("/", http.FileServer(http.Dir("web")))
4988

50-
srv := &http.Server{Addr: addr, Handler: mux}
89+
srv := &http.Server{
90+
Addr: addr,
91+
Handler: mux,
92+
ReadHeaderTimeout: 5 * time.Second,
93+
ReadTimeout: 15 * time.Second,
94+
WriteTimeout: 15 * time.Second,
95+
IdleTimeout: 60 * time.Second,
96+
}
5197

5298
// Graceful shutdown on SIGINT / SIGTERM.
5399
quit := make(chan os.Signal, 1)
@@ -63,6 +109,7 @@ func main() {
63109
<-quit
64110
log.Println("server: shutting down ...")
65111
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
112+
hub.Close()
66113
if err := srv.Shutdown(ctx); err != nil {
67114
log.Print("server: forced shutdown:", err)
68115
cancel()

docs/signaling.md

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@ WebRTC 负责**媒体和数据通道的端到端传输**,但它本身并不定
2828

2929
在浏览器建立 WebRTC 连接之前,需要交换一些“信令”信息:
3030

31-
- 谁在什么房间(`join/leave`);
31+
- 谁在什么房间(`join/joined/leave`);
3232
- 双方的 SDP(`offer/answer`);
3333
- ICE 候选(`candidate`);
34-
- 其它辅助信息(比如房间成员列表 `room_members`)。
34+
- 其它辅助信息(比如房间成员列表 `room_members`、显式挂断 `hangup`、协议错误 `error`)。
3535

3636
本项目选择:
3737

@@ -65,14 +65,16 @@ type Message struct {
6565

6666
- `Type`:消息类型(字符串),例如:
6767
- `"join"`:加入房间请求(从前端发给服务端);
68-
- `"leave"`:离开房间请求(当前 Demo 中暂时很少手动用到);
68+
- `"joined"`:服务端确认加入成功;
69+
- `"leave"`:离开房间请求;
6970
- `"offer"` / `"answer"`:SDP 交换;
7071
- `"candidate"`:ICE 候选;
72+
- `"hangup"`:显式结束某条 PeerConnection;
7173
- `"room_members"`:服务端广播的当前房间成员列表(从服务端发给前端);
72-
- `"ping"`可选心跳消息(从前端发给服务端,用于保活/学习);
73-
- `"pong"`:服务端对 `"ping"` 的可选回应(从服务端发给前端)
74+
- `"error"`协议级错误(如重复 ID、非法房间名等);
75+
- `"ping"` / `"pong"`:兼容保留的应用层心跳消息
7476
- `Room`:房间名,字符串。
75-
- `From`:发送方 ID(前端生成的 `myId`
77+
- `From`:发送方 ID。加入时由客户端申请,加入成功后由服务端绑定到该 WebSocket,并覆盖后续转发消息里的 `from`
7678
- `To`:接收方 ID,仅点对点消息(`offer/answer/candidate`)需要。
7779
- `SDP`:SDP 内容,使用 `json.RawMessage` 承载浏览器产生的原始 SDP 对象。
7880
- `Candidate`:ICE 候选,同样用 `json.RawMessage` 存储浏览器给出的对象。
@@ -82,6 +84,7 @@ type Message struct {
8284

8385
- 一个 `Message` 结构可以覆盖所有信令类型,结构简单;
8486
- 使用 `json.RawMessage` 避免在 Go 中深度解析 SDP/ICE,只做**透明转发**
87+
- 服务端只接受一次身份绑定,后续会校验连接和房间归属,避免客户端伪造 `from/room`
8588

8689
---
8790

@@ -118,11 +121,12 @@ type Client struct {
118121
- 按房间组织客户端:
119122
- `rooms["room1"]["userA"] = *Client`
120123
- `rooms["room1"]["userB"] = *Client`
121-
- 处理下列操作:
122-
- WebSocket 连接升级与关闭;
123-
- 收到 `join/leave/ping/offer/answer/candidate` 消息并处理;
124-
-`offer/answer/candidate``Room + To` 进行转发;
125-
- 在房间成员变化时,广播 `room_members` 消息。
124+
- 处理下列操作:
125+
- WebSocket 连接升级与关闭;
126+
- 收到 `join/leave/ping/offer/answer/candidate/hangup` 消息并处理;
127+
-`offer/answer/candidate/hangup``Room + To` 进行转发;
128+
- 在房间成员变化时,广播 `room_members` 消息;
129+
- 对非法消息回写 `error`
126130

127131
### 3.2 Client 的职责
128132

@@ -212,6 +216,7 @@ func (h *Hub) HandleWS(w http.ResponseWriter, r *http.Request) {
212216
- 成功时打印“ws connected from ...”。
213217
2. 为该连接创建一个 `Client`,带一个缓冲大小为 32 的 `send` 通道。
214218
3. 启动 `writePump` 协程,负责异步写消息。
219+
4. 为连接设置 `SetReadLimit`、读超时、`PongHandler`,并由服务端定期发送 WebSocket ping 帧。
215220
4. 当读循环退出后,按顺序执行显式清理:
216221
- 调用 `removeClient` 移除客户端(阻止新消息进入 `send` 通道);
217222
- 关闭 `send` 通道(终止 `writePump` 的 range 循环);
@@ -221,11 +226,11 @@ func (h *Hub) HandleWS(w http.ResponseWriter, r *http.Request) {
221226

222227
-`for` 循环中,通过 `c.ReadJSON(&msg)` 从 WebSocket 读取 JSON 并反序列化为 `Message`
223228
- 根据 `msg.Type`
224-
- `join`设置 `client.id`/`client.room`并调用 `addClient`
229+
- `join`校验 `id/room`绑定连接身份,加入成功后回写 `joined`
225230
- `leave`:调用 `removeClient`
226-
- `ping`可选心跳消息,服务端可回写 `pong`(非阻塞发送)
227-
- `offer/answer/candidate`:调用 `forward(msg)`根据房间和 `To` 转发
228-
- 其他:打印未知类型日志
231+
- `ping`兼容回写 `pong`
232+
- `offer/answer/candidate/hangup`:调用 `forward(...)`由服务端填充真实 `from/room` 后转发
233+
- 其他:回写 `error`
229234

230235
`ReadJSON` 返回错误(连接关闭/协议错误等):
231236

0 commit comments

Comments
 (0)