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