Skip to content

Commit 73e7e73

Browse files
committed
Feature: Auto-update client
1 parent f063866 commit 73e7e73

File tree

22 files changed

+1030
-533
lines changed

22 files changed

+1030
-533
lines changed

client/internal/engine.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import (
4848
"github.com/netbirdio/netbird/client/internal/routemanager"
4949
"github.com/netbirdio/netbird/client/internal/routemanager/systemops"
5050
"github.com/netbirdio/netbird/client/internal/statemanager"
51+
"github.com/netbirdio/netbird/client/internal/updatemanager"
5152
cProto "github.com/netbirdio/netbird/client/proto"
5253
"github.com/netbirdio/netbird/shared/management/domain"
5354
semaphoregroup "github.com/netbirdio/netbird/util/semaphore-group"
@@ -196,6 +197,9 @@ type Engine struct {
196197
latestSyncResponse *mgmProto.SyncResponse
197198
connSemaphore *semaphoregroup.SemaphoreGroup
198199
flowManager nftypes.FlowManager
200+
201+
// auto-update
202+
updateManager *updatemanager.UpdateManager
199203
}
200204

201205
// Peer is an instance of the Connection Peer
@@ -238,6 +242,7 @@ func NewEngine(
238242
statusRecorder: statusRecorder,
239243
checks: checks,
240244
connSemaphore: semaphoregroup.NewSemaphoreGroup(connInitLimit),
245+
updateManager: updatemanager.NewUpdateManager(clientCtx, statusRecorder),
241246
}
242247

243248
sm := profilemanager.NewServiceManager("")
@@ -304,6 +309,10 @@ func (e *Engine) Stop() error {
304309
e.srWatcher.Close()
305310
}
306311

312+
if e.updateManager != nil {
313+
e.updateManager.Stop()
314+
}
315+
307316
e.statusRecorder.ReplaceOfflinePeers([]peer.State{})
308317
e.statusRecorder.UpdateDNSStates([]peer.NSGroupState{})
309318
e.statusRecorder.UpdateRelayStates([]relay.ProbeResult{})
@@ -665,6 +674,9 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
665674
e.syncMsgMux.Lock()
666675
defer e.syncMsgMux.Unlock()
667676

677+
if update.GetAutoUpdateVersion() != "skip" {
678+
e.updateManager.SetVersion(update.GetAutoUpdateVersion())
679+
}
668680
if update.GetNetbirdConfig() != nil {
669681
wCfg := update.GetNetbirdConfig()
670682
err := e.updateTURNs(wCfg.GetTurns())
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package updatemanager
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"os"
9+
"path/filepath"
10+
"runtime"
11+
"strings"
12+
"sync"
13+
"time"
14+
15+
v "github.com/hashicorp/go-version"
16+
log "github.com/sirupsen/logrus"
17+
18+
"github.com/netbirdio/netbird/client/internal/peer"
19+
cProto "github.com/netbirdio/netbird/client/proto"
20+
"github.com/netbirdio/netbird/version"
21+
)
22+
23+
const (
24+
latestVersion = "latest"
25+
disableAutoUpdate = "disabled"
26+
unknownVersion = "Unknown"
27+
)
28+
29+
type UpdateManager struct {
30+
ctx context.Context
31+
cancel context.CancelFunc
32+
version string
33+
latestVersion string
34+
update *version.Update
35+
lastTrigger time.Time
36+
statusRecorder *peer.Status
37+
mutex sync.Mutex
38+
waitGroup sync.WaitGroup
39+
}
40+
41+
func NewUpdateManager(ctx context.Context, statusRecorder *peer.Status) *UpdateManager {
42+
update := version.NewUpdate("nb/client")
43+
ctx, cancel := context.WithCancel(ctx)
44+
manager := &UpdateManager{
45+
update: update,
46+
lastTrigger: time.Now().Add(-10 * time.Minute),
47+
statusRecorder: statusRecorder,
48+
ctx: ctx,
49+
cancel: cancel,
50+
version: disableAutoUpdate,
51+
latestVersion: unknownVersion,
52+
}
53+
update.SetDaemonVersion(version.NetbirdVersion())
54+
update.SetOnUpdateListener(manager.Updated)
55+
return manager
56+
}
57+
58+
func (u *UpdateManager) SetVersion(v string) {
59+
u.mutex.Lock()
60+
if u.version != v {
61+
log.Tracef("Auto-update version set to %s", v)
62+
u.version = v
63+
u.mutex.Unlock()
64+
go u.Updated("N/A")
65+
} else {
66+
u.mutex.Unlock()
67+
}
68+
}
69+
70+
func (u *UpdateManager) Stop() {
71+
u.update.StopWatch()
72+
u.cancel()
73+
u.waitGroup.Wait()
74+
}
75+
76+
func (u *UpdateManager) Updated(latestVersion string) {
77+
u.waitGroup.Add(1)
78+
defer u.waitGroup.Done()
79+
u.mutex.Lock()
80+
defer u.mutex.Unlock()
81+
select {
82+
case <-u.ctx.Done():
83+
return
84+
default:
85+
}
86+
if latestVersion != "N/A" {
87+
u.latestVersion = latestVersion
88+
}
89+
ctx, cancel := context.WithDeadline(u.ctx, time.Now().Add(time.Minute))
90+
defer cancel()
91+
u.CheckForUpdates(ctx)
92+
}
93+
94+
func (u *UpdateManager) CheckForUpdates(ctx context.Context) {
95+
if u.version == disableAutoUpdate {
96+
log.Trace("Skipped checking for updates, auto-update is disabled")
97+
return
98+
}
99+
currentVersionString := version.NetbirdVersion()
100+
updateVersionString := u.version
101+
if updateVersionString == latestVersion || updateVersionString == "" {
102+
if u.latestVersion == unknownVersion {
103+
log.Tracef("Latest version not fetched yet")
104+
return
105+
}
106+
updateVersionString = u.latestVersion
107+
}
108+
currentVersion, err := v.NewVersion(currentVersionString)
109+
if err != nil {
110+
log.Errorf("Error checking for update, error parsing version `%s`: %v", currentVersionString, err)
111+
return
112+
}
113+
updateVersion, err := v.NewVersion(updateVersionString)
114+
if err != nil {
115+
log.Errorf("Error checking for update, error parsing version `%s`: %v", updateVersionString, err)
116+
return
117+
}
118+
if currentVersion.LessThan(updateVersion) {
119+
if u.lastTrigger.Add(5 * time.Minute).Before(time.Now()) {
120+
u.lastTrigger = time.Now()
121+
log.Debugf("Auto-update triggered, current version: %s, target version: %s", currentVersionString, updateVersionString)
122+
u.statusRecorder.PublishEvent(
123+
cProto.SystemEvent_INFO,
124+
cProto.SystemEvent_SYSTEM,
125+
"Automatically updating client",
126+
"Your client version is older than auto-update version set in Management, updating client now.",
127+
nil,
128+
)
129+
err = u.triggerUpdate(ctx, updateVersionString)
130+
if err != nil {
131+
log.Errorf("Error triggering auto-update: %v", err)
132+
}
133+
}
134+
} else {
135+
log.Debugf("Current version (%s) is equal to or higher than auto-update version (%s)", currentVersionString, updateVersionString)
136+
}
137+
}
138+
139+
func downloadFileToTemporaryDir(ctx context.Context, fileURL string) (string, error) { //nolint:unused
140+
tempDir, err := os.MkdirTemp("", "netbird-installer-*")
141+
if err != nil {
142+
return "", fmt.Errorf("error creating temporary directory: %w", err)
143+
}
144+
fileNameParts := strings.Split(fileURL, "/")
145+
out, err := os.Create(filepath.Join(tempDir, fileNameParts[len(fileNameParts)-1]))
146+
if err != nil {
147+
return "", fmt.Errorf("error creating temporary file: %w", err)
148+
}
149+
defer func() {
150+
if err := out.Close(); err != nil {
151+
log.Errorf("Error closing temporary file: %v", err)
152+
}
153+
}()
154+
155+
req, err := http.NewRequestWithContext(ctx, "GET", fileURL, nil)
156+
if err != nil {
157+
return "", fmt.Errorf("error creating file download request: %w", err)
158+
}
159+
resp, err := http.DefaultClient.Do(req)
160+
if err != nil {
161+
return "", fmt.Errorf("error downloading file: %w", err)
162+
}
163+
defer func() {
164+
if err := resp.Body.Close(); err != nil {
165+
log.Errorf("Error closing response body: %v", err)
166+
}
167+
}()
168+
169+
_, err = io.Copy(out, resp.Body)
170+
if err != nil {
171+
return "", fmt.Errorf("error downloading file: %w", err)
172+
}
173+
174+
log.Tracef("Downloaded update file to %s", out.Name())
175+
176+
return out.Name(), nil
177+
}
178+
179+
func urlWithVersionArch(url, version string) string { //nolint:unused
180+
url = strings.ReplaceAll(url, "%version", version)
181+
url = strings.ReplaceAll(url, "%arch", runtime.GOARCH)
182+
return url
183+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
//go:build darwin
2+
3+
package updatemanager
4+
5+
import (
6+
"context"
7+
"fmt"
8+
"os"
9+
"os/exec"
10+
"os/user"
11+
"strings"
12+
"syscall"
13+
)
14+
15+
const (
16+
pkgDownloadURL = "https://github.com/netbirdio/netbird/releases/download/v%version/netbird_%version_darwin_%arch.pkg"
17+
)
18+
19+
func (u *UpdateManager) triggerUpdate(ctx context.Context, targetVersion string) error {
20+
cmd := exec.CommandContext(ctx, "pkgutil", "--pkg-info", "io.netbird.client")
21+
outBytes, err := cmd.Output()
22+
if err != nil && cmd.ProcessState.ExitCode() == 1 {
23+
// Not installed using pkg file, thus installed using Homebrew
24+
25+
return u.updateHomeBrew(ctx)
26+
}
27+
// Installed using pkg file
28+
path, err := downloadFileToTemporaryDir(ctx, urlWithVersionArch(pkgDownloadURL, targetVersion))
29+
if err != nil {
30+
return fmt.Errorf("error downloading update file: %w", err)
31+
}
32+
33+
volume := "/"
34+
for _, v := range strings.Split(string(outBytes), "\n") {
35+
trimmed := strings.TrimSpace(v)
36+
if strings.HasPrefix(trimmed, "volume: ") {
37+
volume = strings.Split(trimmed, ": ")[1]
38+
}
39+
}
40+
41+
cmd = exec.CommandContext(ctx, "installer", "-pkg", path, "-target", volume)
42+
43+
err = cmd.Start()
44+
if err != nil {
45+
return fmt.Errorf("error running pkg file: %w", err)
46+
}
47+
err = cmd.Process.Release()
48+
49+
return err
50+
}
51+
52+
func (u *UpdateManager) updateHomeBrew(ctx context.Context) error {
53+
// Homebrew must be run as a non-root user
54+
// To find out which user installed NetBird using HomeBrew we can check the owner of our brew tap directory
55+
fileInfo, err := os.Stat("/opt/homebrew/Library/Taps/netbirdio/homebrew-tap/")
56+
if err != nil {
57+
return fmt.Errorf("error getting homebrew installation path info: %w", err)
58+
}
59+
60+
fileSysInfo, ok := fileInfo.Sys().(*syscall.Stat_t)
61+
if !ok {
62+
return fmt.Errorf("error checking file owner, sysInfo type is %T not *syscall.Stat_t", fileInfo.Sys())
63+
}
64+
65+
// Get username from UID
66+
installer, err := user.LookupId(fmt.Sprintf("%d", fileSysInfo.Uid))
67+
if err != nil {
68+
return fmt.Errorf("error looking up brew installer user: %w", err)
69+
}
70+
userName := installer.Name
71+
// Get user HOME, required for brew to run correctly
72+
// https://github.com/Homebrew/brew/issues/15833
73+
homeDir := installer.HomeDir
74+
// Homebrew does not support installing specific versions
75+
// Thus it will always update to latest and ignore targetVersion
76+
upgradeArgs := []string{"-u", userName, "/opt/homebrew/bin/brew", "upgrade", "netbirdio/tap/netbird"}
77+
// Check if netbird-ui is installed
78+
cmd := exec.CommandContext(ctx, "brew", "info", "--json", "netbirdio/tap/netbird-ui")
79+
err = cmd.Run()
80+
if err == nil {
81+
// netbird-ui is installed
82+
upgradeArgs = append(upgradeArgs, "netbirdio/tap/netbird-ui")
83+
}
84+
cmd = exec.CommandContext(ctx, "sudo", upgradeArgs...)
85+
cmd.Env = append(cmd.Env, "HOME="+homeDir)
86+
87+
// Homebrew upgrade doesn't restart the client on its own
88+
// So we have to wait for it to finish running and ensure it's done
89+
// And then basically restart the netbird service
90+
err = cmd.Run()
91+
if err != nil {
92+
return fmt.Errorf("error running brew upgrade: %w", err)
93+
}
94+
95+
currentPID := os.Getpid()
96+
97+
// Restart netbird service after the fact
98+
// This is a workaround since attempting to restart using launchctl will kill the service and die before starting
99+
// the service again as it's a child process
100+
// using SIGTERM should ensure a clean shutdown
101+
process, err := os.FindProcess(currentPID)
102+
if err != nil {
103+
return fmt.Errorf("error finding current process: %w", err)
104+
}
105+
err = process.Signal(syscall.SIGTERM)
106+
if err != nil {
107+
return fmt.Errorf("error sending SIGTERM to current process: %w", err)
108+
}
109+
// We're dying now, which should restart us
110+
111+
return nil
112+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
//go:build freebsd
2+
3+
package updatemanager
4+
5+
import "context"
6+
7+
func (u *UpdateManager) triggerUpdate(ctx context.Context, targetVersion string) error {
8+
// TODO: Implement
9+
return nil
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
//go:build linux
2+
3+
package updatemanager
4+
5+
import "context"
6+
7+
func (u *UpdateManager) triggerUpdate(ctx context.Context, targetVersion string) error {
8+
// TODO: Implement
9+
return nil
10+
}

0 commit comments

Comments
 (0)