Files
admin f21fa5b5e8 cdrop — 跨 OS 剪贴板与文件传输服务
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)。

本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
2026-06-15 21:38:28 +08:00

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] + "…"
}