package handler import ( "cmp" "context" "encoding/json" "errors" "fmt" "net/http" "regexp" "strconv" "strings" "time" ) const ( containerReferrersCacheEcosystem = "oci-referrers" containerReferrersMediaType = "application/vnd.oci.image.index.v1+json" referrersMatchCount = 3 // full match + name + digest ) var ( // referrersPathPattern matches referrers paths: {name}/referrers/{digest} referrersPathPattern = regexp.MustCompile(`^(.+)/referrers/([^/]+)$`) referrersDigestPattern = regexp.MustCompile(`^(sha256:[a-f0-9]{64}|sha512:[a-f0-9]{128})$`) ) // parseReferrersPath extracts repository name and subject digest from a // referrers path. func (h *ContainerHandler) parseReferrersPath(path string) (name, digest string) { matches := referrersPathPattern.FindStringSubmatch(path) if len(matches) != referrersMatchCount { return "", "" } return matches[1], matches[2] } // handleReferrers serves the OCI 1.1 referrers API. // Path format: {name}/referrers/{digest} func (h *ContainerHandler) handleReferrers(w http.ResponseWriter, r *http.Request, path string) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } name, digest := h.parseReferrersPath(path) if name == "" || !referrersDigestPattern.MatchString(digest) { h.containerError(w, http.StatusBadRequest, "DIGEST_INVALID", "invalid referrers digest") return } registryURL, upstreamName, _, ok := h.registryForRequest(r, name) if !ok { h.containerError(w, http.StatusNotFound, "NAME_UNKNOWN", "unknown upstream registry") return } h.proxy.Logger.Debug("container referrers request", "name", upstreamName, "digest", digest) h.serveReferrers(w, r, registryURL, upstreamName, digest) } func (h *ContainerHandler) serveReferrers(w http.ResponseWriter, r *http.Request, registryURL, name, digest string) { // artifactType is never forwarded, so the upstream returns the full index // and one row serves every filter. Without OCI-Filters-Applied in the // response, clients filter the index themselves, as the spec requires. // ns only picks the registry, so it is neither forwarded nor part of the // cache identity. query := r.URL.Query() query.Del("artifactType") query.Del(namespaceQueryParam) // Same identity shape as manifests (registry, name, reference, variant); // the oci-referrers ecosystem keeps the rows apart. cacheKey := h.containerManifestCacheKey(registryURL, name, digest, query.Encode()) cached, err := h.loadContainerReferrers(r.Context(), cacheKey) if err != nil { h.proxy.Logger.Warn("failed to read cached container referrers", "error", err) cached = nil } if cached != nil && h.containerTagsFresh(cached) { h.writeContainerReferrers(w, r, registryURL, cached, false) return } upstreamURL := fmt.Sprintf("%s/v2/%s/referrers/%s", registryURL, name, digest) if encoded := query.Encode(); encoded != "" { upstreamURL += "?" + encoded } req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil) if err != nil { h.containerError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create request") return } req.Header.Set("Accept", containerReferrersMediaType) if cached != nil && cached.etag != "" { req.Header.Set("If-None-Match", cached.etag) } resp, err := h.proxy.HTTPClient.Do(req) if err != nil { h.serveStaleReferrersOrError(w, r, registryURL, cached, err) return } defer func() { _ = resp.Body.Close() }() if resp.StatusCode == http.StatusNotModified && cached != nil { cached.fetchedAt = time.Now() if err := h.storeContainerReferrers(r.Context(), cacheKey, cached); err != nil { h.proxy.Logger.Warn("failed to refresh cached container referrers", "error", err) } h.writeContainerReferrers(w, r, registryURL, cached, false) return } if resp.StatusCode != http.StatusOK { if shouldServeStaleManifest(resp.StatusCode) && cached != nil { h.serveStaleReferrersOrError(w, r, registryURL, cached, fmt.Errorf("upstream returned status %d", resp.StatusCode)) return } // Upstream errors reach the client as sent. Only a 404 tells it the // registry has no referrers API, so it falls back to the sha256- // tag schema served as manifests. h.proxy.relayResponse(w, r, resp, copyContainerReferrersHeaders) return } body, err := h.proxy.ReadMetadata(resp.Body) if err != nil { h.serveStaleReferrersOrError(w, r, registryURL, cached, fmt.Errorf("reading referrers: %w", err)) return } if !json.Valid(body) { h.serveStaleReferrersOrError(w, r, registryURL, cached, errors.New("upstream referrers response is not JSON")) return } referrers := &cachedContainerTags{ body: body, contentType: resp.Header.Get(headerContentType), etag: resp.Header.Get(headerETag), // Stored as sent upstream and rewritten when served, so a row shared // by several client paths to the same registry links back correctly. link: strings.Join(resp.Header.Values("Link"), ", "), size: int64(len(body)), fetchedAt: time.Now(), } if referrers.contentType == "" { referrers.contentType = containerReferrersMediaType } if err := h.storeContainerReferrers(r.Context(), cacheKey, referrers); err != nil { h.proxy.Logger.Warn("failed to cache container referrers", "error", err) } h.writeContainerReferrers(w, r, registryURL, referrers, false) } // serveStaleReferrersOrError serves a cached index when the upstream cannot // answer. Without one it returns 502 rather than 404, so clients do not take // a failed fetch as a registry without the referrers API. func (h *ContainerHandler) serveStaleReferrersOrError(w http.ResponseWriter, r *http.Request, registryURL string, cached *cachedContainerTags, err error) { if cached != nil { h.proxy.Logger.Warn("upstream referrers fetch failed, serving stale cache", "error", err) h.writeContainerReferrers(w, r, registryURL, cached, true) return } if writeUpstreamHeld(w, err) { return } h.proxy.Logger.Error("failed to fetch container referrers", "error", err) h.containerError(w, http.StatusBadGateway, "INTERNAL_ERROR", "failed to fetch from upstream") } // copyContainerReferrersHeaders also keeps Retry-After, since uncached 429 // and 5xx answers are relayed here instead of turning into a 502. func copyContainerReferrersHeaders(destination, source http.Header) { copyContainerTagsHeaders(destination, source) if value := source.Get("Retry-After"); value != "" { destination.Set("Retry-After", value) } } func (h *ContainerHandler) loadContainerReferrers(ctx context.Context, cacheKey string) (*cachedContainerTags, error) { entry, body, err := h.loadContainerMetadata(ctx, containerReferrersCacheEcosystem, cacheKey) if err != nil || entry == nil { return nil, err } referrers := &cachedContainerTags{ body: body, contentType: cmp.Or(entry.ContentType.String, containerReferrersMediaType), etag: entry.ETag.String, link: entry.Link.String, size: int64(len(body)), fetchedAt: entry.FetchedAt.Time, } if entry.Size.Valid { referrers.size = entry.Size.Int64 } return referrers, nil } func (h *ContainerHandler) storeContainerReferrers(ctx context.Context, cacheKey string, referrers *cachedContainerTags) error { size, err := h.storeContainerMetadata(ctx, containerReferrersCacheEcosystem, cacheKey, referrers.body, referrers.etag, referrers.link, referrers.contentType, "", time.Time{}, referrers.fetchedAt) if err != nil { return fmt.Errorf("storing referrers: %w", err) } referrers.size = size return nil } func (h *ContainerHandler) writeContainerReferrers(w http.ResponseWriter, r *http.Request, registryURL string, referrers *cachedContainerTags, stale bool) { w.Header().Set(headerContentType, referrers.contentType) w.Header().Set(headerContentLength, strconv.FormatInt(referrers.size, 10)) if referrers.etag != "" { w.Header().Set(headerETag, referrers.etag) } if link := h.rewriteContainerTagsLink(referrers.link, registryURL, r.URL.Path, r.URL.Query().Get(namespaceQueryParam)); link != "" { w.Header().Set("Link", link) } if stale { w.Header().Set("Warning", containerStaleWarning) } w.WriteHeader(http.StatusOK) _, _ = w.Write(referrers.body) }