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
55 changes: 55 additions & 0 deletions lib/config/openssh/openssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,58 @@ func WriteMuxedSSHConfig(w io.Writer, config *MuxedSSHConfigParameters) error {

return nil
}

var clusterSSHConfigTmpl = template.Must(template.New("cluster-ssh-config").Funcs(template.FuncMap{
"proxyCommandQuote": proxyCommandQuote,
}).Parse(
`# Cluster-specific ssh_config generated by {{ .AppName }} for cluster '{{ .ClusterName }}' via proxy '{{ .ProxyHost }}:{{ .ProxyPort }}'
UserKnownHostsFile "{{ .KnownHostsPath }}"
IdentityFile "{{ .IdentityFilePath }}"
CertificateFile "{{ .CertificateFilePath }}"
Port {{ .Port }}
ProxyCommand {{ proxyCommandQuote .ExecutablePath }} ssh-proxy-command --destination-dir={{ proxyCommandQuote .DestinationDir }} --proxy-server={{ proxyCommandQuote (print .ProxyHost ":" .ProxyPort) }} --cluster={{ proxyCommandQuote .ClusterName }} {{ if .TLSRouting }}--tls-routing{{ else }}--no-tls-routing{{ end }} {{ if .ConnectionUpgrade }}--connection-upgrade{{ else }}--no-connection-upgrade{{ end }} {{ if .Resume }}--resume{{ else }}--no-resume{{ end }} --user=%r --host=%h --port=%p
`))

// ClusterSSHConfigParameters is the parameter set for GetClusterSSHConfig.
type ClusterSSHConfigParameters struct {
AppName SSHConfigApps
ClusterName string
KnownHostsPath string
IdentityFilePath string
CertificateFilePath string
ProxyHost string
ProxyPort string
ExecutablePath string
DestinationDir string
Port int
ConnectionUpgrade bool
TLSRouting bool
Insecure bool
FIPS bool
Resume bool
}

type clusterSSHConfigTmplParams struct {
ClusterSSHConfigParameters
}

// WriteClusterSSHConfig generate a ssh_config that proxies SSH connections via
// tbot and through to a single Teleport cluster. It performs no matching on
// the hostname.
//
// As it does not use the Host match directive, it is also includable within
// another ssh_config, which allows for more complex and customized
// configurations.
func WriteClusterSSHConfig(sb *strings.Builder, config *ClusterSSHConfigParameters) error {
if config.Port == 0 {
config.Port = defaults.SSHServerListenPort
}

if err := clusterSSHConfigTmpl.Execute(sb, clusterSSHConfigTmplParams{
ClusterSSHConfigParameters: *config,
}); err != nil {
return trace.Wrap(err)
}

return nil
}
63 changes: 63 additions & 0 deletions lib/config/openssh/openssh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,66 @@ func TestWriteMuxedSSHConfig(t *testing.T) {
})
}
}

func TestWriteClusterSSHConfig(t *testing.T) {
tests := []struct {
name string
sshVersion string
config *ClusterSSHConfigParameters
}{
{
name: "legacy OpenSSH",
sshVersion: "7.4.0",
config: &ClusterSSHConfigParameters{
AppName: TbotApp,
ClusterName: "example.teleport.sh",
DestinationDir: "/opt/machine-id",
KnownHostsPath: "/opt/machine-id/example.teleport.sh.known_hosts",
CertificateFilePath: "/opt/machine-id/key-cert.pub",
IdentityFilePath: "/opt/machine-id/key",
ExecutablePath: "/bin/tbot",
ProxyHost: "example.teleport.sh",
ProxyPort: "443",
Port: 1234,
Insecure: true,
FIPS: true,
TLSRouting: true,
ConnectionUpgrade: true,
Resume: true,
},
},
{
name: "modern OpenSSH",
sshVersion: "9.0.0",
config: &ClusterSSHConfigParameters{
AppName: TbotApp,
ClusterName: "example.teleport.sh",
DestinationDir: "/opt/machine-id",
KnownHostsPath: "/opt/machine-id/example.teleport.sh.known_hosts",
CertificateFilePath: "/opt/machine-id/key-cert.pub",
IdentityFilePath: "/opt/machine-id/key",
ExecutablePath: "/bin/tbot",
ProxyHost: "example.teleport.sh",
ProxyPort: "443",
Port: 1234,
Insecure: false,
FIPS: false,
TLSRouting: false,
ConnectionUpgrade: false,
Resume: false,
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sb := &strings.Builder{}
err := WriteClusterSSHConfig(sb, tt.config)
if golden.ShouldSet() {
golden.Set(t, []byte(sb.String()))
}
require.NoError(t, err)
require.Equal(t, string(golden.Get(t)), sb.String())
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Cluster-specific ssh_config generated by tbot for cluster 'example.teleport.sh' via proxy 'example.teleport.sh:443'
UserKnownHostsFile "/opt/machine-id/example.teleport.sh.known_hosts"
IdentityFile "/opt/machine-id/key"
CertificateFile "/opt/machine-id/key-cert.pub"
Port 1234
ProxyCommand '/bin/tbot' ssh-proxy-command --destination-dir='/opt/machine-id' --proxy-server='example.teleport.sh:443' --cluster='example.teleport.sh' --tls-routing --connection-upgrade --resume --user=%r --host=%h --port=%p
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Cluster-specific ssh_config generated by tbot for cluster 'example.teleport.sh' via proxy 'example.teleport.sh:443'
UserKnownHostsFile "/opt/machine-id/example.teleport.sh.known_hosts"
IdentityFile "/opt/machine-id/key"
CertificateFile "/opt/machine-id/key-cert.pub"
Port 1234
ProxyCommand '/bin/tbot' ssh-proxy-command --destination-dir='/opt/machine-id' --proxy-server='example.teleport.sh:443' --cluster='example.teleport.sh' --no-tls-routing --no-connection-upgrade --no-resume --user=%r --host=%h --port=%p
52 changes: 50 additions & 2 deletions lib/tbot/service_identity_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ func (s *IdentityOutputService) generate(ctx context.Context) error {
}
if err := renderSSHConfig(
ctx,
s.log,
proxyPing,
clusterNames,
s.cfg.Destination,
Expand Down Expand Up @@ -237,6 +238,7 @@ type alpnTester interface {

func renderSSHConfig(
ctx context.Context,
log *slog.Logger,
proxyPing *webclient.PingResponse,
clusterNames []string,
dest bot.Destination,
Expand All @@ -261,7 +263,7 @@ func renderSSHConfig(

// We'll write known_hosts regardless of Destination type, it's still
// useful alongside a manually-written ssh_config.
knownHosts, err := ssh.GenerateKnownHosts(
knownHosts, clusterKnownHosts, err := ssh.GenerateKnownHosts(
ctx,
certAuthGetter,
clusterNames,
Expand Down Expand Up @@ -332,7 +334,6 @@ func renderSSHConfig(
FIPS: botCfg.FIPS,
TLSRouting: proxyPing.Proxy.TLSRoutingEnabled,
ConnectionUpgrade: connUpgradeRequired,

// Session resumption is enabled by default, this can be
// configurable at a later date if we discover reasons for this to
// be disabled.
Expand All @@ -341,6 +342,53 @@ func renderSSHConfig(
return trace.Wrap(err)
}

// Generate the per cluster files
for _, clusterName := range clusterNames {
sshConfigName := fmt.Sprintf("%s.%s", clusterName, ssh.ConfigName)
knownHostsName := fmt.Sprintf("%s.%s", clusterName, ssh.KnownHostsName)
knownHostsPath := filepath.Join(absDestPath, knownHostsName)

sb := &strings.Builder{}
if err := openssh.WriteClusterSSHConfig(sb, &openssh.ClusterSSHConfigParameters{
AppName: openssh.TbotApp,
ClusterName: clusterName,
KnownHostsPath: knownHostsPath,
IdentityFilePath: identityFilePath,
CertificateFilePath: certificateFilePath,
ProxyHost: proxyHost,
ProxyPort: proxyPort,
ExecutablePath: executablePath,
DestinationDir: absDestPath,

Insecure: botCfg.Insecure,
FIPS: botCfg.FIPS,
TLSRouting: proxyPing.Proxy.TLSRoutingEnabled,
ConnectionUpgrade: connUpgradeRequired,
// Session resumption is enabled by default, this can be
// configurable at a later date if we discover reasons for this to
// be disabled.
Resume: true,
}); err != nil {
return trace.Wrap(err)
}
if err := destDirectory.Write(ctx, sshConfigName, []byte(sb.String())); err != nil {
return trace.Wrap(err)
}

knownHosts, ok := clusterKnownHosts[clusterName]
if !ok {
log.WarnContext(
ctx,
"No generated known_hosts for cluster, will skip",
"cluster", clusterName,
)
continue
}
if err := destDirectory.Write(ctx, knownHostsName, []byte(knownHosts)); err != nil {
return trace.Wrap(err)
}
}

if err := destDirectory.Write(ctx, ssh.ConfigName, []byte(sshConfigBuilder.String())); err != nil {
return trace.Wrap(err)
}
Expand Down
32 changes: 32 additions & 0 deletions lib/tbot/service_identity_output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ package tbot
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"slices"
Expand All @@ -35,6 +36,7 @@ import (
"github.com/gravitational/teleport/lib/tbot/botfs"
"github.com/gravitational/teleport/lib/tbot/config"
"github.com/gravitational/teleport/lib/tbot/ssh"
"github.com/gravitational/teleport/lib/utils"
"github.com/gravitational/teleport/lib/utils/golden"
)

Expand Down Expand Up @@ -154,6 +156,7 @@ func Test_renderSSHConfig(t *testing.T) {

err := renderSSHConfig(
context.Background(),
utils.NewSlogLoggerForTests(),
&webclient.PingResponse{
ClusterName: mockClusterName,
Proxy: webclient.ProxySettings{
Expand Down Expand Up @@ -197,6 +200,35 @@ func Test_renderSSHConfig(t *testing.T) {
require.Equal(
t, string(golden.GetNamed(t, "ssh_config")), string(sshConfigBytes),
)

for clusterType, clusterName := range map[string]string{
"local": mockClusterName,
"remote": mockRemoteClusterName,
} {
clusterKnownHostBytes, err := os.ReadFile(
filepath.Join(dir, fmt.Sprintf("%s.%s", clusterName, ssh.KnownHostsName)),
)
require.NoError(t, err)
clusterKnownHostBytes = replaceTestDir(clusterKnownHostBytes)
clusterSSHConfigBytes, err := os.ReadFile(
filepath.Join(dir, fmt.Sprintf("%s.%s", clusterName, ssh.ConfigName)),
)
require.NoError(t, err)
clusterSSHConfigBytes = replaceTestDir(clusterSSHConfigBytes)

configGolden := fmt.Sprintf("%s_cluster_ssh_config", clusterType)
knownHostsGolden := fmt.Sprintf("%s_cluster_known_hosts", clusterType)
if golden.ShouldSet() {
golden.SetNamed(t, knownHostsGolden, clusterKnownHostBytes)
golden.SetNamed(t, configGolden, clusterSSHConfigBytes)
}
require.Equal(
t, string(golden.GetNamed(t, knownHostsGolden)), string(clusterKnownHostBytes),
)
require.Equal(
t, string(golden.GetNamed(t, configGolden)), string(clusterSSHConfigBytes),
)
}
})
}
}
2 changes: 1 addition & 1 deletion lib/tbot/service_ssh_multiplexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ func (s *SSHMultiplexerService) writeArtifacts(
}

// Generate known hosts
knownHosts, err := ssh.GenerateKnownHosts(
knownHosts, _, err := ssh.GenerateKnownHosts(
ctx,
s.botAuthClient,
clusterNames,
Expand Down
30 changes: 25 additions & 5 deletions lib/tbot/ssh/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,39 +48,59 @@ type certAuthorityGetter interface {

// GenerateKnownHosts generates a known_hosts file for the provided cluster
// names and proxy hosts.
//
// It produces:
// - A main known_hosts file that includes all the clusters, with each
// cluster's CA limited to the wildcard of the cluster's domain name.
// - A known_hosts file per cluster that will match any hostname.
func GenerateKnownHosts(
ctx context.Context,
bot certAuthorityGetter,
clusterNames []string,
proxyHosts string,
) (string, error) {
) (string, map[string]string, error) {
certAuthorities := make([]types.CertAuthority, 0, len(clusterNames))
for _, cn := range clusterNames {
ca, err := bot.GetCertAuthority(ctx, types.CertAuthID{
Type: types.HostCA,
DomainName: cn,
}, false)
if err != nil {
return "", trace.Wrap(err)
return "", nil, trace.Wrap(err)
}
certAuthorities = append(certAuthorities, ca)
}

perCluster := make(map[string]string)
var sb strings.Builder
for _, auth := range authclient.AuthoritiesToTrustedCerts(certAuthorities) {
pubKeys, err := auth.SSHCertPublicKeys()
if err != nil {
return "", trace.Wrap(err)
return "", nil, trace.Wrap(err)
}

var perClusterSB strings.Builder
fmt.Fprintf(
&perClusterSB,
"# Cluster specific known_hosts generated for cluster '%s'\n",
auth.ClusterName,
)
for _, pubKey := range pubKeys {
bytes := ssh.MarshalAuthorizedKey(pubKey)
fmt.Fprintf(&sb,
"@cert-authority %s,%s,*.%s %s type=host\n",
proxyHosts, auth.ClusterName, auth.ClusterName, strings.TrimSpace(string(bytes)),
proxyHosts,
auth.ClusterName,
auth.ClusterName,
strings.TrimSpace(string(bytes)),
)
fmt.Fprintf(&perClusterSB,
"@cert-authority * %s type=host\n",
strings.TrimSpace(string(bytes)),
)
}
perCluster[auth.ClusterName] = perClusterSB.String()
}

return sb.String(), nil
return sb.String(), perCluster, nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Cluster-specific known_hosts for tele.blackmesa.gov
@cert-authority * ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC8kYdyZA1ZSNjZ4pqybDXvWplHQHkU6fPL+cAYHUkAT5CiQV4GOjwaSTcvZNK5U2fQ0jm6jknCnsZi1t9JujCjXUT3bYHCnSwWhXN55QzIu530Q/MeXz5W8TxYRrWULgPhqqtq8B9N554+s40higG21fmhhdDtpmQzw3vJLspY05mnL1+fW+RIKkM4rb150sdZXKINxfNQvERteE8WX0vL2yG4RuqJzYtGCDEGeHd+HLne7xfmqPxun7bUYaxAlplhm1z2J41hqaj8pBwDSEV9SBOZXvh6FjS9nvJCT7Z1bbZwWrAO/7E2ac0eV+5iEc0J+TyufO3F9uod+J+AICtB type=host
@cert-authority * ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC8kYdyZA1ZSNjZ4pqybDXvWplHQHkU6fPL+cAYHUkAT5CiQV4GOjwaSTcvZNK5U2fQ0jm6jknCnsZi1t9JujCjXUT3bYHCnSwWhXN55QzIu530Q/MeXz5W8TxYRrWULgPhqqtq8B9N554+s40higG21fmhhdDtpmQzw3vJLspY05mnL1+fW+RIKkM4rb150sdZXKINxfNQvERteE8WX0vL2yG4RuqJzYtGCDEGeHd+HLne7xfmqPxun7bUYaxAlplhm1z2J41hqaj8pBwDSEV9SBOZXvh6FjS9nvJCT7Z1bbZwWrAO/7E2ac0eV+5iEc0J+TyufO3F9uod+J+AICtB type=host
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Begin generated Teleport configuration for cluster tele.blackmesa.gov with proxy tele.blackmesa.gov by tbot
UserKnownHostsFile "/test/dir/tele.blackmesa.gov.known_hosts"
IdentityFile "/test/dir/key"
CertificateFile "/test/dir/key-cert.pub"
HostKeyAlgorithms rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com,ssh-rsa-cert-v01@openssh.com
Port 3022
ProxyCommand '/path/to/tbot' ssh-proxy-command --destination-dir='/test/dir' --proxy-server='tele.blackmesa.gov:443' --cluster='tele.blackmesa.gov' --tls-routing --no-connection-upgrade --resume --user=%r --host=%h --port=%p
# End generated Teleport configuration
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Cluster specific known_hosts generated for cluster 'tele.blackmesa.gov'
@cert-authority * ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC8kYdyZA1ZSNjZ4pqybDXvWplHQHkU6fPL+cAYHUkAT5CiQV4GOjwaSTcvZNK5U2fQ0jm6jknCnsZi1t9JujCjXUT3bYHCnSwWhXN55QzIu530Q/MeXz5W8TxYRrWULgPhqqtq8B9N554+s40higG21fmhhdDtpmQzw3vJLspY05mnL1+fW+RIKkM4rb150sdZXKINxfNQvERteE8WX0vL2yG4RuqJzYtGCDEGeHd+HLne7xfmqPxun7bUYaxAlplhm1z2J41hqaj8pBwDSEV9SBOZXvh6FjS9nvJCT7Z1bbZwWrAO/7E2ac0eV+5iEc0J+TyufO3F9uod+J+AICtB type=host
@cert-authority * ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC8kYdyZA1ZSNjZ4pqybDXvWplHQHkU6fPL+cAYHUkAT5CiQV4GOjwaSTcvZNK5U2fQ0jm6jknCnsZi1t9JujCjXUT3bYHCnSwWhXN55QzIu530Q/MeXz5W8TxYRrWULgPhqqtq8B9N554+s40higG21fmhhdDtpmQzw3vJLspY05mnL1+fW+RIKkM4rb150sdZXKINxfNQvERteE8WX0vL2yG4RuqJzYtGCDEGeHd+HLne7xfmqPxun7bUYaxAlplhm1z2J41hqaj8pBwDSEV9SBOZXvh6FjS9nvJCT7Z1bbZwWrAO/7E2ac0eV+5iEc0J+TyufO3F9uod+J+AICtB type=host
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Cluster-specific ssh_config generated by tbot for cluster 'tele.blackmesa.gov' via proxy 'tele.blackmesa.gov:443'
UserKnownHostsFile "/test/dir/tele.blackmesa.gov.known_hosts"
IdentityFile "/test/dir/key"
CertificateFile "/test/dir/key-cert.pub"
HostKeyAlgorithms rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com,ssh-rsa-cert-v01@openssh.com
Port 3022
ProxyCommand '/path/to/tbot' ssh-proxy-command --destination-dir='/test/dir' --proxy-server='tele.blackmesa.gov:443' --cluster='tele.blackmesa.gov' --tls-routing --no-connection-upgrade --resume --user=%r --host=%h --port=%p
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Cluster specific known_hosts generated for cluster 'tele.aperture.labs'
@cert-authority * ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC8kYdyZA1ZSNjZ4pqybDXvWplHQHkU6fPL+cAYHUkAT5CiQV4GOjwaSTcvZNK5U2fQ0jm6jknCnsZi1t9JujCjXUT3bYHCnSwWhXN55QzIu530Q/MeXz5W8TxYRrWULgPhqqtq8B9N554+s40higG21fmhhdDtpmQzw3vJLspY05mnL1+fW+RIKkM4rb150sdZXKINxfNQvERteE8WX0vL2yG4RuqJzYtGCDEGeHd+HLne7xfmqPxun7bUYaxAlplhm1z2J41hqaj8pBwDSEV9SBOZXvh6FjS9nvJCT7Z1bbZwWrAO/7E2ac0eV+5iEc0J+TyufO3F9uod+J+AICtB type=host
@cert-authority * ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC8kYdyZA1ZSNjZ4pqybDXvWplHQHkU6fPL+cAYHUkAT5CiQV4GOjwaSTcvZNK5U2fQ0jm6jknCnsZi1t9JujCjXUT3bYHCnSwWhXN55QzIu530Q/MeXz5W8TxYRrWULgPhqqtq8B9N554+s40higG21fmhhdDtpmQzw3vJLspY05mnL1+fW+RIKkM4rb150sdZXKINxfNQvERteE8WX0vL2yG4RuqJzYtGCDEGeHd+HLne7xfmqPxun7bUYaxAlplhm1z2J41hqaj8pBwDSEV9SBOZXvh6FjS9nvJCT7Z1bbZwWrAO/7E2ac0eV+5iEc0J+TyufO3F9uod+J+AICtB type=host
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Cluster-specific ssh_config generated by tbot for cluster 'tele.aperture.labs' via proxy 'tele.blackmesa.gov:443'
UserKnownHostsFile "/test/dir/tele.aperture.labs.known_hosts"
IdentityFile "/test/dir/key"
CertificateFile "/test/dir/key-cert.pub"
HostKeyAlgorithms rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com,ssh-rsa-cert-v01@openssh.com
Port 3022
ProxyCommand '/path/to/tbot' ssh-proxy-command --destination-dir='/test/dir' --proxy-server='tele.blackmesa.gov:443' --cluster='tele.aperture.labs' --tls-routing --no-connection-upgrade --resume --user=%r --host=%h --port=%p
Loading