From b68711be9ca14fc666f38fd0d5fd4d9f5fb9a063 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 3 Oct 2026 11:43:39 +0800 Subject: [PATCH] Render approved IP-auth SIP endpoints for native Asterisk --- internal/asterisk/sip.go | 73 +++++++++++++++++++++++++++++++++++ internal/asterisk/sip_test.go | 53 +++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 internal/asterisk/sip.go create mode 100644 internal/asterisk/sip_test.go diff --git a/internal/asterisk/sip.go b/internal/asterisk/sip.go new file mode 100644 index 0000000..c33b1f4 --- /dev/null +++ b/internal/asterisk/sip.go @@ -0,0 +1,73 @@ +package asterisk + +import ( + "encoding/json" + "errors" + "fmt" + "net/netip" + "regexp" + "sort" + "strings" + + "git.ipao.vip/rogee/go-sip/internal/configread" + "git.ipao.vip/rogee/go-sip/internal/contract" +) + +var trunkName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$`) + +type trunk struct { + ID string `json:"trunk_id"` + Host string `json:"server_host"` + Port int `json:"server_port"` + Transport *string `json:"transport"` + AuthMode *string `json:"auth_mode"` + RegistrationRequired *bool `json:"registration_required"` + Codec string `json:"codec"` + Enabled bool `json:"enabled"` +} + +// Render produces only PJSIP endpoint/AOR objects. Transport is owned by the +// static, reviewed Asterisk base configuration and is never hot-reloaded. +func Render(sip configread.SIP) (string, error) { + raw, err := json.Marshal(sip) + if err != nil { + return "", fmt.Errorf("encode approved SIP snapshot: %w", err) + } + if err := contract.ValidateCurrent("config-read", raw); err != nil { + return "", fmt.Errorf("validate approved SIP snapshot: %w", err) + } + var trunks []trunk + if err := json.Unmarshal(sip.Trunks, &trunks); err != nil { + return "", fmt.Errorf("decode approved SIP trunks: %w", err) + } + seen := make(map[string]bool, len(trunks)) + for _, t := range trunks { + if !trunkName.MatchString(t.ID) || seen[t.ID] { + return "", fmt.Errorf("invalid or duplicated SIP trunk ID %q", t.ID) + } + seen[t.ID] = true + if !t.Enabled { + continue + } + if t.Transport == nil || *t.Transport != "udp" || t.AuthMode == nil || (*t.AuthMode != "none" && *t.AuthMode != "ip") || t.RegistrationRequired == nil || *t.RegistrationRequired { + return "", fmt.Errorf("trunk %q uses unsupported transport, authentication or registration", t.ID) + } + addr, err := netip.ParseAddr(t.Host) + if err != nil || !addr.Is4() || t.Port < 1 || t.Port > 65535 || t.Codec != "PCMA" { + return "", fmt.Errorf("trunk %q has unsupported endpoint address or codec", t.ID) + } + } + sort.Slice(trunks, func(i, j int) bool { return trunks[i].ID < trunks[j].ID }) + var text strings.Builder + text.WriteString("; Generated from the approved SIP snapshot. Do not edit manually.\n") + for _, t := range trunks { + if !t.Enabled { + continue + } + _, _ = fmt.Fprintf(&text, "\n[%s]\ntype=endpoint\ntransport=go-sip-udp\ncontext=go-sip-no-inbound\ndisallow=all\nallow=alaw\naors=%s-aor\ndirect_media=no\n\n[%s-aor]\ntype=aor\ncontact=sip:%s:%d\n", t.ID, t.ID, t.ID, t.Host, t.Port) + } + if text.Len() == 0 { + return "", errors.New("empty generated SIP configuration") + } + return text.String(), nil +} diff --git a/internal/asterisk/sip_test.go b/internal/asterisk/sip_test.go new file mode 100644 index 0000000..90bc8a4 --- /dev/null +++ b/internal/asterisk/sip_test.go @@ -0,0 +1,53 @@ +package asterisk + +import ( + "encoding/json" + "strings" + "testing" + + "git.ipao.vip/rogee/go-sip/internal/configread" +) + +func testSIP(t *testing.T) configread.SIP { + t.Helper() + var sip configread.SIP + if err := json.Unmarshal([]byte(`{"resource":"sip_config","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","revision":9,"trunks":[{"trunk_id":"trunk-shuqi","provider_id":"shuqi","codec":"PCMA","dial_prefix":"7089","enabled":true,"server_host":"61.132.228.221","server_port":5060,"transport":"udp","auth_mode":"ip","registration_required":false,"max_concurrent_calls":1,"caller_profiles":[{"caller_profile_id":"caller-shuqi","caller_id":"BD93205882"}],"schedule":{"time_zone":"Asia/Shanghai","weekly_windows":{"monday":[{"start":"09:00","end":"20:00"}],"tuesday":[],"wednesday":[],"thursday":[],"friday":[],"saturday":[],"sunday":[]}}}]}`), &sip); err != nil { + t.Fatal(err) + } + return sip +} + +func TestRenderApprovedIPTrunk(t *testing.T) { + text, err := Render(testSIP(t)) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"[trunk-shuqi]", "type=endpoint", "transport=go-sip-udp", "allow=alaw", "aors=trunk-shuqi-aor", "contact=sip:61.132.228.221:5060"} { + if !strings.Contains(text, want) { + t.Errorf("rendered SIP is missing %q", want) + } + } + if strings.Contains(text, "type=transport") || strings.Contains(text, "7089") || strings.Contains(text, "BD93205882") || strings.Contains(text, "register=") { + t.Fatal("per-call prefix/caller or registration leaked into static endpoint") + } +} + +func TestRenderRejectsUnsupportedOrAmbiguousSIP(t *testing.T) { + for _, mutation := range []struct{ name, from, to string }{ + {"digest", `"auth_mode":"ip"`, `"auth_mode":"digest"`}, + {"register", `"registration_required":false`, `"registration_required":true`}, + {"tcp", `"transport":"udp"`, `"transport":"tcp"`}, + {"missing_transport", `"transport":"udp"`, `"transport":null`}, + {"missing_auth", `"auth_mode":"ip"`, `"auth_mode":null`}, + {"bad_id", `"trunk_id":"trunk-shuqi"`, `"trunk_id":"bad]\n[attacker"`}, + {"bad_host", `"server_host":"61.132.228.221"`, `"server_host":"oops\npassword=bad"`}, + } { + t.Run(mutation.name, func(t *testing.T) { + sip := testSIP(t) + sip.Trunks = []byte(strings.Replace(string(sip.Trunks), mutation.from, mutation.to, 1)) + if _, err := Render(sip); err == nil { + t.Fatal("unsupported SIP configuration was accepted") + } + }) + } +}