internal/httpd/smart.go
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}