From c6e467e903c3d8dffad506c2e4c6bf220d78fcc0 Mon Sep 17 00:00:00 2001 From: Denys Smirnov Date: Wed, 15 Nov 2023 16:35:05 +0200 Subject: [PATCH] Initial SIP Dispatch Rules implementation (#2241) --- pkg/service/ioservice.go | 25 +- pkg/service/ioservice_sip.go | 329 +++++++++++++++++++++++++ pkg/service/ioservice_sip_test.go | 394 ++++++++++++++++++++++++++++++ 3 files changed, 725 insertions(+), 23 deletions(-) create mode 100644 pkg/service/ioservice_sip.go create mode 100644 pkg/service/ioservice_sip_test.go diff --git a/pkg/service/ioservice.go b/pkg/service/ioservice.go index 2d9070cca..0149e7af6 100644 --- a/pkg/service/ioservice.go +++ b/pkg/service/ioservice.go @@ -17,15 +17,15 @@ package service import ( "context" "errors" - "fmt" "google.golang.org/protobuf/types/known/emptypb" - "github.com/livekit/livekit-server/pkg/telemetry" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" "github.com/livekit/protocol/rpc" "github.com/livekit/psrpc" + + "github.com/livekit/livekit-server/pkg/telemetry" ) type IOInfoService struct { @@ -215,27 +215,6 @@ func (s *IOInfoService) UpdateIngressState(ctx context.Context, req *rpc.UpdateI return &emptypb.Empty{}, nil } -func (s *IOInfoService) EvaluateSIPDispatchRules(ctx context.Context, req *rpc.EvaluateSIPDispatchRulesRequest) (*rpc.EvaluateSIPDispatchRulesResponse, error) { - dispatchRules, err := s.ss.ListSIPDispatchRule(ctx) - if err != nil { - return nil, err - } - - if len(dispatchRules) == 0 { - return nil, fmt.Errorf("No SIP Dispatch Rule Found") - } - - directDispatchRule := dispatchRules[0].Rule.GetDispatchRuleDirect() - if directDispatchRule == nil { - return nil, fmt.Errorf("No SIP Direct Dispatch Rule Found") - } - - return &rpc.EvaluateSIPDispatchRulesResponse{ - RoomName: directDispatchRule.RoomName, - ParticipantIdentity: req.CallingNumber, - }, nil -} - func (s *IOInfoService) GetSIPTrunkAuthentication(ctx context.Context, req *rpc.GetSIPTrunkAuthenticationRequest) (*rpc.GetSIPTrunkAuthenticationResponse, error) { return nil, nil } diff --git a/pkg/service/ioservice_sip.go b/pkg/service/ioservice_sip.go new file mode 100644 index 000000000..715dea823 --- /dev/null +++ b/pkg/service/ioservice_sip.go @@ -0,0 +1,329 @@ +package service + +import ( + "context" + "fmt" + "math" + "regexp" + "sort" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" + "github.com/livekit/protocol/rpc" +) + +// sipRulePriority returns sorting priority for dispatch rules. Lower value means higher priority. +func sipRulePriority(info *livekit.SIPDispatchRuleInfo) int32 { + // In all these cases, prefer pin-protected rules. + // Thus, the order will be the following: + // - 0: Direct or Pin (both pin-protected) + // - 1: Individual (pin-protected) + // - 100: Direct (open) + // - 101: Individual (open) + const ( + last = math.MaxInt32 + ) + // TODO: Maybe allow setting specific priorities for dispatch rules? + switch rule := info.GetRule().GetRule().(type) { + default: + return last + case *livekit.SIPDispatchRule_DispatchRuleDirect: + if rule.DispatchRuleDirect.GetPin() != "" { + return 0 + } + return 100 + case *livekit.SIPDispatchRule_DispatchRulePin: + // TODO: If we assume that Pin is optional, this rule type is very similar to Direct. Could remove it? + return 0 + case *livekit.SIPDispatchRule_DispatchRuleIndividual: + if rule.DispatchRuleIndividual.GetPin() != "" { + return 1 + } + return 101 + } +} + +// sipSortRules predictably sorts dispatch rules by priority (first one is highest). +func sipSortRules(rules []*livekit.SIPDispatchRuleInfo) { + sort.Slice(rules, func(i, j int) bool { + p1, p2 := sipRulePriority(rules[i]), sipRulePriority(rules[j]) + if p1 < p2 { + return true + } else if p1 > p2 { + return false + } + // For predictable sorting order. + room1, _, _ := sipGetPinAndRoom(rules[i]) + room2, _, _ := sipGetPinAndRoom(rules[j]) + return room1 < room2 + }) +} + +// sipSelectDispatch takes a list of dispatch rules, and takes the decision which one should be selected. +// It returns an error if there are conflicting rules. Returns nil if no rules match. +func sipSelectDispatch(rules []*livekit.SIPDispatchRuleInfo, req *rpc.EvaluateSIPDispatchRulesRequest) (*livekit.SIPDispatchRuleInfo, error) { + if len(rules) == 0 { + return nil, nil + } + // Sorting will do the selection for us. We already filtered out irrelevant ones in matchSIPDispatchRule. + sipSortRules(rules) + byPin := make(map[string]*livekit.SIPDispatchRuleInfo) + var ( + pinRule *livekit.SIPDispatchRuleInfo + openRule *livekit.SIPDispatchRuleInfo + ) + openCnt := 0 + for _, r := range rules { + _, pin, err := sipGetPinAndRoom(r) + if err != nil { + return nil, err + } + if pin == "" { + openRule = r // last one + openCnt++ + } else if r2 := byPin[pin]; r2 != nil { + return nil, fmt.Errorf("Conflicting SIP Dispatch Rules: Same PIN for %q and %q", + r.SipDispatchRuleId, r2.SipDispatchRuleId) + } else { + byPin[pin] = r + // Pick the first one with a Pin. If Pin was provided in the request, we already filtered the right rules. + // If not, this rule will just be used to send RequestPin=true flag. + if pinRule == nil { + pinRule = r + } + } + } + if req.GetPin() != "" { + // If it's still nil that's fine. We will report "no rules matched" later. + return pinRule, nil + } + if pinRule != nil { + return pinRule, nil + } + if openCnt > 1 { + return nil, fmt.Errorf("Conflicting SIP Dispatch Rules: Matched %d open rules for %q", openCnt, req.CallingNumber) + } + return openRule, nil +} + +// sipGetPinAndRoom returns a room name/prefix and the pin for a dispatch rule. Just a convenience wrapper. +func sipGetPinAndRoom(info *livekit.SIPDispatchRuleInfo) (room, pin string, err error) { + // TODO: Could probably add methods on SIPDispatchRuleInfo struct instead. + switch rule := info.GetRule().GetRule().(type) { + default: + return "", "", fmt.Errorf("Unsupported SIP Dispatch Rule: %T", rule) + case *livekit.SIPDispatchRule_DispatchRuleDirect: + pin = rule.DispatchRuleDirect.GetPin() + room = rule.DispatchRuleDirect.GetRoomName() + case *livekit.SIPDispatchRule_DispatchRulePin: + pin = rule.DispatchRulePin.GetPin() + room = rule.DispatchRulePin.GetRoomName() + case *livekit.SIPDispatchRule_DispatchRuleIndividual: + pin = rule.DispatchRuleIndividual.GetPin() + room = rule.DispatchRuleIndividual.GetRoomPrefix() + } + return room, pin, nil +} + +// sipMatchTrunk finds a SIP Trunk definition matching the request. +// Returns nil if no rules matched or an error if there are conflicting definitions. +func sipMatchTrunk(trunks []*livekit.SIPTrunkInfo, req *rpc.EvaluateSIPDispatchRulesRequest) (*livekit.SIPTrunkInfo, error) { + var ( + selectedTrunk *livekit.SIPTrunkInfo + defaultTrunk *livekit.SIPTrunkInfo + defaultTrunkCnt int // to error in case there are multiple ones + ) + for _, tr := range trunks { + // Do not consider it if regexp doesn't match. + matches := len(tr.InboundNumbersRegex) == 0 + for _, reStr := range tr.InboundNumbersRegex { + // TODO: we should cache it + re, err := regexp.Compile(reStr) + if err != nil { + logger.Errorw("cannot parse SIP trunk regexp", err, "trunkID", tr.SipTrunkId) + continue + } + if re.MatchString(req.CallingNumber) { + matches = true + break + } + } + if !matches { + continue + } + if tr.OutboundNumber == "" { + // Default/wildcard trunk. + defaultTrunk = tr + defaultTrunkCnt++ + } else if tr.OutboundNumber == req.CalledNumber { + // Trunk specific to the number. + if selectedTrunk != nil { + return nil, fmt.Errorf("Multiple SIP Trunks matched for %q", req.CalledNumber) + } + selectedTrunk = tr + // Keep searching! We want to know if there are any conflicting Trunk definitions. + } + } + if selectedTrunk != nil { + return selectedTrunk, nil + } + if defaultTrunkCnt > 1 { + return nil, fmt.Errorf("Multiple default SIP Trunks matched for %q", req.CalledNumber) + } + // Could still be nil here. + return defaultTrunk, nil +} + +// sipMatchDispatchRule finds the best dispatch rule matching the request parameters. Returns an error if no rule matched. +// Trunk parameter can be nil, in which case only wildcard dispatch rules will be effective (ones without Trunk IDs). +func sipMatchDispatchRule(trunk *livekit.SIPTrunkInfo, rules []*livekit.SIPDispatchRuleInfo, req *rpc.EvaluateSIPDispatchRulesRequest) (*livekit.SIPDispatchRuleInfo, error) { + // Trunk can still be nil here in case none matched or were defined. + // This is still fine, but only in case we'll match exactly one wildcard dispatch rule. + if len(rules) == 0 { + return nil, fmt.Errorf("No SIP Dispatch Rules defined") + } + // We split the matched dispatch rules into two sets: specific and default (aka wildcard). + // First, attempt to match any of the specific rules, where we did match the Trunk ID. + // If nothing matches there - fallback to default/wildcard rules, where no Trunk IDs were mentioned. + var ( + specificRules []*livekit.SIPDispatchRuleInfo + defaultRules []*livekit.SIPDispatchRuleInfo + ) + // TODO: Apart from Pin, it would be nice to have a NoPin flag. + // The way it would work is that we will first list the rules and figure out if at least one has a Pin required. + // If it does, we will immediately respond with RequestPin=true. Now, on the SIP bridge side, we will run + // audio prompt asking for a Pin. The user will have an options to skip the pin (e.g. press #) and only try + // to match no-ping rooms. This will be very useful if only 1 number is available and has to route to both + // private and public rooms. + noPin := false + sentPin := req.GetPin() + for _, info := range rules { + _, rulePin, err := sipGetPinAndRoom(info) + if err != nil { + logger.Errorw("Invalid SIP Dispatch Rule", err, "dispatchRuleID", info.SipDispatchRuleId) + continue + } + // Filter heavily on the Pin, so that only relevant rules remain. + if noPin { + if rulePin != "" { + // Skip pin-protected rules if no pin mode requested. + continue + } + } else if sentPin != "" { + if rulePin == "" { + // Pin already sent, skip non-pin-protected rules. + continue + } + if sentPin != rulePin { + // Pin doesn't match. Don't return an error here, just wait for other rule to match (or none at all). + // Note that we will NOT match non-pin-protected rules, thus it will not fallback to open rules. + continue + } + } + if len(info.TrunkIds) == 0 { + // Default/wildcard dispatch rule. + defaultRules = append(defaultRules, info) + continue + } + // Specific dispatch rules. Require a Trunk associated with the number. + if trunk == nil { + continue + } + matches := false + for _, id := range info.TrunkIds { + if id == trunk.SipTrunkId { + matches = true + break + } + } + if !matches { + continue + } + specificRules = append(specificRules, info) + } + best, err := sipSelectDispatch(specificRules, req) + if err != nil { + return nil, err + } else if best != nil { + return best, nil + } + best, err = sipSelectDispatch(defaultRules, req) + if err != nil { + return nil, err + } else if best != nil { + return best, nil + } + if trunk == nil { + return nil, fmt.Errorf("No SIP Trunk or Dispatch Rules matched for %q", req.CalledNumber) + } + return nil, fmt.Errorf("No SIP Dispatch Rules matched for %q", req.CalledNumber) +} + +// matchSIPTrunk finds a SIP Trunk definition matching the request. +// Returns nil if no rules matched or an error if there are conflicting definitions. +func (s *IOInfoService) matchSIPTrunk(ctx context.Context, req *rpc.EvaluateSIPDispatchRulesRequest) (*livekit.SIPTrunkInfo, error) { + trunks, err := s.ss.ListSIPTrunk(ctx) + if err != nil { + return nil, err + } + return sipMatchTrunk(trunks, req) +} + +// matchSIPDispatchRule finds the best dispatch rule matching the request parameters. Returns an error if no rule matched. +// Trunk parameter can be nil, in which case only wildcard dispatch rules will be effective (ones without Trunk IDs). +func (s *IOInfoService) matchSIPDispatchRule(ctx context.Context, trunk *livekit.SIPTrunkInfo, req *rpc.EvaluateSIPDispatchRulesRequest) (*livekit.SIPDispatchRuleInfo, error) { + // Trunk can still be nil here in case none matched or were defined. + // This is still fine, but only in case we'll match exactly one wildcard dispatch rule. + rules, err := s.ss.ListSIPDispatchRule(ctx) + if err != nil { + return nil, err + } + return sipMatchDispatchRule(trunk, rules, req) +} + +func (s *IOInfoService) EvaluateSIPDispatchRules(ctx context.Context, req *rpc.EvaluateSIPDispatchRulesRequest) (*rpc.EvaluateSIPDispatchRulesResponse, error) { + trunk, err := s.matchSIPTrunk(ctx, req) + if err != nil { + return nil, err + } + best, err := s.matchSIPDispatchRule(ctx, trunk, req) + if err != nil { + return nil, err + } + sentPin := req.GetPin() + + from := req.CallingNumber + if best.HidePhoneNumber { + // TODO: Decide on the phone masking format. + // Maybe keep regional code, but mask all but 4 last digits? + from = from[len(from)-4:] + } + fromName := "Phone " + from + + room, rulePin, err := sipGetPinAndRoom(best) + if err != nil { + return nil, err + } + if rulePin != "" { + if sentPin == "" { + return &rpc.EvaluateSIPDispatchRulesResponse{ + RequestPin: true, + }, nil + } + if rulePin != sentPin { + // This should never happen in practice, because matchSIPDispatchRule should remove rules with the wrong pin. + return nil, fmt.Errorf("Incorrect PIN for SIP room") + } + } else { + // Pin was sent, but room doesn't require one. Assume user accidentally pressed phone button. + } + switch rule := best.GetRule().GetRule().(type) { + case *livekit.SIPDispatchRule_DispatchRuleIndividual: + // TODO: Decide on the suffix. Do we need to escape specific characters? + room = rule.DispatchRuleIndividual.GetRoomPrefix() + from + } + return &rpc.EvaluateSIPDispatchRulesResponse{ + RoomName: room, + ParticipantIdentity: fromName, + }, nil +} diff --git a/pkg/service/ioservice_sip_test.go b/pkg/service/ioservice_sip_test.go new file mode 100644 index 000000000..4b5ada20f --- /dev/null +++ b/pkg/service/ioservice_sip_test.go @@ -0,0 +1,394 @@ +package service + +import ( + "fmt" + "testing" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/rpc" + "github.com/stretchr/testify/require" +) + +const ( + sipNumber1 = "1111 1111" + sipNumber2 = "2222 2222" + sipNumber3 = "3333 3333" + sipTrunkID1 = "aaa" + sipTrunkID2 = "bbb" +) + +func TestSIPMatchTrunk(t *testing.T) { + cases := []struct { + name string + trunks []*livekit.SIPTrunkInfo + exp int + expErr bool + }{ + { + name: "empty", + trunks: nil, + exp: -1, // no error; nil result + }, + { + name: "one wildcard", + trunks: []*livekit.SIPTrunkInfo{ + {SipTrunkId: "aaa"}, + }, + exp: 0, + }, + { + name: "matching", + trunks: []*livekit.SIPTrunkInfo{ + {SipTrunkId: "aaa", OutboundNumber: sipNumber2}, + }, + exp: 0, + }, + { + name: "matching regexp", + trunks: []*livekit.SIPTrunkInfo{ + {SipTrunkId: "aaa", OutboundNumber: sipNumber2, InboundNumbersRegex: []string{`^\d+ \d+$`}}, + }, + exp: 0, + }, + { + name: "not matching", + trunks: []*livekit.SIPTrunkInfo{ + {SipTrunkId: "aaa", OutboundNumber: sipNumber3}, + }, + exp: -1, + }, + { + name: "not matching regexp", + trunks: []*livekit.SIPTrunkInfo{ + {SipTrunkId: "aaa", OutboundNumber: sipNumber2, InboundNumbersRegex: []string{`^\d+$`}}, + }, + exp: -1, + }, + { + name: "one match", + trunks: []*livekit.SIPTrunkInfo{ + {SipTrunkId: "aaa", OutboundNumber: sipNumber3}, + {SipTrunkId: "bbb", OutboundNumber: sipNumber2}, + }, + exp: 1, + }, + { + name: "many matches", + trunks: []*livekit.SIPTrunkInfo{ + {SipTrunkId: "aaa", OutboundNumber: sipNumber3}, + {SipTrunkId: "bbb", OutboundNumber: sipNumber2}, + {SipTrunkId: "ccc", OutboundNumber: sipNumber2}, + }, + expErr: true, + }, + { + name: "many matches default", + trunks: []*livekit.SIPTrunkInfo{ + {SipTrunkId: "aaa", OutboundNumber: sipNumber3}, + {SipTrunkId: "bbb"}, + {SipTrunkId: "ccc", OutboundNumber: sipNumber2}, + {SipTrunkId: "ddd"}, + }, + exp: 2, + }, + { + name: "regexp", + trunks: []*livekit.SIPTrunkInfo{ + {SipTrunkId: "aaa", OutboundNumber: sipNumber3}, + {SipTrunkId: "bbb", OutboundNumber: sipNumber2}, + {SipTrunkId: "ccc", OutboundNumber: sipNumber2, InboundNumbersRegex: []string{`^\d+$`}}, + }, + exp: 1, + }, + { + name: "multiple defaults", + trunks: []*livekit.SIPTrunkInfo{ + {SipTrunkId: "aaa", OutboundNumber: sipNumber3}, + {SipTrunkId: "bbb"}, + {SipTrunkId: "ccc"}, + }, + expErr: true, + }, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + req := &rpc.EvaluateSIPDispatchRulesRequest{ + CallingNumber: sipNumber1, + CalledNumber: sipNumber2, + } + got, err := sipMatchTrunk(c.trunks, req) + if c.expErr { + require.Error(t, err) + require.Nil(t, got) + t.Log(err) + } else { + var exp *livekit.SIPTrunkInfo + if c.exp >= 0 { + exp = c.trunks[c.exp] + } + require.NoError(t, err) + require.Equal(t, exp, got) + } + }) + } +} + +func newSIPTrunkDispatch() *livekit.SIPTrunkInfo { + return &livekit.SIPTrunkInfo{ + SipTrunkId: sipTrunkID1, + OutboundNumber: sipNumber2, + } +} + +func newSIPReqDispatch(pin string, noPin bool) *rpc.EvaluateSIPDispatchRulesRequest { + return &rpc.EvaluateSIPDispatchRulesRequest{ + CallingNumber: sipNumber1, + CalledNumber: sipNumber2, + Pin: pin, + //NoPin: noPin, // TODO + } +} + +func newDirectDispatch(room, pin string) *livekit.SIPDispatchRule { + return &livekit.SIPDispatchRule{ + Rule: &livekit.SIPDispatchRule_DispatchRuleDirect{ + DispatchRuleDirect: &livekit.SIPDispatchRuleDirect{ + RoomName: room, Pin: pin, + }, + }, + } +} + +func newIndividualDispatch(roomPref, pin string) *livekit.SIPDispatchRule { + return &livekit.SIPDispatchRule{ + Rule: &livekit.SIPDispatchRule_DispatchRuleIndividual{ + DispatchRuleIndividual: &livekit.SIPDispatchRuleIndividual{ + RoomPrefix: roomPref, Pin: pin, + }, + }, + } +} + +func TestSIPMatchDispatchRule(t *testing.T) { + cases := []struct { + name string + trunk *livekit.SIPTrunkInfo + rules []*livekit.SIPDispatchRuleInfo + reqPin string + noPin bool + exp int + expErr bool + }{ + // These cases just validate that no rules produce an error. + { + name: "empty", + trunk: nil, + rules: nil, + expErr: true, + }, + { + name: "only trunk", + trunk: newSIPTrunkDispatch(), + rules: nil, + expErr: true, + }, + // Default rules should work even if no trunk is defined. + { + name: "one rule/no trunk", + trunk: nil, + rules: []*livekit.SIPDispatchRuleInfo{ + {TrunkIds: nil, Rule: newDirectDispatch("sip", "")}, + }, + exp: 0, + }, + // Default rule should work with a trunk too. + { + name: "one rule/default trunk", + trunk: newSIPTrunkDispatch(), + rules: []*livekit.SIPDispatchRuleInfo{ + {TrunkIds: nil, Rule: newDirectDispatch("sip", "")}, + }, + exp: 0, + }, + // Rule matching the trunk should be selected. + { + name: "one rule/specific trunk", + trunk: newSIPTrunkDispatch(), + rules: []*livekit.SIPDispatchRuleInfo{ + {TrunkIds: []string{sipTrunkID1, sipTrunkID2}, Rule: newDirectDispatch("sip", "")}, + }, + exp: 0, + }, + // Rule NOT matching the trunk should NOT be selected. + { + name: "one rule/wrong trunk", + trunk: newSIPTrunkDispatch(), + rules: []*livekit.SIPDispatchRuleInfo{ + {TrunkIds: []string{"zzz"}, Rule: newDirectDispatch("sip", "")}, + }, + expErr: true, + }, + // Direct rule with a pin should be selected, even if no pin is provided. + { + name: "direct pin/correct", + trunk: newSIPTrunkDispatch(), + rules: []*livekit.SIPDispatchRuleInfo{ + {TrunkIds: []string{sipTrunkID1}, Rule: newDirectDispatch("sip", "123")}, + {TrunkIds: []string{sipTrunkID2}, Rule: newDirectDispatch("sip", "456")}, + }, + reqPin: "123", + exp: 0, + }, + // Direct rule with a pin should reject wrong pin. + { + name: "direct pin/wrong", + trunk: newSIPTrunkDispatch(), + rules: []*livekit.SIPDispatchRuleInfo{ + {TrunkIds: []string{sipTrunkID1}, Rule: newDirectDispatch("sip", "123")}, + {TrunkIds: []string{sipTrunkID2}, Rule: newDirectDispatch("sip", "456")}, + }, + reqPin: "zzz", + expErr: true, + }, + // Multiple direct rules with the same pin should result in an error. + { + name: "direct pin/conflict", + trunk: newSIPTrunkDispatch(), + rules: []*livekit.SIPDispatchRuleInfo{ + {TrunkIds: []string{sipTrunkID1}, Rule: newDirectDispatch("sip1", "123")}, + {TrunkIds: []string{sipTrunkID1, sipTrunkID2}, Rule: newDirectDispatch("sip2", "123")}, + }, + reqPin: "123", + expErr: true, + }, + // Multiple direct rules with the same pin on different trunks are ok. + { + name: "direct pin/no conflict on different trunk", + trunk: newSIPTrunkDispatch(), + rules: []*livekit.SIPDispatchRuleInfo{ + {TrunkIds: []string{sipTrunkID1}, Rule: newDirectDispatch("sip1", "123")}, + {TrunkIds: []string{sipTrunkID2}, Rule: newDirectDispatch("sip2", "123")}, + }, + reqPin: "123", + exp: 0, + }, + // Specific direct rules should take priority over default direct rules. + { + name: "direct pin/default and specific", + trunk: newSIPTrunkDispatch(), + rules: []*livekit.SIPDispatchRuleInfo{ + {TrunkIds: nil, Rule: newDirectDispatch("sip1", "123")}, + {TrunkIds: []string{sipTrunkID1}, Rule: newDirectDispatch("sip2", "123")}, + }, + reqPin: "123", + exp: 1, + }, + // Specific direct rules should take priority over default direct rules. No pin. + { + name: "direct/default and specific", + trunk: newSIPTrunkDispatch(), + rules: []*livekit.SIPDispatchRuleInfo{ + {TrunkIds: nil, Rule: newDirectDispatch("sip1", "")}, + {TrunkIds: []string{sipTrunkID1}, Rule: newDirectDispatch("sip2", "")}, + }, + exp: 1, + }, + // Specific direct rules should take priority over default direct rules. One with pin, other without. + { + name: "direct/default and specific/mixed 1", + trunk: newSIPTrunkDispatch(), + rules: []*livekit.SIPDispatchRuleInfo{ + {TrunkIds: nil, Rule: newDirectDispatch("sip1", "123")}, + {TrunkIds: []string{sipTrunkID1}, Rule: newDirectDispatch("sip2", "")}, + }, + exp: 1, + }, + { + name: "direct/default and specific/mixed 2", + trunk: newSIPTrunkDispatch(), + rules: []*livekit.SIPDispatchRuleInfo{ + {TrunkIds: nil, Rule: newDirectDispatch("sip1", "")}, + {TrunkIds: []string{sipTrunkID1}, Rule: newDirectDispatch("sip2", "123")}, + }, + exp: 1, + }, + // Multiple default direct rules are not allowed. + { + name: "direct/multiple defaults", + trunk: newSIPTrunkDispatch(), + rules: []*livekit.SIPDispatchRuleInfo{ + {TrunkIds: nil, Rule: newDirectDispatch("sip1", "")}, + {TrunkIds: nil, Rule: newDirectDispatch("sip2", "")}, + }, + expErr: true, + }, + // Cannot use both direct and individual rules with the same pin setup. + { + name: "direct vs individual/private", + trunk: newSIPTrunkDispatch(), + rules: []*livekit.SIPDispatchRuleInfo{ + {TrunkIds: nil, Rule: newIndividualDispatch("pref_", "123")}, + {TrunkIds: nil, Rule: newDirectDispatch("sip", "123")}, + }, + expErr: true, + }, + { + name: "direct vs individual/open", + trunk: newSIPTrunkDispatch(), + rules: []*livekit.SIPDispatchRuleInfo{ + {TrunkIds: nil, Rule: newIndividualDispatch("pref_", "")}, + {TrunkIds: nil, Rule: newDirectDispatch("sip", "")}, + }, + expErr: true, + }, + // Direct rules take priority over individual rules. + { + name: "direct vs individual/priority", + trunk: newSIPTrunkDispatch(), + rules: []*livekit.SIPDispatchRuleInfo{ + {TrunkIds: nil, Rule: newIndividualDispatch("pref_", "123")}, + {TrunkIds: nil, Rule: newDirectDispatch("sip", "456")}, + }, + reqPin: "456", + exp: 1, + }, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + pins := []string{c.reqPin} + if !c.expErr && c.reqPin != "" { + // Should match the same rule, even if no pin is set (so that it can be requested). + pins = append(pins, "") + } + for i, r := range c.rules { + if r.SipDispatchRuleId == "" { + r.SipDispatchRuleId = fmt.Sprintf("rule_%d", i) + } + } + for _, pin := range pins { + pin := pin + name := pin + if name == "" { + name = "no pin" + } + t.Run(name, func(t *testing.T) { + got, err := sipMatchDispatchRule(c.trunk, c.rules, newSIPReqDispatch(pin, c.noPin)) + if c.expErr { + require.Error(t, err) + require.Nil(t, got) + t.Log(err) + } else { + var exp *livekit.SIPDispatchRuleInfo + if c.exp >= 0 { + exp = c.rules[c.exp] + } + require.NoError(t, err) + require.Equal(t, exp, got) + } + }) + } + }) + } +}