Files
livekit/pkg/service/turn.go
T
Sapan Diwakar adf44a2eb6 turn: accept PROXY protocol on the TCP listener (#4852)
* turn: accept PROXY protocol on the TCP listener

Behind a TLS-terminating or reverse proxy that dials from its own address,
the embedded TURN server reports the proxy's address to the client as
XOR-MAPPED-ADDRESS. Firefox rejects a loopback or wildcard mapped address
and abandons the allocation, so relay-only clients never get a relay
candidate (#4851).

Add turn.proxy_protocol. When set, the TCP listener requires a PROXY
protocol v1/v2 header on every connection and uses the client address it
carries; connections without the header are rejected. The header is read
before TLS, so it works with both the built-in TLS listener and
external_tls.

* turn: only trust PROXY headers from configured proxies

A PROXY header from any peer that can reach the port would let a direct
client claim an arbitrary source address. Add
turn.proxy_protocol_trusted_cidrs, defaulting to loopback, and close
connections from any other address before reading the header.
2026-09-09 16:16:44 +08:00

362 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 (
"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/pires/go-proxyproto"
"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 {
listener, err := newTURNTCPListener(turnConf, net.JoinHostPort(addr, strconv.Itoa(turnConf.TLSPort)))
if err != nil {
return nil, err
}
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, "turn.proxyProtocol", turnConf.ProxyProtocol)
}
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)
}
// newTURNTCPListener returns the TCP listener for TURN/TLS. The PROXY protocol
// header, when enabled, is read before TLS so the client address is known to
// the TLS layer and to TURN regardless of who terminates TLS.
func newTURNTCPListener(turnConf config.TURNConfig, address string) (net.Listener, error) {
var tlsConfig *tls.Config
if !turnConf.ExternalTLS {
cert, err := tls.LoadX509KeyPair(turnConf.CertFile, turnConf.KeyFile)
if err != nil {
return nil, errors.Wrap(err, "TURN tls cert required")
}
tlsConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
Certificates: []tls.Certificate{cert},
}
}
var proxyPolicy proxyproto.ConnPolicyFunc
if turnConf.ProxyProtocol {
trusted, err := parsePeerCIDRs("turn.proxy_protocol_trusted_cidrs", turnConf.ProxyProtocolTrustedCIDRs)
if err != nil {
return nil, err
}
if len(trusted) == 0 {
return nil, errors.New("turn.proxy_protocol requires at least one entry in turn.proxy_protocol_trusted_cidrs")
}
proxyPolicy = proxyProtocolPolicy(trusted)
}
listener, err := net.Listen("tcp", address)
if err != nil {
return nil, errors.Wrap(err, "could not listen on TURN TCP port")
}
if proxyPolicy != nil {
listener = &proxyproto.Listener{Listener: listener, ConnPolicy: proxyPolicy}
}
if tlsConfig != nil {
listener = tls.NewListener(listener, tlsConfig)
}
return listener, nil
}
// proxyProtocolPolicy requires the PROXY header from trusted proxies and closes
// every other connection, so the header cannot be forged by a direct client.
func proxyProtocolPolicy(trusted []*net.IPNet) proxyproto.ConnPolicyFunc {
return func(opts proxyproto.ConnPolicyOptions) (proxyproto.Policy, error) {
tcpAddr, ok := opts.Upstream.(*net.TCPAddr)
if !ok {
return proxyproto.REJECT, fmt.Errorf("%w: unexpected address %v", proxyproto.ErrInvalidUpstream, opts.Upstream)
}
for _, ipnet := range trusted {
if ipnet.Contains(tcpAddr.IP) {
return proxyproto.REQUIRE, nil
}
}
// wrapping ErrInvalidUpstream closes this connection and keeps the listener accepting
return proxyproto.REJECT, fmt.Errorf("%w: %s is not a trusted proxy", proxyproto.ErrInvalidUpstream, tcpAddr.IP)
}
}
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
}