mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Server: - Fix YAML injection in LiveKit config generation (quote values) - Revert token TTL to 4h (no server-side JWT revocation) - Derive LiveKit publish permissions from user role (prevent SFU bypass) - Add CAS guard for webhook/voice_leave race condition - Add voice_leave broadcast to rollbackVoiceJoin (prevent ghost state) - Limit webhook body to 64KB (prevent memory abuse) - Add rate limit to voice_token_refresh handler (1/60s) - Add LiveKit health check endpoint (GET /api/v1/livekit/health, 503 on degraded) - Add voice_token_refresh WS handler for client-initiated token refresh - Consolidate voice quality constants (single source of truth) - Fix video limit TOCTOU race (count from DB instead of LiveKit API) - Raise default voice_max_video from 10 to 25 (Discord parity) - Add CountActiveCameras DB query - Non-blocking broadcast send, circuit breaker, exponential backoff - Close send channel before context cancel in serve.go - Guard voice mute/deafen for active channel - Delete orphaned message on attachment link failure - Redact query string from proxy logs (prevent token leak) - Use instance-level HTTP client for health checks (no redirect following) - Set cmd.WaitDelay to prevent goroutine leak on Windows - Log buildJSON marshal errors Client: - Refactor livekitSession.ts from singleton module to LiveKitSession class - Share single AudioContext for all analysers (was 1 per participant) - Extract createRoom() helper (DRY) - Add token refresh timer (3.5h interval, re-arms on failure) - Skip setSpeakers if unchanged (sort in-place, no allocations) - Distinguish user-initiated leave from connection error in retry - Add YouTube videoId validation (prevent iframe src injection) - Add try/finally to disableCamera - Wrap store subscription callbacks in try/catch - Track and cancel initial scroll RAF on cleanup - Add 5s timeout + encodeURIComponent to YouTube oEmbed fetch - Clean raw mic stream on RNNoise suppressor failure - Full voice cleanup on logout via cleanupAll() Tests: - Add 7 new server tests (webhook parsing, voice guards, quality fallback) - Fix 2 pre-existing test failures (mute/deafen invalid payload)
253 lines
6.2 KiB
Go
253 lines
6.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
|
|
}
|
|
|
|
// 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.
|
|
|
|
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
|
|
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.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
|
|
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.
|
|
func (p *LiveKitProcess) Stop() {
|
|
p.mu.Lock()
|
|
p.stopped = true
|
|
cancel := p.cancel
|
|
cmd := p.cmd
|
|
p.mu.Unlock()
|
|
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
|
|
// Wait briefly for the process to exit after context cancellation
|
|
if cmd != nil && cmd.Process != nil {
|
|
done := make(chan struct{})
|
|
go func() {
|
|
_ = cmd.Wait()
|
|
close(done)
|
|
}()
|
|
|
|
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")
|
|
_ = cmd.Process.Kill()
|
|
}
|
|
}
|
|
}
|