v0.7.0 45bfc5b

handler.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close