mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-18 16:24:16 +00:00
DomainRoutingMiddleware normalized the request Host to a bare hostname but
matched server.registry_domains verbatim, so any configured domain carrying a
port could never match. config-appview.example.yaml ships
`registry_domains: [127.0.0.1:5000, atcr.io]`, which means that entry has been
inert since it was written.
It fails closed in the worst way. That same host is also the auto-detected UI
host, and `host == uiHost` was evaluated first, so /v2/* was answered with
"registry API is not available on this domain, use 127.0.0.1:5000" — naming the
exact host the client had just used. The registry API is unreachable on the dev
stack, and any single-host deployment hits the same wall: listing a host in
registry_domains does nothing if it is also the UI host.
Both sides are now normalized through hostWithoutPort, and a registry domain
takes /v2/* even when it doubles as the UI host, which is a legitimate
single-domain deployment. Everything else is unchanged: a UI-only host still
refuses /v2/, registry domains still redirect non-/v2 traffic to the UI, and
/auth/token and /auth/device/* are still served directly so a cross-host 307
cannot strip the Authorization header.
hostWithoutPort uses net.SplitHostPort instead of the previous LastIndex(":")
scan, which mangled bracketed IPv6 literals into "[::1" and could never match
the "::1" that url.URL.Hostname() yields for the UI host.
The middleware had no tests at all. The two failing cases are pinned first, and
the four pre-existing behaviours are pinned alongside them so the reorder
cannot quietly widen what /v2/ is served on.
Pre-existing at efabb677 rather than introduced by this range, but it blocks
every registry-facing batch in the stack, so it lands at the base.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
123 lines
4.3 KiB
Go
123 lines
4.3 KiB
Go
package appview
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
// DomainRoutingMiddleware had no coverage at all, which is how both defects
|
|
// below survived into a shipped example config.
|
|
//
|
|
// The middleware strips the port from the request Host before matching, but
|
|
// compares against server.registry_domains verbatim. config-appview.example.yaml
|
|
// ships `registry_domains: [127.0.0.1:5000, atcr.io]`, so the first entry can
|
|
// never match any request — and because it also equals the auto-detected UI
|
|
// host, /v2/* is answered with "registry API is not available on this domain,
|
|
// use 127.0.0.1:5000", naming the very host that was used.
|
|
|
|
const (
|
|
bodyRegistry = "NEXT" // the wrapped handler ran
|
|
)
|
|
|
|
func routingProbe(t *testing.T, registryDomains []string, uiBaseURL, host, path string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(bodyRegistry))
|
|
})
|
|
h := DomainRoutingMiddleware(registryDomains, uiBaseURL)(next)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
req.Host = host
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// A port-bearing registry domain must match a request to that host:port.
|
|
// The shipped example config depends on this and it does not hold today.
|
|
func TestDomainRouting_PortBearingRegistryDomainMatches(t *testing.T) {
|
|
rec := routingProbe(t,
|
|
[]string{"registry.example:5000"},
|
|
"http://ui.example:5000",
|
|
"registry.example:5000",
|
|
"/v2/",
|
|
)
|
|
if rec.Body.String() != bodyRegistry {
|
|
t.Fatalf("registry domain configured with a port did not serve /v2/: status=%d body=%q",
|
|
rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// A single host serving both the UI and the registry is a legitimate
|
|
// deployment, and is exactly what the dev stack is. Listing that host in
|
|
// registry_domains should grant it /v2/*, but `host == uiHost` is evaluated
|
|
// first, so the registry entry is silently ignored.
|
|
func TestDomainRouting_SameHostServesBothUIAndRegistry(t *testing.T) {
|
|
const host = "127.0.0.1:5000"
|
|
|
|
v2 := routingProbe(t, []string{host}, "http://127.0.0.1:5000", host, "/v2/")
|
|
if v2.Body.String() != bodyRegistry {
|
|
t.Errorf("/v2/ not served when the UI host is also a registry domain: status=%d body=%q",
|
|
v2.Code, v2.Body.String())
|
|
}
|
|
|
|
ui := routingProbe(t, []string{host}, "http://127.0.0.1:5000", host, "/settings")
|
|
if ui.Body.String() != bodyRegistry {
|
|
t.Errorf("UI path stopped working on a dual-role host: status=%d body=%q",
|
|
ui.Code, ui.Body.String())
|
|
}
|
|
}
|
|
|
|
// Guard the behaviour that must NOT change: a UI-only host still refuses /v2/.
|
|
func TestDomainRouting_UIOnlyHostStillBlocksV2(t *testing.T) {
|
|
rec := routingProbe(t,
|
|
[]string{"registry.example"},
|
|
"http://ui.example",
|
|
"ui.example",
|
|
"/v2/",
|
|
)
|
|
if rec.Body.String() == bodyRegistry {
|
|
t.Fatal("/v2/ was served on a host that is not a registry domain")
|
|
}
|
|
}
|
|
|
|
func TestDomainRouting_RegistryDomainRedirectsNonV2ToUI(t *testing.T) {
|
|
rec := routingProbe(t,
|
|
[]string{"registry.example"},
|
|
"http://ui.example",
|
|
"registry.example",
|
|
"/settings",
|
|
)
|
|
if rec.Code != http.StatusTemporaryRedirect {
|
|
t.Fatalf("expected 307 to the UI, got %d", rec.Code)
|
|
}
|
|
if got := rec.Header().Get("Location"); got != "http://ui.example/settings" {
|
|
t.Fatalf("unexpected redirect target %q", got)
|
|
}
|
|
}
|
|
|
|
// Auth endpoints stay on the registry domain: a cross-host 307 would strip the
|
|
// Authorization header.
|
|
func TestDomainRouting_AuthEndpointsServedOnRegistryDomain(t *testing.T) {
|
|
for _, path := range []string{"/auth/token", "/auth/device/code"} {
|
|
rec := routingProbe(t, []string{"registry.example"}, "http://ui.example", "registry.example", path)
|
|
if rec.Body.String() != bodyRegistry {
|
|
t.Errorf("%s was not served directly on the registry domain: status=%d", path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDomainRouting_UnknownHostRedirectsExceptHealth(t *testing.T) {
|
|
rec := routingProbe(t, []string{"registry.example"}, "http://ui.example", "cdn.origin", "/")
|
|
if rec.Code != http.StatusTemporaryRedirect {
|
|
t.Errorf("unknown host should 307, got %d", rec.Code)
|
|
}
|
|
|
|
health := routingProbe(t, []string{"registry.example"}, "http://ui.example", "cdn.origin", "/health")
|
|
if health.Body.String() != bodyRegistry {
|
|
t.Errorf("/health should be served on unknown hosts, got status=%d", health.Code)
|
|
}
|
|
}
|