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