feat(jitsi): add Jitsi auth provider and engine

This commit is contained in:
zarazaex69
2026-05-15 15:37:58 +03:00
parent af87120f73
commit eceeaeba92
11 changed files with 1003 additions and 13 deletions

View File

@@ -0,0 +1,94 @@
// Package jitsi implements a pass-through auth provider for self-hosted Jitsi
// Meet instances.
//
// Public Jitsi Meet servers do not require authentication for guest access;
// the only "credentials" the engine needs are the host+room pair extracted
// from a user-supplied room URL. This provider does no HTTP at all — it just
// parses the URL and forwards host+room to the engine via auth.Credentials.
//
// Supported RoomURL forms:
//
// - "https://meet.example.com/myroom"
// - "http://meet.example.com/myroom"
// - "meet.example.com/myroom"
//
// Optional URL path prefixes (e.g. "/jitsi") are preserved as part of the
// host when present, so deployments behind a path-mounted reverse proxy work
// transparently — the j library accepts any host string the WebSocket dial
// can resolve.
package jitsi
import (
"context"
"errors"
"fmt"
"strings"
"github.com/openlibrecommunity/olcrtc/internal/auth"
)
// CredentialKeyRoom is the auth.Credentials.Extra key that carries the Jitsi
// room name (the conference identifier on the host).
const CredentialKeyRoom = "room"
// ErrInvalidRoomURL is returned when the supplied RoomURL cannot be parsed
// into a host+room pair.
var ErrInvalidRoomURL = errors.New("jitsi: invalid room URL (expected host/room or https://host/room)")
// Provider produces engine credentials for a Jitsi Meet room.
type Provider struct{}
// Engine reports which engine consumes credentials from this auth provider.
func (Provider) Engine() string { return "jitsi" }
// DefaultServiceURL returns the empty string: there is no canonical default
// Jitsi instance — every deployment is user-supplied.
func (Provider) DefaultServiceURL() string { return "" }
// Issue parses cfg.RoomURL into host+room and returns engine credentials.
//
// The URL field of the returned Credentials carries the Jitsi host (e.g.
// "meet.example.com"); the room name lives in Extra under CredentialKeyRoom.
// Token is unused — Jitsi guest access requires no token.
func (Provider) Issue(_ context.Context, cfg auth.Config) (auth.Credentials, error) {
host, room, err := parseRoomURL(cfg.RoomURL)
if err != nil {
return auth.Credentials{}, err
}
return auth.Credentials{
URL: host,
Token: "",
Extra: map[string]string{CredentialKeyRoom: room},
}, nil
}
// parseRoomURL splits a Jitsi room URL into (host, room).
//
// Accepts URLs with or without scheme. The host part is the segment before
// the first "/" after stripping the scheme; the room is everything that
// follows, with leading/trailing slashes trimmed.
func parseRoomURL(raw string) (host string, room string, err error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", auth.ErrRoomIDRequired
}
if idx := strings.Index(raw, "://"); idx >= 0 {
raw = raw[idx+3:]
}
raw = strings.TrimPrefix(raw, "//")
raw = strings.TrimPrefix(raw, "/")
slash := strings.Index(raw, "/")
if slash <= 0 {
return "", "", fmt.Errorf("%w: %q", ErrInvalidRoomURL, raw)
}
host = strings.TrimSpace(raw[:slash])
room = strings.Trim(raw[slash+1:], "/")
if host == "" || room == "" {
return "", "", fmt.Errorf("%w: %q", ErrInvalidRoomURL, raw)
}
return host, room, nil
}
func init() { //nolint:gochecknoinits // auth registration is the canonical Go pattern for plugins
auth.Register("jitsi", Provider{})
}

View File

@@ -0,0 +1,83 @@
package jitsi
import (
"context"
"errors"
"testing"
"github.com/openlibrecommunity/olcrtc/internal/auth"
)
func TestParseRoomURL(t *testing.T) {
tests := []struct {
name string
raw string
host string
room string
wantErr bool
}{
{name: "https url", raw: "https://meet.cryptopro.ru/myroom", host: "meet.cryptopro.ru", room: "myroom"},
{name: "http url", raw: "http://meet.example/myroom", host: "meet.example", room: "myroom"},
{name: "scheme-less", raw: "meet.example.com/myroom", host: "meet.example.com", room: "myroom"},
{name: "trailing slash", raw: "https://meet.example/myroom/", host: "meet.example", room: "myroom"},
{name: "double slash leader", raw: "//meet.example/myroom", host: "meet.example", room: "myroom"},
{name: "uppercase room", raw: "https://meet.example/MyRoom", host: "meet.example", room: "MyRoom"},
{name: "empty", raw: "", wantErr: true},
{name: "host only", raw: "meet.example.com", wantErr: true},
{name: "no room", raw: "https://meet.example/", wantErr: true},
{name: "scheme only", raw: "https://", wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
host, room, err := parseRoomURL(tc.raw)
if tc.wantErr {
if err == nil {
t.Fatalf("parseRoomURL(%q) = (%q, %q), want error", tc.raw, host, room)
}
return
}
if err != nil {
t.Fatalf("parseRoomURL(%q) error = %v, want nil", tc.raw, err)
}
if host != tc.host || room != tc.room {
t.Fatalf("parseRoomURL(%q) = (%q, %q), want (%q, %q)",
tc.raw, host, room, tc.host, tc.room)
}
})
}
}
func TestProviderIssue(t *testing.T) {
creds, err := Provider{}.Issue(context.Background(), auth.Config{
RoomURL: "https://meet.cryptopro.ru/olcrtc",
Name: "olcrtc-test",
})
if err != nil {
t.Fatalf("Issue: %v", err)
}
if creds.URL != "meet.cryptopro.ru" {
t.Fatalf("URL = %q, want %q", creds.URL, "meet.cryptopro.ru")
}
if got := creds.Extra[CredentialKeyRoom]; got != "olcrtc" {
t.Fatalf("room = %q, want %q", got, "olcrtc")
}
if creds.Token != "" {
t.Fatalf("Token = %q, want empty", creds.Token)
}
}
func TestProviderIssueRequiresRoom(t *testing.T) {
_, err := Provider{}.Issue(context.Background(), auth.Config{RoomURL: ""})
if !errors.Is(err, auth.ErrRoomIDRequired) {
t.Fatalf("Issue() err = %v, want ErrRoomIDRequired", err)
}
}
func TestProviderEngine(t *testing.T) {
if got := (Provider{}).Engine(); got != "jitsi" {
t.Fatalf("Engine() = %q, want %q", got, "jitsi")
}
if got := (Provider{}).DefaultServiceURL(); got != "" {
t.Fatalf("DefaultServiceURL() = %q, want empty", got)
}
}

View File

@@ -2,10 +2,12 @@
package builtin
import (
authJitsi "github.com/openlibrecommunity/olcrtc/internal/auth/jitsi"
authSaluteJazz "github.com/openlibrecommunity/olcrtc/internal/auth/salutejazz"
authTelemost "github.com/openlibrecommunity/olcrtc/internal/auth/telemost"
authWBStream "github.com/openlibrecommunity/olcrtc/internal/auth/wbstream"
_ "github.com/openlibrecommunity/olcrtc/internal/engine/goolom" // engine registration via init
_ "github.com/openlibrecommunity/olcrtc/internal/engine/jitsi" // engine registration via init
_ "github.com/openlibrecommunity/olcrtc/internal/engine/livekit" // engine registration via init
_ "github.com/openlibrecommunity/olcrtc/internal/engine/salutejazz" // engine registration via init
)
@@ -15,5 +17,6 @@ func Register() {
registerEngineAuth("wbstream", authWBStream.Provider{})
registerEngineAuth("jazz", authSaluteJazz.Provider{})
registerEngineAuth("telemost", authTelemost.Provider{})
registerEngineAuth("jitsi", authJitsi.Provider{})
registerDirect("none")
}

View File

@@ -80,6 +80,11 @@ var (
"019e23c2-a580-7550-b08a-7ac5342ca21f",
"WB Stream room id for real e2e; autogenerated when empty",
)
realE2EJitsiRoom = flag.String( //nolint:gochecknoglobals // package-level state intentional
"olcrtc.real-jitsi-room",
"https://meet.cryptopro.ru/deadbeef",
"Jitsi Meet room URL for real e2e (format https://host/room or host/room)",
)
realE2ETimeout = flag.Duration( //nolint:gochecknoglobals // package-level state intentional
"olcrtc.real-timeout",
90*time.Second,
@@ -337,7 +342,7 @@ func registerMemoryCarrierAs(t *testing.T, name string) {
}
func builtInCarrierNames() []string {
return []string{"jazz", "telemost", "wbstream"} //nolint:goconst // test literal, repetition is intentional
return []string{"jazz", "telemost", "wbstream", "jitsi"} //nolint:goconst // test literal, repetition is intentional
}
func builtInTransportNames() []string {
@@ -365,6 +370,21 @@ func realE2ECaseExpectation(carrierName, transportName string) realE2EExpectatio
return realE2EExpectPass
}
return realE2EExpectFail
case "jitsi":
// Jitsi colibri-ws bridge channel maps cleanly onto the
// datachannel transport (raw bytes broadcast through
// EndpointMessage). Video transports go through pion's
// PeerConnection negotiated via Jingle session-accept; results
// are bridge/instance dependent (some operators throttle or
// strip non-camera video), hence best-effort.
switch transportName {
case transportData:
return realE2EExpectPass
case transportVP8, transportVideo, transportSEI:
return realE2EBestEffort
default:
return realE2EBestEffort
}
default:
return realE2EExpectPass
}
@@ -432,6 +452,30 @@ func TestRealE2ECaseExpectation(t *testing.T) {
transport: transportData,
want: realE2EExpectFail,
},
{
name: "jitsi datachannel is expected to pass",
carrier: "jitsi",
transport: transportData,
want: realE2EExpectPass,
},
{
name: "jitsi vp8channel is best effort",
carrier: "jitsi",
transport: transportVP8,
want: realE2EBestEffort,
},
{
name: "jitsi videochannel is best effort",
carrier: "jitsi",
transport: transportVideo,
want: realE2EBestEffort,
},
{
name: "jitsi seichannel is best effort",
carrier: "jitsi",
transport: transportSEI,
want: realE2EBestEffort,
},
}
for _, tt := range tests {
@@ -484,6 +528,17 @@ func realRoomURL(ctx context.Context, t *testing.T, carrierName string) string {
t.Skipf("skip wbstream real e2e: create room failed: %v", err)
}
return room
case "jitsi":
// Jitsi has no notion of "creating" a room — names are conjured
// on first join. The default flag points at meet.cryptopro.ru
// (a CryptoPro-operated public Jitsi instance) with a fixed
// room slug so the server and client land in the same MUC.
_ = ctx
room := *realE2EJitsiRoom
if room == "" {
t.Skip("skip jitsi real e2e: empty -olcrtc.real-jitsi-room")
}
return room
default:
return ""
}

View File

@@ -0,0 +1,20 @@
package jitsi
import (
"encoding/base64"
"testing"
"github.com/zarazaex69/j"
)
func encodeForTest(t *testing.T, data []byte) string {
t.Helper()
return base64.StdEncoding.EncodeToString(data)
}
func makeBridgeMessage(class string, fields map[string]any) j.BridgeMessage {
return j.BridgeMessage{
Class: class,
Fields: fields,
}
}

View File

@@ -0,0 +1,543 @@
// Package jitsi implements an engine.Session backed by the Jitsi Meet
// XMPP/Jingle/colibri-ws stack via the github.com/zarazaex69/j library.
//
// The engine speaks the wire protocol of a self-hosted Jitsi instance: it
// joins the MUC, waits for a Jingle session-initiate from Jicofo, opens the
// JVB bridge channel (colibri-ws) for byte transport, and optionally
// negotiates a pion *webrtc.PeerConnection for video tracks.
//
// Service-specific bits (URL parsing) live in the auth/jitsi package; this
// engine is told the host and room name through engine.Config (URL carries
// the host string, Extra["room"] carries the room name).
//
// The Jingle session-initiate is only delivered by Jicofo once at least one
// other participant is present in the conference, mirroring the Telemost /
// SaluteJazz two-peer requirement that olcrtc already accommodates.
package jitsi
import (
"context"
"encoding/base64"
"errors"
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/openlibrecommunity/olcrtc/internal/engine"
"github.com/openlibrecommunity/olcrtc/internal/logger"
"github.com/pion/webrtc/v4"
"github.com/zarazaex69/j"
)
const (
defaultSendQueueSize = 5000
// bridgeMaxMessageSize is the practical upper bound on a single colibri-ws
// payload. JVB enforces a max-message-size around 16 KiB; payloads above
// that cause the bridge to drop the websocket. The default datachannel
// transport in olcrtc already uses 12 KiB chunks, well under this limit.
bridgeMaxMessageSize = 16 * 1024
bridgeOpenTimeout = 30 * time.Second
defaultNick = "olcrtc"
credentialKeyRoom = "room"
videoTrackName = "videochannel"
)
var (
// ErrSessionClosed is returned when an operation is attempted on a closed session.
ErrSessionClosed = errors.New("jitsi session closed")
// ErrSendQueueFull is returned when the outbound queue cannot accept more data.
ErrSendQueueFull = errors.New("jitsi send queue full")
// ErrBridgeNotReady is returned when send is attempted before the bridge is open.
ErrBridgeNotReady = errors.New("jitsi bridge not ready")
// ErrSendTooLarge is returned when a single payload exceeds the JVB max-message-size limit.
ErrSendTooLarge = errors.New("jitsi payload exceeds bridge max-message-size")
// ErrHostRequired is returned when no Jitsi host was supplied.
ErrHostRequired = errors.New("jitsi host required")
// ErrRoomRequired is returned when no Jitsi room was supplied.
ErrRoomRequired = errors.New("jitsi room required")
)
// Session is the Jitsi engine handle.
type Session struct {
host string
room string
name string
onData func([]byte)
onReconnect func(*webrtc.DataChannel)
shouldReconnect func() bool
onEnded func(string)
jSess atomic.Pointer[j.Session]
pcMu sync.Mutex
pc *webrtc.PeerConnection
sendQueue chan []byte
bridgeReady atomic.Bool
closed atomic.Bool
done chan struct{}
doneOnce sync.Once
cancel context.CancelFunc
runCtx context.Context //nolint:containedctx // engine owns the supervisor lifetime
wg sync.WaitGroup
videoTrackMu sync.RWMutex
videoTracks []webrtc.TrackLocal
onVideoTrack func(*webrtc.TrackRemote, *webrtc.RTPReceiver)
}
// New creates a new Jitsi engine session.
//
// cfg.URL carries the Jitsi host (e.g. "meet.cryptopro.ru") — populated by the
// jitsi auth provider after parsing the user-supplied room URL. cfg.Extra
// must contain the room name under the "room" key.
func New(_ context.Context, cfg engine.Config) (engine.Session, error) {
host := normaliseHost(cfg.URL)
if host == "" {
return nil, ErrHostRequired
}
var room string
if cfg.Extra != nil {
room = strings.TrimSpace(cfg.Extra[credentialKeyRoom])
}
if room == "" {
return nil, ErrRoomRequired
}
name := sanitiseNick(cfg.Name)
if name == "" {
name = defaultNick
}
runCtx, cancel := context.WithCancel(context.Background())
return &Session{
host: host,
room: room,
name: name,
onData: cfg.OnData,
sendQueue: make(chan []byte, defaultSendQueueSize),
done: make(chan struct{}),
cancel: cancel,
runCtx: runCtx,
}, nil
}
// sanitiseNick reduces a display name to a 7-bit ASCII slug acceptable to
// the j library's MUC presence helper. The helper currently uses byte-level
// slicing on the supplied name to derive a stats-id, so multi-byte UTF-8
// inputs (e.g. Cyrillic) get sliced mid-codepoint and Prosody silently
// rejects the resulting presence stanza.
//
// Non-ASCII characters are dropped; spaces and punctuation are normalised
// to '-'. The result is bounded to 16 characters.
func sanitiseNick(raw string) string {
const maxNickLen = 16
var b strings.Builder
b.Grow(len(raw))
prevDash := false
for _, r := range raw {
switch {
case r >= 'a' && r <= 'z',
r >= 'A' && r <= 'Z',
r >= '0' && r <= '9':
b.WriteRune(r)
prevDash = false
case r == '-' || r == '_':
b.WriteRune(r)
prevDash = false
default:
if !prevDash && b.Len() > 0 {
b.WriteRune('-')
prevDash = true
}
}
if b.Len() >= maxNickLen {
break
}
}
out := strings.Trim(b.String(), "-")
if out == "" {
return ""
}
return out
}
// Capabilities reports what this engine can do.
func (s *Session) Capabilities() engine.Capabilities {
return engine.Capabilities{ByteStream: true, VideoTrack: true}
}
// Connect joins the Jitsi conference, optionally opens the bridge channel,
// and (if video tracks are pending or a remote handler is set) negotiates a
// pion PeerConnection.
func (s *Session) Connect(ctx context.Context) error {
if s.closed.Load() {
return ErrSessionClosed
}
logger.Infof("jitsi: joining %s/%s as %s …", s.host, s.room, s.name)
jSess, err := j.Join(ctx, j.Config{
Host: s.host,
Room: s.room,
Nick: s.name,
Debug: logger.IsVerbose(),
})
if err != nil {
return fmt.Errorf("jitsi join: %w", err)
}
logger.Infof("jitsi: joined %s/%s; colibri-ws=%s", s.host, s.room, jSess.ColibriWS)
s.jSess.Store(jSess)
if s.onData != nil {
bctx, bcancel := context.WithTimeout(ctx, bridgeOpenTimeout)
err := jSess.OpenBridge(bctx)
bcancel()
if err != nil {
_ = jSess.Close()
s.jSess.Store(nil)
return fmt.Errorf("open bridge: %w", err)
}
s.bridgeReady.Store(true)
logger.Infof("jitsi: bridge open (endpoints=%v)", jSess.Endpoints())
}
if s.shouldNegotiatePC() {
if err := s.negotiatePC(ctx, jSess); err != nil {
_ = jSess.Close()
s.jSess.Store(nil)
s.bridgeReady.Store(false)
return err
}
}
s.wg.Add(2)
go s.sendLoop()
go s.recvLoop()
return nil
}
func (s *Session) shouldNegotiatePC() bool {
s.videoTrackMu.RLock()
defer s.videoTrackMu.RUnlock()
return len(s.videoTracks) > 0 || s.onVideoTrack != nil
}
func (s *Session) videoTrackHandler() func(*webrtc.TrackRemote, *webrtc.RTPReceiver) {
s.videoTrackMu.RLock()
defer s.videoTrackMu.RUnlock()
return s.onVideoTrack
}
func (s *Session) negotiatePC(ctx context.Context, jSess *j.Session) error {
settings := webrtc.SettingEngine{}
settings.LoggerFactory = logger.NewPionLoggerFactory()
api := webrtc.NewAPI(webrtc.WithSettingEngine(settings))
// Jicofo emits Plan B style SDP with separate <content> sections per
// media kind and SSRC-keyed source descriptors. pion's default
// UnifiedPlan parser rejects this with "remote SessionDescription
// semantics does not match configuration", so we explicitly request
// Plan B for the conference PeerConnection.
pcConfig := jSess.IceConfig()
pcConfig.SDPSemantics = webrtc.SDPSemanticsPlanB
pc, err := api.NewPeerConnection(pcConfig)
if err != nil {
return fmt.Errorf("new pc: %w", err)
}
s.videoTrackMu.RLock()
for _, track := range s.videoTracks {
if _, addErr := pc.AddTrack(track); addErr != nil {
s.videoTrackMu.RUnlock()
_ = pc.Close()
return fmt.Errorf("add track: %w", addErr)
}
}
s.videoTrackMu.RUnlock()
pc.OnTrack(func(track *webrtc.TrackRemote, recv *webrtc.RTPReceiver) {
if track.Kind() != webrtc.RTPCodecTypeVideo {
return
}
if cb := s.videoTrackHandler(); cb != nil {
cb(track, recv)
}
})
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
logger.Debugf("jitsi pc state: %s", state.String())
if state == webrtc.PeerConnectionStateFailed && !s.closed.Load() && s.onEnded != nil {
s.onEnded("jitsi peer connection failed")
}
})
neg := jSess.Negotiator()
neg.PC = pc
if err := neg.Accept(ctx); err != nil {
_ = pc.Close()
return fmt.Errorf("session-accept: %w", err)
}
s.pcMu.Lock()
s.pc = pc
s.pcMu.Unlock()
return nil
}
// Send queues data for transmission over the bridge.
//
// Send is non-blocking: data is enqueued onto the engine's outbound channel
// and a background goroutine pumps the queue into the colibri-ws bridge with
// the bridge's own backpressure window.
func (s *Session) Send(data []byte) error {
if s.closed.Load() {
return ErrSessionClosed
}
if !s.bridgeReady.Load() {
return ErrBridgeNotReady
}
if len(data) > bridgeMaxMessageSize {
return ErrSendTooLarge
}
select {
case s.sendQueue <- data:
return nil
case <-s.done:
return ErrSessionClosed
default:
return ErrSendQueueFull
}
}
func (s *Session) sendLoop() {
defer s.wg.Done()
for {
select {
case <-s.done:
return
case data, ok := <-s.sendQueue:
if !ok {
return
}
jSess := s.jSess.Load()
if jSess == nil {
return
}
if err := jSess.BridgeSendRaw("", data); err != nil {
if s.closed.Load() {
return
}
logger.Debugf("jitsi bridge send: %v", err)
}
}
}
}
func (s *Session) recvLoop() {
defer s.wg.Done()
jSess := s.jSess.Load()
if jSess == nil || s.onData == nil || !s.bridgeReady.Load() {
return
}
msgs := jSess.BridgeMessages()
if msgs == nil {
return
}
for {
select {
case <-s.done:
return
case msg, ok := <-msgs:
if !ok {
if !s.closed.Load() {
s.signalEnded("jitsi bridge closed")
}
return
}
payload := decodeRaw(msg)
if payload == nil {
continue
}
s.onData(payload)
}
}
}
// decodeRaw extracts the bytes from an EndpointMessage produced by the j
// library's BridgeSendRaw helper. Mirrors the unexported colibri.DecodeRaw —
// the j library's BridgeMessage type alias keeps the necessary fields public,
// but the helper itself lives in an internal package.
func decodeRaw(m j.BridgeMessage) []byte {
if m.Class != "EndpointMessage" {
return nil
}
enc, ok := m.Fields["raw"].(string)
if !ok {
return nil
}
out, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
return nil
}
return out
}
// Close terminates the session and releases resources.
func (s *Session) Close() error {
if !s.closed.CompareAndSwap(false, true) {
return nil
}
if s.cancel != nil {
s.cancel()
}
s.doneOnce.Do(func() { close(s.done) })
s.pcMu.Lock()
pc := s.pc
s.pc = nil
s.pcMu.Unlock()
if pc != nil {
_ = pc.Close()
}
jSess := s.jSess.Swap(nil)
if jSess != nil {
_ = jSess.Close()
}
s.bridgeReady.Store(false)
stopped := make(chan struct{})
go func() {
s.wg.Wait()
close(stopped)
}()
select {
case <-stopped:
case <-time.After(2 * time.Second):
}
return nil
}
// SetReconnectCallback registers a callback for reconnection events.
//
// The Jitsi engine itself does not currently drive a reconnect loop; the
// callback is stored for API parity and wired through the carrier adapter
// for future use.
func (s *Session) SetReconnectCallback(cb func(*webrtc.DataChannel)) { s.onReconnect = cb }
// SetShouldReconnect stores the reconnect predicate (kept for API parity).
func (s *Session) SetShouldReconnect(fn func() bool) { s.shouldReconnect = fn }
// SetEndedCallback registers a function to call when the session ends.
func (s *Session) SetEndedCallback(cb func(string)) { s.onEnded = cb }
// WatchConnection blocks until the session is closed, the parent context
// fires, or the bridge tears down.
func (s *Session) WatchConnection(ctx context.Context) {
select {
case <-ctx.Done():
return
case <-s.done:
return
}
}
// CanSend reports whether the session is ready to accept new data.
func (s *Session) CanSend() bool {
if s.closed.Load() {
return false
}
if s.onData == nil {
// pure video mode — readiness driven by PC connection state
s.pcMu.Lock()
ready := s.pc != nil && s.pc.ConnectionState() == webrtc.PeerConnectionStateConnected
s.pcMu.Unlock()
return ready
}
return s.bridgeReady.Load()
}
// GetSendQueue exposes the outbound queue for upstream metrics.
func (s *Session) GetSendQueue() chan []byte { return s.sendQueue }
// GetBufferedAmount returns a coarse estimate of bytes pending on the wire.
//
// The j library's bridge connection only exposes message-count depth, so we
// approximate bytes by multiplying queue depth by the bridge max-message-size.
// This is enough for upper-layer pacing heuristics; engines that need
// byte-accurate pressure should consult GetSendQueue directly.
func (s *Session) GetBufferedAmount() uint64 {
jSess := s.jSess.Load()
if jSess == nil {
return 0
}
depth := jSess.BridgeSendQueueDepth()
if depth <= 0 {
return 0
}
return uint64(depth) * uint64(bridgeMaxMessageSize) //nolint:gosec // depth is small and bounded by queue cap
}
// AddVideoTrack publishes a video track to the Jitsi conference.
//
// Tracks added before Connect are sent as part of the session-accept SDP
// (so Jicofo announces them to other participants automatically). Tracks
// added afterwards are attached to the live PeerConnection — Jitsi's
// source-add flow is not yet implemented in this engine, so late tracks
// will only be visible on the next reconnect.
func (s *Session) AddVideoTrack(track webrtc.TrackLocal) error {
s.videoTrackMu.Lock()
s.videoTracks = append(s.videoTracks, track)
s.videoTrackMu.Unlock()
s.pcMu.Lock()
pc := s.pc
s.pcMu.Unlock()
if pc == nil {
return nil
}
if _, err := pc.AddTrack(track); err != nil {
return fmt.Errorf("add track: %w", err)
}
return nil
}
// SetVideoTrackHandler registers a callback invoked on every remote video
// track received from the conference.
func (s *Session) SetVideoTrackHandler(cb func(*webrtc.TrackRemote, *webrtc.RTPReceiver)) {
s.videoTrackMu.Lock()
defer s.videoTrackMu.Unlock()
s.onVideoTrack = cb
}
func (s *Session) signalEnded(reason string) {
s.bridgeReady.Store(false)
if s.onEnded != nil {
s.onEnded(reason)
}
}
// normaliseHost strips an optional scheme and trailing slashes off a Jitsi
// host string. The j library expects a bare host; auth providers might pass
// a full URL through verbatim.
func normaliseHost(raw string) string {
raw = strings.TrimSpace(raw)
if idx := strings.Index(raw, "://"); idx >= 0 {
raw = raw[idx+3:]
}
raw = strings.TrimPrefix(raw, "//")
raw = strings.TrimSuffix(raw, "/")
if i := strings.Index(raw, "/"); i >= 0 {
raw = raw[:i]
}
return raw
}
func init() { //nolint:gochecknoinits // engine registration is the canonical Go pattern for plugins
engine.Register("jitsi", New)
}

View File

@@ -0,0 +1,147 @@
package jitsi
import (
"context"
"errors"
"testing"
"github.com/openlibrecommunity/olcrtc/internal/engine"
)
func TestNormaliseHost(t *testing.T) {
tests := []struct {
raw string
want string
}{
{"meet.example.com", "meet.example.com"},
{"https://meet.example.com", "meet.example.com"},
{"https://meet.example.com/", "meet.example.com"},
{"https://meet.example.com/path", "meet.example.com"},
{"//meet.example.com", "meet.example.com"},
{" https://meet.example.com ", "meet.example.com"},
{"", ""},
}
for _, tc := range tests {
t.Run(tc.raw, func(t *testing.T) {
if got := normaliseHost(tc.raw); got != tc.want {
t.Fatalf("normaliseHost(%q) = %q, want %q", tc.raw, got, tc.want)
}
})
}
}
func TestDecodeRaw(t *testing.T) {
const payload = "hello world"
raw := encodeForTest(t, []byte(payload))
got := decodeRaw(makeBridgeMessage("EndpointMessage", map[string]any{"raw": raw}))
if string(got) != payload {
t.Fatalf("decodeRaw = %q, want %q", got, payload)
}
if got := decodeRaw(makeBridgeMessage("OtherClass", map[string]any{"raw": raw})); got != nil {
t.Fatalf("decodeRaw(other class) = %q, want nil", got)
}
if got := decodeRaw(makeBridgeMessage("EndpointMessage", map[string]any{})); got != nil {
t.Fatalf("decodeRaw(no raw) = %q, want nil", got)
}
if got := decodeRaw(makeBridgeMessage("EndpointMessage", map[string]any{"raw": "not-base64!!!"})); got != nil {
t.Fatalf("decodeRaw(bad base64) = %q, want nil", got)
}
}
func TestNewRequiresHost(t *testing.T) {
_, err := New(context.Background(), engine.Config{
Extra: map[string]string{"room": "myroom"},
})
if !errors.Is(err, ErrHostRequired) {
t.Fatalf("err = %v, want ErrHostRequired", err)
}
}
func TestNewRequiresRoom(t *testing.T) {
_, err := New(context.Background(), engine.Config{
URL: "meet.example.com",
})
if !errors.Is(err, ErrRoomRequired) {
t.Fatalf("err = %v, want ErrRoomRequired", err)
}
}
func TestNewSucceeds(t *testing.T) {
sess, err := New(context.Background(), engine.Config{
URL: "https://meet.example.com",
Extra: map[string]string{"room": "myroom"},
Name: "olcrtc-test",
})
if err != nil {
t.Fatalf("New: %v", err)
}
defer func() { _ = sess.Close() }()
caps := sess.Capabilities()
if !caps.ByteStream || !caps.VideoTrack {
t.Fatalf("Capabilities = %+v, want ByteStream && VideoTrack", caps)
}
}
func TestSendBeforeConnect(t *testing.T) {
sess, err := New(context.Background(), engine.Config{
URL: "meet.example.com",
Extra: map[string]string{"room": "myroom"},
OnData: func([]byte) {},
})
if err != nil {
t.Fatalf("New: %v", err)
}
defer func() { _ = sess.Close() }()
if err := sess.Send([]byte("data")); !errors.Is(err, ErrBridgeNotReady) {
t.Fatalf("Send err = %v, want ErrBridgeNotReady", err)
}
}
func TestSendAfterClose(t *testing.T) {
sess, err := New(context.Background(), engine.Config{
URL: "meet.example.com",
Extra: map[string]string{"room": "myroom"},
})
if err != nil {
t.Fatalf("New: %v", err)
}
if err := sess.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if err := sess.Send([]byte("data")); !errors.Is(err, ErrSessionClosed) {
t.Fatalf("Send err = %v, want ErrSessionClosed", err)
}
}
func TestSanitiseNick(t *testing.T) {
tests := []struct {
raw string
want string
}{
{"alice", "alice"},
{"Alice Smith", "Alice-Smith"},
{"Конрад Олег", ""},
{"olcrtc-bot42", "olcrtc-bot42"},
{" bob ", "bob"},
{"$$$ %%%", ""},
{"verylongnicknamethatexceedslimit", "verylongnicknamet"[:16]},
}
for _, tc := range tests {
t.Run(tc.raw, func(t *testing.T) {
if got := sanitiseNick(tc.raw); got != tc.want {
t.Fatalf("sanitiseNick(%q) = %q, want %q", tc.raw, got, tc.want)
}
})
}
}
func TestEngineRegistration(t *testing.T) {
if _, err := engine.New(context.Background(), "jitsi", engine.Config{
URL: "meet.example.com",
Extra: map[string]string{"room": "myroom"},
}); err != nil {
t.Fatalf("engine.New(jitsi) = %v, want nil", err)
}
}