coder/cli/gitssh.go

191 lines
5.8 KiB
Go

package cli
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"golang.org/x/xerrors"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
)
func (r *RootCmd) gitssh() *clibase.Cmd {
cmd := &clibase.Cmd{
Use: "gitssh",
Hidden: true,
Short: `Wraps the "ssh" command and uses the coder gitssh key for authentication`,
Handler: func(inv *clibase.Invocation) error {
ctx := inv.Context()
env := os.Environ()
// Catch interrupt signals to ensure the temporary private
// key file is cleaned up on most cases.
ctx, stop := signal.NotifyContext(ctx, InterruptSignals...)
defer stop()
// Early check so errors are reported immediately.
identityFiles, err := parseIdentityFilesForHost(ctx, inv.Args, env)
if err != nil {
return err
}
client, err := r.createAgentClient()
if err != nil {
return xerrors.Errorf("create agent client: %w", err)
}
key, err := client.GitSSHKey(ctx)
if err != nil {
return xerrors.Errorf("get agent git ssh token: %w", err)
}
privateKeyFile, err := os.CreateTemp("", "coder-gitsshkey-*")
if err != nil {
return xerrors.Errorf("create temp gitsshkey file: %w", err)
}
defer func() {
_ = privateKeyFile.Close()
_ = os.Remove(privateKeyFile.Name())
}()
_, err = privateKeyFile.WriteString(key.PrivateKey)
if err != nil {
return xerrors.Errorf("write to temp gitsshkey file: %w", err)
}
err = privateKeyFile.Close()
if err != nil {
return xerrors.Errorf("close temp gitsshkey file: %w", err)
}
// Append our key, giving precedence to user keys. Note that
// OpenSSH server are typically configured with MaxAuthTries
// set to the default value of 6. This means that only the 6
// first keys can be tried. However, we will assume that if
// a user has configured 6+ keys for a host, they know what
// they're doing. This behavior is critical if a server has
// been configured with MaxAuthTries set to 1.
identityFiles = append(identityFiles, privateKeyFile.Name())
var identityArgs []string
for _, id := range identityFiles {
identityArgs = append(identityArgs, "-i", id)
}
args := inv.Args
args = append(identityArgs, args...)
c := exec.CommandContext(ctx, "ssh", args...)
c.Env = append(c.Env, env...)
c.Stderr = inv.Stderr
c.Stdout = inv.Stdout
c.Stdin = inv.Stdin
err = c.Run()
if err != nil {
exitErr := &exec.ExitError{}
if xerrors.As(err, &exitErr) && exitErr.ExitCode() == 255 {
_, _ = fmt.Fprintln(inv.Stderr,
"\n"+cliui.Styles.Wrap.Render("Coder authenticates with "+cliui.Styles.Field.Render("git")+
" using the public key below. All clones with SSH are authenticated automatically 🪄.")+"\n")
_, _ = fmt.Fprintln(inv.Stderr, cliui.Styles.Code.Render(strings.TrimSpace(key.PublicKey))+"\n")
_, _ = fmt.Fprintln(inv.Stderr, "Add to GitHub and GitLab:")
_, _ = fmt.Fprintln(inv.Stderr, cliui.Styles.Prompt.String()+"https://github.com/settings/ssh/new")
_, _ = fmt.Fprintln(inv.Stderr, cliui.Styles.Prompt.String()+"https://gitlab.com/-/profile/keys")
_, _ = fmt.Fprintln(inv.Stderr)
return err
}
return xerrors.Errorf("run ssh command: %w", err)
}
return nil
},
}
return cmd
}
// fallbackIdentityFiles is the list of identity files SSH tries when
// none have been defined for a host.
var fallbackIdentityFiles = strings.Join([]string{
"identityfile ~/.ssh/id_rsa",
"identityfile ~/.ssh/id_dsa",
"identityfile ~/.ssh/id_ecdsa",
"identityfile ~/.ssh/id_ecdsa_sk",
"identityfile ~/.ssh/id_ed25519",
"identityfile ~/.ssh/id_ed25519_sk",
"identityfile ~/.ssh/id_xmss",
}, "\n")
// parseIdentityFilesForHost uses ssh -G to discern what SSH keys have
// been enabled for the host (via the users SSH config) and returns a
// list of existing identity files.
//
// We do this because when no keys are defined for a host, SSH uses
// fallback keys (see above). However, by passing `-i` to attach our
// private key, we're effectively disabling the fallback keys.
//
// Example invocation:
//
// ssh -G -o SendEnv=GIT_PROTOCOL git@github.com git-upload-pack 'coder/coder'
//
// The extra arguments work without issue and lets us run the command
// as-is without stripping out the excess (git-upload-pack 'coder/coder').
func parseIdentityFilesForHost(ctx context.Context, args, env []string) (identityFiles []string, error error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, xerrors.Errorf("get user home dir failed: %w", err)
}
var outBuf bytes.Buffer
var r io.Reader = &outBuf
args = append([]string{"-G"}, args...)
cmd := exec.CommandContext(ctx, "ssh", args...)
cmd.Env = append(cmd.Env, env...)
cmd.Stdout = &outBuf
cmd.Stderr = io.Discard
err = cmd.Run()
if err != nil {
// If ssh -G failed, the SSH version is likely too old, fallback
// to using the default identity files.
r = strings.NewReader(fallbackIdentityFiles)
}
s := bufio.NewScanner(r)
for s.Scan() {
line := s.Text()
if strings.HasPrefix(line, "identityfile ") {
id := strings.TrimPrefix(line, "identityfile ")
if strings.HasPrefix(id, "~/") {
id = home + id[1:]
}
// OpenSSH on Windows is weird, it supports using (and does
// use) mixed \ and / in paths.
//
// Example: C:\Users\ZeroCool/.ssh/known_hosts
//
// To check the file existence in Go, though, we want to use
// proper Windows paths.
// OpenSSH is amazing, this will work on Windows too:
// C:\Users\ZeroCool/.ssh/id_rsa
id = filepath.FromSlash(id)
// Only include the identity file if it exists.
if _, err := os.Stat(id); err == nil {
identityFiles = append(identityFiles, id)
}
}
}
if err := s.Err(); err != nil {
// This should never happen, the check is for completeness.
return nil, xerrors.Errorf("scan ssh output: %w", err)
}
return identityFiles, nil
}