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}