diff --git a/README.md b/README.md index faed8dfa..8c1c0cfd 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 8192660d..0492890f 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 041433c9..5fce0013 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 a7954721..40512f8f 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 d06ec19f..2b01f70d 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()