mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-12 02:15:01 +00:00
Client-tools managed updates version caching (#54563)
* Add profile integration to disable update and re-execution for specific cluster * Complete integration for the tctl and tsh * Add commands for tsh * Fix linter warnings * Add config file with version and disabling status * Move check out from helper * Fixed re-execution ignore if versions is identical * Move logic out from client * Remove helper package and profile integration * Fix argument parsing by filtering * Use same Darwin platform approach of package extraction for Linux Add client tools cleanup for V1 directories * Fix packaging unit test * Add cleanup for last recently used tools * Add migration from v1 for better support Show error log message about failed update/re-execution instead of failing command execution in case if updated binary was broken, modified or not able to validate signature * Add ignore the version check fail, add more debug information * Check update for commands `tsh ssh`, `tsh proxy ssh` Fixed creating `.tsh` subdirectory when TELEPORT_HOME is set Fix `tsh --proxy` flag parsing * Wraps client init function to check client tools managed update only when it requested for `tsh ssh` and `tsh proxy ssh` * Move filesystem lock to configuration library Configuration modification protected by lock, other process must wait until it is released * Rename command to `tsh update`, `tsh update --clear` * Add test for argument filtering * Update RFD Make max tools installed to be configurable and set to 3 by default * Replace "automatic updates" to "managed updates" * Updated comments to reflect the latest changes * Fix migration for older versions with two packages * CR changes * Prevent failing tools execution if configuration file is corrupted * Remove lock file as part of cleanup command * Added context to arguments * Use a separate Kingpin application for tctl, as is already done for tsh. Double parsing may cause issues since it is not stateless.
This commit is contained in:
@@ -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]),
|
||||
|
||||
@@ -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]))
|
||||
}
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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),
|
||||
)
|
||||
}
|
||||
+114
-43
@@ -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)
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
+168
-99
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+29
-13
@@ -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 {
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+58
-23
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user