daemon.go

  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/server/config"
 16	"github.com/charmbracelet/soft-serve/server/git"
 17	"github.com/go-git/go-git/v5/plumbing/format/pktline"
 18)
 19
 20// ErrServerClosed indicates that the server has been closed.
 21var ErrServerClosed = errors.New("git: Server closed")
 22
 23// Daemon represents a Git daemon.
 24type Daemon struct {
 25	auth     git.Hooks
 26	listener net.Listener
 27	addr     string
 28	exit     chan struct{}
 29	conns    map[net.Conn]struct{}
 30	cfg      *config.Config
 31	wg       sync.WaitGroup
 32}
 33
 34// NewDaemon returns a new Git daemon.
 35func NewDaemon(cfg *config.Config, auth git.Hooks) (*Daemon, error) {
 36	addr := fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.GitPort)
 37	d := &Daemon{
 38		addr:  addr,
 39		auth:  auth,
 40		exit:  make(chan struct{}),
 41		cfg:   cfg,
 42		conns: make(map[net.Conn]struct{}),
 43	}
 44	listener, err := net.Listen("tcp", d.addr)
 45	if err != nil {
 46		return nil, err
 47	}
 48	d.listener = listener
 49	d.wg.Add(1)
 50	return d, nil
 51}
 52
 53// Start starts the Git TCP daemon.
 54func (d *Daemon) Start() error {
 55	// set up channel on which to send accepted connections
 56	listen := make(chan net.Conn, d.cfg.GitMaxConnections)
 57	go d.acceptConnection(d.listener, listen)
 58
 59	// loop work cycle with accept connections or interrupt
 60	// by system signal
 61	for {
 62		log.Printf("listener len %d cap %d", len(listen), cap(listen))
 63		select {
 64		case conn := <-listen:
 65			d.wg.Add(1)
 66			go func() {
 67				d.handleClient(conn)
 68				d.wg.Done()
 69			}()
 70		case <-d.exit:
 71			if err := d.Close(); err != nil {
 72				return err
 73			}
 74			return ErrServerClosed
 75		}
 76	}
 77}
 78
 79func fatal(c net.Conn, err error) {
 80	git.WritePktline(c, err)
 81	if err := c.Close(); err != nil {
 82		log.Printf("git: error closing connection: %v", err)
 83	}
 84}
 85
 86// acceptConnection accepts connections on the listener.
 87func (d *Daemon) acceptConnection(listener net.Listener, listen chan<- net.Conn) {
 88	defer d.wg.Done()
 89	for {
 90		conn, err := listener.Accept()
 91		if err != nil {
 92			select {
 93			case <-d.exit:
 94				log.Printf("git: listener closed")
 95				return
 96			default:
 97				log.Printf("git: error accepting connection: %v", err)
 98				continue
 99			}
100		}
101		listen <- conn
102	}
103}
104
105// handleClient handles a git protocol client.
106func (d *Daemon) handleClient(c net.Conn) {
107	d.conns[c] = struct{}{}
108	defer delete(d.conns, c)
109
110	// Close connection if there are too many open connections.
111	if len(d.conns) >= d.cfg.GitMaxConnections {
112		log.Printf("git: max connections reached, closing %s", c.RemoteAddr())
113		fatal(c, git.ErrMaxConns)
114		return
115	}
116
117	// Set connection timeout.
118	if err := c.SetDeadline(time.Now().Add(time.Duration(d.cfg.GitMaxTimeout) * time.Second)); err != nil {
119		log.Printf("git: error setting deadline: %v", err)
120		fatal(c, git.ErrSystemMalfunction)
121		return
122	}
123
124	readc := make(chan struct{}, 1)
125	go func() {
126		select {
127		case <-time.After(time.Duration(d.cfg.GitMaxReadTimeout) * time.Second):
128			log.Printf("git: read timeout from %s", c.RemoteAddr())
129			fatal(c, git.ErrMaxTimeout)
130		case <-readc:
131		}
132	}()
133
134	s := pktline.NewScanner(c)
135	if !s.Scan() {
136		if err := s.Err(); err != nil {
137			log.Printf("git: error scanning pktline: %v", err)
138			fatal(c, git.ErrSystemMalfunction)
139		}
140		return
141	}
142	readc <- struct{}{}
143
144	line := s.Bytes()
145	split := bytes.SplitN(line, []byte{' '}, 2)
146	if len(split) != 2 {
147		return
148	}
149
150	var repo string
151	cmd := string(split[0])
152	opts := bytes.Split(split[1], []byte{'\x00'})
153	if len(opts) == 0 {
154		return
155	}
156	repo = filepath.Clean(string(opts[0]))
157
158	log.Printf("git: connect %s %s %s", c.RemoteAddr(), cmd, repo)
159	defer log.Printf("git: disconnect %s %s %s", c.RemoteAddr(), cmd, repo)
160	repo = strings.TrimPrefix(repo, "/")
161	auth := d.auth.AuthRepo(strings.TrimSuffix(repo, ".git"), nil)
162	if auth < git.ReadOnlyAccess {
163		fatal(c, git.ErrNotAuthed)
164		return
165	}
166	// git bare repositories should end in ".git"
167	// https://git-scm.com/docs/gitrepository-layout
168	if !strings.HasSuffix(repo, ".git") {
169		repo += ".git"
170	}
171
172	err := git.GitPack(c, c, c, cmd, d.cfg.RepoPath, repo)
173	if err == git.ErrInvalidRepo {
174		trimmed := strings.TrimSuffix(repo, ".git")
175		log.Printf("git: invalid repo %q trying again %q", repo, trimmed)
176		err = git.GitPack(c, c, c, cmd, d.cfg.RepoPath, trimmed)
177	}
178	if err != nil {
179		fatal(c, err)
180		return
181	}
182}
183
184// Close closes the underlying listener.
185func (d *Daemon) Close() error {
186	return d.listener.Close()
187}
188
189// Shutdown gracefully shuts down the daemon.
190func (d *Daemon) Shutdown(_ context.Context) error {
191	close(d.exit)
192	d.wg.Wait()
193	return nil
194}