2026-01-20 21:52:44 +00:00
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
2026-07-10 22:59:15 +00:00
|
|
|
"errors"
|
2026-01-20 21:52:44 +00:00
|
|
|
"fmt"
|
|
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
2026-07-10 22:59:15 +00:00
|
|
|
|
2026-10-12 14:04:16 +02:00
|
|
|
"github.com/git-pkgs/proxy/internal/retention"
|
2026-07-10 22:59:15 +00:00
|
|
|
"github.com/git-pkgs/registries/fetch"
|
2026-01-20 21:52:44 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
2026-03-18 10:59:29 +00:00
|
|
|
goUpstream = "https://proxy.golang.org"
|
|
|
|
|
asciiCaseOffset = 32 // difference between lowercase and uppercase ASCII letters
|
2026-01-20 21:52:44 +00:00
|
|
|
)
|
|
|
|
|
|
2026-10-12 14:04:16 +02:00
|
|
|
// Module zips are stored under pkg:golang/{module path}, which the PURL
|
|
|
|
|
// rules lowercase, so a package rule covers a module whatever case either
|
|
|
|
|
// is written in. A major version suffix (/v2) is part of the module path
|
|
|
|
|
// and so a module of its own. A key in the escaped form proxy URLs and the
|
|
|
|
|
// module cache use (github.com/!azure) does not survive the default
|
|
|
|
|
// mapping's round trip and is refused; the handler stores decoded paths,
|
|
|
|
|
// so such a key could never match.
|
|
|
|
|
func init() {
|
|
|
|
|
retention.Default.Register(retention.Spec{Key: "golang", CanonicalPackage: retention.DefaultCanonical("golang")})
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// GoHandler handles Go module proxy protocol requests.
|
|
|
|
|
type GoHandler struct {
|
Add upstream.go_sumdbs_offline for networks where only the proxy has egress
On a build network where clients cannot reach sum.golang.org at all, the
proxy should keep answering for the database during an outage, since a 404
on supported would only send the go command somewhere it cannot go. With
the switch set, a cached tree of any age is enough; without it, the tree
has to be fresh, as before.
That does not make every cached module verify. The go command checks a
lookup against tiles of the tree that came with it, and sum.golang.org
attaches a different tree to almost every lookup, so it can ask for a tile
nobody fetched through the proxy before. configuration.md says so.
While the database is known to be unreachable, offline mode also serves
cached lookups and tiles straight away. Otherwise each of them would sit
through the upstream timeout before the fetch falls back to the cache,
and a build would take minutes for as long as the outage lasts. A lookup
or tile that falls back to the cache renews what the proxy knows about
the database, unless a probe has reached it since the request started.
The go command asks supported only once per process, so a long build would
otherwise be back to waiting a minute into the outage. recordTimeout is
now recordFailure, since lookups use it too.
The option needs cache_metadata, since without it there is no cached tree
to fall back on, and the config refuses the combination.
2026-10-10 17:55:04 +02:00
|
|
|
proxy *Proxy
|
|
|
|
|
upstreamURL string
|
|
|
|
|
proxyURL string
|
|
|
|
|
sumDBs map[string]string
|
|
|
|
|
sumDBOffline bool
|
|
|
|
|
sumDBProbes goSumDBProbes
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewGoHandler creates a new Go module proxy handler.
|
|
|
|
|
func NewGoHandler(proxy *Proxy, proxyURL string) *GoHandler {
|
|
|
|
|
return &GoHandler{
|
|
|
|
|
proxy: proxy,
|
|
|
|
|
upstreamURL: goUpstream,
|
|
|
|
|
proxyURL: strings.TrimSuffix(proxyURL, "/"),
|
2026-10-10 09:57:43 +02:00
|
|
|
sumDBs: map[string]string{defaultGoSumDBName: defaultGoSumDBURL},
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 11:19:54 +01:00
|
|
|
// NewGoHandlerWithUpstream creates a Go module handler with a custom upstream.
|
|
|
|
|
func NewGoHandlerWithUpstream(proxy *Proxy, proxyURL, upstreamURL string) *GoHandler {
|
|
|
|
|
h := NewGoHandler(proxy, proxyURL)
|
|
|
|
|
h.upstreamURL = configuredUpstreamURL(upstreamURL, goUpstream)
|
|
|
|
|
return h
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// Routes returns the HTTP handler for Go proxy requests.
|
|
|
|
|
func (h *GoHandler) Routes() http.Handler {
|
|
|
|
|
// Go module paths can contain slashes, so just use the handler directly
|
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Method != http.MethodGet {
|
|
|
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
h.handleRequest(w, r)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleRequest routes Go proxy requests based on the URL pattern.
|
|
|
|
|
func (h *GoHandler) handleRequest(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
path := strings.TrimPrefix(r.URL.Path, "/")
|
|
|
|
|
|
2026-10-10 09:57:43 +02:00
|
|
|
// Checksum database requests go to the database, not the module upstream
|
|
|
|
|
if rest, ok := strings.CutPrefix(path, "sumdb/"); ok {
|
|
|
|
|
h.handleSumDB(w, r, rest)
|
2026-01-20 21:52:44 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for @v/ pattern to identify module requests
|
|
|
|
|
if idx := strings.Index(path, "/@v/"); idx >= 0 {
|
|
|
|
|
module := path[:idx]
|
|
|
|
|
rest := path[idx+4:] // after "/@v/"
|
|
|
|
|
|
2026-03-19 21:06:02 +00:00
|
|
|
decodedMod := decodeGoModule(module)
|
2026-01-20 21:52:44 +00:00
|
|
|
switch {
|
|
|
|
|
case rest == "list":
|
|
|
|
|
// GET /{module}/@v/list - list versions
|
2026-03-19 21:06:02 +00:00
|
|
|
h.proxyCached(w, r, decodedMod+"/@v/list")
|
2026-01-20 21:52:44 +00:00
|
|
|
|
|
|
|
|
case strings.HasSuffix(rest, ".info"):
|
|
|
|
|
// GET /{module}/@v/{version}.info - version metadata
|
2026-03-19 21:06:02 +00:00
|
|
|
h.proxyCached(w, r, decodedMod+"/@v/"+rest)
|
2026-01-20 21:52:44 +00:00
|
|
|
|
|
|
|
|
case strings.HasSuffix(rest, ".mod"):
|
|
|
|
|
// GET /{module}/@v/{version}.mod - go.mod file
|
2026-03-19 21:06:02 +00:00
|
|
|
h.proxyCached(w, r, decodedMod+"/@v/"+rest)
|
2026-01-20 21:52:44 +00:00
|
|
|
|
|
|
|
|
case strings.HasSuffix(rest, ".zip"):
|
|
|
|
|
// GET /{module}/@v/{version}.zip - source archive (cache this)
|
|
|
|
|
version := strings.TrimSuffix(rest, ".zip")
|
|
|
|
|
h.handleDownload(w, r, module, version)
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for @latest
|
|
|
|
|
if strings.HasSuffix(path, "/@latest") {
|
2026-03-19 21:06:02 +00:00
|
|
|
module := strings.TrimSuffix(path, "/@latest")
|
|
|
|
|
h.proxyCached(w, r, decodeGoModule(module)+"/@latest")
|
2026-01-20 21:52:44 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleDownload serves a module zip, fetching and caching from upstream if needed.
|
|
|
|
|
func (h *GoHandler) handleDownload(w http.ResponseWriter, r *http.Request, module, version string) {
|
|
|
|
|
// Decode module path (! followed by lowercase = uppercase)
|
|
|
|
|
decodedModule := decodeGoModule(module)
|
|
|
|
|
filename := fmt.Sprintf("%s@%s.zip", lastComponent(decodedModule), version)
|
|
|
|
|
|
|
|
|
|
h.proxy.Logger.Info("go module download request",
|
|
|
|
|
"module", decodedModule, "version", version)
|
|
|
|
|
|
2026-08-29 11:19:54 +01:00
|
|
|
downloadURL := h.upstreamURL + r.URL.Path
|
|
|
|
|
result, err := h.proxy.GetOrFetchArtifactFromURL(
|
|
|
|
|
r.Context(), "golang", decodedModule, version, filename, downloadURL,
|
|
|
|
|
)
|
2026-01-20 21:52:44 +00:00
|
|
|
if err != nil {
|
2026-07-10 22:59:15 +00:00
|
|
|
if errors.Is(err, fetch.ErrNotFound) {
|
|
|
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-09-20 14:42:30 +05:30
|
|
|
if errors.Is(err, ErrArtifactBlocked) || errors.Is(err, ErrVersionDenied) {
|
2026-09-03 05:06:44 -04:00
|
|
|
http.Error(w, err.Error(), http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
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("failed to get artifact", "error", err)
|
|
|
|
|
http.Error(w, "failed to fetch module", http.StatusBadGateway)
|
|
|
|
|
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-03-19 21:06:02 +00:00
|
|
|
// proxyCached forwards a request with metadata caching.
|
|
|
|
|
func (h *GoHandler) proxyCached(w http.ResponseWriter, r *http.Request, cacheKey string) {
|
|
|
|
|
h.proxy.ProxyCached(w, r, h.upstreamURL+r.URL.Path, "golang", cacheKey, "*/*")
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// decodeGoModule decodes an encoded module path.
|
|
|
|
|
// In the encoding, uppercase letters are represented as "!" followed by lowercase.
|
|
|
|
|
func decodeGoModule(encoded string) string {
|
|
|
|
|
var b strings.Builder
|
|
|
|
|
for i := 0; i < len(encoded); i++ {
|
|
|
|
|
if encoded[i] == '!' && i+1 < len(encoded) {
|
2026-03-18 10:59:29 +00:00
|
|
|
b.WriteByte(encoded[i+1] - asciiCaseOffset) // lowercase to uppercase
|
2026-01-20 21:52:44 +00:00
|
|
|
i++
|
|
|
|
|
} else {
|
|
|
|
|
b.WriteByte(encoded[i])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return b.String()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// lastComponent returns the last path component of a module path.
|
|
|
|
|
func lastComponent(path string) string {
|
|
|
|
|
if idx := strings.LastIndex(path, "/"); idx >= 0 {
|
|
|
|
|
return path[idx+1:]
|
|
|
|
|
}
|
|
|
|
|
return path
|
|
|
|
|
}
|