Files
livekit/pkg/service/utils.go
Paul Wells 9ac10fba0e agent endpoints: serve the front on its own middleware chain
The front was mounted on the API mux and served through the API middleware
chain, which is shaped for handlers that buffer a request and write one
response. Four consequences.

negroni.NewRecovery() recovers every panic with no http.ErrAbortHandler
exemption, and NewRecovery sets PrintStack. On an abort it wrote "PANIC: ..."
and a goroutine stack into the already-committed body, then returned normally,
so net/http terminated the chunked stream cleanly. A truncated response
reached the client as complete, with a stack trace appended. Detection was
already correct; delivery was not, and the x-lk- trailers that would otherwise
signal it are stripped before the response leaves. A Content-Length response
still failed safe, since net/http enforces the declared length itself, so the
gap was the chunked and trailer paths.

Endpoints.Disabled documents that it turns the front off, but only
registrations were refused; the mount was unconditional.

The CORS method list omits PUT, which a manifest may declare.

The API body limiter capped request bodies at MaxAPIRequestBodySize, though
the front streams a body through a pooled buffer and never holds one.

NewHTTPHandler now routes the prefix to a chain carrying AgentRecovery, a CORS
list matching the methods a manifest may declare, and the api-key auth
middleware the front resolves a caller's access from. The mount is built only
when endpoints are enabled, so the prefix otherwise falls through and 404s.
RemoveDoubleSlashes moves above the split, so routing and both chains see one
path form.

Taking the front off the mux also stops ServeMux rewriting the paths it is
handed: "//x", "/../" and interior "//" were answered with a redirect rather
than proxied, which a byte-transparent exchange cannot do.

The endpoint stack tests now build the production handler, so they run on the
chain the node serves on.
2026-09-14 13:11:10 -07:00

473 lines
14 KiB
Go

// 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 service
import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/ua-parser/uap-go/uaparser"
"gopkg.in/yaml.v3"
"github.com/livekit/livekit-server/pkg/config"
"github.com/livekit/livekit-server/pkg/routing"
"github.com/livekit/livekit-server/pkg/routing/selector"
"github.com/livekit/livekit-server/pkg/rtc"
"github.com/livekit/livekit-server/pkg/utils"
"github.com/livekit/protocol/auth"
"github.com/livekit/protocol/livekit"
"github.com/livekit/protocol/logger"
)
var (
ErrGzipReadFailed = errors.New("cannot read decompressed data")
ErrGzipTooLarge = errors.New("decompressed data too large")
ErrRequestBodyTooLarge = errors.New("request body too large")
)
var gzipReaderPool = sync.Pool{
New: func() any { return &gzip.Reader{} },
}
func DecompressGzip(compressed []byte) ([]byte, error) {
reader := gzipReaderPool.Get().(*gzip.Reader)
defer gzipReaderPool.Put(reader)
if err := reader.Reset(bytes.NewReader(compressed)); err != nil {
return nil, fmt.Errorf("%w: %w", ErrGzipReadFailed, err)
}
out, err := io.ReadAll(io.LimitReader(reader, http.DefaultMaxHeaderBytes+1))
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrGzipReadFailed, err)
}
if len(out) > http.DefaultMaxHeaderBytes {
return nil, ErrGzipTooLarge
}
return out, nil
}
func handleError(w http.ResponseWriter, r *http.Request, status int, err error, keysAndValues ...any) {
keysAndValues = append(keysAndValues, "status", status)
if r != nil && r.URL != nil {
keysAndValues = append(keysAndValues, "method", r.Method, "path", r.URL.Path)
}
if !errors.Is(err, context.Canceled) && !errors.Is(r.Context().Err(), context.Canceled) {
utils.GetLogger(r.Context()).WithCallDepth(1).Warnw("error handling request", err, keysAndValues...)
}
w.WriteHeader(status)
}
func HandleError(w http.ResponseWriter, r *http.Request, status int, err error, keysAndValues ...any) {
handleError(w, r, status, err, keysAndValues...)
_, _ = w.Write([]byte(err.Error()))
}
func HandleErrorJson(w http.ResponseWriter, r *http.Request, status int, err error, keysAndValues ...any) {
handleError(w, r, status, err, keysAndValues...)
json.NewEncoder(w).Encode(struct {
Error string `json:"error"`
}{
Error: err.Error(),
})
w.Header().Add("Content-type", "application/json")
}
func boolValue(s string) bool {
return s == "1" || s == "true"
}
func RemoveDoubleSlashes(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
// Path and RawPath must move together: once they disagree, EscapedPath()
// re-encodes from Path and every escape in the request is lost.
if strings.HasPrefix(r.URL.EscapedPath(), "//") {
r.URL.Path = r.URL.Path[1:]
if r.URL.RawPath != "" {
r.URL.RawPath = r.URL.RawPath[1:]
}
}
next(w, r)
}
// WithPathNormalization applies RemoveDoubleSlashes ahead of h, so routing and every
// middleware below it see one path form.
func WithPathNormalization(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
RemoveDoubleSlashes(w, r, h.ServeHTTP)
})
}
// RequestBodyLimiter bounds the size of an incoming HTTP request body so that
// large messages cannot exhaust memory. The Twirp handlers decode the whole
// body before any grant check runs, so the limit is applied here, up front.
//
// It does not decode the body itself: a request whose Content-Length exceeds
// the limit is rejected with 413, and the body is wrapped with
// http.MaxBytesReader so a missing or dishonest Content-Length is still caught
// by the downstream decoder.
type RequestBodyLimiter struct {
maxBytes int64
}
func NewRequestBodyLimiter(maxBytes int64) *RequestBodyLimiter {
return &RequestBodyLimiter{maxBytes: maxBytes}
}
func (l *RequestBodyLimiter) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
if l.maxBytes <= 0 || r.Body == nil {
next(w, r)
return
}
// reject early when the declared size already exceeds the limit
if r.ContentLength > l.maxBytes {
HandleError(w, r, http.StatusRequestEntityTooLarge, ErrRequestBodyTooLarge)
return
}
// bound the read in case Content-Length is missing or wrong
r.Body = http.MaxBytesReader(w, r.Body, l.maxBytes)
next(w, r)
}
func IsValidDomain(domain string) bool {
domainRegexp := regexp.MustCompile(`^(?i)[a-z0-9-]+(\.[a-z0-9-]+)+\.?$`)
return domainRegexp.MatchString(domain)
}
func GetClientIP(r *http.Request) string {
// CF proxy typically is first thing the user reaches
if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
return ip
}
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
return ip
}
if ip := r.Header.Get("X-Real-IP"); ip != "" {
return ip
}
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
return ip
}
func SetRoomConfiguration(createRequest *livekit.CreateRoomRequest, conf *livekit.RoomConfiguration) {
if conf == nil {
return
}
createRequest.Agents = conf.Agents
createRequest.Egress = conf.Egress
createRequest.EmptyTimeout = conf.EmptyTimeout
createRequest.DepartureTimeout = conf.DepartureTimeout
createRequest.MaxParticipants = conf.MaxParticipants
createRequest.MinPlayoutDelay = conf.MinPlayoutDelay
createRequest.MaxPlayoutDelay = conf.MaxPlayoutDelay
createRequest.SyncStreams = conf.SyncStreams
createRequest.Metadata = conf.Metadata
createRequest.Tags = conf.Tags
}
func ParseClientInfo(r *http.Request) *livekit.ClientInfo {
values := r.Form
ci := &livekit.ClientInfo{}
if pv, err := strconv.ParseInt(values.Get("protocol"), 10, 32); err == nil {
ci.Protocol = int32(pv)
}
if cp, err := strconv.ParseInt(values.Get("client_protocol"), 10, 32); err == nil {
ci.ClientProtocol = int32(cp)
}
sdkString := values.Get("sdk")
switch sdkString {
case "js":
ci.Sdk = livekit.ClientInfo_JS
case "ios", "swift":
ci.Sdk = livekit.ClientInfo_SWIFT
case "android":
ci.Sdk = livekit.ClientInfo_ANDROID
case "flutter":
ci.Sdk = livekit.ClientInfo_FLUTTER
case "go":
ci.Sdk = livekit.ClientInfo_GO
case "unity":
ci.Sdk = livekit.ClientInfo_UNITY
case "reactnative":
ci.Sdk = livekit.ClientInfo_REACT_NATIVE
case "rust":
ci.Sdk = livekit.ClientInfo_RUST
case "python":
ci.Sdk = livekit.ClientInfo_PYTHON
case "cpp":
ci.Sdk = livekit.ClientInfo_CPP
case "unityweb":
ci.Sdk = livekit.ClientInfo_UNITY_WEB
case "node":
ci.Sdk = livekit.ClientInfo_NODE
case "esp32":
ci.Sdk = livekit.ClientInfo_ESP32
}
ci.Version = values.Get("version")
ci.Os = values.Get("os")
ci.OsVersion = values.Get("os_version")
ci.Browser = values.Get("browser")
ci.BrowserVersion = values.Get("browser_version")
ci.DeviceModel = values.Get("device_model")
ci.Network = values.Get("network")
if capStr := values.Get("capabilities"); capStr != "" {
for _, name := range strings.Split(capStr, ",") {
name = strings.TrimSpace(name)
if name == "" {
continue
}
if v, ok := livekit.ClientInfo_Capability_value[name]; ok {
ci.Capabilities = append(ci.Capabilities, livekit.ClientInfo_Capability(v))
}
}
}
AugmentClientInfo(ci, r)
return ci
}
var (
userAgentParserCache *uaparser.Parser
userAgentParserInit sync.Once
)
func createUserAgentParserWithCustomRules() (*uaparser.Parser, error) {
defaultYaml := uaparser.DefinitionYaml
rules := make(map[string]any)
err := yaml.Unmarshal(defaultYaml, rules)
if err != nil {
return nil, err
}
rules["user_agent_parsers"] = append(rules["user_agent_parsers"].([]any), map[string]any{
"regex": "OBS-Studio\\/([0-9\\.]+)",
"family_replacement": "OBS Studio",
"v1_replacement": "$1",
})
customYaml, err := yaml.Marshal(rules)
if err != nil {
return nil, err
}
return uaparser.NewFromBytes([]byte(customYaml))
}
func getUserAgentParser() *uaparser.Parser {
userAgentParserInit.Do(func() {
if parser, err := createUserAgentParserWithCustomRules(); err != nil {
logger.Warnw("could not create user agent parser with custom rules, using default", err)
userAgentParserCache = uaparser.NewFromSaved()
} else {
userAgentParserCache = parser
}
})
return userAgentParserCache
}
func AugmentClientInfo(ci *livekit.ClientInfo, req *http.Request) {
if ci == nil {
return
}
// get real address (forwarded http header) - check Cloudflare headers first, fall back to X-Forwarded-For
ci.Address = GetClientIP(req)
// attempt to parse types for SDKs that support browser as a platform
if ci.Sdk == livekit.ClientInfo_JS ||
ci.Sdk == livekit.ClientInfo_REACT_NATIVE ||
ci.Sdk == livekit.ClientInfo_FLUTTER ||
ci.Sdk == livekit.ClientInfo_UNITY ||
ci.Sdk == livekit.ClientInfo_UNKNOWN {
client := getUserAgentParser().Parse(req.UserAgent())
if ci.Browser == "" {
ci.Browser = client.UserAgent.Family
ci.BrowserVersion = client.UserAgent.ToVersionString()
}
if ci.Os == "" {
ci.Os = client.Os.Family
ci.OsVersion = client.Os.ToVersionString()
}
if ci.DeviceModel == "" {
model := client.Device.Family
if model != "" && client.Device.Model != "" && model != client.Device.Model {
model += " " + client.Device.Model
}
ci.DeviceModel = model
}
}
}
type ValidateConnectRequestParams struct {
roomName livekit.RoomName
publish string
metadata string
attributes map[string]string
}
type ValidateConnectRequestResult struct {
roomName livekit.RoomName
grants *auth.ClaimGrants
tokenExpiresAt time.Time
region string
createRoomRequest *livekit.CreateRoomRequest
}
func ValidateConnectRequest(
lgr logger.Logger,
r *http.Request,
limitConfig config.LimitConfig,
params ValidateConnectRequestParams,
router routing.MessageRouter,
roomAllocator RoomAllocator,
) (ValidateConnectRequestResult, int, error) {
var res ValidateConnectRequestResult
// require a claim
claims := GetGrants(r.Context())
if claims == nil || claims.Video == nil {
return res, http.StatusUnauthorized, rtc.ErrPermissionDenied
}
roomNameInToken, err := EnsureJoinPermission(r.Context())
if err != nil {
return res, http.StatusUnauthorized, err
}
if claims.Identity == "" {
return res, http.StatusBadRequest, ErrIdentityEmpty
}
if !limitConfig.CheckParticipantIdentityLength(claims.Identity) {
return res, http.StatusBadRequest, fmt.Errorf("%w: max length %d", ErrParticipantIdentityExceedsLimits, limitConfig.MaxParticipantIdentityLength)
}
if claims.RoomConfig != nil {
if err := claims.RoomConfig.CheckCredentials(); err != nil {
lgr.Warnw("credentials found in token", nil)
// TODO(dz): in a future version, we'll reject these connections
}
}
res.roomName = params.roomName
if roomNameInToken != "" {
res.roomName = roomNameInToken
}
if res.roomName == "" {
return res, http.StatusBadRequest, ErrNoRoomName
}
if !limitConfig.CheckRoomNameLength(string(res.roomName)) {
return res, http.StatusBadRequest, fmt.Errorf("%w: max length %d", ErrRoomNameExceedsLimits, limitConfig.MaxRoomNameLength)
}
// this is new connection for existing participant - with publish only permissions
if params.publish != "" {
// Make sure grant has GetCanPublish set,
if !claims.Video.GetCanPublish() {
return res, http.StatusUnauthorized, rtc.ErrPermissionDenied
}
// Make sure by default subscribe is off
claims.Video.SetCanSubscribe(false)
claims.Identity += "#" + params.publish
}
// room allocator validations
err = roomAllocator.ValidateCreateRoom(r.Context(), res.roomName)
if err != nil {
if errors.Is(err, ErrRoomNotFound) {
return res, http.StatusNotFound, err
} else {
return res, http.StatusInternalServerError, err
}
}
if router, ok := router.(routing.Router); ok {
res.region = router.GetRegion()
if foundNode, err := router.GetNodeForRoom(r.Context(), res.roomName); err == nil {
if selector.LimitsReached(limitConfig, foundNode.Stats) {
return res, http.StatusServiceUnavailable, rtc.ErrLimitExceeded
}
}
}
createRequest := &livekit.CreateRoomRequest{
Name: string(res.roomName),
RoomPreset: claims.RoomPreset,
}
SetRoomConfiguration(createRequest, claims.GetRoomConfiguration())
res.createRoomRequest = createRequest
if len(params.metadata) != 0 {
// Make sure grant has GetCanUpdateOwnMetadata set
if !claims.Video.GetCanUpdateOwnMetadata() {
return res, http.StatusUnauthorized, rtc.ErrPermissionDenied
}
claims.Metadata = params.metadata
}
// Add extra attributes to the participant
if len(params.attributes) != 0 {
// Make sure grant has GetCanUpdateOwnMetadata set
if !claims.Video.GetCanUpdateOwnMetadata() {
return res, http.StatusUnauthorized, rtc.ErrPermissionDenied
}
if claims.Attributes == nil {
claims.Attributes = make(map[string]string, len(params.attributes))
}
for k, v := range params.attributes {
if v == "" {
continue // do not allow deleting existing attributes
}
claims.Attributes[k] = v
}
}
res.grants = claims
res.tokenExpiresAt = GetTokenExpiresAt(r.Context())
return res, http.StatusOK, nil
}
func IsRTCPath(path string) bool {
return path == "/rtc" || path == "/rtc/v1"
}
func IsRTCValidatePath(path string) bool {
return path == "/rtc/validate" || path == "/rtc/v1/validate"
}
func IsAgentWorkerPath(path string) bool {
return path == "/agent"
}
func IsAgentPath(path string) bool {
return strings.HasPrefix(path, "/agent")
}