mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-09 15:49:16 -05:00
- Keep the download URLs of a batch response in memory until shortly before they expire, so the GET that follows needs no second batch request. A kept URL that fails is replaced by a fresh one. - Cut off a download that isn't the object's size. - Copy an object whose content is already stored, such as for a fork, instead of downloading it, once the upstream has confirmed the repository has it. Artifacts are indexed by content hash for this.
309 lines
10 KiB
Go
309 lines
10 KiB
Go
package handler
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/git-pkgs/proxy/internal/config"
|
|
"github.com/git-pkgs/proxy/internal/gitcache"
|
|
"github.com/git-pkgs/proxy/internal/metrics"
|
|
)
|
|
|
|
const (
|
|
gitEcosystem = "git"
|
|
|
|
gitInfoRefs = "/info/refs"
|
|
gitUploadPack = "/git-upload-pack"
|
|
gitReceivePack = "/git-receive-pack"
|
|
gitRequestType = "application/x-git-upload-pack-request"
|
|
|
|
// gitRequestTimeout replaces the server's read and write timeouts for Git
|
|
// requests once any request body has been read: a cold clone of a large
|
|
// repository keeps the client waiting before the first byte, and a large
|
|
// pack takes a while to send.
|
|
gitRequestTimeout = 2 * time.Hour
|
|
// gitWriteStallTimeout bounds each write of a Git response, so a client
|
|
// that stops reading does not hold an upload-pack process for
|
|
// gitRequestTimeout.
|
|
gitWriteStallTimeout = 2 * time.Minute
|
|
)
|
|
|
|
// GitHandler serves Git repositories read-only over smart HTTP at
|
|
// /git/{name}/{repo}, from a local cache of the upstream.git hosts. Clients
|
|
// clone and fetch with ordinary git commands, usually through an insteadOf
|
|
// rewrite. Pushing and the dumb protocol are refused.
|
|
type GitHandler struct {
|
|
proxy *Proxy
|
|
cache *gitcache.Cache
|
|
upstreams map[string]gitUpstream
|
|
// baseURL is where clients reach the proxy, for LFS download URLs.
|
|
baseURL string
|
|
// lfsDownloads keeps upstream LFS download URLs between a batch and
|
|
// the GETs that follow it.
|
|
lfsDownloads *lfsDownloads
|
|
// writeStall is gitWriteStallTimeout, shorter in tests.
|
|
writeStall time.Duration
|
|
}
|
|
|
|
type gitUpstream struct {
|
|
config.GitUpstream
|
|
// dir names the upstream's directory in the cache (gitcache.UpstreamDir).
|
|
dir string
|
|
// fetchWants is off when upstream.auth has credentials for the host. A
|
|
// fetch by ID there could reach commits outside the repos allowlist,
|
|
// as GitHub serves any commit in a repository's fork network.
|
|
fetchWants bool
|
|
}
|
|
|
|
// NewGitHandler creates a Git handler serving upstream.git from cache.
|
|
func NewGitHandler(proxy *Proxy, cache *gitcache.Cache, upstream config.UpstreamConfig) *GitHandler {
|
|
h := &GitHandler{
|
|
proxy: proxy,
|
|
cache: cache,
|
|
upstreams: make(map[string]gitUpstream, len(upstream.Git)),
|
|
writeStall: gitWriteStallTimeout,
|
|
lfsDownloads: &lfsDownloads{},
|
|
}
|
|
for name, git := range upstream.Git {
|
|
git.URL = strings.TrimSuffix(git.URL, "/")
|
|
h.upstreams[name] = gitUpstream{
|
|
GitUpstream: git,
|
|
dir: gitcache.UpstreamDir(name, git.URL),
|
|
fetchWants: !upstream.HasAuthWithin(git.URL),
|
|
}
|
|
}
|
|
return h
|
|
}
|
|
|
|
// Routes returns the HTTP handler for Git requests. Mount this at /git.
|
|
func (h *GitHandler) Routes() http.Handler {
|
|
return http.HandlerFunc(h.serve)
|
|
}
|
|
|
|
// gitRequest is a parsed /git/{name}/{repo}/{endpoint} request.
|
|
type gitRequest struct {
|
|
infoRefs bool
|
|
fetchWants bool
|
|
// key names the repository in the cache: {name}-{URL hash}/{repo}.git,
|
|
// with any .git the client gave removed from repo first, so both
|
|
// spellings share one copy.
|
|
key string
|
|
// repoURL is the upstream repository, spelled as the client spelled it.
|
|
repoURL string
|
|
}
|
|
|
|
func (h *GitHandler) serve(w http.ResponseWriter, r *http.Request) {
|
|
if repoPath, endpoint, ok := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), gitLFSPath); ok {
|
|
h.serveLFS(w, r, repoPath, endpoint)
|
|
return
|
|
}
|
|
req, status, msg := h.parse(r)
|
|
if status != 0 {
|
|
http.Error(w, msg, status)
|
|
return
|
|
}
|
|
// An upload-pack request is read in full first, within the server's read
|
|
// timeout, so nothing waits on a client that is slow to send one.
|
|
var body *gitcache.Request
|
|
if !req.infoRefs {
|
|
var err error
|
|
if body, err = h.cache.ReadRequest(r); err != nil {
|
|
h.serveRequestError(w, req, err)
|
|
return
|
|
}
|
|
defer body.Close()
|
|
}
|
|
// The read deadline is extended too: once the body is read, an HTTP/1
|
|
// server watches the connection, and a read timeout there would cancel
|
|
// the request.
|
|
rc := http.NewResponseController(w)
|
|
deadline := time.Now().Add(gitRequestTimeout)
|
|
if err := errors.Join(rc.SetReadDeadline(deadline), rc.SetWriteDeadline(deadline)); err != nil {
|
|
h.proxy.Logger.Warn("git: cannot extend the request deadlines", "error", err)
|
|
}
|
|
w = &stallWriter{ResponseWriter: w, rc: rc, stall: h.writeStall, deadline: deadline}
|
|
// What is left buffered once the handler returns is bounded as well.
|
|
defer func() { _ = rc.SetWriteDeadline(time.Now().Add(h.writeStall)) }()
|
|
|
|
lease, err := h.cache.Open(r.Context(), req.key, req.repoURL,
|
|
gitcache.OpenOptions{Refresh: req.infoRefs, FetchWants: req.fetchWants})
|
|
if err != nil {
|
|
h.serveError(w, r, err)
|
|
return
|
|
}
|
|
defer lease.Release()
|
|
if !req.infoRefs {
|
|
lease.ServeUploadPack(w, r, body)
|
|
return
|
|
}
|
|
if lease.Hit() {
|
|
metrics.RecordArtifactCacheHit(gitEcosystem)
|
|
} else {
|
|
metrics.RecordArtifactCacheMiss(gitEcosystem)
|
|
}
|
|
lease.ServeInfoRefs(w, r)
|
|
}
|
|
|
|
func (h *GitHandler) parse(r *http.Request) (gitRequest, int, string) {
|
|
path := strings.TrimPrefix(r.URL.Path, "/")
|
|
var req gitRequest
|
|
switch {
|
|
case strings.HasSuffix(path, gitInfoRefs):
|
|
path = strings.TrimSuffix(path, gitInfoRefs)
|
|
req.infoRefs = true
|
|
switch r.URL.Query().Get("service") {
|
|
case "git-upload-pack":
|
|
case "git-receive-pack":
|
|
return req, http.StatusForbidden, "pushing is not supported"
|
|
default:
|
|
return req, http.StatusForbidden, "only the smart HTTP protocol is supported"
|
|
}
|
|
if r.Method != http.MethodGet {
|
|
return req, http.StatusMethodNotAllowed, "method not allowed"
|
|
}
|
|
case strings.HasSuffix(path, gitUploadPack):
|
|
path = strings.TrimSuffix(path, gitUploadPack)
|
|
if r.Method != http.MethodPost {
|
|
return req, http.StatusMethodNotAllowed, "method not allowed"
|
|
}
|
|
if r.Header.Get("Content-Type") != gitRequestType {
|
|
return req, http.StatusUnsupportedMediaType, "unsupported content type"
|
|
}
|
|
case strings.HasSuffix(path, gitReceivePack):
|
|
return req, http.StatusForbidden, "pushing is not supported"
|
|
default:
|
|
return req, http.StatusNotFound, "not found"
|
|
}
|
|
|
|
repo, status, msg := h.repository(path)
|
|
if status != 0 {
|
|
return req, status, msg
|
|
}
|
|
req.key = repo.key
|
|
req.repoURL = repo.url
|
|
req.fetchWants = repo.upstream.fetchWants
|
|
return req, 0, ""
|
|
}
|
|
|
|
// gitRepository is a repository of an upstream.git host, as a request path
|
|
// names it.
|
|
type gitRepository struct {
|
|
// name is the upstream's name and repo the path under it, without .git.
|
|
name, repo string
|
|
upstream gitUpstream
|
|
// key names the repository in the cache: {name}-{URL hash}/{repo}.git,
|
|
// with any .git the client gave removed from repo first, so both
|
|
// spellings share one copy.
|
|
key string
|
|
// url is the upstream repository, spelled as the client spelled it, and
|
|
// escapedRepo is repo escaped for a URL path.
|
|
url, escapedRepo string
|
|
}
|
|
|
|
// repository resolves {name}/{repo} to an upstream repository, or returns
|
|
// the status and message to refuse it with.
|
|
func (h *GitHandler) repository(path string) (gitRepository, int, string) {
|
|
name, repoPath, _ := strings.Cut(path, "/")
|
|
upstream, ok := h.upstreams[name]
|
|
if !ok {
|
|
return gitRepository{}, http.StatusNotFound, "unknown upstream"
|
|
}
|
|
segments := strings.Split(repoPath, "/")
|
|
last := len(segments) - 1
|
|
suffix := ""
|
|
if trimmed, ok := strings.CutSuffix(segments[last], ".git"); ok {
|
|
segments[last], suffix = trimmed, ".git"
|
|
}
|
|
escaped := make([]string, len(segments))
|
|
for i, segment := range segments {
|
|
if !validGitSegment(segment, i == last) {
|
|
return gitRepository{}, http.StatusNotFound, "invalid repository path"
|
|
}
|
|
escaped[i] = url.PathEscape(segment)
|
|
}
|
|
repo := strings.Join(segments, "/")
|
|
if !upstream.AllowsRepo(repo) {
|
|
// As for a missing repository, so a prober learns nothing about
|
|
// the allowlist.
|
|
return gitRepository{}, http.StatusNotFound, "repository not found"
|
|
}
|
|
escapedRepo := strings.Join(escaped, "/")
|
|
return gitRepository{
|
|
name: name,
|
|
repo: repo,
|
|
upstream: upstream,
|
|
key: upstream.dir + "/" + repo + ".git",
|
|
url: upstream.URL + "/" + escapedRepo + suffix,
|
|
escapedRepo: escapedRepo,
|
|
}, 0, ""
|
|
}
|
|
|
|
// validGitSegment checks one segment of a repository path, which becomes both
|
|
// part of a directory under the cache and part of the upstream URL. Only the
|
|
// last segment may end in .git, so one repository's directory never sits
|
|
// inside another's.
|
|
func validGitSegment(segment string, last bool) bool {
|
|
if segment == "" || segment == "." || segment == ".." || (!last && strings.HasSuffix(segment, ".git")) {
|
|
return false
|
|
}
|
|
for _, r := range segment {
|
|
if r < ' ' || r == 0x7f || r == '\\' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// serveRequestError answers an upload-pack request whose body could not be
|
|
// read.
|
|
func (h *GitHandler) serveRequestError(w http.ResponseWriter, req gitRequest, err error) {
|
|
if errors.Is(err, gitcache.ErrRequestTooLarge) {
|
|
h.proxy.Logger.Warn("git: request body passed the limit", "repository", req.key)
|
|
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
|
|
return
|
|
}
|
|
http.Error(w, "cannot read the request body", http.StatusBadRequest)
|
|
}
|
|
|
|
// stallWriter bounds each write of a response by stall, where the response
|
|
// as a whole may take until deadline.
|
|
type stallWriter struct {
|
|
http.ResponseWriter
|
|
rc *http.ResponseController
|
|
stall time.Duration
|
|
deadline time.Time
|
|
}
|
|
|
|
func (w *stallWriter) Write(p []byte) (int, error) {
|
|
_ = w.rc.SetWriteDeadline(time.Now().Add(w.stall))
|
|
n, err := w.ResponseWriter.Write(p)
|
|
_ = w.rc.SetWriteDeadline(w.deadline)
|
|
return n, err
|
|
}
|
|
|
|
func (w *stallWriter) Unwrap() http.ResponseWriter {
|
|
return w.ResponseWriter
|
|
}
|
|
|
|
// serveError answers a request whose repository could not be opened. The
|
|
// cache has already logged why.
|
|
func (h *GitHandler) serveError(w http.ResponseWriter, r *http.Request, err error) {
|
|
if r.Context().Err() != nil {
|
|
return
|
|
}
|
|
switch {
|
|
case errors.Is(err, gitcache.ErrNotFound):
|
|
http.Error(w, "repository not found", http.StatusNotFound)
|
|
case errors.Is(err, gitcache.ErrRefused):
|
|
http.Error(w, "upstream refused the request", http.StatusBadGateway)
|
|
case errors.Is(err, gitcache.ErrTooLarge):
|
|
http.Error(w, "repository is larger than git.max_repo_size or git.max_size", http.StatusInsufficientStorage)
|
|
case errors.Is(err, gitcache.ErrCacheFull):
|
|
http.Error(w, "git cache is full", http.StatusInsufficientStorage)
|
|
default:
|
|
http.Error(w, "upstream unavailable", http.StatusBadGateway)
|
|
}
|
|
}
|