daemon.go

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