diff --git a/integration/autoupdate/tools/updater_test.go b/integration/autoupdate/tools/updater_test.go
index 06ecdf581ad..0ef2eabb4aa 100644
--- a/integration/autoupdate/tools/updater_test.go
+++ b/integration/autoupdate/tools/updater_test.go
@@ -24,7 +24,6 @@ import (
"fmt"
"os"
"os/exec"
- "path/filepath"
"regexp"
"strings"
"testing"
@@ -60,8 +59,13 @@ func TestUpdate(t *testing.T) {
err := updater.Update(ctx, testVersions[0])
require.NoError(t, err)
+ tshPath, err := updater.ToolPath("tsh", testVersions[0])
+ require.NoError(t, err)
+ tctlPath, err := updater.ToolPath("tctl", testVersions[0])
+ require.NoError(t, err)
+
// Verify that the installed version is equal to requested one.
- cmd := exec.CommandContext(ctx, filepath.Join(toolsDir, "tctl"), "version")
+ cmd := exec.CommandContext(ctx, tctlPath, "version")
out, err := cmd.Output()
require.NoError(t, err)
@@ -71,7 +75,7 @@ func TestUpdate(t *testing.T) {
// Execute version command again with setting the new version which must
// trigger re-execution of the same command after downloading requested version.
- cmd = exec.CommandContext(ctx, filepath.Join(toolsDir, "tsh"), "version")
+ cmd = exec.CommandContext(ctx, tshPath, "version")
cmd.Env = append(
os.Environ(),
fmt.Sprintf("%s=%s", teleportToolsVersion, testVersions[1]),
@@ -101,6 +105,9 @@ func TestParallelUpdate(t *testing.T) {
err := updater.Update(ctx, testVersions[0])
require.NoError(t, err)
+ tshPath, err := updater.ToolPath("tsh", testVersions[0])
+ require.NoError(t, err)
+
// By setting the limit request next test http serving file going blocked until unlock is sent.
lock := make(chan struct{})
limitedWriter.SetLimitRequest(limitRequest{
@@ -111,7 +118,7 @@ func TestParallelUpdate(t *testing.T) {
outputs := make([]bytes.Buffer, 3)
errChan := make(chan error, 3)
for i := range outputs {
- cmd := exec.Command(filepath.Join(toolsDir, "tsh"), "version")
+ cmd := exec.Command(tshPath, "version")
cmd.Stdout = &outputs[i]
cmd.Stderr = &outputs[i]
cmd.Env = append(
@@ -173,9 +180,11 @@ func TestUpdateInterruptSignal(t *testing.T) {
)
err := updater.Update(ctx, testVersions[0])
require.NoError(t, err)
+ tshPath, err := updater.ToolPath("tsh", testVersions[0])
+ require.NoError(t, err)
var output bytes.Buffer
- cmd := exec.Command(filepath.Join(toolsDir, "tsh"), "version")
+ cmd := exec.Command(tshPath, "version")
cmd.Stdout = &output
cmd.Stderr = &output
cmd.Env = append(
@@ -237,9 +246,11 @@ func TestUpdateForOSSBuild(t *testing.T) {
)
err := updater.Update(ctx, testVersions[0])
require.NoError(t, err)
+ tshPath, err := updater.ToolPath("tsh", testVersions[0])
+ require.NoError(t, err)
// Verify that requested update is ignored by OSS build and version wasn't updated.
- cmd := exec.CommandContext(ctx, filepath.Join(toolsDir, "tsh"), "version")
+ cmd := exec.CommandContext(ctx, tshPath, "version")
cmd.Env = append(
os.Environ(),
fmt.Sprintf("%s=%s", teleportToolsVersion, testVersions[1]),
@@ -253,7 +264,7 @@ func TestUpdateForOSSBuild(t *testing.T) {
// Next update is set with the base URL env variable, must download new version.
t.Setenv(autoupdate.BaseURLEnvVar, baseURL)
- cmd = exec.CommandContext(ctx, filepath.Join(toolsDir, "tsh"), "version")
+ cmd = exec.CommandContext(ctx, tshPath, "version")
cmd.Env = append(
os.Environ(),
fmt.Sprintf("%s=%s", teleportToolsVersion, testVersions[1]),
diff --git a/integration/autoupdate/tools/updater_tsh_test.go b/integration/autoupdate/tools/updater_tsh_test.go
index 17816e34e44..6e56cf168dc 100644
--- a/integration/autoupdate/tools/updater_tsh_test.go
+++ b/integration/autoupdate/tools/updater_tsh_test.go
@@ -36,8 +36,10 @@ import (
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/api/types/autoupdate"
"github.com/gravitational/teleport/integration/autoupdate/tools/updater"
+ "github.com/gravitational/teleport/lib/auth"
"github.com/gravitational/teleport/lib/autoupdate/tools"
"github.com/gravitational/teleport/lib/client"
+ "github.com/gravitational/teleport/lib/service"
"github.com/gravitational/teleport/lib/utils"
testserver "github.com/gravitational/teleport/tool/teleport/testenv"
)
@@ -45,9 +47,233 @@ import (
// TestAliasLoginWithUpdater runs test cluster with enabled auto updates for client tools,
// checks that defined alias in tsh configuration is replaced to the proper login command
// and after auto update this not leads to recursive alias re-execution.
+//
+// # Managed updates: enabled.
+// $ tsh loginByAlias
+// $ tctl status
+// $ tsh version
+// Teleport v3.2.1
func TestAliasLoginWithUpdater(t *testing.T) {
ctx := context.Background()
+ rootServer, homeDir, installDir := bootstrapTestServer(t)
+ setupManagedUpdates(t, rootServer.GetAuthServer(), autoupdate.ToolsUpdateModeEnabled, testVersions[1])
+
+ // Assign alias to the login command for test cluster.
+ proxyAddr, err := rootServer.ProxyWebAddr()
+ require.NoError(t, err)
+
+ // Fetch compiled test binary and install to tools dir [v1.2.3].
+ updater := tools.NewUpdater(installDir, testVersions[0], tools.WithBaseURL(baseURL))
+ require.NoError(t, updater.Update(ctx, testVersions[0]))
+ tshPath, err := updater.ToolPath("tsh", testVersions[0])
+ require.NoError(t, err)
+ tctlPath, err := updater.ToolPath("tctl", testVersions[0])
+ require.NoError(t, err)
+
+ configPath := filepath.Join(homeDir, client.TSHConfigPath)
+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0700))
+ out, err := yaml.Marshal(client.TSHConfig{
+ Aliases: map[string]string{
+ "loginalice": fmt.Sprintf(
+ "%s login --insecure --proxy %s --user alice --auth %s",
+ tshPath, proxyAddr, constants.LocalConnector,
+ ),
+ },
+ })
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(configPath, out, 0600))
+
+ // Execute alias command which must be transformed to the login command.
+ // Since client tools autoupdates is enabled and target version is set
+ // in the test cluster, we have to update client tools to new version.
+ cmd := exec.CommandContext(ctx, tshPath, "loginalice")
+ cmd.Env = os.Environ()
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ require.NoError(t, cmd.Run())
+
+ // Verify tctl status after login.
+ cmd = exec.CommandContext(ctx, tctlPath, "status", "--insecure")
+ cmd.Env = os.Environ()
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ require.NoError(t, cmd.Run())
+
+ // Run version command to verify that login command executed auto update and
+ // tsh was upgraded to [v3.2.1].
+ cmd = exec.CommandContext(ctx, tshPath, "version")
+ out, err = cmd.Output()
+ require.NoError(t, err)
+ matches := pattern.FindStringSubmatch(string(out))
+ require.Len(t, matches, 2)
+ require.Equal(t, testVersions[1], matches[1])
+
+ // Verifies that version commands shows version re-executed from.
+ require.Contains(t, string(out), fmt.Sprintf("Re-executed from version: %s", testVersions[0]))
+}
+
+// TestLoginWithUpdaterAndProfile runs test cluster with disabled managed updates for client tools,
+// verifies that if we set env variable during login we keep using updated version.
+//
+// # Managed updates: disabled.
+// $ TELEPORT_TOOLS_VERSION=3.2.1 tsh login --proxy proxy.example.com
+// # Check that created profile after login has enabled autoupdates flag.
+// $ tsh version
+// Teleport v3.2.1
+func TestLoginWithUpdaterAndProfile(t *testing.T) {
+ ctx := context.Background()
+
+ rootServer, _, installDir := bootstrapTestServer(t)
+ setupManagedUpdates(t, rootServer.GetAuthServer(), autoupdate.ToolsUpdateModeDisabled, testVersions[1])
+
+ proxyAddr, err := rootServer.ProxyWebAddr()
+ require.NoError(t, err)
+
+ // Fetch compiled test binary and install to tools dir [v1.2.3].
+ updater := tools.NewUpdater(installDir, testVersions[0], tools.WithBaseURL(baseURL))
+ require.NoError(t, updater.Update(ctx, testVersions[0]))
+ tshPath, err := updater.ToolPath("tsh", testVersions[0])
+ require.NoError(t, err)
+
+ // First login with set version during login process
+ t.Setenv("TELEPORT_TOOLS_VERSION", testVersions[1])
+ cmd := exec.CommandContext(ctx, tshPath,
+ "login", "--proxy", proxyAddr.String(), "--insecure", "--user", "alice", "--auth", constants.LocalConnector)
+ cmd.Env = os.Environ()
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ require.NoError(t, cmd.Run())
+ // Unset the version after update process.
+ require.NoError(t, os.Unsetenv("TELEPORT_TOOLS_VERSION"))
+
+ // Run version command to verify that login command executed auto update and
+ // tsh was upgraded to [v3.2.1].
+ cmd = exec.CommandContext(ctx, tshPath, "version")
+ out, err := cmd.Output()
+ require.NoError(t, err)
+ matches := pattern.FindStringSubmatch(string(out))
+ require.Len(t, matches, 2)
+ require.Equal(t, testVersions[1], matches[1])
+}
+
+// TestLoginWithDisabledUpdateInProfile runs test cluster with enabled managed updates for client tools,
+// verifies that after first update and disabling.
+//
+// # Managed updates: disabled.
+// $ TELEPORT_TOOLS_VERSION=3.2.1 tsh version
+// Teleport v3.2.1
+// $ tsh login --proxy proxy.example.com
+// $ tsh version
+// Teleport v1.2.3
+func TestLoginWithDisabledUpdateInProfile(t *testing.T) {
+ ctx := context.Background()
+
+ rootServer, _, installDir := bootstrapTestServer(t)
+ setupManagedUpdates(t, rootServer.GetAuthServer(), autoupdate.ToolsUpdateModeDisabled, testVersions[1])
+
+ proxyAddr, err := rootServer.ProxyWebAddr()
+ require.NoError(t, err)
+
+ // Fetch compiled test binary and install to tools dir [v1.2.3].
+ updater := tools.NewUpdater(installDir, testVersions[0], tools.WithBaseURL(baseURL))
+ require.NoError(t, updater.Update(ctx, testVersions[0]))
+ tshPath, err := updater.ToolPath("tsh", testVersions[0])
+ require.NoError(t, err)
+
+ // Set env variable to forcibly request update on version command.
+ t.Setenv("TELEPORT_TOOLS_VERSION", testVersions[1])
+ cmd := exec.CommandContext(ctx, tshPath, "version")
+ cmd.Env = os.Environ()
+ out, err := cmd.Output()
+ require.NoError(t, err)
+ // Check the version.
+ matches := pattern.FindStringSubmatch(string(out))
+ require.Len(t, matches, 2)
+ require.Equal(t, testVersions[1], matches[1])
+ // Unset the version after update process.
+ require.NoError(t, os.Unsetenv("TELEPORT_TOOLS_VERSION"))
+
+ // Second login has to update profile and disable further managed updates.
+ cmd = exec.CommandContext(ctx, tshPath,
+ "login", "--proxy", proxyAddr.String(), "--insecure", "--user", "alice", "--auth", constants.LocalConnector)
+ cmd.Env = os.Environ()
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ require.NoError(t, cmd.Run())
+
+ // Run version command to verify that login command executed auto update and
+ // tsh was upgraded to [v3.2.1].
+ cmd = exec.CommandContext(ctx, tshPath, "version")
+ out, err = cmd.Output()
+ require.NoError(t, err)
+ // Check the version.
+ matches = pattern.FindStringSubmatch(string(out))
+ require.Len(t, matches, 2)
+ fmt.Println(string(out))
+ require.Equal(t, testVersions[0], matches[1])
+}
+
+// TestLoginWithDisabledUpdateForcedByEnv verifies that on disabled cluster we are still
+// able to update client tools by always setting the environment variable.
+//
+// # Managed updates: disabled.
+// $ tsh login --proxy proxy.example.com
+// $ TELEPORT_TOOLS_VERSION=3.2.1 tsh version
+// Teleport v3.2.1
+// $ tsh version
+// Teleport v1.2.3
+func TestLoginWithDisabledUpdateForcedByEnv(t *testing.T) {
+ ctx := context.Background()
+
+ rootServer, _, installDir := bootstrapTestServer(t)
+ setupManagedUpdates(t, rootServer.GetAuthServer(), autoupdate.ToolsUpdateModeDisabled, testVersions[1])
+
+ proxyAddr, err := rootServer.ProxyWebAddr()
+ require.NoError(t, err)
+
+ // Fetch compiled test binary and install to tools dir [v1.2.3].
+ updater := tools.NewUpdater(installDir, testVersions[0], tools.WithBaseURL(baseURL))
+ require.NoError(t, updater.Update(ctx, testVersions[0]))
+ tshPath, err := updater.ToolPath("tsh", testVersions[0])
+ require.NoError(t, err)
+
+ // Second login has to update profile and disable further managed updates.
+ cmd := exec.CommandContext(ctx, tshPath,
+ "login", "--proxy", proxyAddr.String(), "--insecure", "--user", "alice", "--auth", constants.LocalConnector)
+ cmd.Env = os.Environ()
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ require.NoError(t, cmd.Run())
+
+ // Trying to forcibly use specific version not during login.
+ t.Setenv("TELEPORT_TOOLS_VERSION", testVersions[1])
+ cmd = exec.CommandContext(ctx, tshPath, "version")
+ cmd.Env = os.Environ()
+ out, err := cmd.Output()
+ require.NoError(t, err)
+ // Check that version is used that requested from env variable.
+ matches := pattern.FindStringSubmatch(string(out))
+ require.Len(t, matches, 2)
+ fmt.Println(string(out))
+ require.Equal(t, testVersions[1], matches[1])
+ // Unset the version after update process.
+ require.NoError(t, os.Unsetenv("TELEPORT_TOOLS_VERSION"))
+
+ // Run version command to verify that login command executed auto update and
+ // tsh is version [v1.2.3] since it was requested not during login and cluster
+ // has disabled managed updates.
+ cmd = exec.CommandContext(ctx, tshPath, "version")
+ out, err = cmd.Output()
+ require.NoError(t, err)
+ matches = pattern.FindStringSubmatch(string(out))
+ require.Len(t, matches, 2)
+ fmt.Println(string(out))
+ require.Equal(t, testVersions[0], matches[1])
+}
+
+func bootstrapTestServer(t *testing.T) (*service.TeleportProcess, string, string) {
+ t.Helper()
homeDir := filepath.Join(t.TempDir(), "home")
require.NoError(t, os.MkdirAll(homeDir, 0700))
installDir := filepath.Join(t.TempDir(), "local")
@@ -59,20 +285,6 @@ func TestAliasLoginWithUpdater(t *testing.T) {
require.NoError(t, err)
alice.SetRoles([]string{"access"})
- // Enable client tools auto updates and set the target version.
- config, err := autoupdate.NewAutoUpdateConfig(&autoupdatev1pb.AutoUpdateConfigSpec{
- Tools: &autoupdatev1pb.AutoUpdateConfigSpecTools{
- Mode: autoupdate.ToolsUpdateModeEnabled,
- },
- })
- require.NoError(t, err)
- version, err := autoupdate.NewAutoUpdateVersion(&autoupdatev1pb.AutoUpdateVersionSpec{
- Tools: &autoupdatev1pb.AutoUpdateVersionSpecTools{
- TargetVersion: testVersions[1], // [v3.2.1]
- },
- })
- require.NoError(t, err)
-
// Disable 2fa to simplify login for test.
ap, err := types.NewAuthPreferenceFromConfigFile(types.AuthPreferenceSpecV2{
Type: constants.Local,
@@ -89,63 +301,34 @@ func TestAliasLoginWithUpdater(t *testing.T) {
testserver.WithAuthPreference(ap),
)
authService := rootServer.GetAuthServer()
- _, err = authService.UpsertAutoUpdateConfig(ctx, config)
- require.NoError(t, err)
- _, err = authService.UpsertAutoUpdateVersion(ctx, version)
- require.NoError(t, err)
+
+ // Set password for the cluster login.
password, err := utils.CryptoRandomHex(6)
require.NoError(t, err)
t.Setenv(updater.TestPassword, password)
err = authService.UpsertPassword("alice", []byte(password))
require.NoError(t, err)
- // Assign alias to the login command for test cluster.
- proxyAddr, err := rootServer.ProxyWebAddr()
- require.NoError(t, err)
- configPath := filepath.Join(homeDir, client.TSHConfigPath)
- require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0700))
- executable := filepath.Join(installDir, "tsh")
- out, err := yaml.Marshal(client.TSHConfig{
- Aliases: map[string]string{
- "loginalice": fmt.Sprintf(
- "%s login --insecure --proxy %s --user alice --auth %s",
- executable, proxyAddr, constants.LocalConnector,
- ),
+ return rootServer, homeDir, installDir
+}
+
+func setupManagedUpdates(t *testing.T, server *auth.Server, muMode string, muVersion string) {
+ t.Helper()
+ ctx := context.Background()
+ config, err := autoupdate.NewAutoUpdateConfig(&autoupdatev1pb.AutoUpdateConfigSpec{
+ Tools: &autoupdatev1pb.AutoUpdateConfigSpecTools{
+ Mode: muMode,
},
})
require.NoError(t, err)
- require.NoError(t, os.WriteFile(configPath, out, 0600))
-
- // Fetch compiled test binary and install to tools dir [v1.2.3].
- err = tools.NewUpdater(installDir, testVersions[0], tools.WithBaseURL(baseURL)).Update(ctx, testVersions[0])
+ version, err := autoupdate.NewAutoUpdateVersion(&autoupdatev1pb.AutoUpdateVersionSpec{
+ Tools: &autoupdatev1pb.AutoUpdateVersionSpecTools{
+ TargetVersion: muVersion,
+ },
+ })
require.NoError(t, err)
-
- // Execute alias command which must be transformed to the login command.
- // Since client tools autoupdates is enabled and target version is set
- // in the test cluster, we have to update client tools to new version.
- cmd := exec.CommandContext(ctx, executable, "loginalice")
- cmd.Env = os.Environ()
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
- require.NoError(t, cmd.Run())
-
- // Verify tctl status after login.
- cmd = exec.CommandContext(ctx, filepath.Join(installDir, "tctl"), "status", "--insecure")
- cmd.Env = os.Environ()
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
- require.NoError(t, cmd.Run())
-
- // Run version command to verify that login command executed auto update and
- // tsh was upgraded to [v3.2.1].
- cmd = exec.CommandContext(ctx, executable, "version")
- out, err = cmd.Output()
+ _, err = server.UpsertAutoUpdateConfig(ctx, config)
+ require.NoError(t, err)
+ _, err = server.UpsertAutoUpdateVersion(ctx, version)
require.NoError(t, err)
-
- matches := pattern.FindStringSubmatch(string(out))
- require.Len(t, matches, 2)
- require.Equal(t, testVersions[1], matches[1])
-
- // Verifies that version commands shows version re-executed from.
- require.Contains(t, string(out), fmt.Sprintf("Re-executed from version: %s", testVersions[0]))
}
diff --git a/lib/autoupdate/tools/config.go b/lib/autoupdate/tools/config.go
new file mode 100644
index 00000000000..8bf1deefa65
--- /dev/null
+++ b/lib/autoupdate/tools/config.go
@@ -0,0 +1,209 @@
+/*
+ * Teleport
+ * Copyright (C) 2025 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 tools
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "log/slog"
+ "maps"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/lib/utils"
+)
+
+const (
+ // configFileVersion identifies the version of the configuration file
+ // might be used for future migrations.
+ configFileVersion = "v1"
+ // lockFileName is file used for locking update process in parallel.
+ lockFileName = ".lock"
+ // configFileName is the configuration file used to store versions for known hosts
+ // and the installed versions of client tools.
+ configFileName = ".config.json"
+ configFilePerms = 0o644
+ // defaultSizeStoredVersion defines how many versions will be stored in the tools
+ // directory. Older versions will be cleaned up based on least recently used.
+ defaultSizeStoredVersion = 3
+)
+
+// ClientToolsConfig is configuration structure for client tools managed updates.
+type ClientToolsConfig struct {
+ // Version determines version of configuration file (to support future extensions).
+ Version string `json:"version"`
+ // Configs stores information about profile and cluster version and mode:
+ // `{"profile-name":{"version": "1.2.3", "disabled":false}}`.
+ Configs map[string]*ClusterConfig `json:"configs"`
+ // Tools stores information about tools directories per versions:
+ // `[{"tool_name": "tsh", "path": "tool-path", "version": "tool-version"}]`.
+ Tools []Tool `json:"tools"`
+ // MaxTools defines the maximum number of tools allowed in the tools directory.
+ // Any tools exceeding this limit will be removed during the next installation.
+ MaxTools int `json:"max_tools"`
+}
+
+// AddTool adds a tool to the collection in the configuration, always placing it at the top.
+// The collection size is limited by the `defaultSizeStoredVersion` constant.
+func (ctc *ClientToolsConfig) AddTool(tool Tool) {
+ for _, t := range ctc.Tools {
+ if t.Version == tool.Version {
+ maps.Copy(t.PathMap, tool.PathMap)
+ return
+ }
+ }
+ if ctc.MaxTools <= 0 {
+ ctc.MaxTools = defaultSizeStoredVersion
+ }
+ if len(ctc.Tools) >= ctc.MaxTools {
+ ctc.Tools = append([]Tool{tool}, ctc.Tools[:ctc.MaxTools-1]...)
+ } else {
+ ctc.Tools = append([]Tool{tool}, ctc.Tools...)
+ }
+}
+
+// SetConfig sets the version and mode flag for a specific host.
+func (ctc *ClientToolsConfig) SetConfig(proxy string, version string, disabled bool) {
+ if config, ok := ctc.Configs[proxy]; ok {
+ config.Disabled = disabled
+ config.Version = version
+ } else {
+ ctc.Configs[proxy] = &ClusterConfig{Version: version, Disabled: disabled}
+ }
+}
+
+// SelectVersion lookups the version and re-order by last recently used.
+func (ctc *ClientToolsConfig) SelectVersion(version string) *Tool {
+ for i, tool := range ctc.Tools {
+ if tool.Version == version {
+ ctc.Tools = append([]Tool{tool}, append(ctc.Tools[:i], ctc.Tools[i+1:]...)...)
+ return &tool
+ }
+ }
+ return nil
+}
+
+// HasVersion check that specific version present in collection.
+func (ctc *ClientToolsConfig) HasVersion(version string) bool {
+ return slices.ContainsFunc(ctc.Tools, func(s Tool) bool {
+ return version == s.Version
+ })
+}
+
+// ClusterConfig stores required version and mode for specific cluster.
+type ClusterConfig struct {
+ Version string `json:"version"`
+ Disabled bool `json:"disabled"`
+}
+
+// Tool stores tools path per version, each tool might be stored in different path.
+type Tool struct {
+ // Version is the version of the tools (tsh, tctl) as defined in the PathMap.
+ Version string `json:"version"`
+ // PathMap stores the relative path (within the tools directory) for each tool binary.
+ // For example: {"tctl": "package-id/tctl"}.
+ PathMap map[string]string `json:"path"`
+}
+
+// PackageNames returns the package names extracted from the tool path map.
+func (c *Tool) PackageNames() []string {
+ var packageNames []string
+ for _, path := range c.PathMap {
+ dir := strings.SplitN(path, string(filepath.Separator), 2)
+ if len(dir) > 0 {
+ packageNames = append(packageNames, dir[0])
+ }
+ }
+ return packageNames
+}
+
+// getToolsConfig reads the configuration file for client tools managed updates,
+// and acquires a filesystem lock until the configuration is read and deserialized.
+func getToolsConfig(toolsDir string) (ctc *ClientToolsConfig, err error) {
+ unlock, err := utils.FSWriteLock(filepath.Join(toolsDir, lockFileName))
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ defer func() {
+ err = trace.NewAggregate(err, unlock())
+ }()
+
+ ctc = &ClientToolsConfig{
+ Configs: make(map[string]*ClusterConfig),
+ }
+ data, err := os.ReadFile(filepath.Join(toolsDir, configFileName))
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return nil, trace.Wrap(err)
+ }
+ if data != nil {
+ if err := json.Unmarshal(data, ctc); err != nil {
+ // If the configuration file content is corrupted, tools execution should not fail.
+ // Instead, we should proceed and re-install the required version.
+ slog.WarnContext(context.Background(), "failed to unmarshal config file", "error", err)
+ }
+ }
+
+ return ctc, nil
+}
+
+// updateToolsConfig creates or opens the configuration file for client tools managed updates,
+// and acquires a filesystem lock until the configuration is written and closed.
+func updateToolsConfig(toolsDir string, update func(ctc *ClientToolsConfig) error) (err error) {
+ unlock, err := utils.FSWriteLock(filepath.Join(toolsDir, lockFileName))
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ defer func() {
+ err = trace.NewAggregate(err, unlock())
+ }()
+
+ ctc := &ClientToolsConfig{
+ Version: configFileVersion,
+ Configs: make(map[string]*ClusterConfig),
+ }
+ data, err := os.ReadFile(filepath.Join(toolsDir, configFileName))
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return trace.Wrap(err)
+ }
+ if data != nil {
+ if err := json.Unmarshal(data, ctc); err != nil {
+ // If the configuration file content is corrupted, tools execution should not fail.
+ // Instead, we should proceed and re-install the required version.
+ slog.WarnContext(context.Background(), "failed to unmarshal config file", "error", err)
+ }
+ }
+
+ // Perform update values before configuration file is going to be written.
+ if err := update(ctc); err != nil {
+ return trace.Wrap(err)
+ }
+
+ jsonData, err := json.Marshal(ctc)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ return trace.Wrap(
+ os.WriteFile(filepath.Join(toolsDir, configFileName), jsonData, configFilePerms),
+ )
+}
diff --git a/lib/autoupdate/tools/helper.go b/lib/autoupdate/tools/helper.go
index 499a7c5d377..d93a83cec76 100644
--- a/lib/autoupdate/tools/helper.go
+++ b/lib/autoupdate/tools/helper.go
@@ -23,10 +23,13 @@ import (
"errors"
"log/slog"
"os"
+ "path/filepath"
"github.com/gravitational/trace"
"github.com/gravitational/teleport"
+ "github.com/gravitational/teleport/api/profile"
+ "github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/autoupdate"
stacksignal "github.com/gravitational/teleport/lib/utils/signal"
)
@@ -39,66 +42,131 @@ var (
baseURL = autoupdate.DefaultBaseURL
)
-// CheckAndUpdateLocal verifies if the TELEPORT_TOOLS_VERSION environment variable
-// is set and a version is defined (or disabled by setting it to "off"). The requested
-// version is compared with the current client tools version. If they differ, the version
-// package is downloaded, extracted to the client tools directory, and re-executed
-// with the updated version.
-// If $TELEPORT_HOME/bin contains downloaded client tools, it always re-executes
-// using the version from the home directory.
-func CheckAndUpdateLocal(ctx context.Context, reExecArgs []string) error {
- toolsDir, err := Dir()
- if err != nil {
- slog.WarnContext(ctx, "Client tools update is disabled", "error", err)
- return nil
- }
-
+// newUpdater inits the updater with default base URL and creates directory
+// if it doesn't exist.
+func newUpdater(toolsDir string) (*Updater, error) {
// Overrides default base URL for custom CDN for downloading updates.
if envBaseURL := os.Getenv(autoupdate.BaseURLEnvVar); envBaseURL != "" {
baseURL = envBaseURL
}
- updater := NewUpdater(toolsDir, version, WithBaseURL(baseURL))
- // 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 client tools.
- toolsVersion, reExec, err := updater.CheckLocal()
+ // Create tools directory if it does not exist.
+ if err := os.MkdirAll(toolsDir, 0o755); err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ return NewUpdater(toolsDir, version, WithBaseURL(baseURL)), nil
+}
+
+// CheckAndUpdateLocal verifies if the TELEPORT_TOOLS_VERSION environment variable
+// is set and whether a version is defined (or explicitly disabled by setting it to "off").
+// The environment variable always takes precedence over other version settings.
+//
+// If `currentProfileName` is specified, the function attempts to find the tools version
+// required for the specified cluster in configuration file and re-execute it.
+//
+// The requested version is compared to the currently running client tools version.
+// If they differ, the requested version is downloaded and extracted into the client tools directory,
+// the installation is recorded in the configuration file, and the tool is re-executed with the updated version.
+func CheckAndUpdateLocal(ctx context.Context, currentProfileName string, reExecArgs []string) error {
+ var err error
+ if currentProfileName == "" {
+ home := os.Getenv(types.HomeEnvVar)
+ if home != "" {
+ home = filepath.Clean(home)
+ }
+ profilePath := profile.FullProfilePath(home)
+ currentProfileName, err = profile.GetCurrentProfileName(profilePath)
+ if err != nil && !trace.IsNotFound(err) {
+ return trace.Wrap(err)
+ }
+ }
+
+ toolsDir, err := Dir()
+ if err != nil {
+ slog.WarnContext(ctx, "Client tools update is disabled", "error", err)
+ return nil
+ }
+ updater, err := newUpdater(toolsDir)
if err != nil {
return trace.Wrap(err)
}
- if reExec {
- return trace.Wrap(updateAndReExec(ctx, updater, toolsVersion, reExecArgs))
+
+ slog.DebugContext(ctx, "Attempting to local update", "current_profile_name", currentProfileName)
+ resp, err := updater.CheckLocal(ctx, currentProfileName)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ if resp.ReExec {
+ return trace.Wrap(updateAndReExec(ctx, updater, resp.Version, reExecArgs))
}
return nil
}
-// CheckAndUpdateRemote verifies client tools version is set for update in cluster
-// configuration by making the http request to `webapi/find` endpoint. The requested
-// version is compared with the current client tools version. If they differ, the version
-// package is downloaded, extracted to the client tools directory, and re-executed
+// CheckAndUpdateRemote verifies the client tools version configured for updates in the cluster
+// by making an HTTP request to the `webapi/find` endpoint.
+//
+// If the TELEPORT_TOOLS_VERSION environment variable is set during the remote check,
+// the version specified in the environment variable takes precedence over the version
+// provided by the cluster. This version will also be recorded in the configuration for the cluster.
+//
+// The requested version is compared with the current client tools version.
+// If they differ, the requested version is downloaded, extracted into the client tools directory,
+// the installed version is recorded in the configuration, and the tool is re-executed
// with the updated version.
-// If $TELEPORT_HOME/bin contains downloaded client tools, it always re-executes
-// using the version from the home directory.
-func CheckAndUpdateRemote(ctx context.Context, proxy string, insecure bool, reExecArgs []string) error {
+func CheckAndUpdateRemote(ctx context.Context, currentProfileName string, insecure bool, reExecArgs []string) error {
toolsDir, err := Dir()
if err != nil {
slog.WarnContext(ctx, "Client tools update is disabled", "error", err)
return nil
}
-
- // Overrides default base URL for custom CDN for downloading updates.
- if envBaseURL := os.Getenv(autoupdate.BaseURLEnvVar); envBaseURL != "" {
- baseURL = envBaseURL
- }
-
- updater := NewUpdater(toolsDir, version, WithBaseURL(baseURL))
- toolsVersion, reExec, err := updater.CheckRemote(ctx, proxy, insecure)
+ updater, err := newUpdater(toolsDir)
if err != nil {
return trace.Wrap(err)
}
- if reExec {
- return trace.Wrap(updateAndReExec(ctx, updater, toolsVersion, reExecArgs))
+
+ slog.DebugContext(ctx, "Attempting to remote update", "current_profile_name", currentProfileName, "insecure", insecure)
+ resp, err := updater.CheckRemote(ctx, currentProfileName, insecure)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ if !resp.Disabled && resp.ReExec {
+ return trace.Wrap(updateAndReExec(ctx, updater, resp.Version, reExecArgs))
+ }
+
+ return nil
+}
+
+// DownloadUpdate checks if a client tools version is set for update in the cluster
+// configuration by making an HTTP request to the `webapi/find` endpoint.
+// Downloads the new version if it is not already installed without re-execution.
+func DownloadUpdate(ctx context.Context, name string, insecure bool) error {
+ toolsDir, err := Dir()
+ if err != nil {
+ slog.WarnContext(ctx, "Client tools update is disabled", "error", err)
+ return nil
+ }
+ updater, err := newUpdater(toolsDir)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ slog.DebugContext(ctx, "Attempting to remote update", "name", name, "insecure", insecure)
+ resp, err := updater.CheckRemote(ctx, name, insecure)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ if !resp.Disabled && resp.ReExec {
+ ctxUpdate, cancel := stacksignal.GetSignalHandler().NotifyContext(ctx)
+ defer cancel()
+ err := updater.Update(ctxUpdate, resp.Version)
+ if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, ErrNoBaseURL) {
+ return trace.Wrap(err)
+ }
}
return nil
@@ -110,15 +178,18 @@ func updateAndReExec(ctx context.Context, updater *Updater, toolsVersion string,
// 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) && !errors.Is(err, errNoBaseURL) {
- return trace.Wrap(err)
+ err := updater.Update(ctxUpdate, toolsVersion)
+ if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, ErrNoBaseURL) {
+ slog.ErrorContext(ctx, "Failed to update tools version", "error", err, "version", toolsVersion)
+ // Continue executing the current version of the client tools (tsh, tctl)
+ // to avoid potential issues with update process (timeout, missing version).
+ return nil
}
// Re-execute client tools with the correct version of client tools.
- code, err := updater.Exec(args)
+ code, err := updater.Exec(toolsVersion, args)
if err != nil && !errors.Is(err, os.ErrNotExist) {
- slog.DebugContext(ctx, "Failed to re-exec client tool", "error", err)
+ slog.DebugContext(ctx, "Failed to re-exec client tool", "error", err, "code", code)
os.Exit(code)
} else if err == nil {
os.Exit(code)
diff --git a/lib/autoupdate/tools/migration.go b/lib/autoupdate/tools/migration.go
new file mode 100644
index 00000000000..d0b9fe94c31
--- /dev/null
+++ b/lib/autoupdate/tools/migration.go
@@ -0,0 +1,152 @@
+/*
+ * Teleport
+ * Copyright (C) 2025 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 tools
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/google/uuid"
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/lib/utils"
+)
+
+const (
+ // migrationFilePerms defines the permissions for files created during the migration process.
+ migrationFilePerms = 0o755
+)
+
+// migrateV1AndUpdateConfig launches migration process and add migrated
+// tools to configuration file.
+func migrateV1AndUpdateConfig(toolsDir string, tools []string) error {
+ if err := updateToolsConfig(toolsDir, func(ctc *ClientToolsConfig) error {
+ migratedTools, err := migrateV1(toolsDir, tools)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ if len(migratedTools) == 0 {
+ return nil
+ }
+
+ for _, tool := range migratedTools {
+ ctc.AddTool(tool)
+ }
+ return nil
+ }); err != nil {
+ return trace.Wrap(err)
+ }
+
+ return nil
+}
+
+// migrateV1 verifies the tool binary located in the tool's directory.
+// If it is a symlink, it reads the target location and generates a tool object
+// to be saved in the configuration for backward compatibility.
+// If it is a regular binary, a new package folder should be created,
+// and the binary should be copied to the new location.
+// TODO(vapopov): DELETE in v21.0.0 - the version without caching will no longer be supported.
+func migrateV1(toolsDir string, tools []string) (map[string]Tool, error) {
+ newPkg := fmt.Sprint(uuid.New().String(), updatePackageSuffixV2)
+ migratedTools := make(map[string]Tool)
+ for _, tool := range tools {
+ path := filepath.Join(toolsDir, tool)
+ info, err := os.Lstat(path)
+ if os.IsNotExist(err) {
+ continue
+ }
+ if err != nil {
+ return nil, trace.Wrap(err, "failed to retrieve information for tool %q", tool)
+ }
+
+ toolVersion, err := CheckToolVersion(path)
+ if trace.IsBadParameter(err) {
+ // If we can't identify toolVersion, it is blocked by EDR software or binary
+ // is damaged we should continue migration process.
+ slog.ErrorContext(context.Background(), "failed to check the toolVersion", "error", err)
+ continue
+ } else if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ if info.Mode().Type()&os.ModeSymlink != 0 {
+ fullPath, err := os.Readlink(path)
+ if err != nil {
+ return nil, trace.Wrap(err, "failed to read symlink %q", path)
+ }
+ pkg, relPath, err := extractPackageName(toolsDir, fullPath)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ if err := utils.RecursiveCopy(filepath.Join(toolsDir, pkg), filepath.Join(toolsDir, newPkg), nil); err != nil {
+ return nil, trace.Wrap(err)
+ }
+ if t, ok := migratedTools[toolVersion]; ok {
+ t.PathMap[tool] = filepath.Join(newPkg, relPath)
+ } else {
+ migratedTools[toolVersion] = Tool{
+ Version: toolVersion,
+ PathMap: map[string]string{tool: filepath.Join(newPkg, relPath)},
+ }
+ }
+ continue
+ }
+
+ // Create new toolVersion of the package and move tools to new destination.
+ if t, ok := migratedTools[toolVersion]; ok {
+ newPath := filepath.Join(toolsDir, newPkg, tool)
+ if err := utils.CopyFile(path, newPath, migrationFilePerms); err != nil {
+ return nil, trace.Wrap(err)
+ }
+ t.PathMap[tool] = filepath.Join(newPkg, tool)
+ } else {
+ if err := os.Mkdir(filepath.Join(toolsDir, newPkg), migrationFilePerms); err != nil {
+ return nil, trace.Wrap(err)
+ }
+ newPath := filepath.Join(toolsDir, newPkg, tool)
+ if err := utils.CopyFile(path, newPath, migrationFilePerms); err != nil {
+ return nil, trace.Wrap(err)
+ }
+ migratedTools[toolVersion] = Tool{
+ Version: toolVersion,
+ PathMap: map[string]string{tool: filepath.Join(newPkg, tool)},
+ }
+ }
+
+ }
+
+ return migratedTools, nil
+}
+
+func extractPackageName(toolsDir string, fullPath string) (string, string, error) {
+ rel, err := filepath.Rel(toolsDir, fullPath)
+ if err != nil {
+ return "", "", trace.Wrap(err)
+ }
+ dir := strings.SplitN(rel, string(filepath.Separator), 2)
+ if len(dir) == 2 && strings.HasSuffix(dir[0], updatePackageSuffix) {
+ return dir[0], dir[1], nil
+ }
+
+ return "", fullPath, nil
+}
diff --git a/lib/autoupdate/tools/updater.go b/lib/autoupdate/tools/updater.go
index b236324c81d..08615e69740 100644
--- a/lib/autoupdate/tools/updater.go
+++ b/lib/autoupdate/tools/updater.go
@@ -24,6 +24,7 @@ import (
"crypto/sha256"
"crypto/x509"
"encoding/hex"
+ "errors"
"fmt"
"io"
"log/slog"
@@ -53,13 +54,16 @@ const (
// teleportToolsVersionReExecEnv is internal environment name for transferring original
// version to re-executed ones.
teleportToolsVersionReExecEnv = "TELEPORT_TOOLS_VERSION_REEXEC"
+ // teleportToolsDirsEnv overrides Teleport tools directory for saving updated
+ // versions.
+ teleportToolsDirsEnv = "TELEPORT_TOOLS_DIR"
// reservedFreeDisk is the predefined amount of free disk space (in bytes) required
// to remain available after downloading archives.
reservedFreeDisk = 10 * 1024 * 1024 // 10 Mb
- // lockFileName is file used for locking update process in parallel.
- lockFileName = ".lock"
- // updatePackageSuffix is directory suffix used for package extraction in tools directory.
+ // updatePackageSuffix is directory suffix used for package extraction in tools directory for v1.
updatePackageSuffix = "-update-pkg"
+ // updatePackageSuffix is directory suffix used for package extraction in tools directory for v2.
+ updatePackageSuffixV2 = "-update-pkg-v2"
)
var (
@@ -67,6 +71,13 @@ var (
pattern = regexp.MustCompile(`(?m)Teleport v(.*) git`)
)
+// UpdateResponse contains information about after update process.
+type UpdateResponse struct {
+ Version string `json:"version,omitempty"`
+ ReExec bool `json:"reExec,omitempty"`
+ Disabled bool `json:"disabled,omitempty"`
+}
+
// Option applies an option value for the Updater.
type Option func(u *Updater)
@@ -133,37 +144,61 @@ func NewUpdater(toolsDir, localVersion string, options ...Option) *Updater {
// 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, err error) {
+func (u *Updater) CheckLocal(ctx context.Context, profileName string) (resp *UpdateResponse, 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, nil
+ return &UpdateResponse{Version: "", ReExec: false}, nil
// Requested version already the same as client version.
case u.localVersion:
- return u.localVersion, false, nil
+ return &UpdateResponse{Version: u.localVersion, ReExec: 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 nil, trace.Wrap(err, "checking that request version is semantic")
}
- return requestedVersion, true, nil
+ return &UpdateResponse{Version: requestedVersion, ReExec: true}, nil
}
- // If a version of client tools has already been downloaded to
- // tools directory, return that.
- toolsVersion, err := CheckToolVersion(u.toolsDir)
- if trace.IsNotFound(err) || toolsVersion == u.localVersion {
- return u.localVersion, false, nil
+ // We should acquire and release the lock before checking the version
+ // by executing the binary, as it might block tool execution until the version
+ // check is completed, which can take several seconds.
+ ctc, err := getToolsConfig(u.toolsDir)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ if config, ok := ctc.Configs[profileName]; ok {
+ if config.Disabled || config.Version == u.localVersion {
+ return &UpdateResponse{Version: config.Version, ReExec: false}, nil
+ } else {
+ return &UpdateResponse{Version: config.Version, ReExec: true}, nil
+ }
+ }
+
+ // Backward compatibility check. If a version of the client tools has already been downloaded
+ // to the tools directory, return it. Version check failures should be ignored, as EDR software
+ // might block execution or a broken version may already exist in the tools' directory.
+ toolsVersion, err := CheckExecutedToolVersion(u.toolsDir)
+ if trace.IsNotFound(err) || errors.Is(err, ErrVersionCheck) || toolsVersion == u.localVersion {
+ return &UpdateResponse{Version: u.localVersion, ReExec: false}, nil
}
if err != nil {
- return "", false, trace.Wrap(err)
+ return nil, trace.Wrap(err)
}
- return toolsVersion, true, nil
+ if !ctc.HasVersion(toolsVersion) {
+ if err := migrateV1AndUpdateConfig(u.toolsDir, u.tools); err != nil {
+ // Execution should not be interrupted if migration fails. Instead, it's better to
+ // re-download the version that was supposed to be migrated but failed for some reason.
+ slog.WarnContext(ctx, "Failed to migrate client tools", "error", err)
+ }
+ }
+
+ return &UpdateResponse{Version: toolsVersion, ReExec: true}, nil
}
// CheckRemote first checks the version set by the environment variable. If not set or disabled,
@@ -171,29 +206,49 @@ func (u *Updater) CheckLocal() (version string, reExec bool, err error) {
// 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, insecure bool) (version string, reExec bool, err error) {
+func (u *Updater) CheckRemote(ctx context.Context, proxyAddr string, insecure bool) (response *UpdateResponse, err error) {
+ proxyHost, err := utils.Host(proxyAddr)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
// 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, nil
+ return &UpdateResponse{Version: "", ReExec: false}, nil
// Requested version already the same as client version.
case u.localVersion:
- return u.localVersion, false, nil
+ if err := updateToolsConfig(u.toolsDir, func(ctc *ClientToolsConfig) error {
+ ctc.SetConfig(proxyHost, requestedVersion, false)
+ return nil
+ }); err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return &UpdateResponse{Version: u.localVersion, ReExec: 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 nil, trace.Wrap(err, "checking that request version is semantic")
}
- return requestedVersion, true, nil
+ // If the environment variable is set during a remote check,
+ // prioritize this version for the current host and use it as the default
+ // for all commands under the current profile.
+ if err := updateToolsConfig(u.toolsDir, func(ctc *ClientToolsConfig) error {
+ ctc.SetConfig(proxyHost, requestedVersion, false)
+ return nil
+ }); err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return &UpdateResponse{Version: requestedVersion, ReExec: true}, nil
}
certPool, err := x509.SystemCertPool()
if err != nil {
- return "", false, trace.Wrap(err)
+ return nil, trace.Wrap(err)
}
resp, err := webclient.Find(&webclient.Config{
Context: ctx,
@@ -203,94 +258,77 @@ func (u *Updater) CheckRemote(ctx context.Context, proxyAddr string, insecure bo
Insecure: insecure,
})
if err != nil {
- return "", false, trace.Wrap(err)
+ return nil, trace.Wrap(err)
}
- // If a version of client tools has already been downloaded to
- // tools directory, return that.
- toolsVersion, err := CheckToolVersion(u.toolsDir)
- if err != nil && !trace.IsNotFound(err) {
- return "", false, trace.Wrap(err)
- }
+ updateResp := &UpdateResponse{Version: u.localVersion, ReExec: false}
switch {
case !resp.AutoUpdate.ToolsAutoUpdate || resp.AutoUpdate.ToolsVersion == "":
- if toolsVersion == "" {
- return u.localVersion, false, nil
- }
+ updateResp = &UpdateResponse{Version: u.localVersion, ReExec: false, Disabled: true}
case u.localVersion == resp.AutoUpdate.ToolsVersion:
- return u.localVersion, false, nil
- case resp.AutoUpdate.ToolsVersion != toolsVersion:
- return resp.AutoUpdate.ToolsVersion, true, nil
+ updateResp = &UpdateResponse{Version: u.localVersion, ReExec: false}
+ default:
+ updateResp = &UpdateResponse{Version: resp.AutoUpdate.ToolsVersion, ReExec: true}
}
- return toolsVersion, true, nil
-}
-
-// UpdateWithLock acquires filesystem lock, downloads requested version package,
-// unarchive and replace existing one.
-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)
- }
- // Lock concurrent client tools execution util requested version is updated.
- unlock, err := utils.FSWriteLock(filepath.Join(u.toolsDir, lockFileName))
- if err != nil {
- return trace.Wrap(err)
- }
- defer func() {
- err = trace.NewAggregate(err, unlock())
- }()
-
- // 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.
- toolsVersion, err := CheckToolVersion(u.toolsDir)
- if err != nil && !trace.IsNotFound(err) {
- return trace.Wrap(err)
- }
- if updateToolsVersion == toolsVersion {
+ if err := updateToolsConfig(u.toolsDir, func(ctc *ClientToolsConfig) error {
+ ctc.SetConfig(proxyHost, updateResp.Version, updateResp.Disabled)
return nil
+ }); err != nil {
+ return nil, trace.Wrap(err)
}
- // Download and update client tools in tools directory.
- if err := u.Update(ctx, updateToolsVersion); err != nil {
- return trace.Wrap(err)
- }
-
- return nil
+ return updateResp, nil
}
-// Update downloads requested version and replace it with existing one and cleanups the previous downloads
-// with defined updater directory suffix.
+// Update acquires filesystem lock, downloads requested version package, unarchive, replace
+// existing one and cleanups the previous downloads with defined updater directory suffix.
func (u *Updater) Update(ctx context.Context, toolsVersion string) error {
- // Get platform specific download URLs.
- packages, err := teleportPackageURLs(ctx, u.uriTemplate, u.baseURL, toolsVersion)
- if err != nil {
- return trace.Wrap(err)
- }
+ err := updateToolsConfig(u.toolsDir, func(ctc *ClientToolsConfig) error {
+ // ignoreTools is the list of tools installed and tracked by the config.
+ // They should be preserved during cleanup. If we have more than [defaultSizeStoredVersion]
+ // versions, the updater will forget about the least used version.
+ var ignoreTools []string
+ for _, tool := range ctc.Tools {
+ // 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.
+ if tool.Version == toolsVersion {
+ return nil
+ }
+ ignoreTools = append(ignoreTools, tool.PackageNames()...)
+ }
- var pkgNames []string
- for _, pkg := range packages {
- pkgName := fmt.Sprint(uuid.New().String(), updatePackageSuffix)
- if err := u.update(ctx, pkg, pkgName); err != nil {
+ // Get platform specific download URLs.
+ packages, err := teleportPackageURLs(ctx, u.uriTemplate, u.baseURL, toolsVersion)
+ if 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)
- }
+ var pkgNames []string
+ for _, pkg := range packages {
+ pkgName := fmt.Sprint(uuid.New().String(), updatePackageSuffixV2)
+ if err := u.update(ctx, ctc, pkg, pkgName); err != nil {
+ return trace.Wrap(err)
+ }
+ pkgNames = append(pkgNames, pkgName)
+ }
+ // Cleanup all tools in directory with the specific prefix by ignoring tools
+ // that are currently recorded in the configuration.
+ if err := packaging.RemoveWithSuffix(u.toolsDir, updatePackageSuffixV2, append(ignoreTools, pkgNames...)); err != nil {
+ slog.WarnContext(ctx, "failed to clean up tools directory", "error", err)
+ }
- return nil
+ return nil
+ })
+
+ return trace.Wrap(err)
}
// 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, pkgName string) error {
+func (u *Updater) update(ctx context.Context, ctc *ClientToolsConfig, pkg packageURL, pkgName string) error {
f, err := os.CreateTemp("", "teleport-")
if err != nil {
return trace.Wrap(err)
@@ -330,34 +368,65 @@ func (u *Updater) update(ctx context.Context, pkg packageURL, pkgName string) er
}
// Perform atomic replace so concurrent exec do not fail.
- if err := packaging.ReplaceToolsBinaries(u.toolsDir, f.Name(), extractDir, u.tools); err != nil {
+ toolsMap, err := packaging.ReplaceToolsBinaries(f.Name(), extractDir, u.tools)
+ if err != nil {
return trace.Wrap(err)
}
+ for key, val := range toolsMap {
+ toolsMap[key] = filepath.Join(pkgName, val)
+ }
+ ctc.AddTool(Tool{Version: pkg.Version, PathMap: toolsMap})
+
return nil
}
-// Exec re-executes tool command with same arguments and environ variables.
-func (u *Updater) Exec(args []string) (int, error) {
- path, err := toolName(u.toolsDir)
- if err != nil {
- return 0, trace.Wrap(err)
+// ToolPath loads full path from config file to specific tool and version.
+func (u *Updater) ToolPath(toolName, toolVersion string) (path string, err error) {
+ var tool *Tool
+ if err := updateToolsConfig(u.toolsDir, func(ctc *ClientToolsConfig) error {
+ tool = ctc.SelectVersion(toolVersion)
+ return nil
+ }); err != nil {
+ return "", trace.Wrap(err)
}
- // To prevent re-execution loop we have to disable update logic for re-execution,
- // by unsetting current tools version env variable and setting it to "off".
- if err := os.Unsetenv(teleportToolsVersionEnv); err != nil {
- return 0, trace.Wrap(err)
+ if tool == nil {
+ return "", trace.NotFound("tool version %q not found", toolVersion)
}
- if err := os.Unsetenv(teleportToolsVersionReExecEnv); err != nil {
- return 0, trace.Wrap(err)
+ relPath, ok := tool.PathMap[toolName]
+ if !ok {
+ return "", trace.NotFound("tool %q not found", toolName)
}
- env := os.Environ()
+ return filepath.Join(u.toolsDir, relPath), nil
+}
+
+// Exec re-executes tool command with same arguments and environ variables.
+func (u *Updater) Exec(toolsVersion string, args []string) (int, error) {
executablePath, err := os.Executable()
if err != nil {
return 0, trace.Wrap(err)
}
+ path, err := u.ToolPath(filepath.Base(executablePath), toolsVersion)
+ if err != nil {
+ return 0, trace.Wrap(err)
+ }
+
+ for _, unset := range []string{
+ teleportToolsVersionReExecEnv,
+ teleportToolsDirsEnv,
+ } {
+ if err := os.Unsetenv(unset); err != nil {
+ return 0, trace.Wrap(err)
+ }
+ }
+ env := append(os.Environ(), fmt.Sprintf("%s=%s", teleportToolsDirsEnv, u.toolsDir))
+ // To prevent re-execution loop we have to disable update logic for re-execution,
+ // by unsetting current tools version env variable and setting it to "off".
if path == executablePath {
+ if err := os.Unsetenv(teleportToolsVersionEnv); err != nil {
+ return 0, trace.Wrap(err)
+ }
env = append(env, teleportToolsVersionEnv+"=off")
}
env = append(env, fmt.Sprintf("%s=%s", teleportToolsVersionReExecEnv, u.localVersion))
diff --git a/lib/autoupdate/tools/utils.go b/lib/autoupdate/tools/utils.go
index e158ef53416..dcd9b1f06eb 100644
--- a/lib/autoupdate/tools/utils.go
+++ b/lib/autoupdate/tools/utils.go
@@ -39,22 +39,41 @@ import (
"github.com/gravitational/teleport/lib/autoupdate"
"github.com/gravitational/teleport/lib/modules"
"github.com/gravitational/teleport/lib/utils"
+ "github.com/gravitational/teleport/lib/utils/packaging"
)
-var errNoBaseURL = errors.New("baseURL is not defined")
+var (
+ // ErrNoBaseURL is returned when `TELEPORT_CDN_BASE_URL` must be set
+ // in order to proceed with managed updates.
+ ErrNoBaseURL = errors.New("baseURL is not defined")
+ // ErrVersionCheck is returned when the downloaded version fails
+ // to execute for version identification.
+ ErrVersionCheck = errors.New("version check failed")
+)
-// Dir returns the path to client tools in $TELEPORT_HOME/bin.
+// Dir returns the client tools installation directory path, using the following fallback order:
+// $TELEPORT_TOOLS_DIR, $TELEPORT_HOME/bin, and $HOME/.tsh/bin.
func Dir() (string, error) {
- home := os.Getenv(types.HomeEnvVar)
- if home == "" {
- var err error
- home, err = os.UserHomeDir()
- if err != nil {
- return "", trace.Wrap(err)
+ toolsDir := os.Getenv(teleportToolsDirsEnv)
+ if toolsDir == "" {
+ toolsDir = os.Getenv(types.HomeEnvVar)
+ if toolsDir == "" {
+ var err error
+ toolsDir, err = os.UserHomeDir()
+ if err != nil {
+ return "", trace.Wrap(err)
+ }
+ toolsDir = filepath.Join(toolsDir, ".tsh", "bin")
+ } else {
+ toolsDir = filepath.Join(toolsDir, "bin")
}
}
- return filepath.Join(home, ".tsh", "bin"), nil
+ toolsDir, err := filepath.Abs(toolsDir)
+ if err != nil {
+ return "", trace.Wrap(err)
+ }
+ return toolsDir, nil
}
// DefaultClientTools list of the client tools needs to be updated by default.
@@ -67,16 +86,20 @@ func DefaultClientTools() []string {
}
}
-// 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.
+// CheckExecutedToolVersion invokes the exact executable from the tools directory to retrieve its version.
+func CheckExecutedToolVersion(toolsDir string) (string, error) {
path, err := toolName(toolsDir)
if err != nil {
return "", trace.Wrap(err)
}
- if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
- return "", trace.NotFound("autoupdate tool not found in %q", toolsDir)
+ return CheckToolVersion(path)
+}
+
+// CheckToolVersion returns client tools version, must return NotFoundError if
+// the client tools is not found in specified path.
+func CheckToolVersion(toolPath string) (string, error) {
+ if _, err := os.Stat(toolPath); errors.Is(err, os.ErrNotExist) {
+ return "", trace.NotFound("autoupdate tool not found in %q", toolPath)
} else if err != nil {
return "", trace.Wrap(err)
}
@@ -89,11 +112,13 @@ func CheckToolVersion(toolsDir string) (string, error) {
// Execute "{tsh, tctl} version" and pass in TELEPORT_TOOLS_VERSION=off to
// turn off all automatic updates code paths to prevent any recursion.
- command := exec.CommandContext(ctx, path, "version")
+ command := exec.CommandContext(ctx, toolPath, "version")
command.Env = []string{teleportToolsVersionEnv + "=off"}
output, err := command.Output()
if err != nil {
- return "", trace.WrapWithMessage(err, "failed to determine version of %q tool", path)
+ slog.DebugContext(context.Background(), "failed to determine version",
+ "tool", toolPath, "error", err, "output", string(output))
+ return "", ErrVersionCheck
}
// The output for "{tsh, tctl} version" can be multiple lines. Find the
@@ -133,10 +158,35 @@ func GetReExecFromVersion(ctx context.Context) string {
return reExecFromVersion
}
+// CleanUp cleans the tools directory with downloaded versions.
+func CleanUp(toolsDir string, tools []string) error {
+ var aggErr []error
+ for _, tool := range tools {
+ if err := os.Remove(filepath.Join(toolsDir, tool)); err != nil && !os.IsNotExist(err) {
+ aggErr = append(aggErr, err)
+ }
+ }
+ if err := os.Remove(filepath.Join(toolsDir, lockFileName)); err != nil && !os.IsNotExist(err) {
+ aggErr = append(aggErr, err)
+ }
+ if err := os.Remove(filepath.Join(toolsDir, configFileName)); err != nil && !os.IsNotExist(err) {
+ aggErr = append(aggErr, err)
+ }
+ if err := packaging.RemoveWithSuffix(toolsDir, updatePackageSuffix, nil); err != nil {
+ aggErr = append(aggErr, err)
+ }
+ if err := packaging.RemoveWithSuffix(toolsDir, updatePackageSuffixV2, nil); err != nil {
+ aggErr = append(aggErr, err)
+ }
+
+ return trace.NewAggregate(aggErr...)
+}
+
// packageURL defines URLs to the archive and their archive sha256 hash file, and marks
// if this package is optional, for such case download needs to be ignored if package
// not found in CDN.
type packageURL struct {
+ Version string
Archive string
Hash string
Optional bool
@@ -148,7 +198,7 @@ func teleportPackageURLs(ctx context.Context, uriTmpl string, baseURL, version s
envBaseURL := os.Getenv(autoupdate.BaseURLEnvVar)
if m.BuildType() == modules.BuildOSS && envBaseURL == "" {
slog.WarnContext(ctx, "Client tools updates are disabled as they are licensed under AGPL. To use Community Edition builds or custom binaries, set the 'TELEPORT_CDN_BASE_URL' environment variable.")
- return nil, errNoBaseURL
+ return nil, ErrNoBaseURL
}
var flags autoupdate.InstallFlags
@@ -170,13 +220,13 @@ func teleportPackageURLs(ctx context.Context, uriTmpl string, baseURL, version s
}
return []packageURL{
- {Archive: teleportURL, Hash: teleportURL + ".sha256"},
- {Archive: tshURL, Hash: tshURL + ".sha256", Optional: true},
+ {Version: version, Archive: teleportURL, Hash: teleportURL + ".sha256"},
+ {Version: version, Archive: tshURL, Hash: tshURL + ".sha256", Optional: true},
}, nil
}
return []packageURL{
- {Archive: teleportURL, Hash: teleportURL + ".sha256"},
+ {Version: version, Archive: teleportURL, Hash: teleportURL + ".sha256"},
}, nil
}
diff --git a/lib/client/api.go b/lib/client/api.go
index 4d2059d27b4..1f20e5083b7 100644
--- a/lib/client/api.go
+++ b/lib/client/api.go
@@ -746,7 +746,7 @@ func RetryWithRelogin(ctx context.Context, tc *TeleportClient, fn func() error,
return trace.Wrap(err)
}
- // Save profile to record proxy credentials
+ // Save profile to record proxy credentials.
if err := tc.SaveProfile(opt.makeCurrentProfile); err != nil {
log.WarnContext(ctx, "Failed to save profile", "error", err)
return trace.Wrap(err)
diff --git a/lib/utils/cli.go b/lib/utils/cli.go
index 10b31d1da29..7f646032979 100644
--- a/lib/utils/cli.go
+++ b/lib/utils/cli.go
@@ -484,3 +484,27 @@ func FormatAlert(alert types.ClusterAlert) string {
}
return buf.String()
}
+
+// FilterArguments filters the input arguments, keeping only those defined in the provided `kingpin.ApplicationModel`.
+// For example, if the model defines only one boolean flag `--insecure`, all other arguments in `args`
+// will be excluded, and only the `--insecure` flag will remain.
+func FilterArguments(args []string, model *kingpin.ApplicationModel) []string {
+ var result []string
+ for _, flag := range model.Flags {
+ for i := range args {
+ if strings.HasPrefix(args[i], fmt.Sprint("--", flag.Name, "=")) {
+ result = append(result, args[i])
+ break
+ }
+ if args[i] == fmt.Sprint("--", flag.Name) {
+ if flag.IsBoolFlag() {
+ result = append(result, args[i])
+ } else if i+2 <= len(args) {
+ result = append(result, args[i], args[i+1])
+ }
+ break
+ }
+ }
+ }
+ return result
+}
diff --git a/lib/utils/cli_test.go b/lib/utils/cli_test.go
index fc3875ed21e..7e299c410d2 100644
--- a/lib/utils/cli_test.go
+++ b/lib/utils/cli_test.go
@@ -24,6 +24,7 @@ import (
"log/slog"
"testing"
+ "github.com/alecthomas/kingpin/v2"
"github.com/gravitational/trace"
"github.com/stretchr/testify/require"
)
@@ -160,3 +161,54 @@ func TestAllowWhitespace(t *testing.T) {
require.Equal(t, tt.out, AllowWhitespace(tt.in), fmt.Sprintf("test case %v", i))
}
}
+
+// TestFilterArguments tests filtering command arguments.
+func TestFilterArguments(t *testing.T) {
+ t.Parallel()
+
+ app := kingpin.New("tsh", "")
+ app.Flag("proxy", "").String()
+ app.Flag("check-update", "").Bool()
+
+ tests := []struct {
+ args []string
+ expected []string
+ }{
+ {
+ args: []string{"--insecure", "--proxy", "localhost", "--check-update", "test"},
+ expected: []string{"--proxy", "localhost", "--check-update"},
+ },
+ {
+ args: []string{"--insecure", "--proxy=localhost", "--check-update", "test"},
+ expected: []string{"--proxy=localhost", "--check-update"},
+ },
+ {
+ args: []string{"--proxy", "localhost", "test"},
+ expected: []string{"--proxy", "localhost"},
+ },
+ {
+ args: []string{"--proxy"},
+ expected: []string(nil),
+ },
+ {
+ args: []string{"--insecure", "--check-update", "test", "--proxy=localhost"},
+ expected: []string{"--proxy=localhost", "--check-update"},
+ },
+ {
+ args: []string{"--insecure", "--check-update", "test", "--proxy1=localhost"},
+ expected: []string{"--check-update"},
+ },
+ {
+ args: []string{"--check-update", "test", "--proxy1", "localhost"},
+ expected: []string{"--check-update"},
+ },
+ {
+ args: []string{"--insecure", "test", "--proxy1", "localhost", "--check-update"},
+ expected: []string{"--check-update"},
+ },
+ }
+
+ for i, tt := range tests {
+ require.Equal(t, tt.expected, FilterArguments(tt.args, app.Model()), fmt.Sprintf("test case %v", i))
+ }
+}
diff --git a/lib/utils/packaging/unarchive.go b/lib/utils/packaging/unarchive.go
index ed81a4b4b2d..ddcab0da5c5 100644
--- a/lib/utils/packaging/unarchive.go
+++ b/lib/utils/packaging/unarchive.go
@@ -70,22 +70,22 @@ func RemoveWithSuffix(dir, suffix string, skipNames []string) error {
// replaceZip un-archives the Teleport package in .zip format, iterates through
// the compressed content, and ignores everything not matching the binaries specified
-// in the execNames argument. The data is extracted to extractDir, and symlinks are created
-// in toolsDir pointing to the extractDir path with binaries.
-func replaceZip(toolsDir string, archivePath string, extractDir string, execNames []string) error {
+// in the execNames argument. The data is extracted to extractDir, and copies are created in toolsDir.
+func replaceZip(archivePath string, extractDir string, execNames []string) (map[string]string, error) {
+ execPaths := make(map[string]string, len(execNames))
f, err := os.Open(archivePath)
if err != nil {
- return trace.Wrap(err)
+ return nil, trace.Wrap(err)
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
- return trace.Wrap(err)
+ return nil, trace.Wrap(err)
}
zipReader, err := zip.NewReader(f, fi.Size())
if err != nil {
- return trace.Wrap(err)
+ return nil, trace.Wrap(err)
}
var totalSize uint64 = 0
@@ -101,7 +101,7 @@ func replaceZip(toolsDir string, archivePath string, extractDir string, execName
}
// Verify that we have enough space for uncompressed zipFile.
if err := checkFreeSpace(extractDir, totalSize); err != nil {
- return trace.Wrap(err)
+ return nil, trace.Wrap(err)
}
for _, zipFile := range zipReader.File {
@@ -123,26 +123,17 @@ func replaceZip(toolsDir string, archivePath string, extractDir string, execName
if err != nil {
return trace.Wrap(err)
}
- defer destFile.Close()
-
if _, err := io.Copy(destFile, file); err != nil {
- return trace.Wrap(err)
- }
- appPath := filepath.Join(toolsDir, baseName)
- // For the Windows build, we need to copy the binary to perform updates without requiring
- // administrative access, which would otherwise be needed for creating symlinks.
- // Since symlinks are not used on the Windows platform, there's no need to remove appPath
- // before copying the new binary — it will simply be replaced.
- if err := utils.CopyFile(dest, appPath, 0o755); err != nil {
- return trace.Wrap(err)
+ return trace.NewAggregate(err, destFile.Close())
}
return trace.Wrap(destFile.Close())
}(zipFile); err != nil {
- return trace.Wrap(err)
+ return nil, trace.Wrap(err)
}
+ execPaths[baseName] = baseName
}
- return nil
+ return execPaths, nil
}
// checkFreeSpace verifies that we have enough requested space (in bytes) at specific directory.
diff --git a/lib/utils/packaging/unarchive_test.go b/lib/utils/packaging/unarchive_test.go
index b124b603b0f..4b64083aec3 100644
--- a/lib/utils/packaging/unarchive_test.go
+++ b/lib/utils/packaging/unarchive_test.go
@@ -22,6 +22,7 @@ package packaging
import (
"context"
+ "fmt"
"os"
"path/filepath"
"runtime"
@@ -36,21 +37,7 @@ import (
// TestPackaging verifies un-archiving of all supported teleport package formats.
func TestPackaging(t *testing.T) {
script := "#!/bin/sh\necho test"
-
- sourceDir, err := os.MkdirTemp(os.TempDir(), "source")
- require.NoError(t, err)
-
- toolsDir, err := os.MkdirTemp(os.TempDir(), "dest")
- require.NoError(t, err)
-
- extractDir, err := os.MkdirTemp(toolsDir, "extract")
- require.NoError(t, err)
-
- t.Cleanup(func() {
- require.NoError(t, os.RemoveAll(extractDir))
- require.NoError(t, os.RemoveAll(sourceDir))
- require.NoError(t, os.RemoveAll(toolsDir))
- })
+ sourceDir := t.TempDir()
// Create test script for packaging in relative path `teleport\bin` to ensure that
// binaries going to be identified and extracted flatten to `extractDir`.
@@ -62,20 +49,23 @@ func TestPackaging(t *testing.T) {
ctx := context.Background()
t.Run("tar.gz", func(t *testing.T) {
+ toolsDir := t.TempDir()
+ extractDir, err := os.MkdirTemp(toolsDir, "extract")
+ require.NoError(t, err)
+
archivePath := filepath.Join(toolsDir, "tsh.tar.gz")
err = archive.CompressDirToTarGzFile(ctx, sourceDir, archivePath)
require.NoError(t, err)
require.FileExists(t, archivePath, "archive not created")
// For the .tar.gz format we extract app by app to check that content discard is not required.
- err = replaceTarGz(toolsDir, archivePath, extractDir, []string{"tctl"})
+ toolsMap, err := replaceTarGz(archivePath, extractDir, []string{"tsh", "tctl"})
require.NoError(t, err)
- err = replaceTarGz(toolsDir, archivePath, extractDir, []string{"tsh"})
- require.NoError(t, err)
- assert.FileExists(t, filepath.Join(toolsDir, "tsh"), "script not created")
- assert.FileExists(t, filepath.Join(toolsDir, "tctl"), "script not created")
+ for tool, path := range toolsMap {
+ assert.FileExists(t, filepath.Join(extractDir, path), fmt.Sprintf("script: %q not found", tool))
+ }
- data, err := os.ReadFile(filepath.Join(toolsDir, "tsh"))
+ data, err := os.ReadFile(filepath.Join(extractDir, "tsh"))
require.NoError(t, err)
assert.Equal(t, script, string(data))
})
@@ -84,33 +74,42 @@ func TestPackaging(t *testing.T) {
if runtime.GOOS != "darwin" {
t.Skip("unsupported platform")
}
+ toolsDir := t.TempDir()
+ extractDir, err := os.MkdirTemp(toolsDir, "extract")
+ require.NoError(t, err)
+
archivePath := filepath.Join(toolsDir, "tsh.pkg")
err = archive.CompressDirToPkgFile(ctx, sourceDir, archivePath, "com.example.pkgtest")
require.NoError(t, err)
require.FileExists(t, archivePath, "archive not created")
- err = replacePkg(toolsDir, archivePath, filepath.Join(extractDir, "apps"), []string{"tsh", "tctl"})
+ toolsMap, err := replacePkg(archivePath, filepath.Join(extractDir, "apps"), []string{"tsh", "tctl"})
require.NoError(t, err)
- assert.FileExists(t, filepath.Join(toolsDir, "tsh"), "script not created")
- assert.FileExists(t, filepath.Join(toolsDir, "tctl"), "script not created")
-
- data, err := os.ReadFile(filepath.Join(toolsDir, "tsh"))
- require.NoError(t, err)
- assert.Equal(t, script, string(data))
+ for tool, path := range toolsMap {
+ assert.FileExists(t, filepath.Join(extractDir, "apps", path), fmt.Sprintf("script: %q not found", tool))
+ data, err := os.ReadFile(filepath.Join(extractDir, "apps", path))
+ require.NoError(t, err)
+ assert.Equal(t, script, string(data))
+ }
})
t.Run("zip", func(t *testing.T) {
+ toolsDir := t.TempDir()
+ extractDir, err := os.MkdirTemp(toolsDir, "extract")
+ require.NoError(t, err)
+
archivePath := filepath.Join(toolsDir, "tsh.zip")
err = archive.CompressDirToZipFile(ctx, sourceDir, archivePath)
require.NoError(t, err)
require.FileExists(t, archivePath, "archive not created")
- err = replaceZip(toolsDir, archivePath, extractDir, []string{"tsh", "tctl"})
+ toolsMap, err := replaceZip(archivePath, extractDir, []string{"tsh", "tctl"})
require.NoError(t, err)
- assert.FileExists(t, filepath.Join(toolsDir, "tsh"), "script not created")
- assert.FileExists(t, filepath.Join(toolsDir, "tctl"), "script not created")
+ for tool, path := range toolsMap {
+ assert.FileExists(t, filepath.Join(extractDir, path), fmt.Sprintf("script: %q not found", tool))
+ }
- data, err := os.ReadFile(filepath.Join(toolsDir, "tsh"))
+ data, err := os.ReadFile(filepath.Join(extractDir, "tsh"))
require.NoError(t, err)
assert.Equal(t, script, string(data))
})
diff --git a/lib/utils/packaging/unarchive_unix.go b/lib/utils/packaging/unarchive_unix.go
index 8daf1b3aa55..a525fb21c04 100644
--- a/lib/utils/packaging/unarchive_unix.go
+++ b/lib/utils/packaging/unarchive_unix.go
@@ -31,7 +31,6 @@ import (
"runtime"
"slices"
- "github.com/google/renameio/v2"
"github.com/gravitational/trace"
"github.com/gravitational/teleport/api/constants"
@@ -43,12 +42,12 @@ import (
//
// For Darwin, archivePath must be a .pkg file.
// For other POSIX, archivePath must be a gzipped tarball.
-func ReplaceToolsBinaries(toolsDir string, archivePath string, extractDir string, execNames []string) error {
+func ReplaceToolsBinaries(archivePath string, extractDir string, execNames []string) (map[string]string, error) {
switch runtime.GOOS {
case constants.DarwinOS:
- return replacePkg(toolsDir, archivePath, extractDir, execNames)
+ return replacePkg(archivePath, extractDir, execNames)
default:
- return replaceTarGz(toolsDir, archivePath, extractDir, execNames)
+ return replaceTarGz(archivePath, extractDir, execNames)
}
}
@@ -56,19 +55,20 @@ func ReplaceToolsBinaries(toolsDir string, archivePath string, extractDir string
// the compressed content, and ignores everything not matching the app binaries specified
// in the apps argument. The data is extracted to extractDir, and symlinks are created
// in toolsDir pointing to the extractDir path with binaries.
-func replaceTarGz(toolsDir string, archivePath string, extractDir string, execNames []string) error {
+func replaceTarGz(archivePath string, extractDir string, execNames []string) (map[string]string, error) {
+ execPaths := make(map[string]string, len(execNames))
if err := validateFreeSpaceTarGz(archivePath, extractDir, execNames); err != nil {
- return trace.Wrap(err)
+ return nil, trace.Wrap(err)
}
f, err := os.Open(archivePath)
if err != nil {
- return trace.Wrap(err)
+ return nil, trace.Wrap(err)
}
defer f.Close()
gzipReader, err := gzip.NewReader(f)
if err != nil {
- return trace.Wrap(err)
+ return nil, trace.Wrap(err)
}
tarReader := tar.NewReader(gzipReader)
for {
@@ -77,7 +77,7 @@ func replaceTarGz(toolsDir string, archivePath string, extractDir string, execNa
break
}
if err != nil {
- return trace.Wrap(err)
+ return nil, trace.Wrap(err)
}
baseName := filepath.Base(header.Name)
// Skip over any files in the archive that are not in execNames.
@@ -86,27 +86,22 @@ func replaceTarGz(toolsDir string, archivePath string, extractDir string, execNa
}
if err = func(header *tar.Header) error {
- tempFile, err := renameio.TempFile(extractDir, filepath.Join(toolsDir, baseName))
+ dest := filepath.Join(extractDir, baseName)
+ destFile, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
return trace.Wrap(err)
}
- defer tempFile.Cleanup()
- if err := os.Chmod(tempFile.Name(), 0o755); err != nil {
- return trace.Wrap(err)
+ if _, err := io.Copy(destFile, tarReader); err != nil {
+ return trace.NewAggregate(err, destFile.Close())
}
- if _, err := io.Copy(tempFile, tarReader); err != nil {
- return trace.Wrap(err)
- }
- if err := tempFile.CloseAtomicallyReplace(); err != nil {
- return trace.Wrap(err)
- }
- return trace.Wrap(tempFile.Cleanup())
+ return trace.Wrap(destFile.Close())
}(header); err != nil {
- return trace.Wrap(err)
+ return nil, trace.Wrap(err)
}
+ execPaths[baseName] = baseName
}
- return trace.Wrap(gzipReader.Close())
+ return execPaths, trace.Wrap(gzipReader.Close())
}
// validateFreeSpaceTarGz validates that extraction size match available disk space in `extractDir`.
@@ -146,7 +141,8 @@ func validateFreeSpaceTarGz(archivePath string, extractDir string, execNames []s
// The data is extracted to extractDir, and symlinks are created in toolsDir pointing to the binaries
// in extractDir. Before creating the symlinks, each binary must be executed at least once to pass
// OS signature verification.
-func replacePkg(toolsDir string, archivePath string, extractDir string, execNames []string) error {
+func replacePkg(archivePath string, extractDir string, execNames []string) (map[string]string, error) {
+ execPaths := make(map[string]string, len(execNames))
// Use "pkgutil" from the filesystem to expand the archive. In theory .pkg
// files are xz archives, however it's still safer to use "pkgutil" in-case
// Apple makes non-standard changes to the format.
@@ -154,11 +150,11 @@ func replacePkg(toolsDir string, archivePath string, extractDir string, execName
// Full command: pkgutil --expand-full NAME.pkg DIRECTORY/
pkgutil, err := exec.LookPath("pkgutil")
if err != nil {
- return trace.Wrap(err)
+ return nil, trace.Wrap(err)
}
if err = exec.Command(pkgutil, "--expand-full", archivePath, extractDir).Run(); err != nil {
- return trace.Wrap(err)
+ return nil, trace.Wrap(err)
}
err = filepath.Walk(extractDir, func(path string, info os.FileInfo, err error) error {
@@ -191,18 +187,14 @@ func replacePkg(toolsDir string, archivePath string, extractDir string, execName
if err := command.Run(); err != nil {
return trace.WrapWithMessage(err, "failed to validate binary")
}
-
- // Due to macOS applications not being a single binary (they are a
- // directory), atomic operations are not possible. To work around this, use
- // a symlink (which can be atomically swapped), then do a cleanup pass
- // removing any stale copies of the expanded package.
- newName := filepath.Join(toolsDir, filepath.Base(path))
- if err := renameio.Symlink(path, newName); err != nil {
+ relPath, err := filepath.Rel(extractDir, path)
+ if err != nil {
return trace.Wrap(err)
}
+ execPaths[info.Name()] = relPath
return nil
})
- return trace.Wrap(err)
+ return execPaths, trace.Wrap(err)
}
diff --git a/lib/utils/packaging/unarchive_windows.go b/lib/utils/packaging/unarchive_windows.go
index c07471adce8..f17b6c7e4be 100644
--- a/lib/utils/packaging/unarchive_windows.go
+++ b/lib/utils/packaging/unarchive_windows.go
@@ -25,6 +25,6 @@ package packaging
// toolsDir/[name].
//
// For Windows, archivePath must be a .zip file.
-func ReplaceToolsBinaries(toolsDir string, archivePath string, extractPath string, execNames []string) error {
- return replaceZip(toolsDir, archivePath, extractPath, execNames)
+func ReplaceToolsBinaries(archivePath string, extractPath string, execNames []string) (map[string]string, error) {
+ return replaceZip(archivePath, extractPath, execNames)
}
diff --git a/lib/utils/utils.go b/lib/utils/utils.go
index 827489016ce..2a5143927bd 100644
--- a/lib/utils/utils.go
+++ b/lib/utils/utils.go
@@ -303,6 +303,16 @@ func Host(hostname string) (string, error) {
return host, nil
}
+// TryHost is a utility function that extracts host from the host:port pair,
+// in case of any error returns the original value.
+func TryHost(in string) string {
+ out, err := Host(in)
+ if err != nil {
+ return in
+ }
+ return out
+}
+
// SplitHostPort splits host and port and checks that host is not empty
func SplitHostPort(hostname string) (string, string, error) {
host, port, err := net.SplitHostPort(hostname)
diff --git a/rfd/0144-client-tools-updates.md b/rfd/0144-client-tools-updates.md
index b0c980e134c..a8ee755be76 100644
--- a/rfd/0144-client-tools-updates.md
+++ b/rfd/0144-client-tools-updates.md
@@ -14,7 +14,7 @@ state: draft
## What/Why
This RFD describes how client tools like `tsh` and `tctl` can be kept up to
-date, either using automatic updates or self-managed updates.
+date, either using managed updates or self-managed updates.
Keeping client tools updated helps with security (fixes for known security
vulnerabilities are pushed to endpoints), bugs (fixes for resolved issues are
@@ -30,13 +30,13 @@ rules).
Client tools like `tsh` and `tctl` will automatically download and install the
required version for the Teleport cluster.
-Enrollment in automatic updates for client tools will be controlled at the
-cluster level. By default all Cloud clusters will be opted into automatic
+Enrollment in managed updates for client tools will be controlled at the
+cluster level. By default, all Cloud clusters will be opted into managed
updates for client tools. Cluster administrators using MDM software like Jamf
will be able opt-out manually manage updates.
-Self-hosted clusters will be be opted out, but have the option to use the same
-automatic update mechanism.
+Self-hosted clusters will be opted out, but have the option to use the same
+managed update mechanism.
Inspiration drawn from https://go.dev/doc/toolchain.
@@ -44,13 +44,18 @@ Inspiration drawn from https://go.dev/doc/toolchain.
#### Client tools
-##### Automatic updates
+##### Managed updates
When `tsh login` is executed, client tools will check `/v1/webapi/find` to
-determine if automatic updates are enabled. If the cluster's required version
+determine if managed updates are enabled. If the cluster's required version
differs from the current binary, client tools will download and re-execute
-using the version required by the cluster. All other `tsh` subcommands (like
-`tsh ssh ...`) will always use the downloaded version.
+using the version required by the cluster. This means that Managed Updates
+support major version differences, as the login command is intercepted
+to check the version first.
+
+To enable managed updates for different set of command such as `tsh ssh` or
+`tsh proxy ssh` to verify the version set in cluster during login to ssh
+you need to set `TELEPORT_TOOLS_CHECK_UPDATE=t` environment variable.
The original client tools binaries won't be overwritten. Instead, an additional
binary will be downloaded and stored in `~/.tsh/bin` with `0755` permissions.
@@ -68,8 +73,14 @@ To enable concurrent operation of client tools, a locking mechanisms utilizing
$ tree ~/.tsh
~/.tsh
├── bin
-│ ├── tctl
-│ └── tsh
+│ ├── .config.json
+│ ├── .lock
+│ ├── 7de24a1e-8141-4fc8-9a1f-cd4665afa338-update-pkg-v2
+│ │ ├── tctl
+│ │ └── tsh
+│ └── d00ffd3d-700d-47b0-bd65-94506f1362e2-update-pkg-v2
+│ ├── tctl
+│ └── tsh
├── current-profile
├── keys
│ └── proxy.example.com
@@ -85,6 +96,37 @@ $ tree ~/.tsh
└── proxy.example.com.yaml
```
+The configuration file structure should include a map of known hosts and a list of installed tools, ordered by most recently used.
+
+- The map of known hosts stores data received from the cluster configuration, such as version and mode. This version will be used for re-execution unless it is overridden by the `TELEPORT_TOOLS_VERSION` environment variable.
+- The list of installed tools has a limited size (at least 3 installations). When a tool is no longer referenced in the configuration, it will be removed during cleanup.
+
+When a specific version of a tool is requested for re-execution, the path is determined from the configuration as: `$TELEPORT_TOOLS_DIR/package/path[tool_name]`
+
+```json
+{
+ "configs": {
+ "proxy.example.com": {
+ "version": "17.5.1",
+ "disabled": false
+ }
+ },
+ "max_tools": 3,
+ "tools": [
+ {
+ "version": "17.5.1",
+ "path": {"tctl": "tctl", "tsh": "tsh"},
+ "package": "d00ffd3d-700d-47b0-bd65-94506f1362e2-update-pkg-v2"
+ },
+ {
+ "version": "17.5.2",
+ "path": {"tctl": "tctl", "tsh": "tsh"},
+ "package": "7de24a1e-8141-4fc8-9a1f-cd4665afa338-update-pkg-v2"
+ }
+ ]
+}
+```
+
Users can cancel client tools updates using `Ctrl-C`. This may be needed if the
user is on a low bandwidth connection (LTE or public Wi-Fi), if the Teleport
download server is inaccessible, or the user urgently needs to access the
@@ -99,14 +141,22 @@ Update progress: [▒▒▒▒▒▒ ] (Ctrl-C to cancel update)
```
All archive downloads are targeted to the `cdn.teleport.dev` endpoint and depend
-on the operating system, platform, and edition. Where edition must be identified
-by the original client tools binary, URL pattern:
+on the operating system, platform, and edition. The CDN base URL can be overridden
+by the `TELEPORT_CDN_BASE_URL` environment variable and required to be set for OSS build.
+Where edition must be identified by the original client tools binary, URL pattern:
`https://cdn.teleport.dev/teleport-{, ent-}v15.3.0-{linux, darwin, windows}-{amd64,arm64,arm,386}-{fips-}bin.tar.gz`
+
An environment variable `TELEPORT_TOOLS_VERSION` will be introduced that can be
-`X.Y.Z` (use specific semver version) or `off` (do not update). This
-environment variable can be used as a emergency workaround for a known issue,
-pinning to a specific version in CI/CD, or for debugging.
+`X.Y.Z` (use specific semver version) or `off` (do not update). This environment
+variable can be used for manual updates, pinning to a specific version in CI/CD,
+or for debugging.
+
+By setting `TELEPORT_TOOLS_VERSION=X.Y.Z` during `tsh login`, the advertised version
+from the cluster will be ignored, as well as any disabled mode. This specified
+version will be recorded in the client tools configuration file and associated
+with the cluster. When the profile for this cluster is active, the specified
+version will be used for all commands.
During re-execution, child process will inherit all environment variables and
flags. `TELEPORT_TOOLS_VERSION=off` will be added during re-execution to
@@ -115,42 +165,31 @@ prevent infinite loops.
When `tctl` is used to connect to Auth Service running on the same host over
`localhost`, `tctl` assumes a special administrator role that can perform all
operations on a cluster. In this situation the expectation is for the version
-of `tctl` and `teleport` to match so automatic updates will not be used.
+of `tctl` and `teleport` to match so managed updates will not be used.
-> [!NOTE]
-> If a user connects to multiple root clusters, each running a different
-> version of Teleport, client tools will attempt to download the differing
-> version of Teleport each time the user performs a `tsh login`.
->
-> In practice, the number of users impacted by this would be small. Customer
-> Cloud tenants would be on the same version and this feature is turned off by
-> default for self-hosted cluster.
->
-> However, for those people in this situation, the recommendation would be to
-> use self-managed updates.
##### Errors and warnings
-If cluster administrator has chosen not to enroll client tools in automatic
+If cluster administrator has chosen not to enroll client tools in managed
updates and does not self-manage client tools updates as outlined in
[Self-managed client tools updates](#self-managed-client-tools-updates), a
series of warnings and errors with increasing urgency will be shown to the
user.
If the version of client tools is within the same major version as advertised
-by the cluster, a warning will be shown to urge the user to enroll in automatic
+by the cluster, a warning will be shown to urge the user to enroll in managed
updates. Warnings will not prevent the user from using client tools that are
slightly out of date.
```
$ tsh login --proxy=proxy.example.com
-Warning: Client tools are out of date, update to vX.Y.Z.
+WARNING: Client tools are out of date, update to vX.Y.Z.
Update Teleport to vX.Y.Z from https://goteleport.com/download or your system
package manager.
-Enroll in automatic updates to keep client tools like tsh and tctl
-automatically updated. https://goteleport.com/docs/upgrading/automatic-updates
+Enroll in managed updates to keep client tools like tsh and tctl
+automatically updated. https://goteleport.com/docs/upgrading/client-tools-autoupdate/
[...]
```
@@ -166,8 +205,8 @@ WARNING: Client tools are 1 major version out of date, update to vX.Y.Z.
Some functionality may not work. Update Teleport to vX.Y.Z from
https://goteleport.com/download or your system package manager.
-Enroll in automatic updates to keep client tools like tsh and tctl
-automatically updated. https://goteleport.com/docs/upgrading/automatic-updates
+Enroll in managed updates to keep client tools like tsh and tctl
+automatically updated. https://goteleport.com/docs/upgrading/client-tools-autoupdate/
```
If the version of client tools is 2 (or more) versions lower than the version
@@ -182,8 +221,8 @@ ERROR: Client tools are N major versions out of date, update to vX.Y.Z.
Your cluster requires {tsh,tctl} vX.Y.Z. Update Teleport from
https://goteleport.com/download or your system package manager.
-Enroll in automatic updates to keep client tools like tsh and tctl
-automatically updated. https://goteleport.com/docs/upgrading/automatic-updates
+Enroll in managed updates to keep client tools like tsh and tctl
+automatically updated. https://goteleport.com/docs/upgrading/client-tools-autoupdate/
Use the "--skip-version-check" flag to bypass this check and attempt to connect
to this cluster.
@@ -208,11 +247,11 @@ $ tctl autoupdate client-tools status --proxy proxy.example.com --format json
##### Cluster configuration
-Enrollment of clients in automatic updates will be enforced at the cluster
+Enrollment of clients in managed updates will be enforced at the cluster
level.
The `autoupdate_config` resource will be updated to allow cluster
-administrators to turn client tools automatic updates `on` or `off`.
+administrators to turn client tools managed updates `on` or `off`.
A `autoupdate_version` resource will be added to allow cluster administrators
to manage the version of tools pushed to clients.
@@ -222,7 +261,7 @@ to manage the version of tools pushed to clients.
> to `autoupdate_version` on Cloud.
>
> While Cloud customers will be able to use `autoupdate_config` to
-> turn client tools automatic updates `off` and self-manage updates, they will
+> turn client tools managed updates `off` and self-manage updates, they will
> not be able to control the version of client tools in `autoupdate_version`.
> That will continue to be managed by the Teleport Cloud team.
@@ -234,7 +273,7 @@ kind: autoupdate_config
spec:
tools:
# tools mode allows to enable client tools updates or disable at the
- # cluster level. Disable client tools automatic updates only if self-managed
+ # cluster level. Disable client tools managed updates only if self-managed
# updates are in place.
mode: enabled|disabled
```
@@ -292,14 +331,14 @@ frequency.
How Cloud will push changes to `autoupdate_version` is out of scope for this
RFD and will be handled by a separate Cloud specific RFD.
-Automatic updates for Teleport Connect are out of scope for this RFD as it uses
+Managed updates for Teleport Connect are out of scope for this RFD as it uses
a different install/update mechanism. For now it will call `tsh` with
-`TELEPORT_TOOLS_VERSION=off` until automatic updates support can be added to
+`TELEPORT_TOOLS_VERSION=off` until managed updates support can be added to
Connect.
### Security
-The initial version of automatic updates will rely on TLS to establish
+The initial version of managed updates will rely on TLS to establish
connection authenticity to the Teleport download server. The authenticity of
assets served from the download server is out of scope for this RFD. Cluster
administrators concerned with the authenticity of assets served from the
diff --git a/tool/tctl/common/tctl.go b/tool/tctl/common/tctl.go
index c1a47e0acdd..a8f671c5ad3 100644
--- a/tool/tctl/common/tctl.go
+++ b/tool/tctl/common/tctl.go
@@ -26,6 +26,7 @@ import (
"os"
"path/filepath"
"runtime"
+ "strings"
"github.com/alecthomas/kingpin/v2"
"github.com/gravitational/trace"
@@ -73,11 +74,7 @@ type CLICommand interface {
//
// distribution: name of the Teleport distribution
func Run(ctx context.Context, commands []CLICommand) {
- if err := tools.CheckAndUpdateLocal(ctx, os.Args[1:]); err != nil {
- utils.FatalError(err)
- }
-
- err := TryRun(commands, os.Args[1:])
+ err := TryRun(ctx, commands, os.Args[1:])
if err != nil {
var exitError *common.ExitCodeError
if errors.As(err, &exitError) {
@@ -89,9 +86,24 @@ func Run(ctx context.Context, commands []CLICommand) {
// TryRun is a helper function for Run to call - it runs a tctl command and returns an error.
// This is useful for testing tctl, because we can capture the returned error in tests.
-func TryRun(commands []CLICommand, args []string) error {
+func TryRun(ctx context.Context, commands []CLICommand, args []string) error {
utils.InitLogger(utils.LoggingForCLI, slog.LevelWarn)
+ var ccf tctlcfg.GlobalCLIFlags
+ muApp := utils.InitCLIParser("tctl", GlobalHelpString)
+ muApp.Flag("auth-server",
+ fmt.Sprintf("Attempts to connect to specific auth/proxy address(es) instead of local auth [%v]", defaults.AuthConnectAddr().Addr)).
+ Envar(authAddrEnvVar).
+ StringsVar(&ccf.AuthServerAddr)
+
+ // We need to parse the arguments before executing managed updates to identify
+ // the profile name and the required version for the current cluster.
+ // All other commands and flags may change between versions, so full parsing
+ // should be performed only after managed updates are applied.
+ if _, err := muApp.Parse(utils.FilterArguments(args, muApp.Model())); err != nil {
+ slog.WarnContext(ctx, "can't identify current profile", "error", err)
+ }
+
// app is the command line parser
app := utils.InitCLIParser("tctl", GlobalHelpString)
@@ -99,8 +111,18 @@ func TryRun(commands []CLICommand, args []string) error {
// commands
cfg := servicecfg.MakeDefaultConfig()
cfg.CircuitBreakerConfig = breaker.NoopBreakerConfig()
+ cfg.TeleportHome = os.Getenv(types.HomeEnvVar)
+ if cfg.TeleportHome != "" {
+ cfg.TeleportHome = filepath.Clean(cfg.TeleportHome)
+ }
- var ccf tctlcfg.GlobalCLIFlags
+ var name string
+ if len(ccf.AuthServerAddr) != 0 {
+ name = utils.TryHost(strings.TrimPrefix(strings.ToLower(ccf.AuthServerAddr[0]), "https://"))
+ }
+ if err := tools.CheckAndUpdateLocal(ctx, name, args); err != nil {
+ return trace.Wrap(err)
+ }
// Each command will add itself to the CLI parser.
for i := range commands {
@@ -157,14 +179,8 @@ func TryRun(commands []CLICommand, args []string) error {
return trace.BadParameter("tctl --identity also requires --auth-server")
}
- cfg.TeleportHome = os.Getenv(types.HomeEnvVar)
- if cfg.TeleportHome != "" {
- cfg.TeleportHome = filepath.Clean(cfg.TeleportHome)
- }
-
cfg.Debug = ccf.Debug
- ctx := context.Background()
clientFunc := commonclient.GetInitFunc(ccf, cfg)
// Execute whatever is selected.
for _, c := range commands {
diff --git a/tool/tsh/common/autoupdate.go b/tool/tsh/common/autoupdate.go
new file mode 100644
index 00000000000..62f6dca8e5b
--- /dev/null
+++ b/tool/tsh/common/autoupdate.go
@@ -0,0 +1,72 @@
+/*
+ * Teleport
+ * Copyright (C) 2025 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 common
+
+import (
+ "github.com/alecthomas/kingpin/v2"
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/lib/autoupdate/tools"
+)
+
+type autoUpdateCommand struct {
+ update *managedUpdatesUpdateCommand
+}
+
+func newUpdateCommand(app *kingpin.Application) *autoUpdateCommand {
+ root := &autoUpdateCommand{
+ update: &managedUpdatesUpdateCommand{},
+ }
+
+ root.update.CmdClause = app.Command("update",
+ "Update client tools (tsh, tctl) to the latest version defined by the cluster configuration.")
+ root.update.CmdClause.Flag("clear", "Removes locally installed client tools updates from the Teleport home directory.").BoolVar(&root.update.clear)
+
+ return root
+}
+
+// managedUpdatesUpdateCommand additionally check for the latest available client tools
+// version in cluster and runs update.
+type managedUpdatesUpdateCommand struct {
+ *kingpin.CmdClause
+ clear bool
+}
+
+func (c *managedUpdatesUpdateCommand) run(cf *CLIConf) error {
+ if c.clear {
+ toolsDir, err := tools.Dir()
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ if err := tools.CleanUp(toolsDir, tools.DefaultClientTools()); err != nil {
+ return trace.Wrap(err)
+ }
+ return nil
+ }
+
+ tc, err := makeClient(cf)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ if err := tools.DownloadUpdate(cf.Context, tc.WebProxyAddr, tc.InsecureSkipVerify); err != nil {
+ return trace.Wrap(err)
+ }
+
+ return nil
+}
diff --git a/tool/tsh/common/proxy.go b/tool/tsh/common/proxy.go
index 7b1e0fcd3ee..c559525081c 100644
--- a/tool/tsh/common/proxy.go
+++ b/tool/tsh/common/proxy.go
@@ -52,8 +52,8 @@ import (
// onProxyCommandSSH creates a local ssh proxy, dialing a node and transferring
// data through stdin and stdout, to be used as an OpenSSH and PuTTY proxy
// command.
-func onProxyCommandSSH(cf *CLIConf) error {
- tc, err := makeClient(cf)
+func onProxyCommandSSH(cf *CLIConf, initFunc ClientInitFunc) error {
+ tc, err := initFunc(cf)
if err != nil {
return trace.Wrap(err)
}
diff --git a/tool/tsh/common/tctl_test.go b/tool/tsh/common/tctl_test.go
index 94958414f93..13c9a763386 100644
--- a/tool/tsh/common/tctl_test.go
+++ b/tool/tsh/common/tctl_test.go
@@ -140,7 +140,7 @@ func TestRemoteTctlWithProfile(t *testing.T) {
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
- err := common.TryRun(tt.commands, tt.args)
+ err := common.TryRun(context.Background(), tt.commands, tt.args)
if tt.wantErrContains != "" {
var exitError *toolcommon.ExitCodeError
require.ErrorAs(t, err, &exitError)
diff --git a/tool/tsh/common/tsh.go b/tool/tsh/common/tsh.go
index 58a6a829cb6..280b8193ba5 100644
--- a/tool/tsh/common/tsh.go
+++ b/tool/tsh/common/tsh.go
@@ -24,6 +24,7 @@ import (
"errors"
"fmt"
"io"
+ "log/slog"
"maps"
"net"
"net/url"
@@ -143,6 +144,10 @@ var accessRequestModes = []string{
accessRequestModeRole,
}
+// ClientInitFunc defines a function that initiates a connection to
+// the Teleport cluster using the CLI configuration.
+type ClientInitFunc func(cf *CLIConf) (*client.TeleportClient, error)
+
// CLIConf stores command line arguments and flags:
type CLIConf struct {
// UserHost contains "[login]@hostname" argument to SSH command
@@ -645,6 +650,9 @@ type CLIConf struct {
// forkKillFd is the file descriptor for the child process to check the
// parent's state when re-execing.
forkKillFd uint64
+
+ // checkManagedUpdates initiates check of managed update after client connects to cluster.
+ checkManagedUpdates bool
}
func (c *CLIConf) isForkAuthChild() bool {
@@ -773,6 +781,7 @@ const (
requestModeEnvVar = "TELEPORT_REQUEST_MODE"
mcpClientConfigEnvVar = "TELEPORT_MCP_CLIENT_CONFIG"
mcpConfigJSONFormatEnvVar = "TELEPORT_MCP_CONFIG_JSON_FORMAT"
+ toolsCheckUpdateEnvVar = "TELEPORT_TOOLS_CHECK_UPDATE"
clusterHelp = "Specify the Teleport cluster to connect."
browserHelp = "Set to 'none' to suppress browser opening on login."
@@ -806,10 +815,6 @@ type CliOption func(*CLIConf) error
//
// DO NOT RUN TESTS that call Run() in parallel (unless you taken precautions).
func Run(ctx context.Context, args []string, opts ...CliOption) error {
- if err := tools.CheckAndUpdateLocal(ctx, args); err != nil {
- return trace.Wrap(err)
- }
-
cf := CLIConf{
Context: ctx,
TracingProvider: tracing.NoopProvider(),
@@ -817,6 +822,24 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error {
DTAutoEnroll: dtenroll.AutoEnroll,
}
+ // We need to parse the arguments before executing managed updates to identify
+ // the profile name and the required version for the current cluster.
+ // All other commands and flags may change between versions, so full parsing
+ // should be performed only after managed updates are applied.
+ var proxyArg string
+ muApp := utils.InitCLIParser("tsh", "")
+ muApp.Flag("proxy", "Teleport proxy address").Envar(proxyEnvVar).Hidden().StringVar(&proxyArg)
+ muApp.Flag("check-update", "Check for availability of managed update.").Envar(toolsCheckUpdateEnvVar).Hidden().BoolVar(&cf.checkManagedUpdates)
+
+ if _, err := muApp.Parse(utils.FilterArguments(args, muApp.Model())); err != nil {
+ slog.WarnContext(ctx, "can't identify current profile", "error", err)
+ }
+ // Check local update for specific proxy from configuration.
+ name := utils.TryHost(strings.TrimPrefix(strings.ToLower(proxyArg), "https://"))
+ if err := tools.CheckAndUpdateLocal(ctx, name, args); err != nil {
+ return trace.Wrap(err)
+ }
+
// run early to enable debug logging if env var is set.
// this makes it possible to debug early startup functionality, particularly command aliases.
if _, err := initLogger(&cf, utils.LoggingForCLI, parseLoggingOptsFromEnv()); err != nil {
@@ -897,6 +920,7 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error {
StringVar(&cf.MlockMode)
app.HelpFlag.Short('h')
app.Flag("piv-slot", "Specify a PIV slot key to use for Hardware Key support instead of the default. Ex: \"9d\".").Envar("TELEPORT_PIV_SLOT").StringVar(&cf.PIVSlot)
+ app.Flag("check-update", "Check for availability of managed update.").Envar(toolsCheckUpdateEnvVar).Hidden().BoolVar(&cf.checkManagedUpdates)
ver := app.Command("version", "Print the tsh client and Proxy server versions for the current context.")
ver.Flag("format", defaults.FormatFlagDescription(defaults.DefaultFormats...)).Short('f').Default(teleport.Text).EnumVar(&cf.Format, defaults.DefaultFormats...)
@@ -1397,6 +1421,9 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error {
puttyConfig.Hidden()
}
+ // Client-tools managed updates commands.
+ updateCommand := newUpdateCommand(app)
+
// FIDO2, TouchID and WebAuthnWin commands.
f2 := fido2.NewCommand(app)
tid := touchid.NewCommand(app)
@@ -1602,7 +1629,7 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error {
case ver.FullCommand():
err = onVersion(&cf)
case ssh.FullCommand():
- err = onSSH(&cf)
+ err = onSSH(&cf, wrapInitClientWithUpdateCheck(makeClient, args))
case resolve.FullCommand():
err = onResolve(&cf)
// If quiet was specified for this command and
@@ -1735,7 +1762,7 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error {
case scan.keys.FullCommand():
err = scan.keys.run(&cf)
case proxySSH.FullCommand():
- err = onProxyCommandSSH(&cf)
+ err = onProxyCommandSSH(&cf, wrapInitClientWithUpdateCheck(makeClient, args))
case proxyDB.FullCommand():
err = onProxyCommandDB(&cf)
case proxyApp.FullCommand():
@@ -1864,6 +1891,8 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error {
err = mcpCmd.list.run()
case mcpCmd.config.FullCommand():
err = mcpCmd.config.run()
+ case updateCommand.update.FullCommand():
+ err = updateCommand.update.run(&cf)
default:
// Handle commands that might not be available.
switch {
@@ -2146,7 +2175,7 @@ func onLogin(cf *CLIConf, reExecArgs ...string) error {
// in case if parameters match, re-fetch kube clusters and print
// 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 == "":
+ utils.TryHost(cf.Proxy) == utils.TryHost(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.
@@ -2165,7 +2194,7 @@ func onLogin(cf *CLIConf, reExecArgs ...string) error {
return trace.Wrap(printLoginInformation(cf, profile, profiles, cf.getAccessListsToReview(tc)))
// 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) &&
+ case utils.TryHost(cf.Proxy) == utils.TryHost(profile.ProxyURL.Host) &&
cf.SiteName == "" && cf.DesiredRoles == "" && cf.RequestID == "" && cf.IdentityFileOut == "":
// The user has typed `tsh login`, if the running binary needs to
@@ -2198,7 +2227,7 @@ func onLogin(cf *CLIConf, reExecArgs ...string) error {
// proxy is unspecified or the same as the currently provided proxy,
// but cluster is specified, treat this as selecting a new cluster
// for the same proxy
- case (cf.Proxy == "" || host(cf.Proxy) == host(profile.ProxyURL.Host)) && cf.SiteName != "":
+ case (cf.Proxy == "" || utils.TryHost(cf.Proxy) == utils.TryHost(profile.ProxyURL.Host)) && cf.SiteName != "":
_, err := tc.PingAndShowMOTD(cf.Context)
if err != nil {
return trace.Wrap(err)
@@ -2228,7 +2257,7 @@ func onLogin(cf *CLIConf, reExecArgs ...string) error {
// proxy is unspecified or the same as the currently provided proxy,
// but desired roles or request ID is specified, treat this as a
// privilege escalation request for the same login session.
- case (cf.Proxy == "" || host(cf.Proxy) == host(profile.ProxyURL.Host)) && (cf.DesiredRoles != "" || cf.RequestID != "") && cf.IdentityFileOut == "":
+ case (cf.Proxy == "" || utils.TryHost(cf.Proxy) == utils.TryHost(profile.ProxyURL.Host)) && (cf.DesiredRoles != "" || cf.RequestID != "") && cf.IdentityFileOut == "":
_, err := tc.PingAndShowMOTD(cf.Context)
if err != nil {
return trace.Wrap(err)
@@ -4108,7 +4137,7 @@ func onResolve(cf *CLIConf) error {
}
// onSSH executes 'tsh ssh' command
-func onSSH(cf *CLIConf) error {
+func onSSH(cf *CLIConf, initFunc ClientInitFunc) error {
// Handle fork after authentication.
var disownSignal *os.File
var forkAuthSuccessful atomic.Bool
@@ -4157,7 +4186,7 @@ func onSSH(cf *CLIConf) error {
return trace.BadParameter("required argument '[user@]host' not provided")
}
- tc, err := makeClient(cf)
+ tc, err := initFunc(cf)
if err != nil {
return trace.Wrap(err)
}
@@ -4373,6 +4402,23 @@ func makeClient(cf *CLIConf) (*client.TeleportClient, error) {
return tc, trace.Wrap(err)
}
+// wrapInitClientWithUpdateCheck wraps the client initialization function to the Teleport cluster,
+// adding a managed update check immediately after the connection is established.
+func wrapInitClientWithUpdateCheck(clientInitFunc ClientInitFunc, reExecArgs []string) ClientInitFunc {
+ return func(cf *CLIConf) (*client.TeleportClient, error) {
+ tc, err := clientInitFunc(cf)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ if cf.checkManagedUpdates {
+ if err := tools.CheckAndUpdateRemote(cf.Context, tc.WebProxyAddr, tc.InsecureSkipVerify, reExecArgs); err != nil {
+ return nil, trace.Wrap(err)
+ }
+ }
+ return tc, nil
+ }
+}
+
// makeClient takes the command-line configuration and a proxy address and constructs & returns
// a fully configured TeleportClient object
func makeClientForProxy(cf *CLIConf, proxy string) (*client.TeleportClient, error) {
@@ -5480,17 +5526,6 @@ func getTshEnv() map[string]string {
return env
}
-// host is a utility function that extracts
-// host from the host:port pair, in case of any error
-// returns the original value
-func host(in string) string {
- out, err := utils.Host(in)
- if err != nil {
- return in
- }
- return out
-}
-
func awaitRequestResolution(ctx context.Context, clt authclient.ClientI, req types.AccessRequest) (types.AccessRequest, error) {
filter := types.AccessRequestFilter{
User: req.GetUser(),