From e561f1ce613999d3a690ee110362f4f77316ae2d Mon Sep 17 00:00:00 2001 From: Andrew Lytvynov Date: Mon, 23 Oct 2023 10:24:57 -0700 Subject: [PATCH] clientupdate: manually restart Windows GUI after update (#9906) When updating via c2n, `tailscale.exe update` runs from `tailscaled.exe` which runs as SYSTEM. The MSI installer does not start the GUI when running as SYSTEM. This results in Tailscale just existing on auto-update, which is ungood. Instead, always ask the MSI installer to not launch the GUI (via `TS_NOLAUNCH` argument) and launch it manually with a token from the current logged in user. The token code was borrowed from https://github.com/tailscale/tailscale/blob/d9081d6ba201e19a4f9fc341511a6e105bdbb85f/net/dns/wsl_windows.go#L207-L232 Also, make some logging changes so that these issues are easier to debug in the future. Updates #755 Signed-off-by: Andrew Lytvynov --- clientupdate/clientupdate.go | 89 ++++++++++++++++++++++------ clientupdate/clientupdate_windows.go | 29 +++++++++ 2 files changed, 101 insertions(+), 17 deletions(-) diff --git a/clientupdate/clientupdate.go b/clientupdate/clientupdate.go index 1fbcdd4dd..85a826108 100644 --- a/clientupdate/clientupdate.go +++ b/clientupdate/clientupdate.go @@ -631,24 +631,53 @@ func (up *Updater) updateMacAppStore() error { return nil } -// winMSIEnv is the environment variable that, if set, is the MSI file for the -// update command to install. It's passed like this so we can stop the -// tailscale.exe process from running before the msiexec process runs and tries -// to overwrite ourselves. -const winMSIEnv = "TS_UPDATE_WIN_MSI" +const ( + // winMSIEnv is the environment variable that, if set, is the MSI file for + // the update command to install. It's passed like this so we can stop the + // tailscale.exe process from running before the msiexec process runs and + // tries to overwrite ourselves. + winMSIEnv = "TS_UPDATE_WIN_MSI" + // winExePathEnv is the environment variable that is set along with + // winMSIEnv and carries the full path of the calling tailscale.exe binary. + // It is used to re-launch the GUI process (tailscale-ipn.exe) after + // install is complete. + winExePathEnv = "TS_UPDATE_WIN_EXE_PATH" +) var ( - verifyAuthenticode func(string) error // or nil on non-Windows - markTempFileFunc func(string) error // or nil on non-Windows + verifyAuthenticode func(string) error // or nil on non-Windows + markTempFileFunc func(string) error // or nil on non-Windows + launchTailscaleAsWinGUIUser func(string) error // or nil on non-Windows ) func (up *Updater) updateWindows() error { if msi := os.Getenv(winMSIEnv); msi != "" { + // stdout/stderr from this part of the install could be lost since the + // parent tailscaled is replaced. Create a temp log file to have some + // output to debug with in case update fails. + close, err := up.switchOutputToFile() + if err != nil { + up.Logf("failed to create log file for installation: %v; proceeding with existing outputs", err) + } else { + defer close.Close() + } + up.Logf("installing %v ...", msi) if err := up.installMSI(msi); err != nil { up.Logf("MSI install failed: %v", err) return err } + up.Logf("relaunching tailscale-ipn.exe...") + exePath := os.Getenv(winExePathEnv) + if exePath == "" { + up.Logf("env var %q not passed to installer binary copy", winExePathEnv) + return fmt.Errorf("env var %q not passed to installer binary copy", winExePathEnv) + } + if err := launchTailscaleAsWinGUIUser(exePath); err != nil { + up.Logf("Failed to re-launch tailscale after update: %v", err) + return err + } + up.Logf("success.") return nil } @@ -691,7 +720,7 @@ func (up *Updater) updateWindows() error { up.Logf("authenticode verification succeeded") up.Logf("making tailscale.exe copy to switch to...") - selfCopy, err := makeSelfCopy() + selfOrig, selfCopy, err := makeSelfCopy() if err != nil { return err } @@ -699,7 +728,7 @@ func (up *Updater) updateWindows() error { up.Logf("running tailscale.exe copy for final install...") cmd := exec.Command(selfCopy, "update") - cmd.Env = append(os.Environ(), winMSIEnv+"="+msiTarget) + cmd.Env = append(os.Environ(), winMSIEnv+"="+msiTarget, winExePathEnv+"="+selfOrig) cmd.Stdout = up.Stderr cmd.Stderr = up.Stderr cmd.Stdin = os.Stdin @@ -712,10 +741,35 @@ func (up *Updater) updateWindows() error { panic("unreachable") } +func (up *Updater) switchOutputToFile() (io.Closer, error) { + var logFilePath string + exePath, err := os.Executable() + if err != nil { + logFilePath = filepath.Join(os.TempDir(), "tailscale-updater.log") + } else { + logFilePath = strings.TrimSuffix(exePath, ".exe") + ".log" + } + + up.Logf("writing update output to %q", logFilePath) + logFile, err := os.Create(logFilePath) + if err != nil { + return nil, err + } + + up.Logf = func(m string, args ...any) { + fmt.Fprintf(logFile, m+"\n", args...) + } + up.Stdout = logFile + up.Stderr = logFile + return logFile, nil +} + func (up *Updater) installMSI(msi string) error { var err error for tries := 0; tries < 2; tries++ { - cmd := exec.Command("msiexec.exe", "/i", filepath.Base(msi), "/quiet", "/promptrestart", "/qn") + // TS_NOLAUNCH: don't automatically launch the app after install. + // We will launch it explicitly as the current GUI user afterwards. + cmd := exec.Command("msiexec.exe", "/i", filepath.Base(msi), "/quiet", "/promptrestart", "/qn", "TS_NOLAUNCH=true") cmd.Dir = filepath.Dir(msi) cmd.Stdout = up.Stdout cmd.Stderr = up.Stderr @@ -724,6 +778,7 @@ func (up *Updater) installMSI(msi string) error { if err == nil { break } + up.Logf("Install attempt failed: %v", err) uninstallVersion := version.Short() if v := os.Getenv("TS_DEBUG_UNINSTALL_VERSION"); v != "" { uninstallVersion = v @@ -753,30 +808,30 @@ func msiUUIDForVersion(ver string) string { return "{" + strings.ToUpper(uuid.NewSHA1(uuid.NameSpaceURL, []byte(msiURL)).String()) + "}" } -func makeSelfCopy() (tmpPathExe string, err error) { +func makeSelfCopy() (origPathExe, tmpPathExe string, err error) { selfExe, err := os.Executable() if err != nil { - return "", err + return "", "", err } f, err := os.Open(selfExe) if err != nil { - return "", err + return "", "", err } defer f.Close() f2, err := os.CreateTemp("", "tailscale-updater-*.exe") if err != nil { - return "", err + return "", "", err } if f := markTempFileFunc; f != nil { if err := f(f2.Name()); err != nil { - return "", err + return "", "", err } } if _, err := io.Copy(f2, f); err != nil { f2.Close() - return "", err + return "", "", err } - return f2.Name(), f2.Close() + return selfExe, f2.Name(), f2.Close() } func (up *Updater) downloadURLToFile(pathSrc, fileDst string) (ret error) { diff --git a/clientupdate/clientupdate_windows.go b/clientupdate/clientupdate_windows.go index 2f6899a60..299343e02 100644 --- a/clientupdate/clientupdate_windows.go +++ b/clientupdate/clientupdate_windows.go @@ -7,13 +7,20 @@ package clientupdate import ( + "os/exec" + "os/user" + "path/filepath" + "syscall" + "golang.org/x/sys/windows" + "tailscale.com/util/winutil" "tailscale.com/util/winutil/authenticode" ) func init() { markTempFileFunc = markTempFileWindows verifyAuthenticode = verifyTailscale + launchTailscaleAsWinGUIUser = launchTailscaleAsGUIUser } func markTempFileWindows(name string) error { @@ -26,3 +33,25 @@ func markTempFileWindows(name string) error { func verifyTailscale(path string) error { return authenticode.Verify(path, certSubjectTailscale) } + +func launchTailscaleAsGUIUser(exePath string) error { + exePath = filepath.Join(filepath.Dir(exePath), "tailscale-ipn.exe") + + var token windows.Token + if u, err := user.Current(); err == nil && u.Name == "SYSTEM" { + sessionID := winutil.WTSGetActiveConsoleSessionId() + if sessionID != 0xFFFFFFFF { + if err := windows.WTSQueryUserToken(sessionID, &token); err != nil { + return err + } + defer token.Close() + } + } + + cmd := exec.Command(exePath) + cmd.SysProcAttr = &syscall.SysProcAttr{ + Token: syscall.Token(token), + HideWindow: true, + } + return cmd.Start() +}