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