Commit 0dd284d9dd
Verified · cmc ci/build: success ci/test: success ci/vuln: success
Layout: unified · split
cmd/gitbay/local.go +1 −5
| @@ -187,11 +187,7 @@ func cmdInit(args []string) int { | |||
| 187 | 187 | ||
| 188 | // captureSSH runs a server command and returns its stdout. | 188 | // captureSSH runs a server command and returns its stdout. |
| 189 | func captureSSH(t target, serverArgv []string) (string, int) { | 189 | func captureSSH(t target, serverArgv []string) (string, int) { |
| 190 | args := []string{} | 190 | args := sshArgs(t.inst) |
| 191 | if t.inst.Port != 0 && t.inst.Port != 22 { | ||
| 192 | args = append(args, "-p", fmt.Sprint(t.inst.Port)) | ||
| 193 | } | ||
| 194 | args = append(args, t.inst.SSHOptions...) | ||
| 195 | quoted := quoteAll(serverArgv) | 191 | quoted := quoteAll(serverArgv) |
| 196 | args = append(args, t.inst.SSHUser()+"@"+t.inst.Host, "--", strings.Join(quoted, " ")) | 192 | args = append(args, t.inst.SSHUser()+"@"+t.inst.Host, "--", strings.Join(quoted, " ")) |
| 197 | cmd := exec.Command("ssh", args...) | 193 | cmd := exec.Command("ssh", args...) |
cmd/gitbay/ssh.go +29 −10
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | "io" | 6 | "io" |
| 7 | "os" | 7 | "os" |
| 8 | "os/exec" | 8 | "os/exec" |
| 9 | "path/filepath" | ||
| 9 | "regexp" | 10 | "regexp" |
| 10 | "strconv" | 11 | "strconv" |
| 11 | "strings" | 12 | "strings" |
| @@ -78,14 +79,36 @@ func shellQuote(arg string) string { | |||
| 78 | return "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'" | 79 | return "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'" |
| 79 | } | 80 | } |
| 80 | 81 | ||
| 82 | // sshArgs is every argument before the destination: the port, connection | ||
| 83 | // multiplexing, and the profile's own options last so they win. | ||
| 84 | // | ||
| 85 | // Multiplexing is what makes a CLI over SSH usable: without it every | ||
| 86 | // command pays a full handshake, seconds on a distant instance, and with | ||
| 87 | // it the second command in five minutes rides the first's connection | ||
| 88 | // (#94). The control socket lives under ~/.ssh, which ssh requires to be | ||
| 89 | // private; a profile can set no_multiplex = true to opt out. | ||
| 90 | func sshArgs(inst cliconfig.Instance) []string { | ||
| 91 | args := []string{} | ||
| 92 | if inst.Port != 0 && inst.Port != 22 { | ||
| 93 | args = append(args, "-p", strconv.Itoa(inst.Port)) | ||
| 94 | } | ||
| 95 | if !inst.NoMultiplex { | ||
| 96 | if home, err := os.UserHomeDir(); err == nil { | ||
| 97 | if st, err := os.Stat(filepath.Join(home, ".ssh")); err == nil && st.IsDir() { | ||
| 98 | args = append(args, | ||
| 99 | "-o", "ControlMaster=auto", | ||
| 100 | "-o", "ControlPath="+filepath.Join(home, ".ssh", "gitbay-%C"), | ||
| 101 | "-o", "ControlPersist=300") | ||
| 102 | } | ||
| 103 | } | ||
| 104 | } | ||
| 105 | return append(args, inst.SSHOptions...) | ||
| 106 | } | ||
| 107 | |||
| 81 | // runSSH executes the server command over the system ssh binary, wiring | 108 | // runSSH executes the server command over the system ssh binary, wiring |
| 82 | // stdio through. It returns the remote exit code. | 109 | // stdio through. It returns the remote exit code. |
| 83 | func runSSH(t target, serverArgv []string, stdin io.Reader) int { | 110 | func runSSH(t target, serverArgv []string, stdin io.Reader) int { |
| 84 | args := []string{} | 111 | args := sshArgs(t.inst) |
| 85 | if t.inst.Port != 0 && t.inst.Port != 22 { | ||
| 86 | args = append(args, "-p", strconv.Itoa(t.inst.Port)) | ||
| 87 | } | ||
| 88 | args = append(args, t.inst.SSHOptions...) | ||
| 89 | quoted := make([]string, len(serverArgv)) | 112 | quoted := make([]string, len(serverArgv)) |
| 90 | for i, a := range serverArgv { | 113 | for i, a := range serverArgv { |
| 91 | quoted[i] = shellQuote(a) | 114 | quoted[i] = shellQuote(a) |
| @@ -114,11 +137,7 @@ func runSSH(t target, serverArgv []string, stdin io.Reader) int { | |||
| 114 | // sshCapture runs a server command and returns its stdout, discarding | 137 | // sshCapture runs a server command and returns its stdout, discarding |
| 115 | // stderr. Used for quiet metadata fetches like issue templates. | 138 | // stderr. Used for quiet metadata fetches like issue templates. |
| 116 | func sshCapture(t target, serverArgv []string) (string, int) { | 139 | func sshCapture(t target, serverArgv []string) (string, int) { |
| 117 | args := []string{} | 140 | args := sshArgs(t.inst) |
| 118 | if t.inst.Port != 0 && t.inst.Port != 22 { | ||
| 119 | args = append(args, "-p", strconv.Itoa(t.inst.Port)) | ||
| 120 | } | ||
| 121 | args = append(args, t.inst.SSHOptions...) | ||
| 122 | quoted := make([]string, len(serverArgv)) | 141 | quoted := make([]string, len(serverArgv)) |
| 123 | for i, a := range serverArgv { | 142 | for i, a := range serverArgv { |
| 124 | quoted[i] = shellQuote(a) | 143 | quoted[i] = shellQuote(a) |
cmd/gitbay/sshargs_test.go added +43
| @@ -0,0 +1,43 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | "path/filepath" | ||
| 6 | "slices" | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | |||
| 10 | "gitbay.org/gitbay/internal/cliconfig" | ||
| 11 | ) | ||
| 12 | |||
| 13 | // Every ssh the CLI spawns shares one connection per instance, so a | ||
| 14 | // command costs a round trip rather than a handshake (#94). The socket | ||
| 15 | // sits under ~/.ssh; with no such directory, or no_multiplex set, the | ||
| 16 | // arguments are the plain ones. | ||
| 17 | func TestSSHArgsMultiplex(t *testing.T) { | ||
| 18 | home := t.TempDir() | ||
| 19 | t.Setenv("HOME", home) | ||
| 20 | inst := cliconfig.Instance{Host: "forge.test", Port: 2222, SSHOptions: []string{"-i", "k"}} | ||
| 21 | |||
| 22 | if args := sshArgs(inst); slices.Contains(args, "ControlMaster=auto") { | ||
| 23 | t.Errorf("multiplexing without ~/.ssh: %v", args) | ||
| 24 | } | ||
| 25 | os.Mkdir(filepath.Join(home, ".ssh"), 0o700) | ||
| 26 | |||
| 27 | args := sshArgs(inst) | ||
| 28 | joined := strings.Join(args, " ") | ||
| 29 | for _, want := range []string{"-p 2222", "ControlMaster=auto", "ControlPersist=300", | ||
| 30 | "ControlPath=" + filepath.Join(home, ".ssh", "gitbay-%C")} { | ||
| 31 | if !strings.Contains(joined, want) { | ||
| 32 | t.Errorf("missing %q in %v", want, args) | ||
| 33 | } | ||
| 34 | } | ||
| 35 | if args[len(args)-2] != "-i" || args[len(args)-1] != "k" { | ||
| 36 | t.Errorf("profile options are not last: %v", args) | ||
| 37 | } | ||
| 38 | |||
| 39 | inst.NoMultiplex = true | ||
| 40 | if args := sshArgs(inst); slices.Contains(args, "ControlMaster=auto") { | ||
| 41 | t.Errorf("no_multiplex ignored: %v", args) | ||
| 42 | } | ||
| 43 | } | ||
internal/cliconfig/cliconfig.go +4
| @@ -21,6 +21,10 @@ type Instance struct { | |||
| 21 | // e.g. ["-i", "~/.ssh/forge_ed25519"]. Most setups need none: the | 21 | // e.g. ["-i", "~/.ssh/forge_ed25519"]. Most setups need none: the |
| 22 | // system ssh already honors ~/.ssh/config and the agent. | 22 | // system ssh already honors ~/.ssh/config and the agent. |
| 23 | SSHOptions []string `toml:"ssh_options,omitempty"` | 23 | SSHOptions []string `toml:"ssh_options,omitempty"` |
| 24 | // NoMultiplex turns off SSH connection sharing for this instance. On | ||
| 25 | // by default: one handshake per five minutes instead of one per | ||
| 26 | // command. | ||
| 27 | NoMultiplex bool `toml:"no_multiplex,omitempty"` | ||
| 24 | } | 28 | } |
| 25 | 29 | ||
| 26 | func (i Instance) SSHUser() string { | 30 | func (i Instance) SSHUser() string { |