cmd/gomote: implements GRPC ssh command

This change adds the implementation for the GRPC ssh command to the
gomote client.

Updates golang/go#48737
For golang/go#47521

Change-Id: I1e1f09ad23b0f07d28e0c5d06ad00cb948bb41f8
Reviewed-on: https://go-review.googlesource.com/c/build/+/405514
Reviewed-by: Alex Rakoczy <alex@golang.org>
Reviewed-by: Heschi Kreinick <heschi@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
Auto-Submit: Carlos Amedee <carlos@golang.org>
Run-TryBot: Carlos Amedee <carlos@golang.org>
diff --git a/cmd/gomote/gomote.go b/cmd/gomote/gomote.go
index 1d18cda..4df0288 100644
--- a/cmd/gomote/gomote.go
+++ b/cmd/gomote/gomote.go
@@ -162,7 +162,7 @@
 	registerCommand("rdp", "RDP (Remote Desktop Protocol) to a Windows buildlet", rdp)
 	registerCommand("rm", "delete files or directories", rm)
 	registerCommand("run", "run a command on a buildlet", legacyRun)
-	registerCommand("ssh", "ssh to a buildlet", ssh)
+	registerCommand("ssh", "ssh to a buildlet", legacySSH)
 	registerCommand("v2", "version 2 of the gomote commands", version2)
 }
 
@@ -222,6 +222,7 @@
 		"ls":      ls,
 		"run":     run,
 		"ping":    ping,
+		"ssh":     ssh,
 	}
 	if len(args) == 0 {
 		usage()
diff --git a/cmd/gomote/ssh.go b/cmd/gomote/ssh.go
index b870013..21f9f11 100644
--- a/cmd/gomote/ssh.go
+++ b/cmd/gomote/ssh.go
@@ -5,18 +5,23 @@
 package main
 
 import (
+	"context"
+	"errors"
 	"flag"
 	"fmt"
+	"io/ioutil"
 	"log"
 	"os"
 	"os/exec"
+	"path/filepath"
 	"strings"
 	"syscall"
 
+	"golang.org/x/build/internal/gomote/protos"
 	"golang.org/x/build/internal/gophers"
 )
 
-func ssh(args []string) error {
+func legacySSH(args []string) error {
 	fs := flag.NewFlagSet("ssh", flag.ContinueOnError)
 	fs.Usage = func() {
 		fmt.Fprintln(os.Stderr, "ssh usage: gomote ssh <instance>")
@@ -53,3 +58,119 @@
 	syscall.Exec(ssh, []string{"ssh", "-p", "2222", sshUser + "@farmer.golang.org"}, os.Environ())
 	return nil
 }
+
+func ssh(args []string) error {
+	fs := flag.NewFlagSet("ssh", flag.ContinueOnError)
+	fs.Usage = func() {
+		fmt.Fprintln(os.Stderr, "ssh usage: gomote ssh <instance>")
+		fs.PrintDefaults()
+		os.Exit(1)
+	}
+	fs.Parse(args)
+	if fs.NArg() != 1 {
+		fs.Usage()
+	}
+
+	name := fs.Arg(0)
+	sshKeyDir, err := sshConfigDirectory()
+	if err != nil {
+		return err
+	}
+	pubKey, priKey, err := localKeyPair(sshKeyDir)
+	if err != nil {
+		return err
+	}
+	pubKeyBytes, err := os.ReadFile(pubKey)
+	if err != nil {
+		return err
+	}
+	ctx := context.Background()
+	client := gomoteServerClient(ctx)
+	resp, err := client.SignSSHKey(ctx, &protos.SignSSHKeyRequest{
+		GomoteId:     name,
+		PublicSshKey: []byte(pubKeyBytes),
+	})
+	if err != nil {
+		return fmt.Errorf("unable to retrieve SSH certificate: %s", statusFromError(err))
+	}
+	certPath, err := writeCertificateToDisk(resp.GetSignedPublicSshKey())
+	if err != nil {
+		return err
+	}
+	return sshConnect(name, priKey, certPath)
+}
+
+func sshConfigDirectory() (string, error) {
+	configDir, err := os.UserConfigDir()
+	if err != nil {
+		return "", fmt.Errorf("unable to retrieve user configuration directory: %s", err)
+	}
+	sshConfigDir := filepath.Join(configDir, "gomote", ".ssh")
+	err = os.MkdirAll(sshConfigDir, 0700)
+	if err != nil {
+		return "", fmt.Errorf("unable to create user SSH configuration directory: %s", err)
+	}
+	return sshConfigDir, nil
+}
+
+func localKeyPair(sshDir string) (string, string, error) {
+	priKey := filepath.Join(sshDir, "id_ed25519")
+	pubKey := filepath.Join(sshDir, "id_ed25519.pub")
+	if !fileExists(priKey) || !fileExists(pubKey) {
+		log.Printf("local ssh keys do not exist, attempting to create them")
+		if err := createLocalKeyPair(pubKey, priKey); err != nil {
+			return "", "", fmt.Errorf("unable to create local SSH key pair: %s", err)
+		}
+	}
+	return pubKey, priKey, nil
+}
+
+func createLocalKeyPair(pubKey, priKey string) error {
+	cmd := exec.Command("ssh-keygen", "-o", "-a", "256", "-t", "ed25519", "-f", priKey)
+	cmd.Stdout = os.Stdout
+	cmd.Stdin = os.Stdin
+	cmd.Stderr = os.Stderr
+	return cmd.Run()
+}
+
+func writeCertificateToDisk(b []byte) (string, error) {
+	tmpDir := filepath.Join(os.TempDir(), ".gomote")
+	if err := os.MkdirAll(tmpDir, 0700); err != nil {
+		return "", fmt.Errorf("unable to create temp directory for certficates: %s", err)
+	}
+	tf, err := ioutil.TempFile(tmpDir, "id_ed25519-*-cert.pub")
+	if err != nil {
+		return "", err
+	}
+	if err := tf.Chmod(0600); err != nil {
+		return "", err
+	}
+	if _, err := tf.Write(b); err != nil {
+		return "", err
+	}
+	return tf.Name(), tf.Close()
+}
+
+func sshConnect(name string, priKey, certPath string) error {
+	ssh, err := exec.LookPath("ssh")
+	if err != nil {
+		return fmt.Errorf("path to ssh not found: %s", err)
+	}
+	cli := []string{"-o", fmt.Sprintf("CertificateFile=%s", certPath), "-i", priKey, "-p", "2222", name + "@farmer.golang.org"}
+	fmt.Printf("$ %s %s\n", ssh, strings.Join(cli, " "))
+	cmd := exec.Command(ssh, cli...)
+	cmd.Stdout = os.Stdout
+	cmd.Stdin = os.Stdin
+	cmd.Stderr = os.Stderr
+	if err := cmd.Run(); err != nil {
+		return fmt.Errorf("unable to ssh into instance: %s", err)
+	}
+	return nil
+}
+
+func fileExists(path string) bool {
+	if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
+		return false
+	}
+	return true
+}