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
}
This commit is contained in:
parent
976fdf53a5
commit
f76946fc41
5 changed files with 2645 additions and 0 deletions
416
sipmsg.go
Normal file
416
sipmsg.go
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
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)))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue