internal/sshd/sshd.go

f8b976a97290a20d552056a999511f5d27d8e8ec
gitbay/internal/sshd/sshd.go history · blame · raw

675 lines · 20694 bytes

  1// Package sshd implements the embedded SSH listener: public-key auth against
  2// registered keys, then dispatch to git transport or control commands.
  3package sshd
  4
  5import (
  6	"context"
  7	"crypto/ed25519"
  8	"crypto/rand"
  9	"encoding/base64"
 10	"encoding/pem"
 11	"errors"
 12	"fmt"
 13	"io"
 14	"log/slog"
 15	"maps"
 16	"net"
 17	"os"
 18	"path/filepath"
 19	"slices"
 20	"strconv"
 21	"strings"
 22	"sync"
 23	"sync/atomic"
 24	"time"
 25
 26	"golang.org/x/crypto/ssh"
 27
 28	"gitbay.org/gitbay/internal/config"
 29	"gitbay.org/gitbay/internal/control"
 30	"gitbay.org/gitbay/internal/gitutil"
 31	"gitbay.org/gitbay/internal/hookd"
 32	"gitbay.org/gitbay/internal/packlimit"
 33	"gitbay.org/gitbay/internal/policy"
 34	"gitbay.org/gitbay/internal/protocol"
 35	"gitbay.org/gitbay/internal/store"
 36)
 37
 38type Server struct {
 39	cfg         config.Config
 40	st          *store.Store
 41	packs       *packlimit.Limiter
 42	sshCfg      *ssh.ServerConfig
 43	authLimiter *rateLimiter
 44	sessions    sync.WaitGroup // accepted connections still being served
 45	mu          sync.Mutex
 46	conns       map[*conn]struct{}
 47	stopping    chan struct{} // closed by Stop
 48	stopOnce    sync.Once
 49}
 50
 51// conn is one accepted connection and how many sessions it is running.
 52// A CLI's shared connection sits idle between commands; on shutdown an
 53// idle connection is closed at once and only a session mid-command is
 54// waited for (#141).
 55type conn struct {
 56	net    net.Conn
 57	active atomic.Int32
 58	// keyID and userID are the key that authenticated the connection and
 59	// its account: 0 before the handshake and for an unregistered key.
 60	// Guarded by Server.mu.
 61	keyID, userID int64
 62	revoked       chan struct{} // closed by cut
 63	cutOnce       sync.Once
 64}
 65
 66// cut ends the connection because its key was revoked: a git transport
 67// on it is killed, and every other command loses its channel.
 68func (c *conn) cut() {
 69	c.cutOnce.Do(func() { close(c.revoked) })
 70	c.net.Close()
 71}
 72
 73func New(cfg config.Config, st *store.Store, packs *packlimit.Limiter) (*Server, error) {
 74	s := &Server{cfg: cfg, st: st, packs: packs, authLimiter: newRateLimiter(cfg.Limits.SSHAuthRate, time.Minute), conns: map[*conn]struct{}{}, stopping: make(chan struct{})}
 75
 76	sc := &ssh.ServerConfig{
 77		PublicKeyCallback: s.authenticate,
 78		ServerVersion:     "SSH-2.0-gitbayd",
 79	}
 80	signers, err := loadHostKeys(cfg)
 81	if err != nil {
 82		return nil, err
 83	}
 84	for _, sg := range signers {
 85		sc.AddHostKey(sg)
 86	}
 87	s.sshCfg = sc
 88	st.OnRevoke(s.revoke)
 89	return s, nil
 90}
 91
 92// loadHostKeys loads the configured host keys, or generates an ed25519 key
 93// under server.root/ssh/ when none are configured.
 94func loadHostKeys(cfg config.Config) ([]ssh.Signer, error) {
 95	paths := cfg.SSH.HostKeys
 96	if len(paths) == 0 {
 97		p := filepath.Join(cfg.Server.Root, "ssh", "host_ed25519")
 98		if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) {
 99			if err := generateHostKey(p); err != nil {
100				return nil, fmt.Errorf("generating host key: %w", err)
101			}
102			slog.Info("generated ssh host key", "path", p)
103		}
104		paths = []string{p}
105	}
106	var signers []ssh.Signer
107	for _, p := range paths {
108		raw, err := os.ReadFile(p)
109		if err != nil {
110			return nil, fmt.Errorf("host key %s: %w", p, err)
111		}
112		sg, err := ssh.ParsePrivateKey(raw)
113		if err != nil {
114			return nil, fmt.Errorf("host key %s: %w", p, err)
115		}
116		signers = append(signers, sg)
117	}
118	return signers, nil
119}
120
121func generateHostKey(path string) error {
122	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
123		return err
124	}
125	_, priv, err := ed25519.GenerateKey(rand.Reader)
126	if err != nil {
127		return err
128	}
129	block, err := ssh.MarshalPrivateKey(priv, "")
130	if err != nil {
131		return err
132	}
133	return os.WriteFile(path, pem.EncodeToMemory(block), 0o600)
134}
135
136// authenticate resolves the presented key to a registered account. The SSH
137// username is ignored; identity comes from the key alone. When registration
138// is open or invite-based, unknown keys are admitted to run exactly one
139// command: register.
140func (s *Server) authenticate(meta ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Permissions, error) {
141	ip := remoteIP(meta.RemoteAddr())
142	if !s.authLimiter.allow(ip) {
143		// One audit entry per throttled window, not per rejected attempt.
144		if s.authLimiter.firstThrottle(ip) {
145			s.st.Audit(0, "auth.throttled", map[string]any{"ip": ip, "rate": s.cfg.Limits.SSHAuthRate})
146		}
147		return nil, fmt.Errorf("too many authentication attempts; try again shortly")
148	}
149	fp := ssh.FingerprintSHA256(pub)
150	key, err := s.st.SSHKeyByFingerprint(fp)
151	if err != nil && !errors.Is(err, store.ErrNotFound) {
152		// The store, not the key, failed. Neither a failure against the
153		// limiter nor "unknown key": a busy database during a restart
154		// would otherwise lock every client out for a minute.
155		slog.Error("ssh auth: key lookup", "err", err)
156		return nil, fmt.Errorf("authentication temporarily unavailable")
157	}
158	if err != nil {
159		if s.cfg.Registration.Mode != "closed" {
160			return &ssh.Permissions{Extensions: map[string]string{
161				"anon-key": base64.StdEncoding.EncodeToString(pub.Marshal()),
162			}}, nil
163		}
164		s.authLimiter.fail(ip)
165		s.st.Audit(0, "auth.failed", map[string]any{"ip": ip, "fingerprint": fp})
166		return nil, fmt.Errorf("unknown key %s", fp)
167	}
168	if key.Expired(time.Now()) {
169		s.authLimiter.fail(ip)
170		s.st.Audit(key.UserID, "auth.expired", map[string]any{"ip": ip, "fingerprint": fp})
171		return nil, fmt.Errorf("key %s has expired", fp)
172	}
173	s.authLimiter.success(ip)
174	return &ssh.Permissions{Extensions: map[string]string{
175		"user-id": strconv.FormatInt(key.UserID, 10),
176		"key-id":  strconv.FormatInt(key.ID, 10),
177	}}, nil
178}
179
180// Serve accepts connections on ln until it is closed.
181func (s *Server) Serve(ln net.Listener) error {
182	served := make(chan struct{})
183	defer close(served)
184	go s.sweep(served)
185	for {
186		nc, err := ln.Accept()
187		if err != nil {
188			return err
189		}
190		c := &conn{net: nc, revoked: make(chan struct{})}
191		s.mu.Lock()
192		s.conns[c] = struct{}{}
193		s.mu.Unlock()
194		s.sessions.Add(1)
195		go func() {
196			defer s.sessions.Done()
197			defer func() {
198				s.mu.Lock()
199				delete(s.conns, c)
200				s.mu.Unlock()
201			}()
202			s.handleConn(c)
203		}()
204	}
205}
206
207// revoke closes the connections opened by the keys r names.
208func (s *Server) revoke(r store.Revoked) {
209	var cut []*conn
210	s.mu.Lock()
211	for c := range s.conns {
212		if c.keyID == 0 {
213			continue
214		}
215		if (r.UserID != 0 && c.userID == r.UserID) || slices.Contains(r.KeyIDs, c.keyID) {
216			cut = append(cut, c)
217		}
218	}
219	s.mu.Unlock()
220	for _, c := range cut {
221		c.cut()
222	}
223}
224
225// sweepInterval bounds how long a revocation this process was not told
226// about (gitbayd admin on the host) leaves a connection open.
227const sweepInterval = 15 * time.Second
228
229func (s *Server) sweep(served <-chan struct{}) {
230	t := time.NewTicker(sweepInterval)
231	defer t.Stop()
232	for {
233		select {
234		case <-t.C:
235			s.sweepOnce()
236		case <-served:
237			return
238		case <-s.stopping:
239			return
240		}
241	}
242}
243
244// sweepOnce cuts every connection whose key is no longer live. Only
245// connections whose key was asked about are judged: one that
246// authenticated while the query ran waits for the next sweep.
247func (s *Server) sweepOnce() {
248	asked := map[int64]bool{}
249	s.mu.Lock()
250	for c := range s.conns {
251		if c.keyID != 0 {
252			asked[c.keyID] = true
253		}
254	}
255	s.mu.Unlock()
256	if len(asked) == 0 {
257		return
258	}
259	live, err := s.st.LiveSSHKeys(slices.Collect(maps.Keys(asked)))
260	if err != nil {
261		slog.Error("ssh sweep: key lookup", "err", err)
262		return
263	}
264	var cut []*conn
265	s.mu.Lock()
266	for c := range s.conns {
267		if asked[c.keyID] && !live[c.keyID] {
268			cut = append(cut, c)
269		}
270	}
271	s.mu.Unlock()
272	for _, c := range cut {
273		c.cut()
274	}
275}
276
277// Stop ends the commands that run until something happens (build log
278// --follow), so a shutdown drain waits only for work that finishes. It
279// does not close connections; Shutdown does.
280func (s *Server) Stop() {
281	s.stopOnce.Do(func() { close(s.stopping) })
282}
283
284// Shutdown closes every idle connection, then waits for the ones with a
285// session running, or for ctx. The caller closes the listener first; a
286// push in flight completes rather than being cut mid-pack.
287func (s *Server) Shutdown(ctx context.Context) error {
288	s.Stop()
289	s.mu.Lock()
290	for c := range s.conns {
291		if c.active.Load() == 0 {
292			c.net.Close()
293		}
294	}
295	s.mu.Unlock()
296	done := make(chan struct{})
297	go func() {
298		s.sessions.Wait()
299		close(done)
300	}()
301	select {
302	case <-done:
303		return nil
304	case <-ctx.Done():
305		return ctx.Err()
306	}
307}
308
309func (s *Server) handleConn(c *conn) {
310	defer c.net.Close()
311	sconn, chans, reqs, err := ssh.NewServerConn(c.net, s.sshCfg)
312	if err != nil {
313		return
314	}
315	defer sconn.Close()
316	ext := sconn.Permissions.Extensions
317	s.mu.Lock()
318	c.keyID, _ = strconv.ParseInt(ext["key-id"], 10, 64)
319	c.userID, _ = strconv.ParseInt(ext["user-id"], 10, 64)
320	s.mu.Unlock()
321	go ssh.DiscardRequests(reqs)
322
323	for newCh := range chans {
324		if newCh.ChannelType() != "session" {
325			newCh.Reject(ssh.UnknownChannelType, "only session channels are supported")
326			continue
327		}
328		ch, chReqs, err := newCh.Accept()
329		if err != nil {
330			continue
331		}
332		c.active.Add(1)
333		go func() {
334			defer c.active.Add(-1)
335			s.handleSession(c, sconn, ch, chReqs)
336		}()
337	}
338}
339
340func (s *Server) handleSession(c *conn, sconn *ssh.ServerConn, ch ssh.Channel, reqs <-chan *ssh.Request) {
341	defer ch.Close()
342	var term control.Term
343	for req := range reqs {
344		switch req.Type {
345		case "exec":
346			var payload struct{ Command string }
347			if err := ssh.Unmarshal(req.Payload, &payload); err != nil {
348				req.Reply(false, nil)
349				continue
350			}
351			req.Reply(true, nil)
352			// x/crypto closes reqs when the client closes the channel. That
353			// is how a follow learns nobody is reading: the CLI's shared
354			// connection outlives a Ctrl-C, the channel does not. Stop
355			// ends it too, for a restart.
356			closed := make(chan struct{})
357			go func() {
358				for r := range reqs {
359					r.Reply(false, nil)
360				}
361				close(closed)
362			}()
363			done := make(chan struct{})
364			go func() {
365				select {
366				case <-closed:
367				case <-s.stopping:
368				}
369				close(done)
370			}()
371			code := s.runExec(c, sconn, ch, term, payload.Command, done)
372			sendExit(ch, code)
373			return
374		case "shell":
375			req.Reply(true, nil)
376			fmt.Fprintf(ch, "gitbay control plane: interactive shells are not available.\nTry: ssh %s help\n", s.cfg.Server.SiteURL)
377			sendExit(ch, protocol.ExitUsage)
378			return
379		case "env":
380			var kv struct{ Name, Value string }
381			if ssh.Unmarshal(req.Payload, &kv) == nil && kv.Name == "GITBAY_TERM" {
382				term = control.ParseTerm(kv.Value)
383			}
384			req.Reply(true, nil)
385		case "pty-req":
386			// Harmless; accept and ignore.
387			req.Reply(true, nil)
388		default:
389			req.Reply(false, nil)
390		}
391	}
392}
393
394func sendExit(ch ssh.Channel, code int) {
395	var msg = struct{ Status uint32 }{uint32(code)}
396	ch.SendRequest("exit-status", false, ssh.Marshal(&msg))
397}
398
399func (s *Server) runExec(c *conn, sconn *ssh.ServerConn, ch ssh.Channel, term control.Term, cmdline string, done <-chan struct{}) int {
400	ext := sconn.Permissions.Extensions
401	if blob := ext["anon-key"]; blob != "" {
402		return s.runAnonymous(ch, blob, cmdline)
403	}
404	userID, _ := strconv.ParseInt(ext["user-id"], 10, 64)
405	keyID, _ := strconv.ParseInt(ext["key-id"], 10, 64)
406	// A connection outlives its commands, so the key is read again for
407	// each one: what it may do is what it may do now (#256).
408	key, err := s.st.SSHKeyByID(keyID)
409	if errors.Is(err, store.ErrNotFound) || (err == nil && key.UserID != userID) {
410		fmt.Fprintln(ch.Stderr(), "this key is no longer registered")
411		return protocol.ExitDenied
412	}
413	if err != nil {
414		slog.Error("ssh exec: key lookup", "err", err)
415		fmt.Fprintln(ch.Stderr(), "authentication temporarily unavailable")
416		return protocol.ExitFailure
417	}
418	if key.Expired(time.Now()) {
419		fmt.Fprintln(ch.Stderr(), "this key has expired; remove it and add a new one")
420		return protocol.ExitDenied
421	}
422	user, err := s.st.UserByID(userID)
423	if err != nil {
424		fmt.Fprintln(ch.Stderr(), "account no longer exists")
425		return protocol.ExitDenied
426	}
427	_ = s.st.TouchSSHKey(keyID)
428	return Exec(s.cfg, s.st, s.packs, user, key, term, cmdline, ch, ch, ch.Stderr(), done, s.stopping, c.revoked)
429}
430
431// runAnonymous handles a session from an unregistered key: the register
432// command and nothing else.
433func (s *Server) runAnonymous(ch ssh.Channel, keyB64, cmdline string) int {
434	raw, err := base64.StdEncoding.DecodeString(keyB64)
435	if err != nil {
436		return protocol.ExitFailure
437	}
438	pub, err := ssh.ParsePublicKey(raw)
439	if err != nil {
440		return protocol.ExitFailure
441	}
442	argv, err := protocol.Tokenize(cmdline)
443	if err != nil {
444		fmt.Fprintf(ch.Stderr(), "cannot parse command: %v\n", err)
445		return protocol.ExitUsage
446	}
447	if len(argv) == 0 || argv[0] != "register" {
448		host := s.cfg.SiteHost()
449		fp := ssh.FingerprintSHA256(pub)
450		flag := map[string]string{"open": "--email <address>", "invite": "--invite <code>"}[s.cfg.Registration.Mode]
451		fmt.Fprintf(ch.Stderr(),
452			"this key (%s) is not registered on %s.\n"+
453				"already have an account? add it at %s/settings#keys\n"+
454				"new here? ssh git@%s register --username <name> %s\n",
455			fp, host, strings.TrimSuffix(s.cfg.Server.SiteURL, "/"), host, flag)
456		return protocol.ExitDenied
457	}
458	return control.RunRegister(s.cfg, s.st, pub, argv, ch, ch.Stderr())
459}
460
461// Exec runs one SSH exec command line for an authenticated key. It is the
462// single dispatch path shared by the embedded listener and the system-sshd
463// forced command (gitbayd shell). Closing revoked kills a git transport.
464func Exec(cfg config.Config, st *store.Store, packs *packlimit.Limiter, user store.User, key store.SSHKey, term control.Term, cmdline string,
465	stdin io.Reader, stdout, stderr io.Writer, done, stopping, revoked <-chan struct{}) int {
466	if user.Disabled {
467		fmt.Fprintln(stderr, "this account is disabled; contact the instance admin")
468		return protocol.ExitDenied
469	}
470	argv, err := protocol.Tokenize(cmdline)
471	if err != nil {
472		fmt.Fprintf(stderr, "cannot parse command: %v\n", err)
473		return protocol.ExitUsage
474	}
475	if len(argv) > 0 {
476		switch argv[0] {
477		case "git-upload-pack", "git-receive-pack", "git-upload-archive":
478			code := protocol.ExitDenied
479			if user.Pending {
480				fmt.Fprintln(stderr, "your account is not active yet: verify your email first")
481			} else {
482				code = runGit(cfg, st, packs, user, key.Scope, argv, stdin, stdout, stderr, done, stopping, revoked)
483			}
484			// A refused push is a refused write, audited like one. runGit
485			// refuses only with the path as the one argument, so argv[1:]
486			// holds no value beyond the target.
487			if argv[0] == "git-receive-pack" && (code == protocol.ExitDenied || code == protocol.ExitNotFound) {
488				control.AuditRefused(st, user.ID, "refused git-receive-pack",
489					map[string]any{"argv": argv[1:], "source": key.Fingerprint, "exit": code})
490			}
491			return code
492		case "git-lfs-authenticate":
493			// Part of the git transport, not the control plane: usable by
494			// git-scoped and deploy keys, with the transports' access rules.
495			if user.Pending {
496				fmt.Fprintln(stderr, "your account is not active yet: verify your email first")
497				return protocol.ExitDenied
498			}
499			return runLFSAuthenticate(cfg, st, user, key.Scope, argv, stdout, stderr)
500		}
501	}
502	ctx := &control.Ctx{
503		User:     user,
504		Scope:    key.Scope,
505		Source:   key.Fingerprint,
506		Term:     term,
507		Store:    st,
508		Cfg:      cfg,
509		Stdin:    stdin,
510		Stdout:   stdout,
511		Stderr:   stderr,
512		Done:     done,
513		Stopping: stopping,
514		Expires:  key.ExpiresAt,
515	}
516	return control.Dispatch(ctx, argv)
517}
518
519// runGit streams a git transport service after access checks.
520func runGit(cfg config.Config, st *store.Store, packs *packlimit.Limiter, user store.User, scope string, argv []string,
521	stdin io.Reader, stdout, stderr io.Writer, done, stopping, revoked <-chan struct{}) int {
522	service := argv[0]
523	if len(argv) != 2 {
524		fmt.Fprintf(stderr, "usage: %s <path>\n", service)
525		return protocol.ExitUsage
526	}
527	write := service == "git-receive-pack"
528
529	repo, err := st.RepoByPath(argv[1])
530	if err != nil {
531		fmt.Fprintln(stderr, "repository not found")
532		return protocol.ExitNotFound
533	}
534	if policy.IsDeployScope(scope) {
535		// A deploy key authorizes by its binding alone: one repository,
536		// its mode, nothing inherited from whoever registered it. Any
537		// mismatch reads as nonexistence, same as the access rules.
538		if !policy.DeployScopeAllows(scope, repo.ID, write) {
539			fmt.Fprintln(stderr, "repository not found")
540			return protocol.ExitNotFound
541		}
542	} else {
543		grant, err := st.AccessRole(repo.ID, user.ID)
544		if err != nil {
545			fmt.Fprintln(stderr, "internal error")
546			return protocol.ExitFailure
547		}
548		if !policy.CanRead(user, repo, grant) {
549			// Same answer as nonexistence: private repos must not be enumerable.
550			fmt.Fprintln(stderr, "repository not found")
551			return protocol.ExitNotFound
552		}
553		if !policy.ScopeAllowsGit(scope, repo.Path(), write) {
554			fmt.Fprintf(stderr, "this key's scope (%s) does not allow %s on %s\n", scope, service, repo.Path())
555			return protocol.ExitDenied
556		}
557		if write && !policy.CanWrite(user, repo, grant) {
558			fmt.Fprintf(stderr, "write access to %s denied\n", repo.Path())
559			return protocol.ExitDenied
560		}
561	}
562	if write && repo.Settings.Archived {
563		fmt.Fprintf(stderr, "%s is archived and read-only\n", repo.Path())
564		return protocol.ExitDenied
565	}
566	if write {
567		if mirrored, err := st.PullMirrored(repo.ID); err == nil && mirrored {
568			fmt.Fprintf(stderr, "%s is a pull mirror: its refs come from the upstream; push there instead\n", repo.Path())
569			return protocol.ExitDenied
570		}
571	}
572
573	dir := control.RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name)
574	env := []string{
575		hookd.EnvSocket + "=" + hookd.SocketPath(cfg.Server.Root),
576		hookd.EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10),
577		hookd.EnvUserID + "=" + strconv.FormatInt(user.ID, 10),
578		hookd.EnvScope + "=" + scope,
579	}
580	// A storage quota on the owner rides the same mechanism as the pack
581	// cap: the pack may be no larger than what the owner has left.
582	maxPack := cfg.Limits.MaxPackBytes
583	if write && repo.OwnerKind == "user" {
584		if limit := control.ByteLimit(st, control.QuotaConfig(cfg), repo.OwnerID); limit > 0 {
585			used := control.OwnedBytes(st, cfg.Server.Root, repo.OwnerID)
586			left := limit - used
587			if left <= 0 {
588				fmt.Fprintf(stderr, "%s's storage quota is used up (%d of %d bytes); delete something, or ask an admin to raise the limit\n", repo.OwnerName, used, limit)
589				return protocol.ExitDenied
590			}
591			if maxPack == 0 || left < maxPack {
592				maxPack = left
593			}
594		}
595	}
596	if write {
597		// hookd answers only a hook that names this receive-pack.
598		token, err := st.CreatePushToken(repo.ID, user.ID, scope)
599		if err != nil {
600			fmt.Fprintln(stderr, "internal error")
601			return protocol.ExitFailure
602		}
603		defer st.DeletePushToken(token)
604		env = append(env, hookd.EnvToken+"="+token)
605	}
606	cancel := revoked
607	if !write {
608		// Pack generation shares one budget with smart HTTP and git://.
609		// receive-pack stays outside it: its post-receive runs after the
610		// client has its report, and must not be queued or killed.
611		principal := "user:" + strconv.FormatInt(user.ID, 10)
612		release, err := packs.Acquire(done, principal)
613		if err != nil {
614			packs.Refused("ssh", principal, err)
615		}
616		if errors.Is(err, packlimit.ErrBusy) {
617			fmt.Fprintln(stderr, "the server is busy: it is at its limit of concurrent clones and fetches; try again in a minute")
618			return protocol.ExitFailure
619		}
620		if err != nil {
621			// ErrGone: the client left, or the server is restarting.
622			fmt.Fprintln(stderr, "the server is restarting; try again in a minute")
623			return protocol.ExitFailure
624		}
625		// Deferred before Transport runs, so it fires after git has
626		// exited and been waited for.
627		defer release()
628		// A client that stops reading would hold its slot for as long
629		// as its channel stays open.
630		client := stdout
631		var stalled <-chan struct{}
632		var unwatch func()
633		stdout, stalled, unwatch = packs.Watch(client)
634		defer unwatch()
635		kill := make(chan struct{})
636		finished := make(chan struct{})
637		defer close(finished)
638		go func() {
639			left := done
640			for {
641				select {
642				case <-finished:
643					return
644				case <-revoked:
645				case <-left:
646					select {
647					case <-stopping:
648						// done closes on a restart too; a clone already
649						// running finishes then. Only a departed client
650						// ends it.
651						left = nil
652						continue
653					default:
654					}
655				case <-stalled:
656					close(kill)
657					// A write blocked on the client's window outlives
658					// git; closing the channel ends it and the stdin copy,
659					// so Transport's Wait returns.
660					if c, ok := client.(io.Closer); ok {
661						c.Close()
662					}
663					return
664				}
665				close(kill)
666				return
667			}
668		}()
669		cancel = kill
670	}
671	if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, maxPack, cancel); err != nil {
672		return protocol.ExitFailure
673	}
674	return protocol.ExitOK
675}