f21fa5b5e8
Commilitia Drop:自托管的跨设备剪贴板同步与点对点文件传输。 - 后端 Go(chi / SQLite WAL / SSE Hub / WebRTC signaling + 状态机 / Relay ring buffer),编译进单个 distroless 镜像(前端 go:embed)。 - 前端 React + TanStack Router + Zustand,自实现 SSE + WebRTC P2P,NAT 受阻时回退服务端中继;聚珍(Juzhen)CJK 综合排版。 - 桌面端 Wails v2(macOS / Windows),瘦客户端复用 web。 - 鉴权 OIDC PKCE(自建 Casdoor 等),refresh_token 信封加密存系统密钥库;iOS Shortcut 用 HS256 scoped token。 架构文档与变更记录见 docs 分支(PROJECT_BRIEF / FRONTEND_DESIGN / CHANGELOG)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
162 lines
4.8 KiB
Go
162 lines
4.8 KiB
Go
// 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] + "…"
|
|
}
|