mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-09 15:49:16 -05:00
* Serve Git repositories from a local cache
Named upstream.git hosts are served read-only over Git's smart HTTP
protocol at /git/{name}/{repo}, so git clone and git fetch work against
the proxy through an insteadOf rewrite. Each repository is a bare clone
at git.cache_dir/{name}-{hash of the upstream URL}/{repo}.git, served
by git upload-pack.
Every request for refs checks the upstream's branches, tags and HEAD
with a protocol v2 ls-refs limited to those prefixes, and fetches only
when they changed, atomically and with --prune. Concurrent requests
share one refresh. Only branches and tags are cached, and HEAD follows
the upstream's default branch.
A commit a client asks for by ID that the cache lacks, such as a pull
request head, is fetched and kept under a hidden ref until a branch or
tag includes it or for 7 days. One the upstream cannot provide is not
asked for again for a minute, or an hour when it is larger than
git.max_repo_size leaves. This is off for upstreams upstream.auth has
credentials for, since GitHub serves commits from any repository in a
fork network.
git never reaches an upstream itself. Its fetches go through a loopback
relay that forwards with the proxy's upstream client, so address
checks, upstream.auth and the access log apply, and git never sees a
credential. Redirects are not followed, and git runs with an
environment of its own.
The cached copy is served while the upstream fails or is slower than
git.refresh_wait, until git.stale_max_age has passed since the last
successful check. Refusals and missing repositories are never answered
from the cache.
git.max_repo_size cuts a transfer off as it streams, and every fetched
pack is kept as a pack, so a cut-off leaves nothing behind. A
repository that can no longer be refreshed within the limit is cloned
again with only its branches and tags. git.max_size evicts the least
recently used repositories, never one in use. At startup only the
directory of a repointed upstream's old URL is removed, and Close waits
for refreshes in progress.
Hosts with upstream.auth credentials need a repos allowlist, and a
repository outside it answers 404.
Refs #403
* Document setting the Git rewrite through environment variables
The Git section of the configuration docs now explains what insteadOf
does, with the URL a clone fetches from, and shows the same rewrites set
through GIT_CONFIG_COUNT, GIT_CONFIG_KEY_n and GIT_CONFIG_VALUE_n for
CI. Package managers that run git for Git dependencies inherit those
variables, so their fetches are cached too; npm's GitHub tarball
downloads do not go through git and are not.
Refs #403
* Note that upstream.auth credentials reach every route to a host
Credentials are matched by URL, so a token configured for a Git upstream
is also sent by a generic upstream at the same URL, which forwards any
path and checks no repos allowlist. With a token for a private GitHub
org, /generic/github/acme/private.git/info/refs would list that
repository's refs to anyone who can reach the proxy.
* Cap concurrent upload-pack processes and request body size
Every POST git-upload-pack started a pack-objects with git's defaults,
a delta-search thread per CPU and unbounded window memory, and nothing
limited how many ran at once, so a burst of CI clones of one large
repository could run the proxy out of memory. At most two upload-pack
processes per CPU (GOMAXPROCS, so a container's limit) now build packs
at once, and further requests wait for a slot while the client is
connected. ls-refs builds no pack and does not wait. Serving runs with
pack.threads=1 and pack.windowMemory=64m, and every git process with
core.bigFileThreshold=8m, so index-pack streams large files instead of
holding them in memory.
A request body was streamed to upload-pack unbounded, so a small gzip
body could feed it an arbitrarily long negotiation for as long as the
request may run. Bodies are now limited to 100MB, both as received and
decoded, and one past the limit fails with an error instead of being
cut short quietly.
* Apply refresh_wait to the ref check only
refresh_wait cut off the whole refresh, fetch included, so after a push
with a large pack a client got the old refs once it passed, though the
upstream was fine and the docs say a clone right after a push sees the
new commit. It now bounds only the check of the upstream's branches and
tags. Once the check finds they changed, requests wait for the fetch;
one that fails, or stalls and is abandoned by http.lowSpeedTime, still
falls back to the cached copy within stale_max_age.
* Lock cache_dir against a second process
The cache clears its temporary directory at startup, so a second
process given the same cache_dir, such as a second pod on a shared
volume, would delete the first one's clones in progress, and each
process's eviction knows only its own readers. New now locks
{cache_dir}/.gitcache-lock before touching anything and holds it until
Close: flock on Unix, a handle opened without sharing on Windows. The
OS releases it when the process exits, so a crashed process can
restart. A second process fails to start with an error naming the
directory.
* Evict again once repositories in use are released
Eviction skips repositories a request or refresh is using, and only the
release of one too large to refresh ran it again, so a cache filled past
max_size while its repositories were in use stayed over it once they
went idle, until the next fetch. Eviction now records when it is left
over max_size, and the next release runs it again.
* Cap git.max_repo_size at git.max_size
A repository larger than max_size could still be cloned, up to
max_repo_size, which defaults to 5GB whatever max_size is. Once released
it was evicted to bring the cache back within max_size, and the next
request cloned it again. max_size now caps max_repo_size, so such a
repository is refused for an hour like any other too large one, with an
error that names both limits.
* Count fetches in progress toward git.max_size
Fetches enforced only max_repo_size, and eviction ran after each one,
so up to 16 concurrent fetches could take the cache past max_size by as
much as each may download. Each fetch now reserves space within
max_size as the relay receives its bytes, a chunk at a time, evicting
least recently used idle repositories to make room. When the fetch
ends, the repository's measured size replaces the reservation under the
cache lock, so its bytes are neither counted twice nor missed.
When evicting every idle repository would not make room, none is
evicted for the fetch, and it is cut off with ErrCacheFull rather than
ErrTooLarge, so the repository is not refused for an hour: a clone
answers 507, and a refresh serves the cached copy as when the upstream
fails. A fetch that runs out of room while another has just been cut
off waits, up to a minute, for that one's space, so two fetches that do
not fit together do not both fail. A fetch that was cut off never
waits, so no two wait on each other.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
224 lines
7.2 KiB
Go
224 lines
7.2 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"path"
|
|
"strings"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const (
|
|
defaultGitCacheDir = "./cache/git"
|
|
defaultGitMaxRepoSize = "5GB"
|
|
defaultGitRefreshWait = "5s"
|
|
defaultGitStaleMaxAge = "24h"
|
|
)
|
|
|
|
// GitUpstream is an upstream.git entry: a Git server's base URL and,
|
|
// optionally, the repositories under it that may be served. In YAML or JSON
|
|
// it is either the URL alone or an object with url and repos.
|
|
type GitUpstream struct {
|
|
URL string `json:"url" yaml:"url"`
|
|
|
|
// Repos lists path.Match patterns for repository paths under URL, such
|
|
// as "mediawiki/*". A pattern ending in "/**" also matches every
|
|
// repository nested below its prefix: "mediawiki/**" matches
|
|
// mediawiki/skins/Vector. Empty allows every repository. Required when
|
|
// upstream.auth has credentials for the URL, since everyone who can
|
|
// reach the proxy can read what it serves.
|
|
Repos []string `json:"repos" yaml:"repos"`
|
|
}
|
|
|
|
// UnmarshalYAML accepts a bare URL as well as the object form.
|
|
func (g *GitUpstream) UnmarshalYAML(node *yaml.Node) error {
|
|
if node.Kind == yaml.ScalarNode {
|
|
return node.Decode(&g.URL)
|
|
}
|
|
type plain GitUpstream
|
|
return node.Decode((*plain)(g))
|
|
}
|
|
|
|
// UnmarshalJSON accepts a bare URL as well as the object form.
|
|
func (g *GitUpstream) UnmarshalJSON(data []byte) error {
|
|
if err := json.Unmarshal(data, &g.URL); err == nil {
|
|
return nil
|
|
}
|
|
type plain GitUpstream
|
|
return json.Unmarshal(data, (*plain)(g))
|
|
}
|
|
|
|
// AllowsRepo reports whether repo, a path under URL, may be served.
|
|
func (g GitUpstream) AllowsRepo(repo string) bool {
|
|
if len(g.Repos) == 0 {
|
|
return true
|
|
}
|
|
for _, pattern := range g.Repos {
|
|
if matchRepoPattern(pattern, repo) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func matchRepoPattern(pattern, repo string) bool {
|
|
prefix, nested := strings.CutSuffix(pattern, "/**")
|
|
if !nested {
|
|
ok, _ := path.Match(pattern, repo)
|
|
return ok
|
|
}
|
|
segments := strings.Count(prefix, "/") + 1
|
|
parts := strings.SplitN(repo, "/", segments+1)
|
|
if len(parts) <= segments {
|
|
return false
|
|
}
|
|
ok, _ := path.Match(prefix, strings.Join(parts[:segments], "/"))
|
|
return ok
|
|
}
|
|
|
|
// GitConfig configures the Git repository cache behind /git/{name}/.
|
|
type GitConfig struct {
|
|
// CacheDir holds the cached bare repositories. It must be local disk
|
|
// used by this process only, which locks it. Default: ./cache/git.
|
|
CacheDir string `json:"cache_dir" yaml:"cache_dir"`
|
|
|
|
// MaxSize bounds the cache, counting fetches in progress; the least
|
|
// recently used repositories not in use are evicted to stay within it.
|
|
// Empty or "0" disables the limit.
|
|
MaxSize string `json:"max_size" yaml:"max_size"`
|
|
|
|
// MaxRepoSize is the most one repository may take; a clone or fetch that
|
|
// would pass it fails. "0" disables the limit. Default: 5GB. MaxSize
|
|
// caps it when set.
|
|
MaxRepoSize string `json:"max_repo_size" yaml:"max_repo_size"`
|
|
|
|
// RefreshWait is how long a request waits for the upstream's branches
|
|
// and tags to be checked before it is served the cached copy. A fetch of
|
|
// new commits the check finds is waited for in full. Default: 5s.
|
|
RefreshWait string `json:"refresh_wait" yaml:"refresh_wait"`
|
|
|
|
// StaleMaxAge is how long after the last successful upstream check a
|
|
// cached copy may still be served when the upstream fails or is slow.
|
|
// Default: 24h.
|
|
StaleMaxAge string `json:"stale_max_age" yaml:"stale_max_age"`
|
|
|
|
maxSize int64
|
|
maxRepoSize int64
|
|
refreshWait time.Duration
|
|
staleMaxAge time.Duration
|
|
}
|
|
|
|
// MaxSizeBytes returns the parsed MaxSize, 0 for no limit. Validate must run first.
|
|
func (g *GitConfig) MaxSizeBytes() int64 { return g.maxSize }
|
|
|
|
// MaxRepoSizeBytes returns the parsed MaxRepoSize, 0 for no limit. Validate must run first.
|
|
func (g *GitConfig) MaxRepoSizeBytes() int64 { return g.maxRepoSize }
|
|
|
|
// RefreshWaitDuration returns the parsed RefreshWait. Validate must run first.
|
|
func (g *GitConfig) RefreshWaitDuration() time.Duration { return g.refreshWait }
|
|
|
|
// StaleMaxAgeDuration returns the parsed StaleMaxAge. Validate must run first.
|
|
func (g *GitConfig) StaleMaxAgeDuration() time.Duration { return g.staleMaxAge }
|
|
|
|
// Validate checks the Git cache settings, applying defaults where unset.
|
|
func (g *GitConfig) Validate() error {
|
|
if g.CacheDir == "" {
|
|
g.CacheDir = defaultGitCacheDir
|
|
}
|
|
if g.MaxRepoSize == "" {
|
|
g.MaxRepoSize = defaultGitMaxRepoSize
|
|
}
|
|
if g.RefreshWait == "" {
|
|
g.RefreshWait = defaultGitRefreshWait
|
|
}
|
|
if g.StaleMaxAge == "" {
|
|
g.StaleMaxAge = defaultGitStaleMaxAge
|
|
}
|
|
|
|
var err error
|
|
if g.maxSize, err = parseNonNegativeSize("git.max_size", g.MaxSize); err != nil {
|
|
return err
|
|
}
|
|
if g.maxRepoSize, err = parseNonNegativeSize("git.max_repo_size", g.MaxRepoSize); err != nil {
|
|
return err
|
|
}
|
|
if g.refreshWait, err = parseNonNegativeDuration("git.refresh_wait", g.RefreshWait); err != nil {
|
|
return err
|
|
}
|
|
if g.staleMaxAge, err = parseNonNegativeDuration("git.stale_max_age", g.StaleMaxAge); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseNonNegativeSize(field, value string) (int64, error) {
|
|
size, err := ParseSize(value)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid %s: %w", field, err)
|
|
}
|
|
if size < 0 {
|
|
return 0, fmt.Errorf("invalid %s %q: must not be negative", field, value)
|
|
}
|
|
return size, nil
|
|
}
|
|
|
|
func parseNonNegativeDuration(field, value string) (time.Duration, error) {
|
|
d, err := time.ParseDuration(value)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid %s %q: %w", field, value, err)
|
|
}
|
|
if d < 0 {
|
|
return 0, fmt.Errorf("invalid %s %q: must not be negative", field, value)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// validateGitUpstreams checks upstream.git names, URLs and repository
|
|
// patterns. An upstream that upstream.auth has credentials for must list its
|
|
// repositories, so configuring a token does not open every repository it can
|
|
// read to everyone who can reach the proxy.
|
|
func (u *UpstreamConfig) validateGitUpstreams() error {
|
|
for name, upstream := range u.Git {
|
|
field := "upstream.git." + name
|
|
if name == "" || name == "." || name == ".." || name[0] == '.' || strings.ContainsAny(name, `/\\`) {
|
|
return fmt.Errorf("invalid upstream.git name %q", name)
|
|
}
|
|
if err := validateAbsoluteURL(field, upstream.URL); err != nil {
|
|
return err
|
|
}
|
|
if scheme, _, _ := strings.Cut(upstream.URL, "://"); !strings.EqualFold(scheme, "https") && !strings.EqualFold(scheme, "http") {
|
|
return fmt.Errorf("invalid %s %q: only http and https are supported", field, upstream.URL)
|
|
}
|
|
for _, pattern := range upstream.Repos {
|
|
if _, err := path.Match(strings.TrimSuffix(pattern, "/**"), ""); pattern == "" || err != nil {
|
|
return fmt.Errorf("invalid %s.repos pattern %q", field, pattern)
|
|
}
|
|
}
|
|
if len(upstream.Repos) == 0 && u.HasAuthWithin(upstream.URL) {
|
|
return fmt.Errorf("%s needs repos: upstream.auth has credentials for %s, "+
|
|
"and everyone who can reach the proxy could read every repository they allow", field, upstream.URL)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// HasAuthWithin reports whether an upstream.auth scope covers baseURL or a
|
|
// path under it.
|
|
func (u *UpstreamConfig) HasAuthWithin(baseURL string) bool {
|
|
base, err := parseAuthURL(baseURL)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for scope := range u.Auth {
|
|
configured, err := parseAuthURL(scope)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if authURLMatches(configured, base) || authURLMatches(base, configured) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|