mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-09-16 07:42:05 -04:00
* Add Alpine APK repository proxy support
- Serve named APK repositories at /apk/{repository}/ with the official
Alpine mirror as the default repository
- Cache v2 APKINDEX.tar.gz and v3 Packages.adb indexes and detached
signatures via the metadata cache, serving stored bytes unchanged so
apk signature verification keeps working
- Cache .apk packages in the shared artifact cache keyed by the full
repository path, since APK filenames do not include the architecture
- Add configurable upstream repositories via upstream.apk with
validation, plus dashboard registry instructions
- Add tests for index/signature byte fidelity, per-arch caching, cache
hits, offline reads, upstream authentication, and 404 handling
- Document apk usage in README, config example, and configuration docs
* Serve APK package HEAD requests without a body
Use the method-aware serveArtifact helper (as container.go does) so HEAD
responses carry Content-Length but omit the body; add a regression test.
* Drop doubled blank line in docs/configuration.md
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
362 lines
12 KiB
Go
362 lines
12 KiB
Go
package handler
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/git-pkgs/registries/fetch"
|
|
)
|
|
|
|
func TestAPKHandler_parseAPKPath(t *testing.T) {
|
|
h := &APKHandler{}
|
|
|
|
assertPathParser(t, "parseAPKPath", h.parseAPKPath, []pathParseCase{
|
|
{"v3.22/main/x86_64/busybox-1.37.0-r12.apk", "busybox", "1.37.0-r12", "x86_64"},
|
|
{"v3.22/main/aarch64/alpine-baselayout-data-3.7.0-r0.apk", "alpine-baselayout-data", "3.7.0-r0", "aarch64"},
|
|
{"edge/community/x86_64/openjdk21-jre-21.0.2_p13-r1.apk", "openjdk21-jre", "21.0.2_p13-r1", "x86_64"},
|
|
{"busybox-1.37.0-r12.apk", "busybox", "1.37.0-r12", ""},
|
|
{"v3.22/main/x86_64/invalid.apk", "", "", ""},
|
|
{"v3.22/main/x86_64/not-an-apk-file", "", "", ""},
|
|
})
|
|
}
|
|
|
|
func TestAPKHandler_Routes(t *testing.T) {
|
|
h := NewAPKHandler(nil, "http://localhost:8080", nil)
|
|
assertRoutesBasics(t, h.Routes(), "/alpine/v3.22/main/x86_64/APKINDEX.tar.gz", "/alpine/v3.22/../../../etc/passwd")
|
|
}
|
|
|
|
func TestAPKHandler_DefaultsToOfficialMirror(t *testing.T) {
|
|
h := NewAPKHandler(nil, "http://localhost:8080", nil)
|
|
if got := h.repositories[defaultAPKRepositoryName]; got != defaultAPKUpstream {
|
|
t.Errorf("default repository = %q, want %q", got, defaultAPKUpstream)
|
|
}
|
|
}
|
|
|
|
func TestAPKHandler_UnknownRepositoryReturns404(t *testing.T) {
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
h := NewAPKHandler(proxy, "http://localhost:8080", map[string]string{"alpine": "https://example.test"})
|
|
|
|
for _, target := range []string{
|
|
"/unknown/v3.22/main/x86_64/APKINDEX.tar.gz",
|
|
"/alpine",
|
|
"/",
|
|
} {
|
|
w := serveAPKRequest(h, target)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("%s: status = %d, want 404", target, w.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAPKHandler_MetadataCacheKeysDoNotCollideAcrossRepositories guards the
|
|
// hashed metadata cache key: with a separator-based key, repositories named
|
|
// "alpine" and "alpine_edge" would share cache entries for
|
|
// /alpine/edge/main/x86_64/APKINDEX.tar.gz and
|
|
// /alpine_edge/main/x86_64/APKINDEX.tar.gz, serving one repository's signed
|
|
// index to clients of the other.
|
|
func TestAPKHandler_MetadataCacheKeysDoNotCollideAcrossRepositories(t *testing.T) {
|
|
indexA := "signed index of repository A"
|
|
upstreamA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/edge/main/x86_64/APKINDEX.tar.gz" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
_, _ = fmt.Fprint(w, indexA)
|
|
}))
|
|
defer upstreamA.Close()
|
|
|
|
indexB := "signed index of repository B"
|
|
upstreamB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/main/x86_64/APKINDEX.tar.gz" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
_, _ = fmt.Fprint(w, indexB)
|
|
}))
|
|
defer upstreamB.Close()
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
proxy.CacheMetadata = true
|
|
proxy.MetadataTTL = time.Hour
|
|
proxy.HTTPClient = http.DefaultClient
|
|
|
|
h := NewAPKHandler(proxy, "http://proxy.example", map[string]string{
|
|
"alpine": upstreamA.URL,
|
|
"alpine_edge": upstreamB.URL,
|
|
})
|
|
|
|
first := serveAPKRequest(h, "/alpine/edge/main/x86_64/APKINDEX.tar.gz")
|
|
if first.Code != http.StatusOK || first.Body.String() != indexA {
|
|
t.Fatalf("repository A: status = %d, body = %q, want 200 %q", first.Code, first.Body.String(), indexA)
|
|
}
|
|
|
|
// Served within the metadata TTL: a colliding key would return indexA here.
|
|
second := serveAPKRequest(h, "/alpine_edge/main/x86_64/APKINDEX.tar.gz")
|
|
if second.Code != http.StatusOK {
|
|
t.Fatalf("repository B: status = %d, want 200: %s", second.Code, second.Body.String())
|
|
}
|
|
if second.Body.String() != indexB {
|
|
t.Errorf("repository B served %q, want %q (cache key collision)", second.Body.String(), indexB)
|
|
}
|
|
}
|
|
|
|
// TestAPKHandler_IndexesServedUnchanged covers v2 (APKINDEX.tar.gz) and v3
|
|
// (Packages.adb) indexes plus detached signatures: bytes must be served
|
|
// unchanged so apk signature verification keeps working, and within the
|
|
// metadata TTL cached copies must be served without contacting the upstream
|
|
// (the stale-after-TTL fallback itself is covered by the shared ProxyCached
|
|
// tests).
|
|
func TestAPKHandler_IndexesServedUnchanged(t *testing.T) {
|
|
files := map[string][]byte{
|
|
"/v3.22/main/x86_64/APKINDEX.tar.gz": []byte("\x1f\x8b\x08v2-index-with-embedded-signature"),
|
|
"/v3.22/main/x86_64/Packages.adb": []byte("ADB.v3-index-binary\x00payload"),
|
|
"/v3.22/main/x86_64/Packages.adb.sig": []byte("detached-signature-bytes"),
|
|
}
|
|
|
|
var available atomic.Bool
|
|
available.Store(true)
|
|
var upstreamRequests atomic.Int32
|
|
var authHeader string
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !available.Load() {
|
|
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
authHeader = r.Header.Get("Authorization")
|
|
data, ok := files[r.URL.Path]
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
upstreamRequests.Add(1)
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
_, _ = w.Write(data)
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
proxy.CacheMetadata = true
|
|
proxy.MetadataTTL = time.Hour
|
|
proxy.HTTPClient = upstream.Client()
|
|
proxy.AuthForURL = func(string) (string, string) {
|
|
return "Authorization", "Bearer apk-token"
|
|
}
|
|
|
|
h := NewAPKHandler(proxy, "http://proxy.example", map[string]string{"alpine": upstream.URL})
|
|
|
|
for path, want := range files {
|
|
w := serveAPKRequest(h, "/alpine"+path)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("%s: status = %d, want 200: %s", path, w.Code, w.Body.String())
|
|
}
|
|
if got := w.Body.Bytes(); string(got) != string(want) {
|
|
t.Errorf("%s: body altered:\ngot %q\nwant %q", path, got, want)
|
|
}
|
|
}
|
|
if authHeader != "Bearer apk-token" {
|
|
t.Errorf("Authorization = %q, want %q", authHeader, "Bearer apk-token")
|
|
}
|
|
|
|
// Upstream goes away: cached indexes must still be served, unchanged.
|
|
available.Store(false)
|
|
requestsBefore := upstreamRequests.Load()
|
|
for path, want := range files {
|
|
w := serveAPKRequest(h, "/alpine"+path)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("%s offline: status = %d, want 200: %s", path, w.Code, w.Body.String())
|
|
}
|
|
if got := w.Body.Bytes(); string(got) != string(want) {
|
|
t.Errorf("%s offline: body altered:\ngot %q\nwant %q", path, got, want)
|
|
}
|
|
}
|
|
if got := upstreamRequests.Load(); got != requestsBefore {
|
|
t.Errorf("upstream requests during offline reads = %d, want %d", got, requestsBefore)
|
|
}
|
|
}
|
|
|
|
// TestAPKHandler_PackageDownloadCachesPerArch covers package downloads, cache
|
|
// hits, offline reads, and that identically named packages for different
|
|
// architectures are cached separately.
|
|
func TestAPKHandler_PackageDownloadCachesPerArch(t *testing.T) {
|
|
packages := map[string][]byte{
|
|
"/v3.22/main/x86_64/busybox-1.37.0-r12.apk": []byte("x86_64 package bytes"),
|
|
"/v3.22/main/aarch64/busybox-1.37.0-r12.apk": []byte("aarch64 package bytes"),
|
|
}
|
|
|
|
var available atomic.Bool
|
|
available.Store(true)
|
|
var packageRequests atomic.Int32
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !available.Load() {
|
|
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
data, ok := packages[r.URL.Path]
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
packageRequests.Add(1)
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
_, _ = w.Write(data)
|
|
}))
|
|
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 := NewAPKHandler(proxy, "http://proxy.example", map[string]string{"alpine": upstream.URL})
|
|
|
|
for path, want := range packages {
|
|
w := serveAPKRequest(h, "/alpine"+path)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("%s: status = %d, want 200: %s", path, w.Code, w.Body.String())
|
|
}
|
|
if got := w.Body.String(); got != string(want) {
|
|
t.Errorf("%s: body = %q, want %q", path, got, want)
|
|
}
|
|
}
|
|
if got := packageRequests.Load(); got != 2 {
|
|
t.Fatalf("upstream package requests = %d, want 2 (one per architecture)", got)
|
|
}
|
|
|
|
// Second round must be served from cache, even with the upstream down.
|
|
available.Store(false)
|
|
for path, want := range packages {
|
|
w := serveAPKRequest(h, "/alpine"+path)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("%s cached: status = %d, want 200: %s", path, w.Code, w.Body.String())
|
|
}
|
|
if got := w.Body.String(); got != string(want) {
|
|
t.Errorf("%s cached: body = %q, want %q", path, got, want)
|
|
}
|
|
}
|
|
if got := packageRequests.Load(); got != 2 {
|
|
t.Errorf("upstream package requests after cache hits = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestAPKHandler_PackageDownloadSendsUpstreamAuth(t *testing.T) {
|
|
var authHeader string
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
authHeader = r.Header.Get("Authorization")
|
|
if authHeader != "Bearer apk-token" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
_, _ = fmt.Fprint(w, "private package")
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
client := upstream.Client()
|
|
client.Transport = &authRoundTripper{base: client.Transport, header: "Authorization", value: "Bearer apk-token"}
|
|
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(client), fetch.WithMaxRetries(0))
|
|
proxy.Fetcher = fetcher
|
|
t.Cleanup(func() { _ = fetcher.Close() })
|
|
|
|
h := NewAPKHandler(proxy, "http://proxy.example", map[string]string{"private": upstream.URL})
|
|
|
|
w := serveAPKRequest(h, "/private/v3.22/main/x86_64/busybox-1.37.0-r12.apk")
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
|
|
}
|
|
if w.Body.String() != "private package" {
|
|
t.Errorf("body = %q, want %q", w.Body.String(), "private package")
|
|
}
|
|
if authHeader != "Bearer apk-token" {
|
|
t.Errorf("Authorization = %q, want %q", authHeader, "Bearer apk-token")
|
|
}
|
|
}
|
|
|
|
func TestAPKHandler_UnparseablePackageProxiedDirectly(t *testing.T) {
|
|
var requested string
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requested = r.URL.Path
|
|
_, _ = fmt.Fprint(w, "raw bytes")
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
proxy.HTTPClient = upstream.Client()
|
|
|
|
h := NewAPKHandler(proxy, "http://proxy.example", map[string]string{"alpine": upstream.URL})
|
|
|
|
w := serveAPKRequest(h, "/alpine/v3.22/main/x86_64/no-version.apk")
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
|
|
}
|
|
if requested != "/v3.22/main/x86_64/no-version.apk" {
|
|
t.Errorf("upstream path = %q, want %q", requested, "/v3.22/main/x86_64/no-version.apk")
|
|
}
|
|
if w.Body.String() != "raw bytes" {
|
|
t.Errorf("body = %q, want %q", w.Body.String(), "raw bytes")
|
|
}
|
|
}
|
|
|
|
// authRoundTripper adds a static auth header, mimicking the server's
|
|
// authentication-aware upstream transport.
|
|
type authRoundTripper struct {
|
|
base http.RoundTripper
|
|
header string
|
|
value string
|
|
}
|
|
|
|
func (a *authRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
req = req.Clone(req.Context())
|
|
req.Header.Set(a.header, a.value)
|
|
base := a.base
|
|
if base == nil {
|
|
base = http.DefaultTransport
|
|
}
|
|
return base.RoundTrip(req)
|
|
}
|
|
|
|
// TestAPKHandler_PackageHeadOmitsBody verifies that HEAD requests for cached
|
|
// packages return headers (including Content-Length) without a body.
|
|
func TestAPKHandler_PackageHeadOmitsBody(t *testing.T) {
|
|
pkg := []byte("package bytes")
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
_, _ = w.Write(pkg)
|
|
}))
|
|
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 := NewAPKHandler(proxy, "http://proxy.example", map[string]string{"alpine": upstream.URL})
|
|
|
|
target := "/alpine/v3.22/main/x86_64/busybox-1.37.0-r12.apk"
|
|
if w := serveAPKRequest(h, target); w.Code != http.StatusOK {
|
|
t.Fatalf("seeding GET: status = %d, want 200: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodHead, target, nil))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("HEAD: status = %d, want 200: %s", w.Code, w.Body.String())
|
|
}
|
|
if got := w.Body.Len(); got != 0 {
|
|
t.Errorf("HEAD body length = %d, want 0", got)
|
|
}
|
|
if got := w.Header().Get("Content-Length"); got != fmt.Sprint(len(pkg)) {
|
|
t.Errorf("HEAD Content-Length = %q, want %d", got, len(pkg))
|
|
}
|
|
}
|
|
|
|
func serveAPKRequest(h *APKHandler, target string) *httptest.ResponseRecorder {
|
|
w := httptest.NewRecorder()
|
|
h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil))
|
|
return w
|
|
}
|