mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-24 12:30:12 +08:00
* Set Teleport version in SSH clients. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Add more tests. Fix lint. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Fix import. Fix lint exclude. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Fix lint - come on! Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Fix race by making a defensive copy. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Use ErrorIs. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Deep clone client conf. Early return for err. Make consistent. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Return new conf if nil. Add trace.Wrap to missing. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Add IsSSHFeatureSupported. Switch from api to api_test for test pack. Make prefix exported const. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Revert accidental commit. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Return sentinel error for older clients or non-teleport clients to improve downstream handling. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Clean up. Co-authored-by: Chris Thach <chris.thach@protonmail.com> * Apply suggestions from code review Co-authored-by: Edoardo Spadolini <edoardo.spadolini@gmail.com> * Remove defensive check. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Change from trace.BadParam to reg error. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Use strings.Cut and improve readability. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Use error struct. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Add test that errors if ssh.ClientConfig changes. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Move to subpackage. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Use static assertion instead. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Return error if config is nil. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Allow clients to override client version via config. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Fix test because I forgot to update it. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Move assert to test file. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Remove error return val. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Relax parser to allow for more. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Fix lint. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * WIP take on a tracessh wrapper. Will fail CI. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Add tests and polish new package. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Revert changes to tracessh. Add lint config. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Revert changes to use tracessh. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Delete extra s. Co-authored-by: Chris Thach <chris.thach@protonmail.com> * Apply suggestions from code review. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Add IsEmpty helper methods. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * First pass of refactoring. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Second pass refactor. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Third pass refactor. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Fix nil panic. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Fix missing user value. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Fix missing User value. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Remove Get from name. Polish. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Fix incorrect signers for clients. Make changes consistent. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Allow ClientConfig to be empty in proxy config. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Fix eager signers regression in generateClientConfig. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Add edge case for space after prefix. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Fix issues from code review. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Drop suffix. Update comments. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Update ClientConfig docs. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Add warning about using the Teleport version. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Teleport version is mandatory. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Revert timeout to make Claude/Codex happy. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Remove alias in places that didn't need it. Revert lib/cloud/gcp/alloydb.go. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Add wrapper for tracessh.Client. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Add tests for generate client config. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Drop config from field name to shorten. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Fix lint issues. Signed-off-by: Chris Thach <chris.thach@goteleport.com> --------- Signed-off-by: Chris Thach <chris.thach@goteleport.com> Co-authored-by: Edoardo Spadolini <edoardo.spadolini@gmail.com>
335 lines
12 KiB
Go
335 lines
12 KiB
Go
// Copyright 2026 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 ssh
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"net"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gravitational/trace"
|
|
"golang.org/x/crypto/ssh"
|
|
|
|
"github.com/gravitational/teleport/api"
|
|
"github.com/gravitational/teleport/api/defaults"
|
|
"github.com/gravitational/teleport/api/observability/tracing"
|
|
tracessh "github.com/gravitational/teleport/api/observability/tracing/ssh"
|
|
)
|
|
|
|
const (
|
|
// VersionPrefix is the prefix for the SSH client version string used by Teleport SSH clients.
|
|
VersionPrefix = "SSH-2.0-Teleport"
|
|
|
|
// DefaultClientVersion is the default SSH client identification string used by Teleport SSH clients.
|
|
//
|
|
// The Teleport version included in the client version string is intended for informational/debugging purposes only
|
|
// and should NEVER be relied upon for inferring client capabilities or behavior. The presence of specific features
|
|
// should be determined by parsing the version string for the expected feature flags. The string is not guaranteed
|
|
// to be in any specific format, so it should not be parsed as a structured version string (e.g., semantic
|
|
// versioning). DO NOT USE the Teleport version for any logic other than display or logging.
|
|
DefaultClientVersion = VersionPrefix + "_" + api.Version
|
|
|
|
// InBandMFAFeature is a flag included in the client version string to indicate support for in-band MFA (RFD 234).
|
|
InBandMFAFeature = "mfav1"
|
|
)
|
|
|
|
// ClientVersionWithFeatures returns a client version string that includes the specified features. If no features are
|
|
// provided, it returns the default client version string.
|
|
func ClientVersionWithFeatures(features ...string) string {
|
|
if len(features) == 0 {
|
|
return DefaultClientVersion
|
|
}
|
|
|
|
return DefaultClientVersion + " " + strings.Join(features, ",")
|
|
}
|
|
|
|
// NonTeleportSSHVersionError is returned by ParseClientVersion when the provided SSH client version string does not
|
|
// have the expected Teleport prefix. The client is either not a Teleport client or is an older Teleport version that
|
|
// did not set a client version string.
|
|
type NonTeleportSSHVersionError struct{}
|
|
|
|
// Error returns the error message for NonTeleportSSHVersionError.
|
|
func (NonTeleportSSHVersionError) Error() string {
|
|
return "SSH client version is not a Teleport version"
|
|
}
|
|
|
|
// ParseClientVersion parses the given SSH client version string and extracts the Teleport version and supported
|
|
// features. It returns the Teleport version and a slice of supported features. If no features are specified, the
|
|
// features slice will be nil. If the client version string does not have the expected Teleport prefix, it returns a
|
|
// NonTeleportSSHVersionError. If the client version string contains invalid characters, it returns a BadParameter
|
|
// error.
|
|
//
|
|
// It intentionally does not attempt to parse the Teleport version into a structured format, as the Teleport version
|
|
// string is primarily used for informational purposes and may include additional metadata in the future. It is also
|
|
// intentional that features may contain spaces.
|
|
//
|
|
// Accepted formats are:
|
|
// - SSH-2.0-Teleport_<teleport_version>
|
|
// - SSH-2.0-Teleport_<teleport_version> <feature1,feature2,...>
|
|
func ParseClientVersion(clientVersion string) (string, []string, error) {
|
|
// Ensure it is actually has the Teleport SSH client version prefix before doing any further parsing or validation.
|
|
rest, ok := strings.CutPrefix(clientVersion, VersionPrefix)
|
|
if !ok {
|
|
return "", nil, NonTeleportSSHVersionError{}
|
|
}
|
|
|
|
// Sanity check that the client version string only contains valid ASCII characters we allow. Since this can be sent
|
|
// by untrusted clients, we want to avoid any potential issues with invalid characters. We intentionally do not
|
|
// return the specific invalid character in the error message since it could be used for malicious purposes.
|
|
if strings.ContainsFunc(
|
|
clientVersion,
|
|
func(r rune) bool { return r < ' ' || r > '~' },
|
|
) {
|
|
return "", nil, trace.BadParameter(
|
|
"SSH client version contains invalid characters (only ASCII characters 32-126 are allowed)",
|
|
)
|
|
}
|
|
|
|
// The Teleport client name and the version are separated by an underscore. This is to ensure consistency with
|
|
// OpenSSH client version strings, which also use an underscore to separate the client name from the version.
|
|
if !strings.HasPrefix(rest, "_") {
|
|
return "", nil, trace.BadParameter("SSH client name and version must be separated by an underscore")
|
|
}
|
|
|
|
// Remove the leading underscore.
|
|
rest = strings.TrimPrefix(rest, "_")
|
|
|
|
// Reject an empty version after the required underscore.
|
|
if rest == "" {
|
|
return "", nil, trace.BadParameter("SSH client version must include a non-empty Teleport version")
|
|
}
|
|
|
|
// Separate the version part from the features part by the first space.
|
|
versionPart, featuresPart, hasFeatures := strings.Cut(rest, " ")
|
|
|
|
if versionPart == "" {
|
|
return "", nil, trace.BadParameter("SSH client version must include a non-empty Teleport version")
|
|
}
|
|
|
|
// If there are no features, return the version.
|
|
if !hasFeatures || featuresPart == "" {
|
|
return versionPart, nil, nil
|
|
}
|
|
|
|
// Split the features part into individual features.
|
|
features := strings.Split(featuresPart, ",")
|
|
|
|
return versionPart, features, nil
|
|
}
|
|
|
|
// IsFeatureSupported checks if the given SSH client version string indicates support for the specified feature.
|
|
func IsFeatureSupported(clientVersion, feature string) (bool, error) {
|
|
_, features, err := ParseClientVersion(clientVersion)
|
|
if err != nil {
|
|
return false, trace.Wrap(err)
|
|
}
|
|
|
|
return slices.Contains(features, feature), nil
|
|
}
|
|
|
|
// PublicKeyAuthConfig configures the public-key authentication method used by a Teleport SSH client.
|
|
type PublicKeyAuthConfig struct {
|
|
// Signers dynamically returns signers for public-key authentication.
|
|
Signers func() ([]ssh.Signer, error)
|
|
}
|
|
|
|
// IsEmpty returns true if the PublicKeyAuthConfig does not have an effective Signers function set.
|
|
func (c PublicKeyAuthConfig) IsEmpty() bool {
|
|
return c.Signers == nil
|
|
}
|
|
|
|
func (c PublicKeyAuthConfig) authMethod() (ssh.AuthMethod, error) {
|
|
if c.IsEmpty() {
|
|
return nil, trace.BadParameter("public key auth requires Signers")
|
|
}
|
|
|
|
return ssh.PublicKeysCallback(c.Signers), nil
|
|
}
|
|
|
|
// ClientConfig defines all client-side parameters required to establish and authenticate an SSH connection with
|
|
// Teleport. The minimal set of required parameters is User, HostKeyCallback, and PublicKeyAuth.Signers. The rest are
|
|
// optional parameters that can be used to customize the SSH connection behavior. The client version string sent during
|
|
// the SSH handshake is determined based on how the fields are set, with the default being DefaultClientVersion if no
|
|
// features are indicated.
|
|
type ClientConfig struct {
|
|
// Config contains configuration data common to both ServerConfig and ClientConfig.
|
|
SSHConfig ssh.Config
|
|
|
|
// User contains the username to authenticate as.
|
|
User string
|
|
|
|
// PublicKeyAuth configures the required public-key authentication method.
|
|
PublicKeyAuth PublicKeyAuthConfig
|
|
|
|
// HostKeyCallback validates the server host key during the SSH handshake.
|
|
HostKeyCallback ssh.HostKeyCallback
|
|
|
|
// BannerCallback displays server banners during the SSH handshake.
|
|
BannerCallback ssh.BannerCallback
|
|
|
|
// HostKeyAlgorithms lists the accepted server host key algorithms in order of preference.
|
|
HostKeyAlgorithms []string
|
|
|
|
// Timeout is the maximum amount of time to wait for the underlying TCP connection to establish. If zero, Teleport's
|
|
// default I/O timeout is used. Negative values are preserved.
|
|
Timeout time.Duration
|
|
|
|
// AuthCallback, if non-nil, is invoked before each authentication attempt.
|
|
//
|
|
// TODO(cthach): Enable when https://github.com/golang/go/issues/76146 is resolved and a new version of x/crypto/ssh
|
|
// is released.
|
|
// AuthCallback ssh.ClientAuthCallback
|
|
}
|
|
|
|
// SSHClientConfig builds a new [ssh.ClientConfig] from the client config.
|
|
func (c ClientConfig) sshClientConfig() (*ssh.ClientConfig, error) {
|
|
switch {
|
|
case c.User == "":
|
|
return nil, trace.BadParameter("config User must be set")
|
|
|
|
case c.HostKeyCallback == nil:
|
|
return nil, trace.BadParameter("config HostKeyCallback must be set")
|
|
}
|
|
|
|
authMethods, err := c.authMethods()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return &ssh.ClientConfig{
|
|
Config: c.SSHConfig,
|
|
User: c.User,
|
|
Auth: authMethods,
|
|
HostKeyCallback: c.HostKeyCallback,
|
|
BannerCallback: c.BannerCallback,
|
|
ClientVersion: c.clientVersion(),
|
|
HostKeyAlgorithms: slices.Clone(c.HostKeyAlgorithms),
|
|
Timeout: cmp.Or(c.Timeout, defaults.DefaultIOTimeout),
|
|
}, nil
|
|
}
|
|
|
|
// IsEmpty() returns true if the config does not have any effective values.
|
|
func (c ClientConfig) IsEmpty() bool {
|
|
return c.User == "" &&
|
|
c.PublicKeyAuth.IsEmpty() &&
|
|
c.HostKeyCallback == nil
|
|
}
|
|
|
|
// Client is a thin wrapper around tracessh.Client.
|
|
type Client = tracessh.Client
|
|
|
|
// Dial dials an SSH server using the client config.
|
|
func Dial(
|
|
ctx context.Context,
|
|
network string,
|
|
addr string,
|
|
config ClientConfig,
|
|
opts ...tracing.Option,
|
|
) (*Client, error) {
|
|
sshConfig, err := config.sshClientConfig()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
client, err := tracessh.Dial(ctx, network, addr, sshConfig, opts...)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return client, nil
|
|
}
|
|
|
|
// NewClient creates a traced SSH client over an existing connection using the client config.
|
|
//
|
|
// Timeout behavior is determined by [ClientConfig.Timeout]:
|
|
// - If Timeout > 0, the SSH handshake respects the earlier of the context deadline or Timeout.
|
|
// - If Timeout == 0, the SSH handshake uses Teleport's default I/O timeout.
|
|
// - If Timeout < 0, the SSH handshake only respects the context deadline or cancellation.
|
|
func NewClient(
|
|
ctx context.Context,
|
|
conn net.Conn,
|
|
addr string,
|
|
config ClientConfig,
|
|
opts ...tracing.Option,
|
|
) (*Client, error) {
|
|
sshConfig, err := config.sshClientConfig()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
client, err := tracessh.NewClientWithTimeout(ctx, conn, addr, sshConfig, opts...)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return client, nil
|
|
}
|
|
|
|
// NewClientConn creates a traced SSH client connection over an existing connection using the client config.
|
|
//
|
|
// Timeout behavior is determined by [ClientConfig.Timeout]:
|
|
// - If Timeout > 0, the SSH handshake respects the earlier of the context deadline or Timeout.
|
|
// - If Timeout == 0, the SSH handshake uses Teleport's default I/O timeout.
|
|
// - If Timeout < 0, the SSH handshake only respects the context deadline or cancellation.
|
|
func NewClientConn(
|
|
ctx context.Context,
|
|
conn net.Conn,
|
|
addr string,
|
|
config ClientConfig,
|
|
opts ...tracing.Option,
|
|
) (ssh.Conn, <-chan ssh.NewChannel, <-chan *ssh.Request, error) {
|
|
sshConfig, err := config.sshClientConfig()
|
|
if err != nil {
|
|
return nil, nil, nil, trace.Wrap(err)
|
|
}
|
|
|
|
sshConn, chans, reqs, err := tracessh.NewClientConnWithTimeout(
|
|
ctx,
|
|
conn,
|
|
addr,
|
|
sshConfig,
|
|
opts...,
|
|
)
|
|
if err != nil {
|
|
return nil, nil, nil, trace.Wrap(err)
|
|
}
|
|
|
|
return sshConn, chans, reqs, nil
|
|
}
|
|
|
|
func (c ClientConfig) clientVersion() string {
|
|
switch {
|
|
// TODO(cthach): Set the in-band MFA feature flag using if AuthCallback is non-nil once
|
|
// https://github.com/golang/go/issues/76146 is resolved and a new version of x/crypto/ssh is released.
|
|
// case c.AuthCallback != nil:
|
|
// return clientVersionWithFeatures(InBandMFAFeature)
|
|
|
|
default:
|
|
return DefaultClientVersion
|
|
}
|
|
}
|
|
|
|
func (c ClientConfig) authMethods() ([]ssh.AuthMethod, error) {
|
|
publicKeyAuthMethod, err := c.PublicKeyAuth.authMethod()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return []ssh.AuthMethod{publicKeyAuthMethod}, nil
|
|
}
|