diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..f6150ead --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 00000000..fd52cfbc --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,72 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ "develop", master ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "develop" ] + schedule: + - cron: '43 14 * * 4' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'go' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 diff --git a/README.md b/README.md index 621b7007..92d99b2e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Take Back Control of Your Computer +# Control Your Computer,
[Get Peace of Mind](https://safing.io/portmaster/) Portmaster is a free and open-source application that puts you back in charge over all your computer's network connections. Developed in the EU đŸ‡ĒđŸ‡ē, Austria. diff --git a/broadcasts/api.go b/broadcasts/api.go new file mode 100644 index 00000000..f855ddfc --- /dev/null +++ b/broadcasts/api.go @@ -0,0 +1,116 @@ +package broadcasts + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/safing/portbase/api" + "github.com/safing/portbase/database" + "github.com/safing/portbase/database/accessor" +) + +func registerAPIEndpoints() error { + if err := api.RegisterEndpoint(api.Endpoint{ + Path: `broadcasts/matching-data`, + Read: api.PermitAdmin, + BelongsTo: module, + StructFunc: handleMatchingData, + Name: "Get Broadcast Notifications Matching Data", + Description: "Returns the data used by the broadcast notifications to match the instance.", + }); err != nil { + return err + } + + if err := api.RegisterEndpoint(api.Endpoint{ + Path: `broadcasts/reset-state`, + Write: api.PermitAdmin, + WriteMethod: http.MethodPost, + BelongsTo: module, + ActionFunc: handleResetState, + Name: "Resets the Broadcast Notification States", + Description: "Delete the cache of Broadcast Notifications, making them appear again.", + }); err != nil { + return err + } + + if err := api.RegisterEndpoint(api.Endpoint{ + Path: `broadcasts/simulate`, + Write: api.PermitAdmin, + WriteMethod: http.MethodPost, + BelongsTo: module, + ActionFunc: handleSimulate, + Name: "Simulate Broadcast Notifications", + Description: "Test broadcast notifications by sending a valid source file in the body.", + Parameters: []api.Parameter{ + { + Method: http.MethodPost, + Field: "state", + Value: "true", + Description: "Check against state when deciding to display a broadcast notification. Acknowledgements are always saved.", + }, + }, + }); err != nil { + return err + } + + return nil +} + +func handleMatchingData(ar *api.Request) (i interface{}, err error) { + return collectData(), nil +} + +func handleResetState(ar *api.Request) (msg string, err error) { + err = db.Delete(broadcastStatesDBKey) + if err != nil { + return "", err + } + return "Reset complete.", nil +} + +func handleSimulate(ar *api.Request) (msg string, err error) { + // Parse broadcast notification data. + broadcasts, err := parseBroadcastSource(ar.InputData) + if err != nil { + return "", fmt.Errorf("failed to parse broadcast notifications update: %w", err) + } + + // Get and marshal matching data. + matchingData := collectData() + matchingJSON, err := json.Marshal(matchingData) + if err != nil { + return "", fmt.Errorf("failed to marshal broadcast notifications matching data: %w", err) + } + matchingDataAccessor := accessor.NewJSONBytesAccessor(&matchingJSON) + + var bss *BroadcastStates + if ar.URL.Query().Get("state") == "true" { + // Get broadcast notification states. + bss, err = getBroadcastStates() + if err != nil { + if !errors.Is(err, database.ErrNotFound) { + return "", fmt.Errorf("failed to get broadcast notifications states: %w", err) + } + bss = newBroadcastStates() + } + } + + // Go through all broadcast nofications and check if they match. + var results []string + for _, bn := range broadcasts.Notifications { + err := handleBroadcast(bn, matchingDataAccessor, bss) + switch { + case err == nil: + results = append(results, fmt.Sprintf("%30s: displayed", bn.id)) + case errors.Is(err, ErrSkip): + results = append(results, fmt.Sprintf("%30s: %s", bn.id, err)) + default: + results = append(results, fmt.Sprintf("FAILED %23s: %s", bn.id, err)) + } + } + + return strings.Join(results, "\n"), nil +} diff --git a/broadcasts/data.go b/broadcasts/data.go new file mode 100644 index 00000000..73da1bb9 --- /dev/null +++ b/broadcasts/data.go @@ -0,0 +1,102 @@ +package broadcasts + +import ( + "time" + + "github.com/safing/portbase/config" + "github.com/safing/portmaster/intel/geoip" + "github.com/safing/portmaster/netenv" + "github.com/safing/portmaster/updates" + "github.com/safing/spn/access" + "github.com/safing/spn/captain" +) + +var portmasterStarted = time.Now() + +func collectData() interface{} { + data := make(map[string]interface{}) + + // Get data about versions. + versions := updates.GetSimpleVersions() + data["Updates"] = versions + data["Version"] = versions.Build.Version + numericVersion, err := MakeNumericVersion(versions.Build.Version) + if err != nil { + data["NumericVersion"] = &DataError{ + Error: err, + } + } else { + data["NumericVersion"] = numericVersion + } + + // Get data about install. + installInfo, err := GetInstallInfo() + if err != nil { + data["Install"] = &DataError{ + Error: err, + } + } else { + data["Install"] = installInfo + } + + // Get global configuration. + data["Config"] = config.GetActiveConfigValues() + + // Get data about device location. + locs, ok := netenv.GetInternetLocation() + if ok && locs.Best().LocationOrNil() != nil { + loc := locs.Best() + data["Location"] = &Location{ + Country: loc.Location.Country.ISOCode, + Coordinates: loc.Location.Coordinates, + ASN: loc.Location.AutonomousSystemNumber, + ASOrg: loc.Location.AutonomousSystemOrganization, + Source: loc.Source, + SourceAccuracy: loc.SourceAccuracy, + } + } + + // Get data about SPN status. + data["SPN"] = captain.GetSPNStatus() + + // Get data about account. + userRecord, err := access.GetUser() + if err != nil { + data["Account"] = &DataError{ + Error: err, + } + } else { + data["Account"] = &Account{ + UserRecord: userRecord, + UpToDate: userRecord.Meta().Modified > time.Now().Add(-7*24*time.Hour).Unix(), + MayUseUSP: userRecord.MayUseSPN(), + } + } + + // Time running. + data["UptimeHours"] = int(time.Since(portmasterStarted).Hours()) + + return data +} + +// Location holds location matching data. +type Location struct { + Country string + Coordinates geoip.Coordinates + ASN uint + ASOrg string + Source netenv.DeviceLocationSource + SourceAccuracy int +} + +// Account holds SPN account matching data. +type Account struct { + *access.UserRecord + UpToDate bool + MayUseUSP bool +} + +// DataError represents an error getting some matching data. +type DataError struct { + Error error +} diff --git a/broadcasts/install_info.go b/broadcasts/install_info.go new file mode 100644 index 00000000..2f667a17 --- /dev/null +++ b/broadcasts/install_info.go @@ -0,0 +1,175 @@ +package broadcasts + +import ( + "errors" + "fmt" + "strconv" + "sync" + "time" + + semver "github.com/hashicorp/go-version" + + "github.com/safing/portbase/database" + "github.com/safing/portbase/database/query" + "github.com/safing/portbase/database/record" + "github.com/safing/portbase/info" + "github.com/safing/portbase/log" +) + +const installInfoDBKey = "core:status/install-info" + +// InstallInfo holds generic info about the install. +type InstallInfo struct { + record.Base + sync.Mutex + + Version string + NumericVersion int64 + + Time time.Time + NumericDate int64 + DaysSinceInstall int64 + UnixTimestamp int64 +} + +// GetInstallInfo returns the install info from the database. +func GetInstallInfo() (*InstallInfo, error) { + r, err := db.Get(installInfoDBKey) + if err != nil { + return nil, err + } + + // Unwrap. + if r.IsWrapped() { + // Only allocate a new struct, if we need it. + newRecord := &InstallInfo{} + err = record.Unwrap(r, newRecord) + if err != nil { + return nil, err + } + return newRecord, nil + } + + // or adjust type + newRecord, ok := r.(*InstallInfo) + if !ok { + return nil, fmt.Errorf("record not of type *InstallInfo, but %T", r) + } + return newRecord, nil +} + +func ensureInstallInfo() { + // Get current install info from database. + installInfo, err := GetInstallInfo() + if err != nil { + installInfo = &InstallInfo{} + if !errors.Is(err, database.ErrNotFound) { + log.Warningf("updates: failed to load install info: %s", err) + } + } + + // Fill in missing data and save. + installInfo.checkAll() + if err := installInfo.save(); err != nil { + log.Warningf("updates: failed to save install info: %s", err) + } +} + +func (ii *InstallInfo) save() error { + if !ii.KeyIsSet() { + ii.SetKey(installInfoDBKey) + } + return db.Put(ii) +} + +func (ii *InstallInfo) checkAll() { + ii.checkVersion() + ii.checkInstallDate() +} + +func (ii *InstallInfo) checkVersion() { + // Check if everything is present. + if ii.Version != "" && ii.NumericVersion > 0 { + return + } + + // Update version information. + versionInfo := info.GetInfo() + ii.Version = versionInfo.Version + + // Update numeric version. + if versionInfo.Version != "" { + numericVersion, err := MakeNumericVersion(versionInfo.Version) + if err != nil { + log.Warningf("updates: failed to make numeric version: %s", err) + } else { + ii.NumericVersion = numericVersion + } + } +} + +// MakeNumericVersion makes a numeric version with the first three version +// segment always using three digits. +func MakeNumericVersion(version string) (numericVersion int64, err error) { + // Parse version string. + ver, err := semver.NewVersion(version) + if err != nil { + return 0, fmt.Errorf("failed to parse core version: %w", err) + } + + // Transform version for numeric representation. + segments := ver.Segments() + for i := 0; i < 3 && i < len(segments); i++ { + segmentNumber := int64(segments[i]) + if segmentNumber > 999 { + segmentNumber = 999 + } + switch i { + case 0: + numericVersion += segmentNumber * 1000000 + case 1: + numericVersion += segmentNumber * 1000 + case 2: + numericVersion += segmentNumber + } + } + + return numericVersion, nil +} + +func (ii *InstallInfo) checkInstallDate() { + // Check if everything is present. + if ii.UnixTimestamp > 0 && + ii.NumericDate > 0 && + ii.DaysSinceInstall > 0 && + !ii.Time.IsZero() { + return + } + + // Find oldest created database entry and use it as install time. + oldest := time.Now().Unix() + it, err := db.Query(query.New("core")) + if err != nil { + log.Warningf("updates: failed to create iterator for searching DB for install time: %s", err) + return + } + defer it.Cancel() + for r := range it.Next { + if oldest > r.Meta().Created { + oldest = r.Meta().Created + } + } + + // Set data. + ii.UnixTimestamp = oldest + ii.Time = time.Unix(oldest, 0) + ii.DaysSinceInstall = int64(time.Since(ii.Time).Hours()) / 24 + + // Transform date for numeric representation. + numericDate, err := strconv.ParseInt(ii.Time.Format("20060102"), 10, 64) + if err != nil { + log.Warningf("updates: failed to make numeric date from %s: %s", ii.Time, err) + } else { + ii.NumericDate = numericDate + } +} diff --git a/broadcasts/module.go b/broadcasts/module.go new file mode 100644 index 00000000..360bc912 --- /dev/null +++ b/broadcasts/module.go @@ -0,0 +1,46 @@ +package broadcasts + +import ( + "sync" + "time" + + "github.com/safing/portbase/database" + "github.com/safing/portbase/modules" +) + +var ( + module *modules.Module + + db = database.NewInterface(&database.Options{ + Local: true, + Internal: true, + }) + + startOnce sync.Once +) + +func init() { + module = modules.Register("broadcasts", prep, start, nil, "updates", "netenv", "notifications") +} + +func prep() error { + // Register API endpoints. + if err := registerAPIEndpoints(); err != nil { + return err + } + + return nil +} + +func start() error { + // Ensure the install info is up to date. + ensureInstallInfo() + + // Start broadcast notifier task. + startOnce.Do(func() { + module.NewTask("broadcast notifier", broadcastNotify). + Repeat(10 * time.Minute).Queue() + }) + + return nil +} diff --git a/broadcasts/notify.go b/broadcasts/notify.go new file mode 100644 index 00000000..c7b7d661 --- /dev/null +++ b/broadcasts/notify.go @@ -0,0 +1,285 @@ +package broadcasts + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "strings" + "sync" + "time" + + "github.com/ghodss/yaml" + + "github.com/safing/portbase/database" + "github.com/safing/portbase/database/accessor" + "github.com/safing/portbase/database/query" + "github.com/safing/portbase/log" + "github.com/safing/portbase/modules" + "github.com/safing/portbase/notifications" + "github.com/safing/portmaster/updates" +) + +const ( + broadcastsResourcePath = "intel/portmaster/notifications.yaml" + + broadcastNotificationIDPrefix = "broadcasts:" + + minRepeatDuration = 1 * time.Hour +) + +// Errors. +var ( + ErrSkip = errors.New("broadcast skipped") + ErrSkipDoesNotMatch = fmt.Errorf("%w: does not match", ErrSkip) + ErrSkipAlreadyActive = fmt.Errorf("%w: already active", ErrSkip) + ErrSkipAlreadyShown = fmt.Errorf("%w: already shown", ErrSkip) + ErrSkipRemovedByMismatch = fmt.Errorf("%w: removed due to mismatch", ErrSkip) + ErrSkipRemovedBySource = fmt.Errorf("%w: removed by source", ErrSkip) +) + +// BroadcastNotifications holds the data structure of the broadcast +// notifications update file. +type BroadcastNotifications struct { + Notifications map[string]*BroadcastNotification +} + +// BroadcastNotification is a single broadcast notification. +type BroadcastNotification struct { + *notifications.Notification + id string + + // Match holds a query string that needs to match the local matching data in + // order for the broadcast to be displayed. + Match string + matchingQuery *query.Query + // AttachToModule signifies if the broadcast notification should be attached to the module. + AttachToModule bool + // Remove signifies that the broadcast should be canceled and its state removed. + Remove bool + // Permanent signifies that the broadcast cannot be acknowledge by the user + // and remains in the UI indefinitely. + Permanent bool + // Repeat specifies a duration after which the broadcast should be shown again. + Repeat string + repeatDuration time.Duration +} + +func broadcastNotify(ctx context.Context, t *modules.Task) error { + // Get broadcast notifications file, load it from disk and parse it. + broadcastsResource, err := updates.GetFile(broadcastsResourcePath) + if err != nil { + return fmt.Errorf("failed to get broadcast notifications update: %w", err) + } + broadcastsData, err := ioutil.ReadFile(broadcastsResource.Path()) + if err != nil { + return fmt.Errorf("failed to load broadcast notifications update: %w", err) + } + broadcasts, err := parseBroadcastSource(broadcastsData) + if err != nil { + return fmt.Errorf("failed to parse broadcast notifications update: %w", err) + } + + // Get and marshal matching data. + matchingData := collectData() + matchingJSON, err := json.Marshal(matchingData) + if err != nil { + return fmt.Errorf("failed to marshal broadcast notifications matching data: %w", err) + } + matchingDataAccessor := accessor.NewJSONBytesAccessor(&matchingJSON) + + // Get broadcast notification states. + bss, err := getBroadcastStates() + if err != nil { + if !errors.Is(err, database.ErrNotFound) { + return fmt.Errorf("failed to get broadcast notifications states: %w", err) + } + bss = newBroadcastStates() + } + + // Go through all broadcast nofications and check if they match. + for _, bn := range broadcasts.Notifications { + err := handleBroadcast(bn, matchingDataAccessor, bss) + switch { + case err == nil: + log.Infof("broadcasts: displaying broadcast %s", bn.id) + case errors.Is(err, ErrSkip): + log.Tracef("broadcasts: skipped displaying broadcast %s: %s", bn.id, err) + default: + log.Warningf("broadcasts: failed to handle broadcast %s: %s", bn.id, err) + } + } + + return nil +} + +func parseBroadcastSource(yamlData []byte) (*BroadcastNotifications, error) { + // Parse data. + broadcasts := &BroadcastNotifications{} + err := yaml.Unmarshal(yamlData, broadcasts) + if err != nil { + return nil, err + } + + // Add IDs to struct for easier handling. + for id, bn := range broadcasts.Notifications { + bn.id = id + + // Parse matching query. + if bn.Match != "" { + q, err := query.ParseQuery("query / where " + bn.Match) + if err != nil { + return nil, fmt.Errorf("failed to parse query of broadcast notification %s: %w", bn.id, err) + } + bn.matchingQuery = q + } + + // Parse the repeat duration. + if bn.Repeat != "" { + duration, err := time.ParseDuration(bn.Repeat) + if err != nil { + return nil, fmt.Errorf("failed to parse repeat duration of broadcast notification %s: %w", bn.id, err) + } + bn.repeatDuration = duration + // Raise duration to minimum. + if bn.repeatDuration < minRepeatDuration { + bn.repeatDuration = minRepeatDuration + } + } + } + + return broadcasts, nil +} + +func handleBroadcast(bn *BroadcastNotification, matchingDataAccessor accessor.Accessor, bss *BroadcastStates) error { + // Check if broadcast was already shown. + if bss != nil { + state, ok := bss.States[bn.id] + switch { + case !ok || state.Read.IsZero(): + // Was never shown, continue. + case bn.repeatDuration == 0 && !state.Read.IsZero(): + // Was already shown and is not repeated, skip. + return ErrSkipAlreadyShown + case bn.repeatDuration > 0 && time.Now().Add(-bn.repeatDuration).After(state.Read): + // Was already shown, but should be repeated now, continue. + } + } + + // Check if broadcast should be removed. + if bn.Remove { + removeBroadcast(bn, bss) + return ErrSkipRemovedBySource + } + + // Skip if broadcast does not match. + if bn.matchingQuery != nil && !bn.matchingQuery.MatchesAccessor(matchingDataAccessor) { + removed := removeBroadcast(bn, bss) + if removed { + return ErrSkipRemovedByMismatch + } + return ErrSkipDoesNotMatch + } + + // Check if there is already an active notification for this. + eventID := broadcastNotificationIDPrefix + bn.id + n := notifications.Get(eventID) + if n != nil { + // Already active! + return ErrSkipAlreadyActive + } + + // Prepare notification for displaying. + n = bn.Notification + n.EventID = eventID + n.GUID = "" + n.State = "" + n.SelectedActionID = "" + + // It is okay to edit the notification, as they are loaded from the file every time. + // Add dismiss button if the notification is not permanent. + if !bn.Permanent { + n.AvailableActions = append(n.AvailableActions, ¬ifications.Action{ + ID: "ack", + Text: "Got it!", + }) + } + n.SetActionFunction(markBroadcastAsRead) + + // Display notification. + n.Save() + + // Attach to module to raise more awareness. + if bn.AttachToModule { + n.AttachToModule(module) + } + + return nil +} + +func removeBroadcast(bn *BroadcastNotification, bss *BroadcastStates) (removed bool) { + // Remove any active notification. + n := notifications.Get(broadcastNotificationIDPrefix + bn.id) + if n != nil { + removed = true + n.Delete() + } + + // Remove any state. + if bss != nil { + delete(bss.States, bn.id) + } + + return +} + +var savingBroadcastStateLock sync.Mutex + +func markBroadcastAsRead(ctx context.Context, n *notifications.Notification) error { + // Lock persisting broadcast state. + savingBroadcastStateLock.Lock() + defer savingBroadcastStateLock.Unlock() + + // Get notification data. + var broadcastID, actionID string + func() { + n.Lock() + defer n.Unlock() + broadcastID = strings.TrimPrefix(n.EventID, broadcastNotificationIDPrefix) + actionID = n.SelectedActionID + }() + + // Check response. + switch actionID { + case "ack": + case "": + return fmt.Errorf("no action ID for %s", broadcastID) + default: + return fmt.Errorf("unexpected action ID for %s: %s", broadcastID, actionID) + } + + // Get broadcast notification states. + bss, err := getBroadcastStates() + if err != nil { + if !errors.Is(err, database.ErrNotFound) { + return fmt.Errorf("failed to get broadcast notifications states: %w", err) + } + bss = newBroadcastStates() + } + + // Get state for this notification. + bs, ok := bss.States[broadcastID] + if !ok { + bs = &BroadcastState{} + bss.States[broadcastID] = bs + } + + // Delete to allow for timely repeats. + n.Delete() + + // Mark as read and save to DB. + log.Infof("broadcasts: user acknowledged broadcast %s", broadcastID) + bs.Read = time.Now() + return bss.save() +} diff --git a/broadcasts/state.go b/broadcasts/state.go new file mode 100644 index 00000000..afe8994c --- /dev/null +++ b/broadcasts/state.go @@ -0,0 +1,64 @@ +package broadcasts + +import ( + "fmt" + "sync" + "time" + + "github.com/safing/portbase/database/record" +) + +const broadcastStatesDBKey = "core:broadcasts/state" + +// BroadcastStates holds states for broadcast notifications. +type BroadcastStates struct { + record.Base + sync.Mutex + + States map[string]*BroadcastState +} + +// BroadcastState holds state for a single broadcast notifications. +type BroadcastState struct { + Read time.Time +} + +func (bss *BroadcastStates) save() error { + return db.Put(bss) +} + +// getbroadcastStates returns the broadcast states from the database. +func getBroadcastStates() (*BroadcastStates, error) { + r, err := db.Get(broadcastStatesDBKey) + if err != nil { + return nil, err + } + + // Unwrap. + if r.IsWrapped() { + // Only allocate a new struct, if we need it. + newRecord := &BroadcastStates{} + err = record.Unwrap(r, newRecord) + if err != nil { + return nil, err + } + return newRecord, nil + } + + // or adjust type + newRecord, ok := r.(*BroadcastStates) + if !ok { + return nil, fmt.Errorf("record not of type *BroadcastStates, but %T", r) + } + return newRecord, nil +} + +// newBroadcastStates returns a new BroadcastStates. +func newBroadcastStates() *BroadcastStates { + bss := &BroadcastStates{ + States: make(map[string]*BroadcastState), + } + bss.SetKey(broadcastStatesDBKey) + + return bss +} diff --git a/broadcasts/testdata/README.md b/broadcasts/testdata/README.md new file mode 100644 index 00000000..1752a7f6 --- /dev/null +++ b/broadcasts/testdata/README.md @@ -0,0 +1,9 @@ +# Testing Broadcast Notifications + +``` +# Reset state +curl -X POST http://127.0.0.1:817/api/v1/broadcasts/reset-state + +# Simulate notifications +curl --upload-file notifications.yaml http://127.0.0.1:817/api/v1/broadcasts/simulate +``` diff --git a/broadcasts/testdata/notifications.yaml b/broadcasts/testdata/notifications.yaml new file mode 100644 index 00000000..6c60d6b5 --- /dev/null +++ b/broadcasts/testdata/notifications.yaml @@ -0,0 +1,22 @@ +notifications: + test1: + title: "[TEST] Normal Broadcast" + message: "This is a normal broadcast without matching. (#1)" + test2: + title: "[TEST] Permanent Broadcast" + message: "This is a permanent broadcast without matching. (#2)" + type: 1 # Warning + permanent: true + test3: + title: "[TEST] Repeating Broadcast" + message: "This is a repeating broadcast without matching. (#3)" + repeat: "1m" + test4: + title: "[TEST] Matching Broadcast: PM version" + message: "This is a normal broadcast that matches the PM version. (#4)" + match: "NumericVersion > 8000" + test5: + title: "[TEST] Important Update" + message: "A criticial update has been released, please update immediately. (#5)" + type: 3 # Error + attachToModule: true diff --git a/cmds/portmaster-start/logs.go b/cmds/portmaster-start/logs.go index d14f1250..09fa01c6 100644 --- a/cmds/portmaster-start/logs.go +++ b/cmds/portmaster-start/logs.go @@ -17,7 +17,7 @@ import ( ) func initializeLogFile(logFilePath string, identifier string, version string) *os.File { - logFile, err := os.OpenFile(logFilePath, os.O_RDWR|os.O_CREATE, 0o0444) + logFile, err := os.OpenFile(logFilePath, os.O_RDWR|os.O_CREATE, 0o0440) if err != nil { log.Printf("failed to create log file %s: %s\n", logFilePath, err) return nil diff --git a/core/core.go b/core/core.go index ec2431b6..42645f0f 100644 --- a/core/core.go +++ b/core/core.go @@ -8,7 +8,7 @@ import ( "github.com/safing/portbase/modules" "github.com/safing/portbase/modules/subsystems" "github.com/safing/portmaster/updates" - + _ "github.com/safing/portmaster/broadcasts" _ "github.com/safing/portmaster/netenv" _ "github.com/safing/portmaster/netquery" _ "github.com/safing/portmaster/status" @@ -27,7 +27,7 @@ var ( ) func init() { - module = modules.Register("core", prep, start, nil, "base", "subsystems", "status", "updates", "api", "notifications", "ui", "netenv", "network", "interception", "compat", "netquery") + module = modules.Register("core", prep, start, nil, "base", "subsystems", "status", "updates", "api", "notifications", "ui", "netenv", "network", "netquery", "interception", "compat", "broadcasts") subsystems.Register( "core", "Core", diff --git a/firewall/dns.go b/firewall/dns.go index cb78e5c4..d8c4b582 100644 --- a/firewall/dns.go +++ b/firewall/dns.go @@ -263,7 +263,7 @@ func UpdateIPsAndCNAMEs(q *resolver.Query, rrCache *resolver.RRCache, conn *netw // Package IPs and CNAMEs into IPInfo structs. for _, ip := range ips { // Never save domain attributions for localhost IPs. - if netutils.ClassifyIP(ip) == netutils.HostLocal { + if netutils.GetIPScope(ip) == netutils.HostLocal { continue } diff --git a/firewall/interception/nfq/packet.go b/firewall/interception/nfq/packet.go index 46ca3ea5..528f2e3c 100644 --- a/firewall/interception/nfq/packet.go +++ b/firewall/interception/nfq/packet.go @@ -141,6 +141,13 @@ func (pkt *packet) Drop() error { } func (pkt *packet) PermanentAccept() error { + // If the packet is localhost only, do not permanently accept the outgoing + // packet, as the packet mark will be copied to the connection mark, which + // will stick and it will bypass the incoming queue. + if !pkt.Info().Inbound && pkt.Info().Dst.IsLoopback() { + return pkt.Accept() + } + return pkt.mark(MarkAcceptAlways) } diff --git a/firewall/interception/nfqueue_linux.go b/firewall/interception/nfqueue_linux.go index f117cd67..9d731648 100644 --- a/firewall/interception/nfqueue_linux.go +++ b/firewall/interception/nfqueue_linux.go @@ -10,8 +10,8 @@ import ( "github.com/hashicorp/go-multierror" "github.com/safing/portbase/log" - "github.com/safing/portbase/notifications" "github.com/safing/portmaster/firewall/interception/nfq" + "github.com/safing/portmaster/netenv" "github.com/safing/portmaster/network/packet" ) @@ -141,13 +141,10 @@ func activateNfqueueFirewall() error { return err } - if err := activateIPTables(iptables.ProtocolIPv6, v6rules, v6once, v6chains); err != nil { - notifications.NotifyError( - "interception:ipv6-possibly-disabled", - "Is IPv6 enabled?", - "The Portmaster succeeded with IPv4 network integration, but failed with IPv6 integration. Please make sure IPv6 is enabled on your device.", - ) - return err + if netenv.IPv6Enabled() { + if err := activateIPTables(iptables.ProtocolIPv6, v6rules, v6once, v6chains); err != nil { + return err + } } return nil @@ -163,8 +160,10 @@ func DeactivateNfqueueFirewall() error { } // IPv6 - if err := deactivateIPTables(iptables.ProtocolIPv6, v6once, v6chains); err != nil { - result = multierror.Append(result, err) + if netenv.IPv6Enabled() { + if err := deactivateIPTables(iptables.ProtocolIPv6, v6once, v6chains); err != nil { + result = multierror.Append(result, err) + } } return result.ErrorOrNil() @@ -264,15 +263,22 @@ func StartNfqueueInterception(packets chan<- packet.Packet) (err error) { _ = Stop() return fmt.Errorf("nfqueue(IPv4, in): %w", err) } - out6Queue, err = nfq.New(17060, true) - if err != nil { - _ = Stop() - return fmt.Errorf("nfqueue(IPv6, out): %w", err) - } - in6Queue, err = nfq.New(17160, true) - if err != nil { - _ = Stop() - return fmt.Errorf("nfqueue(IPv6, in): %w", err) + + if netenv.IPv6Enabled() { + out6Queue, err = nfq.New(17060, true) + if err != nil { + _ = Stop() + return fmt.Errorf("nfqueue(IPv6, out): %w", err) + } + in6Queue, err = nfq.New(17160, true) + if err != nil { + _ = Stop() + return fmt.Errorf("nfqueue(IPv6, in): %w", err) + } + } else { + log.Warningf("interception: no IPv6 stack detected, disabling IPv6 network integration") + out6Queue = &disabledNfQueue{} + in6Queue = &disabledNfQueue{} } go handleInterception(packets) @@ -327,3 +333,11 @@ func handleInterception(packets chan<- packet.Packet) { } } } + +type disabledNfQueue struct{} + +func (dnfq *disabledNfQueue) PacketChannel() <-chan packet.Packet { + return nil +} + +func (dnfq *disabledNfQueue) Destroy() {} diff --git a/go.mod b/go.mod index 9fd7c7b5..95a6a374 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/cookieo9/resources-go v0.0.0-20150225115733-d27c04069d0d github.com/coreos/go-iptables v0.6.0 github.com/florianl/go-nfqueue v1.3.1 + github.com/ghodss/yaml v1.0.0 github.com/godbus/dbus/v5 v5.1.0 github.com/google/gopacket v1.1.19 github.com/hashicorp/go-multierror v1.1.1 @@ -25,6 +26,13 @@ require ( golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e zombiezen.com/go/sqlite v0.10.0 + github.com/stretchr/testify v1.8.0 + github.com/tannerryan/ring v1.1.2 + github.com/tevino/abool v1.2.0 + github.com/umahmood/haversine v0.0.0-20151105152445-808ab04add26 + golang.org/x/net v0.0.0-20220622184535-263ec571b305 + golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f + golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664 ) require ( @@ -36,7 +44,6 @@ require ( github.com/bluele/gcache v0.0.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fxamacker/cbor/v2 v2.4.0 // indirect - github.com/ghodss/yaml v1.0.0 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/gofrs/uuid v4.2.0+incompatible // indirect github.com/google/go-cmp v0.5.8 // indirect diff --git a/go.sum b/go.sum index 1790fa2a..168ff329 100644 --- a/go.sum +++ b/go.sum @@ -930,6 +930,7 @@ github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= @@ -937,8 +938,9 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tannerryan/ring v1.1.2 h1:iXayOjqHQOLzuy9GwSKuG3nhWfzQkldMlQivcgIr7gQ= github.com/tannerryan/ring v1.1.2/go.mod h1:DkELJEjbZhJBtFKR9Xziwj3HKZnb/knRgljNqp65vH4= @@ -1228,8 +1230,6 @@ golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220513224357-95641704303c/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220621193019-9d032be2e588/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220708220712-1185a9018129 h1:vucSRfWwTsoXro7P+3Cjlr6flUMtzCwzlvkxEQtHHB0= -golang.org/x/net v0.0.0-20220708220712-1185a9018129/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1391,8 +1391,6 @@ golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220513210249-45d2b4557a2a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e h1:NHvCuwuS43lGnYhten69ZWqi2QOj/CiDNcKbVqwVoew= -golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -1691,8 +1689,9 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= diff --git a/intel/filterlists/updater.go b/intel/filterlists/updater.go index ff5ce3e9..7d15e85e 100644 --- a/intel/filterlists/updater.go +++ b/intel/filterlists/updater.go @@ -24,6 +24,11 @@ var updateInProgress = abool.New() func tryListUpdate(ctx context.Context) error { err := performUpdate(ctx) if err != nil { + // Check if we are shutting down. + if module.IsStopping() { + return nil + } + // Check if the module already has a failure status set. If not, set a // generic one with the returned error. failureStatus, _, _ := module.FailureStatus() diff --git a/intel/geoip/lookup.go b/intel/geoip/lookup.go index 7210bc79..c635b574 100644 --- a/intel/geoip/lookup.go +++ b/intel/geoip/lookup.go @@ -26,3 +26,8 @@ func GetLocation(ip net.IP) (*Location, error) { record.FillMissingInfo() return record, nil } + +// IsInitialized returns whether the geoip database has been initialized. +func IsInitialized(v6, wait bool) bool { + return worker.GetReader(v6, wait) != nil +} diff --git a/nameserver/module.go b/nameserver/module.go index f5165585..ed7eb740 100644 --- a/nameserver/module.go +++ b/nameserver/module.go @@ -259,7 +259,11 @@ func getListenAddresses(listenAddress string) (ip1, ip2 net.IP, port uint16, err // listen separately for IPv4 and IPv6. if ipString == "localhost" { ip1 = net.IPv4(127, 0, 0, 17) - ip2 = net.IPv6loopback + if netenv.IPv6Enabled() { + ip2 = net.IPv6loopback + } else { + log.Warningf("nameserver: no IPv6 stack detected, disabling IPv6 nameserver listener") + } } else { ip1 = net.ParseIP(ipString) if ip1 == nil { diff --git a/netenv/api.go b/netenv/api.go index 237dee57..20a2f688 100644 --- a/netenv/api.go +++ b/netenv/api.go @@ -55,7 +55,7 @@ func registerAPIEndpoints() error { Read: api.PermitUser, BelongsTo: module, StructFunc: func(ar *api.Request) (i interface{}, err error) { - return getLocationFromTraceroute() + return getLocationFromTraceroute(&DeviceLocations{}) }, Name: "Get Approximate Internet Location via Traceroute", Description: "Returns an approximation of where the device is on the Internet using a the traceroute technique.", diff --git a/netenv/location.go b/netenv/location.go index 6245ab45..8c8c5d56 100644 --- a/netenv/location.go +++ b/netenv/location.go @@ -47,16 +47,16 @@ type DeviceLocations struct { } // Best returns the best (most accurate) device location. -func (dl *DeviceLocations) Best() *DeviceLocation { - if len(dl.All) > 0 { - return dl.All[0] +func (dls *DeviceLocations) Best() *DeviceLocation { + if len(dls.All) > 0 { + return dls.All[0] } return nil } // BestV4 returns the best (most accurate) IPv4 device location. -func (dl *DeviceLocations) BestV4() *DeviceLocation { - for _, loc := range dl.All { +func (dls *DeviceLocations) BestV4() *DeviceLocation { + for _, loc := range dls.All { if loc.IPVersion == packet.IPv4 { return loc } @@ -65,8 +65,8 @@ func (dl *DeviceLocations) BestV4() *DeviceLocation { } // BestV6 returns the best (most accurate) IPv6 device location. -func (dl *DeviceLocations) BestV6() *DeviceLocation { - for _, loc := range dl.All { +func (dls *DeviceLocations) BestV6() *DeviceLocation { + for _, loc := range dls.All { if loc.IPVersion == packet.IPv6 { return loc } @@ -74,11 +74,8 @@ func (dl *DeviceLocations) BestV6() *DeviceLocation { return nil } -func copyDeviceLocations() *DeviceLocations { - locationsLock.Lock() - defer locationsLock.Unlock() - - // Create a copy of the locations, but not the entries. +// Copy creates a copy of the locations, but not the individual entries. +func (dls *DeviceLocations) Copy() *DeviceLocations { cp := &DeviceLocations{ All: make([]*DeviceLocation, len(locations.All)), } @@ -87,6 +84,32 @@ func copyDeviceLocations() *DeviceLocations { return cp } +// AddLocation adds a location. +func (dls *DeviceLocations) AddLocation(dl *DeviceLocation) { + if dls == nil { + return + } + + // Add to locations, if better. + var exists bool + for i, existing := range dls.All { + if (dl.IP == nil && existing.IP == nil) || dl.IP.Equal(existing.IP) { + exists = true + if dl.IsMoreAccurateThan(existing) { + // Replace + dls.All[i] = dl + break + } + } + } + if !exists { + dls.All = append(dls.All, dl) + } + + // Sort locations. + sort.Sort(sortLocationsByAccuracy(dls.All)) +} + // DeviceLocation represents a single IP and metadata. It must not be changed // once created. type DeviceLocation struct { @@ -147,6 +170,12 @@ func (dl *DeviceLocation) String() string { return "" case dl.Location == nil: return dl.IP.String() + case dl.Source == SourceTimezone: + return fmt.Sprintf( + "TZ(%.0f/%.0f)", + dl.Location.Coordinates.Latitude, + dl.Location.Coordinates.Longitude, + ) default: return fmt.Sprintf("%s (AS%d in %s)", dl.IP, dl.Location.AutonomousSystemNumber, dl.Location.Country.ISOCode) } @@ -193,6 +222,14 @@ func (a sortLocationsByAccuracy) Less(i, j int) bool { return !a[j].IsMoreAccura // SetInternetLocation provides the location management system with a possible Internet location. func SetInternetLocation(ip net.IP, source DeviceLocationSource) (dl *DeviceLocation, ok bool) { + locationsLock.Lock() + defer locationsLock.Unlock() + + return locations.AddIP(ip, source) +} + +// AddIP adds a new location based on the given IP. +func (dls *DeviceLocations) AddIP(ip net.IP, source DeviceLocationSource) (dl *DeviceLocation, ok bool) { // Check if IP is global. if netutils.GetIPScope(ip) != netutils.Global { return nil, false @@ -222,38 +259,10 @@ func SetInternetLocation(ip net.IP, source DeviceLocationSource) (dl *DeviceLoca } loc.Location = geoLoc - addLocation(loc) + dls.AddLocation(loc) return loc, true } -func addLocation(dl *DeviceLocation) { - if dl == nil { - return - } - - locationsLock.Lock() - defer locationsLock.Unlock() - - // Add to locations, if better. - var exists bool - for i, existing := range locations.All { - if (dl.IP == nil && existing.IP == nil) || dl.IP.Equal(existing.IP) { - exists = true - if dl.IsMoreAccurateThan(existing) { - // Replace - locations.All[i] = dl - break - } - } - } - if !exists { - locations.All = append(locations.All, dl) - } - - // Sort locations. - sort.Sort(sortLocationsByAccuracy(locations.All)) -} - // GetApproximateInternetLocation returns the approximate Internet location. // Deprecated: Please use GetInternetLocation instead. func GetApproximateInternetLocation() (net.IP, error) { @@ -271,30 +280,21 @@ func GetInternetLocation() (deviceLocations *DeviceLocations, ok bool) { // Check if the network changed, if not, return cache. if !locationNetworkChangedFlag.IsSet() { - return copyDeviceLocations(), true + locationsLock.Lock() + defer locationsLock.Unlock() + return locations.Copy(), true } locationNetworkChangedFlag.Refresh() - // Reset locations. - func() { - locationsLock.Lock() - defer locationsLock.Unlock() - locations = &DeviceLocations{} - }() - - // Get all assigned addresses. - v4s, v6s, err := GetAssignedAddresses() - if err != nil { - log.Warningf("netenv: failed to get assigned addresses for device location: %s", err) - return nil, false - } + // Create new location list. + dls := &DeviceLocations{} // Check interfaces for global addresses. - v4ok, v6ok := getLocationFromInterfaces() + v4ok, v6ok := getLocationFromInterfaces(dls) // Try other methods for missing locations. - if len(v4s) > 0 && !v4ok { - _, err = getLocationFromTraceroute() + if !v4ok { + _, err := getLocationFromTraceroute(dls) if err != nil { log.Warningf("netenv: failed to get IPv4 device location from traceroute: %s", err) } else { @@ -303,35 +303,43 @@ func GetInternetLocation() (deviceLocations *DeviceLocations, ok bool) { // Get location from timezone as final fallback. if !v4ok { - getLocationFromTimezone(packet.IPv4) + getLocationFromTimezone(dls, packet.IPv4) } } - if len(v6s) > 0 && !v6ok { + if !v6ok && IPv6Enabled() { // TODO: Find more ways to get IPv6 device location // Get location from timezone as final fallback. - getLocationFromTimezone(packet.IPv6) + getLocationFromTimezone(dls, packet.IPv6) } + // As a last guard, make sure there is at least one location in the list. + if len(dls.All) == 0 { + getLocationFromTimezone(dls, packet.IPv4) + } + + // Set new locations. + locationsLock.Lock() + defer locationsLock.Unlock() + locations = dls + // Return gathered locations. - cp := copyDeviceLocations() - return cp, true + return locations.Copy(), true } -func getLocationFromInterfaces() (v4ok, v6ok bool) { +func getLocationFromInterfaces(dls *DeviceLocations) (v4ok, v6ok bool) { globalIPv4, globalIPv6, err := GetAssignedGlobalAddresses() if err != nil { log.Warningf("netenv: location: failed to get assigned global addresses: %s", err) return false, false } - for _, ip := range globalIPv4 { - if _, ok := SetInternetLocation(ip, SourceInterface); ok { + if _, ok := dls.AddIP(ip, SourceInterface); ok { v4ok = true } } for _, ip := range globalIPv6 { - if _, ok := SetInternetLocation(ip, SourceInterface); ok { + if _, ok := dls.AddIP(ip, SourceInterface); ok { v6ok = true } } @@ -349,7 +357,7 @@ func getLocationFromUPnP() (ok bool) { } */ -func getLocationFromTraceroute() (dl *DeviceLocation, err error) { +func getLocationFromTraceroute(dls *DeviceLocations) (dl *DeviceLocation, err error) { // Create connection. conn, err := net.ListenPacket("ip4:icmp", "") if err != nil { @@ -470,7 +478,7 @@ nextHop: // We have received a valid time exceeded error. // If message came from a global unicast, us it! if netutils.GetIPScope(remoteIP) == netutils.Global { - dl, ok := SetInternetLocation(remoteIP, SourceTraceroute) + dl, ok := dls.AddIP(remoteIP, SourceTraceroute) if !ok { return nil, errors.New("invalid IP address") } @@ -516,7 +524,7 @@ func recvICMP(currentHop int, icmpPacketsViaFirewall chan packet.Packet) ( } } -func getLocationFromTimezone(ipVersion packet.IPVersion) (ok bool) { //nolint:unparam // This is documentation. +func getLocationFromTimezone(dls *DeviceLocations, ipVersion packet.IPVersion) { // Create base struct. tzLoc := &DeviceLocation{ IPVersion: ipVersion, @@ -531,6 +539,5 @@ func getLocationFromTimezone(ipVersion packet.IPVersion) (ok bool) { //nolint:un tzLoc.Location.Coordinates.Latitude = 48 tzLoc.Location.Coordinates.Longitude = float64(offsetSeconds) / 43200 * 180 - addLocation(tzLoc) - return true + dls.AddLocation(tzLoc) } diff --git a/netenv/main.go b/netenv/main.go index 0d831e76..3363754a 100644 --- a/netenv/main.go +++ b/netenv/main.go @@ -1,6 +1,9 @@ package netenv import ( + "github.com/tevino/abool" + + "github.com/safing/portbase/log" "github.com/safing/portbase/modules" ) @@ -20,6 +23,8 @@ func init() { } func prep() error { + checkForIPv6Stack() + if err := registerAPIEndpoints(); err != nil { return err } @@ -46,3 +51,22 @@ func start() error { return nil } + +var ipv6Enabled = abool.NewBool(true) + +// IPv6Enabled returns whether the device has an active IPv6 stack. +// This is only checked once on startup in order to maintain consistency. +func IPv6Enabled() bool { + return ipv6Enabled.IsSet() +} + +func checkForIPv6Stack() { + _, v6IPs, err := GetAssignedAddresses() + if err != nil { + log.Warningf("netenv: failed to get assigned addresses to check for ipv6 stack: %s", err) + return + } + + // Set IPv6 as enabled if any IPv6 addresses are found. + ipv6Enabled.SetTo(len(v6IPs) > 0) +} diff --git a/network/state/lookup.go b/network/state/lookup.go index 6e2525f1..46aac9a6 100644 --- a/network/state/lookup.go +++ b/network/state/lookup.go @@ -60,7 +60,7 @@ func Lookup(pktInfo *packet.Info, fast bool) (pid int, inbound bool, err error) return udp6Table.lookup(pktInfo, fast) default: - return socket.UndefinedProcessID, false, errors.New("unsupported protocol for finding process") + return socket.UndefinedProcessID, pktInfo.Inbound, errors.New("unsupported protocol for finding process") } } diff --git a/process/special.go b/process/special.go index 4b6a89e4..7d2c3e93 100644 --- a/process/special.go +++ b/process/special.go @@ -8,6 +8,7 @@ import ( "golang.org/x/sync/singleflight" "github.com/safing/portbase/log" + "github.com/safing/portmaster/network/socket" "github.com/safing/portmaster/profile" ) @@ -28,6 +29,13 @@ const ( NetworkHostProcessID = -255 ) +func init() { + // Check required matching values. + if UndefinedProcessID != socket.UndefinedProcessID { + panic("UndefinedProcessID does not match socket.UndefinedProcessID") + } +} + var ( // unidentifiedProcess is used for non-attributed outgoing connections. unidentifiedProcess = &Process{ diff --git a/profile/config.go b/profile/config.go index 987594d4..e371a175 100644 --- a/profile/config.go +++ b/profile/config.go @@ -258,6 +258,12 @@ Examples: "192.168.0.1 TCP/HTTP", "LAN UDP/50000-55000", "example.com */HTTPS", Important: DNS Requests are only matched against domain and filter list rules, all others require an IP address and are checked only with the following IP connection. `, `"`, "`") + // rulesVerdictNames defines the verdicts names to be used for filter rules. + rulesVerdictNames := map[string]string{ + "-": "Block", // Default. + "+": "Allow", + } + // Endpoint Filter List err = config.Register(&config.Option{ Name: "Outgoing Rules", @@ -268,10 +274,11 @@ Important: DNS Requests are only matched against domain and filter list rules, a OptType: config.OptTypeStringArray, DefaultValue: []string{}, Annotations: config.Annotations{ - config.StackableAnnotation: true, - config.DisplayHintAnnotation: endpoints.DisplayHintEndpointList, - config.DisplayOrderAnnotation: cfgOptionEndpointsOrder, - config.CategoryAnnotation: "Rules", + config.StackableAnnotation: true, + config.DisplayHintAnnotation: endpoints.DisplayHintEndpointList, + config.DisplayOrderAnnotation: cfgOptionEndpointsOrder, + config.CategoryAnnotation: "Rules", + endpoints.EndpointListVerdictNamesAnnotation: rulesVerdictNames, }, ValidationRegex: endpoints.ListEntryValidationRegex, ValidationFunc: endpoints.ValidateEndpointListConfigOption, @@ -283,6 +290,7 @@ Important: DNS Requests are only matched against domain and filter list rules, a cfgStringArrayOptions[CfgOptionEndpointsKey] = cfgOptionEndpoints // Service Endpoint Filter List + defaultIncomingRulesValue := []string{"+ Localhost"} err = config.Register(&config.Option{ Name: "Incoming Rules", Key: CfgOptionServiceEndpointsKey, @@ -290,13 +298,14 @@ Important: DNS Requests are only matched against domain and filter list rules, a Help: rulesHelp, Sensitive: true, OptType: config.OptTypeStringArray, - DefaultValue: []string{"+ Localhost"}, + DefaultValue: defaultIncomingRulesValue, ExpertiseLevel: config.ExpertiseLevelExpert, Annotations: config.Annotations{ - config.StackableAnnotation: true, - config.DisplayHintAnnotation: endpoints.DisplayHintEndpointList, - config.DisplayOrderAnnotation: cfgOptionServiceEndpointsOrder, - config.CategoryAnnotation: "Rules", + config.StackableAnnotation: true, + config.DisplayHintAnnotation: endpoints.DisplayHintEndpointList, + config.DisplayOrderAnnotation: cfgOptionServiceEndpointsOrder, + config.CategoryAnnotation: "Rules", + endpoints.EndpointListVerdictNamesAnnotation: rulesVerdictNames, config.QuickSettingsAnnotation: []config.QuickSetting{ { Name: "SSH", @@ -313,6 +322,16 @@ Important: DNS Requests are only matched against domain and filter list rules, a Action: config.QuickMergeTop, Value: []string{"+ * */3389"}, }, + { + Name: "Allow all from LAN", + Action: config.QuickMergeTop, + Value: []string{"+ LAN"}, + }, + { + Name: "Allow all from Internet", + Action: config.QuickMergeTop, + Value: []string{"+ Internet"}, + }, }, }, ValidationRegex: endpoints.ListEntryValidationRegex, @@ -321,38 +340,17 @@ Important: DNS Requests are only matched against domain and filter list rules, a if err != nil { return err } - cfgOptionServiceEndpoints = config.Concurrent.GetAsStringArray(CfgOptionServiceEndpointsKey, []string{}) + cfgOptionServiceEndpoints = config.Concurrent.GetAsStringArray(CfgOptionServiceEndpointsKey, defaultIncomingRulesValue) cfgStringArrayOptions[CfgOptionServiceEndpointsKey] = cfgOptionServiceEndpoints - filterListsHelp := strings.ReplaceAll(`Filter lists contain domains and IP addresses that are known to be used adversarial. The data is collected from many public sources and put into the following categories. In order to active a category, add it's "ID" to the list. - -**Ads & Trackers** - ID: "TRAC" -Services that track and profile people online, including as ads, analytics and telemetry. - -**Malware** - ID: "MAL" -Services that are (ab)used for attacking devices through technical means. - -**Deception** - ID: "DECEP" -Services that trick humans into thinking the service is genuine, while it is not, including phishing, fake news and fraud. - -**Bad Stuff (Mixed)** - ID: "BAD" -Miscellaneous services that are believed to be harmful to security or privacy, but their exact use is unknown, not categorized, or lists have mixed categories. - -**NSFW** - ID: "NSFW" -Services that are generally not accepted in work environments, including pornography, violence and gambling. - -The lists are automatically updated every hour using incremental updates. -[See here](https://github.com/safing/intel-data) for more detail about these lists, their sources and how to help to improve them. -`, `"`, "`") - // Filter list IDs + defaultFilterListsValue := []string{"TRAC", "MAL", "BAD"} err = config.Register(&config.Option{ Name: "Filter Lists", Key: CfgOptionFilterListsKey, Description: "Block connections that match enabled filter lists.", - Help: filterListsHelp, OptType: config.OptTypeStringArray, - DefaultValue: []string{"TRAC", "MAL", "BAD"}, + DefaultValue: defaultFilterListsValue, Annotations: config.Annotations{ config.DisplayHintAnnotation: "filter list", config.DisplayOrderAnnotation: cfgOptionFilterListsOrder, @@ -363,7 +361,7 @@ The lists are automatically updated every hour using incremental updates. if err != nil { return err } - cfgOptionFilterLists = config.Concurrent.GetAsStringArray(CfgOptionFilterListsKey, []string{}) + cfgOptionFilterLists = config.Concurrent.GetAsStringArray(CfgOptionFilterListsKey, defaultFilterListsValue) cfgStringArrayOptions[CfgOptionFilterListsKey] = cfgOptionFilterLists // Include CNAMEs diff --git a/resolver/resolver-mdns.go b/resolver/resolver-mdns.go index ce0eab6d..29677350 100644 --- a/resolver/resolver-mdns.go +++ b/resolver/resolver-mdns.go @@ -12,6 +12,7 @@ import ( "github.com/miekg/dns" "github.com/safing/portbase/log" + "github.com/safing/portmaster/netenv" "github.com/safing/portmaster/network/netutils" ) @@ -91,19 +92,6 @@ func listenToMDNS(ctx context.Context) error { }() } - multicast6Conn, err = net.ListenMulticastUDP("udp6", nil, &net.UDPAddr{IP: net.IP([]byte{0xff, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfb}), Port: 5353}) - if err != nil { - // TODO: retry after some time - log.Warningf("intel(mdns): failed to create udp6 listen multicast socket: %s", err) - } else { - module.StartServiceWorker("mdns udp6 multicast listener", 0, func(ctx context.Context) error { - return listenForDNSPackets(ctx, multicast6Conn, messages) - }) - defer func() { - _ = multicast6Conn.Close() - }() - } - unicast4Conn, err = net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) if err != nil { // TODO: retry after some time @@ -117,17 +105,34 @@ func listenToMDNS(ctx context.Context) error { }() } - unicast6Conn, err = net.ListenUDP("udp6", &net.UDPAddr{IP: net.IPv6zero, Port: 0}) - if err != nil { - // TODO: retry after some time - log.Warningf("intel(mdns): failed to create udp6 listen socket: %s", err) + if netenv.IPv6Enabled() { + multicast6Conn, err = net.ListenMulticastUDP("udp6", nil, &net.UDPAddr{IP: net.IP([]byte{0xff, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfb}), Port: 5353}) + if err != nil { + // TODO: retry after some time + log.Warningf("intel(mdns): failed to create udp6 listen multicast socket: %s", err) + } else { + module.StartServiceWorker("mdns udp6 multicast listener", 0, func(ctx context.Context) error { + return listenForDNSPackets(ctx, multicast6Conn, messages) + }) + defer func() { + _ = multicast6Conn.Close() + }() + } + + unicast6Conn, err = net.ListenUDP("udp6", &net.UDPAddr{IP: net.IPv6zero, Port: 0}) + if err != nil { + // TODO: retry after some time + log.Warningf("intel(mdns): failed to create udp6 listen socket: %s", err) + } else { + module.StartServiceWorker("mdns udp6 unicast listener", 0, func(ctx context.Context) error { + return listenForDNSPackets(ctx, unicast6Conn, messages) + }) + defer func() { + _ = unicast6Conn.Close() + }() + } } else { - module.StartServiceWorker("mdns udp6 unicast listener", 0, func(ctx context.Context) error { - return listenForDNSPackets(ctx, unicast6Conn, messages) - }) - defer func() { - _ = unicast6Conn.Close() - }() + log.Warningf("resolver: no IPv6 stack detected, disabling IPv6 mDNS resolver") } // start message handler diff --git a/updates/export.go b/updates/export.go index e00dee33..0c03b365 100644 --- a/updates/export.go +++ b/updates/export.go @@ -2,11 +2,8 @@ package updates import ( "context" - "errors" "sync" - "github.com/safing/portbase/database" - "github.com/safing/portbase/database/query" "github.com/safing/portbase/database/record" "github.com/safing/portbase/info" "github.com/safing/portbase/log" @@ -14,48 +11,95 @@ import ( "github.com/safing/portmaster/updates/helper" ) -// Database key for update information. const ( + // versionsDBKey is the database key for update version information. versionsDBKey = "core:status/versions" + + // versionsDBKey is the database key for simple update version information. + simpleVersionsDBKey = "core:status/simple-versions" ) -var ( - versionExport *versions - versionExportDB = database.NewInterface(&database.Options{ - Local: true, - Internal: true, - }) - versionExportHook *database.RegisteredHook -) - -// versions holds updates status information. -type versions struct { +// Versions holds update versions and status information. +type Versions struct { record.Base - lock sync.Mutex + sync.Mutex Core *info.Info Resources map[string]*updater.Resource Channel string Beta bool Staging bool +} - internalSave bool +// SimpleVersions holds simplified update versions and status information. +type SimpleVersions struct { + record.Base + sync.Mutex + + Build *info.Info + Resources map[string]*SimplifiedResourceVersion + Channel string +} + +// SimplifiedResourceVersion holds version information about one resource. +type SimplifiedResourceVersion struct { + Version string +} + +// GetVersions returns the update versions and status information. +// Resources must be locked when accessed. +func GetVersions() *Versions { + return &Versions{ + Core: info.GetInfo(), + Resources: registry.Export(), + Channel: initialReleaseChannel, + Beta: initialReleaseChannel == helper.ReleaseChannelBeta, + Staging: initialReleaseChannel == helper.ReleaseChannelStaging, + } +} + +// GetSimpleVersions returns the simplified update versions and status information. +func GetSimpleVersions() *SimpleVersions { + // Fill base info. + v := &SimpleVersions{ + Build: info.GetInfo(), + Resources: make(map[string]*SimplifiedResourceVersion), + Channel: initialReleaseChannel, + } + + // Iterate through all versions and add version info. + for id, resource := range registry.Export() { + func() { + resource.Lock() + defer resource.Unlock() + + // Get current in-used or selected version. + var rv *updater.ResourceVersion + switch { + case resource.ActiveVersion != nil: + rv = resource.ActiveVersion + case resource.SelectedVersion != nil: + rv = resource.SelectedVersion + } + + // Get information from resource. + if rv != nil { + v.Resources[id] = &SimplifiedResourceVersion{ + Version: rv.VersionNumber, + } + } + }() + } + + return v } func initVersionExport() (err error) { - // init export struct - versionExport = &versions{ - internalSave: true, - Channel: initialReleaseChannel, - Beta: initialReleaseChannel == helper.ReleaseChannelBeta, - Staging: initialReleaseChannel == helper.ReleaseChannelStaging, + if err := GetVersions().save(); err != nil { + log.Warningf("updates: failed to export version information: %s", err) } - versionExport.SetKey(versionsDBKey) - - // attach hook to database - versionExportHook, err = database.RegisterHook(query.New(versionsDBKey), &exportHook{}) - if err != nil { - return err + if err := GetSimpleVersions().save(); err != nil { + log.Warningf("updates: failed to export version information: %s", err) } return module.RegisterEventHook( @@ -66,71 +110,24 @@ func initVersionExport() (err error) { ) } -func stopVersionExport() error { - return versionExportHook.Cancel() +func (v *Versions) save() error { + if !v.KeyIsSet() { + v.SetKey(versionsDBKey) + } + return db.Put(v) +} + +func (v *SimpleVersions) save() error { + if !v.KeyIsSet() { + v.SetKey(simpleVersionsDBKey) + } + return db.Put(v) } // export is an event hook. func export(_ context.Context, _ interface{}) error { - // populate - versionExport.lock.Lock() - versionExport.Core = info.GetInfo() - versionExport.Resources = registry.Export() - versionExport.lock.Unlock() - - // save - err := versionExportDB.Put(versionExport) - if err != nil { - log.Warningf("updates: failed to export versions: %s", err) + if err := GetVersions().save(); err != nil { + return err } - - return nil -} - -// Lock locks the versionExport and all associated resources. -func (v *versions) Lock() { - // lock self - v.lock.Lock() - - // lock all resources - for _, res := range v.Resources { - res.Lock() - } -} - -// Lock unlocks the versionExport and all associated resources. -func (v *versions) Unlock() { - // unlock all resources - for _, res := range v.Resources { - res.Unlock() - } - - // unlock self - v.lock.Unlock() -} - -type exportHook struct { - database.HookBase -} - -// UsesPrePut implements the Hook interface. -func (eh *exportHook) UsesPrePut() bool { - return true -} - -var errInternalRecord = errors.New("may not modify internal record") - -// PrePut implements the Hook interface. -func (eh *exportHook) PrePut(r record.Record) (record.Record, error) { - if r.IsWrapped() { - return nil, errInternalRecord - } - ve, ok := r.(*versions) - if !ok { - return nil, errInternalRecord - } - if !ve.internalSave { - return nil, errInternalRecord - } - return r, nil + return GetSimpleVersions().save() } diff --git a/updates/helper/indexes.go b/updates/helper/indexes.go index db97b84d..e925be1a 100644 --- a/updates/helper/indexes.go +++ b/updates/helper/indexes.go @@ -33,6 +33,12 @@ func SetIndexes(registry *updater.ResourceRegistry, releaseChannel string, delet // Reset indexes before adding them (again). registry.ResetIndexes() + // Add the intel index first, in order to be able to override it with the + // other indexes when needed. + registry.AddIndex(updater.Index{ + Path: "all/intel/intel.json", + }) + // Always add the stable index as a base. registry.AddIndex(updater.Index{ Path: ReleaseChannelStable + ".json", @@ -85,13 +91,6 @@ func SetIndexes(registry *updater.ResourceRegistry, releaseChannel string, delet } } - // Add the intel index last, as it updates the fastest and should not be - // crippled by other faulty indexes. It can only specify versions for its - // scope anyway. - registry.AddIndex(updater.Index{ - Path: "all/intel/intel.json", - }) - // Set pre-release usage. registry.SetUsePreReleases(usePreReleases) diff --git a/updates/main.go b/updates/main.go index 550e23ac..bee808a0 100644 --- a/updates/main.go +++ b/updates/main.go @@ -7,6 +7,7 @@ import ( "runtime" "time" + "github.com/safing/portbase/database" "github.com/safing/portbase/dataroot" "github.com/safing/portbase/log" "github.com/safing/portbase/modules" @@ -48,6 +49,11 @@ var ( updateASAP bool disableTaskSchedule bool + db = database.NewInterface(&database.Options{ + Local: true, + Internal: true, + }) + // UserAgent is an HTTP User-Agent that is used to add // more context to requests made by the registry when // fetching resources from the update server. @@ -55,6 +61,8 @@ var ( ) const ( + updatesDirName = "updates" + updateFailed = "updates:failed" updateSuccess = "updates:success" ) @@ -108,7 +116,7 @@ func start() error { registry.UserAgent = userAgentFromFlag } // initialize - err := registry.Initialize(dataroot.Root().ChildDir("updates", 0o0755)) + err := registry.Initialize(dataroot.Root().ChildDir(updatesDirName, 0o0755)) if err != nil { return err } @@ -275,7 +283,7 @@ func stop() error { } } - return stopVersionExport() + return nil } // RootPath returns the root path used for storing updates.