caddy-sip-guardian/dialog_state.go
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

392 lines
9.2 KiB
Go

package sipguardian
import (
"sync"
"time"
"go.uber.org/zap"
)
// DialogState tracks the state of a SIP dialog for topology hiding
type DialogState struct {
// Dialog identifiers
CallID string
FromTag string
ToTag string
LocalBranch string // Via branch we added
// Original values (for response rewriting)
OriginalCallID string // if anonymized
OriginalVia string // original top Via header
OriginalContact string // original Contact header
// Proxy values (what we replaced with)
ProxyContact string
ProxyVia string
// Client information (for routing responses)
ClientHost string
ClientPort int
// Timestamps
Created time.Time
LastActivity time.Time
// State tracking
IsConfirmed bool // 2xx received for dialog-creating request
IsTerminated bool
}
// DialogManager manages dialog state for topology hiding
type DialogManager struct {
dialogs map[string]*DialogState
byBranch map[string]*DialogState // Quick lookup by Via branch
cleanupTTL time.Duration
logger *zap.Logger
mu sync.RWMutex
}
// NewDialogManager creates a new dialog manager
func NewDialogManager(logger *zap.Logger, cleanupTTL time.Duration) *DialogManager {
if cleanupTTL == 0 {
cleanupTTL = 10 * time.Minute
}
dm := &DialogManager{
dialogs: make(map[string]*DialogState),
byBranch: make(map[string]*DialogState),
cleanupTTL: cleanupTTL,
logger: logger,
}
return dm
}
// dialogKey generates a unique key for a dialog
func dialogKey(callID, fromTag string) string {
return callID + ":" + fromTag
}
// CreateDialog creates a new dialog state for an outgoing request
func (dm *DialogManager) CreateDialog(msg *SIPMessage, clientHost string, clientPort int) *DialogState {
dm.mu.Lock()
defer dm.mu.Unlock()
callID := msg.GetCallID()
fromTag := msg.GetFromTag()
branch := msg.GetViaBranch()
state := &DialogState{
CallID: callID,
FromTag: fromTag,
LocalBranch: branch,
ClientHost: clientHost,
ClientPort: clientPort,
Created: time.Now(),
LastActivity: time.Now(),
}
// Store original Via if we're going to modify it
if via := msg.GetHeader("Via"); via != nil {
state.OriginalVia = via.Value
}
// Store original Contact
if contact := msg.GetHeader("Contact"); contact != nil {
state.OriginalContact = contact.Value
}
// Store by dialog key and by branch
key := dialogKey(callID, fromTag)
dm.dialogs[key] = state
if branch != "" {
dm.byBranch[branch] = state
}
dm.logger.Debug("Dialog created",
zap.String("call_id", callID),
zap.String("from_tag", fromTag),
zap.String("branch", branch),
)
return state
}
// GetDialogByCallID retrieves a dialog by Call-ID and From-tag
func (dm *DialogManager) GetDialogByCallID(callID, fromTag string) *DialogState {
dm.mu.RLock()
defer dm.mu.RUnlock()
key := dialogKey(callID, fromTag)
return dm.dialogs[key]
}
// GetDialogByBranch retrieves a dialog by Via branch (for response routing)
func (dm *DialogManager) GetDialogByBranch(branch string) *DialogState {
dm.mu.RLock()
defer dm.mu.RUnlock()
return dm.byBranch[branch]
}
// UpdateDialog updates an existing dialog with new information
func (dm *DialogManager) UpdateDialog(callID, fromTag string, toTag string) {
dm.mu.Lock()
defer dm.mu.Unlock()
key := dialogKey(callID, fromTag)
if state, ok := dm.dialogs[key]; ok {
state.LastActivity = time.Now()
if toTag != "" && state.ToTag == "" {
state.ToTag = toTag
}
}
}
// ConfirmDialog marks a dialog as confirmed (2xx received)
func (dm *DialogManager) ConfirmDialog(callID, fromTag string) {
dm.mu.Lock()
defer dm.mu.Unlock()
key := dialogKey(callID, fromTag)
if state, ok := dm.dialogs[key]; ok {
state.IsConfirmed = true
state.LastActivity = time.Now()
}
}
// TerminateDialog marks a dialog as terminated
func (dm *DialogManager) TerminateDialog(callID, fromTag string) {
dm.mu.Lock()
defer dm.mu.Unlock()
key := dialogKey(callID, fromTag)
if state, ok := dm.dialogs[key]; ok {
state.IsTerminated = true
state.LastActivity = time.Now()
}
}
// RemoveDialog removes a dialog from the manager
func (dm *DialogManager) RemoveDialog(callID, fromTag string) {
dm.mu.Lock()
defer dm.mu.Unlock()
key := dialogKey(callID, fromTag)
if state, ok := dm.dialogs[key]; ok {
// Remove from branch index
if state.LocalBranch != "" {
delete(dm.byBranch, state.LocalBranch)
}
delete(dm.dialogs, key)
dm.logger.Debug("Dialog removed",
zap.String("call_id", callID),
zap.String("from_tag", fromTag),
)
}
}
// StoreOriginals stores original header values before rewriting
func (dm *DialogManager) StoreOriginals(callID, fromTag string, via, contact string, originalCallID string) {
dm.mu.Lock()
defer dm.mu.Unlock()
key := dialogKey(callID, fromTag)
if state, ok := dm.dialogs[key]; ok {
if via != "" {
state.OriginalVia = via
}
if contact != "" {
state.OriginalContact = contact
}
if originalCallID != "" {
state.OriginalCallID = originalCallID
}
}
}
// StoreProxyValues stores the values we used to replace originals
func (dm *DialogManager) StoreProxyValues(callID, fromTag string, via, contact string) {
dm.mu.Lock()
defer dm.mu.Unlock()
key := dialogKey(callID, fromTag)
if state, ok := dm.dialogs[key]; ok {
if via != "" {
state.ProxyVia = via
}
if contact != "" {
state.ProxyContact = contact
}
}
}
// Cleanup removes stale dialogs
func (dm *DialogManager) Cleanup() int {
dm.mu.Lock()
defer dm.mu.Unlock()
cutoff := time.Now().Add(-dm.cleanupTTL)
removed := 0
for key, state := range dm.dialogs {
// Remove terminated dialogs after TTL
if state.IsTerminated && state.LastActivity.Before(cutoff) {
if state.LocalBranch != "" {
delete(dm.byBranch, state.LocalBranch)
}
delete(dm.dialogs, key)
removed++
continue
}
// Remove unconfirmed dialogs that have been idle too long
if !state.IsConfirmed && state.LastActivity.Before(cutoff) {
if state.LocalBranch != "" {
delete(dm.byBranch, state.LocalBranch)
}
delete(dm.dialogs, key)
removed++
continue
}
// Remove very old dialogs regardless of state (prevent memory leak)
if state.Created.Before(time.Now().Add(-24 * time.Hour)) {
if state.LocalBranch != "" {
delete(dm.byBranch, state.LocalBranch)
}
delete(dm.dialogs, key)
removed++
}
}
if removed > 0 {
dm.logger.Debug("Cleaned up stale dialogs", zap.Int("count", removed))
}
return removed
}
// GetStats returns statistics about the dialog manager
func (dm *DialogManager) GetStats() map[string]interface{} {
dm.mu.RLock()
defer dm.mu.RUnlock()
confirmed := 0
terminated := 0
pending := 0
for _, state := range dm.dialogs {
if state.IsTerminated {
terminated++
} else if state.IsConfirmed {
confirmed++
} else {
pending++
}
}
return map[string]interface{}{
"total_dialogs": len(dm.dialogs),
"confirmed_dialogs": confirmed,
"terminated_dialogs": terminated,
"pending_dialogs": pending,
"branch_index_size": len(dm.byBranch),
}
}
// TransactionState tracks a single SIP transaction (simpler than full dialog)
type TransactionState struct {
Branch string
Method string
ClientHost string
ClientPort int
OriginalVia string
Created time.Time
}
// TransactionManager manages transaction state for stateless topology hiding
type TransactionManager struct {
transactions map[string]*TransactionState
cleanupTTL time.Duration
logger *zap.Logger
mu sync.RWMutex
}
// NewTransactionManager creates a new transaction manager
func NewTransactionManager(logger *zap.Logger, cleanupTTL time.Duration) *TransactionManager {
if cleanupTTL == 0 {
cleanupTTL = 32 * time.Second // SIP transaction timeout
}
return &TransactionManager{
transactions: make(map[string]*TransactionState),
cleanupTTL: cleanupTTL,
logger: logger,
}
}
// CreateTransaction creates a new transaction state
func (tm *TransactionManager) CreateTransaction(branch, method, clientHost string, clientPort int, originalVia string) *TransactionState {
tm.mu.Lock()
defer tm.mu.Unlock()
state := &TransactionState{
Branch: branch,
Method: method,
ClientHost: clientHost,
ClientPort: clientPort,
OriginalVia: originalVia,
Created: time.Now(),
}
tm.transactions[branch] = state
return state
}
// GetTransaction retrieves a transaction by branch
func (tm *TransactionManager) GetTransaction(branch string) *TransactionState {
tm.mu.RLock()
defer tm.mu.RUnlock()
return tm.transactions[branch]
}
// RemoveTransaction removes a transaction
func (tm *TransactionManager) RemoveTransaction(branch string) {
tm.mu.Lock()
defer tm.mu.Unlock()
delete(tm.transactions, branch)
}
// Cleanup removes expired transactions
func (tm *TransactionManager) Cleanup() int {
tm.mu.Lock()
defer tm.mu.Unlock()
cutoff := time.Now().Add(-tm.cleanupTTL)
removed := 0
for branch, state := range tm.transactions {
if state.Created.Before(cutoff) {
delete(tm.transactions, branch)
removed++
}
}
return removed
}
// GetStats returns statistics about the transaction manager
func (tm *TransactionManager) GetStats() map[string]interface{} {
tm.mu.RLock()
defer tm.mu.RUnlock()
return map[string]interface{}{
"active_transactions": len(tm.transactions),
}
}