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	wg       sync.WaitGroup
 87	once     sync.Once
 88	logger   *log.Logger
 89}
 90
 91// NewDaemon returns a new Git daemon.
 92func NewGitDaemon(ctx context.Context) (*GitDaemon, error) {
 93	cfg := config.FromContext(ctx)
 94	addr := cfg.Git.ListenAddr
 95	d := &GitDaemon{
 96		ctx:      ctx,
 97		addr:     addr,
 98		finished: make(chan struct{}, 1),
 99		cfg:      cfg,
100		conns:    connections{m: make(map[net.Conn]struct{})},
101		logger:   log.FromContext(ctx).WithPrefix("gitdaemon"),
102	}
103	listener, err := net.Listen("tcp", d.addr)
104	if err != nil {
105		return nil, err
106	}
107	d.listener = listener
108	return d, nil
109}
110
111// Start starts the Git TCP daemon.
112func (d *GitDaemon) Start() error {
113	defer d.listener.Close() // nolint: errcheck
114
115	d.wg.Add(1)
116	defer d.wg.Done()
117
118	var tempDelay time.Duration
119	for {
120		conn, err := d.listener.Accept()
121		if err != nil {
122			select {
123			case <-d.finished:
124				return ErrServerClosed
125			default:
126				d.logger.Debugf("git: error accepting connection: %v", err)
127			}
128			if ne, ok := err.(net.Error); ok && ne.Temporary() {
129				if tempDelay == 0 {
130					tempDelay = 5 * time.Millisecond
131				} else {
132					tempDelay *= 2
133				}
134				if max := 1 * time.Second; tempDelay > max {
135					tempDelay = max
136				}
137				time.Sleep(tempDelay)
138				continue
139			}
140			return err
141		}
142
143		// Close connection if there are too many open connections.
144		if d.conns.Size()+1 >= d.cfg.Git.MaxConnections {
145			d.logger.Debugf("git: max connections reached, closing %s", conn.RemoteAddr())
146			d.fatal(conn, git.ErrMaxConnections)
147			continue
148		}
149
150		d.wg.Add(1)
151		go func() {
152			d.handleClient(conn)
153			d.wg.Done()
154		}()
155	}
156}
157
158func (d *GitDaemon) fatal(c net.Conn, err error) {
159	git.WritePktline(c, err)
160	if err := c.Close(); err != nil {
161		d.logger.Debugf("git: error closing connection: %v", err)
162	}
163}
164
165// handleClient handles a git protocol client.
166func (d *GitDaemon) handleClient(conn net.Conn) {
167	ctx, cancel := context.WithCancel(context.Background())
168	idleTimeout := time.Duration(d.cfg.Git.IdleTimeout) * time.Second
169	c := &serverConn{
170		Conn:          conn,
171		idleTimeout:   idleTimeout,
172		closeCanceler: cancel,
173	}
174	if d.cfg.Git.MaxTimeout > 0 {
175		dur := time.Duration(d.cfg.Git.MaxTimeout) * time.Second
176		c.maxDeadline = time.Now().Add(dur)
177	}
178	d.conns.Add(c)
179	defer func() {
180		d.conns.Close(c)
181	}()
182
183	readc := make(chan struct{}, 1)
184	s := pktline.NewScanner(c)
185	go func() {
186		if !s.Scan() {
187			if err := s.Err(); err != nil {
188				if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
189					d.fatal(c, git.ErrTimeout)
190				} else {
191					d.logger.Debugf("git: error scanning pktline: %v", err)
192					d.fatal(c, git.ErrSystemMalfunction)
193				}
194			}
195			return
196		}
197		readc <- struct{}{}
198	}()
199
200	select {
201	case <-ctx.Done():
202		if err := ctx.Err(); err != nil {
203			d.logger.Debugf("git: connection context error: %v", err)
204		}
205		return
206	case <-readc:
207		line := s.Bytes()
208		split := bytes.SplitN(line, []byte{' '}, 2)
209		if len(split) != 2 {
210			d.fatal(c, git.ErrInvalidRequest)
211			return
212		}
213
214		gitPack := git.UploadPack
215		counter := uploadPackGitCounter
216		cmd := string(split[0])
217		switch cmd {
218		case git.UploadPackBin:
219			gitPack = git.UploadPack
220		case git.UploadArchiveBin:
221			gitPack = git.UploadArchive
222			counter = uploadArchiveGitCounter
223		default:
224			d.fatal(c, git.ErrInvalidRequest)
225			return
226		}
227
228		opts := bytes.Split(split[1], []byte{'\x00'})
229		if len(opts) == 0 {
230			d.fatal(c, git.ErrInvalidRequest)
231			return
232		}
233
234		if !d.cfg.Backend.AllowKeyless() {
235			d.fatal(c, git.ErrNotAuthed)
236			return
237		}
238
239		name := utils.SanitizeRepo(string(opts[0]))
240		d.logger.Debugf("git: connect %s %s %s", c.RemoteAddr(), cmd, name)
241		defer d.logger.Debugf("git: disconnect %s %s %s", c.RemoteAddr(), cmd, name)
242		// git bare repositories should end in ".git"
243		// https://git-scm.com/docs/gitrepository-layout
244		repo := name + ".git"
245		reposDir := filepath.Join(d.cfg.DataPath, "repos")
246		if err := git.EnsureWithin(reposDir, repo); err != nil {
247			d.fatal(c, err)
248			return
249		}
250
251		auth := d.cfg.Backend.AccessLevel(name, "")
252		if auth < backend.ReadOnlyAccess {
253			d.fatal(c, git.ErrNotAuthed)
254			return
255		}
256
257		// Environment variables to pass down to git hooks.
258		envs := []string{
259			"SOFT_SERVE_REPO_NAME=" + name,
260			"SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repo),
261		}
262
263		if err := gitPack(ctx, c, c, c, filepath.Join(reposDir, repo), envs...); err != nil {
264			d.fatal(c, err)
265			return
266		}
267
268		counter.WithLabelValues(name)
269	}
270}
271
272// Close closes the underlying listener.
273func (d *GitDaemon) Close() error {
274	d.once.Do(func() { close(d.finished) })
275	err := d.listener.Close()
276	d.conns.CloseAll()
277	return err
278}
279
280// Shutdown gracefully shuts down the daemon.
281func (d *GitDaemon) Shutdown(ctx context.Context) error {
282	d.once.Do(func() { close(d.finished) })
283	err := d.listener.Close()
284	finished := make(chan struct{}, 1)
285	go func() {
286		d.wg.Wait()
287		finished <- struct{}{}
288	}()
289	select {
290	case <-ctx.Done():
291		return ctx.Err()
292	case <-finished:
293		return err
294	}
295}
296
297type serverConn struct {
298	net.Conn
299
300	idleTimeout   time.Duration
301	maxDeadline   time.Time
302	closeCanceler context.CancelFunc
303}
304
305func (c *serverConn) Write(p []byte) (n int, err error) {
306	c.updateDeadline()
307	n, err = c.Conn.Write(p)
308	if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil {
309		c.closeCanceler()
310	}
311	return
312}
313
314func (c *serverConn) Read(b []byte) (n int, err error) {
315	c.updateDeadline()
316	n, err = c.Conn.Read(b)
317	if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil {
318		c.closeCanceler()
319	}
320	return
321}
322
323func (c *serverConn) Close() (err error) {
324	err = c.Conn.Close()
325	if c.closeCanceler != nil {
326		c.closeCanceler()
327	}
328	return
329}
330
331func (c *serverConn) updateDeadline() {
332	switch {
333	case c.idleTimeout > 0:
334		idleDeadline := time.Now().Add(c.idleTimeout)
335		if idleDeadline.Unix() < c.maxDeadline.Unix() || c.maxDeadline.IsZero() {
336			c.Conn.SetDeadline(idleDeadline)
337			return
338		}
339		fallthrough
340	default:
341		c.Conn.SetDeadline(c.maxDeadline)
342	}
343}