mirror of
https://github.com/safing/portmaster
synced 2026-04-28 11:30:37 +00:00
Implement initial proof-of-concept for split tunnel functionality on Windows, allowing applications to route traffic through a designated network interface while bypassing default system routing. Features: - Split tunnel module with TCP/UDP proxy infrastructure - Firewall integration with split tunnel verdict handling - SplitTunneling context attached to connections - Configuration options: enable toggle, interface selection, and policy rules - UI display of split tunnel connection details in connection info panel - Subsystem configuration for user-level access Windows-specific implementation: - Uses proxy-based interface routing on Windows - Automatic or manual interface detection and binding - Support for IPv4 and IPv6 traffic Note: Linux implementation is under development. SPN takes precedence over split tunnel when both are enabled, ensuring SPN connections bypass this feature.
582 lines
17 KiB
Go
582 lines
17 KiB
Go
package profile
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/tevino/abool"
|
|
|
|
"github.com/safing/portmaster/base/config"
|
|
"github.com/safing/portmaster/base/database/record"
|
|
"github.com/safing/portmaster/base/log"
|
|
"github.com/safing/portmaster/base/utils"
|
|
"github.com/safing/portmaster/service/intel/filterlists"
|
|
"github.com/safing/portmaster/service/profile/binmeta"
|
|
"github.com/safing/portmaster/service/profile/endpoints"
|
|
)
|
|
|
|
// ProfileSource is the source of the profile.
|
|
type ProfileSource string //nolint:golint
|
|
|
|
// Profile Sources.
|
|
const (
|
|
SourceLocal ProfileSource = "local" // local, editable
|
|
SourceSpecial ProfileSource = "special" // specials (read-only)
|
|
)
|
|
|
|
// Default Action IDs.
|
|
const (
|
|
DefaultActionNotSet uint8 = 0
|
|
DefaultActionBlock uint8 = 1
|
|
DefaultActionAsk uint8 = 2
|
|
DefaultActionPermit uint8 = 3
|
|
)
|
|
|
|
// Profile is used to predefine a security profile for applications.
|
|
type Profile struct { //nolint:maligned // not worth the effort
|
|
record.Base
|
|
sync.RWMutex
|
|
|
|
// ID is a unique identifier for the profile.
|
|
ID string // constant
|
|
// Source describes the source of the profile.
|
|
Source ProfileSource // constant
|
|
// Name is a human readable name of the profile. It
|
|
// defaults to the basename of the application.
|
|
Name string
|
|
// Description may hold an optional description of the
|
|
// profile or the purpose of the application.
|
|
Description string
|
|
// Warning may hold an optional warning about this application.
|
|
// It may be static or be added later on when the Portmaster detected an
|
|
// issue with the application.
|
|
Warning string
|
|
// WarningLastUpdated holds the timestamp when the Warning field was last
|
|
// updated.
|
|
WarningLastUpdated time.Time
|
|
// Homepage may refer to the website of the application
|
|
// vendor.
|
|
Homepage string
|
|
|
|
// Deprecated: Icon holds the icon of the application. The value
|
|
// may either be a filepath, a database key or a blob URL.
|
|
// See IconType for more information.
|
|
Icon string
|
|
// Deprecated: IconType describes the type of the Icon property.
|
|
IconType binmeta.IconType
|
|
// Icons holds a list of icons to represent the application.
|
|
Icons []binmeta.Icon
|
|
|
|
// Deprecated: LinkedPath used to point to the executables this
|
|
// profile was created for.
|
|
// Until removed, it will be added to the Fingerprints as an exact path match.
|
|
LinkedPath string // constant
|
|
// PresentationPath holds the path of an executable that should be used for
|
|
// get representative information from, like the name of the program or the icon.
|
|
// Is automatically removed when the path does not exist.
|
|
// Is automatically populated with the next match when empty.
|
|
PresentationPath string
|
|
// UsePresentationPath can be used to enable/disable fetching information
|
|
// from the executable at PresentationPath. In some cases, this is not
|
|
// desirable.
|
|
UsePresentationPath bool
|
|
// Fingerprints holds process matching information.
|
|
Fingerprints []Fingerprint
|
|
// Config holds profile specific setttings. It's a nested
|
|
// object with keys defining the settings database path. All keys
|
|
// until the actual settings value (which is everything that is not
|
|
// an object) need to be concatenated for the settings database
|
|
// path.
|
|
Config map[string]interface{}
|
|
|
|
// LastEdited holds the UTC timestamp in seconds when the profile was last
|
|
// edited by the user. This is not set automatically, but has to be manually
|
|
// set by the user interface.
|
|
LastEdited int64
|
|
// Created holds the UTC timestamp in seconds when the
|
|
// profile has been created.
|
|
Created int64
|
|
|
|
// Internal is set to true if the profile is attributed to a
|
|
// Portmaster internal process. Internal is set during profile
|
|
// creation and may be accessed without lock.
|
|
Internal bool
|
|
|
|
// layeredProfile is a link to the layered profile with this profile as the
|
|
// main profile.
|
|
// All processes with the same binary should share the same instance of the
|
|
// local profile and the associated layered profile.
|
|
layeredProfile *LayeredProfile
|
|
|
|
// Interpreted Data
|
|
configPerspective *config.Perspective
|
|
dataParsed bool
|
|
defaultAction uint8
|
|
endpoints endpoints.Endpoints
|
|
serviceEndpoints endpoints.Endpoints
|
|
filterListsSet bool
|
|
filterListIDs []string
|
|
spnUsagePolicy endpoints.Endpoints
|
|
spnTransitHubPolicy endpoints.Endpoints
|
|
spnExitHubPolicy endpoints.Endpoints
|
|
splitTunUsagePolicy endpoints.Endpoints
|
|
|
|
// Lifecycle Management
|
|
outdated *abool.AtomicBool
|
|
lastActive *int64
|
|
|
|
// savedInternally is set to true for profiles that are saved internally.
|
|
savedInternally bool
|
|
}
|
|
|
|
func (profile *Profile) prepProfile() {
|
|
// prepare configuration
|
|
profile.outdated = abool.New()
|
|
profile.lastActive = new(int64)
|
|
|
|
// Migration of LinkedPath to PresentationPath
|
|
if profile.PresentationPath == "" && profile.LinkedPath != "" {
|
|
profile.PresentationPath = profile.LinkedPath
|
|
}
|
|
}
|
|
|
|
func (profile *Profile) parseConfig() error {
|
|
// Check if already parsed.
|
|
if profile.dataParsed {
|
|
return nil
|
|
}
|
|
|
|
// Create new perspective and marked as parsed.
|
|
var err error
|
|
profile.configPerspective, err = config.NewPerspective(profile.Config)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create config perspective: %w", err)
|
|
}
|
|
profile.dataParsed = true
|
|
|
|
var lastErr error
|
|
action, ok := profile.configPerspective.GetAsString(CfgOptionDefaultActionKey)
|
|
profile.defaultAction = DefaultActionNotSet
|
|
if ok {
|
|
switch action {
|
|
case DefaultActionPermitValue:
|
|
profile.defaultAction = DefaultActionPermit
|
|
case DefaultActionAskValue:
|
|
profile.defaultAction = DefaultActionAsk
|
|
case DefaultActionBlockValue:
|
|
profile.defaultAction = DefaultActionBlock
|
|
default:
|
|
lastErr = fmt.Errorf(`default action "%s" invalid`, action)
|
|
}
|
|
}
|
|
|
|
list, ok := profile.configPerspective.GetAsStringArray(CfgOptionEndpointsKey)
|
|
profile.endpoints = nil
|
|
if ok {
|
|
profile.endpoints, err = endpoints.ParseEndpoints(list)
|
|
if err != nil {
|
|
lastErr = err
|
|
}
|
|
}
|
|
|
|
list, ok = profile.configPerspective.GetAsStringArray(CfgOptionServiceEndpointsKey)
|
|
profile.serviceEndpoints = nil
|
|
if ok {
|
|
profile.serviceEndpoints, err = endpoints.ParseEndpoints(list)
|
|
if err != nil {
|
|
lastErr = err
|
|
}
|
|
}
|
|
|
|
list, ok = profile.configPerspective.GetAsStringArray(CfgOptionFilterListsKey)
|
|
profile.filterListsSet = false
|
|
if ok {
|
|
profile.filterListIDs, err = filterlists.ResolveListIDs(list)
|
|
if err != nil {
|
|
log.Warningf("profiles: failed to resolve filter list IDs: %s", err)
|
|
} else {
|
|
profile.filterListsSet = true
|
|
}
|
|
}
|
|
|
|
list, ok = profile.configPerspective.GetAsStringArray(CfgOptionSplitTunUsagePolicyKey)
|
|
profile.splitTunUsagePolicy = nil
|
|
if ok {
|
|
profile.splitTunUsagePolicy, err = endpoints.ParseEndpoints(list)
|
|
if err != nil {
|
|
lastErr = err
|
|
}
|
|
}
|
|
|
|
list, ok = profile.configPerspective.GetAsStringArray(CfgOptionSPNUsagePolicyKey)
|
|
profile.spnUsagePolicy = nil
|
|
if ok {
|
|
profile.spnUsagePolicy, err = endpoints.ParseEndpoints(list)
|
|
if err != nil {
|
|
lastErr = err
|
|
}
|
|
}
|
|
|
|
list, ok = profile.configPerspective.GetAsStringArray(CfgOptionTransitHubPolicyKey)
|
|
profile.spnTransitHubPolicy = nil
|
|
if ok {
|
|
profile.spnTransitHubPolicy, err = endpoints.ParseEndpoints(list)
|
|
if err != nil {
|
|
lastErr = err
|
|
}
|
|
}
|
|
|
|
list, ok = profile.configPerspective.GetAsStringArray(CfgOptionExitHubPolicyKey)
|
|
profile.spnExitHubPolicy = nil
|
|
if ok {
|
|
profile.spnExitHubPolicy, err = endpoints.ParseEndpoints(list)
|
|
if err != nil {
|
|
lastErr = err
|
|
}
|
|
}
|
|
|
|
return lastErr
|
|
}
|
|
|
|
// New returns a new Profile.
|
|
// Optionally, you may supply custom configuration in the flat (key=value) form.
|
|
func New(profile *Profile) *Profile {
|
|
// Create profile if none is given.
|
|
if profile == nil {
|
|
profile = &Profile{}
|
|
}
|
|
|
|
// Set default and internal values.
|
|
profile.Created = time.Now().Unix()
|
|
profile.savedInternally = true
|
|
|
|
// Expand any given configuration.
|
|
if profile.Config != nil {
|
|
profile.Config = config.Expand(profile.Config)
|
|
} else {
|
|
profile.Config = make(map[string]interface{})
|
|
}
|
|
|
|
// Generate ID if none is given.
|
|
if profile.ID == "" {
|
|
if len(profile.Fingerprints) > 0 {
|
|
// Derive from fingerprints.
|
|
profile.ID = DeriveProfileID(profile.Fingerprints)
|
|
} else {
|
|
// Generate random ID as fallback.
|
|
log.Warningf("profile: creating new profile without fingerprints to derive ID from")
|
|
profile.ID = utils.RandomUUID("").String()
|
|
}
|
|
}
|
|
|
|
// Make key from ID and source.
|
|
profile.makeKey()
|
|
|
|
// Prepare and parse initial profile config.
|
|
profile.prepProfile()
|
|
if err := profile.parseConfig(); err != nil {
|
|
log.Errorf("profile: failed to parse new profile: %s", err)
|
|
}
|
|
|
|
return profile
|
|
}
|
|
|
|
// ScopedID returns the scoped ID (Source + ID) of the profile.
|
|
func (profile *Profile) ScopedID() string {
|
|
return MakeScopedID(profile.Source, profile.ID)
|
|
}
|
|
|
|
// makeKey derives and sets the record Key from the profile attributes.
|
|
func (profile *Profile) makeKey() {
|
|
profile.SetKey(MakeProfileKey(profile.Source, profile.ID))
|
|
}
|
|
|
|
// Save saves the profile to the database.
|
|
func (profile *Profile) Save() error {
|
|
if profile.ID == "" {
|
|
return errors.New("profile: tried to save profile without ID")
|
|
}
|
|
if profile.Source == "" {
|
|
return fmt.Errorf("profile: profile %s does not specify a source", profile.ID)
|
|
}
|
|
|
|
return profileDB.Put(profile)
|
|
}
|
|
|
|
// delete deletes the profile from the database.
|
|
func (profile *Profile) delete() error {
|
|
// Check if a key is set.
|
|
if !profile.KeyIsSet() {
|
|
return errors.New("key is not set")
|
|
}
|
|
|
|
// Delete from database.
|
|
profile.Meta().Delete()
|
|
err := profileDB.Put(profile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Post handling is done by the profile update feed.
|
|
return nil
|
|
}
|
|
|
|
// MarkStillActive marks the profile as still active.
|
|
func (profile *Profile) MarkStillActive() {
|
|
atomic.StoreInt64(profile.lastActive, time.Now().Unix())
|
|
}
|
|
|
|
// LastActive returns the unix timestamp when the profile was last marked as
|
|
// still active.
|
|
func (profile *Profile) LastActive() int64 {
|
|
return atomic.LoadInt64(profile.lastActive)
|
|
}
|
|
|
|
// String returns a string representation of the Profile.
|
|
func (profile *Profile) String() string {
|
|
return fmt.Sprintf("<%s %s/%s>", profile.Name, profile.Source, profile.ID)
|
|
}
|
|
|
|
// IsOutdated returns whether the this instance of the profile is marked as outdated.
|
|
func (profile *Profile) IsOutdated() bool {
|
|
return profile.outdated.IsSet()
|
|
}
|
|
|
|
// GetEndpoints returns the endpoint list of the profile. This functions
|
|
// requires the profile to be read locked.
|
|
func (profile *Profile) GetEndpoints() endpoints.Endpoints {
|
|
return profile.endpoints
|
|
}
|
|
|
|
// GetServiceEndpoints returns the service endpoint list of the profile. This
|
|
// functions requires the profile to be read locked.
|
|
func (profile *Profile) GetServiceEndpoints() endpoints.Endpoints {
|
|
return profile.serviceEndpoints
|
|
}
|
|
|
|
// AddEndpoint adds an endpoint to the endpoint list, saves the profile and reloads the configuration.
|
|
func (profile *Profile) AddEndpoint(newEntry string) {
|
|
profile.addEndpointEntry(CfgOptionEndpointsKey, newEntry)
|
|
}
|
|
|
|
// AddServiceEndpoint adds a service endpoint to the endpoint list, saves the profile and reloads the configuration.
|
|
func (profile *Profile) AddServiceEndpoint(newEntry string) {
|
|
profile.addEndpointEntry(CfgOptionServiceEndpointsKey, newEntry)
|
|
}
|
|
|
|
func (profile *Profile) addEndpointEntry(cfgKey, newEntry string) {
|
|
changed := false
|
|
|
|
// When finished, save the profile.
|
|
defer func() {
|
|
if !changed {
|
|
return
|
|
}
|
|
|
|
err := profile.Save()
|
|
if err != nil {
|
|
log.Warningf("profile: failed to save profile %s after add an endpoint rule: %s", profile.ScopedID(), err)
|
|
}
|
|
}()
|
|
|
|
// Lock the profile for editing.
|
|
profile.Lock()
|
|
defer profile.Unlock()
|
|
|
|
// Get the endpoint list configuration value and add the new entry.
|
|
endpointList, ok := profile.configPerspective.GetAsStringArray(cfgKey)
|
|
if ok {
|
|
// A list already exists, check for duplicates within the same prefix.
|
|
newEntryPrefix := strings.Split(newEntry, " ")[0] + " "
|
|
for _, entry := range endpointList {
|
|
if !strings.HasPrefix(entry, newEntryPrefix) {
|
|
// We found an entry with a different prefix than the new entry.
|
|
// Beyond this entry we cannot possibly know if identical entries will
|
|
// match, so we will have to add the new entry no matter what the rest
|
|
// of the list has.
|
|
break
|
|
}
|
|
|
|
if entry == newEntry {
|
|
// An identical entry is already in the list, abort.
|
|
log.Debugf("profile: ignoring new endpoint rule for %s, as identical is already present: %s", profile, newEntry)
|
|
return
|
|
}
|
|
}
|
|
endpointList = append([]string{newEntry}, endpointList...)
|
|
} else {
|
|
endpointList = []string{newEntry}
|
|
}
|
|
|
|
// Save new value back to profile.
|
|
config.PutValueIntoHierarchicalConfig(profile.Config, cfgKey, endpointList)
|
|
changed = true
|
|
|
|
// Reload the profile manually in order to parse the newly added entry.
|
|
profile.dataParsed = false
|
|
err := profile.parseConfig()
|
|
if err != nil {
|
|
log.Errorf("profile: failed to parse %s config after adding endpoint: %s", profile, err)
|
|
}
|
|
}
|
|
|
|
// LayeredProfile returns the layered profile associated with this profile.
|
|
func (profile *Profile) LayeredProfile() *LayeredProfile {
|
|
profile.Lock()
|
|
defer profile.Unlock()
|
|
|
|
return profile.layeredProfile
|
|
}
|
|
|
|
// EnsureProfile ensures that the given record is a *Profile, and returns it.
|
|
func EnsureProfile(r record.Record) (*Profile, error) {
|
|
// unwrap
|
|
if r.IsWrapped() {
|
|
// only allocate a new struct, if we need it
|
|
newProfile := &Profile{}
|
|
err := record.Unwrap(r, newProfile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return newProfile, nil
|
|
}
|
|
|
|
// or adjust type
|
|
newProfile, ok := r.(*Profile)
|
|
if !ok {
|
|
return nil, fmt.Errorf("record not of type *Profile, but %T", r)
|
|
}
|
|
return newProfile, nil
|
|
}
|
|
|
|
// updateMetadata updates meta data fields on the profile and returns whether
|
|
// the profile was changed.
|
|
func (profile *Profile) updateMetadata(binaryPath string) (changed bool) {
|
|
// Check if this is a local profile, else warn and return.
|
|
if profile.Source != SourceLocal {
|
|
log.Warningf("tried to update metadata for non-local profile %s", profile.ScopedID())
|
|
return false
|
|
}
|
|
|
|
// Set PresentationPath if unset.
|
|
if profile.PresentationPath == "" && binaryPath != "" {
|
|
profile.PresentationPath = binaryPath
|
|
changed = true
|
|
}
|
|
|
|
// Migrate LinkedPath to PresentationPath.
|
|
// TODO: Remove in v1.5
|
|
if profile.PresentationPath == "" && profile.LinkedPath != "" {
|
|
profile.PresentationPath = profile.LinkedPath
|
|
changed = true
|
|
}
|
|
|
|
// Set Name if unset.
|
|
if profile.Name == "" && profile.PresentationPath != "" {
|
|
// Generate a default profile name from path.
|
|
profile.Name = binmeta.GenerateBinaryNameFromPath(profile.PresentationPath)
|
|
changed = true
|
|
}
|
|
|
|
// Migrate to Fingerprints.
|
|
// TODO: Remove in v1.5
|
|
if len(profile.Fingerprints) == 0 && profile.LinkedPath != "" {
|
|
profile.Fingerprints = []Fingerprint{
|
|
{
|
|
Type: FingerprintTypePathID,
|
|
Operation: FingerprintOperationEqualsID,
|
|
Value: profile.LinkedPath,
|
|
},
|
|
}
|
|
changed = true
|
|
}
|
|
|
|
// UI Backward Compatibility:
|
|
// Fill LinkedPath with PresentationPath
|
|
// TODO: Remove in v1.1
|
|
if profile.LinkedPath == "" && profile.PresentationPath != "" {
|
|
profile.LinkedPath = profile.PresentationPath
|
|
changed = true
|
|
}
|
|
|
|
return changed
|
|
}
|
|
|
|
// updateMetadataFromSystem updates the profile metadata with data from the
|
|
// operating system and saves it afterwards.
|
|
func (profile *Profile) updateMetadataFromSystem(ctx context.Context, md MatchingData) error {
|
|
var changed bool
|
|
|
|
// This function is only valid for local profiles.
|
|
if profile.Source != SourceLocal || profile.PresentationPath == "" {
|
|
return fmt.Errorf("tried to update metadata for non-local or non-path profile %s", profile.ScopedID())
|
|
}
|
|
|
|
// Get home from ENV.
|
|
var home string
|
|
if env := md.Env(); env != nil {
|
|
home = env["HOME"]
|
|
}
|
|
|
|
// Get binary icon and name.
|
|
newIcon, newName, err := binmeta.GetIconAndName(ctx, profile.PresentationPath, home)
|
|
switch {
|
|
case err == nil:
|
|
// Continue
|
|
case errors.Is(err, binmeta.ErrIconIgnored):
|
|
newIcon = nil
|
|
// Continue
|
|
default:
|
|
log.Warningf("profile: failed to get binary icon/name for %s: %s", profile.PresentationPath, err)
|
|
}
|
|
|
|
// Apply new data to profile.
|
|
func() {
|
|
// Lock profile for applying metadata.
|
|
profile.Lock()
|
|
defer profile.Unlock()
|
|
|
|
// Apply new name if it changed.
|
|
if newName != "" && profile.Name != newName {
|
|
profile.Name = newName
|
|
changed = true
|
|
}
|
|
|
|
// Apply new icon if found.
|
|
if newIcon != nil && !profile.iconExists(newIcon) {
|
|
if len(profile.Icons) == 0 {
|
|
profile.Icons = []binmeta.Icon{*newIcon}
|
|
} else {
|
|
profile.Icons = append(profile.Icons, *newIcon)
|
|
profile.Icons = binmeta.SortAndCompactIcons(profile.Icons)
|
|
}
|
|
changed = true
|
|
}
|
|
}()
|
|
|
|
// If anything changed, save the profile.
|
|
// profile.Lock must not be held!
|
|
if changed {
|
|
err := profile.Save()
|
|
if err != nil {
|
|
log.Warningf("profile: failed to save %s after metadata update: %s", profile.ScopedID(), err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Checks if the given icon already assigned to the profile.
|
|
func (profile *Profile) iconExists(newIcon *binmeta.Icon) bool {
|
|
for _, icon := range profile.Icons {
|
|
if icon.Value == newIcon.Value && icon.Type == newIcon.Type && icon.Source == newIcon.Source {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|