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