safing-portmaster/spn/navigator/optimize.go
Daniel Hååvi 80664d1a27
Restructure modules ()
* Move portbase into monorepo

* Add new simple module mgr

* [WIP] Switch to new simple module mgr

* Add StateMgr and more worker variants

* [WIP] Switch more modules

* [WIP] Switch more modules

* [WIP] swtich more modules

* [WIP] switch all SPN modules

* [WIP] switch all service modules

* [WIP] Convert all workers to the new module system

* [WIP] add new task system to module manager

* [WIP] Add second take for scheduling workers

* [WIP] Add FIXME for bugs in new scheduler

* [WIP] Add minor improvements to scheduler

* [WIP] Add new worker scheduler

* [WIP] Fix more bug related to new module system

* [WIP] Fix start handing of the new module system

* [WIP] Improve startup process

* [WIP] Fix minor issues

* [WIP] Fix missing subsystem in settings

* [WIP] Initialize managers in constructor

* [WIP] Move module event initialization to constrictors

* [WIP] Fix setting for enabling and disabling the SPN module

* [WIP] Move API registeration into module construction

* [WIP] Update states mgr for all modules

* [WIP] Add CmdLine operation support

* Add state helper methods to module group and instance

* Add notification and module status handling to status package

* Fix starting issues

* Remove pilot widget and update security lock to new status data

* Remove debug logs

* Improve http server shutdown

* Add workaround for cleanly shutting down firewall+netquery

* Improve logging

* Add syncing states with notifications for new module system

* Improve starting, stopping, shutdown; resolve FIXMEs/TODOs

* [WIP] Fix most unit tests

* Review new module system and fix minor issues

* Push shutdown and restart events again via API

* Set sleep mode via interface

* Update example/template module

* [WIP] Fix spn/cabin unit test

* Remove deprecated UI elements

* Make log output more similar for the logging transition phase

* Switch spn hub and observer cmds to new module system

* Fix log sources

* Make worker mgr less error prone

* Fix tests and minor issues

* Fix observation hub

* Improve shutdown and restart handling

* Split up big connection.go source file

* Move varint and dsd packages to structures repo

* Improve expansion test

* Fix linter warnings

* Fix interception module on windows

* Fix linter errors

---------

Co-authored-by: Vladimir Stoilov <vladimir@safing.io>
2024-08-09 18:15:48 +03:00

388 lines
11 KiB
Go

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 range max {
// 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])
}
}