Merge branch 'refactor/universal-carrier'

Brings universal carrier refactor, vp8channel improvements, mobile/pkg
exports, docs translation/refresh, and CI nightly stress soak schedule
into master.
This commit is contained in:
zarazaex69
2026-05-22 18:09:15 +03:00
171 changed files with 18451 additions and 9401 deletions

View File

@@ -2,9 +2,13 @@ name: CI
on:
push:
branches: [ "main", "master" ]
pull_request:
branches: [ "main", "master" ]
branches: ["main", "master"]
schedule:
# Nightly stress soak — 03:17 UTC keeps it off the hour to avoid
# contention with the GitHub Actions hourly stampede.
- cron: "17 3 * * *"
workflow_dispatch:
jobs:
test:
@@ -18,7 +22,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25.x'
go-version: "1.25.x"
- name: Run tests
run: go test -count=1 ./...
@@ -34,11 +38,28 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25.x'
go-version: "1.25.x"
- name: Run tests with coverage
run: go test -count=1 ./... --cover
race:
name: Test (-race)
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.25.x"
- name: Run tests with -race
run: go test -count=1 -race ./...
real-e2e:
name: Real E2E (Providers x Transports)
runs-on: ubuntu-latest
@@ -51,7 +72,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25.x'
go-version: "1.25.x"
- name: Install media tools
run: sudo apt-get update && sudo apt-get install -y ffmpeg
@@ -59,9 +80,43 @@ jobs:
- name: Run real provider e2e matrix
run: |
go test -count=1 -v ./internal/e2e \
-olcrtc.real-carriers=telemost,wbstream,jitsi \
-run '^TestRealProviderTransportMatrix$' \
-olcrtc.real-e2e
stress-soak:
name: Real E2E Stress Soak (Nightly)
# Long-form stress over real carriers: only on schedule or manual
# dispatch. Push and PR runs stay fast.
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.25.x"
- name: Install media tools
run: sudo apt-get update && sudo apt-get install -y ffmpeg
- name: Run real stress soak
run: |
go test -count=1 -v ./internal/e2e \
-run '^TestRealProviderTransportStress$' \
-timeout=85m \
-olcrtc.real-e2e \
-olcrtc.stress \
-olcrtc.real-carriers=telemost,wbstream,jitsi \
-olcrtc.stress-bulk-duration=90s \
-olcrtc.stress-duration=120s \
-olcrtc.stress-echo-size=1024 \
-olcrtc.stress-case-timeout=8m
lint:
name: Lint
runs-on: ubuntu-latest
@@ -69,12 +124,12 @@ jobs:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25.x'
go-version: "1.25.x"
- name: golangci-lint
run: |
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
@@ -87,18 +142,18 @@ jobs:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25.x'
go-version: "1.25.x"
- name: Install Mage
run: go install github.com/magefile/mage@latest
- name: Build CLI (Cross)
run: mage cross
- name: Upload CLI Artifacts
uses: actions/upload-artifact@v4
with:
@@ -112,29 +167,29 @@ jobs:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25.x'
go-version: "1.25.x"
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
distribution: "temurin"
java-version: "17"
- name: Install gomobile
run: |
go install golang.org/x/mobile/cmd/gomobile@latest
gomobile init
- name: Install Mage
run: go install github.com/magefile/mage@latest
- name: Build Mobile
run: mage mobile
- name: Upload Android Artifact
uses: actions/upload-artifact@v4
with:

3
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# Prerequisites
*.d
.DS_Store
# Object files
*.o
@@ -246,6 +247,6 @@ go.work.sum
build/
GEMINI.md
code/package-lock.json
olcrtc
!cmd/olcrtc/
!cmd/olcrtc/main_test.go
!pkg/

View File

@@ -25,7 +25,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \
FROM alpine:${ALPINE_VERSION} AS runtime
RUN apk add --no-cache ca-certificates tzdata && \
RUN apk add --no-cache ca-certificates ffmpeg tzdata && \
addgroup -S olcrtc && \
mkdir -p /usr/share/olcrtc /var/lib/olcrtc && \
adduser -S -D -h /var/lib/olcrtc -s /sbin/nologin -G olcrtc olcrtc && \
@@ -43,9 +43,13 @@ WORKDIR /var/lib/olcrtc
ENV OLCRTC_MODE=srv \
OLCRTC_CARRIER= \
OLCRTC_TRANSPORT=datachannel \
OLCRTC_DATA_DIR=/usr/share/olcrtc \
OLCRTC_DNS=1.1.1.1:53 \
OLCRTC_KEY_FILE=/var/lib/olcrtc/key.hex
OLCRTC_DNS=8.8.8.8:53 \
OLCRTC_KEY_FILE=/var/lib/olcrtc/key.hex \
OLCRTC_SOCKS_HOST=127.0.0.1 \
OLCRTC_SOCKS_PORT=8808 \
OLCRTC_FFMPEG=ffmpeg
VOLUME ["/var/lib/olcrtc"]

View File

@@ -1,12 +1,17 @@
// Package main provides the olcrtc CLI entrypoint.
//
// Usage: olcrtc <config.yaml>
//
// All runtime settings come from the YAML file. There are no other CLI flags.
package main
import (
"bytes"
"context"
"errors"
"flag"
"fmt"
"io"
"log"
"os"
"os/signal"
"path/filepath"
@@ -16,15 +21,23 @@ import (
protoLogger "github.com/livekit/protocol/logger"
lksdk "github.com/livekit/server-sdk-go/v2"
"github.com/openlibrecommunity/olcrtc/internal/app/session"
configpkg "github.com/openlibrecommunity/olcrtc/internal/config"
"github.com/openlibrecommunity/olcrtc/internal/logger"
"github.com/openlibrecommunity/olcrtc/internal/names"
"github.com/openlibrecommunity/olcrtc/internal/supervisor"
"github.com/openlibrecommunity/olcrtc/internal/transport/videochannel"
)
const modeGen = "gen"
// ErrDataDirRequired is returned when no data directory is specified.
var ErrDataDirRequired = errors.New("data directory required (use -data data)")
// ErrConfigPathRequired is returned when no config file is provided.
var ErrConfigPathRequired = errors.New("usage: olcrtc <config.yaml>")
// ErrDataDirRequired is returned when the YAML config does not specify a data directory.
var ErrDataDirRequired = errors.New("data directory required (set 'data:' in YAML)")
// ErrProfilesUnsupportedForGen is returned when failover profiles are configured for gen mode.
var ErrProfilesUnsupportedForGen = errors.New("profiles are only supported for srv and cnc modes")
//nolint:gochecknoglobals // Tests replace the long-running session runner with a bounded function.
var runSession = session.Run
@@ -32,41 +45,19 @@ var runSession = session.Run
//nolint:gochecknoglobals // Tests replace gen runner with a stub.
var runGen = execGen
type config struct {
mode string
link string
transport string
carrier string
roomID string
clientID string
socksPort int
socksHost string
socksUser string
socksPass string
keyHex string
debug bool
dataDir string
dnsServer string
socksProxyAddr string
socksProxyPort int
videoWidth int
videoHeight int
videoFPS int
videoBitrate string
videoHW string
videoQRSize int
videoQRRecovery string
videoCodec string
videoTileModule int
videoTileRS int
vp8FPS int
vp8BatchSize int
seiFPS int
seiBatchSize int
seiFragmentSize int
seiAckTimeoutMS int
amount int
ffmpegPath string
// loadedConfig bundles the parsed YAML file and the derived session config.
type loadedConfig struct {
scfg session.Config
profiles []supervisor.Profile
failover failoverConfig
dataDir string
debug bool
ffmpegPath string
}
type failoverConfig struct {
retryDelay time.Duration
maxCycles int
}
func main() {
@@ -81,43 +72,202 @@ func run() error {
}
func runWithArgs(args []string) error {
logger.DisableNoisyPionLogs()
installStderrFilter()
session.RegisterDefaults()
cfg, err := parseFlagsFrom(args, flag.ExitOnError)
if len(args) != 1 || args[0] == "-h" || args[0] == "--help" || args[0] == "-help" {
return ErrConfigPathRequired
}
cfg, err := loadConfig(args[0])
if err != nil {
return err
}
return runWithConfig(cfg)
}
func runWithConfig(cfg config) error {
func loadConfig(path string) (loadedConfig, error) {
f, err := configpkg.Load(path)
if err != nil {
return loadedConfig{}, fmt.Errorf("load config: %w", err)
}
base := configpkg.Apply(session.Config{}, f)
profiles := make([]supervisor.Profile, 0, len(f.Profiles))
for i, profile := range f.Profiles {
name := profile.Name
if name == "" {
name = fmt.Sprintf("profile-%d", i+1)
}
profiles = append(profiles, supervisor.Profile{
Name: name,
Config: configpkg.ApplyProfile(base, profile),
})
}
failover, err := parseFailoverConfig(f.Failover)
if err != nil {
return loadedConfig{}, err
}
return loadedConfig{
scfg: base,
profiles: profiles,
failover: failover,
dataDir: f.Data,
debug: f.Debug,
ffmpegPath: f.FFmpeg,
}, nil
}
func parseFailoverConfig(f configpkg.Failover) (failoverConfig, error) {
retryDelay := supervisor.DefaultRetryDelay
if f.RetryDelay != "" {
parsed, err := time.ParseDuration(f.RetryDelay)
if err != nil {
return failoverConfig{}, fmt.Errorf("parse failover.retry_delay: %w", err)
}
retryDelay = parsed
}
return failoverConfig{retryDelay: retryDelay, maxCycles: f.MaxCycles}, nil
}
func runWithConfig(cfg loadedConfig) error {
configureLogging(cfg.debug)
if cfg.ffmpegPath != "ffmpeg" && cfg.ffmpegPath != "" {
videochannel.FFmpegPath = cfg.ffmpegPath
}
if cfg.mode == modeGen {
return runGen(cfg)
scfg, err := session.ApplyAuthDefaults(cfg.scfg)
if err != nil {
return fmt.Errorf("validate config: %w", err)
}
scfg = session.ApplyTransportDefaults(scfg)
scfg = session.ApplyLivenessDefaults(scfg)
if scfg.Mode == modeGen {
if len(cfg.profiles) > 0 {
return ErrProfilesUnsupportedForGen
}
return runGen(scfg)
}
if err := session.Validate(toSessionConfig(cfg)); err != nil {
if len(cfg.profiles) > 0 {
profiles, err := prepareProfiles(cfg.profiles)
if err != nil {
return err
}
return runFailoverSessionMode(cfg.dataDir, profiles, cfg.failover)
}
return runSessionMode(cfg.dataDir, scfg)
}
func prepareProfiles(profiles []supervisor.Profile) ([]supervisor.Profile, error) {
out := make([]supervisor.Profile, 0, len(profiles))
for _, profile := range profiles {
scfg, err := session.ApplyAuthDefaults(profile.Config)
if err != nil {
return nil, fmt.Errorf("validate profile %q: %w", profile.Name, err)
}
profile.Config = session.ApplyLivenessDefaults(session.ApplyTransportDefaults(scfg))
out = append(out, profile)
}
return out, nil
}
func runSessionMode(dataDir string, scfg session.Config) error {
if err := session.Validate(scfg); err != nil {
return fmt.Errorf("validate config: %w", err)
}
if cfg.dataDir == "" {
if err := prepareRuntimeData(dataDir); err != nil {
return err
}
return runManaged(func(ctx context.Context) error {
return runSession(ctx, scfg)
})
}
func runFailoverSessionMode(dataDir string, profiles []supervisor.Profile, failover failoverConfig) error {
for _, profile := range profiles {
if err := session.Validate(profile.Config); err != nil {
return fmt.Errorf("validate profile %q: %w", profile.Name, err)
}
}
if err := prepareRuntimeData(dataDir); err != nil {
return err
}
return runManaged(func(ctx context.Context) error {
return supervisor.Run(ctx, supervisor.Config{
Profiles: profiles,
RetryDelay: failover.retryDelay,
MaxCycles: failover.maxCycles,
OnProfileStart: func(profile supervisor.Profile, cycle int) {
logger.Infof("failover cycle=%d starting profile=%s carrier=%s transport=%s",
cycle, profile.Name, profile.Config.Auth, profile.Config.Transport)
},
OnProfileEnd: func(profile supervisor.Profile, cycle int, err error) {
if err != nil {
logger.Warnf("failover cycle=%d profile=%s ended with error: %v", cycle, profile.Name, err)
return
}
logger.Warnf("failover cycle=%d profile=%s ended", cycle, profile.Name)
},
OnStatus: logFailoverStatus,
}, runSession)
})
}
func logFailoverStatus(status supervisor.Status) {
if !logger.IsVerbose() {
return
}
active := status.ActiveProfile
if active == "" {
active = "none"
}
logger.Debugf("failover status cycle=%d active=%s last_error=%q profiles=%s history=%d",
status.Cycle, active, status.LastError, formatProfileStatuses(status.Profiles), len(status.History))
}
func formatProfileStatuses(profiles []supervisor.ProfileStatus) string {
if len(profiles) == 0 {
return "[]"
}
var buf bytes.Buffer
buf.WriteByte('[')
for i, profile := range profiles {
if i > 0 {
buf.WriteByte(' ')
}
fmt.Fprintf(&buf, "%s{starts=%d failures=%d clean=%d}",
profile.Name, profile.Starts, profile.Failures, profile.CleanEnds)
}
buf.WriteByte(']')
return buf.String()
}
func prepareRuntimeData(dataDir string) error {
if dataDir == "" {
return ErrDataDirRequired
}
dataDir, err := resolveDataDir(cfg.dataDir)
resolvedDataDir, err := resolveDataDir(dataDir)
if err != nil {
return err
}
if err := loadNames(dataDir); err != nil {
if err := loadNames(resolvedDataDir); err != nil {
return err
}
return nil
}
func runManaged(run func(context.Context) error) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -126,7 +276,7 @@ func runWithConfig(cfg config) error {
errCh := make(chan error, 1)
go func() {
errCh <- runSession(ctx, toSessionConfig(cfg))
errCh <- run(ctx)
}()
select {
@@ -139,8 +289,7 @@ func runWithConfig(cfg config) error {
}
}
func execGen(cfg config) error {
scfg := toSessionConfig(cfg)
func execGen(scfg session.Config) error {
if err := session.ValidateGen(scfg); err != nil {
return fmt.Errorf("validate gen config: %w", err)
}
@@ -165,64 +314,44 @@ func execGen(cfg config) error {
}
}
func parseFlagsFrom(args []string, errorHandling flag.ErrorHandling) (config, error) {
cfg := config{}
fs := flag.NewFlagSet("olcrtc", errorHandling)
if errorHandling == flag.ContinueOnError {
fs.SetOutput(io.Discard)
// noisyPrefixes lists log prefixes from third-party libs that spam via std log.
var noisyPrefixes = [][]byte{ //nolint:gochecknoglobals // package-level filter list
[]byte("turnc"), []byte("[turn]"), []byte("Fail to refresh permissions"),
}
// filteredWriter wraps an io.Writer and drops lines whose prefix matches noisyPrefixes.
type filteredWriter struct{ w io.Writer }
func (f filteredWriter) Write(p []byte) (int, error) {
for _, prefix := range noisyPrefixes {
if bytes.Contains(p, prefix) {
return len(p), nil
}
}
fs.StringVar(&cfg.mode, "mode", "", "Mode: srv or cnc")
fs.StringVar(&cfg.link, "link", "", "Link: direct (p2p connection type)")
fs.StringVar(&cfg.transport, "transport", "", "Transport: datachannel, videochannel, seichannel")
fs.StringVar(&cfg.carrier, "carrier", "", "Carrier: telemost, jazz, wbstream")
fs.StringVar(&cfg.roomID, "id", "", "Room ID")
fs.StringVar(&cfg.clientID, "client-id", "", "Client ID: binds one srv to one cnc (required)")
fs.IntVar(&cfg.socksPort, "socks-port", 0, "SOCKS5 port (client only)")
fs.StringVar(&cfg.socksHost, "socks-host", "", "SOCKS5 listen host (client only)")
fs.StringVar(&cfg.socksUser, "socks-user", "", "SOCKS5 username for incoming connections (client only, optional)")
fs.StringVar(&cfg.socksPass, "socks-pass", "", "SOCKS5 password for incoming connections (client only, optional)")
fs.StringVar(&cfg.keyHex, "key", "", "Shared encryption key (hex)")
fs.BoolVar(&cfg.debug, "debug", false, "Enable verbose logging")
fs.StringVar(&cfg.dataDir, "data", "", "Path to data directory")
fs.StringVar(&cfg.dnsServer, "dns", "", "DNS server (e.g. 1.1.1.1:53)")
fs.StringVar(&cfg.socksProxyAddr, "socks-proxy", "", "SOCKS5 proxy address (server only)")
fs.IntVar(&cfg.socksProxyPort, "socks-proxy-port", 0, "SOCKS5 proxy port (server only)")
fs.IntVar(&cfg.videoWidth, "video-w", 0, "Video logical width (videochannel only)")
fs.IntVar(&cfg.videoHeight, "video-h", 0, "Video logical height (videochannel only)")
fs.IntVar(&cfg.videoFPS, "video-fps", 0, "Video frames per second (videochannel only)")
fs.StringVar(&cfg.videoBitrate, "video-bitrate", "", "Video bitrate (videochannel only)")
fs.StringVar(&cfg.videoHW, "video-hw", "", "Hardware acceleration (none, nvenc)")
fs.IntVar(&cfg.videoQRSize, "video-qr-size", 0, "Video QR code fragment size (videochannel only)")
fs.StringVar(&cfg.videoQRRecovery, "video-qr-recovery", "low",
"QR error correction: low (7%), medium (15%), high (25%), highest (30%)")
fs.StringVar(&cfg.videoCodec, "video-codec", "qrcode", "Visual codec: qrcode or tile")
fs.IntVar(&cfg.videoTileModule, "video-tile-module", 0,
"Tile module size in pixels 1..270 (videochannel tile only, default 4)")
fs.IntVar(&cfg.videoTileRS, "video-tile-rs", 0,
"Tile Reed-Solomon parity percent 0..200 (videochannel tile only, default 20)")
fs.IntVar(&cfg.vp8FPS, "vp8-fps", 0, "VP8 frames per second (vp8channel only, default 25)")
fs.IntVar(&cfg.vp8BatchSize, "vp8-batch", 0, "VP8 frames per tick (vp8channel only, default 1)")
fs.IntVar(&cfg.seiFPS, "fps", 0, "Frames per second for transports that use video timing (seichannel)")
fs.IntVar(&cfg.seiBatchSize, "batch", 0, "Transport frames per tick for batched transports (seichannel)")
fs.IntVar(&cfg.seiFragmentSize, "frag", 0, "Fragment size in bytes for fragmented transports (seichannel)")
fs.IntVar(&cfg.seiAckTimeoutMS, "ack-ms", 0, "ACK timeout in milliseconds for reliable visual transports (seichannel)")
fs.IntVar(&cfg.amount, "amount", 0, "Number of rooms to generate (gen mode only)")
fs.StringVar(&cfg.ffmpegPath, "ffmpeg", "ffmpeg", "Path to ffmpeg executable")
if err := fs.Parse(args); err != nil {
return cfg, fmt.Errorf("parse flags: %w", err)
n, err := f.w.Write(p)
if err != nil {
return n, fmt.Errorf("log write: %w", err)
}
return n, nil
}
return cfg, nil
func isNoisyLogLine(line []byte) bool {
for _, prefix := range noisyPrefixes {
if bytes.Contains(line, prefix) {
return true
}
}
return false
}
func configureLogging(debug bool) {
installStderrFilter()
log.SetOutput(filteredWriter{w: os.Stderr})
logger.DisableNoisyPionLogs()
if debug {
logger.SetVerbose(true)
return
}
// Suppress noisy LiveKit/pion logs unless debug is enabled.
_ = os.Setenv("PION_LOG_DISABLE", "all")
lksdk.SetLogger(protoLogger.GetDiscardLogger())
}
@@ -250,42 +379,6 @@ func loadNames(dataDir string) error {
return nil
}
func toSessionConfig(cfg config) session.Config {
return session.Config{
Mode: cfg.mode,
Link: cfg.link,
Transport: cfg.transport,
Carrier: cfg.carrier,
RoomID: cfg.roomID,
ClientID: cfg.clientID,
KeyHex: cfg.keyHex,
SOCKSHost: cfg.socksHost,
SOCKSPort: cfg.socksPort,
SOCKSUser: cfg.socksUser,
SOCKSPass: cfg.socksPass,
DNSServer: cfg.dnsServer,
SOCKSProxyAddr: cfg.socksProxyAddr,
SOCKSProxyPort: cfg.socksProxyPort,
VideoWidth: cfg.videoWidth,
VideoHeight: cfg.videoHeight,
VideoFPS: cfg.videoFPS,
VideoBitrate: cfg.videoBitrate,
VideoHW: cfg.videoHW,
VideoQRSize: cfg.videoQRSize,
VideoQRRecovery: cfg.videoQRRecovery,
VideoCodec: cfg.videoCodec,
VideoTileModule: cfg.videoTileModule,
VideoTileRS: cfg.videoTileRS,
VP8FPS: cfg.vp8FPS,
VP8BatchSize: cfg.vp8BatchSize,
SEIFPS: cfg.seiFPS,
SEIBatchSize: cfg.seiBatchSize,
SEIFragmentSize: cfg.seiFragmentSize,
SEIAckTimeoutMS: cfg.seiAckTimeoutMS,
Amount: cfg.amount,
}
}
func waitForShutdown(errCh <-chan error) error {
done := make(chan error, 1)
go func() {

View File

@@ -3,121 +3,56 @@ package main
import (
"context"
"errors"
"flag"
"os"
"path/filepath"
"testing"
"github.com/openlibrecommunity/olcrtc/internal/app/session"
"github.com/openlibrecommunity/olcrtc/internal/logger"
"github.com/openlibrecommunity/olcrtc/internal/supervisor"
)
var errBoom = errors.New("boom")
//nolint:cyclop // table-driven test naturally has many branches
func TestToSessionConfig(t *testing.T) {
cfg := config{
mode: "cnc",
link: "direct", //nolint:goconst // test literal, repetition is intentional
transport: "vp8channel",
carrier: "jazz", //nolint:goconst // test literal, repetition is intentional
roomID: "room", //nolint:goconst // test literal, repetition is intentional
clientID: "client", //nolint:goconst // test literal, repetition is intentional
keyHex: "key", //nolint:goconst // test literal, repetition is intentional
socksHost: "127.0.0.1",
socksPort: 1080,
dnsServer: "1.1.1.1:53", //nolint:goconst // test literal, repetition is intentional
socksProxyAddr: "proxy",
socksProxyPort: 1081,
videoWidth: 640,
videoHeight: 480,
videoFPS: 30,
videoBitrate: "1M",
videoHW: "none",
videoQRSize: 4,
videoQRRecovery: "low",
videoCodec: "qrcode",
videoTileModule: 4,
videoTileRS: 20,
vp8FPS: 25,
vp8BatchSize: 8,
seiFPS: 40,
seiBatchSize: 3,
seiFragmentSize: 512,
seiAckTimeoutMS: 1500,
amount: 5,
}
const (
testAuthWBStream = "wbstream"
testDNSServer = "8.8.8.8:53"
)
got := toSessionConfig(cfg)
if got.Mode != cfg.mode || got.Carrier != "jazz" || got.SOCKSPort != cfg.socksPort ||
got.VideoTileRS != cfg.videoTileRS || got.VP8BatchSize != cfg.vp8BatchSize ||
got.SEIFPS != cfg.seiFPS || got.SEIBatchSize != cfg.seiBatchSize ||
got.SEIFragmentSize != cfg.seiFragmentSize || got.SEIAckTimeoutMS != cfg.seiAckTimeoutMS ||
got.Amount != cfg.amount {
t.Fatalf("toSessionConfig() = %+v", got)
func writeYAML(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "olcrtc.yaml")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write yaml: %v", err)
}
return path
}
//nolint:cyclop // table-driven test naturally has many branches
func TestParseFlagsFrom(t *testing.T) {
cfg, err := parseFlagsFrom([]string{
"-mode", "srv", //nolint:goconst // test literal, repetition is intentional
"-link", "direct",
"-transport", "vp8channel",
"-carrier", "telemost",
"-id", "room",
"-client-id", "client",
"-socks-port", "1080",
"-socks-host", "127.0.0.1",
"-key", "key",
"-debug",
"-data", "data",
"-dns", "9.9.9.9:53",
"-socks-proxy", "proxy",
"-socks-proxy-port", "1081",
"-video-w", "640",
"-video-h", "480",
"-video-fps", "30",
"-video-bitrate", "1M",
"-video-hw", "none",
"-video-qr-size", "128",
"-video-qr-recovery", "high",
"-video-codec", "tile",
"-video-tile-module", "6",
"-video-tile-rs", "40",
"-vp8-fps", "24",
"-vp8-batch", "3",
"-fps", "40",
"-batch", "4",
"-frag", "512",
"-ack-ms", "1500",
"-amount", "7",
}, flag.ContinueOnError)
if err != nil {
t.Fatalf("parseFlagsFrom() error = %v", err)
func TestRunWithArgsRequiresConfig(t *testing.T) {
session.RegisterDefaults()
if err := runWithArgs(nil); !errors.Is(err, ErrConfigPathRequired) {
t.Fatalf("runWithArgs(nil) = %v, want %v", err, ErrConfigPathRequired)
}
if cfg.mode != "srv" || cfg.carrier != "telemost" || cfg.roomID != "room" ||
cfg.debug != true || cfg.videoCodec != "tile" || cfg.videoTileRS != 40 ||
cfg.vp8FPS != 24 || cfg.vp8BatchSize != 3 || cfg.seiFPS != 40 ||
cfg.seiBatchSize != 4 || cfg.seiFragmentSize != 512 || cfg.seiAckTimeoutMS != 1500 ||
cfg.amount != 7 {
t.Fatalf("parseFlagsFrom() = %+v", cfg)
if err := runWithArgs([]string{"-h"}); !errors.Is(err, ErrConfigPathRequired) {
t.Fatalf("runWithArgs(-h) = %v, want %v", err, ErrConfigPathRequired)
}
_, err = parseFlagsFrom([]string{"-bad"}, flag.ContinueOnError)
if err == nil {
t.Fatal("parseFlagsFrom(bad flag) error = nil")
if err := runWithArgs([]string{"a.yaml", "b.yaml"}); !errors.Is(err, ErrConfigPathRequired) {
t.Fatalf("runWithArgs(two args) = %v, want %v", err, ErrConfigPathRequired)
}
}
func TestRunGenModeValidationErrors(t *testing.T) {
session.RegisterDefaults()
if err := runWithConfig(config{mode: "gen"}); err == nil { //nolint:goconst // test literal, repetition is intentional
if err := runWithConfig(loadedConfig{scfg: session.Config{Mode: modeGen}}); err == nil {
t.Fatal("runWithConfig(gen, no carrier) error = nil")
}
if err := runWithConfig(config{mode: "gen", carrier: "wbstream", dnsServer: "1.1.1.1:53"}); err == nil { //nolint:goconst,lll // test literal, repetition is intentional
cfg := loadedConfig{scfg: session.Config{
Mode: modeGen, Auth: testAuthWBStream, DNSServer: testDNSServer,
}}
if err := runWithConfig(cfg); err == nil {
t.Fatal("runWithConfig(gen, amount=0) error = nil")
}
}
@@ -128,16 +63,18 @@ func TestRunGenModeCallsGen(t *testing.T) {
var collected []string
oldRunGen := runGen
t.Cleanup(func() { runGen = oldRunGen })
runGen = func(cfg config) error {
if cfg.carrier != "wbstream" || cfg.dnsServer != "1.1.1.1:53" || cfg.amount != 3 {
t.Fatalf("runGen cfg = %+v", cfg)
runGen = func(scfg session.Config) error {
if scfg.Auth != testAuthWBStream || scfg.DNSServer != testDNSServer || scfg.Amount != 3 {
t.Fatalf("runGen scfg = %+v", scfg)
}
collected = append(collected, "ok")
return nil
}
err := runWithConfig(config{mode: "gen", carrier: "wbstream", dnsServer: "1.1.1.1:53", amount: 3})
if err != nil {
cfg := loadedConfig{scfg: session.Config{
Mode: modeGen, Auth: testAuthWBStream, DNSServer: testDNSServer, Amount: 3,
}}
if err := runWithConfig(cfg); err != nil {
t.Fatalf("runWithConfig(gen) error = %v", err)
}
if len(collected) != 1 {
@@ -147,22 +84,20 @@ func TestRunGenModeCallsGen(t *testing.T) {
func TestRunWithConfigValidationAndDataDirErrors(t *testing.T) {
session.RegisterDefaults()
cfg := config{
mode: "srv",
link: "direct",
transport: "datachannel",
carrier: "jazz",
clientID: "client",
keyHex: "key",
dnsServer: "1.1.1.1:53",
videoCodec: "qrcode",
scfg := session.Config{
Mode: "srv",
Transport: "datachannel",
Auth: "jitsi",
RoomID: "https://meet.cryptopro.ru/test",
KeyHex: "key",
DNSServer: "8.8.8.8:53",
}
if err := runWithConfig(cfg); !errors.Is(err, ErrDataDirRequired) {
if err := runWithConfig(loadedConfig{scfg: scfg}); !errors.Is(err, ErrDataDirRequired) {
t.Fatalf("runWithConfig(no data dir) = %v, want %v", err, ErrDataDirRequired)
}
cfg.mode = ""
if err := runWithConfig(cfg); err == nil {
scfg.Mode = ""
if err := runWithConfig(loadedConfig{scfg: scfg}); err == nil {
t.Fatal("runWithConfig(invalid config) error = nil")
}
}
@@ -183,7 +118,7 @@ func TestRunWithArgsSuccessfulSessionReturn(t *testing.T) {
called := false
runSession = func(ctx context.Context, cfg session.Config) error {
called = true
if cfg.Mode != "srv" || cfg.Carrier != "jazz" || cfg.ClientID != "client" {
if cfg.Mode != "srv" || cfg.Auth != "jitsi" {
t.Fatalf("session config = %+v", cfg)
}
select {
@@ -194,17 +129,22 @@ func TestRunWithArgsSuccessfulSessionReturn(t *testing.T) {
return nil
}
err := runWithArgs([]string{
"-mode", "srv",
"-link", "direct",
"-transport", "datachannel",
"-carrier", "jazz",
"-client-id", "client",
"-key", "key",
"-dns", "1.1.1.1:53",
"-data", dir,
})
if err != nil {
yamlPath := writeYAML(t, `
mode: srv
link: direct
auth:
provider: jitsi
room:
id: https://meet.cryptopro.ru/test
crypto:
key: key
net:
transport: datachannel
dns: 8.8.8.8:53
data: `+dir+`
`)
if err := runWithArgs([]string{yamlPath}); err != nil {
t.Fatalf("runWithArgs() error = %v", err)
}
if !called {
@@ -212,6 +152,112 @@ func TestRunWithArgsSuccessfulSessionReturn(t *testing.T) {
}
}
func TestRunWithArgsAppliesTransportDefaults(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "names"), []byte("A\n"), 0o600); err != nil {
t.Fatalf("WriteFile(names) error = %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "surnames"), []byte("B\n"), 0o600); err != nil {
t.Fatalf("WriteFile(surnames) error = %v", err)
}
oldRunSession := runSession
t.Cleanup(func() { runSession = oldRunSession })
runSession = func(_ context.Context, cfg session.Config) error {
if cfg.VP8.FPS != 60 || cfg.VP8.BatchSize != 64 {
t.Errorf("VP8 defaults = fps %d batch %d, want 60/64", cfg.VP8.FPS, cfg.VP8.BatchSize)
}
return nil
}
yamlPath := writeYAML(t, `
mode: srv
link: direct
auth:
provider: wbstream
room:
id: room
crypto:
key: key
net:
transport: vp8channel
dns: 8.8.8.8:53
data: `+dir+`
`)
if err := runWithArgs([]string{yamlPath}); err != nil {
t.Fatalf("runWithArgs() error = %v", err)
}
}
func TestRunWithArgsFailoverProfiles(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "names"), []byte("A\n"), 0o600); err != nil {
t.Fatalf("WriteFile(names) error = %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "surnames"), []byte("B\n"), 0o600); err != nil {
t.Fatalf("WriteFile(surnames) error = %v", err)
}
oldRunSession := runSession
t.Cleanup(func() { runSession = oldRunSession })
var seen []string
runSession = func(_ context.Context, cfg session.Config) error {
seen = append(seen, cfg.Auth+"/"+cfg.Transport)
if cfg.Auth == "wbstream" && (cfg.VP8.FPS != 60 || cfg.VP8.BatchSize != 64) {
t.Errorf("VP8 defaults = fps %d batch %d, want 60/64", cfg.VP8.FPS, cfg.VP8.BatchSize)
}
return errBoom
}
yamlPath := writeYAML(t, `
mode: srv
link: direct
crypto:
key: key
net:
dns: 8.8.8.8:53
profiles:
- name: wb-primary
auth:
provider: wbstream
room:
id: room
net:
transport: vp8channel
- name: jitsi-backup
auth:
provider: jitsi
room:
id: https://meet.example/room
net:
transport: datachannel
failover:
retry_delay: -1ns
max_cycles: 1
data: `+dir+`
`)
err := runWithArgs([]string{yamlPath})
if !errors.Is(err, supervisor.ErrMaxCyclesExceeded) {
t.Fatalf("runWithArgs() error = %v, want %v", err, supervisor.ErrMaxCyclesExceeded)
}
want := []string{"wbstream/vp8channel", "jitsi/datachannel"}
if !equalStrings(seen, want) {
t.Fatalf("seen profiles = %v, want %v", seen, want)
}
}
func TestRunWithConfigRejectsProfilesInGenMode(t *testing.T) {
cfg := loadedConfig{
scfg: session.Config{Mode: modeGen},
profiles: []supervisor.Profile{{Name: "one"}},
}
if err := runWithConfig(cfg); !errors.Is(err, ErrProfilesUnsupportedForGen) {
t.Fatalf("runWithConfig() error = %v, want %v", err, ErrProfilesUnsupportedForGen)
}
}
func TestConfigureLogging(t *testing.T) {
t.Setenv("PION_LOG_DISABLE", "")
logger.SetVerbose(false)
@@ -219,8 +265,8 @@ func TestConfigureLogging(t *testing.T) {
if !logger.IsVerbose() {
t.Fatal("configureLogging(true) did not enable verbose logging")
}
if got := os.Getenv("PION_LOG_DISABLE"); got != "" {
t.Fatalf("configureLogging(true) PION_LOG_DISABLE = %q, want empty", got)
if got := os.Getenv("PION_LOG_DISABLE"); got != "turnc" {
t.Fatalf("configureLogging(true) PION_LOG_DISABLE = %q, want turnc", got)
}
logger.SetVerbose(false)
@@ -233,6 +279,18 @@ func TestConfigureLogging(t *testing.T) {
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestResolveDataDir(t *testing.T) {
abs := filepath.Join(t.TempDir(), "data")
got, err := resolveDataDir(abs)

View File

@@ -0,0 +1,54 @@
//go:build !windows
package main
import (
"bufio"
"io"
"os"
"sync"
"golang.org/x/sys/unix"
)
var stderrFilterOnce sync.Once //nolint:gochecknoglobals // process-wide stderr fd filter
func installStderrFilter() {
stderrFilterOnce.Do(func() {
origFD, err := unix.Dup(int(os.Stderr.Fd()))
if err != nil {
return
}
reader, writer, err := os.Pipe()
if err != nil {
_ = unix.Close(origFD)
return
}
if err := unix.Dup2(int(writer.Fd()), int(os.Stderr.Fd())); err != nil {
_ = reader.Close()
_ = writer.Close()
_ = unix.Close(origFD)
return
}
_ = writer.Close()
os.Stderr = os.NewFile(uintptr(unix.Stderr), "/dev/stderr")
orig := os.NewFile(uintptr(origFD), "/dev/stderr-original")
go copyFilteredStderr(reader, orig)
})
}
func copyFilteredStderr(reader *os.File, out io.Writer) {
defer func() { _ = reader.Close() }()
br := bufio.NewReader(reader)
for {
line, err := br.ReadBytes('\n')
if len(line) > 0 && !isNoisyLogLine(line) {
if _, writeErr := out.Write(line); writeErr != nil {
return
}
}
if err != nil {
return
}
}
}

View File

@@ -0,0 +1,5 @@
//go:build windows
package main
func installStderrFilter() {}

View File

@@ -1,59 +0,0 @@
#!/usr/bin/env python3
import asyncio
import json
import uuid
import aiohttp
API_BASE = "https://bk.salutejazz.ru"
JAZZ_HEADERS = {"X-Jazz-ClientId": str(uuid.uuid4()), "X-Jazz-AuthType": "ANONYMOUS", "X-Client-AuthType": "ANONYMOUS", "Content-Type": "application/json"}
async def get_jazz_info():
print("\n--- SaluteJazz Info ---")
timeout = aiohttp.ClientTimeout(total=15)
async with aiohttp.ClientSession(timeout=timeout) as session:
print("[1/4] API Initialization...")
try:
r = await session.post(f"{API_BASE}/room/create-meeting", headers=JAZZ_HEADERS, json={"title": "InfoBot", "guestEnabled": True, "lobbyEnabled": False, "room3dEnabled": False})
rj = await r.json()
print(" :P Room created")
print(json.dumps(rj, indent=2))
r2 = await session.post(f"{API_BASE}/room/{rj['roomId']}/preconnect", headers=JAZZ_HEADERS, json={"password": rj["password"], "jazzNextMigration": {"b2bBaseRoomSupport": True, "sdkRoomSupport": True, "mediaWithoutAutoSubscribeSupport": True}})
r2j = await r2.json()
print(" :P Preconnect info received")
print(json.dumps(r2j, indent=2))
conn_url = r2j['connectorUrl']
except Exception as e:
print(f" X Error: {e}"); return
print(f"\n[2/4] Connecting to signaling...")
async with session.ws_connect(conn_url) as ws:
await ws.send_json({"roomId": rj["roomId"], "event": "join", "requestId": str(uuid.uuid4()), "payload": {"password": rj["password"], "participantName": "InfoBot", "supportedFeatures": {"attachedRooms": True}, "isSilent": False}})
print(" :P Signaling established")
print("\n[3/4] Collecting network & media details...")
end = asyncio.get_event_loop().time() + 8
while asyncio.get_event_loop().time() < end:
try:
m = await asyncio.wait_for(ws.receive(), 1)
if m.type == aiohttp.WSMsgType.TEXT:
d = json.loads(m.data); ev = d.get("event", ""); p = d.get("payload", {}); meth = p.get("method", "")
print(f" -> Event: {ev}{' ('+meth+')' if meth else ''}")
if meth == "rtc:config":
print("\n--- ICE Servers ---")
print(json.dumps(p.get("configuration", {}).get("iceServers", []), indent=2))
elif meth == "rtc:offer":
print("\n--- SDP Offer (Codecs & Quality) ---")
print(p.get("description", {}).get("sdp", ""))
elif ev == "join-response":
print("\n--- Participant Group ---")
print(json.dumps(p.get("participantGroup", {}), indent=2))
else:
print(json.dumps(p, indent=2))
except: continue
print("\n--- INFO COLLECTION COMPLETE ---")
if __name__ == "__main__":
try: asyncio.run(get_jazz_info())
except KeyboardInterrupt: pass

View File

@@ -1,241 +0,0 @@
#!/usr/bin/env python3
"""PoC: SaluteJazz DataChannel over LiveKit."""
import asyncio
import io
import json
import logging
import time
import uuid
import aiohttp
from aiortc import RTCConfiguration, RTCIceCandidate, RTCIceServer, RTCPeerConnection, RTCSessionDescription
from aiortc.mediastreams import AudioStreamTrack
from aiortc.rtcconfiguration import RTCBundlePolicy
logging.getLogger("aiortc").setLevel(logging.WARNING)
API_BASE = "https://bk.salutejazz.ru"
JAZZ_HEADERS = {"X-Jazz-ClientId": str(uuid.uuid4()), "X-Jazz-AuthType": "ANONYMOUS", "X-Client-AuthType": "ANONYMOUS", "Content-Type": "application/json"}
TEST_MESSAGES = ["Hello Jazz DC!", "Hello world", "X" * 100, "Final test"]
def _pb_varint(v: int) -> bytes:
b = bytearray()
while v > 0x7F: b.append((v & 0x7F) | 0x80); v >>= 7
b.append(v & 0x7F)
return bytes(b)
def _pb_field(f: int, w: int, d: bytes) -> bytes:
t = _pb_varint((f << 3) | w)
return t + d if w == 0 else (t + _pb_varint(len(d)) + d if w == 2 else t + d)
def _read_varint(s: io.BytesIO) -> int | None:
res, shift = 0, 0
while b := s.read(1):
res |= (b[0] & 0x7F) << shift
if not (b[0] & 0x80): return res
shift += 7
return None
def encode_data_packet(payload: bytes, topic: str = "") -> bytes:
uf = _pb_field(2, 2, payload) + (_pb_field(4, 2, topic.encode()) if topic else b"") + _pb_field(8, 2, str(uuid.uuid4()).encode())
return _pb_field(1, 0, _pb_varint(0)) + _pb_field(2, 2, uf)
def decode_data_packet(raw: bytes) -> tuple[bytes, str] | None:
s = io.BytesIO(raw)
ud = None
while (tg := _read_varint(s)) is not None:
wt = tg & 0x07
if wt == 0: _read_varint(s)
elif wt == 2:
l = _read_varint(s)
if l is None: break
d = s.read(l)
if (tg >> 3) == 2: ud = d
elif wt == 1: s.read(8)
elif wt == 5: s.read(4)
else: break
if ud is None: return None
p, t, ins = b"", "", io.BytesIO(ud)
while (tg := _read_varint(ins)) is not None:
wt = tg & 0x07
if wt == 0: _read_varint(ins)
elif wt == 2:
l = _read_varint(ins)
if l is None: break
d = ins.read(l)
fn = tg >> 3
if fn == 2: p = d
elif fn == 4: t = d.decode(errors="replace")
elif wt == 1: ins.read(8)
elif wt == 5: ins.read(4)
else: break
return p, t
async def _create_peer(name: str, room: dict, session: aiohttp.ClientSession, is_server: bool = False, stats: dict = None) -> dict:
ws = await session.ws_connect(room["connectorUrl"])
await ws.send_json({"roomId": room["roomId"], "event": "join", "requestId": str(uuid.uuid4()), "payload": {"password": room["password"], "participantName": name, "supportedFeatures": {"attachedRooms": True, "sessionGroups": True}, "isSilent": False}})
peer = {"ws": ws, "pc_sub": None, "pc_pub": None, "dc": None, "ready": asyncio.Event(), "sub_ready": asyncio.Event()}
group_id, p_ice_sub, p_ice_pub = None, [], []
ice_servers = []
async def ws_loop():
nonlocal group_id
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
data = json.loads(msg.data)
ev = data.get("event", "")
p = data.get("payload", {})
m = p.get("method", "")
if ev == "join-response": group_id = p.get("participantGroup", {}).get("groupId")
elif ev == "media-out" and m == "rtc:config":
for s in p.get("configuration", {}).get("iceServers", []):
urls = [u for u in s.get("urls", []) if "transport=udp" in u]
if urls: ice_servers.append(RTCIceServer(urls=[urls[0]], username=s.get("username"), credential=s.get("credential")))
elif ev == "media-out" and m == "rtc:offer" and not peer["pc_sub"]:
peer["pc_sub"] = RTCPeerConnection(configuration=RTCConfiguration(iceServers=ice_servers, bundlePolicy=RTCBundlePolicy.MAX_BUNDLE))
@peer["pc_sub"].on("connectionstatechange")
def _():
if peer["pc_sub"].connectionState == "connected": peer["sub_ready"].set()
@peer["pc_sub"].on("datachannel")
def on_dc(ch):
if ch.label != "_reliable": return
@ch.on("message")
def on_msg(msg_data):
parsed = decode_data_packet(msg_data if isinstance(msg_data, bytes) else msg_data.encode())
if not parsed or parsed[1] != "poc": return
stats["recv"] += 1
if is_server and peer["dc"]:
try:
peer["dc"].send(encode_data_packet(f"Echo: {parsed[0].decode()}".encode(), "poc"))
stats["sent"] += 1
except: pass
@peer["pc_sub"].on("icecandidate")
async def on_sub_ice(e):
if e and e.candidate and group_id:
await ws.send_json({"roomId": room["roomId"], "event": "media-in", "groupId": group_id, "requestId": str(uuid.uuid4()), "payload": {"method": "rtc:ice", "rtcIceCandidates": [{"candidate": e.candidate.candidate, "sdpMid": e.candidate.sdpMid, "sdpMLineIndex": e.candidate.sdpMLineIndex, "usernameFragment": "", "target": "SUBSCRIBER"}]}})
await peer["pc_sub"].setRemoteDescription(RTCSessionDescription(sdp=p["description"]["sdp"], type="offer"))
ans = await peer["pc_sub"].createAnswer()
await peer["pc_sub"].setLocalDescription(ans)
await ws.send_json({"roomId": room["roomId"], "event": "media-in", "groupId": group_id, "requestId": str(uuid.uuid4()), "payload": {"method": "rtc:answer", "description": {"type": "answer", "sdp": peer["pc_sub"].localDescription.sdp}}})
for c in p_ice_sub:
pts = c.get("candidate","").split()
if len(pts) >= 8: await peer["pc_sub"].addIceCandidate(RTCIceCandidate(int(pts[1]), pts[0].split(":")[1], pts[4], int(pts[5]), int(pts[3]), pts[2], pts[7], str(c.get("sdpMid", "0")), c.get("sdpMLineIndex", 0)))
p_ice_sub.clear()
await asyncio.sleep(0.3)
peer["pc_pub"] = RTCPeerConnection(configuration=RTCConfiguration(iceServers=ice_servers, bundlePolicy=RTCBundlePolicy.MAX_BUNDLE))
peer["pc_pub"].addTrack(AudioStreamTrack())
peer["dc"] = peer["pc_pub"].createDataChannel("_reliable", ordered=True)
@peer["dc"].on("open")
def on_open(): peer["ready"].set()
@peer["dc"].on("message")
def on_pub_msg(msg_data):
parsed = decode_data_packet(msg_data if isinstance(msg_data, bytes) else msg_data.encode())
if parsed and parsed[1] == "poc": stats["recv"] += 1
@peer["pc_pub"].on("icecandidate")
async def on_pub_ice(e):
if e and e.candidate and group_id:
await ws.send_json({"roomId": room["roomId"], "event": "media-in", "groupId": group_id, "requestId": str(uuid.uuid4()), "payload": {"method": "rtc:ice", "rtcIceCandidates": [{"candidate": e.candidate.candidate, "sdpMid": e.candidate.sdpMid, "sdpMLineIndex": e.candidate.sdpMLineIndex, "usernameFragment": "", "target": "PUBLISHER"}]}})
await ws.send_json({"roomId": room["roomId"], "event": "media-in", "groupId": group_id, "requestId": str(uuid.uuid4()), "payload": {"method": "rtc:track:add", "cid": str(uuid.uuid4()), "track": {"type": "AUDIO", "source": "MICROPHONE", "muted": True}}})
pub_offer = await peer["pc_pub"].createOffer()
await peer["pc_pub"].setLocalDescription(pub_offer)
await ws.send_json({"roomId": room["roomId"], "event": "media-in", "groupId": group_id, "requestId": str(uuid.uuid4()), "payload": {"method": "rtc:offer", "description": {"type": "offer", "sdp": peer["pc_pub"].localDescription.sdp}}})
elif ev == "media-out" and m == "rtc:answer" and peer["pc_pub"]:
await peer["pc_pub"].setRemoteDescription(RTCSessionDescription(sdp=p["description"]["sdp"], type="answer"))
for c in p_ice_pub:
pts = c.get("candidate","").split()
if len(pts) >= 8: await peer["pc_pub"].addIceCandidate(RTCIceCandidate(int(pts[1]), pts[0].split(":")[1], pts[4], int(pts[5]), int(pts[3]), pts[2], pts[7], str(c.get("sdpMid", "0")), c.get("sdpMLineIndex", 0)))
p_ice_pub.clear()
elif ev == "media-out" and m == "rtc:ice":
for c in p.get("rtcIceCandidates", []):
pts = c.get("candidate","").split()
if len(pts) < 8: continue
ice = RTCIceCandidate(int(pts[1]), pts[0].split(":")[1], pts[4], int(pts[5]), int(pts[3]), pts[2], pts[7], str(c.get("sdpMid", "0")), c.get("sdpMLineIndex", 0))
tgt = c.get("target")
if tgt == "SUBSCRIBER": (await peer["pc_sub"].addIceCandidate(ice)) if peer["pc_sub"] else p_ice_sub.append(c)
elif tgt == "PUBLISHER": (await peer["pc_pub"].addIceCandidate(ice)) if peer["pc_pub"] else p_ice_pub.append(c)
async def _keep():
while not ws.closed:
await asyncio.sleep(5)
if group_id: await ws.send_json({"roomId": room["roomId"], "event": "media-in", "groupId": group_id, "requestId": str(uuid.uuid4()), "payload": {"method": "rtc:ping", "ping_req": {"timestamp": int(time.time()*1000), "rtt": 0}}})
peer["task"] = asyncio.create_task(ws_loop())
peer["keep"] = asyncio.create_task(_keep())
return peer
async def run_poc() -> dict:
print("\n--- SaluteJazz PoC ---")
results = {"server_ok": False, "client_ok": False, "sent": 0, "recv": 0, "errors": []}
s_stats, c_stats = {"sent": 0, "recv": 0}, {"sent": 0, "recv": 0}
async with aiohttp.ClientSession() as session:
try:
r = await session.post(f"{API_BASE}/room/create-meeting", headers=JAZZ_HEADERS, json={"title": "PoC", "guestEnabled": True, "lobbyEnabled": False})
rj = await r.json()
r2 = await session.post(f"{API_BASE}/room/{rj['roomId']}/preconnect", headers=JAZZ_HEADERS, json={"password": rj["password"], "jazzNextMigration": {"b2bBaseRoomSupport": True, "demoRoomBaseSupport": True, "demoRoomVersionSupport": 2, "mediaWithoutAutoSubscribeSupport": True}})
room_inf = {"roomId": rj["roomId"], "password": rj["password"], "connectorUrl": (await r2.json())["connectorUrl"]}
except Exception as e:
results["errors"].append(f"Auth fail: {e}")
return results
print("[1/3] Connecting Server & Client...")
try:
server = await _create_peer("Server", room_inf, session, is_server=True, stats=s_stats)
await asyncio.wait_for(server["ready"].wait(), 15.0)
results["server_ok"] = True
client = await _create_peer("Client", room_inf, session, is_server=False, stats=c_stats)
await asyncio.wait_for(client["ready"].wait(), 15.0)
results["client_ok"] = True
print(" :P Peers connected")
except Exception as e:
results["errors"].append(str(e))
return results
print("\n[2/3] Exchanging messages...")
await asyncio.sleep(1)
for idx, msg in enumerate(TEST_MESSAGES, 1):
try:
client["dc"].send(encode_data_packet(msg.encode(), "poc"))
c_stats["sent"] += 1
print(f" -> Sent: {msg}")
await asyncio.sleep(0.5)
except Exception as e:
results["errors"].append(f"Sending {idx} failed: {str(e)}")
await asyncio.sleep(3)
results["sent"], results["recv"] = c_stats["sent"], c_stats["recv"]
print("\n[3/3] Cleaning up...")
for p in (server, client):
for t in ["task", "keep"]: p[t].cancel()
await p["ws"].close()
for pc in [p["pc_sub"], p["pc_pub"]]:
if pc: await pc.close()
return results
def print_results(res: dict):
print("\n--- TEST RESULTS ---")
print(f"Server: {':P' if res['server_ok'] else 'X'} / Client: {':P' if res['client_ok'] else 'X'}")
print(f"Messages: Sent {res['sent']} / Recv {res['recv']}")
if res['errors']:
for e in res['errors']: print(f" Error: {e}")
print(f"\n{':P SUCCESS' if res['sent'] and res['sent'] == res['recv'] else 'X FAILED'}\n")
if __name__ == "__main__":
try: res = asyncio.run(run_poc()); print_results(res)
except KeyboardInterrupt: pass

View File

@@ -2,8 +2,9 @@
"""PoC: WB Stream DataChannel over LiveKit."""
import asyncio
import base64
import json
import logging
import uuid
import requests
try:
@@ -16,7 +17,25 @@ logging.getLogger("livekit").setLevel(logging.WARNING)
API_BASE = "https://stream.wb.ru"
WS_URL = "wss://rtc-el-01.wb.ru"
TEST_MESSAGES = ["Hello WB Stream!", "Hello world", "X" * 100, "Final test"]
HARDCODED_ROOM_ID = "019e23c2-a580-7550-b08a-7ac5342ca21f"
TEST_ATTEMPTS = 60
TEST_MESSAGES = [f"WB Stream DataChannel attempt {idx:02d}" for idx in range(1, TEST_ATTEMPTS + 1)]
def _decode_jwt_payload(token: str) -> dict:
"""Decode JWT payload without verifying the signature; useful for inspecting LiveKit grants."""
try:
payload = token.split(".")[1]
payload += "=" * (-len(payload) % 4)
return json.loads(base64.urlsafe_b64decode(payload))
except Exception as exc:
return {"decode_error": str(exc)}
def _print_token_grants(label: str, token: str) -> None:
payload = _decode_jwt_payload(token)
print(f" {label} token identity={payload.get('sub')} name={payload.get('name')}")
print(f" {label} video grants={json.dumps(payload.get('video', {}), ensure_ascii=False, sort_keys=True)}")
def _get_room_token(room_id: str, display_name: str) -> tuple[str, str]:
"""Retrieves the room token via the guest API."""
@@ -45,24 +64,47 @@ def _get_room_token(room_id: str, display_name: str) -> tuple[str, str]:
async def run_poc() -> dict:
"""Runs the complete PoC flow."""
print("\n--- WB Stream PoC ---")
results = {"server_ok": False, "client_ok": False, "sent": 0, "recv": 0, "errors": []}
results = {
"server_ok": False,
"client_ok": False,
"sent": 0,
"server_recv": 0,
"echo_sent": 0,
"client_recv": 0,
"errors": [],
}
server, client = rtc.Room(), rtc.Room()
shared_room_id, _ = _get_room_token("", "OlcRTC-Server")
shared_room_id = HARDCODED_ROOM_ID
print("[1/3] Connecting Server & Client...")
try:
shared_room_id, server_tok = _get_room_token("", "OlcRTC-Server")
shared_room_id, server_tok = _get_room_token(shared_room_id, "OlcRTC-Server")
_, client_tok = _get_room_token(shared_room_id, "OlcRTC-Client")
_print_token_grants("server", server_tok)
_print_token_grants("client", client_tok)
@server.on("data_received")
def on_server_data(dp: rtc.DataPacket):
if dp.topic == "olcrtc":
asyncio.create_task(server.local_participant.publish_data(f"Echo: {dp.data.decode()}".encode(), topic="olcrtc"))
msg = dp.data.decode(errors="replace")
results["server_recv"] += 1
print(f" <- Server recv #{results['server_recv']:02d}: {msg}")
async def echo() -> None:
try:
await server.local_participant.publish_data(f"Echo: {msg}".encode(), topic="olcrtc")
results["echo_sent"] += 1
except Exception as exc:
results["errors"].append(f"Echo failed: {exc}")
asyncio.create_task(echo())
@client.on("data_received")
def on_client_data(dp: rtc.DataPacket):
results["recv"] += 1
if dp.topic == "olcrtc":
results["client_recv"] += 1
print(f" <- Client recv #{results['client_recv']:02d}: {dp.data.decode(errors='replace')}")
await server.connect(WS_URL, server_tok)
results["server_ok"] = True
@@ -96,10 +138,14 @@ async def run_poc() -> dict:
def print_results(res: dict):
print("\n--- TEST RESULTS ---")
print(f"Server: {':P' if res['server_ok'] else 'X'} / Client: {':P' if res['client_ok'] else 'X'}")
print(f"Messages: Sent {res['sent']} / Recv {res['recv']}")
print(
"Messages: "
f"Client sent {res['sent']} / Server recv {res['server_recv']} / "
f"Echo sent {res['echo_sent']} / Client recv {res['client_recv']}"
)
if res['errors']:
for e in res['errors']: print(f" Error: {e}")
print(f"\n{':P SUCCESS' if res['sent'] and res['sent'] == res['recv'] else 'X FAILED'}\n")
print(f"\n{':P SUCCESS' if res['sent'] and res['sent'] == res['client_recv'] else 'X FAILED'}\n")
if __name__ == "__main__":
try:

View File

@@ -18,8 +18,10 @@ logging.getLogger("livekit").setLevel(logging.WARNING)
API_BASE = "https://stream.wb.ru"
WS_URL = "wss://wbstream01-el.wb.ru:7880"
HARDCODED_ROOM_ID = "019e23c2-a580-7550-b08a-7ac5342ca21f"
FPS = 10
TEST_MESSAGES = ["Hello WB Stream via Video!", "Packed JSON payload test.", "X" * 200, "Final VideoChannel test"]
TEST_ATTEMPTS = 60
TEST_MESSAGES = [f"WB Stream VideoChannel attempt {idx:02d}" for idx in range(1, TEST_ATTEMPTS + 1)]
def _encode(text: str) -> np.ndarray:
@@ -72,7 +74,7 @@ async def run_poc() -> dict:
print("[1/3] Connecting peers...")
try:
shared_room_id, server_tok = _get_room_token("", "OlcRTC-Server")
shared_room_id, server_tok = _get_room_token(HARDCODED_ROOM_ID, "OlcRTC-Server")
_, client_tok = _get_room_token(shared_room_id, "OlcRTC-Client")
async def process_video_stream(stream: rtc.VideoStream):

43
docker-compose.client.yml Normal file
View File

@@ -0,0 +1,43 @@
services:
olcrtc-client:
build:
context: .
image: olcrtc/client:local
container_name: olcrtc-client
restart: unless-stopped
network_mode: host
environment:
OLCRTC_MODE: cnc
OLCRTC_CARRIER: "${OLCRTC_CARRIER:?set OLCRTC_CARRIER (jitsi, telemost, wbstream, none)}"
OLCRTC_TRANSPORT: "${OLCRTC_TRANSPORT:-datachannel}"
OLCRTC_ROOM_ID: "${OLCRTC_ROOM_ID:?set OLCRTC_ROOM_ID to the server room}"
OLCRTC_KEY: "${OLCRTC_KEY:?set OLCRTC_KEY to the server encryption key}"
OLCRTC_KEY_FILE: "${OLCRTC_KEY_FILE:-/var/lib/olcrtc/key.hex}"
OLCRTC_DNS: "${OLCRTC_DNS:-8.8.8.8:53}"
OLCRTC_SOCKS_HOST: "${OLCRTC_SOCKS_HOST:-127.0.0.1}"
OLCRTC_SOCKS_PORT: "${OLCRTC_SOCKS_PORT:-8808}"
OLCRTC_SOCKS_USER: "${OLCRTC_SOCKS_USER:-}"
OLCRTC_SOCKS_PASS: "${OLCRTC_SOCKS_PASS:-}"
OLCRTC_VIDEO_W: "${OLCRTC_VIDEO_W:-0}"
OLCRTC_VIDEO_H: "${OLCRTC_VIDEO_H:-0}"
OLCRTC_VIDEO_FPS: "${OLCRTC_VIDEO_FPS:-0}"
OLCRTC_VIDEO_BITRATE: "${OLCRTC_VIDEO_BITRATE:-}"
OLCRTC_VIDEO_HW: "${OLCRTC_VIDEO_HW:-none}"
OLCRTC_VIDEO_CODEC: "${OLCRTC_VIDEO_CODEC:-qrcode}"
OLCRTC_VIDEO_QR_SIZE: "${OLCRTC_VIDEO_QR_SIZE:-0}"
OLCRTC_VIDEO_QR_RECOVERY: "${OLCRTC_VIDEO_QR_RECOVERY:-low}"
OLCRTC_VIDEO_TILE_MODULE: "${OLCRTC_VIDEO_TILE_MODULE:-0}"
OLCRTC_VIDEO_TILE_RS: "${OLCRTC_VIDEO_TILE_RS:-0}"
OLCRTC_VP8_FPS: "${OLCRTC_VP8_FPS:-0}"
OLCRTC_VP8_BATCH: "${OLCRTC_VP8_BATCH:-0}"
OLCRTC_SEI_FPS: "${OLCRTC_SEI_FPS:-0}"
OLCRTC_SEI_BATCH: "${OLCRTC_SEI_BATCH:-0}"
OLCRTC_SEI_FRAG: "${OLCRTC_SEI_FRAG:-0}"
OLCRTC_SEI_ACK: "${OLCRTC_SEI_ACK:-0}"
OLCRTC_DEBUG: "${OLCRTC_DEBUG:-false}"
volumes:
- olcrtc-client-state:/var/lib/olcrtc
init: true
volumes:
olcrtc-client-state:

View File

@@ -6,12 +6,31 @@ services:
container_name: olcrtc-server
restart: unless-stopped
environment:
OLCRTC_CARRIER: "${OLCRTC_CARRIER:?set OLCRTC_CARRIER (telemost, jazz, wbstream)}"
OLCRTC_MODE: srv
OLCRTC_CARRIER: "${OLCRTC_CARRIER:?set OLCRTC_CARRIER (jitsi, telemost, wbstream, none)}"
OLCRTC_TRANSPORT: "${OLCRTC_TRANSPORT:-datachannel}"
OLCRTC_ROOM_ID: "${OLCRTC_ROOM_ID:-}"
OLCRTC_KEY: "${OLCRTC_KEY:-}"
OLCRTC_DNS: "${OLCRTC_DNS:-1.1.1.1:53}"
OLCRTC_KEY_FILE: "${OLCRTC_KEY_FILE:-/var/lib/olcrtc/key.hex}"
OLCRTC_DNS: "${OLCRTC_DNS:-8.8.8.8:53}"
OLCRTC_SOCKS_PROXY: "${OLCRTC_SOCKS_PROXY:-}"
OLCRTC_SOCKS_PROXY_PORT: "${OLCRTC_SOCKS_PROXY_PORT:-1080}"
OLCRTC_VIDEO_W: "${OLCRTC_VIDEO_W:-0}"
OLCRTC_VIDEO_H: "${OLCRTC_VIDEO_H:-0}"
OLCRTC_VIDEO_FPS: "${OLCRTC_VIDEO_FPS:-0}"
OLCRTC_VIDEO_BITRATE: "${OLCRTC_VIDEO_BITRATE:-}"
OLCRTC_VIDEO_HW: "${OLCRTC_VIDEO_HW:-none}"
OLCRTC_VIDEO_CODEC: "${OLCRTC_VIDEO_CODEC:-qrcode}"
OLCRTC_VIDEO_QR_SIZE: "${OLCRTC_VIDEO_QR_SIZE:-0}"
OLCRTC_VIDEO_QR_RECOVERY: "${OLCRTC_VIDEO_QR_RECOVERY:-low}"
OLCRTC_VIDEO_TILE_MODULE: "${OLCRTC_VIDEO_TILE_MODULE:-0}"
OLCRTC_VIDEO_TILE_RS: "${OLCRTC_VIDEO_TILE_RS:-0}"
OLCRTC_VP8_FPS: "${OLCRTC_VP8_FPS:-0}"
OLCRTC_VP8_BATCH: "${OLCRTC_VP8_BATCH:-0}"
OLCRTC_SEI_FPS: "${OLCRTC_SEI_FPS:-0}"
OLCRTC_SEI_BATCH: "${OLCRTC_SEI_BATCH:-0}"
OLCRTC_SEI_FRAG: "${OLCRTC_SEI_FRAG:-0}"
OLCRTC_SEI_ACK: "${OLCRTC_SEI_ACK:-0}"
OLCRTC_DEBUG: "${OLCRTC_DEBUG:-false}"
volumes:
- olcrtc-state:/var/lib/olcrtc

File diff suppressed because it is too large Load Diff

199
docs/configuration.md Normal file
View File

@@ -0,0 +1,199 @@
<div align="center">
<img src="https://github.com/openlibrecommunity/material/blob/master/olcrtc.png" width="250" height="250">
![License](https://img.shields.io/badge/license-WTFPL-0D1117?style=flat-square&logo=open-source-initiative&logoColor=green&labelColor=0D1117)
![Golang](https://img.shields.io/badge/-Golang-0D1117?style=flat-square&logo=go&logoColor=00A7D0)
</div>
# Настройка YAML
`olcrtc` читает runtime-настройки из одного YAML-файла. CLI принимает ровно один аргумент - путь к конфигу; отдельных CLI-флагов для режима, транспорта и провайдера больше нет.
```bash
olcrtc /etc/olcrtc/server.yaml
olcrtc /etc/olcrtc/client.yaml
```
Готовые примеры:
- [`server.jitsi.datachannel.yaml`](./examples/server.jitsi.datachannel.yaml) - jitsi + datachannel srv
- [`client.jitsi.datachannel.yaml`](./examples/client.jitsi.datachannel.yaml) - jitsi + datachannel cnc
- [`server.jitsi.videochannel.yaml`](./examples/server.jitsi.videochannel.yaml) - jitsi + videochannel srv
- [`client.jitsi.videochannel.yaml`](./examples/client.jitsi.videochannel.yaml) - jitsi + videochannel cnc
- [`server.jitsi.seichannel.yaml`](./examples/server.jitsi.seichannel.yaml) - jitsi + seichannel srv
- [`client.jitsi.seichannel.yaml`](./examples/client.jitsi.seichannel.yaml) - jitsi + seichannel cnc
- [`server.jitsi.vp8channel.yaml`](./examples/server.jitsi.vp8channel.yaml) - jitsi + vp8channel srv
- [`client.jitsi.vp8channel.yaml`](./examples/client.jitsi.vp8channel.yaml) - jitsi + vp8channel cnc
- [`server.telemost.datachannel.yaml`](./examples/server.telemost.datachannel.yaml) - telemost + datachannel srv
- [`client.telemost.datachannel.yaml`](./examples/client.telemost.datachannel.yaml) - telemost + datachannel cnc
- [`server.telemost.videochannel.yaml`](./examples/server.telemost.videochannel.yaml) - telemost + videochannel srv
- [`client.telemost.videochannel.yaml`](./examples/client.telemost.videochannel.yaml) - telemost + videochannel cnc
- [`server.telemost.seichannel.yaml`](./examples/server.telemost.seichannel.yaml) - telemost + seichannel srv
- [`client.telemost.seichannel.yaml`](./examples/client.telemost.seichannel.yaml) - telemost + seichannel
- [`server.telemost.vp8channel.yaml`](./examples/server.telemost.vp8channel.yaml) - telemost + vp8channel srv
- [`client.telemost.vp8channel.yaml`](./examples/client.telemost.vp8channel.yaml) - telemost + vp8channel cnc
- [`server.wbstream.datachannel.yaml`](./examples/server.wbstream.datachannel.yaml) - wbstream + datachannel srv
- [`client.wbstream.datachannel.yaml`](./examples/client.wbstream.datachannel.yaml) - wbstream + datachannel cnc
- [`server.wbstream.videochannel.yaml`](./examples/server.wbstream.videochannel.yaml) - wbstream + videochannel srv
- [`client.wbstream.videochannel.yaml`](./examples/client.wbstream.videochannel.yaml) - wbstream + videochannel cnc
- [`server.wbstream.seichannel.yaml`](./examples/server.wbstream.seichannel.yaml) - wbstream + seichannel srv
- [`client.wbstream.seichannel.yaml`](./examples/client.wbstream.seichannel.yaml) - wbstream + seichannel cnc
- [`server.wbstream.vp8channel.yaml`](./examples/server.wbstream.vp8channel.yaml) - wbstream + vp8channel srv
- [`client.wbstream.vp8channel.yaml`](./examples/client.wbstream.vp8channel.yaml) - wbstream + vp8channel cnc
- [`failover.yaml`](./examples/failover.yaml) - failover
## Схема
| YAML path | Значение |
|---|---|
| `mode` | `srv`, `cnc` или `gen` |
| `auth.provider` | `jitsi`, `telemost`, `wbstream`, `none` |
| `room.id` | ID/URL комнаты для выбранного auth-провайдера |
| `room.channel` | необязательный ID канала для peer-routing сценариев |
| `crypto.key` / `crypto.key_file` | общий ключ: 64 hex-символа, напрямую или из файла |
| `net.transport` | `datachannel`, `vp8channel`, `seichannel`, `videochannel` |
| `net.dns` | DNS resolver в формате `host:port` |
| `socks.host` / `socks.port` | локальный SOCKS5 listener в `mode: cnc` |
| `socks.user` / `socks.pass` | необязательная auth для входящих SOCKS5-подключений |
| `socks.proxy_addr` / `socks.proxy_port` | исходящий SOCKS5-прокси на серверной стороне |
| `engine.name` / `engine.url` / `engine.token` | прямой engine-режим, только при `auth.provider: none` |
| `video.*` | настройки `videochannel` |
| `vp8.*` | настройки `vp8channel` |
| `sei.*` | настройки `seichannel` |
| `liveness.interval` | интервал ping по control stream, по умолчанию `10s` |
| `liveness.timeout` | таймаут pong, по умолчанию `5s` |
| `liveness.failures` | сколько pong можно пропустить до rebuild, по умолчанию `3` |
| `lifecycle.max_session_duration` | плановый rebuild сессии, например `6h`; пусто = выключено |
| `traffic.max_payload_size` | лимит зашифрованного wire-message; `0` = лимит транспорта |
| `traffic.min_delay` / `traffic.max_delay` | необязательный pacing отправки, например `5ms` / `30ms` |
| `gen.amount` | режим `gen`: сколько комнат создать |
| `profiles[]` | список failover-профилей для `srv`/`cnc` |
| `failover.retry_delay` | пауза перед следующим профилем, например `2s` |
| `failover.max_cycles` | сколько полных проходов по профилям сделать; `0` = бесконечно |
| `data` | путь к директории с runtime-данными (`names`, `surnames`) |
| `debug` | подробное логирование |
| `ffmpeg` | путь к бинарнику ffmpeg для `videochannel` |
`crypto.key_file` читается относительно YAML-файла. Нельзя одновременно задавать `crypto.key` и `crypto.key_file`.
`mode: cnc` запрещает слушать не-loopback адрес (`0.0.0.0`, LAN IP и т.п.), если не заданы оба поля `socks.user` и `socks.pass`.
## Обязательный минимум
### Сервер
```yaml
mode: srv
auth:
provider: jitsi
room:
id: "https://meet.cryptopro.ru/REPLACE_ME_WITH_ROOM_ID"
crypto:
key: "REPLACE_ME_WITH_64_HEX_CHARS"
net:
transport: datachannel
dns: "8.8.8.8:53"
data: data
```
### Клиент
```yaml
mode: cnc
auth:
provider: jitsi
room:
id: "https://meet.cryptopro.ru/REPLACE_ME_WITH_ROOM_ID"
crypto:
key: "REPLACE_ME_WITH_64_HEX_CHARS"
net:
transport: datachannel
dns: "8.8.8.8:53"
socks:
host: "127.0.0.1"
port: 8808
data: data
```
## Liveness
После `CLIENT_HELLO` / `SERVER_WELCOME` первый smux stream остаётся открытым как зашифрованный control stream. По нему `olcrtc` отправляет `CONTROL_PING` / `CONTROL_PONG`, чтобы проверять именно рабочий путь туннеля, а не только статус WebRTC-соединения.
```yaml
liveness:
interval: 10s
timeout: 5s
failures: 3
```
Когда порог пропущенных pong достигнут, текущая smux-сессия пересоздаётся. В failover-режиме профиль, который завершился после неудачного reconnect, отдаёт управление supervisor, и тот пробует следующий профиль.
## Lifecycle Rotation
`lifecycle.max_session_duration` задаёт плановый верхний предел длительности одного звонка/сессии у провайдера. Когда время истекает, активная `srv` или `cnc` сессия закрывается и запускается заново с тем же конфигом.
```yaml
lifecycle:
max_session_duration: 6h
```
Поле необязательное. Формат - Go duration: `30m`, `2h`, `6h`. Ноль и отрицательные значения не принимаются.
## Traffic Shaping
`traffic` добавляет общий wrapper вокруг выбранного транспорта. Он может ограничить размер зашифрованного сообщения и добавить небольшую задержку перед отправкой. Данные не обрезаются: если payload не помещается в эффективный лимит, отправка завершается явной ошибкой.
```yaml
traffic:
max_payload_size: 4096
min_delay: 5ms
max_delay: 30ms
```
Лимит сжимается до `MaxPayloadSize`, который заявляет выбранный транспорт. Клиент и сервер также уменьшают smux frame size с учётом crypto overhead. Значение `0` не добавляет лимит сверх лимита транспорта. Если задан только `min_delay`, задержка фиксированная. Используй одинаковые `traffic`-настройки на обеих сторонах.
## Failover Profiles
`mode: srv` и `mode: cnc` могут задавать `profiles`. Верхнеуровневые поля становятся общими defaults, а каждый профиль переопределяет только то, что указано внутри него.
```yaml
mode: srv
crypto:
key_file: ./olcrtc.key
net:
dns: "8.8.8.8:53"
data: data
profiles:
- name: wb-vp8
auth:
provider: wbstream
room:
id: "WB_ROOM_ID"
net:
transport: vp8channel
- name: jitsi-dc
auth:
provider: jitsi
room:
id: "https://meet.example.org/olcrtc-room"
net:
transport: datachannel
failover:
retry_delay: 2s
max_cycles: 0
```
Порядок профилей и параметры комнаты должны быть совместимы на сервере и клиенте. Активные smux streams между профилями не мигрируют; новые подключения смогут восстановиться на следующем профиле.
## mode: gen
`gen` оставлен для auth-провайдеров, которые реализуют создание комнат через API.
Текущие встроенные провайдеры (`jitsi`, `telemost`, `wbstream`) не создают комнаты
через `olcrtc`: для `telemost` и `wbstream` создай комнату на сайте сервиса и
вставь её в `room.id`; для `jitsi` укажи URL комнаты.

View File

@@ -0,0 +1,34 @@
# Клиентский конфиг: jitsi + datachannel
# Запуск: olcrtc docs/examples/client.jitsi.datachannel.yaml
mode: cnc
auth:
provider: jitsi
# Для jitsi: полный URL комнаты (https://host/room или host/room).
# Должен совпадать с сервером.
room:
id: "https://meet.cryptopro.ru/REPLACE_WITH_ROOM_NAME"
crypto:
# Можно использовать key_file: "./olcrtc.key", чтобы не хранить секрет прямо здесь.
key: "REPLACE_ME_WITH_64_HEX_CHARS" # должен совпадать с сервером
net:
transport: datachannel
dns: "8.8.8.8:53"
liveness:
interval: 10s
timeout: 5s
failures: 3
socks:
host: "127.0.0.1"
port: 8808
user: "" # необязательная входящая auth
pass: ""
data: data
debug: false

View File

@@ -0,0 +1,40 @@
# Клиентский конфиг: jitsi + seichannel
# Запуск: olcrtc docs/examples/client.jitsi.seichannel.yaml
mode: cnc
auth:
provider: jitsi
# Для jitsi: полный URL комнаты (https://host/room или host/room).
# Должен совпадать с сервером.
room:
id: "https://meet.cryptopro.ru/REPLACE_WITH_ROOM_NAME"
crypto:
# Можно использовать key_file: "./olcrtc.key", чтобы не хранить секрет прямо здесь.
key: "REPLACE_ME_WITH_64_HEX_CHARS" # должен совпадать с сервером
net:
transport: seichannel
dns: "8.8.8.8:53"
liveness:
interval: 10s
timeout: 5s
failures: 3
socks:
host: "127.0.0.1"
port: 8808
user: "" # необязательная входящая auth
pass: ""
sei:
fps: 60
batch_size: 64
fragment_size: 900
ack_timeout_ms: 2000
data: data
debug: false

View File

@@ -0,0 +1,47 @@
# Клиентский конфиг: jitsi + videochannel
# Запуск: olcrtc docs/examples/client.jitsi.videochannel.yaml
mode: cnc
auth:
provider: jitsi
# Для jitsi: полный URL комнаты (https://host/room или host/room).
# Должен совпадать с сервером.
room:
id: "https://meet.cryptopro.ru/REPLACE_WITH_ROOM_NAME"
crypto:
# Можно использовать key_file: "./olcrtc.key", чтобы не хранить секрет прямо здесь.
key: "REPLACE_ME_WITH_64_HEX_CHARS" # должен совпадать с сервером
net:
transport: videochannel
dns: "8.8.8.8:53"
liveness:
interval: 10s
timeout: 5s
failures: 3
socks:
host: "127.0.0.1"
port: 8808
user: "" # необязательная входящая auth
pass: ""
video:
width: 1920
height: 1080
fps: 30
bitrate: "2M"
hw: none
codec: qrcode
qr_size: 0
qr_recovery: low
tile_module: 4
tile_rs: 20
ffmpeg: ffmpeg
data: data
debug: false

View File

@@ -0,0 +1,38 @@
# Клиентский конфиг: jitsi + vp8channel
# Запуск: olcrtc docs/examples/client.jitsi.vp8channel.yaml
mode: cnc
auth:
provider: jitsi
# Для jitsi: полный URL комнаты (https://host/room или host/room).
# Должен совпадать с сервером.
room:
id: "https://meet.cryptopro.ru/REPLACE_WITH_ROOM_NAME"
crypto:
# Можно использовать key_file: "./olcrtc.key", чтобы не хранить секрет прямо здесь.
key: "REPLACE_ME_WITH_64_HEX_CHARS" # должен совпадать с сервером
net:
transport: vp8channel
dns: "8.8.8.8:53"
liveness:
interval: 10s
timeout: 5s
failures: 3
socks:
host: "127.0.0.1"
port: 8808
user: "" # необязательная входящая auth
pass: ""
vp8:
fps: 60
batch_size: 64
data: data
debug: false

View File

@@ -0,0 +1,34 @@
# Клиентский конфиг: telemost + datachannel
# Запуск: olcrtc docs/examples/client.telemost.datachannel.yaml
mode: cnc
auth:
provider: telemost
# Для telemost: Room ID, который вернул сервис.
# Должен совпадать с сервером.
room:
id: "REPLACE_WITH_TELEMOST_ROOM_ID"
crypto:
# Можно использовать key_file: "./olcrtc.key", чтобы не хранить секрет прямо здесь.
key: "REPLACE_ME_WITH_64_HEX_CHARS" # должен совпадать с сервером
net:
transport: datachannel
dns: "8.8.8.8:53"
liveness:
interval: 10s
timeout: 5s
failures: 3
socks:
host: "127.0.0.1"
port: 8808
user: "" # необязательная входящая auth
pass: ""
data: data
debug: false

View File

@@ -0,0 +1,40 @@
# Клиентский конфиг: telemost + seichannel
# Запуск: olcrtc docs/examples/client.telemost.seichannel.yaml
mode: cnc
auth:
provider: telemost
# Для telemost: Room ID, который вернул сервис.
# Должен совпадать с сервером.
room:
id: "REPLACE_WITH_TELEMOST_ROOM_ID"
crypto:
# Можно использовать key_file: "./olcrtc.key", чтобы не хранить секрет прямо здесь.
key: "REPLACE_ME_WITH_64_HEX_CHARS" # должен совпадать с сервером
net:
transport: seichannel
dns: "8.8.8.8:53"
liveness:
interval: 10s
timeout: 5s
failures: 3
socks:
host: "127.0.0.1"
port: 8808
user: "" # необязательная входящая auth
pass: ""
sei:
fps: 60
batch_size: 64
fragment_size: 900
ack_timeout_ms: 2000
data: data
debug: false

View File

@@ -0,0 +1,47 @@
# Клиентский конфиг: telemost + videochannel
# Запуск: olcrtc docs/examples/client.telemost.videochannel.yaml
mode: cnc
auth:
provider: telemost
# Для telemost: Room ID, который вернул сервис.
# Должен совпадать с сервером.
room:
id: "REPLACE_WITH_TELEMOST_ROOM_ID"
crypto:
# Можно использовать key_file: "./olcrtc.key", чтобы не хранить секрет прямо здесь.
key: "REPLACE_ME_WITH_64_HEX_CHARS" # должен совпадать с сервером
net:
transport: videochannel
dns: "8.8.8.8:53"
liveness:
interval: 10s
timeout: 5s
failures: 3
socks:
host: "127.0.0.1"
port: 8808
user: "" # необязательная входящая auth
pass: ""
video:
width: 1920
height: 1080
fps: 30
bitrate: "2M"
hw: none
codec: qrcode
qr_size: 0
qr_recovery: low
tile_module: 4
tile_rs: 20
ffmpeg: ffmpeg
data: data
debug: false

View File

@@ -0,0 +1,38 @@
# Клиентский конфиг: telemost + vp8channel
# Запуск: olcrtc docs/examples/client.telemost.vp8channel.yaml
mode: cnc
auth:
provider: telemost
# Для telemost: Room ID, который вернул сервис.
# Должен совпадать с сервером.
room:
id: "REPLACE_WITH_TELEMOST_ROOM_ID"
crypto:
# Можно использовать key_file: "./olcrtc.key", чтобы не хранить секрет прямо здесь.
key: "REPLACE_ME_WITH_64_HEX_CHARS" # должен совпадать с сервером
net:
transport: vp8channel
dns: "8.8.8.8:53"
liveness:
interval: 10s
timeout: 5s
failures: 3
socks:
host: "127.0.0.1"
port: 8808
user: "" # необязательная входящая auth
pass: ""
vp8:
fps: 60
batch_size: 64
data: data
debug: false

View File

@@ -0,0 +1,34 @@
# Клиентский конфиг: wbstream + datachannel
# Запуск: olcrtc docs/examples/client.wbstream.datachannel.yaml
mode: cnc
auth:
provider: wbstream
# Для wbstream: Room ID, который вернул сервис.
# Должен совпадать с сервером.
room:
id: "REPLACE_WITH_WB_ROOM_ID"
crypto:
# Можно использовать key_file: "./olcrtc.key", чтобы не хранить секрет прямо здесь.
key: "REPLACE_ME_WITH_64_HEX_CHARS" # должен совпадать с сервером
net:
transport: datachannel
dns: "8.8.8.8:53"
liveness:
interval: 10s
timeout: 5s
failures: 3
socks:
host: "127.0.0.1"
port: 8808
user: "" # необязательная входящая auth
pass: ""
data: data
debug: false

View File

@@ -0,0 +1,40 @@
# Клиентский конфиг: wbstream + seichannel
# Запуск: olcrtc docs/examples/client.wbstream.seichannel.yaml
mode: cnc
auth:
provider: wbstream
# Для wbstream: Room ID, который вернул сервис.
# Должен совпадать с сервером.
room:
id: "REPLACE_WITH_WB_ROOM_ID"
crypto:
# Можно использовать key_file: "./olcrtc.key", чтобы не хранить секрет прямо здесь.
key: "REPLACE_ME_WITH_64_HEX_CHARS" # должен совпадать с сервером
net:
transport: seichannel
dns: "8.8.8.8:53"
liveness:
interval: 10s
timeout: 5s
failures: 3
socks:
host: "127.0.0.1"
port: 8808
user: "" # необязательная входящая auth
pass: ""
sei:
fps: 60
batch_size: 64
fragment_size: 900
ack_timeout_ms: 2000
data: data
debug: false

View File

@@ -0,0 +1,47 @@
# Клиентский конфиг: wbstream + videochannel
# Запуск: olcrtc docs/examples/client.wbstream.videochannel.yaml
mode: cnc
auth:
provider: wbstream
# Для wbstream: Room ID, который вернул сервис.
# Должен совпадать с сервером.
room:
id: "REPLACE_WITH_WB_ROOM_ID"
crypto:
# Можно использовать key_file: "./olcrtc.key", чтобы не хранить секрет прямо здесь.
key: "REPLACE_ME_WITH_64_HEX_CHARS" # должен совпадать с сервером
net:
transport: videochannel
dns: "8.8.8.8:53"
liveness:
interval: 10s
timeout: 5s
failures: 3
socks:
host: "127.0.0.1"
port: 8808
user: "" # необязательная входящая auth
pass: ""
video:
width: 1920
height: 1080
fps: 30
bitrate: "2M"
hw: none
codec: qrcode
qr_size: 0
qr_recovery: low
tile_module: 4
tile_rs: 20
ffmpeg: ffmpeg
data: data
debug: false

View File

@@ -0,0 +1,38 @@
# Клиентский конфиг: wbstream + vp8channel
# Запуск: olcrtc docs/examples/client.wbstream.vp8channel.yaml
mode: cnc
auth:
provider: wbstream
# Для wbstream: Room ID, который вернул сервис.
# Должен совпадать с сервером.
room:
id: "REPLACE_WITH_WB_ROOM_ID"
crypto:
# Можно использовать key_file: "./olcrtc.key", чтобы не хранить секрет прямо здесь.
key: "REPLACE_ME_WITH_64_HEX_CHARS" # должен совпадать с сервером
net:
transport: vp8channel
dns: "8.8.8.8:53"
liveness:
interval: 10s
timeout: 5s
failures: 3
socks:
host: "127.0.0.1"
port: 8808
user: "" # необязательная входящая auth
pass: ""
vp8:
fps: 60
batch_size: 64
data: data
debug: false

View File

@@ -0,0 +1,48 @@
# Failover-конфиг
# Используй одинаковый порядок профилей на обеих сторонах.
mode: srv
crypto:
key_file: "./olcrtc.key"
net:
dns: "8.8.8.8:53"
liveness:
interval: 10s
timeout: 5s
failures: 3
# Необязательный плановый rebuild для каждого активного профиля.
# lifecycle:
# max_session_duration: 6h
# Необязательный лимит/pacing для зашифрованных wire-сообщений.
# traffic:
# max_payload_size: 4096
# min_delay: 5ms
# max_delay: 30ms
data: data
profiles:
- name: wb-vp8
auth:
provider: wbstream
room:
id: "REPLACE_WITH_WB_ROOM_ID"
net:
transport: vp8channel
- name: jitsi-datachannel
auth:
provider: jitsi
room:
id: "https://meet.example.org/REPLACE_WITH_ROOM_NAME"
net:
transport: datachannel
failover:
retry_delay: 2s
max_cycles: 0

View File

@@ -0,0 +1,33 @@
# Серверный конфиг: jitsi + datachannel
# Запуск: olcrtc docs/examples/server.jitsi.datachannel.yaml
mode: srv
auth:
provider: jitsi
# Для jitsi: полный URL комнаты (https://host/room или host/room).
# Должен совпадать с клиентом.
room:
id: "https://meet.cryptopro.ru/REPLACE_WITH_ROOM_NAME"
crypto:
# 32 байта в hex (64 символа). Сгенерировать: openssl rand -hex 32
# Можно использовать key_file: "./olcrtc.key", чтобы не хранить секрет прямо здесь.
key: "REPLACE_ME_WITH_64_HEX_CHARS" # должен совпадать с клиентом
net:
transport: datachannel
dns: "8.8.8.8:53"
liveness:
interval: 10s
timeout: 5s
failures: 3
socks:
proxy_addr: "" # например "127.0.0.1"
proxy_port: 0 # например 1080
data: data
debug: false

View File

@@ -0,0 +1,39 @@
# Серверный конфиг: jitsi + seichannel
# Запуск: olcrtc docs/examples/server.jitsi.seichannel.yaml
mode: srv
auth:
provider: jitsi
# Для jitsi: полный URL комнаты (https://host/room или host/room).
# Должен совпадать с клиентом.
room:
id: "https://meet.cryptopro.ru/REPLACE_WITH_ROOM_NAME"
crypto:
# 32 байта в hex (64 символа). Сгенерировать: openssl rand -hex 32
# Можно использовать key_file: "./olcrtc.key", чтобы не хранить секрет прямо здесь.
key: "REPLACE_ME_WITH_64_HEX_CHARS" # должен совпадать с клиентом
net:
transport: seichannel
dns: "8.8.8.8:53"
liveness:
interval: 10s
timeout: 5s
failures: 3
socks:
proxy_addr: "" # например "127.0.0.1"
proxy_port: 0 # например 1080
sei:
fps: 60
batch_size: 64
fragment_size: 900
ack_timeout_ms: 2000
data: data
debug: false

View File

@@ -0,0 +1,46 @@
# Серверный конфиг: jitsi + videochannel
# Запуск: olcrtc docs/examples/server.jitsi.videochannel.yaml
mode: srv
auth:
provider: jitsi
# Для jitsi: полный URL комнаты (https://host/room или host/room).
# Должен совпадать с клиентом.
room:
id: "https://meet.cryptopro.ru/REPLACE_WITH_ROOM_NAME"
crypto:
# 32 байта в hex (64 символа). Сгенерировать: openssl rand -hex 32
# Можно использовать key_file: "./olcrtc.key", чтобы не хранить секрет прямо здесь.
key: "REPLACE_ME_WITH_64_HEX_CHARS" # должен совпадать с клиентом
net:
transport: videochannel
dns: "8.8.8.8:53"
liveness:
interval: 10s
timeout: 5s
failures: 3
socks:
proxy_addr: "" # например "127.0.0.1"
proxy_port: 0 # например 1080
video:
width: 1920
height: 1080
fps: 30
bitrate: "2M"
hw: none
codec: qrcode
qr_size: 0
qr_recovery: low
tile_module: 4
tile_rs: 20
ffmpeg: ffmpeg
data: data
debug: false

View File

@@ -0,0 +1,37 @@
# Серверный конфиг: jitsi + vp8channel
# Запуск: olcrtc docs/examples/server.jitsi.vp8channel.yaml
mode: srv
auth:
provider: jitsi
# Для jitsi: полный URL комнаты (https://host/room или host/room).
# Должен совпадать с клиентом.
room:
id: "https://meet.cryptopro.ru/REPLACE_WITH_ROOM_NAME"
crypto:
# 32 байта в hex (64 символа). Сгенерировать: openssl rand -hex 32
# Можно использовать key_file: "./olcrtc.key", чтобы не хранить секрет прямо здесь.
key: "REPLACE_ME_WITH_64_HEX_CHARS" # должен совпадать с клиентом
net:
transport: vp8channel
dns: "8.8.8.8:53"
liveness:
interval: 10s
timeout: 5s
failures: 3
socks:
proxy_addr: "" # например "127.0.0.1"
proxy_port: 0 # например 1080
vp8:
fps: 60
batch_size: 64
data: data
debug: false

View File

@@ -0,0 +1,33 @@
# Серверный конфиг: telemost + datachannel
# Запуск: olcrtc docs/examples/server.telemost.datachannel.yaml
mode: srv
auth:
provider: telemost
# Для telemost: Room ID, который вернул сервис.
# Должен совпадать с клиентом.
room:
id: "REPLACE_WITH_TELEMOST_ROOM_ID"
crypto:
# 32 байта в hex (64 символа). Сгенерировать: openssl rand -hex 32
# Можно использовать key_file: "./olcrtc.key", чтобы не хранить секрет прямо здесь.
key: "REPLACE_ME_WITH_64_HEX_CHARS" # должен совпадать с клиентом
net:
transport: datachannel
dns: "8.8.8.8:53"
liveness:
interval: 10s
timeout: 5s
failures: 3
socks:
proxy_addr: "" # например "127.0.0.1"
proxy_port: 0 # например 1080
data: data
debug: false

View File

@@ -0,0 +1,39 @@
# Серверный конфиг: telemost + seichannel
# Запуск: olcrtc docs/examples/server.telemost.seichannel.yaml
mode: srv
auth:
provider: telemost
# Для telemost: Room ID, который вернул сервис.
# Должен совпадать с клиентом.
room:
id: "REPLACE_WITH_TELEMOST_ROOM_ID"
crypto:
# 32 байта в hex (64 символа). Сгенерировать: openssl rand -hex 32
# Можно использовать key_file: "./olcrtc.key", чтобы не хранить секрет прямо здесь.
key: "REPLACE_ME_WITH_64_HEX_CHARS" # должен совпадать с клиентом
net:
transport: seichannel
dns: "8.8.8.8:53"
liveness:
interval: 10s
timeout: 5s
failures: 3
socks:
proxy_addr: "" # например "127.0.0.1"
proxy_port: 0 # например 1080
sei:
fps: 60
batch_size: 64
fragment_size: 900
ack_timeout_ms: 2000
data: data
debug: false

View File

@@ -0,0 +1,46 @@
# Серверный конфиг: telemost + videochannel
# Запуск: olcrtc docs/examples/server.telemost.videochannel.yaml
mode: srv
auth:
provider: telemost
# Для telemost: Room ID, который вернул сервис.
# Должен совпадать с клиентом.
room:
id: "REPLACE_WITH_TELEMOST_ROOM_ID"
crypto:
# 32 байта в hex (64 символа). Сгенерировать: openssl rand -hex 32
# Можно использовать key_file: "./olcrtc.key", чтобы не хранить секрет прямо здесь.
key: "REPLACE_ME_WITH_64_HEX_CHARS" # должен совпадать с клиентом
net:
transport: videochannel
dns: "8.8.8.8:53"
liveness:
interval: 10s
timeout: 5s
failures: 3
socks:
proxy_addr: "" # например "127.0.0.1"
proxy_port: 0 # например 1080
video:
width: 1920
height: 1080
fps: 30
bitrate: "2M"
hw: none
codec: qrcode
qr_size: 0
qr_recovery: low
tile_module: 4
tile_rs: 20
ffmpeg: ffmpeg
data: data
debug: false

View File

@@ -0,0 +1,37 @@
# Серверный конфиг: telemost + vp8channel
# Запуск: olcrtc docs/examples/server.telemost.vp8channel.yaml
mode: srv
auth:
provider: telemost
# Для telemost: Room ID, который вернул сервис.
# Должен совпадать с клиентом.
room:
id: "REPLACE_WITH_TELEMOST_ROOM_ID"
crypto:
# 32 байта в hex (64 символа). Сгенерировать: openssl rand -hex 32
# Можно использовать key_file: "./olcrtc.key", чтобы не хранить секрет прямо здесь.
key: "REPLACE_ME_WITH_64_HEX_CHARS" # должен совпадать с клиентом
net:
transport: vp8channel
dns: "8.8.8.8:53"
liveness:
interval: 10s
timeout: 5s
failures: 3
socks:
proxy_addr: "" # например "127.0.0.1"
proxy_port: 0 # например 1080
vp8:
fps: 60
batch_size: 64
data: data
debug: false

View File

@@ -0,0 +1,33 @@
# Серверный конфиг: wbstream + datachannel
# Запуск: olcrtc docs/examples/server.wbstream.datachannel.yaml
mode: srv
auth:
provider: wbstream
# Для wbstream: Room ID, который вернул сервис.
# Должен совпадать с клиентом.
room:
id: "REPLACE_WITH_WB_ROOM_ID"
crypto:
# 32 байта в hex (64 символа). Сгенерировать: openssl rand -hex 32
# Можно использовать key_file: "./olcrtc.key", чтобы не хранить секрет прямо здесь.
key: "REPLACE_ME_WITH_64_HEX_CHARS" # должен совпадать с клиентом
net:
transport: datachannel
dns: "8.8.8.8:53"
liveness:
interval: 10s
timeout: 5s
failures: 3
socks:
proxy_addr: "" # например "127.0.0.1"
proxy_port: 0 # например 1080
data: data
debug: false

View File

@@ -0,0 +1,39 @@
# Серверный конфиг: wbstream + seichannel
# Запуск: olcrtc docs/examples/server.wbstream.seichannel.yaml
mode: srv
auth:
provider: wbstream
# Для wbstream: Room ID, который вернул сервис.
# Должен совпадать с клиентом.
room:
id: "REPLACE_WITH_WB_ROOM_ID"
crypto:
# 32 байта в hex (64 символа). Сгенерировать: openssl rand -hex 32
# Можно использовать key_file: "./olcrtc.key", чтобы не хранить секрет прямо здесь.
key: "REPLACE_ME_WITH_64_HEX_CHARS" # должен совпадать с клиентом
net:
transport: seichannel
dns: "8.8.8.8:53"
liveness:
interval: 10s
timeout: 5s
failures: 3
socks:
proxy_addr: "" # например "127.0.0.1"
proxy_port: 0 # например 1080
sei:
fps: 60
batch_size: 64
fragment_size: 900
ack_timeout_ms: 2000
data: data
debug: false

View File

@@ -0,0 +1,46 @@
# Серверный конфиг: wbstream + videochannel
# Запуск: olcrtc docs/examples/server.wbstream.videochannel.yaml
mode: srv
auth:
provider: wbstream
# Для wbstream: Room ID, который вернул сервис.
# Должен совпадать с клиентом.
room:
id: "REPLACE_WITH_WB_ROOM_ID"
crypto:
# 32 байта в hex (64 символа). Сгенерировать: openssl rand -hex 32
# Можно использовать key_file: "./olcrtc.key", чтобы не хранить секрет прямо здесь.
key: "REPLACE_ME_WITH_64_HEX_CHARS" # должен совпадать с клиентом
net:
transport: videochannel
dns: "8.8.8.8:53"
liveness:
interval: 10s
timeout: 5s
failures: 3
socks:
proxy_addr: "" # например "127.0.0.1"
proxy_port: 0 # например 1080
video:
width: 1920
height: 1080
fps: 30
bitrate: "2M"
hw: none
codec: qrcode
qr_size: 0
qr_recovery: low
tile_module: 4
tile_rs: 20
ffmpeg: ffmpeg
data: data
debug: false

View File

@@ -0,0 +1,37 @@
# Серверный конфиг: wbstream + vp8channel
# Запуск: olcrtc docs/examples/server.wbstream.vp8channel.yaml
mode: srv
auth:
provider: wbstream
# Для wbstream: Room ID, который вернул сервис.
# Должен совпадать с клиентом.
room:
id: "REPLACE_WITH_WB_ROOM_ID"
crypto:
# 32 байта в hex (64 символа). Сгенерировать: openssl rand -hex 32
# Можно использовать key_file: "./olcrtc.key", чтобы не хранить секрет прямо здесь.
key: "REPLACE_ME_WITH_64_HEX_CHARS" # должен совпадать с клиентом
net:
transport: vp8channel
dns: "8.8.8.8:53"
liveness:
interval: 10s
timeout: 5s
failures: 3
socks:
proxy_addr: "" # например "127.0.0.1"
proxy_port: 0 # например 1080
vp8:
fps: 60
batch_size: 64
data: data
debug: false

View File

@@ -22,25 +22,33 @@
### git
```sh
apt install git # Debian / Ubuntu / Mint
pacman -S git # Arch / CacheOS / Manjaro
apt install git # Debian / Ubuntu / Mint
pacman -S git # Arch / CacheOS / Manjaro
dnf install git # Fedora / RHEL / CentOS
```
### podman
```sh
apt install podman # Debian / Ubuntu / Mint
pacman -S podman # Arch / CacheOS / Manjaro
dnf install podman # Fedora / RHEL / CentOS
apt install podman # Debian / Ubuntu / Mint
pacman -S podman # Arch / CacheOS / Manjaro
dnf install podman # Fedora / RHEL / CentOS
```
### curl
```sh
apt install curl # Debian / Ubuntu/ Mint
pacman -S curl # Arch / CacheOS / Manjaro
dnf install curl # Fedora
apt install curl # Debian / Ubuntu / Mint
pacman -S curl # Arch / CacheOS / Manjaro
dnf install curl # Fedora / RHEL / CentOS
```
### swap (ОЗУ)
Если у вас меньше 4ГБ оперативной памяти, сборка может вылетать. **Обязательно включите SWAP**:
```bash
sudo fallocate -l 4G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile
```
---
@@ -66,8 +74,6 @@ cd olcrtc
./script/srv.sh
```
Скрипт задаст несколько вопросов.
#### Флаги `srv.sh`
| Флаг | Что делает |
@@ -82,63 +88,53 @@ cd olcrtc
./script/srv.sh --branch=dev --no-cache # ветка dev, без кеша
```
### Carrier (на каком сервисе передавать трафик)
### Auth (на каком сервисе передавать трафик)
```
Select carrier:
1) telemost
2) jazz
Выберите auth-провайдера:
1) jitsi
2) telemost
3) wbstream
Enter choice [1-3, default: 3]:
Введите номер [1-3, по умолчанию: 1]:
```
Выбери сервис. Полную матрицу совместимости смотри в [settings.md](settings.md).
**По умолчанию `wbstream`** - работает со всеми транспортами, рекомендуется.
**По умолчанию `jitsi`** - стабильно работает на datachannel против self-hosted и публичных Jitsi инстансов (например `meet.cryptopro.ru`).
### Transport (как именно передавать данные)
```
Select transport:
Выберите транспорт:
1) datachannel
2) videochannel
3) seichannel
4) vp8channel
Enter choice [1-4, default: 1]:
Введите номер [1-4, по умолчанию: 1]:
```
Рекомендации:
- **datachannel** - самый быстрый, минимальный пинг. Работает с `jazz` и `wbstream`. **Jazz банит IP за datachannel** - лучше используй только с `wbstream`.
- **vp8channel** - работает везде, быстрый, но большой пинг.
- **seichannel** - работает везде кроме telemost, медленный, но мелкий пинг.
- **videochannel** - работает везде, самый медленный и большой пинг.
- **datachannel** - самый быстрый, минимальный пинг. Стабильно работает с `jitsi` через colibri-ws bridge channel. **WBStream DC не работает** в обычном guest flow (токены без `canPublishData`). **Telemost удалил DC**.
- **vp8channel** - работает с telemost и wbstream, быстрый, но большой пинг.
- **seichannel** - работает только с wbstream, медленный, но мелкий пинг.
- **videochannel** - работает с wbstream стабильно, с telemost по возможности; самый медленный и с большим пингом.
**Лучшая комбинация: `wbstream + datachannel`** - максимальная скорость, минимальный пинг, без риска бана.
**Рекомендуемая комбинация: `jitsi + datachannel`** - работает стабильно, не требует регистрации, легко поднимать на своём сервере. Альтернатива: `wbstream + vp8channel`.
### Room ID
```
Enter Room ID:
Введите Room ID:
```
Для **telemost** и **wbstream** - создай руму через сайт ([телемост](https://telemost.yandex.ru/), [wbstream](https://stream.wb.ru)) и вставь её ID.
Для **jitsi** — полный URL комнаты в формате `https://host/room` (например `https://meet.cryptopro.ru/myroom`). Имя комнаты придумывается на лету, без регистрации. Подойдёт любой публичный или self-hosted Jitsi Meet.
Для **jazz** скрипт предложит выбор: сгенерировать автоматически (рекомендуется) или ввести существующий ID. При автогенерации скрипт запустит `gen` и получит ID до старта сервера. Также можно создать руму через сайт [jazz](https://salutejazz.ru/calls/create).
### Client ID
```
Enter Client ID [default: default]:
```
Это обязательный идентификатор клиента. Он должен быть одинаковым на сервере и клиенте - используется чтобы клиент подключался именно к вашему серверу, а не к случайному серверу в руме.
Один `-client-id` технически может держать бесконечное количество одновременных соединений. Однако SFU ограничивает полосу пропускания на одного участника звонка, поэтому оптимально использовать схему **1 client-id = 1 пользователь** - но это не обязательное требование.
Для **telemost** и **wbstream** - создай руму через сайт ([telemost](https://telemost.yandex.ru/), [wbstream](https://stream.wb.ru)) и вставь её ID.
### DNS
```
DNS server [default: 8.8.8.8:53]:
DNS-сервер [по умолчанию: 8.8.8.8:53]:
```
Нажми Enter. Менять не нужно если нет причин, на всякий можно поставить 77.88.8.8 или DNS твоего провайдера.
@@ -146,7 +142,7 @@ DNS server [default: 8.8.8.8:53]:
### SOCKS5 прокси для исходящего трафика
```
Use SOCKS5 proxy for egress? (y/N):
Использовать SOCKS5-прокси для исходящего трафика? (y/N):
```
Если нет - просто Enter, если надо то введи `y`. Нужно чтобы сервер сам ходил через прокси.
@@ -154,10 +150,10 @@ Use SOCKS5 proxy for egress? (y/N):
### Параметры транспорта (только для videochannel)
```
Video codec:
Видео-кодек:
1) qrcode
2) tile (requires 1080x1080)
Enter choice [1-2, default: 1]:
2) tile (требует 1080x1080)
Введите номер [1-2, по умолчанию: 1]:
```
Выбери кодек:
@@ -167,57 +163,57 @@ Enter choice [1-2, default: 1]:
#### qrcode
```
Video width [default: 1920]:
Video height [default: 1080]:
QR error correction (low/medium/high/highest) [default: low]:
QR fragment size bytes [default: 0 (auto)]:
Ширина видео [по умолчанию: 1920]:
Высота видео [по умолчанию: 1080]:
Коррекция ошибок QR (low/medium/high/highest) [по умолчанию: low]:
Размер QR-фрагмента в байтах [по умолчанию: 0 (авто)]:
```
- **Video width / height** - разрешение видео. Больше = больше данных за кадр, но тяжелее поток.
- **QR error correction** - коррекция ошибок: `low` быстрее, `highest` надёжнее при плохом канале.
- **QR fragment size** - размер фрагмента в байтах. `0` = автоматически.
- **Ширина / высота видео** - разрешение видео. Больше = больше данных за кадр, но тяжелее поток.
- **Коррекция ошибок QR** - `low` быстрее, `highest` надёжнее при плохом канале.
- **Размер QR-фрагмента** - размер фрагмента в байтах. `0` = автоматически.
#### tile
```
[*] Tile codec selected - forcing 1080x1080
Tile module size in pixels 1..270 [default: 4]:
Tile Reed-Solomon parity percent 0..200 [default: 20]:
[*] Выбран tile-кодек, принудительно выставляю 1080x1080
Размер tile-модуля в пикселях 1..270 [по умолчанию: 4]:
Процент Reed-Solomon parity для tile 0..200 [по умолчанию: 20]:
```
- **Tile module size** - размер одного тайла в пикселях. Меньше = больше данных за кадр.
- **Размер tile-модуля** - размер одного тайла в пикселях. Меньше = больше данных за кадр.
- **Tile Reed-Solomon parity** - процент избыточности. `0` = без коррекции, `20` оптимально.
#### Общие параметры (для обоих кодеков)
```
Video FPS [default: 30]:
Video bitrate [default: 2M]:
Hardware acceleration (none/nvenc) [default: none]:
FPS видео [по умолчанию: 30]:
Битрейт видео [по умолчанию: 2M]:
Аппаратное ускорение (none/nvenc) [по умолчанию: none]:
```
- **Video FPS** - кадров в секунду. Больше FPS = выше пропускная способность, больше нагрузка на CPU.
- **Video bitrate** - битрейт ffmpeg. Примеры: `2M`, `5M`, `500K`.
- **Hardware acceleration** - `none` если нет GPU, `nvenc` для NVIDIA GPU.
- **FPS видео** - кадров в секунду. Больше FPS = выше пропускная способность, больше нагрузка на CPU.
- **Битрейт видео** - битрейт ffmpeg. Примеры: `2M`, `5M`, `500K`.
- **Аппаратное ускорение** - `none` если нет GPU, `nvenc` для NVIDIA GPU.
---
### Параметры транспорта (только для vp8channel)
```
VP8 FPS [default: 25]: 60
VP8 batch size (frames per tick) [default: 1]: 64
VP8 FPS [по умолчанию: 60]:
VP8 batch size (кадров за тик) [по умолчанию: 64]:
```
Введи `60` и `64` - это оптимальные значения.
Нажми Enter, если устраивают значения по умолчанию `60` и `64`.
### Параметры транспорта (только для seichannel)
```
SEI FPS [default: 20]: 60
SEI batch size (frames per tick) [default: 1]: 64
SEI fragment size in bytes [default: 900]: 900
SEI ACK timeout in milliseconds [default: 3000]: 2000
SEI FPS [по умолчанию: 60]:
SEI batch size (кадров за тик) [по умолчанию: 64]:
Размер SEI-фрагмента в байтах [по умолчанию: 900]:
SEI ACK timeout в миллисекундах [по умолчанию: 2000]:
```
Нажми Enter для всех - значения по умолчанию оптимальны.
@@ -229,17 +225,16 @@ SEI ACK timeout in milliseconds [default: 3000]: 2000
После запуска скрипт выведет:
```
[+] Server started successfully!
[+] Сервер успешно запущен!
Container name: olcrtc-server
Carrier: Carrier
Transport: Transport
Room ID: Room ID
Client ID: default
Encryption key: Encryption key
Имя контейнера: olcrtc-server
Auth: wbstream
Transport: datachannel
Room ID: abc123xyz
Ключ шифрования: d823fa01cb3e0609b67322f7cf984c4ee2e294936fc24ef38c9e59f4799
```
**Сохрани Room ID, Client ID и Encryption key** - они нужны для клиента.
**Сохрани Room ID и ключ шифрования** - они нужны для клиента.
---
@@ -253,20 +248,12 @@ cd olcrtc
./script/cnc.sh
```
Отвечай на те же вопросы что на сервере - **carrier, transport, room ID и client ID должны совпадать**.
Когда спросит client ID:
```
Enter Client ID [default: default]: default
```
Введи тот же `client ID`, который использовал на сервере.
Отвечай на те же вопросы что на сервере - **auth, transport и room ID должны совпадать**.
Когда спросит ключ:
```
Enter Encryption Key (hex): Encryption key
Введите ключ шифрования (hex):
```
Вставь ключ с сервера.
@@ -274,8 +261,8 @@ Enter Encryption Key (hex): Encryption key
### SOCKS5 адрес и порт
```
SOCKS5 ip [default: 127.0.0.1]:
SOCKS5 port [default: 8808]:
SOCKS5 IP [по умолчанию: 127.0.0.1]:
SOCKS5 порт [по умолчанию: 8808]:
```
Нажми Enter оба раза. Прокси поднимется на `127.0.0.1:8808`.
@@ -283,7 +270,7 @@ SOCKS5 port [default: 8808]:
### SOCKS5 аутентификация (необязательно)
```
SOCKS5 username (leave empty to disable auth):
SOCKS5 логин (оставь пустым, чтобы отключить auth):
```
Если нужна защита логином и паролем - введи логин, затем пароль. Если нет - просто Enter, аутентификация будет отключена.
@@ -291,10 +278,9 @@ SOCKS5 username (leave empty to disable auth):
### Результат
```
[+] Client started successfully!
[+] Клиент успешно запущен!
Container name: olcrtc-client
Client ID: default
Имя контейнера: olcrtc-client
SOCKS5 proxy: 127.0.0.1:8808
```
@@ -341,4 +327,4 @@ podman stop olcrtc-client
Хочешь собрать руками без Podman? -> [Мануальная сборка](manual.md)
Все флаги и матрица совместимости -> [settings.md](settings.md)
Все настройки и матрица совместимости -> [settings.md](settings.md)

View File

@@ -10,12 +10,24 @@
# Мануальная сборка
Этот способ для тех кто хочет собрать бинарник руками без Docker/Podman.
Нужен Go 1.26+, mage, git.
Проект в бете. По проблемам: t.me/openlibrecommunity
Нужен Go 1.25+, mage, git.
---
### swap (ОЗУ)
Если у вас меньше 4ГБ оперативной памяти, сборка может вылетать. **Обязательно включите SWAP**:
```bash
sudo fallocate -l 4G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile
```
---
## Что нужно установить
## Шаг 1: Установить git
```sh
@@ -26,12 +38,12 @@ dnf install git # Fedora / RHEL / CentOS
---
## Шаг 2: Установить Go 1.26+
## Шаг 2: Установить Go 1.25+
### Arch / Fedora (всё просто)
```sh
pacman -S go # Arch / CachyOS / Manjaro
pacman -S go # Arch / CachyOS / Manjaro
dnf install go # Fedora / RHEL / CentOS
```
@@ -51,28 +63,26 @@ Pin-Priority: 100
EOF
sudo apt update
sudo apt install -t testing golang-1.26
sudo apt install -t testing golang-go
sudo update-alternatives --install /usr/bin/go go `which go` 10
sudo update-alternatives --install /usr/bin/gofmt gofmt `which gofmt` 10
sudo update-alternatives --install /usr/bin/go go /usr/lib/go-1.26/bin/go 20
sudo update-alternatives --install /usr/bin/gofmt gofmt /usr/lib/go-1.26/bin/gofmt 20
```
Иначе через SDK:
```sh
apt install golang # ставим старый go - он нужен только чтобы скачать новый
go install golang.org/dl/go1.26.0@latest # скачиваем установщик go1.26
~/go/bin/go1.26.0 download # скачиваем сам go1.26
mv ~/go/bin/go1.26.0 /usr/local/bin/go # заменяем системный go
go install golang.org/dl/go1.25.0@latest # скачиваем установщик go1.25
~/go/bin/go1.25.0 download # скачиваем сам go1.25
mv ~/go/bin/go1.25.0 /usr/local/bin/go # заменяем системный go
```
### Проверка
```sh
go version
# go version go1.26.x linux/amd64
# go version go1.25.x linux/amd64
```
---
@@ -108,7 +118,6 @@ git clone https://github.com/openlibrecommunity/olcrtc --recurse-submodules
cd olcrtc
```
`--recurse-submodules` обязателен - без него videochannel не соберётся.
---
@@ -123,9 +132,6 @@ mage cross # все платформы сразу (если собираешь
```
build/olcrtc-linux-amd64
build/olcrtc-linux-arm64
build/olcrtc-windows-amd64.exe
build/olcrtc-darwin-amd64
```
---
@@ -143,55 +149,76 @@ openssl rand -hex 32
---
## Шаг 7: Придумать client ID
## Шаг 7: Запустить сервер
Это обязательный идентификатор клиента. Он должен совпадать на сервере и клиенте, иначе сервер отклонит соединение.
На серверной машине (VPS и т.д.). Подбери нужную комбинацию auth provider + transport из матрицы в [settings.md](settings.md).
```sh
CLIENT_ID=default
### jitsi + datachannel (рекомендуется)
Самый простой способ: используй любой self-hosted или публичный Jitsi Meet инстанс. Регистрация не нужна, имя комнаты выдумывается на лету. По умолчанию в примерах ниже — `meet.cryptopro.ru`, но подойдёт любой другой (`meet.jit.si`, свой self-hosted и т.п.).
Создай YAML конфиг:
```yaml
# server.yaml
mode: srv
auth:
provider: jitsi
room:
id: "https://meet.cryptopro.ru/myroom"
crypto:
key: "d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799"
net:
transport: datachannel
dns: "8.8.8.8:53"
data: data
```
Подойдёт любая короткая строка без пробелов: `home-laptop`, `android-01`, `archlinux`.
Один `-client-id` технически может держать бесконечное количество одновременных соединений. Однако SFU ограничивает полосу пропускания на одного участника звонка, поэтому оптимально использовать схему **1 client-id = 1 пользователь** - но это не обязательное требование.
---
## Шаг 8: Запустить сервер
На серверной машине (VPS и т.д.). Подбери нужную комбинацию carrier + transport из матрицы в [settings.md](settings.md).
### wbstream + datachannel (рекомендуется - максимальная скорость и пинг)
Сначала создай руму вручную через сайт [wbstream](https://stream.wb.ru) (автогенерация через `-mode gen` для wbstream больше не поддерживается) и сохрани её ID:
Запусти:
```sh
ROOM_ID="<room-id-со-stream.wb.ru>"
./build/olcrtc-linux-amd64 server.yaml
```
Затем запусти сервер:
Сервер сам присоединится к комнате (в качестве участника без камеры/микрофона) и будет ждать, пока клиент тоже зайдёт. Без второго участника Jicofo не выдаёт session-initiate — это особенность Jitsi.
### wbstream + vp8channel (альтернатива)
Создай руму через сайт [wbstream](https://stream.wb.ru) и вставь её ID в `room.id`.
`wbstream + datachannel` **не работает** в обычном guest flow — WB Stream выдаёт токены с `canPublishData=false`, и DC не маршрутизирует данные. Для обычного использования выбирай `vp8channel`.
Создай YAML конфиг:
```yaml
# server.yaml
mode: srv
auth:
provider: wbstream
room:
id: "<room-id-со-stream.wb.ru>"
crypto:
key: "d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799"
net:
transport: vp8channel
dns: "8.8.8.8:53"
data: data
```
Запусти:
```sh
./build/olcrtc-linux-amd64 \
-mode srv \
-carrier wbstream \
-transport datachannel \
-id "$ROOM_ID" \
-client-id "$CLIENT_ID" \
-key d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799 \
-link direct \
-dns 1.1.1.1:53 \
-data data
./build/olcrtc-linux-amd64 server.yaml
```
Room ID нужно передать клиенту.
### Добавить отладку
Добавь `--debug` к любой команде - увидишь каждое соединение:
Добавь `debug: true` в YAML конфиг - увидишь каждое соединение:
```
2026/05/03 08:05:23 Connecting link via direct/datachannel/wbstream...
2026/05/03 08:05:23 Connecting link via direct/vp8channel/wbstream...
2026/05/03 08:05:25 wbstream publisher state: connected
2026/05/03 08:05:27 Link connected
2026/05/03 08:05:43 sid=3 connect icanhazip.com:443
@@ -200,70 +227,100 @@ Room ID нужно передать клиенту.
---
## Шаг 9: Запустить клиент
## Шаг 8: Запустить клиент
На своей машине. Carrier, transport, id, `client-id` и key должны совпадать с сервером.
На своей машине. `auth.provider`, `net.transport`, `room.id` и `crypto.key` должны совпадать с сервером.
### wbstream + datachannel
### jitsi + datachannel (рекомендуется)
```yaml
# client.yaml
mode: cnc
auth:
provider: jitsi
room:
id: "https://meet.cryptopro.ru/myroom"
crypto:
key: "<hex-key-такой-же-как-на-сервере>"
net:
transport: datachannel
dns: "8.8.8.8:53"
socks:
host: "127.0.0.1"
port: 8808
data: data
```
```sh
./build/olcrtc-linux-amd64 \
-mode cnc \
-carrier wbstream \
-transport datachannel \
-id abc123xyz \
-client-id "$CLIENT_ID" \
-key <hex-key> \
-link direct \
-dns 1.1.1.1:53 \
-data data \
-socks-host 127.0.0.1 \
-socks-port 1080
./build/olcrtc-linux-amd64 client.yaml
```
После запуска SOCKS5 будет слушать на `127.0.0.1:8808`. Используй любой клиент с поддержкой SOCKS5 (`curl --socks5 127.0.0.1:8808 ...`, браузер с переключателем прокси и т.п.).
### wbstream + vp8channel (альтернатива)
```yaml
# client.yaml
mode: cnc
auth:
provider: wbstream
room:
id: "<room-id>"
crypto:
key: "<hex-key>"
net:
transport: vp8channel
dns: "8.8.8.8:53"
socks:
host: "127.0.0.1"
port: 8808
data: data
```
```sh
./build/olcrtc-linux-amd64 client.yaml
```
После старта в логах появится:
```
SOCKS5 server listening on 127.0.0.1:1080
SOCKS5 server listening on 127.0.0.1:8808
```
Если нужно защитить прокси логином и паролем (например на машине с несколькими пользователями), добавь `-socks-user` и `-socks-pass`:
Если нужно защитить прокси логином и паролем (например на машине с несколькими пользователями), добавь `socks.user` и `socks.pass` в конфиг:
```sh
./build/olcrtc-linux-amd64 \
-mode cnc \
-carrier wbstream \
-transport datachannel \
-id abc123xyz \
-client-id "$CLIENT_ID" \
-key <hex-key> \
-link direct \
-dns 1.1.1.1:53 \
-data data \
-socks-host 127.0.0.1 \
-socks-port 1080 \
-socks-user myuser \
-socks-pass mypass
```yaml
# client.yaml
mode: cnc
auth:
provider: wbstream
room:
id: "<room-id>"
crypto:
key: "<hex-key>"
net:
transport: vp8channel
dns: "8.8.8.8:53"
socks:
host: "127.0.0.1"
port: 8808
user: myuser
pass: mypass
data: data
```
Без этих флагов аутентификация отключена - поведение прежнее.
Без этих полей аутентификация отключена - поведение прежнее.
---
## Шаг 10: Проверить
## Шаг 9: Проверить
```sh
curl --socks5-hostname 127.0.0.1:1080 https://icanhazip.com
curl --socks5-hostname 127.0.0.1:8808 https://icanhazip.com
```
Должен вернуть IP сервера.
Или выставить переменную чтобы весь трафик шёл через прокси:
```sh
export all_proxy=socks5h://127.0.0.1:1080
curl https://icanhazip.com
```
---
@@ -271,17 +328,20 @@ curl https://icanhazip.com
```sh
mage build # собрать для текущей платформы
mage buildCLI # собрать только CLI бинарник
mage cross # собрать для всех платформ
mage deps # скачать и обновить зависимости
mage clean # удалить build/
mage test # запустить тесты
mage e2e # запустить E2E тесты (нужны реальные провайдеры)
mage lint # запустить линтер
mage podman # собрать образ через podman
mage docker # собрать образ через docker
mage mobile # собрать Android AAR
```
---
Используешь скрипты вместо ручной сборки? -> [Быстрый старт](fast.md)
Все флаги и матрица совместимости -> [settings.md](settings.md)
Все настройки и матрица совместимости -> [settings.md](settings.md)

View File

@@ -12,226 +12,383 @@
## Матрица совместимости
| Transport | telemost | jazz | wbstream |
|-----------|:--------:|:----:|:--------:|
| datachannel | - | * | + |
| vp8channel | + | + | + |
| seichannel | - | + | + |
| videochannel | + | + | + |
| Transport | telemost | wbstream | jitsi |
|-----------|:--------:|:--------:|:-----:|
| datachannel | - | ~ | + |
| vp8channel | + | + | ~ |
| seichannel | - | + | ~ |
| videochannel | + | + | ~ |
**Легенда:**
- `+` - работает
- `-` - не поддерживается
- `*` - работает, но не желательно
- `+` - работает (pass в E2E тестах)
- `-` - не работает / не поддерживается (fail в E2E тестах)
- `~` - нестабильно (может работать)
**Рекомендуемая комбинация: `wbstream + datachannel`** - максимальная скорость, минимальный пинг.
**Telemost:** только vp8channel стабильно проходит. DataChannel удалён из Telemost. seichannel не поддерживается. videochannel - медленно.
**WBStream:** все транспорты кроме datachannel работают. DataChannel в обычном guest flow без выдавания модератора не работает - WB Stream выдаёт токены с `canPublishData=false`, и DC не маршрутизирует данные.
**Jitsi:** datachannel стабильно проходит - реализован поверх colibri-ws bridge channel и шлёт байты через `EndpointMessage{raw}` broadcast. Подходит для self-hosted и публичных Jitsi Meet инстансов без аутентификации (`https://meet.cryptopro.ru/...`, `https://meet.jit.si/...` и т.п.). Видео-транспорты (vp8channel, seichannel, videochannel) экспонируют sendable VideoTrack через pion PeerConnection после Jingle session-accept, но Jicofo требует дополнительных протокольных шагов (LastN, ReceiverVideoConstraints, source-add) для маршрутизации видео - поэтому они помечены `~` .
**Jitsi + seichannel — отдельная оговорка.** SEI NAL-юниты идут пассажиром в H.264 видеопотоке, а Jicofo на self-hosted инстансах (например `meet.cryptopro.ru`) периодически режет/откладывает upstream видео когда ресивера в комнате формально нет - для нас это выглядит как `seichannel ack timeout` при формально живом PeerConnection. В steady-state транспорт работает, но e2e матрица помечает его `Unstable` (флаппит): зелёного и красного результата в CI достаточно, тест suite на этом не валится. Для надёжной передачи данных через jitsi предпочтительнее `datachannel` или `vp8channel`.
**Рекомендуемая комбинация: `jitsi + datachannel`** — стабильно работает на любом self-hosted или публичном Jitsi Meet (например `meet.cryptopro.ru`), не требует регистрации, простая руму создания. Альтернатива: `wbstream + vp8channel` — стабильно для коммерческих сценариев, не требует специальных прав.
Скорость по убыванию: `datachannel` > `vp8channel` > `seichannel` > `videochannel`
---
## Обязательные флаги
## Обязательные поля YAML конфига
| Флаг | Что вводить |
|------|-------------|
| `-mode` | `srv` на сервере, `cnc` на клиенте, `gen` для генерации Room ID |
| `-carrier` | `telemost`, `jazz` или `wbstream` |
| `-transport` | `datachannel`, `vp8channel`, `seichannel` или `videochannel` |
| `-id` | Room ID |
| `-client-id` | Общий идентификатор клиента. Должен совпадать на сервере и клиенте. Один client-id может держать бесконечное количество соединений, но SFU ограничивает полосу на участника - оптимально 1 client-id = 1 пользователь (не обязательно) |
| `-key` | Ключ шифрования hex 64 символа. Генерация: `openssl rand -hex 32` |
| `-link` | Всегда `direct` |
| `-data` | Всегда `data` |
| `-dns` | DNS-сервер, например `1.1.1.1:53` |
| YAML поле | Что вводить |
|-----------|-------------|
| `mode` | `srv` на сервере, `cnc` на клиенте, `gen` для генерации Room ID |
| `auth.provider` | `telemost`, `wbstream`, `jitsi` или `none` |
| `net.transport` | `datachannel`, `vp8channel`, `seichannel` или `videochannel` |
| `room.id` | Room ID |
| `crypto.key` или `crypto.key_file` | Ключ шифрования hex 64 символа. Генерация: `openssl rand -hex 32` |
| `data` | Всегда `data` |
| `net.dns` | DNS-сервер, например `8.8.8.8:53` |
---
## Необязательные флаги
## Необязательные поля
| Флаг | Описание |
|------|----------|
| `--debug` | Подробные логи соединений |
| YAML поле | Описание |
|-----------|----------|
| `debug` | `true` для подробных логов соединений |
| `profiles` | Список профилей failover для `srv`/`cnc` |
| `failover.retry_delay` | Пауза перед следующим профилем, например `2s` |
| `failover.max_cycles` | Сколько полных проходов по профилям сделать; `0` = бесконечно |
| `liveness.interval` | Интервал ping по control stream, по умолчанию `10s` |
| `liveness.timeout` | Сколько ждать pong, по умолчанию `5s` |
| `liveness.failures` | Сколько pong можно пропустить перед rebuild, по умолчанию `3` |
| `lifecycle.max_session_duration` | Плановый rebuild сессии после указанного времени, например `6h`; если поле не задано, выключено |
| `traffic.max_payload_size` | Лимит размера зашифрованного wire-message; `0` = лимит транспорта |
| `traffic.min_delay` / `.max_delay` | Необязательный pacing отправки, например `5ms` / `30ms` |
`crypto.key_file` читается относительно YAML-файла. Не указывай `crypto.key` и `crypto.key_file` одновременно.
Если задан `profiles`, поля верхнего уровня становятся общими defaults, а
каждый профиль переопределяет только свои `auth`, `room`, `net`, `engine` и
настройки транспорта/liveness. Порядок профилей должен совпадать на сервере и
клиенте.
`liveness` проверяет именно зашифрованный smux control stream после handshake,
а не только статус WebRTC/provider соединения. Если pong не приходит несколько
раз подряд, текущая smux-сессия пересоздается.
`lifecycle.max_session_duration` ограничивает длительность одного звонка /
provider session. Когда таймер истекает, текущая `srv` или `cnc` сессия
закрывается и стартует заново с тем же конфигом. Пока эта настройка включена,
чистое завершение сессии тоже перезапускается, чтобы второй peer мог догнать
плановый rebuild. Формат значения: `30m`, `2h`, `6h`; `0s` и отрицательные
значения не принимаются.
`traffic` добавляет общий wrapper над выбранным transport. Он может ограничить
размер зашифрованного сообщения и добавить небольшую задержку перед отправкой.
Данные не обрезаются: если сообщение не помещается в эффективный лимит, send
возвращает явную ошибку. При заданном `max_payload_size` smux frame size также
уменьшается с учетом crypto overhead; при `0` остается лимит выбранного
transport. Используй одинаковые traffic-настройки на обеих сторонах.
---
## -mode gen
## mode: gen
Генерирует Room ID заранее, не запуская сервер. Поддерживается только для `jazz`. Для `wbstream` создавай руму вручную через [stream.wb.ru](https://stream.wb.ru) (автогенерация отключена со стороны WB).
`gen` оставлен для auth-провайдеров, которые умеют создавать комнаты через API.
Сейчас встроенные провайдеры не поддерживают автосоздание комнат через `olcrtc`.
**Обязательные флаги:**
| Флаг | Описание |
|------|----------|
| `-carrier` | `jazz` |
| `-dns` | DNS-сервер |
| `-amount` | Количество комнат |
```sh
./olcrtc -mode gen -carrier jazz -dns 1.1.1.1:53 -amount 3
# room-id-1
# room-id-2
# room-id-3
```
Для `telemost` и `wbstream` создай комнату через сайт сервиса и вставь её ID в
`room.id`. Для `jitsi` укажи URL комнаты.
---
## Флаги только для сервера (`-mode srv`)
## Поля только для сервера (`mode: srv`)
| Флаг | Описание |
|------|----------|
| `-socks-proxy` | Адрес SOCKS5-прокси для исходящего трафика сервера |
| `-socks-proxy-port` | Порт этого прокси |
| YAML поле | Описание |
|-----------|----------|
| `socks.proxy_addr` | Адрес SOCKS5-прокси для исходящего трафика сервера |
| `socks.proxy_port` | Порт этого прокси |
---
## Флаги только для клиента (`-mode cnc`)
## Поля только для клиента (`mode: cnc`)
| Флаг | Описание | По умолчанию |
|------|----------|:------------:|
| `-socks-host` | На каком адресе поднять SOCKS5 | `127.0.0.1` |
| `-socks-port` | На каком порту поднять SOCKS5 | `1080` |
| `-socks-user` | Логин для входящих SOCKS5-подключений (необязательно) | - |
| `-socks-pass` | Пароль для входящих SOCKS5-подключений (необязательно) | - |
| YAML поле | Описание | По умолчанию |
|-----------|----------|:------------:|
| `socks.host` | На каком адресе поднять SOCKS5 | `127.0.0.1` |
| `socks.port` | На каком порту поднять SOCKS5 | `1080` |
| `socks.user` | Логин для входящих SOCKS5-подключений (необязательно) | - |
| `socks.pass` | Пароль для входящих SOCKS5-подключений (необязательно) | - |
Если `-socks-user` не задан - аутентификация отключена (любой локальный клиент может подключиться).
Если `socks.user` не задан - аутентификация отключена (любой локальный клиент может подключиться).
Если задан - клиент принимает только подключения с правильным логином и паролем (RFC 1929).
Если `socks.host` не loopback (`127.0.0.1`, `::1`, `localhost`), `socks.user` и `socks.pass` обязательны.
Это защита от случайного открытого SOCKS5-прокси в локальной сети или интернете.
---
## datachannel
Дополнительных флагов нет - всё по умолчанию.
Дополнительных полей нет - всё по умолчанию.
---
## vp8channel
**Рекомендуется: `-vp8-fps 60 -vp8-batch 64`** (числа лучше чётные, больший batch = выше скорость)
**Рекомендуется: `fps: 60`, `batch_size: 64`** (числа лучше чётные, больший batch = выше скорость)
| Флаг | Описание | По умолчанию |
|------|----------|:------------:|
| `-vp8-fps` | FPS VP8 потока | `25` |
| `-vp8-batch` | Кадров за тик | `1` |
| YAML поле | Описание | По умолчанию |
|-----------|----------|:------------:|
| `vp8.fps` | FPS VP8 потока | `60` |
| `vp8.batch_size` | Кадров за тик | `64` |
---
## seichannel
**Рекомендуется: `-fps 60 -batch 64 -frag 900 -ack-ms 2000`**
**Рекомендуется: `fps: 60`, `batch_size: 64`, `fragment_size: 900`, `ack_timeout_ms: 2000`**
| Флаг | Описание | По умолчанию |
|------|----------|:------------:|
| `-fps` | FPS H264 потока | `60` |
| `-batch` | Кадров за тик | `64` |
| `-frag` | Размер фрагмента в байтах | `900` |
| `-ack-ms` | Таймаут ACK в миллисекундах | `2000` |
| YAML поле | Описание | По умолчанию |
|-----------|----------|:------------:|
| `sei.fps` | FPS H264 потока | `60` |
| `sei.batch_size` | Кадров за тик | `64` |
| `sei.fragment_size` | Размер фрагмента в байтах | `900` |
| `sei.ack_timeout_ms` | Таймаут ACK в миллисекундах | `2000` |
---
## videochannel
**Рекомендуется: `-video-codec qrcode -video-w 1080 -video-h 1080 -video-fps 60 -video-bitrate 5000k -video-hw none`**
**Рекомендуется: `codec: qrcode`, `width: 1080`, `height: 1080`, `fps: 60`, `bitrate: "5000k"`, `hw: none`**
| Флаг | Описание | По умолчанию |
|------|----------|:------------:|
| `-video-codec` | `qrcode` или `tile` | `qrcode` |
| `-video-w` | Ширина в пикселях | `1920` |
| `-video-h` | Высота в пикселях | `1080` |
| `-video-fps` | FPS | `30` |
| `-video-bitrate` | Битрейт, например `2M` или `5000k` | `2M` |
| `-video-hw` | Аппаратное ускорение: `none` или `nvenc` | `none` |
| `-video-qr-recovery` | Коррекция ошибок QR: `low` / `medium` / `high` / `highest` | `low` |
| `-video-qr-size` | Размер фрагмента QR в байтах, `0` = авто | `0` |
| `-video-tile-module` | Размер тайла в пикселях 1..270 (только `tile`) | `4` |
| `-video-tile-rs` | Reed-Solomon паритет % 0..200 (только `tile`) | `20` |
| `-ffmpeg` | Путь к исполняемому файлу ffmpeg | `ffmpeg` |
| YAML поле | Описание | По умолчанию |
|-----------|----------|:------------:|
| `video.codec` | `qrcode` или `tile` | `qrcode` |
| `video.width` | Ширина в пикселях | `1920` |
| `video.height` | Высота в пикселях | `1080` |
| `video.fps` | FPS | `30` |
| `video.bitrate` | Битрейт, например `"2M"` или `"5000k"` | `"2M"` |
| `video.hw` | Аппаратное ускорение: `none` или `nvenc` | `none` |
| `video.qr_recovery` | Коррекция ошибок QR: `low` / `medium` / `high` / `highest` | `low` |
| `video.qr_size` | Размер фрагмента QR в байтах, `0` = авто | `0` |
| `video.tile_module` | Размер тайла в пикселях 1..270 (только `tile`) | `4` |
| `video.tile_rs` | Reed-Solomon паритет % 0..200 (только `tile`) | `20` |
| `ffmpeg` | Путь к исполняемому файлу ffmpeg | `ffmpeg` |
Для codec `tile` нужно точно `1080x1080`.
---
## Готовые команды
## Готовые конфиги
### wbstream + datachannel (рекомендуется - максимальная скорость, без бана)
### wbstream + datachannel (не работает в обычном guest flow)
```sh
WB Stream DataChannel **не работает** в обычном guest flow — WB Stream выдаёт токены с `canPublishData=false`, и DC не маршрутизирует данные. Этот режим помечен как expected fail в E2E тестах. Для обычного использования выбирай `vp8channel`, `seichannel` или `videochannel`.
```yaml
# room ID нужно создать вручную через https://stream.wb.ru
ROOM_ID="<room-id-со-stream.wb.ru>"
# сервер
./olcrtc -mode srv -carrier wbstream -transport datachannel \
-id "$ROOM_ID" -client-id <client-id> -key <hex-key> -link direct -data data -dns 1.1.1.1:53
# клиент
./olcrtc -mode cnc -carrier wbstream -transport datachannel \
-id "$ROOM_ID" -client-id <client-id> -key <hex-key> -link direct -data data -dns 1.1.1.1:53 \
-socks-host 127.0.0.1 -socks-port 1080
# server.yaml
mode: srv
auth:
provider: wbstream
room:
id: "<room-id-со-stream.wb.ru>"
crypto:
key: "<hex-key>"
net:
transport: datachannel
dns: "8.8.8.8:53"
data: data
```
### wbstream + datachannel + SOCKS5 аутентификация
```yaml
# client.yaml
mode: cnc
auth:
provider: wbstream
room:
id: "<room-id-со-stream.wb.ru>"
crypto:
key: "<hex-key>"
net:
transport: datachannel
dns: "8.8.8.8:53"
socks:
host: "127.0.0.1"
port: 8808
data: data
```
```sh
# клиент с логином и паролем на прокси
./olcrtc -mode cnc -carrier wbstream -transport datachannel \
-id "$ROOM_ID" -client-id <client-id> -key <hex-key> -link direct -data data -dns 1.1.1.1:53 \
-socks-host 127.0.0.1 -socks-port 1080 \
-socks-user myuser -socks-pass mypass
### wbstream + datachannel + SOCKS5 аутентификация (не работает в обычном guest flow)
```yaml
# client.yaml с логином и паролем на прокси
mode: cnc
auth:
provider: wbstream
room:
id: "<room-id>"
crypto:
key: "<hex-key>"
net:
transport: datachannel
dns: "8.8.8.8:53"
socks:
host: "127.0.0.1"
port: 8808
user: myuser
pass: mypass
data: data
```
Использование:
```sh
curl --socks5-hostname myuser:mypass@127.0.0.1:1080 https://icanhazip.com
curl --socks5-hostname myuser:mypass@127.0.0.1:8808 https://icanhazip.com
# или
export all_proxy=socks5h://myuser:mypass@127.0.0.1:1080
export all_proxy=socks5h://myuser:mypass@127.0.0.1:8808
```
---
### telemost + vp8channel
```sh
# сервер
./olcrtc -mode srv -carrier telemost -transport vp8channel \
-id <room-id> -client-id <client-id> -key <hex-key> -link direct -data data \
-vp8-fps 60 -vp8-batch 64
# клиент
./olcrtc -mode cnc -carrier telemost -transport vp8channel \
-id <room-id> -client-id <client-id> -key <hex-key> -link direct -data data \
-socks-host 127.0.0.1 -socks-port 1080 \
-vp8-fps 60 -vp8-batch 64
```yaml
# server.yaml
mode: srv
auth:
provider: telemost
room:
id: "<room-id>"
crypto:
key: "<hex-key>"
net:
transport: vp8channel
dns: "8.8.8.8:53"
vp8:
fps: 60
batch_size: 64
data: data
```
### telemost + seichannel
```sh
# сервер
./olcrtc -mode srv -carrier telemost -transport seichannel \
-id <room-id> -client-id <client-id> -key <hex-key> -link direct -data data \
-fps 60 -batch 64 -frag 900 -ack-ms 2000
# клиент
./olcrtc -mode cnc -carrier telemost -transport seichannel \
-id <room-id> -client-id <client-id> -key <hex-key> -link direct -data data \
-socks-host 127.0.0.1 -socks-port 1080 \
-fps 60 -batch 64 -frag 900 -ack-ms 2000
```yaml
# client.yaml
mode: cnc
auth:
provider: telemost
room:
id: "<room-id>"
crypto:
key: "<hex-key>"
net:
transport: vp8channel
dns: "8.8.8.8:53"
socks:
host: "127.0.0.1"
port: 8808
vp8:
fps: 60
batch_size: 64
data: data
```
### telemost + videochannel (крайний случай)
### telemost + seichannel (не работает)
```sh
# сервер
./olcrtc -mode srv -carrier telemost -transport videochannel \
-id <room-id> -client-id <client-id> -key <hex-key> -link direct -data data \
-video-codec qrcode -video-w 1080 -video-h 1080 \
-video-fps 60 -video-bitrate 5000k -video-hw none
> ⚠️ Эта комбинация помечена как expected fail в E2E тестах. Telemost не поддерживает seichannel.
# клиент
./olcrtc -mode cnc -carrier telemost -transport videochannel \
-id <room-id> -client-id <client-id> -key <hex-key> -link direct -data data \
-socks-host 127.0.0.1 -socks-port 1080 \
-video-codec qrcode -video-w 1080 -video-h 1080 \
-video-fps 60 -video-bitrate 5000k -video-hw none
```yaml
# server.yaml
mode: srv
auth:
provider: telemost
room:
id: "<room-id>"
crypto:
key: "<hex-key>"
net:
transport: seichannel
dns: "8.8.8.8:53"
sei:
fps: 60
batch_size: 64
fragment_size: 900
ack_timeout_ms: 2000
data: data
```
```yaml
# client.yaml
mode: cnc
auth:
provider: telemost
room:
id: "<room-id>"
crypto:
key: "<hex-key>"
net:
transport: seichannel
dns: "8.8.8.8:53"
socks:
host: "127.0.0.1"
port: 8808
sei:
fps: 60
batch_size: 64
fragment_size: 900
ack_timeout_ms: 2000
data: data
```
### telemost + videochannel (best effort, нестабильно)
```yaml
# server.yaml
mode: srv
auth:
provider: telemost
room:
id: "<room-id>"
crypto:
key: "<hex-key>"
net:
transport: videochannel
dns: "8.8.8.8:53"
video:
codec: qrcode
width: 1080
height: 1080
fps: 60
bitrate: "5000k"
hw: none
data: data
```
```yaml
# client.yaml
mode: cnc
auth:
provider: telemost
room:
id: "<room-id>"
crypto:
key: "<hex-key>"
net:
transport: videochannel
dns: "8.8.8.8:53"
socks:
host: "127.0.0.1"
port: 8808
video:
codec: qrcode
width: 1080
height: 1080
fps: 60
bitrate: "5000k"
hw: none
data: data
```
---

View File

@@ -92,8 +92,8 @@ olcrtc://...
Каждая строка сервера содержит один `olcrtc`-URI в формате из [uri.md](uri.md):
```text
olcrtc://<Carrier>?<Transport>@<RoomID>#<EncryptionKey>%<ClientID>$<MIMO>
olcrtc://<Carrier>?<Transport><key=value&key=value>@<RoomID>#<EncryptionKey>%<ClientID>$<MIMO>
olcrtc://<Auth>?<Transport>@<RoomID>#<EncryptionKey>$<MIMO>
olcrtc://<Auth>?<Transport><key=value&key=value>@<RoomID>#<EncryptionKey>$<MIMO>
```
Одна строка = один сервер/одна запись подписки.
@@ -141,7 +141,7 @@ olcrtc://<Carrier>?<Transport><key=value&key=value>@<RoomID>#<EncryptionKey>%<Cl
#used: 10mb/10gb
#available: 9.99gb
olcrtc://wbstream?seichannel<fps=60&batch=64&frag=900&ack-ms=2000>@room-01#d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799%android-01$RU / olcng free sub / IPv6
olcrtc://wbstream?seichannel<fps=60&batch=64&frag=900&ack-ms=2000>@room-01#d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799$RU / olcng free sub / IPv6
##name: RU-1
##icon: 🇷🇺
##color: #4A90E2
@@ -150,11 +150,11 @@ olcrtc://wbstream?seichannel<fps=60&batch=64&frag=900&ack-ms=2000>@room-01#d823f
##ip: 203.0.113.10
##comment: basic free node
olcrtc://wbstream?datachannel@abc123xyz#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa%android-01$DE / backup / IPv4
olcrtc://wbstream?datachannel@abc123xyz#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa$DE / backup / IPv4
##name: DE-Backup
##icon: 🇩🇪
##color: #2EBD85
##comment: reserve route, wbstream+datachannel - max speed
##comment: reserve route, wbstream+datachannel does not work in guest flow
```
## Имплементация клиента для подписок
@@ -165,4 +165,4 @@ olcrtc://wbstream?datachannel@abc123xyz#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
URI-формат для отдельного сервера: [uri.md](uri.md)
Матрица совместимости carrier + transport: [settings.md](settings.md)
Матрица совместимости auth + transport: [settings.md](settings.md)

View File

@@ -12,15 +12,15 @@
Этот документ описывает **соглашение для разработчиков клиентских приложений**, которым нужен компактный способ передавать параметры подключения `olcrtc`.
Текущий `olcrtc` не парсит такой URI автоматически. Если клиентское приложение хочет использовать эту запись, оно должно само разобрать строку и передать полученные поля в свои вызовы `olcrtc`.
Текущий `olcrtc` не парсит такой URI автоматически. Если клиентское приложение хочет использовать эту запись, оно должно само разобрать строку и передать полученные поля в YAML конфиг `olcrtc`.
---
## Формат
```text
olcrtc://<Carrier>?<Transport>@<RoomID>#<EncryptionKey>%<ClientID>$<MIMO>
olcrtc://<Carrier>?<Transport><key=value&key=value>@<RoomID>#<EncryptionKey>%<ClientID>$<MIMO>
olcrtc://<Auth>?<Transport>@<RoomID>#<EncryptionKey>$<MIMO>
olcrtc://<Auth>?<Transport><key=value&key=value>@<RoomID>#<EncryptionKey>$<MIMO>
```
Все поля после `olcrtc://` считаются частью клиентского соглашения.
@@ -33,12 +33,11 @@ olcrtc://<Carrier>?<Transport><key=value&key=value>@<RoomID>#<EncryptionKey>%<Cl
| Поле | Значение |
|------|----------|
| `<Carrier>` | Имя carrier, например `telemost`, `jazz`, `wbstream` |
| `<Auth>` | Имя auth-провайдера, например `telemost`, `wbstream`, `jitsi` |
| `<Transport>` | Имя транспорта, например `datachannel`, `vp8channel`, `seichannel`, `videochannel` |
| payload | Параметры транспорта в `<key=value&...>`. Ключи совпадают с CLI-флагами без дефиса. Блок опускается если используются defaults |
| `<RoomID>` | Идентификатор комнаты или carrier-specific room URL/ID |
| payload | Параметры транспорта в `<key=value&...>`. Ключи совпадают с YAML полями. Блок опускается если используются defaults |
| `<RoomID>` | Идентификатор комнаты или auth-specific room URL/ID |
| `<EncryptionKey>` | Ключ шифрования в hex, обычно 64 символа (`32` байта) |
| `<ClientID>` | Идентификатор клиента. Должен совпадать с ожидаемым значением на сервере. Один client-id может держать бесконечное количество соединений, но SFU ограничивает полосу на участника — оптимально 1 client-id = 1 пользователь (не обязательно) |
| `<MIMO>` | Свободный комментарий для UI/метаданных, например `RU / olc free sub / IPv6` |
---
@@ -51,50 +50,49 @@ Payload не используется.
### vp8channel
| Ключ | CLI-флаг | Описание |
|------|----------|----------|
| `vp8-fps` | `-vp8-fps` | FPS VP8 потока |
| `vp8-batch` | `-vp8-batch` | Кадров за тик |
| Ключ | YAML поле | Описание |
|------|-----------|----------|
| `vp8-fps` | `vp8.fps` | FPS VP8 потока |
| `vp8-batch` | `vp8.batch_size` | Кадров за тик |
### seichannel
| Ключ | CLI-флаг | Описание |
|------|----------|----------|
| `fps` | `-fps` | FPS H264 потока |
| `batch` | `-batch` | Кадров за тик |
| `frag` | `-frag` | Размер фрагмента в байтах |
| `ack-ms` | `-ack-ms` | Таймаут ACK в миллисекундах |
| Ключ | YAML поле | Описание |
|------|-----------|----------|
| `fps` | `sei.fps` | FPS H264 потока |
| `batch` | `sei.batch_size` | Кадров за тик |
| `frag` | `sei.fragment_size` | Размер фрагмента в байтах |
| `ack-ms` | `sei.ack_timeout_ms` | Таймаут ACK в миллисекундах |
### videochannel
| Ключ | CLI-флаг | Описание |
|------|----------|----------|
| `video-w` | `-video-w` | Ширина в пикселях |
| `video-h` | `-video-h` | Высота в пикселях |
| `video-fps` | `-video-fps` | FPS |
| `video-bitrate` | `-video-bitrate` | Битрейт, например `5000k` или `2M` |
| `video-hw` | `-video-hw` | Аппаратное ускорение: `none` или `nvenc` |
| `video-codec` | `-video-codec` | `qrcode` или `tile` |
| `video-qr-size` | `-video-qr-size` | Размер фрагмента QR в байтах |
| `video-qr-recovery` | `-video-qr-recovery` | Коррекция ошибок: `low` / `medium` / `high` / `highest` |
| `video-tile-module` | `-video-tile-module` | Размер тайла в пикселях 1..270 (только `tile`) |
| `video-tile-rs` | `-video-tile-rs` | Reed-Solomon паритет % 0..200 (только `tile`) |
| Ключ | YAML поле | Описание |
|------|-----------|----------|
| `video-w` | `video.width` | Ширина в пикселях |
| `video-h` | `video.height` | Высота в пикселях |
| `video-fps` | `video.fps` | FPS |
| `video-bitrate` | `video.bitrate` | Битрейт, например `5000k` или `2M` |
| `video-hw` | `video.hw` | Аппаратное ускорение: `none` или `nvenc` |
| `video-codec` | `video.codec` | `qrcode` или `tile` |
| `video-qr-size` | `video.qr_size` | Размер фрагмента QR в байтах |
| `video-qr-recovery` | `video.qr_recovery` | Коррекция ошибок: `low` / `medium` / `high` / `highest` |
| `video-tile-module` | `video.tile_module` | Размер тайла в пикселях 1..270 (только `tile`) |
| `video-tile-rs` | `video.tile_rs` | Reed-Solomon паритет % 0..200 (только `tile`) |
---
## Соответствие параметрам olcrtc
## Соответствие YAML полям olcrtc
| URI поле | Параметр / значение |
|----------|---------------------|
| `<Carrier>` | `-carrier` |
| `<Transport>` | `-transport` |
| payload | соответствующие флаги транспорта |
| `<RoomID>` | `-id` |
| `<EncryptionKey>` | `-key` |
| `<ClientID>` | `-client-id` |
| URI поле | YAML поле |
|----------|-----------|
| `<Auth>` | `auth.provider` |
| `<Transport>` | `net.transport` |
| payload | соответствующие YAML поля транспорта |
| `<RoomID>` | `room.id` |
| `<EncryptionKey>` | `crypto.key` |
| `<MIMO>` | В `olcrtc` не передаётся. Это только клиентский комментарий |
`-link direct` и `-data data` в этом формате не кодируются, потому что для текущих сценариев они фиксированные.
`data: data` в этом формате не кодируется, потому что это локальная runtime-настройка конкретного запуска.
---
@@ -107,7 +105,6 @@ Payload не используется.
| `<...>` | payload параметров транспорта |
| `@` | `<RoomID>` |
| `#` | `<EncryptionKey>` |
| `%` | `<ClientID>` |
| `$` | `<MIMO>` |
Рекомендуется не использовать эти символы внутри самих полей. Если клиенту это нужно, он должен ввести собственное escaping/percent-encoding правило и применять его симметрично при кодировании и декодировании.
@@ -116,85 +113,130 @@ Payload не используется.
## Примеры
### wbstream + datachannel (рекомендуется)
### wbstream + datachannel (не работает в обычном guest flow)
```text
olcrtc://wbstream?datachannel@room-01#d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799%android-01$RU / olc free sub / IPv6
olcrtc://wbstream?datachannel@room-01#d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799$RU / olc free sub / IPv6
```
Payload не нужен - datachannel параметров не имеет.
Payload не нужен - datachannel параметров не имеет. Для WBStream этот режим **не работает** в обычном guest flow: WB Stream выдаёт токены с `canPublishData=false`, и DC не маршрутизирует данные.
### Эквивалент CLI
### Эквивалент YAML
```sh
./olcrtc -mode cnc \
-carrier wbstream \
-transport datachannel \
-id room-01 \
-client-id android-01 \
-key d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799 \
-link direct \
-data data
```yaml
mode: cnc
auth:
provider: wbstream
room:
id: "room-01"
crypto:
key: "d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799"
net:
transport: datachannel
data: data
```
### wbstream + vp8channel
```text
olcrtc://wbstream?vp8channel<vp8-fps=60&vp8-batch=64>@room-01#d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799%android-01$RU / olc free sub / IPv6
olcrtc://wbstream?vp8channel<vp8-fps=60&vp8-batch=64>@room-01#d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799$RU / olc free sub / IPv6
```
### Эквивалент CLI
### Эквивалент YAML
```sh
./olcrtc -mode cnc \
-carrier wbstream \
-transport vp8channel \
-id room-01 \
-client-id android-01 \
-key d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799 \
-link direct \
-data data \
-vp8-fps 60 -vp8-batch 64
```yaml
mode: cnc
auth:
provider: wbstream
room:
id: "room-01"
crypto:
key: "d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799"
net:
transport: vp8channel
vp8:
fps: 60
batch_size: 64
data: data
```
### jazz + seichannel
### wbstream + seichannel
```text
olcrtc://jazz?seichannel<fps=60&batch=64&frag=900&ack-ms=2000>@room-01#d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799%android-01$DE / olc free sub
olcrtc://wbstream?seichannel<fps=60&batch=64&frag=900&ack-ms=2000>@room-01#d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799$DE / olc free sub
```
### Эквивалент CLI
### Эквивалент YAML
```sh
./olcrtc -mode cnc \
-carrier jazz \
-transport seichannel \
-id room-01 \
-client-id android-01 \
-key d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799 \
-link direct \
-data data \
-fps 60 -batch 64 -frag 900 -ack-ms 2000
```yaml
mode: cnc
auth:
provider: wbstream
room:
id: "room-01"
crypto:
key: "d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799"
net:
transport: seichannel
sei:
fps: 60
batch_size: 64
fragment_size: 900
ack_timeout_ms: 2000
data: data
```
### telemost + videochannel
```text
olcrtc://telemost?videochannel<video-w=1080&video-h=1080&video-fps=60&video-bitrate=5000k&video-hw=none&video-codec=qrcode>@room-01#d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799%android-01$MIMO
olcrtc://telemost?videochannel<video-w=1080&video-h=1080&video-fps=60&video-bitrate=5000k&video-hw=none&video-codec=qrcode>@room-01#d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799$MIMO
```
### Эквивалент CLI
### Эквивалент YAML
```sh
./olcrtc -mode cnc \
-carrier telemost \
-transport videochannel \
-id room-01 \
-client-id android-01 \
-key d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799 \
-link direct \
-data data \
-video-w 1080 -video-h 1080 -video-fps 60 -video-bitrate 5000k -video-hw none -video-codec qrcode
```yaml
mode: cnc
auth:
provider: telemost
room:
id: "room-01"
crypto:
key: "d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799"
net:
transport: videochannel
video:
width: 1080
height: 1080
fps: 60
bitrate: "5000k"
hw: none
codec: qrcode
data: data
```
---
### jitsi + datachannel
```text
olcrtc://jitsi?datachannel@https://meet.cryptopro.ru/myroom#d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799$RU / olc free sub
```
`<RoomID>` для jitsi — полный URL комнаты в формате `https://host/room` (или `host/room`). Поддерживается любой self-hosted Jitsi Meet инстанс без аутентификации; для публичных серверов вроде `meet.jit.si` тот же формат.
### Эквивалент YAML
```yaml
mode: cnc
auth:
provider: jitsi
room:
id: "https://meet.cryptopro.ru/myroom"
crypto:
key: "d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799"
net:
transport: datachannel
data: data
```
---
@@ -207,4 +249,4 @@ olcrtc://telemost?videochannel<video-w=1080&video-h=1080&video-fps=60&video-bitr
Формат подписки (список серверов): [sub.md](sub.md)
Матрица совместимости carrier + transport: [settings.md](settings.md)
Матрица совместимости auth + transport: [settings.md](settings.md)

18
go.mod
View File

@@ -8,14 +8,19 @@ require (
github.com/livekit/protocol v1.45.3
github.com/livekit/server-sdk-go/v2 v2.16.2
github.com/magefile/mage v1.17.1
github.com/pion/interceptor v0.1.44
github.com/pion/logging v0.2.4
github.com/pion/rtp v1.10.1
github.com/pion/webrtc/v4 v4.2.11
github.com/pion/webrtc/v4 v4.2.12
github.com/xtaci/kcp-go/v5 v5.6.72
github.com/xtaci/smux v1.5.57
github.com/zarazaex69/gr v0.0.0-20260430043628-45b595f4fef0
github.com/zarazaex69/j v0.0.0-20260518222913-cb593e3bc582
golang.org/x/crypto v0.50.0
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b
golang.org/x/sys v0.43.0
google.golang.org/genproto v0.0.0-20260209200024-4cfbd4190f57
gopkg.in/yaml.v3 v3.0.1
)
require (
@@ -27,6 +32,7 @@ require (
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/dennwc/iters v1.2.2 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/frostbyte73/core v0.1.1 // indirect
@@ -51,18 +57,16 @@ require (
github.com/nats-io/nuid v1.0.1 // indirect
github.com/pion/datachannel v1.6.0 // indirect
github.com/pion/dtls/v3 v3.1.2 // indirect
github.com/pion/ice/v4 v4.2.2 // indirect
github.com/pion/interceptor v0.1.44 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/ice/v4 v4.2.5 // indirect
github.com/pion/mdns/v2 v2.1.0 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.16 // indirect
github.com/pion/sctp v1.9.4 // indirect
github.com/pion/sctp v1.9.5 // indirect
github.com/pion/sdp/v3 v3.0.18 // indirect
github.com/pion/srtp/v3 v3.0.10 // indirect
github.com/pion/stun/v3 v3.1.2 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/pion/turn/v4 v4.1.4 // indirect
github.com/pion/turn/v5 v5.0.3 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/redis/go-redis/v9 v9.17.2 // indirect
@@ -79,7 +83,6 @@ require (
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.44.0 // indirect
@@ -88,6 +91,5 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/grpc v1.79.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
rsc.io/qr v0.2.0 // indirect
)

18
go.sum
View File

@@ -35,6 +35,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4=
github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
@@ -160,8 +162,8 @@ github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
github.com/pion/ice/v4 v4.2.2 h1:dQJzzcgTFHDYyV3BoCfjPeX+JEtr58BWPi4PGyo6Vjg=
github.com/pion/ice/v4 v4.2.2/go.mod h1:2quLV1S5v1tAx3VvAJaH//KGitRXvo4RKlX6D3tnN+c=
github.com/pion/ice/v4 v4.2.5 h1:5umUQy4hX6HwMsCnJ0SX337YYCeTWDgC9JWyvUqHIHs=
github.com/pion/ice/v4 v4.2.5/go.mod h1:aaABRaykEYnNjccjbiimuYxViaASeuv5mk9BpplUxK0=
github.com/pion/interceptor v0.1.44 h1:sNlZwM8dWXU9JQAkJh8xrarC0Etn8Oolcniukmuy0/I=
github.com/pion/interceptor v0.1.44/go.mod h1:4atVlBkcgXuUP+ykQF0qOCGU2j7pQzX2ofvPRFsY5RY=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
@@ -174,8 +176,8 @@ github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA=
github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
github.com/pion/sctp v1.9.4 h1:cMxEu0F5tbP4qH07bKf1Zjf4rUih9LIo0qQt424e258=
github.com/pion/sctp v1.9.4/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw=
github.com/pion/sctp v1.9.5 h1:QoSFB/drmAsmSeSFNQNI3xx010nW4HsycCZckRVWWag=
github.com/pion/sctp v1.9.5/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw=
github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI=
github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8=
github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ=
@@ -188,8 +190,10 @@ github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
github.com/pion/webrtc/v4 v4.2.11 h1:QUX1QZKlNIn4O7U5JxLPGP0sV5RTncZkzu9SPR3jVNU=
github.com/pion/webrtc/v4 v4.2.11/go.mod h1:s/rAiyy77GyRFrZMx+Ls6aua26dIBPudH8/ZHYbIRWY=
github.com/pion/turn/v5 v5.0.3 h1:I+Nw0fQgdPWF1SXDj0egWDhCkcff7gWiigdQpOK52Ak=
github.com/pion/turn/v5 v5.0.3/go.mod h1:fs4SogUh/aRGQzonc4Lx3Jp4EU3j3t0PfNDEd9KcD/w=
github.com/pion/webrtc/v4 v4.2.12 h1:ux8i+aJxu0OdhcAcVO39JEeodWugD0wdVJoRDtXk1CY=
github.com/pion/webrtc/v4 v4.2.12/go.mod h1:M/DeGZkhdWZVmVgGr34HOD9yUDekVJtz9c9PGO18urQ=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -231,6 +235,8 @@ github.com/xtaci/smux v1.5.57/go.mod h1:IGQ9QYrBphmb/4aTnLEcJby0TNr3NV+OslIOMrX8
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zarazaex69/gr v0.0.0-20260430043628-45b595f4fef0 h1:dMjHX/YPV3ZD/KJKFjQdlMBwj2/rZIuOVKOvGv26m9k=
github.com/zarazaex69/gr v0.0.0-20260430043628-45b595f4fef0/go.mod h1:7vALI2tjaLTOGiDKV7V2JkVU9bA1YADBDQA6uvpp1ac=
github.com/zarazaex69/j v0.0.0-20260518222913-cb593e3bc582 h1:5ZvS/7kBTqTMKMjMO3S/4neE4YHRoYKbQdx/4y8Kobc=
github.com/zarazaex69/j v0.0.0-20260518222913-cb593e3bc582/go.mod h1:7/ypJTenOIPx23fpo5uF7l4u+rxZqg9cFbTL/N77Ktc=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=

View File

@@ -5,15 +5,18 @@ import (
"context"
"errors"
"fmt"
"net"
"slices"
"sync/atomic"
"time"
"github.com/openlibrecommunity/olcrtc/internal/carrier"
"github.com/openlibrecommunity/olcrtc/internal/carrier/builtin"
"github.com/openlibrecommunity/olcrtc/internal/auth"
"github.com/openlibrecommunity/olcrtc/internal/client"
"github.com/openlibrecommunity/olcrtc/internal/link"
"github.com/openlibrecommunity/olcrtc/internal/link/direct"
"github.com/openlibrecommunity/olcrtc/internal/provider/jazz"
"github.com/openlibrecommunity/olcrtc/internal/control"
enginebuiltin "github.com/openlibrecommunity/olcrtc/internal/engine/builtin"
"github.com/openlibrecommunity/olcrtc/internal/logger"
"github.com/openlibrecommunity/olcrtc/internal/names"
"github.com/openlibrecommunity/olcrtc/internal/runtime"
"github.com/openlibrecommunity/olcrtc/internal/server"
"github.com/openlibrecommunity/olcrtc/internal/transport"
"github.com/openlibrecommunity/olcrtc/internal/transport/datachannel"
@@ -23,142 +26,306 @@ import (
)
const (
modeSRV = "srv"
modeCNC = "cnc"
modeGen = "gen"
carrierJazz = "jazz"
carrierTelemost = "telemost"
carrierWBStream = "wbstream"
transportVideo = "videochannel"
transportVP8 = "vp8channel"
transportSEI = "seichannel"
videoCodecQRCode = "qrcode"
videoCodecTile = "tile"
roomURLAny = "any"
telemostRoomURLPrefix = "https://telemost.yandex.ru/j/"
modeSRV = "srv"
modeCNC = "cnc"
modeGen = "gen"
authNone = "none"
transportVideo = "videochannel"
transportVP8 = "vp8channel"
transportSEI = "seichannel"
videoCodecQRCode = "qrcode"
videoCodecTile = "tile"
)
const (
defaultVideoWidth = 1920
defaultVideoHeight = 1080
defaultVideoFPS = 30
defaultVideoBitrate = "2M"
defaultVideoHW = "none"
defaultVideoQRRecovery = "low"
defaultVP8FPS = 60
defaultVP8BatchSize = 64
defaultSEIFPS = 60
defaultSEIBatchSize = 64
defaultSEIFragmentSize = 900
defaultSEIAckTimeoutMS = 2000
)
var sessionRestartDelay = 2 * time.Second //nolint:gochecknoglobals // tests shorten lifecycle rotation delay
var (
// ErrRoomIDRequired indicates that a room id is required for the selected carrier.
ErrRoomIDRequired = errors.New("room ID required (use -id <id>)")
ErrRoomIDRequired = errors.New("room ID required (set room.id)")
// ErrModeRequired indicates that mode is not one of the supported values.
ErrModeRequired = errors.New("mode required (use -mode srv, -mode cnc or -mode gen)")
// ErrAmountRequired indicates that -amount is required for gen mode.
ErrAmountRequired = errors.New("amount required for gen mode (use -amount <n>)")
// ErrCarrierRequired indicates that no carrier was selected.
ErrCarrierRequired = errors.New(
"carrier required (use -carrier telemost, -carrier jazz or -carrier wbstream)")
ErrModeRequired = errors.New("mode required (set mode to srv, cnc or gen)")
// ErrAmountRequired indicates that gen.amount is required for gen mode.
ErrAmountRequired = errors.New("amount required for gen mode (set gen.amount)")
// ErrAuthRequired indicates that no auth provider was selected.
ErrAuthRequired = errors.New(
"auth provider required (set auth.provider to jitsi, telemost, wbstream or none)")
// ErrURLRequired indicates that auth.url must be provided when the auth provider has no default URL.
ErrURLRequired = errors.New("SFU URL required (set auth.url)")
// ErrUnsupportedCarrier indicates that carrier is not registered.
ErrUnsupportedCarrier = errors.New("unsupported carrier")
// ErrUnsupportedLink indicates that link is not registered.
ErrUnsupportedLink = errors.New("unsupported link")
// ErrUnsupportedTransport indicates that transport is not registered.
ErrUnsupportedTransport = errors.New("unsupported transport")
// ErrLinkRequired indicates that link is not provided.
ErrLinkRequired = errors.New("link required (use -link direct)")
// ErrTransportRequired indicates that transport is not provided.
ErrTransportRequired = errors.New(
"transport required (use -transport datachannel, -transport videochannel, " +
"-transport seichannel or -transport vp8channel)")
"transport required (set transport to datachannel, videochannel, seichannel or vp8channel)")
// ErrKeyRequired indicates that encryption key is not provided.
ErrKeyRequired = errors.New("key required (use -key <hex>)")
ErrKeyRequired = errors.New("key required (set crypto.key)")
// ErrDNSServerRequired indicates that dns server is not provided.
ErrDNSServerRequired = errors.New("dns server required (use -dns 1.1.1.1:53)")
ErrDNSServerRequired = errors.New("dns server required (set net.dns)")
// ErrVideoWidthRequired indicates that video width is required for videochannel.
ErrVideoWidthRequired = errors.New("video width required for videochannel (use -video-w)")
ErrVideoWidthRequired = errors.New("video width required for videochannel (set video.width)")
// ErrVideoHeightRequired indicates that video height is required for videochannel.
ErrVideoHeightRequired = errors.New("video height required for videochannel (use -video-h)")
ErrVideoHeightRequired = errors.New("video height required for videochannel (set video.height)")
// ErrVideoFPSRequired indicates that video fps is required for videochannel.
ErrVideoFPSRequired = errors.New("video fps required for videochannel (use -video-fps)")
ErrVideoFPSRequired = errors.New("video fps required for videochannel (set video.fps)")
// ErrVideoBitrateRequired indicates that video bitrate is required for videochannel.
ErrVideoBitrateRequired = errors.New(
"video bitrate required for videochannel (use -video-bitrate)")
"video bitrate required for videochannel (set video.bitrate)")
// ErrVideoHWRequired indicates that video hardware acceleration is required.
ErrVideoHWRequired = errors.New(
"video hardware acceleration required for videochannel (use -video-hw none/nvenc)")
"video hardware acceleration required for videochannel (set video.hw to none or nvenc)")
// ErrVideoCodecInvalid indicates that the video codec is not valid.
ErrVideoCodecInvalid = errors.New(
"invalid video codec for videochannel (use -video-codec qrcode or -video-codec tile)")
"invalid video codec for videochannel (set video.codec to qrcode or tile)")
// ErrTileCodecDimensions indicates that tile codec requires 1080x1080 dimensions.
ErrTileCodecDimensions = errors.New("tile codec requires -video-w 1080 -video-h 1080")
ErrTileCodecDimensions = errors.New("tile codec requires video.width: 1080 and video.height: 1080")
// ErrVP8FPSRequired indicates that vp8 fps is required for vp8channel.
ErrVP8FPSRequired = errors.New("vp8 fps required for vp8channel (use -vp8-fps)")
ErrVP8FPSRequired = errors.New("vp8 fps required for vp8channel (set vp8.fps)")
// ErrVP8BatchSizeRequired indicates that vp8 batch size is required for vp8channel.
ErrVP8BatchSizeRequired = errors.New("vp8 batch size required for vp8channel (use -vp8-batch)")
ErrVP8BatchSizeRequired = errors.New("vp8 batch size required for vp8channel (set vp8.batch_size)")
// ErrSEIFPSRequired indicates that seichannel fps is required.
ErrSEIFPSRequired = errors.New("fps required for seichannel (use -fps)")
ErrSEIFPSRequired = errors.New("fps required for seichannel (set sei.fps)")
// ErrSEIBatchSizeRequired indicates that seichannel batch size is required.
ErrSEIBatchSizeRequired = errors.New("batch size required for seichannel (use -batch)")
ErrSEIBatchSizeRequired = errors.New("batch size required for seichannel (set sei.batch_size)")
// ErrSEIFragmentSizeRequired indicates that seichannel fragment size is required.
ErrSEIFragmentSizeRequired = errors.New("fragment size required for seichannel (use -frag)")
ErrSEIFragmentSizeRequired = errors.New("fragment size required for seichannel (set sei.fragment_size)")
// ErrSEIAckTimeoutRequired indicates that seichannel ack timeout is required.
ErrSEIAckTimeoutRequired = errors.New("ack timeout required for seichannel (use -ack-ms)")
ErrSEIAckTimeoutRequired = errors.New("ack timeout required for seichannel (set sei.ack_timeout_ms)")
// ErrSOCKSHostRequired indicates that socks host is required for cnc mode.
ErrSOCKSHostRequired = errors.New("socks host required for cnc mode (use -socks-host)")
ErrSOCKSHostRequired = errors.New("socks host required for cnc mode (set socks.host)")
// ErrSOCKSPortRequired indicates that socks port is required for cnc mode.
ErrSOCKSPortRequired = errors.New("socks port required for cnc mode (use -socks-port)")
// ErrClientIDRequired indicates that client ID is required.
ErrClientIDRequired = errors.New("client ID required (use -client-id <id>)")
ErrSOCKSPortRequired = errors.New("socks port required for cnc mode (set socks.port)")
// ErrSOCKSAuthRequired indicates that a non-loopback SOCKS listener requires authentication.
ErrSOCKSAuthRequired = errors.New(
"socks auth required when binding outside loopback (set socks.user and socks.pass)")
// ErrLivenessIntervalInvalid indicates that liveness.interval is not a positive duration.
ErrLivenessIntervalInvalid = errors.New(
"invalid liveness interval (set liveness.interval to a duration > 0)")
// ErrLivenessTimeoutInvalid indicates that liveness.timeout is not a positive duration.
ErrLivenessTimeoutInvalid = errors.New(
"invalid liveness timeout (set liveness.timeout to a duration > 0)")
// ErrLivenessFailuresInvalid indicates that liveness.failures is not positive.
ErrLivenessFailuresInvalid = errors.New(
"invalid liveness failures (set liveness.failures to a value > 0)")
// ErrLifecycleMaxSessionDurationInvalid indicates that lifecycle.max_session_duration is not a positive duration.
ErrLifecycleMaxSessionDurationInvalid = errors.New(
"invalid max session duration (set lifecycle.max_session_duration to a duration > 0)")
// ErrTrafficMaxPayloadSizeInvalid indicates that traffic.max_payload_size is not valid.
ErrTrafficMaxPayloadSizeInvalid = errors.New(
"invalid traffic max payload size (set traffic.max_payload_size to 0 or a value above crypto overhead)")
// ErrTrafficMinDelayInvalid indicates that traffic.min_delay is not a non-negative duration.
ErrTrafficMinDelayInvalid = errors.New(
"invalid traffic min delay (set traffic.min_delay to a duration >= 0)")
// ErrTrafficMaxDelayInvalid indicates that traffic.max_delay is not a non-negative duration.
ErrTrafficMaxDelayInvalid = errors.New(
"invalid traffic max delay (set traffic.max_delay to a duration >= 0 and >= traffic.min_delay)")
errPositiveDuration = errors.New("duration must be > 0")
errNonNegativeDuration = errors.New("duration must be >= 0")
)
// VideoConfig holds tunables for the videochannel transport.
type VideoConfig struct {
Width int
Height int
FPS int
Bitrate string
HW string
QRSize int
QRRecovery string
Codec string
TileModule int
TileRS int
}
// VP8Config holds tunables for the vp8channel transport.
type VP8Config struct {
FPS int
BatchSize int
}
// SEIConfig holds tunables for the seichannel transport.
type SEIConfig struct {
FPS int
BatchSize int
FragmentSize int
AckTimeoutMS int
}
// Config holds runtime session settings.
type Config struct {
Mode string
Link string
Transport string
Carrier string
RoomID string
ClientID string
KeyHex string
SOCKSHost string
SOCKSPort int
SOCKSUser string
SOCKSPass string
DNSServer string
SOCKSProxyAddr string
SOCKSProxyPort int
VideoWidth int
VideoHeight int
VideoFPS int
VideoBitrate string
VideoHW string
VideoQRSize int
VideoQRRecovery string
VideoCodec string
VideoTileModule int
VideoTileRS int
VP8FPS int
VP8BatchSize int
SEIFPS int
SEIBatchSize int
SEIFragmentSize int
SEIAckTimeoutMS int
Amount int
Mode string
Transport string
Auth string
Engine string
URL string
Token string
RoomID string
ChannelID string
KeyHex string
SOCKSHost string
SOCKSPort int
SOCKSUser string
SOCKSPass string
DNSServer string
SOCKSProxyAddr string
SOCKSProxyPort int
Video VideoConfig
VP8 VP8Config
SEI SEIConfig
LivenessInterval string
LivenessTimeout string
LivenessFailures int
MaxSessionDuration string
TrafficMaxPayloadSize int
TrafficMinDelay string
TrafficMaxDelay string
Amount int
}
// RegisterDefaults registers built-in carriers and transports.
func RegisterDefaults() {
builtin.Register()
link.Register("direct", direct.New)
enginebuiltin.RegisterDefaults()
transport.Register("datachannel", datachannel.New)
transport.Register("videochannel", videochannel.New)
transport.Register("seichannel", seichannel.New)
transport.Register("vp8channel", vp8channel.New)
}
// ApplyAuthDefaults fills in Engine and URL from the auth provider when they are not set explicitly.
// For -auth none the fields are left untouched (the caller supplies them directly).
//
// An empty cfg.URL is acceptable when the auth provider does not advertise a
// DefaultServiceURL. Providers that DO advertise a DefaultServiceURL still
// require URL to be set when their default cannot be applied.
func ApplyAuthDefaults(cfg Config) (Config, error) {
if cfg.Auth == authNone || cfg.Auth == "" {
return cfg, nil
}
p, _ := auth.Get(cfg.Auth) // unknown auth is caught later by validateAuth
if p == nil {
return cfg, nil
}
if cfg.Engine == "" {
cfg.Engine = p.Engine()
}
if cfg.URL == "" {
cfg.URL = p.DefaultServiceURL()
}
if cfg.URL == "" && p.DefaultServiceURL() != "" {
return cfg, fmt.Errorf("%w: auth provider %q has no default URL", ErrURLRequired, cfg.Auth)
}
return cfg, nil
}
// ApplyTransportDefaults fills documented transport defaults without changing core routing fields.
func ApplyTransportDefaults(cfg Config) Config {
switch cfg.Transport {
case transportVideo:
return applyVideoDefaults(cfg)
case transportVP8:
return applyVP8Defaults(cfg)
case transportSEI:
return applySEIDefaults(cfg)
default:
return cfg
}
}
// ApplyLivenessDefaults fills documented control-stream liveness defaults.
func ApplyLivenessDefaults(cfg Config) Config {
if cfg.LivenessInterval == "" {
cfg.LivenessInterval = control.DefaultInterval.String()
}
if cfg.LivenessTimeout == "" {
cfg.LivenessTimeout = control.DefaultTimeout.String()
}
if cfg.LivenessFailures == 0 {
cfg.LivenessFailures = control.DefaultFailures
}
return cfg
}
func applyVideoDefaults(cfg Config) Config {
if cfg.Video.Codec == "" {
cfg.Video.Codec = videoCodecQRCode
}
width := defaultVideoWidth
if cfg.Video.Codec == videoCodecTile {
width = defaultVideoHeight
}
if cfg.Video.Width == 0 {
cfg.Video.Width = width
}
if cfg.Video.Height == 0 {
cfg.Video.Height = defaultVideoHeight
}
if cfg.Video.FPS == 0 {
cfg.Video.FPS = defaultVideoFPS
}
if cfg.Video.Bitrate == "" {
cfg.Video.Bitrate = defaultVideoBitrate
}
if cfg.Video.HW == "" {
cfg.Video.HW = defaultVideoHW
}
if cfg.Video.QRRecovery == "" {
cfg.Video.QRRecovery = defaultVideoQRRecovery
}
return cfg
}
func applyVP8Defaults(cfg Config) Config {
if cfg.VP8.FPS == 0 {
cfg.VP8.FPS = defaultVP8FPS
}
if cfg.VP8.BatchSize == 0 {
cfg.VP8.BatchSize = defaultVP8BatchSize
}
return cfg
}
func applySEIDefaults(cfg Config) Config {
if cfg.SEI.FPS == 0 {
cfg.SEI.FPS = defaultSEIFPS
}
if cfg.SEI.BatchSize == 0 {
cfg.SEI.BatchSize = defaultSEIBatchSize
}
if cfg.SEI.FragmentSize == 0 {
cfg.SEI.FragmentSize = defaultSEIFragmentSize
}
if cfg.SEI.AckTimeoutMS == 0 {
cfg.SEI.AckTimeoutMS = defaultSEIAckTimeoutMS
}
return cfg
}
// Validate verifies that the runtime config refers to registered components and all required fields are present.
func Validate(cfg Config) error {
if err := validateMode(cfg); err != nil {
return err
}
if err := validateCarrier(cfg); err != nil {
return err
}
if err := validateLink(cfg); err != nil {
if err := validateAuth(cfg); err != nil {
return err
}
if err := validateTransportRegistration(cfg); err != nil {
@@ -170,6 +337,15 @@ func Validate(cfg Config) error {
if err := validateTransportConfig(cfg); err != nil {
return err
}
if err := validateLivenessConfig(cfg); err != nil {
return err
}
if err := validateLifecycleConfig(cfg); err != nil {
return err
}
if err := validateTrafficConfig(cfg); err != nil {
return err
}
return validateModeConfig(cfg)
}
@@ -182,22 +358,12 @@ func validateMode(cfg Config) error {
}
}
func validateCarrier(cfg Config) error {
if cfg.Carrier == "" {
return ErrCarrierRequired
func validateAuth(cfg Config) error {
if cfg.Auth == "" {
return ErrAuthRequired
}
if !slices.Contains(carrier.Available(), cfg.Carrier) {
return fmt.Errorf("%w: %s (available: %v)", ErrUnsupportedCarrier, cfg.Carrier, carrier.Available())
}
return nil
}
func validateLink(cfg Config) error {
if cfg.Link == "" {
return ErrLinkRequired
}
if !slices.Contains(link.Available(), cfg.Link) {
return fmt.Errorf("%w: %s (available: %v)", ErrUnsupportedLink, cfg.Link, link.Available())
if !slices.Contains(enginebuiltin.Available(), cfg.Auth) {
return fmt.Errorf("%w: %s (available: %v)", ErrUnsupportedCarrier, cfg.Auth, enginebuiltin.Available())
}
return nil
}
@@ -213,12 +379,9 @@ func validateTransportRegistration(cfg Config) error {
}
func validateCommon(cfg Config) error {
if cfg.RoomID == "" && cfg.Carrier != carrierJazz {
if cfg.RoomID == "" && cfg.Auth != authNone {
return ErrRoomIDRequired
}
if cfg.ClientID == "" {
return ErrClientIDRequired
}
if cfg.KeyHex == "" {
return ErrKeyRequired
}
@@ -242,55 +405,55 @@ func validateTransportConfig(cfg Config) error {
}
func validateVideoCodec(cfg Config) error {
if cfg.VideoCodec != "" && cfg.VideoCodec != videoCodecQRCode && cfg.VideoCodec != videoCodecTile {
if cfg.Video.Codec != "" && cfg.Video.Codec != videoCodecQRCode && cfg.Video.Codec != videoCodecTile {
return ErrVideoCodecInvalid
}
if cfg.VideoCodec == videoCodecTile && (cfg.VideoWidth != 1080 || cfg.VideoHeight != 1080) {
if cfg.Video.Codec == videoCodecTile && (cfg.Video.Width != 1080 || cfg.Video.Height != 1080) {
return ErrTileCodecDimensions
}
return nil
}
func validateVideoChannel(cfg Config) error {
if cfg.VideoWidth == 0 {
if cfg.Video.Width == 0 {
return ErrVideoWidthRequired
}
if cfg.VideoHeight == 0 {
if cfg.Video.Height == 0 {
return ErrVideoHeightRequired
}
if cfg.VideoFPS == 0 {
if cfg.Video.FPS == 0 {
return ErrVideoFPSRequired
}
if cfg.VideoBitrate == "" {
if cfg.Video.Bitrate == "" {
return ErrVideoBitrateRequired
}
if cfg.VideoHW == "" {
if cfg.Video.HW == "" {
return ErrVideoHWRequired
}
return validateVideoCodec(cfg)
}
func validateVP8Channel(cfg Config) error {
if cfg.VP8FPS == 0 {
if cfg.VP8.FPS == 0 {
return ErrVP8FPSRequired
}
if cfg.VP8BatchSize == 0 {
if cfg.VP8.BatchSize == 0 {
return ErrVP8BatchSizeRequired
}
return nil
}
func validateSEIChannel(cfg Config) error {
if cfg.SEIFPS == 0 {
if cfg.SEI.FPS == 0 {
return ErrSEIFPSRequired
}
if cfg.SEIBatchSize == 0 {
if cfg.SEI.BatchSize == 0 {
return ErrSEIBatchSizeRequired
}
if cfg.SEIFragmentSize == 0 {
if cfg.SEI.FragmentSize == 0 {
return ErrSEIFragmentSizeRequired
}
if cfg.SEIAckTimeoutMS == 0 {
if cfg.SEI.AckTimeoutMS == 0 {
return ErrSEIAckTimeoutRequired
}
return nil
@@ -306,76 +469,226 @@ func validateModeConfig(cfg Config) error {
if cfg.SOCKSPort == 0 {
return ErrSOCKSPortRequired
}
if !isLoopbackListenHost(cfg.SOCKSHost) && (cfg.SOCKSUser == "" || cfg.SOCKSPass == "") {
return ErrSOCKSAuthRequired
}
return nil
}
func validateLivenessConfig(cfg Config) error {
if _, err := parseLivenessDuration(cfg.LivenessInterval, control.DefaultInterval); err != nil {
return fmt.Errorf("%w: %w", ErrLivenessIntervalInvalid, err)
}
if _, err := parseLivenessDuration(cfg.LivenessTimeout, control.DefaultTimeout); err != nil {
return fmt.Errorf("%w: %w", ErrLivenessTimeoutInvalid, err)
}
if cfg.LivenessFailures < 0 {
return ErrLivenessFailuresInvalid
}
return nil
}
func validateLifecycleConfig(cfg Config) error {
if _, err := maxSessionDuration(cfg); err != nil {
return err
}
return nil
}
func parseLivenessDuration(value string, def time.Duration) (time.Duration, error) {
if value == "" {
return def, nil
}
d, err := time.ParseDuration(value)
if err != nil {
return 0, fmt.Errorf("parse duration: %w", err)
}
if d <= 0 {
return 0, errPositiveDuration
}
return d, nil
}
func livenessConfig(cfg Config) (control.Config, error) {
interval, err := parseLivenessDuration(cfg.LivenessInterval, control.DefaultInterval)
if err != nil {
return control.Config{}, fmt.Errorf("%w: %w", ErrLivenessIntervalInvalid, err)
}
timeout, err := parseLivenessDuration(cfg.LivenessTimeout, control.DefaultTimeout)
if err != nil {
return control.Config{}, fmt.Errorf("%w: %w", ErrLivenessTimeoutInvalid, err)
}
failures := cfg.LivenessFailures
if failures == 0 {
failures = control.DefaultFailures
}
if failures < 0 {
return control.Config{}, ErrLivenessFailuresInvalid
}
return control.Config{Interval: interval, Timeout: timeout, Failures: failures}, nil
}
func maxSessionDuration(cfg Config) (time.Duration, error) {
if cfg.MaxSessionDuration == "" {
return 0, nil
}
d, err := time.ParseDuration(cfg.MaxSessionDuration)
if err != nil {
return 0, fmt.Errorf("%w: %w", ErrLifecycleMaxSessionDurationInvalid, err)
}
if d <= 0 {
return 0, ErrLifecycleMaxSessionDurationInvalid
}
return d, nil
}
func validateTrafficConfig(cfg Config) error {
_, err := trafficConfig(cfg)
return err
}
func trafficConfig(cfg Config) (transport.TrafficConfig, error) {
if cfg.TrafficMaxPayloadSize < 0 || (cfg.TrafficMaxPayloadSize > 0 &&
cfg.TrafficMaxPayloadSize < runtime.MinSmuxWirePayload) {
return transport.TrafficConfig{}, ErrTrafficMaxPayloadSizeInvalid
}
minDelay, err := parseOptionalNonNegativeDuration(cfg.TrafficMinDelay)
if err != nil {
return transport.TrafficConfig{}, fmt.Errorf("%w: %w", ErrTrafficMinDelayInvalid, err)
}
maxDelay, err := parseOptionalNonNegativeDuration(cfg.TrafficMaxDelay)
if err != nil {
return transport.TrafficConfig{}, fmt.Errorf("%w: %w", ErrTrafficMaxDelayInvalid, err)
}
if maxDelay > 0 && maxDelay < minDelay {
return transport.TrafficConfig{}, ErrTrafficMaxDelayInvalid
}
return transport.TrafficConfig{
MaxPayloadSize: cfg.TrafficMaxPayloadSize,
MinDelay: minDelay,
MaxDelay: maxDelay,
}, nil
}
func parseOptionalNonNegativeDuration(value string) (time.Duration, error) {
if value == "" {
return 0, nil
}
d, err := time.ParseDuration(value)
if err != nil {
return 0, fmt.Errorf("parse duration: %w", err)
}
if d < 0 {
return 0, errNonNegativeDuration
}
return d, nil
}
func isLoopbackListenHost(host string) bool {
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// Run starts the configured mode.
func Run(ctx context.Context, cfg Config) error {
roomURL := buildRoomURL(cfg.Carrier, cfg.RoomID)
cfg = ApplyTransportDefaults(cfg)
cfg = ApplyLivenessDefaults(cfg)
configureDefaultResolver(cfg.DNSServer)
roomURL := cfg.RoomID
liveness, err := livenessConfig(cfg)
if err != nil {
return err
}
maxDuration, err := maxSessionDuration(cfg)
if err != nil {
return err
}
traffic, err := trafficConfig(cfg)
if err != nil {
return err
}
run := func(ctx context.Context) error {
return runOnce(ctx, cfg, roomURL, liveness, traffic)
}
if maxDuration > 0 {
return runWithSessionRotation(ctx, maxDuration, run)
}
return run(ctx)
}
func configureDefaultResolver(dnsServer string) {
if dnsServer == "" {
return
}
net.DefaultResolver = &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
d := net.Dialer{Timeout: 3 * time.Second}
return d.DialContext(ctx, network, dnsServer)
},
}
}
func runOnce(
ctx context.Context,
cfg Config,
roomURL string,
liveness control.Config,
traffic transport.TrafficConfig,
) error {
opts := buildTransportOptions(cfg)
switch cfg.Mode {
case modeSRV:
if err := server.Run(
ctx,
cfg.Link,
cfg.Transport,
cfg.Carrier,
roomURL,
cfg.KeyHex,
cfg.ClientID,
cfg.DNSServer,
cfg.SOCKSProxyAddr,
cfg.SOCKSProxyPort,
cfg.VideoWidth,
cfg.VideoHeight,
cfg.VideoFPS,
cfg.VideoBitrate,
cfg.VideoHW,
cfg.VideoQRSize,
cfg.VideoQRRecovery,
cfg.VideoCodec,
cfg.VideoTileModule,
cfg.VideoTileRS,
cfg.VP8FPS,
cfg.VP8BatchSize,
cfg.SEIFPS,
cfg.SEIBatchSize,
cfg.SEIFragmentSize,
cfg.SEIAckTimeoutMS,
); err != nil {
if err := server.Run(ctx, server.Config{
Transport: cfg.Transport,
Carrier: cfg.Auth,
RoomURL: roomURL,
ChannelID: cfg.ChannelID,
KeyHex: cfg.KeyHex,
DNSServer: cfg.DNSServer,
SOCKSProxyAddr: cfg.SOCKSProxyAddr,
SOCKSProxyPort: cfg.SOCKSProxyPort,
TransportOptions: opts,
Engine: cfg.Engine,
URL: cfg.URL,
Token: cfg.Token,
Liveness: liveness,
Traffic: traffic,
OnSessionOpen: func(sessionID, deviceID string, claims map[string]any) {
logger.Infof("session opened: id=%s device=%s claims=%v", sessionID, deviceID, claims)
},
OnSessionClose: func(sessionID, reason string) {
logger.Infof("session closed: id=%s reason=%s", sessionID, reason)
},
OnTraffic: func(sessionID, addr string, bytesIn, bytesOut uint64) {
logger.Infof("traffic: session=%s addr=%s in=%d out=%d", sessionID, addr, bytesIn, bytesOut)
},
}); err != nil {
return fmt.Errorf("server: %w", err)
}
return nil
case modeCNC:
if err := client.Run(
ctx,
cfg.Link,
cfg.Transport,
cfg.Carrier,
roomURL,
cfg.KeyHex,
cfg.ClientID,
fmt.Sprintf("%s:%d", cfg.SOCKSHost, cfg.SOCKSPort),
cfg.DNSServer,
cfg.SOCKSUser,
cfg.SOCKSPass,
cfg.VideoWidth,
cfg.VideoHeight,
cfg.VideoFPS,
cfg.VideoBitrate,
cfg.VideoHW,
cfg.VideoQRSize,
cfg.VideoQRRecovery,
cfg.VideoCodec,
cfg.VideoTileModule,
cfg.VideoTileRS,
cfg.VP8FPS,
cfg.VP8BatchSize,
cfg.SEIFPS,
cfg.SEIBatchSize,
cfg.SEIFragmentSize,
cfg.SEIAckTimeoutMS,
); err != nil {
if err := client.Run(ctx, client.Config{
Transport: cfg.Transport,
Carrier: cfg.Auth,
RoomURL: roomURL,
ChannelID: cfg.ChannelID,
KeyHex: cfg.KeyHex,
LocalAddr: fmt.Sprintf("%s:%d", cfg.SOCKSHost, cfg.SOCKSPort),
DNSServer: cfg.DNSServer,
SOCKSUser: cfg.SOCKSUser,
SOCKSPass: cfg.SOCKSPass,
TransportOptions: opts,
Engine: cfg.Engine,
URL: cfg.URL,
Token: cfg.Token,
Liveness: liveness,
Traffic: traffic,
}); err != nil {
return fmt.Errorf("client: %w", err)
}
return nil
@@ -384,29 +697,59 @@ func Run(ctx context.Context, cfg Config) error {
}
}
func buildRoomURL(carrierName, roomID string) string {
switch carrierName {
case carrierTelemost:
return telemostRoomURLPrefix + roomID
case carrierJazz:
if roomID == "" {
return roomURLAny
func runWithSessionRotation(ctx context.Context, maxDuration time.Duration, run func(context.Context) error) error {
for cycle := 1; ; cycle++ {
currentCycle := cycle
runCtx, cancel := context.WithCancel(ctx)
var rotated atomic.Bool
timer := time.AfterFunc(maxDuration, func() {
rotated.Store(true)
logger.Infof("session max duration reached: duration=%s cycle=%d", maxDuration, currentCycle)
cancel()
})
err := run(runCtx)
cancel()
timer.Stop()
if ctx.Err() != nil {
return nil //nolint:nilerr // parent cancellation is normal shutdown for rotation
}
return roomID
case carrierWBStream:
return roomID
default:
return roomID
if rotated.Load() {
if err != nil {
logger.Warnf("session rotation ended with error: cycle=%d err=%v", currentCycle, err)
}
logger.Infof("session rotation restarting: next_cycle=%d", currentCycle+1)
if err := waitSessionRestart(ctx); err != nil {
return nil //nolint:nilerr // canceled restart delay means normal shutdown
}
continue
}
if err != nil {
return err
}
logger.Infof("session ended cleanly with lifecycle rotation enabled: next_cycle=%d", currentCycle+1)
if err := waitSessionRestart(ctx); err != nil {
return nil //nolint:nilerr // canceled restart delay means normal shutdown
}
}
}
func waitSessionRestart(ctx context.Context) error {
select {
case <-ctx.Done():
return fmt.Errorf("restart delay canceled: %w", ctx.Err())
case <-time.After(sessionRestartDelay):
return nil
}
}
// ValidateGen validates that the config contains enough fields to run gen mode.
func ValidateGen(cfg Config) error {
if cfg.Carrier == "" {
return ErrCarrierRequired
if cfg.Auth == "" {
return ErrAuthRequired
}
if !slices.Contains(carrier.Available(), cfg.Carrier) {
return fmt.Errorf("%w: %s (available: %v)", ErrUnsupportedCarrier, cfg.Carrier, carrier.Available())
if !slices.Contains(enginebuiltin.Available(), cfg.Auth) {
return fmt.Errorf("%w: %s (available: %v)", ErrUnsupportedCarrier, cfg.Auth, enginebuiltin.Available())
}
if cfg.DNSServer == "" {
return ErrDNSServerRequired
@@ -414,6 +757,13 @@ func ValidateGen(cfg Config) error {
if cfg.Amount < 1 {
return ErrAmountRequired
}
p, err := auth.Get(cfg.Auth)
if err != nil {
return fmt.Errorf("%w: %s", ErrUnsupportedCarrier, cfg.Auth)
}
if _, ok := p.(auth.RoomCreator); !ok {
return fmt.Errorf("%w: %s does not support room generation", ErrUnsupportedCarrier, cfg.Auth)
}
return nil
}
@@ -440,27 +790,31 @@ func genRetry(ctx context.Context, fn func(context.Context) error) error {
return lastErr
}
// Gen creates cfg.Amount rooms for the configured carrier and writes each room ID to out.
// Gen creates cfg.Amount rooms for the configured auth provider and writes each room ID to out.
func Gen(ctx context.Context, cfg Config, out func(string)) error {
switch cfg.Carrier {
case carrierJazz:
for i := range cfg.Amount {
var roomID string
err := genRetry(ctx, func(ctx context.Context) error {
info, err := jazz.CreateRoom(ctx)
if err != nil {
return fmt.Errorf("jazz.CreateRoom: %w", err)
}
roomID = info.RoomID
return nil
})
if err != nil {
return fmt.Errorf("gen jazz room %d: %w", i+1, err)
configureDefaultResolver(cfg.DNSServer)
p, err := auth.Get(cfg.Auth)
if err != nil {
return fmt.Errorf("%w: %s", ErrUnsupportedCarrier, cfg.Auth)
}
creator, ok := p.(auth.RoomCreator)
if !ok {
return fmt.Errorf("%w: %s does not support room generation", ErrUnsupportedCarrier, cfg.Auth)
}
for i := range cfg.Amount {
var roomID string
err := genRetry(ctx, func(ctx context.Context) error {
var genErr error
roomID, genErr = creator.CreateRoom(ctx, auth.Config{Name: names.Generate(), DNSServer: cfg.DNSServer})
if genErr != nil {
return fmt.Errorf("CreateRoom: %w", genErr)
}
out(roomID)
return nil
})
if err != nil {
return fmt.Errorf("gen room %d: %w", i+1, err)
}
default:
return fmt.Errorf("%w: %s does not support room generation", ErrUnsupportedCarrier, cfg.Carrier)
out(roomID)
}
return nil
}

View File

@@ -3,22 +3,134 @@ package session
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
"github.com/openlibrecommunity/olcrtc/internal/control"
"github.com/openlibrecommunity/olcrtc/internal/runtime"
)
const testBadDuration = "nope"
func TestApplyTransportDefaults(t *testing.T) {
tests := []struct {
name string
in Config
want Config
}{
{
name: "vp8",
in: Config{Transport: transportVP8},
want: Config{Transport: transportVP8, VP8: VP8Config{FPS: 60, BatchSize: 64}},
},
{
name: "sei",
in: Config{Transport: transportSEI},
want: Config{
Transport: transportSEI,
SEI: SEIConfig{FPS: 60, BatchSize: 64, FragmentSize: 900, AckTimeoutMS: 2000},
},
},
{
name: "video qrcode",
in: Config{Transport: transportVideo},
want: Config{
Transport: transportVideo,
Video: VideoConfig{
Width: 1920, Height: 1080, FPS: 30, Bitrate: "2M",
HW: defaultVideoHW, QRRecovery: "low", Codec: videoCodecQRCode,
},
},
},
{
name: "video tile dimensions",
in: Config{Transport: transportVideo, Video: VideoConfig{Codec: videoCodecTile}},
want: Config{
Transport: transportVideo,
Video: VideoConfig{
Width: 1080, Height: 1080, FPS: 30, Bitrate: "2M",
HW: defaultVideoHW, QRRecovery: "low", Codec: videoCodecTile,
},
},
},
{
name: "keeps explicit values",
in: Config{
Transport: transportSEI,
SEI: SEIConfig{FPS: 10, BatchSize: 2, FragmentSize: 300, AckTimeoutMS: 1500},
},
want: Config{
Transport: transportSEI,
SEI: SEIConfig{FPS: 10, BatchSize: 2, FragmentSize: 300, AckTimeoutMS: 1500},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ApplyTransportDefaults(tt.in)
if got != tt.want {
t.Fatalf("ApplyTransportDefaults() = %+v, want %+v", got, tt.want)
}
})
}
}
func TestApplyLivenessDefaults(t *testing.T) {
got := ApplyLivenessDefaults(Config{})
if got.LivenessInterval != control.DefaultInterval.String() {
t.Fatalf("LivenessInterval = %q, want %q", got.LivenessInterval, control.DefaultInterval.String())
}
if got.LivenessTimeout != control.DefaultTimeout.String() {
t.Fatalf("LivenessTimeout = %q, want %q", got.LivenessTimeout, control.DefaultTimeout.String())
}
if got.LivenessFailures != control.DefaultFailures {
t.Fatalf("LivenessFailures = %d, want %d", got.LivenessFailures, control.DefaultFailures)
}
explicit := Config{LivenessInterval: "1s", LivenessTimeout: "500ms", LivenessFailures: 9}
if got := ApplyLivenessDefaults(explicit); got != explicit {
t.Fatalf("ApplyLivenessDefaults() = %+v, want %+v", got, explicit)
}
}
func TestRunWithSessionRotationRestartsAfterMaxDuration(t *testing.T) {
oldRestartDelay := sessionRestartDelay
sessionRestartDelay = time.Millisecond
t.Cleanup(func() { sessionRestartDelay = oldRestartDelay })
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var calls atomic.Int32
err := runWithSessionRotation(ctx, 5*time.Millisecond, func(ctx context.Context) error {
if calls.Add(1) >= 2 {
cancel()
return nil
}
<-ctx.Done()
return nil
})
if err != nil {
t.Fatalf("runWithSessionRotation() error = %v", err)
}
if got := calls.Load(); got < 2 {
t.Fatalf("run calls = %d, want at least 2", got)
}
}
//nolint:maintidx // table-driven validation test naturally has many cases
func TestValidate(t *testing.T) {
RegisterDefaults()
base := Config{
Mode: modeSRV,
Link: "direct",
Transport: "datachannel",
Carrier: "telemost", //nolint:goconst // test literal, repetition is intentional
Auth: "telemost",
RoomID: "room-1",
ClientID: "client-1",
KeyHex: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff",
DNSServer: "1.1.1.1:53", //nolint:goconst // test literal, repetition is intentional
DNSServer: "8.8.8.8:53", //nolint:goconst // test literal, repetition is intentional
}
tests := []struct {
@@ -27,15 +139,6 @@ func TestValidate(t *testing.T) {
want error
}{
{name: "valid baseline", cfg: base},
{
name: "jazz allows empty room id",
cfg: func() Config {
cfg := base
cfg.Carrier = "jazz" //nolint:goconst // test literal, repetition is intentional
cfg.RoomID = ""
return cfg
}(),
},
{
name: "cnc requires socks host and port",
cfg: func() Config {
@@ -59,20 +162,11 @@ func TestValidate(t *testing.T) {
name: "unsupported carrier",
cfg: func() Config {
cfg := base
cfg.Carrier = "unknown" //nolint:goconst // test literal, repetition is intentional
cfg.Auth = "unknown" //nolint:goconst // test literal, repetition is intentional
return cfg
}(),
want: ErrUnsupportedCarrier,
},
{
name: "unsupported link",
cfg: func() Config {
cfg := base
cfg.Link = "unknown"
return cfg
}(),
want: ErrUnsupportedLink,
},
{
name: "unsupported transport",
cfg: func() Config {
@@ -83,7 +177,7 @@ func TestValidate(t *testing.T) {
want: ErrUnsupportedTransport,
},
{
name: "room id required for non jazz",
name: "room id required",
cfg: func() Config {
cfg := base
cfg.RoomID = ""
@@ -91,15 +185,6 @@ func TestValidate(t *testing.T) {
}(),
want: ErrRoomIDRequired,
},
{
name: "client id required",
cfg: func() Config {
cfg := base
cfg.ClientID = ""
return cfg
}(),
want: ErrClientIDRequired,
},
{
name: "key required",
cfg: func() Config {
@@ -132,12 +217,12 @@ func TestValidate(t *testing.T) {
cfg: func() Config {
cfg := base
cfg.Transport = "videochannel"
cfg.VideoWidth = 640
cfg.VideoHeight = 480
cfg.VideoFPS = 30
cfg.VideoBitrate = "1M"
cfg.VideoHW = "none" //nolint:goconst // test literal, repetition is intentional
cfg.VideoCodec = "bogus"
cfg.Video.Width = 640
cfg.Video.Height = 480
cfg.Video.FPS = 30
cfg.Video.Bitrate = "1M"
cfg.Video.HW = defaultVideoHW
cfg.Video.Codec = "bogus"
return cfg
}(),
want: ErrVideoCodecInvalid,
@@ -147,7 +232,7 @@ func TestValidate(t *testing.T) {
cfg: func() Config {
cfg := base
cfg.Transport = "videochannel"
cfg.VideoWidth = 640
cfg.Video.Width = 640
return cfg
}(),
want: ErrVideoHeightRequired,
@@ -157,8 +242,8 @@ func TestValidate(t *testing.T) {
cfg: func() Config {
cfg := base
cfg.Transport = "videochannel"
cfg.VideoWidth = 640
cfg.VideoHeight = 480
cfg.Video.Width = 640
cfg.Video.Height = 480
return cfg
}(),
want: ErrVideoFPSRequired,
@@ -168,9 +253,9 @@ func TestValidate(t *testing.T) {
cfg: func() Config {
cfg := base
cfg.Transport = "videochannel"
cfg.VideoWidth = 640
cfg.VideoHeight = 480
cfg.VideoFPS = 30
cfg.Video.Width = 640
cfg.Video.Height = 480
cfg.Video.FPS = 30
return cfg
}(),
want: ErrVideoBitrateRequired,
@@ -180,10 +265,10 @@ func TestValidate(t *testing.T) {
cfg: func() Config {
cfg := base
cfg.Transport = "videochannel"
cfg.VideoWidth = 640
cfg.VideoHeight = 480
cfg.VideoFPS = 30
cfg.VideoBitrate = "1M"
cfg.Video.Width = 640
cfg.Video.Height = 480
cfg.Video.FPS = 30
cfg.Video.Bitrate = "1M"
return cfg
}(),
want: ErrVideoHWRequired,
@@ -193,12 +278,12 @@ func TestValidate(t *testing.T) {
cfg: func() Config {
cfg := base
cfg.Transport = "videochannel"
cfg.VideoWidth = 640
cfg.VideoHeight = 480
cfg.VideoFPS = 30
cfg.VideoBitrate = "1M"
cfg.VideoHW = "none"
cfg.VideoCodec = "tile"
cfg.Video.Width = 640
cfg.Video.Height = 480
cfg.Video.FPS = 30
cfg.Video.Bitrate = "1M"
cfg.Video.HW = defaultVideoHW
cfg.Video.Codec = "tile"
return cfg
}(),
want: ErrTileCodecDimensions,
@@ -208,12 +293,12 @@ func TestValidate(t *testing.T) {
cfg: func() Config {
cfg := base
cfg.Transport = "videochannel"
cfg.VideoWidth = 1080
cfg.VideoHeight = 1080
cfg.VideoFPS = 30
cfg.VideoBitrate = "1M"
cfg.VideoHW = "none"
cfg.VideoCodec = "tile"
cfg.Video.Width = 1080
cfg.Video.Height = 1080
cfg.Video.FPS = 30
cfg.Video.Bitrate = "1M"
cfg.Video.HW = defaultVideoHW
cfg.Video.Codec = "tile"
return cfg
}(),
},
@@ -231,7 +316,7 @@ func TestValidate(t *testing.T) {
cfg: func() Config {
cfg := base
cfg.Transport = "vp8channel"
cfg.VP8FPS = 25
cfg.VP8.FPS = 25
return cfg
}(),
want: ErrVP8BatchSizeRequired,
@@ -241,8 +326,8 @@ func TestValidate(t *testing.T) {
cfg: func() Config {
cfg := base
cfg.Transport = "vp8channel"
cfg.VP8FPS = 25
cfg.VP8BatchSize = 16
cfg.VP8.FPS = 25
cfg.VP8.BatchSize = 16
return cfg
}(),
},
@@ -260,7 +345,7 @@ func TestValidate(t *testing.T) {
cfg: func() Config {
cfg := base
cfg.Transport = "seichannel"
cfg.SEIFPS = 20
cfg.SEI.FPS = 20
return cfg
}(),
want: ErrSEIBatchSizeRequired,
@@ -270,8 +355,8 @@ func TestValidate(t *testing.T) {
cfg: func() Config {
cfg := base
cfg.Transport = "seichannel"
cfg.SEIFPS = 20
cfg.SEIBatchSize = 1
cfg.SEI.FPS = 20
cfg.SEI.BatchSize = 1
return cfg
}(),
want: ErrSEIFragmentSizeRequired,
@@ -281,9 +366,9 @@ func TestValidate(t *testing.T) {
cfg: func() Config {
cfg := base
cfg.Transport = "seichannel"
cfg.SEIFPS = 20
cfg.SEIBatchSize = 1
cfg.SEIFragmentSize = 900
cfg.SEI.FPS = 20
cfg.SEI.BatchSize = 1
cfg.SEI.FragmentSize = 900
return cfg
}(),
want: ErrSEIAckTimeoutRequired,
@@ -293,10 +378,10 @@ func TestValidate(t *testing.T) {
cfg: func() Config {
cfg := base
cfg.Transport = "seichannel"
cfg.SEIFPS = 20
cfg.SEIBatchSize = 1
cfg.SEIFragmentSize = 900
cfg.SEIAckTimeoutMS = 3000
cfg.SEI.FPS = 20
cfg.SEI.BatchSize = 1
cfg.SEI.FragmentSize = 900
cfg.SEI.AckTimeoutMS = 3000
return cfg
}(),
},
@@ -320,6 +405,148 @@ func TestValidate(t *testing.T) {
}(),
want: ErrSOCKSPortRequired,
},
{
name: "cnc rejects unauthenticated wildcard socks bind",
cfg: func() Config {
cfg := base
cfg.Mode = modeCNC
cfg.SOCKSHost = "0.0.0.0"
cfg.SOCKSPort = 1080
return cfg
}(),
want: ErrSOCKSAuthRequired,
},
{
name: "cnc allows authenticated wildcard socks bind",
cfg: func() Config {
cfg := base
cfg.Mode = modeCNC
cfg.SOCKSHost = "0.0.0.0"
cfg.SOCKSPort = 1080
cfg.SOCKSUser = "user"
cfg.SOCKSPass = "pass"
return cfg
}(),
},
{
name: "cnc allows localhost socks bind without auth",
cfg: func() Config {
cfg := base
cfg.Mode = modeCNC
cfg.SOCKSHost = "localhost"
cfg.SOCKSPort = 1080
return cfg
}(),
},
{
name: "liveness rejects bad interval",
cfg: func() Config {
cfg := base
cfg.LivenessInterval = testBadDuration
return cfg
}(),
want: ErrLivenessIntervalInvalid,
},
{
name: "liveness rejects zero timeout",
cfg: func() Config {
cfg := base
cfg.LivenessTimeout = "0s"
return cfg
}(),
want: ErrLivenessTimeoutInvalid,
},
{
name: "liveness rejects negative failures",
cfg: func() Config {
cfg := base
cfg.LivenessFailures = -1
return cfg
}(),
want: ErrLivenessFailuresInvalid,
},
{
name: "lifecycle accepts max session duration",
cfg: func() Config {
cfg := base
cfg.MaxSessionDuration = "1h"
return cfg
}(),
},
{
name: "lifecycle rejects bad max session duration",
cfg: func() Config {
cfg := base
cfg.MaxSessionDuration = testBadDuration
return cfg
}(),
want: ErrLifecycleMaxSessionDurationInvalid,
},
{
name: "lifecycle rejects zero max session duration",
cfg: func() Config {
cfg := base
cfg.MaxSessionDuration = "0s"
return cfg
}(),
want: ErrLifecycleMaxSessionDurationInvalid,
},
{
name: "traffic accepts shaping",
cfg: func() Config {
cfg := base
cfg.TrafficMaxPayloadSize = 4096
cfg.TrafficMinDelay = "5ms"
cfg.TrafficMaxDelay = "30ms"
return cfg
}(),
},
{
name: "traffic rejects negative max payload",
cfg: func() Config {
cfg := base
cfg.TrafficMaxPayloadSize = -1
return cfg
}(),
want: ErrTrafficMaxPayloadSizeInvalid,
},
{
name: "traffic rejects payload too small for encrypted smux frame",
cfg: func() Config {
cfg := base
cfg.TrafficMaxPayloadSize = runtime.MinSmuxWirePayload - 1
return cfg
}(),
want: ErrTrafficMaxPayloadSizeInvalid,
},
{
name: "traffic rejects bad min delay",
cfg: func() Config {
cfg := base
cfg.TrafficMinDelay = testBadDuration
return cfg
}(),
want: ErrTrafficMinDelayInvalid,
},
{
name: "traffic rejects negative max delay",
cfg: func() Config {
cfg := base
cfg.TrafficMaxDelay = "-1ms"
return cfg
}(),
want: ErrTrafficMaxDelayInvalid,
},
{
name: "traffic rejects max delay below min delay",
cfg: func() Config {
cfg := base
cfg.TrafficMinDelay = "30ms"
cfg.TrafficMaxDelay = "5ms"
return cfg
}(),
want: ErrTrafficMaxDelayInvalid,
},
}
for _, tt := range tests {
@@ -338,25 +565,7 @@ func TestValidate(t *testing.T) {
}
}
func TestBuildRoomURL(t *testing.T) {
tests := []struct {
carrier string
roomID string
want string
}{
{carrier: "telemost", roomID: "abc", want: "https://telemost.yandex.ru/j/abc"},
{carrier: "jazz", roomID: "", want: "any"},
{carrier: "jazz", roomID: "room", want: "room"},
{carrier: "wbstream", roomID: "wb", want: "wb"}, //nolint:goconst // test literal, repetition is intentional
{carrier: "other", roomID: "raw", want: "raw"},
}
for _, tt := range tests {
if got := buildRoomURL(tt.carrier, tt.roomID); got != tt.want {
t.Fatalf("buildRoomURL(%q, %q) = %q, want %q", tt.carrier, tt.roomID, got, tt.want)
}
}
}
const testAuthWBStream = "wbstream"
func TestValidateGen(t *testing.T) {
RegisterDefaults()
@@ -367,36 +576,33 @@ func TestValidateGen(t *testing.T) {
want error
}{
{
name: "valid wbstream",
cfg: Config{Carrier: "wbstream", DNSServer: "1.1.1.1:53", Amount: 3},
name: "wbstream room generation unsupported",
cfg: Config{Auth: testAuthWBStream, DNSServer: "8.8.8.8:53", Amount: 3},
want: ErrUnsupportedCarrier,
},
{
name: "valid jazz",
cfg: Config{Carrier: "jazz", DNSServer: "1.1.1.1:53", Amount: 1},
name: "missing auth",
cfg: Config{DNSServer: "8.8.8.8:53", Amount: 1},
want: ErrAuthRequired,
},
{
name: "missing carrier",
cfg: Config{DNSServer: "1.1.1.1:53", Amount: 1},
want: ErrCarrierRequired,
},
{
name: "unsupported carrier",
cfg: Config{Carrier: "unknown", DNSServer: "1.1.1.1:53", Amount: 1},
name: "unsupported auth",
cfg: Config{Auth: "unknown", DNSServer: "8.8.8.8:53", Amount: 1},
want: ErrUnsupportedCarrier,
},
{
name: "missing dns",
cfg: Config{Carrier: "wbstream", Amount: 1},
cfg: Config{Auth: testAuthWBStream, Amount: 1},
want: ErrDNSServerRequired,
},
{
name: "amount zero",
cfg: Config{Carrier: "wbstream", DNSServer: "1.1.1.1:53", Amount: 0},
cfg: Config{Auth: testAuthWBStream, DNSServer: "8.8.8.8:53", Amount: 0},
want: ErrAmountRequired,
},
{
name: "amount negative",
cfg: Config{Carrier: "wbstream", DNSServer: "1.1.1.1:53", Amount: -1},
cfg: Config{Auth: testAuthWBStream, DNSServer: "8.8.8.8:53", Amount: -1},
want: ErrAmountRequired,
},
}
@@ -417,9 +623,9 @@ func TestValidateGen(t *testing.T) {
}
}
func TestGenUnsupportedCarrier(t *testing.T) {
func TestGenUnsupportedAuth(t *testing.T) {
RegisterDefaults()
cfg := Config{Carrier: "telemost", DNSServer: "1.1.1.1:53", Amount: 1}
cfg := Config{Auth: "telemost", DNSServer: "8.8.8.8:53", Amount: 1}
err := Gen(context.Background(), cfg, func(string) {})
if !errors.Is(err, ErrUnsupportedCarrier) {
t.Fatalf("Gen(telemost) error = %v, want ErrUnsupportedCarrier", err)

View File

@@ -0,0 +1,43 @@
package session
import (
"github.com/openlibrecommunity/olcrtc/internal/transport"
"github.com/openlibrecommunity/olcrtc/internal/transport/seichannel"
"github.com/openlibrecommunity/olcrtc/internal/transport/videochannel"
"github.com/openlibrecommunity/olcrtc/internal/transport/vp8channel"
)
// buildTransportOptions packs per-transport tuning fields from cfg into the
// typed Options value the chosen transport expects. Transports without
// tunable options (datachannel) return nil.
func buildTransportOptions(cfg Config) transport.Options {
switch cfg.Transport {
case transportVideo:
return videochannel.Options{
Width: cfg.Video.Width,
Height: cfg.Video.Height,
FPS: cfg.Video.FPS,
Bitrate: cfg.Video.Bitrate,
HW: cfg.Video.HW,
QRSize: cfg.Video.QRSize,
QRRecovery: cfg.Video.QRRecovery,
Codec: cfg.Video.Codec,
TileModule: cfg.Video.TileModule,
TileRS: cfg.Video.TileRS,
}
case transportVP8:
return vp8channel.Options{
FPS: cfg.VP8.FPS,
BatchSize: cfg.VP8.BatchSize,
}
case transportSEI:
return seichannel.Options{
FPS: cfg.SEI.FPS,
BatchSize: cfg.SEI.BatchSize,
FragmentSize: cfg.SEI.FragmentSize,
AckTimeoutMS: cfg.SEI.AckTimeoutMS,
}
default:
return nil
}
}

95
internal/auth/auth.go Normal file
View File

@@ -0,0 +1,95 @@
// Package auth defines how room credentials are produced for an engine.
//
// An auth provider is responsible for any service-specific HTTP / login flow
// (WB Stream, Yandex Telemost, Jitsi, ...) and produces a
// Credentials value that an engine can use to connect. Some auth providers
// also support creating new rooms; that capability is optional and is
// expressed via the RoomCreator interface.
//
// The "none" auth provider passes a caller-supplied URL+Token through
// unchanged — this is the path that sing-box and other downstream consumers
// take when they want to use olcrtc as a generic LiveKit/Goolom/Jitsi
// transport without any service-specific behaviour baked in.
package auth
import (
"context"
"errors"
)
var (
// ErrAuthNotFound is returned when a requested auth provider is not registered.
ErrAuthNotFound = errors.New("auth provider not found")
// ErrRoomCreationUnsupported is returned when an auth provider cannot create rooms.
ErrRoomCreationUnsupported = errors.New("auth provider does not support room creation")
// ErrRoomIDRequired is returned when an auth flow needs an existing room ID and none was supplied.
ErrRoomIDRequired = errors.New("room ID required")
)
// Credentials carry everything an engine needs to connect to an SFU.
//
// URL is the signaling endpoint (e.g. wss://livekit.example/). Token is the
// access token (LiveKit JWT, Goolom session credential, etc). Extra is for
// engine-specific bits that don't fit the common shape — engines should not
// rely on it being populated unless their paired auth provider documents it.
type Credentials struct {
URL string
Token string
Extra map[string]string
}
// Config is the input to an auth provider.
type Config struct {
// RoomURL is the user-facing room link (e.g. https://telemost.yandex.ru/j/123).
// Optional for providers that can also create rooms on demand.
RoomURL string
// Name is the display name to register with.
Name string
// DNSServer / ProxyAddr / ProxyPort are network knobs for outbound HTTP.
DNSServer string
ProxyAddr string
ProxyPort int
}
// Provider produces engine credentials.
type Provider interface {
// Engine reports which engine this auth provider feeds.
Engine() string
// DefaultServiceURL returns the well-known service URL for this provider
// (e.g. "https://stream.wb.ru"). Returns "" if no default exists — in that
// case the caller must supply -url explicitly.
DefaultServiceURL() string
// Issue obtains credentials for the given room.
Issue(ctx context.Context, cfg Config) (Credentials, error)
}
// RoomCreator is implemented by auth providers that can create new rooms
// against their backing service. Used by `olcrtc -mode gen`.
type RoomCreator interface {
CreateRoom(ctx context.Context, cfg Config) (roomID string, err error)
}
var registry = make(map[string]Provider) //nolint:gochecknoglobals // package-level state intentional
// Register adds an auth provider to the registry.
func Register(name string, p Provider) {
registry[name] = p
}
// Get returns a registered auth provider by name.
func Get(name string) (Provider, error) {
p, ok := registry[name]
if !ok {
return nil, ErrAuthNotFound
}
return p, nil
}
// Available returns the list of registered auth provider names.
func Available() []string {
names := make([]string, 0, len(registry))
for name := range registry {
names = append(names, name)
}
return names
}

View File

@@ -0,0 +1,96 @@
// 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" }
const defaultServiceURL = "https://meet.cryptopro.ru"
// DefaultServiceURL returns the default Jitsi Meet service URL used by config
// defaults and interactive helpers.
func (Provider) DefaultServiceURL() string { return defaultServiceURL }
// 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) (string, string, 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,88 @@
package jitsi
import (
"context"
"errors"
"testing"
"github.com/openlibrecommunity/olcrtc/internal/auth"
)
const (
testRoom = "myroom"
testHost = "meet.example"
)
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/" + testRoom, host: "meet.cryptopro.ru", room: testRoom},
{name: "http url", raw: "http://" + testHost + "/" + testRoom, host: testHost, room: testRoom},
{name: "scheme-less", raw: "meet.example.com/" + testRoom, host: "meet.example.com", room: testRoom},
{name: "trailing slash", raw: "https://" + testHost + "/" + testRoom + "/", host: testHost, room: testRoom},
{name: "double slash leader", raw: "//" + testHost + "/" + testRoom, host: testHost, room: testRoom},
{name: "uppercase room", raw: "https://" + testHost + "/MyRoom", host: testHost, room: "MyRoom"},
{name: "empty", raw: "", wantErr: true},
{name: "host only", raw: "meet.example.com", wantErr: true},
{name: "no room", raw: "https://" + testHost + "/", 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 != defaultServiceURL {
t.Fatalf("DefaultServiceURL() = %q, want %q", got, defaultServiceURL)
}
}

View File

@@ -1,3 +1,9 @@
// Package telemost is the auth provider for the Yandex Telemost service.
// It fetches the connection metadata (media server URL, peer ID, room ID,
// signing credentials) the Goolom engine needs to join a conference.
//
// Telemost does not expose an API to create rooms — they originate in the
// Yandex UI — so this provider does not implement auth.RoomCreator.
package telemost
import (
@@ -5,7 +11,6 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
@@ -63,14 +68,12 @@ func GetConnectionInfo(ctx context.Context, roomURL, displayName string) (*Conne
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("%w %d: %s", ErrAPI, resp.StatusCode, body)
return nil, fmt.Errorf("telemost api status: %w", protect.StatusError(ErrAPI, resp, 4096))
}
var info ConnectionInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &info, nil
}

View File

@@ -31,9 +31,9 @@ func TestGetConnectionInfo(t *testing.T) {
t.Fatalf("display_name query = %q", r.URL.Query().Get("display_name"))
}
_ = json.NewEncoder(w).Encode(ConnectionInfo{
RoomID: "room", //nolint:goconst // test literal, repetition is intentional
PeerID: "peer-id", //nolint:goconst // test literal, repetition is intentional
Credentials: "creds", //nolint:goconst // test literal, repetition is intentional
RoomID: "room",
PeerID: "peer-id",
Credentials: "creds",
})
})
@@ -63,24 +63,3 @@ func TestGetConnectionInfoErrors(t *testing.T) {
t.Fatal("GetConnectionInfo() unexpectedly accepted bad json")
}
}
func TestTelemostNewPeerUsesConnectionInfo(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(ConnectionInfo{
RoomID: "room",
PeerID: "peer-id",
Credentials: "creds",
})
})
withTelemostAPIServer(t, mux)
p, err := NewPeer(context.Background(), "room", "name", nil)
if err != nil {
t.Fatalf("NewPeer() error = %v", err)
}
if p.roomURL != "room" || p.name != "name" || p.conn.PeerID != "peer-id" || p.sendQueue == nil {
t.Fatalf("NewPeer() = %+v", p)
}
}

View File

@@ -0,0 +1,54 @@
package telemost
import (
"context"
"fmt"
"strings"
"github.com/openlibrecommunity/olcrtc/internal/auth"
)
const roomURLPrefix = "https://telemost.yandex.ru/j/"
// Provider produces Goolom credentials for the Yandex Telemost service.
type Provider struct{}
// Engine reports which engine consumes credentials from this auth provider.
func (Provider) Engine() string { return "goolom" }
// DefaultServiceURL returns the Telemost conference base URL.
func (Provider) DefaultServiceURL() string { return "https://telemost.yandex.ru" }
// Issue fetches connection info for a Telemost room and returns engine credentials.
//
// cfg.RoomURL accepts either a full Telemost conference URL
// (https://telemost.yandex.ru/j/<id>) or just the room ID hash. Room
// creation is not supported by the Telemost API; rooms originate in the
// Yandex UI.
func (Provider) Issue(ctx context.Context, cfg auth.Config) (auth.Credentials, error) {
if cfg.RoomURL == "" {
return auth.Credentials{}, auth.ErrRoomIDRequired
}
roomURL := cfg.RoomURL
if !strings.HasPrefix(roomURL, "https://") {
roomURL = roomURLPrefix + roomURL
}
info, err := GetConnectionInfo(ctx, roomURL, cfg.Name)
if err != nil {
return auth.Credentials{}, fmt.Errorf("get connection info: %w", err)
}
return auth.Credentials{
URL: info.ClientConfig.MediaServerURL,
Token: info.PeerID,
Extra: map[string]string{
"roomID": info.RoomID,
"credentials": info.Credentials,
"roomURL": roomURL,
"telemetryReferer": roomURL,
},
}, nil
}
func init() { //nolint:gochecknoinits // auth registration is the canonical Go pattern for plugins
auth.Register("telemost", Provider{})
}

View File

@@ -1,3 +1,6 @@
// Package wbstream is the auth provider for the WB Stream service. It
// produces LiveKit credentials by registering a guest, joining an existing
// room, and exchanging the guest access token for a room token.
package wbstream
import (
@@ -6,17 +9,17 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/openlibrecommunity/olcrtc/internal/protect"
)
const defaultWSURL = "wss://rtc-el-02.wb.ru"
var apiBase = "https://stream.wb.ru" //nolint:gochecknoglobals // package-level state intentional
var (
errGuestRegister = errors.New("guest register failed")
errCreateRoom = errors.New("create room failed")
errJoinRoom = errors.New("join room failed")
errGetToken = errors.New("get token failed")
)
@@ -35,15 +38,6 @@ type guestRegisterResponse struct {
AccessToken string `json:"accessToken"`
}
type createRoomRequest struct {
RoomType string `json:"roomType"`
RoomPrivacy string `json:"roomPrivacy"`
}
type createRoomResponse struct {
RoomID string `json:"roomId"`
}
type tokenResponse struct {
RoomToken string `json:"roomToken"`
ServerURL string `json:"serverUrl"`
@@ -78,8 +72,7 @@ func registerGuest(ctx context.Context, displayName string) (string, error) {
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("%w: %d %s", errGuestRegister, resp.StatusCode, b)
return "", fmt.Errorf("guest register status: %w", protect.StatusError(errGuestRegister, resp, 4096))
}
var res guestRegisterResponse
@@ -89,57 +82,6 @@ func registerGuest(ctx context.Context, displayName string) (string, error) {
return res.AccessToken, nil
}
func createRoom(ctx context.Context, accessToken string) (string, error) {
u := apiBase + "/api-room/api/v2/room"
reqBody := createRoomRequest{
RoomType: "ROOM_TYPE_ALL_ON_SCREEN",
RoomPrivacy: "ROOM_PRIVACY_FREE",
}
body, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal request body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewBuffer(body))
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("User-Agent", "Mozilla/5.0 (Linux x86_64)")
client := protect.NewHTTPClient()
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("do request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
b, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("%w: %d %s", errCreateRoom, resp.StatusCode, b)
}
var res createRoomResponse
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return "", fmt.Errorf("decode response: %w", err)
}
return res.RoomID, nil
}
// CreateRoom registers a temporary guest, creates a WB Stream room, and returns its id.
func CreateRoom(ctx context.Context, displayName string) (string, error) {
accessToken, err := registerGuest(ctx, displayName)
if err != nil {
return "", fmt.Errorf("register guest: %w", err)
}
roomID, err := createRoom(ctx, accessToken)
if err != nil {
return "", fmt.Errorf("create room: %w", err)
}
return roomID, nil
}
func joinRoom(ctx context.Context, accessToken, roomID string) error {
u := fmt.Sprintf("%s/api-room/api/v1/room/%s/join", apiBase, roomID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader([]byte("{}")))
@@ -158,17 +100,16 @@ func joinRoom(ctx context.Context, accessToken, roomID string) error {
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("%w: %d %s", errJoinRoom, resp.StatusCode, b)
return fmt.Errorf("join room status: %w", protect.StatusError(errJoinRoom, resp, 4096))
}
return nil
}
func getToken(ctx context.Context, accessToken, roomID, displayName string) (string, error) {
func getToken(ctx context.Context, accessToken, roomID, displayName string) (tokenResponse, error) {
u := fmt.Sprintf("%s/api-room-manager/v2/room/%s/connection-details", apiBase, roomID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return "", fmt.Errorf("create request: %w", err)
return tokenResponse{}, fmt.Errorf("create request: %w", err)
}
q := req.URL.Query()
@@ -182,18 +123,17 @@ func getToken(ctx context.Context, accessToken, roomID, displayName string) (str
client := protect.NewHTTPClient()
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("do request: %w", err)
return tokenResponse{}, fmt.Errorf("do request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("%w: %d %s", errGetToken, resp.StatusCode, b)
return tokenResponse{}, fmt.Errorf("get token status: %w", protect.StatusError(errGetToken, resp, 4096))
}
var res tokenResponse
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return "", fmt.Errorf("decode response: %w", err)
return tokenResponse{}, fmt.Errorf("decode response: %w", err)
}
return res.RoomToken, nil
return res, nil
}

View File

@@ -0,0 +1,124 @@
package wbstream
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/openlibrecommunity/olcrtc/internal/auth"
)
const (
testAccessToken = "access"
testRoomID = "room"
testToken = "token"
testPeerName = "peer"
)
func withWBAPIServer(t *testing.T, h http.Handler) {
t.Helper()
old := apiBase
srv := httptest.NewServer(h)
t.Cleanup(func() {
apiBase = old
srv.Close()
})
apiBase = srv.URL
}
func TestWBStreamAPIHappyPath(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("POST /auth/api/v1/auth/user/guest-register", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(guestRegisterResponse{AccessToken: testAccessToken}) //nolint:gosec
})
mux.HandleFunc("POST /api-room/api/v1/room/"+testRoomID+"/join", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("GET /api-room-manager/v2/room/"+testRoomID+"/connection-details",
func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("displayName") != testPeerName {
t.Fatalf("displayName query = %q", r.URL.Query().Get("displayName"))
}
_ = json.NewEncoder(w).Encode(tokenResponse{RoomToken: testToken})
})
withWBAPIServer(t, mux)
access, err := registerGuest(context.Background(), testPeerName)
if err != nil {
t.Fatalf("registerGuest() error = %v", err)
}
if access != testAccessToken {
t.Fatalf("registerGuest() = %q", access)
}
if err := joinRoom(context.Background(), access, testRoomID); err != nil {
t.Fatalf("joinRoom() error = %v", err)
}
tok, err := getToken(context.Background(), access, testRoomID, testPeerName)
if err != nil {
t.Fatalf("getToken() error = %v", err)
}
if tok.RoomToken != testToken {
t.Fatalf("getToken() = %q", tok.RoomToken)
}
}
func TestWBStreamAPIErrors(t *testing.T) {
withWBAPIServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "bad", http.StatusBadGateway)
}))
if _, err := registerGuest(context.Background(), testPeerName); !errors.Is(err, errGuestRegister) {
t.Fatalf("registerGuest() error = %v, want %v", err, errGuestRegister)
}
if err := joinRoom(context.Background(), testAccessToken, testRoomID); !errors.Is(err, errJoinRoom) {
t.Fatalf("joinRoom() error = %v, want %v", err, errJoinRoom)
}
if _, err := getToken(context.Background(), testAccessToken, testRoomID, testPeerName); !errors.Is(err, errGetToken) {
t.Fatalf("getToken() error = %v, want %v", err, errGetToken)
}
}
func TestWBStreamIssue(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("POST /auth/api/v1/auth/user/guest-register", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(guestRegisterResponse{AccessToken: testAccessToken}) //nolint:gosec
})
mux.HandleFunc("POST /api-room/api/v1/room/{id}/join", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("GET /api-room-manager/v2/room/{id}/connection-details", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(tokenResponse{RoomToken: testToken})
})
withWBAPIServer(t, mux)
p := Provider{}
creds, err := p.Issue(context.Background(), auth.Config{
RoomURL: testRoomID,
Name: testPeerName,
})
if err != nil {
t.Fatalf("Issue() error = %v", err)
}
if creds.Token != testToken {
t.Fatalf("creds.Token = %q", creds.Token)
}
if creds.Extra["roomID"] != testRoomID {
t.Fatalf("creds.Extra[roomID] = %q", creds.Extra["roomID"])
}
}
func TestWBStreamIssueRequiresRoom(t *testing.T) {
p := Provider{}
for _, roomURL := range []string{"", "any"} {
_, err := p.Issue(context.Background(), auth.Config{RoomURL: roomURL, Name: testPeerName})
if !errors.Is(err, auth.ErrRoomIDRequired) {
t.Fatalf("Issue(RoomURL=%q) error = %v, want %v", roomURL, err, auth.ErrRoomIDRequired)
}
}
}

View File

@@ -0,0 +1,54 @@
package wbstream
import (
"context"
"fmt"
"github.com/openlibrecommunity/olcrtc/internal/auth"
)
// Provider produces LiveKit credentials for the WB Stream service.
type Provider struct{}
// Engine reports which engine consumes credentials from this auth provider.
func (Provider) Engine() string { return "livekit" }
// DefaultServiceURL returns the WB Stream service URL.
func (Provider) DefaultServiceURL() string { return "https://stream.wb.ru" }
// Issue runs the WB Stream auth flow and returns LiveKit credentials.
func (Provider) Issue(ctx context.Context, cfg auth.Config) (auth.Credentials, error) {
if cfg.RoomURL == "" || cfg.RoomURL == "any" {
return auth.Credentials{}, auth.ErrRoomIDRequired
}
accessToken, err := registerGuest(ctx, cfg.Name)
if err != nil {
return auth.Credentials{}, fmt.Errorf("register guest: %w", err)
}
roomID := cfg.RoomURL
if err := joinRoom(ctx, accessToken, roomID); err != nil {
return auth.Credentials{}, fmt.Errorf("join room: %w", err)
}
tok, err := getToken(ctx, accessToken, roomID, cfg.Name)
if err != nil {
return auth.Credentials{}, fmt.Errorf("get token: %w", err)
}
url := tok.ServerURL
if url == "" {
url = defaultWSURL
}
return auth.Credentials{
URL: url,
Token: tok.RoomToken,
Extra: map[string]string{"roomID": roomID},
}, nil
}
func init() { //nolint:gochecknoinits // auth registration is the canonical Go pattern for plugins
auth.Register("wbstream", Provider{})
}

View File

@@ -1,121 +0,0 @@
package builtin
import (
"context"
"fmt"
"github.com/openlibrecommunity/olcrtc/internal/carrier"
"github.com/openlibrecommunity/olcrtc/internal/provider"
"github.com/pion/webrtc/v4"
)
type providerSession struct {
provider provider.Provider
}
func (s *providerSession) Capabilities() carrier.Capabilities {
caps := carrier.Capabilities{ByteStream: true}
_, caps.VideoTrack = s.provider.(videoTrackProvider)
return caps
}
func (s *providerSession) OpenByteStream() (carrier.ByteStream, error) {
return &providerByteStream{provider: s.provider}, nil
}
func (s *providerSession) OpenVideoTrack() (carrier.VideoTrack, error) {
vtp, ok := s.provider.(videoTrackProvider)
if !ok {
return nil, carrier.ErrVideoTrackUnsupported
}
return &providerVideoTrack{provider: vtp}, nil
}
type videoTrackProvider interface {
provider.Provider
provider.VideoTrackCapable
}
type providerByteStream struct {
provider provider.Provider
}
func (p *providerByteStream) Connect(ctx context.Context) error {
if err := p.provider.Connect(ctx); err != nil {
return fmt.Errorf("connect: %w", err)
}
return nil
}
func (p *providerByteStream) Send(data []byte) error {
if err := p.provider.Send(data); err != nil {
return fmt.Errorf("send: %w", err)
}
return nil
}
func (p *providerByteStream) Close() error {
if err := p.provider.Close(); err != nil {
return fmt.Errorf("close: %w", err)
}
return nil
}
func (p *providerByteStream) SetReconnectCallback(cb func()) {
p.provider.SetReconnectCallback(func(_ *webrtc.DataChannel) {
if cb != nil {
cb()
}
})
}
func (p *providerByteStream) SetShouldReconnect(fn func() bool) { p.provider.SetShouldReconnect(fn) }
func (p *providerByteStream) SetEndedCallback(cb func(string)) { p.provider.SetEndedCallback(cb) }
func (p *providerByteStream) WatchConnection(ctx context.Context) {
p.provider.WatchConnection(ctx)
}
func (p *providerByteStream) CanSend() bool { return p.provider.CanSend() }
type providerVideoTrack struct {
provider videoTrackProvider
}
func (v *providerVideoTrack) Connect(ctx context.Context) error {
if err := v.provider.Connect(ctx); err != nil {
return fmt.Errorf("connect: %w", err)
}
return nil
}
func (v *providerVideoTrack) Close() error {
if err := v.provider.Close(); err != nil {
return fmt.Errorf("close: %w", err)
}
return nil
}
func (v *providerVideoTrack) SetReconnectCallback(cb func()) {
v.provider.SetReconnectCallback(func(_ *webrtc.DataChannel) {
if cb != nil {
cb()
}
})
}
func (v *providerVideoTrack) SetShouldReconnect(fn func() bool) { v.provider.SetShouldReconnect(fn) }
func (v *providerVideoTrack) SetEndedCallback(cb func(string)) { v.provider.SetEndedCallback(cb) }
func (v *providerVideoTrack) WatchConnection(ctx context.Context) {
v.provider.WatchConnection(ctx)
}
func (v *providerVideoTrack) CanSend() bool { return v.provider.CanSend() }
func (v *providerVideoTrack) AddTrack(track webrtc.TrackLocal) error {
if err := v.provider.AddVideoTrack(track); err != nil {
return fmt.Errorf("add track: %w", err)
}
return nil
}
func (v *providerVideoTrack) SetTrackHandler(cb func(*webrtc.TrackRemote, *webrtc.RTPReceiver)) {
v.provider.SetVideoTrackHandler(cb)
}

View File

@@ -1,205 +0,0 @@
package builtin
import (
"context"
"errors"
"testing"
"github.com/openlibrecommunity/olcrtc/internal/carrier"
"github.com/pion/webrtc/v4"
)
var (
errConnectBoom = errors.New("connect boom")
errSendBoom = errors.New("send boom")
errCloseBoom = errors.New("close boom")
errTrackBoom = errors.New("track boom")
)
type stubProvider struct {
connectErr error
sendErr error
closeErr error
canSend bool
reconnectCallback func(*webrtc.DataChannel)
shouldReconnect func() bool
endedCallback func(string)
watchCalled bool
addTrackErr error
trackHandlerCalled bool
}
func (s *stubProvider) Connect(context.Context) error { return s.connectErr }
func (s *stubProvider) Send([]byte) error { return s.sendErr }
func (s *stubProvider) Close() error { return s.closeErr }
func (s *stubProvider) SetReconnectCallback(cb func(*webrtc.DataChannel)) { s.reconnectCallback = cb }
func (s *stubProvider) SetShouldReconnect(fn func() bool) { s.shouldReconnect = fn }
func (s *stubProvider) SetEndedCallback(cb func(string)) { s.endedCallback = cb }
func (s *stubProvider) WatchConnection(context.Context) { s.watchCalled = true }
func (s *stubProvider) CanSend() bool { return s.canSend }
func (s *stubProvider) GetSendQueue() chan []byte { return nil }
func (s *stubProvider) GetBufferedAmount() uint64 { return 0 }
func (s *stubProvider) AddVideoTrack(webrtc.TrackLocal) error { return s.addTrackErr }
func (s *stubProvider) SetVideoTrackHandler(func(*webrtc.TrackRemote, *webrtc.RTPReceiver)) {
s.trackHandlerCalled = true
}
type plainProvider struct {
connectErr error
sendErr error
closeErr error
canSend bool
reconnectCallback func(*webrtc.DataChannel)
shouldReconnect func() bool
endedCallback func(string)
watchCalled bool
}
func (p *plainProvider) Connect(context.Context) error { return p.connectErr }
func (p *plainProvider) Send([]byte) error { return p.sendErr }
func (p *plainProvider) Close() error { return p.closeErr }
func (p *plainProvider) SetReconnectCallback(cb func(*webrtc.DataChannel)) { p.reconnectCallback = cb }
func (p *plainProvider) SetShouldReconnect(fn func() bool) { p.shouldReconnect = fn }
func (p *plainProvider) SetEndedCallback(cb func(string)) { p.endedCallback = cb }
func (p *plainProvider) WatchConnection(context.Context) { p.watchCalled = true }
func (p *plainProvider) CanSend() bool { return p.canSend }
func (p *plainProvider) GetSendQueue() chan []byte { return nil }
func (p *plainProvider) GetBufferedAmount() uint64 { return 0 }
func TestProviderSessionOpenVideoTrackUnsupported(t *testing.T) {
sess := &providerSession{provider: &plainProvider{}}
caps := sess.Capabilities()
if !caps.ByteStream || caps.VideoTrack {
t.Fatalf("Capabilities() = %+v, want byte true and video false", caps)
}
_, err := sess.OpenVideoTrack()
if !errors.Is(err, carrier.ErrVideoTrackUnsupported) {
t.Fatalf("OpenVideoTrack() error = %v, want %v", err, carrier.ErrVideoTrackUnsupported)
}
}
func TestProviderByteStreamWrapsProviderAndCallbacks(t *testing.T) {
prov := &stubProvider{canSend: true}
stream := &providerByteStream{provider: prov}
called := false
stream.SetReconnectCallback(func() { called = true })
if prov.reconnectCallback == nil {
t.Fatal("SetReconnectCallback() did not install provider callback")
}
prov.reconnectCallback(nil)
if !called {
t.Fatal("reconnect callback was not adapted")
}
reconnectAllowed := false
stream.SetShouldReconnect(func() bool { reconnectAllowed = true; return true })
if prov.shouldReconnect == nil || !prov.shouldReconnect() || !reconnectAllowed {
t.Fatal("SetShouldReconnect() was not forwarded")
}
ended := ""
stream.SetEndedCallback(func(reason string) { ended = reason })
if prov.endedCallback == nil {
t.Fatal("SetEndedCallback() was not forwarded")
}
prov.endedCallback("bye")
if ended != "bye" {
t.Fatalf("ended callback reason = %q, want bye", ended)
}
stream.WatchConnection(context.Background())
if !prov.watchCalled {
t.Fatal("WatchConnection() was not forwarded")
}
if !stream.CanSend() {
t.Fatal("CanSend() = false, want true")
}
}
func TestProviderByteStreamWrapsErrors(t *testing.T) {
prov := &stubProvider{
connectErr: errConnectBoom,
sendErr: errSendBoom,
closeErr: errCloseBoom,
}
stream := &providerByteStream{provider: prov}
if err := stream.Connect(context.Background()); err == nil || err.Error() != "connect: connect boom" {
t.Fatalf("Connect() error = %v", err)
}
if err := stream.Send([]byte("x")); err == nil || err.Error() != "send: send boom" {
t.Fatalf("Send() error = %v", err)
}
if err := stream.Close(); err == nil || err.Error() != "close: close boom" {
t.Fatalf("Close() error = %v", err)
}
}
func TestProviderSessionOpenByteStreamAndVideoTrack(t *testing.T) {
prov := &stubProvider{canSend: true}
sess := &providerSession{provider: prov}
stream, err := sess.OpenByteStream()
if err != nil {
t.Fatalf("OpenByteStream() error = %v", err)
}
if !stream.CanSend() {
t.Fatal("byte stream CanSend() = false, want true")
}
video, err := sess.OpenVideoTrack()
if err != nil {
t.Fatalf("OpenVideoTrack() error = %v", err)
}
if err := video.Connect(context.Background()); err != nil {
t.Fatalf("video Connect() error = %v", err)
}
if err := video.Close(); err != nil {
t.Fatalf("video Close() error = %v", err)
}
video.SetShouldReconnect(func() bool { return true })
video.SetEndedCallback(func(string) {})
video.WatchConnection(context.Background())
if !video.CanSend() || prov.shouldReconnect == nil || prov.endedCallback == nil || !prov.watchCalled {
t.Fatal("video adapter did not forward calls")
}
}
func TestProviderVideoTrackWrapsOperations(t *testing.T) {
prov := &stubProvider{canSend: true, addTrackErr: errTrackBoom}
track := &providerVideoTrack{provider: prov}
called := false
track.SetReconnectCallback(func() { called = true })
prov.reconnectCallback(nil)
if !called {
t.Fatal("reconnect callback was not adapted")
}
track.SetTrackHandler(func(*webrtc.TrackRemote, *webrtc.RTPReceiver) {})
if !prov.trackHandlerCalled {
t.Fatal("SetTrackHandler() was not forwarded")
}
if err := track.AddTrack(nil); err == nil || err.Error() != "add track: track boom" {
t.Fatalf("AddTrack() error = %v", err)
}
}
func TestProviderVideoTrackWrapsConnectCloseErrors(t *testing.T) {
prov := &stubProvider{
connectErr: errConnectBoom,
closeErr: errCloseBoom,
}
track := &providerVideoTrack{provider: prov}
if err := track.Connect(context.Background()); err == nil || err.Error() != "connect: connect boom" {
t.Fatalf("Connect() error = %v", err)
}
if err := track.Close(); err == nil || err.Error() != "close: close boom" {
t.Fatalf("Close() error = %v", err)
}
}

View File

@@ -1,38 +0,0 @@
// Package builtin registers the built-in carrier implementations.
package builtin
import (
"context"
"github.com/openlibrecommunity/olcrtc/internal/carrier"
"github.com/openlibrecommunity/olcrtc/internal/provider"
"github.com/openlibrecommunity/olcrtc/internal/provider/jazz"
"github.com/openlibrecommunity/olcrtc/internal/provider/telemost"
"github.com/openlibrecommunity/olcrtc/internal/provider/wbstream"
)
type providerFactory func(context.Context, provider.Config) (provider.Provider, error)
// Register wires the built-in carriers into the carrier registry.
func Register() {
registerProvider("jazz", jazz.New)
registerProvider("telemost", telemost.New)
registerProvider("wbstream", wbstream.New)
}
func registerProvider(name string, factory providerFactory) {
carrier.Register(name, func(ctx context.Context, cfg carrier.Config) (carrier.Session, error) {
prov, err := factory(ctx, provider.Config{
RoomURL: cfg.RoomURL,
Name: cfg.Name,
OnData: cfg.OnData,
DNSServer: cfg.DNSServer,
ProxyAddr: cfg.ProxyAddr,
ProxyPort: cfg.ProxyPort,
})
if err != nil {
return nil, err
}
return &providerSession{provider: prov}, nil
})
}

View File

@@ -1,18 +0,0 @@
package builtin
import (
"slices"
"testing"
"github.com/openlibrecommunity/olcrtc/internal/carrier"
)
func TestRegister(t *testing.T) {
Register()
available := carrier.Available()
for _, want := range []string{"jazz", "telemost", "wbstream"} {
if !slices.Contains(available, want) {
t.Fatalf("Available() = %v, missing %q", available, want)
}
}
}

View File

@@ -1,32 +0,0 @@
package carrier
import (
"context"
"github.com/pion/webrtc/v4"
)
// ByteStream is a carrier capability for bidirectional byte transport.
type ByteStream interface {
Connect(ctx context.Context) error
Send(data []byte) error
Close() error
SetReconnectCallback(cb func())
SetShouldReconnect(fn func() bool)
SetEndedCallback(cb func(string))
WatchConnection(ctx context.Context)
CanSend() bool
}
// VideoTrack is a carrier capability for bidirectional video transport.
type VideoTrack interface {
Connect(ctx context.Context) error
Close() error
SetReconnectCallback(cb func())
SetShouldReconnect(fn func() bool)
SetEndedCallback(cb func(string))
WatchConnection(ctx context.Context)
CanSend() bool
AddTrack(track webrtc.TrackLocal) error
SetTrackHandler(cb func(*webrtc.TrackRemote, *webrtc.RTPReceiver))
}

View File

@@ -1,75 +0,0 @@
// Package carrier exposes carrier-oriented registration and construction APIs.
package carrier
import (
"context"
"errors"
)
var (
// ErrCarrierNotFound is returned when a requested carrier is not registered.
ErrCarrierNotFound = errors.New("carrier not found")
// ErrByteStreamUnsupported is returned when a carrier cannot provide a byte stream.
ErrByteStreamUnsupported = errors.New("carrier does not support byte stream")
// ErrVideoTrackUnsupported is returned when a carrier cannot exchange video tracks.
ErrVideoTrackUnsupported = errors.New("carrier does not support video tracks")
)
// Capabilities describes the transport primitives a carrier can expose.
type Capabilities struct {
ByteStream bool
VideoTrack bool
}
// Session is the carrier-level runtime handle.
type Session interface {
Capabilities() Capabilities
}
// ByteStreamCapable is implemented by carriers that can expose a byte stream.
type ByteStreamCapable interface {
OpenByteStream() (ByteStream, error)
}
// VideoTrackCapable is implemented by carriers that can exchange video tracks.
type VideoTrackCapable interface {
OpenVideoTrack() (VideoTrack, error)
}
// Config holds carrier configuration.
type Config struct {
RoomURL string
Name string
OnData func([]byte)
DNSServer string
ProxyAddr string
ProxyPort int
}
// Factory creates a new carrier session.
type Factory func(ctx context.Context, cfg Config) (Session, error)
var registry = make(map[string]Factory) //nolint:gochecknoglobals // package-level state intentional
// Register adds a carrier factory to the registry.
func Register(name string, factory Factory) {
registry[name] = factory
}
// New creates a carrier session by name.
func New(ctx context.Context, name string, cfg Config) (Session, error) {
factory, ok := registry[name]
if !ok {
return nil, ErrCarrierNotFound
}
return factory(ctx, cfg)
}
// Available returns a list of registered carriers.
func Available() []string {
names := make([]string, 0, len(registry))
for name := range registry {
names = append(names, name)
}
return names
}

View File

@@ -1,66 +0,0 @@
package carrier
import (
"context"
"errors"
"reflect"
"testing"
)
type stubSession struct{}
func (s *stubSession) Capabilities() Capabilities {
return Capabilities{ByteStream: true, VideoTrack: true}
}
func snapshotCarrierRegistry() map[string]Factory {
out := make(map[string]Factory, len(registry))
for k, v := range registry {
out[k] = v
}
return out
}
func restoreCarrierRegistry(src map[string]Factory) {
registry = make(map[string]Factory, len(src))
for k, v := range src {
registry[k] = v
}
}
func TestRegisterAndAvailable(t *testing.T) {
old := snapshotCarrierRegistry()
t.Cleanup(func() { restoreCarrierRegistry(old) })
Register("test-carrier", func(_ context.Context, cfg Config) (Session, error) {
if cfg.Name != "peer" {
t.Fatalf("carrier config name = %q, want peer", cfg.Name)
}
return &stubSession{}, nil
})
sess, err := New(context.Background(), "test-carrier", Config{Name: "peer"})
if err != nil {
t.Fatalf("New() error = %v", err)
}
caps := sess.Capabilities()
if !caps.ByteStream || !caps.VideoTrack {
t.Fatalf("Capabilities() = %+v, want byte and video true", caps)
}
if !reflect.DeepEqual(Available(), []string{"test-carrier"}) {
t.Fatalf("Available() = %#v, want %#v", Available(), []string{"test-carrier"})
}
}
func TestNewReturnsErrCarrierNotFound(t *testing.T) {
old := snapshotCarrierRegistry()
t.Cleanup(func() { restoreCarrierRegistry(old) })
registry = map[string]Factory{}
_, err := New(context.Background(), "missing", Config{})
if !errors.Is(err, ErrCarrierNotFound) {
t.Fatalf("New() error = %v, want %v", err, ErrCarrierNotFound)
}
}

View File

@@ -4,20 +4,26 @@ package client
import (
"context"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/openlibrecommunity/olcrtc/internal/control"
"github.com/openlibrecommunity/olcrtc/internal/crypto"
"github.com/openlibrecommunity/olcrtc/internal/link"
"github.com/openlibrecommunity/olcrtc/internal/handshake"
"github.com/openlibrecommunity/olcrtc/internal/logger"
"github.com/openlibrecommunity/olcrtc/internal/muxconn"
"github.com/openlibrecommunity/olcrtc/internal/names"
"github.com/openlibrecommunity/olcrtc/internal/runtime"
"github.com/openlibrecommunity/olcrtc/internal/transport"
"github.com/xtaci/smux"
)
@@ -27,7 +33,8 @@ var (
// ErrProxyAuth is returned when SOCKS proxy authentication fails.
ErrProxyAuth = errors.New("SOCKS proxy auth failed")
// ErrKeySize is returned when the encryption key is not 32 bytes.
ErrKeySize = errors.New("key must be 32 bytes")
// Re-exported from runtime for compatibility with errors.Is callers.
ErrKeySize = runtime.ErrKeySize
// ErrInvalidSOCKSVersion is returned when the SOCKS version is not 5.
ErrInvalidSOCKSVersion = errors.New("invalid socks version")
// ErrUnsupportedSOCKSCommand is returned for unsupported SOCKS commands.
@@ -44,118 +51,113 @@ var (
// Client handles local SOCKS5 connections and tunnels them to the server.
type Client struct {
ln link.Link
cipher *crypto.Cipher
conn *muxconn.Conn
session *smux.Session
sessMu sync.RWMutex
clientID string
dnsServer string
socksUser string
socksPass string
ln transport.Transport
cipher *crypto.Cipher
conn *muxconn.Conn
session *smux.Session
controlStrm *smux.Stream
controlStop context.CancelFunc
sessMu sync.RWMutex
reconnectMu sync.Mutex
health *runtime.HealthTracker
deviceID string
sessionID string
claims map[string]any
dnsServer string
socksUser string
socksPass string
}
// Run starts the client with the specified parameters.
func Run(
ctx context.Context,
linkName,
transportName,
carrierName,
roomURL,
keyHex,
clientID string,
localAddr string,
dnsServer,
socksUser string,
socksPass string,
videoWidth int,
videoHeight int,
videoFPS int,
videoBitrate string,
videoHW string,
videoQRSize int,
videoQRRecovery string,
videoCodec string,
videoTileModule int,
videoTileRS int,
vp8FPS int,
vp8BatchSize int,
seiFPS int,
seiBatchSize int,
seiFragmentSize int,
seiAckTimeoutMS int,
) error {
return RunWithReady(
ctx, linkName, transportName, carrierName, roomURL, keyHex, clientID, localAddr,
dnsServer, socksUser, socksPass, nil,
videoWidth, videoHeight, videoFPS, videoBitrate, videoHW,
videoQRSize, videoQRRecovery, videoCodec, videoTileModule, videoTileRS,
vp8FPS, vp8BatchSize,
seiFPS, seiBatchSize, seiFragmentSize, seiAckTimeoutMS,
)
// HealthFunc is called when the client control health snapshot changes.
type HealthFunc func(control.Status)
// Config holds runtime configuration for [Run] and [RunWithReady].
type Config struct {
Transport string
Carrier string
RoomURL string
ChannelID string
KeyHex string
LocalAddr string
DNSServer string
SOCKSUser string
SOCKSPass string
TransportOptions transport.Options
Engine string
URL string
Token string
Liveness control.Config
Traffic transport.TrafficConfig
// DeviceID overrides the persistent client-side device identifier. Leave
// empty to derive one from DeviceIDPath (or generate a random one if both
// are empty).
DeviceID string
// DeviceIDPath is a file in which to persist the auto-generated device ID
// across restarts. Ignored when DeviceID is set explicitly.
DeviceIDPath string
// Claims is sent to the server in CLIENT_HELLO and forwarded verbatim to
// the server's AuthHook. Free-form key/value bag for plan, user, region, etc.
Claims map[string]any
// OnHealth receives liveness/reconnect status updates. Nil means no-op.
OnHealth HealthFunc
}
// RunWithReady is like Run but accepts a callback that is called when the client is ready.
func RunWithReady(
ctx context.Context,
linkName,
transportName,
carrierName,
roomURL,
keyHex,
clientID string,
localAddr string,
dnsServer,
socksUser string,
socksPass string,
onReady func(),
videoWidth int,
videoHeight int,
videoFPS int,
videoBitrate string,
videoHW string,
videoQRSize int,
videoQRRecovery string,
videoCodec string,
videoTileModule int,
videoTileRS int,
vp8FPS int,
vp8BatchSize int,
seiFPS int,
seiBatchSize int,
seiFragmentSize int,
seiAckTimeoutMS int,
) error {
// Run starts the client with the given configuration.
func Run(ctx context.Context, cfg Config) error {
return RunWithReady(ctx, cfg, nil)
}
// RunWithReady is like Run but invokes onReady once the local SOCKS listener is up.
func RunWithReady(ctx context.Context, cfg Config, onReady func()) error {
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
cipher, err := setupCipher(keyHex)
cipher, err := setupCipher(cfg.KeyHex)
if err != nil {
return fmt.Errorf("setupCipher failed: %w", err)
}
c := &Client{cipher: cipher, clientID: clientID, dnsServer: dnsServer, socksUser: socksUser, socksPass: socksPass}
if err := c.bringUpLink(
runCtx, linkName, transportName, carrierName, roomURL, cancel,
dnsServer, "", 0,
videoWidth, videoHeight, videoFPS, videoBitrate, videoHW,
videoQRSize, videoQRRecovery, videoCodec, videoTileModule, videoTileRS,
vp8FPS, vp8BatchSize,
seiFPS, seiBatchSize, seiFragmentSize, seiAckTimeoutMS,
); err != nil {
return err
deviceID, err := resolveDeviceID(cfg.DeviceID, cfg.DeviceIDPath)
if err != nil {
return fmt.Errorf("resolve device id: %w", err)
}
c := &Client{
cipher: cipher,
deviceID: deviceID,
claims: cfg.Claims,
dnsServer: cfg.DNSServer,
socksUser: cfg.SOCKSUser,
socksPass: cfg.SOCKSPass,
health: runtime.NewHealthTracker(cfg.OnHealth),
}
// shutdown is registered BEFORE bringUpLink so we always close any
// link/session that bringUpLink managed to set up before it
// errored out. The previous ordering returned early on failure
// (e.g. handshake timeout against a wedged seichannel transport)
// without ever calling Close on the carrier link, leaving our MUC
// presence behind as a ghost participant in the next test that
// joined the same room. shutdown is nil-safe — it skips fields
// that bringUpLink hadn't populated yet.
defer c.shutdown()
if err := c.bringUpLink(runCtx, cfg, cancel); err != nil {
return err
}
lc := net.ListenConfig{}
listener, err := lc.Listen(runCtx, "tcp4", localAddr)
listener, err := lc.Listen(runCtx, "tcp4", cfg.LocalAddr)
if err != nil {
return fmt.Errorf("failed to listen on %s: %w", localAddr, err)
return fmt.Errorf("failed to listen on %s: %w", cfg.LocalAddr, err)
}
defer func() { _ = listener.Close() }()
logger.Infof("SOCKS5 server listening on %s", localAddr)
logger.Infof("SOCKS5 server listening on %s", cfg.LocalAddr)
if onReady != nil {
onReady()
@@ -169,45 +171,22 @@ func RunWithReady(
func (c *Client) bringUpLink(
ctx context.Context,
linkName, transportName, carrierName, roomURL string,
cfg Config,
cancel context.CancelFunc,
dnsServer, socksProxyAddr string,
socksProxyPort int,
videoWidth, videoHeight, videoFPS int,
videoBitrate, videoHW string,
videoQRSize int,
videoQRRecovery string,
videoCodec string,
videoTileModule, videoTileRS int,
vp8FPS, vp8BatchSize int,
seiFPS, seiBatchSize, seiFragmentSize, seiAckTimeoutMS int,
) error {
ln, err := link.New(ctx, linkName, link.Config{
Transport: transportName,
Carrier: carrierName,
RoomURL: roomURL,
ClientID: c.clientID,
Name: names.Generate(),
OnData: c.onData,
DNSServer: dnsServer,
ProxyAddr: socksProxyAddr,
ProxyPort: socksProxyPort,
VideoWidth: videoWidth,
VideoHeight: videoHeight,
VideoFPS: videoFPS,
VideoBitrate: videoBitrate,
VideoHW: videoHW,
VideoQRSize: videoQRSize,
VideoQRRecovery: videoQRRecovery,
VideoCodec: videoCodec,
VideoTileModule: videoTileModule,
VideoTileRS: videoTileRS,
VP8FPS: vp8FPS,
VP8BatchSize: vp8BatchSize,
SEIFPS: seiFPS,
SEIBatchSize: seiBatchSize,
SEIFragmentSize: seiFragmentSize,
SEIAckTimeoutMS: seiAckTimeoutMS,
ln, err := transport.New(ctx, cfg.Transport, transport.Config{
Carrier: cfg.Carrier,
RoomURL: cfg.RoomURL,
Engine: cfg.Engine,
URL: cfg.URL,
Token: cfg.Token,
ChannelID: cfg.ChannelID,
DeviceID: c.deviceID,
Name: names.Generate(),
OnData: c.onData,
DNSServer: cfg.DNSServer,
Options: cfg.TransportOptions,
Traffic: cfg.Traffic,
})
if err != nil {
return fmt.Errorf("failed to create link: %w", err)
@@ -218,87 +197,381 @@ func (c *Client) bringUpLink(
logger.Infof("Client link reported conference end: %s", reason)
cancel()
})
ln.SetReconnectCallback(func() { c.handleReconnect() })
ln.SetShouldReconnect(func() bool { return ctx.Err() == nil })
ln.SetReconnectCallback(func() {
if ctx.Err() != nil {
return
}
// Carrier callback fires after the link is back up. If handshake
// still fails it usually means the server hasn't completed its
// own reinstall yet — keep the listener up and wait for either
// another callback or a future liveness loss to re-trigger.
c.handleReconnect(ctx, cfg, cancel, "carrier")
})
if err := ln.Connect(ctx); err != nil {
return fmt.Errorf("failed to connect link: %w", err)
}
c.conn = muxconn.New(ln, c.cipher)
sess, err := smux.Client(c.conn, smuxConfig())
sess, err := smux.Client(c.conn, smuxConfig(linkMaxPayload(ln)))
if err != nil {
return fmt.Errorf("smux client: %w", err)
}
control, sid, err := openControlStream(ctx, sess, c.deviceID, c.claims)
if err != nil {
_ = sess.Close()
_ = c.conn.Close()
return fmt.Errorf("handshake: %w", err)
}
logger.Infof("session %s opened (device=%s)", sid, c.deviceID)
c.sessMu.Lock()
c.session = sess
c.controlStrm = control
c.sessionID = sid
c.sessMu.Unlock()
c.recordSession(sid)
c.startControlLoop(ctx, cfg, cancel, control)
go ln.WatchConnection(ctx)
return nil
}
// smuxConfig returns the tuned smux config used on both ends.
func smuxConfig() *smux.Config {
cfg := smux.DefaultConfig()
cfg.Version = 2
cfg.KeepAliveDisabled = true
cfg.MaxFrameSize = 32768
cfg.MaxReceiveBuffer = 16 * 1024 * 1024
cfg.MaxStreamBuffer = 1024 * 1024
cfg.KeepAliveInterval = 10 * time.Second
cfg.KeepAliveTimeout = 60 * time.Second
return cfg
// openControlStream opens stream #1 on sess and performs the handshake.
// The stream stays open for the lifetime of the smux session and carries
// post-handshake control messages.
func openControlStream(
ctx context.Context,
sess *smux.Session,
deviceID string,
claims map[string]any,
) (*smux.Stream, string, error) {
return openControlStreamTimeout(ctx, sess, deviceID, claims, handshake.DefaultTimeout)
}
func (c *Client) handleReconnect() {
logger.Infof("client link reconnect - tearing down smux session")
c.sessMu.Lock()
if c.session != nil {
_ = c.session.Close()
c.session = nil
}
if c.conn != nil {
_ = c.conn.Close()
c.conn = nil
}
c.sessMu.Unlock()
c.conn = muxconn.New(c.ln, c.cipher)
sess, err := smux.Client(c.conn, smuxConfig())
func openControlStreamTimeout(
ctx context.Context,
sess *smux.Session,
deviceID string,
claims map[string]any,
timeout time.Duration,
) (*smux.Stream, string, error) {
stream, err := sess.OpenStream()
if err != nil {
logger.Warnf("smux re-init failed: %v", err)
return
return nil, "", fmt.Errorf("open control stream: %w", err)
}
done := make(chan struct{})
go func() {
select {
case <-ctx.Done():
_ = stream.Close()
case <-done:
}
}()
defer close(done)
_ = stream.SetDeadline(time.Now().Add(timeout))
sid, err := handshake.Client(stream, deviceID, claims)
_ = stream.SetDeadline(time.Time{})
if err != nil {
_ = stream.Close()
if ctx.Err() != nil {
return nil, "", fmt.Errorf("handshake client: %w", ctx.Err())
}
return nil, "", fmt.Errorf("handshake client: %w", err)
}
return stream, sid, nil
}
// resolveDeviceID returns the device ID to send in CLIENT_HELLO.
//
// Precedence:
// 1. Explicit deviceID arg (Config.DeviceID) — used verbatim.
// 2. Persistent file at path (Config.DeviceIDPath) — read if it exists,
// otherwise generated and written for future runs.
// 3. Random UUID per run when both inputs are empty.
func resolveDeviceID(deviceID, path string) (string, error) {
if deviceID != "" {
return deviceID, nil
}
if path == "" {
return uuid.NewString(), nil
}
// #nosec G304 -- persistent device ID path is explicit user configuration.
data, err := os.ReadFile(path)
if err == nil {
id := strings.TrimSpace(string(data))
if id != "" {
return id, nil
}
} else if !errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("read device id %s: %w", path, err)
}
id := uuid.NewString()
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return "", fmt.Errorf("mkdir device id dir: %w", err)
}
if err := os.WriteFile(path, []byte(id+"\n"), 0o600); err != nil {
return "", fmt.Errorf("write device id %s: %w", path, err)
}
return id, nil
}
func smuxConfig(maxWirePayload int) *smux.Config {
return runtime.SmuxConfig(maxWirePayload)
}
func linkMaxPayload(tr transport.Transport) int {
return runtime.MaxPayload(tr)
}
func (c *Client) handleReconnect(ctx context.Context, cfg Config, cancel context.CancelFunc, reason string) {
c.reconnectMu.Lock()
defer c.reconnectMu.Unlock()
c.recordReconnect()
logger.Infof("client reconnect reason=%s - tearing down smux session", reason)
c.resetLinkPeer()
// Install a fresh muxconn immediately so onData never hits nil while
// the old session is being torn down. tryReopenSession will swap it
// again with its own conn on each attempt.
newConn := muxconn.New(c.ln, c.cipher)
c.sessMu.Lock()
oldControl := c.controlStrm
oldControlStop := c.controlStop
oldSess := c.session
oldConn := c.conn
c.conn = newConn
c.session = nil
c.controlStrm = nil
c.controlStop = nil
c.sessionID = ""
c.sessMu.Unlock()
if oldControlStop != nil {
oldControlStop()
}
if oldSess != nil {
_ = oldSess.Close()
}
if oldConn != nil {
_ = oldConn.Close()
}
if oldControl != nil {
_ = oldControl.Close()
}
// When liveness on top of a still-"connected" carrier expires, the
// underlying ICE/data path has gone silent without the engine noticing.
// Re-handshaking over the dead carrier just times out repeatedly, so
// ask the carrier to rebuild itself; the new carrier will fire its own
// reconnect callback which then drives a fresh handshake.
if reason == "liveness" && c.ln != nil {
c.ln.Reconnect("liveness")
}
c.retryHandshake(ctx, cfg, cancel, reason)
}
func (c *Client) retryHandshake(ctx context.Context, cfg Config, cancel context.CancelFunc, reason string) {
const (
initialDelay = 300 * time.Millisecond
maxDelay = 5 * time.Second
)
delay := initialDelay
for attempt := 1; ; attempt++ {
if ctx.Err() != nil {
return
}
logger.Infof("client reconnect attempt=%d reason=%s", attempt, reason)
if c.tryReopenSession(ctx, cfg, cancel, attempt) {
return
}
// Don't fail the whole process on liveness reconnect: the carrier
// rebuild may take dozens of seconds (e.g. ICE restart on a flaky
// network). Keep the SOCKS5 listener open and wait — handleSocks5
// will return host-unreachable to clients until we recover. For
// carrier-driven reconnects the callback fires after the link is
// already up, so a missed handshake is more suspicious; cap it.
if reason == "carrier" && attempt >= 5 {
logger.Warnf("client reconnect: exhausted %d handshake attempts (reason=%s) — keeping listener up", attempt, reason)
return
}
select {
case <-ctx.Done():
return
case <-time.After(delay):
}
if delay < maxDelay {
delay *= 2
if delay > maxDelay {
delay = maxDelay
}
}
}
}
func (c *Client) resetLinkPeer() {
c.sessMu.RLock()
ln := c.ln
c.sessMu.RUnlock()
if resetter, ok := ln.(interface{ ResetPeer() }); ok {
resetter.ResetPeer()
}
}
func (c *Client) tryReopenSession(
ctx context.Context,
cfg Config,
cancel context.CancelFunc,
attempt int,
) bool {
conn := muxconn.New(c.ln, c.cipher)
c.sessMu.Lock()
old := c.conn
c.conn = conn
c.sessMu.Unlock()
if old != nil {
_ = old.Close()
}
sess, err := smux.Client(conn, smuxConfig(linkMaxPayload(c.ln)))
if err != nil {
logger.Warnf("smux re-init failed (attempt %d): %v", attempt, err)
return false
}
control, sid, err := openControlStreamTimeout(ctx, sess, c.deviceID, c.claims, 2*time.Second)
if err != nil {
logger.Warnf("handshake on reconnect failed (attempt %d): %v", attempt, err)
_ = sess.Close()
return false
}
logger.Infof("session %s reopened (device=%s)", sid, c.deviceID)
c.sessMu.Lock()
c.session = sess
c.controlStrm = control
c.sessionID = sid
c.sessMu.Unlock()
c.recordSession(sid)
c.startControlLoop(ctx, cfg, cancel, control)
return true
}
func (c *Client) startControlLoop(
ctx context.Context,
cfg Config,
cancel context.CancelFunc,
stream *smux.Stream,
) {
controlCtx, stop := context.WithCancel(ctx)
c.sessMu.Lock()
c.controlStop = stop
c.sessMu.Unlock()
liveness := cfg.Liveness
onPong := liveness.OnPong
onMissedPong := liveness.OnMissedPong
onUnhealthy := liveness.OnUnhealthy
liveness.OnPong = func(h control.Health) {
c.sessMu.RLock()
sid := c.sessionID
c.sessMu.RUnlock()
c.recordPong(h)
logger.Debugf("control alive session=%s rtt=%v seq=%d", sid, h.RTT, h.Seq)
if onPong != nil {
onPong(h)
}
}
liveness.OnMissedPong = func(missed int) {
c.recordMissed(missed)
logger.Warnf("control missed pong on client: missed_pongs=%d", missed)
if onMissedPong != nil {
onMissedPong(missed)
}
}
liveness.OnUnhealthy = func(missed int) {
c.recordUnhealthy(missed)
logger.Warnf("control stream unhealthy on client: missed_pongs=%d", missed)
if onUnhealthy != nil {
onUnhealthy(missed)
}
}
go func() {
err := control.Run(controlCtx, stream, liveness)
if controlCtx.Err() != nil || ctx.Err() != nil {
return
}
if err != nil {
logger.Warnf("client control stream ended: %v", err)
}
// handleReconnect now retries indefinitely on liveness so it only
// returns false on ctx cancellation; don't tear down the client.
c.handleReconnect(ctx, cfg, cancel, "liveness")
}()
}
// Status returns the latest client-side control health snapshot.
func (c *Client) Status() control.Status {
return c.health.Status()
}
func (c *Client) recordSession(sessionID string) { c.health.RecordSession(sessionID) }
func (c *Client) recordPong(h control.Health) { c.health.RecordPong(h) }
func (c *Client) recordMissed(missed int) { c.health.RecordMissed(missed) }
func (c *Client) recordUnhealthy(missed int) { c.health.RecordUnhealthy(missed) }
func (c *Client) recordReconnect() { c.health.RecordReconnect() }
func (c *Client) shutdown() {
c.sessMu.Lock()
if c.session != nil {
_ = c.session.Close()
}
if c.conn != nil {
_ = c.conn.Close()
}
control := c.controlStrm
controlStop := c.controlStop
sess := c.session
conn := c.conn
c.controlStrm = nil
c.controlStop = nil
c.session = nil
c.conn = nil
c.sessMu.Unlock()
notifyControlClose(control)
if controlStop != nil {
controlStop()
}
if sess != nil {
_ = sess.Close()
}
if conn != nil {
_ = conn.Close()
}
if c.ln != nil {
_ = c.ln.Close()
}
if control != nil {
_ = control.Close()
}
}
func notifyControlClose(stream *smux.Stream) {
if stream == nil {
return
}
_ = stream.SetWriteDeadline(time.Now().Add(2 * time.Second))
if err := control.SendClose(stream); err == nil {
time.Sleep(200 * time.Millisecond)
}
_ = stream.SetWriteDeadline(time.Time{})
_ = stream.CloseWrite()
}
func setupCipher(keyHex string) (*crypto.Cipher, error) {
key, err := hex.DecodeString(keyHex)
cipher, err := runtime.SetupCipher(keyHex)
if err != nil {
return nil, fmt.Errorf("failed to decode key: %w", err)
}
if len(key) != 32 {
return nil, fmt.Errorf("%w: got %d", ErrKeySize, len(key))
}
cipher, err := crypto.NewCipher(string(key))
if err != nil {
return nil, fmt.Errorf("failed to create cipher: %w", err)
return nil, fmt.Errorf("client: %w", err)
}
return cipher, nil
}
@@ -381,10 +654,9 @@ func (c *Client) tunnel(conn net.Conn, sess *smux.Session, targetAddr string, ta
func (c *Client) sendConnectRequest(stream *smux.Stream, targetAddr string, targetPort int) error {
connectReq, err := json.Marshal(map[string]any{
"cmd": "connect",
"clientId": c.clientID,
"addr": targetAddr,
"port": targetPort,
"cmd": "connect",
"addr": targetAddr,
"port": targetPort,
})
if err != nil {
return fmt.Errorf("sid=%d marshal connect req: %w", stream.ID(), err)

View File

@@ -11,13 +11,21 @@ import (
"testing"
"time"
"github.com/openlibrecommunity/olcrtc/internal/control"
cryptopkg "github.com/openlibrecommunity/olcrtc/internal/crypto"
"github.com/openlibrecommunity/olcrtc/internal/muxconn"
"github.com/openlibrecommunity/olcrtc/internal/runtime"
"github.com/openlibrecommunity/olcrtc/internal/transport"
"github.com/xtaci/smux"
)
var errUnexpectedConnectRequest = errors.New("unexpected connect request")
const (
testConnectCommand = "connect"
testConnectHost = "example.com"
)
func TestSetupCipher(t *testing.T) {
keyHex := "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
cipher, err := setupCipher(keyHex)
@@ -39,9 +47,15 @@ func TestSetupCipherRejectsBadInput(t *testing.T) {
}
func TestSmuxConfig(t *testing.T) {
cfg := smuxConfig()
if cfg.Version != 2 || !cfg.KeepAliveDisabled || cfg.MaxFrameSize != 32768 || cfg.MaxReceiveBuffer != 16*1024*1024 {
t.Fatalf("smuxConfig() = %+v", cfg)
cfg := smuxConfig(0)
if cfg.Version != 2 || cfg.KeepAliveDisabled || cfg.MaxFrameSize != 32768 || cfg.MaxReceiveBuffer != 16*1024*1024 {
t.Fatalf("smuxConfig(0) = %+v", cfg)
}
capped := smuxConfig(4096)
want := 4096 - runtime.SmuxWireOverhead
if capped.MaxFrameSize != want {
t.Fatalf("smuxConfig(4096).MaxFrameSize = %d, want %d",
capped.MaxFrameSize, want)
}
}
@@ -384,7 +398,6 @@ func TestReadSocks5AddrReadErrors(t *testing.T) {
}
}
//nolint:cyclop // table-driven test naturally has many branches
func TestSendConnectRequestOverSmux(t *testing.T) {
a, b := net.Pipe()
defer func() {
@@ -392,12 +405,12 @@ func TestSendConnectRequestOverSmux(t *testing.T) {
_ = b.Close()
}()
serverSess, err := smux.Server(a, smuxConfig())
serverSess, err := smux.Server(a, smuxConfig(0))
if err != nil {
t.Fatalf("smux.Server() error = %v", err)
}
defer func() { _ = serverSess.Close() }()
clientSess, err := smux.Client(b, smuxConfig())
clientSess, err := smux.Client(b, smuxConfig(0))
if err != nil {
t.Fatalf("smux.Client() error = %v", err)
}
@@ -417,7 +430,7 @@ func TestSendConnectRequestOverSmux(t *testing.T) {
done <- err
return
}
if req["cmd"] != "connect" || req["clientId"] != "client-1" || req["addr"] != "example.com" { //nolint:goconst,lll // test literal, repetition is intentional
if req["cmd"] != testConnectCommand || req["addr"] != testConnectHost {
done <- errUnexpectedConnectRequest
return
}
@@ -431,8 +444,8 @@ func TestSendConnectRequestOverSmux(t *testing.T) {
}
defer func() { _ = stream.Close() }()
c := &Client{clientID: "client-1"}
if err := c.sendConnectRequest(stream, "example.com", 443); err != nil {
c := &Client{deviceID: "client-1"}
if err := c.sendConnectRequest(stream, testConnectHost, 443); err != nil {
t.Fatalf("sendConnectRequest() error = %v", err)
}
if err := <-done; err != nil {
@@ -446,12 +459,12 @@ func TestSendConnectRequestRejectsBadAck(t *testing.T) {
_ = a.Close()
_ = b.Close()
}()
serverSess, err := smux.Server(a, smuxConfig())
serverSess, err := smux.Server(a, smuxConfig(0))
if err != nil {
t.Fatalf("smux.Server() error = %v", err)
}
defer func() { _ = serverSess.Close() }()
clientSess, err := smux.Client(b, smuxConfig())
clientSess, err := smux.Client(b, smuxConfig(0))
if err != nil {
t.Fatalf("smux.Client() error = %v", err)
}
@@ -473,14 +486,53 @@ func TestSendConnectRequestRejectsBadAck(t *testing.T) {
}
defer func() { _ = stream.Close() }()
c := &Client{clientID: "client-1"}
c := &Client{deviceID: "client-1"}
if err := c.sendConnectRequest(stream, "example.com", 443); !errors.Is(err, ErrRemoteNotReady) {
t.Fatalf("sendConnectRequest() error = %v, want %v", err, ErrRemoteNotReady)
}
}
func TestOpenControlStreamStopsOnContextCancel(t *testing.T) {
a, b := net.Pipe()
defer func() {
_ = a.Close()
_ = b.Close()
}()
serverSess, err := smux.Server(a, smuxConfig(0))
if err != nil {
t.Fatalf("smux.Server() error = %v", err)
}
defer func() { _ = serverSess.Close() }()
clientSess, err := smux.Client(b, smuxConfig(0))
if err != nil {
t.Fatalf("smux.Client() error = %v", err)
}
defer func() { _ = clientSess.Close() }()
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
_, _, err := openControlStreamTimeout(ctx, clientSess, "dev", nil, time.Hour)
errCh <- err
}()
time.Sleep(20 * time.Millisecond)
cancel()
select {
case err := <-errCh:
if !errors.Is(err, context.Canceled) {
t.Fatalf("openControlStreamTimeout() error = %v, want context.Canceled", err)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for context cancellation")
}
}
type closerLinkStub struct {
closed bool
closed bool
resetCount int
}
func (s *closerLinkStub) Connect(context.Context) error { return nil }
@@ -491,6 +543,9 @@ func (s *closerLinkStub) SetShouldReconnect(func() bool) {}
func (s *closerLinkStub) SetEndedCallback(func(string)) {}
func (s *closerLinkStub) WatchConnection(context.Context) {}
func (s *closerLinkStub) CanSend() bool { return true }
func (s *closerLinkStub) Features() transport.Features { return transport.Features{} }
func (s *closerLinkStub) Reconnect(string) {}
func (s *closerLinkStub) ResetPeer() { s.resetCount++ }
func TestOnDataWithNilConn(_ *testing.T) {
c := &Client{}
@@ -513,3 +568,106 @@ func TestShutdownClosesLinkAndConn(t *testing.T) {
t.Fatal("shutdown() did not close link")
}
}
func TestResetLinkPeer(t *testing.T) {
ln := &closerLinkStub{}
c := &Client{ln: ln}
c.resetLinkPeer()
if ln.resetCount != 1 {
t.Fatalf("ResetPeer calls = %d, want 1", ln.resetCount)
}
}
//nolint:cyclop // integration-style control loop test needs setup and async assertions together
func TestStartControlLoopReportsPong(t *testing.T) {
a, b := net.Pipe()
defer func() {
_ = a.Close()
_ = b.Close()
}()
serverSess, err := smux.Server(a, smuxConfig(0))
if err != nil {
t.Fatalf("smux.Server() error = %v", err)
}
defer func() { _ = serverSess.Close() }()
clientSess, err := smux.Client(b, smuxConfig(0))
if err != nil {
t.Fatalf("smux.Client() error = %v", err)
}
defer func() { _ = clientSess.Close() }()
peerStreamCh := make(chan *smux.Stream, 1)
go func() {
stream, err := serverSess.AcceptStream()
if err == nil {
peerStreamCh <- stream
}
}()
stream, err := clientSess.OpenStream()
if err != nil {
t.Fatalf("OpenStream() error = %v", err)
}
peerStream := <-peerStreamCh
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
got := make(chan control.Health, 1)
c := &Client{sessionID: "sid-control", health: runtime.NewHealthTracker(nil)}
c.recordSession("sid-control")
c.startControlLoop(ctx, Config{
Liveness: control.Config{
Interval: 10 * time.Millisecond,
Timeout: 100 * time.Millisecond,
Failures: 2,
OnPong: func(h control.Health) {
select {
case got <- h:
default:
}
},
},
}, cancel, stream)
go func() {
_ = control.Run(ctx, peerStream, control.Config{
Interval: 10 * time.Millisecond,
Timeout: 100 * time.Millisecond,
Failures: 2,
})
}()
select {
case h := <-got:
if h.Seq == 0 {
t.Fatal("Health.Seq = 0")
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for control pong")
}
status := c.Status()
if status.SessionID != "sid-control" {
t.Fatalf("Status.SessionID = %q, want sid-control", status.SessionID)
}
if status.LastPong.IsZero() || status.LastRTT < 0 || status.MissedPongs != 0 {
t.Fatalf("Status() = %+v", status)
}
}
func TestStatusRecordsReconnectAndUnhealthy(t *testing.T) {
updates := 0
c := &Client{health: runtime.NewHealthTracker(func(control.Status) { updates++ })}
c.recordSession("sid-1")
c.recordMissed(2)
c.recordUnhealthy(3)
c.recordReconnect()
status := c.Status()
if status.SessionID != "sid-1" || status.MissedPongs != 3 ||
status.UnhealthyEvents != 1 || status.Reconnects != 1 || status.LastUnhealthy.IsZero() {
t.Fatalf("Status() = %+v", status)
}
if updates != 4 {
t.Fatalf("health updates = %d, want 4", updates)
}
}

362
internal/config/config.go Normal file
View File

@@ -0,0 +1,362 @@
// Package config loads olcrtc runtime configuration from YAML files.
//
// The YAML schema mirrors [session.Config]. Fields left unset in the file
// remain at their zero value. Use [Apply] to map a parsed [File] onto an
// existing [session.Config]; non-zero fields in the session config take
// precedence over the YAML values.
//
//nolint:tagliatelle // YAML keys are the documented config file schema.
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"unicode/utf8"
"github.com/openlibrecommunity/olcrtc/internal/app/session"
"gopkg.in/yaml.v3"
)
var (
// ErrConfigNotFound is returned when a config file path is set but the file does not exist.
ErrConfigNotFound = errors.New("config file not found")
// ErrConfigInvalidUTF8 is returned when a config file is not valid UTF-8.
ErrConfigInvalidUTF8 = errors.New("config file is not valid UTF-8")
// ErrCryptoKeyConflict is returned when both inline and file-backed keys are configured.
ErrCryptoKeyConflict = errors.New("crypto.key and crypto.key_file cannot both be set")
// ErrCryptoKeyFileEmpty is returned when crypto.key_file points to an empty file.
ErrCryptoKeyFileEmpty = errors.New("crypto key file is empty")
)
// File is the on-disk YAML schema.
type File struct {
Mode string `yaml:"mode"`
Auth Auth `yaml:"auth"`
Room Room `yaml:"room"`
Crypto Crypto `yaml:"crypto"`
Net Net `yaml:"net"`
SOCKS SOCKS `yaml:"socks"`
Engine Engine `yaml:"engine"`
Video Video `yaml:"video"`
VP8 VP8 `yaml:"vp8"`
SEI SEI `yaml:"sei"`
Liveness Liveness `yaml:"liveness"`
Lifecycle Lifecycle `yaml:"lifecycle"`
Traffic Traffic `yaml:"traffic"`
Gen Gen `yaml:"gen"`
Profiles []Profile `yaml:"profiles"`
Failover Failover `yaml:"failover"`
Data string `yaml:"data"`
Debug bool `yaml:"debug"`
FFmpeg string `yaml:"ffmpeg"`
}
// Profile is a failover entry that overrides top-level runtime fields.
type Profile struct {
Name string `yaml:"name"`
Auth Auth `yaml:"auth"`
Room Room `yaml:"room"`
Crypto Crypto `yaml:"crypto"`
Net Net `yaml:"net"`
SOCKS SOCKS `yaml:"socks"`
Engine Engine `yaml:"engine"`
Video Video `yaml:"video"`
VP8 VP8 `yaml:"vp8"`
SEI SEI `yaml:"sei"`
Liveness Liveness `yaml:"liveness"`
Lifecycle Lifecycle `yaml:"lifecycle"`
Traffic Traffic `yaml:"traffic"`
}
// Failover controls ordered profile failover.
type Failover struct {
RetryDelay string `yaml:"retry_delay"`
MaxCycles int `yaml:"max_cycles"`
}
// Auth selects the auth provider.
type Auth struct {
Provider string `yaml:"provider"` // telemost, wbstream, none
}
// Room identifies the conference room.
type Room struct {
ID string `yaml:"id"`
Channel string `yaml:"channel"`
}
// Crypto holds the shared secret used to authenticate and encrypt the tunnel.
type Crypto struct {
Key string `yaml:"key"` // 64-char hex (32 bytes)
KeyFile string `yaml:"key_file"` // path to a file containing crypto.key
}
// Net groups network and transport selection.
type Net struct {
Transport string `yaml:"transport"` // datachannel, videochannel, seichannel, vp8channel
DNS string `yaml:"dns"`
}
// SOCKS bundles SOCKS5 listener and outbound-proxy settings.
type SOCKS struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
User string `yaml:"user"`
Pass string `yaml:"pass"`
ProxyAddr string `yaml:"proxy_addr"`
ProxyPort int `yaml:"proxy_port"`
}
// Engine selects a direct SFU connection when Auth.Provider is "none".
type Engine struct {
Name string `yaml:"name"` // livekit, goolom, jitsi
URL string `yaml:"url"`
Token string `yaml:"token"`
}
// Video tunes the videochannel transport.
type Video struct {
Width int `yaml:"width"`
Height int `yaml:"height"`
FPS int `yaml:"fps"`
Bitrate string `yaml:"bitrate"`
HW string `yaml:"hw"`
QRSize int `yaml:"qr_size"`
QRRecovery string `yaml:"qr_recovery"`
Codec string `yaml:"codec"`
TileModule int `yaml:"tile_module"`
TileRS int `yaml:"tile_rs"`
}
// VP8 tunes the vp8channel transport.
type VP8 struct {
FPS int `yaml:"fps"`
BatchSize int `yaml:"batch_size"`
}
// SEI tunes the seichannel transport.
type SEI struct {
FPS int `yaml:"fps"`
BatchSize int `yaml:"batch_size"`
FragmentSize int `yaml:"fragment_size"`
AckTimeoutMS int `yaml:"ack_timeout_ms"`
}
// Liveness tunes the post-handshake control stream ping/pong checks.
type Liveness struct {
Interval string `yaml:"interval"`
Timeout string `yaml:"timeout"`
Failures int `yaml:"failures"`
}
// Lifecycle controls planned session rebuilds.
type Lifecycle struct {
MaxSessionDuration string `yaml:"max_session_duration"`
}
// Traffic controls optional reliability-oriented send shaping.
type Traffic struct {
MaxPayloadSize int `yaml:"max_payload_size"`
MinDelay string `yaml:"min_delay"`
MaxDelay string `yaml:"max_delay"`
}
// Gen controls room-generation mode.
type Gen struct {
Amount int `yaml:"amount"`
}
// Load parses a YAML file from disk.
func Load(path string) (File, error) {
// #nosec G304 -- config path is an explicit CLI/user input.
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return File{}, fmt.Errorf("%w: %s", ErrConfigNotFound, path)
}
return File{}, fmt.Errorf("read config %s: %w", path, err)
}
if !utf8.Valid(data) {
return File{}, fmt.Errorf("parse config %s: %w", path, ErrConfigInvalidUTF8)
}
var f File
if err := yaml.Unmarshal(data, &f); err != nil {
return File{}, fmt.Errorf("parse config %s: %w", path, err)
}
if err := loadExternalSecrets(path, &f); err != nil {
return File{}, err
}
return f, nil
}
func loadExternalSecrets(configPath string, f *File) error {
if f.Crypto.KeyFile == "" {
return loadProfileSecrets(configPath, f.Profiles)
}
if f.Crypto.Key != "" {
return ErrCryptoKeyConflict
}
key, err := readKeyFile(configPath, f.Crypto.KeyFile)
if err != nil {
return err
}
f.Crypto.Key = key
return loadProfileSecrets(configPath, f.Profiles)
}
func loadProfileSecrets(configPath string, profiles []Profile) error {
for i := range profiles {
if profiles[i].Crypto.KeyFile == "" {
continue
}
if profiles[i].Crypto.Key != "" {
return fmt.Errorf("profiles[%d]: %w", i, ErrCryptoKeyConflict)
}
key, err := readKeyFile(configPath, profiles[i].Crypto.KeyFile)
if err != nil {
return fmt.Errorf("profiles[%d]: %w", i, err)
}
profiles[i].Crypto.Key = key
}
return nil
}
func readKeyFile(configPath, keyFile string) (string, error) {
keyPath := keyFile
if !filepath.IsAbs(keyPath) {
keyPath = filepath.Join(filepath.Dir(configPath), keyPath)
}
// #nosec G304 -- key_file is an explicit path in the user's config file.
data, err := os.ReadFile(keyPath)
if err != nil {
return "", fmt.Errorf("read crypto key file %s: %w", keyPath, err)
}
key := strings.TrimSpace(string(data))
if key == "" {
return "", ErrCryptoKeyFileEmpty
}
return key, nil
}
// Apply merges f onto dst. CLI-set fields (non-zero values in dst) win;
// YAML values fill in the rest.
func Apply(dst session.Config, f File) session.Config {
dst.Mode = pickString(dst.Mode, f.Mode)
dst.Transport = pickString(dst.Transport, f.Net.Transport)
dst.Auth = pickString(dst.Auth, f.Auth.Provider)
dst.Engine = pickString(dst.Engine, f.Engine.Name)
dst.URL = pickString(dst.URL, f.Engine.URL)
dst.Token = pickString(dst.Token, f.Engine.Token)
dst.RoomID = pickString(dst.RoomID, f.Room.ID)
dst.ChannelID = pickString(dst.ChannelID, f.Room.Channel)
dst.KeyHex = pickString(dst.KeyHex, f.Crypto.Key)
dst.SOCKSHost = pickString(dst.SOCKSHost, f.SOCKS.Host)
dst.SOCKSPort = pickInt(dst.SOCKSPort, f.SOCKS.Port)
dst.SOCKSUser = pickString(dst.SOCKSUser, f.SOCKS.User)
dst.SOCKSPass = pickString(dst.SOCKSPass, f.SOCKS.Pass)
dst.DNSServer = pickString(dst.DNSServer, f.Net.DNS)
dst.SOCKSProxyAddr = pickString(dst.SOCKSProxyAddr, f.SOCKS.ProxyAddr)
dst.SOCKSProxyPort = pickInt(dst.SOCKSProxyPort, f.SOCKS.ProxyPort)
dst.Video.Width = pickInt(dst.Video.Width, f.Video.Width)
dst.Video.Height = pickInt(dst.Video.Height, f.Video.Height)
dst.Video.FPS = pickInt(dst.Video.FPS, f.Video.FPS)
dst.Video.Bitrate = pickString(dst.Video.Bitrate, f.Video.Bitrate)
dst.Video.HW = pickString(dst.Video.HW, f.Video.HW)
dst.Video.QRSize = pickInt(dst.Video.QRSize, f.Video.QRSize)
dst.Video.QRRecovery = pickString(dst.Video.QRRecovery, f.Video.QRRecovery)
dst.Video.Codec = pickString(dst.Video.Codec, f.Video.Codec)
dst.Video.TileModule = pickInt(dst.Video.TileModule, f.Video.TileModule)
dst.Video.TileRS = pickInt(dst.Video.TileRS, f.Video.TileRS)
dst.VP8.FPS = pickInt(dst.VP8.FPS, f.VP8.FPS)
dst.VP8.BatchSize = pickInt(dst.VP8.BatchSize, f.VP8.BatchSize)
dst.SEI.FPS = pickInt(dst.SEI.FPS, f.SEI.FPS)
dst.SEI.BatchSize = pickInt(dst.SEI.BatchSize, f.SEI.BatchSize)
dst.SEI.FragmentSize = pickInt(dst.SEI.FragmentSize, f.SEI.FragmentSize)
dst.SEI.AckTimeoutMS = pickInt(dst.SEI.AckTimeoutMS, f.SEI.AckTimeoutMS)
dst.LivenessInterval = pickString(dst.LivenessInterval, f.Liveness.Interval)
dst.LivenessTimeout = pickString(dst.LivenessTimeout, f.Liveness.Timeout)
dst.LivenessFailures = pickInt(dst.LivenessFailures, f.Liveness.Failures)
dst.MaxSessionDuration = pickString(dst.MaxSessionDuration, f.Lifecycle.MaxSessionDuration)
dst.TrafficMaxPayloadSize = pickInt(dst.TrafficMaxPayloadSize, f.Traffic.MaxPayloadSize)
dst.TrafficMinDelay = pickString(dst.TrafficMinDelay, f.Traffic.MinDelay)
dst.TrafficMaxDelay = pickString(dst.TrafficMaxDelay, f.Traffic.MaxDelay)
dst.Amount = pickInt(dst.Amount, f.Gen.Amount)
return dst
}
// ApplyProfile overlays a failover profile onto an already-applied base config.
func ApplyProfile(base session.Config, p Profile) session.Config {
dst := base
dst.Transport = overlayString(dst.Transport, p.Net.Transport)
dst.Auth = overlayString(dst.Auth, p.Auth.Provider)
dst.Engine = overlayString(dst.Engine, p.Engine.Name)
dst.URL = overlayString(dst.URL, p.Engine.URL)
dst.Token = overlayString(dst.Token, p.Engine.Token)
dst.RoomID = overlayString(dst.RoomID, p.Room.ID)
dst.ChannelID = overlayString(dst.ChannelID, p.Room.Channel)
dst.KeyHex = overlayString(dst.KeyHex, p.Crypto.Key)
dst.SOCKSHost = overlayString(dst.SOCKSHost, p.SOCKS.Host)
dst.SOCKSPort = overlayInt(dst.SOCKSPort, p.SOCKS.Port)
dst.SOCKSUser = overlayString(dst.SOCKSUser, p.SOCKS.User)
dst.SOCKSPass = overlayString(dst.SOCKSPass, p.SOCKS.Pass)
dst.DNSServer = overlayString(dst.DNSServer, p.Net.DNS)
dst.SOCKSProxyAddr = overlayString(dst.SOCKSProxyAddr, p.SOCKS.ProxyAddr)
dst.SOCKSProxyPort = overlayInt(dst.SOCKSProxyPort, p.SOCKS.ProxyPort)
dst.Video.Width = overlayInt(dst.Video.Width, p.Video.Width)
dst.Video.Height = overlayInt(dst.Video.Height, p.Video.Height)
dst.Video.FPS = overlayInt(dst.Video.FPS, p.Video.FPS)
dst.Video.Bitrate = overlayString(dst.Video.Bitrate, p.Video.Bitrate)
dst.Video.HW = overlayString(dst.Video.HW, p.Video.HW)
dst.Video.QRSize = overlayInt(dst.Video.QRSize, p.Video.QRSize)
dst.Video.QRRecovery = overlayString(dst.Video.QRRecovery, p.Video.QRRecovery)
dst.Video.Codec = overlayString(dst.Video.Codec, p.Video.Codec)
dst.Video.TileModule = overlayInt(dst.Video.TileModule, p.Video.TileModule)
dst.Video.TileRS = overlayInt(dst.Video.TileRS, p.Video.TileRS)
dst.VP8.FPS = overlayInt(dst.VP8.FPS, p.VP8.FPS)
dst.VP8.BatchSize = overlayInt(dst.VP8.BatchSize, p.VP8.BatchSize)
dst.SEI.FPS = overlayInt(dst.SEI.FPS, p.SEI.FPS)
dst.SEI.BatchSize = overlayInt(dst.SEI.BatchSize, p.SEI.BatchSize)
dst.SEI.FragmentSize = overlayInt(dst.SEI.FragmentSize, p.SEI.FragmentSize)
dst.SEI.AckTimeoutMS = overlayInt(dst.SEI.AckTimeoutMS, p.SEI.AckTimeoutMS)
dst.LivenessInterval = overlayString(dst.LivenessInterval, p.Liveness.Interval)
dst.LivenessTimeout = overlayString(dst.LivenessTimeout, p.Liveness.Timeout)
dst.LivenessFailures = overlayInt(dst.LivenessFailures, p.Liveness.Failures)
dst.MaxSessionDuration = overlayString(dst.MaxSessionDuration, p.Lifecycle.MaxSessionDuration)
dst.TrafficMaxPayloadSize = overlayInt(dst.TrafficMaxPayloadSize, p.Traffic.MaxPayloadSize)
dst.TrafficMinDelay = overlayString(dst.TrafficMinDelay, p.Traffic.MinDelay)
dst.TrafficMaxDelay = overlayString(dst.TrafficMaxDelay, p.Traffic.MaxDelay)
return dst
}
func pickString(cli, yamlVal string) string {
if cli != "" {
return cli
}
return yamlVal
}
func pickInt(cli, yamlVal int) int {
if cli != 0 {
return cli
}
return yamlVal
}
func overlayString(base, override string) string {
if override != "" {
return override
}
return base
}
func overlayInt(base, override int) int {
if override != 0 {
return override
}
return base
}

View File

@@ -0,0 +1,335 @@
package config
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/openlibrecommunity/olcrtc/internal/app/session"
)
const (
testModeSrv = "srv"
testAuthProvider = "wbstream"
testRoomID = "r1"
testCryptoKey = "deadbeef"
testDNSServer = "8.8.8.8:53"
)
func TestLoadAndApply(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "olcrtc.yaml")
body := `
mode: srv
link: direct
auth:
provider: wbstream
room:
id: r1
crypto:
key: deadbeef
net:
transport: datachannel
dns: 8.8.8.8:53
socks:
host: 127.0.0.1
port: 1080
user: u
pass: p
vp8:
fps: 25
batch_size: 4
liveness:
interval: 2s
timeout: 500ms
failures: 4
lifecycle:
max_session_duration: 6h
traffic:
max_payload_size: 4096
min_delay: 5ms
max_delay: 30ms
gen:
amount: 3
debug: true
`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
f, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
requireLoadedFile(t, f)
got := Apply(session.Config{}, f)
requireAppliedConfig(t, got)
}
func requireLoadedFile(t *testing.T, f File) {
t.Helper()
if f.Mode != testModeSrv {
t.Fatalf("Mode = %q, want %q", f.Mode, testModeSrv)
}
if f.Auth.Provider != testAuthProvider {
t.Fatalf("Auth.Provider = %q, want %q", f.Auth.Provider, testAuthProvider)
}
if f.Room.ID != testRoomID {
t.Fatalf("Room.ID = %q, want %q", f.Room.ID, testRoomID)
}
if f.Crypto.Key != testCryptoKey {
t.Fatalf("Crypto.Key = %q, want %q", f.Crypto.Key, testCryptoKey)
}
}
func requireAppliedConfig(t *testing.T, got session.Config) {
t.Helper()
want := session.Config{
Mode: testModeSrv,
Auth: testAuthProvider,
RoomID: testRoomID,
KeyHex: testCryptoKey,
Transport: "datachannel",
DNSServer: testDNSServer,
SOCKSHost: "127.0.0.1",
SOCKSPort: 1080,
SOCKSUser: "u",
SOCKSPass: "p",
VP8: session.VP8Config{FPS: 25, BatchSize: 4},
LivenessInterval: "2s",
LivenessTimeout: "500ms",
LivenessFailures: 4,
MaxSessionDuration: "6h",
TrafficMaxPayloadSize: 4096,
TrafficMinDelay: "5ms",
TrafficMaxDelay: "30ms",
Amount: 3,
}
if got != want {
t.Fatalf("Apply produced wrong config: %+v, want %+v", got, want)
}
}
func TestApplyCLIWins(t *testing.T) {
cli := session.Config{
Mode: "cnc",
KeyHex: "from-cli",
SOCKSPort: 9999,
}
f := File{
Mode: testModeSrv,
Crypto: Crypto{Key: "from-yaml"},
SOCKS: SOCKS{Port: 1234, Host: "0.0.0.0"},
}
got := Apply(cli, f)
if got.Mode != "cnc" {
t.Errorf("Mode: got %q, want cnc (CLI wins)", got.Mode)
}
if got.KeyHex != "from-cli" {
t.Errorf("KeyHex: got %q, want from-cli (CLI wins)", got.KeyHex)
}
if got.SOCKSPort != 9999 {
t.Errorf("SOCKSPort: got %d, want 9999 (CLI wins)", got.SOCKSPort)
}
if got.SOCKSHost != "0.0.0.0" {
t.Errorf("SOCKSHost: got %q, want 0.0.0.0 (YAML fills empty CLI)", got.SOCKSHost)
}
}
//nolint:cyclop // profile merge fixture intentionally checks many mapped fields
func TestLoadAndApplyProfile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "olcrtc.yaml")
body := `
mode: srv
link: direct
crypto:
key: shared-key
net:
dns: 8.8.8.8:53
liveness:
interval: 5s
timeout: 2s
failures: 5
lifecycle:
max_session_duration: 6h
traffic:
max_payload_size: 8192
min_delay: 10ms
max_delay: 40ms
profiles:
- name: wb-vp8
auth:
provider: wbstream
room:
id: wb-room
net:
transport: vp8channel
vp8:
fps: 30
liveness:
interval: 1s
lifecycle:
max_session_duration: 30m
traffic:
max_payload_size: 4096
max_delay: 20ms
- name: jitsi-dc
auth:
provider: jitsi
room:
id: https://meet.example/room
net:
transport: datachannel
dns: 8.8.8.8:53
failover:
retry_delay: 100ms
max_cycles: 2
`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
f, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(f.Profiles) != 2 {
t.Fatalf("profiles = %d, want 2", len(f.Profiles))
}
if f.Failover.RetryDelay != "100ms" || f.Failover.MaxCycles != 2 {
t.Fatalf("Failover = %+v, want retry_delay 100ms max_cycles 2", f.Failover)
}
base := Apply(session.Config{}, f)
first := ApplyProfile(base, f.Profiles[0])
if first.Auth != "wbstream" || first.Transport != "vp8channel" || first.RoomID != "wb-room" {
t.Fatalf("first profile = %+v", first)
}
if first.KeyHex != "shared-key" || first.DNSServer != testDNSServer || first.VP8.FPS != 30 ||
first.LivenessInterval != "1s" || first.LivenessTimeout != "2s" || first.LivenessFailures != 5 ||
first.MaxSessionDuration != "30m" || first.TrafficMaxPayloadSize != 4096 ||
first.TrafficMinDelay != "10ms" || first.TrafficMaxDelay != "20ms" {
t.Fatalf("first inherited/overlaid fields = %+v", first)
}
second := ApplyProfile(base, f.Profiles[1])
if second.Auth != "jitsi" || second.Transport != "datachannel" ||
second.RoomID != "https://meet.example/room" || second.DNSServer != testDNSServer {
t.Fatalf("second profile = %+v", second)
}
if second.LivenessInterval != "5s" || second.LivenessTimeout != "2s" || second.LivenessFailures != 5 ||
second.MaxSessionDuration != "6h" || second.TrafficMaxPayloadSize != 8192 ||
second.TrafficMinDelay != "10ms" || second.TrafficMaxDelay != "40ms" {
t.Fatalf("second lifecycle/liveness fields = %+v", second)
}
}
func TestLoadProfileCryptoKeyFile(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "profile.key"), []byte(testCryptoKey+"\n"), 0o600); err != nil {
t.Fatalf("write key: %v", err)
}
path := filepath.Join(dir, "olcrtc.yaml")
body := `
profiles:
- name: file-key
crypto:
key_file: profile.key
`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
f, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if got := f.Profiles[0].Crypto.Key; got != testCryptoKey {
t.Fatalf("profile key = %q, want %q", got, testCryptoKey)
}
}
func TestLoadCryptoKeyFileRelativeToConfig(t *testing.T) {
dir := t.TempDir()
keyPath := filepath.Join(dir, "secret.key")
if err := os.WriteFile(keyPath, []byte(testCryptoKey+"\n"), 0o600); err != nil {
t.Fatalf("write key: %v", err)
}
path := filepath.Join(dir, "olcrtc.yaml")
body := `
mode: srv
crypto:
key_file: secret.key
`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
f, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if f.Crypto.Key != testCryptoKey {
t.Fatalf("Crypto.Key = %q, want %q", f.Crypto.Key, testCryptoKey)
}
}
func TestLoadCryptoKeyFileConflict(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "olcrtc.yaml")
body := `
crypto:
key: deadbeef
key_file: secret.key
`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
_, err := Load(path)
if !errors.Is(err, ErrCryptoKeyConflict) {
t.Fatalf("Load() error = %v, want %v", err, ErrCryptoKeyConflict)
}
}
func TestLoadCryptoKeyFileEmpty(t *testing.T) {
dir := t.TempDir()
keyPath := filepath.Join(dir, "secret.key")
if err := os.WriteFile(keyPath, []byte("\n"), 0o600); err != nil {
t.Fatalf("write key: %v", err)
}
path := filepath.Join(dir, "olcrtc.yaml")
body := `
crypto:
key_file: secret.key
`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
_, err := Load(path)
if !errors.Is(err, ErrCryptoKeyFileEmpty) {
t.Fatalf("Load() error = %v, want %v", err, ErrCryptoKeyFileEmpty)
}
}
func TestLoadMissing(t *testing.T) {
_, err := Load(filepath.Join(t.TempDir(), "nope.yaml"))
if err == nil {
t.Fatal("expected error for missing file")
}
}
func TestLoadInvalidUTF8(t *testing.T) {
path := filepath.Join(t.TempDir(), "olcrtc.yaml")
if err := os.WriteFile(path, []byte{'m', 'o', 'd', 'e', ':', ' ', 0xff}, 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
_, err := Load(path)
if !errors.Is(err, ErrConfigInvalidUTF8) {
t.Fatalf("Load() error = %v, want invalid UTF-8 error", err)
}
}

349
internal/control/control.go Normal file
View File

@@ -0,0 +1,349 @@
// Package control implements the post-handshake control stream protocol.
//
// The control stream is the first smux stream after the olcrtc handshake. It
// stays inside the encrypted muxconn path, so ping/pong proves that the actual
// tunnel path still round-trips, not merely that the provider connection is up.
//
// Wire format matches the handshake framing: a 4-byte big-endian length
// followed by a JSON message.
//
//nolint:tagliatelle // JSON keys are the stable wire protocol schema.
package control
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"sync"
"time"
"github.com/openlibrecommunity/olcrtc/internal/framing"
)
const (
// ProtoVersion identifies the control stream wire format.
ProtoVersion = 1
// MaxMessageSize caps one control frame.
MaxMessageSize = 16 * 1024
// DefaultInterval is the default interval between ping probes.
DefaultInterval = 10 * time.Second
// DefaultTimeout is the default time to wait for a pong.
DefaultTimeout = 5 * time.Second
// DefaultFailures is the default number of consecutive missed pongs before
// the stream is marked unhealthy.
DefaultFailures = 3
)
// MsgType labels a control message.
type MsgType string
const (
// TypePing is sent periodically to prove control-stream liveness.
TypePing MsgType = "CONTROL_PING"
// TypePong replies to a ping with the same sequence and timestamp.
TypePong MsgType = "CONTROL_PONG"
// TypeClose tells the peer this control session is intentionally closing.
TypeClose MsgType = "CONTROL_CLOSE"
)
var (
// ErrUnhealthy is returned when the stream misses too many pong replies.
ErrUnhealthy = errors.New("control stream unhealthy")
// ErrClosedByPeer is returned when the peer gracefully closes the control session.
ErrClosedByPeer = errors.New("control stream closed by peer")
// ErrProtocolVersion is returned when the peer announces an incompatible version.
ErrProtocolVersion = errors.New("incompatible control protocol version")
// ErrUnexpectedMessage is returned for unknown or malformed control message types.
ErrUnexpectedMessage = errors.New("unexpected control message")
// ErrFrameTooLarge is returned when a frame exceeds [MaxMessageSize].
ErrFrameTooLarge = framing.ErrFrameTooLarge
)
// Message is one control-stream frame.
type Message struct {
Version int `json:"version"`
Type MsgType `json:"type"`
Seq uint64 `json:"seq,omitempty"`
SentUnixNano int64 `json:"sent_unix_nano,omitempty"`
}
// Health is reported when a ping round trip completes.
type Health struct {
Seq uint64
RTT time.Duration
LastSeen time.Time
}
// Status is a point-in-time view of control-stream health maintained by
// callers that embed the control loop.
type Status struct {
SessionID string
LastPong time.Time
LastRTT time.Duration
MissedPongs int
Reconnects uint64
UnhealthyEvents uint64
LastUnhealthy time.Time
}
// Config controls the liveness loop.
type Config struct {
Interval time.Duration
Timeout time.Duration
Failures int
// OnPong is called after a matching pong is received.
OnPong func(Health)
// OnMissedPong is called when one or more outstanding pongs time out.
OnMissedPong func(missed int)
// OnUnhealthy is called before Run returns [ErrUnhealthy].
OnUnhealthy func(missed int)
}
func (cfg Config) withDefaults() Config {
if cfg.Interval <= 0 {
cfg.Interval = DefaultInterval
}
if cfg.Timeout <= 0 {
cfg.Timeout = DefaultTimeout
}
if cfg.Failures <= 0 {
cfg.Failures = DefaultFailures
}
return cfg
}
// Run drives bidirectional ping/pong liveness until ctx is canceled, rw closes,
// or the configured failure threshold is reached.
func Run(ctx context.Context, rw io.ReadWriteCloser, cfg Config) error {
cfg = cfg.withDefaults()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
state := &state{
rw: rw,
cfg: cfg,
pending: make(map[uint64]time.Time),
now: time.Now,
out: make(chan Message, 16),
}
errCh := make(chan error, 3)
go func() {
<-ctx.Done()
_ = rw.Close()
}()
go func() { errCh <- state.readLoop(ctx) }()
go func() { errCh <- state.probeLoop(ctx) }()
go func() { errCh <- state.writeLoop(ctx) }()
err := <-errCh
cancel()
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil
}
return err
}
type state struct {
rw io.ReadWriteCloser
cfg Config
now func() time.Time
out chan Message
mu sync.Mutex
pending map[uint64]time.Time
nextSeq uint64
failures int
}
func (s *state) readLoop(ctx context.Context) error {
for {
raw, err := readFrame(s.rw)
if err != nil {
return readLoopErr(ctx, err)
}
msg, err := parseMessage(raw)
if err != nil {
return err
}
if err := s.handleReadMessage(ctx, msg); err != nil {
return err
}
}
}
func readLoopErr(ctx context.Context, err error) error {
if ctx.Err() != nil {
return fmt.Errorf("read loop canceled: %w", ctx.Err())
}
return err
}
func (s *state) handleReadMessage(ctx context.Context, msg Message) error {
switch msg.Type {
case TypePing:
return s.enqueuePong(ctx, msg)
case TypePong:
s.handlePong(msg)
return nil
case TypeClose:
return ErrClosedByPeer
default:
return fmt.Errorf("%w: got %q", ErrUnexpectedMessage, msg.Type)
}
}
func (s *state) enqueuePong(ctx context.Context, ping Message) error {
err := s.enqueue(ctx, Message{
Version: ProtoVersion,
Type: TypePong,
Seq: ping.Seq,
SentUnixNano: ping.SentUnixNano,
})
if err != nil {
return readLoopErr(ctx, err)
}
return nil
}
func (s *state) probeLoop(ctx context.Context) error {
ticker := time.NewTicker(s.cfg.Interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return fmt.Errorf("probe loop canceled: %w", ctx.Err())
case <-ticker.C:
if err := s.sendProbe(ctx); err != nil {
return err
}
}
}
}
func (s *state) sendProbe(ctx context.Context) error {
now := s.now()
s.mu.Lock()
missedNow := 0
for seq, sent := range s.pending {
if now.Sub(sent) < s.cfg.Timeout {
continue
}
delete(s.pending, seq)
s.failures++
missedNow++
}
missed := s.failures
if s.failures >= s.cfg.Failures {
s.mu.Unlock()
if missedNow > 0 && s.cfg.OnMissedPong != nil {
s.cfg.OnMissedPong(missed)
}
if s.cfg.OnUnhealthy != nil {
s.cfg.OnUnhealthy(missed)
}
return fmt.Errorf("%w: missed %d pong(s)", ErrUnhealthy, missed)
}
s.nextSeq++
seq := s.nextSeq
s.pending[seq] = now
s.mu.Unlock()
if missedNow > 0 && s.cfg.OnMissedPong != nil {
s.cfg.OnMissedPong(missed)
}
return s.enqueue(ctx, Message{
Version: ProtoVersion,
Type: TypePing,
Seq: seq,
SentUnixNano: now.UnixNano(),
})
}
func (s *state) handlePong(msg Message) {
now := s.now()
s.mu.Lock()
sent, ok := s.pending[msg.Seq]
if ok {
delete(s.pending, msg.Seq)
s.failures = 0
}
s.mu.Unlock()
if !ok || s.cfg.OnPong == nil {
return
}
s.cfg.OnPong(Health{
Seq: msg.Seq,
RTT: now.Sub(sent),
LastSeen: now,
})
}
func (s *state) enqueue(ctx context.Context, msg Message) error {
select {
case <-ctx.Done():
return fmt.Errorf("enqueue canceled: %w", ctx.Err())
case s.out <- msg:
return nil
}
}
func (s *state) writeLoop(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return fmt.Errorf("write loop canceled: %w", ctx.Err())
case msg := <-s.out:
if err := writeFrame(s.rw, msg); err != nil {
if ctx.Err() != nil {
return fmt.Errorf("write loop canceled: %w", ctx.Err())
}
return err
}
}
}
}
func parseMessage(raw []byte) (Message, error) {
var msg Message
if err := json.Unmarshal(raw, &msg); err != nil {
return Message{}, fmt.Errorf("parse control message: %w", err)
}
if msg.Version != ProtoVersion {
return Message{}, fmt.Errorf("%w: peer v%d, local v%d",
ErrProtocolVersion, msg.Version, ProtoVersion)
}
if msg.Type != TypePing && msg.Type != TypePong && msg.Type != TypeClose {
return Message{}, fmt.Errorf("%w: got %q", ErrUnexpectedMessage, msg.Type)
}
return msg, nil
}
// SendClose sends a best-effort graceful close notification on the control stream.
func SendClose(w io.Writer) error {
return writeFrame(w, Message{Version: ProtoVersion, Type: TypeClose})
}
func writeFrame(w io.Writer, msg Message) error {
if err := framing.WriteJSON(w, msg, MaxMessageSize); err != nil {
return fmt.Errorf("control: %w", err)
}
return nil
}
func readFrame(r io.Reader) ([]byte, error) {
body, err := framing.ReadBytes(r, MaxMessageSize)
if err != nil {
return nil, fmt.Errorf("control: %w", err)
}
return body, nil
}

View File

@@ -0,0 +1,158 @@
package control
import (
"context"
"encoding/binary"
"errors"
"io"
"net"
"testing"
"time"
)
func controlPair(t *testing.T) (net.Conn, net.Conn) {
t.Helper()
a, b := net.Pipe()
t.Cleanup(func() {
_ = a.Close()
_ = b.Close()
})
return a, b
}
func TestRunPingPongReportsRTT(t *testing.T) {
a, b := controlPair(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
got := make(chan Health, 1)
cfg := Config{
Interval: 10 * time.Millisecond,
Timeout: 100 * time.Millisecond,
Failures: 2,
OnPong: func(h Health) {
select {
case got <- h:
default:
}
},
}
errCh := make(chan error, 2)
go func() { errCh <- Run(ctx, a, cfg) }()
go func() { errCh <- Run(ctx, b, cfg) }()
select {
case h := <-got:
if h.Seq == 0 {
t.Fatal("Health.Seq = 0")
}
if h.RTT < 0 {
t.Fatalf("Health.RTT = %v", h.RTT)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for pong health")
}
cancel()
for range 2 {
if err := <-errCh; err != nil {
t.Fatalf("Run() after cancel = %v", err)
}
}
}
func TestRunMarksUnhealthyAfterMissedPongs(t *testing.T) {
a, b := controlPair(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
_, _ = io.Copy(io.Discard, b)
}()
missedCh := make(chan int, 1)
missedCallbackCh := make(chan int, 1)
errCh := make(chan error, 1)
go func() {
errCh <- Run(ctx, a, Config{
Interval: 10 * time.Millisecond,
Timeout: 5 * time.Millisecond,
Failures: 2,
OnMissedPong: func(missed int) {
select {
case missedCallbackCh <- missed:
default:
}
},
OnUnhealthy: func(missed int) { missedCh <- missed },
})
}()
select {
case err := <-errCh:
if !errors.Is(err, ErrUnhealthy) {
t.Fatalf("Run() error = %v, want ErrUnhealthy", err)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for unhealthy result")
}
if missed := <-missedCh; missed < 2 {
t.Fatalf("missed = %d, want >= 2", missed)
}
if missed := <-missedCallbackCh; missed < 1 {
t.Fatalf("missed callback = %d, want >= 1", missed)
}
}
func TestRunRejectsBadProtocolVersion(t *testing.T) {
a, b := controlPair(t)
errCh := make(chan error, 1)
go func() {
errCh <- Run(context.Background(), a, Config{Interval: time.Hour})
}()
if err := writeFrame(b, Message{Version: 999, Type: TypePing, Seq: 1}); err != nil {
t.Fatalf("writeFrame() error = %v", err)
}
select {
case err := <-errCh:
if !errors.Is(err, ErrProtocolVersion) {
t.Fatalf("Run() error = %v, want ErrProtocolVersion", err)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for protocol error")
}
}
func TestRunStopsOnPeerClose(t *testing.T) {
a, b := controlPair(t)
errCh := make(chan error, 1)
go func() {
errCh <- Run(context.Background(), a, Config{Interval: time.Hour})
}()
if err := SendClose(b); err != nil {
t.Fatalf("SendClose() error = %v", err)
}
select {
case err := <-errCh:
if !errors.Is(err, ErrClosedByPeer) {
t.Fatalf("Run() error = %v, want ErrClosedByPeer", err)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for peer close")
}
}
func TestReadFrameRejectsTooLarge(t *testing.T) {
a, b := controlPair(t)
go func() {
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], MaxMessageSize+1)
_, _ = b.Write(hdr[:])
}()
_, err := readFrame(a)
if !errors.Is(err, ErrFrameTooLarge) {
t.Fatalf("readFrame() error = %v, want ErrFrameTooLarge", err)
}
}

View File

@@ -10,6 +10,9 @@ import (
"golang.org/x/crypto/chacha20poly1305"
)
// WireOverhead is the number of bytes added to each encrypted message.
const WireOverhead = chacha20poly1305.NonceSizeX + chacha20poly1305.Overhead
var (
// ErrInvalidKeySize is returned when the encryption key is not 32 bytes.
ErrInvalidKeySize = errors.New("invalid key size")

317
internal/e2e/stress_test.go Normal file
View File

@@ -0,0 +1,317 @@
package e2e
import (
"bufio"
"bytes"
"context"
"errors"
"flag"
"fmt"
"io"
"net"
"runtime"
"slices"
"testing"
"time"
enginebuiltin "github.com/openlibrecommunity/olcrtc/internal/engine/builtin"
)
var (
errStressNoRoundtrips = errors.New("no successful roundtrips within duration")
errStressPayloadMatch = errors.New("payload mismatch")
errStressNoBulkProgress = errors.New("bulk pump made zero progress")
)
var (
realStress = flag.Bool( //nolint:gochecknoglobals // package-level state intentional
"olcrtc.stress",
false,
"run real provider stress matrix (bulk transfer + sustained echo) — requires -olcrtc.real-e2e",
)
realStressBulkDuration = flag.Duration( //nolint:gochecknoglobals // package-level state intentional
"olcrtc.stress-bulk-duration",
60*time.Second,
"per-case duration for the bulk pattern-pump phase (set 0 to skip). "+
"Throughput differs by ~3 orders of magnitude across transports "+
"(datachannel: MiB/s; videochannel: KB/s), so we measure how much "+
"flows in a fixed time rather than fixing the byte budget.",
)
realStressDuration = flag.Duration( //nolint:gochecknoglobals // package-level state intentional
"olcrtc.stress-duration",
30*time.Second,
"per-case duration for the sustained echo phase (set 0 to skip)",
)
realStressEchoSize = flag.Int( //nolint:gochecknoglobals // package-level state intentional
"olcrtc.stress-echo-size",
1024,
"single-roundtrip payload size during the sustained echo phase",
)
realStressCaseTimeout = flag.Duration( //nolint:gochecknoglobals // package-level state intentional
"olcrtc.stress-case-timeout",
5*time.Minute,
"hard timeout per stress carrier×transport case (covers connect + bulk + echo)",
)
realStressBulkChunkSize = flag.Int( //nolint:gochecknoglobals // package-level state intentional
"olcrtc.stress-bulk-chunk",
4096,
"bulk request-response chunk size in bytes",
)
)
// TestRealProviderTransportStress exercises every real carrier×transport
// combination under load. For each pair, two phases run sequentially over
// a single SOCKS connection:
//
// 1. Bulk phase: stream a deterministic byte pattern through the tunnel
// for -olcrtc.stress-bulk-duration and verify it echoes back byte-for-
// byte. Reports observed throughput. Different transports differ by
// orders of magnitude (qr-encoded videochannel vs SCTP datachannel),
// so we measure rather than assert a fixed budget.
// 2. Echo phase: send -olcrtc.stress-echo-size payloads as fast as the
// loop will go for -olcrtc.stress-duration, recording per-RT latency
// and computing p50/p95/p99.
//
// Around both phases we snapshot runtime.NumGoroutine to surface obvious
// goroutine leaks introduced by reconnect / bytestream / epoch regressions.
//
// Gated by -olcrtc.stress so it never runs on every push; intended for the
// nightly soak job in CI and for local stress profiling.
//
//nolint:cyclop // matrix of carrier×transport expectations is naturally branchy
func TestRealProviderTransportStress(t *testing.T) {
if !*realE2E {
t.Skip("real provider e2e disabled; pass -olcrtc.real-e2e to enable")
}
if !*realStress {
t.Skip("stress disabled; pass -olcrtc.stress to enable")
}
carriers := splitTestList(*realE2ECarriers)
transports := splitTestList(*realE2ETransports)
if len(carriers) == 0 {
t.Fatal("no real e2e carriers selected")
}
if len(transports) == 0 {
t.Fatal("no real e2e transports selected")
}
echoAddr := startEchoServer(t)
for _, carrierName := range carriers {
t.Run(carrierName, func(t *testing.T) {
roomCtx, cancelRoom := context.WithTimeout(context.Background(), *realStressCaseTimeout)
defer cancelRoom()
roomURL := requireRealRoom(roomCtx, t, carrierName)
var authFailed bool
for _, transportName := range transports {
t.Run(transportName, func(t *testing.T) {
if authFailed {
t.Skip("skipping: carrier auth failed on previous transport")
}
expectation := realE2ECaseExpectation(carrierName, transportName)
if expectation == realE2EExpectFail {
t.Skip("skipping: combo not expected to pass even at baseline")
}
err := runRealE2EStressCase(t, carrierName, transportName, roomURL, echoAddr)
if err != nil && errors.Is(err, enginebuiltin.ErrAuthFailed) {
authFailed = true
t.Skipf("skip %s stress: auth failed: %v", carrierName, err)
}
switch {
case err == nil:
t.Logf("STRESS OK %s/%s", carrierName, transportName)
case expectation == realE2EExpectUnstable:
logUnstableOutcome(t, "STRESS UNSTABLE", carrierName, transportName, err)
default:
t.Fatalf("STRESS FAIL %s/%s: %v", carrierName, transportName, err)
}
})
}
})
}
}
//nolint:cyclop // two phases plus tunnel/connection setup naturally branch
func runRealE2EStressCase(t *testing.T, carrierName, transportName, roomURL, echoAddr string) (err error) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), *realStressCaseTimeout)
defer cancel()
goroutinesBefore := runtime.NumGoroutine()
rt, err := startRealTunnel(ctx, t, carrierName, transportName, roomURL, testClientDeviceID, testClientDeviceID)
if err != nil {
return err
}
defer func() {
if stopErr := rt.stopErr(); err == nil && stopErr != nil {
err = stopErr
}
}()
conn, err := connectViaSOCKSWithin(rt.socksAddr, echoAddr, *realStressCaseTimeout)
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
if d := *realStressBulkDuration; d > 0 {
written, dur, err := streamPatternForDuration(conn, d, *realStressBulkChunkSize)
if err != nil {
return fmt.Errorf("bulk pump: %w", err)
}
throughput := float64(written) / dur.Seconds() / (1 << 20)
t.Logf("bulk %s/%s: %d bytes in %s (%.3f MiB/s)",
carrierName, transportName, written, dur, throughput)
if written == 0 {
return errStressNoBulkProgress
}
}
if d := *realStressDuration; d > 0 {
stats, err := sustainedEcho(conn, *realStressEchoSize, d)
if err != nil {
return fmt.Errorf("sustained echo: %w", err)
}
t.Logf("echo %s/%s: %d rt in %s, p50=%s p95=%s p99=%s max=%s lost=%d",
carrierName, transportName, stats.count, d,
stats.p50, stats.p95, stats.p99, stats.maxLatency, stats.lost)
if stats.count == 0 {
return fmt.Errorf("%w: %s", errStressNoRoundtrips, d)
}
}
goroutinesAfter := runtime.NumGoroutine()
// Allow some slack — pion/quic spawn helpers that take time to wind down
// after Close, but a real leak shows up as tens of extra goroutines.
const goroutineLeakSlack = 30
if goroutinesAfter > goroutinesBefore+goroutineLeakSlack {
t.Logf("WARNING: goroutines grew %d -> %d during %s/%s",
goroutinesBefore, goroutinesAfter, carrierName, transportName)
}
return nil
}
// streamPatternForDuration pumps a deterministic byte pattern through conn
// for at most `duration` using a synchronous request-response loop: write a
// chunk, wait until the same chunk echoes back and verify, then write the
// next one. Returns total bytes successfully echoed and elapsed time.
//
// Why request-response rather than concurrent write+read streams:
// transport throughputs differ by ~3 orders of magnitude (datachannel does
// MiB/s; videochannel/seichannel ~25 KB/s through 256-byte qr-encoded
// frames at 25 FPS). An asynchronous writer outruns a slow transport,
// fills muxconn / SOCKS / RTP-track buffers, and the deadlocked pipe
// eventually trips a TCP-write deadline — which is not a real bug, just
// the natural consequence of pumping into a slow pipe with no flow
// control. Request-response naturally rate-limits to the transport's
// actual round-trip throughput, which is what we want to measure.
func streamPatternForDuration(conn net.Conn, duration time.Duration, chunkSize int) (int64, time.Duration, error) {
if chunkSize <= 0 {
chunkSize = 4096
}
// Per-chunk roundtrip deadline. Slow transports (videochannel) can
// take seconds+ per chunk in practice; 15s gives ample margin
// without making genuine stalls hang forever.
const chunkTimeout = 15 * time.Second
start := time.Now()
deadline := start.Add(duration)
buf := make([]byte, chunkSize)
echoed := make([]byte, chunkSize)
want := make([]byte, chunkSize)
reader := bufio.NewReader(conn)
var total int64
for time.Now().Before(deadline) {
fillPattern(buf, total)
if err := conn.SetWriteDeadline(time.Now().Add(chunkTimeout)); err != nil {
return total, time.Since(start), fmt.Errorf("set write deadline at %d: %w", total, err)
}
if _, err := conn.Write(buf); err != nil {
return total, time.Since(start), fmt.Errorf("write at %d: %w", total, err)
}
if err := conn.SetReadDeadline(time.Now().Add(chunkTimeout)); err != nil {
return total, time.Since(start), fmt.Errorf("set read deadline at %d: %w", total, err)
}
if _, err := io.ReadFull(reader, echoed); err != nil {
return total, time.Since(start), fmt.Errorf("read at %d: %w", total, err)
}
fillPattern(want, total)
if !bytes.Equal(echoed, want) {
return total, time.Since(start), fmt.Errorf("%w %d", errPayloadMismatchOffset, total)
}
total += int64(chunkSize)
}
return total, time.Since(start), nil
}
type echoStats struct {
count int
lost int
p50, p95, p99 time.Duration
maxLatency time.Duration
}
// sustainedEcho writes payloads of size `payloadSize` and waits for them to
// echo back, recording per-roundtrip latency. Runs until duration elapses
// or the underlying connection fails. Each write/read uses a deadline so a
// stuck transport surfaces as a finite-time test failure rather than a hang.
//
//nolint:cyclop // per-rt deadlines + error wrapping naturally branch many ways
func sustainedEcho(conn net.Conn, payloadSize int, duration time.Duration) (echoStats, error) {
if payloadSize < 4 {
payloadSize = 4
}
deadline := time.Now().Add(duration)
payload := make([]byte, payloadSize)
for i := range payload {
payload[i] = byte('a' + (i % 26))
}
// Mark the payload terminator so we can ReadFull a fixed length back.
payload[payloadSize-1] = '\n'
reader := bufio.NewReader(conn)
var stats echoStats
latencies := make([]time.Duration, 0, 1024)
buf := make([]byte, payloadSize)
for time.Now().Before(deadline) {
if err := conn.SetWriteDeadline(time.Now().Add(5 * time.Second)); err != nil {
return stats, fmt.Errorf("set write deadline: %w", err)
}
start := time.Now()
if _, err := conn.Write(payload); err != nil {
stats.lost++
return stats, fmt.Errorf("write at rt #%d: %w", stats.count, err)
}
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
return stats, fmt.Errorf("set read deadline: %w", err)
}
if _, err := io.ReadFull(reader, buf); err != nil {
stats.lost++
return stats, fmt.Errorf("read at rt #%d: %w", stats.count, err)
}
lat := time.Since(start)
if !bytes.Equal(buf, payload) {
return stats, fmt.Errorf("%w at rt #%d", errStressPayloadMatch, stats.count)
}
latencies = append(latencies, lat)
if lat > stats.maxLatency {
stats.maxLatency = lat
}
stats.count++
}
if len(latencies) > 0 {
slices.Sort(latencies)
stats.p50 = latencies[len(latencies)*50/100]
stats.p95 = latencies[min(len(latencies)*95/100, len(latencies)-1)]
stats.p99 = latencies[min(len(latencies)*99/100, len(latencies)-1)]
}
return stats, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,148 @@
// Package builtin wires the built-in auth providers to their engines and
// registers a name-keyed factory that transports use to obtain an
// [engine.Session]. The factory replaces the former carrier layer: when
// the auth provider is "none" the caller supplies engine/URL/token
// directly; otherwise the named provider issues credentials and the
// matching engine is constructed.
package builtin
import (
"context"
"errors"
"fmt"
"github.com/openlibrecommunity/olcrtc/internal/auth"
authJitsi "github.com/openlibrecommunity/olcrtc/internal/auth/jitsi"
authTelemost "github.com/openlibrecommunity/olcrtc/internal/auth/telemost"
authWBStream "github.com/openlibrecommunity/olcrtc/internal/auth/wbstream"
"github.com/openlibrecommunity/olcrtc/internal/engine"
_ "github.com/openlibrecommunity/olcrtc/internal/engine/goolom" // register goolom engine via init
_ "github.com/openlibrecommunity/olcrtc/internal/engine/jitsi" // register jitsi engine via init
_ "github.com/openlibrecommunity/olcrtc/internal/engine/livekit" // register livekit engine via init
)
// ErrCarrierNotFound is returned when an unregistered carrier name is requested.
var ErrCarrierNotFound = errors.New("carrier not found")
// ErrAuthFailed wraps an auth provider rejection. It pairs with the inner
// provider error returned from [Open].
var ErrAuthFailed = errors.New("carrier auth failed")
// Config holds the inputs to [Open]. The fields mirror the subset of
// transport.Config that engines consume.
type Config struct {
RoomURL string
Name string
OnData func([]byte)
OnPeerData func(peerID string, data []byte)
DNSServer string
ProxyAddr string
ProxyPort int
// Engine, URL, Token are honoured only for the "none" carrier (direct
// engine access); other carriers derive them from their auth provider.
Engine string
URL string
Token string
}
// Factory creates an engine session for a given carrier.
type Factory func(ctx context.Context, cfg Config) (engine.Session, error)
var registry = map[string]Factory{} //nolint:gochecknoglobals // package-level registry
// Register adds a carrier factory.
func Register(name string, f Factory) {
registry[name] = f
}
// Open looks up the carrier factory and creates an engine session.
func Open(ctx context.Context, name string, cfg Config) (engine.Session, error) {
f, ok := registry[name]
if !ok {
return nil, fmt.Errorf("%w: %q", ErrCarrierNotFound, name)
}
return f(ctx, cfg)
}
// Available reports all registered carrier names.
func Available() []string {
names := make([]string, 0, len(registry))
for name := range registry {
names = append(names, name)
}
return names
}
// RegisterDefaults wires the built-in carriers: jitsi, telemost, wbstream
// and "none" (direct engine access).
func RegisterDefaults() {
registerEngineAuth("wbstream", authWBStream.Provider{})
registerEngineAuth("telemost", authTelemost.Provider{})
registerEngineAuth("jitsi", authJitsi.Provider{})
registerDirect("none")
}
// registerDirect registers a carrier that skips auth: the caller supplies
// engine/URL/token directly via [Config].
func registerDirect(name string) {
Register(name, func(ctx context.Context, cfg Config) (engine.Session, error) {
engineName := cfg.Engine
if engineName == "" {
engineName = "livekit"
}
sess, err := engine.New(ctx, engineName, engine.Config{
URL: cfg.URL,
Token: cfg.Token,
Name: cfg.Name,
OnData: cfg.OnData,
OnPeerData: cfg.OnPeerData,
DNSServer: cfg.DNSServer,
ProxyAddr: cfg.ProxyAddr,
ProxyPort: cfg.ProxyPort,
})
if err != nil {
return nil, fmt.Errorf("engine new: %w", err)
}
return sess, nil
})
}
// registerEngineAuth registers a carrier that resolves credentials through an
// auth provider and connects via the engine the auth provider reports.
func registerEngineAuth(name string, provider auth.Provider) {
Register(name, func(ctx context.Context, cfg Config) (engine.Session, error) {
authCfg := auth.Config{
RoomURL: cfg.RoomURL,
Name: cfg.Name,
DNSServer: cfg.DNSServer,
ProxyAddr: cfg.ProxyAddr,
ProxyPort: cfg.ProxyPort,
}
creds, err := provider.Issue(ctx, authCfg)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrAuthFailed, err)
}
sess, err := engine.New(ctx, provider.Engine(), engine.Config{
URL: creds.URL,
Token: creds.Token,
Name: cfg.Name,
Extra: creds.Extra,
OnData: cfg.OnData,
OnPeerData: cfg.OnPeerData,
DNSServer: cfg.DNSServer,
ProxyAddr: cfg.ProxyAddr,
ProxyPort: cfg.ProxyPort,
Refresh: func(ctx context.Context) (engine.Credentials, error) {
fresh, err := provider.Issue(ctx, authCfg)
if err != nil {
return engine.Credentials{}, fmt.Errorf("auth refresh: %w", err)
}
return engine.Credentials{URL: fresh.URL, Token: fresh.Token, Extra: fresh.Extra}, nil
},
})
if err != nil {
return nil, fmt.Errorf("engine new: %w", err)
}
return sess, nil
})
}

126
internal/engine/engine.go Normal file
View File

@@ -0,0 +1,126 @@
// Package engine defines the wire-level transport engine that connects to a
// remote SFU. An engine is independent of how the room credentials were
// obtained: it accepts a signaling URL and an access token, and exposes the
// byte/video primitives the rest of olcrtc consumes.
//
// Engines model the SFU protocol family (e.g. LiveKit, Goolom). Service-
// specific bits (e.g. WB / Telemost API flows) live in the auth
// package, not here.
package engine
import (
"context"
"errors"
"github.com/pion/webrtc/v4"
)
var (
// ErrEngineNotFound is returned when a requested engine is not registered.
ErrEngineNotFound = errors.New("engine not found")
// ErrByteStreamUnsupported is returned when an engine cannot expose a byte stream.
ErrByteStreamUnsupported = errors.New("engine does not support byte stream")
// ErrVideoTrackUnsupported is returned when an engine cannot exchange video tracks.
ErrVideoTrackUnsupported = errors.New("engine does not support video tracks")
)
// Capabilities describes the transport primitives an engine can expose.
type Capabilities struct {
ByteStream bool
VideoTrack bool
}
// Credentials are produced by an auth provider — duplicated here to avoid an
// import cycle between engine and auth.
type Credentials struct {
URL string
Token string
Extra map[string]string
}
// Config is the runtime input to an engine factory. URL/Token are produced by
// an auth provider (or supplied directly by the caller for "none" auth).
// Extra carries engine-specific fields that don't fit the common shape
// (e.g. providers that need metadata beyond URL/token can pass it here).
//
// Refresh, when set, is called by an engine whose protocol requires fresh
// credentials on each reconnect (e.g. Goolom: every reconnect needs a new
// peerID/credentials tuple from the room-info HTTP endpoint). Engines that
// don't need this should ignore it.
type Config struct {
URL string
Token string
Name string
Extra map[string]string
OnData func([]byte)
OnPeerData func(peerID string, data []byte)
DNSServer string
ProxyAddr string
ProxyPort int
Refresh func(ctx context.Context) (Credentials, error)
}
// Session is the engine-level runtime handle. It is shaped to match what
// the upper transport layer expects: send/receive bytes, optional video
// tracks, and lifecycle callbacks.
//
//nolint:interfacebloat // mirrors the historical provider.Provider surface that the rest of olcrtc consumes
type Session interface {
Connect(ctx context.Context) error
Send(data []byte) error
Close() error
SetReconnectCallback(cb func(*webrtc.DataChannel))
SetShouldReconnect(fn func() bool)
SetEndedCallback(cb func(string))
WatchConnection(ctx context.Context)
CanSend() bool
GetSendQueue() chan []byte
GetBufferedAmount() uint64
Capabilities() Capabilities
// Reconnect asks the engine to tear down and re-establish the underlying
// SFU connection. Used by upper layers when a liveness probe declares the
// carrier dead before the engine has noticed (e.g. silent packet loss on
// a video track). Implementations should be best-effort and idempotent;
// reason is logged for diagnostics.
Reconnect(reason string)
}
// PeerSession is implemented by engines that can address byte payloads to a
// specific remote endpoint and report the sender endpoint on receive.
type PeerSession interface {
SendTo(peerID string, data []byte) error
}
// VideoTrackCapable is implemented by engines that can exchange video tracks.
type VideoTrackCapable interface {
AddVideoTrack(track webrtc.TrackLocal) error
SetVideoTrackHandler(cb func(*webrtc.TrackRemote, *webrtc.RTPReceiver))
}
// Factory creates a new engine session.
type Factory func(ctx context.Context, cfg Config) (Session, error)
var registry = make(map[string]Factory) //nolint:gochecknoglobals // package-level state intentional
// Register adds an engine factory to the registry.
func Register(name string, factory Factory) {
registry[name] = factory
}
// New creates an engine session by name.
func New(ctx context.Context, name string, cfg Config) (Session, error) {
factory, ok := registry[name]
if !ok {
return nil, ErrEngineNotFound
}
return factory(ctx, cfg)
}
// Available returns the list of registered engine names.
func Available() []string {
names := make([]string, 0, len(registry))
for name := range registry {
names = append(names, name)
}
return names
}

View File

@@ -0,0 +1,438 @@
package goolom
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/openlibrecommunity/olcrtc/internal/engine"
"github.com/openlibrecommunity/olcrtc/internal/logger"
"github.com/openlibrecommunity/olcrtc/internal/protect"
"github.com/pion/webrtc/v4"
)
// Connect starts the WebRTC connection process.
func (s *Session) Connect(ctx context.Context) error {
s.closed.Store(false)
s.resetMediaState()
config := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:stun.rtc.yandex.net:3478"}}},
SDPSemantics: webrtc.SDPSemanticsUnifiedPlan,
}
if err := s.setupPeerConnections(config); err != nil {
return err
}
keepAliveCh, sessionCloseCh := s.resetSession()
var dcReady chan struct{}
if s.onData != nil {
var err error
s.dc, err = s.pcPub.CreateDataChannel("olcrtc", nil)
if err != nil {
return fmt.Errorf("create dc: %w", err)
}
dcReady = make(chan struct{})
s.setupDataChannelHandlers(dcReady, sessionCloseCh)
}
if err := s.dialWebSocket(); err != nil {
return err
}
s.setupICEHandlers()
s.startBackgroundGoroutines(ctx, keepAliveCh)
if s.onData != nil {
select {
case <-dcReady:
return nil
case <-time.After(15 * time.Second):
return ErrDataChannelTimeout
case <-ctx.Done():
return fmt.Errorf("connect context cancelled: %w", ctx.Err())
}
}
return s.waitForMediaReady(ctx, 20*time.Second)
}
func (s *Session) waitForMediaReady(ctx context.Context, timeout time.Duration) error {
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-s.subscriberConn:
case <-timer.C:
return ErrSubscriberMediaTimeout
case <-ctx.Done():
return fmt.Errorf("connect context cancelled: %w", ctx.Err())
}
return nil
}
func (s *Session) setupPeerConnections(config webrtc.Configuration) error {
settingEngine := webrtc.SettingEngine{}
if protect.Protector != nil {
settingEngine.SetICEProxyDialer(protect.NewProxyDialer())
}
settingEngine.LoggerFactory = logger.NewPionLoggerFactory()
api := webrtc.NewAPI(webrtc.WithSettingEngine(settingEngine))
var err error
s.pcSub, err = api.NewPeerConnection(config)
if err != nil {
return fmt.Errorf("new sub pc: %w", err)
}
s.pcSub.OnConnectionStateChange(s.onSubscriberConnectionStateChange)
s.pcSub.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
if track.Kind() != webrtc.RTPCodecTypeVideo {
return
}
logger.Infof("goolom remote video track: codec=%s stream=%s track=%s",
track.Codec().MimeType, track.StreamID(), track.ID())
if cb := s.videoTrackHandler(); cb != nil {
cb(track, receiver)
}
})
s.pcPub, err = api.NewPeerConnection(config)
if err != nil {
return fmt.Errorf("new pub pc: %w", err)
}
s.pcPub.OnConnectionStateChange(s.onPublisherConnectionStateChange)
if err := s.attachPendingVideoTracks(); err != nil {
return err
}
return nil
}
func (s *Session) dialWebSocket() error {
wsDialer := protect.NewWebSocketDialer(wsHandshakeTimeout)
ws, resp, err := wsDialer.Dial(s.mediaServerURL, nil)
if err != nil {
return fmt.Errorf("dial ws: %w", err)
}
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
s.ws = ws
ws.SetPongHandler(func(string) error {
_ = ws.SetReadDeadline(time.Now().Add(wsReadTimeout))
return nil
})
_ = ws.SetReadDeadline(time.Now().Add(wsReadTimeout))
return nil
}
func (s *Session) startBackgroundGoroutines(ctx context.Context, keepAliveCh chan struct{}) {
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.keepAlive(keepAliveCh)
}()
_ = s.sendHello()
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.handleSignaling(ctx)
}()
}
func (s *Session) onConnectionStateChange(state webrtc.PeerConnectionState) {
if !s.closed.Load() && state == webrtc.PeerConnectionStateFailed {
s.queueReconnect()
}
}
func (s *Session) onSubscriberConnectionStateChange(state webrtc.PeerConnectionState) {
logger.Debugf("goolom subscriber state: %s", state.String())
switch state {
case webrtc.PeerConnectionStateConnected:
s.subscriberReady.Store(true)
closeSignal(s.subscriberConn)
case webrtc.PeerConnectionStateDisconnected,
webrtc.PeerConnectionStateFailed,
webrtc.PeerConnectionStateClosed:
s.subscriberReady.Store(false)
case webrtc.PeerConnectionStateUnknown,
webrtc.PeerConnectionStateNew,
webrtc.PeerConnectionStateConnecting:
}
s.onConnectionStateChange(state)
}
func (s *Session) onPublisherConnectionStateChange(state webrtc.PeerConnectionState) {
logger.Debugf("goolom publisher state: %s", state.String())
switch state {
case webrtc.PeerConnectionStateConnected:
s.publisherReady.Store(true)
closeSignal(s.publisherConn)
case webrtc.PeerConnectionStateDisconnected,
webrtc.PeerConnectionStateFailed,
webrtc.PeerConnectionStateClosed:
s.publisherReady.Store(false)
case webrtc.PeerConnectionStateUnknown,
webrtc.PeerConnectionStateNew,
webrtc.PeerConnectionStateConnecting:
}
s.onConnectionStateChange(state)
}
// Close terminates the session and releases resources.
func (s *Session) Close() error {
alreadyClosing := s.closed.Swap(true)
s.sendQueueClosed.Store(true)
if !alreadyClosing {
leaveUID := uuid.New().String()
leaveAck := s.registerAckWaiter(leaveUID)
// 2s matches our jitsi tear-down budget. The reason is the same:
// without giving the server time to register the leave, a
// back-to-back reconnection from the same client collides with a
// still-alive ghost participant on the SFU side and inherits
// stale media-flow state.
if s.sendLeave(leaveUID) {
_ = s.waitForAck(leaveUID, leaveAck, 2*time.Second)
} else {
s.removeAckWaiter(leaveUID)
}
}
closeSignal(s.closeCh)
s.stopSession()
if s.dc != nil {
_ = s.dc.Close()
}
if s.pcPub != nil {
_ = s.pcPub.Close()
}
if s.pcSub != nil {
_ = s.pcSub.Close()
}
if s.ws != nil {
s.wsMu.Lock()
_ = s.ws.WriteControl(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""),
time.Now().Add(time.Second))
_ = s.ws.Close()
s.wsMu.Unlock()
}
done := make(chan struct{})
go func() {
s.wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
}
return nil
}
// WatchConnection monitors the connection lifecycle and reconnects as needed.
func (s *Session) WatchConnection(ctx context.Context) {
const maxReconnects = 10
const reconnectWindow = 5 * time.Minute
for {
select {
case <-ctx.Done():
return
case <-s.closeCh:
return
case <-s.reconnectCh:
if s.handleReconnectAttempt(ctx, maxReconnects, reconnectWindow) {
return
}
}
}
}
func (s *Session) handleReconnectAttempt(ctx context.Context, maxReconnects int, reconnectWindow time.Duration) bool {
if time.Since(s.lastReconnect) > reconnectWindow {
s.reconnectCount = 0
}
s.reconnectCount++
s.lastReconnect = time.Now()
if s.reconnectCount > maxReconnects {
s.signalEnded("reconnect limit reached")
return true
}
backoff := time.Duration(s.reconnectCount) * 2 * time.Second
if backoff > 30*time.Second {
backoff = 30 * time.Second
}
return s.retryReconnect(ctx, backoff)
}
func (s *Session) retryReconnect(ctx context.Context, backoff time.Duration) bool {
for {
if err := s.reconnect(ctx); err != nil {
logger.Debugf("reconnect failed: %v", err)
select {
case <-ctx.Done():
return true
case <-s.closeCh:
return true
case <-time.After(backoff):
continue
}
}
break
}
return false
}
func (s *Session) reconnect(ctx context.Context) error {
s.reconnecting.Store(true)
defer s.reconnecting.Store(false)
s.sendLeave(uuid.New().String())
time.Sleep(500 * time.Millisecond)
s.stopSession()
if s.dc != nil {
_ = s.dc.Close()
}
if s.pcPub != nil {
_ = s.pcPub.Close()
}
if s.pcSub != nil {
_ = s.pcSub.Close()
}
if s.ws != nil {
s.wsMu.Lock()
_ = s.ws.WriteControl(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""),
time.Now().Add(time.Second))
_ = s.ws.Close()
s.wsMu.Unlock()
}
if s.onReconnect != nil {
s.onReconnect(nil)
}
time.Sleep(3 * time.Second)
if s.refresh == nil {
return ErrNoRefresh
}
creds, err := s.refresh(ctx)
if err != nil {
return fmt.Errorf("reconnect refresh: %w", err)
}
s.applyRefreshedCredentials(creds)
if err := s.Connect(ctx); err != nil {
return err
}
if s.onReconnect != nil {
s.onReconnect(s.dc)
}
s.drainReconnectQueue()
return nil
}
func (s *Session) applyRefreshedCredentials(creds engine.Credentials) {
if creds.URL != "" {
s.mediaServerURL = creds.URL
}
if creds.Token != "" {
s.peerID = creds.Token
}
if creds.Extra == nil {
return
}
if v := creds.Extra[credentialKeyRoomID]; v != "" {
s.roomID = v
}
if v := creds.Extra[credentialKeyCredentials]; v != "" {
s.credentials = v
}
if v := creds.Extra[credentialKeyRoomURL]; v != "" {
s.roomURL = v
}
if v := creds.Extra[credentialKeyTelemetryReferer]; v != "" {
s.telemetryReferer = v
}
}
func (s *Session) drainReconnectQueue() {
for {
select {
case <-s.reconnectCh:
default:
return
}
}
}
func (s *Session) queueReconnect() {
if s.closed.Load() || s.reconnecting.Load() {
return
}
if s.shouldReconnect != nil && !s.shouldReconnect() {
return
}
select {
case s.reconnectCh <- struct{}{}:
default:
}
}
// Reconnect asks the goolom session to tear down its peer connections and
// rejoin the room. Triggered by upper layers when they detect liveness loss
// before the underlying PC has reported failure (silent black-hole on the
// data path).
func (s *Session) Reconnect(reason string) {
if s.closed.Load() {
return
}
logger.Infof("goolom reconnect requested: %s", reason)
s.stopSession()
s.queueReconnect()
}
func (s *Session) stopSession() {
s.stopTelemetry()
s.sessionMu.Lock()
closeSignal(s.keepAliveCh)
closeSignal(s.sessionCloseCh)
s.sessionMu.Unlock()
}
func (s *Session) resetSession() (chan struct{}, chan struct{}) {
s.sessionMu.Lock()
defer s.sessionMu.Unlock()
s.keepAliveCh = make(chan struct{})
s.sessionCloseCh = make(chan struct{})
return s.keepAliveCh, s.sessionCloseCh
}
func (s *Session) resetMediaState() {
s.subscriberReady.Store(false)
s.publisherReady.Store(false)
s.subscriberConn = make(chan struct{})
s.publisherConn = make(chan struct{})
}
func (s *Session) signalEnded(reason string) {
s.closed.Store(true)
s.stopTelemetry()
if s.onEnded != nil {
s.onEnded(reason)
}
}

View File

@@ -0,0 +1,318 @@
package goolom
import (
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/openlibrecommunity/olcrtc/internal/logger"
"github.com/pion/webrtc/v4"
)
func (s *Session) setupDataChannelHandlers(dcReady chan struct{}, sessionCloseCh chan struct{}) {
s.dc.OnOpen(func() {
numWorkers := 4
for i := range numWorkers {
s.wg.Add(1)
go func(workerID int) {
defer s.wg.Done()
s.processSendQueue(workerID, sessionCloseCh)
}(i)
}
close(dcReady)
})
s.dc.OnClose(s.onDataChannelClose)
s.dc.OnMessage(s.onDataChannelMessage)
s.pcSub.OnDataChannel(func(dc *webrtc.DataChannel) {
if s.onData != nil {
dc.OnMessage(s.onDataChannelMessage)
}
})
}
func (s *Session) onDataChannelClose() {
if !s.closed.Load() {
s.queueReconnect()
}
}
func (s *Session) onDataChannelMessage(msg webrtc.DataChannelMessage) {
if s.onData != nil && len(msg.Data) > 0 {
s.onData(msg.Data)
}
}
func (s *Session) handleSdpOffer(offer map[string]any, uid string, sendPub bool) error {
sdp, _ := offer["sdp"].(string)
pcSeq, _ := offer["pcSeq"].(float64)
if err := s.pcSub.SetRemoteDescription(webrtc.SessionDescription{
Type: webrtc.SDPTypeOffer,
SDP: sdp,
}); err != nil {
return fmt.Errorf("set remote desc: %w", err)
}
answer, err := s.pcSub.CreateAnswer(nil)
if err != nil {
return fmt.Errorf("create answer: %w", err)
}
if err := s.pcSub.SetLocalDescription(answer); err != nil {
return fmt.Errorf("set local desc: %w", err)
}
s.wsMu.Lock()
_ = s.ws.WriteJSON(map[string]any{
keyUID: uuid.New().String(),
"subscriberSdpAnswer": map[string]any{
keyPcSeq: int(pcSeq),
"sdp": answer.SDP,
},
})
s.wsMu.Unlock()
s.sendAck(uid)
if s.onData == nil {
if err := s.sendSetSlots(); err != nil {
logger.Debugf("setSlots error: %v", err)
}
}
if !sendPub {
return nil
}
time.Sleep(300 * time.Millisecond)
pubOffer, err := s.pcPub.CreateOffer(nil)
if err != nil {
return fmt.Errorf("create pub offer: %w", err)
}
if err := s.pcPub.SetLocalDescription(pubOffer); err != nil {
return fmt.Errorf("set local pub desc: %w", err)
}
s.wsMu.Lock()
_ = s.ws.WriteJSON(map[string]any{
keyUID: uuid.New().String(),
"publisherSdpOffer": map[string]any{
keyPcSeq: 1,
"sdp": pubOffer.SDP,
"tracks": s.publisherTrackDescriptions(),
},
})
s.wsMu.Unlock()
return nil
}
func (s *Session) handleSdpAnswer(answer map[string]any, uid string) {
sdp, _ := answer["sdp"].(string)
if err := s.pcPub.SetRemoteDescription(webrtc.SessionDescription{
Type: webrtc.SDPTypeAnswer,
SDP: sdp,
}); err != nil {
logger.Debugf("SetRemoteDescription error: %v", err)
}
s.sendAck(uid)
}
func (s *Session) handleICE(cand map[string]any) {
candStr, _ := cand["candidate"].(string)
target, _ := cand["target"].(string)
sdpMid, _ := cand["sdpMid"].(string)
sdpMLineIndex, _ := cand["sdpMlineIndex"].(float64)
parts := strings.Fields(candStr)
if len(parts) < 8 {
return
}
init := webrtc.ICECandidateInit{
Candidate: candStr,
SDPMid: &sdpMid,
SDPMLineIndex: func() *uint16 { v := uint16(sdpMLineIndex); return &v }(),
}
switch target {
case "SUBSCRIBER":
_ = s.pcSub.AddICECandidate(init)
case "PUBLISHER":
_ = s.pcPub.AddICECandidate(init)
}
}
func (s *Session) setupICEHandlers() {
s.pcSub.OnICECandidate(func(c *webrtc.ICECandidate) {
if c == nil {
return
}
init := c.ToJSON()
s.wsMu.Lock()
_ = s.ws.WriteJSON(map[string]any{
keyUID: uuid.New().String(),
"webrtcIceCandidate": map[string]any{
"candidate": init.Candidate,
"sdpMid": init.SDPMid,
"sdpMlineIndex": init.SDPMLineIndex,
"target": "SUBSCRIBER",
keyPcSeq: 1,
},
})
s.wsMu.Unlock()
})
s.pcPub.OnICECandidate(func(c *webrtc.ICECandidate) {
if c == nil {
return
}
init := c.ToJSON()
s.wsMu.Lock()
_ = s.ws.WriteJSON(map[string]any{
keyUID: uuid.New().String(),
"webrtcIceCandidate": map[string]any{
"candidate": init.Candidate,
"sdpMid": init.SDPMid,
"sdpMlineIndex": init.SDPMLineIndex,
"target": "PUBLISHER",
keyPcSeq: 1,
},
})
s.wsMu.Unlock()
})
}
func (s *Session) sendSetSlots() error {
s.wsMu.Lock()
defer s.wsMu.Unlock()
// Goolom only forwards as many remote videos as the subscriber asks for via
// setSlots. Request a generous count so each subscriber sees every active
// publisher in the room.
slots := make([]map[string]int, 0, 8)
for range 8 {
slots = append(slots, map[string]int{"width": 1280, "height": 720})
}
if err := s.ws.WriteJSON(map[string]any{
keyUID: uuid.New().String(),
"setSlots": map[string]any{
"slots": slots,
"audioSlotsCount": 0,
"key": 1,
"shutdownAllVideo": nil,
"withSelfView": false,
"selfViewVisibility": "ON_LOADING_THEN_SHOW",
"gridConfig": map[string]any{},
},
}); err != nil {
return fmt.Errorf("write set slots: %w", err)
}
return nil
}
func (s *Session) publisherTrackDescriptions() []map[string]any {
if s.pcPub == nil {
return nil
}
tracks := make([]map[string]any, 0)
for _, transceiver := range s.pcPub.GetTransceivers() {
sender := transceiver.Sender()
if sender == nil {
continue
}
track := sender.Track()
if track == nil {
continue
}
kind := "VIDEO"
if track.Kind() == webrtc.RTPCodecTypeAudio {
kind = "AUDIO"
}
tracks = append(tracks, map[string]any{
"mid": transceiver.Mid(),
"transceiverMid": transceiver.Mid(),
"kind": kind,
"priority": 0,
"label": track.ID(),
"codecs": map[string]any{},
"groupId": 1,
keyDescription: "",
})
}
return tracks
}
func isNonTURNURL(url string) bool {
return url != "" && !strings.HasPrefix(url, "turn:") && !strings.HasPrefix(url, "turns:")
}
func parseICEURLs(server map[string]any) []string {
var urls []string
switch rawURLs := server["urls"].(type) {
case []any:
for _, rawURL := range rawURLs {
if url, ok := rawURL.(string); ok && isNonTURNURL(url) {
urls = append(urls, url)
}
}
case []string:
for _, url := range rawURLs {
if isNonTURNURL(url) {
urls = append(urls, url)
}
}
}
return urls
}
func parseICEServer(rawServer any) (webrtc.ICEServer, bool) {
server, ok := rawServer.(map[string]any)
if !ok {
return webrtc.ICEServer{}, false
}
urls := parseICEURLs(server)
if len(urls) == 0 {
return webrtc.ICEServer{}, false
}
ice := webrtc.ICEServer{URLs: urls}
if username, ok := server["username"].(string); ok {
ice.Username = username
}
if credential, ok := server["credential"].(string); ok {
ice.Credential = credential
}
return ice, true
}
func (s *Session) applyServerHelloConfig(serverHello map[string]any) {
rawCfg, ok := serverHello["rtcConfiguration"].(map[string]any)
if !ok {
return
}
rawServers, ok := rawCfg["iceServers"].([]any)
if !ok || len(rawServers) == 0 {
return
}
iceServers := make([]webrtc.ICEServer, 0, len(rawServers))
for _, rawServer := range rawServers {
if ice, ok := parseICEServer(rawServer); ok {
iceServers = append(iceServers, ice)
}
}
if len(iceServers) == 0 {
return
}
cfg := webrtc.Configuration{
ICEServers: iceServers,
SDPSemantics: webrtc.SDPSemanticsUnifiedPlan,
}
if s.pcSub != nil {
_ = s.pcSub.SetConfiguration(cfg)
}
if s.pcPub != nil {
_ = s.pcPub.SetConfiguration(cfg)
}
}

View File

@@ -0,0 +1,322 @@
// Package goolom implements an engine.Session backed by the Goolom SFU
// signaling protocol. Goolom is the proprietary SFU developed for Yandex
// Telemost; the on-wire protocol — capabilities offer, separated subscriber
// and publisher PeerConnections, ack/pong keepalive, slots-based subscribe
// model — is what this engine speaks.
//
// HTTP auth (room-info lookup, telemetry referer, etc.) lives in the auth
// package; this engine consumes a media-server WebSocket URL plus the
// peer/room/credentials tuple supplied as engine.Config.
package goolom
import (
"context"
"errors"
"fmt"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
"github.com/openlibrecommunity/olcrtc/internal/engine"
"github.com/pion/webrtc/v4"
)
const (
realDataChannelMessageLimit = 12288
defaultSendDelayLow = 2 * time.Millisecond
defaultSendDelayMax = 12 * time.Millisecond
defaultTelemetryInterval = 20 * time.Second
defaultSendQueueSize = 5000
defaultBufferHighWaterMark = 512 * 1024
defaultSendQueueCapHard = 4000
wsReadTimeout = 60 * time.Second
wsHandshakeTimeout = 15 * time.Second
keyUID = "uid"
keyDescription = "description"
keyPcSeq = "pcSeq"
keyName = "name"
stateTerminated = "terminated"
credentialKeyRoomID = "roomID"
credentialKeyCredentials = "credentials"
credentialKeyRoomURL = "roomURL"
credentialKeyTelemetryReferer = "telemetryReferer"
)
var (
// ErrDataChannelTimeout is returned when the DataChannel fails to open in time.
ErrDataChannelTimeout = errors.New("datachannel timeout")
// ErrDataChannelNotReady is returned when send is called before the DataChannel is open.
ErrDataChannelNotReady = errors.New("datachannel not ready")
// ErrSendQueueClosed is returned when send is called after Close.
ErrSendQueueClosed = errors.New("send queue closed")
// ErrSendQueueTimeout is returned when the send queue cannot accept new data in time.
ErrSendQueueTimeout = errors.New("send queue timeout")
// ErrSessionClosed is returned when the session is closed mid-operation.
ErrSessionClosed = errors.New("session closed")
// ErrPeerClosed is returned when the peer is closed mid-operation.
ErrPeerClosed = errors.New("peer closed")
// ErrSubscriberMediaTimeout is returned when the subscriber media is not ready in time.
ErrSubscriberMediaTimeout = errors.New("subscriber media timeout")
// ErrPublisherNotInitialized is returned when the publisher PC is not set up.
ErrPublisherNotInitialized = errors.New("publisher peer connection not initialized")
// ErrURLRequired is returned when no media-server WebSocket URL was supplied.
ErrURLRequired = errors.New("goolom media server URL required")
// ErrRoomIDRequired is returned when no room ID was supplied.
ErrRoomIDRequired = errors.New("goolom room ID required")
// ErrPeerIDRequired is returned when no peer ID was supplied.
ErrPeerIDRequired = errors.New("goolom peer ID required")
// ErrNoRefresh is returned when reconnect is attempted without a refresh callback.
ErrNoRefresh = errors.New("goolom reconnect: no refresh callback supplied")
)
// TrafficShape controls outgoing data-channel pacing.
type TrafficShape struct {
MaxMessageSize int
MinDelay time.Duration
MaxDelay time.Duration
}
// Session is the Goolom engine handle.
type Session struct {
name string
mediaServerURL string
peerID string
roomID string
credentials string
roomURL string // referer for telemetry — opaque to the engine
telemetryReferer string
refresh func(ctx context.Context) (engine.Credentials, error)
ws *websocket.Conn
wsMu sync.Mutex
pcSub *webrtc.PeerConnection
pcPub *webrtc.PeerConnection
dc *webrtc.DataChannel
onData func([]byte)
onReconnect func(*webrtc.DataChannel)
shouldReconnect func() bool
onEnded func(string)
reconnectCh chan struct{}
closeCh chan struct{}
keepAliveCh chan struct{}
telemetryCh chan struct{}
sessionCloseCh chan struct{}
lastReconnect time.Time
reconnectCount int
sessionMu sync.Mutex
sendQueue chan []byte
sendQueueClosed atomic.Bool
closed atomic.Bool
reconnecting atomic.Bool
telemetryActive atomic.Bool
ackMu sync.Mutex
ackWaiters map[string]chan struct{}
trafficShape TrafficShape
videoTrackMu sync.RWMutex
videoTracks []webrtc.TrackLocal
onVideoTrack func(*webrtc.TrackRemote, *webrtc.RTPReceiver)
subscriberReady atomic.Bool
publisherReady atomic.Bool
subscriberConn chan struct{}
publisherConn chan struct{}
wg sync.WaitGroup
httpClient *http.Client
}
// New creates a new Goolom engine session.
//
// cfg.URL is the media server WebSocket URL. cfg.Token carries the peer ID.
// cfg.Extra carries the rest of the room tuple: roomID, credentials, and an
// optional roomURL / telemetryReferer string the engine uses verbatim as the
// Referer header for telemetry posts.
func New(_ context.Context, cfg engine.Config) (engine.Session, error) {
if cfg.URL == "" {
return nil, ErrURLRequired
}
peerID := cfg.Token
if peerID == "" {
return nil, ErrPeerIDRequired
}
roomID := ""
credentials := ""
roomURL := ""
telemetryReferer := ""
if cfg.Extra != nil {
roomID = cfg.Extra[credentialKeyRoomID]
credentials = cfg.Extra[credentialKeyCredentials]
roomURL = cfg.Extra[credentialKeyRoomURL]
telemetryReferer = cfg.Extra[credentialKeyTelemetryReferer]
}
if roomID == "" {
return nil, ErrRoomIDRequired
}
if telemetryReferer == "" {
telemetryReferer = roomURL
}
return &Session{
name: cfg.Name,
mediaServerURL: cfg.URL,
peerID: peerID,
roomID: roomID,
credentials: credentials,
roomURL: roomURL,
telemetryReferer: telemetryReferer,
refresh: cfg.Refresh,
onData: cfg.OnData,
reconnectCh: make(chan struct{}, 1),
closeCh: make(chan struct{}),
keepAliveCh: make(chan struct{}),
sessionCloseCh: make(chan struct{}),
telemetryCh: make(chan struct{}, 1),
sendQueue: make(chan []byte, defaultSendQueueSize),
ackWaiters: make(map[string]chan struct{}),
subscriberConn: make(chan struct{}),
publisherConn: make(chan struct{}),
trafficShape: TrafficShape{
MaxMessageSize: realDataChannelMessageLimit,
MinDelay: defaultSendDelayLow,
MaxDelay: defaultSendDelayMax,
},
httpClient: nil,
}, nil
}
// Capabilities reports what this engine can do.
func (s *Session) Capabilities() engine.Capabilities {
return engine.Capabilities{ByteStream: true, VideoTrack: true}
}
// SetTrafficShape adjusts the outgoing data-channel pacing.
func (s *Session) SetTrafficShape(shape TrafficShape) {
if shape.MaxMessageSize <= 0 {
shape.MaxMessageSize = realDataChannelMessageLimit
}
if shape.MaxDelay < shape.MinDelay {
shape.MaxDelay = shape.MinDelay
}
s.trafficShape = shape
}
// Send queues data for transmission.
func (s *Session) Send(data []byte) error {
if s.dc == nil || s.dc.ReadyState() != webrtc.DataChannelStateOpen {
return ErrDataChannelNotReady
}
if s.sendQueueClosed.Load() {
return ErrSendQueueClosed
}
select {
case s.sendQueue <- data:
return nil
case <-time.After(50 * time.Millisecond):
return ErrSendQueueTimeout
}
}
// GetSendQueue returns the transmission queue.
func (s *Session) GetSendQueue() chan []byte { return s.sendQueue }
// GetBufferedAmount returns the WebRTC buffered amount.
func (s *Session) GetBufferedAmount() uint64 {
if s.dc != nil {
return s.dc.BufferedAmount()
}
return 0
}
// SetEndedCallback sets the callback for connection termination.
func (s *Session) SetEndedCallback(cb func(string)) { s.onEnded = cb }
// SetReconnectCallback sets the callback for reconnection events.
func (s *Session) SetReconnectCallback(cb func(*webrtc.DataChannel)) { s.onReconnect = cb }
// SetShouldReconnect sets the policy for reconnection.
func (s *Session) SetShouldReconnect(fn func() bool) { s.shouldReconnect = fn }
// CanSend checks if data can be sent.
func (s *Session) CanSend() bool {
if s.onData == nil {
if s.hasLocalVideoTracks() {
return !s.closed.Load() && s.subscriberReady.Load() && s.publisherReady.Load()
}
return !s.closed.Load() && s.subscriberReady.Load()
}
if s.dc == nil || s.dc.ReadyState() != webrtc.DataChannelStateOpen {
return false
}
return len(s.sendQueue) < defaultSendQueueCapHard
}
// AddVideoTrack adds a video track to the publisher peer connection.
func (s *Session) AddVideoTrack(track webrtc.TrackLocal) error {
s.videoTrackMu.Lock()
s.videoTracks = append(s.videoTracks, track)
s.videoTrackMu.Unlock()
if s.pcPub == nil {
return nil
}
if _, err := s.pcPub.AddTrack(track); err != nil {
return fmt.Errorf("failed to add track: %w", err)
}
return nil
}
// SetVideoTrackHandler registers a callback for remote video tracks.
func (s *Session) SetVideoTrackHandler(cb func(*webrtc.TrackRemote, *webrtc.RTPReceiver)) {
s.videoTrackMu.Lock()
defer s.videoTrackMu.Unlock()
s.onVideoTrack = cb
}
func (s *Session) hasLocalVideoTracks() bool {
s.videoTrackMu.RLock()
defer s.videoTrackMu.RUnlock()
return len(s.videoTracks) > 0
}
func (s *Session) videoTrackHandler() func(*webrtc.TrackRemote, *webrtc.RTPReceiver) {
s.videoTrackMu.RLock()
defer s.videoTrackMu.RUnlock()
return s.onVideoTrack
}
func (s *Session) attachPendingVideoTracks() error {
s.videoTrackMu.RLock()
defer s.videoTrackMu.RUnlock()
for _, track := range s.videoTracks {
if _, err := s.pcPub.AddTrack(track); err != nil {
return fmt.Errorf("add video track: %w", err)
}
}
return nil
}
func closeSignal(ch chan struct{}) {
if ch == nil {
return
}
select {
case <-ch:
default:
close(ch)
}
}
func init() { //nolint:gochecknoinits // engine registration is the canonical Go pattern for plugins
engine.Register("goolom", New)
}

View File

@@ -1,4 +1,4 @@
package telemost
package goolom
import (
"testing"
@@ -7,7 +7,7 @@ import (
//nolint:cyclop // table-driven test naturally has many branches
func TestSessionReconnectAndEndedHelpers(t *testing.T) {
p := &Peer{
s := &Session{
reconnectCh: make(chan struct{}, 2),
closeCh: make(chan struct{}),
keepAliveCh: make(chan struct{}),
@@ -15,71 +15,71 @@ func TestSessionReconnectAndEndedHelpers(t *testing.T) {
telemetryCh: make(chan struct{}, 1),
}
keepAliveCh, sessionCloseCh := p.resetSession()
if keepAliveCh == nil || sessionCloseCh == nil || keepAliveCh != p.keepAliveCh || sessionCloseCh != p.sessionCloseCh {
keepAliveCh, sessionCloseCh := s.resetSession()
if keepAliveCh == nil || sessionCloseCh == nil || keepAliveCh != s.keepAliveCh || sessionCloseCh != s.sessionCloseCh {
t.Fatal("resetSession() did not replace session channels")
}
p.subscriberReady.Store(true)
p.publisherReady.Store(true)
p.resetMediaState()
if p.subscriberReady.Load() || p.publisherReady.Load() || p.subscriberConn == nil || p.publisherConn == nil {
s.subscriberReady.Store(true)
s.publisherReady.Store(true)
s.resetMediaState()
if s.subscriberReady.Load() || s.publisherReady.Load() || s.subscriberConn == nil || s.publisherConn == nil {
t.Fatal("resetMediaState() did not reset readiness")
}
p.queueReconnect()
s.queueReconnect()
select {
case <-p.reconnectCh:
case <-s.reconnectCh:
default:
t.Fatal("queueReconnect() did not enqueue")
}
p.SetShouldReconnect(func() bool { return false })
p.queueReconnect()
s.SetShouldReconnect(func() bool { return false })
s.queueReconnect()
select {
case <-p.reconnectCh:
case <-s.reconnectCh:
t.Fatal("queueReconnect() enqueued despite policy=false")
default:
}
p.reconnectCh <- struct{}{}
p.reconnectCh <- struct{}{}
p.drainReconnectQueue()
s.reconnectCh <- struct{}{}
s.reconnectCh <- struct{}{}
s.drainReconnectQueue()
select {
case <-p.reconnectCh:
case <-s.reconnectCh:
t.Fatal("drainReconnectQueue() left queued item")
default:
}
p.telemetryActive.Store(true)
p.stopTelemetry()
s.telemetryActive.Store(true)
s.stopTelemetry()
select {
case <-p.telemetryCh:
case <-s.telemetryCh:
default:
t.Fatal("stopTelemetry() did not signal active telemetry")
}
ended := ""
p.SetEndedCallback(func(reason string) { ended = reason })
p.signalEnded("done")
if !p.closed.Load() || ended != "done" {
t.Fatalf("signalEnded() closed=%v reason=%q", p.closed.Load(), ended)
s.SetEndedCallback(func(reason string) { ended = reason })
s.signalEnded("done")
if !s.closed.Load() || ended != "done" {
t.Fatalf("signalEnded() closed=%v reason=%q", s.closed.Load(), ended)
}
}
func TestWaitForAckTimeoutAndClose(t *testing.T) {
p := &Peer{
s := &Session{
closeCh: make(chan struct{}),
ackWaiters: make(map[string]chan struct{}),
}
ch := p.registerAckWaiter("timeout")
if p.waitForAck("timeout", ch, time.Millisecond) {
ch := s.registerAckWaiter("timeout")
if s.waitForAck("timeout", ch, time.Millisecond) {
t.Fatal("waitForAck(timeout) = true")
}
ch = p.registerAckWaiter("closed")
close(p.closeCh)
if p.waitForAck("closed", ch, time.Second) {
ch = s.registerAckWaiter("closed")
close(s.closeCh)
if s.waitForAck("closed", ch, time.Second) {
t.Fatal("waitForAck(closeCh) = true")
}
}

View File

@@ -0,0 +1,303 @@
package goolom
import (
"context"
"fmt"
"runtime"
"strings"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/openlibrecommunity/olcrtc/internal/logger"
)
func (s *Session) sendHello() error {
hello := map[string]any{
keyUID: uuid.New().String(),
"hello": map[string]any{
"participantMeta": map[string]any{
keyName: s.name,
"role": "SPEAKER",
keyDescription: "",
"sendAudio": false,
"sendVideo": s.hasLocalVideoTracks(),
},
"participantAttributes": map[string]any{
keyName: s.name,
"role": "SPEAKER",
keyDescription: "",
},
"sendAudio": false,
"sendVideo": s.hasLocalVideoTracks(),
"sendSharing": false,
"participantId": s.peerID,
"roomId": s.roomID,
"serviceName": "telemost",
"credentials": s.credentials,
"capabilitiesOffer": goolomCapabilitiesOffer(),
"sdkInfo": map[string]any{
"implementation": "browser",
"version": "5.27.0",
"userAgent": "Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0",
"hwConcurrency": runtime.NumCPU(),
},
"sdkInitializationId": uuid.New().String(),
"disablePublisher": !s.hasLocalVideoTracks(),
"disableSubscriber": false,
"disableSubscriberAudio": true,
},
}
s.wsMu.Lock()
defer s.wsMu.Unlock()
if err := s.ws.WriteJSON(hello); err != nil {
return fmt.Errorf("write hello: %w", err)
}
return nil
}
func (s *Session) handleSignaling(ctx context.Context) {
pubSent := false
for {
var msg map[string]any
if err := s.ws.ReadJSON(&msg); err != nil {
if !s.closed.Load() {
logger.Debugf("ws read error: %v", err)
s.queueReconnect()
}
return
}
s.updateWSDeadline()
uid, _ := msg[keyUID].(string)
s.handleMessageEvents(ctx, msg, uid)
if isConferenceEndMessage(msg) {
s.signalEnded("conference ended")
return
}
if offer, ok := msg["subscriberSdpOffer"].(map[string]any); ok {
if err := s.handleSdpOffer(offer, uid, !pubSent); err != nil {
logger.Debugf("sdp offer error: %v", err)
continue
}
pubSent = true
}
s.handleSignalingResponses(msg, uid)
}
}
func (s *Session) handleMessageEvents(ctx context.Context, msg map[string]any, uid string) {
if _, ok := msg["ack"]; ok {
s.resolveAck(uid)
}
if serverHello, ok := msg["serverHello"].(map[string]any); ok {
s.applyServerHelloConfig(serverHello)
s.startTelemetry(ctx, serverHello)
s.sendAck(uid)
}
s.handleCommonMessages(msg, uid)
}
func (s *Session) handleSignalingResponses(msg map[string]any, uid string) {
if answer, ok := msg["publisherSdpAnswer"].(map[string]any); ok {
s.handleSdpAnswer(answer, uid)
}
if cand, ok := msg["webrtcIceCandidate"].(map[string]any); ok {
s.handleICE(cand)
}
}
func (s *Session) updateWSDeadline() {
s.wsMu.Lock()
if s.ws != nil {
_ = s.ws.SetReadDeadline(time.Now().Add(wsReadTimeout))
}
s.wsMu.Unlock()
}
func (s *Session) handleCommonMessages(msg map[string]any, uid string) {
if _, ok := msg["updateDescription"]; ok {
s.sendAck(uid)
}
if _, ok := msg["vadActivity"]; ok {
s.sendAck(uid)
}
if _, ok := msg["ping"]; ok {
s.sendPong(uid)
}
if _, ok := msg["pong"]; ok {
s.sendAck(uid)
}
}
func (s *Session) sendAck(uid string) {
if uid == "" {
return
}
s.wsMu.Lock()
defer s.wsMu.Unlock()
_ = s.ws.WriteJSON(map[string]any{
keyUID: uid,
"ack": map[string]any{
"status": map[string]any{"code": "OK"},
},
})
}
func (s *Session) sendPong(uid string) {
s.wsMu.Lock()
defer s.wsMu.Unlock()
_ = s.ws.WriteJSON(map[string]any{
keyUID: uid,
"pong": map[string]any{},
})
}
func (s *Session) registerAckWaiter(uid string) chan struct{} {
ch := make(chan struct{})
s.ackMu.Lock()
s.ackWaiters[uid] = ch
s.ackMu.Unlock()
return ch
}
func (s *Session) removeAckWaiter(uid string) {
s.ackMu.Lock()
delete(s.ackWaiters, uid)
s.ackMu.Unlock()
}
func (s *Session) waitForAck(uid string, ch <-chan struct{}, timeout time.Duration) bool {
if uid == "" {
return false
}
defer s.removeAckWaiter(uid)
select {
case <-ch:
return true
case <-time.After(timeout):
return false
case <-s.closeCh:
return false
}
}
func (s *Session) resolveAck(uid string) {
if uid == "" {
return
}
s.ackMu.Lock()
ch := s.ackWaiters[uid]
if ch != nil {
delete(s.ackWaiters, uid)
close(ch)
}
s.ackMu.Unlock()
}
func (s *Session) sendLeave(uid string) bool {
s.wsMu.Lock()
defer s.wsMu.Unlock()
if s.ws == nil {
return false
}
leave := map[string]any{
keyUID: uid,
"leave": map[string]any{},
}
if err := s.ws.WriteJSON(leave); err != nil {
return false
}
return true
}
func (s *Session) keepAlive(keepAliveCh <-chan struct{}) {
wsTicker := time.NewTicker(30 * time.Second)
defer wsTicker.Stop()
appTicker := time.NewTicker(5 * time.Second)
defer appTicker.Stop()
for {
select {
case <-wsTicker.C:
if !s.sendWSPing() {
return
}
case <-appTicker.C:
if !s.sendAppPing() {
return
}
case <-keepAliveCh:
return
case <-s.closeCh:
return
}
}
}
func (s *Session) sendWSPing() bool {
s.wsMu.Lock()
defer s.wsMu.Unlock()
if s.ws != nil {
if err := s.ws.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)); err != nil {
logger.Debugf("ws ping error: %v", err)
s.queueReconnect()
return false
}
}
return true
}
func (s *Session) sendAppPing() bool {
s.wsMu.Lock()
defer s.wsMu.Unlock()
if s.ws != nil {
if err := s.ws.WriteJSON(map[string]any{
keyUID: uuid.New().String(),
"ping": map[string]any{},
}); err != nil {
logger.Debugf("app ping error: %v", err)
s.queueReconnect()
return false
}
}
return true
}
func isConferenceEndMessage(msg map[string]any) bool {
for _, key := range []string{"conferenceClosed", "conferenceEnded", "roomClosed", "roomEnded", "callEnded"} {
if _, ok := msg[key]; ok {
return true
}
}
if raw, ok := msg["conference"].(map[string]any); ok {
if state, _ := raw["state"].(string); isEndedState(state) {
return true
}
}
if raw, ok := msg["conferenceState"].(map[string]any); ok {
if state, _ := raw["state"].(string); isEndedState(state) {
return true
}
}
return false
}
func isEndedState(state string) bool {
switch strings.ToLower(state) {
case "closed", "ended", "finished", stateTerminated:
return true
default:
return false
}
}

View File

@@ -0,0 +1,246 @@
package goolom
import (
"bytes"
"context"
"encoding/json"
"math/rand/v2"
"net/http"
"time"
"github.com/google/uuid"
"github.com/openlibrecommunity/olcrtc/internal/logger"
"github.com/openlibrecommunity/olcrtc/internal/protect"
)
func (s *Session) processSendQueue(workerID int, sessionCloseCh <-chan struct{}) {
for {
select {
case <-sessionCloseCh:
return
case <-s.closeCh:
return
case data := <-s.sendQueue:
if len(data) > s.trafficShape.MaxMessageSize {
logger.Debugf("oversized message size=%d limit=%d", len(data), s.trafficShape.MaxMessageSize)
continue
}
waited, err := s.waitBufferedAmount(workerID, sessionCloseCh)
if err != nil {
return
}
if waited > 0 {
logger.Verbosef("[WORKER-%d] Drained after %v", workerID, waited)
}
if err := s.dc.Send(data); err != nil {
logger.Debugf("send error: %v", err)
s.queueReconnect()
return
}
if s.trafficShape.MinDelay > 0 {
time.Sleep(s.calculateDelay())
}
}
}
}
func (s *Session) waitBufferedAmount(workerID int, sessionCloseCh <-chan struct{}) (time.Duration, error) {
start := time.Now()
for s.dc.BufferedAmount() > defaultBufferHighWaterMark {
select {
case <-sessionCloseCh:
return 0, ErrSessionClosed
case <-s.closeCh:
return 0, ErrPeerClosed
case <-time.After(10 * time.Millisecond):
if time.Since(start) > 5*time.Second {
logger.Debugf("buffer wait timeout worker=%d", workerID)
return time.Since(start), nil
}
}
}
return time.Since(start), nil
}
func (s *Session) calculateDelay() time.Duration {
minDelay := s.trafficShape.MinDelay
maxDelay := s.trafficShape.MaxDelay
if maxDelay <= minDelay {
return minDelay
}
return minDelay + time.Duration(rand.Int64N(int64(maxDelay-minDelay))) //nolint:gosec,lll // G404: non-cryptographic shaping randomness
}
func (s *Session) startTelemetry(ctx context.Context, serverHello map[string]any) {
endpoint, interval, ok := parseTelemetryCfg(serverHello)
if !ok {
return
}
if !s.telemetryActive.CompareAndSwap(false, true) {
return
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
defer s.telemetryActive.Store(false)
ticker := time.NewTicker(interval)
defer ticker.Stop()
s.sendTelemetry(ctx, endpoint, "join")
for {
select {
case <-ticker.C:
s.sendTelemetry(ctx, endpoint, "stats")
case <-s.telemetryCh:
s.sendTelemetry(ctx, endpoint, "leave")
return
case <-s.closeCh:
s.sendTelemetry(ctx, endpoint, "leave")
return
}
}
}()
}
func parseTelemetryCfg(serverHello map[string]any) (string, time.Duration, bool) {
cfg, ok := serverHello["telemetryConfiguration"].(map[string]any)
if !ok {
return "", 0, false
}
endpoint, ok := cfg["logEndpoint"].(string)
if !ok || endpoint == "" {
endpoint, ok = cfg["endpoint"].(string)
if !ok || endpoint == "" {
endpoint, _ = cfg["url"].(string)
}
}
if endpoint == "" {
return "", 0, false
}
interval := defaultTelemetryInterval
if raw, ok := cfg["sendingInterval"].(float64); ok && raw > 0 {
interval = time.Duration(raw) * time.Millisecond
}
return endpoint, interval, true
}
func (s *Session) stopTelemetry() {
if s.telemetryActive.Load() {
select {
case s.telemetryCh <- struct{}{}:
default:
}
}
}
func (s *Session) sendTelemetry(ctx context.Context, endpoint, event string) {
body, err := json.Marshal(map[string]any{
"event": event,
"timestamp": time.Now().UnixMilli(),
"peerId": s.peerID,
"roomId": s.roomID,
"displayName": s.name,
"implementation": "browser",
"dataChannel": map[string]any{
"bufferedAmount": s.GetBufferedAmount(),
"sendQueue": len(s.sendQueue),
},
})
if err != nil {
return
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
logger.Verbosef("Telemetry req error: %v", err)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0")
if s.telemetryReferer != "" {
req.Header.Set("Referer", s.telemetryReferer)
}
req.Header.Set("X-Requested-With", "XMLHttpRequest")
req.Header.Set("Client-Instance-Id", uuid.New().String())
req.Header.Set("X-Telemost-Client-Version", "187.1.0")
req.Header.Set("Idempotency-Key", uuid.New().String())
client := protect.NewHTTPClient()
resp, err := client.Do(req)
if err != nil {
logger.Verbosef("Telemetry send error: %v", err)
return
}
defer func() { _ = resp.Body.Close() }()
}
func goolomCapabilitiesOffer() map[string]any {
return map[string]any{
"offerAnswerMode": []string{"SEPARATE"},
"initialSubscriberOffer": []string{"ON_HELLO"},
"slotsMode": []string{"FROM_CONTROLLER"},
"simulcastMode": []string{"DISABLED", "STATIC"},
"selfVadStatus": []string{"FROM_SERVER", "FROM_CLIENT"},
"dataChannelSharing": []string{"TO_RTP"},
"videoEncoderConfig": []string{"NO_CONFIG", "ONLY_INIT_CONFIG", "RUNTIME_CONFIG"},
"dataChannelVideoCodec": []string{"VP8", "UNIQUE_CODEC_FROM_TRACK_DESCRIPTION"},
"bandwidthLimitationReason": []string{
"BANDWIDTH_REASON_DISABLED",
"BANDWIDTH_REASON_ENABLED",
},
"sdkDefaultDeviceManagement": []string{
"SDK_DEFAULT_DEVICE_MANAGEMENT_DISABLED",
"SDK_DEFAULT_DEVICE_MANAGEMENT_ENABLED",
},
"joinOrderLayout": []string{"JOIN_ORDER_LAYOUT_DISABLED", "JOIN_ORDER_LAYOUT_ENABLED"},
"pinLayout": []string{"PIN_LAYOUT_DISABLED"},
"sendSelfViewVideoSlot": []string{
"SEND_SELF_VIEW_VIDEO_SLOT_DISABLED",
"SEND_SELF_VIEW_VIDEO_SLOT_ENABLED",
},
"serverLayoutTransition": []string{"SERVER_LAYOUT_TRANSITION_DISABLED"},
"sdkPublisherOptimizeBitrate": []string{
"SDK_PUBLISHER_OPTIMIZE_BITRATE_DISABLED",
"SDK_PUBLISHER_OPTIMIZE_BITRATE_FULL",
"SDK_PUBLISHER_OPTIMIZE_BITRATE_ONLY_SELF",
},
"sdkNetworkLostDetection": []string{"SDK_NETWORK_LOST_DETECTION_DISABLED"},
"sdkNetworkPathMonitor": []string{"SDK_NETWORK_PATH_MONITOR_DISABLED"},
"publisherVp9": []string{"PUBLISH_VP9_DISABLED", "PUBLISH_VP9_ENABLED"},
"svcMode": []string{"SVC_MODE_DISABLED", "SVC_MODE_L3T3", "SVC_MODE_L3T3_KEY"},
"subscriberOfferAsyncAck": []string{"SUBSCRIBER_OFFER_ASYNC_ACK_DISABLED", "SUBSCRIBER_OFFER_ASYNC_ACK_ENABLED"},
"androidBluetoothRoutingFix": []string{
"ANDROID_BLUETOOTH_ROUTING_FIX_DISABLED",
},
"fixedIceCandidatesPoolSize": []string{
"FIXED_ICE_CANDIDATES_POOL_SIZE_DISABLED",
},
"sdkAndroidTelecomIntegration": []string{
"SDK_ANDROID_TELECOM_INTEGRATION_DISABLED",
},
"setActiveCodecsMode": []string{
"SET_ACTIVE_CODECS_MODE_DISABLED",
"SET_ACTIVE_CODECS_MODE_VIDEO_ONLY",
},
"subscriberDtlsPassiveMode": []string{
"SUBSCRIBER_DTLS_PASSIVE_MODE_DISABLED",
},
"publisherOpusDred": []string{
"PUBLISHER_OPUS_DRED_DISABLED",
},
"publisherOpusLowBitrate": []string{
"PUBLISHER_OPUS_LOW_BITRATE_DISABLED",
},
"sdkAndroidDestroySessionOnTaskRemoved": []string{
"SDK_ANDROID_DESTROY_SESSION_ON_TASK_REMOVED_DISABLED",
},
"svcModes": []string{"FALSE"},
"reportTelemetryModes": []string{"TRUE"},
"keepDefaultDevicesModes": []string{"FALSE"},
}
}

View File

@@ -0,0 +1,339 @@
package jitsi
import (
"context"
"encoding/binary"
"fmt"
"math/rand/v2"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/openlibrecommunity/olcrtc/internal/engine"
)
// TestReconnectWindowResetsAfterTimeWindow covers fix 5d4592f: when the
// reconnect window elapses, reconnectCount must roll back to zero so the
// 5-attempt cap does not consume attempts accumulated long ago.
//
// The existing reconnect tests never exercise the window-rollover branch
// of handleReconnectAttempt; this test drives it directly.
func TestReconnectWindowResetsAfterTimeWindow(t *testing.T) {
js := newChurnSession(t)
defer func() { _ = js.Close() }()
// Pre-fill the window with maxReconnects attempts as if they happened
// just inside the window. The next attempt without rollover would trip
// the cap; with rollover (window expired) it must start fresh.
js.reconnectMu.Lock()
js.reconnectWindowStart = time.Now().Add(-reconnectWindow - time.Second)
js.reconnectCount = maxReconnects
js.reconnectMu.Unlock()
count, rolled := simulateAttempt(js)
if !rolled {
t.Fatal("expected window rollover, got continuation of stale window")
}
if count != 1 {
t.Fatalf("reconnectCount after rollover = %d, want 1", count)
}
}
// TestReconnectWindowEnforcesCapWithinWindow covers the negative half of
// fix 5d4592f: within a single window, attempts past the cap must signal
// session end. Pairs with the rollover test above to lock in both branches.
func TestReconnectWindowEnforcesCapWithinWindow(t *testing.T) {
js := newChurnSession(t)
defer func() { _ = js.Close() }()
endedCh := make(chan string, 1)
js.SetEndedCallback(func(reason string) {
select {
case endedCh <- reason:
default:
}
})
// Seed window in the present so attempts accumulate without rollover.
js.reconnectMu.Lock()
js.reconnectWindowStart = time.Now()
js.reconnectCount = maxReconnects
js.reconnectMu.Unlock()
// One more attempt should exceed the cap and end the session.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan bool, 1)
go func() { done <- js.handleReconnectAttempt(ctx) }()
select {
case reason := <-endedCh:
if reason == "" {
t.Fatal("ended with empty reason")
}
case <-time.After(2 * time.Second):
t.Fatal("cap was not enforced within window")
}
cancel()
<-done
}
// TestResetPeerClearsBindingForNewPeer covers fix 032151b: after an
// upper-layer handshake failure the supervisor calls ResetPeer, and the
// next peer in the room must be allowed to latch — not blocked by the
// previously-latched (now stale) endpoint.
//
// jitsi_test.go has no coverage for this path.
func TestResetPeerClearsBindingForNewPeer(t *testing.T) {
js := newChurnSession(t)
defer func() { _ = js.Close() }()
var got [][]byte
var mu sync.Mutex
js.onData = func(b []byte) {
mu.Lock()
got = append(got, append([]byte(nil), b...))
mu.Unlock()
}
js.localEpoch.Store(0xDEADBEEF)
// Peer A latches and delivers.
frameA := makeBridgeFrameForEpoch(t, 0x1111, 0, []byte("from-A"))
js.deliverBridgeMessage(makeBridgeMessageFrom("peerA", map[string]any{rawFieldKey: frameA}), true)
// Peer B tries while A still owns the latch — must be dropped.
frameB1 := makeBridgeFrameForEpoch(t, 0x2222, 0, []byte("from-B-blocked"))
js.deliverBridgeMessage(makeBridgeMessageFrom("peerB", map[string]any{rawFieldKey: frameB1}), true)
// Handshake failure recovery: reset.
js.ResetPeer()
if js.peerEpoch.Load() != 0 {
t.Fatalf("peerEpoch after ResetPeer = %#x, want 0", js.peerEpoch.Load())
}
if p := js.peerEndpoint.Load(); p != nil {
t.Fatalf("peerEndpoint after ResetPeer = %q, want nil", *p)
}
// Peer B retries and is now allowed.
frameB2 := makeBridgeFrameForEpoch(t, 0x2222, 0, []byte("from-B-allowed"))
js.deliverBridgeMessage(makeBridgeMessageFrom("peerB", map[string]any{rawFieldKey: frameB2}), true)
mu.Lock()
defer mu.Unlock()
if len(got) != 2 {
t.Fatalf("delivered = %d frames, want 2 (from-A then from-B-allowed): %q", len(got), got)
}
if string(got[0]) != "from-A" || string(got[1]) != "from-B-allowed" {
t.Fatalf("delivered = %q, want [from-A from-B-allowed]", got)
}
}
// TestChurnPeerEpochChanges hammers fix acac112 (epoch-based bridge frame
// filtering) under churn: many epoch transitions in rapid succession from
// the same peer. Existing tests fire a single epoch change; this test fires
// hundreds and asserts that:
// - no payload carrying a stale receiver-epoch is delivered;
// - peerEpoch always tracks the latest accepted sender-epoch;
// - the reconnect channel is signaled (at least once) on real changes.
//
// Run with -race to catch CAS misuses on peerEpoch / peerEndpoint.
func TestChurnPeerEpochChanges(t *testing.T) {
js := newChurnSession(t)
defer func() { _ = js.Close() }()
js.localEpoch.Store(0x42424242)
js.SetShouldReconnect(func() bool { return true })
var delivered atomic.Uint64
var staleDelivered atomic.Uint64
js.onData = func(b []byte) {
delivered.Add(1)
// Stale frames in this test are tagged with the literal "STALE".
if len(b) >= 5 && string(b[:5]) == "STALE" {
staleDelivered.Add(1)
}
}
const iterations = 500
const goroutines = 8
var wg sync.WaitGroup
for g := range goroutines {
seed := uint64(g) + 1
wg.Go(func() {
rng := rand.New(rand.NewPCG(seed, seed^0x9E3779B97F4A7C15)) //nolint:gosec // weak RNG is fine for test fixtures
for i := range iterations {
switch rng.IntN(3) {
case 0:
// Fresh epoch; receiverEpoch=0 acts as announce.
ep := uint32(rng.Uint64()|1) & 0xFFFFFFFE //nolint:gosec // truncation is the intent
payload := fmt.Appendf(nil, "ok-%d-%d", seed, i)
raw := makeBridgeFrameForEpoch(t, ep, 0, payload)
js.deliverBridgeMessage(
makeBridgeMessageFrom("peerA",
map[string]any{rawFieldKey: raw}), true)
case 1:
// Stale: receiverEpoch mismatched with local. Must be dropped.
raw := makeBridgeFrameForEpoch(t, 0x1111, 0xBADBAD, []byte("STALE-rcv"))
js.deliverBridgeMessage(
makeBridgeMessageFrom("peerA",
map[string]any{rawFieldKey: raw}), true)
case 2:
// Acknowledging local epoch: must pass.
payload := fmt.Appendf(nil, "ack-%d-%d", seed, i)
raw := makeBridgeFrameForEpoch(t, 0x9999, 0x42424242, payload)
js.deliverBridgeMessage(
makeBridgeMessageFrom("peerA",
map[string]any{rawFieldKey: raw}), true)
}
drainReconnectCh(js)
}
})
}
wg.Wait()
if staleDelivered.Load() != 0 {
t.Fatalf("stale frames delivered: %d (filter regression)", staleDelivered.Load())
}
if delivered.Load() == 0 {
t.Fatal("no frames delivered at all — filter is too aggressive")
}
}
// TestChurnConcurrentResetAndDeliver races ResetPeer against concurrent
// deliverBridgeMessage from multiple peers. Under -race it would catch
// torn reads on peerEndpoint / peerEpoch; logically it asserts that we
// never deliver data attributed to a peer that lost the latch.
func TestChurnConcurrentResetAndDeliver(t *testing.T) {
js := newChurnSession(t)
defer func() { _ = js.Close() }()
js.localEpoch.Store(0x55555555)
js.SetShouldReconnect(func() bool { return true })
js.onData = func([]byte) {} // discard
stop := make(chan struct{})
var wg sync.WaitGroup
for i, peer := range []string{"peerA", "peerB", "peerC"} {
ep := uint32(0x1000 * (i + 1))
wg.Go(func() {
for {
select {
case <-stop:
return
default:
}
raw := makeBridgeFrameForEpoch(t, ep, 0, []byte(peer))
js.deliverBridgeMessage(
makeBridgeMessageFrom(peer,
map[string]any{rawFieldKey: raw}), true)
drainReconnectCh(js)
}
})
}
wg.Go(func() {
for {
select {
case <-stop:
return
default:
}
js.ResetPeer()
time.Sleep(time.Microsecond * 50)
}
})
time.Sleep(200 * time.Millisecond)
close(stop)
wg.Wait()
}
// TestChurnReconnectAttemptSerial exercises handleReconnectAttempt across
// many synthetic windows back-to-back. The lock added on the reconnect
// counters means -race must stay clean even though only one goroutine
// drives the loop (matching production), so we also fire one extra reader
// to surface any future regression that adds a second writer.
func TestChurnReconnectAttemptSerial(t *testing.T) {
js := newChurnSession(t)
defer func() { _ = js.Close() }()
stop := make(chan struct{})
go func() {
// Reader: snapshots counters without blocking the writer.
for {
select {
case <-stop:
return
default:
}
js.reconnectMu.Lock()
_ = js.reconnectCount
_ = js.reconnectWindowStart
js.reconnectMu.Unlock()
}
}()
for i := range 20 {
// Force rollover every iteration.
js.reconnectMu.Lock()
js.reconnectWindowStart = time.Now().Add(-reconnectWindow - time.Second)
js.reconnectCount = 0
js.reconnectMu.Unlock()
count, rolled := simulateAttempt(js)
if !rolled {
t.Fatalf("iter %d: expected rollover", i)
}
if count != 1 {
t.Fatalf("iter %d: count after rollover = %d, want 1", i, count)
}
}
close(stop)
}
// --- helpers ---
func newChurnSession(t *testing.T) *Session {
t.Helper()
sess, err := New(context.Background(), engine.Config{
URL: testHost,
Extra: map[string]string{credentialKeyRoom: testRoom},
})
if err != nil {
t.Fatalf("New: %v", err)
}
js, ok := sess.(*Session)
if !ok {
t.Fatal("sess is not *Session")
}
return js
}
// simulateAttempt replicates the window-and-counter logic of
// handleReconnectAttempt without invoking reconnect() (which would touch
// real network state). Returns (post-increment count, true-if-window-rolled).
func simulateAttempt(js *Session) (int, bool) {
now := time.Now()
js.reconnectMu.Lock()
defer js.reconnectMu.Unlock()
rolled := false
if js.reconnectWindowStart.IsZero() || now.Sub(js.reconnectWindowStart) > reconnectWindow {
js.reconnectWindowStart = now
js.reconnectCount = 0
rolled = true
}
js.reconnectCount++
return js.reconnectCount, rolled
}
func drainReconnectCh(js *Session) {
select {
case <-js.reconnectCh:
default:
}
}
// Keep binary.BigEndian referenced even if all current uses are removed.
var _ = binary.BigEndian

View File

@@ -0,0 +1,45 @@
package jitsi
import (
"encoding/base64"
"encoding/binary"
"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,
}
}
func makeBridgeMessageFrom(from string, fields map[string]any) j.BridgeMessage {
return j.BridgeMessage{
Class: "EndpointMessage",
From: from,
Fields: fields,
}
}
func makeBridgeFrame(t *testing.T, payload []byte) string {
t.Helper()
return makeBridgeFrameForEpoch(t, 0x10203040, 0, payload)
}
func makeBridgeFrameForEpoch(t *testing.T, senderEpoch, receiverEpoch uint32, payload []byte) string {
t.Helper()
framed := append([]byte{}, bridgeMagic[:]...)
var hdr [8]byte
binary.BigEndian.PutUint32(hdr[0:4], senderEpoch)
binary.BigEndian.PutUint32(hdr[4:8], receiverEpoch)
framed = append(framed, hdr[:]...)
framed = append(framed, payload...)
return base64.StdEncoding.EncodeToString(framed)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,419 @@
package jitsi
import (
"context"
"errors"
"testing"
"time"
"github.com/openlibrecommunity/olcrtc/internal/engine"
"github.com/zarazaex69/j"
)
const (
testHost = "meet.example.com"
testRoom = "myroom"
rawFieldKey = "raw"
classEndpoint = "EndpointMessage"
)
func TestNormaliseHost(t *testing.T) {
tests := []struct {
raw string
want string
}{
{testHost, testHost},
{"https://" + testHost, testHost},
{"https://" + testHost + "/", testHost},
{"https://" + testHost + "/path", testHost},
{"//" + testHost, testHost},
{" https://" + testHost + " ", testHost},
{"", ""},
}
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"
encoded := encodeForTest(t, []byte(payload))
got := decodeRaw(makeBridgeMessage(classEndpoint, map[string]any{rawFieldKey: encoded}))
if string(got) != payload {
t.Fatalf("decodeRaw = %q, want %q", got, payload)
}
if got := decodeRaw(makeBridgeMessage("OtherClass", map[string]any{rawFieldKey: encoded})); got != nil {
t.Fatalf("decodeRaw(other class) = %q, want nil", got)
}
if got := decodeRaw(makeBridgeMessage(classEndpoint, map[string]any{})); got != nil {
t.Fatalf("decodeRaw(no raw) = %q, want nil", got)
}
if got := decodeRaw(makeBridgeMessage(classEndpoint, map[string]any{rawFieldKey: "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{credentialKeyRoom: testRoom},
})
if !errors.Is(err, ErrHostRequired) {
t.Fatalf("err = %v, want ErrHostRequired", err)
}
}
func TestNewRequiresRoom(t *testing.T) {
_, err := New(context.Background(), engine.Config{
URL: testHost,
})
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://" + testHost,
Extra: map[string]string{credentialKeyRoom: testRoom},
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 TestByteStreamNegotiatesPeerConnectionWithoutRequestingVideo(t *testing.T) {
sess, err := New(context.Background(), engine.Config{
URL: testHost,
Extra: map[string]string{credentialKeyRoom: testRoom},
OnData: func([]byte) {},
})
if err != nil {
t.Fatalf("New: %v", err)
}
defer func() { _ = sess.Close() }()
js, ok := sess.(*Session)
if !ok {
t.Fatal("sess is not *Session")
}
if !js.shouldNegotiatePC() {
t.Fatal("shouldNegotiatePC() = false for bytestream session")
}
if js.shouldRequestVideo() {
t.Fatal("shouldRequestVideo() = true for bytestream-only session")
}
}
func TestVideoSessionNegotiatesPeerConnectionAndRequestsVideo(t *testing.T) {
sess, err := New(context.Background(), engine.Config{
URL: testHost,
Extra: map[string]string{credentialKeyRoom: testRoom},
})
if err != nil {
t.Fatalf("New: %v", err)
}
defer func() { _ = sess.Close() }()
js, ok := sess.(*Session)
if !ok {
t.Fatal("sess is not *Session")
}
if js.shouldNegotiatePC() {
t.Fatal("shouldNegotiatePC() = true before bytestream/video is configured")
}
if err := js.AddVideoTrack(nil); err != nil {
t.Fatalf("AddVideoTrack(nil): %v", err)
}
if !js.shouldNegotiatePC() {
t.Fatal("shouldNegotiatePC() = false for video session")
}
if !js.shouldRequestVideo() {
t.Fatal("shouldRequestVideo() = false for video session")
}
}
func TestSendBeforeConnect(t *testing.T) {
sess, err := New(context.Background(), engine.Config{
URL: testHost,
Extra: map[string]string{credentialKeyRoom: testRoom},
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: testHost,
Extra: map[string]string{credentialKeyRoom: testRoom},
})
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"},
{"Конрад Олег", "Konrad-Oleg"},
{"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 TestDeliverBridgeMessageMagicAndPeerLatch(t *testing.T) {
sess, err := New(context.Background(), engine.Config{
URL: testHost,
Extra: map[string]string{credentialKeyRoom: testRoom},
})
if err != nil {
t.Fatalf("New: %v", err)
}
defer func() { _ = sess.Close() }()
js, ok := sess.(*Session)
if !ok {
t.Fatal("sess is not *Session")
}
var received [][]byte
js.onData = func(b []byte) {
received = append(received, append([]byte(nil), b...))
}
good := makeBridgeFrame(t, []byte("alpha"))
bad := encodeForTest(t, []byte("alpha")) // no magic prefix
// First valid frame from peerA latches the peer and is delivered.
if !js.deliverBridgeMessage(makeBridgeMessageFrom("peerA", map[string]any{rawFieldKey: good}), true) {
t.Fatal("deliverBridgeMessage returned false on valid frame")
}
// Frame without magic is dropped.
js.deliverBridgeMessage(makeBridgeMessageFrom("peerA", map[string]any{rawFieldKey: bad}), true)
// Frame from a different sender after latch is dropped even with magic.
js.deliverBridgeMessage(makeBridgeMessageFrom("peerB", map[string]any{rawFieldKey: good}), true)
// Another frame from latched peer still flows.
beta := makeBridgeFrame(t, []byte("beta"))
js.deliverBridgeMessage(makeBridgeMessageFrom("peerA", map[string]any{rawFieldKey: beta}), true)
if len(received) != 2 {
t.Fatalf("received frames = %d, want 2 (%q)", len(received), received)
}
if string(received[0]) != "alpha" || string(received[1]) != "beta" {
t.Fatalf("received = %q, want [alpha beta]", received)
}
}
func TestDeliverBridgeMessageWithPeerDataDoesNotLatchSinglePeer(t *testing.T) {
sess, err := New(context.Background(), engine.Config{
URL: testHost,
Extra: map[string]string{credentialKeyRoom: testRoom},
})
if err != nil {
t.Fatalf("New: %v", err)
}
defer func() { _ = sess.Close() }()
js, ok := sess.(*Session)
if !ok {
t.Fatal("sess is not *Session")
}
got := make(map[string]string)
js.onPeerData = func(peerID string, b []byte) {
got[peerID] = string(b)
}
frameA := makeBridgeFrameForEpoch(t, 0x1111, 0, []byte("alpha"))
frameB := makeBridgeFrameForEpoch(t, 0x2222, 0, []byte("beta"))
js.deliverBridgeMessage(makeBridgeMessageFrom("peerA", map[string]any{rawFieldKey: frameA}), true)
js.deliverBridgeMessage(makeBridgeMessageFrom("peerB", map[string]any{rawFieldKey: frameB}), true)
if got["peerA"] != "alpha" || got["peerB"] != "beta" {
t.Fatalf("peer data = %#v, want both peers delivered", got)
}
}
func TestDeliverBridgeMessageDropsStalePeerEpoch(t *testing.T) {
sess, err := New(context.Background(), engine.Config{
URL: testHost,
Extra: map[string]string{credentialKeyRoom: testRoom},
})
if err != nil {
t.Fatalf("New: %v", err)
}
defer func() { _ = sess.Close() }()
js, ok := sess.(*Session)
if !ok {
t.Fatal("sess is not *Session")
}
js.localEpoch.Store(0x2222)
delivered := false
js.onData = func([]byte) { delivered = true }
stale := makeBridgeFrameForEpoch(t, 0x1111, 0xaaaa, []byte("old-smux"))
js.deliverBridgeMessage(makeBridgeMessageFrom("peerA", map[string]any{rawFieldKey: stale}), true)
if delivered {
t.Fatal("stale peer-epoch frame was delivered")
}
}
func TestReconnectEpochAnnounceWithZeroPeerEpochIsAccepted(t *testing.T) {
sess, err := New(context.Background(), engine.Config{
URL: testHost,
Extra: map[string]string{credentialKeyRoom: testRoom},
})
if err != nil {
t.Fatalf("New: %v", err)
}
defer func() { _ = sess.Close() }()
js, ok := sess.(*Session)
if !ok {
t.Fatal("sess is not *Session")
}
js.localEpoch.Store(0x2222)
announce := makeBridgeFrameForEpoch(t, 0x1111, 0, nil)
js.deliverBridgeMessage(makeBridgeMessageFrom("peerA", map[string]any{rawFieldKey: announce}), true)
if got := js.peerEpoch.Load(); got != 0x1111 {
t.Fatalf("peerEpoch = 0x%08x, want announce epoch", got)
}
}
func TestDeliverBridgeMessagePeerEpochChangeRequestsReconnect(t *testing.T) {
sess, err := New(context.Background(), engine.Config{
URL: testHost,
Extra: map[string]string{credentialKeyRoom: testRoom},
})
if err != nil {
t.Fatalf("New: %v", err)
}
defer func() { _ = sess.Close() }()
js, ok := sess.(*Session)
if !ok {
t.Fatal("sess is not *Session")
}
js.localEpoch.Store(0x3333)
js.SetShouldReconnect(func() bool { return true })
var received [][]byte
js.onData = func(b []byte) {
received = append(received, append([]byte(nil), b...))
}
first := makeBridgeFrameForEpoch(t, 0x1111, 0, []byte("first"))
js.deliverBridgeMessage(makeBridgeMessageFrom("peerA", map[string]any{rawFieldKey: first}), true)
changed := makeBridgeFrameForEpoch(t, 0x2222, 0x3333, nil)
js.deliverBridgeMessage(makeBridgeMessageFrom("peerA", map[string]any{rawFieldKey: changed}), true)
if len(received) != 1 || string(received[0]) != "first" {
t.Fatalf("received = %q, want only first payload", received)
}
select {
case <-js.reconnectCh:
case <-time.After(time.Second):
t.Fatal("peer epoch change did not request reconnect")
}
}
func TestBridgeCloseRequestsReconnect(t *testing.T) {
sess, err := New(context.Background(), engine.Config{
URL: testHost,
Extra: map[string]string{credentialKeyRoom: testRoom},
})
if err != nil {
t.Fatalf("New: %v", err)
}
defer func() { _ = sess.Close() }()
js, ok := sess.(*Session)
if !ok {
t.Fatal("sess is not *Session")
}
var ended string
js.SetEndedCallback(func(reason string) { ended = reason })
js.SetShouldReconnect(func() bool { return true })
if js.deliverBridgeMessage(j.BridgeMessage{}, false) {
t.Fatal("deliverBridgeMessage returned true on closed bridge")
}
select {
case <-js.reconnectCh:
case <-time.After(time.Second):
t.Fatal("bridge close did not request reconnect")
}
if ended != "" {
t.Fatalf("ended = %q, want empty", ended)
}
}
func TestBridgeCloseEndsWhenReconnectDisabled(t *testing.T) {
sess, err := New(context.Background(), engine.Config{
URL: testHost,
Extra: map[string]string{credentialKeyRoom: testRoom},
})
if err != nil {
t.Fatalf("New: %v", err)
}
defer func() { _ = sess.Close() }()
js, ok := sess.(*Session)
if !ok {
t.Fatal("sess is not *Session")
}
var ended string
js.SetEndedCallback(func(reason string) { ended = reason })
js.SetShouldReconnect(func() bool { return false })
if js.deliverBridgeMessage(j.BridgeMessage{}, false) {
t.Fatal("deliverBridgeMessage returned true on closed bridge")
}
if ended != "jitsi bridge closed" {
t.Fatalf("ended = %q, want bridge close reason", ended)
}
}
func TestEngineRegistration(t *testing.T) {
if _, err := engine.New(context.Background(), "jitsi", engine.Config{
URL: testHost,
Extra: map[string]string{credentialKeyRoom: testRoom},
}); err != nil {
t.Fatalf("engine.New(jitsi) = %v, want nil", err)
}
}

View File

@@ -0,0 +1,532 @@
// Package livekit implements an engine.Session backed by the LiveKit SFU
// protocol via the upstream livekit/server-sdk-go client.
//
// This engine is service-agnostic: it accepts a wss:// signaling URL and an
// access token, and provides byte-stream + video-track primitives over a
// LiveKit room. Service-specific token acquisition (e.g. WB Stream,
// or a self-hosted LiveKit deployment) lives in the auth package.
package livekit
import (
"context"
"errors"
"fmt"
"log"
"sync"
"sync/atomic"
"time"
protoLogger "github.com/livekit/protocol/logger"
lksdk "github.com/livekit/server-sdk-go/v2"
"github.com/openlibrecommunity/olcrtc/internal/engine"
"github.com/openlibrecommunity/olcrtc/internal/logger"
"github.com/pion/webrtc/v4"
)
const (
defaultSendQueueSize = 5000
defaultSendQueueCapHard = 4000
dataPublishTopic = "olcrtc"
videoTrackName = "videochannel"
reconnectWindow = 5 * time.Minute
maxReconnects = 10
)
var (
// ErrSessionClosed is returned when an operation is attempted on a closed session.
ErrSessionClosed = errors.New("livekit session closed")
// ErrSendQueueFull is returned when the outbound queue cannot accept more data.
ErrSendQueueFull = errors.New("livekit send queue full")
// ErrRoomNotConnected is returned when the underlying room is not connected yet.
ErrRoomNotConnected = errors.New("livekit room not connected")
// ErrURLRequired is returned when no signaling URL was supplied.
ErrURLRequired = errors.New("livekit signaling URL required")
// ErrTokenRequired is returned when no access token was supplied.
ErrTokenRequired = errors.New("livekit access token required")
)
type roomHandle interface {
publishData(data []byte) error
publishTrack(track webrtc.TrackLocal) error
unpublishLocalTracks()
disconnect()
connectionState() lksdk.ConnectionState
}
type sdkRoom struct {
room *lksdk.Room
}
func (r *sdkRoom) publishData(data []byte) error {
if err := r.room.LocalParticipant.PublishDataPacket(
lksdk.UserData(data),
lksdk.WithDataPublishTopic(dataPublishTopic),
lksdk.WithDataPublishReliable(true),
); err != nil {
return fmt.Errorf("publish data packet: %w", err)
}
return nil
}
func (r *sdkRoom) publishTrack(track webrtc.TrackLocal) error {
_, err := r.room.LocalParticipant.PublishTrack(track, &lksdk.TrackPublicationOptions{Name: videoTrackName})
if err != nil {
return fmt.Errorf("publish track: %w", err)
}
return nil
}
func (r *sdkRoom) unpublishLocalTracks() {
if r.room == nil || r.room.LocalParticipant == nil {
return
}
for _, publication := range r.room.LocalParticipant.TrackPublications() {
if publication.SID() == "" {
continue
}
if err := r.room.LocalParticipant.UnpublishTrack(publication.SID()); err != nil {
log.Printf("livekit unpublish track error: %v", err)
}
}
}
func (r *sdkRoom) disconnect() {
r.room.Disconnect()
// LiveKit's Disconnect returns after local SDK teardown, before the
// server necessarily evicts the participant. Give the signalling path a
// short grace period so immediate reconnects do not inherit stale room
// state from a ghost participant.
time.Sleep(2 * time.Second)
}
func (r *sdkRoom) connectionState() lksdk.ConnectionState {
return r.room.ConnectionState()
}
type connectRoomFunc func(url, token string, callback *lksdk.RoomCallback) (roomHandle, error)
func connectSDKRoom(url, token string, callback *lksdk.RoomCallback) (roomHandle, error) {
room, err := lksdk.ConnectToRoomWithToken(
url,
token,
callback,
lksdk.WithAutoSubscribe(true),
lksdk.WithLogger(protoLogger.GetDiscardLogger()),
)
if err != nil {
return nil, fmt.Errorf("connect to livekit room: %w", err)
}
return &sdkRoom{room: room}, nil
}
// Session is the LiveKit engine handle.
type Session struct {
url string
token string
name string
refresh func(ctx context.Context) (engine.Credentials, error)
connectRoom connectRoomFunc
room roomHandle
roomMu sync.RWMutex
onData func([]byte)
onReconnect func(*webrtc.DataChannel)
shouldReconnect func() bool
onEnded func(string)
reconnectCh chan struct{}
closeCh chan struct{}
lastReconnect time.Time
reconnectCount int
sendQueue chan []byte
closed atomic.Bool
reconnecting atomic.Bool
done chan struct{}
cancel context.CancelFunc
shutdownOnce sync.Once
sendWorkerOnce sync.Once
videoTrackMu sync.RWMutex
videoTracks []webrtc.TrackLocal
onVideoTrack func(*webrtc.TrackRemote, *webrtc.RTPReceiver)
wg sync.WaitGroup
}
// New creates a new LiveKit engine session.
func New(ctx context.Context, cfg engine.Config) (engine.Session, error) {
if cfg.URL == "" {
return nil, ErrURLRequired
}
if cfg.Token == "" {
return nil, ErrTokenRequired
}
_, cancel := context.WithCancel(ctx)
return &Session{
url: cfg.URL,
token: cfg.Token,
name: cfg.Name,
refresh: cfg.Refresh,
connectRoom: connectSDKRoom,
onData: cfg.OnData,
reconnectCh: make(chan struct{}, 1),
closeCh: make(chan struct{}),
sendQueue: make(chan []byte, defaultSendQueueSize),
done: make(chan struct{}),
cancel: cancel,
}, nil
}
// Capabilities reports what this engine can do.
func (s *Session) Capabilities() engine.Capabilities {
return engine.Capabilities{ByteStream: true, VideoTrack: true}
}
// Connect joins the LiveKit room.
func (s *Session) Connect(ctx context.Context) error {
s.closed.Store(false)
if err := s.connectSession(ctx); err != nil {
return err
}
s.startSendWorker()
return nil
}
func (s *Session) connectSession(_ context.Context) error {
roomCB := &lksdk.RoomCallback{
ParticipantCallback: lksdk.ParticipantCallback{
OnDataReceived: func(data []byte, _ lksdk.DataReceiveParams) {
if s.onData != nil {
s.onData(data)
}
},
OnTrackSubscribed: func(track *webrtc.TrackRemote, _ *lksdk.RemoteTrackPublication, _ *lksdk.RemoteParticipant) {
if track.Kind() != webrtc.RTPCodecTypeVideo {
return
}
s.videoTrackMu.RLock()
cb := s.onVideoTrack
s.videoTrackMu.RUnlock()
if cb != nil {
cb(track, nil)
}
},
},
OnDisconnected: func() {
if s.closed.Load() || s.reconnecting.Load() {
return
}
if !s.queueReconnect() {
s.signalEnded("disconnected from livekit")
}
},
}
room, err := s.connectRoom(s.url, s.token, roomCB)
if err != nil {
return fmt.Errorf("connect to room: %w", err)
}
s.setRoom(room)
if err := s.publishPendingTracks(); err != nil {
return err
}
return nil
}
func (s *Session) publishPendingTracks() error {
room := s.currentRoom()
if room == nil {
return ErrRoomNotConnected
}
s.videoTrackMu.RLock()
defer s.videoTrackMu.RUnlock()
for _, track := range s.videoTracks {
if err := room.publishTrack(track); err != nil {
return fmt.Errorf("failed to publish track: %w", err)
}
}
return nil
}
func (s *Session) startSendWorker() {
s.sendWorkerOnce.Do(func() {
s.wg.Add(1)
go s.processSendQueue()
})
}
func (s *Session) processSendQueue() {
defer s.wg.Done()
for {
select {
case <-s.done:
return
case data, ok := <-s.sendQueue:
if !ok {
return
}
room := s.waitForConnectedRoom()
if room == nil {
return
}
if err := room.publishData(data); err != nil {
log.Printf("livekit publish data error: %v", err)
}
}
}
}
func (s *Session) waitForConnectedRoom() roomHandle {
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
for {
room := s.currentRoom()
if room != nil && room.connectionState() == lksdk.ConnectionStateConnected {
return room
}
select {
case <-s.done:
return nil
case <-ticker.C:
}
}
}
// Send queues data for transmission.
func (s *Session) Send(data []byte) error {
if s.closed.Load() {
return ErrSessionClosed
}
select {
case s.sendQueue <- data:
return nil
default:
return ErrSendQueueFull
}
}
// Close terminates the session.
func (s *Session) Close() error {
s.closed.Store(true)
s.shutdown()
return nil
}
func (s *Session) shutdown() {
s.shutdownOnce.Do(func() {
if s.cancel != nil {
s.cancel()
}
closeSignal(s.closeCh)
closeSignal(s.done)
if room := s.swapRoom(nil); room != nil {
room.unpublishLocalTracks()
room.disconnect()
}
s.wg.Wait()
})
}
// SetReconnectCallback stores the reconnect callback.
func (s *Session) SetReconnectCallback(cb func(*webrtc.DataChannel)) { s.onReconnect = cb }
// SetShouldReconnect stores the reconnect predicate.
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 monitors the connection lifecycle and reconnects as needed.
func (s *Session) WatchConnection(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-s.closeCh:
return
case <-s.reconnectCh:
if s.handleReconnectAttempt(ctx) {
return
}
}
}
}
func (s *Session) handleReconnectAttempt(ctx context.Context) bool {
if time.Since(s.lastReconnect) > reconnectWindow {
s.reconnectCount = 0
}
s.reconnectCount++
s.lastReconnect = time.Now()
if s.reconnectCount > maxReconnects {
s.signalEnded("reconnect limit reached")
return true
}
backoff := time.Duration(s.reconnectCount) * 2 * time.Second
if backoff > 30*time.Second {
backoff = 30 * time.Second
}
for {
if err := s.reconnect(ctx); err != nil {
logger.Debugf("livekit reconnect failed: %v", err)
select {
case <-ctx.Done():
return true
case <-s.closeCh:
return true
case <-time.After(backoff):
continue
}
}
s.drainReconnectQueue()
return false
}
}
func (s *Session) reconnect(ctx context.Context) error {
s.reconnecting.Store(true)
defer s.reconnecting.Store(false)
if room := s.swapRoom(nil); room != nil {
room.unpublishLocalTracks()
room.disconnect()
}
if s.refresh != nil {
creds, err := s.refresh(ctx)
if err != nil {
return fmt.Errorf("refresh credentials: %w", err)
}
s.applyRefreshedCredentials(creds)
}
if err := s.connectSession(ctx); err != nil {
return err
}
if s.onReconnect != nil {
s.onReconnect(nil)
}
return nil
}
func (s *Session) applyRefreshedCredentials(creds engine.Credentials) {
if creds.URL != "" {
s.url = creds.URL
}
if creds.Token != "" {
s.token = creds.Token
}
}
func (s *Session) queueReconnect() bool {
if s.closed.Load() || s.reconnecting.Load() {
return false
}
if s.shouldReconnect != nil && !s.shouldReconnect() {
return false
}
select {
case s.reconnectCh <- struct{}{}:
default:
}
return true
}
// Reconnect asks the LiveKit session to tear down its room handle and rejoin.
// Triggered by upper layers when liveness probes declare the carrier dead
// before LiveKit has noticed (silent data-path black-hole).
func (s *Session) Reconnect(reason string) {
if s.closed.Load() {
return
}
logger.Infof("livekit reconnect requested: %s", reason)
s.queueReconnect()
}
func (s *Session) drainReconnectQueue() {
for {
select {
case <-s.reconnectCh:
default:
return
}
}
}
func (s *Session) signalEnded(reason string) {
s.closed.Store(true)
s.shutdown()
if s.onEnded != nil {
s.onEnded(reason)
}
}
// CanSend reports whether the session is ready to accept data.
func (s *Session) CanSend() bool {
if s.closed.Load() || s.reconnecting.Load() || len(s.sendQueue) >= defaultSendQueueCapHard {
return false
}
room := s.currentRoom()
return room != nil && room.connectionState() == lksdk.ConnectionStateConnected
}
// GetSendQueue exposes the outbound queue.
func (s *Session) GetSendQueue() chan []byte { return s.sendQueue }
// GetBufferedAmount is a stub for LiveKit (the SDK handles its own buffering).
func (s *Session) GetBufferedAmount() uint64 { return 0 }
// AddVideoTrack publishes a video track to the room.
func (s *Session) AddVideoTrack(track webrtc.TrackLocal) error {
s.videoTrackMu.Lock()
s.videoTracks = append(s.videoTracks, track)
s.videoTrackMu.Unlock()
room := s.currentRoom()
if room == nil {
return nil
}
if err := room.publishTrack(track); err != nil {
return fmt.Errorf("failed to publish track: %w", err)
}
return nil
}
// SetVideoTrackHandler registers a callback for remote video tracks.
func (s *Session) SetVideoTrackHandler(cb func(*webrtc.TrackRemote, *webrtc.RTPReceiver)) {
s.videoTrackMu.Lock()
defer s.videoTrackMu.Unlock()
s.onVideoTrack = cb
}
func (s *Session) currentRoom() roomHandle {
s.roomMu.RLock()
defer s.roomMu.RUnlock()
return s.room
}
func (s *Session) setRoom(room roomHandle) {
s.roomMu.Lock()
defer s.roomMu.Unlock()
s.room = room
}
func (s *Session) swapRoom(room roomHandle) roomHandle {
s.roomMu.Lock()
defer s.roomMu.Unlock()
old := s.room
s.room = room
return old
}
func closeSignal(ch chan struct{}) {
select {
case <-ch:
default:
close(ch)
}
}
func init() { //nolint:gochecknoinits // engine registration is the canonical Go pattern for plugins
engine.Register("livekit", New)
}

View File

@@ -0,0 +1,321 @@
package livekit
import (
"context"
"errors"
"sync"
"testing"
"time"
lksdk "github.com/livekit/server-sdk-go/v2"
"github.com/openlibrecommunity/olcrtc/internal/engine"
"github.com/pion/webrtc/v4"
)
const (
testOldURL = "wss://old"
testOldToken = "old-token"
)
var errFakeConnect = errors.New("boom")
type fakeRoom struct {
mu sync.Mutex
state lksdk.ConnectionState
published [][]byte
tracks int
unpublished int
disconnected int
}
func newFakeRoom() *fakeRoom {
return &fakeRoom{state: lksdk.ConnectionStateConnected}
}
func (r *fakeRoom) publishData(data []byte) error {
r.mu.Lock()
defer r.mu.Unlock()
r.published = append(r.published, append([]byte(nil), data...))
return nil
}
func (r *fakeRoom) publishTrack(webrtc.TrackLocal) error {
r.mu.Lock()
defer r.mu.Unlock()
r.tracks++
return nil
}
func (r *fakeRoom) unpublishLocalTracks() {
r.mu.Lock()
defer r.mu.Unlock()
r.unpublished++
}
func (r *fakeRoom) disconnect() {
r.mu.Lock()
defer r.mu.Unlock()
r.disconnected++
r.state = lksdk.ConnectionStateDisconnected
}
func (r *fakeRoom) connectionState() lksdk.ConnectionState {
r.mu.Lock()
defer r.mu.Unlock()
return r.state
}
type fakeConnector struct {
mu sync.Mutex
urls []string
tokens []string
callbacks []*lksdk.RoomCallback
rooms []*fakeRoom
connected chan struct{}
err error
}
func newFakeConnector() *fakeConnector {
return &fakeConnector{connected: make(chan struct{}, 8)}
}
func (c *fakeConnector) connect(url, token string, cb *lksdk.RoomCallback) (roomHandle, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.err != nil {
return nil, c.err
}
room := newFakeRoom()
c.urls = append(c.urls, url)
c.tokens = append(c.tokens, token)
c.callbacks = append(c.callbacks, cb)
c.rooms = append(c.rooms, room)
c.connected <- struct{}{}
return room, nil
}
func (c *fakeConnector) count() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.rooms)
}
func (c *fakeConnector) callback(i int) *lksdk.RoomCallback {
c.mu.Lock()
defer c.mu.Unlock()
return c.callbacks[i]
}
func (c *fakeConnector) room(i int) *fakeRoom {
c.mu.Lock()
defer c.mu.Unlock()
return c.rooms[i]
}
func (c *fakeConnector) snapshot() ([]string, []string) {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.urls...), append([]string(nil), c.tokens...)
}
func waitFor(t *testing.T, cond func() bool) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("condition was not met before timeout")
}
//nolint:cyclop // reconnect flow test keeps setup and postconditions in one scenario
func TestReconnectRefreshesCredentialsAndReplacesRoom(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
refreshes := 0
sess, err := New(ctx, engine.Config{
URL: testOldURL,
Token: testOldToken,
Refresh: func(context.Context) (engine.Credentials, error) {
refreshes++
return engine.Credentials{URL: "wss://new", Token: "new-token"}, nil
},
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
s, ok := sess.(*Session)
if !ok {
t.Fatalf("New() type = %T, want *Session", sess)
}
connector := newFakeConnector()
s.connectRoom = connector.connect
reconnected := make(chan struct{}, 1)
s.SetReconnectCallback(func(*webrtc.DataChannel) {
reconnected <- struct{}{}
})
if err := s.Connect(ctx); err != nil {
t.Fatalf("Connect() error = %v", err)
}
go s.WatchConnection(ctx)
connector.callback(0).OnDisconnected()
waitFor(t, func() bool { return connector.count() == 2 })
select {
case <-reconnected:
case <-time.After(time.Second):
t.Fatal("reconnect callback was not called")
}
urls, tokens := connector.snapshot()
if got, want := urls, []string{testOldURL, "wss://new"}; !equalStrings(got, want) {
t.Fatalf("connect urls = %v, want %v", got, want)
}
if got, want := tokens, []string{testOldToken, "new-token"}; !equalStrings(got, want) {
t.Fatalf("connect tokens = %v, want %v", got, want)
}
if refreshes != 1 {
t.Fatalf("refreshes = %d, want 1", refreshes)
}
oldRoom := connector.room(0)
oldRoom.mu.Lock()
if oldRoom.disconnected != 1 || oldRoom.unpublished != 1 {
t.Fatalf("old room cleanup disconnected=%d unpublished=%d, want 1/1",
oldRoom.disconnected, oldRoom.unpublished)
}
oldRoom.mu.Unlock()
if !s.CanSend() {
t.Fatal("CanSend() = false after reconnect, want true")
}
if err := s.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
}
//nolint:cyclop // terminal disconnect test keeps setup and cleanup assertions together
func TestDisconnectedEndsWhenReconnectDisallowed(t *testing.T) {
ctx := context.Background()
sess, err := New(ctx, engine.Config{URL: testOldURL, Token: testOldToken})
if err != nil {
t.Fatalf("New() error = %v", err)
}
s, ok := sess.(*Session)
if !ok {
t.Fatalf("New() type = %T, want *Session", sess)
}
connector := newFakeConnector()
s.connectRoom = connector.connect
s.SetShouldReconnect(func() bool { return false })
ended := make(chan string, 1)
s.SetEndedCallback(func(reason string) {
ended <- reason
})
if err := s.Connect(ctx); err != nil {
t.Fatalf("Connect() error = %v", err)
}
connector.callback(0).OnDisconnected()
select {
case reason := <-ended:
if reason != "disconnected from livekit" {
t.Fatalf("ended reason = %q, want disconnected from livekit", reason)
}
case <-time.After(time.Second):
t.Fatal("ended callback was not called")
}
if !s.closed.Load() {
t.Fatal("closed = false after terminal disconnect")
}
if connector.count() != 1 {
t.Fatalf("connect count = %d, want 1", connector.count())
}
room := connector.room(0)
room.mu.Lock()
if room.disconnected != 1 || room.unpublished != 1 {
t.Fatalf("terminal room cleanup disconnected=%d unpublished=%d, want 1/1",
room.disconnected, room.unpublished)
}
room.mu.Unlock()
if err := s.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
room.mu.Lock()
if room.disconnected != 1 || room.unpublished != 1 {
t.Fatalf("second close cleanup disconnected=%d unpublished=%d, want still 1/1",
room.disconnected, room.unpublished)
}
room.mu.Unlock()
}
func TestCanSendRequiresConnectedRoomAndQueueHeadroom(t *testing.T) {
s := &Session{
sendQueue: make(chan []byte, defaultSendQueueSize),
done: make(chan struct{}),
closeCh: make(chan struct{}),
}
if s.CanSend() {
t.Fatal("CanSend() = true without room")
}
room := newFakeRoom()
room.state = lksdk.ConnectionStateDisconnected
s.setRoom(room)
if s.CanSend() {
t.Fatal("CanSend() = true for disconnected room")
}
room.state = lksdk.ConnectionStateConnected
if !s.CanSend() {
t.Fatal("CanSend() = false for connected room")
}
for range defaultSendQueueCapHard {
s.sendQueue <- []byte("x")
}
if s.CanSend() {
t.Fatal("CanSend() = true at queue high watermark")
}
}
func TestReconnectFailureRetriesUntilContextDone(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s := &Session{
url: testOldURL,
token: testOldToken,
connectRoom: func(string, string, *lksdk.RoomCallback) (roomHandle, error) {
cancel()
return nil, errFakeConnect
},
reconnectCh: make(chan struct{}, 1),
closeCh: make(chan struct{}),
sendQueue: make(chan []byte, defaultSendQueueSize),
done: make(chan struct{}),
}
if terminal := s.handleReconnectAttempt(ctx); !terminal {
t.Fatal("handleReconnectAttempt() = false after context cancellation")
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

View File

@@ -0,0 +1,60 @@
// Package framing implements the length-prefixed JSON message framing used by
// the olcrtc control and handshake protocols.
//
// Wire format: 4-byte big-endian length followed by that many bytes of body.
// Body interpretation (JSON, protobuf, etc.) is up to the caller; this package
// only deals with byte-level framing.
package framing
import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
)
// ErrFrameTooLarge is returned when a frame exceeds the configured max size.
var ErrFrameTooLarge = errors.New("frame too large")
// WriteJSON marshals msg as JSON and writes it framed.
func WriteJSON(w io.Writer, msg any, maxSize int) error {
body, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
return WriteBytes(w, body, maxSize)
}
// WriteBytes writes body as a single length-prefixed frame.
func WriteBytes(w io.Writer, body []byte, maxSize int) error {
if maxSize > 0 && len(body) > maxSize {
return fmt.Errorf("%w: %d > %d", ErrFrameTooLarge, len(body), maxSize)
}
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], uint32(len(body))) //nolint:gosec // size bounded by maxSize check
if _, err := w.Write(hdr[:]); err != nil {
return fmt.Errorf("write hdr: %w", err)
}
if _, err := w.Write(body); err != nil {
return fmt.Errorf("write body: %w", err)
}
return nil
}
// ReadBytes reads one length-prefixed frame from r.
func ReadBytes(r io.Reader, maxSize int) ([]byte, error) {
var hdr [4]byte
if _, err := io.ReadFull(r, hdr[:]); err != nil {
return nil, fmt.Errorf("read hdr: %w", err)
}
n := binary.BigEndian.Uint32(hdr[:])
if maxSize > 0 && n > uint32(maxSize) { //nolint:gosec // maxSize is non-negative
return nil, fmt.Errorf("%w: %d > %d", ErrFrameTooLarge, n, maxSize)
}
buf := make([]byte, n)
if _, err := io.ReadFull(r, buf); err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
return buf, nil
}

View File

@@ -0,0 +1,77 @@
package framing_test
import (
"bytes"
"errors"
"io"
"strings"
"testing"
"github.com/openlibrecommunity/olcrtc/internal/framing"
)
func TestRoundTripJSON(t *testing.T) {
var buf bytes.Buffer
type msg struct {
Type string `json:"type"`
N int `json:"n"`
}
in := msg{Type: "ping", N: 7}
if err := framing.WriteJSON(&buf, in, 1024); err != nil {
t.Fatalf("write: %v", err)
}
body, err := framing.ReadBytes(&buf, 1024)
if err != nil {
t.Fatalf("read: %v", err)
}
want := `{"type":"ping","n":7}`
if string(body) != want {
t.Fatalf("body=%q want=%q", body, want)
}
}
func TestWriteTooLarge(t *testing.T) {
var buf bytes.Buffer
err := framing.WriteBytes(&buf, []byte(strings.Repeat("x", 10)), 5)
if !errors.Is(err, framing.ErrFrameTooLarge) {
t.Fatalf("want ErrFrameTooLarge, got %v", err)
}
}
func TestReadTooLarge(t *testing.T) {
var buf bytes.Buffer
// Manually craft an oversized header.
buf.Write([]byte{0x00, 0x00, 0x10, 0x00}) // 4096
_, err := framing.ReadBytes(&buf, 1024)
if !errors.Is(err, framing.ErrFrameTooLarge) {
t.Fatalf("want ErrFrameTooLarge, got %v", err)
}
}
func TestReadTruncated(t *testing.T) {
var buf bytes.Buffer
buf.Write([]byte{0x00, 0x00, 0x00, 0x04})
buf.WriteByte(0x41) // only 1 of 4 body bytes
_, err := framing.ReadBytes(&buf, 1024)
if err == nil || errors.Is(err, framing.ErrFrameTooLarge) {
t.Fatalf("want EOF/unexpected, got %v", err)
}
if !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("want UnexpectedEOF, got %v", err)
}
}
func TestZeroMaxAllowsAnything(t *testing.T) {
var buf bytes.Buffer
big := bytes.Repeat([]byte{0xAA}, 100_000)
if err := framing.WriteBytes(&buf, big, 0); err != nil {
t.Fatalf("write: %v", err)
}
got, err := framing.ReadBytes(&buf, 0)
if err != nil {
t.Fatalf("read: %v", err)
}
if !bytes.Equal(got, big) {
t.Fatalf("roundtrip mismatch")
}
}

View File

@@ -0,0 +1,207 @@
// Package handshake implements the olcrtc session handshake.
//
// The handshake runs on the first smux stream (control stream) of a tunnel.
// Wire format on the control stream is length-prefixed JSON: each message is
// a 4-byte big-endian length followed by that many bytes of JSON.
//
// client server
// │ CLIENT_HELLO │
// │ ─────────────────────► │
// │ │ AuthHook(claims) → sessionID | err
// │ SERVER_WELCOME / REJECT│
// │ ◄───────────────────── │
// │ │
//
// After the exchange the control stream stays open; tunnel traffic flows over
// additional smux streams opened by the client. The control stream then
// carries ping/pong liveness and future control messages.
//
//nolint:tagliatelle // JSON keys are the stable wire protocol schema.
package handshake
import (
"encoding/json"
"errors"
"fmt"
"io"
"time"
"github.com/openlibrecommunity/olcrtc/internal/framing"
)
// ProtoVersion identifies the wire-format version. Bumped only on breaking
// changes to message layout or semantics.
const ProtoVersion = 1
// MaxMessageSize caps a single handshake frame. 64 KiB is comfortably larger
// than any legitimate HELLO/WELCOME payload and prevents memory blowups from
// malicious peers.
const MaxMessageSize = 64 * 1024
// DefaultTimeout bounds how long either side will wait for the peer's reply
// before bailing out.
const DefaultTimeout = 15 * time.Second
// MsgType labels each protocol message.
type MsgType string
const (
// TypeHello is the client's first message.
TypeHello MsgType = "CLIENT_HELLO"
// TypeWelcome is the server's success reply.
TypeWelcome MsgType = "SERVER_WELCOME"
// TypeReject is the server's failure reply.
TypeReject MsgType = "SERVER_REJECT"
)
// Hello is sent by the client to begin a session.
type Hello struct {
Version int `json:"version"`
Type MsgType `json:"type"`
DeviceID string `json:"device_id"`
Claims map[string]any `json:"claims,omitempty"`
}
// Welcome is the server's response on a successful handshake.
type Welcome struct {
Version int `json:"version"`
Type MsgType `json:"type"`
SessionID string `json:"session_id"`
}
// Reject is the server's response when auth fails.
type Reject struct {
Version int `json:"version"`
Type MsgType `json:"type"`
Reason string `json:"reason"`
}
// Errors returned by [Client] and [Server].
var (
// ErrRejected wraps a server-side rejection. The reason is in the error message.
ErrRejected = errors.New("handshake rejected")
// ErrProtocolVersion is returned when peer announces an incompatible version.
ErrProtocolVersion = errors.New("incompatible protocol version")
// ErrUnexpectedMessage is returned when a peer sends the wrong message type.
ErrUnexpectedMessage = errors.New("unexpected handshake message")
// ErrFrameTooLarge is returned when a peer announces a frame above [MaxMessageSize].
ErrFrameTooLarge = framing.ErrFrameTooLarge
)
// AuthFunc is invoked by [Server] after parsing CLIENT_HELLO.
// It returns the session ID to send back to the client, or an error to reject
// the connection. The error's message is forwarded to the client as the
// reject reason, so it should not leak sensitive details.
type AuthFunc func(deviceID string, claims map[string]any) (sessionID string, err error)
// Client performs the client side of the handshake on rw and returns the
// session ID assigned by the server.
func Client(rw io.ReadWriter, deviceID string, claims map[string]any) (string, error) {
hello := Hello{
Version: ProtoVersion,
Type: TypeHello,
DeviceID: deviceID,
Claims: claims,
}
if err := writeFrame(rw, hello); err != nil {
return "", fmt.Errorf("send hello: %w", err)
}
raw, err := readFrame(rw)
if err != nil {
return "", fmt.Errorf("read welcome: %w", err)
}
var probe struct {
Type MsgType `json:"type"`
}
if err := json.Unmarshal(raw, &probe); err != nil {
return "", fmt.Errorf("parse reply: %w", err)
}
switch probe.Type {
case TypeHello:
return "", fmt.Errorf("%w: got %q", ErrUnexpectedMessage, probe.Type)
case TypeWelcome:
return parseWelcome(raw)
case TypeReject:
return parseReject(raw)
default:
return "", fmt.Errorf("%w: got %q", ErrUnexpectedMessage, probe.Type)
}
}
func parseWelcome(raw []byte) (string, error) {
var w Welcome
if err := json.Unmarshal(raw, &w); err != nil {
return "", fmt.Errorf("parse welcome: %w", err)
}
if w.Version != ProtoVersion {
return "", fmt.Errorf("%w: server v%d, client v%d",
ErrProtocolVersion, w.Version, ProtoVersion)
}
return w.SessionID, nil
}
func parseReject(raw []byte) (string, error) {
var r Reject
if err := json.Unmarshal(raw, &r); err != nil {
return "", fmt.Errorf("parse reject: %w", err)
}
return "", fmt.Errorf("%w: %s", ErrRejected, r.Reason)
}
// Server performs the server side of the handshake. It reads CLIENT_HELLO,
// invokes auth, and writes the corresponding WELCOME or REJECT. On success it
// returns the parsed Hello and the session ID produced by auth.
func Server(rw io.ReadWriter, auth AuthFunc) (Hello, string, error) {
raw, err := readFrame(rw)
if err != nil {
return Hello{}, "", fmt.Errorf("read hello: %w", err)
}
var h Hello
if err := json.Unmarshal(raw, &h); err != nil {
_ = writeFrame(rw, Reject{Version: ProtoVersion, Type: TypeReject, Reason: "malformed hello"})
return Hello{}, "", fmt.Errorf("parse hello: %w", err)
}
if h.Type != TypeHello {
_ = writeFrame(rw, Reject{Version: ProtoVersion, Type: TypeReject, Reason: "expected CLIENT_HELLO"})
return h, "", fmt.Errorf("%w: got %q", ErrUnexpectedMessage, h.Type)
}
if h.Version != ProtoVersion {
_ = writeFrame(rw, Reject{Version: ProtoVersion, Type: TypeReject, Reason: "protocol version mismatch"})
return h, "", fmt.Errorf("%w: client v%d, server v%d",
ErrProtocolVersion, h.Version, ProtoVersion)
}
sessionID, err := auth(h.DeviceID, h.Claims)
if err != nil {
_ = writeFrame(rw, Reject{Version: ProtoVersion, Type: TypeReject, Reason: err.Error()})
return h, "", fmt.Errorf("auth: %w", err)
}
if err := writeFrame(rw, Welcome{
Version: ProtoVersion,
Type: TypeWelcome,
SessionID: sessionID,
}); err != nil {
return h, sessionID, fmt.Errorf("send welcome: %w", err)
}
return h, sessionID, nil
}
func writeFrame(w io.Writer, msg any) error {
if err := framing.WriteJSON(w, msg, MaxMessageSize); err != nil {
return fmt.Errorf("handshake: %w", err)
}
return nil
}
func readFrame(r io.Reader) ([]byte, error) {
body, err := framing.ReadBytes(r, MaxMessageSize)
if err != nil {
return nil, fmt.Errorf("handshake: %w", err)
}
return body, nil
}

View File

@@ -0,0 +1,132 @@
package handshake
import (
"errors"
"io"
"net"
"strings"
"testing"
)
const testSessionID = "sess-42"
var errNope = errors.New("nope")
func pair(t *testing.T) (net.Conn, net.Conn) {
t.Helper()
a, b := net.Pipe()
t.Cleanup(func() {
_ = a.Close()
_ = b.Close()
})
return a, b
}
func TestHandshakeRoundTrip(t *testing.T) {
cConn, sConn := pair(t)
go func() {
hello, sid, err := Server(sConn, func(deviceID string, claims map[string]any) (string, error) {
if deviceID != "dev-1" {
t.Errorf("device id = %q", deviceID)
}
if claims["plan"] != "pro" {
t.Errorf("claims = %v", claims)
}
return testSessionID, nil
})
if err != nil {
t.Errorf("Server: %v", err)
}
if hello.DeviceID != "dev-1" || sid != testSessionID {
t.Errorf("Server returned hello=%+v sid=%q", hello, sid)
}
}()
sid, err := Client(cConn, "dev-1", map[string]any{"plan": "pro"})
if err != nil {
t.Fatalf("Client: %v", err)
}
if sid != testSessionID {
t.Fatalf("session id = %q, want sess-42", sid)
}
}
func TestHandshakeRejected(t *testing.T) {
cConn, sConn := pair(t)
go func() {
_, _, _ = Server(sConn, func(string, map[string]any) (string, error) {
return "", errNope
})
}()
_, err := Client(cConn, "dev-1", nil)
if !errors.Is(err, ErrRejected) {
t.Fatalf("Client err = %v, want ErrRejected", err)
}
if !strings.Contains(err.Error(), "nope") {
t.Fatalf("err message %q missing reason", err.Error())
}
}
func TestHandshakeProtocolMismatch(t *testing.T) {
cConn, sConn := pair(t)
go func() {
_ = writeFrame(cConn, Hello{Version: 999, Type: TypeHello, DeviceID: "dev"})
_, _ = readFrame(cConn) // drain server's REJECT so its write does not block
}()
_, _, err := Server(sConn, func(string, map[string]any) (string, error) {
t.Fatal("auth must not be invoked on protocol mismatch")
return "", nil
})
if !errors.Is(err, ErrProtocolVersion) {
t.Fatalf("Server err = %v, want ErrProtocolVersion", err)
}
}
func TestHandshakeUnexpectedType(t *testing.T) {
cConn, sConn := pair(t)
go func() {
_ = writeFrame(cConn, Hello{Version: ProtoVersion, Type: "BOGUS", DeviceID: "dev"})
_, _ = readFrame(cConn) // drain server's REJECT
}()
_, _, err := Server(sConn, func(string, map[string]any) (string, error) {
t.Fatal("auth must not be invoked on bad type")
return "", nil
})
if !errors.Is(err, ErrUnexpectedMessage) {
t.Fatalf("Server err = %v, want ErrUnexpectedMessage", err)
}
}
func TestReadFrameTooLarge(t *testing.T) {
cConn, sConn := pair(t)
go func() {
var hdr [4]byte
hdr[0] = 0xff
hdr[1] = 0xff
_, _ = cConn.Write(hdr[:])
_ = cConn.Close()
}()
_, err := readFrame(sConn)
if !errors.Is(err, ErrFrameTooLarge) {
t.Fatalf("readFrame err = %v, want ErrFrameTooLarge", err)
}
}
func TestReadFrameEOF(t *testing.T) {
cConn, sConn := pair(t)
_ = cConn.Close()
_, err := readFrame(sConn)
if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrClosedPipe) {
t.Fatalf("readFrame err = %v", err)
}
}

View File

@@ -1,78 +0,0 @@
// Package direct provides a pass-through link implementation above transports.
package direct
import (
"context"
"fmt"
"github.com/openlibrecommunity/olcrtc/internal/link"
"github.com/openlibrecommunity/olcrtc/internal/transport"
)
type directLink struct {
transport transport.Transport
}
// New creates a direct link that forwards bytes to the selected transport.
func New(ctx context.Context, cfg link.Config) (link.Link, error) {
tr, err := transport.New(ctx, cfg.Transport, transport.Config{
Carrier: cfg.Carrier,
RoomURL: cfg.RoomURL,
ClientID: cfg.ClientID,
Name: cfg.Name,
OnData: cfg.OnData,
DNSServer: cfg.DNSServer,
ProxyAddr: cfg.ProxyAddr,
ProxyPort: cfg.ProxyPort,
VideoWidth: cfg.VideoWidth,
VideoHeight: cfg.VideoHeight,
VideoFPS: cfg.VideoFPS,
VideoBitrate: cfg.VideoBitrate,
VideoHW: cfg.VideoHW,
VideoQRSize: cfg.VideoQRSize,
VideoQRRecovery: cfg.VideoQRRecovery,
VideoCodec: cfg.VideoCodec,
VideoTileModule: cfg.VideoTileModule,
VideoTileRS: cfg.VideoTileRS,
VP8FPS: cfg.VP8FPS,
VP8BatchSize: cfg.VP8BatchSize,
SEIFPS: cfg.SEIFPS,
SEIBatchSize: cfg.SEIBatchSize,
SEIFragmentSize: cfg.SEIFragmentSize,
SEIAckTimeoutMS: cfg.SEIAckTimeoutMS,
})
if err != nil {
return nil, fmt.Errorf("create transport for direct link: %w", err)
}
return &directLink{transport: tr}, nil
}
func (d *directLink) Connect(ctx context.Context) error {
if err := d.transport.Connect(ctx); err != nil {
return fmt.Errorf("transport connect: %w", err)
}
return nil
}
func (d *directLink) Send(data []byte) error {
if err := d.transport.Send(data); err != nil {
return fmt.Errorf("transport send: %w", err)
}
return nil
}
func (d *directLink) Close() error {
if err := d.transport.Close(); err != nil {
return fmt.Errorf("transport close: %w", err)
}
return nil
}
func (d *directLink) SetReconnectCallback(cb func()) { d.transport.SetReconnectCallback(cb) }
func (d *directLink) SetShouldReconnect(fn func() bool) { d.transport.SetShouldReconnect(fn) }
func (d *directLink) SetEndedCallback(cb func(string)) { d.transport.SetEndedCallback(cb) }
func (d *directLink) WatchConnection(ctx context.Context) {
d.transport.WatchConnection(ctx)
}
func (d *directLink) CanSend() bool { return d.transport.CanSend() }

View File

@@ -1,145 +0,0 @@
package direct
import (
"context"
"errors"
"testing"
"github.com/openlibrecommunity/olcrtc/internal/link"
"github.com/openlibrecommunity/olcrtc/internal/transport"
)
var (
errDirectBoom = errors.New("boom")
errDirectConnectBoom = errors.New("connect boom")
errDirectSendBoom = errors.New("send boom")
errDirectCloseBoom = errors.New("close boom")
)
type stubTransport struct {
connectErr error
sendErr error
closeErr error
canSend bool
connectCalled bool
sendData []byte
watched bool
reconnectCB func()
shouldFn func() bool
endedCB func(string)
}
func (s *stubTransport) Connect(context.Context) error {
s.connectCalled = true
return s.connectErr
}
func (s *stubTransport) Send(data []byte) error {
s.sendData = append([]byte(nil), data...)
return s.sendErr
}
func (s *stubTransport) Close() error { return s.closeErr }
func (s *stubTransport) SetReconnectCallback(cb func()) {
s.reconnectCB = cb
}
func (s *stubTransport) SetShouldReconnect(fn func() bool) { s.shouldFn = fn }
func (s *stubTransport) SetEndedCallback(cb func(string)) { s.endedCB = cb }
func (s *stubTransport) WatchConnection(context.Context) { s.watched = true }
func (s *stubTransport) CanSend() bool { return s.canSend }
func (s *stubTransport) Features() transport.Features { return transport.Features{} }
//nolint:cyclop // table-driven test naturally has many branches
func TestNewForwardsConfigAndMethods(t *testing.T) {
name := "direct-test-forward"
var seen transport.Config
tr := &stubTransport{canSend: true}
transport.Register(name, func(_ context.Context, cfg transport.Config) (transport.Transport, error) {
seen = cfg
return tr, nil
})
ln, err := New(context.Background(), link.Config{
Transport: name,
Carrier: "carrier",
RoomURL: "room",
ClientID: "client",
Name: "peer",
DNSServer: "1.1.1.1:53",
ProxyAddr: "127.0.0.1",
ProxyPort: 1080,
VideoWidth: 640,
VideoHeight: 480,
VideoFPS: 30,
VideoBitrate: "1M",
VideoHW: "none",
VideoQRSize: 4,
VideoQRRecovery: "low",
VideoCodec: "qrcode",
VideoTileModule: 3,
VideoTileRS: 20,
VP8FPS: 25,
VP8BatchSize: 8,
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
if seen.ClientID != "client" || seen.ProxyPort != 1080 || seen.VideoTileRS != 20 || seen.VP8BatchSize != 8 {
t.Fatalf("forwarded config = %+v", seen)
}
if err := ln.Connect(context.Background()); err != nil {
t.Fatalf("Connect() error = %v", err)
}
if !tr.connectCalled {
t.Fatal("Connect() was not forwarded")
}
if err := ln.Send([]byte("payload")); err != nil {
t.Fatalf("Send() error = %v", err)
}
if string(tr.sendData) != "payload" {
t.Fatalf("Send() forwarded %q, want payload", tr.sendData)
}
ln.SetReconnectCallback(func() {})
ln.SetShouldReconnect(func() bool { return true })
ln.SetEndedCallback(func(string) {})
ln.WatchConnection(context.Background())
if tr.reconnectCB == nil || tr.shouldFn == nil || tr.endedCB == nil || !tr.watched {
t.Fatal("callbacks/watch were not forwarded")
}
if !ln.CanSend() {
t.Fatal("CanSend() = false, want true")
}
}
func TestNewWrapsFactoryError(t *testing.T) {
name := "direct-test-error"
transport.Register(name, func(context.Context, transport.Config) (transport.Transport, error) {
return nil, errDirectBoom
})
_, err := New(context.Background(), link.Config{Transport: name})
if err == nil || err.Error() != "create transport for direct link: boom" {
t.Fatalf("New() error = %v", err)
}
}
func TestDirectLinkWrapsTransportErrors(t *testing.T) {
ln := &directLink{transport: &stubTransport{
connectErr: errDirectConnectBoom,
sendErr: errDirectSendBoom,
closeErr: errDirectCloseBoom,
}}
if err := ln.Connect(context.Background()); err == nil || err.Error() != "transport connect: connect boom" {
t.Fatalf("Connect() error = %v", err)
}
if err := ln.Send([]byte("x")); err == nil || err.Error() != "transport send: send boom" {
t.Fatalf("Send() error = %v", err)
}
if err := ln.Close(); err == nil || err.Error() != "transport close: close boom" {
t.Fatalf("Close() error = %v", err)
}
}

View File

@@ -1,81 +0,0 @@
// Package link defines link-layer abstractions above transports.
package link
import (
"context"
"errors"
)
var (
// ErrLinkNotFound is returned when a requested link is not registered.
ErrLinkNotFound = errors.New("link not found")
)
// Link defines a byte link above a transport.
type Link interface {
Connect(ctx context.Context) error
Send(data []byte) error
Close() error
SetReconnectCallback(cb func())
SetShouldReconnect(fn func() bool)
SetEndedCallback(cb func(string))
WatchConnection(ctx context.Context)
CanSend() bool
}
// Config holds common link configuration.
type Config struct {
Transport string
Carrier string
RoomURL string
ClientID string
Name string
OnData func([]byte)
DNSServer string
ProxyAddr string
ProxyPort int
VideoWidth int
VideoHeight int
VideoFPS int
VideoBitrate string
VideoHW string
VideoQRSize int
VideoQRRecovery string
VideoCodec string
VideoTileModule int
VideoTileRS int
VP8FPS int
VP8BatchSize int
SEIFPS int
SEIBatchSize int
SEIFragmentSize int
SEIAckTimeoutMS int
}
// Factory creates a link instance.
type Factory func(ctx context.Context, cfg Config) (Link, error)
var registry = make(map[string]Factory) //nolint:gochecknoglobals // package-level state intentional
// Register adds a link factory to the registry.
func Register(name string, factory Factory) {
registry[name] = factory
}
// New creates a link instance by name.
func New(ctx context.Context, name string, cfg Config) (Link, error) {
factory, ok := registry[name]
if !ok {
return nil, ErrLinkNotFound
}
return factory(ctx, cfg)
}
// Available returns a list of registered link names.
func Available() []string {
names := make([]string, 0, len(registry))
for name := range registry {
names = append(names, name)
}
return names
}

View File

@@ -1,71 +0,0 @@
package link
import (
"context"
"errors"
"reflect"
"testing"
)
type stubLink struct{}
func (s *stubLink) Connect(context.Context) error { return nil }
func (s *stubLink) Send([]byte) error { return nil }
func (s *stubLink) Close() error { return nil }
func (s *stubLink) SetReconnectCallback(func()) {}
func (s *stubLink) SetShouldReconnect(func() bool) {}
func (s *stubLink) SetEndedCallback(func(string)) {}
func (s *stubLink) WatchConnection(context.Context) {}
func (s *stubLink) CanSend() bool { return true }
func snapshotLinkRegistry() map[string]Factory {
out := make(map[string]Factory, len(registry))
for k, v := range registry {
out[k] = v
}
return out
}
func restoreLinkRegistry(src map[string]Factory) {
registry = make(map[string]Factory, len(src))
for k, v := range src {
registry[k] = v
}
}
func TestNewAndAvailable(t *testing.T) {
old := snapshotLinkRegistry()
t.Cleanup(func() { restoreLinkRegistry(old) })
called := false
Register("test-link", func(_ context.Context, cfg Config) (Link, error) {
called = cfg.ClientID == "client-1"
return &stubLink{}, nil
})
got, err := New(context.Background(), "test-link", Config{ClientID: "client-1"})
if err != nil {
t.Fatalf("New() error = %v", err)
}
if !called {
t.Fatal("factory did not receive config")
}
if _, ok := got.(*stubLink); !ok {
t.Fatalf("New() returned %T, want *stubLink", got)
}
if !reflect.DeepEqual(Available(), []string{"test-link"}) {
t.Fatalf("Available() = %#v, want %#v", Available(), []string{"test-link"})
}
}
func TestNewReturnsErrLinkNotFound(t *testing.T) {
old := snapshotLinkRegistry()
t.Cleanup(func() { restoreLinkRegistry(old) })
registry = map[string]Factory{}
_, err := New(context.Background(), "missing", Config{})
if !errors.Is(err, ErrLinkNotFound) {
t.Fatalf("New() error = %v, want %v", err, ErrLinkNotFound)
}
}

View File

@@ -2,13 +2,84 @@
package logger
import (
"fmt"
"log"
"os"
"strings"
"sync/atomic"
"github.com/pion/logging"
)
// verboseEnabled controls whether verbose and debug logging is enabled.
var verboseEnabled atomic.Bool //nolint:gochecknoglobals // package-level state intentional
// DisableNoisyPionLogs suppresses Pion scopes that are known to emit
// high-volume non-actionable background noise.
func DisableNoisyPionLogs() {
mergePionLogDisable("turnc")
removePionLogScopes([]string{"turnc"}, "ERROR", "WARN", "INFO", "DEBUG", "TRACE")
}
func mergePionLogDisable(scopes ...string) {
const envKey = "PION_LOG_DISABLE"
current := strings.TrimSpace(os.Getenv(envKey))
if strings.EqualFold(current, "all") {
return
}
seen := make(map[string]struct{})
var merged []string
for _, scope := range strings.Split(current, ",") {
scope = strings.TrimSpace(strings.ToLower(scope))
if scope == "" {
continue
}
seen[scope] = struct{}{}
merged = append(merged, scope)
}
for _, scope := range scopes {
scope = strings.TrimSpace(strings.ToLower(scope))
if scope == "" {
continue
}
if _, ok := seen[scope]; ok {
continue
}
seen[scope] = struct{}{}
merged = append(merged, scope)
}
_ = os.Setenv(envKey, strings.Join(merged, ","))
}
func removePionLogScopes(scopes []string, levels ...string) {
remove := make(map[string]struct{}, len(scopes))
for _, scope := range scopes {
scope = strings.TrimSpace(strings.ToLower(scope))
if scope != "" {
remove[scope] = struct{}{}
}
}
for _, level := range levels {
envKey := "PION_LOG_" + level
current := strings.TrimSpace(os.Getenv(envKey))
if current == "" || strings.EqualFold(current, "all") {
continue
}
var kept []string
for _, scope := range strings.Split(current, ",") {
scope = strings.TrimSpace(strings.ToLower(scope))
if scope == "" {
continue
}
if _, drop := remove[scope]; drop {
continue
}
kept = append(kept, scope)
}
_ = os.Setenv(envKey, strings.Join(kept, ","))
}
}
// SetVerbose enables or disables verbose/debug logging.
func SetVerbose(enabled bool) {
verboseEnabled.Store(enabled)
@@ -62,3 +133,110 @@ func Debugf(format string, v ...any) {
log.Printf(format, v...)
}
}
// PionLoggerFactory implements a dummy logger factory for pion.
type PionLoggerFactory struct{}
// NewPionLoggerFactory creates a new PionLoggerFactory.
func NewPionLoggerFactory() logging.LoggerFactory {
return &PionLoggerFactory{}
}
// NewLogger creates a new logger for the given scope.
func (f *PionLoggerFactory) NewLogger(scope string) logging.LeveledLogger {
return &PionLeveledLogger{scope: scope}
}
// PionLeveledLogger implements a leveled logger that redirects to the standard log package.
type PionLeveledLogger struct {
scope string
}
// Trace logs a trace message.
func (l *PionLeveledLogger) Trace(msg string) {
if verboseEnabled.Load() {
log.Printf("[%s] TRACE: %s", l.scope, msg)
}
}
// Tracef logs a formatted trace message.
func (l *PionLeveledLogger) Tracef(format string, args ...any) {
if verboseEnabled.Load() {
log.Printf("[%s] TRACE: %s", l.scope, fmt.Sprintf(format, args...))
}
}
// Debug logs a debug message.
func (l *PionLeveledLogger) Debug(msg string) {
if verboseEnabled.Load() {
log.Printf("[%s] DEBUG: %s", l.scope, msg)
}
}
// Debugf logs a formatted debug message.
func (l *PionLeveledLogger) Debugf(format string, args ...any) {
if verboseEnabled.Load() {
log.Printf("[%s] DEBUG: %s", l.scope, fmt.Sprintf(format, args...))
}
}
// Info logs an info message.
func (l *PionLeveledLogger) Info(msg string) {
if shouldDropPionLog(l.scope, msg) {
return
}
log.Printf("[%s] INFO: %s", l.scope, msg)
}
// Infof logs a formatted info message.
func (l *PionLeveledLogger) Infof(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
if shouldDropPionLog(l.scope, msg) {
return
}
log.Printf("[%s] INFO: %s", l.scope, msg)
}
// Warn logs a warning message.
func (l *PionLeveledLogger) Warn(msg string) {
if shouldDropPionLog(l.scope, msg) {
return
}
log.Printf("[%s] WARN: %s", l.scope, msg)
}
// Warnf logs a formatted warning message.
func (l *PionLeveledLogger) Warnf(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
if shouldDropPionLog(l.scope, msg) {
return
}
log.Printf("[%s] WARN: %s", l.scope, msg)
}
// Error logs an error message.
func (l *PionLeveledLogger) Error(msg string) {
if shouldDropPionLog(l.scope, msg) {
return
}
log.Printf("[%s] ERROR: %s", l.scope, msg)
}
// Errorf logs a formatted error message.
func (l *PionLeveledLogger) Errorf(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
if shouldDropPionLog(l.scope, msg) {
return
}
log.Printf("[%s] ERROR: %s", l.scope, msg)
}
func shouldDropPionLog(scope, msg string) bool {
scope = strings.ToLower(scope)
if scope == "srtp" || scope == "turnc" {
return true
}
msg = strings.ToLower(msg)
return strings.Contains(msg, "refresh permissions") ||
strings.Contains(msg, "createpermission error response")
}

View File

@@ -3,6 +3,7 @@ package logger
import (
"bytes"
"log"
"os"
"strings"
"testing"
)
@@ -70,3 +71,37 @@ func TestVerboseAndDebugLogging(t *testing.T) {
}
}
}
func TestPionLoggerDropsTURNRefreshNoise(t *testing.T) {
buf := captureLogs(t)
turnc := NewPionLoggerFactory().NewLogger("turnc")
turnc.Errorf("Fail to refresh permissions: %s", "CreatePermission error response")
ice := NewPionLoggerFactory().NewLogger("ice")
ice.Errorf("Fail to refresh permissions: %s", "CreatePermission error response")
ice.Warn("normal warning")
got := buf.String()
if strings.Contains(got, "turnc") || strings.Contains(got, "refresh permissions") {
t.Fatalf("unexpected TURN refresh noise in log output: %q", got)
}
if !strings.Contains(got, "normal warning") {
t.Fatalf("expected normal warning to pass through, got %q", got)
}
}
func TestDisableNoisyPionLogsMergesTurncScope(t *testing.T) {
t.Setenv("PION_LOG_DISABLE", "ice")
t.Setenv("PION_LOG_ERROR", "turnc,ice")
DisableNoisyPionLogs()
got := os.Getenv("PION_LOG_DISABLE")
if !strings.Contains(got, "ice") || !strings.Contains(got, "turnc") {
t.Fatalf("PION_LOG_DISABLE = %q, want ice and turnc", got)
}
if got := os.Getenv("PION_LOG_ERROR"); got != "ice" {
t.Fatalf("PION_LOG_ERROR = %q, want ice", got)
}
}

View File

@@ -24,16 +24,17 @@ import (
"time"
"github.com/openlibrecommunity/olcrtc/internal/crypto"
"github.com/openlibrecommunity/olcrtc/internal/link"
"github.com/openlibrecommunity/olcrtc/internal/logger"
"github.com/openlibrecommunity/olcrtc/internal/transport"
)
// ErrClosed is returned from Read/Write after the conn has been closed.
var ErrClosed = errors.New("muxconn: closed")
// Conn is an io.ReadWriteCloser over a link.Link with optional AEAD wrapping.
// Conn is an io.ReadWriteCloser over a [transport.Transport] with optional AEAD wrapping.
type Conn struct {
ln link.Link
ln transport.Transport
send func([]byte) error
cipher *crypto.Cipher
mu sync.Mutex
@@ -42,14 +43,40 @@ type Conn struct {
closed bool
}
// New wires a Conn over the given link. Push must be set as the link's OnData
// callback before this conn is used.
func New(ln link.Link, cipher *crypto.Cipher) *Conn {
c := &Conn{ln: ln, cipher: cipher}
// New wires a Conn over the given transport. Push must be set as the
// transport's OnData callback before this conn is used.
func New(ln transport.Transport, cipher *crypto.Cipher) *Conn {
c := &Conn{ln: ln, send: ln.Send, cipher: cipher}
c.cond = sync.NewCond(&c.mu)
return c
}
// NewPeer wires a Conn whose writes are addressed to a specific transport peer.
func NewPeer(ln transport.PeerTransport, cipher *crypto.Cipher, peerID string) *Conn {
c := &Conn{
ln: ln,
send: func(data []byte) error {
return ln.SendTo(peerID, data)
},
cipher: cipher,
}
c.cond = sync.NewCond(&c.mu)
return c
}
// Reset clears any buffered inbound bytes, re-arms a closed conn for writes,
// and unblocks pending Reads so the smux session on top of it exits cleanly.
// Use it when the link stays up but the peer's smux session has been rebuilt:
// the inbound byte stream (now indistinguishable random-looking data) must be
// parsed by the fresh smux state, not the old one.
func (c *Conn) Reset() {
c.mu.Lock()
c.buf = nil
c.closed = false
c.cond.Broadcast()
c.mu.Unlock()
}
// Push hands an encrypted wire payload (one OnData event) to the conn.
func (c *Conn) Push(ciphertext []byte) {
pt, err := c.cipher.Decrypt(ciphertext)
@@ -110,7 +137,7 @@ func (c *Conn) Write(p []byte) (int, error) {
if err != nil {
return 0, fmt.Errorf("encrypt: %w", err)
}
if err := c.ln.Send(enc); err != nil {
if err := c.send(enc); err != nil {
return 0, fmt.Errorf("send: %w", err)
}
return len(p), nil

Some files were not shown because too many files have changed in this diff Show More