Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions api/utils/keypaths/keypaths.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment on lines +85 to +87
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: move this within VNetSSHConfigPath?

)

// Here's the file layout of all these keypaths.
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
131 changes: 130 additions & 1 deletion lib/vnet/opensshconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
104 changes: 104 additions & 0 deletions lib/vnet/opensshconfig_test.go
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

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)
}
12 changes: 11 additions & 1 deletion lib/vnet/user_process.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 7 additions & 9 deletions lib/vnet/user_process_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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{
Expand All @@ -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()
Expand All @@ -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")
}
}
Loading
Loading