diff --git a/README.md b/README.md index 2a08eaac..556e6767 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ A 3-day cooldown means that when `lodash` publishes version `4.18.0`, your build Resolution order: package override, then ecosystem override, then global default. This lets you set a conservative default and carve out exceptions for packages where you need faster updates. See [docs/configuration.md](docs/configuration.md) for the full config reference. +One instance can offer several cooldown policies through [named profiles](docs/configuration.md#cooldown-profiles). Configure `cooldown.profiles` and point clients at paths such as `/cooldown/3d/npm/` or `/cooldown/7d/cargo/`. Profiles share the package cache; existing URLs keep using the global policy. + ## Artifact Scanning Cooldown only looks at a version's publish timestamp — it never inspects the actual bytes. Artifact scanning closes that gap: when enabled, every artifact is staged into storage and scanned by one or more external services (trivy, ClamAV, Wiz, or anything else that speaks a small HTTP/JSON contract) before it's committed to the cache and served to clients. diff --git a/config.example.yaml b/config.example.yaml index e43e4346..c8ab11b1 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -482,6 +482,13 @@ health: # to spot malicious releases before they're pulled into projects. # Supported durations: "7d" (days), "48h" (hours), "30m" (minutes), "0" (disabled) cooldown: + # Independent policies selected through /cooldown/{name}/{registry}/. + # profiles: + # 3d: + # default: "3d" + # 7d: + # default: "7d" + # Global default cooldown for all ecosystems # default: "3d" diff --git a/docs/configuration.md b/docs/configuration.md index 38765e08..63cc175d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1148,6 +1148,37 @@ Uploads are off by default, so a client that can reach the proxy cannot overwrit `max_age` and `max_size` are independent and can be combined. When both are set, age-based eviction runs first, then size-based eviction trims remaining entries oldest-first. +## Cooldown profiles + +Set `cooldown.profiles` to offer several policies from one instance. Clients select a profile through their package manager's registry URL: + +```yaml +cooldown: + default: "0" + profiles: + 3d: + default: "3d" + 7d: + default: "7d" + packages: + "pkg:npm/@example/tool": "0" +``` + +For the `3d` profile, use these paths after your proxy's base URL: + +| Client | Path | +|--------|------| +| npm registry | `/cooldown/3d/npm/` | +| Go `GOPROXY` | `/cooldown/3d/go` | +| Cargo registry | `sparse+https://your-proxy.example/cooldown/3d/cargo/` | +| Bundler RubyGems mirror | `/cooldown/3d/gem` | + +The prefix works with all package protocol routes. Cooldown has an effect only for [registries with cooldown support](../README.md#supported-registries). Each profile defines its own `default`, `ecosystems`, `packages` and `package_patterns`; it does not inherit the global cooldown overrides. Existing unprefixed URLs, the UI, APIs and background mirror jobs use the global policy. + +Profile names contain lowercase letters, digits, hyphens and underscores, and start with a letter or digit. Profiles must be configured before startup; an unknown profile returns 404. Nested profiles are rejected. Profile paths expose package protocols only, so `/cooldown/3d/ui/` and `/cooldown/3d/api/` return 404. + +Database records, artifacts and upstream metadata are shared. Generated download URLs retain the profile prefix, and filtered metadata and its validators are separated by profile. Changing a registry URL does not rewrite artifact URLs already recorded in a lockfile; regenerate those URLs if the selected policy must cover the whole installation. + ## Cooldown The cooldown feature hides package versions published too recently, giving the community time to spot malicious releases before they reach your projects. When a version is within its cooldown period, it's stripped from metadata responses so package managers won't install it. diff --git a/internal/config/config.go b/internal/config/config.go index 68c578f3..3b5c6cb0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -237,6 +237,9 @@ type Config struct { // CooldownConfig configures version cooldown periods. // Versions published more recently than the cooldown are hidden from metadata responses. type CooldownConfig struct { + // Profiles are independent policies exposed at /cooldown/{name}/. + Profiles map[string]CooldownConfig `json:"profiles,omitempty" yaml:"profiles,omitempty"` + // Default is the global default cooldown (e.g., "3d", "48h", "0" to disable). Default string `json:"default" yaml:"default"` @@ -1665,6 +1668,10 @@ func (c *Config) validateCacheArtifacts() error { } func (c *Config) validateComponents() error { + if err := c.Cooldown.ValidateProfiles(); err != nil { + return err + } + if err := validateMetadataRewriteCacheSize(c.MetadataRewriteCacheSize); err != nil { return err } diff --git a/internal/config/cooldown_profiles.go b/internal/config/cooldown_profiles.go new file mode 100644 index 00000000..225dd201 --- /dev/null +++ b/internal/config/cooldown_profiles.go @@ -0,0 +1,64 @@ +package config + +import ( + "fmt" + "maps" + "regexp" + "slices" + + "github.com/git-pkgs/cooldown" +) + +var cooldownProfileName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) + +// Policy builds the policy for this URL's cooldown settings. +func (c *CooldownConfig) Policy() (*cooldown.Policy, error) { + return cooldown.NewPolicy(&cooldown.Config{ + Default: c.Default, Ecosystems: c.Ecosystems, Packages: c.NormalizedPackages(), + }, c.PackagePatterns) +} + +// ValidateProfiles checks names and policies before they become routes. +func (c *CooldownConfig) ValidateProfiles() error { + for _, name := range slices.Sorted(maps.Keys(c.Profiles)) { + profile := c.Profiles[name] + if !cooldownProfileName.MatchString(name) { + return fmt.Errorf("invalid cooldown profile name %q: use lowercase letters, digits, hyphens or underscores", name) + } + if len(profile.Profiles) > 0 { + return fmt.Errorf("cooldown.profiles.%s cannot contain profiles", name) + } + if err := profile.validateProfileDurations(); err != nil { + return fmt.Errorf("cooldown.profiles.%s: %w", name, err) + } + if _, err := profile.Policy(); err != nil { + return fmt.Errorf("cooldown.profiles.%s: %w", name, err) + } + } + return nil +} + +func (c *CooldownConfig) validateProfileDurations() error { + values := map[string]string{"default": c.Default} + for group, overrides := range map[string]map[string]string{ + "ecosystems": c.Ecosystems, "packages": c.Packages, "package_patterns": c.PackagePatterns, + } { + for key, value := range overrides { + values[group+"."+key] = value + } + } + for _, key := range slices.Sorted(maps.Keys(values)) { + value := values[key] + if key == "default" && value == "" { + continue + } + duration, err := cooldown.ParseDuration(value) + if err != nil { + return fmt.Errorf("invalid %s: %w", key, err) + } + if duration < 0 { + return fmt.Errorf("invalid %s: duration must be non-negative", key) + } + } + return nil +} diff --git a/internal/config/cooldown_profiles_test.go b/internal/config/cooldown_profiles_test.go new file mode 100644 index 00000000..6f10362d --- /dev/null +++ b/internal/config/cooldown_profiles_test.go @@ -0,0 +1,81 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestLoadCooldownProfiles(t *testing.T) { + filename := filepath.Join(t.TempDir(), "config.yaml") + data := `cooldown: + default: 7d + ecosystems: + npm: "0" + profiles: + 3d: + default: 3d + packages: + "pkg:pypi/Django": "0" + strict: + default: 14d + package_patterns: + "pkg:npm/@example/*": 1d +` + if err := os.WriteFile(filename, []byte(data), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(filename) + if err != nil { + t.Fatal(err) + } + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + profile := cfg.Cooldown.Profiles["3d"] + policy, err := profile.Policy() + if err != nil { + t.Fatal(err) + } + if got := policy.For("npm", "pkg:npm/demo"); got != 72*time.Hour { + t.Fatalf("profile inherited global ecosystem override: %v", got) + } + if got := policy.For("pypi", "pkg:pypi/django"); got != 0 { + t.Fatalf("canonical package override = %v", got) + } + strict := cfg.Cooldown.Profiles["strict"] + policy, err = strict.Policy() + if err != nil { + t.Fatal(err) + } + if got := policy.For("npm", "pkg:npm/%40example/tool"); got != 24*time.Hour { + t.Fatalf("profile pattern = %v", got) + } +} + +func TestLoadRejectsInvalidCooldownProfiles(t *testing.T) { + for _, tc := range []struct{ name, profile, want string }{ + {"path", `"a/b": {default: 3d}`, "profile name"}, + {"uppercase", `Strict: {default: 3d}`, "profile name"}, + {"empty", `"": {default: 3d}`, "profile name"}, + {"duration", `strict: {default: nope}`, "cooldown.profiles.strict"}, + {"pattern", `strict: {package_patterns: {"pkg:npm/[": 3d}}`, "cooldown.profiles.strict"}, + {"nested", `strict: {profiles: {nested: {default: 3d}}}`, "cannot contain profiles"}, + } { + t.Run(tc.name, func(t *testing.T) { + filename := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(filename, []byte("cooldown:\n profiles:\n "+tc.profile+"\n"), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(filename) + if err == nil { + err = cfg.Validate() + } + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("Load error = %v, want %q", err, tc.want) + } + }) + } +} diff --git a/internal/handler/download_cooldown.go b/internal/handler/download_cooldown.go index c491ef3d..4ede9a8f 100644 --- a/internal/handler/download_cooldown.go +++ b/internal/handler/download_cooldown.go @@ -103,7 +103,7 @@ type publishIndexEntry struct { // publishIndex reuses parsed documents by digest, or for a minute without one. func publishIndex[V any](p *Proxy, key string, load func() metadataResult, build func(metadataResult) (V, error)) (V, error) { //nolint:ireturn // Supports timestamp maps and Helm lookups. - c := &p.publishIndexes + c := &p.sharedProxy().publishIndexes fresh := func() (V, bool) { c.mu.Lock() defer c.mu.Unlock() diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 125b1174..d1885941 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -304,6 +304,7 @@ type Proxy struct { // cooldownWatch holds the rewrites recording which versions cooldown // holds back; see cooldown_expiry.go. cooldownWatch cooldownWatch + shared *Proxy } // cooldownEcosystems are the ecosystems whose handlers apply cooldown. Others @@ -946,18 +947,19 @@ type inflightFetch struct { // proxy_cache_misses_total and upstream fetch observations is what coalescing // saved. func (p *Proxy) coalesceFetch(ctx context.Context, key string, recheck func() (artifacts.Artifact, string, bool), commit func(context.Context) (artifacts.Artifact, string, error)) (*CacheResult, error) { - p.fetchMu.Lock() - if p.inFlight == nil { - p.inFlight = make(map[string]*inflightFetch) + shared := p.sharedProxy() + shared.fetchMu.Lock() + if shared.inFlight == nil { + shared.inFlight = make(map[string]*inflightFetch) } - f, joined := p.inFlight[key] + f, joined := shared.inFlight[key] if joined { f.waiters++ } else { f = &inflightFetch{done: make(chan struct{})} - p.inFlight[key] = f + shared.inFlight[key] = f } - p.fetchMu.Unlock() + shared.fetchMu.Unlock() if !joined { if streamsCacheMiss(ctx) { @@ -985,28 +987,30 @@ func (p *Proxy) coalesceFetch(ctx context.Context, key string, recheck func() (a // leaveFetch withdraws a waiter that gave up. Once the fetch has finished the // waiter was counted as holding any unrecorded object, so it releases it. func (p *Proxy) leaveFetch(f *inflightFetch) { - p.fetchMu.Lock() + shared := p.sharedProxy() + shared.fetchMu.Lock() select { case <-f.done: - p.fetchMu.Unlock() + shared.fetchMu.Unlock() if f.unrecorded != nil { f.unrecorded.release() } default: f.waiters-- - p.fetchMu.Unlock() + shared.fetchMu.Unlock() } } // runSharedFetch performs the fetch that joined callers are waiting on. It is // never abandoned early, and always releases the key and wakes the waiters. func (p *Proxy) runSharedFetch(ctx context.Context, key string, f *inflightFetch, recheck func() (artifacts.Artifact, string, bool), commit func(context.Context) (artifacts.Artifact, string, error)) (*CacheResult, error) { + shared := p.sharedProxy() // Set before running so a panicking commit leaves waiters with an error // rather than a zero-valued artifact. f.err = errSharedFetchAbandoned defer func() { - p.fetchMu.Lock() - delete(p.inFlight, key) + shared.fetchMu.Lock() + delete(shared.inFlight, key) if f.unrecorded != nil { // Hand each waiter a hold, then drop the one kept while the // waiters were still unknown. @@ -1014,7 +1018,7 @@ func (p *Proxy) runSharedFetch(ctx context.Context, key string, f *inflightFetch defer f.unrecorded.release() } close(f.done) - p.fetchMu.Unlock() + shared.fetchMu.Unlock() }() // A caller checks the cache before reaching here, so a fetch that finished @@ -1162,12 +1166,13 @@ func upstreamFetchDetached(ctx context.Context) bool { const maxDetachedFetches = 64 func (p *Proxy) acquireDetachSlot() bool { - p.fetchMu.Lock() - if p.detachSlots == nil { - p.detachSlots = make(chan struct{}, maxDetachedFetches) + shared := p.sharedProxy() + shared.fetchMu.Lock() + if shared.detachSlots == nil { + shared.detachSlots = make(chan struct{}, maxDetachedFetches) } - slots := p.detachSlots - p.fetchMu.Unlock() + slots := shared.detachSlots + shared.fetchMu.Unlock() select { case slots <- struct{}{}: @@ -1178,7 +1183,8 @@ func (p *Proxy) acquireDetachSlot() bool { } func (p *Proxy) releaseDetachSlot() { - <-p.detachSlots + shared := p.sharedProxy() + <-shared.detachSlots } // fetchedArtifact is what a shared fetch hands its callers: metadata and a @@ -1877,18 +1883,19 @@ func metadataCoalesceKey(ecosystem, cacheKey, upstreamURL, accept, acceptEncodin // fail when the first of them disconnects. It stays bounded by the HTTP // client's timeout. Waiters still leave when their own context ends. func (p *Proxy) coalesceMetadata(ctx context.Context, key string, fetch func(context.Context) metadataResult) (metadataResult, bool) { - p.metaMu.Lock() - if p.inFlightMeta == nil { - p.inFlightMeta = make(map[string]*inflightMetadata) + shared := p.sharedProxy() + shared.metaMu.Lock() + if shared.inFlightMeta == nil { + shared.inFlightMeta = make(map[string]*inflightMetadata) } - f, joined := p.inFlightMeta[key] + f, joined := shared.inFlightMeta[key] if joined { f.waiters++ } else { f = &inflightMetadata{done: make(chan struct{})} - p.inFlightMeta[key] = f + shared.inFlightMeta[key] = f } - p.metaMu.Unlock() + shared.metaMu.Unlock() if joined { select { @@ -1903,9 +1910,9 @@ func (p *Proxy) coalesceMetadata(ctx context.Context, key string, fetch func(con // rather than an empty body. f.res = metadataResult{err: errSharedFetchAbandoned} defer func() { - p.metaMu.Lock() - delete(p.inFlightMeta, key) - p.metaMu.Unlock() + shared.metaMu.Lock() + delete(shared.inFlightMeta, key) + shared.metaMu.Unlock() close(f.done) }() f.res = fetch(context.WithoutCancel(ctx)) diff --git a/internal/handler/profile.go b/internal/handler/profile.go new file mode 100644 index 00000000..233e7bbb --- /dev/null +++ b/internal/handler/profile.go @@ -0,0 +1,30 @@ +package handler + +import "time" + +// WithCooldown creates a policy view sharing storage, fetches and rewrite space. +// Configure the original proxy before constructing its views. +func (p *Proxy) WithCooldown(policy CooldownPolicy, shortest time.Duration) *Proxy { + return &Proxy{ + DB: p.DB, Storage: p.Storage, Fetcher: p.Fetcher, Resolver: p.Resolver, + RouteFor: p.RouteFor, Logger: p.Logger, + Cooldown: policy, ShortestCooldown: shortest, Denylist: p.Denylist, + CacheMetadata: p.CacheMetadata, MetadataTTL: p.MetadataTTL, + MetadataMaxSize: p.MetadataMaxSize, MetadataStreamMaxSize: p.MetadataStreamMaxSize, + GradleReadOnly: p.GradleReadOnly, HostedEnabled: p.HostedEnabled, + GradleMaxUploadSize: p.GradleMaxUploadSize, NPMFullMetadata: p.NPMFullMetadata, + DirectServe: p.DirectServe, DirectServeTTL: p.DirectServeTTL, + DirectServeBaseURL: p.DirectServeBaseURL, HTTPClient: p.HTTPClient, + RateLimits: p.RateLimits, HostedNPMScopes: p.HostedNPMScopes, + HostedComposerVendors: p.HostedComposerVendors, StreamArtifacts: p.StreamArtifacts, + Scanners: p.Scanners, ScanSigningKey: p.ScanSigningKey, ScanFetchBaseURL: p.ScanFetchBaseURL, + rewrites: p.rewrites, now: p.now, started: p.started, shared: p.sharedProxy(), + } +} + +func (p *Proxy) sharedProxy() *Proxy { + if p.shared != nil { + return p.shared + } + return p +} diff --git a/internal/handler/profile_test.go b/internal/handler/profile_test.go new file mode 100644 index 00000000..5caf7b86 --- /dev/null +++ b/internal/handler/profile_test.go @@ -0,0 +1,234 @@ +package handler + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/git-pkgs/cooldown" + "github.com/git-pkgs/proxy/internal/denylist" + "github.com/git-pkgs/proxy/internal/scanner" + registryclient "github.com/git-pkgs/registries/client" +) + +func TestWithCooldownCopiesProxyFields(t *testing.T) { + p, _, _, _ := setupTestProxy(t) + p.RouteFor = func(string, string) *ArtifactRoute { return nil } + p.Cooldown = profilePolicy(t, "1d") + p.ShortestCooldown = 24 * time.Hour + p.Denylist = &denylist.Policy{} + p.CacheMetadata = true + p.MetadataTTL = time.Hour + p.MetadataMaxSize = 1024 + p.MetadataStreamMaxSize = 2048 + p.GradleReadOnly = true + p.HostedEnabled = true + p.GradleMaxUploadSize = 4096 + p.NPMFullMetadata = true + p.DirectServe = true + p.DirectServeTTL = time.Minute + p.DirectServeBaseURL = "https://storage.example.com" + p.RateLimits = registryclient.NewRateLimitGate(http.DefaultTransport) + p.HostedNPMScopes = []string{"@example"} + p.HostedComposerVendors = []string{"example"} + p.StreamArtifacts = true + p.Scanners = &scanner.Group{} + p.ScanSigningKey = []byte("signing-key") + p.ScanFetchBaseURL = "http://scanner.example.com" + p.SetMetadataRewriteCacheSize(1024) + p.inFlight = map[string]*inflightFetch{"artifact": {}} + p.detachSlots = make(chan struct{}, 1) + p.inFlightMeta = map[string]*inflightMetadata{"metadata": {}} + p.publishIndexes.entries = map[string]publishIndexEntry{"document": {}} + p.cooldownWatch.active = map[*cooldownRecorder]struct{}{{}: {}} + + policy := profilePolicy(t, "3d") + view := p.WithCooldown(policy, 72*time.Hour) + if view.Cooldown != policy || view.ShortestCooldown != 72*time.Hour || view.shared != p { + t.Fatal("view did not select its policy and shared proxy") + } + original, copied := reflect.ValueOf(p).Elem(), reflect.ValueOf(view).Elem() + for i := range original.NumField() { + name := original.Type().Field(i).Name + before, after := original.Field(i), copied.Field(i) + switch name { + case "Cooldown", "ShortestCooldown", "shared": + continue + case "fetchMu", "inFlight", "detachSlots", "metaMu", "inFlightMeta", "publishIndexes", "cooldownWatch": + if !after.IsZero() { + t.Errorf("view field %s is not empty", name) + } + continue + } + if before.IsZero() { + t.Errorf("populate Proxy.%s to check whether WithCooldown copies it", name) + continue + } + switch before.Kind() { + case reflect.Func, reflect.Slice: + if before.Pointer() != after.Pointer() { + t.Errorf("WithCooldown did not copy %s", name) + } + if before.Kind() == reflect.Slice && (before.Len() != after.Len() || before.Cap() != after.Cap()) { + t.Errorf("WithCooldown changed slice bounds for %s", name) + } + default: + if !before.Equal(after) { + t.Errorf("WithCooldown did not copy %s", name) + } + } + } +} + +func TestProfilesShareDownloadPublishIndexes(t *testing.T) { + for _, c := range downloadCooldownCases() { + if c.ecosystem != "conda" && c.ecosystem != "helm" { + continue + } + t.Run(c.ecosystem, func(t *testing.T) { + var calls atomic.Int64 + times := map[string]time.Time{cooldownRecentVersion: time.Now().Add(-4 * 24 * time.Hour)} + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + c.metadata(w, r, times) + })) + t.Cleanup(upstream.Close) + p, _, _, fetcher := setupTestProxy(t) + p.HTTPClient = upstream.Client() + p.Cooldown = profilePolicy(t, "1d") + fetcher.fetchErr = errors.New("artifact must be served from the cache") + c.seedCachedArtifact(t, p, cooldownRecentVersion) + permissive := p.WithCooldown(profilePolicy(t, "3d"), 72*time.Hour) + strict := p.WithCooldown(profilePolicy(t, "7d"), 168*time.Hour) + nested := permissive.WithCooldown(profilePolicy(t, "5d"), 120*time.Hour) + for _, request := range []struct { + proxy *Proxy + want int + }{ + {permissive, http.StatusOK}, + {strict, http.StatusNotFound}, + {p, http.StatusOK}, + {nested, http.StatusNotFound}, + } { + w := serveGet(t, c.handler(request.proxy, upstream.URL), c.path(cooldownRecentVersion)) + wantStatus(t, w, request.want, "") + } + if n := calls.Load(); n != 1 { + t.Errorf("index downloads across profiles = %d, want 1", n) + } + if n := len(p.publishIndexes.entries); n != 1 { + t.Errorf("shared publish indexes = %d, want 1", n) + } + if fetcher.fetchCalled { + t.Error("cached artifact was fetched again") + } + }) + } +} + +func profilePolicy(t *testing.T, duration string) *cooldown.Policy { + t.Helper() + policy, err := cooldown.NewPolicy(&cooldown.Config{Default: duration}, nil) + if err != nil { + t.Fatal(err) + } + return policy +} + +func TestProfilesCoalesceUncachedMetadata(t *testing.T) { + u := newMetadataUpstream(t, http.StatusOK) + p := metadataTestProxy(t, u) + views := []*Proxy{p.WithCooldown(profilePolicy(t, "3d"), 72*time.Hour), p.WithCooldown(profilePolicy(t, "7d"), 168*time.Hour)} + first := startMetadataCalls(views[0], context.Background(), 1, u.URL, contentTypeJSON) + <-u.entered + second := startMetadataCalls(views[1], context.Background(), 1, u.URL, contentTypeJSON) + key := metadataCoalesceKey("npm", "left-pad", u.URL, contentTypeJSON, "", false) + var release sync.Once + t.Cleanup(func() { release.Do(func() { close(u.release) }) }) + waitForMetadataWaiters(t, p, key, 1) + release.Do(func() { close(u.release) }) + for _, calls := range [][]chan metadataCall{first, second} { + result := <-calls[0] + if result.err != nil || !strings.Contains(string(result.body), "accept") { + t.Fatalf("metadata = %s, error %v", result.body, result.err) + } + } + if n := u.calls.Load(); n != 1 { + t.Fatalf("upstream requests = %d, want 1", n) + } +} + +func TestProfilesCoalesceArtifactRequests(t *testing.T) { + proxy, db, _, _ := setupTestProxy(t) + if err := db.SetVersionPublishedAt("pkg:cargo/demo@1.0.0", "pkg:cargo/demo", time.Now().Add(-10*24*time.Hour)); err != nil { + t.Fatal(err) + } + fetcher := &countingFetcher{content: "crate bytes", delay: fetchHoldTime} + proxy.Fetcher = fetcher + views := []*Proxy{proxy.WithCooldown(profilePolicy(t, "3d"), 72*time.Hour), proxy.WithCooldown(profilePolicy(t, "7d"), 168*time.Hour)} + var wg sync.WaitGroup + start := make(chan struct{}) + for range 4 { + for _, view := range views { + wg.Go(func() { + <-start + h := NewCargoHandler(view, "http://proxy.test", "https://index.crates.io", "https://static.crates.io/crates") + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/crates/demo/1.0.0/download", nil)) + if w.Code != http.StatusOK || w.Body.String() != "crate bytes" { + t.Errorf("artifact response %d: %s", w.Code, w.Body) + } + }) + } + } + close(start) + wg.Wait() + if n := fetcher.calls.Load(); n != 1 { + t.Fatalf("upstream fetches = %d, want 1", n) + } +} + +type profileClockPolicy struct { + *cooldown.Policy + now func() time.Time +} + +func (p profileClockPolicy) IsAllowed(ecosystem, packagePURL string, publishedAt time.Time) bool { + return p.Evaluate(ecosystem, packagePURL, publishedAt, p.now()).Allowed +} + +func TestProfileExpiryReleasesVersionsIndependently(t *testing.T) { + u, proxy, _ := gemCacheSetup(t) + clock := &testClock{t: time.Now()} + proxy.now = clock.now + proxy.SetMetadataRewriteCacheSize(1 << 20) + view := proxy.WithCooldown(profileClockPolicy{profilePolicy(t, "3d"), clock.now}, 72*time.Hour) + h := NewGemHandlerWithUpstream(view, "http://proxy.test/cooldown/3d", u.URL).Routes() + strictView := proxy.WithCooldown(profileClockPolicy{profilePolicy(t, "7d"), clock.now}, 168*time.Hour) + strict := NewGemHandlerWithUpstream(strictView, "http://proxy.test/cooldown/7d", u.URL).Routes() + first := getWith(t, h, gemInfoPath, nil) + if first.Code != http.StatusOK || first.Body.String() != gemFiltered { + t.Fatalf("initial response %d: %s", first.Code, first.Body) + } + if first := getWith(t, strict, gemInfoPath, nil); first.Code != http.StatusOK || first.Body.String() != gemFiltered { + t.Fatalf("initial strict response %d: %s", first.Code, first.Body) + } + clock.advance(4 * 24 * time.Hour) + second := getWith(t, h, gemInfoPath, nil) + if second.Code != http.StatusOK || !strings.Contains(second.Body.String(), "2.0.0 |") { + t.Fatalf("version did not leave cooldown: %d %s", second.Code, second.Body) + } + if second := getWith(t, strict, gemInfoPath, nil); second.Code != http.StatusOK || second.Body.String() != gemFiltered { + t.Fatalf("strict profile released version early: %d %s", second.Code, second.Body) + } + if n, _ := u.counts(gemInfoPath); n != 1 { + t.Errorf("expiry refetched raw metadata %d times", n) + } +} diff --git a/internal/server/cooldown_profiles.go b/internal/server/cooldown_profiles.go new file mode 100644 index 00000000..e818a013 --- /dev/null +++ b/internal/server/cooldown_profiles.go @@ -0,0 +1,26 @@ +package server + +import ( + "net/http" + "strings" + + "github.com/git-pkgs/proxy/internal/handler" + "github.com/go-chi/chi/v5" +) + +func (s *Server) mountCooldownProfiles(r chi.Router, proxy *handler.Proxy) error { + if err := s.cfg.Cooldown.ValidateProfiles(); err != nil { + return err + } + for name, cfg := range s.cfg.Cooldown.Profiles { + policy, err := cfg.Policy() + if err != nil { + return err + } + prefix := "/cooldown/" + name + profile := chi.NewRouter() + s.mountProtocolHandlersAt(profile, proxy.WithCooldown(policy, cfg.Shortest()), strings.TrimRight(s.cfg.BaseURL, "/")+prefix) + r.Mount(prefix, http.StripPrefix(prefix, profile)) + } + return nil +} diff --git a/internal/server/cooldown_profiles_test.go b/internal/server/cooldown_profiles_test.go new file mode 100644 index 00000000..ad924344 --- /dev/null +++ b/internal/server/cooldown_profiles_test.go @@ -0,0 +1,253 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/git-pkgs/proxy/internal/config" +) + +func TestCooldownProfilesThroughServerStart(t *testing.T) { + if os.Getenv("PROXY_TEST_PROFILES") != "1" { + cmd := exec.Command(os.Args[0], "-test.run=^TestCooldownProfilesThroughServerStart$") + cmd.Env = append(os.Environ(), "PROXY_TEST_PROFILES=1") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("profile server: %v\n%s", err, output) + } + return + } + upstream, counts := profileTestUpstream(t) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + cfg := config.Default() + cfg.BaseURL = "http://" + listener.Addr().String() + cfg.Database.Path = filepath.Join(t.TempDir(), "cache.db") + cfg.Storage.URL = "file://" + t.TempDir() + cfg.CacheMetadata = true + cfg.MetadataTTL = "1h" + cfg.Upstream.AllowLoopback = true + cfg.Upstream.NPM = upstream.URL + "/npm" + cfg.Upstream.Cargo = upstream.URL + "/cargo" + cfg.Upstream.CargoDownload = upstream.URL + "/crates" + cfg.Upstream.Gem = upstream.URL + "/gem" + cfg.Upstream.Go = upstream.URL + "/go" + cfg.Cooldown = config.CooldownConfig{Profiles: map[string]config.CooldownConfig{ + "3d": {Default: "3d"}, "7d": {Default: "7d"}, + }} + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + s, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)), BuildInfo{}) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- s.Start(listener) }() + client := &http.Client{Timeout: 5 * time.Second} + defer func() { + client.CloseIdleConnections() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := s.Shutdown(ctx); err != nil { + t.Error(err) + } + if err := <-done; !errors.Is(err, http.ErrServerClosed) { + t.Error(err) + } + }() + get := func(path string, headers map[string]string) (*http.Response, string) { + t.Helper() + req, err := http.NewRequest(http.MethodGet, cfg.BaseURL+path, nil) + if err != nil { + t.Fatal(err) + } + for name, value := range headers { + req.Header.Set(name, value) + } + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + return resp, string(body) + } + assertProfileVersionLists(t, get) + assertProfileRouting(t, cfg.BaseURL, get, counts) + assertConcurrentProfileRequests(t, client, cfg.BaseURL) +} + +type profileGetter func(string, map[string]string) (*http.Response, string) + +func assertProfileVersionLists(t *testing.T, get profileGetter) { + t.Helper() + for _, registry := range []struct{ path, old, middle, newest string }{ + {"npm/demo", `"1.0.0"`, `"2.0.0"`, `"3.0.0"`}, + {"cargo/de/mo/demo", `"vers":"1.0.0"`, `"vers":"2.0.0"`, `"vers":"3.0.0"`}, + {"gem/info/demo", "1.0.0 |", "2.0.0 |", "3.0.0 |"}, + {"go/example.com/demo/@v/list", "v1.0.0", "v2.0.0", "v3.0.0"}, + } { + t.Run(registry.path, func(t *testing.T) { + for _, profile := range []struct { + prefix string + middle, newest bool + }{ + {"/cooldown/3d/", true, false}, {"/cooldown/7d/", false, false}, {"/", true, true}, + } { + resp, body := get(profile.prefix+registry.path, nil) + if resp.StatusCode != http.StatusOK || !strings.Contains(body, registry.old) || + strings.Contains(body, registry.middle) != profile.middle || strings.Contains(body, registry.newest) != profile.newest { + t.Fatalf("%s%s: status %d, body %s", profile.prefix, registry.path, resp.StatusCode, body) + } + } + }) + } +} + +func assertProfileRouting(t *testing.T, baseURL string, get profileGetter, counts func(string) int) { + t.Helper() + for _, profile := range []string{"3d", "7d"} { + _, body := get("/cooldown/"+profile+"/cargo/config.json", nil) + if !strings.Contains(body, baseURL+"/cooldown/"+profile+"/cargo/crates/") { + t.Fatalf("Cargo download URL lost profile: %s", body) + } + _, body = get("/cooldown/"+profile+"/npm/demo", nil) + if !strings.Contains(body, baseURL+"/cooldown/"+profile+"/npm/demo/-/") { + t.Fatalf("npm download URL lost profile: %s", body) + } + } + loose, _ := get("/cooldown/3d/npm/demo", nil) + strict, _ := get("/cooldown/7d/npm/demo", map[string]string{"If-None-Match": loose.Header.Get("ETag")}) + if strict.StatusCode != http.StatusOK || strict.Header.Get("ETag") == loose.Header.Get("ETag") { + t.Fatalf("cross-profile validator returned status %d", strict.StatusCode) + } + resp, _ := get("/cooldown/7d/npm/demo", map[string]string{"If-None-Match": strict.Header.Get("ETag")}) + if resp.StatusCode != http.StatusNotModified { + t.Fatalf("same-profile revalidation returned %d", resp.StatusCode) + } + for _, tc := range []struct { + path string + status int + }{ + {"/cooldown/3d/npm/demo/-/demo-2.0.0.tgz", http.StatusOK}, + {"/cooldown/7d/npm/demo/-/demo-2.0.0.tgz", http.StatusNotFound}, + {"/cooldown/3d/npm/demo/-/demo-2.0.0.tgz", http.StatusOK}, + {"/cooldown/7d/go/example.com/demo/@v/v2.0.0.mod", http.StatusForbidden}, + {"/cooldown/unknown/npm/demo", http.StatusNotFound}, + {"/cooldown/3d/ui/", http.StatusNotFound}, + {"/cooldown/3d/api/packages", http.StatusNotFound}, + {"/cooldown/3d/metrics", http.StatusNotFound}, + } { + resp, _ := get(tc.path, nil) + if resp.StatusCode != tc.status { + t.Errorf("%s: status %d, want %d", tc.path, resp.StatusCode, tc.status) + } + } + if got := counts("/npm/demo/-/demo-2.0.0.tgz"); got != 1 { + t.Errorf("artifact upstream fetches = %d, want 1", got) + } +} + +func assertConcurrentProfileRequests(t *testing.T, client *http.Client, baseURL string) { + t.Helper() + var wg sync.WaitGroup + for range 8 { + for _, profile := range []string{"3d", "7d"} { + wg.Go(func() { + resp, err := client.Get(baseURL + "/cooldown/" + profile + "/npm/demo") + if err != nil { + t.Error(err) + return + } + defer func() { _ = resp.Body.Close() }() + var doc struct{ Versions map[string]json.RawMessage } + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + t.Error(err) + return + } + _, middle := doc.Versions["2.0.0"] + if middle != (profile == "3d") { + t.Errorf("%s served another profile's versions", profile) + } + }) + } + } + wg.Wait() +} + +func profileTestUpstream(t *testing.T) (*httptest.Server, func(string) int) { + t.Helper() + var mu sync.Mutex + counts := make(map[string]int) + ages := []time.Duration{10 * 24 * time.Hour, 5 * 24 * time.Hour, 24 * time.Hour} + var upstream *httptest.Server + upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + counts[r.URL.Path]++ + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/npm/demo": + versions := map[string]any{} + times := map[string]string{} + for i, age := range ages { + version := fmt.Sprintf("%d.0.0", i+1) + times[version] = time.Now().Add(-age).UTC().Format(time.RFC3339) + versions[version] = map[string]any{"name": "demo", "version": version, "dist": map[string]string{ + "tarball": upstream.URL + "/npm/demo/-/demo-" + version + ".tgz", + }} + } + _ = json.NewEncoder(w).Encode(map[string]any{"name": "demo", "versions": versions, "time": times, "dist-tags": map[string]string{"latest": "3.0.0"}}) + case "/npm/demo/-/demo-2.0.0.tgz": + _, _ = io.WriteString(w, "cached package bytes") + case "/cargo/de/mo/demo": + for i, age := range ages { + _, _ = fmt.Fprintf(w, "{\"name\":\"demo\",\"vers\":\"%d.0.0\",\"deps\":[],\"features\":{},\"yanked\":false,\"pubtime\":%q}\n", i+1, time.Now().Add(-age).UTC().Format(time.RFC3339)) + } + case "/gem/info/demo": + _, _ = io.WriteString(w, "---\n1.0.0 |checksum:a\n2.0.0 |checksum:b\n3.0.0 |checksum:c\n") + case "/gem/api/v1/versions/demo.json": + versions := make([]map[string]string, 0, len(ages)) + for i, age := range ages { + versions = append(versions, map[string]string{"number": fmt.Sprintf("%d.0.0", i+1), "created_at": time.Now().Add(-age).UTC().Format(time.RFC3339)}) + } + _ = json.NewEncoder(w).Encode(versions) + case "/go/example.com/demo/@v/list": + _, _ = io.WriteString(w, "v1.0.0\nv2.0.0\nv3.0.0\n") + default: + for i, age := range ages { + if r.URL.Path == fmt.Sprintf("/go/example.com/demo/@v/v%d.0.0.info", i+1) { + _ = json.NewEncoder(w).Encode(map[string]string{"Version": fmt.Sprintf("v%d.0.0", i+1), "Time": time.Now().Add(-age).UTC().Format(time.RFC3339)}) + return + } + } + http.NotFound(w, r) + } + })) + t.Cleanup(upstream.Close) + return upstream, func(path string) int { + mu.Lock() + defer mu.Unlock() + return counts[path] + } +} diff --git a/internal/server/middleware.go b/internal/server/middleware.go index 7d2bd983..aa4da11d 100644 --- a/internal/server/middleware.go +++ b/internal/server/middleware.go @@ -121,6 +121,10 @@ func (s *Server) LoggerMiddleware(next http.Handler) http.Handler { // "other" along with the UI, health and metrics paths, pooling a real // ecosystem's traffic with traffic that belongs to no ecosystem at all. func requestEcosystem(path string) string { + if rest, ok := strings.CutPrefix(path, "/cooldown/"); ok { + _, rest, _ = strings.Cut(rest, "/") + path = "/" + rest + } segment, _, _ := strings.Cut(strings.TrimPrefix(path, "/"), "/") switch segment { case "npm", "cargo", "hex", "pub", "pypi", "maven", "gradle", "nuget", diff --git a/internal/server/server.go b/internal/server/server.go index 55fe597b..aa12c40a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -77,7 +77,6 @@ import ( "time" "github.com/git-pkgs/archives" - "github.com/git-pkgs/cooldown" shared "github.com/git-pkgs/enrichment" swaggerdoc "github.com/git-pkgs/proxy/docs/swagger" "github.com/git-pkgs/proxy/internal/accesslog" @@ -286,16 +285,11 @@ func (s *Server) serve(listener net.Listener) error { baseFetcher := fetch.NewFetcher(fetch.WithHTTPClient(artifactClient), fetch.WithMaxArtifactSize(s.cfg.ParseArtifactMaxSize())) fetcher := fetch.NewCircuitBreakerFetcher(baseFetcher) s.breakers = newBreakerMonitor(fetcher, s.logger) - cd := &cooldown.Config{ - Default: s.cfg.Cooldown.Default, - Ecosystems: s.cfg.Cooldown.Ecosystems, - Packages: s.cfg.Cooldown.NormalizedPackages(), - } proxy := handler.NewProxy(s.db, s.storage, fetcher, nil, s.logger) configureMirrorResolvers(proxy, s.cfg.Upstream) proxy.HTTPClient = metadataClient proxy.RateLimits = s.rateLimits - cooldownPolicy, err := cooldown.NewPolicy(cd, s.cfg.Cooldown.PackagePatterns) + cooldownPolicy, err := s.cfg.Cooldown.Policy() if err != nil { return fmt.Errorf("configuring cooldown policy: %w", err) } @@ -348,6 +342,9 @@ func (s *Server) serve(listener net.Listener) error { // Mount protocol handlers s.mountProtocolHandlers(r, proxy) + if err := s.mountCooldownProfiles(r, proxy); err != nil { + return err + } // Health, stats, and metrics endpoints r.Get("/health", s.handleHealth) @@ -462,71 +459,75 @@ func (s *Server) serve(listener net.Listener) error { // mountProtocolHandlers constructs every ecosystem handler and mounts it on // r under its protocol prefix. func (s *Server) mountProtocolHandlers(r chi.Router, proxy *handler.Proxy) { - npmHandler := handler.NewNPMHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.NPM). + s.mountProtocolHandlersAt(r, proxy, s.cfg.BaseURL) +} + +func (s *Server) mountProtocolHandlersAt(r chi.Router, proxy *handler.Proxy, baseURL string) { + npmHandler := handler.NewNPMHandler(proxy, baseURL, s.cfg.Upstream.NPM). WithPackageRoutes(s.cfg.Upstream.NPMRoutes) cargoHandler := handler.NewCargoHandler( proxy, - s.cfg.BaseURL, + baseURL, s.cfg.Upstream.Cargo, s.cfg.Upstream.CargoDownload, ) - gemHandler := handler.NewGemHandlerWithUpstream(proxy, s.cfg.BaseURL, s.cfg.Upstream.Gem) - goHandler := handler.NewGoHandlerWithUpstream(proxy, s.cfg.BaseURL, s.cfg.Upstream.Go) + gemHandler := handler.NewGemHandlerWithUpstream(proxy, baseURL, s.cfg.Upstream.Gem) + goHandler := handler.NewGoHandlerWithUpstream(proxy, baseURL, s.cfg.Upstream.Go) goHandler.SetSumDBs(s.cfg.Upstream.GoSumDBs, s.cfg.Upstream.GoSumDBsOffline) goHandler.SetVulnDB(s.cfg.Upstream.GoVulnDB) hexHandler := handler.NewHexHandlerWithUpstreams( proxy, - s.cfg.BaseURL, + baseURL, s.cfg.Upstream.Hex, s.cfg.Upstream.HexAPI, ) - pubHandler := handler.NewPubHandlerWithUpstream(proxy, s.cfg.BaseURL, s.cfg.Upstream.Pub) + pubHandler := handler.NewPubHandlerWithUpstream(proxy, baseURL, s.cfg.Upstream.Pub) pypiHandler := handler.NewPyPIHandlerWithUpstreams( proxy, - s.cfg.BaseURL, + baseURL, s.cfg.Upstream.PyPI, s.cfg.Upstream.PyPIDownload, ) mavenHandler := handler.NewMavenHandler( proxy, - s.cfg.BaseURL, + baseURL, s.cfg.Upstream.Maven, s.cfg.Upstream.GradlePluginPortal, ).WithRepositories(s.cfg.Upstream.MavenRepositories) gradleHandler := handler.NewGradleBuildCacheHandler(proxy) nugetHandler := handler.NewNuGetHandlerWithUpstreams( proxy, - s.cfg.BaseURL, + baseURL, s.cfg.Upstream.NuGet, s.cfg.Upstream.NuGetSearch, ) composerHandler := handler.NewComposerHandlerWithUpstreams( proxy, - s.cfg.BaseURL, + baseURL, s.cfg.Upstream.Composer, s.cfg.Upstream.ComposerRepository, ).WithPackageRoutes(s.cfg.Upstream.ComposerRoutes) - conanHandler := handler.NewConanHandlerWithUpstream(proxy, s.cfg.BaseURL, s.cfg.Upstream.Conan) - chefHandler := handler.NewChefHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.Chef) - condaHandler := handler.NewCondaHandlerWithUpstream(proxy, s.cfg.BaseURL, s.cfg.Upstream.Conda) - cranHandler := handler.NewCRANHandlerWithUpstream(proxy, s.cfg.BaseURL, s.cfg.Upstream.CRAN) + conanHandler := handler.NewConanHandlerWithUpstream(proxy, baseURL, s.cfg.Upstream.Conan) + chefHandler := handler.NewChefHandler(proxy, baseURL, s.cfg.Upstream.Chef) + condaHandler := handler.NewCondaHandlerWithUpstream(proxy, baseURL, s.cfg.Upstream.Conda) + cranHandler := handler.NewCRANHandlerWithUpstream(proxy, baseURL, s.cfg.Upstream.CRAN) juliaHandler := handler.NewJuliaHandlerWithUpstream(proxy, s.cfg.Upstream.Julia) - swiftHandler := handler.NewSwiftHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.Swift) + swiftHandler := handler.NewSwiftHandler(proxy, baseURL, s.cfg.Upstream.Swift) homebrewHandler := handler.NewHomebrewHandler(proxy, s.cfg.Upstream.HomebrewAPI) - containerHandler := s.newContainerHandler(proxy) + containerHandler := s.newContainerHandlerAt(proxy, baseURL) helmHandler := handler.NewHelmHandlerWithOCIRegistries( - proxy, s.cfg.BaseURL, s.cfg.Upstream.Helm, s.cfg.Upstream.OCIDefault, s.cfg.Upstream.OCI) - apkHandler := handler.NewAPKHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.APK). + proxy, baseURL, s.cfg.Upstream.Helm, s.cfg.Upstream.OCIDefault, s.cfg.Upstream.OCI) + apkHandler := handler.NewAPKHandler(proxy, baseURL, s.cfg.Upstream.APK). WithSnapshots(s.cfg.ParseAPKSnapshots()) - archHandler := handler.NewArchHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.Arch) - freebsdHandler := handler.NewFreeBSDHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.FreeBSD) + archHandler := handler.NewArchHandler(proxy, baseURL, s.cfg.Upstream.Arch) + freebsdHandler := handler.NewFreeBSDHandler(proxy, baseURL, s.cfg.Upstream.FreeBSD) debianHandler := handler.NewDebianHandler( proxy, - s.cfg.BaseURL, + baseURL, s.cfg.Upstream.Debian, s.cfg.Upstream.DebianRepositories, ) - rpmHandler := handler.NewRPMHandlerWithUpstream(proxy, s.cfg.BaseURL, s.cfg.Upstream.RPM) + rpmHandler := handler.NewRPMHandlerWithUpstream(proxy, baseURL, s.cfg.Upstream.RPM) genericHandler := handler.NewGenericHandler(proxy, s.cfg.Upstream.Generic, s.cfg.Upstream.GenericArtifacts). WithPaths(s.cfg.Upstream.GenericPaths) @@ -558,7 +559,7 @@ func (s *Server) mountProtocolHandlers(r chi.Router, proxy *handler.Proxy) { r.Mount("/rpm", http.StripPrefix("/rpm", rpmHandler.Routes())) r.Mount("/generic", http.StripPrefix("/generic", genericHandler.Routes())) if s.git != nil { - gitHandler := handler.NewGitHandler(proxy, s.git, s.cfg.Upstream).WithBaseURL(s.cfg.BaseURL) + gitHandler := handler.NewGitHandler(proxy, s.git, s.cfg.Upstream).WithBaseURL(baseURL) r.Mount("/git", http.StripPrefix("/git", gitHandler.Routes())) } } @@ -629,9 +630,13 @@ func NewMirrorProxy(cfg *config.Config, db *database.DB, store storage.Storage, // newContainerHandler builds the OCI handler with the configured default // registry, named upstreams, mirrored registries and Homebrew routes. func (s *Server) newContainerHandler(proxy *handler.Proxy) *handler.ContainerHandler { + return s.newContainerHandlerAt(proxy, s.cfg.BaseURL) +} + +func (s *Server) newContainerHandlerAt(proxy *handler.Proxy, baseURL string) *handler.ContainerHandler { containerHandler := handler.NewContainerHandlerWithRegistry( proxy, - s.cfg.BaseURL, + baseURL, s.cfg.Upstream.OCIDefault, s.cfg.Upstream.OCI, )