internal/gitd/gitd.go
133 lines · 3478 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 p := principal(conn.RemoteAddr())
76 release, err := s.packs.Acquire(nil, p)
77 if err != nil {
78 s.packs.Refused("git", p, err)
79 writeErr(conn, err.Error())
80 return
81 }
82 // Deferred before git runs, so it fires after git has exited and
83 // been waited for.
84 defer release()
85 out, stalled, unwatch := s.packs.Watch(conn)
86 defer unwatch()
87 kill := make(chan struct{})
88 finished := make(chan struct{})
89 defer close(finished)
90 go func() {
91 select {
92 case <-finished:
93 case <-stalled:
94 close(kill)
95 // A write blocked on a client that stopped reading outlives
96 // git; closing the connection ends it and the stdin copy.
97 conn.Close()
98 }
99 }()
100
101 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
102 gitutil.Transport("git-upload-pack", dir, conn, out, io.Discard, protoEnv, 0, kill)
103}
104
105// principal is the pack-limit principal for a client at addr.
106func principal(addr net.Addr) string {
107 host, _, _ := net.SplitHostPort(addr.String())
108 return packlimit.AddrPrincipal(host)
109}
110
111func readPktLine(r io.Reader) (string, error) {
112 var lenHex [4]byte
113 if _, err := io.ReadFull(r, lenHex[:]); err != nil {
114 return "", err
115 }
116 n, err := strconv.ParseUint(string(lenHex[:]), 16, 16)
117 if err != nil || n < 4 || n > 65520 {
118 return "", fmt.Errorf("bad pkt length %q", lenHex)
119 }
120 if n == 4 {
121 return "", nil // flush-pkt
122 }
123 buf := make([]byte, n-4)
124 if _, err := io.ReadFull(r, buf); err != nil {
125 return "", err
126 }
127 return strings.TrimSuffix(string(buf), "\n"), nil
128}
129
130func writeErr(w io.Writer, msg string) {
131 line := "ERR " + msg + "\n"
132 fmt.Fprintf(w, "%04x%s", len(line)+4, line)
133}