// Package calls integrates Cloudflare Realtime TURN. // // Cloudflare Realtime issues short-lived ICE-server credentials via: // // POST https://rtc.live.cloudflare.com/v1/turn/keys/{KEY_ID}/credentials/generate-ice-servers // Authorization: Bearer {API_TOKEN} // {"ttl": 86400} // // Response 201: // // { "iceServers": [ // { "urls": ["stun:stun.cloudflare.com:3478", ...] }, // { "urls": ["turn:turn.cloudflare.com:3478?transport=udp", // "turn:turn.cloudflare.com:3478?transport=tcp", // "turns:turn.cloudflare.com:5349?transport=tcp"], // "username": "...", "credential": "..." } // ]} // // Free of charge for cdrop's egress volume (1 TB/mo soft cap is enough). package calls import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "sync" "time" ) const ( endpointTmpl = "https://rtc.live.cloudflare.com/v1/turn/keys/%s/credentials/generate-ice-servers" // DefaultTTL caps how long an issued TURN credential stays valid (R3). The // same bundle is shared by every client of this instance, so a user who // extracts it could relay traffic on cdrop's Cloudflare quota until it // expires; a short TTL keeps that window small. 1h is ample to set up a // transfer (creds authenticate the Allocate; the allocation outlives them). DefaultTTL = 1 * time.Hour requestTimeout = 10 * time.Second ) type ICEServer struct { URLs []string `json:"urls"` Username string `json:"username,omitempty"` Credential string `json:"credential,omitempty"` } type ICEResponse struct { ICEServers []ICEServer `json:"iceServers"` } // Provider is concurrency-safe and caches generated credentials so multiple // concurrent web clients share one upstream call to Cloudflare. type Provider struct { keyID string apiToken string ttl time.Duration refreshMargin time.Duration client *http.Client mu sync.Mutex cached *ICEResponse expiresAt time.Time } // NewProvider returns a Provider configured to mint creds with the given TTL. // ttl ≤ 0 falls back to DefaultTTL. The cache refreshes once a served credential // drops below refreshMargin (a quarter of the TTL, floored at 5m) of remaining // life, so every credential handed out still has a comfortable validity window // even with a short TTL. func NewProvider(keyID, apiToken string, ttl time.Duration) *Provider { if ttl <= 0 { ttl = DefaultTTL } margin := ttl / 4 if margin < 5*time.Minute { margin = 5 * time.Minute } if margin >= ttl { // Pathologically short TTLs: keep margin under the TTL so the cache can // still serve at least briefly rather than refetching on every call. margin = ttl / 2 } return &Provider{ keyID: keyID, apiToken: apiToken, ttl: ttl, refreshMargin: margin, client: &http.Client{Timeout: requestTimeout}, } } // Get returns a fresh ICE-server bundle. Repeated calls within the TTL window // (minus refreshMargin) return the cached value; otherwise a new fetch happens. // On upstream failure, falls back to the most recent cached value if any. func (p *Provider) Get(ctx context.Context) (*ICEResponse, error) { p.mu.Lock() defer p.mu.Unlock() if p.cached != nil && time.Until(p.expiresAt) > p.refreshMargin { return p.cached, nil } creds, err := p.fetch(ctx) if err != nil { if p.cached != nil { slog.Warn("cf turn refresh failed; using cached creds", "err", err, "expires_in", time.Until(p.expiresAt)) return p.cached, nil } return nil, err } p.cached = creds p.expiresAt = time.Now().Add(p.ttl) slog.Info("cf turn creds refreshed", "servers", len(creds.ICEServers), "ttl", p.ttl) return creds, nil } func (p *Provider) fetch(ctx context.Context) (*ICEResponse, error) { body := bytes.NewReader([]byte(fmt.Sprintf(`{"ttl":%d}`, int(p.ttl.Seconds())))) url := fmt.Sprintf(endpointTmpl, p.keyID) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body) if err != nil { return nil, fmt.Errorf("build request: %w", err) } req.Header.Set("Authorization", "Bearer "+p.apiToken) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") resp, err := p.client.Do(req) if err != nil { return nil, fmt.Errorf("cloudflare unreachable: %w", err) } defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusCreated { return nil, fmt.Errorf("cloudflare turn status %d: %s", resp.StatusCode, truncate(string(raw), 200)) } var r ICEResponse if err := json.Unmarshal(raw, &r); err != nil { return nil, fmt.Errorf("decode response: %w", err) } if len(r.ICEServers) == 0 { return nil, errors.New("cloudflare returned empty iceServers") } return &r, nil } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "…" }