diff --git a/lib/puttyhosts/puttyhosts.go b/lib/puttyhosts/puttyhosts.go new file mode 100644 index 0000000000000..a616e406892c0 --- /dev/null +++ b/lib/puttyhosts/puttyhosts.go @@ -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 ) 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 +} diff --git a/lib/puttyhosts/puttyhosts_test.go b/lib/puttyhosts/puttyhosts_test.go new file mode 100644 index 0000000000000..e4845818b2ac3 --- /dev/null +++ b/lib/puttyhosts/puttyhosts_test.go @@ -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) + }) + } +} diff --git a/lib/utils/registry/registry_windows.go b/lib/utils/registry/registry_windows.go new file mode 100644 index 0000000000000..43f6f27727bc0 --- /dev/null +++ b/lib/utils/registry/registry_windows.go @@ -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 +} diff --git a/tool/tsh/common/putty_config.go b/tool/tsh/common/putty_config.go new file mode 100644 index 0000000000000..3a3a6df30b9b4 --- /dev/null +++ b/tool/tsh/common/putty_config.go @@ -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") +} diff --git a/tool/tsh/common/putty_config_windows.go b/tool/tsh/common/putty_config_windows.go new file mode 100644 index 0000000000000..01afe7c0729bf --- /dev/null +++ b/tool/tsh/common/putty_config_windows.go @@ -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 +} diff --git a/tool/tsh/common/tsh.go b/tool/tsh/common/tsh.go index 916ac51833518..307bedca57f22 100644 --- a/tool/tsh/common/tsh.go +++ b/tool/tsh/common/tsh.go @@ -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) @@ -1324,6 +1336,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():