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