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/server/backend"
15 "github.com/charmbracelet/soft-serve/server/config"
16 "github.com/charmbracelet/soft-serve/server/git"
17 "github.com/charmbracelet/soft-serve/server/utils"
18 "github.com/go-git/go-git/v5/plumbing/format/pktline"
19 "github.com/prometheus/client_golang/prometheus"
20 "github.com/prometheus/client_golang/prometheus/promauto"
21)
22
23var (
24 uploadPackGitCounter = promauto.NewCounterVec(prometheus.CounterOpts{
25 Namespace: "soft_serve",
26 Subsystem: "git",
27 Name: "git_upload_pack_total",
28 Help: "The total number of git-upload-pack requests",
29 }, []string{"repo"})
30
31 uploadArchiveGitCounter = promauto.NewCounterVec(prometheus.CounterOpts{
32 Namespace: "soft_serve",
33 Subsystem: "git",
34 Name: "git_upload_archive_total",
35 Help: "The total number of git-upload-archive requests",
36 }, []string{"repo"})
37)
38
39var (
40
41 // ErrServerClosed indicates that the server has been closed.
42 ErrServerClosed = fmt.Errorf("git: %w", net.ErrClosed)
43)
44
45// GitDaemon represents a Git daemon.
46type GitDaemon struct {
47 ctx context.Context
48 listener net.Listener
49 addr string
50 finished chan struct{}
51 conns connections
52 cfg *config.Config
53 be backend.Backend
54 wg sync.WaitGroup
55 once sync.Once
56 logger *log.Logger
57}
58
59// NewDaemon returns a new Git daemon.
60func NewGitDaemon(ctx context.Context) (*GitDaemon, error) {
61 cfg := config.FromContext(ctx)
62 addr := cfg.Git.ListenAddr
63 d := &GitDaemon{
64 ctx: ctx,
65 addr: addr,
66 finished: make(chan struct{}, 1),
67 cfg: cfg,
68 be: backend.FromContext(ctx),
69 conns: connections{m: make(map[net.Conn]struct{})},
70 logger: log.FromContext(ctx).WithPrefix("gitdaemon"),
71 }
72 listener, err := net.Listen("tcp", d.addr)
73 if err != nil {
74 return nil, err
75 }
76 d.listener = listener
77 return d, nil
78}
79
80// Start starts the Git TCP daemon.
81func (d *GitDaemon) Start() error {
82 defer d.listener.Close() // nolint: errcheck
83
84 d.wg.Add(1)
85 defer d.wg.Done()
86
87 var tempDelay time.Duration
88 for {
89 conn, err := d.listener.Accept()
90 if err != nil {
91 select {
92 case <-d.finished:
93 return ErrServerClosed
94 default:
95 d.logger.Debugf("git: error accepting connection: %v", err)
96 }
97 if ne, ok := err.(net.Error); ok && ne.Temporary() {
98 if tempDelay == 0 {
99 tempDelay = 5 * time.Millisecond
100 } else {
101 tempDelay *= 2
102 }
103 if max := 1 * time.Second; tempDelay > max {
104 tempDelay = max
105 }
106 time.Sleep(tempDelay)
107 continue
108 }
109 return err
110 }
111
112 // Close connection if there are too many open connections.
113 if d.conns.Size()+1 >= d.cfg.Git.MaxConnections {
114 d.logger.Debugf("git: max connections reached, closing %s", conn.RemoteAddr())
115 d.fatal(conn, git.ErrMaxConnections)
116 continue
117 }
118
119 d.wg.Add(1)
120 go func() {
121 d.handleClient(conn)
122 d.wg.Done()
123 }()
124 }
125}
126
127func (d *GitDaemon) fatal(c net.Conn, err error) {
128 git.WritePktline(c, err)
129 if err := c.Close(); err != nil {
130 d.logger.Debugf("git: error closing connection: %v", err)
131 }
132}
133
134// handleClient handles a git protocol client.
135func (d *GitDaemon) handleClient(conn net.Conn) {
136 ctx, cancel := context.WithCancel(context.Background())
137 idleTimeout := time.Duration(d.cfg.Git.IdleTimeout) * time.Second
138 c := &serverConn{
139 Conn: conn,
140 idleTimeout: idleTimeout,
141 closeCanceler: cancel,
142 }
143 if d.cfg.Git.MaxTimeout > 0 {
144 dur := time.Duration(d.cfg.Git.MaxTimeout) * time.Second
145 c.maxDeadline = time.Now().Add(dur)
146 }
147 d.conns.Add(c)
148 defer func() {
149 d.conns.Close(c)
150 }()
151
152 readc := make(chan struct{}, 1)
153 s := pktline.NewScanner(c)
154 go func() {
155 if !s.Scan() {
156 if err := s.Err(); err != nil {
157 if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
158 d.fatal(c, git.ErrTimeout)
159 } else {
160 d.logger.Debugf("git: error scanning pktline: %v", err)
161 d.fatal(c, git.ErrSystemMalfunction)
162 }
163 }
164 return
165 }
166 readc <- struct{}{}
167 }()
168
169 select {
170 case <-ctx.Done():
171 if err := ctx.Err(); err != nil {
172 d.logger.Debugf("git: connection context error: %v", err)
173 }
174 return
175 case <-readc:
176 line := s.Bytes()
177 split := bytes.SplitN(line, []byte{' '}, 2)
178 if len(split) != 2 {
179 d.fatal(c, git.ErrInvalidRequest)
180 return
181 }
182
183 var handler git.ServiceHandler
184 var counter *prometheus.CounterVec
185 service := git.Service(split[0])
186 switch service {
187 case git.UploadPackService:
188 handler = git.UploadPack
189 counter = uploadPackGitCounter
190 case git.UploadArchiveService:
191 handler = git.UploadArchive
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.WithContext(ctx)
231 if !be.AllowKeyless() {
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(repo); err != nil {
251 d.fatal(c, git.ErrInvalidRepo)
252 return
253 }
254
255 auth := be.AccessLevel(name, "")
256 if auth < backend.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 }
267
268 // Add git protocol environment variable.
269 if len(extraParams) > 0 {
270 var gitProto string
271 for k, v := range extraParams {
272 if len(gitProto) > 0 {
273 gitProto += ":"
274 }
275 gitProto += k + "=" + v
276 }
277 envs = append(envs, "GIT_PROTOCOL="+gitProto)
278 }
279
280 envs = append(envs, d.cfg.Environ()...)
281
282 cmd := git.ServiceCommand{
283 Stdin: c,
284 Stdout: c,
285 Stderr: c,
286 Env: envs,
287 Dir: filepath.Join(reposDir, repo),
288 }
289
290 if err := handler(ctx, cmd); err != nil {
291 d.logger.Debugf("git: error handling request: %v", err)
292 d.fatal(c, err)
293 return
294 }
295
296 counter.WithLabelValues(name)
297 }
298}
299
300// Close closes the underlying listener.
301func (d *GitDaemon) Close() error {
302 d.once.Do(func() { close(d.finished) })
303 err := d.listener.Close()
304 d.conns.CloseAll()
305 return err
306}
307
308// Shutdown gracefully shuts down the daemon.
309func (d *GitDaemon) Shutdown(ctx context.Context) error {
310 d.once.Do(func() { close(d.finished) })
311 err := d.listener.Close()
312 finished := make(chan struct{}, 1)
313 go func() {
314 d.wg.Wait()
315 finished <- struct{}{}
316 }()
317 select {
318 case <-ctx.Done():
319 return ctx.Err()
320 case <-finished:
321 return err
322 }
323}