v0.3.0 9fdf386

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	"context"
11	"errors"
12	"log"
13	"math"
14	"net/http"
15	"net/http/cgi"
16	"net/netip"
17	"regexp"
18	"strconv"
19	"strings"
20
21	"github.com/bnema/zerowrap"
22
23	"bnema.dev/ermite/internal/clientlimit"
24	"bnema.dev/ermite/internal/domain"
25	"bnema.dev/ermite/internal/ports"
26)
27
28// Patterns are the ServeMux patterns the handler serves. Everything else
29// under /<name>/ (dumb HTTP, HEAD, objects/...) is a 404 from the mux.
30var Patterns = []string{
31	"GET /{repo}/info/refs",
32	"POST /{repo}/git-upload-pack",
33	"POST /{repo}/git-receive-pack",
34}
35
36// DefaultMaxBodyBytes bounds a request body (a push) when Config.MaxBodyBytes
37// is zero.
38const DefaultMaxBodyBytes int64 = 4 << 30
39
40// uploadPackMaxBody bounds the body of git-upload-pack, which is only a list
41// of wants and haves: git http-backend buffers it whole when its length is
42// unknown, so it must stay small whatever Config.MaxBodyBytes says.
43const uploadPackMaxBody int64 = 64 << 20
44
45// Names of the environment variables that carry the hook callback settings
46// from ermite, through git, to the hook process.
47const (
48	// EnvHookSocket is the path of the hook Unix socket.
49	EnvHookSocket = "ERMITE_HOOK_SOCKET"
50	// EnvHookToken is the per-request hook token.
51	EnvHookToken = "ERMITE_HOOK_TOKEN"
52)
53
54const (
55	serviceUpload  = "git-upload-pack"
56	serviceReceive = "git-receive-pack"
57)
58
59// Authenticator turns the secret of a request into a principal. It returns
60// domain.ErrUnauthenticated for a secret that is not valid. app.Auth fits.
61type Authenticator interface {
62	Authenticate(ctx context.Context, secret string) (domain.Principal, error)
63}
64
65// Repos is the read side of the repository use cases. A repository the
66// principal may not read is domain.ErrNotFound. app.Repos fits.
67type Repos interface {
68	Get(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error)
69}
70
71// Pushes opens a repository for a push: it is domain.ErrUnauthenticated for an
72// anonymous caller, domain.ErrNotFound for a repository the caller may not
73// write and cannot see (or that is missing and may not be created),
74// domain.ErrForbidden for one it can see but not write. With create, a missing
75// repository the principal may write is created. app.Pushes fits.
76type Pushes interface {
77	Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error
78}
79
80// HookTokens hands out the per-request tokens the hooks of a push present
81// back to ermite, and the socket they present them on.
82type HookTokens interface {
83	// Register binds a new token to the repository and principal of a push.
84	// release revokes it and must be called when the request ends.
85	Register(ctx context.Context, repo domain.RepoName, p domain.Principal) (token string, release func())
86	// Socket is the path of the hook socket.
87	Socket() string
88}
89
90// Config configures a Handler.
91type Config struct {
92	Auth    Authenticator
93	Repos   Repos
94	Pushes  Pushes
95	Backend ports.GitBackend
96	// Hooks provides hook tokens for pushes. It is required.
97	Hooks HookTokens
98	// TrustedProxies are the networks allowed to set X-Forwarded-For.
99	TrustedProxies []netip.Prefix
100	// Limiter counts failed authentications; nil builds a default one.
101	Limiter *clientlimit.Limiter
102	// MaxBodyBytes bounds the body of a push; 0 means DefaultMaxBodyBytes.
103	// The body of a fetch is bounded by 64 MiB at most.
104	MaxBodyBytes int64
105}
106
107// Handler serves the smart HTTP routes of Patterns.
108type Handler struct {
109	cfg     Config
110	limiter *clientlimit.Limiter
111	maxBody int64
112}
113
114// New builds a Handler.
115func New(cfg Config) *Handler {
116	h := &Handler{cfg: cfg, limiter: cfg.Limiter, maxBody: cfg.MaxBodyBytes}
117	if h.limiter == nil {
118		h.limiter = clientlimit.NewLimiter(nil)
119	}
120	if h.maxBody <= 0 {
121		h.maxBody = DefaultMaxBodyBytes
122	}
123	return h
124}
125
126// route is a parsed Git request.
127type route struct {
128	repo    string // the path segment, with any .git suffix
129	suffix  string // "info/refs", "git-upload-pack" or "git-receive-pack"
130	service string // serviceUpload or serviceReceive
131}
132
133func (rt route) push() bool { return rt.service == serviceReceive }
134
135// parseRoute recognizes /<repo>/info/refs?service=..., /<repo>/git-upload-pack
136// and /<repo>/git-receive-pack. info/refs without a service is the dumb
137// protocol, which ermite does not serve.
138func parseRoute(r *http.Request) (route, int) {
139	seg, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")
140	if !ok || seg == "" {
141		return route{}, http.StatusNotFound
142	}
143	rt := route{repo: seg, suffix: rest}
144	switch rest {
145	case "info/refs":
146		if r.Method != http.MethodGet && r.Method != http.MethodHead {
147			return route{}, http.StatusMethodNotAllowed
148		}
149		// Exactly one service value: a repeated parameter is ambiguous, and
150		// what is handed to the backend is rebuilt from this one anyway.
151		switch svcs := r.URL.Query()["service"]; {
152		case len(svcs) != 1:
153			return route{}, http.StatusNotFound
154		case svcs[0] == serviceUpload, svcs[0] == serviceReceive:
155			rt.service = svcs[0]
156		default:
157			return route{}, http.StatusNotFound
158		}
159	case serviceUpload, serviceReceive:
160		if r.Method != http.MethodPost {
161			return route{}, http.StatusMethodNotAllowed
162		}
163		rt.service = rest
164	default:
165		return route{}, http.StatusNotFound
166	}
167	return rt, 0
168}
169
170// ServeHTTP implements http.Handler.
171func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
172	var rt route
173	var name domain.RepoName
174	var principal domain.Principal
175	defer func() {
176		log := zerowrap.FromCtx(r.Context())
177		log.Debug().
178			Str("repo", name.String()).
179			Str("service", rt.service).
180			Str("principal", principal.Label()).
181			Str("token_id", principal.TokenID()).
182			Msg("git request")
183	}()
184
185	rt, status := parseRoute(r)
186	if status != 0 {
187		http.Error(w, http.StatusText(status), status)
188		return
189	}
190	name, err := domain.ParseRepoPath(rt.repo)
191	if err != nil {
192		notFound(w)
193		return
194	}
195
196	ctx := r.Context()
197	log := zerowrap.FromCtx(ctx)
198	addr := clientlimit.ClientAddr(r, h.cfg.TrustedProxies)
199	if blocked, wait := h.limiter.Blocked(ctx, addr); blocked {
200		log.Warn().Str("client", addr).Msg("too many failed authentications, request blocked")
201		w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds()))))
202		http.Error(w, "too many failed authentications", http.StatusTooManyRequests)
203		return
204	}
205
206	principal, err = h.authenticate(r, addr)
207	if err != nil {
208		if errors.Is(err, domain.ErrUnauthenticated) {
209			challenge(w)
210			return
211		}
212		log.Error().Err(err).Msg("authenticate git request")
213		http.Error(w, "internal error", http.StatusInternalServerError)
214		return
215	}
216
217	if rt.push() {
218		// Only the first request of a push may create the repository.
219		err = h.cfg.Pushes.Open(ctx, principal, name, rt.suffix == "info/refs")
220	} else {
221		_, err = h.cfg.Repos.Get(ctx, principal, name)
222	}
223	if err != nil {
224		h.denied(ctx, w, principal, err)
225		return
226	}
227
228	h.serveBackend(w, r, rt, name, principal)
229}
230
231// authenticate reads the credentials of the request. No credentials is the
232// anonymous principal; credentials that are not valid count as a failure of
233// the client address and give domain.ErrUnauthenticated.
234func (h *Handler) authenticate(r *http.Request, addr string) (domain.Principal, error) {
235	secret, present := credentials(r)
236	if !present {
237		return domain.Anonymous(), nil
238	}
239	p, err := h.cfg.Auth.Authenticate(r.Context(), secret)
240	if err != nil {
241		if errors.Is(err, domain.ErrUnauthenticated) {
242			h.limiter.Fail(r.Context(), addr)
243			log := zerowrap.FromCtx(r.Context())
244			log.Debug().Str("client", addr).Msg("invalid credentials")
245		}
246		return domain.Anonymous(), err
247	}
248	return p, nil
249}
250
251// credentials extracts the token from an Authorization header: the password of
252// Basic (the username is ignored) or a Bearer token. present is true when the
253// header exists, even if it carries nothing usable, which then fails
254// authentication.
255func credentials(r *http.Request) (secret string, present bool) {
256	auth := r.Header.Get("Authorization")
257	if auth == "" {
258		return "", false
259	}
260	if _, pass, ok := r.BasicAuth(); ok {
261		return pass, true
262	}
263	if scheme, tok, ok := strings.Cut(auth, " "); ok && strings.EqualFold(scheme, "Bearer") {
264		return strings.TrimSpace(tok), true
265	}
266	return "", true
267}
268
269// denied answers an authorization failure. A caller without valid
270// credentials is asked for them wherever the repository does not show itself,
271// which keeps a private repository and a missing one indistinguishable.
272func (h *Handler) denied(ctx context.Context, w http.ResponseWriter, p domain.Principal, err error) {
273	switch {
274	case errors.Is(err, domain.ErrUnauthenticated):
275		challenge(w)
276	case errors.Is(err, domain.ErrNotFound):
277		if p.IsAnonymous() {
278			challenge(w)
279			return
280		}
281		notFound(w)
282	case errors.Is(err, domain.ErrForbidden):
283		http.Error(w, "forbidden", http.StatusForbidden)
284	default:
285		log := zerowrap.FromCtx(ctx)
286		log.Error().Err(err).Msg("authorize git request")
287		http.Error(w, "internal error", http.StatusInternalServerError)
288	}
289}
290
291func challenge(w http.ResponseWriter) {
292	w.Header().Set("WWW-Authenticate", `Basic realm="ermite"`)
293	http.Error(w, "authentication required", http.StatusUnauthorized)
294}
295
296func notFound(w http.ResponseWriter) { http.Error(w, "not found", http.StatusNotFound) }
297
298// gitProtocolRe is what the Git-Protocol header may carry: colon-separated
299// key=value pairs such as "version=2". Git passes it on as GIT_PROTOCOL to
300// its subprocesses, so anything else is dropped.
301var gitProtocolRe = regexp.MustCompile(`^[a-z0-9._-]+=[a-z0-9._-]+(:[a-z0-9._-]+=[a-z0-9._-]+)*$`)
302
303// maxGitProtocolLen bounds the Git-Protocol header.
304const maxGitProtocolLen = 128
305
306// inheritedEnv are the variables net/http/cgi copies from the ermite process
307// on some platforms (osDefaultInheritEnv). Setting them empty keeps the
308// environment of the process out of Git and its hooks.
309var inheritedEnv = []string{
310	"LD_LIBRARY_PATH=", "LD_LIBRARYN32_PATH=", "LD_LIBRARY64_PATH=",
311	"LD_LIBRARY_PATH_32=", "LD_LIBRARY_PATH_64=", "DYLD_LIBRARY_PATH=", "SHLIB_PATH=",
312}
313
314// serveBackend runs git http-backend for an authorized request.
315func (h *Handler) serveBackend(w http.ResponseWriter, r *http.Request, rt route, name domain.RepoName, p domain.Principal) {
316	ctx := r.Context()
317	logger := zerowrap.FromCtx(ctx)
318
319	be, err := h.cfg.Backend.HTTPBackend(ctx, rt.push())
320	if err != nil {
321		logger.Error().Err(err).Msg("prepare git http-backend")
322		http.Error(w, "internal error", http.StatusInternalServerError)
323		return
324	}
325	env := append(append([]string(nil), be.Env...), inheritedEnv...)
326	if rt.suffix == serviceReceive {
327		token, release := h.cfg.Hooks.Register(ctx, name, p)
328		defer release()
329		env = append(env, EnvHookSocket+"="+h.cfg.Hooks.Socket(), EnvHookToken+"="+token)
330	}
331
332	req, status := h.backendRequest(w, r, rt, name)
333	if status != 0 {
334		http.Error(w, http.StatusText(status), status)
335		return
336	}
337
338	(&cgi.Handler{
339		Path:   be.Path,
340		Dir:    be.Dir,
341		Env:    env,
342		Stderr: logWriter{ctx: ctx, repo: name.String()},
343		Logger: log.New(logWriter{ctx: ctx, repo: name.String(), warn: true}, "", 0),
344	}).ServeHTTP(w, req)
345}
346
347// bodyLimit is the largest request body accepted for the route.
348func (h *Handler) bodyLimit(rt route) int64 {
349	if rt.push() {
350		return h.maxBody
351	}
352	return min(h.maxBody, uploadPackMaxBody)
353}
354
355// backendRequest builds the request handed to CGI. The path names the
356// directory of the repository, the query is rebuilt from the validated
357// service, and the headers are rebuilt from an allowlist: CGI turns every
358// header into an environment variable of Git and of its hooks, so nothing
359// else (credentials, cookies, forwarding headers, anything arbitrary) goes
360// through. The body is streamed and cut off at the limit; a body of unknown
361// length (chunked, which CGI refuses) is passed on with its length unknown.
362// status is non-zero when the request must be refused.
363func (h *Handler) backendRequest(w http.ResponseWriter, r *http.Request, rt route, name domain.RepoName) (req *http.Request, status int) {
364	limit := h.bodyLimit(rt)
365	if r.ContentLength > limit {
366		return nil, http.StatusRequestEntityTooLarge
367	}
368	req = r.Clone(r.Context())
369	req.URL.Path = "/" + name.String() + ".git/" + rt.suffix
370	req.URL.RawPath = ""
371	req.URL.RawQuery = ""
372	if rt.suffix == "info/refs" {
373		req.URL.RawQuery = "service=" + rt.service
374	}
375
376	req.Header = make(http.Header, 5)
377	for _, k := range []string{"Content-Type", "Content-Encoding", "Accept", "User-Agent"} {
378		if v := r.Header.Get(k); v != "" {
379			req.Header.Set(k, v)
380		}
381	}
382	if v := r.Header.Get("Git-Protocol"); v != "" && len(v) <= maxGitProtocolLen && gitProtocolRe.MatchString(v) {
383		req.Header.Set("Git-Protocol", v)
384	}
385
386	req.Body = http.MaxBytesReader(w, r.Body, limit)
387	if r.ContentLength < 0 || len(r.TransferEncoding) > 0 {
388		req.ContentLength = -1
389		req.TransferEncoding = nil
390	}
391	return req, 0
392}
393
394// logWriter sends what the Git process (or the CGI layer) writes to its
395// standard error to the logger of the request.
396type logWriter struct {
397	ctx  context.Context
398	repo string
399	warn bool
400}
401
402func (l logWriter) Write(b []byte) (int, error) {
403	msg := strings.TrimSpace(string(b))
404	if msg == "" {
405		return len(b), nil
406	}
407	logger := zerowrap.FromCtx(l.ctx)
408	if l.warn {
409		logger.Warn().Str("repo", l.repo).Msg(msg)
410	} else {
411		logger.Debug().Str("repo", l.repo).Str("source", "git").Msg(msg)
412	}
413	return len(b), nil
414}

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