-
Notifications
You must be signed in to change notification settings - Fork 2.1k
tsh: Implement puttyconfig command to add saved PuTTY sessions to Windows registry #19316
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
085600d
tsh: Implement puttyconfig command to add saved PuTTY sessions to Win…
webvictim 933fcd6
Addressed comments from code review
webvictim d535a1f
Add support for leaf clusters
webvictim f594cc9
Refactoring from code review
webvictim 3fae739
Address more feedback from code review
webvictim 5c33074
Rebase following tsh/common changes
webvictim 243a375
Fix up putty_config_windows
webvictim f2740df
Reorder command
webvictim c1cc057
Remove surplus comment
webvictim 32f1a4c
Use a separate list instead of overloading the 'extra' key
webvictim d41f62f
Address Tim's code review comments
webvictim 5315c9f
Address some of Zac's comments
webvictim 73b050a
Refactor formatLocalCommandString to use text/template
webvictim 015268f
Refactor non-Windows logic into puttyhosts
webvictim 22a9b4c
Fix subcommand name
webvictim 88de211
Fix test structure
webvictim b282ddb
Add some more hostnames test cases
webvictim 6c741ad
Apply suggestions from code review
webvictim 14dbb2b
Fix up
webvictim File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was just curious, but the answer was quite educating: https://chat.openai.com/share/0898d082-ba3a-489e-a71c-83e84cbd3657
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was actually really interesting; I'd never thought of using ChatGPT to explain or validate regex. It also gave me ideas for a few more test cases.
Unfortunately...
Maybe being naive is a good thing?!