mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-03-17 06:43:01 +00:00
feat: add TURN domain configuration and certificate provisioning via Caddy
This commit is contained in:
parent
714a986a78
commit
85eb98ed34
@ -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: <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
|
||||
|
||||
```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.
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
165
pkg/namespace/turn_cert.go
Normal file
165
pkg/namespace/turn_cert.go
Normal 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
|
||||
}
|
||||
204
pkg/namespace/turn_cert_test.go
Normal file
204
pkg/namespace/turn_cert_test.go
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user