package handler import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "net/http" "strconv" "strings" "sync" "time" "github.com/git-pkgs/proxy/internal/database" "github.com/git-pkgs/proxy/internal/metrics" "golang.org/x/mod/module" "golang.org/x/mod/semver" "golang.org/x/mod/sumdb/tlog" ) const ( // defaultGoSumDBName is the checksum database the go command uses when // GOSUMDB is not set. defaultGoSumDBName = "sum.golang.org" defaultGoSumDBURL = "https://sum.golang.org" goEcosystem = "golang" // goSumDBProbeBackoff is how long supported keeps answering 404 after the // proxy failed to reach a checksum database, before it tries again. goSumDBProbeBackoff = time.Minute // goSumDBProbeTimeout is how long supported waits for the database. The // go command's own request has no timeout, so a database that drops // packets must not hold every build for the full upstream timeout. goSumDBProbeTimeout = 5 * time.Second ) // goSumDBProbes remembers when the proxy last failed to reach each checksum // database, so a database that is unreachable does not make every go command // wait for an upstream timeout before it falls back. type goSumDBProbes struct { // timeout overrides goSumDBProbeTimeout when set (tests). timeout time.Duration mu sync.Mutex failed map[string]time.Time succeeded map[string]time.Time } func (p *goSumDBProbes) probeTimeout() time.Duration { if p.timeout > 0 { return p.timeout } return goSumDBProbeTimeout } func (p *goSumDBProbes) recentlyFailed(name string) bool { p.mu.Lock() defer p.mu.Unlock() failedAt, ok := p.failed[name] return ok && time.Since(failedAt) < goSumDBProbeBackoff } func (p *goSumDBProbes) record(name string, reachable bool) { p.mu.Lock() defer p.mu.Unlock() if reachable { delete(p.failed, name) if p.succeeded == nil { p.succeeded = make(map[string]time.Time) } p.succeeded[name] = time.Now() return } if p.failed == nil { p.failed = make(map[string]time.Time) } p.failed[name] = time.Now() } // recordFailure records that the database did not answer a request started // at start, unless a probe has reached it since: a probe that timed out, or // a lookup that fell back to the cache, must not overwrite a newer success. func (p *goSumDBProbes) recordFailure(name string, start time.Time) { p.mu.Lock() defer p.mu.Unlock() if !p.succeeded[name].Before(start) { return } if p.failed == nil { p.failed = make(map[string]time.Time) } p.failed[name] = time.Now() } // SetSumDBs sets the checksum databases served at /sumdb/{name}/, mapping // each database name to its base URL. An empty map keeps the default, // sum.golang.org. offline makes a cached tree of any age enough to keep // answering for a database the proxy cannot reach, for networks where // clients have no route to the database of their own. func (h *GoHandler) SetSumDBs(sumDBs map[string]string, offline bool) { h.sumDBOffline = offline if len(sumDBs) == 0 { h.sumDBs = map[string]string{defaultGoSumDBName: defaultGoSumDBURL} return } h.sumDBs = make(map[string]string, len(sumDBs)) for name, sumDBURL := range sumDBs { h.sumDBs[name] = strings.TrimSuffix(sumDBURL, "/") } } // handleSumDB serves checksum database requests that the go command sends // through GOPROXY as /sumdb/{name}/{path}. The module proxy upstream does not // serve them, so they are fetched from the configured database itself. // Unknown names get 404, which makes the go command contact the database // directly. func (h *GoHandler) handleSumDB(w http.ResponseWriter, r *http.Request, rest string) { name, path, _ := strings.Cut(rest, "/") sumDBURL, ok := h.sumDBs[name] if !ok { http.NotFound(w, r) return } if path == "supported" { if h.sumDBReachable(r, name, sumDBURL) { w.WriteHeader(http.StatusOK) } else { http.NotFound(w, r) } return } immutable, valid := parseGoSumDBPath(path) if !valid { http.NotFound(w, r) return } // Only mirrors and auditors fetch data tiles, never the go command, and // together they hold the whole log, so they are not stored. if tile, err := tlog.ParseTilePath(path); err == nil && tile.L < 0 { h.proxy.proxyMetadataStream(w, r, sumDBURL+"/"+path, "identity", "*/*") return } cacheKey := goSumDBCacheKey(name, sumDBURL, path) check := goSumDBResponseCheck(path) served, damaged := false, false if immutable { served, damaged = h.serveCachedSumDBTile(w, r, cacheKey, check) } if served { return } h.proxySumDB(w, r, name, sumDBURL+"/"+path, cacheKey, check, damaged) } // sumDBReachable reports whether the proxy can serve a checksum database: it // can fetch the latest signed tree, or has it cached. Once supported answers // 200 the go command uses only the proxy for that database and never falls // back to contacting it directly, so a database the proxy cannot reach gets // 404 instead. The probe goes through the same cache entry as a latest // request: with cache_metadata it costs at most one upstream request per // metadata TTL, without it each probe fetches latest. func (h *GoHandler) sumDBReachable(r *http.Request, name, sumDBURL string) bool { cacheKey := goSumDBCacheKey(name, sumDBURL, "latest") treeCheck := goSumDBResponseCheck("latest") if h.sumDBProbes.recentlyFailed(name) { return h.cachedTreeServes(r.Context(), cacheKey, treeCheck) } // The fetch is shared with concurrent callers and runs detached from this // request, so it is bounded by the upstream timeout, not by ours. Wait // for it only up to the probe timeout; it finishes in the background, // records its own outcome and fills the cache for the next probe. start := time.Now() done := make(chan error, 1) go func() { done <- h.probeSumDB(context.WithoutCancel(r.Context()), name, cacheKey, sumDBURL, treeCheck) }() var err error select { case err = <-done: case <-time.After(h.sumDBProbes.probeTimeout()): err = fmt.Errorf("no answer within %s", h.sumDBProbes.probeTimeout()) h.sumDBProbes.recordFailure(name, start) case <-r.Context().Done(): // The client went away; that says nothing about the database. return false } if err == nil { return true } // The stale-cache fallback inside the fetch only runs once upstream has // failed, which a silent database delays past the probe timeout. A tree // cached earlier may still serve lookups, so look for it here. if h.cachedTreeServes(r.Context(), cacheKey, treeCheck) { return true } h.proxy.Logger.Info("checksum database unreachable, sending go clients to it directly", "name", name, "url", sumDBURL, "error", err) return false } // probeSumDB fetches the database's latest tree and records whether the // database answered. A tree still fresh in the cache is enough for supported // but records nothing, since the database was not asked: counting it as an // answer would keep lookups that fail during an outage from being // remembered. A tree served because upstream failed is not an answer. func (h *GoHandler) probeSumDB(ctx context.Context, name, cacheKey, sumDBURL string, check func([]byte) error) error { if entry, _, _, ok := h.readCachedSumDB(ctx, cacheKey, check); ok && h.treeFresh(entry) { return nil } res := h.proxy.lookupMetadata(ctx, goEcosystem, cacheKey, sumDBURL+"/latest", "*/*", "identity", check) err := res.err if err == nil && res.stale { err = errors.New("checksum database did not answer, tree served from cache") } h.sumDBProbes.record(name, err == nil) return err } // cachedTreeServes reports whether a cached tree lets the proxy answer for // the database although the database did not: always with go_sumdbs_offline, // otherwise only while the tree is fresh. func (h *GoHandler) cachedTreeServes(ctx context.Context, cacheKey string, check func([]byte) error) bool { entry, _, _, ok := h.readCachedSumDB(ctx, cacheKey, check) return ok && (h.sumDBOffline || h.treeFresh(entry)) } // treeFresh reports whether a cached tree is within the metadata TTL. func (h *GoHandler) treeFresh(entry *database.MetadataCacheEntry) bool { ttl := h.proxy.MetadataTTL return ttl > 0 && entry.FetchedAt.Valid && time.Since(entry.FetchedAt.Time) < ttl } // readCachedSumDB reads a checksum database response from the metadata // cache regardless of its age. It reports false when nothing usable is // cached. func (h *GoHandler) readCachedSumDB(ctx context.Context, cacheKey string, check func([]byte) error) (entry *database.MetadataCacheEntry, body []byte, contentType string, ok bool) { p := h.proxy if !p.CacheMetadata || p.DB == nil { return nil, nil, "", false } entry, err := p.DB.GetMetadataCache(goEcosystem, cacheKey) if err != nil || entry == nil { return nil, nil, "", false } body, contentType, err = p.readCachedMetadata(ctx, entry, check) if err != nil { return nil, nil, "", false } return entry, body, contentType, true } // parseGoSumDBPath reports whether path is a request the checksum database // protocol defines and whether its response can never change. Only complete // tiles are immutable; partial tiles, latest and lookups follow the metadata // TTL. func parseGoSumDBPath(path string) (immutable, valid bool) { if path == "latest" { return false, true } if mod, ok := strings.CutPrefix(path, "lookup/"); ok { return false, validGoSumDBLookup(mod) } tile, err := tlog.ParseTilePath(path) if err != nil { return false, false } return tile.W == 1< 0: w.Header().Set(headerContentType, "text/plain; charset=utf-8") w.Header().Set("X-Content-Type-Options", "nosniff") w.WriteHeader(http.StatusNotFound) _, _ = w.Write(notFound.body) case errors.Is(err, ErrUpstreamNotFound): http.Error(w, "not found", http.StatusNotFound) default: if writeUpstreamHeld(w, err) { return } p.Logger.Error("checksum database fetch failed", "url", upstreamURL, "error", err) http.Error(w, "failed to fetch from upstream", http.StatusBadGateway) } return } p.writeMetadataCachedResponseWithEncoding(w, r, goEcosystem, cacheKey, res.body, res.contentType, res.contentEncoding) } // fetchSumDB fetches a checksum database response through the metadata // cache. A cached copy that failed check is fetched again without // revalidating it: past the TTL, a database that sends an ETag would answer // 304 and hand back the damaged copy for good. func (h *GoHandler) fetchSumDB(ctx context.Context, upstreamURL, cacheKey string, check func([]byte) error, damaged bool) metadataResult { p := h.proxy if damaged { return p.fetchMetadataFromUpstream(ctx, goEcosystem, cacheKey, upstreamURL, "*/*", "identity", check, nil) } return p.lookupMetadata(ctx, goEcosystem, cacheKey, upstreamURL, "*/*", "identity", check) }