safing-portmaster/spn/navigator/findnearest.go

441 lines
11 KiB
Go

package navigator
import (
"errors"
"fmt"
mrand "math/rand"
"sort"
"strings"
"time"
"github.com/safing/portmaster/service/intel/geoip"
"github.com/safing/portmaster/spn/hub"
)
const (
// defaultMaxNearbyMatches defines a default value of how many matches a
// nearby pin find operation in a map should return.
defaultMaxNearbyMatches = 100
// defaultRandomizeNearbyPinTopPercent defines the top percent of a nearby
// pins set that should be randomized for balancing purposes.
// Range: 0-1.
defaultRandomizeNearbyPinTopPercent = 0.1
)
// nearbyPins is a list of nearby Pins to a certain location.
type nearbyPins struct {
pins []*nearbyPin
minPins int
maxPins int
maxCost float32
cutOffLimit float32
randomizeTopPercent float32
debug *nearbyPinsDebug
}
// nearbyPinsDebug holds additional debugging for nearbyPins.
type nearbyPinsDebug struct {
tooExpensive []*nearbyPin
disregarded []*nearbyDisregardedPin
}
// nearbyDisregardedPin represents a disregarded pin.
type nearbyDisregardedPin struct {
pin *Pin
reason string
}
// nearbyPin represents a Pin and the proximity to a certain location.
type nearbyPin struct {
pin *Pin
cost float32
}
// Len is the number of elements in the collection.
func (nb *nearbyPins) Len() int {
return len(nb.pins)
}
// Less reports whether the element with index i should sort before the element
// with index j.
func (nb *nearbyPins) Less(i, j int) bool {
return nb.pins[i].cost < nb.pins[j].cost
}
// Swap swaps the elements with indexes i and j.
func (nb *nearbyPins) Swap(i, j int) {
nb.pins[i], nb.pins[j] = nb.pins[j], nb.pins[i]
}
// add potentially adds a Pin to the list of nearby Pins.
func (nb *nearbyPins) add(pin *Pin, cost float32) {
if len(nb.pins) > nb.minPins && nb.maxCost > 0 && cost > nb.maxCost {
// Add debug data if enabled.
if nb.debug != nil {
nb.debug.tooExpensive = append(nb.debug.tooExpensive,
&nearbyPin{
pin: pin,
cost: cost,
},
)
}
return
}
nb.pins = append(nb.pins, &nearbyPin{
pin: pin,
cost: cost,
})
}
// contains checks if the collection contains a Pin.
func (nb *nearbyPins) get(id string) *nearbyPin {
for _, nbPin := range nb.pins {
if nbPin.pin.Hub.ID == id {
return nbPin
}
}
return nil
}
// clean sort and shortens the list to the configured maximum.
func (nb *nearbyPins) clean() {
// Sort nearby Pins so that the closest one is on top.
sort.Sort(nb)
// Set maximum cost based on max difference, if we have enough pins.
if len(nb.pins) >= nb.minPins {
nb.maxCost = nb.pins[0].cost + nb.cutOffLimit
}
// Remove superfluous Pins from the list.
if len(nb.pins) > nb.maxPins {
// Add debug data if enabled.
if nb.debug != nil {
nb.debug.tooExpensive = append(nb.debug.tooExpensive, nb.pins[nb.maxPins:]...)
}
nb.pins = nb.pins[:nb.maxPins]
}
// Remove Pins that are too costly.
if len(nb.pins) > nb.minPins {
// Search for first pin that is too costly.
okUntil := nb.minPins
for ; okUntil < len(nb.pins); okUntil++ {
if nb.pins[okUntil].cost > nb.maxCost {
break
}
}
// Add debug data if enabled.
if nb.debug != nil {
nb.debug.tooExpensive = append(nb.debug.tooExpensive, nb.pins[okUntil:]...)
}
// Cut off the list at that point.
nb.pins = nb.pins[:okUntil]
}
}
// randomizeTop randomized to the top nearest pins for balancing the network.
func (nb *nearbyPins) randomizeTop() {
switch {
case nb.randomizeTopPercent == 0:
// Check if randomization is enabled.
return
case len(nb.pins) < 2:
// Check if we have enough pins to work with.
return
}
// Find randomization set.
randomizeUpTo := len(nb.pins)
threshold := nb.pins[0].cost * (1 + nb.randomizeTopPercent)
for i, nb := range nb.pins {
// Find first value above the threshold to stop.
if nb.cost > threshold {
randomizeUpTo = i
break
}
}
// Shuffle top set.
if randomizeUpTo >= 2 {
mr := mrand.New(mrand.NewSource(time.Now().UnixNano())) //nolint:gosec
mr.Shuffle(randomizeUpTo, nb.Swap)
}
}
// FindNearestHubs searches for the nearest Hubs to the given IP address. The returned Hubs must not be modified in any way.
func (m *Map) FindNearestHubs(locationV4, locationV6 *geoip.Location, opts *Options, matchFor HubType) ([]*hub.Hub, error) {
m.RLock()
defer m.RUnlock()
// Check if map is populated.
if m.isEmpty() {
return nil, ErrEmptyMap
}
// Set default options if unset.
if opts == nil {
opts = m.defaultOptions()
}
// Find nearest Pins.
nearby, err := m.findNearestPins(locationV4, locationV6, opts, matchFor, false)
if err != nil {
return nil, err
}
// Convert to Hub list and return.
hubs := make([]*hub.Hub, 0, len(nearby.pins))
for _, nbPin := range nearby.pins {
hubs = append(hubs, nbPin.pin.Hub)
}
return hubs, nil
}
func (m *Map) findNearestPins(locationV4, locationV6 *geoip.Location, opts *Options, matchFor HubType, debug bool) (*nearbyPins, error) {
// Fail if no location is provided.
if locationV4 == nil && locationV6 == nil {
return nil, errors.New("no location provided")
}
// Raise maxMatches to nearestPinsMinimum.
maxMatches := defaultMaxNearbyMatches
if maxMatches < nearestPinsMinimum {
maxMatches = nearestPinsMinimum
}
// Create nearby Pins list.
nearby := &nearbyPins{
minPins: nearestPinsMinimum,
maxPins: maxMatches,
cutOffLimit: nearestPinsMaxCostDifference,
randomizeTopPercent: defaultRandomizeNearbyPinTopPercent,
}
if debug {
nearby.debug = &nearbyPinsDebug{}
}
// Create pin matcher.
matcher := opts.Matcher(matchFor, m.intel)
// Iterate over all Pins in the Map to find the nearest ones.
for _, pin := range m.all {
var cost float32
// Check if the Pin matches the criteria.
if !matcher(pin) {
// Add debug data if enabled.
if nearby.debug != nil && pin.State.Has(StateActive|StateReachable) {
nearby.debug.disregarded = append(nearby.debug.disregarded,
&nearbyDisregardedPin{
pin: pin,
reason: "does not match general criteria",
},
)
}
// Debugging:
// log.Tracef("spn/navigator: skipping %s with states %s for finding nearest", pin, pin.State)
continue
}
// Check if the Hub supports at least one IP version we are looking for.
switch {
case locationV4 != nil && pin.LocationV4 != nil:
// Both have IPv4!
case locationV6 != nil && pin.LocationV6 != nil:
// Both have IPv6!
default:
// Hub does not support any IP version we need.
// Add debug data if enabled.
if nearby.debug != nil {
nearby.debug.disregarded = append(nearby.debug.disregarded,
&nearbyDisregardedPin{
pin: pin,
reason: "does not support the required IP version",
},
)
}
continue
}
// If finding a home hub and the global routing profile is set to home ("VPN"),
// check if all local IP versions are available on the Hub.
if matchFor == HomeHub && cfgOptionRoutingAlgorithm() == RoutingProfileHomeID {
switch {
case locationV4 != nil && pin.LocationV4 == nil:
// Device has IPv4, but Hub does not!
fallthrough
case locationV6 != nil && pin.LocationV6 == nil:
// Device has IPv6, but Hub does not!
// Add debug data if enabled.
if nearby.debug != nil {
nearby.debug.disregarded = append(nearby.debug.disregarded,
&nearbyDisregardedPin{
pin: pin,
reason: "home hub needs all IP versions of client (when Home/VPN routing)",
},
)
}
continue
}
}
// 1. Calculate cost based on distance
if locationV4 != nil && pin.LocationV4 != nil {
if locationV4.IsAnycast && m.home != nil {
// If the destination is anycast, calculate cost though proximity to home hub instead, if possible.
cost = lessButPositive(cost, CalculateDestinationCost(
proximityBetweenPins(pin, m.home),
))
} else {
// Regular cost calculation through proximity.
cost = lessButPositive(cost, CalculateDestinationCost(
locationV4.EstimateNetworkProximity(pin.LocationV4),
))
}
}
if locationV6 != nil && pin.LocationV6 != nil {
if locationV6.IsAnycast && m.home != nil {
// If the destination is anycast, calculate cost though proximity to home hub instead, if possible.
cost = lessButPositive(cost, CalculateDestinationCost(
proximityBetweenPins(pin, m.home),
))
} else {
// Regular cost calculation through proximity.
cost = lessButPositive(cost, CalculateDestinationCost(
locationV6.EstimateNetworkProximity(pin.LocationV6),
))
}
}
// If no cost could be calculated, fall back to a default value.
if cost == 0 {
cost = CalculateDestinationCost(50) // proximity out of 0-100
}
// Debugging:
// if matchFor == HomeHub {
// log.Tracef("spn/navigator: adding %.2f proximity cost to home hub %s", cost, pin.Hub)
// }
// 2. Add cost based on Hub status
cost += CalculateHubCost(pin.Hub.Status.Load)
// Debugging:
// if matchFor == HomeHub {
// log.Tracef("spn/navigator: adding %.2f hub cost to home hub %s", CalculateHubCost(pin.Hub.Status.Load), pin.Hub)
// }
// 3. If matching a home hub, add cost based on capacity/latency performance.
if matchFor == HomeHub {
// Find best capacity/latency values.
var (
bestCapacity int
bestLatency time.Duration
)
for _, lane := range pin.Hub.Status.Lanes {
if lane.Capacity > bestCapacity {
bestCapacity = lane.Capacity
}
if bestLatency == 0 || lane.Latency < bestLatency {
bestLatency = lane.Latency
}
}
// Add cost of best capacity/latency values.
cost += CalculateLaneCost(bestLatency, bestCapacity)
// Debugging:
// log.Tracef("spn/navigator: adding %.2f lane cost to home hub %s", CalculateLaneCost(bestLatency, bestCapacity), pin.Hub)
// log.Debugf("spn/navigator: total cost of %.2f to home hub %s", cost, pin.Hub)
}
nearby.add(pin, cost)
// Clean the nearby list if have collected more than two times the max amount.
if len(nearby.pins) >= nearby.maxPins*2 {
nearby.clean()
}
}
// Check if we found any nearby pins
if nearby.Len() == 0 {
return nil, ErrAllPinsDisregarded
}
// Clean one last time and return the list.
nearby.clean()
// Randomize top nearest pins for load balancing.
nearby.randomizeTop()
// Debugging:
// if matchFor == HomeHub {
// log.Debug("spn/navigator: nearby pins:")
// for _, nbPin := range nearby.pins {
// log.Debugf("spn/navigator: nearby pin %s", nbPin)
// }
// }
return nearby, nil
}
func (nb *nearbyPins) String() string {
s := make([]string, 0, len(nb.pins))
for _, nbPin := range nb.pins {
s = append(s, nbPin.String())
}
return strings.Join(s, ", ")
}
func (nb *nearbyPin) String() string {
return fmt.Sprintf("%s at %.2fc", nb.pin, nb.cost)
}
func proximityBetweenPins(a, b *Pin) float32 {
var x, y float32
// Get IPv4 network proximity.
if a.LocationV4 != nil && b.LocationV4 != nil {
x = a.LocationV4.EstimateNetworkProximity(b.LocationV4)
}
// Get IPv6 network proximity.
if a.LocationV6 != nil && b.LocationV6 != nil {
y = a.LocationV6.EstimateNetworkProximity(b.LocationV6)
}
// Return higher proximity.
if x > y {
return x
}
return y
}
func lessButPositive(a, b float32) float32 {
switch {
case a == 0:
return b
case b == 0:
return a
case a < b:
return a
default:
return b
}
}