Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | | ✗ |
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 |

Expand Down
7 changes: 7 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down
17 changes: 16 additions & 1 deletion internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
},
},
{
Expand All @@ -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 {
Expand Down
186 changes: 186 additions & 0 deletions internal/handler/apk.go
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), "*/*")
}
Comment on lines +141 to +144

@pinguinfuss pinguinfuss Aug 29, 2026

Copy link
Copy Markdown
Author

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 fetchUpstreamMetadata in internal/handler/handler.go, and the debian, rpm, helm, and conda handlers 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: identity disables Go's transparent decompression, but an upstream that mislabels stored .gz objects 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.


// 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
}
Loading