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)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
// 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] + "…"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package calls
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The refresh margin scales with the TTL (a quarter, floored at 5m) so a short
|
||||
// R3 TTL still leaves every served credential a comfortable validity window
|
||||
// without refetching on every call.
|
||||
func TestNewProvider_RefreshMargin(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ttl time.Duration
|
||||
want time.Duration
|
||||
}{
|
||||
{"default when zero", 0, DefaultTTL / 4}, // ttl<=0 → DefaultTTL (1h) → 15m
|
||||
{"one hour", time.Hour, 15 * time.Minute}, // quarter
|
||||
{"quarter floored at 5m", 10 * time.Minute, 5 * time.Minute}, // 2.5m → floor 5m, 5m<10m
|
||||
{"pathologically short", 4 * time.Minute, 2 * time.Minute}, // floor 5m >= 4m ttl → ttl/2
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
p := NewProvider("k", "tok", c.ttl)
|
||||
if p.refreshMargin != c.want {
|
||||
t.Errorf("refreshMargin: got %v, want %v", p.refreshMargin, c.want)
|
||||
}
|
||||
if p.refreshMargin >= p.ttl {
|
||||
t.Errorf("margin %v must stay under ttl %v so the cache can serve", p.refreshMargin, p.ttl)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user