Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 07:39:19 -05:00
pkg-proxy/internal/handler/apk.go
Simon Chrzanowski 3e2a84a88a Mirror Alpine packages
The PURL namespace names the upstream.apk repository, and the distro and
arch qualifiers syft writes give the release and architecture. The package
is looked up in the main and community indexes of that release and cached
under the path the APK handler serves it from.
2026-11-04 15:44:01 +01:00

231 lines
8.5 KiB
Go

package handler
import (
"crypto/sha256"
"encoding/hex"
"net/http"
"regexp"
"strings"
"time"
"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
// snapshots: see WithSnapshots.
snapshots time.Duration
}
// 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 {
return &APKHandler{
proxy: proxy,
proxyURL: strings.TrimSuffix(proxyURL, "/"),
repositories: APKRepositories(repositories),
}
}
// APKRepositories returns the upstream.apk repositories by name without
// trailing slashes, or the official Alpine mirror as "alpine" when none are
// configured.
func APKRepositories(configured map[string]string) map[string]string {
repositories := make(map[string]string, len(configured))
for name, repositoryURL := range configured {
repositories[name] = strings.TrimSuffix(repositoryURL, "/")
}
if len(repositories) == 0 {
repositories[defaultAPKRepositoryName] = defaultAPKUpstream
}
return repositories
}
// APKCacheFilename returns the filename a package at path
// ({release}/{repo}/{arch}/{file}) of repository is cached under.
func APKCacheFilename(repository, path string) string {
return repository + "/" + path
}
// Routes returns the HTTP handler for APK requests.
// Mount this at /apk on your router.
func (h *APKHandler) Routes() http.Handler {
return rejectQueryChars(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
}
segment, rest, ok := strings.Cut(path, "/")
repository, upstreamURL, day, snapshot, found := h.resolveRepository(segment)
if !ok || rest == "" || !found {
http.NotFound(w, r)
return
}
switch {
case snapshot && (isAPKIndex(rest) || isAPKSignature(rest)):
// A dated snapshot serves the kept version of the index; packages
// and other files below are the same as under the plain path.
h.serveSnapshot(w, r, repository, upstreamURL, rest, day)
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 := APKCacheFilename(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) {
cacheKey := h.metadataCacheKey(repository, upstreamURL, path)
if h.snapshotsEnabled() {
h.serveMetadataWithSnapshot(w, r, upstreamURL+"/"+path, cacheKey)
return
}
h.proxy.ProxyCached(w, r, upstreamURL+"/"+path, apkEcosystem, cacheKey, "*/*")
}
// 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
}