Files
OwnCord/Server/ws/livekit.go
T
J3vbandClaude b8b7a2a1f9 fix: correctness fixes across LiveKit voice, client session and transport paths (#1374)
* fix: enhance bugfix workflow documentation with detailed clustering and staging instructions

* fix(voice): 6 defect(s) (OC-0001, OC-0006, OC-0009, OC-0010, OC-0015, OC-0029)

* fix(voice): 1 defect(s) (OC-0005)

* fix(client): 1 defect(s) (OC-0007)

* fix(client): 1 defect(s) (OC-0011)

* fix(client): 1 defect(s) (OC-0012)

* fix(admin): 1 defect(s) (OC-0013)

* fix(client): 3 defect(s) (OC-0014, OC-0024, OC-0031)

* fix(voice): 1 defect(s) (OC-0018)

* fix(voice): 1 defect(s) (OC-0019)

* fix(client): 1 defect(s) (OC-0021)

* fix(client): 1 defect(s) (OC-0025)

* fix(ws): 1 defect(s) (OC-0026)

* fix(client): 1 defect(s) (OC-0027)

* fix(client): 1 defect(s) (OC-0028)

* fix(identity): 1 defect(s) (OC-0030)

* fix(voice): 1 defect(s) (OC-0016)

* fix(client): 2 defect(s) (OC-0002, OC-0020)

OC-0002: chain offer handling behind the announce chain so an offer that
arrives immediately behind its sender's announce is not dropped as an
unknown peer.

OC-0020: retire a departing peer's ECDH key on participant-left so a
replayed pre-leave announce cannot overwrite the fresh key they rejoined
with.

* fix(voice): 1 defect(s) (OC-0008)

handleVoiceJoin handed the client its LiveKit token before checking whether
the join had been superseded by a concurrent eviction (moderator kick/move,
the CONNECT_VOICE revocation sweep, CleanupVoiceForChannel). Those evictors
delete the voice_states row, clear the client's in-memory state, and call
RemoveParticipant — which no-ops because the join has not reached the SFU
yet. The client was left holding a live 5-minute RoomJoin credential for a
membership the server had just torn down.

Re-check the client's voice state immediately after GenerateToken and
withhold the credential if the join was superseded, with a best-effort
RemoveParticipant to match every other eviction path.

* fix(ws): 2 defect(s) (OC-0017, OC-0022)

OC-0017: sweepStaleVoiceStates re-checks the live client immediately before
deleting a snapshotted-stale voice_states row. voice_join commits the row
before calling c.setVoiceState, so a join that lands inside that window was
snapshotted as a ghost and had its just-committed row deleted, leaving the
client in voice in memory with no DB row.

OC-0022: CleanupVoiceForChannel resolves its voice_leave audience with a
variant of channelReadAudience that skips the archived short-circuit. Both
production callers archive the channel before evicting, so the plain
resolver always returned an empty audience and only the evicted
participants learned the call ended.

* fix(voice): 1 defect(s) (OC-0023)

Camera and screenshare now draw from the same per-channel voice_max_video
budget. handleVoiceScreenshareV2 performed no cap check at all, and the
camera gate's slot-count subquery counted only `camera = 1` rows, so a
screensharing occupant was invisible to it. Both gates now count
`camera = 1 OR screenshare = 1` via a shared enableVideoSlot helper.

* fix(client): 2 defect(s) (OC-0032, OC-0033)

OC-0033: voice_disconnected staleness guard swallowed the kick toast when
the sibling voice_leave had already cleared currentChannelId. Treat a
cleared store as not-stale.

OC-0032: VIDEO_LIMIT rollback assumed the camera, tearing down a working
camera and leaving refused screen tracks published. Correlate by envelope
id and roll back the kind that was actually refused.

* fix(voice): 1 defect(s) (OC-0034)

* fix(client): 1 defect(s) (OC-0035)

A superseded video-enable id makes rollbackPendingVideo return undefined.
The dispatcher's ternary treated undefined as "not screen" and called
disableCamera(), tearing down a working camera the user never touched.
Return early instead: undefined means there is nothing to roll back.

* fix(voice): 1 defect(s) (OC-0036)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-15 12:57:51 +02:00

286 lines
8.9 KiB
Go

// Package ws provides the LiveKit integration client.
//
// LiveKitClient wraps the LiveKit server SDK for token generation and
// room management. It is the primary interface between OwnCord's WS
// handlers and the LiveKit server.
package ws
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/livekit/protocol/auth"
"github.com/livekit/protocol/livekit"
lksdk "github.com/livekit/server-sdk-go/v2"
"github.com/owncord/server/config"
)
// tokenTTL is the validity duration for generated LiveKit access tokens.
// Short-lived (5 min) to limit replay window (BUG-127). The client requests
// a refresh via voice_token_refresh before expiry. Security mitigations:
// - Tokens are scoped to a single room
// - Server can revoke room access via LiveKit API on ban/kick
// - Webhook participant_joined validates voice_states membership
// - CanPublishSources restricts track types per permission (BUG-128)
const tokenTTL = 5 * time.Minute
// LiveKitClient provides token generation and room management via
// the LiveKit server SDK.
type LiveKitClient struct {
apiKey string
apiSecret string
url string
roomSvc *lksdk.RoomServiceClient
}
// NewLiveKitClient creates a new LiveKit client from the voice config.
// Returns an error if the credentials are missing or still set to the
// well-known default dev values (which are public in the source code).
func NewLiveKitClient(cfg *config.VoiceConfig) (*LiveKitClient, error) {
if cfg.LiveKitAPIKey == "" || cfg.LiveKitAPISecret == "" {
return nil, fmt.Errorf("livekit: api_key and api_secret are required")
}
if cfg.LiveKitURL == "" {
return nil, fmt.Errorf("livekit: url is required")
}
if config.IsDefaultVoiceCredentials(cfg) {
return nil, fmt.Errorf("livekit: refusing to start with default dev credentials — set voice.livekit_api_key and voice.livekit_api_secret in config.yaml")
}
// LiveKit room service client uses the HTTP URL (not WS).
// Convert ws:// to http:// and wss:// to https:// for the REST API.
httpURL := wsToHTTP(cfg.LiveKitURL)
roomSvc := lksdk.NewRoomServiceClient(httpURL, cfg.LiveKitAPIKey, cfg.LiveKitAPISecret)
slog.Info("livekit: client initialized",
"url", cfg.LiveKitURL,
"http_url", httpURL)
return &LiveKitClient{
apiKey: cfg.LiveKitAPIKey,
apiSecret: cfg.LiveKitAPISecret,
url: cfg.LiveKitURL,
roomSvc: roomSvc,
}, nil
}
// RoomName returns the LiveKit room name for an OwnCord channel.
func RoomName(channelID int64) string {
return fmt.Sprintf("channel-%d", channelID)
}
func participantIdentity(userID int64, voiceJoinToken string) string {
if voiceJoinToken == "" {
return fmt.Sprintf("user-%d", userID)
}
return fmt.Sprintf("user-%d:%s", userID, voiceJoinToken)
}
// GenerateToken creates a LiveKit access token for the given user
// to join the specified channel's voice room.
//
// canPublish controls audio publishing (SpeakVoice permission).
// canVideo and canScreenShare control which additional track sources are
// allowed at the SFU level via CanPublishSources, preventing users from
// bypassing OwnCord's USE_VIDEO/SHARE_SCREEN checks via raw LiveKit (BUG-128).
func (c *LiveKitClient) GenerateToken(
userID int64,
username string,
channelID int64,
voiceJoinToken string,
canPublish bool,
canSubscribe bool,
canVideo bool,
canScreenShare bool,
) (string, error) {
roomName := RoomName(channelID)
identity := participantIdentity(userID, voiceJoinToken)
at := auth.NewAccessToken(c.apiKey, c.apiSecret)
grant := &auth.VideoGrant{
RoomJoin: true,
Room: roomName,
CanSubscribe: &canSubscribe,
}
// Use CanPublishSources to restrict which track types the user may
// publish. This supersedes CanPublish and prevents SFU-level bypass.
//
// SPEAK_VOICE (microphone), USE_VIDEO (camera) and SHARE_SCREEN (screen
// share) are independent permission bits — a channel override can deny
// SPEAK_VOICE while still granting USE_VIDEO/SHARE_SCREEN (OC-0016). The
// source list is therefore built from all three independently; CanPublish
// is only used as a hard deny when none of them grant anything, since
// LiveKit's GetCanPublishSource treats CanPublish=false as an override
// that blocks every source regardless of CanPublishSources.
var sources []string
if canPublish {
sources = append(sources, "microphone")
}
if canVideo {
sources = append(sources, "camera")
}
if canScreenShare {
sources = append(sources, "screen_share", "screen_share_audio")
}
if len(sources) > 0 {
grant.CanPublishSources = sources
} else {
grant.CanPublish = &canPublish
}
grant.CanPublishData = &canPublish
at.SetVideoGrant(grant).
SetIdentity(identity).
SetName(username).
SetValidFor(tokenTTL)
token, err := at.ToJWT()
if err != nil {
return "", fmt.Errorf("livekit: generating token: %w", err)
}
slog.Debug("livekit: generated token",
"identity", identity,
"room", roomName,
"can_publish", canPublish,
"can_video", canVideo,
"can_screen_share", canScreenShare)
return token, nil
}
// URL returns the LiveKit WebSocket URL for client connections.
func (c *LiveKitClient) URL() string {
return c.url
}
// lkTimeout is the maximum duration for LiveKit SDK calls (remove, list, etc.).
const lkTimeout = 5 * time.Second
// RemoveParticipant forcefully disconnects a participant from a room.
func (c *LiveKitClient) RemoveParticipant(ctx context.Context, channelID int64, userID int64, voiceJoinToken string) error {
roomName := RoomName(channelID)
identity := participantIdentity(userID, voiceJoinToken)
ctx, cancel := context.WithTimeout(ctx, lkTimeout)
defer cancel()
_, err := c.roomSvc.RemoveParticipant(ctx, &livekit.RoomParticipantIdentity{
Room: roomName,
Identity: identity,
})
if err != nil {
return fmt.Errorf("livekit: removing participant %s from %s: %w", identity, roomName, err)
}
slog.Info("livekit: removed participant",
"identity", identity,
"room", roomName)
return nil
}
// MuteParticipantAudio mutes or unmutes every microphone track the participant
// publishes, so a moderator's server mute holds at the SFU instead of relying
// on the target's client to honor it. A participant with no published audio
// track yet is not an error: the room join grant is re-derived on the next
// token mint, and the client refuses its own unmute while server_muted.
func (c *LiveKitClient) MuteParticipantAudio(ctx context.Context, channelID, userID int64, voiceJoinToken string, muted bool) error {
roomName := RoomName(channelID)
identity := participantIdentity(userID, voiceJoinToken)
ctx, cancel := context.WithTimeout(ctx, lkTimeout)
defer cancel()
p, err := c.roomSvc.GetParticipant(ctx, &livekit.RoomParticipantIdentity{
Room: roomName,
Identity: identity,
})
if err != nil {
return fmt.Errorf("livekit: getting participant %s in %s: %w", identity, roomName, err)
}
for _, t := range p.Tracks {
if t.Type != livekit.TrackType_AUDIO {
continue
}
if _, mErr := c.roomSvc.MutePublishedTrack(ctx, &livekit.MuteRoomTrackRequest{
Room: roomName,
Identity: identity,
TrackSid: t.Sid,
Muted: muted,
}); mErr != nil {
return fmt.Errorf("livekit: muting track %s of %s: %w", t.Sid, identity, mErr)
}
}
slog.Info("livekit: server mute applied",
"identity", identity,
"room", roomName,
"muted", muted)
return nil
}
// ListParticipants returns all participants in a channel's voice room.
func (c *LiveKitClient) ListParticipants(channelID int64) ([]*livekit.ParticipantInfo, error) {
roomName := RoomName(channelID)
ctx, cancel := context.WithTimeout(context.Background(), lkTimeout)
defer cancel()
resp, err := c.roomSvc.ListParticipants(ctx, &livekit.ListParticipantsRequest{
Room: roomName,
})
if err != nil {
return nil, fmt.Errorf("livekit: listing participants in %s: %w", roomName, err)
}
return resp.Participants, nil
}
// CountVideoTracks returns the number of video tracks published in a room.
// Used for MaxVideo enforcement.
func (c *LiveKitClient) CountVideoTracks(channelID int64) (int, error) {
participants, err := c.ListParticipants(channelID)
if err != nil {
return 0, err
}
count := 0
for _, p := range participants {
for _, t := range p.Tracks {
if t.Type == livekit.TrackType_VIDEO {
count++
}
}
}
return count, nil
}
// HealthCheck verifies connectivity to the LiveKit server by listing rooms.
// Returns true if the server responds successfully.
func (c *LiveKitClient) HealthCheck(ctx context.Context) (bool, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
_, err := c.roomSvc.ListRooms(ctx, &livekit.ListRoomsRequest{})
if err != nil {
return false, fmt.Errorf("livekit health check failed: %w", err)
}
return true, nil
}
// wsToHTTP converts a WebSocket URL to an HTTP URL.
func wsToHTTP(wsURL string) string {
switch {
case len(wsURL) >= 6 && wsURL[:6] == "wss://":
return "https://" + wsURL[6:]
case len(wsURL) >= 5 && wsURL[:5] == "ws://":
return "http://" + wsURL[5:]
default:
return wsURL
}
}