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
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Resolution order: package override, then ecosystem override, then global default
| Alpine | Alpine Linux | | ✓ |
| Arch | Arch Linux | | ✗ |
| Chef | Chef | | ✗ |
| Generic | Any | | |
| Generic | Any | | |
| Helm | Kubernetes | | ✗ |
| Vagrant | Vagrant | | ✗ |

Expand Down Expand Up @@ -494,6 +494,32 @@ http://localhost:8080/apk/private
apk appends the architecture and index filename to each repository line
itself.

### GitHub Releases / mise (aqua backend)

Configure named generic upstreams:

```yaml
upstream:
generic:
github: "https://github.com"
github-api: "https://api.github.com"
```

Then rewrite GitHub URLs in mise's settings (`~/.config/mise/config.toml`, mise ≥ 2025.9.3):

```toml
[settings.url_replacements]
"regex:^https://github\\.com/([^/]+)/([^/]+)/releases/download/(.+)" = "http://localhost:8080/generic/github/$1/$2/releases/download/$3"
"regex:^https://api\\.github\\.com/(.*)" = "http://localhost:8080/generic/github-api/$1"
```

Release assets are cached permanently after the first download and keep
installing while GitHub is down. Tag lookups through `api.github.com` are
cached for `metadata_ttl` and served stale during an outage or rate limit.
Commit a `mise.lock` and install with `mise install --locked` so pinned
installs need no API call at all. Add a bearer token for `https://api.github.com`
under `upstream.auth` if the fleet exceeds GitHub's anonymous rate limit.

## Configuration

The proxy can be configured via:
Expand Down Expand Up @@ -792,6 +818,7 @@ Recently cached:
| `GET /helm/{repository}/*` | HTTP Helm chart repository protocol |
| `GET /v2/*` | OCI/Docker registry protocol |
| `GET /apk/{repository}/*` | Alpine APK repository protocol |
| `GET /generic/{name}/*` | Generic HTTP download proxy (GitHub release assets, mise/aqua) |
| `GET /debian/*` | Debian/APT repository protocol |
| `GET /rpm/*` | RPM/Yum repository protocol |

Expand Down
8 changes: 8 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,14 @@ upstream:
# alpine: "https://dl-cdn.alpinelinux.org/alpine"
# private: "https://apk.example.com"

# Named generic HTTP upstreams (used by /generic/{name}/). The remaining
# request path and query are appended to the upstream URL. GitHub release
# assets ({owner}/{repo}/releases/download/{tag}/{asset}) are cached
# immutably; other paths use the metadata cache with stale-on-error.
# generic:
# github: "https://github.com"
# github-api: "https://api.github.com"

# Authentication for upstream registries
# Keys are absolute URL scopes. Scheme, host, effective port, and path
# segment boundaries must match; the longest matching scope wins.
Expand Down
31 changes: 31 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,37 @@ repository's `index.yaml` so chart archives are downloaded through the proxy.
Chart archives are retained only when their SHA-256 digest matches the digest
listed in the index. Relative and absolute chart URLs are both supported.

Generic HTTP upstreams proxy plain downloads from fixed base URLs:

```yaml
upstream:
# Named HTTP upstreams, served at /generic/{name}/. The rest of the
# request path and the query string are appended to the upstream URL.
generic:
github: "https://github.com"
github-api: "https://api.github.com"
auth:
# Optional: raise the GitHub API rate limit. Scoped to this host only,
# so the token is never sent to the object store GitHub redirects to.
"https://api.github.com":
type: bearer
token: "${GITHUB_TOKEN}"
```

Only configured upstreams are reachable, so this is not an open HTTP proxy.
Paths shaped like `{owner}/{repo}/releases/download/{tag}/{asset}` are
version-pinned GitHub release assets: they are stored in the artifact cache
and served from it without revalidation, including while the upstream is
down. Every other path is served through the metadata cache (`cache_metadata`
must be enabled for offline fallback): fresh within `metadata_ttl`, then
revalidated with the upstream's `ETag`/`Last-Modified`, and served stale with
a `Warning: 110` header when the upstream fails, refuses or rate-limits the
request. Metadata responses are buffered up to `metadata_max_size`, so keep
large mutable downloads (`releases/latest/download/...`) off this route.

This is the cache behind [mise](https://mise.jdx.dev)'s aqua backend; see the
mise section in the README for the client-side `url_replacements`.

`upstream.oci_default` sets the registry used by unprefixed `/v2` requests,
while `upstream.oci` selects named registries through the `upstream/{name}/`
repository prefix. For example, `oci://proxy.example.com/upstream/ghcr/owner/chart`
Expand Down
11 changes: 11 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,14 @@ type UpstreamConfig struct {
// oci://proxy.example.com/upstream/ghcr/owner/chart.
OCI map[string]string `json:"oci" yaml:"oci"`

// Generic maps names to plain HTTP upstream base URLs, served at
// /generic/{name}/. The remaining request path and query string are
// appended to the upstream URL. GitHub release asset paths
// ({owner}/{repo}/releases/download/{tag}/{asset}) are cached in the
// artifact cache; everything else goes through the metadata cache.
// Example: {"github": "https://github.com", "github-api": "https://api.github.com"}.
Generic map[string]string `json:"generic" yaml:"generic"`

// Auth configures authentication for upstream registries.
// Keys are absolute URL scopes matched by scheme, host, effective port,
// and path-segment prefix.
Expand Down Expand Up @@ -485,6 +493,9 @@ func (u *UpstreamConfig) Validate() error {
if err := validateNamedUpstreams("upstream.oci", u.OCI); err != nil {
return err
}
if err := validateNamedUpstreams("upstream.generic", u.Generic); err != nil {
return err
}
return nil
}

Expand Down
20 changes: 19 additions & 1 deletion internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1062,12 +1062,30 @@ func TestValidateNamedUpstreams(t *testing.T) {
wantErr bool
}{
{
name: "valid Helm, OCI, and APK upstreams",
name: "valid Helm, OCI, APK, and generic upstreams",
modify: func(cfg *Config) {
cfg.Upstream.Helm = map[string]string{"bitnami": "https://charts.bitnami.com/bitnami"}
cfg.Upstream.OCI = map[string]string{"ghcr": "https://ghcr.io"}
cfg.Upstream.APK = map[string]string{"alpine": "https://dl-cdn.alpinelinux.org/alpine"}
cfg.Upstream.Generic = map[string]string{
"github": "https://github.com",
"github-api": "https://api.github.com",
}
},
},
{
name: "generic upstream name contains path separator",
modify: func(cfg *Config) {
cfg.Upstream.Generic = map[string]string{"github/releases": "https://github.com"}
},
wantErr: true,
},
{
name: "generic upstream URL is not absolute",
modify: func(cfg *Config) {
cfg.Upstream.Generic = map[string]string{"github": "github.com"}
},
wantErr: true,
},
{
name: "Helm upstream name contains path separator",
Expand Down
166 changes: 166 additions & 0 deletions internal/handler/generic.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package handler

import (
"crypto/sha256"
"encoding/hex"
"net/http"
"regexp"
"strings"
)

const (
genericEcosystem = "generic"
// genericAcceptAny is sent upstream when the client did not send an
// Accept header, so generic upstreams are not asked for JSON by default.
genericAcceptAny = "*/*"
// githubReleaseAssetMatchCount is the full match plus owner, repository,
// tag and asset filename.
githubReleaseAssetMatchCount = 5
)

// githubReleaseAssetPattern matches the path of a GitHub release asset
// download, {owner}/{repo}/releases/download/{tag}/{asset}. A tag pins the
// asset to one release, so these downloads are cached in the artifact cache
// and served without revalidation once fetched.
var githubReleaseAssetPattern = regexp.MustCompile(`^([^/]+)/([^/]+)/releases/download/([^/]+)/([^/]+)$`)

// GenericHandler proxies plain HTTP downloads from configured upstream base
// URLs. Each configured upstream is mounted at /generic/{name}/ and the
// remaining request path (and query string) is appended to the upstream URL.
//
// Only configured upstreams are reachable, so the proxy is not an open HTTP
// proxy. The handler is the caching layer behind tools that download from
// fixed URL shapes, such as mise's aqua backend fetching GitHub release
// assets, and is pointed at by URL-rewriting settings on the client.
//
// Release-asset paths ({owner}/{repo}/releases/download/{tag}/{asset}) are
// version-pinned and cached in the shared artifact cache, so they keep being
// served when the upstream is unreachable. Every other path is served through
// the metadata cache: fresh within the metadata TTL, revalidated with the
// upstream's validators after that, and served stale when the upstream fails
// or refuses the request. That covers API responses such as
// api.github.com/repos/{owner}/{repo}/releases/tags/{tag}.
type GenericHandler struct {
proxy *Proxy
repositories map[string]string
}

// NewGenericHandler creates a generic HTTP download proxy handler.
func NewGenericHandler(proxy *Proxy, repositories map[string]string) *GenericHandler {
h := &GenericHandler{
proxy: proxy,
repositories: make(map[string]string, len(repositories)),
}
for name, upstreamURL := range repositories {
h.repositories[name] = strings.TrimSuffix(upstreamURL, "/")
}
return h
}

// Routes returns the HTTP handler for generic download requests.
// Mount this at /generic on your router.
func (h *GenericHandler) Routes() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}

path := strings.TrimPrefix(r.URL.Path, "/")

if containsPathTraversal(path) {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}

repository, rest, ok := strings.Cut(path, "/")
upstreamURL, found := h.repositories[repository]
if !ok || rest == "" || !found {
http.NotFound(w, r)
return
}

if asset, ok := parseGitHubReleaseAsset(rest); ok {
h.handleReleaseAsset(w, r, repository, upstreamURL, rest, asset)
return
}

h.handleMetadata(w, r, repository, upstreamURL, rest)
})
}

// githubReleaseAsset is the identity of a version-pinned release download.
type githubReleaseAsset struct {
owner string
repo string
tag string
filename string
}

// parseGitHubReleaseAsset extracts the release identity from a path shaped
// like {owner}/{repo}/releases/download/{tag}/{asset}.
func parseGitHubReleaseAsset(path string) (githubReleaseAsset, bool) {
matches := githubReleaseAssetPattern.FindStringSubmatch(path)
if len(matches) != githubReleaseAssetMatchCount {
return githubReleaseAsset{}, false
}
return githubReleaseAsset{
owner: matches[1],
repo: matches[2],
tag: matches[3],
filename: matches[4],
}, true
}

// handleReleaseAsset fetches and caches a version-pinned release asset in the
// artifact cache. The configured upstream name is part of the cache identity
// so two upstreams serving the same path never share bytes.
func (h *GenericHandler) handleReleaseAsset(w http.ResponseWriter, r *http.Request, repository, upstreamURL, path string, asset githubReleaseAsset) {
name := asset.owner + "/" + asset.repo
downloadURL := upstreamURL + "/" + path
cacheFilename := repository + "/" + asset.filename

h.proxy.Logger.Info("generic release asset download",
"repository", repository, "name", name, "version", asset.tag, "filename", asset.filename)

result, err := h.proxy.GetOrFetchArtifactFromURL(
r.Context(), genericEcosystem, name, asset.tag, cacheFilename, downloadURL)
if err != nil {
h.proxy.serveArtifactError(w, err, "failed to fetch release asset")
return
}

if result.ContentType == "" {
result.ContentType = "application/octet-stream"
}
serveArtifact(w, r.Method, result)
}

// handleMetadata serves any other path through the metadata cache. The query
// string is forwarded and is part of the cache identity, and the client's
// Accept header is replayed so content-negotiated upstreams (the GitHub API)
// cache the representation the client asked for.
func (h *GenericHandler) handleMetadata(w http.ResponseWriter, r *http.Request, repository, upstreamURL, path string) {
target := upstreamURL + "/" + path
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}

accept := r.Header.Get("Accept")
if accept == "" {
accept = genericAcceptAny
}

h.proxy.ProxyCached(w, r, target, genericEcosystem,
h.metadataCacheKey(repository, upstreamURL, path, r.URL.RawQuery), accept)
}

// metadataCacheKey derives the metadata cache key from the upstream name, its
// URL, the request path and query. Hashing the identity keeps distinct
// upstreams from sharing entries and drops cached entries when an upstream is
// repointed, mirroring APKHandler.metadataCacheKey.
func (h *GenericHandler) metadataCacheKey(repository, upstreamURL, path, query string) string {
identity := repository + "\x00" + upstreamURL + "\x00" + path + "\x00" + query
digest := sha256.Sum256([]byte(identity))
return hex.EncodeToString(digest[:])
}
Comment on lines +149 to +166
Loading