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