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