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