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	log "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// NewDaemon 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)
 83	if err != nil {
 84		return err
 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 {
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
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) 
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) 
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 {
205		case git.UploadPackService:
206			counter = uploadPackGitCounter
207		case git.UploadArchiveService:
208			counter = uploadArchiveGitCounter
209		case git.ReceivePackService, git.LFSTransferService:
210			// These services don't have counters in this context
211			d.fatal(c, git.ErrInvalidRequest)
212			return
213		default:
214			d.fatal(c, git.ErrInvalidRequest)
215			return
216		}
217
218		opts := bytes.SplitN(split[1], []byte{0}, 3)
219		if len(opts) < 2 {
220			d.fatal(c, git.ErrInvalidRequest) 
221			return
222		}
223
224		host := strings.TrimPrefix(string(opts[1]), "host=")
225		extraParams := map[string]string{}
226
227		if len(opts) > 2 {
228			buf := bytes.TrimPrefix(opts[2], []byte{0})
229			for _, o := range bytes.Split(buf, []byte{0}) {
230				opt := string(o)
231				if opt == "" {
232					continue
233				}
234
235				kv := strings.SplitN(opt, "=", 2)
236				if len(kv) != 2 {
237					d.logger.Errorf("git: invalid option %q", opt)
238					continue
239				}
240
241				extraParams[kv[0]] = kv[1]
242			}
243
244			version := extraParams["version"]
245			if version != "" {
246				d.logger.Debugf("git: protocol version %s", version)
247			}
248		}
249
250		be := d.be
251		if !be.AllowKeyless(ctx) {
252			d.fatal(c, git.ErrNotAuthed)
253			return
254		}
255
256		name := utils.SanitizeRepo(string(opts[0]))
257		d.logger.Debugf("git: connect %s %s %s", c.RemoteAddr(), service, name)
258		defer d.logger.Debugf("git: disconnect %s %s %s", c.RemoteAddr(), service, name)
259
260		// git bare repositories should end in ".git"
261		// https://git-scm.com/docs/gitrepository-layout
262		repo := name + ".git"
263		reposDir := filepath.Join(d.cfg.DataPath, "repos")
264		if err := git.EnsureWithin(reposDir, repo); err != nil {
265			d.logger.Debugf("git: error ensuring repo path: %v", err)
266			d.fatal(c, git.ErrInvalidRepo)
267			return
268		}
269
270		if _, err := d.be.Repository(ctx, repo); err != nil {
271			d.fatal(c, git.ErrInvalidRepo)
272			return
273		}
274
275		auth := be.AccessLevel(ctx, name, "")
276		if auth < access.ReadOnlyAccess {
277			d.fatal(c, git.ErrNotAuthed)
278			return
279		}
280
281		// Environment variables to pass down to git hooks.
282		envs := []string{
283			"SOFT_SERVE_REPO_NAME=" + name,
284			"SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repo),
285			"SOFT_SERVE_HOST=" + host,
286			"SOFT_SERVE_LOG_PATH=" + filepath.Join(d.cfg.DataPath, "log", "hooks.log"),
287		}
288
289		// Add git protocol environment variable.
290		if len(extraParams) > 0 {
291			var gitProto string
292			for k, v := range extraParams {
293				if len(gitProto) > 0 {
294					gitProto += ":"
295				}
296				gitProto += k + "=" + v
297			}
298			envs = append(envs, "GIT_PROTOCOL="+gitProto)
299		}
300
301		envs = append(envs, d.cfg.Environ()...)
302
303		cmd := git.ServiceCommand{
304			Stdin:  c,
305			Stdout: c,
306			Stderr: c,
307			Env:    envs,
308			Dir:    filepath.Join(reposDir, repo),
309		}
310
311		if err := service.Handler(ctx, cmd); err != nil {
312			d.logger.Debugf("git: error handling request: %v", err)
313			d.fatal(c, err)
314			return
315		}
316
317		counter.WithLabelValues(name)
318	}
319}
320
321// Close closes the underlying listener.
322func (d *GitDaemon) Close() error {
323	err := d.closeListener()
324	d.conns.CloseAll() 
325	return err
326}
327
328// closeListener closes the listener and the finished channel.
329func (d *GitDaemon) closeListener() error {
330	if d.done.Load() {
331		return ErrServerClosed
332	}
333	var err error
334	d.liMu.Lock()
335	for _, l := range d.listeners {
336		if err = l.Close(); err != nil {
337			err = errors.Join(err, fmt.Errorf("close listener %s: %w", l.Addr(), err))
338		}
339	}
340	d.listeners = d.listeners[:0]
341	d.liMu.Unlock()
342	d.once.Do(func() {
343		d.done.Store(true)
344		close(d.finished)
345	})
346	return err
347}
348
349// Shutdown gracefully shuts down the daemon.
350func (d *GitDaemon) Shutdown(ctx context.Context) error {
351	if d.done.Load() {
352		return ErrServerClosed
353	}
354
355	err := d.closeListener()
356	finished := make(chan struct{}, 1)
357	go func() {
358		defer close(finished)
359		d.wg.Wait()
360	}()
361	select {
362	case <-ctx.Done():
363		return ctx.Err()
364	case <-finished:
365		return err
366	}
367}