Commit c1ccabf7ee
Verified · cmc
cmd/gitbayd/system.go +2 −1
| @@ -8,6 +8,7 @@ import ( | ||
| 8 | 8 | "golang.org/x/crypto/ssh" |
| 9 | 9 | |
| 10 | 10 | "gitbay.org/gitbay/internal/config" |
| 11 | "gitbay.org/gitbay/internal/control" | |
| 11 | 12 | "gitbay.org/gitbay/internal/protocol" |
| 12 | 13 | "gitbay.org/gitbay/internal/sshd" |
| 13 | 14 | ) |
| @@ -93,7 +94,7 @@ func shellCmd() *cobra.Command { | ||
| 93 | 94 | fmt.Fprintf(os.Stderr, "gitbay control plane: interactive shells are not available.\nTry: ssh <host> help\n") |
| 94 | 95 | os.Exit(protocol.ExitUsage) |
| 95 | 96 | } |
| 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) | |
| 97 | 98 | st.Close() |
| 98 | 99 | os.Exit(code) |
| 99 | 100 | return nil |
e2e/ssh_test.go +24
| @@ -195,6 +195,30 @@ func (i *instance) ssh(t *testing.T, key string, stdin string, args ...string) ( | ||
| 195 | 195 | return out.String(), errOut.String(), code |
| 196 | 196 | } |
| 197 | 197 | |
| 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. | |
| 202 | func (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 | ||
| 198 | 222 | func TestControlPlaneOverBareSSH(t *testing.T) { |
| 199 | 223 | t.Parallel() |
| 200 | 224 | inst := startInstance(t) |
e2e/term_test.go added +87
| @@ -0,0 +1,87 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 13 | func 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. | |
| 38 | func 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 { | ||
| 29 | 29 | JSON bool |
| 30 | 30 | // Term is the client's terminal, from GITBAY_TERM. The zero value |
| 31 | 31 | // is plain output. |
| 32 | Term Term | |
| 32 | Term Term | |
| 33 | 33 | // ViaAPI marks requests arriving over HTTP, from the token API or |
| 34 | 34 | // the web. Every command runs there; nothing is held back for SSH |
| 35 | 35 | // any more (#234). The flag stays because the rate limiter and the |
| @@ -104,6 +104,21 @@ func Lookup(argv []string) (Command, []string, bool) { | ||
| 104 | 104 | // Dispatch runs argv for an authenticated session. The dispatcher — not the |
| 105 | 105 | // handlers — enforces key scope: control commands require a full-scope key. |
| 106 | 106 | func 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 | } | |
| 107 | 122 | if len(argv) == 0 { |
| 108 | 123 | return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help") |
| 109 | 124 | } |
| @@ -121,6 +136,10 @@ func Dispatch(c *Ctx, argv []string) int { | ||
| 121 | 136 | c.JSON = true |
| 122 | 137 | continue |
| 123 | 138 | } |
| 139 | if v, ok := strings.CutPrefix(a, "--term="); ok { | |
| 140 | c.Term = ParseTerm(v) | |
| 141 | continue | |
| 142 | } | |
| 124 | 143 | args = append(args, a) |
| 125 | 144 | } |
| 126 | 145 | // 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 { | ||
| 276 | 276 | ds = append(ds, out{r.Path(), r.Visibility, desc, r.Settings.Archived}) |
| 277 | 277 | } |
| 278 | 278 | return c.emitPage(p, ds, next, func(w io.Writer) { |
| 279 | tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION") | |
| 279 | 280 | for _, d := range ds { |
| 280 | mark := "" | |
| 281 | cells := []cell{cRef(d.Path), cState(d.Visibility), cFlex(d.Description)} | |
| 281 | 282 | if d.Archived { |
| 282 | mark = "\t[archived]" | |
| 283 | cells = append(cells, cText("[archived]")) | |
| 283 | 284 | } |
| 284 | fmt.Fprintf(w, "%s\t%s\t%s%s\n", d.Path, d.Visibility, d.Description, mark) | |
| 285 | tb.row(cells...) | |
| 285 | 286 | } |
| 287 | tb.flush() | |
| 286 | 288 | }) |
| 287 | 289 | } |
| 288 | 290 | |
internal/sshd/sshd.go +13 −5
| @@ -239,6 +239,7 @@ func (s *Server) handleConn(c *conn) { | ||
| 239 | 239 | |
| 240 | 240 | func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-chan *ssh.Request) { |
| 241 | 241 | defer ch.Close() |
| 242 | var term control.Term | |
| 242 | 243 | for req := range reqs { |
| 243 | 244 | switch req.Type { |
| 244 | 245 | case "exec": |
| @@ -267,7 +268,7 @@ func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-cha | ||
| 267 | 268 | } |
| 268 | 269 | close(done) |
| 269 | 270 | }() |
| 270 | code := s.runExec(sconn, ch, payload.Command, done) | |
| 271 | code := s.runExec(sconn, ch, term, payload.Command, done) | |
| 271 | 272 | sendExit(ch, code) |
| 272 | 273 | return |
| 273 | 274 | case "shell": |
| @@ -275,7 +276,13 @@ func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-cha | ||
| 275 | 276 | fmt.Fprintf(ch, "gitbay control plane: interactive shells are not available.\nTry: ssh %s help\n", s.cfg.Server.SiteURL) |
| 276 | 277 | sendExit(ch, protocol.ExitUsage) |
| 277 | 278 | 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": | |
| 279 | 286 | // Harmless; accept and ignore. |
| 280 | 287 | req.Reply(true, nil) |
| 281 | 288 | default: |
| @@ -289,7 +296,7 @@ func sendExit(ch ssh.Channel, code int) { | ||
| 289 | 296 | ch.SendRequest("exit-status", false, ssh.Marshal(&msg)) |
| 290 | 297 | } |
| 291 | 298 | |
| 292 | func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string, done <-chan struct{}) int { | |
| 299 | func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, term control.Term, cmdline string, done <-chan struct{}) int { | |
| 293 | 300 | ext := sconn.Permissions.Extensions |
| 294 | 301 | if blob := ext["anon-key"]; blob != "" { |
| 295 | 302 | return s.runAnonymous(ch, blob, cmdline) |
| @@ -302,7 +309,7 @@ func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string, | ||
| 302 | 309 | return protocol.ExitDenied |
| 303 | 310 | } |
| 304 | 311 | _ = 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) | |
| 306 | 313 | } |
| 307 | 314 | |
| 308 | 315 | // 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 { | ||
| 332 | 339 | // Exec runs one SSH exec command line for an authenticated key. It is the |
| 333 | 340 | // single dispatch path shared by the embedded listener and the system-sshd |
| 334 | 341 | // forced command (gitbayd shell). |
| 335 | func Exec(cfg config.Config, st *store.Store, user store.User, scope, source, cmdline string, | |
| 342 | func Exec(cfg config.Config, st *store.Store, user store.User, scope, source string, term control.Term, cmdline string, | |
| 336 | 343 | stdin io.Reader, stdout, stderr io.Writer, done, stopping <-chan struct{}) int { |
| 337 | 344 | if user.Disabled { |
| 338 | 345 | 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 | ||
| 365 | 372 | User: user, |
| 366 | 373 | Scope: scope, |
| 367 | 374 | Source: source, |
| 375 | Term: term, | |
| 368 | 376 | Store: st, |
| 369 | 377 | Cfg: cfg, |
| 370 | 378 | Stdin: stdin, |