mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-03-17 06:23:00 +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)
|
## 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.
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
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