From a8a99eba1c85520bdd9a356bb55a62a068c85107 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 4 Oct 2026 13:24:14 +0800 Subject: [PATCH] feat(agent): preserve approved SIP route in ARI originate request --- internal/asterisk/originate.go | 30 +++++++++++++++++++++ internal/asterisk/originate_test.go | 41 +++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 internal/asterisk/originate.go create mode 100644 internal/asterisk/originate_test.go diff --git a/internal/asterisk/originate.go b/internal/asterisk/originate.go new file mode 100644 index 0000000..5887346 --- /dev/null +++ b/internal/asterisk/originate.go @@ -0,0 +1,30 @@ +package asterisk + +import ( + "errors" + "fmt" + "regexp" + "strings" + "time" + + "github.com/CyCoreSystems/ari/v5" +) + +var dialedRoute = regexp.MustCompile(`^[a-zA-Z0-9]+$`) + +// approvedOriginateRequest binds an already-approved Agent instruction to one +// native ARI channel. The Dispatcher, not the Agent, selected the trunk, +// caller and complete dialed number (including any trunk-specific prefix). +func approvedOriginateRequest(executionID, trunkID, dialedCallee, callerID string, answerTimeout time.Duration) (ari.OriginateRequest, error) { + if !trunkName.MatchString(executionID) || !trunkName.MatchString(trunkID) || !dialedRoute.MatchString(dialedCallee) || strings.TrimSpace(callerID) == "" || strings.ContainsAny(callerID, "\r\n") { + return ari.OriginateRequest{}, errors.New("approved ARI channel identity or route is incomplete") + } + seconds := int(answerTimeout / time.Second) + if seconds < 1 { + return ari.OriginateRequest{}, fmt.Errorf("approved ARI answer window is shorter than one second: %s", answerTimeout) + } + return ari.OriginateRequest{ + ChannelID: executionID, Endpoint: "PJSIP/" + dialedCallee + "@" + trunkID, + CallerID: callerID, App: "go-sip-agent", Timeout: seconds, + }, nil +} diff --git a/internal/asterisk/originate_test.go b/internal/asterisk/originate_test.go new file mode 100644 index 0000000..1e09878 --- /dev/null +++ b/internal/asterisk/originate_test.go @@ -0,0 +1,41 @@ +package asterisk + +import ( + "testing" + "time" +) + +func TestOriginatePreservesApprovedRouteAndCaller(t *testing.T) { + for _, tc := range []struct { + trunk, dialed, caller, expected string + }{ + {"shuqi", "708915000000001", "BD1234", "PJSIP/708915000000001@shuqi"}, + {"zhongding", "15000000001", "mbkq", "PJSIP/15000000001@zhongding"}, + {"baiying", "mka75515000000001", "KQ1234", "PJSIP/mka75515000000001@baiying"}, + } { + request, err := approvedOriginateRequest("execution-123", tc.trunk, tc.dialed, tc.caller, 7*time.Second+900*time.Millisecond) + if err != nil { + t.Fatal(err) + } + if request.Endpoint != tc.expected || request.CallerID != tc.caller || request.ChannelID != "execution-123" || request.Timeout != 7 || request.App != "go-sip-agent" { + t.Fatalf("Agent changed approved SIP route/caller or exceeded answer deadline: %+v", request) + } + } +} + +func TestOriginateRejectsMissingOrUnrepresentableSignedRoute(t *testing.T) { + for _, tc := range []struct { + executionID, trunk, dialed, caller string + answer time.Duration + }{ + {"", "shuqi", "708915000000001", "BD1234", time.Second}, + {"execution-123", "../shuqi", "708915000000001", "BD1234", time.Second}, + {"execution-123", "shuqi", "7089/15000000001", "BD1234", time.Second}, + {"execution-123", "shuqi", "708915000000001", "BD1234\n", time.Second}, + {"execution-123", "shuqi", "708915000000001", "BD1234", 900 * time.Millisecond}, + } { + if _, err := approvedOriginateRequest(tc.executionID, tc.trunk, tc.dialed, tc.caller, tc.answer); err == nil { + t.Fatal("invalid signed route or subsecond answer timeout cannot create a channel") + } + } +}