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