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