Add TURN permission handler. (#4505)

* Add TURN permission handler.

- Turn off permissions to private/link local/multicast and internal IPs
- Add a list of CIDRs that can be used for more things to deny
  permission to.

* unused

* add config for allowing private IPs, used in testing

* add a TTL to user name and use it to auth

* allow list for restricted peer CIDRs
This commit is contained in:
Raja Subramanian
2026-05-06 23:43:11 +05:30
committed by GitHub
parent 8ffcef93b2
commit 3de6f517e5
7 changed files with 178 additions and 39 deletions
+15
View File
@@ -277,6 +277,21 @@ keys:
# # optional (set only if not using external TLS termination)
# # cert_file: /path/to/cert.pem
# # key_file: /path/to/key.pem
# # TTL of the TURN credentials in seconds - defaults to 300
# ttl_seconds: 300
# # list of restricted peer CIDRs (loopback, link-local (unicast, multicast), multicast, private, unspecified) to allow access to.
# # By default (i. e. empty list), all restricted peer CIDRs are denied access.
# # When not empty, only the specified CIDRs are allowed access.
# # Note that this check is applied to restricted peer CIDRs only.
# allow_restricted_peer_cidrs:
# - 10.0.0.0/8
# - 192.168.0.0/16
# # list of peer CIDRs to deny access to.
# # This applies to all peer CIDRs, including restricted ones.
# # Deny list takes precedence over allow list.
# deny_peer_cidrs:
# - 10.0.0.0/8
# - 192.168.0.0/16
# ingress server
# ingress:
+12
View File
@@ -227,6 +227,17 @@ type TURNConfig struct {
RelayPortRangeEnd uint16 `yaml:"relay_range_end,omitempty"`
ExternalTLS bool `yaml:"external_tls,omitempty"`
BindAddresses []string `yaml:"bind_addresses,omitempty"`
// TTL of the TURN credentials in seconds - defaults to 300
TTLSeconds int `yaml:"ttl_seconds,omitempty"`
// list of restricted peer CIDRs (loopback, link-local (unicast, multicast), multicast, private, unspecified) to allow access to.
// By default (i. e. empty list), all restricted peer CIDRs are denied access.
// When not empty, only the specified CIDRs are allowed access.
// Note that this check is applied to restricted peer CIDRs only.
AllowRestrictedPeerCIDRs []string `yaml:"allow_restricted_peer_cidrs,omitempty"`
// list of peer CIDRs to deny access to
// This applies to all peer CIDRs, including restricted ones.
// Deny list takes precedence over allow list.
DenyPeerCIDRs []string `yaml:"deny_peer_cidrs,omitempty"`
}
type NodeSelectorConfig struct {
@@ -421,6 +432,7 @@ var DefaultConfig = Config{
TURN: TURNConfig{
Enabled: false,
BindAddresses: []string{"0.0.0.0"},
TTLSeconds: 300,
},
NodeSelector: NodeSelectorConfig{
Kind: "any",
+1 -1
View File
@@ -1036,7 +1036,7 @@ func (r *RoomManager) iceServersForParticipant(apiKey string, participant types.
urls = append(urls, fmt.Sprintf("turns:%s:443?transport=tcp", r.config.TURN.Domain))
}
if len(urls) > 0 {
username := r.turnAuthHandler.CreateUsername(apiKey, participant.ID())
username := r.turnAuthHandler.CreateUsername(apiKey, participant.ID(), r.config.TURN.TTLSeconds)
password, err := r.turnAuthHandler.CreatePassword(apiKey, participant.ID())
if err != nil {
participant.GetLogger().Warnw("could not create turn password", err)
+65 -8
View File
@@ -21,6 +21,7 @@ import (
"net"
"strconv"
"strings"
"time"
"github.com/jxskiss/base62"
"github.com/pion/turn/v4"
@@ -92,6 +93,41 @@ func NewTurnServer(conf *config.Config, authHandler turn.AuthHandler, 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 _, cidr := range turnConf.AllowRestrictedPeerCIDRs {
if _, ipnet, err := net.ParseCIDR(cidr); err == nil {
if ipnet.Contains(peerIP) {
allowed = true
break
}
}
}
if !allowed {
return false
}
// if allowed, check deny list for overrides
}
for _, cidr := range turnConf.DenyPeerCIDRs {
if _, ipnet, err := net.ParseCIDR(cidr); err == nil {
if ipnet.Contains(peerIP) {
return false
}
}
}
return true
}
if turnConf.TLSPort > 0 {
var listener net.Listener
var listenerErr error
@@ -121,6 +157,7 @@ func NewTurnServer(conf *config.Config, authHandler turn.AuthHandler, standalone
listenerConfig := turn.ListenerConfig{
Listener: listener,
RelayAddressGenerator: relayAddrGen,
PermissionHandler: permissionHandler,
}
serverConfig.ListenerConfigs = append(serverConfig.ListenerConfigs, listenerConfig)
@@ -140,6 +177,7 @@ func NewTurnServer(conf *config.Config, authHandler turn.AuthHandler, standalone
packetConfig := turn.PacketConnConfig{
PacketConn: udpListener,
RelayAddressGenerator: relayAddrGen,
PermissionHandler: permissionHandler,
}
serverConfig.PacketConnConfigs = append(serverConfig.PacketConnConfigs, packetConfig)
logValues = append(logValues, "turn.portUDP", turnConf.UDPPort)
@@ -164,21 +202,30 @@ func NewTURNAuthHandler(keyProvider auth.KeyProvider) *TURNAuthHandler {
}
}
func (h *TURNAuthHandler) CreateUsername(apiKey string, pID livekit.ParticipantID) string {
return base62.EncodeToString([]byte(fmt.Sprintf("%s|%s", apiKey, pID)))
func (h *TURNAuthHandler) CreateUsername(apiKey string, pID livekit.ParticipantID, ttlSeconds int) string {
expiry := time.Now().Add(time.Duration(ttlSeconds) * time.Second).Unix()
return base62.EncodeToString(fmt.Appendf(nil, "%s|%s|%d", apiKey, pID, expiry))
}
func (h *TURNAuthHandler) ParseUsername(username string) (apiKey string, pID livekit.ParticipantID, err error) {
func (h *TURNAuthHandler) ParseUsername(username string) (apiKey string, pID livekit.ParticipantID, expiry time.Time, err error) {
decoded, err := base62.DecodeString(username)
if err != nil {
return "", "", err
return "", "", time.Time{}, err
}
parts := strings.Split(string(decoded), "|")
if len(parts) != 2 {
return "", "", errors.New("invalid username")
if len(parts) != 2 && len(parts) != 3 {
return "", "", time.Time{}, errors.New("invalid username")
}
expiry = time.Time{}
if len(parts) == 3 {
if unixTime, err := strconv.ParseInt(parts[2], 10, 64); err != nil {
return "", "", time.Time{}, err
} else {
expiry = time.Unix(unixTime, 0)
}
}
return parts[0], livekit.ParticipantID(parts[1]), nil
return parts[0], livekit.ParticipantID(parts[1]), expiry, nil
}
func (h *TURNAuthHandler) CreatePassword(apiKey string, pID livekit.ParticipantID) (string, error) {
@@ -197,9 +244,19 @@ func (h *TURNAuthHandler) HandleAuth(username, realm string, srcAddr net.Addr) (
return nil, false
}
parts := strings.Split(string(decoded), "|")
if len(parts) != 2 {
if len(parts) != 2 && len(parts) != 3 {
return nil, false
}
if len(parts) == 3 {
if unixTime, err := strconv.ParseInt(parts[2], 10, 64); err != nil {
return nil, false
} else {
expiry := time.Unix(unixTime, 0)
if time.Now().After(expiry) {
return nil, false
}
}
}
password, err := h.CreatePassword(parts[0], livekit.ParticipantID(parts[1]))
if err != nil {
logger.Warnw("could not create TURN password", err, "username", username)
+5 -2
View File
@@ -691,8 +691,11 @@ func (c *RTCClient) handleSignalResponse(res *livekit.SignalResponse) error {
return nil
}
func (c *RTCClient) WaitUntilConnected() error {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
func (c *RTCClient) WaitUntilConnected(timeout time.Duration) error {
if timeout == 0 {
timeout = 20 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
for {
select {
+21 -5
View File
@@ -132,14 +132,30 @@ func waitUntilConnected(t *testing.T, clients ...*testclient.RTCClient) {
wg := sync.WaitGroup{}
for i := range clients {
c := clients[i]
wg.Add(1)
go func() {
defer wg.Done()
err := c.WaitUntilConnected()
wg.Go(func() {
err := c.WaitUntilConnected(5 * time.Second)
if err != nil {
t.Error(err)
}
}()
})
}
wg.Wait()
if t.Failed() {
t.FailNow()
}
}
func ensureNotConnected(t *testing.T, clients ...*testclient.RTCClient) {
logger.Infow("checking if clients connect")
wg := sync.WaitGroup{}
for i := range clients {
c := clients[i]
wg.Go(func() {
err := c.WaitUntilConnected(5 * time.Second)
if err == nil {
t.Error(fmt.Errorf("expected client to not connect: %s", c.ID()))
}
})
}
wg.Wait()
if t.Failed() {
+59 -23
View File
@@ -1321,31 +1321,67 @@ func TestTurnRelay(t *testing.T) {
return
}
s := createSingleNodeServer(func(c *config.Config) {
c.TURN.Enabled = true
c.TURN.UDPPort = 3478
})
go func() {
if err := s.Start(); err != nil {
logger.Errorw("server returned error", err)
}
}()
defer s.Stop(true)
testCases := []struct {
name string
allowRestrictedPeerCIDRs []string
denyPeerCIDRs []string
expectedToConnect bool
}{
{
"allow",
[]string{"10.0.0.0/8", "192.168.0.0/16"},
nil,
true,
},
{
"not-allowed",
nil,
nil,
false,
},
{
"denied-overrides-allowed",
[]string{"10.0.0.0/8", "192.168.0.0/16"},
[]string{"10.0.0.0/8", "192.168.0.0/16"},
false,
},
}
waitForServerToStart(s)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := createSingleNodeServer(func(c *config.Config) {
c.TURN.Enabled = true
c.TURN.UDPPort = 3478
c.TURN.AllowRestrictedPeerCIDRs = tc.allowRestrictedPeerCIDRs
c.TURN.DenyPeerCIDRs = tc.denyPeerCIDRs
})
go func() {
if err := s.Start(); err != nil {
logger.Errorw("server returned error", err)
}
}()
defer s.Stop(true)
c1 := createRTCClient("relay_c1", defaultServerPort, testRTCServicePathv0, &testclient.Options{
AutoSubscribe: true,
ForceRelay: true,
})
defer c1.Stop()
waitForServerToStart(s)
waitUntilConnected(t, c1)
c1 := createRTCClient("relay_c1", defaultServerPort, testRTCServicePathv0, &testclient.Options{
AutoSubscribe: true,
ForceRelay: true,
})
defer c1.Stop()
testutils.WithTimeout(t, func() string {
if !c1.IsLocalCandidateRelaySelected() {
return "expected local candidate to be relay"
}
return ""
})
if tc.expectedToConnect {
waitUntilConnected(t, c1)
testutils.WithTimeout(t, func() string {
if !c1.IsLocalCandidateRelaySelected() {
return "expected local candidate to be relay"
}
return ""
})
} else {
ensureNotConnected(t, c1)
}
})
}
}