Files
OwnCord/Server/ws/livekit_process.go
T
jevb 76cb9b9630 fix: security hardening, DM auth, LiveKit stability, and voice call timer
Security fixes (from multi-reviewer code review):
- Add DM participant auth checks to channel_focus, typing, and REST
  message endpoints — prevents unauthorized access to DM channels
- Fix TOCTOU race in GetOrCreateDMChannel using IMMEDIATE transaction
- Validate YAML credentials before LiveKit config interpolation
- Add CSS variable injection prevention in custom theme loader
- Validate localStorage JSON before unsafe type casts

LiveKit stability:
- Track remote mic audio elements for cleanup on abnormal disconnect
- Remove duplicate token refresh timer scheduling
- Add .catch() to all floating applyMicMuteState promises
- Clear reconnectAc after async post-connect work completes
- Fix double cmd.Wait() race in LiveKit process Stop()
- Reorder voice_join guards: validate channel before livekit==nil check
- Add startup warning for external LiveKit webhook CIDR mismatch

DM system fixes:
- Emit dm_channel_close WebSocket event from REST close handler
- Re-open DM for caller when channel already exists
- Fix unread count incrementing for own messages and active DMs
- Reset channelBeforeDm after Back navigation (stale state bug)

New feature:
- Voice call duration timer in VoiceWidget (MM:SS / HH:MM:SS elapsed)
- Accent color restored on app startup (was only applied in settings)

Test infrastructure:
- Add DM tables to all test schemas (hubTestSchema)
- Inject test LiveKit client in voice handler tests (fixes 28 failures)
2026-03-27 16:14:54 +01:00

278 lines
7.2 KiB
Go

// 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"
"sync"
"time"
"github.com/owncord/server/config"
)
// 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 sync.Mutex
cmd *exec.Cmd
cancel context.CancelFunc
stopped bool
runDone chan struct{} // closed by runLoop when cmd.Run() returns
}
// 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
},
},
}
}
// generateConfig writes a minimal livekit.yaml for the companion process.
func (p *LiveKitProcess) generateConfig() (string, error) {
cfgPath := filepath.Join(p.dataDir, "livekit.yaml")
// 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))
}
}
}
content := fmt.Sprintf(`# Auto-generated by OwnCord — do not edit manually.
port: 7880
rtc:
port_range_start: 50000
port_range_end: 60000
use_external_ip: true
pli_throttle:
low_quality: 500ms
mid_quality: 1s
high_quality: 1s
keys:
"%s": "%s"
logging:
level: info
`, p.cfg.LiveKitAPIKey, p.cfg.LiveKitAPISecret)
if err := os.MkdirAll(p.dataDir, 0o755); 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
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) {
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)
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() (bool, error) {
httpURL := wsToHTTP(p.cfg.LiveKitURL)
ctx, cancel := context.WithTimeout(context.Background(), 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
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()
}
}
}
}