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