Skip to content
Open
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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | | ✗ |
Expand Down Expand Up @@ -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`:
Expand Down Expand Up @@ -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 |

Expand Down
6 changes: 6 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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")
Expand Down
23 changes: 23 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
}
Expand Down
70 changes: 58 additions & 12 deletions internal/handler/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -130,30 +164,34 @@ 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 {
if errors.Is(err, ErrUpstreamNotFound) {
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)
}
Expand Down Expand Up @@ -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" {
Expand All @@ -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.
Expand Down
Loading