From 85eb98ed3425012fb16023214a2b788551e23983 Mon Sep 17 00:00:00 2001 From: anonpenguin23 Date: Mon, 23 Feb 2026 16:57:29 +0200 Subject: [PATCH] feat: add TURN domain configuration and certificate provisioning via Caddy --- docs/WEBRTC.md | 75 +++++-- .../handlers/namespace/spawn_handler.go | 18 +- pkg/namespace/cluster_manager.go | 1 + pkg/namespace/cluster_manager_webrtc.go | 2 + pkg/namespace/systemd_spawner.go | 58 ++++- pkg/namespace/turn_cert.go | 165 ++++++++++++++ pkg/namespace/turn_cert_test.go | 204 ++++++++++++++++++ 7 files changed, 482 insertions(+), 41 deletions(-) create mode 100644 pkg/namespace/turn_cert.go create mode 100644 pkg/namespace/turn_cert_test.go diff --git a/docs/WEBRTC.md b/docs/WEBRTC.md index bae8261..2db2d14 100644 --- a/docs/WEBRTC.md +++ b/docs/WEBRTC.md @@ -72,18 +72,37 @@ orama namespace disable webrtc --namespace myapp ## Client Integration (JavaScript) +### Authentication + +All WebRTC endpoints require authentication. Use one of: + +``` +# Option A: API Key via header (recommended) +X-API-Key: + +# Option B: API Key via Authorization header +Authorization: ApiKey + +# Option C: JWT Bearer token +Authorization: Bearer +``` + ### 1. Get TURN Credentials ```javascript -const response = await fetch('https://ns-myapp.orama.network/v1/webrtc/turn/credentials', { +const response = await fetch('https://ns-myapp.orama-devnet.network/v1/webrtc/turn/credentials', { method: 'POST', - headers: { 'Authorization': `Bearer ${jwt}` } + headers: { 'X-API-Key': apiKey } }); -const { urls, username, credential, ttl } = await response.json(); -// urls: ["turn:1.2.3.4:3478?transport=udp", "turns:1.2.3.4:443?transport=udp"] +const { uris, username, password, ttl } = await response.json(); +// uris: [ +// "turn:turn.ns-myapp.orama-devnet.network:3478?transport=udp", +// "turn:turn.ns-myapp.orama-devnet.network:3478?transport=tcp", +// "turns:turn.ns-myapp.orama-devnet.network:5349" +// ] // username: "{expiry_unix}:{namespace}" -// credential: HMAC-SHA1 derived +// password: HMAC-SHA1 derived (base64) // ttl: 600 (seconds) ``` @@ -91,7 +110,7 @@ const { urls, username, credential, ttl } = await response.json(); ```javascript const pc = new RTCPeerConnection({ - iceServers: [{ urls, username, credential }], + iceServers: [{ urls: uris, username, credential: password }], iceTransportPolicy: 'relay' // enforced by SFU }); ``` @@ -100,8 +119,7 @@ const pc = new RTCPeerConnection({ ```javascript const ws = new WebSocket( - `wss://ns-myapp.orama.network/v1/webrtc/signal?room=${roomId}`, - ['Bearer', jwt] + `wss://ns-myapp.orama-devnet.network/v1/webrtc/signal?room=${roomId}&api_key=${apiKey}` ); ws.onmessage = (event) => { @@ -126,22 +144,22 @@ ws.onmessage = (event) => { ### 4. Room Management (REST) ```javascript +const headers = { 'X-API-Key': apiKey, 'Content-Type': 'application/json' }; + // Create room await fetch('/v1/webrtc/rooms', { method: 'POST', - headers: { 'Authorization': `Bearer ${jwt}`, 'Content-Type': 'application/json' }, + headers, body: JSON.stringify({ room_id: 'my-room' }) }); // List rooms -const rooms = await fetch('/v1/webrtc/rooms', { - headers: { 'Authorization': `Bearer ${jwt}` } -}); +const rooms = await fetch('/v1/webrtc/rooms', { headers }); // Close room await fetch('/v1/webrtc/rooms?room_id=my-room', { method: 'DELETE', - headers: { 'Authorization': `Bearer ${jwt}` } + headers }); ``` @@ -176,22 +194,32 @@ await fetch('/v1/webrtc/rooms?room_id=my-room', { WebRTC uses a **separate port allocation system** from the core namespace ports: -| Service | Port Range | Per Namespace | -|---------|-----------|---------------| -| SFU signaling | 30000-30099 | 1 port | -| SFU media (RTP) | 20000-29999 | 500 ports | -| TURN listen | 3478 (standard) | fixed | -| TURN TLS | 443/udp (standard) | fixed | -| TURN relay | 49152-65535 | 800 ports | +| Service | Port Range | Protocol | Per Namespace | +|---------|-----------|----------|---------------| +| SFU signaling | 30000-30099 | TCP (WireGuard only) | 1 port | +| SFU media (RTP) | 20000-29999 | UDP (WireGuard only) | 500 ports | +| TURN listen | 3478 | UDP + TCP | fixed | +| TURNS (TLS) | 5349 | TCP | fixed | +| TURN relay | 49152-65535 | UDP | 800 ports | ## TURN Credential Protocol - Credentials use HMAC-SHA1 with a per-namespace shared secret - Username format: `{expiry_unix}:{namespace}` +- Password: `base64(HMAC-SHA1(shared_secret, username))` - Default TTL: 600 seconds (10 minutes) - SFU proactively sends `refresh-credentials` at 80% of TTL (8 minutes) - Clients should update ICE servers on receiving refresh +## TURNS TLS Certificate + +TURNS (port 5349) uses TLS. Certificate provisioning: + +1. **Let's Encrypt (primary)**: On TURN spawn, the TURN domain is added to the local Caddy instance's Caddyfile. Caddy provisions a Let's Encrypt cert via DNS-01 ACME challenge (using the orama DNS provider). TURN reads the cert from Caddy's storage. +2. **Self-signed (fallback)**: If Caddy cert provisioning fails (timeout, Caddy not running), a self-signed cert is generated with the node's public IP as SAN. + +Caddy auto-renews Let's Encrypt certs at ~60 days. TURN picks up renewed certs on restart. + ## Monitoring ```bash @@ -226,19 +254,20 @@ systemctl status orama-namespace-turn@myapp - **Forced relay**: `iceTransportPolicy: relay` enforced server-side. Clients cannot bypass TURN. - **HMAC credentials**: Per-namespace TURN shared secret. Credentials expire after 10 minutes. - **Namespace isolation**: Each namespace has its own TURN secret, port ranges, and rooms. -- **Authentication required**: All WebRTC endpoints require JWT or API key (not in `isPublicPath()`). +- **Authentication required**: All WebRTC endpoints require API key or JWT (`X-API-Key` header, `Authorization: ApiKey`, or `Authorization: Bearer`). - **Room management**: Creating/closing rooms requires namespace ownership. - **SFU on WireGuard only**: SFU binds to 10.0.0.x, never 0.0.0.0. Only reachable via TURN relay. - **Permissions-Policy**: `camera=(self), microphone=(self)` — only same-origin can access media devices. ## Firewall -When WebRTC is enabled, the following ports are opened via UFW: +When WebRTC is enabled, the following ports are opened via UFW on TURN nodes: | Port | Protocol | Purpose | |------|----------|---------| | 3478 | UDP | TURN standard | -| 443 | UDP | TURN TLS (does not conflict with Caddy TCP 443) | +| 3478 | TCP | TURN TCP fallback (for clients behind UDP-blocking firewalls) | +| 5349 | TCP | TURNS — TURN over TLS (encrypted, works through strict firewalls/DPI) | | 49152-65535 | UDP | TURN relay range (allocated per namespace) | SFU ports are NOT opened in the firewall — they are WireGuard-internal only. diff --git a/pkg/gateway/handlers/namespace/spawn_handler.go b/pkg/gateway/handlers/namespace/spawn_handler.go index 2e0be35..392ce63 100644 --- a/pkg/gateway/handlers/namespace/spawn_handler.go +++ b/pkg/gateway/handlers/namespace/spawn_handler.go @@ -71,6 +71,7 @@ type SpawnRequest struct { TURNAuthSecret string `json:"turn_auth_secret,omitempty"` TURNRelayStart int `json:"turn_relay_start,omitempty"` TURNRelayEnd int `json:"turn_relay_end,omitempty"` + TURNDomain string `json:"turn_domain,omitempty"` // Cluster state (when action = "save-cluster-state") ClusterState json.RawMessage `json:"cluster_state,omitempty"` @@ -344,15 +345,16 @@ func (h *SpawnHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { case "spawn-turn": cfg := namespacepkg.TURNInstanceConfig{ - Namespace: req.Namespace, - NodeID: req.NodeID, - ListenAddr: req.TURNListenAddr, + Namespace: req.Namespace, + NodeID: req.NodeID, + ListenAddr: req.TURNListenAddr, TURNSListenAddr: req.TURNTURNSAddr, - PublicIP: req.TURNPublicIP, - Realm: req.TURNRealm, - AuthSecret: req.TURNAuthSecret, - RelayPortStart: req.TURNRelayStart, - RelayPortEnd: req.TURNRelayEnd, + PublicIP: req.TURNPublicIP, + Realm: req.TURNRealm, + AuthSecret: req.TURNAuthSecret, + RelayPortStart: req.TURNRelayStart, + RelayPortEnd: req.TURNRelayEnd, + TURNDomain: req.TURNDomain, } if err := h.systemdSpawner.SpawnTURN(ctx, req.Namespace, req.NodeID, cfg); err != nil { h.logger.Error("Failed to spawn TURN instance", zap.Error(err)) diff --git a/pkg/namespace/cluster_manager.go b/pkg/namespace/cluster_manager.go index b5d1aaa..9ff9be2 100644 --- a/pkg/namespace/cluster_manager.go +++ b/pkg/namespace/cluster_manager.go @@ -1965,6 +1965,7 @@ func (cm *ClusterManager) restoreClusterFromState(ctx context.Context, state *Cl AuthSecret: webrtcCfg.TURNSharedSecret, RelayPortStart: state.TURNRelayPortStart, RelayPortEnd: state.TURNRelayPortEnd, + TURNDomain: fmt.Sprintf("turn.ns-%s.%s", state.NamespaceName, cm.baseDomain), } if err := cm.systemdSpawner.SpawnTURN(ctx, state.NamespaceName, cm.localNodeID, turnCfg); err != nil { cm.logger.Error("Failed to restore TURN from state", zap.String("namespace", state.NamespaceName), zap.Error(err)) diff --git a/pkg/namespace/cluster_manager_webrtc.go b/pkg/namespace/cluster_manager_webrtc.go index 7853109..726bcf6 100644 --- a/pkg/namespace/cluster_manager_webrtc.go +++ b/pkg/namespace/cluster_manager_webrtc.go @@ -132,6 +132,7 @@ func (cm *ClusterManager) EnableWebRTC(ctx context.Context, namespaceName, enabl AuthSecret: turnSecret, RelayPortStart: turnBlock.TURNRelayPortStart, RelayPortEnd: turnBlock.TURNRelayPortEnd, + TURNDomain: turnDomain, } if err := cm.spawnTURNOnNode(ctx, node, namespaceName, turnCfg); err != nil { @@ -474,6 +475,7 @@ func (cm *ClusterManager) spawnTURNRemote(ctx context.Context, nodeIP string, cf "turn_auth_secret": cfg.AuthSecret, "turn_relay_start": cfg.RelayPortStart, "turn_relay_end": cfg.RelayPortEnd, + "turn_domain": cfg.TURNDomain, }) return err } diff --git a/pkg/namespace/systemd_spawner.go b/pkg/namespace/systemd_spawner.go index 94ea4e7..4e83cc0 100644 --- a/pkg/namespace/systemd_spawner.go +++ b/pkg/namespace/systemd_spawner.go @@ -402,6 +402,7 @@ type TURNInstanceConfig struct { AuthSecret string // HMAC-SHA1 shared secret RelayPortStart int // Start of relay port range RelayPortEnd int // End of relay port range + TURNDomain string // TURN domain for Let's Encrypt cert (e.g., "turn.ns-myapp.orama-devnet.network") } // SpawnTURN starts a TURN instance using systemd @@ -420,20 +421,41 @@ func (s *SystemdSpawner) SpawnTURN(ctx context.Context, namespace, nodeID string configPath := filepath.Join(configDir, fmt.Sprintf("turn-%s.yaml", nodeID)) - // Generate self-signed TLS cert for TURNS if not already present + // Provision TLS cert for TURNS — try Let's Encrypt via Caddy first, fall back to self-signed certPath := filepath.Join(configDir, "turn-cert.pem") keyPath := filepath.Join(configDir, "turn-key.pem") if cfg.TURNSListenAddr != "" { if _, err := os.Stat(certPath); os.IsNotExist(err) { - if err := turn.GenerateSelfSignedCert(certPath, keyPath, cfg.PublicIP); err != nil { - s.logger.Warn("Failed to generate TURNS self-signed cert, TURNS will be disabled", - zap.String("namespace", namespace), - zap.Error(err)) - cfg.TURNSListenAddr = "" // Disable TURNS if cert generation fails - } else { - s.logger.Info("Generated TURNS self-signed certificate", - zap.String("namespace", namespace), - zap.String("cert_path", certPath)) + // Try Let's Encrypt via Caddy first + if cfg.TURNDomain != "" { + acmeEndpoint := "http://localhost:6001/v1/internal/acme" + caddyCert, caddyKey, provErr := provisionTURNCertViaCaddy(cfg.TURNDomain, acmeEndpoint, 2*time.Minute) + if provErr == nil { + certPath = caddyCert + keyPath = caddyKey + s.logger.Info("Using Let's Encrypt cert from Caddy for TURNS", + zap.String("namespace", namespace), + zap.String("domain", cfg.TURNDomain), + zap.String("cert_path", certPath)) + } else { + s.logger.Warn("Let's Encrypt cert provisioning failed, falling back to self-signed", + zap.String("namespace", namespace), + zap.String("domain", cfg.TURNDomain), + zap.Error(provErr)) + } + } + // Fallback: generate self-signed cert if no cert is available yet + if _, statErr := os.Stat(certPath); os.IsNotExist(statErr) { + if err := turn.GenerateSelfSignedCert(certPath, keyPath, cfg.PublicIP); err != nil { + s.logger.Warn("Failed to generate TURNS self-signed cert, TURNS will be disabled", + zap.String("namespace", namespace), + zap.Error(err)) + cfg.TURNSListenAddr = "" // Disable TURNS if cert generation fails + } else { + s.logger.Info("Generated TURNS self-signed certificate", + zap.String("namespace", namespace), + zap.String("cert_path", certPath)) + } } } } @@ -518,6 +540,22 @@ func (s *SystemdSpawner) StopTURN(ctx context.Context, namespace, nodeID string) zap.Error(fwErr)) } + // Remove TURN cert block from Caddyfile (if provisioned via Let's Encrypt) + configDir := filepath.Join(s.namespaceBase, namespace, "configs") + configPath := filepath.Join(configDir, fmt.Sprintf("turn-%s.yaml", nodeID)) + if data, readErr := os.ReadFile(configPath); readErr == nil { + var turnCfg turn.Config + if yaml.Unmarshal(data, &turnCfg) == nil && turnCfg.Realm != "" { + turnDomain := fmt.Sprintf("turn.ns-%s.%s", namespace, turnCfg.Realm) + if removeErr := removeTURNCertFromCaddy(turnDomain); removeErr != nil { + s.logger.Warn("Failed to remove TURN cert from Caddyfile", + zap.String("namespace", namespace), + zap.String("domain", turnDomain), + zap.Error(removeErr)) + } + } + } + return err } diff --git a/pkg/namespace/turn_cert.go b/pkg/namespace/turn_cert.go new file mode 100644 index 0000000..00ac1ed --- /dev/null +++ b/pkg/namespace/turn_cert.go @@ -0,0 +1,165 @@ +package namespace + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const ( + caddyfilePath = "/etc/caddy/Caddyfile" + + // Caddy stores ACME certs under this directory relative to its data dir. + caddyACMECertDir = "certificates/acme-v02.api.letsencrypt.org-directory" + + turnCertBeginMarker = "# BEGIN TURN CERT: " + turnCertEndMarker = "# END TURN CERT: " +) + +// provisionTURNCertViaCaddy appends the TURN domain to the local Caddyfile, +// reloads Caddy to trigger DNS-01 ACME certificate provisioning, and waits +// for the cert files to appear. Returns the cert/key paths on success. +// If Caddy is not available or cert provisioning times out, returns an error +// so the caller can fall back to a self-signed cert. +func provisionTURNCertViaCaddy(domain, acmeEndpoint string, timeout time.Duration) (certPath, keyPath string, err error) { + // Check if cert already exists from a previous provisioning + certPath, keyPath = caddyCertPaths(domain) + if _, err := os.Stat(certPath); err == nil { + return certPath, keyPath, nil + } + + // Read current Caddyfile + data, err := os.ReadFile(caddyfilePath) + if err != nil { + return "", "", fmt.Errorf("failed to read Caddyfile: %w", err) + } + + caddyfile := string(data) + + // Check if domain block already exists (idempotent) + marker := turnCertBeginMarker + domain + if strings.Contains(caddyfile, marker) { + // Block already present — just wait for cert + return waitForCaddyCert(domain, timeout) + } + + // Append a minimal Caddyfile block for the TURN domain + block := fmt.Sprintf(` +%s%s +%s { + tls { + issuer acme { + dns orama { + endpoint %s + } + } + } + respond "OK" 200 +} +%s%s +`, turnCertBeginMarker, domain, domain, acmeEndpoint, turnCertEndMarker, domain) + + if err := os.WriteFile(caddyfilePath, []byte(caddyfile+block), 0644); err != nil { + return "", "", fmt.Errorf("failed to write Caddyfile: %w", err) + } + + // Reload Caddy to pick up the new domain + if err := reloadCaddy(); err != nil { + return "", "", fmt.Errorf("failed to reload Caddy: %w", err) + } + + // Wait for cert to be provisioned + return waitForCaddyCert(domain, timeout) +} + +// removeTURNCertFromCaddy removes the TURN domain block from the Caddyfile +// and reloads Caddy. Safe to call even if the block doesn't exist. +func removeTURNCertFromCaddy(domain string) error { + data, err := os.ReadFile(caddyfilePath) + if err != nil { + return fmt.Errorf("failed to read Caddyfile: %w", err) + } + + caddyfile := string(data) + beginMarker := turnCertBeginMarker + domain + endMarker := turnCertEndMarker + domain + + beginIdx := strings.Index(caddyfile, beginMarker) + if beginIdx == -1 { + return nil // Block not found, nothing to remove + } + + endIdx := strings.Index(caddyfile, endMarker) + if endIdx == -1 { + return nil // Malformed markers, skip + } + + // Include the end marker line itself + endIdx += len(endMarker) + // Also consume the trailing newline if present + if endIdx < len(caddyfile) && caddyfile[endIdx] == '\n' { + endIdx++ + } + + // Remove leading newline before the begin marker if present + if beginIdx > 0 && caddyfile[beginIdx-1] == '\n' { + beginIdx-- + } + + newCaddyfile := caddyfile[:beginIdx] + caddyfile[endIdx:] + if err := os.WriteFile(caddyfilePath, []byte(newCaddyfile), 0644); err != nil { + return fmt.Errorf("failed to write Caddyfile: %w", err) + } + + return reloadCaddy() +} + +// caddyCertPaths returns the expected cert and key file paths in Caddy's +// storage for a given domain. Caddy stores ACME certs as standard PEM files. +func caddyCertPaths(domain string) (certPath, keyPath string) { + dataDir := caddyDataDir() + certDir := filepath.Join(dataDir, caddyACMECertDir, domain) + return filepath.Join(certDir, domain+".crt"), filepath.Join(certDir, domain+".key") +} + +// caddyDataDir returns Caddy's data directory. Caddy uses XDG_DATA_HOME/caddy +// if set, otherwise falls back to $HOME/.local/share/caddy. +func caddyDataDir() string { + if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" { + return filepath.Join(xdg, "caddy") + } + home := os.Getenv("HOME") + if home == "" { + home = "/root" // Caddy runs as root in our setup + } + return filepath.Join(home, ".local", "share", "caddy") +} + +// waitForCaddyCert polls for the cert file to appear with a timeout. +func waitForCaddyCert(domain string, timeout time.Duration) (string, string, error) { + certPath, keyPath := caddyCertPaths(domain) + deadline := time.Now().Add(timeout) + + for time.Now().Before(deadline) { + if _, err := os.Stat(certPath); err == nil { + if _, err := os.Stat(keyPath); err == nil { + return certPath, keyPath, nil + } + } + time.Sleep(5 * time.Second) + } + + return "", "", fmt.Errorf("timed out waiting for Caddy to provision cert for %s (checked %s)", domain, certPath) +} + +// reloadCaddy sends a reload signal to Caddy via systemctl. +func reloadCaddy() error { + cmd := exec.Command("systemctl", "reload", "caddy") + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("systemctl reload caddy failed: %w (%s)", err, strings.TrimSpace(string(output))) + } + return nil +} diff --git a/pkg/namespace/turn_cert_test.go b/pkg/namespace/turn_cert_test.go new file mode 100644 index 0000000..3eec4ae --- /dev/null +++ b/pkg/namespace/turn_cert_test.go @@ -0,0 +1,204 @@ +package namespace + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCaddyCertPaths(t *testing.T) { + // Override HOME for deterministic test + origHome := os.Getenv("HOME") + origXDG := os.Getenv("XDG_DATA_HOME") + defer func() { + os.Setenv("HOME", origHome) + os.Setenv("XDG_DATA_HOME", origXDG) + }() + + t.Run("default HOME path", func(t *testing.T) { + os.Setenv("HOME", "/root") + os.Unsetenv("XDG_DATA_HOME") + + certPath, keyPath := caddyCertPaths("turn.ns-test.example.com") + + expectedCert := "/root/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/turn.ns-test.example.com/turn.ns-test.example.com.crt" + expectedKey := "/root/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/turn.ns-test.example.com/turn.ns-test.example.com.key" + + if certPath != expectedCert { + t.Errorf("cert path = %q, want %q", certPath, expectedCert) + } + if keyPath != expectedKey { + t.Errorf("key path = %q, want %q", keyPath, expectedKey) + } + }) + + t.Run("XDG_DATA_HOME override", func(t *testing.T) { + os.Setenv("XDG_DATA_HOME", "/custom/data") + certPath, keyPath := caddyCertPaths("turn.ns-test.example.com") + + expectedCert := "/custom/data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/turn.ns-test.example.com/turn.ns-test.example.com.crt" + expectedKey := "/custom/data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/turn.ns-test.example.com/turn.ns-test.example.com.key" + + if certPath != expectedCert { + t.Errorf("cert path = %q, want %q", certPath, expectedCert) + } + if keyPath != expectedKey { + t.Errorf("key path = %q, want %q", keyPath, expectedKey) + } + }) +} + +func TestRemoveTURNCertFromCaddy_MarkerRemoval(t *testing.T) { + // Create a temporary Caddyfile with a TURN cert block + tmpDir := t.TempDir() + tmpCaddyfile := filepath.Join(tmpDir, "Caddyfile") + + domain := "turn.ns-test.example.com" + original := `{ + email admin@example.com +} + +*.example.com { + tls { + issuer acme { + dns orama { + endpoint http://localhost:6001/v1/internal/acme + } + } + } + reverse_proxy localhost:6001 +} + +# BEGIN TURN CERT: turn.ns-test.example.com +turn.ns-test.example.com { + tls { + issuer acme { + dns orama { + endpoint http://localhost:6001/v1/internal/acme + } + } + } + respond "OK" 200 +} +# END TURN CERT: turn.ns-test.example.com +` + + if err := os.WriteFile(tmpCaddyfile, []byte(original), 0644); err != nil { + t.Fatal(err) + } + + // Test the marker removal logic directly (not calling removeTURNCertFromCaddy + // because it tries to reload Caddy via systemctl) + data, err := os.ReadFile(tmpCaddyfile) + if err != nil { + t.Fatal(err) + } + + caddyfile := string(data) + beginMarker := turnCertBeginMarker + domain + endMarker := turnCertEndMarker + domain + + beginIdx := findIndex(caddyfile, beginMarker) + if beginIdx == -1 { + t.Fatal("BEGIN marker not found") + } + + endIdx := findIndex(caddyfile, endMarker) + if endIdx == -1 { + t.Fatal("END marker not found") + } + + // Include end marker line + endIdx += len(endMarker) + if endIdx < len(caddyfile) && caddyfile[endIdx] == '\n' { + endIdx++ + } + + // Remove leading newline + if beginIdx > 0 && caddyfile[beginIdx-1] == '\n' { + beginIdx-- + } + + result := caddyfile[:beginIdx] + caddyfile[endIdx:] + + // Verify the TURN block is removed + if findIndex(result, "TURN CERT") != -1 { + t.Error("TURN CERT markers still present after removal") + } + if findIndex(result, "turn.ns-test.example.com") != -1 { + t.Error("TURN domain still present after removal") + } + + // Verify the rest of the Caddyfile is intact + if findIndex(result, "*.example.com") == -1 { + t.Error("wildcard domain block was incorrectly removed") + } + if findIndex(result, "reverse_proxy localhost:6001") == -1 { + t.Error("reverse_proxy directive was incorrectly removed") + } +} + +func TestRemoveTURNCertFromCaddy_NoMarkers(t *testing.T) { + // When no markers exist, the Caddyfile should be unchanged + original := `{ + email admin@example.com +} + +*.example.com { + reverse_proxy localhost:6001 +} +` + caddyfile := original + beginMarker := turnCertBeginMarker + "turn.ns-test.example.com" + + beginIdx := findIndex(caddyfile, beginMarker) + if beginIdx != -1 { + t.Error("expected no BEGIN marker in Caddyfile without TURN block") + } + // If no marker found, nothing to remove — original unchanged +} + +func TestCaddyDataDir(t *testing.T) { + origHome := os.Getenv("HOME") + origXDG := os.Getenv("XDG_DATA_HOME") + defer func() { + os.Setenv("HOME", origHome) + os.Setenv("XDG_DATA_HOME", origXDG) + }() + + t.Run("XDG set", func(t *testing.T) { + os.Setenv("XDG_DATA_HOME", "/xdg/data") + got := caddyDataDir() + if got != "/xdg/data/caddy" { + t.Errorf("caddyDataDir() = %q, want /xdg/data/caddy", got) + } + }) + + t.Run("HOME fallback", func(t *testing.T) { + os.Unsetenv("XDG_DATA_HOME") + os.Setenv("HOME", "/home/user") + got := caddyDataDir() + if got != "/home/user/.local/share/caddy" { + t.Errorf("caddyDataDir() = %q, want /home/user/.local/share/caddy", got) + } + }) + + t.Run("root fallback", func(t *testing.T) { + os.Unsetenv("XDG_DATA_HOME") + os.Unsetenv("HOME") + got := caddyDataDir() + if got != "/root/.local/share/caddy" { + t.Errorf("caddyDataDir() = %q, want /root/.local/share/caddy", got) + } + }) +} + +// findIndex returns the index of the first occurrence of substr in s, or -1. +func findIndex(s, substr string) int { + for i := 0; i+len(substr) <= len(s); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +}