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.
Bound the anonymous Git HTTP read path
86991e7020a418a08a5e3a36ce8f296561392e3b · Keenan Nemetz · 13d ago · parent e05f1c48
Bound the anonymous Git HTTP read path The transport that needs no account had no bound on anything: the request body was capped only at "not negative", the backend ran without a request context or deadline, the path check accepted any suffix after `git-upload-pack` and handed it to the CGI as `PATH_INFO`, and the server set no `ReadTimeout` and no `MaxHeaderBytes`. Bound what the read side can spend, which is every dimension available to a fetch and none of the ones a push would need: a 2 MiB body cap with a required `Content-Length`, exactly two endpoints with one method and one service each so the CGI never sees an unvalidated path, `exec.CommandContext` plus `WaitDelay` plus an explicit kill so a disconnect or an expired budget stops git instead of leaving it running, and a concurrency semaphore with a per-client bound that answers 503 with Retry-After rather than queueing. The client address is the connection's own unless the operator names a proxy header, because behind a proxy the connection address is the proxy. Both limits and the header name are configuration, `git_http` turns the transport off entirely, and `status` lists them so an operator can see what is in force. `WriteTimeout` is deliberately not set: clone, archives, LFS objects and site files are streamed, so those flows are bounded where they are produced.
server/githttp/githttp.go +200−21
| @@ -1,43 +1,90 @@ | |||
| 1 | 1 | // Package githttp serves anonymous, read-only git over smart HTTP. | |
| 2 | 2 | // | |
| 3 | 3 | // Writes live on SSH, so this package has no push path at all: no chunked | |
| 4 | // spooling, no push semaphore, no byte cap. Those existed only for POST bodies | ||
| 5 | // that carried packs. What remains is upload-pack behind a visibility check. | ||
| 6 | // Private repositories answer 404 rather than 403, because a 403 confirms that | ||
| 7 | // the repository exists. | ||
| 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. | ||
| 8 | 15 | package githttp | |
| 9 | 16 | ||
| 10 | 17 | import ( | |
| 11 | 18 | "bufio" | |
| 12 | 19 | "bytes" | |
| 20 | "context" | ||
| 21 | "errors" | ||
| 13 | 22 | "fmt" | |
| 14 | 23 | "io" | |
| 15 | 24 | "log/slog" | |
| 25 | "net" | ||
| 16 | 26 | "net/http" | |
| 17 | 27 | "os" | |
| 18 | 28 | "os/exec" | |
| 19 | 29 | "strconv" | |
| 20 | 30 | "strings" | |
| 31 | "sync" | ||
| 21 | 32 | "sync/atomic" | |
| 33 | "time" | ||
| 22 | 34 | ||
| 23 | 35 | "github.com/rodent-software/vole/forge/access" | |
| 24 | 36 | "github.com/rodent-software/vole/forge/repo" | |
| 25 | 37 | ) | |
| 26 | 38 | ||
| 39 | const ( | ||
| 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 | |||
| 27 | 56 | type Config struct { | |
| 28 | 57 | // Root is the directory containing <name>.git bare repos. | |
| 29 | 58 | Root string | |
| 30 | 59 | // Open resolves a repo by name. | |
| 31 | 60 | Open func(name string) (*repo.Meta, error) | |
| 32 | 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 | ||
| 33 | 73 | } | |
| 34 | 74 | ||
| 35 | 75 | type Proxy struct { | |
| 36 | cfg Config | ||
| 76 | cfg Config | ||
| 77 | sem chan struct{} | ||
| 78 | |||
| 79 | mu sync.Mutex | ||
| 80 | perClient map[string]int | ||
| 81 | |||
| 37 | 82 | stats struct { | |
| 38 | reads atomic.Int64 | ||
| 39 | hidden atomic.Int64 | ||
| 40 | pushes atomic.Int64 | ||
| 83 | reads atomic.Int64 | ||
| 84 | hidden atomic.Int64 | ||
| 85 | pushes atomic.Int64 | ||
| 86 | busy atomic.Int64 | ||
| 87 | timedOut atomic.Int64 | ||
| 41 | 88 | } | |
| 42 | 89 | } | |
| 43 | 90 | ||
| @@ -45,7 +92,16 @@ func New(cfg Config) *Proxy { | |||
| 45 | 92 | if cfg.Log == nil { | |
| 46 | 93 | cfg.Log = slog.Default() | |
| 47 | 94 | } | |
| 48 | return &Proxy{cfg: cfg} | ||
| 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{}} | ||
| 49 | 105 | } | |
| 50 | 106 | ||
| 51 | 107 | func (p *Proxy) Stats() map[string]int64 { | |
| @@ -53,6 +109,8 @@ func (p *Proxy) Stats() map[string]int64 { | |||
| 53 | 109 | "reads": p.stats.reads.Load(), | |
| 54 | 110 | "hidden": p.stats.hidden.Load(), | |
| 55 | 111 | "push_attempts": p.stats.pushes.Load(), | |
| 112 | "refused_busy": p.stats.busy.Load(), | ||
| 113 | "timed_out": p.stats.timedOut.Load(), | ||
| 56 | 114 | } | |
| 57 | 115 | } | |
| 58 | 116 | ||
| @@ -125,31 +183,124 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 125 | 183 | http.Error(w, "vole: pushes are not accepted over HTTP; use an SSH remote", http.StatusForbidden) | |
| 126 | 184 | return | |
| 127 | 185 | } | |
| 128 | if rest != "info/refs" && !strings.HasPrefix(rest, "git-upload-pack") { | ||
| 186 | // Two endpoints exist, and each has exactly one method. Accepting the prefixes | ||
| 187 | // instead let anything shaped like a path through to the CGI, where containment | ||
| 188 | // depended on git's own handling of PATH_INFO. | ||
| 189 | switch { | ||
| 190 | case rest == "info/refs" && r.Method == http.MethodGet: | ||
| 191 | if svc := parseQuery(r.URL.RawQuery)["service"]; svc != "git-upload-pack" { | ||
| 192 | http.NotFound(w, r) | ||
| 193 | return | ||
| 194 | } | ||
| 195 | case rest == "git-upload-pack" && r.Method == http.MethodPost: | ||
| 196 | default: | ||
| 129 | 197 | http.NotFound(w, r) | |
| 130 | 198 | return | |
| 131 | 199 | } | |
| 132 | if r.Method != http.MethodGet && r.Method != http.MethodPost { | ||
| 133 | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | ||
| 134 | return | ||
| 200 | if r.Method == http.MethodPost { | ||
| 201 | // CGI cannot express "length unknown", and an anonymous body with no length | ||
| 202 | // is a body with no bound. | ||
| 203 | if r.ContentLength < 0 { | ||
| 204 | http.Error(w, "vole: chunked request bodies are not supported on the read path", | ||
| 205 | http.StatusLengthRequired) | ||
| 206 | return | ||
| 207 | } | ||
| 208 | if r.ContentLength == 0 || r.ContentLength > maxUploadPackBody { | ||
| 209 | http.Error(w, "vole: request body is not a fetch negotiation", http.StatusRequestEntityTooLarge) | ||
| 210 | return | ||
| 211 | } | ||
| 135 | 212 | } | |
| 136 | // upload-pack request bodies are small (wants/haves). CGI cannot express | ||
| 137 | // "length unknown", so refuse rather than buffer. | ||
| 138 | if r.Method == http.MethodPost && r.ContentLength < 0 { | ||
| 139 | http.Error(w, "vole: chunked request bodies are not supported on the read path", | ||
| 140 | http.StatusLengthRequired) | ||
| 213 | |||
| 214 | // Acquire after the checks that can reject the request outright: a 404 must | ||
| 215 | // not cost a slot a real clone could be using. | ||
| 216 | ip := clientIP(r, p.cfg.ClientIPHeader) | ||
| 217 | if !p.acquire(ip) { | ||
| 218 | p.stats.busy.Add(1) | ||
| 219 | w.Header().Set("Retry-After", "1") | ||
| 220 | http.Error(w, "vole: too many concurrent clones; retry shortly", http.StatusServiceUnavailable) | ||
| 141 | 221 | return | |
| 142 | 222 | } | |
| 223 | defer p.release(ip) | ||
| 143 | 224 | ||
| 144 | 225 | if err := p.backend(w, r, m, rest); err != nil { | |
| 226 | if errors.Is(err, context.DeadlineExceeded) { | ||
| 227 | p.stats.timedOut.Add(1) | ||
| 228 | return // the client already has an answer; nothing left to send | ||
| 229 | } | ||
| 145 | 230 | p.cfg.Log.Error("git backend", "repo", name, "err", err) | |
| 146 | 231 | return | |
| 147 | 232 | } | |
| 148 | 233 | p.stats.reads.Add(1) | |
| 149 | 234 | } | |
| 150 | 235 | ||
| 236 | // acquire takes a transport slot and a per-client slot, or reports that the | ||
| 237 | // request was refused. It never blocks: a queue of waiting clones is another way | ||
| 238 | // to spend memory without bound, and a 503 with Retry-After is honest about it. | ||
| 239 | func (p *Proxy) acquire(ip string) bool { | ||
| 240 | select { | ||
| 241 | case p.sem <- struct{}{}: | ||
| 242 | default: | ||
| 243 | return false | ||
| 244 | } | ||
| 245 | if p.cfg.MaxPerClient <= 0 { | ||
| 246 | return true | ||
| 247 | } | ||
| 248 | p.mu.Lock() | ||
| 249 | n := p.perClient[ip] | ||
| 250 | p.mu.Unlock() | ||
| 251 | if n >= p.cfg.MaxPerClient { | ||
| 252 | <-p.sem | ||
| 253 | return false | ||
| 254 | } | ||
| 255 | p.mu.Lock() | ||
| 256 | p.perClient[ip] = n + 1 | ||
| 257 | p.mu.Unlock() | ||
| 258 | return true | ||
| 259 | } | ||
| 260 | |||
| 261 | func (p *Proxy) release(ip string) { | ||
| 262 | if p.cfg.MaxPerClient > 0 { | ||
| 263 | p.mu.Lock() | ||
| 264 | if p.perClient[ip] <= 1 { | ||
| 265 | delete(p.perClient, ip) | ||
| 266 | } else { | ||
| 267 | p.perClient[ip]-- | ||
| 268 | } | ||
| 269 | p.mu.Unlock() | ||
| 270 | } | ||
| 271 | <-p.sem | ||
| 272 | } | ||
| 273 | |||
| 274 | // clientIP is who a limit applies to. The connection's own address is the only | ||
| 275 | // one that cannot be forged; a proxy header is used only when the operator named | ||
| 276 | // one, and then the last entry wins because that is the one the nearest proxy | ||
| 277 | // appended. | ||
| 278 | func clientIP(r *http.Request, header string) string { | ||
| 279 | if header != "" { | ||
| 280 | if v := r.Header.Get(header); v != "" { | ||
| 281 | if i := strings.LastIndexByte(v, ','); i >= 0 { | ||
| 282 | v = v[i+1:] | ||
| 283 | } | ||
| 284 | if v = strings.TrimSpace(v); v != "" { | ||
| 285 | return v | ||
| 286 | } | ||
| 287 | } | ||
| 288 | } | ||
| 289 | if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { | ||
| 290 | return host | ||
| 291 | } | ||
| 292 | return r.RemoteAddr | ||
| 293 | } | ||
| 294 | |||
| 151 | 295 | func (p *Proxy) backend(w http.ResponseWriter, r *http.Request, m *repo.Meta, rest string) error { | |
| 152 | cmd := exec.Command("git", "http-backend") | ||
| 296 | // The subprocess belongs to the request: a client that disconnects, or one that | ||
| 297 | // keeps a clone open past its budget, must not leave git running. WaitDelay | ||
| 298 | // bounds the reap once the context is done and the pipes are closed, so a | ||
| 299 | // backend that ignores stdin's closing cannot hold the goroutine either. | ||
| 300 | ctx, cancel := context.WithTimeout(r.Context(), p.cfg.RequestTimeout) | ||
| 301 | defer cancel() | ||
| 302 | cmd := exec.CommandContext(ctx, "git", "http-backend") | ||
| 303 | cmd.WaitDelay = 5 * time.Second | ||
| 153 | 304 | cmd.Env = append(os.Environ(), p.cgiEnv(r, m, rest)...) | |
| 154 | 305 | stdin, err := cmd.StdinPipe() | |
| 155 | 306 | if err != nil { | |
| @@ -167,10 +318,27 @@ func (p *Proxy) backend(w http.ResponseWriter, r *http.Request, m *repo.Meta, re | |||
| 167 | 318 | ||
| 168 | 319 | go func() { | |
| 169 | 320 | defer stdin.Close() | |
| 170 | if r.Body != nil { | ||
| 171 | io.Copy(stdin, r.Body) | ||
| 321 | if r.Body == nil { | ||
| 322 | return | ||
| 323 | } | ||
| 324 | // A body that trips the cap, or a client that goes away mid-upload, ends | ||
| 325 | // the backend instead of being read to the end. | ||
| 326 | if _, err := io.Copy(stdin, r.Body); err != nil { | ||
| 327 | cancel() | ||
| 172 | 328 | } | |
| 173 | 329 | }() | |
| 330 | // Closing the request body is what unblocks that copy when the request ends: | ||
| 331 | // a Read on a body whose client has gone otherwise waits for the connection's | ||
| 332 | // own timeout, holding the backend's stdin open and the reap with it. | ||
| 333 | copyDone := make(chan struct{}) | ||
| 334 | go func() { | ||
| 335 | select { | ||
| 336 | case <-ctx.Done(): | ||
| 337 | r.Body.Close() | ||
| 338 | case <-copyDone: | ||
| 339 | } | ||
| 340 | }() | ||
| 341 | defer close(copyDone) | ||
| 174 | 342 | ||
| 175 | 343 | br := bufio.NewReaderSize(stdout, 256<<10) | |
| 176 | 344 | status := http.StatusOK | |
| @@ -178,7 +346,12 @@ func (p *Proxy) backend(w http.ResponseWriter, r *http.Request, m *repo.Meta, re | |||
| 178 | 346 | for { | |
| 179 | 347 | line, err := br.ReadString('\n') | |
| 180 | 348 | if err != nil { | |
| 349 | cancel() | ||
| 181 | 350 | cmd.Wait() | |
| 351 | if errors.Is(ctx.Err(), context.DeadlineExceeded) { | ||
| 352 | http.Error(w, "vole: the fetch exceeded its time budget", http.StatusGatewayTimeout) | ||
| 353 | return context.DeadlineExceeded | ||
| 354 | } | ||
| 182 | 355 | msg := strings.TrimSpace(stderr.String()) | |
| 183 | 356 | if msg == "" { | |
| 184 | 357 | msg = err.Error() | |
| @@ -222,6 +395,9 @@ func (p *Proxy) backend(w http.ResponseWriter, r *http.Request, m *repo.Meta, re | |||
| 222 | 395 | n, rerr := br.Read(buf) | |
| 223 | 396 | if n > 0 { | |
| 224 | 397 | if _, werr := w.Write(buf[:n]); werr != nil { | |
| 398 | // The reader is gone. Stop git rather than let it finish writing a | ||
| 399 | // pack nobody will receive. | ||
| 400 | cancel() | ||
| 225 | 401 | break | |
| 226 | 402 | } | |
| 227 | 403 | if flusher != nil { | |
| @@ -234,6 +410,9 @@ func (p *Proxy) backend(w http.ResponseWriter, r *http.Request, m *repo.Meta, re | |||
| 234 | 410 | } | |
| 235 | 411 | // A non-zero exit here is git refusing a read; the client has the reason. | |
| 236 | 412 | cmd.Wait() | |
| 413 | if errors.Is(ctx.Err(), context.DeadlineExceeded) { | ||
| 414 | return context.DeadlineExceeded | ||
| 415 | } | ||
| 237 | 416 | return nil | |
| 238 | 417 | } | |
| 239 | 418 | ||