@ -16,6 +16,7 @@ import (
"path/filepath"
"runtime"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/sys/windows"
@ -34,6 +35,12 @@ const (
// It is used to re-launch the GUI process (tailscale-ipn.exe) after
// install is complete.
winExePathEnv = "TS_UPDATE_WIN_EXE_PATH"
// winVersionEnv is the environment variable that is set along with
// winMSIEnv and carries the version of tailscale that is being installed.
// It is used for logging purposes.
winVersionEnv = "TS_UPDATE_WIN_VERSION"
// updaterPrefix is the prefix for the temporary executable created by [makeSelfCopy].
updaterPrefix = "tailscale-updater"
)
func makeSelfCopy ( ) ( origPathExe , tmpPathExe string , err error ) {
@ -46,7 +53,7 @@ func makeSelfCopy() (origPathExe, tmpPathExe string, err error) {
return "" , "" , err
}
defer f . Close ( )
f2 , err := os . CreateTemp ( "" , "tailscale-updater -*.exe")
f2 , err := os . CreateTemp ( "" , updaterPrefix + " -*.exe")
if err != nil {
return "" , "" , err
}
@ -137,7 +144,7 @@ you can run the command prompt as Administrator one of these ways:
up . Logf ( "authenticode verification succeeded" )
up . Logf ( "making tailscale.exe copy to switch to..." )
up . cleanupOldDownloads ( filepath . Join ( os . TempDir ( ) , "tailscale-updater -*.exe") )
up . cleanupOldDownloads ( filepath . Join ( os . TempDir ( ) , updaterPrefix + " -*.exe") )
selfOrig , selfCopy , err := makeSelfCopy ( )
if err != nil {
return err
@ -146,7 +153,7 @@ you can run the command prompt as Administrator one of these ways:
up . Logf ( "running tailscale.exe copy for final install..." )
cmd := exec . Command ( selfCopy , "update" )
cmd . Env = append ( os . Environ ( ) , winMSIEnv + "=" + msiTarget , winExePathEnv + "=" + selfOrig )
cmd . Env = append ( os . Environ ( ) , winMSIEnv + "=" + msiTarget , winExePathEnv + "=" + selfOrig , winVersionEnv + "=" + ver )
cmd . Stdout = up . Stderr
cmd . Stderr = up . Stderr
cmd . Stdin = os . Stdin
@ -162,23 +169,62 @@ you can run the command prompt as Administrator one of these ways:
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" , "/norestart" , "/qn" )
// msiexec.exe requires exclusive access to the log file, so create a dedicated one for each run.
installLogPath := up . startNewLogFile ( "tailscale-installer" , os . Getenv ( winVersionEnv ) )
up . Logf ( "Install log: %s" , installLogPath )
cmd := exec . Command ( "msiexec.exe" , "/i" , filepath . Base ( msi ) , "/quiet" , "/norestart" , "/qn" , "/L*v" , installLogPath )
cmd . Dir = filepath . Dir ( msi )
cmd . Stdout = up . Stdout
cmd . Stderr = up . Stderr
cmd . Stdin = os . Stdin
err = cmd . Run ( )
if err == nil {
break
switch err := err . ( type ) {
case nil :
// Success.
return nil
case * exec . ExitError :
// For possible error codes returned by Windows Installer, see
// https://web.archive.org/web/20250409144914/https://learn.microsoft.com/en-us/windows/win32/msi/error-codes
switch windows . Errno ( err . ExitCode ( ) ) {
case windows . ERROR_SUCCESS_REBOOT_REQUIRED :
// In most cases, updating Tailscale should not require a reboot.
// If it does, it might be because we failed to close the GUI
// and the installer couldn't replace tailscale-ipn.exe.
// The old GUI will continue to run until the next reboot.
// Not ideal, but also not a retryable error.
up . Logf ( "[unexpected] reboot required" )
return nil
case windows . ERROR_SUCCESS_REBOOT_INITIATED :
// Same as above, but perhaps the device is configured to prompt
// the user to reboot and the user has chosen to reboot now.
up . Logf ( "[unexpected] reboot initiated" )
return nil
case windows . ERROR_INSTALL_ALREADY_RUNNING :
// The Windows Installer service is currently busy.
// It could be our own install initiated by user/MDM/GP, another MSI install or perhaps a Windows Update install.
// Anyway, we can't do anything about it right now. The user (or tailscaled) can retry later.
// Retrying now will likely fail, and is risky since we might uninstall the current version
// and then fail to install the new one, leaving the user with no Tailscale at all.
//
// TODO(nickkhyl,awly): should we check if this is actually a downgrade before uninstalling the current version?
// Also, maybe keep retrying the install longer if we uninstalled the current version due to a failed install attempt?
up . Logf ( "another installation is already in progress" )
return err
}
default :
// Everything else is a retryable error.
}
up . Logf ( "Install attempt failed: %v" , err )
uninstallVersion := up . currentVersion
if v := os . Getenv ( "TS_DEBUG_UNINSTALL_VERSION" ) ; v != "" {
uninstallVersion = v
}
uninstallLogPath := up . startNewLogFile ( "tailscale-uninstaller" , uninstallVersion )
// Assume it's a downgrade, which msiexec won't permit. Uninstall our current version first.
up . Logf ( "Uninstalling current version %q for downgrade..." , uninstallVersion )
cmd = exec . Command ( "msiexec.exe" , "/x" , msiUUIDForVersion ( uninstallVersion ) , "/norestart" , "/qn" )
up . Logf ( "Uninstall log: %s" , uninstallLogPath )
cmd = exec . Command ( "msiexec.exe" , "/x" , msiUUIDForVersion ( uninstallVersion ) , "/norestart" , "/qn" , "/L*v" , uninstallLogPath )
cmd . Stdout = up . Stdout
cmd . Stderr = up . Stderr
cmd . Stdin = os . Stdin
@ -205,12 +251,14 @@ 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" )
logFilePath = up. startNewLogFile ( updaterPrefix , os . Getenv ( winVersionEnv ) )
} else {
logFilePath = strings . TrimSuffix ( exePath , ".exe" ) + ".log"
// Use the same suffix as the self-copy executable.
suffix := strings . TrimSuffix ( strings . TrimPrefix ( filepath . Base ( exePath ) , updaterPrefix ) , ".exe" )
logFilePath = up . startNewLogFile ( updaterPrefix , os . Getenv ( winVersionEnv ) + suffix )
}
up . Logf ( "writing update output to %q ", logFilePath )
up . Logf ( "writing update output to : %s ", logFilePath )
logFile , err := os . Create ( logFilePath )
if err != nil {
return nil , err
@ -223,3 +271,20 @@ func (up *Updater) switchOutputToFile() (io.Closer, error) {
up . Stderr = logFile
return logFile , nil
}
// startNewLogFile returns a name for a new log file.
// It cleans up any old log files with the same baseNamePrefix.
func ( up * Updater ) startNewLogFile ( baseNamePrefix , baseNameSuffix string ) string {
baseName := fmt . Sprintf ( "%s-%s-%s.log" , baseNamePrefix ,
time . Now ( ) . Format ( "20060102T150405" ) , baseNameSuffix )
dir := filepath . Join ( os . Getenv ( "ProgramData" ) , "Tailscale" , "Logs" )
if err := os . MkdirAll ( dir , 0700 ) ; err != nil {
up . Logf ( "failed to create log directory: %v" , err )
return filepath . Join ( os . TempDir ( ) , baseName )
}
// TODO(nickkhyl): preserve up to N old log files?
up . cleanupOldDownloads ( filepath . Join ( dir , baseNamePrefix + "-*.log" ) )
return filepath . Join ( dir , baseName )
}