Add call protocol, Rmux. Update AmneziaWG. Fixes and improvements

This commit is contained in:
Shtorm
2026-08-06 14:19:34 +03:00
parent 051e928b01
commit d6b9f693c4
89 changed files with 15671 additions and 132 deletions

View File

@@ -0,0 +1,363 @@
package wbstream
import (
"bytes"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/sagernet/sing-box/transport/call/common"
)
const (
APIBase = "https://stream.wb.ru"
Origin = "https://stream.wb.ru"
)
var WBStreamCookieAllowlist = []string{
"wbx-refresh",
"x_wbaas_token",
"_wbauid",
"wbx-validation-key",
}
var ModeratorPermissions = []string{
"ROOM_PERMISSION_SEND_CHAT",
"ROOM_PERMISSION_SHARE_AUDIO",
"ROOM_PERMISSION_SHARE_SCREEN",
"ROOM_PERMISSION_SHARE_VIDEO",
"ROOM_PERMISSION_MODIFY_PERMISSIONS",
"ROOM_PERMISSION_MODERATE_ROOM",
"ROOM_PERMISSION_CALL_DATA_ACCESS",
"ROOM_PERMISSION_LOCAL_RECORD",
}
type guestRegisterRequest struct {
DisplayName string `json:"displayName"`
Device guestDeviceCfg `json:"device"`
}
type guestDeviceCfg struct {
DeviceName string `json:"deviceName"`
DeviceType string `json:"deviceType"`
}
type guestRegisterResponse struct {
AccessToken string `json:"accessToken"`
}
type createRoomRequest struct {
RoomType string `json:"roomType"`
RoomPrivacy string `json:"roomPrivacy"`
}
type createRoomResponse struct {
RoomID string `json:"roomId"`
}
type connectionDetailsResponse struct {
RoomToken string `json:"roomToken"`
ServerURL string `json:"serverUrl"`
}
type cookieTransport struct {
base http.RoundTripper
cookie string
}
type slideV3Response struct {
Payload struct {
AccessToken string `json:"access_token"`
} `json:"payload"`
}
func ParseRoomID(input string) string {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return ""
}
if rest, ok := strings.CutPrefix(trimmed, "wbstream://"); ok {
return strings.Trim(rest, "/")
}
if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
u, err := url.Parse(trimmed)
if err == nil {
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
for i := 0; i < len(parts)-1; i++ {
if parts[i] == "room" && parts[i+1] != "" {
return parts[i+1]
}
}
}
}
return strings.Trim(trimmed, "/")
}
func RegisterGuest(client *http.Client, displayName string) (string, error) {
body, _ := json.Marshal(guestRegisterRequest{
DisplayName: displayName,
Device: guestDeviceCfg{
DeviceName: "Linux",
DeviceType: "PARTICIPANT_DEVICE_TYPE_WEB_DESKTOP",
},
})
req, err := http.NewRequest(http.MethodPost, APIBase+"/auth/api/v1/auth/user/guest-register", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpDo(client, req)
if err != nil {
return "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("guest-register: status %d: %s", resp.StatusCode, string(raw))
}
var r guestRegisterResponse
if err := json.Unmarshal(raw, &r); err != nil {
return "", fmt.Errorf("guest-register decode: %w", err)
}
return r.AccessToken, nil
}
func CreateRoom(client *http.Client, accessToken string) (string, error) {
body, _ := json.Marshal(createRoomRequest{
RoomType: "ROOM_TYPE_ALL_ON_SCREEN",
RoomPrivacy: "ROOM_PRIVACY_FREE",
})
req, err := http.NewRequest(http.MethodPost, APIBase+"/api-room/api/v2/room", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
setBearer(req, accessToken)
resp, err := httpDo(client, req)
if err != nil {
return "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("create-room: status %d: %s", resp.StatusCode, string(raw))
}
var r createRoomResponse
if err := json.Unmarshal(raw, &r); err != nil {
return "", fmt.Errorf("create-room decode: %w", err)
}
return r.RoomID, nil
}
func JoinRoom(client *http.Client, accessToken, roomID string) error {
url := fmt.Sprintf("%s/api-room/api/v1/room/%s/join", APIBase, roomID)
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader([]byte("{}")))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
setBearer(req, accessToken)
resp, err := httpDo(client, req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
raw, _ := io.ReadAll(resp.Body)
return fmt.Errorf("join-room: status %d: %s", resp.StatusCode, string(raw))
}
return nil
}
func GetConnectionDetails(client *http.Client, accessToken, roomID, displayName string) (string, string, error) {
detailsURL := fmt.Sprintf("%s/api-room-manager/v2/room/%s/connection-details?deviceType=PARTICIPANT_DEVICE_TYPE_WEB_DESKTOP&displayName=%s",
APIBase, roomID, url.QueryEscape(displayName))
req, err := http.NewRequest(http.MethodGet, detailsURL, nil)
if err != nil {
return "", "", err
}
setBearer(req, accessToken)
resp, err := httpDo(client, req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("connection-details: status %d: %s", resp.StatusCode, string(raw))
}
var r connectionDetailsResponse
if err := json.Unmarshal(raw, &r); err != nil {
return "", "", fmt.Errorf("connection-details decode: %w", err)
}
return r.RoomToken, r.ServerURL, nil
}
func AuthAndGetToken(client *http.Client, roomID, displayName string) (string, string, string, string, error) {
accessToken, err := RegisterGuest(client, displayName)
if err != nil {
return "", "", "", "", fmt.Errorf("register guest: %w", err)
}
return joinAndGetDetails(client, accessToken, roomID, displayName)
}
func AuthAsLoggedIn(client *http.Client, cookieHeader, accessToken, roomID, displayName string) (string, string, string, string, error) {
if cookieHeader == "" && accessToken == "" {
return "", "", "", "", fmt.Errorf("cookies or access token required for logged-in auth")
}
client = clientWithCookies(client, cookieHeader)
return joinAndGetDetails(client, accessToken, roomID, displayName)
}
func RefreshAccessToken(client *http.Client, cookieHeader, deviceID string) (string, error) {
req, err := http.NewRequest(http.MethodPost, "https://auth-stream.wb.ru/v2/auth/slide-v3", bytes.NewReader(nil))
if err != nil {
return "", err
}
if deviceID == "" {
deviceID = newRequestID()
}
req.Header.Set("wb-apptype", "web")
req.Header.Set("X-Real-IP", "")
req.Header.Set("deviceId", deviceID)
req.Header.Set("X-Request-ID", newRequestID())
req.Header.Set("Origin", Origin)
req.Header.Set("Referer", Origin+"/")
req.Header.Set("Cookie", cookieHeader)
req.Header.Set("User-Agent", common.UserAgent)
if client == nil {
client = http.DefaultClient
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("slide-v3: status %d: %s", resp.StatusCode, string(raw))
}
var r slideV3Response
if err := json.Unmarshal(raw, &r); err != nil {
return "", fmt.Errorf("slide-v3 decode: %w", err)
}
if r.Payload.AccessToken == "" {
return "", fmt.Errorf("slide-v3: empty access_token in response: %s", string(raw))
}
return r.Payload.AccessToken, nil
}
func SetParticipantPermissions(client *http.Client, accessToken, roomID, participantID string, permissions []string) error {
setURL := fmt.Sprintf("%s/api-room-manager/api/v1/room/%s/participant/%s/set-permissions", APIBase, roomID, participantID)
body, err := json.Marshal(map[string]any{"permissions": permissions})
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPut, setURL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
setBearer(req, accessToken)
resp, err := httpDo(client, req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("set-permissions %s -> %d %s", participantID, resp.StatusCode, string(respBody))
}
return nil
}
func KickParticipant(client *http.Client, accessToken, roomID, participantID string) error {
if client == nil {
client = http.DefaultClient
}
kickURL := fmt.Sprintf("%s/api-room-manager/api/v1/room/%s/participant/%s/kick", APIBase, roomID, participantID)
req, err := http.NewRequest("DELETE", kickURL, strings.NewReader("{}"))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("User-Agent", common.UserAgent)
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("kick %s -> %d %s", participantID, resp.StatusCode, string(body))
}
return nil
}
func (t *cookieTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("Cookie", t.cookie)
base := t.base
if base == nil {
base = http.DefaultTransport
}
return base.RoundTrip(req)
}
func httpDo(client *http.Client, req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", common.UserAgent)
if client == nil {
client = http.DefaultClient
}
return client.Do(req)
}
func clientWithCookies(client *http.Client, cookieHeader string) *http.Client {
if cookieHeader == "" {
return client
}
if client == nil {
client = &http.Client{}
}
wrapped := *client
wrapped.Transport = &cookieTransport{base: client.Transport, cookie: cookieHeader}
return &wrapped
}
func setBearer(req *http.Request, accessToken string) {
if accessToken != "" {
req.Header.Set("Authorization", "Bearer "+accessToken)
}
}
func newRequestID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "00000000-0000-0000-0000-000000000000"
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
func joinAndGetDetails(client *http.Client, accessToken, roomID, displayName string) (string, string, string, string, error) {
var err error
if roomID == "" {
roomID, err = CreateRoom(client, accessToken)
if err != nil {
return "", "", "", "", fmt.Errorf("create room: %w", err)
}
}
if err := JoinRoom(client, accessToken, roomID); err != nil {
return "", "", "", "", fmt.Errorf("join room: %w", err)
}
roomToken, serverURL, err := GetConnectionDetails(client, accessToken, roomID, displayName)
if err != nil {
return "", "", "", "", fmt.Errorf("get connection details: %w", err)
}
return roomID, roomToken, accessToken, serverURL, nil
}

View File

@@ -0,0 +1,185 @@
package wbstream
import (
"context"
"fmt"
"net/http"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/transport/call/common"
"github.com/sagernet/sing-box/transport/call/tunnel"
"github.com/sagernet/sing/common/logger"
N "github.com/sagernet/sing/common/network"
)
func ConnectCreator(ctx context.Context, cookieStr, roomID, mode string, readBuf int, dialer N.Dialer, logger logger.ContextLogger) (*tunnel.RelayBridge, string, error) {
deviceID := common.CookieValue(cookieStr, "__wb_device_id")
if deviceID == "" {
return nil, "", fmt.Errorf("wbstream: cookies missing __wb_device_id")
}
cookieHeader := common.FilterCookies(cookieStr, WBStreamCookieAllowlist)
httpClient := common.HttpClient(dialer)
bearer, err := RefreshAccessToken(httpClient, cookieHeader, deviceID)
if err != nil {
return nil, "", fmt.Errorf("wbstream: slide-v3 refresh: %w", err)
}
requestedRoom := ParseRoomID(roomID)
resolvedRoomID, roomToken, accessToken, serverURL, err := AuthAsLoggedIn(httpClient, cookieHeader, bearer, requestedRoom, "Creator")
if err != nil {
return nil, "", fmt.Errorf("wbstream: auth: %w", err)
}
if readBuf <= 0 {
readBuf = 32768
}
if mode == "" {
mode = TunnelModeDC
}
obf, err := tunnel.NewTunnelObfuscator(tunnel.DeriveSecretFromJoinLink(resolvedRoomID))
if err != nil {
return nil, "", fmt.Errorf("wbstream: obfuscator init: %w", err)
}
joinSession := func(token, access, server string) (*Session, <-chan tunnel.DataTunnel) {
tunCh := make(chan tunnel.DataTunnel, 1)
sess := NewSession(SessionConfig{
RoomToken: token,
ServerURL: server,
DisplayName: "Creator",
TunnelMode: mode,
Obfuscator: obf,
Logger: logger,
Dialer: dialer,
RoomID: resolvedRoomID,
AccessToken: access,
ReadBuf: readBuf,
})
sess.OnConnected = func(tun tunnel.DataTunnel) {
select {
case tunCh <- tun:
default:
}
}
return sess, tunCh
}
sess, tunCh := joinSession(roomToken, accessToken, serverURL)
if err := sess.Start(); err != nil {
return nil, "", fmt.Errorf("wbstream: session start: %w", err)
}
var firstTun tunnel.DataTunnel
select {
case firstTun = <-tunCh:
case <-ctx.Done():
sess.Close()
return nil, "", ctx.Err()
case <-time.After(60 * time.Second):
sess.Close()
return nil, "", fmt.Errorf("wbstream: creator tunnel timed out")
}
relay := tunnel.NewRelayBridge(firstTun, "creator", bridgeReadBufFor(firstTun, readBuf), dialer, logger)
go creatorReconnectLoop(ctx, relay, sess, joinSession, httpClient, cookieHeader, deviceID, resolvedRoomID, readBuf, logger)
return relay, APIBase + "/room/" + resolvedRoomID, nil
}
func ConnectJoiner(ctx context.Context, roomID, displayName, mode string, readBuf int, dialer N.Dialer, dnsRouter adapter.DNSRouter, logger logger.ContextLogger) (tunnel.DataTunnel, error) {
roomID = ParseRoomID(roomID)
if displayName == "" {
displayName = "Joiner"
}
if mode == "" {
mode = TunnelModeDC
}
joiner := NewWBStreamJoiner(logger, dialer, dnsRouter, nil)
tunCh := make(chan tunnel.DataTunnel, 1)
joiner.OnConnected = func(tun tunnel.DataTunnel) {
select {
case tunCh <- tun:
default:
}
}
params := fmt.Sprintf(`{"roomId":%q,"displayName":%q,"tunnelMode":%q}`, roomID, displayName, mode)
go joiner.RunWithParams(params)
select {
case tun := <-tunCh:
return tun, nil
case <-ctx.Done():
joiner.Close()
return nil, ctx.Err()
}
}
func creatorReconnectLoop(
ctx context.Context,
relay *tunnel.RelayBridge,
sess *Session,
joinSession func(token, access, server string) (*Session, <-chan tunnel.DataTunnel),
httpClient *http.Client,
cookieHeader, deviceID, roomID string,
readBuf int,
logger logger.ContextLogger,
) {
current := sess
for {
select {
case <-current.Done():
case <-ctx.Done():
return
}
current.Close()
if relay.IsClosed() {
return
}
logger.Debug("wbstream: creator session ended, rejoining")
var newTun tunnel.DataTunnel
for {
select {
case <-ctx.Done():
return
case <-time.After(3 * time.Second):
}
if relay.IsClosed() {
return
}
bearer, err := RefreshAccessToken(httpClient, cookieHeader, deviceID)
if err != nil {
logger.Warn(fmt.Sprintf("wbstream: rejoin token refresh failed: %v, retrying", err))
continue
}
_, roomToken, accessToken, serverURL, err := AuthAsLoggedIn(httpClient, cookieHeader, bearer, roomID, "Creator")
if err != nil {
logger.Warn(fmt.Sprintf("wbstream: rejoin auth failed: %v, retrying", err))
continue
}
newSess, tunCh := joinSession(roomToken, accessToken, serverURL)
if err := newSess.Start(); err != nil {
logger.Warn(fmt.Sprintf("wbstream: rejoin session start failed: %v, retrying", err))
continue
}
select {
case newTun = <-tunCh:
case <-ctx.Done():
newSess.Close()
return
case <-time.After(60 * time.Second):
logger.Warn("wbstream: rejoin tunnel timed out, retrying")
newSess.Close()
continue
}
current = newSess
break
}
relay.SwapTunnel(newTun)
logger.Info(fmt.Sprintf("wbstream: creator tunnel reconnected, buf=%d", bridgeReadBufFor(newTun, readBuf)))
}
}
func bridgeReadBufFor(tun tunnel.DataTunnel, readBuf int) int {
switch tun.(type) {
case *tunnel.DCTunnel, *tunnel.MultiTrackKCPTunnel:
return readBuf
}
return common.VP8BufSize
}

View File

@@ -0,0 +1,53 @@
package wbstream
import (
"github.com/pion/datachannel"
"github.com/sagernet/sing-box/transport/call/livekit"
)
type dataPacketWrapper struct {
inner datachannel.ReadWriteCloser
kind int
}
func (w *dataPacketWrapper) ReadDataChannel(p []byte) (int, bool, error) {
buf := make([]byte, len(p))
for {
n, isString, err := w.inner.ReadDataChannel(buf)
if err != nil {
return 0, false, err
}
if n == 0 {
continue
}
payload, ok := livekit.DecodeDataPacketUser(buf[:n])
if !ok || len(payload) == 0 {
continue
}
copied := copy(p, payload)
return copied, isString, nil
}
}
func (w *dataPacketWrapper) WriteDataChannel(p []byte, isString bool) (int, error) {
wire := livekit.EncodeDataPacketUser(p, w.kind)
if _, err := w.inner.WriteDataChannel(wire, isString); err != nil {
return 0, err
}
return len(p), nil
}
func (w *dataPacketWrapper) Read(p []byte) (int, error) {
n, _, err := w.ReadDataChannel(p)
return n, err
}
func (w *dataPacketWrapper) Write(p []byte) (int, error) {
return w.WriteDataChannel(p, false)
}
func (w *dataPacketWrapper) Close() error { return w.inner.Close() }
func newDataPacketWrapper(inner datachannel.ReadWriteCloser, kind int) *dataPacketWrapper {
return &dataPacketWrapper{inner: inner, kind: kind}
}

View File

@@ -0,0 +1,217 @@
package wbstream
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/pion/webrtc/v4"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/transport/call/common"
"github.com/sagernet/sing-box/transport/call/tunnel"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
)
const (
reconnectInitialDelay = time.Second
reconnectMaxDelay = 16 * time.Second
)
type WBStreamJoiner struct {
logger logger.ContextLogger
OnConnected func(tunnel.DataTunnel)
dialer N.Dialer
dnsRouter adapter.DNSRouter
PCConfig common.PeerConnectionConfigurer
mu sync.Mutex
session *Session
closed bool
stopCh chan struct{}
stopOnce sync.Once
}
func NewWBStreamJoiner(logger logger.ContextLogger, dialer N.Dialer, dnsRouter adapter.DNSRouter, pcConfig common.PeerConnectionConfigurer) *WBStreamJoiner {
return &WBStreamJoiner{
logger: logger,
dialer: dialer,
dnsRouter: dnsRouter,
PCConfig: pcConfig,
stopCh: make(chan struct{}),
}
}
func (j *WBStreamJoiner) RunWithParams(jsonParams string) {
var params struct {
RoomID string `json:"roomId"`
DisplayName string `json:"displayName"`
TunnelMode string `json:"tunnelMode"`
VP8FPS int `json:"vp8Fps"`
VP8Batch int `json:"vp8Batch"`
DualTrack bool `json:"dualTrack"`
Reliable *bool `json:"reliable"`
}
if err := json.Unmarshal([]byte(jsonParams), &params); err != nil {
j.logger.Error(fmt.Sprintf("wbstream-joiner: failed to parse params: %v", err))
return
}
if params.RoomID == "" {
j.logger.Error("wbstream-joiner: missing roomId")
return
}
if params.DisplayName == "" {
params.DisplayName = "Joiner"
}
reliable := params.Reliable != nil && *params.Reliable
httpClient := j.makeHTTPClient()
j.logger.Info(fmt.Sprintf("wbstream-joiner: room=%s name=%s vp8Fps=%d vp8Batch=%d dualTrack=%v", params.RoomID, params.DisplayName, params.VP8FPS, params.VP8Batch, params.DualTrack))
obf, err := tunnel.NewTunnelObfuscator(tunnel.DeriveSecretFromJoinLink(params.RoomID))
if err != nil {
j.logger.Error(fmt.Sprintf("wbstream-joiner: obfuscator init failed: %v", err))
return
}
j.logger.Debug(fmt.Sprintf("wbstream-joiner: obf key-source=%q localEpoch=0x%08x", params.RoomID, obf.LocalEpoch()))
var settingEngine *webrtc.SettingEngine
if j.PCConfig != nil {
se := webrtc.SettingEngine{}
j.PCConfig.ConfigureSettingEngine(&se)
settingEngine = &se
}
var attempt atomic.Int32
j.logger.Info("wbstream-joiner: connecting")
if err := j.runOnce(httpClient, params.RoomID, params.DisplayName, params.TunnelMode, obf, settingEngine, params.VP8FPS, params.VP8Batch, params.DualTrack, reliable, &attempt); err != nil {
j.logger.Error(fmt.Sprintf("wbstream-joiner: %v", err))
return
}
for {
if j.isClosed() {
j.logger.Info("wbstream-joiner: stopped")
return
}
j.logger.Info("wbstream-joiner: tunnel lost")
if !j.waitBeforeRetry(int(attempt.Load())) {
return
}
attempt.Add(1)
if j.isClosed() {
return
}
j.logger.Info(fmt.Sprintf("wbstream-joiner: reconnect attempt #%d", attempt.Load()))
if err := j.runOnce(httpClient, params.RoomID, params.DisplayName, params.TunnelMode, obf, settingEngine, params.VP8FPS, params.VP8Batch, params.DualTrack, reliable, &attempt); err != nil {
j.logger.Warn(fmt.Sprintf("wbstream-joiner: %v, will retry", err))
}
}
}
func (j *WBStreamJoiner) MarkConfigAcked() {
j.mu.Lock()
sess := j.session
j.mu.Unlock()
if sess != nil {
sess.MarkConfigAcked()
}
}
func (j *WBStreamJoiner) Close() {
j.stopOnce.Do(func() { close(j.stopCh) })
j.mu.Lock()
j.closed = true
sess := j.session
j.session = nil
j.mu.Unlock()
if sess != nil {
sess.Close()
}
}
func (j *WBStreamJoiner) runOnce(httpClient *http.Client, roomID, displayName, tunnelMode string, obf *tunnel.TunnelObfuscator, settingEngine *webrtc.SettingEngine, vp8FPS, vp8Batch int, dualTrack, reliable bool, attempt *atomic.Int32) error {
_, roomToken, _, serverURL, authErr := AuthAndGetToken(httpClient, roomID, displayName)
if authErr != nil {
return fmt.Errorf("auth: %w", authErr)
}
j.logger.Debug(fmt.Sprintf("wbstream-joiner: server=%s", serverURL))
sess := NewSession(SessionConfig{
RoomToken: roomToken,
ServerURL: serverURL,
DisplayName: displayName,
TunnelMode: tunnelMode,
Obfuscator: obf,
Logger: j.logger,
SettingEngine: settingEngine,
Dialer: j.dialer,
DNSRouter: j.dnsRouter,
VP8FPS: vp8FPS,
VP8Batch: vp8Batch,
ScreenShare: dualTrack,
IsJoiner: true,
Reliable: reliable,
})
sess.OnConnected = func(tun tunnel.DataTunnel) {
attempt.Store(0)
j.logger.Info("wbstream-joiner: === TUNNEL CONNECTED ===")
if j.OnConnected != nil {
j.OnConnected(tun)
}
}
j.mu.Lock()
if j.closed {
j.mu.Unlock()
sess.Close()
return nil
}
j.session = sess
j.mu.Unlock()
if err := sess.Start(); err != nil {
j.clearSession(sess)
return fmt.Errorf("session: %w", err)
}
<-sess.Done()
sess.Close()
j.clearSession(sess)
return nil
}
func (j *WBStreamJoiner) waitBeforeRetry(attempt int) bool {
delay := common.BackoffWithJitter(attempt, reconnectInitialDelay, reconnectMaxDelay)
j.logger.Debug(fmt.Sprintf("wbstream-joiner: waiting %s before reconnect", delay))
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-timer.C:
return !j.isClosed()
case <-j.stopCh:
return false
}
}
func (j *WBStreamJoiner) clearSession(sess *Session) {
j.mu.Lock()
if j.session == sess {
j.session = nil
}
j.mu.Unlock()
}
func (j *WBStreamJoiner) isClosed() bool {
j.mu.Lock()
defer j.mu.Unlock()
return j.closed
}
func (j *WBStreamJoiner) makeDialContext() func(ctx context.Context, network, addr string) (net.Conn, error) {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
return j.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
}
}
func (j *WBStreamJoiner) makeHTTPClient() *http.Client {
transport := &http.Transport{DialContext: j.makeDialContext()}
return &http.Client{Timeout: 60 * time.Second, Transport: transport}
}

View File

@@ -0,0 +1,738 @@
package wbstream
import (
"context"
"fmt"
"net"
"sync"
"time"
"github.com/google/uuid"
"github.com/pion/rtp"
"github.com/pion/rtp/codecs"
"github.com/pion/webrtc/v4"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/transport/call/common"
"github.com/sagernet/sing-box/transport/call/livekit"
"github.com/sagernet/sing-box/transport/call/tunnel"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
)
type peerEntry struct {
sid string
identity string
firstSeen time.Time
state int32
promoted bool
}
const (
TunnelModeVideo = "video"
TunnelModeDC = "dc"
)
type SessionConfig struct {
RoomToken string
ServerURL string
DisplayName string
TunnelMode string
Obfuscator *tunnel.TunnelObfuscator
Logger logger.ContextLogger
SettingEngine *webrtc.SettingEngine
Dialer N.Dialer
DNSRouter adapter.DNSRouter
VP8FPS int
VP8Batch int
RoomID string
AccessToken string
ReadBuf int
ScreenShare bool
IsJoiner bool
Reliable bool
}
type Session struct {
cfg SessionConfig
lk *livekit.Client
sampleTracks []*webrtc.TrackLocalStaticSample
sampleTransceivers []*webrtc.RTPTransceiver
pubReliableDC *webrtc.DataChannel
pubReliableDCReady bool
subReliableDC *webrtc.DataChannel
vp8tun *tunnel.MultiTrackTunnel
kcptun *tunnel.MultiTrackKCPTunnel
dctun *tunnel.DCTunnel
mu sync.Mutex
tunFired bool
done chan struct{}
peersBySID map[string]peerEntry
kickedSIDs map[string]bool
configAcked chan struct{}
configAckedOnce sync.Once
OnConnected func(tunnel.DataTunnel)
OnPeerRestart func()
OnRemoteCandidate func(target int, candidateOrSDP string)
}
func NewSession(cfg SessionConfig) *Session {
return &Session{
cfg: cfg,
done: make(chan struct{}),
configAcked: make(chan struct{}),
}
}
func (s *Session) MarkConfigAcked() {
s.configAckedOnce.Do(func() {
s.cfg.Logger.Debug("[lk] peer acked vp8 config")
close(s.configAcked)
})
}
func (s *Session) Done() <-chan struct{} { return s.done }
func (s *Session) Start() error {
s.lk = livekit.NewClient(livekit.Config{
ServerURL: s.cfg.ServerURL,
Token: s.cfg.RoomToken,
Origin: Origin,
UserAgent: common.UserAgent,
Logger: s.cfg.Logger,
SettingEngine: s.cfg.SettingEngine,
NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return s.cfg.Dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
},
DNSRouter: s.cfg.DNSRouter,
})
s.lk.OnReady = s.onLKReady
s.lk.OnTrack = s.onRemoteTrack
s.lk.OnDataChannel = s.onRemoteDataChannel
s.lk.OnPubConnected = s.startTunnel
if s.cfg.AccessToken != "" && s.cfg.RoomID != "" {
s.lk.OnParticipantUpdate = s.onParticipantUpdate
}
s.lk.OnRemoteCandidate = func(target int, ic webrtc.ICECandidateInit) {
if s.OnRemoteCandidate != nil {
s.OnRemoteCandidate(target, ic.Candidate)
}
}
s.lk.OnRemoteSDP = func(target int, _, sdp string) {
if s.OnRemoteCandidate != nil {
s.OnRemoteCandidate(-1, sdp)
}
}
if err := s.lk.Connect(); err != nil {
return err
}
go s.lk.PingLoop()
go func() {
if err := s.lk.ReadLoop(); err != nil {
s.cfg.Logger.Debug(fmt.Sprintf("[lk] read loop ended: %v", err))
}
s.stopTunnels()
close(s.done)
}()
return nil
}
func (s *Session) AdaptTrackCount(peerCount int) {
if peerCount < 1 {
return
}
pubPC := s.lk.PubPC()
if pubPC == nil {
return
}
s.mu.Lock()
current := len(s.sampleTracks)
s.mu.Unlock()
if peerCount == current {
s.cfg.Logger.Debug(fmt.Sprintf("[lk] adapt-track-count: peer=%d current=%d, no change", peerCount, current))
return
}
if peerCount > current {
s.cfg.Logger.Debug(fmt.Sprintf("[lk] adapt-track-count: scaling publisher tracks %d -> %d", current, peerCount))
for i := current; i < peerCount; i++ {
if !s.addPublisherTrack(pubPC, i) {
return
}
}
} else {
s.cfg.Logger.Debug(fmt.Sprintf("[lk] adapt-track-count: shrinking publisher tracks %d -> %d", current, peerCount))
for i := current; i > peerCount; i-- {
if !s.removePublisherTrack() {
return
}
}
}
offer, err := pubPC.CreateOffer(nil)
if err != nil {
s.cfg.Logger.Error(fmt.Sprintf("[lk] adapt-track-count: create offer: %v", err))
return
}
if err := pubPC.SetLocalDescription(offer); err != nil {
s.cfg.Logger.Error(fmt.Sprintf("[lk] adapt-track-count: set local offer: %v", err))
return
}
if err := s.lk.SendOffer(offer.SDP); err != nil {
s.cfg.Logger.Error(fmt.Sprintf("[lk] adapt-track-count: send offer: %v", err))
return
}
s.cfg.Logger.Debug(fmt.Sprintf("[lk] adapt-track-count: renegotiation offer sent (%d bytes)", len(offer.SDP)))
}
func (s *Session) Close() {
if s.lk != nil {
s.lk.Close()
}
}
func (s *Session) stopTunnels() {
s.mu.Lock()
vp8 := s.vp8tun
kcptun := s.kcptun
s.mu.Unlock()
if kcptun != nil {
kcptun.Stop()
}
if vp8 != nil {
vp8.Stop()
}
}
func (s *Session) onLKReady() {
pubPC := s.lk.PubPC()
if pubPC == nil {
return
}
camID := "videochannel-" + uuid.New().String()
trackCam, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: 90000},
camID, "tunnel-video-"+uuid.New().String(),
)
if err != nil {
s.cfg.Logger.Error(fmt.Sprintf("[lk] create local cam track: %v", err))
return
}
tracks := []*webrtc.TrackLocalStaticSample{trackCam}
if s.cfg.ScreenShare {
screenID := "screenchannel-" + uuid.New().String()
trackScreen, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: 90000},
screenID, "tunnel-screen-"+uuid.New().String(),
)
if err != nil {
s.cfg.Logger.Error(fmt.Sprintf("[lk] create local screen track: %v", err))
return
}
tracks = append(tracks, trackScreen)
}
transceivers := make([]*webrtc.RTPTransceiver, 0, len(tracks))
for _, t := range tracks {
trx, err := pubPC.AddTransceiverFromTrack(t,
webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly})
if err != nil {
s.cfg.Logger.Error(fmt.Sprintf("[lk] add transceiver: %v", err))
return
}
transceivers = append(transceivers, trx)
}
s.mu.Lock()
s.sampleTracks = tracks
s.sampleTransceivers = transceivers
s.mu.Unlock()
ordered := true
dc, err := pubPC.CreateDataChannel("_reliable", &webrtc.DataChannelInit{
Ordered: &ordered,
})
if err != nil {
s.cfg.Logger.Error(fmt.Sprintf("[lk] create reliable DC: %v", err))
return
}
s.mu.Lock()
s.pubReliableDC = dc
s.mu.Unlock()
dc.OnOpen(func() {
s.cfg.Logger.Debug("[lk] reliable DC open")
s.mu.Lock()
s.pubReliableDCReady = true
s.mu.Unlock()
s.maybeStartDCTunnel()
})
for i, t := range tracks {
source := livekit.TrackSourceCamera
if i > 0 {
source = livekit.TrackSourceScreenShare
}
if err := s.lk.SendAddTrack(t.ID(), "videochannel",
livekit.TrackTypeVideo, source, 1280, 720); err != nil {
s.cfg.Logger.Error(fmt.Sprintf("[lk] send add-track: %v", err))
return
}
}
offer, err := pubPC.CreateOffer(nil)
if err != nil {
s.cfg.Logger.Error(fmt.Sprintf("[lk] create offer: %v", err))
return
}
if err := pubPC.SetLocalDescription(offer); err != nil {
s.cfg.Logger.Error(fmt.Sprintf("[lk] set local offer: %v", err))
return
}
if err := s.lk.SendOffer(offer.SDP); err != nil {
s.cfg.Logger.Error(fmt.Sprintf("[lk] send offer: %v", err))
return
}
s.cfg.Logger.Debug(fmt.Sprintf("[lk] sent publisher offer (%d bytes)", len(offer.SDP)))
}
func (s *Session) startTunnel() {
s.mu.Lock()
if s.vp8tun != nil || len(s.sampleTracks) == 0 {
s.mu.Unlock()
return
}
subs := make([]*tunnel.VP8DataTunnel, 0, len(s.sampleTracks))
for _, t := range s.sampleTracks {
subs = append(subs, tunnel.NewVP8DataTunnelWithQueue(t, s.cfg.Obfuscator, s.cfg.Logger, tunnel.KCPCarrierQueueDepth))
}
s.vp8tun = tunnel.NewMultiTrackTunnel(subs)
s.vp8tun.SetOnPeerRestart(func() {
s.cfg.Logger.Debug("[wb] peer epoch changed, signalling peer-restart")
s.rearmAutoDetect()
if s.OnPeerRestart != nil {
s.OnPeerRestart()
}
})
s.vp8tun.Start(s.cfg.VP8FPS, s.cfg.VP8Batch)
tun := s.vp8tun
s.mu.Unlock()
s.cfg.Logger.Debug(fmt.Sprintf("[lk] vp8 tunnel writer started tracks=%d", len(subs)))
var active tunnel.DataTunnel = tun
if s.cfg.TunnelMode == TunnelModeVideo && s.cfg.Reliable {
active = s.maybeWrapReliable(tun)
}
if s.cfg.IsJoiner && s.cfg.TunnelMode != TunnelModeDC {
go s.configPingPong(active, len(subs))
}
if s.cfg.TunnelMode == TunnelModeVideo {
s.fireOnConnected(active)
return
}
if s.cfg.TunnelMode == "" {
tun.SetOnData(func(payload []byte) { s.activate(tun, payload) })
}
}
func (s *Session) configPingPong(tun tunnel.DataTunnel, trackCount int) {
frame := tunnel.EncodeVP8Config(s.cfg.VP8FPS, s.cfg.VP8Batch, trackCount)
tun.SendData(frame)
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
select {
case <-s.configAcked:
return
case <-s.done:
return
case <-ticker.C:
s.cfg.Logger.Debug("[lk] resending vp8 config (no ack yet)")
tun.SendData(tunnel.EncodeVP8Config(s.cfg.VP8FPS, s.cfg.VP8Batch, trackCount))
}
}
}
func (s *Session) maybeStartDCTunnel() {
s.mu.Lock()
if s.dctun != nil {
s.mu.Unlock()
return
}
pubDC := s.pubReliableDC
subDC := s.subReliableDC
pubReady := s.pubReliableDCReady
s.mu.Unlock()
if pubDC == nil || subDC == nil || !pubReady {
return
}
if subDC.ReadyState() != webrtc.DataChannelStateOpen {
return
}
subRaw, err := subDC.Detach()
if err != nil {
s.cfg.Logger.Error(fmt.Sprintf("[lk] detach sub DC: %v", err))
return
}
pubRaw, err := pubDC.Detach()
if err != nil {
s.cfg.Logger.Error(fmt.Sprintf("[lk] detach pub DC: %v", err))
return
}
readWrapped := newDataPacketWrapper(subRaw, livekit.DataPacketKindReliable)
writeWrapped := newDataPacketWrapper(pubRaw, livekit.DataPacketKindReliable)
readBuf := s.cfg.ReadBuf
if readBuf == 0 {
readBuf = common.DCBufSize
}
dctun := tunnel.NewChunkedDCTunnelFromRaw(readWrapped, writeWrapped, s.cfg.Obfuscator, readBuf, s.cfg.Logger)
if dctun == nil {
return
}
s.mu.Lock()
s.dctun = dctun
s.mu.Unlock()
s.cfg.Logger.Debug("[lk] dc tunnel ready (pub+sub _reliable)")
if s.cfg.TunnelMode == TunnelModeDC {
s.fireOnConnected(dctun)
return
}
if s.cfg.TunnelMode == "" {
dctun.SetOnData(func(payload []byte) { s.activate(dctun, payload) })
}
}
func (s *Session) fireOnConnected(tun tunnel.DataTunnel) {
s.mu.Lock()
if s.tunFired {
s.mu.Unlock()
return
}
s.tunFired = true
s.mu.Unlock()
if s.OnConnected != nil {
s.OnConnected(tun)
}
}
func (s *Session) activate(tun tunnel.DataTunnel, payload []byte) {
s.mu.Lock()
if s.tunFired {
s.mu.Unlock()
return
}
s.tunFired = true
s.mu.Unlock()
var delivered tunnel.DataTunnel = tun
useKCP := false
if _, ok := tun.(*tunnel.MultiTrackTunnel); ok && !tunnel.LooksLikeRelayFrame(payload) {
delivered = s.maybeWrapReliable(tun)
useKCP = true
}
s.cfg.Logger.Debug(fmt.Sprintf("[lk] auto-detected active tunnel: %T", delivered))
if s.OnConnected != nil {
s.OnConnected(delivered)
}
switch v := tun.(type) {
case *tunnel.DCTunnel:
if fwd := v.OnData(); fwd != nil {
fwd(payload)
}
case *tunnel.MultiTrackTunnel:
if useKCP {
if kcptun, ok := delivered.(*tunnel.MultiTrackKCPTunnel); ok {
kcptun.InjectSegment(payload)
}
} else {
v.DeliverData(payload)
}
}
}
func (s *Session) maybeWrapReliable(tun tunnel.DataTunnel) tunnel.DataTunnel {
vp8, ok := tun.(*tunnel.MultiTrackTunnel)
if !ok {
return tun
}
wrapped := tunnel.NewMultiTrackKCPTunnel(vp8, s.cfg.Logger)
s.mu.Lock()
if s.kcptun != nil {
s.kcptun.StopLayer()
}
s.kcptun = wrapped
s.mu.Unlock()
s.cfg.Logger.Debug("[lk] per-track kcp reliability active over video tunnel")
return wrapped
}
func (s *Session) currentVP8Tun() *tunnel.MultiTrackTunnel {
s.mu.Lock()
defer s.mu.Unlock()
return s.vp8tun
}
func (s *Session) removePublisherTrack() bool {
s.mu.Lock()
if len(s.sampleTransceivers) <= 1 || len(s.sampleTracks) <= 1 {
s.mu.Unlock()
s.cfg.Logger.Debug("[lk] adapt-track-count: refusing to remove cam slot")
return false
}
last := len(s.sampleTransceivers) - 1
trx := s.sampleTransceivers[last]
s.sampleTransceivers = s.sampleTransceivers[:last]
s.sampleTracks = s.sampleTracks[:last]
vp8 := s.vp8tun
kcptun := s.kcptun
s.mu.Unlock()
if kcptun != nil {
kcptun.RemoveLastSession()
}
if vp8 != nil {
vp8.RemoveLastSubTunnel()
}
if err := trx.Stop(); err != nil {
s.cfg.Logger.Error(fmt.Sprintf("[lk] adapt-track-count: stop transceiver: %v", err))
return false
}
return true
}
func (s *Session) addPublisherTrack(pubPC *webrtc.PeerConnection, slot int) bool {
labelPrefix := "screenchannel-"
streamPrefix := "tunnel-screen-"
source := livekit.TrackSourceScreenShare
if slot == 0 {
labelPrefix = "videochannel-"
streamPrefix = "tunnel-video-"
source = livekit.TrackSourceCamera
}
track, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: 90000},
labelPrefix+uuid.New().String(), streamPrefix+uuid.New().String(),
)
if err != nil {
s.cfg.Logger.Error(fmt.Sprintf("[lk] adapt-track-count: new track slot=%d: %v", slot, err))
return false
}
trx, err := pubPC.AddTransceiverFromTrack(track,
webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly})
if err != nil {
s.cfg.Logger.Error(fmt.Sprintf("[lk] adapt-track-count: add transceiver slot=%d: %v", slot, err))
return false
}
if err := s.lk.SendAddTrack(track.ID(), "videochannel",
livekit.TrackTypeVideo, source, 1280, 720); err != nil {
s.cfg.Logger.Error(fmt.Sprintf("[lk] adapt-track-count: send add-track slot=%d: %v", slot, err))
return false
}
s.mu.Lock()
s.sampleTracks = append(s.sampleTracks, track)
s.sampleTransceivers = append(s.sampleTransceivers, trx)
vp8 := s.vp8tun
kcptun := s.kcptun
s.mu.Unlock()
if vp8 != nil {
newSub := tunnel.NewVP8DataTunnelWithQueue(track, s.cfg.Obfuscator, s.cfg.Logger, tunnel.KCPCarrierQueueDepth)
vp8.AddSubTunnel(newSub)
if kcptun != nil {
kcptun.AddSession(newSub)
}
}
return true
}
func (s *Session) rearmAutoDetect() {
if s.cfg.TunnelMode != "" {
return
}
s.mu.Lock()
s.tunFired = false
orphanKCP := s.kcptun
s.kcptun = nil
vp8 := s.vp8tun
dc := s.dctun
s.mu.Unlock()
if orphanKCP != nil {
orphanKCP.StopLayer()
}
if vp8 != nil {
vp8.SetOnData(func(payload []byte) { s.activate(vp8, payload) })
}
if dc != nil {
dc.SetOnData(func(payload []byte) { s.activate(dc, payload) })
}
}
func (s *Session) onRemoteTrack(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
if track.Codec().MimeType != webrtc.MimeTypeVP8 {
go func() {
buf := make([]byte, common.UDPBufSize)
for {
if _, _, err := track.Read(buf); err != nil {
return
}
}
}()
return
}
go s.readVP8Track(track)
}
func (s *Session) readVP8Track(track *webrtc.TrackRemote) {
var vp8Pkt codecs.VP8Packet
var pkt rtp.Packet
var frameBuf []byte
var lastSeq uint16
var haveLastSeq bool
frameValid := false
var recvCount int
buf := make([]byte, common.RTPBufSize)
for {
n, _, err := track.Read(buf)
if err != nil {
return
}
if pkt.Unmarshal(buf[:n]) != nil {
continue
}
if haveLastSeq && pkt.SequenceNumber != lastSeq+1 {
frameValid = false
frameBuf = frameBuf[:0]
}
lastSeq = pkt.SequenceNumber
haveLastSeq = true
vp8Payload, err := vp8Pkt.Unmarshal(pkt.Payload)
if err != nil {
frameValid = false
frameBuf = frameBuf[:0]
continue
}
if vp8Pkt.S == 1 {
frameBuf = frameBuf[:0]
frameValid = true
}
if !frameValid {
continue
}
frameBuf = append(frameBuf, vp8Payload...)
if !pkt.Marker {
continue
}
recvCount++
if recvCount <= 3 || recvCount%200 == 0 {
s.cfg.Logger.Debug(fmt.Sprintf("[lk-video] recv vp8 frame #%d %d bytes", recvCount, len(frameBuf)))
}
tun := s.currentVP8Tun()
if tun != nil {
tun.HandleFrame(frameBuf)
}
frameBuf = frameBuf[:0]
frameValid = false
}
}
func (s *Session) onRemoteDataChannel(dc *webrtc.DataChannel) {
s.cfg.Logger.Debug(fmt.Sprintf("[lk] remote DC label=%s id=%v", dc.Label(), dc.ID()))
if dc.Label() != "_reliable" {
return
}
s.mu.Lock()
s.subReliableDC = dc
s.mu.Unlock()
dc.OnOpen(func() {
s.cfg.Logger.Debug("[lk] remote _reliable DC open")
s.maybeStartDCTunnel()
})
}
func (s *Session) onParticipantUpdate(updates []livekit.ParticipantInfo) {
selfSID := s.lk.Join().ParticipantSID
s.mu.Lock()
if s.peersBySID == nil {
s.peersBySID = make(map[string]peerEntry)
}
newcomerSIDs := make(map[string]bool)
canPromote := s.cfg.AccessToken != "" && s.cfg.RoomID != ""
for _, p := range updates {
if p.SID == "" || p.SID == selfSID {
continue
}
if p.State == livekit.ParticipantStateDisconnected {
delete(s.peersBySID, p.SID)
delete(s.kickedSIDs, p.SID)
continue
}
if s.kickedSIDs[p.SID] {
continue
}
entry, ok := s.peersBySID[p.SID]
if !ok {
entry = peerEntry{sid: p.SID, identity: p.Identity, firstSeen: time.Now()}
newcomerSIDs[p.SID] = true
}
if p.Identity != "" {
entry.identity = p.Identity
}
entry.state = p.State
s.peersBySID[p.SID] = entry
}
var stale []peerEntry
staleSIDs := make(map[string]bool)
if len(newcomerSIDs) > 0 {
for _, e := range s.peersBySID {
if e.state == livekit.ParticipantStateActive && !newcomerSIDs[e.sid] {
stale = append(stale, e)
staleSIDs[e.sid] = true
}
}
}
var toPromote []peerEntry
if canPromote {
for sid, entry := range s.peersBySID {
if staleSIDs[sid] {
continue
}
if !entry.promoted && entry.state == livekit.ParticipantStateActive && entry.identity != "" {
entry.promoted = true
s.peersBySID[sid] = entry
toPromote = append(toPromote, entry)
}
}
}
s.mu.Unlock()
for _, e := range toPromote {
go s.promotePeer(e.sid, e.identity)
}
if len(stale) == 0 {
return
}
for _, e := range stale {
if e.identity == "" {
continue
}
if err := KickParticipant(common.HttpClient(s.cfg.Dialer), s.cfg.AccessToken, s.cfg.RoomID, e.identity); err != nil {
s.cfg.Logger.Warn(fmt.Sprintf("[wb] kick failed identity=%s: %v", e.identity, err))
continue
}
s.cfg.Logger.Info(fmt.Sprintf("[wb] kicked stale peer identity=%s sid=%s", e.identity, e.sid))
s.mu.Lock()
delete(s.peersBySID, e.sid)
if s.kickedSIDs == nil {
s.kickedSIDs = make(map[string]bool)
}
s.kickedSIDs[e.sid] = true
s.mu.Unlock()
}
}
func (s *Session) promotePeer(sid, identity string) {
if err := SetParticipantPermissions(common.HttpClient(s.cfg.Dialer), s.cfg.AccessToken, s.cfg.RoomID, identity, ModeratorPermissions); err != nil {
s.cfg.Logger.Warn(fmt.Sprintf("[wb] promote failed identity=%s: %v", identity, err))
s.mu.Lock()
if entry, ok := s.peersBySID[sid]; ok {
entry.promoted = false
s.peersBySID[sid] = entry
}
s.mu.Unlock()
return
}
s.cfg.Logger.Info(fmt.Sprintf("[wb] promoted to moderator identity=%s sid=%s", identity, sid))
}