mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-02 19:43:10 +03:00
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:
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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` |
|
||||
|
||||
---
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user