wip: migrate to mono-repo. SPN has already been moved to spn/

This commit is contained in:
Patrick Pacher
2024-03-15 11:55:13 +01:00
parent b30fd00ccf
commit 8579430db9
577 changed files with 35981 additions and 818 deletions

672
spn/navigator/api.go Normal file
View File

@@ -0,0 +1,672 @@
package navigator
import (
"bytes"
"errors"
"fmt"
"math"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"text/tabwriter"
"time"
"github.com/awalterschulze/gographviz"
"github.com/safing/portbase/api"
"github.com/safing/portbase/log"
"github.com/safing/portmaster/spn/docks"
"github.com/safing/portmaster/spn/hub"
)
var (
apiMapsLock sync.Mutex
apiMaps = make(map[string]*Map)
)
func addMapToAPI(m *Map) {
apiMapsLock.Lock()
defer apiMapsLock.Unlock()
apiMaps[m.Name] = m
}
func getMapForAPI(name string) (m *Map, ok bool) {
apiMapsLock.Lock()
defer apiMapsLock.Unlock()
m, ok = apiMaps[name]
return
}
func removeMapFromAPI(name string) {
apiMapsLock.Lock()
defer apiMapsLock.Unlock()
delete(apiMaps, name)
}
func registerAPIEndpoints() error {
if err := api.RegisterEndpoint(api.Endpoint{
Path: `spn/map/{map:[A-Za-z0-9]{1,255}}/pins`,
Read: api.PermitUser,
BelongsTo: module,
StructFunc: handleMapPinsRequest,
Name: "Get SPN map pins",
Description: "Returns a list of pins on the map.",
}); err != nil {
return err
}
if err := api.RegisterEndpoint(api.Endpoint{
Path: `spn/map/{map:[A-Za-z0-9]{1,255}}/intel/update`,
Write: api.PermitSelf,
BelongsTo: module,
ActionFunc: handleIntelUpdateRequest,
Name: "Update map intelligence.",
Description: "Updates the intel data of the map.",
}); err != nil {
return err
}
if err := api.RegisterEndpoint(api.Endpoint{
Path: `spn/map/{map:[A-Za-z0-9]{1,255}}/optimization`,
Read: api.PermitUser,
BelongsTo: module,
StructFunc: handleMapOptimizationRequest,
Name: "Get SPN map optimization",
Description: "Returns the calculated optimization for the map.",
}); err != nil {
return err
}
if err := api.RegisterEndpoint(api.Endpoint{
Path: `spn/map/{map:[A-Za-z0-9]{1,255}}/optimization/table`,
Read: api.PermitUser,
BelongsTo: module,
DataFunc: handleMapOptimizationTableRequest,
Name: "Get SPN map optimization as a table",
Description: "Returns the calculated optimization for the map as a table.",
}); err != nil {
return err
}
if err := api.RegisterEndpoint(api.Endpoint{
Path: `spn/map/{map:[A-Za-z0-9]{1,255}}/measurements`,
Read: api.PermitUser,
BelongsTo: module,
StructFunc: handleMapMeasurementsRequest,
Name: "Get SPN map measurements",
Description: "Returns the measurements of the map.",
}); err != nil {
return err
}
if err := api.RegisterEndpoint(api.Endpoint{
Path: `spn/map/{map:[A-Za-z0-9]{1,255}}/measurements/table`,
MimeType: api.MimeTypeText,
Read: api.PermitUser,
BelongsTo: module,
DataFunc: handleMapMeasurementsTableRequest,
Name: "Get SPN map measurements as a table",
Description: "Returns the measurements of the map as a table.",
}); err != nil {
return err
}
if err := api.RegisterEndpoint(api.Endpoint{
Path: `spn/map/{map:[A-Za-z0-9]{1,255}}/graph{format:\.[a-z]{2,4}}`,
Read: api.PermitUser,
BelongsTo: module,
HandlerFunc: handleMapGraphRequest,
Name: "Get SPN map graph",
Description: "Returns a graph of the given SPN map.",
Parameters: []api.Parameter{
{
Method: http.MethodGet,
Field: "map (in path)",
Value: "name of map",
Description: "Specify the map you want to get the map for. The main map is called `main`.",
},
{
Method: http.MethodGet,
Field: "format (in path)",
Value: "file type",
Description: "Specify the format you want to get the map in. Available values: `dot`, `html`. Please note that the html format is only available in development mode.",
},
},
}); err != nil {
return err
}
// Register API endpoints from other files.
if err := registerRouteAPIEndpoints(); err != nil {
return err
}
return nil
}
func handleMapPinsRequest(ar *api.Request) (i interface{}, err error) {
// Get map.
m, ok := getMapForAPI(ar.URLVars["map"])
if !ok {
return nil, errors.New("map not found")
}
// Export all pins.
sortedPins := m.sortedPins(true)
exportedPins := make([]*PinExport, len(sortedPins))
for key, pin := range sortedPins {
exportedPins[key] = pin.Export()
}
return exportedPins, nil
}
func handleIntelUpdateRequest(ar *api.Request) (msg string, err error) {
// Get map.
m, ok := getMapForAPI(ar.URLVars["map"])
if !ok {
return "", errors.New("map not found")
}
// Parse new intel data.
newIntel, err := hub.ParseIntel(ar.InputData)
if err != nil {
return "", fmt.Errorf("failed to parse intel data: %w", err)
}
// Apply intel data.
err = m.UpdateIntel(newIntel, cfgOptionTrustNodeNodes())
if err != nil {
return "", fmt.Errorf("failed to apply intel data: %w", err)
}
return "successfully applied given intel data", nil
}
func handleMapOptimizationRequest(ar *api.Request) (i interface{}, err error) {
// Get map.
m, ok := getMapForAPI(ar.URLVars["map"])
if !ok {
return nil, errors.New("map not found")
}
return m.Optimize(nil)
}
func handleMapOptimizationTableRequest(ar *api.Request) (data []byte, err error) {
// Get map.
m, ok := getMapForAPI(ar.URLVars["map"])
if !ok {
return nil, errors.New("map not found")
}
// Get optimization result.
result, err := m.Optimize(nil)
if err != nil {
return nil, err
}
// Read lock map, as we access pins.
m.RLock()
defer m.RUnlock()
// Get cranes for additional metadata.
assignedCranes := docks.GetAllAssignedCranes()
// Write metadata.
buf := bytes.NewBuffer(nil)
buf.WriteString("Optimization:\n")
fmt.Fprintf(buf, "Purpose: %s\n", result.Purpose)
if len(result.Approach) == 1 {
fmt.Fprintf(buf, "Approach: %s\n", result.Approach[0])
} else if len(result.Approach) > 1 {
buf.WriteString("Approach:\n")
for _, approach := range result.Approach {
fmt.Fprintf(buf, " - %s\n", approach)
}
}
fmt.Fprintf(buf, "MaxConnect: %d\n", result.MaxConnect)
fmt.Fprintf(buf, "StopOthers: %v\n", result.StopOthers)
// Build table of suggested connections.
buf.WriteString("\nSuggested Connections:\n")
tabWriter := tabwriter.NewWriter(buf, 8, 4, 3, ' ', 0)
fmt.Fprint(tabWriter, "Hub Name\tReason\tDuplicate\tCountry\tRegion\tLatency\tCapacity\tCost\tGeo Prox.\tHub ID\tLifetime Usage\tPeriod Usage\tProt\tStatus\n")
for _, suggested := range result.SuggestedConnections {
var dupe string
if suggested.Duplicate {
dupe = "yes"
} else {
// Only lock dupes once.
suggested.pin.measurements.Lock()
defer suggested.pin.measurements.Unlock()
}
// Add row.
fmt.Fprintf(tabWriter,
"%s\t%s\t%s\t%s\t%s\t%s\t%.2fMbit/s\t%.2fc\t%.2f%%\t%s",
suggested.Hub.Info.Name,
suggested.Reason,
dupe,
getPinCountry(suggested.pin),
suggested.pin.region.getName(),
suggested.pin.measurements.Latency,
float64(suggested.pin.measurements.Capacity)/1000000,
suggested.pin.measurements.CalculatedCost,
suggested.pin.measurements.GeoProximity,
suggested.Hub.ID,
)
// Add usage stats.
if crane, ok := assignedCranes[suggested.Hub.ID]; ok {
addUsageStatsToTable(crane, tabWriter)
}
// Add linebreak.
fmt.Fprint(tabWriter, "\n")
}
_ = tabWriter.Flush()
return buf.Bytes(), nil
}
// addUsageStatsToTable compiles some usage stats of a lane and addes them to the table.
// Table Fields: Lifetime Usage, Period Usage, Prot, Mine.
func addUsageStatsToTable(crane *docks.Crane, tabWriter *tabwriter.Writer) {
ltIn, ltOut, ltStart, pIn, pOut, pStart := crane.NetState.GetTrafficStats()
ltDuration := time.Since(ltStart)
pDuration := time.Since(pStart)
// Build ownership and stopping info.
var status string
isMine := crane.IsMine()
isStopping := crane.IsStopping()
stoppingRequested, stoppingRequestedByPeer, markedStoppingAt := crane.NetState.StoppingState()
if isMine {
status = "mine"
}
if isStopping || stoppingRequested || stoppingRequestedByPeer {
if isMine {
status += " - "
}
status += "stopping "
if stoppingRequested {
status += "<r"
}
if isStopping {
status += "!"
}
if stoppingRequestedByPeer {
status += "r>"
}
if isStopping && !markedStoppingAt.IsZero() {
status += " since " + markedStoppingAt.Truncate(time.Minute).String()
}
}
fmt.Fprintf(tabWriter,
"\t%.2fGB %.2fMbit/s %.2f%%out since %s\t%.2fGB %.2fMbit/s %.2f%%out since %s\t%s\t%s",
float64(ltIn+ltOut)/1000000000,
(float64(ltIn+ltOut)/1000000/ltDuration.Seconds())*8,
float64(ltOut)/float64(ltIn+ltOut)*100,
ltDuration.Truncate(time.Second),
float64(pIn+pOut)/1000000000,
(float64(pIn+pOut)/1000000/pDuration.Seconds())*8,
float64(pOut)/float64(pIn+pOut)*100,
pDuration.Truncate(time.Second),
crane.Transport().Protocol,
status,
)
}
func handleMapMeasurementsRequest(ar *api.Request) (i interface{}, err error) {
// Get map.
m, ok := getMapForAPI(ar.URLVars["map"])
if !ok {
return nil, errors.New("map not found")
}
// Get and sort pins.
list := m.pinList(true)
sort.Sort(sortByLowestMeasuredCost(list))
// Copy data and return.
measurements := make([]*hub.Measurements, 0, len(list))
for _, pin := range list {
measurements = append(measurements, pin.measurements.Copy())
}
return measurements, nil
}
func handleMapMeasurementsTableRequest(ar *api.Request) (data []byte, err error) {
// Get map.
m, ok := getMapForAPI(ar.URLVars["map"])
if !ok {
return nil, errors.New("map not found")
}
matcher := m.DefaultOptions().Transit.Matcher(m.GetIntel())
// Get and sort pins.
list := m.pinList(true)
sort.Sort(sortByLowestMeasuredCost(list))
// Get cranes for usage stats.
assignedCranes := docks.GetAllAssignedCranes()
// Build table and return.
buf := bytes.NewBuffer(nil)
tabWriter := tabwriter.NewWriter(buf, 8, 4, 3, ' ', 0)
fmt.Fprint(tabWriter, "Hub Name\tCountry\tRegion\tLatency\tCapacity\tCost\tGeo Prox.\tHub ID\tLifetime Usage\tPeriod Usage\tProt\tStatus\n")
for _, pin := range list {
// Only print regarded Hubs.
if !matcher(pin) {
continue
}
// Add row.
pin.measurements.Lock()
defer pin.measurements.Unlock()
fmt.Fprintf(tabWriter,
"%s\t%s\t%s\t%s\t%.2fMbit/s\t%.2fc\t%.2f%%\t%s",
pin.Hub.Info.Name,
getPinCountry(pin),
pin.region.getName(),
pin.measurements.Latency,
float64(pin.measurements.Capacity)/1000000,
pin.measurements.CalculatedCost,
pin.measurements.GeoProximity,
pin.Hub.ID,
)
// Add usage stats.
if crane, ok := assignedCranes[pin.Hub.ID]; ok {
addUsageStatsToTable(crane, tabWriter)
}
// Add linebreak.
fmt.Fprint(tabWriter, "\n")
}
_ = tabWriter.Flush()
return buf.Bytes(), nil
}
func getPinCountry(pin *Pin) string {
switch {
case pin.LocationV4 != nil && pin.LocationV4.Country.Code != "":
return pin.LocationV4.Country.Code
case pin.LocationV6 != nil && pin.LocationV6.Country.Code != "":
return pin.LocationV6.Country.Code
case pin.EntityV4 != nil && pin.EntityV4.Country != "":
return pin.EntityV4.Country
case pin.EntityV6 != nil && pin.EntityV6.Country != "":
return pin.EntityV6.Country
default:
return ""
}
}
func handleMapGraphRequest(w http.ResponseWriter, hr *http.Request) {
r := api.GetAPIRequest(hr)
if r == nil {
http.Error(w, "API request invalid.", http.StatusInternalServerError)
return
}
// Get map.
m, ok := getMapForAPI(r.URLVars["map"])
if !ok {
http.Error(w, "Map not found.", http.StatusNotFound)
return
}
// Check format.
var format string
switch r.URLVars["format"] {
case ".dot":
format = "dot"
case ".html":
format = "html"
// Check if we are in dev mode.
if !devMode() {
http.Error(w, "Graph html formatting (js rendering) is only available in dev mode.", http.StatusPreconditionFailed)
return
}
default:
http.Error(w, "Unsupported format.", http.StatusBadRequest)
return
}
// Build graph.
graph := gographviz.NewGraph()
_ = graph.AddAttr("", "overlap", "scale")
_ = graph.AddAttr("", "center", "true")
_ = graph.AddAttr("", "ratio", "fill")
for _, pin := range m.sortedPins(true) {
_ = graph.AddNode("", pin.Hub.ID, map[string]string{
"label": graphNodeLabel(pin),
"tooltip": graphNodeTooltip(pin),
"color": graphNodeBorderColor(pin),
"fillcolor": graphNodeColor(pin),
"shape": "circle",
"style": "filled",
"fontsize": "20",
"penwidth": "4",
"margin": "0",
})
for _, lane := range pin.ConnectedTo {
if graph.IsNode(lane.Pin.Hub.ID) && pin.State != StateNone {
// Create attributes.
edgeOptions := map[string]string{
"tooltip": graphEdgeTooltip(pin, lane.Pin, lane),
"color": graphEdgeColor(pin, lane.Pin, lane),
"len": fmt.Sprintf("%f", lane.Latency.Seconds()*200),
"penwidth": fmt.Sprintf("%f", math.Sqrt(float64(lane.Capacity)/1000000)*2),
}
// Add edge.
_ = graph.AddEdge(pin.Hub.ID, lane.Pin.Hub.ID, false, edgeOptions)
}
}
}
var mimeType string
var responseData []byte
switch format {
case "dot":
mimeType = "text/x-dot"
responseData = []byte(graph.String())
case "html":
mimeType = "text/html"
responseData = []byte(fmt.Sprintf(
`<!DOCTYPE html><html><meta charset="utf-8"><body style="margin:0;padding:0;">
<style>#graph svg {height: 99.5vh; width: 99.5vw;}</style>
<div id="graph"></div>
<script src="/assets/vendor/js/hpcc-js-wasm-1.13.0/index.min.js"></script>
<script src="/assets/vendor/js/d3-7.3.0/d3.min.js"></script>
<script src="/assets/vendor/js/d3-graphviz-4.1.0/d3-graphviz.min.js"></script>
<script>
d3.select("#graph").graphviz(useWorker=false).engine("neato").renderDot(%s%s%s);
</script>
</body></html>`,
"`", graph.String(), "`",
))
}
// Write response.
w.Header().Set("Content-Type", mimeType+"; charset=utf-8")
w.Header().Set("Content-Length", strconv.Itoa(len(responseData)))
w.WriteHeader(http.StatusOK)
_, err := w.Write(responseData)
if err != nil {
log.Tracer(r.Context()).Warningf("api: failed to write response: %s", err)
}
}
func graphNodeLabel(pin *Pin) (s string) {
var comment string
switch {
case pin.State == StateNone:
comment = "dead"
case pin.State.Has(StateIsHomeHub):
comment = "Home"
case pin.State.HasAnyOf(StateSummaryDisregard):
comment = "disregarded"
case !pin.State.Has(StateSummaryRegard):
comment = "not regarded"
case pin.State.Has(StateTrusted):
comment = "trusted"
}
if comment != "" {
comment = fmt.Sprintf("\n(%s)", comment)
}
if pin.Hub.Status.Load >= 80 {
comment += fmt.Sprintf("\nHIGH LOAD: %d", pin.Hub.Status.Load)
}
return fmt.Sprintf(
`"%s%s"`,
strings.ReplaceAll(pin.Hub.Name(), " ", "\n"),
comment,
)
}
func graphNodeTooltip(pin *Pin) string {
// Gather IP info.
var v4Info, v6Info string
if pin.Hub.Info.IPv4 != nil {
if pin.LocationV4 != nil {
v4Info = fmt.Sprintf(
"%s (%s AS%d %s)",
pin.Hub.Info.IPv4.String(),
pin.LocationV4.Country.Code,
pin.LocationV4.AutonomousSystemNumber,
pin.LocationV4.AutonomousSystemOrganization,
)
} else {
v4Info = pin.Hub.Info.IPv4.String()
}
}
if pin.Hub.Info.IPv6 != nil {
if pin.LocationV6 != nil {
v6Info = fmt.Sprintf(
"%s (%s AS%d %s)",
pin.Hub.Info.IPv6.String(),
pin.LocationV6.Country.Code,
pin.LocationV6.AutonomousSystemNumber,
pin.LocationV6.AutonomousSystemOrganization,
)
} else {
v6Info = pin.Hub.Info.IPv6.String()
}
}
return fmt.Sprintf(
`"ID: %s
States: %s
Version: %s
IPv4: %s
IPv6: %s
Load: %d
Cost: %.2f"`,
pin.Hub.ID,
pin.State,
pin.Hub.Status.Version,
v4Info,
v6Info,
pin.Hub.Status.Load,
pin.Cost,
)
}
func graphEdgeTooltip(from, to *Pin, lane *Lane) string {
return fmt.Sprintf(
`"%s <> %s
Latency: %s
Capacity: %.2f Mbit/s
Cost: %.2f"`,
from.Hub.Info.Name, to.Hub.Info.Name,
lane.Latency,
float64(lane.Capacity)/1000000,
lane.Cost,
)
}
// Graphviz colors.
// See https://graphviz.org/doc/info/colors.html
const (
graphColorWarning = "orange2"
graphColorError = "red2"
graphColorHomeAndConnected = "steelblue2"
graphColorDisregard = "tomato2"
graphColorNotRegard = "tan2"
graphColorTrusted = "seagreen2"
graphColorDefaultNode = "seashell2"
graphColorDefaultEdge = "black"
graphColorNone = "transparent"
)
func graphNodeColor(pin *Pin) string {
switch {
case pin.State == StateNone:
return graphColorNone
case pin.Hub.Status.Load >= 95:
return graphColorError
case pin.Hub.Status.Load >= 80:
return graphColorWarning
case pin.State.Has(StateIsHomeHub):
return graphColorHomeAndConnected
case pin.State.HasAnyOf(StateSummaryDisregard):
return graphColorDisregard
case !pin.State.Has(StateSummaryRegard):
return graphColorNotRegard
case pin.State.Has(StateTrusted):
return graphColorTrusted
default:
return graphColorDefaultNode
}
}
func graphNodeBorderColor(pin *Pin) string {
switch {
case pin.HasActiveTerminal():
return graphColorHomeAndConnected
default:
return graphColorNone
}
}
func graphEdgeColor(from, to *Pin, lane *Lane) string {
// Check lane stats.
if lane.Capacity == 0 || lane.Latency == 0 {
return graphColorWarning
}
// Alert if capacity is under 10Mbit/s or latency is over 100ms.
if lane.Capacity < 10000000 || lane.Latency > 100*time.Millisecond {
return graphColorError
}
// Check for active edge forward.
if to.HasActiveTerminal() && len(to.Connection.Route.Path) >= 2 {
secondLastHopIndex := len(to.Connection.Route.Path) - 2
if to.Connection.Route.Path[secondLastHopIndex].HubID == from.Hub.ID {
return graphColorHomeAndConnected
}
}
// Check for active edge backward.
if from.HasActiveTerminal() && len(from.Connection.Route.Path) >= 2 {
secondLastHopIndex := len(from.Connection.Route.Path) - 2
if from.Connection.Route.Path[secondLastHopIndex].HubID == to.Hub.ID {
return graphColorHomeAndConnected
}
}
// Return default color if edge is not active.
return graphColorDefaultEdge
}

396
spn/navigator/api_route.go Normal file
View File

@@ -0,0 +1,396 @@
package navigator
import (
"bytes"
"errors"
"fmt"
mrand "math/rand"
"net"
"net/http"
"strings"
"text/tabwriter"
"time"
"github.com/safing/portbase/api"
"github.com/safing/portbase/config"
"github.com/safing/portmaster/service/intel"
"github.com/safing/portmaster/service/intel/geoip"
"github.com/safing/portmaster/service/netenv"
"github.com/safing/portmaster/service/network/netutils"
"github.com/safing/portmaster/service/profile"
"github.com/safing/portmaster/service/profile/endpoints"
)
func registerRouteAPIEndpoints() error {
if err := api.RegisterEndpoint(api.Endpoint{
Path: `spn/map/{map:[A-Za-z0-9]{1,255}}/route/to/{destination:[a-z0-9_\.:-]{1,255}}`,
Read: api.PermitUser,
BelongsTo: module,
ActionFunc: handleRouteCalculationRequest,
Name: "Calculate Route through SPN",
Description: "Returns a textual representation of the routing process.",
Parameters: []api.Parameter{
{
Method: http.MethodGet,
Field: "profile",
Value: "<id>|global",
Description: "Specify a profile ID to load more settings for simulation.",
},
{
Method: http.MethodGet,
Field: "encrypted",
Value: "true",
Description: "Specify to signify that the simulated connection should be regarded as encrypted. Only valid with a profile.",
},
},
}); err != nil {
return err
}
return nil
}
func handleRouteCalculationRequest(ar *api.Request) (msg string, err error) { //nolint:maintidx
// Get map.
m, ok := getMapForAPI(ar.URLVars["map"])
if !ok {
return "", errors.New("map not found")
}
// Get profile ID.
profileID := ar.Request.URL.Query().Get("profile")
// Parse destination and prepare options.
entity := &intel.Entity{}
destination := ar.URLVars["destination"]
matchFor := DestinationHub
var (
introText string
locationV4, locationV6 *geoip.Location
opts *Options
)
switch {
case destination == "":
// Destination is required.
return "", errors.New("no destination provided")
case destination == "home":
if profileID != "" {
return "", errors.New("cannot apply profile to home hub route")
}
// Simulate finding home hub.
locations, ok := netenv.GetInternetLocation()
if !ok || len(locations.All) == 0 {
return "", errors.New("failed to locate own device for finding home hub")
}
introText = fmt.Sprintf("looking for home hub near %s and %s", locations.BestV4(), locations.BestV6())
locationV4 = locations.BestV4().LocationOrNil()
locationV6 = locations.BestV6().LocationOrNil()
matchFor = HomeHub
// START of copied from captain/navigation.go
// Get own entity.
// Checking the entity against the entry policies is somewhat hit and miss
// anyway, as the device location is an approximation.
var myEntity *intel.Entity
if dl := locations.BestV4(); dl != nil && dl.IP != nil {
myEntity = (&intel.Entity{IP: dl.IP}).Init(0)
myEntity.FetchData(ar.Context())
} else if dl := locations.BestV6(); dl != nil && dl.IP != nil {
myEntity = (&intel.Entity{IP: dl.IP}).Init(0)
myEntity.FetchData(ar.Context())
}
// Build navigation options for searching for a home hub.
homePolicy, err := endpoints.ParseEndpoints(config.GetAsStringArray("spn/homePolicy", []string{})())
if err != nil {
return "", fmt.Errorf("failed to parse home hub policy: %w", err)
}
opts = &Options{
Home: &HomeHubOptions{
HubPolicies: []endpoints.Endpoints{homePolicy},
CheckHubPolicyWith: myEntity,
},
}
// Add requirement to only use Safing nodes when not using community nodes.
if !config.GetAsBool("spn/useCommunityNodes", true)() {
opts.Home.RequireVerifiedOwners = []string{"Safing"}
}
// Require a trusted home node when the routing profile requires less than two hops.
routingProfile := GetRoutingProfile(config.GetAsString(profile.CfgOptionRoutingAlgorithmKey, DefaultRoutingProfileID)())
if routingProfile.MinHops < 2 {
opts.Home.Regard = opts.Home.Regard.Add(StateTrusted)
}
// END of copied
case net.ParseIP(destination) != nil:
entity.IP = net.ParseIP(destination)
fallthrough
case netutils.IsValidFqdn(destination):
fallthrough
case netutils.IsValidFqdn(destination + "."):
// Resolve domain to IP, if not inherired from a previous case.
var ignoredIPs int
if entity.IP == nil {
entity.Domain = destination
// Resolve name to IPs.
ips, err := net.DefaultResolver.LookupIP(ar.Context(), "ip", destination)
if err != nil {
return "", fmt.Errorf("failed to lookup IP address of %s: %w", destination, err)
}
if len(ips) == 0 {
return "", fmt.Errorf("failed to lookup IP address of %s: no result", destination)
}
// Shuffle IPs.
if len(ips) >= 2 {
mr := mrand.New(mrand.NewSource(time.Now().UnixNano())) //nolint:gosec
mr.Shuffle(len(ips), func(i, j int) {
ips[i], ips[j] = ips[j], ips[i]
})
}
entity.IP = ips[0]
ignoredIPs = len(ips) - 1
}
entity.Init(0)
// Get location of IP.
location, ok := entity.GetLocation(ar.Context())
if !ok {
return "", fmt.Errorf("failed to get geoip location for %s: %s", entity.IP, entity.LocationError)
}
// Assign location to separate variables.
if entity.IP.To4() != nil {
locationV4 = location
} else {
locationV6 = location
}
// Set intro text.
if entity.Domain != "" {
introText = fmt.Sprintf("looking for route to %s at %s\n(ignoring %d additional IPs returned by DNS)", entity.IP, formatLocation(location), ignoredIPs)
} else {
introText = fmt.Sprintf("looking for route to %s at %s", entity.IP, formatLocation(location))
}
// Get profile.
if profileID != "" {
var lp *profile.LayeredProfile
if profileID == "global" {
// Create new empty profile for easy access to global settings.
lp = profile.NewLayeredProfile(profile.New(nil))
} else {
// Get local profile by ID.
localProfile, err := profile.GetLocalProfile(profileID, nil, nil)
if err != nil {
return "", fmt.Errorf("failed to get profile: %w", err)
}
lp = localProfile.LayeredProfile()
}
opts = DeriveTunnelOptions(
lp,
entity,
ar.Request.URL.Query().Has("encrypted"),
)
} else {
opts = m.defaultOptions()
}
default:
return "", errors.New("invalid destination provided")
}
// Finalize entity.
entity.Init(0)
// Start formatting output.
lines := []string{
"Routing simulation: " + introText,
"Please note that this routing simulation does match the behavior of regular routing to 100%.",
"",
}
// Print options.
// ==================
lines = append(lines, "Routing Options:")
lines = append(lines, "Algorithm: "+opts.RoutingProfile)
if opts.Home != nil {
lines = append(lines, "Home Options:")
lines = append(lines, fmt.Sprintf(" Regard: %s", opts.Home.Regard))
lines = append(lines, fmt.Sprintf(" Disregard: %s", opts.Home.Disregard))
lines = append(lines, fmt.Sprintf(" No Default: %v", opts.Home.NoDefaults))
lines = append(lines, fmt.Sprintf(" Hub Policies: %v", opts.Home.HubPolicies))
lines = append(lines, fmt.Sprintf(" Require Verified Owners: %v", opts.Home.RequireVerifiedOwners))
}
if opts.Transit != nil {
lines = append(lines, "Transit Options:")
lines = append(lines, fmt.Sprintf(" Regard: %s", opts.Transit.Regard))
lines = append(lines, fmt.Sprintf(" Disregard: %s", opts.Transit.Disregard))
lines = append(lines, fmt.Sprintf(" No Default: %v", opts.Transit.NoDefaults))
lines = append(lines, fmt.Sprintf(" Hub Policies: %v", opts.Transit.HubPolicies))
lines = append(lines, fmt.Sprintf(" Require Verified Owners: %v", opts.Transit.RequireVerifiedOwners))
}
if opts.Destination != nil {
lines = append(lines, "Destination Options:")
lines = append(lines, fmt.Sprintf(" Regard: %s", opts.Destination.Regard))
lines = append(lines, fmt.Sprintf(" Disregard: %s", opts.Destination.Disregard))
lines = append(lines, fmt.Sprintf(" No Default: %v", opts.Destination.NoDefaults))
lines = append(lines, fmt.Sprintf(" Hub Policies: %v", opts.Destination.HubPolicies))
lines = append(lines, fmt.Sprintf(" Require Verified Owners: %v", opts.Destination.RequireVerifiedOwners))
if opts.Destination.CheckHubPolicyWith != nil {
lines = append(lines, " Check Hub Policy With:")
if opts.Destination.CheckHubPolicyWith.Domain != "" {
lines = append(lines, fmt.Sprintf(" Domain: %v", opts.Destination.CheckHubPolicyWith.Domain))
}
if opts.Destination.CheckHubPolicyWith.IP != nil {
lines = append(lines, fmt.Sprintf(" IP: %v", opts.Destination.CheckHubPolicyWith.IP))
}
if opts.Destination.CheckHubPolicyWith.Port != 0 {
lines = append(lines, fmt.Sprintf(" Port: %v", opts.Destination.CheckHubPolicyWith.Port))
}
}
}
lines = append(lines, "\n")
// Find nearest hubs.
// ==================
// Start operating in map.
m.RLock()
defer m.RUnlock()
// Check if map is populated.
if m.isEmpty() {
return "", ErrEmptyMap
}
// Find nearest hubs.
nbPins, err := m.findNearestPins(locationV4, locationV6, opts, matchFor, true)
if err != nil {
lines = append(lines, fmt.Sprintf("FAILED to find any suitable exit hub: %s", err))
return strings.Join(lines, "\n"), nil
// return "", fmt.Errorf("failed to search for nearby pins: %w", err)
}
// Print found exits to table.
lines = append(lines, "Considered Exits (cheapest 10% are shuffled)")
buf := bytes.NewBuffer(nil)
tabWriter := tabwriter.NewWriter(buf, 8, 4, 3, ' ', 0)
fmt.Fprint(tabWriter, "Hub Name\tCost\tLocation\n")
for _, nbPin := range nbPins.pins {
fmt.Fprintf(tabWriter,
"%s\t%.0f\t%s\n",
nbPin.pin.Hub.Name(),
nbPin.cost,
formatMultiLocation(nbPin.pin.LocationV4, nbPin.pin.LocationV6),
)
}
_ = tabWriter.Flush()
lines = append(lines, buf.String())
// Print too expensive exits to table.
lines = append(lines, "Too Expensive Exits:")
buf = bytes.NewBuffer(nil)
tabWriter = tabwriter.NewWriter(buf, 8, 4, 3, ' ', 0)
fmt.Fprint(tabWriter, "Hub Name\tCost\tLocation\n")
for _, nbPin := range nbPins.debug.tooExpensive {
fmt.Fprintf(tabWriter,
"%s\t%.0f\t%s\n",
nbPin.pin.Hub.Name(),
nbPin.cost,
formatMultiLocation(nbPin.pin.LocationV4, nbPin.pin.LocationV6),
)
}
_ = tabWriter.Flush()
lines = append(lines, buf.String())
// Print disregarded exits to table.
lines = append(lines, "Disregarded Exits:")
buf = bytes.NewBuffer(nil)
tabWriter = tabwriter.NewWriter(buf, 8, 4, 3, ' ', 0)
fmt.Fprint(tabWriter, "Hub Name\tReason\tStates\n")
for _, nbPin := range nbPins.debug.disregarded {
fmt.Fprintf(tabWriter,
"%s\t%s\t%s\n",
nbPin.pin.Hub.Name(),
nbPin.reason,
nbPin.pin.State,
)
}
_ = tabWriter.Flush()
lines = append(lines, buf.String())
// Find routes.
// ============
// Unless we looked for a home node.
if destination == "home" {
return strings.Join(lines, "\n"), nil
}
// Find routes.
routes, err := m.findRoutes(nbPins, opts)
if err != nil {
lines = append(lines, fmt.Sprintf("FAILED to find routes: %s", err))
return strings.Join(lines, "\n"), nil
// return "", fmt.Errorf("failed to find routes: %w", err)
}
// Print found routes to table.
lines = append(lines, "Considered Routes (cheapest 10% are shuffled)")
buf = bytes.NewBuffer(nil)
tabWriter = tabwriter.NewWriter(buf, 8, 4, 3, ' ', 0)
fmt.Fprint(tabWriter, "Cost\tPath\n")
for _, route := range routes.All {
fmt.Fprintf(tabWriter,
"%.0f\t%s\n",
route.TotalCost,
formatRoute(route, entity.IP),
)
}
_ = tabWriter.Flush()
lines = append(lines, buf.String())
return strings.Join(lines, "\n"), nil
}
func formatLocation(loc *geoip.Location) string {
return fmt.Sprintf(
"%s (%s - AS%d %s)",
loc.Country.Name,
loc.Country.Code,
loc.AutonomousSystemNumber,
loc.AutonomousSystemOrganization,
)
}
func formatMultiLocation(a, b *geoip.Location) string {
switch {
case a != nil:
return formatLocation(a)
case b != nil:
return formatLocation(b)
default:
return ""
}
}
func formatRoute(r *Route, dst net.IP) string {
s := make([]string, 0, len(r.Path)+1)
for i, hop := range r.Path {
if i == 0 {
s = append(s, hop.pin.Hub.Name())
} else {
s = append(s, fmt.Sprintf(">> %.2fc >> %s", hop.Cost, hop.pin.Hub.Name()))
}
}
s = append(s, fmt.Sprintf(">> %.2fc >> %s", r.DstCost, dst))
return strings.Join(s, " ")
}

72
spn/navigator/costs.go Normal file
View File

@@ -0,0 +1,72 @@
package navigator
import "time"
const (
nearestPinsMaxCostDifference = 5000
nearestPinsMinimum = 10
)
// CalculateLaneCost calculates the cost of using a Lane based on the given
// Lane latency and capacity.
// Ranges from 0 to 10000.
func CalculateLaneCost(latency time.Duration, capacity int) (cost float32) {
// - One point for every ms in latency (linear)
if latency != 0 {
cost += float32(latency) / float32(time.Millisecond)
} else {
// Add cautious default cost if latency is not available.
cost += 1000
}
capacityFloat := float32(capacity)
switch {
case capacityFloat == 0:
// Add cautious default cost if capacity is not available.
cost += 4000
case capacityFloat < cap1Mbit:
// - Between 1000 and 10000 points for ranges below 1Mbit/s
cost += 1000 + 9000*((cap1Mbit-capacityFloat)/cap1Mbit)
case capacityFloat < cap10Mbit:
// - Between 100 and 1000 points for ranges below 10Mbit/s
cost += 100 + 900*((cap10Mbit-capacityFloat)/cap10Mbit)
case capacityFloat < cap100Mbit:
// - Between 20 and 100 points for ranges below 100Mbit/s
cost += 20 + 80*((cap100Mbit-capacityFloat)/cap100Mbit)
case capacityFloat < cap1Gbit:
// - Between 5 and 20 points for ranges below 1Gbit/s
cost += 5 + 15*((cap1Gbit-capacityFloat)/cap1Gbit)
case capacityFloat < cap10Gbit:
// - Between 0 and 5 points for ranges below 10Gbit/s
cost += 5 * ((cap10Gbit - float32(capacity)) / cap10Gbit)
}
return cost
}
// CalculateHubCost calculates the cost of using a Hub based on the given Hub load.
// Ranges from 100 to 10000.
func CalculateHubCost(load int) (cost float32) {
switch {
case load >= 100:
return 10000
case load >= 95:
return 1000
case load >= 80:
return 500
default:
return 100
}
}
// CalculateDestinationCost calculates the cost of a destination hub to a
// destination server based on the given proximity.
// Ranges from 0 to 2500.
func CalculateDestinationCost(proximity float32) (cost float32) {
// Invert from proximity (0-100) to get a distance value.
distance := 100 - proximity
// Take the distance to the power of three and then divide by hundred in order to
// make high distances exponentially more expensive.
return (distance * distance * distance) / 100
}

164
spn/navigator/database.go Normal file
View File

@@ -0,0 +1,164 @@
package navigator
import (
"context"
"fmt"
"strings"
"github.com/safing/portbase/database"
"github.com/safing/portbase/database/iterator"
"github.com/safing/portbase/database/query"
"github.com/safing/portbase/database/record"
"github.com/safing/portbase/database/storage"
)
var mapDBController *database.Controller
// StorageInterface provices a storage.Interface to the
// configuration manager.
type StorageInterface struct {
storage.InjectBase
}
// Database prefixes:
// Pins: map:main/<Hub ID>
// DNS Requests: network:tree/<PID>/dns/<ID>
// IP Connections: network:tree/<PID>/ip/<ID>
func makeDBKey(mapName, hubID string) string {
return fmt.Sprintf("map:%s/%s", mapName, hubID)
}
func parseDBKey(key string) (mapName, hubID string) {
// Split into segments.
segments := strings.Split(key, "/")
// Keys have 1 or 2 segments.
switch len(segments) {
case 1:
return segments[0], ""
case 2:
return segments[0], segments[1]
default:
return "", ""
}
}
// Get returns a database record.
func (s *StorageInterface) Get(key string) (record.Record, error) {
// Parse key and check if valid.
mapName, hubID := parseDBKey(key)
if mapName == "" || hubID == "" {
return nil, storage.ErrNotFound
}
// Get map.
m, ok := getMapForAPI(mapName)
if !ok {
return nil, storage.ErrNotFound
}
// Get Pin from map.
pin, ok := m.GetPin(hubID)
if !ok {
return nil, storage.ErrNotFound
}
return pin.Export(), nil
}
// Query returns a an iterator for the supplied query.
func (s *StorageInterface) Query(q *query.Query, local, internal bool) (*iterator.Iterator, error) {
// Parse key and check if valid.
mapName, _ := parseDBKey(q.DatabaseKeyPrefix())
if mapName == "" {
return nil, storage.ErrNotFound
}
// Get map.
m, ok := getMapForAPI(mapName)
if !ok {
return nil, storage.ErrNotFound
}
// Start query worker.
it := iterator.New()
module.StartWorker("map query", func(_ context.Context) error {
s.processQuery(m, q, it)
return nil
})
return it, nil
}
func (s *StorageInterface) processQuery(m *Map, q *query.Query, it *iterator.Iterator) {
// Return all matching pins.
for _, pin := range m.sortedPins(true) {
export := pin.Export()
if q.Matches(export) {
select {
case it.Next <- export:
case <-it.Done:
return
}
}
}
it.Finish(nil)
}
func registerMapDatabase() error {
_, err := database.Register(&database.Database{
Name: "map",
Description: "SPN Network Maps",
StorageType: database.StorageTypeInjected,
})
if err != nil {
return err
}
controller, err := database.InjectDatabase("map", &StorageInterface{})
if err != nil {
return err
}
mapDBController = controller
return nil
}
func withdrawMapDatabase() {
mapDBController.Withdraw()
}
// PushPinChanges pushes all changed pins to subscribers.
func (m *Map) PushPinChanges() {
module.StartWorker("push pin changes", m.pushPinChangesWorker)
}
func (m *Map) pushPinChangesWorker(ctx context.Context) error {
m.RLock()
defer m.RUnlock()
for _, pin := range m.all {
if pin.pushChanges.SetToIf(true, false) {
mapDBController.PushUpdate(pin.Export())
}
}
return nil
}
// pushChange pushes changes of the pin, if the pushChanges flag is set.
func (pin *Pin) pushChange() {
// Check before starting the worker.
if pin.pushChanges.IsNotSet() {
return
}
// Start worker to push changes.
module.StartWorker("push pin change", func(ctx context.Context) error {
if pin.pushChanges.SetToIf(true, false) {
mapDBController.PushUpdate(pin.Export())
}
return nil
})
}

View 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
}
}

View File

@@ -0,0 +1,124 @@
package navigator
import (
"testing"
)
func TestFindNearest(t *testing.T) {
t.Parallel()
// Create map and lock faking in order to guarantee reproducability of faked data.
m := getDefaultTestMap()
fakeLock.Lock()
defer fakeLock.Unlock()
for i := 0; i < 100; i++ {
// Create a random destination address
ip4, loc4 := createGoodIP(true)
nbPins, err := m.findNearestPins(loc4, nil, m.DefaultOptions(), DestinationHub, false)
if err != nil {
t.Error(err)
} else {
t.Logf("Pins near %s: %s", ip4, nbPins)
}
}
for i := 0; i < 100; i++ {
// Create a random destination address
ip6, loc6 := createGoodIP(true)
nbPins, err := m.findNearestPins(nil, loc6, m.DefaultOptions(), DestinationHub, false)
if err != nil {
t.Error(err)
} else {
t.Logf("Pins near %s: %s", ip6, nbPins)
}
}
}
/*
TODO: Find a way to quickly generate good geoip data on the fly, as we don't want to measure IP address generation, but only finding the nearest pins.
func BenchmarkFindNearest(b *testing.B) {
// Create map and lock faking in order to guarantee reproducability of faked data.
m := getDefaultTestMap()
fakeLock.Lock()
defer fakeLock.Unlock()
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Create a random destination address
var dstIP net.IP
if i%2 == 0 {
dstIP = net.ParseIP(gofakeit.IPv4Address())
} else {
dstIP = net.ParseIP(gofakeit.IPv6Address())
}
_, err := m.findNearestPins(dstIP, m.DefaultOptions(),DestinationHub if err != nil {
b.Error(err)
}
}
}
*/
func findFakeHomeHub(m *Map) {
// Create fake IP address.
_, loc4 := createGoodIP(true)
_, loc6 := createGoodIP(false)
nbPins, err := m.findNearestPins(loc4, loc6, m.defaultOptions(), HomeHub, false)
if err != nil {
panic(err)
}
if len(nbPins.pins) == 0 {
panic("could not find a Home Hub")
}
// Set Home.
m.home = nbPins.pins[0].pin
// Recalculate reachability.
if err := m.recalculateReachableHubs(); err != nil {
panic(err)
}
}
func TestNearbyPinsCleaning(t *testing.T) {
t.Parallel()
testCleaning(t, []float32{10, 20, 30, 40, 50, 60, 70, 80, 90, 100}, 3)
testCleaning(t, []float32{10, 11, 12, 13, 50, 60, 70, 80, 90, 100}, 4)
testCleaning(t, []float32{10, 11, 12, 40, 50, 60, 70, 80, 90, 100}, 3)
testCleaning(t, []float32{10, 11, 30, 40, 50, 60, 70, 80, 90, 100}, 3)
}
func testCleaning(t *testing.T, costs []float32, expectedLeftOver int) {
t.Helper()
nb := &nearbyPins{
minPins: 3,
maxPins: 5,
cutOffLimit: 10,
}
// Simulate usage.
for _, cost := range costs {
// Add to list.
nb.add(nil, cost)
// Clean once in a while.
if len(nb.pins) > nb.maxPins {
nb.clean()
}
}
// Final clean.
nb.clean()
// Check results.
t.Logf("result: %+v", nb.pins)
if len(nb.pins) != expectedLeftOver {
t.Errorf("unexpected amount of left over pins: %+v", nb.pins)
}
}

234
spn/navigator/findroutes.go Normal file
View File

@@ -0,0 +1,234 @@
package navigator
import (
"errors"
"fmt"
"net"
"github.com/safing/portmaster/service/intel/geoip"
)
const (
// defaultMaxRouteMatches defines a default value of how many matches a
// route find operation in a map should return.
defaultMaxRouteMatches = 10
// defaultRandomizeRoutesTopPercent defines the top percent of a routes
// set that should be randomized for balancing purposes.
// Range: 0-1.
defaultRandomizeRoutesTopPercent = 0.1
)
// FindRoutes finds possible routes to the given IP, with the given options.
func (m *Map) FindRoutes(ip net.IP, opts *Options) (*Routes, error) {
m.Lock()
defer m.Unlock()
// Check if map is populated.
if m.isEmpty() {
return nil, ErrEmptyMap
}
// Check if home hub is set.
if m.home == nil {
return nil, ErrHomeHubUnset
}
// Get the location of the given IP address.
var locationV4, locationV6 *geoip.Location
var err error
// Save whether the given IP address is a IPv4 or IPv6 address.
if v4 := ip.To4(); v4 != nil {
locationV4, err = geoip.GetLocation(ip)
} else {
locationV6, err = geoip.GetLocation(ip)
}
if err != nil {
return nil, fmt.Errorf("failed to get IP location: %w", err)
}
// Set default options if unset.
if opts == nil {
opts = m.defaultOptions()
}
// Handle special home routing profile.
if opts.RoutingProfile == RoutingProfileHomeID {
switch {
case locationV4 != nil && m.home.LocationV4 == nil:
// Destination is IPv4, but Hub has no IPv4!
// Upgrade routing profile.
opts.RoutingProfile = RoutingProfileSingleHopID
case locationV6 != nil && m.home.LocationV6 == nil:
// Destination is IPv6, but Hub has no IPv6!
// Upgrade routing profile.
opts.RoutingProfile = RoutingProfileSingleHopID
default:
// Return route with only home hub for home hub routing.
return &Routes{
All: []*Route{{
Path: []*Hop{{
pin: m.home,
HubID: m.home.Hub.ID,
}},
Algorithm: RoutingProfileHomeID,
}},
}, nil
}
}
// Find nearest Pins.
nearby, err := m.findNearestPins(locationV4, locationV6, opts, DestinationHub, false)
if err != nil {
return nil, err
}
return m.findRoutes(nearby, opts)
}
// FindRouteToHub finds possible routes to the given Hub, with the given options.
func (m *Map) FindRouteToHub(hubID string, opts *Options) (*Routes, error) {
m.Lock()
defer m.Unlock()
// Get Pin.
pin, ok := m.all[hubID]
if !ok {
return nil, ErrHubNotFound
}
// Create a nearby with a single Pin.
nearby := &nearbyPins{
pins: []*nearbyPin{
{
pin: pin,
},
},
}
// Find a route to the given Hub.
return m.findRoutes(nearby, opts)
}
func (m *Map) findRoutes(dsts *nearbyPins, opts *Options) (*Routes, error) {
if m.home == nil {
return nil, ErrHomeHubUnset
}
// Initialize matchers.
var done bool
transitMatcher := opts.Transit.Matcher(m.intel)
destinationMatcher := opts.Destination.Matcher(m.intel)
routingProfile := GetRoutingProfile(opts.RoutingProfile)
// Create routes collector.
routes := &Routes{
maxRoutes: defaultMaxRouteMatches,
randomizeTopPercent: defaultRandomizeRoutesTopPercent,
}
// TODO:
// Start from the destination and use HopDistance to prioritize
// exploring routes that are in the right direction.
// How would we handle selecting the destination node based on route to client?
// Should we just try all destinations?
// Create initial route.
route := &Route{
// Estimate how much space we will need, else it'll just expand.
Path: make([]*Hop, 1, routingProfile.MinHops+routingProfile.MaxExtraHops),
}
route.Path[0] = &Hop{
pin: m.home,
// TODO: add initial cost
}
// exploreHop explores a hop (Lane) to a connected Pin.
var exploreHop func(route *Route, lane *Lane)
// exploreLanes explores all Lanes of a Pin.
exploreLanes := func(route *Route) {
for _, lane := range route.Path[len(route.Path)-1].pin.ConnectedTo {
// Check if we are done and can skip the rest.
if done {
return
}
// Explore!
exploreHop(route, lane)
}
}
exploreHop = func(route *Route, lane *Lane) {
// Check if the Pin should be regarded as Transit Hub.
if !transitMatcher(lane.Pin) {
return
}
// Add Pin to the current path and remove when done.
route.addHop(lane.Pin, lane.Cost+lane.Pin.Cost)
defer route.removeHop()
// Check if the route would even make it into the list.
if !routes.isGoodEnough(route) {
return
}
// Check route compliance.
// This also includes some algorithm-based optimizations.
switch routingProfile.checkRouteCompliance(route, routes) {
case routeOk:
// Route would be compliant.
// Now, check if the last hop qualifies as a Destination Hub.
if destinationMatcher(lane.Pin) {
// Get Pin as nearby Pin.
nbPin := dsts.get(lane.Pin.Hub.ID)
if nbPin != nil {
// Pin is listed as selected Destination Hub!
// Complete route to add destination ("last mile") cost.
route.completeRoute(nbPin.cost)
routes.add(route)
// We have found a route and have come to an end here.
return
}
}
// The Route is compliant, but we haven't found a Destination Hub yet.
fallthrough
case routeNonCompliant:
// Continue exploration.
exploreLanes(route)
case routeDisqualified:
fallthrough
default:
// Route is disqualified and we can return without further exploration.
}
}
// Start the hop exploration tree.
// This will fork into about a gazillion branches and add all the found valid
// routes to the list.
exploreLanes(route)
// Check if we found anything.
if len(routes.All) == 0 {
return nil, errors.New("failed to find any routes")
}
// Randomize top routes for load balancing.
routes.randomizeTop()
// Copy remaining data to routes.
routes.makeExportReady(opts.RoutingProfile)
// Debugging:
// log.Debug("spn/navigator: routes:")
// for _, route := range routes.All {
// log.Debugf("spn/navigator: %s", route)
// }
return routes, nil
}

View File

@@ -0,0 +1,54 @@
package navigator
import (
"net"
"testing"
)
func TestFindRoutes(t *testing.T) {
t.Parallel()
// Create map and lock faking in order to guarantee reproducability of faked data.
m := getOptimizedDefaultTestMap(t)
fakeLock.Lock()
defer fakeLock.Unlock()
for i := 0; i < 1; i++ {
// Create a random destination address
dstIP, _ := createGoodIP(i%2 == 0)
routes, err := m.FindRoutes(dstIP, m.DefaultOptions())
switch {
case err != nil:
t.Error(err)
case len(routes.All) == 0:
t.Logf("No routes for %s", dstIP)
default:
t.Logf("Best route for %s: %s", dstIP, routes.All[0])
}
}
}
func BenchmarkFindRoutes(b *testing.B) {
// Create map and lock faking in order to guarantee reproducability of faked data.
m := getOptimizedDefaultTestMap(nil)
fakeLock.Lock()
defer fakeLock.Unlock()
// Pre-generate 100 IPs
preGenIPs := make([]net.IP, 0, 100)
for i := 0; i < cap(preGenIPs); i++ {
ip, _ := createGoodIP(i%2 == 0)
preGenIPs = append(preGenIPs, ip)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
routes, err := m.FindRoutes(preGenIPs[i%len(preGenIPs)], m.DefaultOptions())
if err != nil {
b.Error(err)
} else {
b.Logf("Best route for %s: %s", preGenIPs[i%len(preGenIPs)], routes.All[0])
}
}
}

222
spn/navigator/intel.go Normal file
View File

@@ -0,0 +1,222 @@
package navigator
import (
"context"
"errors"
"golang.org/x/exp/slices"
"github.com/safing/portbase/log"
"github.com/safing/portmaster/service/intel/geoip"
"github.com/safing/portmaster/service/profile/endpoints"
"github.com/safing/portmaster/spn/hub"
)
// UpdateIntel supplies the map with new intel data. The data is not copied, so
// it must not be modified after being supplied. If the map is empty, the
// bootstrap hubs will be added to the map.
func (m *Map) UpdateIntel(update *hub.Intel, trustNodes []string) error {
// Check if intel data is already parsed.
if update.Parsed() == nil {
return errors.New("intel data is not parsed")
}
m.Lock()
defer m.Unlock()
// Update the map's reference to the intel data.
m.intel = update
// Update pins with new intel data.
for _, pin := range m.all {
// Add/Update location data from IP addresses.
pin.updateLocationData()
// Override Pin Data.
m.updateInfoOverrides(pin)
// Update Trust and Advisory Statuses.
m.updateIntelStatuses(pin, trustNodes)
// Push changes.
// TODO: Only set when pin changed.
pin.pushChanges.Set()
}
// Configure the map's regions.
m.updateRegions(m.intel.Regions)
// Push pin changes.
m.PushPinChanges()
log.Infof("spn/navigator: updated intel on map %s", m.Name)
// Add bootstrap hubs if map is empty.
if m.isEmpty() {
return m.addBootstrapHubs(m.intel.BootstrapHubs)
}
return nil
}
// GetIntel returns the map's intel data.
func (m *Map) GetIntel() *hub.Intel {
m.RLock()
defer m.RUnlock()
return m.intel
}
func (m *Map) updateIntelStatuses(pin *Pin, trustNodes []string) {
// Reset all related states.
pin.removeStates(StateTrusted | StateUsageDiscouraged | StateUsageAsHomeDiscouraged | StateUsageAsDestinationDiscouraged)
// Check if Intel data is loaded.
if m.intel == nil {
return
}
// Check Hub Intel
hubIntel, ok := m.intel.Hubs[pin.Hub.ID]
if ok {
// Apply the verified owner, if any.
pin.VerifiedOwner = hubIntel.VerifiedOwner
// Check if Hub is discontinued.
if hubIntel.Discontinued {
// Reset state, set offline and return.
pin.State = StateNone
pin.addStates(StateOffline)
return
}
// Check if Hub is trusted.
if hubIntel.Trusted {
pin.addStates(StateTrusted)
}
}
// Check manual trust status.
switch {
case slices.Contains[[]string, string](trustNodes, pin.VerifiedOwner):
pin.addStates(StateTrusted)
case slices.Contains[[]string, string](trustNodes, pin.Hub.ID):
pin.addStates(StateTrusted)
}
// Check advisories.
// Check for UsageDiscouraged.
checkStatusList(
pin,
StateUsageDiscouraged,
m.intel.AdviseOnlyTrustedHubs,
m.intel.Parsed().HubAdvisory,
)
// Check for UsageAsHomeDiscouraged.
checkStatusList(
pin,
StateUsageAsHomeDiscouraged,
m.intel.AdviseOnlyTrustedHomeHubs,
m.intel.Parsed().HomeHubAdvisory,
)
// Check for UsageAsDestinationDiscouraged.
checkStatusList(
pin,
StateUsageAsDestinationDiscouraged,
m.intel.AdviseOnlyTrustedDestinationHubs,
m.intel.Parsed().DestinationHubAdvisory,
)
}
func checkStatusList(pin *Pin, state PinState, requireTrusted bool, endpointList endpoints.Endpoints) {
if requireTrusted && !pin.State.Has(StateTrusted) {
pin.addStates(state)
return
}
if pin.EntityV4 != nil {
result, _ := endpointList.Match(context.TODO(), pin.EntityV4)
if result == endpoints.Denied {
pin.addStates(state)
return
}
}
if pin.EntityV6 != nil {
result, _ := endpointList.Match(context.TODO(), pin.EntityV6)
if result == endpoints.Denied {
pin.addStates(state)
}
}
}
func (m *Map) updateInfoOverrides(pin *Pin) {
// Check if Intel data is loaded and if there are any overrides.
if m.intel == nil {
return
}
// Get overrides for this pin.
hubIntel, ok := m.intel.Hubs[pin.Hub.ID]
if !ok || hubIntel.Override == nil {
return
}
overrides := hubIntel.Override
// Apply overrides
if overrides.CountryCode != "" {
if pin.LocationV4 != nil {
pin.LocationV4.Country = geoip.GetCountryInfo(overrides.CountryCode)
}
if pin.EntityV4 != nil {
pin.EntityV4.Country = overrides.CountryCode
}
if pin.LocationV6 != nil {
pin.LocationV6.Country = geoip.GetCountryInfo(overrides.CountryCode)
}
if pin.EntityV6 != nil {
pin.EntityV6.Country = overrides.CountryCode
}
}
if overrides.Coordinates != nil {
if pin.LocationV4 != nil {
pin.LocationV4.Coordinates = *overrides.Coordinates
}
if pin.EntityV4 != nil {
pin.EntityV4.Coordinates = overrides.Coordinates
}
if pin.LocationV6 != nil {
pin.LocationV6.Coordinates = *overrides.Coordinates
}
if pin.EntityV6 != nil {
pin.EntityV6.Coordinates = overrides.Coordinates
}
}
if overrides.ASN != 0 {
if pin.LocationV4 != nil {
pin.LocationV4.AutonomousSystemNumber = overrides.ASN
}
if pin.EntityV4 != nil {
pin.EntityV4.ASN = overrides.ASN
}
if pin.LocationV6 != nil {
pin.LocationV6.AutonomousSystemNumber = overrides.ASN
}
if pin.EntityV6 != nil {
pin.EntityV6.ASN = overrides.ASN
}
}
if overrides.ASOrg != "" {
if pin.LocationV4 != nil {
pin.LocationV4.AutonomousSystemOrganization = overrides.ASOrg
}
if pin.EntityV4 != nil {
pin.EntityV4.ASOrg = overrides.ASOrg
}
if pin.LocationV6 != nil {
pin.LocationV6.AutonomousSystemOrganization = overrides.ASOrg
}
if pin.EntityV6 != nil {
pin.EntityV6.ASOrg = overrides.ASOrg
}
}
}

165
spn/navigator/map.go Normal file
View File

@@ -0,0 +1,165 @@
package navigator
import (
"sort"
"sync"
"time"
"github.com/safing/portbase/database"
"github.com/safing/portbase/log"
"github.com/safing/portmaster/service/intel/geoip"
"github.com/safing/portmaster/spn/docks"
"github.com/safing/portmaster/spn/hub"
)
// Map represent a collection of Pins and their relationship and status.
type Map struct {
sync.RWMutex
Name string
all map[string]*Pin
intel *hub.Intel
regions []*Region
home *Pin
homeTerminal *docks.CraneTerminal
measuringEnabled bool
hubUpdateHook *database.RegisteredHook
// analysisLock guards access to all of this map's Pin.analysis,
// regardedPins and the lastDesegrationAttempt fields.
analysisLock sync.Mutex
regardedPins []*Pin
lastDesegrationAttempt time.Time
}
// NewMap returns a new and empty Map.
func NewMap(name string, enableMeasuring bool) *Map {
m := &Map{
Name: name,
all: make(map[string]*Pin),
measuringEnabled: enableMeasuring,
}
addMapToAPI(m)
return m
}
// Close removes the map's integration, taking it "offline".
func (m *Map) Close() {
removeMapFromAPI(m.Name)
}
// GetPin returns the Pin of the Hub with the given ID.
func (m *Map) GetPin(hubID string) (pin *Pin, ok bool) {
m.RLock()
defer m.RUnlock()
pin, ok = m.all[hubID]
return
}
// GetHome returns the current home and it's accompanying terminal.
// Both may be nil.
func (m *Map) GetHome() (*Pin, *docks.CraneTerminal) {
m.RLock()
defer m.RUnlock()
return m.home, m.homeTerminal
}
// SetHome sets the given hub as the new home. Optionally, a terminal may be
// supplied to accompany the home hub.
func (m *Map) SetHome(id string, t *docks.CraneTerminal) (ok bool) {
m.Lock()
defer m.Unlock()
// Get pin from map.
newHome, ok := m.all[id]
if !ok {
return false
}
// Remove home hub state from all pins.
for _, pin := range m.all {
pin.removeStates(StateIsHomeHub)
}
// Set pin as home.
m.home = newHome
m.homeTerminal = t
m.home.addStates(StateIsHomeHub)
// Recalculate reachable.
err := m.recalculateReachableHubs()
if err != nil {
log.Warningf("spn/navigator: failed to recalculate reachable hubs: %s", err)
}
m.PushPinChanges()
return true
}
// GetAvailableCountries returns a map of countries including their information
// where the map has pins suitable for the given type.
func (m *Map) GetAvailableCountries(opts *Options, forType HubType) map[string]*geoip.CountryInfo {
if opts == nil {
opts = m.defaultOptions()
}
m.RLock()
defer m.RUnlock()
matcher := opts.Matcher(forType, m.intel)
countries := make(map[string]*geoip.CountryInfo)
for _, pin := range m.all {
if !matcher(pin) {
continue
}
if pin.LocationV4 != nil && countries[pin.LocationV4.Country.Code] == nil {
countries[pin.LocationV4.Country.Code] = &pin.LocationV4.Country
}
if pin.LocationV6 != nil && countries[pin.LocationV6.Country.Code] == nil {
countries[pin.LocationV6.Country.Code] = &pin.LocationV6.Country
}
}
return countries
}
// isEmpty returns whether the Map is regarded as empty.
func (m *Map) isEmpty() bool {
if m.home != nil {
// When a home hub is set, we also regard a map with only one entry to be
// empty, as this will be the case for Hubs, which will have their own
// entry in the Map.
return len(m.all) <= 1
}
return len(m.all) == 0
}
func (m *Map) pinList(lockMap bool) []*Pin {
if lockMap {
m.RLock()
defer m.RUnlock()
}
// Copy into slice.
list := make([]*Pin, 0, len(m.all))
for _, pin := range m.all {
list = append(list, pin)
}
return list
}
func (m *Map) sortedPins(lockMap bool) []*Pin {
// Get list.
list := m.pinList(lockMap)
// Sort list.
sort.Sort(sortByPinID(list))
return list
}

View File

@@ -0,0 +1,85 @@
package navigator
import (
"fmt"
"sort"
"strings"
)
// MapStats holds generic map statistics.
type MapStats struct {
Name string
States map[PinState]int
Lanes map[int]int
ActiveTerminals int
}
// Stats collects and returns statistics from the map.
func (m *Map) Stats() *MapStats {
m.Lock()
defer m.Unlock()
// Create stats struct.
stats := &MapStats{
Name: m.Name,
States: make(map[PinState]int),
Lanes: make(map[int]int),
}
for _, state := range allStates {
stats.States[state] = 0
}
// Iterate over all Pins to collect data.
for _, pin := range m.all {
// Count active terminals.
if pin.HasActiveTerminal() {
stats.ActiveTerminals++
}
// Check all states.
for _, state := range allStates {
if pin.State.Has(state) {
stats.States[state]++
}
}
// Count lanes.
laneCnt, ok := stats.Lanes[len(pin.ConnectedTo)]
if ok {
stats.Lanes[len(pin.ConnectedTo)] = laneCnt + 1
} else {
stats.Lanes[len(pin.ConnectedTo)] = 1
}
}
return stats
}
func (ms *MapStats) String() string {
var builder strings.Builder
// Write header.
fmt.Fprintf(&builder, "Stats for Map %s:\n", ms.Name)
// Write State Stats
stateSummary := make([]string, 0, len(ms.States))
for state, cnt := range ms.States {
stateSummary = append(stateSummary, fmt.Sprintf("State %s: %d Hubs", state, cnt))
}
sort.Strings(stateSummary)
for _, stateSum := range stateSummary {
fmt.Fprintln(&builder, stateSum)
}
// Write Lane Stats
laneStats := make([]string, 0, len(ms.Lanes))
for laneCnt, pinCnt := range ms.Lanes {
laneStats = append(laneStats, fmt.Sprintf("%d Lanes: %d Hubs", laneCnt, pinCnt))
}
sort.Strings(laneStats)
for _, laneStat := range laneStats {
fmt.Fprintln(&builder, laneStat)
}
return builder.String()
}

279
spn/navigator/map_test.go Normal file
View File

@@ -0,0 +1,279 @@
package navigator
import (
"fmt"
"net"
"sync"
"testing"
"time"
"github.com/brianvoe/gofakeit"
"github.com/safing/jess/lhash"
"github.com/safing/portmaster/service/intel/geoip"
"github.com/safing/portmaster/spn/hub"
)
var (
fakeLock sync.Mutex
defaultMapCreate sync.Once
defaultMap *Map
)
func getDefaultTestMap() *Map {
defaultMapCreate.Do(func() {
defaultMap = createRandomTestMap(1, 200)
})
return defaultMap
}
func TestRandomMapCreation(t *testing.T) {
t.Parallel()
m := getDefaultTestMap()
fmt.Println("All Pins:")
for _, pin := range m.all {
fmt.Printf("%s: %s %s\n", pin, pin.Hub.Info.IPv4, pin.Hub.Info.IPv6)
}
// Print stats
fmt.Printf("\n%s\n", m.Stats())
// Print home
fmt.Printf("Selected Home Hub: %s\n", m.home)
}
func createRandomTestMap(seed int64, size int) *Map {
fakeLock.Lock()
defer fakeLock.Unlock()
// Seed with parameter to make it reproducible.
gofakeit.Seed(seed)
// Enforce minimum size.
if size < 10 {
size = 10
}
// Create Hub list.
var hubs []*hub.Hub
// Create Intel data structure.
mapIntel := &hub.Intel{
Hubs: make(map[string]*hub.HubIntel),
}
// Define periodic values.
var currentGroup string
// Create [size] fake Hubs.
for i := 0; i < size; i++ {
// Change group every 5 Hubs.
if i%5 == 0 {
currentGroup = gofakeit.Username()
}
// Create new fake Hub and add to the list.
h := createFakeHub(currentGroup, true, mapIntel)
hubs = append(hubs, h)
}
// Fake three superseeded Hubs.
for i := 0; i < 3; i++ {
h := hubs[size-1-i]
// Set FirstSeen in the past and copy an IP address of an existing Hub.
h.FirstSeen = time.Now().Add(-1 * time.Hour)
if i%2 == 0 {
h.Info.IPv4 = hubs[i].Info.IPv4
} else {
h.Info.IPv6 = hubs[i].Info.IPv6
}
}
// Create Lanes between Hubs in order to create the network.
totalConnections := size * 10
for i := 0; i < totalConnections; i++ {
// Get new random indexes.
indexA := gofakeit.Number(0, size-1)
indexB := gofakeit.Number(0, size-1)
if indexA == indexB {
continue
}
// Get Hubs and check if they are already connected.
hubA := hubs[indexA]
hubB := hubs[indexB]
if hubA.GetLaneTo(hubB.ID) != nil {
// already connected
continue
}
if hubB.GetLaneTo(hubA.ID) != nil {
// already connected
continue
}
// Create connections.
_ = hubA.AddLane(createLane(hubB.ID))
// Add the second connection in 99% of cases.
// If this is missing, the Pins should not show up as connected.
if gofakeit.Number(0, 100) != 0 {
_ = hubB.AddLane(createLane(hubA.ID))
}
}
// Parse constructed intel data
err := mapIntel.ParseAdvisories()
if err != nil {
panic(err)
}
// Create map and add Pins.
m := NewMap(fmt.Sprintf("Test-Map-%d", seed), true)
m.intel = mapIntel
for _, h := range hubs {
m.UpdateHub(h)
}
// Fake communication error with three Hubs.
var i int
for _, pin := range m.all {
pin.MarkAsFailingFor(1 * time.Hour)
pin.addStates(StateFailing)
if i++; i >= 3 {
break
}
}
// Set a Home Hub.
findFakeHomeHub(m)
return m
}
func createFakeHub(group string, randomFailes bool, mapIntel *hub.Intel) *hub.Hub {
// Create fake Hub ID.
idSrc := gofakeit.Password(true, true, true, true, true, 64)
id := lhash.Digest(lhash.BLAKE2b_256, []byte(idSrc)).Base58()
ip4, _ := createGoodIP(true)
ip6, _ := createGoodIP(false)
// Create and return new fake Hub.
h := &hub.Hub{
ID: id,
Info: &hub.Announcement{
ID: id,
Timestamp: time.Now().Unix(),
Name: gofakeit.Username(),
Group: group,
// ContactAddress // TODO
// ContactService // TODO
// Hosters []string // TODO
// Datacenter string // TODO
IPv4: ip4,
IPv6: ip6,
},
Status: &hub.Status{
Timestamp: time.Now().Unix(),
Keys: map[string]*hub.Key{
"a": {
Expires: time.Now().Add(48 * time.Hour).Unix(),
},
},
Load: gofakeit.Number(10, 100),
},
Measurements: hub.NewMeasurements(),
FirstSeen: time.Now(),
}
h.Measurements.Latency = createLatency()
h.Measurements.Capacity = createCapacity()
h.Measurements.CalculatedCost = CalculateLaneCost(
h.Measurements.Latency,
h.Measurements.Capacity,
)
// Return if not failures of any kind should be simulated.
if !randomFailes {
return h
}
// Set hub-based states.
if gofakeit.Number(0, 100) == 0 {
// Fake Info message error.
h.InvalidInfo = true
}
if gofakeit.Number(0, 100) == 0 {
// Fake Status message error.
h.InvalidStatus = true
}
if gofakeit.Number(0, 100) == 0 {
// Fake expired exchange keys.
for _, key := range h.Status.Keys {
key.Expires = time.Now().Add(-1 * time.Hour).Unix()
}
}
// Return if not failures of any kind should be simulated.
if mapIntel == nil {
return h
}
// Set advisory-based states.
if gofakeit.Number(0, 10) == 0 {
// Make Trusted State
mapIntel.Hubs[h.ID] = &hub.HubIntel{
Trusted: true,
}
}
if gofakeit.Number(0, 100) == 0 {
// Discourage any usage.
mapIntel.HubAdvisory = append(mapIntel.HubAdvisory, "- "+h.Info.IPv4.String())
}
if gofakeit.Number(0, 100) == 0 {
// Discourage Home Hub usage.
mapIntel.HomeHubAdvisory = append(mapIntel.HomeHubAdvisory, "- "+h.Info.IPv4.String())
}
if gofakeit.Number(0, 100) == 0 {
// Discourage Destination Hub usage.
mapIntel.DestinationHubAdvisory = append(mapIntel.DestinationHubAdvisory, "- "+h.Info.IPv4.String())
}
return h
}
func createGoodIP(v4 bool) (net.IP, *geoip.Location) {
var candidate net.IP
for i := 0; i < 100; i++ {
if v4 {
candidate = net.ParseIP(gofakeit.IPv4Address())
} else {
candidate = net.ParseIP(gofakeit.IPv6Address())
}
loc, err := geoip.GetLocation(candidate)
if err == nil && loc.Coordinates.Latitude != 0 {
return candidate, loc
}
}
return candidate, nil
}
func createLane(toHubID string) *hub.Lane {
return &hub.Lane{
ID: toHubID,
Latency: createLatency(),
Capacity: createCapacity(),
}
}
func createLatency() time.Duration {
// Return a value between 10ms and 100ms.
return time.Duration(gofakeit.Float64Range(10, 100) * float64(time.Millisecond))
}
func createCapacity() int {
// Return a value between 10Mbit/s and 1Gbit/s.
return gofakeit.Number(10000000, 1000000000)
}

View File

@@ -0,0 +1,144 @@
package navigator
import (
"context"
"sort"
"time"
"github.com/safing/portbase/log"
"github.com/safing/portbase/modules"
"github.com/safing/portmaster/spn/docks"
"github.com/safing/portmaster/spn/terminal"
)
// Measurements Configuration.
const (
NavigatorMeasurementTTLDefault = 4 * time.Hour
NavigatorMeasurementTTLByCostBase = 6 * time.Minute
NavigatorMeasurementTTLByCostMin = 4 * time.Hour
NavigatorMeasurementTTLByCostMax = 50 * time.Hour
// With a base TTL of 3m, this leads to:
// 20c -> 2h -> raised to 4h.
// 50c -> 5h
// 100c -> 10h
// 1000c -> 100h -> capped to 50h.
)
func (m *Map) measureHubs(ctx context.Context, _ *modules.Task) error {
if home, _ := m.GetHome(); home == nil {
log.Debug("spn/navigator: skipping measuring, no home hub set")
return nil
}
var unknownErrCnt int
matcher := m.DefaultOptions().Transit.Matcher(m.GetIntel())
// Get list and sort in order to check near/low-cost hubs earlier.
list := m.pinList(true)
sort.Sort(sortByLowestMeasuredCost(list))
// Find first pin where any measurement has expired.
for _, pin := range list {
// Check if measuring is enabled.
if pin.measurements == nil {
continue
}
// Check if Pin is regarded.
if !matcher(pin) {
continue
}
// Calculate dynamic TTL.
var checkWithTTL time.Duration
if pin.HopDistance == 2 { // Hub is directly connected.
checkWithTTL = calculateMeasurementTTLByCost(
pin.measurements.GetCalculatedCost(),
docks.CraneMeasurementTTLByCostBase,
docks.CraneMeasurementTTLByCostMin,
docks.CraneMeasurementTTLByCostMax,
)
} else {
checkWithTTL = calculateMeasurementTTLByCost(
pin.measurements.GetCalculatedCost(),
NavigatorMeasurementTTLByCostBase,
NavigatorMeasurementTTLByCostMin,
NavigatorMeasurementTTLByCostMax,
)
}
// Check if we have measured the pin within the TTL.
if !pin.measurements.Expired(checkWithTTL) {
continue
}
// Measure connection.
tErr := docks.MeasureHub(ctx, pin.Hub, checkWithTTL)
// Independent of outcome, recalculate the cost.
latency, _ := pin.measurements.GetLatency()
capacity, _ := pin.measurements.GetCapacity()
calculatedCost := CalculateLaneCost(latency, capacity)
pin.measurements.SetCalculatedCost(calculatedCost)
// Log result.
log.Infof(
"spn/navigator: updated measurements for connection to %s: %s %.2fMbit/s %.2fc",
pin.Hub,
latency,
float64(capacity)/1000000,
calculatedCost,
)
switch {
case tErr.IsOK():
// All good, continue.
case tErr.Is(terminal.ErrTryAgainLater):
if tErr.IsExternal() {
// Remote is measuring, just continue with next.
log.Debugf("spn/navigator: remote %s is measuring, continuing with next", pin.Hub)
} else {
// We are measuring, abort and restart measuring again later.
log.Debugf("spn/navigator: postponing measuring because we are currently engaged in measuring")
return nil
}
default:
log.Warningf("spn/navigator: failed to measure connection to %s: %s", pin.Hub, tErr)
unknownErrCnt++
if unknownErrCnt >= 3 {
log.Warningf("spn/navigator: postponing measuring task because of multiple errors")
return nil
}
}
}
return nil
}
// SaveMeasuredHubs saves all Hubs that have unsaved measurements.
func (m *Map) SaveMeasuredHubs() {
m.RLock()
defer m.RUnlock()
for _, pin := range m.all {
if !pin.measurements.IsPersisted() {
if err := pin.Hub.Save(); err != nil {
log.Warningf("spn/navigator: failed to save Hub %s to persist measurements: %s", pin.Hub, err)
}
}
}
}
func calculateMeasurementTTLByCost(cost float32, base, min, max time.Duration) time.Duration {
calculated := time.Duration(cost) * base
switch {
case calculated < min:
return min
case calculated > max:
return max
default:
return calculated
}
}

177
spn/navigator/metrics.go Normal file
View File

@@ -0,0 +1,177 @@
package navigator
import (
"sort"
"sync"
"time"
"github.com/tevino/abool"
"github.com/safing/portbase/api"
"github.com/safing/portbase/metrics"
)
var metricsRegistered = abool.New()
func registerMetrics() (err error) {
// Only register metrics once.
if !metricsRegistered.SetToIf(false, true) {
return nil
}
// Map Stats.
_, err = metrics.NewGauge(
"spn/map/main/latency/all/lowest/seconds",
nil,
getLowestLatency,
&metrics.Options{
Name: "SPN Map Lowest Latency",
Permission: api.PermitUser,
},
)
if err != nil {
return err
}
_, err = metrics.NewGauge(
"spn/map/main/latency/fas/lowest/seconds",
nil,
getLowestLatencyFromFas,
&metrics.Options{
Name: "SPN Map Lowest Latency",
Permission: api.PermitUser,
},
)
if err != nil {
return err
}
_, err = metrics.NewGauge(
"spn/map/main/capacity/all/highest/bytes",
nil,
getHighestCapacity,
&metrics.Options{
Name: "SPN Map Lowest Latency",
Permission: api.PermitUser,
},
)
if err != nil {
return err
}
_, err = metrics.NewGauge(
"spn/map/main/capacity/fas/highest/bytes",
nil,
getHighestCapacityFromFas,
&metrics.Options{
Name: "SPN Map Lowest Latency",
Permission: api.PermitUser,
},
)
if err != nil {
return err
}
return nil
}
var (
mapStats *mapMetrics
mapStatsExpires time.Time
mapStatsLock sync.Mutex
mapStatsTTL = 55 * time.Second
)
type mapMetrics struct {
lowestLatency float64
lowestForeignASLatency float64
highestCapacity float64
highestForeignASCapacity float64
}
func getLowestLatency() float64 { return getMapStats().lowestLatency }
func getLowestLatencyFromFas() float64 { return getMapStats().lowestForeignASLatency }
func getHighestCapacity() float64 { return getMapStats().highestCapacity }
func getHighestCapacityFromFas() float64 { return getMapStats().highestForeignASCapacity }
func getMapStats() *mapMetrics {
mapStatsLock.Lock()
defer mapStatsLock.Unlock()
// Return cache if still valid.
if time.Now().Before(mapStatsExpires) {
return mapStats
}
// Refresh.
mapStats = &mapMetrics{}
// Get all pins and home.
list := Main.pinList(true)
home, _ := Main.GetHome()
// Return empty stats if we have incomplete data.
if len(list) <= 1 || home == nil {
mapStatsExpires = time.Now().Add(mapStatsTTL)
return mapStats
}
// Sort by latency.
sort.Sort(sortByLowestMeasuredLatency(list))
// Get lowest latency.
lowestLatency, _ := list[0].measurements.GetLatency()
mapStats.lowestLatency = lowestLatency.Seconds()
// Find best foreign AS latency.
bestForeignASPin := findFirstForeignASStatsPin(home, list)
if bestForeignASPin != nil {
lowestForeignASLatency, _ := bestForeignASPin.measurements.GetLatency()
mapStats.lowestForeignASLatency = lowestForeignASLatency.Seconds()
}
// Sort by capacity.
sort.Sort(sortByHighestMeasuredCapacity(list))
// Get highest capacity.
highestCapacity, _ := list[0].measurements.GetCapacity()
mapStats.highestCapacity = float64(highestCapacity) / 8
// Find best foreign AS capacity.
bestForeignASPin = findFirstForeignASStatsPin(home, list)
if bestForeignASPin != nil {
highestForeignASCapacity, _ := bestForeignASPin.measurements.GetCapacity()
mapStats.highestForeignASCapacity = float64(highestForeignASCapacity) / 8
}
mapStatsExpires = time.Now().Add(mapStatsTTL)
return mapStats
}
func findFirstForeignASStatsPin(home *Pin, list []*Pin) *Pin {
// Find best foreign AS latency.
for _, pin := range list {
compared := false
// Skip if IPv4 AS matches.
if home.LocationV4 != nil && pin.LocationV4 != nil {
if home.LocationV4.AutonomousSystemNumber == pin.LocationV4.AutonomousSystemNumber {
continue
}
compared = true
}
// Skip if IPv6 AS matches.
if home.LocationV6 != nil && pin.LocationV6 != nil {
if home.LocationV6.AutonomousSystemNumber == pin.LocationV6.AutonomousSystemNumber {
continue
}
compared = true
}
// Skip if no data was compared
if !compared {
continue
}
return pin
}
return nil
}

129
spn/navigator/module.go Normal file
View File

@@ -0,0 +1,129 @@
package navigator
import (
"errors"
"time"
"github.com/safing/portbase/config"
"github.com/safing/portbase/log"
"github.com/safing/portbase/modules"
"github.com/safing/portmaster/service/intel/geoip"
"github.com/safing/portmaster/spn/conf"
)
const (
// cfgOptionRoutingAlgorithmKey is copied from profile/config.go to avoid import loop.
cfgOptionRoutingAlgorithmKey = "spn/routingAlgorithm"
// cfgOptionRoutingAlgorithmKey is copied from captain/config.go to avoid import loop.
cfgOptionTrustNodeNodesKey = "spn/trustNodes"
)
var (
// ErrHomeHubUnset is returned when the Home Hub is required and not set.
ErrHomeHubUnset = errors.New("map has no Home Hub set")
// ErrEmptyMap is returned when the Map is empty.
ErrEmptyMap = errors.New("map is empty")
// ErrHubNotFound is returned when the Hub was not found on the Map.
ErrHubNotFound = errors.New("hub not found")
// ErrAllPinsDisregarded is returned when all pins have been disregarded.
ErrAllPinsDisregarded = errors.New("all pins have been disregarded")
)
var (
module *modules.Module
// Main is the primary map used.
Main *Map
devMode config.BoolOption
cfgOptionRoutingAlgorithm config.StringOption
cfgOptionTrustNodeNodes config.StringArrayOption
)
func init() {
module = modules.Register("navigator", prep, start, stop, "terminal", "geoip", "netenv")
}
func prep() error {
return registerAPIEndpoints()
}
func start() error {
Main = NewMap(conf.MainMapName, true)
devMode = config.Concurrent.GetAsBool(config.CfgDevModeKey, false)
cfgOptionRoutingAlgorithm = config.Concurrent.GetAsString(cfgOptionRoutingAlgorithmKey, DefaultRoutingProfileID)
cfgOptionTrustNodeNodes = config.Concurrent.GetAsStringArray(cfgOptionTrustNodeNodesKey, []string{})
err := registerMapDatabase()
if err != nil {
return err
}
// Wait for geoip databases to be ready.
// Try again if not yet ready, as this is critical.
// The "wait" parameter times out after 1 second.
// Allow 30 seconds for both databases to load.
geoInitCheck:
for i := 0; i < 30; i++ {
switch {
case !geoip.IsInitialized(false, true): // First, IPv4.
case !geoip.IsInitialized(true, true): // Then, IPv6.
default:
break geoInitCheck
}
}
err = Main.InitializeFromDatabase()
if err != nil {
// Wait for three seconds, then try again.
time.Sleep(3 * time.Second)
err = Main.InitializeFromDatabase()
if err != nil {
// Even if the init fails, we can try to start without it and get data along the way.
log.Warningf("spn/navigator: %s", err)
}
}
err = Main.RegisterHubUpdateHook()
if err != nil {
return err
}
// TODO: delete superseded hubs after x amount of time
module.NewTask("update states", Main.updateStates).
Repeat(1 * time.Hour).
Schedule(time.Now().Add(3 * time.Minute))
module.NewTask("update failing states", Main.updateFailingStates).
Repeat(1 * time.Minute).
Schedule(time.Now().Add(3 * time.Minute))
if conf.PublicHub() {
// Only measure Hubs on public Hubs.
module.NewTask("measure hubs", Main.measureHubs).
Repeat(5 * time.Minute).
Schedule(time.Now().Add(1 * time.Minute))
// Only register metrics on Hubs, as they only make sense there.
err := registerMetrics()
if err != nil {
return err
}
}
return nil
}
func stop() error {
withdrawMapDatabase()
Main.CancelHubUpdateHook()
Main.SaveMeasuredHubs()
Main.Close()
return nil
}

View File

@@ -0,0 +1,13 @@
package navigator
import (
"testing"
"github.com/safing/portbase/log"
"github.com/safing/portmaster/service/core/pmtesting"
)
func TestMain(m *testing.M) {
log.SetLogLevel(log.DebugLevel)
pmtesting.TestMain(m, module)
}

388
spn/navigator/optimize.go Normal file
View File

@@ -0,0 +1,388 @@
package navigator
import (
"fmt"
"sort"
"time"
"github.com/safing/portmaster/spn/docks"
"github.com/safing/portmaster/spn/hub"
)
const (
optimizationLowestCostConnections = 3
optimizationHopDistanceTarget = 3
waitUntilMeasuredUpToPercent = 0.5
desegrationAttemptBackoff = time.Hour
)
// Optimization Purposes.
const (
OptimizePurposeBootstrap = "bootstrap"
OptimizePurposeDesegregate = "desegregate"
OptimizePurposeWait = "wait"
OptimizePurposeTargetStructure = "target-structure"
)
// AnalysisState holds state for analyzing the network for optimizations.
type AnalysisState struct { //nolint:maligned
// Suggested signifies that a direct connection to this Hub is suggested by
// the optimization algorithm.
Suggested bool
// SuggestedHopDistance holds the hop distance to this Hub when only
// considering the suggested Hubs as connected.
SuggestedHopDistance int
// SuggestedHopDistanceInRegion holds the hop distance to this Hub in the
// same region when only considering the suggested Hubs as connected.
SuggestedHopDistanceInRegion int
// CrossRegionalConnections holds the amount of connections a Pin has from
// the current region.
CrossRegionalConnections int
// CrossRegionalLowestCostLane holds the lowest cost of the counted
// connections from the current region.
CrossRegionalLowestCostLane float32
// CrossRegionalLaneCosts holds all the cross regional lane costs.
CrossRegionalLaneCosts []float32
// CrossRegionalHighestCostInHubLimit holds to highest cost of the lowest
// cost connections within the maximum allowed lanes on a Hub from the
// current region.
CrossRegionalHighestCostInHubLimit float32
}
// initAnalysis creates all Pin.analysis fields.
// The caller needs to hold the map and analysis lock..
func (m *Map) initAnalysis(result *OptimizationResult) {
// Compile lists of regarded pins.
m.regardedPins = make([]*Pin, 0, len(m.all))
for _, region := range m.regions {
region.regardedPins = make([]*Pin, 0, len(m.all))
}
// Find all regarded pins.
for _, pin := range m.all {
if result.matcher(pin) {
m.regardedPins = append(m.regardedPins, pin)
// Add to region.
if pin.region != nil {
pin.region.regardedPins = append(pin.region.regardedPins, pin)
}
}
}
// Initialize analysis state.
for _, pin := range m.all {
pin.analysis = &AnalysisState{}
}
}
// clearAnalysis reset all Pin.analysis fields.
// The caller needs to hold the map and analysis lock.
func (m *Map) clearAnalysis() {
m.regardedPins = nil
for _, region := range m.regions {
region.regardedPins = nil
}
for _, pin := range m.all {
pin.analysis = nil
}
}
// OptimizationResult holds the result of an optimizaion analysis.
type OptimizationResult struct {
// Purpose holds a semi-human readable constant of the optimization purpose.
Purpose string
// Approach holds human readable descriptions of how the stated purpose
// should be achieved.
Approach []string
// SuggestedConnections holds the Hubs to which connections are suggested.
SuggestedConnections []*SuggestedConnection
// MaxConnect specifies how many connections should be created at maximum
// based on this optimization.
MaxConnect int
// StopOthers specifies if other connections than the suggested ones may
// be stopped.
StopOthers bool
// opts holds the options for matching Hubs in this optimization.
opts *HubOptions
// matcher is the matcher used to create the regarded Pins.
// Required for updating suggested hop distance.
matcher PinMatcher
}
// SuggestedConnection holds suggestions by the optimization system.
type SuggestedConnection struct {
// Hub holds the Hub to which a connection is suggested.
Hub *hub.Hub
// pin holds the Pin of the Hub.
pin *Pin
// Reason holds a reason why this connection is suggested.
Reason string
// Duplicate marks duplicate entries. These should be ignored when
// connecting, but are helpful for understand the optimization result.
Duplicate bool
}
func (or *OptimizationResult) addApproach(description string) {
or.Approach = append(or.Approach, description)
}
func (or *OptimizationResult) addSuggested(reason string, pins ...*Pin) {
for _, pin := range pins {
// Mark as suggested.
pin.analysis.Suggested = true
// Check if this is a duplicate.
var duplicate bool
for _, sc := range or.SuggestedConnections {
if pin.Hub.ID == sc.Hub.ID {
duplicate = true
break
}
}
// Add to suggested connections.
or.SuggestedConnections = append(or.SuggestedConnections, &SuggestedConnection{
Hub: pin.Hub,
pin: pin,
Reason: reason,
Duplicate: duplicate,
})
// Update hop distances if we have a matcher.
if or.matcher != nil {
or.markSuggestedReachable(pin, 2)
or.markSuggestedReachableInRegion(pin, 2)
}
}
}
func (or *OptimizationResult) markSuggestedReachable(suggested *Pin, hopDistance int) {
// Don't update if distance is greater or equal than current one.
if hopDistance >= suggested.analysis.SuggestedHopDistance {
return
}
// Set suggested hop distance.
suggested.analysis.SuggestedHopDistance = hopDistance
// Increase distance and apply to matching Pins.
hopDistance++
for _, lane := range suggested.ConnectedTo {
if or.matcher(lane.Pin) {
or.markSuggestedReachable(lane.Pin, hopDistance)
}
}
}
// Optimize analyzes the map and suggests changes.
func (m *Map) Optimize(opts *HubOptions) (result *OptimizationResult, err error) {
m.RLock()
defer m.RUnlock()
// Check if the map is empty.
if m.isEmpty() {
return nil, ErrEmptyMap
}
// Set default options if unset.
if opts == nil {
opts = &HubOptions{}
}
return m.optimize(opts)
}
func (m *Map) optimize(opts *HubOptions) (result *OptimizationResult, err error) {
if m.home == nil {
return nil, ErrHomeHubUnset
}
// Set default options if unset.
if opts == nil {
opts = &HubOptions{}
}
// Create result.
result = &OptimizationResult{
opts: opts,
matcher: opts.Matcher(TransitHub, m.intel),
}
// Setup analyis.
m.analysisLock.Lock()
defer m.analysisLock.Unlock()
m.initAnalysis(result)
defer m.clearAnalysis()
// Bootstrap to the network and desegregate map.
// If there is a result, return it immediately.
returnImmediately := m.optimizeForBootstrappingAndDesegregation(result)
if returnImmediately {
return result, nil
}
// Check if we have the measurements we need.
if m.measuringEnabled {
// Cound pins with valid measurements.
var validMeasurements float32
for _, pin := range m.regardedPins {
if pin.measurements.Valid() {
validMeasurements++
}
}
// If less than the required amount of regarded Pins have valid
// measurements, let's wait until we have that.
if validMeasurements/float32(len(m.regardedPins)) < waitUntilMeasuredUpToPercent {
return &OptimizationResult{
Purpose: OptimizePurposeWait,
Approach: []string{"Wait for measurements of 80% of regarded nodes for better optimization."},
}, nil
}
}
// Set default values for target structure optimization.
result.Purpose = OptimizePurposeTargetStructure
result.MaxConnect = 3
result.StopOthers = true
// Optimize for lowest cost.
m.optimizeForLowestCost(result, optimizationLowestCostConnections)
// Optimize for lowest cost in region.
m.optimizeForLowestCostInRegion(result)
// Optimize for distance constraint in region.
m.optimizeForDistanceConstraintInRegion(result, 3)
// Optimize for region-to-region connectivity.
m.optimizeForRegionConnectivity(result)
// Optimize for satellite-to-region connectivity.
m.optimizeForSatelliteConnectivity(result)
// Lapse traffic stats after optimizing for good fresh data next time.
for _, crane := range docks.GetAllAssignedCranes() {
crane.NetState.LapsePeriod()
}
// Clean and return.
return result, nil
}
func (m *Map) optimizeForBootstrappingAndDesegregation(result *OptimizationResult) (returnImmediately bool) {
// All regarded Pins are reachable.
reachable := len(m.regardedPins)
// Count Pins that may be connectable.
connectable := make([]*Pin, 0, len(m.all))
// Copy opts as we are going to make changes.
opts := result.opts.Copy()
opts.NoDefaults = true
opts.Regard = StateNone
opts.Disregard = StateSummaryDisregard
// Collect Pins with matcher.
matcher := opts.Matcher(TransitHub, m.intel)
for _, pin := range m.all {
if matcher(pin) {
connectable = append(connectable, pin)
}
}
switch {
case reachable == 0:
// Sort by lowest cost.
sort.Sort(sortByLowestMeasuredCost(connectable))
// Return bootstrap optimization.
result.Purpose = OptimizePurposeBootstrap
result.Approach = []string{"Connect to a near Hub to connect to the network."}
result.MaxConnect = 1
result.addSuggested("bootstrap", connectable...)
return true
case reachable > len(connectable)/2:
// We are part of the majority network, continue with regular optimization.
case time.Now().Add(-desegrationAttemptBackoff).Before(m.lastDesegrationAttempt):
// We tried to desegregate recently, continue with regular optimization.
default:
// We are in a network comprised of less than half of the known nodes.
// Attempt to connect to an unconnected one to desegregate the network.
// Copy opts as we are going to make changes.
opts = opts.Copy()
opts.NoDefaults = true
opts.Regard = StateNone
opts.Disregard = StateSummaryDisregard | StateReachable
// Iterate over all Pins to find any matching Pin.
desegregateWith := make([]*Pin, 0, len(m.all)-reachable)
matcher := opts.Matcher(TransitHub, m.intel)
for _, pin := range m.all {
if matcher(pin) {
desegregateWith = append(desegregateWith, pin)
}
}
// Sort by lowest connection cost.
sort.Sort(sortByLowestMeasuredCost(desegregateWith))
// Build desegration optimization.
result.Purpose = OptimizePurposeDesegregate
result.Approach = []string{"Attempt to desegregate network by connection to an unreachable Hub."}
result.MaxConnect = 1
result.addSuggested("desegregate", desegregateWith...)
// Record desegregation attempt.
m.lastDesegrationAttempt = time.Now()
return true
}
return false
}
func (m *Map) optimizeForLowestCost(result *OptimizationResult, max int) {
// Add approach.
result.addApproach(fmt.Sprintf("Connect to best (lowest cost) %d Hubs globally.", max))
// Sort by lowest cost.
sort.Sort(sortByLowestMeasuredCost(m.regardedPins))
// Add to suggested pins.
if len(m.regardedPins) <= max {
result.addSuggested("best globally", m.regardedPins...)
} else {
result.addSuggested("best globally", m.regardedPins[:max]...)
}
}
func (m *Map) optimizeForDistanceConstraint(result *OptimizationResult, max int) { //nolint:unused // TODO: Likely to be used again.
// Add approach.
result.addApproach(fmt.Sprintf("Satisfy max hop constraint of %d globally.", optimizationHopDistanceTarget))
for i := 0; i < max; i++ {
// Sort by lowest cost.
sort.Sort(sortBySuggestedHopDistanceAndLowestMeasuredCost(m.regardedPins))
// Return when all regarded Pins are within the distance constraint.
if m.regardedPins[0].analysis.SuggestedHopDistance <= optimizationHopDistanceTarget {
return
}
// If not, suggest a connection to the best match.
result.addSuggested("satisfy global hop constraint", m.regardedPins[0])
}
}

View File

@@ -0,0 +1,224 @@
package navigator
import (
"fmt"
"sort"
)
func (or *OptimizationResult) markSuggestedReachableInRegion(suggested *Pin, hopDistance int) {
// Abort if suggested Pin has no region.
if suggested.region == nil {
return
}
// Don't update if distance is greater or equal than current one.
if hopDistance >= suggested.analysis.SuggestedHopDistanceInRegion {
return
}
// Set suggested hop distance.
suggested.analysis.SuggestedHopDistanceInRegion = hopDistance
// Increase distance and apply to matching Pins.
hopDistance++
for _, lane := range suggested.ConnectedTo {
if lane.Pin.region != nil &&
lane.Pin.region.ID == suggested.region.ID &&
or.matcher(lane.Pin) {
or.markSuggestedReachableInRegion(lane.Pin, hopDistance)
}
}
}
func (m *Map) optimizeForLowestCostInRegion(result *OptimizationResult) {
if m.home == nil || m.home.region == nil {
return
}
region := m.home.region
// Add approach.
result.addApproach(fmt.Sprintf("Connect to best (lowest cost) %d Hubs within the region.", region.internalMinLanesOnHub))
// Sort by lowest cost.
sort.Sort(sortByLowestMeasuredCost(region.regardedPins))
// Add to suggested pins.
if len(region.regardedPins) <= region.internalMinLanesOnHub {
result.addSuggested("best in region", region.regardedPins...)
} else {
result.addSuggested("best in region", region.regardedPins[:region.internalMinLanesOnHub]...)
}
}
func (m *Map) optimizeForDistanceConstraintInRegion(result *OptimizationResult, max int) {
if m.home == nil || m.home.region == nil {
return
}
region := m.home.region
// Add approach.
result.addApproach(fmt.Sprintf("Satisfy max hop constraint of %d within the region.", region.internalMaxHops))
// Sort by lowest cost.
sort.Sort(sortBySuggestedHopDistanceInRegionAndLowestMeasuredCost(region.regardedPins))
for i := 0; i < max && i < len(region.regardedPins); i++ {
// Return when all regarded Pins are within the distance constraint.
if region.regardedPins[i].analysis.SuggestedHopDistanceInRegion <= region.internalMaxHops {
return
}
// If not, suggest a connection to the best match.
result.addSuggested("satisfy regional hop constraint", region.regardedPins[i])
}
}
func (m *Map) optimizeForRegionConnectivity(result *OptimizationResult) {
if m.home == nil || m.home.region == nil {
return
}
region := m.home.region
// Add approach.
result.addApproach("Connect region to other regions.")
// Optimize for every region.
checkRegions:
for _, otherRegion := range m.regions {
// Skip own region.
if region.ID == otherRegion.ID {
continue
}
// Collect data on connections to that region.
lanesToRegion, highestCostWithinLaneLimit := m.countConnectionsToRegion(result, region, otherRegion)
// Sort by lowest cost.
sort.Sort(sortByLowestMeasuredCost(otherRegion.regardedPins))
// Find cheapest connections with a free slot or better values.
var lanesSuggested int
for _, pin := range otherRegion.regardedPins {
myCost := pin.measurements.GetCalculatedCost()
// Check if we are done or region is satisfied.
switch {
case lanesSuggested >= region.regionalMaxLanesOnHub:
// We hit our max.
continue checkRegions
case lanesToRegion >= otherRegion.regionalMinLanes && myCost >= highestCostWithinLaneLimit:
// Region has enough lanes and we are not better.
continue checkRegions
}
// Check if we can contribute on this Pin.
switch {
case pin.analysis.CrossRegionalConnections < otherRegion.regionalMaxLanesOnHub &&
lanesToRegion < otherRegion.regionalMinLanes:
// There is a free spot on this Pin and the region needs more connections.
result.addSuggested("occupy cross-region lane on pin", pin)
lanesSuggested++
lanesToRegion++
// Because our own Pin is not counted, this should be the default
// suggestion for a stable network.
case myCost < pin.analysis.CrossRegionalHighestCostInHubLimit:
// We have a better connection to this Pin than at least one other existing connection (within the limit!).
result.addSuggested("replace cross-region lane on pin", pin)
lanesSuggested++
lanesToRegion++
case myCost < highestCostWithinLaneLimit &&
pin.analysis.CrossRegionalConnections < otherRegion.regionalMaxLanesOnHub:
// We have a better connection to this Pin than another existing region-to-region connection.
result.addSuggested("replace unrelated cross-region lane", pin)
lanesSuggested++
lanesToRegion++
}
}
}
}
// countConnectionsToRegion analyzes existing lanes from this to another
// region, with taking lanes from this Hub into account.
func (m *Map) countConnectionsToRegion(result *OptimizationResult, region *Region, otherRegion *Region) (lanesToRegion int, highestCostWithinLaneLimit float32) {
for _, pin := range region.regardedPins {
// Skip self.
if m.home.Hub.ID == pin.Hub.ID {
continue
}
// Find lanes to other region.
for _, lane := range pin.ConnectedTo {
if lane.Pin.region != nil &&
lane.Pin.region.ID == otherRegion.ID &&
result.matcher(lane.Pin) {
// This is a lane from this region to a regarded Pin in the other region.
lanesToRegion++
// Count cross region connection.
lane.Pin.analysis.CrossRegionalConnections++
// Collect lane costs.
lane.Pin.analysis.CrossRegionalLaneCosts = append(
lane.Pin.analysis.CrossRegionalLaneCosts,
lane.Cost,
)
}
}
}
// Calculate lane costs from collected lane costs.
for _, pin := range otherRegion.regardedPins {
sort.Sort(sortCostsByLowest(pin.analysis.CrossRegionalLaneCosts))
switch {
case len(pin.analysis.CrossRegionalLaneCosts) == 0:
// Nothing to do.
case len(pin.analysis.CrossRegionalLaneCosts) < otherRegion.regionalMaxLanesOnHub:
pin.analysis.CrossRegionalLowestCostLane = pin.analysis.CrossRegionalLaneCosts[0]
pin.analysis.CrossRegionalHighestCostInHubLimit = pin.analysis.CrossRegionalLaneCosts[len(pin.analysis.CrossRegionalLaneCosts)-1]
default:
pin.analysis.CrossRegionalLowestCostLane = pin.analysis.CrossRegionalLaneCosts[0]
pin.analysis.CrossRegionalHighestCostInHubLimit = pin.analysis.CrossRegionalLaneCosts[otherRegion.regionalMaxLanesOnHub-1]
}
// Find highest cost within limit.
if pin.analysis.CrossRegionalHighestCostInHubLimit > highestCostWithinLaneLimit {
highestCostWithinLaneLimit = pin.analysis.CrossRegionalHighestCostInHubLimit
}
}
return lanesToRegion, highestCostWithinLaneLimit
}
func (m *Map) optimizeForSatelliteConnectivity(result *OptimizationResult) {
if m.home == nil {
return
}
// This is only for Hubs that are not in a region.
if m.home.region != nil {
return
}
// Add approach.
result.addApproach("Connect satellite to regions.")
// Optimize for every region.
for _, region := range m.regions {
// Sort by lowest cost.
sort.Sort(sortByLowestMeasuredCost(region.regardedPins))
// Add to suggested pins.
if len(region.regardedPins) <= region.satelliteMinLanes {
result.addSuggested(fmt.Sprintf("best to region %s", region.ID), region.regardedPins...)
} else {
result.addSuggested(fmt.Sprintf("best to region %s", region.ID), region.regardedPins[:region.satelliteMinLanes]...)
}
}
}
type sortCostsByLowest []float32
func (a sortCostsByLowest) Len() int { return len(a) }
func (a sortCostsByLowest) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a sortCostsByLowest) Less(i, j int) bool { return a[i] < a[j] }

View File

@@ -0,0 +1,188 @@
package navigator
import (
"strings"
"sync"
"testing"
"github.com/safing/portmaster/spn/hub"
)
var (
optimizedDefaultMapCreate sync.Once
optimizedDefaultMap *Map
)
func getOptimizedDefaultTestMap(t *testing.T) *Map {
t.Helper()
optimizedDefaultMapCreate.Do(func() {
optimizedDefaultMap = createRandomTestMap(2, 100)
optimizedDefaultMap.optimizeTestMap(t)
})
return optimizedDefaultMap
}
func (m *Map) optimizeTestMap(t *testing.T) {
t.Helper()
t.Logf("optimizing test map %s with %d pins", m.Name, len(m.all))
// Save original Home, as we will be switching around the home for the
// optimization.
run := 0
newLanes := 0
originalHome := m.home
mcf := newMeasurementCachedFactory()
for {
run++
newLanesInRun := 0
// Let's check if we have a run without any map changes.
lastRun := true
for _, pin := range m.all {
// Set Home to this Pin for this iteration.
if !m.SetHome(pin.Hub.ID, nil) {
panic("failed to set home")
}
// Update measurements for the new home.
updateMeasurements(m, mcf)
optimizeResult, err := m.optimize(nil)
if err != nil {
panic(err)
}
lanesCreatedWithResult := 0
for _, connectTo := range optimizeResult.SuggestedConnections {
// Check if lane to suggested Hub already exists.
if m.home.Hub.GetLaneTo(connectTo.Hub.ID) != nil {
continue
}
// Add lanes to the Hub status.
_ = m.home.Hub.AddLane(createLane(connectTo.Hub.ID))
_ = connectTo.Hub.AddLane(createLane(m.home.Hub.ID))
// Update Hubs in map.
m.UpdateHub(m.home.Hub)
m.UpdateHub(connectTo.Hub)
newLanes++
newLanesInRun++
// We are changing the map in this run, so this is not the last.
lastRun = false
// Only create as many lanes as suggested by the result.
lanesCreatedWithResult++
if lanesCreatedWithResult >= optimizeResult.MaxConnect {
break
}
}
if optimizeResult.Purpose != OptimizePurposeTargetStructure {
// If we aren't yet building the target structure, we need to keep building.
lastRun = false
}
}
// Log progress.
if t != nil {
t.Logf(
"optimizing: added %d lanes in run #%d (%d Hubs) - %d new lanes in total",
newLanesInRun,
run,
len(m.all),
newLanes,
)
}
// End optimization after last run.
if lastRun {
break
}
}
// Log what was done and set home back to the original value.
if t != nil {
t.Logf("finished optimizing test map %s: added %d lanes in %d runs", m.Name, newLanes, run)
}
m.home = originalHome
}
func TestOptimize(t *testing.T) {
t.Parallel()
m := getOptimizedDefaultTestMap(t)
matcher := m.defaultOptions().Destination.Matcher(m.intel)
originalHome := m.home
for _, pin := range m.all {
// Set Home to this Pin for this iteration.
m.home = pin
err := m.recalculateReachableHubs()
if err != nil {
panic(err)
}
for _, peer := range m.all {
// Check if the Pin matches the criteria.
if !matcher(peer) {
continue
}
// TODO: Adapt test to new regions.
if peer.HopDistance > 5 {
t.Errorf("Optimization error: %s is %d hops away from %s", peer, peer.HopDistance, pin)
}
}
}
// Print stats
t.Logf("optimized map:\n%s\n", m.Stats())
m.home = originalHome
}
func updateMeasurements(m *Map, mcf *measurementCachedFactory) {
for _, pin := range m.all {
pin.measurements = mcf.getOrCreate(m.home.Hub.ID, pin.Hub.ID)
}
}
type measurementCachedFactory struct {
cache map[string]*hub.Measurements
}
func newMeasurementCachedFactory() *measurementCachedFactory {
return &measurementCachedFactory{
cache: make(map[string]*hub.Measurements),
}
}
func (mcf *measurementCachedFactory) getOrCreate(from, to string) *hub.Measurements {
var id string
comparison := strings.Compare(from, to)
switch {
case comparison == 0:
return nil
case comparison > 0:
id = from + "-" + to
case comparison < 0:
id = to + "-" + from
}
m, ok := mcf.cache[id]
if ok {
return m
}
m = hub.NewMeasurements()
m.Latency = createLatency()
m.Capacity = createCapacity()
m.CalculatedCost = CalculateLaneCost(
m.Latency,
m.Capacity,
)
mcf.cache[id] = m
return m
}

330
spn/navigator/options.go Normal file
View File

@@ -0,0 +1,330 @@
package navigator
import (
"context"
"github.com/safing/portbase/log"
"github.com/safing/portmaster/service/intel"
"github.com/safing/portmaster/service/profile"
"github.com/safing/portmaster/service/profile/endpoints"
"github.com/safing/portmaster/spn/hub"
)
// HubType is the usage type of a Hub in routing.
type HubType uint8
// Hub Types.
const (
HomeHub HubType = iota
TransitHub
DestinationHub
)
// DeriveTunnelOptions derives and returns the tunnel options from the connection and profile.
// This function lives in firewall/tunnel.go and is set here to avoid import loops.
var DeriveTunnelOptions func(lp *profile.LayeredProfile, destination *intel.Entity, connEncrypted bool) *Options
// Options holds configuration options for operations with the Map.
type Options struct { //nolint:maligned
// Home holds the options for Home Hubs.
Home *HomeHubOptions
// Transit holds the options for Transit Hubs.
Transit *TransitHubOptions
// Destination holds the options for Destination Hubs.
Destination *DestinationHubOptions
// RoutingProfile defines the algorithm to use to find a route.
RoutingProfile string
}
// HomeHubOptions holds configuration options for Home Hub operations with the Map.
type HomeHubOptions HubOptions
// TransitHubOptions holds configuration options for Transit Hub operations with the Map.
type TransitHubOptions HubOptions
// DestinationHubOptions holds configuration options for Destination Hub operations with the Map.
type DestinationHubOptions HubOptions
// HubOptions holds configuration options for a specific hub type for operations with the Map.
type HubOptions struct {
// Regard holds required States. Only Hubs where all of these are present
// will taken into account for the operation. If NoDefaults is not set, a
// basic set of desirable states is added automatically.
Regard PinState
// Disregard holds disqualifying States. Only Hubs where none of these are
// present will be taken into account for the operation. If NoDefaults is not
// set, a basic set of undesirable states is added automatically.
Disregard PinState
// NoDefaults declares whether default and recommended Regard and Disregard states should not be used.
NoDefaults bool
// HubPolicies is a collection of endpoint lists that Hubs must pass in order
// to be taken into account for the operation.
HubPolicies []endpoints.Endpoints
// RequireVerifiedOwners specifies which verified owners are allowed to be used.
// If the list is empty, all owners are allowed.
RequireVerifiedOwners []string
// CheckHubPolicyWith provides an entity that must match the Hubs entry or exit
// policy (depending on type) in order to be taken into account for the operation.
CheckHubPolicyWith *intel.Entity
}
// Copy returns a shallow copy of the Options.
func (o *Options) Copy() *Options {
copied := &Options{
RoutingProfile: o.RoutingProfile,
}
if o.Home != nil {
c := HomeHubOptions(HubOptions(*o.Home).Copy())
copied.Home = &c
}
if o.Transit != nil {
c := TransitHubOptions(HubOptions(*o.Transit).Copy())
copied.Transit = &c
}
if o.Destination != nil {
c := DestinationHubOptions(HubOptions(*o.Destination).Copy())
copied.Destination = &c
}
return copied
}
// Copy returns a shallow copy of the Options.
func (o HubOptions) Copy() HubOptions {
return HubOptions{
Regard: o.Regard,
Disregard: o.Disregard,
NoDefaults: o.NoDefaults,
HubPolicies: o.HubPolicies,
RequireVerifiedOwners: o.RequireVerifiedOwners,
CheckHubPolicyWith: o.CheckHubPolicyWith,
}
}
// PinMatcher is a stateful matching function generated by Options.
type PinMatcher func(pin *Pin) bool
// DefaultOptions returns the default options for this Map.
func (m *Map) DefaultOptions() *Options {
m.Lock()
defer m.Unlock()
return m.defaultOptions()
}
func (m *Map) defaultOptions() *Options {
opts := &Options{
RoutingProfile: DefaultRoutingProfileID,
}
return opts
}
// HubPoliciesAreSet returns whether any of the given hub policies are set and non-empty.
func HubPoliciesAreSet(policies []endpoints.Endpoints) bool {
for _, policy := range policies {
if policy.IsSet() {
return true
}
}
return false
}
var emptyHubOptions = &HubOptions{}
// Matcher generates a PinMatcher based on the Options.
func (o *HomeHubOptions) Matcher(hubIntel *hub.Intel) PinMatcher {
if o == nil {
return emptyHubOptions.Matcher(HomeHub, hubIntel)
}
// Convert and call base func.
ho := HubOptions(*o)
return ho.Matcher(HomeHub, hubIntel)
}
// Matcher generates a PinMatcher based on the Options.
func (o *TransitHubOptions) Matcher(hubIntel *hub.Intel) PinMatcher {
if o == nil {
return emptyHubOptions.Matcher(TransitHub, hubIntel)
}
// Convert and call base func.
ho := HubOptions(*o)
return ho.Matcher(TransitHub, hubIntel)
}
// Matcher generates a PinMatcher based on the Options.
func (o *DestinationHubOptions) Matcher(hubIntel *hub.Intel) PinMatcher {
if o == nil {
return emptyHubOptions.Matcher(DestinationHub, hubIntel)
}
// Convert and call base func.
ho := HubOptions(*o)
return ho.Matcher(DestinationHub, hubIntel)
}
// Matcher generates a PinMatcher based on the Options.
// Always use the Matcher on option structs if you can.
func (o *Options) Matcher(hubType HubType, hubIntel *hub.Intel) PinMatcher {
switch hubType {
case HomeHub:
return o.Home.Matcher(hubIntel)
case TransitHub:
return o.Transit.Matcher(hubIntel)
case DestinationHub:
return o.Destination.Matcher(hubIntel)
default:
return nil // This will panic, but should never be used.
}
}
// Matcher generates a PinMatcher based on the Options.
func (o *HubOptions) Matcher(hubType HubType, hubIntel *hub.Intel) PinMatcher {
// Fallback to empty hub options.
if o == nil {
o = emptyHubOptions
}
// Compile states to regard and disregard.
regard := o.Regard
disregard := o.Disregard
// Add default states.
if !o.NoDefaults {
// Add default States.
regard = regard.Add(StateSummaryRegard)
disregard = disregard.Add(StateSummaryDisregard)
// Add type based Advisories.
switch hubType {
case HomeHub:
// Home Hubs don't need to be reachable and don't need keys ready to be used.
regard = regard.Remove(StateReachable)
regard = regard.Remove(StateActive)
// Follow advisory.
disregard = disregard.Add(StateUsageAsHomeDiscouraged)
// Home Hub may be the current Home Hub.
disregard = disregard.Remove(StateIsHomeHub)
case TransitHub:
// Transit Hubs get no additional states.
case DestinationHub:
// Follow advisory.
disregard = disregard.Add(StateUsageAsDestinationDiscouraged)
// Do not use if Hub reports network issues.
disregard = disregard.Add(StateConnectivityIssues)
}
}
// Add intel policies.
hubPolicies := o.HubPolicies
if hubIntel != nil && hubIntel.Parsed() != nil {
switch hubType {
case HomeHub:
hubPolicies = append(hubPolicies, hubIntel.Parsed().HubAdvisory, hubIntel.Parsed().HomeHubAdvisory)
case TransitHub:
hubPolicies = append(hubPolicies, hubIntel.Parsed().HubAdvisory)
case DestinationHub:
hubPolicies = append(hubPolicies, hubIntel.Parsed().HubAdvisory, hubIntel.Parsed().DestinationHubAdvisory)
}
}
// Add entry/exit policiy checks.
checkHubPolicyWith := o.CheckHubPolicyWith
return func(pin *Pin) bool {
// Check required Pin States.
if !pin.State.Has(regard) || pin.State.HasAnyOf(disregard) {
return false
}
// Check verified owners.
if len(o.RequireVerifiedOwners) > 0 {
// Check if Pin has a verified owner at all.
if pin.VerifiedOwner == "" {
return false
}
// Check if verified owner is in the list.
inList := false
for _, allowed := range o.RequireVerifiedOwners {
if pin.VerifiedOwner == allowed {
inList = true
break
}
}
// Pin does not have a verified owner from the allowed list.
if !inList {
return false
}
}
// Check policies.
policyCheck:
for _, policy := range hubPolicies {
// Check if policy is set.
if !policy.IsSet() {
continue
}
// Check if policy matches.
result, reason := policy.MatchMulti(context.TODO(), pin.EntityV4, pin.EntityV6)
switch result {
case endpoints.NoMatch:
// Continue with check.
case endpoints.MatchError:
log.Warningf("spn/navigator: failed to match policy: %s", reason)
// Continue with check for now.
// TODO: Rethink how to do this. If eg. the geoip database has a
// problem, then no Hub will match. For now, just continue to the
// next rule set. Not optimal, but fail safe.
case endpoints.Denied:
// Explicitly denied, abort immediately.
return false
case endpoints.Permitted:
// Explicitly allowed, abort check and continue.
break policyCheck
}
}
// Check entry/exit policies.
if checkHubPolicyWith != nil {
switch hubType {
case HomeHub:
if endpointListMatch(pin.Hub.Info.EntryPolicy(), checkHubPolicyWith) == endpoints.Denied {
// Hub does not allow entry from the given entity.
return false
}
case TransitHub:
// Transit Hubs do not have a hub policy.
case DestinationHub:
if endpointListMatch(pin.Hub.Info.ExitPolicy(), checkHubPolicyWith) == endpoints.Denied {
// Hub does not allow exit to the given entity.
return false
}
}
}
return true // All checks have passed.
}
}
func endpointListMatch(list endpoints.Endpoints, entity *intel.Entity) endpoints.EPResult {
// Check if endpoint list and entity are available.
if !list.IsSet() || entity == nil {
return endpoints.NoMatch
}
// Match and return result only.
result, _ := list.Match(context.TODO(), entity)
return result
}

269
spn/navigator/pin.go Normal file
View File

@@ -0,0 +1,269 @@
package navigator
import (
"context"
"net"
"strings"
"time"
"github.com/tevino/abool"
"github.com/safing/portbase/log"
"github.com/safing/portmaster/service/intel"
"github.com/safing/portmaster/service/intel/geoip"
"github.com/safing/portmaster/spn/docks"
"github.com/safing/portmaster/spn/hub"
)
// Pin represents a Hub on a Map.
type Pin struct { //nolint:maligned
// Hub Information
Hub *hub.Hub
EntityV4 *intel.Entity
EntityV6 *intel.Entity
LocationV4 *geoip.Location
LocationV6 *geoip.Location
// Hub Status
State PinState
// VerifiedOwner holds the name of the verified owner / operator of the Hub.
VerifiedOwner string
// HopDistance signifies the needed hops to reach this Hub.
// HopDistance is measured from the view of a client.
// A Hub itself will have itself at distance 1.
// Directly connected Hubs have a distance of 2.
HopDistance int
// Cost is the routing cost of this Hub.
Cost float32
// ConnectedTo holds validated lanes.
ConnectedTo map[string]*Lane // Key is Hub ID.
// FailingUntil specifies until when this Hub should be regarded as failing.
// This is connected to StateFailing.
FailingUntil time.Time
// Connection holds a information about a connection to the Hub of this Pin.
Connection *PinConnection
// Internal
// pushChanges is set to true if something noteworthy on the Pin changed and
// an update needs to be pushed by the database storage interface to whoever
// is listening.
pushChanges *abool.AtomicBool
// measurements holds Measurements regarding this Pin.
// It must always be set and the reference must not be changed when measuring
// is enabled.
// Access to fields within are coordinated by itself.
measurements *hub.Measurements
// analysis holds the analysis state.
// Should only be set during analysis and be reset at the start and removed at the end of an analysis.
analysis *AnalysisState
// region is the region this Pin belongs to.
region *Region
}
// PinConnection represents a connection to a terminal on the Hub.
type PinConnection struct {
// Terminal holds the active terminal session.
Terminal *docks.ExpansionTerminal
// Route is the route built for this terminal.
Route *Route
}
// Lane is a connection to another Hub.
type Lane struct {
// Pin is the Pin/Hub this Lane connects to.
Pin *Pin
// Capacity designates the available bandwidth between these Hubs.
// It is specified in bit/s.
Capacity int
// Lateny designates the latency between these Hubs.
// It is specified in nanoseconds.
Latency time.Duration
// Cost is the routing cost of this lane.
Cost float32
// active is a helper flag in order help remove abandoned Lanes.
active bool
}
// Lock locks the Pin via the Hub's lock.
func (pin *Pin) Lock() {
pin.Hub.Lock()
}
// Unlock unlocks the Pin via the Hub's lock.
func (pin *Pin) Unlock() {
pin.Hub.Unlock()
}
// String returns a human-readable representation of the Pin.
func (pin *Pin) String() string {
return "<Pin " + pin.Hub.Name() + ">"
}
// GetState returns the state of the pin.
func (pin *Pin) GetState() PinState {
pin.Lock()
defer pin.Unlock()
return pin.State
}
// updateLocationData fetches the necessary location data in order to correctly map out the Pin.
func (pin *Pin) updateLocationData() {
// TODO: We are currently assigning the Hub ID to the entity domain to
// support matching a Hub by its ID. The issue here is that the domain
// rules are lower-cased, so we have to lower-case the ID here too.
// This is not optimal from a security perspective, but there are still
// enough bits left that this cannot be easily exploited.
if pin.Hub.Info.IPv4 != nil {
pin.EntityV4 = (&intel.Entity{
IP: pin.Hub.Info.IPv4,
Domain: strings.ToLower(pin.Hub.ID) + ".",
}).Init(0)
var ok bool
pin.LocationV4, ok = pin.EntityV4.GetLocation(context.TODO())
if !ok {
log.Warningf("spn/navigator: failed to get location of %s of %s", pin.Hub.Info.IPv4, pin.Hub.StringWithoutLocking())
return
}
} else {
pin.EntityV4 = nil
pin.LocationV4 = nil
}
if pin.Hub.Info.IPv6 != nil {
pin.EntityV6 = (&intel.Entity{
IP: pin.Hub.Info.IPv6,
Domain: strings.ToLower(pin.Hub.ID) + ".",
}).Init(0)
var ok bool
pin.LocationV6, ok = pin.EntityV6.GetLocation(context.TODO())
if !ok {
log.Warningf("spn/navigator: failed to get location of %s of %s", pin.Hub.Info.IPv6, pin.Hub.StringWithoutLocking())
return
}
} else {
pin.EntityV6 = nil
pin.LocationV6 = nil
}
}
// GetLocation returns the geoip location of the Pin, preferring first the given IP, then IPv4.
func (pin *Pin) GetLocation(ip net.IP) *geoip.Location {
pin.Lock()
defer pin.Unlock()
switch {
case ip != nil && ip.Equal(pin.Hub.Info.IPv4) && pin.LocationV4 != nil:
return pin.LocationV4
case ip != nil && ip.Equal(pin.Hub.Info.IPv6) && pin.LocationV6 != nil:
return pin.LocationV6
case pin.LocationV4 != nil:
return pin.LocationV4
case pin.LocationV6 != nil:
return pin.LocationV6
default:
return nil
}
}
// SetActiveTerminal sets an active terminal for the pin.
func (pin *Pin) SetActiveTerminal(pc *PinConnection) {
pin.Lock()
defer pin.Unlock()
pin.Connection = pc
if pin.Connection != nil && pin.Connection.Terminal != nil {
pin.Connection.Terminal.SetChangeNotifyFunc(pin.NotifyTerminalChange)
}
pin.pushChanges.Set()
}
// GetActiveTerminal returns the active terminal of the pin.
func (pin *Pin) GetActiveTerminal() *docks.ExpansionTerminal {
pin.Lock()
defer pin.Unlock()
if !pin.hasActiveTerminal() {
return nil
}
return pin.Connection.Terminal
}
// HasActiveTerminal returns whether the Pin has an active terminal.
func (pin *Pin) HasActiveTerminal() bool {
pin.Lock()
defer pin.Unlock()
return pin.hasActiveTerminal()
}
func (pin *Pin) hasActiveTerminal() bool {
return pin.Connection != nil &&
pin.Connection.Terminal.Abandoning.IsNotSet()
}
// NotifyTerminalChange notifies subscribers of the changed terminal.
func (pin *Pin) NotifyTerminalChange() {
pin.pushChanges.Set()
pin.pushChange()
}
// IsFailing returns whether the pin should be treated as failing.
// The Pin is locked for this.
func (pin *Pin) IsFailing() bool {
pin.Lock()
defer pin.Unlock()
return time.Now().Before(pin.FailingUntil)
}
// MarkAsFailingFor marks the pin as failing.
// The Pin is locked for this.
// Changes are pushed.
func (pin *Pin) MarkAsFailingFor(duration time.Duration) {
pin.Lock()
defer pin.Unlock()
until := time.Now().Add(duration)
// Only ever increase failing until, never reduce.
if until.After(pin.FailingUntil) {
pin.FailingUntil = until
}
pin.addStates(StateFailing)
pin.pushChanges.Set()
pin.pushChange()
}
// ResetFailingState resets the failing state.
// The Pin is locked for this.
// Changes are not pushed, but Pins are marked.
func (pin *Pin) ResetFailingState() {
pin.Lock()
defer pin.Unlock()
if time.Now().Before(pin.FailingUntil) {
pin.FailingUntil = time.Now()
pin.pushChanges.Set()
}
if pin.State.Has(StateFailing) {
pin.removeStates(StateFailing)
pin.pushChanges.Set()
}
}

View File

@@ -0,0 +1,98 @@
package navigator
import (
"sync"
"time"
"github.com/safing/portbase/database/record"
"github.com/safing/portmaster/service/intel"
"github.com/safing/portmaster/spn/hub"
)
// PinExport is the exportable version of a Pin.
type PinExport struct {
record.Base
sync.Mutex
ID string
Name string
Map string
FirstSeen time.Time
EntityV4 *intel.Entity
EntityV6 *intel.Entity
// TODO: add coords
States []string // From pin.State
VerifiedOwner string
HopDistance int
ConnectedTo map[string]*LaneExport // Key is Hub ID.
Route []string // Includes Home Hub and this Pin's ID.
SessionActive bool
Info *hub.Announcement
Status *hub.Status
}
// LaneExport is the exportable version of a Lane.
type LaneExport struct {
HubID string
// Capacity designates the available bandwidth between these Hubs.
// It is specified in bit/s.
Capacity int
// Lateny designates the latency between these Hubs.
// It is specified in nanoseconds.
Latency time.Duration
}
// Export puts the Pin's information into an exportable format.
func (pin *Pin) Export() *PinExport {
pin.Lock()
defer pin.Unlock()
// Shallow copy static values.
export := &PinExport{
ID: pin.Hub.ID,
Name: pin.Hub.Info.Name,
Map: pin.Hub.Map,
FirstSeen: pin.Hub.FirstSeen,
EntityV4: pin.EntityV4,
EntityV6: pin.EntityV6,
States: pin.State.Export(),
VerifiedOwner: pin.VerifiedOwner,
HopDistance: pin.HopDistance,
SessionActive: pin.hasActiveTerminal() || pin.State.Has(StateIsHomeHub),
Info: pin.Hub.Info, // Is updated as a whole, no need to copy.
Status: pin.Hub.Status, // Is updated as a whole, no need to copy.
}
// Export lanes.
export.ConnectedTo = make(map[string]*LaneExport, len(pin.ConnectedTo))
for key, lane := range pin.ConnectedTo {
export.ConnectedTo[key] = &LaneExport{
HubID: lane.Pin.Hub.ID,
Capacity: lane.Capacity,
Latency: lane.Latency,
}
}
// Export route to Pin, if connected.
if pin.Connection != nil && pin.Connection.Route != nil {
export.Route = make([]string, len(pin.Connection.Route.Path))
for key, hop := range pin.Connection.Route.Path {
export.Route[key] = hop.HubID
}
}
// Create database record metadata.
export.SetKey(makeDBKey(export.Map, export.ID))
export.SetMeta(&record.Meta{
Created: export.FirstSeen.Unix(),
Modified: time.Now().Unix(),
})
return export
}

231
spn/navigator/region.go Normal file
View File

@@ -0,0 +1,231 @@
package navigator
import (
"context"
"math"
"github.com/safing/portbase/log"
"github.com/safing/portmaster/service/profile/endpoints"
"github.com/safing/portmaster/spn/hub"
)
const (
defaultRegionalMinLanesPerHub = 0.5
defaultRegionalMaxLanesOnHub = 2
defaultSatelliteMinLanesPerHub = 0.3
defaultInternalMinLanesOnHub = 3
defaultInternalMaxHops = 3
)
// Region specifies a group of Hubs for optimization purposes.
type Region struct {
ID string
Name string
config *hub.RegionConfig
memberPolicy endpoints.Endpoints
pins []*Pin
regardedPins []*Pin
regionalMinLanes int
regionalMaxLanesOnHub int
satelliteMinLanes int
internalMinLanesOnHub int
internalMaxHops int
}
func (region *Region) getName() string {
switch {
case region == nil:
return "-"
case region.Name != "":
return region.Name
default:
return region.ID
}
}
func (m *Map) updateRegions(config []*hub.RegionConfig) {
// Reset map and pins.
m.regions = make([]*Region, 0, len(config))
for _, pin := range m.all {
pin.region = nil
}
// Stop if not regions are defined.
if len(config) == 0 {
return
}
// Build regions from config.
for _, regionConfig := range config {
// Check if region has an ID.
if regionConfig.ID == "" {
log.Error("spn/navigator: region is missing ID")
// Abort adding this region to the map.
continue
}
// Create new region.
region := &Region{
ID: regionConfig.ID,
Name: regionConfig.Name,
config: regionConfig,
}
// Parse member policy.
if len(regionConfig.MemberPolicy) == 0 {
log.Errorf("spn/navigator: member policy of region %s is missing", region.ID)
// Abort adding this region to the map.
continue
}
memberPolicy, err := endpoints.ParseEndpoints(regionConfig.MemberPolicy)
if err != nil {
log.Errorf("spn/navigator: failed to parse member policy of region %s: %s", region.ID, err)
// Abort adding this region to the map.
continue
}
region.memberPolicy = memberPolicy
// Recalculate region properties.
region.recalculateProperties()
// Add region to map.
m.regions = append(m.regions, region)
}
// Update region in all Pins.
for _, pin := range m.all {
m.updatePinRegion(pin)
}
}
func (region *Region) addPin(pin *Pin) {
// Find pin in region.
for _, regionPin := range region.pins {
if pin.Hub.ID == regionPin.Hub.ID {
// Pin is already part of region.
return
}
}
// Check if pin is already part of this region.
if pin.region != nil && pin.region.ID == region.ID {
return
}
// Remove pin from previous region.
if pin.region != nil {
pin.region.removePin(pin)
}
// Add new pin to region.
region.pins = append(region.pins, pin)
pin.region = region
// Recalculate region properties.
region.recalculateProperties()
}
func (region *Region) removePin(pin *Pin) {
// Find pin index in region.
removeIndex := -1
for index, regionPin := range region.pins {
if pin.Hub.ID == regionPin.Hub.ID {
removeIndex = index
break
}
}
if removeIndex < 0 {
// Pin is not part of region.
return
}
// Remove pin from region.
region.pins = append(region.pins[:removeIndex], region.pins[removeIndex+1:]...)
// Recalculate region properties.
region.recalculateProperties()
}
func (region *Region) recalculateProperties() {
// Regional properties.
region.regionalMinLanes = calculateMinLanes(
len(region.pins),
region.config.RegionalMinLanes,
region.config.RegionalMinLanesPerHub,
defaultRegionalMinLanesPerHub,
)
region.regionalMaxLanesOnHub = region.config.RegionalMaxLanesOnHub
if region.regionalMaxLanesOnHub <= 0 {
region.regionalMaxLanesOnHub = defaultRegionalMaxLanesOnHub
}
// Satellite properties.
region.satelliteMinLanes = calculateMinLanes(
len(region.pins),
region.config.SatelliteMinLanes,
region.config.SatelliteMinLanesPerHub,
defaultSatelliteMinLanesPerHub,
)
// Internal properties.
region.internalMinLanesOnHub = region.config.InternalMinLanesOnHub
if region.internalMinLanesOnHub <= 0 {
region.internalMinLanesOnHub = defaultInternalMinLanesOnHub
}
region.internalMaxHops = region.config.InternalMaxHops
if region.internalMaxHops <= 0 {
region.internalMaxHops = defaultInternalMaxHops
}
// Values below 2 do not make any sense for max hops.
if region.internalMaxHops < 2 {
region.internalMaxHops = 2
}
}
func calculateMinLanes(regionHubCount, minLanes int, minLanesPerHub, defaultMinLanesPerHub float64) (minLaneCount int) {
// Validate hub count.
if regionHubCount <= 0 {
// Reset to safe value.
regionHubCount = 1
}
// Set to configured minimum lanes.
minLaneCount = minLanes
// Raise to configured minimum lanes per Hub.
if minLanesPerHub != 0 {
minLanesFromSize := int(math.Ceil(float64(regionHubCount) * minLanesPerHub))
if minLanesFromSize > minLaneCount {
minLaneCount = minLanesFromSize
}
}
// Raise to default minimum lanes per Hub, if still 0.
if minLaneCount <= 0 {
minLaneCount = int(math.Ceil(float64(regionHubCount) * defaultMinLanesPerHub))
}
return minLaneCount
}
func (m *Map) updatePinRegion(pin *Pin) {
for _, region := range m.regions {
// Check if pin matches the region's member policy.
if pin.EntityV4 != nil {
result, _ := region.memberPolicy.Match(context.TODO(), pin.EntityV4)
if result == endpoints.Permitted {
region.addPin(pin)
return
}
}
if pin.EntityV6 != nil {
result, _ := region.memberPolicy.Match(context.TODO(), pin.EntityV6)
if result == endpoints.Permitted {
region.addPin(pin)
return
}
}
}
}

221
spn/navigator/route.go Normal file
View File

@@ -0,0 +1,221 @@
package navigator
import (
"fmt"
mrand "math/rand"
"sort"
"strings"
"time"
)
// Routes holds a collection of Routes.
type Routes struct {
All []*Route
randomizeTopPercent float32
maxCost float32 // automatic
maxRoutes int // manual setting
}
// Len is the number of elements in the collection.
func (r *Routes) Len() int {
return len(r.All)
}
// Less reports whether the element with index i should sort before the element
// with index j.
func (r *Routes) Less(i, j int) bool {
return r.All[i].TotalCost < r.All[j].TotalCost
}
// Swap swaps the elements with indexes i and j.
func (r *Routes) Swap(i, j int) {
r.All[i], r.All[j] = r.All[j], r.All[i]
}
// isGoodEnough reports whether the route would survive a clean process.
func (r *Routes) isGoodEnough(route *Route) bool {
if r.maxCost > 0 && route.TotalCost > r.maxCost {
return false
}
return true
}
// add adds a Route if it is good enough.
func (r *Routes) add(route *Route) {
if !r.isGoodEnough(route) {
return
}
r.All = append(r.All, route.CopyUpTo(0))
r.clean()
}
// clean sort and shortens the list to the configured maximum.
func (r *Routes) clean() {
// Sort Routes so that the best ones are on top.
sort.Sort(r)
// Remove all remaining from the list.
if len(r.All) > r.maxRoutes {
r.All = r.All[:r.maxRoutes]
}
// Set new maximum total cost.
if len(r.All) >= r.maxRoutes {
r.maxCost = r.All[len(r.All)-1].TotalCost
}
}
// randomizeTop randomized to the top nearest pins for balancing the network.
func (r *Routes) randomizeTop() {
switch {
case r.randomizeTopPercent == 0:
// Check if randomization is enabled.
return
case len(r.All) < 2:
// Check if we have enough pins to work with.
return
}
// Find randomization set.
randomizeUpTo := len(r.All)
threshold := r.All[0].TotalCost * (1 + r.randomizeTopPercent)
for i, r := range r.All {
// Find first value above the threshold to stop.
if r.TotalCost > threshold {
randomizeUpTo = i
break
}
}
// Shuffle top set.
if randomizeUpTo >= 2 {
mr := mrand.New(mrand.NewSource(time.Now().UnixNano())) //nolint:gosec
mr.Shuffle(randomizeUpTo, r.Swap)
}
}
// Route is a path through the map.
type Route struct {
// Path is a list of Transit Hubs and the Destination Hub, including the Cost
// for each Hop.
Path []*Hop
// DstCost is the calculated cost between the Destination Hub and the destination IP.
DstCost float32
// TotalCost is the sum of all costs of this Route.
TotalCost float32
// Algorithm is the ID of the algorithm used to calculate the route.
Algorithm string
}
// Hop is one hop of a route's path.
type Hop struct {
pin *Pin
// HubID is the Hub ID.
HubID string
// Cost is the cost for both Lane to this Hub and the Hub itself.
Cost float32
}
// addHop adds a hop to the route.
func (r *Route) addHop(pin *Pin, cost float32) {
r.Path = append(r.Path, &Hop{
pin: pin,
Cost: cost,
})
r.recalculateTotalCost()
}
// completeRoute completes the route by adding the destination cost of the
// connection between the last hop and the destination IP.
func (r *Route) completeRoute(dstCost float32) {
r.DstCost = dstCost
r.recalculateTotalCost()
}
// removeHop removes the last hop from the Route.
func (r *Route) removeHop() {
// Reset DstCost, as the route might have been completed.
r.DstCost = 0
if len(r.Path) >= 1 {
r.Path = r.Path[:len(r.Path)-1]
}
r.recalculateTotalCost()
}
// recalculateTotalCost recalculates to total cost of this route.
func (r *Route) recalculateTotalCost() {
r.TotalCost = r.DstCost
for _, hop := range r.Path {
if hop.pin.HasActiveTerminal() {
// If we have an active connection, only take 80% of the cost.
r.TotalCost += hop.Cost * 0.8
} else {
r.TotalCost += hop.Cost
}
}
}
// CopyUpTo makes a somewhat deep copy of the Route up to the specified amount
// and returns it. Hops themselves are not copied, because their data does not
// change. Therefore, returned Hops may not be edited.
// Specify an amount of 0 to copy all.
func (r *Route) CopyUpTo(n int) *Route {
// Check amount.
if n == 0 || n > len(r.Path) {
n = len(r.Path)
}
newRoute := &Route{
Path: make([]*Hop, n),
DstCost: r.DstCost,
TotalCost: r.TotalCost,
}
copy(newRoute.Path, r.Path)
return newRoute
}
// makeExportReady fills in all the missing data fields which are meant for
// exporting only.
func (r *Routes) makeExportReady(algorithm string) {
for _, route := range r.All {
route.makeExportReady(algorithm)
}
}
// makeExportReady fills in all the missing data fields which are meant for
// exporting only.
func (r *Route) makeExportReady(algorithm string) {
r.Algorithm = algorithm
for _, hop := range r.Path {
hop.makeExportReady()
}
}
// makeExportReady fills in all the missing data fields which are meant for
// exporting only.
func (hop *Hop) makeExportReady() {
hop.HubID = hop.pin.Hub.ID
}
// Pin returns the Pin of the Hop.
func (hop *Hop) Pin() *Pin {
return hop.pin
}
func (r *Route) String() string {
s := make([]string, 0, len(r.Path)+2)
s = append(s, fmt.Sprintf("route with %.2fc:", r.TotalCost))
for i, hop := range r.Path {
if i == 0 {
s = append(s, hop.pin.String())
} else {
s = append(s, fmt.Sprintf("--> %.2fc %s", hop.Cost, hop.pin))
}
}
s = append(s, fmt.Sprintf("--> %.2fc", r.DstCost))
return strings.Join(s, " ")
}

View File

@@ -0,0 +1,162 @@
package navigator
import (
"github.com/safing/portbase/log"
"github.com/safing/portmaster/service/profile"
)
// RoutingProfile defines a routing algorithm with some options.
type RoutingProfile struct {
ID string
// Name is the human readable name of the profile.
Name string
// MinHops defines how many hops a route must have at minimum. In order to
// reduce confusion, the Home Hub is also counted.
MinHops int
// MaxHops defines the limit on how many hops a route may have. In order to
// reduce confusion, the Home Hub is also counted.
MaxHops int
// MaxExtraHops sets a limit on how many extra hops are allowed in addition
// to the amount of Hops in the currently best route. This is an optimization
// option and should not interfere with finding the best route, but might
// reduce the amount of routes found.
MaxExtraHops int
// MaxExtraCost sets a limit on the extra cost allowed in addition to the
// cost of the currently best route. This is an optimization option and
// should not interfere with finding the best route, but might reduce the
// amount of routes found.
MaxExtraCost float32
}
// Routing Profile Names.
const (
RoutingProfileHomeID = "home"
RoutingProfileSingleHopID = "single-hop"
RoutingProfileDoubleHopID = "double-hop"
RoutingProfileTripleHopID = "triple-hop"
)
// Routing Profiles.
var (
DefaultRoutingProfileID = profile.DefaultRoutingProfileID
RoutingProfileHome = &RoutingProfile{
ID: "home",
Name: "Plain VPN Mode",
MinHops: 1,
MaxHops: 1,
}
RoutingProfileSingleHop = &RoutingProfile{
ID: "single-hop",
Name: "Speed Focused",
MinHops: 1,
MaxHops: 3,
MaxExtraHops: 1,
MaxExtraCost: 10000,
}
RoutingProfileDoubleHop = &RoutingProfile{
ID: "double-hop",
Name: "Balanced",
MinHops: 2,
MaxHops: 4,
MaxExtraHops: 2,
MaxExtraCost: 10000,
}
RoutingProfileTripleHop = &RoutingProfile{
ID: "triple-hop",
Name: "Privacy Focused",
MinHops: 3,
MaxHops: 5,
MaxExtraHops: 3,
MaxExtraCost: 10000,
}
)
// GetRoutingProfile returns the routing profile with the given ID.
func GetRoutingProfile(id string) *RoutingProfile {
switch id {
case RoutingProfileHomeID:
return RoutingProfileHome
case RoutingProfileSingleHopID:
return RoutingProfileSingleHop
case RoutingProfileDoubleHopID:
return RoutingProfileDoubleHop
case RoutingProfileTripleHopID:
return RoutingProfileTripleHop
default:
return RoutingProfileDoubleHop
}
}
type routeCompliance uint8
const (
routeOk routeCompliance = iota // Route is fully compliant and can be used.
routeNonCompliant // Route is not compliant, but this might change if more hops are added.
routeDisqualified // Route is disqualified and won't be able to become compliant.
)
func (rp *RoutingProfile) checkRouteCompliance(route *Route, foundRoutes *Routes) routeCompliance {
switch {
case len(route.Path) < rp.MinHops:
// Route is shorter than the defined minimum.
return routeNonCompliant
case len(route.Path) > rp.MaxHops:
// Route is longer than the defined maximum.
return routeDisqualified
}
// Check for hub re-use.
if len(route.Path) >= 2 {
lastHop := route.Path[len(route.Path)-1]
for _, hop := range route.Path[:len(route.Path)-1] {
if lastHop.pin.Hub.ID == hop.pin.Hub.ID {
return routeDisqualified
}
}
}
// Check if hub is already in use, if so check if the route matches.
if len(route.Path) >= 2 {
// Get active connection to the last pin of the current path.
lastPinConnection := route.Path[len(route.Path)-1].pin.Connection
switch {
case lastPinConnection == nil:
// Last pin is not yet connected.
case len(lastPinConnection.Route.Path) < 2:
// Path of last pin does not have enough hops.
// This is unexpected and should not happen.
log.Errorf(
"navigator: expected active connection to %s to have 2 hops or more on path, but it had %d",
route.Path[len(route.Path)-1].pin.Hub.StringWithoutLocking(),
len(lastPinConnection.Route.Path),
)
case lastPinConnection.Route.Path[len(lastPinConnection.Route.Path)-2].pin.Hub.ID != route.Path[len(route.Path)-2].pin.Hub.ID:
// The previous hop of the existing route and the one we are evaluating don't match.
// Currently, we only allow one session per Hub.
return routeDisqualified
}
}
// Abort route exploration when we are outside the optimization boundaries.
if len(foundRoutes.All) > 0 {
// Get the best found route.
best := foundRoutes.All[0]
// Abort if current route exceeds max extra costs.
if route.TotalCost > best.TotalCost+rp.MaxExtraCost {
return routeDisqualified
}
// Abort if current route exceeds max extra hops.
if len(route.Path) > len(best.Path)+rp.MaxExtraHops {
return routeDisqualified
}
}
return routeOk
}

141
spn/navigator/sort.go Normal file
View File

@@ -0,0 +1,141 @@
package navigator
type sortByPinID []*Pin
func (a sortByPinID) Len() int { return len(a) }
func (a sortByPinID) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a sortByPinID) Less(i, j int) bool { return a[i].Hub.ID < a[j].Hub.ID }
type sortByLowestMeasuredCost []*Pin
func (a sortByLowestMeasuredCost) Len() int { return len(a) }
func (a sortByLowestMeasuredCost) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a sortByLowestMeasuredCost) Less(i, j int) bool {
x := a[i].measurements.GetCalculatedCost()
y := a[j].measurements.GetCalculatedCost()
if x != y {
return x < y
}
// Fall back to geo proximity.
gx := a[i].measurements.GetGeoProximity()
gy := a[j].measurements.GetGeoProximity()
if gx != gy {
return gx > gy
}
// Fall back to Hub ID.
return a[i].Hub.ID < a[j].Hub.ID
}
type sortBySuggestedHopDistanceAndLowestMeasuredCost []*Pin
func (a sortBySuggestedHopDistanceAndLowestMeasuredCost) Len() int { return len(a) }
func (a sortBySuggestedHopDistanceAndLowestMeasuredCost) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a sortBySuggestedHopDistanceAndLowestMeasuredCost) Less(i, j int) bool {
// First sort by suggested hop distance.
if a[i].analysis.SuggestedHopDistance != a[j].analysis.SuggestedHopDistance {
return a[i].analysis.SuggestedHopDistance > a[j].analysis.SuggestedHopDistance
}
// Then by cost.
x := a[i].measurements.GetCalculatedCost()
y := a[j].measurements.GetCalculatedCost()
if x != y {
return x < y
}
// Fall back to geo proximity.
gx := a[i].measurements.GetGeoProximity()
gy := a[j].measurements.GetGeoProximity()
if gx != gy {
return gx > gy
}
// Fall back to Hub ID.
return a[i].Hub.ID < a[j].Hub.ID
}
type sortBySuggestedHopDistanceInRegionAndLowestMeasuredCost []*Pin
func (a sortBySuggestedHopDistanceInRegionAndLowestMeasuredCost) Len() int { return len(a) }
func (a sortBySuggestedHopDistanceInRegionAndLowestMeasuredCost) Swap(i, j int) {
a[i], a[j] = a[j], a[i]
}
func (a sortBySuggestedHopDistanceInRegionAndLowestMeasuredCost) Less(i, j int) bool {
// First sort by suggested hop distance.
if a[i].analysis.SuggestedHopDistanceInRegion != a[j].analysis.SuggestedHopDistanceInRegion {
return a[i].analysis.SuggestedHopDistanceInRegion > a[j].analysis.SuggestedHopDistanceInRegion
}
// Then by cost.
x := a[i].measurements.GetCalculatedCost()
y := a[j].measurements.GetCalculatedCost()
if x != y {
return x < y
}
// Fall back to geo proximity.
gx := a[i].measurements.GetGeoProximity()
gy := a[j].measurements.GetGeoProximity()
if gx != gy {
return gx > gy
}
// Fall back to Hub ID.
return a[i].Hub.ID < a[j].Hub.ID
}
type sortByLowestMeasuredLatency []*Pin
func (a sortByLowestMeasuredLatency) Len() int { return len(a) }
func (a sortByLowestMeasuredLatency) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a sortByLowestMeasuredLatency) Less(i, j int) bool {
x, _ := a[i].measurements.GetLatency()
y, _ := a[j].measurements.GetLatency()
switch {
case x == y:
// Go to fallbacks.
case x == 0:
// Ignore zero values.
return false // j/y is better.
case y == 0:
// Ignore zero values.
return true // i/x is better.
default:
return x < y
}
// Fall back to geo proximity.
gx := a[i].measurements.GetGeoProximity()
gy := a[j].measurements.GetGeoProximity()
if gx != gy {
return gx > gy
}
// Fall back to Hub ID.
return a[i].Hub.ID < a[j].Hub.ID
}
type sortByHighestMeasuredCapacity []*Pin
func (a sortByHighestMeasuredCapacity) Len() int { return len(a) }
func (a sortByHighestMeasuredCapacity) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a sortByHighestMeasuredCapacity) Less(i, j int) bool {
x, _ := a[i].measurements.GetCapacity()
y, _ := a[j].measurements.GetCapacity()
if x != y {
return x > y
}
// Fall back to geo proximity.
gx := a[i].measurements.GetGeoProximity()
gy := a[j].measurements.GetGeoProximity()
if gx != gy {
return gx > gy
}
// Fall back to Hub ID.
return a[i].Hub.ID < a[j].Hub.ID
}

112
spn/navigator/sort_test.go Normal file
View File

@@ -0,0 +1,112 @@
package navigator
import (
"sort"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/safing/portmaster/spn/hub"
)
func TestSorting(t *testing.T) {
t.Parallel()
list := []*Pin{
{
Hub: &hub.Hub{
ID: "a",
},
measurements: &hub.Measurements{
Latency: 3,
Capacity: 4,
CalculatedCost: 5,
},
analysis: &AnalysisState{
SuggestedHopDistance: 3,
},
},
{
Hub: &hub.Hub{
ID: "b",
},
measurements: &hub.Measurements{
Latency: 4,
Capacity: 3,
CalculatedCost: 1,
},
analysis: &AnalysisState{
SuggestedHopDistance: 2,
},
},
{
Hub: &hub.Hub{
ID: "c",
},
measurements: &hub.Measurements{
Latency: 5,
Capacity: 2,
CalculatedCost: 2,
},
analysis: &AnalysisState{
SuggestedHopDistance: 4,
},
},
{
Hub: &hub.Hub{
ID: "d",
},
measurements: &hub.Measurements{
Latency: 1,
Capacity: 1,
CalculatedCost: 3,
},
analysis: &AnalysisState{
SuggestedHopDistance: 4,
},
},
{
Hub: &hub.Hub{
ID: "e",
},
measurements: &hub.Measurements{
Latency: 2,
Capacity: 5,
CalculatedCost: 4,
},
analysis: &AnalysisState{
SuggestedHopDistance: 4,
},
},
}
sort.Sort(sortByLowestMeasuredCost(list))
checkSorting(t, list, "b-c-d-e-a")
sort.Sort(sortBySuggestedHopDistanceAndLowestMeasuredCost(list))
checkSorting(t, list, "c-d-e-a-b")
sort.Sort(sortByLowestMeasuredLatency(list))
checkSorting(t, list, "d-e-a-b-c")
sort.Sort(sortByHighestMeasuredCapacity(list))
checkSorting(t, list, "e-a-b-c-d")
sort.Sort(sortByPinID(list))
checkSorting(t, list, "a-b-c-d-e")
}
func checkSorting(t *testing.T, sortedList []*Pin, expectedOrder string) {
t.Helper()
// Build list ID string.
ids := make([]string, 0, len(sortedList))
for _, pin := range sortedList {
ids = append(ids, pin.Hub.ID)
}
sortedIDs := strings.Join(ids, "-")
// Check for matching order.
assert.Equal(t, expectedOrder, sortedIDs, "should match")
}

426
spn/navigator/state.go Normal file
View File

@@ -0,0 +1,426 @@
package navigator
import (
"strings"
"time"
)
// PinState holds a bit-mapped collection of Pin states, or a single state used
// for assigment and matching.
type PinState uint16
const (
// StateNone represents an empty state.
StateNone PinState = 0
// Negative States.
// StateInvalid signifies that there was an error while processing or
// handling this Hub.
StateInvalid PinState = 1 << (iota - 1) // 1 << 0 => 00000001 => 0x01
// StateSuperseded signifies that this Hub was superseded by another. This is
// the case if any other Hub with a matching IP was verified after this one.
// Verification timestamp equals Hub.FirstSeen.
StateSuperseded // 0x02
// StateFailing signifies that a recent error was encountered while
// communicating with this Hub. Pin.FailingUntil specifies when this state is
// re-evaluated at earliest.
StateFailing // 0x04
// StateOffline signifies that the Hub is offline.
StateOffline // 0x08
// Positive States.
// StateHasRequiredInfo signifies that the Hub announces the minimum required
// information about itself.
StateHasRequiredInfo // 0x10
// StateReachable signifies that the Hub is reachable via the network from
// the currently connected primary Hub.
StateReachable // 0x20
// StateActive signifies that everything seems fine with the Hub and
// connections to it should succeed. This is tested by checking if a valid
// semi-ephemeral public key is available.
StateActive // 0x40
_ // 0x80: Reserved
// Trust and Advisory States.
// StateTrusted signifies the Hub has the special trusted status.
StateTrusted // 0x0100
// StateUsageDiscouraged signifies that usage of the Hub is discouraged for any task.
StateUsageDiscouraged // 0x0200
// StateUsageAsHomeDiscouraged signifies that usage of the Hub as a Home Hub is discouraged.
StateUsageAsHomeDiscouraged // 0x0400
// StateUsageAsDestinationDiscouraged signifies that usage of the Hub as a Destination Hub is discouraged.
StateUsageAsDestinationDiscouraged // 0x0800
// Special States.
// StateIsHomeHub signifies that the Hub is the current Home Hub. While not
// negative in itself, selecting the Home Hub does not make sense in almost
// all cases.
StateIsHomeHub // 0x1000
// StateConnectivityIssues signifies that the Hub reports connectivity issues.
// This might impact all connectivity or just some.
// This does not invalidate the Hub for all operations and not in all cases.
StateConnectivityIssues // 0x2000
// StateAllowUnencrypted signifies that the Hub is available to handle unencrypted connections.
StateAllowUnencrypted // 0x4000
// State Summaries.
// StateSummaryRegard summarizes all states that must always be set in order to take a Hub into consideration for any task.
// TODO: Add StateHasRequiredInfo when we start enforcing Hub information.
StateSummaryRegard = StateReachable | StateActive
// StateSummaryDisregard summarizes all states that must not be set in order to take a Hub into consideration for any task.
StateSummaryDisregard = StateInvalid |
StateSuperseded |
StateFailing |
StateOffline |
StateUsageDiscouraged |
StateIsHomeHub
)
var allStates = []PinState{
StateInvalid,
StateSuperseded,
StateFailing,
StateOffline,
StateHasRequiredInfo,
StateReachable,
StateActive,
StateTrusted,
StateUsageDiscouraged,
StateUsageAsHomeDiscouraged,
StateUsageAsDestinationDiscouraged,
StateIsHomeHub,
StateConnectivityIssues,
StateAllowUnencrypted,
}
// Add returns a new PinState with the given states added.
func (pinState PinState) Add(states PinState) PinState {
// OR:
// 0011
// | 0101
// = 0111
return pinState | states
}
// Remove returns a new PinState with the given states removed.
func (pinState PinState) Remove(states PinState) PinState {
// AND NOT:
// 0011
// &^ 0101
// = 0010
return pinState &^ states
}
// Has returns whether the state has all of the given states.
func (pinState PinState) Has(states PinState) bool {
// AND:
// 0011
// & 0101
// = 0001
return pinState&states == states
}
// HasAnyOf returns whether the state has any of the given states.
func (pinState PinState) HasAnyOf(states PinState) bool {
// AND:
// 0011
// & 0101
// = 0001
return (pinState & states) != 0
}
// HasNoneOf returns whether the state does not have any of the given states.
func (pinState PinState) HasNoneOf(states PinState) bool {
// AND:
// 0011
// & 0101
// = 0001
return (pinState & states) == 0
}
// addStates adds the given states on the Pin.
func (pin *Pin) addStates(states PinState) {
pin.State = pin.State.Add(states)
}
// removeStates removes the given states on the Pin.
func (pin *Pin) removeStates(states PinState) {
pin.State = pin.State.Remove(states)
}
func (m *Map) updateStateSuperseded(pin *Pin) {
pin.removeStates(StateSuperseded)
// Update StateSuperseded
// Iterate over all Pins in order to find a matching IP address.
// In order to prevent false positive matching, we have to go through IPv4
// and IPv6 separately.
// TODO: This will not scale well beyond about 1000 Hubs.
// IPv4 Loop
if pin.Hub.Info.IPv4 != nil {
for _, mapPin := range m.all {
// Skip Pin itself
if mapPin.Hub.ID == pin.Hub.ID {
continue
}
// Check for a matching IPv4 address.
if mapPin.Hub.Info.IPv4 != nil && pin.Hub.Info.IPv4.Equal(mapPin.Hub.Info.IPv4) {
continueChecking := checkAndHandleSuperseding(pin, mapPin)
if !continueChecking {
break
}
}
}
}
// IPv6 Loop
if pin.Hub.Info.IPv6 != nil {
for _, mapPin := range m.all {
// Skip Pin itself
if mapPin.Hub.ID == pin.Hub.ID {
continue
}
// Check for a matching IPv6 address.
if mapPin.Hub.Info.IPv6 != nil && pin.Hub.Info.IPv6.Equal(mapPin.Hub.Info.IPv6) {
continueChecking := checkAndHandleSuperseding(pin, mapPin)
if !continueChecking {
break
}
}
}
}
}
func checkAndHandleSuperseding(newPin, existingPin *Pin) (continueChecking bool) {
const (
supersedeNone = iota
supersedeExisting
supersedeNew
)
var action int
switch {
case newPin.Hub.ID == existingPin.Hub.ID:
// Cannot supersede same Hub.
// Continue checking.
action = supersedeNone
// Step 1: Check if only one is active.
case newPin.State.Has(StateActive) && existingPin.State.HasNoneOf(StateActive):
// If only the new Hub is active, supersede the existing one.
action = supersedeExisting
case newPin.State.HasNoneOf(StateActive) && existingPin.State.Has(StateActive):
// If only the existing Hub is active, supersede the new one.
action = supersedeNew
// Step 2: Check if only one is reachable.
case newPin.State.Has(StateReachable) && existingPin.State.HasNoneOf(StateReachable):
// If only the new Hub is reachable, supersede the existing one.
action = supersedeExisting
case newPin.State.HasNoneOf(StateReachable) && existingPin.State.Has(StateReachable):
// If only the existing Hub is reachable, supersede the new one.
action = supersedeNew
// Step 3: Check which one has been seen first.
case newPin.Hub.FirstSeen.After(existingPin.Hub.FirstSeen):
// If the new Hub has been first seen later, supersede the existing one.
action = supersedeExisting
default:
// If the existing Hub has been first seen later, supersede the new one.
action = supersedeNew
}
switch action {
case supersedeExisting:
existingPin.addStates(StateSuperseded)
existingPin.pushChanges.Set()
// Continue checking, as there might be other Hubs to be superseded.
return true
case supersedeNew:
newPin.addStates(StateSuperseded)
newPin.pushChanges.Set()
// If the new pin is superseded, do _not_ continue, as this will lead to an incorrect state.
return false
case supersedeNone:
fallthrough
default:
// Do nothing, continue checking.
return true
}
}
func (pin *Pin) updateStateHasRequiredInfo() {
pin.removeStates(StateHasRequiredInfo)
// Check for required Hub Information.
switch {
case len(pin.Hub.Info.Name) == 0:
case len(pin.Hub.Info.Group) == 0:
case len(pin.Hub.Info.ContactAddress) == 0:
case len(pin.Hub.Info.ContactService) == 0:
case len(pin.Hub.Info.Hosters) == 0:
case len(pin.Hub.Info.Hosters[0]) == 0:
case len(pin.Hub.Info.Datacenter) == 0:
default:
pin.addStates(StateHasRequiredInfo)
}
}
func (m *Map) updateActiveHubs() {
now := time.Now().Unix()
for _, pin := range m.all {
pin.updateStateActive(now)
}
}
func (pin *Pin) updateStateActive(now int64) {
pin.removeStates(StateActive)
// Check for active key.
for _, key := range pin.Hub.Status.Keys {
if now < key.Expires {
pin.addStates(StateActive)
return
}
}
}
func (m *Map) recalculateReachableHubs() error {
if m.home == nil {
return ErrHomeHubUnset
}
// reset
for _, pin := range m.all {
pin.removeStates(StateReachable)
pin.HopDistance = 0
pin.pushChanges.Set()
}
// find all connected Hubs
m.home.markReachable(1)
return nil
}
func (pin *Pin) markReachable(hopDistance int) {
switch {
case !pin.State.Has(StateReachable):
// Pin wasn't reachable before.
case hopDistance < pin.HopDistance:
// New path has a shorter distance.
case pin.State.HasAnyOf(StateSummaryDisregard): //nolint:staticcheck
// Ignore disregarded pins for reachability calculation.
return
default:
// Pin is already reachable at same or better distance.
return
}
// Update reachability.
pin.addStates(StateReachable)
pin.HopDistance = hopDistance
pin.pushChanges.Set()
// Propagate to connected Pins.
hopDistance++
for _, lane := range pin.ConnectedTo {
lane.Pin.markReachable(hopDistance)
}
}
// Export returns a list of all state names.
func (pinState PinState) Export() []string {
// Check if there are no states.
if pinState == StateNone {
return nil
}
// Collect state names.
var stateNames []string
for _, state := range allStates {
if pinState.Has(state) {
stateNames = append(stateNames, state.Name())
}
}
return stateNames
}
// String returns the states as a human readable string.
func (pinState PinState) String() string {
stateNames := pinState.Export()
if len(stateNames) == 0 {
return "None"
}
return strings.Join(stateNames, ", ")
}
// Name returns the name of a single state flag.
func (pinState PinState) Name() string {
switch pinState {
case StateNone:
return "None"
case StateInvalid:
return "Invalid"
case StateSuperseded:
return "Superseded"
case StateFailing:
return "Failing"
case StateOffline:
return "Offline"
case StateHasRequiredInfo:
return "HasRequiredInfo"
case StateReachable:
return "Reachable"
case StateActive:
return "Active"
case StateTrusted:
return "Trusted"
case StateUsageDiscouraged:
return "UsageDiscouraged"
case StateUsageAsHomeDiscouraged:
return "UsageAsHomeDiscouraged"
case StateUsageAsDestinationDiscouraged:
return "UsageAsDestinationDiscouraged"
case StateIsHomeHub:
return "IsHomeHub"
case StateConnectivityIssues:
return "ConnectivityIssues"
case StateAllowUnencrypted:
return "AllowUnencrypted"
case StateSummaryRegard, StateSummaryDisregard:
// Satisfy exhaustive linter.
fallthrough
default:
return "Unknown"
}
}

View File

@@ -0,0 +1,31 @@
package navigator
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestStates(t *testing.T) {
t.Parallel()
p := &Pin{}
p.addStates(StateInvalid | StateFailing | StateSuperseded)
assert.Equal(t, StateInvalid|StateFailing|StateSuperseded, p.State)
p.removeStates(StateFailing | StateSuperseded)
assert.Equal(t, StateInvalid, p.State)
p.addStates(StateTrusted | StateActive)
assert.True(t, p.State.Has(StateInvalid|StateTrusted))
assert.False(t, p.State.Has(StateInvalid|StateSuperseded))
assert.True(t, p.State.HasAnyOf(StateInvalid|StateTrusted))
assert.True(t, p.State.HasAnyOf(StateInvalid|StateSuperseded))
assert.False(t, p.State.HasAnyOf(StateSuperseded|StateFailing))
assert.False(t, p.State.Has(StateSummaryRegard))
assert.False(t, p.State.Has(StateSummaryDisregard))
assert.True(t, p.State.HasAnyOf(StateSummaryRegard))
assert.True(t, p.State.HasAnyOf(StateSummaryDisregard))
}

234
spn/navigator/testdata/main-intel.yml vendored Normal file
View File

@@ -0,0 +1,234 @@
---
BootstrapHubs:
- tcp://[2a01:4f8:172:3753::2]:17#Zwtb8EKMatnMRkW1VaLh8CPV3QswD9iuRU4Sda8uLezUkC # fogos [DE]
- tcp://[2a01:4f9:2a:d48::2]:17#Zwkwujs345P4ZygNZcEafawTqfZieCBVogQZ3xZPWiu7BU # heleus [FI]
- tcp://138.201.140.70:17#Zwtb8EKMatnMRkW1VaLh8CPV3QswD9iuRU4Sda8uLezUkC # fogos [DE]
- tcp://95.216.13.61:17#Zwkwujs345P4ZygNZcEafawTqfZieCBVogQZ3xZPWiu7BU # heleus [FI]
Hubs:
ZwhpYS1jWzXvPYKFhJqh1ZD3bKquLLoSoJ6RjeshmcXoFx: # voria [US]
Trusted: true
VerifiedOwner: Safing
Override:
CountryCode: US
Coordinates: # Ashburn, VA
Latitude: 39.04
Longitude: -77.48
AccuracyRadius: 20
ZwkAKBoyEd3PkE5RGDNmghahzHiBiTZA7Mg3XH7X3HjS39: # noru [US]
Trusted: true
VerifiedOwner: Safing
ZwkapJz5HFWpgd9PHsZLVueBu9PDmTJHKp382Wm9MB2EB7: # lovas [US]
Trusted: true
VerifiedOwner: Safing
Override:
CountryCode: US
Coordinates: # Los Angeles, CA
Latitude: 34.03
Longitude: -118.15
AccuracyRadius: 20
ZwkLShvVYvQFGmpY1MNhSSPXCktojywMVtv2N86mFbNH4w: # tooina [CA]
Trusted: true
VerifiedOwner: Safing
Zwkwujs345P4ZygNZcEafawTqfZieCBVogQZ3xZPWiu7BU: # heleus [FI]
Trusted: true
VerifiedOwner: Safing
Zwm72XieV6aeNKbwtJW8JdPUwT1hopQaLanLXjxcTfV3B9: # mergan [US]
Trusted: true
VerifiedOwner: Safing
Zwmp5SgUK9FidWBSCDK4d6dyRp3vhz3dQdwma1E4TMfiRw: # grenenia [FR]
Trusted: true
VerifiedOwner: Safing
Override:
CountryCode: FR
Coordinates: # Gravelines
Latitude: 50.59
Longitude: 2.07
AccuracyRadius: 20
ZwnFd1bSQrBegPZqFkS7DZU29x4PbojpFmTQFUnzQoicKp: # telos [IL]
Trusted: true
VerifiedOwner: Safing
Zwpg5FoXYVYidzgbdvDyvBBcrArmmHvK9nH3v7KDHiywtt: # melcor [PL]
Trusted: true
VerifiedOwner: Safing
ZwpsJpwngWyba54AbVkCawcRQ2HP37RRQAgj5LHNR2svRf: # soalis [AU]
Trusted: true
VerifiedOwner: Safing
Zwpy5hbrQkKznJwbUmn9WpJwGkpWD9VqE2pi9yfMDQM7PK: # rin9 [FR]
Trusted: true
VerifiedOwner: Safing
Override:
CountryCode: FR
Coordinates: # Strasbourg
Latitude: 48.35
Longitude: 7.45
AccuracyRadius: 20
ZwqANMrhcyJZb8cRMEd3FdPcXY7ZbvviPPfTUQpLNau12J: # sulkam [GB]
Trusted: true
VerifiedOwner: Safing
ZwwBspMhigqcEYv2cryipzJsi4vkHhnBqUmDmkJ2xizGFx: # surn [US]
Trusted: true
VerifiedOwner: Safing
Override:
CountryCode: US
Coordinates: # Seattle, WA
Latitude: 47.36
Longitude: -122.19
AccuracyRadius: 20
ZwsvsES3SHz1VLnwFPxDbW6DC8Esp1PiEtUHxGnm4BTYHt: # fungvis [DE]
Trusted: true
VerifiedOwner: Safing
Zwtb8EKMatnMRkW1VaLh8CPV3QswD9iuRU4Sda8uLezUkC: # fogos [DS]
Trusted: true
VerifiedOwner: Safing
ZwtfvBuq5wkKYRth8rGCuGyp42nMe4doASUDJiDHJ8iucn: # vamalla [AT]
Trusted: true
VerifiedOwner: Safing
ZwtjwvdPxG4u7oB2zmNJFvsDy5VDLT9UArDkYDGfC9bkDt: # carros [US]
Trusted: true
VerifiedOwner: Safing
ZwvMZt6RcrrRuCdufjApnosxWbzsP8rTPRuHGeHu5KU241: # syniru [SG]
Trusted: true
VerifiedOwner: Safing
Override:
CountryCode: SG
Coordinates: # Singapore
Latitude: 1.18
Longitude: 103.50
AccuracyRadius: 20
ZwvyDLz8221fcSBw6GKZNDnwEn4YmE9m7JPieLUVe7iGR9: # calla [CA]
Trusted: true
VerifiedOwner: Safing
Zwvz9S6uyxn4ww1JGqJiisGMDmH2hz6mhwutmJXvTtwQww: # cidai [US]
Trusted: true
VerifiedOwner: Safing
ZwxJvZDZH18RUEQ3oFcR5uCqeXJaqkoi9P5Sj1aZ62HPin: # nutis [DE]
Trusted: true
VerifiedOwner: Safing
ZwvPQVFkoDbx3J6qThNwfLHZqwvFgUYLYtirCHVd7FfjBz: # perturn [CZ]
Trusted: true
VerifiedOwner: Safing
Override:
CountryCode: CZ
Coordinates: # Prague
Latitude: 50.05
Longitude: 14.25
AccuracyRadius: 100
Zwj52Q7d5ezvFk7HKB42dBtFu152bC9JasYF7BHB724RfG: # sono [NL]
Trusted: true
VerifiedOwner: Safing
ZwmhYMEmw36CzgVUp9sLjoK3gkVDWdMPiupEcekpTAXur8: # ivtos [TR]
Trusted: true
VerifiedOwner: Safing
Override:
CountryCode: TR
Coordinates: # Izmir
Latitude: 38.25
Longitude: 27.90
AccuracyRadius: 20
Zwm9JX1hBNUUvSYc3gpMhmw84ay45SuyXE7D2UgETM7XCn: # porcania [PT]
Trusted: true
VerifiedOwner: Safing
ZwxE83uRV9LcM8Bm3QjXjjejNRhBBBJAethPf14R6gcZwf: # steepeus [SE]
Trusted: true
VerifiedOwner: Safing
InfoOverrides:
workaround:
for: bug
AdviseOnlyTrustedHubs: false
AdviseOnlyTrustedHomeHubs: true
AdviseOnlyTrustedDestinationHubs: false
HomeHubAdvisory:
- "- Zwj52Q7d5ezvFk7HKB42dBtFu152bC9JasYF7BHB724RfG" # sono [NL] is too slow for home hub
- "- Zwm9JX1hBNUUvSYc3gpMhmw84ay45SuyXE7D2UgETM7XCn" # porcania [PT] is too slow for home hub
- "- ZwmhYMEmw36CzgVUp9sLjoK3gkVDWdMPiupEcekpTAXur8" # ivtos [TR] is too slow for home hub
- "- ZwvPQVFkoDbx3J6qThNwfLHZqwvFgUYLYtirCHVd7FfjBz" # perturn [CZ] is too slow for home hub
Regions:
- ID: europe
Name: Europe
RegionalMinLanes: 5
RegionalMinLanesPerHub: 0.7
RegionalMaxLanesOnHub: 2
SatelliteMinLanes: 2
SatelliteMinLanesPerHub: 0.3
InternalMinLanesOnHub: 3
InternalMaxHops: 3
MemberPolicy:
- "+ AD"
- "+ AL"
- "+ AT"
- "+ AX"
- "+ BA"
- "+ BE"
- "+ BG"
- "+ BY"
- "+ CH"
- "+ CZ"
- "+ DE"
- "+ DK"
- "+ EE"
- "+ ES"
- "+ FI"
- "+ FO"
- "+ FR"
- "+ GB"
- "+ GG"
- "+ GI"
- "+ GR"
- "+ HR"
- "+ HU"
- "+ IE"
- "+ IM"
- "+ IS"
- "+ IT"
- "+ JE"
- "+ LI"
- "+ LT"
- "+ LU"
- "+ LV"
- "+ MC"
- "+ MD"
- "+ ME"
- "+ MK"
- "+ MT"
- "+ NL"
- "+ NO"
- "+ PL"
- "+ PT"
- "+ RO"
- "+ RS"
- "+ RU"
- "+ SE"
- "+ SI"
- "+ SJ"
- "+ SK"
- "+ SM"
- "+ UA"
- "+ VA"
- ID: north-america
Name: "North America"
RegionalMinLanes: 5
RegionalMinLanesPerHub: 0.7
RegionalMaxLanesOnHub: 2
SatelliteMinLanes: 2
SatelliteMinLanesPerHub: 0.3
InternalMinLanesOnHub: 3
InternalMaxHops: 3
MemberPolicy:
- "+ BM"
- "+ BZ"
- "+ CA"
- "+ CR"
- "+ GL"
- "+ GT"
- "+ HN"
- "+ MX"
- "+ NI"
- "+ PA"
- "+ PM"
- "+ SV"
- "+ US"

776
spn/navigator/update.go Normal file
View File

@@ -0,0 +1,776 @@
package navigator
import (
"context"
"fmt"
"path"
"strings"
"time"
"github.com/tevino/abool"
"golang.org/x/exp/slices"
"github.com/safing/portbase/config"
"github.com/safing/portbase/database"
"github.com/safing/portbase/database/query"
"github.com/safing/portbase/database/record"
"github.com/safing/portbase/log"
"github.com/safing/portbase/modules"
"github.com/safing/portbase/utils"
"github.com/safing/portmaster/service/intel/geoip"
"github.com/safing/portmaster/service/netenv"
"github.com/safing/portmaster/service/profile"
"github.com/safing/portmaster/spn/hub"
)
var db = database.NewInterface(&database.Options{
Local: true,
Internal: true,
})
// InitializeFromDatabase loads all Hubs from the given database prefix and adds them to the Map.
func (m *Map) InitializeFromDatabase() error {
m.Lock()
defer m.Unlock()
// start query for Hubs
iter, err := db.Query(query.New(hub.MakeHubDBKey(m.Name, "")))
if err != nil {
return fmt.Errorf("failed to start query for initialization feed of %s map: %w", m.Name, err)
}
// update navigator
var hubCount int
log.Tracef("spn/navigator: starting to initialize %s map from database", m.Name)
for r := range iter.Next {
h, err := hub.EnsureHub(r)
if err != nil {
log.Warningf("spn/navigator: could not parse hub %q while initializing %s map: %s", r.Key(), m.Name, err)
continue
}
hubCount++
m.updateHub(h, false, true)
}
switch {
case iter.Err() != nil:
return fmt.Errorf("failed to (fully) initialize %s map: %w", m.Name, iter.Err())
case hubCount == 0:
log.Warningf("spn/navigator: no hubs available for %s map - this is normal on first start", m.Name)
default:
log.Infof("spn/navigator: added %d hubs from database to %s map", hubCount, m.Name)
}
return nil
}
// UpdateHook updates the a map from database changes.
type UpdateHook struct {
database.HookBase
m *Map
}
// UsesPrePut implements the Hook interface.
func (hook *UpdateHook) UsesPrePut() bool {
return true
}
// PrePut implements the Hook interface.
func (hook *UpdateHook) PrePut(r record.Record) (record.Record, error) {
// Remove deleted hubs from the map.
if r.Meta().IsDeleted() {
hook.m.RemoveHub(path.Base(r.Key()))
return r, nil
}
// Ensure we have a hub and update it in navigation map.
h, err := hub.EnsureHub(r)
if err != nil {
log.Debugf("spn/navigator: record %s is not a hub", r.Key())
} else {
hook.m.updateHub(h, true, false)
}
return r, nil
}
// RegisterHubUpdateHook registers a database pre-put hook that updates all
// Hubs saved at the given database prefix.
func (m *Map) RegisterHubUpdateHook() (err error) {
m.hubUpdateHook, err = database.RegisterHook(
query.New(hub.MakeHubDBKey(m.Name, "")),
&UpdateHook{m: m},
)
return err
}
// CancelHubUpdateHook cancels the map's update hook.
func (m *Map) CancelHubUpdateHook() {
if m.hubUpdateHook != nil {
if err := m.hubUpdateHook.Cancel(); err != nil {
log.Warningf("spn/navigator: failed to cancel update hook for map %s: %s", m.Name, err)
}
}
}
// RemoveHub removes a Hub from the Map.
func (m *Map) RemoveHub(id string) {
m.Lock()
defer m.Unlock()
// Get pin and remove it from the map, if it exists.
pin, ok := m.all[id]
if !ok {
return
}
delete(m.all, id)
// Remove lanes from removed Pin.
for id := range pin.ConnectedTo {
// Remove Lane from peer.
peer, ok := m.all[id]
if ok {
delete(peer.ConnectedTo, pin.Hub.ID)
peer.pushChanges.Set()
}
}
// Push update to subscriptions.
export := pin.Export()
export.Meta().Delete()
mapDBController.PushUpdate(export)
// Push lane changes.
m.PushPinChanges()
}
// UpdateHub updates a Hub on the Map.
func (m *Map) UpdateHub(h *hub.Hub) {
m.updateHub(h, true, true)
}
func (m *Map) updateHub(h *hub.Hub, lockMap, lockHub bool) {
if lockMap {
m.Lock()
defer m.Unlock()
}
if lockHub {
h.Lock()
defer h.Unlock()
}
// Hub requires both Info and Status to be added to the Map.
if h.Info == nil || h.Status == nil {
return
}
// Create or update Pin.
pin, ok := m.all[h.ID]
if ok {
pin.Hub = h
} else {
pin = &Pin{
Hub: h,
ConnectedTo: make(map[string]*Lane),
pushChanges: abool.New(),
}
m.all[h.ID] = pin
}
pin.pushChanges.Set()
// 1. Update Pin Data.
// Add/Update location data from IP addresses.
pin.updateLocationData()
// Override Pin Data.
m.updateInfoOverrides(pin)
// Update Hub cost.
pin.Cost = CalculateHubCost(pin.Hub.Status.Load)
// Ensure measurements are set when enabled.
if m.measuringEnabled && pin.measurements == nil {
// Get shared measurements.
pin.measurements = pin.Hub.GetMeasurementsWithLockedHub()
// Update cost calculation.
latency, _ := pin.measurements.GetLatency()
capacity, _ := pin.measurements.GetCapacity()
pin.measurements.SetCalculatedCost(CalculateLaneCost(latency, capacity))
// Update geo proximity.
// Get own location.
var myLocation *geoip.Location
switch {
case m.home != nil && m.home.LocationV4 != nil:
myLocation = m.home.LocationV4
case m.home != nil && m.home.LocationV6 != nil:
myLocation = m.home.LocationV6
default:
locations, ok := netenv.GetInternetLocation()
if ok {
myLocation = locations.Best().LocationOrNil()
}
}
// Calculate proximity with available location.
if myLocation != nil {
switch {
case pin.LocationV4 != nil:
pin.measurements.SetGeoProximity(
myLocation.EstimateNetworkProximity(pin.LocationV4),
)
case pin.LocationV6 != nil:
pin.measurements.SetGeoProximity(
myLocation.EstimateNetworkProximity(pin.LocationV6),
)
}
}
}
// 2. Update Pin States.
// Update the invalid status of the Pin.
if pin.Hub.InvalidInfo || pin.Hub.InvalidStatus {
pin.addStates(StateInvalid)
} else {
pin.removeStates(StateInvalid)
}
// Update online status of the Pin.
if pin.Hub.HasFlag(hub.FlagOffline) || pin.Hub.Status.Version == hub.VersionOffline {
pin.addStates(StateOffline)
} else {
pin.removeStates(StateOffline)
}
// Update online status of the Pin.
if pin.Hub.HasFlag(hub.FlagAllowUnencrypted) {
pin.addStates(StateAllowUnencrypted)
} else {
pin.removeStates(StateAllowUnencrypted)
}
// Update from status flags.
if pin.Hub.HasFlag(hub.FlagNetError) {
pin.addStates(StateConnectivityIssues)
} else {
pin.removeStates(StateConnectivityIssues)
}
// Update Trust and Advisory Statuses.
m.updateIntelStatuses(pin, cfgOptionTrustNodeNodes())
// Update Statuses derived from Hub.
pin.updateStateHasRequiredInfo()
pin.updateStateActive(time.Now().Unix())
// 3. Update Lanes.
// Mark all existing Lanes as inactive.
for _, lane := range pin.ConnectedTo {
lane.active = false
}
// Update Lanes (connections to other Hubs) from the Status.
for _, lane := range pin.Hub.Status.Lanes {
// Check if this is a Lane to itself.
if lane.ID == pin.Hub.ID {
continue
}
// First, get the Lane peer.
peer, ok := m.all[lane.ID]
if !ok {
// We need to wait for peer to be added to the Map.
continue
}
m.updateHubLane(pin, lane, peer)
}
// Remove all inactive/abandoned Lanes from both Pins.
var removedLanes bool
for id, lane := range pin.ConnectedTo {
if !lane.active {
// Remove Lane from this Pin.
delete(pin.ConnectedTo, id)
pin.pushChanges.Set()
removedLanes = true
// Remove Lane from peer.
peer, ok := m.all[id]
if ok {
delete(peer.ConnectedTo, pin.Hub.ID)
peer.pushChanges.Set()
}
}
}
// Fully recalculate reachability if any Lanes were removed.
if removedLanes {
err := m.recalculateReachableHubs()
if err != nil {
log.Warningf("spn/navigator: failed to recalculate reachable Hubs: %s", err)
}
}
// 4. Update states that depend on other information.
// Check if hub is superseded or if it supersedes another hub.
m.updateStateSuperseded(pin)
// Push updates.
m.PushPinChanges()
}
const (
minUnconfirmedLatency = 10 * time.Millisecond
maxUnconfirmedCapacity = 100000000 // 100Mbit/s
cap1Mbit float32 = 1000000
cap10Mbit float32 = 10000000
cap100Mbit float32 = 100000000
cap1Gbit float32 = 1000000000
cap10Gbit float32 = 10000000000
)
// updateHubLane updates a lane between two Hubs on the Map.
// pin must already be locked, lane belongs to pin.
// peer will be locked by this function.
func (m *Map) updateHubLane(pin *Pin, lane *hub.Lane, peer *Pin) {
peer.Hub.Lock()
defer peer.Hub.Unlock()
// Then get the corresponding Lane from that peer, if it exists.
var peerLane *hub.Lane
for _, possiblePeerLane := range peer.Hub.Status.Lanes {
if possiblePeerLane.ID == pin.Hub.ID {
peerLane = possiblePeerLane
// We have found the corresponding peerLane, break the loop.
break
}
}
if peerLane == nil {
// The peer obviously does not advertise a Lane to this Hub.
// Maybe this is a fresh Lane, and the message has not yet reached us.
// Alternatively, the Lane could have been recently removed.
// Abandon this Lane for now.
delete(pin.ConnectedTo, peer.Hub.ID)
return
}
// Calculate combined latency, use the greater value.
combinedLatency := lane.Latency
if peerLane.Latency > combinedLatency {
combinedLatency = peerLane.Latency
}
// Enforce minimum value if at least one side has no data.
if (lane.Latency == 0 || peerLane.Latency == 0) && combinedLatency < minUnconfirmedLatency {
combinedLatency = minUnconfirmedLatency
}
// Calculate combined capacity, use the lesser existing value.
combinedCapacity := lane.Capacity
if combinedCapacity == 0 || (peerLane.Capacity > 0 && peerLane.Capacity < combinedCapacity) {
combinedCapacity = peerLane.Capacity
}
// Enforce maximum value if at least one side has no data.
if (lane.Capacity == 0 || peerLane.Capacity == 0) && combinedCapacity > maxUnconfirmedCapacity {
combinedCapacity = maxUnconfirmedCapacity
}
// Calculate lane cost.
laneCost := CalculateLaneCost(combinedLatency, combinedCapacity)
// Add Lane to both Pins and override old values in the process.
pin.ConnectedTo[peer.Hub.ID] = &Lane{
Pin: peer,
Capacity: combinedCapacity,
Latency: combinedLatency,
Cost: laneCost,
active: true,
}
peer.ConnectedTo[pin.Hub.ID] = &Lane{
Pin: pin,
Capacity: combinedCapacity,
Latency: combinedLatency,
Cost: laneCost,
active: true,
}
peer.pushChanges.Set()
// Check for reachability.
if pin.State.Has(StateReachable) {
peer.markReachable(pin.HopDistance + 1)
}
if peer.State.Has(StateReachable) {
pin.markReachable(peer.HopDistance + 1)
}
}
// ResetFailingStates resets the failing state on all pins.
func (m *Map) ResetFailingStates(ctx context.Context) {
m.Lock()
defer m.Unlock()
for _, pin := range m.all {
pin.ResetFailingState()
}
m.PushPinChanges()
}
func (m *Map) updateFailingStates(ctx context.Context, task *modules.Task) error {
m.Lock()
defer m.Unlock()
for _, pin := range m.all {
if pin.State.Has(StateFailing) && !pin.IsFailing() {
pin.removeStates(StateFailing)
}
}
return nil
}
func (m *Map) updateStates(ctx context.Context, task *modules.Task) error {
var toDelete []string
m.Lock()
defer m.Unlock()
pinLoop:
for _, pin := range m.all {
// Check for discontinued Hubs.
if m.intel != nil {
hubIntel, ok := m.intel.Hubs[pin.Hub.ID]
if ok && hubIntel.Discontinued {
toDelete = append(toDelete, pin.Hub.ID)
log.Infof("spn/navigator: deleting discontinued %s", pin.Hub)
continue pinLoop
}
}
// Check for obsoleted Hubs.
if pin.State.HasNoneOf(StateActive) && pin.Hub.Obsolete() {
toDelete = append(toDelete, pin.Hub.ID)
log.Infof("spn/navigator: deleting obsolete %s", pin.Hub)
}
// Delete hubs async, as deleting triggers a couple hooks that lock the map.
if len(toDelete) > 0 {
module.StartWorker("delete hubs", func(_ context.Context) error {
for _, idToDelete := range toDelete {
err := hub.RemoveHubAndMsgs(m.Name, idToDelete)
if err != nil {
log.Warningf("spn/navigator: failed to delete Hub %s: %s", idToDelete, err)
}
}
return nil
})
}
}
// Update StateActive.
m.updateActiveHubs()
// Update StateReachable.
return m.recalculateReachableHubs()
}
// AddBootstrapHubs adds the given bootstrap hubs to the map.
func (m *Map) AddBootstrapHubs(bootstrapTransports []string) error {
m.Lock()
defer m.Unlock()
return m.addBootstrapHubs(bootstrapTransports)
}
func (m *Map) addBootstrapHubs(bootstrapTransports []string) error {
var anyAdded bool
var lastErr error
var failed int
for _, bootstrapTransport := range bootstrapTransports {
err := m.addBootstrapHub(bootstrapTransport)
if err != nil {
log.Warningf("spn/navigator: failed to add bootstrap hub %q to map %s: %s", bootstrapTransport, m.Name, err)
lastErr = err
failed++
} else {
anyAdded = true
}
}
if lastErr != nil && !anyAdded {
return lastErr
}
return nil
}
func (m *Map) addBootstrapHub(bootstrapTransport string) error {
// Parse bootstrap hub.
transport, hubID, hubIP, err := hub.ParseBootstrapHub(bootstrapTransport)
if err != nil {
return fmt.Errorf("invalid bootstrap hub: %w", err)
}
// Check if hub already exists.
var h *hub.Hub
pin, ok := m.all[hubID]
if ok {
h = pin.Hub
} else {
h = &hub.Hub{
ID: hubID,
Map: m.Name,
Info: &hub.Announcement{
ID: hubID,
},
Status: &hub.Status{},
FirstSeen: time.Now(), // Do not garbage collect bootstrap hubs.
}
}
// Add IP if it does not yet exist.
if hubIP4 := hubIP.To4(); hubIP4 != nil {
if h.Info.IPv4 == nil {
h.Info.IPv4 = hubIP4
} else if !h.Info.IPv4.Equal(hubIP4) {
return fmt.Errorf("additional bootstrap entry with same ID but mismatching IP address: %s", hubIP)
}
} else {
if h.Info.IPv6 == nil {
h.Info.IPv6 = hubIP
} else if !h.Info.IPv6.Equal(hubIP) {
return fmt.Errorf("additional bootstrap entry with same ID but mismatching IP address: %s", hubIP)
}
}
// Add transport if it does not yet exist.
t := transport.String()
if !utils.StringInSlice(h.Info.Transports, t) {
h.Info.Transports = append(h.Info.Transports, t)
}
// Add/update to map for bootstrapping.
m.updateHub(h, false, false)
log.Infof("spn/navigator: added/updated bootstrap %s to map %s", h, m.Name)
return nil
}
// UpdateConfigQuickSettings updates config quick settings with available countries.
func (m *Map) UpdateConfigQuickSettings(ctx context.Context) error {
ctx, tracer := log.AddTracer(ctx)
tracer.Trace("navigator: updating SPN rules country quick settings")
defer tracer.Submit()
opts := m.DefaultOptions()
opts.Home = &HomeHubOptions{
Regard: StateTrusted,
}
opts.Destination = &DestinationHubOptions{
Regard: StateTrusted,
Disregard: StateIsHomeHub,
}
// Home Policy.
if err := m.updateQuickSettingExcludeCountryList(ctx, "spn/homePolicy", opts, HomeHub); err != nil {
return err
}
// Transit Policy.
if err := m.updateQuickSettingExcludeCountryList(ctx, profile.CfgOptionTransitHubPolicyKey, opts, TransitHub); err != nil {
return err
}
// Exit Policy.
if err := m.updateSelectRuleCountryList(ctx, profile.CfgOptionExitHubPolicyKey, opts, DestinationHub); err != nil {
return err
}
// DNS Exit Policy.
if err := m.updateSelectRuleCountryList(ctx, "spn/dnsExitPolicy", opts, DestinationHub); err != nil {
return err
}
// Trust Nodes.
if err := m.updateQuickSettingVerifiedOwnerList(ctx, "spn/trustNodes"); err != nil {
return err
}
tracer.Trace("navigator: finished updating SPN rules country quick settings")
return nil
}
func (m *Map) updateQuickSettingExcludeCountryList(ctx context.Context, configKey string, opts *Options, matchFor HubType) error {
// Get config option.
cfgOption, err := config.GetOption(configKey)
if err != nil {
return fmt.Errorf("failed to get config option %s: %w", configKey, err)
}
// Get list of countries for this config option.
countries := m.GetAvailableCountries(opts, matchFor)
// Convert to list.
countryList := make([]*geoip.CountryInfo, 0, len(countries))
for _, country := range countries {
countryList = append(countryList, country)
}
// Sort list.
slices.SortFunc[[]*geoip.CountryInfo, *geoip.CountryInfo](countryList, func(a, b *geoip.CountryInfo) int {
return strings.Compare(a.Name, b.Name)
})
// Compile list of quick settings.
quickSettings := make([]config.QuickSetting, 0, len(countries))
for _, country := range countryList {
quickSettings = append(quickSettings, config.QuickSetting{
Name: fmt.Sprintf("Exclude %s (%s)", country.Name, country.Code),
Value: []string{fmt.Sprintf("- %s", country.Code)},
Action: config.QuickMergeTop,
})
}
// Lock config option and set new quick settings.
cfgOption.Lock()
defer cfgOption.Unlock()
cfgOption.Annotations[config.QuickSettingsAnnotation] = quickSettings
log.Tracer(ctx).Debugf("navigator: updated %d countries in quick settings for %s", len(quickSettings), configKey)
return nil
}
type selectCountry struct {
config.QuickSetting
FlagID string
}
func (m *Map) updateSelectRuleCountryList(ctx context.Context, configKey string, opts *Options, matchFor HubType) error {
// Get config option.
cfgOption, err := config.GetOption(configKey)
if err != nil {
return fmt.Errorf("failed to get config option %s: %w", configKey, err)
}
// Get list of countries for this config option.
countries := m.GetAvailableCountries(opts, matchFor)
// Convert to list.
countryList := make([]*geoip.CountryInfo, 0, len(countries))
for _, country := range countries {
countryList = append(countryList, country)
}
// Sort list.
slices.SortFunc[[]*geoip.CountryInfo, *geoip.CountryInfo](countryList, func(a, b *geoip.CountryInfo) int {
return strings.Compare(a.Name, b.Name)
})
// Get continents from countries.
continents := make(map[string]*geoip.ContinentInfo)
for _, country := range countryList {
continents[country.Continent.Code] = &country.Continent
}
// Convert to list.
continentList := make([]*geoip.ContinentInfo, 0, len(continents))
for _, continent := range continents {
continentList = append(continentList, continent)
}
// Sort list.
slices.SortFunc[[]*geoip.ContinentInfo, *geoip.ContinentInfo](continentList, func(a, b *geoip.ContinentInfo) int {
return strings.Compare(a.Name, b.Name)
})
// Start compiling all options.
selections := make([]selectCountry, 0, len(continents)+len(countries)+2)
// Add EU as special region.
selections = append(selections, selectCountry{
QuickSetting: config.QuickSetting{
Name: "European Union",
Value: []string{"+ AT", "+ BE", "+ BG", "+ CY", "+ CZ", "+ DE", "+ DK", "+ EE", "+ ES", "+ FI", "+ FR", "+ GR", "+ HR", "+ HU", "+ IE", "+ IT", "+ LT", "+ LU", "+ LV", "+ MT", "+ NL", "+ PL", "+ PT", "+ RO", "+ SE", "+ SI", "+ SK", "- *"},
Action: config.QuickReplace,
},
FlagID: "EU",
})
selections = append(selections, selectCountry{
QuickSetting: config.QuickSetting{
Name: "US and Canada",
Value: []string{"+ US", "+ CA", "- *"},
Action: config.QuickReplace,
},
})
// Add countries to quick settings.
for _, country := range countryList {
selections = append(selections, selectCountry{
QuickSetting: config.QuickSetting{
Name: fmt.Sprintf("%s (%s)", country.Name, country.Code),
Value: []string{fmt.Sprintf("+ %s", country.Code), "- *"},
Action: config.QuickReplace,
},
FlagID: country.Code,
})
}
// Add continents to quick settings.
for _, continent := range continentList {
selections = append(selections, selectCountry{
QuickSetting: config.QuickSetting{
Name: fmt.Sprintf("%s (C:%s)", continent.Name, continent.Code),
Value: []string{fmt.Sprintf("+ C:%s", continent.Code), "- *"},
Action: config.QuickReplace,
},
})
}
// Lock config option and set new quick settings.
cfgOption.Lock()
defer cfgOption.Unlock()
cfgOption.Annotations[config.QuickSettingsAnnotation] = selections
log.Tracer(ctx).Debugf("navigator: updated %d countries in quick settings for %s", len(selections), configKey)
return nil
}
func (m *Map) updateQuickSettingVerifiedOwnerList(ctx context.Context, configKey string) error {
// Get config option.
cfgOption, err := config.GetOption(configKey)
if err != nil {
return fmt.Errorf("failed to get config option %s: %w", configKey, err)
}
pins := m.pinList(true)
verifiedOwners := make([]string, 0, len(pins)/5) // Capacity is an estimation.
for _, pin := range pins {
pin.Lock()
vo := pin.VerifiedOwner
pin.Unlock()
// Skip invalid/unneeded values.
switch vo {
case "", "Safing":
continue
}
// Add to list, if not yet in there.
if !slices.Contains[[]string, string](verifiedOwners, vo) {
verifiedOwners = append(verifiedOwners, vo)
}
}
// Sort list.
slices.Sort[[]string](verifiedOwners)
// Compile list of quick settings.
quickSettings := make([]config.QuickSetting, 0, len(verifiedOwners))
for _, vo := range verifiedOwners {
quickSettings = append(quickSettings, config.QuickSetting{
Name: fmt.Sprintf("Trust %s", vo),
Value: []string{vo},
Action: config.QuickMergeBottom,
})
}
// Lock config option and set new quick settings.
cfgOption.Lock()
defer cfgOption.Unlock()
cfgOption.Annotations[config.QuickSettingsAnnotation] = quickSettings
log.Tracer(ctx).Debugf("navigator: updated %d verified owners in quick settings for %s", len(quickSettings), configKey)
return nil
}