2026-01-20 21:52:44 +00:00
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
2026-10-11 18:01:15 +02:00
|
|
|
|
|
|
|
|
"github.com/git-pkgs/proxy/internal/retention"
|
2026-01-20 21:52:44 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
conanUpstream = "https://center.conan.io"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-11 18:01:15 +02:00
|
|
|
// Recipe and package archives are stored under pkg:conan/{name}, with the
|
|
|
|
|
// version as the version and the revisions and any user/channel in the
|
|
|
|
|
// filename, and purl keeps conan names as they are. A package rule therefore
|
|
|
|
|
// covers every version, revision and user/channel of a recipe, and uses the
|
|
|
|
|
// default key mapping.
|
|
|
|
|
func init() {
|
|
|
|
|
retention.Default.Register(retention.Spec{Key: "conan", CanonicalPackage: retention.DefaultCanonical("conan")})
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// ConanHandler handles Conan registry protocol requests.
|
|
|
|
|
type ConanHandler struct {
|
|
|
|
|
proxy *Proxy
|
|
|
|
|
upstreamURL string
|
|
|
|
|
proxyURL string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewConanHandler creates a new Conan protocol handler.
|
|
|
|
|
func NewConanHandler(proxy *Proxy, proxyURL string) *ConanHandler {
|
|
|
|
|
return &ConanHandler{
|
|
|
|
|
proxy: proxy,
|
|
|
|
|
upstreamURL: conanUpstream,
|
|
|
|
|
proxyURL: strings.TrimSuffix(proxyURL, "/"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 11:19:54 +01:00
|
|
|
// NewConanHandlerWithUpstream creates a Conan handler with a custom upstream.
|
|
|
|
|
func NewConanHandlerWithUpstream(proxy *Proxy, proxyURL, upstreamURL string) *ConanHandler {
|
|
|
|
|
h := NewConanHandler(proxy, proxyURL)
|
|
|
|
|
h.upstreamURL = configuredUpstreamURL(upstreamURL, conanUpstream)
|
|
|
|
|
return h
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// Routes returns the HTTP handler for Conan requests.
|
|
|
|
|
func (h *ConanHandler) Routes() http.Handler {
|
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
|
|
|
|
|
// Ping endpoint
|
|
|
|
|
mux.HandleFunc("GET /v1/ping", h.handlePing)
|
|
|
|
|
mux.HandleFunc("GET /v2/ping", h.handlePing)
|
|
|
|
|
|
2026-10-11 17:56:15 +02:00
|
|
|
// Recipe and package file downloads, as built by the Conan 2 client
|
|
|
|
|
// (ClientV2Router.recipe_file and package_file). The v1 protocol hands out
|
|
|
|
|
// signed absolute URLs on the upstream host, so its downloads never pass
|
|
|
|
|
// through here.
|
|
|
|
|
mux.HandleFunc("GET /v2/conans/{name}/{version}/{user}/{channel}/revisions/{revision}/files/{filename}", h.handleRecipeFile)
|
|
|
|
|
mux.HandleFunc("GET /v2/conans/{name}/{version}/{user}/{channel}/revisions/{revision}/packages/{pkgref}/revisions/{pkgrev}/files/{filename}", h.handlePackageFile)
|
2026-01-20 21:52:44 +00:00
|
|
|
|
2026-03-19 21:06:02 +00:00
|
|
|
// Proxy all other endpoints (metadata, search, etc.) with caching
|
|
|
|
|
mux.HandleFunc("GET /", h.proxyCached)
|
2026-01-20 21:52:44 +00:00
|
|
|
|
|
|
|
|
return mux
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handlePing responds to Conan ping requests.
|
|
|
|
|
func (h *ConanHandler) handlePing(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
w.Header().Set("X-Conan-Server-Capabilities", "revisions")
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleRecipeFile serves a recipe file, fetching and caching from upstream if needed.
|
|
|
|
|
func (h *ConanHandler) handleRecipeFile(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
name := r.PathValue("name")
|
|
|
|
|
version := r.PathValue("version")
|
|
|
|
|
user := r.PathValue("user")
|
|
|
|
|
channel := r.PathValue("channel")
|
|
|
|
|
revision := r.PathValue("revision")
|
|
|
|
|
filename := r.PathValue("filename")
|
|
|
|
|
|
2026-10-11 17:56:15 +02:00
|
|
|
// Only the archives are artifacts; the rest goes through the metadata cache.
|
2026-01-20 21:52:44 +00:00
|
|
|
if !h.shouldCacheFile(filename) {
|
2026-10-11 17:56:15 +02:00
|
|
|
h.proxyCached(w, r)
|
2026-01-20 21:52:44 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h.proxy.Logger.Info("conan recipe download",
|
|
|
|
|
"name", name, "version", version, "user", user, "channel", channel, "filename", filename)
|
|
|
|
|
|
|
|
|
|
upstreamURL := h.upstreamURL + r.URL.Path
|
2026-10-11 17:56:15 +02:00
|
|
|
storageFilename := conanStorageFilename(user, channel, revision+"_"+filename)
|
2026-01-20 21:52:44 +00:00
|
|
|
|
2026-10-11 17:56:15 +02:00
|
|
|
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conan", name, version, storageFilename, upstreamURL)
|
2026-01-20 21:52:44 +00:00
|
|
|
if err != nil {
|
2026-07-26 20:07:46 +02:00
|
|
|
h.proxy.serveArtifactError(w, err, "failed to fetch file")
|
2026-01-20 21:52:44 +00:00
|
|
|
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
|
|
|
ServeArtifactRequest(w, r, result)
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handlePackageFile serves a package file, fetching and caching from upstream if needed.
|
|
|
|
|
func (h *ConanHandler) handlePackageFile(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
name := r.PathValue("name")
|
|
|
|
|
version := r.PathValue("version")
|
|
|
|
|
user := r.PathValue("user")
|
|
|
|
|
channel := r.PathValue("channel")
|
|
|
|
|
revision := r.PathValue("revision")
|
|
|
|
|
pkgref := r.PathValue("pkgref")
|
|
|
|
|
pkgrev := r.PathValue("pkgrev")
|
|
|
|
|
filename := r.PathValue("filename")
|
|
|
|
|
|
2026-10-11 17:56:15 +02:00
|
|
|
// Only the archives are artifacts; the rest goes through the metadata cache.
|
2026-01-20 21:52:44 +00:00
|
|
|
if !h.shouldCacheFile(filename) {
|
2026-10-11 17:56:15 +02:00
|
|
|
h.proxyCached(w, r)
|
2026-01-20 21:52:44 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h.proxy.Logger.Info("conan package download",
|
|
|
|
|
"name", name, "version", version, "pkgref", pkgref, "filename", filename)
|
|
|
|
|
|
|
|
|
|
upstreamURL := h.upstreamURL + r.URL.Path
|
2026-10-11 17:56:15 +02:00
|
|
|
storageFilename := conanStorageFilename(user, channel, revision+"_"+pkgref+"_"+pkgrev+"_"+filename)
|
2026-01-20 21:52:44 +00:00
|
|
|
|
2026-10-11 17:56:15 +02:00
|
|
|
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conan", name, version, storageFilename, upstreamURL)
|
2026-01-20 21:52:44 +00:00
|
|
|
if err != nil {
|
2026-07-26 20:07:46 +02:00
|
|
|
h.proxy.serveArtifactError(w, err, "failed to fetch file")
|
2026-01-20 21:52:44 +00:00
|
|
|
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
|
|
|
ServeArtifactRequest(w, r, result)
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-10-11 17:56:15 +02:00
|
|
|
// conanStorageFilename names a cached archive. Archives are stored under
|
|
|
|
|
// pkg:conan/{name}@{version}, so the file name carries the revisions and, when
|
|
|
|
|
// set, user and channel, to keep every recipe and binary apart.
|
|
|
|
|
func conanStorageFilename(user, channel, file string) string {
|
|
|
|
|
if user == "_" && channel == "_" {
|
|
|
|
|
return file
|
|
|
|
|
}
|
|
|
|
|
return user + "@" + channel + "_" + file
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// shouldCacheFile returns true if the file should be cached.
|
|
|
|
|
func (h *ConanHandler) shouldCacheFile(filename string) bool {
|
2026-10-11 17:56:15 +02:00
|
|
|
// Cache the large archive files. Since Conan 2.25 they can also be xz or
|
|
|
|
|
// zstd (core.upload:compression_format).
|
2026-01-20 21:52:44 +00:00
|
|
|
cacheFiles := []string{
|
2026-10-11 17:56:15 +02:00
|
|
|
"conan_sources.tgz", "conan_sources.txz", "conan_sources.tzst",
|
|
|
|
|
"conan_export.tgz", "conan_export.txz", "conan_export.tzst",
|
|
|
|
|
"conan_package.tgz", "conan_package.txz", "conan_package.tzst",
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, f := range cacheFiles {
|
|
|
|
|
if filename == f {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 21:06:02 +00:00
|
|
|
// proxyCached forwards a request with metadata caching.
|
|
|
|
|
func (h *ConanHandler) proxyCached(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
cacheKey := strings.TrimPrefix(r.URL.Path, "/")
|
|
|
|
|
cacheKey = strings.ReplaceAll(cacheKey, "/", "_")
|
|
|
|
|
if r.URL.RawQuery != "" {
|
|
|
|
|
cacheKey += "_" + r.URL.RawQuery
|
|
|
|
|
}
|
|
|
|
|
upstreamURL := h.upstreamURL + r.URL.Path
|
|
|
|
|
if r.URL.RawQuery != "" {
|
|
|
|
|
upstreamURL += "?" + r.URL.RawQuery
|
|
|
|
|
}
|
|
|
|
|
h.proxy.ProxyCached(w, r, upstreamURL, "conan", cacheKey, "*/*")
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// proxyUpstream forwards a request to conan center without caching.
|
|
|
|
|
func (h *ConanHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
upstreamURL := h.upstreamURL + r.URL.Path
|
|
|
|
|
if r.URL.RawQuery != "" {
|
|
|
|
|
upstreamURL += "?" + r.URL.RawQuery
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h.proxy.Logger.Debug("proxying to upstream", "url", upstreamURL)
|
|
|
|
|
|
|
|
|
|
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "failed to create request", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Copy authorization header for authenticated requests
|
|
|
|
|
if auth := r.Header.Get("Authorization"); auth != "" {
|
|
|
|
|
req.Header.Set("Authorization", auth)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 07:44:11 +00:00
|
|
|
resp, err := h.proxy.HTTPClient.Do(req)
|
2026-01-20 21:52:44 +00:00
|
|
|
if err != nil {
|
2026-10-15 13:35:12 +01:00
|
|
|
if writeUpstreamHeld(w, err) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
h.proxy.Logger.Error("upstream request failed", "error", err)
|
|
|
|
|
http.Error(w, "upstream request failed", http.StatusBadGateway)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
|
|
2026-09-26 15:44:28 +05:30
|
|
|
h.proxy.relayResponse(w, r, resp, nil)
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|