From 75ed24e589de062a51967553eb3a9e31ce934546 Mon Sep 17 00:00:00 2001 From: Vadym Popov Date: Thu, 31 Oct 2024 13:09:24 -0400 Subject: [PATCH] Client auto updates integration for {tctl,tsh} (#47815) * Client auto updates integration for tctl/tsh * Add version validation Fix recursive version check for darwin platform Fix cleanup for multi-package support * Fix identifying tools removal from home directory * Replace ToolsMode with ToolsAutoUpdate * Reuse insecure flag for tests * Fix CheckRemote with login * Fix windows administrative access requirement Update must be able to be canceled, re-execute with latest version or last updated Show progress bar before request is made * Fix update cancellation for login action Address review comments * Add signal handler with stack context cancellation * Use copy instead of hard link for windows Fix progress bar if we can't receive size of package * Replace with list in order to support manual cancel * Download archive package to temp directory * Decrease timeout for client tools proxy call --- api/client/webclient/webclient.go | 4 +- integration/autoupdate/tools/main_test.go | 17 +-- integration/autoupdate/tools/updater/main.go | 21 +--- integration/autoupdate/tools/updater_test.go | 17 +-- .../{archive.go => archive/packaging.go} | 2 +- integrations/terraform/go.mod | 1 + integrations/terraform/go.sum | 1 - lib/autoupdate/tools/progress.go | 51 ++++++-- lib/autoupdate/tools/updater.go | 118 ++++++++++-------- lib/autoupdate/tools/utils.go | 19 ++- lib/client/api.go | 35 ++++++ lib/utils/packaging/unarchive.go | 16 ++- lib/utils/packaging/unarchive_test.go | 10 +- lib/utils/packaging/unarchive_unix.go | 5 +- lib/utils/signal/stack_handler.go | 98 +++++++++++++++ lib/utils/signal/stack_handler_test.go | 88 +++++++++++++ lib/web/apiserver.go | 94 +++++++++++--- lib/web/apiserver_ping_test.go | 51 ++++++-- tool/tctl/common/tctl.go | 41 +++++- tool/tctl/main.go | 8 +- tool/tsh/common/tsh.go | 104 ++++++++++++++- 21 files changed, 650 insertions(+), 151 deletions(-) rename integration/helpers/{archive.go => archive/packaging.go} (99%) create mode 100644 lib/utils/signal/stack_handler.go create mode 100644 lib/utils/signal/stack_handler_test.go diff --git a/api/client/webclient/webclient.go b/api/client/webclient/webclient.go index ba959b20a772f..2942330c35456 100644 --- a/api/client/webclient/webclient.go +++ b/api/client/webclient/webclient.go @@ -335,8 +335,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"` } // KubeProxySettings is kubernetes proxy settings 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 96d5486462067..20660f72f06e2 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 4e45558f25c33..d7354dbea74ff 100644 --- a/integrations/terraform/go.mod +++ b/integrations/terraform/go.mod @@ -167,6 +167,7 @@ require ( github.com/google/go-tpm-tools v0.4.4 // indirect github.com/google/go-tspi v0.3.0 // indirect github.com/google/gofuzz v1.2.0 // indirect + github.com/google/renameio/v2 v2.0.0 // indirect github.com/google/s2a-go v0.1.7 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect diff --git a/integrations/terraform/go.sum b/integrations/terraform/go.sum index f55371fc300f6..5043f2d8f7d63 100644 --- a/integrations/terraform/go.sum +++ b/integrations/terraform/go.sum @@ -612,7 +612,6 @@ github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -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 4337bcd241632..9eae61ca71277 100644 --- a/lib/client/api.go +++ b/lib/client/api.go @@ -73,6 +73,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/terminal" "github.com/gravitational/teleport/lib/defaults" @@ -92,6 +93,7 @@ import ( "github.com/gravitational/teleport/lib/utils" "github.com/gravitational/teleport/lib/utils/agentconn" "github.com/gravitational/teleport/lib/utils/proxy" + "github.com/gravitational/teleport/lib/utils/signal" ) const ( @@ -668,6 +670,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) + } + } + return fn() } 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 dca0512e948c1..efb98f79a6d89 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -49,22 +49,26 @@ import ( "github.com/gravitational/trace" "github.com/jonboulle/clockwork" "github.com/julienschmidt/httprouter" - "github.com/sashabaranov/go-openai" "github.com/sirupsen/logrus" "go.opentelemetry.io/otel/exporters/otlp/otlptrace" oteltrace "go.opentelemetry.io/otel/trace" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" "golang.org/x/crypto/ssh" "golang.org/x/mod/semver" - "golang.org/x/time/rate" "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/timestamppb" "github.com/gravitational/teleport" + "github.com/gravitational/teleport/api" + "github.com/gravitational/teleport/api/types/autoupdate" apiclient "github.com/gravitational/teleport/api/client" + "github.com/sashabaranov/go-openai" + "golang.org/x/time/rate" "github.com/gravitational/teleport/api/client/proto" "github.com/gravitational/teleport/api/client/webclient" "github.com/gravitational/teleport/api/constants" apidefaults "github.com/gravitational/teleport/api/defaults" + autoupdatepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/autoupdate/v1" "github.com/gravitational/teleport/api/mfa" apitracing "github.com/gravitational/teleport/api/observability/tracing" "github.com/gravitational/teleport/api/types" @@ -130,6 +134,10 @@ const ( // DefaultFeatureWatchInterval is the default time in which the feature watcher // should ping the auth server to check for updated features DefaultFeatureWatchInterval = time.Minute * 5 + // findEndpointCacheTTL is the cache TTL for the find endpoint generic answer. + // This cache is here to protect against accidental or intentional DDoS, the TTL must be low to quickly reflect + // cluster configuration changes. + findEndpointCacheTTL = 10 * time.Second ) // healthCheckAppServerFunc defines a function used to perform a health check @@ -179,6 +187,11 @@ type Handler struct { // an authenticated websocket so unauthenticated sockets dont get left // open. wsIODeadline time.Duration + + // findEndpointCache is used to cache the find endpoint answer. As this endpoint is unprotected and has high + // rate-limits, each call must cause minimal work. The cached answer can be modulated after, for example if the + // caller specified its Automatic Updates UUID or group. + findEndpointCache *utils.FnCache } // HandlerOption is a functional argument - an option that can be passed @@ -429,6 +442,18 @@ func NewHandler(cfg Config, opts ...HandlerOption) (*APIHandler, error) { } } + // We create the cache after applying the options to make sure we use the fake clock if it was passed. + findCache, err := utils.NewFnCache(utils.FnCacheConfig{ + TTL: findEndpointCacheTTL, + Clock: h.clock, + Context: cfg.Context, + ReloadOnErr: false, + }) + if err != nil { + return nil, trace.Wrap(err, "creating /find cache") + } + h.findEndpointCache = findCache + sessionLingeringThreshold := cachedSessionLingeringThreshold if cfg.CachedSessionLingeringThreshold != nil { sessionLingeringThreshold = *cfg.CachedSessionLingeringThreshold @@ -1515,39 +1540,50 @@ func (h *Handler) ping(w http.ResponseWriter, r *http.Request, p httprouter.Para MinClientVersion: teleport.MinClientVersion, ClusterName: h.auth.clusterName, AutomaticUpgrades: pr.ServerFeatures.GetAutomaticUpgrades(), + AutoUpdate: h.automaticUpdateSettings(r.Context()), }, nil } func (h *Handler) find(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { - // TODO(jent,espadolini): add a time-based cache to further reduce load on this endpoint - proxyConfig, err := h.cfg.ProxySettings.GetProxySettings(r.Context()) + // cache the generic answer to avoid doing work for each request + resp, err := utils.FnCacheGet[*webclient.PingResponse](r.Context(), h.findEndpointCache, "find", func(ctx context.Context) (*webclient.PingResponse, error) { + proxyConfig, err := h.cfg.ProxySettings.GetProxySettings(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + return &webclient.PingResponse{ + Proxy: *proxyConfig, + ServerVersion: teleport.Version, + MinClientVersion: teleport.MinClientVersion, + ClusterName: h.auth.clusterName, + AutoUpdate: h.automaticUpdateSettings(ctx), + }, nil + }) if err != nil { return nil, trace.Wrap(err) } - response := webclient.PingResponse{ - Proxy: *proxyConfig, - ServerVersion: teleport.Version, - MinClientVersion: teleport.MinClientVersion, - ClusterName: h.auth.clusterName, - } + return resp, nil +} - autoUpdateConfig, err := h.cfg.AccessPoint.GetAutoUpdateConfig(r.Context()) +// TODO: add the request as a parameter when we'll need to modulate the content based on the UUID and group +func (h *Handler) automaticUpdateSettings(ctx context.Context) webclient.AutoUpdateSettings { + autoUpdateConfig, err := h.cfg.AccessPoint.GetAutoUpdateConfig(ctx) // TODO(vapopov) DELETE IN v18.0.0 check of IsNotImplemented, must be backported to all latest supported versions. if err != nil && !trace.IsNotFound(err) && !trace.IsNotImplemented(err) { - h.logger.ErrorContext(r.Context(), "failed to receive AutoUpdateConfig", "error", err) - } else if err == nil { - response.AutoUpdate.ToolsMode = autoUpdateConfig.GetSpec().GetTools().GetMode() + h.logger.ErrorContext(ctx, "failed to receive AutoUpdateConfig", "error", err) } - autoUpdateVersion, err := h.cfg.AccessPoint.GetAutoUpdateVersion(r.Context()) + autoUpdateVersion, err := h.cfg.AccessPoint.GetAutoUpdateVersion(ctx) // TODO(vapopov) DELETE IN v18.0.0 check of IsNotImplemented, must be backported to all latest supported versions. if err != nil && !trace.IsNotFound(err) && !trace.IsNotImplemented(err) { - h.logger.ErrorContext(r.Context(), "failed to receive AutoUpdateVersion", "error", err) - } else if err == nil { - response.AutoUpdate.ToolsVersion = autoUpdateVersion.GetSpec().GetTools().GetTargetVersion() + h.logger.ErrorContext(ctx, "failed to receive AutoUpdateVersion", "error", err) } - return response, nil + return webclient.AutoUpdateSettings{ + ToolsAutoUpdate: getToolsAutoUpdate(autoUpdateConfig), + ToolsVersion: getToolsVersion(autoUpdateVersion), + } } func (h *Handler) pingWithConnector(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { @@ -4814,3 +4850,23 @@ func readEtagFromAppHash(fs http.FileSystem) (string, error) { return etag, nil } + +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 config.GetSpec().GetTools().GetMode() == autoupdate.ToolsUpdateModeEnabled + } + return false +} + +func getToolsVersion(version *autoupdatepb.AutoUpdateVersion) string { + // If we can't get the AU version or tools AU version is not specified, we default to the current proxy version. + // This ensures we always advertise a version compatible with the cluster. + if version.GetSpec().GetTools() == nil { + return api.Version + } + return version.GetSpec().GetTools().GetTargetVersion() +} diff --git a/lib/web/apiserver_ping_test.go b/lib/web/apiserver_ping_test.go index 202355f517134..e6a01c365788e 100644 --- a/lib/web/apiserver_ping_test.go +++ b/lib/web/apiserver_ping_test.go @@ -30,6 +30,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/gravitational/teleport/api" "github.com/gravitational/teleport/api/client/webclient" "github.com/gravitational/teleport/api/constants" autoupdatev1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/autoupdate/v1" @@ -263,8 +264,11 @@ func TestPing_autoUpdateResources(t *testing.T) { expected webclient.AutoUpdateSettings }{ { - name: "resources not defined", - expected: webclient.AutoUpdateSettings{}, + name: "resources not defined", + expected: webclient.AutoUpdateSettings{ + ToolsVersion: api.Version, + ToolsAutoUpdate: false, + }, }, { name: "enable auto update", @@ -273,21 +277,37 @@ func TestPing_autoUpdateResources(t *testing.T) { Mode: autoupdate.ToolsUpdateModeEnabled, }, }, - expected: webclient.AutoUpdateSettings{ToolsMode: "enabled"}, - cleanup: true, + expected: webclient.AutoUpdateSettings{ + ToolsAutoUpdate: true, + ToolsVersion: api.Version, + }, + cleanup: true, + }, + { + name: "empty config and version", + config: &autoupdatev1pb.AutoUpdateConfigSpec{}, + version: &autoupdatev1pb.AutoUpdateVersionSpec{}, + expected: webclient.AutoUpdateSettings{ + ToolsVersion: api.Version, + ToolsAutoUpdate: false, + }, + cleanup: true, }, { - name: "set auto update version", + name: "set tools auto update version", version: &autoupdatev1pb.AutoUpdateVersionSpec{ Tools: &autoupdatev1pb.AutoUpdateVersionSpecTools{ TargetVersion: "1.2.3", }, }, - expected: webclient.AutoUpdateSettings{ToolsVersion: "1.2.3"}, - cleanup: true, + expected: webclient.AutoUpdateSettings{ + ToolsVersion: "1.2.3", + ToolsAutoUpdate: false, + }, + cleanup: true, }, { - name: "enable auto update and set version", + name: "enable tools auto update and set version", config: &autoupdatev1pb.AutoUpdateConfigSpec{ Tools: &autoupdatev1pb.AutoUpdateConfigSpecTools{ Mode: autoupdate.ToolsUpdateModeEnabled, @@ -298,7 +318,10 @@ func TestPing_autoUpdateResources(t *testing.T) { TargetVersion: "1.2.3", }, }, - expected: webclient.AutoUpdateSettings{ToolsMode: "enabled", ToolsVersion: "1.2.3"}, + expected: webclient.AutoUpdateSettings{ + ToolsAutoUpdate: true, + ToolsVersion: "1.2.3", + }, }, { name: "modify auto update config and version", @@ -312,7 +335,10 @@ func TestPing_autoUpdateResources(t *testing.T) { TargetVersion: "3.2.1", }, }, - expected: webclient.AutoUpdateSettings{ToolsMode: "disabled", ToolsVersion: "3.2.1"}, + expected: webclient.AutoUpdateSettings{ + ToolsAutoUpdate: false, + ToolsVersion: "3.2.1", + }, }, } for _, tc := range tests { @@ -330,6 +356,11 @@ func TestPing_autoUpdateResources(t *testing.T) { require.NoError(t, err) } + // expire the fn cache to force the next answer to be fresh + for _, proxy := range env.proxies { + proxy.clock.Advance(2 * findEndpointCacheTTL) + } + resp, err := client.NewInsecureWebClient().Do(req) require.NoError(t, err) diff --git a/tool/tctl/common/tctl.go b/tool/tctl/common/tctl.go index 660ef0cc4b6fb..b933995b14f29 100644 --- a/tool/tctl/common/tctl.go +++ b/tool/tctl/common/tctl.go @@ -43,6 +43,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/service/servicecfg" "github.com/gravitational/teleport/lib/utils" "github.com/gravitational/teleport/lib/utils/hostid" + "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 1eecb99209fbc..21628bd8c89e4 100644 --- a/tool/tctl/main.go +++ b/tool/tctl/main.go @@ -19,12 +19,18 @@ package main import ( + "context" + + "github.com/gravitational/teleport/lib/utils/signal" "github.com/gravitational/teleport/tool/tctl/common" ) func main() { + ctx, cancel := signal.GetSignalHandler().NotifyContext(context.Background()) + defer cancel() + // aggregate common and oss-specific command variants commands := common.Commands() commands = append(commands, common.OSSCommands()...) - common.Run(commands) + common.Run(ctx, commands) } diff --git a/tool/tsh/common/tsh.go b/tool/tsh/common/tsh.go index cbe2a3ef6ea20..f864351d8bdff 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" @@ -605,7 +606,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 @@ -703,6 +704,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(), @@ -1227,8 +1260,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) @@ -1848,6 +1879,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(clockwork.NewRealClock()) { switch { @@ -1858,6 +1897,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) @@ -1871,6 +1917,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) @@ -1941,7 +1994,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) + } } } @@ -5470,6 +5527,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 {