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

270 lines
7.9 KiB
Go

package relay
import (
"context"
"errors"
"log/slog"
"sync"
"sync/atomic"
"time"
)
const (
DefaultMaxSessions = 8
DefaultMaxSessionsPerUser = 3
DefaultSessionCap = 64 << 20 // 64 MiB
DefaultChunkLimit = 1 << 20 // 1 MiB
// DefaultIdleTTL is how long a registered relay session may sit without any
// chunk/stream activity before the reaper tears it down. A fallback that
// registered but never got used (both peers vanished) is reclaimed rather
// than pinning a slot — and, once bytes flowed, its 64 MiB ring — forever.
DefaultIdleTTL = 2 * time.Minute
// DefaultReapPeriod is how often the reaper goroutine sweeps for idle sessions.
DefaultReapPeriod = 30 * time.Second
)
var (
ErrTooManySessions = errors.New("relay: too many active sessions")
ErrForbidden = errors.New("relay: caller is not a participant of this session")
ErrNotRegistered = errors.New("relay: session not registered")
)
// Session pairs a Ring with the metadata that proves who is allowed to use it.
// The ring is allocated lazily on the first Acquire so a registered-but-unused
// fallback costs only bookkeeping until bytes actually flow.
type Session struct {
ID string
UserID string
Sender string // device name of the legitimate sender (POSTs chunks)
Receiver string // device name of the legitimate receiver (GETs the stream)
capBytes int
ring *Ring // nil until first Acquire; guarded by Manager.mu
bytesWritten atomic.Int64
lastActivity atomic.Int64 // unix seconds; the reaper's idle clock
readerActive atomic.Bool // single-consumer guard (a second reader tears the ring)
}
func (s *Session) touch() { s.lastActivity.Store(time.Now().Unix()) }
func (s *Session) Write(ctx context.Context, p []byte) (int, error) {
s.touch()
n, err := s.ring.Write(ctx, p)
if n > 0 {
s.bytesWritten.Add(int64(n))
}
return n, err
}
func (s *Session) Read(ctx context.Context, p []byte) (int, error) {
s.touch()
return s.ring.Read(ctx, p)
}
func (s *Session) CloseWriter() {
if s.ring != nil {
s.ring.CloseWriter()
}
}
func (s *Session) Abort() {
if s.ring != nil {
s.ring.Abort()
}
}
func (s *Session) BytesWritten() int64 { return s.bytesWritten.Load() }
// AcquireReader claims the single consumer slot. The relay stream is strictly
// single-consumer: two concurrent readers would each drain bytes out of the
// ring and silently corrupt the receiver's copy (G2). A second reader gets a
// false here so the handler can 409 instead of joining.
func (s *Session) AcquireReader() bool { return s.readerActive.CompareAndSwap(false, true) }
// ReleaseReader frees the consumer slot when the stream handler returns.
func (s *Session) ReleaseReader() { s.readerActive.Store(false) }
// Manager owns active relay sessions. A session must be Register-ed (which the
// transfer service does when a transfer enters RELAY_ACTIVE) before either peer
// can Acquire it. This is the core of the R1/G2 hardening: the old Acquire
// minted a session for any unknown id on demand, so 8 one-byte requests could
// exhaust every slot and pin ~512 MiB. Now only legitimate, server-minted
// transfers that actually reached RELAY_ACTIVE get a slot, capped both globally
// and per-user, and reclaimed when idle.
type Manager struct {
mu sync.Mutex
sessions map[string]*Session
perUser map[string]int
maxSessions int
maxPerUser int
sessionCap int
idleTTL time.Duration
}
func NewManager(maxSessions, sessionCapBytes int) *Manager {
if maxSessions <= 0 {
maxSessions = DefaultMaxSessions
}
if sessionCapBytes <= 0 {
sessionCapBytes = DefaultSessionCap
}
maxPerUser := DefaultMaxSessionsPerUser
if maxPerUser > maxSessions {
// A per-user cap above the global cap is meaningless; clamp so small
// global caps (tests, constrained deploys) still behave sanely.
maxPerUser = maxSessions
}
return &Manager{
sessions: map[string]*Session{},
perUser: map[string]int{},
maxSessions: maxSessions,
maxPerUser: maxPerUser,
sessionCap: sessionCapBytes,
idleTTL: DefaultIdleTTL,
}
}
// Register reserves a relay slot for a transfer that has just entered
// RELAY_ACTIVE, recording its two legitimate participants. Only a registered
// session can later be joined via Acquire.
//
// Idempotent for the same (id, userID): a duplicate Register just refreshes the
// idle clock. A mismatched userID is ErrForbidden (ids are server-minted, so
// this is defensive). Returns ErrTooManySessions when the global or per-user
// cap is reached — the caller (transfer service) surfaces that as a retryable
// failure rather than moving the transfer into a relay mode with no slot.
func (m *Manager) Register(id, userID, sender, receiver string) error {
m.mu.Lock()
defer m.mu.Unlock()
if s, ok := m.sessions[id]; ok {
if s.UserID != userID {
return ErrForbidden
}
s.touch()
return nil
}
if len(m.sessions) >= m.maxSessions {
return ErrTooManySessions
}
if m.perUser[userID] >= m.maxPerUser {
return ErrTooManySessions
}
s := &Session{
ID: id,
UserID: userID,
Sender: sender,
Receiver: receiver,
capBytes: m.sessionCap,
}
s.touch()
m.sessions[id] = s
m.perUser[userID] = m.perUser[userID] + 1
return nil
}
// Acquire joins an already-registered relay session as one of its two
// participants. Unlike the old implementation it never creates a session:
// - unknown id → ErrNotRegistered
// - different user → ErrForbidden
// - not sender/receiver → ErrForbidden
//
// device is the caller's X-Device-Name (set by the auth middleware). The 64 MiB
// ring is allocated here, lazily, on the first join.
func (m *Manager) Acquire(id, userID, device string) (*Session, error) {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[id]
if !ok {
return nil, ErrNotRegistered
}
if s.UserID != userID {
return nil, ErrForbidden
}
if device == "" || (device != s.Sender && device != s.Receiver) {
return nil, ErrForbidden
}
if s.ring == nil {
s.ring = newRing(s.capBytes)
}
s.touch()
return s, nil
}
// Release removes the session from the active set and aborts any blocked
// Read/Write. Safe to call multiple times (terminal transitions and the pending
// sweeper both call it). A no-op for an id that was never registered.
func (m *Manager) Release(id string) {
m.mu.Lock()
s, ok := m.sessions[id]
if ok {
delete(m.sessions, id)
m.decUserLocked(s.UserID)
}
m.mu.Unlock()
if ok {
s.Abort()
}
}
// decUserLocked drops one from a user's active count, deleting the key at zero
// so perUser never accumulates empty entries. Caller holds m.mu.
func (m *Manager) decUserLocked(userID string) {
if n := m.perUser[userID]; n <= 1 {
delete(m.perUser, userID)
} else {
m.perUser[userID] = n - 1
}
}
// Active reports the current count of held sessions.
func (m *Manager) Active() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.sessions)
}
// reapIdle aborts and removes every session whose last activity is older than
// the idle TTL, returning how many it reclaimed. now is unix seconds (injected
// so tests don't depend on the wall clock).
func (m *Manager) reapIdle(now int64) int {
cutoff := now - int64(m.idleTTL/time.Second)
var dead []*Session
m.mu.Lock()
for id, s := range m.sessions {
if s.lastActivity.Load() < cutoff {
delete(m.sessions, id)
m.decUserLocked(s.UserID)
dead = append(dead, s)
}
}
m.mu.Unlock()
for _, s := range dead {
s.Abort()
}
return len(dead)
}
// RunReaper blocks until ctx is cancelled, periodically reclaiming idle relay
// sessions (fallbacks that registered but never completed — e.g. both peers
// went away mid-transfer). Mirrors transfer.RunSweeper; start it once at boot.
func (m *Manager) RunReaper(ctx context.Context) {
t := time.NewTicker(DefaultReapPeriod)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if n := m.reapIdle(time.Now().Unix()); n > 0 {
slog.Info("relay reaper: reclaimed idle sessions", "count", n)
}
}
}
}