golang.org/x/build@v0.0.0-20240506185731-218518f32b70/internal/coordinator/remote/ssh.go (about) 1 // Copyright 2022 The Go Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 //go:build linux || darwin 6 7 package remote 8 9 import ( 10 "bytes" 11 "context" 12 "crypto/ecdsa" 13 "crypto/elliptic" 14 "crypto/rand" 15 "crypto/x509" 16 "encoding/pem" 17 "errors" 18 "fmt" 19 "io" 20 "log" 21 "net" 22 "os" 23 "os/exec" 24 "strconv" 25 "strings" 26 "sync" 27 "syscall" 28 "time" 29 "unsafe" 30 31 "github.com/creack/pty" 32 gssh "github.com/gliderlabs/ssh" 33 "golang.org/x/build/dashboard" 34 "golang.org/x/build/internal/envutil" 35 "golang.org/x/crypto/ssh" 36 ) 37 38 // SignPublicSSHKey signs a public SSH key using the certificate authority. These keys are intended for use with the specified gomote and owner. 39 // The public SSH are intended to be used in OpenSSH certificate authentication with the gomote SSH server. 40 func SignPublicSSHKey(ctx context.Context, caPriKey ssh.Signer, rawPubKey []byte, sessionID, ownerID string, d time.Duration) ([]byte, error) { 41 pubKey, _, _, _, err := ssh.ParseAuthorizedKey(rawPubKey) 42 if err != nil { 43 return nil, fmt.Errorf("unable to parse public key=%w", err) 44 } 45 cert := &ssh.Certificate{ 46 Key: pubKey, 47 Serial: 1, 48 CertType: ssh.UserCert, 49 KeyId: "go_build", 50 ValidPrincipals: []string{fmt.Sprintf("%s@farmer.golang.org", sessionID), ownerID}, 51 ValidAfter: uint64(time.Now().Unix()), 52 ValidBefore: uint64(time.Now().Add(d).Unix()), 53 Permissions: ssh.Permissions{ 54 Extensions: map[string]string{ 55 "permit-X11-forwarding": "", 56 "permit-agent-forwarding": "", 57 "permit-port-forwarding": "", 58 "permit-pty": "", 59 "permit-user-rc": "", 60 }, 61 }, 62 } 63 if err := cert.SignCert(rand.Reader, caPriKey); err != nil { 64 return nil, fmt.Errorf("cerificate.SignCert() = %w", err) 65 } 66 mCert := ssh.MarshalAuthorizedKey(cert) 67 return mCert, nil 68 } 69 70 // SSHKeyPair generates a set of ecdsa256 SSH Keys. The public key is serialized for inclusion in 71 // an OpenSSH authorized_keys file. The private key is PEM encoded. 72 func SSHKeyPair() (privateKey []byte, publicKey []byte, err error) { 73 private, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 74 if err != nil { 75 return nil, nil, err 76 } 77 public, err := ssh.NewPublicKey(&private.PublicKey) 78 if err != nil { 79 return nil, nil, err 80 } 81 publicKey = ssh.MarshalAuthorizedKey(public) 82 priKeyByt, err := x509.MarshalECPrivateKey(private) 83 if err != nil { 84 return nil, nil, fmt.Errorf("unable to marshal private key=%w", err) 85 } 86 privateKey = pem.EncodeToMemory(&pem.Block{ 87 Type: "EC PRIVATE KEY", 88 Bytes: priKeyByt, 89 }) 90 return 91 } 92 93 // SSHOption are options to set for the SSH server. 94 type SSHOption func(*SSHServer) 95 96 // EnableLUCIOption sets the configuration needed for swarming bots to connect to the 97 // SSH server. 98 func EnableLUCIOption() SSHOption { 99 return func(s *SSHServer) { 100 s.server.Handler = s.HandleIncomingSSHPostAuthSwarming 101 } 102 } 103 104 // SSHServer is the SSH server that the coordinator provides. 105 type SSHServer struct { 106 gomotePublicKey string 107 privateHostKeyFile string 108 server *gssh.Server 109 sessionPool *SessionPool 110 } 111 112 // NewSSHServer creates an SSH server used to access remote buildlet sessions. 113 func NewSSHServer(addr string, hostPrivateKey, gomotePublicKey, caPrivateKey []byte, sp *SessionPool, opts ...SSHOption) (*SSHServer, error) { 114 hostSigner, err := ssh.ParsePrivateKey(hostPrivateKey) 115 if err != nil { 116 return nil, fmt.Errorf("failed to parse SSH host key: %v; not configuring SSH server", err) 117 } 118 CASigner, err := ssh.ParsePrivateKey(caPrivateKey) 119 if err != nil { 120 return nil, fmt.Errorf("failed to parse SSH host key: %v; not configuring SSH server", err) 121 } 122 privateHostKeyFile, err := WriteSSHPrivateKeyToTempFile(hostPrivateKey) 123 if err != nil { 124 return nil, fmt.Errorf("error writing ssh private key to temp file: %v; not configuring SSH server", err) 125 } 126 if len(gomotePublicKey) == 0 { 127 return nil, errors.New("invalid gomote public key") 128 } 129 s := &SSHServer{ 130 gomotePublicKey: string(gomotePublicKey), 131 privateHostKeyFile: privateHostKeyFile, 132 sessionPool: sp, 133 server: &gssh.Server{ 134 Addr: addr, 135 PublicKeyHandler: handleCertificateAuthFunc(sp, CASigner), 136 HostSigners: []gssh.Signer{hostSigner}, 137 }, 138 } 139 s.server.Handler = s.HandleIncomingSSHPostAuth 140 for _, opt := range opts { 141 opt(s) 142 } 143 return s, nil 144 } 145 146 // ListenAndServe attempts to start the SSH server. This blocks until the server stops. 147 func (ss *SSHServer) ListenAndServe() error { 148 return ss.server.ListenAndServe() 149 } 150 151 // Close immediately closes all active listeners and connections. 152 func (ss *SSHServer) Close() error { 153 return ss.server.Close() 154 } 155 156 // serve attempts to start the SSH server and listens with the passed in net.Listener. This blocks 157 // until the server stops. This should be used while testing the server. 158 func (ss *SSHServer) serve(l net.Listener) error { 159 return ss.server.Serve(l) 160 } 161 162 // HandleIncomingSSHPostAuth handles post-authentication requests for the SSH server. This handler uses 163 // Sessions for session management. 164 func (ss *SSHServer) HandleIncomingSSHPostAuth(s gssh.Session) { 165 inst := s.User() 166 ptyReq, winCh, isPty := s.Pty() 167 if !isPty { 168 fmt.Fprintf(s, "scp etc not yet supported; https://golang.org/issue/21140\n") 169 return 170 } 171 rs, err := ss.sessionPool.Session(inst) 172 if err != nil { 173 fmt.Fprintf(s, "unknown instance %q", inst) 174 return 175 } 176 hostConf, ok := dashboard.Hosts[rs.HostType] 177 if !ok { 178 fmt.Fprintf(s, "instance %q has unknown host type %q\n", inst, rs.HostType) 179 return 180 } 181 bconf, ok := dashboard.Builders[rs.BuilderType] 182 if !ok { 183 fmt.Fprintf(s, "instance %q has unknown builder type %q\n", inst, rs.BuilderType) 184 return 185 } 186 187 ctx, cancel := context.WithCancel(s.Context()) 188 defer cancel() 189 if err := ss.sessionPool.KeepAlive(ctx, inst); err != nil { 190 log.Printf("ssh: KeepAlive on session=%s failed: %s", inst, err) 191 } 192 193 sshUser := hostConf.SSHUsername 194 useLocalSSHProxy := bconf.GOOS() != "plan9" 195 if sshUser == "" && useLocalSSHProxy { 196 fmt.Fprintf(s, "instance %q host type %q does not have SSH configured\n", inst, rs.HostType) 197 return 198 } 199 if !hostConf.IsHermetic() { 200 fmt.Fprintf(s, "WARNING: instance %q host type %q is not currently\n", inst, rs.HostType) 201 fmt.Fprintf(s, "configured to have a hermetic filesystem per boot.\n") 202 fmt.Fprintf(s, "You must be careful not to modify machine state\n") 203 fmt.Fprintf(s, "that will affect future builds.\n") 204 } 205 log.Printf("connecting to ssh to instance %q ...", inst) 206 fmt.Fprint(s, "# Welcome to the gomote ssh proxy.\n") 207 fmt.Fprint(s, "# Connecting to/starting remote ssh...\n") 208 fmt.Fprint(s, "#\n") 209 210 var localProxyPort int 211 bc, err := ss.sessionPool.BuildletClient(inst) 212 if err != nil { 213 fmt.Fprintf(s, "failed to connect to ssh on %s: %v\n", inst, err) 214 return 215 } 216 if useLocalSSHProxy { 217 sshConn, err := bc.ConnectSSH(sshUser, ss.gomotePublicKey) 218 log.Printf("buildlet(%q).ConnectSSH = %T, %v", inst, sshConn, err) 219 if err != nil { 220 fmt.Fprintf(s, "failed to connect to ssh on %s: %v\n", inst, err) 221 return 222 } 223 defer sshConn.Close() 224 225 // Now listen on some localhost port that we'll proxy to sshConn. 226 // The openssh ssh command line tool will connect to this IP. 227 ln, err := net.Listen("tcp", "localhost:0") 228 if err != nil { 229 fmt.Fprintf(s, "local listen error: %v\n", err) 230 return 231 } 232 localProxyPort = ln.Addr().(*net.TCPAddr).Port 233 log.Printf("ssh local proxy port for %s: %v", inst, localProxyPort) 234 var lnCloseOnce sync.Once 235 lnClose := func() { lnCloseOnce.Do(func() { ln.Close() }) } 236 defer lnClose() 237 238 // Accept at most one connection from localProxyPort and proxy 239 // it to sshConn. 240 go func() { 241 c, err := ln.Accept() 242 lnClose() 243 if err != nil { 244 return 245 } 246 defer c.Close() 247 errc := make(chan error, 1) 248 go func() { 249 _, err := io.Copy(c, sshConn) 250 errc <- err 251 }() 252 go func() { 253 _, err := io.Copy(sshConn, c) 254 errc <- err 255 }() 256 err = <-errc 257 }() 258 } 259 workDir, err := bc.WorkDir(ctx) 260 if err != nil { 261 fmt.Fprintf(s, "Error getting WorkDir: %v\n", err) 262 return 263 } 264 ip, _, ipErr := net.SplitHostPort(bc.IPPort()) 265 266 fmt.Fprint(s, "# `gomote push` and the builders use:\n") 267 fmt.Fprintf(s, "# - workdir: %s\n", workDir) 268 fmt.Fprintf(s, "# - GOROOT: %s/go\n", workDir) 269 fmt.Fprintf(s, "# - GOPATH: %s/gopath\n", workDir) 270 fmt.Fprintf(s, "# - env: %s\n", strings.Join(bconf.Env(), " ")) // TODO: shell quote? 271 fmt.Fprint(s, "# Happy debugging.\n") 272 273 log.Printf("ssh to %s: starting ssh -p %d for %s@localhost", inst, localProxyPort, sshUser) 274 var cmd *exec.Cmd 275 switch bconf.GOOS() { 276 default: 277 cmd = exec.Command("ssh", 278 "-p", strconv.Itoa(localProxyPort), 279 "-o", "UserKnownHostsFile=/dev/null", 280 "-o", "StrictHostKeyChecking=no", 281 "-i", ss.privateHostKeyFile, 282 sshUser+"@localhost") 283 case "plan9": 284 fmt.Fprintf(s, "# Plan9 user/pass: glenda/glenda123\n") 285 if ipErr != nil { 286 fmt.Fprintf(s, "# Failed to get IP out of %q: %v\n", bc.IPPort(), ipErr) 287 return 288 } 289 cmd = exec.Command("/usr/local/bin/drawterm", 290 "-a", ip, "-c", ip, "-u", "glenda", "-k", "user=glenda") 291 } 292 envutil.SetEnv(cmd, "TERM="+ptyReq.Term) 293 f, err := pty.Start(cmd) 294 if err != nil { 295 log.Printf("running ssh client to %s: %v", inst, err) 296 return 297 } 298 defer f.Close() 299 go func() { 300 for win := range winCh { 301 setWinsize(f, win.Width, win.Height) 302 } 303 }() 304 go func() { 305 ss.setupRemoteSSHEnv(bconf, workDir, f) 306 io.Copy(f, s) // stdin 307 }() 308 io.Copy(s, f) // stdout 309 cmd.Process.Kill() 310 cmd.Wait() 311 } 312 313 // HandleIncomingSSHPostAuthSwarming handles post-authentication requests for the SSH server. This handler uses 314 // Sessions for session management. 315 func (ss *SSHServer) HandleIncomingSSHPostAuthSwarming(s gssh.Session) { 316 inst := s.User() 317 ptyReq, winCh, isPty := s.Pty() 318 if !isPty { 319 fmt.Fprintf(s, "scp etc not yet supported; https://go.dev/issue/21140\n") 320 return 321 } 322 rs, err := ss.sessionPool.Session(inst) 323 if err != nil { 324 fmt.Fprintf(s, "unknown instance %q", inst) 325 return 326 } 327 ctx, cancel := context.WithCancel(s.Context()) 328 defer cancel() 329 if err := ss.sessionPool.KeepAlive(ctx, inst); err != nil { 330 log.Printf("ssh: KeepAlive on session=%s failed: %s", inst, err) 331 } 332 333 sshUser := "swarming" 334 isPlan9 := strings.Contains(rs.HostType, "plan9") 335 useLocalSSHProxy := !isPlan9 336 if sshUser == "" && useLocalSSHProxy { 337 fmt.Fprintf(s, "instance %q host type %q does not have SSH configured\n", inst, rs.HostType) 338 return 339 } 340 // TODO(go.dev/issue/64064) do we still need hermetic checks? 341 log.Printf("connecting to ssh to instance %q ...", inst) 342 fmt.Fprint(s, "# Welcome to the gomote ssh proxy.\n") 343 fmt.Fprint(s, "# Connecting to/starting remote ssh...\n") 344 fmt.Fprint(s, "#\n") 345 346 var localProxyPort int 347 bc, err := ss.sessionPool.BuildletClient(inst) 348 if err != nil { 349 fmt.Fprintf(s, "failed to connect to ssh on %s: %v\n", inst, err) 350 return 351 } 352 if useLocalSSHProxy { 353 sshConn, err := bc.ConnectSSH(sshUser, ss.gomotePublicKey) 354 log.Printf("buildlet(%q).ConnectSSH = %T, %v", inst, sshConn, err) 355 if err != nil { 356 fmt.Fprintf(s, "failed to connect to ssh on %s: %v\n", inst, err) 357 return 358 } 359 defer sshConn.Close() 360 361 // Now listen on some localhost port that we'll proxy to sshConn. 362 // The openssh ssh command line tool will connect to this IP. 363 ln, err := net.Listen("tcp", "localhost:0") 364 if err != nil { 365 fmt.Fprintf(s, "local listen error: %v\n", err) 366 return 367 } 368 localProxyPort = ln.Addr().(*net.TCPAddr).Port 369 log.Printf("ssh local proxy port for %s: %v", inst, localProxyPort) 370 var lnCloseOnce sync.Once 371 lnClose := func() { lnCloseOnce.Do(func() { ln.Close() }) } 372 defer lnClose() 373 374 // Accept at most one connection from localProxyPort and proxy 375 // it to sshConn. 376 go func() { 377 c, err := ln.Accept() 378 lnClose() 379 if err != nil { 380 return 381 } 382 defer c.Close() 383 errc := make(chan error, 1) 384 go func() { 385 _, err := io.Copy(c, sshConn) 386 errc <- err 387 }() 388 go func() { 389 _, err := io.Copy(sshConn, c) 390 errc <- err 391 }() 392 err = <-errc 393 }() 394 } 395 workDir, err := bc.WorkDir(ctx) 396 if err != nil { 397 fmt.Fprintf(s, "Error getting WorkDir: %v\n", err) 398 return 399 } 400 ip, _, ipErr := net.SplitHostPort(bc.IPPort()) 401 402 fmt.Fprint(s, "# `gomote push` and the builders use:\n") 403 fmt.Fprintf(s, "# - workdir: %s\n", workDir) 404 fmt.Fprintf(s, "# - GOROOT: %s/go\n", workDir) 405 fmt.Fprintf(s, "# - GOPATH: %s/gopath\n", workDir) 406 fmt.Fprint(s, "# Happy debugging.\n") 407 408 log.Printf("ssh to %s: starting ssh -p %d for %s@localhost", inst, localProxyPort, sshUser) 409 cmd := exec.Command("ssh", 410 "-p", strconv.Itoa(localProxyPort), 411 "-o", "UserKnownHostsFile=/dev/null", 412 "-o", "StrictHostKeyChecking=no", 413 "-i", ss.privateHostKeyFile, 414 sshUser+"@localhost") 415 if isPlan9 { 416 fmt.Fprintf(s, "# Plan9 user/pass: glenda/glenda123\n") 417 if ipErr != nil { 418 fmt.Fprintf(s, "# Failed to get IP out of %q: %v\n", bc.IPPort(), ipErr) 419 return 420 } 421 cmd = exec.Command("/usr/local/bin/drawterm", 422 "-a", ip, "-c", ip, "-u", "glenda", "-k", "user=glenda") 423 } 424 425 envutil.SetEnv(cmd, "TERM="+ptyReq.Term) 426 f, err := pty.Start(cmd) 427 if err != nil { 428 log.Printf("running ssh client to %s: %v", inst, err) 429 return 430 } 431 defer f.Close() 432 go func() { 433 for win := range winCh { 434 setWinsize(f, win.Width, win.Height) 435 } 436 }() 437 go func() { 438 ss.setupRemoteSSHEnvSwarm(rs.BuilderType, workDir, f) 439 io.Copy(f, s) // stdin 440 }() 441 io.Copy(s, f) // stdout 442 cmd.Process.Kill() 443 cmd.Wait() 444 } 445 446 // setupRemoteSSHEnvSwarm prints environmental details to the writer. 447 // This makes the new SSH session easier to use for Go testing. 448 func (ss *SSHServer) setupRemoteSSHEnvSwarm(builderType, workDir string, f io.Writer) { 449 if strings.Contains(builderType, "windows") { 450 // TODO(65826) find a universal way of setting the working directory. 451 fmt.Fprintf(f, `cd %s`+"\r\n", workDir) 452 return 453 } 454 fmt.Fprintf(f, "cd %s\n", workDir) 455 } 456 457 // setupRemoteSSHEnv sets up environment variables on the remote system. 458 // This makes the new SSH session easier to use for Go testing. 459 func (ss *SSHServer) setupRemoteSSHEnv(bconf *dashboard.BuildConfig, workDir string, f io.Writer) { 460 switch bconf.GOOS() { 461 default: 462 // A Unix system. 463 for _, env := range bconf.Env() { 464 fmt.Fprintln(f, env) 465 if idx := strings.Index(env, "="); idx > 0 { 466 fmt.Fprintf(f, "export %s\n", env[:idx]) 467 } 468 } 469 fmt.Fprintf(f, "GOPATH=%s/gopath\n", workDir) 470 fmt.Fprintf(f, "PATH=$PATH:%s/go/bin\n", workDir) 471 fmt.Fprintf(f, "export GOPATH PATH\n") 472 fmt.Fprintf(f, "cd %s/go/src\n", workDir) 473 case "windows": 474 for _, env := range bconf.Env() { 475 fmt.Fprintf(f, "set %s\n", env) 476 } 477 fmt.Fprintf(f, `set GOPATH=%s\gopath`+"\n", workDir) 478 fmt.Fprintf(f, `set PATH=%%PATH%%;%s\go\bin`+"\n", workDir) 479 fmt.Fprintf(f, `cd %s\go\src`+"\n", workDir) 480 case "plan9": 481 // TODO 482 } 483 } 484 485 // WriteSSHPrivateKeyToTempFile writes a key to a temporary file on the local file system. It also 486 // sets the permissions on the file to what is expected by OpenSSH implementations of SSH. 487 func WriteSSHPrivateKeyToTempFile(key []byte) (path string, err error) { 488 tf, err := os.CreateTemp("", "ssh-priv-key") 489 if err != nil { 490 return "", err 491 } 492 if err := tf.Chmod(0600); err != nil { 493 return "", err 494 } 495 if _, err := tf.Write(key); err != nil { 496 return "", err 497 } 498 return tf.Name(), tf.Close() 499 } 500 501 // handleCertificateAuthFunc creates a function that authenticates the session using OpenSSH certificate 502 // authentication. The passed in certificate is tested to ensure it is valid, signed by the CA and 503 // corresponds to an existing session. 504 func handleCertificateAuthFunc(sp *SessionPool, caKeySigner ssh.Signer) gssh.PublicKeyHandler { 505 return func(ctx gssh.Context, key gssh.PublicKey) bool { 506 sessionID := ctx.User() 507 cert, ok := key.(*ssh.Certificate) 508 if !ok { 509 log.Printf("public key is not a certificate session=%s", sessionID) 510 return false 511 } 512 if cert.CertType != ssh.UserCert { 513 log.Printf("certificate not user cert session=%s", sessionID) 514 return false 515 } 516 if !bytes.Equal(cert.SignatureKey.Marshal(), caKeySigner.PublicKey().Marshal()) { 517 log.Printf("certificate is not signed by recognized Certificate Authority session=%s", sessionID) 518 return false 519 } 520 521 ses, err := sp.Session(sessionID) 522 if err != nil { 523 log.Printf("HandleCertificateAuth: unable to retrieve session=%s: %s", sessionID, err) 524 return false 525 } 526 certChecker := &ssh.CertChecker{} 527 wantPrincipal := fmt.Sprintf("%s@farmer.golang.org", sessionID) 528 if err := certChecker.CheckCert(wantPrincipal, cert); err != nil { 529 log.Printf("certChecker.CheckCert(%s, user_certificate) = %s", wantPrincipal, err) 530 return false 531 } 532 for _, principal := range cert.ValidPrincipals { 533 if principal == ses.OwnerID { 534 return true 535 } 536 } 537 log.Printf("HandleCertificateAuth: unable to verify ownerID in certificate principals") 538 return false 539 } 540 } 541 542 // authorizedKey is a Github user's SSH authorized key, in both string and parsed format. 543 type authorizedKey struct { 544 AuthorizedLine string // e.g. "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILj8HGIG9NsT34PHxO8IBq0riSBv7snp30JM8AanBGoV" 545 PublicKey ssh.PublicKey 546 } 547 548 func setWinsize(f *os.File, w, h int) { 549 syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), uintptr(syscall.TIOCSWINSZ), 550 uintptr(unsafe.Pointer(&struct{ h, w, x, y uint16 }{uint16(h), uint16(w), 0, 0}))) 551 }