mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-09-16 07:42:05 -04:00
* 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>
311 lines
11 KiB
Go
311 lines
11 KiB
Go
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
upstreamhttp "github.com/git-pkgs/proxy/internal/httpclient"
|
|
"github.com/git-pkgs/registries/fetch"
|
|
)
|
|
|
|
const testReleaseAssetPath = "/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64"
|
|
|
|
func TestParseGitHubReleaseAsset(t *testing.T) {
|
|
tests := []struct {
|
|
path string
|
|
want githubReleaseAsset
|
|
ok bool
|
|
}{
|
|
{
|
|
"jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64",
|
|
githubReleaseAsset{owner: "jqlang", repo: "jq", tag: "jq-1.7.1", filename: "jq-linux-amd64"},
|
|
true,
|
|
},
|
|
{
|
|
"cli/cli/releases/download/v2.63.2/gh_2.63.2_linux_amd64.tar.gz",
|
|
githubReleaseAsset{owner: "cli", repo: "cli", tag: "v2.63.2", filename: "gh_2.63.2_linux_amd64.tar.gz"},
|
|
true,
|
|
},
|
|
// Mutable: resolves to whatever is latest today.
|
|
{"jqlang/jq/releases/latest/download/jq-linux-amd64", githubReleaseAsset{}, false},
|
|
// API lookups and tag listings are not assets.
|
|
{"repos/jqlang/jq/releases/tags/jq-1.7.1", githubReleaseAsset{}, false},
|
|
{"jqlang/jq/releases/tag/jq-1.7.1", githubReleaseAsset{}, false},
|
|
// Source archives are a different shape.
|
|
{"jqlang/jq/archive/refs/tags/jq-1.7.1.tar.gz", githubReleaseAsset{}, false},
|
|
// Extra or missing segments.
|
|
{"jqlang/jq/releases/download/jq-1.7.1", githubReleaseAsset{}, false},
|
|
{"jqlang/jq/releases/download/jq-1.7.1/dir/asset", githubReleaseAsset{}, false},
|
|
{"", githubReleaseAsset{}, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got, ok := parseGitHubReleaseAsset(tt.path)
|
|
if ok != tt.ok || got != tt.want {
|
|
t.Errorf("parseGitHubReleaseAsset(%q) = (%+v, %v), want (%+v, %v)", tt.path, got, ok, tt.want, tt.ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGenericHandler_RejectsUnknownUpstreamAndBadPaths(t *testing.T) {
|
|
h := NewGenericHandler(testProxy(), map[string]string{"github": "https://github.com"})
|
|
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
target string
|
|
want int
|
|
}{
|
|
{"unknown upstream", http.MethodGet, "/gitlab/owner/repo/releases/download/v1/asset", http.StatusNotFound},
|
|
{"missing path", http.MethodGet, "/github", http.StatusNotFound},
|
|
{"missing path with slash", http.MethodGet, "/github/", http.StatusNotFound},
|
|
{"traversal", http.MethodGet, "/github/../etc/passwd", http.StatusBadRequest},
|
|
{"encoded traversal", http.MethodGet, "/github/%2e%2e/etc/passwd", http.StatusBadRequest},
|
|
{"post", http.MethodPost, "/github/owner/repo/releases/download/v1/asset", http.StatusMethodNotAllowed},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
h.Routes().ServeHTTP(w, httptest.NewRequest(tt.method, tt.target, nil))
|
|
if w.Code != tt.want {
|
|
t.Errorf("status = %d, want %d", w.Code, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGenericHandler_ReleaseAssetIsCachedAndServedWhenUpstreamDown(t *testing.T) {
|
|
asset := []byte("jq binary bytes")
|
|
var available atomic.Bool
|
|
available.Store(true)
|
|
var upstreamRequests atomic.Int32
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !available.Load() {
|
|
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
if r.URL.Path != testReleaseAssetPath {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
upstreamRequests.Add(1)
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
_, _ = w.Write(asset)
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(upstream.Client()), fetch.WithMaxRetries(0))
|
|
proxy.Fetcher = fetcher
|
|
t.Cleanup(func() { _ = fetcher.Close() })
|
|
|
|
h := NewGenericHandler(proxy, map[string]string{"github": upstream.URL})
|
|
|
|
w := serveGenericRequest(h, "/github"+testReleaseAssetPath)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
|
|
}
|
|
if got := w.Body.String(); got != string(asset) {
|
|
t.Errorf("body = %q, want %q", got, asset)
|
|
}
|
|
if got := upstreamRequests.Load(); got != 1 {
|
|
t.Fatalf("upstream requests = %d, want 1", got)
|
|
}
|
|
|
|
// Second request must be served from cache, even with the upstream down.
|
|
available.Store(false)
|
|
w = serveGenericRequest(h, "/github"+testReleaseAssetPath)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("cached: status = %d, want 200: %s", w.Code, w.Body.String())
|
|
}
|
|
if got := w.Body.String(); got != string(asset) {
|
|
t.Errorf("cached: body = %q, want %q", got, asset)
|
|
}
|
|
if got := upstreamRequests.Load(); got != 1 {
|
|
t.Errorf("upstream requests after cache hit = %d, want 1", got)
|
|
}
|
|
|
|
// HEAD is answered from the same cache entry without a body.
|
|
w = httptest.NewRecorder()
|
|
h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodHead, "/github"+testReleaseAssetPath, nil))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("HEAD: status = %d, want 200", w.Code)
|
|
}
|
|
if w.Body.Len() != 0 {
|
|
t.Errorf("HEAD: body length = %d, want 0", w.Body.Len())
|
|
}
|
|
}
|
|
|
|
func TestGenericHandler_ReleaseAssetNotFoundIsNotCached(t *testing.T) {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(upstream.Client()), fetch.WithMaxRetries(0))
|
|
proxy.Fetcher = fetcher
|
|
t.Cleanup(func() { _ = fetcher.Close() })
|
|
|
|
h := NewGenericHandler(proxy, map[string]string{"github": upstream.URL})
|
|
w := serveGenericRequest(h, "/github"+testReleaseAssetPath)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGenericHandler_MetadataForwardsQueryAndServesStaleOnThrottle(t *testing.T) {
|
|
const apiPath = "/repos/jqlang/jq/releases/tags/jq-1.7.1"
|
|
body := `{"tag_name":"jq-1.7.1"}`
|
|
var throttled atomic.Bool
|
|
var gotQuery string
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != apiPath {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
gotQuery = r.URL.RawQuery
|
|
if throttled.Load() {
|
|
w.Header().Set("Retry-After", "60")
|
|
http.Error(w, `{"message":"API rate limit exceeded"}`, http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/vnd.github+json")
|
|
w.Header().Set("ETag", `"v1"`)
|
|
_, _ = w.Write([]byte(body))
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
proxy.HTTPClient = upstream.Client()
|
|
proxy.CacheMetadata = true
|
|
// A tiny TTL so the second request is past freshness and has to consult
|
|
// the upstream, and the served copy is marked stale.
|
|
proxy.MetadataTTL = time.Millisecond
|
|
|
|
h := NewGenericHandler(proxy, map[string]string{"github-api": upstream.URL})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/github-api"+apiPath+"?per_page=1", nil)
|
|
w := httptest.NewRecorder()
|
|
h.Routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
|
|
}
|
|
if got := w.Body.String(); got != body {
|
|
t.Errorf("body = %q, want %q", got, body)
|
|
}
|
|
if gotQuery != "per_page=1" {
|
|
t.Errorf("upstream query = %q, want %q", gotQuery, "per_page=1")
|
|
}
|
|
if ct := w.Header().Get("Content-Type"); ct != "application/vnd.github+json" {
|
|
t.Errorf("Content-Type = %q, want upstream's", ct)
|
|
}
|
|
|
|
// The upstream now throttles us: the cached body must be served stale
|
|
// rather than the 429 being passed through.
|
|
throttled.Store(true)
|
|
time.Sleep(5 * time.Millisecond)
|
|
w = httptest.NewRecorder()
|
|
h.Routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("throttled: status = %d, want 200 stale: %s", w.Code, w.Body.String())
|
|
}
|
|
if got := w.Body.String(); got != body {
|
|
t.Errorf("throttled: body = %q, want cached %q", got, body)
|
|
}
|
|
if warning := w.Header().Get("Warning"); !strings.Contains(warning, "110") {
|
|
t.Errorf("throttled: Warning = %q, want a 110 stale warning", warning)
|
|
}
|
|
}
|
|
|
|
func TestGenericHandler_DistinctUpstreamsDoNotShareCache(t *testing.T) {
|
|
first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte("from first"))
|
|
}))
|
|
defer first.Close()
|
|
second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte("from second"))
|
|
}))
|
|
defer second.Close()
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(first.Client()), fetch.WithMaxRetries(0))
|
|
proxy.Fetcher = fetcher
|
|
t.Cleanup(func() { _ = fetcher.Close() })
|
|
|
|
h := NewGenericHandler(proxy, map[string]string{"one": first.URL, "two": second.URL})
|
|
|
|
w := serveGenericRequest(h, "/one"+testReleaseAssetPath)
|
|
if got := w.Body.String(); got != "from first" {
|
|
t.Fatalf("one: body = %q, want %q", got, "from first")
|
|
}
|
|
w = serveGenericRequest(h, "/two"+testReleaseAssetPath)
|
|
if got := w.Body.String(); got != "from second" {
|
|
t.Fatalf("two: body = %q, want %q (must not reuse the first upstream's cache entry)", got, "from second")
|
|
}
|
|
}
|
|
|
|
func TestGenericHandler_UpstreamAuthIsScopedToTheConfiguredHost(t *testing.T) {
|
|
asset := []byte("private asset")
|
|
var storageAuth atomic.Value
|
|
storageAuth.Store("unset")
|
|
|
|
// The object store the release host redirects to must never see the
|
|
// token configured for the release host.
|
|
objectStore := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
storageAuth.Store(r.Header.Get("Authorization"))
|
|
_, _ = w.Write(asset)
|
|
}))
|
|
defer objectStore.Close()
|
|
|
|
var releaseAuth string
|
|
releaseHost := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
releaseAuth = r.Header.Get("Authorization")
|
|
if releaseAuth != "Bearer github-token" {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
http.Redirect(w, r, objectStore.URL+"/signed"+r.URL.Path, http.StatusFound)
|
|
}))
|
|
defer releaseHost.Close()
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
authClient := &http.Client{Transport: upstreamhttp.NewTransport(http.DefaultTransport,
|
|
upstreamhttp.AuthFunc(func(url string) (string, string) {
|
|
if strings.HasPrefix(url, releaseHost.URL) {
|
|
return "Authorization", "Bearer github-token"
|
|
}
|
|
return "", ""
|
|
}))}
|
|
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(authClient), fetch.WithMaxRetries(0))
|
|
proxy.Fetcher = fetcher
|
|
t.Cleanup(func() { _ = fetcher.Close() })
|
|
|
|
h := NewGenericHandler(proxy, map[string]string{"github": releaseHost.URL})
|
|
w := serveGenericRequest(h, "/github"+testReleaseAssetPath)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
|
|
}
|
|
if got := w.Body.String(); got != string(asset) {
|
|
t.Errorf("body = %q, want %q", got, asset)
|
|
}
|
|
if releaseAuth != "Bearer github-token" {
|
|
t.Errorf("release host Authorization = %q, want the configured token", releaseAuth)
|
|
}
|
|
if got := storageAuth.Load(); got != "" {
|
|
t.Errorf("object store Authorization = %q, want none after the cross-host redirect", got)
|
|
}
|
|
}
|
|
|
|
func serveGenericRequest(h *GenericHandler, target string) *httptest.ResponseRecorder {
|
|
w := httptest.NewRecorder()
|
|
h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil))
|
|
return w
|
|
}
|