mirror of
https://github.com/shtorm-7/sing-box-extended.git
synced 2026-08-08 11:35:16 +03:00
Add call protocol, Rmux. Update AmneziaWG. Fixes and improvements
This commit is contained in:
363
transport/call/wbstream/api.go
Normal file
363
transport/call/wbstream/api.go
Normal 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
|
||||
}
|
||||
185
transport/call/wbstream/connect.go
Normal file
185
transport/call/wbstream/connect.go
Normal 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
|
||||
}
|
||||
53
transport/call/wbstream/dc_packet.go
Normal file
53
transport/call/wbstream/dc_packet.go
Normal 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}
|
||||
}
|
||||
217
transport/call/wbstream/joiner.go
Normal file
217
transport/call/wbstream/joiner.go
Normal 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), ¶ms); 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}
|
||||
}
|
||||
738
transport/call/wbstream/session.go
Normal file
738
transport/call/wbstream/session.go
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user