From ac42e25868b2b33b081275744f4dd96470f83964 Mon Sep 17 00:00:00 2001 From: Yvan <8574526@qq,com> Date: Wed, 23 Sep 2026 09:03:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=B9=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 + deploy/deploy.sh | 15 ++++++ docs/LIVE.md | 14 ++++-- internal/integration/live/server.go | 60 +++++++++++++++++++++--- internal/integration/live/server_test.go | 27 +++++++++++ 5 files changed, 109 insertions(+), 9 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1355558..8f80cb8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,8 @@ USER kra EXPOSE 8000 EXPOSE 9000 +# WebRTC 媒体 UDP 端口(KRA_LIVE_UDP_PORT,deploy.sh 默认 9504 并通过 -p 发布) +EXPOSE 9504/udp VOLUME /data/conf CMD ["sh", "-c", "exec ./$BIN_NAME -conf /data/conf"] diff --git a/deploy/deploy.sh b/deploy/deploy.sh index fac4052..b8bb1a5 100644 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -25,6 +25,9 @@ UPLOADS_DIR="$BASE_DIR/uploads" # 上传文件目录 -> 容器内 /app/uploads FRONTEND_PORT=9501 # admin 管理后台 nginx 容器 BACKEND_PORT=9502 # 后端 API/WebSocket 容器 LIVEWEB_PORT=9503 # live 用户端(PC 直播页) nginx 容器 +LIVE_UDP_PORT="${KRA_LIVE_UDP_PORT:-9504}" # WebRTC 音视频固定 UDP 端口(与 9501-9503 顺延配套) +LIVE_PUBLIC_IP="${KRA_LIVE_PUBLIC_IP:-}" # 留空时从站点域名解析 +LIVE_HOST="${KRA_LIVE_HOST:-live.fuhua91.com}" NETWORK="live-net" BACKEND_NAME="live-backend" FRONTEND_NAME="live-frontend" @@ -49,6 +52,15 @@ if [ ! -f "$CONFIG_DIR/config.yaml" ]; then exit 1 fi +if [ -z "$LIVE_PUBLIC_IP" ]; then + LIVE_PUBLIC_IP="$(getent ahostsv4 "$LIVE_HOST" | awk 'NR == 1 { print $1 }')" +fi +if ! printf '%s' "$LIVE_PUBLIC_IP" | grep -Eq '^[0-9]+(\.[0-9]+){3}$'; then + echo "错误: 无法确定直播服务公网 IPv4,请通过 KRA_LIVE_PUBLIC_IP 显式设置" + exit 1 +fi +echo "==> WebRTC 媒体地址: $LIVE_PUBLIC_IP:$LIVE_UDP_PORT/udp" + # 3. 登录 Harbor;未传密码时复用服务器现有的 Docker 登录态 HARBOR_USER="${HARBOR_USER:-$HARBOR_USER_DEFAULT}" HARBOR_LOGGED_IN=false @@ -80,6 +92,9 @@ docker run -d --name "$BACKEND_NAME" \ --restart always \ --network "$NETWORK" \ -p "$BACKEND_PORT:8000" \ + -p "$LIVE_UDP_PORT:$LIVE_UDP_PORT/udp" \ + -e KRA_LIVE_PUBLIC_IP="$LIVE_PUBLIC_IP" \ + -e KRA_LIVE_UDP_PORT="$LIVE_UDP_PORT" \ -v "$CONFIG_DIR:/data/conf" \ -v "$LOG_DIR:/app/logs" \ -v "$UPLOADS_DIR:/app/uploads" \ diff --git a/docs/LIVE.md b/docs/LIVE.md index 0843969..1e0b515 100644 --- a/docs/LIVE.md +++ b/docs/LIVE.md @@ -146,12 +146,20 @@ go run ./tmp/live_acceptance verify ## 网络配置 -默认使用 Pion 主机候选。生产环境可以配置: +默认使用 Pion 主机候选。本机开发无需额外配置;后端运行在 Docker、Kubernetes 或 NAT 后方时,推荐使用一个固定 UDP 端口并声明浏览器可访问的公网或局域网 IP: + +```text +KRA_LIVE_PUBLIC_IP=203.0.113.10 +KRA_LIVE_UDP_PORT=9504 +``` + +容器必须同时发布同一个 UDP 端口,例如 `-p 9504:9504/udp`,主机防火墙和云安全组也必须放行 UDP 9504。`deploy/deploy.sh` 已自动发布该端口(默认 9504,与 9501-9503 顺延配套),并默认从 `live.fuhua91.com` 解析公网 IPv4;也可以在执行脚本前显式设置 `KRA_LIVE_PUBLIC_IP`。 + +若基础设施更适合端口范围,也可以不设置 `KRA_LIVE_UDP_PORT`,改用: ```text -KRA_LIVE_STUN_URL=stun:stun.example.com:3478 KRA_LIVE_UDP_MIN_PORT=40000 KRA_LIVE_UDP_MAX_PORT=49999 ``` -配置 UDP 端口范围后,需要在防火墙或容器网络中放行对应端口。 +端口范围模式同样需要逐一发布和放行整个 UDP 范围。跨复杂 NAT、企业网络或移动网络时,可额外配置 `KRA_LIVE_STUN_URL=stun:stun.example.com:3478`;需要强制中继时应部署 TURN 服务。 diff --git a/internal/integration/live/server.go b/internal/integration/live/server.go index b935dbe..fad1bc8 100644 --- a/internal/integration/live/server.go +++ b/internal/integration/live/server.go @@ -5,7 +5,9 @@ import ( "encoding/json" "errors" "fmt" + "io" "log/slog" + "net" "os" "strconv" "strings" @@ -29,6 +31,7 @@ type Manager struct { chat *bizchat.Usecase logger *slog.Logger api *webrtc.API + iceMux io.Closer mu sync.RWMutex rooms map[string]*room peers map[*melody.Session]*peer @@ -89,19 +92,57 @@ type chatSendPayload struct { Content string `json:"content"` } -func newPionAPI() (*webrtc.API, error) { +func newPionAPI() (*webrtc.API, io.Closer, error) { settings := webrtc.SettingEngine{} + fixedPort := envPort("KRA_LIVE_UDP_PORT") minPort := envPort("KRA_LIVE_UDP_MIN_PORT") maxPort := envPort("KRA_LIVE_UDP_MAX_PORT") + if fixedPort > 0 && (minPort > 0 || maxPort > 0) { + return nil, nil, errors.New("KRA_LIVE_UDP_PORT cannot be combined with KRA_LIVE_UDP_MIN_PORT/KRA_LIVE_UDP_MAX_PORT") + } if minPort > 0 || maxPort > 0 { if minPort == 0 || maxPort == 0 || minPort > maxPort { - return nil, fmt.Errorf("invalid KRA_LIVE_UDP_MIN_PORT/KRA_LIVE_UDP_MAX_PORT") + return nil, nil, fmt.Errorf("invalid KRA_LIVE_UDP_MIN_PORT/KRA_LIVE_UDP_MAX_PORT") } if err := settings.SetEphemeralUDPPortRange(minPort, maxPort); err != nil { - return nil, fmt.Errorf("configure live udp port range: %w", err) + return nil, nil, fmt.Errorf("configure live udp port range: %w", err) } } - return webrtc.NewAPI(webrtc.WithSettingEngine(settings)), nil + publicIPs, err := livePublicIPs() + if err != nil { + return nil, nil, err + } + if len(publicIPs) > 0 { + settings.SetNAT1To1IPs(publicIPs, webrtc.ICECandidateTypeHost) + } + var mux io.Closer + if fixedPort > 0 { + packetConn, listenErr := net.ListenPacket("udp4", fmt.Sprintf(":%d", fixedPort)) + if listenErr != nil { + return nil, nil, fmt.Errorf("listen on live udp port %d: %w", fixedPort, listenErr) + } + udpMux := webrtc.NewICEUDPMux(nil, packetConn) + settings.SetICEUDPMux(udpMux) + mux = udpMux + } + return webrtc.NewAPI(webrtc.WithSettingEngine(settings)), mux, nil +} + +func livePublicIPs() ([]string, error) { + value := strings.TrimSpace(os.Getenv("KRA_LIVE_PUBLIC_IP")) + if value == "" { + return nil, nil + } + items := strings.Split(value, ",") + result := make([]string, 0, len(items)) + for _, item := range items { + item = strings.TrimSpace(item) + if net.ParseIP(item) == nil { + return nil, fmt.Errorf("invalid KRA_LIVE_PUBLIC_IP %q", item) + } + result = append(result, item) + } + return result, nil } func envPort(key string) uint16 { @@ -133,7 +174,7 @@ func New(hub platformws.Hub, streams *bizlive.StreamUsecase, rooms *bizlive.Room if chat == nil { return nil, nil, errors.New("live chat usecase is unavailable") } - api, err := newPionAPI() + api, iceMux, err := newPionAPI() if err != nil { return nil, nil, err } @@ -141,12 +182,14 @@ func New(hub platformws.Hub, streams *bizlive.StreamUsecase, rooms *bizlive.Room logger = slog.Default() } logger = logger.With("mod", "live") - m := &Manager{hub: hub, streams: streams, roomCatalog: rooms, chat: chat, logger: logger, api: api, rooms: make(map[string]*room), peers: make(map[*melody.Session]*peer), observers: make(map[*melody.Session]struct{}), stop: make(chan struct{})} + m := &Manager{hub: hub, streams: streams, roomCatalog: rooms, chat: chat, logger: logger, api: api, iceMux: iceMux, rooms: make(map[string]*room), peers: make(map[*melody.Session]*peer), observers: make(map[*melody.Session]struct{}), stop: make(chan struct{})} hub.OnMessage(m.onMessage) hub.OnDisconnect(m.onDisconnect) go m.heartbeatLoop() logger.Info("live signaling manager initialized", "stun_configured", len(liveICEServers()) > 0, + "public_ips", strings.TrimSpace(os.Getenv("KRA_LIVE_PUBLIC_IP")), + "udp_port", envPort("KRA_LIVE_UDP_PORT"), "udp_min_port", envPort("KRA_LIVE_UDP_MIN_PORT"), "udp_max_port", envPort("KRA_LIVE_UDP_MAX_PORT")) return m, m.Close, nil @@ -164,6 +207,11 @@ func (m *Manager) Close() { for _, p := range peers { m.closePeer(p, "server_shutdown") } + if m.iceMux != nil { + if err := m.iceMux.Close(); err != nil { + m.liveLogger().Warn("live UDP mux close failed", "error", err) + } + } } func (m *Manager) onMessage(session *melody.Session, raw []byte) { diff --git a/internal/integration/live/server_test.go b/internal/integration/live/server_test.go index 6a4d778..8e663fb 100644 --- a/internal/integration/live/server_test.go +++ b/internal/integration/live/server_test.go @@ -3,6 +3,7 @@ package live import ( "context" "encoding/json" + "strings" "sync" "testing" @@ -480,3 +481,29 @@ func TestClosedSupersededPeerConnectionDoesNotRemovePeer(t *testing.T) { } var _ platformws.Hub = (*liveHubStub)(nil) + +func TestNewPionAPIRejectsConflictingUDPConfiguration(t *testing.T) { + t.Setenv("KRA_LIVE_UDP_PORT", "40000") + t.Setenv("KRA_LIVE_UDP_MIN_PORT", "40000") + t.Setenv("KRA_LIVE_UDP_MAX_PORT", "40010") + + _, closer, err := newPionAPI() + if closer != nil { + _ = closer.Close() + } + if err == nil || !strings.Contains(err.Error(), "cannot be combined") { + t.Fatalf("newPionAPI error = %v, want conflicting UDP configuration", err) + } +} + +func TestNewPionAPIRejectsInvalidPublicIP(t *testing.T) { + t.Setenv("KRA_LIVE_PUBLIC_IP", "live.example.com") + + _, closer, err := newPionAPI() + if closer != nil { + _ = closer.Close() + } + if err == nil || !strings.Contains(err.Error(), "invalid KRA_LIVE_PUBLIC_IP") { + t.Fatalf("newPionAPI error = %v, want invalid public IP", err) + } +}