Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,32 @@ redis:
username: redis username
password: redis password
db: redis db
# TLS for Redis (managed Redis that rejects plaintext). Same keys as livekit-server:
# use_tls: true # deprecated alias
# tls:
# enabled: true
# insecure: false
# server_name: redis.example.com

# optional fields
health_port: if used, will open an http port for health checks
prometheus_port: port used to collect prometheus metrics. Used for autoscaling
log_level: debug, info, warn, or error (default info)
sip_port: port to listen and send SIP traffic (default 5060)
rtp_port: port to listen and send RTP traffic (default 10000-20000)
# SIP over TLS (inbound listen + outbound dial share this config).
# tls.certs are used as the server identity and, by default, as the client
# certificate when a trunk requires mTLS on outbound TLS dials (#530).
# tls:
# port: 5061
# port_listen: 5061
# certs:
# - cert_file: /path/to/fullchain.pem
# key_file: /path/to/privkey.pem
# # optional separate client identity for outbound mTLS:
# # client_certs:
# # - cert_file: /path/to/client.pem
# # key_file: /path/to/client-key.pem
```

The config file can be added to a mounted volume with its location passed in the SIP_CONFIG_FILE env var, or its body can be passed in the SIP_CONFIG_BODY env var.
Expand Down
19 changes: 11 additions & 8 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ type TLSConfig struct {
Port int `yaml:"port"` // announced SIP signaling port
ListenPort int `yaml:"port_listen"` // SIP signaling port to listen on
Certs []TLSCert `yaml:"certs"`
KeyLog string `yaml:"key_log"`
// ClientCerts are presented on outbound SIP/TLS dials when the peer
// requests a client certificate (mTLS). When empty, Certs are reused.
ClientCerts []TLSCert `yaml:"client_certs"`
KeyLog string `yaml:"key_log"`

MinVersion string `yaml:"min_version"` // min TLS version, accepts: "tls1.0", "tls1.1", "tls1.2", "tls1.3"
MaxVersion string `yaml:"max_version"` // max TLS version, accepts: "tls1.0", "tls1.1", "tls1.2", "tls1.3"
Expand Down Expand Up @@ -113,16 +116,16 @@ type Config struct {
MediaUseExternalIP bool `yaml:"media_use_external_ip"`
MediaNAT1To1IP string `yaml:"media_nat_1_to_1_ip"`

MediaTimeout time.Duration `yaml:"media_timeout"`
MediaTimeoutInitial time.Duration `yaml:"media_timeout_initial"`
SymmetricRTP bool `yaml:"symmetric_rtp"`
MediaTimeout time.Duration `yaml:"media_timeout"`
MediaTimeoutInitial time.Duration `yaml:"media_timeout_initial"`
SymmetricRTP bool `yaml:"symmetric_rtp"`
// RTPDrainingIdleTimeout / RTPDrainingDuration control how long a closed call's RTP
// port is kept bound and draining before it can be reallocated. Set to a negative
// value to disable. Zero uses the defaults.
RTPDrainingIdleTimeout time.Duration `yaml:"rtp_draining_idle_timeout"`
RTPDrainingDuration time.Duration `yaml:"rtp_draining_duration"`
IgnoreLocalAddrInSDP bool `yaml:"ignore_local_addr_in_sdp"` // enable symmetric RTP if local IP is specified in SDP
Codecs map[string]bool `yaml:"codecs"`
RTPDrainingIdleTimeout time.Duration `yaml:"rtp_draining_idle_timeout"`
RTPDrainingDuration time.Duration `yaml:"rtp_draining_duration"`
IgnoreLocalAddrInSDP bool `yaml:"ignore_local_addr_in_sdp"` // enable symmetric RTP if local IP is specified in SDP
Codecs map[string]bool `yaml:"codecs"`

// HideInboundPort controls how SIP endpoint responds to unverified inbound requests.
// Setting it to true makes SIP server silently drop INVITE requests if it gets a negative Auth or Dispatch response.
Expand Down
18 changes: 11 additions & 7 deletions pkg/sip/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,13 +244,16 @@ func (s *Service) Start() error {
if len(tconf.Certs) == 0 {
return errors.New("TLS certificate required")
}
var certs []tls.Certificate
for _, c := range tconf.Certs {
cert, err := tls.LoadX509KeyPair(c.CertFile, c.KeyFile)
certs, err := loadTLSCertificates(tconf.Certs)
if err != nil {
return err
}
clientCerts := certs
if len(tconf.ClientCerts) > 0 {
clientCerts, err = loadTLSCertificates(tconf.ClientCerts)
if err != nil {
return err
}
certs = append(certs, cert)
}
var keyLog io.Writer
if tconf.KeyLog != "" {
Expand All @@ -269,9 +272,10 @@ func (s *Service) Start() error {
}()
}
tlsConf = &tls.Config{
NextProtos: tlsALPNProtocols(tconf.ALPNProtocols),
Certificates: certs,
KeyLogWriter: keyLog,
NextProtos: tlsALPNProtocols(tconf.ALPNProtocols),
Certificates: certs,
GetClientCertificate: clientCertificateFunc(clientCerts),
KeyLogWriter: keyLog,
}

if len(tconf.CipherSuites) > 0 {
Expand Down
30 changes: 30 additions & 0 deletions pkg/sip/tls.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,40 @@ import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"

"github.com/livekit/protocol/logger"

"github.com/livekit/sip/pkg/config"
)

// loadTLSCertificates loads PEM certificate/key pairs from disk.
func loadTLSCertificates(certs []config.TLSCert) ([]tls.Certificate, error) {
out := make([]tls.Certificate, 0, len(certs))
for _, c := range certs {
cert, err := tls.LoadX509KeyPair(c.CertFile, c.KeyFile)
if err != nil {
return nil, fmt.Errorf("load TLS cert %q: %w", c.CertFile, err)
}
out = append(out, cert)
}
return out, nil
}

// clientCertificateFunc returns a GetClientCertificate hook that always
// presents the first configured certificate when the peer requests client
// auth. Go's default selection can return no certificate when the peer's
// acceptable CAs don't match our leaf, which breaks outbound SIP trunks that
// require mTLS (livekit/sip#530).
func clientCertificateFunc(certs []tls.Certificate) func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
return func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
if len(certs) == 0 {
return nil, nil
}
return &certs[0], nil
}
Comment on lines +47 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Outbound TLS connection can crash when no client identity is configured

An empty result is handed back to Go's TLS client (return nil, nil at pkg/sip/tls.go:49) when no client identity is configured, so a call placed over an encrypted trunk that asks for a client certificate can crash the service instead of connecting without one.
Impact: If this path is ever reached, the process panics during the handshake instead of gracefully continuing without a client certificate.

crypto/tls requires GetClientCertificate to return a non-nil Certificate

Go's documentation for tls.Config.GetClientCertificate states the hook must return a non-nil *Certificate; to send no certificate it must return an empty &tls.Certificate{}. Internally the client does certMsg.certificate = *cert (TLS 1.3) / certMsg.certificates = chainToSend.Certificate (TLS 1.2), which nil-dereferences on a nil return. The default implementation returns new(Certificate) for exactly this reason.

Today pkg/sip/service.go:244-254 rejects a TLS config with zero certs, so clientCerts is always non-empty in production, making the branch effectively unreachable — but the helper is exported to the package and pkg/sip/tls_test.go:13-16 asserts the nil behaviour, locking in the contract violation for any future caller.

Suggested change
return func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
if len(certs) == 0 {
return nil, nil
}
return &certs[0], nil
}
return func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
if len(certs) == 0 {
// crypto/tls requires a non-nil Certificate; an empty one means "send no cert".
return &tls.Certificate{}, nil
}
return &certs[0], nil
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

func makeTLSCipherMap(CipherSuites []*tls.CipherSuite) map[string]*tls.CipherSuite {
cipherSuitesMap := make(map[string]*tls.CipherSuite)
for _, c := range CipherSuites {
Expand Down
23 changes: 23 additions & 0 deletions pkg/sip/tls_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,29 @@ import (
"github.com/stretchr/testify/require"
)

func TestClientCertificateFunc(t *testing.T) {
t.Run("empty", func(t *testing.T) {
fn := clientCertificateFunc(nil)
cert, err := fn(&tls.CertificateRequestInfo{})
require.NoError(t, err)
require.Nil(t, cert)
})

t.Run("presents first cert even when CA filter would exclude it", func(t *testing.T) {
// Minimal placeholder certificate; GetClientCertificate must return it
// regardless of CertificateRequestInfo.AcceptableCAs.
placeholder := tls.Certificate{Certificate: [][]byte{{0x30}}}
fn := clientCertificateFunc([]tls.Certificate{placeholder})
cri := &tls.CertificateRequestInfo{
AcceptableCAs: [][]byte{[]byte("cn=unrelated-ca")},
}
cert, err := fn(cri)
require.NoError(t, err)
require.NotNil(t, cert)
require.Equal(t, placeholder.Certificate, cert.Certificate)
})
}

func TestParseCipherSuites(t *testing.T) {
log := logger.GetLogger()

Expand Down