blob: 70641572131964b2132e3f2a9d831c7880d87c25 [file] [log] [blame]
// Copyright 2022 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build go1.16 && (linux || darwin)
// +build go1.16
// +build linux darwin
package remote
import (
"bufio"
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"fmt"
"io"
"io/ioutil"
"log"
"net"
"net/http"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"syscall"
"time"
"unsafe"
"github.com/creack/pty"
gssh "github.com/gliderlabs/ssh"
"golang.org/x/build/dashboard"
"golang.org/x/build/internal/envutil"
"golang.org/x/build/internal/gophers"
"golang.org/x/crypto/ssh"
)
// SignPublicSSHKey signs a public SSH key using the certificate authority. These keys are intended for use with the specified gomote and owner.
// The public SSH are intended to be used in OpenSSH certificate authentication with the gomote SSH server.
func SignPublicSSHKey(ctx context.Context, caPriKey ssh.Signer, rawPubKey []byte, sessionID, ownerID string, d time.Duration) ([]byte, error) {
pubKey, _, _, _, err := ssh.ParseAuthorizedKey(rawPubKey)
if err != nil {
return nil, fmt.Errorf("unable to parse public key=%w", err)
}
cert := &ssh.Certificate{
Key: pubKey,
Serial: 1,
CertType: ssh.UserCert,
KeyId: "go_build",
ValidPrincipals: []string{fmt.Sprintf("%s@farmer.golang.org", sessionID), ownerID},
ValidAfter: uint64(time.Now().Unix()),
ValidBefore: uint64(time.Now().Add(d).Unix()),
Permissions: ssh.Permissions{
Extensions: map[string]string{
"permit-X11-forwarding": "",
"permit-agent-forwarding": "",
"permit-port-forwarding": "",
"permit-pty": "",
"permit-user-rc": "",
},
},
}
if err := cert.SignCert(rand.Reader, caPriKey); err != nil {
return nil, fmt.Errorf("cerificate.SignCert() = %w", err)
}
mCert := ssh.MarshalAuthorizedKey(cert)
return mCert, nil
}
// SSHKeyPair generates a set of ecdsa256 SSH Keys. The public key is serialized for inclusion in
// an OpenSSH authorized_keys file. The private key is PEM encoded.
func SSHKeyPair() (privateKey []byte, publicKey []byte, err error) {
private, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, err
}
public, err := ssh.NewPublicKey(&private.PublicKey)
if err != nil {
return nil, nil, err
}
publicKey = ssh.MarshalAuthorizedKey(public)
priKeyByt, err := x509.MarshalECPrivateKey(private)
if err != nil {
return nil, nil, fmt.Errorf("unable to marshal private key=%w", err)
}
privateKey = pem.EncodeToMemory(&pem.Block{
Type: "EC PRIVATE KEY",
Bytes: priKeyByt,
})
return
}
// SSHServer is the SSH server that the coordinator provides.
type SSHServer struct {
gomotePublicKey string
privateHostKeyFile string
remoteBuildlets *Buildlets
server *gssh.Server
sessionPool *SessionPool
}
// NewSSHServer creates an SSH server used to access remote buildlet sessions.
func NewSSHServer(addr string, hostPrivateKey, gomotePublicKey, caPrivateKey []byte, sp *SessionPool, rbs *Buildlets) (*SSHServer, error) {
hostSigner, err := ssh.ParsePrivateKey(hostPrivateKey)
if err != nil {
return nil, fmt.Errorf("failed to parse SSH host key: %v; not configuring SSH server", err)
}
CASigner, err := ssh.ParsePrivateKey(caPrivateKey)
if err != nil {
return nil, fmt.Errorf("failed to parse SSH host key: %v; not configuring SSH server", err)
}
privateHostKeyFile, err := WriteSSHPrivateKeyToTempFile(hostPrivateKey)
if err != nil {
return nil, fmt.Errorf("error writing ssh private key to temp file: %v; not configuring SSH server", err)
}
s := &SSHServer{
gomotePublicKey: string(gomotePublicKey),
privateHostKeyFile: privateHostKeyFile,
remoteBuildlets: rbs,
sessionPool: sp,
server: &gssh.Server{
Addr: addr,
PublicKeyHandler: chainSSHPublicKeyHandlers(handleSSHPublicKeyAuth, handleCertificateAuthFunc(sp, CASigner)),
HostSigners: []gssh.Signer{hostSigner},
},
}
s.server.Handler = s.HandleIncomingSSHPostAuth
return s, nil
}
// ListenAndServe attempts to start the SSH server. This blocks until the server stops.
func (ss *SSHServer) ListenAndServe() error {
return ss.server.ListenAndServe()
}
// Close imediately closes all active listeners and connections.
func (ss *SSHServer) Close() error {
return ss.server.Close()
}
// serve attempts to start the SSH server and listens with the passed in net.Listener. This blocks
// until the server stops. This should be used while testing the server.
func (ss *SSHServer) serve(l net.Listener) error {
return ss.server.Serve(l)
}
// HandleIncomingSSHPostAuth handles SSH requests after the session has been authenticated. It forwards
// the request to either the legacy handler or the new handler.
// TODO(go.dev/issue/47521) The legacy path can be removed once the migration to the new security model
// is complete.
func (ss *SSHServer) HandleIncomingSSHPostAuth(s gssh.Session) {
inst := s.User()
requestedMutable := strings.HasPrefix(inst, "mutable-")
if requestedMutable {
inst = strings.TrimPrefix(inst, "mutable-")
}
_, _, isPty := s.Pty()
if !isPty {
fmt.Fprintf(s, "scp etc not yet supported; https://golang.org/issue/21140\n")
return
}
if ss.gomotePublicKey == "" {
fmt.Fprint(s, "invalid gomote-ssh-public-key")
return
}
ss.remoteBuildlets.Lock()
rb, ok := ss.remoteBuildlets.M[inst]
ss.remoteBuildlets.Unlock()
if ok {
ss.legacyIncomingSSHPostAuth(s, rb)
return
}
rs, err := ss.sessionPool.Session(inst)
if err != nil {
fmt.Fprintf(s, "unknown instance %q", inst)
return
}
ss.IncomingSSHPostAuth(s, rs)
}
// legacyIncomingSSHPostAuth is the handler for legacy remote buildlet SSH sessions.
func (ss *SSHServer) legacyIncomingSSHPostAuth(s gssh.Session, rb *Buildlet) {
inst := s.User()
user := UserFromGomoteInstanceName(inst)
requestedMutable := strings.HasPrefix(inst, "mutable-")
if requestedMutable {
inst = strings.TrimPrefix(inst, "mutable-")
}
ptyReq, winCh, isPty := s.Pty()
if !isPty {
fmt.Fprintf(s, "scp etc not yet supported; https://golang.org/issue/21140\n")
return
}
if ss.gomotePublicKey == "" {
fmt.Fprint(s, "invalid gomote-ssh-public-key")
return
}
hostType := rb.HostType
hostConf, ok := dashboard.Hosts[hostType]
if !ok {
fmt.Fprintf(s, "instance %q has unknown host type %q\n", inst, hostType)
return
}
bconf, ok := dashboard.Builders[rb.BuilderType]
if !ok {
fmt.Fprintf(s, "instance %q has unknown builder type %q\n", inst, rb.BuilderType)
return
}
ctx, cancel := context.WithCancel(s.Context())
defer cancel()
go rb.Renew(ctx, ss.remoteBuildlets)
sshUser := hostConf.SSHUsername
useLocalSSHProxy := bconf.GOOS() != "plan9"
if sshUser == "" && useLocalSSHProxy {
fmt.Fprintf(s, "instance %q host type %q does not have SSH configured\n", inst, hostType)
return
}
if !hostConf.IsHermetic() && !requestedMutable {
fmt.Fprintf(s, "WARNING: instance %q host type %q is not currently\n", inst, hostType)
fmt.Fprintf(s, "configured to have a hermetic filesystem per boot.\n")
fmt.Fprintf(s, "You must be careful not to modify machine state\n")
fmt.Fprintf(s, "that will affect future builds. Do you agree? If so,\n")
fmt.Fprintf(s, "run gomote ssh --i-will-not-break-the-host <INST>\n")
return
}
log.Printf("connecting to ssh to instance %q ...", inst)
fmt.Fprintf(s, "# Welcome to the gomote ssh proxy, %s.\n", user)
fmt.Fprintf(s, "# Connecting to/starting remote ssh...\n")
fmt.Fprintf(s, "#\n")
var localProxyPort int
if useLocalSSHProxy {
sshConn, err := rb.Buildlet().ConnectSSH(sshUser, ss.gomotePublicKey)
log.Printf("buildlet(%q).ConnectSSH = %T, %v", inst, sshConn, err)
if err != nil {
fmt.Fprintf(s, "failed to connect to ssh on %s: %v\n", inst, err)
return
}
defer sshConn.Close()
// Now listen on some localhost port that we'll proxy to sshConn.
// The openssh ssh command line tool will connect to this IP.
ln, err := net.Listen("tcp", "localhost:0")
if err != nil {
fmt.Fprintf(s, "local listen error: %v\n", err)
return
}
localProxyPort = ln.Addr().(*net.TCPAddr).Port
log.Printf("ssh local proxy port for %s: %v", inst, localProxyPort)
var lnCloseOnce sync.Once
lnClose := func() { lnCloseOnce.Do(func() { ln.Close() }) }
defer lnClose()
// Accept at most one connection from localProxyPort and proxy
// it to sshConn.
go func() {
c, err := ln.Accept()
lnClose()
if err != nil {
return
}
defer c.Close()
errc := make(chan error, 1)
go func() {
_, err := io.Copy(c, sshConn)
errc <- err
}()
go func() {
_, err := io.Copy(sshConn, c)
errc <- err
}()
err = <-errc
}()
}
workDir, err := rb.Buildlet().WorkDir(ctx)
if err != nil {
fmt.Fprintf(s, "Error getting WorkDir: %v\n", err)
return
}
ip, _, ipErr := net.SplitHostPort(rb.Buildlet().IPPort())
fmt.Fprintf(s, "# `gomote push` and the builders use:\n")
fmt.Fprintf(s, "# - workdir: %s\n", workDir)
fmt.Fprintf(s, "# - GOROOT: %s/go\n", workDir)
fmt.Fprintf(s, "# - GOPATH: %s/gopath\n", workDir)
fmt.Fprintf(s, "# - env: %s\n", strings.Join(bconf.Env(), " ")) // TODO: shell quote?
fmt.Fprintf(s, "# Happy debugging.\n")
log.Printf("ssh to %s: starting ssh -p %d for %s@localhost", inst, localProxyPort, sshUser)
var cmd *exec.Cmd
switch bconf.GOOS() {
default:
cmd = exec.Command("ssh",
"-p", strconv.Itoa(localProxyPort),
"-o", "UserKnownHostsFile=/dev/null",
"-o", "StrictHostKeyChecking=no",
"-i", ss.privateHostKeyFile,
sshUser+"@localhost")
case "plan9":
fmt.Fprintf(s, "# Plan9 user/pass: glenda/glenda123\n")
if ipErr != nil {
fmt.Fprintf(s, "# Failed to get IP out of %q: %v\n", rb.Buildlet().IPPort(), err)
return
}
cmd = exec.Command("/usr/local/bin/drawterm",
"-a", ip, "-c", ip, "-u", "glenda", "-k", "user=glenda")
}
envutil.SetEnv(cmd, "TERM="+ptyReq.Term)
f, err := pty.Start(cmd)
if err != nil {
log.Printf("running ssh client to %s: %v", inst, err)
return
}
defer f.Close()
go func() {
for win := range winCh {
setWinsize(f, win.Width, win.Height)
}
}()
go func() {
io.Copy(f, s) // stdin
}()
io.Copy(s, f) // stdout
cmd.Process.Kill()
cmd.Wait()
}
// IncomingSSHPostAuth handles post-authentication requests for the SSH server. This handler uses
// Sessions for session management.
func (ss *SSHServer) IncomingSSHPostAuth(s gssh.Session, rs *Session) {
inst := s.User()
ptyReq, winCh, _ := s.Pty()
hostConf, ok := dashboard.Hosts[rs.HostType]
if !ok {
fmt.Fprintf(s, "instance %q has unknown host type %q\n", inst, rs.HostType)
return
}
bconf, ok := dashboard.Builders[rs.BuilderType]
if !ok {
fmt.Fprintf(s, "instance %q has unknown builder type %q\n", inst, rs.BuilderType)
return
}
ctx, cancel := context.WithCancel(s.Context())
defer cancel()
err := ss.sessionPool.KeepAlive(ctx, inst)
if err != nil {
log.Printf("ssh: KeepAlive on session=%s failed: %s", inst, err)
}
sshUser := hostConf.SSHUsername
useLocalSSHProxy := bconf.GOOS() != "plan9"
if sshUser == "" && useLocalSSHProxy {
fmt.Fprintf(s, "instance %q host type %q does not have SSH configured\n", inst, rs.HostType)
return
}
if !hostConf.IsHermetic() {
fmt.Fprintf(s, "WARNING: instance %q host type %q is not currently\n", inst, rs.HostType)
fmt.Fprintf(s, "configured to have a hermetic filesystem per boot.\n")
fmt.Fprintf(s, "You must be careful not to modify machine state\n")
fmt.Fprintf(s, "that will affect future builds. Do you agree? If so,\n")
fmt.Fprintf(s, "run gomote ssh --i-will-not-break-the-host <INST>\n")
return
}
log.Printf("connecting to ssh to instance %q ...", inst)
fmt.Fprint(s, "# Welcome to the gomote ssh proxy.\n")
fmt.Fprint(s, "# Connecting to/starting remote ssh...\n")
fmt.Fprint(s, "#\n")
var localProxyPort int
bc, err := ss.sessionPool.BuildletClient(inst)
if err != nil {
fmt.Fprintf(s, "failed to connect to ssh on %s: %v\n", inst, err)
return
}
if useLocalSSHProxy {
sshConn, err := bc.ConnectSSH(sshUser, ss.gomotePublicKey)
log.Printf("buildlet(%q).ConnectSSH = %T, %v", inst, sshConn, err)
if err != nil {
fmt.Fprintf(s, "failed to connect to ssh on %s: %v\n", inst, err)
return
}
defer sshConn.Close()
// Now listen on some localhost port that we'll proxy to sshConn.
// The openssh ssh command line tool will connect to this IP.
ln, err := net.Listen("tcp", "localhost:0")
if err != nil {
fmt.Fprintf(s, "local listen error: %v\n", err)
return
}
localProxyPort = ln.Addr().(*net.TCPAddr).Port
log.Printf("ssh local proxy port for %s: %v", inst, localProxyPort)
var lnCloseOnce sync.Once
lnClose := func() { lnCloseOnce.Do(func() { ln.Close() }) }
defer lnClose()
// Accept at most one connection from localProxyPort and proxy
// it to sshConn.
go func() {
c, err := ln.Accept()
lnClose()
if err != nil {
return
}
defer c.Close()
errc := make(chan error, 1)
go func() {
_, err := io.Copy(c, sshConn)
errc <- err
}()
go func() {
_, err := io.Copy(sshConn, c)
errc <- err
}()
err = <-errc
}()
}
workDir, err := bc.WorkDir(ctx)
if err != nil {
fmt.Fprintf(s, "Error getting WorkDir: %v\n", err)
return
}
ip, _, ipErr := net.SplitHostPort(bc.IPPort())
fmt.Fprint(s, "# `gomote push` and the builders use:\n")
fmt.Fprintf(s, "# - workdir: %s\n", workDir)
fmt.Fprintf(s, "# - GOROOT: %s/go\n", workDir)
fmt.Fprintf(s, "# - GOPATH: %s/gopath\n", workDir)
fmt.Fprintf(s, "# - env: %s\n", strings.Join(bconf.Env(), " ")) // TODO: shell quote?
fmt.Fprint(s, "# Happy debugging.\n")
log.Printf("ssh to %s: starting ssh -p %d for %s@localhost", inst, localProxyPort, sshUser)
var cmd *exec.Cmd
switch bconf.GOOS() {
default:
cmd = exec.Command("ssh",
"-p", strconv.Itoa(localProxyPort),
"-o", "UserKnownHostsFile=/dev/null",
"-o", "StrictHostKeyChecking=no",
"-i", ss.privateHostKeyFile,
sshUser+"@localhost")
case "plan9":
fmt.Fprintf(s, "# Plan9 user/pass: glenda/glenda123\n")
if ipErr != nil {
fmt.Fprintf(s, "# Failed to get IP out of %q: %v\n", bc.IPPort(), err)
return
}
cmd = exec.Command("/usr/local/bin/drawterm",
"-a", ip, "-c", ip, "-u", "glenda", "-k", "user=glenda")
}
envutil.SetEnv(cmd, "TERM="+ptyReq.Term)
f, err := pty.Start(cmd)
if err != nil {
log.Printf("running ssh client to %s: %v", inst, err)
return
}
defer f.Close()
go func() {
for win := range winCh {
setWinsize(f, win.Width, win.Height)
}
}()
go func() {
io.Copy(f, s) // stdin
}()
io.Copy(s, f) // stdout
cmd.Process.Kill()
cmd.Wait()
}
// WriteSSHPrivateKeyToTempFile writes a key to a temporary file on the local file system. It also
// sets the permissions on the file to what is expected by OpenSSH implementations of SSH.
func WriteSSHPrivateKeyToTempFile(key []byte) (path string, err error) {
tf, err := ioutil.TempFile("", "ssh-priv-key")
if err != nil {
return "", err
}
if err := tf.Chmod(0600); err != nil {
return "", err
}
if _, err := tf.Write(key); err != nil {
return "", err
}
return tf.Name(), tf.Close()
}
// chainSSHPublicKeyHandlers allows you to chain public key authentication handlers. Each handler is
// attempted until one succeeds.
func chainSSHPublicKeyHandlers(handlers ...gssh.PublicKeyHandler) gssh.PublicKeyHandler {
return func(ctx gssh.Context, key gssh.PublicKey) bool {
for _, handler := range handlers {
if handler(ctx, key) {
return true
}
}
return false
}
}
// handleCertificateAuthFunc creates a function that authenticates the session using OpenSSH certificate
// authentication. The passed in certificate is tested to ensure it is valid, signed by the CA and
// corresponds to an existing session.
func handleCertificateAuthFunc(sp *SessionPool, caKeySigner ssh.Signer) gssh.PublicKeyHandler {
return func(ctx gssh.Context, key gssh.PublicKey) bool {
sessionID := ctx.User()
cert, ok := key.(*ssh.Certificate)
if !ok {
log.Printf("public key is not a certificate session=%s", sessionID)
return false
}
if cert.CertType != ssh.UserCert {
log.Printf("certificate not user cert session=%s", sessionID)
return false
}
if !bytes.Equal(cert.SignatureKey.Marshal(), caKeySigner.PublicKey().Marshal()) {
log.Printf("certificate is not signed by recognized Certificate Authority session=%s", sessionID)
return false
}
_, err := sp.Session(sessionID)
if err != nil {
log.Printf("HandleCertificateAuth: unable to retrieve session=%s: %s", sessionID, err)
return false
}
certChecker := &ssh.CertChecker{}
wantPrincipal := fmt.Sprintf("%s@farmer.golang.org", sessionID)
if err := certChecker.CheckCert(wantPrincipal, cert); err != nil {
log.Printf("certChecker.CheckCert(%s, user_certificate) = %s", wantPrincipal, err)
return false
}
return true
}
}
func handleSSHPublicKeyAuth(ctx gssh.Context, key gssh.PublicKey) bool {
inst := ctx.User() // expected to be of form "user-USER-goos-goarch-etc"
user := UserFromGomoteInstanceName(inst)
if user == "" {
return false
}
// Map the gomote username to the github username, and use the
// github user's public ssh keys for authentication. This is
// mostly of laziness and pragmatism, not wanting to invent or
// maintain a new auth mechanism or password/key registry.
githubUser := gophers.GitHubOfGomoteUser(user)
keys := githubPublicKeys(githubUser)
for _, authKey := range keys {
if gssh.KeysEqual(key, authKey.PublicKey) {
log.Printf("for instance %q, github user %q key matched: %s", inst, githubUser, authKey.AuthorizedLine)
return true
}
}
return false
}
// authorizedKey is a Github user's SSH authorized key, in both string and parsed format.
type authorizedKey struct {
AuthorizedLine string // e.g. "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILj8HGIG9NsT34PHxO8IBq0riSBv7snp30JM8AanBGoV"
PublicKey ssh.PublicKey
}
func githubPublicKeys(user string) []authorizedKey {
// TODO: caching, rate limiting.
req, err := http.NewRequest("GET", "https://github.com/"+user+".keys", nil)
if err != nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req = req.WithContext(ctx)
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Printf("getting %s github keys: %v", user, err)
return nil
}
defer res.Body.Close()
if res.StatusCode != 200 {
return nil
}
var keys []authorizedKey
bs := bufio.NewScanner(res.Body)
for bs.Scan() {
key, _, _, _, err := ssh.ParseAuthorizedKey(bs.Bytes())
if err != nil {
log.Printf("parsing github user %q key %q: %v", user, bs.Text(), err)
continue
}
keys = append(keys, authorizedKey{
PublicKey: key,
AuthorizedLine: strings.TrimSpace(bs.Text()),
})
}
if err := bs.Err(); err != nil {
return nil
}
return keys
}
// UserFromGomoteInstanceName returns the username part of a gomote
// remote instance name.
//
// The instance name is of two forms. The normal form is:
//
// user-bradfitz-linux-amd64-0
//
// The overloaded form to convey that the user accepts responsibility
// for changes to the underlying host is to prefix the same instance
// name with the string "mutable-", such as:
//
// mutable-user-bradfitz-darwin-amd64-10_8-0
//
// The mutable part is ignored by this function.
func UserFromGomoteInstanceName(name string) string {
name = strings.TrimPrefix(name, "mutable-")
if !strings.HasPrefix(name, "user-") {
return ""
}
user := name[len("user-"):]
hyphen := strings.IndexByte(user, '-')
if hyphen == -1 {
return ""
}
return user[:hyphen]
}
func setWinsize(f *os.File, w, h int) {
syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), uintptr(syscall.TIOCSWINSZ),
uintptr(unsafe.Pointer(&struct{ h, w, x, y uint16 }{uint16(h), uint16(w), 0, 0})))
}