snapshot of https://github.com/livekit/livekit.git @ 08c5433449c106943231037d0b892895f6e92d3b (2026-09-15)
Some checks failed
Release to Docker / docker (push) Failing after 2s
Release Test Server to Docker / docker (push) Failing after 3s
Test / test (push) Failing after 16s

This commit is contained in:
upstream-mirror
2026-09-15 16:14:22 +05:00
commit 78704103fc
422 changed files with 154805 additions and 0 deletions

271
cmd/server/commands.go Normal file
View File

@@ -0,0 +1,271 @@
// Copyright 2023 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 (
"context"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/dustin/go-humanize"
"github.com/olekukonko/tablewriter"
"github.com/olekukonko/tablewriter/tw"
"github.com/urfave/cli/v3"
"gopkg.in/yaml.v3"
"github.com/livekit/protocol/auth"
"github.com/livekit/protocol/livekit"
"github.com/livekit/protocol/utils"
"github.com/livekit/protocol/utils/guid"
"github.com/livekit/livekit-server/pkg/config"
"github.com/livekit/livekit-server/pkg/routing"
"github.com/livekit/livekit-server/pkg/service"
)
func generateKeys(_ context.Context, _ *cli.Command) error {
apiKey := guid.New(utils.APIKeyPrefix)
secret := utils.RandomSecret()
fmt.Println("API Key: ", apiKey)
fmt.Println("API Secret: ", secret)
return nil
}
func printPorts(_ context.Context, c *cli.Command) error {
conf, err := getConfig(c)
if err != nil {
return err
}
udpPorts := make([]string, 0)
tcpPorts := make([]string, 0)
tcpPorts = append(tcpPorts, fmt.Sprintf("%d - HTTP service", conf.Port))
if conf.RTC.TCPPort != 0 {
tcpPorts = append(tcpPorts, fmt.Sprintf("%d - ICE/TCP", conf.RTC.TCPPort))
}
if conf.RTC.UDPPort.Valid() {
portStr, _ := conf.RTC.UDPPort.MarshalYAML()
udpPorts = append(udpPorts, fmt.Sprintf("%s - ICE/UDP", portStr))
} else {
udpPorts = append(udpPorts, fmt.Sprintf("%d-%d - ICE/UDP range", conf.RTC.ICEPortRangeStart, conf.RTC.ICEPortRangeEnd))
}
if conf.TURN.Enabled {
if conf.TURN.TLSPort > 0 {
tcpPorts = append(tcpPorts, fmt.Sprintf("%d - TURN/TLS", conf.TURN.TLSPort))
}
if conf.TURN.UDPPort > 0 {
udpPorts = append(udpPorts, fmt.Sprintf("%d - TURN/UDP", conf.TURN.UDPPort))
}
}
fmt.Println("TCP Ports")
for _, p := range tcpPorts {
fmt.Println(p)
}
fmt.Println("UDP Ports")
for _, p := range udpPorts {
fmt.Println(p)
}
return nil
}
func helpVerbose(_ context.Context, c *cli.Command) error {
generatedFlags, err := config.GenerateCLIFlags(baseFlags, false)
if err != nil {
return err
}
flags := append([]cli.Flag{}, baseFlags...)
flags = append(flags, generatedFlags...)
root := c.Root()
root.Flags = flags
c.Flags = flags
return cli.ShowAppHelp(c)
}
func createToken(_ context.Context, c *cli.Command) error {
room := c.String("room")
identity := c.String("identity")
conf, err := getConfig(c)
if err != nil {
return err
}
// use the first API key from config
if len(conf.Keys) == 0 {
// try to load from file
if _, err := os.Stat(conf.KeyFile); err != nil {
return err
}
f, err := os.Open(conf.KeyFile)
if err != nil {
return err
}
defer func() {
_ = f.Close()
}()
decoder := yaml.NewDecoder(f)
if err = decoder.Decode(conf.Keys); err != nil {
return err
}
if len(conf.Keys) == 0 {
return fmt.Errorf("keys are not configured")
}
}
var apiKey string
var apiSecret string
for k, v := range conf.Keys {
apiKey = k
apiSecret = v
break
}
grant := &auth.VideoGrant{
RoomJoin: true,
Room: room,
}
if c.Bool("recorder") {
grant.Hidden = true
grant.Recorder = true
grant.SetCanPublish(false)
grant.SetCanPublishData(false)
}
at := auth.NewAccessToken(apiKey, apiSecret).
AddGrant(grant).
SetIdentity(identity).
SetValidFor(30 * 24 * time.Hour)
token, err := at.ToJWT()
if err != nil {
return err
}
fmt.Println("Token:", token)
return nil
}
func listNodes(_ context.Context, c *cli.Command) error {
conf, err := getConfig(c)
if err != nil {
return err
}
currentNode, err := routing.NewLocalNode(conf)
if err != nil {
return err
}
router, err := service.InitializeRouter(conf, currentNode)
if err != nil {
return err
}
nodes, err := router.ListNodes()
if err != nil {
return err
}
table := tablewriter.NewTable(os.Stdout,
tablewriter.WithHeaderAutoFormat(tw.Off),
tablewriter.WithRowAutoWrap(0),
tablewriter.WithRendition(tw.Rendition{
Settings: tw.Settings{
Separators: tw.Separators{BetweenRows: tw.On},
},
}),
tablewriter.WithRowAlignmentConfig(tw.CellAlignment{
PerColumn: []tw.Align{
tw.AlignCenter, tw.AlignCenter, tw.AlignCenter,
tw.AlignRight, tw.AlignRight,
tw.AlignRight,
tw.AlignRight, tw.AlignRight,
tw.AlignRight, tw.AlignRight, tw.AlignRight,
tw.AlignRight, tw.AlignRight,
tw.AlignCenter,
},
}),
)
table.Header(
"ID", "IP Address", "Region",
"CPUs", "CPU Usage\nLoad Avg",
"Memory Used/Total",
"Rooms", "Clients\nTracks In/Out",
"Bytes/s In/Out\nBytes Total", "Packets/s In/Out\nPackets Total", "System Dropped Pkts/s\nPkts/s Out/Dropped",
"Nack/s\nNack Total", "Retrans/s\nRetrans Total",
"Started At\nUpdated At",
)
for _, node := range nodes {
stats := node.Stats
rate := &livekit.NodeStatsRate{}
if len(stats.Rates) > 0 {
rate = stats.Rates[0]
}
// Id and state
idAndState := fmt.Sprintf("%s\n(%s)", node.Id, node.State.Enum().String())
// System stats
cpus := strconv.Itoa(int(stats.NumCpus))
cpuUsageAndLoadAvg := fmt.Sprintf("%.2f %%\n%.2f %.2f %.2f", stats.CpuLoad*100,
stats.LoadAvgLast1Min, stats.LoadAvgLast5Min, stats.LoadAvgLast15Min)
memUsage := fmt.Sprintf("%s / %s", humanize.Bytes(stats.MemoryUsed), humanize.Bytes(stats.MemoryTotal))
// Room stats
rooms := strconv.Itoa(int(stats.NumRooms))
clientsAndTracks := fmt.Sprintf("%d\n%d / %d", stats.NumClients, stats.NumTracksIn, stats.NumTracksOut)
// Packet stats
bytes := fmt.Sprintf("%sps / %sps\n%s / %s", humanize.Bytes(uint64(rate.BytesIn)), humanize.Bytes(uint64(rate.BytesOut)),
humanize.Bytes(stats.BytesIn), humanize.Bytes(stats.BytesOut))
packets := fmt.Sprintf("%s / %s\n%s / %s", humanize.Comma(int64(rate.PacketsIn)), humanize.Comma(int64(rate.PacketsOut)),
strings.TrimSpace(humanize.SIWithDigits(float64(stats.PacketsIn), 2, "")), strings.TrimSpace(humanize.SIWithDigits(float64(stats.PacketsOut), 2, "")))
sysPacketsDroppedPct := float32(0)
if rate.SysPacketsOut+rate.SysPacketsDropped > 0 {
sysPacketsDroppedPct = float32(rate.SysPacketsDropped) / float32(rate.SysPacketsDropped+rate.SysPacketsOut)
}
sysPackets := fmt.Sprintf("%.2f %%\n%v / %v", sysPacketsDroppedPct*100, float64(rate.SysPacketsOut), float64(rate.SysPacketsDropped))
nacks := fmt.Sprintf("%.2f\n%s", rate.NackTotal, strings.TrimSpace(humanize.SIWithDigits(float64(stats.NackTotal), 2, "")))
retransmit := fmt.Sprintf("%.2f\n%s", rate.RetransmitPacketsOut, strings.TrimSpace(humanize.SIWithDigits(float64(stats.RetransmitPacketsOut), 2, "")))
// Date
startedAndUpdated := fmt.Sprintf("%s\n%s", time.Unix(stats.StartedAt, 0).UTC().UTC().Format("2006-01-02 15:04:05"),
time.Unix(stats.UpdatedAt, 0).UTC().Format("2006-01-02 15:04:05"))
table.Append(
idAndState, node.Ip, node.Region,
cpus, cpuUsageAndLoadAvg,
memUsage,
rooms, clientsAndTracks,
bytes, packets, sysPackets,
nacks, retransmit,
startedAndUpdated,
)
}
table.Render() //nolint:errcheck
return nil
}

334
cmd/server/main.go Normal file
View File

@@ -0,0 +1,334 @@
// Copyright 2023 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 (
"context"
"fmt"
"math/rand"
"net"
"os"
"os/signal"
"runtime"
"runtime/pprof"
"syscall"
"time"
"github.com/urfave/cli/v3"
"github.com/livekit/protocol/logger"
"github.com/livekit/protocol/tracer/jaeger"
"github.com/livekit/livekit-server/pkg/config"
"github.com/livekit/livekit-server/pkg/routing"
"github.com/livekit/livekit-server/pkg/rtc"
"github.com/livekit/livekit-server/pkg/service"
"github.com/livekit/livekit-server/pkg/telemetry/prometheus"
"github.com/livekit/livekit-server/version"
)
var baseFlags = []cli.Flag{
&cli.StringSliceFlag{
Name: "bind",
Usage: "IP address to listen on, use flag multiple times to specify multiple addresses",
},
&cli.StringFlag{
Name: "config",
Usage: "path to LiveKit config file",
},
&cli.StringFlag{
Name: "config-body",
Usage: "LiveKit config in YAML, typically passed in as an environment var in a container",
Sources: cli.EnvVars("LIVEKIT_CONFIG"),
},
&cli.StringFlag{
Name: "key-file",
Usage: "path to file that contains API keys/secrets",
},
&cli.StringFlag{
Name: "keys",
Usage: "api keys (key: secret\\n)",
Sources: cli.EnvVars("LIVEKIT_KEYS"),
},
&cli.StringFlag{
Name: "region",
Usage: "region of the current node. Used by regionaware node selector",
Sources: cli.EnvVars("LIVEKIT_REGION"),
},
&cli.StringFlag{
Name: "node-ip",
Usage: "IP address of the current node, used to advertise to clients. Automatically determined by default",
Sources: cli.EnvVars("NODE_IP"),
},
&cli.StringFlag{
Name: "udp-port",
Usage: "UDP port(s) to use for WebRTC traffic",
Sources: cli.EnvVars("UDP_PORT"),
},
&cli.StringFlag{
Name: "redis-host",
Usage: "host (incl. port) to redis server",
Sources: cli.EnvVars("REDIS_HOST"),
},
&cli.StringFlag{
Name: "redis-password",
Usage: "password to redis",
Sources: cli.EnvVars("REDIS_PASSWORD"),
},
&cli.StringFlag{
Name: "turn-cert",
Usage: "tls cert file for TURN server",
Sources: cli.EnvVars("LIVEKIT_TURN_CERT"),
},
&cli.StringFlag{
Name: "turn-key",
Usage: "tls key file for TURN server",
Sources: cli.EnvVars("LIVEKIT_TURN_KEY"),
},
&cli.StringFlag{
Name: "cpuprofile",
Usage: "write CPU profile to `file`",
},
&cli.StringFlag{
Name: "memprofile",
Usage: "write memory profile to `file`",
},
&cli.BoolFlag{
Name: "dev",
Usage: "sets log-level to debug, console formatter, and /debug/pprof. insecure for production",
},
&cli.BoolFlag{
Name: "disable-strict-config",
Usage: "disables strict config parsing",
Hidden: true,
},
}
func init() {
rand.Seed(time.Now().Unix())
}
func main() {
defer func() {
if rtc.Recover(logger.GetLogger()) != nil {
os.Exit(1)
}
}()
generatedFlags, err := config.GenerateCLIFlags(baseFlags, true)
if err != nil {
fmt.Println(err)
}
cmd := &cli.Command{
Name: "livekit-server",
Usage: "High performance WebRTC server",
Description: "run without subcommands to start the server",
Flags: append(baseFlags, generatedFlags...),
Action: startServer,
Commands: []*cli.Command{
{
Name: "generate-keys",
Usage: "generates an API key and secret pair",
Action: generateKeys,
},
{
Name: "ports",
Usage: "print ports that server is configured to use",
Action: printPorts,
},
{
// this subcommand is deprecated, token generation is provided by CLI
Name: "create-join-token",
Hidden: true,
Usage: "create a room join token for development use",
Action: createToken,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "room",
Usage: "name of room to join",
Required: true,
},
&cli.StringFlag{
Name: "identity",
Usage: "identity of participant that holds the token",
Required: true,
},
&cli.BoolFlag{
Name: "recorder",
Usage: "creates a hidden participant that can only subscribe",
Required: false,
},
},
},
{
Name: "list-nodes",
Usage: "list all nodes",
Action: listNodes,
},
{
Name: "help-verbose",
Usage: "prints app help, including all generated configuration flags",
Action: helpVerbose,
},
},
Version: version.Version,
}
if err := cmd.Run(context.Background(), os.Args); err != nil {
fmt.Println(err)
}
}
func getConfig(c *cli.Command) (*config.Config, error) {
confString, err := getConfigString(c.String("config"), c.String("config-body"))
if err != nil {
return nil, err
}
strictMode := !c.Bool("disable-strict-config")
conf, err := config.NewConfig(confString, strictMode, c, baseFlags)
if err != nil {
return nil, err
}
config.InitLoggerFromConfig(&conf.Logging)
if conf.Development {
logger.Infow("starting in development mode")
if len(conf.Keys) == 0 {
logger.Infow("no keys provided, using placeholder keys",
"API Key", "devkey",
"API Secret", "secret",
)
conf.Keys = map[string]string{
"devkey": "secret",
}
shouldMatchRTCIP := false
// when dev mode and using shared keys, we'll bind to localhost by default
if conf.BindAddresses == nil {
conf.BindAddresses = []string{
"127.0.0.1",
"::1",
}
} else {
// if non-loopback addresses are provided, then we'll match RTC IP to bind address
// our IP discovery ignores loopback addresses
for _, addr := range conf.BindAddresses {
ip := net.ParseIP(addr)
if ip != nil && !ip.IsLoopback() && !ip.IsUnspecified() {
shouldMatchRTCIP = true
}
}
}
if shouldMatchRTCIP {
for _, bindAddr := range conf.BindAddresses {
conf.RTC.IPs.Includes = append(conf.RTC.IPs.Includes, bindAddr+"/24")
}
}
}
}
return conf, nil
}
func startServer(ctx context.Context, c *cli.Command) error {
conf, err := getConfig(c)
if err != nil {
return err
}
if url := conf.Trace.JaegerURL; url != "" {
jaeger.Configure(ctx, url, "livekit")
}
// validate API key length
err = conf.ValidateKeys()
if err != nil {
return err
}
if err = conf.LoadTURNSecrets(); err != nil {
return err
}
if cpuProfile := c.String("cpuprofile"); cpuProfile != "" {
if f, err := os.Create(cpuProfile); err != nil {
return err
} else {
if err := pprof.StartCPUProfile(f); err != nil {
f.Close()
return err
}
defer func() {
pprof.StopCPUProfile()
f.Close()
}()
}
}
if memProfile := c.String("memprofile"); memProfile != "" {
if f, err := os.Create(memProfile); err != nil {
return err
} else {
defer func() {
// run memory profile at termination
runtime.GC()
_ = pprof.WriteHeapProfile(f)
_ = f.Close()
}()
}
}
currentNode, err := routing.NewLocalNode(conf)
if err != nil {
return err
}
if err := prometheus.Init(string(currentNode.NodeID()), currentNode.NodeType()); err != nil {
return err
}
server, err := service.InitializeServer(conf, currentNode)
if err != nil {
return err
}
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
go func() {
for i := range 2 {
sig := <-sigChan
force := i > 0
logger.Infow("exit requested, shutting down", "signal", sig, "force", force)
go server.Stop(force)
}
}()
return server.Start()
}
func getConfigString(configFile string, inConfigBody string) (string, error) {
if inConfigBody != "" || configFile == "" {
return inConfigBody, nil
}
outConfigBody, err := os.ReadFile(configFile)
if err != nil {
return "", err
}
return string(outConfigBody), nil
}

63
cmd/server/main_test.go Normal file
View File

@@ -0,0 +1,63 @@
// Copyright 2023 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 (
"os"
"testing"
"github.com/stretchr/testify/require"
)
type testStruct struct {
configFileName string
configBody string
expectedError error
expectedConfigBody string
}
func TestGetConfigString(t *testing.T) {
tests := []testStruct{
{"", "", nil, ""},
{"", "configBody", nil, "configBody"},
{"file", "configBody", nil, "configBody"},
{"file", "", nil, "fileContent"},
}
for _, test := range tests {
func() {
writeConfigFile(test, t)
defer os.Remove(test.configFileName)
configBody, err := getConfigString(test.configFileName, test.configBody)
require.Equal(t, test.expectedError, err)
require.Equal(t, test.expectedConfigBody, configBody)
}()
}
}
func TestShouldReturnErrorIfConfigFileDoesNotExist(t *testing.T) {
configBody, err := getConfigString("notExistingFile", "")
require.Error(t, err)
require.Empty(t, configBody)
}
func writeConfigFile(test testStruct, t *testing.T) {
if test.configFileName != "" {
d1 := []byte(test.expectedConfigBody)
err := os.WriteFile(test.configFileName, d1, 0o644)
require.NoError(t, err)
}
}

View File

@@ -0,0 +1,52 @@
# 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.
# Mock LiveKit API server for SDK conformance testing. Build from the repo
# root: docker build -f cmd/test-server/Dockerfile -t livekit/test-server .
#
# Pinned by digest so the build is reproducible even if the tag is republished.
# The tag is kept alongside it for readability; Renovate updates both together.
FROM golang:1.26.7-alpine3.24@sha256:28d89ee9cc0ff9fec75c82ca201e6bf7fdf9a679d4b7b24dfa04f2bb766bb468 AS builder
ARG TARGETARCH
WORKDIR /workspace
# Build with exactly the toolchain in this image; fail (don't silently download)
# if go.mod ever requires a newer version, so the pinned image stays authoritative.
ENV GOTOOLCHAIN=local
COPY go.mod go.mod
COPY go.sum go.sum
RUN go mod download
COPY cmd/ cmd/
COPY pkg/ pkg/
COPY version/ version/
RUN CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH GO111MODULE=on go build -a -o livekit-test-server ./cmd/test-server
FROM alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b
# Pull the latest security patches for base packages within the pinned Alpine
# release; the digest above is only refreshed by a Renovate PR. See the note in
# the repo-root Dockerfile about the cold-cache assumption.
RUN apk upgrade --no-cache
COPY --from=builder /workspace/livekit-test-server /livekit-test-server
# region-0 (primary, 9999) + 3 fallback regions
EXPOSE 9999 10000 10001 10002
ENTRYPOINT ["/livekit-test-server"]

213
cmd/test-server/README.md Normal file
View File

@@ -0,0 +1,213 @@
# LiveKit SDK test server
A stateless, per-request programmable mock of the LiveKit server HTTP API. It
exists so the server SDKs (Go, Rust, Python, Node, Kotlin, Ruby) can exercise
client-side behavior against one shared implementation, published as a Docker
image and booted by each SDK's CI.
## Why it looks the way it does
- **Stateless.** All behavior is selected by a single per-request `X-Lk-Mock`
header (a JSON object), so the server holds no mutable state and tests run in
parallel.
- **Multi-port = multi-region.** The process binds one listener per simulated
region (`--ports`). A port's position in the list is its **region index**;
index `0` is the primary the SDK is initially pointed at. `GET
/settings/regions` advertises all of them in order.
- **One header drives every attempt.** The SDK sends the same control header on
the initial request *and* every failover retry. Each listener decides what to
do from its **own** index, so a single `X-Lk-Mock: {"failRegions":[0]}` makes
the primary fail while the first fallback succeeds — no coordination needed.
- **Realistic latency.** Methods that block in the real server block here too:
`CreateSIPParticipant` with `wait_until_answered` and `TransferSIPParticipant`
take ~11s before responding, so SDKs can exercise their timeouts.
- **The whole API is mocked with populated responses.** Every RoomService,
Egress, Ingress, SIP, and Connector method returns a type-correct, populated
response: scalar fields that share a name with the request are echoed (e.g.
`name`, `metadata`, `identity`, timeouts), `id`/`sid` fields get placeholder
values, and list endpoints return one element. Both protobuf and JSON Twirp
clients are supported. A client can override the response entirely with the
`response` field (see below). Unregistered/future methods fall back
to an empty (all-default) message, which still decodes cleanly.
## Running
```bash
go run ./cmd/test-server # primary :9999, regions :10000-10002
go run ./cmd/test-server --ports 9999,10000 # primary + one fallback
# Docker
docker build -f cmd/test-server/Dockerfile -t livekit/test-server .
docker run -p 9999-10002:9999-10002 livekit/test-server
```
| Flag | Env | Default | Meaning |
|---|---|---|---|
| `--ports` | `LK_TEST_SERVER_PORTS` | `9999,10000,10001,10002` | listener ports; index = position |
| `--advertise-host` | `LK_TEST_SERVER_ADVERTISE_HOST` | `http://127.0.0.1` | base URL used in `/settings/regions` |
| `--bind` | `LK_TEST_SERVER_BIND` | `0.0.0.0` | bind address |
| `--twirp-prefix` | `LK_TEST_SERVER_TWIRP_PREFIX` | `/twirp` | Twirp path prefix |
## Control protocol
All behavior is driven by a single `X-Lk-Mock` request header whose value is a
JSON object. The SDK sends the same header on API calls, on the
`/settings/regions` fetch, and on every failover retry (it must forward
client-configured custom headers onto all of them). Omit the header — or any
field — for normal behavior. Every field is optional:
| Field | Default | Effect |
|---|---|---|
| `failRegions` | — | array of region indices that fail this request, e.g. `[0]` or `[0,1]`. Each listener fails only if its own index is listed. |
| `failMode` | `status` | how a failing region fails: `status` (write a Twirp error), or `drop` (close the connection → transport error). |
| `failStatus` | `503` | HTTP status for a `status`-mode failure. |
| `failTwirpCode` | derived from status | Twirp error code string in the failure body. |
| `delayMs` | — | delay (ms) before responding, on success or failure. Overrides a method's natural latency — use it for timeout tests, or set it to skip a SIP method's built-in ~11s wait. |
| `regionsStatus` | `200` | override the status of `GET /settings/regions`. |
| `response` | — | the response message for the called method (a JSON object, protojson-shaped); replaces the populated default, giving full control over the returned payload. |
| `skipAuth` | `false` | `true` disables permission enforcement for the request (use for tests that aren't about authz, e.g. failover tests with a placeholder token). |
| `sipStatus` | — | fail a SIP dial method (`CreateSIPParticipant`/`TransferSIPParticipant`) with a SIP status, e.g. `{"code":486,"status":"Busy Here"}` (`status` optional). The Twirp error code and `sip_status_code`/`sip_status`/`error_details` metadata are derived from it exactly as the real server does. Composes with `delayMs` to simulate "ring, then fail". |
| `pinnedRegions` | — | array of region **names** the project is pinned to (its **allowed** regions), e.g. `["region-1"]`, mirroring cloud's per-project `PinnedRegions` (region names, not indices). When non-empty a pin is in effect: any region whose name is **not** listed rejects the request with **HTTP 451** (a region-pin violation), and `GET /settings/regions` returns **only** the listed regions. The SDK is expected to see the 451, re-fetch `/settings/regions`, and retry against an allowed region — the 451 carries no region hint (its body is the middleware's plain text, not a Twirp error). This redirect is always active in clients; it can't be disabled. Use a name no listener advertises (e.g. `["region-99"]`) to model a pinned region that's unreachable. Names are the mock's region names (`region-0`, `region-1`, …); contrast `failRegions`, which addresses listeners by index. |
Example: `X-Lk-Mock: {"skipAuth":true,"failRegions":[0],"failStatus":400}`
> **Deprecated:** the older per-setting headers — `X-Lk-Mock-Fail-Regions`,
> `X-Lk-Mock-Fail-Mode` (incl. the `delay` mode), `X-Lk-Mock-Fail-Status`,
> `X-Lk-Mock-Fail-Twirp-Code`, `X-Lk-Mock-Delay-Ms`, `X-Lk-Mock-Regions-Status`,
> `X-Lk-Mock-Response`, `X-Lk-Mock-Skip-Auth` — are still honored for existing
> clients and will be removed later. When `X-Lk-Mock` is also present, its fields
> take precedence per-field. New clients should use `X-Lk-Mock` only.
Response headers:
| Header | Meaning |
|---|---|
| `X-Lk-Mock-Region` | index of the region that served the response (blank on a failed region). Assert on this to confirm which region a failover landed on. |
## Signal connection (WebSocket) mocking
The mock also speaks enough of the LiveKit signal protocol for SDKs to run
end-to-end signal-connection tests (connect, keepalive, reconnect, leave, and
the failure/timeout modes a client must classify). Signal behavior is selected
by a participant attribute (`lk.mock`) in the access token (see below) — the
WebSocket client can't set request headers, so it can't carry a control header.
Selecting via the token means parallel tests need no shared
state.
Endpoints (both protocol versions are supported and behave identically):
| Path | Purpose |
|---|---|
| `/rtc`, `/rtc/v1` | WebSocket signal connection |
| `/rtc/validate`, `/rtc/v1/validate` | HTTP validate (the client fetches this when the WS fails to open) |
- The access token is read from the `access_token` query param (or a
`Bearer` Authorization header) and verified against the API secret. A
missing/malformed/expired/wrongly-signed token makes `validate` return
**401** (and the WS refuse the upgrade).
- Wire format is **binary protobuf**: `SignalRequest` in, `SignalResponse` out.
- The v1 embedded publisher offer (`join_request` connection param) is
**ignored** — no valid offer is required.
- Keepalive uses a short `pingTimeout=3s` / `pingInterval=1s` in the join so
timeout tests run fast.
**Mode selection is via a participant attribute.** After the token is verified,
the server reads the `lk.mock` entry from the token's `attributes` claim
(`ClaimGrants.Attributes`, a `map[string]string`). The value of that attribute
is a stringified JSON control object whose `signal` field picks the behavior.
The `lk.mock` namespace is the attribute **key** (dot notation, matching
LiveKit's convention for internal attributes), so the value has no inner parent:
```
attribute key: lk.mock
attribute value: {"signal":"no_pong"}
```
The control object also accepts an optional `leaveAction` field — a
`LeaveRequest_Action`, given either as the number (`0`=DISCONNECT, `1`=RESUME,
`2`=RECONNECT) or the enum name (`"RECONNECT"`, case-insensitive) — that sets
the `action` on the `LeaveRequest` the leave-sending modes emit
(`leave_when_connected`, `leave_first_message`, `leave_during_reconnect`). When
absent it defaults to `0` (DISCONNECT). Examples:
```
attribute value: {"signal":"leave_when_connected","leaveAction":"RECONNECT"}
```
If the `lk.mock` attribute is absent/empty, its value is unparseable, or its
`signal` is unknown, the mode defaults to `happy`. Both the WS handlers and the
validate handlers read the mode from this same attribute.
Behavior modes (any unknown/absent `signal` = `happy`):
| `signal` value | Effect |
|---|---|
| `happy` | validate → 200; WS sends `JoinResponse` (or `ReconnectResponse` if `reconnect=1`), pongs pings, closes cleanly (1000) on client `LeaveRequest` |
| `validate_500` | validate → 500; WS refuses upgrade with 500 |
| `validate_service_not_found` | validate → 404 with a body *without* the room marker (client → serviceNotFound); WS refuses with 404 |
| `room_not_found` | validate → 404 with body `requested room does not exist` (client → notAllowed); WS refuses with 404 |
| `no_first_message` | WS accepted, server sends nothing (client hits connect timeout) |
| `no_pong` | WS sends the join, then never pongs (client hits ping timeout) |
| `close_before_join` | WS upgrade succeeds, then ~50ms later a clean close (code 1011, empty reason) *before* any first message — unexpected closure during connect |
| `close_when_connected` | WS sends join, then ~200ms later closes with code 1011 |
| `drop_when_connected` | WS sends join, then ~200ms later abruptly drops the TCP connection with no close handshake — client observes an abnormal closure (code 1006) |
| `leave_when_connected` | WS sends join, then ~200ms later sends a `LeaveRequest` |
| `leave_first_message` | WS sends a `LeaveRequest` as the first (and only) message |
| `leave_during_reconnect` | on a `reconnect=1` connection, sends `LeaveRequest` first; otherwise behaves like `happy` |
`LeaveRequest`s carry `reason=SERVER_SHUTDOWN` and `action` from the control's
optional `leaveAction` (default `DISCONNECT (0)`).
## Permission enforcement
Every API method requires the same token grants the real LiveKit server checks
(see `pkg/service/auth.go`), so the mock doubles as a conformance check that an
SDK attaches the right permissions automatically. Tokens are parsed and verified
with the protocol's own `auth` helpers — the same code path the real server uses
— against the mock's configured API secret (default `secret`, matching
`livekit-server --dev`; override with `--api-secret` / `LK_TEST_SERVER_API_SECRET`).
- Missing, malformed, or wrongly-signed `Authorization` → `401 unauthenticated`.
- Validly-signed token without the required grant → `403 permission_denied`.
- `roomAdmin`-scoped methods also require the token's `room` to match the
request's room; `ForwardParticipant`/`MoveParticipant` additionally require
`destinationRoom` to match.
SDKs exercising permissions should sign tokens with the same API secret the mock
is configured with (`secret` by default).
| Grant | Methods |
|---|---|
| `video.roomCreate` | `CreateRoom`, `DeleteRoom`, all `Connector` calls |
| `video.roomList` | `ListRooms` |
| `video.roomRecord` | all `Egress` methods |
| `video.ingressAdmin` | all `Ingress` methods |
| `video.roomAdmin` (+ `room`) | room participant/data/metadata methods, `AgentDispatchService` methods |
| `video.roomAdmin` (+ `room` + `destinationRoom`) | `ForwardParticipant`, `MoveParticipant` |
| `sip.admin` | SIP trunk & dispatch-rule CRUD |
| `sip.call` | `CreateSIPParticipant`; `TransferSIPParticipant` (also needs `roomAdmin`) |
Send `X-Lk-Mock: {"skipAuth":true}` to bypass enforcement for tests that aren't
about permissions.
## Common recipes
| Goal | `X-Lk-Mock` value |
|---|---|
| Happy path | (no header) — valid token with the method's grant → 200 from region `0` |
| Bypass auth (failover tests) | `{"skipAuth":true}` |
| Missing-permission error | (no header) — token without the required grant → 403 |
| Failover succeeds on region 1 | `{"failRegions":[0]}` |
| Exhaust to region 2 | `{"failRegions":[0,1]}` |
| All regions down | `{"failRegions":[0,1,2,3]}` |
| 4xx, no retry | `{"failRegions":[0],"failStatus":400}` |
| Transport-error failover | `{"failRegions":[0],"failMode":"drop"}` |
| Timeout test | `{"delayMs":30000}` |
| Region discovery unreachable | `{"regionsStatus":500}` |
| Custom response payload | `{"response":{"sid":"RM_x","name":"my-room"}}` |
| SIP busy signal | `{"sipStatus":{"code":486,"status":"Busy Here"}}` |
| SIP carrier decline | `{"sipStatus":{"code":603}}` |
Note: SDK region failover normally only engages for `*.livekit.cloud` hosts.
Since tests point at `127.0.0.1`, set the SDK's failover-enable option to its
forced-on value so failover engages against localhost.

224
cmd/test-server/auth.go Normal file
View File

@@ -0,0 +1,224 @@
// 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 (
"net/http"
"strings"
"github.com/livekit/protocol/auth"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
)
// The mock enforces the same token permissions the real LiveKit server requires
// for each API method (see pkg/service/auth.go in livekit/livekit). The point is
// to verify that SDKs attach the correct grants automatically. Tokens are parsed
// and verified with the protocol's own auth helpers (the same code the server
// uses), against the mock's configured API secret (default "secret", matching
// `livekit-server --dev`); set --api-secret / LK_TEST_SERVER_API_SECRET to change it.
//
// Set `"skipAuth": true` in the X-Lk-Mock header to bypass enforcement for tests
// that aren't about permissions (e.g. region-failover tests with a placeholder token).
// perm describes the grants a method requires. roomAdmin additionally requires
// the token's room to match the request's room; destRoom further requires the
// token's destinationRoom to match the request's destination_room.
type perm struct {
roomCreate bool
roomList bool
roomRecord bool
ingressAdmin bool
roomAdmin bool
destRoom bool
sipAdmin bool
sipCall bool
}
// methodPerms maps "<package>.<Service>/<Method>" to its required grants,
// matching the Ensure*Permission checks in the real server's services.
var methodPerms = map[string]perm{
// RoomService
"livekit.RoomService/CreateRoom": {roomCreate: true},
"livekit.RoomService/DeleteRoom": {roomCreate: true},
"livekit.RoomService/ListRooms": {roomList: true},
"livekit.RoomService/ListParticipants": {roomAdmin: true},
"livekit.RoomService/GetParticipant": {roomAdmin: true},
"livekit.RoomService/RemoveParticipant": {roomAdmin: true},
"livekit.RoomService/MutePublishedTrack": {roomAdmin: true},
"livekit.RoomService/UpdateParticipant": {roomAdmin: true},
"livekit.RoomService/UpdateSubscriptions": {roomAdmin: true},
"livekit.RoomService/SendData": {roomAdmin: true},
"livekit.RoomService/UpdateRoomMetadata": {roomAdmin: true},
"livekit.RoomService/ForwardParticipant": {destRoom: true},
"livekit.RoomService/MoveParticipant": {destRoom: true},
"livekit.RoomService/PerformRpc": {roomAdmin: true},
// Egress — all require record permission
"livekit.Egress/StartEgress": {roomRecord: true},
"livekit.Egress/StartRoomCompositeEgress": {roomRecord: true},
"livekit.Egress/StartWebEgress": {roomRecord: true},
"livekit.Egress/StartParticipantEgress": {roomRecord: true},
"livekit.Egress/StartTrackCompositeEgress": {roomRecord: true},
"livekit.Egress/StartTrackEgress": {roomRecord: true},
"livekit.Egress/UpdateLayout": {roomRecord: true},
"livekit.Egress/UpdateStream": {roomRecord: true},
"livekit.Egress/ListEgress": {roomRecord: true},
"livekit.Egress/StopEgress": {roomRecord: true},
// Ingress — all require ingress admin
"livekit.Ingress/CreateIngress": {ingressAdmin: true},
"livekit.Ingress/UpdateIngress": {ingressAdmin: true},
"livekit.Ingress/ListIngress": {ingressAdmin: true},
"livekit.Ingress/DeleteIngress": {ingressAdmin: true},
// SIP — trunk/dispatch administration requires sip.admin
"livekit.SIP/CreateSIPInboundTrunk": {sipAdmin: true},
"livekit.SIP/CreateSIPOutboundTrunk": {sipAdmin: true},
"livekit.SIP/UpdateSIPInboundTrunk": {sipAdmin: true},
"livekit.SIP/UpdateSIPOutboundTrunk": {sipAdmin: true},
"livekit.SIP/GetSIPInboundTrunk": {sipAdmin: true},
"livekit.SIP/GetSIPOutboundTrunk": {sipAdmin: true},
"livekit.SIP/ListSIPTrunk": {sipAdmin: true},
"livekit.SIP/ListSIPInboundTrunk": {sipAdmin: true},
"livekit.SIP/ListSIPOutboundTrunk": {sipAdmin: true},
"livekit.SIP/DeleteSIPTrunk": {sipAdmin: true},
"livekit.SIP/CreateSIPDispatchRule": {sipAdmin: true},
"livekit.SIP/UpdateSIPDispatchRule": {sipAdmin: true},
"livekit.SIP/ListSIPDispatchRule": {sipAdmin: true},
"livekit.SIP/DeleteSIPDispatchRule": {sipAdmin: true},
// Placing a call requires sip.call; transfer also requires room admin.
"livekit.SIP/CreateSIPParticipant": {sipCall: true},
"livekit.SIP/TransferSIPParticipant": {sipCall: true, roomAdmin: true},
// AgentDispatch — room admin scoped to the dispatch's room
"livekit.AgentDispatchService/CreateDispatch": {roomAdmin: true},
"livekit.AgentDispatchService/DeleteDispatch": {roomAdmin: true},
"livekit.AgentDispatchService/ListDispatch": {roomAdmin: true},
// Connector (cloud) — initiating a call requires room create
"livekit.Connector/DialWhatsAppCall": {roomCreate: true},
"livekit.Connector/DisconnectWhatsAppCall": {roomCreate: true},
"livekit.Connector/ConnectWhatsAppCall": {roomCreate: true},
"livekit.Connector/AcceptWhatsAppCall": {roomCreate: true},
"livekit.Connector/ConnectTwilioCall": {roomCreate: true},
}
// authorize enforces the permissions a method requires. It returns the HTTP
// status and Twirp error code to send (0, "" means authorized / not enforced).
func (h *mockHandler) authorize(key string, r *http.Request, cfg *mockConfig, req proto.Message) (int, string) {
if cfg.SkipAuth {
return 0, ""
}
p, known := methodPerms[key]
if !known {
return 0, "" // unknown/future method: don't enforce
}
grants, err := h.verifyToken(r.Header.Get("Authorization"))
if err != nil {
// Missing, malformed, or improperly-signed token, like the real server.
return http.StatusUnauthorized, "unauthenticated"
}
if !p.satisfiedBy(grants, req) {
return http.StatusForbidden, "permission_denied"
}
return 0, ""
}
// verifyToken parses and verifies a "Bearer <jwt>" header using the protocol's
// own auth helpers (the same path the real server uses), returning the grants.
func (h *mockHandler) verifyToken(authorization string) (*auth.ClaimGrants, error) {
token := strings.TrimSpace(strings.TrimPrefix(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 (p perm) satisfiedBy(g *auth.ClaimGrants, req proto.Message) bool {
v, s := g.Video, g.SIP
if p.roomCreate && (v == nil || !v.RoomCreate) {
return false
}
if p.roomList && (v == nil || !v.RoomList) {
return false
}
if p.roomRecord && (v == nil || !v.RoomRecord) {
return false
}
if p.ingressAdmin && (v == nil || !v.IngressAdmin) {
return false
}
if p.sipAdmin && (s == nil || !s.Admin) {
return false
}
if p.sipCall && (s == nil || !s.Call) {
return false
}
if p.roomAdmin || p.destRoom {
if v == nil || !v.RoomAdmin {
return false
}
if room := requestRoom(req); room != "" && v.Room != room {
return false
}
}
if p.destRoom {
if dest := requestString(req, "destination_room"); dest != "" && v.DestinationRoom != dest {
return false
}
}
return true
}
// requestRoom reads the room name a request targets, trying the common "room"
// and "room_name" fields.
func requestRoom(req proto.Message) string {
if v := requestString(req, "room"); v != "" {
return v
}
return requestString(req, "room_name")
}
func requestString(req proto.Message, field string) string {
if req == nil {
return ""
}
m := req.ProtoReflect()
fd := m.Descriptor().Fields().ByName(protoreflect.Name(field))
if fd == nil || fd.Kind() != protoreflect.StringKind || fd.IsList() {
return ""
}
return m.Get(fd).String()
}
func requestBool(req proto.Message, field string) bool {
if req == nil {
return false
}
m := req.ProtoReflect()
fd := m.Descriptor().Fields().ByName(protoreflect.Name(field))
if fd == nil || fd.Kind() != protoreflect.BoolKind || fd.IsList() {
return false
}
return m.Get(fd).Bool()
}

168
cmd/test-server/config.go Normal file
View File

@@ -0,0 +1,168 @@
// 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"
)
const (
// headerMock carries the whole mock control config as a JSON object; see
// mockConfig and the README. Sent by the SDK on API calls (and forwarded onto
// the /settings/regions fetch and every failover retry).
headerMock = "X-Lk-Mock"
// headerRegion is set on responses to the index of the region that served it.
headerRegion = "X-Lk-Mock-Region"
)
const (
// regionPinStatus is the HTTP status cloud middleware returns for a region-pin
// violation — an API call reaching a region the project is not pinned to. It is
// 451 (Unavailable For Legal Reasons); clients key off it to rediscover regions
// and retry against an allowed one.
regionPinStatus = http.StatusUnavailableForLegalReasons
// regionPinMessage is the plain-text body the middleware writes with the 451.
regionPinMessage = "project not allowed in this region."
)
// Deprecated: the individual X-Lk-Mock-* control headers predate the unified
// X-Lk-Mock JSON header. They are still honored for existing clients; new
// clients should send X-Lk-Mock instead. When X-Lk-Mock is present its fields
// take precedence over any legacy header.
const (
legacyHeaderFailRegions = "X-Lk-Mock-Fail-Regions"
legacyHeaderFailMode = "X-Lk-Mock-Fail-Mode"
legacyHeaderFailStatus = "X-Lk-Mock-Fail-Status"
legacyHeaderFailTwirpCode = "X-Lk-Mock-Fail-Twirp-Code"
legacyHeaderDelayMs = "X-Lk-Mock-Delay-Ms"
legacyHeaderRegionsStatus = "X-Lk-Mock-Regions-Status"
legacyHeaderResponse = "X-Lk-Mock-Response"
legacyHeaderSkipAuth = "X-Lk-Mock-Skip-Auth"
)
// legacyDefaultDelayMs is the sleep used by the deprecated "delay" fail mode when
// no X-Lk-Mock-Delay-Ms is given (long enough to trip client timeouts).
const legacyDefaultDelayMs = 30_000
// mockConfig is the JSON value of the X-Lk-Mock request header. Every field is
// optional; the zero value means "behave normally". A single object keeps the
// control protocol simple — the SDK serializes one struct instead of juggling a
// header per knob.
type mockConfig struct {
// FailRegions lists region indices that should fail this request. A listener
// fails only if its own region index appears here, so one config can make the
// primary fail while a fallback succeeds.
FailRegions []int `json:"failRegions,omitempty"`
// FailMode selects how a failing region fails: "status" (default) writes a
// Twirp error; "drop" closes the connection to force a transport error.
// ("delay" is a deprecated legacy mode; new clients use DelayMs instead.)
FailMode string `json:"failMode,omitempty"`
// FailStatus is the HTTP status for a "status"-mode failure (default 503).
FailStatus int `json:"failStatus,omitempty"`
// FailTwirpCode overrides the Twirp error code string in the failure body
// (default derived from FailStatus).
FailTwirpCode string `json:"failTwirpCode,omitempty"`
// DelayMs delays the response by this many milliseconds before returning,
// whether the region succeeds or fails. It overrides a method's natural
// latency (see methodLatency): set it high for timeout tests, or to 0 to skip
// a SIP method's built-in wait. Nil means "use the natural latency".
DelayMs *int `json:"delayMs,omitempty"`
// RegionsStatus overrides the HTTP status of GET /settings/regions (default 200).
RegionsStatus int `json:"regionsStatus,omitempty"`
// Response is the protojson of the response message for the called method; it
// replaces the populated default, giving full control over the payload.
Response json.RawMessage `json:"response,omitempty"`
// SkipAuth disables permission enforcement for this request (for tests that
// aren't about authz, e.g. failover tests with a placeholder token).
SkipAuth bool `json:"skipAuth,omitempty"`
// SIPStatus, when set on a SIP dial method (CreateSIPParticipant /
// TransferSIPParticipant), fails the call with this SIP status. The Twirp
// error code and metadata (sip_status_code, sip_status, error_details) are
// derived from it exactly as the real server does, so the SDK sees an
// identical error. Composes with DelayMs to simulate "ring, then fail".
SIPStatus *sipStatusConfig `json:"sipStatus,omitempty"`
// PinnedRegions lists the region names a project is pinned to (its allowed
// regions), mirroring cloud's per-project PinnedRegions — which are region names,
// not indices. When non-empty a pin is in effect: any region whose name is NOT
// listed rejects the request with HTTP 451 (see regionPinStatus), exactly as
// cloud middleware turns away an API call reaching a region the project is not
// pinned to, and GET /settings/regions returns only the listed regions. The
// client is expected to see the 451, re-fetch /settings/regions, and retry
// against an allowed region — the 451 carries no region hint. An empty/absent
// value means "not pinned": every region serves and discovery returns all
// regions. Names are this mock's region names (e.g. "region-0"); contrast
// FailRegions, which addresses listeners by index.
PinnedRegions []string `json:"pinnedRegions,omitempty"`
// legacyDelayMs is the sleep used by the deprecated "delay" fail mode. It is
// populated only from the legacy X-Lk-Mock-Delay-Ms header, never from JSON.
legacyDelayMs int
}
// sipStatusConfig is a SIP response to inject; see mockConfig.SIPStatus.
type sipStatusConfig struct {
// Code is the SIP response code, e.g. 486 (Busy Here) or 603 (Decline).
Code int `json:"code"`
// Status is the SIP reason phrase; defaults to the code's canonical name.
Status string `json:"status,omitempty"`
}
// parseMockConfig builds the request's config. Deprecated individual X-Lk-Mock-*
// headers form the base; the unified X-Lk-Mock JSON header (if present) is
// overlaid on top, so its fields win per-field while absent fields keep the
// legacy value.
func parseMockConfig(r *http.Request) mockConfig {
cfg := parseLegacyConfig(r)
if v := r.Header.Get(headerMock); v != "" {
// Unmarshal overwrites only the fields present in the JSON; the unexported
// legacyDelayMs is untouched.
_ = json.Unmarshal([]byte(v), &cfg)
}
return cfg
}
// parseLegacyConfig reads the deprecated per-setting headers into a config.
func parseLegacyConfig(r *http.Request) mockConfig {
var cfg mockConfig
for _, part := range strings.Split(r.Header.Get(legacyHeaderFailRegions), ",") {
if idx, err := strconv.Atoi(strings.TrimSpace(part)); err == nil {
cfg.FailRegions = append(cfg.FailRegions, idx)
}
}
cfg.FailMode = r.Header.Get(legacyHeaderFailMode)
cfg.FailStatus = parseStatus(r.Header.Get(legacyHeaderFailStatus))
cfg.FailTwirpCode = r.Header.Get(legacyHeaderFailTwirpCode)
cfg.RegionsStatus = parseStatus(r.Header.Get(legacyHeaderRegionsStatus))
if resp := r.Header.Get(legacyHeaderResponse); resp != "" {
cfg.Response = json.RawMessage(resp)
}
cfg.SkipAuth = strings.EqualFold(r.Header.Get(legacyHeaderSkipAuth), "true")
cfg.legacyDelayMs = legacyDefaultDelayMs
if ms, err := strconv.Atoi(r.Header.Get(legacyHeaderDelayMs)); err == nil && ms >= 0 {
cfg.legacyDelayMs = ms
}
return cfg
}
// parseStatus returns a valid HTTP status from s, or 0 if absent/invalid.
func parseStatus(s string) int {
if v, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && v >= 100 && v <= 599 {
return v
}
return 0
}

332
cmd/test-server/handlers.go Normal file
View File

@@ -0,0 +1,332 @@
// 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 (
"io"
"net/http"
"slices"
"strconv"
"strings"
"time"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"github.com/livekit/protocol/livekit"
"github.com/livekit/protocol/utils/protojson"
"github.com/livekit/protocol/utils/xtwirp"
)
// apiSpec captures the request and response message types for one Twirp method,
// so the mock can decode the incoming request and build a typed response.
type apiSpec struct {
newReq func() proto.Message
newResp func() proto.Message
}
// ptrMsg constrains a pointer type that is also a proto.Message, letting reg
// construct fresh request/response values generically.
type ptrMsg[T any] interface {
*T
proto.Message
}
// apiHandlers maps "<package>.<Service>/<Method>" to its message types. It
// covers the full LiveKit API surface; see init below.
var apiHandlers = map[string]apiSpec{}
func reg[ReqT, RespT any, Req ptrMsg[ReqT], Resp ptrMsg[RespT]](key string) {
apiHandlers[key] = apiSpec{
newReq: func() proto.Message { return Req(new(ReqT)) },
newResp: func() proto.Message { return Resp(new(RespT)) },
}
}
func init() {
// RoomService
reg[livekit.CreateRoomRequest, livekit.Room]("livekit.RoomService/CreateRoom")
reg[livekit.ListRoomsRequest, livekit.ListRoomsResponse]("livekit.RoomService/ListRooms")
reg[livekit.DeleteRoomRequest, livekit.DeleteRoomResponse]("livekit.RoomService/DeleteRoom")
reg[livekit.ListParticipantsRequest, livekit.ListParticipantsResponse]("livekit.RoomService/ListParticipants")
reg[livekit.RoomParticipantIdentity, livekit.ParticipantInfo]("livekit.RoomService/GetParticipant")
reg[livekit.RoomParticipantIdentity, livekit.RemoveParticipantResponse]("livekit.RoomService/RemoveParticipant")
reg[livekit.MuteRoomTrackRequest, livekit.MuteRoomTrackResponse]("livekit.RoomService/MutePublishedTrack")
reg[livekit.UpdateParticipantRequest, livekit.ParticipantInfo]("livekit.RoomService/UpdateParticipant")
reg[livekit.UpdateSubscriptionsRequest, livekit.UpdateSubscriptionsResponse]("livekit.RoomService/UpdateSubscriptions")
reg[livekit.SendDataRequest, livekit.SendDataResponse]("livekit.RoomService/SendData")
reg[livekit.UpdateRoomMetadataRequest, livekit.Room]("livekit.RoomService/UpdateRoomMetadata")
reg[livekit.ForwardParticipantRequest, livekit.ForwardParticipantResponse]("livekit.RoomService/ForwardParticipant")
reg[livekit.MoveParticipantRequest, livekit.MoveParticipantResponse]("livekit.RoomService/MoveParticipant")
reg[livekit.PerformRpcRequest, livekit.PerformRpcResponse]("livekit.RoomService/PerformRpc")
// Egress
reg[livekit.StartEgressRequest, livekit.EgressInfo]("livekit.Egress/StartEgress")
reg[livekit.UpdateLayoutRequest, livekit.EgressInfo]("livekit.Egress/UpdateLayout")
reg[livekit.UpdateStreamRequest, livekit.EgressInfo]("livekit.Egress/UpdateStream")
reg[livekit.ListEgressRequest, livekit.ListEgressResponse]("livekit.Egress/ListEgress")
reg[livekit.StopEgressRequest, livekit.EgressInfo]("livekit.Egress/StopEgress")
reg[livekit.RoomCompositeEgressRequest, livekit.EgressInfo]("livekit.Egress/StartRoomCompositeEgress")
reg[livekit.WebEgressRequest, livekit.EgressInfo]("livekit.Egress/StartWebEgress")
reg[livekit.ParticipantEgressRequest, livekit.EgressInfo]("livekit.Egress/StartParticipantEgress")
reg[livekit.TrackCompositeEgressRequest, livekit.EgressInfo]("livekit.Egress/StartTrackCompositeEgress")
reg[livekit.TrackEgressRequest, livekit.EgressInfo]("livekit.Egress/StartTrackEgress")
// Ingress
reg[livekit.CreateIngressRequest, livekit.IngressInfo]("livekit.Ingress/CreateIngress")
reg[livekit.UpdateIngressRequest, livekit.IngressInfo]("livekit.Ingress/UpdateIngress")
reg[livekit.ListIngressRequest, livekit.ListIngressResponse]("livekit.Ingress/ListIngress")
reg[livekit.DeleteIngressRequest, livekit.IngressInfo]("livekit.Ingress/DeleteIngress")
// SIP
reg[livekit.ListSIPTrunkRequest, livekit.ListSIPTrunkResponse]("livekit.SIP/ListSIPTrunk")
reg[livekit.CreateSIPInboundTrunkRequest, livekit.SIPInboundTrunkInfo]("livekit.SIP/CreateSIPInboundTrunk")
reg[livekit.CreateSIPOutboundTrunkRequest, livekit.SIPOutboundTrunkInfo]("livekit.SIP/CreateSIPOutboundTrunk")
reg[livekit.UpdateSIPInboundTrunkRequest, livekit.SIPInboundTrunkInfo]("livekit.SIP/UpdateSIPInboundTrunk")
reg[livekit.UpdateSIPOutboundTrunkRequest, livekit.SIPOutboundTrunkInfo]("livekit.SIP/UpdateSIPOutboundTrunk")
reg[livekit.GetSIPInboundTrunkRequest, livekit.GetSIPInboundTrunkResponse]("livekit.SIP/GetSIPInboundTrunk")
reg[livekit.GetSIPOutboundTrunkRequest, livekit.GetSIPOutboundTrunkResponse]("livekit.SIP/GetSIPOutboundTrunk")
reg[livekit.ListSIPInboundTrunkRequest, livekit.ListSIPInboundTrunkResponse]("livekit.SIP/ListSIPInboundTrunk")
reg[livekit.ListSIPOutboundTrunkRequest, livekit.ListSIPOutboundTrunkResponse]("livekit.SIP/ListSIPOutboundTrunk")
reg[livekit.DeleteSIPTrunkRequest, livekit.SIPTrunkInfo]("livekit.SIP/DeleteSIPTrunk")
reg[livekit.CreateSIPDispatchRuleRequest, livekit.SIPDispatchRuleInfo]("livekit.SIP/CreateSIPDispatchRule")
reg[livekit.UpdateSIPDispatchRuleRequest, livekit.SIPDispatchRuleInfo]("livekit.SIP/UpdateSIPDispatchRule")
reg[livekit.ListSIPDispatchRuleRequest, livekit.ListSIPDispatchRuleResponse]("livekit.SIP/ListSIPDispatchRule")
reg[livekit.DeleteSIPDispatchRuleRequest, livekit.SIPDispatchRuleInfo]("livekit.SIP/DeleteSIPDispatchRule")
reg[livekit.CreateSIPParticipantRequest, livekit.SIPParticipantInfo]("livekit.SIP/CreateSIPParticipant")
reg[livekit.TransferSIPParticipantRequest, livekit.TransferSIPParticipantResponse]("livekit.SIP/TransferSIPParticipant")
// Connector
reg[livekit.DialWhatsAppCallRequest, livekit.DialWhatsAppCallResponse]("livekit.Connector/DialWhatsAppCall")
reg[livekit.DisconnectWhatsAppCallRequest, livekit.DisconnectWhatsAppCallResponse]("livekit.Connector/DisconnectWhatsAppCall")
reg[livekit.ConnectWhatsAppCallRequest, livekit.ConnectWhatsAppCallResponse]("livekit.Connector/ConnectWhatsAppCall")
reg[livekit.AcceptWhatsAppCallRequest, livekit.AcceptWhatsAppCallResponse]("livekit.Connector/AcceptWhatsAppCall")
reg[livekit.ConnectTwilioCallRequest, livekit.ConnectTwilioCallResponse]("livekit.Connector/ConnectTwilioCall")
}
// serveAPI handles a Twirp call end to end: decode the request, enforce the
// method's required permissions (like the real server's auth middleware), apply
// any region-failure injection, then serve a populated response.
func (h *mockHandler) serveAPI(w http.ResponseWriter, r *http.Request) {
json := strings.Contains(r.Header.Get("Content-Type"), "json")
key := strings.TrimPrefix(r.URL.Path, h.twirpPrefix+"/")
spec, known := apiHandlers[key]
// Decode the request up front — needed both to enforce room-scoped grants
// and to build the echoed response.
var req proto.Message
if known {
body, _ := io.ReadAll(r.Body)
req = spec.newReq()
if json {
_ = protojson.Unmarshal(body, req)
} else {
_ = proto.Unmarshal(body, req)
}
}
cfg := parseMockConfig(r)
// Permission enforcement comes first, mirroring the real server.
if status, code := h.authorize(key, r, &cfg, req); status != 0 {
writeTwirpErrorCode(w, status, code, "mock: "+code)
return
}
// A project pinned to other regions is turned away by cloud middleware up front
// — before the request is served and with no latency — when it reaches a region
// it isn't pinned to. The check is by region name, as it is on the real server.
if len(cfg.PinnedRegions) > 0 && !slices.Contains(cfg.PinnedRegions, h.regionName()) {
h.failRegionPin(w)
return
}
// Delay before responding (success or failure). An explicit delayMs overrides
// the method's natural latency — e.g. CreateSIPParticipant blocking until the
// callee answers.
delay := methodLatency(key, req)
if cfg.DelayMs != nil {
delay = time.Duration(*cfg.DelayMs) * time.Millisecond
}
if delay > 0 {
time.Sleep(delay)
}
// A SIP dial that fails carries a SIP status; the Twirp code and metadata are
// derived from it exactly as the real server does.
if cfg.SIPStatus != nil && isSIPDialMethod(key) {
h.failSIP(w, &cfg)
return
}
if h.shouldFail(&cfg) {
h.fail(w, &cfg)
return
}
h.writeAPIResponse(w, json, known, req, spec, &cfg)
}
// methodLatency returns the realistic time a method blocks before responding, so
// the mock approximates the real server's behavior. CreateSIPParticipant blocks
// until the callee answers when wait_until_answered is set; TransferSIPParticipant
// always blocks until the transfer (REFER) completes.
func methodLatency(key string, req proto.Message) time.Duration {
switch key {
case "livekit.SIP/CreateSIPParticipant":
if requestBool(req, "wait_until_answered") {
return sipAnswerLatency
}
case "livekit.SIP/TransferSIPParticipant":
return sipAnswerLatency
}
return 0
}
// sipAnswerLatency is how long a SIP call takes to be answered/transferred in the
// mock — long enough to exercise client-side timeouts around these calls.
const sipAnswerLatency = 11 * time.Second
// isSIPDialMethod reports whether key places a call that can fail with a SIP status.
func isSIPDialMethod(key string) bool {
switch key {
case "livekit.SIP/CreateSIPParticipant", "livekit.SIP/TransferSIPParticipant":
return true
}
return false
}
// failSIP fails the request with the configured SIP status, mirroring the real
// server: the status maps to a Twirp error code and attaches sip_status_code,
// sip_status, and error_details metadata via xtwirp.
func (h *mockHandler) failSIP(w http.ResponseWriter, cfg *mockConfig) {
st := &livekit.SIPStatus{
Code: livekit.SIPStatusCode(cfg.SIPStatus.Code),
Status: cfg.SIPStatus.Status,
}
writeTwirpErr(w, xtwirp.ToError(st))
}
// failRegionPin rejects the request with HTTP 451, mirroring cloud middleware
// turning away an API call from a project pinned to a different region. The body
// is the middleware's plain-text message (not a Twirp error): clients key off the
// 451 status to rediscover regions and retry against an allowed one.
func (h *mockHandler) failRegionPin(w http.ResponseWriter) {
w.Header().Set(headerRegion, "")
w.WriteHeader(regionPinStatus)
_, _ = w.Write([]byte(regionPinMessage))
}
// writeAPIResponse serves a populated, type-correct response for a known API
// method. The response is the reflection-populated default unless the mock
// config carries a `response` (protojson), which overrides it entirely. Content
// type (protobuf vs JSON) mirrors the request.
func (h *mockHandler) writeAPIResponse(w http.ResponseWriter, json, known bool, req proto.Message, spec apiSpec, cfg *mockConfig) {
w.Header().Set(headerRegion, strconv.Itoa(h.regionIndex))
if !known {
// Unknown/future method: an empty body still decodes to a valid default
// message in every Twirp client.
writeEmptySuccess(w, json)
return
}
resp := spec.newResp()
if len(cfg.Response) > 0 {
if err := protojson.Unmarshal(cfg.Response, resp); err != nil {
// Malformed override: fall back to the populated default.
resp = spec.newResp()
populateMessage(resp.ProtoReflect(), req.ProtoReflect(), 1)
}
} else {
populateMessage(resp.ProtoReflect(), req.ProtoReflect(), 1)
}
if json {
out, _ := protojson.Marshal(resp)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(out)
} else {
out, _ := proto.Marshal(resp)
w.Header().Set("Content-Type", "application/protobuf")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(out)
}
}
func writeEmptySuccess(w http.ResponseWriter, json bool) {
if json {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("{}"))
} else {
w.Header().Set("Content-Type", "application/protobuf")
w.WriteHeader(http.StatusOK)
}
}
// populateMessage fills a response message with plausible values: it echoes
// scalar fields that share a name with the request, assigns placeholder values
// to id/sid fields, and adds one element to repeated-message (list) fields so
// list endpoints return non-empty results. depth bounds list-element nesting.
func populateMessage(m protoreflect.Message, req protoreflect.Message, depth int) {
fields := m.Descriptor().Fields()
for i := 0; i < fields.Len(); i++ {
fd := fields.Get(i)
// Echo a same-named scalar field from the request (e.g. name, metadata,
// identity, room, timeouts).
if req != nil && fd.Cardinality() != protoreflect.Repeated && isScalarKind(fd.Kind()) {
if rf := req.Descriptor().Fields().ByName(fd.Name()); rf != nil &&
rf.Kind() == fd.Kind() && rf.Cardinality() != protoreflect.Repeated && req.Has(rf) {
m.Set(fd, req.Get(rf))
continue
}
}
// Give id/sid-like string fields a deterministic placeholder.
if fd.Kind() == protoreflect.StringKind && fd.Cardinality() != protoreflect.Repeated && !m.Has(fd) {
n := string(fd.Name())
if n == "id" || n == "sid" || strings.HasSuffix(n, "_id") || strings.HasSuffix(n, "_sid") {
m.Set(fd, protoreflect.ValueOfString("MOCK_"+strings.ToUpper(n)))
continue
}
}
// Populate list endpoints with a single element so clients see results.
if depth > 0 && fd.IsList() && fd.Kind() == protoreflect.MessageKind {
list := m.Mutable(fd).List()
elem := list.NewElement()
populateMessage(elem.Message(), nil, depth-1)
list.Append(elem)
}
}
}
func isScalarKind(k protoreflect.Kind) bool {
switch k {
case protoreflect.BoolKind,
protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Uint32Kind,
protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Uint64Kind,
protoreflect.Sfixed32Kind, protoreflect.Fixed32Kind,
protoreflect.Sfixed64Kind, protoreflect.Fixed64Kind,
protoreflect.FloatKind, protoreflect.DoubleKind,
protoreflect.StringKind, protoreflect.BytesKind:
return true
default:
return false
}
}

273
cmd/test-server/main.go Normal file
View File

@@ -0,0 +1,273 @@
// 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.
// Command test-server is a programmable mock of the LiveKit server HTTP API,
// used by the server SDKs to test client behavior. See cmd/test-server/README.md.
package main
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"slices"
"strconv"
"strings"
"syscall"
"time"
"github.com/twitchtv/twirp"
"github.com/livekit/protocol/livekit"
"github.com/livekit/protocol/utils/protojson"
)
func main() {
portsFlag := flagValue("--ports", "LK_TEST_SERVER_PORTS", "9999,10000,10001,10002")
advertiseHost := flagValue("--advertise-host", "LK_TEST_SERVER_ADVERTISE_HOST", "http://127.0.0.1")
bindAddr := flagValue("--bind", "LK_TEST_SERVER_BIND", "0.0.0.0")
twirpPrefix := flagValue("--twirp-prefix", "LK_TEST_SERVER_TWIRP_PREFIX", "/twirp")
// API secret used to verify request tokens for permission enforcement.
// Defaults to the `livekit-server --dev` secret.
apiSecret := flagValue("--api-secret", "LK_TEST_SERVER_API_SECRET", "secret")
ports, err := parsePorts(portsFlag)
if err != nil {
fmt.Fprintf(os.Stderr, "invalid --ports: %v\n", err)
os.Exit(1)
}
advertiseHost = strings.TrimRight(advertiseHost, "/")
regions := &livekit.RegionSettings{}
for i, p := range ports {
regions.Regions = append(regions.Regions, &livekit.RegionInfo{
Region: fmt.Sprintf("region-%d", i),
Url: fmt.Sprintf("%s:%d", advertiseHost, p),
Distance: int64(i),
})
}
errCh := make(chan error, len(ports))
for i, p := range ports {
srv := &http.Server{
Addr: fmt.Sprintf("%s:%d", bindAddr, p),
Handler: &mockHandler{regionIndex: i, regions: regions, twirpPrefix: twirpPrefix, apiSecret: apiSecret},
}
go func() { errCh <- srv.ListenAndServe() }()
fmt.Printf("test-server: region-%d listening on %s:%d (advertised as %s:%d)\n", i, bindAddr, p, advertiseHost, p)
}
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
select {
case err := <-errCh:
fmt.Fprintf(os.Stderr, "listener failed: %v\n", err)
os.Exit(1)
case <-sigCh:
fmt.Println("test-server: shutting down")
}
}
type mockHandler struct {
regionIndex int
regions *livekit.RegionSettings
twirpPrefix string
apiSecret string
}
func (h *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/settings/regions":
h.handleRegions(w, r)
case isValidatePath(r.URL.Path):
h.handleValidate(w, r)
case isSignalPath(r.URL.Path):
h.handleSignal(w, r)
case strings.HasPrefix(r.URL.Path, h.twirpPrefix+"/"):
h.handleTwirp(w, r)
case r.URL.Path == "/" || r.URL.Path == "/_test/health":
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
default:
http.NotFound(w, r)
}
}
func (h *mockHandler) handleRegions(w http.ResponseWriter, r *http.Request) {
cfg := parseMockConfig(r)
if cfg.RegionsStatus != 0 && cfg.RegionsStatus != http.StatusOK {
w.WriteHeader(cfg.RegionsStatus)
return
}
regions := h.regions
if len(cfg.PinnedRegions) > 0 {
// A pinned project's discovery returns only its allowed regions, so the
// client redirects straight to one after a 451 — mirroring cloud filtering
// /settings/regions by the project's PinnedRegions.
regions = pinnedRegionSettings(h.regions, cfg.PinnedRegions)
}
body, err := protojson.Marshal(regions)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "max-age=0")
w.Header().Set(headerRegion, strconv.Itoa(h.regionIndex))
_, _ = w.Write(body)
}
func (h *mockHandler) handleTwirp(w http.ResponseWriter, r *http.Request) {
h.serveAPI(w, r)
}
// regionName is the advertised name of the region this listener represents.
func (h *mockHandler) regionName() string {
if h.regionIndex < len(h.regions.Regions) {
return h.regions.Regions[h.regionIndex].Region
}
return ""
}
// pinnedRegionSettings returns only the regions whose name appears in pinned,
// mirroring how cloud filters /settings/regions to a project's PinnedRegions.
// Names outside the region list are ignored.
func pinnedRegionSettings(all *livekit.RegionSettings, pinned []string) *livekit.RegionSettings {
out := &livekit.RegionSettings{}
for _, region := range all.Regions {
if slices.Contains(pinned, region.Region) {
out.Regions = append(out.Regions, region)
}
}
return out
}
func (h *mockHandler) shouldFail(cfg *mockConfig) bool {
return slices.Contains(cfg.FailRegions, h.regionIndex)
}
func (h *mockHandler) fail(w http.ResponseWriter, cfg *mockConfig) {
switch strings.ToLower(cfg.FailMode) {
case "drop":
if hj, ok := w.(http.Hijacker); ok {
if conn, _, err := hj.Hijack(); err == nil {
_ = conn.Close()
return
}
}
w.WriteHeader(http.StatusServiceUnavailable)
return
case "delay":
// Deprecated legacy mode: sleep, then status-fail. New clients should set
// DelayMs (which delays every response) instead.
time.Sleep(time.Duration(cfg.legacyDelayMs) * time.Millisecond)
}
status := cfg.FailStatus
if status < 100 || status > 599 {
status = http.StatusServiceUnavailable
}
writeTwirpError(w, cfg, status)
}
func writeTwirpError(w http.ResponseWriter, cfg *mockConfig, status int) {
code := cfg.FailTwirpCode
if code == "" {
code = twirpCodeForStatus(status)
}
writeTwirpErrorCode(w, status, code, fmt.Sprintf("mock failure (status %d)", status))
}
// writeTwirpErrorCode writes a Twirp JSON error with an explicit code and message.
func writeTwirpErrorCode(w http.ResponseWriter, status int, code, msg string) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set(headerRegion, "")
w.WriteHeader(status)
_, _ = fmt.Fprintf(w, `{"code":%q,"msg":%q}`, code, msg)
}
// writeTwirpErr writes a full Twirp JSON error — code, message, and metadata —
// using the HTTP status Twirp derives from the error code.
func writeTwirpErr(w http.ResponseWriter, terr twirp.Error) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set(headerRegion, "")
w.WriteHeader(twirp.ServerHTTPStatusFromErrorCode(terr.Code()))
_ = json.NewEncoder(w).Encode(struct {
Code string `json:"code"`
Msg string `json:"msg"`
Meta map[string]string `json:"meta,omitempty"`
}{
Code: string(terr.Code()),
Msg: terr.Msg(),
Meta: terr.MetaMap(),
})
}
func twirpCodeForStatus(status int) string {
switch {
case status == http.StatusBadRequest:
return "invalid_argument"
case status == http.StatusUnauthorized:
return "unauthenticated"
case status == http.StatusForbidden:
return "permission_denied"
case status == http.StatusNotFound:
return "not_found"
case status == http.StatusTooManyRequests:
return "resource_exhausted"
case status >= 500:
return "unavailable"
default:
return "internal"
}
}
func parsePorts(s string) ([]int, error) {
var ports []int
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
v, err := strconv.Atoi(part)
if err != nil {
return nil, fmt.Errorf("%q is not a port number", part)
}
ports = append(ports, v)
}
if len(ports) == 0 {
return nil, errors.New("at least one port is required")
}
return ports, nil
}
// flagValue resolves a setting from a --flag, then an environment variable, then a default.
func flagValue(flag, env, def string) string {
prefix := flag + "="
for i, arg := range os.Args[1:] {
if arg == flag {
if i+2 <= len(os.Args[1:]) {
return os.Args[1:][i+1]
}
}
if strings.HasPrefix(arg, prefix) {
return strings.TrimPrefix(arg, prefix)
}
}
if v := os.Getenv(env); v != "" {
return v
}
return def
}

471
cmd/test-server/signal.go Normal file
View File

@@ -0,0 +1,471 @@
// 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":"<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" || 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,
},
},
}
}

View File

@@ -0,0 +1,451 @@
// 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"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"google.golang.org/protobuf/proto"
"github.com/livekit/protocol/auth"
"github.com/livekit/protocol/livekit"
)
const testSecret = "secret"
func newTestServer() *httptest.Server {
return httptest.NewServer(&mockHandler{regionIndex: 0, apiSecret: testSecret})
}
// mintToken signs a token whose `lk.mock` attribute selects mode (empty → happy).
func mintToken(t *testing.T, mode string) string {
t.Helper()
if mode == "" {
return mintTokenControl(t, nil)
}
return mintTokenControl(t, &signalControl{Signal: mode})
}
// mintTokenControl signs a token carrying ctrl as the `lk.mock` attribute (nil → none).
func mintTokenControl(t *testing.T, ctrl *signalControl) string {
t.Helper()
at := auth.NewAccessToken("APItest", testSecret).
SetIdentity("tester").
SetValidFor(time.Hour).
SetVideoGrant(&auth.VideoGrant{Room: "test-room", RoomJoin: true})
if ctrl != nil {
raw, err := json.Marshal(ctrl)
if err != nil {
t.Fatalf("marshal control: %v", err)
}
at.SetAttributes(map[string]string{signalControlAttribute: string(raw)})
}
tok, err := at.ToJWT()
if err != nil {
t.Fatalf("mint token: %v", err)
}
return tok
}
// mintTokenAttr signs a token whose `lk.mock` attribute is the given raw value.
func mintTokenAttr(t *testing.T, attrValue string) string {
t.Helper()
at := auth.NewAccessToken("APItest", testSecret).
SetIdentity("tester").
SetValidFor(time.Hour).
SetVideoGrant(&auth.VideoGrant{Room: "test-room", RoomJoin: true}).
SetAttributes(map[string]string{signalControlAttribute: attrValue})
tok, err := at.ToJWT()
if err != nil {
t.Fatalf("mint token: %v", err)
}
return tok
}
func wsURL(base, path, token string) string {
u := strings.Replace(base, "http://", "ws://", 1)
sep := "?"
if strings.Contains(path, "?") {
sep = "&"
}
return u + path + sep + "access_token=" + token
}
func dial(t *testing.T, base, path, token string) *websocket.Conn {
t.Helper()
c, _, err := websocket.DefaultDialer.Dial(wsURL(base, path, token), nil)
if err != nil {
t.Fatalf("dial %s: %v", path, err)
}
return c
}
func readResp(t *testing.T, c *websocket.Conn, timeout time.Duration) *livekit.SignalResponse {
t.Helper()
_ = c.SetReadDeadline(time.Now().Add(timeout))
mt, payload, err := c.ReadMessage()
if err != nil {
t.Fatalf("read: %v", err)
}
if mt != websocket.BinaryMessage {
t.Fatalf("expected binary message, got %d", mt)
}
resp := &livekit.SignalResponse{}
if err := proto.Unmarshal(payload, resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
return resp
}
func writeReq(t *testing.T, c *websocket.Conn, req *livekit.SignalRequest) {
t.Helper()
payload, err := proto.Marshal(req)
if err != nil {
t.Fatalf("marshal req: %v", err)
}
if err := c.WriteMessage(websocket.BinaryMessage, payload); err != nil {
t.Fatalf("write req: %v", err)
}
}
func TestHappyJoinAndPingPong(t *testing.T) {
srv := newTestServer()
defer srv.Close()
c := dial(t, srv.URL, "/rtc", mintToken(t, "happy"))
defer c.Close()
resp := readResp(t, c, 2*time.Second)
join := resp.GetJoin()
if join == nil {
t.Fatalf("first message not join: %T", resp.Message)
}
if join.PingTimeout == 0 || join.PingInterval == 0 {
t.Fatalf("ping timeout/interval must be non-zero: %d/%d", join.PingTimeout, join.PingInterval)
}
if join.ServerInfo == nil || join.Room == nil || join.Participant == nil {
t.Fatalf("join missing serverInfo/room/participant")
}
if join.Room.Name != "test-room" {
t.Fatalf("room name = %q, want test-room", join.Room.Name)
}
// pingReq -> pongResp echoing timestamp
writeReq(t, c, &livekit.SignalRequest{
Message: &livekit.SignalRequest_PingReq{PingReq: &livekit.Ping{Timestamp: 12345}},
})
pr := readResp(t, c, 2*time.Second)
if pr.GetPongResp() == nil || pr.GetPongResp().LastPingTimestamp != 12345 {
t.Fatalf("expected pongResp echoing 12345, got %+v", pr.Message)
}
// legacy ping -> pong
writeReq(t, c, &livekit.SignalRequest{Message: &livekit.SignalRequest_Ping{Ping: 999}})
pong := readResp(t, c, 2*time.Second)
if pong.GetPong() == 0 {
t.Fatalf("expected pong, got %+v", pong.Message)
}
}
func TestReconnectResponse(t *testing.T) {
srv := newTestServer()
defer srv.Close()
c := dial(t, srv.URL, "/rtc?reconnect=1", mintToken(t, "happy"))
defer c.Close()
resp := readResp(t, c, 2*time.Second)
if resp.GetReconnect() == nil {
t.Fatalf("expected reconnect response, got %T", resp.Message)
}
}
func TestV1PathHappy(t *testing.T) {
srv := newTestServer()
defer srv.Close()
c := dial(t, srv.URL, "/rtc/v1", mintToken(t, "happy"))
defer c.Close()
if readResp(t, c, 2*time.Second).GetJoin() == nil {
t.Fatal("v1 first message not join")
}
}
func TestNoPong(t *testing.T) {
srv := newTestServer()
defer srv.Close()
c := dial(t, srv.URL, "/rtc", mintToken(t, "no_pong"))
defer c.Close()
if readResp(t, c, 2*time.Second).GetJoin() == nil {
t.Fatal("expected join")
}
writeReq(t, c, &livekit.SignalRequest{
Message: &livekit.SignalRequest_PingReq{PingReq: &livekit.Ping{Timestamp: 1}},
})
_ = c.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
if _, _, err := c.ReadMessage(); err == nil {
t.Fatal("expected no pong (timeout), but got a message")
}
}
func TestLeaveFirstMessage(t *testing.T) {
srv := newTestServer()
defer srv.Close()
c := dial(t, srv.URL, "/rtc", mintToken(t, "leave_first_message"))
defer c.Close()
if readResp(t, c, 2*time.Second).GetLeave() == nil {
t.Fatal("expected leave as first message")
}
}
func TestCloseWhenConnected(t *testing.T) {
srv := newTestServer()
defer srv.Close()
c := dial(t, srv.URL, "/rtc", mintToken(t, "close_when_connected"))
defer c.Close()
if readResp(t, c, 2*time.Second).GetJoin() == nil {
t.Fatal("expected join")
}
_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
_, _, err := c.ReadMessage()
ce, ok := err.(*websocket.CloseError)
if !ok {
t.Fatalf("expected close error, got %v", err)
}
if ce.Code != websocket.CloseInternalServerErr {
t.Fatalf("expected close code 1011, got %d", ce.Code)
}
}
func TestLeaveWhenConnected(t *testing.T) {
srv := newTestServer()
defer srv.Close()
c := dial(t, srv.URL, "/rtc", mintToken(t, "leave_when_connected"))
defer c.Close()
if readResp(t, c, 2*time.Second).GetJoin() == nil {
t.Fatal("expected join")
}
leave := readResp(t, c, 2*time.Second).GetLeave()
if leave == nil {
t.Fatal("expected leave after join")
}
// Default action is DISCONNECT.
if leave.Action != livekit.LeaveRequest_DISCONNECT {
t.Fatalf("default leave action = %v, want DISCONNECT", leave.Action)
}
}
func TestLeaveActionOverride(t *testing.T) {
srv := newTestServer()
defer srv.Close()
tok := mintTokenControl(t, &signalControl{
Signal: "leave_when_connected",
LeaveAction: leaveActionValue(livekit.LeaveRequest_RECONNECT),
})
c := dial(t, srv.URL, "/rtc", tok)
defer c.Close()
if readResp(t, c, 2*time.Second).GetJoin() == nil {
t.Fatal("expected join")
}
leave := readResp(t, c, 2*time.Second).GetLeave()
if leave == nil {
t.Fatal("expected leave after join")
}
if leave.Action != livekit.LeaveRequest_RECONNECT {
t.Fatalf("leave action = %v, want RECONNECT", leave.Action)
}
}
func TestLeaveActionByName(t *testing.T) {
srv := newTestServer()
defer srv.Close()
// leaveAction may be the enum name instead of the number.
tok := mintTokenAttr(t, `{"signal":"leave_when_connected","leaveAction":"RECONNECT"}`)
c := dial(t, srv.URL, "/rtc", tok)
defer c.Close()
if readResp(t, c, 2*time.Second).GetJoin() == nil {
t.Fatal("expected join")
}
leave := readResp(t, c, 2*time.Second).GetLeave()
if leave == nil {
t.Fatal("expected leave after join")
}
if leave.Action != livekit.LeaveRequest_RECONNECT {
t.Fatalf("leave action = %v, want RECONNECT", leave.Action)
}
}
func TestNoFirstMessage(t *testing.T) {
srv := newTestServer()
defer srv.Close()
c := dial(t, srv.URL, "/rtc", mintToken(t, "no_first_message"))
defer c.Close()
_ = c.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
if _, _, err := c.ReadMessage(); err == nil {
t.Fatal("expected no first message (timeout)")
}
}
func TestCloseBeforeJoin(t *testing.T) {
srv := newTestServer()
defer srv.Close()
c := dial(t, srv.URL, "/rtc", mintToken(t, "close_before_join"))
defer c.Close()
// First read must be the close, never a join.
_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
_, _, err := c.ReadMessage()
ce, ok := err.(*websocket.CloseError)
if !ok {
t.Fatalf("expected close error before any message, got %v", err)
}
if ce.Code != websocket.CloseInternalServerErr {
t.Fatalf("expected close code 1011, got %d", ce.Code)
}
if ce.Text != "" {
t.Fatalf("expected empty close reason, got %q", ce.Text)
}
}
func TestDropWhenConnected(t *testing.T) {
srv := newTestServer()
defer srv.Close()
c := dial(t, srv.URL, "/rtc", mintToken(t, "drop_when_connected"))
defer c.Close()
if readResp(t, c, 2*time.Second).GetJoin() == nil {
t.Fatal("expected join")
}
// Abrupt TCP drop → abnormal closure (1006 to a browser): a read error that
// is not a normal (1000) close.
_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
_, _, err := c.ReadMessage()
if err == nil {
t.Fatal("expected read error after abrupt drop")
}
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
t.Fatalf("expected abnormal (non-1000) closure, got %v", err)
}
}
func getStatusBody(t *testing.T, url string) (int, string) {
t.Helper()
resp, err := http.Get(url)
if err != nil {
t.Fatalf("get %s: %v", url, err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
func TestValidateModes(t *testing.T) {
srv := newTestServer()
defer srv.Close()
// happy → 200
if st, _ := getStatusBody(t, srv.URL+"/rtc/validate?access_token="+mintToken(t, "happy")); st != 200 {
t.Fatalf("happy validate = %d, want 200", st)
}
// v1 validate happy → 200
if st, _ := getStatusBody(t, srv.URL+"/rtc/v1/validate?access_token="+mintToken(t, "happy")); st != 200 {
t.Fatalf("v1 happy validate = %d, want 200", st)
}
// validate_500 → 500
if st, _ := getStatusBody(t, srv.URL+"/rtc/validate?access_token="+mintToken(t, "validate_500")); st != 500 {
t.Fatalf("validate_500 = %d, want 500", st)
}
// validate_service_not_found → 404, no marker
st, body := getStatusBody(t, srv.URL+"/rtc/validate?access_token="+mintToken(t, "validate_service_not_found"))
if st != 404 || strings.Contains(body, "requested room does not exist") {
t.Fatalf("service_not_found = %d body=%q", st, body)
}
// room_not_found → 404 with marker
st, body = getStatusBody(t, srv.URL+"/rtc/validate?access_token="+mintToken(t, "room_not_found"))
if st != 404 || !strings.Contains(body, "requested room does not exist") {
t.Fatalf("room_not_found = %d body=%q", st, body)
}
// bad token → 401
if st, _ := getStatusBody(t, srv.URL+"/rtc/validate?access_token=not-a-jwt"); st != 401 {
t.Fatalf("bad token validate = %d, want 401", st)
}
// missing token → 401
if st, _ := getStatusBody(t, srv.URL+"/rtc/validate"); st != 401 {
t.Fatalf("missing token validate = %d, want 401", st)
}
}
func TestValidateErrorModesRefuseWS(t *testing.T) {
srv := newTestServer()
defer srv.Close()
// Validate-error modes must refuse the upgrade so the client falls back to validate.
_, resp, err := websocket.DefaultDialer.Dial(wsURL(srv.URL, "/rtc", mintToken(t, "validate_500")), nil)
if err == nil {
t.Fatal("expected WS dial to fail for validate_500")
}
if resp == nil || resp.StatusCode != 500 {
t.Fatalf("expected 500 on WS refuse, got %v", resp)
}
}
func TestValidateCORSHeader(t *testing.T) {
srv := newTestServer()
defer srv.Close()
// ACAO must be present on every status so a browser fetch can read it.
cases := map[string]string{
"happy": mintToken(t, "happy"), // 200
"bad": "bad", // 401
"room_not_found": mintToken(t, "room_not_found"), // 404
"validate_500": mintToken(t, "validate_500"), // 500
}
for name, tok := range cases {
resp, err := http.Get(srv.URL + "/rtc/validate?access_token=" + tok)
if err != nil {
t.Fatalf("%s: get: %v", name, err)
}
got := resp.Header.Get("Access-Control-Allow-Origin")
resp.Body.Close()
if got != "*" {
t.Fatalf("%s (status %d): ACAO = %q, want *", name, resp.StatusCode, got)
}
}
}
func TestWSCrossOrigin(t *testing.T) {
srv := newTestServer()
defer srv.Close()
// A mismatched browser Origin must still upgrade (CheckOrigin allows any).
hdr := http.Header{}
hdr.Set("Origin", "http://localhost:5173")
c, _, err := websocket.DefaultDialer.Dial(wsURL(srv.URL, "/rtc", mintToken(t, "happy")), hdr)
if err != nil {
t.Fatalf("cross-origin dial: %v", err)
}
defer c.Close()
if readResp(t, c, 2*time.Second).GetJoin() == nil {
t.Fatal("expected join on cross-origin WS")
}
}
func TestLeaveDuringReconnect(t *testing.T) {
srv := newTestServer()
defer srv.Close()
c := dial(t, srv.URL, "/rtc?reconnect=1", mintToken(t, "leave_during_reconnect"))
defer c.Close()
if readResp(t, c, 2*time.Second).GetLeave() == nil {
t.Fatal("expected leave on reconnect")
}
}