blob: d3dea53cf4fec6fd51eaf75af3183a93bb564c24 [file]
// Copyright 2017 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.
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"golang.org/x/build/internal/gomote/protos"
)
// sshServer is the ssh proxy through which ssh and scp
// reach buildlets.
const sshServer = "gomotessh.golang.org"
func ssh(args []string) error {
fs := flag.NewFlagSet("ssh", flag.ContinueOnError)
fs.Usage = func() {
usageLogger.Print("ssh usage: gomote ssh [-n] <instance> [cmd...]")
fs.PrintDefaults()
os.Exit(1)
}
printOnly := fs.Bool("n", false, "print the ssh command line but do not run it")
fs.Parse(args)
var name string
var remoteCmd []string
if fs.NArg() >= 1 {
name = fs.Arg(0)
remoteCmd = fs.Args()[1:]
} else if activeGroup != nil {
if len(activeGroup.Instances) != 1 {
return fmt.Errorf("command only supports groups with exactly one member")
}
name = activeGroup.Instances[0]
} else {
fs.Usage()
}
priKey, certPath, err := sshCertificate(name)
if err != nil {
return err
}
return sshConnect(name, priKey, certPath, remoteCmd, *printOnly)
}
// scp copies files to or from buildlets.
// Arguments of the form instance:path name files on that instance;
// all other arguments, including scp flags, pass through to scp.
func scp(args []string) error {
instances, rewritten := scpArgs(args)
if len(instances) == 0 {
usageLogger.Print("scp usage: gomote scp [scp-args] [<instance>:]file... [<instance>:]file")
os.Exit(1)
}
scpPath, err := exec.LookPath("scp")
if err != nil {
return fmt.Errorf("path to scp not found: %w", err)
}
cli := []string{"-P", "2222"}
var priKey string
for _, inst := range instances {
pk, certPath, err := sshCertificate(inst)
if err != nil {
return err
}
priKey = pk
cli = append(cli, "-o", "CertificateFile="+certPath)
}
cli = append(cli, "-i", priKey)
cli = append(cli, rewritten...)
fmt.Printf("$ %s\n", shellJoin(append([]string{scpPath}, cli...)))
cmd := exec.Command(scpPath, cli...)
cmd.Stdout = os.Stdout
cmd.Stdin = os.Stdin
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("unable to scp: %w", err)
}
return nil
}
// scpArgs rewrites the arguments for an scp command, replacing
// arguments of the form instance:path with instance@sshServer:path
// and returning the instances mentioned along with the rewritten
// argument list.
func scpArgs(args []string) (instances, rewritten []string) {
for _, arg := range args {
inst, ok := scpInstance(arg)
if !ok {
rewritten = append(rewritten, arg)
continue
}
if !slices.Contains(instances, inst) {
instances = append(instances, inst)
}
rewritten = append(rewritten, inst+"@"+sshServer+strings.TrimPrefix(arg, inst))
}
return instances, rewritten
}
// scpInstance reports the instance name if arg has the remote form
// instance:path, using scp's own rule: an argument is remote if it
// contains a colon before any slash. Flags and arguments that
// already name a user with @ are left alone.
func scpInstance(arg string) (string, bool) {
if strings.HasPrefix(arg, "-") {
return "", false
}
i := strings.IndexAny(arg, ":/@")
if i <= 0 || arg[i] != ':' {
return "", false
}
return arg[:i], true
}
// sshCertificate signs the local SSH public key for the named
// instance, returning the paths of the local private key and the
// signed certificate.
func sshCertificate(name string) (priKey, certPath string, err error) {
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: %w", err)
}
certPath, err = writeCertificateToDisk(resp.GetSignedPublicSshKey())
if err != nil {
return "", "", err
}
return priKey, certPath, nil
}
func sshConfigDirectory() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("unable to retrieve user configuration directory: %w", 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: %w", 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: %w", 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: %w", err)
}
tf, err := os.CreateTemp(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, remoteCmd []string, printOnly bool) error {
ssh, err := exec.LookPath("ssh")
if err != nil {
return fmt.Errorf("path to ssh not found: %w", err)
}
cli := []string{"-o", fmt.Sprintf("CertificateFile=%s", certPath), "-i", priKey, "-p", "2222", name + "@" + sshServer}
cli = append(cli, remoteCmd...)
if printOnly {
fmt.Println(shellJoin(append([]string{ssh}, cli...)))
return nil
}
fmt.Printf("$ %s\n", shellJoin(append([]string{ssh}, 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: %w", err)
}
return nil
}
// shellQuote quotes s as needed for use in a shell command line.
func shellQuote(s string) string {
if s != "" && !strings.ContainsAny(s, " \t\n'\"\\$&|;<>()*?[]#~`!{}") {
return s
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// shellJoin joins args into a shell command line, quoting as needed.
func shellJoin(args []string) string {
quoted := make([]string, len(args))
for i, arg := range args {
quoted[i] = shellQuote(arg)
}
return strings.Join(quoted, " ")
}
func fileExists(path string) bool {
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
return false
}
return true
}