Files
livekit/pkg/service/ingress.go
T
Benjamin PrachtandClaude Opus 5 91f33da004 ingress: add opt-in support for udp:// URL pull ingress (#4810)
* ingress: add opt-in support for udp:// URL pull ingress

URL pull ingress previously accepted only http, https and srt source
URLs. Add udp:// as an accepted scheme, gated behind a new
`ingress.enable_udp_url_pull` config option that defaults to false, so
unauthenticated UDP sources are not reachable unless the operator opts
in.

Also add a counterfeiter fake for IngressLauncher and a test covering
the scheme validation matrix.

The ingress handler binds a local socket on the caller supplied address
and port instead of connecting out like the http and srt sources do.
Spell out what that means for operators in both the config field doc
comment and config-sample.yaml: caller controlled local port binding,
unauthenticated and spoofable input, and multicast relaying of traffic
on the handler's local network.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 12:11:14 -07:00

421 lines
11 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 (
"context"
"fmt"
"net/url"
"github.com/livekit/protocol/ingress"
"github.com/livekit/protocol/livekit"
"github.com/livekit/protocol/logger"
"github.com/livekit/protocol/rpc"
"github.com/livekit/protocol/utils"
"github.com/livekit/protocol/utils/guid"
"github.com/livekit/psrpc"
"github.com/livekit/livekit-server/pkg/config"
"github.com/livekit/livekit-server/pkg/telemetry"
)
//counterfeiter:generate . IngressLauncher
type IngressLauncher interface {
LaunchPullIngress(ctx context.Context, info *livekit.IngressInfo) (*livekit.IngressInfo, error)
}
type IngressService struct {
conf *config.IngressConfig
nodeID livekit.NodeID
bus psrpc.MessageBus
psrpcClient rpc.IngressClient
store IngressStore
io IOClient
telemetry telemetry.TelemetryService
launcher IngressLauncher
}
func NewIngressServiceWithIngressLauncher(
conf *config.IngressConfig,
nodeID livekit.NodeID,
bus psrpc.MessageBus,
psrpcClient rpc.IngressClient,
store IngressStore,
io IOClient,
ts telemetry.TelemetryService,
launcher IngressLauncher,
) *IngressService {
return &IngressService{
conf: conf,
nodeID: nodeID,
bus: bus,
psrpcClient: psrpcClient,
store: store,
io: io,
telemetry: ts,
launcher: launcher,
}
}
func NewIngressService(
conf *config.IngressConfig,
nodeID livekit.NodeID,
bus psrpc.MessageBus,
psrpcClient rpc.IngressClient,
store IngressStore,
io IOClient,
ts telemetry.TelemetryService,
) *IngressService {
s := NewIngressServiceWithIngressLauncher(conf, nodeID, bus, psrpcClient, store, io, ts, nil)
s.launcher = s
return s
}
func (s *IngressService) CreateIngress(ctx context.Context, req *livekit.CreateIngressRequest) (*livekit.IngressInfo, error) {
fields := []any{
"inputType", req.InputType,
"name", req.Name,
}
if req.RoomName != "" {
fields = append(fields, "room", req.RoomName, "identity", req.ParticipantIdentity)
}
defer func() {
AppendLogFields(ctx, fields...)
}()
var url string
switch req.InputType {
case livekit.IngressInput_RTMP_INPUT:
url = s.conf.RTMPBaseURL
case livekit.IngressInput_WHIP_INPUT:
url = s.conf.WHIPBaseURL
case livekit.IngressInput_URL_INPUT:
default:
return nil, ingress.ErrInvalidIngressType
}
ig, err := s.CreateIngressWithUrl(ctx, url, req)
if err != nil {
return nil, err
}
fields = append(fields, "ingressID", ig.IngressId)
return ig, nil
}
func (s *IngressService) CreateIngressWithUrl(ctx context.Context, urlStr string, req *livekit.CreateIngressRequest) (*livekit.IngressInfo, error) {
err := EnsureIngressAdminPermission(ctx)
if err != nil {
return nil, twirpAuthError(err)
}
if s.store == nil {
return nil, ErrIngressNotConnected
}
if req.InputType == livekit.IngressInput_URL_INPUT {
if req.Url == "" {
return nil, ingress.ErrInvalidIngress("missing URL parameter")
}
urlObj, err := url.Parse(req.Url)
if err != nil {
return nil, psrpc.NewError(psrpc.InvalidArgument, err)
}
switch urlObj.Scheme {
case "http", "https", "srt":
case "udp":
if !s.conf.EnableUDPURLPull {
return nil, ingress.ErrInvalidIngress("udp url pull is not enabled")
}
default:
return nil, ingress.ErrInvalidIngress(fmt.Sprintf("invalid url scheme %s", urlObj.Scheme))
}
// Marshall the URL again for sanitization
urlStr = urlObj.String()
}
reqID := RequestID(ctx)
var sk string
if req.InputType != livekit.IngressInput_URL_INPUT {
sk = guid.New("")
}
info := &livekit.IngressInfo{
IngressId: DeterministicID(utils.IngressPrefix, reqID),
Name: req.Name,
StreamKey: sk,
Url: urlStr,
InputType: req.InputType,
Audio: req.Audio,
Video: req.Video,
EnableTranscoding: req.EnableTranscoding,
RoomName: req.RoomName,
ParticipantIdentity: req.ParticipantIdentity,
ParticipantName: req.ParticipantName,
ParticipantMetadata: req.ParticipantMetadata,
State: &livekit.IngressState{},
Enabled: req.Enabled,
}
switch req.InputType {
case livekit.IngressInput_RTMP_INPUT,
livekit.IngressInput_WHIP_INPUT:
info.Reusable = true
if err := ingress.ValidateForSerialization(info); err != nil {
return nil, err
}
case livekit.IngressInput_URL_INPUT:
if err := ingress.Validate(info); err != nil {
return nil, err
}
default:
return nil, ingress.ErrInvalidIngressType
}
updateEnableTranscoding(info)
if req.InputType == livekit.IngressInput_URL_INPUT {
retInfo, err := s.launcher.LaunchPullIngress(ctx, info)
if retInfo != nil {
info = retInfo
} else {
info.State.Status = livekit.IngressState_ENDPOINT_ERROR
info.State.Error = err.Error()
}
if err != nil {
return info, err
}
// The Ingress instance will create the ingress object when handling the URL pull ingress
} else {
var resp *rpc.CreateIngressResponse
resp, err = s.io.CreateIngress(ctx, info)
switch err {
case nil:
if resp.GetInfo() != nil {
info = resp.GetInfo()
}
case ingress.ErrIngressOutOfDate:
// Error returned if the ingress was already created by the ingress service
err = nil
default:
logger.Errorw("could not create ingress object", err)
return nil, err
}
}
return info, nil
}
func (s *IngressService) LaunchPullIngress(ctx context.Context, info *livekit.IngressInfo) (*livekit.IngressInfo, error) {
req := &rpc.StartIngressRequest{
Info: info,
}
return s.psrpcClient.StartIngress(ctx, req)
}
func updateEnableTranscoding(info *livekit.IngressInfo) {
// Set BypassTranscoding as well for backward compatibility
if info.EnableTranscoding != nil {
info.BypassTranscoding = !*info.EnableTranscoding
return
}
switch info.InputType {
case livekit.IngressInput_WHIP_INPUT:
f := false
info.EnableTranscoding = &f
info.BypassTranscoding = true
default:
t := true
info.EnableTranscoding = &t
}
}
func updateInfoUsingRequest(req *livekit.UpdateIngressRequest, info *livekit.IngressInfo) error {
if req.Name != "" {
info.Name = req.Name
}
if req.RoomName != "" {
info.RoomName = req.RoomName
}
if req.ParticipantIdentity != "" {
info.ParticipantIdentity = req.ParticipantIdentity
}
if req.ParticipantName != "" {
info.ParticipantName = req.ParticipantName
}
if req.EnableTranscoding != nil {
info.EnableTranscoding = req.EnableTranscoding
}
if req.ParticipantMetadata != "" {
info.ParticipantMetadata = req.ParticipantMetadata
}
if req.Audio != nil {
info.Audio = req.Audio
}
if req.Video != nil {
info.Video = req.Video
}
if req.Enabled != nil {
info.Enabled = req.Enabled
}
if err := ingress.ValidateForSerialization(info); err != nil {
return err
}
updateEnableTranscoding(info)
return nil
}
func (s *IngressService) UpdateIngress(ctx context.Context, req *livekit.UpdateIngressRequest) (*livekit.IngressInfo, error) {
fields := []any{
"ingress", req.IngressId,
"name", req.Name,
}
if req.RoomName != "" {
fields = append(fields, "room", req.RoomName, "identity", req.ParticipantIdentity)
}
AppendLogFields(ctx, fields...)
err := EnsureIngressAdminPermission(ctx)
if err != nil {
return nil, twirpAuthError(err)
}
if s.psrpcClient == nil {
return nil, ErrIngressNotConnected
}
info, err := s.store.LoadIngress(ctx, req.IngressId)
if err != nil {
logger.Errorw("could not load ingress info", err)
return nil, err
}
if !info.Reusable {
logger.Infow("ingress update attempted on non reusable ingress", "ingressID", info.IngressId)
return info, ErrIngressNonReusable
}
switch info.State.Status {
case livekit.IngressState_ENDPOINT_ERROR:
info.State.Status = livekit.IngressState_ENDPOINT_INACTIVE
_, err = s.io.UpdateIngressState(ctx, &rpc.UpdateIngressStateRequest{
IngressId: req.IngressId,
State: info.State,
})
if err != nil {
logger.Warnw("could not store ingress state", err)
}
fallthrough
case livekit.IngressState_ENDPOINT_INACTIVE:
err = updateInfoUsingRequest(req, info)
if err != nil {
return nil, err
}
case livekit.IngressState_ENDPOINT_BUFFERING,
livekit.IngressState_ENDPOINT_PUBLISHING:
err := updateInfoUsingRequest(req, info)
if err != nil {
return nil, err
}
// Do not store the returned state as the ingress service will do it
if _, err = s.psrpcClient.UpdateIngress(ctx, req.IngressId, req); err != nil {
logger.Warnw("could not update active ingress", err)
}
}
err = s.store.UpdateIngress(ctx, info)
if err != nil {
logger.Errorw("could not update ingress info", err)
return nil, err
}
return info, nil
}
func (s *IngressService) ListIngress(ctx context.Context, req *livekit.ListIngressRequest) (*livekit.ListIngressResponse, error) {
AppendLogFields(ctx, "room", req.RoomName)
err := EnsureIngressAdminPermission(ctx)
if err != nil {
return nil, twirpAuthError(err)
}
if s.store == nil {
return nil, ErrIngressNotConnected
}
var infos []*livekit.IngressInfo
if req.IngressId != "" {
info, err := s.store.LoadIngress(ctx, req.IngressId)
if err != nil {
return nil, err
}
infos = []*livekit.IngressInfo{info}
} else {
infos, err = s.store.ListIngress(ctx, livekit.RoomName(req.RoomName))
if err != nil {
logger.Errorw("could not list ingress info", err)
return nil, err
}
}
return &livekit.ListIngressResponse{Items: infos}, nil
}
func (s *IngressService) DeleteIngress(ctx context.Context, req *livekit.DeleteIngressRequest) (*livekit.IngressInfo, error) {
AppendLogFields(ctx, "ingressID", req.IngressId)
if err := EnsureIngressAdminPermission(ctx); err != nil {
return nil, twirpAuthError(err)
}
if s.psrpcClient == nil {
return nil, ErrIngressNotConnected
}
info, err := s.store.LoadIngress(ctx, req.IngressId)
if err != nil {
return nil, err
}
switch info.State.Status {
case livekit.IngressState_ENDPOINT_BUFFERING,
livekit.IngressState_ENDPOINT_PUBLISHING:
if _, err = s.psrpcClient.DeleteIngress(ctx, req.IngressId, req); err != nil {
logger.Warnw("could not stop active ingress", err)
}
}
err = s.store.DeleteIngress(ctx, info)
if err != nil {
logger.Errorw("could not delete ingress info", err)
return nil, err
}
info.State.Status = livekit.IngressState_ENDPOINT_INACTIVE
s.telemetry.IngressDeleted(ctx, info)
return info, nil
}