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) } } } }