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