mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-08-23 12:24:57 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e672fc5ca |
12 changed files with 812 additions and 60 deletions
26
README.md
26
README.md
|
|
@ -40,6 +40,7 @@ Resolution order: package override, then ecosystem override, then global default
|
|||
| CRAN | R | | ✓ |
|
||||
| Julia | Julia | | ✓ |
|
||||
| Container | Docker/OCI | | ✓ |
|
||||
| Homebrew | macOS/Linux | | ✓ |
|
||||
| Debian | Debian/Ubuntu | | ✓ |
|
||||
| RPM | RHEL/Fedora | | ✓ |
|
||||
| Alpine | Alpine Linux | | ✗ |
|
||||
|
|
@ -157,6 +158,29 @@ export GOPROXY=http://localhost:8080/go,direct
|
|||
|
||||
Or in your shell profile for persistence.
|
||||
|
||||
### Homebrew
|
||||
|
||||
Point Homebrew's JSON API and artifact domain at the proxy:
|
||||
|
||||
```bash
|
||||
export HOMEBREW_API_DOMAIN=http://localhost:8080/homebrew
|
||||
export HOMEBREW_ARTIFACT_DOMAIN=http://localhost:8080
|
||||
```
|
||||
|
||||
The artifact domain proxies manifests and bottle blobs under `/v2/homebrew/core/`. GHCR routing is limited to that repository. Source archives, cask application downloads, custom tap artifacts, and legacy flat-file bottle mirrors use Homebrew's normal fallback URLs. Keep fallback enabled by leaving `HOMEBREW_ARTIFACT_DOMAIN_NO_FALLBACK` unset.
|
||||
|
||||
Enable `cache_metadata` or set `PROXY_CACHE_METADATA=true` to retain Homebrew JSON API responses for offline fallback. Bottle blobs and their OCI manifests are cached without this setting.
|
||||
|
||||
The upstreams default to `https://formulae.brew.sh/api` for the JSON API and `https://ghcr.io` for artifacts. To chain this proxy to another proxy, configure its Homebrew endpoints as the upstreams:
|
||||
|
||||
```yaml
|
||||
upstream:
|
||||
homebrew_api: "https://upstream-proxy.example.com/homebrew"
|
||||
homebrew_artifact: "https://upstream-proxy.example.com"
|
||||
```
|
||||
|
||||
The equivalent environment variables are `PROXY_UPSTREAM_HOMEBREW_API` and `PROXY_UPSTREAM_HOMEBREW_ARTIFACT`.
|
||||
|
||||
### Hex (Elixir)
|
||||
|
||||
Configure in `~/.hex/hex.config`:
|
||||
|
|
@ -670,7 +694,9 @@ Recently cached:
|
|||
| `GET /cran/*` | CRAN (R) protocol |
|
||||
| `GET /julia/*` | Julia Pkg server protocol |
|
||||
| `GET /helm/{repository}/*` | HTTP Helm chart repository protocol |
|
||||
| `GET /homebrew/*` | Homebrew JSON API |
|
||||
| `GET /v2/*` | OCI/Docker registry protocol |
|
||||
| `GET /v2/homebrew/core/*` | Homebrew core bottle manifests and blobs from GHCR |
|
||||
| `GET /debian/*` | Debian/APT repository protocol |
|
||||
| `GET /rpm/*` | RPM/Yum repository protocol |
|
||||
|
||||
|
|
|
|||
|
|
@ -102,6 +102,12 @@ upstream:
|
|||
# Debian/APT repository URL (used by /debian endpoint)
|
||||
debian: "http://deb.debian.org/debian"
|
||||
|
||||
# Homebrew JSON API URL (used by /homebrew endpoint)
|
||||
homebrew_api: "https://formulae.brew.sh/api"
|
||||
|
||||
# Homebrew artifact registry URL (used for /v2/homebrew/core requests)
|
||||
homebrew_artifact: "https://ghcr.io"
|
||||
|
||||
# Named HTTP Helm chart repositories (used by /helm/{name}/)
|
||||
# helm:
|
||||
# bitnami: "https://charts.bitnami.com/bitnami"
|
||||
|
|
|
|||
|
|
@ -143,6 +143,8 @@ upstream:
|
|||
gradle_plugin_portal: "https://plugins.gradle.org/m2"
|
||||
cargo: "https://index.crates.io"
|
||||
cargo_download: "https://static.crates.io/crates"
|
||||
homebrew_api: "https://formulae.brew.sh/api"
|
||||
homebrew_artifact: "https://ghcr.io"
|
||||
|
||||
# Named HTTP Helm chart repositories, served at /helm/{name}/.
|
||||
helm:
|
||||
|
|
|
|||
|
|
@ -318,6 +318,14 @@ type UpstreamConfig struct {
|
|||
// Default: http://deb.debian.org/debian
|
||||
Debian string `json:"debian" yaml:"debian"`
|
||||
|
||||
// HomebrewAPI is the upstream Homebrew JSON API URL.
|
||||
// Default: https://formulae.brew.sh/api
|
||||
HomebrewAPI string `json:"homebrew_api" yaml:"homebrew_api"`
|
||||
|
||||
// HomebrewArtifact is the upstream registry URL for Homebrew artifacts.
|
||||
// Default: https://ghcr.io
|
||||
HomebrewArtifact string `json:"homebrew_artifact" yaml:"homebrew_artifact"`
|
||||
|
||||
// Helm maps repository names to HTTP Helm chart repository URLs.
|
||||
// Requests use /helm/{name}/index.yaml and chart URLs in the index are
|
||||
// rewritten to the same named proxy endpoint.
|
||||
|
|
@ -476,6 +484,8 @@ func Default() *Config {
|
|||
Cargo: "https://index.crates.io",
|
||||
CargoDownload: "https://static.crates.io/crates",
|
||||
Debian: "http://deb.debian.org/debian",
|
||||
HomebrewAPI: "https://formulae.brew.sh/api",
|
||||
HomebrewArtifact: "https://ghcr.io",
|
||||
},
|
||||
Gradle: GradleConfig{
|
||||
BuildCache: GradleBuildCacheConfig{
|
||||
|
|
@ -566,6 +576,8 @@ func (c *Config) LoadFromEnv() {
|
|||
setEnvString(&c.Upstream.Maven, "PROXY_UPSTREAM_MAVEN")
|
||||
setEnvString(&c.Upstream.GradlePluginPortal, "PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL")
|
||||
setEnvString(&c.Upstream.Debian, "PROXY_UPSTREAM_DEBIAN")
|
||||
setEnvString(&c.Upstream.HomebrewAPI, "PROXY_UPSTREAM_HOMEBREW_API")
|
||||
setEnvString(&c.Upstream.HomebrewArtifact, "PROXY_UPSTREAM_HOMEBREW_ARTIFACT")
|
||||
setEnvString(&c.Cooldown.Default, "PROXY_COOLDOWN_DEFAULT")
|
||||
setEnvBool(&c.CacheMetadata, "PROXY_CACHE_METADATA")
|
||||
setEnvBool(&c.MirrorAPI, "PROXY_MIRROR_API")
|
||||
|
|
|
|||
|
|
@ -44,6 +44,12 @@ func TestDefault(t *testing.T) {
|
|||
if cfg.Upstream.Debian != "http://deb.debian.org/debian" {
|
||||
t.Errorf("Upstream.Debian = %q, want %q", cfg.Upstream.Debian, "http://deb.debian.org/debian")
|
||||
}
|
||||
if cfg.Upstream.HomebrewAPI != "https://formulae.brew.sh/api" {
|
||||
t.Errorf("Upstream.HomebrewAPI = %q, want %q", cfg.Upstream.HomebrewAPI, "https://formulae.brew.sh/api")
|
||||
}
|
||||
if cfg.Upstream.HomebrewArtifact != "https://ghcr.io" {
|
||||
t.Errorf("Upstream.HomebrewArtifact = %q, want %q", cfg.Upstream.HomebrewArtifact, "https://ghcr.io")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
|
|
@ -217,6 +223,9 @@ log:
|
|||
format: "json"
|
||||
access_log:
|
||||
path: "/var/log/proxy/access.jsonl"
|
||||
upstream:
|
||||
homebrew_api: "https://homebrew-api.example.com"
|
||||
homebrew_artifact: "https://homebrew-artifact.example.com"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("writing config file: %v", err)
|
||||
|
|
@ -248,6 +257,12 @@ access_log:
|
|||
if cfg.AccessLog.Path != "/var/log/proxy/access.jsonl" {
|
||||
t.Errorf("AccessLog.Path = %q, want %q", cfg.AccessLog.Path, "/var/log/proxy/access.jsonl")
|
||||
}
|
||||
if cfg.Upstream.HomebrewAPI != "https://homebrew-api.example.com" {
|
||||
t.Errorf("Upstream.HomebrewAPI = %q, want %q", cfg.Upstream.HomebrewAPI, "https://homebrew-api.example.com")
|
||||
}
|
||||
if cfg.Upstream.HomebrewArtifact != "https://homebrew-artifact.example.com" {
|
||||
t.Errorf("Upstream.HomebrewArtifact = %q, want %q", cfg.Upstream.HomebrewArtifact, "https://homebrew-artifact.example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadJSON(t *testing.T) {
|
||||
|
|
@ -287,6 +302,8 @@ func TestLoadFromEnv(t *testing.T) {
|
|||
t.Setenv("PROXY_UPSTREAM_MAVEN", "https://maven.example.com/repository/maven-public")
|
||||
t.Setenv("PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL", "https://plugins.example.com/m2")
|
||||
t.Setenv("PROXY_UPSTREAM_DEBIAN", "http://archive.ubuntu.com/ubuntu")
|
||||
t.Setenv("PROXY_UPSTREAM_HOMEBREW_API", "https://homebrew-api.example.com")
|
||||
t.Setenv("PROXY_UPSTREAM_HOMEBREW_ARTIFACT", "https://homebrew-artifact.example.com")
|
||||
t.Setenv("PROXY_GRADLE_BUILD_CACHE_READ_ONLY", "true")
|
||||
t.Setenv("PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE", "32MB")
|
||||
t.Setenv("PROXY_GRADLE_BUILD_CACHE_MAX_AGE", "12h")
|
||||
|
|
@ -322,6 +339,12 @@ func TestLoadFromEnv(t *testing.T) {
|
|||
if cfg.Upstream.Debian != "http://archive.ubuntu.com/ubuntu" {
|
||||
t.Errorf("Upstream.Debian = %q, want %q", cfg.Upstream.Debian, "http://archive.ubuntu.com/ubuntu")
|
||||
}
|
||||
if cfg.Upstream.HomebrewAPI != "https://homebrew-api.example.com" {
|
||||
t.Errorf("Upstream.HomebrewAPI = %q, want %q", cfg.Upstream.HomebrewAPI, "https://homebrew-api.example.com")
|
||||
}
|
||||
if cfg.Upstream.HomebrewArtifact != "https://homebrew-artifact.example.com" {
|
||||
t.Errorf("Upstream.HomebrewArtifact = %q, want %q", cfg.Upstream.HomebrewArtifact, "https://homebrew-artifact.example.com")
|
||||
}
|
||||
if !cfg.Gradle.BuildCache.ReadOnly {
|
||||
t.Error("Gradle.BuildCache.ReadOnly = false, want true")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@ type ContainerHandler struct {
|
|||
registryURL string
|
||||
proxyURL string
|
||||
namedRegistries map[string]string
|
||||
registries []containerRegistry
|
||||
}
|
||||
|
||||
type containerRegistry struct {
|
||||
repositoryPrefix string
|
||||
registryURL string
|
||||
}
|
||||
|
||||
// NewContainerHandler creates a new container registry protocol handler.
|
||||
|
|
@ -47,6 +53,36 @@ func NewContainerHandler(proxy *Proxy, proxyURL string, namedRegistries ...map[s
|
|||
return h
|
||||
}
|
||||
|
||||
// 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) {
|
||||
h.registries = append(h.registries, containerRegistry{
|
||||
repositoryPrefix: strings.Trim(repositoryPrefix, "/"),
|
||||
registryURL: strings.TrimSuffix(registryURL, "/"),
|
||||
})
|
||||
}
|
||||
|
||||
// BlockRegistry prevents a repository and its descendants from falling back to
|
||||
// the default OCI registry. A more specific registered repository still wins.
|
||||
func (h *ContainerHandler) BlockRegistry(repositoryPrefix string) {
|
||||
h.RegisterRegistry(repositoryPrefix, "")
|
||||
}
|
||||
|
||||
func (h *ContainerHandler) registryURLFor(name string) string {
|
||||
registryURL := h.registryURL
|
||||
matchLength := 0
|
||||
for _, registry := range h.registries {
|
||||
if name != registry.repositoryPrefix && !strings.HasPrefix(name, registry.repositoryPrefix+"/") {
|
||||
continue
|
||||
}
|
||||
if len(registry.repositoryPrefix) > matchLength {
|
||||
registryURL = registry.registryURL
|
||||
matchLength = len(registry.repositoryPrefix)
|
||||
}
|
||||
}
|
||||
return registryURL
|
||||
}
|
||||
|
||||
// Routes returns the HTTP handler for container registry requests.
|
||||
// Mount this at /v2 on your router.
|
||||
func (h *ContainerHandler) Routes() http.Handler {
|
||||
|
|
@ -114,10 +150,8 @@ func (h *ContainerHandler) handleBlobDownload(w http.ResponseWriter, r *http.Req
|
|||
}
|
||||
if cached != nil {
|
||||
w.Header().Set("Docker-Content-Digest", digest)
|
||||
if cached.ContentType != "" {
|
||||
w.Header().Set("Content-Type", cached.ContentType)
|
||||
} else {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
if cached.ContentType == "" {
|
||||
cached.ContentType = "application/octet-stream"
|
||||
}
|
||||
serveArtifact(w, r.Method, cached)
|
||||
return
|
||||
|
|
@ -130,13 +164,14 @@ func (h *ContainerHandler) handleBlobDownload(w http.ResponseWriter, r *http.Req
|
|||
}
|
||||
|
||||
// Try to get from cache, or fetch from the authentication-aware upstream client.
|
||||
result, err := h.proxy.GetOrFetchArtifactFromURL(
|
||||
result, err := h.proxy.GetOrFetchArtifactFromURLWithDigest(
|
||||
r.Context(),
|
||||
"oci",
|
||||
cacheName,
|
||||
digest, // use digest as version
|
||||
filename,
|
||||
fmt.Sprintf("%s/v2/%s/blobs/%s", registryURL, upstreamName, digest),
|
||||
digest,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
|
|
@ -144,16 +179,19 @@ func (h *ContainerHandler) handleBlobDownload(w http.ResponseWriter, r *http.Req
|
|||
h.containerError(w, http.StatusNotFound, "BLOB_UNKNOWN", "blob unknown to registry")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrArtifactDigestMismatch) {
|
||||
h.proxy.Logger.Error("upstream blob failed digest verification", "error", err)
|
||||
h.containerError(w, http.StatusBadGateway, "DIGEST_INVALID", "blob digest verification failed")
|
||||
return
|
||||
}
|
||||
h.proxy.Logger.Error("failed to fetch blob", "error", err)
|
||||
h.containerError(w, http.StatusBadGateway, "INTERNAL_ERROR", "failed to fetch blob")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Docker-Content-Digest", digest)
|
||||
if result.ContentType != "" {
|
||||
w.Header().Set("Content-Type", result.ContentType)
|
||||
} else {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
if result.ContentType == "" {
|
||||
result.ContentType = "application/octet-stream"
|
||||
}
|
||||
ServeArtifact(w, result)
|
||||
}
|
||||
|
|
@ -241,18 +279,22 @@ func (h *ContainerHandler) proxyBlobHead(w http.ResponseWriter, r *http.Request,
|
|||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
for _, header := range []string{"Content-Type", "Content-Length", "Docker-Content-Digest"} {
|
||||
for _, header := range []string{"Content-Type", "Content-Length", "Docker-Content-Digest", "ETag", "Last-Modified"} {
|
||||
if v := resp.Header.Get(header); v != "" {
|
||||
w.Header().Set(header, v)
|
||||
}
|
||||
}
|
||||
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices && w.Header().Get("Docker-Content-Digest") == "" {
|
||||
w.Header().Set("Docker-Content-Digest", digest)
|
||||
}
|
||||
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
}
|
||||
|
||||
// registryForName resolves a client-visible OCI repository name to an upstream
|
||||
// registry and its repository name. Named upstreams use upstream/{name}/ as a
|
||||
// reserved prefix; all other names continue to target Docker Hub.
|
||||
// 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) {
|
||||
parts := strings.SplitN(name, "/", registrySelectorParts)
|
||||
if len(parts) >= 2 && parts[0] == "upstream" {
|
||||
|
|
@ -265,7 +307,11 @@ func (h *ContainerHandler) registryForName(name string) (registryURL, upstreamNa
|
|||
}
|
||||
return registryURL, parts[2], name, true
|
||||
}
|
||||
return h.registryURL, name, name, true
|
||||
registryURL = h.registryURLFor(name)
|
||||
if registryURL == "" {
|
||||
return "", "", "", false
|
||||
}
|
||||
return registryURL, name, name, true
|
||||
}
|
||||
|
||||
// containerError writes an OCI-compliant error response.
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ type cachedContainerManifest struct {
|
|||
contentDigest string
|
||||
etag string
|
||||
size int64
|
||||
lastModified time.Time
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
|
|
@ -44,7 +45,7 @@ func (h *ContainerHandler) serveManifest(w http.ResponseWriter, r *http.Request,
|
|||
|
||||
immutable := manifestDigestReferencePattern.MatchString(reference)
|
||||
if cached != nil && (immutable || h.containerManifestFresh(cached)) {
|
||||
writeContainerManifest(w, r.Method, cached, false)
|
||||
writeContainerManifest(w, r, cached, false)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -71,12 +72,12 @@ func (h *ContainerHandler) serveManifest(w http.ResponseWriter, r *http.Request,
|
|||
if err := h.storeContainerManifest(r.Context(), cacheKey, cached); err != nil {
|
||||
h.proxy.Logger.Warn("failed to refresh cached container manifest", "error", err)
|
||||
}
|
||||
writeContainerManifest(w, r.Method, cached, false)
|
||||
writeContainerManifest(w, r, cached, false)
|
||||
return
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if cached != nil && shouldServeStaleManifest(resp.StatusCode) {
|
||||
writeContainerManifest(w, r.Method, cached, true)
|
||||
writeContainerManifest(w, r, cached, true)
|
||||
return
|
||||
}
|
||||
copyContainerManifestHeaders(w.Header(), resp.Header)
|
||||
|
|
@ -96,17 +97,27 @@ func (h *ContainerHandler) serveManifest(w http.ResponseWriter, r *http.Request,
|
|||
h.serveStaleManifestOrError(w, r, cached, fmt.Errorf("reading manifest: %w", err))
|
||||
return
|
||||
}
|
||||
computedDigest := sha256Digest(body)
|
||||
contentDigest := resp.Header.Get("Docker-Content-Digest")
|
||||
if contentDigest == "" {
|
||||
contentDigest = computedDigest
|
||||
}
|
||||
if (immutable && reference != computedDigest) || contentDigest != computedDigest {
|
||||
h.proxy.Logger.Error("upstream manifest failed digest verification",
|
||||
"name", name, "reference", reference, "expected", contentDigest, "actual", computedDigest)
|
||||
h.containerError(w, http.StatusBadGateway, "DIGEST_INVALID", "manifest digest verification failed")
|
||||
return
|
||||
}
|
||||
|
||||
manifest := &cachedContainerManifest{
|
||||
body: body,
|
||||
contentType: resp.Header.Get("Content-Type"),
|
||||
contentDigest: resp.Header.Get("Docker-Content-Digest"),
|
||||
contentDigest: contentDigest,
|
||||
etag: resp.Header.Get("ETag"),
|
||||
size: int64(len(body)),
|
||||
lastModified: parseHTTPTime(resp.Header.Get("Last-Modified")),
|
||||
fetchedAt: time.Now(),
|
||||
}
|
||||
if manifest.contentDigest == "" {
|
||||
manifest.contentDigest = sha256Digest(body)
|
||||
}
|
||||
if err := h.storeContainerManifest(r.Context(), cacheKey, manifest); err != nil {
|
||||
h.proxy.Logger.Warn("failed to cache container manifest", "error", err)
|
||||
}
|
||||
|
|
@ -116,13 +127,13 @@ func (h *ContainerHandler) serveManifest(w http.ResponseWriter, r *http.Request,
|
|||
h.proxy.Logger.Warn("failed to cache container manifest by digest", "error", err)
|
||||
}
|
||||
}
|
||||
writeContainerManifest(w, r.Method, manifest, false)
|
||||
writeContainerManifest(w, r, manifest, false)
|
||||
}
|
||||
|
||||
func (h *ContainerHandler) serveStaleManifestOrError(w http.ResponseWriter, r *http.Request, cached *cachedContainerManifest, err error) {
|
||||
if cached != nil {
|
||||
h.proxy.Logger.Warn("upstream manifest fetch failed, serving stale cache", "error", err)
|
||||
writeContainerManifest(w, r.Method, cached, true)
|
||||
writeContainerManifest(w, r, cached, true)
|
||||
return
|
||||
}
|
||||
h.proxy.Logger.Error("failed to fetch manifest", "error", err)
|
||||
|
|
@ -172,6 +183,9 @@ func (h *ContainerHandler) loadContainerManifest(ctx context.Context, cacheKey s
|
|||
if entry.Size.Valid {
|
||||
manifest.size = entry.Size.Int64
|
||||
}
|
||||
if entry.LastModified.Valid {
|
||||
manifest.lastModified = entry.LastModified.Time
|
||||
}
|
||||
if entry.FetchedAt.Valid {
|
||||
manifest.fetchedAt = entry.FetchedAt.Time
|
||||
}
|
||||
|
|
@ -196,11 +210,12 @@ func (h *ContainerHandler) storeContainerManifest(ctx context.Context, cacheKey
|
|||
ContentType: sql.NullString{String: manifest.contentType, Valid: manifest.contentType != ""},
|
||||
ContentDigest: sql.NullString{String: manifest.contentDigest, Valid: manifest.contentDigest != ""},
|
||||
Size: sql.NullInt64{Int64: size, Valid: true},
|
||||
LastModified: sql.NullTime{Time: manifest.lastModified, Valid: !manifest.lastModified.IsZero()},
|
||||
FetchedAt: sql.NullTime{Time: manifest.fetchedAt, Valid: !manifest.fetchedAt.IsZero()},
|
||||
})
|
||||
}
|
||||
|
||||
func writeContainerManifest(w http.ResponseWriter, method string, manifest *cachedContainerManifest, stale bool) {
|
||||
func writeContainerManifest(w http.ResponseWriter, r *http.Request, manifest *cachedContainerManifest, stale bool) {
|
||||
if manifest.contentType != "" {
|
||||
w.Header().Set("Content-Type", manifest.contentType)
|
||||
}
|
||||
|
|
@ -211,11 +226,24 @@ func writeContainerManifest(w http.ResponseWriter, method string, manifest *cach
|
|||
if manifest.etag != "" {
|
||||
w.Header().Set("ETag", manifest.etag)
|
||||
}
|
||||
if !manifest.lastModified.IsZero() {
|
||||
w.Header().Set("Last-Modified", manifest.lastModified.UTC().Format(http.TimeFormat))
|
||||
}
|
||||
if stale {
|
||||
w.Header().Set("Warning", containerStaleWarning)
|
||||
}
|
||||
if manifest.etag != "" && r.Header.Get("If-None-Match") == manifest.etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
if !manifest.lastModified.IsZero() {
|
||||
if modifiedSince, err := http.ParseTime(r.Header.Get("If-Modified-Since")); err == nil && !manifest.lastModified.After(modifiedSince) {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if method != http.MethodHead {
|
||||
if r.Method != http.MethodHead {
|
||||
_, _ = w.Write(manifest.body)
|
||||
}
|
||||
}
|
||||
|
|
@ -234,13 +262,18 @@ func containerManifestAccept(r *http.Request) string {
|
|||
}
|
||||
|
||||
func copyContainerManifestHeaders(destination, source http.Header) {
|
||||
for _, header := range []string{"Content-Type", "Content-Length", "Docker-Content-Digest", "ETag", "WWW-Authenticate"} {
|
||||
for _, header := range []string{"Content-Type", "Content-Length", "Docker-Content-Digest", "ETag", "Last-Modified", "WWW-Authenticate"} {
|
||||
if value := source.Get(header); value != "" {
|
||||
destination.Set(header, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseHTTPTime(value string) time.Time {
|
||||
parsed, _ := http.ParseTime(value)
|
||||
return parsed
|
||||
}
|
||||
|
||||
func shouldServeStaleManifest(status int) bool {
|
||||
return status == http.StatusTooManyRequests || status >= http.StatusInternalServerError
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -135,17 +136,18 @@ func TestContainerHandler_parseTagsListPath(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestContainerHandler_NamedOCIRegistryServesHelmArtifacts(t *testing.T) {
|
||||
digest := "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd"
|
||||
const blob = "chart archive"
|
||||
digest := "sha256:" + sha256Hex(blob)
|
||||
manifest := `{"schemaVersion":2,"config":{"mediaType":"application/vnd.cncf.helm.config.v1+json"},"layers":[{"mediaType":"application/vnd.cncf.helm.chart.content.v1.tar+gzip","digest":"` + digest + `"}]}`
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v2/owner/demo/manifests/1.0.0":
|
||||
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
|
||||
w.Header().Set("Docker-Content-Digest", digest)
|
||||
w.Header().Set("Docker-Content-Digest", "sha256:"+sha256Hex(manifest))
|
||||
_, _ = io.WriteString(w, manifest)
|
||||
case "/v2/owner/demo/blobs/" + digest:
|
||||
w.Header().Set("Content-Type", "application/vnd.cncf.helm.chart.content.v1.tar+gzip")
|
||||
_, _ = io.WriteString(w, "chart archive")
|
||||
_, _ = io.WriteString(w, blob)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
|
@ -180,8 +182,28 @@ func TestContainerHandler_NamedOCIRegistryServesHelmArtifacts(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestContainerHandler_registryURLForUsesLongestRepositoryPrefix(t *testing.T) {
|
||||
h := &ContainerHandler{registryURL: "https://registry-1.docker.io"}
|
||||
h.RegisterRegistry("homebrew", "https://example.test")
|
||||
h.RegisterRegistry("homebrew/core", "https://ghcr.io/")
|
||||
|
||||
tests := map[string]string{
|
||||
"homebrew/core": "https://ghcr.io",
|
||||
"homebrew/core/jq": "https://ghcr.io",
|
||||
"homebrew/portable-ruby": "https://example.test",
|
||||
"homebrew-core/jq": "https://registry-1.docker.io",
|
||||
"library/homebrew/core/jq": "https://registry-1.docker.io",
|
||||
}
|
||||
for name, want := range tests {
|
||||
if got := h.registryURLFor(name); got != want {
|
||||
t.Errorf("registryURLFor(%q) = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerHandler_BlobDownload_DiscoversBearerChallenge(t *testing.T) {
|
||||
digest := "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd"
|
||||
blob := "upstream blob"
|
||||
digest := sha256Digest([]byte(blob))
|
||||
registryRequests := 0
|
||||
tokenRequests := 0
|
||||
var upstream *httptest.Server
|
||||
|
|
@ -202,7 +224,7 @@ func TestContainerHandler_BlobDownload_DiscoversBearerChallenge(t *testing.T) {
|
|||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = io.WriteString(w, "upstream blob")
|
||||
_, _ = io.WriteString(w, blob)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
|
@ -233,8 +255,8 @@ func TestContainerHandler_BlobDownload_DiscoversBearerChallenge(t *testing.T) {
|
|||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
if got := w.Body.String(); got != "upstream blob" {
|
||||
t.Errorf("body = %q, want %q", got, "upstream blob")
|
||||
if got := w.Body.String(); got != blob {
|
||||
t.Errorf("body = %q, want %q", got, blob)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -246,10 +268,102 @@ func TestContainerHandler_BlobDownload_DiscoversBearerChallenge(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestContainerHandler_HomebrewBlobDoesNotForwardClientCredentialsToRegistryOrCDN(t *testing.T) {
|
||||
blob := "homebrew bottle"
|
||||
digest := sha256Digest([]byte(blob))
|
||||
var registryAuthorization, cdnAuthorization string
|
||||
|
||||
cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cdnAuthorization = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/vnd.homebrew.bottle")
|
||||
_, _ = io.WriteString(w, blob)
|
||||
}))
|
||||
defer cdn.Close()
|
||||
|
||||
registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
registryAuthorization = r.Header.Get("Authorization")
|
||||
http.Redirect(w, r, cdn.URL+"/bottle", http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer registry.Close()
|
||||
|
||||
defaultRequests := 0
|
||||
defaultRegistry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defaultRequests++
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer defaultRegistry.Close()
|
||||
|
||||
proxy, _, _, _ := setupTestProxy(t)
|
||||
client := registry.Client()
|
||||
artifactFetcher := fetch.NewFetcher(
|
||||
fetch.WithHTTPClient(client),
|
||||
fetch.WithMaxRetries(0),
|
||||
)
|
||||
t.Cleanup(func() { _ = artifactFetcher.Close() })
|
||||
proxy.Fetcher = artifactFetcher
|
||||
|
||||
h := &ContainerHandler{proxy: proxy, registryURL: defaultRegistry.URL}
|
||||
h.RegisterRegistry("homebrew/core", registry.URL)
|
||||
req := httptest.NewRequest(http.MethodGet, "/homebrew/core/jq/blobs/"+digest, nil)
|
||||
req.Header.Set("Authorization", "Bearer client-secret")
|
||||
w := httptest.NewRecorder()
|
||||
h.Routes().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
if got := w.Body.String(); got != blob {
|
||||
t.Errorf("body = %q, want %q", got, blob)
|
||||
}
|
||||
if got := w.Header().Get("Content-Type"); got != "application/vnd.homebrew.bottle" {
|
||||
t.Errorf("Content-Type = %q, want application/vnd.homebrew.bottle", got)
|
||||
}
|
||||
if registryAuthorization != "" {
|
||||
t.Errorf("registry Authorization = %q, want empty", registryAuthorization)
|
||||
}
|
||||
if cdnAuthorization != "" {
|
||||
t.Errorf("CDN Authorization = %q, want empty", cdnAuthorization)
|
||||
}
|
||||
if defaultRequests != 0 {
|
||||
t.Errorf("default registry requests = %d, want 0", defaultRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerHandler_BlobDigestMismatchIsNotCached(t *testing.T) {
|
||||
proxy, _, store, fetcher := setupTestProxy(t)
|
||||
fetcher.artifact = &fetch.Artifact{
|
||||
Body: io.NopCloser(strings.NewReader("wrong bottle")),
|
||||
ContentType: "application/octet-stream",
|
||||
}
|
||||
digest := sha256Digest([]byte("expected bottle"))
|
||||
h := &ContainerHandler{proxy: proxy, registryURL: "https://registry.example.test"}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/homebrew/core/jq/blobs/"+digest, nil))
|
||||
|
||||
if w.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusBadGateway, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "DIGEST_INVALID") {
|
||||
t.Errorf("body = %q, want DIGEST_INVALID", w.Body.String())
|
||||
}
|
||||
cached, err := proxy.GetCachedArtifact(t.Context(), "oci", "homebrew/core/jq", digest, digest)
|
||||
if err != nil {
|
||||
t.Fatalf("checking cache: %v", err)
|
||||
}
|
||||
if cached != nil {
|
||||
t.Error("digest-mismatched blob was recorded in the cache")
|
||||
}
|
||||
if len(store.files) != 0 {
|
||||
t.Errorf("stored files = %d, want 0", len(store.files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerHandler_CachedImagePullSurvivesRegistryAndTokenOutages(t *testing.T) {
|
||||
digest := "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd"
|
||||
manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json"}`
|
||||
blob := "cached image blob"
|
||||
manifestDigest := sha256Digest([]byte(manifest))
|
||||
blobDigest := sha256Digest([]byte(blob))
|
||||
registryAvailable := true
|
||||
tokenAvailable := true
|
||||
registryRequests := 0
|
||||
|
|
@ -284,9 +398,9 @@ func TestContainerHandler_CachedImagePullSurvivesRegistryAndTokenOutages(t *test
|
|||
switch r.URL.Path {
|
||||
case "/v2/library/nginx/manifests/latest":
|
||||
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
|
||||
w.Header().Set("Docker-Content-Digest", digest)
|
||||
w.Header().Set("Docker-Content-Digest", manifestDigest)
|
||||
_, _ = io.WriteString(w, manifest)
|
||||
case "/v2/library/nginx/blobs/" + digest:
|
||||
case "/v2/library/nginx/blobs/" + blobDigest:
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = io.WriteString(w, blob)
|
||||
default:
|
||||
|
|
@ -316,7 +430,7 @@ func TestContainerHandler_CachedImagePullSurvivesRegistryAndTokenOutages(t *test
|
|||
body string
|
||||
}{
|
||||
{path: "/library/nginx/manifests/latest", body: manifest},
|
||||
{path: "/library/nginx/blobs/" + digest, body: blob},
|
||||
{path: "/library/nginx/blobs/" + blobDigest, body: blob},
|
||||
} {
|
||||
response := httptest.NewRecorder()
|
||||
warmHandler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, request.path, nil))
|
||||
|
|
@ -352,10 +466,11 @@ func TestContainerHandler_CachedImagePullSurvivesRegistryAndTokenOutages(t *test
|
|||
name string
|
||||
path string
|
||||
body string
|
||||
digest string
|
||||
}{
|
||||
{name: "tag manifest", path: "/library/nginx/manifests/latest", body: manifest},
|
||||
{name: "digest manifest", path: "/library/nginx/manifests/" + digest, body: manifest},
|
||||
{name: "blob", path: "/library/nginx/blobs/" + digest, body: blob},
|
||||
{name: "tag manifest", path: "/library/nginx/manifests/latest", body: manifest, digest: manifestDigest},
|
||||
{name: "digest manifest", path: "/library/nginx/manifests/" + manifestDigest, body: manifest, digest: manifestDigest},
|
||||
{name: "blob", path: "/library/nginx/blobs/" + blobDigest, body: blob, digest: blobDigest},
|
||||
} {
|
||||
t.Run(request.name, func(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
|
|
@ -366,8 +481,8 @@ func TestContainerHandler_CachedImagePullSurvivesRegistryAndTokenOutages(t *test
|
|||
if got := response.Body.String(); got != request.body {
|
||||
t.Errorf("body = %q, want %q", got, request.body)
|
||||
}
|
||||
if got := response.Header().Get("Docker-Content-Digest"); got != digest {
|
||||
t.Errorf("Docker-Content-Digest = %q, want %q", got, digest)
|
||||
if got := response.Header().Get("Docker-Content-Digest"); got != request.digest {
|
||||
t.Errorf("Docker-Content-Digest = %q, want %q", got, request.digest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -495,8 +610,9 @@ func TestContainerHandler_BlobHead_DirectServeRedirects(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestContainerHandler_ManifestByDigest_CacheHitSkipsUpstream(t *testing.T) {
|
||||
digest := "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json"}`
|
||||
digest := sha256Digest([]byte(manifest))
|
||||
lastModified := time.Date(2026, time.August, 14, 9, 30, 0, 0, time.UTC)
|
||||
upstreamAvailable := true
|
||||
upstreamRequests := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -512,6 +628,7 @@ func TestContainerHandler_ManifestByDigest_CacheHitSkipsUpstream(t *testing.T) {
|
|||
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
|
||||
w.Header().Set("Docker-Content-Digest", digest)
|
||||
w.Header().Set("ETag", `"manifest-etag"`)
|
||||
w.Header().Set("Last-Modified", lastModified.Format(http.TimeFormat))
|
||||
if r.Method != http.MethodHead {
|
||||
_, _ = io.WriteString(w, manifest)
|
||||
}
|
||||
|
|
@ -543,6 +660,23 @@ func TestContainerHandler_ManifestByDigest_CacheHitSkipsUpstream(t *testing.T) {
|
|||
if got := second.Header().Get("Docker-Content-Digest"); got != digest {
|
||||
t.Errorf("cached Docker-Content-Digest = %q, want %q", got, digest)
|
||||
}
|
||||
if got := second.Header().Get("Last-Modified"); got != lastModified.Format(http.TimeFormat) {
|
||||
t.Errorf("cached Last-Modified = %q, want %q", got, lastModified.Format(http.TimeFormat))
|
||||
}
|
||||
|
||||
conditionalRequest := httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/"+digest, nil)
|
||||
conditionalRequest.Header.Set("If-None-Match", `"manifest-etag"`)
|
||||
conditional := httptest.NewRecorder()
|
||||
h.Routes().ServeHTTP(conditional, conditionalRequest)
|
||||
if conditional.Code != http.StatusNotModified {
|
||||
t.Fatalf("conditional status = %d, want %d", conditional.Code, http.StatusNotModified)
|
||||
}
|
||||
if got := conditional.Header().Get("ETag"); got != `"manifest-etag"` {
|
||||
t.Errorf("conditional ETag = %q, want %q", got, `"manifest-etag"`)
|
||||
}
|
||||
if conditional.Body.Len() != 0 {
|
||||
t.Errorf("conditional body length = %d, want 0", conditional.Body.Len())
|
||||
}
|
||||
|
||||
head := httptest.NewRecorder()
|
||||
h.Routes().ServeHTTP(head, httptest.NewRequest(http.MethodHead, "/library/nginx/manifests/"+digest, nil))
|
||||
|
|
@ -561,9 +695,72 @@ func TestContainerHandler_ManifestByDigest_CacheHitSkipsUpstream(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestContainerHandler_ManifestDigestMismatchIsNotCached(t *testing.T) {
|
||||
manifest := `{"schemaVersion":2}`
|
||||
digest := sha256Digest([]byte("different manifest"))
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
|
||||
w.Header().Set("Docker-Content-Digest", digest)
|
||||
_, _ = io.WriteString(w, manifest)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
proxy, db, _, _ := setupTestProxy(t)
|
||||
proxy.HTTPClient = upstream.Client()
|
||||
h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL}
|
||||
req := httptest.NewRequest(http.MethodGet, "/homebrew/core/jq/manifests/"+digest, nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.Routes().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusBadGateway, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "DIGEST_INVALID") {
|
||||
t.Errorf("body = %q, want DIGEST_INVALID", w.Body.String())
|
||||
}
|
||||
cacheKey := h.containerManifestCacheKey(upstream.URL, "homebrew/core/jq", digest, containerManifestAccept(req))
|
||||
entry, err := db.GetMetadataCache(containerManifestCacheEcosystem, cacheKey)
|
||||
if err != nil {
|
||||
t.Fatalf("checking manifest cache: %v", err)
|
||||
}
|
||||
if entry != nil {
|
||||
t.Error("digest-mismatched manifest was recorded in the cache")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerHandler_ManifestTagWithInvalidDigestIsNotAliased(t *testing.T) {
|
||||
manifest := `{"schemaVersion":2}`
|
||||
invalidDigest := sha256Digest([]byte("different manifest"))
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
|
||||
w.Header().Set("Docker-Content-Digest", invalidDigest)
|
||||
_, _ = io.WriteString(w, manifest)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
proxy, db, _, _ := setupTestProxy(t)
|
||||
proxy.HTTPClient = upstream.Client()
|
||||
h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL}
|
||||
req := httptest.NewRequest(http.MethodGet, "/homebrew/core/jq/manifests/latest", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.Routes().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusBadGateway, w.Body.String())
|
||||
}
|
||||
cacheKey := h.containerManifestCacheKey(upstream.URL, "homebrew/core/jq", invalidDigest, containerManifestAccept(req))
|
||||
entry, err := db.GetMetadataCache(containerManifestCacheEcosystem, cacheKey)
|
||||
if err != nil {
|
||||
t.Fatalf("checking manifest cache: %v", err)
|
||||
}
|
||||
if entry != nil {
|
||||
t.Error("tag manifest was cached under an unverified digest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerHandler_ManifestByTag_UsesStaleCacheOnUpstreamFailure(t *testing.T) {
|
||||
digest := "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json"}`
|
||||
digest := sha256Digest([]byte(manifest))
|
||||
upstreamAvailable := true
|
||||
upstreamRequests := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
|
|
@ -610,8 +807,8 @@ func TestContainerHandler_ManifestByTag_UsesStaleCacheOnUpstreamFailure(t *testi
|
|||
}
|
||||
|
||||
func TestContainerHandler_ManifestByTag_CachesDigestAlias(t *testing.T) {
|
||||
digest := "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||
manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json"}`
|
||||
digest := sha256Digest([]byte(manifest))
|
||||
upstreamAvailable := true
|
||||
upstreamRequests := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -658,7 +855,8 @@ func TestContainerHandler_ManifestByTag_CachesDigestAlias(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestContainerHandler_ManifestByTag_StaleHeadChecksUpstream(t *testing.T) {
|
||||
oldDigest := "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
|
||||
manifest := `{"schemaVersion":2}`
|
||||
oldDigest := sha256Digest([]byte(manifest))
|
||||
newDigest := "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
|
||||
currentDigest := oldDigest
|
||||
upstreamRequests := 0
|
||||
|
|
@ -668,7 +866,7 @@ func TestContainerHandler_ManifestByTag_StaleHeadChecksUpstream(t *testing.T) {
|
|||
w.Header().Set("Docker-Content-Digest", currentDigest)
|
||||
w.Header().Set("ETag", `"`+currentDigest+`"`)
|
||||
if r.Method != http.MethodHead {
|
||||
_, _ = io.WriteString(w, `{"schemaVersion":2}`)
|
||||
_, _ = io.WriteString(w, manifest)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
|
|
|||
|
|
@ -633,6 +633,8 @@ func (p *Proxy) fetchUpstreamMetadata(ctx context.Context, upstreamURL string, e
|
|||
|
||||
if entry != nil && entry.ETag.Valid {
|
||||
req.Header.Set("If-None-Match", entry.ETag.String)
|
||||
} else if entry != nil && entry.LastModified.Valid {
|
||||
req.Header.Set("If-Modified-Since", entry.LastModified.Time.UTC().Format(http.TimeFormat))
|
||||
}
|
||||
|
||||
resp, err := p.HTTPClient.Do(req)
|
||||
|
|
@ -775,6 +777,12 @@ func (p *Proxy) ProxyCached(w http.ResponseWriter, r *http.Request, upstreamURL,
|
|||
func (p *Proxy) writeMetadataCachedResponse(w http.ResponseWriter, r *http.Request, ecosystem, cacheKey string, body []byte, contentType string) {
|
||||
cm := p.lookupCachedMeta(ecosystem, cacheKey)
|
||||
|
||||
if cm.etag != "" {
|
||||
w.Header().Set("ETag", cm.etag)
|
||||
}
|
||||
if !cm.lastModified.IsZero() {
|
||||
w.Header().Set("Last-Modified", cm.lastModified.UTC().Format(http.TimeFormat))
|
||||
}
|
||||
if cm.etag != "" {
|
||||
if match := r.Header.Get("If-None-Match"); match != "" && match == cm.etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
|
|
@ -792,23 +800,19 @@ func (p *Proxy) writeMetadataCachedResponse(w http.ResponseWriter, r *http.Reque
|
|||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
||||
if cm.etag != "" {
|
||||
w.Header().Set("ETag", cm.etag)
|
||||
}
|
||||
if !cm.lastModified.IsZero() {
|
||||
w.Header().Set("Last-Modified", cm.lastModified.UTC().Format(http.TimeFormat))
|
||||
}
|
||||
if cm.stale {
|
||||
w.Header().Set("Warning", `110 - "Response is Stale"`)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if r.Method != http.MethodHead {
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
}
|
||||
|
||||
// proxyMetadataStream forwards an upstream metadata response by streaming it to the client
|
||||
// without buffering the full body in memory.
|
||||
func (p *Proxy) proxyMetadataStream(w http.ResponseWriter, r *http.Request, upstreamURL string, acceptHeaders ...string) {
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
|
||||
req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to create request", http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -841,8 +845,10 @@ func (p *Proxy) proxyMetadataStream(w http.ResponseWriter, r *http.Request, upst
|
|||
}
|
||||
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
if r.Method != http.MethodHead {
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Proxy) applyUpstreamAuth(req *http.Request) {
|
||||
if p.AuthForURL == nil {
|
||||
|
|
@ -858,12 +864,22 @@ func (p *Proxy) applyUpstreamAuth(req *http.Request) {
|
|||
// GetOrFetchArtifactFromURL retrieves an artifact from cache or fetches from a specific URL.
|
||||
// This is useful for registries where download URLs are determined from metadata.
|
||||
func (p *Proxy) GetOrFetchArtifactFromURL(ctx context.Context, ecosystem, name, version, filename, downloadURL string) (*CacheResult, error) {
|
||||
return p.GetOrFetchArtifactFromURLWithHeaders(ctx, ecosystem, name, version, filename, downloadURL, nil)
|
||||
return p.getOrFetchArtifactFromURL(ctx, ecosystem, name, version, filename, downloadURL, nil, "")
|
||||
}
|
||||
|
||||
// GetOrFetchArtifactFromURLWithHeaders retrieves an artifact from cache or fetches from a URL
|
||||
// with additional request-specific HTTP headers.
|
||||
func (p *Proxy) GetOrFetchArtifactFromURLWithHeaders(ctx context.Context, ecosystem, name, version, filename, downloadURL string, headers http.Header) (*CacheResult, error) {
|
||||
return p.getOrFetchArtifactFromURL(ctx, ecosystem, name, version, filename, downloadURL, headers, "")
|
||||
}
|
||||
|
||||
// GetOrFetchArtifactFromURLWithDigest retrieves an artifact and verifies its
|
||||
// SHA-256 digest before adding a newly fetched response to the cache.
|
||||
func (p *Proxy) GetOrFetchArtifactFromURLWithDigest(ctx context.Context, ecosystem, name, version, filename, downloadURL, digest string) (*CacheResult, error) {
|
||||
return p.getOrFetchArtifactFromURL(ctx, ecosystem, name, version, filename, downloadURL, nil, digest)
|
||||
}
|
||||
|
||||
func (p *Proxy) getOrFetchArtifactFromURL(ctx context.Context, ecosystem, name, version, filename, downloadURL string, headers http.Header, digest string) (*CacheResult, error) {
|
||||
if cached, err := p.GetCachedArtifact(ctx, ecosystem, name, version, filename); err != nil {
|
||||
return nil, err
|
||||
} else if cached != nil {
|
||||
|
|
@ -873,10 +889,10 @@ func (p *Proxy) GetOrFetchArtifactFromURLWithHeaders(ctx context.Context, ecosys
|
|||
|
||||
pkgPURL := purl.MakePURLString(ecosystem, name, "")
|
||||
versionPURL := purl.MakePURLString(ecosystem, name, version)
|
||||
return p.fetchAndCacheFromURL(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, headers)
|
||||
return p.fetchAndCacheFromURL(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, headers, digest)
|
||||
}
|
||||
|
||||
func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL string, headers http.Header) (*CacheResult, error) {
|
||||
func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL string, headers http.Header, expectedDigest string) (*CacheResult, error) {
|
||||
p.Logger.Info("fetching from upstream",
|
||||
"ecosystem", ecosystem, "name", name, "version", version, "url", downloadURL)
|
||||
|
||||
|
|
@ -894,6 +910,12 @@ func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, versi
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("storing artifact: %w", err)
|
||||
}
|
||||
if expectedDigest != "" && expectedDigest != "sha256:"+hash {
|
||||
if deleteErr := p.Storage.Delete(ctx, storagePath); deleteErr != nil {
|
||||
p.Logger.Warn("failed to remove artifact with invalid digest", "path", storagePath, "error", deleteErr)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: expected %s, got sha256:%s", ErrArtifactDigestMismatch, expectedDigest, hash)
|
||||
}
|
||||
|
||||
if err := p.updateCacheDB(ecosystem, name, filename, pkgPURL, versionPURL, downloadURL, storagePath, hash, size, artifact.ContentType); err != nil {
|
||||
p.Logger.Warn("failed to update cache database", "error", err)
|
||||
|
|
@ -912,3 +934,7 @@ func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, versi
|
|||
Cached: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ErrArtifactDigestMismatch indicates that fetched bytes did not match their
|
||||
// digest-addressed URL and were not recorded in the cache database.
|
||||
var ErrArtifactDigestMismatch = errors.New("artifact digest mismatch")
|
||||
|
|
|
|||
70
internal/handler/homebrew.go
Normal file
70
internal/handler/homebrew.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
homebrewArtifactNamespace = "homebrew"
|
||||
homebrewArtifactRepository = "homebrew/core"
|
||||
homebrewMetadataEcosystem = "homebrew"
|
||||
)
|
||||
|
||||
// HomebrewHandler proxies Homebrew's JSON API without modifying signed files.
|
||||
type HomebrewHandler struct {
|
||||
proxy *Proxy
|
||||
apiUpstream string
|
||||
}
|
||||
|
||||
// NewHomebrewHandler creates a Homebrew JSON API handler.
|
||||
func NewHomebrewHandler(proxy *Proxy, apiUpstream string) *HomebrewHandler {
|
||||
return &HomebrewHandler{
|
||||
proxy: proxy,
|
||||
apiUpstream: strings.TrimSuffix(apiUpstream, "/"),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterHomebrewArtifacts routes homebrew/core OCI requests to its configured
|
||||
// registry and blocks other homebrew repositories from reaching the default
|
||||
// OCI registry.
|
||||
func RegisterHomebrewArtifacts(container *ContainerHandler, artifactUpstream string) {
|
||||
container.BlockRegistry(homebrewArtifactNamespace)
|
||||
container.RegisterRegistry(homebrewArtifactRepository, artifactUpstream)
|
||||
}
|
||||
|
||||
// Routes returns the Homebrew JSON API handler. Mount this at /homebrew.
|
||||
func (h *HomebrewHandler) Routes() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
requestPath := strings.TrimPrefix(r.URL.EscapedPath(), "/")
|
||||
if requestPath == "" || containsPathTraversal(requestPath) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
upstreamURL := h.apiUpstream + "/" + requestPath
|
||||
if r.URL.RawQuery != "" {
|
||||
upstreamURL += "?" + r.URL.RawQuery
|
||||
}
|
||||
|
||||
accept := r.Header.Get("Accept")
|
||||
if r.Method == http.MethodHead {
|
||||
h.proxy.proxyMetadataStream(w, r, upstreamURL, accept)
|
||||
return
|
||||
}
|
||||
h.proxy.ProxyCached(w, r, upstreamURL, homebrewMetadataEcosystem, homebrewMetadataCacheKey(requestPath, r.URL.RawQuery), accept)
|
||||
})
|
||||
}
|
||||
|
||||
func homebrewMetadataCacheKey(requestPath, rawQuery string) string {
|
||||
sum := sha256.Sum256([]byte(requestPath + "\x00" + rawQuery))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
307
internal/handler/homebrew_test.go
Normal file
307
internal/handler/homebrew_test.go
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/git-pkgs/registries/fetch"
|
||||
)
|
||||
|
||||
func TestHomebrewHandler_PreservesSignedResponseAndClientValidators(t *testing.T) {
|
||||
body := " {\n \"payload\": \"signed bytes\",\n \"signatures\": []\n}\n"
|
||||
etag := `"homebrew-api-etag"`
|
||||
lastModified := time.Date(2026, time.August, 14, 9, 30, 0, 0, time.UTC)
|
||||
requests := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("upstream method = %s, want GET", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/api/internal/packages.arm64_tahoe.jws.json" {
|
||||
t.Errorf("upstream path = %q", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "" {
|
||||
t.Errorf("upstream Authorization = %q, want empty", got)
|
||||
}
|
||||
if got := r.Header.Get("Cookie"); got != "" {
|
||||
t.Errorf("upstream Cookie = %q, want empty", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("ETag", etag)
|
||||
w.Header().Set("Last-Modified", lastModified.Format(http.TimeFormat))
|
||||
_, _ = io.WriteString(w, body)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
proxy, _, _, _ := setupTestProxy(t)
|
||||
proxy.CacheMetadata = true
|
||||
proxy.MetadataTTL = time.Hour
|
||||
proxy.HTTPClient = upstream.Client()
|
||||
h := NewHomebrewHandler(proxy, upstream.URL+"/api").Routes()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/internal/packages.arm64_tahoe.jws.json", nil)
|
||||
req.Header.Set("Authorization", "Bearer client-secret")
|
||||
req.Header.Set("Cookie", "session=client-secret")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
if got := w.Body.String(); got != body {
|
||||
t.Errorf("body = %q, want byte-for-byte %q", got, body)
|
||||
}
|
||||
if got := w.Header().Get("Content-Type"); got != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", got)
|
||||
}
|
||||
wantContentLength := strconv.Itoa(len(body))
|
||||
if got := w.Header().Get("Content-Length"); got != wantContentLength {
|
||||
t.Errorf("Content-Length = %q, want %q", got, wantContentLength)
|
||||
}
|
||||
if got := w.Header().Get("ETag"); got != etag {
|
||||
t.Errorf("ETag = %q, want %q", got, etag)
|
||||
}
|
||||
if got := w.Header().Get("Last-Modified"); got != lastModified.Format(http.TimeFormat) {
|
||||
t.Errorf("Last-Modified = %q, want %q", got, lastModified.Format(http.TimeFormat))
|
||||
}
|
||||
|
||||
conditionalRequest := httptest.NewRequest(http.MethodGet, "/internal/packages.arm64_tahoe.jws.json", nil)
|
||||
conditionalRequest.Header.Set("If-None-Match", etag)
|
||||
conditional := httptest.NewRecorder()
|
||||
h.ServeHTTP(conditional, conditionalRequest)
|
||||
if conditional.Code != http.StatusNotModified {
|
||||
t.Fatalf("conditional status = %d, want %d", conditional.Code, http.StatusNotModified)
|
||||
}
|
||||
if got := conditional.Header().Get("ETag"); got != etag {
|
||||
t.Errorf("conditional ETag = %q, want %q", got, etag)
|
||||
}
|
||||
if conditional.Body.Len() != 0 {
|
||||
t.Errorf("conditional body length = %d, want 0", conditional.Body.Len())
|
||||
}
|
||||
|
||||
modifiedSinceRequest := httptest.NewRequest(http.MethodGet, "/internal/packages.arm64_tahoe.jws.json", nil)
|
||||
modifiedSinceRequest.Header.Set("If-Modified-Since", lastModified.Format(http.TimeFormat))
|
||||
modifiedSince := httptest.NewRecorder()
|
||||
h.ServeHTTP(modifiedSince, modifiedSinceRequest)
|
||||
if modifiedSince.Code != http.StatusNotModified {
|
||||
t.Fatalf("If-Modified-Since status = %d, want %d", modifiedSince.Code, http.StatusNotModified)
|
||||
}
|
||||
if got := modifiedSince.Header().Get("Last-Modified"); got != lastModified.Format(http.TimeFormat) {
|
||||
t.Errorf("conditional Last-Modified = %q, want %q", got, lastModified.Format(http.TimeFormat))
|
||||
}
|
||||
if requests != 1 {
|
||||
t.Errorf("upstream requests = %d, want 1", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomebrewHandler_HeadPreservesUpstreamMethodWithMetadataCacheEnabled(t *testing.T) {
|
||||
body := `{"payload":"signed bytes","signatures":[]}`
|
||||
upstreamMethod := ""
|
||||
upstreamAuthorization := ""
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamMethod = r.Method
|
||||
upstreamAuthorization = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
||||
w.Header().Set("ETag", `"head-etag"`)
|
||||
if r.Method != http.MethodHead {
|
||||
_, _ = io.WriteString(w, body)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
proxy, _, _, _ := setupTestProxy(t)
|
||||
proxy.CacheMetadata = true
|
||||
proxy.MetadataTTL = time.Hour
|
||||
proxy.HTTPClient = upstream.Client()
|
||||
h := NewHomebrewHandler(proxy, upstream.URL+"/api").Routes()
|
||||
|
||||
headRequest := httptest.NewRequest(http.MethodHead, "/formula.jws.json", nil)
|
||||
headRequest.Header.Set("Authorization", "Bearer client-secret")
|
||||
head := httptest.NewRecorder()
|
||||
h.ServeHTTP(head, headRequest)
|
||||
|
||||
if head.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", head.Code, http.StatusOK)
|
||||
}
|
||||
if upstreamMethod != http.MethodHead {
|
||||
t.Errorf("upstream method = %q, want HEAD", upstreamMethod)
|
||||
}
|
||||
if upstreamAuthorization != "" {
|
||||
t.Errorf("upstream Authorization = %q, want empty", upstreamAuthorization)
|
||||
}
|
||||
if head.Body.Len() != 0 {
|
||||
t.Errorf("body length = %d, want 0", head.Body.Len())
|
||||
}
|
||||
if got := head.Header().Get("Content-Length"); got != strconv.Itoa(len(body)) {
|
||||
t.Errorf("Content-Length = %q, want %d", got, len(body))
|
||||
}
|
||||
if got := head.Header().Get("ETag"); got != `"head-etag"` {
|
||||
t.Errorf("ETag = %q, want %q", got, `"head-etag"`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomebrewHandler_ServesStaleCachedResponseWhenUpstreamFails(t *testing.T) {
|
||||
body := `{"payload":"signed bytes","signatures":[]}`
|
||||
available := true
|
||||
requests := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
requests++
|
||||
if !available {
|
||||
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("ETag", `"stale-etag"`)
|
||||
_, _ = io.WriteString(w, body)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
proxy, _, _, _ := setupTestProxy(t)
|
||||
proxy.CacheMetadata = true
|
||||
proxy.MetadataTTL = 5 * time.Millisecond
|
||||
proxy.HTTPClient = upstream.Client()
|
||||
h := NewHomebrewHandler(proxy, upstream.URL+"/api").Routes()
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
h.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/formula.jws.json", nil))
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("warm status = %d, want %d", first.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
available = false
|
||||
stale := httptest.NewRecorder()
|
||||
h.ServeHTTP(stale, httptest.NewRequest(http.MethodGet, "/formula.jws.json", nil))
|
||||
if stale.Code != http.StatusOK {
|
||||
t.Fatalf("stale status = %d, want %d; body: %s", stale.Code, http.StatusOK, stale.Body.String())
|
||||
}
|
||||
if got := stale.Body.String(); got != body {
|
||||
t.Errorf("stale body = %q, want %q", got, body)
|
||||
}
|
||||
if got := stale.Header().Get("Warning"); got != containerStaleWarning {
|
||||
t.Errorf("Warning = %q, want %q", got, containerStaleWarning)
|
||||
}
|
||||
if requests != 2 {
|
||||
t.Errorf("upstream requests = %d, want 2", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomebrewHandler_ProxiesSupportedAPIPaths(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, r.URL.RequestURI())
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
proxy, _, _, _ := setupTestProxy(t)
|
||||
proxy.HTTPClient = upstream.Client()
|
||||
h := NewHomebrewHandler(proxy, upstream.URL+"/api").Routes()
|
||||
|
||||
paths := []string{
|
||||
"/formula.jws.json",
|
||||
"/cask.jws.json",
|
||||
"/formula/jq.json",
|
||||
"/cask/firefox.json",
|
||||
"/internal/packages.arm64_tahoe.jws.json?download=1",
|
||||
}
|
||||
for _, requestPath := range paths {
|
||||
t.Run(requestPath, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, requestPath, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
if got, want := w.Body.String(), "/api"+requestPath; got != want {
|
||||
t.Errorf("upstream request = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomebrewHandler_RejectsUnsupportedRequests(t *testing.T) {
|
||||
proxy, _, _, _ := setupTestProxy(t)
|
||||
h := NewHomebrewHandler(proxy, "https://example.test/api").Routes()
|
||||
|
||||
method := httptest.NewRecorder()
|
||||
h.ServeHTTP(method, httptest.NewRequest(http.MethodPost, "/formula.jws.json", nil))
|
||||
if method.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("POST status = %d, want %d", method.Code, http.StatusMethodNotAllowed)
|
||||
}
|
||||
if got := method.Header().Get("Allow"); got != "GET, HEAD" {
|
||||
t.Errorf("Allow = %q, want GET, HEAD", got)
|
||||
}
|
||||
|
||||
root := httptest.NewRecorder()
|
||||
h.ServeHTTP(root, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if root.Code != http.StatusNotFound {
|
||||
t.Errorf("root status = %d, want %d", root.Code, http.StatusNotFound)
|
||||
}
|
||||
|
||||
traversal := httptest.NewRecorder()
|
||||
h.ServeHTTP(traversal, httptest.NewRequest(http.MethodGet, "/%2e%2e/secret", nil))
|
||||
if traversal.Code != http.StatusNotFound {
|
||||
t.Errorf("traversal status = %d, want %d", traversal.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterHomebrewArtifacts(t *testing.T) {
|
||||
h := &ContainerHandler{registryURL: dockerHubRegistry}
|
||||
artifactUpstream := "https://homebrew-proxy.example.com"
|
||||
RegisterHomebrewArtifacts(h, artifactUpstream+"/")
|
||||
|
||||
if got := h.registryURLFor("homebrew/core/jq"); got != artifactUpstream {
|
||||
t.Errorf("homebrew/core registry = %q, want %q", got, artifactUpstream)
|
||||
}
|
||||
if got := h.registryURLFor("homebrew/cask/firefox"); got != "" {
|
||||
t.Errorf("other Homebrew registry = %q, want blocked", got)
|
||||
}
|
||||
if got := h.registryURLFor("library/nginx"); got != dockerHubRegistry {
|
||||
t.Errorf("unrelated registry = %q, want %q", got, dockerHubRegistry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterHomebrewArtifactsRejectsOtherHomebrewRoutes(t *testing.T) {
|
||||
upstreamRequests := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
upstreamRequests++
|
||||
_, _ = io.WriteString(w, "unexpected upstream response")
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
proxy, _, _, fetcher := setupTestProxy(t)
|
||||
proxy.HTTPClient = upstream.Client()
|
||||
fetcher.artifact = &fetch.Artifact{
|
||||
Body: io.NopCloser(strings.NewReader("unexpected upstream blob")),
|
||||
ContentType: "application/octet-stream",
|
||||
}
|
||||
h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL}
|
||||
RegisterHomebrewArtifacts(h, "https://ghcr.io")
|
||||
|
||||
const digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
paths := []string{
|
||||
"/homebrew/cask/firefox/blobs/" + digest,
|
||||
"/homebrew/cask/firefox/manifests/latest",
|
||||
"/homebrew/cask/firefox/tags/list",
|
||||
}
|
||||
for _, path := range paths {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want %d; body: %s", w.Code, http.StatusNotFound, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if fetcher.fetchCalled {
|
||||
t.Error("blocked Homebrew blob reached the artifact fetcher")
|
||||
}
|
||||
if upstreamRequests != 0 {
|
||||
t.Errorf("blocked Homebrew routes made %d upstream requests, want 0", upstreamRequests)
|
||||
}
|
||||
}
|
||||
|
|
@ -259,7 +259,9 @@ func (s *Server) Start() error {
|
|||
condaHandler := handler.NewCondaHandler(proxy, s.cfg.BaseURL)
|
||||
cranHandler := handler.NewCRANHandler(proxy, s.cfg.BaseURL)
|
||||
juliaHandler := handler.NewJuliaHandler(proxy, s.cfg.BaseURL)
|
||||
homebrewHandler := handler.NewHomebrewHandler(proxy, s.cfg.Upstream.HomebrewAPI)
|
||||
containerHandler := handler.NewContainerHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.OCI)
|
||||
handler.RegisterHomebrewArtifacts(containerHandler, s.cfg.Upstream.HomebrewArtifact)
|
||||
helmHandler := handler.NewHelmHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.Helm)
|
||||
debianHandler := handler.NewDebianHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.Debian)
|
||||
rpmHandler := handler.NewRPMHandler(proxy, s.cfg.BaseURL)
|
||||
|
|
@ -279,6 +281,7 @@ func (s *Server) Start() error {
|
|||
r.Mount("/conda", http.StripPrefix("/conda", condaHandler.Routes()))
|
||||
r.Mount("/cran", http.StripPrefix("/cran", cranHandler.Routes()))
|
||||
r.Mount("/julia", http.StripPrefix("/julia", juliaHandler.Routes()))
|
||||
r.Mount("/homebrew", http.StripPrefix("/homebrew", homebrewHandler.Routes()))
|
||||
r.Mount("/v2", http.StripPrefix("/v2", containerHandler.Routes()))
|
||||
r.Mount("/helm", http.StripPrefix("/helm", helmHandler.Routes()))
|
||||
r.Mount("/debian", http.StripPrefix("/debian", debianHandler.Routes()))
|
||||
|
|
|
|||
Loading…
Reference in a new issue