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

650
transport/call/dion/api.go Normal file
View File

@@ -0,0 +1,650 @@
package dion
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/sagernet/sing-box/transport/call/common"
N "github.com/sagernet/sing/common/network"
)
var ErrSessionExpired = errors.New("dion: session expired, re-login required")
var errLoginEndpointMissing = errors.New("dion: login endpoint not available")
const (
accessCookieName = "vc-access-token"
refreshCookieName = "vc-refresh-token"
loginClientsPath = "/v2/users/login/web"
loginPlatformPath = "/platform/v2/auth/auth-providers/dion/login/password"
)
const (
refreshSkewSeconds = 60
refreshMaxAttempts = 3
refreshBaseDelay = 2 * time.Second
refreshDelayMultiply = 1.75
)
const (
APIBase = "https://api.dion.vc"
APIClientsBase = "https://api-clients.dion.vc"
WebBase = "https://dion.vc"
Origin = "https://dion.vc"
CookieDomain = "dion.vc"
)
type GuestUser struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Initials string `json:"initials"`
Position string `json:"position"`
AvatarHTTPPath string `json:"avatar_http_path"`
IsProfileFilledIn bool `json:"is_profile_filled_in"`
Roles []string `json:"roles"`
}
type GuestAuthResponse struct {
AccessToken string `json:"access_token"`
AuthProvider string `json:"auth_provider"`
IsAuthBySSO bool `json:"is_auth_by_sso"`
User GuestUser `json:"user"`
}
type LoginResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
AuthProvider string `json:"auth_provider"`
IsAuthBySSO bool `json:"is_auth_by_sso"`
User GuestUser `json:"user"`
}
type EventInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
OrgID string `json:"org_id"`
Admins []string `json:"admins"`
PSTN struct {
Number string `json:"number"`
Pin int `json:"pin"`
Prefix string `json:"prefix"`
} `json:"pstn"`
}
type WSSConnectResponse struct {
Host string `json:"host"`
Path string `json:"path"`
Schema string `json:"schema"`
URL string `json:"url"`
Params map[string]string `json:"params"`
}
type Session struct {
HTTPClient *http.Client
Device DeviceProfile
AccessToken string
AccessTokenExp time.Time
UserID string
SessionID string
cookiesPath string
email string
password string
refreshMu sync.Mutex
}
type AuthResult struct {
Session *Session
Event *EventInfo
WSS *WSSConnectResponse
SessionID string
}
func NewSession(dialer N.Dialer) (*Session, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, fmt.Errorf("cookiejar: %w", err)
}
httpClient := common.HttpClient(dialer)
httpClient.Jar = jar
return &Session{HTTPClient: httpClient, Device: RandomDeviceProfile()}, nil
}
func (s *Session) RegisterGuest() (*GuestAuthResponse, error) {
auth, err := s.callRefreshOnce()
if err != nil {
return nil, err
}
s.applyRefreshResult(auth)
return auth, nil
}
func (s *Session) RegisterAnonymousGuest(eventID, displayName string) (*GuestAuthResponse, error) {
if eventID == "" {
return nil, fmt.Errorf("empty event_id")
}
if displayName == "" {
displayName = "Guest"
}
body, _ := json.Marshal(map[string]any{
"event_id": eventID,
"name": displayName,
})
req, err := http.NewRequest(http.MethodPost, APIBase+"/platform/v1/users/register/guest", bytes.NewReader(body))
if err != nil {
return nil, err
}
s.setBaseHeaders(req, "")
req.Header.Set("Content-Type", "application/json")
resp, err := s.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("register/guest: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("register/guest: status %d: %s", resp.StatusCode, string(raw))
}
var auth GuestAuthResponse
if err := json.Unmarshal(raw, &auth); err != nil {
return nil, fmt.Errorf("register/guest decode: %w", err)
}
if auth.AccessToken == "" {
return nil, fmt.Errorf("register/guest: empty access_token: %s", string(raw))
}
s.applyRefreshResult(&auth)
return &auth, nil
}
// SetCredentials stores an email/password pair used by refreshLocked to
// re-authenticate when the refresh cookie is missing or rejected.
func (s *Session) SetCredentials(email, password string) {
s.email = strings.TrimSpace(email)
s.password = password
}
// LoginWithPassword exchanges credentials for a fresh token pair. The web
// front-end posts to api-clients, and switches to the platform endpoint when
// the DION_PLATFORM_COOKIE_AUTH_ENABLED toggle is on, so both are tried.
func (s *Session) LoginWithPassword(email, password string) error {
if email == "" || password == "" {
return fmt.Errorf("login: email and password are required")
}
body, _ := json.Marshal(map[string]string{"email": email, "password": password})
login, err := s.postLogin(APIClientsBase+loginClientsPath, body)
if errors.Is(err, errLoginEndpointMissing) {
login, err = s.postLogin(APIBase+loginPlatformPath, body)
}
if err != nil {
return err
}
s.email = email
s.password = password
s.applyLoginResult(login)
return nil
}
func (s *Session) postLogin(target string, body []byte) (*LoginResponse, error) {
req, err := http.NewRequest(http.MethodPost, target, bytes.NewReader(body))
if err != nil {
return nil, err
}
s.setBaseHeaders(req, "")
req.Header.Set("Content-Type", "application/json")
resp, err := s.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("login: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusMethodNotAllowed {
return nil, errLoginEndpointMissing
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("login: status %d: %s", resp.StatusCode, string(raw))
}
var login LoginResponse
if err := json.Unmarshal(raw, &login); err != nil {
return nil, fmt.Errorf("login decode: %w", err)
}
if login.AccessToken == "" {
return nil, fmt.Errorf("login: empty access_token: %s", string(raw))
}
return &login, nil
}
func (s *Session) applyLoginResult(login *LoginResponse) {
s.AccessToken = login.AccessToken
s.UserID = login.User.ID
if exp, err := parseJWTExpiry(login.AccessToken); err == nil {
s.AccessTokenExp = exp
}
if login.RefreshToken != "" {
s.SetCookieInJar(refreshCookieName, login.RefreshToken)
}
s.SetCookieInJar(accessCookieName, login.AccessToken)
}
func (s *Session) Refresh() error {
s.refreshMu.Lock()
defer s.refreshMu.Unlock()
return s.refreshLocked()
}
func (s *Session) EnsureValidToken() error {
s.refreshMu.Lock()
defer s.refreshMu.Unlock()
if s.AccessToken != "" && !s.AccessTokenExp.IsZero() &&
time.Until(s.AccessTokenExp) > time.Duration(refreshSkewSeconds)*time.Second {
return nil
}
return s.refreshLocked()
}
func (s *Session) DoAuthenticated(buildRequest func() (*http.Request, error)) (*http.Response, error) {
if err := s.EnsureValidToken(); err != nil {
return nil, err
}
req, err := buildRequest()
if err != nil {
return nil, err
}
s.setBaseHeaders(req, s.AccessToken)
resp, err := s.HTTPClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusUnauthorized {
return resp, nil
}
staleToken := s.AccessToken
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
s.refreshMu.Lock()
if s.AccessToken == staleToken {
if err := s.refreshLocked(); err != nil {
s.refreshMu.Unlock()
return nil, err
}
}
s.refreshMu.Unlock()
retryReq, err := buildRequest()
if err != nil {
return nil, err
}
s.setBaseHeaders(retryReq, s.AccessToken)
return s.HTTPClient.Do(retryReq)
}
func (s *Session) WhoAmI() (json.RawMessage, error) {
resp, err := s.DoAuthenticated(func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, APIBase+"/platform/v1/whoami", nil)
})
if err != nil {
return nil, fmt.Errorf("whoami: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("whoami: status %d: %s", resp.StatusCode, string(raw))
}
return raw, nil
}
func (s *Session) GetEventBySlug(slug string) (*EventInfo, error) {
if slug == "" {
return nil, fmt.Errorf("empty room ID")
}
eventURL := fmt.Sprintf("%s/conference/v1/events/slug/%s", APIBase, slug)
resp, err := s.DoAuthenticated(func() (*http.Request, error) {
req, err := http.NewRequest(http.MethodGet, eventURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
return req, nil
})
if err != nil {
return nil, fmt.Errorf("get event: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("get event: status %d: %s", resp.StatusCode, string(raw))
}
var event EventInfo
if err := json.Unmarshal(raw, &event); err != nil {
return nil, fmt.Errorf("get event decode: %w", err)
}
if event.ID == "" {
return nil, fmt.Errorf("get event: empty id: %s", string(raw))
}
return &event, nil
}
func (s *Session) GenerateSlug() (string, error) {
resp, err := s.DoAuthenticated(func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, APIClientsBase+"/v2/events/slug/generate", nil)
})
if err != nil {
return "", fmt.Errorf("generate room ID: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("generate room ID: status %d: %s", resp.StatusCode, string(raw))
}
var out struct {
Slug string `json:"slug"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", fmt.Errorf("generate room ID decode: %w", err)
}
if out.Slug == "" {
return "", fmt.Errorf("generate room ID: empty: %s", string(raw))
}
return out.Slug, nil
}
type CreateEventOptions struct {
Slug string
EventParams []string
IsImpersonalSlug bool
IsOnCloud bool
}
func (s *Session) CreateEvent(opts CreateEventOptions) (*EventInfo, error) {
if opts.Slug == "" {
return nil, fmt.Errorf("empty room ID")
}
if opts.EventParams == nil {
opts.EventParams = []string{"guest_access"}
}
body, _ := json.Marshal(map[string]any{
"event_params": opts.EventParams,
"is_impersonal_slug": opts.IsImpersonalSlug,
"is_on_cloud": opts.IsOnCloud,
"slug": opts.Slug,
})
resp, err := s.DoAuthenticated(func() (*http.Request, error) {
req, err := http.NewRequest(http.MethodPost, APIBase+"/conference/v1/events", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
return req, nil
})
if err != nil {
return nil, fmt.Errorf("create event: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("create event: status %d: %s", resp.StatusCode, string(raw))
}
var event EventInfo
if err := json.Unmarshal(raw, &event); err != nil {
return nil, fmt.Errorf("create event decode: %w", err)
}
if event.ID == "" {
return nil, fmt.Errorf("create event: empty id: %s", string(raw))
}
return &event, nil
}
func (s *Session) CreateRoom() (*EventInfo, error) {
slug, err := s.GenerateSlug()
if err != nil {
return nil, err
}
return s.CreateEvent(CreateEventOptions{
Slug: slug,
EventParams: []string{"guest_access"},
IsImpersonalSlug: true,
IsOnCloud: true,
})
}
func (s *Session) ConnectWSS(sessionID string) (*WSSConnectResponse, error) {
if sessionID == "" {
sessionID = uuid.New().String()
}
body, _ := json.Marshal(map[string]string{"session_id": sessionID})
resp, err := s.DoAuthenticated(func() (*http.Request, error) {
req, err := http.NewRequest(http.MethodPost, APIBase+"/conference/v1/connect/wss", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
return req, nil
})
if err != nil {
return nil, fmt.Errorf("connect/wss: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("connect/wss: status %d: %s", resp.StatusCode, string(raw))
}
var wss WSSConnectResponse
if err := json.Unmarshal(raw, &wss); err != nil {
return nil, fmt.Errorf("connect/wss decode: %w", err)
}
if wss.URL == "" {
return nil, fmt.Errorf("connect/wss: empty url: %s", string(raw))
}
s.SessionID = sessionID
return &wss, nil
}
func (s *Session) LookupEventBySlugAnonymous(slug string) (*EventInfo, error) {
if slug == "" {
return nil, fmt.Errorf("empty room ID")
}
eventURL := fmt.Sprintf("%s/conference/v1/events/slug/%s", APIBase, slug)
req, err := http.NewRequest(http.MethodGet, eventURL, nil)
if err != nil {
return nil, err
}
s.setBaseHeaders(req, "")
req.Header.Set("Content-Type", "application/json")
resp, err := s.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("get event anon: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("get event anon: status %d: %s", resp.StatusCode, string(raw))
}
var event EventInfo
if err := json.Unmarshal(raw, &event); err != nil {
return nil, fmt.Errorf("get event anon decode: %w", err)
}
if event.ID == "" {
return nil, fmt.Errorf("get event anon: empty id: %s", string(raw))
}
return &event, nil
}
func JoinAsGuest(dialer N.Dialer, slug, displayName string) (*Session, *EventInfo, error) {
session, err := NewSession(dialer)
if err != nil {
return nil, nil, err
}
if err := session.PrimeCookies(slug); err != nil {
return nil, nil, fmt.Errorf("prime cookies: %w", err)
}
event, err := session.LookupEventBySlugAnonymous(slug)
if err != nil {
return nil, nil, err
}
if _, err := session.RegisterAnonymousGuest(event.ID, displayName); err != nil {
return nil, nil, fmt.Errorf("RegisterAnonymousGuest: %w", err)
}
return session, event, nil
}
func AuthAndGetTicket(dialer N.Dialer, slug string) (*AuthResult, error) {
session, err := NewSession(dialer)
if err != nil {
return nil, err
}
if err := session.PrimeCookies(slug); err != nil {
return nil, err
}
if _, err := session.RegisterGuest(); err != nil {
return nil, err
}
if _, err := session.WhoAmI(); err != nil {
return nil, fmt.Errorf("whoami after guest auth: %w", err)
}
event, err := session.GetEventBySlug(slug)
if err != nil {
return nil, err
}
sessionID := uuid.New().String()
wss, err := session.ConnectWSS(sessionID)
if err != nil {
return nil, err
}
return &AuthResult{
Session: session,
Event: event,
WSS: wss,
SessionID: sessionID,
}, nil
}
func ParseRoom(input string) string {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return ""
}
trimmed = strings.TrimPrefix(trimmed, "dion://")
trimmed = strings.TrimPrefix(trimmed, "https://")
trimmed = strings.TrimPrefix(trimmed, "http://")
trimmed = strings.TrimPrefix(trimmed, "dion.vc/")
trimmed = strings.TrimPrefix(trimmed, "event/")
if idx := strings.Index(trimmed, "?"); idx >= 0 {
trimmed = trimmed[:idx]
}
if idx := strings.Index(trimmed, "/"); idx >= 0 {
trimmed = trimmed[:idx]
}
return trimmed
}
func (s *Session) setBaseHeaders(req *http.Request, accessToken string) {
req.Header.Set("User-Agent", s.Device.UserAgent)
req.Header.Set("Origin", Origin)
req.Header.Set("Referer", Origin+"/")
req.Header.Set("Accept", "*/*")
req.Header.Set("Accept-Language", "en")
req.Header.Set("X-Request-Id", uuid.New().String())
for name, value := range s.Device.Headers() {
req.Header.Set(name, value)
}
if accessToken != "" {
req.Header.Set("Authorization", "Bearer "+accessToken)
}
}
func (s *Session) callRefreshOnce() (*GuestAuthResponse, error) {
req, err := http.NewRequest(http.MethodPost, APIBase+"/platform/v1/auth/refresh/web", bytes.NewReader(nil))
if err != nil {
return nil, err
}
s.setBaseHeaders(req, "")
req.Header.Set("Content-Length", "0")
resp, err := s.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("auth/refresh/web: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
return nil, fmt.Errorf("%w: status %d: %s", ErrSessionExpired, resp.StatusCode, string(raw))
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("auth/refresh/web: status %d: %s", resp.StatusCode, string(raw))
}
var auth GuestAuthResponse
if err := json.Unmarshal(raw, &auth); err != nil {
return nil, fmt.Errorf("auth/refresh/web decode: %w", err)
}
if auth.AccessToken == "" {
return nil, fmt.Errorf("auth/refresh/web: empty access_token: %s", string(raw))
}
return &auth, nil
}
func (s *Session) applyRefreshResult(auth *GuestAuthResponse) {
s.AccessToken = auth.AccessToken
s.UserID = auth.User.ID
if exp, err := parseJWTExpiry(auth.AccessToken); err == nil {
s.AccessTokenExp = exp
}
s.SetCookieInJar(accessCookieName, auth.AccessToken)
}
func (s *Session) refreshLocked() error {
if !s.HasRefreshCookie() && s.HasCredentials() {
return s.LoginWithPassword(s.email, s.password)
}
var lastErr error
delay := refreshBaseDelay
for attempt := 1; attempt <= refreshMaxAttempts; attempt++ {
auth, err := s.callRefreshOnce()
if err == nil {
s.applyRefreshResult(auth)
return nil
}
if errors.Is(err, ErrSessionExpired) {
if s.HasCredentials() {
return s.LoginWithPassword(s.email, s.password)
}
return err
}
lastErr = err
if attempt < refreshMaxAttempts {
time.Sleep(delay)
delay = time.Duration(float64(delay) * refreshDelayMultiply)
}
}
return fmt.Errorf("refresh failed after %d attempts: %w", refreshMaxAttempts, lastErr)
}
func parseJWTExpiry(token string) (time.Time, error) {
parts := strings.Split(token, ".")
if len(parts) < 2 {
return time.Time{}, fmt.Errorf("invalid JWT")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return time.Time{}, fmt.Errorf("decode payload: %w", err)
}
var claims struct {
Exp int64 `json:"exp"`
}
if err := json.Unmarshal(payload, &claims); err != nil {
return time.Time{}, fmt.Errorf("parse claims: %w", err)
}
if claims.Exp == 0 {
return time.Time{}, fmt.Errorf("no exp claim")
}
return time.Unix(claims.Exp, 0), nil
}

732
transport/call/dion/call.go Normal file
View File

@@ -0,0 +1,732 @@
package dion
import (
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
"github.com/pion/rtp/codecs"
"github.com/pion/webrtc/v4"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing/common/logger"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing-box/transport/call/tunnel"
)
const (
sendVideoMidIndex = 12
sendScreenShareMidIndex = 13
recvScreenShareMidStr = "14"
defaultRecvVideoMid = "1"
recvVideoMidCount = 9
creatorVP8FPS = 24
creatorVP8Batch = 10
joinerVP8FPS = 24
joinerVP8Batch = 5
)
type Role string
const (
RoleCreator Role = "creator"
RoleJoiner Role = "joiner"
)
type CallConfig struct {
Auth *Session
Event *EventInfo
Obfuscator *tunnel.TunnelObfuscator
DisplayName string
Logger logger.ContextLogger
RecvMid string
Role Role
SettingEngine *webrtc.SettingEngine
Dialer N.Dialer
DNSRouter adapter.DNSRouter
}
type PeerEntry struct {
SessionID string
UserID string
Name string
CamState bool
JoinedAt time.Time
}
type Call struct {
cfg CallConfig
signaling *SignalingClient
peer *PionPeer
sendTrack *webrtc.TrackLocalStaticSample
vp8tun *tunnel.VP8DataTunnel
mySessionID string
peersMu sync.Mutex
peersByID map[string]*PeerEntry
subscribed map[string]bool
peerToMid map[string]string
freeMids []string
pendingSubs []string
onConnectedFired atomic.Bool
OnConnected func(tunnel.DataTunnel)
OnPeerRestart func()
OnRemoteSDP func(sdp string)
done chan struct{}
closeOnce sync.Once
}
func NewCall(cfg CallConfig) *Call {
if cfg.Logger == nil {
cfg.Logger = logger.NOP()
}
if cfg.Role == "" {
cfg.Role = RoleCreator
}
if cfg.RecvMid == "" {
cfg.RecvMid = defaultRecvVideoMid
}
var freeMids []string
if cfg.Role == RoleJoiner {
freeMids = []string{recvScreenShareMidStr}
} else {
freeMids = make([]string, 0, recvVideoMidCount)
for midIndex := 1; midIndex < recvVideoMidCount; midIndex++ {
freeMids = append(freeMids, fmt.Sprintf("%d", midIndex))
}
freeMids = append(freeMids, "0")
}
return &Call{
cfg: cfg,
peersByID: make(map[string]*PeerEntry),
subscribed: make(map[string]bool),
peerToMid: make(map[string]string),
freeMids: freeMids,
done: make(chan struct{}),
}
}
func (c *Call) Done() <-chan struct{} { return c.done }
func (c *Call) SessionID() string { return c.mySessionID }
func (c *Call) Start() error {
sessionID := uuid.New().String()
c.mySessionID = sessionID
c.cfg.Logger.Debug(fmt.Sprintf("[call] my session_id=%s", sessionID))
wss, err := c.cfg.Auth.ConnectWSS(sessionID)
if err != nil {
return fmt.Errorf("ConnectWSS: %w", err)
}
signaling, err := DialSignaling(wss.URL, SignalingDialOptions{
UserAgent: c.cfg.Auth.Device.UserAgent,
Logger: c.cfg.Logger,
Dialer: c.cfg.Dialer,
})
if err != nil {
return fmt.Errorf("DialSignaling: %w", err)
}
c.signaling = signaling
if err := signaling.WaitConnected(15 * time.Second); err != nil {
return fmt.Errorf("WaitConnected: %w", err)
}
youJoinedChan := make(chan YouJoinedParams, 1)
sdpAnswerChan := make(chan SDPAnswerParams, 4)
var onceYouJoined sync.Once
signaling.OnYouJoined = func(params YouJoinedParams) {
onceYouJoined.Do(func() { youJoinedChan <- params })
}
signaling.OnSDPAnswer = func(answerSDP string, transceivers []TransceiverDesc) {
select {
case sdpAnswerChan <- SDPAnswerParams{Answer: answerSDP, Transceivers: transceivers}:
default:
}
}
signaling.OnSpeakerJoined = c.handleSpeakerJoined
signaling.OnSpeakerDisconnected = c.handleSpeakerDisconnected
signaling.OnSpeakerCamStateChanged = c.handleSpeakerCamStateChanged
signaling.OnConfSpeakersState = c.handleConfSpeakersState
signaling.OnGetVideoFromUserResponse = c.handleGetVideoFromUserResponse
signaling.OnGetScreenSharingFromUserResponse = c.handleGetScreenSharingFromUserResponse
readLoopDone := make(chan error, 1)
go func() { readLoopDone <- signaling.ReadLoop() }()
if err := signaling.Subscribe(c.cfg.Event.ID, sessionID); err != nil {
return fmt.Errorf("Subscribe: %w", err)
}
var youJoined YouJoinedParams
select {
case youJoined = <-youJoinedChan:
case err := <-readLoopDone:
return fmt.Errorf("read loop ended before you_joined: %v", err)
case <-time.After(15 * time.Second):
return fmt.Errorf("timeout waiting for you_joined")
}
c.cfg.Logger.Debug(fmt.Sprintf("[call] you_joined ice_servers=%d", len(youJoined.IceServers)))
pionAPI := NewPionAPI(c.cfg.SettingEngine)
iceServers := ResolveICEServerHosts(youJoined.IceServers, c.cfg.DNSRouter, c.cfg.Dialer, c.cfg.Logger)
peer, err := BuildPionPeer(pionAPI, iceServers)
if err != nil {
return fmt.Errorf("BuildPionPeer: %w", err)
}
c.peer = peer
sendMidIndex := sendVideoMidIndex
trackLabel := "dion-tunnel-" + sessionID
if c.cfg.Role == RoleCreator {
sendMidIndex = sendScreenShareMidIndex
trackLabel = "dion-tunnel-screen-" + sessionID
}
track, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: 90000},
"video", trackLabel,
)
if err != nil {
return fmt.Errorf("NewTrackLocalStaticSample: %w", err)
}
c.sendTrack = track
if len(peer.Transceivers) <= sendMidIndex {
return fmt.Errorf("transceiver layout short, have %d", len(peer.Transceivers))
}
sender := peer.Transceivers[sendMidIndex].Sender()
if sender == nil {
return fmt.Errorf("mid=%d sender nil", sendMidIndex)
}
if err := sender.ReplaceTrack(track); err != nil {
return fmt.Errorf("ReplaceTrack: %w", err)
}
c.cfg.Logger.Debug(fmt.Sprintf("[call] role=%s attached send track to mid=%d", c.cfg.Role, sendMidIndex))
peer.PC.OnTrack(func(remoteTrack *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
c.cfg.Logger.Debug(fmt.Sprintf("[call] OnTrack id=%q kind=%s codec=%s ssrc=%d",
remoteTrack.ID(), remoteTrack.Kind().String(), remoteTrack.Codec().MimeType, remoteTrack.SSRC()))
if remoteTrack.Codec().MimeType != webrtc.MimeTypeVP8 {
go drainTrack(remoteTrack)
return
}
go c.readVP8Track(remoteTrack)
})
var pendingMu sync.Mutex
pendingCandidates := make([]webrtc.ICECandidateInit, 0, 32)
remoteSet := false
sendCandidate := func(cand webrtc.ICECandidateInit) {
entry := ICECandidateJSON{Candidate: cand.Candidate}
if cand.SDPMid != nil {
m := *cand.SDPMid
entry.SDPMid = &m
}
if cand.SDPMLineIndex != nil {
i := *cand.SDPMLineIndex
entry.SDPMLineIndex = &i
}
if cand.UsernameFragment != nil {
entry.UsernameFragment = *cand.UsernameFragment
}
if err := signaling.SendICECandidates([]ICECandidateJSON{entry}); err != nil {
c.cfg.Logger.Warn(fmt.Sprintf("[ice] SendICECandidates: %v", err))
}
}
flushPending := func() {
pendingMu.Lock()
toFlush := pendingCandidates
pendingCandidates = nil
pendingMu.Unlock()
for _, cand := range toFlush {
sendCandidate(cand)
}
}
peer.PC.OnICECandidate(func(cand *webrtc.ICECandidate) {
if cand == nil {
return
}
init := cand.ToJSON()
pendingMu.Lock()
alreadyRemote := remoteSet
if !alreadyRemote {
pendingCandidates = append(pendingCandidates, init)
}
pendingMu.Unlock()
if alreadyRemote {
sendCandidate(init)
}
})
iceConnected := make(chan struct{}, 1)
iceDead := make(chan webrtc.ICEConnectionState, 1)
peer.PC.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
c.cfg.Logger.Debug(fmt.Sprintf("[ice] state=%s", state.String()))
switch state {
case webrtc.ICEConnectionStateConnected, webrtc.ICEConnectionStateCompleted:
select {
case iceConnected <- struct{}{}:
default:
}
case webrtc.ICEConnectionStateFailed, webrtc.ICEConnectionStateClosed:
select {
case iceDead <- state:
default:
}
}
})
envelope, _, err := peer.CreateAndSetOffer()
if err != nil {
return fmt.Errorf("CreateAndSetOffer: %w", err)
}
offerParams := SDPOfferParams{
MicState: false,
CamState: false,
NoiseSuppressionState: true,
ScreenSharingQuality: "default",
Datachannels: peer.DatachannelDescs,
Transceivers: peer.TransceiverDescs,
Offer: envelope,
}
if err := signaling.SendSDPOffer(offerParams); err != nil {
return fmt.Errorf("SendSDPOffer: %w", err)
}
var answer SDPAnswerParams
select {
case answer = <-sdpAnswerChan:
case err := <-readLoopDone:
return fmt.Errorf("read loop ended before sdp_answer: %v", err)
case <-time.After(20 * time.Second):
return fmt.Errorf("timeout waiting for sdp_answer")
}
if c.OnRemoteSDP != nil {
c.OnRemoteSDP(answer.Answer)
}
if err := peer.PC.SetRemoteDescription(webrtc.SessionDescription{
Type: webrtc.SDPTypeAnswer,
SDP: answer.Answer,
}); err != nil {
return fmt.Errorf("SetRemoteDescription: %w", err)
}
pendingMu.Lock()
remoteSet = true
pendingMu.Unlock()
flushPending()
select {
case <-iceConnected:
case state := <-iceDead:
return fmt.Errorf("ICE died before connected: %s", state.String())
case err := <-readLoopDone:
return fmt.Errorf("read loop ended before ICE connected: %v", err)
case <-time.After(30 * time.Second):
return fmt.Errorf("timeout waiting for ICE connected; state=%s", peer.PC.ICEConnectionState().String())
}
c.cfg.Logger.Debug("[ice] connected")
fps, batch := joinerVP8FPS, joinerVP8Batch
if c.cfg.Role == RoleCreator {
fps, batch = creatorVP8FPS, creatorVP8Batch
}
c.vp8tun = tunnel.NewVP8DataTunnel(c.sendTrack, c.cfg.Obfuscator, c.cfg.Logger)
c.vp8tun.Start(fps, batch)
c.fireOnConnected(c.vp8tun)
if c.cfg.Role == RoleCreator {
if err := signaling.SendScreenSharingSwitchOn(); err != nil {
c.cfg.Logger.Warn(fmt.Sprintf("[call] SendScreenSharingSwitchOn: %v", err))
} else {
c.cfg.Logger.Debug("[call] sent screensharing_switch_on")
}
if err := signaling.SendScreensharingQualityChange("good"); err != nil {
c.cfg.Logger.Warn(fmt.Sprintf("[call] SendScreensharingQualityChange: %v", err))
} else {
c.cfg.Logger.Debug("[call] sent screensharing_quality_change=good")
}
} else {
if err := signaling.SendCamStateChange(true); err != nil {
c.cfg.Logger.Warn(fmt.Sprintf("[call] SendCamStateChange: %v", err))
} else {
c.cfg.Logger.Debug("[call] sent cam_state_change=true")
}
}
go c.discoverPeersAndSubscribe()
go c.runStatReporter()
go func() {
defer close(c.done)
select {
case state := <-iceDead:
c.cfg.Logger.Debug(fmt.Sprintf("[call] ICE went to %s", state.String()))
case err := <-readLoopDone:
c.cfg.Logger.Debug(fmt.Sprintf("[call] read loop ended: %v", err))
}
}()
return nil
}
func (c *Call) Close() {
c.closeOnce.Do(func() {
if c.vp8tun != nil {
c.vp8tun.Stop()
}
if c.signaling != nil {
c.signaling.Close()
}
if c.peer != nil {
c.peer.Close()
}
})
}
func (c *Call) fireOnConnected(tun tunnel.DataTunnel) {
if !c.onConnectedFired.CompareAndSwap(false, true) {
return
}
if c.OnConnected != nil {
c.OnConnected(tun)
}
}
func (c *Call) handleSpeakerJoined(params SpeakerJoinedParams) {
if params.SessionID == c.mySessionID {
return
}
c.peersMu.Lock()
_, wasKnown := c.peersByID[params.SessionID]
c.peersByID[params.SessionID] = &PeerEntry{
SessionID: params.SessionID,
UserID: params.UserID,
Name: params.Name,
CamState: params.CamState,
JoinedAt: time.Now(),
}
var toKick []string
if !wasKnown {
for id := range c.peersByID {
if id != params.SessionID {
toKick = append(toKick, id)
}
}
}
c.peersMu.Unlock()
c.cfg.Logger.Debug(fmt.Sprintf("[call] speaker_joined session_id=%s name=%q cam=%v", params.SessionID, params.Name, params.CamState))
for _, staleID := range toKick {
if err := c.signaling.SendKickOne(staleID); err != nil {
c.cfg.Logger.Debug(fmt.Sprintf("[call] SendKickOne(%s): %v", staleID, err))
continue
}
c.cfg.Logger.Debug(fmt.Sprintf("[call] kicked stale peer session_id=%s for newcomer=%s", staleID, params.SessionID))
c.peersMu.Lock()
delete(c.peersByID, staleID)
delete(c.subscribed, staleID)
c.releaseMidLocked(staleID)
c.peersMu.Unlock()
}
if len(toKick) > 0 && c.OnPeerRestart != nil {
c.cfg.Logger.Debug(fmt.Sprintf("[call] firing OnPeerRestart from kick path (kicked=%d newcomer=%s)", len(toKick), params.SessionID))
c.OnPeerRestart()
}
if c.cfg.Role == RoleJoiner || params.CamState {
c.subscribeIfNeeded(params.SessionID)
}
}
func (c *Call) handleSpeakerDisconnected(params SpeakerDisconnectedParams) {
c.peersMu.Lock()
delete(c.peersByID, params.SessionID)
delete(c.subscribed, params.SessionID)
c.releaseMidLocked(params.SessionID)
var freshestUnsubscribed string
var freshestAt time.Time
for sid, entry := range c.peersByID {
if c.subscribed[sid] {
continue
}
if entry.JoinedAt.After(freshestAt) {
freshestAt = entry.JoinedAt
freshestUnsubscribed = sid
}
}
hasFreeMid := len(c.freeMids) > 0
c.peersMu.Unlock()
c.cfg.Logger.Debug(fmt.Sprintf("[call] speaker_disconnected session_id=%s", params.SessionID))
if freshestUnsubscribed != "" && hasFreeMid {
c.cfg.Logger.Debug(fmt.Sprintf("[call] claiming freed mid for unsubscribed peer %s", freshestUnsubscribed))
c.subscribeIfNeeded(freshestUnsubscribed)
}
}
func (c *Call) handleSpeakerCamStateChanged(params SpeakerCamStateChangedParams) {
if params.SessionID == c.mySessionID {
return
}
c.peersMu.Lock()
if entry, ok := c.peersByID[params.SessionID]; ok {
entry.CamState = params.CamState
} else {
c.peersByID[params.SessionID] = &PeerEntry{SessionID: params.SessionID, CamState: params.CamState, JoinedAt: time.Now()}
}
c.peersMu.Unlock()
c.cfg.Logger.Debug(fmt.Sprintf("[call] speaker_cam_state_changed session_id=%s cam=%v", params.SessionID, params.CamState))
if params.CamState {
c.subscribeIfNeeded(params.SessionID)
}
}
func (c *Call) handleConfSpeakersState(response ConfSpeakersStateResponse) {
for _, entry := range response.Speakers {
if entry.SessionID == c.mySessionID || entry.SessionID == "" {
continue
}
c.peersMu.Lock()
c.peersByID[entry.SessionID] = &PeerEntry{
SessionID: entry.SessionID,
UserID: entry.UserID,
Name: entry.Name,
CamState: entry.CamState,
JoinedAt: time.Now(),
}
c.peersMu.Unlock()
if c.cfg.Role == RoleJoiner || entry.CamState {
c.subscribeIfNeeded(entry.SessionID)
}
}
}
func (c *Call) discoverPeersAndSubscribe() {
time.Sleep(500 * time.Millisecond)
if err := c.signaling.SendConfSpeakersState(DefaultConfSpeakersStateRequest()); err != nil {
c.cfg.Logger.Debug(fmt.Sprintf("[call] SendConfSpeakersState: %v", err))
}
}
func (c *Call) subscribeIfNeeded(peerSessionID string) {
c.peersMu.Lock()
if c.subscribed[peerSessionID] {
c.peersMu.Unlock()
return
}
entry := c.peersByID[peerSessionID]
if entry == nil {
c.peersMu.Unlock()
return
}
if len(c.freeMids) == 0 {
c.peersMu.Unlock()
c.cfg.Logger.Debug(fmt.Sprintf("[call] no free recv mid for peer %s, ignoring", peerSessionID))
return
}
mid := c.freeMids[0]
c.freeMids = c.freeMids[1:]
c.peerToMid[peerSessionID] = mid
c.subscribed[peerSessionID] = true
c.pendingSubs = append(c.pendingSubs, peerSessionID)
c.peersMu.Unlock()
var sendErr error
if c.cfg.Role == RoleJoiner {
sendErr = c.signaling.SendGetScreenSharingFromUser(GetScreenSharingFromUserRequest{
SessionID: entry.SessionID,
TransceiverID: mid,
UserID: entry.UserID,
})
} else {
sendErr = c.signaling.SendGetVideoFromUser(GetVideoFromUserRequest{
SessionID: entry.SessionID,
TransceiverID: mid,
UserID: entry.UserID,
Username: entry.Name,
})
}
if sendErr != nil {
c.cfg.Logger.Debug(fmt.Sprintf("[call] subscribe to peer %s failed: %v", peerSessionID, sendErr))
c.peersMu.Lock()
delete(c.subscribed, peerSessionID)
delete(c.peerToMid, peerSessionID)
c.freeMids = append(c.freeMids, mid)
if len(c.pendingSubs) > 0 && c.pendingSubs[len(c.pendingSubs)-1] == peerSessionID {
c.pendingSubs = c.pendingSubs[:len(c.pendingSubs)-1]
}
c.peersMu.Unlock()
return
}
c.cfg.Logger.Debug(fmt.Sprintf("[call] subscribed to %s on mid=%s", peerSessionID, mid))
if c.OnPeerRestart != nil {
c.cfg.Logger.Debug(fmt.Sprintf("[call] firing OnPeerRestart from subscribe path (peer=%s)", peerSessionID))
c.OnPeerRestart()
}
}
func (c *Call) handleGetVideoFromUserResponse(resp GetVideoFromUserResponse, errCode int, errMsg string) {
c.handleSubscribeResponse("get_video_from_user", resp.SessionID, resp.TransceiverID, errCode, errMsg)
}
func (c *Call) handleGetScreenSharingFromUserResponse(resp GetScreenSharingFromUserResponse, errCode int, errMsg string) {
c.handleSubscribeResponse("get_screensharing_from_user", resp.SessionID, resp.TransceiverID, errCode, errMsg)
}
func (c *Call) handleSubscribeResponse(rpc, sessionID, transceiverID string, errCode int, errMsg string) {
c.peersMu.Lock()
if sessionID == "" && len(c.pendingSubs) > 0 {
sessionID = c.pendingSubs[0]
c.pendingSubs = c.pendingSubs[1:]
} else if len(c.pendingSubs) > 0 {
for i, pending := range c.pendingSubs {
if pending == sessionID {
c.pendingSubs = append(c.pendingSubs[:i], c.pendingSubs[i+1:]...)
break
}
}
}
if errCode != 0 {
mid := c.peerToMid[sessionID]
delete(c.subscribed, sessionID)
delete(c.peerToMid, sessionID)
if mid != "" {
c.freeMids = append(c.freeMids, mid)
}
c.peersMu.Unlock()
c.cfg.Logger.Debug(fmt.Sprintf("[call] %s FAILED session=%s mid=%s code=%d msg=%q", rpc, sessionID, mid, errCode, errMsg))
return
}
c.peersMu.Unlock()
c.cfg.Logger.Debug(fmt.Sprintf("[call] %s OK session=%s mid=%s", rpc, sessionID, transceiverID))
}
func (c *Call) releaseMidLocked(peerSessionID string) {
if mid, ok := c.peerToMid[peerSessionID]; ok {
delete(c.peerToMid, peerSessionID)
c.freeMids = append(c.freeMids, mid)
}
}
func (c *Call) readVP8Track(track *webrtc.TrackRemote) {
var vp8Pkt codecs.VP8Packet
var frameBuf []byte
var lastSeq uint16
var haveLastSeq bool
frameValid := false
for {
pkt, _, err := track.ReadRTP()
if err != nil {
return
}
if pkt == 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
}
if c.vp8tun != nil {
c.vp8tun.HandleFrame(frameBuf)
}
frameBuf = frameBuf[:0]
frameValid = false
}
}
func (c *Call) runStatReporter() {
select {
case <-c.done:
return
case <-time.After(1500 * time.Millisecond):
}
if err := c.signaling.SendPCIceStat(); err != nil {
c.cfg.Logger.Debug(fmt.Sprintf("[call] SendPCIceStat: %v", err))
} else {
c.cfg.Logger.Debug("[call] sent pc_ice_stat")
}
c.sendStatReport()
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-c.done:
return
case <-ticker.C:
c.sendStatReport()
}
}
}
func (c *Call) sendStatReport() {
report := ClientStatReport{
ReportTimeUnixMS: time.Now().UnixMilli(),
Connection: ClientStatConnection{},
}
report.Audio.In = ClientStatAudioIn{Codec: "opus", IsEnabled: true, Mid: 9}
report.Video.In = c.buildVideoInStats()
outStat := ClientStatVideoOut{
Mid: sendVideoMidIndex,
Codec: "VP8",
IsEnabled: true,
Resolution: ClientStatResolution{Width: 1280, Height: 720},
Framerate: c.vp8tun.FPS(),
ScalabilityMode: "L1T1",
}
report.Video.Out = outStat
report.Video.OutV2 = []ClientStatVideoOut{outStat}
if err := c.signaling.SendClientStatZip(report); err != nil {
c.cfg.Logger.Debug(fmt.Sprintf("[call] SendClientStatZip: %v", err))
}
}
func (c *Call) buildVideoInStats() []ClientStatVideoIn {
c.peersMu.Lock()
defer c.peersMu.Unlock()
out := make([]ClientStatVideoIn, 0, len(c.peerToMid))
for sessionID, midStr := range c.peerToMid {
midInt := 0
fmt.Sscanf(midStr, "%d", &midInt)
out = append(out, ClientStatVideoIn{
Codec: "VP8",
IsEnabled: true,
Mid: midInt,
Resolution: ClientStatResolution{Width: 1280, Height: 720},
Framerate: c.vp8tun.FPS(),
SessionID: sessionID,
})
}
return out
}
func drainTrack(track *webrtc.TrackRemote) {
buf := make([]byte, 1500)
for {
if _, _, err := track.Read(buf); err != nil {
return
}
}
}

View File

@@ -0,0 +1,139 @@
package dion
import (
"context"
"fmt"
"time"
"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, email, password string, readBuf int, dialer N.Dialer, logger logger.ContextLogger) (*tunnel.RelayBridge, string, error) {
auth, err := NewSession(dialer)
if err != nil {
return nil, "", fmt.Errorf("dion: new session: %w", err)
}
if err := auth.LoadCookieString(cookieStr); err != nil {
return nil, "", fmt.Errorf("dion: load cookies: %w", err)
}
auth.SetCredentials(email, password)
if err := auth.EnsureValidToken(); err != nil {
return nil, "", fmt.Errorf("dion: ensure valid token: %w", err)
}
requestedRoom := ParseRoom(roomID)
var event *EventInfo
if requestedRoom != "" {
event, err = auth.GetEventBySlug(requestedRoom)
if err != nil {
return nil, "", fmt.Errorf("dion: get event by slug: %w", err)
}
} else {
event, err = auth.CreateRoom()
if err != nil {
return nil, "", fmt.Errorf("dion: create room: %w", err)
}
}
joinLink := WebBase + "/event/" + event.Slug
if readBuf <= 0 {
readBuf = 32768
}
obf, err := tunnel.NewTunnelObfuscator(tunnel.DeriveSecretFromJoinLink(event.Slug))
if err != nil {
return nil, "", fmt.Errorf("dion: obfuscator init: %w", err)
}
relayCh := make(chan *tunnel.RelayBridge, 1)
var activeRelay *tunnel.RelayBridge
call := NewCall(CallConfig{
Auth: auth,
Event: event,
Obfuscator: obf,
DisplayName: "Creator",
Logger: logger,
Dialer: dialer,
Role: RoleCreator,
})
call.OnConnected = func(tun tunnel.DataTunnel) {
if activeRelay != nil {
activeRelay.Reset()
}
bridgeReadBuf := common.VP8BufSize
if _, ok := tun.(*tunnel.DCTunnel); ok {
bridgeReadBuf = readBuf
}
activeRelay = tunnel.NewRelayBridge(tun, "creator", bridgeReadBuf, dialer, logger)
activeRelay.MarkReady()
select {
case relayCh <- activeRelay:
default:
}
}
call.OnPeerRestart = func() {
if activeRelay != nil {
activeRelay.Reset()
}
}
go func() {
if err := call.Start(); err != nil {
logger.Error(fmt.Sprintf("dion: call start failed: %v", err))
}
}()
select {
case relay := <-relayCh:
return relay, joinLink, nil
case <-ctx.Done():
call.Close()
return nil, "", ctx.Err()
case <-time.After(60 * time.Second):
call.Close()
return nil, "", fmt.Errorf("dion: creator tunnel timed out")
}
}
func ConnectJoiner(ctx context.Context, roomID, displayName string, readBuf int, dialer N.Dialer, logger logger.ContextLogger) (tunnel.DataTunnel, error) {
if displayName == "" {
displayName = "Joiner"
}
slug := ParseRoom(roomID)
if slug == "" {
return nil, fmt.Errorf("dion: missing room")
}
auth, event, err := JoinAsGuest(dialer, slug, displayName)
if err != nil {
return nil, fmt.Errorf("dion: join as guest: %w", err)
}
obf, err := tunnel.NewTunnelObfuscator(tunnel.DeriveSecretFromJoinLink(event.Slug))
if err != nil {
return nil, fmt.Errorf("dion: obfuscator init: %w", err)
}
call := NewCall(CallConfig{
Auth: auth,
Event: event,
Obfuscator: obf,
DisplayName: displayName,
Logger: logger,
Dialer: dialer,
Role: RoleJoiner,
})
tunCh := make(chan tunnel.DataTunnel, 1)
call.OnConnected = func(tun tunnel.DataTunnel) {
select {
case tunCh <- tun:
default:
}
}
go func() {
if err := call.Start(); err != nil {
logger.Error(fmt.Sprintf("dion: call start failed: %v", err))
}
}()
select {
case tun := <-tunCh:
return tun, nil
case <-ctx.Done():
call.Close()
return nil, ctx.Err()
}
}

View File

@@ -0,0 +1,134 @@
package dion
import (
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
)
type CookieEntry struct {
Name string `json:"name"`
Value string `json:"value"`
}
func (s *Session) LoadCookies(entries []CookieEntry) error {
if s.HTTPClient.Jar == nil {
jar, err := cookiejar.New(nil)
if err != nil {
return fmt.Errorf("cookiejar: %w", err)
}
s.HTTPClient.Jar = jar
}
web, err := url.Parse(WebBase)
if err != nil {
return fmt.Errorf("parse target %s: %w", WebBase, err)
}
cookies := make([]*http.Cookie, 0, len(entries))
for _, entry := range entries {
if entry.Name == "" {
continue
}
cookies = append(cookies, &http.Cookie{
Name: entry.Name,
Value: entry.Value,
Path: "/",
Domain: CookieDomain,
})
}
s.HTTPClient.Jar.SetCookies(web, cookies)
s.seedAccessTokenFromCookies(entries)
return nil
}
func (s *Session) LoadCookieString(cookieStr string) error {
cookieStr = strings.TrimSpace(cookieStr)
if cookieStr == "" {
return fmt.Errorf("empty cookie string")
}
var entries []CookieEntry
for _, piece := range strings.Split(cookieStr, ";") {
piece = strings.TrimSpace(piece)
if piece == "" {
continue
}
eq := strings.IndexByte(piece, '=')
if eq <= 0 {
continue
}
entries = append(entries, CookieEntry{Name: piece[:eq], Value: piece[eq+1:]})
}
return s.LoadCookies(entries)
}
func (s *Session) SetCookieInJar(name, value string) {
if s.HTTPClient == nil || s.HTTPClient.Jar == nil {
return
}
web, err := url.Parse(WebBase)
if err != nil {
return
}
s.HTTPClient.Jar.SetCookies(web, []*http.Cookie{
{Name: name, Value: value, Path: "/", Domain: CookieDomain},
})
}
func (s *Session) HasCredentials() bool {
return s.email != "" && s.password != ""
}
func (s *Session) HasRefreshCookie() bool {
if s.HTTPClient == nil || s.HTTPClient.Jar == nil {
return false
}
web, err := url.Parse(WebBase)
if err != nil {
return false
}
for _, c := range s.HTTPClient.Jar.Cookies(web) {
if c.Name == refreshCookieName && c.Value != "" {
return true
}
}
return false
}
func (s *Session) PrimeCookies(slug string) error {
target := WebBase + "/"
if slug != "" {
target = fmt.Sprintf("%s/event/%s?showWeb=true", WebBase, slug)
}
req, err := http.NewRequest(http.MethodGet, target, nil)
if err != nil {
return err
}
s.setBaseHeaders(req, "")
resp, err := s.HTTPClient.Do(req)
if err != nil {
return fmt.Errorf("prime cookies: %w", err)
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("prime cookies: status %d", resp.StatusCode)
}
return nil
}
func (s *Session) seedAccessTokenFromCookies(entries []CookieEntry) {
for _, entry := range entries {
if entry.Name != accessCookieName || entry.Value == "" {
continue
}
exp, err := parseJWTExpiry(entry.Value)
if err != nil {
return
}
s.AccessToken = entry.Value
s.AccessTokenExp = exp
return
}
}

View File

@@ -0,0 +1,185 @@
package dion
import (
"fmt"
"math/rand/v2"
)
type DeviceProfile struct {
UserAgent string
Platform string
BrowserType string
BrowserVersion string
DeviceBrand string
DeviceModel string
DeviceType string
OS string
OSVersion string
ScreenWidth int
ScreenHeight int
}
type deviceTemplate struct {
os string
osVersionPool []string
deviceBrandPool []string
deviceModelPool []string
browsers []browserTemplate
}
type browserTemplate struct {
browserType string
versionPool []string
userAgentFn func(osVersion, browserVersion string) string
}
var commonScreens = [][2]int{
{1280, 720}, {1366, 768}, {1440, 900}, {1536, 864},
{1600, 900}, {1680, 1050}, {1728, 1117}, {1920, 1080},
{2048, 1152}, {2560, 1440}, {2880, 1800}, {3840, 2160},
}
var deviceTemplates = []deviceTemplate{
{
os: "Mac OS",
osVersionPool: []string{"10.15.7", "11.7.10", "12.7.6", "13.6.9", "14.6.1", "15.1.0"},
deviceBrandPool: []string{"Apple"},
deviceModelPool: []string{"Macintosh"},
browsers: []browserTemplate{
{
browserType: "Chrome",
versionPool: []string{"141.0.0.0", "143.0.0.0", "145.0.0.0", "147.0.0.0", "149.0.0.0"},
userAgentFn: func(osVersion, browserVersion string) string {
return fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X %s) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36",
macOSVersionForUA(osVersion), browserVersion)
},
},
{
browserType: "Safari",
versionPool: []string{"17.6", "18.0", "18.1", "18.2"},
userAgentFn: func(osVersion, browserVersion string) string {
return fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X %s) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/%s Safari/605.1.15",
macOSVersionForUA(osVersion), browserVersion)
},
},
{
browserType: "Firefox",
versionPool: []string{"128.0", "131.0", "133.0", "135.0"},
userAgentFn: func(osVersion, browserVersion string) string {
return fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X %s; rv:%s) Gecko/20100101 Firefox/%s",
macOSVersionForUA(osVersion), browserVersion, browserVersion)
},
},
},
},
{
os: "Windows",
osVersionPool: []string{"10", "11"},
deviceBrandPool: []string{"Dell", "Lenovo", "HP", "Asus", "Acer", "MSI"},
deviceModelPool: []string{"PC"},
browsers: []browserTemplate{
{
browserType: "Chrome",
versionPool: []string{"141.0.0.0", "143.0.0.0", "145.0.0.0", "147.0.0.0", "149.0.0.0"},
userAgentFn: func(osVersion, browserVersion string) string {
return fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36",
browserVersion)
},
},
{
browserType: "Edge",
versionPool: []string{"141.0.0.0", "143.0.0.0", "145.0.0.0", "147.0.0.0"},
userAgentFn: func(osVersion, browserVersion string) string {
return fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36 Edg/%s",
browserVersion, browserVersion)
},
},
{
browserType: "Firefox",
versionPool: []string{"128.0", "131.0", "133.0", "135.0"},
userAgentFn: func(osVersion, browserVersion string) string {
return fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:%s) Gecko/20100101 Firefox/%s",
browserVersion, browserVersion)
},
},
},
},
{
os: "Linux",
osVersionPool: []string{"x86_64", "x86_64 GNU"},
deviceBrandPool: []string{"Dell", "Lenovo", "HP", "System76", "Framework"},
deviceModelPool: []string{"PC"},
browsers: []browserTemplate{
{
browserType: "Chrome",
versionPool: []string{"141.0.0.0", "143.0.0.0", "145.0.0.0", "147.0.0.0", "149.0.0.0"},
userAgentFn: func(osVersion, browserVersion string) string {
return fmt.Sprintf("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36",
browserVersion)
},
},
{
browserType: "Firefox",
versionPool: []string{"128.0", "131.0", "133.0", "135.0"},
userAgentFn: func(osVersion, browserVersion string) string {
return fmt.Sprintf("Mozilla/5.0 (X11; Linux x86_64; rv:%s) Gecko/20100101 Firefox/%s",
browserVersion, browserVersion)
},
},
},
},
}
func RandomDeviceProfile() DeviceProfile {
tmpl := deviceTemplates[rand.IntN(len(deviceTemplates))]
browser := tmpl.browsers[rand.IntN(len(tmpl.browsers))]
osVersion := tmpl.osVersionPool[rand.IntN(len(tmpl.osVersionPool))]
browserVersion := browser.versionPool[rand.IntN(len(browser.versionPool))]
screen := commonScreens[rand.IntN(len(commonScreens))]
return DeviceProfile{
UserAgent: browser.userAgentFn(osVersion, browserVersion),
Platform: "web",
BrowserType: browser.browserType,
BrowserVersion: browserVersion,
DeviceBrand: tmpl.deviceBrandPool[rand.IntN(len(tmpl.deviceBrandPool))],
DeviceModel: tmpl.deviceModelPool[rand.IntN(len(tmpl.deviceModelPool))],
DeviceType: "pc",
OS: tmpl.os,
OSVersion: osVersion,
ScreenWidth: screen[0],
ScreenHeight: screen[1],
}
}
func (p DeviceProfile) Headers() map[string]string {
return map[string]string{
"d-platform": p.Platform,
"d-browser-type": p.BrowserType,
"d-browser-version": p.BrowserVersion,
"d-device-brand": p.DeviceBrand,
"d-device-model": p.DeviceModel,
"d-device-type": p.DeviceType,
"d-os": p.OS,
"d-os-version": p.OSVersion,
"d-screen-height": fmt.Sprintf("%d", p.ScreenHeight),
"d-screen-width": fmt.Sprintf("%d", p.ScreenWidth),
}
}
func macOSVersionForUA(osVersion string) string {
switch osVersion {
case "10.15.7":
return "10_15_7"
case "11.7.10":
return "10_15_7"
case "12.7.6":
return "10_15_7"
case "13.6.9":
return "10_15_7"
case "14.6.1":
return "10_15_7"
case "15.1.0":
return "10_15_7"
}
return "10_15_7"
}

View File

@@ -0,0 +1,262 @@
package dion
import (
"context"
"fmt"
"net"
"net/netip"
"strings"
"github.com/pion/webrtc/v4"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/common/dialer"
"github.com/sagernet/sing/common/logger"
N "github.com/sagernet/sing/common/network"
)
type TransceiverPlan struct {
Mid int
Direction webrtc.RTPTransceiverDirection
Kind webrtc.RTPCodecType
Ctype string
}
type DataChannelPlan struct {
ID uint16
Label string
}
var DionTransceiverLayout = []TransceiverPlan{
{Mid: 0, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
{Mid: 1, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
{Mid: 2, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
{Mid: 3, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
{Mid: 4, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
{Mid: 5, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
{Mid: 6, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
{Mid: 7, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
{Mid: 8, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
{Mid: 9, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeAudio, Ctype: "Audio"},
{Mid: 10, Direction: webrtc.RTPTransceiverDirectionSendonly, Kind: webrtc.RTPCodecTypeAudio, Ctype: "Audio"},
{Mid: 11, Direction: webrtc.RTPTransceiverDirectionSendonly, Kind: webrtc.RTPCodecTypeAudio, Ctype: "AudioScreenSharing"},
{Mid: 12, Direction: webrtc.RTPTransceiverDirectionSendonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
{Mid: 13, Direction: webrtc.RTPTransceiverDirectionSendonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "ScreenSharing"},
{Mid: 14, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "ScreenSharing"},
{Mid: 15, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
{Mid: 16, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Padding"},
}
var DionDataChannels = []DataChannelPlan{
{ID: 0, Label: "vad"},
{ID: 1, Label: "stats"},
{ID: 2, Label: "speed"},
{ID: 3, Label: "video_quality"},
{ID: 4, Label: "media_messages"},
}
type PionPeer struct {
PC *webrtc.PeerConnection
Transceivers []*webrtc.RTPTransceiver
DataChannels map[string]*webrtc.DataChannel
TransceiverDescs []TransceiverDesc
DatachannelDescs []DataChannelDesc
}
func NewPionAPI(customEngine ...*webrtc.SettingEngine) *webrtc.API {
mediaEngine := &webrtc.MediaEngine{}
if err := mediaEngine.RegisterDefaultCodecs(); err != nil {
panic(fmt.Errorf("dion: register default codecs: %w", err))
}
engine := webrtc.SettingEngine{}
if len(customEngine) > 0 && customEngine[0] != nil {
engine = *customEngine[0]
}
return webrtc.NewAPI(
webrtc.WithMediaEngine(mediaEngine),
webrtc.WithSettingEngine(engine),
)
}
func ResolveICEServerHosts(entries []ICEServerEntry, dnsRouter adapter.DNSRouter, d N.Dialer, logger logger.ContextLogger) []ICEServerEntry {
if dnsRouter == nil {
return entries
}
resolved := make(map[string]string)
out := make([]ICEServerEntry, 0, len(entries))
for _, entry := range entries {
urls := make([]string, len(entry.URLs))
copy(urls, entry.URLs)
for k, raw := range urls {
host := extractICEHost(raw)
if host == "" {
continue
}
ip, ok := resolved[host]
if !ok {
var addrs []netip.Addr
var err error
addrs, err = dnsRouter.Lookup(context.Background(), host, d.(dialer.ResolveDialer).QueryOptions())
if err != nil {
logger.Warn(fmt.Sprintf("[dion] resolve ICE host %s failed: %v", host, err))
continue
}
ip = addrs[0].String()
resolved[host] = ip
logger.Debug(fmt.Sprintf("[dion] resolved ICE host %s -> %s", host, addrs[0]))
}
urls[k] = strings.Replace(raw, host, ip, 1)
}
out = append(out, ICEServerEntry{URLs: urls, Username: entry.Username, Credential: entry.Credential})
}
return out
}
func IceServerEntriesToWebRTC(entries []ICEServerEntry) []webrtc.ICEServer {
out := make([]webrtc.ICEServer, 0, len(entries))
for _, entry := range entries {
out = append(out, webrtc.ICEServer{
URLs: entry.URLs,
Username: entry.Username,
Credential: entry.Credential,
})
}
return out
}
func BuildPionPeer(api *webrtc.API, iceServers []ICEServerEntry) (*PionPeer, error) {
pc, err := api.NewPeerConnection(webrtc.Configuration{
ICEServers: IceServerEntriesToWebRTC(iceServers),
BundlePolicy: webrtc.BundlePolicyMaxBundle,
RTCPMuxPolicy: webrtc.RTCPMuxPolicyRequire,
})
if err != nil {
return nil, fmt.Errorf("new peer connection: %w", err)
}
transceivers := make([]*webrtc.RTPTransceiver, 0, len(DionTransceiverLayout))
for _, plan := range DionTransceiverLayout {
transceiver, err := pc.AddTransceiverFromKind(plan.Kind, webrtc.RTPTransceiverInit{
Direction: plan.Direction,
})
if err != nil {
pc.Close()
return nil, fmt.Errorf("add transceiver mid=%d kind=%s dir=%s: %w", plan.Mid, plan.Kind, plan.Direction, err)
}
transceivers = append(transceivers, transceiver)
}
dataChannels := make(map[string]*webrtc.DataChannel, len(DionDataChannels))
for _, plan := range DionDataChannels {
negotiated := true
id := plan.ID
dc, err := pc.CreateDataChannel(plan.Label, &webrtc.DataChannelInit{
Negotiated: &negotiated,
ID: &id,
})
if err != nil {
pc.Close()
return nil, fmt.Errorf("create datachannel %s id=%d: %w", plan.Label, plan.ID, err)
}
dataChannels[plan.Label] = dc
}
dcDescs := make([]DataChannelDesc, 0, len(DionDataChannels))
for _, plan := range DionDataChannels {
dcDescs = append(dcDescs, DataChannelDesc{ID: int(plan.ID), Label: plan.Label})
}
return &PionPeer{
PC: pc,
Transceivers: transceivers,
DataChannels: dataChannels,
DatachannelDescs: dcDescs,
}, nil
}
func (p *PionPeer) BuildOfferDescriptors() error {
if len(p.Transceivers) != len(DionTransceiverLayout) {
return fmt.Errorf("transceiver count drift: have %d want %d", len(p.Transceivers), len(DionTransceiverLayout))
}
descs := make([]TransceiverDesc, 0, len(p.Transceivers))
for index, transceiver := range p.Transceivers {
mid := transceiver.Mid()
if mid == "" {
return fmt.Errorf("transceiver index=%d has empty mid; call SetLocalDescription first", index)
}
plan := DionTransceiverLayout[index]
descs = append(descs, TransceiverDesc{
TransceiverID: mid,
SessionID: "",
Direction: directionToDion(plan.Direction),
Ctype: plan.Ctype,
})
}
p.TransceiverDescs = descs
return nil
}
func (p *PionPeer) CreateAndSetOffer() (offerEnvelope string, sdpOffer string, err error) {
offer, err := p.PC.CreateOffer(nil)
if err != nil {
return "", "", fmt.Errorf("create offer: %w", err)
}
if err := p.PC.SetLocalDescription(offer); err != nil {
return "", "", fmt.Errorf("set local description: %w", err)
}
if err := p.BuildOfferDescriptors(); err != nil {
return "", "", err
}
envelope, err := BuildSDPOfferEnvelope(offer.SDP)
if err != nil {
return "", "", fmt.Errorf("build envelope: %w", err)
}
return envelope, offer.SDP, nil
}
func (p *PionPeer) ApplyAnswerEnvelope(answerEnvelope string) error {
sdp, err := DecodeSDPAnswerInner(answerEnvelope)
if err != nil {
return fmt.Errorf("decode answer envelope: %w", err)
}
return p.PC.SetRemoteDescription(webrtc.SessionDescription{
Type: webrtc.SDPTypeAnswer,
SDP: sdp,
})
}
func (p *PionPeer) Close() error {
if p.PC == nil {
return nil
}
return p.PC.Close()
}
func directionToDion(direction webrtc.RTPTransceiverDirection) string {
switch direction {
case webrtc.RTPTransceiverDirectionSendonly:
return "SendOnly"
case webrtc.RTPTransceiverDirectionRecvonly:
return "RecvOnly"
case webrtc.RTPTransceiverDirectionSendrecv:
return "SendRecv"
case webrtc.RTPTransceiverDirectionInactive:
return "Inactive"
}
return "Unknown"
}
func extractICEHost(raw string) string {
value := raw
for _, prefix := range []string{"stun:", "turn:", "turns:"} {
value = strings.TrimPrefix(value, prefix)
}
if idx := strings.Index(value, "?"); idx >= 0 {
value = value[:idx]
}
if idx := strings.LastIndex(value, ":"); idx >= 0 {
value = value[:idx]
}
if value == "" {
return ""
}
if net.ParseIP(value) != nil {
return ""
}
return value
}

View File

@@ -0,0 +1,692 @@
package dion
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
"github.com/sagernet/sing-box/transport/call/common"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
)
const (
MethodServerConnected = "server:notify:main:connected"
MethodServerYouJoined = "server:you_joined"
MethodServerSubscribeResponse = "server:main:response:subscribe:conference"
MethodServerSDPAnswer = "server:sdp_answer"
MethodServerSpeakerJoined = "server:speaker_joined"
MethodServerSpeakerDisconnected = "server:speaker_disconnected"
MethodServerHeartbeat = "server:notify:main:heartbeat"
MethodServerSpeakersResponse = "server:response:speakers"
MethodServerSpeakersResponseZip = "server:response:speakers_zip"
MethodClientSubscribeConference = "client:main:request:subscribe:conference"
MethodClientSDPOffer = "client:request:media:sdp_offer"
MethodClientSendICECandidates = "client:request:send_ice_candidates_zip"
MethodClientPCICEStat = "client:request:pc_ice_stat"
MethodClientTrace = "client:trace"
MethodClientConfSpeakersState = "client:request:conf_speakers_state_zip"
MethodServerConfSpeakersState = "server:response:conf_speakers_state_zip"
MethodClientGetVideoFromUser = "client:request:get_video_from_user"
MethodClientStopVideoFromUser = "client:request:stop_video_from_user"
MethodServerGetVideoFromUser = "server:response:get_video_from_user"
MethodServerStopVideoFromUser = "server:response:stop_video_from_user"
MethodClientCamStateChange = "client:request:cam_state_change"
MethodClientMicStateChange = "client:request:mic_state_change"
MethodClientScreenSharingSwitchOn = "client:request:screensharing_switch_on"
MethodClientScreenSharingSwitchOff = "client:request:screensharing_switch_off"
MethodClientGetScreenSharingFromUser = "client:request:get_screensharing_from_user"
MethodClientStopScreenSharingFromUser = "client:request:stop_screensharing_from_user"
MethodClientScreensharingQualityChange = "client:request:screensharing_quality_change"
MethodServerGetScreenSharingFromUser = "server:response:get_screensharing_from_user"
MethodClientClientStatZip = "client:request:client_stat_zip"
MethodClientKickOne = "client:request:kick_one"
MethodServerKickOneResponse = "server:response:kick_one"
MethodServerYouKicked = "server:you_kicked"
MethodServerYourCamStateChanged = "server:response:your_cam_state_changed"
MethodServerYourMicStateChanged = "server:response:your_mic_state_changed"
MethodServerSpeakerCamStateChanged = "server:speaker_cam_state_changed"
MethodServerSpeakerMicStateChanged = "server:speaker_mic_state_changed"
ProductVersion = "6.14.0"
SubscriptionVersion = "2.0"
)
type Frame struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *RPCError `json:"error,omitempty"`
ID json.RawMessage `json:"id,omitempty"`
}
type RPCError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type TransceiverDesc struct {
TransceiverID string `json:"transceiver_id"`
SessionID string `json:"session_id"`
Direction string `json:"direction"`
Ctype string `json:"ctype"`
}
type DataChannelDesc struct {
ID int `json:"id"`
Label string `json:"label"`
}
type SDPEnvelope struct {
Type string `json:"type"`
SDP string `json:"sdp"`
}
type SDPOfferParams struct {
MicState bool `json:"mic_state"`
CamState bool `json:"cam_state"`
NoiseSuppressionState bool `json:"noise_suppression_state"`
VideoQuality *string `json:"video_quality"`
ScreenSharingQuality string `json:"screen_sharing_quality"`
Datachannels []DataChannelDesc `json:"datachannels"`
Transceivers []TransceiverDesc `json:"transceivers"`
Offer string `json:"offer"`
}
type SDPAnswerParams struct {
Answer string `json:"answer"`
Transceivers []TransceiverDesc `json:"transceivers"`
}
type ICEServerEntry struct {
URLs []string `json:"urls"`
Username string `json:"username"`
Credential string `json:"credential"`
}
type YouJoinedParams struct {
IcePolicy string `json:"ice_policy"`
IceServers []ICEServerEntry `json:"ice_servers"`
Event json.RawMessage `json:"event"`
EventParams json.RawMessage `json:"event_params"`
PreferredCodecs json.RawMessage `json:"preferred_codecs"`
}
type SpeakerJoinedParams struct {
SessionID string `json:"session_id"`
UserID string `json:"user_id,omitempty"`
Name string `json:"name,omitempty"`
CamState bool `json:"cam_state"`
MicState bool `json:"mic_state"`
Extra json.RawMessage `json:"-"`
}
type SpeakerCamStateChangedParams struct {
SessionID string `json:"session_id"`
CamState bool `json:"cam_state"`
}
type SpeakerMicStateChangedParams struct {
SessionID string `json:"session_id"`
MicState bool `json:"mic_state"`
}
type SpeakerEntry struct {
SessionID string `json:"session_id"`
UserID string `json:"user_id"`
Name string `json:"name"`
MicState bool `json:"mic_state"`
CamState bool `json:"cam_state"`
Role string `json:"role"`
WebinarRole string `json:"webinar_role"`
IsGuest bool `json:"is_guest"`
}
type ConfSpeakersStateResponse struct {
SpeakersCount int `json:"speakers_count"`
WebinarSpeakersCount int `json:"webinar_speakers_count"`
Speakers []SpeakerEntry `json:"speakers"`
}
type ConfSpeakersStateRequest struct {
SessionIDs []string `json:"session_ids"`
TileParams ConfSpeakersTileParams `json:"tile_params"`
InputVideoQuality string `json:"input_video_quality"`
ScreenParams ConfSpeakersScreenParams `json:"screen_params"`
}
type ConfSpeakersTileParams struct {
Mode string `json:"mode"`
MosaicParams ConfSpeakersMosaicParams `json:"mosaic_params"`
IsModeBlocked bool `json:"is_mode_blocked"`
}
type ConfSpeakersMosaicParams struct {
MaxTilesCount int `json:"max_tiles_count"`
}
type ConfSpeakersScreenParams struct {
Height int `json:"height"`
Width int `json:"width"`
}
type SpeakerDisconnectedParams struct {
SessionID string `json:"session_id"`
}
type ICECandidateJSON struct {
Candidate string `json:"candidate"`
SDPMid *string `json:"sdpMid"`
SDPMLineIndex *uint16 `json:"sdpMLineIndex"`
UsernameFragment string `json:"usernameFragment,omitempty"`
}
type GetVideoFromUserRequest struct {
SessionID string `json:"session_id"`
TransceiverID string `json:"transceiver_id"`
UserID string `json:"user_id"`
Username string `json:"username"`
}
type GetVideoFromUserResponse struct {
SessionID string `json:"session_id"`
TransceiverID string `json:"transceiver_id"`
}
type GetScreenSharingFromUserRequest struct {
SessionID string `json:"session_id"`
TransceiverID string `json:"transceiver_id"`
UserID string `json:"user_id"`
}
type GetScreenSharingFromUserResponse struct {
SessionID string `json:"session_id"`
TransceiverID string `json:"transceiver_id"`
}
type ClientStatVideoIn struct {
BytesReceived int64 `json:"bytes_received"`
Codec string `json:"codec"`
IsEnabled bool `json:"is_enabled"`
JitterBufferDelay float64 `json:"jitter_buffer_delay"`
JitterBufferEmittedCount int `json:"jitter_buffer_emitted_count"`
Jitter float64 `json:"jitter"`
Mid int `json:"mid"`
PacketsLost int `json:"packets_lost"`
PacketsReceived int `json:"packets_received"`
Framerate int `json:"framerate"`
FreezeCount int `json:"freeze_count"`
Resolution ClientStatResolution `json:"resolution"`
Rid string `json:"rid"`
TotalFreezesDuration int `json:"total_freezes_duration"`
SessionID string `json:"session_id"`
}
type ClientStatVideoOut struct {
Mid int `json:"mid"`
BytesSent int64 `json:"bytes_sent"`
Codec string `json:"codec"`
IsEnabled bool `json:"is_enabled"`
PacketsSent int `json:"packets_sent"`
RemoteStats ClientStatRemoteStats `json:"remote_stats"`
TargetBitrate int `json:"target_bitrate"`
Framerate int `json:"framerate"`
FreezeCount int `json:"freeze_count"`
Resolution ClientStatResolution `json:"resolution"`
Rid string `json:"rid"`
TotalFreezesDuration int `json:"total_freezes_duration"`
SessionID string `json:"session_id"`
ScalabilityMode string `json:"scalability_mode"`
}
type ClientStatResolution struct {
Height int `json:"height"`
Width int `json:"width"`
}
type ClientStatRemoteStats struct {
Jitter float64 `json:"jitter"`
FractionPacketsLost float64 `json:"fraction_packets_lost"`
PacketsLost int `json:"packets_lost"`
RTT float64 `json:"rtt"`
}
type ClientStatAudioIn struct {
BytesReceived int64 `json:"bytes_received"`
Codec string `json:"codec"`
IsEnabled bool `json:"is_enabled"`
JitterBufferDelay float64 `json:"jitter_buffer_delay"`
JitterBufferEmittedCount int `json:"jitter_buffer_emitted_count"`
Jitter float64 `json:"jitter"`
Mid int `json:"mid"`
PacketsLost int `json:"packets_lost"`
PacketsReceived int `json:"packets_received"`
}
type ClientStatConnection struct {
BytesReceived int64 `json:"bytes_received"`
BytesSent int64 `json:"bytes_sent"`
CurrentRTT float64 `json:"current_rtt"`
}
type ClientStatReport struct {
ReportTimeUnixMS int64 `json:"report_time_unix_ms"`
Connection ClientStatConnection `json:"connection"`
Audio struct {
In ClientStatAudioIn `json:"in"`
} `json:"audio"`
Video struct {
In []ClientStatVideoIn `json:"in"`
OutV2 []ClientStatVideoOut `json:"out_v2"`
Out ClientStatVideoOut `json:"out"`
} `json:"video"`
Screensharing struct{} `json:"screensharing"`
}
type SignalingDialOptions struct {
UserAgent string
Origin string
Logger logger.ContextLogger
Dialer N.Dialer
}
type SignalingClient struct {
conn *websocket.Conn
writeMu sync.Mutex
closed atomic.Bool
logger logger.ContextLogger
sessionID string
eventID string
OnYouJoined func(YouJoinedParams)
OnSubscribeResponse func()
OnSDPAnswer func(answerSDP string, transceivers []TransceiverDesc)
OnSpeakerJoined func(SpeakerJoinedParams)
OnSpeakerDisconnected func(SpeakerDisconnectedParams)
OnConfSpeakersState func(ConfSpeakersStateResponse)
OnSpeakerCamStateChanged func(SpeakerCamStateChangedParams)
OnSpeakerMicStateChanged func(SpeakerMicStateChangedParams)
OnGetVideoFromUserResponse func(resp GetVideoFromUserResponse, errCode int, errMessage string)
OnGetScreenSharingFromUserResponse func(resp GetScreenSharingFromUserResponse, errCode int, errMessage string)
OnHeartbeat func()
OnUnknown func(method string, params json.RawMessage)
OnDataChannelMessage func(method string, params json.RawMessage)
}
func DialSignaling(wssURL string, opts SignalingDialOptions) (*SignalingClient, error) {
if !strings.Contains(wssURL, "socket_version=") {
joiner := "&"
if !strings.Contains(wssURL, "?") {
joiner = "?"
}
wssURL = wssURL + joiner + "socket_version=2.0"
}
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
if opts.Dialer != nil {
dialer.NetDialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return opts.Dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
}
}
headers := http.Header{}
if opts.UserAgent != "" {
headers.Set("User-Agent", opts.UserAgent)
}
if opts.Origin != "" {
headers.Set("Origin", opts.Origin)
} else {
headers.Set("Origin", Origin)
}
log := opts.Logger
if log == nil {
log = logger.NOP()
}
conn, resp, err := dialer.Dial(wssURL, headers)
if err != nil {
status := 0
if resp != nil {
status = resp.StatusCode
}
return nil, fmt.Errorf("ws dial: %w status=%d url=%s", err, status, wssURL)
}
if resp != nil {
log.Debug(fmt.Sprintf("dion: ws dial status=%d", resp.StatusCode))
}
return &SignalingClient{conn: conn, logger: log}, nil
}
func (c *SignalingClient) Close() error {
if !c.closed.CompareAndSwap(false, true) {
return nil
}
common.CloseWS(c.conn)
return nil
}
func (c *SignalingClient) WaitConnected(timeout time.Duration) error {
c.conn.SetReadDeadline(time.Now().Add(timeout))
_, raw, err := c.conn.ReadMessage()
if err != nil {
return fmt.Errorf("read connected: %w", err)
}
var frame Frame
if err := json.Unmarshal(raw, &frame); err != nil {
return fmt.Errorf("decode connected: %w", err)
}
if frame.Method != MethodServerConnected {
return fmt.Errorf("expected %s, got %s", MethodServerConnected, frame.Method)
}
c.logger.Debug("dion: signaling connected")
return nil
}
func (c *SignalingClient) Subscribe(eventID, sessionID string) error {
c.eventID = eventID
c.sessionID = sessionID
return c.sendFrame(MethodClientSubscribeConference, map[string]any{
"event_id": eventID,
"conf_user_session_id": sessionID,
"main_user_session_id": nil,
"product_version": ProductVersion,
"subscription_version": SubscriptionVersion,
})
}
func (c *SignalingClient) SendTrace(deviceInfo map[string]any) error {
data, err := json.Marshal(deviceInfo)
if err != nil {
return fmt.Errorf("marshal trace: %w", err)
}
return c.sendFrame(MethodClientTrace, map[string]any{"data": string(data)})
}
func (c *SignalingClient) SendSDPOffer(params SDPOfferParams) error {
return c.sendFrame(MethodClientSDPOffer, params)
}
func (c *SignalingClient) SendConfSpeakersState(request ConfSpeakersStateRequest) error {
encoded, err := ZipEncode(request)
if err != nil {
return fmt.Errorf("zip conf_speakers_state: %w", err)
}
return c.sendFrame(MethodClientConfSpeakersState, encoded)
}
func (c *SignalingClient) SendGetVideoFromUser(request GetVideoFromUserRequest) error {
return c.sendFrame(MethodClientGetVideoFromUser, request)
}
func (c *SignalingClient) SendStopVideoFromUser(request GetVideoFromUserRequest) error {
return c.sendFrame(MethodClientStopVideoFromUser, request)
}
func (c *SignalingClient) SendCamStateChange(state bool) error {
return c.sendFrame(MethodClientCamStateChange, map[string]any{"state": state})
}
func (c *SignalingClient) SendMicStateChange(state bool) error {
return c.sendFrame(MethodClientMicStateChange, map[string]any{"state": state})
}
func (c *SignalingClient) SendScreenSharingSwitchOn() error {
return c.sendFrame(MethodClientScreenSharingSwitchOn, map[string]any{})
}
func (c *SignalingClient) SendScreenSharingSwitchOff() error {
return c.sendFrame(MethodClientScreenSharingSwitchOff, map[string]any{})
}
func (c *SignalingClient) SendGetScreenSharingFromUser(request GetScreenSharingFromUserRequest) error {
return c.sendFrame(MethodClientGetScreenSharingFromUser, request)
}
func (c *SignalingClient) SendScreensharingQualityChange(quality string) error {
return c.sendFrame(MethodClientScreensharingQualityChange, map[string]any{"quality": quality})
}
func (c *SignalingClient) SendKickOne(sessionID string) error {
return c.sendFrame(MethodClientKickOne, map[string]any{"session_id": sessionID})
}
func (c *SignalingClient) SendPCIceStat() error {
return c.sendFrame(MethodClientPCICEStat, map[string]any{"device": "web"})
}
func (c *SignalingClient) SendClientStatZip(report ClientStatReport) error {
encoded, err := ZipEncode(report)
if err != nil {
return fmt.Errorf("zip client_stat: %w", err)
}
return c.sendFrame(MethodClientClientStatZip, encoded)
}
func (c *SignalingClient) SendICECandidates(candidates []ICECandidateJSON) error {
encoded := make([]string, 0, len(candidates))
for _, candidate := range candidates {
raw, err := EncodeICECandidate(candidate)
if err != nil {
return fmt.Errorf("encode candidate: %w", err)
}
encoded = append(encoded, raw)
}
zipped, err := ZipEncode(map[string]any{"candidates": encoded})
if err != nil {
return fmt.Errorf("zip candidates: %w", err)
}
return c.sendFrame(MethodClientSendICECandidates, zipped)
}
func (c *SignalingClient) ReadLoop() error {
for {
if c.closed.Load() {
return nil
}
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
_, raw, err := c.conn.ReadMessage()
if err != nil {
if c.closed.Load() {
return nil
}
return fmt.Errorf("ws read: %w", err)
}
var frame Frame
if err := json.Unmarshal(raw, &frame); err != nil {
c.logger.Debug(fmt.Sprintf("dion: drop non-json frame: %v", err))
continue
}
if frame.Error != nil {
c.logger.Debug(fmt.Sprintf("dion: <- %s ERROR code=%d message=%q", frame.Method, frame.Error.Code, frame.Error.Message))
}
c.dispatch(frame)
}
}
func EncodeICECandidate(candidate ICECandidateJSON) (string, error) {
plain, err := json.Marshal(candidate)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(plain), nil
}
func DecodeICECandidate(encoded string) (ICECandidateJSON, error) {
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return ICECandidateJSON{}, fmt.Errorf("base64: %w", err)
}
var out ICECandidateJSON
if err := json.Unmarshal(raw, &out); err != nil {
return ICECandidateJSON{}, fmt.Errorf("unmarshal: %w", err)
}
return out, nil
}
func BuildSDPOfferEnvelope(offerSDP string) (string, error) {
return ZipEncode(SDPEnvelope{Type: "offer", SDP: offerSDP})
}
func DecodeSDPAnswerInner(answerZipped string) (string, error) {
var inner SDPEnvelope
if err := ZipDecode(answerZipped, &inner); err != nil {
return "", err
}
return inner.SDP, nil
}
func DefaultConfSpeakersStateRequest() ConfSpeakersStateRequest {
return ConfSpeakersStateRequest{
SessionIDs: []string{},
TileParams: ConfSpeakersTileParams{
Mode: "mosaic",
MosaicParams: ConfSpeakersMosaicParams{MaxTilesCount: 9},
IsModeBlocked: false,
},
InputVideoQuality: "auto",
ScreenParams: ConfSpeakersScreenParams{Height: 720, Width: 1280},
}
}
func (c *SignalingClient) sendFrame(method string, params any) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
if c.closed.Load() {
return fmt.Errorf("signaling closed")
}
payload := map[string]any{
"jsonrpc": "2.0",
"method": method,
"params": params,
}
raw, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal frame: %w", err)
}
return c.conn.WriteMessage(websocket.TextMessage, raw)
}
func (c *SignalingClient) dispatch(frame Frame) {
switch frame.Method {
case MethodServerConnected:
c.logger.Debug("dion: late server:notify:main:connected")
case MethodServerYouJoined:
var youJoined YouJoinedParams
if err := json.Unmarshal(frame.Params, &youJoined); err != nil {
c.logger.Debug(fmt.Sprintf("dion: decode you_joined: %v", err))
return
}
if c.OnYouJoined != nil {
c.OnYouJoined(youJoined)
}
case MethodServerSubscribeResponse:
if c.OnSubscribeResponse != nil {
c.OnSubscribeResponse()
}
case MethodServerSDPAnswer:
var answer SDPAnswerParams
if err := json.Unmarshal(frame.Params, &answer); err != nil {
c.logger.Debug(fmt.Sprintf("dion: decode sdp_answer: %v", err))
return
}
var inner SDPEnvelope
if err := ZipDecode(answer.Answer, &inner); err != nil {
c.logger.Debug(fmt.Sprintf("dion: decode sdp_answer envelope: %v", err))
return
}
if c.OnSDPAnswer != nil {
c.OnSDPAnswer(inner.SDP, answer.Transceivers)
}
case MethodServerSpeakerJoined:
var joined SpeakerJoinedParams
if err := json.Unmarshal(frame.Params, &joined); err != nil {
c.logger.Debug(fmt.Sprintf("dion: decode speaker_joined: %v", err))
return
}
joined.Extra = frame.Params
if c.OnSpeakerJoined != nil {
c.OnSpeakerJoined(joined)
}
case MethodServerSpeakerDisconnected:
var left SpeakerDisconnectedParams
if err := json.Unmarshal(frame.Params, &left); err != nil {
c.logger.Debug(fmt.Sprintf("dion: decode speaker_disconnected: %v", err))
return
}
if c.OnSpeakerDisconnected != nil {
c.OnSpeakerDisconnected(left)
}
case MethodServerSpeakerCamStateChanged:
var changed SpeakerCamStateChangedParams
if err := json.Unmarshal(frame.Params, &changed); err != nil {
c.logger.Debug(fmt.Sprintf("dion: decode speaker_cam_state_changed: %v", err))
return
}
if c.OnSpeakerCamStateChanged != nil {
c.OnSpeakerCamStateChanged(changed)
}
case MethodServerSpeakerMicStateChanged:
var changed SpeakerMicStateChangedParams
if err := json.Unmarshal(frame.Params, &changed); err != nil {
c.logger.Debug(fmt.Sprintf("dion: decode speaker_mic_state_changed: %v", err))
return
}
if c.OnSpeakerMicStateChanged != nil {
c.OnSpeakerMicStateChanged(changed)
}
case MethodServerConfSpeakersState:
var encoded string
if err := json.Unmarshal(frame.Params, &encoded); err != nil {
c.logger.Debug(fmt.Sprintf("dion: decode conf_speakers_state envelope: %v", err))
return
}
var response ConfSpeakersStateResponse
if err := ZipDecode(encoded, &response); err != nil {
c.logger.Debug(fmt.Sprintf("dion: decode conf_speakers_state body: %v", err))
return
}
if c.OnConfSpeakersState != nil {
c.OnConfSpeakersState(response)
}
case MethodServerHeartbeat:
if c.OnHeartbeat != nil {
c.OnHeartbeat()
}
case MethodServerGetVideoFromUser:
var resp GetVideoFromUserResponse
_ = json.Unmarshal(frame.Params, &resp)
errCode := 0
errMsg := ""
if frame.Error != nil {
errCode = frame.Error.Code
errMsg = frame.Error.Message
}
if c.OnGetVideoFromUserResponse != nil {
c.OnGetVideoFromUserResponse(resp, errCode, errMsg)
}
case MethodServerGetScreenSharingFromUser:
var resp GetScreenSharingFromUserResponse
_ = json.Unmarshal(frame.Params, &resp)
errCode := 0
errMsg := ""
if frame.Error != nil {
errCode = frame.Error.Code
errMsg = frame.Error.Message
}
if c.OnGetScreenSharingFromUserResponse != nil {
c.OnGetScreenSharingFromUserResponse(resp, errCode, errMsg)
}
default:
if c.OnUnknown != nil {
c.OnUnknown(frame.Method, frame.Params)
}
}
}

View File

@@ -0,0 +1,49 @@
package dion
import (
"bytes"
"compress/gzip"
"encoding/base64"
"encoding/json"
"fmt"
"io"
)
func ZipEncode(value any) (string, error) {
plain, err := json.Marshal(value)
if err != nil {
return "", fmt.Errorf("marshal: %w", err)
}
var compressed bytes.Buffer
gz := gzip.NewWriter(&compressed)
if _, err := gz.Write(plain); err != nil {
return "", fmt.Errorf("gzip write: %w", err)
}
if err := gz.Close(); err != nil {
return "", fmt.Errorf("gzip close: %w", err)
}
return base64.StdEncoding.EncodeToString(compressed.Bytes()), nil
}
func ZipDecode(encoded string, out any) error {
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return fmt.Errorf("base64: %w", err)
}
reader, err := gzip.NewReader(bytes.NewReader(raw))
if err != nil {
return fmt.Errorf("gzip reader: %w", err)
}
defer reader.Close()
plain, err := io.ReadAll(reader)
if err != nil {
return fmt.Errorf("gzip read: %w", err)
}
if out == nil {
return nil
}
if err := json.Unmarshal(plain, out); err != nil {
return fmt.Errorf("unmarshal: %w", err)
}
return nil
}