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