mirror of
https://github.com/gravitational/teleport.git
synced 2026-09-24 16:17:11 +08:00
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:
co-authored by
Zac Bergquist
parent
99d82593ed
commit
8abbea6fec
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user