From 7f8fec65edd68c78213d55b24432602f40c921d5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 04:05:06 +0000 Subject: [PATCH] feat: present client certs for outbound SIP TLS mTLS (#530) Outbound SIP/TLS dials share the UserAgent tls.Config with the inbound listener, but Go's default client-certificate selection can return no cert when the peer's AcceptableCAs don't match our leaf. Set an explicit GetClientCertificate hook so configured identities are always presented when the trunk requires mTLS. Also allow optional tls.client_certs for a separate outbound identity, and document Redis TLS + SIP TLS/mTLS config keys in the README (redis.tls was already supported via livekit/protocol; see #470). Fixes livekit/sip#530 Fixes livekit/sip#470 Co-authored-by: li xuanqun <793005378@qq.com> --- README.md | 19 +++++++++++++++++++ pkg/config/config.go | 19 +++++++++++-------- pkg/sip/service.go | 18 +++++++++++------- pkg/sip/tls.go | 30 ++++++++++++++++++++++++++++++ pkg/sip/tls_test.go | 23 +++++++++++++++++++++++ 5 files changed, 94 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index faed8dfa5..8c1c0cfd7 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,12 @@ 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 @@ -62,6 +68,19 @@ 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. diff --git a/pkg/config/config.go b/pkg/config/config.go index 8192660d3..0492890f6 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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" @@ -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. diff --git a/pkg/sip/service.go b/pkg/sip/service.go index 041433c93..5fce0013f 100644 --- a/pkg/sip/service.go +++ b/pkg/sip/service.go @@ -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 != "" { @@ -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 { diff --git a/pkg/sip/tls.go b/pkg/sip/tls.go index a79547210..40512f8f5 100644 --- a/pkg/sip/tls.go +++ b/pkg/sip/tls.go @@ -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 + } +} + func makeTLSCipherMap(CipherSuites []*tls.CipherSuite) map[string]*tls.CipherSuite { cipherSuitesMap := make(map[string]*tls.CipherSuite) for _, c := range CipherSuites { diff --git a/pkg/sip/tls_test.go b/pkg/sip/tls_test.go index d06ec19ff..2b01f70d0 100644 --- a/pkg/sip/tls_test.go +++ b/pkg/sip/tls_test.go @@ -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()