-
-
Notifications
You must be signed in to change notification settings - Fork 35
Add Alpine APK repository proxy support #293
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
pinguinfuss
wants to merge
3
commits into
git-pkgs:main
Choose a base branch
from
pinguinfuss:alpine-apk-support
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The mechanism is real, but it isn't specific to this PR: the code path is the shared
fetchUpstreamMetadataininternal/handler/handler.go, and thedebian,rpm,helm, andcondahandlers go through exactly the same transport behavior for their signed/hash-pinned metadata. This handler only inherits it.A narrow fix here would also be incomplete: explicitly setting
Accept-Encoding: identitydisables Go's transparent decompression, but an upstream that mislabels stored.gzobjects with Content-Encoding: gzip would then have its raw gzip bytes cached and re-served without that header — the metadata cache stores a content type but no content encoding. Doing this properly means persisting/forwarding Content-Encoding in the metadata cache and fixing all ecosystems at once.I'd prefer to track that as a separate hardening issue against the shared metadata fetch path rather than grow this PR's scope.