diff --git a/README.md b/README.md index 9f25994b..1044a7c4 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,10 @@ psrpc: # optional gzip compression of psrpc bus payloads, see the compatibility quality: gzip level 1-9. 0, the default, disables compression threshold: payload bytes below which compression is skipped (default 1024) max_decompressed_size: cap on an inbound payload after decompression, 0 for unlimited +enable_opus: offer the Opus codec for SIP media (default false, experimental) +dtls_srtp: + enabled: accept WebRTC-style DTLS-SRTP offers (default false, experimental) + handshake_timeout: maximum time for ICE/DTLS setup (default 10s) ``` 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. @@ -84,6 +88,16 @@ The config file can be added to a mounted volume with its location passed in the > The remaining `psrpc` keys (`max_attempts`, `timeout`, `backoff`, `buffer_size`) are accepted for config > parity with LiveKit server, but SIP does not read them. +#### Codecs + +PCMU, PCMA, G722, and DTMF are negotiated by default. Opus is **disabled by default** - set `enable_opus: true` to offer it. Validate interoperability with your SIP infrastructure before enabling in production. + +When enabled, Opus (`opus/48000/2`, 48 kHz mono) is preferred over G722 and G711. Peers that do not support Opus fall back to G722, then G711 transparently. + +#### DTLS-SRTP + +Set `dtls_srtp.enabled: true` to accept inbound `UDP/TLS/RTP/SAVPF` offers with DTLS fingerprints. ICE-lite offers, including those used by Meta WhatsApp Business Calling, are supported over IPv4. Existing RTP and SDES-SRTP calls continue to use their original media paths. + ### Using the SIP service #### Creating Bridge and Dispatch Rule diff --git a/go.mod b/go.mod index a4cf126c..e0df5321 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,9 @@ require ( github.com/mjibson/go-dsp v0.0.0-20180508042940-11479a337f12 github.com/ory/dockertest/v3 v3.12.0 github.com/pion/rtp v1.10.5 + github.com/pion/dtls/v3 v3.1.5 github.com/pion/sdp/v3 v3.0.19 + github.com/pion/srtp/v3 v3.0.12 github.com/pion/webrtc/v4 v4.2.18 github.com/prometheus/client_golang v1.24.1 github.com/sirupsen/logrus v1.9.4 @@ -96,7 +98,6 @@ require ( github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/runc v1.3.3 // indirect github.com/pion/datachannel v1.6.2 // indirect - github.com/pion/dtls/v3 v3.1.5 // indirect github.com/pion/ice/v4 v4.4.0 // indirect github.com/pion/interceptor v0.1.47 // indirect github.com/pion/logging v0.2.4 // indirect @@ -104,7 +105,6 @@ require ( github.com/pion/randutil v0.1.0 // indirect github.com/pion/rtcp v1.2.17 // indirect github.com/pion/sctp v1.11.1 // indirect - github.com/pion/srtp/v3 v3.0.12 // indirect github.com/pion/stun/v3 v3.1.6 // indirect github.com/pion/transport/v4 v4.0.2 // indirect github.com/pion/turn/v5 v5.0.12 // indirect diff --git a/pkg/config/config.go b/pkg/config/config.go index 50f9cdc7..53d67496 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -45,6 +45,23 @@ var ( DefaultRTPPortRange = rtcconfig.PortRange{Start: 10000, End: 20000} ) +// OpusConfig reserves encoder tuning fields for a media-sdk version that can +// expose them. Non-zero values are rejected rather than silently ignored. +type OpusConfig struct { + Bitrate int `yaml:"bitrate"` // target bitrate in bits/sec (e.g. 24000); 0 = auto + Complexity int `yaml:"complexity"` // encoder complexity 1-10; 0 = default + FEC bool `yaml:"fec"` // enable in-band Forward Error Correction + PacketLossPercent int `yaml:"packet_loss_percent"` // expected packet loss 0-100, tunes FEC +} + +// DTLSSRTPConfig enables WebRTC-style DTLS-SRTP media for SIP peers such as +// Meta Business Calling. It is deliberately independent of SIP signaling TLS +// and of the legacy SDES media-encryption setting. +type DTLSSRTPConfig struct { + Enabled bool `yaml:"enabled"` + HandshakeTimeout time.Duration `yaml:"handshake_timeout"` +} + const ( // After a call closes we keep its RTP port bound and draining so a freshly // allocated call can't inherit a port a peer is still sending stale media to. @@ -126,6 +143,9 @@ type Config struct { 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"` + EnableOpus bool `yaml:"enable_opus"` + Opus OpusConfig `yaml:"opus"` + DTLSSRTP DTLSSRTPConfig `yaml:"dtls_srtp"` // 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. @@ -212,6 +232,12 @@ func (c *Config) Init() error { if c.MaxCpuUtilization <= 0 || c.MaxCpuUtilization > 1 { c.MaxCpuUtilization = 0.9 } + if c.DTLSSRTP.HandshakeTimeout <= 0 { + c.DTLSSRTP.HandshakeTimeout = 10 * time.Second + } + if c.Opus.Bitrate != 0 || c.Opus.Complexity != 0 || c.Opus.FEC || c.Opus.PacketLossPercent != 0 { + return fmt.Errorf("opus bitrate, complexity, fec, and packet_loss_percent are not supported by the current media-sdk") + } if err := c.InitLogger(); err != nil { return err diff --git a/pkg/config/config_opus_test.go b/pkg/config/config_opus_test.go new file mode 100644 index 00000000..553a34fc --- /dev/null +++ b/pkg/config/config_opus_test.go @@ -0,0 +1,37 @@ +// Copyright 2026 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 config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUnsupportedOpusSettingsAreRejected(t *testing.T) { + tests := map[string]OpusConfig{ + "bitrate": {Bitrate: 24000}, + "complexity": {Complexity: 5}, + "fec": {FEC: true}, + "packet loss": {PacketLossPercent: 10}, + } + for name, opus := range tests { + t.Run(name, func(t *testing.T) { + conf := Config{Opus: opus} + err := conf.Init() + require.ErrorContains(t, err, "not supported by the current media-sdk") + }) + } +} diff --git a/pkg/sip/dtls_sdp.go b/pkg/sip/dtls_sdp.go new file mode 100644 index 00000000..4c5b6ec4 --- /dev/null +++ b/pkg/sip/dtls_sdp.go @@ -0,0 +1,293 @@ +// Copyright 2026 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 sip + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "errors" + "fmt" + "math/big" + "net/netip" + "strings" + "time" + + pice "github.com/pion/ice/v4" + psdp "github.com/pion/sdp/v3" +) + +var errDTLSSDP = errors.New("invalid DTLS-SRTP SDP") + +type dtlsCertificate struct { + certificate tls.Certificate + fingerprint string +} + +func newDTLSCertificate() (*dtlsCertificate, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, err + } + tmpl := &x509.Certificate{SerialNumber: serial, Subject: pkix.Name{CommonName: "livekit-sip-dtls"}, NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(24 * time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}} + raw, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + return nil, err + } + digest := sha256.Sum256(raw) + parts := make([]string, len(digest)) + for i, b := range digest { + parts[i] = fmt.Sprintf("%02X", b) + } + return &dtlsCertificate{certificate: tls.Certificate{Certificate: [][]byte{raw}, PrivateKey: key}, fingerprint: strings.Join(parts, ":")}, nil +} + +type dtlsMediaConfig struct { + remoteFingerprint string + remoteSetup string + localSetup string + isClient bool + certificate *dtlsCertificate + ice *dtlsICEConfig +} + +// dtlsICEConfig is deliberately transport metadata. Meta advertises ICE-lite, +// therefore LiveKit must act as the controlling ICE agent before DTLS starts. +type dtlsICEConfig struct { + remoteUfrag, remotePwd string + remoteCandidates []dtlsICECandidate + localUfrag, localPwd string + local netip.AddrPort +} + +// dtlsICECandidate keeps the complete candidate line used to construct the +// Pion candidate, plus the fields that determine transport identity. Keeping +// the original metadata is important for priorities and non-host candidates. +type dtlsICECandidate struct { + foundation string + component uint16 + priority uint32 + address netip.AddrPort + typ pice.CandidateType + related *pice.CandidateRelatedAddress + extensions []pice.CandidateExtension + raw string +} + +func mediaAttribute(m *psdp.MediaDescription, key string) (string, bool) { + for _, a := range m.Attributes { + if a.Key == key { + return a.Value, true + } + } + return "", false +} + +func sessionAttribute(s *psdp.SessionDescription, key string) (string, bool) { + for _, a := range s.Attributes { + if a.Key == key { + return a.Value, true + } + } + return "", false +} + +func parseDTLSOffer(raw []byte, cert *dtlsCertificate) (*dtlsMediaConfig, error) { + var s psdp.SessionDescription + if err := s.Unmarshal(raw); err != nil { + return nil, fmt.Errorf("%w: %v", errDTLSSDP, err) + } + for _, m := range s.MediaDescriptions { + if m.MediaName.Media != "audio" || !strings.EqualFold(strings.Join(m.MediaName.Protos, "/"), "UDP/TLS/RTP/SAVPF") { + continue + } + if cert == nil { + return nil, fmt.Errorf("%w: DTLS-SRTP is not configured", errDTLSSDP) + } + fp, ok := mediaAttribute(m, "fingerprint") + if !ok { + fp, ok = sessionAttribute(&s, "fingerprint") + } + if !ok { + return nil, fmt.Errorf("%w: fingerprint missing", errDTLSSDP) + } + fields := strings.Fields(fp) + if len(fields) != 2 || !strings.EqualFold(fields[0], "sha-256") { + return nil, fmt.Errorf("%w: SHA-256 fingerprint required", errDTLSSDP) + } + v := strings.ReplaceAll(fields[1], ":", "") + decoded, err := hex.DecodeString(v) + if err != nil || len(decoded) != sha256.Size { + return nil, fmt.Errorf("%w: malformed fingerprint", errDTLSSDP) + } + setup, ok := mediaAttribute(m, "setup") + if !ok { + setup, ok = sessionAttribute(&s, "setup") + } + if !ok { + return nil, fmt.Errorf("%w: setup missing", errDTLSSDP) + } + if _, ok = mediaAttribute(m, "rtcp-mux"); !ok { + return nil, fmt.Errorf("%w: rtcp-mux required", errDTLSSDP) + } + out := &dtlsMediaConfig{remoteFingerprint: strings.ToUpper(fields[1]), remoteSetup: strings.ToLower(setup), certificate: cert} + if ufrag, hasUfrag := mediaAttribute(m, "ice-ufrag"); hasUfrag { + pwd, hasPwd := mediaAttribute(m, "ice-pwd") + if !hasPwd || ufrag == "" || pwd == "" { + return nil, fmt.Errorf("%w: incomplete ICE credentials", errDTLSSDP) + } + remoteCandidates, err := iceRemoteCandidates(m) + if err != nil { + return nil, fmt.Errorf("%w: %v", errDTLSSDP, err) + } + lu, err := iceCredential(8) + if err != nil { + return nil, err + } + lp, err := iceCredential(24) + if err != nil { + return nil, err + } + out.ice = &dtlsICEConfig{remoteUfrag: ufrag, remotePwd: pwd, remoteCandidates: remoteCandidates, localUfrag: lu, localPwd: lp} + } + switch strings.ToLower(setup) { + case "actpass", "active": + out.localSetup, out.isClient = "passive", false + case "passive": + out.localSetup, out.isClient = "active", true + default: + return nil, fmt.Errorf("%w: unsupported setup role %q", errDTLSSDP, setup) + } + return out, nil + } + return nil, nil +} + +func iceCredential(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func iceRemoteCandidates(m *psdp.MediaDescription) ([]dtlsICECandidate, error) { + var out []dtlsICECandidate + seen := make(map[string]struct{}) + for _, a := range m.Attributes { + if a.Key != "candidate" { + continue + } + candidate, err := pice.UnmarshalCandidate(a.Value) + if err != nil || candidate.Component() != pice.ComponentRTP || candidate.NetworkType() != pice.NetworkTypeUDP4 { + continue + } + ip, err := netip.ParseAddr(candidate.Address()) + if err != nil || !ip.Is4() || candidate.Port() <= 0 || candidate.Port() > 65535 { + continue + } + raw := candidate.Marshal() + if _, ok := seen[raw]; ok { + continue + } + seen[raw] = struct{}{} + out = append(out, dtlsICECandidate{ + foundation: candidate.Foundation(), + component: candidate.Component(), + priority: candidate.Priority(), + address: netip.AddrPortFrom(ip, uint16(candidate.Port())), + typ: candidate.Type(), + related: candidate.RelatedAddress(), + extensions: candidate.Extensions(), + raw: raw, + }) + } + if len(out) == 0 { + return nil, errors.New("ICE candidate missing") + } + return out, nil +} + +func sameDTLSRemoteTransport(a, b *dtlsMediaConfig) bool { + if a == nil || b == nil { + return a == b + } + if a.remoteFingerprint != b.remoteFingerprint || a.remoteSetup != b.remoteSetup || a.localSetup != b.localSetup || a.isClient != b.isClient { + return false + } + if a.ice == nil || b.ice == nil { + return a.ice == b.ice + } + if a.ice.remoteUfrag != b.ice.remoteUfrag || a.ice.remotePwd != b.ice.remotePwd || len(a.ice.remoteCandidates) != len(b.ice.remoteCandidates) { + return false + } + for i := range a.ice.remoteCandidates { + if a.ice.remoteCandidates[i].raw != b.ice.remoteCandidates[i].raw { + return false + } + } + return true +} + +func reuseDTLSLocalTransport(dst, active *dtlsMediaConfig) { + if dst == nil || dst.ice == nil || active == nil || active.ice == nil { + return + } + dst.ice.localUfrag = active.ice.localUfrag + dst.ice.localPwd = active.ice.localPwd + dst.ice.local = active.ice.local +} + +func addDTLSAnswer(answer *psdp.SessionDescription, d *dtlsMediaConfig) error { + for _, m := range answer.MediaDescriptions { + if m.MediaName.Media != "audio" { + continue + } + m.MediaName.Protos = []string{"UDP", "TLS", "RTP", "SAVPF"} + attrs := m.Attributes[:0] + for _, a := range m.Attributes { + if a.Key != "crypto" && a.Key != "fingerprint" && a.Key != "setup" && a.Key != "rtcp-mux" { + attrs = append(attrs, a) + } + } + m.Attributes = append(attrs, psdp.Attribute{Key: "fingerprint", Value: "sha-256 " + d.certificate.fingerprint}, psdp.Attribute{Key: "setup", Value: d.localSetup}, psdp.Attribute{Key: "rtcp-mux"}) + if d.ice != nil { + if answer.ConnectionInformation == nil || answer.ConnectionInformation.Address == nil { + return fmt.Errorf("%w: answer has no connection address", errDTLSSDP) + } + ip, err := netip.ParseAddr(answer.ConnectionInformation.Address.Address) + if err != nil { + return fmt.Errorf("%w: invalid answer address", errDTLSSDP) + } + d.ice.local = netip.AddrPortFrom(ip, uint16(m.MediaName.Port.Value)) + m.Attributes = append(m.Attributes, + psdp.Attribute{Key: "ice-ufrag", Value: d.ice.localUfrag}, + psdp.Attribute{Key: "ice-pwd", Value: d.ice.localPwd}, + psdp.Attribute{Key: "candidate", Value: fmt.Sprintf("1 1 udp 2130706431 %s %d typ host", d.ice.local.Addr(), d.ice.local.Port())}, + ) + } + return nil + } + return fmt.Errorf("%w: answer has no audio media", errDTLSSDP) +} diff --git a/pkg/sip/dtls_sdp_test.go b/pkg/sip/dtls_sdp_test.go new file mode 100644 index 00000000..be5317d9 --- /dev/null +++ b/pkg/sip/dtls_sdp_test.go @@ -0,0 +1,172 @@ +// Copyright 2026 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 sip + +import ( + "strings" + "testing" + + pice "github.com/pion/ice/v4" + psdp "github.com/pion/sdp/v3" + "github.com/stretchr/testify/require" +) + +const metaLikeOffer = "v=0\r\n" + + "o=- 1 1 IN IP4 198.51.100.10\r\n" + + "s=-\r\nt=0 0\r\n" + + "a=fingerprint:sha-256 AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA\r\n" + + "m=audio 40000 UDP/TLS/RTP/SAVPF 111\r\n" + + "c=IN IP4 198.51.100.10\r\n" + + "a=setup:actpass\r\na=rtcp-mux\r\na=rtpmap:111 opus/48000/2\r\n" + +func TestParseMetaDTLSOffer(t *testing.T) { + cert, err := newDTLSCertificate() + require.NoError(t, err) + c, err := parseDTLSOffer([]byte(metaLikeOffer), cert) + require.NoError(t, err) + require.NotNil(t, c) + require.False(t, c.isClient) + require.Equal(t, "passive", c.localSetup) +} + +func TestParseMetaDTLSOfferRequiresMuxAndFingerprint(t *testing.T) { + cert, err := newDTLSCertificate() + require.NoError(t, err) + _, err = parseDTLSOffer([]byte(strings.Replace(metaLikeOffer, "a=rtcp-mux\r\n", "", 1)), cert) + require.ErrorIs(t, err, errDTLSSDP) + _, err = parseDTLSOffer([]byte(strings.Replace(metaLikeOffer, "a=fingerprint:sha-256 AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA\r\n", "", 1)), cert) + require.ErrorIs(t, err, errDTLSSDP) +} + +func TestDTLSAnswerUsesSAVPF(t *testing.T) { + cert, err := newDTLSCertificate() + require.NoError(t, err) + conf, err := parseDTLSOffer([]byte(metaLikeOffer), cert) + require.NoError(t, err) + s := &psdp.SessionDescription{MediaDescriptions: []*psdp.MediaDescription{{MediaName: psdp.MediaName{Media: "audio", Port: psdp.RangedPort{Value: 10000}, Protos: []string{"RTP", "AVP"}, Formats: []string{"111"}}, Attributes: []psdp.Attribute{{Key: "rtpmap", Value: "111 opus/48000/2"}}}}} + require.NoError(t, addDTLSAnswer(s, conf)) + raw, err := s.Marshal() + require.NoError(t, err) + text := string(raw) + require.Contains(t, text, "UDP/TLS/RTP/SAVPF") + require.Contains(t, text, "a=setup:passive") + require.Contains(t, text, "a=rtcp-mux") + require.Contains(t, text, "a=fingerprint:sha-256 "+cert.fingerprint) +} + +func TestParseMetaICEOfferAndAnswer(t *testing.T) { + cert, err := newDTLSCertificate() + require.NoError(t, err) + offer := strings.Replace( + metaLikeOffer, + "a=setup:actpass\r\n", + "a=ice-lite\r\n"+ + "a=candidate:2 1 udp 2122262783 2001:db8::1 3480 typ host\r\n"+ + "a=candidate:1 1 udp 2122260223 198.51.100.10 3480 typ host\r\n"+ + "a=ice-ufrag:remote-user\r\n"+ + "a=ice-pwd:remote-password-value\r\n"+ + "a=setup:actpass\r\n", + 1, + ) + conf, err := parseDTLSOffer([]byte(offer), cert) + require.NoError(t, err) + require.NotNil(t, conf.ice) + require.Equal(t, "remote-user", conf.ice.remoteUfrag) + require.Equal(t, "remote-password-value", conf.ice.remotePwd) + require.Len(t, conf.ice.remoteCandidates, 1) + require.Equal(t, "198.51.100.10:3480", conf.ice.remoteCandidates[0].address.String()) + require.NotEmpty(t, conf.ice.localUfrag) + require.NotEmpty(t, conf.ice.localPwd) + + var answer psdp.SessionDescription + require.NoError(t, answer.Unmarshal([]byte( + "v=0\r\n"+ + "o=- 1 1 IN IP4 203.0.113.20\r\n"+ + "s=-\r\n"+ + "c=IN IP4 203.0.113.20\r\n"+ + "t=0 0\r\n"+ + "m=audio 12000 RTP/AVP 111\r\n"+ + "a=rtpmap:111 opus/48000/2\r\n", + ))) + require.NoError(t, addDTLSAnswer(&answer, conf)) + raw, err := answer.Marshal() + require.NoError(t, err) + text := string(raw) + require.Contains(t, text, "a=ice-ufrag:"+conf.ice.localUfrag) + require.Contains(t, text, "a=ice-pwd:"+conf.ice.localPwd) + require.Contains(t, text, "a=candidate:1 1 udp 2130706431 203.0.113.20 12000 typ host") +} + +func TestParseAllSupportedICECandidates(t *testing.T) { + cert, err := newDTLSCertificate() + require.NoError(t, err) + offer := strings.Replace(metaLikeOffer, "a=setup:actpass\r\n", + "a=candidate:dead 1 udp 2130706431 192.0.2.1 9 typ host generation 0\r\n"+ + "a=candidate:live 1 udp 1694498815 198.51.100.10 3480 typ srflx raddr 10.0.0.1 rport 5000 generation 0\r\n"+ + "a=candidate:rtcp 2 udp 2130706430 198.51.100.10 3481 typ host\r\n"+ + "a=candidate:v6 1 udp 2130706431 2001:db8::1 3480 typ host\r\n"+ + "a=ice-ufrag:remote-user\r\na=ice-pwd:remote-password-value\r\na=setup:actpass\r\n", 1) + + conf, err := parseDTLSOffer([]byte(offer), cert) + require.NoError(t, err) + require.Len(t, conf.ice.remoteCandidates, 2) + require.Equal(t, "dead", conf.ice.remoteCandidates[0].foundation) + require.Equal(t, uint32(2130706431), conf.ice.remoteCandidates[0].priority) + require.Equal(t, pice.CandidateTypeServerReflexive, conf.ice.remoteCandidates[1].typ) + require.Equal(t, "10.0.0.1", conf.ice.remoteCandidates[1].related.Address) + require.Equal(t, 5000, conf.ice.remoteCandidates[1].related.Port) + require.Contains(t, conf.ice.remoteCandidates[1].raw, "generation 0") +} + +func TestParseMetaICEOfferRequiresIPv4Candidate(t *testing.T) { + cert, err := newDTLSCertificate() + require.NoError(t, err) + offer := strings.Replace( + metaLikeOffer, + "a=setup:actpass\r\n", + "a=candidate:2 1 udp 2122262783 2001:db8::1 3480 typ host\r\n"+ + "a=ice-ufrag:remote-user\r\n"+ + "a=ice-pwd:remote-password-value\r\n"+ + "a=setup:actpass\r\n", + 1, + ) + _, err = parseDTLSOffer([]byte(offer), cert) + require.ErrorIs(t, err, errDTLSSDP) +} + +func TestDTLSRemoteTransportIdentity(t *testing.T) { + cert, err := newDTLSCertificate() + require.NoError(t, err) + baseOffer := strings.Replace(metaLikeOffer, "a=setup:actpass\r\n", + "a=candidate:one 1 udp 2130706431 198.51.100.10 3480 typ host\r\n"+ + "a=ice-ufrag:remote-user\r\na=ice-pwd:remote-password-value\r\na=setup:actpass\r\n", 1) + base, err := parseDTLSOffer([]byte(baseOffer), cert) + require.NoError(t, err) + + tests := map[string]string{ + "fingerprint": strings.Replace(baseOffer, "AA:AA:AA", "BB:AA:AA", 1), + "setup role": strings.Replace(baseOffer, "a=setup:actpass", "a=setup:passive", 1), + "ice ufrag": strings.Replace(baseOffer, "remote-user", "changed-user", 1), + "ice pwd": strings.Replace(baseOffer, "remote-password-value", "changed-password-value", 1), + "candidate": strings.Replace(baseOffer, "198.51.100.10 3480", "198.51.100.11 3481", 1), + } + for name, offer := range tests { + t.Run(name, func(t *testing.T) { + changed, err := parseDTLSOffer([]byte(offer), cert) + require.NoError(t, err) + require.False(t, sameDTLSRemoteTransport(base, changed)) + }) + } +} diff --git a/pkg/sip/dtls_srtp.go b/pkg/sip/dtls_srtp.go new file mode 100644 index 00000000..ee08bc63 --- /dev/null +++ b/pkg/sip/dtls_srtp.go @@ -0,0 +1,484 @@ +// Copyright 2026 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 sip + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "io" + "net" + "strings" + "sync" + "time" + + "github.com/livekit/media-sdk/rtp" + "github.com/livekit/protocol/logger" + pdtls "github.com/pion/dtls/v3" + pice "github.com/pion/ice/v4" + prtp "github.com/pion/rtp" + psrtp "github.com/pion/srtp/v3" +) + +// dtlsSrtpSession is deliberately a media transport: callers see the same +// RTP Session interface used by clear RTP and SDES-SRTP. +type dtlsSrtpSession struct { + log logger.Logger + conf *dtlsMediaConfig + raw *udpConn + mux *dtlsMux + ready chan struct{} + mu sync.RWMutex + err error + srtp *psrtp.SessionSRTP + srtcp *psrtp.SessionSRTCP + dtls *pdtls.Conn + iceAgent *pice.Agent + iceMux pice.UDPMux + remote net.Addr + ctx context.Context + cancel context.CancelFunc + closed bool + closeOnce sync.Once +} + +func newDTLSSRTPSession(log logger.Logger, conn *udpConn, conf *dtlsMediaConfig, timeout time.Duration, remote net.Addr) *dtlsSrtpSession { + ctx, cancel := context.WithCancel(context.Background()) + s := &dtlsSrtpSession{log: log, conf: conf, raw: conn, ready: make(chan struct{}), remote: remote, ctx: ctx, cancel: cancel} + if conf.ice == nil { + s.mux = newDTLSMux(conn) + } + go s.start(timeout) + return s +} + +func (s *dtlsSrtpSession) start(timeout time.Duration) { + defer close(s.ready) + ctx, cancel := context.WithTimeout(s.ctx, timeout) + defer cancel() + if err := ctx.Err(); err != nil { + s.mu.Lock() + s.err = err + s.mu.Unlock() + return + } + verify := func(raw [][]byte, _ [][]*x509.Certificate) error { + if len(raw) == 0 { + return errors.New("DTLS peer sent no certificate") + } + d := sha256.Sum256(raw[0]) + got := make([]string, len(d)) + for i, b := range d { + got[i] = fmt.Sprintf("%02X", b) + } + if !strings.EqualFold(strings.Join(got, ":"), s.conf.remoteFingerprint) { + return errors.New("DTLS peer fingerprint mismatch") + } + return nil + } + cfg := &pdtls.Config{Certificates: []tls.Certificate{s.conf.certificate.certificate}, InsecureSkipVerify: true, VerifyPeerCertificate: verify, ClientAuth: pdtls.RequireAnyClientCert, SRTPProtectionProfiles: []pdtls.SRTPProtectionProfile{pdtls.SRTP_AEAD_AES_128_GCM, pdtls.SRTP_AES128_CM_HMAC_SHA1_80}} + if s.conf.ice != nil { + iceConn, err := s.connectICE(ctx, s.conf.ice) + if err != nil { + s.mu.Lock() + s.err = err + s.mu.Unlock() + s.log.Warnw("ICE connectivity failed", err) + return + } + mux := newDTLSMux(iceConn) + s.mu.Lock() + closed := s.closed + if !closed { + s.mux = mux + } + s.mu.Unlock() + if closed { + _ = mux.Close() + return + } + } + var c *pdtls.Conn + var srtpSession *psrtp.SessionSRTP + var srtcpSession *psrtp.SessionSRTCP + var err error + if s.conf.isClient { + c, err = pdtls.Client(s.mux.dtls, s.remote, cfg) + } else { + c, err = pdtls.Server(s.mux.dtls, s.remote, cfg) + } + if err == nil { + err = c.HandshakeContext(ctx) + } + if err == nil { + profile, ok := c.SelectedSRTPProtectionProfile() + if !ok { + err = errors.New("DTLS did not negotiate an SRTP profile") + } + var sp psrtp.ProtectionProfile + if err == nil { + switch profile { + case pdtls.SRTP_AEAD_AES_128_GCM: + sp = psrtp.ProtectionProfileAeadAes128Gcm + case pdtls.SRTP_AES128_CM_HMAC_SHA1_80: + sp = psrtp.ProtectionProfileAes128CmHmacSha1_80 + default: + err = fmt.Errorf("unsupported DTLS-SRTP profile %v", profile) + } + } + if err == nil { + state, ok := c.ConnectionState() + if !ok { + err = errors.New("DTLS connection state unavailable") + } + if err == nil { + scfg := &psrtp.Config{Profile: sp} + err = scfg.ExtractSessionKeysFromDTLS(&state, s.conf.isClient) + if err == nil { + srtpSession, err = psrtp.NewSessionSRTP(s.mux.srtp, scfg) + } + if err == nil { + srtcpSession, err = psrtp.NewSessionSRTCP(s.mux.srtcp, scfg) + } + } + } + } + s.mu.Lock() + closed := s.closed + if !closed { + s.dtls, s.srtp, s.srtcp, s.err = c, srtpSession, srtcpSession, err + } + s.mu.Unlock() + if closed { + if srtpSession != nil { + _ = srtpSession.Close() + } + if srtcpSession != nil { + _ = srtcpSession.Close() + } + if c != nil { + _ = c.Close() + } + return + } + if err != nil { + s.log.Warnw("DTLS-SRTP handshake failed", err) + } else { + s.log.Infow("DTLS-SRTP handshake complete", "role", s.conf.localSetup) + } +} + +// connectICE runs the controlling side of ICE against Meta's ICE-lite offer. +// The returned Conn carries only selected-pair application data; STUN remains +// inside Pion, so the DTLS/RTP mux sees the same transport boundary as before. +func (s *dtlsSrtpSession) connectICE(ctx context.Context, c *dtlsICEConfig) (net.Conn, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + pc := icePacketConn{udpConn: s.raw} + mux := pice.NewUDPMuxDefault(pice.UDPMuxParams{UDPConn: pc}) + closeICE := func(agent *pice.Agent) { + if agent != nil { + _ = agent.Close() + } + _ = mux.Close() + } + agent, err := pice.NewAgent(&pice.AgentConfig{ + LocalUfrag: c.localUfrag, LocalPwd: c.localPwd, + NetworkTypes: []pice.NetworkType{pice.NetworkTypeUDP4}, + CandidateTypes: []pice.CandidateType{pice.CandidateTypeHost}, + NAT1To1IPs: []string{c.local.Addr().String()}, + NAT1To1IPCandidateType: pice.CandidateTypeHost, + UDPMux: mux, + }) + if err != nil { + closeICE(nil) + return nil, err + } + s.mu.Lock() + closed := s.closed + if !closed { + s.iceAgent = agent + s.iceMux = mux + } + s.mu.Unlock() + if closed { + closeICE(agent) + return nil, context.Canceled + } + done := make(chan struct{}) + agent.OnCandidate(func(candidate pice.Candidate) { + if candidate == nil { + close(done) + } + }) + if err = agent.GatherCandidates(); err != nil { + closeICE(agent) + return nil, err + } + select { + case <-done: + case <-ctx.Done(): + closeICE(agent) + return nil, ctx.Err() + } + if err = addRemoteICECandidates(ctx, agent, c.remoteCandidates); err != nil { + closeICE(agent) + return nil, err + } + return agent.Dial(ctx, c.remoteUfrag, c.remotePwd) +} + +func addRemoteICECandidates(ctx context.Context, agent *pice.Agent, candidates []dtlsICECandidate) error { + for _, remoteConfig := range candidates { + remote, candidateErr := pice.UnmarshalCandidate(remoteConfig.raw) + if candidateErr != nil { + return candidateErr + } + if err := agent.AddRemoteCandidate(remote); err != nil { + return err + } + } + // AddRemoteCandidate schedules its update on the agent loop. Do not start + // checks until every advertised candidate is visible to that loop. + for { + remote, err := agent.GetRemoteCandidates() + if err != nil { + return err + } + if len(remote) >= len(candidates) { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Millisecond): + } + } +} + +// icePacketConn lets Pion interrupt reads without taking ownership of the +// media port's underlying socket. A transport rebuild reuses that socket. +type icePacketConn struct{ *udpConn } + +func (c icePacketConn) ReadFrom(b []byte) (int, net.Addr, error) { + n, a, err := c.ReadFromUDPAddrPort(b) + return n, net.UDPAddrFromAddrPort(a), err +} +func (c icePacketConn) WriteTo(b []byte, addr net.Addr) (int, error) { + a, ok := addr.(*net.UDPAddr) + if !ok { + return 0, errors.New("ICE destination is not UDP") + } + return c.WriteToUDPAddrPort(b, a.AddrPort()) +} + +func (s *dtlsSrtpSession) wait() (*psrtp.SessionSRTP, error) { + select { + case <-s.ready: + case <-s.ctx.Done(): + return nil, s.ctx.Err() + } + s.mu.RLock() + defer s.mu.RUnlock() + if s.err != nil { + return nil, s.err + } + if s.srtp == nil { + return nil, io.EOF + } + return s.srtp, nil +} +func (s *dtlsSrtpSession) OpenWriteStream() (rtp.WriteStream, error) { + return dtlsWriteStream{s: s}, nil +} +func (s *dtlsSrtpSession) AcceptStream() (rtp.ReadStream, uint32, error) { + x, err := s.wait() + if err != nil { + return nil, 0, err + } + r, ssrc, err := x.AcceptStream() + if err != nil { + return nil, 0, err + } + return dtlsReadStream{r}, ssrc, nil +} +func (s *dtlsSrtpSession) Close() error { + s.closeOnce.Do(func() { + // Cancel startup first so ICE gathering/checks, DTLS handshaking and + // callers waiting for readiness all become interruptible immediately. + s.cancel() + s.mu.Lock() + s.closed = true + s.mu.Unlock() + + s.closeTransports() + <-s.ready + // Catch any resource whose creation was already in flight when the + // first snapshot was taken. start has exited before this second pass. + s.closeTransports() + }) + return nil +} + +func (s *dtlsSrtpSession) closeTransports() { + // Never hold the session mutex while closing Pion transports. Their Close + // methods may wait for readers that need the same session state to finish. + s.mu.RLock() + agent, iceMux, mux := s.iceAgent, s.iceMux, s.mux + srtpSession, srtcpSession, dtlsConn := s.srtp, s.srtcp, s.dtls + s.mu.RUnlock() + + if agent != nil { + _ = agent.Close() + } + if iceMux != nil { + _ = iceMux.Close() + } + if mux != nil { + _ = mux.Close() + } + if srtpSession != nil { + _ = srtpSession.Close() + } + if srtcpSession != nil { + _ = srtcpSession.Close() + } + if dtlsConn != nil { + _ = dtlsConn.Close() + } +} + +type dtlsWriteStream struct{ s *dtlsSrtpSession } + +func (w dtlsWriteStream) String() string { return "DTLS-SRTPWriteStream" } +func (w dtlsWriteStream) WriteRTP(h *prtp.Header, payload []byte) (int, error) { + x, err := w.s.wait() + if err != nil { + return 0, err + } + out, err := x.OpenWriteStream() + if err != nil { + return 0, err + } + return out.WriteRTP(h, payload) +} + +type dtlsReadStream struct{ r *psrtp.ReadStreamSRTP } + +func (r dtlsReadStream) ReadRTP(h *prtp.Header, payload []byte) (int, error) { + n, err := r.r.Read(payload) + if err != nil { + return 0, err + } + var p prtp.Packet + if err = p.Unmarshal(payload[:n]); err != nil { + return 0, err + } + *h = p.Header + return copy(payload, p.Payload), nil +} + +type muxPacket struct { + b []byte + addr net.Addr +} +type dtlsEndpoint struct { + parent *dtlsMux + packets chan muxPacket + closed chan struct{} + once sync.Once +} +type dtlsMux struct { + conn net.Conn + dtls, srtp, srtcp *dtlsEndpoint + closed chan struct{} + once sync.Once +} + +func newDTLSEndpoint(m *dtlsMux) *dtlsEndpoint { + return &dtlsEndpoint{parent: m, packets: make(chan muxPacket, 128), closed: make(chan struct{})} +} +func newDTLSMux(c net.Conn) *dtlsMux { + m := &dtlsMux{conn: c, closed: make(chan struct{})} + m.dtls = newDTLSEndpoint(m) + m.srtp = newDTLSEndpoint(m) + m.srtcp = newDTLSEndpoint(m) + go m.readLoop() + return m +} +func (m *dtlsMux) readLoop() { + b := make([]byte, 2048) + for { + n, err := m.conn.Read(b) + if err != nil { + m.Close() + return + } + if n == 0 { + continue + } + p := muxPacket{b: append([]byte(nil), b[:n]...), addr: m.conn.RemoteAddr()} + var e *dtlsEndpoint + if p.b[0] >= 20 && p.b[0] <= 63 { + e = m.dtls + } else if p.b[0] >= 128 && p.b[0] <= 191 { + if len(p.b) > 1 && p.b[1] >= 192 && p.b[1] <= 223 { + e = m.srtcp + } else { + e = m.srtp + } + } else { + continue + } + select { + case e.packets <- p: + case <-e.closed: + case <-m.closed: + return + } + } +} +func (m *dtlsMux) Close() error { + m.once.Do(func() { close(m.closed); _ = m.dtls.Close(); _ = m.srtp.Close(); _ = m.srtcp.Close() }) + return nil +} +func (e *dtlsEndpoint) ReadFrom(b []byte) (int, net.Addr, error) { + select { + case <-e.closed: + return 0, nil, io.EOF + case p := <-e.packets: + return copy(b, p.b), p.addr, nil + } +} +func (e *dtlsEndpoint) WriteTo(b []byte, _ net.Addr) (int, error) { + select { + case <-e.closed: + return 0, io.EOF + default: + return e.parent.conn.Write(b) + } +} +func (e *dtlsEndpoint) Read(b []byte) (int, error) { n, _, err := e.ReadFrom(b); return n, err } +func (e *dtlsEndpoint) Write(b []byte) (int, error) { return e.WriteTo(b, nil) } +func (e *dtlsEndpoint) RemoteAddr() net.Addr { return e.parent.conn.RemoteAddr() } +func (e *dtlsEndpoint) Close() error { e.once.Do(func() { close(e.closed) }); return nil } +func (e *dtlsEndpoint) LocalAddr() net.Addr { return e.parent.conn.LocalAddr() } +func (e *dtlsEndpoint) SetDeadline(time.Time) error { return nil } +func (e *dtlsEndpoint) SetReadDeadline(time.Time) error { return nil } +func (e *dtlsEndpoint) SetWriteDeadline(time.Time) error { return nil } diff --git a/pkg/sip/dtls_srtp_test.go b/pkg/sip/dtls_srtp_test.go new file mode 100644 index 00000000..ab13ce43 --- /dev/null +++ b/pkg/sip/dtls_srtp_test.go @@ -0,0 +1,256 @@ +// Copyright 2026 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 sip + +import ( + "context" + "fmt" + "net/netip" + "sync" + "testing" + "time" + + pice "github.com/pion/ice/v4" + psdp "github.com/pion/sdp/v3" + "github.com/stretchr/testify/require" + + "github.com/livekit/media-sdk/g711" + mediasdp "github.com/livekit/media-sdk/sdp" + "github.com/livekit/protocol/logger" +) + +func dtlsICEOffer(fingerprint, ufrag, pwd string) []byte { + return []byte(fmt.Sprintf("v=0\r\n"+ + "o=- 1 1 IN IP4 127.0.0.1\r\n"+ + "s=-\r\nt=0 0\r\n"+ + "m=audio 40000 UDP/TLS/RTP/SAVPF 0\r\n"+ + "c=IN IP4 127.0.0.1\r\n"+ + "a=candidate:remote 1 udp 2130706431 127.0.0.1 40000 typ host\r\n"+ + "a=ice-ufrag:%s\r\na=ice-pwd:%s\r\n"+ + "a=fingerprint:sha-256 %s\r\n"+ + "a=setup:actpass\r\na=rtcp-mux\r\n"+ + "a=rtpmap:0 PCMU/8000\r\na=sendrecv\r\n", ufrag, pwd, fingerprint)) +} + +func answerICECredentials(t *testing.T, raw []byte) (string, string) { + t.Helper() + var answer psdp.SessionDescription + require.NoError(t, answer.Unmarshal(raw)) + require.NotEmpty(t, answer.MediaDescriptions) + ufrag, ok := mediaAttribute(answer.MediaDescriptions[0], "ice-ufrag") + require.True(t, ok) + pwd, ok := mediaAttribute(answer.MediaDescriptions[0], "ice-pwd") + require.True(t, ok) + return ufrag, pwd +} + +func newDTLSTestMediaPort(t *testing.T, timeout time.Duration) *mediaPort { + t.Helper() + cert, err := newDTLSCertificate() + require.NoError(t, err) + return newTestPort(t, logger.NewTestLogger(t), newTestConn(1), &MediaOptions{ + IP: newIP("127.0.0.1"), + Codecs: testCodecSet(g711.ULawSDPNameAndRate), + DTLSEnabled: true, + DTLSCertificate: cert, + DTLSHandshakeTimeout: timeout, + }, RoomSampleRate) +} + +func TestDTLSRefreshReusesActiveICETransport(t *testing.T) { + m := newDTLSTestMediaPort(t, 30*time.Second) + offer := dtlsICEOffer(m.opts.DTLSCertificate.fingerprint, "remote-user", "remote-password-value") + + first, err := m.GenerateAnswer(offer) + require.NoError(t, err) + firstPipeline := m.pipeline + firstUfrag, firstPwd := answerICECredentials(t, first) + + second, err := m.GenerateAnswer(offer) + require.NoError(t, err) + secondUfrag, secondPwd := answerICECredentials(t, second) + + require.Same(t, firstPipeline, m.pipeline, "session refresh must keep the active media transport") + require.Equal(t, firstUfrag, secondUfrag, "answer must not advertise unused ICE credentials") + require.Equal(t, firstPwd, secondPwd, "answer must not advertise unused ICE credentials") +} + +func TestDTLSICERestartRebuildsTransport(t *testing.T) { + m := newDTLSTestMediaPort(t, 30*time.Second) + first, err := m.GenerateAnswer(dtlsICEOffer(m.opts.DTLSCertificate.fingerprint, "remote-user-one", "remote-password-value-one")) + require.NoError(t, err) + firstPipeline := m.pipeline + firstUfrag, firstPwd := answerICECredentials(t, first) + + second, err := m.GenerateAnswer(dtlsICEOffer(m.opts.DTLSCertificate.fingerprint, "remote-user-two", "remote-password-value-two")) + require.NoError(t, err) + secondUfrag, secondPwd := answerICECredentials(t, second) + + require.NotSame(t, firstPipeline, m.pipeline, "ICE restart must rebuild the media transport") + require.NotEqual(t, firstUfrag, secondUfrag) + require.NotEqual(t, firstPwd, secondPwd) +} + +func TestDTLSSessionImmediateCloseCancelsStartup(t *testing.T) { + m := newDTLSTestMediaPort(t, time.Hour) + offer := dtlsICEOffer(m.opts.DTLSCertificate.fingerprint, "remote-user", "remote-password-value") + _, err := m.GenerateAnswer(offer) + require.NoError(t, err) + + done := make(chan struct{}) + go func() { + m.Close() + close(done) + }() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("immediate close waited for DTLS handshake timeout") + } +} + +func TestDTLSSessionConcurrentCloseDuringStartup(t *testing.T) { + for range 20 { + m := newDTLSTestMediaPort(t, time.Hour) + offer := dtlsICEOffer(m.opts.DTLSCertificate.fingerprint, "remote-user", "remote-password-value") + _, err := m.GenerateAnswer(offer) + require.NoError(t, err) + + var wg sync.WaitGroup + for range 4 { + wg.Go(m.Close) + } + closed := make(chan struct{}) + go func() { + wg.Wait() + close(closed) + }() + select { + case <-closed: + case <-time.After(time.Second): + t.Fatal("concurrent close blocked during DTLS/ICE startup") + } + } +} + +func TestLegacyMediaDoesNotUseDTLSState(t *testing.T) { + cert, err := newDTLSCertificate() + require.NoError(t, err) + newPort := func(encryption mediasdp.Encryption) *mediaPort { + return newTestPort(t, logger.NewTestLogger(t), newTestConn(1), &MediaOptions{ + IP: newIP("127.0.0.1"), + Codecs: testCodecSet(g711.ULawSDPNameAndRate), + Encryption: encryption, + DTLSEnabled: true, + DTLSCertificate: cert, + }, RoomSampleRate) + } + + t.Run("RTP AVP", func(t *testing.T) { + m := newPort(mediasdp.EncryptionNone) + _, err := m.GenerateAnswer(offerAt(t, netip.MustParseAddrPort("127.0.0.1:40000"))) + require.NoError(t, err) + require.Nil(t, m.dtls) + _, isDTLS := m.pipeline.sess.(*dtlsSrtpSession) + require.False(t, isDTLS) + }) + + t.Run("SDES SRTP", func(t *testing.T) { + m := newPort(mediasdp.EncryptionRequire) + _, err := m.GenerateAnswer(offerAtEnc(t, netip.MustParseAddrPort("127.0.0.1:40002"), mediasdp.EncryptionRequire)) + require.NoError(t, err) + require.Nil(t, m.dtls) + _, isDTLS := m.pipeline.sess.(*dtlsSrtpSession) + require.False(t, isDTLS) + }) +} + +func gatherICECandidates(t *testing.T, agent *pice.Agent) []pice.Candidate { + t.Helper() + done := make(chan struct{}) + var once sync.Once + require.NoError(t, agent.OnCandidate(func(candidate pice.Candidate) { + if candidate == nil { + once.Do(func() { close(done) }) + } + })) + require.NoError(t, agent.GatherCandidates()) + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("ICE candidate gathering timed out") + } + candidates, err := agent.GetLocalCandidates() + require.NoError(t, err) + require.NotEmpty(t, candidates) + return candidates +} + +func TestICEChecksFallBackToLaterCandidate(t *testing.T) { + newAgent := func(lite bool) *pice.Agent { + agent, err := pice.NewAgent(&pice.AgentConfig{ + NetworkTypes: []pice.NetworkType{pice.NetworkTypeUDP4}, + CandidateTypes: []pice.CandidateType{pice.CandidateTypeHost}, + IncludeLoopback: true, + Lite: lite, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = agent.Close() }) + return agent + } + + controlling := newAgent(false) + controlled := newAgent(true) + controllingCandidates := gatherICECandidates(t, controlling) + controlledCandidates := gatherICECandidates(t, controlled) + + for _, candidate := range controllingCandidates { + copyCandidate, err := pice.UnmarshalCandidate(candidate.Marshal()) + require.NoError(t, err) + require.NoError(t, controlled.AddRemoteCandidate(copyCandidate)) + } + + remoteCandidates := []dtlsICECandidate{{raw: "unreachable 1 udp 4294967295 192.0.2.1 9 typ host"}} + for _, candidate := range controlledCandidates { + remoteCandidates = append(remoteCandidates, dtlsICECandidate{raw: candidate.Marshal()}) + } + addCtx, addCancel := context.WithTimeout(context.Background(), time.Second) + defer addCancel() + require.NoError(t, addRemoteICECandidates(addCtx, controlling, remoteCandidates)) + got, err := controlling.GetRemoteCandidates() + require.NoError(t, err) + require.Len(t, got, len(remoteCandidates)) + + controllingUfrag, controllingPwd, err := controlling.GetLocalUserCredentials() + require.NoError(t, err) + controlledUfrag, controlledPwd, err := controlled.GetLocalUserCredentials() + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + acceptResult := make(chan error, 1) + go func() { + conn, acceptErr := controlled.Accept(ctx, controllingUfrag, controllingPwd) + if acceptErr == nil { + acceptErr = conn.Close() + } + acceptResult <- acceptErr + }() + conn, err := controlling.Dial(ctx, controlledUfrag, controlledPwd) + require.NoError(t, err, "ICE should connect through the later usable candidate") + require.NoError(t, conn.Close()) + require.NoError(t, <-acceptResult) +} diff --git a/pkg/sip/inbound.go b/pkg/sip/inbound.go index 10ff2c60..b0a8ed83 100644 --- a/pkg/sip/inbound.go +++ b/pkg/sip/inbound.go @@ -936,6 +936,9 @@ func (c *inboundCall) handleInvite(ctx context.Context, tid traceid.ID, req *sip } else if errors.Is(err, sdp.ErrNoCommonCrypto) { status, term = callMediaFailed, stats.ClientError("no-common-crypto") sipReason = sip.StatusBadRequest + } else if errors.Is(err, errDTLSSDP) { + status, term = callMediaFailed, stats.ClientError("dtls-sdp-error") + sipReason = sip.StatusNotAcceptableHere } else if e := (SDPError{}); errors.As(err, &e) { status, term = callMediaFailed, stats.ClientError("sdp-error") sipReason = sip.StatusBadRequest @@ -1252,6 +1255,9 @@ func (c *inboundCall) createMediaPort(mconf *sipMediaConfig, conf *config.Config Codecs: mconf.Codecs, Encryption: mconf.Encryption, DTMFAudio: conf.AudioDTMF, + DTLSEnabled: conf.DTLSSRTP.Enabled, + DTLSCertificate: c.s.dtlsCertificate, + DTLSHandshakeTimeout: conf.DTLSSRTP.HandshakeTimeout, }, RoomSampleRate) if err != nil { return err diff --git a/pkg/sip/media_codecs.go b/pkg/sip/media_codecs.go index 2729aee9..22f757d9 100644 --- a/pkg/sip/media_codecs.go +++ b/pkg/sip/media_codecs.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "slices" + "strings" "time" _ "github.com/livekit/media-sdk/all" @@ -32,6 +33,8 @@ import ( "github.com/livekit/protocol/livekit" ) +const OpusSDPName = "opus/48000/2" + var defaultCodecs = msdk.NewCodecSet() func init() { @@ -40,6 +43,7 @@ func init() { g711.ULawSDPNameAndRate: true, g722.SDPNameAndRate: true, amrwb.SDPNameAndRate: false, // optional + OpusSDPName: false, // opt-in via enable_opus config flag dtmf.SDPNameAndRate: true, }) } @@ -133,7 +137,27 @@ func codecSet(m *livekit.SIPMediaConfig) (*msdk.CodecSet, error) { } name = fmt.Sprintf("%s/%d", name, rate) s.SetEnabled(name, true) + if sdpName := resolveSDPName(name); sdpName != "" { + s.SetEnabled(sdpName, true) + } } s.SetEnabled(dtmf.SDPNameAndRate, true) return s, nil } + +// resolveSDPName finds the full SDP name for a codec specified as "name/rate" +// by matching against registered codecs. This handles codecs like Opus whose +// SDP name includes a channel count suffix (e.g., "opus/48000/2"). +func resolveSDPName(name string) string { + name = strings.ToLower(name) + for _, c := range msdk.Codecs() { + sdpName := strings.ToLower(c.Info().SDPName) + if sdpName == name { + return "" + } + if strings.HasPrefix(sdpName, name+"/") { + return c.Info().SDPName + } + } + return "" +} diff --git a/pkg/sip/media_codecs_opus.go b/pkg/sip/media_codecs_opus.go new file mode 100644 index 00000000..827af55c --- /dev/null +++ b/pkg/sip/media_codecs_opus.go @@ -0,0 +1,60 @@ +// Copyright 2024 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. + +//go:build cgo + +package sip + +import ( + msdk "github.com/livekit/media-sdk" + "github.com/livekit/media-sdk/opus" + "github.com/livekit/protocol/logger" +) + +func init() { + msdk.RegisterCodec(msdk.NewAudioCodec(msdk.CodecInfo{ + SDPName: OpusSDPName, + SampleRate: 48000, + RTPClockRate: 48000, + RTPIsStatic: false, + Priority: 10, + Disabled: true, + FileExt: "opus", + }, opusDecode, opusEncode)) +} + +// SetOpusEnabled toggles Opus in both the per-call default codec set and the +// global media-sdk codec set. Call once during Service.Start. +func SetOpusEnabled(enabled bool) { + defaultCodecs.SetEnabled(OpusSDPName, enabled) + msdk.CodecSetEnabled(OpusSDPName, enabled) +} + +func opusDecode(w msdk.PCM16Writer) msdk.WriteCloser[opus.Sample] { + dec, err := opus.Decode(w, 1, logger.GetLogger()) + if err != nil { + logger.GetLogger().Errorw("opus decode init failed", err) + return nil + } + return dec +} + +func opusEncode(w msdk.WriteCloser[opus.Sample]) msdk.PCM16Writer { + enc, err := opus.Encode(w, 1, logger.GetLogger()) + if err != nil { + logger.GetLogger().Errorw("opus encode init failed", err) + return nil + } + return enc +} diff --git a/pkg/sip/media_codecs_opus_nocgo.go b/pkg/sip/media_codecs_opus_nocgo.go new file mode 100644 index 00000000..20d930bf --- /dev/null +++ b/pkg/sip/media_codecs_opus_nocgo.go @@ -0,0 +1,20 @@ +// Copyright 2024 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. + +//go:build !cgo + +package sip + +// SetOpusEnabled is a no-op in non-CGo builds; Opus requires libopus. +func SetOpusEnabled(_ bool) {} diff --git a/pkg/sip/media_codecs_opus_test.go b/pkg/sip/media_codecs_opus_test.go new file mode 100644 index 00000000..66c494e2 --- /dev/null +++ b/pkg/sip/media_codecs_opus_test.go @@ -0,0 +1,139 @@ +// Copyright 2024 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. + +//go:build cgo + +package sip + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + msdk "github.com/livekit/media-sdk" + "github.com/livekit/media-sdk/g711" + "github.com/livekit/media-sdk/g722" + "github.com/livekit/media-sdk/sdp" + "github.com/livekit/protocol/livekit" +) + +func TestResolveSDPName(t *testing.T) { + t.Run("two-part name resolves to three-part", func(t *testing.T) { + got := resolveSDPName("opus/48000") + require.Equal(t, OpusSDPName, got) + }) + t.Run("exact three-part match returns empty", func(t *testing.T) { + got := resolveSDPName("opus/48000/2") + require.Empty(t, got) + }) + t.Run("unknown codec returns empty", func(t *testing.T) { + got := resolveSDPName("unknown/8000") + require.Empty(t, got) + }) +} + +func TestCodecSetWithOpus(t *testing.T) { + enableOpusForTest(t) + + m := &livekit.SIPMediaConfig{ + OnlyListedCodecs: true, + Codecs: []*livekit.SIPCodec{ + {Name: "opus", Rate: 48000}, + }, + } + s, err := codecSet(m) + require.NoError(t, err) + require.True(t, s.IsEnabledByName(OpusSDPName), + "codecSet should enable opus/48000/2 when opus/48000 is listed") +} + +// enableOpusForTest turns Opus on for a test and restores disabled state after. +func enableOpusForTest(t *testing.T) { + t.Helper() + SetOpusEnabled(true) + t.Cleanup(func() { SetOpusEnabled(false) }) +} + +// TestOpusDisabledByDefault verifies that without calling SetOpusEnabled, +// Opus is absent from defaultCodecs — so existing deployments are unaffected. +func TestOpusDisabledByDefault(t *testing.T) { + c := sdp.CodecByNameWith(defaultCodecs, OpusSDPName, nil) + require.Nil(t, c, "opus must not appear in defaultCodecs by default") +} + +// TestOpusRegistered verifies the codec is present in msdk.Codecs(), uses a +// dynamic payload type, and runs at the correct 48 kHz clock rate. +func TestOpusRegistered(t *testing.T) { + enableOpusForTest(t) + + c := sdp.CodecByNameWith(defaultCodecs, OpusSDPName, nil) + require.NotNil(t, c, "opus codec must be present in defaultCodecs when enabled") + + _, ok := c.(msdk.AudioCodec) + require.True(t, ok, "opus codec must implement AudioCodec") + + info := c.Info() + require.Equal(t, OpusSDPName, info.SDPName) + require.Equal(t, 48000, info.SampleRate) + require.Equal(t, 48000, info.RTPClockRate) + require.False(t, info.RTPIsStatic, "opus must use a dynamic payload type") +} + +// TestOpusInSDPOffer verifies that after enabling Opus, an SDP offer contains +// an rtpmap line advertising opus/48000/2. +func TestOpusInSDPOffer(t *testing.T) { + enableOpusForTest(t) + + _, md, err := sdp.OfferMediaWith(defaultCodecs, 12345, sdp.EncryptionNone) + require.NoError(t, err) + + var found bool + for _, a := range md.Attributes { + if a.Key == "rtpmap" && strings.Contains(strings.ToLower(a.Value), "opus/48000/2") { + found = true + break + } + } + require.True(t, found, "SDP offer should contain an rtpmap line for opus/48000/2") +} + +// TestOpusPreferredOverG722 verifies codec selection picks Opus (priority 10) +// over G722 (priority -5) and G711 (priority -10/-20) when all are offered. +func TestOpusPreferredOverG722(t *testing.T) { + enableOpusForTest(t) + + opusC, ok := sdp.CodecByNameWith(defaultCodecs, OpusSDPName, nil).(msdk.AudioCodec) + require.True(t, ok, "opus must be an AudioCodec") + + ulawC, ok := sdp.CodecByNameWith(defaultCodecs, g711.ULawSDPName, nil).(msdk.AudioCodec) + require.True(t, ok, "PCMU must be an AudioCodec") + + g722C, ok := sdp.CodecByNameWith(defaultCodecs, g722.SDPName, nil).(msdk.AudioCodec) + require.True(t, ok, "G722 must be an AudioCodec") + + desc := sdp.MediaDesc{ + Codecs: []sdp.CodecInfo{ + {Type: 0, Codec: ulawC}, + {Type: 9, Codec: g722C}, + {Type: 111, Codec: opusC}, + }, + } + got, err := sdp.SelectAudio(desc, false) + require.NoError(t, err) + require.Equal(t, OpusSDPName, got.Codec.Info().SDPName, + "Opus should win priority-based codec selection") + require.Equal(t, byte(111), got.Type, + "peer-assigned payload type 111 must be honored") +} diff --git a/pkg/sip/media_pipeline.go b/pkg/sip/media_pipeline.go index a32d6dfe..5ab862d4 100644 --- a/pkg/sip/media_pipeline.go +++ b/pkg/sip/media_pipeline.go @@ -20,6 +20,7 @@ import ( "fmt" "io" "math" + "net" "os" "strings" "sync" @@ -43,6 +44,7 @@ type MediaPortPipelineConfig struct { stats *PortStats onNewSSRC func() bool onPacket func() + dtls *dtlsMediaConfig } func NewMediaPortPipeline( @@ -131,7 +133,10 @@ func (p *mediaPortPipeline) init( p.lastDTMFEvent.Store(math.MaxUint64) var err error - if mc.Crypto != nil { + if p.conf.dtls != nil { + remote := &net.UDPAddr{IP: mc.Remote.Addr().AsSlice(), Port: int(mc.Remote.Port())} + p.sess = newDTLSSRTPSession(p.conf.log, port, p.conf.dtls, p.conf.opts.DTLSHandshakeTimeout, remote) + } else if mc.Crypto != nil { p.sess, err = srtp.NewSession(p.conf.log, port, mc.Crypto) } else { p.sess = rtp.NewSession(p.conf.log, port) diff --git a/pkg/sip/media_port.go b/pkg/sip/media_port.go index 12f3131f..22c72396 100644 --- a/pkg/sip/media_port.go +++ b/pkg/sip/media_port.go @@ -371,6 +371,9 @@ type MediaOptions struct { Codecs *msdk.CodecSet Encryption sdp.Encryption DTMFAudio bool + DTLSEnabled bool + DTLSCertificate *dtlsCertificate + DTLSHandshakeTimeout time.Duration } func (o *MediaOptions) ApplyDefaults() { @@ -532,6 +535,7 @@ type mediaPort struct { localSDP []byte offer *sdp.Offer negotiated *sdp.MediaConfig + dtls *dtlsMediaConfig audioIn *msdk.WriteCloserSwitch[msdk.PCM16Sample] // SIP RTP -> LK PCM audioOut *msdk.WriteCloserSwitch[msdk.PCM16Sample] // LK PCM -> SIP RTP @@ -785,6 +789,22 @@ func (p *mediaPort) GenerateAnswer(offerData []byte) ([]byte, error) { return p.GetLocalSDP() } + dtlsConf, err := parseDTLSOffer(offerData, p.opts.DTLSCertificate) + if err != nil { + return nil, SDPError{Err: err} + } + if dtlsConf != nil && !p.opts.DTLSEnabled { + return nil, SDPError{Err: fmt.Errorf("%w: disabled", errDTLSSDP)} + } + if dtlsConf != nil { + p.mu.RLock() + activeDTLS := p.dtls + if sameDTLSRemoteTransport(activeDTLS, dtlsConf) { + reuseDTLSLocalTransport(dtlsConf, activeDTLS) + } + p.mu.RUnlock() + } + offer, err := parseOfferWith(p.log, p.mon, p.codecs, offerData) if err != nil { return nil, SDPError{Err: err} @@ -793,16 +813,25 @@ func (p *mediaPort) GenerateAnswer(offerData []byte) ([]byte, error) { isReinvite := p.negotiated != nil p.mu.RUnlock() p.reportPeerCodecs(offer.MediaDesc, isReinvite) - answer, mc, err := offer.Answer(p.externalIP, p.Port(), p.encryption, sdp.WithLocalProfiles(p.localCrypto)) + answerEncryption := p.encryption + if dtlsConf != nil { + answerEncryption = sdp.EncryptionNone + } + answer, mc, err := offer.Answer(p.externalIP, p.Port(), answerEncryption, sdp.WithLocalProfiles(p.localCrypto)) if err != nil { return nil, SDPError{Err: err} } + if dtlsConf != nil { + if err := addDTLSAnswer(&answer.SDP, dtlsConf); err != nil { + return nil, SDPError{Err: err} + } + } answerData, err := answer.SDP.Marshal() if err != nil { return nil, err } - err = p.configure(mc, answerData) + err = p.configure(mc, dtlsConf, answerData) if err != nil { return nil, err } @@ -836,7 +865,7 @@ func (p *mediaPort) ProcessAnswer(answerData []byte) error { return err } - err = p.configure(mc, localSDPBytes) + err = p.configure(mc, nil, localSDPBytes) if err != nil { return err } @@ -893,7 +922,7 @@ func parseAnswerWith(log logger.Logger, mon *stats.CallMonitor, codecs *msdk.Cod // Building pipeline -func (p *mediaPort) configure(c *sdp.MediaConfig, localSDP []byte) error { +func (p *mediaPort) configure(c *sdp.MediaConfig, dtlsConf *dtlsMediaConfig, localSDP []byte) error { // Map the durable udpConn + WriteCloserSwitch anchors onto a fresh mediaPortPipeline. // Rebuild from scratch under mu: closePipelineLocked (soft-closes the session via udpConn), // Reopen the port, then Configure a new generation and Swap TX leaves into the anchors. @@ -912,6 +941,9 @@ func (p *mediaPort) configure(c *sdp.MediaConfig, localSDP []byte) error { } changeSetSummary := NewChangeSetSummary(p.negotiated, c) + if !sameDTLSRemoteTransport(p.dtls, dtlsConf) { + changeSetSummary |= changeSetDTLSTransport + } if changeSetSummary.includes(changeSetLocalAddr) { return errors.New("unexpected local address change") @@ -950,7 +982,7 @@ func (p *mediaPort) configure(c *sdp.MediaConfig, localSDP []byte) error { p.log.Infow("peer requested hold", "direction", c.PeerDirection.String(), "remote", c.Remote.String()) } if changeSetSummary.shouldReconfigure() { - if changeSetSummary != changeSetNew { + if changeSetSummary != changeSetNew && !changeSetSummary.includes(changeSetDTLSTransport) { // Explicitly disable renegotiation for now // Compatibility to today's behavior: return 200 OK, but don't reconfigure the pipeline return nil @@ -969,6 +1001,7 @@ func (p *mediaPort) configure(c *sdp.MediaConfig, localSDP []byte) error { stats: p.stats, onNewSSRC: p.mediaReceived.Break, onPacket: p.onNewMediaPacket, + dtls: dtlsConf, } newPipeline, err := NewMediaPortPipeline( pipelineConfig, @@ -984,10 +1017,10 @@ func (p *mediaPort) configure(c *sdp.MediaConfig, localSDP []byte) error { audioToPort, dtmfToPort = newPipeline.GetConnectors() // These are not propagating Close() p.pipeline = newPipeline - - p.localSDP = localSDP // TODO: Move to end of function when reconfiguring is supported + p.localSDP = localSDP } p.negotiated = c + p.dtls = dtlsConf return nil } @@ -1006,6 +1039,7 @@ const ( changeSetLocalAddr changeSetRemoteAddr changeSetPeerDirection + changeSetDTLSTransport ) func NewChangeSetSummary(current, new *sdp.MediaConfig) changeSetSummary { @@ -1046,7 +1080,7 @@ func NewChangeSetSummary(current, new *sdp.MediaConfig) changeSetSummary { } func (c changeSetSummary) shouldReconfigure() bool { - return c&(changeSetNew|changeSetAudioCodec|changeSetDTMF|changeSetCrypto) != 0 + return c&(changeSetNew|changeSetAudioCodec|changeSetDTMF|changeSetCrypto|changeSetDTLSTransport) != 0 } func (c changeSetSummary) includes(feature changeSetSummary) bool { diff --git a/pkg/sip/media_port_test.go b/pkg/sip/media_port_test.go index 2675bf94..bdeabf8a 100644 --- a/pkg/sip/media_port_test.go +++ b/pkg/sip/media_port_test.go @@ -430,6 +430,11 @@ func TestMediaPortAudioRoundTrip(t *testing.T) { for _, codec := range allAudioCodecs() { info := codec.Info() t.Run(strings.ReplaceAll(info.SDPName, "/", "-"), func(t *testing.T) { + if strings.EqualFold(info.SDPName, OpusSDPName) { + // Opus is lossy, so waveform-equality assertions do not apply. + // Codec registration and negotiation are covered separately. + t.SkipNow() + } for _, resample := range []bool{true, false} { t.Run(fmt.Sprintf("resample=%t", resample), func(t *testing.T) { for _, enc := range []sdp.Encryption{sdp.EncryptionNone, sdp.EncryptionRequire} { diff --git a/pkg/sip/server.go b/pkg/sip/server.go index 6858cb8c..a11ca3d9 100644 --- a/pkg/sip/server.go +++ b/pkg/sip/server.go @@ -176,7 +176,8 @@ type Server struct { cli *Client // optional, for outbound reinvite handling - res mediaRes + res mediaRes + dtlsCertificate *dtlsCertificate } type inProgressInvite struct { @@ -206,6 +207,10 @@ func WithClient(cli *Client) ServerOption { } } +func WithDTLSSRTPCertificate(cert *dtlsCertificate) ServerOption { + return func(s *Server) { s.dtlsCertificate = cert } +} + // WithInterceptors configures all sip handlers to be wrapped with the given set // of interceptors. Interceptors are applied s.t. the first interceptor is the // outermost one. diff --git a/pkg/sip/service.go b/pkg/sip/service.go index a28da229..7df9093f 100644 --- a/pkg/sip/service.go +++ b/pkg/sip/service.go @@ -103,8 +103,16 @@ func NewService(region string, conf *config.Config, mon *stats.Monitor, log logg if conf.MediaTimeoutInitial <= 0 { conf.MediaTimeoutInitial = defaultMediaTimeoutInitial } + var dtlsCert *dtlsCertificate + if conf.DTLSSRTP.Enabled { + var err error + dtlsCert, err = newDTLSCertificate() + if err != nil { + return nil, fmt.Errorf("create DTLS-SRTP certificate: %w", err) + } + } cli := NewClient(region, conf, log, mon, getStateHandler) - options := append([]ServerOption{WithClient(cli)}, opts...) + options := append([]ServerOption{WithClient(cli), WithDTLSSRTPCertificate(dtlsCert)}, opts...) s := &Service{ conf: conf, log: log, @@ -226,6 +234,7 @@ func (s *Service) Start() error { } } DefaultCodecs().SetEnabledMap(s.conf.Codecs) + SetOpusEnabled(s.conf.EnableOpus) if err := s.mon.Start(s.conf); err != nil { return err