From b9180bdbeb1685183ffde3d01bf53bcfcc877525 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 10:50:11 +0000 Subject: [PATCH] feat(server): support dual-homed voice hosts and user-managed livekit.yaml Servers reachable via both a LAN IP and a public IP could only serve voice on one of them: config.yaml accepts a single voice.node_ip and OwnCord regenerates data/livekit.yaml on every start, discarding manual edits. LiveKit has no multi-IP list, but it does support advertising internal host candidates alongside the external mapping. - New voice.advertise_internal_ip (OWNCORD_VOICE_ADVERTISE_INTERNAL_IP): emits rtc.advertise_internal_ip: true so LAN clients get a reachable candidate while remote clients keep using node_ip. - livekit.yaml escape hatch: if the file exists without the auto-generated marker header, OwnCord leaves it untouched, giving operators access to every LiveKit option (ips.includes, interfaces, stun_servers, ...). The generated header documents how to take ownership. Closes #111 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK --- Server/config/config.go | 7 +- Server/config/config_test.go | 22 +++++++ Server/livekit.yaml.example | 5 ++ Server/ws/livekit_process.go | 34 +++++++++- Server/ws/livekit_test.go | 123 +++++++++++++++++++++++++++++++++++ docs/livekit-setup.md | 5 +- docs/server-configuration.md | 9 +++ 7 files changed, 200 insertions(+), 5 deletions(-) diff --git a/Server/config/config.go b/Server/config/config.go index 2bd99919..9dd03548 100644 --- a/Server/config/config.go +++ b/Server/config/config.go @@ -97,7 +97,11 @@ type VoiceConfig struct { LiveKitURL string `koanf:"livekit_url"` // LiveKit server WebSocket URL (e.g. ws://localhost:7880) LiveKitBinaryPath string `koanf:"livekit_binary"` // path to livekit-server binary; empty = don't auto-start NodeIP string `koanf:"node_ip"` // public IP for WebRTC ICE candidates; empty = auto-detect - Quality string `koanf:"quality"` // low | medium | high + // AdvertiseInternalIP makes LiveKit advertise internal (LAN) host candidates + // in addition to the external node_ip mapping, so clients on the local + // network can connect while remote clients use the public IP. + AdvertiseInternalIP bool `koanf:"advertise_internal_ip"` + Quality string `koanf:"quality"` // low | medium | high } // ServerConfig holds HTTP server settings. @@ -248,6 +252,7 @@ voice: livekit_url: "ws://localhost:7880" # LiveKit server WebSocket URL # livekit_binary: "" # path to livekit-server binary; empty = don't auto-start # node_ip: "" # public IP for WebRTC media (required for remote users behind NAT) + # advertise_internal_ip: false # also advertise LAN IPs so local-network clients can connect # quality: "medium" # low | medium | high # github: diff --git a/Server/config/config_test.go b/Server/config/config_test.go index 879cafdb..dcd6666b 100644 --- a/Server/config/config_test.go +++ b/Server/config/config_test.go @@ -266,6 +266,7 @@ voice: livekit_api_key: "mykey" livekit_api_secret: "mysecret" livekit_url: "ws://lk.example.com:7880" + advertise_internal_ip: true ` if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil { t.Fatalf("failed to write yaml: %v", err) @@ -288,6 +289,27 @@ voice: if cfg.Voice.LiveKitURL != "ws://lk.example.com:7880" { t.Errorf("Voice.LiveKitURL = %q, want 'ws://lk.example.com:7880'", cfg.Voice.LiveKitURL) } + if !cfg.Voice.AdvertiseInternalIP { + t.Error("Voice.AdvertiseInternalIP = false, want true") + } +} + +func TestLoadVoiceAdvertiseInternalIPFromEnv(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte("voice:\n quality: high\n"), 0o644); err != nil { + t.Fatalf("failed to write yaml: %v", err) + } + + t.Setenv("OWNCORD_VOICE_ADVERTISE_INTERNAL_IP", "true") + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if !cfg.Voice.AdvertiseInternalIP { + t.Error("Voice.AdvertiseInternalIP = false, want true from env override") + } } func TestLoadEnvOverridesPrecedenceOverYAML(t *testing.T) { diff --git a/Server/livekit.yaml.example b/Server/livekit.yaml.example index 31001e55..bebd61b4 100644 --- a/Server/livekit.yaml.example +++ b/Server/livekit.yaml.example @@ -16,6 +16,11 @@ rtc: node_ip: "YOUR_SERVER_PUBLIC_IP" # use_external_ip: true # uncomment on cloud VMs instead of node_ip + # If the server is reachable via both a LAN IP and a public IP (dual-homed), + # uncomment this so LiveKit also advertises the internal address — clients on + # the local network then connect via LAN while remote clients use node_ip. + # advertise_internal_ip: true + keys: # Must match LIVEKIT_API_KEY / LIVEKIT_API_SECRET in your .env file YOUR_API_KEY: YOUR_API_SECRET diff --git a/Server/ws/livekit_process.go b/Server/ws/livekit_process.go index 767b874c..92d4cf17 100644 --- a/Server/ws/livekit_process.go +++ b/Server/ws/livekit_process.go @@ -13,6 +13,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "time" "github.com/owncord/server/config" @@ -51,10 +52,27 @@ func NewLiveKitProcess(cfg *config.VoiceConfig, tlsCfg *config.TLSConfig, dataDi } } +// 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 { + if !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. @@ -78,14 +96,24 @@ func (p *LiveKitProcess) generateConfig() (string, error) { } 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 — do not edit manually. + 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 + use_external_ip: true%s%s pli_throttle: low_quality: 500ms mid_quality: 1s @@ -96,7 +124,7 @@ keys: logging: level: info -`, nodeIPLine, p.cfg.LiveKitAPIKey, p.cfg.LiveKitAPISecret) +`, 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) diff --git a/Server/ws/livekit_test.go b/Server/ws/livekit_test.go index f4fd591c..535a85f0 100644 --- a/Server/ws/livekit_test.go +++ b/Server/ws/livekit_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "os" + "path/filepath" "strings" "testing" "time" @@ -646,6 +647,128 @@ func TestGenerateConfig_UnsafeNodeIPChars(t *testing.T) { } } +func TestGenerateConfig_WithAdvertiseInternalIP(t *testing.T) { + t.Parallel() + + cfg := &config.VoiceConfig{ + LiveKitAPIKey: "key1", + LiveKitAPISecret: "secret1", + LiveKitURL: "ws://localhost:7880", + NodeIP: "203.0.113.10", + AdvertiseInternalIP: true, + } + proc := ws.NewLiveKitProcess(cfg, &config.TLSConfig{}, t.TempDir()) + + cfgPath, err := proc.GenerateConfigForTest() + if err != nil { + t.Fatalf("generateConfig: %v", err) + } + content, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("reading config file: %v", err) + } + + got := string(content) + if !strings.Contains(got, "advertise_internal_ip: true") { + t.Errorf("expected advertise_internal_ip in config.\nGot:\n%s", got) + } + if !strings.Contains(got, `node_ip: "203.0.113.10"`) { + t.Errorf("expected node_ip alongside advertise_internal_ip.\nGot:\n%s", got) + } +} + +func TestGenerateConfig_DefaultOmitsAdvertiseInternalIP(t *testing.T) { + t.Parallel() + + cfg := &config.VoiceConfig{ + LiveKitAPIKey: "key1", + LiveKitAPISecret: "secret1", + LiveKitURL: "ws://localhost:7880", + } + proc := ws.NewLiveKitProcess(cfg, &config.TLSConfig{}, t.TempDir()) + + cfgPath, err := proc.GenerateConfigForTest() + if err != nil { + t.Fatalf("generateConfig: %v", err) + } + content, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("reading config file: %v", err) + } + + if strings.Contains(string(content), "advertise_internal_ip") { + t.Error("config should not contain advertise_internal_ip by default") + } +} + +func TestGenerateConfig_PreservesUserManagedFile(t *testing.T) { + t.Parallel() + + dataDir := t.TempDir() + cfgPath := filepath.Join(dataDir, "livekit.yaml") + userContent := "# my custom livekit config\nport: 7880\nrtc:\n ips:\n includes: [10.0.0.0/8]\n" + if err := os.WriteFile(cfgPath, []byte(userContent), 0o600); err != nil { + t.Fatalf("writing user config: %v", err) + } + + cfg := &config.VoiceConfig{ + LiveKitAPIKey: "key1", + LiveKitAPISecret: "secret1", + LiveKitURL: "ws://localhost:7880", + } + proc := ws.NewLiveKitProcess(cfg, &config.TLSConfig{}, dataDir) + + gotPath, err := proc.GenerateConfigForTest() + if err != nil { + t.Fatalf("generateConfig: %v", err) + } + if gotPath != cfgPath { + t.Errorf("expected path %q, got %q", cfgPath, gotPath) + } + + content, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("reading config file: %v", err) + } + if string(content) != userContent { + t.Errorf("user-managed livekit.yaml was modified.\nWant:\n%s\nGot:\n%s", userContent, content) + } +} + +func TestGenerateConfig_OverwritesAutoGeneratedFile(t *testing.T) { + t.Parallel() + + dataDir := t.TempDir() + cfgPath := filepath.Join(dataDir, "livekit.yaml") + old := "# Auto-generated by OwnCord — do not edit manually.\nport: 7880\nstale: true\n" + if err := os.WriteFile(cfgPath, []byte(old), 0o600); err != nil { + t.Fatalf("writing old config: %v", err) + } + + cfg := &config.VoiceConfig{ + LiveKitAPIKey: "key1", + LiveKitAPISecret: "secret1", + LiveKitURL: "ws://localhost:7880", + } + proc := ws.NewLiveKitProcess(cfg, &config.TLSConfig{}, dataDir) + + if _, err := proc.GenerateConfigForTest(); err != nil { + t.Fatalf("generateConfig: %v", err) + } + + content, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("reading config file: %v", err) + } + got := string(content) + if strings.Contains(got, "stale: true") { + t.Error("auto-generated livekit.yaml was not regenerated") + } + if !strings.Contains(got, `"key1": "secret1"`) { + t.Errorf("regenerated config missing keys.\nGot:\n%s", got) + } +} + // --------------------------------------------------------------------------- // livekit_process.go – Start guard tests // --------------------------------------------------------------------------- diff --git a/docs/livekit-setup.md b/docs/livekit-setup.md index 9fcdbd60..10446235 100644 --- a/docs/livekit-setup.md +++ b/docs/livekit-setup.md @@ -86,6 +86,8 @@ voice: | `livekit_api_secret` | Shared secret for JWT signing (min 32 chars) | `"owncord-dev-secret-key-min-32chars"` | | `livekit_url` | LiveKit WebSocket URL | `ws://localhost:7880` | | `livekit_binary` | Path to `livekit-server` binary. Empty = assume externally managed | `""` (disabled) | +| `node_ip` | Public IP for WebRTC ICE candidates (remote users behind NAT) | `""` (auto-detect) | +| `advertise_internal_ip` | Also advertise LAN IPs — enable on dual-homed servers (LAN + public IP) so local clients can connect | `false` | | `quality` | Default voice quality preset | `"medium"` | Environment variable overrides use the `OWNCORD_` prefix: `OWNCORD_VOICE_LIVEKIT_API_KEY`, `OWNCORD_VOICE_LIVEKIT_API_SECRET`, etc. @@ -110,7 +112,7 @@ For LAN-only setups, ensure these ports are open on Windows Firewall. For remote When `livekit_binary` is set, OwnCord manages LiveKit as a companion process: -1. **Config generation**: OwnCord auto-generates `data/livekit.yaml` with the API key/secret, port 7880, and UDP range 50000-60000 +1. **Config generation**: OwnCord auto-generates `data/livekit.yaml` with the API key/secret, port 7880, and UDP range 50000-60000. To manage the file yourself (custom `rtc` options, multiple interfaces, ...), delete the header line containing the auto-generated marker — OwnCord then leaves the file untouched on future starts. Your `keys:` entry must still match `voice.livekit_api_key` / `voice.livekit_api_secret`. 2. **Process launch**: `livekit-server --config data/livekit.yaml` 3. **Crash recovery**: Exponential backoff restart (3s -> 6s -> 12s ... up to 60s), gives up after 10 consecutive rapid failures 4. **Health checks**: `GET http://localhost:7880/` verifies LiveKit is responding @@ -169,6 +171,7 @@ LiveKit sends webhooks to `POST /api/v1/livekit/webhook`. The endpoint verifies | "backend unavailable" from `/livekit` proxy | LiveKit not running on port 7880 | Check `livekit_binary` path or start LiveKit manually | | "too many rapid failures, giving up" in logs | LiveKit binary crashes on startup | Run `livekit-server --config data/livekit.yaml` manually to see errors | | Mixed content / insecure WS error | Client using direct URL over HTTPS page | Client should use the `/livekit` proxy path | +| Voice works via public IP but not on the LAN (dual-homed server) | LiveKit only advertises the public `node_ip` | Set `voice.advertise_internal_ip: true` so LAN host candidates are advertised too | | `GET /api/v1/livekit/health` returns degraded | LiveKit server not reachable | Verify LiveKit is running: `curl http://localhost:7880` | --- diff --git a/docs/server-configuration.md b/docs/server-configuration.md index 8f10d8ea..73cd38ad 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -57,10 +57,17 @@ Configuration is loaded in three layers (later layers override earlier ones): | `voice.livekit_url` | string | `"ws://localhost:7880"` | LiveKit server WebSocket URL | | `voice.livekit_binary` | string | `""` | Path to `livekit-server` binary; empty = don't auto-start | | `voice.node_ip` | string | `""` | Public IP for WebRTC ICE candidates; empty = auto-detect. Required for remote users behind NAT. | +| `voice.advertise_internal_ip` | bool | `false` | Also advertise internal (LAN) IPs as ICE candidates. Enable when the server is reachable via both a LAN IP and a public IP so local-network clients can connect to voice. | | `voice.quality` | string | `"medium"` | Voice quality preset: `low`, `medium`, `high` | > **Warning:** If `livekit_api_key` or `livekit_api_secret` are left empty, random credentials are generated on each startup. This means voice tokens break on restart. Always set stable credentials in production. See [LiveKit Setup](livekit-setup.md) for details. +#### Server with both a LAN and a public IP + +If your server is dual-homed (e.g. `192.168.1.10` on the LAN and `47.x.x.x` public), set `voice.node_ip` to the public IP **and** `voice.advertise_internal_ip: true`. LiveKit then advertises the LAN address in addition to the public one, so clients on the local network connect directly while remote clients use the public IP. + +For LiveKit options OwnCord does not model, you can take ownership of the auto-started server's config: edit `data/livekit.yaml` and delete the header line containing the auto-generated marker — OwnCord will stop regenerating the file on startup (your `keys:` entry must still match `voice.livekit_api_key` / `voice.livekit_api_secret`). + ### GitHub / Updates (`github`) | Key | Type | Default | Description | @@ -128,6 +135,7 @@ Every config key can be overridden via environment variables using the prefix `O | `OWNCORD_VOICE_LIVEKIT_API_SECRET` | `voice.livekit_api_secret` | | `OWNCORD_VOICE_LIVEKIT_URL` | `voice.livekit_url` | | `OWNCORD_VOICE_NODE_IP` | `voice.node_ip` | +| `OWNCORD_VOICE_ADVERTISE_INTERNAL_IP` | `voice.advertise_internal_ip` | | `OWNCORD_VOICE_QUALITY` | `voice.quality` | | `OWNCORD_GITHUB_TOKEN` | `github.token` | | `OWNCORD_EVENT_PERSISTENCE_ENABLED` | `event_persistence.enabled` | @@ -176,6 +184,7 @@ voice: livekit_url: "ws://localhost:7880" livekit_binary: "" # path to livekit-server binary node_ip: "" # public IP for remote users behind NAT + advertise_internal_ip: false # also advertise LAN IPs (dual-homed servers) quality: "medium" # low | medium | high github: