internal/sshd/sshd.go
671 lines · 20605 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 release, err := packs.Acquire(done, "user:"+strconv.FormatInt(user.ID, 10))
612 if errors.Is(err, packlimit.ErrBusy) {
613 fmt.Fprintln(stderr, "the server is busy: it is at its limit of concurrent clones and fetches; try again in a minute")
614 return protocol.ExitFailure
615 }
616 if err != nil {
617 // ErrGone: the client left, or the server is restarting.
618 fmt.Fprintln(stderr, "the server is restarting; try again in a minute")
619 return protocol.ExitFailure
620 }
621 // Deferred before Transport runs, so it fires after git has
622 // exited and been waited for.
623 defer release()
624 // A client that stops reading would hold its slot for as long
625 // as its channel stays open.
626 client := stdout
627 var stalled <-chan struct{}
628 var unwatch func()
629 stdout, stalled, unwatch = packs.Watch(client)
630 defer unwatch()
631 kill := make(chan struct{})
632 finished := make(chan struct{})
633 defer close(finished)
634 go func() {
635 left := done
636 for {
637 select {
638 case <-finished:
639 return
640 case <-revoked:
641 case <-left:
642 select {
643 case <-stopping:
644 // done closes on a restart too; a clone already
645 // running finishes then. Only a departed client
646 // ends it.
647 left = nil
648 continue
649 default:
650 }
651 case <-stalled:
652 close(kill)
653 // A write blocked on the client's window outlives
654 // git; closing the channel ends it and the stdin copy,
655 // so Transport's Wait returns.
656 if c, ok := client.(io.Closer); ok {
657 c.Close()
658 }
659 return
660 }
661 close(kill)
662 return
663 }
664 }()
665 cancel = kill
666 }
667 if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, maxPack, cancel); err != nil {
668 return protocol.ExitFailure
669 }
670 return protocol.ExitOK
671}