internal/gitd/gitd.go
131 lines · 3437 bytes
1// Package gitd implements the anonymous git:// protocol listener. Read-only
2// upload-pack, and only for repositories that are public AND have opted in
3// via settings — on an instance where [git_daemon] is enabled at all.
4package gitd
5
6import (
7 "fmt"
8 "io"
9 "net"
10 "strconv"
11 "strings"
12 "time"
13
14 "gitbay.org/gitbay/internal/config"
15 "gitbay.org/gitbay/internal/control"
16 "gitbay.org/gitbay/internal/gitutil"
17 "gitbay.org/gitbay/internal/packlimit"
18 "gitbay.org/gitbay/internal/store"
19)
20
21type Server struct {
22 cfg config.Config
23 st *store.Store
24 packs *packlimit.Limiter
25}
26
27func New(cfg config.Config, st *store.Store, packs *packlimit.Limiter) *Server {
28 return &Server{cfg: cfg, st: st, packs: packs}
29}
30
31func (s *Server) Serve(ln net.Listener) error {
32 for {
33 conn, err := ln.Accept()
34 if err != nil {
35 return err
36 }
37 go s.handle(conn)
38 }
39}
40
41func (s *Server) handle(conn net.Conn) {
42 defer conn.Close()
43 conn.SetReadDeadline(time.Now().Add(30 * time.Second))
44
45 req, err := readPktLine(conn)
46 if err != nil {
47 return
48 }
49 conn.SetReadDeadline(time.Time{})
50
51 // Request form: "git-upload-pack /owner/name.git\0host=...\0[\0extra\0]"
52 service, rest, ok := strings.Cut(req, " ")
53 if !ok || service != "git-upload-pack" {
54 writeErr(conn, "only git-upload-pack is available over git://")
55 return
56 }
57 parts := strings.Split(rest, "\x00")
58 path := parts[0]
59 var protoEnv []string
60 for _, p := range parts[1:] {
61 if v, ok := strings.CutPrefix(p, "version="); ok {
62 protoEnv = []string{"GIT_PROTOCOL=version=" + v}
63 }
64 }
65
66 repo, err := s.st.RepoByPath(path)
67 if err != nil || repo.Visibility != "public" || !repo.Settings.GitDaemon {
68 // One answer for missing, private, and not-opted-in.
69 writeErr(conn, "repository not exported")
70 return
71 }
72
73 // A nil done: a queued client that leaves, or a restart, does not
74 // end the wait; only the limiter's wait does.
75 release, err := s.packs.Acquire(nil, principal(conn.RemoteAddr()))
76 if err != nil {
77 writeErr(conn, err.Error())
78 return
79 }
80 // Deferred before git runs, so it fires after git has exited and
81 // been waited for.
82 defer release()
83 out, stalled, unwatch := s.packs.Watch(conn)
84 defer unwatch()
85 kill := make(chan struct{})
86 finished := make(chan struct{})
87 defer close(finished)
88 go func() {
89 select {
90 case <-finished:
91 case <-stalled:
92 close(kill)
93 // A write blocked on a client that stopped reading outlives
94 // git; closing the connection ends it and the stdin copy.
95 conn.Close()
96 }
97 }()
98
99 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
100 gitutil.Transport("git-upload-pack", dir, conn, out, io.Discard, protoEnv, 0, kill)
101}
102
103// principal is the pack-limit principal for a client at addr.
104func principal(addr net.Addr) string {
105 host, _, _ := net.SplitHostPort(addr.String())
106 return packlimit.AddrPrincipal(host)
107}
108
109func readPktLine(r io.Reader) (string, error) {
110 var lenHex [4]byte
111 if _, err := io.ReadFull(r, lenHex[:]); err != nil {
112 return "", err
113 }
114 n, err := strconv.ParseUint(string(lenHex[:]), 16, 16)
115 if err != nil || n < 4 || n > 65520 {
116 return "", fmt.Errorf("bad pkt length %q", lenHex)
117 }
118 if n == 4 {
119 return "", nil // flush-pkt
120 }
121 buf := make([]byte, n-4)
122 if _, err := io.ReadFull(r, buf); err != nil {
123 return "", err
124 }
125 return strings.TrimSuffix(string(buf), "\n"), nil
126}
127
128func writeErr(w io.Writer, msg string) {
129 line := "ERR " + msg + "\n"
130 fmt.Fprintf(w, "%04x%s", len(line)+4, line)
131}