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
}
392 lines
9.2 KiB
Go
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),
|
|
}
|
|
}
|