mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-16 15:26:40 +00:00
[client, management] auto-update (#4732)
This commit is contained in:
@@ -34,6 +34,7 @@ import (
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
protobuf "google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
@@ -43,7 +44,6 @@ import (
|
||||
"github.com/netbirdio/netbird/client/ui/desktop"
|
||||
"github.com/netbirdio/netbird/client/ui/event"
|
||||
"github.com/netbirdio/netbird/client/ui/process"
|
||||
|
||||
"github.com/netbirdio/netbird/util"
|
||||
|
||||
"github.com/netbirdio/netbird/version"
|
||||
@@ -87,22 +87,24 @@ func main() {
|
||||
|
||||
// Create the service client (this also builds the settings or networks UI if requested).
|
||||
client := newServiceClient(&newServiceClientArgs{
|
||||
addr: flags.daemonAddr,
|
||||
logFile: logFile,
|
||||
app: a,
|
||||
showSettings: flags.showSettings,
|
||||
showNetworks: flags.showNetworks,
|
||||
showLoginURL: flags.showLoginURL,
|
||||
showDebug: flags.showDebug,
|
||||
showProfiles: flags.showProfiles,
|
||||
showQuickActions: flags.showQuickActions,
|
||||
addr: flags.daemonAddr,
|
||||
logFile: logFile,
|
||||
app: a,
|
||||
showSettings: flags.showSettings,
|
||||
showNetworks: flags.showNetworks,
|
||||
showLoginURL: flags.showLoginURL,
|
||||
showDebug: flags.showDebug,
|
||||
showProfiles: flags.showProfiles,
|
||||
showQuickActions: flags.showQuickActions,
|
||||
showUpdate: flags.showUpdate,
|
||||
showUpdateVersion: flags.showUpdateVersion,
|
||||
})
|
||||
|
||||
// Watch for theme/settings changes to update the icon.
|
||||
go watchSettingsChanges(a, client)
|
||||
|
||||
// Run in window mode if any UI flag was set.
|
||||
if flags.showSettings || flags.showNetworks || flags.showDebug || flags.showLoginURL || flags.showProfiles || flags.showQuickActions {
|
||||
if flags.showSettings || flags.showNetworks || flags.showDebug || flags.showLoginURL || flags.showProfiles || flags.showQuickActions || flags.showUpdate {
|
||||
a.Run()
|
||||
return
|
||||
}
|
||||
@@ -128,15 +130,17 @@ func main() {
|
||||
}
|
||||
|
||||
type cliFlags struct {
|
||||
daemonAddr string
|
||||
showSettings bool
|
||||
showNetworks bool
|
||||
showProfiles bool
|
||||
showDebug bool
|
||||
showLoginURL bool
|
||||
showQuickActions bool
|
||||
errorMsg string
|
||||
saveLogsInFile bool
|
||||
daemonAddr string
|
||||
showSettings bool
|
||||
showNetworks bool
|
||||
showProfiles bool
|
||||
showDebug bool
|
||||
showLoginURL bool
|
||||
showQuickActions bool
|
||||
errorMsg string
|
||||
saveLogsInFile bool
|
||||
showUpdate bool
|
||||
showUpdateVersion string
|
||||
}
|
||||
|
||||
// parseFlags reads and returns all needed command-line flags.
|
||||
@@ -156,6 +160,8 @@ func parseFlags() *cliFlags {
|
||||
flag.StringVar(&flags.errorMsg, "error-msg", "", "displays an error message window")
|
||||
flag.BoolVar(&flags.saveLogsInFile, "use-log-file", false, fmt.Sprintf("save logs in a file: %s/netbird-ui-PID.log", os.TempDir()))
|
||||
flag.BoolVar(&flags.showLoginURL, "login-url", false, "show login URL in a popup window")
|
||||
flag.BoolVar(&flags.showUpdate, "update", false, "show update progress window")
|
||||
flag.StringVar(&flags.showUpdateVersion, "update-version", "", "version to update to")
|
||||
flag.Parse()
|
||||
return &flags
|
||||
}
|
||||
@@ -319,6 +325,8 @@ type serviceClient struct {
|
||||
mExitNodeDeselectAll *systray.MenuItem
|
||||
logFile string
|
||||
wLoginURL fyne.Window
|
||||
wUpdateProgress fyne.Window
|
||||
updateContextCancel context.CancelFunc
|
||||
|
||||
connectCancel context.CancelFunc
|
||||
}
|
||||
@@ -329,15 +337,17 @@ type menuHandler struct {
|
||||
}
|
||||
|
||||
type newServiceClientArgs struct {
|
||||
addr string
|
||||
logFile string
|
||||
app fyne.App
|
||||
showSettings bool
|
||||
showNetworks bool
|
||||
showDebug bool
|
||||
showLoginURL bool
|
||||
showProfiles bool
|
||||
showQuickActions bool
|
||||
addr string
|
||||
logFile string
|
||||
app fyne.App
|
||||
showSettings bool
|
||||
showNetworks bool
|
||||
showDebug bool
|
||||
showLoginURL bool
|
||||
showProfiles bool
|
||||
showQuickActions bool
|
||||
showUpdate bool
|
||||
showUpdateVersion string
|
||||
}
|
||||
|
||||
// newServiceClient instance constructor
|
||||
@@ -355,7 +365,7 @@ func newServiceClient(args *newServiceClientArgs) *serviceClient {
|
||||
|
||||
showAdvancedSettings: args.showSettings,
|
||||
showNetworks: args.showNetworks,
|
||||
update: version.NewUpdate("nb/client-ui"),
|
||||
update: version.NewUpdateAndStart("nb/client-ui"),
|
||||
}
|
||||
|
||||
s.eventHandler = newEventHandler(s)
|
||||
@@ -375,6 +385,8 @@ func newServiceClient(args *newServiceClientArgs) *serviceClient {
|
||||
s.showProfilesUI()
|
||||
case args.showQuickActions:
|
||||
s.showQuickActionsUI()
|
||||
case args.showUpdate:
|
||||
s.showUpdateProgress(ctx, args.showUpdateVersion)
|
||||
}
|
||||
|
||||
return s
|
||||
@@ -814,7 +826,7 @@ func (s *serviceClient) handleSSOLogin(ctx context.Context, loginResp *proto.Log
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *serviceClient) menuUpClick(ctx context.Context) error {
|
||||
func (s *serviceClient) menuUpClick(ctx context.Context, wannaAutoUpdate bool) error {
|
||||
systray.SetTemplateIcon(iconConnectingMacOS, s.icConnecting)
|
||||
conn, err := s.getSrvClient(defaultFailTimeout)
|
||||
if err != nil {
|
||||
@@ -836,7 +848,9 @@ func (s *serviceClient) menuUpClick(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := conn.Up(ctx, &proto.UpRequest{}); err != nil {
|
||||
if _, err := s.conn.Up(s.ctx, &proto.UpRequest{
|
||||
AutoUpdate: protobuf.Bool(wannaAutoUpdate),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("start connection: %w", err)
|
||||
}
|
||||
|
||||
@@ -1097,6 +1111,26 @@ func (s *serviceClient) onTrayReady() {
|
||||
s.updateExitNodes()
|
||||
}
|
||||
})
|
||||
s.eventManager.AddHandler(func(event *proto.SystemEvent) {
|
||||
// todo use new Category
|
||||
if windowAction, ok := event.Metadata["progress_window"]; ok {
|
||||
targetVersion, ok := event.Metadata["version"]
|
||||
if !ok {
|
||||
targetVersion = "unknown"
|
||||
}
|
||||
log.Debugf("window action: %v", windowAction)
|
||||
if windowAction == "show" {
|
||||
if s.updateContextCancel != nil {
|
||||
s.updateContextCancel()
|
||||
s.updateContextCancel = nil
|
||||
}
|
||||
|
||||
subCtx, cancel := context.WithCancel(s.ctx)
|
||||
go s.eventHandler.runSelfCommand(subCtx, "update", "--update-version", targetVersion)
|
||||
s.updateContextCancel = cancel
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
go s.eventManager.Start(s.ctx)
|
||||
go s.eventHandler.listen(s.ctx)
|
||||
|
||||
@@ -80,7 +80,7 @@ func (h *eventHandler) handleConnectClick() {
|
||||
go func() {
|
||||
defer connectCancel()
|
||||
|
||||
if err := h.client.menuUpClick(connectCtx); err != nil {
|
||||
if err := h.client.menuUpClick(connectCtx, true); err != nil {
|
||||
st, ok := status.FromError(err)
|
||||
if errors.Is(err, context.Canceled) || (ok && st.Code() == codes.Canceled) {
|
||||
log.Debugf("connect operation cancelled by user")
|
||||
@@ -185,7 +185,7 @@ func (h *eventHandler) handleAdvancedSettingsClick() {
|
||||
go func() {
|
||||
defer h.client.mAdvancedSettings.Enable()
|
||||
defer h.client.getSrvConfig()
|
||||
h.runSelfCommand(h.client.ctx, "settings", "true")
|
||||
h.runSelfCommand(h.client.ctx, "settings")
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ func (h *eventHandler) handleCreateDebugBundleClick() {
|
||||
h.client.mCreateDebugBundle.Disable()
|
||||
go func() {
|
||||
defer h.client.mCreateDebugBundle.Enable()
|
||||
h.runSelfCommand(h.client.ctx, "debug", "true")
|
||||
h.runSelfCommand(h.client.ctx, "debug")
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ func (h *eventHandler) handleNetworksClick() {
|
||||
h.client.mNetworks.Disable()
|
||||
go func() {
|
||||
defer h.client.mNetworks.Enable()
|
||||
h.runSelfCommand(h.client.ctx, "networks", "true")
|
||||
h.runSelfCommand(h.client.ctx, "networks")
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -237,17 +237,21 @@ func (h *eventHandler) updateConfigWithErr() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *eventHandler) runSelfCommand(ctx context.Context, command, arg string) {
|
||||
func (h *eventHandler) runSelfCommand(ctx context.Context, command string, args ...string) {
|
||||
proc, err := os.Executable()
|
||||
if err != nil {
|
||||
log.Errorf("error getting executable path: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, proc,
|
||||
fmt.Sprintf("--%s=%s", command, arg),
|
||||
// Build the full command arguments
|
||||
cmdArgs := []string{
|
||||
fmt.Sprintf("--%s=true", command),
|
||||
fmt.Sprintf("--daemon-addr=%s", h.client.addr),
|
||||
)
|
||||
}
|
||||
cmdArgs = append(cmdArgs, args...)
|
||||
|
||||
cmd := exec.CommandContext(ctx, proc, cmdArgs...)
|
||||
|
||||
if out := h.client.attachOutput(cmd); out != nil {
|
||||
defer func() {
|
||||
@@ -257,17 +261,17 @@ func (h *eventHandler) runSelfCommand(ctx context.Context, command, arg string)
|
||||
}()
|
||||
}
|
||||
|
||||
log.Printf("running command: %s --%s=%s --daemon-addr=%s", proc, command, arg, h.client.addr)
|
||||
log.Printf("running command: %s", cmd.String())
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
log.Printf("command '%s %s' failed with exit code %d", command, arg, exitErr.ExitCode())
|
||||
log.Printf("command '%s' failed with exit code %d", cmd.String(), exitErr.ExitCode())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("command '%s %s' completed successfully", command, arg)
|
||||
log.Printf("command '%s' completed successfully", cmd.String())
|
||||
}
|
||||
|
||||
func (h *eventHandler) logout(ctx context.Context) error {
|
||||
|
||||
@@ -397,7 +397,7 @@ type profileMenu struct {
|
||||
logoutSubItem *subItem
|
||||
profilesState []Profile
|
||||
downClickCallback func() error
|
||||
upClickCallback func(context.Context) error
|
||||
upClickCallback func(context.Context, bool) error
|
||||
getSrvClientCallback func(timeout time.Duration) (proto.DaemonServiceClient, error)
|
||||
loadSettingsCallback func()
|
||||
app fyne.App
|
||||
@@ -411,7 +411,7 @@ type newProfileMenuArgs struct {
|
||||
profileMenuItem *systray.MenuItem
|
||||
emailMenuItem *systray.MenuItem
|
||||
downClickCallback func() error
|
||||
upClickCallback func(context.Context) error
|
||||
upClickCallback func(context.Context, bool) error
|
||||
getSrvClientCallback func(timeout time.Duration) (proto.DaemonServiceClient, error)
|
||||
loadSettingsCallback func()
|
||||
app fyne.App
|
||||
@@ -579,7 +579,7 @@ func (p *profileMenu) refresh() {
|
||||
connectCtx, connectCancel := context.WithCancel(p.ctx)
|
||||
p.serviceClient.connectCancel = connectCancel
|
||||
|
||||
if err := p.upClickCallback(connectCtx); err != nil {
|
||||
if err := p.upClickCallback(connectCtx, false); err != nil {
|
||||
log.Errorf("failed to handle up click after switching profile: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -267,7 +267,7 @@ func (s *serviceClient) showQuickActionsUI() {
|
||||
|
||||
connCmd := connectCommand{
|
||||
connectClient: func() error {
|
||||
return s.menuUpClick(s.ctx)
|
||||
return s.menuUpClick(s.ctx, false)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
140
client/ui/update.go
Normal file
140
client/ui/update.go
Normal file
@@ -0,0 +1,140 @@
|
||||
//go:build !(linux && 386)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
func (s *serviceClient) showUpdateProgress(ctx context.Context, version string) {
|
||||
log.Infof("show installer progress window: %s", version)
|
||||
s.wUpdateProgress = s.app.NewWindow("Automatically updating client")
|
||||
|
||||
statusLabel := widget.NewLabel("Updating...")
|
||||
infoLabel := widget.NewLabel(fmt.Sprintf("Your client version is older than the auto-update version set in Management.\nUpdating client to: %s.", version))
|
||||
content := container.NewVBox(infoLabel, statusLabel)
|
||||
s.wUpdateProgress.SetContent(content)
|
||||
s.wUpdateProgress.CenterOnScreen()
|
||||
s.wUpdateProgress.SetFixedSize(true)
|
||||
s.wUpdateProgress.SetCloseIntercept(func() {
|
||||
// this is empty to lock window until result known
|
||||
})
|
||||
s.wUpdateProgress.RequestFocus()
|
||||
s.wUpdateProgress.Show()
|
||||
|
||||
updateWindowCtx, cancel := context.WithTimeout(ctx, 15*time.Minute)
|
||||
|
||||
// Initialize dot updater
|
||||
updateText := dotUpdater()
|
||||
|
||||
// Channel to receive the result from RPC call
|
||||
resultErrCh := make(chan error, 1)
|
||||
resultOkCh := make(chan struct{}, 1)
|
||||
|
||||
// Start RPC call in background
|
||||
go func() {
|
||||
conn, err := s.getSrvClient(defaultFailTimeout)
|
||||
if err != nil {
|
||||
log.Infof("backend not reachable, upgrade in progress: %v", err)
|
||||
close(resultOkCh)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := conn.GetInstallerResult(updateWindowCtx, &proto.InstallerResultRequest{})
|
||||
if err != nil {
|
||||
log.Infof("backend stopped responding, upgrade in progress: %v", err)
|
||||
close(resultOkCh)
|
||||
return
|
||||
}
|
||||
|
||||
if !resp.Success {
|
||||
resultErrCh <- mapInstallError(resp.ErrorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// Success
|
||||
close(resultOkCh)
|
||||
}()
|
||||
|
||||
// Update UI with dots and wait for result
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
defer cancel()
|
||||
|
||||
// allow closing update window after 10 sec
|
||||
timerResetCloseInterceptor := time.NewTimer(10 * time.Second)
|
||||
defer timerResetCloseInterceptor.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-updateWindowCtx.Done():
|
||||
s.showInstallerResult(statusLabel, updateWindowCtx.Err())
|
||||
return
|
||||
case err := <-resultErrCh:
|
||||
s.showInstallerResult(statusLabel, err)
|
||||
return
|
||||
case <-resultOkCh:
|
||||
log.Info("backend exited, upgrade in progress, closing all UI")
|
||||
killParentUIProcess()
|
||||
s.app.Quit()
|
||||
return
|
||||
case <-ticker.C:
|
||||
statusLabel.SetText(updateText())
|
||||
case <-timerResetCloseInterceptor.C:
|
||||
s.wUpdateProgress.SetCloseIntercept(nil)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *serviceClient) showInstallerResult(statusLabel *widget.Label, err error) {
|
||||
s.wUpdateProgress.SetCloseIntercept(nil)
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
log.Warn("update watcher timed out")
|
||||
statusLabel.SetText("Update timed out. Please try again.")
|
||||
case errors.Is(err, context.Canceled):
|
||||
log.Info("update watcher canceled")
|
||||
statusLabel.SetText("Update canceled.")
|
||||
case err != nil:
|
||||
log.Errorf("update failed: %v", err)
|
||||
statusLabel.SetText("Update failed: " + err.Error())
|
||||
default:
|
||||
s.wUpdateProgress.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// dotUpdater returns a closure that cycles through dots for a loading animation.
|
||||
func dotUpdater() func() string {
|
||||
dotCount := 0
|
||||
return func() string {
|
||||
dotCount = (dotCount + 1) % 4
|
||||
return fmt.Sprintf("%s%s", "Updating", strings.Repeat(".", dotCount))
|
||||
}
|
||||
}
|
||||
|
||||
func mapInstallError(msg string) error {
|
||||
msg = strings.ToLower(strings.TrimSpace(msg))
|
||||
|
||||
switch {
|
||||
case strings.Contains(msg, "deadline exceeded"), strings.Contains(msg, "timeout"):
|
||||
return context.DeadlineExceeded
|
||||
case strings.Contains(msg, "canceled"), strings.Contains(msg, "cancelled"):
|
||||
return context.Canceled
|
||||
case msg == "":
|
||||
return errors.New("unknown update error")
|
||||
default:
|
||||
return errors.New(msg)
|
||||
}
|
||||
}
|
||||
7
client/ui/update_notwindows.go
Normal file
7
client/ui/update_notwindows.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !windows && !(linux && 386)
|
||||
|
||||
package main
|
||||
|
||||
func killParentUIProcess() {
|
||||
// No-op on non-Windows platforms
|
||||
}
|
||||
44
client/ui/update_windows.go
Normal file
44
client/ui/update_windows.go
Normal file
@@ -0,0 +1,44 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
nbprocess "github.com/netbirdio/netbird/client/ui/process"
|
||||
)
|
||||
|
||||
// killParentUIProcess finds and kills the parent systray UI process on Windows.
|
||||
// This is a workaround in case the MSI installer fails to properly terminate the UI process.
|
||||
// The installer should handle this via util:CloseApplication with TerminateProcess, but this
|
||||
// provides an additional safety mechanism to ensure the UI is closed before the upgrade proceeds.
|
||||
func killParentUIProcess() {
|
||||
pid, running, err := nbprocess.IsAnotherProcessRunning()
|
||||
if err != nil {
|
||||
log.Warnf("failed to check for parent UI process: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !running {
|
||||
log.Debug("no parent UI process found to kill")
|
||||
return
|
||||
}
|
||||
|
||||
log.Infof("killing parent UI process (PID: %d)", pid)
|
||||
|
||||
// Open the process with terminate rights
|
||||
handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, uint32(pid))
|
||||
if err != nil {
|
||||
log.Warnf("failed to open parent process %d: %v", pid, err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = windows.CloseHandle(handle)
|
||||
}()
|
||||
|
||||
// Terminate the process with exit code 0
|
||||
if err := windows.TerminateProcess(handle, 0); err != nil {
|
||||
log.Warnf("failed to terminate parent process %d: %v", pid, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user