package app import ( "fmt" "net/netip" "strings" ) var ( transportPolicyDomainCachePath = stateDir + "/domain-cache.json" transportPolicyLoadDomainCacheState = loadDomainCacheState ) func detectTransportDestinationLockConflicts(current, next []TransportPolicyIntent, locks TransportOwnerLockState) []TransportConflictRecord { if len(locks.Items) == 0 { return nil } currentOwners := map[string]string{} for _, raw := range current { norm, key, _, err := normalizeTransportIntent(raw) if err != nil || key == "" || strings.TrimSpace(norm.ClientID) == "" { continue } currentOwners[key] = norm.ClientID } if len(currentOwners) == 0 { return nil } conflicts := make([]TransportConflictRecord, 0, 4) var domainCache domainCacheState domainCacheLoaded := false domainResolved := map[string]map[string]struct{}{} for _, raw := range next { norm, key, pfx, err := normalizeTransportIntent(raw) if err != nil || key == "" || strings.TrimSpace(norm.ClientID) == "" { continue } prevOwner, exists := currentOwners[key] if !exists || prevOwner == norm.ClientID { continue } switch strings.ToLower(strings.TrimSpace(norm.SelectorType)) { case "cidr": if !pfx.IsValid() { continue } for _, lock := range locks.Items { if strings.TrimSpace(lock.ClientID) != prevOwner { continue } dst, err := netip.ParseAddr(strings.TrimSpace(lock.DestinationIP)) if err != nil || !dst.Is4() { continue } if !pfx.Contains(dst) { continue } conflicts = append(conflicts, TransportConflictRecord{ Key: fmt.Sprintf("destination_lock:%s:%s", key, dst.String()), Type: "destination_lock", Severity: "block", Owners: []string{prevOwner, norm.ClientID}, Reason: fmt.Sprintf( "destination %s is sticky-locked to %s by conntrack state", dst.String(), prevOwner, ), SuggestedResolution: "wait conntrack expiry or clear conntrack state for destination before owner switch", }) } case "domain": ipsBySelector, ok := domainResolved[key] if !ok { if !domainCacheLoaded { domainCache = transportPolicyLoadDomainCacheState(transportPolicyDomainCachePath, nil) domainCacheLoaded = true } ipsBySelector = transportPolicyResolveDomainSelectorIPs(norm.SelectorValue, domainCache) domainResolved[key] = ipsBySelector } if len(ipsBySelector) == 0 { continue } for _, lock := range locks.Items { if strings.TrimSpace(lock.ClientID) != prevOwner { continue } dstRaw := strings.TrimSpace(lock.DestinationIP) dst, err := netip.ParseAddr(dstRaw) if err != nil || !dst.Is4() { continue } dstKey := dst.String() if _, hit := ipsBySelector[dstKey]; !hit { continue } conflicts = append(conflicts, TransportConflictRecord{ Key: fmt.Sprintf("destination_lock:%s:%s", key, dstKey), Type: "destination_lock", Severity: "block", Owners: []string{prevOwner, norm.ClientID}, Reason: fmt.Sprintf( "domain %s resolves to destination %s sticky-locked to %s by conntrack state", norm.SelectorValue, dstKey, prevOwner, ), SuggestedResolution: "wait conntrack expiry or clear conntrack state for destination before owner switch", }) } } } return dedupeTransportConflicts(conflicts) } func transportPolicyResolveDomainSelectorIPs(selector string, cache domainCacheState) map[string]struct{} { out := map[string]struct{}{} sel := strings.Trim(strings.ToLower(strings.TrimSpace(selector)), ".") if sel == "" { return out } wildcard := false if strings.HasPrefix(sel, "*.") { wildcard = true sel = strings.Trim(strings.TrimPrefix(sel, "*."), ".") } if sel == "" { return out } for host := range cache.Domains { h := strings.Trim(strings.ToLower(strings.TrimSpace(host)), ".") if h == "" { continue } if wildcard { if h != sel && !strings.HasSuffix(h, "."+sel) { continue } } else { if h != sel && !strings.HasSuffix(h, "."+sel) { continue } } for _, src := range []domainCacheSource{domainCacheSourceDirect, domainCacheSourceWildcard} { ips := cache.getStoredIPs(h, src) for _, ip := range ips { addr, err := netip.ParseAddr(strings.TrimSpace(ip)) if err != nil || !addr.Is4() { continue } out[addr.String()] = struct{}{} } } } return out }