From 40bc980d897b770a1a38d250156598ec14665c8a Mon Sep 17 00:00:00 2001 From: Nishad Musthafa Date: Mon, 9 Jun 2025 20:22:17 -0700 Subject: [PATCH 1/3] Detailed responses in trunk iter This will help discern between cases when 1. No trunks are configured 2. Trunks are configured but none matched.(This can happen when there is no default trunk) 3. There is a default trunk that got applied 4. A specific trunk matched --- sip/sip.go | 192 ++++++++++++++++++++++++++++++------------------ sip/sip_test.go | 137 ++++++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+), 71 deletions(-) diff --git a/sip/sip.go b/sip/sip.go index b7dd2f987..db8836169 100644 --- a/sip/sip.go +++ b/sip/sip.go @@ -451,6 +451,30 @@ func matchNumbers(num string, allowed []string) bool { return false } +// TrunkMatchType indicates how a trunk was matched +type TrunkMatchType int + +const ( + // TrunkMatchEmpty indicates no trunks were defined + TrunkMatchEmpty TrunkMatchType = iota + // TrunkMatchNone indicates trunks exist but none matched + TrunkMatchNone + // TrunkMatchDefault indicates only a default trunk (with no specific numbers) matched + TrunkMatchDefault + // TrunkMatchSpecific indicates a trunk with specific numbers matched + TrunkMatchSpecific +) + +// TrunkMatchResult provides detailed information about the trunk matching process +type TrunkMatchResult struct { + // The matched trunk, if any + Trunk *livekit.SIPInboundTrunkInfo + // How the trunk was matched + MatchType TrunkMatchType + // Number of default trunks found + DefaultTrunkCount int +} + // MatchTrunk finds a SIP Trunk definition matching the request. // Returns nil if no rules matched or an error if there are conflicting definitions. // @@ -459,6 +483,99 @@ func MatchTrunk(trunks []*livekit.SIPInboundTrunkInfo, call *rpc.SIPCall, opts . return MatchTrunkIter(iters.Slice(trunks), call, opts...) } +// MatchTrunkIterDetailed is like MatchTrunkIter but returns detailed match information +func MatchTrunkIterDetailed(it iters.Iter[*livekit.SIPInboundTrunkInfo], call *rpc.SIPCall, opts ...MatchTrunkOpt) (*TrunkMatchResult, error) { + defer it.Close() + var opt matchTrunkOpts + for _, fnc := range opts { + fnc(&opt) + } + opt.defaults() + + result := &TrunkMatchResult{ + MatchType: TrunkMatchEmpty, // Start with assumption it's empty + } + + var ( + selectedTrunk *livekit.SIPInboundTrunkInfo + defaultTrunk *livekit.SIPInboundTrunkInfo + defaultTrunkPrev *livekit.SIPInboundTrunkInfo + sawAnyTrunk bool + ) + calledNorm := NormalizeNumber(call.To.User) + for { + tr, err := it.Next() + if err == io.EOF { + break + } else if err != nil { + return nil, err + } + if !sawAnyTrunk { + sawAnyTrunk = true + result.MatchType = TrunkMatchNone // We have trunks but haven't matched any yet + } + tr = opt.Replace(tr) + // Do not consider it if number doesn't match. + if !matchNumbers(call.From.User, tr.AllowedNumbers) { + if !opt.Filtered(tr, TrunkFilteredCallingNumberDisallowed) { + continue + } + } + if !matchAddrMasks(call.SourceIp, call.From.Host, tr.AllowedAddresses) { + if !opt.Filtered(tr, TrunkFilteredSourceAddressDisallowed) { + continue + } + } + if len(tr.Numbers) == 0 { + // Default/wildcard trunk. + defaultTrunkPrev = defaultTrunk + defaultTrunk = tr + result.DefaultTrunkCount++ + } else { + for _, num := range tr.Numbers { + if num == call.To.User || NormalizeNumber(num) == calledNorm { + // Trunk specific to the number. + if selectedTrunk != nil { + opt.Conflict(selectedTrunk, tr, TrunkConflictCalledNumber) + if opt.AllowConflicts { + // This path is unreachable, since we pick the first trunk. Kept for completeness. + continue + } + return nil, twirp.NewErrorf(twirp.FailedPrecondition, "Multiple SIP Trunks matched for %q", call.To.User) + } + selectedTrunk = tr + if opt.AllowConflicts { + // Pick the first match as soon as it's found. We don't care about conflicts. + result.Trunk = selectedTrunk + result.MatchType = TrunkMatchSpecific + return result, nil + } + // Keep searching! We want to know if there are any conflicting Trunk definitions. + } else { + opt.Filtered(tr, TrunkFilteredCalledNumberDisallowed) + } + } + } + } + + if selectedTrunk != nil { + result.Trunk = selectedTrunk + result.MatchType = TrunkMatchSpecific + return result, nil + } + if result.DefaultTrunkCount > 1 { + opt.Conflict(defaultTrunk, defaultTrunkPrev, TrunkConflictDefault) + if !opt.AllowConflicts { + return nil, twirp.NewErrorf(twirp.FailedPrecondition, "Multiple default SIP Trunks matched for %q", call.To.User) + } + } + if defaultTrunk != nil { + result.Trunk = defaultTrunk + result.MatchType = TrunkMatchDefault + } + return result, nil +} + type matchTrunkOpts struct { AllowConflicts bool Filtered TrunkFilteredFunc @@ -541,78 +658,11 @@ func WithTrunkReplace(fnc TrunkReplaceFunc) MatchTrunkOpt { // MatchTrunkIter finds a SIP Trunk definition matching the request. // Returns nil if no rules matched or an error if there are conflicting definitions. func MatchTrunkIter(it iters.Iter[*livekit.SIPInboundTrunkInfo], call *rpc.SIPCall, opts ...MatchTrunkOpt) (*livekit.SIPInboundTrunkInfo, error) { - defer it.Close() - var opt matchTrunkOpts - for _, fnc := range opts { - fnc(&opt) - } - opt.defaults() - var ( - selectedTrunk *livekit.SIPInboundTrunkInfo - defaultTrunk *livekit.SIPInboundTrunkInfo - defaultTrunkPrev *livekit.SIPInboundTrunkInfo - defaultTrunkCnt int // to error in case there are multiple ones - ) - calledNorm := NormalizeNumber(call.To.User) - for { - tr, err := it.Next() - if err == io.EOF { - break - } else if err != nil { - return nil, err - } - tr = opt.Replace(tr) - // Do not consider it if number doesn't match. - if !matchNumbers(call.From.User, tr.AllowedNumbers) { - if !opt.Filtered(tr, TrunkFilteredCallingNumberDisallowed) { - continue - } - } - if !matchAddrMasks(call.SourceIp, call.From.Host, tr.AllowedAddresses) { - if !opt.Filtered(tr, TrunkFilteredSourceAddressDisallowed) { - continue - } - } - if len(tr.Numbers) == 0 { - // Default/wildcard trunk. - defaultTrunkPrev = defaultTrunk - defaultTrunk = tr - defaultTrunkCnt++ - } else { - for _, num := range tr.Numbers { - if num == call.To.User || NormalizeNumber(num) == calledNorm { - // Trunk specific to the number. - if selectedTrunk != nil { - opt.Conflict(selectedTrunk, tr, TrunkConflictCalledNumber) - if opt.AllowConflicts { - // This path is unreachable, since we pick the first trunk. Kept for completeness. - continue - } - return nil, twirp.NewErrorf(twirp.FailedPrecondition, "Multiple SIP Trunks matched for %q", call.To.User) - } - selectedTrunk = tr - if opt.AllowConflicts { - // Pick the first match as soon as it's found. We don't care about conflicts. - return selectedTrunk, nil - } - // Keep searching! We want to know if there are any conflicting Trunk definitions. - } else { - opt.Filtered(tr, TrunkFilteredCalledNumberDisallowed) - } - } - } - } - if selectedTrunk != nil { - return selectedTrunk, nil - } - if defaultTrunkCnt > 1 { - opt.Conflict(defaultTrunk, defaultTrunkPrev, TrunkConflictDefault) - if !opt.AllowConflicts { - return nil, twirp.NewErrorf(twirp.FailedPrecondition, "Multiple default SIP Trunks matched for %q", call.To.User) - } + result, err := MatchTrunkIterDetailed(it, call, opts...) + if err != nil { + return nil, err } - // Could still be nil here. - return defaultTrunk, nil + return result.Trunk, nil } // MatchDispatchRule finds the best dispatch rule matching the request parameters. Returns an error if no rule matched. diff --git a/sip/sip_test.go b/sip/sip_test.go index ba5fdc5b9..7b163d151 100644 --- a/sip/sip_test.go +++ b/sip/sip_test.go @@ -824,3 +824,140 @@ func TestMatchMasks(t *testing.T) { }) } } + +func TestMatchTrunkIterDetailed(t *testing.T) { + for _, c := range []struct { + name string + trunks []*livekit.SIPInboundTrunkInfo + expMatchType TrunkMatchType + expTrunkID string + expDefaultCount int + expErr bool + from string + to string + src string + host string + }{ + { + name: "empty", + trunks: nil, + expMatchType: TrunkMatchEmpty, + expTrunkID: "", + expErr: false, + }, + { + name: "one wildcard", + trunks: []*livekit.SIPInboundTrunkInfo{ + {SipTrunkId: "aaa"}, + }, + expMatchType: TrunkMatchDefault, + expTrunkID: "aaa", + expDefaultCount: 1, + expErr: false, + }, + { + name: "specific match", + trunks: []*livekit.SIPInboundTrunkInfo{ + {SipTrunkId: "aaa", Numbers: []string{sipNumber2}}, + }, + expMatchType: TrunkMatchSpecific, + expTrunkID: "aaa", + expDefaultCount: 0, + expErr: false, + }, + { + name: "no match with trunks", + trunks: []*livekit.SIPInboundTrunkInfo{ + {SipTrunkId: "aaa", Numbers: []string{sipNumber3}}, + }, + expMatchType: TrunkMatchNone, + expTrunkID: "", + expDefaultCount: 0, + expErr: false, + }, + { + name: "multiple defaults", + trunks: []*livekit.SIPInboundTrunkInfo{ + {SipTrunkId: "aaa"}, + {SipTrunkId: "bbb"}, + }, + expMatchType: TrunkMatchDefault, + expTrunkID: "aaa", + expDefaultCount: 2, + expErr: true, + }, + { + name: "specific over default", + trunks: []*livekit.SIPInboundTrunkInfo{ + {SipTrunkId: "aaa"}, + {SipTrunkId: "bbb", Numbers: []string{sipNumber2}}, + }, + expMatchType: TrunkMatchSpecific, + expTrunkID: "bbb", + expDefaultCount: 1, + expErr: false, + }, + { + name: "multiple specific", + trunks: []*livekit.SIPInboundTrunkInfo{ + {SipTrunkId: "aaa", Numbers: []string{sipNumber2}}, + {SipTrunkId: "bbb", Numbers: []string{sipNumber2}}, + }, + expMatchType: TrunkMatchSpecific, + expTrunkID: "aaa", + expDefaultCount: 0, + expErr: true, + }, + } { + c := c + t.Run(c.name, func(t *testing.T) { + from, to, src, host := c.from, c.to, c.src, c.host + if from == "" { + from = sipNumber1 + } + if to == "" { + to = sipNumber2 + } + if src == "" { + src = "1.1.1.1" + } + if host == "" { + host = "sip.example.com" + } + call := &rpc.SIPCall{ + SourceIp: src, + From: &livekit.SIPUri{ + User: from, + Host: host, + }, + To: &livekit.SIPUri{ + User: to, + }, + } + call.Address = call.To + + var conflicts []string + result, err := MatchTrunkIterDetailed(iters.Slice(c.trunks), call, WithTrunkConflict(func(t1, t2 *livekit.SIPInboundTrunkInfo, reason TrunkConflictReason) { + conflicts = append(conflicts, fmt.Sprintf("%v: %v vs %v", reason, t1.SipTrunkId, t2.SipTrunkId)) + })) + + if c.expErr { + require.Error(t, err) + require.NotEmpty(t, conflicts, "expected conflicts but got none") + } else { + require.NoError(t, err) + require.Empty(t, conflicts, "unexpected conflicts: %v", conflicts) + + if c.expTrunkID == "" { + require.Nil(t, result.Trunk) + } else { + require.NotNil(t, result.Trunk) + require.Equal(t, c.expTrunkID, result.Trunk.SipTrunkId) + } + + require.Equal(t, c.expMatchType, result.MatchType) + require.Equal(t, c.expDefaultCount, result.DefaultTrunkCount) + } + }) + } +} From f967386bf2f7c072ab5625c294c483fc9113db07 Mon Sep 17 00:00:00 2001 From: Nishad Musthafa Date: Tue, 10 Jun 2025 14:42:10 -0700 Subject: [PATCH 2/3] Dropping the Iter from the function name --- sip/sip.go | 6 +++--- sip/sip_test.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/sip/sip.go b/sip/sip.go index db8836169..f1f1d9f13 100644 --- a/sip/sip.go +++ b/sip/sip.go @@ -483,8 +483,8 @@ func MatchTrunk(trunks []*livekit.SIPInboundTrunkInfo, call *rpc.SIPCall, opts . return MatchTrunkIter(iters.Slice(trunks), call, opts...) } -// MatchTrunkIterDetailed is like MatchTrunkIter but returns detailed match information -func MatchTrunkIterDetailed(it iters.Iter[*livekit.SIPInboundTrunkInfo], call *rpc.SIPCall, opts ...MatchTrunkOpt) (*TrunkMatchResult, error) { +// MatchTrunkDetailed is like MatchTrunkIter but returns detailed match information +func MatchTrunkDetailed(it iters.Iter[*livekit.SIPInboundTrunkInfo], call *rpc.SIPCall, opts ...MatchTrunkOpt) (*TrunkMatchResult, error) { defer it.Close() var opt matchTrunkOpts for _, fnc := range opts { @@ -658,7 +658,7 @@ func WithTrunkReplace(fnc TrunkReplaceFunc) MatchTrunkOpt { // MatchTrunkIter finds a SIP Trunk definition matching the request. // Returns nil if no rules matched or an error if there are conflicting definitions. func MatchTrunkIter(it iters.Iter[*livekit.SIPInboundTrunkInfo], call *rpc.SIPCall, opts ...MatchTrunkOpt) (*livekit.SIPInboundTrunkInfo, error) { - result, err := MatchTrunkIterDetailed(it, call, opts...) + result, err := MatchTrunkDetailed(it, call, opts...) if err != nil { return nil, err } diff --git a/sip/sip_test.go b/sip/sip_test.go index 7b163d151..c8a11b5ee 100644 --- a/sip/sip_test.go +++ b/sip/sip_test.go @@ -825,7 +825,7 @@ func TestMatchMasks(t *testing.T) { } } -func TestMatchTrunkIterDetailed(t *testing.T) { +func TestMatchTrunkDetailed(t *testing.T) { for _, c := range []struct { name string trunks []*livekit.SIPInboundTrunkInfo @@ -937,7 +937,7 @@ func TestMatchTrunkIterDetailed(t *testing.T) { call.Address = call.To var conflicts []string - result, err := MatchTrunkIterDetailed(iters.Slice(c.trunks), call, WithTrunkConflict(func(t1, t2 *livekit.SIPInboundTrunkInfo, reason TrunkConflictReason) { + result, err := MatchTrunkDetailed(iters.Slice(c.trunks), call, WithTrunkConflict(func(t1, t2 *livekit.SIPInboundTrunkInfo, reason TrunkConflictReason) { conflicts = append(conflicts, fmt.Sprintf("%v: %v vs %v", reason, t1.SipTrunkId, t2.SipTrunkId)) })) From c1ff3fcee72ddd3abe1144d950afa421568714ee Mon Sep 17 00:00:00 2001 From: Nishad Musthafa Date: Tue, 10 Jun 2025 14:53:08 -0700 Subject: [PATCH 3/3] Adding changeset --- .changeset/chubby-insects-cut.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/chubby-insects-cut.md diff --git a/.changeset/chubby-insects-cut.md b/.changeset/chubby-insects-cut.md new file mode 100644 index 000000000..a64d6d5db --- /dev/null +++ b/.changeset/chubby-insects-cut.md @@ -0,0 +1,5 @@ +--- +"github.com/livekit/protocol": patch +--- + +adding detailed responses to the trunk match logic which can help with decisions on blocking