Adapt updates package to new updater module in portbase, clean up

This commit is contained in:
Daniel 2019-10-25 13:28:15 +02:00
parent 92ccb36952
commit bfde6cb044
21 changed files with 455 additions and 1210 deletions

63
updates/config.go Normal file
View file

@ -0,0 +1,63 @@
package updates
import (
"context"
"fmt"
"github.com/safing/portbase/config"
)
var (
releaseChannel config.StringOption
devMode config.BoolOption
previousReleaseChannel string
previousDevMode bool
)
func registerConfig() error {
err := config.Register(&config.Option{
Name: "Release Channel",
Key: releaseChannelKey,
Description: "The Release Channel changes which updates are applied. When using beta, you will receive new features earlier and Portmaster will update more frequently. Some beta or experimental features are also available in the stable release channel.",
OptType: config.OptTypeString,
ExpertiseLevel: config.ExpertiseLevelExpert,
ReleaseLevel: config.ReleaseLevelBeta,
RequiresRestart: false,
DefaultValue: releaseChannelStable,
ExternalOptType: "string list",
ValidationRegex: fmt.Sprintf("^(%s|%s)$", releaseChannelStable, releaseChannelBeta),
})
if err != nil {
return err
}
return module.RegisterEventHook("config", "config change", "update registry config", updateRegistryConfig)
}
func initConfig() {
releaseChannel = config.GetAsString(releaseChannelKey, releaseChannelStable)
devMode = config.GetAsBool("core/devMode", false)
}
func updateRegistryConfig(_ context.Context, _ interface{}) error {
changed := false
if releaseChannel() != previousReleaseChannel {
registry.SetBeta(releaseChannel() == releaseChannelBeta)
previousReleaseChannel = releaseChannel()
changed = true
}
if devMode() != previousDevMode {
registry.SetBeta(devMode())
previousDevMode = devMode()
changed = true
}
if changed {
registry.SelectVersions()
module.TriggerEvent(eventVersionUpdate, nil)
}
return nil
}

View file

@ -1,9 +0,0 @@
package updates
// current paths:
// all/ui/assets.zip
// all/ui/modules/base.zip
// all/ui/modules/settings.zip
// all/ui/modules/profilemgr.zip
// all/ui/modules/monitor.zip
// linux_amd64/app/portmaster-ui

129
updates/export.go Normal file
View file

@ -0,0 +1,129 @@
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"
"github.com/safing/portbase/updater"
)
// database key for update information
const (
versionsDBKey = "core:status/versions"
)
// working vars
var (
versionExport *versions
versionExportDB = database.NewInterface(nil)
versionExportHook *database.RegisteredHook
)
// versions holds updates status information.
type versions struct {
record.Base
lock sync.Mutex
Core *info.Info
Resources map[string]*updater.Resource
internalSave bool
}
func initVersionExport() (err error) {
// init export struct
versionExport = &versions{
internalSave: true,
}
versionExport.SetKey(versionsDBKey)
// attach hook to database
versionExportHook, err = database.RegisterHook(query.New(versionsDBKey), &exportHook{})
if err != nil {
return err
}
module.RegisterEventHook(
"updates",
eventVersionUpdate,
"export version status",
export,
)
return nil
}
func stopVersionExport() error {
return versionExportHook.Cancel()
}
var exportMicroTaskName = "update version status"
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)
}
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
}

View file

@ -1,126 +0,0 @@
package updates
import (
"bytes"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"runtime"
"time"
"github.com/google/renameio"
"github.com/safing/portbase/log"
)
var (
updateURLs = []string{
"https://updates.safing.io",
}
)
func fetchFile(realFilepath, updateFilepath string, tries int) error {
// backoff when retrying
if tries > 0 {
time.Sleep(time.Duration(tries*tries) * time.Second)
}
// create URL
downloadURL, err := joinURLandPath(updateURLs[tries%len(updateURLs)], updateFilepath)
if err != nil {
return fmt.Errorf("error build url (%s + %s): %s", updateURLs[tries%len(updateURLs)], updateFilepath, err)
}
// check destination dir
dirPath := filepath.Dir(realFilepath)
err = updateStorage.EnsureAbsPath(dirPath)
if err != nil {
return fmt.Errorf("could not create updates folder: %s", dirPath)
}
// open file for writing
atomicFile, err := renameio.TempFile(tmpStorage.Path, realFilepath)
if err != nil {
return fmt.Errorf("could not create temp file for download: %s", err)
}
defer atomicFile.Cleanup()
// start file download
resp, err := http.Get(downloadURL)
if err != nil {
return fmt.Errorf("error fetching url (%s): %s", downloadURL, err)
}
defer resp.Body.Close()
// download and write file
n, err := io.Copy(atomicFile, resp.Body)
if err != nil {
return fmt.Errorf("failed downloading %s: %s", downloadURL, err)
}
if resp.ContentLength != n {
return fmt.Errorf("download unfinished, written %d out of %d bytes", n, resp.ContentLength)
}
// finalize file
err = atomicFile.CloseAtomicallyReplace()
if err != nil {
return fmt.Errorf("updates: failed to finalize file %s: %s", realFilepath, err)
}
// set permissions
if runtime.GOOS != "windows" {
// FIXME: only set executable files to 0755, set other to 0644
err = os.Chmod(realFilepath, 0755)
if err != nil {
log.Warningf("updates: failed to set permissions on downloaded file %s: %s", realFilepath, err)
}
}
log.Infof("updates: fetched %s (stored to %s)", downloadURL, realFilepath)
return nil
}
func fetchData(downloadPath string, tries int) ([]byte, error) {
// backoff when retrying
if tries > 0 {
time.Sleep(time.Duration(tries*tries) * time.Second)
}
// create URL
downloadURL, err := joinURLandPath(updateURLs[tries%len(updateURLs)], downloadPath)
if err != nil {
return nil, fmt.Errorf("error build url (%s + %s): %s", updateURLs[tries%len(updateURLs)], downloadPath, err)
}
// start file download
resp, err := http.Get(downloadURL)
if err != nil {
return nil, fmt.Errorf("error fetching url (%s): %s", downloadURL, err)
}
defer resp.Body.Close()
// download and write file
buf := bytes.NewBuffer(make([]byte, 0, resp.ContentLength))
n, err := io.Copy(buf, resp.Body)
if err != nil {
return nil, fmt.Errorf("failed downloading %s: %s", downloadURL, err)
}
if resp.ContentLength != n {
return nil, fmt.Errorf("download unfinished, written %d out of %d bytes", n, resp.ContentLength)
}
return buf.Bytes(), nil
}
func joinURLandPath(baseURL, urlPath string) (string, error) {
u, err := url.Parse(baseURL)
if err != nil {
return "", err
}
u.Path = path.Join(u.Path, urlPath)
return u.String(), nil
}

View file

@ -1,47 +0,0 @@
package updates
// File represents a file from the update system.
type File struct {
filepath string
version string
stable bool
}
// NewFile combines update file attributes into an easy to use object.
func NewFile(filepath string, version string, stable bool) *File {
return &File{
filepath: filepath,
version: version,
stable: stable,
}
}
// Path returns the filepath of the file.
func (f *File) Path() string {
return f.filepath
}
// Version returns the version of the file.
func (f *File) Version() string {
return f.version
}
// Stable returns whether the file is from a stable release.
func (f *File) Stable() bool {
return f.stable
}
// Open opens the file and returns the
func (f *File) Open() {
}
// ReportError reports an error back to Safing. This will not automatically blacklist the file.
func (f *File) ReportError() {
}
// Blacklist notifies the update system that this file is somehow broken, and should be ignored from now on.
func (f *File) Blacklist() {
}

View file

@ -1,46 +0,0 @@
package updates
import (
"fmt"
"regexp"
"strings"
)
var (
fileVersionRegex = regexp.MustCompile(`_v[0-9]+-[0-9]+-[0-9]+b?`)
rawVersionRegex = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+b?\*?$`)
)
// GetIdentifierAndVersion splits the given file path into its identifier and version.
func GetIdentifierAndVersion(versionedPath string) (identifier, version string, ok bool) {
// extract version
rawVersion := fileVersionRegex.FindString(versionedPath)
if rawVersion == "" {
return "", "", false
}
// replace - with . and trim _
version = strings.Replace(strings.TrimLeft(rawVersion, "_v"), "-", ".", -1)
// put together without version
i := strings.Index(versionedPath, rawVersion)
if i < 0 {
// extracted version not in string (impossible)
return "", "", false
}
return versionedPath[:i] + versionedPath[i+len(rawVersion):], version, true
}
// GetVersionedPath combines the identifier and version and returns it as a file path.
func GetVersionedPath(identifier, version string) (versionedPath string) {
// split in half
splittedFilePath := strings.SplitN(identifier, ".", 2)
// replace . with -
transformedVersion := strings.Replace(version, ".", "-", -1)
// put together
if len(splittedFilePath) == 1 {
return fmt.Sprintf("%s_v%s", splittedFilePath[0], transformedVersion)
}
return fmt.Sprintf("%s_v%s.%s", splittedFilePath[0], transformedVersion, splittedFilePath[1])
}

View file

@ -1,51 +0,0 @@
package updates
import (
"regexp"
"testing"
)
func testRegexMatch(t *testing.T, testRegex *regexp.Regexp, testString string, shouldMatch bool) {
if testRegex.MatchString(testString) != shouldMatch {
if shouldMatch {
t.Errorf("regex %s should match %s", testRegex, testString)
} else {
t.Errorf("regex %s should not match %s", testRegex, testString)
}
}
}
func testRegexFind(t *testing.T, testRegex *regexp.Regexp, testString string, shouldMatch bool) {
if (testRegex.FindString(testString) != "") != shouldMatch {
if shouldMatch {
t.Errorf("regex %s should find %s", testRegex, testString)
} else {
t.Errorf("regex %s should not find %s", testRegex, testString)
}
}
}
func TestRegexes(t *testing.T) {
testRegexMatch(t, rawVersionRegex, "0.1.2", true)
testRegexMatch(t, rawVersionRegex, "0.1.2*", true)
testRegexMatch(t, rawVersionRegex, "0.1.2b", true)
testRegexMatch(t, rawVersionRegex, "0.1.2b*", true)
testRegexMatch(t, rawVersionRegex, "12.13.14", true)
testRegexMatch(t, rawVersionRegex, "v0.1.2", false)
testRegexMatch(t, rawVersionRegex, "0.", false)
testRegexMatch(t, rawVersionRegex, "0.1", false)
testRegexMatch(t, rawVersionRegex, "0.1.", false)
testRegexMatch(t, rawVersionRegex, ".1.2", false)
testRegexMatch(t, rawVersionRegex, ".1.", false)
testRegexMatch(t, rawVersionRegex, "012345", false)
testRegexFind(t, fileVersionRegex, "/path/to/file_v1-2-3", true)
testRegexFind(t, fileVersionRegex, "/path/to/file_v1-2-3.exe", true)
testRegexFind(t, fileVersionRegex, "/path/to/file-v1-2-3", false)
testRegexFind(t, fileVersionRegex, "/path/to/file_v1.2.3", false)
testRegexFind(t, fileVersionRegex, "/path/to/file_1-2-3", false)
testRegexFind(t, fileVersionRegex, "/path/to/file_v1-2", false)
testRegexFind(t, fileVersionRegex, "/path/to/file-v1-2-3", false)
}

View file

@ -1,107 +1,38 @@
package updates
import (
"errors"
"fmt"
"os"
"path"
"path/filepath"
"runtime"
"github.com/safing/portbase/log"
)
// Errors
var (
ErrNotFound = errors.New("the requested file could not be found")
ErrNotAvailableLocally = errors.New("the requested file is not available locally")
"github.com/safing/portbase/updater"
)
// GetPlatformFile returns the latest platform specific file identified by the given identifier.
func GetPlatformFile(identifier string) (*File, error) {
func GetPlatformFile(identifier string) (*updater.File, error) {
identifier = path.Join(fmt.Sprintf("%s_%s", runtime.GOOS, runtime.GOARCH), identifier)
// 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 loadOrFetchFile(identifier, true)
}
// GetLocalPlatformFile returns the latest platform specific file identified by the given identifier, that is available locally.
func GetLocalPlatformFile(identifier string) (*File, error) {
identifier = path.Join(fmt.Sprintf("%s_%s", runtime.GOOS, runtime.GOARCH), identifier)
// 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 loadOrFetchFile(identifier, false)
file, err := registry.GetFile(identifier)
if err != nil {
return nil, err
}
module.TriggerEvent(eventVersionUpdate, nil)
return file, nil
}
// GetFile returns the latest generic file identified by the given identifier.
func GetFile(identifier string) (*File, error) {
func GetFile(identifier string) (*updater.File, error) {
identifier = path.Join("all", identifier)
return loadOrFetchFile(identifier, true)
}
// GetLocalFile returns the latest generic file identified by the given identifier, that is available locally.
func GetLocalFile(identifier string) (*File, error) {
identifier = path.Join("all", identifier)
return loadOrFetchFile(identifier, false)
}
func getLatestFilePath(identifier string) (versionedFilePath, version string, stable bool, ok bool) {
updatesLock.RLock()
defer updatesLock.RUnlock()
version, ok = stableUpdates[identifier]
if !ok {
version, ok = localUpdates[identifier]
if !ok {
log.Tracef("updates: file %s does not exist", identifier)
return "", "", false, false
// TODO: if in development mode, reload latest index to check for newly sideloaded updates
// err := reloadLatest()
}
}
// TODO: Fix for stable release
return GetVersionedPath(identifier, version), version, false, true
}
func loadOrFetchFile(identifier string, fetch bool) (*File, error) {
versionedFilePath, version, stable, ok := getLatestFilePath(identifier)
if !ok {
// TODO: if in development mode, search updates dir for sideloaded apps
return nil, ErrNotFound
}
// build final filepath
realFilePath := filepath.Join(updateStorage.Path, filepath.FromSlash(versionedFilePath))
if _, err := os.Stat(realFilePath); err == nil {
// file exists
updateUsedStatus(identifier, version)
return NewFile(realFilePath, version, stable), nil
}
// check download dir
err := tmpStorage.Ensure()
file, err := registry.GetFile(identifier)
if err != nil {
return nil, fmt.Errorf("could not prepare tmp directory for download: %s", err)
return nil, err
}
if !fetch {
return nil, ErrNotAvailableLocally
}
// download file
log.Tracef("updates: starting download of %s", versionedFilePath)
for tries := 0; tries < 5; tries++ {
err = fetchFile(realFilePath, versionedFilePath, tries)
if err != nil {
log.Tracef("updates: failed to download %s: %s, retrying (%d)", versionedFilePath, err, tries+1)
} else {
updateUsedStatus(identifier, version)
return NewFile(realFilePath, version, stable), nil
}
}
log.Warningf("updates: failed to download %s: %s", versionedFilePath, err)
return nil, err
module.TriggerEvent(eventVersionUpdate, nil)
return file, nil
}

View file

@ -1,24 +0,0 @@
package updates
import "testing"
func testBuildVersionedFilePath(t *testing.T, identifier, version, expectedVersionedFilePath string) {
updatesLock.Lock()
stableUpdates[identifier] = version
// betaUpdates[identifier] = version
updatesLock.Unlock()
versionedFilePath, _, _, ok := getLatestFilePath(identifier)
if !ok {
t.Errorf("identifier %s should exist", identifier)
}
if versionedFilePath != expectedVersionedFilePath {
t.Errorf("unexpected versionedFilePath: %s", versionedFilePath)
}
}
func TestBuildVersionedFilePath(t *testing.T) {
testBuildVersionedFilePath(t, "path/to/asset.zip", "1.2.3", "path/to/asset_v1-2-3.zip")
testBuildVersionedFilePath(t, "path/to/asset.tar.gz", "1.2.3b", "path/to/asset_v1-2-3b.tar.gz")
testBuildVersionedFilePath(t, "path/to/asset", "1.2.3b", "path/to/asset_v1-2-3b")
}

View file

@ -1,204 +0,0 @@
package updates
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"runtime"
"sync"
"github.com/safing/portbase/log"
"github.com/safing/portbase/utils"
semver "github.com/hashicorp/go-version"
)
var (
stableUpdates = make(map[string]string)
betaUpdates = make(map[string]string)
localUpdates = make(map[string]string)
updatesLock sync.RWMutex
)
// LoadLatest (re)loads the latest available updates from disk.
func LoadLatest() error {
newLocalUpdates := make(map[string]string)
// all
prefix := "all"
new, err1 := ScanForLatest(filepath.Join(updateStorage.Path, prefix), false)
for key, val := range new {
newLocalUpdates[filepath.ToSlash(filepath.Join(prefix, key))] = val
}
// os_platform
prefix = fmt.Sprintf("%s_%s", runtime.GOOS, runtime.GOARCH)
new, err2 := ScanForLatest(filepath.Join(updateStorage.Path, prefix), false)
for key, val := range new {
newLocalUpdates[filepath.ToSlash(filepath.Join(prefix, key))] = val
}
if err1 != nil && err2 != nil {
return fmt.Errorf("could not load latest update versions: %s, %s", err1, err2)
}
log.Tracef("updates: loading latest updates:")
for key, val := range newLocalUpdates {
log.Tracef("updates: %s v%s", key, val)
}
updatesLock.Lock()
localUpdates = newLocalUpdates
updatesLock.Unlock()
log.Tracef("updates: load complete")
// update version status
updatesLock.RLock()
defer updatesLock.RUnlock()
updateStatus(versionClassLocal, localUpdates)
return nil
}
// ScanForLatest scan the local update directory and returns a map of the latest/newest component versions.
func ScanForLatest(baseDir string, hardFail bool) (latest map[string]string, lastError error) {
var added int
latest = make(map[string]string)
filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
if !os.IsNotExist(err) {
lastError = fmt.Errorf("updates: could not read %s: %s", path, err)
if hardFail {
return lastError
}
log.Warning(lastError.Error())
}
return nil
}
if !info.IsDir() {
added++
}
relativePath, err := filepath.Rel(baseDir, path)
if err != nil {
return err
}
relativePath = filepath.ToSlash(relativePath)
identifierPath, version, ok := GetIdentifierAndVersion(relativePath)
if !ok {
return nil
}
// add/update index
storedVersion, ok := latest[identifierPath]
if ok {
parsedVersion, err := semver.NewVersion(version)
if err != nil {
lastError = fmt.Errorf("updates: could not parse version of %s: %s", path, err)
if hardFail {
return lastError
}
log.Warning(lastError.Error())
}
parsedStoredVersion, err := semver.NewVersion(storedVersion)
if err != nil {
lastError = fmt.Errorf("updates: could not parse version of %s: %s", path, err)
if hardFail {
return lastError
}
log.Warning(lastError.Error())
}
// compare
if parsedVersion.GreaterThan(parsedStoredVersion) {
latest[identifierPath] = version
}
} else {
latest[identifierPath] = version
}
return nil
})
if lastError != nil {
if hardFail {
return nil, lastError
}
if added == 0 {
return latest, lastError
}
}
return latest, nil
}
// LoadIndexes loads the current update indexes from disk.
func LoadIndexes() error {
data, err := ioutil.ReadFile(filepath.Join(updateStorage.Path, "stable.json"))
if err != nil {
return err
}
newStableUpdates := make(map[string]string)
err = json.Unmarshal(data, &newStableUpdates)
if err != nil {
return err
}
if len(newStableUpdates) == 0 {
return errors.New("stable.json is empty")
}
log.Tracef("updates: loaded stable.json")
updatesLock.Lock()
stableUpdates = newStableUpdates
updatesLock.Unlock()
// update version status
updatesLock.RLock()
defer updatesLock.RUnlock()
updateStatus(versionClassStable, stableUpdates)
return nil
}
// CreateSymlinks creates a directory structure with unversions symlinks to the given updates list.
func CreateSymlinks(symlinkRoot, updateStorage *utils.DirStructure, updatesList map[string]string) error {
err := os.RemoveAll(symlinkRoot.Path)
if err != nil {
return fmt.Errorf("failed to wipe symlink root: %s", err)
}
err = symlinkRoot.Ensure()
if err != nil {
return fmt.Errorf("failed to create symlink root: %s", err)
}
for identifier, version := range updatesList {
targetPath := filepath.Join(updateStorage.Path, filepath.FromSlash(GetVersionedPath(identifier, version)))
linkPath := filepath.Join(symlinkRoot.Path, filepath.FromSlash(identifier))
linkPathDir := filepath.Dir(linkPath)
err = symlinkRoot.EnsureAbsPath(linkPathDir)
if err != nil {
return fmt.Errorf("failed to create dir for link: %s", err)
}
relativeTargetPath, err := filepath.Rel(linkPathDir, targetPath)
if err != nil {
return fmt.Errorf("failed to get relative target path: %s", err)
}
err = os.Symlink(relativeTargetPath, linkPath)
if err != nil {
return fmt.Errorf("failed to link %s: %s", identifier, err)
}
}
return nil
}

View file

@ -1,73 +0,0 @@
package updates
import (
"io/ioutil"
"os"
"path/filepath"
"testing"
)
func testLoadLatestScope(t *testing.T, basePath, filePath, expectedIdentifier, expectedVersion string) {
fullPath := filepath.Join(basePath, filePath)
// create dir
dirPath := filepath.Dir(fullPath)
err := os.MkdirAll(dirPath, 0755)
if err != nil {
t.Fatalf("could not create test dir: %s\n", err)
return
}
// touch file
err = ioutil.WriteFile(fullPath, []byte{}, 0644)
if err != nil {
t.Fatalf("could not create test file: %s\n", err)
return
}
// run loadLatestScope
latest, err := ScanForLatest(basePath, true)
if err != nil {
t.Errorf("could not update latest: %s\n", err)
return
}
for key, val := range latest {
localUpdates[key] = val
}
// test result
version, ok := localUpdates[expectedIdentifier]
if !ok {
t.Errorf("identifier %s not in map", expectedIdentifier)
t.Errorf("current map: %v", localUpdates)
}
if version != expectedVersion {
t.Errorf("unexpected version for %s: %s", filePath, version)
}
}
func TestLoadLatestScope(t *testing.T) {
updatesLock.Lock()
defer updatesLock.Unlock()
tmpDir, err := ioutil.TempDir("", "testing_")
if err != nil {
t.Fatalf("could not create test dir: %s\n", err)
return
}
defer os.RemoveAll(tmpDir)
testLoadLatestScope(t, tmpDir, "all/ui/assets_v1-2-3.zip", "all/ui/assets.zip", "1.2.3")
testLoadLatestScope(t, tmpDir, "all/ui/assets_v1-2-4b.zip", "all/ui/assets.zip", "1.2.4b")
testLoadLatestScope(t, tmpDir, "all/ui/assets_v1-2-5.zip", "all/ui/assets.zip", "1.2.5")
testLoadLatestScope(t, tmpDir, "all/ui/assets_v1-3-4.zip", "all/ui/assets.zip", "1.3.4")
testLoadLatestScope(t, tmpDir, "all/ui/assets_v2-3-4.zip", "all/ui/assets.zip", "2.3.4")
testLoadLatestScope(t, tmpDir, "all/ui/assets_v1-2-3.zip", "all/ui/assets.zip", "2.3.4")
testLoadLatestScope(t, tmpDir, "all/ui/assets_v1-2-4.zip", "all/ui/assets.zip", "2.3.4")
testLoadLatestScope(t, tmpDir, "all/ui/assets_v1-3-4.zip", "all/ui/assets.zip", "2.3.4")
testLoadLatestScope(t, tmpDir, "os_platform/portmaster/portmaster_v1-2-3", "os_platform/portmaster/portmaster", "1.2.3")
testLoadLatestScope(t, tmpDir, "os_platform/portmaster/portmaster_v2-1-1", "os_platform/portmaster/portmaster", "2.1.1")
testLoadLatestScope(t, tmpDir, "os_platform/portmaster/portmaster_v1-2-3", "os_platform/portmaster/portmaster", "2.1.1")
}

View file

@ -1,87 +1,118 @@
package updates
import (
"errors"
"os"
"context"
"fmt"
"runtime"
"time"
"github.com/safing/portmaster/core/structure"
"github.com/safing/portbase/info"
"github.com/safing/portbase/log"
"github.com/safing/portbase/modules"
"github.com/safing/portbase/utils"
"github.com/safing/portbase/updater"
"github.com/safing/portmaster/core/structure"
)
const (
isWindows = runtime.GOOS == "windows"
onWindows = runtime.GOOS == "windows"
releaseChannelKey = "core/releaseChannel"
releaseChannelStable = "stable"
releaseChannelBeta = "beta"
eventVersionUpdate = "active version update"
eventResourceUpdate = "resource update"
)
var (
updateStorage *utils.DirStructure
tmpStorage *utils.DirStructure
module *modules.Module
registry *updater.ResourceRegistry
)
// SetDataRoot sets the data root from which the updates module derives its paths.
func SetDataRoot(root *utils.DirStructure) {
if root != nil && updateStorage == nil {
updateStorage = root.ChildDir("updates", 0755)
tmpStorage = updateStorage.ChildDir("tmp", 0777)
}
}
func init() {
modules.Register("updates", prep, start, stop, "core")
}
func prep() error {
SetDataRoot(structure.Root())
if updateStorage == nil {
return errors.New("update storage path is not set")
}
err := updateStorage.Ensure()
if err != nil {
return err
}
status.Core = info.GetInfo()
return nil
module = modules.Register("updates", registerConfig, start, stop, "core")
module.RegisterEvent(eventVersionUpdate)
module.RegisterEvent(eventResourceUpdate)
}
func start() error {
err := initUpdateStatusHook()
if err != nil {
return err
}
initConfig()
err = LoadIndexes()
if err != nil {
if os.IsNotExist(err) {
// download indexes
log.Infof("updates: downloading update index...")
err = UpdateIndexes()
if err != nil {
log.Errorf("updates: failed to download update index: %s", err)
}
} else {
return err
var mandatoryUpdates []string
if onWindows {
mandatoryUpdates = []string{
platform("core/portmaster-core.exe"),
platform("control/portmaster-control.exe"),
platform("app/portmaster-app.exe"),
platform("notifier/portmaster-notifier.exe"),
platform("notifier/portmaster-snoretoast.exe"),
}
} else {
mandatoryUpdates = []string{
platform("core/portmaster-core"),
platform("control/portmaster-control"),
platform("app/portmaster-app"),
platform("notifier/portmaster-notifier"),
}
}
err = LoadLatest()
// create registry
registry = &updater.ResourceRegistry{
Name: "updates",
UpdateURLs: []string{
"https://updates.safing.io",
},
MandatoryUpdates: mandatoryUpdates,
Beta: releaseChannel() == releaseChannelBeta,
DevMode: devMode(),
Online: true,
}
// initialize
err := registry.Initialize(structure.Root().ChildDir("updates", 0755))
if err != nil {
return err
}
go updater()
go updateNotifier()
err = registry.LoadIndexes()
if err != nil {
return err
}
err = registry.ScanStorage("")
if err != nil {
log.Warningf("updates: error during storage scan: %s", err)
}
registry.SelectVersions()
module.TriggerEvent(eventVersionUpdate, nil)
err = initVersionExport()
if err != nil {
return err
}
// start updater task
module.NewTask("updater", func(ctx context.Context, task *modules.Task) {
err := registry.DownloadUpdates(ctx)
if err != nil {
log.Warningf("updates: failed to update: %s", err)
}
module.TriggerEvent(eventResourceUpdate, nil)
}).Repeat(24 * time.Hour).MaxDelay(1 * time.Hour).Schedule(time.Now().Add(10 * time.Second))
// react to upgrades
initUpgrader()
return nil
}
func stop() error {
// delete download tmp dir
return os.RemoveAll(tmpStorage.Path)
if registry != nil {
return registry.Cleanup()
}
return stopVersionExport()
}
func platform(identifier string) string {
return fmt.Sprintf("%s_%s/%s", runtime.GOOS, runtime.GOARCH, identifier)
}

View file

@ -1,43 +0,0 @@
package updates
import (
"fmt"
"time"
"github.com/safing/portbase/notifications"
)
const coreIdentifier = "core/portmaster-core"
var lastNotified time.Time
func updateNotifier() {
time.Sleep(5 * time.Minute)
for {
ident := coreIdentifier
if isWindows {
ident += ".exe"
}
file, err := GetLocalPlatformFile(ident)
if err == nil {
status.Lock()
liveVersion := status.Core.Version
status.Unlock()
if file.Version() != liveVersion {
// create notification
(&notifications.Notification{
ID: "updates-core-update-available",
Message: fmt.Sprintf("There is an update available for the Portmaster core (v%s), please restart the Portmaster to apply the update.", file.Version()),
Type: notifications.Info,
Expires: time.Now().Add(1 * time.Minute).Unix(),
}).Save()
}
}
time.Sleep(1 * time.Hour)
}
}

View file

@ -1,132 +0,0 @@
package updates
import (
"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"
"github.com/tevino/abool"
)
// database key for update information
const (
statusDBKey = "core:status/updates"
)
// version type
type versionClass int
const (
versionClassLocal versionClass = iota
versionClassStable
versionClassBeta
)
// working vars
var (
status *versionStatus
statusDB = database.NewInterface(nil)
statusHook *database.RegisteredHook
enableStatusSave = abool.NewBool(false)
)
func init() {
status = &versionStatus{
Modules: make(map[string]*versionStatusEntry),
}
status.SetKey(statusDBKey)
}
// versionStatus holds update version status information.
type versionStatus struct {
record.Base
sync.Mutex
Core *info.Info
Modules map[string]*versionStatusEntry
}
func (vs *versionStatus) save() {
enableStatusSave.SetTo(true)
err := statusDB.Put(vs)
if err != nil {
log.Warningf("could not save updates version status: %s", err)
}
}
// versionStatusEntry holds information about the update status of a module.
type versionStatusEntry struct {
LastVersionUsed string
LocalVersion string
StableVersion string
BetaVersion string
}
func updateUsedStatus(identifier string, version string) {
status.Lock()
defer status.Unlock()
entry, ok := status.Modules[identifier]
if !ok {
entry = &versionStatusEntry{}
status.Modules[identifier] = entry
}
entry.LastVersionUsed = version
log.Tracef("updates: updated last used version of %s: %s", identifier, version)
go status.save()
}
func updateStatus(vClass versionClass, state map[string]string) {
status.Lock()
defer status.Unlock()
for identifier, version := range state {
entry, ok := status.Modules[identifier]
if !ok {
entry = &versionStatusEntry{}
status.Modules[identifier] = entry
}
switch vClass {
case versionClassLocal:
entry.LocalVersion = version
case versionClassStable:
entry.StableVersion = version
case versionClassBeta:
entry.BetaVersion = version
}
}
go status.save()
}
type updateStatusHook struct {
database.HookBase
}
// UsesPrePut implements the Hook interface.
func (sh *updateStatusHook) UsesPrePut() bool {
return true
}
// PrePut implements the Hook interface.
func (sh *updateStatusHook) PrePut(r record.Record) (record.Record, error) {
if enableStatusSave.SetToIf(true, false) {
return r, nil
}
return nil, errors.New("may only be changed by updates module")
}
func initUpdateStatusHook() (err error) {
statusHook, err = database.RegisterHook(query.New(statusDBKey), &updateStatusHook{})
return err
}

53
updates/testing.go Normal file
View file

@ -0,0 +1,53 @@
package updates
import (
"os"
"path/filepath"
"github.com/safing/portbase/log"
"github.com/safing/portbase/updater"
"github.com/safing/portbase/utils"
)
// InitForTesting initializes the update module directly. This is intended to be only used by unit tests that require the updates module.
func InitForTesting() error {
// create registry
registry = &updater.ResourceRegistry{
Name: "testing-updates",
UpdateURLs: []string{
"https://updates.safing.io",
},
Beta: false,
DevMode: false,
Online: true,
}
// set data dir
root := utils.NewDirStructure(
filepath.Join(os.TempDir(), "pm-testing"),
0755,
)
err := root.Ensure()
if err != nil {
return err
}
// initialize
err = registry.Initialize(root.ChildDir("updates", 0755))
if err != nil {
return err
}
err = registry.LoadIndexes()
if err != nil {
return err
}
err = registry.ScanStorage("")
if err != nil {
log.Warningf("updates: error during storage scan: %s", err)
}
registry.SelectVersions()
return nil
}

View file

@ -1,167 +0,0 @@
package updates
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"os"
"path"
"path/filepath"
"runtime"
"time"
"github.com/safing/portbase/log"
)
func updater() {
time.Sleep(10 * time.Second)
for {
err := UpdateIndexes()
if err != nil {
log.Warningf("updates: updating index failed: %s", err)
}
err = DownloadUpdates()
if err != nil {
log.Warningf("updates: downloading updates failed: %s", err)
}
err = runFileUpgrades()
if err != nil {
log.Warningf("updates: failed to upgrade portmaster-control: %s", err)
}
err = cleanOldUpgradedFiles()
if err != nil {
log.Warningf("updates: failed to clean old upgraded files: %s", err)
}
time.Sleep(1 * time.Hour)
}
}
func markFileForDownload(identifier string) {
// get file
_, ok := localUpdates[identifier]
// only mark if it does not yet exist
if !ok {
localUpdates[identifier] = "loading..."
}
}
func markPlatformFileForDownload(identifier string) {
// add platform prefix
identifier = path.Join(fmt.Sprintf("%s_%s", runtime.GOOS, runtime.GOARCH), identifier)
// mark file
markFileForDownload(identifier)
}
// UpdateIndexes downloads the current update indexes.
func UpdateIndexes() (err error) {
// download new indexes
var data []byte
for tries := 0; tries < 3; tries++ {
data, err = fetchData("stable.json", tries)
if err == nil {
break
}
}
if err != nil {
return fmt.Errorf("failed to download: %s", err)
}
newStableUpdates := make(map[string]string)
err = json.Unmarshal(data, &newStableUpdates)
if err != nil {
return fmt.Errorf("failed to parse: %s", err)
}
if len(newStableUpdates) == 0 {
return errors.New("index is empty")
}
// update stable index
updatesLock.Lock()
stableUpdates = newStableUpdates
updatesLock.Unlock()
// check dir
err = updateStorage.Ensure()
if err != nil {
return err
}
// save stable index
err = ioutil.WriteFile(filepath.Join(updateStorage.Path, "stable.json"), data, 0644)
if err != nil {
log.Warningf("updates: failed to save new version of stable.json: %s", err)
}
// update version status
updatesLock.RLock()
updateStatus(versionClassStable, stableUpdates)
updatesLock.RUnlock()
// FIXME IN STABLE: correct log line
log.Infof("updates: updated index stable.json (alpha/beta until we actually reach stable)")
return nil
}
// DownloadUpdates checks if updates are available and downloads updates of used components.
func DownloadUpdates() (err error) {
// ensure important components are always updated
updatesLock.Lock()
if runtime.GOOS == "windows" {
markPlatformFileForDownload("core/portmaster-core.exe")
markPlatformFileForDownload("control/portmaster-control.exe")
markPlatformFileForDownload("app/portmaster-app.exe")
markPlatformFileForDownload("notifier/portmaster-notifier.exe")
markPlatformFileForDownload("notifier/portmaster-snoretoast.exe")
} else {
markPlatformFileForDownload("core/portmaster-core")
markPlatformFileForDownload("control/portmaster-control")
markPlatformFileForDownload("app/portmaster-app")
markPlatformFileForDownload("notifier/portmaster-notifier")
}
updatesLock.Unlock()
// check download dir
err = tmpStorage.Ensure()
if err != nil {
return fmt.Errorf("could not prepare tmp directory for download: %s", err)
}
// RLock for the remaining function
updatesLock.RLock()
defer updatesLock.RUnlock()
// update existing files
log.Tracef("updates: updating existing files")
for identifier, newVersion := range stableUpdates {
oldVersion, ok := localUpdates[identifier]
if ok && newVersion != oldVersion {
log.Tracef("updates: updating %s to %s", identifier, newVersion)
filePath := GetVersionedPath(identifier, newVersion)
realFilePath := filepath.Join(updateStorage.Path, filePath)
for tries := 0; tries < 3; tries++ {
err = fetchFile(realFilePath, filePath, tries)
if err == nil {
break
}
}
if err != nil {
log.Warningf("updates: failed to update %s to %s: %s", identifier, newVersion, err)
}
}
}
log.Tracef("updates: finished updating existing files")
// remove tmp folder after we are finished
err = os.RemoveAll(tmpStorage.Path)
if err != nil {
log.Tracef("updates: failed to remove tmp dir %s after downloading updates: %s", updateStorage.Path, err)
}
return nil
}

View file

@ -1,18 +1,24 @@
package updates
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"regexp"
"strings"
"time"
"github.com/tevino/abool"
"github.com/google/renameio"
"github.com/safing/portbase/info"
"github.com/safing/portbase/log"
"github.com/safing/portbase/notifications"
"github.com/safing/portbase/updater"
processInfo "github.com/shirou/gopsutil/process"
)
@ -21,21 +27,102 @@ const (
upgradedSuffix = "-upgraded"
)
func runFileUpgrades() error {
var (
upgraderActive = abool.NewBool(false)
dontUpgradeBefore = time.Now().Add(5 * time.Minute)
pmCtrlUpdate *updater.File
pmCoreUpdate *updater.File
rawVersionRegex = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+b?\*?$`)
)
func initUpgrader() {
module.RegisterEventHook(
"updates",
eventResourceUpdate,
"run upgrades",
upgrader,
)
}
func upgrader(_ context.Context, _ interface{}) error {
// like a lock, but discard additional runs
if !upgraderActive.SetToIf(false, true) {
return nil
}
defer upgraderActive.SetTo(false)
// upgrade portmaster control
err := upgradePortmasterControl()
if err != nil {
log.Warningf("updates: failed to upgrade portmaster-control: %s", err)
}
err = upgradeCoreNotify()
if err != nil {
log.Warningf("updates: failed to notify about core upgrade: %s", err)
}
return nil
}
func upgradeCoreNotify() error {
identifier := "core/portmaster-core" // identifier, use forward slash!
if onWindows {
identifier += ".exe"
}
// check if we can upgrade
if pmCoreUpdate == nil || pmCoreUpdate.UpgradeAvailable() {
// get newest portmaster-control
new, err := GetPlatformFile(identifier)
if err != nil {
return err
}
pmCoreUpdate = new
} else {
return nil
}
if info.GetInfo().Version != pmCoreUpdate.Version() {
// create notification
(&notifications.Notification{
ID: "updates-core-update-available",
Message: fmt.Sprintf("There is an update available for the Portmaster core (v%s), please restart the Portmaster to apply the update.", pmCoreUpdate.Version()),
Type: notifications.Info,
}).Save()
}
return nil
}
func upgradePortmasterControl() error {
filename := "portmaster-control"
if runtime.GOOS == "windows" {
if onWindows {
filename += ".exe"
}
// get newest portmaster-control
newFile, err := GetPlatformFile("control/" + filename) // identifier, use forward slash!
// check if we can upgrade
if pmCtrlUpdate == nil || pmCtrlUpdate.UpgradeAvailable() {
// get newest portmaster-control
new, err := GetPlatformFile("control/" + filename) // identifier, use forward slash!
if err != nil {
return err
}
pmCtrlUpdate = new
} else {
return nil
}
// check if registry tmp dir is ok
err := registry.TmpDir().Ensure()
if err != nil {
return err
return fmt.Errorf("failed to prep updates tmp dir: %s", err)
}
// update portmaster-control in data root
rootControlPath := filepath.Join(filepath.Dir(updateStorage.Path), filename)
err = upgradeFile(rootControlPath, newFile)
rootControlPath := filepath.Join(filepath.Dir(registry.StorageDir().Path), filename)
err = upgradeFile(rootControlPath, pmCtrlUpdate)
if err != nil {
return err
}
@ -50,7 +137,7 @@ func runFileUpgrades() error {
if err != nil {
return fmt.Errorf("could not get parent process name for upgrade checks: %s", err)
}
if !strings.HasPrefix(parentName, filename) {
if parentName != filename {
log.Tracef("updates: parent process does not seem to be portmaster-control, name is %s", parentName)
return nil
}
@ -58,7 +145,7 @@ func runFileUpgrades() error {
if err != nil {
return fmt.Errorf("could not get parent process path for upgrade: %s", err)
}
err = upgradeFile(parentPath, newFile)
err = upgradeFile(parentPath, pmCtrlUpdate)
if err != nil {
return err
}
@ -67,7 +154,7 @@ func runFileUpgrades() error {
return nil
}
func upgradeFile(fileToUpgrade string, file *File) error {
func upgradeFile(fileToUpgrade string, file *updater.File) error {
fileExists := false
_, err := os.Stat(fileToUpgrade)
if err == nil {
@ -75,12 +162,6 @@ func upgradeFile(fileToUpgrade string, file *File) error {
fileExists = true
}
// ensure that the tmp dir exists
err = tmpStorage.Ensure()
if err != nil {
return fmt.Errorf("unable to create directory for upgrade process: %s", err)
}
if fileExists {
// get current version
var currentVersion string
@ -107,12 +188,18 @@ func upgradeFile(fileToUpgrade string, file *File) error {
// 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("unable to check updates tmp dir for moving file that needs upgrade: %s", err)
}
// maybe we're on windows and it's in use, try moving
err = os.Rename(fileToUpgrade, filepath.Join(
tmpStorage.Path,
registry.TmpDir().Path,
fmt.Sprintf(
"%s-%d%s",
GetVersionedPath(filepath.Base(fileToUpgrade), currentVersion),
updater.GetVersionedPath(filepath.Base(fileToUpgrade), currentVersion),
time.Now().UTC().Unix(),
upgradedSuffix,
),
@ -124,11 +211,10 @@ func upgradeFile(fileToUpgrade string, file *File) error {
}
// copy upgrade
// TODO: handle copy failure
err = copyFile(file.Path(), fileToUpgrade)
if err != nil {
time.Sleep(1 * time.Second)
// try again
time.Sleep(1 * time.Second)
err = copyFile(file.Path(), fileToUpgrade)
if err != nil {
return err
@ -136,7 +222,7 @@ func upgradeFile(fileToUpgrade string, file *File) error {
}
// check permissions
if runtime.GOOS != "windows" {
if !onWindows {
info, err := os.Stat(fileToUpgrade)
if err != nil {
return fmt.Errorf("failed to get file info on %s: %s", fileToUpgrade, err)
@ -153,11 +239,11 @@ func upgradeFile(fileToUpgrade string, file *File) error {
func copyFile(srcPath, dstPath string) (err error) {
// open file for writing
atomicDstFile, err := renameio.TempFile(tmpStorage.Path, dstPath)
atomicDstFile, err := renameio.TempFile(registry.TmpDir().Path, dstPath)
if err != nil {
return fmt.Errorf("could not create temp file for atomic copy: %s", err)
}
defer atomicDstFile.Cleanup()
defer atomicDstFile.Cleanup() //nolint:errcheck // ignore error for now, tmp dir will be cleaned later again anyway
// open source
srcFile, err := os.Open(srcPath)
@ -180,7 +266,3 @@ func copyFile(srcPath, dstPath string) (err error) {
return nil
}
func cleanOldUpgradedFiles() error {
return os.RemoveAll(tmpStorage.Path)
}

View file

@ -1 +0,0 @@
uptool

View file

@ -1,41 +0,0 @@
package main
import (
"os"
"path/filepath"
"github.com/safing/portbase/utils"
"github.com/spf13/cobra"
)
var (
updatesStorage *utils.DirStructure
)
var rootCmd = &cobra.Command{
Use: "uptool",
Short: "helper tool for the update process",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
cmd.Usage()
},
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
dir := args[0]
absPath, err := filepath.Abs(dir)
if err != nil {
return err
}
updatesStorage = utils.NewDirStructure(absPath, 0755)
return nil
},
SilenceUsage: true,
}
func main() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}

View file

@ -1,35 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"github.com/safing/portmaster/updates"
"github.com/spf13/cobra"
)
func init() {
rootCmd.AddCommand(scanCmd)
}
var scanCmd = &cobra.Command{
Use: "scan",
Short: "Scan the specified directory and print the result",
Args: cobra.ExactArgs(1),
RunE: scan,
}
func scan(cmd *cobra.Command, args []string) error {
latest, err := updates.ScanForLatest(updatesStorage.Path, true)
if err != nil {
return err
}
data, err := json.MarshalIndent(latest, "", " ")
if err != nil {
return err
}
fmt.Println(string(data))
return nil
}

View file

@ -1,45 +0,0 @@
package main
import (
"encoding/json"
"io/ioutil"
"path/filepath"
"github.com/safing/portmaster/updates"
"github.com/spf13/cobra"
)
func init() {
rootCmd.AddCommand(updateCmd)
}
var updateCmd = &cobra.Command{
Use: "update",
Short: "Update scans the specified directory and updates the index and symlink structure",
Args: cobra.ExactArgs(1),
RunE: update,
}
func update(cmd *cobra.Command, args []string) error {
latest, err := updates.ScanForLatest(updatesStorage.Path, true)
if err != nil {
return err
}
data, err := json.MarshalIndent(latest, "", " ")
if err != nil {
return err
}
err = ioutil.WriteFile(filepath.Join(updatesStorage.Path, "stable.json"), data, 0755)
if err != nil {
return err
}
err = updates.CreateSymlinks(updatesStorage.ChildDir("latest", 0755), updatesStorage, latest)
if err != nil {
return err
}
return nil
}