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