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