internal/httpd/smart.go

e6cd75b5f28bacf51620bb531320c30fd4e66bfd
gitbay/internal/httpd/smart.go history · blame · raw

250 lines · 8651 bytes

15 symbols in this file
  1// Package httpd serves the HTTP listener: anonymous smart-HTTP git reads for
  2// public repositories, and (from M5) the web UI. There is no authentication
  3// on this listener by design — private repositories answer 404 everywhere,
  4// and pushes are refused with a pkt-line ERR so no git version ever falls
  5// back to asking for credentials.
  6package httpd
  7
  8import (
  9	"bufio"
 10	"compress/gzip"
 11	"errors"
 12	"fmt"
 13	"io"
 14	"net"
 15	"net/http"
 16	"os"
 17	"os/exec"
 18	"strconv"
 19	"strings"
 20	"sync"
 21	"time"
 22
 23	"gitbay.org/gitbay/internal/config"
 24	"gitbay.org/gitbay/internal/control"
 25	"gitbay.org/gitbay/internal/gitutil"
 26	"gitbay.org/gitbay/internal/packlimit"
 27	"gitbay.org/gitbay/internal/store"
 28	"gitbay.org/gitbay/internal/toolpath"
 29)
 30
 31type Server struct {
 32	cfg      config.Config
 33	st       *store.Store
 34	packs    *packlimit.Limiter
 35	apiLimit *apiLimiter
 36	proxies  []*net.IPNet  // http.trusted_proxies, parsed once
 37	stopping chan struct{} // closed by Stop
 38	uploads  sync.Map      // user id -> struct{}: release asset uploads in flight
 39	stopOnce sync.Once
 40}
 41
 42func New(cfg config.Config, st *store.Store, packs *packlimit.Limiter) *Server {
 43	proxies, _ := cfg.HTTP.TrustedProxyNets() // validated at config load
 44	return &Server{cfg: cfg, st: st, packs: packs, apiLimit: newAPILimiter(cfg.Limits.APIRate), proxies: proxies,
 45		stopping: make(chan struct{})}
 46}
 47
 48// Stop ends the requests running a command that lasts until something
 49// happens (build log --follow), so a shutdown drain waits only for work
 50// that finishes. Other requests, git transport included, run on.
 51func (s *Server) Stop() {
 52	s.stopOnce.Do(func() { close(s.stopping) })
 53}
 54
 55// until is closed when the request ends or the server stops, whichever
 56// comes first: the Done a following command runs under.
 57func (s *Server) until(r *http.Request) <-chan struct{} {
 58	done := make(chan struct{})
 59	go func() {
 60		select {
 61		case <-r.Context().Done():
 62		case <-s.stopping:
 63		}
 64		close(done)
 65	}()
 66	return done
 67}
 68
 69// receivePackRefusal exists only to fail legibly if a client POSTs without
 70// reading the advertisement first.
 71func (s *Server) receivePackRefusal(w http.ResponseWriter, r *http.Request) {
 72	http.Error(w, s.pushRefusalMessage(r.PathValue("owner"), r.PathValue("repo")), http.StatusForbidden)
 73}
 74
 75// publicRepo resolves owner/name and returns it only if it exists and is
 76// public. Every failure mode is the same 404.
 77func (s *Server) publicRepo(owner, name string) (store.Repo, bool) {
 78	repo, err := s.st.RepoByPath(owner + "/" + name)
 79	if err != nil || repo.Visibility != "public" {
 80		return store.Repo{}, false
 81	}
 82	return repo, true
 83}
 84
 85func pktLine(w io.Writer, s string) {
 86	fmt.Fprintf(w, "%04x%s", len(s)+4, s)
 87}
 88
 89func pktFlush(w io.Writer) { io.WriteString(w, "0000") }
 90
 91func (s *Server) pushRefusalMessage(owner, repo string) string {
 92	host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://"), "/")
 93	name := strings.TrimSuffix(repo, ".git")
 94	return fmt.Sprintf("pushes to this forge go over SSH: git remote set-url --push origin git@%s:%s/%s.git", host, owner, name)
 95}
 96
 97func (s *Server) infoRefs(w http.ResponseWriter, r *http.Request) {
 98	owner, name := r.PathValue("owner"), r.PathValue("repo")
 99	repo, ok := s.publicRepo(owner, name)
100	if !ok {
101		http.NotFound(w, r)
102		return
103	}
104	switch service := r.URL.Query().Get("service"); service {
105	case "git-upload-pack":
106		w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement")
107		w.Header().Set("Cache-Control", "no-cache")
108		pktLine(w, "# service=git-upload-pack\n")
109		pktFlush(w)
110		dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
111		cmd := exec.CommandContext(r.Context(), toolpath.Look("git"), "upload-pack", "--stateless-rpc", "--advertise-refs", dir)
112		cmd.Env = append(os.Environ(), gitProtocolEnv(r)...)
113		cmd.Stdout = w
114		cmd.Run()
115	case "git-receive-pack":
116		// HTTP 200 with a pkt-line ERR: every git version renders this as
117		// "fatal: remote error: ..." and never falls back to credential
118		// prompting the way a 401/403 would.
119		w.Header().Set("Content-Type", "application/x-git-receive-pack-advertisement")
120		w.Header().Set("Cache-Control", "no-cache")
121		pktLine(w, "# service=git-receive-pack\n")
122		pktFlush(w)
123		pktLine(w, "ERR "+s.pushRefusalMessage(owner, name)+"\n")
124	default:
125		// Dumb-protocol clients are not supported.
126		http.NotFound(w, r)
127	}
128}
129
130func (s *Server) uploadPack(w http.ResponseWriter, r *http.Request) {
131	repo, ok := s.publicRepo(r.PathValue("owner"), r.PathValue("repo"))
132	if !ok {
133		http.NotFound(w, r)
134		return
135	}
136	body := io.Reader(r.Body)
137	if r.Header.Get("Content-Encoding") == "gzip" {
138		gz, err := gzip.NewReader(body)
139		if err != nil {
140			http.Error(w, "bad gzip body", http.StatusBadRequest)
141			return
142		}
143		defer gz.Close()
144		body = gz
145	}
146	br := bufio.NewReader(body)
147	cancel := r.Context().Done()
148	out := io.Writer(w)
149	if !lsRefs(br) {
150		o, kill, finish, ok := s.packSlot(w, r)
151		if !ok {
152			return
153		}
154		// Deferred before git runs, so it fires after git has exited
155		// and been waited for.
156		defer finish()
157		out, cancel = o, kill
158	}
159	w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
160	w.Header().Set("Cache-Control", "no-cache")
161	dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
162	cmd := exec.Command(toolpath.Look("git"), "-c", "uploadpack.keepAlive=5", "upload-pack", "--stateless-rpc", dir)
163	cmd.Env = append(os.Environ(), gitProtocolEnv(r)...)
164	cmd.Stdin = br
165	cmd.Stdout = out
166	gitutil.RunUntil(cmd, cancel)
167}
168
169// packSlot takes a pack-generation slot for r, answering 503 with
170// Retry-After when none comes free. A queued request waits at most the
171// limiter's wait, and Stop ends the wait so it does not hold up a
172// restart's drain; net/http notices a departed client only after the
173// body is read, so that rarely ends it. On success out is w watched for
174// stalls, kill closes when git must stop — the client left, or no write
175// to it completed for packlimit.StallDeadline — and finish, called once
176// git has exited, releases the slot.
177func (s *Server) packSlot(w http.ResponseWriter, r *http.Request) (out io.Writer, kill <-chan struct{}, finish func(), ok bool) {
178	principal := s.packPrincipal(r)
179	release, err := s.packs.Acquire(s.until(r), principal)
180	if err != nil {
181		s.packs.Refused("http", principal, err)
182		msg := "the server is restarting; try again in a minute"
183		if errors.Is(err, packlimit.ErrBusy) {
184			msg = "the server is busy: it is at its limit of concurrent clones and fetches; try again in a minute"
185		}
186		w.Header().Set("Retry-After", "30")
187		http.Error(w, msg, http.StatusServiceUnavailable)
188		return nil, nil, nil, false
189	}
190	out, stalled, unwatch := s.packs.Watch(w)
191	killed := make(chan struct{})
192	finished := make(chan struct{})
193	exited := make(chan struct{})
194	go func() {
195		defer close(exited)
196		select {
197		case <-finished:
198			return
199		case <-r.Context().Done():
200		case <-stalled:
201			// A write blocked on a client that stopped reading, or a
202			// read of a body it stopped sending, outlives git;
203			// expired deadlines end both copies, so Wait returns.
204			rc := http.NewResponseController(w)
205			rc.SetReadDeadline(time.Now())
206			rc.SetWriteDeadline(time.Now())
207		}
208		close(killed)
209	}()
210	return out, killed, func() {
211		// The watcher must not touch w once the handler has returned.
212		close(finished)
213		<-exited
214		unwatch()
215		release()
216	}, true
217}
218
219// lsRefs reports whether a protocol v2 request is a ref listing, which
220// generates no pack. Its first pkt-line is "command=ls-refs".
221func lsRefs(br *bufio.Reader) bool {
222	const want = "command=ls-refs"
223	head, err := br.Peek(4 + len(want))
224	return err == nil && string(head[4:]) == want
225}
226
227// packPrincipal is who a fetch is counted against: the account when the
228// request carries a valid bearer token or web session, the same key SSH
229// uses, so switching transport buys no extra slots; otherwise the
230// client address, an IPv6 one by its /64.
231func (s *Server) packPrincipal(r *http.Request) string {
232	if tok, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok && strings.TrimSpace(tok) != "" {
233		if u, _, err := s.st.APITokenUser(store.HashToken(strings.TrimSpace(tok))); err == nil {
234			return "user:" + strconv.FormatInt(u.ID, 10)
235		}
236	}
237	if u := s.viewer(r); u.ID != 0 {
238		return "user:" + strconv.FormatInt(u.ID, 10)
239	}
240	return packlimit.AddrPrincipal(s.clientIP(r))
241}
242
243// gitProtocolEnv forwards the client's protocol negotiation header so
244// protocol v2 works over stateless HTTP.
245func gitProtocolEnv(r *http.Request) []string {
246	if p := r.Header.Get("Git-Protocol"); p != "" {
247		return []string{"GIT_PROTOCOL=" + p}
248	}
249	return nil
250}