package profile import ( "context" "errors" "fmt" "path/filepath" "strings" "sync" "sync/atomic" "time" "github.com/tevino/abool" "github.com/safing/portbase/config" "github.com/safing/portbase/database/record" "github.com/safing/portbase/log" "github.com/safing/portbase/utils" "github.com/safing/portbase/utils/osdetail" "github.com/safing/portmaster/intel/filterlists" "github.com/safing/portmaster/profile/endpoints" ) // profileSource is the source of the profile. type profileSource string // Profile Sources. const ( SourceLocal profileSource = "local" // local, editable SourceSpecial profileSource = "special" // specials (read-only) SourceNetwork profileSource = "network" SourceCommunity profileSource = "community" SourceEnterprise profileSource = "enterprise" ) // Default Action IDs. const ( DefaultActionNotSet uint8 = 0 DefaultActionBlock uint8 = 1 DefaultActionAsk uint8 = 2 DefaultActionPermit uint8 = 3 ) // iconType describes the type of the Icon property // of a profile. type iconType string // Supported icon types. const ( IconTypeFile iconType = "path" IconTypeDatabase iconType = "database" IconTypeBlob iconType = "blob" ) // 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 the the website of the application // vendor. Homepage string // 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 // IconType describes the type of the Icon property. IconType iconType // LinkedPath is a filesystem path to the executable this // profile was created for. LinkedPath string // constant // LinkedProfiles is a list of other profiles LinkedProfiles []string // SecurityLevel is the mininum security level to apply to // connections made with this profile. // Note(ppacher): we may deprecate this one as it can easily // be "simulated" by adjusting the settings // directly. SecurityLevel uint8 // 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 spnExitHubPolicy 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) } 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 "permit": profile.defaultAction = DefaultActionPermit case "ask": profile.defaultAction = DefaultActionAsk case "block": 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 { lastErr = err } else { profile.filterListsSet = true } } 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(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( source profileSource, id string, linkedPath string, customConfig map[string]interface{}, ) *Profile { if customConfig != nil { customConfig = config.Expand(customConfig) } else { customConfig = make(map[string]interface{}) } profile := &Profile{ ID: id, Source: source, LinkedPath: linkedPath, Created: time.Now().Unix(), Config: customConfig, savedInternally: true, } // Generate random ID if none is given. if id == "" { 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) } // 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.addEndpointyEntry(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.addEndpointyEntry(CfgOptionServiceEndpointsKey, newEntry) } func (profile *Profile) addEndpointyEntry(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: ingoring 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. If there is data that needs to be fetched from the // operating system, it will start an async worker to fetch that data and save // the profile afterwards. 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 } profile.Lock() defer profile.Unlock() // Update special profile and return if it was one. if ok, changed := updateSpecialProfileMetadata(profile, binaryPath); ok { return changed } var needsUpdateFromSystem bool // Check profile name. filename := filepath.Base(profile.LinkedPath) // Update profile name if it is empty or equals the filename, which is the // case for older profiles. if strings.TrimSpace(profile.Name) == "" || profile.Name == filename { // Generate a default profile name if does not exist. profile.Name = osdetail.GenerateBinaryNameFromPath(profile.LinkedPath) if profile.Name == filename { // TODO: Theoretically, the generated name could be identical to the // filename. // As a quick fix, append a space to the name. profile.Name += " " } changed = true needsUpdateFromSystem = true } // If needed, get more/better data from the operating system. if needsUpdateFromSystem { module.StartWorker("get profile metadata", profile.updateMetadataFromSystem) } return changed } func (profile *Profile) copyMetadataFrom(otherProfile *Profile) (changed bool) { if profile.Name != otherProfile.Name { profile.Name = otherProfile.Name changed = true } if profile.Description != otherProfile.Description { profile.Description = otherProfile.Description changed = true } if profile.Homepage != otherProfile.Homepage { profile.Homepage = otherProfile.Homepage changed = true } if profile.Icon != otherProfile.Icon { profile.Icon = otherProfile.Icon changed = true } if profile.IconType != otherProfile.IconType { profile.IconType = otherProfile.IconType changed = true } return } // updateMetadataFromSystem updates the profile metadata with data from the // operating system and saves it afterwards. func (profile *Profile) updateMetadataFromSystem(ctx context.Context) error { // This function is only valid for local profiles. if profile.Source != SourceLocal || profile.LinkedPath == "" { return fmt.Errorf("tried to update metadata for non-local / non-linked profile %s", profile.ScopedID()) } // Save the profile when finished, if needed. save := false defer func() { if save { err := profile.Save() if err != nil { log.Warningf("profile: failed to save %s after metadata update: %s", profile.ScopedID(), err) } } }() // Get binary name from linked path. newName, err := osdetail.GetBinaryNameFromSystem(profile.LinkedPath) if err != nil { switch { case errors.Is(err, osdetail.ErrNotSupported): case errors.Is(err, osdetail.ErrNotFound): case errors.Is(err, osdetail.ErrEmptyOutput): default: log.Warningf("profile: error while getting binary name for %s: %s", profile.LinkedPath, err) } return nil } // Check if the new name is valid. if strings.TrimSpace(newName) == "" { return nil } // Get filename of linked path for comparison. filename := filepath.Base(profile.LinkedPath) // TODO: Theoretically, the generated name from the system could be identical // to the filename. This would mean that the worker is triggered every time // the profile is freshly loaded. if newName == filename { // As a quick fix, append a space to the name. newName += " " } // Lock profile for applying metadata. profile.Lock() defer profile.Unlock() // Apply new name if it changed. if profile.Name != newName { profile.Name = newName save = true } return nil }