diff --git a/profile/active.go b/profile/active.go index 94c86603..6de5041d 100644 --- a/profile/active.go +++ b/profile/active.go @@ -38,21 +38,6 @@ func getAllActiveProfiles() []*Profile { return result } -// findActiveProfile searched for an active local profile using the linked path. -func findActiveProfile(linkedPath string) *Profile { - activeProfilesLock.RLock() - defer activeProfilesLock.RUnlock() - - for _, activeProfile := range activeProfiles { - if activeProfile.LinkedPath == linkedPath { - activeProfile.MarkStillActive() - return activeProfile - } - } - - return nil -} - // addActiveProfile registers a active profile. func addActiveProfile(profile *Profile) { activeProfilesLock.Lock() diff --git a/profile/config-update.go b/profile/config-update.go index f8979d3d..6f249548 100644 --- a/profile/config-update.go +++ b/profile/config-update.go @@ -6,7 +6,6 @@ import ( "sync" "time" - "github.com/safing/portbase/config" "github.com/safing/portbase/modules" "github.com/safing/portmaster/intel/filterlists" "github.com/safing/portmaster/profile/endpoints" @@ -91,11 +90,7 @@ func updateGlobalConfigProfile(ctx context.Context, task *modules.Task) error { lastErr = err } - // build global profile for reference - profile := New(SourceSpecial, "global-config", "", nil) - profile.Name = "Global Configuration" - profile.Internal = true - + // Build config. newConfig := make(map[string]interface{}) // fill profile config options for key, value := range cfgStringOptions { @@ -111,8 +106,14 @@ func updateGlobalConfigProfile(ctx context.Context, task *modules.Task) error { newConfig[key] = value() } - // expand and assign - profile.Config = config.Expand(newConfig) + // Build global profile for reference. + profile := New(&Profile{ + ID: "global-config", + Source: SourceSpecial, + Name: "Global Configuration", + Config: newConfig, + Internal: true, + }) // save profile err = profile.Save() diff --git a/profile/database.go b/profile/database.go index 2438d667..c59235fd 100644 --- a/profile/database.go +++ b/profile/database.go @@ -65,41 +65,19 @@ func startProfileUpdateChecker() error { continue profileFeed } - // If the record is being deleted, reset the profile. - // create an empty profile instead. - if r.Meta().IsDeleted() { - newProfile, err := GetProfile( - activeProfile.Source, - activeProfile.ID, - activeProfile.LinkedPath, - true, - ) - if err != nil { - log.Errorf("profile: failed to create new profile after reset: %s", err) - } else { - // Copy metadata from the old profile. - newProfile.copyMetadataFrom(activeProfile) - // Save the new profile. - err = newProfile.Save() - if err != nil { - log.Errorf("profile: failed to save new profile after reset: %s", err) - } - } - - // If the new profile was successfully created, update layered profile. - activeProfile.outdated.Set() - if err == nil { - newProfile.layeredProfile.Update() - } - module.TriggerEvent(profileConfigChange, nil) - } - // Always increase the revision counter of the layer profile. // This marks previous connections in the UI as decided with outdated settings. if activeProfile.layeredProfile != nil { activeProfile.layeredProfile.increaseRevisionCounter(true) } + // Always mark as outdated if the record is being deleted. + if r.Meta().IsDeleted() { + activeProfile.outdated.Set() + module.TriggerEvent(profileConfigChange, nil) + continue + } + // If the profile is saved externally (eg. via the API), have the // next one to use it reload the profile from the database. receivedProfile, err := EnsureProfile(r) diff --git a/profile/fingerprint.go b/profile/fingerprint.go new file mode 100644 index 00000000..1fb44fbf --- /dev/null +++ b/profile/fingerprint.go @@ -0,0 +1,323 @@ +package profile + +import ( + "fmt" + "regexp" + "strings" +) + +// # Matching and Scores +// +// There are three levels: +// +// 1. Type: What matched? +// 1. Tag: 40.000 points +// 2. Env: 30.000 points +// 3. MatchingPath: 20.000 points +// 4. Path: 10.000 points +// 2. Operation: How was it mached? +// 1. Equals: 3.000 points +// 2. Prefix: 2.000 points +// 3. Regex: 1.000 points +// 3. How "strong" was the match? +// 1. Equals: Length of path (irrelevant) +// 2. Prefix: Length of prefix +// 3. Regex: 0 (we are not suicidal) + +// ms-store:Microsoft.One.Note + +// Path Match /path/to/file +// Tag MS-Store Match value +// Env Regex Key Value + +// Fingerprint Type IDs. +const ( + FingerprintTypeTagID = "tag" + FingerprintTypeEnvID = "env" + FingerprintTypePathID = "path" // Matches both MatchingPath and Path. + + FingerprintOperationEqualsID = "equals" + FingerprintOperationPrefixID = "prefix" + FingerprintOperationRegexID = "regex" + + tagMatchBaseScore = 40_000 + envMatchBaseScore = 30_000 + matchingPathMatchBaseScore = 20_000 + pathMatchBaseScore = 10_000 + + fingerprintEqualsBaseScore = 3_000 + fingerprintPrefixBaseScore = 2_000 + fingerprintRegexBaseScore = 1_000 + + maxMatchStrength = 499 +) + +type ( + // Fingerprint defines a way of matching a process. + // The Key is only valid - but required - for some types. + Fingerprint struct { + Type string + Key string // Key must always fully match. + Operation string + Value string + } + + // Tag represents a simple key/value kind of tag used in process metadata + // and fingerprints. + Tag struct { + Key string + Value string + } + + // MatchingData is an interface to fetching data in the matching process. + MatchingData interface { + Tags() []Tag + Env() map[string]string + Path() string + MatchingPath() string + } + + matchingFingerprint interface { + MatchesKey(key string) bool + Match(value string) (score int) + } +) + +// MatchesKey returns whether the optional fingerprint key (for some types +// only) matches the given key. +func (fp Fingerprint) MatchesKey(key string) bool { + return key == fp.Key +} + +// KeyInTags checks is the given key is in the tags. +func KeyInTags(tags []Tag, key string) bool { + for _, tag := range tags { + if key == tag.Key { + return true + } + } + return false +} + +// KeyAndValueInTags checks is the given key/value pair is in the tags. +func KeyAndValueInTags(tags []Tag, key, value string) bool { + for _, tag := range tags { + if key == tag.Key && value == tag.Value { + return true + } + } + return false +} + +type fingerprintEquals struct { + Fingerprint +} + +func (fp fingerprintEquals) Match(value string) (score int) { + if value == fp.Value { + return fingerprintEqualsBaseScore + checkMatchStrength(len(fp.Value)) + } + return 0 +} + +type fingerprintPrefix struct { + Fingerprint +} + +func (fp fingerprintPrefix) Match(value string) (score int) { + if strings.HasPrefix(value, fp.Value) { + return fingerprintPrefixBaseScore + checkMatchStrength(len(fp.Value)) + } + return 0 +} + +type fingerprintRegex struct { + Fingerprint + regex *regexp.Regexp +} + +func (fp fingerprintRegex) Match(value string) (score int) { + if fp.regex.MatchString(value) { + // Do not return any deviation from the base score. + // Trying to assign different scores to regex probably won't turn out to + // be a good idea. + return fingerprintRegexBaseScore + } + return 0 +} + +type parsedFingerprints struct { + tagPrints []matchingFingerprint + envPrints []matchingFingerprint + pathPrints []matchingFingerprint +} + +func parseFingerprints(raw []Fingerprint, deprecatedLinkedPath string) (parsed *parsedFingerprints, firstErr error) { + parsed = &parsedFingerprints{} + + // Add deprecated linked path to fingerprints. + if deprecatedLinkedPath != "" { + parsed.pathPrints = append(parsed.pathPrints, &fingerprintEquals{ + Fingerprint: Fingerprint{ + Type: FingerprintTypePathID, + Operation: FingerprintOperationEqualsID, + Value: deprecatedLinkedPath, + }, + }) + } + + // Parse all fingerprints. + // Do not fail when one fails, instead return the first encountered error. + for _, entry := range raw { + // Check type and required key. + switch entry.Type { + case FingerprintTypeTagID, FingerprintTypeEnvID: + if entry.Key == "" { + if firstErr == nil { + firstErr = fmt.Errorf("%s fingerprint is missing key", entry.Type) + } + continue + } + case FingerprintTypePathID: + // Don't need a key. + default: + // Unknown type. + if firstErr == nil { + firstErr = fmt.Errorf("unknown fingerprint type: %q", entry.Type) + } + continue + } + + // Create and/or collect operation match functions. + switch entry.Operation { + case FingerprintOperationEqualsID: + parsed.addMatchingFingerprint(entry, fingerprintEquals{entry}) + + case FingerprintOperationPrefixID: + parsed.addMatchingFingerprint(entry, fingerprintPrefix{entry}) + + case FingerprintOperationRegexID: + regex, err := regexp.Compile(entry.Value) + if err != nil { + if firstErr == nil { + firstErr = fmt.Errorf("failed to compile regex fingerprint: %s", entry.Value) + } + } else { + parsed.addMatchingFingerprint(entry, fingerprintRegex{ + Fingerprint: entry, + regex: regex, + }) + } + + default: + if firstErr == nil { + firstErr = fmt.Errorf("unknown fingerprint operation: %q", entry.Type) + } + } + } + + return parsed, firstErr +} + +func (parsed *parsedFingerprints) addMatchingFingerprint(fp Fingerprint, matchingPrint matchingFingerprint) { + switch fp.Type { + case FingerprintTypeTagID: + parsed.tagPrints = append(parsed.tagPrints, matchingPrint) + case FingerprintTypeEnvID: + parsed.envPrints = append(parsed.envPrints, matchingPrint) + case FingerprintTypePathID: + parsed.pathPrints = append(parsed.pathPrints, matchingPrint) + default: + // This should never happen, as the types are checked already. + panic(fmt.Sprintf("unknown fingerprint type: %q", fp.Type)) + } +} + +// MatchFingerprints returns the highest matching score of the given +// fingerprints and matching data. +func MatchFingerprints(prints *parsedFingerprints, md MatchingData) (highestScore int) { + // Check tags. + for _, tagPrint := range prints.tagPrints { + for _, tag := range md.Tags() { + // Check if tag key matches. + if !tagPrint.MatchesKey(tag.Key) { + continue + } + + // Try matching the tag value. + score := tagPrint.Match(tag.Value) + if score > highestScore { + highestScore = score + } + } + } + // If something matched, add base score and return. + if highestScore > 0 { + return tagMatchBaseScore + highestScore + } + + // Check env. + for _, envPrint := range prints.envPrints { + for key, value := range md.Env() { + // Check if env key matches. + if !envPrint.MatchesKey(key) { + continue + } + + // Try matching the env value. + score := envPrint.Match(value) + if score > highestScore { + highestScore = score + } + } + } + // If something matched, add base score and return. + if highestScore > 0 { + return envMatchBaseScore + highestScore + } + + // Check matching path. + matchingPath := md.MatchingPath() + if matchingPath != "" { + for _, pathPrint := range prints.pathPrints { + // Try matching the path value. + score := pathPrint.Match(matchingPath) + if score > highestScore { + highestScore = score + } + } + // If something matched, add base score and return. + if highestScore > 0 { + return matchingPathMatchBaseScore + highestScore + } + } + + // Check path. + path := md.Path() + if path != "" { + for _, pathPrint := range prints.pathPrints { + // Try matching the path value. + score := pathPrint.Match(path) + if score > highestScore { + highestScore = score + } + } + // If something matched, add base score and return. + if highestScore > 0 { + return pathMatchBaseScore + highestScore + } + } + + // Nothing matched. + return 0 +} + +func checkMatchStrength(value int) int { + if value > maxMatchStrength { + return maxMatchStrength + } + if value < -maxMatchStrength { + return -maxMatchStrength + } + return value +} diff --git a/profile/get.go b/profile/get.go index c6ef0b5d..d9820c18 100644 --- a/profile/get.go +++ b/profile/get.go @@ -2,20 +2,24 @@ package profile import ( "errors" + "fmt" + "path" + "strings" "sync" - "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/notifications" ) var getProfileLock sync.Mutex -// GetProfile fetches a profile. This function ensures that the loaded profile -// is shared among all callers. You must always supply both the scopedID and -// linkedPath parameters whenever available. -func GetProfile(source profileSource, id, linkedPath string, reset bool) ( //nolint:gocognit +// GetLocalProfile fetches a profile. This function ensures that the loaded profile +// is shared among all callers. Always provide all available data points. +// Passing an ID without MatchingData is valid, but could lead to inconsistent +// data - use with caution. +func GetLocalProfile(id string, md MatchingData, createProfileCallback func() *Profile) ( //nolint:gocognit profile *Profile, err error, ) { @@ -27,99 +31,90 @@ func GetProfile(source profileSource, id, linkedPath string, reset bool) ( //nol var previousVersion *Profile - // Fetch profile depending on the available information. - switch { - case id != "": - scopedID := makeScopedID(source, id) - - // Get profile via the scoped ID. - // Check if there already is an active and not outdated profile. - profile = getActiveProfile(scopedID) + // Get active profile based on the ID, if available. + if id != "" { + // Check if there already is an active profile. + profile = getActiveProfile(makeScopedID(SourceLocal, id)) if profile != nil { - profile.MarkStillActive() - - if profile.outdated.IsSet() || reset { - previousVersion = profile - } else { + // Mark active and return if not outdated. + if profile.outdated.IsNotSet() { + profile.MarkStillActive() return profile, nil } - } - // Get from database. - if !reset { - profile, err = getProfile(scopedID) - // Check if the profile is special and needs a reset. - if err == nil && specialProfileNeedsReset(profile) { - profile = getSpecialProfile(id, linkedPath) - } - } else { - // Simulate missing profile to create new one. - err = database.ErrNotFound + // If outdated, get from database. + previousVersion = profile + profile = nil } - - case linkedPath != "": - // Search for profile via a linked path. - // Check if there already is an active and not outdated profile for - // the linked path. - profile = findActiveProfile(linkedPath) - if profile != nil { - if profile.outdated.IsSet() || reset { - previousVersion = profile - } else { - return profile, nil - } - } - - // Get from database. - if !reset { - profile, err = findProfile(linkedPath) - // Check if the profile is special and needs a reset. - if err == nil && specialProfileNeedsReset(profile) { - profile = getSpecialProfile(id, linkedPath) - } - } else { - // Simulate missing profile to create new one. - err = database.ErrNotFound - } - - default: - return nil, errors.New("cannot fetch profile without ID or path") } - // Create new profile if none was found. - if errors.Is(err, database.ErrNotFound) { - err = nil + // In some cases, we might need to get a profile directly, without matching data. + // This could lead to inconsistent data - use with caution. + if md == nil { + if id == "" { + return nil, errors.New("cannot get local profiles without ID and matching data") + } - // Check if there is a special profile for this ID. - profile = getSpecialProfile(id, linkedPath) + profile, err = getProfile(makeScopedID(SourceLocal, id)) + if err != nil { + return nil, fmt.Errorf("failed to load profile %s by ID: %w", makeScopedID(SourceLocal, id), err) + } + } - // If not, create a standard profile. + // If we don't have a profile yet, find profile based on matching data. + if profile == nil { + profile, err = findProfile(SourceLocal, md) + if err != nil { + return nil, fmt.Errorf("failed to search for profile: %w", err) + } + } + + // If we still don't have a profile, create a new one. + if profile == nil { + // Try the profile creation callback, if we have one. + if createProfileCallback != nil { + profile = createProfileCallback() + } + + // If that did not work, create a standard profile. if profile == nil { - profile = New(SourceLocal, id, linkedPath, nil) + fpPath := md.MatchingPath() + if fpPath == "" { + fpPath = md.Path() + } + + profile = New(&Profile{ + ID: id, + Source: SourceLocal, + PresentationPath: md.Path(), + Fingerprints: []Fingerprint{ + { + Type: FingerprintTypePathID, + Operation: FingerprintOperationEqualsID, + Value: fpPath, + }, + }, + }) } } - // If there was a non-recoverable error, return here. - if err != nil { - return nil, err - } + // Prepare profile for first use. // Process profiles are coming directly from the database or are new. // As we don't use any caching, these will be new objects. - // Add a layeredProfile to local and network profiles. - if profile.Source == SourceLocal || profile.Source == SourceNetwork { - // If we are refetching, assign the layered profile from the previous version. - // The internal references will be updated when the layered profile checks for updates. - if previousVersion != nil { - profile.layeredProfile = previousVersion.layeredProfile - } + // Add a layeredProfile. - // Local profiles must have a layered profile, create a new one if it - // does not yet exist. - if profile.layeredProfile == nil { - profile.layeredProfile = NewLayeredProfile(profile) - } + // If we are refetching, assign the layered profile from the previous version. + // The internal references will be updated when the layered profile checks for updates. + if previousVersion != nil && previousVersion.layeredProfile != nil { + profile.layeredProfile = previousVersion.layeredProfile + } + + // Profiles must have a layered profile, create a new one if it + // does not yet exist. + if profile.layeredProfile == nil { + profile.layeredProfile = NewLayeredProfile(profile) } // Add the profile to the currently active profiles. @@ -137,40 +132,89 @@ func getProfile(scopedID string) (profile *Profile, err error) { } // Parse and prepare the profile, return the result. - return prepProfile(r) + return loadProfile(r) } // findProfile searches for a profile with the given linked path. If it cannot // find one, it will create a new profile for the given linked path. -func findProfile(linkedPath string) (profile *Profile, err error) { - // Search the database for a matching profile. - it, err := profileDB.Query( - query.New(makeProfileKey(SourceLocal, "")).Where( - query.Where("LinkedPath", query.SameAs, linkedPath), - ), +func findProfile(source profileSource, md MatchingData) (profile *Profile, err error) { + // TODO: Loading every profile from database and parsing it for every new + // process might be quite expensive. Measure impact and possibly improve. + + // Get iterator over all profiles. + it, err := profileDB.Query(query.New(profilesDBPath + makeScopedID(source, ""))) + if err != nil { + return nil, fmt.Errorf("failed to query for profiles: %w", err) + } + + // Find best matching profile. + var ( + highestScore int + bestMatch record.Record ) +profileFeed: + for r := range it.Next { + // Parse fingerprints. + prints, err := loadProfileFingerprints(r) + if err != nil { + log.Debugf("profile: failed to load fingerprints of %s: %s", r.Key(), err) + continue + } + + // Get matching score and compare. + score := MatchFingerprints(prints, md) + switch { + case score == 0: + // Continue to next. + case score > highestScore: + highestScore = score + bestMatch = r + case score == highestScore: + // Notify user of conflict and abort. + // Use first match - this should be consistent. + notifyConflictingProfiles(bestMatch, r, md) + it.Cancel() + break profileFeed + } + } + + // Check if there was an error while iterating. + if it.Err() != nil { + return nil, fmt.Errorf("failed to iterate over profiles: %w", err) + } + + // Return nothing if no profile matched. + if bestMatch == nil { + return nil, nil + } + + // If we have a match, parse and return the profile. + profile, err = loadProfile(bestMatch) + if err != nil { + return nil, fmt.Errorf("failed to parse selected profile %s: %w", bestMatch.Key(), err) + } + + // Check if this profile is already active and return the active version instead. + if activeProfile := getActiveProfile(profile.ScopedID()); activeProfile != nil { + return activeProfile, nil + } + + // Return nothing if no profile matched. + return profile, nil +} + +func loadProfileFingerprints(r record.Record) (parsed *parsedFingerprints, err error) { + // Ensure it's a profile. + profile, err := EnsureProfile(r) if err != nil { return nil, err } - // Only wait for the first result, or until the query ends. - r := <-it.Next - // Then cancel the query, should it still be running. - it.Cancel() - - // Prep and return an existing profile. - if r != nil { - profile, err = prepProfile(r) - return profile, err - } - - // If there was no profile in the database, create a new one, and return it. - profile = New(SourceLocal, "", linkedPath, nil) - - return profile, nil + // Parse and return fingerprints. + return parseFingerprints(profile.Fingerprints, profile.LinkedPath) } -func prepProfile(r record.Record) (*Profile, error) { +func loadProfile(r record.Record) (*Profile, error) { // ensure its a profile profile, err := EnsureProfile(r) if err != nil { @@ -192,3 +236,50 @@ func prepProfile(r record.Record) (*Profile, error) { // return parsed profile return profile, nil } + +func notifyConflictingProfiles(a, b record.Record, md MatchingData) { + // Get profile names. + var idA, nameA, idB, nameB string + profileA, err := EnsureProfile(a) + if err == nil { + idA = profileA.ScopedID() + nameA = profileA.Name + } else { + idA = strings.TrimPrefix(a.Key(), profilesDBPath) + nameA = path.Base(idA) + } + profileB, err := EnsureProfile(b) + if err == nil { + idB = profileB.ScopedID() + nameB = profileB.Name + } else { + idB = strings.TrimPrefix(b.Key(), profilesDBPath) + nameB = path.Base(idB) + } + + // Notify user about conflict. + notifications.NotifyWarn( + fmt.Sprintf("profiles:match-conflict:%s:%s", idA, idB), + "App Settings Match Conflict", + fmt.Sprintf( + "Multiple app settings match the app at %q with the same priority, please change on of them: %q or %q", + md.Path(), + nameA, + nameB, + ), + notifications.Action{ + Text: "Change (1)", + Type: notifications.ActionTypeOpenProfile, + Payload: idA, + }, + notifications.Action{ + Text: "Change (2)", + Type: notifications.ActionTypeOpenProfile, + Payload: idB, + }, + notifications.Action{ + ID: "ack", + Text: "OK", + }, + ) +} diff --git a/profile/profile-layered-provider.go b/profile/profile-layered-provider.go index 91378a40..81d54c4b 100644 --- a/profile/profile-layered-provider.go +++ b/profile/profile-layered-provider.go @@ -74,9 +74,11 @@ func getProfileRevision(p *Profile) (*LayeredProfile, error) { } // Update profiles if necessary. - if layeredProfile.NeedsUpdate() { - layeredProfile.Update() - } + // TODO: Cannot update as we have too little information. + // Just return the current state. Previous code: + // if layeredProfile.NeedsUpdate() { + // layeredProfile.Update() + // } return layeredProfile, nil } diff --git a/profile/profile-layered.go b/profile/profile-layered.go index 5fcbe75d..b2f7850b 100644 --- a/profile/profile-layered.go +++ b/profile/profile-layered.go @@ -57,8 +57,8 @@ func NewLayeredProfile(localProfile *Profile) *LayeredProfile { lp := &LayeredProfile{ localProfile: localProfile, - layers: make([]*Profile, 0, len(localProfile.LinkedProfiles)+1), - LayerIDs: make([]string, 0, len(localProfile.LinkedProfiles)+1), + layers: make([]*Profile, 0, 1), + LayerIDs: make([]string, 0, 1), globalValidityFlag: config.NewValidityFlag(), RevisionCounter: 1, securityLevel: &securityLevelVal, @@ -246,17 +246,23 @@ func (lp *LayeredProfile) NeedsUpdate() (outdated bool) { } // Update checks for and replaces any outdated profiles. -func (lp *LayeredProfile) Update() (revisionCounter uint64) { +func (lp *LayeredProfile) Update(md MatchingData, createProfileCallback func() *Profile) (revisionCounter uint64) { lp.Lock() defer lp.Unlock() var changed bool for i, layer := range lp.layers { if layer.outdated.IsSet() { - changed = true + // Check for unsupported sources. + if layer.Source != SourceLocal { + log.Warningf("profile: updating profiles outside of local source is not supported: %s", layer.ScopedID()) + layer.outdated.UnSet() + continue + } // Update layer. - newLayer, err := GetProfile(layer.Source, layer.ID, layer.LinkedPath, false) + changed = true + newLayer, err := GetLocalProfile(layer.ID, md, createProfileCallback) if err != nil { log.Errorf("profiles: failed to update profile %s: %s", layer.ScopedID(), err) } else { diff --git a/profile/profile.go b/profile/profile.go index 62d7da3f..09dc8d68 100644 --- a/profile/profile.go +++ b/profile/profile.go @@ -26,11 +26,8 @@ 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" + SourceLocal profileSource = "local" // local, editable + SourceSpecial profileSource = "special" // specials (read-only) ) // Default Action IDs. @@ -83,11 +80,17 @@ type Profile struct { //nolint:maligned // not worth the effort Icon string // IconType describes the type of the Icon property. IconType iconType - // LinkedPath is a filesystem path to the executable this + // Deprecated: LinkedPath used to point to the executableis this // profile was created for. + // Until removed, it will be added to the Fingerprints as an exact path match. LinkedPath string // constant - // LinkedProfiles is a list of other profiles - LinkedProfiles []string + // 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 + // Fingerprints holds process matching information. + Fingerprints []Fingerprint // 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 @@ -143,6 +146,11 @@ 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 { @@ -227,29 +235,25 @@ func (profile *Profile) parseConfig() error { // 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{}) +func New(profile *Profile) *Profile { + // Create profile if none is given. + if profile == nil { + profile = &Profile{} } - profile := &Profile{ - ID: id, - Source: source, - LinkedPath: linkedPath, - Created: time.Now().Unix(), - Config: customConfig, - savedInternally: true, + // 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 random ID if none is given. - if id == "" { + if profile.ID == "" { profile.ID = utils.RandomUUID("").String() } @@ -437,13 +441,13 @@ func (profile *Profile) UpdateMetadata(binaryPath string) (changed bool) { var needsUpdateFromSystem bool // Check profile name. - filename := filepath.Base(profile.LinkedPath) + filename := filepath.Base(profile.PresentationPath) // 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) + profile.Name = osdetail.GenerateBinaryNameFromPath(profile.PresentationPath) if profile.Name == filename { // TODO: Theoretically, the generated name could be identical to the // filename. @@ -462,37 +466,12 @@ func (profile *Profile) UpdateMetadata(binaryPath string) (changed bool) { 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()) + if profile.Source != SourceLocal || profile.PresentationPath == "" { + return fmt.Errorf("tried to update metadata for non-local or non-path profile %s", profile.ScopedID()) } // Save the profile when finished, if needed. @@ -507,14 +486,14 @@ func (profile *Profile) updateMetadataFromSystem(ctx context.Context) error { }() // Get binary name from linked path. - newName, err := osdetail.GetBinaryNameFromSystem(profile.LinkedPath) + newName, err := osdetail.GetBinaryNameFromSystem(profile.PresentationPath) 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) + log.Warningf("profile: error while getting binary name for %s: %s", profile.PresentationPath, err) } return nil } @@ -525,7 +504,7 @@ func (profile *Profile) updateMetadataFromSystem(ctx context.Context) error { } // Get filename of linked path for comparison. - filename := filepath.Base(profile.LinkedPath) + filename := filepath.Base(profile.PresentationPath) // TODO: Theoretically, the generated name from the system could be identical // to the filename. This would mean that the worker is triggered every time diff --git a/profile/special.go b/profile/special.go index c89ac3b1..89b06f3c 100644 --- a/profile/special.go +++ b/profile/special.go @@ -1,9 +1,12 @@ package profile import ( + "errors" "time" + "github.com/safing/portbase/database" "github.com/safing/portbase/log" + "github.com/safing/portmaster/status" ) const ( @@ -74,6 +77,73 @@ If you think you might have messed up the settings of the System DNS Client, jus PortmasterNotifierProfileDescription = `This is the Portmaster UI Tray Notifier.` ) +// GetSpecialProfile fetches a special profile. This function ensures that the loaded profile +// is shared among all callers. Always provide all available data points. +func GetSpecialProfile(id string, path string) ( //nolint:gocognit + profile *Profile, + err error, +) { + // Check if we have an ID. + if id == "" { + return nil, errors.New("cannot get special profile without ID") + } + scopedID := makeScopedID(SourceLocal, id) + + // Globally lock getting a profile. + // This does not happen too often, and it ensures we really have integrity + // and no race conditions. + getProfileLock.Lock() + defer getProfileLock.Unlock() + + // Check if there already is an active profile. + var previousVersion *Profile + profile = getActiveProfile(scopedID) + if profile != nil { + // Mark active and return if not outdated. + if profile.outdated.IsNotSet() { + profile.MarkStillActive() + return profile, nil + } + + // If outdated, get from database. + previousVersion = profile + } + + // Get special profile from DB and check if it needs a reset. + profile, err = getProfile(scopedID) + if err != nil { + if !errors.Is(err, database.ErrNotFound) { + log.Warningf("profile: failed to get special profile %s: %s", id, err) + } + profile = createSpecialProfile(id, path) + } else if specialProfileNeedsReset(profile) { + log.Debugf("profile: resetting special profile %s", id) + profile = createSpecialProfile(id, path) + } + if profile == nil { + return nil, errors.New("given ID is not a special profile ID") + } + + // Prepare profile for first use. + + // If we are refetching, assign the layered profile from the previous version. + // The internal references will be updated when the layered profile checks for updates. + if previousVersion != nil && previousVersion.layeredProfile != nil { + profile.layeredProfile = previousVersion.layeredProfile + } + + // Profiles must have a layered profile, create a new one if it + // does not yet exist. + if profile.layeredProfile == nil { + profile.layeredProfile = NewLayeredProfile(profile) + } + + // Add the profile to the currently active profiles. + addActiveProfile(profile) + + return profile, nil +} + func updateSpecialProfileMetadata(profile *Profile, binaryPath string) (ok, changed bool) { // Get new profile name and check if profile is applicable to special handling. var newProfileName, newDescription string @@ -115,35 +185,45 @@ func updateSpecialProfileMetadata(profile *Profile, binaryPath string) (ok, chan changed = true } - // Update LinkedPath to new value. - if profile.LinkedPath != binaryPath { - profile.LinkedPath = binaryPath + // Update PresentationPath to new value. + if profile.PresentationPath != binaryPath { + profile.PresentationPath = binaryPath changed = true } return true, changed } -func getSpecialProfile(profileID, linkedPath string) *Profile { +func createSpecialProfile(profileID string, path string) *Profile { switch profileID { case UnidentifiedProfileID: - return New(SourceLocal, UnidentifiedProfileID, linkedPath, nil) + return New(&Profile{ + ID: UnidentifiedProfileID, + Source: SourceLocal, + PresentationPath: path, + }) case SystemProfileID: - return New(SourceLocal, SystemProfileID, linkedPath, nil) + return New(&Profile{ + ID: SystemProfileID, + Source: SourceLocal, + PresentationPath: path, + }) case SystemResolverProfileID: - systemResolverProfile := New( - SourceLocal, - SystemResolverProfileID, - linkedPath, - map[string]interface{}{ + return New(&Profile{ + ID: SystemResolverProfileID, + Source: SourceLocal, + PresentationPath: path, + Config: map[string]interface{}{ // Explicitly setting the default action to "permit" will improve the // user experience for people who set the global default to "prompt". // Resolved domain from the system resolver are checked again when // attributed to a connection of a regular process. Otherwise, users // would see two connection prompts for the same domain. CfgOptionDefaultActionKey: "permit", + // Explicitly allow incoming connections. + CfgOptionBlockInboundKey: status.SecurityLevelOff, // Explicitly allow localhost and answers to multicast protocols that // are commonly used by system resolvers. // TODO: When the Portmaster gains the ability to attribute multicast @@ -154,6 +234,7 @@ func getSpecialProfile(profileID, linkedPath string) *Profile { "+ LAN UDP/5353", // Allow inbound mDNS requests and multicast replies. "+ LAN UDP/5355", // Allow inbound LLMNR requests and multicast replies. "+ LAN UDP/1900", // Allow inbound SSDP requests and multicast replies. + "- *", // Deny everything else. }, // Explicitly disable all filter lists, as these will be checked later // with the attributed connection. As this is the system resolver, this @@ -161,44 +242,44 @@ func getSpecialProfile(profileID, linkedPath string) *Profile { // the system resolver is used. Users who want to CfgOptionFilterListsKey: []string{}, }, - ) - return systemResolverProfile + }) case PortmasterProfileID: - profile := New(SourceLocal, PortmasterProfileID, linkedPath, nil) - profile.Internal = true - return profile + return New(&Profile{ + ID: PortmasterProfileID, + Source: SourceLocal, + PresentationPath: path, + Internal: true, + }) case PortmasterAppProfileID: - profile := New( - SourceLocal, - PortmasterAppProfileID, - linkedPath, - map[string]interface{}{ + return New(&Profile{ + ID: PortmasterAppProfileID, + Source: SourceLocal, + PresentationPath: path, + Config: map[string]interface{}{ CfgOptionDefaultActionKey: "block", CfgOptionEndpointsKey: []string{ "+ Localhost", "+ .safing.io", }, }, - ) - profile.Internal = true - return profile + Internal: true, + }) case PortmasterNotifierProfileID: - profile := New( - SourceLocal, - PortmasterNotifierProfileID, - linkedPath, - map[string]interface{}{ + return New(&Profile{ + ID: PortmasterNotifierProfileID, + Source: SourceLocal, + PresentationPath: path, + Config: map[string]interface{}{ CfgOptionDefaultActionKey: "block", CfgOptionEndpointsKey: []string{ "+ Localhost", }, }, - ) - profile.Internal = true - return profile + Internal: true, + }) default: return nil