From 359a2d94dfce4f60a6558aca27198b1ef9aca41f Mon Sep 17 00:00:00 2001 From: zarazaex69 Date: Wed, 13 May 2026 16:37:09 +0300 Subject: [PATCH] feat: add YAML configuration support --- cmd/olcrtc/main.go | 57 ++++++++++- docs/client.example.yaml | 60 +++++++++++ docs/configuration.md | 51 +++++++++ docs/server.example.yaml | 64 ++++++++++++ go.mod | 2 +- internal/config/config.go | 182 +++++++++++++++++++++++++++++++++ internal/config/config_test.go | 93 +++++++++++++++++ 7 files changed, 503 insertions(+), 6 deletions(-) create mode 100644 docs/client.example.yaml create mode 100644 docs/configuration.md create mode 100644 docs/server.example.yaml create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go diff --git a/cmd/olcrtc/main.go b/cmd/olcrtc/main.go index 251b28f..2be27b5 100644 --- a/cmd/olcrtc/main.go +++ b/cmd/olcrtc/main.go @@ -18,6 +18,7 @@ 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/transport/videochannel" @@ -35,6 +36,7 @@ var runSession = session.Run var runGen = execGen type config struct { + configPath string mode string link string transport string @@ -95,7 +97,42 @@ func runWithArgs(args []string) error { return runWithConfig(cfg) } +// applyConfigFile loads cfg.configPath (if set) and merges its values into scfg. +// CLI flags (already populated) take precedence over YAML. +func applyConfigFile(cfg config, scfg session.Config) (session.Config, error) { + if cfg.configPath == "" { + return scfg, nil + } + f, err := configpkg.Load(cfg.configPath) + if err != nil { + return scfg, fmt.Errorf("load config: %w", err) + } + return configpkg.Apply(scfg, f), nil +} + +// mergeFileMeta fills cmd-level fields (data dir, debug, ffmpeg) that aren't +// part of session.Config but still need to come from the YAML file. +func mergeFileMeta(cfg *config, f configpkg.File) { + if cfg.dataDir == "" { + cfg.dataDir = f.Data + } + if !cfg.debug { + cfg.debug = f.Debug + } + if (cfg.ffmpegPath == "" || cfg.ffmpegPath == "ffmpeg") && f.FFmpeg != "" { + cfg.ffmpegPath = f.FFmpeg + } +} + func runWithConfig(cfg config) error { + if cfg.configPath != "" { + f, err := configpkg.Load(cfg.configPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + mergeFileMeta(&cfg, f) + } + configureLogging(cfg.debug) if cfg.ffmpegPath != "ffmpeg" && cfg.ffmpegPath != "" { @@ -110,7 +147,11 @@ func runWithConfig(cfg config) error { } func runSessionMode(cfg config) error { - scfg, err := session.ApplyAuthDefaults(toSessionConfig(cfg)) + scfg, err := applyConfigFile(cfg, toSessionConfig(cfg)) + if err != nil { + return err + } + scfg, err = session.ApplyAuthDefaults(scfg) if err != nil { return fmt.Errorf("validate config: %w", err) } @@ -118,16 +159,17 @@ func runSessionMode(cfg config) error { return fmt.Errorf("validate config: %w", err) } - if cfg.dataDir == "" { + dataDir := cfg.dataDir + 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 } @@ -153,7 +195,11 @@ func runSessionMode(cfg config) error { } func execGen(cfg config) error { - scfg, err := session.ApplyAuthDefaults(toSessionConfig(cfg)) + scfg, err := applyConfigFile(cfg, toSessionConfig(cfg)) + if err != nil { + return err + } + scfg, err = session.ApplyAuthDefaults(scfg) if err != nil { return fmt.Errorf("validate gen config: %w", err) } @@ -188,6 +234,7 @@ func parseFlagsFrom(args []string, errorHandling flag.ErrorHandling) (config, er fs.SetOutput(io.Discard) } + fs.StringVar(&cfg.configPath, "config", "", "Path to YAML config file (CLI flags override file values)") 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") diff --git a/docs/client.example.yaml b/docs/client.example.yaml new file mode 100644 index 0000000..c5cf611 --- /dev/null +++ b/docs/client.example.yaml @@ -0,0 +1,60 @@ +# olcrtc client config example +# Run with: olcrtc -config client.yaml +# Any CLI flag (e.g. -key, -id) overrides the corresponding YAML field. + +mode: cnc + +link: direct +carrier: "" + +auth: + provider: wbstream # must match the server + +room: + id: "ROOM_ID_HERE" # must match the server + client_id: "default" # must match the server (deprecated) + +crypto: + key: "REPLACE_ME_WITH_64_HEX_CHARS" # must match the server + +net: + transport: datachannel # must match the server + dns: "8.8.8.8:53" + +# Local SOCKS5 listener exposed to applications +socks: + host: "127.0.0.1" + port: 8808 + user: "" # optional inbound auth + pass: "" + +# Direct engine mode — only when auth.provider is "none" +engine: + name: "" + url: "" + token: "" + +vp8: + fps: 25 + batch_size: 1 + +sei: + fps: 20 + batch_size: 1 + fragment_size: 900 + ack_timeout_ms: 3000 + +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 + +data: data +debug: false diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..f933b00 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,51 @@ +# Configuration + +olcrtc accepts the same settings via CLI flags or a YAML file. Use whichever +fits your deployment: + +```bash +# CLI flags (existing behaviour) +olcrtc -mode srv -auth wbstream -id room123 -key $(openssl rand -hex 32) ... + +# YAML file +olcrtc -config /etc/olcrtc/server.yaml + +# YAML file plus CLI overrides — any flag wins over the corresponding YAML field +olcrtc -config /etc/olcrtc/server.yaml -id room999 +``` + +Examples: + +- [`server.example.yaml`](./server.example.yaml) +- [`client.example.yaml`](./client.example.yaml) + +## Schema + +| YAML path | CLI flag | Notes | +|----------------------------|----------------------|-----------------------------------------------| +| `mode` | `-mode` | `srv`, `cnc`, or `gen` | +| `link` | `-link` | `direct` | +| `auth.provider` | `-auth` | `telemost`, `jazz`, `wbstream`, `none` | +| `room.id` | `-id` | conference room id | +| `room.client_id` | `-client-id` | deprecated, will be removed | +| `crypto.key` | `-key` | 64-char hex (32 bytes) | +| `net.transport` | `-transport` | `datachannel`, `videochannel`, `seichannel`, `vp8channel` | +| `net.dns` | `-dns` | resolver `host:port` | +| `socks.host` / `.port` | `-socks-host` / `-socks-port` | client-side listener | +| `socks.user` / `.pass` | `-socks-user` / `-socks-pass` | optional client-side auth | +| `socks.proxy_addr` / `.proxy_port` | `-socks-proxy` / `-socks-proxy-port` | server-side egress proxy | +| `engine.name` / `.url` / `.token` | `-engine` / `-url` / `-token` | only when `auth.provider: none` | +| `video.*` | `-video-*` | videochannel tuning | +| `vp8.*` | `-vp8-*` | vp8channel tuning | +| `sei.fps` / `.batch_size` / `.fragment_size` / `.ack_timeout_ms` | `-fps` / `-batch` / `-frag` / `-ack-ms` | seichannel tuning | +| `gen.amount` | `-amount` | gen mode: number of rooms to create | +| `data` | `-data` | path to data directory | +| `debug` | `-debug` | verbose logging | +| `ffmpeg` | `-ffmpeg` | path to ffmpeg binary | + +## Precedence + +`CLI flag (non-zero) > YAML value > zero value`. + +A CLI flag with its zero value (e.g. `-socks-port 0`) does NOT override a YAML +value — pass an explicit non-zero value to override. diff --git a/docs/server.example.yaml b/docs/server.example.yaml new file mode 100644 index 0000000..af44256 --- /dev/null +++ b/docs/server.example.yaml @@ -0,0 +1,64 @@ +# olcrtc server config example +# Run with: olcrtc -config server.yaml +# Any CLI flag (e.g. -key, -id) overrides the corresponding YAML field. + +mode: srv + +# Connection topology +link: direct # p2p link type +carrier: "" # leave empty for default selection from auth provider + +auth: + provider: wbstream # telemost | jazz | wbstream | none + +room: + id: "ROOM_ID_HERE" + client_id: "default" # deprecated: server identifier (will be removed in upcoming refactor) + +crypto: + # 32-byte hex (64 chars). Generate with: openssl rand -hex 32 + key: "REPLACE_ME_WITH_64_HEX_CHARS" + +net: + transport: datachannel # datachannel | videochannel | seichannel | vp8channel + dns: "8.8.8.8:53" + +# Outbound SOCKS5 proxy for server-side egress (optional) +socks: + proxy_addr: "" # e.g. "127.0.0.1" + proxy_port: 0 # e.g. 1080 + +# Direct engine mode — only used when auth.provider is "none" +engine: + name: "" # livekit | goolom | salutejazz + url: "" + token: "" + +# vp8channel tuning (only when net.transport == vp8channel) +vp8: + fps: 25 + batch_size: 1 + +# seichannel tuning (only when net.transport == seichannel) +sei: + fps: 20 + batch_size: 1 + fragment_size: 900 + ack_timeout_ms: 3000 + +# videochannel tuning (only when net.transport == videochannel) +video: + width: 1920 + height: 1080 + fps: 30 + bitrate: "2M" + hw: none # none | nvenc + codec: qrcode # qrcode | tile (tile requires 1080x1080) + qr_size: 0 # 0 = auto + qr_recovery: low # low (7%) | medium (15%) | high (25%) | highest (30%) + tile_module: 4 # 1..270, only for codec: tile + tile_rs: 20 # 0..200, only for codec: tile + +data: data # data directory (names files etc.) +debug: false +ffmpeg: ffmpeg # path to ffmpeg binary (only used by videochannel) diff --git a/go.mod b/go.mod index 2b715f9..f1e9288 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( golang.org/x/crypto v0.50.0 golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b google.golang.org/genproto v0.0.0-20260209200024-4cfbd4190f57 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -88,6 +89,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 ) diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3a061f9 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,182 @@ +// 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, allowing CLI flags to fill them in. Use +// [Apply] to merge a parsed [File] onto an existing [session.Config]; +// non-zero fields in the session config (typically populated from CLI flags) +// take precedence over the YAML values. +package config + +import ( + "errors" + "fmt" + "os" + + "github.com/openlibrecommunity/olcrtc/internal/app/session" + "gopkg.in/yaml.v3" +) + +// ErrConfigNotFound is returned when a config file path is set but the file does not exist. +var ErrConfigNotFound = errors.New("config file not found") + +// File is the on-disk YAML schema. +type File struct { + Mode string `yaml:"mode"` + Link string `yaml:"link"` + Carrier string `yaml:"carrier"` + 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"` + Gen Gen `yaml:"gen"` + Data string `yaml:"data"` + Debug bool `yaml:"debug"` + FFmpeg string `yaml:"ffmpeg"` +} + +// Auth selects the auth provider. +type Auth struct { + Provider string `yaml:"provider"` // telemost, jazz, wbstream, none +} + +// Room identifies the conference room. +type Room struct { + ID string `yaml:"id"` + ClientID string `yaml:"client_id"` // deprecated: server identifier (will be removed) +} + +// Crypto holds the shared secret used to authenticate and encrypt the tunnel. +type Crypto struct { + Key string `yaml:"key"` // 64-char hex (32 bytes) +} + +// 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, salutejazz + 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"` +} + +// 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) { + 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) + } + var f File + if err := yaml.Unmarshal(data, &f); err != nil { + return File{}, fmt.Errorf("parse config %s: %w", path, err) + } + return f, 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.Link = pickString(dst.Link, f.Link) + 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.ClientID = pickString(dst.ClientID, f.Room.ClientID) + 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.VideoWidth = pickInt(dst.VideoWidth, f.Video.Width) + dst.VideoHeight = pickInt(dst.VideoHeight, f.Video.Height) + dst.VideoFPS = pickInt(dst.VideoFPS, f.Video.FPS) + dst.VideoBitrate = pickString(dst.VideoBitrate, f.Video.Bitrate) + dst.VideoHW = pickString(dst.VideoHW, f.Video.HW) + dst.VideoQRSize = pickInt(dst.VideoQRSize, f.Video.QRSize) + dst.VideoQRRecovery = pickString(dst.VideoQRRecovery, f.Video.QRRecovery) + dst.VideoCodec = pickString(dst.VideoCodec, f.Video.Codec) + dst.VideoTileModule = pickInt(dst.VideoTileModule, f.Video.TileModule) + dst.VideoTileRS = pickInt(dst.VideoTileRS, f.Video.TileRS) + dst.VP8FPS = pickInt(dst.VP8FPS, f.VP8.FPS) + dst.VP8BatchSize = pickInt(dst.VP8BatchSize, f.VP8.BatchSize) + dst.SEIFPS = pickInt(dst.SEIFPS, f.SEI.FPS) + dst.SEIBatchSize = pickInt(dst.SEIBatchSize, f.SEI.BatchSize) + dst.SEIFragmentSize = pickInt(dst.SEIFragmentSize, f.SEI.FragmentSize) + dst.SEIAckTimeoutMS = pickInt(dst.SEIAckTimeoutMS, f.SEI.AckTimeoutMS) + dst.Amount = pickInt(dst.Amount, f.Gen.Amount) + 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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..9c54d72 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,93 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/openlibrecommunity/olcrtc/internal/app/session" +) + +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 + client_id: c1 +crypto: + key: deadbeef +net: + transport: datachannel + dns: 1.1.1.1:53 +socks: + host: 127.0.0.1 + port: 1080 + user: u + pass: p +vp8: + fps: 25 + batch_size: 4 +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) + } + if f.Mode != "srv" || f.Auth.Provider != "wbstream" || f.Room.ID != "r1" || f.Crypto.Key != "deadbeef" { + t.Fatalf("unexpected file: %+v", f) + } + + got := Apply(session.Config{}, f) + if got.Mode != "srv" || got.Link != "direct" || got.Auth != "wbstream" || + got.RoomID != "r1" || got.ClientID != "c1" || got.KeyHex != "deadbeef" || + got.Transport != "datachannel" || got.DNSServer != "1.1.1.1:53" || + got.SOCKSHost != "127.0.0.1" || got.SOCKSPort != 1080 || + got.SOCKSUser != "u" || got.SOCKSPass != "p" || + got.VP8FPS != 25 || got.VP8BatchSize != 4 || got.Amount != 3 { + t.Fatalf("Apply produced wrong config: %+v", got) + } +} + +func TestApplyCLIWins(t *testing.T) { + cli := session.Config{ + Mode: "cnc", + KeyHex: "from-cli", + SOCKSPort: 9999, + } + f := File{ + Mode: "srv", + 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) + } +} + +func TestLoadMissing(t *testing.T) { + _, err := Load(filepath.Join(t.TempDir(), "nope.yaml")) + if err == nil { + t.Fatal("expected error for missing file") + } +}