1package server
2
3import (
4 "bytes"
5 "context"
6 "fmt"
7 "net"
8 "path/filepath"
9 "sync"
10 "time"
11
12 "github.com/charmbracelet/soft-serve/server/backend"
13 "github.com/charmbracelet/soft-serve/server/config"
14 "github.com/charmbracelet/soft-serve/server/utils"
15 "github.com/go-git/go-git/v5/plumbing/format/pktline"
16 "github.com/prometheus/client_golang/prometheus"
17 "github.com/prometheus/client_golang/prometheus/promauto"
18)
19
20var (
21 uploadPackGitCounter = promauto.NewCounterVec(prometheus.CounterOpts{
22 Namespace: "soft_serve",
23 Subsystem: "git",
24 Name: "git_upload_pack_total",
25 Help: "The total number of git-upload-pack requests",
26 }, []string{"repo"})
27
28 uploadArchiveGitCounter = promauto.NewCounterVec(prometheus.CounterOpts{
29 Namespace: "soft_serve",
30 Subsystem: "git",
31 Name: "git_upload_archive_total",
32 Help: "The total number of git-upload-archive requests",
33 }, []string{"repo"})
34)
35
36// ErrServerClosed indicates that the server has been closed.
37var ErrServerClosed = fmt.Errorf("git: %w", net.ErrClosed)
38
39// connections synchronizes access to to a net.Conn pool.
40type connections struct {
41 m map[net.Conn]struct{}
42 mu sync.Mutex
43}
44
45func (m *connections) Add(c net.Conn) {
46 m.mu.Lock()
47 defer m.mu.Unlock()
48 m.m[c] = struct{}{}
49}
50
51func (m *connections) Close(c net.Conn) {
52 m.mu.Lock()
53 defer m.mu.Unlock()
54 _ = c.Close()
55 delete(m.m, c)
56}
57
58func (m *connections) Size() int {
59 m.mu.Lock()
60 defer m.mu.Unlock()
61 return len(m.m)
62}
63
64func (m *connections) CloseAll() {
65 m.mu.Lock()
66 defer m.mu.Unlock()
67 for c := range m.m {
68 _ = c.Close()
69 delete(m.m, c)
70 }
71}
72
73// GitDaemon represents a Git daemon.
74type GitDaemon struct {
75 listener net.Listener
76 addr string
77 finished chan struct{}
78 conns connections
79 cfg *config.Config
80 wg sync.WaitGroup
81 once sync.Once
82}
83
84// NewDaemon returns a new Git daemon.
85func NewGitDaemon(cfg *config.Config) (*GitDaemon, error) {
86 addr := cfg.Git.ListenAddr
87 d := &GitDaemon{
88 addr: addr,
89 finished: make(chan struct{}, 1),
90 cfg: cfg,
91 conns: connections{m: make(map[net.Conn]struct{})},
92 }
93 listener, err := net.Listen("tcp", d.addr)
94 if err != nil {
95 return nil, err
96 }
97 d.listener = listener
98 return d, nil
99}
100
101// Start starts the Git TCP daemon.
102func (d *GitDaemon) Start() error {
103 defer d.listener.Close() // nolint: errcheck
104
105 d.wg.Add(1)
106 defer d.wg.Done()
107
108 var tempDelay time.Duration
109 for {
110 conn, err := d.listener.Accept()
111 if err != nil {
112 select {
113 case <-d.finished:
114 return ErrServerClosed
115 default:
116 logger.Debugf("git: error accepting connection: %v", err)
117 }
118 if ne, ok := err.(net.Error); ok && ne.Temporary() {
119 if tempDelay == 0 {
120 tempDelay = 5 * time.Millisecond
121 } else {
122 tempDelay *= 2
123 }
124 if max := 1 * time.Second; tempDelay > max {
125 tempDelay = max
126 }
127 time.Sleep(tempDelay)
128 continue
129 }
130 return err
131 }
132
133 // Close connection if there are too many open connections.
134 if d.conns.Size()+1 >= d.cfg.Git.MaxConnections {
135 logger.Debugf("git: max connections reached, closing %s", conn.RemoteAddr())
136 fatal(conn, ErrMaxConnections)
137 continue
138 }
139
140 d.wg.Add(1)
141 go func() {
142 d.handleClient(conn)
143 d.wg.Done()
144 }()
145 }
146}
147
148func fatal(c net.Conn, err error) {
149 writePktline(c, err)
150 if err := c.Close(); err != nil {
151 logger.Debugf("git: error closing connection: %v", err)
152 }
153}
154
155// handleClient handles a git protocol client.
156func (d *GitDaemon) handleClient(conn net.Conn) {
157 ctx, cancel := context.WithCancel(context.Background())
158 idleTimeout := time.Duration(d.cfg.Git.IdleTimeout) * time.Second
159 c := &serverConn{
160 Conn: conn,
161 idleTimeout: idleTimeout,
162 closeCanceler: cancel,
163 }
164 if d.cfg.Git.MaxTimeout > 0 {
165 dur := time.Duration(d.cfg.Git.MaxTimeout) * time.Second
166 c.maxDeadline = time.Now().Add(dur)
167 }
168 d.conns.Add(c)
169 defer func() {
170 d.conns.Close(c)
171 }()
172
173 readc := make(chan struct{}, 1)
174 s := pktline.NewScanner(c)
175 go func() {
176 if !s.Scan() {
177 if err := s.Err(); err != nil {
178 if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
179 fatal(c, ErrTimeout)
180 } else {
181 logger.Debugf("git: error scanning pktline: %v", err)
182 fatal(c, ErrSystemMalfunction)
183 }
184 }
185 return
186 }
187 readc <- struct{}{}
188 }()
189
190 select {
191 case <-ctx.Done():
192 if err := ctx.Err(); err != nil {
193 logger.Debugf("git: connection context error: %v", err)
194 }
195 return
196 case <-readc:
197 line := s.Bytes()
198 split := bytes.SplitN(line, []byte{' '}, 2)
199 if len(split) != 2 {
200 fatal(c, ErrInvalidRequest)
201 return
202 }
203
204 gitPack := uploadPack
205 counter := uploadPackGitCounter
206 cmd := string(split[0])
207 switch cmd {
208 case uploadPackBin:
209 gitPack = uploadPack
210 case uploadArchiveBin:
211 gitPack = uploadArchive
212 counter = uploadArchiveGitCounter
213 default:
214 fatal(c, ErrInvalidRequest)
215 return
216 }
217
218 opts := bytes.Split(split[1], []byte{'\x00'})
219 if len(opts) == 0 {
220 fatal(c, ErrInvalidRequest)
221 return
222 }
223
224 name := utils.SanitizeRepo(string(opts[0]))
225 logger.Debugf("git: connect %s %s %s", c.RemoteAddr(), cmd, name)
226 defer logger.Debugf("git: disconnect %s %s %s", c.RemoteAddr(), cmd, name)
227 // git bare repositories should end in ".git"
228 // https://git-scm.com/docs/gitrepository-layout
229 repo := name + ".git"
230 reposDir := filepath.Join(d.cfg.DataPath, "repos")
231 if err := ensureWithin(reposDir, repo); err != nil {
232 fatal(c, err)
233 return
234 }
235
236 auth := d.cfg.Backend.AccessLevel(name, nil)
237 if auth < backend.ReadOnlyAccess {
238 fatal(c, ErrNotAuthed)
239 return
240 }
241
242 if err := gitPack(c, c, c, filepath.Join(reposDir, repo)); err != nil {
243 fatal(c, err)
244 return
245 }
246
247 counter.WithLabelValues(name)
248 }
249}
250
251// Close closes the underlying listener.
252func (d *GitDaemon) Close() error {
253 d.once.Do(func() { close(d.finished) })
254 err := d.listener.Close()
255 d.conns.CloseAll()
256 return err
257}
258
259// Shutdown gracefully shuts down the daemon.
260func (d *GitDaemon) Shutdown(ctx context.Context) error {
261 d.once.Do(func() { close(d.finished) })
262 err := d.listener.Close()
263 finished := make(chan struct{}, 1)
264 go func() {
265 d.wg.Wait()
266 finished <- struct{}{}
267 }()
268 select {
269 case <-ctx.Done():
270 return ctx.Err()
271 case <-finished:
272 return err
273 }
274}
275
276type serverConn struct {
277 net.Conn
278
279 idleTimeout time.Duration
280 maxDeadline time.Time
281 closeCanceler context.CancelFunc
282}
283
284func (c *serverConn) Write(p []byte) (n int, err error) {
285 c.updateDeadline()
286 n, err = c.Conn.Write(p)
287 if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil {
288 c.closeCanceler()
289 }
290 return
291}
292
293func (c *serverConn) Read(b []byte) (n int, err error) {
294 c.updateDeadline()
295 n, err = c.Conn.Read(b)
296 if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil {
297 c.closeCanceler()
298 }
299 return
300}
301
302func (c *serverConn) Close() (err error) {
303 err = c.Conn.Close()
304 if c.closeCanceler != nil {
305 c.closeCanceler()
306 }
307 return
308}
309
310func (c *serverConn) updateDeadline() {
311 switch {
312 case c.idleTimeout > 0:
313 idleDeadline := time.Now().Add(c.idleTimeout)
314 if idleDeadline.Unix() < c.maxDeadline.Unix() || c.maxDeadline.IsZero() {
315 c.Conn.SetDeadline(idleDeadline)
316 return
317 }
318 fallthrough
319 default:
320 c.Conn.SetDeadline(c.maxDeadline)
321 }
322}