Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/ws-proxy/pkg/sshproxy/server.go
2500 views
1
// Copyright (c) 2021 Gitpod GmbH. All rights reserved.
2
// Licensed under the GNU Affero General Public License (AGPL).
3
// See License.AGPL.txt in the project root for license information.
4
5
package sshproxy
6
7
import (
8
"context"
9
"crypto/ed25519"
10
"crypto/rand"
11
"crypto/subtle"
12
"errors"
13
"fmt"
14
"io"
15
"net"
16
"net/http"
17
"regexp"
18
"strings"
19
"time"
20
21
"github.com/gitpod-io/gitpod/common-go/analytics"
22
"github.com/gitpod-io/gitpod/common-go/log"
23
gitpod "github.com/gitpod-io/gitpod/gitpod-protocol"
24
supervisor "github.com/gitpod-io/gitpod/supervisor/api"
25
tracker "github.com/gitpod-io/gitpod/ws-proxy/pkg/analytics"
26
"github.com/gitpod-io/gitpod/ws-proxy/pkg/common"
27
"github.com/gitpod-io/golang-crypto/ssh"
28
"github.com/prometheus/client_golang/prometheus"
29
"golang.org/x/xerrors"
30
"google.golang.org/grpc"
31
"google.golang.org/grpc/credentials/insecure"
32
"sigs.k8s.io/controller-runtime/pkg/metrics"
33
)
34
35
// This is copy from proxy/workspacerouter.go
36
const 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})"
37
38
var (
39
SSHConnectionCount = prometheus.NewGauge(prometheus.GaugeOpts{
40
Name: "gitpod_ws_proxy_ssh_connection_count",
41
Help: "Current number of SSH connection",
42
})
43
44
SSHAttemptTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
45
Name: "gitpod_ws_proxy_ssh_attempt_total",
46
Help: "Total number of SSH attempt",
47
}, []string{"status", "error_type"})
48
49
SSHTunnelOpenedTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
50
Name: "gitpod_ws_proxy_ssh_tunnel_opened_total",
51
Help: "Total number of SSH tunnels opened by the ws-proxy",
52
}, []string{})
53
54
SSHTunnelClosedTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
55
Name: "gitpod_ws_proxy_ssh_tunnel_closed_total",
56
Help: "Total number of SSH tunnels closed by the ws-proxy",
57
}, []string{"code"})
58
)
59
60
var (
61
ErrWorkspaceNotFound = NewSSHErrorWithReject("WS_NOTFOUND", "not found workspace")
62
ErrWorkspaceNotRunning = NewSSHErrorWithReject("WS_NOT_RUNNING", "workspace not running")
63
ErrWorkspaceIDInvalid = NewSSHErrorWithReject("WS_ID_INVALID", "workspace id invalid")
64
ErrUsernameFormat = NewSSHErrorWithReject("USER_FORMAT", "username format is not correct")
65
ErrMissPrivateKey = NewSSHErrorWithReject("MISS_KEY", "missing privateKey")
66
ErrConnFailed = NewSSHError("CONN_FAILED", "cannot to connect with workspace")
67
ErrCreateSSHKey = NewSSHError("CREATE_KEY_FAILED", "cannot create private pair in workspace")
68
69
ErrAuthFailed = NewSSHError("AUTH_FAILED", "auth failed")
70
// ErrAuthFailedWithReject is same with ErrAuthFailed, it will just disconnect immediately to avoid pointless retries
71
ErrAuthFailedWithReject = NewSSHErrorWithReject("AUTH_FAILED", "auth failed")
72
)
73
74
type SSHError struct {
75
shortName string
76
description string
77
err error
78
}
79
80
func (e SSHError) Error() string {
81
return e.description
82
}
83
84
func (e SSHError) ShortName() string {
85
return e.shortName
86
}
87
func (e SSHError) Unwrap() error {
88
return e.err
89
}
90
91
func NewSSHError(shortName string, description string) SSHError {
92
return SSHError{shortName: shortName, description: description}
93
}
94
95
func NewSSHErrorWithReject(shortName string, description string) SSHError {
96
return SSHError{shortName: shortName, description: description, err: ssh.ErrDenied}
97
}
98
99
type Session struct {
100
Conn *ssh.ServerConn
101
102
WorkspaceID string
103
InstanceID string
104
OwnerUserId string
105
106
PublicKey ssh.PublicKey
107
WorkspacePrivateKey ssh.Signer
108
}
109
110
type Server struct {
111
Heartbeater Heartbeat
112
113
HostKeys []ssh.Signer
114
sshConfig *ssh.ServerConfig
115
workspaceInfoProvider common.WorkspaceInfoProvider
116
caKey ssh.Signer
117
}
118
119
func init() {
120
metrics.Registry.MustRegister(
121
SSHConnectionCount,
122
SSHAttemptTotal,
123
SSHTunnelClosedTotal,
124
SSHTunnelOpenedTotal,
125
)
126
}
127
128
// New creates a new SSH proxy server
129
130
func New(signers []ssh.Signer, workspaceInfoProvider common.WorkspaceInfoProvider, heartbeat Heartbeat, caKey ssh.Signer) *Server {
131
server := &Server{
132
workspaceInfoProvider: workspaceInfoProvider,
133
Heartbeater: &noHeartbeat{},
134
HostKeys: signers,
135
caKey: caKey,
136
}
137
if heartbeat != nil {
138
server.Heartbeater = heartbeat
139
}
140
141
authWithWebsocketTunnel := func(conn ssh.ConnMetadata) (*ssh.Permissions, error) {
142
wsConn, ok := conn.RawConn().(*gitpod.WebsocketConnection)
143
if !ok {
144
return nil, ErrAuthFailed
145
}
146
info, ok := wsConn.Ctx.Value(common.WorkspaceInfoIdentifier).(map[string]string)
147
if !ok || info == nil {
148
return nil, ErrAuthFailed
149
}
150
workspaceId := info[common.WorkspaceIDIdentifier]
151
_, err := server.GetWorkspaceInfo(workspaceId)
152
if err != nil {
153
return nil, err
154
}
155
log.WithField(common.WorkspaceIDIdentifier, workspaceId).Info("success auth via websocket")
156
return &ssh.Permissions{
157
Extensions: map[string]string{
158
"workspaceId": workspaceId,
159
"debugWorkspace": info[common.DebugWorkspaceIdentifier],
160
},
161
}, nil
162
}
163
164
server.sshConfig = &ssh.ServerConfig{
165
ServerVersion: "SSH-2.0-GITPOD-GATEWAY",
166
NoClientAuth: true,
167
NoClientAuthCallback: func(conn ssh.ConnMetadata) (*ssh.Permissions, error) {
168
if perm, err := authWithWebsocketTunnel(conn); err == nil {
169
return perm, nil
170
}
171
args := strings.Split(conn.User(), "#")
172
workspaceId := args[0]
173
var debugWorkspace string
174
if strings.HasPrefix(workspaceId, "debug-") {
175
debugWorkspace = "true"
176
workspaceId = strings.TrimPrefix(workspaceId, "debug-")
177
}
178
wsInfo, err := server.GetWorkspaceInfo(workspaceId)
179
if err != nil {
180
return nil, err
181
}
182
// NoClientAuthCallback only support workspaceId#ownerToken
183
if len(args) != 2 {
184
return nil, ssh.ErrNoAuth
185
}
186
if wsInfo.Auth.OwnerToken != args[1] {
187
return nil, ErrAuthFailedWithReject
188
}
189
server.TrackSSHConnection(wsInfo, "auth", nil)
190
return &ssh.Permissions{
191
Extensions: map[string]string{
192
"workspaceId": workspaceId,
193
"debugWorkspace": debugWorkspace,
194
},
195
}, nil
196
},
197
PasswordCallback: func(conn ssh.ConnMetadata, password []byte) (perm *ssh.Permissions, err error) {
198
workspaceId, ownerToken := conn.User(), string(password)
199
var debugWorkspace string
200
if strings.HasPrefix(workspaceId, "debug-") {
201
debugWorkspace = "true"
202
workspaceId = strings.TrimPrefix(workspaceId, "debug-")
203
}
204
wsInfo, err := server.GetWorkspaceInfo(workspaceId)
205
if err != nil {
206
return nil, err
207
}
208
defer func() {
209
server.TrackSSHConnection(wsInfo, "auth", err)
210
}()
211
if wsInfo.Auth.OwnerToken != ownerToken {
212
return nil, ErrAuthFailed
213
}
214
return &ssh.Permissions{
215
Extensions: map[string]string{
216
"workspaceId": workspaceId,
217
"debugWorkspace": debugWorkspace,
218
},
219
}, nil
220
},
221
PublicKeyCallback: func(conn ssh.ConnMetadata, pk ssh.PublicKey) (perm *ssh.Permissions, err error) {
222
if perm, err := authWithWebsocketTunnel(conn); err == nil {
223
return perm, nil
224
}
225
workspaceId := conn.User()
226
var debugWorkspace string
227
if strings.HasPrefix(workspaceId, "debug-") {
228
debugWorkspace = "true"
229
workspaceId = strings.TrimPrefix(workspaceId, "debug-")
230
}
231
wsInfo, err := server.GetWorkspaceInfo(workspaceId)
232
if err != nil {
233
return nil, err
234
}
235
defer func() {
236
server.TrackSSHConnection(wsInfo, "auth", err)
237
}()
238
ctx, cancel := context.WithCancel(context.Background())
239
defer cancel()
240
ok, _ := server.VerifyPublicKey(ctx, wsInfo, pk)
241
if !ok {
242
return nil, ErrAuthFailed
243
}
244
return &ssh.Permissions{
245
Extensions: map[string]string{
246
"workspaceId": workspaceId,
247
"debugWorkspace": debugWorkspace,
248
},
249
}, nil
250
},
251
}
252
for _, s := range signers {
253
server.sshConfig.AddHostKey(s)
254
}
255
return server
256
}
257
258
func ReportSSHAttemptMetrics(err error) {
259
if err == nil {
260
SSHAttemptTotal.WithLabelValues("success", "").Inc()
261
return
262
}
263
errorType := "OTHERS"
264
if serverAuthErr, ok := err.(*ssh.ServerAuthError); ok && len(serverAuthErr.Errors) > 0 {
265
if authErr, ok := serverAuthErr.Errors[len(serverAuthErr.Errors)-1].(SSHError); ok {
266
errorType = authErr.ShortName()
267
}
268
} else if authErr, ok := err.(SSHError); ok {
269
errorType = authErr.ShortName()
270
}
271
SSHAttemptTotal.WithLabelValues("failed", errorType).Inc()
272
}
273
274
func (s *Server) RequestForward(reqs <-chan *ssh.Request, targetConn ssh.Conn) {
275
for req := range reqs {
276
result, payload, err := targetConn.SendRequest(req.Type, req.WantReply, req.Payload)
277
if err != nil {
278
continue
279
}
280
_ = req.Reply(result, payload)
281
}
282
}
283
284
func (s *Server) HandleConn(c net.Conn) {
285
clientConn, clientChans, clientReqs, err := ssh.NewServerConn(c, s.sshConfig)
286
if err != nil {
287
c.Close()
288
if errors.Is(err, io.EOF) {
289
return
290
}
291
ReportSSHAttemptMetrics(err)
292
log.WithError(err).Error("failed to create new server connection")
293
return
294
}
295
defer clientConn.Close()
296
297
if clientConn.Permissions == nil || clientConn.Permissions.Extensions == nil || clientConn.Permissions.Extensions["workspaceId"] == "" {
298
return
299
}
300
workspaceId := clientConn.Permissions.Extensions["workspaceId"]
301
debugWorkspace := clientConn.Permissions.Extensions["debugWorkspace"] == "true"
302
wsInfo, err := s.GetWorkspaceInfo(workspaceId)
303
if err != nil {
304
ReportSSHAttemptMetrics(err)
305
log.WithField("workspaceId", workspaceId).WithError(err).Error("failed to get workspace info")
306
return
307
}
308
log := log.WithField("instanceId", wsInfo.InstanceID).WithField("isMk2", wsInfo.IsManagedByMk2)
309
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
310
supervisorPort := "22999"
311
if debugWorkspace {
312
supervisorPort = "24999"
313
}
314
315
var key ssh.Signer
316
//nolint:ineffassign
317
userName := "gitpod"
318
319
session := &Session{
320
Conn: clientConn,
321
WorkspaceID: workspaceId,
322
InstanceID: wsInfo.InstanceID,
323
OwnerUserId: wsInfo.OwnerUserId,
324
}
325
326
if !wsInfo.IsManagedByMk2 {
327
if s.caKey == nil || !wsInfo.IsEnabledSSHCA {
328
err = xerrors.Errorf("workspace not managed by mk2, but didn't have SSH CA enabled")
329
s.TrackSSHConnection(wsInfo, "connect", ErrCreateSSHKey)
330
ReportSSHAttemptMetrics(ErrCreateSSHKey)
331
log.WithError(err).Error("failed to generate ssh cert")
332
cancel()
333
return
334
}
335
// obtain the SSH username from workspacekit.
336
workspacekitPort := "22998"
337
userName, err = workspaceSSHUsername(ctx, wsInfo.IPAddress, workspacekitPort)
338
if err != nil {
339
userName = "root"
340
log.WithError(err).Warn("failed to retrieve the SSH username. Using root.")
341
}
342
}
343
344
if s.caKey != nil && wsInfo.IsEnabledSSHCA {
345
key, err = s.GenerateSSHCert(ctx, userName)
346
if err != nil {
347
s.TrackSSHConnection(wsInfo, "connect", ErrCreateSSHKey)
348
ReportSSHAttemptMetrics(ErrCreateSSHKey)
349
log.WithError(err).Error("failed to generate ssh cert")
350
cancel()
351
return
352
}
353
session.WorkspacePrivateKey = key
354
} else {
355
key, userName, err = s.GetWorkspaceSSHKey(ctx, wsInfo.IPAddress, supervisorPort)
356
if err != nil {
357
cancel()
358
s.TrackSSHConnection(wsInfo, "connect", ErrCreateSSHKey)
359
ReportSSHAttemptMetrics(ErrCreateSSHKey)
360
log.WithError(err).Error("failed to create private pair in workspace")
361
return
362
}
363
364
session.WorkspacePrivateKey = key
365
}
366
367
cancel()
368
369
sshPort := "23001"
370
if debugWorkspace {
371
sshPort = "25001"
372
}
373
remoteAddr := wsInfo.IPAddress + ":" + sshPort
374
conn, err := net.Dial("tcp", remoteAddr)
375
if err != nil {
376
s.TrackSSHConnection(wsInfo, "connect", ErrConnFailed)
377
ReportSSHAttemptMetrics(ErrConnFailed)
378
log.WithField("workspaceIP", wsInfo.IPAddress).WithError(err).Error("dial failed")
379
return
380
}
381
defer conn.Close()
382
383
workspaceConn, workspaceChans, workspaceReqs, err := ssh.NewClientConn(conn, remoteAddr, &ssh.ClientConfig{
384
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
385
User: userName,
386
Auth: []ssh.AuthMethod{
387
ssh.PublicKeysCallback(func() (signers []ssh.Signer, err error) {
388
return []ssh.Signer{key}, nil
389
}),
390
},
391
Timeout: 10 * time.Second,
392
})
393
if err != nil {
394
s.TrackSSHConnection(wsInfo, "connect", ErrConnFailed)
395
ReportSSHAttemptMetrics(ErrConnFailed)
396
log.WithField("workspaceIP", wsInfo.IPAddress).WithError(err).Error("connect failed")
397
return
398
}
399
s.Heartbeater.SendHeartbeat(wsInfo.InstanceID, false, true)
400
ctx, cancel = context.WithCancel(context.Background())
401
402
s.TrackSSHConnection(wsInfo, "connect", nil)
403
SSHConnectionCount.Inc()
404
ReportSSHAttemptMetrics(nil)
405
406
forwardRequests := func(reqs <-chan *ssh.Request, targetConn ssh.Conn) {
407
for req := range reqs {
408
result, payload, err := targetConn.SendRequest(req.Type, req.WantReply, req.Payload)
409
if err != nil {
410
continue
411
}
412
_ = req.Reply(result, payload)
413
}
414
}
415
// client -> workspace global request forward
416
go forwardRequests(clientReqs, workspaceConn)
417
// workspce -> client global request forward
418
go forwardRequests(workspaceReqs, clientConn)
419
420
go func() {
421
for newChannel := range workspaceChans {
422
go s.ChannelForward(ctx, session, clientConn, newChannel)
423
}
424
}()
425
426
go func() {
427
for newChannel := range clientChans {
428
go s.ChannelForward(ctx, session, workspaceConn, newChannel)
429
}
430
}()
431
432
go func() {
433
clientConn.Wait()
434
cancel()
435
}()
436
go func() {
437
workspaceConn.Wait()
438
cancel()
439
}()
440
<-ctx.Done()
441
SSHConnectionCount.Dec()
442
workspaceConn.Close()
443
clientConn.Close()
444
cancel()
445
}
446
447
func (s *Server) GetWorkspaceInfo(workspaceId string) (*common.WorkspaceInfo, error) {
448
wsInfo := s.workspaceInfoProvider.WorkspaceInfo(workspaceId)
449
if wsInfo == nil {
450
if matched, _ := regexp.Match(workspaceIDRegex, []byte(workspaceId)); matched {
451
return nil, ErrWorkspaceNotFound
452
}
453
return nil, ErrWorkspaceIDInvalid
454
}
455
if !wsInfo.IsRunning {
456
return nil, ErrWorkspaceNotRunning
457
}
458
return wsInfo, nil
459
}
460
461
func (s *Server) TrackSSHConnection(wsInfo *common.WorkspaceInfo, phase string, err error) {
462
// if we didn't find an associated user, we don't want to track
463
if wsInfo == nil {
464
return
465
}
466
propertics := make(map[string]interface{})
467
propertics["workspaceId"] = wsInfo.WorkspaceID
468
propertics["instanceId"] = wsInfo.InstanceID
469
propertics["state"] = "success"
470
propertics["phase"] = phase
471
472
if err != nil {
473
propertics["state"] = "failed"
474
propertics["cause"] = err.Error()
475
}
476
477
tracker.Track(analytics.TrackMessage{
478
Identity: analytics.Identity{UserID: wsInfo.OwnerUserId},
479
Event: "ssh_connection",
480
Properties: propertics,
481
})
482
}
483
484
func (s *Server) VerifyPublicKey(ctx context.Context, wsInfo *common.WorkspaceInfo, pk ssh.PublicKey) (bool, error) {
485
for _, keyStr := range wsInfo.SSHPublicKeys {
486
key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyStr))
487
if err != nil {
488
continue
489
}
490
keyData := key.Marshal()
491
pkd := pk.Marshal()
492
if len(keyData) == len(pkd) && subtle.ConstantTimeCompare(keyData, pkd) == 1 {
493
return true, nil
494
}
495
}
496
return false, nil
497
}
498
499
func (s *Server) GetWorkspaceSSHKey(ctx context.Context, workspaceIP string, supervisorPort string) (ssh.Signer, string, error) {
500
supervisorConn, err := grpc.Dial(workspaceIP+":"+supervisorPort, grpc.WithTransportCredentials(insecure.NewCredentials()))
501
if err != nil {
502
return nil, "", xerrors.Errorf("failed connecting to supervisor: %w", err)
503
}
504
defer supervisorConn.Close()
505
keyInfo, err := supervisor.NewControlServiceClient(supervisorConn).CreateSSHKeyPair(ctx, &supervisor.CreateSSHKeyPairRequest{})
506
if err != nil {
507
return nil, "", xerrors.Errorf("failed getting ssh key pair info from supervisor: %w", err)
508
}
509
key, err := ssh.ParsePrivateKey([]byte(keyInfo.PrivateKey))
510
if err != nil {
511
return nil, "", xerrors.Errorf("failed parse private key: %w", err)
512
}
513
userName := keyInfo.UserName
514
if userName == "" {
515
userName = "gitpod"
516
}
517
return key, userName, nil
518
}
519
520
func (s *Server) GenerateSSHCert(ctx context.Context, userName string) (ssh.Signer, error) {
521
// prepare certificate for signing
522
nonce := make([]byte, 32)
523
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
524
return nil, xerrors.Errorf("failed to generate signed SSH key: error generating random nonce: %w", err)
525
}
526
527
pk, pv, err := ed25519.GenerateKey(rand.Reader)
528
if err != nil {
529
return nil, err
530
}
531
b, err := ssh.NewPublicKey(pk)
532
if err != nil {
533
return nil, err
534
}
535
536
priv, err := ssh.NewSignerFromSigner(pv)
537
if err != nil {
538
return nil, err
539
}
540
541
now := time.Now()
542
543
certificate := &ssh.Certificate{
544
Serial: 0,
545
Key: b,
546
KeyId: "ws-proxy",
547
ValidPrincipals: []string{userName},
548
ValidAfter: uint64(now.Add(-10 * time.Minute).In(time.UTC).Unix()),
549
ValidBefore: uint64(now.Add(10 * time.Minute).In(time.UTC).Unix()),
550
CertType: ssh.UserCert,
551
Permissions: ssh.Permissions{
552
Extensions: map[string]string{
553
"permit-pty": "",
554
"permit-user-rc": "",
555
"permit-X11-forwarding": "",
556
"permit-port-forwarding": "",
557
"permit-agent-forwarding": "",
558
},
559
},
560
Nonce: nonce,
561
SignatureKey: s.caKey.PublicKey(),
562
}
563
err = certificate.SignCert(rand.Reader, s.caKey)
564
if err != nil {
565
return nil, err
566
}
567
certSigner, err := ssh.NewCertSigner(certificate, priv)
568
if err != nil {
569
return nil, err
570
}
571
return certSigner, nil
572
}
573
574
func (s *Server) Serve(l net.Listener) error {
575
for {
576
conn, err := l.Accept()
577
if err != nil {
578
return err
579
}
580
581
go s.HandleConn(conn)
582
}
583
}
584
585
func workspaceSSHUsername(ctx context.Context, workspaceIP string, workspacekitPort string) (string, error) {
586
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("http://%v:%v/ssh/username", workspaceIP, workspacekitPort), nil)
587
if err != nil {
588
return "", err
589
}
590
591
resp, err := http.DefaultClient.Do(req)
592
if err != nil {
593
return "", err
594
}
595
defer resp.Body.Close()
596
597
result, err := io.ReadAll(resp.Body)
598
if err != nil {
599
return "", err
600
}
601
602
if resp.StatusCode != http.StatusOK {
603
return "", fmt.Errorf("unexpected status: %v (%v)", string(result), resp.StatusCode)
604
}
605
606
return string(result), nil
607
}
608
609