server/ssh/web/session.go
Rama master · réplica de lectura del repositorio original.
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]
}
}
}
}