1package daemon
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "fmt"
8 "net"
9 "path/filepath"
10 "strings"
11 "sync"
12 "sync/atomic"
13 "time"
14
15 "charm.land/log/v2"
16 "github.com/charmbracelet/soft-serve/pkg/access"
17 "github.com/charmbracelet/soft-serve/pkg/backend"
18 "github.com/charmbracelet/soft-serve/pkg/config"
19 "github.com/charmbracelet/soft-serve/pkg/git"
20 "github.com/charmbracelet/soft-serve/pkg/utils"
21 "github.com/go-git/go-git/v5/plumbing/format/pktline"
22 "github.com/prometheus/client_golang/prometheus"
23 "github.com/prometheus/client_golang/prometheus/promauto"
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
42// ErrServerClosed indicates that the server has been closed.
43var ErrServerClosed = fmt.Errorf("git: %w", net.ErrClosed)
44
45// GitDaemon represents a Git daemon.
46type GitDaemon struct {
47 ctx context.Context
48 addr string
49 finished chan struct{}
50 conns connections
51 cfg *config.Config
52 be *backend.Backend
53 wg sync.WaitGroup
54 once sync.Once
55 logger *log.Logger
56 done atomic.Bool // indicates if the server has been closed
57 listeners []net.Listener
58 liMu sync.Mutex
59}
60
61// NewGitDaemon returns a new Git daemon.
62func NewGitDaemon(ctx context.Context) (*GitDaemon, error) {
63 cfg := config.FromContext(ctx)
64 addr := cfg.Git.ListenAddr
65 d := &GitDaemon{
66 ctx: ctx,
67 addr: addr,
68 finished: make(chan struct{}, 1),
69 cfg: cfg,
70 be: backend.FromContext(ctx),
71 conns: connections{m: make(map[net.Conn]struct{})},
72 logger: log.FromContext(ctx).WithPrefix("gitdaemon"),
73 }
74 return d, nil
75}
76
77// ListenAndServe starts the Git TCP daemon.
78func (d *GitDaemon) ListenAndServe() error {
79 if d.done.Load() {
80 return ErrServerClosed
81 }
82 var cfg net.ListenConfig
83 listener, err := cfg.Listen(d.ctx, "tcp", d.addr)
84 if err != nil {
85 return err
86 }
87 return d.Serve(listener)
88}
89
90// Serve listens on the TCP network address and serves Git requests.
91func (d *GitDaemon) Serve(listener net.Listener) error {
92 if d.done.Load() {
93 return ErrServerClosed
94 }
95
96 d.wg.Add(1)
97 defer d.wg.Done()
98 d.liMu.Lock()
99 d.listeners = append(d.listeners, listener)
100 d.liMu.Unlock()
101
102 var tempDelay time.Duration
103 for {
104 conn, err := listener.Accept()
105 if err != nil {
106 select {
107 case <-d.finished:
108 return ErrServerClosed
109 default:
110 d.logger.Debugf("git: error accepting connection: %v", err)
111 }
112 if ne, ok := err.(net.Error); ok && ne.Temporary() {
113 if tempDelay == 0 {
114 tempDelay = 5 * time.Millisecond
115 } else {
116 tempDelay *= 2
117 }
118 if max := 1 * time.Second; tempDelay > max { //nolint:revive
119 tempDelay = max
120 }
121 time.Sleep(tempDelay)
122 continue
123 }
124 return err
125 }
126
127 // Close connection if there are too many open connections.
128 if d.conns.Size()+1 >= d.cfg.Git.MaxConnections {
129 d.logger.Debugf("git: max connections reached, closing %s", conn.RemoteAddr())
130 d.fatal(conn, git.ErrMaxConnections)
131 continue
132 }
133
134 d.wg.Add(1)
135 go func() {
136 d.handleClient(conn)
137 d.wg.Done()
138 }()
139 }
140}
141
142func (d *GitDaemon) fatal(c net.Conn, err error) {
143 git.WritePktlineErr(c, err) //nolint: errcheck
144 if err := c.Close(); err != nil {
145 d.logger.Debugf("git: error closing connection: %v", err)
146 }
147}
148
149// handleClient handles a git protocol client.
150func (d *GitDaemon) handleClient(conn net.Conn) {
151 ctx, cancel := context.WithCancel(context.Background())
152 idleTimeout := time.Duration(d.cfg.Git.IdleTimeout) * time.Second
153 c := &serverConn{
154 Conn: conn,
155 idleTimeout: idleTimeout,
156 closeCanceler: cancel,
157 }
158 if d.cfg.Git.MaxTimeout > 0 {
159 dur := time.Duration(d.cfg.Git.MaxTimeout) * time.Second
160 c.maxDeadline = time.Now().Add(dur)
161 }
162 d.conns.Add(c)
163 defer func() {
164 d.conns.Close(c) //nolint: errcheck
165 }()
166
167 errc := make(chan error, 1)
168
169 s := pktline.NewScanner(c)
170 go func() {
171 if !s.Scan() {
172 if err := s.Err(); err != nil {
173 errc <- err
174 }
175 }
176 errc <- nil
177 }()
178
179 select {
180 case <-ctx.Done():
181 if err := ctx.Err(); err != nil {
182 d.logger.Debugf("git: connection context error: %v", err)
183 d.fatal(c, git.ErrTimeout)
184 }
185 return
186 case err := <-errc:
187 if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
188 d.fatal(c, git.ErrTimeout)
189 return
190 } else if err != nil {
191 d.logger.Debugf("git: error scanning pktline: %v", err)
192 d.fatal(c, git.ErrSystemMalfunction)
193 return
194 }
195
196 line := s.Bytes()
197 split := bytes.SplitN(line, []byte{' '}, 2)
198 if len(split) != 2 {
199 d.fatal(c, git.ErrInvalidRequest)
200 return
201 }
202
203 var counter *prometheus.CounterVec
204 service := git.Service(split[0])
205 switch service {
206 case git.UploadPackService:
207 counter = uploadPackGitCounter
208 case git.UploadArchiveService:
209 counter = uploadArchiveGitCounter
210 default:
211 d.fatal(c, git.ErrInvalidRequest)
212 return
213 }
214
215 opts := bytes.SplitN(split[1], []byte{0}, 3)
216 if len(opts) < 2 {
217 d.fatal(c, git.ErrInvalidRequest) //nolint: errcheck
218 return
219 }
220
221 host := strings.TrimPrefix(string(opts[1]), "host=")
222 extraParams := map[string]string{}
223
224 if len(opts) > 2 {
225 buf := bytes.TrimPrefix(opts[2], []byte{0})
226 for _, o := range bytes.Split(buf, []byte{0}) {
227 opt := string(o)
228 if opt == "" {
229 continue
230 }
231
232 kv := strings.SplitN(opt, "=", 2)
233 if len(kv) != 2 {
234 d.logger.Errorf("git: invalid option %q", opt)
235 continue
236 }
237
238 extraParams[kv[0]] = kv[1]
239 }
240
241 version := extraParams["version"]
242 if version != "" {
243 d.logger.Debugf("git: protocol version %s", version)
244 }
245 }
246
247 be := d.be
248 if !be.AllowKeyless(ctx) {
249 d.fatal(c, git.ErrNotAuthed)
250 return
251 }
252
253 name := utils.SanitizeRepo(string(opts[0]))
254 d.logger.Debugf("git: connect %s %s %s", c.RemoteAddr(), service, name)
255 defer d.logger.Debugf("git: disconnect %s %s %s", c.RemoteAddr(), service, name)
256
257 // git bare repositories should end in ".git"
258 // https://git-scm.com/docs/gitrepository-layout
259 repo := name + ".git"
260 reposDir := filepath.Join(d.cfg.DataPath, "repos")
261 if err := git.EnsureWithin(reposDir, repo); err != nil {
262 d.logger.Debugf("git: error ensuring repo path: %v", err)
263 d.fatal(c, git.ErrInvalidRepo)
264 return
265 }
266
267 if _, err := d.be.Repository(ctx, repo); err != nil {
268 d.fatal(c, git.ErrInvalidRepo)
269 return
270 }
271
272 auth := be.AccessLevel(ctx, name, "")
273 if auth < access.ReadOnlyAccess {
274 d.fatal(c, git.ErrNotAuthed)
275 return
276 }
277
278 // Environment variables to pass down to git hooks.
279 envs := []string{
280 "SOFT_SERVE_REPO_NAME=" + name,
281 "SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repo),
282 "SOFT_SERVE_HOST=" + host,
283 "SOFT_SERVE_LOG_PATH=" + filepath.Join(d.cfg.DataPath, "log", "hooks.log"),
284 }
285
286 // Add git protocol environment variable.
287 if len(extraParams) > 0 {
288 var gitProto string
289 for k, v := range extraParams {
290 if len(gitProto) > 0 {
291 gitProto += ":"
292 }
293 gitProto += k + "=" + v
294 }
295 envs = append(envs, "GIT_PROTOCOL="+gitProto)
296 }
297
298 envs = append(envs, d.cfg.Environ()...)
299
300 cmd := git.ServiceCommand{
301 Stdin: c,
302 Stdout: c,
303 Stderr: c,
304 Env: envs,
305 Dir: filepath.Join(reposDir, repo),
306 }
307
308 if err := service.Handler(ctx, cmd); err != nil {
309 d.logger.Debugf("git: error handling request: %v", err)
310 d.fatal(c, err)
311 return
312 }
313
314 counter.WithLabelValues(name)
315 }
316}
317
318// Close closes the underlying listener.
319func (d *GitDaemon) Close() error {
320 err := d.closeListener()
321 d.conns.CloseAll() //nolint: errcheck
322 return err
323}
324
325// closeListener closes the listener and the finished channel.
326func (d *GitDaemon) closeListener() error {
327 if d.done.Load() {
328 return ErrServerClosed
329 }
330 var err error
331 d.liMu.Lock()
332 for _, l := range d.listeners {
333 if err = l.Close(); err != nil {
334 err = errors.Join(err, fmt.Errorf("close listener %s: %w", l.Addr(), err))
335 }
336 }
337 d.listeners = d.listeners[:0]
338 d.liMu.Unlock()
339 d.once.Do(func() {
340 d.done.Store(true)
341 close(d.finished)
342 })
343 return err
344}
345
346// Shutdown gracefully shuts down the daemon.
347func (d *GitDaemon) Shutdown(ctx context.Context) error {
348 if d.done.Load() {
349 return ErrServerClosed
350 }
351
352 err := d.closeListener()
353 finished := make(chan struct{}, 1)
354 go func() {
355 defer close(finished)
356 d.wg.Wait()
357 }()
358 select {
359 case <-ctx.Done():
360 return ctx.Err()
361 case <-finished:
362 return err
363 }
364}