Files
elmprodvpn/selective-vpn-api/app/transport_policy_owner_destination_lock.go

163 lines
4.3 KiB
Go

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
}