1package daemon
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "fmt"
8 "log"
9 "net"
10 "path/filepath"
11 "strings"
12 "sync"
13 "time"
14
15 "github.com/charmbracelet/soft-serve/proto"
16 "github.com/charmbracelet/soft-serve/server/config"
17 "github.com/charmbracelet/soft-serve/server/git"
18 "github.com/go-git/go-git/v5/plumbing/format/pktline"
19)
20
21// ErrServerClosed indicates that the server has been closed.
22var ErrServerClosed = errors.New("git: Server closed")
23
24// connections synchronizes access to to a net.Conn pool.
25type connections struct {
26 m map[net.Conn]struct{}
27 mu sync.Mutex
28}
29
30func (m *connections) Add(c net.Conn) {
31 m.mu.Lock()
32 defer m.mu.Unlock()
33 m.m[c] = struct{}{}
34}
35
36func (m *connections) Close(c net.Conn) {
37 m.mu.Lock()
38 defer m.mu.Unlock()
39 _ = c.Close()
40 delete(m.m, c)
41}
42
43func (m *connections) Size() int {
44 m.mu.Lock()
45 defer m.mu.Unlock()
46 return len(m.m)
47}
48
49func (m *connections) CloseAll() {
50 m.mu.Lock()
51 defer m.mu.Unlock()
52 for c := range m.m {
53 _ = c.Close()
54 delete(m.m, c)
55 }
56}
57
58// Daemon represents a Git daemon.
59type Daemon struct {
60 listener net.Listener
61 addr string
62 finished chan struct{}
63 conns connections
64 cfg *config.Config
65 wg sync.WaitGroup
66 once sync.Once
67}
68
69// NewDaemon returns a new Git daemon.
70func NewDaemon(cfg *config.Config) (*Daemon, error) {
71 addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Git.Port)
72 d := &Daemon{
73 addr: addr,
74 finished: make(chan struct{}, 1),
75 cfg: cfg,
76 conns: connections{m: make(map[net.Conn]struct{})},
77 }
78 listener, err := net.Listen("tcp", d.addr)
79 if err != nil {
80 return nil, err
81 }
82 d.listener = listener
83 return d, nil
84}
85
86// Start starts the Git TCP daemon.
87func (d *Daemon) Start() error {
88 defer d.listener.Close()
89
90 d.wg.Add(1)
91 defer d.wg.Done()
92
93 var tempDelay time.Duration
94 for {
95 conn, err := d.listener.Accept()
96 if err != nil {
97 select {
98 case <-d.finished:
99 return ErrServerClosed
100 default:
101 log.Printf("git: error accepting connection: %v", err)
102 }
103 if ne, ok := err.(net.Error); ok && ne.Temporary() {
104 if tempDelay == 0 {
105 tempDelay = 5 * time.Millisecond
106 } else {
107 tempDelay *= 2
108 }
109 if max := 1 * time.Second; tempDelay > max {
110 tempDelay = max
111 }
112 time.Sleep(tempDelay)
113 continue
114 }
115 return err
116 }
117
118 // Close connection if there are too many open connections.
119 if d.conns.Size()+1 >= d.cfg.Git.MaxConnections {
120 log.Printf("git: max connections reached, closing %s", conn.RemoteAddr())
121 fatal(conn, git.ErrMaxConnections)
122 continue
123 }
124
125 d.wg.Add(1)
126 go func() {
127 d.handleClient(conn)
128 d.wg.Done()
129 }()
130 }
131}
132
133func fatal(c net.Conn, err error) {
134 git.WritePktline(c, err)
135 if err := c.Close(); err != nil {
136 log.Printf("git: error closing connection: %v", err)
137 }
138}
139
140// handleClient handles a git protocol client.
141func (d *Daemon) handleClient(conn net.Conn) {
142 ctx, cancel := context.WithCancel(context.Background())
143 idleTimeout := time.Duration(d.cfg.Git.IdleTimeout) * time.Second
144 c := &serverConn{
145 Conn: conn,
146 idleTimeout: idleTimeout,
147 closeCanceler: cancel,
148 }
149 if d.cfg.Git.MaxTimeout > 0 {
150 dur := time.Duration(d.cfg.Git.MaxTimeout) * time.Second
151 c.maxDeadline = time.Now().Add(dur)
152 }
153 d.conns.Add(c)
154 defer func() {
155 d.conns.Close(c)
156 }()
157
158 readc := make(chan struct{}, 1)
159 s := pktline.NewScanner(c)
160 go func() {
161 if !s.Scan() {
162 if err := s.Err(); err != nil {
163 if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
164 fatal(c, git.ErrTimeout)
165 } else {
166 log.Printf("git: error scanning pktline: %v", err)
167 fatal(c, git.ErrSystemMalfunction)
168 }
169 }
170 return
171 }
172 readc <- struct{}{}
173 }()
174
175 select {
176 case <-ctx.Done():
177 if err := ctx.Err(); err != nil {
178 log.Printf("git: connection context error: %v", err)
179 }
180 return
181 case <-readc:
182 line := s.Bytes()
183 split := bytes.SplitN(line, []byte{' '}, 2)
184 if len(split) != 2 {
185 fatal(c, git.ErrInvalidRequest)
186 return
187 }
188
189 var repo string
190 cmd := string(split[0])
191 opts := bytes.Split(split[1], []byte{'\x00'})
192 if len(opts) == 0 {
193 fatal(c, git.ErrInvalidRequest)
194 return
195 }
196 repo = filepath.Clean(string(opts[0]))
197
198 log.Printf("git: connect %s %s %s", c.RemoteAddr(), cmd, repo)
199 defer log.Printf("git: disconnect %s %s %s", c.RemoteAddr(), cmd, repo)
200 repo = strings.TrimPrefix(repo, "/")
201 auth := d.cfg.AuthRepo(strings.TrimSuffix(repo, ".git"), nil)
202 if auth < proto.ReadOnlyAccess {
203 fatal(c, git.ErrNotAuthed)
204 return
205 }
206 // git bare repositories should end in ".git"
207 // https://git-scm.com/docs/gitrepository-layout
208 if !strings.HasSuffix(repo, ".git") {
209 repo += ".git"
210 }
211
212 err := git.GitPack(c, c, c, cmd, d.cfg.RepoPath(), repo)
213 if err == git.ErrInvalidRepo {
214 trimmed := strings.TrimSuffix(repo, ".git")
215 log.Printf("git: invalid repo %q trying again %q", repo, trimmed)
216 err = git.GitPack(c, c, c, cmd, d.cfg.RepoPath(), trimmed)
217 }
218 if err != nil {
219 fatal(c, err)
220 return
221 }
222 }
223}
224
225// Close closes the underlying listener.
226func (d *Daemon) Close() error {
227 d.once.Do(func() { close(d.finished) })
228 err := d.listener.Close()
229 d.conns.CloseAll()
230 return err
231}
232
233// Shutdown gracefully shuts down the daemon.
234func (d *Daemon) Shutdown(ctx context.Context) error {
235 d.once.Do(func() { close(d.finished) })
236 err := d.listener.Close()
237 finished := make(chan struct{}, 1)
238 go func() {
239 d.wg.Wait()
240 finished <- struct{}{}
241 }()
242 select {
243 case <-ctx.Done():
244 return ctx.Err()
245 case <-finished:
246 return err
247 }
248}