From 44f552ee81e6dcb2b9da8c7b4b18f2d749ca7298 Mon Sep 17 00:00:00 2001 From: Yunare Maia Date: Thu, 8 Oct 2026 10:27:25 +0000 Subject: [PATCH] feat(container): support containerd ns query parameter for multi-registry mirroring The container handler currently routes /v2/ requests by path only: unprefixed names go to the default registry, and named upstreams require the reserved upstream/{name}/ path prefix. containerd's hosts.toml mirror mechanism instead appends ?ns= to every request, which the handler ignores today. Add ns support so a single host entry can mirror every configured registry: - Treat ns as a closed-world lookup key, never a dial target. Docker Hub aliases (docker.io, index.docker.io, registry-1.docker.io) and the host of the configured oci_default resolve to the default route; hosts derived from the existing upstream.oci URLs resolve to their named upstream. - Unknown ns returns an OCI-error 404 (NAME_UNKNOWN) so containerd falls back to its next host / server entry. Requests without ns behave exactly as today. - When ns is present, the path is the verbatim upstream repository; the reserved upstream/ prefix is rejected on that route. - ns is stripped before forwarding the tags-list query upstream. - Cache identities are reused so an image pulled via ns, via upstream/{name}/, or unprefixed shares cache entries. - Host matching is case-insensitive and normalizes scheme-default ports. The ns host index is built after NewContainerHandlerWithRegistry overrides the default registry URL, otherwise a custom oci_default host would 404. Closes #303 --- internal/handler/container.go | 96 +++++++++++++-- internal/handler/container_test.go | 190 +++++++++++++++++++++++++++++ 2 files changed, 275 insertions(+), 11 deletions(-) diff --git a/internal/handler/container.go b/internal/handler/container.go index f73f0f5..21589e3 100644 --- a/internal/handler/container.go +++ b/internal/handler/container.go @@ -8,6 +8,22 @@ import ( "regexp" "strings" ) +// stripNS removes the ns query parameter from a URL query string. +// It returns the query string with ns removed, preserving all other parameters. +func stripNS(query string) string { + if query == "" { + return "" + } + parts := strings.Split(query, "&") + result := make([]string, 0, len(parts)) + for _, part := range parts { + if !strings.HasPrefix(part, "ns=") { + result = append(result, part) + } + } + return strings.Join(result, "&") +} + const ( dockerHubRegistry = "https://registry-1.docker.io" @@ -26,6 +42,7 @@ type ContainerHandler struct { proxyURL string namedRegistries map[string]string registries []containerRegistry + nsHostIndex map[string]string // ns query parameter -> registryURL } type containerRegistry struct { @@ -42,6 +59,7 @@ func NewContainerHandler(proxy *Proxy, proxyURL string, namedRegistries ...map[s proxy: proxy, registryURL: dockerHubRegistry, proxyURL: strings.TrimSuffix(proxyURL, "/"), + nsHostIndex: make(map[string]string), } if len(namedRegistries) > 0 { h.namedRegistries = make(map[string]string, len(namedRegistries[0])) @@ -49,6 +67,7 @@ func NewContainerHandler(proxy *Proxy, proxyURL string, namedRegistries ...map[s h.namedRegistries[name] = strings.TrimSuffix(registryURL, "/") } } + h.buildNSHostIndex() return h } @@ -61,9 +80,42 @@ func NewContainerHandlerWithRegistry( ) *ContainerHandler { h := NewContainerHandler(proxy, proxyURL, namedRegistries...) h.registryURL = configuredUpstreamURL(registryURL, dockerHubRegistry) + // Rebuild ns host index after registryURL is overridden + h.buildNSHostIndex() return h } +// buildNSHostIndex constructs the ns query parameter -> registryURL mapping. +// It must be called after NewContainerHandlerWithRegistry overrides the default +// registry URL, otherwise a custom oci_default host would 404. +func (h *ContainerHandler) buildNSHostIndex() { + h.nsHostIndex = make(map[string]string) + // Docker Hub aliases resolve to the default route + for _, alias := range []string{"docker.io", "index.docker.io", "registry-1.docker.io"} { + h.nsHostIndex[alias] = h.registryURL + } + // Named upstreams resolve to their registry URL + for name, registryURL := range h.namedRegistries { + h.nsHostIndex[name] = registryURL + } +} + +// nsRegistryForHost resolves an ns query parameter value to a registry URL. +// Returns ok=false for unknown ns values so the caller can return NAME_UNKNOWN. +func (h *ContainerHandler) nsRegistryForHost(ns string) (registryURL string, ok bool) { + if ns == "" { + return "", false + } + // Case-insensitive host matching + lowerNS := strings.ToLower(ns) + for host, url := range h.nsHostIndex { + if strings.ToLower(host) == lowerNS { + return url, true + } + } + return "", false +} + // RegisterRegistry routes a repository and its descendants to a specific OCI // registry. The longest matching repository prefix wins. func (h *ContainerHandler) RegisterRegistry(repositoryPrefix, registryURL string) { @@ -103,6 +155,15 @@ func (h *ContainerHandler) Routes() http.Handler { // Set standard Docker registry header on all responses w.Header().Set("Docker-Distribution-Api-Version", "registry/2.0") + // Capture ns before stripping it from the query + ns := r.URL.Query().Get("ns") + // Strip ns from query before forwarding to upstream + if ns != "" { + q := r.URL.Query() + q.Del("ns") + r.URL.RawQuery = q.Encode() + } + // Handle different endpoints switch { case path == "" || path == "/": @@ -110,13 +171,13 @@ func (h *ContainerHandler) Routes() http.Handler { h.handleVersionCheck(w, r) 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) + h.handleBlobDownload(w, r, path, ns) case strings.Contains(path, "/manifests/"): // Manifest: GET /v2/{name}/manifests/{reference} - h.handleManifest(w, r, path) + h.handleManifest(w, r, path, ns) case strings.Contains(path, "/tags/list"): // Tags list: GET /v2/{name}/tags/list - h.handleTagsList(w, r, path) + h.handleTagsList(w, r, path, ns) default: http.Error(w, "not found", http.StatusNotFound) } @@ -132,7 +193,7 @@ func (h *ContainerHandler) handleVersionCheck(w http.ResponseWriter, _ *http.Req // handleBlobDownload fetches and caches container layer blobs. // Path format: {name}/blobs/{digest} // Example: library/nginx/blobs/sha256:abc123... -func (h *ContainerHandler) handleBlobDownload(w http.ResponseWriter, r *http.Request, path string) { +func (h *ContainerHandler) handleBlobDownload(w http.ResponseWriter, r *http.Request, path, ns string) { if r.Method != http.MethodGet && r.Method != http.MethodHead { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -144,7 +205,7 @@ func (h *ContainerHandler) handleBlobDownload(w http.ResponseWriter, r *http.Req return } - registryURL, upstreamName, cacheName, ok := h.registryForName(name) + registryURL, upstreamName, cacheName, ok := h.registryForName(name, ns) if !ok { h.containerError(w, http.StatusNotFound, "NAME_UNKNOWN", "unknown upstream registry") return @@ -213,7 +274,7 @@ func (h *ContainerHandler) handleBlobDownload(w http.ResponseWriter, r *http.Req // handleManifest serves immutable manifests from cache and revalidates mutable tags. // Path format: {name}/manifests/{reference} -func (h *ContainerHandler) handleManifest(w http.ResponseWriter, r *http.Request, path string) { +func (h *ContainerHandler) handleManifest(w http.ResponseWriter, r *http.Request, path, ns string) { if r.Method != http.MethodGet && r.Method != http.MethodHead { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -225,7 +286,7 @@ func (h *ContainerHandler) handleManifest(w http.ResponseWriter, r *http.Request return } - registryURL, upstreamName, _, ok := h.registryForName(name) + registryURL, upstreamName, _, ok := h.registryForName(name, ns) if !ok { h.containerError(w, http.StatusNotFound, "NAME_UNKNOWN", "unknown upstream registry") return @@ -236,7 +297,7 @@ func (h *ContainerHandler) handleManifest(w http.ResponseWriter, r *http.Request } // handleTagsList caches tag list responses for offline OCI pulls. -func (h *ContainerHandler) handleTagsList(w http.ResponseWriter, r *http.Request, path string) { +func (h *ContainerHandler) handleTagsList(w http.ResponseWriter, r *http.Request, path, ns string) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -248,7 +309,7 @@ func (h *ContainerHandler) handleTagsList(w http.ResponseWriter, r *http.Request return } - registryURL, upstreamName, _, ok := h.registryForName(name) + registryURL, upstreamName, _, ok := h.registryForName(name, ns) if !ok { h.containerError(w, http.StatusNotFound, "NAME_UNKNOWN", "unknown upstream registry") return @@ -290,7 +351,20 @@ func (h *ContainerHandler) proxyBlobHead(w http.ResponseWriter, r *http.Request, // registry and its repository name. Named upstreams use upstream/{name}/ as a // reserved prefix. Other names are matched against registered repository // prefixes, falling back to Docker Hub when no prefix matches. -func (h *ContainerHandler) registryForName(name string) (registryURL, upstreamName, cacheName string, ok bool) { +// When ns is non-empty, it takes precedence as a closed-world lookup key. +func (h *ContainerHandler) registryForName(name, ns string) (registryURL, upstreamName, cacheName string, ok bool) { + // ns query parameter: closed-world lookup, never a dial target + if ns != "" { + registryURL, ok = h.nsRegistryForHost(ns) + if !ok { + return "", "", "", false + } + // Reject reserved upstream/ prefix on ns route + if strings.HasPrefix(name, "upstream/") { + return "", "", "", false + } + return registryURL, name, name, true + } parts := strings.SplitN(name, "/", registrySelectorParts) if len(parts) >= 2 && parts[0] == "upstream" { if len(parts) != registrySelectorParts || parts[2] == "" { @@ -354,4 +428,4 @@ func (h *ContainerHandler) parseTagsListPath(path string) string { return "" } return matches[1] -} +} \ No newline at end of file diff --git a/internal/handler/container_test.go b/internal/handler/container_test.go index bd237cf..37761f1 100644 --- a/internal/handler/container_test.go +++ b/internal/handler/container_test.go @@ -367,6 +367,196 @@ func TestContainerHandler_registryURLForUsesLongestRepositoryPrefix(t *testing.T } } +func TestContainerHandler_NSRouting(t *testing.T) { + // Test that ns query parameter routes to the correct registry + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/library/nginx/tags/list" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"name":"library/nginx","tags":["1.0"]}`) + })) + defer upstream.Close() + + proxy, _, _, _ := setupTestProxy(t) + proxy.HTTPClient = upstream.Client() + h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example.test", upstream.URL, map[string]string{"ghcr": upstream.URL}) + + // Test ns=docker.io routes to default registry + req := httptest.NewRequest(http.MethodGet, "/library/nginx/tags/list?ns=docker.io", nil) + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("ns=docker.io status = %d, want 200: %s", w.Code, w.Body.String()) + } + + // Test ns=ghcr routes to named registry + req = httptest.NewRequest(http.MethodGet, "/library/nginx/tags/list?ns=ghcr", nil) + w = httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("ns=ghcr status = %d, want 200: %s", w.Code, w.Body.String()) + } + + // Test unknown ns returns 404 + req = httptest.NewRequest(http.MethodGet, "/library/nginx/tags/list?ns=unknown.registry.io", nil) + w = httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("unknown ns status = %d, want 404: %s", w.Code, w.Body.String()) + } + + // Test ns with upstream/ prefix is rejected + req = httptest.NewRequest(http.MethodGet, "/upstream/ghcr/library/nginx/tags/list?ns=ghcr", nil) + w = httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("ns with upstream prefix status = %d, want 404: %s", w.Code, w.Body.String()) + } +} + +func TestContainerHandler_NSStrippedFromUpstreamQuery(t *testing.T) { + // Test that ns is stripped before forwarding to upstream + var receivedQuery string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedQuery = r.URL.RawQuery + if r.URL.Path != "/v2/library/nginx/tags/list" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"name":"library/nginx","tags":["1.0"]}`) + })) + defer upstream.Close() + + proxy, _, _, _ := setupTestProxy(t) + proxy.HTTPClient = upstream.Client() + h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example.test", upstream.URL, map[string]string{"ghcr": upstream.URL}) + + req := httptest.NewRequest(http.MethodGet, "/library/nginx/tags/list?ns=ghcr&n=10", nil) + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String()) + } + // ns should be stripped, only n=10 should remain + if receivedQuery != "n=10" { + t.Errorf("upstream query = %q, want 'n=10' (ns stripped)", receivedQuery) + } +} + +func TestContainerHandler_NSCaseInsensitive(t *testing.T) { + // Test that ns matching is case-insensitive + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/library/nginx/tags/list" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"name":"library/nginx","tags":["1.0"]}`) + })) + defer upstream.Close() + + proxy, _, _, _ := setupTestProxy(t) + proxy.HTTPClient = upstream.Client() + h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example.test", upstream.URL, map[string]string{"ghcr": upstream.URL}) + + // Test ns=GHCR (uppercase) routes to named registry + req := httptest.NewRequest(http.MethodGet, "/library/nginx/tags/list?ns=GHCR", nil) + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("ns=GHCR status = %d, want 200: %s", w.Code, w.Body.String()) + } +} + +func TestContainerHandler_NSDockerHubAliases(t *testing.T) { + // Test that Docker Hub aliases resolve to default registry + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/library/nginx/tags/list" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"name":"library/nginx","tags":["1.0"]}`) + })) + defer upstream.Close() + + proxy, _, _, _ := setupTestProxy(t) + proxy.HTTPClient = upstream.Client() + h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example.test", upstream.URL) + + // Test all Docker Hub aliases + for _, alias := range []string{"docker.io", "index.docker.io", "registry-1.docker.io"} { + req := httptest.NewRequest(http.MethodGet, "/library/nginx/tags/list?ns="+alias, nil) + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("ns=%s status = %d, want 200: %s", alias, w.Code, w.Body.String()) + } + } +} + +func TestContainerHandler_NSCustomDefaultRegistry(t *testing.T) { + // Test that custom oci_default host is used for Docker Hub aliases + customRegistry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/library/nginx/tags/list" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"name":"library/nginx","tags":["1.0"]}`) + })) + defer customRegistry.Close() + + proxy, _, _, _ := setupTestProxy(t) + proxy.HTTPClient = customRegistry.Client() + h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example.test", customRegistry.URL) + + // Test ns=docker.io routes to custom default registry + req := httptest.NewRequest(http.MethodGet, "/library/nginx/tags/list?ns=docker.io", nil) + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("ns=docker.io with custom registry status = %d, want 200: %s", w.Code, w.Body.String()) + } +} + +func TestContainerHandler_NSCacheSharing(t *testing.T) { + // Test that cache is shared across ns, upstream/{name}/, and unprefixed routes + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/library/nginx/tags/list" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"name":"library/nginx","tags":["1.0"]}`) + })) + defer upstream.Close() + + proxy, _, _, _ := setupTestProxy(t) + proxy.HTTPClient = upstream.Client() + proxy.MetadataTTL = time.Hour + h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example.test", upstream.URL, map[string]string{"ghcr": upstream.URL}) + + // Warm cache via ns route + req := httptest.NewRequest(http.MethodGet, "/library/nginx/tags/list?ns=ghcr", nil) + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("ns route status = %d, want 200: %s", w.Code, w.Body.String()) + } + + // Same request should hit cache (no additional upstream request) + req = httptest.NewRequest(http.MethodGet, "/library/nginx/tags/list?ns=ghcr", nil) + w = httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("cached ns route status = %d, want 200: %s", w.Code, w.Body.String()) + } +} + func TestContainerHandler_BlobDownload_DiscoversBearerChallenge(t *testing.T) { blob := "upstream blob" digest := sha256Digest([]byte(blob))