Ryan Malloy c73fa9d3d1 Add extension enumeration detection and comprehensive SIP protection
Major features:
- Extension enumeration detection with 3 detection algorithms:
  - Max unique extensions threshold (default: 20 in 5 min)
  - Sequential pattern detection (e.g., 100,101,102...)
  - Rapid-fire detection (many extensions in short window)
- Prometheus metrics for all SIP Guardian operations
- SQLite persistent storage for bans and attack history
- Webhook notifications for ban/unban/suspicious events
- GeoIP-based country blocking with continent shortcuts
- Per-method rate limiting with token bucket algorithm

Bug fixes:
- Fix whitelist count always reporting zero in stats
- Fix whitelisted connections metric never incrementing
- Fix Caddyfile config not being applied to shared guardian

New files:
- enumeration.go: Extension enumeration detector
- enumeration_test.go: 14 comprehensive unit tests
- metrics.go: Prometheus metrics handler
- storage.go: SQLite persistence layer
- webhooks.go: Webhook notification system
- geoip.go: MaxMind GeoIP integration
- ratelimit.go: Per-method rate limiting

Testing:
- sandbox/ contains complete Docker Compose test environment
- All 14 enumeration tests pass
2025-12-07 15:22:28 -07:00

197 lines
5.0 KiB
Go

package sipguardian
import (
"fmt"
"net"
"sync"
"github.com/oschwald/maxminddb-golang"
)
// GeoIPLookup provides IP to country lookup using MaxMind databases
type GeoIPLookup struct {
db *maxminddb.Reader
mu sync.RWMutex
}
// GeoIPRecord represents the data we extract from MaxMind database
type GeoIPRecord struct {
Country struct {
ISOCode string `maxminddb:"iso_code"`
Names map[string]string `maxminddb:"names"`
} `maxminddb:"country"`
City struct {
Names map[string]string `maxminddb:"names"`
} `maxminddb:"city"`
Continent struct {
Code string `maxminddb:"code"`
Names map[string]string `maxminddb:"names"`
} `maxminddb:"continent"`
}
// NewGeoIPLookup creates a new GeoIP lookup from a MaxMind database file
// Supports both GeoLite2-Country and GeoLite2-City databases
func NewGeoIPLookup(path string) (*GeoIPLookup, error) {
db, err := maxminddb.Open(path)
if err != nil {
return nil, fmt.Errorf("failed to open GeoIP database: %w", err)
}
return &GeoIPLookup{
db: db,
}, nil
}
// LookupCountry returns the ISO country code for an IP address
func (g *GeoIPLookup) LookupCountry(ipStr string) (string, error) {
g.mu.RLock()
defer g.mu.RUnlock()
ip := net.ParseIP(ipStr)
if ip == nil {
return "", fmt.Errorf("invalid IP address: %s", ipStr)
}
var record GeoIPRecord
err := g.db.Lookup(ip, &record)
if err != nil {
return "", fmt.Errorf("lookup failed: %w", err)
}
return record.Country.ISOCode, nil
}
// LookupFull returns full GeoIP information for an IP address
func (g *GeoIPLookup) LookupFull(ipStr string) (*GeoIPRecord, error) {
g.mu.RLock()
defer g.mu.RUnlock()
ip := net.ParseIP(ipStr)
if ip == nil {
return nil, fmt.Errorf("invalid IP address: %s", ipStr)
}
var record GeoIPRecord
err := g.db.Lookup(ip, &record)
if err != nil {
return nil, fmt.Errorf("lookup failed: %w", err)
}
return &record, nil
}
// Close closes the database
func (g *GeoIPLookup) Close() error {
g.mu.Lock()
defer g.mu.Unlock()
return g.db.Close()
}
// CountryName returns the English name for a country code
func (g *GeoIPLookup) CountryName(ipStr string) (string, error) {
record, err := g.LookupFull(ipStr)
if err != nil {
return "", err
}
if name, ok := record.Country.Names["en"]; ok {
return name, nil
}
return record.Country.ISOCode, nil
}
// IsPrivate checks if an IP is in a private/reserved range
func IsPrivateIP(ipStr string) bool {
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
// Private IPv4 ranges
privateBlocks := []string{
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"127.0.0.0/8",
"169.254.0.0/16", // Link-local
"100.64.0.0/10", // Carrier-grade NAT
}
// Private IPv6 ranges
if ip.To4() == nil {
privateBlocks = append(privateBlocks,
"::1/128", // Loopback
"fc00::/7", // Unique local
"fe80::/10", // Link-local
)
}
for _, block := range privateBlocks {
_, cidr, err := net.ParseCIDR(block)
if err != nil {
continue
}
if cidr.Contains(ip) {
return true
}
}
return false
}
// Continent codes for grouping countries
var ContinentCountries = map[string][]string{
"AF": { // Africa
"AO", "BF", "BI", "BJ", "BW", "CD", "CF", "CG", "CI", "CM",
"CV", "DJ", "DZ", "EG", "EH", "ER", "ET", "GA", "GH", "GM",
"GN", "GQ", "GW", "KE", "KM", "LR", "LS", "LY", "MA", "MG",
"ML", "MR", "MU", "MW", "MZ", "NA", "NE", "NG", "RE", "RW",
"SC", "SD", "SH", "SL", "SN", "SO", "SS", "ST", "SZ", "TD",
"TG", "TN", "TZ", "UG", "YT", "ZA", "ZM", "ZW",
},
"AS": { // Asia
"AE", "AF", "AM", "AZ", "BD", "BH", "BN", "BT", "CN", "CY",
"GE", "HK", "ID", "IL", "IN", "IQ", "IR", "JO", "JP", "KG",
"KH", "KP", "KR", "KW", "KZ", "LA", "LB", "LK", "MM", "MN",
"MO", "MV", "MY", "NP", "OM", "PH", "PK", "PS", "QA", "SA",
"SG", "SY", "TH", "TJ", "TL", "TM", "TR", "TW", "UZ", "VN",
"YE",
},
"EU": { // Europe
"AD", "AL", "AT", "AX", "BA", "BE", "BG", "BY", "CH", "CZ",
"DE", "DK", "EE", "ES", "FI", "FO", "FR", "GB", "GG", "GI",
"GR", "HR", "HU", "IE", "IM", "IS", "IT", "JE", "LI", "LT",
"LU", "LV", "MC", "MD", "ME", "MK", "MT", "NL", "NO", "PL",
"PT", "RO", "RS", "RU", "SE", "SI", "SJ", "SK", "SM", "UA",
"VA", "XK",
},
"NA": { // North America
"AG", "AI", "AW", "BB", "BL", "BM", "BQ", "BS", "BZ", "CA",
"CR", "CU", "CW", "DM", "DO", "GD", "GL", "GP", "GT", "HN",
"HT", "JM", "KN", "KY", "LC", "MF", "MQ", "MS", "MX", "NI",
"PA", "PM", "PR", "SV", "SX", "TC", "TT", "US", "VC", "VG",
"VI",
},
"SA": { // South America
"AR", "BO", "BR", "CL", "CO", "EC", "FK", "GF", "GY", "PE",
"PY", "SR", "UY", "VE",
},
"OC": { // Oceania
"AS", "AU", "CK", "FJ", "FM", "GU", "KI", "MH", "MP", "NC",
"NF", "NR", "NU", "NZ", "PF", "PG", "PN", "PW", "SB", "TK",
"TO", "TV", "VU", "WF", "WS",
},
"AN": { // Antarctica
"AQ", "GS", "HM", "TF",
},
}
// ExpandContinentCode expands a continent code to its country codes
func ExpandContinentCode(code string) []string {
if countries, ok := ContinentCountries[code]; ok {
return countries
}
return nil
}