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		select {
 63		case conn := <-listen:
 64			d.wg.Add(1)
 65			go func() {
 66				d.handleClient(conn)
 67				d.wg.Done()
 68			}()
 69		case <-d.exit:
 70			if err := d.Close(); err != nil {
 71				return err
 72			}
 73			return ErrServerClosed
 74		}
 75	}
 76}
 77
 78func fatal(c net.Conn, err error) {
 79	git.WritePktline(c, err)
 80	if err := c.Close(); err != nil {
 81		log.Printf("git: error closing connection: %v", err)
 82	}
 83}
 84
 85// acceptConnection accepts connections on the listener.
 86func (d *Daemon) acceptConnection(listener net.Listener, listen chan<- net.Conn) {
 87	defer d.wg.Done()
 88	for {
 89		conn, err := listener.Accept()
 90		if err != nil {
 91			select {
 92			case <-d.exit:
 93				log.Printf("git: listener closed")
 94				return
 95			default:
 96				log.Printf("git: error accepting connection: %v", err)
 97				continue
 98			}
 99		}
100		listen <- conn
101	}
102}
103
104// handleClient handles a git protocol client.
105func (d *Daemon) handleClient(c net.Conn) {
106	d.conns[c] = struct{}{}
107	defer delete(d.conns, c)
108
109	// Close connection if there are too many open connections.
110	if len(d.conns) >= d.cfg.GitMaxConnections {
111		log.Printf("git: max connections reached, closing %s", c.RemoteAddr())
112		fatal(c, git.ErrMaxConns)
113		return
114	}
115
116	// Set connection timeout.
117	if err := c.SetDeadline(time.Now().Add(time.Duration(d.cfg.GitMaxTimeout) * time.Second)); err != nil {
118		log.Printf("git: error setting deadline: %v", err)
119		fatal(c, git.ErrSystemMalfunction)
120		return
121	}
122
123	readc := make(chan struct{}, 1)
124	go func() {
125		select {
126		case <-time.After(time.Duration(d.cfg.GitMaxReadTimeout) * time.Second):
127			log.Printf("git: read timeout from %s", c.RemoteAddr())
128			fatal(c, git.ErrMaxTimeout)
129		case <-readc:
130		}
131	}()
132
133	s := pktline.NewScanner(c)
134	if !s.Scan() {
135		if err := s.Err(); err != nil {
136			log.Printf("git: error scanning pktline: %v", err)
137			fatal(c, git.ErrSystemMalfunction)
138		}
139		return
140	}
141	readc <- struct{}{}
142
143	line := s.Bytes()
144	split := bytes.SplitN(line, []byte{' '}, 2)
145	if len(split) != 2 {
146		return
147	}
148
149	var repo string
150	cmd := string(split[0])
151	opts := bytes.Split(split[1], []byte{'\x00'})
152	if len(opts) == 0 {
153		return
154	}
155	repo = filepath.Clean(string(opts[0]))
156
157	log.Printf("git: connect %s %s %s", c.RemoteAddr(), cmd, repo)
158	defer log.Printf("git: disconnect %s %s %s", c.RemoteAddr(), cmd, repo)
159	repo = strings.TrimPrefix(repo, "/")
160	auth := d.auth.AuthRepo(strings.TrimSuffix(repo, ".git"), nil)
161	if auth < git.ReadOnlyAccess {
162		fatal(c, git.ErrNotAuthed)
163		return
164	}
165	// git bare repositories should end in ".git"
166	// https://git-scm.com/docs/gitrepository-layout
167	if !strings.HasSuffix(repo, ".git") {
168		repo += ".git"
169	}
170
171	err := git.GitPack(c, c, c, cmd, d.cfg.RepoPath, repo)
172	if err == git.ErrInvalidRepo {
173		trimmed := strings.TrimSuffix(repo, ".git")
174		log.Printf("git: invalid repo %q trying again %q", repo, trimmed)
175		err = git.GitPack(c, c, c, cmd, d.cfg.RepoPath, trimmed)
176	}
177	if err != nil {
178		fatal(c, err)
179		return
180	}
181}
182
183// Close closes the underlying listener.
184func (d *Daemon) Close() error {
185	return d.listener.Close()
186}
187
188// Shutdown gracefully shuts down the daemon.
189func (d *Daemon) Shutdown(_ context.Context) error {
190	close(d.exit)
191	d.wg.Wait()
192	return nil
193}