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,269 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user