diff --git a/README.md b/README.md index 3a9444d..1eb1ade 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Resolution order: package override, then ecosystem override, then global default | Container | Docker/OCI | | ✓ | | Debian | Debian/Ubuntu | | ✓ | | RPM | RHEL/Fedora | | ✓ | -| Alpine | Alpine Linux | | ✗ | +| Alpine | Alpine Linux | | ✓ | | Arch | Arch Linux | | ✗ | | Chef | Chef | | ✗ | | Generic | Any | | ✗ | @@ -435,6 +435,46 @@ sudo dnf clean all sudo dnf update ``` +### Alpine / apk + +Point `/etc/apk/repositories` at the proxy. The default repository name +`alpine` proxies the official mirror (`https://dl-cdn.alpinelinux.org/alpine`): + +``` +http://localhost:8080/apk/alpine/v3.22/main +http://localhost:8080/apk/alpine/v3.22/community +``` + +Then: + +```bash +apk update +``` + +Repository indexes (v2 `APKINDEX.tar.gz` and v3 `Packages.adb`), detached +signatures, and packages are served byte-for-byte unchanged, so apk's normal +signature verification keeps working. Indexes use the metadata cache +(`metadata_ttl`, stale fallback); `.apk` packages are stored in the shared +artifact cache and remain available when the upstream is unreachable. + +To proxy other mirrors or private repositories, configure named upstreams +under `upstream.apk` (this replaces the built-in default; re-add `alpine` if +you still want it): + +```yaml +upstream: + apk: + alpine: "https://dl-cdn.alpinelinux.org/alpine" + private: "https://apk.example.com" +``` + +``` +http://localhost:8080/apk/private +``` + +apk appends the architecture and index filename to each repository line +itself. + ## Configuration The proxy can be configured via: @@ -724,6 +764,7 @@ Recently cached: | `GET /julia/*` | Julia Pkg server protocol | | `GET /helm/{repository}/*` | HTTP Helm chart repository protocol | | `GET /v2/*` | OCI/Docker registry protocol | +| `GET /apk/{repository}/*` | Alpine APK repository protocol | | `GET /debian/*` | Debian/APT repository protocol | | `GET /rpm/*` | RPM/Yum repository protocol | diff --git a/config.example.yaml b/config.example.yaml index 0a74554..b4f0ec4 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -179,6 +179,13 @@ upstream: # oci: # ghcr: "https://ghcr.io" + # Named Alpine APK repositories (used by /apk/{name}/). + # Defaults to {"alpine": "https://dl-cdn.alpinelinux.org/alpine"} when empty; + # configuring any entry replaces that default. + # apk: + # alpine: "https://dl-cdn.alpinelinux.org/alpine" + # private: "https://apk.example.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. diff --git a/docs/configuration.md b/docs/configuration.md index de88498..15e07cb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -214,6 +214,23 @@ uses the `ghcr` registry with `owner/chart` as its repository. When the proxy uses plain HTTP (for example `localhost:8080`), pass `--plain-http` to Helm OCI commands. +```yaml +upstream: + + # Named Alpine APK repositories, served at /apk/{name}/. + apk: + alpine: "https://dl-cdn.alpinelinux.org/alpine" +``` + +Alpine APK repositories are read-only. Requests to `/apk/{name}/…` mirror the +upstream layout, e.g. `/apk/alpine/v3.22/main/x86_64/APKINDEX.tar.gz`. Indexes +(v2 `APKINDEX.tar.gz`, v3 `Packages.adb`) and detached signatures are cached +with the metadata TTL and served byte-for-byte unchanged so apk signature +verification keeps working; `.apk` packages use the shared artifact cache. +When `upstream.apk` is empty, a single repository named `alpine` pointing at +the official mirror is available; configuring any entry replaces that default. + + ## Authentication Configure authentication for private upstream registries. The same authentication-aware client is used for metadata and artifact downloads, and credentials can reference environment variables using `${VAR_NAME}` syntax. diff --git a/internal/config/config.go b/internal/config/config.go index 2fbf64f..4b322a4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -411,6 +411,12 @@ type UpstreamConfig struct { // rewritten to the same named proxy endpoint. Helm map[string]string `json:"helm" yaml:"helm"` + // APK maps repository names to Alpine APK repository base URLs, served + // at /apk/{name}/. The remaining request path mirrors the upstream + // layout, e.g. /apk/alpine/v3.22/main/x86_64/APKINDEX.tar.gz. + // Default when empty: {"alpine": "https://dl-cdn.alpinelinux.org/alpine"}. + APK map[string]string `json:"apk" yaml:"apk"` + // OCI maps names to OCI registry URLs. Requests to a named registry use // the repository prefix upstream/{name}/, for example // oci://proxy.example.com/upstream/ghcr/owner/chart. @@ -459,6 +465,9 @@ func (u *UpstreamConfig) Validate() error { if err := validateNamedUpstreams("upstream.helm", u.Helm); err != nil { return err } + if err := validateNamedUpstreams("upstream.apk", u.APK); err != nil { + return err + } if err := validateNamedUpstreams("upstream.oci", u.OCI); err != nil { return err } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 71249d7..517503b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1058,10 +1058,11 @@ func TestValidateNamedUpstreams(t *testing.T) { wantErr bool }{ { - name: "valid Helm and OCI upstreams", + name: "valid Helm, OCI, and APK 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"} }, }, { @@ -1078,6 +1079,20 @@ func TestValidateNamedUpstreams(t *testing.T) { }, wantErr: true, }, + { + name: "APK upstream name contains path separator", + modify: func(cfg *Config) { + cfg.Upstream.APK = map[string]string{"alpine/edge": "https://dl-cdn.alpinelinux.org/alpine"} + }, + wantErr: true, + }, + { + name: "APK upstream URL is not absolute", + modify: func(cfg *Config) { + cfg.Upstream.APK = map[string]string{"private": "apk.example.com"} + }, + wantErr: true, + }, } for _, tt := range tests { diff --git a/internal/handler/apk.go b/internal/handler/apk.go new file mode 100644 index 0000000..599aca6 --- /dev/null +++ b/internal/handler/apk.go @@ -0,0 +1,186 @@ +package handler + +import ( + "crypto/sha256" + "encoding/hex" + "net/http" + "regexp" + "strings" +) + +const ( + apkEcosystem = "alpine" + // defaultAPKRepositoryName is the repository name used when no + // upstream.apk repositories are configured. + defaultAPKRepositoryName = "alpine" + // defaultAPKUpstream is the official Alpine Linux mirror. + defaultAPKUpstream = "https://dl-cdn.alpinelinux.org/alpine" + apkMatchCount = 3 // full match + name + version +) + +// APKHandler handles Alpine APK repository protocol requests. Each configured +// upstream repository is mounted at /apk/{repository}/ and the remaining path +// mirrors the upstream layout ({release}/{repo}/{arch}/{file}). +// +// Repository indexes (v2 APKINDEX.tar.gz, v3 Packages.adb) and detached +// signatures are served byte-for-byte unchanged through the metadata cache so +// apk signature verification keeps working. Package files are cached in the +// shared artifact cache and stay available when the upstream is unreachable. +type APKHandler struct { + proxy *Proxy + proxyURL string + repositories map[string]string +} + +// NewAPKHandler creates an Alpine APK repository protocol handler. +// When repositories is empty, a single repository named "alpine" pointing at +// the official Alpine mirror is used. +func NewAPKHandler(proxy *Proxy, proxyURL string, repositories map[string]string) *APKHandler { + h := &APKHandler{ + proxy: proxy, + proxyURL: strings.TrimSuffix(proxyURL, "/"), + repositories: make(map[string]string, len(repositories)), + } + for name, repositoryURL := range repositories { + h.repositories[name] = strings.TrimSuffix(repositoryURL, "/") + } + if len(h.repositories) == 0 { + h.repositories[defaultAPKRepositoryName] = defaultAPKUpstream + } + return h +} + +// Routes returns the HTTP handler for APK requests. +// Mount this at /apk on your router. +func (h *APKHandler) 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 + } + + switch { + case isAPKIndex(rest) || isAPKSignature(rest): + // Indexes and detached signatures are signed upstream metadata. + // Cache them with the metadata TTL and serve the stored bytes + // unchanged so apk verification continues to work. + h.handleMetadata(w, r, repository, upstreamURL, rest) + case strings.HasSuffix(rest, ".apk"): + // Package downloads - cache these in the artifact cache. + h.handlePackageDownload(w, r, repository, upstreamURL, rest) + default: + // Other files - proxy directly. + h.proxyFile(w, r, upstreamURL, rest) + } + }) +} + +// isAPKIndex reports whether the path names a repository index: +// APKINDEX.tar.gz (apk v2) or Packages.adb (apk v3). +func isAPKIndex(path string) bool { + base := path[strings.LastIndex(path, "/")+1:] + return base == "APKINDEX.tar.gz" || base == "Packages.adb" +} + +// isAPKSignature reports whether the path names a detached signature file. +func isAPKSignature(path string) bool { + return strings.HasSuffix(path, ".sig") || strings.HasSuffix(path, ".rsa.pub") +} + +// handlePackageDownload fetches and caches .apk packages. +// Path format: {release}/{repo}/{arch}/{name}-{version}-r{rel}.apk +// Example: v3.22/main/x86_64/busybox-1.37.0-r12.apk +// +// APK filenames do not include the architecture, so the same filename can hold +// different bytes per architecture (and per release). The full request path is +// therefore part of the cache identity. +func (h *APKHandler) handlePackageDownload(w http.ResponseWriter, r *http.Request, repository, upstreamURL, path string) { + name, version, arch := h.parseAPKPath(path) + if name == "" { + // Can't parse, just proxy directly + h.proxyFile(w, r, upstreamURL, path) + return + } + + downloadURL := upstreamURL + "/" + path + cacheFilename := repository + "/" + path + + h.proxy.Logger.Info("apk package download", + "repository", repository, "name", name, "version", version, "arch", arch) + + result, err := h.proxy.GetOrFetchArtifactFromURL( + r.Context(), apkEcosystem, name, version, cacheFilename, downloadURL) + if err != nil { + h.proxy.serveArtifactError(w, err, "failed to fetch package") + return + } + + if result.ContentType == "" { + result.ContentType = "application/octet-stream" + } + serveArtifact(w, r.Method, result) +} + +// handleMetadata serves repository indexes and signatures through the +// metadata cache. Stored bytes are re-served verbatim, which keeps embedded +// and detached signatures valid. +func (h *APKHandler) handleMetadata(w http.ResponseWriter, r *http.Request, repository, upstreamURL, path string) { + h.proxy.ProxyCached(w, r, upstreamURL+"/"+path, apkEcosystem, + h.metadataCacheKey(repository, upstreamURL, path), "*/*") +} + +// metadataCacheKey derives the metadata cache key from the repository name, +// its upstream URL, and the request path. Hashing the identity keeps distinct +// repositories from sharing cache entries (repository names may contain '_' +// and a separator-based key would be ambiguous) and drops cached entries when +// a repository is repointed at a different upstream, mirroring +// HelmHandler.indexCacheKey. +func (h *APKHandler) metadataCacheKey(repository, upstreamURL, path string) string { + identity := repository + "\x00" + upstreamURL + "\x00" + path + digest := sha256.Sum256([]byte(identity)) + return hex.EncodeToString(digest[:]) +} + +// proxyFile proxies any file directly without caching. +func (h *APKHandler) proxyFile(w http.ResponseWriter, r *http.Request, upstreamURL, path string) { + h.proxy.ProxyFile(w, r, upstreamURL+"/"+path) +} + +// apkPackagePattern matches .apk filenames to extract name and version. +// Format: {name}-{version}-r{rel}.apk where version starts with a digit. +// Examples: +// - busybox-1.37.0-r12.apk +// - alpine-baselayout-data-3.7.0-r0.apk +var apkPackagePattern = regexp.MustCompile(`^(.+)-(\d[^-]*-r\d+)\.apk$`) + +// parseAPKPath extracts package info from a path containing an APK filename. +// The architecture is taken from the parent directory since APK filenames do +// not include it. +func (h *APKHandler) parseAPKPath(path string) (name, version, arch string) { + segments := strings.Split(path, "/") + filename := segments[len(segments)-1] + if len(segments) > 1 { + arch = segments[len(segments)-2] + } + + matches := apkPackagePattern.FindStringSubmatch(filename) + if len(matches) != apkMatchCount { + return "", "", "" + } + + return matches[1], matches[2], arch +} diff --git a/internal/handler/apk_test.go b/internal/handler/apk_test.go new file mode 100644 index 0000000..77e1da5 --- /dev/null +++ b/internal/handler/apk_test.go @@ -0,0 +1,362 @@ +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 +} diff --git a/internal/handler/notfound_ecosystems_test.go b/internal/handler/notfound_ecosystems_test.go index 726557b..bf3fe55 100644 --- a/internal/handler/notfound_ecosystems_test.go +++ b/internal/handler/notfound_ecosystems_test.go @@ -19,6 +19,8 @@ func TestArtifactDownloadUpstreamNotFoundReturns404(t *testing.T) { func(p *Proxy) http.Handler { return NewDebianHandler(p, "http://localhost", "").Routes() }}, {"rpm", "/releases/39/Everything/x86_64/os/Packages/n/nginx-1.24.0-1.fc39.x86_64.rpm", func(p *Proxy) http.Handler { return NewRPMHandler(p, "http://localhost").Routes() }}, + {"apk", "/alpine/v3.22/main/x86_64/busybox-1.37.0-r12.apk", + func(p *Proxy) http.Handler { return NewAPKHandler(p, "http://localhost", nil).Routes() }}, {"nuget", "/v3-flatcontainer/newtonsoft.json/13.0.3/newtonsoft.json.13.0.3.nupkg", func(p *Proxy) http.Handler { return NewNuGetHandler(p, "http://localhost").Routes() }}, {"pypi", "/packages/packages/ab/cd/ef0123456789/requests-2.31.0-py3-none-any.whl", diff --git a/internal/server/dashboard.go b/internal/server/dashboard.go index 797a9ca..582fc2d 100644 --- a/internal/server/dashboard.go +++ b/internal/server/dashboard.go @@ -123,6 +123,7 @@ func supportedEcosystems() []string { // that the 'select' list in the UI will be in the expected // order return []string{ + "alpine", "cargo", "composer", "conan", @@ -190,6 +191,8 @@ func ecosystemBadgeClasses(ecosystem string) string { return base + " bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-300" case "rpm": return base + " bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-300" + case "alpine": + return base + " bg-lime-100 text-lime-800 dark:bg-lime-900/50 dark:text-lime-300" default: return base + " bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300" } @@ -444,5 +447,18 @@ gpgcheck=0 sudo dnf clean all sudo dnf update`), }, + { + ID: "apk", + Name: "Alpine APK", + Language: "Alpine Linux", + Endpoint: "/apk/", + Instructions: template.HTML(`

Configure apk to use the proxy:

+
# In /etc/apk/repositories
+` + baseURL + `/apk/alpine/v3.22/main
+` + baseURL + `/apk/alpine/v3.22/community
+
+# Then run:
+apk update
`), + }, } } diff --git a/internal/server/server.go b/internal/server/server.go index 541be1c..51e5945 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -17,6 +17,7 @@ // - /cran/* - CRAN (R) protocol // - /julia/* - Julia Pkg server protocol // - /v2/* - OCI/Docker container registry protocol +// - /apk/* - Alpine APK repository protocol // - /debian/* - Debian/APT repository protocol // - /rpm/* - RPM/Yum repository protocol // @@ -303,6 +304,7 @@ func (s *Server) serve(listener net.Listener) error { s.cfg.Upstream.OCI, ) helmHandler := handler.NewHelmHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.Helm) + apkHandler := handler.NewAPKHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.APK) debianHandler := handler.NewDebianHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.Debian) rpmHandler := handler.NewRPMHandlerWithUpstream(proxy, s.cfg.BaseURL, s.cfg.Upstream.RPM) @@ -323,6 +325,7 @@ func (s *Server) serve(listener net.Listener) error { r.Mount("/julia", http.StripPrefix("/julia", juliaHandler.Routes())) r.Mount("/v2", http.StripPrefix("/v2", containerHandler.Routes())) r.Mount("/helm", http.StripPrefix("/helm", helmHandler.Routes())) + r.Mount("/apk", http.StripPrefix("/apk", apkHandler.Routes())) r.Mount("/debian", http.StripPrefix("/debian", debianHandler.Routes())) r.Mount("/rpm", http.StripPrefix("/rpm", rpmHandler.Routes()))