wip: migrate to mono-repo. SPN has already been moved to spn/

This commit is contained in:
Patrick Pacher
2024-03-15 11:55:13 +01:00
parent b30fd00ccf
commit 8579430db9
577 changed files with 35981 additions and 818 deletions

163
service/updates/api.go Normal file
View File

@@ -0,0 +1,163 @@
package updates
import (
"bytes"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/ghodss/yaml"
"github.com/safing/portbase/api"
"github.com/safing/portbase/log"
"github.com/safing/portbase/utils"
)
const (
apiPathCheckForUpdates = "updates/check"
)
func registerAPIEndpoints() error {
if err := api.RegisterEndpoint(api.Endpoint{
Name: "Check for Updates",
Description: "Checks if new versions are available. If automatic updates are enabled, they are also downloaded and applied.",
Parameters: []api.Parameter{{
Method: http.MethodPost,
Field: "download",
Value: "",
Description: "Force downloading and applying of all updates, regardless of auto-update settings.",
}},
Path: apiPathCheckForUpdates,
Write: api.PermitUser,
BelongsTo: module,
ActionFunc: func(r *api.Request) (msg string, err error) {
// Check if we should also download regardless of settings.
downloadAll := r.URL.Query().Has("download")
// Trigger update task.
err = TriggerUpdate(true, downloadAll)
if err != nil {
return "", err
}
// Report how we triggered.
if downloadAll {
return "downloading all updates...", nil
}
return "checking for updates...", nil
},
}); err != nil {
return err
}
if err := api.RegisterEndpoint(api.Endpoint{
Name: "Get Resource",
Description: "Returns the requested resource from the udpate system",
Path: `updates/get/{identifier:[A-Za-z0-9/\.\-_]{1,255}}`,
Read: api.PermitUser,
ReadMethod: http.MethodGet,
BelongsTo: module,
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
// Get identifier from URL.
var identifier string
if ar := api.GetAPIRequest(r); ar != nil {
identifier = ar.URLVars["identifier"]
}
if identifier == "" {
http.Error(w, "no resource speicified", http.StatusBadRequest)
return
}
// Get resource.
resource, err := registry.GetFile(identifier)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
// Open file for reading.
file, err := os.Open(resource.Path())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer file.Close() //nolint:errcheck,gosec
// Assign file to reader
var reader io.Reader = file
// Add version to header.
w.Header().Set("Resource-Version", resource.Version())
// Set Content-Type.
contentType, _ := utils.MimeTypeByExtension(filepath.Ext(resource.Path()))
w.Header().Set("Content-Type", contentType)
// Check if the content type may be returned.
accept := r.Header.Get("Accept")
if accept != "" {
mimeTypes := strings.Split(accept, ",")
// First, clean mime types.
for i, mimeType := range mimeTypes {
mimeType = strings.TrimSpace(mimeType)
mimeType, _, _ = strings.Cut(mimeType, ";")
mimeTypes[i] = mimeType
}
// Second, check if we may return anything.
var acceptsAny bool
for _, mimeType := range mimeTypes {
switch mimeType {
case "*", "*/*":
acceptsAny = true
}
}
// Third, check if we can convert.
if !acceptsAny {
var converted bool
sourceType, _, _ := strings.Cut(contentType, ";")
findConvertiblePair:
for _, mimeType := range mimeTypes {
switch {
case sourceType == "application/yaml" && mimeType == "application/json":
yamlData, err := io.ReadAll(reader)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
jsonData, err := yaml.YAMLToJSON(yamlData)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
reader = bytes.NewReader(jsonData)
converted = true
break findConvertiblePair
}
}
// If we could not convert to acceptable format, return an error.
if !converted {
http.Error(w, "conversion to requested format not supported", http.StatusNotAcceptable)
return
}
}
}
// Write file.
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
_, err = io.Copy(w, reader)
if err != nil {
log.Errorf("updates: failed to serve resource file: %s", err)
return
}
}
},
}); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,44 @@
[Unit]
Description=Portmaster by Safing
Documentation=https://safing.io
Documentation=https://docs.safing.io
Before=nss-lookup.target network.target shutdown.target
After=systemd-networkd.service
Conflicts=shutdown.target
Conflicts=firewalld.service
Wants=nss-lookup.target
[Service]
Type=simple
Restart=on-failure
RestartSec=10
LockPersonality=yes
MemoryDenyWriteExecute=yes
NoNewPrivileges=yes
PrivateTmp=yes
PIDFile=/opt/safing/portmaster/core-lock.pid
Environment=LOGLEVEL=info
Environment=PORTMASTER_ARGS=
EnvironmentFile=-/etc/default/portmaster
ProtectSystem=true
#ReadWritePaths=/var/lib/portmaster
#ReadWritePaths=/run/xtables.lock
RestrictAddressFamilies=AF_UNIX AF_NETLINK AF_INET AF_INET6
RestrictNamespaces=yes
# In future version portmaster will require access to user home
# directories to verify application permissions.
ProtectHome=read-only
ProtectKernelTunables=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
PrivateDevices=yes
AmbientCapabilities=cap_chown cap_kill cap_net_admin cap_net_bind_service cap_net_broadcast cap_net_raw cap_sys_module cap_sys_ptrace cap_dac_override cap_fowner cap_fsetid cap_sys_resource cap_bpf cap_perfmon
CapabilityBoundingSet=cap_chown cap_kill cap_net_admin cap_net_bind_service cap_net_broadcast cap_net_raw cap_sys_module cap_sys_ptrace cap_dac_override cap_fowner cap_fsetid cap_sys_resource cap_bpf cap_perfmon
# SystemCallArchitectures=native
# SystemCallFilter=@system-service @module
# SystemCallErrorNumber=EPERM
ExecStart=/opt/safing/portmaster/portmaster-start --data /opt/safing/portmaster core -- $PORTMASTER_ARGS
ExecStopPost=-/opt/safing/portmaster/portmaster-start recover-iptables
[Install]
WantedBy=multi-user.target

179
service/updates/config.go Normal file
View File

@@ -0,0 +1,179 @@
package updates
import (
"context"
"github.com/tevino/abool"
"github.com/safing/portbase/config"
"github.com/safing/portbase/log"
"github.com/safing/portmaster/service/updates/helper"
)
const cfgDevModeKey = "core/devMode"
var (
releaseChannel config.StringOption
devMode config.BoolOption
enableSoftwareUpdates config.BoolOption
enableIntelUpdates config.BoolOption
initialReleaseChannel string
previousReleaseChannel string
softwareUpdatesCurrentlyEnabled bool
intelUpdatesCurrentlyEnabled bool
previousDevMode bool
forceCheck = abool.New()
forceDownload = abool.New()
)
func registerConfig() error {
err := config.Register(&config.Option{
Name: "Release Channel",
Key: helper.ReleaseChannelKey,
Description: `Use "Stable" for the best experience. The "Beta" channel will have the newest features and fixes, but may also break and cause interruption. Use others only temporarily and when instructed.`,
OptType: config.OptTypeString,
ExpertiseLevel: config.ExpertiseLevelExpert,
ReleaseLevel: config.ReleaseLevelStable,
RequiresRestart: true,
DefaultValue: helper.ReleaseChannelStable,
PossibleValues: []config.PossibleValue{
{
Name: "Stable",
Description: "Production releases.",
Value: helper.ReleaseChannelStable,
},
{
Name: "Beta",
Description: "Production releases for testing new features that may break and cause interruption.",
Value: helper.ReleaseChannelBeta,
},
{
Name: "Support",
Description: "Support releases or version changes for troubleshooting. Only use temporarily and when instructed.",
Value: helper.ReleaseChannelSupport,
},
{
Name: "Staging",
Description: "Dangerous development releases for testing random things and experimenting. Only use temporarily and when instructed.",
Value: helper.ReleaseChannelStaging,
},
},
Annotations: config.Annotations{
config.DisplayOrderAnnotation: -4,
config.DisplayHintAnnotation: config.DisplayHintOneOf,
config.CategoryAnnotation: "Updates",
},
})
if err != nil {
return err
}
err = config.Register(&config.Option{
Name: "Automatic Software Updates",
Key: enableSoftwareUpdatesKey,
Description: "Automatically check for and download software updates. This does not include intelligence data updates.",
OptType: config.OptTypeBool,
ExpertiseLevel: config.ExpertiseLevelExpert,
ReleaseLevel: config.ReleaseLevelStable,
RequiresRestart: false,
DefaultValue: true,
Annotations: config.Annotations{
config.DisplayOrderAnnotation: -12,
config.CategoryAnnotation: "Updates",
},
})
if err != nil {
return err
}
err = config.Register(&config.Option{
Name: "Automatic Intelligence Data Updates",
Key: enableIntelUpdatesKey,
Description: "Automatically check for and download intelligence data updates. This includes filter lists, geo-ip data, and more. Does not include software updates.",
OptType: config.OptTypeBool,
ExpertiseLevel: config.ExpertiseLevelExpert,
ReleaseLevel: config.ReleaseLevelStable,
RequiresRestart: false,
DefaultValue: true,
Annotations: config.Annotations{
config.DisplayOrderAnnotation: -11,
config.CategoryAnnotation: "Updates",
},
})
if err != nil {
return err
}
return nil
}
func initConfig() {
releaseChannel = config.Concurrent.GetAsString(helper.ReleaseChannelKey, helper.ReleaseChannelStable)
initialReleaseChannel = releaseChannel()
previousReleaseChannel = releaseChannel()
enableSoftwareUpdates = config.Concurrent.GetAsBool(enableSoftwareUpdatesKey, true)
enableIntelUpdates = config.Concurrent.GetAsBool(enableIntelUpdatesKey, true)
softwareUpdatesCurrentlyEnabled = enableSoftwareUpdates()
intelUpdatesCurrentlyEnabled = enableIntelUpdates()
devMode = config.Concurrent.GetAsBool(cfgDevModeKey, false)
previousDevMode = devMode()
}
func updateRegistryConfig(_ context.Context, _ interface{}) error {
changed := false
if enableSoftwareUpdates() != softwareUpdatesCurrentlyEnabled {
softwareUpdatesCurrentlyEnabled = enableSoftwareUpdates()
changed = true
}
if enableIntelUpdates() != intelUpdatesCurrentlyEnabled {
intelUpdatesCurrentlyEnabled = enableIntelUpdates()
changed = true
}
if devMode() != previousDevMode {
registry.SetDevMode(devMode())
previousDevMode = devMode()
changed = true
}
if releaseChannel() != previousReleaseChannel {
previousReleaseChannel = releaseChannel()
changed = true
}
if changed {
// Update indexes based on new settings.
warning := helper.SetIndexes(
registry,
releaseChannel(),
true,
softwareUpdatesCurrentlyEnabled,
intelUpdatesCurrentlyEnabled,
)
if warning != nil {
log.Warningf("updates: %s", warning)
}
// Select versions depending on new indexes and modes.
registry.SelectVersions()
module.TriggerEvent(VersionUpdateEvent, nil)
if softwareUpdatesCurrentlyEnabled || intelUpdatesCurrentlyEnabled {
module.Resolve("")
if err := TriggerUpdate(true, false); err != nil {
log.Warningf("updates: failed to trigger update: %s", err)
}
log.Infof("updates: automatic updates are now enabled")
} else {
log.Warningf("updates: automatic updates are now completely disabled")
}
}
return nil
}

242
service/updates/export.go Normal file
View File

@@ -0,0 +1,242 @@
package updates
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"github.com/safing/portbase/database/record"
"github.com/safing/portbase/info"
"github.com/safing/portbase/log"
"github.com/safing/portbase/updater"
"github.com/safing/portbase/utils/debug"
"github.com/safing/portmaster/service/updates/helper"
)
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"
// updateStatusDBKey is the database key for update status information.
updateStatusDBKey = "core:status/updates"
)
// Versions holds update versions and status information.
type Versions struct {
record.Base
sync.Mutex
Core *info.Info
Resources map[string]*updater.Resource
Channel string
Beta bool
Staging 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
}
// UpdateStateExport is a wrapper to export the updates state.
type UpdateStateExport struct {
record.Base
sync.Mutex
*updater.UpdateState
}
// 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
}
// GetStateExport gets the update state from the registry and returns it in an
// exportable struct.
func GetStateExport() *UpdateStateExport {
export := registry.GetState()
return &UpdateStateExport{
UpdateState: &export.Updates,
}
}
// LoadStateExport loads the exported update state from the database.
func LoadStateExport() (*UpdateStateExport, error) {
r, err := db.Get(updateStatusDBKey)
if err != nil {
return nil, err
}
// unwrap
if r.IsWrapped() {
// only allocate a new struct, if we need it
newRecord := &UpdateStateExport{}
err = record.Unwrap(r, newRecord)
if err != nil {
return nil, err
}
return newRecord, nil
}
// or adjust type
newRecord, ok := r.(*UpdateStateExport)
if !ok {
return nil, fmt.Errorf("record not of type *UpdateStateExport, but %T", r)
}
return newRecord, nil
}
func initVersionExport() (err error) {
if err := GetVersions().save(); err != nil {
log.Warningf("updates: failed to export version information: %s", err)
}
if err := GetSimpleVersions().save(); err != nil {
log.Warningf("updates: failed to export version information: %s", err)
}
return module.RegisterEventHook(
ModuleName,
VersionUpdateEvent,
"export version status",
export,
)
}
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)
}
func (s *UpdateStateExport) save() error {
if !s.KeyIsSet() {
s.SetKey(updateStatusDBKey)
}
return db.Put(s)
}
// export is an event hook.
func export(_ context.Context, _ interface{}) error {
// Export versions.
if err := GetVersions().save(); err != nil {
return err
}
if err := GetSimpleVersions().save(); err != nil {
return err
}
// Export udpate state.
if err := GetStateExport().save(); err != nil {
return err
}
return nil
}
// AddToDebugInfo adds the update system status to the given debug.Info.
func AddToDebugInfo(di *debug.Info) {
// Get resources from registry.
resources := registry.Export()
platformPrefix := helper.PlatformIdentifier("")
// Collect data for debug info.
var active, selected []string
var activeCnt, totalCnt int
for id, r := range resources {
// Ignore resources for other platforms.
if !strings.HasPrefix(id, "all/") && !strings.HasPrefix(id, platformPrefix) {
continue
}
totalCnt++
if r.ActiveVersion != nil {
activeCnt++
active = append(active, fmt.Sprintf("%s: %s", id, r.ActiveVersion.VersionNumber))
}
if r.SelectedVersion != nil {
selected = append(selected, fmt.Sprintf("%s: %s", id, r.SelectedVersion.VersionNumber))
}
}
sort.Strings(active)
sort.Strings(selected)
// Compile to one list.
lines := make([]string, 0, len(active)+len(selected)+3)
lines = append(lines, "Active:")
lines = append(lines, active...)
lines = append(lines, "")
lines = append(lines, "Selected:")
lines = append(lines, selected...)
// Add section.
di.AddSection(
fmt.Sprintf("Updates: %s (%d/%d)", initialReleaseChannel, activeCnt, totalCnt),
debug.UseCodeSection|debug.AddContentLineBreaks,
lines...,
)
}

72
service/updates/get.go Normal file
View File

@@ -0,0 +1,72 @@
package updates
import (
"path"
"github.com/safing/portbase/updater"
"github.com/safing/portmaster/service/updates/helper"
)
// GetPlatformFile returns the latest platform specific file identified by the given identifier.
func GetPlatformFile(identifier string) (*updater.File, error) {
identifier = helper.PlatformIdentifier(identifier)
file, err := registry.GetFile(identifier)
if err != nil {
return nil, err
}
module.TriggerEvent(VersionUpdateEvent, nil)
return file, nil
}
// GetFile returns the latest generic file identified by the given identifier.
func GetFile(identifier string) (*updater.File, error) {
identifier = path.Join("all", identifier)
file, err := registry.GetFile(identifier)
if err != nil {
return nil, err
}
module.TriggerEvent(VersionUpdateEvent, nil)
return file, nil
}
// GetPlatformVersion returns the selected platform specific version of the
// given identifier.
// The returned resource version may not be modified.
func GetPlatformVersion(identifier string) (*updater.ResourceVersion, error) {
identifier = helper.PlatformIdentifier(identifier)
rv, err := registry.GetVersion(identifier)
if err != nil {
return nil, err
}
return rv, nil
}
// GetVersion returns the selected generic version of the given identifier.
// The returned resource version may not be modified.
func GetVersion(identifier string) (*updater.ResourceVersion, error) {
identifier = path.Join("all", identifier)
rv, err := registry.GetVersion(identifier)
if err != nil {
return nil, err
}
return rv, nil
}
// GetVersionWithFullID returns the selected generic version of the given full identifier.
// The returned resource version may not be modified.
func GetVersionWithFullID(identifier string) (*updater.ResourceVersion, error) {
rv, err := registry.GetVersion(identifier)
if err != nil {
return nil, err
}
return rv, nil
}

View File

@@ -0,0 +1,57 @@
package helper
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/safing/portbase/log"
"github.com/safing/portbase/updater"
)
var pmElectronUpdate *updater.File
const suidBitWarning = `Failed to set SUID permissions for chrome-sandbox. This is required for Linux kernel versions that do not have unprivileged user namespaces (CONFIG_USER_NS_UNPRIVILEGED) enabled. If you're running and up-to-date distribution kernel you can likely ignore this warning. If you encounter issue starting the user interface please either update your kernel or set the SUID bit (mode 0%0o) on %s`
// EnsureChromeSandboxPermissions makes sure the chrome-sandbox distributed
// by our app-electron package has the SUID bit set on systems that do not
// allow unprivileged CLONE_NEWUSER (clone(3)).
// On non-linux systems or systems that have kernel.unprivileged_userns_clone
// set to 1 EnsureChromeSandboPermissions is a NO-OP.
func EnsureChromeSandboxPermissions(reg *updater.ResourceRegistry) error {
if runtime.GOOS != "linux" {
return nil
}
if pmElectronUpdate != nil && !pmElectronUpdate.UpgradeAvailable() {
return nil
}
identifier := PlatformIdentifier("app/portmaster-app.zip")
var err error
pmElectronUpdate, err = reg.GetFile(identifier)
if err != nil {
if errors.Is(err, updater.ErrNotAvailableLocally) {
return nil
}
return fmt.Errorf("failed to get file: %w", err)
}
unpackedPath := strings.TrimSuffix(
pmElectronUpdate.Path(),
filepath.Ext(pmElectronUpdate.Path()),
)
sandboxFile := filepath.Join(unpackedPath, "chrome-sandbox")
if err := os.Chmod(sandboxFile, 0o0755|os.ModeSetuid); err != nil {
log.Errorf(suidBitWarning, 0o0755|os.ModeSetuid, sandboxFile)
return fmt.Errorf("failed to chmod: %w", err)
}
log.Debugf("updates: fixed SUID permission for chrome-sandbox")
return nil
}

View File

@@ -0,0 +1,134 @@
package helper
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"github.com/safing/jess/filesig"
"github.com/safing/portbase/updater"
)
// Release Channel Configuration Keys.
const (
ReleaseChannelKey = "core/releaseChannel"
ReleaseChannelJSONKey = "core.releaseChannel"
)
// Release Channels.
const (
ReleaseChannelStable = "stable"
ReleaseChannelBeta = "beta"
ReleaseChannelStaging = "staging"
ReleaseChannelSupport = "support"
)
// SetIndexes sets the update registry indexes and also configures the registry
// to use pre-releases based on the channel.
func SetIndexes(
registry *updater.ResourceRegistry,
releaseChannel string,
deleteUnusedIndexes bool,
autoDownload bool,
autoDownloadIntel bool,
) (warning error) {
usePreReleases := false
// Be reminded that the order is important, as indexes added later will
// override the current release from earlier indexes.
// 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",
AutoDownload: autoDownloadIntel,
})
// Always add the stable index as a base.
registry.AddIndex(updater.Index{
Path: ReleaseChannelStable + ".json",
AutoDownload: autoDownload,
})
// Add beta index if in beta or staging channel.
indexPath := ReleaseChannelBeta + ".json"
if releaseChannel == ReleaseChannelBeta ||
releaseChannel == ReleaseChannelStaging ||
(releaseChannel == "" && indexExists(registry, indexPath)) {
registry.AddIndex(updater.Index{
Path: indexPath,
PreRelease: true,
AutoDownload: autoDownload,
})
usePreReleases = true
} else if deleteUnusedIndexes {
err := deleteIndex(registry, indexPath)
if err != nil {
warning = fmt.Errorf("failed to delete unused index %s: %w", indexPath, err)
}
}
// Add staging index if in staging channel.
indexPath = ReleaseChannelStaging + ".json"
if releaseChannel == ReleaseChannelStaging ||
(releaseChannel == "" && indexExists(registry, indexPath)) {
registry.AddIndex(updater.Index{
Path: indexPath,
PreRelease: true,
AutoDownload: autoDownload,
})
usePreReleases = true
} else if deleteUnusedIndexes {
err := deleteIndex(registry, indexPath)
if err != nil {
warning = fmt.Errorf("failed to delete unused index %s: %w", indexPath, err)
}
}
// Add support index if in support channel.
indexPath = ReleaseChannelSupport + ".json"
if releaseChannel == ReleaseChannelSupport ||
(releaseChannel == "" && indexExists(registry, indexPath)) {
registry.AddIndex(updater.Index{
Path: indexPath,
AutoDownload: autoDownload,
})
usePreReleases = true
} else if deleteUnusedIndexes {
err := deleteIndex(registry, indexPath)
if err != nil {
warning = fmt.Errorf("failed to delete unused index %s: %w", indexPath, err)
}
}
// Set pre-release usage.
registry.SetUsePreReleases(usePreReleases)
return warning
}
func indexExists(registry *updater.ResourceRegistry, indexPath string) bool {
_, err := os.Stat(filepath.Join(registry.StorageDir().Path, indexPath))
return err == nil
}
func deleteIndex(registry *updater.ResourceRegistry, indexPath string) error {
// Remove index itself.
err := os.Remove(filepath.Join(registry.StorageDir().Path, indexPath))
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
// Remove any accompanying signature.
err = os.Remove(filepath.Join(registry.StorageDir().Path, indexPath+filesig.Extension))
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
return nil
}

View File

@@ -0,0 +1,42 @@
package helper
import (
"github.com/safing/jess"
"github.com/safing/portbase/updater"
)
var (
// VerificationConfig holds the complete verification configuration for the registry.
VerificationConfig = map[string]*updater.VerificationOptions{
"": { // Default.
TrustStore: BinarySigningTrustStore,
DownloadPolicy: updater.SignaturePolicyRequire,
DiskLoadPolicy: updater.SignaturePolicyWarn,
},
"all/intel/": nil, // Disable until IntelHub supports signing.
}
// BinarySigningKeys holds the signing keys in text format.
BinarySigningKeys = []string{
// Safing Code Signing Key #1
"recipient:public-ed25519-key:safing-code-signing-key-1:92bgBLneQUWrhYLPpBDjqHbpFPuNVCPAaivQ951A4aq72HcTiw7R1QmPJwFM1mdePAvEVDjkeb8S4fp2pmRCsRa8HrCvWQEjd88rfZ6TznJMfY4g7P8ioGFjfpyx2ZJ8WCZJG5Qt4Z9nkabhxo2Nbi3iywBTYDLSbP5CXqi7jryW7BufWWuaRVufFFzhwUC2ryWFWMdkUmsAZcvXwde4KLN9FrkWAy61fGaJ8GCwGnGCSitANnU2cQrsGBXZzxmzxwrYD",
// Safing Code Signing Key #2
"recipient:public-ed25519-key:safing-code-signing-key-2:92bgBLneQUWrhYLPpBDjqHbPC2d1o5JMyZFdavWBNVtdvbPfzDewLW95ScXfYPHd3QvWHSWCtB4xpthaYWxSkK1kYiGp68DPa2HaU8yQ5dZhaAUuV4Kzv42pJcWkCeVnBYqgGBXobuz52rFqhDJy3rz7soXEmYhJEJWwLwMeioK3VzN3QmGSYXXjosHMMNC76rjufSoLNtUQUWZDSnHmqbuxbKMCCsjFXUGGhtZVyb7bnu7QLTLk6SKHBJDMB6zdL9sw3",
}
// BinarySigningTrustStore is an in-memory trust store with the signing keys.
BinarySigningTrustStore = jess.NewMemTrustStore()
)
func init() {
for _, signingKey := range BinarySigningKeys {
rcpt, err := jess.RecipientFromTextFormat(signingKey)
if err != nil {
panic(err)
}
err = BinarySigningTrustStore.StoreSignet(rcpt)
if err != nil {
panic(err)
}
}
}

View File

@@ -0,0 +1,92 @@
package helper
import (
"fmt"
"runtime"
"github.com/tevino/abool"
)
const onWindows = runtime.GOOS == "windows"
var intelOnly = abool.New()
// IntelOnly specifies that only intel data is mandatory.
func IntelOnly() {
intelOnly.Set()
}
// PlatformIdentifier converts identifier for the current platform.
func PlatformIdentifier(identifier string) string {
// From https://golang.org/pkg/runtime/#GOARCH
// GOOS is the running program's operating system target: one of darwin, freebsd, linux, and so on.
// GOARCH is the running program's architecture target: one of 386, amd64, arm, s390x, and so on.
return fmt.Sprintf("%s_%s/%s", runtime.GOOS, runtime.GOARCH, identifier)
}
// MandatoryUpdates returns mandatory updates that should be loaded on install
// or reset.
func MandatoryUpdates() (identifiers []string) {
// Intel
identifiers = append(
identifiers,
// Filter lists data
"all/intel/lists/index.dsd",
"all/intel/lists/base.dsdl",
"all/intel/lists/intermediate.dsdl",
"all/intel/lists/urgent.dsdl",
// Geo IP data
"all/intel/geoip/geoipv4.mmdb.gz",
"all/intel/geoip/geoipv6.mmdb.gz",
)
// Stop here if we only want intel data.
if intelOnly.IsSet() {
return identifiers
}
// Binaries
if onWindows {
identifiers = append(
identifiers,
PlatformIdentifier("core/portmaster-core.exe"),
PlatformIdentifier("kext/portmaster-kext.sys"),
PlatformIdentifier("kext/portmaster-kext.pdb"),
PlatformIdentifier("start/portmaster-start.exe"),
PlatformIdentifier("notifier/portmaster-notifier.exe"),
PlatformIdentifier("notifier/portmaster-wintoast.dll"),
)
} else {
identifiers = append(
identifiers,
PlatformIdentifier("core/portmaster-core"),
PlatformIdentifier("start/portmaster-start"),
PlatformIdentifier("notifier/portmaster-notifier"),
)
}
// Components, Assets and Data
identifiers = append(
identifiers,
// User interface components
PlatformIdentifier("app/portmaster-app.zip"),
"all/ui/modules/portmaster.zip",
"all/ui/modules/assets.zip",
)
return identifiers
}
// AutoUnpackUpdates returns assets that need unpacking.
func AutoUnpackUpdates() []string {
if intelOnly.IsSet() {
return []string{}
}
return []string{
PlatformIdentifier("app/portmaster-app.zip"),
}
}

343
service/updates/main.go Normal file
View File

@@ -0,0 +1,343 @@
package updates
import (
"context"
"errors"
"flag"
"fmt"
"net/url"
"runtime"
"time"
"github.com/safing/portbase/database"
"github.com/safing/portbase/dataroot"
"github.com/safing/portbase/log"
"github.com/safing/portbase/modules"
"github.com/safing/portbase/updater"
"github.com/safing/portmaster/service/updates/helper"
)
const (
onWindows = runtime.GOOS == "windows"
enableSoftwareUpdatesKey = "core/automaticUpdates"
enableIntelUpdatesKey = "core/automaticIntelUpdates"
// ModuleName is the name of the update module
// and can be used when declaring module dependencies.
ModuleName = "updates"
// VersionUpdateEvent is emitted every time a new
// version of a monitored resource is selected.
// During module initialization VersionUpdateEvent
// is also emitted.
VersionUpdateEvent = "active version update"
// ResourceUpdateEvent is emitted every time the
// updater successfully performed a resource update.
// ResourceUpdateEvent is emitted even if no new
// versions are available. Subscribers are expected
// to check if new versions of their resources are
// available by checking File.UpgradeAvailable().
ResourceUpdateEvent = "resource update"
)
var (
module *modules.Module
registry *updater.ResourceRegistry
userAgentFromFlag string
updateServerFromFlag string
updateTask *modules.Task
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.
UserAgent = fmt.Sprintf("Portmaster (%s %s)", runtime.GOOS, runtime.GOARCH)
// DefaultUpdateURLs defines the default base URLs of the update server.
DefaultUpdateURLs = []string{
"https://updates.safing.io",
}
// DisableSoftwareAutoUpdate specifies whether software updates should be disabled.
// This is used on Android, as it will never require binary updates.
DisableSoftwareAutoUpdate = false
)
const (
updatesDirName = "updates"
updateTaskRepeatDuration = 1 * time.Hour
)
func init() {
module = modules.Register(ModuleName, prep, start, stop, "base")
module.RegisterEvent(VersionUpdateEvent, true)
module.RegisterEvent(ResourceUpdateEvent, true)
flag.StringVar(&updateServerFromFlag, "update-server", "", "set an alternative update server (full URL)")
flag.StringVar(&userAgentFromFlag, "update-agent", "", "set an alternative user agent for requests to the update server")
}
func prep() error {
if err := registerConfig(); err != nil {
return err
}
// Check if update server URL supplied via flag is a valid URL.
if updateServerFromFlag != "" {
u, err := url.Parse(updateServerFromFlag)
if err != nil {
return fmt.Errorf("supplied update server URL is invalid: %w", err)
}
if u.Scheme != "https" {
return errors.New("supplied update server URL must use HTTPS")
}
}
return registerAPIEndpoints()
}
func start() error {
initConfig()
restartTask = module.NewTask("automatic restart", automaticRestart).MaxDelay(10 * time.Minute)
if err := module.RegisterEventHook(
"config",
"config change",
"update registry config",
updateRegistryConfig); err != nil {
return err
}
// create registry
registry = &updater.ResourceRegistry{
Name: ModuleName,
UpdateURLs: DefaultUpdateURLs,
UserAgent: UserAgent,
MandatoryUpdates: helper.MandatoryUpdates(),
AutoUnpack: helper.AutoUnpackUpdates(),
Verification: helper.VerificationConfig,
DevMode: devMode(),
Online: true,
}
// Override values from flags.
if userAgentFromFlag != "" {
registry.UserAgent = userAgentFromFlag
}
if updateServerFromFlag != "" {
registry.UpdateURLs = []string{updateServerFromFlag}
}
// pre-init state
updateStateExport, err := LoadStateExport()
if err != nil {
log.Debugf("updates: failed to load exported update state: %s", err)
} else if updateStateExport.UpdateState != nil {
err := registry.PreInitUpdateState(*updateStateExport.UpdateState)
if err != nil {
return err
}
}
// initialize
err = registry.Initialize(dataroot.Root().ChildDir(updatesDirName, 0o0755))
if err != nil {
return err
}
// register state provider
err = registerRegistryStateProvider()
if err != nil {
return err
}
registry.StateNotifyFunc = pushRegistryState
// Set indexes based on the release channel.
warning := helper.SetIndexes(
registry,
initialReleaseChannel,
true,
enableSoftwareUpdates() && !DisableSoftwareAutoUpdate,
enableIntelUpdates(),
)
if warning != nil {
log.Warningf("updates: %s", warning)
}
err = registry.LoadIndexes(module.Ctx)
if err != nil {
log.Warningf("updates: failed to load indexes: %s", err)
}
err = registry.ScanStorage("")
if err != nil {
log.Warningf("updates: error during storage scan: %s", err)
}
registry.SelectVersions()
module.TriggerEvent(VersionUpdateEvent, nil)
// Initialize the version export - this requires the registry to be set up.
err = initVersionExport()
if err != nil {
return err
}
// start updater task
updateTask = module.NewTask("updater", func(ctx context.Context, task *modules.Task) error {
return checkForUpdates(ctx)
})
if !disableTaskSchedule {
updateTask.
Repeat(updateTaskRepeatDuration).
MaxDelay(30 * time.Minute)
}
if updateASAP {
updateTask.StartASAP()
}
// react to upgrades
if err := initUpgrader(); err != nil {
return err
}
warnOnIncorrectParentPath()
return nil
}
// TriggerUpdate queues the update task to execute ASAP.
func TriggerUpdate(forceIndexCheck, downloadAll bool) error {
switch {
case !module.Online():
updateASAP = true
case !forceIndexCheck && !enableSoftwareUpdates() && !enableIntelUpdates():
return fmt.Errorf("automatic updating is disabled")
default:
if forceIndexCheck {
forceCheck.Set()
}
if downloadAll {
forceDownload.Set()
}
// If index check if forced, start quicker.
if forceIndexCheck {
updateTask.StartASAP()
} else {
updateTask.Queue()
}
}
log.Debugf("updates: triggering update to run as soon as possible")
return nil
}
// DisableUpdateSchedule disables the update schedule.
// If called, updates are only checked when TriggerUpdate()
// is called.
func DisableUpdateSchedule() error {
switch module.Status() {
case modules.StatusStarting, modules.StatusOnline, modules.StatusStopping:
return fmt.Errorf("module already online")
}
disableTaskSchedule = true
return nil
}
func checkForUpdates(ctx context.Context) (err error) {
// Set correct error if context was canceled.
defer func() {
select {
case <-ctx.Done():
err = context.Canceled
default:
}
}()
// Get flags.
forceIndexCheck := forceCheck.SetToIf(true, false)
downloadAll := forceDownload.SetToIf(true, false)
// Check again if downloading updates is enabled, or forced.
if !forceIndexCheck && !enableSoftwareUpdates() && !enableIntelUpdates() {
log.Warningf("updates: automatic updates are disabled")
return nil
}
defer func() {
// Resolve any error and send success notification.
if err == nil {
log.Infof("updates: successfully checked for updates")
notifyUpdateSuccess(forceIndexCheck)
return
}
// Log and notify error.
log.Errorf("updates: check failed: %s", err)
notifyUpdateCheckFailed(forceIndexCheck, err)
}()
if err = registry.UpdateIndexes(ctx); err != nil {
err = fmt.Errorf("failed to update indexes: %w", err)
return //nolint:nakedret // TODO: Would "return err" work with the defer?
}
err = registry.DownloadUpdates(ctx, downloadAll)
if err != nil {
err = fmt.Errorf("failed to download updates: %w", err)
return //nolint:nakedret // TODO: Would "return err" work with the defer?
}
registry.SelectVersions()
// Unpack selected resources.
err = registry.UnpackResources()
if err != nil {
err = fmt.Errorf("failed to unpack updates: %w", err)
return //nolint:nakedret // TODO: Would "return err" work with the defer?
}
// Purge old resources
registry.Purge(2)
module.TriggerEvent(ResourceUpdateEvent, nil)
return nil
}
func stop() error {
if registry != nil {
err := registry.Cleanup()
if err != nil {
log.Warningf("updates: failed to clean up registry: %s", err)
}
}
return nil
}
// RootPath returns the root path used for storing updates.
func RootPath() string {
if !module.Online() {
return ""
}
return registry.StorageDir().Path
}

168
service/updates/notify.go Normal file
View File

@@ -0,0 +1,168 @@
package updates
import (
"fmt"
"strings"
"sync/atomic"
"time"
"github.com/safing/portbase/notifications"
)
const (
updateFailed = "updates:failed"
updateSuccess = "updates:success"
updateSuccessPending = "updates:success-pending"
updateSuccessDownloaded = "updates:success-downloaded"
failedUpdateNotifyDurationThreshold = 24 * time.Hour
failedUpdateNotifyCountThreshold = 3
)
var updateFailedCnt = new(atomic.Int32)
func notifyUpdateSuccess(force bool) {
updateFailedCnt.Store(0)
module.Resolve(updateFailed)
updateState := registry.GetState().Updates
flavor := updateSuccess
switch {
case len(updateState.PendingDownload) > 0:
// Show notification if there are pending downloads.
flavor = updateSuccessPending
case updateState.LastDownloadAt != nil &&
time.Since(*updateState.LastDownloadAt) < 5*time.Second:
// Show notification if we downloaded something within the last minute.
flavor = updateSuccessDownloaded
case force:
// Always show notification if update was manually triggered.
default:
// Otherwise, the update was uneventful. Do not show notification.
return
}
switch flavor {
case updateSuccess:
notifications.Notify(&notifications.Notification{
EventID: updateSuccess,
Type: notifications.Info,
Title: "Portmaster Is Up-To-Date",
Message: "Portmaster successfully checked for updates. Everything is up to date.\n\n" + getUpdatingInfoMsg(),
Expires: time.Now().Add(1 * time.Minute).Unix(),
AvailableActions: []*notifications.Action{
{
ID: "ack",
Text: "OK",
},
},
})
case updateSuccessPending:
msg := fmt.Sprintf(
`%d updates are available for download:
- %s
Press "Download Now" to download and automatically apply all pending updates. You will be notified of important updates that need restarting.`,
len(updateState.PendingDownload),
strings.Join(updateState.PendingDownload, "\n- "),
)
notifications.Notify(&notifications.Notification{
EventID: updateSuccess,
Type: notifications.Info,
Title: fmt.Sprintf("%d Updates Available", len(updateState.PendingDownload)),
Message: msg,
AvailableActions: []*notifications.Action{
{
ID: "ack",
Text: "OK",
},
{
ID: "download",
Text: "Download Now",
Type: notifications.ActionTypeWebhook,
Payload: &notifications.ActionTypeWebhookPayload{
URL: apiPathCheckForUpdates + "?download",
ResultAction: "display",
},
},
},
})
case updateSuccessDownloaded:
msg := fmt.Sprintf(
`%d updates were downloaded and applied:
- %s
%s
`,
len(updateState.LastDownload),
strings.Join(updateState.LastDownload, "\n- "),
getUpdatingInfoMsg(),
)
notifications.Notify(&notifications.Notification{
EventID: updateSuccess,
Type: notifications.Info,
Title: fmt.Sprintf("%d Updates Applied", len(updateState.LastDownload)),
Message: msg,
Expires: time.Now().Add(1 * time.Minute).Unix(),
AvailableActions: []*notifications.Action{
{
ID: "ack",
Text: "OK",
},
},
})
}
}
func getUpdatingInfoMsg() string {
switch {
case enableSoftwareUpdates() && enableIntelUpdates():
return "You will be notified of important updates that need restarting."
case enableIntelUpdates():
return "Automatic software updates are disabled, but you will be notified when a new software update is ready to be downloaded and applied."
default:
return "Automatic software updates are disabled. Please check for updates regularly yourself."
}
}
func notifyUpdateCheckFailed(force bool, err error) {
failedCnt := updateFailedCnt.Add(1)
lastSuccess := registry.GetState().Updates.LastSuccessAt
switch {
case force:
// Always show notification if update was manually triggered.
case failedCnt < failedUpdateNotifyCountThreshold:
// Not failed often enough for notification.
return
case lastSuccess == nil:
// No recorded successful update.
case time.Now().Add(-failedUpdateNotifyDurationThreshold).Before(*lastSuccess):
// Failed too recently for notification.
return
}
notifications.NotifyWarn(
updateFailed,
"Update Check Failed",
fmt.Sprintf(
"Portmaster failed to check for updates. This might be a temporary issue of your device, your network or the update servers. The Portmaster will automatically try again later. The error was: %s",
err,
),
notifications.Action{
Text: "Try Again Now",
Type: notifications.ActionTypeWebhook,
Payload: &notifications.ActionTypeWebhookPayload{
URL: apiPathCheckForUpdates,
ResultAction: "display",
},
},
).AttachToModule(module)
}

View File

@@ -0,0 +1,8 @@
//go:build !linux
// +build !linux
package updates
func upgradeSystemIntegration() error {
return nil
}

View File

@@ -0,0 +1,204 @@
package updates
import (
"bytes"
"crypto/sha256"
_ "embed"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"github.com/tevino/abool"
"golang.org/x/exp/slices"
"github.com/safing/portbase/dataroot"
"github.com/safing/portbase/log"
"github.com/safing/portbase/utils/renameio"
)
var (
portmasterCoreServiceFilePath = "portmaster.service"
portmasterNotifierServiceFilePath = "portmaster_notifier.desktop"
backupExtension = ".backup"
//go:embed assets/portmaster.service
currentPortmasterCoreServiceFile []byte
checkedSystemIntegration = abool.New()
// ErrRequiresManualUpgrade is returned when a system integration file requires a manual upgrade.
ErrRequiresManualUpgrade = errors.New("requires a manual upgrade")
)
func upgradeSystemIntegration() {
// Check if we already checked the system integration.
if !checkedSystemIntegration.SetToIf(false, true) {
return
}
// Upgrade portmaster core systemd service.
err := upgradeSystemIntegrationFile(
"portmaster core systemd service",
filepath.Join(dataroot.Root().Path, portmasterCoreServiceFilePath),
0o0600,
currentPortmasterCoreServiceFile,
[]string{
"bc26dd37e6953af018ad3676ee77570070e075f2b9f5df6fa59d65651a481468", // Commit 19c76c7 on 2022-01-25
"cc0cb49324dfe11577e8c066dd95cc03d745b50b2153f32f74ca35234c3e8cb5", // Commit ef479e5 on 2022-01-24
"d08a3b5f3aee351f8e120e6e2e0a089964b94c9e9d0a9e5fa822e60880e315fd", // Commit b64735e on 2021-12-07
},
)
if err != nil {
log.Warningf("updates: %s", err)
return
}
// Upgrade portmaster notifier systemd user service.
// Permissions only!
err = upgradeSystemIntegrationFile(
"portmaster notifier systemd user service",
filepath.Join(dataroot.Root().Path, portmasterNotifierServiceFilePath),
0o0644,
nil, // Do not update contents.
nil, // Do not update contents.
)
if err != nil {
log.Warningf("updates: %s", err)
return
}
}
// upgradeSystemIntegrationFile upgrades the file contents and permissions.
// System integration files are not necessarily present and may also be
// edited by third parties, such as the OS itself or other installers.
// The supplied hashes must be sha256 hex-encoded.
func upgradeSystemIntegrationFile(
name string,
filePath string,
fileMode fs.FileMode,
fileData []byte,
permittedUpgradeHashes []string,
) error {
// Upgrade file contents.
if len(fileData) > 0 {
if err := upgradeSystemIntegrationFileContents(name, filePath, fileData, permittedUpgradeHashes); err != nil {
return err
}
}
// Upgrade file permissions.
if fileMode != 0 {
if err := upgradeSystemIntegrationFilePermissions(name, filePath, fileMode); err != nil {
return err
}
}
return nil
}
// upgradeSystemIntegrationFileContents upgrades the file contents.
// System integration files are not necessarily present and may also be
// edited by third parties, such as the OS itself or other installers.
// The supplied hashes must be sha256 hex-encoded.
func upgradeSystemIntegrationFileContents(
name string,
filePath string,
fileData []byte,
permittedUpgradeHashes []string,
) error {
// Read existing file.
existingFileData, err := os.ReadFile(filePath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("failed to read %s at %s: %w", name, filePath, err)
}
// Check if file is already the current version.
existingSum := sha256.Sum256(existingFileData)
existingHexSum := hex.EncodeToString(existingSum[:])
currentSum := sha256.Sum256(fileData)
currentHexSum := hex.EncodeToString(currentSum[:])
if existingHexSum == currentHexSum {
log.Debugf("updates: %s at %s is up to date", name, filePath)
return nil
}
// Check if we are allowed to upgrade from the existing file.
if !slices.Contains[[]string, string](permittedUpgradeHashes, existingHexSum) {
return fmt.Errorf("%s at %s (sha256:%s) %w, as it is not a previously published version and cannot be automatically upgraded - try installing again", name, filePath, existingHexSum, ErrRequiresManualUpgrade)
}
// Start with upgrade!
// Make backup of existing file.
err = CopyFile(filePath, filePath+backupExtension)
if err != nil {
return fmt.Errorf(
"failed to create backup of %s from %s to %s: %w",
name,
filePath,
filePath+backupExtension,
err,
)
}
// Open destination file for writing.
atomicDstFile, err := renameio.TempFile(registry.TmpDir().Path, filePath)
if err != nil {
return fmt.Errorf("failed to create tmp file to update %s at %s: %w", name, filePath, err)
}
defer atomicDstFile.Cleanup() //nolint:errcheck // ignore error for now, tmp dir will be cleaned later again anyway
// Write file.
_, err = io.Copy(atomicDstFile, bytes.NewReader(fileData))
if err != nil {
return err
}
// Finalize file.
err = atomicDstFile.CloseAtomicallyReplace()
if err != nil {
return fmt.Errorf("failed to finalize update of %s at %s: %w", name, filePath, err)
}
log.Warningf("updates: %s at %s was upgraded to %s - a reboot may be required", name, filePath, currentHexSum)
return nil
}
// upgradeSystemIntegrationFilePermissions upgrades the file permissions.
// System integration files are not necessarily present and may also be
// edited by third parties, such as the OS itself or other installers.
func upgradeSystemIntegrationFilePermissions(
name string,
filePath string,
fileMode fs.FileMode,
) error {
// Get current file permissions.
stat, err := os.Stat(filePath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("failed to read %s file metadata at %s: %w", name, filePath, err)
}
// If permissions are as expected, do nothing.
if stat.Mode().Perm() == fileMode {
return nil
}
// Otherwise, set correct permissions.
err = os.Chmod(filePath, fileMode)
if err != nil {
return fmt.Errorf("failed to update %s file permissions at %s: %w", name, filePath, err)
}
log.Warningf("updates: %s file permissions at %s updated to %v", name, filePath, fileMode)
return nil
}

143
service/updates/restart.go Normal file
View File

@@ -0,0 +1,143 @@
package updates
import (
"context"
"os/exec"
"runtime"
"sync"
"time"
"github.com/tevino/abool"
"github.com/safing/portbase/log"
"github.com/safing/portbase/modules"
)
const (
// RestartExitCode will instruct portmaster-start to restart the process immediately, potentially with a new version.
RestartExitCode = 23
)
var (
// RebootOnRestart defines whether the whole system, not just the service,
// should be restarted automatically when triggering a restart internally.
RebootOnRestart bool
restartTask *modules.Task
restartPending = abool.New()
restartTriggered = abool.New()
restartTime time.Time
restartTimeLock sync.Mutex
)
// IsRestarting returns whether a restart has been triggered.
func IsRestarting() bool {
return restartTriggered.IsSet()
}
// RestartIsPending returns whether a restart is pending.
func RestartIsPending() (pending bool, restartAt time.Time) {
if restartPending.IsNotSet() {
return false, time.Time{}
}
restartTimeLock.Lock()
defer restartTimeLock.Unlock()
return true, restartTime
}
// DelayedRestart triggers a restart of the application by shutting down the
// module system gracefully and returning with RestartExitCode. The restart
// may be further delayed by up to 10 minutes by the internal task scheduling
// system. This only works if the process is managed by portmaster-start.
func DelayedRestart(delay time.Duration) {
// Check if restart is already pending.
if !restartPending.SetToIf(false, true) {
return
}
// Schedule the restart task.
log.Warningf("updates: restart triggered, will execute in %s", delay)
restartAt := time.Now().Add(delay)
restartTask.Schedule(restartAt)
// Set restartTime.
restartTimeLock.Lock()
defer restartTimeLock.Unlock()
restartTime = restartAt
}
// AbortRestart aborts a (delayed) restart.
func AbortRestart() {
if restartPending.SetToIf(true, false) {
log.Warningf("updates: restart aborted")
// Cancel schedule.
restartTask.Schedule(time.Time{})
}
}
// TriggerRestartIfPending triggers an automatic restart, if one is pending.
// This can be used to prepone a scheduled restart if the conditions are preferable.
func TriggerRestartIfPending() {
if restartPending.IsSet() {
restartTask.StartASAP()
}
}
// RestartNow immediately executes a restart.
// This only works if the process is managed by portmaster-start.
func RestartNow() {
restartPending.Set()
restartTask.StartASAP()
}
func automaticRestart(_ context.Context, _ *modules.Task) error {
// Check if the restart is still scheduled.
if restartPending.IsNotSet() {
return nil
}
// Trigger restart.
if restartTriggered.SetToIf(false, true) {
log.Warning("updates: initiating (automatic) restart")
// Check if we should reboot instead.
var rebooting bool
if RebootOnRestart {
// Trigger system reboot and record success.
rebooting = triggerSystemReboot()
if !rebooting {
log.Warningf("updates: rebooting failed, only restarting service instead")
}
}
// Set restart exit code.
if !rebooting {
modules.SetExitStatusCode(RestartExitCode)
}
// Do not use a worker, as this would block itself here.
go modules.Shutdown() //nolint:errcheck
}
return nil
}
func triggerSystemReboot() (success bool) {
switch runtime.GOOS {
case "linux":
err := exec.Command("systemctl", "reboot").Run()
if err != nil {
log.Errorf("updates: triggering reboot with systemctl failed: %s", err)
return false
}
default:
log.Warningf("updates: rebooting is not support on %s", runtime.GOOS)
return false
}
return true
}

49
service/updates/state.go Normal file
View File

@@ -0,0 +1,49 @@
package updates
import (
"github.com/safing/portbase/database/record"
"github.com/safing/portbase/runtime"
"github.com/safing/portbase/updater"
)
var pushRegistryStatusUpdate runtime.PushFunc
// RegistryStateExport is a wrapper to export the registry state.
type RegistryStateExport struct {
record.Base
*updater.RegistryState
}
func exportRegistryState(s *updater.RegistryState) *RegistryStateExport {
if s == nil {
state := registry.GetState()
s = &state
}
export := &RegistryStateExport{
RegistryState: s,
}
export.CreateMeta()
export.SetKey("runtime:core/updates/state")
return export
}
func pushRegistryState(s *updater.RegistryState) {
export := exportRegistryState(s)
pushRegistryStatusUpdate(export)
}
func registerRegistryStateProvider() (err error) {
registryStateProvider := runtime.SimpleValueGetterFunc(func(_ string) ([]record.Record, error) {
return []record.Record{exportRegistryState(nil)}, nil
})
pushRegistryStatusUpdate, err = runtime.Register("core/updates/state", registryStateProvider)
if err != nil {
return err
}
return nil
}

409
service/updates/upgrader.go Normal file
View File

@@ -0,0 +1,409 @@
package updates
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
processInfo "github.com/shirou/gopsutil/process"
"github.com/tevino/abool"
"github.com/safing/portbase/dataroot"
"github.com/safing/portbase/info"
"github.com/safing/portbase/log"
"github.com/safing/portbase/notifications"
"github.com/safing/portbase/rng"
"github.com/safing/portbase/updater"
"github.com/safing/portbase/utils/renameio"
"github.com/safing/portmaster/service/updates/helper"
)
const (
upgradedSuffix = "-upgraded"
exeExt = ".exe"
)
var (
upgraderActive = abool.NewBool(false)
pmCtrlUpdate *updater.File
pmCoreUpdate *updater.File
spnHubUpdate *updater.File
rawVersionRegex = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+b?\*?$`)
)
func initUpgrader() error {
return module.RegisterEventHook(
ModuleName,
ResourceUpdateEvent,
"run upgrades",
upgrader,
)
}
func upgrader(_ context.Context, _ interface{}) error {
// Lock runs, but discard additional runs.
if !upgraderActive.SetToIf(false, true) {
return nil
}
defer upgraderActive.SetTo(false)
// Upgrade portmaster-start.
err := upgradePortmasterStart()
if err != nil {
log.Warningf("updates: failed to upgrade portmaster-start: %s", err)
}
// Upgrade based on binary.
binBaseName := strings.Split(filepath.Base(os.Args[0]), "_")[0]
switch binBaseName {
case "portmaster-core":
// Notify about upgrade.
if err := upgradeCoreNotify(); err != nil {
log.Warningf("updates: failed to notify about core upgrade: %s", err)
}
// Fix chrome sandbox permissions.
if err := helper.EnsureChromeSandboxPermissions(registry); err != nil {
log.Warningf("updates: failed to handle electron upgrade: %s", err)
}
// Upgrade system integration.
upgradeSystemIntegration()
case "spn-hub":
// Trigger upgrade procedure.
if err := upgradeHub(); err != nil {
log.Warningf("updates: failed to initiate hub upgrade: %s", err)
}
}
return nil
}
func upgradeCoreNotify() error {
if pmCoreUpdate != nil && !pmCoreUpdate.UpgradeAvailable() {
return nil
}
// make identifier
identifier := "core/portmaster-core" // identifier, use forward slash!
if onWindows {
identifier += exeExt
}
// get newest portmaster-core
newFile, err := GetPlatformFile(identifier)
if err != nil {
return err
}
pmCoreUpdate = newFile
// check for new version
if info.GetInfo().Version != pmCoreUpdate.Version() {
n := notifications.Notify(&notifications.Notification{
EventID: "updates:core-update-available",
Type: notifications.Info,
Title: fmt.Sprintf(
"Portmaster Update v%s Is Ready!",
pmCoreUpdate.Version(),
),
Category: "Core",
Message: fmt.Sprintf(
`A new Portmaster version is ready to go! Restart the Portmaster to upgrade to %s.`,
pmCoreUpdate.Version(),
),
ShowOnSystem: true,
AvailableActions: []*notifications.Action{
// TODO: Use special UI action in order to reload UI on restart.
{
ID: "restart",
Text: "Restart",
},
{
ID: "later",
Text: "Not now",
},
},
})
n.SetActionFunction(upgradeCoreNotifyActionHandler)
log.Debugf("updates: new portmaster version available, sending notification to user")
}
return nil
}
func upgradeCoreNotifyActionHandler(_ context.Context, n *notifications.Notification) error {
switch n.SelectedActionID {
case "restart":
log.Infof("updates: user triggered restart via core update notification")
RestartNow()
case "later":
n.Delete()
}
return nil
}
func upgradeHub() error {
if spnHubUpdate != nil && !spnHubUpdate.UpgradeAvailable() {
return nil
}
// Make identifier for getting file from updater.
identifier := "hub/spn-hub" // identifier, use forward slash!
if onWindows {
identifier += exeExt
}
// Get newest spn-hub file.
newFile, err := GetPlatformFile(identifier)
if err != nil {
return err
}
spnHubUpdate = newFile
// Check if the new version is different.
if info.GetInfo().Version != spnHubUpdate.Version() {
// Get random delay with up to three hours.
delayMinutes, err := rng.Number(3 * 60)
if err != nil {
return err
}
// Delay restart for at least one hour for preparations.
DelayedRestart(time.Duration(delayMinutes+60) * time.Minute)
// Increase update checks in order to detect aborts better.
if !disableTaskSchedule {
updateTask.Repeat(10 * time.Minute)
}
} else {
AbortRestart()
// Set update task schedule back to normal.
if !disableTaskSchedule {
updateTask.Repeat(updateTaskRepeatDuration)
}
}
return nil
}
func upgradePortmasterStart() error {
filename := "portmaster-start"
if onWindows {
filename += exeExt
}
// check if we can upgrade
if pmCtrlUpdate == nil || pmCtrlUpdate.UpgradeAvailable() {
// get newest portmaster-start
newFile, err := GetPlatformFile("start/" + filename) // identifier, use forward slash!
if err != nil {
return err
}
pmCtrlUpdate = newFile
} else {
return nil
}
// update portmaster-start in data root
rootPmStartPath := filepath.Join(dataroot.Root().Path, filename)
err := upgradeBinary(rootPmStartPath, pmCtrlUpdate)
if err != nil {
return err
}
return nil
}
func warnOnIncorrectParentPath() {
expectedFileName := "portmaster-start"
if onWindows {
expectedFileName += exeExt
}
// upgrade parent process, if it's portmaster-start
parent, err := processInfo.NewProcess(int32(os.Getppid()))
if err != nil {
log.Tracef("could not get parent process: %s", err)
return
}
parentName, err := parent.Name()
if err != nil {
log.Tracef("could not get parent process name: %s", err)
return
}
if parentName != expectedFileName {
// Only warn about this if not in dev mode.
if !devMode() {
log.Warningf("updates: parent process does not seem to be portmaster-start, name is %s", parentName)
}
// TODO(ppacher): once we released a new installer and folks had time
// to update we should send a module warning/hint to the
// UI notifying the user that he's still using portmaster-control.
return
}
parentPath, err := parent.Exe()
if err != nil {
log.Tracef("could not get parent process path: %s", err)
return
}
absPath, err := filepath.Abs(parentPath)
if err != nil {
log.Tracef("could not get absolut parent process path: %s", err)
return
}
root := filepath.Dir(registry.StorageDir().Path)
if !strings.HasPrefix(absPath, root) {
log.Warningf("detected unexpected path %s for portmaster-start", absPath)
notifications.NotifyWarn(
"updates:unsupported-parent",
"Unsupported Launcher",
fmt.Sprintf(
"The Portmaster has been launched by an unexpected %s binary at %s. Please configure your system to use the binary at %s as this version will be kept up to date automatically.",
expectedFileName,
absPath,
filepath.Join(root, expectedFileName),
),
)
}
}
func upgradeBinary(fileToUpgrade string, file *updater.File) error {
fileExists := false
_, err := os.Stat(fileToUpgrade)
if err == nil {
// file exists and is accessible
fileExists = true
}
if fileExists {
// get current version
var currentVersion string
cmd := exec.Command(fileToUpgrade, "version", "--short")
out, err := cmd.Output()
if err == nil {
// abort if version matches
currentVersion = strings.Trim(strings.TrimSpace(string(out)), "*")
if currentVersion == file.Version() {
log.Debugf("updates: %s is already v%s", fileToUpgrade, file.Version())
// already up to date!
return nil
}
} else {
log.Warningf("updates: failed to run %s to get version for upgrade check: %s", fileToUpgrade, err)
currentVersion = "0.0.0"
}
// test currentVersion for sanity
if !rawVersionRegex.MatchString(currentVersion) {
log.Debugf("updates: version string returned by %s is invalid: %s", fileToUpgrade, currentVersion)
}
// try removing old version
err = os.Remove(fileToUpgrade)
if err != nil {
// ensure tmp dir is here
err = registry.TmpDir().Ensure()
if err != nil {
return fmt.Errorf("could not prepare tmp directory for moving file that needs upgrade: %w", err)
}
// maybe we're on windows and it's in use, try moving
err = os.Rename(fileToUpgrade, filepath.Join(
registry.TmpDir().Path,
fmt.Sprintf(
"%s-%d%s",
filepath.Base(fileToUpgrade),
time.Now().UTC().Unix(),
upgradedSuffix,
),
))
if err != nil {
return fmt.Errorf("unable to move file that needs upgrade: %w", err)
}
}
}
// copy upgrade
err = CopyFile(file.Path(), fileToUpgrade)
if err != nil {
// try again
time.Sleep(1 * time.Second)
err = CopyFile(file.Path(), fileToUpgrade)
if err != nil {
return err
}
}
// check permissions
if !onWindows {
info, err := os.Stat(fileToUpgrade)
if err != nil {
return fmt.Errorf("failed to get file info on %s: %w", fileToUpgrade, err)
}
if info.Mode() != 0o0755 {
err := os.Chmod(fileToUpgrade, 0o0755) //nolint:gosec // Set execute permissions.
if err != nil {
return fmt.Errorf("failed to set permissions on %s: %w", fileToUpgrade, err)
}
}
}
log.Infof("updates: upgraded %s to v%s", fileToUpgrade, file.Version())
return nil
}
// CopyFile atomically copies a file using the update registry's tmp dir.
func CopyFile(srcPath, dstPath string) error {
// check tmp dir
err := registry.TmpDir().Ensure()
if err != nil {
return fmt.Errorf("could not prepare tmp directory for copying file: %w", err)
}
// open file for writing
atomicDstFile, err := renameio.TempFile(registry.TmpDir().Path, dstPath)
if err != nil {
return fmt.Errorf("could not create temp file for atomic copy: %w", err)
}
defer atomicDstFile.Cleanup() //nolint:errcheck // ignore error for now, tmp dir will be cleaned later again anyway
// open source
srcFile, err := os.Open(srcPath)
if err != nil {
return err
}
defer func() {
_ = srcFile.Close()
}()
// copy data
_, err = io.Copy(atomicDstFile, srcFile)
if err != nil {
return err
}
// finalize file
err = atomicDstFile.CloseAtomicallyReplace()
if err != nil {
return fmt.Errorf("updates: failed to finalize copy to file %s: %w", dstPath, err)
}
return nil
}