Files
teleport/api/utils/sshutils/ssh.go
T
Chris Thach 306b6a9db0 Set Teleport version in SSH clients (#65004)
* 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>
2026-04-07 18:06:00 +00:00

325 lines
9.7 KiB
Go

/*
Copyright 2021 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 sshutils defines several functions and types used across the
// Teleport API and other Teleport packages when working with SSH.
package sshutils
import (
"bytes"
"context"
"crypto"
"crypto/subtle"
"errors"
"io"
"net"
"regexp"
"strings"
"github.com/gravitational/trace"
"golang.org/x/crypto/ssh"
"github.com/gravitational/teleport/api/defaults"
apissh "github.com/gravitational/teleport/api/ssh"
)
// HandshakePayload structure is sent as a JSON blob by the teleport
// proxy to every SSH server who identifies itself as Teleport server
//
// It allows teleport proxies to communicate additional data to server
type HandshakePayload struct {
// ClientAddr is the IP address of the remote client
ClientAddr string `json:"clientAddr,omitempty"`
// TracingContext contains tracing information so that spans can be correlated
// across ssh boundaries
TracingContext map[string]string `json:"tracingContext,omitempty"`
}
// ParseCertificate parses an SSH certificate from the authorized_keys format.
func ParseCertificate(buf []byte) (*ssh.Certificate, error) {
k, _, _, _, err := ssh.ParseAuthorizedKey(buf)
if err != nil {
return nil, trace.Wrap(err)
}
cert, ok := k.(*ssh.Certificate)
if !ok {
return nil, trace.BadParameter("not an SSH certificate")
}
return cert, nil
}
// ParseKnownHosts parses provided known_hosts entries into ssh.PublicKey list.
// If one or more hostnames are provided, only keys that have at least one match
// will be returned.
func ParseKnownHosts(knownHosts [][]byte, matchHostnames ...string) ([]ssh.PublicKey, error) {
var keys []ssh.PublicKey
for _, line := range knownHosts {
for {
_, hosts, publicKey, _, bytes, err := ssh.ParseKnownHosts(line)
if errors.Is(err, io.EOF) {
break
} else if err != nil {
return nil, trace.Wrap(err, "failed parsing known hosts: %v; raw line: %q", err, line)
}
if len(matchHostnames) == 0 || HostNameMatch(matchHostnames, hosts) {
keys = append(keys, publicKey)
}
line = bytes
}
}
return keys, nil
}
// HostNameMatch returns whether at least one of the given hosts matches one
// of the given matchHosts. If a host has a wildcard prefix "*.", it will be
// used to match. Ex: "*.example.com" will match "proxy.example.com".
func HostNameMatch(matchHosts []string, hosts []string) bool {
for _, matchHost := range matchHosts {
for _, host := range hosts {
if host == matchHost || matchesWildcard(matchHost, host) {
return true
}
}
}
return false
}
// matchesWildcard ensures the given `hostname` matches the given `pattern`.
// The `pattern` should be prefixed with `*.` which will match exactly one domain
// segment, meaning `*.example.com` will match `foo.example.com` but not
// `foo.bar.example.com`.
func matchesWildcard(hostname, pattern string) bool {
pattern = strings.TrimSpace(pattern)
// Don't allow non-wildcard or empty patterns.
if !strings.HasPrefix(pattern, "*.") || len(pattern) < 3 {
return false
}
matchHost := pattern[2:]
// Trim any trailing "." in case of an absolute domain.
hostname = strings.TrimSuffix(hostname, ".")
_, hostnameRoot, found := strings.Cut(hostname, ".")
if !found {
return false
}
return hostnameRoot == matchHost
}
// ParseAuthorizedKeys parses provided authorized_keys entries into ssh.PublicKey list.
func ParseAuthorizedKeys(authorizedKeys [][]byte) ([]ssh.PublicKey, error) {
var keys []ssh.PublicKey
for _, line := range authorizedKeys {
publicKey, _, _, _, err := ssh.ParseAuthorizedKey(line)
if err != nil {
return nil, trace.Wrap(err, "failed parsing authorized keys: %v; raw line: %q", err, line)
}
keys = append(keys, publicKey)
}
return keys, nil
}
// ProxyClientSSHConfig returns an ssh.ClientConfig from the given ssh.AuthMethod.
// If known_hosts are provided, they will be used in the config's HostKeyCallback.
//
// The config is set up to authenticate to proxy with the first available principal.
func ProxyClientSSHConfig(sshCert *ssh.Certificate, priv crypto.Signer, knownHosts ...[]byte) (apissh.ClientConfig, error) {
signer, err := SSHSigner(sshCert, priv)
if err != nil {
return apissh.ClientConfig{}, trace.Wrap(err)
}
cfg := apissh.ClientConfig{
PublicKeyAuth: apissh.PublicKeyAuthConfig{
Signers: func() ([]ssh.Signer, error) {
return []ssh.Signer{signer}, nil
},
},
Timeout: defaults.DefaultIOTimeout,
}
// The KeyId is not always a valid principal, so we use the first valid principal instead.
cfg.User = sshCert.KeyId
if len(sshCert.ValidPrincipals) > 0 {
cfg.User = sshCert.ValidPrincipals[0]
}
if len(knownHosts) > 0 {
trustedKeys, err := ParseKnownHosts(knownHosts)
if err != nil {
return apissh.ClientConfig{}, trace.Wrap(err)
}
cfg.HostKeyCallback, err = HostKeyCallback(trustedKeys, false)
if err != nil {
return apissh.ClientConfig{}, trace.Wrap(err, "failed to convert certificate authorities to HostKeyCallback")
}
}
return cfg, nil
}
// SSHSigner returns an ssh.Signer from certificate and private key
func SSHSigner(sshCert *ssh.Certificate, signer crypto.Signer) (ssh.Signer, error) {
sshSigner, err := ssh.NewSignerFromKey(signer)
if err != nil {
return nil, trace.Wrap(err)
}
sshSigner, err = ssh.NewCertSigner(sshCert, sshSigner)
if err != nil {
return nil, trace.Wrap(err)
}
return sshSigner, nil
}
// AsAuthMethod returns an "auth method" interface, a common abstraction
// used by Golang SSH library. This is how you actually use a Key to feed
// it into the SSH lib.
func AsAuthMethod(sshCert *ssh.Certificate, signer crypto.Signer) (ssh.AuthMethod, error) {
sshSigner, err := SSHSigner(sshCert, signer)
if err != nil {
return nil, trace.Wrap(err)
}
return ssh.PublicKeys(sshSigner), nil
}
// HostKeyCallback returns an ssh.HostKeyCallback that validates host
// keys/certs against trusted host keys, usually associated with trusted CAs.
//
// If no trusted keys are provided, the returned ssh.HostKeyCallback is nil.
// This causes golang.org/x/crypto/ssh to prompt the user to verify host key
// fingerprint (same as OpenSSH does for an unknown host).
func HostKeyCallback(trustedKeys []ssh.PublicKey, withHostKeyFallback bool) (ssh.HostKeyCallback, error) {
// No trusted keys are provided, return a nil callback which will prompt the user for trust.
if len(trustedKeys) == 0 {
return nil, nil
}
callbackConfig := HostKeyCallbackConfig{
GetHostCheckers: func() ([]ssh.PublicKey, error) {
return trustedKeys, nil
},
}
if withHostKeyFallback {
callbackConfig.HostKeyFallback = hostKeyFallbackFunc(trustedKeys)
}
callback, err := NewHostKeyCallback(callbackConfig)
if err != nil {
return nil, trace.Wrap(err)
}
return callback, nil
}
func hostKeyFallbackFunc(knownHosts []ssh.PublicKey) func(hostname string, remote net.Addr, key ssh.PublicKey) error {
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
for _, knownHost := range knownHosts {
if KeysEqual(key, knownHost) {
return nil
}
}
return trace.AccessDenied("host %v presented a public key instead of a host certificate which isn't among known hosts", hostname)
}
}
// KeysEqual is constant time compare of the keys to avoid timing attacks
func KeysEqual(ak, bk ssh.PublicKey) bool {
a := ak.Marshal()
b := bk.Marshal()
return subtle.ConstantTimeCompare(a, b) == 1
}
// OpenSSH cert types look like "<key-type>-cert-v<version>@openssh.com".
var sshCertTypeRegex = regexp.MustCompile(`^[a-z0-9\-]+-cert-v[0-9]{2}@openssh\.com$`)
// IsSSHCertType checks if the given string looks like an ssh cert type.
// e.g. ssh-rsa-cert-v01@openssh.com.
func IsSSHCertType(val string) bool {
return sshCertTypeRegex.MatchString(val)
}
type contextDialer func(ctx context.Context, network, addr string) (net.Conn, error)
type runSSHOpts struct {
dialContext contextDialer
}
// RunSSHOption allows setting options as functional arguments to RunSSH.
type RunSSHOption func(*runSSHOpts)
// WithDialer connects to an SSH server with a custom dialer.
func WithDialer(dialer contextDialer) RunSSHOption {
return func(opts *runSSHOpts) {
opts.dialContext = dialer
}
}
// RunSSH runs a command on an SSH server and returns the output.
func RunSSH(ctx context.Context, addr, command string, cfg apissh.ClientConfig, opts ...RunSSHOption) ([]byte, []byte, error) {
var options runSSHOpts
for _, opt := range opts {
opt(&options)
}
conn, err := options.dialContext(ctx, "tcp", addr)
if err != nil {
return nil, nil, trace.Wrap(err)
}
sshClient, err := apissh.NewClient(ctx, conn, addr, cfg)
if err != nil {
return nil, nil, trace.Wrap(err)
}
defer sshClient.Close()
session, err := sshClient.NewSession(ctx)
if err != nil {
return nil, nil, trace.Wrap(err)
}
defer session.Close()
// Execute the command.
var stdout bytes.Buffer
session.Stdout = &stdout
var stderr bytes.Buffer
session.Stderr = &stderr
err = session.Run(ctx, command)
return stdout.Bytes(), stderr.Bytes(), trace.Wrap(err)
}
// ChannelReadWriter represents the data streams of an ssh.Channel-like object.
type ChannelReadWriter interface {
io.ReadWriter
Stderr() io.ReadWriter
}
// DiscardChannelData discards all data received from an ssh channel in the
// background.
func DiscardChannelData(ch ChannelReadWriter) {
if ch == nil {
return
}
go io.Copy(io.Discard, ch)
go io.Copy(io.Discard, ch.Stderr())
}