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