Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
bc59749
Feature: Auto-update client
mohamed-essam Jul 30, 2025
c632878
Resolve comments
mohamed-essam Aug 19, 2025
762b9b7
Restructure version.Update to use channel
mohamed-essam Aug 20, 2025
84501a3
Fix deadlock issues
mohamed-essam Aug 25, 2025
58d4812
Define constants for version semantics
mohamed-essam Aug 26, 2025
d2e198b
Fix lint
mohamed-essam Aug 26, 2025
59ae92c
Refactor handleAutoUpdateVersion to outside handleSync
mohamed-essam Aug 26, 2025
6025eb1
Add unit tests
mohamed-essam Sep 2, 2025
ecf1e90
Merge branch 'main' into feat/auto-upgrade
mohamed-essam Sep 7, 2025
ec47a84
Remove testing.T.Context() as it's added in go1.24
mohamed-essam Sep 8, 2025
d19f829
Move autoUpdateVersion inside NetworkMap
mohamed-essam Sep 15, 2025
02afd4e
Move to networkMap.PeerConfig
mohamed-essam Sep 16, 2025
5042339
Update management/server/account.go
mlsmaycon Sep 20, 2025
ad3985a
Merge pull request #4504 from netbirdio/sub-feat/auto-upgrade/move-ve…
mohamed-essam Sep 21, 2025
b070304
Modify client-side behavior
mohamed-essam Oct 1, 2025
e04b989
Change ProgressBarInfinite to Updating... label
mohamed-essam Oct 1, 2025
723c418
Merge branch 'main' into feat/auto-upgrade
mohamed-essam Oct 6, 2025
0d2ce56
Merge branch 'feat/auto-upgrade' into auto-upgrade-mod
mohamed-essam Oct 6, 2025
b37ba44
Resolve issues
mohamed-essam Oct 8, 2025
436d740
Merge branch 'feat/auto-upgrade' into auto-upgrade-mod
mohamed-essam Oct 8, 2025
d5ea408
Resolve issues
mohamed-essam Oct 8, 2025
5556ff3
Merge pull request #4563 from netbirdio/auto-upgrade-mod
mohamed-essam Oct 12, 2025
582ff1f
Fix auto-update message handling
pappz Oct 13, 2025
9ae48a0
Remove unused codes and remove unnecessary variables
pappz Oct 13, 2025
7fa926d
Fix deadlock
pappz Oct 13, 2025
6200aaf
Fix state handling
pappz Oct 13, 2025
7d846bf
Fix nil pointer exception in expectedSemVer
pappz Oct 13, 2025
bab5cd4
Clean up temp dir
pappz Oct 13, 2025
cd19f4d
Code cleaning in updateState
pappz Oct 13, 2025
1354096
Fix windows build
pappz Oct 13, 2025
18f884f
- fix nil pointer for context
pappz Oct 13, 2025
9313b49
Fix Windows installer
pappz Oct 14, 2025
6eee52b
Fix auto update success message check
pappz Oct 15, 2025
030ddae
Merge branch 'main' into feat/auto-upgrade
pappz Oct 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions client/internal/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import (
"github.com/netbirdio/netbird/client/internal/routemanager"
"github.com/netbirdio/netbird/client/internal/routemanager/systemops"
"github.com/netbirdio/netbird/client/internal/statemanager"
"github.com/netbirdio/netbird/client/internal/updatemanager"
cProto "github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/shared/management/domain"
semaphoregroup "github.com/netbirdio/netbird/util/semaphore-group"
Expand All @@ -75,6 +76,7 @@ const (
PeerConnectionTimeoutMax = 45000 // ms
PeerConnectionTimeoutMin = 30000 // ms
connInitLimit = 200
disableAutoUpdate = "disabled"
)

var ErrResetConnection = fmt.Errorf("reset connection")
Expand Down Expand Up @@ -199,6 +201,9 @@ type Engine struct {
connSemaphore *semaphoregroup.SemaphoreGroup
flowManager nftypes.FlowManager

// auto-update
updateManager *updatemanager.UpdateManager

// WireGuard interface monitor
wgIfaceMonitor *WGIfaceMonitor
wgIfaceMonitorWg sync.WaitGroup
Expand Down Expand Up @@ -314,6 +319,10 @@ func (e *Engine) Stop() error {
e.srWatcher.Close()
}

if e.updateManager != nil {
e.updateManager.Stop()
}

e.statusRecorder.ReplaceOfflinePeers([]peer.State{})
e.statusRecorder.UpdateDNSStates([]peer.NSGroupState{})
e.statusRecorder.UpdateRelayStates([]relay.ProbeResult{})
Expand Down Expand Up @@ -712,10 +721,26 @@ func (e *Engine) PopulateNetbirdConfig(netbirdConfig *mgmProto.NetbirdConfig, mg
return nil
}

func (e *Engine) handleAutoUpdateVersion(autoUpdateVersion string) {
if e.updateManager == nil && autoUpdateVersion != disableAutoUpdate {
e.updateManager = updatemanager.NewUpdateManager(e.statusRecorder)
e.updateManager.Start(e.ctx)
} else if e.updateManager != nil && autoUpdateVersion == disableAutoUpdate {
e.updateManager.Stop()
e.updateManager = nil
}
if e.updateManager != nil {
e.updateManager.SetVersion(autoUpdateVersion)
}
}

func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
e.syncMsgMux.Lock()
defer e.syncMsgMux.Unlock()

if update.NetworkMap != nil && update.NetworkMap.PeerConfig != nil {
e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdateVersion)
}
if update.GetNetbirdConfig() != nil {
wCfg := update.GetNetbirdConfig()
err := e.updateTURNs(wCfg.GetTurns())
Expand Down
261 changes: 261 additions & 0 deletions client/internal/updatemanager/manager.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
package updatemanager

import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"

v "github.com/hashicorp/go-version"
log "github.com/sirupsen/logrus"

"github.com/netbirdio/netbird/client/internal/peer"
cProto "github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/version"
)

const (
latestVersion = "latest"
)

type UpdateInterface interface {
StopWatch()
SetDaemonVersion(newVersion string) bool
SetOnUpdateListener(updateFn func())
LatestVersion() *v.Version
StartFetcher()
}

type UpdateManager struct {
lastTrigger time.Time
statusRecorder *peer.Status
mgmUpdateChan chan struct{}
updateChannel chan struct{}
wg sync.WaitGroup
currentVersion string
updateFunc func(ctx context.Context, targetVersion string) error

cancel context.CancelFunc
update UpdateInterface

expectedVersion *v.Version
updateToLatestVersion bool
expectedVersionMutex sync.Mutex
}

func NewUpdateManager(statusRecorder *peer.Status) *UpdateManager {
manager := &UpdateManager{
statusRecorder: statusRecorder,
mgmUpdateChan: make(chan struct{}, 1),
updateChannel: make(chan struct{}, 1),
currentVersion: version.NetbirdVersion(),
updateFunc: triggerUpdate,

Check failure on line 58 in client/internal/updatemanager/manager.go

View workflow job for this annotation

GitHub Actions / JS / Build

undefined: triggerUpdate
update: version.NewUpdate("nb/client"),
}
return manager
}

func (u *UpdateManager) WithCustomVersionUpdate(versionUpdate UpdateInterface) *UpdateManager {
u.update = versionUpdate
return u
}

func (u *UpdateManager) Start(ctx context.Context) {
if u.cancel != nil {
log.Errorf("UpdateManager already started")
return
}

go u.update.StartFetcher()
u.update.SetDaemonVersion(u.currentVersion)
u.update.SetOnUpdateListener(func() {
select {
case u.updateChannel <- struct{}{}:
default:
}
})

ctx, cancel := context.WithCancel(ctx)
u.cancel = cancel

u.wg.Add(1)
go u.updateLoop(ctx)
}

func (u *UpdateManager) SetVersion(expectedVersion string) {
if u.cancel == nil {
log.Errorf("UpdateManager not started")
return
}

u.expectedVersionMutex.Lock()
defer u.expectedVersionMutex.Unlock()
if expectedVersion == latestVersion {
u.updateToLatestVersion = true
u.expectedVersion = nil
} else {
expectedSemVer, err := v.NewVersion(expectedVersion)
if err != nil {
log.Errorf("Error parsing version: %v", err)
return
}
if u.expectedVersion.Equal(expectedSemVer) {
return
}
u.expectedVersion = expectedSemVer
u.updateToLatestVersion = false
}

select {
case u.mgmUpdateChan <- struct{}{}:
default:
}
}

func (u *UpdateManager) Stop() {
if u.cancel == nil {
return
}

u.cancel()
if u.update != nil {
u.update.StopWatch()
u.update = nil
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You set u.update = nil in Stop() while updateLoop or handleUpdate could still try to access it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is only referenced inside handleUpdate once inside a mutex, so I used this to prevent a race and also added a check so that if u.update is nil it should return

}

u.wg.Wait()
}

func (u *UpdateManager) updateLoop(ctx context.Context) {
defer u.wg.Done()

for {
select {
case <-ctx.Done():
return
case <-u.mgmUpdateChan:
case <-u.updateChannel:
}

u.handleUpdate(ctx)
}
}

func (u *UpdateManager) handleUpdate(ctx context.Context) {
var updateVersion *v.Version

u.expectedVersionMutex.Lock()
expectedVersion := u.expectedVersion
useLatest := u.updateToLatestVersion
curLatestVersion := u.update.LatestVersion()
u.expectedVersionMutex.Unlock()

switch {
// Resolve "latest" to actual version
case useLatest:
if curLatestVersion == nil {
log.Tracef("Latest version not fetched yet")
return
}
updateVersion = curLatestVersion
// Update to specific version
case u.expectedVersion != nil:
updateVersion = expectedVersion
default:
log.Debugf("No expected version information set")
return
}

if !u.shouldUpdate(updateVersion) {
return
}

ctx, cancel := context.WithDeadline(ctx, time.Now().Add(time.Minute))
defer cancel()

u.lastTrigger = time.Now()
log.Debugf("Auto-update triggered, current version: %s, target version: %s", u.currentVersion, updateVersion)
u.statusRecorder.PublishEvent(
cProto.SystemEvent_INFO,
cProto.SystemEvent_SYSTEM,
"Automatically updating client",
"Your client version is older than auto-update version set in Management, updating client now.",
nil,
)

err := u.updateFunc(ctx, updateVersion.String())
if err != nil {
log.Errorf("Error triggering auto-update: %v", err)
}
}

func (u *UpdateManager) shouldUpdate(updateVersion *v.Version) bool {
currentVersion, err := v.NewVersion(u.currentVersion)
if err != nil {
log.Errorf("Error checking for update, error parsing version `%s`: %v", u.currentVersion, err)
return false
}
if currentVersion.GreaterThanOrEqual(updateVersion) {
log.Debugf("Current version (%s) is equal to or higher than auto-update version (%s)", u.currentVersion, updateVersion)
return false
}

if time.Since(u.lastTrigger) < 5*time.Minute {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure but we can miss a potential upgrade if we just ignore. What is the goal with this condition?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is so it doesn't spam retrying to upgrade with any trigger since each failure will show an error to the user, so this is just a way to backoff failures

log.Tracef("No need to update")
return false
}

return true
}

func downloadFileToTemporaryDir(ctx context.Context, fileURL string) (string, error) { //nolint:unused
tempDir, err := os.MkdirTemp("", "netbird-installer-*")
if err != nil {
return "", fmt.Errorf("error creating temporary directory: %w", err)
}
fileNameParts := strings.Split(fileURL, "/")
out, err := os.Create(filepath.Join(tempDir, fileNameParts[len(fileNameParts)-1]))
if err != nil {
return "", fmt.Errorf("error creating temporary file: %w", err)
}
defer func() {
if err := out.Close(); err != nil {
log.Errorf("Error closing temporary file: %v", err)
}
}()

req, err := http.NewRequestWithContext(ctx, "GET", fileURL, nil)
if err != nil {
return "", fmt.Errorf("error creating file download request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("error downloading file: %w", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
log.Errorf("Error closing response body: %v", err)
}
}()

_, err = io.Copy(out, resp.Body)
if err != nil {
return "", fmt.Errorf("error downloading file: %w", err)
}

log.Tracef("Downloaded update file to %s", out.Name())

return out.Name(), nil
}

func urlWithVersionArch(url, version string) string { //nolint:unused
url = strings.ReplaceAll(url, "%version", version)
url = strings.ReplaceAll(url, "%arch", runtime.GOARCH)
return url
}
Loading
Loading