wip: migrate to mono-repo. SPN has already been moved to spn/
This commit is contained in:
441
spn/navigator/findnearest.go
Normal file
441
spn/navigator/findnearest.go
Normal file
@@ -0,0 +1,441 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user