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
391
dialog_state.go
Normal file
391
dialog_state.go
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
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),
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue