Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,17 @@ Or pull images directly:
docker pull localhost:8080/library/nginx:latest
```

SBOMs, signatures and attestations attached to an image are found through the
OCI 1.1 referrers API (`GET /v2/{name}/referrers/{digest}`). `oras discover`
and `notation verify` use it, and so does cosign for its newer signature
bundles. The proxy forwards these requests and caches the returned index with
the same TTL as other metadata, so they keep working offline. It does not
filter by `artifactType`; it returns the full list and the client filters it,
as the spec allows. Registries without the referrers API (GHCR, for example)
answer 404, and clients then look up the `sha256-<digest>` tag instead, which
the proxy serves like any other manifest. The proxy is pull-only, so attaching
new artifacts through it is not supported.

#### containerd (Kubernetes, k3s, nerdctl)

containerd mirrors send the original registry host in an `ns` query
Expand Down
9 changes: 6 additions & 3 deletions internal/handler/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,12 +324,15 @@ func (h *ContainerHandler) Routes() http.Handler {
case strings.HasSuffix(path, "/blobs/"+r.URL.Query().Get("digest")) || strings.Contains(path, "/blobs/sha256:"):
// Blob download: GET /v2/{name}/blobs/{digest}
h.handleBlobDownload(w, r, path)
case strings.Contains(path, "/manifests/"):
case manifestPathPattern.MatchString(path):
// Manifest: GET /v2/{name}/manifests/{reference}
h.handleManifest(w, r, path)
case strings.Contains(path, "/tags/list"):
case tagsListPathPattern.MatchString(path):
// Tags list: GET /v2/{name}/tags/list
h.handleTagsList(w, r, path)
case referrersPathPattern.MatchString(path):
// Referrers: GET /v2/{name}/referrers/{digest}
h.handleReferrers(w, r, path)
default:
http.Error(w, "not found", http.StatusNotFound)
}
Expand Down Expand Up @@ -656,7 +659,7 @@ func (h *ContainerHandler) parseBlobPath(path string) (name, digest string) {
}

// manifestPathPattern matches manifest paths: {name}/manifests/{reference}
var manifestPathPattern = regexp.MustCompile(`^(.+)/manifests/(.+)$`)
var manifestPathPattern = regexp.MustCompile(`^(.+)/manifests/([^/]+)$`)

// parseManifestPath extracts repository name and reference from a manifest path.
func (h *ContainerHandler) parseManifestPath(path string) (name, reference string) {
Expand Down
22 changes: 22 additions & 0 deletions internal/handler/container_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,25 @@ func (h *ContainerHandler) storeContainerMetadata(ctx context.Context, ecosystem
}
return size, nil
}

// loadContainerMetadata returns a cached metadata row and its body, or nil
// when nothing usable is cached.
func (h *ContainerHandler) loadContainerMetadata(ctx context.Context, ecosystem, cacheKey string) (*database.MetadataCacheEntry, []byte, error) {
if h.proxy.DB == nil || h.proxy.Storage == nil {
return nil, nil, nil
}
entry, err := h.proxy.DB.GetMetadataCache(ecosystem, cacheKey)
if err != nil || entry == nil {
return nil, nil, err
}
reader, err := h.proxy.Storage.Open(ctx, entry.StoragePath)
if err != nil {
return nil, nil, nil
}
defer func() { _ = reader.Close() }()
body, err := h.proxy.ReadMetadata(reader)
if err != nil {
return nil, nil, err
}
return entry, body, nil
}
219 changes: 219 additions & 0 deletions internal/handler/container_referrers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
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.Info("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-<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
}
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)
}
Loading
Loading