Implements RFC 3261 compliant topology hiding to protect internal
infrastructure from external attackers:
New files:
- sipmsg.go: SIP message parsing/serialization with full header support
- sipheaders.go: Via, Contact, From/To header parsing with compact forms
- dialog_state.go: Dialog and transaction state management for response correlation
- topology.go: TopologyHider handler for caddy-l4 integration
- topology_test.go: Comprehensive unit tests (26 new tests, 60 total)
Features:
- Via header insertion (proxy adds own Via, pops on response)
- Contact header rewriting (hide internal IPs behind proxy address)
- Sensitive header stripping (P-Asserted-Identity, Server, etc.)
- Call-ID anonymization (optional)
- Private IP masking in all headers
- Dialog state tracking for stateful response routing
- Transaction state for stateless operation
Caddyfile configuration:
sip_topology_hider {
proxy_host 203.0.113.1
proxy_port 5060
upstream udp/192.168.1.100:5060
rewrite_via
rewrite_contact
strip_headers P-Preferred-Identity Server
}
834 lines
22 KiB
Go
834 lines
22 KiB
Go
package sipguardian
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// =============================================================================
|
|
// SIP Message Parsing Tests (sipmsg.go)
|
|
// =============================================================================
|
|
|
|
func TestParseSIPRequest(t *testing.T) {
|
|
raw := "INVITE sip:bob@example.com SIP/2.0\r\n" +
|
|
"Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK776asdhds\r\n" +
|
|
"From: \"Alice\" <sip:alice@example.com>;tag=1928301774\r\n" +
|
|
"To: <sip:bob@example.com>\r\n" +
|
|
"Call-ID: a84b4c76e66710@pc33.example.com\r\n" +
|
|
"CSeq: 314159 INVITE\r\n" +
|
|
"Contact: <sip:alice@192.168.1.100:5060>\r\n" +
|
|
"Content-Type: application/sdp\r\n" +
|
|
"Content-Length: 4\r\n" +
|
|
"\r\n" +
|
|
"test"
|
|
|
|
msg, err := ParseSIPMessage([]byte(raw))
|
|
if err != nil {
|
|
t.Fatalf("Failed to parse SIP request: %v", err)
|
|
}
|
|
|
|
if !msg.IsRequest {
|
|
t.Error("Expected IsRequest to be true")
|
|
}
|
|
if msg.Method != "INVITE" {
|
|
t.Errorf("Expected method INVITE, got %s", msg.Method)
|
|
}
|
|
if msg.RequestURI != "sip:bob@example.com" {
|
|
t.Errorf("Expected RequestURI sip:bob@example.com, got %s", msg.RequestURI)
|
|
}
|
|
if msg.SIPVersion != "SIP/2.0" {
|
|
t.Errorf("Expected SIP/2.0, got %s", msg.SIPVersion)
|
|
}
|
|
if string(msg.Body) != "test" {
|
|
t.Errorf("Expected body 'test', got '%s'", string(msg.Body))
|
|
}
|
|
}
|
|
|
|
func TestParseSIPResponse(t *testing.T) {
|
|
raw := "SIP/2.0 200 OK\r\n" +
|
|
"Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK776asdhds;received=10.0.0.1\r\n" +
|
|
"From: \"Alice\" <sip:alice@example.com>;tag=1928301774\r\n" +
|
|
"To: <sip:bob@example.com>;tag=a6c85cf\r\n" +
|
|
"Call-ID: a84b4c76e66710@pc33.example.com\r\n" +
|
|
"CSeq: 314159 INVITE\r\n" +
|
|
"Contact: <sip:bob@192.168.1.200:5060>\r\n" +
|
|
"Content-Length: 0\r\n" +
|
|
"\r\n"
|
|
|
|
msg, err := ParseSIPMessage([]byte(raw))
|
|
if err != nil {
|
|
t.Fatalf("Failed to parse SIP response: %v", err)
|
|
}
|
|
|
|
if msg.IsRequest {
|
|
t.Error("Expected IsRequest to be false for response")
|
|
}
|
|
if msg.StatusCode != 200 {
|
|
t.Errorf("Expected status code 200, got %d", msg.StatusCode)
|
|
}
|
|
if msg.ReasonPhrase != "OK" {
|
|
t.Errorf("Expected reason phrase 'OK', got '%s'", msg.ReasonPhrase)
|
|
}
|
|
}
|
|
|
|
func TestSIPMessageGetHeader(t *testing.T) {
|
|
raw := "REGISTER sip:example.com SIP/2.0\r\n" +
|
|
"Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK776\r\n" +
|
|
"From: <sip:alice@example.com>;tag=123\r\n" +
|
|
"To: <sip:alice@example.com>\r\n" +
|
|
"Call-ID: test123@host\r\n" +
|
|
"CSeq: 1 REGISTER\r\n" +
|
|
"Content-Length: 0\r\n" +
|
|
"\r\n"
|
|
|
|
msg, _ := ParseSIPMessage([]byte(raw))
|
|
|
|
// Test case-insensitive lookup
|
|
via := msg.GetHeader("via")
|
|
if via == nil {
|
|
t.Error("Failed to get Via header (lowercase)")
|
|
}
|
|
|
|
Via := msg.GetHeader("Via")
|
|
if Via == nil {
|
|
t.Error("Failed to get Via header (mixed case)")
|
|
}
|
|
|
|
// Test GetCallID helper
|
|
callID := msg.GetCallID()
|
|
if callID != "test123@host" {
|
|
t.Errorf("Expected Call-ID 'test123@host', got '%s'", callID)
|
|
}
|
|
|
|
// Test GetFromTag helper
|
|
fromTag := msg.GetFromTag()
|
|
if fromTag != "123" {
|
|
t.Errorf("Expected From tag '123', got '%s'", fromTag)
|
|
}
|
|
}
|
|
|
|
func TestSIPMessageCompactHeaders(t *testing.T) {
|
|
// Test compact header forms (RFC 3261 Section 7.3.3)
|
|
raw := "INVITE sip:bob@example.com SIP/2.0\r\n" +
|
|
"v: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK776\r\n" +
|
|
"f: <sip:alice@example.com>;tag=123\r\n" +
|
|
"t: <sip:bob@example.com>\r\n" +
|
|
"i: compact-call-id@host\r\n" +
|
|
"CSeq: 1 INVITE\r\n" +
|
|
"m: <sip:alice@192.168.1.100>\r\n" +
|
|
"l: 0\r\n" +
|
|
"\r\n"
|
|
|
|
msg, err := ParseSIPMessage([]byte(raw))
|
|
if err != nil {
|
|
t.Fatalf("Failed to parse message with compact headers: %v", err)
|
|
}
|
|
|
|
// Via (v)
|
|
if msg.GetHeader("Via") == nil {
|
|
t.Error("Failed to get Via via compact form 'v'")
|
|
}
|
|
|
|
// Call-ID (i)
|
|
if msg.GetCallID() != "compact-call-id@host" {
|
|
t.Error("Failed to get Call-ID via compact form 'i'")
|
|
}
|
|
|
|
// Contact (m)
|
|
if msg.GetHeader("Contact") == nil {
|
|
t.Error("Failed to get Contact via compact form 'm'")
|
|
}
|
|
}
|
|
|
|
func TestSIPMessageSerialize(t *testing.T) {
|
|
raw := "INVITE sip:bob@example.com SIP/2.0\r\n" +
|
|
"Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK776\r\n" +
|
|
"From: <sip:alice@example.com>;tag=123\r\n" +
|
|
"To: <sip:bob@example.com>\r\n" +
|
|
"Call-ID: test@host\r\n" +
|
|
"CSeq: 1 INVITE\r\n" +
|
|
"Content-Length: 0\r\n" +
|
|
"\r\n"
|
|
|
|
msg, _ := ParseSIPMessage([]byte(raw))
|
|
serialized := msg.Serialize()
|
|
|
|
// Parse again and verify round-trip
|
|
msg2, err := ParseSIPMessage(serialized)
|
|
if err != nil {
|
|
t.Fatalf("Failed to parse serialized message: %v", err)
|
|
}
|
|
|
|
if msg.Method != msg2.Method {
|
|
t.Errorf("Method mismatch after round-trip: %s vs %s", msg.Method, msg2.Method)
|
|
}
|
|
if msg.GetCallID() != msg2.GetCallID() {
|
|
t.Errorf("Call-ID mismatch after round-trip")
|
|
}
|
|
}
|
|
|
|
func TestSIPMessageHeaderManipulation(t *testing.T) {
|
|
raw := "INVITE sip:bob@example.com SIP/2.0\r\n" +
|
|
"Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK776\r\n" +
|
|
"From: <sip:alice@example.com>;tag=123\r\n" +
|
|
"To: <sip:bob@example.com>\r\n" +
|
|
"Call-ID: test@host\r\n" +
|
|
"CSeq: 1 INVITE\r\n" +
|
|
"Content-Length: 0\r\n" +
|
|
"\r\n"
|
|
|
|
msg, _ := ParseSIPMessage([]byte(raw))
|
|
|
|
// Test PrependHeader (for Via insertion)
|
|
msg.PrependHeader("Via", "SIP/2.0/UDP proxy.example.com:5060;branch=z9hG4bKproxy")
|
|
|
|
vias := msg.GetHeaders("Via")
|
|
if len(vias) != 2 {
|
|
t.Errorf("Expected 2 Via headers after prepend, got %d", len(vias))
|
|
}
|
|
if !strings.Contains(vias[0].Value, "proxy.example.com") {
|
|
t.Error("Prepended Via should be first")
|
|
}
|
|
|
|
// Test RemoveFirstHeader
|
|
msg.RemoveFirstHeader("Via")
|
|
vias = msg.GetHeaders("Via")
|
|
if len(vias) != 1 {
|
|
t.Errorf("Expected 1 Via header after removal, got %d", len(vias))
|
|
}
|
|
|
|
// Test SetHeader
|
|
msg.SetHeader("User-Agent", "Test/1.0")
|
|
ua := msg.GetHeader("User-Agent")
|
|
if ua == nil || ua.Value != "Test/1.0" {
|
|
t.Error("SetHeader failed")
|
|
}
|
|
|
|
// Test RemoveHeader
|
|
msg.RemoveHeader("User-Agent")
|
|
if msg.GetHeader("User-Agent") != nil {
|
|
t.Error("RemoveHeader failed")
|
|
}
|
|
}
|
|
|
|
func TestSIPMessageClone(t *testing.T) {
|
|
raw := "INVITE sip:bob@example.com SIP/2.0\r\n" +
|
|
"Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK776\r\n" +
|
|
"From: <sip:alice@example.com>;tag=123\r\n" +
|
|
"To: <sip:bob@example.com>\r\n" +
|
|
"Call-ID: test@host\r\n" +
|
|
"CSeq: 1 INVITE\r\n" +
|
|
"Content-Length: 4\r\n" +
|
|
"\r\n" +
|
|
"test"
|
|
|
|
msg, _ := ParseSIPMessage([]byte(raw))
|
|
clone := msg.Clone()
|
|
|
|
// Modify original
|
|
msg.SetHeader("Via", "modified")
|
|
msg.Body = []byte("modified")
|
|
|
|
// Clone should be unchanged
|
|
if clone.GetHeader("Via").Value == "modified" {
|
|
t.Error("Clone was affected by original modification")
|
|
}
|
|
if string(clone.Body) != "test" {
|
|
t.Error("Clone body was affected by original modification")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Header Parsing Tests (sipheaders.go)
|
|
// =============================================================================
|
|
|
|
func TestParseViaHeader(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected ViaHeader
|
|
}{
|
|
{
|
|
name: "basic UDP",
|
|
input: "SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK776asdhds",
|
|
expected: ViaHeader{
|
|
Protocol: "SIP/2.0",
|
|
Transport: "UDP",
|
|
Host: "192.168.1.100",
|
|
Port: 5060,
|
|
Branch: "z9hG4bK776asdhds",
|
|
},
|
|
},
|
|
{
|
|
name: "with received and rport",
|
|
input: "SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK776;received=10.0.0.1;rport=12345",
|
|
expected: ViaHeader{
|
|
Protocol: "SIP/2.0",
|
|
Transport: "UDP",
|
|
Host: "192.168.1.100",
|
|
Port: 5060,
|
|
Branch: "z9hG4bK776",
|
|
Received: "10.0.0.1",
|
|
RPort: 12345,
|
|
},
|
|
},
|
|
{
|
|
name: "TCP transport",
|
|
input: "SIP/2.0/TCP proxy.example.com:5060;branch=z9hG4bKabc",
|
|
expected: ViaHeader{
|
|
Protocol: "SIP/2.0",
|
|
Transport: "TCP",
|
|
Host: "proxy.example.com",
|
|
Port: 5060,
|
|
Branch: "z9hG4bKabc",
|
|
},
|
|
},
|
|
{
|
|
name: "default port",
|
|
input: "SIP/2.0/UDP 192.168.1.100;branch=z9hG4bK123",
|
|
expected: ViaHeader{
|
|
Protocol: "SIP/2.0",
|
|
Transport: "UDP",
|
|
Host: "192.168.1.100",
|
|
Port: 5060, // Default
|
|
Branch: "z9hG4bK123",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
via, err := ParseViaHeader(tt.input)
|
|
if err != nil {
|
|
t.Fatalf("ParseViaHeader failed: %v", err)
|
|
}
|
|
|
|
if via.Protocol != tt.expected.Protocol {
|
|
t.Errorf("Protocol: got %s, want %s", via.Protocol, tt.expected.Protocol)
|
|
}
|
|
if via.Transport != tt.expected.Transport {
|
|
t.Errorf("Transport: got %s, want %s", via.Transport, tt.expected.Transport)
|
|
}
|
|
if via.Host != tt.expected.Host {
|
|
t.Errorf("Host: got %s, want %s", via.Host, tt.expected.Host)
|
|
}
|
|
if via.Port != tt.expected.Port {
|
|
t.Errorf("Port: got %d, want %d", via.Port, tt.expected.Port)
|
|
}
|
|
if via.Branch != tt.expected.Branch {
|
|
t.Errorf("Branch: got %s, want %s", via.Branch, tt.expected.Branch)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestViaHeaderSerialize(t *testing.T) {
|
|
via := &ViaHeader{
|
|
Protocol: "SIP/2.0",
|
|
Transport: "UDP",
|
|
Host: "192.168.1.100",
|
|
Port: 5060,
|
|
Branch: "z9hG4bK776",
|
|
Received: "10.0.0.1",
|
|
RPort: 12345,
|
|
}
|
|
|
|
serialized := via.Serialize()
|
|
|
|
// Parse it back
|
|
via2, err := ParseViaHeader(serialized)
|
|
if err != nil {
|
|
t.Fatalf("Failed to parse serialized Via: %v", err)
|
|
}
|
|
|
|
if via2.Host != via.Host || via2.Branch != via.Branch || via2.Received != via.Received {
|
|
t.Error("Via header round-trip failed")
|
|
}
|
|
}
|
|
|
|
func TestParseContactHeader(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
uri string
|
|
dname string
|
|
}{
|
|
{
|
|
name: "simple URI",
|
|
input: "<sip:alice@192.168.1.100:5060>",
|
|
uri: "sip:alice@192.168.1.100:5060",
|
|
dname: "",
|
|
},
|
|
{
|
|
name: "with display name",
|
|
input: "\"Alice\" <sip:alice@example.com>;expires=3600",
|
|
uri: "sip:alice@example.com",
|
|
dname: "Alice",
|
|
},
|
|
{
|
|
name: "star contact (REGISTER)",
|
|
input: "*",
|
|
uri: "*",
|
|
dname: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
contact, err := ParseContactHeader(tt.input)
|
|
if err != nil {
|
|
t.Fatalf("ParseContactHeader failed: %v", err)
|
|
}
|
|
|
|
if contact.URI != tt.uri {
|
|
t.Errorf("URI: got %s, want %s", contact.URI, tt.uri)
|
|
}
|
|
if contact.DisplayName != tt.dname {
|
|
t.Errorf("DisplayName: got %s, want %s", contact.DisplayName, tt.dname)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseFromToHeader(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
uri string
|
|
tag string
|
|
dname string
|
|
}{
|
|
{
|
|
name: "From with tag",
|
|
input: "\"Bob\" <sip:bob@example.com>;tag=a6c85cf",
|
|
uri: "sip:bob@example.com",
|
|
tag: "a6c85cf",
|
|
dname: "Bob",
|
|
},
|
|
{
|
|
name: "To without tag",
|
|
input: "<sip:alice@example.com>",
|
|
uri: "sip:alice@example.com",
|
|
tag: "",
|
|
dname: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
header, err := ParseFromToHeader(tt.input)
|
|
if err != nil {
|
|
t.Fatalf("ParseFromToHeader failed: %v", err)
|
|
}
|
|
|
|
if header.URI != tt.uri {
|
|
t.Errorf("URI: got %s, want %s", header.URI, tt.uri)
|
|
}
|
|
if header.Tag != tt.tag {
|
|
t.Errorf("Tag: got %s, want %s", header.Tag, tt.tag)
|
|
}
|
|
if header.DisplayName != tt.dname {
|
|
t.Errorf("DisplayName: got %s, want %s", header.DisplayName, tt.dname)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractHostFromURI(t *testing.T) {
|
|
tests := []struct {
|
|
uri string
|
|
expected string
|
|
}{
|
|
{"sip:alice@example.com", "example.com"},
|
|
{"sip:bob@192.168.1.100:5060", "192.168.1.100"},
|
|
{"sips:secure@tls.example.com:5061;transport=tls", "tls.example.com"},
|
|
{"sip:user@host;param=value", "host"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.uri, func(t *testing.T) {
|
|
host := ExtractHostFromURI(tt.uri)
|
|
if host != tt.expected {
|
|
t.Errorf("ExtractHostFromURI(%s) = %s, want %s", tt.uri, host, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRewriteURIHost(t *testing.T) {
|
|
tests := []struct {
|
|
uri string
|
|
newHost string
|
|
newPort int
|
|
expected string
|
|
}{
|
|
{
|
|
uri: "sip:alice@192.168.1.100:5060",
|
|
newHost: "proxy.example.com",
|
|
newPort: 5060,
|
|
expected: "sip:alice@proxy.example.com",
|
|
},
|
|
{
|
|
uri: "sip:bob@internal.lan:5060;transport=udp",
|
|
newHost: "external.example.com",
|
|
newPort: 5080,
|
|
expected: "sip:bob@external.example.com:5080;transport=udp",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.uri, func(t *testing.T) {
|
|
result := RewriteURIHost(tt.uri, tt.newHost, tt.newPort)
|
|
if result != tt.expected {
|
|
t.Errorf("RewriteURIHost(%s, %s, %d) = %s, want %s",
|
|
tt.uri, tt.newHost, tt.newPort, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGenerateBranch(t *testing.T) {
|
|
branch := GenerateBranch()
|
|
|
|
// RFC 3261: Branch must start with "z9hG4bK"
|
|
if !strings.HasPrefix(branch, "z9hG4bK") {
|
|
t.Errorf("Branch %s does not start with z9hG4bK", branch)
|
|
}
|
|
|
|
// Should be unique
|
|
branch2 := GenerateBranch()
|
|
if branch == branch2 {
|
|
t.Error("GenerateBranch should generate unique values")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Dialog State Tests (dialog_state.go)
|
|
// =============================================================================
|
|
|
|
func TestDialogManagerCreateAndGet(t *testing.T) {
|
|
logger := zap.NewNop()
|
|
dm := NewDialogManager(logger, 5*time.Minute)
|
|
|
|
// Create a mock SIP message
|
|
raw := "INVITE sip:bob@example.com SIP/2.0\r\n" +
|
|
"Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK776test\r\n" +
|
|
"From: <sip:alice@example.com>;tag=fromtag123\r\n" +
|
|
"To: <sip:bob@example.com>\r\n" +
|
|
"Call-ID: testcall@example.com\r\n" +
|
|
"CSeq: 1 INVITE\r\n" +
|
|
"Contact: <sip:alice@192.168.1.100>\r\n" +
|
|
"Content-Length: 0\r\n" +
|
|
"\r\n"
|
|
|
|
msg, _ := ParseSIPMessage([]byte(raw))
|
|
|
|
// Create dialog
|
|
state := dm.CreateDialog(msg, "192.168.1.100", 5060)
|
|
|
|
if state == nil {
|
|
t.Fatal("CreateDialog returned nil")
|
|
}
|
|
if state.CallID != "testcall@example.com" {
|
|
t.Errorf("CallID mismatch: got %s", state.CallID)
|
|
}
|
|
if state.FromTag != "fromtag123" {
|
|
t.Errorf("FromTag mismatch: got %s", state.FromTag)
|
|
}
|
|
|
|
// Get by Call-ID
|
|
retrieved := dm.GetDialogByCallID("testcall@example.com", "fromtag123")
|
|
if retrieved == nil {
|
|
t.Fatal("GetDialogByCallID returned nil")
|
|
}
|
|
if retrieved != state {
|
|
t.Error("Retrieved dialog doesn't match created dialog")
|
|
}
|
|
|
|
// Get by branch
|
|
byBranch := dm.GetDialogByBranch("z9hG4bK776test")
|
|
if byBranch == nil {
|
|
t.Fatal("GetDialogByBranch returned nil")
|
|
}
|
|
if byBranch != state {
|
|
t.Error("Retrieved dialog by branch doesn't match")
|
|
}
|
|
}
|
|
|
|
func TestDialogManagerUpdateAndConfirm(t *testing.T) {
|
|
logger := zap.NewNop()
|
|
dm := NewDialogManager(logger, 5*time.Minute)
|
|
|
|
raw := "INVITE sip:bob@example.com SIP/2.0\r\n" +
|
|
"Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bKupdate\r\n" +
|
|
"From: <sip:alice@example.com>;tag=from456\r\n" +
|
|
"To: <sip:bob@example.com>\r\n" +
|
|
"Call-ID: updatetest@example.com\r\n" +
|
|
"CSeq: 1 INVITE\r\n" +
|
|
"Content-Length: 0\r\n" +
|
|
"\r\n"
|
|
|
|
msg, _ := ParseSIPMessage([]byte(raw))
|
|
dm.CreateDialog(msg, "192.168.1.100", 5060)
|
|
|
|
// Update with To-tag (from 200 OK)
|
|
dm.UpdateDialog("updatetest@example.com", "from456", "totag789")
|
|
|
|
state := dm.GetDialogByCallID("updatetest@example.com", "from456")
|
|
if state.ToTag != "totag789" {
|
|
t.Errorf("ToTag not updated: got %s", state.ToTag)
|
|
}
|
|
|
|
// Confirm dialog
|
|
dm.ConfirmDialog("updatetest@example.com", "from456")
|
|
if !state.IsConfirmed {
|
|
t.Error("Dialog not confirmed")
|
|
}
|
|
|
|
// Terminate dialog
|
|
dm.TerminateDialog("updatetest@example.com", "from456")
|
|
if !state.IsTerminated {
|
|
t.Error("Dialog not terminated")
|
|
}
|
|
}
|
|
|
|
func TestDialogManagerRemove(t *testing.T) {
|
|
logger := zap.NewNop()
|
|
dm := NewDialogManager(logger, 5*time.Minute)
|
|
|
|
raw := "INVITE sip:bob@example.com SIP/2.0\r\n" +
|
|
"Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bKremove\r\n" +
|
|
"From: <sip:alice@example.com>;tag=removetag\r\n" +
|
|
"To: <sip:bob@example.com>\r\n" +
|
|
"Call-ID: removetest@example.com\r\n" +
|
|
"CSeq: 1 INVITE\r\n" +
|
|
"Content-Length: 0\r\n" +
|
|
"\r\n"
|
|
|
|
msg, _ := ParseSIPMessage([]byte(raw))
|
|
dm.CreateDialog(msg, "192.168.1.100", 5060)
|
|
|
|
// Remove dialog
|
|
dm.RemoveDialog("removetest@example.com", "removetag")
|
|
|
|
// Should be gone
|
|
if dm.GetDialogByCallID("removetest@example.com", "removetag") != nil {
|
|
t.Error("Dialog not removed from call-id index")
|
|
}
|
|
if dm.GetDialogByBranch("z9hG4bKremove") != nil {
|
|
t.Error("Dialog not removed from branch index")
|
|
}
|
|
}
|
|
|
|
func TestDialogManagerStats(t *testing.T) {
|
|
logger := zap.NewNop()
|
|
dm := NewDialogManager(logger, 5*time.Minute)
|
|
|
|
// Create multiple dialogs in different states
|
|
for i := 0; i < 3; i++ {
|
|
raw := "INVITE sip:bob@example.com SIP/2.0\r\n" +
|
|
"Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bKstats" + string(rune('a'+i)) + "\r\n" +
|
|
"From: <sip:alice@example.com>;tag=statstag" + string(rune('a'+i)) + "\r\n" +
|
|
"To: <sip:bob@example.com>\r\n" +
|
|
"Call-ID: statstest" + string(rune('a'+i)) + "@example.com\r\n" +
|
|
"CSeq: 1 INVITE\r\n" +
|
|
"Content-Length: 0\r\n" +
|
|
"\r\n"
|
|
|
|
msg, _ := ParseSIPMessage([]byte(raw))
|
|
dm.CreateDialog(msg, "192.168.1.100", 5060)
|
|
}
|
|
|
|
// Confirm one
|
|
dm.ConfirmDialog("statstesta@example.com", "statstaga")
|
|
|
|
// Terminate one
|
|
dm.TerminateDialog("statstestb@example.com", "statstagb")
|
|
|
|
stats := dm.GetStats()
|
|
if stats["total_dialogs"].(int) != 3 {
|
|
t.Errorf("Expected 3 total dialogs, got %v", stats["total_dialogs"])
|
|
}
|
|
if stats["confirmed_dialogs"].(int) != 1 {
|
|
t.Errorf("Expected 1 confirmed dialog, got %v", stats["confirmed_dialogs"])
|
|
}
|
|
if stats["terminated_dialogs"].(int) != 1 {
|
|
t.Errorf("Expected 1 terminated dialog, got %v", stats["terminated_dialogs"])
|
|
}
|
|
}
|
|
|
|
func TestTransactionManager(t *testing.T) {
|
|
logger := zap.NewNop()
|
|
tm := NewTransactionManager(logger, 32*time.Second)
|
|
|
|
// Create transaction
|
|
state := tm.CreateTransaction("z9hG4bKtx123", "INVITE", "192.168.1.100", 5060, "SIP/2.0/UDP original:5060")
|
|
|
|
if state == nil {
|
|
t.Fatal("CreateTransaction returned nil")
|
|
}
|
|
|
|
// Retrieve
|
|
retrieved := tm.GetTransaction("z9hG4bKtx123")
|
|
if retrieved == nil {
|
|
t.Fatal("GetTransaction returned nil")
|
|
}
|
|
if retrieved.Method != "INVITE" {
|
|
t.Errorf("Method mismatch: got %s", retrieved.Method)
|
|
}
|
|
|
|
// Remove
|
|
tm.RemoveTransaction("z9hG4bKtx123")
|
|
if tm.GetTransaction("z9hG4bKtx123") != nil {
|
|
t.Error("Transaction not removed")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Topology Hider Tests (topology.go)
|
|
// =============================================================================
|
|
|
|
func TestTopologyHiderConfig(t *testing.T) {
|
|
th := &TopologyHider{
|
|
Enabled: true,
|
|
ProxyHost: "proxy.example.com",
|
|
ProxyPort: 5060,
|
|
RewriteVia: true,
|
|
RewriteContact: true,
|
|
AnonymizeCallID: false,
|
|
HidePrivateIPs: true,
|
|
StripHeaders: []string{"Server", "User-Agent"},
|
|
}
|
|
|
|
if !th.Enabled {
|
|
t.Error("Expected Enabled to be true")
|
|
}
|
|
if len(th.StripHeaders) != 2 {
|
|
t.Errorf("Expected 2 strip headers, got %d", len(th.StripHeaders))
|
|
}
|
|
}
|
|
|
|
func TestIsDialogCreating(t *testing.T) {
|
|
tests := []struct {
|
|
method string
|
|
expected bool
|
|
}{
|
|
{"INVITE", true},
|
|
{"SUBSCRIBE", true},
|
|
{"REFER", true},
|
|
{"NOTIFY", true},
|
|
{"REGISTER", false},
|
|
{"OPTIONS", false},
|
|
{"BYE", false},
|
|
{"CANCEL", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.method, func(t *testing.T) {
|
|
raw := tt.method + " sip:bob@example.com SIP/2.0\r\n" +
|
|
"Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK123\r\n" +
|
|
"From: <sip:alice@example.com>;tag=123\r\n" +
|
|
"To: <sip:bob@example.com>\r\n" +
|
|
"Call-ID: test@host\r\n" +
|
|
"CSeq: 1 " + tt.method + "\r\n" +
|
|
"Content-Length: 0\r\n" +
|
|
"\r\n"
|
|
|
|
msg, _ := ParseSIPMessage([]byte(raw))
|
|
if msg.IsDialogCreating() != tt.expected {
|
|
t.Errorf("%s: IsDialogCreating() = %v, want %v",
|
|
tt.method, msg.IsDialogCreating(), tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsDialogTerminating(t *testing.T) {
|
|
byeMsg := "BYE sip:bob@example.com SIP/2.0\r\n" +
|
|
"Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK123\r\n" +
|
|
"From: <sip:alice@example.com>;tag=123\r\n" +
|
|
"To: <sip:bob@example.com>;tag=456\r\n" +
|
|
"Call-ID: test@host\r\n" +
|
|
"CSeq: 2 BYE\r\n" +
|
|
"Content-Length: 0\r\n" +
|
|
"\r\n"
|
|
|
|
msg, _ := ParseSIPMessage([]byte(byeMsg))
|
|
if !msg.IsDialogTerminating() {
|
|
t.Error("BYE should be dialog terminating")
|
|
}
|
|
|
|
inviteMsg := "INVITE sip:bob@example.com SIP/2.0\r\n" +
|
|
"Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK123\r\n" +
|
|
"From: <sip:alice@example.com>;tag=123\r\n" +
|
|
"To: <sip:bob@example.com>\r\n" +
|
|
"Call-ID: test@host\r\n" +
|
|
"CSeq: 1 INVITE\r\n" +
|
|
"Content-Length: 0\r\n" +
|
|
"\r\n"
|
|
|
|
msg2, _ := ParseSIPMessage([]byte(inviteMsg))
|
|
if msg2.IsDialogTerminating() {
|
|
t.Error("INVITE should not be dialog terminating")
|
|
}
|
|
}
|
|
|
|
func TestSensitiveHeadersList(t *testing.T) {
|
|
expected := []string{
|
|
"P-Preferred-Identity",
|
|
"P-Asserted-Identity",
|
|
"Remote-Party-ID",
|
|
"P-Charging-Vector",
|
|
"P-Charging-Function-Addresses",
|
|
"Server",
|
|
"User-Agent",
|
|
"X-Asterisk-HangupCause",
|
|
"X-Asterisk-HangupCauseCode",
|
|
}
|
|
|
|
if len(SensitiveHeaders) != len(expected) {
|
|
t.Errorf("Expected %d sensitive headers, got %d", len(expected), len(SensitiveHeaders))
|
|
}
|
|
|
|
for _, h := range expected {
|
|
found := false
|
|
for _, sh := range SensitiveHeaders {
|
|
if sh == h {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("Missing sensitive header: %s", h)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUpdateContentLength(t *testing.T) {
|
|
raw := "INVITE sip:bob@example.com SIP/2.0\r\n" +
|
|
"Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK123\r\n" +
|
|
"From: <sip:alice@example.com>;tag=123\r\n" +
|
|
"To: <sip:bob@example.com>\r\n" +
|
|
"Call-ID: test@host\r\n" +
|
|
"CSeq: 1 INVITE\r\n" +
|
|
"Content-Length: 0\r\n" +
|
|
"\r\n"
|
|
|
|
msg, _ := ParseSIPMessage([]byte(raw))
|
|
|
|
// Set a body
|
|
msg.Body = []byte("v=0\r\no=- 12345 12345 IN IP4 192.168.1.100\r\ns=-\r\n")
|
|
msg.UpdateContentLength()
|
|
|
|
cl := msg.GetHeader("Content-Length")
|
|
if cl == nil {
|
|
t.Fatal("Content-Length header missing")
|
|
}
|
|
if cl.Value != "48" {
|
|
t.Errorf("Content-Length should be 48, got %s", cl.Value)
|
|
}
|
|
}
|