Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 15:49:16 -05:00
A caching proxy for package registries.
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Ondrej Kokes 129e6c6c1b
Filter cached packages by ecosystem using pills (#319)
* experimental filtering pills

* align data in the dashboard table more nicely (#318)

* Bump zizmorcore/zizmor-action from 0.6.2 to 0.6.3 (#333)

Bumps [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) from 0.6.2 to 0.6.3.
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](3dc1ecc9bc...70fb788f84)

---
updated-dependencies:
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.6.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump docker/setup-qemu-action from 4.2.0 to 4.3.0 (#331)

Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 4.2.0 to 4.3.0.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](96fe6ef7f3...1f40c72289)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump azure/setup-helm from 4.3.1 to 5.0.1 (#332)

Bumps [azure/setup-helm](https://github.com/azure/setup-helm) from 4.3.1 to 5.0.1.
- [Release notes](https://github.com/azure/setup-helm/releases)
- [Changelog](https://github.com/Azure/setup-helm/blob/main/CHANGELOG.md)
- [Commits](1a275c3b69...9bc31f4ebc)

---
updated-dependencies:
- dependency-name: azure/setup-helm
  dependency-version: 5.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump modernc.org/sqlite from 1.57.0 to 1.58.0 (#337)

Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.57.0 to 1.58.0.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.57.0...v1.58.0)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.58.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump github.com/aws/aws-sdk-go-v2/config from 1.32.40 to 1.33.2 (#336)

Bumps [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) from 1.32.40 to 1.33.2.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.32.40...config/v1.33.2)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.33.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump github.com/prometheus/client_model from 0.6.2 to 0.6.3 (#330)

Bumps [github.com/prometheus/client_model](https://github.com/prometheus/client_model) from 0.6.2 to 0.6.3.
- [Release notes](https://github.com/prometheus/client_model/releases)
- [Commits](https://github.com/prometheus/client_model/compare/v0.6.2...v0.6.3)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_model
  dependency-version: 0.6.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump golang.org/x/sync from 0.22.0 to 0.23.0 (#335)

Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.22.0 to 0.23.0.
- [Commits](https://github.com/golang/sync/compare/v0.22.0...v0.23.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sync
  dependency-version: 0.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump github.com/aws/aws-sdk-go-v2/service/ecr from 1.61.0 to 1.64.0 (#334)

Bumps [github.com/aws/aws-sdk-go-v2/service/ecr](https://github.com/aws/aws-sdk-go-v2) from 1.61.0 to 1.64.0.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.61.0...service/s3/v1.64.0)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/service/ecr
  dependency-version: 1.63.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* fix(handler): let the ProxyCached path request a per-call Accept-Encoding (#324)

* fix(handler): fetch conda repodata gzip-compressed on both hops

#304 made the ProxyCached path request Accept-Encoding: identity so the
metadata cache stores upstream bytes verbatim. That is required for the
signed / hash-pinned index ecosystems, but conda's repodata.json is large
plain JSON: linux-64 repodata.json is ~441 MB uncompressed (over the
metadata_max_size cap, so it 502s today) versus ~34 MB gzip.

Replace the ProxyCached path's verbatim bool with an explicit
acceptEncoding string ('' = leave unset / transparent, 'identity', or
'gzip'), reusing #304's existing store-and-replay of Content-Encoding
unchanged. ProxyCached keeps its exported signature and continues to send
identity, so the nine other ecosystems and helm/maven are untouched; only
conda's repodata.json / current_repodata.json now request gzip. Setting
Accept-Encoding explicitly disables Go's transparent decompression, so the
compressed bytes and the Content-Encoding: gzip header are cached and
replayed exactly as identity bytes are. conda, mamba and pixi solicit and
decode gzip on .json URLs; repodata.json.bz2 stays identity.

Fixes #305

* fix(handler): pass metadata content-encoding with the body it describes

The adversarial review of the conda gzip route found a reachable
regression: writeMetadataCachedResponse took Content-Encoding from a
fresh cache-row read while cacheMetadataBlob skips the row write when
Storage.Store fails. Under identity that was benign (the body was plain
anyway), but on the new gzip route a disk-full or object-store outage
served raw gzip bytes as Content-Type: application/json with no
Content-Encoding and HTTP 200 -- conda, mamba and pixi fail to parse
them, with no HTTP signal and only a Warn log, on every request until a
cache write succeeds.

fetchOrCacheMetadata now returns the encoding of the body it hands back
(the upstream value on a fetch, the stored row's value on a TTL hit or
stale fallback) and proxyCachedWithEncoding passes it to
writeMetadataCachedResponse, so the header always describes the bytes
actually written. cachedMeta drops its now-unused content_encoding
field. helm and maven pass "" -- both fetch transparently, so their
stored encoding was always empty and behaviour is unchanged.

Also fixes a vacuous assertion in the new conda test: the upstream
request counter incremented behind the availability gate, so the
cached-replay block could never observe a refetch.

* fix(handler): pin the stale-fallback content-encoding and drop a dead guard

Follow-ups from the adversarial review of the #305 branch, limited to
code this branch introduced:

- proxyMetadataStream is only ever reached with an explicit
  Accept-Encoding (ProxyCached passes identity, conda passes gzip or
  identity), so the guard around the header set was unreachable; replace
  it with the plain one-token substitution of the former literal, which
  is the smallest change from main.
- The stale-fallback return of fetchOrCacheMetadata (encoding taken from
  the cache row) was the one #305 return site no test pinned: replacing
  it with an empty encoding survived the whole suite. Add a conda test
  that expires the entry, fails the upstream, and asserts the stored
  gzip blob is served with Content-Encoding: gzip.

Not changed, by scope: cacheMetadataBlob still discards the
UpsertMetadataCache error (pre-existing on main). If Storage.Store
succeeds and the row write fails, a later stale fallback or TTL hit can
serve the gzip blob with the row's stale encoding; that needs a DB write
failure plus a second event and is tracked separately.

* fix(handler): restore the pre-existing cachedMeta content-encoding field

The third adversarial review classified deleting cachedMeta.contentEncoding
and its lookupCachedMeta populate as elective: neither line was created by
this branch nor forced by the fix (writeMetadataCachedResponse now reads
the encoding from its parameter and ignores the row value). Under the rule
that pre-existing code this branch did not have to touch stays untouched,
restore both as they are on main. No behaviour change.

Residuals the review documented, unchanged by scope (both share one root
cause: the encoding lives in the cache row and the bytes in the blob, and
neither is written or read atomically):

- cacheMetadataBlob discards the UpsertMetadataCache error, so after a
  successful gzip Store and a failed row write a later stale fallback or
  TTL hit can serve the gzip blob with the row's stale encoding.
- During the one-time identity->gzip rollout, a request that read a
  pre-branch identity row, lost the upstream race to a request that stored
  the gzip blob, and then failed upstream serves the gzip bytes with no
  Content-Encoding for that one response; later requests self-heal.
- helm and maven now pass an empty encoding; on main a spec-violating
  upstream that answered a transparent gzip request with an encoding Go
  does not decode (e.g. br) would have had that header replayed from the
  row. Degenerate; documented rather than changed.

* fix(handler): keep conda's proxyCached and .bz2 route as on main

Threading acceptEncoding through CondaHandler.proxyCached changed the
form of two pieces of original code the fix did not need to touch: the
repodata.json.bz2 route (method value rewritten as a closure) and
proxyCached itself (new parameter, new call). Restore both exactly as on
main; ProxyCached still sends identity, so the .bz2 route is unchanged in
behaviour. handleRepodata's non-cooldown branch now derives the cache key
inline and calls proxyCachedWithEncoding with gzip directly, so the only
original conda.go line that changes is that one call.

* fix(handler): keep writeMetadataCachedResponse and its callers as on main

Adding a contentEncoding parameter to writeMetadataCachedResponse changed
a signature that predates #304 and dragged its two pre-#304 callers
(helm.go, maven.go) into the diff, even though #304 only ever added the
cm.contentEncoding block inside the function body.

Restore writeMetadataCachedResponse's doc and signature exactly as on
main and make it a delegate that passes an empty encoding to a new
unexported writeMetadataCachedResponseWithEncoding, which carries the
original body with #304's block reading the parameter instead of the
cache row. proxyCachedWithEncoding calls the sibling with the encoding
returned alongside the body. helm.go and maven.go drop out of the diff;
their behaviour is unchanged (both fetch transparently, so their stored
encoding was always empty). Same split pattern as ProxyCached ->
proxyCachedWithEncoding.

* fix(handler): move the conda gzip change to its own branch

The conda call site in handleRepodata predates #304 and #304 never
touched it, so under the rule that this PR only corrects code and
behaviour #304 introduced it does not belong here. Restore conda.go and
conda_test.go as on main; the conda change continues on a stacked branch
against its own issue.

Replace the conda-route tests with tests that exercise
proxyCachedWithEncoding directly, so this PR still pins its own plumbing:
gzip is requested and the compressed bytes plus Content-Encoding are
cached and replayed (cached and streaming paths), the header survives a
metadata cache write failure, and the stale fallback keeps the stored
encoding.

* fix(homebrew): fetch the JSON API gzip-compressed on both hops

Homebrew (#254) routes every API path through ProxyCached and so, since
#304, fetches formula.jws.json (~33 MB plain, ~5 MB gzip) uncompressed on
every refresh -- the case that motivated #305.

Request gzip for the JSON API via proxyCachedWithEncoding: brew fetches
every API download with curl --compressed and decodes Content-Encoding
itself, so the compressed bytes and header are cached and served as-is
and both hops stay compressed. The analytics endpoints are the one brew
consumer fetched without --compressed; they stay on identity.

* fix(handler): leave Accept-Encoding unset in proxyMetadataStream for an empty value

fetchUpstreamMetadata treats an empty acceptEncoding as 'do not set the
header'; proxyMetadataStream set it unconditionally, which would send an
empty Accept-Encoding line if a caller ever passed . Guard it the same
way so both paths agree. No caller passes  today.

* fix(handler): keep the metadata row and blob from describing different bytes

Two ways the cache row could stop describing the stored blob once a
caller requests gzip, both raised by the review of #324:

- cacheMetadataBlob stored the blob and then discarded the
  UpsertMetadataCache error. After a successful gzip store and a failed
  row write, a later TTL hit or stale fallback served the gzip blob with
  the previous row's encoding. On a row-write failure, log it and delete
  the blob just written, so the next request refetches instead.
- fetchOrCacheMetadata read the row once up front and reused it for the
  stale fallback. A request that read an identity row, lost the upstream
  race to a request that stored the gzip blob, and then failed upstream
  labelled the new blob with the old row. Re-read the row before falling
  back so the encoding matches the blob as it is now.

Both only become harmful with an encoding change, which this branch
introduces; the pre-existing validator-from-row read is tracked
separately.

* Drop unused cachedMeta.contentEncoding and fix stale doc reference

The field was added by #304 and its only reader is replaced in this
branch by the encoding parameter passed alongside the body. The
proxyCachedWithEncoding comment named conda repodata, which was moved
out of this branch in 5991d95; Homebrew is the caller that ships here.

---------

Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>

* Fix duplicate fetches and 502s on concurrent cache misses (#329)

* Return the stored artifact from storeArtifact, not a reader

storeArtifact returned a CacheResult holding an open file handle. A
handle has one read position, so it can only ever serve a single caller,
which is what blocks sharing one fetch between concurrent requests.

Return the artifact and its storage path instead, and let each caller
open its own reader through openStoredArtifact. Threading that type
through fetchAndCache, fetchAndCacheFromURL and their error paths is
mechanical; behaviour is unchanged.

* Coalesce concurrent cache misses

A cache miss went from checkCache straight to an upstream fetch with
nothing tracking in-flight work, so N concurrent requests for one
uncached artifact produced N upstream fetches and N stores to the same
key. That is the CI shape: parallel jobs installing overlapping
dependencies against a cold cache. The duplicate stores also fail
requests, racing fileblob's per-key ".attrs" sidecar into a partial read
served as a 502. Over 12 runs of 8 simultaneous requests for one
uncached tarball, against bb2205a: before, 8 fetches per run and 12 of
96 responses were 502; after, 1 fetch per run and none failed.

Route both miss paths through a shared in-flight map keyed on the
artifact, including the download URL and upstream-declared hash so
callers expecting different bytes never share a fetch.

singleflight does not fit: Do gives waiters no way to leave, while
DoChan lets the caller running the fetch abandon it, breaking
storeArtifact's scan-on-disconnect contract. Deciding roles under a
mutex gives both behaviours. The fetch runs on the first caller's
context and is seen through; waiters leave when their own clients do.

This removes the sidecar trigger on this path. The race is in fileblob
and three writers bypass this path entirely, so it is fixed separately.

Fewer failures now reach the circuit breaker, so it trips later.

Sixteen concurrent callers against real file:// storage fail 10 of 10
runs on main and pass 10 of 10 here. Other tests pin key discrimination,
failure propagation, resolver-path coalescing, per-caller readers,
waiter cancellation, key release and panic safety. allocs/op is
unchanged. mockStorage gains a mutex so concurrent tests can use it.

* Normalize digest case in the coalescing key

artifactHashMatches compares digests with strings.EqualFold, but the
coalescing key used the hash verbatim. The same digest in two casings
produced two keys, so two callers for one artifact each ran their own
upstream fetch and store, which is what the coalescing is meant to
prevent.

* Make the panic coalescing test deterministic

The test timed the second caller's arrival with a sleep, so which caller
became the leader was left to the scheduler. When it lost that race the
second caller ran the fetch itself, and its panic was not recovered, so
the test binary died instead of the test failing.

Whether a caller has reached the wait is not observable from outside:
it runs a cache lookup against the database first, so releasing the
leader on a timer races that query. Drive coalesceFetch directly and
hold the shared entry instead, which removes the timing entirely. The
panicking fetcher is no longer needed.

* Recheck the cache before running a shared fetch

A caller checks the cache before it reaches coalesceFetch, so a fetch
that commits in that gap is invisible to it. Arriving after the sharing
entry is gone, it became a new leader and fetched, stored and scanned an
artifact the cache already held.

The leader now rechecks the committed record first. It serves that
record only if its bytes still open, because a record can outlive them,
and refetching is the recovery the cache lookup already makes for that
case. Waiters are unaffected: the record fills the same shared value a
fetch would, and each caller opens its own reader from it.

The recheck is the leader's alone. A waiter has a fetch in flight to
wait on, and rechecking would race it for no gain.

* Lock the mock fetcher's bookkeeping

Coalescing tests call the handler from many goroutines. The key keeps
the fetch itself serialized, but the mock should not lean on that: it
now locks the fields it records, so any concurrency the handler applies
is safe under the race detector.

* Wait for the leader's fetch instead of sleeping

The canceled-waiter test slept 200ms and assumed the leader had taken
the key by then. On a slow scheduler the canceled call could become the
leader and the test would no longer cover waiter cancellation. The
fetcher now signals when its first fetch begins, which happens only once
the key is held.

* Bump google.golang.org/grpc from 1.83.1 to 1.83.2 (#346)

Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.83.1 to 1.83.2.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.83.1...v1.83.2)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.83.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Stop writing fileblob's .attrs sidecar (#328)

* Stop writing fileblob's .attrs sidecar

fileblob stores blob metadata in an ".attrs" file per object and rewrites
it with os.Create, truncating in place outside the atomic rename that
protects the blob. A read overlapping a write decodes a partial file and
fails with "opening reader: EOF", served as a 502. One writer against
four readers on a single key failed 408 of 2000 reads. cacheMetadataBlob
is most exposed to it, rewriting a key on every refresh while readers
are served from it.

Nothing in the proxy reads what the sidecar holds. gocloud.dev/blob is
imported only by internal/storage, Store sets no ContentType, and
Attributes is used only for Size, which comes from os.Stat. A missing
sidecar already defaults cleanly, so "metadata=skip" removes the hazard
rather than locking around it, and saves a write per store.

* Clear .attrs sidecars left by earlier versions

metadata=skip stops fileblob rewriting sidecars but does not delete ones
already on disk, so a sidecar left partial by an interrupted write now
fails every read of its key for good. Before, a later store repaired it
by rewriting.

Store therefore removes the sidecar for the key it writes. Removal is
atomic where the rewrite was not, so a concurrent reader gets the whole
old file or nothing. Delete already removes sidecars, so the two paths
drain a cache between them.

Deriving that path is necessary because fileblob's key escaping is
unexported. It is the identity for a plain key and parts from one only
for keys that are not valid local paths, which is what filepath.Localize
rejects. That also keeps the removal inside the cache directory: without
it a key holding ".." resolves outside.

The clearing test runs one key per storage path the proxy builds, seeded
through a bucket that still writes sidecars so the path under test is
fileblob's own.

* Explain why failed sidecar cleanup does not fail the store

Move the removal into clearLegacySidecar and say why its error is
dropped rather than returned. A failed removal leaves exactly the state
this change inherited, while failing the write would turn a cleanup miss
into a failed request.

Windows makes that concrete: Go opens files with FILE_SHARE_READ and
FILE_SHARE_WRITE but not FILE_SHARE_DELETE, so a reader holding the
sidecar open blocks deletion, and that reader is the workload this
change exists to protect. Propagating would fail stores during exactly
the overlap being fixed. The next store of the key retries.

A test pins it, using a non-empty directory at the sidecar path to make
os.Remove fail with something other than not-exist on any platform.

* Fail the concurrency test if its writer stops

The writer returned silently when Store failed, so the test could pass
with no concurrent writes at all. Its error is now reported, and the
test also checks that at least one write completed.

Reporting it showed the writer had been dying on Windows at its first
collision: Go opens files without FILE_SHARE_DELETE, so a reader holding
the file open makes the writer's rename fail with access denied. The
test now skips there, since it cannot contend a writer with readers on
that platform.

* Clear legacy sidecars for keys fileblob escapes

legacySidecarPath declined any key filepath.Localize rejects, which on
Windows is every key with a colon: OCI digests and Debian epochs. Their
sidecars were never cleared there, and a truncated one kept failing
reads, since fileblob still reads a sidecar it finds under metadata=skip.

fileblob hex-escapes such characters on the way to disk. The path is now
derived the same way, so the sidecar is looked for where fileblob wrote
it. Localize still validates the escaped form, which keeps the removal
inside the cache directory.

* Drop stale comment about declining colon keys on Windows

623ff3e made legacySidecarPath escape keys the way fileblob does, so
colon-bearing keys are now cleared on Windows and the OCI and Debian
rows in TestStoreClearsLegacyAttrsSidecar prove it. The comment
described the behaviour before that commit.

---------

Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>

* feat(debian): serve additional APT archives at /debian/{name}/

A Debian release is served by more than one archive. Security updates live on
a different host than the main archive, so a single upstream.debian URL cannot
serve a complete suite set and -security suites are unreachable.

Add upstream.debian_repositories, a name-to-URL map served at /debian/{name}/,
modelled on upstream.apk. The field is additive: upstream.debian keeps serving
/debian/pool/... and /debian/dists/... with unchanged cache identities, so
existing deployments and their warm caches are unaffected, and the scalar
field and PROXY_UPSTREAM_DEBIAN are untouched.

Named repositories scope both caches by name, since the same filename can hold
different bytes in different archives. Metadata keys are hashed over name,
upstream URL, and path, as APKHandler does. Names are validated through
validateNamedUpstreams, and "pool" and "dists" are refused because they would
shadow the main archive's own paths.

An unconfigured first path segment stays a main-archive path rather than
returning 404 as the APK handler does: the main archive is unnamed and serves
paths of its own at the root.

* fix(server): tune the shared upstream transport defaults (#351)

* fix(server): tune the shared upstream transport defaults

server.serve builds the shared client with safehttp.New, which clones
Go's default transport: MaxIdleConnsPerHost stays 0 (an effective limit
of two idle connections per host) and ResponseHeaderTimeout stays 0.
Handing that client to fetch.NewFetcher via fetch.WithHTTPClient
replaces the fetcher's own defaults of 10 idle connections per host and
a 60-second response-header timeout.

Set both on the shared transport, matching the fetcher defaults: a
second burst of concurrent cache misses to one registry now reuses its
connections instead of re-dialling most of them, and an upstream that
accepts a request but stalls before sending headers is cut off after 60
seconds rather than only by the client's overall timeout.

Tests measure connection reuse across two concurrent bursts against a
TLS upstream that counts accepted connections (Go default: at most two
reused; tuned: all eight) and assert that a stall before headers fails
with the response-header timeout.

Fixes #327

* fix(server): reconcile http_timeout docs and tighten the transport tests

The http_timeout documentation and the config comment said "0" disables
the upstream timeout entirely. With a fixed 60-second
ResponseHeaderTimeout on the shared transport that is no longer the
whole story, so both now say that waiting for response headers stays
bounded independently of the setting.

Test cleanup from review: the 50ms settle between bursts was dead time
(the transport returns a connection to the idle pool before the body's
final Read returns, so burst returning already means the pool is
settled); the maxNewInBurst sentinel became explicit min/max bounds per
case; the stall test dropped the client.Timeout override and the
elapsed-time assertion, which was redundant with the error-text check
in any realistic run and whose failure message misattributed the cause,
and its comment now says plainly that the field assertions pin
production while the behavioural half runs at a lowered timeout.

* test(server): hold each burst at the upstream instead of sleeping

The reuse test kept a burst in flight with a 100ms handler sleep, so a
process stall longer than that between spawning the goroutines and
their dials let a request finish early and hand its connection to a
sibling. Review reproduced this with forced stalls: the default-transport
case then dialled 5 instead of 6 connections.

The handler now answers only once burstSize requests are waiting at the
same time. With HTTP/1.1 pinned that forces every burst onto burstSize
distinct connections regardless of scheduling, and the measured counts
stay exactly 6 new for Go's default and 0 for the tuned transport, also
under the same forced stalls.

* fix(database): set connection pool limits for Postgres (#350)

* fix(database): set connection pool limits for Postgres

OpenPostgres returned sqlx.Open's handle with database/sql's defaults:
no cap on open connections and two idle ones. Under load nearly every
request opened a new Postgres session, ran its few statements and closed
it again, paying a backend fork and SCRAM authentication each time.

Set the pool limits the issue suggests: 32 open and 32 idle connections,
idle connections closed after 5 minutes and every connection recycled
after 30 minutes. The values live in named constants because the mnd
linter rejects the literals inline.

The test (skipped without PROXY_DATABASE_URL, like the other Postgres
tests) takes 16 connections from the pool, releases them and checks that
all 16 stay idle; with the default pool only two survive.

Fixes #323

* test(database): pin the pool properties instead of the wiring

Review pointed out that the pool test compared MaxOpenConnections to the
constant it was set from, so the assertion followed the constant and
would have accepted postgresMaxOpenConns = 0 (unlimited), and that its
burst of 16 only proved MaxIdleConns >= 16.

The burst now takes postgresMaxIdleConns connections, so every
configured idle slot has to survive the release, and the open cap is
checked to be finite and large enough for that burst before any
connection is taken, so a cap below the idle count fails fast instead
of blocking in db.Conn. The doc comment now says which settings the
test covers; the idle-time and lifetime settings only show up in
DBStats.MaxIdleTimeClosed and MaxLifetimeClosed after minutes of
wall-clock time and stay unexercised.

* test(database): guard the pool test against a vacuous burst

Review showed that with the burst tied to postgresMaxIdleConns the test
also passed for a constant of 2, database/sql's default, or of 0, where
it took no connections at all. It now fails outright unless the
configured idle count exceeds the default, and every connection it
takes is released in a cleanup, so an assertion failure mid-burst no
longer leaves sessions open for the rest of the test binary.

* test(database): drop every schema table in the Postgres fixture (#349)

createTestPostgresDB dropped artifacts, versions, packages and
schema_info before calling CreateSchema, but not the migrations table.
On a database that has seen one test the migration records survive, so
the next CreateSchema fails while recording 001_add_packages_enrichment_
columns with a duplicate key on migrations_pkey. Running the package
against one Postgres therefore failed from the second Postgres-backed
test on.

Drop vulnerabilities, metadata_cache and migrations as well, so the
fixture clears every table CreateSchema creates. The package now passes
repeatedly against the same database.

* Discard a stale cache entry under the coalescing key (#348)

The digest-aware cache check discarded a stale entry before its caller
took the key. A slow caller could delete an entry another caller's
fetch had just committed, and everyone sharing that fetch then failed
to open it.

The check now only reports the miss. The caller running the shared
fetch discards the entry under the key, after the recheck, so one a
previous fetch refreshed is served, not deleted. The mismatch warning
fires once per refresh instead of once per request.

Swift HEAD no longer discards either, having no fetch to do it under.
It probes upstream as before, and the next GET replaces the entry.

Different digests or URLs, or no digest, use different keys and can
still collide on the storage path. That is the storage layout follow-up.

* Separate OCI request timeout from readiness probes in loopback test (#347)

The 250ms client is meant for the readiness poll, where a timeout is
retried. Reusing it for the OCI manifest request makes the test flake
under -race on Windows CI when fetch and cache I/O take longer, as
seen on #328. The request checks upstream routing, not latency.

* Fix NuGet cooldown enforcement for listings and downloads (#340)

* fix(nuget): enforce cooldown across metadata and downloads

* fix(nuget): support legacy registration and preserve valid metadata cache

* refactor(nuget): address maintainer review cleanup

* Fall back to embedded build info for Version (#352)

go install does not apply goreleaser's -ldflags -X, so binaries
installed that way reported 'dev'. Read debug.BuildInfo.Main.Version
when the ldflag is unset.

* docs(debian): trim comments on the named-repositories change

The motivation for separate archives was repeated across six files and the
reserved-name rationale across five. Keep each in one place -- the reference
docs -- and leave the code comments to what an informed reader cannot get
from the code: why an unknown first segment is not a 404 as it is for APK,
and why the main archive keeps its legacy cache identities.

Comments and prose only; no behaviour change.

* docs(debian): drop a stray space in a test comment

* Bump github.com/aws/aws-sdk-go-v2/service/ecr from 1.64.0 to 1.65.0 (#357)

Bumps [github.com/aws/aws-sdk-go-v2/service/ecr](https://github.com/aws/aws-sdk-go-v2) from 1.64.0 to 1.65.0.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.64.0...service/s3/v1.65.0)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/service/ecr
  dependency-version: 1.65.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump github.com/git-pkgs/spdx from 0.3.1 to 0.3.2 (#355)

Bumps [github.com/git-pkgs/spdx](https://github.com/git-pkgs/spdx) from 0.3.1 to 0.3.2.
- [Commits](https://github.com/git-pkgs/spdx/compare/v0.3.1...v0.3.2)

---
updated-dependencies:
- dependency-name: github.com/git-pkgs/spdx
  dependency-version: 0.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump zizmorcore/zizmor-action from 0.6.3 to 0.6.4 (#354)

Bumps [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) from 0.6.3 to 0.6.4.
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](70fb788f84...cc914d7f37)

---
updated-dependencies:
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.6.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump github.com/aws/aws-sdk-go-v2/config from 1.33.2 to 1.33.4 (#356)

Bumps [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) from 1.33.2 to 1.33.4.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.33.2...config/v1.33.4)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.33.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat(debian): report a misspelled repository name

A request to /debian/{name}/ for a name that is not configured fell through
to the main archive, so a typo surfaced as the upstream's own HTML 404 with
no mention of the repository. The reply now names the unknown repository and
lists the configured ones.

Scoped to {name}/dists/ and {name}/pool/. The main archive serves README,
indices/ and project/ at its root, so an unknown first segment stays a
main-archive path everywhere else.

apt prints only the status line, so this is visible via curl or the proxy
log rather than in apt output.

* fix(helm): support OCI references in HTTP repository indexes

* feat: add exact-version package denylist

* Exit non-zero when mirror denylist is invalid

* Omit malformed Helm chart releases instead of failing the index

A single bad release in a third-party index made the whole
repository unusable through the proxy.

* End an eviction pass that cannot evict anything

A failed delete leaves the record eligible, so the same batch came back
on the next iteration while the recorded size never dropped. Against a
backend refusing deletes the pass retried forever, burning CPU and
flooding the log. A canceled context made it faster: every delete fails
at once, so the loop spun as quickly as the database could answer.

The pass now ends when a batch clears no record, and returns as soon as
the context is canceled. Progress counts records rather than bytes,
because a cached record can have no size recorded. A pass can therefore
end with the cache still above its limit, leaving the records that
failed for the next pass to retry.

* Fix the handler test build

The denylist tests call NewDebianHandler with three arguments, but the
additional APT archives change gave it a fourth. Each passed CI on its
own branch, and together they leave internal/handler without a test
binary that compiles, so go vet and go test fail on main.

* docs: explain APT denylist limitations and version pinning

* Assert omitted Helm releases are not downloadable

* build(deps): bump go.opentelemetry.io/otel/sdk from 1.44.0 to 1.45.0

Bumps [go.opentelemetry.io/otel/sdk](https://github.com/open-telemetry/opentelemetry-go) from 1.44.0 to 1.45.0.
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.44.0...v1.45.0)

---
updated-dependencies:
- dependency-name: go.opentelemetry.io/otel/sdk
  dependency-version: 1.45.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Widen wall-clock bound in flaky probe timeout test

Loaded CI runners exceeded the 500ms bound; 5s still proves the
probe timeout fired instead of blocking on Store.

* Add and align CI security and dependency automation

* fix(mirror): match cached artifacts by resolved filename

* Standardize the README license footer

* Test that GCS Delete of a missing object returns nil

* Give each fetch its own storage path

Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.

storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.

A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.

* build(deps): bump docker/setup-buildx-action from 4.3.0 to 4.4.1

Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 4.3.0 to 4.4.1.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](37fe631027...f87e5991a6)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.4.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps): bump docker/setup-qemu-action from 4.3.0 to 4.4.0

Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 4.3.0 to 4.4.0.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](1f40c72289...9901266195)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps): bump docker/build-push-action from 7.3.0 to 7.4.0

Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 7.3.0 to 7.4.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](53b7df96c9...c3c9e263c2)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 7.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps): bump github.com/aws/aws-sdk-go-v2/service/ecr

Bumps [github.com/aws/aws-sdk-go-v2/service/ecr](https://github.com/aws/aws-sdk-go-v2) from 1.65.0 to 1.66.0.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.65.0...service/s3/v1.66.0)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/service/ecr
  dependency-version: 1.66.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps): bump golang.org/x/net from 0.58.0 to 0.59.0

Bumps [golang.org/x/net](https://github.com/golang/net) from 0.58.0 to 0.59.0.
- [Commits](https://github.com/golang/net/compare/v0.58.0...v0.59.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.59.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps): bump github.com/git-pkgs/registries from 0.9.1 to 0.9.2

Bumps [github.com/git-pkgs/registries](https://github.com/git-pkgs/registries) from 0.9.1 to 0.9.2.
- [Release notes](https://github.com/git-pkgs/registries/releases)
- [Commits](https://github.com/git-pkgs/registries/compare/v0.9.1...v0.9.2)

---
updated-dependencies:
- dependency-name: github.com/git-pkgs/registries
  dependency-version: 0.9.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps): bump github.com/git-pkgs/magic from 0.3.1 to 0.4.0

Bumps [github.com/git-pkgs/magic](https://github.com/git-pkgs/magic) from 0.3.1 to 0.4.0.
- [Release notes](https://github.com/git-pkgs/magic/releases)
- [Commits](https://github.com/git-pkgs/magic/compare/v0.3.1...v0.4.0)

---
updated-dependencies:
- dependency-name: github.com/git-pkgs/magic
  dependency-version: 0.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps): bump modernc.org/sqlite from 1.58.0 to 1.59.0

Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.58.0 to 1.59.0.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.58.0...v1.59.0)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.59.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps): bump github.com/git-pkgs/vers from 0.7.0 to 0.7.1

Bumps [github.com/git-pkgs/vers](https://github.com/git-pkgs/vers) from 0.7.0 to 0.7.1.
- [Commits](https://github.com/git-pkgs/vers/compare/v0.7.0...v0.7.1)

---
updated-dependencies:
- dependency-name: github.com/git-pkgs/vers
  dependency-version: 0.7.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps): bump github.com/aws/aws-sdk-go-v2/config

Bumps [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) from 1.33.4 to 1.33.5.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.33.4...config/v1.33.5)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.33.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps): bump github.com/git-pkgs/archives from 0.7.0 to 0.7.1

Bumps [github.com/git-pkgs/archives](https://github.com/git-pkgs/archives) from 0.7.0 to 0.7.1.
- [Commits](https://github.com/git-pkgs/archives/compare/v0.7.0...v0.7.1)

---
updated-dependencies:
- dependency-name: github.com/git-pkgs/archives
  dependency-version: 0.7.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(conda): preserve gzip for large repodata indexes

* fix: centralize pass-through response relaying

* Clear before deleting on eviction, and requeue failed reclaims

Eviction deleted an object before checking its record still pointed at
it. If a refetch had moved the record, that object was queued and a
request that read the record earlier could still be opening it. Eviction
now clears first, skips the delete if the record moved, and queues the
object when the delete fails after a clear.

Reclaim queues a failed delete again, which moves it behind the rest, so
objects the backend keeps refusing cannot fill every batch.

Also drop the clearArtifactCache passthrough and ClearCachedArtifact's
unused context, note that only tests call ArtifactPath, and document that
max_size does not count objects waiting to be reclaimed.

* Log and count requests aborted mid-relay

A truncated relay panics with http.ErrAbortHandler, which skipped the
request log line, the request metrics and the access log entry.

* Batch cache hit writes

Every cached download wrote its hit in its own UPDATE. SQLite runs on
one connection, so each download was a write transaction the rest
queued behind, holding a warm npm cache near 1,000 requests a second
with the CPU two thirds idle.

Hits are now counted in memory and written in one transaction every
database.hit_flush_interval, "1s" by default, "0" for a write per hit.
A failed write keeps the hits for the next flush, and Close writes
what is pending. The same test then served about 2,200 requests a
second.

Closes #322.

* fix pnpm audit

* apply review changes

* Name every mounted route in requestEcosystem

- Map /apk to alpine, matching what the handler writes to the database
- Report /helm, /homebrew, /generic and /swift under their own names
- Test every one of the 23 mounts, so the next route added cannot
  quietly fall through to "other"

Part 1 of 6 splitting #381 up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(handler): stop asserting on an upstream connection the proxy abandons

TestRelayRoutes fails intermittently on CI with "write: connection reset
by peer" at the upstream handler's final Flush, on /hex/packages/demo and
/gem/info/demo.

Those two routes are the only ones in the table that fan out a second
upstream request: with cooldown enabled, the hex and gem handlers fetch
version timestamps concurrently with the artifact. The test server answers
that sidecar request with the same 64 KiB chunked 502, but the handler
returns as soon as it sees a non-200 and closes the body unread, so the
proxy drops the connection while the test server is still writing to it.
Whether the reset lands before or after the Flush is a scheduling race,
which is why it only surfaces on loaded runners.

The flush error says nothing about the response under test, which is
asserted on the downstream side, so stop reporting it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Per-ecosystem cache and download statistics

- Add GetEcosystemStats, aggregating packages, versions, artifacts,
  cache size, downloads and downloaded bytes per ecosystem
- Publish six proxy_ecosystem_* gauges from it on the existing
  one-minute cache-stats tick
- Set and selectively delete rather than Reset, so no scrape lands on
  a half-populated vector
- Report artifacts with no package row under "unattributed" rather
  than dropping them, so the figures still add up

Part 2 of 6 splitting #381 up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Add the /ui/analytics page

- Ring of download size by ecosystem, cache figures, per-ecosystem
  table, vulnerability overview and a Runtime card
- Add metrics.Gather so the page can render counters that were never
  in the database
- Add proxy_response_bytes_total and response-writer byte counting
- Serve a retained snapshot behind a staleness banner when the
  aggregation fails, rather than rendering old figures as current
- Extract the security overview into a shared component

Part 3 of 6 splitting #381 up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Report the per-ecosystem breakdown from GET /stats

- Add downloaded_bytes, downloads and an ecosystems array, served from
  the snapshot the gauges and the page already share
- Add stats_unavailable so a failed aggregation is distinguishable
  from an idle proxy
- Regenerate the OpenAPI spec

Part 4 of 6 splitting #381 up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Add a Grafana dashboard

- 33 panels, no hardcoded data source UID
- Three ecosystem variables, because the label means three different
  things across /metrics: the package record, the request path, and
  the handler's own name

Part 5 of 6 splitting #381 up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Attribute requests to a caller and a client tool

- Track per-caller request and byte counts in a bounded table
- Export proxy_client_requests_total and
  proxy_client_response_bytes_total over a closed label set; addresses
  are never labels
- Gate the sources card behind ui_request_sources, off by default,
  since /ui carries no authentication of its own
- Move access_log.trust_forwarded_for to a top-level key
- Keep remote as the TCP peer and add remote_ip alongside it

Part 6 of 6 splitting #381 up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Update enrichment to v0.7.2 (#394)

* Update ecosystems-go to v0.5.0

* Update enrichment to v0.7.2

* Update git-pkgs dependencies (#396)

* Keep hit timestamps from moving backwards

With proxies sharing a database, a batch can flush after a newer hit is
already written, and overwriting last_accessed_at and updated_at with
its older time could reorder LRU eviction. Both now keep the later of
the stored and incoming times. The count still adds up.

* build(deps): bump alpine from 3.24.1 to 3.24.2 (#397)

Bumps alpine from 3.24.1 to 3.24.2.

---
updated-dependencies:
- dependency-name: alpine
  dependency-version: 3.24.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Install development tools directly in CI (#398)

* Add storage.cache_artifacts to serve artifacts without storing them (#387)

* Add storage.passthrough to serve artifacts without storing them

With storage.passthrough enabled the proxy streams every artifact from
upstream to the client and never writes it to storage or the cache
database. Metadata filtering, cooldown and the denylist work as before,
so the proxy can sit behind another cache (e.g. an Artifactory remote)
purely as a policy layer without holding a second copy of every package.

Artifacts whose digest is known up front (OCI, Swift, Helm) are verified
while streaming. Their responses are sent chunked and the connection is
aborted on a mismatch, so a client never receives a tampered artifact as
a complete response.

Passthrough is rejected together with scanning, direct_serve and
mirror_api, and the mirror command refuses to run with it, since all of
them depend on stored artifacts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address review: abort truncated streams, guard every cache read

- serveArtifact aborts the response on any body read error, and when fewer
  bytes than the declared size were written, not only on a digest mismatch.
  A failed or short upstream body in passthrough mode no longer reaches the
  client as a complete 200.
- Streamed upstream read failures are logged and counted as
  stream_failed upstream errors before the response is aborted.
- checkCache reports a miss when artifact caching is off, so every cache
  read path, including Swift archive HEAD requests, ignores entries stored
  before the mode was enabled. Denylisted versions are still rejected.
- Rename storage.passthrough to storage.cache_artifacts (default true,
  PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes.

Tests cover an OCI blob shorter than its Content-Length and an npm tarball
whose chunked response ends without the final chunk, both served through
the HTTP handlers, plus a Swift HEAD request against a cached archive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Rename Proxy.Passthrough to StreamArtifacts

Match the storage.cache_artifacts option: the field says what changes
(artifacts are streamed instead of stored) and its zero value keeps the
usual caching behaviour for every Proxy built without the server config,
such as the mirror command and tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Show the by-client table regardless of ui_request_sources

The flag exists to withhold caller addresses. The by-client table carries
none, and the same figures are already public at /metrics, so gating it
left two metrics with no tile on the page in the default config.

* Drop unused registry from TestUpdateEcosystemStatsSumsAliasedRows

* Cache rewritten npm and Composer metadata (#402)

The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.

Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.

metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.

Whole cached requests, measured locally:
  @babel/core      7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
  typescript       155 ms -> 10.5 ms, 1.37M -> 133 allocations
  symfony/console  17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.

* Copy inherited Composer fields shallowly when expanding (#401)

expandMinifiedVersions deep-copied every inherited field into every
version (b68184c) so that rewriting one version's dist URL in place
could not change the versions that inherited it. For a package with a
long history that means recursively copying require, autoload and the
rest hundreds of times per document, on every metadata request.

dist is the only field the proxy changes after expansion, so copy just
that: rewriteDistURL now gives the version its own dist map before
setting the URL, and expansion shares the other inherited values.
TestComposerExpandMinifiedSharedDistReferences still guards the original
bug, and fails if the copy in rewriteDistURL is removed.

On the symfony/console metadata from Packagist the rewritten output is
byte-identical, and the rewrite drops from about 20.7 ms to 13.8 ms,
with allocations down from 13.6 MB to 9 MB.

* 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.

* Coalesce concurrent metadata fetches (#400)

* Coalesce concurrent metadata fetches

Concurrent cache misses for one artifact already share a single upstream
fetch (#329), but metadata misses did not: every request for a package's
metadata went to the registry, even when the same document was already
being fetched. In CI that is the common case. Composer resolves versions
on every job, since MediaWiki core and its extensions commit no
composer.lock, and each package is one /p2/ document, so a burst of jobs
fetched each document once per job.

Metadata misses now follow the same pattern as artifacts: the first caller
fetches, the rest wait for its result, and the caller that takes the key
rechecks the cache first so a fetch that just finished is not repeated.
The key includes the Accept and Accept-Encoding headers, which change the
bytes upstream returns (npm's abbreviated and full documents), and whether
the caller validates the response.

One difference from artifacts: the shared fetch is detached from the first
caller's cancellation. Artifact fetches keep it for scanning and mirroring,
neither of which applies to metadata, and otherwise one job disconnecting
would fail every job waiting on the same document. runScan detaches the
same way, and the HTTP client's timeout still bounds the fetch.

This covers every ecosystem that goes through fetchOrCacheMetadata
(Composer, npm, PyPI, Maven, Cargo, Swift, Pub, Helm, NuGet), with
metadata caching on or off. Streamed metadata with caching off is not
coalesced.

On an 8 vCPU test instance with caching off, 10 concurrent clients
requesting the npm metadata of 1,263 packages went from 12,630 upstream
requests to 1,898. With cache_metadata on, 20 clients requesting 1,538
packages made exactly 1,538.

* Run each caller's validate on a shared metadata fetch

A caller that joined another's metadata fetch got the shared bytes
without running its own validate. NuGet's cooldown path uses validate to
decode the document for the request, so a joined download found no
publication date and was allowed, bypassing the cooldown.

coalesceMetadata now reports whether the caller joined, and
coalescedMetadataMiss runs that caller's validate on the shared bytes.
Validation before caching is unchanged: the first caller still runs it
inside the fetch.

TestNuGetCooldownConcurrentDownloads reproduces the bypass through
NuGetHandler.Routes() with two overlapping downloads of a version inside
its cooldown.

* Support npm content-addressed tarball URLs

* Add cooldown package pattern overrides (#407)

* Add cooldown package pattern overrides

* Expose full cooldown decisions through pattern policy

* Reject unsafe cooldown pattern syntax

* Fix cached package ecosystem filters

* Update supportedEcosystems comment for filter pills

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: pinguinfuss <pinguinfuss@users.noreply.github.com>
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
Co-authored-by: montehurd <montehurd@users.noreply.github.com>
Co-authored-by: Andrew Rich <andrew.rich@beacon.bio>
Co-authored-by: Abhinav Gautam <abgautam1017@gmail.com>
Co-authored-by: montehurd <mhurd@wikimedia.org>
Co-authored-by: wickedOne <wicliff.wolda@gmail.com>
Co-authored-by: Wicliff Wolda <wicliff.wolda@quentic.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Daniil Skripchenko <90247855+DANIILSKRIPCHENKO@users.noreply.github.com>
Co-authored-by: PhantomPhoton <120879831+PhantomPhoton@users.noreply.github.com>
2026-10-05 08:20:36 +01:00
.github Install development tools directly in CI (#398) 2026-10-01 19:48:27 +01:00
cmd/proxy Add storage.cache_artifacts to serve artifacts without storing them (#387) 2026-10-02 09:28:20 +01:00
deploy Add a Grafana dashboard 2026-10-01 16:15:03 +02:00
docs Add cooldown package pattern overrides (#407) 2026-10-04 20:48:41 +01:00
internal Filter cached packages by ecosystem using pills (#319) 2026-10-05 08:20:36 +01:00
.gitignore Make built-in upstream URLs configurable (#255) 2026-08-29 11:19:54 +01:00
.golangci.yml Add .golangci.yml and clear gocognit/goconst findings (#113) 2026-05-05 10:25:17 +01:00
.goreleaser.yaml Use cosign v4 bundle format for release signing 2026-04-27 21:26:23 +01:00
config.example.yaml Add cooldown package pattern overrides (#407) 2026-10-04 20:48:41 +01:00
CONTRIBUTING.md Add the /ui/analytics page 2026-10-01 16:15:03 +02:00
docker-compose.yml Add gocloud.dev/blob for S3 and filesystem storage 2026-01-29 16:13:16 +00:00
Dockerfile build(deps): bump alpine from 3.24.1 to 3.24.2 (#397) 2026-10-01 16:46:01 +01:00
go.mod Install development tools directly in CI (#398) 2026-10-01 19:48:27 +01:00
go.sum Install development tools directly in CI (#398) 2026-10-01 19:48:27 +01:00
LICENSE Add LICENSE 2026-04-13 10:36:34 +01:00
package-lock.json Refactoring and features 2026-02-03 22:40:40 +00:00
README.md Merge pull request #393 from wickedOne/analytics-6-source-attribution 2026-10-02 10:38:16 +01:00

git-pkgs proxy

A caching proxy for package registries. Speeds up package downloads by caching artifacts locally, reducing bandwidth usage and improving reliability.

Version Cooldown

Most supply chain attacks rely on speed: a malicious version gets published and consumed by automated pipelines within minutes, before anyone notices. The cooldown feature adds a quarantine period to newly published versions. When enabled, the proxy strips versions from metadata responses until they've aged past a configurable threshold.

cooldown:
  default: "3d"              # hide versions published less than 3 days ago
  ecosystems:
    npm: "7d"                # npm gets a longer window
    cargo: "0"               # disable for cargo
  packages:
    "pkg:npm/lodash": "0"    # exempt trusted packages

A 3-day cooldown means that when lodash publishes version 4.18.0, your builds keep using 4.17.21 until 3 days have passed. If the new release turns out to be compromised, you were never exposed.

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 for the full config reference.

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.

scanning:
  enabled: true
  signing_key: ${PROXY_SCANNING_SIGNING_KEY}
  scanners:
    - name: clamav
      url: http://clamav-adapter:8080/scan
      mode: block             # a block verdict deletes the artifact and returns 403
    - name: trivy
      url: http://trivy-adapter:8081/scan
      mode: monitor            # findings are logged, never gate caching
      ecosystems: [npm, pypi]

The proxy never uploads artifact bytes to a scanner. Each scanner is notified with package metadata plus a short-lived signed URL; the scanner pulls the bytes itself from the proxy's own storage. Scanners run concurrently, and the first block-mode scanner to report a verdict of not-allowed wins immediately, canceling the rest. See docs/configuration.md for the full config reference and the scanner HTTP contract.

Supported Registries

Registry Language/Platform Cooldown Completed
npm JavaScript Yes ✓
Cargo Rust Yes ✓
RubyGems Ruby Yes ✓
Go proxy Go ✓
Hex Elixir Yes* ✓
pub.dev Dart Yes ✓
PyPI Python Yes ✓
Maven Java ✓
Gradle Build Cache Java/Kotlin ✓
NuGet .NET Yes ✓
Composer PHP Yes ✓
Conan C/C++ ✓
Conda Python/R Yes ✓
CRAN R ✓
Julia Julia ✓
Swift Swift ✓
Container Docker/OCI ✓
Homebrew macOS/Linux ✓
Debian Debian/Ubuntu ✓
RPM RHEL/Fedora ✓
Alpine Alpine Linux ✓
Arch Arch Linux ✗
Chef Chef ✗
Generic Any ✓
Helm Kubernetes ✓
Vagrant Vagrant ✗

Cooldown requires publish timestamps in metadata. Registries without a "Yes" in the cooldown column either don't expose timestamps or haven't been wired up yet.

* Hex cooldown requires disabling registry signature verification (HEX_NO_VERIFY_REPO_ORIGIN=1) since the proxy re-encodes the protobuf payload.

Install

brew install git-pkgs/git-pkgs/proxy

Or download a binary from the releases page.

Helm

Install the chart from GHCR, setting the public URL that package-manager clients will use to reach the proxy:

helm install proxy oci://ghcr.io/git-pkgs/charts/proxy \
  --set config.data.base_url=https://proxy.example.com

The default chart deploys one replica backed by a 10 GiB persistent volume, using SQLite and filesystem artifact storage under /data. See deploy/charts/proxy/values.yaml for ingress, external database and object-storage configuration options.

Quick Start

# Build from source
go build -o proxy ./cmd/proxy

# Run with defaults (listens on :8080)
./proxy

# Run with custom settings
./proxy -listen :3000 -base-url https://proxy.example.com

The proxy is now running. Configure your package managers to use it.

OpenAPI (Swagger)

This repo uses swaggo to generate an OpenAPI spec from annotated handlers.

Generate the spec:

go install github.com/swaggo/swag/cmd/swag@latest
go generate ./internal/server

Generated files are written to docs/swagger/.

When the proxy is running, fetch the live spec from:

  • http://localhost:8080/openapi.json

Or replace http://localhost:8080 with your configured base URL. This link is also shown on the dashboard.

Configuring Package Managers

npm

Create or edit ~/.npmrc:

registry=http://localhost:8080/npm/

Or set per-project in .npmrc:

registry=http://localhost:8080/npm/

Or use environment variable:

npm_config_registry=http://localhost:8080/npm/ npm install

npm audit, pnpm audit, yarn npm audit and npm audit signatures work through the proxy: the audit and signing-key endpoints are passed through to the configured upstream registry, with upstream authentication applied. Advisories therefore come from upstream's database, not from the proxy's own vulnerability data, and versions withheld by cooldown are not excluded from the report.

Cargo

Create or edit ~/.cargo/config.toml:

[source.crates-io]
replace-with = "proxy"

[source.proxy]
registry = "sparse+http://localhost:8080/cargo/"

Or set per-project in .cargo/config.toml in your project root.

RubyGems / Bundler

Set the gem source in your Gemfile:

source "http://localhost:8080/gem"

Or configure globally:

gem sources --add http://localhost:8080/gem/
bundle config mirror.https://rubygems.org http://localhost:8080/gem

Go modules

Set the GOPROXY environment variable:

export GOPROXY=http://localhost:8080/go,direct

Or in your shell profile for persistence.

Homebrew

Point Homebrew's JSON API and artifact domain at the proxy:

export HOMEBREW_API_DOMAIN=http://localhost:8080/homebrew
export HOMEBREW_ARTIFACT_DOMAIN=http://localhost:8080

The artifact domain proxies manifests and bottle blobs under /v2/homebrew/core/. GHCR routing is limited to that repository. Source archives, cask application downloads, custom tap artifacts, and legacy flat-file bottle mirrors use Homebrew's normal fallback URLs. Keep fallback enabled by leaving HOMEBREW_ARTIFACT_DOMAIN_NO_FALLBACK unset.

Enable cache_metadata or set PROXY_CACHE_METADATA=true to retain Homebrew JSON API responses for offline fallback. Bottle blobs and their OCI manifests are cached without this setting.

The upstreams default to https://formulae.brew.sh/api for the JSON API and https://ghcr.io for artifacts. To chain this proxy to another proxy, configure its Homebrew endpoints as the upstreams:

upstream:
  homebrew_api: "https://upstream-proxy.example.com/homebrew"
  homebrew_artifact: "https://upstream-proxy.example.com"

The equivalent environment variables are PROXY_UPSTREAM_HOMEBREW_API and PROXY_UPSTREAM_HOMEBREW_ARTIFACT.

Hex (Elixir)

Configure in ~/.hex/hex.config:

{default_url, <<"http://localhost:8080/hex">>}.

Or set the environment variable:

export HEX_MIRROR=http://localhost:8080/hex

pub.dev (Dart/Flutter)

Set the PUB_HOSTED_URL environment variable:

export PUB_HOSTED_URL=http://localhost:8080/pub

PyPI (pip)

Configure pip to use the proxy:

pip install --index-url http://localhost:8080/pypi/simple/ package_name

Or set in ~/.pip/pip.conf:

[global]
index-url = http://localhost:8080/pypi/simple/

Maven

Add to your ~/.m2/settings.xml:

<settings>
  <mirrors>
    <mirror>
      <id>proxy</id>
      <mirrorOf>central</mirrorOf>
      <url>http://localhost:8080/maven/</url>
    </mirror>
  </mirrors>
</settings>

The /maven/ endpoint uses Maven Central as primary upstream and falls back to the Gradle Plugin Portal for Gradle plugin marker metadata and related artifacts when the primary upstream returns not found.

For Gradle plugin resolution via the same proxy endpoint:

pluginManagement {
  repositories {
    maven(url = "http://localhost:8080/maven/")
  }
}

Gradle HTTP Build Cache

Configure in settings.gradle(.kts):

buildCache {
  local {
    enabled = false
  }
  remote<HttpBuildCache> {
    url = uri("http://localhost:8080/gradle/")
    push = true
  }
}

NuGet

Configure in nuget.config:

<configuration>
  <packageSources>
    <clear />
    <add key="proxy" value="http://localhost:8080/nuget/v3/index.json" />
  </packageSources>
</configuration>

Or use the CLI:

dotnet nuget add source http://localhost:8080/nuget/v3/index.json -n proxy

Composer (PHP)

Configure in composer.json:

{
    "repositories": [
        {
            "type": "composer",
            "url": "http://localhost:8080/composer"
        }
    ]
}

Or set globally:

composer config -g repositories.proxy composer http://localhost:8080/composer

Conan (C/C++)

Add the proxy as a remote:

conan remote add proxy http://localhost:8080/conan
conan remote disable conancenter

Or configure in ~/.conan2/remotes.json.

Conda

Configure in ~/.condarc:

channels:
  - http://localhost:8080/conda/main
  - http://localhost:8080/conda/conda-forge
default_channels:
  - http://localhost:8080/conda/main

Or set via command:

conda config --add channels http://localhost:8080/conda/main

CRAN (R)

Set the repository in R:

options(repos = c(CRAN = "http://localhost:8080/cran"))

Or in ~/.Rprofile for persistence:

local({
  r <- getOption("repos")
  r["CRAN"] <- "http://localhost:8080/cran"
  options(repos = r)
})

Julia

Set the Pkg server before starting Julia:

export JULIA_PKG_SERVER=http://localhost:8080/julia

Or inside a running session:

ENV["JULIA_PKG_SERVER"] = "http://localhost:8080/julia"
using Pkg; Pkg.update()

Swift

Configure the proxy as the default registry for the current Swift package:

swift package-registry set --allow-insecure-http http://localhost:8080/swift

Registry dependencies use their scoped package identifier in Package.swift:

dependencies: [
    .package(id: "apple.swift-argument-parser", from: "1.2.0")
]

The proxy supports dependency resolution and source downloads. Publishing with swift package-registry publish is not supported.

Docker / Container Registry

Configure Docker to use the proxy as a registry mirror in /etc/docker/daemon.json:

{
  "registry-mirrors": ["http://localhost:8080"]
}

Then restart Docker:

sudo systemctl restart docker

Or pull images directly:

docker pull localhost:8080/library/nginx:latest

Helm

Configure each HTTP chart repository with a name, then add the matching proxy URL to Helm:

upstream:
  helm:
    bitnami: "https://charts.bitnami.com/bitnami"
helm repo add bitnami http://localhost:8080/helm/bitnami
helm repo update
helm pull bitnami/nginx

The proxy caches index.yaml using the normal metadata-cache settings and caches chart archives after verifying their SHA-256 digest from the index.

For charts stored in an OCI registry, configure a named OCI upstream and add the reserved upstream/{name} prefix to the chart reference:

upstream:
  oci:
    ghcr: "https://ghcr.io"
helm pull oci://localhost:8080/upstream/ghcr/owner/charts/mychart --version 1.0.0 --plain-http

Debian / APT

Configure APT to use the proxy in /etc/apt/sources.list.d/proxy.list:

deb http://localhost:8080/debian stable main contrib

Replace your existing sources.list entries, then:

sudo apt update

The upstream defaults to http://deb.debian.org/debian. To proxy a different APT repository (e.g. Ubuntu), set upstream.debian in the config file or PROXY_UPSTREAM_DEBIAN in the environment:

upstream:
  debian: "http://archive.ubuntu.com/ubuntu"

A release's security updates are served by a separate archive, so additional archives are configured under upstream.debian_repositories and served at /debian/{name}/:

upstream:
  debian: "http://deb.debian.org/debian"
  debian_repositories:
    security: "https://security.debian.org/debian-security"
deb http://localhost:8080/debian trixie main
deb http://localhost:8080/debian/security trixie-security main

upstream.debian keeps serving /debian/pool/… and /debian/dists/… unchanged. See Debian archives.

The version denylist blocks recognized .deb downloads but leaves signed APT indexes unchanged. To keep APT from selecting a denied version, configure client-side version pinning.

RPM / Yum / DNF

Configure yum/dnf to use the proxy in /etc/yum.repos.d/proxy.repo:

[proxy-fedora]
name=Fedora via Proxy
baseurl=http://localhost:8080/rpm/releases/$releasever/Everything/$basearch/os/
enabled=1
gpgcheck=0

Then:

sudo dnf clean all
sudo dnf update

Alpine / apk

Point /etc/apk/repositories at the proxy. The default repository name alpine proxies the official mirror (https://dl-cdn.alpinelinux.org/alpine):

http://localhost:8080/apk/alpine/v3.22/main
http://localhost:8080/apk/alpine/v3.22/community

Then:

apk update

Repository indexes (v2 APKINDEX.tar.gz and v3 Packages.adb), detached signatures, and packages are served byte-for-byte unchanged, so apk's normal signature verification keeps working. Indexes use the metadata cache (metadata_ttl, stale fallback); .apk packages are stored in the shared artifact cache and remain available when the upstream is unreachable.

To proxy other mirrors or private repositories, configure named upstreams under upstream.apk (this replaces the built-in default; re-add alpine if you still want it):

upstream:
  apk:
    alpine: "https://dl-cdn.alpinelinux.org/alpine"
    private: "https://apk.example.com"
http://localhost:8080/apk/private

apk appends the architecture and index filename to each repository line itself.

GitHub Releases / mise (aqua backend)

Configure named generic upstreams:

upstream:
  generic:
    github: "https://github.com"
    github-api: "https://api.github.com"

Then rewrite GitHub URLs in mise's settings (~/.config/mise/config.toml, mise ≥ 2025.9.3):

[settings.url_replacements]
"regex:^https://github\\.com/([^/]+)/([^/]+)/releases/download/(.+)" = "http://localhost:8080/generic/github/$1/$2/releases/download/$3"
"regex:^https://api\\.github\\.com/(.*)" = "http://localhost:8080/generic/github-api/$1"

Release assets are cached permanently after the first download and keep installing while GitHub is down. Tag lookups through api.github.com are cached for metadata_ttl and served stale during an outage or rate limit. Commit a mise.lock and install with mise install --locked so pinned installs need no API call at all. Add a bearer token for https://api.github.com under upstream.auth if the fleet exceeds GitHub's anonymous rate limit.

Configuration

The proxy can be configured via:

  1. Command line flags (highest priority)
  2. Environment variables
  3. Configuration file (YAML or JSON)

Command Line Flags

-config string           Path to configuration file
-listen string           Address to listen on (default ":8080")
-base-url string         Public URL of this proxy (default "http://localhost:8080")
-storage-url string      Storage URL (file://, s3://, gs://, azblob://)
-storage-path string     Path to artifact storage directory (deprecated, use -storage-url)
-database-driver string  Database driver: sqlite or postgres (default "sqlite")
-database-path string    Path to SQLite database file (default "./cache/proxy.db")
-database-url string     PostgreSQL connection URL
-log-level string        Log level: debug, info, warn, error (default "info")
-log-format string       Log format: text, json (default "text")
-access-log string       Path to the JSONL access log
-version                 Print version and exit

Environment Variables

PROXY_LISTEN=:8080
PROXY_BASE_URL=http://localhost:8080
PROXY_UI_URL=http://localhost:8080  # Optional; defaults to PROXY_BASE_URL
PROXY_STORAGE_URL=file:///var/cache/proxy/artifacts
PROXY_DATABASE_DRIVER=sqlite
PROXY_DATABASE_PATH=./cache/proxy.db
PROXY_DATABASE_URL=postgres://user:pass@localhost/proxy?sslmode=disable
PROXY_LOG_LEVEL=info
PROXY_LOG_FORMAT=text
PROXY_ACCESS_LOG_PATH=/var/log/proxy/access.jsonl
PROXY_UPSTREAM_SWIFT=https://tuist.dev/api/registry/swift

Configuration File

listen: ":8080"
base_url: "http://localhost:8080"

storage:
  url: "file:///var/cache/proxy/artifacts"
  max_size: "10GB"  # Optional: evict LRU when exceeded

database:
  driver: "sqlite"
  path: "/var/lib/proxy/cache.db"

log:
  level: "info"
  format: "text"

access_log:
  path: "/var/log/proxy/access.jsonl"  # Optional JSONL activity log

# Optional: override upstream URLs
upstream:
  npm: "https://registry.npmjs.org"
  cargo: "https://index.crates.io"
  swift: "https://tuist.dev/api/registry/swift"

# Optional: version cooldown (see above)
cooldown:
  default: "3d"

See the configuration reference for every upstream key, environment variable, and default URL.

Run with config file:

./proxy -config /etc/proxy/config.yaml

PostgreSQL

SQLite is the default and works well for single-node deployments. For multi-node setups or if you prefer a managed database, switch to Postgres:

database:
  driver: "postgres"
  url: "postgres://user:password@localhost:5432/proxy?sslmode=disable"

Or via environment variables:

PROXY_DATABASE_DRIVER=postgres
PROXY_DATABASE_URL=postgres://user:password@localhost:5432/proxy?sslmode=disable

The proxy creates tables automatically on first run.

S3 Storage

The proxy can store cached artifacts in S3 or any S3-compatible service (MinIO, R2, etc.) instead of the local filesystem.

storage:
  url: "s3://my-bucket-name?region=us-east-1"

For S3-compatible services like MinIO:

storage:
  url: "s3://my-bucket?endpoint=http://localhost:9000&disableSSL=true&s3ForcePathStyle=true"

Set credentials via standard AWS environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION).

Google Cloud Storage

The proxy can store cached artifacts in a GCS bucket using the gs:// URL scheme.

storage:
  url: "gs://my-bucket-name"

Authentication uses Application Default Credentials, which means no credentials need to be embedded in the config or environment. Supported sources, in order:

  • GKE Workload Identity — bind the Kubernetes service account running the proxy to a Google service account that has roles/storage.objectAdmin on the bucket. The proxy will use the workload's token automatically.
  • Attached service account on GCE, Cloud Run, Cloud Functions, etc.
  • GOOGLE_APPLICATION_CREDENTIALS environment variable pointing at a service account JSON key file.
  • gcloud auth application-default login for local development.

GKE Workload Identity setup

# 1. Create a Google service account
gcloud iam service-accounts create git-pkgs-proxy \
  --project=PROJECT_ID

# 2. Grant it access to the bucket
gsutil iam ch \
  serviceAccount:git-pkgs-proxy@PROJECT_ID.iam.gserviceaccount.com:objectAdmin \
  gs://my-bucket-name

# 3. Bind the Kubernetes service account to it
gcloud iam service-accounts add-iam-policy-binding \
  git-pkgs-proxy@PROJECT_ID.iam.gserviceaccount.com \
  --role=roles/iam.workloadIdentityUser \
  --member="serviceAccount:PROJECT_ID.svc.id.goog[NAMESPACE/KSA_NAME]"

# 4. Annotate the Kubernetes service account
kubectl annotate serviceaccount KSA_NAME \
  --namespace=NAMESPACE \
  iam.gke.io/gcp-service-account=git-pkgs-proxy@PROJECT_ID.iam.gserviceaccount.com

Direct serve (signed URLs) with Workload Identity

When direct_serve: true is enabled, the proxy issues HTTP 302 redirects to presigned GCS URLs. Workload Identity provides no private key, so the GCS backend calls the IAM Credentials signBlob API. Grant the service account the token-creator role on itself:

gcloud iam service-accounts add-iam-policy-binding \
  git-pkgs-proxy@PROJECT_ID.iam.gserviceaccount.com \
  --role=roles/iam.serviceAccountTokenCreator \
  --member="serviceAccount:git-pkgs-proxy@PROJECT_ID.iam.gserviceaccount.com"

CLI Commands

serve (default)

Start the proxy server. This is the default command if none is specified.

proxy serve [flags]
proxy [flags]  # same as 'proxy serve'

mirror

Pre-populate the cache from PURLs, SBOM files, or entire registries. Useful for ensuring offline availability or warming the cache before deployments.

# Mirror specific package versions
proxy mirror pkg:npm/lodash@4.17.21 pkg:cargo/serde@1.0.0

# Mirror all versions of a package
proxy mirror pkg:npm/lodash

# Mirror from a CycloneDX or SPDX SBOM
proxy mirror --sbom sbom.cdx.json

# Preview what would be mirrored
proxy mirror --dry-run pkg:npm/lodash

# Control parallelism
proxy mirror --concurrency 8 pkg:npm/lodash@4.17.21

The mirror command accepts the same storage and database flags as serve. Already-cached artifacts are skipped.

A mirror API is also available when the server is running:

# Start a mirror job
curl -X POST http://localhost:8080/api/mirror \
  -H "Content-Type: application/json" \
  -d '{"purls": ["pkg:npm/lodash@4.17.21"]}'

# Start a mirror job from an inline CycloneDX or SPDX JSON SBOM
curl -X POST http://localhost:8080/api/mirror \
  -H "Content-Type: application/json" \
  -d '{"sbom":{"bomFormat":"CycloneDX","components":[{"purl":"pkg:npm/lodash@4.17.21"}]}}'

# Check job status
curl http://localhost:8080/api/mirror/mirror-1

# Cancel a running job
curl -X DELETE http://localhost:8080/api/mirror/mirror-1

stats

Show cache statistics without running the server.

# Text output
proxy stats

# JSON output
proxy stats -json

# Custom database path
proxy stats -database-path /var/lib/proxy/cache.db

# With PostgreSQL
proxy stats -database-driver postgres -database-url postgres://user:pass@localhost/proxy

# Show top 20 most popular packages
proxy stats -popular 20

Example output:

Cache Statistics
================

Packages:   45
Versions:   128
Artifacts:  128
Total size: 892.4 MB
Total hits: 1547

Packages by ecosystem:
  npm        32
  cargo      13

Most popular packages:
   1. npm/lodash (342 hits, 24.7 KB)
   2. npm/react (198 hits, 89.3 KB)
   3. cargo/serde (156 hits, 234.1 KB)

Recently cached:
  npm/express@4.18.2 (2024-01-15 14:32, 54.2 KB)
  cargo/tokio@1.35.0 (2024-01-15 14:28, 412.8 KB)

API Endpoints

Registry Protocols

Endpoint Description
GET / Dashboard (web UI)
GET /health Health check and upstream circuit breaker state (JSON; HTTP 200 healthy, 503 unhealthy)
GET /stats Cache statistics (JSON)
GET /metrics Prometheus metrics
GET /npm/* npm registry protocol
POST /npm/-/npm/v1/security/* npm/pnpm/Yarn audit endpoints, passed through to upstream
GET /npm/-/npm/v1/keys npm registry signing keys, passed through to upstream
GET /cargo/* Cargo sparse index protocol
GET /gem/* RubyGems protocol
GET /go/* Go module proxy protocol
GET /hex/* Hex.pm protocol
GET /pub/* pub.dev protocol
GET /pypi/* PyPI simple/JSON API
GET /maven/* Maven repository protocol
GET /nuget/* NuGet V3 API
GET /composer/* Composer/Packagist protocol
GET /conan/* Conan C/C++ protocol
GET /conda/* Conda/Anaconda protocol
GET /cran/* CRAN (R) protocol
GET /julia/* Julia Pkg server protocol
GET /swift/* Swift Package Registry v1 protocol
GET /helm/{repository}/* HTTP Helm chart repository protocol
GET /homebrew/* Homebrew JSON API
GET /v2/* OCI/Docker registry protocol
GET /v2/homebrew/core/* Homebrew core bottle manifests and blobs from GHCR
GET /apk/{repository}/* Alpine APK repository protocol
GET /generic/{name}/* Generic HTTP download proxy (GitHub release assets, mise/aqua)
GET /debian/* Debian/APT repository protocol (main archive)
GET /debian/{repository}/* Debian/APT repository protocol (named archive, e.g. security)
GET /rpm/* RPM/Yum repository protocol

Mirror API

Endpoint Description
POST /api/mirror Start a mirror job (JSON body with purls or an inline sbom)
GET /api/mirror/{id} Get job status and progress
DELETE /api/mirror/{id} Cancel a running job

Enrichment API

The proxy provides REST endpoints for package metadata enrichment, vulnerability scanning, and outdated detection.

Endpoint Description
GET /api/package/{ecosystem}/{name} Get package metadata
GET /api/package/{ecosystem}/{name}/{version} Get version metadata with vulnerabilities
GET /api/vulns/{ecosystem}/{name} Get all vulnerabilities for a package
GET /api/vulns/{ecosystem}/{name}/{version} Get vulnerabilities for a specific version
POST /api/outdated Check multiple packages for outdated versions
POST /api/bulk Bulk package metadata lookup

Get Package Metadata

curl http://localhost:8080/api/package/npm/lodash

Response:

{
  "ecosystem": "npm",
  "name": "lodash",
  "latest_version": "4.17.21",
  "license": "MIT",
  "license_category": "permissive",
  "description": "Lodash modular utilities",
  "homepage": "https://lodash.com/",
  "repository": "https://github.com/lodash/lodash",
  "registry_url": "https://registry.npmjs.org"
}

Get Version with Vulnerabilities

curl http://localhost:8080/api/package/npm/lodash/4.17.0

Response:

{
  "package": {
    "ecosystem": "npm",
    "name": "lodash",
    "latest_version": "4.17.21",
    "license": "MIT",
    "license_category": "permissive"
  },
  "version": {
    "ecosystem": "npm",
    "name": "lodash",
    "version": "4.17.0",
    "license": "MIT",
    "published_at": "2016-06-17T03:59:56Z",
    "yanked": false,
    "is_outdated": true
  },
  "vulnerabilities": [
    {
      "id": "GHSA-p6mc-m468-83gw",
      "summary": "Prototype Pollution in lodash",
      "severity": "HIGH",
      "cvss_score": 7.4,
      "fixed_version": "4.17.12"
    }
  ],
  "is_outdated": true,
  "license_category": "permissive"
}

Check Outdated Packages

curl -X POST http://localhost:8080/api/outdated \
  -H "Content-Type: application/json" \
  -d '{
    "packages": [
      {"ecosystem": "npm", "name": "lodash", "version": "4.17.0"},
      {"ecosystem": "pypi", "name": "requests", "version": "2.25.0"}
    ]
  }'

Response:

{
  "results": [
    {
      "ecosystem": "npm",
      "name": "lodash",
      "version": "4.17.0",
      "latest_version": "4.17.21",
      "is_outdated": true
    },
    {
      "ecosystem": "pypi",
      "name": "requests",
      "version": "2.25.0",
      "latest_version": "2.31.0",
      "is_outdated": true
    }
  ]
}

Bulk Package Lookup

curl -X POST http://localhost:8080/api/bulk \
  -H "Content-Type: application/json" \
  -d '{
    "purls": [
      "pkg:npm/lodash@4.17.21",
      "pkg:pypi/requests@2.28.0"
    ]
  }'

Response:

{
  "packages": {
    "pkg:npm/lodash": {
      "ecosystem": "npm",
      "name": "lodash",
      "latest_version": "4.17.21",
      "license": "MIT",
      "license_category": "permissive"
    },
    "pkg:pypi/requests": {
      "ecosystem": "pypi",
      "name": "requests",
      "latest_version": "2.31.0",
      "license": "Apache-2.0",
      "license_category": "permissive"
    }
  }
}

Stats Response (HTTP endpoint)

{
  "cached_artifacts": 142,
  "total_size_bytes": 523456789,
  "total_size": "499.2 MB",
  "storage_url": "file:///path/to/cache/artifacts",
  "database_path": "./cache/proxy.db"
}

How It Works

  1. Package manager requests package metadata from the proxy
  2. Proxy fetches metadata from upstream, rewrites artifact URLs to point at proxy
  3. Package manager requests artifact (tarball, crate, etc.)
  4. Proxy checks local cache:
    • Cache hit: Serve from local storage
    • Cache miss: Fetch from upstream, store locally, serve to client
  5. Subsequent requests for the same artifact are served from cache
┌─────────────┐     ┌─────────┐     ┌──────────┐
│   npm/cargo │────▶│  proxy  │────▶│ upstream │
│   client    │◀────│         │◀────│ registry │
└─────────────┘     └─────────┘     └──────────┘
                         │
                         ▼
                    ┌─────────┐
                    │  cache  │
                    │ storage │
                    └─────────┘

Web Interface

The proxy serves a web UI under /ui. No separate frontend build is needed -- templates and assets are embedded in the binary. GET / redirects to /ui/. The UI is mounted under its own prefix so a reverse proxy can apply different access rules to it than to the package endpoints (for example, requiring auth for PathPrefix(/ui) while leaving /npm, /pypi etc. open to build machines).

  • Dashboard (/ui/) -- cache stats, popular packages, recently cached artifacts, and vulnerability overview.
  • Analytics (/ui/analytics) -- accumulated download size as a ring broken down by ecosystem with the total in the middle, the cache size, artifact, package and version counts, a per-ecosystem table, the vulnerability overview, and a Runtime card mirroring every counter /metrics exposes. See Analytics.
  • Install guide (/ui/install) -- per-ecosystem configuration instructions, so you don't have to look them up here.
  • Package browser (/ui/packages) -- browse all cached packages with filtering by ecosystem and sorting by hits, size, name, or vulnerability count.
  • Search (/ui/search?q=...) -- search cached packages by name.
  • Package detail (/ui/package/{ecosystem}/{name}) -- metadata, license, vulnerabilities, and version list for a package. You can select two versions to compare.
  • Version detail (/ui/package/{ecosystem}/{name}/{version}) -- per-version metadata, integrity hash, artifact cache status, and hit counts.
  • Source browser (/ui/package/{ecosystem}/{name}/{version}/browse) -- browse files inside cached archives with syntax highlighting for text files and image previews.
  • Version diff (/ui/package/{ecosystem}/{name}/compare/{v1}...{v2}) -- side-by-side diff of two cached versions showing added, removed, and changed files.

Monitoring

The proxy exposes Prometheus metrics at GET /metrics. All metric names are prefixed with proxy_.

Metric Type Labels Description
proxy_requests_total counter ecosystem, status Proxy responses by package ecosystem and HTTP status
proxy_request_duration_seconds histogram ecosystem, status Proxy request duration
proxy_cache_hits_total counter ecosystem Cache hits
proxy_cache_misses_total counter ecosystem Cache misses
proxy_cache_size_bytes gauge Total size of cached artifacts
proxy_cached_artifacts_total gauge Number of cached artifacts
proxy_upstream_fetch_duration_seconds histogram ecosystem Time spent fetching from upstream
proxy_upstream_errors_total counter ecosystem, error_type Upstream fetch failures
proxy_storage_operation_duration_seconds histogram operation Storage read/write latency
proxy_storage_errors_total counter operation Storage read/write failures
proxy_active_requests gauge In-flight requests
proxy_health_probe_failures_total counter step Storage health probe failures by failing step (write, size, read, verify, delete).
proxy_circuit_breaker_state gauge registry Artifact-fetch circuit breaker state per upstream registry (0 closed, 2 open). Published once that registry's breaker has tripped.
proxy_circuit_breaker_trips_total counter registry Circuit breaker trips per upstream registry.
proxy_ecosystem_downloaded_bytes gauge ecosystem Accumulated bytes served from cache: cache hits multiplied by the artifact size they served.
proxy_ecosystem_artifact_downloads gauge ecosystem Accumulated artifact downloads served from cache.
proxy_ecosystem_cache_size_bytes gauge ecosystem Size of cached artifacts per ecosystem.
proxy_ecosystem_cached_artifacts gauge ecosystem Number of cached artifacts per ecosystem.
proxy_ecosystem_packages gauge ecosystem Known packages per ecosystem.
proxy_ecosystem_versions gauge ecosystem Known package versions per ecosystem.
proxy_response_bytes_total counter ecosystem Response body bytes written to clients. Route-labelled, see the label caveat below.
proxy_client_requests_total counter client Requests by client tool, from the User-Agent.
proxy_client_response_bytes_total counter client Response bytes by client tool.

The ecosystem label on proxy_requests_total and proxy_request_duration_seconds is the mounted route a request arrived on, not the ecosystem recorded against the package it served: /gem reports as rubygems, /go as golang, /composer as packagist, /apk as alpine and /v2 as oci. Anything outside a package route -- the UI, /health, /metrics, /stats -- reports as other, and so did /apk, /helm, /homebrew, /generic and /swift before they were listed; traffic on those five routes now appears under its own name instead.

Cache size, artifact count and the per-ecosystem gauges are refreshed every 60 seconds, from a single pass over the database. Circuit breaker state is read from the fetcher on each scrape of /metrics and each /health request, so proxy_circuit_breaker_trips_total counts the trips visible between those reads — a breaker that opens and recovers entirely between two scrapes is not counted. The remaining metrics update on each request.

The breaker metrics carry one series per upstream host, but only for hosts whose breaker has tripped at least once since startup. A breaker is created per host the proxy fetches artifacts from, and for some ecosystems that host comes from upstream metadata rather than from configuration (composer takes it from a package's dist.url, helm from the chart URLs in index.yaml), so publishing every host would let upstream content grow the series count for the lifetime of the process. Once a host has tripped it keeps reporting, so a recovery still shows up as a transition to 0 rather than as a series that vanishes. /health is not a persistent time series and lists every breaker, tripped or not.

The registry label is the host of the URL the artifact was fetched from. Because that URL can come from upstream metadata, it is not always one a host can be read off — a signed dist.url that fails to parse, for instance — and such a breaker is labelled hostless-url-<digest> instead, where the digest is keyed by a value drawn fresh at startup. Neither /metrics nor /health requires authentication, so a fetch URL is never published as a label or a key; the digest identifies the breaker for as long as the process runs without revealing the URL behind it or letting a chosen URL be matched against it.

Alert on proxy_circuit_breaker_state == 2 sustained for more than a few minutes: while a breaker is open, artifact downloads for that upstream fail with HTTP 502 on every cache miss, and only a single probe request per backoff interval reaches the upstream. Cached artifacts keep serving, and so does metadata for the same ecosystem (metadata does not go through the circuit breaker), so installs fail in a way that looks like a partial upstream outage.

Accumulated download size

proxy_ecosystem_downloaded_bytes is, for every cached artifact, the number of times it was served multiplied by its size. It answers "how much traffic has this proxy actually carried", which is the number that matters when sizing egress or justifying the cache. Two properties are worth knowing before alerting on it.

It counts cache hits, not upstream fetches. The request that first pulls an artifact through the proxy is a miss and is not counted; only later hits are. So the accumulated total is also the upstream bandwidth the cache has saved, not the total bytes the proxy has ever sent.

Eviction removes history. Evicting an artifact clears its size, so its past hits drop out of the total. That is why these are gauges rather than counters, and why the figure can step downwards. Chart them with max_over_time rather than increase, and read a drop after an eviction sweep as expected rather than as data loss.

Nothing at the schema level ties artifacts.version_purl to a version row, so a cached artifact can end up with no ecosystem to attribute it to. Those are reported under the ecosystem unattributed rather than dropped, which keeps the per-ecosystem figures adding up to proxy_cache_size_bytes and proxy_cached_artifacts_total. A non-zero unattributed means the database holds artifact rows whose version or package rows have gone missing.

The ecosystem label on these six is taken from the package record and normalized, so aliases collapse: a database carrying both gem and rubygems rows -- the proxy writes the former, git-pkgs the latter -- reports one rubygems series with the two summed.

Analytics

/ui/analytics reports the accumulated download size as a ring broken down by ecosystem, the cache figures from the dashboard, a per-ecosystem table, the vulnerability overview, and a Runtime card covering every remaining metric /metrics exposes.

The ring shows at most six slices, because part-to-whole stops being readable past that. When more ecosystems are active the smallest are folded into a single "Other" slice; the table below lists every one of them, so nothing is hidden, only summarised.

No history is kept

The proxy stores no time series. The page reads the database and the in-process metric registry at request time and reports current state; there is nowhere for it to read yesterday's figures from, and nothing is written for tomorrow. That splits the figures in two, and the page says which is which.

Database-derived figures survive a restart. Download volume, cache size and the package, version and artifact counts come from the artifacts, packages and versions tables, so they are as durable as the database.

Registry-derived figures do not. Everything in the Runtime card -- request counts and latencies, cache hit rate, upstream and storage errors, circuit breaker state, scan results -- lives only in this process's Prometheus registry and starts from zero on restart. A small number there next to a large one above just means the proxy started recently.

For history, trends and alerting, scrape /metrics with Prometheus. That is the intended split: the UI answers "what is true now", Prometheus answers "what happened".

The same figures are available as JSON from GET /stats, which reports downloaded_bytes, downloads and an ecosystems array carrying the per-ecosystem breakdown, served from the same 60-second snapshot the page and the gauges read. When the aggregation fails with no snapshot to fall back on, the response carries stats_unavailable: true rather than passing zeros off as a count -- the endpoint keeps answering with the artifact count and cache size either way.

Three ecosystem label sets

ecosystem means three slightly different things across /metrics, and queries that join across them need to know which.

From the package record, normalized. The six proxy_ecosystem_* gauges, proxy_cache_hits_total, proxy_cache_misses_total, proxy_integrity_failures_total and the scan metrics. Aliases collapse here: gem reads as rubygems, composer as packagist, go as golang.

From the request path. proxy_requests_total, proxy_request_duration_seconds and proxy_response_bytes_total. The names mostly coincide with the normalized ones -- these also report rubygems, packagist and golang -- but the Debian route reports debian where the package record says deb, and any path that is not a package endpoint reports other, which corresponds to no ecosystem at all.

From the handler's own name. proxy_upstream_fetch_duration_seconds and proxy_upstream_errors_total, which report composer, gem and go where the other two sets report packagist, rubygems and golang. These are published series and are deliberately left as they are; renaming them would break existing queries and alerts.

Request sources

Package managers do not say who invoked them. A request from pip or go carries a Host, an Accept and a User-Agent -- no Referer, no originating URL, nothing naming a repository, pipeline or job. Whatever identity you want has to come from something on the wire, so the proxy attributes requests by the two things always present.

Address -- the TCP peer, or the leftmost X-Forwarded-For entry when trust_forwarded_for is enabled. Enable that only behind a load balancer or ingress that sets the header; any client can send it, so in front of one it lets a caller forge its own attribution and, by cycling synthetic addresses, fill the table and push every genuine caller into the overflow row. The totals stay correct; the attribution is what is lost.

Client -- the tool, taken from the leading User-Agent token: pip, npm, go, docker, apt, curl and so on. Anything unrecognised reports as other.

Set ui_request_sources: true and both appear on /ui/analytics under Runtime -> Request sources, as a table of the busiest callers by bytes downloaded plus a per-tool breakdown. The table is in-memory and process-lifetime, like the rest of that card. It tracks 200 callers, evicting the least recently seen once full, and summarises everything it is not showing individually -- both evicted callers and those ranked below the display limit -- in a single "other callers" row, so the rows always add up to the totals above them.

The flag defaults to off because the page is not authenticated. /ui carries no auth of its own -- it is mounted under its own prefix so a reverse proxy can gate it separately, as Behind a Reverse Proxy describes, but nothing makes you -- and until now it exposed only package data. The sources table changes what is on offer: anyone who can reach the proxy can read the addresses of your build fleet, which tool each runs, and how much each pulled. Turn it on once /ui is gated, or leave it off and read the same detail from the access log.

What this can and cannot tell you. How much an address gives you depends entirely on your network. A fleet of build machines with stable addresses attributes cleanly. Containerised CI usually does not: with Docker or Kubernetes executors every job gets an ephemeral address, and egress is commonly NAT'd behind one gateway, so you get runner-node or gateway granularity, not per-project. If you need per-project attribution the caller has to send something naming itself -- a basic-auth username, or a per-project base URL -- which the proxy does not currently read. Say so and it can be added.

Why addresses are not Prometheus labels. Client tool names are exported as proxy_client_requests_total{client} because they come from a closed set. Addresses are not exported at all: the caller set is unbounded and outside the proxy's control, and every new address would create a time series that lives forever in your TSDB. The same goes for anything job-scoped -- a pipeline ID must never become a label. Per-address and per-request detail belongs in the access log, which records remote_ip, user_agent, client, ecosystem and bytes on every line as JSONL, ready for jq, Loki or whatever you ship logs to.

Grafana dashboard

A ready-made dashboard lives at deploy/grafana/git-pkgs-proxy.json. Import it via Dashboards -> New -> Import and pick your Prometheus data source when prompted; it has no hardcoded data source UID.

It carries three ecosystem filters rather than one, because ecosystem means three different things across /metrics -- see the label sets above. Ecosystem filters the database-derived gauges, Route the request-path counters, and Upstream the two upstream fetch metrics. All three are query variables, so they populate from whatever labels your proxy is actually reporting; a panel is on the one its metric belongs to, and the panel descriptions say which.

Health Check

/health returns a structured JSON report of subsystem health. HTTP 200 if all checks pass; 503 if any fail.

{
  "status": "ok",
  "checks": {
    "database": {"status": "ok"},
    "storage":  {"status": "ok"}
  },
  "circuit_breakers": {
    "registry.npmjs.org": "closed",
    "static.crates.io":   "open"
  }
}

Failing checks include an "error" field. Storage failures also include a "step" field identifying which probe step failed (write, size, read, verify, delete). When the database check fails, the storage entry reports {"status": "skipped"} so the response always carries the same key set.

circuit_breakers reports the state of each upstream's artifact-fetch circuit breaker ("open" or "closed"), keyed by upstream host — or by the hostless-url-<digest> placeholder described under Monitoring where the fetch URL has no host to read. The key is omitted until the proxy has fetched an artifact from at least one upstream, and a host appears only once a breaker has been created for it. Breakers trip after repeated upstream failures and retry the upstream after an exponential backoff. While one is open, artifact downloads for that host return HTTP 502 on a cache miss without contacting the upstream; already-cached artifacts are still served from storage, since the cache is checked before the fetcher. A breaker is reported as "open" throughout its backoff, including the half-open window in which it admits one probe request to test recovery. Breaker state is per process and in memory, so a restart clears it, but a restart is not needed for recovery: the backoff keeps retrying for as long as the breaker is open, so it closes on its own once the upstream serves again.

An open breaker does not set status to "error" or change the HTTP status code: it reports a specific upstream refusing to serve, not this proxy being unfit to receive traffic, and failing the readiness probe over one unhealthy upstream would pull the pod out of rotation for every other ecosystem too. Use proxy_circuit_breaker_state for alerting on it.

Storage probe results are cached for health.storage_probe_interval (default 30s) to bound the cost of probing remote backends. A probe holds an internal mutex for up to 10 seconds (the hardcoded per-probe timeout), so /health is intended as a Kubernetes readiness probe rather than a liveness probe — a slow S3 round-trip should pull the pod from rotation, not restart it.

Scrape config for Prometheus:

scrape_configs:
  - job_name: git-pkgs-proxy
    static_configs:
      - targets: ["localhost:8080"]

Production Deployment

Systemd Service

Create /etc/systemd/system/proxy.service:

[Unit]
Description=git-pkgs proxy
After=network.target

[Service]
Type=simple
User=proxy
ExecStart=/usr/local/bin/proxy -config /etc/proxy/config.yaml
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

Enable and start:

sudo systemctl enable proxy
sudo systemctl start proxy

Docker

A Dockerfile is included in the repo. Build and run:

docker build -t proxy .
docker run -p 8080:8080 -v proxy-data:/data proxy

With Postgres and S3:

docker run -p 8080:8080 \
  -e PROXY_DATABASE_DRIVER=postgres \
  -e PROXY_DATABASE_URL=postgres://user:pass@db:5432/proxy \
  -e PROXY_STORAGE_URL=s3://my-bucket?region=us-east-1 \
  -e AWS_ACCESS_KEY_ID=... \
  -e AWS_SECRET_ACCESS_KEY=... \
  proxy

Behind a Reverse Proxy

When running behind nginx, Apache, or another reverse proxy, set base_url to your public URL:

base_url: "https://proxy.example.com"

If the UI is reached on a different hostname than the package endpoints — for example, the UI exposed publicly on a domain while build machines hit a Docker network alias — set ui_base_url separately. base_url is the URL package managers and metadata rewriting use; ui_base_url is the URL advertised to humans visiting the web UI (canonical/og:url tags and the install guide banner):

base_url: "http://pkg-proxy:8080"        # internal alias for build machines
ui_base_url: "https://proxy.example.com/ui"  # public UI URL

When unset, ui_base_url defaults to base_url.

Warning: the proxy serves the UI and package endpoints on the same listener. Setting ui_base_url only changes what URL the UI advertises to humans; it does not stop package endpoints from being reachable on the same hostname and port. When fronting the proxy with a public reverse proxy, restrict the public route to PathPrefix(/ui) (or your proxy's equivalent), otherwise /npm, /pypi, and the other package endpoints stay exposed alongside the UI.

nginx example, restricting the public host to the UI while leaving package endpoints reachable only on the internal listener:

server {
    listen 443 ssl;
    server_name proxy.example.com;

    location /ui/ {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_buffering off;
    }

    location / {
        return 404;
    }
}

Traefik example using PathPrefix(/ui) so the public router only matches UI traffic:

labels:
  traefik.enable: "true"
  traefik.http.services.pkg-proxy.loadbalancer.server.port: "8080"
  traefik.http.routers.pkg-proxy.rule: "Host(`proxy.example.com`) && PathPrefix(`/ui`)"
  traefik.http.routers.pkg-proxy.entrypoints: "websecure"

Cache Management

The proxy stores artifacts in the configured storage directory with this structure:

cache/artifacts/
├── npm/
│   └── lodash/
│       └── 4.17.21/
│           └── lodash-4.17.21.tgz
├── cargo/
│   └── serde/
│       └── 1.0.193/
│           └── serde-1.0.193.crate
├── oci/
│   └── library/nginx/
│       └── sha256:abc123.../
│           └── sha256:abc123...
├── deb/
│   └── nginx/
│       └── 1.18.0-6/
│           └── nginx_1.18.0-6_amd64.deb
└── rpm/
    └── nginx/
        └── 1.24.0-1.fc39/
            └── nginx-1.24.0-1.fc39.x86_64.rpm

Cache metadata is stored in SQLite (default) or PostgreSQL. To clear a local cache:

rm -rf ./cache/artifacts/*
rm ./cache/proxy.db

The proxy will recreate the database on next start.

Building from Source

Requirements:

  • Go (the project version is declared in go.mod)
git clone https://github.com/git-pkgs/proxy.git
cd proxy
go build -o proxy ./cmd/proxy

Run tests:

go test ./...

License

GPL-3.0-or-later.