Commit c1ccabf7ee

c1ccabf7eed8e7d5ab45b680ed39b7c491eda520

parent: 81fbd1e949

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-24 01:09 UTC

sshd: GITBAY_TERM selects terminal output per session

Ref #254
cmd/gitbayd/system.go +2 −1
@@ -8,6 +8,7 @@ import (
88 "golang.org/x/crypto/ssh"
99
1010 "gitbay.org/gitbay/internal/config"
11 "gitbay.org/gitbay/internal/control"
1112 "gitbay.org/gitbay/internal/protocol"
1213 "gitbay.org/gitbay/internal/sshd"
1314)
@@ -93,7 +94,7 @@ func shellCmd() *cobra.Command {
9394 fmt.Fprintf(os.Stderr, "gitbay control plane: interactive shells are not available.\nTry: ssh <host> help\n")
9495 os.Exit(protocol.ExitUsage)
9596 }
96 code := sshd.Exec(cfg, st, user, key.Scope, key.Fingerprint, cmdline, os.Stdin, os.Stdout, os.Stderr, nil, nil)
97 code := sshd.Exec(cfg, st, user, key.Scope, key.Fingerprint, control.ParseTerm(os.Getenv("GITBAY_TERM")), cmdline, os.Stdin, os.Stdout, os.Stderr, nil, nil)
9798 st.Close()
9899 os.Exit(code)
99100 return nil
e2e/ssh_test.go +24
@@ -195,6 +195,30 @@ func (i *instance) ssh(t *testing.T, key string, stdin string, args ...string) (
195195 return out.String(), errOut.String(), code
196196}
197197
198// sshTerm is ssh with a leading --term=<v> on the command line, as the
199// CLI sends it at a terminal: OpenSSH's ControlMaster does not forward a
200// new session's SetEnv, so the term travels in argv instead. An empty
201// term sends nothing.
202func (i *instance) sshTerm(t *testing.T, key, term string, args ...string) (string, string, int) {
203 t.Helper()
204 if term != "" {
205 // "--" stops the local ssh client from parsing --term=... as one of
206 // its own options; it is not part of the remote command line.
207 args = append([]string{"--", "--term=" + term}, args...)
208 }
209 cmd := i.sshCmd(key, args...)
210 var out, errOut strings.Builder
211 cmd.Stdout, cmd.Stderr = &out, &errOut
212 err := cmd.Run()
213 code := 0
214 if ee, ok := err.(*exec.ExitError); ok {
215 code = ee.ExitCode()
216 } else if err != nil {
217 t.Fatalf("ssh: %v", err)
218 }
219 return out.String(), errOut.String(), code
220}
221
198222func TestControlPlaneOverBareSSH(t *testing.T) {
199223 t.Parallel()
200224 inst := startInstance(t)
e2e/term_test.go added +87
@@ -0,0 +1,87 @@
1package e2e
2
3import (
4 "os"
5 "os/exec"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11// GITBAY_TERM selects terminal output per session. Stock ssh without it
12// gets the plain rows scripts read.
13func TestTermEnvSelectsTerminalOutput(t *testing.T) {
14 t.Parallel()
15 inst := startInstance(t)
16 key := inst.newKey(t, "alice")
17 inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub",
18 "--email", "alice@example.test", "--verified")
19 if _, errOut, code := inst.ssh(t, key, "", "repo", "create", "alice/app"); code != 0 {
20 t.Fatalf("repo create: %d %s", code, errOut)
21 }
22
23 plain, _, _ := inst.sshTerm(t, key, "", "repo", "list")
24 if strings.Contains(plain, "PATH") || !strings.Contains(plain, "alice/app\t") {
25 t.Errorf("plain repo list: %q", plain)
26 }
27 term, _, _ := inst.sshTerm(t, key, "80,color", "repo", "list")
28 if !strings.HasPrefix(term, "\x1b[2mPATH") {
29 t.Errorf("terminal repo list: %q", term)
30 }
31}
32
33// The CLI shares one connection per instance. Each session's terminal
34// selection must reach the server on its own, not the one the master
35// session was opened with — which is why it travels as a leading
36// --term=<v> argument rather than SetEnv: OpenSSH's mux client does not
37// forward a new session's SetEnv onto an existing ControlMaster.
38func TestTermEnvOverMultiplexedSession(t *testing.T) {
39 t.Parallel()
40 inst := startInstance(t)
41 key := inst.newKey(t, "alice")
42 inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub",
43 "--email", "alice@example.test", "--verified")
44 inst.ssh(t, key, "", "repo", "create", "alice/app")
45
46 dir, err := os.MkdirTemp("", "gbmux")
47 if err != nil {
48 t.Fatal(err)
49 }
50 t.Cleanup(func() { os.RemoveAll(dir) })
51 sock := filepath.Join(dir, "cm")
52 mux := func(term string) string {
53 t.Helper()
54 cmdArgs := []string{"repo", "list"}
55 if term != "" {
56 // "--" stops the local ssh client from parsing --term=... as one
57 // of its own options; it is not part of the remote command line.
58 cmdArgs = append([]string{"--", "--term=" + term}, cmdArgs...)
59 }
60 cmd := inst.sshCmd(key, cmdArgs...)
61 opts := []string{"-o", "ControlMaster=auto", "-o", "ControlPath=" + sock, "-o", "ControlPersist=30"}
62 for j, a := range cmd.Args {
63 if a == "git@127.0.0.1" {
64 cmd.Args = append(cmd.Args[:j:j], append(opts, cmd.Args[j:]...)...)
65 break
66 }
67 }
68 out, err := cmd.Output()
69 if err != nil {
70 t.Fatalf("ssh %s: %v", term, err)
71 }
72 return string(out)
73 }
74 t.Cleanup(func() {
75 exec.Command("ssh", "-o", "ControlPath="+sock, "-O", "exit", "git@127.0.0.1").Run()
76 })
77
78 if out := mux("80"); !strings.HasPrefix(out, "PATH") {
79 t.Fatalf("master session: %q", out)
80 }
81 if out := mux("80,color"); !strings.HasPrefix(out, "\x1b[2mPATH") {
82 t.Errorf("second session kept the master's GITBAY_TERM: %q", out)
83 }
84 if out := mux(""); strings.Contains(out, "PATH") {
85 t.Errorf("session without GITBAY_TERM got terminal output: %q", out)
86 }
87}
internal/control/control.go +20 −1
@@ -29,7 +29,7 @@ type Ctx struct {
2929 JSON bool
3030 // Term is the client's terminal, from GITBAY_TERM. The zero value
3131 // is plain output.
32 Term Term
32 Term Term
3333 // ViaAPI marks requests arriving over HTTP, from the token API or
3434 // the web. Every command runs there; nothing is held back for SSH
3535 // any more (#234). The flag stays because the rate limiter and the
@@ -104,6 +104,21 @@ func Lookup(argv []string) (Command, []string, bool) {
104104// Dispatch runs argv for an authenticated session. The dispatcher — not the
105105// handlers — enforces key scope: control commands require a full-scope key.
106106func Dispatch(c *Ctx, argv []string) int {
107 if len(argv) == 0 {
108 return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help")
109 }
110 // A leading --term=<v> selects terminal output for this session, the
111 // same as GITBAY_TERM. It must come off before Lookup: Lookup matches
112 // argv against a command's Path, and a --term= in front would never
113 // match one.
114 for len(argv) > 0 {
115 v, ok := strings.CutPrefix(argv[0], "--term=")
116 if !ok {
117 break
118 }
119 c.Term = ParseTerm(v)
120 argv = argv[1:]
121 }
107122 if len(argv) == 0 {
108123 return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help")
109124 }
@@ -121,6 +136,10 @@ func Dispatch(c *Ctx, argv []string) int {
121136 c.JSON = true
122137 continue
123138 }
139 if v, ok := strings.CutPrefix(a, "--term="); ok {
140 c.Term = ParseTerm(v)
141 continue
142 }
124143 args = append(args, a)
125144 }
126145 // A runner-scoped key reaches the runner protocol and nothing else, so
internal/control/repo.go +5 −3
@@ -276,13 +276,15 @@ func runRepoList(c *Ctx, args []string) int {
276276 ds = append(ds, out{r.Path(), r.Visibility, desc, r.Settings.Archived})
277277 }
278278 return c.emitPage(p, ds, next, func(w io.Writer) {
279 tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION")
279280 for _, d := range ds {
280 mark := ""
281 cells := []cell{cRef(d.Path), cState(d.Visibility), cFlex(d.Description)}
281282 if d.Archived {
282 mark = "\t[archived]"
283 cells = append(cells, cText("[archived]"))
283284 }
284 fmt.Fprintf(w, "%s\t%s\t%s%s\n", d.Path, d.Visibility, d.Description, mark)
285 tb.row(cells...)
285286 }
287 tb.flush()
286288 })
287289}
288290
internal/sshd/sshd.go +13 −5
@@ -239,6 +239,7 @@ func (s *Server) handleConn(c *conn) {
239239
240240func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-chan *ssh.Request) {
241241 defer ch.Close()
242 var term control.Term
242243 for req := range reqs {
243244 switch req.Type {
244245 case "exec":
@@ -267,7 +268,7 @@ func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-cha
267268 }
268269 close(done)
269270 }()
270 code := s.runExec(sconn, ch, payload.Command, done)
271 code := s.runExec(sconn, ch, term, payload.Command, done)
271272 sendExit(ch, code)
272273 return
273274 case "shell":
@@ -275,7 +276,13 @@ func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-cha
275276 fmt.Fprintf(ch, "gitbay control plane: interactive shells are not available.\nTry: ssh %s help\n", s.cfg.Server.SiteURL)
276277 sendExit(ch, protocol.ExitUsage)
277278 return
278 case "pty-req", "env":
279 case "env":
280 var kv struct{ Name, Value string }
281 if ssh.Unmarshal(req.Payload, &kv) == nil && kv.Name == "GITBAY_TERM" {
282 term = control.ParseTerm(kv.Value)
283 }
284 req.Reply(true, nil)
285 case "pty-req":
279286 // Harmless; accept and ignore.
280287 req.Reply(true, nil)
281288 default:
@@ -289,7 +296,7 @@ func sendExit(ch ssh.Channel, code int) {
289296 ch.SendRequest("exit-status", false, ssh.Marshal(&msg))
290297}
291298
292func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string, done <-chan struct{}) int {
299func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, term control.Term, cmdline string, done <-chan struct{}) int {
293300 ext := sconn.Permissions.Extensions
294301 if blob := ext["anon-key"]; blob != "" {
295302 return s.runAnonymous(ch, blob, cmdline)
@@ -302,7 +309,7 @@ func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string,
302309 return protocol.ExitDenied
303310 }
304311 _ = s.st.TouchSSHKey(keyID)
305 return Exec(s.cfg, s.st, user, ext["scope"], ext["key-fp"], cmdline, ch, ch, ch.Stderr(), done, s.stopping)
312 return Exec(s.cfg, s.st, user, ext["scope"], ext["key-fp"], term, cmdline, ch, ch, ch.Stderr(), done, s.stopping)
306313}
307314
308315// runAnonymous handles a session from an unregistered key: the register
@@ -332,7 +339,7 @@ func (s *Server) runAnonymous(ch ssh.Channel, keyB64, cmdline string) int {
332339// Exec runs one SSH exec command line for an authenticated key. It is the
333340// single dispatch path shared by the embedded listener and the system-sshd
334341// forced command (gitbayd shell).
335func Exec(cfg config.Config, st *store.Store, user store.User, scope, source, cmdline string,
342func Exec(cfg config.Config, st *store.Store, user store.User, scope, source string, term control.Term, cmdline string,
336343 stdin io.Reader, stdout, stderr io.Writer, done, stopping <-chan struct{}) int {
337344 if user.Disabled {
338345 fmt.Fprintln(stderr, "this account is disabled; contact the instance admin")
@@ -365,6 +372,7 @@ func Exec(cfg config.Config, st *store.Store, user store.User, scope, source, cm
365372 User: user,
366373 Scope: scope,
367374 Source: source,
375 Term: term,
368376 Store: st,
369377 Cfg: cfg,
370378 Stdin: stdin,