Path: blob/main/components/ws-proxy/pkg/sshproxy/server.go
2500 views
// Copyright (c) 2021 Gitpod GmbH. All rights reserved.1// Licensed under the GNU Affero General Public License (AGPL).2// See License.AGPL.txt in the project root for license information.34package sshproxy56import (7"context"8"crypto/ed25519"9"crypto/rand"10"crypto/subtle"11"errors"12"fmt"13"io"14"net"15"net/http"16"regexp"17"strings"18"time"1920"github.com/gitpod-io/gitpod/common-go/analytics"21"github.com/gitpod-io/gitpod/common-go/log"22gitpod "github.com/gitpod-io/gitpod/gitpod-protocol"23supervisor "github.com/gitpod-io/gitpod/supervisor/api"24tracker "github.com/gitpod-io/gitpod/ws-proxy/pkg/analytics"25"github.com/gitpod-io/gitpod/ws-proxy/pkg/common"26"github.com/gitpod-io/golang-crypto/ssh"27"github.com/prometheus/client_golang/prometheus"28"golang.org/x/xerrors"29"google.golang.org/grpc"30"google.golang.org/grpc/credentials/insecure"31"sigs.k8s.io/controller-runtime/pkg/metrics"32)3334// This is copy from proxy/workspacerouter.go35const workspaceIDRegex = "([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-z]{2,16}-[0-9a-z]{2,16}-[0-9a-z]{8,11})"3637var (38SSHConnectionCount = prometheus.NewGauge(prometheus.GaugeOpts{39Name: "gitpod_ws_proxy_ssh_connection_count",40Help: "Current number of SSH connection",41})4243SSHAttemptTotal = prometheus.NewCounterVec(prometheus.CounterOpts{44Name: "gitpod_ws_proxy_ssh_attempt_total",45Help: "Total number of SSH attempt",46}, []string{"status", "error_type"})4748SSHTunnelOpenedTotal = prometheus.NewCounterVec(prometheus.CounterOpts{49Name: "gitpod_ws_proxy_ssh_tunnel_opened_total",50Help: "Total number of SSH tunnels opened by the ws-proxy",51}, []string{})5253SSHTunnelClosedTotal = prometheus.NewCounterVec(prometheus.CounterOpts{54Name: "gitpod_ws_proxy_ssh_tunnel_closed_total",55Help: "Total number of SSH tunnels closed by the ws-proxy",56}, []string{"code"})57)5859var (60ErrWorkspaceNotFound = NewSSHErrorWithReject("WS_NOTFOUND", "not found workspace")61ErrWorkspaceNotRunning = NewSSHErrorWithReject("WS_NOT_RUNNING", "workspace not running")62ErrWorkspaceIDInvalid = NewSSHErrorWithReject("WS_ID_INVALID", "workspace id invalid")63ErrUsernameFormat = NewSSHErrorWithReject("USER_FORMAT", "username format is not correct")64ErrMissPrivateKey = NewSSHErrorWithReject("MISS_KEY", "missing privateKey")65ErrConnFailed = NewSSHError("CONN_FAILED", "cannot to connect with workspace")66ErrCreateSSHKey = NewSSHError("CREATE_KEY_FAILED", "cannot create private pair in workspace")6768ErrAuthFailed = NewSSHError("AUTH_FAILED", "auth failed")69// ErrAuthFailedWithReject is same with ErrAuthFailed, it will just disconnect immediately to avoid pointless retries70ErrAuthFailedWithReject = NewSSHErrorWithReject("AUTH_FAILED", "auth failed")71)7273type SSHError struct {74shortName string75description string76err error77}7879func (e SSHError) Error() string {80return e.description81}8283func (e SSHError) ShortName() string {84return e.shortName85}86func (e SSHError) Unwrap() error {87return e.err88}8990func NewSSHError(shortName string, description string) SSHError {91return SSHError{shortName: shortName, description: description}92}9394func NewSSHErrorWithReject(shortName string, description string) SSHError {95return SSHError{shortName: shortName, description: description, err: ssh.ErrDenied}96}9798type Session struct {99Conn *ssh.ServerConn100101WorkspaceID string102InstanceID string103OwnerUserId string104105PublicKey ssh.PublicKey106WorkspacePrivateKey ssh.Signer107}108109type Server struct {110Heartbeater Heartbeat111112HostKeys []ssh.Signer113sshConfig *ssh.ServerConfig114workspaceInfoProvider common.WorkspaceInfoProvider115caKey ssh.Signer116}117118func init() {119metrics.Registry.MustRegister(120SSHConnectionCount,121SSHAttemptTotal,122SSHTunnelClosedTotal,123SSHTunnelOpenedTotal,124)125}126127// New creates a new SSH proxy server128129func New(signers []ssh.Signer, workspaceInfoProvider common.WorkspaceInfoProvider, heartbeat Heartbeat, caKey ssh.Signer) *Server {130server := &Server{131workspaceInfoProvider: workspaceInfoProvider,132Heartbeater: &noHeartbeat{},133HostKeys: signers,134caKey: caKey,135}136if heartbeat != nil {137server.Heartbeater = heartbeat138}139140authWithWebsocketTunnel := func(conn ssh.ConnMetadata) (*ssh.Permissions, error) {141wsConn, ok := conn.RawConn().(*gitpod.WebsocketConnection)142if !ok {143return nil, ErrAuthFailed144}145info, ok := wsConn.Ctx.Value(common.WorkspaceInfoIdentifier).(map[string]string)146if !ok || info == nil {147return nil, ErrAuthFailed148}149workspaceId := info[common.WorkspaceIDIdentifier]150_, err := server.GetWorkspaceInfo(workspaceId)151if err != nil {152return nil, err153}154log.WithField(common.WorkspaceIDIdentifier, workspaceId).Info("success auth via websocket")155return &ssh.Permissions{156Extensions: map[string]string{157"workspaceId": workspaceId,158"debugWorkspace": info[common.DebugWorkspaceIdentifier],159},160}, nil161}162163server.sshConfig = &ssh.ServerConfig{164ServerVersion: "SSH-2.0-GITPOD-GATEWAY",165NoClientAuth: true,166NoClientAuthCallback: func(conn ssh.ConnMetadata) (*ssh.Permissions, error) {167if perm, err := authWithWebsocketTunnel(conn); err == nil {168return perm, nil169}170args := strings.Split(conn.User(), "#")171workspaceId := args[0]172var debugWorkspace string173if strings.HasPrefix(workspaceId, "debug-") {174debugWorkspace = "true"175workspaceId = strings.TrimPrefix(workspaceId, "debug-")176}177wsInfo, err := server.GetWorkspaceInfo(workspaceId)178if err != nil {179return nil, err180}181// NoClientAuthCallback only support workspaceId#ownerToken182if len(args) != 2 {183return nil, ssh.ErrNoAuth184}185if wsInfo.Auth.OwnerToken != args[1] {186return nil, ErrAuthFailedWithReject187}188server.TrackSSHConnection(wsInfo, "auth", nil)189return &ssh.Permissions{190Extensions: map[string]string{191"workspaceId": workspaceId,192"debugWorkspace": debugWorkspace,193},194}, nil195},196PasswordCallback: func(conn ssh.ConnMetadata, password []byte) (perm *ssh.Permissions, err error) {197workspaceId, ownerToken := conn.User(), string(password)198var debugWorkspace string199if strings.HasPrefix(workspaceId, "debug-") {200debugWorkspace = "true"201workspaceId = strings.TrimPrefix(workspaceId, "debug-")202}203wsInfo, err := server.GetWorkspaceInfo(workspaceId)204if err != nil {205return nil, err206}207defer func() {208server.TrackSSHConnection(wsInfo, "auth", err)209}()210if wsInfo.Auth.OwnerToken != ownerToken {211return nil, ErrAuthFailed212}213return &ssh.Permissions{214Extensions: map[string]string{215"workspaceId": workspaceId,216"debugWorkspace": debugWorkspace,217},218}, nil219},220PublicKeyCallback: func(conn ssh.ConnMetadata, pk ssh.PublicKey) (perm *ssh.Permissions, err error) {221if perm, err := authWithWebsocketTunnel(conn); err == nil {222return perm, nil223}224workspaceId := conn.User()225var debugWorkspace string226if strings.HasPrefix(workspaceId, "debug-") {227debugWorkspace = "true"228workspaceId = strings.TrimPrefix(workspaceId, "debug-")229}230wsInfo, err := server.GetWorkspaceInfo(workspaceId)231if err != nil {232return nil, err233}234defer func() {235server.TrackSSHConnection(wsInfo, "auth", err)236}()237ctx, cancel := context.WithCancel(context.Background())238defer cancel()239ok, _ := server.VerifyPublicKey(ctx, wsInfo, pk)240if !ok {241return nil, ErrAuthFailed242}243return &ssh.Permissions{244Extensions: map[string]string{245"workspaceId": workspaceId,246"debugWorkspace": debugWorkspace,247},248}, nil249},250}251for _, s := range signers {252server.sshConfig.AddHostKey(s)253}254return server255}256257func ReportSSHAttemptMetrics(err error) {258if err == nil {259SSHAttemptTotal.WithLabelValues("success", "").Inc()260return261}262errorType := "OTHERS"263if serverAuthErr, ok := err.(*ssh.ServerAuthError); ok && len(serverAuthErr.Errors) > 0 {264if authErr, ok := serverAuthErr.Errors[len(serverAuthErr.Errors)-1].(SSHError); ok {265errorType = authErr.ShortName()266}267} else if authErr, ok := err.(SSHError); ok {268errorType = authErr.ShortName()269}270SSHAttemptTotal.WithLabelValues("failed", errorType).Inc()271}272273func (s *Server) RequestForward(reqs <-chan *ssh.Request, targetConn ssh.Conn) {274for req := range reqs {275result, payload, err := targetConn.SendRequest(req.Type, req.WantReply, req.Payload)276if err != nil {277continue278}279_ = req.Reply(result, payload)280}281}282283func (s *Server) HandleConn(c net.Conn) {284clientConn, clientChans, clientReqs, err := ssh.NewServerConn(c, s.sshConfig)285if err != nil {286c.Close()287if errors.Is(err, io.EOF) {288return289}290ReportSSHAttemptMetrics(err)291log.WithError(err).Error("failed to create new server connection")292return293}294defer clientConn.Close()295296if clientConn.Permissions == nil || clientConn.Permissions.Extensions == nil || clientConn.Permissions.Extensions["workspaceId"] == "" {297return298}299workspaceId := clientConn.Permissions.Extensions["workspaceId"]300debugWorkspace := clientConn.Permissions.Extensions["debugWorkspace"] == "true"301wsInfo, err := s.GetWorkspaceInfo(workspaceId)302if err != nil {303ReportSSHAttemptMetrics(err)304log.WithField("workspaceId", workspaceId).WithError(err).Error("failed to get workspace info")305return306}307log := log.WithField("instanceId", wsInfo.InstanceID).WithField("isMk2", wsInfo.IsManagedByMk2)308ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)309supervisorPort := "22999"310if debugWorkspace {311supervisorPort = "24999"312}313314var key ssh.Signer315//nolint:ineffassign316userName := "gitpod"317318session := &Session{319Conn: clientConn,320WorkspaceID: workspaceId,321InstanceID: wsInfo.InstanceID,322OwnerUserId: wsInfo.OwnerUserId,323}324325if !wsInfo.IsManagedByMk2 {326if s.caKey == nil || !wsInfo.IsEnabledSSHCA {327err = xerrors.Errorf("workspace not managed by mk2, but didn't have SSH CA enabled")328s.TrackSSHConnection(wsInfo, "connect", ErrCreateSSHKey)329ReportSSHAttemptMetrics(ErrCreateSSHKey)330log.WithError(err).Error("failed to generate ssh cert")331cancel()332return333}334// obtain the SSH username from workspacekit.335workspacekitPort := "22998"336userName, err = workspaceSSHUsername(ctx, wsInfo.IPAddress, workspacekitPort)337if err != nil {338userName = "root"339log.WithError(err).Warn("failed to retrieve the SSH username. Using root.")340}341}342343if s.caKey != nil && wsInfo.IsEnabledSSHCA {344key, err = s.GenerateSSHCert(ctx, userName)345if err != nil {346s.TrackSSHConnection(wsInfo, "connect", ErrCreateSSHKey)347ReportSSHAttemptMetrics(ErrCreateSSHKey)348log.WithError(err).Error("failed to generate ssh cert")349cancel()350return351}352session.WorkspacePrivateKey = key353} else {354key, userName, err = s.GetWorkspaceSSHKey(ctx, wsInfo.IPAddress, supervisorPort)355if err != nil {356cancel()357s.TrackSSHConnection(wsInfo, "connect", ErrCreateSSHKey)358ReportSSHAttemptMetrics(ErrCreateSSHKey)359log.WithError(err).Error("failed to create private pair in workspace")360return361}362363session.WorkspacePrivateKey = key364}365366cancel()367368sshPort := "23001"369if debugWorkspace {370sshPort = "25001"371}372remoteAddr := wsInfo.IPAddress + ":" + sshPort373conn, err := net.Dial("tcp", remoteAddr)374if err != nil {375s.TrackSSHConnection(wsInfo, "connect", ErrConnFailed)376ReportSSHAttemptMetrics(ErrConnFailed)377log.WithField("workspaceIP", wsInfo.IPAddress).WithError(err).Error("dial failed")378return379}380defer conn.Close()381382workspaceConn, workspaceChans, workspaceReqs, err := ssh.NewClientConn(conn, remoteAddr, &ssh.ClientConfig{383HostKeyCallback: ssh.InsecureIgnoreHostKey(),384User: userName,385Auth: []ssh.AuthMethod{386ssh.PublicKeysCallback(func() (signers []ssh.Signer, err error) {387return []ssh.Signer{key}, nil388}),389},390Timeout: 10 * time.Second,391})392if err != nil {393s.TrackSSHConnection(wsInfo, "connect", ErrConnFailed)394ReportSSHAttemptMetrics(ErrConnFailed)395log.WithField("workspaceIP", wsInfo.IPAddress).WithError(err).Error("connect failed")396return397}398s.Heartbeater.SendHeartbeat(wsInfo.InstanceID, false, true)399ctx, cancel = context.WithCancel(context.Background())400401s.TrackSSHConnection(wsInfo, "connect", nil)402SSHConnectionCount.Inc()403ReportSSHAttemptMetrics(nil)404405forwardRequests := func(reqs <-chan *ssh.Request, targetConn ssh.Conn) {406for req := range reqs {407result, payload, err := targetConn.SendRequest(req.Type, req.WantReply, req.Payload)408if err != nil {409continue410}411_ = req.Reply(result, payload)412}413}414// client -> workspace global request forward415go forwardRequests(clientReqs, workspaceConn)416// workspce -> client global request forward417go forwardRequests(workspaceReqs, clientConn)418419go func() {420for newChannel := range workspaceChans {421go s.ChannelForward(ctx, session, clientConn, newChannel)422}423}()424425go func() {426for newChannel := range clientChans {427go s.ChannelForward(ctx, session, workspaceConn, newChannel)428}429}()430431go func() {432clientConn.Wait()433cancel()434}()435go func() {436workspaceConn.Wait()437cancel()438}()439<-ctx.Done()440SSHConnectionCount.Dec()441workspaceConn.Close()442clientConn.Close()443cancel()444}445446func (s *Server) GetWorkspaceInfo(workspaceId string) (*common.WorkspaceInfo, error) {447wsInfo := s.workspaceInfoProvider.WorkspaceInfo(workspaceId)448if wsInfo == nil {449if matched, _ := regexp.Match(workspaceIDRegex, []byte(workspaceId)); matched {450return nil, ErrWorkspaceNotFound451}452return nil, ErrWorkspaceIDInvalid453}454if !wsInfo.IsRunning {455return nil, ErrWorkspaceNotRunning456}457return wsInfo, nil458}459460func (s *Server) TrackSSHConnection(wsInfo *common.WorkspaceInfo, phase string, err error) {461// if we didn't find an associated user, we don't want to track462if wsInfo == nil {463return464}465propertics := make(map[string]interface{})466propertics["workspaceId"] = wsInfo.WorkspaceID467propertics["instanceId"] = wsInfo.InstanceID468propertics["state"] = "success"469propertics["phase"] = phase470471if err != nil {472propertics["state"] = "failed"473propertics["cause"] = err.Error()474}475476tracker.Track(analytics.TrackMessage{477Identity: analytics.Identity{UserID: wsInfo.OwnerUserId},478Event: "ssh_connection",479Properties: propertics,480})481}482483func (s *Server) VerifyPublicKey(ctx context.Context, wsInfo *common.WorkspaceInfo, pk ssh.PublicKey) (bool, error) {484for _, keyStr := range wsInfo.SSHPublicKeys {485key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyStr))486if err != nil {487continue488}489keyData := key.Marshal()490pkd := pk.Marshal()491if len(keyData) == len(pkd) && subtle.ConstantTimeCompare(keyData, pkd) == 1 {492return true, nil493}494}495return false, nil496}497498func (s *Server) GetWorkspaceSSHKey(ctx context.Context, workspaceIP string, supervisorPort string) (ssh.Signer, string, error) {499supervisorConn, err := grpc.Dial(workspaceIP+":"+supervisorPort, grpc.WithTransportCredentials(insecure.NewCredentials()))500if err != nil {501return nil, "", xerrors.Errorf("failed connecting to supervisor: %w", err)502}503defer supervisorConn.Close()504keyInfo, err := supervisor.NewControlServiceClient(supervisorConn).CreateSSHKeyPair(ctx, &supervisor.CreateSSHKeyPairRequest{})505if err != nil {506return nil, "", xerrors.Errorf("failed getting ssh key pair info from supervisor: %w", err)507}508key, err := ssh.ParsePrivateKey([]byte(keyInfo.PrivateKey))509if err != nil {510return nil, "", xerrors.Errorf("failed parse private key: %w", err)511}512userName := keyInfo.UserName513if userName == "" {514userName = "gitpod"515}516return key, userName, nil517}518519func (s *Server) GenerateSSHCert(ctx context.Context, userName string) (ssh.Signer, error) {520// prepare certificate for signing521nonce := make([]byte, 32)522if _, err := io.ReadFull(rand.Reader, nonce); err != nil {523return nil, xerrors.Errorf("failed to generate signed SSH key: error generating random nonce: %w", err)524}525526pk, pv, err := ed25519.GenerateKey(rand.Reader)527if err != nil {528return nil, err529}530b, err := ssh.NewPublicKey(pk)531if err != nil {532return nil, err533}534535priv, err := ssh.NewSignerFromSigner(pv)536if err != nil {537return nil, err538}539540now := time.Now()541542certificate := &ssh.Certificate{543Serial: 0,544Key: b,545KeyId: "ws-proxy",546ValidPrincipals: []string{userName},547ValidAfter: uint64(now.Add(-10 * time.Minute).In(time.UTC).Unix()),548ValidBefore: uint64(now.Add(10 * time.Minute).In(time.UTC).Unix()),549CertType: ssh.UserCert,550Permissions: ssh.Permissions{551Extensions: map[string]string{552"permit-pty": "",553"permit-user-rc": "",554"permit-X11-forwarding": "",555"permit-port-forwarding": "",556"permit-agent-forwarding": "",557},558},559Nonce: nonce,560SignatureKey: s.caKey.PublicKey(),561}562err = certificate.SignCert(rand.Reader, s.caKey)563if err != nil {564return nil, err565}566certSigner, err := ssh.NewCertSigner(certificate, priv)567if err != nil {568return nil, err569}570return certSigner, nil571}572573func (s *Server) Serve(l net.Listener) error {574for {575conn, err := l.Accept()576if err != nil {577return err578}579580go s.HandleConn(conn)581}582}583584func workspaceSSHUsername(ctx context.Context, workspaceIP string, workspacekitPort string) (string, error) {585req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("http://%v:%v/ssh/username", workspaceIP, workspacekitPort), nil)586if err != nil {587return "", err588}589590resp, err := http.DefaultClient.Do(req)591if err != nil {592return "", err593}594defer resp.Body.Close()595596result, err := io.ReadAll(resp.Body)597if err != nil {598return "", err599}600601if resp.StatusCode != http.StatusOK {602return "", fmt.Errorf("unexpected status: %v (%v)", string(result), resp.StatusCode)603}604605return string(result), nil606}607608609