Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 15:49:16 -05:00
pkg-proxy/internal/handler/apk.go
Christian Heim 1d88be53ab Enable storage retention for alpine
Packages are stored as pkg:apk/alpine/{name} whichever configured repository they came from, so a package rule covers the package in all of them. Rules may leave out the alpine namespace (pkg:apk/curl); another namespace could never match and is refused. The docs row warns that Alpine mirrors only keep the current build, so an evicted older one can't come back.

The mirror can't fetch apk packages, so there's no mirror test.

Refs #306
2026-10-12 10:23:27 +02:00

207 lines
7.5 KiB
Go

package handler
import (
"crypto/sha256"
"encoding/hex"
"net/http"
"regexp"
"strings"
"github.com/git-pkgs/proxy/internal/retention"
"github.com/git-pkgs/purl"
)
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
)
// Packages are stored as pkg:apk/alpine/{name}, the namespace purl gives
// every apk package, whichever configured repository they came from. Package
// rules may leave the namespace out.
func init() {
retention.Default.Register(retention.Spec{Key: apkEcosystem, CanonicalPackage: apkRetentionKey})
}
// apkRetentionKey maps pkg:apk/{name} and pkg:apk/alpine/{name} to the stored
// package PURL. Another namespace, or a name with a slash, can never match a
// stored package and is refused.
func apkRetentionKey(p *purl.PURL) (string, bool) {
if p == nil || p.Type != "apk" || (p.Namespace != "" && p.Namespace != "alpine") || strings.Contains(p.Name, "/") {
return "", false
}
canonical := purl.MakePURLString(apkEcosystem, p.Name, "")
return canonical, canonical != ""
}
// 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.Artifact.MediaType == "" {
result.Artifact.MediaType = "application/octet-stream"
}
ServeArtifactRequest(w, r, 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
}