Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-13 09:39:16 -05:00
pkg-proxy/internal/handler/helm.go

404 lines
13 KiB
Go
Raw Permalink Normal View History

package handler
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/url"
"path"
"strings"
"time"
"gopkg.in/yaml.v3"
"github.com/git-pkgs/proxy/internal/retention"
)
const (
helmMetadataEcosystem = "helm"
helmIndexFilename = "index.yaml"
sha256HexLength = 64
)
// Helm takes no package rules: charts are cached under the configured
// repository's name and the chart digest, not under the chart name, so a key
// like pkg:helm/nginx would name a repository. The helm rule and the default
// cover charts from HTTP repositories; charts pulled over OCI go through the
// container handler and fall under oci.
func init() {
retention.Default.Register(retention.Spec{Key: "helm"})
}
// HelmHandler serves read-only HTTP Helm chart repositories. Each configured
// repository is mounted at /helm/{repository}/.
type HelmHandler struct {
proxy *Proxy
proxyURL string
repositories map[string]string
ociRegistries []helmOCIRegistry
}
// NewHelmHandler creates a Helm chart repository protocol handler.
func NewHelmHandler(proxy *Proxy, proxyURL string, repositories map[string]string) *HelmHandler {
h := &HelmHandler{
proxyURL: strings.TrimSuffix(proxyURL, "/"),
repositories: make(map[string]string, len(repositories)),
proxy: proxy,
}
for name, repositoryURL := range repositories {
h.repositories[name] = strings.TrimSuffix(repositoryURL, "/")
}
return h
}
// Routes returns the HTTP handler for Helm chart repository requests.
func (h *HelmHandler) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /{repository}/index.yaml", h.handleIndex)
mux.HandleFunc("GET /{repository}/charts/{digest}/{filename}", h.handleChart)
return mux
}
func (h *HelmHandler) handleIndex(w http.ResponseWriter, r *http.Request) {
repository, upstreamURL, ok := h.repositoryForRequest(r)
if !ok {
http.NotFound(w, r)
return
}
body, contentType, err := h.fetchIndex(r, repository, upstreamURL)
if err != nil {
h.serveIndexError(w, err)
return
}
rewritten, err := h.rewriteIndex(repository, upstreamURL, body)
if err != nil {
h.proxy.Logger.Warn("failed to rewrite Helm index", "repository", repository, "error", err)
http.Error(w, "invalid Helm repository index", http.StatusBadGateway)
return
}
h.proxy.writeMetadataCachedResponse(w, r, helmMetadataEcosystem, h.indexCacheKey(repository, upstreamURL), rewritten, contentType)
}
func (h *HelmHandler) handleChart(w http.ResponseWriter, r *http.Request) {
repository, upstreamURL, ok := h.repositoryForRequest(r)
if !ok {
http.NotFound(w, r)
return
}
digest, ok := normalizeHelmDigest(r.PathValue("digest"))
filename := r.PathValue("filename")
if !ok || filename == "" || strings.Contains(filename, "/") || containsPathTraversal(filename) {
http.Error(w, "invalid chart request", http.StatusBadRequest)
return
}
cached, err := h.proxy.GetCachedArtifact(r.Context(), helmMetadataEcosystem, repository, digest, filename)
if err != nil {
h.proxy.Logger.Error("failed to check Helm chart cache", "error", err)
http.Error(w, "failed to check chart cache", http.StatusInternalServerError)
return
}
if cached != nil {
Support HTTP byte-range requests for local file cached artifact downloads (#404) * feat(storage): expose efficient seek for local files Open file-backed artifacts with os.Open so local readers support efficient seeking. Leave cloud storage readers unchanged. Keep the legacy sidecar mapping test focused on fileblob's reader: Blob.Open now reads local files directly and no longer consults the sidecar. Continue checking sidecar cleanup and post-store reads. * feat(handler): preserve efficient seek through integrity checks Keep the cache result's single Reader field and preserve io.Seeker when the underlying storage reader supports efficient seeking. Sequential reads continue through whole-object integrity verification. A successful seek switches subsequent reads to the source for range responses, where full-object verification is not possible. Test both modes and verify the source is closed once. * feat(handler): add request-aware byte range responses Add a request-aware artifact serving helper for bounded, open-ended, and suffix byte ranges, including If-Range handling, 206 responses, and 416 responses for valid unsatisfiable ranges. Ignore malformed and multi-range requests by serving the full artifact. Advertise byte-range support only for seekable readers, preserving HEAD, redirect, and non-seekable behavior. Clarify that net/http recovers ErrAbortHandler per request and keeps the server running. * feat(handler): enable byte ranges across artifact downloads Forward each artifact request to the shared range-aware response helper, including cached generic release assets and OCI blobs. This lets the handler honor ranges only when its reader supports efficient seeking, without changing direct-storage redirect behavior. Add warm-cache endpoint tests that assert partial response headers and bytes without an upstream refetch. Document supported range behavior, unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
h.serveChart(w, r, repository, digest, filename, cached)
return
}
body, _, err := h.fetchIndex(r, repository, upstreamURL)
if err != nil {
h.serveIndexError(w, err)
return
}
downloadURL, err := h.findChartDownload(upstreamURL, body, digest, filename)
if err != nil {
if errors.Is(err, errHelmChartNotFound) {
http.NotFound(w, r)
return
}
h.proxy.Logger.Warn("failed to read Helm index", "repository", repository, "error", err)
http.Error(w, "invalid Helm repository index", http.StatusBadGateway)
return
}
Add storage.cache_artifacts to serve artifacts without storing them (#387) * Add storage.passthrough to serve artifacts without storing them With storage.passthrough enabled the proxy streams every artifact from upstream to the client and never writes it to storage or the cache database. Metadata filtering, cooldown and the denylist work as before, so the proxy can sit behind another cache (e.g. an Artifactory remote) purely as a policy layer without holding a second copy of every package. Artifacts whose digest is known up front (OCI, Swift, Helm) are verified while streaming. Their responses are sent chunked and the connection is aborted on a mismatch, so a client never receives a tampered artifact as a complete response. Passthrough is rejected together with scanning, direct_serve and mirror_api, and the mirror command refuses to run with it, since all of them depend on stored artifacts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Address review: abort truncated streams, guard every cache read - serveArtifact aborts the response on any body read error, and when fewer bytes than the declared size were written, not only on a digest mismatch. A failed or short upstream body in passthrough mode no longer reaches the client as a complete 200. - Streamed upstream read failures are logged and counted as stream_failed upstream errors before the response is aborted. - checkCache reports a miss when artifact caching is off, so every cache read path, including Swift archive HEAD requests, ignores entries stored before the mode was enabled. Denylisted versions are still rejected. - Rename storage.passthrough to storage.cache_artifacts (default true, PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes. Tests cover an OCI blob shorter than its Content-Length and an npm tarball whose chunked response ends without the final chunk, both served through the HTTP handlers, plus a Swift HEAD request against a cached archive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Rename Proxy.Passthrough to StreamArtifacts Match the storage.cache_artifacts option: the field says what changes (artifacts are streamed instead of stored) and its zero value keeps the usual caching behaviour for every Proxy built without the server config, such as the mirror command and tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
// A streamed fetch is never stored, so serveChart's digest check has
// nothing to compare against: verify the stream itself instead.
expectedDigest := ""
if h.proxy.StreamArtifacts {
expectedDigest = "sha256:" + digest
}
result, err := h.proxy.GetOrFetchArtifactFromURLWithDigest(
r.Context(), helmMetadataEcosystem, repository, digest, filename, downloadURL, expectedDigest)
if err != nil {
h.proxy.serveArtifactError(w, err, "failed to fetch chart")
return
}
Support HTTP byte-range requests for local file cached artifact downloads (#404) * feat(storage): expose efficient seek for local files Open file-backed artifacts with os.Open so local readers support efficient seeking. Leave cloud storage readers unchanged. Keep the legacy sidecar mapping test focused on fileblob's reader: Blob.Open now reads local files directly and no longer consults the sidecar. Continue checking sidecar cleanup and post-store reads. * feat(handler): preserve efficient seek through integrity checks Keep the cache result's single Reader field and preserve io.Seeker when the underlying storage reader supports efficient seeking. Sequential reads continue through whole-object integrity verification. A successful seek switches subsequent reads to the source for range responses, where full-object verification is not possible. Test both modes and verify the source is closed once. * feat(handler): add request-aware byte range responses Add a request-aware artifact serving helper for bounded, open-ended, and suffix byte ranges, including If-Range handling, 206 responses, and 416 responses for valid unsatisfiable ranges. Ignore malformed and multi-range requests by serving the full artifact. Advertise byte-range support only for seekable readers, preserving HEAD, redirect, and non-seekable behavior. Clarify that net/http recovers ErrAbortHandler per request and keeps the server running. * feat(handler): enable byte ranges across artifact downloads Forward each artifact request to the shared range-aware response helper, including cached generic release assets and OCI blobs. This lets the handler honor ranges only when its reader supports efficient seeking, without changing direct-storage redirect behavior. Add warm-cache endpoint tests that assert partial response headers and bytes without an upstream refetch. Document supported range behavior, unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
h.serveChart(w, r, repository, digest, filename, result)
}
Support HTTP byte-range requests for local file cached artifact downloads (#404) * feat(storage): expose efficient seek for local files Open file-backed artifacts with os.Open so local readers support efficient seeking. Leave cloud storage readers unchanged. Keep the legacy sidecar mapping test focused on fileblob's reader: Blob.Open now reads local files directly and no longer consults the sidecar. Continue checking sidecar cleanup and post-store reads. * feat(handler): preserve efficient seek through integrity checks Keep the cache result's single Reader field and preserve io.Seeker when the underlying storage reader supports efficient seeking. Sequential reads continue through whole-object integrity verification. A successful seek switches subsequent reads to the source for range responses, where full-object verification is not possible. Test both modes and verify the source is closed once. * feat(handler): add request-aware byte range responses Add a request-aware artifact serving helper for bounded, open-ended, and suffix byte ranges, including If-Range handling, 206 responses, and 416 responses for valid unsatisfiable ranges. Ignore malformed and multi-range requests by serving the full artifact. Advertise byte-range support only for seekable readers, preserving HEAD, redirect, and non-seekable behavior. Clarify that net/http recovers ErrAbortHandler per request and keeps the server running. * feat(handler): enable byte ranges across artifact downloads Forward each artifact request to the shared range-aware response helper, including cached generic release assets and OCI blobs. This lets the handler honor ranges only when its reader supports efficient seeking, without changing direct-storage redirect behavior. Add warm-cache endpoint tests that assert partial response headers and bytes without an upstream refetch. Document supported range behavior, unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
func (h *HelmHandler) serveChart(w http.ResponseWriter, r *http.Request, repository, digest, filename string, result *CacheResult) {
if !strings.EqualFold(result.Artifact.Digest.Encoded(), digest) {
if result.Reader != nil {
_ = result.Reader.Close()
}
if clearErr := h.proxy.ClearCachedArtifact(helmMetadataEcosystem, repository, digest, filename); clearErr != nil {
h.proxy.Logger.Warn("failed to clear Helm chart with invalid digest", "error", clearErr)
}
http.Error(w, "chart digest verification failed", http.StatusBadGateway)
return
}
if result.Artifact.MediaType == "" {
w.Header().Set(headerContentType, "application/gzip")
}
Support HTTP byte-range requests for local file cached artifact downloads (#404) * feat(storage): expose efficient seek for local files Open file-backed artifacts with os.Open so local readers support efficient seeking. Leave cloud storage readers unchanged. Keep the legacy sidecar mapping test focused on fileblob's reader: Blob.Open now reads local files directly and no longer consults the sidecar. Continue checking sidecar cleanup and post-store reads. * feat(handler): preserve efficient seek through integrity checks Keep the cache result's single Reader field and preserve io.Seeker when the underlying storage reader supports efficient seeking. Sequential reads continue through whole-object integrity verification. A successful seek switches subsequent reads to the source for range responses, where full-object verification is not possible. Test both modes and verify the source is closed once. * feat(handler): add request-aware byte range responses Add a request-aware artifact serving helper for bounded, open-ended, and suffix byte ranges, including If-Range handling, 206 responses, and 416 responses for valid unsatisfiable ranges. Ignore malformed and multi-range requests by serving the full artifact. Advertise byte-range support only for seekable readers, preserving HEAD, redirect, and non-seekable behavior. Clarify that net/http recovers ErrAbortHandler per request and keeps the server running. * feat(handler): enable byte ranges across artifact downloads Forward each artifact request to the shared range-aware response helper, including cached generic release assets and OCI blobs. This lets the handler honor ranges only when its reader supports efficient seeking, without changing direct-storage redirect behavior. Add warm-cache endpoint tests that assert partial response headers and bytes without an upstream refetch. Document supported range behavior, unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
ServeArtifactRequest(w, r, result)
}
func (h *HelmHandler) repositoryForRequest(r *http.Request) (name, upstreamURL string, ok bool) {
name = r.PathValue("repository")
upstreamURL, ok = h.repositories[name]
return name, upstreamURL, ok
}
func (h *HelmHandler) fetchIndex(r *http.Request, repository, upstreamURL string) ([]byte, string, error) {
return h.proxy.FetchOrCacheMetadata(
r.Context(),
helmMetadataEcosystem,
h.indexCacheKey(repository, upstreamURL),
upstreamURL+"/"+helmIndexFilename,
"application/x-yaml, text/yaml;q=0.9, */*;q=0.1",
)
}
func (h *HelmHandler) indexCacheKey(repository, upstreamURL string) string {
identity := repository + "\x00" + upstreamURL
digest := sha256.Sum256([]byte(identity))
return hex.EncodeToString(digest[:])
}
func (h *HelmHandler) serveIndexError(w http.ResponseWriter, err error) {
if errors.Is(err, ErrUpstreamNotFound) {
http.Error(w, "Helm repository not found", http.StatusNotFound)
return
}
if writeUpstreamHeld(w, err) {
return
}
h.proxy.Logger.Error("failed to fetch Helm index", "error", err)
http.Error(w, "failed to fetch Helm repository index", http.StatusBadGateway)
}
func (h *HelmHandler) rewriteIndex(repository, upstreamURL string, body []byte) ([]byte, error) {
document, entries, err := parseHelmIndex(body)
if err != nil {
return nil, err
}
for i := 0; i < len(entries.Content); i += 2 {
chartName := entries.Content[i].Value
releases := entries.Content[i+1]
if releases.Kind != yaml.SequenceNode {
return nil, fmt.Errorf("chart %q releases must be a sequence", chartName)
}
filtered := make([]*yaml.Node, 0, len(releases.Content))
for _, release := range releases.Content {
chart, err := h.parseChartRelease(chartName, upstreamURL, release)
if err != nil {
h.proxy.Logger.Warn("omitting malformed Helm chart release",
"repository", repository, "chart", chartName, "error", err)
continue
}
if h.chartOnCooldown(chartName, chart.created) {
continue
}
for _, download := range chart.downloads {
if download.oci {
download.node.Value = h.ociChartProxyURL(download.url)
} else {
download.node.Value = h.chartProxyURL(repository, chart.digest, download.filename)
}
}
filtered = append(filtered, release)
}
releases.Content = filtered
}
return yaml.Marshal(document)
}
func (h *HelmHandler) findChartDownload(upstreamURL string, body []byte, digest, filename string) (string, error) {
_, entries, err := parseHelmIndex(body)
if err != nil {
return "", err
}
for i := 0; i < len(entries.Content); i += 2 {
chartName := entries.Content[i].Value
releases := entries.Content[i+1]
if releases.Kind != yaml.SequenceNode {
return "", fmt.Errorf("chart %q releases must be a sequence", chartName)
}
for _, release := range releases.Content {
chart, err := h.parseChartRelease(chartName, upstreamURL, release)
if err != nil {
continue
}
if chart.digest != digest || h.chartOnCooldown(chartName, chart.created) {
continue
}
for _, download := range chart.downloads {
if !download.oci && download.filename == filename {
return download.url, nil
}
}
}
}
return "", errHelmChartNotFound
}
func (h *HelmHandler) chartOnCooldown(chartName string, created time.Time) bool {
return !created.IsZero() && h.proxy.Cooldown != nil && h.proxy.Cooldown.Enabled() &&
!h.proxy.Cooldown.IsAllowed(helmMetadataEcosystem, canonicalPackagePURL(helmMetadataEcosystem, chartName), created)
}
type helmChartDownload struct {
node *yaml.Node
url string
filename string
oci bool
}
type helmChartRelease struct {
created time.Time
digest string
downloads []helmChartDownload
}
var errHelmChartNotFound = errors.New("chart not found in Helm index")
func (h *HelmHandler) parseChartRelease(chartName, upstreamURL string, release *yaml.Node) (helmChartRelease, error) {
digestNode := helmMappingValue(release, "digest")
urlsNode := helmMappingValue(release, "urls")
if digestNode == nil || urlsNode == nil || urlsNode.Kind != yaml.SequenceNode || len(urlsNode.Content) == 0 {
return helmChartRelease{}, fmt.Errorf("chart %q has no digest or URLs", chartName)
}
digest, ok := normalizeHelmDigest(digestNode.Value)
if !ok {
return helmChartRelease{}, fmt.Errorf("chart %q has invalid digest", chartName)
}
baseURL, err := url.Parse(upstreamURL + "/" + helmIndexFilename)
if err != nil {
return helmChartRelease{}, fmt.Errorf("parsing Helm repository URL: %w", err)
}
chart := helmChartRelease{digest: digest}
if createdNode := helmMappingValue(release, "created"); createdNode != nil && createdNode.Value != "" {
chart.created, err = time.Parse(time.RFC3339Nano, createdNode.Value)
if err != nil {
return helmChartRelease{}, fmt.Errorf("chart %q has invalid creation time: %w", chartName, err)
}
}
for _, urlNode := range urlsNode.Content {
download, err := parseHelmChartDownload(chartName, baseURL, urlNode)
if err != nil {
return helmChartRelease{}, err
}
chart.downloads = append(chart.downloads, download)
}
return chart, nil
}
func parseHelmChartDownload(chartName string, baseURL *url.URL, node *yaml.Node) (helmChartDownload, error) {
if node.Kind != yaml.ScalarNode {
return helmChartDownload{}, fmt.Errorf("chart %q has invalid URL", chartName)
}
reference, err := url.Parse(node.Value)
if err != nil {
return helmChartDownload{}, fmt.Errorf("parsing chart %q URL: %w", chartName, err)
}
if reference.Scheme == "oci" {
if reference.Hostname() == "" || strings.Trim(reference.Path, "/") == "" ||
reference.User != nil || reference.RawQuery != "" || reference.ForceQuery || reference.Fragment != "" ||
containsPathTraversal(reference.Path) {
return helmChartDownload{}, fmt.Errorf("chart %q has invalid OCI reference", chartName)
}
return helmChartDownload{node: node, url: node.Value, oci: true}, nil
}
downloadURL := baseURL.ResolveReference(reference)
if (downloadURL.Scheme != "http" && downloadURL.Scheme != "https") || downloadURL.Host == "" {
return helmChartDownload{}, fmt.Errorf("chart %q URL must be HTTP(S) or OCI", chartName)
}
filename := path.Base(downloadURL.Path)
if filename == "." || filename == "/" || filename == "" || !strings.HasSuffix(filename, ".tgz") {
return helmChartDownload{}, fmt.Errorf("chart %q URL must point to a .tgz file", chartName)
}
return helmChartDownload{node: node, url: downloadURL.String(), filename: filename}, nil
}
func (h *HelmHandler) chartProxyURL(repository, digest, filename string) string {
return fmt.Sprintf("%s/helm/%s/charts/%s/%s", h.proxyURL,
url.PathEscape(repository), digest, url.PathEscape(filename))
}
func parseHelmIndex(body []byte) (*yaml.Node, *yaml.Node, error) {
var document yaml.Node
if err := yaml.Unmarshal(body, &document); err != nil {
return nil, nil, fmt.Errorf("parsing Helm index: %w", err)
}
entries, err := helmIndexEntries(&document)
if err != nil {
return nil, nil, err
}
return &document, entries, nil
}
func helmIndexEntries(document *yaml.Node) (*yaml.Node, error) {
if document == nil {
return nil, errors.New("helm index is empty")
}
if len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode {
return nil, errors.New("helm index must be a mapping")
}
entries := helmMappingValue(document.Content[0], "entries")
if entries == nil || entries.Kind != yaml.MappingNode {
return nil, errors.New("helm index has no entries mapping")
}
if len(entries.Content)%2 != 0 {
return nil, errors.New("helm index entries mapping has an incomplete key-value pair")
}
return entries, nil
}
func helmMappingValue(mapping *yaml.Node, key string) *yaml.Node {
if mapping == nil || mapping.Kind != yaml.MappingNode {
return nil
}
for i := 0; i+1 < len(mapping.Content); i += 2 {
if mapping.Content[i].Value == key {
return mapping.Content[i+1]
}
}
return nil
}
func normalizeHelmDigest(value string) (string, bool) {
digest := strings.TrimPrefix(strings.ToLower(value), "sha256:")
if len(digest) != sha256HexLength {
return "", false
}
for _, char := range digest {
if (char < '0' || char > '9') && (char < 'a' || char > 'f') {
return "", false
}
}
return digest, true
}