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