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