// Copyright 2026 LiveKit, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "bytes" "compress/gzip" "encoding/base64" "encoding/json" "io" "net/http" "strconv" "strings" "time" "github.com/gorilla/websocket" "google.golang.org/protobuf/proto" "github.com/livekit/protocol/auth" "github.com/livekit/protocol/livekit" ) // Signal endpoints (/rtc, /rtc/v1 and their /validate counterparts) let SDKs // exercise end-to-end WebSocket signal behavior. Per-connection behavior is // selected by the `lk.mock` participant attribute (see signalControl), so tests // need no shared state. The client fetches validate only when the WS fails to // open, so validate-error modes refuse the upgrade with the matching status and // let that fetch return the definitive status/body. const ( // Short keepalive (seconds, sent in the JoinResponse) so timeout tests run fast. signalPingInterval = 1 signalPingTimeout = 3 // Delay after join before close_when_connected / leave_when_connected act, // giving the client time to mark the connection established. connectedDelay = 200 * time.Millisecond ) // Behavior modes, selected by the `lk.mock` attribute's `signal` field. Any // unknown/absent signal behaves as the happy path. const ( // Validate-endpoint modes (the WS upgrade is refused with the same status // so the client falls back to the validate fetch). modeValidate500 = "validate_500" // validate → 500 modeServiceNotFound = "validate_service_not_found" // validate → 404, generic body modeRoomNotFound = "room_not_found" // validate → 404, "requested room does not exist" // WebSocket signal modes (validate → 200; behavior is on the WS). modeHappy = "happy" // join, pong, clean close on client leave modeNoFirstMessage = "no_first_message" // accept WS, send nothing modeNoPong = "no_pong" // send join, never pong modeCloseBeforeJoin = "close_before_join" // clean close 1011 before any first message modeCloseWhenConnected = "close_when_connected" // send join, then clean close 1011 modeDropWhenConnected = "drop_when_connected" // send join, then abrupt TCP drop (1006) modeLeaveWhenConnected = "leave_when_connected" // send join, then LeaveRequest modeLeaveFirstMessage = "leave_first_message" // LeaveRequest as first message modeLeaveDuringReconnect = "leave_during_reconnect" // on reconnect=1, LeaveRequest first modeDropOnClose = "drop_on_close" // happy, but drop TCP on the client's close frame instead of completing the handshake ) const signalControlAttribute = "lk.mock" // signalControl is the JSON value of the `lk.mock` attribute: // {"signal":"","leaveAction":}. leaveAction is optional // (a LeaveRequest_Action, given as the number or the enum name e.g. // "RECONNECT"; absent/0 = DISCONNECT) and sets the action on emitted leaves. type signalControl struct { Signal string `json:"signal"` LeaveAction leaveActionValue `json:"leaveAction"` } // leaveActionValue is a LeaveRequest_Action that unmarshals from either a JSON // number (2) or an enum name ("RECONNECT", case-insensitive). Anything // unrecognized decodes to 0 (DISCONNECT) rather than failing the whole control. type leaveActionValue livekit.LeaveRequest_Action func (v *leaveActionValue) UnmarshalJSON(b []byte) error { var n int32 if json.Unmarshal(b, &n) == nil { *v = leaveActionValue(n) return nil } var s string if err := json.Unmarshal(b, &s); err != nil { return err } *v = leaveActionValue(livekit.LeaveRequest_Action_value[strings.ToUpper(s)]) return nil } // parseSignalControl parses the `lk.mock` attribute value; absent/invalid → zero. func parseSignalControl(grants *auth.ClaimGrants) signalControl { if grants == nil { return signalControl{} } raw := grants.Attributes[signalControlAttribute] if raw == "" { return signalControl{} } var ctrl signalControl if err := json.Unmarshal([]byte(raw), &ctrl); err != nil { return signalControl{} } return ctrl } // signalMode returns the mode from the `lk.mock` `signal` field; unknown/absent → happy. func signalMode(grants *auth.ClaimGrants) string { switch ctrl := parseSignalControl(grants); ctrl.Signal { case modeValidate500, modeServiceNotFound, modeRoomNotFound, modeHappy, modeNoFirstMessage, modeNoPong, modeCloseBeforeJoin, modeCloseWhenConnected, modeDropWhenConnected, modeLeaveWhenConnected, modeLeaveFirstMessage, modeLeaveDuringReconnect, modeDropOnClose: return ctrl.Signal default: return modeHappy } } func isSignalPath(path string) bool { return path == "/rtc" || path == "/rtc/v1" } func isValidatePath(path string) bool { return path == "/rtc/validate" || path == "/rtc/v1/validate" } var signalUpgrader = websocket.Upgrader{ EnableCompression: true, // Auth is via the access token, so allow any origin. CheckOrigin: func(r *http.Request) bool { return true }, } // verifySignalToken reads the access token (access_token query param or Bearer // header) and verifies it against the mock's API secret. func (h *mockHandler) verifySignalToken(r *http.Request) (*auth.ClaimGrants, error) { token := r.FormValue("access_token") if token == "" { token = strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) } v, err := auth.ParseAPIToken(token) if err != nil { return nil, err } _, grants, err := v.Verify(h.apiSecret) if err != nil { return nil, err } return grants, nil } func grantRoom(grants *auth.ClaimGrants) string { if grants == nil || grants.Video == nil { return "" } return grants.Video.Room } // handleValidate verifies the JWT (bad/expired/missing → 401), then returns the // status the mode dictates. func (h *mockHandler) handleValidate(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") grants, err := h.verifySignalToken(r) if err != nil { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte("invalid token: " + err.Error())) return } switch signalMode(grants) { case modeValidate500: w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte("internal server error")) case modeServiceNotFound: w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte("404 page not found")) case modeRoomNotFound: w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte("requested room does not exist")) default: w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("success")) } } // handleSignal verifies the token, applies validate-error modes by refusing the // upgrade, else upgrades and runs the selected behavior. The v1 publisher offer // is ignored. func (h *mockHandler) handleSignal(w http.ResponseWriter, r *http.Request) { grants, err := h.verifySignalToken(r) if err != nil { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte("invalid token")) return } mode := signalMode(grants) switch mode { case modeValidate500: w.WriteHeader(http.StatusInternalServerError) return case modeServiceNotFound, modeRoomNotFound: w.WriteHeader(http.StatusNotFound) return } reconnect := r.URL.Query().Get("reconnect") == "1" || joinRequestSaysReconnect(r.URL.Query().Get("join_request")) conn, err := signalUpgrader.Upgrade(w, r, nil) if err != nil { return } defer func() { _ = conn.Close() }() h.runSignal(conn, mode, reconnect, grants) } // runSignal drives one WebSocket connection according to mode. func (h *mockHandler) runSignal(conn *websocket.Conn, mode string, reconnect bool, grants *auth.ClaimGrants) { writeResp := func(msg *livekit.SignalResponse) error { payload, err := proto.Marshal(msg) if err != nil { return err } return conn.WriteMessage(websocket.BinaryMessage, payload) } leaveAction := livekit.LeaveRequest_Action(parseSignalControl(grants).LeaveAction) // drainUntilClosed reads/discards until the peer closes, keeping the socket open. drainUntilClosed := func() { for { if _, _, err := conn.ReadMessage(); err != nil { return } } } // Modes that decide the very first message. switch mode { case modeNoFirstMessage: drainUntilClosed() return case modeCloseBeforeJoin: time.Sleep(50 * time.Millisecond) msg := websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "") _ = conn.WriteControl(websocket.CloseMessage, msg, time.Now().Add(time.Second)) return case modeLeaveFirstMessage: _ = writeResp(leaveResponse(leaveAction)) drainUntilClosed() return case modeLeaveDuringReconnect: if reconnect { _ = writeResp(leaveResponse(leaveAction)) drainUntilClosed() return } // Non-reconnect connections fall through to the happy path. } // First message: reconnect response on a resume, join otherwise. if reconnect { if err := writeResp(reconnectResponse(h.regionIndex)); err != nil { return } } else { if err := writeResp(joinResponse(h.regionIndex, grants)); err != nil { return } } // Post-join behaviors. switch mode { case modeCloseWhenConnected: time.Sleep(connectedDelay) msg := websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "mock close_when_connected") _ = conn.WriteControl(websocket.CloseMessage, msg, time.Now().Add(time.Second)) return case modeDropWhenConnected: time.Sleep(connectedDelay) _ = conn.UnderlyingConn().Close() return case modeLeaveWhenConnected: time.Sleep(connectedDelay) _ = writeResp(leaveResponse(leaveAction)) } // drop_on_close: when the client starts a clean close handshake, drop the // TCP connection instead of replying with a close frame, so the client // observes an abnormal closure while it is disconnecting. if mode == modeDropOnClose { conn.SetCloseHandler(func(code int, text string) error { return conn.UnderlyingConn().Close() }) } // Read loop: pong to pings (unless no_pong), clean close on client leave. for { mt, payload, err := conn.ReadMessage() if err != nil { return } if mt != websocket.BinaryMessage { continue } req := &livekit.SignalRequest{} if err := proto.Unmarshal(payload, req); err != nil { continue } switch m := req.Message.(type) { case *livekit.SignalRequest_Ping: if mode != modeNoPong { _ = writeResp(&livekit.SignalResponse{ Message: &livekit.SignalResponse_Pong{Pong: time.Now().UnixMilli()}, }) } case *livekit.SignalRequest_PingReq: if mode != modeNoPong { _ = writeResp(&livekit.SignalResponse{ Message: &livekit.SignalResponse_PongResp{ PongResp: &livekit.Pong{ LastPingTimestamp: m.PingReq.Timestamp, Timestamp: time.Now().UnixMilli(), }, }, }) } case *livekit.SignalRequest_UpdateMetadata: // Ack like the real server does, so SDK tests can observe that a // (possibly queued) request actually reached the server. _ = writeResp(&livekit.SignalResponse{ Message: &livekit.SignalResponse_RequestResponse{ RequestResponse: &livekit.RequestResponse{ RequestId: m.UpdateMetadata.RequestId, Reason: livekit.RequestResponse_OK, }, }, }) case *livekit.SignalRequest_Leave: msg := websocket.FormatCloseMessage(websocket.CloseNormalClosure, "") _ = conn.WriteControl(websocket.CloseMessage, msg, time.Now().Add(time.Second)) return } } } func serverInfo(regionIndex int) *livekit.ServerInfo { return &livekit.ServerInfo{ Edition: livekit.ServerInfo_Standard, Version: "mock", Protocol: 15, Region: regionName(regionIndex), NodeId: "MOCK_NODE", } } func regionName(regionIndex int) string { return "region-" + strconv.Itoa(regionIndex) } // joinResponse builds the initial JoinResponse (non-zero ping config so the // client arms keepalive). func joinResponse(regionIndex int, grants *auth.ClaimGrants) *livekit.SignalResponse { room := grantRoom(grants) identity := "mock-participant" name := "" if grants != nil { if grants.Identity != "" { identity = grants.Identity } name = grants.Name } return &livekit.SignalResponse{ Message: &livekit.SignalResponse_Join{ Join: &livekit.JoinResponse{ Room: &livekit.Room{ Sid: "RM_MOCK", Name: room, }, Participant: &livekit.ParticipantInfo{ Sid: "PA_MOCK", Identity: identity, Name: name, State: livekit.ParticipantInfo_JOINED, }, PingInterval: signalPingInterval, PingTimeout: signalPingTimeout, ServerInfo: serverInfo(regionIndex), ServerVersion: "mock", ServerRegion: regionName(regionIndex), }, }, } } func reconnectResponse(regionIndex int) *livekit.SignalResponse { return &livekit.SignalResponse{ Message: &livekit.SignalResponse_Reconnect{ Reconnect: &livekit.ReconnectResponse{ ServerInfo: serverInfo(regionIndex), }, }, } } // joinRequestSaysReconnect unpacks the v1 `join_request` param and reports its // Reconnect flag. Any decode error is ignored (treated as non-reconnect); this // is a test mock, not a validator. func joinRequestSaysReconnect(param string) bool { if param == "" { return false } wrappedBytes, err := base64.URLEncoding.DecodeString(param) if err != nil { return false } wrapped := &livekit.WrappedJoinRequest{} if err := proto.Unmarshal(wrappedBytes, wrapped); err != nil { return false } var joinBytes []byte switch wrapped.Compression { case livekit.WrappedJoinRequest_NONE: joinBytes = wrapped.JoinRequest case livekit.WrappedJoinRequest_GZIP: gz, err := gzip.NewReader(bytes.NewReader(wrapped.JoinRequest)) if err != nil { return false } defer func() { _ = gz.Close() }() joinBytes, err = io.ReadAll(gz) if err != nil { return false } default: return false } join := &livekit.JoinRequest{} if err := proto.Unmarshal(joinBytes, join); err != nil { return false } return join.Reconnect } func leaveResponse(action livekit.LeaveRequest_Action) *livekit.SignalResponse { return &livekit.SignalResponse{ Message: &livekit.SignalResponse_Leave{ Leave: &livekit.LeaveRequest{ Reason: livekit.DisconnectReason_SERVER_SHUTDOWN, Action: action, }, }, } }