Files
livekit/pkg/service/turn.go
Raja SubramanianandClaude Opus 4.8 223587e140 Add per-participant concurrent TURN allocation quota (#4744)
* Add per-participant concurrent TURN allocation quota

The embedded TURN server authenticated each Allocate request but placed
no cap on how many relay allocations a single participant credential
could hold. One participant could reuse its credential across many client
5-tuples and open one relay socket/port per request, exhausting the
shared relay-port range for everyone else.

Add a configurable per-participant limit (turn.per_user_relay_allocation_limit,
default 4) wired to Pion's QuotaHandler, keyed by the participant ID from
HandleAuth. Slots are reserved before allocation and released when the
allocation ends, under a single lock, so concurrent Allocate bursts cannot
race past the limit; reservations are keyed by source address so retransmits
are idempotent. Over-quota requests receive 486 (Allocation Quota Reached).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Reclaim unconfirmed TURN allocation reservations

Allow reserved a quota slot before Pion built the relay, but the slot was
only released on the allocation-deleted event. An Allocate that passed the
quota check and then failed to create a relay (e.g. relay-port range
exhausted) emits no event, so the reservation leaked: after enough failures
a participant could lock itself out with 486, and the tracking map grew
without bound.

Reservations now start pending and are confirmed on allocation-created; each
pending reservation carries a reclaim timer that frees the slot after a TTL,
so a failed attempt cannot hold a slot forever while concurrent-burst safety
is preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Make TURN reservation reclaim identity-aware

The reclaim timer captured only userID+key. Because Timer.Stop cannot cancel
a callback that has already fired and is waiting on the lock, a stale timer
could delete a replacement reservation created for the same userID+key after
the original was released, leaving a live allocation untracked and letting the
participant exceed its cap.

reclaimPending now captures the slot pointer and only removes the entry when
the map still holds that exact slot, so a stale timer is a no-op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-11 23:12:07 +05:30

319 lines
9.7 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 (
"crypto/sha256"
"crypto/tls"
"fmt"
"net"
"strconv"
"strings"
"time"
"github.com/jxskiss/base62"
"github.com/pion/stun/v3"
"github.com/pion/turn/v5"
"github.com/pkg/errors"
"github.com/livekit/protocol/auth"
"github.com/livekit/protocol/livekit"
"github.com/livekit/protocol/logger"
"github.com/livekit/protocol/logger/pionlogger"
"github.com/livekit/livekit-server/pkg/config"
"github.com/livekit/livekit-server/pkg/telemetry"
"github.com/livekit/livekit-server/pkg/telemetry/prometheus"
)
const (
LivekitRealm = "livekit"
allocateRetries = 50
)
var ErrExpired = errors.New("expired")
// parsePeerCIDRs compiles a list of CIDR strings, failing with a field-specific
// error on any invalid entry so a malformed peer policy is never silently ignored.
func parsePeerCIDRs(field string, cidrs []string) ([]*net.IPNet, error) {
parsed := make([]*net.IPNet, 0, len(cidrs))
for _, cidr := range cidrs {
_, ipnet, err := net.ParseCIDR(cidr)
if err != nil {
return nil, fmt.Errorf("invalid CIDR %q in %s: %w", cidr, field, err)
}
parsed = append(parsed, ipnet)
}
return parsed, nil
}
func NewTurnServer(conf *config.Config, authHandler turn.AuthHandler, standalone bool) (*turn.Server, error) {
turnConf := conf.TURN
if !turnConf.Enabled {
return nil, nil
}
if turnConf.TLSPort <= 0 && turnConf.UDPPort <= 0 {
return nil, errors.New("invalid TURN ports")
} else if turnConf.TLSPort > 0 {
if turnConf.Domain == "" {
return nil, errors.New("TURN domain required")
}
if !IsValidDomain(turnConf.Domain) {
return nil, errors.New("TURN domain is not correct")
}
}
// parse peer CIDR policies once at startup so a malformed entry fails loudly
// instead of being silently skipped on every permission decision (fail-open)
allowRestrictedPeerCIDRs, err := parsePeerCIDRs("turn.allow_restricted_peer_cidrs", turnConf.AllowRestrictedPeerCIDRs)
if err != nil {
return nil, err
}
denyPeerCIDRs, err := parsePeerCIDRs("turn.deny_peer_cidrs", turnConf.DenyPeerCIDRs)
if err != nil {
return nil, err
}
serverConfig := turn.ServerConfig{
Realm: LivekitRealm,
AuthHandler: authHandler,
LoggerFactory: pionlogger.NewLoggerFactory(logger.GetLogger()),
}
// cap concurrent relay allocations per participant so one credential cannot
// exhaust the shared relay-port range (a value <= 0 disables the quota)
if turnConf.PerUserRelayAllocationLimit > 0 {
quota := newTURNAllocationQuota(turnConf.PerUserRelayAllocationLimit)
serverConfig.QuotaHandler = quota.Allow
serverConfig.EventHandler = quota.eventHandler()
}
var logValues []any
logValues = append(logValues, "turn.relay_range_start", turnConf.RelayPortRangeStart)
logValues = append(logValues, "turn.relay_range_end", turnConf.RelayPortRangeEnd)
logValues = append(logValues, "turn.per_user_relay_allocation_limit", turnConf.PerUserRelayAllocationLimit)
for _, addr := range turnConf.BindAddresses {
var nodeIP string
if net.ParseIP(addr).To4() != nil {
nodeIP = conf.RTC.NodeIP.V4
} else {
nodeIP = conf.RTC.NodeIP.V6
}
if nodeIP == "" {
return nil, errors.New("no matching node IP for relay")
}
var relayAddrGen turn.RelayAddressGenerator = &turn.RelayAddressGeneratorPortRange{
RelayAddress: net.ParseIP(nodeIP),
Address: addr,
MinPort: turnConf.RelayPortRangeStart,
MaxPort: turnConf.RelayPortRangeEnd,
MaxRetries: allocateRetries,
}
if standalone {
relayAddrGen = telemetry.NewRelayAddressGenerator(relayAddrGen)
}
permissionHandler := func(_clientAddr net.Addr, peerIP net.IP) bool {
// restricted peer IP is denied by default, unless allowed by the allow list,
if peerIP.IsLoopback() ||
peerIP.IsLinkLocalUnicast() ||
peerIP.IsLinkLocalMulticast() ||
peerIP.IsMulticast() ||
peerIP.IsPrivate() ||
peerIP.IsUnspecified() {
allowed := false
for _, ipnet := range allowRestrictedPeerCIDRs {
if ipnet.Contains(peerIP) {
allowed = true
break
}
}
if !allowed {
return false
}
// if allowed, check deny list for overrides
}
for _, ipnet := range denyPeerCIDRs {
if ipnet.Contains(peerIP) {
return false
}
}
return true
}
if turnConf.TLSPort > 0 {
var listener net.Listener
var listenerErr error
if turnConf.ExternalTLS {
listener, listenerErr = net.Listen("tcp", net.JoinHostPort(addr, strconv.Itoa(turnConf.TLSPort)))
} else {
cert, err := tls.LoadX509KeyPair(turnConf.CertFile, turnConf.KeyFile)
if err != nil {
return nil, errors.Wrap(err, "TURN tls cert required")
}
listener, listenerErr = tls.Listen("tcp", net.JoinHostPort(addr, strconv.Itoa(turnConf.TLSPort)),
&tls.Config{
MinVersion: tls.VersionTLS12,
Certificates: []tls.Certificate{cert},
})
}
if listenerErr != nil {
return nil, errors.Wrap(listenerErr, "could not listen on TURN TCP port")
}
if standalone {
listener = telemetry.NewListener(listener)
}
listenerConfig := turn.ListenerConfig{
Listener: listener,
RelayAddressGenerator: relayAddrGen,
PermissionHandler: permissionHandler,
}
serverConfig.ListenerConfigs = append(serverConfig.ListenerConfigs, listenerConfig)
logValues = append(logValues, "turn.portTLS", turnConf.TLSPort, "turn.externalTLS", turnConf.ExternalTLS)
}
if turnConf.UDPPort > 0 {
udpListener, err := net.ListenPacket("udp", net.JoinHostPort(addr, strconv.Itoa(turnConf.UDPPort)))
if err != nil {
return nil, errors.Wrap(err, "could not listen on TURN UDP port")
}
if standalone {
udpListener = telemetry.NewPacketConn(udpListener, prometheus.Incoming)
}
packetConfig := turn.PacketConnConfig{
PacketConn: udpListener,
RelayAddressGenerator: relayAddrGen,
PermissionHandler: permissionHandler,
}
serverConfig.PacketConnConfigs = append(serverConfig.PacketConnConfigs, packetConfig)
logValues = append(logValues, "turn.portUDP", turnConf.UDPPort)
}
}
logger.Infow("Starting TURN server", logValues...)
return turn.NewServer(serverConfig)
}
func getTURNAuthHandlerFunc(handler *TURNAuthHandler) turn.AuthHandler {
return handler.HandleAuth
}
type TURNAuthHandler struct {
keyProvider auth.KeyProvider
}
func NewTURNAuthHandler(keyProvider auth.KeyProvider) *TURNAuthHandler {
return &TURNAuthHandler{
keyProvider: keyProvider,
}
}
func (h *TURNAuthHandler) CreateUsername(apiKey string, pID livekit.ParticipantID, ttlSeconds int) (string, int64) {
// clamp defensively: non-positive TTLs fall back to the default and overflowing ones are capped
ttlSeconds, _ = config.ClampTURNTTLSeconds(ttlSeconds)
expiry := time.Now().Add(time.Duration(ttlSeconds) * time.Second).Unix()
return base62.EncodeToString(fmt.Appendf(nil, "%s|%s|%d", apiKey, pID, expiry)), expiry
}
func (h *TURNAuthHandler) ParseUsername(username string) (string, livekit.ParticipantID, int64, error) {
decoded, err := base62.DecodeString(username)
if err != nil {
return "", "", 0, err
}
parts := strings.Split(string(decoded), "|")
if len(parts) != 3 {
return "", "", 0, errors.New("invalid username")
}
expiry, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil {
return "", "", 0, err
}
if expiry == 0 {
return "", "", 0, ErrExpired
}
return parts[0], livekit.ParticipantID(parts[1]), expiry, nil
}
func (h *TURNAuthHandler) CreatePassword(apiKey string, pID livekit.ParticipantID, expiry int64) (string, error) {
if expiry == 0 || time.Now().After(time.Unix(expiry, 0)) {
return "", ErrExpired
}
return h.computePassword(apiKey, pID, expiry)
}
func (h *TURNAuthHandler) computePassword(apiKey string, pID livekit.ParticipantID, expiry int64) (string, error) {
secret := h.keyProvider.GetSecret(apiKey)
if secret == "" {
return "", ErrInvalidAPIKey
}
keyInput := fmt.Sprintf("%s|%s|%d", secret, pID, expiry)
sum := sha256.Sum256([]byte(keyInput))
return base62.EncodeToString(sum[:]), nil
}
func (h *TURNAuthHandler) HandleAuth(ra *turn.RequestAttributes) (userID string, key []byte, ok bool) {
username := ra.Username
decoded, err := base62.DecodeString(username)
if err != nil {
return "", nil, false
}
parts := strings.Split(string(decoded), "|")
if len(parts) != 3 {
return "", nil, false
}
expiry, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil {
return "", nil, false
}
if expiry == 0 {
return "", nil, false
}
expiryTime := time.Unix(expiry, 0)
if time.Now().After(expiryTime) {
// TTL only applies to initial allocation. Refresh / CreatePermission /
// ChannelBind / Send / Data requests are still authenticated against the
// username/password but skip the TTL check so long-running sessions can
// keep refreshing past the credential expiry.
if ra.Method == stun.MethodAllocate {
logger.Infow("TURN credential expired", "username", decoded, "participantID", parts[1], "expiry", expiryTime, "method", ra.Method)
return "", nil, false
}
}
password, err := h.computePassword(parts[0], livekit.ParticipantID(parts[1]), expiry)
if err != nil {
logger.Warnw("could not create TURN password", err, "username", decoded)
return "", nil, false
}
return parts[1], turn.GenerateAuthKey(username, LivekitRealm, password), true
}