Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

395 commit(s) on main

update readme Keenan Nemetz · 30m ago 32290114
history blame raw 12.8 KiB
1// Package githttp serves anonymous, read-only git over smart HTTP.
2//
3// Writes live on SSH, so this package has no push path at all: no chunked
4// spooling, no push semaphore. What remains is upload-pack behind a visibility
5// check. Private repositories answer 404 rather than 403, because a 403 confirms
6// that the repository exists.
7//
8// Read-only is not the same as cheap. This handler is reachable without an
9// account, so every way it can spend a resource is bounded: the request body, the
10// number of clones at once, the rate from one address, the time a backend may
11// run, and the shape of the path it will accept. A read path can be bounded
12// tightly in a way a push path cannot — a fetch negotiation is a few hundred
13// kilobytes at most — so these caps are small and absolute rather than
14// best-effort.
15package githttp
16
17import (
18 "bufio"
19 "bytes"
20 "context"
21 "errors"
22 "fmt"
23 "io"
24 "log/slog"
25 "net"
26 "net/http"
27 "os"
28 "os/exec"
29 "strconv"
30 "strings"
31 "sync"
32 "sync/atomic"
33 "time"
34
35 "rodent.software/vole/forge/access"
36 "rodent.software/vole/forge/repo"
37)
38
39const (
40 // maxUploadPackBody is the largest POST body a fetch needs. `want` and `have`
41 // lines are tens of bytes each and protocol v2 negotiation is a single round
42 // trip, so this is generous by two orders of magnitude — and, unlike a push,
43 // it can be a hard cap rather than a spooling threshold.
44 maxUploadPackBody = 2 << 20
45 // defaultConcurrent and defaultPerClient bound the load one instance accepts
46 // from the anonymous read path. They are defaults, not policy: an operator
47 // with a proxy in front raises them.
48 defaultConcurrent = 16
49 defaultPerClient = 8
50 // defaultRequestTimeout is how long one clone may hold a backend process. A
51 // large clone over a slow link is minutes; a stalled one is hours, and that is
52 // what this stops.
53 defaultRequestTimeout = 10 * time.Minute
54)
55
56type Config struct {
57 // Root is the directory containing <name>.git bare repos.
58 Root string
59 // Open resolves a repo by name.
60 Open func(name string) (*repo.Meta, error)
61 Log *slog.Logger
62 // MaxConcurrent bounds simultaneous reads on the transport.
63 MaxConcurrent int
64 // MaxPerClient bounds simultaneous reads from one address. Zero disables it.
65 MaxPerClient int
66 // ClientIPHeader names the header a trusted proxy sets with the client
67 // address. Empty trusts only the connection's own address, which is correct
68 // when nothing sits in front of Vole and wrong behind a proxy: every visitor
69 // would then share one identity and one per-client budget.
70 ClientIPHeader string
71 // RequestTimeout bounds one read, process included.
72 RequestTimeout time.Duration
73}
74
75type Proxy struct {
76 cfg Config
77 sem chan struct{}
78
79 mu sync.Mutex
80 perClient map[string]int
81
82 stats struct {
83 reads atomic.Int64
84 hidden atomic.Int64
85 pushes atomic.Int64
86 busy atomic.Int64
87 timedOut atomic.Int64
88 }
89}
90
91func New(cfg Config) *Proxy {
92 if cfg.Log == nil {
93 cfg.Log = slog.Default()
94 }
95 if cfg.MaxConcurrent <= 0 {
96 cfg.MaxConcurrent = defaultConcurrent
97 }
98 if cfg.MaxPerClient <= 0 {
99 cfg.MaxPerClient = defaultPerClient
100 }
101 if cfg.RequestTimeout <= 0 {
102 cfg.RequestTimeout = defaultRequestTimeout
103 }
104 return &Proxy{cfg: cfg, sem: make(chan struct{}, cfg.MaxConcurrent), perClient: map[string]int{}}
105}
106
107func (p *Proxy) Stats() map[string]int64 {
108 return map[string]int64{
109 "reads": p.stats.reads.Load(),
110 "hidden": p.stats.hidden.Load(),
111 "push_attempts": p.stats.pushes.Load(),
112 "refused_busy": p.stats.busy.Load(),
113 "timed_out": p.stats.timedOut.Load(),
114 }
115}
116
117// Route parses /<name>.git/<rest>, a repository's transport. The suffix is what
118// keeps the transport apart from the page at /<name>: a repository name may not
119// end in ".git" (see repo.ValidateName), so the two can never name the same
120// repository.
121func Route(p string) (name, rest string, ok bool) {
122 trimmed := strings.TrimPrefix(p, "/")
123 i := strings.IndexByte(trimmed, '/')
124 if i <= 0 {
125 return "", "", false
126 }
127 name = trimmed[:i]
128 rest = trimmed[i+1:]
129 if !strings.HasSuffix(name, ".git") {
130 return "", "", false
131 }
132 name = strings.TrimSuffix(name, ".git")
133 // "/.git/info/refs" names no repository. The store refuses an empty name as
134 // well, but a route that answers ok has to mean a name the rest of the path
135 // can be applied to, or the next caller has to remember to check.
136 if name == "" {
137 return "", "", false
138 }
139 return name, rest, true
140}
141
142// IsPush reports whether a request is asking to write.
143func IsPush(rest, rawQuery string) bool {
144 if rest == "info/refs" {
145 return parseQuery(rawQuery)["service"] == "git-receive-pack"
146 }
147 return strings.HasPrefix(rest, "git-receive-pack")
148}
149
150func parseQuery(q string) map[string]string {
151 out := map[string]string{}
152 for _, pair := range strings.Split(q, "&") {
153 if pair == "" {
154 continue
155 }
156 k, v, _ := strings.Cut(pair, "=")
157 out[k] = v
158 }
159 return out
160}
161
162func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
163 name, rest, ok := Route(r.URL.Path)
164 if !ok {
165 http.NotFound(w, r)
166 return
167 }
168 m, err := p.cfg.Open(name)
169 if err != nil {
170 http.NotFound(w, r)
171 return
172 }
173 a, err := m.ACL()
174 if err != nil {
175 p.cfg.Log.Error("read acl", "repo", name, "err", err)
176 http.Error(w, "repository unavailable", http.StatusInternalServerError)
177 return
178 }
179 // The reader has no identity here, so the default role is the only thing
180 // that can grant access. Anything else is indistinguishable from absent.
181 if !a.Can("", access.Read) {
182 p.stats.hidden.Add(1)
183 http.NotFound(w, r)
184 return
185 }
186
187 if IsPush(rest, r.URL.RawQuery) {
188 p.stats.pushes.Add(1)
189 http.Error(w, "vole: pushes are not accepted over HTTP; use an SSH remote", http.StatusForbidden)
190 return
191 }
192 // Two endpoints exist, and each has exactly one method. Accepting the prefixes
193 // instead let anything shaped like a path through to the CGI, where containment
194 // depended on git's own handling of PATH_INFO.
195 switch {
196 case rest == "info/refs" && r.Method == http.MethodGet:
197 if svc := parseQuery(r.URL.RawQuery)["service"]; svc != "git-upload-pack" {
198 http.NotFound(w, r)
199 return
200 }
201 case rest == "git-upload-pack" && r.Method == http.MethodPost:
202 default:
203 http.NotFound(w, r)
204 return
205 }
206 if r.Method == http.MethodPost {
207 // CGI cannot express "length unknown", and an anonymous body with no length
208 // is a body with no bound.
209 if r.ContentLength < 0 {
210 http.Error(w, "vole: chunked request bodies are not supported on the read path",
211 http.StatusLengthRequired)
212 return
213 }
214 if r.ContentLength == 0 || r.ContentLength > maxUploadPackBody {
215 http.Error(w, "vole: request body is not a fetch negotiation", http.StatusRequestEntityTooLarge)
216 return
217 }
218 }
219
220 // Acquire after the checks that can reject the request outright: a 404 must
221 // not cost a slot a real clone could be using.
222 ip := clientIP(r, p.cfg.ClientIPHeader)
223 if !p.acquire(ip) {
224 p.stats.busy.Add(1)
225 w.Header().Set("Retry-After", "1")
226 http.Error(w, "vole: too many concurrent clones; retry shortly", http.StatusServiceUnavailable)
227 return
228 }
229 defer p.release(ip)
230
231 if err := p.backend(w, r, m, rest); err != nil {
232 if errors.Is(err, context.DeadlineExceeded) {
233 p.stats.timedOut.Add(1)
234 return // the client already has an answer; nothing left to send
235 }
236 p.cfg.Log.Error("git backend", "repo", name, "err", err)
237 return
238 }
239 p.stats.reads.Add(1)
240}
241
242// acquire takes a transport slot and a per-client slot, or reports that the
243// request was refused. It never blocks: a queue of waiting clones is another way
244// to spend memory without bound, and a 503 with Retry-After is honest about it.
245func (p *Proxy) acquire(ip string) bool {
246 select {
247 case p.sem <- struct{}{}:
248 default:
249 return false
250 }
251 if p.cfg.MaxPerClient <= 0 {
252 return true
253 }
254 p.mu.Lock()
255 n := p.perClient[ip]
256 p.mu.Unlock()
257 if n >= p.cfg.MaxPerClient {
258 <-p.sem
259 return false
260 }
261 p.mu.Lock()
262 p.perClient[ip] = n + 1
263 p.mu.Unlock()
264 return true
265}
266
267func (p *Proxy) release(ip string) {
268 if p.cfg.MaxPerClient > 0 {
269 p.mu.Lock()
270 if p.perClient[ip] <= 1 {
271 delete(p.perClient, ip)
272 } else {
273 p.perClient[ip]--
274 }
275 p.mu.Unlock()
276 }
277 <-p.sem
278}
279
280// clientIP is who a limit applies to. The connection's own address is the only
281// one that cannot be forged; a proxy header is used only when the operator named
282// one, and then the last entry wins because that is the one the nearest proxy
283// appended.
284func clientIP(r *http.Request, header string) string {
285 if header != "" {
286 if v := r.Header.Get(header); v != "" {
287 if i := strings.LastIndexByte(v, ','); i >= 0 {
288 v = v[i+1:]
289 }
290 if v = strings.TrimSpace(v); v != "" {
291 return v
292 }
293 }
294 }
295 if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
296 return host
297 }
298 return r.RemoteAddr
299}
300
301func (p *Proxy) backend(w http.ResponseWriter, r *http.Request, m *repo.Meta, rest string) error {
302 // The subprocess belongs to the request: a client that disconnects, or one that
303 // keeps a clone open past its budget, must not leave git running. WaitDelay
304 // bounds the reap once the context is done and the pipes are closed, so a
305 // backend that ignores stdin's closing cannot hold the goroutine either.
306 ctx, cancel := context.WithTimeout(r.Context(), p.cfg.RequestTimeout)
307 defer cancel()
308 cmd := exec.CommandContext(ctx, "git", "http-backend")
309 cmd.WaitDelay = 5 * time.Second
310 cmd.Env = append(os.Environ(), p.cgiEnv(r, m, rest)...)
311 stdin, err := cmd.StdinPipe()
312 if err != nil {
313 return err
314 }
315 stdout, err := cmd.StdoutPipe()
316 if err != nil {
317 return err
318 }
319 var stderr bytes.Buffer
320 cmd.Stderr = &stderr
321 if err := cmd.Start(); err != nil {
322 return err
323 }
324
325 go func() {
326 defer stdin.Close()
327 if r.Body == nil {
328 return
329 }
330 // A body that trips the cap, or a client that goes away mid-upload, ends
331 // the backend instead of being read to the end.
332 if _, err := io.Copy(stdin, r.Body); err != nil {
333 cancel()
334 }
335 }()
336 // Closing the request body is what unblocks that copy when the request ends:
337 // a Read on a body whose client has gone otherwise waits for the connection's
338 // own timeout, holding the backend's stdin open and the reap with it.
339 copyDone := make(chan struct{})
340 go func() {
341 select {
342 case <-ctx.Done():
343 r.Body.Close()
344 case <-copyDone:
345 }
346 }()
347 defer close(copyDone)
348
349 br := bufio.NewReaderSize(stdout, 256<<10)
350 status := http.StatusOK
351 headers := http.Header{}
352 for {
353 line, err := br.ReadString('\n')
354 if err != nil {
355 cancel()
356 cmd.Wait()
357 if errors.Is(ctx.Err(), context.DeadlineExceeded) {
358 http.Error(w, "vole: the fetch exceeded its time budget", http.StatusGatewayTimeout)
359 return context.DeadlineExceeded
360 }
361 msg := strings.TrimSpace(stderr.String())
362 if msg == "" {
363 msg = err.Error()
364 }
365 http.Error(w, "git backend: "+msg, http.StatusInternalServerError)
366 return fmt.Errorf("cgi headers: %w", err)
367 }
368 line = strings.TrimRight(line, "\r\n")
369 if line == "" {
370 break
371 }
372 k, v, ok := strings.Cut(line, ":")
373 if !ok {
374 continue
375 }
376 k, v = strings.TrimSpace(k), strings.TrimSpace(v)
377 if strings.EqualFold(k, "Status") {
378 if f := strings.Fields(v); len(f) > 0 {
379 if code, err := strconv.Atoi(f[0]); err == nil {
380 status = code
381 }
382 }
383 continue
384 }
385 headers.Add(k, v)
386 }
387
388 h := w.Header()
389 for k, vs := range headers {
390 if strings.EqualFold(k, "Content-Length") {
391 continue // streamed; let net/http choose framing
392 }
393 for _, v := range vs {
394 h.Add(k, v)
395 }
396 }
397 w.WriteHeader(status)
398 flusher, _ := w.(http.Flusher)
399 buf := make([]byte, 128<<10)
400 for {
401 n, rerr := br.Read(buf)
402 if n > 0 {
403 if _, werr := w.Write(buf[:n]); werr != nil {
404 // The reader is gone. Stop git rather than let it finish writing a
405 // pack nobody will receive.
406 cancel()
407 break
408 }
409 if flusher != nil {
410 flusher.Flush()
411 }
412 }
413 if rerr != nil {
414 break
415 }
416 }
417 // A non-zero exit here is git refusing a read; the client has the reason.
418 cmd.Wait()
419 if errors.Is(ctx.Err(), context.DeadlineExceeded) {
420 return context.DeadlineExceeded
421 }
422 return nil
423}
424
425func (p *Proxy) cgiEnv(r *http.Request, m *repo.Meta, rest string) []string {
426 length := r.ContentLength
427 if length < 0 {
428 length = 0
429 }
430 env := []string{
431 "GIT_PROJECT_ROOT=" + p.cfg.Root,
432 "GIT_HTTP_EXPORT_ALL=1",
433 "PATH_INFO=/" + m.Name + ".git/" + rest,
434 "REQUEST_METHOD=" + r.Method,
435 "QUERY_STRING=" + r.URL.RawQuery,
436 "REMOTE_ADDR=" + r.RemoteAddr,
437 "SERVER_PROTOCOL=" + r.Proto,
438 "GATEWAY_INTERFACE=CGI/1.1",
439 "CONTENT_LENGTH=" + strconv.FormatInt(length, 10),
440 }
441 if ct := r.Header.Get("Content-Type"); ct != "" {
442 env = append(env, "CONTENT_TYPE="+ct)
443 }
444 // http-backend reads GIT_PROTOCOL, not HTTP_GIT_PROTOCOL, for protocol v2.
445 if gp := r.Header.Get("Git-Protocol"); gp != "" {
446 env = append(env, "GIT_PROTOCOL="+gp)
447 }
448 for name, vals := range r.Header {
449 key := "HTTP_" + strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
450 switch key {
451 case "HTTP_CONTENT_TYPE", "HTTP_CONTENT_LENGTH", "HTTP_GIT_PROTOCOL", "HTTP_AUTHORIZATION":
452 continue
453 }
454 env = append(env, key+"="+strings.Join(vals, ","))
455 }
456 return env
457}
458