Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-10 08:09:17 -05:00
Commit graph

899 commits

Author SHA1 Message Date
Simon Chrzanowski
3bf0cda238 Read SQLite through a separate read-only pool
SQLite used one connection for everything, so request lookups queued
behind the per-minute stats queries, dashboard pages and eviction scans.
Writes keep the single connection. Get, Select, Query and QueryRow now go
to a pool of query_only connections (GOMAXPROCS, 2 to 8), which WAL mode
lets run alongside the writer. A write sent to that pool fails.

Pragmas move into the DSN so every connection gets them. Postgres and
in-memory SQLite keep one pool for both.
2026-10-25 17:35:49 +01:00
Simon Chrzanowski
3935ccd6f7 Share upstream rate-limit holds across instances
Holds placed by the rate limit gate are written to a new upstream_holds
table, and each instance reads that table at most every two seconds and
refuses requests to a host any instance holds. Before, every replica had
to get its own 429 before it stopped sending.

Instances waiting on a fetch lease reread the holds when the fetch fails,
so when the holder was rate limited they answer 503 with its Retry-After
instead of each sending one more request to the limited host.
2026-10-25 17:34:08 +01:00
Simon Chrzanowski
3afbf2444e Cache Helm and PyPI metadata rewrites
The Helm index.yaml and PyPI simple pages and JSON API documents were
parsed and rewritten on every request, cache hits and conditional
requests included. They now go through the rewrite cache like npm and
Composer, so a repeat request is served from memory with an ETag of
its own, a dated Last-Modified, a gzip copy and 304 when it matches.

PyPI simple pages are keyed by their content type and the versions
filtered out as well as the upstream page. Download hrefs are now
rewritten with a literal prefix search that makes the same changes as
the old regular expression, and the link filter's expressions are
compiled once.

A chart cache miss no longer parses the whole Helm index again while
the stored index is unchanged: the chart downloads it lists are kept
per repository, keyed by the index digest.
2026-10-25 17:32:52 +01:00
Simon Chrzanowski
0b76ca248a Revalidate stale OCI tags with HEAD and share the request
A stale tag was revalidated by repeating the client's GET with
If-None-Match. Docker Hub counts manifest GETs against its pull limit,
so every TTL expiry of a tag could cost a pull. The tag is now resolved
with a HEAD and the Docker-Content-Digest compared with the cached one;
the manifest is fetched by digest only when that digest isn't cached.
A HEAD without a digest falls back to the conditional GET.

A client HEAD whose digest is cached now points the tag at it, so the
GET that follows is a cache hit. Concurrent requests for the same
manifest share one upstream exchange, and instances sharing Postgres
take turns through the fetch lease.
2026-10-25 17:32:22 +01:00
Simon Chrzanowski
674528a25e Skip the pushed artifact lookup when hosting is disabled
Every npm and Composer download ran GetLocalArtifact, a full artifact
row lookup, even with hosted packages disabled. localVersions already
returns early in that case, so metadata does not list pushes, and
/generic/hosted/ is only served while hosting is enabled.

GetLocalArtifact now returns nil without a lookup when hosting is off.
Downloads then take the normal proxy path. An archive pushed earlier is
still answered from storage by that path and is never replaced by the
upstream copy; only cooldown now applies to it, as metadata no longer
lists it either. The hosted docs say so.
2026-10-25 17:32:22 +01:00
Simon Chrzanowski
fd0d9a5df2 Answer PyPI download cooldown from stored upload times
Every PyPI download with cooldown on read the JSON API first, cached
files included. Without cache_metadata that was a full /pypi/<name>/json
per file.

A download that looks up the JSON API now stores the release's newest
upload time on its versions row. A cached file is then checked against
the stored time without asking upstream. A file that isn't cached is
still looked up, since files can be added to a release later, and the
stored time is refreshed. A stored time inside the window withholds the
release without a lookup.
2026-10-25 17:29:22 +01:00
Simon Chrzanowski
2b62037834 Serve immutable Go and RPM metadata without revalidation
A Go module's .mod and .info for a canonical version, and RPM repodata
files named after their checksum, never change once published. They
went through the metadata TTL, so each cost a revalidation every TTL with
cache_metadata on and a full fetch on every request with it off.

They are now kept in the metadata cache with or without cache_metadata
and served whatever their age. A stored copy is checked against its
digest and fetched again if it doesn't match. Go queries, @v/list,
@latest and repomd.xml stay on the TTL.
2026-10-25 17:29:22 +01:00
Simon Chrzanowski
6d39461d3b Set the Go memory limit from the cgroup limit
Nothing set GOMEMLIMIT, so in a container the heap could grow until the
container was OOM-killed while much of it was garbage, for example while
large metadata documents were being rewritten. At startup on Linux, read
the cgroup v2 or v1 memory limit and set the runtime's soft limit to 80%
of it, leaving room for git subprocesses. An explicit GOMEMLIMIT wins.
2026-10-25 17:28:52 +01:00
Simon Chrzanowski
23f48c5563 Answer HEAD on a cached artifact without opening it
A HEAD on a cache hit opened the stored object and closed it unread.
On S3 and Azure that is a GET whose body is thrown away, which can also
drop the pooled connection; on GCS it is a GET as well.

The server now marks HEAD requests in the request context, and readStored
answers a marked cache hit from the database record. Size, content type
and ETag already came from the record. Accept-Ranges depended on the
opened reader being seekable, so backends now say whether their readers
are (Blob does, GCS does not) and the HEAD response matches the GET.

A HEAD still counts as a cache hit, as before. DirectServe redirects are
unchanged since they are decided before the object would be opened.
2026-10-25 17:27:05 +01:00
Simon Chrzanowski
a5868f1e91 Read metadata into a buffer of its known size
ReadMetadata grew its buffer with io.ReadAll, which allocates about 2.5
times the document size in total. Cached documents have a recorded size
and most upstream responses a Content-Length, so read those into one
buffer of that size. The size is a hint: a missing, wrong or over-limit
one still reads the body correctly and enforces the limit.
2026-10-25 17:24:24 +01:00
Simon Chrzanowski
486768ba19 Use smaller upload buffers for S3 and Azure
Store passed empty writer options, so the S3 driver allocated six 8 MiB
part buffers up front for every object over 16 MiB, on top of reading
the first 16 MiB whole, and Azure used five 8 MiB blocks. Use 5 MiB
parts, the S3 minimum, with two uploads in flight.

A 32 MiB store against a fake S3 endpoint allocates 54 MB instead of
88 MB.
2026-10-25 17:22:23 +01:00
Simon Chrzanowski
77bec54bb5 Verify cache hits against the content hash only
A cache hit hashed the stored object with SHA-256 for content_hash and
again with every native algorithm in versions.integrity, SHA-512 for npm.
The native integrity was already checked when the bytes were stored and
content_hash was computed from the same bytes, so the second hash adds
CPU per byte served without catching anything more. Records without a
content hash still fall back to the native integrity.
2026-10-25 17:22:14 +01:00
Simon Chrzanowski
a2889d40a0 Log per-handler request lines at debug level
Each download and metadata handler logged its own request line at info,
next to the request line LoggerMiddleware already writes for every
request with path, status and duration. That doubled the info log volume
on the hot path. The handler lines only repeat values parsed from the
path, so they move to debug.

Kept at info: the npm audit line (it carries the request body size), the
julia package line (it carries the name resolved from the UUID) and the
gem and hex cooldown lines.
2026-10-25 17:21:11 +01:00
Simon Chrzanowski
ae9cf36f26 Mirror Composer releases
Mirroring pkg:composer failed with unsupported ecosystem. A tagged release
is now mirrored with the dist archive upstream.composer lists, under the
file name the Composer handler serves it as and checked against the
listed shasum. The release's publish time is stored as the handler stores
it, so the archive is served from the cache without the metadata, with
cooldown on as well.

Branches are refused, since the handler reads the metadata for every
branch download, and so are archive URLs with a query and packages in
upstream.composer_routes.

The mirror now finds the whole version rather than its files, for the
dist type and publish time.

Part of #414.
2026-10-25 14:36:43 +01:00
Simon Chrzanowski
749934eb6c Mirror conda packages
A conda version is mirrored with every build anaconda.org lists for it,
each cached under the channel, name, version and subdir/filename the conda
download handler reads from its path. The channel comes from the namespace
or the channel qualifier; a package without one is refused, as retention
keys without one are. The subdir, build and type qualifiers select builds.

Which files a version has, which qualifiers select them and where the
handler caches them is now described per ecosystem, so PyPI and conda share
mirrorFiles.

Part of #414.
2026-10-25 14:27:47 +01:00
Simon Chrzanowski
16ae3deb1c Check mirrored files against their own SHA-256
Mirroring a PyPI version passed each file's sha256 as the integrity
GetOrFetchArtifactFromURLWithIntegrity records with the version. Wheels of
one version share the name and version they are cached under, so the last
file's checksum replaced the others', and serving the other wheels from
the cache failed the integrity check.

The mirror now passes the sha256 as a digest, which is checked against
that file's content hash alone.
2026-10-25 14:25:35 +01:00
Andrew Nesbitt
7bc4a27ef1
Merge pull request #575 from simonchrz/fix/metadata-hit-memory
Stream metadata cache hits for ProxyCached
2026-10-25 12:57:54 +00:00
Simon Chrzanowski
6c9505f322
Drop cached metadata that fails its integrity check
A streamed cache hit whose bytes don't match the row's digest now
records an integrity failure and deletes the row, queueing its blob for
deletion, as a corrupt cached artifact is handled. The next request
fetches the document from upstream instead of aborting until the TTL
runs out.
2026-10-25 12:51:11 +00:00
Andrew Nesbitt
18d2ca215a
Merge pull request #585 from simonchrz/fix/cooldown-fail-closed
Fail closed in gem, Hex and PyPI cooldown
2026-10-25 12:49:04 +00:00
Andrew Nesbitt
ff539550a3
Merge pull request #579 from simonchrz/fix/metadata-path-case
Store metadata under a hash of its cache key
2026-10-25 12:48:53 +00:00
Andrew Nesbitt
255ba721f6
Merge pull request #591 from simonchrz/feat/mirror-pypi
Mirror PyPI packages
2026-10-25 12:47:15 +00:00
Andrew Nesbitt
775b92f1b8
Mount an emptyDir at /tmp in the Helm chart (#590)
The chart's root filesystem is read-only, so hosted Composer pushes failed writing their temporary zip.
2026-10-25 12:42:21 +01:00
Simon Chrzanowski
607a55e746 Mirror PyPI packages
proxy mirror and mirror jobs failed for pypi with "unsupported
ecosystem", since the resolver they use has no registry for it. A PyPI
version has several files, so the mirror now lists them from
upstream.pypi's JSON API, through the same client as version listing,
and fetches each one, verified against its sha256. The file_name
qualifier limits it to one file.

Files are cached under the name and version the download handler reads
from the filename, which can differ from the PURL (zope.interface's
wheels are zope_interface), so pip is served them from the cache.
2026-10-25 12:36:36 +01:00
Simon Chrzanowski
4402075916 Document serving cached downloads when publish times can't be read 2026-10-25 12:32:17 +01:00
Simon Chrzanowski
224bce8f51 Serve cached downloads when cooldown can't read publish times
PyPI and npm downloads look up the publish time before the cache. When
that lookup fails, a file that is already cached is now served, and only
a cache miss fails with 502 or 503. The proxy keeps serving what it has
while the upstream is unreachable, and nothing new is fetched unchecked.

Also drops the unattached comment block in cooldown_fail_closed.go.
2026-10-25 12:31:33 +01:00
Simon Chrzanowski
25f62da322 Queue only a row's own legacy metadata blob
The case-variant lookup filtered on lower(name), which no index covers,
so a legacy row's first lookup scanned every metadata_cache row in its
ecosystem. Reclaim already skips a queued path a row still points at,
so each row now queues just its own legacy blob once it has moved off
it. On case-insensitive storage a case variant still at its legacy path
can lose its blob that way, and is then fetched again.
2026-10-25 12:31:10 +01:00
Simon Chrzanowski
f240d3023f Stream metadata cache hits for ProxyCached
With cache_metadata on, every metadata cache hit read the whole stored
document into its own buffer before any coalescing, since
coalesceMetadata only covers upstream misses. metadata_max_size bounds
one request, not the sum: 30 concurrent hits on a 20 MB document held
about 820 MB.

The handlers that go through ProxyCached serve the stored document
unchanged, so a hit there no longer needs the bytes in memory.
serveCachedMetadata streams the blob from storage to the response
instead, with the row's Content-Type, Content-Encoding, size, ETag and
Last-Modified, and answers If-None-Match and If-Modified-Since with 304
as before. The bytes are checked against the row's content digest as
they are sent, and a mismatch aborts the response like a corrupt cached
artifact. A row without a digest or size, a document over the size
limit, or a blob that can't be opened takes the buffered path as before.

Handlers that rewrite or filter metadata keep reading it into memory,
and the rewrite cache stays in front of them. writeMetadataValidators
is split out of writeMetadataCachedResponseWithEncoding so both paths
share the conditional request handling.
2026-10-25 12:29:28 +01:00
Andrew Nesbitt
3146e80fae
Merge pull request #548 from simonchrz/fix/mirror-upstream-versions
List mirror versions from the configured upstream
2026-10-25 11:09:09 +00:00
Andrew Nesbitt
e141f16d17
Merge pull request #577 from simonchrz/fix/ssrf-forward-proxy
Check upstream destinations sent through a forward proxy
2026-10-25 11:09:02 +00:00
Andrew Nesbitt
0fc8ae71d1
Merge main into fix/mirror-upstream-versions
# Conflicts:
#	go.mod
#	go.sum
2026-10-25 10:49:49 +00:00
Andrew Nesbitt
847f4f351c
Merge main into fix/ssrf-forward-proxy
# Conflicts:
#	go.mod
#	go.sum
2026-10-25 10:36:38 +00:00
Andrew Nesbitt
c6ccbcb974
Merge pull request #588 from simonchrz/refactor/shared-enrichment
Use git-pkgs/enrichment for the enrichment API
2026-10-25 10:30:22 +00:00
Andrew Nesbitt
fcce59c71a
Merge pull request #589 from simonchrz/refactor/purl-normalise
Use purl's ComparisonForm for package identity
2026-10-25 10:30:17 +00:00
Andrew Nesbitt
42584c3845
Bump registries to v0.11.1 2026-10-25 10:29:20 +00:00
Andrew Nesbitt
910dadc220
Bump registries to v0.11.1 2026-10-25 10:17:37 +00:00
Andrew Nesbitt
1b641b6e90
Bump enrichment to v0.7.4 2026-10-25 10:15:58 +00:00
Andrew Nesbitt
2a4ce12248
Bump purl to v0.1.24 and drop redundant Swift lowercasing 2026-10-25 10:14:03 +00:00
Andrew Nesbitt
560a9918ff
Merge pull request #578 from simonchrz/fix/unambiguous-metadata-keys
Hash path-based metadata cache keys
2026-10-25 10:04:56 +00:00
Andrew Nesbitt
bd9f376f30
Merge pull request #580 from simonchrz/fix/git-request-limits
Bound Git requests by their body and response progress
2026-10-25 10:04:49 +00:00
Andrew Nesbitt
fcac1d5fae
Refuse '?' and '#' through rejectQueryChars in APK, Debian and RPM 2026-10-25 09:55:14 +00:00
Andrew Nesbitt
4d0d4a20ae
Spill large Git request bodies into the cache's temporary directory 2026-10-25 09:41:05 +00:00
Simon Chrzanowski
cc4a9c4046 Merge remote-tracking branch 'origin/main' into fix/mirror-upstream-versions
# Conflicts:
#	go.mod
#	go.sum
2026-10-24 19:18:21 +02:00
Simon Chrzanowski
c3d0448e6f Bump registries for streamed npm packuments
registries main streams npm packuments (git-pkgs/registries#109), so
listing the versions of packages whose packument is over 10 MiB, such as
typescript, no longer fails with "unexpected end of JSON input". It also
reads Maven versions from maven-metadata.xml (#114).

go.mod points at a pseudo-version of registries main until those are
released.
2026-10-24 19:03:38 +02:00
Simon Chrzanowski
0b2fa83590 Use registries' forward proxy check
registries' safehttp now checks the destination of requests sent through
a forward proxy (git-pkgs/registries#116), so the proxy's own guard is
removed and newUpstreamClient is back to its earlier form.

go.mod points at a pseudo-version of registries main until #116 is
released.
2026-10-24 19:00:57 +02:00
Simon Chrzanowski
de308e48e0 Use purl's ComparisonForm for denylist and NuGet retention keys
The per-ecosystem rules for when two spellings name the same package
(PEP 503, NuGet case, Composer's leading v, conda channel, OCI paths,
Swift registry case) now live in purl. The denylist keeps only the
proxy's own pkg:generic/swift-registry spelling and the gem platform
match. Keys, cache paths and stored PURLs are unchanged.
2026-10-24 18:58:23 +02:00
Simon Chrzanowski
fc299593ac Answer 404 for packages the registry doesn't have 2026-10-24 18:56:19 +02:00
Simon Chrzanowski
94668c917b Use git-pkgs/enrichment for the enrichment API
internal/enrichment now adapts the shared module instead of calling
registries and OSV itself. License categories in the API and the web UI
come from enrichment.CategorizeLicense, which normalizes the license
first. Vulnerability fixed versions come from the affected range of the
queried package.
2026-10-24 15:55:37 +02:00
simon chrzanowski
6a988050f6
Merge pull request #587 from git-pkgs/fix-docker-mirror-inputs
Preserve Docker registries and SBOM versions when mirroring
2026-10-24 15:08:26 +02:00
Andrew Nesbitt
a29f9fc234
Preserve Docker registries and SBOM versions when mirroring 2026-10-24 13:06:12 +01:00
Simon Chrzanowski
fd4f067a1b Keep legacy metadata whose blob matches its digest
Ignoring every row still at its unhashed path dropped the whole metadata
cache on upgrade, including on Linux and S3 where keys never collided,
and left nothing to serve while the upstream was down.

A legacy row's blob is now hashed once and compared with the row's
content_digest. When it matches, the blob is this key's document: it is
copied to the hashed path and the row moved there, so it is served
fresh or as a stale fallback as before. Only a mismatch, where another
key's document overwrote the shared file, or a row without a digest is
dropped and fetched again.

The legacy blobs are queued for deletion once no key differing only in
case still reads its legacy path, which on a case-insensitive
filesystem is the same file.
2026-10-24 13:10:40 +02:00