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