detached 83e495f

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// 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.
177//
178//hexcheck:ignore suspicious-business-logic-in-adapter decodes smart-HTTP repository routes and owner aliases, validating methods and service query parameters
179func (h *Handler) parseRoute(r *http.Request) (route, int) {
180	p := strings.TrimPrefix(r.URL.Path, "/")
181	var head, rest string
182	for _, suffix := range gitSuffixes {
183		if trimmed, ok := strings.CutSuffix(p, suffix); ok {
184			head, rest = trimmed, suffix[1:]
185			break
186		}
187	}
188	if rest == "" {
189		return route{}, http.StatusNotFound
190	}
191	seg := head
192	if owner, repo, ok := strings.Cut(head, "/"); ok {
193		// The owner is matched as GitHub does, without regard to case, like the
194		// REST subset and the references of actions (domain.OwnerMatches).
195		if !domain.OwnerMatches(h.cfg.Owner, owner) {
196			return route{}, http.StatusNotFound
197		}
198		seg = repo
199	}
200	if seg == "" || strings.Contains(seg, "/") {
201		return route{}, http.StatusNotFound
202	}
203	rt := route{repo: seg, suffix: rest}
204	switch rest {
205	case "info/refs":
206		if r.Method != http.MethodGet && r.Method != http.MethodHead {
207			return route{}, http.StatusMethodNotAllowed
208		}
209		// Exactly one service value: a repeated parameter is ambiguous, and
210		// what is handed to the backend is rebuilt from this one anyway.
211		switch svcs := r.URL.Query()["service"]; {
212		case len(svcs) != 1:
213			return route{}, http.StatusNotFound
214		case svcs[0] == serviceUpload, svcs[0] == serviceReceive:
215			rt.service = svcs[0]
216		default:
217			return route{}, http.StatusNotFound
218		}
219	case serviceUpload, serviceReceive:
220		if r.Method != http.MethodPost {
221			return route{}, http.StatusMethodNotAllowed
222		}
223		rt.service = rest
224	}
225	return rt, 0
226}
227
228// ServeHTTP implements http.Handler.
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.
331//
332//hexcheck:ignore suspicious-business-logic-in-adapter implements the smart-HTTP protocol visibility/auth challenge policy (401 vs 404) for hidden or missing repositories
333func (h *Handler) denied(ctx context.Context, w http.ResponseWriter, p domain.Principal, err error) {
334	switch {
335	case errors.Is(err, domain.ErrUnauthenticated):
336		challenge(w)
337	case errors.Is(err, domain.ErrNotFound):
338		if p.IsAnonymous() {
339			challenge(w)
340			return
341		}
342		notFound(w)
343	case errors.Is(err, domain.ErrForbidden):
344		http.Error(w, "forbidden", http.StatusForbidden)
345	default:
346		log := zerowrap.FromCtx(ctx)
347		log.Error().Err(err).Msg("authorize git request")
348		http.Error(w, "internal error", http.StatusInternalServerError)
349	}
350}
351
352func challenge(w http.ResponseWriter) {
353	w.Header().Set("WWW-Authenticate", `Basic realm="ermite"`)
354	http.Error(w, "authentication required", http.StatusUnauthorized)
355}
356
357func notFound(w http.ResponseWriter) { http.Error(w, "not found", http.StatusNotFound) }
358
359// gitProtocolRe is what the Git-Protocol header may carry: colon-separated
360// key=value pairs such as "version=2". Git passes it on as GIT_PROTOCOL to
361// its subprocesses, so anything else is dropped.
362var gitProtocolRe = regexp.MustCompile(`^[a-z0-9._-]+=[a-z0-9._-]+(:[a-z0-9._-]+=[a-z0-9._-]+)*$`)
363
364// maxGitProtocolLen bounds the Git-Protocol header.
365const maxGitProtocolLen = 128
366
367// inheritedEnv are the variables net/http/cgi copies from the ermite process
368// on some platforms (osDefaultInheritEnv). Setting them empty keeps the
369// environment of the process out of Git and its hooks.
370var inheritedEnv = []string{
371	"LD_LIBRARY_PATH=", "LD_LIBRARYN32_PATH=", "LD_LIBRARY64_PATH=",
372	"LD_LIBRARY_PATH_32=", "LD_LIBRARY_PATH_64=", "DYLD_LIBRARY_PATH=", "SHLIB_PATH=",
373}
374
375// acquire takes a backend slot for the client and the service of rt, or
376// answers 429 (the client already runs its share) or 503 (the server runs all
377// it can) with a Retry-After and reports false.
378//
379// It never waits. A backend can run for minutes, so a queue would only trade
380// a refusal for a held connection and goroutine, and let a burst of requests
381// pile up behind slow clones. Git does not retry by itself, but the refusal is
382// cheap and says when to come back. release must be called when ok.
383func (h *Handler) acquire(ctx context.Context, w http.ResponseWriter, rt route, client string) (release func(), ok bool) {
384	logger := zerowrap.FromCtx(ctx)
385	refuse := func(status int, msg string) {
386		w.Header().Set("Retry-After", retryAfter)
387		http.Error(w, msg, status)
388	}
389
390	h.mu.Lock()
391	if h.clients[client] >= h.perCli {
392		h.mu.Unlock()
393		logger.Warn().Str("client", client).Msg("too many git requests in progress for the client")
394		refuse(http.StatusTooManyRequests, "too many requests in progress")
395		return nil, false
396	}
397	h.clients[client]++
398	h.mu.Unlock()
399	releaseClient := func() {
400		h.mu.Lock()
401		if h.clients[client]--; h.clients[client] <= 0 {
402			delete(h.clients, client)
403		}
404		h.mu.Unlock()
405	}
406
407	slots := h.upload
408	if rt.push() {
409		slots = h.receive
410	}
411	select {
412	case slots <- struct{}{}:
413		return func() { <-slots; releaseClient() }, true
414	default:
415		releaseClient()
416		logger.Warn().Str("service", rt.service).Msg("all git backends busy, request refused")
417		refuse(http.StatusServiceUnavailable, "server busy")
418		return nil, false
419	}
420}
421
422// serveBackend runs git http-backend for an authorized request.
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}

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 4

Tags 45

↑↓ moveenter openesc close