diff --git a/api/utils/keypaths/keypaths.go b/api/utils/keypaths/keypaths.go index f1853313f67fe..60b2c472f8df5 100644 --- a/api/utils/keypaths/keypaths.go +++ b/api/utils/keypaths/keypaths.go @@ -82,6 +82,9 @@ const ( // vnetKnownHosts is the file name of the known_hosts file trusted by // third-party SSH clients connecting to VNet SSH. vnetKnownHosts = "vnet_known_hosts" + // vnetSSHConfig is the file name of the generated OpenSSH-compatible config + // file to be used by third-party SSH clients connecting to VNet SSH. + vnetSSHConfig = "vnet_ssh_config" ) // Here's the file layout of all these keypaths. @@ -93,6 +96,7 @@ const ( // ├── id_vnet --> SSH Private Key for third-party clients of VNet SSH // ├── id_vnet.pub --> SSH Public Key for third-party clients of VNet SSH // ├── vnet_known_hosts --> trusted certificate authorities (their keys) for third-party clients of VNet SSH +// ├── vnet_ssh_config --> OpenSSH-compatible config file for third-party clients of VNet SSH // └── keys --> session keys directory // ├── one.example.com --> Proxy hostname // │ ├── certs.pem --> TLS CA certs for the Teleport CA @@ -456,6 +460,12 @@ func VNetKnownHostsPath(baseDir string) string { return filepath.Join(baseDir, vnetKnownHosts) } +// VNetSSHConfigPath returns the path to VNet's generated OpenSSH-compatible +// config file. +func VNetSSHConfigPath(baseDir string) string { + return filepath.Join(baseDir, vnetSSHConfig) +} + // TrimKeyPathSuffix returns the given path with any key suffix/extension trimmed off. func TrimKeyPathSuffix(path string) string { return strings.TrimSuffix(path, fileExtTLSKey) diff --git a/lib/vnet/opensshconfig.go b/lib/vnet/opensshconfig.go index 3bd5176729b9a..bd7ce4f50d3cc 100644 --- a/lib/vnet/opensshconfig.go +++ b/lib/vnet/opensshconfig.go @@ -17,23 +17,34 @@ package vnet import ( + "bytes" + "cmp" + "context" "encoding/pem" "io" "os" "path/filepath" + "slices" + "strconv" + "strings" + "text/template" + "time" renameio "github.com/google/renameio/v2/maybe" // Writes aren't guaranteed to be atomic on Windows. "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" "golang.org/x/crypto/ssh" "github.com/gravitational/teleport/api/profile" "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/utils" "github.com/gravitational/teleport/api/utils/keypaths" "github.com/gravitational/teleport/lib/cryptosuites" ) const ( - filePerms os.FileMode = 0o600 + filePerms os.FileMode = 0o600 + sshConfigurationUpdateInterval = 30 * time.Second ) // writeSSHKeys writes hostCAKey to ${TELEPORT_HOME}/vnet_known_hosts so that @@ -121,3 +132,121 @@ func generateAndWriteUserKey(profilePath string) (ssh.PublicKey, error) { } return userPubKey, nil } + +// sshConfigurator writes an OpenSSH-compatible config file to +// TELEPORT_HOME/vnet_ssh_config, and keeps it up to date with the list of +// clusters that should match. +type sshConfigurator struct { + cfg sshConfiguratorConfig + profilePath string + clock clockwork.Clock +} + +type sshConfiguratorConfig struct { + clientApplication ClientApplication + homePath string + clock clockwork.Clock +} + +func newSSHConfigurator(cfg sshConfiguratorConfig) *sshConfigurator { + return &sshConfigurator{ + cfg: cfg, + profilePath: fullProfilePath(cfg.homePath), + clock: cmp.Or(cfg.clock, clockwork.NewRealClock()), + } +} + +func (c *sshConfigurator) runConfigurationLoop(ctx context.Context) error { + if err := c.updateSSHConfiguration(ctx); err != nil { + return trace.Wrap(err, "generating vnet_ssh_config") + } + // Delete the configuration file before exiting, if it is imported by the + // default SSH config file it will just stop taking effect. + defer func() { + if err := deleteSSHConfigFile(c.profilePath); err != nil { + log.WarnContext(ctx, "Failed to delete vnet_ssh_config while shutting down", "error", err) + } + }() + // clock.After is intentionally used in the loop instead of a ticker simply + // for more reliable testing. In the test I use clock.BlockUntilContext(1) + // to block until the loop is stuck waiting on the clock. If I used + // clock.NewTicker instead, the ticker always counts as a waiter, and that + // strategy doesn't work. In go 1.25 we can use testing/synctest instead. + for { + select { + case <-c.clock.After(sshConfigurationUpdateInterval): + if err := c.updateSSHConfiguration(ctx); err != nil { + return trace.Wrap(err, "updating vnet_ssh_config") + } + case <-ctx.Done(): + return trace.Wrap(ctx.Err(), "context canceled, shutting down vnet_ssh_config update loop") + } + } +} + +func (c *sshConfigurator) updateSSHConfiguration(ctx context.Context) error { + profileNames, err := c.cfg.clientApplication.ListProfiles() + if err != nil { + return trace.Wrap(err, "listing profiles") + } + hostMatchers := make([]string, 0, len(profileNames)) + for _, profileName := range profileNames { + rootClient, err := c.cfg.clientApplication.GetCachedClient(ctx, profileName, "" /*leafClusterName*/) + if err != nil { + log.WarnContext(ctx, + "Failed to get root cluster client from cache, profile may be expired, not configuring VNet SSH for this cluster", + "profile", profileName, "error", err) + continue + } + hostMatchers = append(hostMatchers, hostMatcher(rootClient.RootClusterName())) + } + hostMatchers = utils.Deduplicate(hostMatchers) + slices.Sort(hostMatchers) + hostMatchersString := strings.Join(hostMatchers, " ") + return trace.Wrap(writeSSHConfigFile(c.profilePath, hostMatchersString)) +} + +func hostMatcher(clusterName string) string { + return "*." + strings.Trim(clusterName, ".") +} + +func deleteSSHConfigFile(profilePath string) error { + p := keypaths.VNetSSHConfigPath(profilePath) + if err := os.Remove(p); err != nil { + err = trace.ConvertSystemError(err) + if trace.IsNotFound(err) { + return nil + } + return trace.Wrap(err, "deleting %s", p) + } + return nil +} + +func writeSSHConfigFile(profilePath, hostMatchers string) error { + t := template.Must(template.New("ssh_config").Parse(configFileTemplate)) + var b bytes.Buffer + if err := t.Execute(&b, configFileTemplateInput{ + Hosts: hostMatchers, + PrivateKeyPath: strconv.Quote(keypaths.VNetClientSSHKeyPath(profilePath)), + KnownHostsPath: strconv.Quote(keypaths.VNetKnownHostsPath(profilePath)), + }); err != nil { + return trace.Wrap(err, "generating SSH config file") + } + p := keypaths.VNetSSHConfigPath(profilePath) + err := renameio.WriteFile(p, b.Bytes(), filePerms) + return trace.Wrap(trace.ConvertSystemError(err), "writing SSH config file to %s", p) +} + +const configFileTemplate = `Host {{ .Hosts }} + IdentityFile {{ .PrivateKeyPath }} + GlobalKnownHostsFile {{ .KnownHostsPath }} + UserKnownHostsFile /dev/null + StrictHostKeyChecking yes + IdentitiesOnly yes +` + +type configFileTemplateInput struct { + Hosts string + PrivateKeyPath string + KnownHostsPath string +} diff --git a/lib/vnet/opensshconfig_test.go b/lib/vnet/opensshconfig_test.go new file mode 100644 index 0000000000000..3289577f783aa --- /dev/null +++ b/lib/vnet/opensshconfig_test.go @@ -0,0 +1,104 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package vnet + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/utils/keypaths" +) + +func TestSSHConfigurator(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + clock := clockwork.NewFakeClockAt(time.Now()) + homePath := t.TempDir() + + fakeClientApp := newFakeClientApp(ctx, t, &fakeClientAppConfig{ + clusters: map[string]testClusterSpec{ + "cluster1": {}, + "cluster2": {}, + }, + // Give the fake client app a different clock so we can rely on + // clock.BlockUntilContext only capturing the SSH configuration loop. + clock: clockwork.NewRealClock(), + }) + + c := newSSHConfigurator(sshConfiguratorConfig{ + clientApplication: fakeClientApp, + homePath: homePath, + clock: clock, + }) + errC := make(chan error) + go func() { + errC <- c.runConfigurationLoop(ctx) + }() + + // Intentionally not using the template defined in the production code to + // test that it actually produces output that looks like this. + expectedConfigFile := func(expectedHosts string) string { + return fmt.Sprintf(`Host %s + IdentityFile "%s/id_vnet" + GlobalKnownHostsFile "%s/vnet_known_hosts" + UserKnownHostsFile /dev/null + StrictHostKeyChecking yes + IdentitiesOnly yes +`, + expectedHosts, + homePath, homePath) + } + + assertConfigFile := func(expectedHosts string) { + t.Helper() + expected := expectedConfigFile(expectedHosts) + contents, err := os.ReadFile(keypaths.VNetSSHConfigPath(homePath)) + require.NoError(t, err) + require.Equal(t, expected, string(contents)) + } + + // Wait until the configurator has had a chance to write the initial config + // file and then get blocked in the loop. + clock.BlockUntilContext(ctx, 1) + // Assert the config file contains both root clusters reported by + // fakeClientApp. + assertConfigFile("*.cluster1 *.cluster2") + + // Add a root cluster, wait until the configurator is blocked in the loop, + // advance the clock, wait until the configurator is blocked again + // indicating it should have updated the config and made it back into the + // loop, and then assert that the new cluster is in the config file. + fakeClientApp.cfg.clusters["cluster3"] = testClusterSpec{} + clock.BlockUntilContext(ctx, 1) + clock.Advance(sshConfigurationUpdateInterval) + clock.BlockUntilContext(ctx, 1) + assertConfigFile("*.cluster1 *.cluster2 *.cluster3") + + // Kill the configurator, wait for it to return, and assert that the config + // file was deleted. + cancel() + require.ErrorIs(t, <-errC, context.Canceled) + _, err := os.Stat(keypaths.VNetSSHConfigPath(homePath)) + require.ErrorIs(t, err, os.ErrNotExist) +} diff --git a/lib/vnet/user_process.go b/lib/vnet/user_process.go index 75df45f7fd196..48eb3dbb38595 100644 --- a/lib/vnet/user_process.go +++ b/lib/vnet/user_process.go @@ -112,13 +112,23 @@ func RunUserProcess(ctx context.Context, clientApplication ClientApplication) (* if err != nil { return nil, trace.Wrap(err) } + + processManager, processCtx := newProcessManager() + sshConfigurator := newSSHConfigurator(sshConfiguratorConfig{ + clientApplication: clientApplication, + }) + processManager.AddCriticalBackgroundTask("SSH configuration loop", func() error { + return trace.Wrap(sshConfigurator.runConfigurationLoop(processCtx)) + }) + userProcess := &UserProcess{ clientApplication: clientApplication, osConfigProvider: osConfigProvider, clientApplicationService: clientApplicationService, clock: clock, + processManager: processManager, } - if err := userProcess.runPlatformUserProcess(ctx); err != nil { + if err := userProcess.runPlatformUserProcess(processCtx); err != nil { return nil, trace.Wrap(err) } return userProcess, nil diff --git a/lib/vnet/user_process_darwin.go b/lib/vnet/user_process_darwin.go index a88f2313688a6..44c0d672470ab 100644 --- a/lib/vnet/user_process_darwin.go +++ b/lib/vnet/user_process_darwin.go @@ -35,7 +35,7 @@ import ( // interface that the daemon uses to query application names and get user // certificates for apps. If successful it sets p.processManager and // p.networkStackInfo. -func (p *UserProcess) runPlatformUserProcess(ctx context.Context) error { +func (p *UserProcess) runPlatformUserProcess(processCtx context.Context) error { ipcCreds, err := newIPCCredentials() if err != nil { return trace.Wrap(err, "creating credentials for IPC") @@ -67,16 +67,14 @@ func (p *UserProcess) runPlatformUserProcess(ctx context.Context) error { ) vnetv1.RegisterClientApplicationServiceServer(grpcServer, p.clientApplicationService) - pm, processCtx := newProcessManager() - p.processManager = pm - pm.AddCriticalBackgroundTask("admin process", func() error { + p.processManager.AddCriticalBackgroundTask("admin process", func() error { defer func() { // Delete service credentials after the service terminates. if ipcCreds.client.remove(credDir); err != nil { - log.ErrorContext(ctx, "Failed to remove service credential files", "error", err) + log.ErrorContext(processCtx, "Failed to remove service credential files", "error", err) } if err := os.RemoveAll(credDir); err != nil { - log.ErrorContext(ctx, "Failed to remove service credential directory", "error", err) + log.ErrorContext(processCtx, "Failed to remove service credential directory", "error", err) } }() daemonConfig := daemon.Config{ @@ -85,13 +83,13 @@ func (p *UserProcess) runPlatformUserProcess(ctx context.Context) error { } return trace.Wrap(execAdminProcess(processCtx, daemonConfig)) }) - pm.AddCriticalBackgroundTask("gRPC service", func() error { + p.processManager.AddCriticalBackgroundTask("gRPC service", func() error { log.InfoContext(processCtx, "Starting gRPC service", "addr", listener.Addr().String()) return trace.Wrap(grpcServer.Serve(listener), "serving VNet user process gRPC service") }) - pm.AddCriticalBackgroundTask("gRPC server closer", func() error { + p.processManager.AddCriticalBackgroundTask("gRPC server closer", func() error { // grpcServer.Serve does not stop on its own when processCtx is done, so // this task waits for processCtx and then explicitly stops grpcServer. <-processCtx.Done() @@ -104,6 +102,6 @@ func (p *UserProcess) runPlatformUserProcess(ctx context.Context) error { p.networkStackInfo = nsi return nil case <-processCtx.Done(): - return trace.Wrap(pm.Wait(), "process manager exited before network stack info was received") + return trace.Wrap(p.processManager.Wait(), "process manager exited before network stack info was received") } } diff --git a/lib/vnet/user_process_windows.go b/lib/vnet/user_process_windows.go index 80d935f209fd1..e0ab82eda82cf 100644 --- a/lib/vnet/user_process_windows.go +++ b/lib/vnet/user_process_windows.go @@ -37,7 +37,7 @@ import ( // interface that the admin process uses to query application names and get user // certificates for apps. It returns a [ProcessManager] which controls the // lifecycle of both the user and admin processes. -func (p *UserProcess) runPlatformUserProcess(ctx context.Context) error { +func (p *UserProcess) runPlatformUserProcess(processCtx context.Context) error { ipcCreds, err := newIPCCredentials() if err != nil { return trace.Wrap(err, "creating credentials for IPC") @@ -77,17 +77,15 @@ func (p *UserProcess) runPlatformUserProcess(ctx context.Context) error { ) vnetv1.RegisterClientApplicationServiceServer(grpcServer, p.clientApplicationService) - pm, processCtx := newProcessManager() - p.processManager = pm - pm.AddCriticalBackgroundTask("admin process", func() error { + p.processManager.AddCriticalBackgroundTask("admin process", func() error { log.InfoContext(processCtx, "Starting Windows service") defer func() { // Delete service credentials after the service terminates. if ipcCreds.client.remove(credDir); err != nil { - log.ErrorContext(ctx, "Failed to remove service credential files", "error", err) + log.ErrorContext(processCtx, "Failed to remove service credential files", "error", err) } if err := os.RemoveAll(credDir); err != nil { - log.ErrorContext(ctx, "Failed to remove service credential directory", "error", err) + log.ErrorContext(processCtx, "Failed to remove service credential directory", "error", err) } }() return trace.Wrap(runService(processCtx, &windowsAdminProcessConfig{ @@ -96,13 +94,13 @@ func (p *UserProcess) runPlatformUserProcess(ctx context.Context) error { userSID: userSID, })) }) - pm.AddCriticalBackgroundTask("gRPC service", func() error { + p.processManager.AddCriticalBackgroundTask("gRPC service", func() error { log.InfoContext(processCtx, "Starting gRPC service", "addr", listener.Addr().String()) return trace.Wrap(grpcServer.Serve(listener), "serving VNet user process gRPC service") }) - pm.AddCriticalBackgroundTask("gRPC server closer", func() error { + p.processManager.AddCriticalBackgroundTask("gRPC server closer", func() error { // grpcServer.Serve does not stop on its own when processCtx is done, so // this task waits for processCtx and then explicitly stops grpcServer. <-processCtx.Done() @@ -115,7 +113,7 @@ func (p *UserProcess) runPlatformUserProcess(ctx context.Context) error { p.networkStackInfo = nsi return nil case <-processCtx.Done(): - return trace.Wrap(pm.Wait(), "process manager exited before network stack info was received") + return trace.Wrap(p.processManager.Wait(), "process manager exited before network stack info was received") } }