internal/httpd/smart.go

1ed9fb9399b21da8e6cf45be8389792aac82d5bc
gitbay/internal/httpd/smart.go history · blame · raw

155 lines · 5183 bytes

  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	"compress/gzip"
 10	"fmt"
 11	"io"
 12	"net"
 13	"net/http"
 14	"os"
 15	"os/exec"
 16	"strings"
 17	"sync"
 18
 19	"gitbay.org/gitbay/internal/config"
 20	"gitbay.org/gitbay/internal/control"
 21	"gitbay.org/gitbay/internal/store"
 22	"gitbay.org/gitbay/internal/toolpath"
 23)
 24
 25type Server struct {
 26	cfg      config.Config
 27	st       *store.Store
 28	apiLimit *apiLimiter
 29	proxies  []*net.IPNet  // http.trusted_proxies, parsed once
 30	stopping chan struct{} // closed by Stop
 31	stopOnce sync.Once
 32}
 33
 34func New(cfg config.Config, st *store.Store) *Server {
 35	proxies, _ := cfg.HTTP.TrustedProxyNets() // validated at config load
 36	return &Server{cfg: cfg, st: st, apiLimit: newAPILimiter(cfg.Limits.APIRate), proxies: proxies,
 37		stopping: make(chan struct{})}
 38}
 39
 40// Stop ends the requests running a command that lasts until something
 41// happens (build log --follow), so a shutdown drain waits only for work
 42// that finishes. Other requests, git transport included, run on.
 43func (s *Server) Stop() {
 44	s.stopOnce.Do(func() { close(s.stopping) })
 45}
 46
 47// until is closed when the request ends or the server stops, whichever
 48// comes first: the Done a following command runs under.
 49func (s *Server) until(r *http.Request) <-chan struct{} {
 50	done := make(chan struct{})
 51	go func() {
 52		select {
 53		case <-r.Context().Done():
 54		case <-s.stopping:
 55		}
 56		close(done)
 57	}()
 58	return done
 59}
 60
 61// receivePackRefusal exists only to fail legibly if a client POSTs without
 62// reading the advertisement first.
 63func (s *Server) receivePackRefusal(w http.ResponseWriter, r *http.Request) {
 64	http.Error(w, s.pushRefusalMessage(r.PathValue("owner"), r.PathValue("repo")), http.StatusForbidden)
 65}
 66
 67// publicRepo resolves owner/name and returns it only if it exists and is
 68// public. Every failure mode is the same 404.
 69func (s *Server) publicRepo(owner, name string) (store.Repo, bool) {
 70	repo, err := s.st.RepoByPath(owner + "/" + name)
 71	if err != nil || repo.Visibility != "public" {
 72		return store.Repo{}, false
 73	}
 74	return repo, true
 75}
 76
 77func pktLine(w io.Writer, s string) {
 78	fmt.Fprintf(w, "%04x%s", len(s)+4, s)
 79}
 80
 81func pktFlush(w io.Writer) { io.WriteString(w, "0000") }
 82
 83func (s *Server) pushRefusalMessage(owner, repo string) string {
 84	host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://"), "/")
 85	name := strings.TrimSuffix(repo, ".git")
 86	return fmt.Sprintf("pushes to this forge go over SSH: git remote set-url --push origin git@%s:%s/%s.git", host, owner, name)
 87}
 88
 89func (s *Server) infoRefs(w http.ResponseWriter, r *http.Request) {
 90	owner, name := r.PathValue("owner"), r.PathValue("repo")
 91	repo, ok := s.publicRepo(owner, name)
 92	if !ok {
 93		http.NotFound(w, r)
 94		return
 95	}
 96	switch service := r.URL.Query().Get("service"); service {
 97	case "git-upload-pack":
 98		w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement")
 99		w.Header().Set("Cache-Control", "no-cache")
100		pktLine(w, "# service=git-upload-pack\n")
101		pktFlush(w)
102		dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
103		cmd := exec.CommandContext(r.Context(), toolpath.Look("git"), "upload-pack", "--stateless-rpc", "--advertise-refs", dir)
104		cmd.Env = append(os.Environ(), gitProtocolEnv(r)...)
105		cmd.Stdout = w
106		cmd.Run()
107	case "git-receive-pack":
108		// HTTP 200 with a pkt-line ERR: every git version renders this as
109		// "fatal: remote error: ..." and never falls back to credential
110		// prompting the way a 401/403 would.
111		w.Header().Set("Content-Type", "application/x-git-receive-pack-advertisement")
112		w.Header().Set("Cache-Control", "no-cache")
113		pktLine(w, "# service=git-receive-pack\n")
114		pktFlush(w)
115		pktLine(w, "ERR "+s.pushRefusalMessage(owner, name)+"\n")
116	default:
117		// Dumb-protocol clients are not supported.
118		http.NotFound(w, r)
119	}
120}
121
122func (s *Server) uploadPack(w http.ResponseWriter, r *http.Request) {
123	repo, ok := s.publicRepo(r.PathValue("owner"), r.PathValue("repo"))
124	if !ok {
125		http.NotFound(w, r)
126		return
127	}
128	body := io.Reader(r.Body)
129	if r.Header.Get("Content-Encoding") == "gzip" {
130		gz, err := gzip.NewReader(body)
131		if err != nil {
132			http.Error(w, "bad gzip body", http.StatusBadRequest)
133			return
134		}
135		defer gz.Close()
136		body = gz
137	}
138	w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
139	w.Header().Set("Cache-Control", "no-cache")
140	dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
141	cmd := exec.CommandContext(r.Context(), toolpath.Look("git"), "upload-pack", "--stateless-rpc", dir)
142	cmd.Env = append(os.Environ(), gitProtocolEnv(r)...)
143	cmd.Stdin = body
144	cmd.Stdout = w
145	cmd.Run()
146}
147
148// gitProtocolEnv forwards the client's protocol negotiation header so
149// protocol v2 works over stateless HTTP.
150func gitProtocolEnv(r *http.Request) []string {
151	if p := r.Header.Get("Git-Protocol"); p != "" {
152		return []string{"GIT_PROTOCOL=" + p}
153	}
154	return nil
155}