1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package githttp serves Git over smart HTTP: it routes, authenticates and
4// authorizes a request, then lets `git http-backend` answer it through
5// net/http/cgi. It also hosts the Unix-socket server that the push hooks call
6// back into.
7package githttp
8
9import (
10 "cmp"
11 "context"
12 "errors"
13 "io"
14 "log"
15 "math"
16 "net/http"
17 "net/http/cgi"
18 "net/netip"
19 "regexp"
20 "runtime"
21 "strconv"
22 "strings"
23 "sync"
24 "time"
25
26 "github.com/bnema/zerowrap"
27
28 "bnema.dev/ermite/internal/clientlimit"
29 "bnema.dev/ermite/internal/domain"
30 "bnema.dev/ermite/internal/ports"
31)
32
33// Patterns are the ServeMux patterns the handler serves. Everything else
34// under /<name>/ (dumb HTTP, HEAD, objects/...) is a 404 from the mux.
35var Patterns = []string{
36 "GET /{repo}/info/refs",
37 "POST /{repo}/git-upload-pack",
38 "POST /{repo}/git-receive-pack",
39}
40
41// OwnerPatterns are the patterns of Patterns under /<owner>/, where owner is
42// ERMITE_OWNER: the URL github.repository gives (actions/checkout clones
43// <server_url>/<owner>/<repo>, with no .git suffix). The owner is a literal
44// segment so that the patterns never overlap the /-/ routes, and other owners
45// fall through to the web, which answers 404. The handler matches the owner
46// without regard to case, as GitHub does, but the mux only the exact spelling
47// of ERMITE_OWNER (lower case, like a repository name); register them only for
48// a valid owner and set Config.Owner to the same.
49func OwnerPatterns(owner string) []string {
50 out := make([]string, len(Patterns))
51 for i, p := range Patterns {
52 method, path, _ := strings.Cut(p, " ")
53 out[i] = method + " /" + owner + path
54 }
55 return out
56}
57
58// DefaultMaxBodyBytes bounds a request body (a push) when Config.MaxBodyBytes
59// is zero.
60const DefaultMaxBodyBytes int64 = 4 << 30
61
62// uploadPackMaxBody bounds the body of git-upload-pack, which is only a list
63// of wants and haves: git http-backend buffers it whole when its length is
64// unknown, so it must stay small whatever Config.MaxBodyBytes says.
65const uploadPackMaxBody int64 = 64 << 20
66
67// Names of the environment variables that carry the hook callback settings
68// from ermite, through git, to the hook process.
69const (
70 // EnvHookSocket is the path of the hook Unix socket.
71 EnvHookSocket = "ERMITE_HOOK_SOCKET"
72 // EnvHookToken is the per-request hook token.
73 EnvHookToken = "ERMITE_HOOK_TOKEN"
74)
75
76const (
77 serviceUpload = "git-upload-pack"
78 serviceReceive = "git-receive-pack"
79)
80
81// Config configures a Handler.
82type Config struct {
83 Auth ports.AuthService
84 Repos ports.RepoLookup
85 Pushes ports.PushService
86 Backend ports.GitBackend
87 // Owner is ERMITE_OWNER: /<owner>/<repo>/... is served like /<repo>/...
88 // (see OwnerPatterns). Empty serves the second form nowhere.
89 Owner string
90 // Hooks provides hook tokens for pushes. It is required.
91 Hooks ports.HookTokens
92 // TrustedProxies are the networks allowed to set X-Forwarded-For.
93 TrustedProxies []netip.Prefix
94 // Limiter counts failed authentications; nil builds a default one.
95 Limiter ports.FailureLimiter
96 // MaxBodyBytes bounds the body of a push; 0 means DefaultMaxBodyBytes.
97 // The body of a fetch is bounded by 64 MiB at most.
98 MaxBodyBytes int64
99 // MaxUploadPacks bounds the git-upload-pack backends (fetches, clones,
100 // and the ref advertisement that precedes them) running at once; 0 means
101 // 2*GOMAXPROCS.
102 MaxUploadPacks int
103 // MaxReceivePacks bounds the git-receive-pack backends (pushes) running
104 // at once; 0 means max(2, GOMAXPROCS/2).
105 MaxReceivePacks int
106 // MaxPerClient bounds the backends running at once for one client (see
107 // clientlimit.Key); 0 means DefaultMaxPerClient.
108 MaxPerClient int
109}
110
111// Bounds on the git http-backend processes and on how long a client may take
112// to send a request body.
113const (
114 // DefaultMaxPerClient is the backends one client may run at once.
115 DefaultMaxPerClient = 4
116
117 // retryAfter is the Retry-After, in seconds, of a request refused because
118 // the backends are busy.
119 retryAfter = "5"
120
121 // fetchBodyTimeout is the whole time a client has to send the body of a
122 // fetch, which is small (64 MiB at most).
123 fetchBodyTimeout = 60 * time.Second
124 // pushIdleTimeout is how long a push may go without sending a byte. A
125 // push is bounded by its size, not by its duration, so the deadline moves
126 // forward with every read.
127 pushIdleTimeout = 2 * time.Minute
128)
129
130// Handler serves the smart HTTP routes of Patterns.
131type Handler struct {
132 cfg Config
133 limiter ports.FailureLimiter
134 maxBody int64
135
136 upload, receive chan struct{} // slots of the backends, per service
137
138 mu sync.Mutex // guards clients
139 clients map[string]int // backends running, per clientlimit.Key
140 perCli int
141}
142
143// New builds a Handler.
144func New(cfg Config) *Handler {
145 h := &Handler{cfg: cfg, limiter: cfg.Limiter, maxBody: cfg.MaxBodyBytes}
146 if h.limiter == nil {
147 h.limiter = clientlimit.NewLimiter(nil)
148 }
149 if h.maxBody <= 0 {
150 h.maxBody = DefaultMaxBodyBytes
151 }
152 procs := runtime.GOMAXPROCS(0)
153 h.upload = make(chan struct{}, cmp.Or(cfg.MaxUploadPacks, 2*procs))
154 h.receive = make(chan struct{}, cmp.Or(cfg.MaxReceivePacks, max(2, procs/2)))
155 h.perCli = cmp.Or(cfg.MaxPerClient, DefaultMaxPerClient)
156 h.clients = make(map[string]int)
157 return h
158}
159
160// route is a parsed Git request.
161type route struct {
162 repo string // the path segment, with any .git suffix
163 suffix string // "info/refs", "git-upload-pack" or "git-receive-pack"
164 service string // serviceUpload or serviceReceive
165}
166
167func (rt route) push() bool { return rt.service == serviceReceive }
168
169// gitSuffixes are the ends of the paths the handler serves, after the
170// repository.
171var gitSuffixes = []string{"/info/refs", "/" + serviceUpload, "/" + serviceReceive}
172
173// parseRoute recognizes /<repo>/info/refs?service=..., /<repo>/git-upload-pack
174// and /<repo>/git-receive-pack, and the same under /<owner>/ for the owner of
175// the forge. info/refs without a service is the dumb protocol, which ermite
176// does not serve.
177func (h *Handler) parseRoute(r *http.Request) (route, int) {
178 p := strings.TrimPrefix(r.URL.Path, "/")
179 var head, rest string
180 for _, suffix := range gitSuffixes {
181 if trimmed, ok := strings.CutSuffix(p, suffix); ok {
182 head, rest = trimmed, suffix[1:]
183 break
184 }
185 }
186 if rest == "" {
187 return route{}, http.StatusNotFound
188 }
189 seg := head
190 if owner, repo, ok := strings.Cut(head, "/"); ok {
191 // The owner is matched as GitHub does, without regard to case, like the
192 // REST subset and the references of actions (domain.OwnerMatches).
193 if !domain.OwnerMatches(h.cfg.Owner, owner) {
194 return route{}, http.StatusNotFound
195 }
196 seg = repo
197 }
198 if seg == "" || strings.Contains(seg, "/") {
199 return route{}, http.StatusNotFound
200 }
201 rt := route{repo: seg, suffix: rest}
202 switch rest {
203 case "info/refs":
204 if r.Method != http.MethodGet && r.Method != http.MethodHead {
205 return route{}, http.StatusMethodNotAllowed
206 }
207 // Exactly one service value: a repeated parameter is ambiguous, and
208 // what is handed to the backend is rebuilt from this one anyway.
209 switch svcs := r.URL.Query()["service"]; {
210 case len(svcs) != 1:
211 return route{}, http.StatusNotFound
212 case svcs[0] == serviceUpload, svcs[0] == serviceReceive:
213 rt.service = svcs[0]
214 default:
215 return route{}, http.StatusNotFound
216 }
217 case serviceUpload, serviceReceive:
218 if r.Method != http.MethodPost {
219 return route{}, http.StatusMethodNotAllowed
220 }
221 rt.service = rest
222 }
223 return rt, 0
224}
225
226// ServeHTTP implements http.Handler.
227//
228//hexcheck:ignore suspicious-business-logic-in-adapter transport orchestration: the authorization decisions are the use cases'
229func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
230 var rt route
231 var name domain.RepoName
232 var principal domain.Principal
233 defer func() {
234 log := zerowrap.FromCtx(r.Context())
235 log.Debug().
236 Str("repo", name.String()).
237 Str("service", rt.service).
238 Str("principal", principal.Label()).
239 Str("token_id", principal.TokenID()).
240 Msg("git request")
241 }()
242
243 rt, status := h.parseRoute(r)
244 if status != 0 {
245 http.Error(w, http.StatusText(status), status)
246 return
247 }
248 name, err := domain.ParseRepoPath(rt.repo)
249 if err != nil {
250 notFound(w)
251 return
252 }
253
254 ctx := r.Context()
255 log := zerowrap.FromCtx(ctx)
256 addr := clientlimit.ClientAddr(r, h.cfg.TrustedProxies)
257 if blocked, wait := h.limiter.Blocked(ctx, addr); blocked {
258 log.Warn().Str("client", addr).Msg("too many failed authentications, request blocked")
259 w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds()))))
260 http.Error(w, "too many failed authentications", http.StatusTooManyRequests)
261 return
262 }
263
264 principal, err = h.authenticate(r, addr)
265 if err != nil {
266 if errors.Is(err, domain.ErrUnauthenticated) {
267 challenge(w)
268 return
269 }
270 log.Error().Err(err).Msg("authenticate git request")
271 http.Error(w, "internal error", http.StatusInternalServerError)
272 return
273 }
274
275 if rt.push() {
276 // Only the first request of a push may create the repository.
277 err = h.cfg.Pushes.Open(ctx, principal, name, rt.suffix == "info/refs")
278 } else {
279 _, err = h.cfg.Repos.Get(ctx, principal, name)
280 }
281 if err != nil {
282 h.denied(ctx, w, principal, err)
283 return
284 }
285
286 h.serveBackend(w, r, rt, name, principal, clientlimit.Key(addr))
287}
288
289// authenticate reads the credentials of the request. No credentials is the
290// anonymous principal; credentials that are not valid count as a failure of
291// the client address and give domain.ErrUnauthenticated.
292func (h *Handler) authenticate(r *http.Request, addr string) (domain.Principal, error) {
293 secret, present := credentials(r)
294 if !present {
295 return domain.Anonymous(), nil
296 }
297 p, err := h.cfg.Auth.Authenticate(r.Context(), secret)
298 if err != nil {
299 if errors.Is(err, domain.ErrUnauthenticated) {
300 h.limiter.Fail(r.Context(), addr)
301 log := zerowrap.FromCtx(r.Context())
302 log.Debug().Str("client", addr).Msg("invalid credentials")
303 }
304 return domain.Anonymous(), err
305 }
306 return p, nil
307}
308
309// credentials extracts the token from an Authorization header: the password of
310// Basic (the username is ignored, actions/checkout sends x-access-token), or
311// the value of "Bearer" or "token". present is true when the
312// header exists, even if it carries nothing usable, which then fails
313// authentication.
314func credentials(r *http.Request) (secret string, present bool) {
315 auth := r.Header.Get("Authorization")
316 if auth == "" {
317 return "", false
318 }
319 if _, pass, ok := r.BasicAuth(); ok {
320 return pass, true
321 }
322 if scheme, tok, ok := strings.Cut(auth, " "); ok && (strings.EqualFold(scheme, "Bearer") || strings.EqualFold(scheme, "token")) {
323 return strings.TrimSpace(tok), true
324 }
325 return "", true
326}
327
328// denied answers an authorization failure. A caller without valid
329// credentials is asked for them wherever the repository does not show itself,
330// which keeps a private repository and a missing one indistinguishable.
331func (h *Handler) denied(ctx context.Context, w http.ResponseWriter, p domain.Principal, err error) {
332 switch {
333 case errors.Is(err, domain.ErrUnauthenticated):
334 challenge(w)
335 case errors.Is(err, domain.ErrNotFound):
336 if p.IsAnonymous() {
337 challenge(w)
338 return
339 }
340 notFound(w)
341 case errors.Is(err, domain.ErrForbidden):
342 http.Error(w, "forbidden", http.StatusForbidden)
343 default:
344 log := zerowrap.FromCtx(ctx)
345 log.Error().Err(err).Msg("authorize git request")
346 http.Error(w, "internal error", http.StatusInternalServerError)
347 }
348}
349
350func challenge(w http.ResponseWriter) {
351 w.Header().Set("WWW-Authenticate", `Basic realm="ermite"`)
352 http.Error(w, "authentication required", http.StatusUnauthorized)
353}
354
355func notFound(w http.ResponseWriter) { http.Error(w, "not found", http.StatusNotFound) }
356
357// gitProtocolRe is what the Git-Protocol header may carry: colon-separated
358// key=value pairs such as "version=2". Git passes it on as GIT_PROTOCOL to
359// its subprocesses, so anything else is dropped.
360var gitProtocolRe = regexp.MustCompile(`^[a-z0-9._-]+=[a-z0-9._-]+(:[a-z0-9._-]+=[a-z0-9._-]+)*$`)
361
362// maxGitProtocolLen bounds the Git-Protocol header.
363const maxGitProtocolLen = 128
364
365// inheritedEnv are the variables net/http/cgi copies from the ermite process
366// on some platforms (osDefaultInheritEnv). Setting them empty keeps the
367// environment of the process out of Git and its hooks.
368var inheritedEnv = []string{
369 "LD_LIBRARY_PATH=", "LD_LIBRARYN32_PATH=", "LD_LIBRARY64_PATH=",
370 "LD_LIBRARY_PATH_32=", "LD_LIBRARY_PATH_64=", "DYLD_LIBRARY_PATH=", "SHLIB_PATH=",
371}
372
373// acquire takes a backend slot for the client and the service of rt, or
374// answers 429 (the client already runs its share) or 503 (the server runs all
375// it can) with a Retry-After and reports false.
376//
377// It never waits. A backend can run for minutes, so a queue would only trade
378// a refusal for a held connection and goroutine, and let a burst of requests
379// pile up behind slow clones. Git does not retry by itself, but the refusal is
380// cheap and says when to come back. release must be called when ok.
381func (h *Handler) acquire(ctx context.Context, w http.ResponseWriter, rt route, client string) (release func(), ok bool) {
382 logger := zerowrap.FromCtx(ctx)
383 refuse := func(status int, msg string) {
384 w.Header().Set("Retry-After", retryAfter)
385 http.Error(w, msg, status)
386 }
387
388 h.mu.Lock()
389 if h.clients[client] >= h.perCli {
390 h.mu.Unlock()
391 logger.Warn().Str("client", client).Msg("too many git requests in progress for the client")
392 refuse(http.StatusTooManyRequests, "too many requests in progress")
393 return nil, false
394 }
395 h.clients[client]++
396 h.mu.Unlock()
397 releaseClient := func() {
398 h.mu.Lock()
399 if h.clients[client]--; h.clients[client] <= 0 {
400 delete(h.clients, client)
401 }
402 h.mu.Unlock()
403 }
404
405 slots := h.upload
406 if rt.push() {
407 slots = h.receive
408 }
409 select {
410 case slots <- struct{}{}:
411 return func() { <-slots; releaseClient() }, true
412 default:
413 releaseClient()
414 logger.Warn().Str("service", rt.service).Msg("all git backends busy, request refused")
415 refuse(http.StatusServiceUnavailable, "server busy")
416 return nil, false
417 }
418}
419
420// serveBackend runs git http-backend for an authorized request.
421//
422//hexcheck:ignore suspicious-business-logic-in-adapter process and CGI plumbing: no business rule is decided here
423func (h *Handler) serveBackend(w http.ResponseWriter, r *http.Request, rt route, name domain.RepoName, p domain.Principal, client string) {
424 ctx := r.Context()
425 logger := zerowrap.FromCtx(ctx)
426
427 release, ok := h.acquire(ctx, w, rt, client)
428 if !ok {
429 return
430 }
431 defer release()
432
433 be, err := h.cfg.Backend.HTTPBackend(ctx, rt.push())
434 if err != nil {
435 logger.Error().Err(err).Msg("prepare git http-backend")
436 http.Error(w, "internal error", http.StatusInternalServerError)
437 return
438 }
439 env := append(append([]string(nil), be.Env...), inheritedEnv...)
440 if rt.suffix == serviceReceive {
441 token, revoke := h.cfg.Hooks.Register(ctx, name, p)
442 defer revoke()
443 env = append(env, EnvHookSocket+"="+h.cfg.Hooks.Socket(), EnvHookToken+"="+token)
444 }
445
446 req, status := h.backendRequest(w, r, rt, name)
447 if status != 0 {
448 http.Error(w, http.StatusText(status), status)
449 return
450 }
451
452 (&cgi.Handler{
453 Path: be.Path,
454 Dir: be.Dir,
455 Env: env,
456 Stderr: logWriter{ctx: ctx, repo: name.String()},
457 Logger: log.New(logWriter{ctx: ctx, repo: name.String(), warn: true}, "", 0),
458 }).ServeHTTP(w, req)
459}
460
461// bodyLimit is the largest request body accepted for the route.
462func (h *Handler) bodyLimit(rt route) int64 {
463 if rt.push() {
464 return h.maxBody
465 }
466 return min(h.maxBody, uploadPackMaxBody)
467}
468
469// backendRequest builds the request handed to CGI. The path names the
470// directory of the repository, the query is rebuilt from the validated
471// service, and the headers are rebuilt from an allowlist: CGI turns every
472// header into an environment variable of Git and of its hooks, so nothing
473// else (credentials, cookies, forwarding headers, anything arbitrary) goes
474// through. The body is streamed and cut off at the limit; a body of unknown
475// length (chunked, which CGI refuses) is passed on with its length unknown.
476// status is non-zero when the request must be refused.
477func (h *Handler) backendRequest(w http.ResponseWriter, r *http.Request, rt route, name domain.RepoName) (req *http.Request, status int) {
478 limit := h.bodyLimit(rt)
479 if r.ContentLength > limit {
480 return nil, http.StatusRequestEntityTooLarge
481 }
482 req = r.Clone(r.Context())
483 req.URL.Path = "/" + name.String() + ".git/" + rt.suffix
484 req.URL.RawPath = ""
485 req.URL.RawQuery = ""
486 if rt.suffix == "info/refs" {
487 req.URL.RawQuery = "service=" + rt.service
488 }
489
490 req.Header = make(http.Header, 5)
491 for _, k := range []string{"Content-Type", "Content-Encoding", "Accept", "User-Agent"} {
492 if v := r.Header.Get(k); v != "" {
493 req.Header.Set(k, v)
494 }
495 }
496 if v := r.Header.Get("Git-Protocol"); v != "" && len(v) <= maxGitProtocolLen && gitProtocolRe.MatchString(v) {
497 req.Header.Set("Git-Protocol", v)
498 }
499
500 body := &deadlineBody{
501 ReadCloser: http.MaxBytesReader(w, r.Body, limit),
502 rc: http.NewResponseController(w),
503 }
504 if rt.push() {
505 body.idle = pushIdleTimeout
506 } else {
507 body.until = time.Now().Add(fetchBodyTimeout)
508 }
509 req.Body = body
510 if r.ContentLength < 0 || len(r.TransferEncoding) > 0 {
511 req.ContentLength = -1
512 req.TransferEncoding = nil
513 }
514 return req, 0
515}
516
517// deadlineBody bounds the time a client takes to send a request body, which
518// the server's own timeouts leave unbounded: a client that stalls would hold a
519// backend slot and its process forever. Until the body ends, the connection
520// read deadline is until, or when that is zero, idle after the last read. At
521// the end of the body the deadline is lifted again, so that it does not cut
522// the connection while the response is still being written.
523type deadlineBody struct {
524 io.ReadCloser
525 rc *http.ResponseController
526 idle time.Duration // moves the deadline forward at each read, if non-zero
527 until time.Time // fixed deadline, when idle is zero
528}
529
530func (b *deadlineBody) Read(p []byte) (int, error) {
531 d := b.until
532 if b.idle > 0 {
533 d = time.Now().Add(b.idle)
534 }
535 // Not every ResponseWriter can set a deadline (HTTP/2 can, wrappers
536 // might not): then the body is simply not bounded here.
537 _ = b.rc.SetReadDeadline(d)
538 n, err := b.ReadCloser.Read(p)
539 if err != nil {
540 _ = b.rc.SetReadDeadline(time.Time{})
541 }
542 return n, err
543}
544
545// logWriter sends what the Git process (or the CGI layer) writes to its
546// standard error to the logger of the request.
547type logWriter struct {
548 ctx context.Context
549 repo string
550 warn bool
551}
552
553func (l logWriter) Write(b []byte) (int, error) {
554 msg := strings.TrimSpace(string(b))
555 if msg == "" {
556 return len(b), nil
557 }
558 logger := zerowrap.FromCtx(l.ctx)
559 if l.warn {
560 logger.Warn().Str("repo", l.repo).Msg(msg)
561 } else {
562 logger.Debug().Str("repo", l.repo).Str("source", "git").Msg(msg)
563 }
564 return len(b), nil
565}