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)