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