vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 19m ago
4b8c9e1d
| 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. |
| 15 | package githttp |
| 16 | |
| 17 | import ( |
| 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 | |
| 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 | |
| 56 | type 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 | |
| 75 | type 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 | |
| 91 | func 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 | |
| 107 | func (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. |
| 121 | func 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. |
| 143 | func 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 | |
| 150 | func 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 | |
| 162 | func (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. |
| 245 | func (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 | |
| 267 | func (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. |
| 284 | func 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 | |
| 301 | func (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 | |
| 425 | func (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 |