internal/httpd/smart.go

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

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