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)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
270 lines
7.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|