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/container_referrers.go

222 lines
8.1 KiB
Go
Raw Permalink Normal View History

Proxy the OCI referrers API in the container handler (#427) * Proxy the OCI referrers API in the container handler GET /v2/{name}/referrers/{digest} used to fall through to a plain 404, so clients only ever saw referrers stored under the sha256-<hex> tag schema. Registries that keep SBOMs and signatures behind the referrers API looked empty through the proxy. The index is now fetched from the upstream that the repository routes to and cached like the tag list: fresh within the metadata TTL, revalidated with If-None-Match, and served stale when the upstream is down. artifactType is not forwarded, so one cached row covers every filter and clients filter the full list themselves, which they do when OCI-Filters-Applied is absent. An upstream 404 is passed through untouched, so clients still fall back to the tag schema for registries like GHCR. When the upstream cannot answer and nothing is cached, the proxy keeps answering 404 as before rather than failing the request. Closes #144 * Cover absolute upstream links in the referrers link test The second page now answers with an absolute Link pointing back at the upstream host, and the test checks that it comes back rewritten to the proxy. That was missing so far, and it also gives the upstream variable a reason to be declared before the handler closure, which staticcheck complained about. * Read cached referrers through a small shared loader loadContainerReferrers was a line-for-line copy of loadContainerTags with the types renamed, which dupl flags in CI. The part that opens the cache row and reads the body now lives in loadContainerMetadata next to storeContainerMetadata, and the referrers loader only maps the row onto its struct. The tag and manifest loaders are left as they are. * Make the manifest routing test notice which handler answered For foo/manifests/referrers/<digest> the manifest handler and the referrers handler send the very same upstream path, so the test passed even with the referrers case moved in front of the manifest case. The fake upstream now rejects requests that only accept an image index, which is what the referrers handler sends. * Check that a cached referrers row links back to whoever reads it A repository can be reached as upstream/<name>/<repo> and, when the default registry is the same host, as plain <repo>. Both end up in the same cache row, so the pagination Link has to be rewritten when the row is served, not when it is stored. The link test now reads the row through both paths; with a store-time rewrite the second path would get the first one's link. * Make the referrers ETag test prove the revalidation Without If-None-Match the fake upstream just sends the full index again, so the old assertions held either way. The test now counts the 304s the upstream sent and expects exactly one. * Reword which tools use the referrers API in the README The cosign mention was too broad. cosign reaches the referrers API for its newer signature bundles, while the old .sig tags keep going through the normal manifest path. Also rewraps the paragraph, one line had run past the others. * Check that a 304 restarts the referrers cache TTL The ETag test now backdates the cached row instead of running with a zero TTL. After the upstream answers 304, a third request has to come from the cache. Before, the test would also have passed if the 304 branch forgot to bump fetchedAt and every request kept revalidating. * Test the referrers Content-Type fallback None of the fake upstreams left out Content-Type, so the default to the image index type was never exercised. oras-go rejects anything else, so it is worth pinning, both for the first fetch and for the cached copy. * Test referrers digests with extra characters around them The invalid-request table only had digests that were too short or used an unknown algorithm, so it would not notice if the digest pattern lost its anchors. Two cases now put junk before and after an otherwise valid sha256 digest. * Reuse the manifest cache key for referrers The referrers handler had its own key function, but it did exactly what containerManifestCacheKey already does with four strings. Referrers rows sit in their own oci-referrers ecosystem anyway, so they can't collide with manifests. * Drop the separate struct for cached referrers A cached referrers response carries the same fields as a cached tag list, and the freshness check was a straight copy too. So the referrers code just uses cachedContainerTags and containerTagsFresh now. Loading, storing and writing stay on their own, since the ecosystem and the Link handling are different. * Route referrers requests by ns like the other OCI endpoints Now that containerd ns support is on main, referrers requests have to go through registryForRequest too, otherwise a _default mirror would look every subject up on Docker Hub. ns only picks the registry, so it is dropped from the forwarded query and the cache key, and the next-page Link keeps it, the same way tag lists do. This also follows the new rewriteContainerTagsLink signature, which takes the namespace. * Without a cached index, referrers now pass upstream errors through and answer 502 when the upstream can't be reached or sends garbage, so clients no longer read an outage as a registry without the referrers API. Stale cache is still served when there is one. * The tag list and manifest routes now only match paths that end in their endpoint, so a repository with tags/list or manifests in its name can still reach the referrers endpoint.
2026-10-12 10:08:01 +02:00
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)
Proxy the OCI referrers API in the container handler (#427) * Proxy the OCI referrers API in the container handler GET /v2/{name}/referrers/{digest} used to fall through to a plain 404, so clients only ever saw referrers stored under the sha256-<hex> tag schema. Registries that keep SBOMs and signatures behind the referrers API looked empty through the proxy. The index is now fetched from the upstream that the repository routes to and cached like the tag list: fresh within the metadata TTL, revalidated with If-None-Match, and served stale when the upstream is down. artifactType is not forwarded, so one cached row covers every filter and clients filter the full list themselves, which they do when OCI-Filters-Applied is absent. An upstream 404 is passed through untouched, so clients still fall back to the tag schema for registries like GHCR. When the upstream cannot answer and nothing is cached, the proxy keeps answering 404 as before rather than failing the request. Closes #144 * Cover absolute upstream links in the referrers link test The second page now answers with an absolute Link pointing back at the upstream host, and the test checks that it comes back rewritten to the proxy. That was missing so far, and it also gives the upstream variable a reason to be declared before the handler closure, which staticcheck complained about. * Read cached referrers through a small shared loader loadContainerReferrers was a line-for-line copy of loadContainerTags with the types renamed, which dupl flags in CI. The part that opens the cache row and reads the body now lives in loadContainerMetadata next to storeContainerMetadata, and the referrers loader only maps the row onto its struct. The tag and manifest loaders are left as they are. * Make the manifest routing test notice which handler answered For foo/manifests/referrers/<digest> the manifest handler and the referrers handler send the very same upstream path, so the test passed even with the referrers case moved in front of the manifest case. The fake upstream now rejects requests that only accept an image index, which is what the referrers handler sends. * Check that a cached referrers row links back to whoever reads it A repository can be reached as upstream/<name>/<repo> and, when the default registry is the same host, as plain <repo>. Both end up in the same cache row, so the pagination Link has to be rewritten when the row is served, not when it is stored. The link test now reads the row through both paths; with a store-time rewrite the second path would get the first one's link. * Make the referrers ETag test prove the revalidation Without If-None-Match the fake upstream just sends the full index again, so the old assertions held either way. The test now counts the 304s the upstream sent and expects exactly one. * Reword which tools use the referrers API in the README The cosign mention was too broad. cosign reaches the referrers API for its newer signature bundles, while the old .sig tags keep going through the normal manifest path. Also rewraps the paragraph, one line had run past the others. * Check that a 304 restarts the referrers cache TTL The ETag test now backdates the cached row instead of running with a zero TTL. After the upstream answers 304, a third request has to come from the cache. Before, the test would also have passed if the 304 branch forgot to bump fetchedAt and every request kept revalidating. * Test the referrers Content-Type fallback None of the fake upstreams left out Content-Type, so the default to the image index type was never exercised. oras-go rejects anything else, so it is worth pinning, both for the first fetch and for the cached copy. * Test referrers digests with extra characters around them The invalid-request table only had digests that were too short or used an unknown algorithm, so it would not notice if the digest pattern lost its anchors. Two cases now put junk before and after an otherwise valid sha256 digest. * Reuse the manifest cache key for referrers The referrers handler had its own key function, but it did exactly what containerManifestCacheKey already does with four strings. Referrers rows sit in their own oci-referrers ecosystem anyway, so they can't collide with manifests. * Drop the separate struct for cached referrers A cached referrers response carries the same fields as a cached tag list, and the freshness check was a straight copy too. So the referrers code just uses cachedContainerTags and containerTagsFresh now. Loading, storing and writing stay on their own, since the ecosystem and the Link handling are different. * Route referrers requests by ns like the other OCI endpoints Now that containerd ns support is on main, referrers requests have to go through registryForRequest too, otherwise a _default mirror would look every subject up on Docker Hub. ns only picks the registry, so it is dropped from the forwarded query and the cache key, and the next-page Link keeps it, the same way tag lists do. This also follows the new rewriteContainerTagsLink signature, which takes the namespace. * Without a cached index, referrers now pass upstream errors through and answer 502 when the upstream can't be reached or sends garbage, so clients no longer read an outage as a registry without the referrers API. Stale cache is still served when there is one. * The tag list and manifest routes now only match paths that end in their endpoint, so a repository with tags/list or manifests in its name can still reach the referrers endpoint.
2026-10-12 10:08:01 +02:00
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-<hex>
// 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
}
Proxy the OCI referrers API in the container handler (#427) * Proxy the OCI referrers API in the container handler GET /v2/{name}/referrers/{digest} used to fall through to a plain 404, so clients only ever saw referrers stored under the sha256-<hex> tag schema. Registries that keep SBOMs and signatures behind the referrers API looked empty through the proxy. The index is now fetched from the upstream that the repository routes to and cached like the tag list: fresh within the metadata TTL, revalidated with If-None-Match, and served stale when the upstream is down. artifactType is not forwarded, so one cached row covers every filter and clients filter the full list themselves, which they do when OCI-Filters-Applied is absent. An upstream 404 is passed through untouched, so clients still fall back to the tag schema for registries like GHCR. When the upstream cannot answer and nothing is cached, the proxy keeps answering 404 as before rather than failing the request. Closes #144 * Cover absolute upstream links in the referrers link test The second page now answers with an absolute Link pointing back at the upstream host, and the test checks that it comes back rewritten to the proxy. That was missing so far, and it also gives the upstream variable a reason to be declared before the handler closure, which staticcheck complained about. * Read cached referrers through a small shared loader loadContainerReferrers was a line-for-line copy of loadContainerTags with the types renamed, which dupl flags in CI. The part that opens the cache row and reads the body now lives in loadContainerMetadata next to storeContainerMetadata, and the referrers loader only maps the row onto its struct. The tag and manifest loaders are left as they are. * Make the manifest routing test notice which handler answered For foo/manifests/referrers/<digest> the manifest handler and the referrers handler send the very same upstream path, so the test passed even with the referrers case moved in front of the manifest case. The fake upstream now rejects requests that only accept an image index, which is what the referrers handler sends. * Check that a cached referrers row links back to whoever reads it A repository can be reached as upstream/<name>/<repo> and, when the default registry is the same host, as plain <repo>. Both end up in the same cache row, so the pagination Link has to be rewritten when the row is served, not when it is stored. The link test now reads the row through both paths; with a store-time rewrite the second path would get the first one's link. * Make the referrers ETag test prove the revalidation Without If-None-Match the fake upstream just sends the full index again, so the old assertions held either way. The test now counts the 304s the upstream sent and expects exactly one. * Reword which tools use the referrers API in the README The cosign mention was too broad. cosign reaches the referrers API for its newer signature bundles, while the old .sig tags keep going through the normal manifest path. Also rewraps the paragraph, one line had run past the others. * Check that a 304 restarts the referrers cache TTL The ETag test now backdates the cached row instead of running with a zero TTL. After the upstream answers 304, a third request has to come from the cache. Before, the test would also have passed if the 304 branch forgot to bump fetchedAt and every request kept revalidating. * Test the referrers Content-Type fallback None of the fake upstreams left out Content-Type, so the default to the image index type was never exercised. oras-go rejects anything else, so it is worth pinning, both for the first fetch and for the cached copy. * Test referrers digests with extra characters around them The invalid-request table only had digests that were too short or used an unknown algorithm, so it would not notice if the digest pattern lost its anchors. Two cases now put junk before and after an otherwise valid sha256 digest. * Reuse the manifest cache key for referrers The referrers handler had its own key function, but it did exactly what containerManifestCacheKey already does with four strings. Referrers rows sit in their own oci-referrers ecosystem anyway, so they can't collide with manifests. * Drop the separate struct for cached referrers A cached referrers response carries the same fields as a cached tag list, and the freshness check was a straight copy too. So the referrers code just uses cachedContainerTags and containerTagsFresh now. Loading, storing and writing stay on their own, since the ecosystem and the Link handling are different. * Route referrers requests by ns like the other OCI endpoints Now that containerd ns support is on main, referrers requests have to go through registryForRequest too, otherwise a _default mirror would look every subject up on Docker Hub. ns only picks the registry, so it is dropped from the forwarded query and the cache key, and the next-page Link keeps it, the same way tag lists do. This also follows the new rewriteContainerTagsLink signature, which takes the namespace. * Without a cached index, referrers now pass upstream errors through and answer 502 when the upstream can't be reached or sends garbage, so clients no longer read an outage as a registry without the referrers API. Stale cache is still served when there is one. * The tag list and manifest routes now only match paths that end in their endpoint, so a repository with tags/list or manifests in its name can still reach the referrers endpoint.
2026-10-12 10:08:01 +02:00
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)
}