diff --git a/api/client/webclient/webclient.go b/api/client/webclient/webclient.go index f3b6ba5586768..4cbbe31a9a706 100644 --- a/api/client/webclient/webclient.go +++ b/api/client/webclient/webclient.go @@ -338,8 +338,8 @@ type ProxySettings struct { type AutoUpdateSettings struct { // ToolsVersion defines the version of {tsh, tctl} for client auto update. ToolsVersion string `json:"tools_version"` - // ToolsMode defines mode client auto update feature `enabled|disabled`. - ToolsMode string `json:"tools_mode"` + // ToolsAutoUpdate indicates if the requesting tools client should be updated. + ToolsAutoUpdate bool `json:"tools_auto_update"` // AgentVersion defines the version of teleport that agents enrolled into autoupdates should run. AgentVersion string `json:"agent_version"` // AgentAutoUpdate indicates if the requesting agent should attempt to update now. diff --git a/integration/autoupdate/tools/main_test.go b/integration/autoupdate/tools/main_test.go index a14a6dc9fc683..bbc3f559f65c0 100644 --- a/integration/autoupdate/tools/main_test.go +++ b/integration/autoupdate/tools/main_test.go @@ -37,7 +37,8 @@ import ( "github.com/gravitational/trace" - "github.com/gravitational/teleport/integration/helpers" + "github.com/gravitational/teleport/api/constants" + "github.com/gravitational/teleport/integration/helpers/archive" ) const ( @@ -133,9 +134,9 @@ func buildAndArchiveApps(ctx context.Context, path string, toolsDir string, vers for _, app := range []string{"tsh", "tctl"} { output := filepath.Join(versionPath, app) switch runtime.GOOS { - case "windows": + case constants.WindowsOS: output = filepath.Join(versionPath, app+".exe") - case "darwin": + case constants.DarwinOS: output = filepath.Join(versionPath, app+".app", "Contents", "MacOS", app) } if err := buildBinary(output, toolsDir, version, baseURL); err != nil { @@ -143,15 +144,15 @@ func buildAndArchiveApps(ctx context.Context, path string, toolsDir string, vers } } switch runtime.GOOS { - case "darwin": + case constants.DarwinOS: archivePath := filepath.Join(path, fmt.Sprintf("teleport-%s.pkg", version)) - return trace.Wrap(helpers.CompressDirToPkgFile(ctx, versionPath, archivePath, "com.example.pkgtest")) - case "windows": + return trace.Wrap(archive.CompressDirToPkgFile(ctx, versionPath, archivePath, "com.example.pkgtest")) + case constants.WindowsOS: archivePath := filepath.Join(path, fmt.Sprintf("teleport-v%s-windows-amd64-bin.zip", version)) - return trace.Wrap(helpers.CompressDirToZipFile(ctx, versionPath, archivePath)) + return trace.Wrap(archive.CompressDirToZipFile(ctx, versionPath, archivePath)) default: archivePath := filepath.Join(path, fmt.Sprintf("teleport-v%s-linux-%s-bin.tar.gz", version, runtime.GOARCH)) - return trace.Wrap(helpers.CompressDirToTarGzFile(ctx, versionPath, archivePath)) + return trace.Wrap(archive.CompressDirToTarGzFile(ctx, versionPath, archivePath)) } } diff --git a/integration/autoupdate/tools/updater/main.go b/integration/autoupdate/tools/updater/main.go index e14c76e5d5aa8..775c7ab7b2e9d 100644 --- a/integration/autoupdate/tools/updater/main.go +++ b/integration/autoupdate/tools/updater/main.go @@ -25,11 +25,9 @@ import ( "log" "os" "os/signal" - "runtime" "syscall" "time" - "github.com/gravitational/teleport/api/constants" "github.com/gravitational/teleport/lib/autoupdate/tools" ) @@ -40,17 +38,20 @@ var ( ) func main() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() ctx, _ = signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) updater := tools.NewUpdater( - clientTools(), + tools.DefaultClientTools(), toolsDir, version, tools.WithBaseURL(baseURL), ) - toolsVersion, reExec := updater.CheckLocal() + toolsVersion, reExec, err := updater.CheckLocal() + if err != nil { + log.Fatal(err) + } if reExec { // Download and update the version of client tools required by the cluster. // This is required if the user passed in the TELEPORT_TOOLS_VERSION explicitly. @@ -76,13 +77,3 @@ func main() { fmt.Printf("Teleport v%v git\n", version) } } - -// clientTools list of the client tools needs to be updated. -func clientTools() []string { - switch runtime.GOOS { - case constants.WindowsOS: - return []string{"tsh.exe", "tctl.exe"} - default: - return []string{"tsh", "tctl"} - } -} diff --git a/integration/autoupdate/tools/updater_test.go b/integration/autoupdate/tools/updater_test.go index 52d7a7b8ab385..d429a7483910a 100644 --- a/integration/autoupdate/tools/updater_test.go +++ b/integration/autoupdate/tools/updater_test.go @@ -26,7 +26,6 @@ import ( "os/exec" "path/filepath" "regexp" - "runtime" "strings" "testing" "time" @@ -34,7 +33,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/gravitational/teleport/api/constants" "github.com/gravitational/teleport/lib/autoupdate/tools" ) @@ -51,7 +49,7 @@ func TestUpdate(t *testing.T) { // Fetch compiled test binary with updater logic and install to $TELEPORT_HOME. updater := tools.NewUpdater( - clientTools(), + tools.DefaultClientTools(), toolsDir, testVersions[0], tools.WithBaseURL(baseURL), @@ -93,7 +91,7 @@ func TestParallelUpdate(t *testing.T) { // Initial fetch the updater binary un-archive and replace. updater := tools.NewUpdater( - clientTools(), + tools.DefaultClientTools(), toolsDir, testVersions[0], tools.WithBaseURL(baseURL), @@ -167,7 +165,7 @@ func TestUpdateInterruptSignal(t *testing.T) { // Initial fetch the updater binary un-archive and replace. updater := tools.NewUpdater( - clientTools(), + tools.DefaultClientTools(), toolsDir, testVersions[0], tools.WithBaseURL(baseURL), @@ -220,12 +218,3 @@ func TestUpdateInterruptSignal(t *testing.T) { } assert.Contains(t, output.String(), "Update progress:") } - -func clientTools() []string { - switch runtime.GOOS { - case constants.WindowsOS: - return []string{"tsh.exe", "tctl.exe"} - default: - return []string{"tsh", "tctl"} - } -} diff --git a/integration/helpers/archive.go b/integration/helpers/archive/packaging.go similarity index 99% rename from integration/helpers/archive.go rename to integration/helpers/archive/packaging.go index 6e48108013d86..ee237749115a3 100644 --- a/integration/helpers/archive.go +++ b/integration/helpers/archive/packaging.go @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package helpers +package archive import ( "archive/tar" diff --git a/integrations/terraform/go.mod b/integrations/terraform/go.mod index c9459a5381189..030b29ba08341 100644 --- a/integrations/terraform/go.mod +++ b/integrations/terraform/go.mod @@ -184,6 +184,7 @@ require ( github.com/google/go-tspi v0.3.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af // indirect + github.com/google/renameio/v2 v2.0.0 // indirect github.com/google/s2a-go v0.1.8 // indirect github.com/google/safetext v0.0.0-20240104143208-7a7d9b3d812f // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect diff --git a/integrations/terraform/go.sum b/integrations/terraform/go.sum index 257c076fd1e5d..95ef579188b65 100644 --- a/integrations/terraform/go.sum +++ b/integrations/terraform/go.sum @@ -1252,7 +1252,6 @@ github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af h1:kmjWCqn2qkEml422C2Rrd27c3VGxi6a/6HNq8QmHRKM= github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= -github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/renameio/v2 v2.0.0 h1:UifI23ZTGY8Tt29JbYFiuyIU3eX+RNFtUwefq9qAhxg= github.com/google/renameio/v2 v2.0.0/go.mod h1:BtmJXm5YlszgC+TD4HOEEUFgkJP3nLxehU6hfe7jRt4= diff --git a/lib/autoupdate/tools/progress.go b/lib/autoupdate/tools/progress.go index 95395003730ec..34bbad2ff888d 100644 --- a/lib/autoupdate/tools/progress.go +++ b/lib/autoupdate/tools/progress.go @@ -20,24 +20,59 @@ package tools import ( "fmt" + "io" "strings" + + "github.com/gravitational/trace" ) type progressWriter struct { - n int64 - limit int64 + n int64 + limit int64 + size int + progress int } -func (w *progressWriter) Write(p []byte) (int, error) { - w.n = w.n + int64(len(p)) +// newProgressWriter creates progress writer instance and prints empty +// progress bar right after initialisation. +func newProgressWriter(size int) (*progressWriter, func()) { + pw := &progressWriter{size: size} + pw.Print(0) + return pw, func() { + fmt.Print("\n") + } +} - n := int((w.n*100)/w.limit) / 10 - bricks := strings.Repeat("▒", n) + strings.Repeat(" ", 10-n) +// Print prints the update progress bar with `n` bricks. +func (w *progressWriter) Print(n int) { + bricks := strings.Repeat("▒", n) + strings.Repeat(" ", w.size-n) fmt.Print("\rUpdate progress: [" + bricks + "] (Ctrl-C to cancel update)") +} - if w.n == w.limit { - fmt.Print("\n") +func (w *progressWriter) Write(p []byte) (int, error) { + if w.limit == 0 || w.size == 0 { + return len(p), nil + } + + w.n += int64(len(p)) + bricks := int((w.n*100)/w.limit) / w.size + if w.progress != bricks { + w.Print(bricks) + w.progress = bricks } return len(p), nil } + +// CopyLimit sets the limit of writing bytes to the progress writer and initiate copying process. +func (w *progressWriter) CopyLimit(dst io.Writer, src io.Reader, limit int64) (written int64, err error) { + if limit < 0 { + n, err := io.Copy(dst, io.TeeReader(src, w)) + w.Print(w.size) + return n, trace.Wrap(err) + } + + w.limit = limit + n, err := io.CopyN(dst, io.TeeReader(src, w), limit) + return n, trace.Wrap(err) +} diff --git a/lib/autoupdate/tools/updater.go b/lib/autoupdate/tools/updater.go index 96991044ccc31..96352e34d9910 100644 --- a/lib/autoupdate/tools/updater.go +++ b/lib/autoupdate/tools/updater.go @@ -36,12 +36,12 @@ import ( "syscall" "time" + "github.com/coreos/go-semver/semver" "github.com/google/uuid" "github.com/gravitational/trace" "github.com/gravitational/teleport/api/client/webclient" "github.com/gravitational/teleport/api/constants" - "github.com/gravitational/teleport/api/types/autoupdate" "github.com/gravitational/teleport/lib/utils" "github.com/gravitational/teleport/lib/utils/packaging" ) @@ -114,30 +114,37 @@ func NewUpdater(tools []string, toolsDir string, localVersion string, options .. // CheckLocal is run at client tool startup and will only perform local checks. // Returns the version needs to be updated and re-executed, by re-execution flag we // understand that update and re-execute is required. -func (u *Updater) CheckLocal() (version string, reExec bool) { +func (u *Updater) CheckLocal() (version string, reExec bool, err error) { // Check if the user has requested a specific version of client tools. requestedVersion := os.Getenv(teleportToolsVersionEnv) switch requestedVersion { // The user has turned off any form of automatic updates. case "off": - return "", false + return "", false, nil // Requested version already the same as client version. case u.localVersion: - return u.localVersion, false + return u.localVersion, false, nil + // No requested version, we continue. + case "": + // Requested version that is not the local one. + default: + if _, err := semver.NewVersion(requestedVersion); err != nil { + return "", false, trace.Wrap(err, "checking that request version is semantic") + } + return requestedVersion, true, nil } // If a version of client tools has already been downloaded to // tools directory, return that. - toolsVersion, err := checkToolVersion(u.toolsDir) - if err != nil { - return "", false + toolsVersion, err := CheckToolVersion(u.toolsDir) + if trace.IsNotFound(err) { + return u.localVersion, false, nil } - // The user has requested a specific version of client tools. - if requestedVersion != "" && requestedVersion != toolsVersion { - return requestedVersion, true + if err != nil { + return "", false, trace.Wrap(err) } - return toolsVersion, false + return toolsVersion, true, nil } // CheckRemote first checks the version set by the environment variable. If not set or disabled, @@ -145,7 +152,7 @@ func (u *Updater) CheckLocal() (version string, reExec bool) { // the `webapi/find` handler, which stores information about the required client tools version to // operate with this cluster. It returns the semantic version that needs updating and whether // re-execution is necessary, by re-execution flag we understand that update and re-execute is required. -func (u *Updater) CheckRemote(ctx context.Context, proxyAddr string) (version string, reExec bool, err error) { +func (u *Updater) CheckRemote(ctx context.Context, proxyAddr string, insecure bool) (version string, reExec bool, err error) { // Check if the user has requested a specific version of client tools. requestedVersion := os.Getenv(teleportToolsVersionEnv) switch requestedVersion { @@ -155,6 +162,14 @@ func (u *Updater) CheckRemote(ctx context.Context, proxyAddr string) (version st // Requested version already the same as client version. case u.localVersion: return u.localVersion, false, nil + // No requested version, we continue. + case "": + // Requested version that is not the local one. + default: + if _, err := semver.NewVersion(requestedVersion); err != nil { + return "", false, trace.Wrap(err, "checking that request version is semantic") + } + return requestedVersion, true, nil } certPool, err := x509.SystemCertPool() @@ -165,7 +180,8 @@ func (u *Updater) CheckRemote(ctx context.Context, proxyAddr string) (version st Context: ctx, ProxyAddr: proxyAddr, Pool: certPool, - Timeout: 30 * time.Second, + Timeout: 10 * time.Second, + Insecure: insecure, }) if err != nil { return "", false, trace.Wrap(err) @@ -173,28 +189,28 @@ func (u *Updater) CheckRemote(ctx context.Context, proxyAddr string) (version st // If a version of client tools has already been downloaded to // tools directory, return that. - toolsVersion, err := checkToolVersion(u.toolsDir) - if err != nil { + toolsVersion, err := CheckToolVersion(u.toolsDir) + if err != nil && !trace.IsNotFound(err) { return "", false, trace.Wrap(err) } switch { - case requestedVersion != "" && requestedVersion != toolsVersion: - return requestedVersion, true, nil - case resp.AutoUpdate.ToolsMode != autoupdate.ToolsUpdateModeEnabled || resp.AutoUpdate.ToolsVersion == "": - return "", false, nil + case !resp.AutoUpdate.ToolsAutoUpdate || resp.AutoUpdate.ToolsVersion == "": + if toolsVersion == "" { + return u.localVersion, false, nil + } case u.localVersion == resp.AutoUpdate.ToolsVersion: - return resp.AutoUpdate.ToolsVersion, false, nil + return u.localVersion, false, nil case resp.AutoUpdate.ToolsVersion != toolsVersion: return resp.AutoUpdate.ToolsVersion, true, nil } - return toolsVersion, false, nil + return toolsVersion, true, nil } // UpdateWithLock acquires filesystem lock, downloads requested version package, // unarchive and replace existing one. -func (u *Updater) UpdateWithLock(ctx context.Context, toolsVersion string) (err error) { +func (u *Updater) UpdateWithLock(ctx context.Context, updateToolsVersion string) (err error) { // Create tools directory if it does not exist. if err := os.MkdirAll(u.toolsDir, 0o755); err != nil { return trace.Wrap(err) @@ -211,21 +227,20 @@ func (u *Updater) UpdateWithLock(ctx context.Context, toolsVersion string) (err // If the version of the running binary or the version downloaded to // tools directory is the same as the requested version of client tools, // nothing to be done, exit early. - teleportVersion, err := checkToolVersion(u.toolsDir) + toolsVersion, err := CheckToolVersion(u.toolsDir) if err != nil && !trace.IsNotFound(err) { return trace.Wrap(err) - } - if toolsVersion == u.localVersion || toolsVersion == teleportVersion { + if updateToolsVersion == toolsVersion { return nil } // Download and update client tools in tools directory. - if err := u.Update(ctx, toolsVersion); err != nil { + if err := u.Update(ctx, updateToolsVersion); err != nil { return trace.Wrap(err) } - return + return nil } // Update downloads requested version and replace it with existing one and cleanups the previous downloads @@ -237,10 +252,18 @@ func (u *Updater) Update(ctx context.Context, toolsVersion string) error { return trace.Wrap(err) } + var pkgNames []string for _, pkg := range packages { - if err := u.update(ctx, pkg); err != nil { + pkgName := fmt.Sprint(uuid.New().String(), updatePackageSuffix) + if err := u.update(ctx, pkg, pkgName); err != nil { return trace.Wrap(err) } + pkgNames = append(pkgNames, pkgName) + } + + // Cleanup the tools directory with previously downloaded and un-archived versions. + if err := packaging.RemoveWithSuffix(u.toolsDir, updatePackageSuffix, pkgNames); err != nil { + slog.WarnContext(ctx, "failed to clean up tools directory", "error", err) } return nil @@ -248,16 +271,8 @@ func (u *Updater) Update(ctx context.Context, toolsVersion string) error { // update downloads the archive and validate against the hash. Download to a // temporary path within tools directory. -func (u *Updater) update(ctx context.Context, pkg packageURL) error { - hash, err := u.downloadHash(ctx, pkg.Hash) - if pkg.Optional && trace.IsNotFound(err) { - return nil - } - if err != nil { - return trace.Wrap(err) - } - - f, err := os.CreateTemp(u.toolsDir, "tmp-") +func (u *Updater) update(ctx context.Context, pkg packageURL, pkgName string) error { + f, err := os.CreateTemp("", "teleport-") if err != nil { return trace.Wrap(err) } @@ -275,11 +290,19 @@ func (u *Updater) update(ctx context.Context, pkg packageURL) error { if err != nil { return trace.Wrap(err) } + + hash, err := u.downloadHash(ctx, pkg.Hash) + if pkg.Optional && trace.IsNotFound(err) { + return nil + } + if err != nil { + return trace.Wrap(err) + } + if !bytes.Equal(archiveHash, hash) { return trace.BadParameter("hash of archive does not match downloaded archive") } - pkgName := fmt.Sprint(uuid.New().String(), updatePackageSuffix) extractDir := filepath.Join(u.toolsDir, pkgName) if runtime.GOOS != constants.DarwinOS { if err := os.Mkdir(extractDir, 0o755); err != nil { @@ -291,10 +314,6 @@ func (u *Updater) update(ctx context.Context, pkg packageURL) error { if err := packaging.ReplaceToolsBinaries(u.toolsDir, f.Name(), extractDir, u.tools); err != nil { return trace.Wrap(err) } - // Cleanup the tools directory with previously downloaded and un-archived versions. - if err := packaging.RemoveWithSuffix(u.toolsDir, updatePackageSuffix, pkgName); err != nil { - return trace.Wrap(err) - } return nil } @@ -340,7 +359,7 @@ func (u *Updater) downloadHash(ctx context.Context, url string) ([]byte, error) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { - return nil, trace.NotFound("hash file is not found: %v", resp.StatusCode) + return nil, trace.NotFound("hash file is not found: %q", url) } if resp.StatusCode != http.StatusOK { return nil, trace.BadParameter("bad status when downloading archive hash: %v", resp.StatusCode) @@ -362,6 +381,11 @@ func (u *Updater) downloadHash(ctx context.Context, url string) ([]byte, error) // downloadArchive downloads the archive package by `url` and writes content to the writer interface, // return calculated sha256 hash sum of the content. func (u *Updater) downloadArchive(ctx context.Context, url string, f io.Writer) ([]byte, error) { + // Display a progress bar before initiating the update request to inform the user that + // an update is in progress, allowing them the option to cancel before actual response + // which might be delayed with slow internet connection or complete isolation to CDN. + pw, finish := newProgressWriter(10) + defer finish() req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, trace.Wrap(err) @@ -385,14 +409,10 @@ func (u *Updater) downloadArchive(ctx context.Context, url string, f io.Writer) } h := sha256.New() - pw := &progressWriter{n: 0, limit: resp.ContentLength} - body := io.TeeReader(io.TeeReader(resp.Body, h), pw) - // It is a little inefficient to download the file to disk and then re-load // it into memory to unarchive later, but this is safer as it allows client // tools to validate the hash before trying to operate on the archive. - _, err = io.CopyN(f, body, resp.ContentLength) - if err != nil { + if _, err := pw.CopyLimit(f, io.TeeReader(resp.Body, h), resp.ContentLength); err != nil { return nil, trace.Wrap(err) } diff --git a/lib/autoupdate/tools/utils.go b/lib/autoupdate/tools/utils.go index d552b31abefe4..f937d228b5cd4 100644 --- a/lib/autoupdate/tools/utils.go +++ b/lib/autoupdate/tools/utils.go @@ -33,6 +33,7 @@ import ( "github.com/coreos/go-semver/semver" "github.com/gravitational/trace" + "github.com/gravitational/teleport/api/constants" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/modules" "github.com/gravitational/teleport/lib/utils" @@ -52,14 +53,26 @@ func Dir() (string, error) { return filepath.Join(home, ".tsh", "bin"), nil } -func checkToolVersion(toolsDir string) (string, error) { +// DefaultClientTools list of the client tools needs to be updated by default. +func DefaultClientTools() []string { + switch runtime.GOOS { + case constants.WindowsOS: + return []string{"tsh.exe", "tctl.exe"} + default: + return []string{"tsh", "tctl"} + } +} + +// CheckToolVersion returns current installed client tools version, must return NotFoundError if +// the client tools is not found in tools directory. +func CheckToolVersion(toolsDir string) (string, error) { // Find the path to the current executable. path, err := toolName(toolsDir) if err != nil { return "", trace.Wrap(err) } if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { - return "", nil + return "", trace.NotFound("autoupdate tool not found in %q", toolsDir) } else if err != nil { return "", trace.Wrap(err) } @@ -76,7 +89,7 @@ func checkToolVersion(toolsDir string) (string, error) { command.Env = []string{teleportToolsVersionEnv + "=off"} output, err := command.Output() if err != nil { - return "", trace.Wrap(err) + return "", trace.WrapWithMessage(err, "failed to determine version of %q tool", path) } // The output for "{tsh, tctl} version" can be multiple lines. Find the diff --git a/lib/client/api.go b/lib/client/api.go index 0a7b35dbd5e51..410934c440942 100644 --- a/lib/client/api.go +++ b/lib/client/api.go @@ -74,6 +74,7 @@ import ( "github.com/gravitational/teleport/lib/auth/touchid" wancli "github.com/gravitational/teleport/lib/auth/webauthncli" "github.com/gravitational/teleport/lib/authz" + "github.com/gravitational/teleport/lib/autoupdate/tools" libmfa "github.com/gravitational/teleport/lib/client/mfa" "github.com/gravitational/teleport/lib/client/sso" "github.com/gravitational/teleport/lib/client/terminal" @@ -95,6 +96,7 @@ import ( "github.com/gravitational/teleport/lib/utils" "github.com/gravitational/teleport/lib/utils/agentconn" logutils "github.com/gravitational/teleport/lib/utils/log" + "github.com/gravitational/teleport/lib/utils/signal" ) const ( @@ -699,6 +701,39 @@ func RetryWithRelogin(ctx context.Context, tc *TeleportClient, fn func() error, return trace.Wrap(err) } + // The user has typed a command like `tsh ssh ...` without being logged in, + // if the running binary needs to be updated, update and re-exec. + // + // If needed, download the new version of {tsh, tctl} and re-exec. Make + // sure to exit this process with the same exit code as the child process. + // + toolsDir, err := tools.Dir() + if err != nil { + return trace.Wrap(err) + } + updater := tools.NewUpdater(tools.DefaultClientTools(), toolsDir, teleport.Version) + toolsVersion, reExec, err := updater.CheckRemote(ctx, tc.WebProxyAddr, tc.InsecureSkipVerify) + if err != nil { + return trace.Wrap(err) + } + if reExec { + ctxUpdate, cancel := signal.GetSignalHandler().NotifyContext(context.Background()) + defer cancel() + // Download the version of client tools required by the cluster. + err := updater.UpdateWithLock(ctxUpdate, toolsVersion) + if err != nil && !errors.Is(err, context.Canceled) { + utils.FatalError(err) + } + // Re-execute client tools with the correct version of client tools. + code, err := updater.Exec() + if err != nil && !errors.Is(err, os.ErrNotExist) { + log.Debugf("Failed to re-exec client tool: %v.", err) + os.Exit(code) + } else if err == nil { + os.Exit(code) + } + } + if opt.afterLoginHook != nil { if err := opt.afterLoginHook(); err != nil { return trace.Wrap(err) diff --git a/lib/utils/packaging/unarchive.go b/lib/utils/packaging/unarchive.go index f1a197e095b1a..6496afbc182c3 100644 --- a/lib/utils/packaging/unarchive.go +++ b/lib/utils/packaging/unarchive.go @@ -38,13 +38,13 @@ const ( ) // RemoveWithSuffix removes all that matches the provided suffix, except for file or directory with `skipName`. -func RemoveWithSuffix(dir, suffix, skipName string) error { +func RemoveWithSuffix(dir, suffix string, skipNames []string) error { var removePaths []string err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil { return trace.Wrap(err) } - if skipName == info.Name() { + if slices.Contains(skipNames, info.Name()) { return nil } if !strings.HasSuffix(info.Name(), suffix) { @@ -59,12 +59,13 @@ func RemoveWithSuffix(dir, suffix, skipName string) error { if err != nil { return trace.Wrap(err) } + var aggErr []error for _, path := range removePaths { if err := os.RemoveAll(path); err != nil { - return trace.Wrap(err) + aggErr = append(aggErr, err) } } - return nil + return trace.NewAggregate(aggErr...) } // replaceZip un-archives the Teleport package in .zip format, iterates through @@ -118,7 +119,7 @@ func replaceZip(toolsDir string, archivePath string, extractDir string, execName defer file.Close() dest := filepath.Join(extractDir, baseName) - destFile, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755) + destFile, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755) if err != nil { return trace.Wrap(err) } @@ -131,7 +132,10 @@ func replaceZip(toolsDir string, archivePath string, extractDir string, execName if err := os.Remove(appPath); err != nil && !os.IsNotExist(err) { return trace.Wrap(err) } - if err := os.Symlink(dest, appPath); err != nil { + // For the Windows build we have to copy binary to be able + // to do this without administrative access as it required + // for symlinks. + if err := utils.CopyFile(dest, appPath, 0o755); err != nil { return trace.Wrap(err) } return trace.Wrap(destFile.Close()) diff --git a/lib/utils/packaging/unarchive_test.go b/lib/utils/packaging/unarchive_test.go index 30933bbb75927..b124b603b0fd5 100644 --- a/lib/utils/packaging/unarchive_test.go +++ b/lib/utils/packaging/unarchive_test.go @@ -30,7 +30,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/gravitational/teleport/integration/helpers" + "github.com/gravitational/teleport/integration/helpers/archive" ) // TestPackaging verifies un-archiving of all supported teleport package formats. @@ -63,7 +63,7 @@ func TestPackaging(t *testing.T) { t.Run("tar.gz", func(t *testing.T) { archivePath := filepath.Join(toolsDir, "tsh.tar.gz") - err = helpers.CompressDirToTarGzFile(ctx, sourceDir, archivePath) + err = archive.CompressDirToTarGzFile(ctx, sourceDir, archivePath) require.NoError(t, err) require.FileExists(t, archivePath, "archive not created") @@ -85,7 +85,7 @@ func TestPackaging(t *testing.T) { t.Skip("unsupported platform") } archivePath := filepath.Join(toolsDir, "tsh.pkg") - err = helpers.CompressDirToPkgFile(ctx, sourceDir, archivePath, "com.example.pkgtest") + err = archive.CompressDirToPkgFile(ctx, sourceDir, archivePath, "com.example.pkgtest") require.NoError(t, err) require.FileExists(t, archivePath, "archive not created") @@ -101,7 +101,7 @@ func TestPackaging(t *testing.T) { t.Run("zip", func(t *testing.T) { archivePath := filepath.Join(toolsDir, "tsh.zip") - err = helpers.CompressDirToZipFile(ctx, sourceDir, archivePath) + err = archive.CompressDirToZipFile(ctx, sourceDir, archivePath) require.NoError(t, err) require.FileExists(t, archivePath, "archive not created") @@ -132,7 +132,7 @@ func TestRemoveWithSuffix(t *testing.T) { dirInSkipPath := filepath.Join(skipPath, dirForRemove) require.NoError(t, os.MkdirAll(skipPath, 0o755)) - err := RemoveWithSuffix(testDir, dirForRemove, skipName) + err := RemoveWithSuffix(testDir, dirForRemove, []string{skipName}) require.NoError(t, err) _, err = os.Stat(filepath.Join(testDir, dirForRemove)) diff --git a/lib/utils/packaging/unarchive_unix.go b/lib/utils/packaging/unarchive_unix.go index ea51afdbbc7f0..8daf1b3aa5525 100644 --- a/lib/utils/packaging/unarchive_unix.go +++ b/lib/utils/packaging/unarchive_unix.go @@ -186,9 +186,10 @@ func replacePkg(toolsDir string, archivePath string, extractDir string, execName // swap operations. This ensures that the "com.apple.macl" extended // attribute is set and macOS will not send a SIGKILL to the process // if multiple processes are trying to operate on it. - command := exec.Command(path, "version", "--client") + command := exec.Command(path, "version") + command.Env = []string{"TELEPORT_TOOLS_VERSION=off"} if err := command.Run(); err != nil { - return trace.Wrap(err) + return trace.WrapWithMessage(err, "failed to validate binary") } // Due to macOS applications not being a single binary (they are a diff --git a/lib/utils/signal/stack_handler.go b/lib/utils/signal/stack_handler.go new file mode 100644 index 0000000000000..0fcbefb081d11 --- /dev/null +++ b/lib/utils/signal/stack_handler.go @@ -0,0 +1,98 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package signal + +import ( + "container/list" + "context" + "os" + "os/signal" + "sync" + "syscall" +) + +// Handler implements stack for context cancellation. +type Handler struct { + mu sync.Mutex + list *list.List +} + +var handler = &Handler{ + list: list.New(), +} + +// GetSignalHandler returns global singleton instance of signal +func GetSignalHandler() *Handler { + return handler +} + +// NotifyContext creates context which going to be canceled after SIGINT, SIGTERM +// in order of adding them to the stack. When very first context is canceled +// we stop watching the OS signals. +func (s *Handler) NotifyContext(parent context.Context) (context.Context, context.CancelFunc) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.list.Len() == 0 { + s.listenSignals() + } + + ctx, cancel := context.WithCancel(parent) + element := s.list.PushBack(cancel) + + return ctx, func() { + s.mu.Lock() + defer s.mu.Unlock() + + s.list.Remove(element) + cancel() + } +} + +// listenSignals sets up the signal listener for SIGINT, SIGTERM. +func (s *Handler) listenSignals() { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + go func() { + for { + if sig := <-sigChan; sig == nil { + return + } + if !s.cancelNext() { + signal.Stop(sigChan) + return + } + } + }() +} + +// cancelNext calls the most recent cancel func in the stack. +func (s *Handler) cancelNext() bool { + s.mu.Lock() + defer s.mu.Unlock() + + if s.list.Len() > 0 { + cancel := s.list.Remove(s.list.Back()) + if cancel != nil { + cancel.(context.CancelFunc)() + } + } + + return s.list.Len() != 0 +} diff --git a/lib/utils/signal/stack_handler_test.go b/lib/utils/signal/stack_handler_test.go new file mode 100644 index 0000000000000..b900939b886e8 --- /dev/null +++ b/lib/utils/signal/stack_handler_test.go @@ -0,0 +1,88 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package signal + +import ( + "context" + "os" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestGetSignalHandler verifies the cancellation stack order. +func TestGetSignalHandler(t *testing.T) { + testHandler := GetSignalHandler() + parent := context.Background() + + ctx1, cancel1 := testHandler.NotifyContext(parent) + ctx2, cancel2 := testHandler.NotifyContext(parent) + ctx3, cancel3 := testHandler.NotifyContext(parent) + ctx4, cancel4 := testHandler.NotifyContext(parent) + t.Cleanup(func() { + cancel4() + cancel2() + cancel1() + }) + + // Verify that all context not canceled. + require.NoError(t, ctx4.Err()) + require.NoError(t, ctx3.Err()) + require.NoError(t, ctx2.Err()) + require.NoError(t, ctx1.Err()) + + // Cancel context manually to ensure it was removed from stack in right order. + cancel3() + + // Check that last added context is canceled. + require.NoError(t, syscall.Kill(os.Getpid(), syscall.SIGINT)) + select { + case <-ctx4.Done(): + assert.ErrorIs(t, ctx3.Err(), context.Canceled) + assert.NoError(t, ctx2.Err()) + assert.NoError(t, ctx1.Err()) + case <-time.After(time.Second): + assert.Fail(t, "context 3 must be canceled") + } + + // Send interrupt signal to cancel next context in the stack. + require.NoError(t, syscall.Kill(os.Getpid(), syscall.SIGINT)) + select { + case <-ctx2.Done(): + assert.ErrorIs(t, ctx4.Err(), context.Canceled) + assert.ErrorIs(t, ctx3.Err(), context.Canceled) + assert.NoError(t, ctx1.Err()) + case <-time.After(time.Second): + assert.Fail(t, "context 2 must be canceled") + } + + // All context must be canceled. + require.NoError(t, syscall.Kill(os.Getpid(), syscall.SIGINT)) + select { + case <-ctx1.Done(): + assert.ErrorIs(t, ctx4.Err(), context.Canceled) + assert.ErrorIs(t, ctx3.Err(), context.Canceled) + assert.ErrorIs(t, ctx2.Err(), context.Canceled) + case <-time.After(time.Second): + assert.Fail(t, "context 1 must be canceled") + } +} diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 2bbb2bcbdd561..cc309efe1043f 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -1593,7 +1593,7 @@ func (h *Handler) automaticUpdateSettings(ctx context.Context) webclient.AutoUpd } return webclient.AutoUpdateSettings{ - ToolsMode: getToolsMode(autoUpdateConfig), + ToolsAutoUpdate: getToolsAutoUpdate(autoUpdateConfig), ToolsVersion: getToolsVersion(autoUpdateVersion), AgentUpdateJitterSeconds: DefaultAgentUpdateJitterSeconds, AgentVersion: getAgentVersion(autoUpdateVersion), @@ -5154,15 +5154,15 @@ func readEtagFromAppHash(fs http.FileSystem) (string, error) { return etag, nil } -func getToolsMode(config *autoupdatepb.AutoUpdateConfig) string { +func getToolsAutoUpdate(config *autoupdatepb.AutoUpdateConfig) bool { // If we can't get the AU config or if AUs are not configured, we default to "disabled". // This ensures we fail open and don't accidentally update agents if something is going wrong. // If we want to enable AUs by default, it would be better to create a default "autoupdate_config" resource // than changing this logic. - if config.GetSpec().GetTools() == nil { - return autoupdate.ToolsUpdateModeDisabled + if config.GetSpec().GetTools() != nil { + return config.GetSpec().GetTools().GetMode() == autoupdate.ToolsUpdateModeEnabled } - return config.GetSpec().GetTools().GetMode() + return false } func getToolsVersion(version *autoupdatepb.AutoUpdateVersion) string { diff --git a/lib/web/apiserver_ping_test.go b/lib/web/apiserver_ping_test.go index 5ce3720375c46..2bf325d4f7902 100644 --- a/lib/web/apiserver_ping_test.go +++ b/lib/web/apiserver_ping_test.go @@ -306,7 +306,7 @@ func TestPing_autoUpdateResources(t *testing.T) { name: "resources not defined", expected: webclient.AutoUpdateSettings{ ToolsVersion: api.Version, - ToolsMode: autoupdate.ToolsUpdateModeDisabled, + ToolsAutoUpdate: false, AgentUpdateJitterSeconds: DefaultAgentUpdateJitterSeconds, AgentAutoUpdate: false, AgentVersion: api.Version, @@ -320,7 +320,7 @@ func TestPing_autoUpdateResources(t *testing.T) { }, }, expected: webclient.AutoUpdateSettings{ - ToolsMode: autoupdate.ToolsUpdateModeEnabled, + ToolsAutoUpdate: true, ToolsVersion: api.Version, AgentUpdateJitterSeconds: DefaultAgentUpdateJitterSeconds, AgentAutoUpdate: false, @@ -346,7 +346,7 @@ func TestPing_autoUpdateResources(t *testing.T) { }, expected: webclient.AutoUpdateSettings{ ToolsVersion: api.Version, - ToolsMode: autoupdate.ToolsUpdateModeDisabled, + ToolsAutoUpdate: false, AgentUpdateJitterSeconds: DefaultAgentUpdateJitterSeconds, AgentAutoUpdate: true, AgentVersion: "1.2.4", @@ -371,7 +371,7 @@ func TestPing_autoUpdateResources(t *testing.T) { }, expected: webclient.AutoUpdateSettings{ ToolsVersion: api.Version, - ToolsMode: autoupdate.ToolsUpdateModeDisabled, + ToolsAutoUpdate: false, AgentUpdateJitterSeconds: DefaultAgentUpdateJitterSeconds, AgentAutoUpdate: false, AgentVersion: "1.2.4", @@ -384,7 +384,7 @@ func TestPing_autoUpdateResources(t *testing.T) { version: &autoupdatev1pb.AutoUpdateVersionSpec{}, expected: webclient.AutoUpdateSettings{ ToolsVersion: api.Version, - ToolsMode: autoupdate.ToolsUpdateModeDisabled, + ToolsAutoUpdate: false, AgentUpdateJitterSeconds: DefaultAgentUpdateJitterSeconds, AgentAutoUpdate: false, AgentVersion: api.Version, @@ -400,7 +400,7 @@ func TestPing_autoUpdateResources(t *testing.T) { }, expected: webclient.AutoUpdateSettings{ ToolsVersion: "1.2.3", - ToolsMode: autoupdate.ToolsUpdateModeDisabled, + ToolsAutoUpdate: false, AgentUpdateJitterSeconds: DefaultAgentUpdateJitterSeconds, AgentAutoUpdate: false, AgentVersion: api.Version, @@ -420,7 +420,7 @@ func TestPing_autoUpdateResources(t *testing.T) { }, }, expected: webclient.AutoUpdateSettings{ - ToolsMode: autoupdate.ToolsUpdateModeEnabled, + ToolsAutoUpdate: true, ToolsVersion: "1.2.3", AgentUpdateJitterSeconds: DefaultAgentUpdateJitterSeconds, AgentAutoUpdate: false, @@ -440,7 +440,7 @@ func TestPing_autoUpdateResources(t *testing.T) { }, }, expected: webclient.AutoUpdateSettings{ - ToolsMode: autoupdate.ToolsUpdateModeDisabled, + ToolsAutoUpdate: false, ToolsVersion: "3.2.1", AgentUpdateJitterSeconds: DefaultAgentUpdateJitterSeconds, AgentAutoUpdate: false, diff --git a/tool/tctl/common/tctl.go b/tool/tctl/common/tctl.go index 48ad1f0b75b6d..5bd531cb54490 100644 --- a/tool/tctl/common/tctl.go +++ b/tool/tctl/common/tctl.go @@ -44,6 +44,7 @@ import ( "github.com/gravitational/teleport/lib/auth/authclient" "github.com/gravitational/teleport/lib/auth/state" "github.com/gravitational/teleport/lib/auth/storage" + "github.com/gravitational/teleport/lib/autoupdate/tools" "github.com/gravitational/teleport/lib/client" "github.com/gravitational/teleport/lib/client/identityfile" libmfa "github.com/gravitational/teleport/lib/client/mfa" @@ -53,6 +54,7 @@ import ( "github.com/gravitational/teleport/lib/reversetunnelclient" "github.com/gravitational/teleport/lib/service/servicecfg" "github.com/gravitational/teleport/lib/utils" + "github.com/gravitational/teleport/lib/utils/signal" "github.com/gravitational/teleport/tool/common" ) @@ -103,8 +105,43 @@ type CLICommand interface { // "distributions" like OSS or Enterprise // // distribution: name of the Teleport distribution -func Run(commands []CLICommand) { - err := TryRun(commands, os.Args[1:]) +func Run(ctx context.Context, commands []CLICommand) { + // The user has typed a command like `tsh ssh ...` without being logged in, + // if the running binary needs to be updated, update and re-exec. + // + // If needed, download the new version of {tsh, tctl} and re-exec. Make + // sure to exit this process with the same exit code as the child process. + // + toolsDir, err := tools.Dir() + if err != nil { + utils.FatalError(err) + } + updater := tools.NewUpdater(tools.DefaultClientTools(), toolsDir, teleport.Version) + toolsVersion, reExec, err := updater.CheckLocal() + if err != nil { + utils.FatalError(err) + } + if reExec { + ctxUpdate, cancel := signal.GetSignalHandler().NotifyContext(ctx) + defer cancel() + // Download the version of client tools required by the cluster. This + // is required if the user passed in the TELEPORT_TOOLS_VERSION + // explicitly. + err := updater.UpdateWithLock(ctxUpdate, toolsVersion) + if err != nil && !errors.Is(err, context.Canceled) { + utils.FatalError(err) + } + // Re-execute client tools with the correct version of client tools. + code, err := updater.Exec() + if err != nil && !errors.Is(err, os.ErrNotExist) { + log.Debugf("Failed to re-exec client tool: %v.", err) + os.Exit(code) + } else if err == nil { + os.Exit(code) + } + } + + err = TryRun(commands, os.Args[1:]) if err != nil { var exitError *common.ExitCodeError if errors.As(err, &exitError) { diff --git a/tool/tctl/main.go b/tool/tctl/main.go index f363e347f25c9..6dfae87ffdef2 100644 --- a/tool/tctl/main.go +++ b/tool/tctl/main.go @@ -19,9 +19,15 @@ package main import ( + "context" + + "github.com/gravitational/teleport/lib/utils/signal" "github.com/gravitational/teleport/tool/tctl/common" ) func main() { - common.Run(common.Commands()) + ctx, cancel := signal.GetSignalHandler().NotifyContext(context.Background()) + defer cancel() + + common.Run(ctx, common.Commands()) } diff --git a/tool/tsh/common/tsh.go b/tool/tsh/common/tsh.go index 2164dea15d3ea..300ccddc9bd07 100644 --- a/tool/tsh/common/tsh.go +++ b/tool/tsh/common/tsh.go @@ -41,7 +41,6 @@ import ( "strconv" "strings" "sync" - "syscall" "time" "github.com/alecthomas/kingpin/v2" @@ -74,6 +73,7 @@ import ( "github.com/gravitational/teleport/lib/asciitable" "github.com/gravitational/teleport/lib/auth/authclient" wancli "github.com/gravitational/teleport/lib/auth/webauthncli" + "github.com/gravitational/teleport/lib/autoupdate/tools" "github.com/gravitational/teleport/lib/benchmark" benchmarkdb "github.com/gravitational/teleport/lib/benchmark/db" "github.com/gravitational/teleport/lib/client" @@ -94,6 +94,7 @@ import ( "github.com/gravitational/teleport/lib/utils" "github.com/gravitational/teleport/lib/utils/diagnostics/latency" "github.com/gravitational/teleport/lib/utils/mlock" + stacksignal "github.com/gravitational/teleport/lib/utils/signal" "github.com/gravitational/teleport/tool/common" "github.com/gravitational/teleport/tool/common/fido2" "github.com/gravitational/teleport/tool/common/touchid" @@ -607,7 +608,7 @@ func Main() { cmdLineOrig := os.Args[1:] var cmdLine []string - ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) + ctx, cancel := stacksignal.GetSignalHandler().NotifyContext(context.Background()) defer cancel() // lets see: if the executable name is 'ssh' or 'scp' we convert @@ -705,6 +706,38 @@ func initLogger(cf *CLIConf) { // // DO NOT RUN TESTS that call Run() in parallel (unless you taken precautions). func Run(ctx context.Context, args []string, opts ...CliOption) error { + // At process startup, check if a version has already been downloaded to + // $TELEPORT_HOME/bin or if the user has set the TELEPORT_TOOLS_VERSION + // environment variable. If so, re-exec that version of {tsh, tctl}. + toolsDir, err := tools.Dir() + if err != nil { + return trace.Wrap(err) + } + updater := tools.NewUpdater(tools.DefaultClientTools(), toolsDir, teleport.Version) + toolsVersion, reExec, err := updater.CheckLocal() + if err != nil { + return trace.Wrap(err) + } + if reExec { + ctxUpdate, cancel := stacksignal.GetSignalHandler().NotifyContext(ctx) + defer cancel() + // Download the version of client tools required by the cluster. This + // is required if the user passed in the TELEPORT_TOOLS_VERSION + // explicitly. + err := updater.UpdateWithLock(ctxUpdate, toolsVersion) + if err != nil && !errors.Is(err, context.Canceled) { + return trace.Wrap(err) + } + // Re-execute client tools with the correct version of client tools. + code, err := updater.Exec() + if err != nil && !errors.Is(err, os.ErrNotExist) { + log.Debugf("Failed to re-exec client tool: %v.", err) + os.Exit(code) + } else if err == nil { + os.Exit(code) + } + } + cf := CLIConf{ Context: ctx, TracingProvider: tracing.NoopProvider(), @@ -1237,8 +1270,6 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error { bench.Hidden() } - var err error - cf.executablePath, err = os.Executable() if err != nil { return trace.Wrap(err) @@ -1864,6 +1895,14 @@ func onLogin(cf *CLIConf) error { } tc.HomePath = cf.HomePath + // The user is not logged in and has typed in `tsh --proxy=... login`, if + // the running binary needs to be updated, update and re-exec. + if profile == nil { + if err := updateAndRun(cf.Context, tc.WebProxyAddr, tc.InsecureSkipVerify); err != nil { + return trace.Wrap(err) + } + } + // client is already logged in and profile is not expired if profile != nil && !profile.IsExpired(time.Now()) { switch { @@ -1874,6 +1913,13 @@ func onLogin(cf *CLIConf) error { // current status case cf.Proxy == "" && cf.SiteName == "" && cf.DesiredRoles == "" && cf.RequestID == "" && cf.IdentityFileOut == "" || host(cf.Proxy) == host(profile.ProxyURL.Host) && cf.SiteName == profile.Cluster && cf.DesiredRoles == "" && cf.RequestID == "": + + // The user has typed `tsh login`, if the running binary needs to + // be updated, update and re-exec. + if err := updateAndRun(cf.Context, tc.WebProxyAddr, tc.InsecureSkipVerify); err != nil { + return trace.Wrap(err) + } + _, err := tc.PingAndShowMOTD(cf.Context) if err != nil { return trace.Wrap(err) @@ -1887,6 +1933,13 @@ func onLogin(cf *CLIConf) error { // if the proxy names match but nothing else is specified; show motd and update active profile and kube configs case host(cf.Proxy) == host(profile.ProxyURL.Host) && cf.SiteName == "" && cf.DesiredRoles == "" && cf.RequestID == "" && cf.IdentityFileOut == "": + + // The user has typed `tsh login`, if the running binary needs to + // be updated, update and re-exec. + if err := updateAndRun(cf.Context, tc.WebProxyAddr, tc.InsecureSkipVerify); err != nil { + return trace.Wrap(err) + } + _, err := tc.PingAndShowMOTD(cf.Context) if err != nil { return trace.Wrap(err) @@ -1957,7 +2010,11 @@ func onLogin(cf *CLIConf) error { // otherwise just pass through to standard login default: - + // The user is logged in and has typed in `tsh --proxy=... login`, if + // the running binary needs to be updated, update and re-exec. + if err := updateAndRun(cf.Context, tc.WebProxyAddr, tc.InsecureSkipVerify); err != nil { + return trace.Wrap(err) + } } } @@ -5546,6 +5603,43 @@ const ( "https://goteleport.com/docs/access-controls/guides/headless/#troubleshooting" ) +func updateAndRun(ctx context.Context, proxy string, insecure bool) error { + // The user has typed a command like `tsh ssh ...` without being logged in, + // if the running binary needs to be updated, update and re-exec. + // + // If needed, download the new version of {tsh, tctl} and re-exec. Make + // sure to exit this process with the same exit code as the child process. + // + toolsDir, err := tools.Dir() + if err != nil { + return trace.Wrap(err) + } + updater := tools.NewUpdater(tools.DefaultClientTools(), toolsDir, teleport.Version) + toolsVersion, reExec, err := updater.CheckRemote(ctx, proxy, insecure) + if err != nil { + return trace.Wrap(err) + } + if reExec { + ctxUpdate, cancel := stacksignal.GetSignalHandler().NotifyContext(context.Background()) + defer cancel() + // Download the version of client tools required by the cluster. + err := updater.UpdateWithLock(ctxUpdate, toolsVersion) + if err != nil && !errors.Is(err, context.Canceled) { + return trace.Wrap(err) + } + // Re-execute client tools with the correct version of client tools. + code, err := updater.Exec() + if err != nil && !errors.Is(err, os.ErrNotExist) { + log.Debugf("Failed to re-exec client tool: %v.", err) + os.Exit(code) + } else if err == nil { + os.Exit(code) + } + } + + return nil +} + // Lock the process memory to prevent rsa keys and certificates in memory from being exposed in a swap. func tryLockMemory(cf *CLIConf) error { if cf.MlockMode == mlockModeAuto {