mirror of
https://github.com/openlibrecommunity/olcrtc.git
synced 2026-06-06 12:29:44 +00:00
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:
99
.github/workflows/ci.yml
vendored
99
.github/workflows/ci.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -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/
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -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"]
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
54
cmd/olcrtc/stderr_filter_unix.go
Normal file
54
cmd/olcrtc/stderr_filter_unix.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
5
cmd/olcrtc/stderr_filter_windows.go
Normal file
5
cmd/olcrtc/stderr_filter_windows.go
Normal file
@@ -0,0 +1,5 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
func installStderrFilter() {}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
43
docker-compose.client.yml
Normal 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:
|
||||
@@ -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
|
||||
|
||||
1087
docs/about.md
1087
docs/about.md
File diff suppressed because it is too large
Load Diff
199
docs/configuration.md
Normal file
199
docs/configuration.md
Normal file
@@ -0,0 +1,199 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="https://github.com/openlibrecommunity/material/blob/master/olcrtc.png" width="250" height="250">
|
||||
|
||||

|
||||

|
||||
|
||||
</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 комнаты.
|
||||
34
docs/examples/client.jitsi.datachannel.yaml
Normal file
34
docs/examples/client.jitsi.datachannel.yaml
Normal 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
|
||||
40
docs/examples/client.jitsi.seichannel.yaml
Normal file
40
docs/examples/client.jitsi.seichannel.yaml
Normal 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
|
||||
47
docs/examples/client.jitsi.videochannel.yaml
Normal file
47
docs/examples/client.jitsi.videochannel.yaml
Normal 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
|
||||
38
docs/examples/client.jitsi.vp8channel.yaml
Normal file
38
docs/examples/client.jitsi.vp8channel.yaml
Normal 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
|
||||
34
docs/examples/client.telemost.datachannel.yaml
Normal file
34
docs/examples/client.telemost.datachannel.yaml
Normal 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
|
||||
40
docs/examples/client.telemost.seichannel.yaml
Normal file
40
docs/examples/client.telemost.seichannel.yaml
Normal 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
|
||||
47
docs/examples/client.telemost.videochannel.yaml
Normal file
47
docs/examples/client.telemost.videochannel.yaml
Normal 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
|
||||
38
docs/examples/client.telemost.vp8channel.yaml
Normal file
38
docs/examples/client.telemost.vp8channel.yaml
Normal 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
|
||||
34
docs/examples/client.wbstream.datachannel.yaml
Normal file
34
docs/examples/client.wbstream.datachannel.yaml
Normal 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
|
||||
40
docs/examples/client.wbstream.seichannel.yaml
Normal file
40
docs/examples/client.wbstream.seichannel.yaml
Normal 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
|
||||
47
docs/examples/client.wbstream.videochannel.yaml
Normal file
47
docs/examples/client.wbstream.videochannel.yaml
Normal 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
|
||||
38
docs/examples/client.wbstream.vp8channel.yaml
Normal file
38
docs/examples/client.wbstream.vp8channel.yaml
Normal 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
|
||||
48
docs/examples/failover.yaml
Normal file
48
docs/examples/failover.yaml
Normal 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
|
||||
33
docs/examples/server.jitsi.datachannel.yaml
Normal file
33
docs/examples/server.jitsi.datachannel.yaml
Normal 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
|
||||
39
docs/examples/server.jitsi.seichannel.yaml
Normal file
39
docs/examples/server.jitsi.seichannel.yaml
Normal 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
|
||||
46
docs/examples/server.jitsi.videochannel.yaml
Normal file
46
docs/examples/server.jitsi.videochannel.yaml
Normal 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
|
||||
37
docs/examples/server.jitsi.vp8channel.yaml
Normal file
37
docs/examples/server.jitsi.vp8channel.yaml
Normal 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
|
||||
33
docs/examples/server.telemost.datachannel.yaml
Normal file
33
docs/examples/server.telemost.datachannel.yaml
Normal 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
|
||||
39
docs/examples/server.telemost.seichannel.yaml
Normal file
39
docs/examples/server.telemost.seichannel.yaml
Normal 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
|
||||
46
docs/examples/server.telemost.videochannel.yaml
Normal file
46
docs/examples/server.telemost.videochannel.yaml
Normal 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
|
||||
37
docs/examples/server.telemost.vp8channel.yaml
Normal file
37
docs/examples/server.telemost.vp8channel.yaml
Normal 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
|
||||
33
docs/examples/server.wbstream.datachannel.yaml
Normal file
33
docs/examples/server.wbstream.datachannel.yaml
Normal 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
|
||||
39
docs/examples/server.wbstream.seichannel.yaml
Normal file
39
docs/examples/server.wbstream.seichannel.yaml
Normal 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
|
||||
46
docs/examples/server.wbstream.videochannel.yaml
Normal file
46
docs/examples/server.wbstream.videochannel.yaml
Normal 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
|
||||
37
docs/examples/server.wbstream.vp8channel.yaml
Normal file
37
docs/examples/server.wbstream.vp8channel.yaml
Normal 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
|
||||
166
docs/fast.md
166
docs/fast.md
@@ -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)
|
||||
|
||||
238
docs/manual.md
238
docs/manual.md
@@ -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)
|
||||
|
||||
437
docs/settings.md
437
docs/settings.md
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
12
docs/sub.md
12
docs/sub.md
@@ -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)
|
||||
|
||||
224
docs/uri.md
224
docs/uri.md
@@ -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
18
go.mod
@@ -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
18
go.sum
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
43
internal/app/session/transport_options.go
Normal file
43
internal/app/session/transport_options.go
Normal 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
95
internal/auth/auth.go
Normal 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
|
||||
}
|
||||
96
internal/auth/jitsi/jitsi.go
Normal file
96
internal/auth/jitsi/jitsi.go
Normal 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{})
|
||||
}
|
||||
88
internal/auth/jitsi/jitsi_test.go
Normal file
88
internal/auth/jitsi/jitsi_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
54
internal/auth/telemost/telemost.go
Normal file
54
internal/auth/telemost/telemost.go
Normal 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{})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
124
internal/auth/wbstream/api_test.go
Normal file
124
internal/auth/wbstream/api_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
54
internal/auth/wbstream/wbstream.go
Normal file
54
internal/auth/wbstream/wbstream.go
Normal 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{})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
362
internal/config/config.go
Normal 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
|
||||
}
|
||||
335
internal/config/config_test.go
Normal file
335
internal/config/config_test.go
Normal 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
349
internal/control/control.go
Normal 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
|
||||
}
|
||||
158
internal/control/control_test.go
Normal file
158
internal/control/control_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
317
internal/e2e/stress_test.go
Normal 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
148
internal/engine/builtin/builtin.go
Normal file
148
internal/engine/builtin/builtin.go
Normal 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
126
internal/engine/engine.go
Normal 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
|
||||
}
|
||||
438
internal/engine/goolom/lifecycle.go
Normal file
438
internal/engine/goolom/lifecycle.go
Normal 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)
|
||||
}
|
||||
}
|
||||
318
internal/engine/goolom/media.go
Normal file
318
internal/engine/goolom/media.go
Normal 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)
|
||||
}
|
||||
}
|
||||
322
internal/engine/goolom/session.go
Normal file
322
internal/engine/goolom/session.go
Normal 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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
303
internal/engine/goolom/signaling.go
Normal file
303
internal/engine/goolom/signaling.go
Normal 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
|
||||
}
|
||||
}
|
||||
246
internal/engine/goolom/state.go
Normal file
246
internal/engine/goolom/state.go
Normal 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"},
|
||||
}
|
||||
}
|
||||
339
internal/engine/jitsi/churn_test.go
Normal file
339
internal/engine/jitsi/churn_test.go
Normal 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
|
||||
45
internal/engine/jitsi/helpers_test.go
Normal file
45
internal/engine/jitsi/helpers_test.go
Normal 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)
|
||||
}
|
||||
1290
internal/engine/jitsi/jitsi.go
Normal file
1290
internal/engine/jitsi/jitsi.go
Normal file
File diff suppressed because it is too large
Load Diff
419
internal/engine/jitsi/jitsi_test.go
Normal file
419
internal/engine/jitsi/jitsi_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
532
internal/engine/livekit/livekit.go
Normal file
532
internal/engine/livekit/livekit.go
Normal 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)
|
||||
}
|
||||
321
internal/engine/livekit/livekit_test.go
Normal file
321
internal/engine/livekit/livekit_test.go
Normal 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
|
||||
}
|
||||
60
internal/framing/framing.go
Normal file
60
internal/framing/framing.go
Normal 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
|
||||
}
|
||||
77
internal/framing/framing_test.go
Normal file
77
internal/framing/framing_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
207
internal/handshake/handshake.go
Normal file
207
internal/handshake/handshake.go
Normal 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
|
||||
}
|
||||
132
internal/handshake/handshake_test.go
Normal file
132
internal/handshake/handshake_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user