Files
OwnCord/Server/ws/hub_sweep.go
T
J3vbandClaude Fable 5 c86d803a18 fix: resolve the six blocked batch-4 ledger findings (#1393)
* fix(ws): resolve an empty READ audience for a channel whose row is gone

channelReadAudience already failed closed on a GetChannel error; a
deleted channel returns (nil, nil) and fell through to the role scan.
Return nobody for a missing row too — voice teardown callers union the
room's participants and the leaver back in, so their signals still land.

Test locks both halves: the non-participant hears nothing, the leaver
still gets voice_leave. (OC-0090)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): re-elect the key holder in CleanupVoiceForChannel

Every other voice-removal path re-elects (finishVoiceLeave, the LiveKit
webhook, registerNow, rollbackVoiceJoin, sweepStaleVoiceStates); the
channel delete/archive path did not, so a torn-down channel's
voiceKeyHolders entry lived for the process lifetime. One updateKeyHolder
call at the end of the teardown deletes it. (OC-0012)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): implement BroadcastMemberUnban so unban reaches connected clients

The admin unban path reaches the hub through an optional-capability type
assertion that *ws.Hub never satisfied, so it always missed silently and
clients connected during a ban kept the user missing from their member
store. Implement the mirror of BroadcastMemberBan: fan out the same
member_join a fresh connect sends (clients already map it to addMember),
reporting offline since the unbanned user cannot be connected. A
compile-time assertion in admin pins the wiring so the assertion can
never silently miss again. (OC-0058)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): exclude the requester's own flag from the video-cap stream count

EnableCameraIfUnderLimit and EnableScreenshareIfUnderLimit counted every
stream in the channel including the very flag the UPDATE sets, so a user
whose server-side flag was already 1 (client lost track and retried) was
refused at the cap against their own stream, with no path out. Subtract
the outer row's own bit from the correlated count: re-enable becomes
idempotent while the requester's other stream and everyone else's still
count. sqlc layer regenerated. (OC-0081)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(ledger): resolve the six blocked batch-4 findings

Four fixed in this branch (OC-0012, OC-0058, OC-0081, OC-0090), each
with an independent revert-proof pass. Two were already fixed on main by
later sibling fixes and are recorded as such: OC-0086 by the OC-0017
pre-delete re-check (#1374), OC-0101 by the OC-0206 early watermark bump
(#1375). The ledger holds zero open and zero blocked findings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 18:28:00 +02:00

453 lines
17 KiB
Go

package ws
import (
"context"
"log/slog"
"sync/atomic"
"time"
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
"github.com/owncord/server/telemetry"
)
// staleClientTimeout is the maximum duration a client can go without sending
// any message before being considered stale and disconnected. The client sends
// a ping every 30s, so 90s (3x) gives plenty of margin.
const staleClientTimeout = 90 * time.Second
// onStaleTick runs the cheap in-memory maintenance driven by the stale ticker.
func (h *Hub) onStaleTick() {
h.sweepStaleClients()
// Per-channel token buckets are created on first broadcast; prune idle
// ones here or the bucket map grows for the process lifetime.
h.topicLimiter.Cleanup(10 * time.Minute)
h.refreshTelemetryGauges()
}
// refreshTelemetryGauges recomputes the connection and voice gauges from live
// client state. Periodic (30s tick, driven by the ctx-less Run loop) rather
// than event-driven: join/leave and register/unregister paths are spread
// across handlers, sweeps, and webhooks — many of them request-scoped, where
// a context.Background() instrumentation call would trip contextcheck — and a
// gauge only needs to be right at scrape time.
func (h *Hub) refreshTelemetryGauges() {
participants := 0
rooms := make(map[int64]struct{})
h.mu.RLock()
connected := len(h.clients)
for _, c := range h.clients {
if chID := c.getVoiceChID(); chID != 0 {
participants++
rooms[chID] = struct{}{}
}
}
h.mu.RUnlock()
m := telemetry.NewAppMetrics()
ctx := context.Background()
m.WSActiveConnections.Set(ctx, float64(connected))
m.VoiceParticipants.Set(ctx, float64(participants))
m.VoiceActiveSessions.Set(ctx, float64(len(rooms)))
}
// kickClient forcibly removes a client from the hub and closes its send channel,
// which causes writePump to exit and the WebSocket connection to close.
// It is safe to call from any goroutine.
func (h *Hub) kickClient(c *Client) {
h.mu.Lock()
if current, ok := h.clients[c.userID]; ok && current == c {
delete(h.clients, c.userID)
}
h.mu.Unlock()
// closeSend BEFORE UnsubscribeAll: Subscribe's only re-take guard is
// isSendClosed, so a Subscribe racing this kick either lands before the
// close (and UnsubscribeAll below removes it) or sees the closed channel
// and refuses. The reverse order leaves the dead client holding the topic.
c.closeSend()
h.pubsub.UnsubscribeAll(c)
}
// startSweep runs sweep on its own goroutine so the hub dispatch loop never
// blocks on the DB-heavy periodic sweeps (they already lock correctly for
// concurrent execution with the hub). inFlight guarantees a sweep never runs
// concurrently with itself: a tick arriving while the previous run is still
// going is dropped, and the next tick retries.
func (h *Hub) startSweep(inFlight *atomic.Bool, sweep func()) {
if !inFlight.CompareAndSwap(false, true) {
return
}
go func() {
defer inFlight.Store(false)
sweep()
}()
}
// sweepStaleClients iterates over all connected clients and kicks any that
// have not sent a message within staleClientTimeout.
func (h *Hub) sweepStaleClients() {
now := time.Now()
h.mu.RLock()
var stale []*Client
for _, c := range h.clients {
if now.Sub(c.getLastActivity()) > staleClientTimeout {
stale = append(stale, c)
}
}
h.mu.RUnlock()
for _, c := range stale {
slog.Warn("hub: closing stale connection (no activity)",
"user_id", c.userID, "last_activity", c.getLastActivity())
h.kickClient(c)
}
}
// sweepRevokedSessions iterates all connected clients and kicks any whose
// session has been deleted, expired, or whose user has been banned. This
// provides time-based session enforcement for idle WebSocket connections
// that never trigger the message-count-based check (BUG-109).
func (h *Hub) sweepRevokedSessions() {
if h.db == nil {
return
}
// Hub run-loop sweeper — no request tie.
ctx := context.Background()
h.mu.RLock()
snapshot := make([]*Client, 0, len(h.clients))
for _, c := range h.clients {
if c.tokenHash != "" {
snapshot = append(snapshot, c)
}
}
h.mu.RUnlock()
if len(snapshot) == 0 {
return
}
// One batched lookup for every connected client instead of a query per
// client per sweep.
hashes := make([]string, len(snapshot))
for i, c := range snapshot {
hashes[i] = c.tokenHash
}
sessions, err := h.db.GetSessionsWithBanStatusBatch(ctx, hashes)
if err != nil {
// A failed batch lookup says nothing about any individual session —
// kicking everyone on a transient DB error would be a mass disconnect.
// Skip this sweep; the next tick retries.
slog.Warn("session sweep: batch session lookup failed", "err", err)
return
}
for _, c := range snapshot {
result := sessions[c.tokenHash]
if result == nil || auth.IsSessionExpired(result.ExpiresAt) {
slog.Info("session sweep: revoked/expired session, disconnecting",
"user_id", c.userID)
h.kickClient(c)
continue
}
tempUser := &db.User{Banned: result.Banned, BanExpires: result.BanExpires}
if auth.IsEffectivelyBanned(tempUser) {
slog.Info("session sweep: banned user, disconnecting",
"user_id", c.userID)
c.sendMsg(buildErrorMsg(ErrCodeBanned, "you are banned"))
h.kickClient(c)
}
}
}
// sweepStaleVoiceEvictRevoked is sweepStaleVoiceStates' permission stage: it
// re-checks CONNECT_VOICE for every client currently in voice and evicts the
// ones who no longer hold it.
func (h *Hub) sweepStaleVoiceEvictRevoked(ctx context.Context) {
// Revocation must evict a live session, not merely block the next join.
// Nothing else in ws re-validates voice permissions for a connection that
// stays open, so a user stripped of CONNECT_VOICE kept their SFU session
// until they disconnected. Checked once a minute, and only for the handful
// of clients actually in voice.
h.mu.RLock()
inVoice := make([]*Client, 0, len(h.clients))
for _, c := range h.clients {
if c.getVoiceChID() != 0 {
inVoice = append(inVoice, c)
}
}
h.mu.RUnlock()
for _, c := range inVoice {
chID := c.getVoiceChID()
if chID == 0 {
continue
}
allowed, err := h.hasChannelPermChecked(ctx, c.userID, chID, permissions.ConnectVoice)
if err != nil {
// A transient read failure (I/O error, lock contention, a
// maintenance window) is not a revocation — hasChannelPerm and
// permissions.Checker.HasChannelPerm both collapse any DB error
// to "denied", which would otherwise evict every in-voice
// participant on one bad read. Skip this client this tick; the
// next tick retries. Mirrors sweepRevokedSessions' guard on its
// own batch lookup below.
slog.Warn("sweepStaleVoiceStates: permission check failed, skipping this tick",
"user_id", c.userID, "channel_id", chID, "err", err)
continue
}
if allowed {
continue
}
// The permission check is a DB round-trip; a voice_join to a
// still-permitted channel may have committed while it ran. The
// eviction is conditional on the client still being in the checked
// channel — never on whatever channel it is in by now.
if !h.handleVoiceLeaveIfStillIn(ctx, c, chID) {
continue
}
slog.Warn("sweepStaleVoiceStates: evicted participant whose CONNECT_VOICE was revoked",
"user_id", c.userID, "channel_id", chID)
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "missing CONNECT_VOICE permission"))
}
}
// sweepStaleVoiceStates queries all voice_states rows and removes any that
// don't match a connected client's voiceChID. This catches ghost users that
// slip through the primary cleanup paths (registerNow, readPump defer,
// LiveKit webhook).
func (h *Hub) sweepStaleVoiceStates() {
if h.db == nil {
return
}
// Hub run-loop sweeper — no request tie.
ctx := context.Background()
h.sweepStaleVoiceEvictRevoked(ctx)
allStates, err := h.db.GetAllVoiceStates(ctx)
if err != nil {
slog.Warn("sweepStaleVoiceStates: GetAllVoiceStates failed", "err", err)
return
}
if len(allStates) == 0 {
return
}
h.mu.RLock()
var stale []struct {
userID int64
channelID int64
joinedAt string
}
for _, vs := range allStates {
c, ok := h.clients[vs.UserID]
if !ok || c.getVoiceChID() != vs.ChannelID {
stale = append(stale, struct {
userID int64
channelID int64
joinedAt string
}{vs.UserID, vs.ChannelID, vs.JoinedAt})
}
}
h.mu.RUnlock()
for _, s := range stale {
if sweepStaleVoiceJoinRaceHook != nil {
sweepStaleVoiceJoinRaceHook(s.userID, s.channelID, s.joinedAt)
}
// Re-check the live client immediately before deleting (OC-0017).
// voice_join.go commits the row before it calls c.setVoiceState
// (BUG-088's ordering), so a join can commit and get snapshotted as a
// ghost above — with c.setVoiceState landing after that snapshot but
// before this delete runs. If the client's current voice state now
// agrees with the row we are about to delete, the join has caught up
// and this is no longer stale: deleting it would leave the client
// "in voice" in memory with no DB row, the one ghost state nothing
// else heals.
h.mu.RLock()
liveClient, liveOK := h.clients[s.userID]
h.mu.RUnlock()
if liveOK {
if liveChID, liveJoinedAt := liveClient.getVoiceState(); liveChID == s.channelID && liveJoinedAt == s.joinedAt {
continue
}
}
// Channel-conditional delete: only removes the row if it still points
// at the channel we snapshotted. If the user rejoined or moved between
// the snapshot and now, the delete is a no-op and we skip the broadcast.
deleted, err := h.db.LeaveVoiceChannelIfMatch(ctx, s.userID, s.channelID, s.joinedAt)
if err != nil {
slog.Error("sweepStaleVoiceStates: LeaveVoiceChannelIfMatch failed",
"err", err, "user_id", s.userID, "channel_id", s.channelID)
continue
}
if !deleted {
continue
}
slog.Warn("sweepStaleVoiceStates: removed ghost voice state",
"user_id", s.userID, "channel_id", s.channelID)
h.broadcastVoiceEvent(ctx, s.channelID, buildVoiceLeave(s.channelID, s.userID))
// Re-elect the key holder now that this ghost row is gone — every
// other path that removes a voice participant does this
// (finishVoiceLeave, the LiveKit webhook, registerNow,
// handleVoiceJoin). Without it, a departed user stripped out here
// while still named as key holder leaves the remaining lowest-uid
// participant self-promoting and rotating the room key locally, and
// its voice_e2ee_offers are rejected with NOT_KEY_HOLDER until the
// next join/leave in the channel. No locks are held here, matching
// the webhook call site.
h.updateKeyHolder(s.channelID)
if h.livekit != nil {
_ = h.livekit.RemoveParticipant(ctx, s.channelID, s.userID, s.joinedAt)
}
}
}
// sweepStaleVoiceJoinRaceHook, when non-nil, runs once per stale entry,
// immediately before sweepStaleVoiceStates acts on it. Test-only (always nil
// in production): it pins the BUG-088 follow-on window (OC-0017) where a
// voice_join's DB commit (voice_join.go's JoinVoiceChannelIfCapacity) and its
// c.setVoiceState call are not atomic — a join can commit its row, get
// snapshotted as a ghost by the h.clients scan above (the joiner's client
// still shows voiceChID 0 at that instant), and only call c.setVoiceState
// after the snapshot but before this loop deletes the row it just committed.
// Too narrow a window to land reliably by staggering real goroutines, so
// tests use this hook to reproduce it deterministically.
var sweepStaleVoiceJoinRaceHook func(userID, channelID int64, joinedAt string)
// hasChannelPermChecked is hasChannelPerm's error-aware counterpart: it
// distinguishes a genuine permission denial (role missing, or the effective
// permission bits don't include perm) from a DB read failure, by inlining the
// same resolution hasChannelPerm/permissions.Checker.HasChannelPerm perform —
// both of which collapse any error into "denied", indistinguishable from a
// real revocation. sweepStaleVoiceStates needs that distinction: unlike a
// handler answering one client's request, it evicts a live voice session on
// "denied", so a transient read failure must not be treated as a revocation.
func (h *Hub) hasChannelPermChecked(ctx context.Context, userID, channelID int64, perm int64) (allowed bool, err error) {
role, err := h.db.GetRoleForUser(ctx, userID)
if err != nil {
return false, err
}
if role == nil {
// No role row is a genuine deny, not an error — mirrors
// hasChannelPerm's role == nil case.
return false, nil
}
if permissions.HasAdmin(role.Permissions) {
return true, nil
}
allow, deny, err := h.db.GetChannelPermissions(ctx, channelID, role.ID)
if err != nil {
return false, err
}
o := permissions.ChannelOverride{Allow: allow, Deny: deny}
if userID != 0 {
uAllow, uDeny, uErr := h.db.GetUserChannelPermissions(ctx, channelID, userID)
if uErr != nil {
return false, uErr
}
o.UserAllow, o.UserDeny = uAllow, uDeny
}
return permissions.EffectiveChannelPerms(role.Permissions, o)&perm == perm, nil
}
// cleanupVoiceRaceClearHook, when non-nil, runs immediately before
// CleanupVoiceForChannel clears a still-matching client's voice state.
// Test-only (always nil in production): the window it pins is two separate
// voiceMu acquisitions with no I/O between them, too narrow to land reliably
// by staggering real goroutines, so tests use this hook to reproduce a
// voice_join racing in at exactly that point deterministically.
var cleanupVoiceRaceClearHook func(*Client)
// CleanupVoiceForChannel removes all voice participants from the given channel.
// Called when a channel is deleted.
func (h *Hub) CleanupVoiceForChannel(channelID int64) {
// Cleanup must complete even if the triggering request goes away.
ctx := context.Background()
// Get all users in the channel's voice state from DB.
states, err := h.db.GetChannelVoiceStates(ctx, channelID)
if err != nil {
slog.Error("CleanupVoiceForChannel GetChannelVoiceStates", "err", err, "channel_id", channelID)
return
}
if len(states) == 0 {
return
}
// Clean up DB state and LiveKit for each participant. Both the row delete
// and the client-state clear are conditional on the participant still
// being in THIS channel: a user who moved to another voice channel
// between the snapshot above and this loop must not be clobbered.
for _, vs := range states {
if _, err := h.db.LeaveVoiceChannelIfMatch(ctx, vs.UserID, channelID, vs.JoinedAt); err != nil {
slog.Error("CleanupVoiceForChannel LeaveVoiceChannelIfMatch", "err", err, "user_id", vs.UserID, "channel_id", channelID)
}
// Clear client voice state and its voice-topic subscription. The
// compare (still in this channel?) and the clear must be one atomic
// operation — a getVoiceChID() read followed by a separate
// unconditional clear leaves a window where a concurrent voice_join
// to another channel commits in between and gets silently wiped
// along with its own voice-topic subscription (OC-0050). Mirrors
// sweepStaleVoiceStates' handleVoiceLeaveIfStillIn /
// clearVoiceStateIfMatch and the LiveKit webhook's inline
// compare-and-clear.
h.mu.RLock()
client, ok := h.clients[vs.UserID]
h.mu.RUnlock()
if ok {
if cleanupVoiceRaceClearHook != nil {
cleanupVoiceRaceClearHook(client)
}
if _, cleared := client.clearVoiceStateIfMatch(channelID); cleared {
h.pubsub.Unsubscribe(client, VoiceTopic(channelID))
}
}
// Remove from LiveKit (best-effort).
if h.livekit != nil {
_ = h.livekit.RemoveParticipant(ctx, channelID, vs.UserID, vs.JoinedAt)
}
}
// Broadcast voice_leave for each participant. All leaves target the same
// channel, so resolve the READ audience once and reuse it per message.
// The evicted participants themselves must always be in it (their client
// state is already cleared, so broadcastVoiceEvent's participant union
// cannot see them): the voice_leave is what drives their own E2EE
// teardown, and voice membership never required READ_MESSAGES.
//
// Both callers of CleanupVoiceForChannel commit archived=1 to this
// channel before evicting (OC-0022) — deliberately, so a concurrent
// voice_join sees the archived gate — which means plain
// channelReadAudience always finds the channel already archived and
// always returns nobody here. Use the ignoring-archived resolver so the
// bystanders who could see this channel and its voice roster a moment
// ago still learn the call ended, not just the evicted participants.
audience := h.channelReadAudienceIgnoringArchived(ctx, channelID)
seen := make(map[int64]struct{}, len(audience))
for _, uid := range audience {
seen[uid] = struct{}{}
}
for _, vs := range states {
if _, ok := seen[vs.UserID]; !ok {
seen[vs.UserID] = struct{}{}
audience = append(audience, vs.UserID)
}
}
for _, vs := range states {
h.broadcastChannelScopedTo(channelID, buildVoiceLeave(channelID, vs.UserID), audience, "voice event")
}
// Re-elect the key holder now that the room is torn down — every other
// removal path does this (finishVoiceLeave, the LiveKit webhook,
// registerNow, rollbackVoiceJoin, sweepStaleVoiceStates). All client
// voice states for this channel were cleared above, so this deletes the
// voiceKeyHolders entry; without it a deleted channel's entry lived for
// the process lifetime (OC-0012). No locks are held here, as
// updateKeyHolder requires.
h.updateKeyHolder(channelID)
}