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