mirror of
https://github.com/livekit/livekit.git
synced 2026-07-29 07:39:44 +00:00
429 lines
13 KiB
Go
429 lines
13 KiB
Go
// 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 (
|
|
"encoding/json"
|
|
"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":"<mode>","leaveAction":<int|name>}. 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"
|
|
|
|
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),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
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,
|
|
},
|
|
},
|
|
}
|
|
}
|