Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add tsh config helper to generate OpenSSH client configuration #7437

Merged
merged 17 commits into from
Jul 22, 2021
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ac3bcc7
Add `tsh config ssh` helper to generate OpenSSH client configuration
timothyb89 Jun 29, 2021
1973fd1
Fix broken link to Trusted Clusters documentation
timothyb89 Jun 29, 2021
f78ffdb
Use text/template for SSH config generation; wrap all errors.
timothyb89 Jun 30, 2021
4a02b15
Rename config helper from `config ssh` to just `config`
timothyb89 Jul 8, 2021
75c9790
Fix known_hosts_migrate_test after rebase
timothyb89 Jul 12, 2021
a022a13
Merge branch 'master' into timothyb89/ssh-config-helper
timothyb89 Jul 12, 2021
8edfa87
First pass at review feedback
timothyb89 Jul 16, 2021
2823c69
Update docs/pages/server-access/guides/openssh.mdx
timothyb89 Jul 19, 2021
1bca517
Ensure top-level hostnames never match wildcard patterns
timothyb89 Jul 19, 2021
9a1bb50
Merge branch 'timothyb89/ssh-config-helper' of github.com:gravitation…
timothyb89 Jul 19, 2021
b596ce6
Add additional host count check to `canPruneOldHostsEntry`.
timothyb89 Jul 19, 2021
c9593e4
Merge branch 'master' into timothyb89/ssh-config-helper
timothyb89 Jul 19, 2021
5b6b0f8
Replace excess call to `isOldStyleHostsEntry` with documented invariant
timothyb89 Jul 20, 2021
71eda99
Merge branch 'master' into timothyb89/ssh-config-helper
timothyb89 Jul 20, 2021
7bde3e1
Trim trailing dots on absolute hostnames in `matchesWildcard`
timothyb89 Jul 21, 2021
e619ea9
Merge remote-tracking branch 'origin/master' into timothyb89/ssh-conf…
timothyb89 Jul 21, 2021
d04f688
Merge branch 'master' into timothyb89/ssh-config-helper
timothyb89 Jul 22, 2021
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
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 [email protected]
```

If any [trusted clusters](../../trustedclusters.mdx) exist, they are also configured:
```bash
ssh [email protected]
```

When connecting to nodes with Teleport daemons running on non-standard ports
(other than `3022`), a port may be specified:
```yaml
ssh -p 4022 [email protected]
```

<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
54 changes: 48 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,33 @@ 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 {
timothyb89 marked this conversation as resolved.
Show resolved Hide resolved
// 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:], ".")
timothyb89 marked this conversation as resolved.
Show resolved Hide resolved

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 +620,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 +709,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
39 changes: 33 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,22 @@ 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"))

// 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.foo", "*.foo"))
}
Loading