repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

9.1 KB · 307 lines · Go Raw History
  1package main
  2
  3// The git smart HTTP wire: clone, fetch and push, over net/http/cgi. Basic auth
  4// because git's credential subsystem natively fills, stores and replays no other
  5// scheme; the password field carries a random token. The browser UI is in auth.go.
  6
  7import (
  8	"context"
  9	"io"
 10	"log/slog"
 11	"net/http"
 12	"net/http/cgi"
 13	"os"
 14	"os/exec"
 15	"path/filepath"
 16	"strconv"
 17	"strings"
 18
 19	"repos.bythewood.me/web"
 20)
 21
 22// gitHTTPBackend finds git's own CGI, which is not on PATH and moves between
 23// distributions.
 24func gitHTTPBackend() string {
 25	candidates := []string{
 26		"/usr/libexec/git-core/git-http-backend",
 27		"/usr/lib/git-core/git-http-backend",
 28	}
 29	for _, p := range candidates {
 30		if _, err := os.Stat(p); err == nil {
 31			return p
 32		}
 33	}
 34	if p, err := exec.LookPath("git-http-backend"); err == nil {
 35		return p
 36	}
 37	return ""
 38}
 39
 40// wire serves everything under /{name}.git/.
 41type wire struct {
 42	store   *Store
 43	db      *DB
 44	backend string
 45	// allowCreate turns push-to-create on.
 46	allowCreate bool
 47}
 48
 49// Router puts the wire in front of the browse mux. A wrapper rather than mux
 50// patterns because a ServeMux wildcard matches a whole segment, so "{name}.git"
 51// cannot be registered.
 52func (wr *wire) Router(next http.Handler) http.Handler {
 53	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 54		seg, rest, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")
 55		name, ok := repoNameFromPath(seg)
 56		if !ok {
 57			next.ServeHTTP(w, r)
 58			return
 59		}
 60		wr.serve(w, r, name, rest)
 61	})
 62}
 63
 64// serve handles one git wire request for an already-parsed repository name.
 65func (wr *wire) serve(w http.ResponseWriter, r *http.Request, name, rest string) {
 66	if isDumbPath(rest) {
 67		http.NotFound(w, r)
 68		return
 69	}
 70
 71	// The service=git-receive-pack probe must be challenged too: answer it
 72	// anonymously and git never asks for a credential, so the push that follows
 73	// fails with a 401 the user cannot act on.
 74	writing := rest == "git-receive-pack" ||
 75		r.URL.Query().Get("service") == "git-receive-pack"
 76
 77	if writing {
 78		user, ok := wr.authenticate(r)
 79		if !ok {
 80			// The realm is what git shows when it prompts.
 81			w.Header().Set("WWW-Authenticate", `Basic realm="repos"`)
 82			http.Error(w, "authentication required", http.StatusUnauthorized)
 83			return
 84		}
 85		r = r.WithContext(withUser(r.Context(), user))
 86	}
 87
 88	repo, ok := wr.store.Open(name)
 89	if !ok {
 90		// Push to create, reachable only after the authentication above.
 91		if !writing || !wr.allowCreate {
 92			http.NotFound(w, r)
 93			return
 94		}
 95		created, err := wr.store.InitBare(r.Context(), name)
 96		if err != nil {
 97			slog.Error("push-to-create failed",
 98				slog.String("repo", name), slog.Any("err", err))
 99			http.Error(w, "could not create repository", http.StatusInternalServerError)
100			return
101		}
102		slog.Info("repository created by push", slog.String("repo", name))
103		if err := wr.db.EnsureRepo(name); err != nil {
104			slog.Error("record new repo", slog.String("repo", name), slog.Any("err", err))
105		}
106		repo = created
107	}
108
109	// A push into a mirror would diverge from upstream, and the next sync would
110	// clobber it or fail.
111	if writing {
112		if meta, err := wr.db.Repo(name); err == nil && meta.Mirror {
113			http.Error(w, "this repository is a mirror of an upstream and does not accept pushes",
114				http.StatusForbidden)
115			return
116		}
117	}
118
119	wr.serveBackend(w, r, repo, name, rest, writing)
120}
121
122// serveBackend hands the request to git.
123func (wr *wire) serveBackend(w http.ResponseWriter, r *http.Request, repo Repo, name, rest string, writing bool) {
124	if wr.backend == "" {
125		http.Error(w, "git http-backend not available", http.StatusInternalServerError)
126		return
127	}
128	_ = repo
129
130	env := []string{
131		// GIT_PROJECT_ROOT plus PATH_INFO is how http-backend is told which
132		// repository to serve; GIT_DIR does not support the /info/refs form.
133		"GIT_PROJECT_ROOT=" + wr.store.Root,
134		// Without this each repository needs a git-daemon-export-ok file.
135		"GIT_HTTP_EXPORT_ALL=1",
136		// Do not raise GIT_HTTP_MAX_REQUEST_BUFFER: it bounds what http-backend
137		// reads into memory during anonymous ref negotiation, and does nothing
138		// for the push path, which is the spooling below.
139		"GIT_CONFIG_NOSYSTEM=1",
140		"HOME=" + os.TempDir(),
141	}
142
143	// http-backend derives the committer identity from REMOTE_USER, so the reflog
144	// records which token made a push.
145	if user := userFrom(r.Context()); user != "" {
146		env = append(env, "REMOTE_USER="+user)
147		// Enabled per request, never in repository config, so no repository is
148		// left in a state an unauthenticated route could push to.
149		env = append(env, "GIT_HTTP_RECEIVE_PACK=1")
150	}
151
152	h := &cgi.Handler{
153		Path: wr.backend,
154		Dir:  wr.store.Root,
155		Env:  env,
156		// http-backend reads PATH_INFO relative to GIT_PROJECT_ROOT, so it needs
157		// /<name>.git/<rest>.
158		Root:   "/",
159		Logger: nil,
160	}
161
162	r2 := r.Clone(r.Context())
163	r2.URL.Path = "/" + name + ".git/" + rest
164
165	// net/http/cgi rejects a chunked request outright, and git switches to chunked
166	// for any pack over http.postBuffer, so a real push must be spooled to a
167	// length-delimited body first. An unauthenticated body gets the smaller cap.
168	limit := int64(maxNegotiationBytes)
169	if writing {
170		limit = maxPushBytes
171	}
172	if len(r2.TransferEncoding) > 0 && r2.TransferEncoding[0] == "chunked" {
173		spooled, size, err := spoolBody(r2.Body, limit)
174		if err != nil {
175			slog.Error("spooling push body failed",
176				slog.String("repo", name), slog.Any("err", err))
177			http.Error(w, "could not read request body", http.StatusBadRequest)
178			return
179		}
180		defer spooled.Close()
181
182		r2.Body = spooled
183		r2.ContentLength = size
184		r2.TransferEncoding = nil
185		r2.Header.Set("Content-Length", strconv.FormatInt(size, 10))
186	}
187
188	h.ServeHTTP(w, r2)
189
190	// http-backend has written the refs by now, so the listing card for this
191	// repository is stale the moment a push finishes.
192	if writing {
193		wr.store.InvalidateOverview(name)
194	}
195}
196
197// spoolBody reads a chunked body to a temporary file, unlinked at creation so it
198// has no name to open and vanishes with the handle even on a kill mid-push.
199func spoolBody(body io.ReadCloser, limit int64) (*spooledFile, int64, error) {
200	defer body.Close()
201
202	f, err := os.CreateTemp("", "repos-push-*")
203	if err != nil {
204		return nil, 0, err
205	}
206	// Unlink now; the open handle keeps it alive.
207	_ = os.Remove(f.Name())
208
209	size, err := io.Copy(f, http.MaxBytesReader(nil, body, limit))
210	if err != nil {
211		f.Close()
212		return nil, 0, err
213	}
214	if _, err := f.Seek(0, io.SeekStart); err != nil {
215		f.Close()
216		return nil, 0, err
217	}
218	return &spooledFile{f}, size, nil
219}
220
221// maxPushBytes matches Cloudflare's request body ceiling, so a push the edge would
222// refuse is refused here rather than spooled to disk first.
223const maxPushBytes = cloudflareBodyLimit
224
225// maxNegotiationBytes caps an unauthenticated body, matching git's own
226// GIT_HTTP_MAX_REQUEST_BUFFER default.
227const maxNegotiationBytes = 10 << 20
228
229type spooledFile struct{ *os.File }
230
231func (s *spooledFile) Close() error { return s.File.Close() }
232
233// authenticate checks a Basic credential and returns the token's label. The
234// username is ignored: git requires a field, and the token alone identifies.
235func (wr *wire) authenticate(r *http.Request) (string, bool) {
236	_, password, ok := r.BasicAuth()
237	if !ok || password == "" {
238		return "", false
239	}
240	label, err := wr.db.VerifyToken(password)
241	if err != nil {
242		// Never log the credential that failed.
243		slog.Info("push authentication failed",
244			slog.String("ip", web.ClientIP(r)))
245		return "", false
246	}
247	return label, true
248}
249
250// isDumbPath matches the dumb-HTTP fallback paths. http-backend would serve loose
251// objects and packed-refs as static files, bypassing every check in serve.
252func isDumbPath(rest string) bool {
253	switch {
254	case strings.HasPrefix(rest, "objects/"),
255		rest == "HEAD",
256		rest == "packed-refs",
257		strings.HasPrefix(rest, "refs/"):
258		return true
259	}
260	return false
261}
262
263// repoNameFromPath pulls "orchard" out of "orchard.git".
264func repoNameFromPath(seg string) (string, bool) {
265	name, ok := strings.CutSuffix(seg, ".git")
266	if !ok {
267		return "", false
268	}
269	if !validName(name) {
270		// Return nothing rather than the rejected name, which would be one
271		// refactor away from a traversal.
272		return "", false
273	}
274	return name, true
275}
276
277// cloneURL is what the repository page tells a person to type.
278func cloneURL(base, name string) string {
279	return strings.TrimSuffix(base, "/") + "/" + name + ".git"
280}
281
282// bridgeCloneURL reaches the repository over the Docker bridge instead of through
283// Cloudflare, which is how a first push too large for the tunnel gets seeded.
284func bridgeCloneURL(container, name string) string {
285	return "http://" + container + ":8000/" + name + ".git"
286}
287
288// archiveName is the top level directory inside a downloaded tarball.
289func archiveName(repo, ref string) string {
290	ref = strings.ReplaceAll(ref, "/", "-")
291	return filepath.Base(repo) + "-" + ref
292}
293
294// userKey is the context key for the token label a push authenticated as.
295type userKey struct{}
296
297func withUser(ctx context.Context, label string) context.Context {
298	return context.WithValue(ctx, userKey{}, label)
299}
300
301// userFrom reads the token label back out; empty means the request was
302// unauthenticated.
303func userFrom(ctx context.Context) string {
304	label, _ := ctx.Value(userKey{}).(string)
305	return label
306}