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
76 changes: 69 additions & 7 deletions pkg/sip/inbound.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,16 +320,61 @@ func sdpBodyFromRequest(req *sip.Request) []byte {
return req.Body()
}

func updateRemoteFromSDP(media *MediaPort, log logger.Logger, codecs *msdk.CodecSet, body []byte) {
// applyReinviteSDP updates the RTP destination from a re-INVITE SDP body when
// the currently negotiated audio codec and payload type remain offered.
//
// An empty body is treated as a session refresh (ok=true, no media change).
// Parse failures leave the destination unchanged and still return ok=true so
// the dialog can be refreshed with the existing local SDP (historical behavior).
//
// When the offer removes the negotiated audio codec/payload, ok=false and the
// caller must reject with 488 without changing the media path. Answering 200
// with the cached local SDP would advertise a codec that was not in the offer
// (see livekit/sip#766 / RFC 3264 §6.1).
func applyReinviteSDP(media *MediaPort, log logger.Logger, codecs *msdk.CodecSet, body []byte) (ok bool) {
if len(body) == 0 || media == nil {
return
return true
}
desc, err := sdp.ParseWith(codecs, body)
if err != nil {
log.Warnw("failed to parse re-INVITE SDP, RTP destination not updated", err)
return
return true
}
if !reinviteOffersCurrentAudio(media, desc) {
cur := media.Config()
var curType byte
var curName string
if cur != nil && cur.Audio.Codec != nil {
curType = cur.Audio.Type
curName = cur.Audio.Codec.Info().SDPName
}
log.Infow("rejecting re-INVITE that removes negotiated audio codec",
"negotiatedType", curType,
"negotiatedCodec", curName,
"remote", desc.Addr,
)
return false
}
media.UpdateRemote(desc.Addr)
return true
}

func reinviteOffersCurrentAudio(media *MediaPort, desc *sdp.Description) bool {
conf := media.Config()
if conf == nil || conf.Audio.Codec == nil {
return true
}
curType := conf.Audio.Type
curName := conf.Audio.Codec.Info().SDPName
for _, c := range desc.MediaDesc.Codecs {
if c.Type != curType {
continue
}
if c.Codec == nil || c.Codec.Info().SDPName == curName {
return true
}
}
return false
}

func (s *Server) onInvite(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) {
Expand Down Expand Up @@ -401,7 +446,11 @@ func (s *Server) processInvite(req *sip.Request, tx sip.ServerTransaction) (retE
s.cmu.RUnlock()
if existing != nil && existing.cc.InviteCSeq() < cc.InviteCSeq() {
existing.log().Infow("reinvite", "content-length", req.ContentLength(), "cseq", cc.InviteCSeq())
existing.updateRemoteFromSDP(sdpBodyFromRequest(req))
body := sdpBodyFromRequest(req)
if !existing.applyReinviteSDP(body) {
cc.RejectAsKeepAlive(sip.StatusNotAcceptableHere, "Not Acceptable Here")
return nil
}
cc.AcceptAsKeepAlive(existing.cc.OwnSDP())
return nil
}
Expand All @@ -411,8 +460,13 @@ func (s *Server) processInvite(req *sip.Request, tx sip.ServerTransaction) (retE
if oc != nil && oc.cc != nil && oc.cc.InviteCSeq() < newCSeq {
localSDP := oc.cc.LocalSDP()
if len(localSDP) != 0 {
body := sdpBodyFromRequest(req)
if !oc.applyReinviteSDP(body) {
oc.log.Infow("rejecting reinvite", "content-length", req.ContentLength(), "cseq", cc.InviteCSeq())
cc.RejectAsKeepAlive(sip.StatusNotAcceptableHere, "Not Acceptable Here")
return nil
}
oc.log.Infow("accepting reinvite", "content-length", req.ContentLength(), "cseq", cc.InviteCSeq())
oc.updateRemoteFromSDP(sdpBodyFromRequest(req))
oc.cc.RecordInvite(newCSeq)
cc.AcceptAsKeepAlive(localSDP)
return nil
Expand Down Expand Up @@ -1483,10 +1537,10 @@ func (c *inboundCall) Shutdown(ctx context.Context) {
c.closeWithTerm(ctx, stats.ServerError("shutdown"))
}

func (c *inboundCall) updateRemoteFromSDP(body []byte) {
func (c *inboundCall) applyReinviteSDP(body []byte) bool {
c.mmu.Lock()
defer c.mmu.Unlock()
updateRemoteFromSDP(c.media, c.log(), c.mediaCodecs, body)
return applyReinviteSDP(c.media, c.log(), c.mediaCodecs, body)
}

func (c *inboundCall) closeMedia() {
Expand Down Expand Up @@ -1983,6 +2037,14 @@ func (c *sipInbound) AcceptAsKeepAlive(sdp []byte) {
c.respondWithData(sip.StatusOK, "OK", "application/sdp", sdp)
}

// RejectAsKeepAlive rejects an in-dialog re-INVITE without tearing down the
// established dialog. Unlike RespondAndDrop it does not cache the response in
// rejectedInvites (which is keyed by Call-ID + From-tag and would poison later
// in-dialog requests) and does not clear dialog state on the established call.
func (c *sipInbound) RejectAsKeepAlive(status sip.StatusCode, reason string) {
c.respond(status, reason)
}

func (c *sipInbound) OwnSDP() []byte {
c.mu.RLock()
defer c.mu.RUnlock()
Expand Down
4 changes: 2 additions & 2 deletions pkg/sip/outbound.go
Original file line number Diff line number Diff line change
Expand Up @@ -522,8 +522,8 @@ func (c *outboundCall) dialSIP(ctx context.Context, tid traceid.ID) error {
return nil
}

func (c *outboundCall) updateRemoteFromSDP(body []byte) {
updateRemoteFromSDP(c.media, c.log, c.sipConf.mediaConfig.Codecs, body)
func (c *outboundCall) applyReinviteSDP(body []byte) bool {
return applyReinviteSDP(c.media, c.log, c.sipConf.mediaConfig.Codecs, body)
}

func (c *outboundCall) connectMedia() {
Expand Down
58 changes: 58 additions & 0 deletions pkg/sip/signaling_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import (
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/durationpb"

msdk "github.com/livekit/media-sdk"
"github.com/livekit/media-sdk/dtmf"
"github.com/livekit/media-sdk/g711"
"github.com/livekit/media-sdk/g722"
"github.com/livekit/media-sdk/sdp"
"github.com/livekit/mediatransportutil/pkg/rtcconfig"
"github.com/livekit/protocol/livekit"
Expand Down Expand Up @@ -700,6 +704,24 @@ func TestReinvite(t *testing.T) {
require.Equal(t, serverLocalSDP, resp.Body(), "body-less re-INVITE should return server local SDP")
require.Equal(t, initialRemote, ic.media.RemoteAddr(), "body-less re-INVITE must not change RTP destination")
})

t.Run("codec_mismatch", func(t *testing.T) {
st := NewServiceTest(t, nil)
call, ic := st.CreateInboundCall(t)
initialRemote := ic.media.RemoteAddr()
conf := ic.media.Config()
require.NotNil(t, conf)
require.NotNil(t, conf.Audio.Codec)

offerBytes := mustIncompatibleAudioOffer(t, conf.Audio.Codec.Info().SDPName, netip.MustParseAddr("9.8.7.6"), 12345)
req, _, err := call.Invite(offerBytes)
require.NoError(t, err)
resp := st.TestUA.TransactionRequest(t, req, true)
require.Equal(t, sip.StatusNotAcceptableHere, resp.StatusCode,
"re-INVITE that removes negotiated codec must be rejected with 488")
require.Equal(t, initialRemote, ic.media.RemoteAddr(),
"rejected codec-changing re-INVITE must not change RTP destination")
})
})
t.Run("outbound", func(t *testing.T) {
t.Run("normal", func(t *testing.T) {
Expand Down Expand Up @@ -744,6 +766,24 @@ func TestReinvite(t *testing.T) {
require.Equal(t, initialRemote, oc.media.RemoteAddr(), "body-less re-INVITE must not change RTP destination")
})

t.Run("codec_mismatch", func(t *testing.T) {
st := NewServiceTest(t, nil)
call, oc, _ := st.CreateOutboundCall(t)
initialRemote := oc.media.RemoteAddr()
conf := oc.media.Config()
require.NotNil(t, conf)
require.NotNil(t, conf.Audio.Codec)

offerBytes := mustIncompatibleAudioOffer(t, conf.Audio.Codec.Info().SDPName, netip.MustParseAddr("9.8.7.6"), 12345)
req, _, err := call.Invite(offerBytes)
require.NoError(t, err)
resp := st.TestUA.TransactionRequest(t, req, false)
require.Equal(t, sip.StatusNotAcceptableHere, resp.StatusCode,
"re-INVITE that removes negotiated codec must be rejected with 488")
require.Equal(t, initialRemote, oc.media.RemoteAddr(),
"rejected codec-changing re-INVITE must not change RTP destination")
})

t.Run("miss", func(t *testing.T) {
st := NewServiceTest(t, nil)
call, oc, _ := st.CreateOutboundCall(t)
Expand All @@ -767,6 +807,24 @@ func TestReinvite(t *testing.T) {
})
}

// mustIncompatibleAudioOffer builds an SDP offer that intentionally excludes
// negotiatedCodec so a re-INVITE cannot keep the established media session.
func mustIncompatibleAudioOffer(t *testing.T, negotiatedCodec string, addr netip.Addr, port int) []byte {
t.Helper()
incompatible := msdk.NewCodecSet()
for _, name := range []string{g711.ULawSDPNameAndRate, g711.ALawSDPNameAndRate, g722.SDPNameAndRate} {
if name != negotiatedCodec {
incompatible.SetEnabled(name, true)
}
}
incompatible.SetEnabled(dtmf.SDPNameAndRate, true)
offer, err := sdp.NewOfferWith(incompatible, addr, port, sdp.EncryptionNone)
require.NoError(t, err)
offerBytes, err := offer.SDP.Marshal()
require.NoError(t, err)
return offerBytes
}

func TestTransfer(t *testing.T) {
const referTo = "tel:+15551234567"

Expand Down