1package daemon
  2
  3import (
  4	"bytes"
  5	"context"
  6	"errors"
  7	"fmt"
  8	"log"
  9	"net"
 10	"path/filepath"
 11	"strings"
 12	"sync"
 13	"time"
 14
 15	"github.com/charmbracelet/soft-serve/proto"
 16	"github.com/charmbracelet/soft-serve/server/config"
 17	"github.com/charmbracelet/soft-serve/server/git"
 18	"github.com/go-git/go-git/v5/plumbing/format/pktline"
 19)
 20
 21// ErrServerClosed indicates that the server has been closed.
 22var ErrServerClosed = errors.New("git: Server closed")
 23
 24// Daemon represents a Git daemon.
 25type Daemon struct {
 26	listener net.Listener
 27	addr     string
 28	finished chan struct{}
 29	conns    map[net.Conn]struct{}
 30	cfg      *config.Config
 31	wg       sync.WaitGroup
 32	once     sync.Once
 33	mtx      sync.RWMutex
 34}
 35
 36// NewDaemon returns a new Git daemon.
 37func NewDaemon(cfg *config.Config) (*Daemon, error) {
 38	addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Git.Port)
 39	d := &Daemon{
 40		addr:     addr,
 41		finished: make(chan struct{}, 1),
 42		cfg:      cfg,
 43		conns:    make(map[net.Conn]struct{}),
 44	}
 45	listener, err := net.Listen("tcp", d.addr)
 46	if err != nil {
 47		return nil, err
 48	}
 49	d.listener = listener
 50	return d, nil
 51}
 52
 53// Start starts the Git TCP daemon.
 54func (d *Daemon) Start() error {
 55	defer d.listener.Close()
 56
 57	d.wg.Add(1)
 58	defer d.wg.Done()
 59
 60	var tempDelay time.Duration
 61	for {
 62		conn, err := d.listener.Accept()
 63		if err != nil {
 64			select {
 65			case <-d.finished:
 66				return ErrServerClosed
 67			default:
 68				log.Printf("git: error accepting connection: %v", err)
 69			}
 70			if ne, ok := err.(net.Error); ok && ne.Temporary() {
 71				if tempDelay == 0 {
 72					tempDelay = 5 * time.Millisecond
 73				} else {
 74					tempDelay *= 2
 75				}
 76				if max := 1 * time.Second; tempDelay > max {
 77					tempDelay = max
 78				}
 79				time.Sleep(tempDelay)
 80				continue
 81			}
 82			return err
 83		}
 84
 85		// Close connection if there are too many open connections.
 86		if len(d.conns)+1 >= d.cfg.Git.MaxConnections {
 87			log.Printf("git: max connections reached, closing %s", conn.RemoteAddr())
 88			fatal(conn, git.ErrMaxConnections)
 89			continue
 90		}
 91
 92		d.wg.Add(1)
 93		go func() {
 94			d.handleClient(conn)
 95			d.wg.Done()
 96		}()
 97	}
 98}
 99
100func fatal(c net.Conn, err error) {
101	git.WritePktline(c, err)
102	if err := c.Close(); err != nil {
103		log.Printf("git: error closing connection: %v", err)
104	}
105}
106
107// handleClient handles a git protocol client.
108func (d *Daemon) handleClient(conn net.Conn) {
109	ctx, cancel := context.WithCancel(context.Background())
110	idleTimeout := time.Duration(d.cfg.Git.IdleTimeout) * time.Second
111	c := &serverConn{
112		Conn:          conn,
113		idleTimeout:   idleTimeout,
114		closeCanceler: cancel,
115	}
116	if d.cfg.Git.MaxTimeout > 0 {
117		dur := time.Duration(d.cfg.Git.MaxTimeout) * time.Second
118		c.maxDeadline = time.Now().Add(dur)
119	}
120	d.conns[c] = struct{}{}
121	defer func() {
122		c.Close()
123		delete(d.conns, c)
124	}()
125
126	readc := make(chan struct{}, 1)
127	s := pktline.NewScanner(c)
128	go func() {
129		if !s.Scan() {
130			if err := s.Err(); err != nil {
131				if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
132					fatal(c, git.ErrTimeout)
133				} else {
134					log.Printf("git: error scanning pktline: %v", err)
135					fatal(c, git.ErrSystemMalfunction)
136				}
137			}
138			return
139		}
140		readc <- struct{}{}
141	}()
142
143	select {
144	case <-ctx.Done():
145		if err := ctx.Err(); err != nil {
146			log.Printf("git: connection context error: %v", err)
147		}
148		return
149	case <-readc:
150		line := s.Bytes()
151		split := bytes.SplitN(line, []byte{' '}, 2)
152		if len(split) != 2 {
153			fatal(c, git.ErrInvalidRequest)
154			return
155		}
156
157		var repo string
158		cmd := string(split[0])
159		opts := bytes.Split(split[1], []byte{'\x00'})
160		if len(opts) == 0 {
161			fatal(c, git.ErrInvalidRequest)
162			return
163		}
164		repo = filepath.Clean(string(opts[0]))
165
166		log.Printf("git: connect %s %s %s", c.RemoteAddr(), cmd, repo)
167		defer log.Printf("git: disconnect %s %s %s", c.RemoteAddr(), cmd, repo)
168		repo = strings.TrimPrefix(repo, "/")
169		auth := d.cfg.AuthRepo(strings.TrimSuffix(repo, ".git"), nil)
170		if auth < proto.ReadOnlyAccess {
171			fatal(c, git.ErrNotAuthed)
172			return
173		}
174		// git bare repositories should end in ".git"
175		// https://git-scm.com/docs/gitrepository-layout
176		if !strings.HasSuffix(repo, ".git") {
177			repo += ".git"
178		}
179
180		err := git.GitPack(c, c, c, cmd, d.cfg.RepoPath(), repo)
181		if err == git.ErrInvalidRepo {
182			trimmed := strings.TrimSuffix(repo, ".git")
183			log.Printf("git: invalid repo %q trying again %q", repo, trimmed)
184			err = git.GitPack(c, c, c, cmd, d.cfg.RepoPath(), trimmed)
185		}
186		if err != nil {
187			fatal(c, err)
188			return
189		}
190	}
191}
192
193// Close closes the underlying listener.
194func (d *Daemon) Close() error {
195	d.once.Do(func() { close(d.finished) })
196	err := d.listener.Close()
197	for c := range d.conns {
198		c.Close()
199		delete(d.conns, c)
200	}
201	return err
202}
203
204// Shutdown gracefully shuts down the daemon.
205func (d *Daemon) Shutdown(ctx context.Context) error {
206	d.once.Do(func() { close(d.finished) })
207	err := d.listener.Close()
208	finished := make(chan struct{}, 1)
209	go func() {
210		d.wg.Wait()
211		finished <- struct{}{}
212	}()
213	select {
214	case <-ctx.Done():
215		return ctx.Err()
216	case <-finished:
217		return err
218	}
219}