Finalize profile merging, add profile metadata state handling, re-attribute connections after profile deletion

This commit is contained in:
Daniel 2023-09-27 14:23:02 +02:00
parent 32342ec91a
commit bed5c72a6b
14 changed files with 622 additions and 96 deletions

View file

@ -2,13 +2,18 @@ package firewall
import ( import (
"context" "context"
"fmt"
"strings"
"github.com/safing/portbase/config"
"github.com/safing/portbase/log" "github.com/safing/portbase/log"
"github.com/safing/portbase/modules" "github.com/safing/portbase/modules"
"github.com/safing/portbase/modules/subsystems" "github.com/safing/portbase/modules/subsystems"
_ "github.com/safing/portmaster/core" _ "github.com/safing/portmaster/core"
"github.com/safing/portmaster/network" "github.com/safing/portmaster/network"
"github.com/safing/portmaster/profile"
"github.com/safing/spn/access" "github.com/safing/spn/access"
"github.com/safing/spn/captain"
) )
var module *modules.Module var module *modules.Module
@ -25,12 +30,6 @@ func init() {
) )
} }
const (
configChangeEvent = "config change"
profileConfigChangeEvent = "profile config change"
onSPNConnectEvent = "spn connect"
)
func prep() error { func prep() error {
network.SetDefaultFirewallHandler(verdictHandler) network.SetDefaultFirewallHandler(verdictHandler)
@ -38,8 +37,8 @@ func prep() error {
// this will be triggered on spn enable/disable // this will be triggered on spn enable/disable
err := module.RegisterEventHook( err := module.RegisterEventHook(
"config", "config",
configChangeEvent, config.ChangeEvent,
"reset connection verdicts", "reset connection verdicts after global config change",
func(ctx context.Context, _ interface{}) error { func(ctx context.Context, _ interface{}) error {
resetAllConnectionVerdicts() resetAllConnectionVerdicts()
return nil return nil
@ -52,10 +51,20 @@ func prep() error {
// Reset connections every time profile changes // Reset connections every time profile changes
err = module.RegisterEventHook( err = module.RegisterEventHook(
"profiles", "profiles",
profileConfigChangeEvent, profile.ConfigChangeEvent,
"reset connection verdicts", "reset connection verdicts after profile config change",
func(ctx context.Context, _ interface{}) error { func(ctx context.Context, eventData interface{}) error {
resetAllConnectionVerdicts() // 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 return nil
}, },
) )
@ -67,8 +76,8 @@ func prep() error {
// connect and disconnecting is triggered on config change event but connecting takеs more time // connect and disconnecting is triggered on config change event but connecting takеs more time
err = module.RegisterEventHook( err = module.RegisterEventHook(
"captain", "captain",
onSPNConnectEvent, captain.SPNConnectedEvent,
"reset connection verdicts", "reset connection verdicts on SPN connect",
func(ctx context.Context, _ interface{}) error { func(ctx context.Context, _ interface{}) error {
resetAllConnectionVerdicts() resetAllConnectionVerdicts()
return nil return nil
@ -83,7 +92,7 @@ func prep() error {
err = module.RegisterEventHook( err = module.RegisterEventHook(
"access", "access",
access.AccountUpdateEvent, access.AccountUpdateEvent,
"update connection feature flags", "update connection feature flags after account update",
func(ctx context.Context, _ interface{}) error { func(ctx context.Context, _ interface{}) error {
resetAllConnectionVerdicts() resetAllConnectionVerdicts()
return nil return nil
@ -93,6 +102,24 @@ func prep() error {
log.Errorf("filter: failed to register event hook: %s", err) 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 { if err := registerConfig(); err != nil {
return err return err
} }

View file

@ -43,13 +43,55 @@ var (
ownPID = os.Getpid() ownPID = os.Getpid()
) )
func resetAllConnectionVerdicts() { func resetSingleConnectionVerdict(connID string) {
// 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")
// Create tracing context. // Create tracing context.
ctx, tracer := log.AddTracer(context.Background()) 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.
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. // Re-evaluate all connections.
var changedVerdicts int var changedVerdicts int
@ -59,13 +101,22 @@ func resetAllConnectionVerdicts() {
continue continue
} }
func() { if resetConnectionVerdict(ctx, conn) {
changedVerdicts++
}
}
tracer.Infof("filter: changed verdict on %d connections", changedVerdicts)
}
func resetConnectionVerdict(ctx context.Context, conn *network.Connection) (verdictChanged bool) {
tracer := log.Tracer(ctx)
conn.Lock() conn.Lock()
defer conn.Unlock() defer conn.Unlock()
// Update feature flags. // Update feature flags.
if err := conn.UpdateFeatures(); err != nil && !errors.Is(err, access.ErrNotLoggedIn) { if err := conn.UpdateFeatures(); err != nil && !errors.Is(err, access.ErrNotLoggedIn) {
tracer.Warningf("network: failed to update connection feature flags: %s", err) tracer.Warningf("filter: failed to update connection feature flags: %s", err)
} }
// Skip internal connections: // Skip internal connections:
@ -73,8 +124,8 @@ func resetAllConnectionVerdicts() {
// - Redirected DNS requests // - Redirected DNS requests
// - SPN Uplink to Home Hub // - SPN Uplink to Home Hub
if conn.Internal { if conn.Internal {
tracer.Tracef("filter: skipping internal connection %s", conn) // tracer.Tracef("filter: skipping internal connection %s", conn)
return return false
} }
tracer.Debugf("filter: re-evaluating verdict of %s", conn) tracer.Debugf("filter: re-evaluating verdict of %s", conn)
@ -99,14 +150,11 @@ func resetAllConnectionVerdicts() {
} }
conn.Save() conn.Save()
tracer.Infof("filter: verdict of connection %s changed from %s to %s", conn, previousVerdict.Verb(), conn.VerdictVerb()) tracer.Infof("filter: verdict of connection %s changed from %s to %s", conn, previousVerdict.Verb(), conn.VerdictVerb())
changedVerdicts++ return true
} else { }
tracer.Tracef("filter: verdict to connection %s unchanged at %s", conn, conn.VerdictVerb()) tracer.Tracef("filter: verdict to connection %s unchanged at %s", conn, conn.VerdictVerb())
} return false
}()
}
tracer.Infof("filter: changed verdict on %d connections", changedVerdicts)
tracer.Submit()
} }
// SetNameserverIPMatcher sets a function that is used to match the internal // SetNameserverIPMatcher sets a function that is used to match the internal

View file

@ -582,6 +582,11 @@ func GetAllConnections() []*Connection {
return conns.list() 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 // SetLocalIP sets the local IP address together with its network scope. The
// connection is not locked for this. // connection is not locked for this.
func (conn *Connection) SetLocalIP(ip net.IP) { func (conn *Connection) SetLocalIP(ip net.IP) {

View file

@ -1,9 +1,16 @@
package network package network
import ( import (
"context"
"fmt"
"strings"
"sync"
"github.com/safing/portbase/log"
"github.com/safing/portbase/modules" "github.com/safing/portbase/modules"
"github.com/safing/portmaster/netenv" "github.com/safing/portmaster/netenv"
"github.com/safing/portmaster/network/state" "github.com/safing/portmaster/network/state"
"github.com/safing/portmaster/profile"
) )
var ( var (
@ -12,8 +19,14 @@ var (
defaultFirewallHandler FirewallHandler defaultFirewallHandler FirewallHandler
) )
// Events.
var (
ConnectionReattributedEvent = "connection re-attributed"
)
func init() { func init() {
module = modules.Register("network", prep, start, nil, "base", "netenv", "processes") module = modules.Register("network", prep, start, nil, "base", "netenv", "processes")
module.RegisterEvent(ConnectionReattributedEvent, false)
} }
// SetDefaultFirewallHandler sets the default firewall handler. // SetDefaultFirewallHandler sets the default firewall handler.
@ -45,5 +58,95 @@ func start() error {
module.StartServiceWorker("clean connections", 0, connectionCleaner) module.StartServiceWorker("clean connections", 0, connectionCleaner)
module.StartServiceWorker("write open dns requests", 0, openDNSRequestWriter) 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 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)
}

View file

@ -40,6 +40,24 @@ func (p *Process) GetProfile(ctx context.Context) (changed bool, err error) {
return true, nil 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. // getSpecialProfileID returns the special profile ID for the process, if any.
func (p *Process) getSpecialProfileID() (specialProfileID string) { func (p *Process) getSpecialProfileID() (specialProfileID string) {
// Check if we need a special profile. // Check if we need a special profile.

66
profile/api.go Normal file
View file

@ -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
}

View file

@ -16,6 +16,7 @@ import (
// core:profiles/<scope>/<id> // core:profiles/<scope>/<id>
// cache:profiles/index/<identifier>/<value> // cache:profiles/index/<identifier>/<value>
// ProfilesDBPath is the base database path for profiles.
const ProfilesDBPath = "core:profiles/" const ProfilesDBPath = "core:profiles/"
var profileDB = database.NewInterface(&database.Options{ var profileDB = database.NewInterface(&database.Options{
@ -59,8 +60,14 @@ func startProfileUpdateChecker() error {
} }
// Get active profile. // Get active profile.
activeProfile := getActiveProfile(strings.TrimPrefix(r.Key(), ProfilesDBPath)) scopedID := strings.TrimPrefix(r.Key(), ProfilesDBPath)
activeProfile := getActiveProfile(scopedID)
if activeProfile == nil { 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. // Don't do any additional actions if the profile is not active.
continue profileFeed continue profileFeed
} }
@ -74,7 +81,9 @@ func startProfileUpdateChecker() error {
// Always mark as outdated if the record is being deleted. // Always mark as outdated if the record is being deleted.
if r.Meta().IsDeleted() { if r.Meta().IsDeleted() {
activeProfile.outdated.Set() activeProfile.outdated.Set()
module.TriggerEvent(profileConfigChange, nil)
meta.MarkDeleted(scopedID)
module.TriggerEvent(DeletedEvent, scopedID)
continue continue
} }
@ -83,7 +92,7 @@ func startProfileUpdateChecker() error {
receivedProfile, err := EnsureProfile(r) receivedProfile, err := EnsureProfile(r)
if err != nil || !receivedProfile.savedInternally { if err != nil || !receivedProfile.savedInternally {
activeProfile.outdated.Set() activeProfile.outdated.Set()
module.TriggerEvent(profileConfigChange, nil) module.TriggerEvent(ConfigChangeEvent, scopedID)
} }
case <-ctx.Done(): case <-ctx.Done():
return nil return nil
@ -105,6 +114,11 @@ func (h *databaseHook) UsesPrePut() bool {
// PrePut implements the Hook interface. // PrePut implements the Hook interface.
func (h *databaseHook) PrePut(r record.Record) (record.Record, error) { func (h *databaseHook) PrePut(r record.Record) (record.Record, error) {
// Do not intervene with metadata key.
if r.Key() == profilesMetadataKey {
return r, nil
}
// convert // convert
profile, err := EnsureProfile(r) profile, err := EnsureProfile(r)
if err != nil { if err != nil {

View file

@ -202,7 +202,8 @@ func invalidDefinitionError(fields []string, msg string) error {
return fmt.Errorf(`invalid endpoint definition: "%s" - %s`, strings.Join(fields, " "), msg) 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) fields := strings.Fields(value)
if len(fields) < 2 { if len(fields) < 2 {
return nil, fmt.Errorf(`invalid endpoint definition: "%s"`, value) return nil, fmt.Errorf(`invalid endpoint definition: "%s"`, value)

View file

@ -287,6 +287,9 @@ func loadProfile(r record.Record) (*Profile, error) {
// Set saved internally to suppress outdating profiles if saving internally. // Set saved internally to suppress outdating profiles if saving internally.
profile.savedInternally = true profile.savedInternally = true
// Mark as recently seen.
meta.UpdateLastSeen(profile.ScopedID())
// return parsed profile // return parsed profile
return profile, nil return profile, nil
} }

View file

@ -1,8 +1,10 @@
package profile package profile
import ( import (
"errors"
"fmt" "fmt"
"sync" "sync"
"time"
"github.com/safing/portbase/database/record" "github.com/safing/portbase/database/record"
) )
@ -11,19 +13,42 @@ import (
// The new profile is saved and returned. // The new profile is saved and returned.
// Only the icon and fingerprints are inherited from other profiles. // Only the icon and fingerprints are inherited from other profiles.
// All other information is taken only from the primary profile. // 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. // Fill info from primary profile.
nowUnix := time.Now().Unix()
newProfile = &Profile{ newProfile = &Profile{
Base: record.Base{}, Base: record.Base{},
RWMutex: sync.RWMutex{}, RWMutex: sync.RWMutex{},
ID: "", // Omit ID to derive it from the new fingerprints. ID: "", // Omit ID to derive it from the new fingerprints.
Source: primary.Source, Source: primary.Source,
Name: primary.Name, Name: name,
Description: primary.Description, Description: primary.Description,
Homepage: primary.Homepage, Homepage: primary.Homepage,
UsePresentationPath: false, // Disable presentation path. UsePresentationPath: false, // Disable presentation path.
SecurityLevel: primary.SecurityLevel, SecurityLevel: primary.SecurityLevel,
Config: primary.Config, 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. // Collect all icons.
@ -35,7 +60,7 @@ func MergeProfiles(primary *Profile, secondaries ...*Profile) (newProfile *Profi
newProfile.Icons = sortAndCompactIcons(newProfile.Icons) newProfile.Icons = sortAndCompactIcons(newProfile.Icons)
// Collect all fingerprints. // 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()) newProfile.Fingerprints = addFingerprints(newProfile.Fingerprints, primary.Fingerprints, primary.ScopedID())
for _, sp := range secondaries { for _, sp := range secondaries {
newProfile.Fingerprints = addFingerprints(newProfile.Fingerprints, sp.Fingerprints, sp.ScopedID()) newProfile.Fingerprints = addFingerprints(newProfile.Fingerprints, sp.Fingerprints, sp.ScopedID())
@ -44,26 +69,19 @@ func MergeProfiles(primary *Profile, secondaries ...*Profile) (newProfile *Profi
// Save new profile. // Save new profile.
newProfile = New(newProfile) newProfile = New(newProfile)
err = newProfile.Save() if err := newProfile.Save(); err != nil {
if err != nil {
return nil, fmt.Errorf("failed to save merged profile: %w", err) return nil, fmt.Errorf("failed to save merged profile: %w", err)
} }
// FIXME: Should we ... ?
// newProfile.updateMetadata()
// newProfile.updateMetadataFromSystem()
// Delete all previous profiles. // Delete all previous profiles.
// FIXME: if err := primary.delete(); err != nil {
/* return nil, fmt.Errorf("failed to delete primary profile %s: %w", primary.ScopedID(), err)
primary.Meta().Delete() }
// Set as outdated and remove from active profiles. for _, sp := range secondaries {
// Signify that profile was deleted and save for sync. if err := sp.delete(); err != nil {
for _, sp := range secondaries { return nil, fmt.Errorf("failed to delete secondary profile %s: %w", sp.ScopedID(), err)
sp.Meta().Delete() }
// Set as outdated and remove from active profiles.
// Signify that profile was deleted and save for sync.
} }
*/
return newProfile, nil return newProfile, nil
} }

184
profile/meta.go Normal file
View file

@ -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)
}

View file

@ -32,11 +32,11 @@ func registerMigrations() error {
Version: "v1.4.7", Version: "v1.4.7",
MigrateFunc: migrateIcons, MigrateFunc: migrateIcons,
}, },
// migration.Migration{ migration.Migration{
// Description: "Migrate from random profile IDs to fingerprint-derived IDs", Description: "Migrate from random profile IDs to fingerprint-derived IDs",
// Version: "v1.5.1", Version: "v1.5.0",
// MigrateFunc: migrateToDerivedIDs, MigrateFunc: migrateToDerivedIDs,
// }, },
) )
} }

View file

@ -1,8 +1,10 @@
package profile package profile
import ( import (
"errors"
"os" "os"
"github.com/safing/portbase/database"
"github.com/safing/portbase/database/migration" "github.com/safing/portbase/database/migration"
"github.com/safing/portbase/log" "github.com/safing/portbase/log"
"github.com/safing/portbase/modules" "github.com/safing/portbase/modules"
@ -16,13 +18,16 @@ var (
updatesPath string updatesPath string
) )
// Events.
const ( const (
profileConfigChange = "profile config change" ConfigChangeEvent = "profile config change"
DeletedEvent = "profile deleted"
) )
func init() { func init() {
module = modules.Register("profiles", prep, start, nil, "base", "updates") module = modules.Register("profiles", prep, start, stop, "base", "updates")
module.RegisterEvent(profileConfigChange, true) module.RegisterEvent(ConfigChangeEvent, true)
module.RegisterEvent(DeletedEvent, true)
} }
func prep() error { func prep() error {
@ -47,6 +52,14 @@ func start() error {
updatesPath += string(os.PathSeparator) 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 { if err := migrations.Migrate(module.Ctx); err != nil {
log.Errorf("profile: migrations failed: %s", err) 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) log.Warningf("profile: error during loading global profile from configuration: %s", err)
} }
if err := registerAPIEndpoints(); err != nil {
return err
}
return nil return nil
} }
func stop() error {
return meta.Save()
}

View file

@ -304,6 +304,24 @@ func (profile *Profile) Save() error {
return profileDB.Put(profile) 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. // MarkStillActive marks the profile as still active.
func (profile *Profile) MarkStillActive() { func (profile *Profile) MarkStillActive() {
atomic.StoreInt64(profile.lastActive, time.Now().Unix()) atomic.StoreInt64(profile.lastActive, time.Now().Unix())