daemon.go

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