A home proxy that chains to a remote tiny-proxy and falls back to the real upstreams needs two things it did not have. 502, 503 and 504 now fall through to the next upstream in the maven, debian, pacman and docker walks. They are what an ingress answers when a chained tiny-proxy is down, and what tiny-proxy itself answers (502 upstream error) when its own upstream fails — either way the object is not bad, the path to it is. A 500 is still the upstream's own answer and is returned as-is, as is anything the last upstream says, and a throttle seen earlier still turns a trailing 404 into a 429. Docker vrepos take `upstreams:` like the others. Each entry brings its own client, cache flag and retry budget, and a blob resume goes back to the upstream that served it. An upstream URL whose path starts with /v2/ is a repository root — another tiny-proxy's vrepo — so the image path is appended as-is rather than getting a second /v2/. The library/ prefix is now decided by the upstream's host instead of a substring of its whole URL, so a chained proxy is not handed library/ twice. When nothing answers at all the last transport error is returned, so a single unreachable registry still reads as 502 rather than as a missing image. github and gitea stay single-upstream: they speak a REST API, not a path-for-path mirror. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
284 lines
9.9 KiB
Go
284 lines
9.9 KiB
Go
// Package docker implements the OCI/Docker Registry v2 pull-through cache.
|
|
// The first path segment after /v2/ names the virtual repo:
|
|
//
|
|
// /v2/<vrepo>/<name...>/manifests/<ref>
|
|
// /v2/<vrepo>/<name...>/blobs/<digest>
|
|
//
|
|
// Multiple upstreams may be configured as an ordered fallback list, e.g. a
|
|
// chained tiny-proxy first and the registry itself second.
|
|
package docker
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"tiny-proxy/internal/auth"
|
|
"tiny-proxy/internal/config"
|
|
"tiny-proxy/internal/metrics"
|
|
"tiny-proxy/internal/proxycache"
|
|
"tiny-proxy/internal/upstream"
|
|
)
|
|
|
|
// upstreamEntry pairs one upstream's config with its HTTP client.
|
|
type upstreamEntry struct {
|
|
conf config.UpstreamConf
|
|
client *http.Client
|
|
}
|
|
|
|
type repo struct {
|
|
cfg config.VRepo
|
|
upstreams []upstreamEntry
|
|
}
|
|
|
|
type Handler struct {
|
|
repos map[string]*repo
|
|
cache *proxycache.Cache
|
|
}
|
|
|
|
// NewHandler builds a docker handler. clients[vrepo] must have one client
|
|
// per entry of vrepo.AllUpstreams(), in the same order.
|
|
func NewHandler(repos []config.VRepo, clients map[string][]*http.Client, cache *proxycache.Cache) *Handler {
|
|
h := &Handler{repos: map[string]*repo{}, cache: cache}
|
|
for _, rc := range repos {
|
|
if rc.Type != config.VRepoDocker {
|
|
continue
|
|
}
|
|
rp := &repo{cfg: rc}
|
|
for i, u := range rc.AllUpstreams() {
|
|
rp.upstreams = append(rp.upstreams, upstreamEntry{conf: u, client: clients[rc.Name][i]})
|
|
}
|
|
h.repos[rc.Name] = rp
|
|
}
|
|
return h
|
|
}
|
|
|
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
rest := strings.TrimPrefix(r.URL.Path, "/v2/")
|
|
if rest == "" {
|
|
// API version ping; docker clients probe this before anything else.
|
|
w.Header().Set("Docker-Distribution-Api-Version", "registry/2.0")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte("{}\n"))
|
|
return
|
|
}
|
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
http.Error(w, "read-only registry", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
vrepo, name, kind, ref, ok := parsePath(rest)
|
|
if !ok {
|
|
http.Error(w, "bad path", http.StatusBadRequest)
|
|
return
|
|
}
|
|
rp, exists := h.repos[vrepo]
|
|
if !exists {
|
|
http.Error(w, "unknown repository", http.StatusNotFound)
|
|
return
|
|
}
|
|
if !auth.Check(rp.cfg.Auth, r) {
|
|
auth.Unauthorized(w)
|
|
return
|
|
}
|
|
// Configured and authorized, so it is safe as a metrics label; the cache
|
|
// reads it back from the context to attribute its hits and misses.
|
|
metrics.MarkVRepo(r.Context(), vrepo)
|
|
|
|
var key string
|
|
var ttl = rp.cfg.MutableTTL()
|
|
if kind == "manifests" {
|
|
key = "docker/" + vrepo + "/manifests/" + name + "/" + ref
|
|
if strings.Contains(ref, ":") { // digest refs are immutable
|
|
ttl = 0
|
|
}
|
|
} else {
|
|
key = "docker/" + vrepo + "/blobs/" + ref
|
|
ttl = 0
|
|
}
|
|
|
|
h.cache.Serve(w, r, key, ttl, func(ctx context.Context) (proxycache.Result, error) {
|
|
return rp.fetch(ctx, r, vrepo, name, kind, ref)
|
|
})
|
|
}
|
|
|
|
// fetch walks the upstream list in configured order: 404, 401, 403, 429,
|
|
// 502, 503, 504 and transport errors fall through to the next upstream (a
|
|
// registry may not carry the image, reject anonymous pulls, throttle us, or
|
|
// — when the upstream is another tiny-proxy — be down behind its ingress);
|
|
// any other status is returned as-is, and so is whatever the last upstream
|
|
// answers. When no upstream served the object but one throttled us, the
|
|
// answer is a 429 rather than a 404, so the client retries instead of
|
|
// reporting the image as missing. Deliberately mirrors distro's fetch loop,
|
|
// as distro mirrors maven's. The one difference: when no upstream answered
|
|
// at all, the last transport error is returned, so a registry that is
|
|
// simply unreachable still reads as 502 rather than as a missing image. The
|
|
// result carries the answering upstream's cache policy and retry budget.
|
|
func (rp *repo) fetch(ctx context.Context, r *http.Request, vrepo, name, kind, ref string) (proxycache.Result, error) {
|
|
limited, retryAfter := false, "" // whether an upstream throttled us, and its Retry-After
|
|
answered := false // whether any upstream produced a response
|
|
var lastErr error
|
|
for i, up := range rp.upstreams {
|
|
upstreamURL := objectURL(up.conf.URL, normalizeName(up.conf.URL, name), kind, ref)
|
|
req, err := http.NewRequestWithContext(ctx, r.Method, upstreamURL, nil)
|
|
if err != nil {
|
|
return proxycache.Result{}, err
|
|
}
|
|
if accept := r.Header.Get("Accept"); accept != "" {
|
|
req.Header.Set("Accept", accept)
|
|
}
|
|
resp, err := up.client.Do(req)
|
|
if err != nil {
|
|
slog.WarnContext(ctx, "docker upstream error, trying next", "vrepo", vrepo, "name", name, "ref", ref, "url", up.conf.URL, "err", err)
|
|
lastErr = err
|
|
continue
|
|
}
|
|
answered = true
|
|
if resp.StatusCode == http.StatusTooManyRequests && !limited {
|
|
slog.WarnContext(ctx, "docker upstream rate limited", "vrepo", vrepo, "name", name, "ref", ref, "url", up.conf.URL)
|
|
limited, retryAfter = true, resp.Header.Get("Retry-After")
|
|
}
|
|
if shouldFallthrough(resp.StatusCode) && i+1 < len(rp.upstreams) {
|
|
resp.Body.Close()
|
|
slog.DebugContext(ctx, "docker upstream miss, trying next", "vrepo", vrepo, "name", name, "ref", ref, "url", up.conf.URL, "status", resp.StatusCode)
|
|
continue
|
|
}
|
|
if limited && resp.StatusCode == http.StatusNotFound {
|
|
// An upstream was throttled, so "not found" is not
|
|
// trustworthy: report the throttling instead.
|
|
resp.Body.Close()
|
|
return proxycache.Result{Resp: rateLimitedResponse(r, name, retryAfter)}, nil
|
|
}
|
|
res := proxycache.Result{Resp: resp, NoStore: !up.conf.CacheEnabled()}
|
|
if kind == "blobs" {
|
|
// Blobs are the large, content-addressed half of a pull and the
|
|
// one worth resuming; manifests are small enough to just
|
|
// refetch. A resume goes back to this same upstream.
|
|
res.Resume = upstream.Resumer(up.client, r.Method, upstreamURL)
|
|
res.ResumeAttempts = up.conf.RetryAttempts()
|
|
res.ResumeMaxWait = up.conf.RetryMaxWait()
|
|
}
|
|
return res, nil
|
|
}
|
|
if limited {
|
|
// Every upstream failed and at least one throttled us.
|
|
return proxycache.Result{Resp: rateLimitedResponse(r, name, retryAfter)}, nil
|
|
}
|
|
if !answered && lastErr != nil {
|
|
return proxycache.Result{}, lastErr
|
|
}
|
|
return proxycache.Result{Resp: notFoundResponse(r, name)}, nil
|
|
}
|
|
|
|
// shouldFallthrough reports whether an upstream status means "try the next
|
|
// upstream": not found, anonymous access rejected, rate limited (the
|
|
// client already waited out retry.attempts), or a gateway error — what an
|
|
// ingress answers for a chained tiny-proxy that is down, and what
|
|
// tiny-proxy itself answers when its own upstream fails. A plain 500 is
|
|
// the upstream's own answer and is returned as-is.
|
|
func shouldFallthrough(code int) bool {
|
|
switch code {
|
|
case http.StatusNotFound, http.StatusUnauthorized, http.StatusForbidden,
|
|
http.StatusTooManyRequests, http.StatusBadGateway,
|
|
http.StatusServiceUnavailable, http.StatusGatewayTimeout:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// notFoundResponse synthesizes a 404 so proxycache relays it uncached when no
|
|
// upstream could serve the object.
|
|
func notFoundResponse(r *http.Request, name string) *http.Response {
|
|
return statusResponse(r, http.StatusNotFound, "not found: "+name+"\n", nil)
|
|
}
|
|
|
|
// rateLimitedResponse synthesizes the 429 returned when throttling, not
|
|
// absence, is why the object could not be served, carrying over the
|
|
// throttling upstream's Retry-After if it sent one.
|
|
func rateLimitedResponse(r *http.Request, name, retryAfter string) *http.Response {
|
|
header := http.Header{}
|
|
if retryAfter != "" {
|
|
header.Set("Retry-After", retryAfter)
|
|
}
|
|
return statusResponse(r, http.StatusTooManyRequests,
|
|
"rate limited by upstream: "+name+"\n", header)
|
|
}
|
|
|
|
// statusResponse builds a synthetic response for proxycache to relay; only
|
|
// 200s are cached, so these are always passed through uncached.
|
|
func statusResponse(r *http.Request, code int, body string, header http.Header) *http.Response {
|
|
if header == nil {
|
|
header = http.Header{}
|
|
}
|
|
header.Set("Content-Type", "text/plain; charset=utf-8")
|
|
return &http.Response{
|
|
Status: strconv.Itoa(code) + " " + http.StatusText(code),
|
|
StatusCode: code,
|
|
Proto: "HTTP/1.1",
|
|
ProtoMajor: 1,
|
|
ProtoMinor: 1,
|
|
Header: header,
|
|
Body: io.NopCloser(strings.NewReader(body)),
|
|
ContentLength: int64(len(body)),
|
|
Request: r,
|
|
}
|
|
}
|
|
|
|
// objectURL builds the upstream URL of one manifest or blob. An upstream
|
|
// URL whose path starts with /v2/ already names a repository root —
|
|
// typically another tiny-proxy's vrepo, https://host/v2/<vrepo> — so the
|
|
// image path is appended to it as-is; any other URL is a registry base and
|
|
// gets the /v2/ prefix.
|
|
func objectURL(base, name, kind, ref string) string {
|
|
root := strings.TrimSuffix(base, "/")
|
|
if u, err := url.Parse(base); err != nil || !(u.Path == "/v2" || strings.HasPrefix(u.Path, "/v2/")) {
|
|
root += "/v2"
|
|
}
|
|
return root + "/" + name + "/" + kind + "/" + ref
|
|
}
|
|
|
|
// parsePath splits "<vrepo>/<name...>/<manifests|blobs>/<ref>".
|
|
func parsePath(rest string) (vrepo, name, kind, ref string, ok bool) {
|
|
segs := strings.Split(strings.Trim(rest, "/"), "/")
|
|
if len(segs) < 4 {
|
|
return "", "", "", "", false
|
|
}
|
|
vrepo = segs[0]
|
|
kind = segs[len(segs)-2]
|
|
if kind != "manifests" && kind != "blobs" {
|
|
return "", "", "", "", false
|
|
}
|
|
ref = segs[len(segs)-1]
|
|
name = strings.Join(segs[1:len(segs)-2], "/")
|
|
if name == "" || ref == "" {
|
|
return "", "", "", "", false
|
|
}
|
|
return vrepo, name, kind, ref, true
|
|
}
|
|
|
|
// normalizeName applies the Docker Hub "library/" convention for
|
|
// single-component image names when the upstream is Docker Hub itself. Only
|
|
// the host is checked: a chained tiny-proxy (https://host/v2/dockerhub)
|
|
// normalizes on its own side, and a "docker.io" in its path must not add a
|
|
// second library/.
|
|
func normalizeName(upstreamURL, name string) string {
|
|
if strings.Contains(name, "/") {
|
|
return name
|
|
}
|
|
u, err := url.Parse(upstreamURL)
|
|
if err != nil {
|
|
return name
|
|
}
|
|
host := u.Hostname()
|
|
for _, hub := range []string{"docker.io", "docker.com"} {
|
|
if host == hub || strings.HasSuffix(host, "."+hub) {
|
|
return "library/" + name
|
|
}
|
|
}
|
|
return name
|
|
}
|