Add Alpine APK repository proxy support (#293)
* 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>
2026-09-02 13:36:00 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 10:58:22 +01:00
|
|
|
if result.Artifact.MediaType == "" {
|
|
|
|
|
result.Artifact.MediaType = "application/octet-stream"
|
Add Alpine APK repository proxy support (#293)
* 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>
2026-09-02 13:36:00 +02:00
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
}
|