daemon.go

  1package daemon
  2
  3import (
  4	"bytes"
  5	"context"
  6	"errors"
  7	"fmt"
  8	"net"
  9	"path/filepath"
 10	"strings"
 11	"sync"
 12	"sync/atomic"
 13	"time"
 14
 15	"github.com/charmbracelet/log/v2"
 16	"github.com/charmbracelet/soft-serve/pkg/access"
 17	"github.com/charmbracelet/soft-serve/pkg/backend"
 18	"github.com/charmbracelet/soft-serve/pkg/config"
 19	"github.com/charmbracelet/soft-serve/pkg/git"
 20	"github.com/charmbracelet/soft-serve/pkg/utils"
 21	"github.com/go-git/go-git/v5/plumbing/format/pktline"
 22	"github.com/prometheus/client_golang/prometheus"
 23	"github.com/prometheus/client_golang/prometheus/promauto"
 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
 42// ErrServerClosed indicates that the server has been closed.
 43var ErrServerClosed = fmt.Errorf("git: %w", net.ErrClosed)
 44
 45// GitDaemon represents a Git daemon.
 46type GitDaemon struct {
 47	ctx       context.Context
 48	addr      string
 49	finished  chan struct{}
 50	conns     connections
 51	cfg       *config.Config
 52	be        *backend.Backend
 53	wg        sync.WaitGroup
 54	once      sync.Once
 55	logger    *log.Logger
 56	done      atomic.Bool // indicates if the server has been closed
 57	listeners []net.Listener
 58	liMu      sync.Mutex
 59}
 60
 61// NewGitDaemon returns a new Git daemon.
 62func NewGitDaemon(ctx context.Context) (*GitDaemon, error) {
 63	cfg := config.FromContext(ctx)
 64	addr := cfg.Git.ListenAddr
 65	d := &GitDaemon{
 66		ctx:      ctx,
 67		addr:     addr,
 68		finished: make(chan struct{}, 1),
 69		cfg:      cfg,
 70		be:       backend.FromContext(ctx),
 71		conns:    connections{m: make(map[net.Conn]struct{})},
 72		logger:   log.FromContext(ctx).WithPrefix("gitdaemon"),
 73	}
 74	return d, nil
 75}
 76
 77// ListenAndServe starts the Git TCP daemon.
 78func (d *GitDaemon) ListenAndServe() error {
 79	if d.done.Load() {
 80		return ErrServerClosed
 81	}
 82	listener, err := net.Listen("tcp", d.addr) //nolint:noctx
 83	if err != nil {
 84		return err //nolint:wrapcheck
 85	}
 86	return d.Serve(listener)
 87}
 88
 89// Serve listens on the TCP network address and serves Git requests.
 90func (d *GitDaemon) Serve(listener net.Listener) error {
 91	if d.done.Load() {
 92		return ErrServerClosed
 93	}
 94
 95	d.wg.Add(1)
 96	defer d.wg.Done()
 97	d.liMu.Lock()
 98	d.listeners = append(d.listeners, listener)
 99	d.liMu.Unlock()
100
101	var tempDelay time.Duration
102	for {
103		conn, err := listener.Accept()
104		if err != nil { //nolint:nestif
105			select {
106			case <-d.finished:
107				return ErrServerClosed
108			default:
109				d.logger.Debugf("git: error accepting connection: %v", err)
110			}
111			if ne, ok := err.(net.Error); ok && ne.Temporary() { //nolint:staticcheck
112				if tempDelay == 0 {
113					tempDelay = 5 * time.Millisecond
114				} else {
115					tempDelay *= 2
116				}
117				if max := 1 * time.Second; tempDelay > max { //nolint:revive
118					tempDelay = max
119				}
120				time.Sleep(tempDelay)
121				continue
122			}
123			return err //nolint:wrapcheck
124		}
125
126		// Close connection if there are too many open connections.
127		if d.conns.Size()+1 >= d.cfg.Git.MaxConnections {
128			d.logger.Debugf("git: max connections reached, closing %s", conn.RemoteAddr())
129			d.fatal(conn, git.ErrMaxConnections)
130			continue
131		}
132
133		d.wg.Add(1)
134		go func() {
135			d.handleClient(conn)
136			d.wg.Done()
137		}()
138	}
139}
140
141func (d *GitDaemon) fatal(c net.Conn, err error) {
142	git.WritePktlineErr(c, err) //nolint:errcheck,gosec
143	if err := c.Close(); err != nil {
144		d.logger.Debugf("git: error closing connection: %v", err)
145	}
146}
147
148// handleClient handles a git protocol client.
149func (d *GitDaemon) handleClient(conn net.Conn) {
150	ctx, cancel := context.WithCancel(context.Background())
151	idleTimeout := time.Duration(d.cfg.Git.IdleTimeout) * time.Second
152	c := &serverConn{
153		Conn:          conn,
154		idleTimeout:   idleTimeout,
155		closeCanceler: cancel,
156	}
157	if d.cfg.Git.MaxTimeout > 0 {
158		dur := time.Duration(d.cfg.Git.MaxTimeout) * time.Second
159		c.maxDeadline = time.Now().Add(dur)
160	}
161	d.conns.Add(c)
162	defer func() {
163		d.conns.Close(c) //nolint:errcheck,gosec
164	}()
165
166	errc := make(chan error, 1)
167
168	s := pktline.NewScanner(c)
169	go func() {
170		if !s.Scan() {
171			if err := s.Err(); err != nil {
172				errc <- err
173			}
174		}
175		errc <- nil
176	}()
177
178	select {
179	case <-ctx.Done():
180		if err := ctx.Err(); err != nil {
181			d.logger.Debugf("git: connection context error: %v", err)
182			d.fatal(c, git.ErrTimeout)
183		}
184		return
185	case err := <-errc:
186		if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
187			d.fatal(c, git.ErrTimeout)
188			return
189		} else if err != nil {
190			d.logger.Debugf("git: error scanning pktline: %v", err)
191			d.fatal(c, git.ErrSystemMalfunction)
192			return
193		}
194
195		line := s.Bytes()
196		split := bytes.SplitN(line, []byte{' '}, 2)
197		if len(split) != 2 {
198			d.fatal(c, git.ErrInvalidRequest)
199			return
200		}
201
202		var counter *prometheus.CounterVec
203		service := git.Service(split[0])
204		switch service { //nolint:exhaustive
205		case git.UploadPackService:
206			counter = uploadPackGitCounter
207		case git.UploadArchiveService:
208			counter = uploadArchiveGitCounter
209		default:
210			d.fatal(c, git.ErrInvalidRequest)
211			return
212		}
213
214		opts := bytes.SplitN(split[1], []byte{0}, 3)
215		if len(opts) < 2 {
216			d.fatal(c, git.ErrInvalidRequest)
217			return
218		}
219
220		host := strings.TrimPrefix(string(opts[1]), "host=")
221		extraParams := map[string]string{}
222
223		if len(opts) > 2 {
224			buf := bytes.TrimPrefix(opts[2], []byte{0})
225			for _, o := range bytes.Split(buf, []byte{0}) {
226				opt := string(o)
227				if opt == "" {
228					continue
229				}
230
231				kv := strings.SplitN(opt, "=", 2)
232				if len(kv) != 2 {
233					d.logger.Errorf("git: invalid option %q", opt)
234					continue
235				}
236
237				extraParams[kv[0]] = kv[1]
238			}
239
240			version := extraParams["version"]
241			if version != "" {
242				d.logger.Debugf("git: protocol version %s", version)
243			}
244		}
245
246		be := d.be
247		if !be.AllowKeyless(ctx) {
248			d.fatal(c, git.ErrNotAuthed)
249			return
250		}
251
252		name := utils.SanitizeRepo(string(opts[0]))
253		d.logger.Debugf("git: connect %s %s %s", c.RemoteAddr(), service, name)
254		defer d.logger.Debugf("git: disconnect %s %s %s", c.RemoteAddr(), service, name)
255
256		// git bare repositories should end in ".git"
257		// https://git-scm.com/docs/gitrepository-layout
258		repo := name + ".git"
259		reposDir := filepath.Join(d.cfg.DataPath, "repos")
260		if err := git.EnsureWithin(reposDir, repo); err != nil {
261			d.logger.Debugf("git: error ensuring repo path: %v", err)
262			d.fatal(c, git.ErrInvalidRepo)
263			return
264		}
265
266		if _, err := d.be.Repository(ctx, repo); err != nil {
267			d.fatal(c, git.ErrInvalidRepo)
268			return
269		}
270
271		auth := be.AccessLevel(ctx, name, "")
272		if auth < access.ReadOnlyAccess {
273			d.fatal(c, git.ErrNotAuthed)
274			return
275		}
276
277		// Environment variables to pass down to git hooks.
278		envs := []string{
279			"SOFT_SERVE_REPO_NAME=" + name,
280			"SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repo),
281			"SOFT_SERVE_HOST=" + host,
282			"SOFT_SERVE_LOG_PATH=" + filepath.Join(d.cfg.DataPath, "log", "hooks.log"),
283		}
284
285		// Add git protocol environment variable.
286		if len(extraParams) > 0 {
287			var gitProto string
288			for k, v := range extraParams {
289				if len(gitProto) > 0 {
290					gitProto += ":"
291				}
292				gitProto += k + "=" + v
293			}
294			envs = append(envs, "GIT_PROTOCOL="+gitProto)
295		}
296
297		envs = append(envs, d.cfg.Environ()...)
298
299		cmd := git.ServiceCommand{
300			Stdin:  c,
301			Stdout: c,
302			Stderr: c,
303			Env:    envs,
304			Dir:    filepath.Join(reposDir, repo),
305		}
306
307		if err := service.Handler(ctx, cmd); err != nil {
308			d.logger.Debugf("git: error handling request: %v", err)
309			d.fatal(c, err)
310			return
311		}
312
313		counter.WithLabelValues(name)
314	}
315}
316
317// Close closes the underlying listener.
318func (d *GitDaemon) Close() error {
319	err := d.closeListener()
320	d.conns.CloseAll() //nolint:errcheck,gosec
321	return err
322}
323
324// closeListener closes the listener and the finished channel.
325func (d *GitDaemon) closeListener() error {
326	if d.done.Load() {
327		return ErrServerClosed
328	}
329	var err error
330	d.liMu.Lock()
331	for _, l := range d.listeners {
332		if err = l.Close(); err != nil {
333			err = errors.Join(err, fmt.Errorf("close listener %s: %w", l.Addr(), err))
334		}
335	}
336	d.listeners = d.listeners[:0]
337	d.liMu.Unlock()
338	d.once.Do(func() {
339		d.done.Store(true)
340		close(d.finished)
341	})
342	return err
343}
344
345// Shutdown gracefully shuts down the daemon.
346func (d *GitDaemon) Shutdown(ctx context.Context) error {
347	if d.done.Load() {
348		return ErrServerClosed
349	}
350
351	err := d.closeListener()
352	finished := make(chan struct{}, 1)
353	go func() {
354		defer close(finished)
355		d.wg.Wait()
356	}()
357	select {
358	case <-ctx.Done():
359		return ctx.Err() //nolint:wrapcheck
360	case <-finished:
361		return err
362	}
363}