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}