Commit 0dd284d9dd

0dd284d9dd4f118cb63b634618d99963eeb660f8

parent: 01ced7ef9e

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-03 15:40 UTC

cli: share one SSH connection per instance

Every command spawned a fresh ssh and paid a full handshake, 3.8 s from
a laptop 170 ms away from the instance. sshArgs now passes
ControlMaster=auto with a socket under ~/.ssh and ControlPersist=300,
so the first command in five minutes pays the handshake and the rest
ride it: 0.36 s for the same command. The profile's ssh_options still
come last and win; no_multiplex = true on an instance opts out, and no
~/.ssh directory means no multiplexing.

Closes #94

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.
189func captureSSH(t target, serverArgv []string) (string, int) { 189func 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.
90func 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.
83func runSSH(t target, serverArgv []string, stdin io.Reader) int { 110func 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.
116func sshCapture(t target, serverArgv []string) (string, int) { 139func 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 @@
1package main
2
3import (
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.
17func 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
26func (i Instance) SSHUser() string { 30func (i Instance) SSHUser() string {