1package daemon
2
3import (
4 "bytes"
5 "context"
6 "fmt"
7 "net"
8 "path/filepath"
9 "sync"
10 "time"
11
12 "github.com/charmbracelet/log"
13 "github.com/charmbracelet/soft-serve/server/backend"
14 "github.com/charmbracelet/soft-serve/server/config"
15 "github.com/charmbracelet/soft-serve/server/git"
16 "github.com/charmbracelet/soft-serve/server/utils"
17 "github.com/go-git/go-git/v5/plumbing/format/pktline"
18 "github.com/prometheus/client_golang/prometheus"
19 "github.com/prometheus/client_golang/prometheus/promauto"
20)
21
22var (
23 logger = log.WithPrefix("server.daemon")
24)
25
26var (
27 uploadPackGitCounter = promauto.NewCounterVec(prometheus.CounterOpts{
28 Namespace: "soft_serve",
29 Subsystem: "git",
30 Name: "git_upload_pack_total",
31 Help: "The total number of git-upload-pack requests",
32 }, []string{"repo"})
33
34 uploadArchiveGitCounter = promauto.NewCounterVec(prometheus.CounterOpts{
35 Namespace: "soft_serve",
36 Subsystem: "git",
37 Name: "git_upload_archive_total",
38 Help: "The total number of git-upload-archive requests",
39 }, []string{"repo"})
40)
41
42var (
43
44 // ErrServerClosed indicates that the server has been closed.
45 ErrServerClosed = fmt.Errorf("git: %w", net.ErrClosed)
46)
47
48// connections synchronizes access to to a net.Conn pool.
49type connections struct {
50 m map[net.Conn]struct{}
51 mu sync.Mutex
52}
53
54func (m *connections) Add(c net.Conn) {
55 m.mu.Lock()
56 defer m.mu.Unlock()
57 m.m[c] = struct{}{}
58}
59
60func (m *connections) Close(c net.Conn) {
61 m.mu.Lock()
62 defer m.mu.Unlock()
63 _ = c.Close()
64 delete(m.m, c)
65}
66
67func (m *connections) Size() int {
68 m.mu.Lock()
69 defer m.mu.Unlock()
70 return len(m.m)
71}
72
73func (m *connections) CloseAll() {
74 m.mu.Lock()
75 defer m.mu.Unlock()
76 for c := range m.m {
77 _ = c.Close()
78 delete(m.m, c)
79 }
80}
81
82// GitDaemon represents a Git daemon.
83type GitDaemon struct {
84 listener net.Listener
85 addr string
86 finished chan struct{}
87 conns connections
88 cfg *config.Config
89 wg sync.WaitGroup
90 once sync.Once
91}
92
93// NewDaemon returns a new Git daemon.
94func NewGitDaemon(cfg *config.Config) (*GitDaemon, error) {
95 addr := cfg.Git.ListenAddr
96 d := &GitDaemon{
97 addr: addr,
98 finished: make(chan struct{}, 1),
99 cfg: cfg,
100 conns: connections{m: make(map[net.Conn]struct{})},
101 }
102 listener, err := net.Listen("tcp", d.addr)
103 if err != nil {
104 return nil, err
105 }
106 d.listener = listener
107 return d, nil
108}
109
110// Start starts the Git TCP daemon.
111func (d *GitDaemon) Start() error {
112 defer d.listener.Close() // nolint: errcheck
113
114 d.wg.Add(1)
115 defer d.wg.Done()
116
117 var tempDelay time.Duration
118 for {
119 conn, err := d.listener.Accept()
120 if err != nil {
121 select {
122 case <-d.finished:
123 return ErrServerClosed
124 default:
125 logger.Debugf("git: error accepting connection: %v", err)
126 }
127 if ne, ok := err.(net.Error); ok && ne.Temporary() {
128 if tempDelay == 0 {
129 tempDelay = 5 * time.Millisecond
130 } else {
131 tempDelay *= 2
132 }
133 if max := 1 * time.Second; tempDelay > max {
134 tempDelay = max
135 }
136 time.Sleep(tempDelay)
137 continue
138 }
139 return err
140 }
141
142 // Close connection if there are too many open connections.
143 if d.conns.Size()+1 >= d.cfg.Git.MaxConnections {
144 logger.Debugf("git: max connections reached, closing %s", conn.RemoteAddr())
145 fatal(conn, git.ErrMaxConnections)
146 continue
147 }
148
149 d.wg.Add(1)
150 go func() {
151 d.handleClient(conn)
152 d.wg.Done()
153 }()
154 }
155}
156
157func fatal(c net.Conn, err error) {
158 git.WritePktline(c, err)
159 if err := c.Close(); err != nil {
160 logger.Debugf("git: error closing connection: %v", err)
161 }
162}
163
164// handleClient handles a git protocol client.
165func (d *GitDaemon) handleClient(conn net.Conn) {
166 ctx, cancel := context.WithCancel(context.Background())
167 idleTimeout := time.Duration(d.cfg.Git.IdleTimeout) * time.Second
168 c := &serverConn{
169 Conn: conn,
170 idleTimeout: idleTimeout,
171 closeCanceler: cancel,
172 }
173 if d.cfg.Git.MaxTimeout > 0 {
174 dur := time.Duration(d.cfg.Git.MaxTimeout) * time.Second
175 c.maxDeadline = time.Now().Add(dur)
176 }
177 d.conns.Add(c)
178 defer func() {
179 d.conns.Close(c)
180 }()
181
182 readc := make(chan struct{}, 1)
183 s := pktline.NewScanner(c)
184 go func() {
185 if !s.Scan() {
186 if err := s.Err(); err != nil {
187 if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
188 fatal(c, git.ErrTimeout)
189 } else {
190 logger.Debugf("git: error scanning pktline: %v", err)
191 fatal(c, git.ErrSystemMalfunction)
192 }
193 }
194 return
195 }
196 readc <- struct{}{}
197 }()
198
199 select {
200 case <-ctx.Done():
201 if err := ctx.Err(); err != nil {
202 logger.Debugf("git: connection context error: %v", err)
203 }
204 return
205 case <-readc:
206 line := s.Bytes()
207 split := bytes.SplitN(line, []byte{' '}, 2)
208 if len(split) != 2 {
209 fatal(c, git.ErrInvalidRequest)
210 return
211 }
212
213 gitPack := git.UploadPack
214 counter := uploadPackGitCounter
215 cmd := string(split[0])
216 switch cmd {
217 case git.UploadPackBin:
218 gitPack = git.UploadPack
219 case git.UploadArchiveBin:
220 gitPack = git.UploadArchive
221 counter = uploadArchiveGitCounter
222 default:
223 fatal(c, git.ErrInvalidRequest)
224 return
225 }
226
227 opts := bytes.Split(split[1], []byte{'\x00'})
228 if len(opts) == 0 {
229 fatal(c, git.ErrInvalidRequest)
230 return
231 }
232
233 if !d.cfg.Backend.AllowKeyless() {
234 fatal(c, git.ErrNotAuthed)
235 return
236 }
237
238 name := utils.SanitizeRepo(string(opts[0]))
239 logger.Debugf("git: connect %s %s %s", c.RemoteAddr(), cmd, name)
240 defer logger.Debugf("git: disconnect %s %s %s", c.RemoteAddr(), cmd, name)
241 // git bare repositories should end in ".git"
242 // https://git-scm.com/docs/gitrepository-layout
243 repo := name + ".git"
244 reposDir := filepath.Join(d.cfg.DataPath, "repos")
245 if err := git.EnsureWithin(reposDir, repo); err != nil {
246 fatal(c, err)
247 return
248 }
249
250 auth := d.cfg.Backend.AccessLevel(name, "")
251 if auth < backend.ReadOnlyAccess {
252 fatal(c, git.ErrNotAuthed)
253 return
254 }
255
256 // Environment variables to pass down to git hooks.
257 envs := []string{
258 "SOFT_SERVE_REPO_NAME=" + name,
259 "SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repo),
260 }
261
262 if err := gitPack(ctx, c, c, c, filepath.Join(reposDir, repo), envs...); err != nil {
263 fatal(c, err)
264 return
265 }
266
267 counter.WithLabelValues(name)
268 }
269}
270
271// Close closes the underlying listener.
272func (d *GitDaemon) Close() error {
273 d.once.Do(func() { close(d.finished) })
274 err := d.listener.Close()
275 d.conns.CloseAll()
276 return err
277}
278
279// Shutdown gracefully shuts down the daemon.
280func (d *GitDaemon) Shutdown(ctx context.Context) error {
281 d.once.Do(func() { close(d.finished) })
282 err := d.listener.Close()
283 finished := make(chan struct{}, 1)
284 go func() {
285 d.wg.Wait()
286 finished <- struct{}{}
287 }()
288 select {
289 case <-ctx.Done():
290 return ctx.Err()
291 case <-finished:
292 return err
293 }
294}
295
296type serverConn struct {
297 net.Conn
298
299 idleTimeout time.Duration
300 maxDeadline time.Time
301 closeCanceler context.CancelFunc
302}
303
304func (c *serverConn) Write(p []byte) (n int, err error) {
305 c.updateDeadline()
306 n, err = c.Conn.Write(p)
307 if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil {
308 c.closeCanceler()
309 }
310 return
311}
312
313func (c *serverConn) Read(b []byte) (n int, err error) {
314 c.updateDeadline()
315 n, err = c.Conn.Read(b)
316 if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil {
317 c.closeCanceler()
318 }
319 return
320}
321
322func (c *serverConn) Close() (err error) {
323 err = c.Conn.Close()
324 if c.closeCanceler != nil {
325 c.closeCanceler()
326 }
327 return
328}
329
330func (c *serverConn) updateDeadline() {
331 switch {
332 case c.idleTimeout > 0:
333 idleDeadline := time.Now().Add(c.idleTimeout)
334 if idleDeadline.Unix() < c.maxDeadline.Unix() || c.maxDeadline.IsZero() {
335 c.Conn.SetDeadline(idleDeadline)
336 return
337 }
338 fallthrough
339 default:
340 c.Conn.SetDeadline(c.maxDeadline)
341 }
342}