mirror of https://github.com/coder/coder.git
191 lines
5.9 KiB
Go
191 lines
5.9 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/v2/cli/clibase"
|
|
"github.com/coder/coder/v2/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.DefaultStyles.Wrap.Render("Coder authenticates with "+cliui.DefaultStyles.Field.Render("git")+
|
|
" using the public key below. All clones with SSH are authenticated automatically 🪄.")+"\n")
|
|
_, _ = fmt.Fprintln(inv.Stderr, cliui.DefaultStyles.Code.Render(strings.TrimSpace(key.PublicKey))+"\n")
|
|
_, _ = fmt.Fprintln(inv.Stderr, "Add to GitHub and GitLab:")
|
|
_, _ = fmt.Fprintln(inv.Stderr, cliui.DefaultStyles.Prompt.String()+"https://github.com/settings/ssh/new")
|
|
_, _ = fmt.Fprintln(inv.Stderr, cliui.DefaultStyles.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
|
|
}
|