Files
livekit/pkg/service/whipservice.go
Anunay Maheshwari 1d804737f9 fix: limit join request and WHIP request body to http.DefaultMaxHeaderBytes (#4450)
* fix: CS-1665

* cleanup

* cleanup and testes

* updates
2026-04-16 01:12:33 +05:30

545 lines
17 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 (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/pion/webrtc/v4"
"github.com/tomnomnom/linkheader"
"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/rtc/types"
sutils "github.com/livekit/livekit-server/pkg/utils"
"github.com/livekit/protocol/livekit"
"github.com/livekit/protocol/logger"
"github.com/livekit/protocol/rpc"
"github.com/livekit/protocol/utils/guid"
"github.com/livekit/psrpc"
)
const (
cParticipantPath = "/whip/v1"
cParticipantIDPath = "/whip/v1/{participant_id}"
)
type WHIPService struct {
http.Handler
config *config.Config
router routing.Router
roomAllocator RoomAllocator
client rpc.WHIPClient[livekit.NodeID]
topicFormatter rpc.TopicFormatter
participantClient rpc.TypedWHIPParticipantClient
}
func NewWHIPService(
config *config.Config,
router routing.Router,
roomAllocator RoomAllocator,
clientParams rpc.ClientParams,
topicFormatter rpc.TopicFormatter,
participantClient rpc.TypedWHIPParticipantClient,
) (*WHIPService, error) {
client, err := rpc.NewWHIPClient[livekit.NodeID](clientParams.Args())
if err != nil {
return nil, err
}
return &WHIPService{
config: config,
router: router,
roomAllocator: roomAllocator,
client: client,
topicFormatter: topicFormatter,
participantClient: participantClient,
}, nil
}
func (s *WHIPService) SetupRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET "+cParticipantPath, s.handleGet)
mux.HandleFunc("OPTIONS "+cParticipantPath, s.handleOptions)
mux.HandleFunc("POST "+cParticipantPath, s.handleCreate)
mux.HandleFunc("GET "+cParticipantIDPath, s.handleParticipantGet)
mux.HandleFunc("PATCH "+cParticipantIDPath, s.handleParticipantPatch)
mux.HandleFunc("DELETE "+cParticipantIDPath, s.handleParticipantDelete)
}
func (s *WHIPService) handleGet(w http.ResponseWriter, r *http.Request) {
// https:/www.rfc-editor.org/rfc/rfc9725.html#name-http-usage
w.WriteHeader(http.StatusNoContent)
}
func (s *WHIPService) handleOptions(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "*")
w.Header().Set("Access-Control-Allow-Methods", "PATCH, OPTIONS, GET, POST, DELETE")
w.Header().Set("Access-Control-Expose-Headers", "*")
w.WriteHeader(http.StatusOK)
// According to https://www.rfc-editor.org/rfc/rfc9725.html#name-stun-turn-server-configurat,
// ICE servers can be returned in OPTIONS response, but not recommended.
//
// Supporting that here is tricky. This would have to get region settings like the
// session CREATE POST request and send a request to get ICE servers from a
// region + media node that is selected. The issue is that a subsequent POST,
// although unlikely, may end up in a different region. Media node in one region and
// TURN in another region, although shuttling media across regions, should still work.
// But, as this is not a recommended way, not supporting it.
}
type createRequest struct {
RoomName livekit.RoomName
ParticipantInit routing.ParticipantInit
ClientIP string
OfferSDP string
SubscribedParticipantTrackNames map[string][]string
FromIngress bool
}
func (s *WHIPService) validateCreate(w http.ResponseWriter, r *http.Request) (*createRequest, int, error) {
claims := GetGrants(r.Context())
if claims == nil || claims.Video == nil {
return nil, http.StatusUnauthorized, rtc.ErrPermissionDenied
}
roomName, err := EnsureJoinPermission(r.Context())
if err != nil {
return nil, http.StatusUnauthorized, err
}
if roomName == "" {
return nil, http.StatusUnauthorized, errors.New("room name cannot be empty")
}
if !s.config.Limit.CheckRoomNameLength(string(roomName)) {
return nil, http.StatusBadRequest, fmt.Errorf("%w: max length %d", ErrRoomNameExceedsLimits, s.config.Limit.MaxRoomNameLength)
}
if claims.Identity == "" {
return nil, http.StatusBadRequest, ErrIdentityEmpty
}
if !s.config.Limit.CheckParticipantIdentityLength(claims.Identity) {
return nil, http.StatusBadRequest, fmt.Errorf("%w: max length %d", ErrParticipantIdentityExceedsLimits, s.config.Limit.MaxParticipantIdentityLength)
}
var clientInfo struct {
ClientIP string `json:"clientIp"`
SubscribedParticipantTrackNames map[string][]string `json:"subscribedParticipantTrackNames"`
}
clientInfoHeader := r.Header.Get("X-LiveKit-ClientInfo")
if clientInfoHeader != "" {
if err := json.NewDecoder(strings.NewReader(clientInfoHeader)).Decode(&clientInfo); err != nil {
return nil, http.StatusBadRequest, fmt.Errorf("malformed json in client info header: %s", err)
}
}
fromIngress := r.Header.Get("X-Livekit-Ingress")
offerSDPBytes, err := io.ReadAll(http.MaxBytesReader(w, r.Body, http.DefaultMaxHeaderBytes))
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
return nil, http.StatusRequestEntityTooLarge, fmt.Errorf("request body exceeds %d bytes", maxErr.Limit)
}
return nil, http.StatusBadRequest, fmt.Errorf("body does not have SDP offer: %s", err)
}
if len(offerSDPBytes) == 0 {
return nil, http.StatusBadRequest, errors.New("body does not have SDP offer")
}
offerSDP := string(offerSDPBytes)
sd := &webrtc.SessionDescription{
Type: webrtc.SDPTypeOffer,
SDP: offerSDP,
}
_, err = sd.Unmarshal()
if err != nil {
return nil, http.StatusBadRequest, fmt.Errorf("malformed SDP offer: %s", err)
}
ci := ParseClientInfo(r)
if ci.Protocol == 0 {
// if no client info available (which will be mostly the case with WHIP clients), at least set protocol
ci.Protocol = types.CurrentProtocol
}
pi := routing.ParticipantInit{
Identity: livekit.ParticipantIdentity(claims.Identity),
Name: livekit.ParticipantName(claims.Name),
AutoSubscribe: true,
Client: ci,
Grants: claims,
CreateRoom: &livekit.CreateRoomRequest{
Name: string(roomName),
RoomPreset: claims.RoomPreset,
},
AdaptiveStream: false,
DisableICELite: true,
}
SetRoomConfiguration(pi.CreateRoom, claims.GetRoomConfiguration())
return &createRequest{
roomName,
pi,
clientInfo.ClientIP,
offerSDP,
clientInfo.SubscribedParticipantTrackNames,
fromIngress != "",
}, http.StatusOK, nil
}
func (s *WHIPService) handleCreate(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Content-type") != "application/sdp" {
s.handleError("Create", w, r, http.StatusBadRequest, fmt.Errorf("unsupported content-type: %s", r.Header.Get("Content-type")))
return
}
w.Header().Add("Content-type", "application/sdp")
req, status, err := s.validateCreate(w, r)
if err != nil {
s.handleError("Create", w, r, status, err)
return
}
if err := s.roomAllocator.SelectRoomNode(r.Context(), req.RoomName, ""); err != nil {
s.handleError("Create", w, r, http.StatusInternalServerError, err)
return
}
rtcNode, err := s.router.GetNodeForRoom(r.Context(), req.RoomName)
if err != nil {
s.handleError("Create", w, r, http.StatusInternalServerError, err)
return
}
connID := livekit.ConnectionID(guid.New("CO_"))
starSession, err := req.ParticipantInit.ToStartSession(req.RoomName, connID)
if err != nil {
s.handleError("Create", w, r, http.StatusInternalServerError, err)
return
}
subscribedParticipantTracks := map[string]*rpc.WHIPCreateRequest_TrackList{}
for identity, trackNames := range req.SubscribedParticipantTrackNames {
subscribedParticipantTracks[identity] = &rpc.WHIPCreateRequest_TrackList{
TrackNames: trackNames,
}
}
res, err := s.client.Create(r.Context(), livekit.NodeID(rtcNode.Id), &rpc.WHIPCreateRequest{
OfferSdp: req.OfferSDP,
StartSession: starSession,
SubscribedParticipantTracks: subscribedParticipantTracks,
FromIngress: req.FromIngress,
})
if err != nil {
s.handleError("Create", w, r, http.StatusServiceUnavailable, err)
return
}
// created resource sent in Location header:
// https://www.rfc-editor.org/rfc/rfc9725.html#name-ingest-session-setup
// using relative location
w.Header().Add("Location", fmt.Sprintf("%s/%s", cParticipantPath, res.ParticipantId))
// ICE servers as Link header(s):
// https://www.rfc-editor.org/rfc/rfc9725.html#name-stun-turn-server-configurat
var iceServerLinks []*linkheader.Link
for _, iceServer := range res.IceServers {
for _, iceURL := range iceServer.Urls {
iceServerLink := &linkheader.Link{
URL: url.PathEscape(iceURL),
Rel: "ice-server",
Params: map[string]string{},
}
if iceServer.Username != "" {
iceServerLink.Params["username"] = iceServer.Username
}
if iceServer.Credential != "" {
iceServerLink.Params["credential"] = iceServer.Credential
}
iceServerLinks = append(iceServerLinks, iceServerLink)
}
}
for _, iceServerLink := range iceServerLinks {
w.Header().Add("Link", iceServerLink.String())
}
// To support ICE Trickle/Restart, HTTP PATCH should have an ETag
// send ICE session ID (ICE ufrag is used as ID) in ETag header
// https://www.rfc-editor.org/rfc/rfc9725.html#name-http-patch-request-usage
if res.IceSessionId != "" {
w.Header().Add("ETag", res.IceSessionId)
}
// 201 Status Created
w.WriteHeader(http.StatusCreated)
// SDP answer in the response body
w.Write([]byte(res.AnswerSdp))
sutils.GetLogger(r.Context()).Infow(
"API WHIP.Create",
"connID", connID,
"participant", req.ParticipantInit.Identity,
"room", req.RoomName,
"status", http.StatusCreated,
"response", logger.Proto(res),
)
}
func (s *WHIPService) handleParticipantGet(w http.ResponseWriter, r *http.Request) {
// https:/www.rfc-editor.org/rfc/rfc9725.html#name-http-usage
w.WriteHeader(http.StatusNoContent)
}
func (s *WHIPService) iceTrickle(
w http.ResponseWriter,
r *http.Request,
roomName livekit.RoomName,
participantIdentity livekit.ParticipantIdentity,
pID livekit.ParticipantID,
iceSessionID string,
sdpFragment string,
) {
_, err := s.participantClient.ICETrickle(
r.Context(),
s.topicFormatter.ParticipantTopic(r.Context(), roomName, participantIdentity),
&rpc.WHIPParticipantICETrickleRequest{
Room: string(roomName),
ParticipantIdentity: string(participantIdentity),
ParticipantId: string(pID),
IceSessionId: iceSessionID,
SdpFragment: sdpFragment,
},
)
if err != nil {
var pe psrpc.Error
if errors.As(err, &pe) {
switch pe.Code() {
case psrpc.NotFound:
s.handleError("Patch", w, r, http.StatusNotFound, errors.New(pe.Error()))
case psrpc.InvalidArgument:
switch pe.Error() {
case rtc.ErrInvalidSDPFragment.Error(), rtc.ErrMidMismatch.Error(), rtc.ErrICECredentialMismatch.Error():
s.handleError("Patch", w, r, http.StatusBadRequest, errors.New(pe.Error()))
default:
s.handleError("Patch", w, r, http.StatusInternalServerError, errors.New(pe.Error()))
}
default:
s.handleError("Patch", w, r, http.StatusInternalServerError, errors.New(pe.Error()))
}
} else {
s.handleError("Patch", w, r, http.StatusInternalServerError, nil)
}
return
}
sutils.GetLogger(r.Context()).Infow(
"API WHIP.Patch",
"method", "ice-trickle",
"room", roomName,
"participant", participantIdentity,
"participantID", pID,
"sdpFragment", sdpFragment,
"status", http.StatusNoContent,
)
w.WriteHeader(http.StatusNoContent)
}
func (s *WHIPService) iceRestart(
w http.ResponseWriter,
r *http.Request,
roomName livekit.RoomName,
participantIdentity livekit.ParticipantIdentity,
pID livekit.ParticipantID,
sdpFragment string,
) {
res, err := s.participantClient.ICERestart(
r.Context(),
s.topicFormatter.ParticipantTopic(r.Context(), roomName, participantIdentity),
&rpc.WHIPParticipantICERestartRequest{
Room: string(roomName),
ParticipantIdentity: string(participantIdentity),
ParticipantId: string(pID),
SdpFragment: sdpFragment,
},
)
if err != nil {
var pe psrpc.Error
if errors.As(err, &pe) {
switch pe.Code() {
case psrpc.NotFound:
s.handleError("Patch", w, r, http.StatusNotFound, errors.New(pe.Error()))
case psrpc.InvalidArgument:
switch pe.Error() {
case rtc.ErrInvalidSDPFragment.Error():
s.handleError("Patch", w, r, http.StatusBadRequest, errors.New(pe.Error()))
default:
s.handleError("Patch", w, r, http.StatusInternalServerError, errors.New(pe.Error()))
}
default:
s.handleError("Patch", w, r, http.StatusInternalServerError, errors.New(pe.Error()))
}
} else {
s.handleError("Patch", w, r, http.StatusInternalServerError, nil)
}
return
}
sutils.GetLogger(r.Context()).Infow(
"API WHIP.Patch",
"method", "ice-restart",
"room", roomName,
"participant", participantIdentity,
"participantID", pID,
"sdpFragment", sdpFragment,
"status", http.StatusNoContent,
"res", logger.Proto(res),
)
if res.IceSessionId != "" {
w.Header().Add("ETag", res.IceSessionId)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(res.SdpFragment))
}
func (s *WHIPService) handleParticipantPatch(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Content-type") != "application/trickle-ice-sdpfrag" {
s.handleError("Patch", w, r, http.StatusBadRequest, fmt.Errorf("unsupported content-type: %s", r.Header.Get("Content-type")))
return
}
w.Header().Add("Content-type", "application/trickle-ice-sdpfrag")
// https://www.rfc-editor.org/rfc/rfc9725.html#name-http-patch-request-usage
ifMatch := r.Header.Get("If-Match")
if ifMatch == "" {
s.handleError("Patch", w, r, http.StatusPreconditionRequired, errors.New("missing entity tag"))
return
}
claims := GetGrants(r.Context())
if claims == nil || claims.Video == nil {
s.handleError("Patch", w, r, http.StatusUnauthorized, rtc.ErrPermissionDenied)
return
}
roomName, err := EnsureJoinPermission(r.Context())
if err != nil {
s.handleError("Patch", w, r, http.StatusUnauthorized, err)
return
}
if roomName == "" {
s.handleError("Patch", w, r, http.StatusUnauthorized, errors.New("room name cannot be empty"))
return
}
if claims.Identity == "" {
s.handleError("Patch", w, r, http.StatusUnauthorized, errors.New("participant identity cannot be empty"))
return
}
pID := livekit.ParticipantID(r.PathValue("participant_id"))
if pID == "" {
s.handleError("Patch", w, r, http.StatusBadRequest, errors.New("participant ID cannot be empty"))
return
}
sdpFragmentBytes, err := io.ReadAll(http.MaxBytesReader(w, r.Body, http.DefaultMaxHeaderBytes))
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
s.handleError("Patch", w, r, http.StatusRequestEntityTooLarge, fmt.Errorf("request body exceeds %d bytes", maxErr.Limit))
return
}
s.handleError("Patch", w, r, http.StatusBadRequest, fmt.Errorf("body does not have SDP fragment: %s", err))
return
}
sdpFragment := string(sdpFragmentBytes)
if ifMatch == "*" {
s.iceRestart(w, r, roomName, livekit.ParticipantIdentity(claims.Identity), pID, sdpFragment)
} else {
s.iceTrickle(w, r, roomName, livekit.ParticipantIdentity(claims.Identity), pID, ifMatch, sdpFragment)
}
}
func (s *WHIPService) handleParticipantDelete(w http.ResponseWriter, r *http.Request) {
claims := GetGrants(r.Context())
if claims == nil || claims.Video == nil {
s.handleError("Delete", w, r, http.StatusUnauthorized, rtc.ErrPermissionDenied)
return
}
roomName, err := EnsureJoinPermission(r.Context())
if err != nil {
s.handleError("Delete", w, r, http.StatusUnauthorized, err)
return
}
if roomName == "" {
s.handleError("Delete", w, r, http.StatusUnauthorized, errors.New("room name cannot be empty"))
return
}
if claims.Identity == "" {
s.handleError("Delete", w, r, http.StatusUnauthorized, errors.New("participant identity cannot be empty"))
return
}
_, err = s.participantClient.DeleteSession(
r.Context(),
s.topicFormatter.ParticipantTopic(r.Context(), roomName, livekit.ParticipantIdentity(claims.Identity)),
&rpc.WHIPParticipantDeleteSessionRequest{
Room: string(roomName),
ParticipantIdentity: claims.Identity,
ParticipantId: r.PathValue("participant_id"),
},
)
if err != nil {
s.handleError("Delete", w, r, http.StatusNotFound, err)
return
}
sutils.GetLogger(r.Context()).Infow(
"API WHIP.Delete",
"participant", claims.Identity,
"participantID", r.PathValue("participant_id"),
"room", roomName,
"status", http.StatusOK,
)
w.WriteHeader(http.StatusOK)
}
func (s *WHIPService) handleError(method string, w http.ResponseWriter, r *http.Request, status int, err error) {
sutils.GetLogger(r.Context()).Warnw(
fmt.Sprintf("API WHIP.%s", method), err,
"status", status,
)
w.WriteHeader(status)
json.NewEncoder(w).Encode(struct {
Error string `json:"error"`
}{
Error: err.Error(),
})
}