1
0
mirror of https://github.com/smallstep/cli.git synced 2026-10-24 11:19:19 +03:00
Files
step-ca-cli/internal/sshutil/shell.go
2026-02-24 13:51:06 +01:00

331 lines
8.0 KiB
Go

package sshutil
import (
"io"
"log"
"net"
"os"
"path/filepath"
"strings"
"github.com/pkg/errors"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
"golang.org/x/term"
"github.com/smallstep/cli-utils/step"
)
// ProxyCommand replaces %%, %h, %p, and %r in the given command.
//
// %% A literal `%`.
// %h The remote hostname.
// %p The remote port.
// %r The remote username.
func ProxyCommand(cmd, user, host, port string) string {
cmd = strings.ReplaceAll(cmd, "%%", "%")
cmd = strings.ReplaceAll(cmd, "%h", host)
cmd = strings.ReplaceAll(cmd, "%p", port)
return strings.ReplaceAll(cmd, "%r", user)
}
// ShellOption is the type used to add new options to the shell.
type ShellOption func(s *Shell) error
// WithAuthMethod adds a new ssh.AuthMethod to the shell.
func WithAuthMethod(am ssh.AuthMethod) ShellOption {
return func(s *Shell) error {
s.authMethods = append(s.authMethods, am)
return nil
}
}
// WithSigner adds the given signer as an ssh.AuthMethod.
func WithSigner(signer ssh.Signer) ShellOption {
return func(s *Shell) error {
s.authMethods = append(s.authMethods, ssh.PublicKeys(signer))
return nil
}
}
// WithCertificate adds a signer with the given certificate as an
// ssh.AuthMethod.
func WithCertificate(cert *ssh.Certificate, priv interface{}) ShellOption {
return func(s *Shell) error {
signer, err := NewCertSigner(cert, priv)
if err != nil {
return err
}
s.authMethods = append(s.authMethods, ssh.PublicKeys(signer))
return nil
}
}
// withDefaultAuthMethod adds the ssh.Agent as an ssh.AuthMethod.
func withDefaultAuthMethod() ShellOption {
return func(s *Shell) error {
agent, err := DialAgent()
if err != nil {
return err
}
s.authMethods = append(s.authMethods, agent.AuthMethod())
return nil
}
}
// withDefaultDialer adds a direct connection dialer.
func withDefaultDialer(user, address string) ShellOption {
return func(s *Shell) error {
s.dialer = func(callback ssh.HostKeyCallback) (*ssh.Client, error) {
client, err := ssh.Dial("tcp", address, &ssh.ClientConfig{
User: user,
Auth: s.authMethods,
HostKeyCallback: callback,
})
if err != nil {
return nil, errors.Wrapf(err, "error connecting %s", address)
}
return client, nil
}
return nil
}
}
func formatAddress(address string) string {
if _, _, err := net.SplitHostPort(address); err != nil {
address += ":22"
}
return address
}
// Shell implements a remote shell to an SSH server using x/crypto/ssh
type Shell struct {
user string
address string
authMethods []ssh.AuthMethod
client *ssh.Client
dialer func(callback ssh.HostKeyCallback) (*ssh.Client, error)
}
// NewShell initializes a new shell to the given address.
func NewShell(user, address string, opts ...ShellOption) (*Shell, error) {
address = formatAddress(address)
// Use known_host as HostKeyCallback
knownHosts, err := knownhosts.New(filepath.Join(step.Home(), ".ssh", "known_hosts"))
if err != nil {
return nil, errors.Wrap(err, "error reading known_hosts")
}
shell := &Shell{
user: user,
address: address,
}
if err := shell.apply(opts); err != nil {
return nil, err
}
if len(shell.authMethods) == 0 {
if err := withDefaultAuthMethod()(shell); err != nil {
return nil, err
}
}
if shell.dialer == nil {
if err := withDefaultDialer(user, address)(shell); err != nil {
return nil, err
}
}
if shell.client, err = shell.dialer(knownHosts); err != nil {
return nil, err
}
return shell, nil
}
func (s *Shell) apply(opts []ShellOption) error {
for _, fn := range opts {
if err := fn(s); err != nil {
return err
}
}
return nil
}
// Close finalizes the connection.
func (s *Shell) Close() error {
return s.client.Close()
}
// Run runs cmd on the remote host.
func (s *Shell) Run(cmd string) error {
// Create a session
session, err := s.client.NewSession()
if err != nil {
return errors.Wrap(err, "error creating a session")
}
defer session.Close()
return session.Run(cmd)
}
// RemoteShell starts a login shell on the remote host.
func (s *Shell) RemoteShell() error {
// Create a session
session, err := s.client.NewSession()
if err != nil {
return errors.Wrap(err, "error creating a session")
}
defer session.Close()
var fallback bool
if fd := int(os.Stdin.Fd()); term.IsTerminal(fd) { // #nosec G115 -- uintptr comes from file descriptor
// Put terminal in raw mode
if originalState, err := term.MakeRaw(fd); err != nil {
fallback = true
} else {
defer term.Restore(fd, originalState)
// Get terminal size
w, h, err := term.GetSize(fd)
if err != nil {
w, h = 80, 40
}
// Request pseudo terminal
if err := requestPty(session, h, w, ssh.TerminalModes{
ssh.ECHO: 1, // enable echoing
ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kbaud
ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud
}); err != nil {
return err
}
}
} else {
fallback = true
}
if fallback {
if err := requestPty(session, 40, 80, ssh.TerminalModes{
ssh.ECHO: 0, // disable echoing
ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kbaud
ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud
}); err != nil {
return errors.Wrap(err, "error getting pseudo terminal")
}
}
stdin, err := session.StdinPipe()
if err != nil {
return errors.Wrap(err, "cannot setup stdin for session")
}
go io.Copy(stdin, os.Stdin)
stdout, err := session.StdoutPipe()
if err != nil {
return errors.Wrap(err, "cannot setup stdout for session")
}
go io.Copy(os.Stdout, stdout)
stderr, err := session.StderrPipe()
if err != nil {
return errors.Wrap(err, "cannot setup stderr for session")
}
go io.Copy(os.Stderr, stderr)
// Start remote shell
if err := session.Shell(); err != nil {
return errors.Wrap(err, "error starting remote shell")
}
return session.Wait()
}
// LocalForward creates a local listener in the bindAddress forwarding the
// packages to the remote hostAddress.
func (s *Shell) LocalForward(bindNetwork, bindAddress, hostNetwork, hostAddress string) error {
l, err := net.Listen(bindNetwork, bindAddress)
if err != nil {
return errors.Wrapf(err, "error listening on %s", bindAddress)
}
defer l.Close()
remote, err := s.client.Dial(hostNetwork, hostAddress)
if err != nil {
return errors.Wrapf(err, "error dialing %s", hostAddress)
}
defer remote.Close()
for {
local, err := l.Accept()
if err != nil {
return errors.Wrapf(err, "error connecting to %s", bindAddress)
}
handleConn(local, remote)
}
}
// RemoteForward creates a remote listener in the bindAddress and forwards the
// packages to the local hostAddress.
func (s *Shell) RemoteForward(bindNetwork, bindAddress, hostNetwork, hostAddress string) error {
l, err := s.client.Listen(bindNetwork, bindAddress)
if err != nil {
return errors.Wrapf(err, "error listening on %s", bindAddress)
}
defer l.Close()
for {
local, err := net.Dial(hostNetwork, hostAddress)
if err != nil {
return errors.Wrapf(err, "error dialing %s", hostAddress)
}
remote, err := l.Accept()
if err != nil {
return errors.Wrapf(err, "error connection to %s", bindAddress)
}
handleConn(remote, local)
}
}
func requestPty(session *ssh.Session, h, w int, modes ssh.TerminalModes) (err error) {
var terms []string
switch t := os.Getenv("TERM"); t {
case "", "xterm-256color":
terms = append(terms, "xterm-256color", "xterm")
case "xterm":
terms = append(terms, "xterm")
default:
terms = append(terms, t, "xterm-256color", "xterm")
}
for _, t := range terms {
if err = session.RequestPty(t, h, w, modes); err == nil {
return nil
}
}
return errors.Wrap(err, "error getting pseudo terminal")
}
func handleConn(local, remote net.Conn) {
chDone := make(chan bool)
// Start remote -> local data transfer
go func() {
_, err := io.Copy(local, remote)
if err != nil {
log.Println("error while copy remote->local:", err)
}
chDone <- true
}()
// Start local -> remote data transfer
go func() {
_, err := io.Copy(remote, local)
if err != nil {
log.Println(err)
}
chDone <- true
}()
<-chDone
}