Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 15:49:16 -05:00
pkg-proxy/internal/handler/git.go
Simon Chrzanowski fe4be3ccd6
Reuse batch download URLs and stored copies for LFS objects
- 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.
2026-10-25 18:19:09 +00:00

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)
}
}