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