diff --git a/docs/pages/server-access/guides/openssh.mdx b/docs/pages/server-access/guides/openssh.mdx index 5d579de9a917f..a740742b3fc83 100644 --- a/docs/pages/server-access/guides/openssh.mdx +++ b/docs/pages/server-access/guides/openssh.mdx @@ -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 + + + At this time, automatic OpenSSH client configuration is only supported on + Linux and macOS. + + +`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 +``` + + + 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. + + +### 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: @@ -250,20 +319,6 @@ certificate authorities for each cluster individually. from the root auth server only. -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: diff --git a/lib/client/keyagent.go b/lib/client/keyagent.go index b818ce39d0175..220ed7cc645c1 100644 --- a/lib/client/keyagent.go +++ b/lib/client/keyagent.go @@ -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) } @@ -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) diff --git a/lib/client/keyagent_test.go b/lib/client/keyagent_test.go index 3ab07a13bee72..70a894e31164d 100644 --- a/lib/client/keyagent_test.go +++ b/lib/client/keyagent_test.go @@ -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". diff --git a/lib/client/keystore.go b/lib/client/keystore.go index b3e53f52f33f3..9cf1efabdd1dd 100644 --- a/lib/client/keystore.go +++ b/lib/client/keystore.go @@ -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) @@ -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) @@ -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 { @@ -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()) @@ -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 } @@ -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) { diff --git a/lib/client/keystore_test.go b/lib/client/keystore_test.go index 04bd548c94d24..f7d7ae4925e05 100644 --- a/lib/client/keystore_test.go +++ b/lib/client/keystore_test.go @@ -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("") @@ -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)) @@ -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 @@ -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) @@ -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")) +} diff --git a/lib/client/known_hosts_migrate.go b/lib/client/known_hosts_migrate.go new file mode 100644 index 0000000000000..6246115a61c3d --- /dev/null +++ b/lib/client/known_hosts_migrate.go @@ -0,0 +1,160 @@ +/* +Copyright 2021 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package client + +import ( + "bytes" + + "github.com/gravitational/teleport" + "github.com/gravitational/trace" + "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" +) + +// knownHostEntry is a parsed entry from a Teleport/OpenSSH known_hosts file, +// used as part of the migration/pruning process to make Teleport's known_hosts +// compatible with OpenSSH. +type knownHostEntry struct { + raw string + marker string + hosts []string + pubKey ssh.PublicKey + comment string +} + +// parseKnownHost parses a single line of a known hosts file into a struct. +func parseKnownHost(raw string) (*knownHostEntry, error) { + // Due to the lack of first-class tuples, we'll need to wrap this in a + // struct to avoid re-parsing lines constantly. We'll also keep the input + // text to preserve formatting for all passed-through entries. + marker, hosts, pubKey, comment, _, err := ssh.ParseKnownHosts([]byte(raw)) + if err != nil { + return nil, trace.Wrap(err) + } + + return &knownHostEntry{ + raw: raw, + marker: marker, + hosts: hosts, + pubKey: pubKey, + comment: comment, + }, nil +} + +// isOldStyleHostsEntry determines if a particular known host entry is explicitly +// formatted as an old-style entry. Only old-style entries are candidates for +// pruning; all others are passed through untouched. +func isOldStyleHostsEntry(entry *knownHostEntry) bool { + if entry.marker != "" { + return false + } + + if len(entry.hosts) != 1 { + return false + } + + if entry.comment != "" { + return false + } + + return true +} + +// canPruneOldHostsEntry determines if a particular old-style hosts entry has an +// equivalent new-style entry and can thus be pruned. Note that this will panic +// if `oldEntry` does not contain at least one host; `isOldStyleHostsEntry` +// validates this. +func canPruneOldHostsEntry(oldEntry *knownHostEntry, newEntries []*knownHostEntry) bool { + // Note: Per sshd's documentation, it is valid (though not recommended) for + // repeated/overlapping entries to exist for a given host; as such, it's + // only safe to prune an old entry when both the (*.)hostname and public key + // match. + + // The new-style entries prepend `*.`, so we'll add that upfront. + oldHost := "*." + oldEntry.hosts[0] + + // We'll need to marshal the keys so we can compare them properly. + oldKey := oldEntry.pubKey.Marshal() + + for _, newEntry := range newEntries { + if oldEntry.pubKey.Type() != newEntry.pubKey.Type() { + continue + } + + newKey := newEntry.pubKey.Marshal() + if !bytes.Equal(oldKey, newKey) { + continue + } + + for _, newHost := range newEntry.hosts { + if newHost == oldHost { + return true + } + } + } + + return false +} + +// pruneOldHostKeys removes all old-style host keys for which a new-style +// duplicate entry exists. This may modify order of host keys, but will not +// change their content. +func pruneOldHostKeys(output []string) []string { + log := logrus.WithField(trace.Component, teleport.ComponentMigrate) + + var ( + oldEntries = make([]*knownHostEntry, 0) + newEntries = make([]*knownHostEntry, 0) + prunedOutput = make([]string, 0) + ) + + // First, categorize all existing known hosts entries. + for i, line := range output { + parsed, err := parseKnownHost(line) + if err != nil { + // If the line isn't parseable, pass it through. + log.WithError(err).Debugf("Unable to parse known host on line %d, skipping", i+1) + prunedOutput = append(prunedOutput, line) + continue + } + + if isOldStyleHostsEntry(parsed) { + // Only old-style entries are candidates for removal. + oldEntries = append(oldEntries, parsed) + } else { + // Everything else is passed through as-is... + prunedOutput = append(prunedOutput, line) + + // ...but only new-style entries are candidates for comparison. + if parsed.marker == "cert-authority" { + newEntries = append(newEntries, parsed) + } + } + } + + // Next, for each old-style entry, determine if an existing new-style entry + // exists. If not, pass it through. + for _, entry := range oldEntries { + if canPruneOldHostsEntry(entry, newEntries) { + log.Debugf("Pruning old known_hosts entry for %s.", entry.hosts[0]) + } else { + prunedOutput = append(prunedOutput, entry.raw) + } + } + + return prunedOutput +} diff --git a/lib/client/known_hosts_migrate_test.go b/lib/client/known_hosts_migrate_test.go new file mode 100644 index 0000000000000..705fc2bf3cc01 --- /dev/null +++ b/lib/client/known_hosts_migrate_test.go @@ -0,0 +1,213 @@ +/* +Copyright 2021 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package client + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/gravitational/teleport/lib/auth/testauthority" + "github.com/gravitational/teleport/lib/defaults" + "github.com/gravitational/teleport/lib/services" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" +) + +type knownHostsMigrateTest struct { + keygen *testauthority.Keygen +} + +func newMigrateTest() knownHostsMigrateTest { + return knownHostsMigrateTest{ + keygen: testauthority.New(), + } +} + +func generateHostCert(t *testing.T, s *knownHostsMigrateTest, clusterName string) []byte { + _, hostPub, err := s.keygen.GenerateKeyPair("") + require.NoError(t, err) + + caSigner, err := ssh.ParsePrivateKey(CAPriv) + require.NoError(t, err) + + cert, err := s.keygen.GenerateHostCert(services.HostCertParams{ + CASigner: caSigner, + HostID: "127.0.0.1", + NodeName: "127.0.0.1", + ClusterName: clusterName, + CASigningAlg: defaults.CASignatureAlgorithm, + PublicHostKey: hostPub, + }) + require.Nil(t, err) + + return cert +} + +func generateOldHostEntry( + t *testing.T, s *knownHostsMigrateTest, cert []byte, clusterName string, +) *knownHostEntry { + formatted := fmt.Sprintf("%s %s", clusterName, strings.TrimSpace(string(cert))) + entry, err := parseKnownHost(formatted) + require.Nil(t, err) + require.Equal(t, formatted, entry.raw) + + return entry +} + +func generateNewHostEntry( + t *testing.T, s *knownHostsMigrateTest, cert []byte, clusterName string, proxyName string, +) *knownHostEntry { + formatted := fmt.Sprintf( + "@cert-authority %s,%s,*.%s %s type=host", + proxyName, clusterName, clusterName, strings.TrimSpace(string(cert)), + ) + entry, err := parseKnownHost(formatted) + require.Nil(t, err) + require.Equal(t, formatted, entry.raw) + + return entry +} + +func TestParseKnownHost(t *testing.T) { + s := newMigrateTest() + + oldCert := generateHostCert(t, &s, "example.com") + oldEntry := generateOldHostEntry(t, &s, oldCert, "example.com") + + require.Empty(t, oldEntry.comment) + require.Empty(t, oldEntry.marker) + require.Equal(t, []string{"example.com"}, oldEntry.hosts) + + oldCertParsed, _, _, _, err := ssh.ParseAuthorizedKey(oldCert) + require.Nil(t, err) + require.True(t, bytes.Equal(oldCertParsed.Marshal(), oldEntry.pubKey.Marshal())) + + newCert := generateHostCert(t, &s, "example.com") + newEntry := generateNewHostEntry(t, &s, newCert, "example.com", "proxy.example.com") + + require.Equal(t, "cert-authority", newEntry.marker) + require.Equal(t, []string{"proxy.example.com", "example.com", "*.example.com"}, newEntry.hosts) + require.Equal(t, "type=host", newEntry.comment) + + newCertParsed, _, _, _, err := ssh.ParseAuthorizedKey(newCert) + require.Nil(t, err) + require.True(t, bytes.Equal(newCertParsed.Marshal(), newEntry.pubKey.Marshal())) +} + +func TestIsOldHostsEntry(t *testing.T) { + s := newMigrateTest() + + // tsh's older format. + cert := generateHostCert(t, &s, "example.com") + oldEntry := generateOldHostEntry(t, &s, cert, "example.com") + require.True(t, isOldStyleHostsEntry(oldEntry)) + + // tsh's new format. + newEntry := generateNewHostEntry(t, &s, cert, "example.com", "proxy.example.com") + require.False(t, isOldStyleHostsEntry(newEntry)) + + // Also test an invalid old cert to ensure it won't be accidentally pruned. + // In this case, multiple hosts should invalidate the key. + hostsEntryString := fmt.Sprintf("foo,bar %s", strings.TrimSpace(string(cert))) + hostsEntry, err := parseKnownHost(hostsEntryString) + require.Nil(t, err) + require.False(t, isOldStyleHostsEntry(hostsEntry)) + + // Additionally, any comment invalidates it. + commentEntryString := fmt.Sprintf("foo %s comment", strings.TrimSpace(string(cert))) + commentEntry, err := parseKnownHost(commentEntryString) + require.Nil(t, err) + require.False(t, isOldStyleHostsEntry(commentEntry)) +} + +func TestCanPruneOldHostsEntry(t *testing.T) { + s := newMigrateTest() + + certFoo := generateHostCert(t, &s, "foo.example.com") + certLeaf := generateHostCert(t, &s, "leaf.example.com") + certBar := generateHostCert(t, &s, "bar.example.com") + oldEntry := generateOldHostEntry(t, &s, certFoo, "foo.example.com") + + // Valid new entries. + newValidFoo := generateNewHostEntry(t, &s, certFoo, "foo.example.com", "proxy.foo.example.com") + newValidLeaf := generateNewHostEntry(t, &s, certLeaf, "leaf.example.com", "proxy.foo.example.com") + + // An entry with a non-matching certificate for its hostname. + newInvalidFoo := generateNewHostEntry(t, &s, certBar, "foo.example.com", "proxy.foo.example.com") + + // An entry with a non-matching hostname for its certificate. + newInvalidBar := generateNewHostEntry(t, &s, certFoo, "bar.example.com", "proxy.bar.example.com") + + // Do not prune an old entry if no new entries exist. + require.False(t, canPruneOldHostsEntry(oldEntry, []*knownHostEntry{})) + + // Do not prune an old entry if the certificate and hostname don't match. + require.False(t, canPruneOldHostsEntry(oldEntry, []*knownHostEntry{newInvalidFoo})) + require.False(t, canPruneOldHostsEntry(oldEntry, []*knownHostEntry{newInvalidBar})) + + // Prune an entry even if it's not the first in the list. + require.True(t, canPruneOldHostsEntry(oldEntry, []*knownHostEntry{newValidLeaf, newValidFoo})) +} + +func TestPruneOldHostKeys(t *testing.T) { + s := newMigrateTest() + + certFoo := generateHostCert(t, &s, "foo.example.com") + certLeaf := generateHostCert(t, &s, "leaf.example.com") + certBar := generateHostCert(t, &s, "bar.example.com") + certBaz := generateHostCert(t, &s, "baz.example.com") + + allOldEntries := []string{ + generateOldHostEntry(t, &s, certFoo, "foo.example.com").raw, + generateOldHostEntry(t, &s, certLeaf, "leaf.example.com").raw, + generateOldHostEntry(t, &s, certBar, "bar.example.com").raw, + } + allNewEntries := []string{ + generateNewHostEntry(t, &s, certFoo, "foo.example.com", "proxy.foo.example.com").raw, + generateNewHostEntry(t, &s, certLeaf, "leaf.example.com", "proxy.foo.example.com").raw, + generateNewHostEntry(t, &s, certBar, "bar.example.com", "proxy.bar.example.com").raw, + generateNewHostEntry(t, &s, certBaz, "baz.example.com", "proxy.baz.example.com").raw, + } + allEntries := append(allOldEntries, allNewEntries...) + + // If only old or only new entries, prune nothing. + require.ElementsMatch(t, pruneOldHostKeys(allOldEntries), allOldEntries) + require.ElementsMatch(t, pruneOldHostKeys(allNewEntries), allNewEntries) + + // If only unmatched entries, prune nothing. Sort order may change. + unmatchedEntries := append(allOldEntries, allNewEntries[3]) // Append baz. + require.ElementsMatch(t, pruneOldHostKeys(unmatchedEntries), unmatchedEntries) + + // Only prune one entry (bar.example.com). + require.ElementsMatch( + t, + pruneOldHostKeys(append(allOldEntries, allNewEntries[2])), + append(allOldEntries[0:2], allNewEntries[2]), + ) + + // Only prune a subset (leaf cluster scenario: foo.example.com, leaf.example.com). + require.ElementsMatch( + t, + pruneOldHostKeys(append(allOldEntries, allNewEntries[0], allNewEntries[1])), + append(allNewEntries[0:2], allOldEntries[2]), + ) + + // Prune everything at once - unlikely in practice, but should still succeed. + require.ElementsMatch(t, pruneOldHostKeys(allEntries), allNewEntries) +} diff --git a/tool/tsh/config.go b/tool/tsh/config.go new file mode 100644 index 0000000000000..4103287737ad7 --- /dev/null +++ b/tool/tsh/config.go @@ -0,0 +1,123 @@ +/* +Copyright 2021 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "fmt" + "net" + "strings" + "text/template" + + "github.com/gravitational/teleport/api/profile" + "github.com/gravitational/teleport/api/utils/keypaths" + "github.com/gravitational/trace" +) + +const sshConfigTemplate = ` +# Common flags for all {{ .clusterName }} hosts +Host *.{{ .clusterName }} {{ .proxyHost }} + UserKnownHostsFile "{{ .knownHostsPath }}" + +# Flags for all {{ .clusterName }} hosts except the proxy +Host *.{{ .clusterName }} !{{ .proxyHost }} + Port 3022 + {{- if .leaf }} + ProxyCommand ssh -p {{ .proxyPort }} {{ .proxyHost }} -s proxy:$(echo %h | cut -d '.' -f 1):%p@{{ .clusterName }} + {{- else }} + ProxyCommand ssh -p {{ .proxyPort }} {{ .proxyHost }} -s proxy:$(echo %h | cut -d '.' -f 1):%p + {{- end }} +` + +// writeSSHConfig generates an OpenSSH config block from the `sshConfigTemplate` +// template string. +func writeSSHConfig(sb *strings.Builder, clusterName string, knownHostsPath string, proxyHost string, proxyPort string, leaf bool) error { + t, err := template.New("ssh-config").Parse(sshConfigTemplate) + if err != nil { + return trace.Wrap(err) + } + + err = t.Execute(sb, map[string]interface{}{ + "clusterName": clusterName, + "proxyPort": proxyPort, + "proxyHost": proxyHost, + "knownHostsPath": knownHostsPath, + "leaf": leaf, + }) + if err != nil { + return trace.WrapWithMessage(err, "error generating SSH configuration from template") + } + + return nil +} + +// onConfig handles the `tsh config` command +func onConfig(cf *CLIConf) error { + tc, err := makeClient(cf, true) + if err != nil { + return trace.Wrap(err) + } + + // Note: TeleportClient.connectToProxy() overrides the proxy address when + // JumpHosts are in use, which this does not currently implement. + proxyHost, proxyPort, err := net.SplitHostPort(tc.Config.SSHProxyAddr) + if err != nil { + return trace.Wrap(err) + } + + // Note: We explicitly opt not to use RetryWithRelogin here as it will write + // its prompt to stdout. If the user pipes this command's output, the + // destination (possibly their ssh config file) may get polluted with + // invalid output. Instead, rely on the normal error messages (which are + // sent to stderr) and expect the user to log in manually. + proxyClient, err := tc.ConnectToProxy(cf.Context) + if err != nil { + return trace.Wrap(err) + } + defer proxyClient.Close() + + rootClusterName, rootErr := proxyClient.RootClusterName() + leafClusters, leafErr := proxyClient.GetLeafClusters(cf.Context) + if err := trace.NewAggregate(rootErr, leafErr); err != nil { + return trace.Wrap(err) + } + + keysDir := profile.FullProfilePath(tc.Config.KeysDir) + knownHostsPath := keypaths.KnownHostsPath(keysDir) + + var sb strings.Builder + + // Start with a newline in case an existing config file does not end with + // one. + fmt.Fprintln(&sb) + fmt.Fprintf(&sb, "#\n# Begin generated Teleport configuration for %s from `tsh config`\n#\n", tc.Config.WebProxyAddr) + + err = writeSSHConfig(&sb, rootClusterName, knownHostsPath, proxyHost, proxyPort, false) + if err != nil { + return trace.Wrap(err) + } + + for _, leafCluster := range leafClusters { + err = writeSSHConfig(&sb, leafCluster.GetName(), knownHostsPath, proxyHost, proxyPort, true) + if err != nil { + return trace.Wrap(err) + } + } + + fmt.Fprintf(&sb, "\n# End generated Teleport configuration\n") + fmt.Print(sb.String()) + return nil +} diff --git a/tool/tsh/tsh.go b/tool/tsh/tsh.go index 3f6b5486ae22d..8ce5437b403b2 100644 --- a/tool/tsh/tsh.go +++ b/tool/tsh/tsh.go @@ -499,6 +499,8 @@ func Run(args []string, opts ...cliOption) error { // MFA subcommands. mfa := newMFACommand(app) + config := app.Command("config", "Print OpenSSH configuration details") + // On Windows, hide the "ssh", "join", "play", "scp", and "bench" commands // because they all use a terminal. if runtime.GOOS == constants.WindowsOS { @@ -507,6 +509,9 @@ func Run(args []string, opts ...cliOption) error { play.Hidden() scp.Hidden() bench.Hidden() + + // Similarly, `config` for ssh depends on bash. + config.Hidden() } // parse CLI commands+flags: @@ -632,6 +637,8 @@ func Run(args []string, opts ...cliOption) error { err = onRequestCreate(&cf) case reqReview.FullCommand(): err = onRequestReview(&cf) + case config.FullCommand(): + err = onConfig(&cf) default: // This should only happen when there's a missing switch case above. err = trace.BadParameter("command %q not configured", command)