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