Ctrl K
ComunidadGuías…

server/ssh/web/session.go

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package web

import (
	"bytes"
	"context"
	"encoding/base64"
	"errors"
	"fmt"
	"io"
	"net"
	"strings"
	"sync"
	"time"
	"unicode/utf8"

	"github.com/shellhub-io/shellhub/pkg/api/scope"
	"github.com/shellhub-io/shellhub/pkg/models"
	"github.com/shellhub-io/shellhub/pkg/uuid"
	"github.com/shellhub-io/shellhub/server/api/services"
	"github.com/shellhub-io/shellhub/server/ssh/pkg/banner"
	"github.com/shellhub-io/shellhub/server/ssh/pkg/challenge"
	"github.com/shellhub-io/shellhub/server/ssh/pkg/webhandoff"
	log "github.com/sirupsen/logrus"
	"golang.org/x/crypto/ssh"
)

// BannerError is a failure that the client should see as an SSH banner rather than as a
// connection error, so that the reason reaches the user's terminal.
type BannerError struct {
	Message string
	kind    banner.Kind
}

// NewBannerError constructs a BannerError and classifies the message exactly
// once at construction time. Use this constructor — never build a BannerError
// literal — so that Message and kind always agree.
func NewBannerError(message string) *BannerError {
	kind := banner.Classify(message)

	return &BannerError{
		Message: message,
		kind:    kind,
	}
}

func (b *BannerError) Error() string {
	return b.Message
}

// Kind returns the banner classification computed when the error was created.
func (b *BannerError) Kind() banner.Kind {
	return b.kind
}

func mapBannerError(e *BannerError) error {
	switch e.Kind() {
	case banner.KindConnectionFailed:
		return ErrConnect
	case banner.KindAccessDenied:
		return ErrAccessDenied
	case banner.KindInvalidSSHID:
		return ErrInvalidSSHID
	default:
		if e.Message != "" {
			log.WithField("banner", e.Message).Warn("received unrecognized SSH banner; treating as connection failure")
		}

		return ErrConnect
	}
}

func bannerCallback() func(string) error {
	return func(message string) error {
		if message == "" {
			return nil
		}

		return NewBannerError(message)
	}
}

type refusal struct {
	mu     sync.Mutex
	reason string
}

func (r *refusal) refuse(reason string) {
	r.mu.Lock()
	defer r.mu.Unlock()

	r.reason = reason
}

func (r *refusal) Err() error {
	r.mu.Lock()
	defer r.mu.Unlock()

	if r.reason == "" {
		return nil
	}

	return fmt.Errorf("%w: %s", ErrApprovalRefused, r.reason)
}

func approvalChallenge(conn *Conn, refused *refusal) ssh.KeyboardInteractiveChallenge {
	return func(name, instruction string, questions []string, _ []bool) ([]string, error) {
		switch name {
		case challenge.Approval:
			if len(questions) == 0 || questions[0] == "" {
				return nil, ErrApprovalCodeMissing
			}

			if _, err := conn.WriteMessage(&Message{Kind: messageKindReauth, Data: questions[0]}); err != nil {
				return nil, errors.Join(ErrConnWriteMessageFailedFrame, err)
			}

			code, err := awaitBrowserApproval(conn)
			if err != nil {
				return nil, err
			}

			return []string{code}, nil
		case challenge.Denied:
			log.WithField("reason", instruction).Debug("the gateway refused the web terminal's login")

			refused.refuse(strings.TrimSpace(instruction))

			return make([]string, len(questions)), nil
		default:
			return make([]string, len(questions)), nil
		}
	}
}

func awaitBrowserApproval(conn *Conn) (string, error) {
	type answer struct {
		code string
		err  error
	}

	answered := make(chan answer, 1)

	go func() {
		for {
			message := new(Message)
			if _, err := conn.ReadMessage(message); err != nil {
				answered <- answer{code: "", err: err}

				return
			}

			if message.Kind != messageKindReauthDone {
				continue
			}

			code, ok := message.Data.(string)
			if !ok {
				answered <- answer{code: "", err: ErrConnReadMessageJSONInvalid}

				return
			}

			answered <- answer{code: code, err: nil}

			return
		}
	}()

	expiry := time.NewTimer(services.SSHApprovalTTL)
	defer expiry.Stop()

	select {
	case got := <-answered:
		return got.code, got.err
	case <-expiry.C:
		return "", ErrApprovalNotAnswered
	}
}

func getAuth(ctx context.Context, service services.Service, conn *Conn, creds *Credentials, refused *refusal) ([]ssh.AuthMethod, error) {
	if creds.PublicKey != "" {
		pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(creds.PublicKey)) //nolint:dogsled
		if err != nil {
			return nil, ErrDataPublicKey
		}

		signer := &Signer{
			conn:      conn,
			publicKey: &pubKey,
		}

		return withApproval(ssh.PublicKeys(signer), conn, refused), nil
	}

	if creds.isPassword() {
		return withApproval(ssh.Password(creds.Password), conn, refused), nil
	}

	device, err := service.GetDevice(ctx, scope.NewUnbounded(reasonWebHandoffDeviceResolve), models.UID(creds.Device))
	if err != nil {
		return nil, ErrFindDevice
	}

	key, err := service.GetPublicKey(ctx, creds.Fingerprint, device.TenantID)
	if err != nil {
		return nil, ErrFindPublicKey
	}

	pubKey, _, _, _, err := ssh.ParseAuthorizedKey(key.Data) //nolint:dogsled
	if err != nil {
		return nil, ErrDataPublicKey
	}

	signer := &Signer{
		conn:      conn,
		publicKey: &pubKey,
	}

	return withApproval(ssh.PublicKeys(signer), conn, refused), nil
}

func withApproval(credential ssh.AuthMethod, conn *Conn, refused *refusal) []ssh.AuthMethod {
	return []ssh.AuthMethod{credential, ssh.KeyboardInteractive(approvalChallenge(conn, refused))}
}

// Signer authenticates to the device with a key the server never holds: each signature is
// asked of the browser over the open connection, and the private key stays in the browser.
type Signer struct {
	conn      *Conn
	publicKey *ssh.PublicKey
}

// PublicKey implements [ssh.Signer].
func (s *Signer) PublicKey() ssh.PublicKey {
	return *s.publicKey
}

// Sign asks the browser to sign data and waits for the answer, implementing [ssh.Signer].
func (s *Signer) Sign(rand io.Reader, data []byte) (*ssh.Signature, error) {
	dataB64 := base64.StdEncoding.EncodeToString(data)
	if _, err := s.conn.WriteMessage(&Message{Kind: messageKindSignature, Data: dataB64}); err != nil {
		return nil, err
	}

	var msg Message
	if _, err := s.conn.ReadMessage(&msg); err != nil {
		return nil, errors.New("invalid signature response")
	}

	signed, ok := msg.Data.(string)
	if !ok {
		return nil, errors.New("data isn't a signed string")
	}

	blob, err := base64.StdEncoding.DecodeString(signed)
	if err != nil {
		return nil, err
	}

	return &ssh.Signature{
		Format: s.PublicKey().Type(),
		Blob:   blob,
	}, nil
}

const gatewayAddress = "localhost:2222"

func newSession(ctx context.Context, service services.Service, handoff *webhandoff.Store, conn *Conn, creds *Credentials, dim Dimensions, info Info, hostKey ssh.PublicKey) error {
	logger := log.WithFields(log.Fields{
		"user":   creds.Username,
		"device": creds.Device,
		"cols":   dim.Cols,
		"rows":   dim.Rows,
		"ip":     info.IP,
	})

	logger.Info("handling web client request started")

	defer logger.Info("handling web client request end")

	connection, err := dialDevice(ctx, service, handoff, conn, creds, info, hostKey, gatewayAddress, logger)
	if err != nil {
		return err
	}

	return serveSession(conn, connection, dim, logger)
}

func dialDevice(ctx context.Context, service services.Service, handoff *webhandoff.Store, conn *Conn, creds *Credentials, info Info, hostKey ssh.PublicKey, gateway string, logger *log.Entry) (*ssh.Client, error) {
	uuid := uuid.Generate()

	user := fmt.Sprintf("%s@%s", creds.Username, uuid)
	refused := new(refusal)

	auth, err := getAuth(ctx, service, conn, creds, refused)
	if err != nil {
		logger.WithError(err).Debug("failed to get the credentials")

		return nil, ErrGetAuth
	}

	handoff.Put(user, webhandoff.Data{
		Device: creds.Device,
		IP:     info.IP,
		UserID: creds.UserID,
	})

	var dialer net.Dialer

	raw, err := dialer.DialContext(ctx, "tcp", gateway)
	if err != nil {
		logger.WithError(err).Debug("failed to dial to the ssh server")

		return nil, ErrConnect
	}

	stopClosingOnCancel := context.AfterFunc(ctx, func() { _ = raw.Close() })
	defer stopClosingOnCancel()

	sshConn, chans, reqs, err := ssh.NewClientConn(raw, gateway, &ssh.ClientConfig{ //nolint:exhaustruct
		User:            user,
		Auth:            auth,
		HostKeyCallback: ssh.FixedHostKey(hostKey),
		BannerCallback:  bannerCallback(),
	})
	if err != nil {
		_ = raw.Close()

		if e, ok := errors.AsType[*BannerError](err); ok {
			logger.WithError(e).Debug("failed to receive the connection banner")

			return nil, mapBannerError(e)
		}

		logger.WithError(err).Debug("failed to dial to the ssh server")

		if refusedErr := refused.Err(); refusedErr != nil {
			return nil, refusedErr
		}

		if errors.Is(err, ErrApprovalNotAnswered) || errors.Is(err, ErrApprovalCodeMissing) {
			return nil, ErrApprovalNotAnswered
		}

		if creds.isPublicKey() || creds.PublicKey != "" {
			return nil, ErrForbiddenPublicKey
		}

		return nil, ErrAuthentication
	}

	return ssh.NewClient(sshConn, chans, reqs), nil
}

func serveSession(conn *Conn, connection *ssh.Client, dim Dimensions, logger *log.Entry) error {
	defer connection.Close() //nolint:errcheck

	if ok, reply, err := connection.SendRequest("session-uid@shellhub.io", true, nil); err == nil && ok {
		if _, err := conn.WriteMessage(&Message{Kind: messageKindSession, Data: string(reply)}); err != nil {
			logger.WithError(err).Debug("failed to send the session UID to the web client")
		}
	}

	agent, err := connection.NewSession()
	if err != nil {
		logger.WithError(err).Debug("failed to create a new session")

		return ErrSession
	}

	defer agent.Close() //nolint:errcheck

	stdin, err := agent.StdinPipe()
	if err != nil {
		logger.WithError(err).Debug("failed to create the stdin pipe")

		return err
	}

	stdout, err := agent.StdoutPipe()
	if err != nil {
		logger.WithError(err).Debug("failed to create the stdout pipe")

		return err
	}

	stderr, err := agent.StderrPipe()
	if err != nil {
		logger.WithError(err).Debug("failed to create the stderr pipe")

		return err
	}

	if err := agent.RequestPty("xterm", int(dim.Rows), int(dim.Cols), ssh.TerminalModes{
		ssh.ECHO:          1,
		ssh.TTY_OP_ISPEED: 14400,
		ssh.TTY_OP_OSPEED: 14400,
	}); err != nil {
		logger.WithError(err).Debug("failed to request the pty on session")

		return ErrPty
	}

	if err := agent.Shell(); err != nil {
		logger.WithError(err).Debug("failed to request the shell on session")

		return ErrShell
	}

	go func() {
		defer agent.Close() //nolint:errcheck

		for {
			var message Message

			if _, err := conn.ReadMessage(&message); err != nil {
				if errors.Is(err, io.EOF) {
					return
				}

				logger.WithError(err).Error("failed to read the message from the client")

				return
			}

			switch message.Kind {
			case messageKindInput:
				buffer, ok := message.Data.(string)
				if !ok {
					logger.Error("input message data is not a string")

					return
				}

				if _, err := stdin.Write([]byte(buffer)); err != nil {
					logger.WithError(err).Error("failed to write the message data on the SSH session")

					return
				}
			case messageKindResize:
				dim, ok := message.Data.(Dimensions)
				if !ok {
					logger.Error("resize message data is not a dimension")

					return
				}

				if err := agent.WindowChange(int(dim.Rows), int(dim.Cols)); err != nil {
					logger.WithError(err).Error("failed to change the size of window for terminal session")

					return
				}
			default:
			}
		}
	}()

	go redirToWs(stdout, conn) //nolint:errcheck
	go io.Copy(conn, stderr)   //nolint:errcheck

	if err := agent.Wait(); err != nil {
		logger.WithError(err).Warning("client remote command returned a error")
	}

	return nil
}

func redirToWs(rd io.Reader, ws *Conn) error {
	var buf [32 * 1024]byte
	var start, end, buflen int

	for {
		nr, err := rd.Read(buf[start:])
		if err != nil {
			return err
		}

		if nr == 0 {
			continue
		}

		buflen = start + nr

		for end = buflen - 1; end >= 0; end-- {
			if utf8.RuneStart(buf[end]) {
				ch, width := utf8.DecodeRune(buf[end:buflen])
				if ch != utf8.RuneError {
					end += width
				}

				break
			}

			if buflen-end >= 6 {
				end = nr

				break
			}
		}

		if end < 0 {
			log.WithFields(log.Fields{
				"buf":    buf,
				"buflen": buflen,
				"start":  start,
				"end":    end,
				"nr":     nr,
			}).Warn("end is negative, skipping write to avoid panic")

			end = 0
		}

		if _, err = ws.WriteBinary([]byte(string(bytes.Runes(buf[0:end])))); err != nil {
			return err
		}

		start = buflen - end

		if start > 0 {
			for i := range start {
				buf[i] = buf[end+i]
			}
		}
	}
}