safing-portmaster/spn/navigator/api_route.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

395 lines
12 KiB
Go

package navigator
import (
"bytes"
"errors"
"fmt"
mrand "math/rand"
"net"
"net/http"
"strings"
"text/tabwriter"
"time"
"github.com/safing/portmaster/base/api"
"github.com/safing/portmaster/base/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,
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, " ")
}