Skip to content

Commit

Permalink
Add tsh config helper to generate OpenSSH client configuration (#7437
Browse files Browse the repository at this point in the history
…) (#7651)

* Add `tsh config ssh` helper to generate OpenSSH client configuration

This adds a new subcommand, `tsh config ssh`, to generate OpenSSH
client configuration snippets that allow users to connect directly to
nodes using the standard `ssh` client.

To support this change, tsh's `known_hosts` file has been modified to
match the format required by OpenSSH when verifying hosts against
certificates. Old-style `known_hosts` entries will be automatically
replaced and pruned when the end user first logs in with an updated
`tsh`. Small changes were additionally made to the keystore and key
agent to pass the proxy host into `AddKnownHostKeys` and to support
wildcard hostnames in `known_hosts` entries.

* Fix broken link to Trusted Clusters documentation

* Use text/template for SSH config generation; wrap all errors.

* Rename config helper from `config ssh` to just `config`

This changes the config helper to use just `tsh config` per
suggestion from @r0mant.

* Fix known_hosts_migrate_test after rebase

* First pass at review feedback

* Update docs/pages/server-access/guides/openssh.mdx

Co-authored-by: Roman Tkachenko <roman@gravitational.com>

* Ensure top-level hostnames never match wildcard patterns

* Add additional host count check to `canPruneOldHostsEntry`.

* Replace excess call to `isOldStyleHostsEntry` with documented invariant

* Trim trailing dots on absolute hostnames in `matchesWildcard`

Co-authored-by: Roman Tkachenko <roman@gravitational.com>

Co-authored-by: Roman Tkachenko <roman@gravitational.com>
  • Loading branch information
timothyb89 and r0mant authored Jul 23, 2021
1 parent 91c2b73 commit bddcbc1
Show file tree
Hide file tree
Showing 9 changed files with 663 additions and 29 deletions.
83 changes: 69 additions & 14 deletions docs/pages/server-access/guides/openssh.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,75 @@ It is possible to use the OpenSSH client `ssh` to connect to nodes within a
Teleport cluster. Teleport supports SSH subsystems and includes a `proxy` subsystem that can be used like `netcat` is with `ProxyCommand` to connect
through a jump host.

OpenSSH client configuration may be generated automatically by `tsh`, or it can
be configured manually. In either case, make sure you are running OpenSSH's
`ssh-agent`, and have logged in to the Teleport proxy:

```bash
eval `ssh-agent`
tsh --proxy=root.example.com login
```

`ssh-agent` will print environment variables into the console. Either `eval` the
output as in the example above, or copy and paste the output into the shell you
will be using to connect to a Teleport node. The output exports the
`SSH_AUTH_SOCK` and `SSH_AGENT_PID` environment variables that allow OpenSSH
clients to find the SSH agent.

### Automatic Setup

<Admonition
type="warning"
title="Warning"
>
At this time, automatic OpenSSH client configuration is only supported on
Linux and macOS.
</Admonition>

`tsh` can automatically generate the necessary OpenSSH client configuration to
connect using the standard OpenSSH client:

```bash
# on the machine where you want to run the ssh client
tsh --proxy=root.example.com config
```

This will generate an OpenSSH client configuration block for the root cluster
and all currently-known leaf clusters. Append this to your local OpenSSH config
file (usually `~/.ssh/config`) using your text editor of choice.

Once configured, log into any node in the `root.example.com` cluster as any
principal listed in your Teleport profile:

```bash
ssh user@node1.root.example.com
```

If any [trusted clusters](../../trustedclusters.mdx) exist, they are also configured:
```bash
ssh user@node2.leaf.example.com
```

When connecting to nodes with Teleport daemons running on non-standard ports
(other than `3022`), a port may be specified:
```yaml
ssh -p 4022 user@node3.leaf.example.com
```

<Admonition
type="tip"
title="Automatic OpenSSH and Multiple Clusters"
>
If you switch between multiple Teleport proxy servers, you'll need to re-run
`tsh config` for each to generate the cluster-specific configuration.

Similarly, if [trusted clusters](../../trustedclusters.mdx) are added or
removed, be sure to re-run the above command and replace the previous
configuration.
</Admonition>

### Manual Setup

On your client machine, you need to import the public key of Teleport's host
certificate. This will allow your OpenSSH client to verify that host certificates
are signed by Teleport's trusted host CA:
Expand Down Expand Up @@ -250,20 +319,6 @@ certificate authorities for each cluster individually.
from the root auth server only.
</Admonition>

Make sure you are running OpenSSH's `ssh-agent`, and have logged in to the
Teleport proxy:

```bash
eval `ssh-agent`
tsh --proxy=root.example.com login
```

`ssh-agent` will print environment variables into the console. Either `eval` the
output as in the example above, or copy and paste the output into the shell you
will be using to connect to a Teleport node. The output exports the
`SSH_AUTH_SOCK` and `SSH_AGENT_PID` environment variables that allow OpenSSH
clients to find the SSH agent.

Lastly, configure the OpenSSH client to use the Teleport proxy when connecting
to nodes with matching names. Edit `~/.ssh/config` for your user or
`/etc/ssh/ssh_config` for global changes:
Expand Down
4 changes: 2 additions & 2 deletions lib/client/keyagent.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ func (a *LocalKeyAgent) AddHostSignersToCache(certAuthorities []auth.TrustedCert
return trace.Wrap(err)
}
a.log.Debugf("Adding CA key for %s", ca.ClusterName)
err = a.keyStore.AddKnownHostKeys(ca.ClusterName, publicKeys)
err = a.keyStore.AddKnownHostKeys(ca.ClusterName, a.proxyHost, publicKeys)
if err != nil {
return trace.Wrap(err)
}
Expand Down Expand Up @@ -380,7 +380,7 @@ func (a *LocalKeyAgent) checkHostKey(addr string, remote net.Addr, key ssh.Publi

// If the user trusts the key, store the key in the local known hosts
// cache ~/.tsh/known_hosts.
err = a.keyStore.AddKnownHostKeys(addr, []ssh.PublicKey{key})
err = a.keyStore.AddKnownHostKeys(addr, a.proxyHost, []ssh.PublicKey{key})
if err != nil {
a.log.Warnf("Failed to save the host key: %v.", err)
return trace.Wrap(err)
Expand Down
2 changes: 1 addition & 1 deletion lib/client/keyagent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ func (s *KeyAgentTestSuite) TestHostCertVerification(c *check.C) {
c.Assert(err, check.IsNil)
caPublicKey, _, _, _, err := ssh.ParseAuthorizedKey(caPub)
c.Assert(err, check.IsNil)
err = lka.keyStore.AddKnownHostKeys("example.com", []ssh.PublicKey{caPublicKey})
err = lka.keyStore.AddKnownHostKeys("example.com", s.hostname, []ssh.PublicKey{caPublicKey})
c.Assert(err, check.IsNil)

// Generate a host certificate for node with role "node".
Expand Down
57 changes: 51 additions & 6 deletions lib/client/keystore.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ type LocalKeyStore interface {

// AddKnownHostKeys adds the public key to the list of known hosts for
// a hostname.
AddKnownHostKeys(hostname string, keys []ssh.PublicKey) error
AddKnownHostKeys(hostname, proxyHost string, keys []ssh.PublicKey) error

// GetKnownHostKeys returns all public keys for a hostname.
GetKnownHostKeys(hostname string) ([]ssh.PublicKey, error)
Expand Down Expand Up @@ -513,7 +513,7 @@ func (fs *fsLocalNonSessionKeyStore) kubeCertPath(idx KeyIndex, kubename string)
}

// AddKnownHostKeys adds a new entry to `known_hosts` file.
func (fs *fsLocalNonSessionKeyStore) AddKnownHostKeys(hostname string, hostKeys []ssh.PublicKey) (retErr error) {
func (fs *fsLocalNonSessionKeyStore) AddKnownHostKeys(hostname, proxyHost string, hostKeys []ssh.PublicKey) (retErr error) {
fp, err := os.OpenFile(fs.knownHostsPath(), os.O_CREATE|os.O_RDWR, 0640)
if err != nil {
return trace.ConvertSystemError(err)
Expand All @@ -536,13 +536,28 @@ func (fs *fsLocalNonSessionKeyStore) AddKnownHostKeys(hostname string, hostKeys
}
// add every host key to the list of entries
for i := range hostKeys {
fs.log.Debugf("Adding known host %s with key: %v", hostname, sshutils.Fingerprint(hostKeys[i]))
fs.log.Debugf("Adding known host %s with proxy %s and key: %v", hostname, proxyHost, sshutils.Fingerprint(hostKeys[i]))
bytes := ssh.MarshalAuthorizedKey(hostKeys[i])
line := strings.TrimSpace(fmt.Sprintf("%s %s", hostname, bytes))

// Write keys in an OpenSSH-compatible format. A previous format was not
// quite OpenSSH-compatible, so we may write a duplicate entry here. Any
// duplicates will be pruned below.
// We include both the proxy server and original hostname as well as the
// root domain wildcard. OpenSSH clients match against both the proxy
// host and nodes (via the wildcard). Teleport itself occasionally uses
// the root cluster name.
line := fmt.Sprintf(
"@cert-authority %s,%s,*.%s %s type=host",
proxyHost, hostname, hostname, strings.TrimSpace(string(bytes)),
)
if _, exists := entries[line]; !exists {
output = append(output, line)
}
}
// Prune any duplicate host entries for migrated hosts. Note that only
// duplicates matching the current hostname/proxyHost will be pruned; others
// will be cleaned up at subsequent logins.
output = pruneOldHostKeys(output)
// re-create the file:
_, err = fp.Seek(0, 0)
if err != nil {
Expand All @@ -557,6 +572,36 @@ func (fs *fsLocalNonSessionKeyStore) AddKnownHostKeys(hostname string, hostKeys
return fp.Sync()
}

// matchesWildcard ensures the given `hostname` matches the given `pattern`.
// The `pattern` may be prefixed with `*.` which will match exactly one domain
// segment, meaning `*.example.com` will match `foo.example.com` but not
// `foo.bar.example.com`.
func matchesWildcard(hostname, pattern string) bool {
// Trim any trailing "." in case of an absolute domain.
hostname = strings.TrimSuffix(hostname, ".")

// Don't allow non-wildcard patterns.
if !strings.HasPrefix(pattern, "*.") {
return false
}

// Never match a top-level hostname.
if !strings.Contains(hostname, ".") {
return false
}

// Don't allow empty matches.
pattern = pattern[2:]
if strings.TrimSpace(pattern) == "" {
return false
}

hostnameParts := strings.Split(hostname, ".")
hostnameRoot := strings.Join(hostnameParts[1:], ".")

return hostnameRoot == pattern
}

// GetKnownHostKeys returns all known public keys from `known_hosts`.
func (fs *fsLocalNonSessionKeyStore) GetKnownHostKeys(hostname string) ([]ssh.PublicKey, error) {
bytes, err := ioutil.ReadFile(fs.knownHostsPath())
Expand All @@ -578,7 +623,7 @@ func (fs *fsLocalNonSessionKeyStore) GetKnownHostKeys(hostname string) ([]ssh.Pu
hostMatch = (hostname == "")
if !hostMatch {
for i := range hosts {
if hosts[i] == hostname {
if hosts[i] == hostname || matchesWildcard(hostname, hosts[i]) {
hostMatch = true
break
}
Expand Down Expand Up @@ -667,7 +712,7 @@ func (noLocalKeyStore) DeleteUserCerts(idx KeyIndex, opts ...CertOption) error {
return errNoLocalKeyStore
}
func (noLocalKeyStore) DeleteKeys() error { return errNoLocalKeyStore }
func (noLocalKeyStore) AddKnownHostKeys(hostname string, keys []ssh.PublicKey) error {
func (noLocalKeyStore) AddKnownHostKeys(hostname, proxyHost string, keys []ssh.PublicKey) error {
return errNoLocalKeyStore
}
func (noLocalKeyStore) GetKnownHostKeys(hostname string) ([]ssh.PublicKey, error) {
Expand Down
43 changes: 37 additions & 6 deletions lib/client/keystore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +164,11 @@ func TestKnownHosts(t *testing.T) {
_, p2, _ := s.keygen.GenerateKeyPair("")
pub2, _, _, _, _ := ssh.ParseAuthorizedKey(p2)

err = s.store.AddKnownHostKeys("example.com", []ssh.PublicKey{pub})
err = s.store.AddKnownHostKeys("example.com", "proxy.example.com", []ssh.PublicKey{pub})
require.NoError(t, err)
err = s.store.AddKnownHostKeys("example.com", []ssh.PublicKey{pub2})
err = s.store.AddKnownHostKeys("example.com", "proxy.example.com", []ssh.PublicKey{pub2})
require.NoError(t, err)
err = s.store.AddKnownHostKeys("example.org", []ssh.PublicKey{pub2})
err = s.store.AddKnownHostKeys("example.org", "proxy.example.org", []ssh.PublicKey{pub2})
require.NoError(t, err)

keys, err := s.store.GetKnownHostKeys("")
Expand All @@ -178,9 +178,9 @@ func TestKnownHosts(t *testing.T) {

// check against dupes:
before, _ := s.store.GetKnownHostKeys("")
err = s.store.AddKnownHostKeys("example.org", []ssh.PublicKey{pub2})
err = s.store.AddKnownHostKeys("example.org", "proxy.example.org", []ssh.PublicKey{pub2})
require.NoError(t, err)
err = s.store.AddKnownHostKeys("example.org", []ssh.PublicKey{pub2})
err = s.store.AddKnownHostKeys("example.org", "proxy.example.org", []ssh.PublicKey{pub2})
require.NoError(t, err)
after, _ := s.store.GetKnownHostKeys("")
require.Equal(t, len(before), len(after))
Expand All @@ -191,6 +191,14 @@ func TestKnownHosts(t *testing.T) {
keys, _ = s.store.GetKnownHostKeys("example.org")
require.Equal(t, len(keys), 1)
require.True(t, apisshutils.KeysEqual(keys[0], pub2))

// check for proxy and wildcard as well:
keys, _ = s.store.GetKnownHostKeys("proxy.example.org")
require.Equal(t, 1, len(keys))
require.True(t, apisshutils.KeysEqual(keys[0], pub2))
keys, _ = s.store.GetKnownHostKeys("*.example.org")
require.Equal(t, 1, len(keys))
require.True(t, apisshutils.KeysEqual(keys[0], pub2))
}

// TestCheckKey makes sure Teleport clients can load non-RSA algorithms in
Expand Down Expand Up @@ -226,7 +234,7 @@ func TestProxySSHConfig(t *testing.T) {
caPub, _, _, _, err := ssh.ParseAuthorizedKey(CAPub)
require.NoError(t, err)

err = s.store.AddKnownHostKeys("127.0.0.1", []ssh.PublicKey{caPub})
err = s.store.AddKnownHostKeys("127.0.0.1", idx.ProxyHost, []ssh.PublicKey{caPub})
require.NoError(t, err)

clientConfig, err := key.ProxyClientSSHConfig(s.store)
Expand Down Expand Up @@ -538,3 +546,26 @@ func TestMemLocalKeyStore(t *testing.T) {
require.Error(t, err)
require.Nil(t, retrievedKey)
}

func TestMatchesWildcard(t *testing.T) {
// Not a wildcard pattern.
require.False(t, matchesWildcard("foo.example.com", "example.com"))

// Not a match.
require.False(t, matchesWildcard("foo.example.org", "*.example.com"))

// Too many levels deep.
require.False(t, matchesWildcard("a.b.example.com", "*.example.com"))

// Single-part hostnames never match.
require.False(t, matchesWildcard("example", "*.example.com"))
require.False(t, matchesWildcard("example", "*.example"))
require.False(t, matchesWildcard("example", "example"))
require.False(t, matchesWildcard("example", "*."))

// Valid wildcard matches.
require.True(t, matchesWildcard("foo.example.com", "*.example.com"))
require.True(t, matchesWildcard("bar.example.com", "*.example.com"))
require.True(t, matchesWildcard("bar.example.com.", "*.example.com"))
require.True(t, matchesWildcard("bar.foo", "*.foo"))
}
Loading

0 comments on commit bddcbc1

Please # to comment.