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/server/config"
16 "github.com/charmbracelet/soft-serve/server/git"
17 "github.com/go-git/go-git/v5/plumbing/format/pktline"
18)
19
20// ErrServerClosed indicates that the server has been closed.
21var ErrServerClosed = errors.New("git: Server closed")
22
23// Daemon represents a Git daemon.
24type Daemon struct {
25 auth git.Hooks
26 listener net.Listener
27 addr string
28 exit 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, auth git.Hooks) (*Daemon, error) {
37 addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Git.Port)
38 d := &Daemon{
39 addr: addr,
40 auth: auth,
41 exit: make(chan struct{}),
42 cfg: cfg,
43 conns: make(map[net.Conn]struct{}),
44 }
45 listener, err := net.Listen("tcp", d.addr)
46 if err != nil {
47 return nil, err
48 }
49 d.listener = listener
50 d.wg.Add(1)
51 return d, nil
52}
53
54// Start starts the Git TCP daemon.
55func (d *Daemon) Start() error {
56 // set up channel on which to send accepted connections
57 listen := make(chan net.Conn, d.cfg.Git.MaxConnections)
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.exit:
71 if err := d.Close(); err != nil {
72 return err
73 }
74 return ErrServerClosed
75 }
76 }
77}
78
79func fatal(c net.Conn, err error) {
80 git.WritePktline(c, err)
81 if err := c.Close(); err != nil {
82 log.Printf("git: error closing connection: %v", err)
83 }
84}
85
86// acceptConnection accepts connections on the listener.
87func (d *Daemon) acceptConnection(listener net.Listener, listen chan<- net.Conn) {
88 defer d.wg.Done()
89 for {
90 conn, err := listener.Accept()
91 if err != nil {
92 select {
93 case <-d.exit:
94 log.Printf("git: listener closed")
95 return
96 default:
97 log.Printf("git: error accepting connection: %v", err)
98 continue
99 }
100 }
101 listen <- conn
102 }
103}
104
105// handleClient handles a git protocol client.
106func (d *Daemon) handleClient(c net.Conn) {
107 d.conns[c] = struct{}{}
108 defer delete(d.conns, c)
109
110 // Close connection if there are too many open connections.
111 if len(d.conns) >= d.cfg.Git.MaxConnections {
112 log.Printf("git: max connections reached, closing %s", c.RemoteAddr())
113 fatal(c, git.ErrMaxConns)
114 return
115 }
116
117 // Set connection timeout.
118 if err := c.SetDeadline(time.Now().Add(time.Duration(d.cfg.Git.MaxTimeout) * time.Second)); err != nil {
119 log.Printf("git: error setting deadline: %v", err)
120 fatal(c, git.ErrSystemMalfunction)
121 return
122 }
123
124 readc := make(chan struct{}, 1)
125 go func() {
126 select {
127 case <-time.After(time.Duration(d.cfg.Git.MaxReadTimeout) * time.Second):
128 log.Printf("git: read timeout from %s", c.RemoteAddr())
129 fatal(c, git.ErrMaxTimeout)
130 case <-readc:
131 }
132 }()
133
134 s := pktline.NewScanner(c)
135 if !s.Scan() {
136 if err := s.Err(); err != nil {
137 log.Printf("git: error scanning pktline: %v", err)
138 fatal(c, git.ErrSystemMalfunction)
139 }
140 return
141 }
142 readc <- struct{}{}
143
144 line := s.Bytes()
145 split := bytes.SplitN(line, []byte{' '}, 2)
146 if len(split) != 2 {
147 return
148 }
149
150 var repo string
151 cmd := string(split[0])
152 opts := bytes.Split(split[1], []byte{'\x00'})
153 if len(opts) == 0 {
154 return
155 }
156 repo = filepath.Clean(string(opts[0]))
157
158 log.Printf("git: connect %s %s %s", c.RemoteAddr(), cmd, repo)
159 defer log.Printf("git: disconnect %s %s %s", c.RemoteAddr(), cmd, repo)
160 repo = strings.TrimPrefix(repo, "/")
161 auth := d.auth.AuthRepo(strings.TrimSuffix(repo, ".git"), nil)
162 if auth < git.ReadOnlyAccess {
163 fatal(c, git.ErrNotAuthed)
164 return
165 }
166 // git bare repositories should end in ".git"
167 // https://git-scm.com/docs/gitrepository-layout
168 if !strings.HasSuffix(repo, ".git") {
169 repo += ".git"
170 }
171
172 err := git.GitPack(c, c, c, cmd, d.cfg.RepoPath(), repo)
173 if err == git.ErrInvalidRepo {
174 trimmed := strings.TrimSuffix(repo, ".git")
175 log.Printf("git: invalid repo %q trying again %q", repo, trimmed)
176 err = git.GitPack(c, c, c, cmd, d.cfg.RepoPath(), trimmed)
177 }
178 if err != nil {
179 fatal(c, err)
180 return
181 }
182}
183
184// Close closes the underlying listener.
185func (d *Daemon) Close() error {
186 d.once.Do(func() { close(d.exit) })
187 return d.listener.Close()
188}
189
190// Shutdown gracefully shuts down the daemon.
191func (d *Daemon) Shutdown(_ context.Context) error {
192 d.once.Do(func() { close(d.exit) })
193 d.wg.Wait()
194 return nil
195}