Ctrl K
ComunidadGuías…

server/ssh/web/web.go

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

Archivo original ↗Preguntar a KiBot
package web

import (
	"context"
	"crypto/rsa"
	"encoding/json"
	"errors"
	"net/http"
	"time"

	"github.com/labstack/echo/v5"
	"github.com/shellhub-io/shellhub/pkg/api/authorizer"
	"github.com/shellhub-io/shellhub/pkg/api/identity"
	"github.com/shellhub-io/shellhub/pkg/models"
	routesmiddleware "github.com/shellhub-io/shellhub/server/api/routes/middleware"
	"github.com/shellhub-io/shellhub/server/api/services"
	"github.com/shellhub-io/shellhub/server/ssh/pkg/magickey"
	"github.com/shellhub-io/shellhub/server/ssh/pkg/webhandoff"
	"github.com/shellhub-io/shellhub/server/ssh/web/pkg/token"
	log "github.com/sirupsen/logrus"
	"golang.org/x/net/websocket"
)

const (
	// WebsocketSSHBridgeRoute is the WebSocket upgrade that carries the terminal
	// stream. It is gated by a single-use token rather than authenticated.
	WebsocketSSHBridgeRoute = "/ws/ssh"
	// WebSessionRoute is the credential/token POST. It is authenticated, so the
	// bridge learns the logged-in ShellHub account (used in a new namespace). It
	// requires authorizer.DeviceConnect, and refuses a device outside the caller's
	// namespace, since the role it checks is the one held there. In a legacy
	// namespace it is the only permission check a browser terminal meets.
	WebSessionRoute = "/ws/ssh/session"
)

func exitLogLevel(err error) log.Level {
	switch {
	case errors.Is(err, ErrConnect),
		errors.Is(err, ErrAccessDenied),
		errors.Is(err, ErrInvalidSSHID),
		errors.Is(err, ErrAuthentication),
		errors.Is(err, ErrGetAuth),
		errors.Is(err, ErrFindDevice),
		errors.Is(err, ErrForbiddenPublicKey),
		errors.Is(err, ErrBridgeCredentialsNotFound),
		errors.Is(err, ErrWebSocketGetToken),
		errors.Is(err, ErrWebSocketGetDimensions),
		errors.Is(err, ErrWebSocketGetIP):
		return log.WarnLevel
	default:
		return log.ErrorLevel
	}
}

// Config is what the bridge needs: how to reach the SSH server it dials over the loopback, and
// how to name this instance in the tokens it signs.
type Config struct {
	// HostKeyFile is the path to that server's host key. The bridge pins its connection to
	// the key's public half, so nothing else answering on the loopback port is accepted.
	HostKeyFile string
	// Issuer names the instance signing web session tokens; it is written to their iss claim.
	Issuer string
}

// NewSSHServerBridge creates routes into a [echo.Router] to connect a webscoket to SSH using Shell session.
//
// authn is the API's authenticator, used to declare the WebSocket upgrade as
// reachable without a credential. It may be nil in tests.
//
// A web session token opens one WebSocket. The upgrade spends it before checking the terminal size
// and the client IP, so an open that fails on either cannot be retried with the same token.
//
// It fails when the SSH server's host key cannot be read, since without it the bridge has
// nothing to pin its connection to.
func NewSSHServerBridge(router *echo.Echo, authn *routesmiddleware.Authenticator, service services.Service, handoff *webhandoff.Store, config *Config) error {
	hostKey, err := readHostKey(config.HostKeyFile)
	if err != nil {
		return err
	}

	manager := newManager(30 * time.Second)

	if authn != nil {
		authn.AllowAnonymous(http.MethodGet, WebsocketSSHBridgeRoute)
	}

	router.Add(http.MethodPost, WebSessionRoute, echo.WrapHandler(
		http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
			type Success struct {
				Token string `json:"token"`
			}

			type Fail struct {
				Error string `json:"error"`
			}

			decoder := json.NewDecoder(req.Body)
			encoder := json.NewEncoder(res)

			response := func(res http.ResponseWriter, status int, data any) {
				res.Header().Set("Content-Type", "application/json")
				res.WriteHeader(status)

				encoder.Encode(data) //nolint:errcheck
			}

			var request Credentials
			if err := decoder.Decode(&request); err != nil {
				response(res, http.StatusBadRequest, Fail{Error: err.Error()})

				return
			}

			caller, _ := identity.FromContext(req.Context())

			request.UserID = caller.ID

			sc, err := services.BoundTo(caller.TenantID)
			if err != nil {
				response(res, http.StatusForbidden, Fail{Error: err.Error()})

				return
			}

			if _, err := service.GetDevice(req.Context(), sc, models.UID(request.Device)); err != nil {
				response(res, http.StatusForbidden, Fail{Error: err.Error()})

				return
			}

			key := magickey.GetReference()

			token, err := token.NewToken(config.Issuer)
			if err != nil {
				response(res, http.StatusBadRequest, Fail{Error: err.Error()})

				return
			}

			if err := request.encryptPassword(key); err != nil {
				status := http.StatusInternalServerError
				if errors.Is(err, rsa.ErrMessageTooLong) {
					status = http.StatusBadRequest
				}

				response(res, status, Fail{Error: err.Error()})

				return
			}

			manager.save(token.ID, &request)

			response(res, http.StatusOK, Success{Token: token.ID})
		})),
		routesmiddleware.RequiresPermission(authorizer.DeviceConnect),
	)

	router.Add(http.MethodGet, WebsocketSSHBridgeRoute, echo.WrapHandler(websocket.Handler(func(wsconn *websocket.Conn) {
		conn := NewConn(wsconn)
		defer conn.Close() //nolint:errcheck // the handler is returning and has no one left to report a close failure to

		exit := func(err error) {
			log.WithError(err).Log(exitLogLevel(err), "web terminal error")

			buffer, marshalErr := json.Marshal(Message{
				Kind: messageKindError,
				Data: err.Error(),
			})
			if marshalErr != nil {
				log.WithError(marshalErr).Error("failed to marshal error message")

				return
			}

			conn.Write(buffer) //nolint:errcheck // a failed write means the browser is gone, so there is no one left to tell
		}

		token, err := getToken(wsconn.Request())
		if err != nil {
			exit(ErrWebSocketGetToken)

			return
		}

		creds, ok := manager.get(token)
		if !ok {
			exit(ErrBridgeCredentialsNotFound)

			return
		}

		cols, rows, err := getDimensions(wsconn.Request())
		if err != nil {
			exit(ErrWebSocketGetDimensions)

			return
		}

		ip, err := getIP(wsconn.Request())
		if err != nil {
			exit(ErrWebSocketGetIP)

			return
		}

		ctx, cancel := whileBrowserAnswers(wsconn.Request().Context(), conn)
		defer cancel()

		if err := creds.decryptPassword(magickey.GetReference()); err != nil {
			exit(errors.Join(ErrBridgeDecryptPassword, err))

			return
		}

		if err := newSession(
			ctx,
			service,
			handoff,
			conn,
			creds,
			Dimensions{cols, rows},
			Info{IP: ip},
			hostKey,
		); err != nil {
			exit(err)

			return
		}
	})))

	return nil
}

func whileBrowserAnswers(parent context.Context, conn *Conn) (context.Context, context.CancelFunc) {
	ctx, cancel := context.WithCancel(parent)

	go func() {
		conn.KeepAlive()
		cancel()
	}()

	return ctx, cancel
}