tsh: Implement puttyconfig command to add saved PuTTY sessions to Windows registry (#19316)

* tsh: Implement puttyconfig command to add saved PuTTY sessions to Windows registry

* Addressed comments from code review

* Add support for leaf clusters

* Refactoring from code review

Also moved registry/hostname functions into external packages

* Address more feedback from code review

* Rebase following tsh/common changes

* Fix up putty_config_windows

* Reorder command

* Remove surplus comment

* Use a separate list instead of overloading the 'extra' key

* Address Tim's code review comments

* Address some of Zac's comments

* Refactor formatLocalCommandString to use text/template

* Refactor non-Windows logic into puttyhosts

* Fix subcommand name

* Fix test structure

* Add some more hostnames test cases

* Apply suggestions from code review

Co-authored-by: Zac Bergquist <zac.bergquist@goteleport.com>

* Fix up

---------

Co-authored-by: Zac Bergquist <zac.bergquist@goteleport.com>
This commit is contained in:
Gus Luxton
2023-07-05 17:53:46 +00:00
committed by GitHub
co-authored by Zac Bergquist
parent 99d82593ed
commit 8abbea6fec
6 changed files with 981 additions and 0 deletions
+240
View File
@@ -0,0 +1,240 @@
/*
Copyright 2023 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 puttyhosts
import (
"context"
"fmt"
"regexp"
"strings"
"text/template"
"github.com/gravitational/trace"
"golang.org/x/crypto/ssh"
"golang.org/x/exp/slices"
"github.com/gravitational/teleport/api/constants"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/auth"
"github.com/gravitational/teleport/lib/client"
"github.com/gravitational/teleport/lib/sshutils"
)
type PuttyProxyTelnetCommandArgs struct {
TSHPath string
Cluster string
}
type HostCAPublicKeyForRegistry struct {
KeyName string
PublicKey string
Hostname string
}
func hostnameContainsDot(hostname string) bool {
return strings.Contains(hostname, ".")
}
func hostnameisWildcard(hostname string) bool {
return strings.HasPrefix(hostname, "*.")
}
func wildcardFromHostname(hostname string) string {
if hostnameisWildcard(hostname) {
return hostname
}
// prevent a panic below if the string doesn't contain a hostname. this should never happen,
// as this function is only intended to be called after checking hostnameContainsDot.
if !hostnameContainsDot(hostname) {
return hostname
}
return fmt.Sprintf("*.%s", strings.Join(strings.Split(hostname, ".")[1:], "."))
}
// AddHostToHostList adds a new hostname to PuTTY's list of trusted hostnames for a given host CA.
//
// Background:
// - For every host CA that it is configured to trust, PuTTY maintains a list of hostnames (hostList) which it should consider
// to be valid for that host CA. This is the same as the @cert-authority lines in an `~/.ssh/known_hosts` file.
// - Trusted hostnames can be individual entries (host1, host2) or wildcards like "*.example.com".
// - PuTTY keeps this list of hostnames stored against each host CA in the Windows registry. It exposes a GUI (under
// Connection -> SSH -> Host Keys -> Configure Host CAs at the time of writing) which expects any new host CAs and
// trusted hostnames for each to be added manually by end users as part of session configuration.
// - This process is mandatory for validation of host CAs in PuTTY to work, but is a cumbersome manual process with many
// clicks required in a nested interface. Instead, this function is called as part of `tsh puttyconfig` to examine the
// existing list of trusted hostnames and automate the process of adding a new valid hostname to a given host CA.
//
// Connection flow:
// - When connecting to a host which presents a host CA, PuTTY searches its list of CAs to find any which are considered
// valid for that hostname, then checks whether the host's presented CA matches any of them. If there is a CA -> hostname
// match, the connection will continue successfully. If not, an error will be shown.
//
// Intended operation of this function:
// - This function is passed the current list of trusted hostnames for a given host CA (retrieved from the registry), along
// with a new hostname entry (from tsh puttyconfig <hostname>) which should be added to the list.
// - It appends the new hostname to the end of the hostList
// - All hostnames in the hostList are converted to their wildcard form if they contain a dot (test.example.com -> *.example.com)
// and are grouped together.
// - If a wildcard group only contains a single hostname which would be matched by its wildcard equivalent, that hostname is added
// to the hostList verbatim to prevent inadvertently matching against too many hosts with the same wildcard.
// - If a wildcard matches more than one hostname, the wildcard will be added to the hostList instead and the single hostnames
// discarded.
// - The hostList is then sorted alphabetically and returned.
//
// This is an effort to keep the length of hostList as short as possible for efficiency and tidiness, while not using any more
// wildcards than necessary and preventing the need for end users to manually configure their trusted host CAs.
func AddHostToHostList(hostList []string, hostname string) []string {
// add the incoming hostname to the hostList before we sort and process it
hostList = append(hostList, hostname)
hostMap := make(map[string][]string)
var extraHosts []string
// iterate over the full hostList
// if the element is a wildcard, add it to the list of wildcards
// if the element is not a wildcard, convert it to a wildcard and add any hostnames it matches to a map
for _, element := range hostList {
// FQDN-based hosts are grouped under a wildcard key
if hostnameContainsDot(element) {
wildcard := wildcardFromHostname(element)
if !slices.Contains(hostMap[wildcard], element) {
hostMap[wildcard] = append(hostMap[wildcard], element)
}
} else {
// any non-wildcard hosts go into the extraHosts list and will be processed separately
extraHosts = append(extraHosts, element)
}
}
var outputHostList []string
// first, add all non-wildcard matches separately
for _, hostname := range extraHosts {
if !slices.Contains(outputHostList, hostname) {
outputHostList = append(outputHostList, hostname)
}
}
// iterate over the map, look for all wildcard keys with more than one hostname matching.
// for each match, add the wildcard to the hostList.
for key, matchingHostnames := range hostMap {
// add all wildcards with more than one hostname matching
if len(matchingHostnames) > 1 {
outputHostList = append(outputHostList, key)
} else {
// add the single hostname which is matched by the given wildcard
outputHostList = append(outputHostList, matchingHostnames[0])
}
}
slices.Sort(outputHostList)
return outputHostList
}
var hostnameRegexp = regexp.MustCompile("^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]).)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$")
// NaivelyValidateHostname checks the provided hostname against a naive regex to ensure it doesn't contain obviously
// illegal characters. It's not guaranteed to be perfect, just a simple sanity check. It returns true when the hostname validates.
func NaivelyValidateHostname(hostname string) bool {
return hostnameRegexp.MatchString(hostname)
}
// FormatLocalCommandString replaces placeholders in a constant with actual values
func FormatLocalCommandString(tshPath string, cluster string) (string, error) {
// PuTTY needs its paths to be double-escaped i.e. C:\\Users\\User\\tsh.exe
escapedTSHPath := strings.ReplaceAll(tshPath, `\`, `\\`)
// build the command using a template
templateString := "{{.TSHPath}} proxy ssh --cluster={{.Cluster}} --proxy=%proxyhost %user@%host:%port"
localCommandTemplate := template.Must(template.New("puttyProxyTelnetCommand").Parse(templateString))
var builder strings.Builder
err := localCommandTemplate.Execute(&builder, PuttyProxyTelnetCommandArgs{
TSHPath: escapedTSHPath,
Cluster: cluster,
})
if err != nil {
return "", trace.Wrap(err)
}
return builder.String(), nil
}
// getAllHostCAs queries the root cluster for its host CAs
func getAllHostCAs(tc *client.TeleportClient, cfContext context.Context) ([]types.CertAuthority, error) {
var err error
// get all CAs for the cluster (including trusted clusters)
var cas []types.CertAuthority
err = tc.WithRootClusterClient(cfContext, func(clt auth.ClientI) error {
cas, err = clt.GetCertAuthorities(cfContext, types.HostCA, false /* exportSecrets */)
if err != nil {
return trace.Wrap(err)
}
return nil
})
if err != nil {
return nil, trace.Wrap(err)
}
return cas, nil
}
// ProcessHostCAPublicKeys gets all the host CAs that the passed client can load (which will be a root cluster and any connected leaf clusters),
// iterates over them to find any host CAs which map to the requested root or leaf cluster and builds a map containing [targetClusterName]->[]CAs.
// These host CA public keys are then ultimately written to the registry so that PuTTY can validate host keys against them when connecting.
func ProcessHostCAPublicKeys(tc *client.TeleportClient, cfContext context.Context, clusterName string) (map[string][]string, error) {
// iterate over all the CAs
hostCAPublicKeys := make(map[string][]string)
hostCAs, err := getAllHostCAs(tc, cfContext)
if err != nil {
return nil, trace.Wrap(err)
}
for _, ca := range hostCAs {
// if this is either the root or the requested leaf cluster, process it
if ca.GetName() == clusterName {
for _, key := range ca.GetTrustedSSHKeyPairs() {
kh, err := sshutils.MarshalKnownHost(sshutils.KnownHost{
Hostname: ca.GetClusterName(),
AuthorizedKey: key.PublicKey,
})
if err != nil {
return nil, trace.Wrap(err)
}
_, _, hostCABytes, _, _, err := ssh.ParseKnownHosts([]byte(kh))
if err != nil {
return nil, trace.Wrap(err)
}
hostCAPublicKey := strings.TrimPrefix(strings.TrimSpace(string(ssh.MarshalAuthorizedKey(hostCABytes))), constants.SSHRSAType+" ")
hostCAPublicKeys[ca.GetName()] = append(hostCAPublicKeys[ca.GetName()], hostCAPublicKey)
}
}
}
return hostCAPublicKeys, nil
}
// FormatHostCAPublicKeysFoRegistry formats a map of clusterNames -> []CAs into a platform-agnostic intermediate
// struct format. This format is passed into functions which write to the Windows registry.
func FormatHostCAPublicKeysForRegistry(hostCAPublicKeys map[string][]string, hostname string) map[string][]HostCAPublicKeyForRegistry {
registryOutput := make(map[string][]HostCAPublicKeyForRegistry)
// add all host CA public keys for cluster
for cluster, hostCAs := range hostCAPublicKeys {
baseKeyName := fmt.Sprintf(`TeleportHostCA-%v`, cluster)
for i, publicKey := range hostCAs {
// append indices to entries if we have multiple public keys for a CA
keyName := baseKeyName
if len(hostCAs) > 1 {
keyName = fmt.Sprintf(`%v-%d`, baseKeyName, i)
}
registryOutput[cluster] = append(registryOutput[cluster], HostCAPublicKeyForRegistry{keyName, publicKey, hostname})
}
}
return registryOutput
}
+307
View File
@@ -0,0 +1,307 @@
/*
Copyright 2023 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 puttyhosts
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
)
func TestAddHostToHostList(t *testing.T) {
t.Parallel()
var tests = []struct {
hostList []string
hostname string
expected []string
}{
{
hostList: []string{
"one.example.com",
"two.example.com",
},
hostname: "three.example.com",
expected: []string{
"*.example.com",
},
},
{
hostList: []string{
"one",
"two",
},
hostname: "three",
expected: []string{
"one",
"two",
"three",
},
},
{
hostList: []string{
"*.example.com",
},
hostname: "one.example.com",
expected: []string{
"*.example.com",
},
},
{
hostList: []string{
"one.example.com",
},
hostname: "two.example.com",
expected: []string{
"*.example.com",
},
},
{
hostList: []string{
"one.alpha.example.com",
"two.beta.example.com",
"three.beta.example.com",
},
hostname: "four.charlie.example.com",
expected: []string{
"one.alpha.example.com",
"*.beta.example.com",
"four.charlie.example.com",
},
},
{
hostList: []string{
"one.alpha.example.com",
"two.beta.example.com",
"three.beta.example.com",
},
hostname: "four",
expected: []string{
"one.alpha.example.com",
"*.beta.example.com",
"four",
},
},
{
hostList: []string{
"eggs.breakfast",
"bacon.breakfast",
"mimosa.breakfast",
"salad.lunch",
},
hostname: "soup.lunch",
expected: []string{
"*.breakfast",
"*.lunch",
},
},
{
hostList: []string{
"*.breakfast",
"*.lunch",
"fish.dinner",
"chips.dinner",
},
hostname: "apple.dessert",
expected: []string{
"*.breakfast",
"*.lunch",
"*.dinner",
"apple.dessert",
},
},
{
hostList: []string{
"one",
"two",
"three.example.com",
"four.example.com",
"five.test.com",
},
hostname: "six",
expected: []string{
"one",
"two",
"*.example.com",
"five.test.com",
"six",
},
},
}
for i, tt := range tests {
t.Run(fmt.Sprintf("test case %d", i), func(t *testing.T) {
output := AddHostToHostList(tt.hostList, tt.hostname)
require.ElementsMatch(t, tt.expected, output)
})
}
}
func TestFormatLocalCommandString(t *testing.T) {
t.Parallel()
var tests = []struct {
PuttyProxyTelnetCommandArgs
expectedOutput string
}{
{
PuttyProxyTelnetCommandArgs{
TSHPath: `C:\Users\Test\tsh.exe`,
Cluster: `teleport.example.com`,
},
`C:\\Users\\Test\\tsh.exe proxy ssh --cluster=teleport.example.com --proxy=%proxyhost %user@%host:%port`,
},
{
PuttyProxyTelnetCommandArgs{
TSHPath: `Z:\localdata\installation path with spaces\teleport\tsh-v13.1.3.exe`,
Cluster: `long-cluster-name-that-isnt-an-fqdn`,
},
`Z:\\localdata\\installation path with spaces\\teleport\\tsh-v13.1.3.exe proxy ssh --cluster=long-cluster-name-that-isnt-an-fqdn --proxy=%proxyhost %user@%host:%port`,
},
{
PuttyProxyTelnetCommandArgs{
TSHPath: `\\SERVER01\UNC\someotherpath\gravitational-teleport-tsh-embedded.exe`,
Cluster: `bigcorp.co1fqdn01.ad.enterpriseydomain.local`,
},
`\\\\SERVER01\\UNC\\someotherpath\\gravitational-teleport-tsh-embedded.exe proxy ssh --cluster=bigcorp.co1fqdn01.ad.enterpriseydomain.local --proxy=%proxyhost %user@%host:%port`,
},
}
for i, tt := range tests {
t.Run(fmt.Sprintf("test case %d", i), func(t *testing.T) {
output, err := FormatLocalCommandString(tt.TSHPath, tt.Cluster)
require.Equal(t, tt.expectedOutput, output)
require.NoError(t, err)
})
}
}
func TestFormatHostCAPublicKeysForRegistry(t *testing.T) {
t.Parallel()
var tests = []struct {
inputMap map[string][]string
hostname string
expectedOutput map[string][]HostCAPublicKeyForRegistry
}{
{
inputMap: map[string][]string{
"teleport.example.com": {
`AAAAB3NzaC1yc2EAAAADAQABAAABAQDNbSbDa+bAjeH6wQPMfcUoyKHOTOwBRc1Lr+5Vy6aHOz+lWsovldH0r4mGFv2mLyWmqax18YVWG/YY+5um9y19SxlIHcAZI/uqnV7lAOhVkni87CGZ+Noww512dlrtczYZDc4735mSYxcSYQyRZywwXOfSqA0Euc6P2a0e03hcdROeJxx50xQcDw/wjreot5swiVHOvOGIIauekPswP58Z+F4goIFaFk5i5gDDBfX4mvtFV5AOkYQlk4hzmwJZ2JpphUQ33YbwhDrEPat2/mLf1tUk6aY8qHFqE9g5bjFjuLQxeva3Y5in49Zt+pg701TbBwS+R8wbuQqDM8b7VgEV`,
`AAAAB3NzaC1yc2EAAAADAQABAAABAQDm0PWl5llSpFArdHkXv8xXgsO9qEAbjvIAjMaoUbr79d03pBlmCCU7Zm3X9NkiLL7om2KLSE7AA0oQI+S+VgrDX17S327uj8M3hNZkfkbKGvzY5NS17DubpEEuAoF1r8Of7GKMbAmQ9d8dF8iNkREaJ+FT8g2JmGtRwmQGf8c0v2FCdz7SbChE9nUxk4Q8f1Qjhx8Pgjga/ntqkB+JpwATVvCxkd/ld0yzh9T0l90dV1TYYwnmWVpQzes1nbotQoMK8vUO20dWBEMWVMxXXp/P4OaztYGLmGJ9YP9upxq8IoSUdef7URUuJZGPWEyCQ0Mk6GRYJHvlX5cNOSHxYDBt`,
},
},
hostname: "test-hostname.example.com",
expectedOutput: map[string][]HostCAPublicKeyForRegistry{
"teleport.example.com": {
HostCAPublicKeyForRegistry{
KeyName: "TeleportHostCA-teleport.example.com-0",
PublicKey: "AAAAB3NzaC1yc2EAAAADAQABAAABAQDNbSbDa+bAjeH6wQPMfcUoyKHOTOwBRc1Lr+5Vy6aHOz+lWsovldH0r4mGFv2mLyWmqax18YVWG/YY+5um9y19SxlIHcAZI/uqnV7lAOhVkni87CGZ+Noww512dlrtczYZDc4735mSYxcSYQyRZywwXOfSqA0Euc6P2a0e03hcdROeJxx50xQcDw/wjreot5swiVHOvOGIIauekPswP58Z+F4goIFaFk5i5gDDBfX4mvtFV5AOkYQlk4hzmwJZ2JpphUQ33YbwhDrEPat2/mLf1tUk6aY8qHFqE9g5bjFjuLQxeva3Y5in49Zt+pg701TbBwS+R8wbuQqDM8b7VgEV",
Hostname: "test-hostname.example.com",
},
HostCAPublicKeyForRegistry{
KeyName: "TeleportHostCA-teleport.example.com-1",
PublicKey: "AAAAB3NzaC1yc2EAAAADAQABAAABAQDm0PWl5llSpFArdHkXv8xXgsO9qEAbjvIAjMaoUbr79d03pBlmCCU7Zm3X9NkiLL7om2KLSE7AA0oQI+S+VgrDX17S327uj8M3hNZkfkbKGvzY5NS17DubpEEuAoF1r8Of7GKMbAmQ9d8dF8iNkREaJ+FT8g2JmGtRwmQGf8c0v2FCdz7SbChE9nUxk4Q8f1Qjhx8Pgjga/ntqkB+JpwATVvCxkd/ld0yzh9T0l90dV1TYYwnmWVpQzes1nbotQoMK8vUO20dWBEMWVMxXXp/P4OaztYGLmGJ9YP9upxq8IoSUdef7URUuJZGPWEyCQ0Mk6GRYJHvlX5cNOSHxYDBt",
Hostname: "test-hostname.example.com",
},
},
},
},
{
inputMap: map[string][]string{
"testClusterTwo": {
`AAAAB3NzaC1yc2EAAAADAQABAAABAQC09sJMb0CHzA8S/bYzHIsP1SgkwMD5QYOLqWhx8skWpheUZK7rTjW4y254CgLIcgGtsYyRdROs1F7IChAqfn9afCSz2a4o9tZiGXdUDw9mCB54aYF/l3WST8y+TOApSaq2Aduxagm4VlWTtohdEIKVphm7l6Dp3kTz2llQ+0qmV338d8InaEFXXhVhfOZ0/erLuFllMkeMQ66R7yjNyubY/bZy3PMF2Miv7VfX8SXAgkkS40v1esHxS26NnyD3l3MwXh99peoYQcDevq6EwYMmKSvdHgcUT+Sm9LJx48+n6ejHTUOZw2E64I26LD6PiIoFavyWVSPN/06W6n1gvmbb`,
},
},
hostname: "some-other-test-host",
expectedOutput: map[string][]HostCAPublicKeyForRegistry{
"testClusterTwo": {
HostCAPublicKeyForRegistry{
KeyName: "TeleportHostCA-testClusterTwo",
PublicKey: "AAAAB3NzaC1yc2EAAAADAQABAAABAQC09sJMb0CHzA8S/bYzHIsP1SgkwMD5QYOLqWhx8skWpheUZK7rTjW4y254CgLIcgGtsYyRdROs1F7IChAqfn9afCSz2a4o9tZiGXdUDw9mCB54aYF/l3WST8y+TOApSaq2Aduxagm4VlWTtohdEIKVphm7l6Dp3kTz2llQ+0qmV338d8InaEFXXhVhfOZ0/erLuFllMkeMQ66R7yjNyubY/bZy3PMF2Miv7VfX8SXAgkkS40v1esHxS26NnyD3l3MwXh99peoYQcDevq6EwYMmKSvdHgcUT+Sm9LJx48+n6ejHTUOZw2E64I26LD6PiIoFavyWVSPN/06W6n1gvmbb",
Hostname: "some-other-test-host",
},
},
},
},
}
for i, tt := range tests {
t.Run(fmt.Sprintf("test case %d", i), func(t *testing.T) {
testOutput := FormatHostCAPublicKeysForRegistry(tt.inputMap, tt.hostname)
require.Equal(t, tt.expectedOutput, testOutput)
})
}
}
func TestNaivelyValidateHostname(t *testing.T) {
t.Parallel()
var tests = []struct {
hostname string
shouldPass bool
}{
{
hostname: "teleport.example.com",
shouldPass: true,
},
{
hostname: "hostname",
shouldPass: true,
},
{
hostname: "testhost-withdashes.example.com",
shouldPass: true,
},
{
hostname: "itendswithnumbers0123",
shouldPass: true,
},
{
hostname: "0123itstartswithnumbers",
shouldPass: true,
},
{
hostname: "hostname-",
shouldPass: false,
},
{
hostname: "general.",
shouldPass: false,
},
{
hostname: "-startswithadash",
shouldPass: false,
},
{
hostname: "endswithadash-",
shouldPass: false,
},
{
hostname: "consecutive..dots",
shouldPass: false,
},
}
for _, tt := range tests {
t.Run(tt.hostname, func(t *testing.T) {
testResult := NaivelyValidateHostname(tt.hostname)
require.Equal(t, tt.shouldPass, testResult)
})
}
}
+84
View File
@@ -0,0 +1,84 @@
/*
Copyright 2022 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 registry
import (
"errors"
"os"
"strconv"
"golang.org/x/sys/windows/registry"
"github.com/gravitational/trace"
log "github.com/sirupsen/logrus"
)
// GetOrCreateRegistryKey loads or creates a registry key handle.
// The key handle must be released with Close() when it is no longer needed.
func GetOrCreateRegistryKey(name string) (registry.Key, error) {
reg, err := registry.OpenKey(registry.CURRENT_USER, name, registry.QUERY_VALUE|registry.CREATE_SUB_KEY|registry.SET_VALUE)
switch {
case errors.Is(err, os.ErrNotExist):
log.Debugf("Registry key %v doesn't exist, trying to create it", name)
reg, _, err = registry.CreateKey(registry.CURRENT_USER, name, registry.QUERY_VALUE|registry.CREATE_SUB_KEY|registry.SET_VALUE)
if err != nil {
log.Debugf("Can't create registry key %v: %v", name, err)
return reg, err
}
case err != nil:
log.Errorf("registry.OpenKey returned error: %v", err)
return reg, err
default:
return reg, nil
}
return reg, nil
}
// WriteDword writes a DWORD value to the given registry key handle
func WriteDword(k registry.Key, name string, value string) error {
dwordValue, err := strconv.ParseUint(value, 10, 32)
if err != nil {
log.Debugf("Failed to convert value %v to uint32: %v", value, err)
return trace.Wrap(err)
}
err = k.SetDWordValue(name, uint32(dwordValue))
if err != nil {
log.Debugf("Failed to write dword %v: %v to registry key %v: %v", name, value, k, err)
return trace.Wrap(err)
}
return nil
}
// registryWriteString writes a string (SZ) value to the given registry key handle
func WriteString(k registry.Key, name string, value string) error {
err := k.SetStringValue(name, value)
if err != nil {
log.Debugf("Failed to write string %v: %v to registry key %v: %v", name, value, k, err)
return trace.Wrap(err)
}
return nil
}
// registryWriteMultiString writes a multi-string value (MULTI_SZ) to the given registry key handle
func WriteMultiString(k registry.Key, name string, values []string) error {
err := k.SetStringsValue(name, values)
if err != nil {
log.Debugf("Failed to write strings %v: %v to registry key %v: %v", name, values, k, err)
return trace.Wrap(err)
}
return nil
}
+28
View File
@@ -0,0 +1,28 @@
//go:build !windows
/*
Copyright 2022 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 common
import (
"github.com/gravitational/trace"
)
// onPuttyConfig handles the `tsh puttyconfig` subcommand
func onPuttyConfig(cf *CLIConf) error {
return trace.NotImplemented("PuTTY config is only implemented on Windows")
}
+308
View File
@@ -0,0 +1,308 @@
/*
Copyright 2022 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 common
import (
"fmt"
"net"
"syscall"
"github.com/gravitational/teleport/api/profile"
"github.com/gravitational/teleport/api/utils/keypaths"
"github.com/gravitational/teleport/lib/puttyhosts"
"github.com/gravitational/teleport/lib/utils/registry"
"github.com/gravitational/trace"
)
// the key should not include HKEY_CURRENT_USER
const puttyRegistryKey = `SOFTWARE\SimonTatham\PuTTY`
const puttyRegistrySessionsKey = puttyRegistryKey + `\Sessions`
const puttyRegistrySSHHostCAsKey = puttyRegistryKey + `\SshHostCAs`
// strings
const puttyProtocol = `ssh`
// ints
const puttyDefaultSSHPort = 3022
const puttyDefaultProxyPort = 0 // no need to set the proxy port as it's abstracted by `tsh proxy ssh`
// dwords
const puttyDwordPresent = `00000001`
const puttyDwordProxyMethod = `00000005` // run a local command
const puttyDwordProxyLogToTerm = `00000002` // only until session starts
const puttyPermitRSASHA1 = `00000000`
const puttyPermitRSASHA256 = `00000001`
const puttyPermitRSASHA512 = `00000001`
// despite the strings/ints in struct, these are stored in the registry as DWORDs
type puttyRegistrySessionDwords struct {
Present string // dword
PortNumber int // dword
ProxyPort int // dword
ProxyMethod string // dword
ProxyLogToTerm string // dword
}
type puttyRegistrySessionStrings struct {
Hostname string
Protocol string
ProxyHost string
ProxyUsername string
ProxyPassword string
ProxyTelnetCommand string
PublicKeyFile string
DetachedCertificate string
UserName string
}
// addPuTTYSession adds a PuTTY session for the given host/port to the Windows registry
func addPuTTYSession(proxyHostname string, hostname string, port int, login string, ppkFilePath string, certificateFilePath string, commandToRun string, leafClusterName string) error {
// note: the use of ` and double % signs here is intentional
// the registry key is named "hostname.example.com%20(proxy:teleport.example.com)"
// this produces a session name which displays in PuTTY as "hostname.example.com (proxy:teleport.example.com)"
puttySessionName := fmt.Sprintf(`%v%%20(proxy:%v)`, hostname, proxyHostname)
if leafClusterName != "" {
// the registry key is named "hostname.example.com%20(leaf:leaf.example.com,proxy:teleport.example.com)"
// this produces a session name which displays in PuTTY as "hostname.example.com (leaf:leaf.example.com,proxy:teleport.example.com)"
puttySessionName = fmt.Sprintf(`%v%%20(leaf:%v,proxy:%v)`, hostname, leafClusterName, proxyHostname)
}
registryKey := fmt.Sprintf(`%v\%v`, puttyRegistrySessionsKey, puttySessionName)
// if the port passed is 0, this means "use server default" so we override it to 3022
if port == 0 {
port = puttyDefaultSSHPort
}
sessionDwords := puttyRegistrySessionDwords{
Present: puttyDwordPresent,
PortNumber: port,
ProxyPort: puttyDefaultProxyPort,
ProxyMethod: puttyDwordProxyMethod,
ProxyLogToTerm: puttyDwordProxyLogToTerm,
}
sessionStrings := puttyRegistrySessionStrings{
Hostname: hostname,
Protocol: puttyProtocol,
ProxyHost: proxyHostname,
ProxyUsername: login,
ProxyPassword: "",
ProxyTelnetCommand: commandToRun,
PublicKeyFile: ppkFilePath,
DetachedCertificate: certificateFilePath,
UserName: login,
}
// now check for and create the individual session key
pk, err := registry.GetOrCreateRegistryKey(registryKey)
if err != nil {
return trace.Wrap(err)
}
defer pk.Close()
// write dwords
if err := registry.WriteDword(pk, "Present", sessionDwords.Present); err != nil {
return trace.Wrap(err)
}
if err := registry.WriteDword(pk, "PortNumber", fmt.Sprintf("%v", sessionDwords.PortNumber)); err != nil {
return trace.Wrap(err)
}
if err := registry.WriteDword(pk, "ProxyPort", fmt.Sprintf("%v", sessionDwords.ProxyPort)); err != nil {
return trace.Wrap(err)
}
if err := registry.WriteDword(pk, "ProxyMethod", sessionDwords.ProxyMethod); err != nil {
return trace.Wrap(err)
}
if err := registry.WriteDword(pk, "ProxyLogToTerm", sessionDwords.ProxyLogToTerm); err != nil {
return trace.Wrap(err)
}
// write strings
if err := registry.WriteString(pk, "Hostname", sessionStrings.Hostname); err != nil {
return trace.Wrap(err)
}
if err := registry.WriteString(pk, "Protocol", sessionStrings.Protocol); err != nil {
return trace.Wrap(err)
}
if err := registry.WriteString(pk, "ProxyHost", sessionStrings.ProxyHost); err != nil {
return trace.Wrap(err)
}
if err := registry.WriteString(pk, "ProxyUsername", sessionStrings.ProxyUsername); err != nil {
return trace.Wrap(err)
}
if err := registry.WriteString(pk, "ProxyTelnetCommand", sessionStrings.ProxyTelnetCommand); err != nil {
return trace.Wrap(err)
}
if err := registry.WriteString(pk, "PublicKeyFile", sessionStrings.PublicKeyFile); err != nil {
return trace.Wrap(err)
}
if err := registry.WriteString(pk, "DetachedCertificate", sessionStrings.DetachedCertificate); err != nil {
return trace.Wrap(err)
}
if err := registry.WriteString(pk, "UserName", sessionStrings.UserName); err != nil {
return trace.Wrap(err)
}
return nil
}
// addHostCAPublicKey adds a host CA to the registry with a set of space-separated hostnames
func addHostCAPublicKey(registryHostCAStruct puttyhosts.HostCAPublicKeyForRegistry) error {
registryKeyName := fmt.Sprintf(`%v\%v`, puttyRegistrySSHHostCAsKey, registryHostCAStruct.KeyName)
// get the subkey with the host CA key name
registryKey, err := registry.GetOrCreateRegistryKey(registryKeyName)
if err != nil {
return trace.Wrap(err)
}
defer registryKey.Close()
hostList, _, err := registryKey.GetStringsValue("MatchHosts")
if err != nil {
// ERROR_FILE_NOT_FOUND is an acceptable error, meaning that the value does not already
// exist and it must be created
if err != syscall.ERROR_FILE_NOT_FOUND {
log.Debugf("Can't get registry value %v: %T", registryKeyName, err)
return trace.Wrap(err)
}
}
// initialize an empty hostlist if there isn't one stored under the registry key
if len(hostList) == 0 {
hostList = []string{}
}
// add the new hostname to the existing hostList from the registry key (if one exists)
hostList = puttyhosts.AddHostToHostList(hostList, registryHostCAStruct.Hostname)
// write strings to subkey
if err := registry.WriteMultiString(registryKey, "MatchHosts", hostList); err != nil {
return trace.Wrap(err)
}
if err := registry.WriteString(registryKey, "PublicKey", registryHostCAStruct.PublicKey); err != nil {
return trace.Wrap(err)
}
// write dwords for signature acceptance
if err := registry.WriteDword(registryKey, "PermitRSASHA1", puttyPermitRSASHA1); err != nil {
return trace.Wrap(err)
}
if err := registry.WriteDword(registryKey, "PermitRSASHA256", puttyPermitRSASHA256); err != nil {
return trace.Wrap(err)
}
if err := registry.WriteDword(registryKey, "PermitRSASHA512", puttyPermitRSASHA512); err != nil {
return trace.Wrap(err)
}
return nil
}
// onPuttyConfig handles the `tsh puttyconfig` subcommand
func onPuttyConfig(cf *CLIConf) error {
tc, err := makeClient(cf)
if err != nil {
return trace.Wrap(err)
}
// validate hostname against a naive regex to make sure it doesn't contain obviously illegal characters due
// to typos or similar. setting an "invalid" key in the registry makes it impossible to delete via the PuTTY
// UI and requires registry edits, so it's much better to error out early here.
hostname := tc.Config.Host
if !puttyhosts.NaivelyValidateHostname(hostname) {
return trace.BadParameter("provided hostname %v does not look like a valid hostname. Make sure it doesn't contain illegal characters.", hostname)
}
port := tc.Config.HostPort
userHostString := hostname
login := ""
if tc.Config.HostLogin != "" {
login = tc.Config.HostLogin
userHostString = fmt.Sprintf("%v@%v", login, userHostString)
}
// connect to proxy to fetch cluster info
proxyClient, err := tc.ConnectToProxy(cf.Context)
if err != nil {
return trace.Wrap(err)
}
defer proxyClient.Close()
// parse out proxy details
proxyHost, _, err := net.SplitHostPort(tc.Config.SSHProxyAddr)
if err != nil {
return trace.Wrap(err)
}
// get root cluster name and set keypaths
rootClusterName, err := proxyClient.RootClusterName(cf.Context)
if err != nil {
return trace.Wrap(err)
}
keysDir := profile.FullProfilePath(tc.Config.KeysDir)
ppkFilePath := keypaths.PPKFilePath(keysDir, proxyHost, tc.Config.Username)
certificateFilePath := keypaths.SSHCertPath(keysDir, proxyHost, tc.Config.Username, rootClusterName)
targetClusterName := rootClusterName
if cf.LeafClusterName != "" {
targetClusterName = cf.LeafClusterName
}
var hostCAPublicKeys map[string][]string
hostCAPublicKeys, err = puttyhosts.ProcessHostCAPublicKeys(tc, cf.Context, targetClusterName)
// update the cert path if a leaf cluster was requested
proxyCommandClusterName := rootClusterName
if cf.LeafClusterName != "" {
// if we haven't found the requested leaf cluster, error out
if _, ok := hostCAPublicKeys[cf.LeafClusterName]; !ok {
return trace.NotFound("Cannot find registered leaf cluster %q. Use the leaf cluster name as it appears in the output of `tsh clusters`.", cf.LeafClusterName)
}
proxyCommandClusterName = cf.LeafClusterName
}
// format all the applicable host CAs into an intermediate data struct that can be added to the registry
addToRegistry := puttyhosts.FormatHostCAPublicKeysForRegistry(hostCAPublicKeys, hostname)
for cluster, values := range addToRegistry {
for i, registryPublicKeyStruct := range values {
if err := addHostCAPublicKey(registryPublicKeyStruct); err != nil {
log.Errorf("Failed to add host CA key for %v: %T", cluster, err)
return trace.Wrap(err)
}
log.Debugf("Added/updated host CA key %d for %v", i, cluster)
}
}
// format local command string (to run 'tsh proxy ssh')
localCommandString, err := puttyhosts.FormatLocalCommandString(cf.executablePath, proxyCommandClusterName)
if err != nil {
return trace.Wrap(err)
}
// add session to registry
if err := addPuTTYSession(proxyHost, tc.Config.Host, port, login, ppkFilePath, certificateFilePath, localCommandString, cf.LeafClusterName); err != nil {
log.Errorf("Failed to add PuTTY session for %v: %T\n", userHostString, err)
return trace.Wrap(err)
}
// handle leaf clusters
if cf.LeafClusterName != "" {
fmt.Printf("Added PuTTY session for %v [leaf:%v,proxy:%v]\n", userHostString, cf.LeafClusterName, proxyHost)
return nil
}
fmt.Printf("Added PuTTY session for %v [proxy:%v]\n", userHostString, proxyHost)
return nil
}
+14
View File
@@ -460,6 +460,9 @@ type CLIConf struct {
// authentication function.
// Defaults to [dtauthn.NewCeremony().Run].
DTAuthnRunCeremony client.DTAuthnRunCeremonyFunc
// LeafClusterName is the optional name of a leaf cluster to connect to instead
LeafClusterName string
}
// Stdout returns the stdout writer.
@@ -1003,6 +1006,15 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error {
config := app.Command("config", "Print OpenSSH configuration details.")
puttyConfig := app.Command("puttyconfig", "Add PuTTY saved session configuration for specified hostname to Windows registry")
puttyConfig.Arg("[user@]host", "Remote hostname and optional login to use").Required().StringVar(&cf.UserHost)
puttyConfig.Flag("port", "SSH port on a remote host").Short('p').Int32Var(&cf.NodePort)
puttyConfig.Flag("leaf", "Add a configuration for connecting to a leaf cluster").StringVar(&cf.LeafClusterName)
// only expose `tsh puttyconfig` subcommand on windows
if runtime.GOOS != constants.WindowsOS {
puttyConfig.Hidden()
}
// FIDO2, TouchID and WebAuthnWin commands.
f2 := newFIDO2Command(app)
tid := newTouchIDCommand(app)
@@ -1323,6 +1335,8 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error {
err = onRequestDrop(&cf)
case config.FullCommand():
err = onConfig(&cf)
case puttyConfig.FullCommand():
err = onPuttyConfig(&cf)
case aws.FullCommand():
err = onAWS(&cf)
case azure.FullCommand():