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