orchard
mirrorEvery 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
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}