cmd/gitbay/ssh.go

195 lines · 6205 bytes

  1package main
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"io"
  7	"os"
  8	"os/exec"
  9	"path/filepath"
 10	"regexp"
 11	"strconv"
 12	"strings"
 13
 14	"gitbay.org/gitbay/internal/cliconfig"
 15	"gitbay.org/gitbay/internal/protocol"
 16	"gitbay.org/gitbay/internal/toolpath"
 17)
 18
 19// context is the resolved target for a command: which instance to talk to
 20// and, when run inside a clone of a forge repo, which repository.
 21type target struct {
 22	inst cliconfig.Instance
 23	repo string // owner/name, "" when not inferable
 24}
 25
 26// resolveTarget picks the instance and repo. An origin remote matching a
 27// CONFIGURED instance wins and carries repo inference; otherwise the
 28// default instance is used with no inference — a clone from some other
 29// host (github, a different forge) must never hijack the command. The
 30// raw origin serves as an ad-hoc instance only when nothing is configured
 31// at all.
 32func resolveTarget() (target, error) {
 33	cfg, err := cliconfig.Load()
 34	if err != nil {
 35		return target{}, err
 36	}
 37
 38	parsed, repo, originOK := cliconfig.ParseRemoteURL(originURL())
 39	if originOK {
 40		norm := func(p int) int {
 41			if p == 0 {
 42				return 22
 43			}
 44			return p
 45		}
 46		for _, inst := range cfg.Instances {
 47			if inst.Host == parsed.Host && norm(inst.Port) == norm(parsed.Port) {
 48				return target{inst: inst, repo: repo}, nil
 49			}
 50		}
 51	}
 52
 53	if inst, _, err := cfg.DefaultInstance(); err == nil {
 54		return target{inst: inst}, nil
 55	}
 56	if originOK {
 57		return target{inst: parsed, repo: repo}, nil
 58	}
 59	return target{}, fmt.Errorf("no gitbay instance configured; run: gitbay remote add <name> <host>")
 60}
 61
 62func originURL() string {
 63	out, err := exec.Command(toolpath.Look("git"), "remote", "get-url", "origin").Output()
 64	if err != nil {
 65		return ""
 66	}
 67	return strings.TrimSpace(string(out))
 68}
 69
 70// bareWord matches arguments that need no quoting for the server-side
 71// POSIX tokenizer.
 72var bareWord = regexp.MustCompile(`^[A-Za-z0-9@%+=:,./_!-]+$`)
 73
 74// shellQuote quotes one argument for the SSH command string; the server
 75// tokenizes with POSIX rules and no expansion.
 76func shellQuote(arg string) string {
 77	if arg != "" && bareWord.MatchString(arg) {
 78		return arg
 79	}
 80	return "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'"
 81}
 82
 83// sshArgs is every argument before the destination: the port, connection
 84// multiplexing, and the profile's own options last so they win.
 85//
 86// Multiplexing is what makes a CLI over SSH usable: without it every
 87// command pays a full handshake, seconds on a distant instance, and with
 88// it the second command in five minutes rides the first's connection
 89// (#94). The control socket lives under ~/.ssh, which ssh requires to be
 90// private; a profile can set no_multiplex = true to opt out.
 91func sshArgs(inst cliconfig.Instance) []string {
 92	args := []string{}
 93	if inst.Port != 0 && inst.Port != 22 {
 94		args = append(args, "-p", strconv.Itoa(inst.Port))
 95	}
 96	if !inst.NoMultiplex {
 97		if home, err := os.UserHomeDir(); err == nil {
 98			if st, err := os.Stat(filepath.Join(home, ".ssh")); err == nil && st.IsDir() {
 99				args = append(args,
100					"-o", "ControlMaster=auto",
101					"-o", "ControlPath="+filepath.Join(home, ".ssh", "gitbay-%C"),
102					"-o", "ControlPersist=300")
103			}
104		}
105	}
106	return append(args, inst.SSHOptions...)
107}
108
109// runSSH executes the server command over the system ssh binary, wiring
110// stdio through. It returns the remote exit code.
111func runSSH(t target, serverArgv []string, stdin io.Reader) int {
112	args := sshArgs(t.inst)
113	quoted := make([]string, len(serverArgv))
114	for i, a := range serverArgv {
115		quoted[i] = shellQuote(a)
116	}
117	args = append(args, t.inst.SSHUser()+"@"+t.inst.Host, "--", strings.Join(quoted, " "))
118
119	cmd := exec.Command(toolpath.Look("ssh"), args...)
120	cmd.Stdin = stdin
121	cmd.Stdout = os.Stdout
122	cmd.Stderr = os.Stderr
123	err := cmd.Run()
124	if err == nil {
125		return 0
126	}
127	if ee, ok := err.(*exec.ExitError); ok {
128		code := ee.ExitCode()
129		if code == 255 { // ssh-level failure (connection, auth, host key)
130			fmt.Fprintln(os.Stderr, "gitbay: ssh could not connect or authenticate; if this worked a moment ago,"+
131				" the instance may be rate-limiting authentication after a burst of connections: wait a minute and retry")
132			return protocol.ExitProtocol
133		}
134		return code
135	}
136	fmt.Fprintln(os.Stderr, "gitbay: running ssh:", err)
137	return protocol.ExitProtocol
138}
139
140// sshCapture runs a server command and returns its stdout, discarding
141// stderr. Used for quiet metadata fetches like issue templates.
142func sshCapture(t target, serverArgv []string) (string, int) {
143	args := sshArgs(t.inst)
144	quoted := make([]string, len(serverArgv))
145	for i, a := range serverArgv {
146		quoted[i] = shellQuote(a)
147	}
148	args = append(args, t.inst.SSHUser()+"@"+t.inst.Host, "--", strings.Join(quoted, " "))
149	out, err := exec.Command(toolpath.Look("ssh"), args...).Output()
150	if err != nil {
151		code := protocol.ExitProtocol
152		if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() != 255 {
153			code = ee.ExitCode()
154		}
155		return "", code
156	}
157	return string(out), 0
158}
159
160// fetchIssueTemplate returns the repo's default issue template body, or ""
161// when there is none (or anything fails — prefill is best-effort).
162func fetchIssueTemplate(t target, repoPath string) string {
163	out, code := sshCapture(t, []string{"issue", "templates", repoPath, "--json"})
164	if code != 0 {
165		return ""
166	}
167	var env struct {
168		Data []struct {
169			Name string `json:"name"`
170			Body string `json:"body"`
171		} `json:"data"`
172	}
173	if json.Unmarshal([]byte(out), &env) != nil || len(env.Data) == 0 {
174		return ""
175	}
176	for _, tpl := range env.Data {
177		if tpl.Name == "issue-template.md" {
178			return tpl.Body
179		}
180	}
181	return env.Data[0].Body
182}
183
184// withRepo prepends the repo path to args unless the user already gave one
185// explicitly (a first argument containing '/'). Commands' server parsers
186// accept the path at any position, so the front is always safe.
187func withRepo(t target, args []string) ([]string, error) {
188	if len(args) > 0 && !strings.HasPrefix(args[0], "-") && strings.Contains(args[0], "/") {
189		return args, nil // explicit owner/name
190	}
191	if t.repo == "" {
192		return nil, fmt.Errorf("no repository given and none inferable: pass <owner/name> or run inside a clone of a gitbay repository")
193	}
194	return append([]string{t.repo}, args...), nil
195}