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