Ryan Malloy f76946fc41 Add SIP topology hiding feature (B2BUA-lite)
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
  }
2025-12-07 19:02:50 -07:00

417 lines
9.7 KiB
Go

package sipguardian
import (
"bytes"
"errors"
"fmt"
"strconv"
"strings"
)
// SIPMessage represents a parsed SIP message (request or response)
type SIPMessage struct {
// Common fields
IsRequest bool
SIPVersion string
Headers []SIPHeader
Body []byte
// Request fields
Method string
RequestURI string
// Response fields
StatusCode int
ReasonPhrase string
}
// SIPHeader represents a single SIP header
type SIPHeader struct {
Name string
Value string
}
// Common errors
var (
ErrInvalidSIPMessage = errors.New("invalid SIP message format")
ErrEmptyMessage = errors.New("empty message")
ErrNoStartLine = errors.New("missing start line")
)
// ParseSIPMessage parses raw bytes into a SIPMessage structure
func ParseSIPMessage(data []byte) (*SIPMessage, error) {
if len(data) == 0 {
return nil, ErrEmptyMessage
}
msg := &SIPMessage{
Headers: make([]SIPHeader, 0),
}
// Split into headers and body at double CRLF
parts := bytes.SplitN(data, []byte("\r\n\r\n"), 2)
headerSection := parts[0]
if len(parts) > 1 {
msg.Body = parts[1]
}
// Split header section into lines
lines := bytes.Split(headerSection, []byte("\r\n"))
if len(lines) == 0 {
return nil, ErrNoStartLine
}
// Parse start line (first line)
if err := msg.parseStartLine(string(lines[0])); err != nil {
return nil, err
}
// Parse headers (remaining lines)
for i := 1; i < len(lines); i++ {
line := string(lines[i])
if line == "" {
continue
}
// Handle header continuation (line starting with whitespace)
if len(line) > 0 && (line[0] == ' ' || line[0] == '\t') {
// Append to previous header
if len(msg.Headers) > 0 {
msg.Headers[len(msg.Headers)-1].Value += " " + strings.TrimSpace(line)
}
continue
}
// Parse header name: value
colonIdx := strings.Index(line, ":")
if colonIdx > 0 {
name := strings.TrimSpace(line[:colonIdx])
value := strings.TrimSpace(line[colonIdx+1:])
msg.Headers = append(msg.Headers, SIPHeader{
Name: name,
Value: value,
})
}
}
return msg, nil
}
// parseStartLine parses the first line of a SIP message
func (m *SIPMessage) parseStartLine(line string) error {
parts := strings.Fields(line)
if len(parts) < 2 {
return ErrInvalidSIPMessage
}
// Check if it's a response (starts with SIP/2.0)
if strings.HasPrefix(parts[0], "SIP/") {
m.IsRequest = false
m.SIPVersion = parts[0]
// Parse status code
if len(parts) < 2 {
return ErrInvalidSIPMessage
}
code, err := strconv.Atoi(parts[1])
if err != nil {
return fmt.Errorf("invalid status code: %v", err)
}
m.StatusCode = code
// Reason phrase is the rest
if len(parts) >= 3 {
m.ReasonPhrase = strings.Join(parts[2:], " ")
}
} else {
// It's a request
m.IsRequest = true
m.Method = parts[0]
if len(parts) < 3 {
return ErrInvalidSIPMessage
}
m.RequestURI = parts[1]
m.SIPVersion = parts[2]
}
return nil
}
// Serialize converts the SIPMessage back to wire format
func (m *SIPMessage) Serialize() []byte {
var buf bytes.Buffer
// Write start line
if m.IsRequest {
buf.WriteString(m.Method)
buf.WriteByte(' ')
buf.WriteString(m.RequestURI)
buf.WriteByte(' ')
buf.WriteString(m.SIPVersion)
} else {
buf.WriteString(m.SIPVersion)
buf.WriteByte(' ')
buf.WriteString(strconv.Itoa(m.StatusCode))
buf.WriteByte(' ')
buf.WriteString(m.ReasonPhrase)
}
buf.WriteString("\r\n")
// Write headers
for _, h := range m.Headers {
buf.WriteString(h.Name)
buf.WriteString(": ")
buf.WriteString(h.Value)
buf.WriteString("\r\n")
}
// End of headers
buf.WriteString("\r\n")
// Write body if present
if len(m.Body) > 0 {
buf.Write(m.Body)
}
return buf.Bytes()
}
// GetHeader returns the first header with the given name (case-insensitive)
func (m *SIPMessage) GetHeader(name string) *SIPHeader {
lowerName := strings.ToLower(name)
// Also check compact form
compactName := getCompactForm(lowerName)
for i := range m.Headers {
headerLower := strings.ToLower(m.Headers[i].Name)
if headerLower == lowerName || headerLower == compactName {
return &m.Headers[i]
}
}
return nil
}
// GetHeaders returns all headers with the given name (case-insensitive)
func (m *SIPMessage) GetHeaders(name string) []*SIPHeader {
lowerName := strings.ToLower(name)
compactName := getCompactForm(lowerName)
var result []*SIPHeader
for i := range m.Headers {
headerLower := strings.ToLower(m.Headers[i].Name)
if headerLower == lowerName || headerLower == compactName {
result = append(result, &m.Headers[i])
}
}
return result
}
// SetHeader sets a header value, replacing existing or adding new
func (m *SIPMessage) SetHeader(name, value string) {
lowerName := strings.ToLower(name)
compactName := getCompactForm(lowerName)
for i := range m.Headers {
headerLower := strings.ToLower(m.Headers[i].Name)
if headerLower == lowerName || headerLower == compactName {
m.Headers[i].Value = value
return
}
}
// Not found, add new
m.Headers = append(m.Headers, SIPHeader{Name: name, Value: value})
}
// PrependHeader adds a header at the beginning of the header list
func (m *SIPMessage) PrependHeader(name, value string) {
newHeader := SIPHeader{Name: name, Value: value}
m.Headers = append([]SIPHeader{newHeader}, m.Headers...)
}
// AppendHeader adds a header at the end of the header list
func (m *SIPMessage) AppendHeader(name, value string) {
m.Headers = append(m.Headers, SIPHeader{Name: name, Value: value})
}
// RemoveHeader removes all headers with the given name (case-insensitive)
func (m *SIPMessage) RemoveHeader(name string) {
lowerName := strings.ToLower(name)
compactName := getCompactForm(lowerName)
newHeaders := make([]SIPHeader, 0, len(m.Headers))
for _, h := range m.Headers {
headerLower := strings.ToLower(h.Name)
if headerLower != lowerName && headerLower != compactName {
newHeaders = append(newHeaders, h)
}
}
m.Headers = newHeaders
}
// RemoveFirstHeader removes only the first header with the given name
func (m *SIPMessage) RemoveFirstHeader(name string) bool {
lowerName := strings.ToLower(name)
compactName := getCompactForm(lowerName)
for i, h := range m.Headers {
headerLower := strings.ToLower(h.Name)
if headerLower == lowerName || headerLower == compactName {
m.Headers = append(m.Headers[:i], m.Headers[i+1:]...)
return true
}
}
return false
}
// GetCallID returns the Call-ID header value
func (m *SIPMessage) GetCallID() string {
if h := m.GetHeader("Call-ID"); h != nil {
return h.Value
}
return ""
}
// GetFromTag extracts the tag parameter from the From header
func (m *SIPMessage) GetFromTag() string {
if h := m.GetHeader("From"); h != nil {
return extractTagParam(h.Value)
}
return ""
}
// GetToTag extracts the tag parameter from the To header
func (m *SIPMessage) GetToTag() string {
if h := m.GetHeader("To"); h != nil {
return extractTagParam(h.Value)
}
return ""
}
// GetCSeq returns the CSeq number and method
func (m *SIPMessage) GetCSeq() (int, string) {
if h := m.GetHeader("CSeq"); h != nil {
parts := strings.Fields(h.Value)
if len(parts) >= 2 {
seq, _ := strconv.Atoi(parts[0])
return seq, parts[1]
}
}
return 0, ""
}
// GetViaBranch extracts the branch parameter from the top Via header
func (m *SIPMessage) GetViaBranch() string {
if h := m.GetHeader("Via"); h != nil {
return extractViaParam(h.Value, "branch")
}
return ""
}
// Clone creates a deep copy of the message
func (m *SIPMessage) Clone() *SIPMessage {
clone := &SIPMessage{
IsRequest: m.IsRequest,
SIPVersion: m.SIPVersion,
Method: m.Method,
RequestURI: m.RequestURI,
StatusCode: m.StatusCode,
ReasonPhrase: m.ReasonPhrase,
Headers: make([]SIPHeader, len(m.Headers)),
}
copy(clone.Headers, m.Headers)
if len(m.Body) > 0 {
clone.Body = make([]byte, len(m.Body))
copy(clone.Body, m.Body)
}
return clone
}
// getCompactForm returns the compact form of a header name
func getCompactForm(name string) string {
compactForms := map[string]string{
"call-id": "i",
"contact": "m",
"content-encoding": "e",
"content-length": "l",
"content-type": "c",
"from": "f",
"subject": "s",
"supported": "k",
"to": "t",
"via": "v",
}
if compact, ok := compactForms[name]; ok {
return compact
}
return ""
}
// extractTagParam extracts the tag parameter from a From/To header value
func extractTagParam(headerValue string) string {
// Look for ;tag= parameter
idx := strings.Index(strings.ToLower(headerValue), ";tag=")
if idx < 0 {
return ""
}
tagStart := idx + 5 // len(";tag=")
rest := headerValue[tagStart:]
// Find end of tag value (semicolon or end of string)
endIdx := strings.IndexAny(rest, ";,>")
if endIdx < 0 {
return rest
}
return rest[:endIdx]
}
// extractViaParam extracts a parameter from a Via header value
func extractViaParam(headerValue, param string) string {
lower := strings.ToLower(headerValue)
search := ";" + param + "="
idx := strings.Index(lower, search)
if idx < 0 {
return ""
}
valueStart := idx + len(search)
rest := headerValue[valueStart:]
// Find end of value
endIdx := strings.IndexAny(rest, ";,")
if endIdx < 0 {
return rest
}
return rest[:endIdx]
}
// IsDialogCreating returns true if this is a dialog-creating request
func (m *SIPMessage) IsDialogCreating() bool {
if !m.IsRequest {
return false
}
switch m.Method {
case "INVITE", "SUBSCRIBE", "REFER", "NOTIFY":
return true
}
return false
}
// IsDialogTerminating returns true if this ends a dialog
func (m *SIPMessage) IsDialogTerminating() bool {
if !m.IsRequest {
return false
}
return m.Method == "BYE"
}
// UpdateContentLength updates the Content-Length header to match body size
func (m *SIPMessage) UpdateContentLength() {
m.SetHeader("Content-Length", strconv.Itoa(len(m.Body)))
}