package management import ( "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net" "net/url" "regexp" "sort" "strings" "time" ) var idPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`) var requestPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$`) var safePrefixPattern = regexp.MustCompile(`^[0-9]{0,32}$`) func newID(prefix string) string { var b [16]byte if _, err := rand.Read(b[:]); err != nil { return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano()) } return prefix + "-" + hex.EncodeToString(b[:]) } func hashJSON(v any) (string, []byte, error) { b, err := json.Marshal(v) if err != nil { return "", nil, err } h := sha256.Sum256(b) return hex.EncodeToString(h[:]), b, nil } func hashBytes(b []byte) string { h := sha256.Sum256(b) return hex.EncodeToString(h[:]) } func utcString(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) } func parseStoredTime(v string) (time.Time, error) { return time.Parse(time.RFC3339Nano, v) } func validID(v string) bool { return idPattern.MatchString(v) } func validRequestID(v string) bool { return requestPattern.MatchString(v) } func maskNumber(v string) string { if v == "" { return "" } if len(v) <= 5 { return strings.Repeat("*", len(v)) } return v[:3] + strings.Repeat("*", len(v)-5) + v[len(v)-2:] } func containsString(values []string, value string) bool { for _, item := range values { if item == value { return true } } return false } func codecIntersection(a, b []string) []string { var out []string for _, v := range a { if containsString(b, v) && !containsString(out, v) { out = append(out, v) } } return out } func sortedStrings(values []string) []string { out := append([]string(nil), values...) sort.Strings(out) return out } func validateHost(host string) error { if host == "" || len(host) > 253 || strings.ContainsAny(host, "\r\n/\\@ \t") { return fmt.Errorf("host must be a hostname or IP address") } if net.ParseIP(host) != nil { ip := net.ParseIP(host) if ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsPrivate() { return fmt.Errorf("private or local SIP host is not allowed") } return nil } if strings.EqualFold(host, "localhost") || strings.HasSuffix(strings.ToLower(host), ".localhost") { return fmt.Errorf("local SIP host is not allowed") } for _, label := range strings.Split(host, ".") { if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") { return fmt.Errorf("invalid SIP host") } } return nil } func validateManagementURL(raw string, allowPrivate bool, allowlist map[string]bool) error { if raw == "" { return nil } u, err := url.Parse(raw) if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { return fmt.Errorf("management_url must be an https URL without credentials, query, or fragment") } host := u.Hostname() if !allowPrivate { if ip := net.ParseIP(host); ip != nil && (ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast()) { return fmt.Errorf("management_url host is outside the allowed management network") } } if len(allowlist) > 0 && !allowlist[strings.ToLower(host)] { return fmt.Errorf("management_url host is not in the configured allowlist") } return nil } func safePrefix(prefix string) error { if !safePrefixPattern.MatchString(prefix) { return fmt.Errorf("dial_prefix must contain digits only") } return nil } func redactedConfig(cfg TrunkConfig) map[string]any { return map[string]any{ "provider_id": cfg.ProviderID, "display_name": cfg.DisplayName, "enabled": cfg.Enabled, "sip": map[string]any{"host": cfg.Sip.Host, "port": cfg.Sip.Port, "transport": cfg.Sip.Transport, "auth_mode": cfg.Sip.AuthMode, "register": cfg.Sip.Register, "credential_configured": cfg.Sip.CredentialRef != ""}, "codec_profile": cfg.CodecProfile, "caller_ids": cfg.CallerIDs, "dial_prefix": cfg.DialPrefix, "egress_pool_id": cfg.EgressPoolID, "max_concurrency": cfg.MaxConcurrency, "max_cps": cfg.MaxCPS, } } func parseCSV(v string) []string { var out []string for _, item := range strings.Split(v, ",") { item = strings.TrimSpace(item) if item != "" && !containsString(out, item) { out = append(out, item) } } return out }