feat: add TURN domain configuration and certificate provisioning via Caddy

This commit is contained in:
anonpenguin23 2026-02-23 16:57:29 +02:00
parent 714a986a78
commit 85eb98ed34
7 changed files with 482 additions and 41 deletions

View File

@ -72,18 +72,37 @@ orama namespace disable webrtc --namespace myapp
## Client Integration (JavaScript) ## Client Integration (JavaScript)
### Authentication
All WebRTC endpoints require authentication. Use one of:
```
# Option A: API Key via header (recommended)
X-API-Key: <your-namespace-api-key>
# Option B: API Key via Authorization header
Authorization: ApiKey <your-namespace-api-key>
# Option C: JWT Bearer token
Authorization: Bearer <jwt>
```
### 1. Get TURN Credentials ### 1. Get TURN Credentials
```javascript ```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', method: 'POST',
headers: { 'Authorization': `Bearer ${jwt}` } headers: { 'X-API-Key': apiKey }
}); });
const { urls, username, credential, ttl } = await response.json(); const { uris, username, password, ttl } = await response.json();
// urls: ["turn:1.2.3.4:3478?transport=udp", "turns:1.2.3.4:443?transport=udp"] // 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}" // username: "{expiry_unix}:{namespace}"
// credential: HMAC-SHA1 derived // password: HMAC-SHA1 derived (base64)
// ttl: 600 (seconds) // ttl: 600 (seconds)
``` ```
@ -91,7 +110,7 @@ const { urls, username, credential, ttl } = await response.json();
```javascript ```javascript
const pc = new RTCPeerConnection({ const pc = new RTCPeerConnection({
iceServers: [{ urls, username, credential }], iceServers: [{ urls: uris, username, credential: password }],
iceTransportPolicy: 'relay' // enforced by SFU iceTransportPolicy: 'relay' // enforced by SFU
}); });
``` ```
@ -100,8 +119,7 @@ const pc = new RTCPeerConnection({
```javascript ```javascript
const ws = new WebSocket( const ws = new WebSocket(
`wss://ns-myapp.orama.network/v1/webrtc/signal?room=${roomId}`, `wss://ns-myapp.orama-devnet.network/v1/webrtc/signal?room=${roomId}&api_key=${apiKey}`
['Bearer', jwt]
); );
ws.onmessage = (event) => { ws.onmessage = (event) => {
@ -126,22 +144,22 @@ ws.onmessage = (event) => {
### 4. Room Management (REST) ### 4. Room Management (REST)
```javascript ```javascript
const headers = { 'X-API-Key': apiKey, 'Content-Type': 'application/json' };
// Create room // Create room
await fetch('/v1/webrtc/rooms', { await fetch('/v1/webrtc/rooms', {
method: 'POST', method: 'POST',
headers: { 'Authorization': `Bearer ${jwt}`, 'Content-Type': 'application/json' }, headers,
body: JSON.stringify({ room_id: 'my-room' }) body: JSON.stringify({ room_id: 'my-room' })
}); });
// List rooms // List rooms
const rooms = await fetch('/v1/webrtc/rooms', { const rooms = await fetch('/v1/webrtc/rooms', { headers });
headers: { 'Authorization': `Bearer ${jwt}` }
});
// Close room // Close room
await fetch('/v1/webrtc/rooms?room_id=my-room', { await fetch('/v1/webrtc/rooms?room_id=my-room', {
method: 'DELETE', 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: WebRTC uses a **separate port allocation system** from the core namespace ports:
| Service | Port Range | Per Namespace | | Service | Port Range | Protocol | Per Namespace |
|---------|-----------|---------------| |---------|-----------|----------|---------------|
| SFU signaling | 30000-30099 | 1 port | | SFU signaling | 30000-30099 | TCP (WireGuard only) | 1 port |
| SFU media (RTP) | 20000-29999 | 500 ports | | SFU media (RTP) | 20000-29999 | UDP (WireGuard only) | 500 ports |
| TURN listen | 3478 (standard) | fixed | | TURN listen | 3478 | UDP + TCP | fixed |
| TURN TLS | 443/udp (standard) | fixed | | TURNS (TLS) | 5349 | TCP | fixed |
| TURN relay | 49152-65535 | 800 ports | | TURN relay | 49152-65535 | UDP | 800 ports |
## TURN Credential Protocol ## TURN Credential Protocol
- Credentials use HMAC-SHA1 with a per-namespace shared secret - Credentials use HMAC-SHA1 with a per-namespace shared secret
- Username format: `{expiry_unix}:{namespace}` - Username format: `{expiry_unix}:{namespace}`
- Password: `base64(HMAC-SHA1(shared_secret, username))`
- Default TTL: 600 seconds (10 minutes) - Default TTL: 600 seconds (10 minutes)
- SFU proactively sends `refresh-credentials` at 80% of TTL (8 minutes) - SFU proactively sends `refresh-credentials` at 80% of TTL (8 minutes)
- Clients should update ICE servers on receiving refresh - 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 ## Monitoring
```bash ```bash
@ -226,19 +254,20 @@ systemctl status orama-namespace-turn@myapp
- **Forced relay**: `iceTransportPolicy: relay` enforced server-side. Clients cannot bypass TURN. - **Forced relay**: `iceTransportPolicy: relay` enforced server-side. Clients cannot bypass TURN.
- **HMAC credentials**: Per-namespace TURN shared secret. Credentials expire after 10 minutes. - **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. - **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. - **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. - **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. - **Permissions-Policy**: `camera=(self), microphone=(self)` — only same-origin can access media devices.
## Firewall ## 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 | | Port | Protocol | Purpose |
|------|----------|---------| |------|----------|---------|
| 3478 | UDP | TURN standard | | 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) | | 49152-65535 | UDP | TURN relay range (allocated per namespace) |
SFU ports are NOT opened in the firewall — they are WireGuard-internal only. SFU ports are NOT opened in the firewall — they are WireGuard-internal only.

View File

@ -71,6 +71,7 @@ type SpawnRequest struct {
TURNAuthSecret string `json:"turn_auth_secret,omitempty"` TURNAuthSecret string `json:"turn_auth_secret,omitempty"`
TURNRelayStart int `json:"turn_relay_start,omitempty"` TURNRelayStart int `json:"turn_relay_start,omitempty"`
TURNRelayEnd int `json:"turn_relay_end,omitempty"` TURNRelayEnd int `json:"turn_relay_end,omitempty"`
TURNDomain string `json:"turn_domain,omitempty"`
// Cluster state (when action = "save-cluster-state") // Cluster state (when action = "save-cluster-state")
ClusterState json.RawMessage `json:"cluster_state,omitempty"` ClusterState json.RawMessage `json:"cluster_state,omitempty"`
@ -344,15 +345,16 @@ func (h *SpawnHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case "spawn-turn": case "spawn-turn":
cfg := namespacepkg.TURNInstanceConfig{ cfg := namespacepkg.TURNInstanceConfig{
Namespace: req.Namespace, Namespace: req.Namespace,
NodeID: req.NodeID, NodeID: req.NodeID,
ListenAddr: req.TURNListenAddr, ListenAddr: req.TURNListenAddr,
TURNSListenAddr: req.TURNTURNSAddr, TURNSListenAddr: req.TURNTURNSAddr,
PublicIP: req.TURNPublicIP, PublicIP: req.TURNPublicIP,
Realm: req.TURNRealm, Realm: req.TURNRealm,
AuthSecret: req.TURNAuthSecret, AuthSecret: req.TURNAuthSecret,
RelayPortStart: req.TURNRelayStart, RelayPortStart: req.TURNRelayStart,
RelayPortEnd: req.TURNRelayEnd, RelayPortEnd: req.TURNRelayEnd,
TURNDomain: req.TURNDomain,
} }
if err := h.systemdSpawner.SpawnTURN(ctx, req.Namespace, req.NodeID, cfg); err != nil { if err := h.systemdSpawner.SpawnTURN(ctx, req.Namespace, req.NodeID, cfg); err != nil {
h.logger.Error("Failed to spawn TURN instance", zap.Error(err)) h.logger.Error("Failed to spawn TURN instance", zap.Error(err))

View File

@ -1965,6 +1965,7 @@ func (cm *ClusterManager) restoreClusterFromState(ctx context.Context, state *Cl
AuthSecret: webrtcCfg.TURNSharedSecret, AuthSecret: webrtcCfg.TURNSharedSecret,
RelayPortStart: state.TURNRelayPortStart, RelayPortStart: state.TURNRelayPortStart,
RelayPortEnd: state.TURNRelayPortEnd, 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 { 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)) cm.logger.Error("Failed to restore TURN from state", zap.String("namespace", state.NamespaceName), zap.Error(err))

View File

@ -132,6 +132,7 @@ func (cm *ClusterManager) EnableWebRTC(ctx context.Context, namespaceName, enabl
AuthSecret: turnSecret, AuthSecret: turnSecret,
RelayPortStart: turnBlock.TURNRelayPortStart, RelayPortStart: turnBlock.TURNRelayPortStart,
RelayPortEnd: turnBlock.TURNRelayPortEnd, RelayPortEnd: turnBlock.TURNRelayPortEnd,
TURNDomain: turnDomain,
} }
if err := cm.spawnTURNOnNode(ctx, node, namespaceName, turnCfg); err != nil { 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_auth_secret": cfg.AuthSecret,
"turn_relay_start": cfg.RelayPortStart, "turn_relay_start": cfg.RelayPortStart,
"turn_relay_end": cfg.RelayPortEnd, "turn_relay_end": cfg.RelayPortEnd,
"turn_domain": cfg.TURNDomain,
}) })
return err return err
} }

View File

@ -402,6 +402,7 @@ type TURNInstanceConfig struct {
AuthSecret string // HMAC-SHA1 shared secret AuthSecret string // HMAC-SHA1 shared secret
RelayPortStart int // Start of relay port range RelayPortStart int // Start of relay port range
RelayPortEnd int // End 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 // 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)) 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") certPath := filepath.Join(configDir, "turn-cert.pem")
keyPath := filepath.Join(configDir, "turn-key.pem") keyPath := filepath.Join(configDir, "turn-key.pem")
if cfg.TURNSListenAddr != "" { if cfg.TURNSListenAddr != "" {
if _, err := os.Stat(certPath); os.IsNotExist(err) { if _, err := os.Stat(certPath); os.IsNotExist(err) {
if err := turn.GenerateSelfSignedCert(certPath, keyPath, cfg.PublicIP); err != nil { // Try Let's Encrypt via Caddy first
s.logger.Warn("Failed to generate TURNS self-signed cert, TURNS will be disabled", if cfg.TURNDomain != "" {
zap.String("namespace", namespace), acmeEndpoint := "http://localhost:6001/v1/internal/acme"
zap.Error(err)) caddyCert, caddyKey, provErr := provisionTURNCertViaCaddy(cfg.TURNDomain, acmeEndpoint, 2*time.Minute)
cfg.TURNSListenAddr = "" // Disable TURNS if cert generation fails if provErr == nil {
} else { certPath = caddyCert
s.logger.Info("Generated TURNS self-signed certificate", keyPath = caddyKey
zap.String("namespace", namespace), s.logger.Info("Using Let's Encrypt cert from Caddy for TURNS",
zap.String("cert_path", certPath)) 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)) 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 return err
} }

165
pkg/namespace/turn_cert.go Normal file
View File

@ -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
}

View File

@ -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
}