diff --git a/lib/srv/server/installer/autodiscover.go b/lib/srv/server/installer/autodiscover.go index 0128261b367ab..3af2a5dbe83aa 100644 --- a/lib/srv/server/installer/autodiscover.go +++ b/lib/srv/server/installer/autodiscover.go @@ -20,7 +20,10 @@ package installer import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" + "io" "log/slog" "net/url" "os" @@ -180,6 +183,9 @@ const ( // etcOSReleaseFile is the location of the OS Release information. // This is valid for most linux distros, that rely on systemd. etcOSReleaseFile = "/etc/os-release" + + // teleportYamlConfigNewExtension is the extension used to indicate that this is a new target teleport.yaml version + teleportYamlConfigNewExtension = ".new" ) var imdsClientTypeToJoinMethod = map[types.InstanceMetadataType]types.JoinMethod{ @@ -198,47 +204,65 @@ func (ani *AutoDiscoverNodeInstaller) Install(ctx context.Context) error { } defer func() { if err := unlockFn(); err != nil { - ani.Logger.WarnContext(ctx, "Failed to remove lock. Please remove it manually.", "file", exclusiveInstallFileLock) + ani.Logger.WarnContext(ctx, "Failed to remove lock. Please remove it manually.", "file", lockFile) } }() - // 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 { + // Check if teleport is already installed and install it, if it's absent + if _, err := os.Stat(ani.binariesLocation.Teleport); err != nil { + ani.Logger.InfoContext(ctx, "Installing teleport") + if err := ani.installTeleportFromRepo(ctx); err != nil { + return trace.Wrap(err) + } + } + + if err := ani.configureTeleportNode(ctx, imdsClient); err != nil { + if trace.IsAlreadyExists(err) { + ani.Logger.InfoContext(ctx, "Configuration at /etc/teleport.yaml already exists and has the same values, skipping teleport.service restart") + // Restarting teleport is not required because the target teleport.yaml + // is up to date with the existing one. + return nil + } + return trace.Wrap(err) } + ani.Logger.InfoContext(ctx, "Configuration written at /etc/teleport.yaml") - if err := ani.configureTeleportNode(ctx, imdsClient, teleportYamlConfigurationPath); err != nil { + ani.Logger.InfoContext(ctx, "Enabling and starting teleport.service") + if err := ani.enableAndRestartTeleportService(ctx); 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") + return nil +} + +// enableAndRestartTeleportService will enable and (re)start the teleport.service. +// This function must be idempotent because we can call it in either one of the following scenarios: +// - teleport was just installed and teleport.service is inactive +// - teleport was already installed but the service is failing +func (ani *AutoDiscoverNodeInstaller) enableAndRestartTeleportService(ctx context.Context) error { + systemctlEnableNowCMD := exec.CommandContext(ctx, ani.binariesLocation.Systemctl, "enable", "teleport") systemctlEnableNowCMDOutput, err := systemctlEnableNowCMD.CombinedOutput() if err != nil { return trace.Wrap(err, string(systemctlEnableNowCMDOutput)) } + systemctlRestartCMD := exec.CommandContext(ctx, ani.binariesLocation.Systemctl, "restart", "teleport") + systemctlRestartCMDOutput, err := systemctlRestartCMD.CombinedOutput() + if err != nil { + return trace.Wrap(err, string(systemctlRestartCMDOutput)) + } + return nil } -func (ani *AutoDiscoverNodeInstaller) configureTeleportNode(ctx context.Context, imdsClient imds.Client, teleportYamlConfigurationPath string) error { +func (ani *AutoDiscoverNodeInstaller) configureTeleportNode(ctx context.Context, imdsClient imds.Client) error { nodeLabels, err := fetchNodeAutoDiscoverLabels(ctx, imdsClient) if err != nil { return trace.Wrap(err) @@ -262,7 +286,9 @@ func (ani *AutoDiscoverNodeInstaller) configureTeleportNode(ctx context.Context, return trace.BadParameter("Unsupported cloud provider: %v", imdsClient.GetType()) } - teleportNodeConfigureArgs := []string{"node", "configure", "--output=file://" + teleportYamlConfigurationPath, + teleportYamlConfigurationPath := ani.buildAbsoluteFilePath(defaults.ConfigFilePath) + teleportYamlConfigurationPathNew := teleportYamlConfigurationPath + teleportYamlConfigNewExtension + teleportNodeConfigureArgs := []string{"node", "configure", "--output=file://" + teleportYamlConfigurationPathNew, 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)), @@ -273,16 +299,58 @@ func (ani *AutoDiscoverNodeInstaller) configureTeleportNode(ctx context.Context, fmt.Sprintf(`--azure-client-id=%s`, shsprintf.EscapeDefaultContext(ani.AzureClientID))) } - ani.Logger.InfoContext(ctx, "Writing teleport configuration", "teleport", ani.binariesLocation.Teleport, "args", teleportNodeConfigureArgs) + ani.Logger.InfoContext(ctx, "Generating 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)) } + defer func() { + // If an error occurs before the os.Rename, let's remove the `.new` file to prevent any leftovers. + // Error is ignored because the file might be already removed. + _ = os.Remove(teleportYamlConfigurationPathNew) + }() + + // Check if file already exists and has the same content that we are about to write + if _, err := os.Stat(teleportYamlConfigurationPath); err == nil { + hashExistingFile, err := checksum(teleportYamlConfigurationPath) + if err != nil { + return trace.Wrap(err) + } + + hashNewFile, err := checksum(teleportYamlConfigurationPathNew) + if err != nil { + return trace.Wrap(err) + } + + if hashExistingFile == hashNewFile { + return trace.AlreadyExists("teleport.yaml is up to date") + } + } + + if err := os.Rename(teleportYamlConfigurationPathNew, teleportYamlConfigurationPath); err != nil { + return trace.Wrap(err) + } + return nil } +func checksum(filename string) (string, error) { + f, err := utils.OpenFileNoUnsafeLinks(filename) + if err != nil { + return "", trace.Wrap(err) + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", trace.Wrap(err) + } + + return hex.EncodeToString(h.Sum(nil)), nil +} + func (ani *AutoDiscoverNodeInstaller) installTeleportFromRepo(ctx context.Context) error { // Read current system information. linuxInfo, err := ani.linuxDistribution() diff --git a/lib/srv/server/installer/autodiscover_test.go b/lib/srv/server/installer/autodiscover_test.go index 2b2980d13bb5c..cb940ef1d7815 100644 --- a/lib/srv/server/installer/autodiscover_test.go +++ b/lib/srv/server/installer/autodiscover_test.go @@ -68,6 +68,25 @@ func buildMockBins(t *testing.T) (map[string]*bintest.Mock, packagemanager.Binar }, releaseMockFNs } +func setupDirsForTest(t *testing.T, testTempDir string, distroConfig map[string]string) { + 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)) + } + } +} + func TestAutoDiscoverNode(t *testing.T) { ctx := context.Background() @@ -107,22 +126,7 @@ func TestAutoDiscoverNode(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)) - } - } + setupDirsForTest(t, testTempDir, distroConfig) installerConfig := &AutoDiscoverNodeInstallerConfig{ RepositoryChannel: "stable/rolling", @@ -177,15 +181,20 @@ func TestAutoDiscoverNode(t *testing.T) { mockBins["teleport"].Expect("node", "configure", - "--output=file://"+testTempDir+"/etc/teleport.yaml", + "--output=file://"+testTempDir+"/etc/teleport.yaml.new", "--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", - ) + ).AndCallFunc(func(c *bintest.Call) { + // create a teleport.yaml configuration file + require.NoError(t, os.WriteFile(testTempDir+"/etc/teleport.yaml.new", []byte("teleport.yaml configuration bytes"), 0o644)) + c.Exit(0) + }) - mockBins["systemctl"].Expect("enable", "--now", "teleport") + mockBins["systemctl"].Expect("enable", "teleport") + mockBins["systemctl"].Expect("restart", "teleport") require.NoError(t, teleportInstaller.Install(ctx)) @@ -198,9 +207,7 @@ func TestAutoDiscoverNode(t *testing.T) { }) t.Run("with automatic upgrades", func(t *testing.T) { - distroName := "ubuntu" - distroVersion := "24.04" - distroConfig := wellKnownOS[distroName][distroVersion] + distroConfig := wellKnownOS["ubuntu"]["24.04"] proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { // We only expect calls to the automatic upgrade default channel's version endpoint. @@ -214,22 +221,7 @@ func TestAutoDiscoverNode(t *testing.T) { 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)) - } - } + setupDirsForTest(t, testTempDir, distroConfig) installerConfig := &AutoDiscoverNodeInstallerConfig{ RepositoryChannel: "stable/rolling", @@ -265,15 +257,20 @@ func TestAutoDiscoverNode(t *testing.T) { mockBins["teleport"].Expect("node", "configure", - "--output=file://"+testTempDir+"/etc/teleport.yaml", + "--output=file://"+testTempDir+"/etc/teleport.yaml.new", "--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", - ) + ).AndCallFunc(func(c *bintest.Call) { + // create a teleport.yaml configuration file + require.NoError(t, os.WriteFile(testTempDir+"/etc/teleport.yaml.new", []byte("teleport.yaml configuration bytes"), 0o644)) + c.Exit(0) + }) - mockBins["systemctl"].Expect("enable", "--now", "teleport") + mockBins["systemctl"].Expect("enable", "teleport") + mockBins["systemctl"].Expect("restart", "teleport") require.NoError(t, teleportInstaller.Install(ctx)) @@ -283,29 +280,12 @@ func TestAutoDiscoverNode(t *testing.T) { }) t.Run("fails when imds server is not available", func(t *testing.T) { - distroName := "ubuntu" - distroVersion := "24.04" - distroConfig := wellKnownOS[distroName][distroVersion] + distroConfig := wellKnownOS["ubuntu"]["24.04"] 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)) - } - } + setupDirsForTest(t, testTempDir, distroConfig) installerConfig := &AutoDiscoverNodeInstallerConfig{ RepositoryChannel: "stable/rolling", @@ -328,14 +308,6 @@ func TestAutoDiscoverNode(t *testing.T) { 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.") @@ -343,6 +315,131 @@ func TestAutoDiscoverNode(t *testing.T) { require.True(t, mockBin.Check(t), "mismatch between expected invocations and actual calls for %q", binName) } }) + + t.Run("reconfigures and restarts if target teleport.yaml is different", func(t *testing.T) { + distroConfig := wellKnownOS["ubuntu"]["24.04"] + + testTempDir := t.TempDir() + + setupDirsForTest(t, testTempDir, distroConfig) + + 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) + + // package manager is not called in this scenario because teleport binary already exists in the system + require.FileExists(t, mockBins["teleport"].Path) + + // create an existing teleport.yaml configuration file + require.NoError(t, os.WriteFile(testTempDir+"/etc/teleport.yaml", []byte("has wrong config"), 0o644)) + + // package manager is not called in this scenario because teleport binary already exists in the system + require.FileExists(t, mockBins["teleport"].Path) + + mockBins["teleport"].Expect("node", + "configure", + "--output=file://"+testTempDir+"/etc/teleport.yaml.new", + "--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", + ).AndCallFunc(func(c *bintest.Call) { + // create a teleport.yaml configuration file + require.NoError(t, os.WriteFile(testTempDir+"/etc/teleport.yaml.new", []byte("teleport.yaml configuration bytes"), 0o644)) + c.Exit(0) + }) + + mockBins["systemctl"].Expect("enable", "teleport") + mockBins["systemctl"].Expect("restart", "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) + } + + require.NoFileExists(t, testTempDir+"/etc/teleport.yaml.new") + require.FileExists(t, testTempDir+"/etc/teleport.yaml") + bs, err := os.ReadFile(testTempDir + "/etc/teleport.yaml") + require.NoError(t, err) + require.Equal(t, "teleport.yaml configuration bytes", string(bs)) + }) + + t.Run("does nothing if teleport is already installed and target teleport.yaml configuration already exists", func(t *testing.T) { + distroName := "ubuntu" + distroVersion := "24.04" + distroConfig := wellKnownOS[distroName][distroVersion] + + testTempDir := t.TempDir() + + setupDirsForTest(t, testTempDir, distroConfig) + + 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) + + // package manager is not called in this scenario because teleport binary already exists in the system + require.FileExists(t, mockBins["teleport"].Path) + + // create an existing teleport.yaml configuration file + require.NoError(t, os.WriteFile(testTempDir+"/etc/teleport.yaml", []byte("teleport.yaml configuration bytes"), 0o644)) + + // package manager is not called in this scenario because teleport binary already exists in the system + require.FileExists(t, mockBins["teleport"].Path) + + mockBins["teleport"].Expect("node", + "configure", + "--output=file://"+testTempDir+"/etc/teleport.yaml.new", + "--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", + ).AndCallFunc(func(c *bintest.Call) { + // create a teleport.yaml configuration file + require.NoError(t, os.WriteFile(testTempDir+"/etc/teleport.yaml.new", []byte("teleport.yaml configuration bytes"), 0o644)) + c.Exit(0) + }) + + 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) + } + + require.NoFileExists(t, testTempDir+"/etc/teleport.yaml.new") + require.FileExists(t, testTempDir+"/etc/teleport.yaml") + bs, err := os.ReadFile(testTempDir + "/etc/teleport.yaml") + require.NoError(t, err) + require.Equal(t, "teleport.yaml configuration bytes", string(bs)) + }) } // wellKnownOS lists the officially supported repositories for Linux Distros