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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK
This commit is contained in:
Claude
2026-07-19 10:50:11 +00:00
parent 6f0a04113f
commit b9180bdbeb
7 changed files with 200 additions and 5 deletions
+6 -1
View File
@@ -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:
+22
View File
@@ -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) {
+5
View File
@@ -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
+31 -3
View File
@@ -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)
+123
View File
@@ -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
// ---------------------------------------------------------------------------
+4 -1
View File
@@ -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` |
---
+9
View File
@@ -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: