From 126776aeca5f73cfd0f610e2c148141dce836f2e Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 27 Sep 2023 14:23:02 +0200 Subject: [PATCH] Finalize profile merging, add profile metadata state handling, re-attribute connections after profile deletion --- firewall/module.go | 57 ++++++++--- firewall/packet_handler.go | 134 +++++++++++++++++-------- network/connection.go | 5 + network/module.go | 103 +++++++++++++++++++ process/profile.go | 18 ++++ profile/api.go | 66 ++++++++++++ profile/database.go | 20 +++- profile/endpoints/endpoint.go | 3 +- profile/get.go | 3 + profile/merge.go | 54 ++++++---- profile/meta.go | 184 ++++++++++++++++++++++++++++++++++ profile/migrations.go | 10 +- profile/module.go | 27 ++++- profile/profile.go | 18 ++++ 14 files changed, 614 insertions(+), 88 deletions(-) create mode 100644 profile/api.go create mode 100644 profile/meta.go diff --git a/firewall/module.go b/firewall/module.go index 999c3888d..de6ca88aa 100644 --- a/firewall/module.go +++ b/firewall/module.go @@ -2,13 +2,18 @@ package firewall import ( "context" + "fmt" + "strings" + "github.com/safing/portbase/config" "github.com/safing/portbase/log" "github.com/safing/portbase/modules" "github.com/safing/portbase/modules/subsystems" _ "github.com/safing/portmaster/core" "github.com/safing/portmaster/network" + "github.com/safing/portmaster/profile" "github.com/safing/spn/access" + "github.com/safing/spn/captain" ) var module *modules.Module @@ -25,12 +30,6 @@ func init() { ) } -const ( - configChangeEvent = "config change" - profileConfigChangeEvent = "profile config change" - onSPNConnectEvent = "spn connect" -) - func prep() error { network.SetDefaultFirewallHandler(verdictHandler) @@ -38,8 +37,8 @@ func prep() error { // this will be triggered on spn enable/disable err := module.RegisterEventHook( "config", - configChangeEvent, - "reset connection verdicts", + config.ChangeEvent, + "reset connection verdicts after global config change", func(ctx context.Context, _ interface{}) error { resetAllConnectionVerdicts() return nil @@ -52,10 +51,20 @@ func prep() error { // Reset connections every time profile changes err = module.RegisterEventHook( "profiles", - profileConfigChangeEvent, - "reset connection verdicts", - func(ctx context.Context, _ interface{}) error { - resetAllConnectionVerdicts() + profile.ConfigChangeEvent, + "reset connection verdicts after profile config change", + func(ctx context.Context, eventData interface{}) error { + // Expected event data: scoped profile ID. + profileID, ok := eventData.(string) + if !ok { + return fmt.Errorf("event data is not a string: %v", eventData) + } + profileSource, profileID, ok := strings.Cut(profileID, "/") + if !ok { + return fmt.Errorf("event data does not seem to be a scoped profile ID: %v", eventData) + } + + resetProfileConnectionVerdict(profileSource, profileID) return nil }, ) @@ -67,8 +76,8 @@ func prep() error { // connect and disconnecting is triggered on config change event but connecting takŠµs more time err = module.RegisterEventHook( "captain", - onSPNConnectEvent, - "reset connection verdicts", + captain.SPNConnectedEvent, + "reset connection verdicts on SPN connect", func(ctx context.Context, _ interface{}) error { resetAllConnectionVerdicts() return nil @@ -83,7 +92,7 @@ func prep() error { err = module.RegisterEventHook( "access", access.AccountUpdateEvent, - "update connection feature flags", + "update connection feature flags after account update", func(ctx context.Context, _ interface{}) error { resetAllConnectionVerdicts() return nil @@ -93,6 +102,24 @@ func prep() error { log.Errorf("filter: failed to register event hook: %s", err) } + err = module.RegisterEventHook( + "network", + network.ConnectionReattributedEvent, + "reset verdict of re-attributed connection", + func(ctx context.Context, eventData interface{}) error { + // Expected event data: connection ID. + connID, ok := eventData.(string) + if !ok { + return fmt.Errorf("event data is not a string: %v", eventData) + } + resetSingleConnectionVerdict(connID) + return nil + }, + ) + if err != nil { + log.Errorf("filter: failed to register event hook: %s", err) + } + if err := registerConfig(); err != nil { return err } diff --git a/firewall/packet_handler.go b/firewall/packet_handler.go index 0ddf3a9a4..8a757362e 100644 --- a/firewall/packet_handler.go +++ b/firewall/packet_handler.go @@ -43,13 +43,55 @@ var ( ownPID = os.Getpid() ) -func resetAllConnectionVerdicts() { +func resetSingleConnectionVerdict(connID string) { + // Create tracing context. + ctx, tracer := log.AddTracer(context.Background()) + defer tracer.Submit() + + conn, ok := network.GetConnection(connID) + if !ok { + conn, ok = network.GetDNSConnection(connID) + if !ok { + tracer.Debugf("filter: could not find re-attributed connection %s for re-evaluation", connID) + return + } + } + + resetConnectionVerdict(ctx, conn) +} + +func resetProfileConnectionVerdict(profileSource, profileID string) { + // Create tracing context. + ctx, tracer := log.AddTracer(context.Background()) + defer tracer.Submit() + // Resetting will force all the connection to be evaluated by the firewall again // this will set new verdicts if configuration was update or spn has been disabled or enabled. - log.Info("interception: re-evaluating all connections") + tracer.Infof("filter: re-evaluating connections of %s/%s", profileSource, profileID) + // Re-evaluate all connections. + var changedVerdicts int + for _, conn := range network.GetAllConnections() { + // Check if connection is complete and attributed to the deleted profile. + if conn.DataIsComplete() && + conn.ProcessContext.Profile == profileID && + conn.ProcessContext.Source == profileSource { + if resetConnectionVerdict(ctx, conn) { + changedVerdicts++ + } + } + } + tracer.Infof("filter: changed verdict on %d connections", changedVerdicts) +} + +func resetAllConnectionVerdicts() { // Create tracing context. ctx, tracer := log.AddTracer(context.Background()) + defer tracer.Submit() + + // Resetting will force all the connection to be evaluated by the firewall again + // this will set new verdicts if configuration was update or spn has been disabled or enabled. + tracer.Info("filter: re-evaluating all connections") // Re-evaluate all connections. var changedVerdicts int @@ -59,54 +101,60 @@ func resetAllConnectionVerdicts() { continue } - func() { - conn.Lock() - defer conn.Unlock() + if resetConnectionVerdict(ctx, conn) { + changedVerdicts++ + } + } + tracer.Infof("filter: changed verdict on %d connections", changedVerdicts) +} - // Update feature flags. - if err := conn.UpdateFeatures(); err != nil && !errors.Is(err, access.ErrNotLoggedIn) { - tracer.Warningf("network: failed to update connection feature flags: %s", err) - } +func resetConnectionVerdict(ctx context.Context, conn *network.Connection) (verdictChanged bool) { + tracer := log.Tracer(ctx) - // Skip internal connections: - // - Pre-authenticated connections from Portmaster - // - Redirected DNS requests - // - SPN Uplink to Home Hub - if conn.Internal { - tracer.Tracef("filter: skipping internal connection %s", conn) - return - } + conn.Lock() + defer conn.Unlock() - tracer.Debugf("filter: re-evaluating verdict of %s", conn) - previousVerdict := conn.Verdict.Firewall + // Update feature flags. + if err := conn.UpdateFeatures(); err != nil && !errors.Is(err, access.ErrNotLoggedIn) { + tracer.Warningf("filter: failed to update connection feature flags: %s", err) + } - // Apply privacy filter and check tunneling. - FilterConnection(ctx, conn, nil, true, true) + // Skip internal connections: + // - Pre-authenticated connections from Portmaster + // - Redirected DNS requests + // - SPN Uplink to Home Hub + if conn.Internal { + // tracer.Tracef("filter: skipping internal connection %s", conn) + return false + } - // Stop existing SPN tunnel if not needed anymore. - if conn.Verdict.Active != network.VerdictRerouteToTunnel && conn.TunnelContext != nil { - err := conn.TunnelContext.StopTunnel() - if err != nil { - tracer.Debugf("filter: failed to stopped unneeded tunnel: %s", err) - } - } + tracer.Debugf("filter: re-evaluating verdict of %s", conn) + previousVerdict := conn.Verdict.Firewall - // Save if verdict changed. - if conn.Verdict.Firewall != previousVerdict { - err := interception.UpdateVerdictOfConnection(conn) - if err != nil { - log.Debugf("filter: failed to update connection verdict: %s", err) - } - conn.Save() - tracer.Infof("filter: verdict of connection %s changed from %s to %s", conn, previousVerdict.Verb(), conn.VerdictVerb()) - changedVerdicts++ - } else { - tracer.Tracef("filter: verdict to connection %s unchanged at %s", conn, conn.VerdictVerb()) - } - }() + // Apply privacy filter and check tunneling. + FilterConnection(ctx, conn, nil, true, true) + + // Stop existing SPN tunnel if not needed anymore. + if conn.Verdict.Active != network.VerdictRerouteToTunnel && conn.TunnelContext != nil { + err := conn.TunnelContext.StopTunnel() + if err != nil { + tracer.Debugf("filter: failed to stopped unneeded tunnel: %s", err) + } } - tracer.Infof("filter: changed verdict on %d connections", changedVerdicts) - tracer.Submit() + + // Save if verdict changed. + if conn.Verdict.Firewall != previousVerdict { + err := interception.UpdateVerdictOfConnection(conn) + if err != nil { + log.Debugf("filter: failed to update connection verdict: %s", err) + } + conn.Save() + tracer.Infof("filter: verdict of connection %s changed from %s to %s", conn, previousVerdict.Verb(), conn.VerdictVerb()) + return true + } + + tracer.Tracef("filter: verdict to connection %s unchanged at %s", conn, conn.VerdictVerb()) + return false } // SetNameserverIPMatcher sets a function that is used to match the internal diff --git a/network/connection.go b/network/connection.go index ab3ec3fbf..55fc6a3d7 100644 --- a/network/connection.go +++ b/network/connection.go @@ -582,6 +582,11 @@ func GetAllConnections() []*Connection { return conns.list() } +// GetDNSConnection fetches a DNS Connection from the database. +func GetDNSConnection(dnsConnID string) (*Connection, bool) { + return dnsConns.get(dnsConnID) +} + // SetLocalIP sets the local IP address together with its network scope. The // connection is not locked for this. func (conn *Connection) SetLocalIP(ip net.IP) { diff --git a/network/module.go b/network/module.go index 1a7ee708d..265ad018c 100644 --- a/network/module.go +++ b/network/module.go @@ -1,9 +1,16 @@ package network import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/safing/portbase/log" "github.com/safing/portbase/modules" "github.com/safing/portmaster/netenv" "github.com/safing/portmaster/network/state" + "github.com/safing/portmaster/profile" ) var ( @@ -12,8 +19,14 @@ var ( defaultFirewallHandler FirewallHandler ) +// Events. +var ( + ConnectionReattributedEvent = "connection re-attributed" +) + func init() { module = modules.Register("network", prep, start, nil, "base", "netenv", "processes") + module.RegisterEvent(ConnectionReattributedEvent, false) } // SetDefaultFirewallHandler sets the default firewall handler. @@ -45,5 +58,95 @@ func start() error { module.StartServiceWorker("clean connections", 0, connectionCleaner) module.StartServiceWorker("write open dns requests", 0, openDNSRequestWriter) + if err := module.RegisterEventHook( + "profiles", + profile.DeletedEvent, + "re-attribute connections from deleted profile", + reAttributeConnections, + ); err != nil { + return err + } + + return nil +} + +var reAttributionLock sync.Mutex + +// reAttributeConnections finds all connections of a deleted profile and re-attributes them. +// Expected event data: scoped profile ID. +func reAttributeConnections(_ context.Context, eventData any) error { + profileID, ok := eventData.(string) + if !ok { + return fmt.Errorf("event data is not a string: %v", eventData) + } + profileSource, profileID, ok := strings.Cut(profileID, "/") + if !ok { + return fmt.Errorf("event data does not seem to be a scoped profile ID: %v", eventData) + } + + // Hold a lock for re-attribution, to prevent simultaneous processing of the + // same connections and make logging cleaner. + reAttributionLock.Lock() + defer reAttributionLock.Unlock() + + // Create tracing context. + ctx, tracer := log.AddTracer(context.Background()) + defer tracer.Submit() + tracer.Infof("network: re-attributing connections from deleted profile %s/%s", profileSource, profileID) + + // Count and log how many connections were re-attributed. + var reAttributed int + + // Re-attribute connections. + for _, conn := range conns.clone() { + // Check if connection is complete and attributed to the deleted profile. + if conn.DataIsComplete() && + conn.ProcessContext.Profile == profileID && + conn.ProcessContext.Source == profileSource { + + reAttributeConnection(ctx, conn) + reAttributed++ + tracer.Debugf("filter: re-attributed %s to %s", conn, conn.process.PrimaryProfileID) + } + } + + // Re-attribute dns connections. + for _, conn := range dnsConns.clone() { + // Check if connection is complete and attributed to the deleted profile. + if conn.DataIsComplete() && + conn.ProcessContext.Profile == profileID && + conn.ProcessContext.Source == profileSource { + + reAttributeConnection(ctx, conn) + reAttributed++ + tracer.Debugf("filter: re-attributed %s to %s", conn, conn.process.PrimaryProfileID) + } + } + + tracer.Infof("filter: re-attributed %d connections", reAttributed) return nil } + +func reAttributeConnection(ctx context.Context, conn *Connection) { + // Check if data is complete. + if !conn.DataIsComplete() { + return + } + + conn.Lock() + defer conn.Unlock() + + // Attempt to assign new profile. + err := conn.process.RefetchProfile(ctx) + if err != nil { + log.Warningf("network: failed to refetch profile for %s: %s", conn, err) + return + } + + // Set the new process context. + conn.ProcessContext = getProcessContext(ctx, conn.process) + conn.Save() + + // Trigger event for re-attribution. + module.TriggerEvent(ConnectionReattributedEvent, conn.ID) +} diff --git a/process/profile.go b/process/profile.go index 65680639c..27c0f9856 100644 --- a/process/profile.go +++ b/process/profile.go @@ -40,6 +40,24 @@ func (p *Process) GetProfile(ctx context.Context) (changed bool, err error) { return true, nil } +// RefetchProfile removes the profile and finds and assigns a new profile. +func (p *Process) RefetchProfile(ctx context.Context) error { + p.Lock() + defer p.Unlock() + + // Get special or regular profile. + localProfile, err := profile.GetLocalProfile(p.getSpecialProfileID(), p.MatchingData(), p.CreateProfileCallback) + if err != nil { + return fmt.Errorf("failed to find profile: %w", err) + } + + // Assign profile to process. + p.PrimaryProfileID = localProfile.ScopedID() + p.profile = localProfile.LayeredProfile() + + return nil +} + // getSpecialProfileID returns the special profile ID for the process, if any. func (p *Process) getSpecialProfileID() (specialProfileID string) { // Check if we need a special profile. diff --git a/profile/api.go b/profile/api.go new file mode 100644 index 000000000..e8213bd6f --- /dev/null +++ b/profile/api.go @@ -0,0 +1,66 @@ +package profile + +import ( + "fmt" + + "github.com/safing/portbase/api" + "github.com/safing/portbase/formats/dsd" +) + +func registerAPIEndpoints() error { + if err := api.RegisterEndpoint(api.Endpoint{ + Name: "Merge profiles", + Description: "Merge multiple profiles into a new one.", + Path: "profile/merge", + Write: api.PermitUser, + BelongsTo: module, + StructFunc: handleMergeProfiles, + }); err != nil { + return err + } + + return nil +} + +type mergeProfilesRequest struct { + Name string `json:"name"` // Name of the new merged profile. + To string `json:"to"` // Profile scoped ID. + From []string `json:"from"` // Profile scoped IDs. +} + +type mergeprofilesResponse struct { + New string `json:"new"` // Profile scoped ID. +} + +func handleMergeProfiles(ar *api.Request) (i interface{}, err error) { + request := &mergeProfilesRequest{} + _, err = dsd.MimeLoad(ar.InputData, ar.Header.Get("Content-Type"), request) + if err != nil { + return nil, fmt.Errorf("failed to parse request: %w", err) + } + + // Get all profiles. + var ( + primary *Profile + secondaries = make([]*Profile, 0, len(request.From)) + ) + if primary, err = getProfile(request.To); err != nil { + return nil, fmt.Errorf("failed to get profile %s: %w", request.To, err) + } + for _, from := range request.From { + sp, err := getProfile(from) + if err != nil { + return nil, fmt.Errorf("failed to get profile %s: %w", request.To, err) + } + secondaries = append(secondaries, sp) + } + + newProfile, err := MergeProfiles(request.Name, primary, secondaries...) + if err != nil { + return nil, fmt.Errorf("failed to merge profiles: %w", err) + } + + return &mergeprofilesResponse{ + New: newProfile.ScopedID(), + }, nil +} diff --git a/profile/database.go b/profile/database.go index 49ecb3c14..d311bd328 100644 --- a/profile/database.go +++ b/profile/database.go @@ -16,6 +16,7 @@ import ( // core:profiles// // cache:profiles/index// +// ProfilesDBPath is the base database path for profiles. const ProfilesDBPath = "core:profiles/" var profileDB = database.NewInterface(&database.Options{ @@ -59,8 +60,14 @@ func startProfileUpdateChecker() error { } // Get active profile. - activeProfile := getActiveProfile(strings.TrimPrefix(r.Key(), ProfilesDBPath)) + scopedID := strings.TrimPrefix(r.Key(), ProfilesDBPath) + activeProfile := getActiveProfile(scopedID) if activeProfile == nil { + // Check if profile is being deleted. + if r.Meta().IsDeleted() { + meta.MarkDeleted(scopedID) + } + // Don't do any additional actions if the profile is not active. continue profileFeed } @@ -74,7 +81,9 @@ func startProfileUpdateChecker() error { // Always mark as outdated if the record is being deleted. if r.Meta().IsDeleted() { activeProfile.outdated.Set() - module.TriggerEvent(profileConfigChange, nil) + + meta.MarkDeleted(scopedID) + module.TriggerEvent(DeletedEvent, scopedID) continue } @@ -83,7 +92,7 @@ func startProfileUpdateChecker() error { receivedProfile, err := EnsureProfile(r) if err != nil || !receivedProfile.savedInternally { activeProfile.outdated.Set() - module.TriggerEvent(profileConfigChange, nil) + module.TriggerEvent(ConfigChangeEvent, scopedID) } case <-ctx.Done(): return nil @@ -105,6 +114,11 @@ func (h *databaseHook) UsesPrePut() bool { // PrePut implements the Hook interface. func (h *databaseHook) PrePut(r record.Record) (record.Record, error) { + // Do not intervene with metadata key. + if r.Key() == profilesMetadataKey { + return r, nil + } + // convert profile, err := EnsureProfile(r) if err != nil { diff --git a/profile/endpoints/endpoint.go b/profile/endpoints/endpoint.go index ad23c0743..b893a634e 100644 --- a/profile/endpoints/endpoint.go +++ b/profile/endpoints/endpoint.go @@ -202,7 +202,8 @@ func invalidDefinitionError(fields []string, msg string) error { return fmt.Errorf(`invalid endpoint definition: "%s" - %s`, strings.Join(fields, " "), msg) } -func parseEndpoint(value string) (endpoint Endpoint, err error) { //nolint:gocognit +//nolint:gocognit,nakedret +func parseEndpoint(value string) (endpoint Endpoint, err error) { fields := strings.Fields(value) if len(fields) < 2 { return nil, fmt.Errorf(`invalid endpoint definition: "%s"`, value) diff --git a/profile/get.go b/profile/get.go index 3a705b4a2..dc37efea4 100644 --- a/profile/get.go +++ b/profile/get.go @@ -287,6 +287,9 @@ func loadProfile(r record.Record) (*Profile, error) { // Set saved internally to suppress outdating profiles if saving internally. profile.savedInternally = true + // Mark as recently seen. + meta.UpdateLastSeen(profile.ScopedID()) + // return parsed profile return profile, nil } diff --git a/profile/merge.go b/profile/merge.go index 581ba07ef..2c0d5cfcc 100644 --- a/profile/merge.go +++ b/profile/merge.go @@ -1,8 +1,10 @@ package profile import ( + "errors" "fmt" "sync" + "time" "github.com/safing/portbase/database/record" ) @@ -11,19 +13,42 @@ import ( // The new profile is saved and returned. // Only the icon and fingerprints are inherited from other profiles. // All other information is taken only from the primary profile. -func MergeProfiles(primary *Profile, secondaries ...*Profile) (newProfile *Profile, err error) { +func MergeProfiles(name string, primary *Profile, secondaries ...*Profile) (newProfile *Profile, err error) { + if primary == nil || len(secondaries) == 0 { + return nil, errors.New("must supply both a primary and at least one secondary profile for merging") + } + // Fill info from primary profile. + nowUnix := time.Now().Unix() newProfile = &Profile{ Base: record.Base{}, RWMutex: sync.RWMutex{}, ID: "", // Omit ID to derive it from the new fingerprints. Source: primary.Source, - Name: primary.Name, + Name: name, Description: primary.Description, Homepage: primary.Homepage, UsePresentationPath: false, // Disable presentation path. SecurityLevel: primary.SecurityLevel, Config: primary.Config, + Created: nowUnix, + } + + // Fall back to name of primary profile, if none is set. + if newProfile.Name == "" { + newProfile.Name = primary.Name + } + + // If any profile was edited, set LastEdited to now. + if primary.LastEdited > 0 { + newProfile.LastEdited = nowUnix + } else { + for _, sp := range secondaries { + if sp.LastEdited > 0 { + newProfile.LastEdited = nowUnix + break + } + } } // Collect all icons. @@ -35,7 +60,7 @@ func MergeProfiles(primary *Profile, secondaries ...*Profile) (newProfile *Profi newProfile.Icons = sortAndCompactIcons(newProfile.Icons) // Collect all fingerprints. - newProfile.Fingerprints = make([]Fingerprint, 0, len(secondaries)+1) // Guess the needed space. + newProfile.Fingerprints = make([]Fingerprint, 0, len(primary.Fingerprints)+len(secondaries)) // Guess the needed space. newProfile.Fingerprints = addFingerprints(newProfile.Fingerprints, primary.Fingerprints, primary.ScopedID()) for _, sp := range secondaries { newProfile.Fingerprints = addFingerprints(newProfile.Fingerprints, sp.Fingerprints, sp.ScopedID()) @@ -44,26 +69,19 @@ func MergeProfiles(primary *Profile, secondaries ...*Profile) (newProfile *Profi // Save new profile. newProfile = New(newProfile) - err = newProfile.Save() - if err != nil { + if err := newProfile.Save(); err != nil { return nil, fmt.Errorf("failed to save merged profile: %w", err) } - // FIXME: Should we ... ? - // newProfile.updateMetadata() - // newProfile.updateMetadataFromSystem() // Delete all previous profiles. - // FIXME: - /* - primary.Meta().Delete() - // Set as outdated and remove from active profiles. - // Signify that profile was deleted and save for sync. - for _, sp := range secondaries { - sp.Meta().Delete() - // Set as outdated and remove from active profiles. - // Signify that profile was deleted and save for sync. + if err := primary.delete(); err != nil { + return nil, fmt.Errorf("failed to delete primary profile %s: %w", primary.ScopedID(), err) + } + for _, sp := range secondaries { + if err := sp.delete(); err != nil { + return nil, fmt.Errorf("failed to delete secondary profile %s: %w", sp.ScopedID(), err) } - */ + } return newProfile, nil } diff --git a/profile/meta.go b/profile/meta.go new file mode 100644 index 000000000..e91a4074f --- /dev/null +++ b/profile/meta.go @@ -0,0 +1,184 @@ +package profile + +import ( + "fmt" + "sync" + "time" + + "github.com/safing/portbase/database/record" +) + +// ProfilesMetadata holds metadata about all profiles that are not fit to be +// stored with the profiles themselves. +type ProfilesMetadata struct { + record.Base + sync.Mutex + + States map[string]*MetaState +} + +// MetaState describes the state of a profile. +type MetaState struct { + State string + At time.Time +} + +// Profile metadata states. +const ( + MetaStateSeen = "seen" + MetaStateDeleted = "deleted" +) + +// EnsureProfilesMetadata ensures that the given record is a *ProfilesMetadata, and returns it. +func EnsureProfilesMetadata(r record.Record) (*ProfilesMetadata, error) { + // unwrap + if r.IsWrapped() { + // only allocate a new struct, if we need it + newMeta := &ProfilesMetadata{} + err := record.Unwrap(r, newMeta) + if err != nil { + return nil, err + } + return newMeta, nil + } + + // or adjust type + newMeta, ok := r.(*ProfilesMetadata) + if !ok { + return nil, fmt.Errorf("record not of type *Profile, but %T", r) + } + return newMeta, nil +} + +var ( + profilesMetadataKey = ProfilesDBPath + "meta" + + meta *ProfilesMetadata + + removeDeletedEntriesAfter = 30 * 24 * time.Hour +) + +// loadProfilesMetadata loads the profile metadata from the database. +// It may only be called during module starting, as there is no lock for "meta" itself. +func loadProfilesMetadata() error { + r, err := profileDB.Get(profilesMetadataKey) + if err != nil { + return err + } + loadedMeta, err := EnsureProfilesMetadata(r) + if err != nil { + return err + } + + // Set package variable. + meta = loadedMeta + return nil +} + +func (meta *ProfilesMetadata) check() { + if meta.States == nil { + meta.States = make(map[string]*MetaState) + } +} + +// Save saves the profile metadata to the database. +func (meta *ProfilesMetadata) Save() error { + if meta == nil { + return nil + } + + func() { + meta.Lock() + defer meta.Unlock() + + if !meta.KeyIsSet() { + meta.SetKey(profilesMetadataKey) + } + }() + + meta.Clean() + return profileDB.Put(meta) +} + +// Clean removes old entries. +func (meta *ProfilesMetadata) Clean() { + if meta == nil { + return + } + + meta.Lock() + defer meta.Unlock() + + for key, state := range meta.States { + switch { + case state == nil: + delete(meta.States, key) + case state.State != MetaStateDeleted: + continue + case time.Since(state.At) > removeDeletedEntriesAfter: + delete(meta.States, key) + } + } +} + +// GetLastSeen returns when the profile with the given ID was last seen. +func (meta *ProfilesMetadata) GetLastSeen(scopedID string) *time.Time { + if meta == nil { + return nil + } + + meta.Lock() + defer meta.Unlock() + + state := meta.States[scopedID] + switch { + case state == nil: + return nil + case state.State == MetaStateSeen: + return &state.At + default: + return nil + } +} + +// UpdateLastSeen sets the profile with the given ID as last seen now. +func (meta *ProfilesMetadata) UpdateLastSeen(scopedID string) { + if meta == nil { + return + } + + meta.Lock() + defer meta.Unlock() + + meta.States[scopedID] = &MetaState{ + State: MetaStateSeen, + At: time.Now().UTC(), + } +} + +// MarkDeleted marks the profile with the given ID as deleted. +func (meta *ProfilesMetadata) MarkDeleted(scopedID string) { + if meta == nil { + return + } + + meta.Lock() + defer meta.Unlock() + + meta.States[scopedID] = &MetaState{ + State: MetaStateDeleted, + At: time.Now().UTC(), + } +} + +// RemoveState removes any state of the profile with the given ID. +func (meta *ProfilesMetadata) RemoveState(scopedID string) { + if meta == nil { + return + } + + meta.Lock() + defer meta.Unlock() + + delete(meta.States, scopedID) +} diff --git a/profile/migrations.go b/profile/migrations.go index 72cd11337..d45bbfd3b 100644 --- a/profile/migrations.go +++ b/profile/migrations.go @@ -32,11 +32,11 @@ func registerMigrations() error { Version: "v1.4.7", MigrateFunc: migrateIcons, }, - // migration.Migration{ - // Description: "Migrate from random profile IDs to fingerprint-derived IDs", - // Version: "v1.5.1", - // MigrateFunc: migrateToDerivedIDs, - // }, + migration.Migration{ + Description: "Migrate from random profile IDs to fingerprint-derived IDs", + Version: "v1.5.0", + MigrateFunc: migrateToDerivedIDs, + }, ) } diff --git a/profile/module.go b/profile/module.go index c41c90046..280579d0f 100644 --- a/profile/module.go +++ b/profile/module.go @@ -1,8 +1,10 @@ package profile import ( + "errors" "os" + "github.com/safing/portbase/database" "github.com/safing/portbase/database/migration" "github.com/safing/portbase/log" "github.com/safing/portbase/modules" @@ -16,13 +18,16 @@ var ( updatesPath string ) +// Events. const ( - profileConfigChange = "profile config change" + ConfigChangeEvent = "profile config change" + DeletedEvent = "profile deleted" ) func init() { - module = modules.Register("profiles", prep, start, nil, "base", "updates") - module.RegisterEvent(profileConfigChange, true) + module = modules.Register("profiles", prep, start, stop, "base", "updates") + module.RegisterEvent(ConfigChangeEvent, true) + module.RegisterEvent(DeletedEvent, true) } func prep() error { @@ -47,6 +52,14 @@ func start() error { updatesPath += string(os.PathSeparator) } + if err := loadProfilesMetadata(); err != nil { + if !errors.Is(err, database.ErrNotFound) { + log.Warningf("profile: failed to load profiles metadata, falling back to empty state: %s", err) + } + meta = &ProfilesMetadata{} + } + meta.check() + if err := migrations.Migrate(module.Ctx); err != nil { log.Errorf("profile: migrations failed: %s", err) } @@ -73,5 +86,13 @@ func start() error { log.Warningf("profile: error during loading global profile from configuration: %s", err) } + if err := registerAPIEndpoints(); err != nil { + return err + } + return nil } + +func stop() error { + return meta.Save() +} diff --git a/profile/profile.go b/profile/profile.go index c57c52d7d..db309080d 100644 --- a/profile/profile.go +++ b/profile/profile.go @@ -304,6 +304,24 @@ func (profile *Profile) Save() error { 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())