diff --git a/api/types/constants.go b/api/types/constants.go
index d227c02beea86..de0533224d263 100644
--- a/api/types/constants.go
+++ b/api/types/constants.go
@@ -672,6 +672,14 @@ const (
// id found via automatic discovery, to avoid re-running
// installation commands on the node.
ProjectIDLabel = TeleportInternalLabelPrefix + "project-id"
+ // RegionLabel is used to identify virtual machines by region found
+ // via automatic discovery, to avoid re-running installation commands
+ // on the node.
+ RegionLabel = TeleportInternalLabelPrefix + "region"
+ // ResourceGroupLabel is used to identify virtual machines by resource-group found
+ // via automatic discovery, to avoid re-running installation commands
+ // on the node.
+ ResourceGroupLabel = TeleportInternalLabelPrefix + "resource-group"
// ZoneLabel is used to identify virtual machines by GCP zone
// found via automatic discovery, to avoid re-running installation
// commands on the node.
diff --git a/lib/srv/server/installer/autodiscover.go b/lib/srv/server/installer/autodiscover.go
new file mode 100644
index 0000000000000..0128261b367ab
--- /dev/null
+++ b/lib/srv/server/installer/autodiscover.go
@@ -0,0 +1,460 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package installer
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "net/url"
+ "os"
+ "os/exec"
+ "path"
+ "slices"
+ "sort"
+ "strings"
+
+ "github.com/google/safetext/shsprintf"
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/api"
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/lib/automaticupgrades/constants"
+ "github.com/gravitational/teleport/lib/automaticupgrades/version"
+ "github.com/gravitational/teleport/lib/cloud"
+ "github.com/gravitational/teleport/lib/cloud/gcp"
+ "github.com/gravitational/teleport/lib/cloud/imds"
+ awsimds "github.com/gravitational/teleport/lib/cloud/imds/aws"
+ azureimds "github.com/gravitational/teleport/lib/cloud/imds/azure"
+ gcpimds "github.com/gravitational/teleport/lib/cloud/imds/gcp"
+ "github.com/gravitational/teleport/lib/defaults"
+ "github.com/gravitational/teleport/lib/linux"
+ "github.com/gravitational/teleport/lib/utils"
+ "github.com/gravitational/teleport/lib/utils/packagemanager"
+)
+
+// AutoDiscoverNodeInstallerConfig installs and configures a Teleport Server into the current system.
+type AutoDiscoverNodeInstallerConfig struct {
+ Logger *slog.Logger
+
+ // ProxyPublicAddr is the proxy public address that the instance will connect to.
+ // Eg, example.platform.sh
+ ProxyPublicAddr string
+
+ // TeleportPackage contains the teleport package name.
+ // Allowed values: teleport, teleport-ent
+ TeleportPackage string
+
+ // RepositoryChannel is the repository channel to use.
+ // Eg stable/cloud or stable/rolling
+ RepositoryChannel string
+
+ // AutoUpgrades indicates whether the installed binaries should auto upgrade.
+ // System must support systemd to enable AutoUpgrades.
+ AutoUpgrades bool
+ autoUpgradesChannelURL string
+
+ // AzureClientID is the client ID of the managed identity to use when joining
+ // the cluster. Only applicable for the azure join method.
+ AzureClientID string
+
+ // TokenName is the token name to be used by the instance to join the cluster.
+ TokenName string
+
+ // aptPublicKeyEndpoint contains the URL for the APT public key.
+ // Defaults to: https://apt.releases.teleport.dev/gpg
+ aptPublicKeyEndpoint string
+
+ // fsRootPrefix is the prefix to use when reading operating system information and when installing teleport.
+ // Used for testing.
+ fsRootPrefix string
+
+ // binariesLocation contain the location of each required binary.
+ // Used for testing.
+ binariesLocation packagemanager.BinariesLocation
+
+ // imdsProviders contains the Cloud Instance Metadata providers.
+ // Used for testing.
+ imdsProviders []func(ctx context.Context) (imds.Client, error)
+}
+
+func (c *AutoDiscoverNodeInstallerConfig) checkAndSetDefaults() error {
+ if c == nil {
+ return trace.BadParameter("install teleport config is required")
+ }
+
+ if c.fsRootPrefix == "" {
+ c.fsRootPrefix = "/"
+ }
+
+ if c.ProxyPublicAddr == "" {
+ return trace.BadParameter("proxy public addr is required")
+ }
+
+ if c.Logger == nil {
+ c.Logger = slog.Default()
+ }
+
+ if c.RepositoryChannel == "" {
+ return trace.BadParameter("repository channel is required")
+ }
+
+ if !slices.Contains(types.PackageNameKinds, c.TeleportPackage) {
+ return trace.BadParameter("teleport-package must be one of %+v", types.PackageNameKinds)
+ }
+
+ if c.AutoUpgrades && c.TeleportPackage != types.PackageNameEnt {
+ return trace.BadParameter("only enterprise package supports auto upgrades")
+ }
+
+ if c.autoUpgradesChannelURL == "" {
+ c.autoUpgradesChannelURL = "https://" + c.ProxyPublicAddr + "/v1/webapi/automaticupgrades/channel/default"
+ }
+
+ c.binariesLocation.CheckAndSetDefaults()
+
+ if len(c.imdsProviders) == 0 {
+ c.imdsProviders = []func(ctx context.Context) (imds.Client, error){
+ func(ctx context.Context) (imds.Client, error) {
+ clt, err := awsimds.NewInstanceMetadataClient(ctx)
+ return clt, trace.Wrap(err)
+ },
+ func(ctx context.Context) (imds.Client, error) {
+ return azureimds.NewInstanceMetadataClient(), nil
+ },
+ func(ctx context.Context) (imds.Client, error) {
+ instancesClient, err := gcp.NewInstancesClient(ctx)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ clt, err := gcpimds.NewInstanceMetadataClient(instancesClient)
+ return clt, trace.Wrap(err)
+ },
+ }
+ }
+
+ return nil
+}
+
+// AutoDiscoverNodeInstaller will install teleport in the current system.
+// It's meant to be used by the Server Auto Discover script.
+type AutoDiscoverNodeInstaller struct {
+ *AutoDiscoverNodeInstallerConfig
+}
+
+// NewAutoDiscoverNodeInstaller returns a new AutoDiscoverNodeInstaller.
+func NewAutoDiscoverNodeInstaller(cfg *AutoDiscoverNodeInstallerConfig) (*AutoDiscoverNodeInstaller, error) {
+ if err := cfg.checkAndSetDefaults(); err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ ti := &AutoDiscoverNodeInstaller{
+ AutoDiscoverNodeInstallerConfig: cfg,
+ }
+
+ return ti, nil
+}
+
+const (
+ // exclusiveInstallFileLock is the name of the lockfile to be used when installing teleport.
+ // Used for the default installers (see api/types/installers/{agentless,}installer.sh.tmpl/).
+ exclusiveInstallFileLock = "/var/lock/teleport_install.lock"
+
+ // etcOSReleaseFile is the location of the OS Release information.
+ // This is valid for most linux distros, that rely on systemd.
+ etcOSReleaseFile = "/etc/os-release"
+)
+
+var imdsClientTypeToJoinMethod = map[types.InstanceMetadataType]types.JoinMethod{
+ types.InstanceMetadataTypeAzure: types.JoinMethodAzure,
+ types.InstanceMetadataTypeEC2: types.JoinMethodIAM,
+ types.InstanceMetadataTypeGCP: types.JoinMethodGCP,
+}
+
+// Install teleport in the current system.
+func (ani *AutoDiscoverNodeInstaller) Install(ctx context.Context) error {
+ // Ensure only one installer is running by locking the same file as the script installers.
+ lockFile := ani.buildAbsoluteFilePath(exclusiveInstallFileLock)
+ unlockFn, err := utils.FSTryWriteLock(lockFile)
+ if err != nil {
+ return trace.BadParameter("Could not get lock %s. Either remove it or wait for the other installer to finish.", lockFile)
+ }
+ defer func() {
+ if err := unlockFn(); err != nil {
+ ani.Logger.WarnContext(ctx, "Failed to remove lock. Please remove it manually.", "file", exclusiveInstallFileLock)
+ }
+ }()
+
+ // Check if teleport is already installed.
+ if _, err := os.Stat(ani.binariesLocation.Teleport); err == nil {
+ ani.Logger.InfoContext(ctx, "Teleport is already installed in the system.")
+ return nil
+ }
+
+ teleportYamlConfigurationPath := ani.buildAbsoluteFilePath(defaults.ConfigFilePath)
+ // Check is teleport was already configured.
+ if _, err := os.Stat(teleportYamlConfigurationPath); err == nil {
+ return trace.BadParameter("Teleport configuration already exists at %s. Please remove it manually.", teleportYamlConfigurationPath)
+ }
+
+ imdsClient, err := ani.getIMDSClient(ctx)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ ani.Logger.InfoContext(ctx, "Detected cloud provider", "cloud", imdsClient.GetType())
+
+ if err := ani.installTeleportFromRepo(ctx); err != nil {
+ return trace.Wrap(err)
+ }
+
+ if err := ani.configureTeleportNode(ctx, imdsClient, teleportYamlConfigurationPath); err != nil {
+ return trace.Wrap(err)
+ }
+
+ ani.Logger.InfoContext(ctx, "Enabling and starting teleport service")
+ systemctlEnableNowCMD := exec.CommandContext(ctx, ani.binariesLocation.Systemctl, "enable", "--now", "teleport")
+ systemctlEnableNowCMDOutput, err := systemctlEnableNowCMD.CombinedOutput()
+ if err != nil {
+ return trace.Wrap(err, string(systemctlEnableNowCMDOutput))
+ }
+
+ return nil
+}
+
+func (ani *AutoDiscoverNodeInstaller) configureTeleportNode(ctx context.Context, imdsClient imds.Client, teleportYamlConfigurationPath string) error {
+ nodeLabels, err := fetchNodeAutoDiscoverLabels(ctx, imdsClient)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ // The last step is to configure the `teleport.yaml`.
+ // We could do this using the github.com/gravitational/teleport/lib/config package.
+ // However, that would cause the configuration to use the current running binary which is different from the binary that was just installed.
+ // That could cause problems if the versions are not compatible.
+ // To prevent creating an invalid configuration, the installed binary must be used.
+
+ labelEntries := make([]string, 0, len(nodeLabels))
+ for labelKey, labelValue := range nodeLabels {
+ labelEntries = append(labelEntries, labelKey+"="+labelValue)
+ }
+ sort.Strings(labelEntries)
+ nodeLabelsCommaSeperated := strings.Join(labelEntries, ",")
+
+ joinMethod, ok := imdsClientTypeToJoinMethod[imdsClient.GetType()]
+ if !ok {
+ return trace.BadParameter("Unsupported cloud provider: %v", imdsClient.GetType())
+ }
+
+ teleportNodeConfigureArgs := []string{"node", "configure", "--output=file://" + teleportYamlConfigurationPath,
+ fmt.Sprintf(`--proxy=%s`, shsprintf.EscapeDefaultContext(ani.ProxyPublicAddr)),
+ fmt.Sprintf(`--join-method=%s`, shsprintf.EscapeDefaultContext(string(joinMethod))),
+ fmt.Sprintf(`--token=%s`, shsprintf.EscapeDefaultContext(ani.TokenName)),
+ fmt.Sprintf(`--labels=%s`, shsprintf.EscapeDefaultContext(nodeLabelsCommaSeperated)),
+ }
+ if ani.AzureClientID != "" {
+ teleportNodeConfigureArgs = append(teleportNodeConfigureArgs,
+ fmt.Sprintf(`--azure-client-id=%s`, shsprintf.EscapeDefaultContext(ani.AzureClientID)))
+ }
+
+ ani.Logger.InfoContext(ctx, "Writing teleport configuration", "teleport", ani.binariesLocation.Teleport, "args", teleportNodeConfigureArgs)
+ teleportNodeConfigureCmd := exec.CommandContext(ctx, ani.binariesLocation.Teleport, teleportNodeConfigureArgs...)
+ teleportNodeConfigureCmdOutput, err := teleportNodeConfigureCmd.CombinedOutput()
+ if err != nil {
+ return trace.Wrap(err, string(teleportNodeConfigureCmdOutput))
+ }
+
+ return nil
+}
+
+func (ani *AutoDiscoverNodeInstaller) installTeleportFromRepo(ctx context.Context) error {
+ // Read current system information.
+ linuxInfo, err := ani.linuxDistribution()
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ ani.Logger.InfoContext(ctx, "Operating system detected.",
+ "id", linuxInfo.ID,
+ "id_like", linuxInfo.IDLike,
+ "codename", linuxInfo.VersionCodename,
+ "version_id", linuxInfo.VersionID,
+ )
+
+ packageManager, err := packagemanager.PackageManagerForSystem(linuxInfo, ani.fsRootPrefix, ani.binariesLocation, ani.aptPublicKeyEndpoint)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ targetVersion := ""
+ var packagesToInstall []packagemanager.PackageVersion
+ if ani.AutoUpgrades {
+ teleportAutoUpdaterPackage := ani.TeleportPackage + "-updater"
+
+ ani.Logger.InfoContext(ctx, "Auto-upgrade enabled: fetching target version", "auto_upgrade_endpoint", ani.autoUpgradesChannelURL)
+ targetVersion = ani.fetchTargetVersion(ctx)
+
+ // No target version advertised.
+ if targetVersion == constants.NoVersion {
+ targetVersion = ""
+ }
+ ani.Logger.InfoContext(ctx, "Using teleport version", "version", targetVersion)
+ packagesToInstall = append(packagesToInstall, packagemanager.PackageVersion{Name: teleportAutoUpdaterPackage, Version: targetVersion})
+ }
+ packagesToInstall = append(packagesToInstall, packagemanager.PackageVersion{Name: ani.TeleportPackage, Version: targetVersion})
+
+ if err := packageManager.AddTeleportRepository(ctx, linuxInfo, ani.RepositoryChannel); err != nil {
+ return trace.BadParameter("failed to add teleport repository to system: %v", err)
+ }
+ if err := packageManager.InstallPackages(ctx, packagesToInstall); err != nil {
+ return trace.BadParameter("failed to install teleport: %v", err)
+ }
+
+ return nil
+}
+
+func (ani *AutoDiscoverNodeInstaller) getIMDSClient(ctx context.Context) (imds.Client, error) {
+ // detect and fetch cloud provider metadata
+ imdsClient, err := cloud.DiscoverInstanceMetadata(ctx, ani.imdsProviders)
+ if err != nil {
+ if trace.IsNotFound(err) {
+ return nil, trace.BadParameter("Auto Discover only runs on Cloud instances with IMDS/Metadata service enabled. Ensure the service is running and try again.")
+ }
+ return nil, trace.Wrap(err)
+ }
+
+ return imdsClient, nil
+}
+
+func (ani *AutoDiscoverNodeInstaller) fetchTargetVersion(ctx context.Context) string {
+ upgradeURL, err := url.Parse(ani.autoUpgradesChannelURL)
+ if err != nil {
+ ani.Logger.WarnContext(ctx, "Failed to parse automatic upgrades default channel url, using api version",
+ "channel_url", ani.autoUpgradesChannelURL,
+ "error", err,
+ "version", api.Version)
+ return api.Version
+ }
+
+ targetVersion, err := version.NewBasicHTTPVersionGetter(upgradeURL).GetVersion(ctx)
+ if err != nil {
+ ani.Logger.WarnContext(ctx, "Failed to query target version, using api version",
+ "error", err,
+ "version", api.Version)
+ return api.Version
+ }
+ ani.Logger.InfoContext(ctx, "Found target version",
+ "channel_url", ani.autoUpgradesChannelURL,
+ "version", targetVersion)
+
+ return strings.TrimSpace(strings.TrimPrefix(targetVersion, "v"))
+}
+
+func fetchNodeAutoDiscoverLabels(ctx context.Context, imdsClient imds.Client) (map[string]string, error) {
+ nodeLabels := make(map[string]string)
+
+ switch imdsClient.GetType() {
+ case types.InstanceMetadataTypeAzure:
+ azureIMDSClient, ok := imdsClient.(*azureimds.InstanceMetadataClient)
+ if !ok {
+ return nil, trace.BadParameter("failed to obtain azure imds client")
+ }
+
+ instanceInfo, err := azureIMDSClient.GetInstanceInfo(ctx)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ nodeLabels[types.SubscriptionIDLabel] = instanceInfo.SubscriptionID
+ nodeLabels[types.VMIDLabel] = instanceInfo.VMID
+ nodeLabels[types.RegionLabel] = instanceInfo.Location
+ nodeLabels[types.ResourceGroupLabel] = instanceInfo.ResourceGroupName
+
+ case types.InstanceMetadataTypeEC2:
+ awsIMDSClient, ok := imdsClient.(*awsimds.InstanceMetadataClient)
+ if !ok {
+ return nil, trace.BadParameter("failed to obtain ec2 imds client")
+ }
+ accountID, err := awsIMDSClient.GetAccountID(ctx)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ instanceID, err := awsIMDSClient.GetID(ctx)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ nodeLabels[types.AWSInstanceIDLabel] = instanceID
+ nodeLabels[types.AWSAccountIDLabel] = accountID
+
+ case types.InstanceMetadataTypeGCP:
+ gcpIMDSClient, ok := imdsClient.(*gcpimds.InstanceMetadataClient)
+ if !ok {
+ return nil, trace.BadParameter("failed to obtain gcp imds client")
+ }
+
+ name, err := gcpIMDSClient.GetName(ctx)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ zone, err := gcpIMDSClient.GetZone(ctx)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ projectID, err := gcpIMDSClient.GetProjectID(ctx)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ nodeLabels[types.NameLabel] = name
+ nodeLabels[types.ZoneLabel] = zone
+ nodeLabels[types.ProjectIDLabel] = projectID
+
+ default:
+ return nil, trace.BadParameter("Unsupported cloud provider: %v", imdsClient.GetType())
+ }
+
+ return nodeLabels, nil
+}
+
+// buildAbsoluteFilePath creates the absolute file path
+func (ani *AutoDiscoverNodeInstaller) buildAbsoluteFilePath(filepath string) string {
+ return path.Join(ani.fsRootPrefix, filepath)
+}
+
+// linuxDistribution reads the current file system to detect the Linux Distro and Version of the current system.
+//
+// https://www.freedesktop.org/software/systemd/man/latest/os-release.html
+func (ani *AutoDiscoverNodeInstaller) linuxDistribution() (*linux.OSRelease, error) {
+ f, err := os.Open(ani.buildAbsoluteFilePath(etcOSReleaseFile))
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ defer f.Close()
+
+ osRelease, err := linux.ParseOSReleaseFromReader(f)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ return osRelease, nil
+}
diff --git a/lib/srv/server/installer/autodiscover_test.go b/lib/srv/server/installer/autodiscover_test.go
new file mode 100644
index 0000000000000..2b2980d13bb5c
--- /dev/null
+++ b/lib/srv/server/installer/autodiscover_test.go
@@ -0,0 +1,626 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package installer
+
+import (
+ "context"
+ "io/fs"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path"
+ "strings"
+ "testing"
+
+ "github.com/buildkite/bintest/v3"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/gravitational/teleport/lib/cloud/imds"
+ "github.com/gravitational/teleport/lib/cloud/imds/azure"
+ "github.com/gravitational/teleport/lib/utils/packagemanager"
+)
+
+func buildMockBins(t *testing.T) (map[string]*bintest.Mock, packagemanager.BinariesLocation, []func() error) {
+ mockedBins := []string{"systemctl",
+ "apt-get", "apt-key",
+ "rpm",
+ "yum", "yum-config-manager",
+ "zypper",
+ "teleport",
+ }
+
+ mapMockBins := make(map[string]*bintest.Mock)
+ releaseMockFNs := make([]func() error, 0, len(mockedBins))
+ for _, mockBinName := range mockedBins {
+ mockBin, err := bintest.NewMock(mockBinName)
+ require.NoError(t, err)
+ mapMockBins[mockBinName] = mockBin
+
+ releaseMockFNs = append(releaseMockFNs, mockBin.Close)
+ }
+
+ return mapMockBins, packagemanager.BinariesLocation{
+ Systemctl: mapMockBins["systemctl"].Path,
+ AptGet: mapMockBins["apt-get"].Path,
+ AptKey: mapMockBins["apt-key"].Path,
+ Rpm: mapMockBins["rpm"].Path,
+ Yum: mapMockBins["yum"].Path,
+ YumConfigManager: mapMockBins["yum-config-manager"].Path,
+ Zypper: mapMockBins["zypper"].Path,
+ Teleport: mapMockBins["teleport"].Path,
+ }, releaseMockFNs
+}
+
+func TestAutoDiscoverNode(t *testing.T) {
+ ctx := context.Background()
+
+ mockRepoKeys := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("my-public-key"))
+ }))
+
+ mockBins, binariesLocation, releaseMockedBinsFN := buildMockBins(t)
+ t.Cleanup(func() {
+ for _, releaseMockBin := range releaseMockedBinsFN {
+ assert.NoError(t, releaseMockBin())
+ }
+ })
+
+ azureIMDSServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case strings.HasSuffix(r.URL.Path, "/versions"):
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(`{"apiVersions":["2019-06-04"]}`))
+ default:
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(`{"resourceId":"test-id", "location":"eastus", "resourceGroupName":"TestGroup", ` +
+ `"subscriptionId": "5187AF11-3581-4AB6-A654-59405CD40C44", "vmId":"ED7DAC09-6E73-447F-BD18-AF4D1196C1E4"}`))
+ }
+ }))
+ mockIMDSProviders := []func(ctx context.Context) (imds.Client, error){
+ func(ctx context.Context) (imds.Client, error) {
+ return azure.NewInstanceMetadataClient(azure.WithBaseURL(azureIMDSServer.URL)), nil
+ },
+ }
+
+ t.Run("well known distros", func(t *testing.T) {
+ for distroName, distroVersions := range wellKnownOS {
+ for distroVersion, distroConfig := range distroVersions {
+ t.Run(distroName+":"+distroVersion, func(t *testing.T) {
+ testTempDir := t.TempDir()
+
+ // Common folders to all distros
+ require.NoError(t, os.MkdirAll(path.Join(testTempDir, "/etc"), fs.ModePerm))
+ require.NoError(t, os.MkdirAll(path.Join(testTempDir, "/usr/local/bin"), fs.ModePerm))
+ require.NoError(t, os.MkdirAll(path.Join(testTempDir, "/usr/share"), fs.ModePerm))
+ require.NoError(t, os.MkdirAll(path.Join(testTempDir, "/var/lock"), fs.ModePerm))
+
+ for fileName, contents := range distroConfig {
+ isDir := strings.HasSuffix(fileName, "/")
+ if isDir {
+ require.Empty(t, contents, "expected no contents for directory %q", fileName)
+ require.NoError(t, os.MkdirAll(path.Join(testTempDir, fileName), fs.ModePerm))
+ } else {
+ filePathWithoutParent := path.Base(fileName)
+ require.NoError(t, os.MkdirAll(path.Join(testTempDir, filePathWithoutParent), fs.ModePerm))
+ require.NoError(t, os.WriteFile(path.Join(testTempDir, fileName), []byte(contents), fs.ModePerm))
+ }
+ }
+
+ installerConfig := &AutoDiscoverNodeInstallerConfig{
+ RepositoryChannel: "stable/rolling",
+ AutoUpgrades: false,
+ ProxyPublicAddr: "proxy.example.com",
+ TeleportPackage: "teleport",
+ TokenName: "my-token",
+ AzureClientID: "azure-client-id",
+
+ fsRootPrefix: testTempDir,
+ imdsProviders: mockIMDSProviders,
+ binariesLocation: binariesLocation,
+ aptPublicKeyEndpoint: mockRepoKeys.URL,
+ }
+
+ teleportInstaller, err := NewAutoDiscoverNodeInstaller(installerConfig)
+ require.NoError(t, err)
+
+ // One of the first things the install command does is to check if teleport is already installed.
+ // If so, it stops the installation with success.
+ // Given that we are mocking the binary, it means it already exists and as such, the installation will stop.
+ // To prevent that, we must rename the file, call ` install teleport` and rename it back.
+ teleportInitialPath := mockBins["teleport"].Path
+ teleportHiddenPath := teleportInitialPath + "-hidden"
+ require.NoError(t, os.Rename(teleportInitialPath, teleportHiddenPath))
+
+ switch distroName {
+ case "ubuntu", "debian":
+ mockBins["apt-get"].Expect("update")
+ mockBins["apt-get"].Expect("install", "-y", "teleport").AndCallFunc(func(c *bintest.Call) {
+ assert.NoError(t, os.Rename(teleportHiddenPath, teleportInitialPath))
+ c.Exit(0)
+ })
+ case "amzn", "rhel", "centos":
+ mockBins["yum"].Expect("install", "-y", "yum-utils")
+ mockBins["rpm"].Expect("--eval", bintest.MatchAny())
+ mockBins["yum-config-manager"].Expect("--add-repo", bintest.MatchAny())
+ mockBins["yum"].Expect("install", "-y", "teleport").AndCallFunc(func(c *bintest.Call) {
+ assert.NoError(t, os.Rename(teleportHiddenPath, teleportInitialPath))
+ c.Exit(0)
+ })
+ case "sles":
+ mockBins["rpm"].Expect("--import", packagemanager.ZypperPublicKeyEndpoint)
+ mockBins["rpm"].Expect("--eval", bintest.MatchAny())
+ mockBins["zypper"].Expect("--non-interactive", "addrepo", bintest.MatchAny())
+ mockBins["zypper"].Expect("--gpg-auto-import-keys", "refresh")
+ mockBins["zypper"].Expect("--non-interactive", "install", "-y", "teleport").AndCallFunc(func(c *bintest.Call) {
+ assert.NoError(t, os.Rename(teleportHiddenPath, teleportInitialPath))
+ c.Exit(0)
+ })
+ }
+
+ mockBins["teleport"].Expect("node",
+ "configure",
+ "--output=file://"+testTempDir+"/etc/teleport.yaml",
+ "--proxy=proxy.example.com",
+ "--join-method=azure",
+ "--token=my-token",
+ "--labels=teleport.internal/region=eastus,teleport.internal/resource-group=TestGroup,teleport.internal/subscription-id=5187AF11-3581-4AB6-A654-59405CD40C44,teleport.internal/vm-id=ED7DAC09-6E73-447F-BD18-AF4D1196C1E4",
+ "--azure-client-id=azure-client-id",
+ )
+
+ mockBins["systemctl"].Expect("enable", "--now", "teleport")
+
+ require.NoError(t, teleportInstaller.Install(ctx))
+
+ for binName, mockBin := range mockBins {
+ require.True(t, mockBin.Check(t), "mismatch between expected invocations and actual calls for %q", binName)
+ }
+ })
+ }
+ }
+ })
+
+ t.Run("with automatic upgrades", func(t *testing.T) {
+ distroName := "ubuntu"
+ distroVersion := "24.04"
+ distroConfig := wellKnownOS[distroName][distroVersion]
+
+ proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ // We only expect calls to the automatic upgrade default channel's version endpoint.
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("v15.4.0\n"))
+ }))
+ t.Cleanup(func() {
+ proxyServer.Close()
+ })
+ proxyPublicAddr := proxyServer.Listener.Addr().String()
+
+ testTempDir := t.TempDir()
+
+ require.NoError(t, os.MkdirAll(path.Join(testTempDir, "/etc"), fs.ModePerm))
+ require.NoError(t, os.MkdirAll(path.Join(testTempDir, "/usr/local/bin"), fs.ModePerm))
+ require.NoError(t, os.MkdirAll(path.Join(testTempDir, "/usr/share"), fs.ModePerm))
+ require.NoError(t, os.MkdirAll(path.Join(testTempDir, "/var/lock"), fs.ModePerm))
+
+ for fileName, contents := range distroConfig {
+ isDir := strings.HasSuffix(fileName, "/")
+ if isDir {
+ require.Empty(t, contents, "expected no contents for directory %q", fileName)
+ require.NoError(t, os.MkdirAll(path.Join(testTempDir, fileName), fs.ModePerm))
+ } else {
+ filePathWithoutParent := path.Base(fileName)
+ require.NoError(t, os.MkdirAll(path.Join(testTempDir, filePathWithoutParent), fs.ModePerm))
+ require.NoError(t, os.WriteFile(path.Join(testTempDir, fileName), []byte(contents), fs.ModePerm))
+ }
+ }
+
+ installerConfig := &AutoDiscoverNodeInstallerConfig{
+ RepositoryChannel: "stable/rolling",
+ AutoUpgrades: true,
+ ProxyPublicAddr: proxyPublicAddr,
+ TeleportPackage: "teleport-ent",
+ TokenName: "my-token",
+ AzureClientID: "azure-client-id",
+
+ fsRootPrefix: testTempDir,
+ imdsProviders: mockIMDSProviders,
+ binariesLocation: binariesLocation,
+ aptPublicKeyEndpoint: mockRepoKeys.URL,
+ autoUpgradesChannelURL: proxyServer.URL,
+ }
+
+ teleportInstaller, err := NewAutoDiscoverNodeInstaller(installerConfig)
+ require.NoError(t, err)
+
+ // One of the first things the install command does is to check if teleport is already installed.
+ // If so, it stops the installation with success.
+ // Given that we are mocking the binary, it means it already exists and as such, the installation will stop.
+ // To prevent that, we must rename the file, call ` install teleport` and rename it back.
+ teleportInitialPath := mockBins["teleport"].Path
+ teleportHiddenPath := teleportInitialPath + "-hidden"
+ require.NoError(t, os.Rename(teleportInitialPath, teleportHiddenPath))
+
+ mockBins["apt-get"].Expect("update")
+ mockBins["apt-get"].Expect("install", "-y", "teleport-ent-updater=15.4.0", "teleport-ent=15.4.0").AndCallFunc(func(c *bintest.Call) {
+ assert.NoError(t, os.Rename(teleportHiddenPath, teleportInitialPath))
+ c.Exit(0)
+ })
+
+ mockBins["teleport"].Expect("node",
+ "configure",
+ "--output=file://"+testTempDir+"/etc/teleport.yaml",
+ "--proxy="+proxyPublicAddr,
+ "--join-method=azure",
+ "--token=my-token",
+ "--labels=teleport.internal/region=eastus,teleport.internal/resource-group=TestGroup,teleport.internal/subscription-id=5187AF11-3581-4AB6-A654-59405CD40C44,teleport.internal/vm-id=ED7DAC09-6E73-447F-BD18-AF4D1196C1E4",
+ "--azure-client-id=azure-client-id",
+ )
+
+ mockBins["systemctl"].Expect("enable", "--now", "teleport")
+
+ require.NoError(t, teleportInstaller.Install(ctx))
+
+ for binName, mockBin := range mockBins {
+ require.True(t, mockBin.Check(t), "mismatch between expected invocations and actual calls for %q", binName)
+ }
+ })
+
+ t.Run("fails when imds server is not available", func(t *testing.T) {
+ distroName := "ubuntu"
+ distroVersion := "24.04"
+ distroConfig := wellKnownOS[distroName][distroVersion]
+ proxyPublicAddr := "proxy.example.com"
+
+ testTempDir := t.TempDir()
+
+ require.NoError(t, os.MkdirAll(path.Join(testTempDir, "/etc"), fs.ModePerm))
+ require.NoError(t, os.MkdirAll(path.Join(testTempDir, "/usr/local/bin"), fs.ModePerm))
+ require.NoError(t, os.MkdirAll(path.Join(testTempDir, "/usr/share"), fs.ModePerm))
+ require.NoError(t, os.MkdirAll(path.Join(testTempDir, "/var/lock"), fs.ModePerm))
+
+ for fileName, contents := range distroConfig {
+ isDir := strings.HasSuffix(fileName, "/")
+ if isDir {
+ require.Empty(t, contents, "expected no contents for directory %q", fileName)
+ require.NoError(t, os.MkdirAll(path.Join(testTempDir, fileName), fs.ModePerm))
+ } else {
+ filePathWithoutParent := path.Base(fileName)
+ require.NoError(t, os.MkdirAll(path.Join(testTempDir, filePathWithoutParent), fs.ModePerm))
+ require.NoError(t, os.WriteFile(path.Join(testTempDir, fileName), []byte(contents), fs.ModePerm))
+ }
+ }
+
+ installerConfig := &AutoDiscoverNodeInstallerConfig{
+ RepositoryChannel: "stable/rolling",
+ AutoUpgrades: true,
+ ProxyPublicAddr: proxyPublicAddr,
+ TeleportPackage: "teleport-ent",
+ TokenName: "my-token",
+ AzureClientID: "azure-client-id",
+
+ fsRootPrefix: testTempDir,
+ imdsProviders: []func(ctx context.Context) (imds.Client, error){
+ func(ctx context.Context) (imds.Client, error) {
+ return &imds.DisabledClient{}, nil
+ },
+ },
+ binariesLocation: binariesLocation,
+ aptPublicKeyEndpoint: mockRepoKeys.URL,
+ }
+
+ teleportInstaller, err := NewAutoDiscoverNodeInstaller(installerConfig)
+ require.NoError(t, err)
+
+ // One of the first things the install command does is to check if teleport is already installed.
+ // If so, it stops the installation with success.
+ // Given that we are mocking the binary, it means it already exists and as such, the installation will stop.
+ // To prevent that, we must rename the file, call ` install teleport` and rename it back.
+ teleportInitialPath := mockBins["teleport"].Path
+ teleportHiddenPath := teleportInitialPath + "-hidden"
+ require.NoError(t, os.Rename(teleportInitialPath, teleportHiddenPath))
+
+ err = teleportInstaller.Install(ctx)
+ require.ErrorContains(t, err, "Auto Discover only runs on Cloud instances with IMDS/Metadata service enabled. Ensure the service is running and try again.")
+
+ for binName, mockBin := range mockBins {
+ require.True(t, mockBin.Check(t), "mismatch between expected invocations and actual calls for %q", binName)
+ }
+ })
+}
+
+// wellKnownOS lists the officially supported repositories for Linux Distros
+// (taken from https://goteleport.com/docs/installation/#package-repositories )
+// Debian 9, 10, 11, 12
+// Ubuntu 16.04 + (only LTS versions are tested)
+// Amazon Linux 2 and 2023
+// CentOS 7, 8, 9
+// RHEL 7, 8, 9
+// SLES 12, 15
+var wellKnownOS = map[string]map[string]map[string]string{
+ "debian": {
+ "9": {etcOSReleaseFile: debian9OSRelease, "/usr/share/keyrings/": "", "/etc/apt/sources.list.d/": ""},
+ "10": {etcOSReleaseFile: debian10OSRelease, "/usr/share/keyrings/": "", "/etc/apt/sources.list.d/": ""},
+ "11": {etcOSReleaseFile: debian11OSRelease, "/usr/share/keyrings/": "", "/etc/apt/sources.list.d/": ""},
+ "12": {etcOSReleaseFile: debian12OSRelease, "/usr/share/keyrings/": "", "/etc/apt/sources.list.d/": ""},
+ },
+ "ubuntu": {
+ "18.04": {etcOSReleaseFile: ubuntu1804OSRelease, "/usr/share/keyrings/": "", "/etc/apt/sources.list.d/": ""},
+ "20.04": {etcOSReleaseFile: ubuntu2004OSRelease, "/usr/share/keyrings/": "", "/etc/apt/sources.list.d/": ""},
+ "22.04": {etcOSReleaseFile: ubuntu2204OSRelease, "/usr/share/keyrings/": "", "/etc/apt/sources.list.d/": ""},
+ "24.04": {etcOSReleaseFile: ubuntu2404OSRelease, "/usr/share/keyrings/": "", "/etc/apt/sources.list.d/": ""},
+ },
+ "amzn": {
+ "2": {etcOSReleaseFile: amzn2OSRelease},
+ "2023": {etcOSReleaseFile: amazn2023OSRelease},
+ },
+ "centos": {
+ "7": {etcOSReleaseFile: centos7OSRelease},
+ "8": {etcOSReleaseFile: centos8OSRelease},
+ "9": {etcOSReleaseFile: centos9OSRelease},
+ },
+ "rhel": {
+ "7": {etcOSReleaseFile: rhel7OSRelease},
+ "8": {etcOSReleaseFile: rhel8OSRelease},
+ "9": {etcOSReleaseFile: rhel9OSRelease},
+ },
+ "sles": {
+ "12": {etcOSReleaseFile: sles12OSRelease},
+ "15": {etcOSReleaseFile: sles15OSRelease},
+ },
+}
+
+const (
+ amzn2OSRelease = `NAME="Amazon Linux"
+VERSION="2"
+ID="amzn"
+ID_LIKE="centos rhel fedora"
+VERSION_ID="2"
+PRETTY_NAME="Amazon Linux 2"
+ANSI_COLOR="0;33"
+CPE_NAME="cpe:2.3:o:amazon:amazon_linux:2"
+HOME_URL="https://amazonlinux.com/"
+SUPPORT_END="2025-06-30"`
+
+ amazn2023OSRelease = `NAME="Amazon Linux"
+VERSION="2023"
+ID="amzn"
+ID_LIKE="fedora"
+VERSION_ID="2023"
+PLATFORM_ID="platform:al2023"
+PRETTY_NAME="Amazon Linux 2023.4.20240528"
+ANSI_COLOR="0;33"
+CPE_NAME="cpe:2.3:o:amazon:amazon_linux:2023"
+HOME_URL="https://aws.amazon.com/linux/amazon-linux-2023/"
+DOCUMENTATION_URL="https://docs.aws.amazon.com/linux/"
+SUPPORT_URL="https://aws.amazon.com/premiumsupport/"
+BUG_REPORT_URL="https://github.com/amazonlinux/amazon-linux-2023"
+VENDOR_NAME="AWS"
+VENDOR_URL="https://aws.amazon.com/"
+SUPPORT_END="2028-03-15"`
+
+ centos7OSRelease = `NAME="CentOS Linux"
+VERSION="7 (Core)"
+ID="centos"
+ID_LIKE="rhel fedora"
+VERSION_ID="7"
+PRETTY_NAME="CentOS Linux 7 (Core)"
+ANSI_COLOR="0;31"
+CPE_NAME="cpe:/o:centos:centos:7"
+HOME_URL="https://www.centos.org/"
+BUG_REPORT_URL="https://bugs.centos.org/"
+
+CENTOS_MANTISBT_PROJECT="CentOS-7"
+CENTOS_MANTISBT_PROJECT_VERSION="7"
+REDHAT_SUPPORT_PRODUCT="centos"
+REDHAT_SUPPORT_PRODUCT_VERSION="7"`
+
+ centos8OSRelease = `NAME="CentOS Linux"
+VERSION="8"
+ID="centos"
+ID_LIKE="rhel fedora"
+VERSION_ID="8"
+PLATFORM_ID="platform:el8"
+PRETTY_NAME="CentOS Linux 8"
+ANSI_COLOR="0;31"
+CPE_NAME="cpe:/o:centos:centos:8"
+HOME_URL="https://centos.org/"
+BUG_REPORT_URL="https://bugs.centos.org/"
+CENTOS_MANTISBT_PROJECT="CentOS-8"
+CENTOS_MANTISBT_PROJECT_VERSION="8"`
+
+ centos9OSRelease = `NAME="CentOS Stream"
+VERSION="9"
+ID="centos"
+ID_LIKE="rhel fedora"
+VERSION_ID="9"
+PLATFORM_ID="platform:el9"
+PRETTY_NAME="CentOS Stream 9"
+ANSI_COLOR="0;31"
+LOGO="fedora-logo-icon"
+CPE_NAME="cpe:/o:centos:centos:9"
+HOME_URL="https://centos.org/"
+BUG_REPORT_URL="https://issues.redhat.com/"
+REDHAT_SUPPORT_PRODUCT="Red Hat Enterprise Linux 9"
+REDHAT_SUPPORT_PRODUCT_VERSION="CentOS Stream"`
+
+ ubuntu1804OSRelease = `NAME="Ubuntu"
+VERSION="18.04.6 LTS (Bionic Beaver)"
+ID=ubuntu
+ID_LIKE=debian
+PRETTY_NAME="Ubuntu 18.04.6 LTS"
+VERSION_ID="18.04"
+HOME_URL="https://www.ubuntu.com/"
+SUPPORT_URL="https://help.ubuntu.com/"
+BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
+PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
+VERSION_CODENAME=bionic
+UBUNTU_CODENAME=bionic`
+
+ ubuntu2004OSRelease = `NAME="Ubuntu"
+VERSION="20.04.6 LTS (Focal Fossa)"
+ID=ubuntu
+ID_LIKE=debian
+PRETTY_NAME="Ubuntu 20.04.6 LTS"
+VERSION_ID="20.04"
+HOME_URL="https://www.ubuntu.com/"
+SUPPORT_URL="https://help.ubuntu.com/"
+BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
+PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
+VERSION_CODENAME=focal
+UBUNTU_CODENAME=focal`
+
+ ubuntu2204OSRelease = `PRETTY_NAME="Ubuntu 22.04 LTS"
+NAME="Ubuntu"
+VERSION_ID="22.04"
+VERSION="22.04 LTS (Jammy Jellyfish)"
+VERSION_CODENAME=jammy
+ID=ubuntu
+ID_LIKE=debian
+HOME_URL="https://www.ubuntu.com/"
+SUPPORT_URL="https://help.ubuntu.com/"
+BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
+PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
+UBUNTU_CODENAME=jammy`
+
+ ubuntu2404OSRelease = `PRETTY_NAME="Ubuntu 24.04 LTS"
+NAME="Ubuntu"
+VERSION_ID="24.04"
+VERSION="24.04 LTS (Noble Numbat)"
+VERSION_CODENAME=noble
+ID=ubuntu
+ID_LIKE=debian
+HOME_URL="https://www.ubuntu.com/"
+SUPPORT_URL="https://help.ubuntu.com/"
+BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
+PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
+UBUNTU_CODENAME=noble
+LOGO=ubuntu-logo`
+
+ debian9OSRelease = `PRETTY_NAME="Debian GNU/Linux 9 (stretch)"
+NAME="Debian GNU/Linux"
+VERSION_ID="9"
+VERSION="9 (stretch)"
+VERSION_CODENAME=stretch
+ID=debian
+HOME_URL="https://www.debian.org/"
+SUPPORT_URL="https://www.debian.org/support"
+BUG_REPORT_URL="https://bugs.debian.org/"`
+
+ debian10OSRelease = `PRETTY_NAME="Debian GNU/Linux 10 (buster)"
+NAME="Debian GNU/Linux"
+VERSION_ID="10"
+VERSION="10 (buster)"
+VERSION_CODENAME=buster
+ID=debian
+HOME_URL="https://www.debian.org/"
+SUPPORT_URL="https://www.debian.org/support"
+BUG_REPORT_URL="https://bugs.debian.org/"`
+
+ debian11OSRelease = `PRETTY_NAME="Debian GNU/Linux 11 (bullseye)"
+NAME="Debian GNU/Linux"
+VERSION_ID="11"
+VERSION="11 (bullseye)"
+VERSION_CODENAME=bullseye
+ID=debian
+HOME_URL="https://www.debian.org/"
+SUPPORT_URL="https://www.debian.org/support"
+BUG_REPORT_URL="https://bugs.debian.org/"`
+
+ debian12OSRelease = `PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
+NAME="Debian GNU/Linux"
+VERSION_ID="12"
+VERSION="12 (bookworm)"
+VERSION_CODENAME=bookworm
+ID=debian
+HOME_URL="https://www.debian.org/"
+SUPPORT_URL="https://www.debian.org/support"
+BUG_REPORT_URL="https://bugs.debian.org/"`
+
+ rhel7OSRelease = `NAME="Red Hat Enterprise Linux Server"
+VERSION="7.5 (Maipo)"
+ID="rhel"
+ID_LIKE="fedora"
+VARIANT="Server"
+VARIANT_ID="server"
+VERSION_ID="7.5"
+PRETTY_NAME="Red Hat Enterprise Linux Server 7.5 (Maipo)"
+ANSI_COLOR="0;31"
+CPE_NAME="cpe:/o:redhat:enterprise_linux:7.5:GA:server"
+HOME_URL="https://www.redhat.com/"
+BUG_REPORT_URL="https://bugzilla.redhat.com/"
+
+REDHAT_BUGZILLA_PRODUCT="Red Hat Enterprise Linux 7"
+REDHAT_BUGZILLA_PRODUCT_VERSION=7.5
+REDHAT_SUPPORT_PRODUCT="Red Hat Enterprise Linux"
+REDHAT_SUPPORT_PRODUCT_VERSION="7.5"`
+
+ rhel8OSRelease = `NAME="Red Hat Enterprise Linux"
+VERSION="8.10 (Ootpa)"
+ID="rhel"
+ID_LIKE="fedora"
+VERSION_ID="8.10"
+PLATFORM_ID="platform:el8"
+PRETTY_NAME="Red Hat Enterprise Linux 8.10 (Ootpa)"
+ANSI_COLOR="0;31"
+CPE_NAME="cpe:/o:redhat:enterprise_linux:8::baseos"
+HOME_URL="https://www.redhat.com/"
+DOCUMENTATION_URL="https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8"
+BUG_REPORT_URL="https://bugzilla.redhat.com/"
+
+REDHAT_BUGZILLA_PRODUCT="Red Hat Enterprise Linux 8"
+REDHAT_BUGZILLA_PRODUCT_VERSION=8.10
+REDHAT_SUPPORT_PRODUCT="Red Hat Enterprise Linux"
+REDHAT_SUPPORT_PRODUCT_VERSION="8.10"`
+
+ rhel9OSRelease = `NAME="Red Hat Enterprise Linux"
+VERSION="9.4 (Plow)"
+ID="rhel"
+ID_LIKE="fedora"
+VERSION_ID="9.4"
+PLATFORM_ID="platform:el9"
+PRETTY_NAME="Red Hat Enterprise Linux 9.4 (Plow)"
+ANSI_COLOR="0;31"
+LOGO="fedora-logo-icon"
+CPE_NAME="cpe:/o:redhat:enterprise_linux:9::baseos"
+HOME_URL="https://www.redhat.com/"
+DOCUMENTATION_URL="https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/9"
+BUG_REPORT_URL="https://bugzilla.redhat.com/"
+
+REDHAT_BUGZILLA_PRODUCT="Red Hat Enterprise Linux 9"
+REDHAT_BUGZILLA_PRODUCT_VERSION=9.4
+REDHAT_SUPPORT_PRODUCT="Red Hat Enterprise Linux"
+REDHAT_SUPPORT_PRODUCT_VERSION="9.4"`
+
+ sles12OSRelease = `NAME="SLES"
+VERSION="12-SP3"
+VERSION_ID="12.3"
+PRETTY_NAME="SUSE Linux Enterprise Server 12 SP3"
+ID="sles"
+ANSI_COLOR="0;32"
+CPE_NAME="cpe:/o:suse:sles:12:sp3"`
+
+ sles15OSRelease = `NAME="SLES"
+VERSION="12-SP3"
+VERSION_ID="12.3"
+PRETTY_NAME="SUSE Linux Enterprise Server 12 SP3"
+ID="sles"
+ANSI_COLOR="0;32"
+CPE_NAME="cpe:/o:suse:sles:12:sp3"`
+)
diff --git a/lib/srv/server/installer/defaultinstallers.go b/lib/srv/server/installer/defaultinstallers.go
new file mode 100644
index 0000000000000..541cfa6dd3cf7
--- /dev/null
+++ b/lib/srv/server/installer/defaultinstallers.go
@@ -0,0 +1,51 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package installer
+
+import (
+ "strings"
+
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/lib/web/scripts/oneoff"
+)
+
+// DefaultInstaller represents the default installer script provided by teleport.
+var DefaultInstaller = oneoffScriptToDefaultInstaller()
+
+func oneoffScriptToDefaultInstaller() *types.InstallerV1 {
+ argsList := []string{
+ "install", "autodiscover-node",
+ "--public-proxy-addr={{.PublicProxyAddr}}",
+ "--teleport-package={{.TeleportPackage}}",
+ "--repo-channel={{.RepoChannel}}",
+ "--auto-upgrade={{.AutomaticUpgrades}}",
+ "--azure-client-id={{.AzureClientID}}",
+ }
+
+ script, err := oneoff.BuildScript(oneoff.OneOffScriptParams{
+ TeleportArgs: strings.Join(argsList, " "),
+ SuccessMessage: "Teleport is installed and running.",
+ TeleportCommandPrefix: oneoff.PrefixSUDO,
+ })
+ if err != nil {
+ panic(err)
+ }
+
+ return types.MustNewInstallerV1(types.DefaultInstallerScriptName, script)
+}
diff --git a/lib/utils/packagemanager/apt.go b/lib/utils/packagemanager/apt.go
new file mode 100644
index 0000000000000..f20225231673c
--- /dev/null
+++ b/lib/utils/packagemanager/apt.go
@@ -0,0 +1,191 @@
+// Teleport
+// Copyright (C) 2024 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package packagemanager
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "os"
+ "os/exec"
+ "path"
+ "strings"
+
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport"
+ "github.com/gravitational/teleport/lib/defaults"
+ "github.com/gravitational/teleport/lib/linux"
+ "github.com/gravitational/teleport/lib/utils"
+)
+
+const (
+ productionAPTPublicKeyEndpoint = "https://apt.releases.teleport.dev/gpg"
+ aptRepoEndpoint = "https://apt.releases.teleport.dev/"
+
+ aptTeleportSourceListFileRelative = "/etc/apt/sources.list.d/teleport.list"
+ aptTeleportPublicKeyFileRelative = "/usr/share/keyrings/teleport-archive-keyring.asc"
+
+ aptFilePermsRepository = 0o644
+)
+
+// APT is a wrapper for apt package manager.
+// This package manager is used in Debian/Ubuntu and distros based on this distribution.
+type APT struct {
+ *APTConfig
+
+ // legacy indicates that the old method of adding repos must be used.
+ // This is used in Xenial (16.04) and Trusty (14.04) Ubuntu releases.
+ legacy bool
+
+ httpClient *http.Client
+}
+
+// APTConfig contains the configurable fields for setting up the APT package manager.
+type APTConfig struct {
+ logger *slog.Logger
+ aptPublicKeyEndpoint string
+ fsRootPrefix string
+ bins BinariesLocation
+}
+
+// CheckAndSetDefaults checks and sets default config values.
+func (p *APTConfig) CheckAndSetDefaults() error {
+ if p == nil {
+ return trace.BadParameter("config is required")
+ }
+
+ if p.aptPublicKeyEndpoint == "" {
+ p.aptPublicKeyEndpoint = productionAPTPublicKeyEndpoint
+ }
+
+ p.bins.CheckAndSetDefaults()
+
+ if p.fsRootPrefix == "" {
+ p.fsRootPrefix = "/"
+ }
+
+ if p.logger == nil {
+ p.logger = slog.Default()
+ }
+
+ return nil
+}
+
+// NewAPT creates a new APT package manager.
+func NewAPT(cfg *APTConfig) (*APT, error) {
+ if err := cfg.CheckAndSetDefaults(); err != nil {
+ return nil, trace.Wrap(err)
+ }
+ httpClient, err := defaults.HTTPClient()
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return &APT{APTConfig: cfg, httpClient: httpClient}, nil
+}
+
+// NewAPTLegacy creates a new APT for legacy ubuntu versions (Xenial and Trusty).
+func NewAPTLegacy(cfg *APTConfig) (*APT, error) {
+ pm, err := NewAPT(cfg)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ pm.legacy = true
+ pm.logger = pm.logger.With("legacy", "true")
+ return pm, nil
+}
+
+// AddTeleportRepository adds the Teleport repository to the current system.
+func (pm *APT) AddTeleportRepository(ctx context.Context, linuxInfo *linux.OSRelease, repoChannel string) error {
+ pm.logger.InfoContext(ctx, "Fetching Teleport repository key", "endpoint", pm.aptPublicKeyEndpoint)
+
+ resp, err := pm.httpClient.Get(pm.aptPublicKeyEndpoint)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ defer resp.Body.Close()
+ publicKey, err := utils.ReadAtMost(resp.Body, teleport.MaxHTTPResponseSize)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ aptTeleportSourceListFile := path.Join(pm.fsRootPrefix, aptTeleportSourceListFileRelative)
+ aptTeleportPublicKeyFile := path.Join(pm.fsRootPrefix, aptTeleportPublicKeyFileRelative)
+ // Format for teleport repo entry should look like this:
+ // deb [signed-by=/usr/share/keyrings/teleport-archive-keyring.asc] https://apt.releases.teleport.dev/${ID?} ${VERSION_CODENAME?} $RepoChannel"
+ teleportRepoMetadata := fmt.Sprintf("deb [signed-by=%s] %s%s %s %s", aptTeleportPublicKeyFile, aptRepoEndpoint, linuxInfo.ID, linuxInfo.VersionCodename, repoChannel)
+
+ switch {
+ case pm.legacy:
+ pm.logger.InfoContext(ctx, "Trusting Teleport repository key", "command", "apt-key add -")
+ aptKeyAddCMD := exec.CommandContext(ctx, pm.bins.AptKey, "add", "-")
+ aptKeyAddCMD.Stdin = bytes.NewReader(publicKey)
+ aptKeyAddCMDOutput, err := aptKeyAddCMD.CombinedOutput()
+ if err != nil {
+ return trace.Wrap(err, string(aptKeyAddCMDOutput))
+ }
+ teleportRepoMetadata = fmt.Sprintf("deb %s %s %s", aptRepoEndpoint, linuxInfo.VersionCodename, repoChannel)
+
+ default:
+ pm.logger.InfoContext(ctx, "Writing Teleport repository key", "destination", aptTeleportPublicKeyFile)
+ if err := os.WriteFile(aptTeleportPublicKeyFile, publicKey, aptFilePermsRepository); err != nil {
+ return trace.Wrap(err)
+ }
+ }
+
+ pm.logger.InfoContext(ctx, "Adding repository metadata", "apt_source_file", aptTeleportSourceListFile, "metadata", teleportRepoMetadata)
+ if err := os.WriteFile(aptTeleportSourceListFile, []byte(teleportRepoMetadata), aptFilePermsRepository); err != nil {
+ return trace.Wrap(err)
+ }
+
+ pm.logger.InfoContext(ctx, "Updating apt sources", "command", "apt-get update")
+ updateReposCMD := exec.CommandContext(ctx, pm.bins.AptGet, "update")
+ updateReposCMDOutput, err := updateReposCMD.CombinedOutput()
+ if err != nil {
+ return trace.Wrap(err, string(updateReposCMDOutput))
+ }
+ return nil
+}
+
+// InstallPackages installs one or multiple packages into the current system.
+func (pm *APT) InstallPackages(ctx context.Context, packageList []PackageVersion) error {
+ if len(packageList) == 0 {
+ return nil
+ }
+
+ installArgs := make([]string, 0, len(packageList)+2)
+ installArgs = append(installArgs, "install", "-y")
+
+ for _, pv := range packageList {
+ if pv.Version != "" {
+ installArgs = append(installArgs, pv.Name+"="+pv.Version)
+ continue
+ }
+ installArgs = append(installArgs, pv.Name)
+ }
+
+ pm.logger.InfoContext(ctx, "Installing", "command", "apt-get "+strings.Join(installArgs, " "))
+
+ installPackagesCMD := exec.CommandContext(ctx, pm.bins.AptGet, installArgs...)
+ installPackagesCMDOutput, err := installPackagesCMD.CombinedOutput()
+ if err != nil {
+ return trace.Wrap(err, string(installPackagesCMDOutput))
+ }
+ return nil
+}
diff --git a/lib/utils/packagemanager/distro_info.go b/lib/utils/packagemanager/distro_info.go
new file mode 100644
index 0000000000000..e402a84f0cd26
--- /dev/null
+++ b/lib/utils/packagemanager/distro_info.go
@@ -0,0 +1,93 @@
+// Teleport
+// Copyright (C) 2024 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package packagemanager
+
+import (
+ "context"
+ "slices"
+
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/lib/linux"
+)
+
+// PackageManagerForSystem returns the PackageManager for the current detected linux distro.
+func PackageManagerForSystem(osRelease *linux.OSRelease, fsRootPrefix string, binariesLocation BinariesLocation, aptPubKeyEndpoint string) (PackageManager, error) {
+ aptWellKnownIDs := []string{"debian", "ubuntu"}
+ legacyAPT := []string{"xenial", "trusty"}
+
+ yumWellKnownIDs := []string{"amzn", "rhel", "centos"}
+
+ zypperWellKnownIDs := []string{"sles", "opensuse-tumbleweed", "opensuse-leap"}
+
+ switch {
+ case slices.Contains(aptWellKnownIDs, osRelease.ID):
+ if slices.Contains(legacyAPT, osRelease.VersionCodename) {
+ pm, err := NewAPTLegacy(&APTConfig{
+ fsRootPrefix: fsRootPrefix,
+ bins: binariesLocation,
+ aptPublicKeyEndpoint: aptPubKeyEndpoint,
+ })
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return pm, nil
+ }
+
+ pm, err := NewAPT(&APTConfig{
+ fsRootPrefix: fsRootPrefix,
+ bins: binariesLocation,
+ aptPublicKeyEndpoint: aptPubKeyEndpoint,
+ })
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return pm, nil
+
+ case slices.Contains(yumWellKnownIDs, osRelease.ID):
+ pm, err := NewYUM(&YUMConfig{
+ fsRootPrefix: fsRootPrefix,
+ bins: binariesLocation,
+ })
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return pm, nil
+
+ case slices.Contains(zypperWellKnownIDs, osRelease.ID):
+ pm, err := NewZypper(&ZypperConfig{
+ fsRootPrefix: fsRootPrefix,
+ bins: binariesLocation,
+ })
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return pm, nil
+
+ default:
+ return nil, trace.NotFound("package manager for etc/os-release:ID=%s not found", osRelease.ID)
+ }
+}
+
+// PackageManager describes the required methods to implement a package manager.
+type PackageManager interface {
+ // AddTeleportRepository adds the Teleport repository using a specific channel.
+ AddTeleportRepository(ctx context.Context, ldi *linux.OSRelease, repoChannel string) error
+ // InstallPackages installs a list of packages.
+ // If a PackageVersion does not contain the version, then it will install the latest available.
+ InstallPackages(context.Context, []PackageVersion) error
+}
diff --git a/lib/utils/packagemanager/package_metadata.go b/lib/utils/packagemanager/package_metadata.go
new file mode 100644
index 0000000000000..5d7e320a9fc08
--- /dev/null
+++ b/lib/utils/packagemanager/package_metadata.go
@@ -0,0 +1,24 @@
+// Teleport
+// Copyright (C) 2024 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package packagemanager
+
+// PackageVersion contains the package name and its version.
+// Version can be empty.
+type PackageVersion struct {
+ Name string
+ Version string
+}
diff --git a/lib/utils/packagemanager/system_binaries.go b/lib/utils/packagemanager/system_binaries.go
new file mode 100644
index 0000000000000..8cfe52f000800
--- /dev/null
+++ b/lib/utils/packagemanager/system_binaries.go
@@ -0,0 +1,71 @@
+// Teleport
+// Copyright (C) 2024 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package packagemanager
+
+// BinariesLocation contains all the required external binaries used when installing teleport.
+// Used for testing.
+type BinariesLocation struct {
+ Systemctl string
+
+ AptGet string
+ AptKey string
+
+ Rpm string
+ Yum string
+ YumConfigManager string
+
+ Zypper string
+
+ // teleport represents the expected location of the teleport binary after installing
+ Teleport string
+}
+
+// CheckAndSetDefaults fills in the default values for each binary path.
+// Default location should be used unless this is part of a test.
+func (bi *BinariesLocation) CheckAndSetDefaults() {
+ if bi.Systemctl == "" {
+ bi.Systemctl = "systemctl"
+ }
+
+ if bi.AptGet == "" {
+ bi.AptGet = "apt-get"
+ }
+
+ if bi.AptKey == "" {
+ bi.AptKey = "apt-key"
+ }
+
+ if bi.Rpm == "" {
+ bi.Rpm = "rpm"
+ }
+
+ if bi.Yum == "" {
+ bi.Yum = "yum"
+ }
+
+ if bi.YumConfigManager == "" {
+ bi.YumConfigManager = "yum-config-manager"
+ }
+
+ if bi.Zypper == "" {
+ bi.Zypper = "zypper"
+ }
+
+ if bi.Teleport == "" {
+ bi.Teleport = "/usr/local/bin/teleport"
+ }
+}
diff --git a/lib/utils/packagemanager/yum.go b/lib/utils/packagemanager/yum.go
new file mode 100644
index 0000000000000..7164084fc95b3
--- /dev/null
+++ b/lib/utils/packagemanager/yum.go
@@ -0,0 +1,133 @@
+// Teleport
+// Copyright (C) 2024 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package packagemanager
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os/exec"
+ "strings"
+
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/lib/linux"
+)
+
+const yumRepoEndpoint = "https://yum.releases.teleport.dev/"
+
+// YUM is a wrapper for yum package manager.
+// This package manager is used in RedHat/AmazonLinux/Fedora/CentOS and othe distros.
+type YUM struct {
+ *YUMConfig
+}
+
+// YUMConfig contains the configurable fields for setting up the YUM package manager.
+type YUMConfig struct {
+ logger *slog.Logger
+ bins BinariesLocation
+ fsRootPrefix string
+}
+
+// CheckAndSetDefaults checks and sets default config values.
+func (p *YUMConfig) CheckAndSetDefaults() error {
+ if p == nil {
+ return trace.BadParameter("config is required")
+ }
+
+ p.bins.CheckAndSetDefaults()
+
+ if p.fsRootPrefix == "" {
+ p.fsRootPrefix = "/"
+ }
+
+ if p.logger == nil {
+ p.logger = slog.Default()
+ }
+
+ return nil
+}
+
+// NewYUM creates a new YUM package manager.
+func NewYUM(cfg *YUMConfig) (*YUM, error) {
+ if err := cfg.CheckAndSetDefaults(); err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return &YUM{YUMConfig: cfg}, nil
+}
+
+// AddTeleportRepository adds the Teleport repository to the current system.
+func (pm *YUM) AddTeleportRepository(ctx context.Context, linuxInfo *linux.OSRelease, repoChannel string) error {
+ // Teleport repo only targets the major version of the target distros.
+ versionID := strings.Split(linuxInfo.VersionID, ".")[0]
+
+ pm.logger.InfoContext(ctx, "Installing yum-utils", "command", "yum install -y yum-utils")
+ installYumUtilsCMD := exec.CommandContext(ctx, pm.bins.Yum, "install", "-y", "yum-utils")
+ installYumUtilsCMDOutput, err := installYumUtilsCMD.CombinedOutput()
+ if err != nil {
+ return trace.Wrap(err, string(installYumUtilsCMDOutput))
+ }
+
+ // Repo location looks like this:
+ // https://yum.releases.teleport.dev/$ID/$VERSION_ID/Teleport/%{_arch}/{{ .RepoChannel }}/teleport.repo
+ repoLocation := fmt.Sprintf("%s%s/%s/Teleport/%%{_arch}/%s/teleport.repo", yumRepoEndpoint, linuxInfo.ID, versionID, repoChannel)
+ pm.logger.InfoContext(ctx, "Building rpm metadata for Teleport repo", "command", "rpm --eval "+repoLocation)
+ rpmEvalTeleportRepoCMD := exec.CommandContext(ctx, pm.bins.Rpm, "--eval", repoLocation)
+ rpmEvalTeleportRepoCMDOutput, err := rpmEvalTeleportRepoCMD.CombinedOutput()
+ if err != nil {
+ return trace.Wrap(err, string(rpmEvalTeleportRepoCMDOutput))
+ }
+
+ // output from the command above might have a `\n` at the end.
+ repoURL := strings.TrimSpace(string(rpmEvalTeleportRepoCMDOutput))
+
+ pm.logger.InfoContext(ctx, "Adding repository metadata", "command", "yum-config-manager --add-repo "+repoURL)
+ yumAddRepoCMD := exec.CommandContext(ctx, pm.bins.YumConfigManager, "--add-repo", repoURL)
+ yumAddRepoCMDOutput, err := yumAddRepoCMD.CombinedOutput()
+ if err != nil {
+ return trace.Wrap(err, string(yumAddRepoCMDOutput))
+ }
+
+ return nil
+}
+
+// InstallPackages installs one or multiple packages into the current system.
+func (pm *YUM) InstallPackages(ctx context.Context, packageList []PackageVersion) error {
+ if len(packageList) == 0 {
+ return nil
+ }
+
+ installArgs := make([]string, 0, len(packageList)+2)
+ installArgs = append(installArgs, "install", "-y")
+
+ for _, pv := range packageList {
+ if pv.Version != "" {
+ installArgs = append(installArgs, pv.Name+"-"+pv.Version)
+ continue
+ }
+ installArgs = append(installArgs, pv.Name)
+ }
+
+ pm.logger.InfoContext(ctx, "Installing", "command", "yum "+strings.Join(installArgs, " "))
+
+ installPackagesCMD := exec.CommandContext(ctx, pm.bins.Yum, installArgs...)
+ installPackagesCMDOutput, err := installPackagesCMD.CombinedOutput()
+ if err != nil {
+ return trace.Wrap(err, string(installPackagesCMDOutput))
+ }
+ return nil
+}
diff --git a/lib/utils/packagemanager/zypper.go b/lib/utils/packagemanager/zypper.go
new file mode 100644
index 0000000000000..45b2197d29343
--- /dev/null
+++ b/lib/utils/packagemanager/zypper.go
@@ -0,0 +1,147 @@
+// Teleport
+// Copyright (C) 2024 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package packagemanager
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os/exec"
+ "strings"
+
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/lib/linux"
+)
+
+const (
+ // ZypperPublicKeyEndpoint is the endpoint that contains the Teleport's GPG production Key.
+ ZypperPublicKeyEndpoint = "https://zypper.releases.teleport.dev/gpg"
+ // zypperRepoEndpoint is the repo endpoint for Zypper based distros.
+ zypperRepoEndpoint = "https://zypper.releases.teleport.dev/"
+)
+
+// Zypper is a wrapper for apt package manager.
+// This package manager is used in OpenSUSE/SLES and distros based on this distribution.
+type Zypper struct {
+ *ZypperConfig
+}
+
+// ZypperConfig contains the configurable fields for setting up the Zypper package manager.
+type ZypperConfig struct {
+ logger *slog.Logger
+ bins BinariesLocation
+ fsRootPrefix string
+}
+
+// CheckAndSetDefaults checks and sets default config values.
+func (p *ZypperConfig) CheckAndSetDefaults() error {
+ if p == nil {
+ return trace.BadParameter("config is required")
+ }
+
+ p.bins.CheckAndSetDefaults()
+
+ if p.fsRootPrefix == "" {
+ p.fsRootPrefix = "/"
+ }
+
+ if p.logger == nil {
+ p.logger = slog.Default()
+ }
+
+ return nil
+}
+
+// NewZypper creates a new Zypper package manager.
+func NewZypper(cfg *ZypperConfig) (*Zypper, error) {
+ if err := cfg.CheckAndSetDefaults(); err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return &Zypper{ZypperConfig: cfg}, nil
+}
+
+// AddTeleportRepository adds the Teleport repository to the current system.
+func (pm *Zypper) AddTeleportRepository(ctx context.Context, linuxInfo *linux.OSRelease, repoChannel string) error {
+ // Teleport repo only targets the major version of the target distros.
+ versionID := strings.Split(linuxInfo.VersionID, ".")[0]
+
+ if linuxInfo.ID == "opensuse-tumbleweed" {
+ versionID = "15" // tumbleweed uses dated VERSION_IDs like 20230702
+ }
+
+ pm.logger.InfoContext(ctx, "Trusting Teleport repository key", "command", "rpm --import "+ZypperPublicKeyEndpoint)
+ importPublicKeyCMD := exec.CommandContext(ctx, pm.bins.Rpm, "--import", ZypperPublicKeyEndpoint)
+ importPublicKeyCMDOutput, err := importPublicKeyCMD.CombinedOutput()
+ if err != nil {
+ return trace.Wrap(err, string(importPublicKeyCMDOutput))
+ }
+
+ // Repo location looks like this:
+ // https://yum.releases.teleport.dev/$ID/$VERSION_ID/Teleport/%{_arch}/{{ .RepoChannel }}/teleport.repo
+ repoLocation := fmt.Sprintf("%s%s/%s/Teleport/%%{_arch}/%s/teleport.repo", zypperRepoEndpoint, linuxInfo.ID, versionID, repoChannel)
+ pm.logger.InfoContext(ctx, "Building rpm metadata for Teleport repo", "command", "rpm --eval "+repoLocation)
+ rpmEvalTeleportRepoCMD := exec.CommandContext(ctx, pm.bins.Rpm, "--eval", repoLocation)
+ rpmEvalTeleportRepoCMDOutput, err := rpmEvalTeleportRepoCMD.CombinedOutput()
+ if err != nil {
+ return trace.Wrap(err, string(rpmEvalTeleportRepoCMDOutput))
+ }
+
+ // output from the command above might have a `\n` at the end.
+ repoURL := strings.TrimSpace(string(rpmEvalTeleportRepoCMDOutput))
+ pm.logger.InfoContext(ctx, "Adding repository metadata", "command", "zypper --non-interactive addrepo "+repoURL)
+ zypperAddRepoCMD := exec.CommandContext(ctx, pm.bins.Zypper, "--non-interactive", "addrepo", repoURL)
+ zypperAddRepoCMDOutput, err := zypperAddRepoCMD.CombinedOutput()
+ if err != nil {
+ return trace.Wrap(err, string(zypperAddRepoCMDOutput))
+ }
+
+ pm.logger.InfoContext(ctx, "Refresh public keys", "command", "zypper --gpg-auto-import-keys refresh")
+ zypperRefreshKeysCMD := exec.CommandContext(ctx, pm.bins.Zypper, "--gpg-auto-import-keys", "refresh")
+ zypperRefreshKeysCMDOutput, err := zypperRefreshKeysCMD.CombinedOutput()
+ if err != nil {
+ return trace.Wrap(err, string(zypperRefreshKeysCMDOutput))
+ }
+
+ return nil
+}
+
+// InstallPackages installs one or multiple packages into the current system.
+func (pm *Zypper) InstallPackages(ctx context.Context, packageList []PackageVersion) error {
+ if len(packageList) == 0 {
+ return nil
+ }
+
+ installArgs := make([]string, 0, len(packageList)+3)
+ installArgs = append(installArgs, "--non-interactive", "install", "-y")
+
+ for _, pv := range packageList {
+ if pv.Version != "" {
+ installArgs = append(installArgs, pv.Name+"-"+pv.Version)
+ continue
+ }
+ installArgs = append(installArgs, pv.Name)
+ }
+
+ pm.logger.InfoContext(ctx, "Installing", "command", "zypper "+strings.Join(installArgs, " "))
+ installPackagesCMD := exec.CommandContext(ctx, pm.bins.Zypper, installArgs...)
+ installPackagesCMDOutput, err := installPackagesCMD.CombinedOutput()
+ if err != nil {
+ return trace.Wrap(err, string(installPackagesCMDOutput))
+ }
+ return nil
+}
diff --git a/tool/teleport/common/install.go b/tool/teleport/common/install.go
new file mode 100644
index 0000000000000..bfe71c50c6f10
--- /dev/null
+++ b/tool/teleport/common/install.go
@@ -0,0 +1,82 @@
+// Teleport
+// Copyright (C) 2024 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package common
+
+import (
+ "context"
+ "log/slog"
+
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/api/utils"
+ "github.com/gravitational/teleport/lib/srv/server/installer"
+ libutils "github.com/gravitational/teleport/lib/utils"
+)
+
+// installAutoDiscoverNodeFlags contains the required flags used to install, configure and start a teleport process.
+type installAutoDiscoverNodeFlags struct {
+ // ProxyPublicAddr is the proxy public address that the instance will connect to.
+ // Eg, proxy.example.com
+ ProxyPublicAddr string
+
+ // TeleportPackage contains the teleport package name.
+ // Allowed values: teleport, teleport-ent
+ TeleportPackage string
+
+ // RepositoryChannel is the repository channel to use.
+ // Eg stable/cloud or stable/rolling
+ RepositoryChannel string
+
+ // AutoUpgradesString indicates whether the installed binaries should auto upgrade.
+ // System must use systemd to enable AutoUpgrades.
+ // Bool CLI Flags can only be used as `--flag1` for true or `--no-flag1` for false.
+ // However, we can only inject a string value here, so we must use a String Flag and then parse its value.
+ AutoUpgradesString string
+
+ // AzureClientID is the client ID of the managed identity to use when joining
+ // the cluster. Only applicable for the azure join method.
+ AzureClientID string
+
+ // TokenName is the token name to be used by the instance to join the cluster.
+ TokenName string
+}
+
+// onInstallAutoDiscoverNode is the handler of the "install autodiscover-node" CLI command.
+func onInstallAutoDiscoverNode(cfg installAutoDiscoverNodeFlags) error {
+ ctx := context.Background()
+ // Ensure we print output to the user. LogLevel at this point was set to Error.
+ libutils.InitLogger(libutils.LoggingForDaemon, slog.LevelInfo)
+
+ autoUpgrades, err := utils.ParseBool(cfg.AutoUpgradesString)
+ if err != nil {
+ return trace.BadParameter("--auto-upgrade must be either true or false")
+ }
+
+ teleportInstaller, err := installer.NewAutoDiscoverNodeInstaller(&installer.AutoDiscoverNodeInstallerConfig{
+ ProxyPublicAddr: cfg.ProxyPublicAddr,
+ TeleportPackage: cfg.TeleportPackage,
+ RepositoryChannel: cfg.RepositoryChannel,
+ AutoUpgrades: autoUpgrades,
+ AzureClientID: cfg.AzureClientID,
+ TokenName: cfg.TokenName,
+ })
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ return trace.Wrap(teleportInstaller.Install(ctx))
+}
diff --git a/tool/teleport/common/teleport.go b/tool/teleport/common/teleport.go
index e07e08478a89b..013a316e62fb8 100644
--- a/tool/teleport/common/teleport.go
+++ b/tool/teleport/common/teleport.go
@@ -89,6 +89,7 @@ func Run(options Options) (app *kingpin.Application, executedCommand string, con
configureDiscoveryBootstrapFlags configureDiscoveryBootstrapFlags
dbConfigCreateFlags createDatabaseConfigFlags
systemdInstallFlags installSystemdFlags
+ installAutoDiscoverNodeFlags installAutoDiscoverNodeFlags
waitFlags waitFlags
rawVersion bool
)
@@ -368,6 +369,15 @@ func Run(options Options) (app *kingpin.Application, executedCommand string, con
systemdInstall.Flag("output", "Write to stdout with -o=stdout or custom path with -o=file:///path").Short('o').Default(teleport.SchemeStdout).StringVar(&systemdInstallFlags.output)
systemdInstall.Alias(systemdInstallExamples) // We're using "alias" section to display usage examples.
+ // This command is hidden because it is only meant to be used by the AutoDiscover script.
+ installAutoDiscoverNode := installCmd.Command("autodiscover-node", "Installs, configures and starts teleport as a Node (used in Server Auto Discovery).").Hidden()
+ installAutoDiscoverNode.Flag("public-proxy-addr", "Teleport public proxy address. Eg https://example.teleport.sh").StringVar(&installAutoDiscoverNodeFlags.ProxyPublicAddr)
+ installAutoDiscoverNode.Flag("teleport-package", "Package name to install. Allowed: teleport or teleport-ent").StringVar(&installAutoDiscoverNodeFlags.TeleportPackage)
+ installAutoDiscoverNode.Flag("repo-channel", "Repository channel to use (eg stable/cloud, stable/rolling or stable/vX).").StringVar(&installAutoDiscoverNodeFlags.RepositoryChannel)
+ installAutoDiscoverNode.Flag("auto-upgrade", "Enables auto-upgrades. Allowed: true or false").StringVar(&installAutoDiscoverNodeFlags.AutoUpgradesString)
+ installAutoDiscoverNode.Flag("azure-client-id", "Azure Client ID when installing in an Azure VM with multiple assigned identities.").StringVar(&installAutoDiscoverNodeFlags.AzureClientID)
+ installAutoDiscoverNode.Arg("token", "Token to use to register with the cluster.").Required().StringVar(&installAutoDiscoverNodeFlags.TokenName)
+
// define a hidden 'scp' command (it implements server-side implementation of handling
// 'scp' requests)
scpc.Flag("t", "sink mode (data consumer)").Short('t').Default("false").BoolVar(&scpFlags.Sink)
@@ -658,6 +668,8 @@ Examples:
err = onConfigureDiscoveryBootstrap(configureDiscoveryBootstrapFlags)
case systemdInstall.FullCommand():
err = onDumpSystemdUnitFile(systemdInstallFlags)
+ case installAutoDiscoverNode.FullCommand():
+ err = onInstallAutoDiscoverNode(installAutoDiscoverNodeFlags)
case discoveryBootstrapCmd.FullCommand():
configureDiscoveryBootstrapFlags.config.Service = configurators.DiscoveryService
err = onConfigureDiscoveryBootstrap(configureDiscoveryBootstrapFlags)