Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 23:59:16 -05:00
pkg-proxy/internal/handler/hex.go
Giles Westwood b2955ef458 Keep holds through late successes and away from the circuit breaker
A response arriving during a hold was sent before it began, so a late
success no longer releases the host; only one after the hold has ended
does.

Artifact fetches check RateLimitGate.Held before calling the fetcher, so
held cache misses fail with the HeldError without counting as failures
in the host's circuit breaker.

Every handler that answers an upstream fetch error with 502 now answers
a held request with 503 and Retry-After instead.

OnHold and RecordUpstreamRateLimitHold drop the unused duration.
2026-10-15 13:35:12 +01:00

428 lines
12 KiB
Go

package handler
import (
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/git-pkgs/proxy/internal/retention"
"google.golang.org/protobuf/encoding/protowire"
)
const (
hexUpstream = "https://repo.hex.pm"
)
// Tarballs are stored under pkg:hex/{name} with the name from the tarball
// filename, and purl keeps hex names as they are, so package rules use the
// default key mapping.
func init() {
retention.Default.Register(retention.Spec{Key: "hex", CanonicalPackage: retention.DefaultCanonical("hex")})
}
// HexHandler handles Hex.pm registry protocol requests.
type HexHandler struct {
proxy *Proxy
upstreamURL string
apiURL string
proxyURL string
}
// NewHexHandler creates a new Hex.pm protocol handler.
func NewHexHandler(proxy *Proxy, proxyURL string) *HexHandler {
return &HexHandler{
proxy: proxy,
upstreamURL: hexUpstream,
apiURL: hexAPIURL,
proxyURL: strings.TrimSuffix(proxyURL, "/"),
}
}
// NewHexHandlerWithUpstreams creates a Hex handler with custom repository and
// API upstreams.
func NewHexHandlerWithUpstreams(proxy *Proxy, proxyURL, upstreamURL, apiURL string) *HexHandler {
h := NewHexHandler(proxy, proxyURL)
h.upstreamURL = configuredUpstreamURL(upstreamURL, hexUpstream)
h.apiURL = configuredUpstreamURL(apiURL, hexAPIURL)
return h
}
// Routes returns the HTTP handler for Hex requests.
func (h *HexHandler) Routes() http.Handler {
mux := http.NewServeMux()
// Package tarballs (cache these)
mux.HandleFunc("GET /tarballs/{filename}", h.handleDownload)
// Registry resources (cached for offline)
mux.HandleFunc("GET /names", h.proxyCached)
mux.HandleFunc("GET /versions", h.proxyCached)
mux.HandleFunc("GET /packages/{name}", h.handlePackages)
// Public keys
mux.HandleFunc("GET /public_key", h.proxyUpstream)
return mux
}
// handleDownload serves a package tarball, fetching and caching from upstream if needed.
func (h *HexHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
h.proxy.handleFilenameDownload(w, r, filenameDownload{
ecosystem: "hex",
upstreamURL: h.upstreamURL,
suffix: ".tar",
parseErr: "could not parse tarball filename",
fetchErr: "failed to fetch package",
parse: h.parseTarballFilename,
})
}
// parseTarballFilename extracts name and version from a hex tarball filename.
// e.g., "phoenix-1.7.10.tar" -> ("phoenix", "1.7.10")
// e.g., "amqp_client-4.1.5-1.tar" -> ("amqp_client", "4.1.5-1")
//
// Hex package names can't contain a hyphen, so the first one separates the
// name from a version that may carry hyphens of its own.
func (h *HexHandler) parseTarballFilename(filename string) (name, version string) {
name, version, ok := strings.Cut(strings.TrimSuffix(filename, ".tar"), "-")
if !ok || name == "" || version == "" {
return "", ""
}
return name, version
}
// hexAPIURL is the Hex HTTP API base URL for fetching package metadata with timestamps.
const hexAPIURL = "https://hex.pm"
// handlePackages proxies the /packages/{name} endpoint, applying cooldown filtering
// when enabled. Since the protobuf format has no timestamps, we fetch them from the
// Hex HTTP API concurrently.
func (h *HexHandler) handlePackages(w http.ResponseWriter, r *http.Request) {
if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
h.proxyCached(w, r)
return
}
name := r.PathValue("name")
if name == "" {
h.proxyCached(w, r)
return
}
h.proxy.Logger.Info("hex package request with cooldown", "name", name)
protoResp, filteredVersions, err := h.fetchPackageAndVersions(r, name)
if err != nil {
if writeUpstreamHeld(w, err) {
return
}
h.proxy.Logger.Error("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
defer func() { _ = protoResp.Body.Close() }()
if protoResp.StatusCode != http.StatusOK {
h.proxy.relayResponse(w, r, protoResp, nil)
return
}
body, err := io.ReadAll(protoResp.Body)
if err != nil {
http.Error(w, "failed to read response", http.StatusInternalServerError)
return
}
if len(filteredVersions) == 0 {
// No versions to filter or couldn't get timestamps, pass through
w.Header().Set(headerContentType, protoResp.Header.Get(headerContentType))
w.Header().Set("Content-Encoding", "gzip")
_, _ = w.Write(body)
return
}
filtered, err := h.filterSignedPackage(body, filteredVersions)
if err != nil {
h.proxy.Logger.Warn("failed to filter hex package, proxying original", "error", err)
w.Header().Set(headerContentType, protoResp.Header.Get(headerContentType))
w.Header().Set("Content-Encoding", "gzip")
_, _ = w.Write(body)
return
}
w.Header().Set(headerContentType, "application/octet-stream")
w.Header().Set("Content-Encoding", "gzip")
_, _ = w.Write(filtered)
}
// fetchPackageAndVersions fetches the protobuf package and version timestamps concurrently.
func (h *HexHandler) fetchPackageAndVersions(r *http.Request, name string) (*http.Response, map[string]bool, error) {
type versionsResult struct {
filtered map[string]bool
err error
}
versionsCh := make(chan versionsResult, 1)
go func() {
filtered, err := h.fetchFilteredVersions(r, name)
versionsCh <- versionsResult{filtered: filtered, err: err}
}()
protoResp, err := h.fetchUpstreamPackage(r, name)
versionsRes := <-versionsCh
if err != nil {
return nil, nil, err
}
if versionsRes.err != nil {
h.proxy.Logger.Warn("failed to fetch hex version timestamps, proxying unfiltered",
"name", name, "error", versionsRes.err)
return protoResp, nil, nil
}
return protoResp, versionsRes.filtered, nil
}
// fetchUpstreamPackage fetches the protobuf package from upstream.
func (h *HexHandler) fetchUpstreamPackage(r *http.Request, name string) (*http.Response, error) {
upstreamURL := h.upstreamURL + "/packages/" + name
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
if err != nil {
return nil, err
}
return h.proxy.HTTPClient.Do(req)
}
// hexRelease represents a version entry from the Hex API.
type hexRelease struct {
Version string `json:"version"`
InsertedAt string `json:"inserted_at"`
}
// hexPackageAPI represents the Hex API response for a package.
type hexPackageAPI struct {
Releases []hexRelease `json:"releases"`
}
// fetchFilteredVersions fetches the Hex API and returns a set of version
// strings that should be filtered out by cooldown.
func (h *HexHandler) fetchFilteredVersions(r *http.Request, name string) (map[string]bool, error) {
apiURL := fmt.Sprintf("%s/api/packages/%s", h.apiURL, name)
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, apiURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := h.proxy.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("hex API returned %d", resp.StatusCode)
}
var pkg hexPackageAPI
if err := json.NewDecoder(resp.Body).Decode(&pkg); err != nil {
return nil, err
}
packagePURL := canonicalPackagePURL("hex", name)
filtered := make(map[string]bool)
for _, release := range pkg.Releases {
insertedAt, err := time.Parse(time.RFC3339Nano, release.InsertedAt)
if err != nil {
continue
}
if !h.proxy.Cooldown.IsAllowed("hex", packagePURL, insertedAt) {
filtered[release.Version] = true
h.proxy.Logger.Info("cooldown: filtering hex version",
"package", name, "version", release.Version,
"published", release.InsertedAt)
}
}
return filtered, nil
}
// filterSignedPackage decompresses gzipped data, decodes the Signed protobuf wrapper,
// filters releases from the Package payload, and re-encodes as gzipped protobuf
// (without the original signature since the payload has changed).
func (h *HexHandler) filterSignedPackage(gzippedData []byte, filteredVersions map[string]bool) ([]byte, error) {
// Decompress gzip
gr, err := gzip.NewReader(bytes.NewReader(gzippedData))
if err != nil {
return nil, err
}
signed, err := io.ReadAll(gr)
if err != nil {
return nil, err
}
_ = gr.Close()
// Parse Signed message: field 1 = payload (bytes), field 2 = signature (bytes)
payload, err := extractProtobufBytes(signed, 1)
if err != nil {
return nil, fmt.Errorf("extracting payload: %w", err)
}
// Filter releases from the Package message
filteredPayload, err := filterPackageReleases(payload, filteredVersions)
if err != nil {
return nil, fmt.Errorf("filtering releases: %w", err)
}
// Re-encode Signed message with modified payload and no signature
var newSigned []byte
newSigned = protowire.AppendTag(newSigned, 1, protowire.BytesType)
newSigned = protowire.AppendBytes(newSigned, filteredPayload)
// Gzip compress
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
if _, err := gw.Write(newSigned); err != nil {
return nil, err
}
if err := gw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// filterPackageReleases filters releases from a Package protobuf message.
// Package: field 1 = releases (repeated), field 2 = name, field 3 = repository
func filterPackageReleases(payload []byte, filteredVersions map[string]bool) ([]byte, error) {
var result []byte
data := payload
for len(data) > 0 {
num, wtype, n := protowire.ConsumeTag(data)
if n < 0 {
return nil, fmt.Errorf("invalid protobuf tag")
}
tagBytes := data[:n]
data = data[n:]
var fieldBytes []byte
switch wtype {
case protowire.BytesType:
v, vn := protowire.ConsumeBytes(data)
if vn < 0 {
return nil, fmt.Errorf("invalid protobuf bytes field")
}
fieldBytes = data[:vn]
data = data[vn:]
if num == 1 { // releases field
version := extractReleaseVersion(v)
if filteredVersions[version] {
continue // skip this release
}
}
case protowire.VarintType:
_, vn := protowire.ConsumeVarint(data)
if vn < 0 {
return nil, fmt.Errorf("invalid protobuf varint")
}
fieldBytes = data[:vn]
data = data[vn:]
default:
return nil, fmt.Errorf("unexpected wire type %d", wtype)
}
result = append(result, tagBytes...)
result = append(result, fieldBytes...)
}
return result, nil
}
// extractReleaseVersion extracts the version string from a Release protobuf message.
// Release: field 1 = version (string)
func extractReleaseVersion(release []byte) string {
data := release
for len(data) > 0 {
num, wtype, n := protowire.ConsumeTag(data)
if n < 0 {
return ""
}
data = data[n:]
switch wtype {
case protowire.BytesType:
v, vn := protowire.ConsumeBytes(data)
if vn < 0 {
return ""
}
if num == 1 {
return string(v)
}
data = data[vn:]
case protowire.VarintType:
_, vn := protowire.ConsumeVarint(data)
if vn < 0 {
return ""
}
data = data[vn:]
default:
return ""
}
}
return ""
}
// extractProtobufBytes extracts a bytes field from a protobuf message by field number.
func extractProtobufBytes(data []byte, fieldNum protowire.Number) ([]byte, error) {
for len(data) > 0 {
num, wtype, n := protowire.ConsumeTag(data)
if n < 0 {
return nil, fmt.Errorf("invalid protobuf tag")
}
data = data[n:]
switch wtype {
case protowire.BytesType:
v, vn := protowire.ConsumeBytes(data)
if vn < 0 {
return nil, fmt.Errorf("invalid protobuf bytes")
}
if num == fieldNum {
return v, nil
}
data = data[vn:]
case protowire.VarintType:
_, vn := protowire.ConsumeVarint(data)
if vn < 0 {
return nil, fmt.Errorf("invalid protobuf varint")
}
data = data[vn:]
default:
return nil, fmt.Errorf("unexpected wire type %d", wtype)
}
}
return nil, fmt.Errorf("field %d not found", fieldNum)
}
// proxyCached forwards a request with metadata caching.
func (h *HexHandler) proxyCached(w http.ResponseWriter, r *http.Request) {
cacheKey := strings.TrimPrefix(r.URL.Path, "/")
h.proxy.ProxyCached(w, r, h.upstreamURL+r.URL.Path, "hex", cacheKey, "*/*")
}
// proxyUpstream forwards a request to hex.pm without caching.
func (h *HexHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
h.proxy.ProxyUpstream(w, r, h.upstreamURL+r.URL.Path, []string{"Accept"})
}