daemon.go

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