// Package ws provides the LiveKit companion process manager. // // LiveKitProcess manages the lifecycle of a livekit-server binary running // alongside chatserver. It auto-generates a minimal livekit.yaml config, // starts the process, monitors health, and restarts on crash. package ws import ( "context" "fmt" "log/slog" "net/http" "os" "os/exec" "path/filepath" "strings" "time" "github.com/owncord/server/config" "github.com/owncord/server/syncutil" ) // LiveKitProcess manages the companion livekit-server binary. type LiveKitProcess struct { cfg *config.VoiceConfig tlsCfg *config.TLSConfig dataDir string httpClient *http.Client // for health checks — no redirect following mu syncutil.Mutex cmd *exec.Cmd cancel context.CancelFunc stopped bool runDone chan struct{} // closed by runLoop when cmd.Run() returns loopDone chan struct{} // closed when runLoop exits entirely } // NewLiveKitProcess creates a new process manager. It does not start the // process — call Start() to launch the LiveKit server. // The tlsCfg is used to configure TLS on the LiveKit server using the same // certs as OwnCord (avoids mixed-content blocks in WebView2). func NewLiveKitProcess(cfg *config.VoiceConfig, tlsCfg *config.TLSConfig, dataDir string) *LiveKitProcess { return &LiveKitProcess{ cfg: cfg, tlsCfg: tlsCfg, dataDir: dataDir, httpClient: &http.Client{ CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, }, } } // autoGeneratedMarker identifies a livekit.yaml written by OwnCord. A file // without this marker is treated as user-managed and never overwritten. const autoGeneratedMarker = "Auto-generated by OwnCord" // generateConfig writes a minimal livekit.yaml for the companion process. // If the file exists and lacks the auto-generated marker, it is treated as // user-managed and left untouched so operators can set LiveKit options // OwnCord does not model (ips.includes, interfaces, stun_servers, ...). func (p *LiveKitProcess) generateConfig() (string, error) { cfgPath := filepath.Join(p.dataDir, "livekit.yaml") if existing, err := os.ReadFile(cfgPath); err == nil { // An empty/whitespace-only file is a truncated leftover, not a // user-managed config — regenerate it rather than wedging LiveKit. if len(strings.TrimSpace(string(existing))) > 0 && !strings.Contains(string(existing), autoGeneratedMarker) { slog.Info("livekit: livekit.yaml is user-managed (auto-generated marker absent), not overwriting", "path", cfgPath) slog.Warn("livekit: ensure the keys entry in your livekit.yaml matches voice.livekit_api_key / voice.livekit_api_secret") return cfgPath, nil } } else if !os.IsNotExist(err) { return "", fmt.Errorf("reading existing livekit config: %w", err) } // No TURN TLS config — LiveKit signaling is proxied through OwnCord's // HTTPS server at /livekit/*, so no separate TLS is needed on LiveKit. // Sanitize credentials for safe YAML interpolation: reject strings // containing characters that could break YAML structure. for _, cred := range []string{p.cfg.LiveKitAPIKey, p.cfg.LiveKitAPISecret} { for _, ch := range cred { if ch == ':' || ch == '#' || ch == '{' || ch == '}' || ch == '\n' || ch == '\r' || ch == '"' || ch == '\\' { return "", fmt.Errorf("LiveKit credential contains unsafe YAML character %q", string(ch)) } } } // Build node_ip line only when configured (required for remote users behind NAT). // Validate: must be a plain IP address (no YAML-breaking chars). nodeIPLine := "" if p.cfg.NodeIP != "" { for _, ch := range p.cfg.NodeIP { if ch == '"' || ch == '\\' || ch == '\n' || ch == '\r' || ch == '#' || ch == '{' || ch == '}' { return "", fmt.Errorf("node_ip contains unsafe character %q", string(ch)) } } nodeIPLine = fmt.Sprintf("\n node_ip: %q", p.cfg.NodeIP) } // Advertise LAN host candidates alongside the external mapping so clients // on the local network can reach a dual-homed (LAN + public IP) server. advertiseInternalLine := "" if p.cfg.AdvertiseInternalIP { advertiseInternalLine = "\n advertise_internal_ip: true" } content := fmt.Sprintf(`# Auto-generated by OwnCord — regenerated on every server start. # To manage this file yourself (custom rtc options, multiple interfaces, etc.), # delete the first line above; OwnCord will then leave the file untouched. # Your keys entry must still match voice.livekit_api_key / # voice.livekit_api_secret in config.yaml. port: 7880 rtc: port_range_start: 50000 port_range_end: 60000 use_external_ip: true%s%s pli_throttle: low_quality: 500ms mid_quality: 1s high_quality: 1s keys: "%s": "%s" logging: level: info `, nodeIPLine, advertiseInternalLine, p.cfg.LiveKitAPIKey, p.cfg.LiveKitAPISecret) if err := os.MkdirAll(p.dataDir, 0o750); err != nil { return "", fmt.Errorf("creating data dir: %w", err) } if err := os.WriteFile(cfgPath, []byte(content), 0o600); err != nil { return "", fmt.Errorf("writing livekit config: %w", err) } return cfgPath, nil } // Start launches the livekit-server binary. If LiveKitBinaryPath is empty, // this is a no-op (assumes LiveKit is managed externally). func (p *LiveKitProcess) Start() error { if p.cfg.LiveKitBinaryPath == "" { slog.Info("livekit: no binary path configured, assuming externally managed") return nil } p.mu.Lock() defer p.mu.Unlock() if p.cmd != nil { return fmt.Errorf("livekit process already running") } cfgPath, err := p.generateConfig() if err != nil { return fmt.Errorf("generating livekit config: %w", err) } ctx, cancel := context.WithCancel(context.Background()) p.cancel = cancel p.loopDone = make(chan struct{}) go p.runLoop(ctx, cfgPath) return nil } // runLoop starts and restarts the process until stopped or context cancelled. // Uses exponential backoff (3s → 6s → 12s … up to 60s) and stops after 10 // consecutive rapid failures (process exits within 30 seconds). func (p *LiveKitProcess) runLoop(ctx context.Context, cfgPath string) { defer func() { p.mu.Lock() if p.loopDone != nil { close(p.loopDone) } p.mu.Unlock() }() const ( baseDelay = 3 * time.Second maxDelay = 60 * time.Second maxRetries = 10 stableAfter = 30 * time.Second // reset counter if process runs longer than this ) rapidFailures := 0 delay := baseDelay for { if ctx.Err() != nil { return } cmd := exec.CommandContext(ctx, p.cfg.LiveKitBinaryPath, "--config", cfgPath) //nolint:gosec // G204: binary path from trusted server config cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.WaitDelay = 6 * time.Second // bound Wait to prevent goroutine leak on Windows p.mu.Lock() if p.stopped { p.mu.Unlock() return } p.cmd = cmd p.runDone = make(chan struct{}) p.mu.Unlock() slog.Info("livekit: starting process", "binary", p.cfg.LiveKitBinaryPath, "config", cfgPath, "rapid_failures", rapidFailures) startTime := time.Now() err := cmd.Run() p.mu.Lock() p.cmd = nil if p.runDone != nil { close(p.runDone) p.runDone = nil } stopped := p.stopped p.mu.Unlock() if stopped || ctx.Err() != nil { slog.Info("livekit: process stopped") return } // If the process ran for a while, it was stable — reset backoff. if time.Since(startTime) > stableAfter { rapidFailures = 0 delay = baseDelay } else { rapidFailures++ } if err != nil { slog.Error("livekit: process exited unexpectedly", "error", err, "rapid_failures", rapidFailures, "restart_delay", delay) } if rapidFailures >= maxRetries { slog.Error("livekit: too many rapid failures, giving up", "rapid_failures", rapidFailures) return } select { case <-time.After(delay): slog.Info("livekit: restarting process") case <-ctx.Done(): return } // Exponential backoff capped at maxDelay. delay *= 2 if delay > maxDelay { delay = maxDelay } } } // IsRunning returns true if the companion process is currently running. func (p *LiveKitProcess) IsRunning() bool { p.mu.Lock() defer p.mu.Unlock() return p.cmd != nil && p.cmd.Process != nil } // HealthCheck probes the LiveKit HTTP endpoint to verify it is accepting // connections. Returns true if the server responds (any status code). func (p *LiveKitProcess) HealthCheck(ctx context.Context) (bool, error) { httpURL := wsToHTTP(p.cfg.LiveKitURL) ctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, httpURL, nil) if err != nil { return false, fmt.Errorf("creating health check request: %w", err) } resp, err := p.httpClient.Do(req) if err != nil { return false, fmt.Errorf("livekit health check failed: %w", err) } _ = resp.Body.Close() return true, nil } // Stop gracefully stops the companion process. // It cancels the context (which signals runLoop) and waits up to 5 seconds // for the process to exit. The actual cmd.Wait() is done by runLoop via // cmd.Run() — we only monitor the process via cmd.Process.Wait() here to // avoid calling exec.Cmd.Wait() twice (which has undefined behavior). func (p *LiveKitProcess) Stop() { p.mu.Lock() p.stopped = true cancel := p.cancel cmd := p.cmd done := p.runDone loopDone := p.loopDone p.mu.Unlock() if cancel != nil { cancel() } // Wait for runLoop's cmd.Run() to return (which closes runDone). // This avoids calling cmd.Wait() or cmd.Process.Wait() from a second // goroutine, which is unsafe on Windows. if done != nil { select { case <-done: slog.Info("livekit: process exited cleanly") case <-time.After(5 * time.Second): slog.Warn("livekit: process did not exit in time, killing") if cmd != nil && cmd.Process != nil { _ = cmd.Process.Kill() } } } // Wait for the entire runLoop goroutine to finish, ensuring no // new iteration can start after Stop returns. if loopDone != nil { select { case <-loopDone: case <-time.After(5 * time.Second): } } }