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