Add generic HTTP download proxy for GitHub release assets (mise/aqua) (#302)
* Add generic HTTP download proxy for GitHub release assets
Adds a /generic/{name}/ route backed by a new upstream.generic named-upstream
map, so tools that download from fixed URL shapes (mise's aqua backend
fetching GitHub release assets, and its tag lookups on api.github.com) can be
pointed at the proxy with client-side URL rewriting. 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 and go through the artifact cache: fetched once, hashed,
served without revalidation, and still served when the upstream is down.
Every other path goes through the metadata cache with the client's Accept
header and query string replayed, so API responses are fresh within
metadata_ttl, revalidated after that, and served stale when the upstream
fails or rate-limits the request.
Tests cover path classification, unknown upstreams and traversal, cache
hits with the upstream down, HEAD, 404 pass-through, Accept and query
forwarding, stale-on-429, cache isolation between upstreams, and that an
upstream token scoped to the release host is not sent to the object store
it redirects to.
Closes #183.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDjDeq27CKzEP2o3GWBY7F
* Use fixed Accept header for generic metadata
---------
Co-authored-by: Giles Westwood <giles@gileswestwood.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:44:54 +01:00
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"net/http"
|
|
|
|
|
"regexp"
|
|
|
|
|
"strings"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
genericEcosystem = "generic"
|
|
|
|
|
// genericAcceptAny is always sent for metadata so every client shares the
|
|
|
|
|
// same cached representation, regardless of its Accept header.
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 10:58:22 +01:00
|
|
|
if result.Artifact.MediaType == "" {
|
|
|
|
|
result.Artifact.MediaType = "application/octet-stream"
|
Add generic HTTP download proxy for GitHub release assets (mise/aqua) (#302)
* Add generic HTTP download proxy for GitHub release assets
Adds a /generic/{name}/ route backed by a new upstream.generic named-upstream
map, so tools that download from fixed URL shapes (mise's aqua backend
fetching GitHub release assets, and its tag lookups on api.github.com) can be
pointed at the proxy with client-side URL rewriting. 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 and go through the artifact cache: fetched once, hashed,
served without revalidation, and still served when the upstream is down.
Every other path goes through the metadata cache with the client's Accept
header and query string replayed, so API responses are fresh within
metadata_ttl, revalidated after that, and served stale when the upstream
fails or rate-limits the request.
Tests cover path classification, unknown upstreams and traversal, cache
hits with the upstream down, HEAD, 404 pass-through, Accept and query
forwarding, stale-on-429, cache isolation between upstreams, and that an
upstream token scoped to the release host is not sent to the object store
it redirects to.
Closes #183.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDjDeq27CKzEP2o3GWBY7F
* Use fixed Accept header for generic metadata
---------
Co-authored-by: Giles Westwood <giles@gileswestwood.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:44:54 +01:00
|
|
|
}
|
|
|
|
|
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. A fixed Accept header
|
|
|
|
|
// keeps all clients on one cached representation.
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h.proxy.ProxyCached(w, r, target, genericEcosystem,
|
|
|
|
|
h.metadataCacheKey(repository, upstreamURL, path, r.URL.RawQuery), genericAcceptAny)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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[:])
|
|
|
|
|
}
|