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

Compare commits

...
Author SHA1 Message Date
Andrew Nesbitt
560ad5cda2
Merge pull request #645 from simonchrz/feat/mirror-refresh
Refresh the most used packages in the background
2026-11-10 12:11:31 +00:00
Andrew Nesbitt
4fd3294e7c
Merge pull request #693 from simonchrz/fix/pypi-release-times
Read PyPI release times without building the whole document
2026-11-10 12:03:53 +00:00
Andrew Nesbitt
f515df3299
Merge pull request #682 from simonchrz/fix/metadata-cache-consistency
Fix metadata cache responses that don't belong together
2026-11-10 11:58:26 +00:00
Andrew Nesbitt
3476449d92
Limit refreshed index documents to the metadata size limit
InRelease, repomd.xml and APKINDEX.tar.gz are read into memory, so cap them at metadata_max_size as the metadata cache did.
2026-11-10 11:56:20 +00:00
Andrew Nesbitt
c419f828e0
Merge pull request #683 from simonchrz/chore/review-followups
Pick up the follow-ups from the #676 and #680 reviews
2026-11-10 11:36:42 +00:00
simon chrzanowski
2076e65210
Merge pull request #697 from montehurd/npm-profile-packument-format
Keep npm packuments in one format across cooldown profiles
2026-11-10 09:23:44 +01:00
simon chrzanowski
9e647ca2e2
Merge pull request #696 from milas/milas/oci-head-fix
Serve HEAD requests from cache in direct serve mode
2026-11-10 09:18:33 +01:00
montehurd
0fd4b5a83f Keep npm packuments in one format across cooldown profiles
Cooldown profiles and the unprefixed URLs share a packument's cache row,
but each chose its format by its own policy: abbreviated without
cooldown, full with it. With cooldown on for only some of them, a request
without cooldown that revalidated the row stored the abbreviated
packument. The next request under cooldown rejected it and fetched the
full one again, even within metadata_ttl, and had no stale copy to serve
when the upstream failed.

Cooldown in the global policy or in any profile now makes all of them
request the full packument, as upstream.npm_full_metadata does. URLs
without cooldown are then served full packuments too.
2026-11-09 18:13:48 -08:00
Milas Bowman
8e6a87a068
fix(oci): serve HEAD requests from cache 2026-11-09 19:39:45 -05:00
Simon Chrzanowski
da6a5b4027 Read PyPI release times without building the whole document
With cooldown on, every simple index request and every download reads
the package's JSON API document for its upload times. It was decoded
into a generic map, which for a package like botocore (4.4 MB) allocated
about 17 MB per request, also when everything was cached. The times are
now decoded into a struct that names only that field, so the rest of
the document is skipped.
2026-11-09 22:45:10 +01:00
Simon Chrzanowski
b719035424 Write Arch databases with their own validators
Arch wrote databases and signatures with the validators of the cache row
read at write time, which may describe other bytes once another instance
rewrote it. Its results now carry validators: fetchedResult for a fetched
database, describedBy for a stored one, and stale on the fallback after an
upstream failure, which also marks it with metadata_ttl 0. Nothing calls
writeMetadataCachedResponseWithEncoding or lookupCachedMeta any more.
2026-11-09 11:57:39 +01:00
Simon Chrzanowski
f8a24fdeeb Merge remote-tracking branch 'origin/main' into fix/metadata-cache-consistency 2026-11-09 11:52:37 +01:00
Andrew Nesbitt
b6e71f4d7a
Merge pull request #684 from simonchrz/perf/jsonstream-jsontext
Read jsonstream objects with jsontext
2026-11-09 09:32:19 +00:00
Andrew Nesbitt
4d9c87bd4b
Merge pull request #678 from simonchrz/fix/cache-key-collisions
Fix cache key collisions in RPM, Maven and the git cache
2026-11-09 09:04:19 +00:00
Simon Chrzanowski
d7399b1043 Say which Maven files get the 404 without listing name rules 2026-11-08 22:51:25 +01:00
Simon Chrzanowski
4ae55605b1 Send Maven coordinate files under odd groups to the 404
A file named {artifact}-{version} under a dotted group directory, such as
org.foo/bar/1.0/bar-1.0-linux.exe, was not recognised as a coordinate file
and so was proxied past the checks. isCoordinateFile no longer checks the
group, so handleDownload answers 404 for it. The docs say which files get
the 404, and the Range test covers a stale If-Range.
2026-11-08 22:18:14 +01:00
Simon Chrzanowski
3776366ba2 Document the 404 for Maven files outside the layout 2026-11-08 21:58:47 +01:00
Simon Chrzanowski
b61e35205c Answer 404 for Maven files outside the layout
A file whose group directories hold a dot or colon was proxied without
caching, which skipped the denylist, scan and cooldown. Such paths aren't
in the Maven layout, so they now get 404. Directory listings and catalogs
still go through proxyUpstream, which shares its error handling with
Proxy.ProxyUpstream in relayUpstream.
2026-11-08 21:49:48 +01:00
Simon Chrzanowski
2ac80bf76b Merge remote-tracking branch 'origin/main' into fix/cache-key-collisions
# Conflicts:
#	internal/gitcache/cache_test.go
2026-11-08 21:47:56 +01:00
Andrew Nesbitt
1c9509d125
Merge pull request #679 from simonchrz/fix/missing-routes
Fix missing routes and protocol gaps
2026-11-08 19:57:38 +00:00
Andrew Nesbitt
488acbea11
Merge pull request #674 from simonchrz/feat/mirror-git-tags-submodules
Mirror pkg:git tags and submodules
2026-11-08 19:41:53 +00:00
Simon Chrzanowski
79a0ae2dd0 Read the row again when stale metadata bytes don't match it
The fallback served replaced bytes with the old row's content type and
encoding, so a blob swapped from identity to gzip went out without its
Content-Encoding. It now reads the row once more and serves the blob
under the row that describes it, and treats the copy as unusable if
none does yet.
2026-11-08 19:14:18 +01:00
Simon Chrzanowski
23316c04d2 Accept object keys with invalid UTF-8 in jsonstream
AppendUnquote reports invalid UTF-8 after replacing it, which failed the
whole copy although the decoder allows it. Key now holds U+FFFD in its
place, as with encoding/json and ScanPackument, and the key is still
written with its original bytes.
2026-11-08 19:12:30 +01:00
Simon Chrzanowski
0efe54fd73 Let the submodule test add a ':' path on Windows
Git for Windows refuses it with core.protectNTFS on.
2026-11-08 19:11:36 +01:00
Simon Chrzanowski
44afe6997a Read jsonstream objects with jsontext
CopyObject takes the reader and decodes it with jsontext, accepting
repeated names and invalid UTF-8 as encoding/json did. Keys are written
with the bytes they were read with instead of being re-encoded. Member
gets Skip, which reads past a value without keeping it, and npm's
RewriteStream skips dropped versions and duplicates with it.

conda-forge noarch repodata.json (195 MB): filtering 0.89 to 0.62 s and
379 to 77 MB allocated, the timestamp index 0.84 to 0.61 s. The next
packument (32 MB): RewriteStream 69 to 34 ms and 38 to 3.5 MB.
2026-11-08 19:03:21 +01:00
Simon Chrzanowski
a330d68564 Use strings.CutLast for .gitmodules keys
Go 1.27 is on main now.
2026-11-08 18:58:40 +01:00
Simon Chrzanowski
3fdbe51ad5 Merge remote-tracking branch 'origin/main' into feat/mirror-git-tags-submodules 2026-11-08 18:56:36 +01:00
Simon Chrzanowski
c2f0345e18 Pick up the follow-ups from the #676 and #680 reviews
The repeated dist-tags name in the jsontext test now has a different
first value, so the test checks that the last one counts. Comments
that repeat the code go, and the upload-pack paragraph states its
check rule once.
2026-11-08 18:56:24 +01:00
Simon Chrzanowski
8ec60f940d Merge remote-tracking branch 'origin/main' into fix/metadata-cache-consistency
# Conflicts:
#	internal/handler/handler.go
#	internal/handler/npm.go
2026-11-08 18:53:58 +01:00
Simon Chrzanowski
140b70233a Move the PyPI root index replacement loop into its own method 2026-11-08 18:51:46 +01:00
Simon Chrzanowski
2ecdcced4c Search again only for the replacement just made in the PyPI root index
Each match searched the rest of the buffer for every replacement. The
root index has no single-quoted links, so after every double-quoted one
the other pattern scanned to the end again, making each chunk quadratic
in its links. Each replacement's next match is now kept and only
searched for again once a replacement passes it (50,000 links: 18.6 to
818 MB/s).
2026-11-08 18:51:00 +01:00
Andrew Nesbitt
43bd67fc13
Merge pull request #681 from simonchrz/fix/cooldown-denylist-gaps
Fix cooldown and denylist gaps
2026-11-08 17:31:53 +00:00
Andrew Nesbitt
1b22f79be2
Fail the conda timestamp test when the index is empty 2026-11-08 17:23:11 +00:00
Andrew Nesbitt
0829eae376
List only the gitlinks .gitmodules names
ls-tree -r read every path in the commit to find a few submodules.
2026-11-08 17:19:03 +00:00
Andrew Nesbitt
fe11f51ed7
Merge pull request #680 from simonchrz/fix/git-cache-gaps
Close two gaps in the git cache
2026-11-08 17:14:20 +00:00
Andrew Nesbitt
53c0fd9c51
Merge pull request #676 from simonchrz/perf/npm-jsontext
Skip discarded npm packument values with jsontext
2026-11-08 17:02:31 +00:00
Andrew Nesbitt
91e53e0445
Merge pull request #677 from simonchrz/fix/config-validation
Reject config mistakes instead of silently ignoring them
2026-11-08 15:59:53 +00:00
Andrew Nesbitt
9fb3119b87
Merge pull request #672 from milas/milas/deb-caching
fix(debian): route packages by filename rather than `/pool` prefix
2026-11-08 15:55:01 +00:00
Simon Chrzanowski
da090443ec Forward Range to upstream for Maven paths proxied uncached
Paths proxied without caching sent no request headers, so a resumed
download got the whole file back instead of the requested bytes.
2026-11-08 12:19:19 +01:00
Simon Chrzanowski
a5f014d64c Check a cached release file against its row before using it
The release listing read the cached InRelease, Release or repomd.xml
without comparing it with the row's digest, so bytes another instance had
replaced could decide which index is served. Such a file is now ignored,
and the index falls back to its own cache entry.
2026-11-08 10:58:43 +01:00
Simon Chrzanowski
0ff118b642 Try the Plugin Portal for Maven paths proxied uncached
Paths the proxy doesn't cache, such as ones with a dotted group
directory, went to the primary upstream only, so a file available only on
the Gradle Plugin Portal returned 404. They now try the Plugin Portal on a
404, as cached files do.
2026-11-08 10:57:38 +01:00
Simon Chrzanowski
5c42a6dab1 Seed the metadata test row without unused parameters
Every caller seeds the same v1 document, which the linter flags.
2026-11-08 10:12:39 +01:00
Simon Chrzanowski
64eb09e674 Warn when mirror.refresh runs without cache_metadata
The docs say refreshing Debian, Alpine and RPM packages needs
cache_metadata, but nothing said so when it was off. Rejecting the
setting would also stop the refresh of every other ecosystem, so the
server logs a warning from the new Config.Warnings at startup.
2026-11-08 10:12:37 +01:00
Simon Chrzanowski
60c899f1df Treat calendar versions as one line in the refresh
A version's first number was taken as its major line, so certifi
2024.8.30 and tzdata 2024.2 never got the next year's release. A first
number of 1000 or more is a year, and all such versions are one line.
2026-11-08 10:12:37 +01:00
Simon Chrzanowski
85dd891879 Recover from a panic mirroring one refreshed version
Refresh called mirrorOne on the scheduler's goroutine without the
recover Run puts around each item, so a panic took the proxy down.
Both now go through mirrorRecovered, which counts it as a failure and
carries on.
2026-11-08 10:12:37 +01:00
Simon Chrzanowski
e598c8e44e Read refreshed index documents without recording an access
The refresh read InRelease, APKINDEX and repomd.xml again through the
metadata cache, which marked them accessed, so every suite looked
fetched within since and never aged out. Read them straight from
upstream, as the Packages and primary indexes already are.
2026-11-08 10:12:37 +01:00
Simon Chrzanowski
b1875040de Leave the refresh's own downloads out of the most used packages
A file the refresh mirrored was fetched just now and never accessed,
which counted as recent use, so the refresh kept every package it had
refreshed in the most used list forever. Fetches under the new
handler.MirrorFetch context set mirrored_at, and a file whose last fetch
was a mirror's counts only once a client downloads it.
2026-11-08 10:12:37 +01:00
Simon Chrzanowski
0ececf3ffa Find the Debian component in the security archive's pool
security.debian.org keeps files under pool/updates/main while its
InRelease lists main/binary-*, so taking the first segment after pool/
as the component found no index and security suites were never
refreshed. Take the first or second segment, whichever the release
file lists indexes for.
2026-11-08 10:12:37 +01:00
Simon Chrzanowski
6d80291d22 Warn when mirroring a cached tag after a failed refresh
A tag found in the cache after the refresh failed was reported as
mirrored with no sign it might be stale, though tags can move. Keep
serving it, as a cached commit is, but log a warning naming the tag,
the commit and the refresh error.
2026-11-08 10:11:36 +01:00
Simon Chrzanowski
fede2463f7 Describe conda shard timestamps as condaTime reads them 2026-11-08 10:10:43 +01:00
Simon Chrzanowski
7fdfd9908f Refetch npm packuments only when a cached copy lacks the version
Under cooldown, a tarball for a version the packument doesn't list is
decided on a fresh fetch of the packument. That check had gaps:

- A stale copy served because upstream failed counted as fetched, so
  the version went through with no publish time. It now triggers the
  refetch like a copy within its TTL.
- A packument just stored from upstream counted as cached, so every
  request for an unlisted version fetched it twice. Only a stored row
  read within its TTL counts now.
- A stored refetch that joined another caller's fetch could get that
  caller's stale fallback and take it as upstream's answer. The stored
  lookup now reports stale results, and a stale refetch is refused.
- A refused refetch dropped the upstream error, so a rate-limit hold
  answered 502 instead of 503 with Retry-After, and a 404 was lost.
  Stale results now carry the upstream error, and the refusal wraps it.
2026-11-08 10:10:43 +01:00
Simon Chrzanowski
06aef28125 Refuse a repository whose fetch is redirected
The relay turned a redirect into a failed fetch, which counted as the
upstream failing rather than refusing, so the cached copy was still
served and no refusal was recorded. Report it as a refusal, as for a
redirected ref listing.
2026-11-08 10:10:39 +01:00
Simon Chrzanowski
806003e1c1 Saturate the upload-pack check age
stale_max_age plus the upload-pack grace overflowed for a stale_max_age
near the largest duration, which made every upload-pack request check
the upstream. Cap the sum instead.
2026-11-08 10:10:39 +01:00
Simon Chrzanowski
fe764ea640 Clear a refusal once the upstream lists the refs
A refusal was cleared only by a refresh that succeeded as a whole. When
the upstream listed the refs again but the fetch that followed failed,
say because the cache was full or the fetch timed out, the old refusal
stayed and the repository was still refused, though the upstream had
just confirmed access. Clear it as soon as the ref check succeeds.
2026-11-08 10:10:39 +01:00
Simon Chrzanowski
6e143432bb Describe SNAPSHOT fallback and release file choice
Document that a mutable SNAPSHOT file falls back to the cached copy
and that HEAD is answered from cache, and that the most recently
fetched of InRelease and Release decides which index is served.
2026-11-08 10:10:35 +01:00
Simon Chrzanowski
09a6b21fac Pick the APT release file that lists the index
The index lookup always used a cached InRelease over Release, so a
stale InRelease decided which index was served to a client that had
just fetched a fresh Release, and it stopped at the nearest cached
release file even when that file, such as the legacy per-component
Release, listed nothing for the path.

Skip release files that don't list the path, and where both InRelease
and Release do, use the one fetched last: apt reads InRelease and
debootstrap Release, and a client asking for an index has most likely
just fetched one of them.
2026-11-08 10:10:35 +01:00
Simon Chrzanowski
ebc1c4646d Mark the offline checksum database copy stale
Offline mode serves the cached copy without asking the database once
it is known to be unreachable, but derived staleness from the row's
age. With metadata_ttl 0 that copy had no Warning header. It is
always stale, since upstream was not asked.
2026-11-08 10:09:53 +01:00
Simon Chrzanowski
989697596f Accept uppercase commit IDs and explain short ones when mirroring
A version that wasn't a lowercase full-length hex ID was taken for a
tag, so an uppercase commit ID failed as a missing tag. Lowercase a
full-length hex version first. A shorter hex version that names no tag
now fails saying it is neither a full commit ID nor a tag.
2026-11-08 10:09:25 +01:00
Simon Chrzanowski
b6a1608eca Drop validators from a stale copy replaced after its row
With metadata_ttl 0 another instance can replace the blob between
reading the row and reading the blob, and the fallback then served
the new bytes under the old row's ETag, so a client holding that ETag
got a wrong 304. Serve the blob without the row's validators when it
doesn't match the row's digest.
2026-11-08 10:09:25 +01:00
Simon Chrzanowski
478d59ddd8 Validate a 304 copy before marking it fresh
The refresh of a row confirmed by a 304 had moved ahead of validate,
so a stored document validate then rejected was still recorded as
fetched now. Validate first, as before.
2026-11-08 10:09:24 +01:00
Simon Chrzanowski
c7031e4897 Track mirrored manifests by sha256 whatever digest names them
Mirroring img:v1@sha512:<X> compared the sha512 digest with the sha256
digest the tag resolved to, so an unmoved tag was reported as moved
and its manifest walked twice. Key seen manifests and the returned
digest by sha256, as tags are.
2026-11-08 10:09:23 +01:00
Simon Chrzanowski
f4d074d7c4 Normalise ecosystems the scanner group is asked about
applicable compared the caller's ecosystem raw against the normalised
configured keys, so a caller passing an alias such as go or rubygems
skipped a scanner restricted to golang or gem. Normalise it first.

NewGroup now also rejects unknown names the way Validate does, through
a shared ScannerEcosystemKey, instead of building a scanner that can
never match.
2026-11-08 10:08:52 +01:00
Simon Chrzanowski
7de58c523f Report errors reading submodules of a mirrored commit
Looking for .gitmodules took any error from git cat-file as the file
not being there, so a mirror whose context was cancelled reported
success with no submodules. Use rev-parse --verify --quiet, whose exit
status 1 means the path is missing, and return other errors.

A .gitmodules git config can't parse failed the mirror of a commit that
is cached all the same. Log a warning and skip its submodules instead,
as an unserved submodule host is skipped.
2026-11-08 10:08:40 +01:00
Simon Chrzanowski
7714869af1 Reject sizes smaller than one byte
ParseSize truncated fractional sizes such as 0.5B to 0, and a
storage.max_size of 0 means unlimited. Reject a non-zero size that
would round down to 0 bytes.
2026-11-08 10:08:40 +01:00
Simon Chrzanowski
e2ef2a8083 Print ignored environment values from proxy mirror
server.Start logs the warnings LoadFromEnv collects, but mirror never
calls it, so an unparseable boolean there was dropped without a word.
Print them to stderr before validating the config. stats and hosted
don't load the config file and aren't affected.
2026-11-08 10:08:40 +01:00
Simon Chrzanowski
8c04642715 Serve the cached SNAPSHOT file when upstream fails
A SNAPSHOT file named without a timestamp was dropped from the cache
before it was fetched again, so an unreachable upstream gave a 502
instead of the last build, concurrent requests could drop each
other's fresh copy, and HEAD downloaded the file.

Fetch it first under the coalescing key and let the new copy replace
the cached one once stored. When the fetch fails for any reason but
the file being missing or blocked, serve the cached copy. HEAD is
answered from cache.
2026-11-08 10:07:57 +01:00
Simon Chrzanowski
6381daa4ed Test git key case folding on every file system
The existing test only runs where the file system ignores case, so it
was skipped on Linux, and it passed if the second spelling had no entry
at all. Add a test that sets foldCase directly and checks both
spellings share one entry, require the entry in the existing test, and
work out the repository key once in acquire.
2026-11-08 10:07:53 +01:00
Simon Chrzanowski
39d6b8c7b0 Rewrite PyPI root index links under the upstream's path
An upstream such as Nexus served from /repository/pypi links projects
as /repository/pypi/simple/<name>/, which the rewrite missed. Match
links under the configured upstream's path, in single-quoted hrefs as
well as double-quoted ones, and drop the upstream's ETag, which names
the body before the rewrite.
2026-11-08 10:07:24 +01:00
Simon Chrzanowski
767e401a3c Check the upstream again on upload-pack while refused
An upload-pack request returned a stored refusal without checking the
upstream, so a client that never asks for info/refs, such as a protocol
v2 client sending ls-refs and fetch directly, stayed refused after a
passing 403 or 404. Refresh first while a refusal stands, as for a copy
past stale_max_age, and refuse only if the refusal remains.
2026-11-08 10:06:50 +01:00
Simon Chrzanowski
5c798069a1 Treat a null notification-url as absent for routed packages
Composer falls back to the default repository's notify-batch when a
version's notification-url is null, which would report installs of a
routed private package to Packagist. Replace null with an empty string
as is already done for pushed versions.

Also drop the pushed-version check's local url variable, which shadowed
net/url, and test that a pushed manifest's own URL is kept.
2026-11-08 10:06:20 +01:00
Simon Chrzanowski
8233ce3339 Clean RPM package paths before caching
The cache key for an RPM package is its request path, so a path with
an empty or "." segment made a second entry for the same upstream
file. The path is now cleaned before it is used as the key and the
upstream URL.
2026-11-08 10:05:58 +01:00
Simon Chrzanowski
ae8b2c6d21 Accept yes, no, on and off in boolean environment variables
Before the strict parser, PROXY_TRUST_FORWARDED_FOR=no meant false.
With only strconv.ParseBool's forms accepted, no was ignored and a
setting enabled in the config file stayed on. Accept yes/no/on/off in
any case for every boolean variable, the Gradle read-only one included.
2026-11-08 10:05:53 +01:00
Simon Chrzanowski
3bb7f7c30d Keep _ as a wildcard in package search
Escaping _ in the search pattern stopped typing_extensions from finding
typing-extensions, which worked before because _ matched any single
character. Escape only % and the escape character.
2026-11-08 10:05:43 +01:00
Simon Chrzanowski
de280a1694 Don't cache Maven paths with a colon in the group or artifact
The cache keys Maven artifacts as group:artifact, so org:foo/bar and
org/foo:bar both came out as org:foo:bar and shared one entry. Such
paths are now proxied without caching, like dotted group directories.
2026-11-08 10:05:23 +01:00
Simon Chrzanowski
3b4ddb43d8 Revert "Remove empty directories below a fetch directory on delete"
This reverts commit fc0e94b. The cleanup stopped at the first segment
that looked like a fetch id, but named Maven repositories put a hex
upstream hash in the filename, so it stopped early. For objects in the
old layout, with no fetch directory, the walk could remove version or
package directories under a concurrent fetch. Leftover empty
directories will be handled separately.
2026-11-08 10:05:03 +01:00
Simon Chrzanowski
fc0e94b080 Remove empty directories below a fetch directory on delete
APK and Arch packages, and now RPM ones, have filenames with directories
in them, which put the object below its fetch directory. Delete only
removed the object's own directory when it was the fetch directory, so
those directories stayed behind. It now removes them up to and including
the fetch directory while they are empty.
2026-11-08 08:56:20 +01:00
Simon Chrzanowski
8f281f1dfa Refuse info/refs while an upstream refusal stands
A check that ran past refresh_wait or failed served the cached copy, also
right after the upstream had refused the repository. With protocol v0 the
refs are in the info/refs response itself, so they were advertised. The
stored refusal now refuses those too, until a check succeeds.
2026-11-08 08:55:02 +01:00
Simon Chrzanowski
61947c9c8c Treat a null Composer notification-url as absent
Composer reads "notification-url": null as missing and reports installs
to notify-batch, so a pushed manifest with null now gets an empty one too.
The docs say a pushed manifest's own notification-url is kept.
2026-11-08 08:51:39 +01:00
Simon Chrzanowski
5c95bd1053 Accept git as a scanner ecosystem
Git LFS objects are scanned under the git key, which the new ecosystem
check rejected, so a scanner restricted to ecosystems: [git] would have
stopped the proxy from starting.
2026-11-08 08:50:55 +01:00
Simon Chrzanowski
1d9b9d4883 Keep policy checks and fallback for mutable SNAPSHOT files
Non-timestamped SNAPSHOT files went straight to upstream, past the
denylist, cooldown, scanning and the Plugin Portal fallback. They now take
the normal download path, with the cached copy dropped first so each
request still gets the current build.

A database error while refreshing a verified 304 no longer forces a full
download: the bytes were checked against the row and upstream confirmed
them.
2026-11-08 08:50:07 +01:00
Simon Chrzanowski
682b4e1291 Keep Maven metadata checksums and SNAPSHOT files current
Without cooldown, maven-metadata.xml and its .md5/.sha1/.sha256/.sha512
files were cached under separate keys with separate TTLs, so a deploy
between the two fetches left the checksum describing another document
and strict builds failed. The checksum files are now computed from the
cached metadata, as the cooldown path already did. The metadata is
also written with the validators of the document served.

SNAPSHOT files named with SNAPSHOT rather than a build timestamp
(lib-1.0-SNAPSHOT.jar, as JitPack and non-unique snapshot repositories
publish them) matched the artifact rules and were cached for good, so
the first build was served forever. They are now passed through
uncached. Timestamped SNAPSHOT files never change and stay cached.
2026-11-08 08:10:16 +01:00
Simon Chrzanowski
92b916d8db Serve APT and RPM index files that match the cached release file
InRelease/Release and repomd.xml had their own metadata cache entries
and TTLs, independent of the Packages, Sources or plainly named
primary.xml.gz files they list. Within a TTL a client could get the
cached release file and a freshly fetched index, or a fresh release
file and a cached index, and apt then failed with "Hash Sum mismatch"
(repositories without by-hash, or RPM repositories built with
--simple-md-filenames).

An index file that the cached release file lists is now served by
that listing: the cached copy while its digest matches the listed
sha256, whatever its age, and otherwise a copy fetched from upstream.
If upstream's copy does not match either, the repository has moved on
since the release file was cached; that copy is served and the
release file is expired so the next update gets a matching pair.
2026-11-08 08:05:21 +01:00
Simon Chrzanowski
a288afab32 Match denylist entries for distro packages as handlers name them
The denylist kept the PURL namespace, but the Debian and RPM handlers
build pkg:deb/{name} and pkg:rpm/{name} without a vendor, while Alpine
and Arch build pkg:apk/alpine/{name} and pkg:alpm/arch/{name} with one.
So standard entries such as pkg:deb/debian/curl@... or
pkg:rpm/fedora/curl@..., and pkg:apk/busybox@... or pkg:alpm/glibc@...,
never matched. Drop the namespace of distro types on both sides, as
retention and the inventory already do, using packageurl's list of
those types.

Debian and RPM handlers read versions from filenames, which leave out
the epoch, so an entry such as pkg:deb/foo@1:2.3-1 never matched
foo_2.3-1_amd64.deb. An entry with an epoch now also covers the
version without it.
2026-11-08 08:04:09 +01:00
Simon Chrzanowski
4f7a8b1b08 Read Conda timestamps in seconds or milliseconds as conda does
The Conda handler divided every repodata timestamp by 1000, assuming
milliseconds. Some entries (a few in conda-forge noarch) use seconds,
so their publish time came out in 1970 and they were never held back
by cooldown. Read a timestamp as milliseconds only when it is above
253402300799, as conda does, in one helper used by the repodata
filter, the shard filter and the download index.
2026-11-08 08:04:09 +01:00
Simon Chrzanowski
3fdc53d39a Stop relaying for a fetch once it has ended
exec.CommandContext kills only the git parent. Its git-remote-http helper
kept its relay connection and went on downloading for up to
http.lowSpeedTime after a client disconnect, Close or fetchTimeout. Each
read it relayed called cover, which reserved the relayed total again on a
reservation already settled or released, so c.reserved grew for good and
could evict repositories, and the helpers outlived the fetch cap.

Tie each relay route to the fetch's context and cancel it when the fetch
returns, so the upstream request ends and the helper's connection is cut.
Make cover refuse a reservation that has been given back.
2026-11-08 08:02:30 +01:00
Simon Chrzanowski
b3728b1e16 Check the cached copy before serving upload-pack requests
Only GET info/refs checked the upstream. A POST git-upload-pack was served
from the cached copy unchecked, so a client posting ls-refs and fetch
directly (protocol v2 allows it) kept getting a repository that was
deleted or made private upstream, past stale_max_age, indefinitely.

Remember when a refresh finds a repository missing or refused upstream,
and refuse upload-pack requests for it until a refresh succeeds. Refresh
first when the copy was last checked more than a minute past
stale_max_age ago; the minute keeps a normal clone (info/refs, then
upload-pack) at one upstream check even with stale_max_age 0. Mirror
refreshes on its own and skips the check.
2026-11-08 08:02:30 +01:00
Simon Chrzanowski
12666071ce Make package search case-insensitive on Postgres too
SearchPackages and CountSearchResults matched names with
p.name LIKE '%query%'. LIKE ignores ASCII case on SQLite but not on
Postgres, so the same search found different packages depending on the
database. The % and _ in a query were also taken as wildcards. Compare
LOWER(name) with LOWER(pattern) and escape the wildcards.
2026-11-08 08:01:34 +01:00
Simon Chrzanowski
20cbeeff22 Disable install notifications for pushed Composer versions
Composer reports installs to the repository's notify-batch URL unless a
version has its own notification-url, and the proxy's packages.json sets
notify-batch to the upstream. Routed packages get an empty
notification-url for this reason, but pushed and hosted-only versions
did not, so installs of private packages were reported to packagist.org.
Give pushed versions the same empty notification-url.
2026-11-08 08:01:34 +01:00
Simon Chrzanowski
1fbcdc95c8 Rewrite project links in the PyPI root simple index
pypi.org's HTML root index links each project as /simple/<name>/. The
proxy relayed it unchanged, but it is mounted at /pypi, so following a
link went to /simple/<name>/ on the proxy and got 404. Rewrite those
hrefs to the proxy's /pypi/simple/ while streaming the response, since
the index is tens of megabytes. The JSON index has no links and is left
alone.
2026-11-08 08:01:34 +01:00
Simon Chrzanowski
81c6ea26c2 Treat git keys differing only in case as one repository
On a case-insensitive file system, as macOS and Windows use by default,
acme/WIDGETS and acme/widgets name the same cache directory but were
tracked as two repositories. Users of one spelling didn't keep the other
from being evicted, so eviction could move the directory away while it
was being read, and each entry had its own write lock, so two refreshes
could fetch into the directory at once.

Check at startup whether the cache directory ignores case and, if so,
track repositories under their lowercased key. The directory and the
upstream URL keep the spelling requested. Case-sensitive file systems,
and so existing caches on Linux, are unchanged.
2026-11-08 08:00:50 +01:00
Simon Chrzanowski
3860a6cae5 Don't cache Maven paths with dotted group directories
parsePath joins the group directories with dots, and the cache filename
is the file's base name, so org.foo/bar/1.0/bar-1.0.jar and
org/foo/bar/1.0/bar-1.0.jar shared one cache entry. Only upstreams that
serve content at such directories, such as raw or hosted repositories,
are affected.

The Maven layout splits a group at its dots, so a group directory never
contains a dot or is empty. Treat such paths as outside the layout and
proxy them uncached, which keeps every existing cache entry valid.
2026-11-08 08:00:50 +01:00
Simon Chrzanowski
fcdf8ebc3a Serve metadata with the validators of the bytes sent
Cached metadata responses took their ETag, Last-Modified and stale
flag from the cache row as it was after the fetch, not from the
document actually sent. When a refetch got a new document but could
not store it, the client got the new body with the old ETag and a
Warning header, and a client still holding the old document got a
304 for it. With metadata_ttl 0 the stale fallback had no Warning at
all, since staleness was inferred from the row's age.

The lookup result now carries the validators of its body (upstream's
for a fetched document, the row's for a stored one) and whether it is
a stale fallback, and the response is written from those.

A 304 also trusted whatever was stored at the row's path. Another
instance sharing the store may have replaced it meanwhile, and the
new bytes were then served and stored under the old ETag. The stored
bytes are now checked against the row's digest, and a 304 whose row
can no longer be refreshed is fetched again without validators
instead of storing the old document back over the new one.
2026-11-08 08:00:26 +01:00
Simon Chrzanowski
d073fdcd52 Close npm, PyPI and pub cooldown gaps in metadata and downloads
npm: a tarball request for a version the cached packument did not list
yet (cached within metadata_ttl before the release) found no publish
time and was let through cooldown, and fetched without integrity. When
cooldown applies and the cached packument lacks the version, fetch it
again and decide on what upstream lists now; if it can't be fetched,
fail as when a publish time can't be read.

npm: cooldown only moved dist-tags.latest. Other tags kept naming
versions in cooldown, while the denylist removes such tags. Cooldown
now removes them too, and drops latest when no version is left, in
both the in-memory and streamed rewrites.

PyPI: the simple JSON rewrite removed filtered files but left their
versions in the PEP 700 versions list. Remove them there too.

pub: latest was never rewritten, so its archive_url pointed at pub.dev,
it kept naming a version in cooldown when no version was left, and the
replacement was the last list entry, which may be a prerelease. Point
latest's archive_url at the proxy, replace a held back latest with the
newest stable version left (or the newest prerelease), and drop it
when nothing is left.
2026-11-08 08:00:14 +01:00
Simon Chrzanowski
2a6c809103 Reject negative and out-of-range sizes and TTLs
ParseSize accepted "-5GB", "NaNGB", "InfGB" and "1e30GB", the last
two clamping to the largest int64. A negative or NaN storage.max_size
passed validation, and eviction treats a limit of zero or less as
unlimited.

Reject negative, NaN, infinite and overflowing sizes in ParseSize, and
reject negative metadata_ttl and storage.direct_serve_ttl at startup.
2026-11-08 07:58:49 +01:00
Simon Chrzanowski
6398ed63f6 Validate and normalise scanner ecosystem names
scanning.scanners[].ecosystems was never checked, and the scanner group
compared it exactly against the keys handlers pass. Natural names such
as go, docker and rubygems passed validation but never matched the
handler keys golang, oci and gem, so a block-mode scanner restricted to
them silently never ran.

Map common aliases to the handler keys, reject unknown names at
startup, and normalise the names again when building the group.
2026-11-08 07:57:30 +01:00
Simon Chrzanowski
0b399c248d Accept sha512 OCI blob digests and check HEAD digests
The blob route and blobPathPattern only matched sha256 digests, so a
sha512 blob got 404 although the fetch path can verify sha512. The image
mirror rejected sha512 digests in index entries, configs and layers for
the same reason. Accept sha512 in both, with exact digest lengths.

A HEAD for an uncached manifest by digest passed on whatever digest the
upstream named. GET checks the body against the requested digest; HEAD
now answers DIGEST_INVALID when the upstream names a different digest
of the same algorithm.
2026-11-08 07:57:26 +01:00
Simon Chrzanowski
b9f047f8dc Parse boolean environment variables strictly
Every boolean PROXY_* variable was true only for exactly "true" or
"1". Anything else, including "True", "TRUE" or a typo, silently
meant false, so PROXY_SCANNING_ENABLED=True turned scanning off and
validation still passed.

Parse them with strconv.ParseBool like
PROXY_GRADLE_BUILD_CACHE_READ_ONLY already does. An unrecognised value
now keeps the current setting and is logged as a warning at startup.
2026-11-08 07:56:56 +01:00
Simon Chrzanowski
cbbeed423b Cache RPM packages under their full path
RPM packages were cached under their bare filename. With upstream.rpm
pointing at a mirror root that serves several distributions, Rocky and
Alma both publish bash-5.1.8-9.el9.x86_64.rpm with different bytes, and
whichever was fetched first was served for both, so dnf failed the
checksum or signature check.

Use the path under the upstream as the cache filename, as the APK and
Arch handlers do. Packages cached before this change are no longer
found and are fetched again once; retention still matches them by
package name.
2026-11-08 07:56:52 +01:00
Simon Chrzanowski
70d9db2906 Add CRAN routes for macOS arch layout and Meta files
R 4.3 and later on macOS fetch binaries from a two-segment platform path
such as bin/macosx/big-sur-arm64/contrib/4.4/. The router only matched a
single platform segment, so both the PACKAGES indexes and the .tgz
downloads returned 404. Add routes for that layout and include the arch
in the stored version so arm64 and x86_64 builds stay apart.

src/contrib/Meta/archive.rds and current.rds, which remotes and pak use
to install archived versions, had no route either. Serve .rds files under
Meta as cached metadata.
2026-11-08 07:55:24 +01:00
Milas Bowman
7d84bf7682
chore: remove deb source pkg logic / fix root-level pkg caching 2026-11-07 15:31:42 -05:00
Simon Chrzanowski
d58b07b2a7 Skip discarded npm packument values with jsontext
ScanPackument and LookupVersion read with a jsontext.Decoder, skipping
values they don't keep with SkipValue and reading the one LookupVersion
returns with ReadValue, instead of decoding each into a json.RawMessage.
Repeated names and invalid UTF-8 are still accepted as encoding/json
accepts them. Go 1.27 is now required, for encoding/json/jsontext.

Benchmarks for both use the 5,000-version packument the handler benchmarks
build, now shared in internal/metadata/npm/npmbench.
2026-11-07 18:02:59 +01:00
Simon Chrzanowski
7f2ac614c4 Mirror commits whose .gitmodules lists no submodule
git config --get-regexp exits 1 when nothing matches, which failed the
mirror of a commit with an empty .gitmodules. The submodules are now read
from --list.
2026-11-07 17:51:13 +01:00
Simon Chrzanowski
e9aa078a85 Mirror pkg:git tags and the submodules a commit pins
A pkg:git version can now be a tag as well as a full commit ID. With a
version, the submodules the commit records are mirrored at the commits it
pins, from the upstream.git hosts serving them; one no host serves is
skipped with a warning.
2026-11-07 17:29:03 +01:00
Simon Chrzanowski
794ea98a28 Match abi3 wheels whatever minimum Python they name
The python tag of an abi3 wheel is a minimum, so cp38-abi3 and cp311-abi3
wheels are now the same kind as a cached cp39-abi3 one.
2026-11-07 17:04:29 +01:00
Simon Chrzanowski
c0ae2e98b3 Merge remote-tracking branch 'origin/main' into feat/mirror-refresh
# Conflicts:
#	internal/database/schema.go
2026-11-07 17:01:32 +01:00
Andrew Nesbitt
72aa8fdc43
Merge pull request #667 from simonchrz/feat/mirror-git
Mirror pkg:git commits into the git cache
2026-11-07 14:58:02 +00:00
Andrew Nesbitt
7e18c6a020
Merge pull request #664 from simonchrz/fix/retention-mirrored-images
Keep blobs of scheduled images from retention
2026-11-07 14:43:06 +00:00
Andrew Nesbitt
14d103bab1
Merge pull request #669 from git-pkgs/docs/package-manager-config
Correct package manager configuration examples
2026-11-07 09:56:06 +00:00
Andrew Nesbitt
f153a83b3a
Cover Fedora updates and Conda defaults in setup docs 2026-11-07 09:46:54 +00:00
Milas Bowman
611feb003a
fix(debian): route packages by filename rather than /pool prefix 2026-11-06 20:12:14 -05:00
Andrew Nesbitt
292ecc1488
Correct package manager configuration examples 2026-11-06 22:36:47 +00:00
Simon Chrzanowski
b0566aa456 Fail a mirror whose refresh is past max_repo_size
The repository is dropped once released, so a cached commit in it would
not be served for long.
2026-11-06 13:00:31 +01:00
Simon Chrzanowski
c037fb9e8b Note that git.max_size can evict a mirrored repository sooner 2026-11-06 12:04:14 +01:00
Simon Chrzanowski
90ba3f9fd1 Say which commits fail to mirror without fetching by ID 2026-11-06 11:44:22 +01:00
Simon Chrzanowski
abe94017a1 Renew wanted refs of mirrored commits and count refreshes that fetched
Mirroring a commit on no branch or tag again writes a fresh wanted ref, so
its 7 days count from the last mirror run. A cached repository whose refresh
fetched new commits is no longer reported as cached.
2026-11-06 11:36:42 +01:00
Andrew Nesbitt
563f58e5cc
Merge pull request #666 from git-pkgs/cooldown-profiles
Add cooldown profiles selected by registry URL
2026-11-06 09:59:37 +00:00
Andrew Nesbitt
691088b947
Merge pull request #658 from git-pkgs/hide-stats-connection-details
Remove connection details from stats response
2026-11-06 09:40:43 +00:00
Andrew Nesbitt
c2dd16903a
Share publish indexes across cooldown profiles 2026-11-06 09:38:07 +00:00
Andrew Nesbitt
0ed91b5872
Redact storage passwords in startup logs 2026-11-06 09:20:43 +00:00
Simon Chrzanowski
03be03b8fe Mirror pkg:git commits into the git cache
proxy mirror, mirror API jobs and SBOMs take pkg:git/{host}/{path}@{commit}
and cache the repository and commit in the cache /git serves, so a later
clone is a cache hit, also with the upstream down. Part of #403.
2026-11-06 07:48:51 +01:00
Andrew Nesbitt
79be33049a
Add cooldown profiles selected by registry URL 2026-11-05 21:42:44 +00:00
Andrew Nesbitt
6f6ffcb364
Merge pull request #533 from pinguinfuss/issue-521-download-cooldown
Check cooldown on artifact downloads for cargo, gem, hex, pub, conda and helm
2026-11-05 21:20:14 +00:00
Andrew Nesbitt
61ac273862
Check primary metadata before allowing untimed downloads 2026-11-05 20:00:19 +00:00
simon chrzanowski
a1021a6cd7
Merge pull request #663 from git-pkgs/dependabot/go_modules/github.com/aws/aws-sdk-go-v2/service/ecr-1.67.1
Bump github.com/aws/aws-sdk-go-v2/service/ecr from 1.66.5 to 1.67.1
2026-11-05 19:41:40 +01:00
Andrew Nesbitt
b9dcbd27fc
Handle cooldown lookup failures and reuse download indexes 2026-11-05 18:41:03 +00:00
Christian Heim
3ab7dcc1dc
The UTC test compares the zone offset, since Postgres reads TIMESTAMP back as a fixed +0000 zone rather than time.UTC. 2026-11-05 18:30:44 +00:00
Christian Heim
5db16cbb99
Conda reads repodata as a stream for the download check, under the same bound as the repodata filter, so a repodata.json over metadata_max_size no longer lets a package in cooldown through. If even the compressed copy is too large to cache, the check streams it from upstream. 2026-11-05 18:30:44 +00:00
Christian Heim
89c60abd1a
Conda now checks each package file against its own timestamp, because builds of one version differ. Conda repodata and helm indexes are parsed once per cached document instead of on every download. 2026-11-05 18:30:43 +00:00
Christian Heim
3456b394be
Cargo, gem, hex, pub, conda and helm now check cooldown on artifact downloads too, so a version inside the window is withheld even when it was cached before. Publish times are stored in UTC. Fixes #521. 2026-11-05 18:30:42 +00:00
dependabot[bot]
550a908d88
Bump github.com/aws/aws-sdk-go-v2/service/ecr from 1.66.5 to 1.67.1
Bumps [github.com/aws/aws-sdk-go-v2/service/ecr](https://github.com/aws/aws-sdk-go-v2) from 1.66.5 to 1.67.1.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/ecr/v1.66.5...service/s3/v1.67.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-05 18:23:50 +00:00
simon chrzanowski
7a1f25932a
Merge pull request #662 from git-pkgs/dependabot/go_modules/github.com/aws/aws-sdk-go-v2/config-1.33.12
Bump github.com/aws/aws-sdk-go-v2/config from 1.33.9 to 1.33.12
2026-11-05 19:18:37 +01:00
simon chrzanowski
3a4dd48934
Merge pull request #661 from git-pkgs/dependabot/github_actions/zizmorcore/zizmor-action-0.8.0
Bump zizmorcore/zizmor-action from 0.7.1 to 0.8.0
2026-11-05 19:18:32 +01:00
simon chrzanowski
8a819ca54c
Merge pull request #660 from git-pkgs/dependabot/github_actions/docker/login-action-4.7.0
Bump docker/login-action from 4.6.0 to 4.7.0
2026-11-05 19:18:27 +01:00
simon chrzanowski
c871b7ca05
Merge pull request #659 from git-pkgs/dependabot/docker/alpine-3.24.3
Bump alpine from 3.24.2 to 3.24.3
2026-11-05 19:18:23 +01:00
Simon Chrzanowski
0300ad2522 Limit APKINDEX to maxIndexBytes once decompressed 2026-11-05 17:44:12 +01:00
Simon Chrzanowski
14d04955b4 Match wheels on any of their alternative tags 2026-11-05 17:43:33 +01:00
Simon Chrzanowski
0f26841f56 Keep blobs of scheduled images from retention
A mirror run that finds a blob cached records it in mirrored_at, which
retention counts as use. LRU eviction keeps using last_accessed_at, so
#508's decision not to count mirror runs as cache hits stands.

Fixes #648.
2026-11-05 17:39:19 +01:00
Simon Chrzanowski
6d971716d9 Describe the looser file kind matching 2026-11-05 17:20:16 +01:00
Simon Chrzanowski
34524a4056 Limit a refreshed index to 512 MiB once decompressed 2026-11-05 17:20:06 +01:00
Simon Chrzanowski
f8c5fa9a1d Match wheels on OS and architecture, and conda builds without the hash anywhere 2026-11-05 17:18:56 +01:00
dependabot[bot]
06dd126211
Bump github.com/aws/aws-sdk-go-v2/config from 1.33.9 to 1.33.12
Bumps [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) from 1.33.9 to 1.33.12.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.33.9...config/v1.33.12)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-05 15:15:18 +00:00
dependabot[bot]
d5dbd7a95a
Bump zizmorcore/zizmor-action from 0.7.1 to 0.8.0
Bumps [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) from 0.7.1 to 0.8.0.
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](85b2b9fb8e...53290badfa)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-05 15:14:03 +00:00
dependabot[bot]
12c5cc9e4c
Bump docker/login-action from 4.6.0 to 4.7.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.6.0 to 4.7.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](dbcb813823...81c6f93d4b)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-05 15:14:00 +00:00
dependabot[bot]
17355d2227
Bump alpine from 3.24.2 to 3.24.3
Bumps alpine from 3.24.2 to 3.24.3.

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-05 15:13:42 +00:00
Andrew Nesbitt
308cc133ab
Remove connection details from stats response 2026-11-05 15:09:16 +00:00
Simon Chrzanowski
55f1b1e2da Reject a negative mirror.refresh.max_bytes 2026-11-05 14:53:36 +01:00
Simon Chrzanowski
010ed4761a Skip index entries whose path leaves the repository 2026-11-05 13:51:07 +01:00
Simon Chrzanowski
6f3e8c6f7e Merge remote-tracking branch 'origin/main' into feat/mirror-refresh
# Conflicts:
#	internal/database/schema.go
2026-11-05 13:50:08 +01:00
Andrew Nesbitt
6c8fb35ab7
Merge pull request #626 from simonchrz/feat/project-inventory
Add a project inventory built from uploaded SBOMs
2026-11-05 12:36:54 +00:00
Simon Chrzanowski
17c2d52500 Note that refreshing Debian, Alpine and RPM needs cache_metadata 2026-11-05 12:34:36 +01:00
Simon Chrzanowski
903fd94df9 Test that apk index errors leave out the URL 2026-11-05 12:34:32 +01:00
Simon Chrzanowski
eb34a1ac6f Name the apk repository, not its URL, in index errors 2026-11-05 12:33:46 +01:00
Simon Chrzanowski
5c46a39b2d Merge remote-tracking branch 'origin/main' into feat/mirror-refresh
# Conflicts:
#	internal/database/schema.go
2026-11-05 12:21:49 +01:00
Simon Chrzanowski
4d70793351 Name the apk repository in mirror errors 2026-11-05 11:56:42 +01:00
Simon Chrzanowski
c20f64ebaa Keep the apk namespace for the mirror's repository lookup
The inventory drops the namespace from apk names so they match the
handler's, but the apk mirror picks the upstream.apk repository from it.
2026-11-05 10:48:44 +01:00
Simon Chrzanowski
1e916bf334 Merge remote-tracking branch 'origin/main' into feat/project-inventory 2026-11-05 10:47:14 +01:00
Simon Chrzanowski
60872fe990 Use sbom v0.1.9 2026-11-05 09:34:02 +01:00
Andrew Nesbitt
fba9e036df
Merge pull request #656 from simonchrz/feat/mirror-apk
Mirror Alpine packages
2026-11-05 08:24:32 +00:00
Simon Chrzanowski
c3f0c23dc3 Name the repository segment of the APK path in the mirror docs 2026-11-04 16:31:50 +01:00
Simon Chrzanowski
e4e14696d0 Say which edge packages the APK mirror can't recognise 2026-11-04 16:20:30 +01:00
Simon Chrzanowski
7095ab67ef Keep the Projects link on an empty projects page
ProjectsListData.Projects hid Layout.Projects, so the header left the
link out when no project had been uploaded.
2026-11-04 16:16:35 +01:00
Simon Chrzanowski
4e25396331 Refuse distro qualifiers that aren't an Alpine release 2026-11-04 16:09:00 +01:00
Simon Chrzanowski
6d3410df41 Use sbom's ProjectRoots for the project's own packages
Drop projectRoots and readFromLockFile; sbom now decides which inferred
roots are the project. sbom is on the merge commit of git-pkgs/sbom#27
until it is tagged.
2026-11-04 16:05:16 +01:00
Simon Chrzanowski
adcda6ca8b Read APKINDEX.tar.gz also where Packages.adb is served beside it 2026-11-04 15:54:10 +01:00
Simon Chrzanowski
3e2a84a88a Mirror Alpine packages
The PURL namespace names the upstream.apk repository, and the distro and
arch qualifiers syft writes give the release and architecture. The package
is looked up in the main and community indexes of that release and cached
under the path the APK handler serves it from.
2026-11-04 15:44:01 +01:00
Andrew Nesbitt
1912deba6c
Merge pull request #620 from simonchrz/feat/composer-advisories
Pass Composer security advisories and filter lists through
2026-11-04 12:38:40 +00:00
Simon Chrzanowski
88c0ba8ab3 Use sbom's root detection for the project's own packages
Bump sbom to v0.1.8 and replace lockFileRoots with sbom.Roots. Inferred
roots count as the project only when read from a lock file, so an image's
packages that nothing depends on are kept.
2026-11-04 13:20:29 +01:00
Simon Chrzanowski
e358666482 Merge remote-tracking branch 'origin/main' into feat/project-inventory
# Conflicts:
#	internal/database/database_test.go
#	internal/database/schema.go
#	internal/server/server.go
2026-11-04 13:19:09 +01:00
Simon Chrzanowski
82b45cbb1b Merge remote-tracking branch 'origin/main' into feat/composer-advisories
# Conflicts:
#	README.md
2026-11-04 13:05:10 +01:00
Andrew Nesbitt
5b10559636
Merge pull request #639 from simonchrz/feat/cooldown-withheld-versions
Show versions held back by cooldown on the package page and in the API
2026-11-04 12:01:24 +00:00
Andrew Nesbitt
ee6cac700c
Merge pull request #655 from simonchrz/fix/composer-download-lookup-status
Answer 502 when a Composer download's metadata lookup fails upstream
2026-11-04 11:59:55 +00:00
Simon Chrzanowski
cb330a5cbf Answer 502 when a Composer download's metadata lookup fails upstream
The lookup took any non-200 metadata response for a version that isn't
listed, so a Packagist outage or rate limit answered the archive request
with 404. Composer treats that as the version being gone.

Only 404 and 410 now mean the document is missing. A 429 is passed on as
503 with Retry-After while the upstream is held, and any other status
answers 502.
2026-11-03 20:10:24 +01:00
Simon Chrzanowski
683455aecb Test available_at through the API and drop FillVersionPublishedAt
The API handler takes the enrichment calls it uses as an interface, so
the test can answer them without reaching a registry.

Remove FillVersionPublishedAt until the shared cooldown filter calls it.
2026-11-03 14:39:36 +01:00
Andrew Nesbitt
9777f4b9bc
Merge pull request #625 from simonchrz/feat/change-feeds
Follow npm and Packagist change feeds to revalidate changed metadata
2026-11-03 11:36:58 +00:00
Simon Chrzanowski
8aebf94f5d Merge remote-tracking branch 'origin/main' into feat/change-feeds
# Conflicts:
#	go.mod
#	go.sum
2026-11-03 11:23:40 +01:00
Simon Chrzanowski
83524b7485 Merge remote-tracking branch 'origin/main' into feat/cooldown-withheld-versions 2026-11-03 11:23:22 +01:00
Andrew Nesbitt
c1a1149bd8
Merge pull request #622 from simonchrz/feat/apk-snapshots
Serve dated snapshots of Alpine APK indexes
2026-11-03 09:36:40 +00:00
Andrew Nesbitt
abee8ef879
Merge pull request #640 from simonchrz/feat/npm-stream-rewrite
Stream npm packuments through storage when rewriting them
2026-11-03 09:33:36 +00:00
Andrew Nesbitt
7cb905638c
Keep proxyCachedWithEncoding doc on the function it describes 2026-11-03 09:28:29 +00:00
Andrew Nesbitt
47ed4b4194
Merge pull request #649 from simonchrz/fix/truncated-upstream-metadata
Fail metadata reads when upstream cuts a document off
2026-11-03 09:22:25 +00:00
Andrew Nesbitt
83a0822979
Merge pull request #644 from simonchrz/mirror-parallel-blobs
Fetch an image's blobs concurrently when mirroring
2026-11-03 09:15:16 +00:00
Andrew Nesbitt
d139d190de
Merge pull request #643 from simonchrz/docker-healthcheck
Document the published container image and add a Docker health check
2026-11-03 08:59:28 +00:00
Andrew Nesbitt
0a961584af
Merge pull request #653 from simonchrz/docs/sqlite-local-disk
Document that SQLite needs a local disk or block volume
2026-11-02 18:07:05 +00:00
simon chrzanowski
07c740737f
Merge pull request #651 from kokes/kokes/ui-version-commit 2026-11-02 18:17:22 +01:00
Simon Chrzanowski
dd987f2de7 Document that SQLite needs a local disk or block volume 2026-11-02 16:56:33 +01:00
Ondrej Kokes
f35d152be0 expose commit sha in UI footer 2026-11-02 11:37:28 +01:00
Simon Chrzanowski
26cae44e41 Bump cooldown to v0.3.1 and set cooldown directly in stream tests
v0.3.1 parses a Config once when it is first used concurrently, so the
stream tests no longer need cooldown.NewPolicy.
2026-11-02 11:33:11 +01:00
Andrew Nesbitt
cc8f50cfb4
Merge pull request #646 from simonchrz/fix/version-publish-time-package
Count stored publish times under their ecosystem in the stats
2026-11-02 10:16:34 +00:00
Simon Chrzanowski
98ff4fd4a2 Merge remote-tracking branch 'origin/main' into feat/npm-stream-rewrite
# Conflicts:
#	cmd/proxy/main.go
2026-11-01 12:54:47 +01:00
Simon Chrzanowski
b08cebf9e4 Cap npm packuments streamed into storage
metadata_max_size bounds what is read into memory, but a packument
streamed from upstream into storage had no limit, and with cooldown was
stored twice. It is now capped by metadata_stream_max_size, 1 GB by
default. A document over the cap fails as an upstream failure: nothing is
stored, and the stored copy, if any, is served instead.

The metadata settings move into configureMetadata to keep serve under
the maintidx limit.
2026-11-01 12:53:00 +01:00
Simon Chrzanowski
6dbda21faf Move the in-memory truncation fix to its own pull request
The change to ReadMetadataSized and fetchUpstreamMetadata, with its tests,
is now #649. upstreamBodyReader stays for storeUpstreamMetadata, in a file
of its own that #649 adds with the same content.
2026-11-01 12:49:09 +01:00
Simon Chrzanowski
143eef7f05 Test snapshots on cache hits and record them through proxyCachedWithEncoding
TestAPKSnapshots_CacheHit covers the cache-hit branch with a metadata TTL
above 0. proxyCachedWithEncoding takes optional hooks for a cache hit and a
fetched body, so the APK handler no longer keeps a copy of it.
2026-11-01 12:48:37 +01:00
Simon Chrzanowski
358b1355a9 Fail metadata reads when upstream cuts a document off
ReadMetadataSized took the transport's io.ErrUnexpectedEOF for the end of
a body shorter than its size hint. A document upstream closed before its
Content-Length was served with a 200 and cached, or failed the cooldown
filter with a 502, instead of the cached copy being served. Upstream
bodies are now read through a reader that marks their read errors, and
such a body fails the read as an upstream failure.
2026-11-01 12:48:04 +01:00
Simon Chrzanowski
7b91897fbb Merge remote-tracking branch 'origin/main' into feat/apk-snapshots
# Conflicts:
#	internal/server/server.go
2026-11-01 12:44:35 +01:00
simon chrzanowski
15789d40bb
Update golang.org/x/net to v0.62.0 (#647)
Fixes CVE-2026-78661 and CVE-2026-97045 in x/net/html, which parses
upstream PyPI simple pages for the denylist.
2026-10-31 20:14:53 +00:00
Simon Chrzanowski
3a0e79d6b4 Fuzz SBOM parsing and test concurrent project uploads
FuzzParse checks that an uploaded body never makes the inventory panic
and that every component it records has a package URL, ecosystem and
version. TestSaveProjectConcurrentUploads saves one project from eight
uploads at once and checks that each succeeds and the components of
exactly one remain.
2026-10-31 18:26:17 +01:00
Simon Chrzanowski
fd16ba1664 Let in-flight blobs finish when one fails
errgroup.WithContext cancelled the blobs being fetched as soon as one
failed. A client pull sharing one of those fetches failed with it, and the
next run had to fetch them again. A failure now only stops further blobs
from starting.
2026-10-31 18:15:11 +01:00
Simon Chrzanowski
83a018bfd4 Test that stats versions add up with orphan publish times
Covers namespaced npm and Maven PURLs, a publish time under an alias of
a package row's ecosystem, and a PURL starting with the orphan prefix,
and checks the per-ecosystem versions against GetCacheStats' total.
2026-10-31 18:09:05 +01:00
Simon Chrzanowski
b73980e8ed Refresh each suite of a shared Debian pool on its own
An index file was mirrored only when it was newer than every cached
version of the package and architecture. Debian suites share one pool, so
once curl from trixie was cached, bookworm's security updates to curl
were older than it and never mirrored. A run now mirrors the newest file
an index lists when it isn't cached and an older version is, which still
never downgrades and leaves a suite alone that only has a newer version
cached.

Tests also cover repositories listing several versions, an unreadable
index next to working ones, Architecture: all packages, scoped npm
packages, denied releases, a listing failure next to other packages, a
cancelled run and Go version lines.
2026-10-31 17:58:00 +01:00
Simon Chrzanowski
f0c96e87a8 Keep the stored copy when upstream cuts a metadata document off
A body that ended before its Content-Length was read as complete in
memory: ReadMetadataSized took the transport's io.ErrUnexpectedEOF for
the end of a body shorter than its size hint. The half document was
served and cached, or failed the filter with a 502, instead of the stored
copy being served. Upstream bodies are now read through a reader that
marks their read errors, and such a failure is an upstream failure on
both paths: streamed, it no longer counts as a failure to store, which
had sent the request to the bounded in-memory path.

A stored packument whose bytes no longer match its row's digest is now
fetched again like a missing one.

Tests also cover concurrent requests sharing one fetch, client
revalidation and upstream failures with nothing cached. The stream tests
build cooldown through cooldown.NewPolicy as the server does, since a
bare cooldown.Config parses itself on first use without locking.
2026-10-31 17:50:24 +01:00
Simon Chrzanowski
11ff4cf314 Fetch an npm packument again when its stored copy is gone within the TTL
A row within its TTL was served without checking its blob, so a missing
blob failed the streamed read and the request fell back to the bounded
in-memory path. A read that finds the stored copy missing now fetches the
packument again into storage, ignoring that row, and reads it once more,
for packument, version and tarball requests.
2026-10-31 16:55:15 +01:00
Simon Chrzanowski
c4979b9e76 Count stored publish times under their ecosystem in the stats
Cooldown stores the publish time of every version it looks up, also of
packages nobody downloaded, which have no package row. The ecosystem stats
counted those versions as unattributed. They now count under the type of
their package PURL; only a PURL that can't be parsed stays unattributed.
2026-10-31 15:19:11 +01:00
Simon Chrzanowski
e50179d0cc Refresh Swift and Helm packages, leave Julia out 2026-10-31 14:09:59 +01:00
Simon Chrzanowski
f80c826e73 Document mirror.refresh 2026-10-31 14:09:02 +01:00
Simon Chrzanowski
2f2d01a414 Refresh Debian, Alpine and RPM packages from their indexes
These clients find new versions in a repository index. The refresh fetches
the InRelease, APKINDEX and repomd.xml files clients fetched again, reads
the Packages and primary indexes they point at for the repositories the
most used packages came from, and mirrors the newer file per package and
architecture, checked against the index's SHA-256 where it has one.
2026-10-31 14:05:05 +01:00
Simon Chrzanowski
3e4491287d Record the upstream URL of cached metadata
The refresh fetches Debian, Alpine and RPM indexes again from the URLs
clients fetched them from, which the hashed cache keys don't keep. Rows
cached before get their URL on the next revalidation, even on a 304.
2026-10-31 13:57:49 +01:00
Simon Chrzanowski
29863e858a Refresh the most used packages in the background
mirror.refresh mirrors, for the top packages per ecosystem by recent
downloads, the newest release of each major line a version is cached on.
Releases within cooldown or on the denylist are skipped, and PyPI and conda
get only the kinds of files clients fetched before. max_versions and
max_bytes cap one run. Instances sharing a database take turns as for
schedules.
2026-10-31 13:53:37 +01:00
simon chrzanowski
1979fb098e
Accept Swift source repository PURLs in the denylist and retention rules (#641)
* Accept Swift source repository PURLs in the denylist and retention rules

Closes #613. pkg:swift/github.com/apple/swift-nio, as SBOM tools and GHSA
write Swift packages, is resolved to its registry identity through the
upstream's /identifiers:

- Denylist entries are resolved at startup, and the proxy refuses to start
  when one can't be, since a skipped entry would let the version through.
- Retention keys are accepted at validation and kept pending. The
  retention loop resolves them before each sweep, and Swift isn't swept
  while any is pending, so a rule that keeps a package is never skipped.

The mirror's source URL helper moves to the handler as SwiftSourceURL.

* Resolve Swift source denylist entries in proxy mirror too

serve resolved source-form Swift denylist entries through the upstream,
but proxy mirror built its denylist directly, so such an entry never
matched a mirror download. Both now use server.ConfigureDenylist, and
neither runs when an entry can't be resolved.

* Apply Swift source policies to every listed identity

A Swift registry may list several identities for one source repository,
but a denylist entry or retention rule naming the repository only
covered the first. Denying pkg:swift/github.com/acme/widget@1.0.0 let
acme.widget through when the registry listed other.widget first, and a
never-evict rule cleared the cached apple.swift-nio archive when
other.swift-nio came first.

Both now cover every identity the registry lists. A retention rule that
conflicts with another rule for any of them stays pending, with none
applied. Mirroring a source PURL still downloads the first identity.
2026-10-31 11:34:05 +00:00
Simon Chrzanowski
1cadc4a7ba Compare Go main modules with their case
purl v0.2.1 keeps the case of Go module paths, so the inventory no
longer lowercases syft's mainModule. Module paths are case-sensitive:
github.com/azure/foo is a dependency of github.com/Azure/foo, not part
of it.
2026-10-31 11:33:31 +01:00
Simon Chrzanowski
7a77baf01a Keep namespace/name for Swift package URLs in the handler mapping
With purl v0.2.1, FullName returns a Swift package as its registry
identity, scope.name. The mirror splits namespace/name again to find
the scope or the source repository, so HandlerPackage keeps that form,
as mirrorTarget did before the two mappings were shared.
2026-10-31 11:33:31 +01:00
Simon Chrzanowski
c6635f3165 Merge remote-tracking branch 'origin/main' into feat/project-inventory 2026-10-31 11:29:24 +01:00
Andrew Nesbitt
3eadb3eb46
Preserve Swift and Go compatibility when upgrading PURL (#642)
* Handle Swift registry identities in cache and vulnerability APIs

* Preserve Go cache and policy behavior when upgrading PURL
2026-10-31 09:49:35 +00:00
Simon Chrzanowski
a79a58b04e Guard the fake registry's manifests in mirror tests
TestMirrorImageKeepsTagMovedByPullDuringWalk moves a tag while a blob is
served. With blobs fetched concurrently, another blob request reads the
manifest map at the same time, which the race detector caught on
Windows.
2026-10-31 08:27:39 +01:00
Simon Chrzanowski
4ae534eba3 Fetch an image's blobs concurrently when mirroring
The image mirror fetched the config and layer blobs of a manifest one
after another, so a run took as long as all layers together. It now
fetches up to three at once, as many as a Docker pull does by default.
For a Home Assistant image (550 MB in ten blobs) that would leave
mostly the time of its largest layer.
2026-10-31 07:58:51 +01:00
Simon Chrzanowski
479f26e09e Start project pruning with the project API and move enrichment routes out of serve 2026-10-30 19:29:11 +01:00
Simon Chrzanowski
9458dc84e1 Share one PURL-to-handler mapping between the mirror and the inventory
packageurl.HandlerPackage returns the ecosystem and name the handlers store
a package URL under. The mirror and the inventory both use it, which
settles where they disagreed: distribution namespaces of apk, alpm, bsd,
deb and rpm are dropped, PyPI and NuGet names use purl's comparison form,
and Maven names are group:artifact as the handler stores them, where the
mirror used group/artifact.
2026-10-30 19:29:11 +01:00
Simon Chrzanowski
04f732e147 Record APK snapshots from the document a request serves
A snapshot was recorded after the response by reading the metadata cache
again, so a refresh that replaced the cached copy in between lost the
version the request served. Record it from the served bytes before
responding instead, and compare a new snapshot with the version current
at its own time, so one recorded late keeps its place in the history.
2026-10-30 19:26:36 +01:00
Simon Chrzanowski
3ce582c733 Keep the last good Composer index when upstream sends one that doesn't parse
parseComposerUpstreamIndex now returns an error for a packages.json that
is not a JSON object or has the advisories and filter fields in the wrong
shape, and fetchUpstreamIndex passes it to the metadata cache as validate.
A malformed 200 is no longer stored: with metadata caching the stored
copy is served as when upstream fails, and without it the error keeps
the copy in memory.
2026-10-30 19:25:41 +01:00
Simon Chrzanowski
760623e617 Serve the stored npm packument on upstream 404 and refetch on a 304 for a missing blob 2026-10-30 19:25:09 +01:00
Simon Chrzanowski
6ee0ef08f1 Merge #642 (purl v0.2.1, registries v0.13.0) into feat/change-feeds 2026-10-30 19:23:28 +01:00
Simon Chrzanowski
d01d32e310 Merge remote-tracking branch 'origin/main' into feat/project-inventory
# Conflicts:
#	internal/server/dashboard.go
2026-10-30 19:22:04 +01:00
Simon Chrzanowski
d0435f6137 Merge remote-tracking branch 'origin/main' into feat/change-feeds 2026-10-30 19:21:37 +01:00
Simon Chrzanowski
f3c38bee42 Merge remote-tracking branch 'origin/main' into feat/composer-advisories 2026-10-30 19:21:32 +01:00
Simon Chrzanowski
9941d79561 Document the published container image and add a Docker health check 2026-10-30 19:19:01 +01:00
Andrew Nesbitt
f32b46b71a
Preserve Go cache and policy behavior when upgrading PURL 2026-10-30 18:02:30 +00:00
Andrew Nesbitt
ba95de17f3
Handle Swift registry identities in cache and vulnerability APIs 2026-10-30 14:41:45 +00:00
Simon Chrzanowski
ddba210a60 Serialize snapshot recording per document on Postgres
AddMetadataSnapshot takes a transaction-scoped advisory lock on the
document before its check and insert, so two replicas recording the same
version can't both read the previous digest and both insert. SQLite runs
one write at a time, so it keeps the single statement.
2026-10-30 15:31:31 +01:00
Simon Chrzanowski
53cac1fb74 Merge remote-tracking branch 'origin/main' into feat/apk-snapshots 2026-10-30 15:27:57 +01:00
Simon Chrzanowski
8e760ca26d Merge remote-tracking branch 'origin/main' into feat/npm-stream-rewrite
# Conflicts:
#	internal/handler/handler.go
2026-10-30 15:25:21 +01:00
Simon Chrzanowski
8c9e87f15f Stage fetched packuments, fall back on storage errors, stream version lookups
A fetched packument is now stored under a staging path and checked before
it replaces the cached copy, so an abbreviated one no longer overwrites a
full copy; the full copy is served as when upstream fails. A failure to
store falls back to the in-memory path, as caching is best effort there.

Requests for a single version or dist-tag and the tarball download's
cooldown and integrity lookup read the stored packument as a stream too,
keeping only the one version entry they need.
2026-10-30 15:24:18 +01:00
simon chrzanowski
cb566d5df6
Expire cooldown rewrites when a withheld version becomes available (#638)
A cached rewrite made with cooldown on stays right until a version it held
back leaves cooldown, so it now expires then instead of after a fixed
time. Handlers ask cooldown through Proxy.cooldown, which reports each
withheld version to the rewrites running at that moment. A rewrite that
held nothing back is kept for the shortest cooldown, at most a day.
2026-10-30 14:06:42 +00:00
simon chrzanowski
ce9ab47eac
Add --latest to mirror only the newest version (#631)
* Add --latest to mirror only the newest version

An unversioned PURL mirrors every version the upstream lists. With
--latest, or "latest": true in a mirror API job, only the newest is
mirrored: the highest by the ecosystem's version ordering, without
yanked or retracted versions and, unless there is nothing else,
pre-releases.

* Pick the latest version with registries.FetchLatestVersion

--latest now uses the shared policy from git-pkgs/registries: the version
the registry advertises as latest where it has one, otherwise the newest
active version by publish time. Deprecated versions are left out too, and
newestVersion and the direct vers dependency go away.
2026-10-30 13:59:52 +00:00
Simon Chrzanowski
5b57994c09 Stream npm packuments through storage when rewriting them
Closes #525. With metadata caching and the rewrite cache on, a packument
is streamed from upstream into storage and rewritten from there in two
passes: the first collects the time map, dist-tags and version names, the
second copies each kept version and rewrites its tarball URL. Neither the
packument nor its rewrite is held in memory uncompressed, so
metadata_max_size no longer limits packuments.

Both passes check the stored bytes against the digest in the cache row, so
a document replaced in between is not rewritten from mixed bytes. Documents
the stream can't handle (abbreviated, no versions object) and rewrite
errors fall back to the in-memory rewrite.
2026-10-30 14:14:10 +01:00
Simon Chrzanowski
1205fa759e Show versions held back by cooldown on the package page and in the API
Part of #617. The package page marks versions in cooldown with when they
become available, and lists a version only seen in metadata (no artifact
row) only while it is held back. /api/package returns the package's
cooldown and available_at on a held-back version.

FillVersionPublishedAt stores a publish time without overwriting one, for
the cooldown filters to call once they record the versions they hold back.
2026-10-30 13:54:24 +01:00
Simon Chrzanowski
77525b0d78 Store each snapshot recording at a path of its own
Two requests recording the same index version in the same millisecond
stored to one path, and the one whose row was not added deleted the
blob the other's row points at. The path now ends in a random suffix.

Also trims comments that restated the PR, notes in the docs that turning
snapshots off leaves them in place, and moves the git cache setup out of
serve, which lint found too long after the merge.
2026-10-30 10:26:21 +01:00
Simon Chrzanowski
77e07d4be9 Merge remote-tracking branch 'origin/main' into feat/apk-snapshots
# Conflicts:
#	internal/config/config_test.go
2026-10-30 10:23:29 +01:00
Andrew Nesbitt
0b1ca01e2c
Merge pull request #627 from git-pkgs/docs/docker-builds
Document using proxy in Docker builds
2026-10-30 09:11:59 +00:00
Andrew Nesbitt
a06b20fd00
Address Docker guide review points 2026-10-30 09:10:44 +00:00
Simon Chrzanowski
c4615ff561 Read change feeds through git-pkgs/registries
The feed readers move to registries' changes package. The proxy keeps the
cursor and moves it past the changes only once their metadata is expired,
so a failed expiry reads them again.
2026-10-30 10:09:21 +01:00
Simon Chrzanowski
fc23498c77 Merge remote-tracking branch 'origin/main' into feat/change-feeds 2026-10-30 10:06:41 +01:00
Andrew Nesbitt
cc1b8ac870
Merge pull request #608 from simonchrz/feat/mirror-cran
Mirror CRAN source packages
2026-10-30 08:59:07 +00:00
Andrew Nesbitt
63a9d9aac2
Merge remote-tracking branch 'origin/main' into feat/composer-advisories 2026-10-30 08:51:44 +00:00
Andrew Nesbitt
9babab7380
Describe archived CRAN versions as fetched unchecked 2026-10-30 08:51:35 +00:00
Andrew Nesbitt
88f22cc440
Merge pull request #615 from simonchrz/ci/monthly-minor-on-breaking
Release a new minor version when breaking changes were merged
2026-10-30 08:48:34 +00:00
Andrew Nesbitt
e09844e3d7
Merge pull request #610 from simonchrz/feat/mirror-brew
Mirror Homebrew bottles
2026-10-30 08:47:36 +00:00
Andrew Nesbitt
54ad028652
Merge pull request #629 from simonchrz/fix/cooldown-unsupported-ecosystems
Show cooldown only for ecosystems that apply it
2026-10-30 08:43:36 +00:00
Andrew Nesbitt
37ecc3d8ce
Trim BottleImage doc comment and rejoin the brew docs list 2026-10-30 08:40:35 +00:00
Andrew Nesbitt
2928b5c27f
Detect Helm's cooldown check in the handler coverage test 2026-10-30 08:36:39 +00:00
Andrew Nesbitt
195d9d31f4
Merge pull request #623 from simonchrz/feat/npm-attestations
Serve npm attestations through the proxy
2026-10-30 08:36:37 +00:00
Simon Chrzanowski
39d178f629 Expire cooldown rewrites when a withheld version becomes available
A cached rewrite made with cooldown on stays right until a version it held
back leaves cooldown, so it now expires then instead of after a fixed
time. Handlers ask cooldown through Proxy.cooldown, which reports each
withheld version to the rewrites running at that moment. A rewrite that
held nothing back is kept for the shortest cooldown, at most a day.
2026-10-30 09:33:56 +01:00
Andrew Nesbitt
e0e212942f
Trim npm attestations doc comments 2026-10-30 08:29:33 +00:00
Simon Chrzanowski
50e65a31d8
Serve npm attestations through the proxy
Packuments list each version's provenance at dist.attestations.url on
registry.npmjs.org. npm takes only the path of that URL and resolves it
against the configured registry, so behind the proxy `npm audit
signatures` asked for /-/npm/v1/attestations/... at the proxy's root,
outside /npm, and could not verify attestations.

The rewrite now points dist.attestations.url at
/npm/-/npm/v1/attestations/{name}@{version}, and the handler serves that
path. It reads the version's attestations URL from the packument and
fetches it from the package's registry, the route's for routed packages;
a URL on another host is not followed and the registry's standard path
is asked instead. Attestations never change once published, so they are
cached as immutable metadata.

Versions blocked by the denylist get 403 and versions held back by
cooldown 404, as their tarballs do. Pushed versions have no upstream
attestations and get 404.
2026-10-30 08:26:27 +00:00
Andrew Nesbitt
e9b74be959
Merge pull request #633 from simonchrz/perf/rewrite-cache-gzip
Keep rewritten metadata gzip-only and longer with cooldown
2026-10-29 20:37:24 +00:00
Simon Chrzanowski
58e87c2571 Cache cooldown rewrites for a 24th of the shortest cooldown
An hour is long next to a cooldown of a few hours. The TTL is now a 24th
of the shortest cooldown configured, between a minute and an hour, and
the RubyGems and Hex section no longer says a minute.
2026-10-29 19:19:12 +01:00
simon chrzanowski
efe682327c
Merge pull request #635 from git-pkgs/dependabot/go_modules/github.com/klauspost/compress-1.20.3
Bump github.com/klauspost/compress from 1.20.1 to 1.20.3
2026-10-29 19:05:07 +01:00
simon chrzanowski
5288f6ae13
Merge pull request #637 from git-pkgs/dependabot/go_modules/modernc.org/sqlite-1.61.0
Bump modernc.org/sqlite from 1.60.1 to 1.61.0
2026-10-29 19:04:42 +01:00
simon chrzanowski
c9a3a6496d
Merge pull request #636 from git-pkgs/dependabot/go_modules/github.com/git-pkgs/archives-0.10.0
Bump github.com/git-pkgs/archives from 0.9.0 to 0.10.0
2026-10-29 19:04:36 +01:00
simon chrzanowski
568f4024b5
Merge pull request #634 from git-pkgs/dependabot/go_modules/github.com/git-pkgs/magic-0.6.0
Bump github.com/git-pkgs/magic from 0.5.0 to 0.6.0
2026-10-29 19:04:30 +01:00
dependabot[bot]
b31ee702a5
Bump modernc.org/sqlite from 1.60.1 to 1.61.0
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.60.1 to 1.61.0.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.60.1...v1.61.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-29 15:14:31 +00:00
dependabot[bot]
359ba32866
Bump github.com/git-pkgs/archives from 0.9.0 to 0.10.0
Bumps [github.com/git-pkgs/archives](https://github.com/git-pkgs/archives) from 0.9.0 to 0.10.0.
- [Commits](https://github.com/git-pkgs/archives/compare/v0.9.0...v0.10.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-29 15:14:22 +00:00
dependabot[bot]
ce60611391
Bump github.com/klauspost/compress from 1.20.1 to 1.20.3
Bumps [github.com/klauspost/compress](https://github.com/klauspost/compress) from 1.20.1 to 1.20.3.
- [Release notes](https://github.com/klauspost/compress/releases)
- [Commits](https://github.com/klauspost/compress/compare/v1.20.1...v1.20.3)

---
updated-dependencies:
- dependency-name: github.com/klauspost/compress
  dependency-version: 1.20.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-29 15:14:17 +00:00
dependabot[bot]
96bd0f4a59
Bump github.com/git-pkgs/magic from 0.5.0 to 0.6.0
Bumps [github.com/git-pkgs/magic](https://github.com/git-pkgs/magic) from 0.5.0 to 0.6.0.
- [Release notes](https://github.com/git-pkgs/magic/releases)
- [Commits](https://github.com/git-pkgs/magic/compare/v0.5.0...v0.6.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-29 15:13:56 +00:00
Simon Chrzanowski
10bc2f3309 Keep rewritten metadata gzip-only and longer with cooldown
pnpm 12 checks every lockfile entry against the registry on each install
unless trustLockfile is set, so each install reads the packument of every
package in the lockfile. The rewrite cache kept each document twice, plain
and gzipped, and with cooldown on for a minute only, so these packuments
were rewritten and compressed again on every install.

Keep only the gzip copy of a cached rewrite, about an eighth of the
document, and decompress it for clients and handlers that need the plain
bytes. Keep rewrites made with cooldown on for an hour. Answer HEAD for
packuments, which pnpm 12 sends before each GET.
2026-10-29 10:52:39 +01:00
Andrew Nesbitt
28ce5f49fc
Make the slow-client storage failure test deterministic 2026-10-28 15:57:06 +00:00
Simon Chrzanowski
2ea13b00d6 Show cooldown only for ecosystems that apply it
The package page showed the default cooldown for every ecosystem, also
for Debian, Alpine, OCI and generic files, which have no publish time to
check. It now shows none there, and the dashboard marks an ecosystem
override for one as not supported. The docs list Helm, which applies
cooldown from the index's created times.
2026-10-28 16:25:08 +01:00
Simon Chrzanowski
f4423ab956
Move the PURL's mirror name and qualifiers out of Enumerate
Enumerate went over the cognitive complexity limit after main added the
Julia uuid qualifier.
2026-10-28 15:18:29 +00:00
Simon Chrzanowski
db8a60e175
Drop the Homebrew sibling formulae filter
git-pkgs/registries#120 stops listing versioned formulae as versions.
2026-10-28 15:18:28 +00:00
Simon Chrzanowski
2a8ceb9389
Mirror Homebrew bottles
pkg:brew PURLs failed with "unsupported ecosystem: brew", although listing their versions worked. Homebrew publishes a formula's bottles as an image on its artifact upstream, which proxy mirror can already fetch, so a brew PURL is now resolved to that image and mirrored like one: the formula's JSON API gives the tag, which carries the revision and the bottles' rebuild, and --platform selects bottles like image platforms.

Listing a formula's versions returned the versioned siblings the Homebrew client adds (openssl@3.6 for openssl@3) as versions of it; they are now left out.
2026-10-28 15:18:14 +00:00
Simon Chrzanowski
842fa21c0a
Fetch archived CRAN versions unchecked and drop the version dedup
PACKAGES.rds comes from the same host as the tarball, so checking against it
doesn't justify a decoder for R serialization. The duplicate archived
versions are tracked in git-pkgs/registries#119.
2026-10-28 15:17:49 +00:00
Simon Chrzanowski
3fd79ccb36
Fuzz the PACKAGES.rds decoder
It reads files from the network, so malformed input must fail with an error. Seeded with the real PACKAGES.rds; 3.7M executions found nothing.
2026-10-28 15:17:49 +00:00
Simon Chrzanowski
f5251d3834
Mirror CRAN source packages
proxy mirror pkg:cran/<name>@<version> now fetches the source tarball
from upstream.cran and caches it under the key the CRAN handler reads,
("cran", name, version, name_version.tar.gz), which serves both
/src/contrib/{file} and /src/contrib/Archive/{name}/{file}.

A current version comes from src/contrib and is checked against the
MD5sum in src/contrib/PACKAGES.gz, read once per five minutes. Other
versions come from src/contrib/Archive/{name}/, checked against the
MD5sum in that directory's PACKAGES.rds, an R-serialized character
matrix read by a small decoder. Without a PACKAGES.rds the archived
tarball is fetched unchecked, as the handler fetches it.

Listing versions dropped duplicates: the CRAN client lists each archived
version twice.
2026-10-28 15:17:48 +00:00
Andrew Nesbitt
5741791c7c
Merge pull request #628 from git-pkgs/bump-registries-v0.12.0
Bump git-pkgs/registries to v0.12.0
2026-10-28 15:10:14 +00:00
Andrew Nesbitt
1c4b9d9994
Merge pull request #621 from simonchrz/feat/go-vulndb
Serve the Go vulnerability database at /go/vulndb
2026-10-28 13:45:46 +00:00
Andrew Nesbitt
9374437960
Bump git-pkgs/registries to v0.12.0 2026-10-28 13:45:11 +00:00
Simon Chrzanowski
9240f9edef Keep the upstream Composer index in memory
Serving packages.json fetched the upstream's on every request with
cache_metadata off, and after each TTL with it on, so every Composer
command waited on the upstream and on http_timeout when it was down.
The parsed index is now kept for the metadata TTL, or an hour when it
is 0, and a failed fetch is not retried within that time.
2026-10-28 14:10:48 +01:00
Simon Chrzanowski
32406d69cd Merge remote-tracking branch 'origin/main' into feat/composer-advisories 2026-10-28 14:07:39 +01:00
Simon Chrzanowski
7a3b79b10a Skip the monthly release while the last one is a draft
Building on a draft's tag would release its breaking changes as a patch
release, without the upgrade notes the draft waits for.
2026-10-28 14:07:33 +01:00
Andrew Nesbitt
58925cadea
Merge pull request #609 from simonchrz/feat/stream-cache-miss
Send artifacts to the client while a cache miss is stored
2026-10-28 12:59:52 +00:00
Andrew Nesbitt
7eed8bc135
Refer to govulncheck's -db flag in comments 2026-10-28 12:58:27 +00:00
Andrew Nesbitt
8d15cdb966
Merge pull request #618 from simonchrz/feat/cooldown-ui
Show the cooldown configuration in the web UI
2026-10-28 12:58:24 +00:00
Andrew Nesbitt
16bd0e5a59
Drop FillVersionPublishedAt until it has a caller 2026-10-28 12:50:57 +00:00
Andrew Nesbitt
626429d337
Merge pull request #624 from simonchrz/feat/npm-dist-tags
Answer npm dist-tag ls from the served packument
2026-10-28 12:45:16 +00:00
Andrew Nesbitt
6916e82207
Merge pull request #611 from simonchrz/feat/mirror-swift
Mirror Swift registry releases
2026-10-28 12:44:00 +00:00
Andrew Nesbitt
9037d937bf
Document using proxy in Docker builds 2026-10-28 11:39:28 +00:00
Simon Chrzanowski
1c706fffd0 Review fixes for the project inventory
- GetProjectsUsingPackage no longer counts each project's components
  per row; the package page only needs names and versions. The ?purl=
  listing leaves component_count out accordingly.
- The delete button sends to DELETE /ui/api/projects/{project}, the same
  handler as the API and still behind the token, so it sits under /ui
  like the cache deletes.
- The project page answers 500 when its components can't be read,
  instead of showing an empty project.
- DELETE of a path ending in /sbom that names no project answers 400
  with a hint, since it's most likely the upload path.
- The upload endpoint lists JSON, XML and plain text (SPDX tag-value)
  as accepted.
- The docs say that versions are compared as the SBOM spells them.
2026-10-27 20:23:14 +01:00
Simon Chrzanowski
8855c86fc5 Match more SBOM names to cached packages
FromPURL now names packages the way their handlers store them:

- NuGet IDs are lowercased, as the NuGet handler stores them.
- PyPI names are normalized as in PEP 503. The PyPI handler takes the
  name from the file name, which spells "." as "_", so
  pkg:pypi/zope.interface has to match the cached zope-interface.
- Conda package URLs without a channel qualifier are skipped: the conda
  handler records packages as channel/name, so they can never match.
- The Go main-module check ignores case. syft keeps the module path's
  case in its mainModule property, while golang package URLs are
  lowercased.

A table test checks every mapped PURL type against the name its handler
passes when caching.
2026-10-27 20:18:50 +01:00
Simon Chrzanowski
b5eec9c696 Drop vulnerability markers from the project inventory
The vulnerabilities table is only written by tests: nothing in the proxy
calls UpsertVulnerability, so the dashboard card, the per-project count
and the per-component markers were always empty. Remove them along with
GetComponentVulnerabilities and inventory.Affected, and keep the
outdated marker, which uses the latest version the handlers record.
2026-10-27 20:15:50 +01:00
Simon Chrzanowski
bd05533bff Delete projects from the project page
The page asks for the upload token and sends it to DELETE
/api/projects/{project}, so /ui gains no unauthenticated way to delete.
The token is kept in session storage for the next delete.
2026-10-27 19:46:51 +01:00
Simon Chrzanowski
895f22a601 Skip the project's own packages in uploaded SBOMs
A syft SBOM of a repository lists the repository itself: the module in
go.mod and the root of package-lock.json, both without a version or with
the version from package.json, and the module its Go binaries were built
from. The project page showed them as dependencies.

Entries without a version are now skipped, as are lock-file roots (lock
entries that depend on others while nothing depends on them) and modules
syft marks as a binary's main module.
2026-10-27 19:43:43 +01:00
Simon Chrzanowski
0bc0e903f8 Add a project inventory built from uploaded SBOMs
Repositories upload an SBOM of their default branch, for example from
syft in CI, to PUT /api/projects/{project}/sbom. The proxy records the
package versions it lists, keyed by the same unversioned PURL the
handlers record packages under, so the inventory joins with what the
proxy has cached and the vulnerabilities it holds.

That answers which repository uses which version of a package: the new
/ui/projects pages list each project's package versions with outdated
and vulnerability markers, package pages show the projects per version,
the dashboard lists vulnerable versions in use, and
GET /api/projects?purl= does the same for scripts.

The feature is off by default. Uploads and deletes need projects.token;
an upload replaces the project's components in one transaction.
projects.max_age removes projects that stopped uploading.
2026-10-27 19:30:34 +01:00
Simon Chrzanowski
92b1fb20a6 Follow npm and Packagist change feeds to revalidate changed metadata
Within metadata_ttl cached npm and Composer metadata is served without
asking upstream, so a new release can take that long to show up. Both
registries publish a feed of the packages that changed.

upstream.npm_change_feed and upstream.composer_change_feed (off by
default, require cache_metadata) start a reader per feed that runs every
minute and clears fetched_at on the cached rows of listed packages that
were fetched before the read. The rows keep their document and ETag, so
the next request revalidates them upstream. Routed packages are cached
under other keys and never match. A Packagist resync expires all cached
Composer metadata. Failed reads back off up to 15 minutes. The feed
position is kept in memory, so after a restart the reader starts from the
feed's current position.
2026-10-27 16:31:47 +01:00
Simon Chrzanowski
e97d4aa306 Answer npm dist-tag ls from the served packument
`npm dist-tag ls` reads /-/package/{name}/dist-tags. The proxy had no
route for it, so the request ended up in the metadata path and failed.

The tags are taken from the packument the proxy serves rather than
relayed from upstream, so latest never names a version cooldown holds
back, routed packages use their route, and pushed versions and
hosted-only scopes count as they do for the packument. Changing tags
stays unsupported and still gets 405.
2026-10-27 16:19:14 +01:00
Simon Chrzanowski
1abb8c029a Serve dated snapshots of Alpine APK indexes
Alpine mirrors keep only the current build of each package, so once a
package is upgraded `apk add curl=8.5.0-r0` fails: the current index no
longer lists the old build, even when the proxy still has it cached.

With upstream.apk_snapshots set (e.g. "90d"), each version of an index or
detached signature the metadata cache stores is copied to
_snapshots/alpine/ and recorded in a new metadata_snapshots table with the
time it was first served. /apk/{name}@{YYYY-MM-DD}/ serves the version that
was current at the end of that day; packages below it are the plain path's
requests. The kept bytes are the signed upstream bytes, so apk verifies
them unchanged.

An hourly job deletes versions replaced longer ago than the configured
duration and queues their blobs on the pending-delete queue; the newest
version of each index stays. Packages listed by kept snapshots are not
protected from eviction or retention; the docs say so.
2026-10-27 16:18:26 +01:00
Simon Chrzanowski
29ecb20733 Serve the Go vulnerability database at /go/vulndb
govulncheck reads vuln.go.dev directly, so on networks that only reach
the proxy it cannot run. upstream.go_vulndb (default https://vuln.go.dev)
is now served at /go/vulndb for `govulncheck -db`.

Only the files the database API defines are passed on (index/db,
index/modules, index/vulns and ID/{id}, as .json and .json.gz), with IDs
limited to letters, digits, '-' and '_'. They are stored and served
byte-for-byte; .json.gz files are gzip data without Content-Encoding.
All files, entries included, follow the metadata TTL since entries change
when their modified date does, and fall back to the cached copy when the
database is unreachable. Non-JSON responses are refused so a block page
does not replace the copy kept for an outage. HEAD is allowed under
/vulndb/, because govulncheck checks a non-default database with HEAD on
index/modules.json.gz.
2026-10-27 16:16:04 +01:00
Simon Chrzanowski
b2553ddd49 Pass Composer security advisories and filter lists through
The proxy's packages.json listed only metadata, search and notify URLs,
so a client using the proxy as its only Composer repository lost
`composer audit`, `audit.block-insecure` and Packagist's malware filter
list without any warning.

The service index now carries the security-advisories and filter entries
of the upstream repository's own packages.json, cached for the metadata
TTL, with URLs pointing at the proxy. An upstream that does not offer
them gets nothing advertised, so a custom upstream.composer_repository
without the API keeps working as before. Advisories and filter entries
embedded in p2 metadata were already passed on unchanged.

Advisories queries and the filter summary are relayed uncached. Names of
routed packages and hosted-only vendors are removed from advisories
queries first, so private package names are not sent to the public API.
2026-10-27 16:15:31 +01:00
Simon Chrzanowski
b79cd4d14a Show the cooldown configuration in the web UI
The dashboard shows the default and the ecosystem overrides, and counts
package overrides and patterns without naming them, since the UI has no
auth. A package page shows the cooldown that applies to the package and
the kind of rule it comes from.

Also add FillVersionPublishedAt, which stores a publish time only where
none is stored, for recording the versions cooldown holds back.
2026-10-27 13:03:42 +01:00
Simon Chrzanowski
fb346ae355 Hold an unrecorded object for a client reading its stored copy
A client that fell behind is switched to the stored copy, which for an
unrecorded refetch is deleted once the waiters close. It now takes its
own hold, released when it closes.
2026-10-27 12:30:59 +01:00
Simon Chrzanowski
4094b0fca9 Create breaking monthly releases as drafts
A breaking release gets upgrade notes before it goes out. release.yml
creates it as a draft and publish.yml isn't started; publishing the
draft by hand starts publish.yml for the images and the chart.
2026-10-27 12:16:50 +01:00
Simon Chrzanowski
c8be267586 Merge remote-tracking branch 'origin/main' into mirror-swift
# Conflicts:
#	docs/configuration.md
#	internal/mirror/mirror.go
#	internal/mirror/versions.go
2026-10-27 11:00:10 +01:00
Andrew Nesbitt
4682c1e563
Merge pull request #616 from kokes/helm-extra-volumes
Add extraVolumes, extraVolumeMounts and extraInitContainers to Helm chart
2026-10-27 09:04:55 +00:00
Andrew Nesbitt
e7f285273e
Merge pull request #607 from simonchrz/feat/mirror-julia
Mirror Julia packages
2026-10-27 08:47:52 +00:00
Ondrej Kokes
1887b124cf Add extraVolumes, extraVolumeMounts and extraInitContainers to Helm chart
The pod only ever got the config, data and tmp volumes, so there was no
way to mount a private CA certificate, file-based credentials or a
projected token, or to run init containers. All three values default to
empty, leaving the rendered Deployment unchanged.

Render the new blocks in CI and document the private CA setup in the
README.

Closes #614

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:14:28 +01:00
Simon Chrzanowski
7e2bf15e00 Release a new minor version when breaking changes were merged
The monthly release skipped the month when a pull request labelled
breaking had merged, leaving the release to be cut by hand. It now
cuts a new minor version instead (a new major version from 1.0 on).
2026-10-26 18:35:17 +01:00
Simon Chrzanowski
9a5e5a2c9a Keep a slow client from holding back a streamed miss
The upstream read fed the client through a pipe, so it ran at the client's
speed. The client is now fed from a bounded buffer and, once it falls
behind, continues from the stored copy where it left off.

A caller handed the stream takes no hold on an unrecorded object.
2026-10-26 16:44:11 +01:00
Simon Chrzanowski
4de0848b18 Merge remote-tracking branch 'origin/main' into feat/stream-cache-miss
# Conflicts:
#	internal/handler/handler.go
2026-10-26 16:44:11 +01:00
Simon Chrzanowski
8ea75a30a3 Check cached Swift documents against their digest and resolve source PURLs
Release lists and manifests record their sha256 digest and are read back
through storedMetadataEntry and openMetadataEntry, so a corrupted copy is
fetched again. pkg:swift PURLs naming a source repository are looked up
through the registry's /identifiers.
2026-10-26 16:43:42 +01:00
Simon Chrzanowski
391c5b709f Index artifacts by filename and shorten Julia comments
CachedArtifactNames filters on filename alone, which the unique
(version_purl, filename) index can't serve.
2026-10-26 16:40:15 +01:00
Simon Chrzanowski
58e2258eb9 Merge main into feat/mirror-swift 2026-10-26 16:38:56 +01:00
Simon Chrzanowski
6bd0cea0fa Merge remote-tracking branch 'origin/main' into feat/mirror-julia 2026-10-26 16:38:08 +01:00
Andrew Nesbitt
2998a97a4a
Merge pull request #612 from simonchrz/refactor/npm-portable-metadata
Extract portable npm metadata transformations
2026-10-26 10:58:30 +00:00
Andrew Nesbitt
2334a51a52
Merge main into feat/mirror-julia
# Conflicts:
#	cmd/proxy/main.go
#	docs/configuration.md
#	internal/mirror/mirror.go
#	internal/mirror/versions.go
#	internal/server/mirror_versions.go
#	internal/server/mirror_versions_test.go
2026-10-26 10:33:01 +00:00
Andrew Nesbitt
fddef8e46e
Merge pull request #506 from pinguinfuss/issue-467-atomic-queue
Clear a cached artifact and queue its file in one transaction
2026-10-26 10:27:24 +00:00
Andrew Nesbitt
61df321331
Merge pull request #606 from simonchrz/feat/mirror-helm
Mirror Helm charts
2026-10-26 10:26:53 +00:00
Andrew Nesbitt
6bbccd600d
Merge pull request #604 from simonchrz/perf/cooldown-metadata-cache
Read gem, Hex and conda cooldown metadata through the metadata cache
2026-10-26 10:25:46 +00:00
Andrew Nesbitt
257d159ecf
Release an unrecorded object's hold only once per reader 2026-10-26 10:19:24 +00:00
Christian Heim
5953af9b05 Merge main. Main now records UpsertArtifact and its queue entry in one transaction itself, so that part of this branch is gone; ClearArtifactCache, eviction and the unrecorded refetch remain, and an unrecorded refetch counts as failed for instances waiting on its lease. 2026-10-26 08:57:26 +01:00
Christian Heim
24acee2731 When a refetched file can be neither recorded nor queued for deletion, it is still served to everyone sharing the fetch and deleted once the last of them has closed it, so it no longer stays in storage with nothing pointing at it. A record that committed despite the reported error keeps its file. 2026-10-26 08:49:50 +01:00
Simon Chrzanowski
e070618021 Move abbreviated packument detection into internal/metadata/npm
Whether a packument is the abbreviated format is a property of the
document, so it sits with the rest of the npm metadata code; the
handler keeps only the error it turns that into. Add a test that keeps
the package's imports to the portable set, give jsonscan a package
comment, and describe the new packages in the architecture docs.
2026-10-25 22:22:00 +01:00
Andrew Nesbitt
8ed0edb90a Extract portable npm metadata transformations
npm metadata rewriting depended on handler.Proxy, which pulls SQL
drivers, cloud SDKs and metrics into its import graph. Move the
transformation into internal/metadata/npm with explicit policy,
evaluation time, public URL and hosted-version inputs, and share the
JSON scanner between npm and Composer through internal/jsonscan.

Rebased onto main: rewritten tarball URLs are still logged once per
document, now through an OnTarballsRewritten callback that reports the
count.
2026-10-25 22:21:37 +01:00
Simon Chrzanowski
673a581f9e Mirror Swift registry releases
proxy mirror and mirror jobs now take pkg:swift PURLs whose namespace
is the registry scope, such as pkg:swift/apple/swift-nio@2.62.0 for
apple.swift-nio. The source archive is fetched from upstream.swift,
checked against the SHA-256 checksum in the release metadata and cached
under the key the archive route serves. PURLs whose namespace is a
source repository URL don't name a registry identity and are refused.
Unversioned PURLs list releases from upstream.swift.

SwiftPM reads the release list and Package.swift before it downloads
an archive, and the handler passed both through uncached, so a mirrored
release still couldn't be resolved with the upstream down. With
cache_metadata the handler now keeps them in the metadata cache along
with their Link header, which carries release list pages and the
version-specific manifests, and serves them when the upstream fails.
Manifests are keyed by tools version, so the 5.8 of an alternate link
and the 5.8.0 SwiftPM requests share a key. The mirror stores every
release list page and each manifest a release links.
2026-10-25 21:48:59 +01:00
Simon Chrzanowski
2fed8901ae Send artifacts to the client while a cache miss is stored
On a cache miss the proxy stored the whole artifact before sending any of
it, so clients with an idle timeout gave up on large artifacts: git-lfs
after 30 seconds, pacman after 10. The caller that starts a shared fetch
now gets the bytes as they arrive while they are stored; requests joining
the fetch, HEAD and range requests still wait for the stored copy.

The client's copy is checked against the upstream's digest as it is read,
and a mismatch aborts the response before it completes. A client going
away leaves the copy to storage running. A storage error leaves the
client's copy running: the client gets the artifact, and the failure is
logged at error level and counted in proxy_artifacts_served_uncached_total.

A block-mode scanner has to see the whole artifact first, so with one
configured for the ecosystem a miss is handled as before.
2026-10-25 20:31:06 +01:00
Simon Chrzanowski
a7fa07ffdb Merge main into feat/mirror-julia 2026-10-25 19:40:38 +01:00
Simon Chrzanowski
c72e18f35c Merge main into feat/mirror-helm 2026-10-25 19:38:45 +01:00
Simon Chrzanowski
3f83d9c0ce Mirror Julia packages
proxy mirror and mirror jobs now take pkg:julia PURLs with the uuid
qualifier. The tree hash of the version comes from the General registry,
after checking the uuid is the one General lists for the name, and the
tarball is fetched from upstream.julia at /package/{uuid}/{tree-hash} and
cached under the name and tree hash the Julia handler uses.

The handler names a package from the registry's Registry.toml, which it
loads from the upstream. Without the upstream it fell back to caching and
looking up by uuid, so a package cached under its name, mirrored or
fetched before a restart, was not served offline. It now looks the
tarball up by its tree hash in that case.

Julia versions are listed from General whatever upstream.julia is, as
packages are identified by uuid and tree hash on every Pkg server serving
General.
2026-10-25 19:37:46 +01:00
Simon Chrzanowski
17e4fc763f Mirror Helm charts
Mirroring pkg:helm PURLs failed with an unsupported ecosystem error. The
namespace now names the upstream.helm repository, as the Helm handler
caches charts per repository: pkg:helm/{repository}/{chart}@{version}.

The chart is found in the repository's index through the handler's own
index lookup, fetched from the first URL the release lists, as helm does,
checked against the listed digest and cached under the repository,
digest and filename the handler serves it from. The index goes through
the metadata cache, so helm finds the chart there while the repository is
down. Unversioned PURLs list every version in the index.

Charts without a repository, from an unconfigured one, or listed in an
OCI registry are refused; the last are pulled through /v2, and the error
names the pkg:oci PURL that mirrors them.
2026-10-25 19:37:22 +01:00
Simon Chrzanowski
f8ee4c15b2 Merge main into perf/cooldown-metadata-cache 2026-10-25 19:37:00 +01:00
Andrew Nesbitt
05ad702a65
Merge pull request #595 from simonchrz/feat/git-lfs-cache
Cache Git LFS objects
2026-10-25 18:32:30 +00:00
Simon Chrzanowski
bcda24e15c
Document timeouts for large LFS objects 2026-10-25 18:19:09 +00:00
Simon Chrzanowski
fe4be3ccd6
Reuse batch download URLs and stored copies for LFS objects
- Keep the download URLs of a batch response in memory until shortly
  before they expire, so the GET that follows needs no second batch
  request. A kept URL that fails is replaced by a fresh one.
- Cut off a download that isn't the object's size.
- Copy an object whose content is already stored, such as for a fork,
  instead of downloading it, once the upstream has confirmed the
  repository has it. Artifacts are indexed by content hash for this.
2026-10-25 18:19:09 +00:00
Simon Chrzanowski
371cefa889
Harden the Git LFS cache
- Keep signed download URLs out of logs and the database: the fetch uses
  the signed URL, everything else the URL without its query.
- Answer a batch from the cache when the upstream can't be reached or
  fails, so git-lfs can use cached objects during an outage. Refusals
  are still passed on.
- Answer a HEAD for an uncached object from the batch response instead
  of downloading it.
2026-10-25 18:19:00 +00:00
Simon Chrzanowski
605e27d69a
Cache Git LFS objects
git-lfs finds the LFS API under the rewritten remote, so clones through
/git/ already asked the proxy for LFS objects and got 404s. The proxy now
serves the batch API for downloads: it forwards the batch to the upstream,
which decides which objects the repository has, and points each download
at the proxy. Objects are served from the artifact cache. A miss asks the
upstream batch API for a fresh download URL, fetches it and checks it
against its sha256 object ID before caching it.

Download URLs often name another host than the Git server, such as object
storage. The proxy fetches from whatever host the upstream names, with the
usual address checks; upstream.auth credentials stay scoped to their
hosts. Objects are cached per repository. Uploads and locking are refused.

Closes #517.
2026-10-25 18:19:00 +00:00
Simon Chrzanowski
14a253426b Merge main into perf/cooldown-metadata-cache
# Conflicts:
#	docs/configuration.md
#	internal/handler/metadata_stream.go
2026-10-25 19:17:18 +01:00
Andrew Nesbitt
91d0df7141
Merge pull request #603 from simonchrz/perf/sqlite-read-pool
Read SQLite through a separate read-only pool
2026-10-25 18:16:26 +00:00
Andrew Nesbitt
3c2449e753
Merge pull request #601 from simonchrz/perf/shared-rate-limit-holds
Share upstream rate-limit holds across instances
2026-10-25 18:15:55 +00:00
Andrew Nesbitt
ab6040743d
Merge pull request #600 from simonchrz/perf/helm-pypi-rewrite-cache
Cache Helm and PyPI metadata rewrites
2026-10-25 18:15:25 +00:00
Andrew Nesbitt
6288e6162d
Merge pull request #599 from simonchrz/perf/oci-tag-head-revalidation
Revalidate stale OCI tags with HEAD and coalesce manifest requests
2026-10-25 18:15:01 +00:00
Andrew Nesbitt
c5b8088a48
Merge pull request #598 from simonchrz/perf/immutable-metadata
Stop refetching Go, RPM and PyPI metadata that can't change
2026-10-25 18:14:33 +00:00
Andrew Nesbitt
9360117837
Merge pull request #597 from simonchrz/perf/memory-quick-wins
Cut memory and CPU on cache hits, uploads and metadata reads
2026-10-25 18:14:03 +00:00
Andrew Nesbitt
c17a01c214
Merge pull request #594 from simonchrz/feat/mirror-composer-archive
Mirror Composer releases
2026-10-25 18:13:15 +00:00
Andrew Nesbitt
b5b36dc3af
Merge pull request #593 from simonchrz/feat/mirror-conda
Mirror conda packages
2026-10-25 18:12:45 +00:00
Andrew Nesbitt
17ab4bbc3c
Merge pull request #602 from simonchrz/perf/hot-path-small-fixes
Trim three small costs on the download path
2026-10-25 18:12:10 +00:00
Andrew Nesbitt
030240bfdc
Merge pull request #605 from simonchrz/fix/conda-bz2-cooldown
Refuse unfiltered conda repodata while cooldown is on
2026-10-25 18:11:20 +00:00
Andrew Nesbitt
6cdd926a9e
Merge pull request #592 from simonchrz/fix/mirror-file-digest
Check mirrored files against their own SHA-256
2026-10-25 18:10:36 +00:00
Simon Chrzanowski
6347735679 Treat file::memory: URIs as in-memory and test more of the read pool
isMemoryPath missed URIs such as file::memory:?cache=shared, whose
readers would each have got an empty database. New tests open a database
left in rollback-journal mode and run writes, hit flushes, lookups and
stats scans concurrently.
2026-10-25 18:14:58 +01:00
Simon Chrzanowski
4b3bd6f815 Refuse unfiltered conda repodata while cooldown is on
The proxy filters repodata.json and current_repodata.json, but
repodata.json.bz2 went through the metadata cache unfiltered, and
repodata.json.zst and repodata.jlap reached the download handler and were
passed through. conda tries repodata.json.zst first, so with cooldown on it
got versions still in cooldown.

With cooldown on these now answer 404, and conda falls back to
repodata.json. repodata_from_packages.json is filtered like repodata.json.
2026-10-25 17:50:49 +01:00
Simon Chrzanowski
b09ac87264 Read gem, Hex and conda cooldown metadata through the metadata cache
With cooldown on, the RubyGems compact index and versions API, the Hex
package and Hex API entry, and conda's repodata.json were fetched with
plain HTTP requests on every client request: no TTL, no revalidation,
no coalescing and no stale fallback, even with cache_metadata.

Gem and Hex now read both documents through the metadata cache and
filter the cached copies. The filtered result goes through the rewrite
cache and is served with an ETag. When the times document is older than
the index and misses a version, it is fetched again; if that fails the
version is left out. Conda filters a fresh stored repodata.json as it
reads it from storage, uses the metadata cache otherwise, and remembers
the ETag of the filtered copy until a withheld entry leaves cooldown.
2026-10-25 17:41:40 +01:00
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
Simon Chrzanowski
d64bfc6d36 Refuse '?' and '#' in paths in more handlers
Debian, RPM, APK, PyPI, CRAN and Maven build upstream URLs from the
decoded request path, where a '?' or '#' would end the path and cache
one file under another's name. Only the server middleware refused them;
Arch and FreeBSD also check in the handler. Check in these handlers
too, so they stay safe when mounted without the middleware.
2026-10-24 12:57:20 +02:00
Simon Chrzanowski
aa0460a15b Bound Hex and gem cooldown reads by the metadata size limit
With cooldown on, the Hex handler read the /packages response, its decompressed payload and the Hex API's JSON without a limit, and the gem handler decoded the versions API's JSON the same way. A misbehaving upstream could make the proxy hold any amount in memory, and a small gzip payload could expand without bound.

These reads now go through ReadMetadata, so metadata_max_size applies to each, and to the Hex payload after decompression as the NuGet handler does. A response over the limit fails the request with 502.
2026-10-24 12:55:24 +02:00
Simon Chrzanowski
09e41d0272 Fail closed in gem, Hex and PyPI cooldown
RubyGems, Hex and PyPI keep publish times outside the metadata clients
read, so cooldown fetches them from a second API. When that fetch
failed, was rate limited, or returned something that didn't parse, the
compact index, Hex package or simple page was served unfiltered, and a
Hex package that couldn't be re-encoded was passed on as upstream sent
it. A flaky or rate-limited timestamp API let every version in
cooldown through.

These now fail with 502, or with 503 and Retry-After when the rate
limit gate held the request, as the Go handler already does. PyPI file
downloads check the same way. A PyPI package the JSON API answers 404
for still has no times and is served unfiltered, as before.

npm, Composer, pub.dev and PyPI JSON metadata that couldn't be
rewritten was also passed on unfiltered unless the package had denied
versions. It now fails with 502 whenever cooldown applies too. npm
tarball downloads whose packument couldn't be fetched skipped the
cooldown check and downloaded straight from upstream; they now fail.

All of this only applies when cooldown covers the package: packages
exempted with a duration of 0 skip the timestamp lookups, and the gem
and Hex handlers serve them through the metadata cache as when cooldown
is off.
2026-10-24 12:55:24 +02:00
Simon Chrzanowski
77bd66acab Bound Git requests by their body and response progress
upload-pack was started with its stdin attached to the request body, after
the read deadline had been extended to two hours, and ls-refs requests took
no slot. A client that sent part of a request and then nothing kept an
upload-pack process waiting, and any number of them could.

Stateless requests are complete once sent, so the handler now reads the
body in full first, within the server's read timeout, keeping bodies past
1 MiB in a temporary file. Only then are the deadlines extended for the
response. info/refs and ls-refs share their own slots, four per CPU, so they
still never wait behind clones. Each response write is bounded to two
minutes, so a client that stops reading a pack frees its slot instead of
holding it until the request timeout.
2026-10-24 12:55:14 +02:00
Simon Chrzanowski
d2c6587996 Hash path-based metadata cache keys
Conan, CRAN, Conda, RPM, the main Debian archive and the default Maven
repository keyed cached metadata by the request path with '/' replaced
by '_'. Names and versions may contain '_', so foo/bar_1.0 and
foo_bar/1.0 shared an entry, and with cache_metadata one reference's
recipe files were served for the other. Conan also appended the query
with '_', so a path without a query could collide with a shorter path
plus one.

Hash the upstream URL, the path and, for Conan, the query instead, as
the APK, Arch, generic and Helm handlers already do. Each part is
length-prefixed so no two inputs share a key.
2026-10-24 12:55:00 +02:00
Andrew Nesbitt
562ffb3ecd
Merge pull request #586 from simonchrz/fix/gem-filename-length
Refuse overlong gem filenames
2026-10-24 11:44:41 +01:00
Andrew Nesbitt
319253a1ce
Merge pull request #584 from simonchrz/fix/gem-index-gzip
Filter gzipped gem compact indexes under cooldown
2026-10-24 11:44:26 +01:00
Andrew Nesbitt
1c3f1aa255
Merge pull request #583 from simonchrz/fix/verify-all-digests
Verify every OCI digest and Helm charts before caching
2026-10-24 11:44:04 +01:00
Andrew Nesbitt
cefdbe6b70
Merge pull request #582 from simonchrz/fix/npm-tarball-unverified
Don't fetch npm tarballs whose packument can't be read
2026-10-24 11:43:41 +01:00
Andrew Nesbitt
5d9df6047c
Merge pull request #581 from simonchrz/fix/generic-auth-paths
Require generic_paths for generic upstreams with credentials
2026-10-24 11:43:11 +01:00
Andrew Nesbitt
9ef99e2ebc
Merge pull request #576 from simonchrz/fix/registry-token-scope
Send registry tokens only to the token service's own registry
2026-10-24 11:38:40 +01:00
Andrew Nesbitt
08e9e7ddea
Merge pull request #574 from simonchrz/fix/mirror-csrf-ui-framing
Refuse cross-site mirror jobs and framing of the UI
2026-10-24 11:35:18 +01:00
Andrew Nesbitt
4a1e4fe4e4
Merge pull request #573 from simonchrz/feat/maven-cooldown
Apply cooldown to Maven
2026-10-24 11:34:46 +01:00
Andrew Nesbitt
b855c5b744
Merge pull request #572 from simonchrz/fix/flaky-tests
Fix flaky tests on Windows and slow runners
2026-10-24 11:33:34 +01:00
Andrew Nesbitt
7aee2bb078
Merge pull request #571 from git-pkgs/docs/oci-pull-only
Document that the OCI handler is pull-only
2026-10-24 11:33:00 +01:00
Andrew Nesbitt
44b81a2f1e
Merge pull request #570 from simonchrz/feat/fetch-leases
Fetch each cache miss once across instances
2026-10-24 11:32:37 +01:00
Andrew Nesbitt
81fbbe6c28
Merge pull request #569 from simonchrz/fix/multi-replica-locks
Coordinate instances sharing a Postgres database
2026-10-24 11:31:59 +01:00
Andrew Nesbitt
4bc42c4fb1
Merge pull request #561 from simonchrz/feat/conda-shards
Serve conda sharded repodata through cooldown and the cache
2026-10-24 11:31:31 +01:00
Simon Chrzanowski
ce39583784 Verify Helm charts before they are cached
Without artifact streaming, a chart was fetched with no expected
digest, committed to the cache, and only then compared with the
index's digest by serveChart, which cleared it again. Until then the
unverified chart was a cache entry like any other.

The index digest is now always passed to the fetch, so a mismatching
chart is discarded before it is committed, as it already was when
streaming. serveChart's check stays for charts that were cached before.
2026-10-24 08:12:59 +02:00
Simon Chrzanowski
ff10ab3491 Verify sha512 OCI digests and refuse ones that can't be checked
Manifests and digest-addressed artifacts were only checked against
sha256 digests. A sha512 digest, which the OCI spec also allows, or a
digest with any other algorithm was fetched and cached unchecked: a
manifest requested as sha512:<hex>, or a tag whose Docker-Content-Digest
header named one, was stored under that digest whatever the body was.

Manifests are now checked against any digest in the reference or the
Docker-Content-Digest header with the digest's own algorithm, and one
the proxy can't compute is answered with DIGEST_INVALID and not cached.
GetOrFetchArtifactFromURLWithDigest verifies sha512 (and sha384)
digests through the declared-integrity check, so a mismatch is never
committed, and refuses other algorithms with ErrUnsupportedDigest
instead of fetching unverified. Blob routes still only accept sha256.
2026-10-24 08:12:59 +02:00
Simon Chrzanowski
13cb62c236 Store metadata under a hash of its cache key
Cached metadata was stored at _metadata/<ecosystem>/<key>/metadata. Keys
are case-sensitive, but on a case-insensitive filesystem such as APFS or
NTFS, file:// storage put keys that differ only in case in one file:
github.com/Sirupsen/logrus and github.com/sirupsen/logrus each had a
row, and whichever was cached last was served for both. Keys may also
hold names Windows reserves or end in a dot.

The path now carries a SHA-256 of the exact key. Keys that already are
SHA-256 hex digests, as most hashed keys are, keep their path.

A row still pointing at an unhashed path may hold another key's bytes,
so it is neither served nor revalidated; the document is fetched again
and stored at the new path, and the old blob is queued for deletion.
2026-10-24 08:09:25 +02:00
Simon Chrzanowski
9500c5b5a4 Check upstream destinations sent through a forward proxy
The upstream client refuses loopback, private and link-local addresses
when it dials, but with HTTP_PROXY or HTTPS_PROXY set it dials the
forward proxy, so only the forward proxy's address was checked. A URL
taken from upstream metadata, such as a Helm chart URL, a Composer dist
URL, a token realm or a redirect, could then reach 169.254.169.254 or
an internal host through the forward proxy.

The transport's proxy function now resolves the destination of every
request it sends to a forward proxy and applies the same rules,
including upstream.allow_loopback and upstream.allow_private_hosts,
before handing it over. A name that cannot be resolved locally is still
passed on, since some networks only resolve public names at the
forward proxy. Direct requests are unchanged.

Git fetches and token requests use the same transport, so they are
covered too.
2026-10-24 08:07:13 +02:00
Simon Chrzanowski
b6d7575b88 Don't fetch npm tarballs whose packument can't be read
An uncached tarball is checked against the integrity its packument
declares. When the packument couldn't be fetched or was refused, for
example because it is larger than metadata_max_size or the registry
answered 503, the download went ahead with no integrity at all, and
the unverified tarball was cached and served to every later client.

Without the packument the download now fails like the Composer one
does: 502, or 404 when the registry says the package doesn't exist.
Nothing is fetched or cached. Tarballs already in the cache are still
served, since they were verified when they were stored, and a packument
that lists the version without a usable checksum still falls back to
an unverified fetch as before.
2026-10-24 08:06:10 +02:00
Simon Chrzanowski
2ea8915e06 Filter gzipped gem compact indexes under cooldown
With cooldown on, /info/{name} is fetched from upstream and versions still in cooldown are removed line by line. The fetch passed on the client's Accept-Encoding, and setting that header turns off the transport's transparent decompression, so for any client that accepts gzip, Bundler included, the filter scanned gzip bytes, matched no versions and sent the compressed index on with upstream's Content-Encoding. Cooldown filtered nothing for those clients.

The fetch now passes on only Accept, so the transport decompresses the index before it is filtered. If-None-Match and If-Modified-Since are no longer passed on either: the client's validators came from a filtered copy, not from upstream's file.

The filtered index no longer carries upstream's ETag, Last-Modified, Digest, Repr-Digest or range headers, which describe the unfiltered file; Bundler checks the digest against the body it gets.

The index is now read through ReadMetadata before anything is written. bufio.Scanner stopped at a line over 64 KB and the handler sent what it had so far as a complete 200; a read error or an index over the metadata size limit now answers 502.
2026-10-24 08:03:16 +02:00
Simon Chrzanowski
112e9c89ec Require generic_paths for generic upstreams with credentials
A generic upstream forwards any path below /generic/{name}/. The
documented mise setup puts a GITHUB_TOKEN on api.github.com to raise
the rate limit, so anyone who could reach the proxy could make any GET
request with that token, including for private repositories it can
read, and the answers went into the shared metadata cache. upstream.git
already refuses an upstream with credentials unless it lists its repos.

upstream.generic_paths now lists, per generic upstream, the paths it
may serve, with the same pattern syntax as git repos. Other paths
answer 404 without reaching the upstream, and so does a path holding
'%', '?', '#' or a backslash, which the upstream could read as a
different path than the one that matched. Config validation refuses a
generic upstream that upstream.auth has credentials for unless it has
generic_paths, which also covers a github.com token reaching the
generic github upstream. Upstreams without credentials keep serving
every path.

The docs and example config show the paths mise needs and recommend a
fine-grained token limited to public repositories.
2026-10-24 08:02:22 +02:00
Simon Chrzanowski
aa3adbfa63 Bound the registry token and challenge caches
The transport remembered a challenge for every /v2/<repository> it was
ever challenged on and never forgot one, so a long-running proxy, or a
metadata document naming many registries, grew the map without limit.
Tokens were only pruned of expired entries, and the expiry came from
the token service.

Challenges are now kept per registry host, holding only the realm and
service, and the pull scope for a request is built from its path, so
pulling many repositories from one registry keeps one entry. Both
caches have a size limit, evicting an arbitrary challenge or the token
that expires soonest, and a token is reused for at most an hour,
whatever lifetime the token service claims.
2026-10-24 08:02:10 +02:00
Simon Chrzanowski
351fe7c0b5 Send registry tokens only to the token service's own registry
After a 401 the transport fetches a token from the challenge's realm,
using credentials configured for that realm, and sends it to the host
that issued the challenge. It trusted that host if it had any configured
credentials, even unrelated ones, or shared the realm's registrable
domain. The second rule makes every tenant on a shared domain such as
jfrog.io, azurecr.io or pkg.dev the same site, and the first hands a
token minted from one registry's credentials to another registry the
proxy happens to have a key for.

A credentialed token now goes only to a registry on the realm's own
host and port, to one configured with the same credentials as the
realm, or to Docker Hub and GitLab.com, whose registries use a token
service on another host. Anonymous tokens are unaffected.

The scope in the token request also came from the challenge, so a
registry could ask for push access or for other repositories. The proxy
only reads, so it now always asks for pull access to the repository in
the request path.
2026-10-24 08:00:48 +02:00
Simon Chrzanowski
f27fe1193d Refuse overlong gem filenames
parseGemFilename tries every "-<digit>" in the filename as the start of the version and matches the rest against two patterns each time, so its cost grows with the square of the filename's length. A path of a few hundred kilobytes kept a request busy for minutes, and the parse doesn't watch the request context.

Gem filenames longer than 512 bytes are now answered with 400 before parsing. Real ones are far shorter.
2026-10-24 07:59:32 +02:00
Simon Chrzanowski
9a788958ec Refuse framing of the web UI
Without X-Frame-Options or a frame-ancestors policy another site could
frame a version page and trick a signed-in user into clicking Delete.
Every /ui response now sends both; browsed archive files keep their
sandbox policy alongside frame-ancestors.
2026-10-24 07:48:08 +02:00
Simon Chrzanowski
003c76049f Require a JSON content type to start mirror jobs
A page on another site could make a visitor's browser POST a text/plain
body that parses as JSON and start mirror jobs. Browsers only send
application/json cross-origin after a CORS preflight, which the proxy
never answers, so HandleCreate now refuses other content types with 415.
2026-10-24 07:48:08 +02:00
Simon Chrzanowski
faecb56c85 Polish Maven cooldown after review
- Name the proxy in the User-Agent of publish-time HEAD requests, as its
  artifact downloads do, rather than Go's default, which Maven Central
  asks tools not to send.
- Take a version's time from its Gradle .module as well as its .pom, so
  either one fetched first carries it.
- Serve filtered metadata through the shared rewrite path, with gzip and
  the stale warning like the other ecosystems.
- Say in the docs that a refused .pom or .module is still cached.
2026-10-23 20:53:06 +02:00
Simon Chrzanowski
f81274b499 Describe how Maven publish times are read 2026-10-23 20:07:53 +02:00
Simon Chrzanowski
78f614e82c Read a Maven version's time from its .pom download
A build fetching pinned versions never reads maven-metadata.xml, so each
version's time was read with a HEAD before its download, roughly
doubling the requests to Maven Central, which answered a fresh build
with 429 and a 30-minute Retry-After. The .pom's own response now gives
the time; Maven fetches the .pom before a version's other files, so
their check reads the stored time. A HEAD is only sent for a .pom served
from the cache or a file requested before its .pom.

A 429 on that HEAD now answers 503 with the upstream's Retry-After, as
other held upstream requests do, instead of 502.
2026-10-23 19:50:34 +02:00
Simon Chrzanowski
e6c4a3e57a Document Maven cooldown
Includes the caveats from #132: Nexus and Artifactory dates, Google's
Maven Central mirror, version-ordered lists and SNAPSHOTs.
2026-10-23 19:38:28 +02:00
Simon Chrzanowski
74f8eb378b Apply cooldown to Maven
A version's publish time is the Last-Modified of its .pom, read with a
HEAD request once per version and stored in the versions table. Named
repositories store theirs under a repository_url qualifier.

Versions in cooldown are left out of maven-metadata.xml, <latest> and
<release> are moved to the last versions left, and the metadata's
checksum files are computed from what is served. Files of a version in
cooldown get 403. Only versions that can still be in cooldown are
checked: none when <lastUpdated> is older than the window, otherwise
from the end of the list back to the first old enough version.

Part of #132.
2026-10-23 19:38:28 +02:00
Simon Chrzanowski
562faac436 Split Maven metadata fetch from its response
Cooldown needs to read maven-metadata.xml before serving it, with the
same Gradle Plugin Portal fallback and error handling.
2026-10-23 19:38:28 +02:00
Simon Chrzanowski
e0cb94641f Make the git cache's RefreshWait test independent of timing
TestSlowFetchAfterPushIsWaitedFor relied on the ref check finishing
within two seconds, which a slow Windows runner missed. The test now
holds the fetch and ends RefreshWait itself. When the check and the wait
end together, ensureFresh now waits for the fetch rather than picking
either at random. Failing gitcache tests print the cache's log.
2026-10-23 19:34:09 +02:00
Simon Chrzanowski
cdd3bea338 Cut the relay connection on any upstream copy error
A response that ended early for another reason than the size limit was
passed on as complete. TestRelayStopsAtLimit failed that way in CI. The
test upstream now reads the request body as a git server does, and the
test prints the relay's log when it fails.
2026-10-23 19:34:09 +02:00
Simon Chrzanowski
35df59f8f2 Wait for background jobs before closing the database at shutdown
Shutdown cancelled the eviction, retention, reclaim, backfill and stats
loops but closed storage and the database without waiting for them, and
the stats loop never stopped. A pass still running kept the database
open, which on Windows left proxy.db locked after a server test. Shutdown
now waits up to 30 seconds for them, and for mirror jobs.
2026-10-23 19:34:09 +02:00
Simon Chrzanowski
0acc41490e Stop mirror jobs before a job store test cleans up
The job store tests left their jobs running after the test, and on
Windows the test's directories could not be removed while a job was
still writing to them. JobStore.Wait now waits for jobs to return, and
the tests cancel their jobs and wait for them.
2026-10-23 19:34:09 +02:00
Simon Chrzanowski
64162c77d8 Keep detached lease waits alive and stop waiting on a failed fetch
A detached fetch waiting on another instance's lease ran the wait under
the request context, so a client leaving cancelled every caller joined
to it. The wait now runs past the client, bounded by the holder's fetch
and the lease expiry.

When the holder's fetch failed, each waiting instance took the lease in
turn and repeated it, so against a hanging upstream the k-th instance
waited about k timeouts. The holder now marks the lease failed. The
instances waiting on it then serve their stale metadata copy if they
have one, and otherwise fetch at the same time.
2026-10-23 19:19:22 +02:00
Simon Chrzanowski
743289c8ca Let an interrupted mirror schedule run again
A schedule's claim was kept when its run was cut off, so after a
restart every instance skipped it for most of an interval. The scheduler
now drops the claim when the run ends with its context cancelled.
2026-10-23 19:07:32 +02:00
Simon Chrzanowski
eb5ff1a9da Accept only relative paths in a conda shard index's base URLs
Clients resolve base_url and shards_base_url against the proxy's URL. A
scheme or host sends them around the proxy, and a leading slash leaves
its mount, so neither layout is served.
2026-10-23 19:06:07 +02:00
Andrew Nesbitt
f0548d107f
Document that the OCI handler is pull-only 2026-10-23 17:34:15 +01:00
Andrew Nesbitt
0ef7312d3c
Merge pull request #568 from simonchrz/perf/composer-download-cache-first
Serve cached Composer releases without a metadata lookup
2026-10-23 16:51:15 +01:00
Andrew Nesbitt
e050fbfa24
Merge pull request #567 from simonchrz/fix/npm-search-and-abbreviated
Fix npm search routing and abbreviated packuments under cooldown
2026-10-23 16:48:33 +01:00
Andrew Nesbitt
d87f857856
Merge pull request #566 from simonchrz/perf/cooldown-rewrite-cache
Cache cooldown metadata rewrites for a minute
2026-10-23 16:45:19 +01:00
Andrew Nesbitt
183b07e14d
Merge pull request #565 from simonchrz/fix/julia-registry-refresh
Refresh the Julia name map only from the advertised registry
2026-10-23 16:44:27 +01:00
Andrew Nesbitt
20e648f4b7
Merge pull request #564 from simonchrz/feat/go-cooldown
Apply cooldown to Go modules
2026-10-23 16:43:12 +01:00
Simon Chrzanowski
84111c387a Fetch each cache miss once across instances
Instances sharing a Postgres database each fetched a missed artifact from
upstream; only callers within one instance shared a fetch. The instance
that fetches now holds a lease on the miss in a new fetch_leases table and
renews it while it downloads. Another instance missing the same key polls
the cache and serves the stored copy, or takes the lease over when the
holder releases it without storing anything or stops renewing it.

Metadata misses use the same lease when metadata caching is on with a TTL
above zero; without a stored copy there is nothing to wait for.

proxy_fetch_lease_waits_total counts the misses that waited. Reclaim
deletes leases left by stopped instances.

Refs #543
2026-10-23 16:54:54 +02:00
Simon Chrzanowski
3fcdb0b652 Close the SQLite test databases in the lock tests
Windows cannot remove the temporary directory while the file is open.
2026-10-23 16:30:43 +02:00
Simon Chrzanowski
b907ef0abf Run the Postgres tests in CI
The tests gated on PROXY_DATABASE_URL were always skipped in CI. A new job
runs them against a Postgres 17 service container.
2026-10-23 16:23:54 +02:00
Simon Chrzanowski
d02ad9b2ba Coordinate instances sharing a Postgres database
Instances started together on an empty database created the schema at the
same time and all but one failed on duplicate types. Schema creation and
migrations now run under a Postgres advisory lock.

Eviction, reclaim, retention and the Gradle build cache sweep take a
per-job advisory lock for each pass, and an instance that finds the job
running elsewhere skips the pass. Before, every instance evicted the whole
overshoot over max_size at once and emptied the cache.

Mirror schedules record their last start in a new job_runs table, so each
schedule runs once per interval instead of once per instance.

Refs #543
2026-10-23 16:15:24 +02:00
Simon Chrzanowski
f4cab2d302 Cache cooldown metadata rewrites for a minute
With cooldown on, every npm, Composer and Chef metadata request rewrote
the whole document, because a cached rewrite could keep hiding a
version after its cooldown ended. For a 24.5 MB packument that is about
45 ms and 31 MB of allocations per request, and the result was served
uncompressed and without Last-Modified, so Composer, which revalidates
p2 files with If-Modified-Since only, never got a 304.

The rewrite cache now keeps cooldown rewrites with an expiry of one
minute, so a version leaving cooldown shows up at most that late.
Concurrent requests share one rewrite, clients get the gzip copy, and
the rewrite is dated through metadataDate like any other. Its ETag is
the hash of its output, so replicas agree on it.

The cache is keyed by the digest the metadata cache already recorded,
instead of hashing the document again, which also lets storedRewrite
serve cooldown rewrites without reading the stored document.

Without the rewrite cache, cooldown rewrites behave as before.
2026-10-23 13:24:58 +02:00
Simon Chrzanowski
be50be27e2 Serve cached Composer releases without a metadata lookup
Every Composer archive download resolved its dist from the package
metadata before checking the artifact cache. With cache_metadata off,
the default, that meant an uncoalesced upstream GET of the p2 document
for every download of an archive the proxy already had: three downloads
of a cached archive made three metadata requests. With the cache on,
each download after the TTL revalidated and rescanned the document.

The lookup is only needed for the publish time cooldown checks
downloads against, since lock files name archive URLs directly. Like
the npm handler, record that time in the versions table when the dist
is first resolved, and on later downloads of a tagged release read it
from there and serve the cached archive straight away. Without a stored
time, as for archives cached by an older proxy, the download takes the
metadata path, which then records it. The time is stored even with
cooldown off, so enabling cooldown later keeps the fast path.

The cache still enforces the denylist and verifies the stored digest,
and an archive is only ever cached under a name that was checked
against the metadata, so those guarantees hold. Branches, whose
archive and time change with every push, routed packages, whose
versions row would not say which repository the time came from, and
requests with a query string such as ?sha= keep resolving from the
metadata.
2026-10-23 13:22:56 +02:00
Simon Chrzanowski
8dcd194c39 Fix npm search routing and abbreviated packuments under cooldown
`npm search` requests /-/v1/search?text=... . The path has no "/-/"
after the leading slash is trimmed, so it fell through to the package
metadata handler: upstream got /-/v1%2Fsearch without the query
string, and the answer was cached under "-/v1/search", so every later
search was served the first one's results. Search and /-/ping (used
by `npm ping`, which lost its query string the same way) are now
relayed to upstream uncached with their query strings, like the
signing-keys endpoint.

Packuments share one metadata cache row whatever format they were
fetched in. Without cooldown the proxy asks for the abbreviated
format, which has no "time" map. After cooldown was turned on, a
still-fresh abbreviated packument kept being served from cache, and
used as the stale copy when upstream failed, so cooldown filtered
nothing and the tarball check let recent versions through. When full
metadata is required (cooldown or npm_full_metadata on), packument
lookups now pass a validator that rejects an abbreviated document
(top-level "modified", no "time"), so it counts as a cache miss, is
refetched in full and is never used as a stale fallback. Behaviour
without cooldown is unchanged.

fetchMetadataDocument takes the validate callback that
fetchOrCacheMetadata already has; other callers pass nil.
2026-10-23 13:22:49 +02:00
Simon Chrzanowski
5b1c53b24d Refresh the Julia name map only from the advertised registry
Each request for a General registry tarball whose hash differed from
the loaded name map started a goroutine that downloaded the whole
registry from upstream again, with no limit on concurrent loads and no
deadline. Alternating between two valid snapshots made every request
cost a full download and parse, and left the map at whichever snapshot
came last, older ones included.

The refresh now only loads the hash /registries currently lists, read
through the metadata cache, runs one load at a time and skips while one
is running, and stops after five minutes. Registry.toml is read up to
64 MB.
2026-10-23 13:09:31 +02:00
Simon Chrzanowski
a787913727 Fail closed in Go cooldown and key stored times by escaped path
A failed .info fetch counted as an unknown time, so a rate-limited or
flaky upstream let versions in cooldown through. It now fails the list
or download with 502; only a version upstream doesn't know, or whose
.info has no time, is let through.

Times are stored under the escaped module path, which keeps modules
that differ only in case apart. Modules with uppercase letters were not
stored before and fetched every .info again on each list request. Only
canonical versions are stored, since a query such as a branch name can
resolve to a later commit.

A .mod or .zip whose version doesn't unescape is answered with 404
instead of being passed upstream unchecked.
2026-10-23 12:46:39 +02:00
Simon Chrzanowski
4d74ad6113 Apply cooldown to Go modules
With cooldown on, @v/list leaves out versions whose .info time is
inside the window, and @latest falls back to the newest release out of
cooldown when upstream's answer is too new. Each listed version's
.info is read once and its time is stored in the versions table, as
npm does, except for modules whose path has uppercase letters, since
golang PURLs are lowercased.

A .info, .mod or .zip for a version in cooldown, and a query such as a
branch name that resolves to one, gets 403. The go command moves on to
the next GOPROXY entry after 404 or 410, so a 404 would let
GOPROXY=proxy,direct fetch the version directly.

Go has no publish time: .info carries the commit time of the revision,
so a version tagged on an old commit counts as old. The docs say so.

Part of #132
2026-10-23 12:40:19 +02:00
Simon Chrzanowski
96cc1ee87f Serve conda sharded repodata through cooldown and the cache
The shard index (repodata_shards.msgpack.zst) and the shards of CEP 16
fell through to the uncached passthrough, so clients that use them, such
as pixi, got no cooldown filtering and no offline copy.

Without cooldown, the index and shards are now served through the
metadata cache. With cooldown, shards have to be filtered, but clients
cache them by the hash in the index and never fetch a hash twice. The
proxy rewrites the index instead: each hash is XORed with a mask derived
from channel, subdir and the current hour, and shards_base_url points at
cooldown-shards/{hour}/. That route undoes the mask, fetches the
upstream shard and drops records still in cooldown. The unmasked shard
paths answer 404 while cooldown is on.

When the index puts packages or shards somewhere the proxy doesn't
route, or has an unknown version, the proxy answers 404 and clients fall
back to repodata.json, which is filtered as before.

Fixes #529
2026-10-23 12:07:01 +02:00
Andrew Nesbitt
7c6f97c89f
Merge pull request #545 from simonchrz/feat/freebsd
Proxy FreeBSD pkg repositories
2026-10-23 10:09:11 +01:00
Andrew Nesbitt
e361eccf45
Merge pull request #546 from simonchrz/build/freebsd-release
Release FreeBSD binaries
2026-10-23 10:07:31 +01:00
Andrew Nesbitt
2dd02a6ef3
Merge pull request #549 from simonchrz/fix/mirror-ecosystem-names
Mirror gems and composer packages under the handler names
2026-10-23 09:37:27 +01:00
Andrew Nesbitt
a960a5f9ac
Merge pull request #550 from simonchrz/fix/mirror-resolver-upstreams
Mirror packages from the configured upstreams
2026-10-23 09:36:34 +01:00
Andrew Nesbitt
ec497bef6a
Merge pull request #554 from simonchrz/fix/dot-segments
Refuse dot segments in package paths and upstream auth matching
2026-10-23 09:34:37 +01:00
Andrew Nesbitt
1bd5cfc724
Merge pull request #555 from simonchrz/fix/denylist-names
Match denylist entries the way each ecosystem spells names
2026-10-23 09:33:42 +01:00
Andrew Nesbitt
6b36f59ade
Merge pull request #556 from simonchrz/fix/maven-extensions
Route every Maven artifact file through the download path
2026-10-23 09:32:09 +01:00
Andrew Nesbitt
f79642b579
Merge pull request #557 from simonchrz/fix/conda-arch
Cache conda packages per subdir
2026-10-23 09:30:50 +01:00
Andrew Nesbitt
2e4b1033ea
Merge pull request #559 from simonchrz/build/go-1.27.3
Build with Go 1.27.3 everywhere
2026-10-23 09:29:53 +01:00
Simon Chrzanowski
e1650e202d Match more denylist spellings
Denylist entries now also match Composer versions with Packagist's
leading v, OCI repositories written as a path, Swift registry packages
in either form (also when streaming), Go versions with escaped uppercase
letters, and gems whose names contain a dash followed by a digit. OCI
manifests are checked by tag and digest, and denied blobs and Swift
archives answer 403 instead of 500 or 502.
2026-10-23 09:58:04 +02:00
Simon Chrzanowski
d9d3bd2ff1 Check unhashed FreeBSD packages against the denylist
Package files outside Hashed/, such as All/{name}-{version}.pkg from
repositories built without pkg repo --hash and the bootstrap
Latest/pkg.pkg, were relayed without any checks. They are still passed
through uncached, since their names can be reused for rebuilds, but a
versioned name is now checked against the denylist first, and while a
scanner covers freebsd they are refused, because only cached artifacts
can be scanned.
2026-10-23 09:57:49 +02:00
Simon Chrzanowski
d28c0befbf Build with Go 1.27.3 everywhere
CI, the release binaries and swagger take their Go version from go.mod,
which pinned 1.26.7 and so missed the stdlib fixes in 1.26.8 to 1.26.10.
A toolchain line now sets 1.27.3 for them, and the Docker builder uses
the same version. The go line stays at 1.26.7, so source builds with an
older local Go still work.

golangci-lint 2.13.1 can't read Go 1.27.2+ export data, so CI moves to
2.15.0. Its errcheck flags two test cleanups that dropped the server's
exit error; they now check it.
2026-10-23 09:57:37 +02:00
Simon Chrzanowski
1fbee102b1 Ignore path parameters when checking for dot segments
Tomcat and Jetty drop ";params" from a path segment before resolving it,
so "..;" passed the dot-segment check and still got the credentials
configured for the upstream path. The check now ignores ";params", and
package routes refuse ';' like '?' and '#', which also stops "1.0;x"
from passing the denylist as a different version. Composer archives
stay exempt, since git branch names may contain ';'.
2026-10-23 09:47:07 +02:00
Andrew Nesbitt
618975354b
Merge pull request #558 from montehurd/contributing-review-rules
Document how pull requests are reviewed and merged
2026-10-23 08:45:43 +01:00
montehurd
0ed360b8f4 Document how pull requests are reviewed and merged
Record the rules agreed in #514: one approval from a maintainer other
than the author plus green CI, a "request changes" review blocks the
merge, and bigger changes start with an issue.
2026-10-22 18:55:05 -07:00
Simon Chrzanowski
20c8878dd9 Match denylist entries the way each ecosystem spells names
Denylist entries were compared as plain PURL strings, but handlers build
PURLs from download URLs that spell packages differently, so some denied
versions were still served. Entries and checked PURLs now go through one
per-ecosystem comparison form: PyPI names follow PEP 503 (case and runs of
-, _ and . are equivalent), NuGet ids and versions ignore case, a gem
version also matches its platform builds, and a conda entry matches the
package in every channel unless it carries a channel qualifier. Cache keys
are unchanged.
2026-10-22 20:46:33 +02:00
Simon Chrzanowski
0e6ec299b3 Refuse dot segments in package paths and upstream auth matching
Routers clean literal dot segments, but encoded ones such as %2e%2e
reach the handlers. Maven, conda, cran, go and gem append the decoded
path to the upstream URL, so the upstream resolved it outside the
configured base path. Package routes now answer 400 for a '.' or '..'
segment, also when encoded twice or written with a backslash.

Upstream credentials matched such a URL by its path prefix, so
/scope/../other still got the credentials configured for /scope. URLs
with dot segments now get no credentials.
2026-10-22 20:42:19 +02:00
Simon Chrzanowski
01692142a7 Cache conda packages per subdir
Conda download caching keyed artifacts on channel, name, version and
filename only, but the same filename can hold different bytes in each
subdir (python_abi-3.11-4_cp311.conda in linux-64 and osx-arm64, for
example). A client on one platform could be served another platform's
package. The subdir is now part of the cached filename, so each one gets
its own entry. Entries cached under the old key are no longer used and
are re-fetched on first request.
2026-10-22 20:42:17 +02:00
Simon Chrzanowski
f9d4da8400 Route every Maven artifact file through the download path
Only files with a known artifact or checksum extension went through the
download path; anything else, such as the .exe binaries protoc and gRPC
plugins publish or .hpi and .so files, was relayed from upstream as is.
Those files skipped the denylist, cooldown, scanning and the cache. Any
file in a version directory named {artifact}-{version}..., the way Maven
names what it publishes (timestamped for SNAPSHOTs), now takes the
download path. Directory listings and catalogs are still proxied.
2026-10-22 20:41:51 +02:00
Andrew Nesbitt
37bef64bca
Merge pull request #553 from git-pkgs/dependabot/github_actions/zizmorcore/zizmor-action-0.7.1
Bump zizmorcore/zizmor-action from 0.6.4 to 0.7.1
2026-10-22 16:57:43 +01:00
Andrew Nesbitt
efb77991bc
Merge pull request #552 from git-pkgs/dependabot/github_actions/goreleaser/goreleaser-action-7.3.0
Bump goreleaser/goreleaser-action from 7.2.3 to 7.3.0
2026-10-22 16:55:03 +01:00
dependabot[bot]
d154757ca3
Bump zizmorcore/zizmor-action from 0.6.4 to 0.7.1
Bumps [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) from 0.6.4 to 0.7.1.
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](cc914d7f37...85b2b9fb8e)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-22 15:13:43 +00:00
dependabot[bot]
c08c25fd04
Bump goreleaser/goreleaser-action from 7.2.3 to 7.3.0
Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 7.2.3 to 7.3.0.
- [Release notes](https://github.com/goreleaser/goreleaser-action/releases)
- [Commits](f06c13b6b1...a4f02ab535)

---
updated-dependencies:
- dependency-name: goreleaser/goreleaser-action
  dependency-version: 7.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-22 15:13:38 +00:00
Simon Chrzanowski
e92afe967e Mirror packages from the configured upstreams
proxy mirror and mirror API jobs resolved download URLs with a bare
resolver, so npm, cargo, gem, golang, hex, pub, maven and nuget packages
came from the public registries whatever upstream.* says. The resolver
now takes the configured upstreams as base URLs.

npm packages in upstream.npm_routes are fetched from their route and
stored under the route's filename prefix, the way the npm handler does,
so a public package can't stand in for a routed one and clients are
served the mirrored tarball.

Refs #414
2026-10-22 16:06:56 +02:00
Simon Chrzanowski
baeccd4408 Mirror gems and composer packages under the handler names
proxy mirror filed pkg:gem and pkg:composer packages under rubygems and
packagist, the names purl gives their registries, while the handlers
store them under gem and composer. Since the resolver accepts both
spellings, mirrored gems were downloaded and filed under rubygems, so
the packages list filtered by gem didn't show them.

The mirror now uses the handler names. The package list, count and
search match every spelling of the requested ecosystem, so rows already
written as rubygems or packagist, by the mirror or by git-pkgs, show up
too without a migration.

Fixes #415
2026-10-22 15:57:14 +02:00
Simon Chrzanowski
f44ce1e60f List mirror versions from the configured upstream
proxy mirror and mirror API jobs listed the versions of unversioned
PURLs from the public registries, ignoring upstream.*. They now list
them from the configured upstream through the proxy's upstream client,
and npm packages from their npm_routes entry. Where the upstream has no
listing API, or the ecosystem has no listing at all, the error asks
for a version.

Refs #524
2026-10-22 15:38:35 +02:00
Simon Chrzanowski
25a5414427 Release FreeBSD binaries
The proxy builds without cgo for FreeBSD, and the test suite passes on
FreeBSD 15.1.
2026-10-22 13:22:13 +02:00
Simon Chrzanowski
b8a4cf71de Keep the long config test and dashboard list under the maintidx limit
The FreeBSD config cases get their own test, and the dashboard entry
moves into a helper.
2026-10-22 13:03:16 +02:00
Simon Chrzanowski
91b37ec1f8 Proxy FreeBSD pkg repositories at /freebsd/{name}/
Catalogues (meta.conf, packagesite and data) carry their signature
inside the archive and go through the metadata cache byte for byte.
Packages under Hashed/ have the content hash in their name, so they go
to the artifact cache as pkg:bsd/freebsd/{name}@{version}. Other files,
including the bootstrap files under Latest/, are passed through.
pkg.freebsd.org is the built-in freebsd repository; more mirrors go
under upstream.freebsd.

Closes #536
2026-10-22 12:36:27 +02:00
Andrew Nesbitt
bd83ef242a
Merge pull request #537 from simonchrz/feat/hosted-adapters
Host generic files and share one push path across hosted ecosystems
2026-10-22 09:07:05 +01:00
Andrew Nesbitt
31a4f4da8b
Merge pull request #541 from simonchrz/fix/stale-metadata-definite-errors
Flag stale Composer and Chef metadata
2026-10-22 09:05:06 +01:00
Andrew Nesbitt
140eca7022
Merge pull request #544 from simonchrz/ci/monthly-release
Cut a patch release on the 1st of each month
2026-10-22 09:03:22 +01:00
Simon Chrzanowski
0e25e46732 Keep the stale fallback for 404 and 401/403, leave npm to #503
Serving cached metadata when the upstream refuses is documented for
generic upstreams, and GitHub rate limits are 403s, so the fallback
stays as it is. #503 handles npm's definite answers. What is left here
is the stale warning for Composer, Chef and hosted npm metadata, which
went through the rewrite cache without it.
2026-10-22 09:14:33 +02:00
Simon Chrzanowski
d4addfe26e Cut a patch release on the 1st of each month
A scheduled workflow tags the next patch version when main is green,
something has merged since the last tag and no pull request labelled
breaking was merged. It dispatches release.yml and publish.yml on the
new tag, since a tag created with GITHUB_TOKEN doesn't start them.
Scheduled runs are dry runs until the MONTHLY_RELEASE variable is true.

Closes #532
2026-10-21 15:43:32 +02:00
montehurd
626a8e80ec
Serve Git repositories from a local cache (#492)
* Serve Git repositories from a local cache

Named upstream.git hosts are served read-only over Git's smart HTTP
protocol at /git/{name}/{repo}, so git clone and git fetch work against
the proxy through an insteadOf rewrite. Each repository is a bare clone
at git.cache_dir/{name}-{hash of the upstream URL}/{repo}.git, served
by git upload-pack.

Every request for refs checks the upstream's branches, tags and HEAD
with a protocol v2 ls-refs limited to those prefixes, and fetches only
when they changed, atomically and with --prune. Concurrent requests
share one refresh. Only branches and tags are cached, and HEAD follows
the upstream's default branch.

A commit a client asks for by ID that the cache lacks, such as a pull
request head, is fetched and kept under a hidden ref until a branch or
tag includes it or for 7 days. One the upstream cannot provide is not
asked for again for a minute, or an hour when it is larger than
git.max_repo_size leaves. This is off for upstreams upstream.auth has
credentials for, since GitHub serves commits from any repository in a
fork network.

git never reaches an upstream itself. Its fetches go through a loopback
relay that forwards with the proxy's upstream client, so address
checks, upstream.auth and the access log apply, and git never sees a
credential. Redirects are not followed, and git runs with an
environment of its own.

The cached copy is served while the upstream fails or is slower than
git.refresh_wait, until git.stale_max_age has passed since the last
successful check. Refusals and missing repositories are never answered
from the cache.

git.max_repo_size cuts a transfer off as it streams, and every fetched
pack is kept as a pack, so a cut-off leaves nothing behind. A
repository that can no longer be refreshed within the limit is cloned
again with only its branches and tags. git.max_size evicts the least
recently used repositories, never one in use. At startup only the
directory of a repointed upstream's old URL is removed, and Close waits
for refreshes in progress.

Hosts with upstream.auth credentials need a repos allowlist, and a
repository outside it answers 404.

Refs #403

* Document setting the Git rewrite through environment variables

The Git section of the configuration docs now explains what insteadOf
does, with the URL a clone fetches from, and shows the same rewrites set
through GIT_CONFIG_COUNT, GIT_CONFIG_KEY_n and GIT_CONFIG_VALUE_n for
CI. Package managers that run git for Git dependencies inherit those
variables, so their fetches are cached too; npm's GitHub tarball
downloads do not go through git and are not.

Refs #403

* Note that upstream.auth credentials reach every route to a host

Credentials are matched by URL, so a token configured for a Git upstream
is also sent by a generic upstream at the same URL, which forwards any
path and checks no repos allowlist. With a token for a private GitHub
org, /generic/github/acme/private.git/info/refs would list that
repository's refs to anyone who can reach the proxy.

* Cap concurrent upload-pack processes and request body size

Every POST git-upload-pack started a pack-objects with git's defaults,
a delta-search thread per CPU and unbounded window memory, and nothing
limited how many ran at once, so a burst of CI clones of one large
repository could run the proxy out of memory. At most two upload-pack
processes per CPU (GOMAXPROCS, so a container's limit) now build packs
at once, and further requests wait for a slot while the client is
connected. ls-refs builds no pack and does not wait. Serving runs with
pack.threads=1 and pack.windowMemory=64m, and every git process with
core.bigFileThreshold=8m, so index-pack streams large files instead of
holding them in memory.

A request body was streamed to upload-pack unbounded, so a small gzip
body could feed it an arbitrarily long negotiation for as long as the
request may run. Bodies are now limited to 100MB, both as received and
decoded, and one past the limit fails with an error instead of being
cut short quietly.

* Apply refresh_wait to the ref check only

refresh_wait cut off the whole refresh, fetch included, so after a push
with a large pack a client got the old refs once it passed, though the
upstream was fine and the docs say a clone right after a push sees the
new commit. It now bounds only the check of the upstream's branches and
tags. Once the check finds they changed, requests wait for the fetch;
one that fails, or stalls and is abandoned by http.lowSpeedTime, still
falls back to the cached copy within stale_max_age.

* Lock cache_dir against a second process

The cache clears its temporary directory at startup, so a second
process given the same cache_dir, such as a second pod on a shared
volume, would delete the first one's clones in progress, and each
process's eviction knows only its own readers. New now locks
{cache_dir}/.gitcache-lock before touching anything and holds it until
Close: flock on Unix, a handle opened without sharing on Windows. The
OS releases it when the process exits, so a crashed process can
restart. A second process fails to start with an error naming the
directory.

* Evict again once repositories in use are released

Eviction skips repositories a request or refresh is using, and only the
release of one too large to refresh ran it again, so a cache filled past
max_size while its repositories were in use stayed over it once they
went idle, until the next fetch. Eviction now records when it is left
over max_size, and the next release runs it again.

* Cap git.max_repo_size at git.max_size

A repository larger than max_size could still be cloned, up to
max_repo_size, which defaults to 5GB whatever max_size is. Once released
it was evicted to bring the cache back within max_size, and the next
request cloned it again. max_size now caps max_repo_size, so such a
repository is refused for an hour like any other too large one, with an
error that names both limits.

* Count fetches in progress toward git.max_size

Fetches enforced only max_repo_size, and eviction ran after each one,
so up to 16 concurrent fetches could take the cache past max_size by as
much as each may download. Each fetch now reserves space within
max_size as the relay receives its bytes, a chunk at a time, evicting
least recently used idle repositories to make room. When the fetch
ends, the repository's measured size replaces the reservation under the
cache lock, so its bytes are neither counted twice nor missed.

When evicting every idle repository would not make room, none is
evicted for the fetch, and it is cut off with ErrCacheFull rather than
ErrTooLarge, so the repository is not refused for an hour: a clone
answers 507, and a refresh serves the cached copy as when the upstream
fails. A fetch that runs out of room while another has just been cut
off waits, up to a minute, for that one's space, so two fetches that do
not fit together do not both fail. A fetch that was cut off never
waits, so no two wait on each other.

---------

Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-10-21 11:47:16 +01:00
Simon Chrzanowski
0832c6142b Refuse pushes of a version that holds a pushed file under another name
Composer names are case-insensitive, so acme/private and acme/Private
share the version record and its integrity but get different filenames.
A second push under the other case was accepted and broke the integrity
check of the first archive. For ecosystems with one file per version,
any pushed file of the version now refuses the push, both before the
upload and atomically when recording it.
2026-10-21 12:33:43 +02:00
Simon Chrzanowski
48916bcb09 Never let a cache fill replace a pushed artifact
- Record a fetched artifact with its package and version in one
  transaction that refuses to replace a pushed artifact, so a fetch that
  finishes after a push keeps both the pushed file and its version's
  integrity. Pushes and cache fills share this write, including the
  compare-and-swap on the storage path that queues every replaced object.
- Don't open the stored file for ecosystems without a manifest.
- Derive multi-file versions from the file being named in the path.
- Validate DELETE paths like PUT paths, answering 400 for malformed ones.
- Parse ?sha256= with integrity.ParseHex.
2026-10-21 12:24:04 +02:00
Simon Chrzanowski
5f431a3d89 Test concurrent pushes of one file at the database level
The handler-level test runs on SQLite only. This one also runs against
Postgres when PROXY_DATABASE_URL is set.
2026-10-21 12:24:04 +02:00
Simon Chrzanowski
5c0120f305 Record pushes atomically and address review findings
- Record the package, version and artifact of a push in one transaction
  that only inserts the artifact if no pushed copy exists. Of two
  concurrent pushes of the same file exactly one wins, and the loser's
  version integrity is rolled back with it.
- Check for an existing file before reading the request body, and send
  Expect: 100-continue from proxy hosted push, so a refused re-push isn't
  uploaded first.
- Leave an existing version record alone for multi-file ecosystems, so a
  generic push doesn't reset a version cached from upstream.generic.
- Reserve the upstream.generic name "hosted" only while hosting is
  enabled, and only intercept /generic/hosted/ then.
- Accept a file argument in proxy hosted delete for generic only.
- Mark multi-file ecosystems explicitly, and give parse a min/max range.
2026-10-21 12:24:04 +02:00
Simon Chrzanowski
d13b8be0ae Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.

Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.

For all hosted ecosystems:

- A second push of the same file is refused with 409. Clients pin pushed
  files by version and often by hash, so a published file is no longer
  silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
  proxy hosted push always sends it.

Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.

Refs #133, #134, #535
2026-10-21 12:23:54 +02:00
Simon Chrzanowski
994a9368d1 Keep the stale warning on filtered npm version metadata
With cooldown or a version denylist, /pkg/<version> reads the filtered
packument through handleFilteredNPMVersionMetadata, which dropped the
stale flag.
2026-10-21 12:23:33 +02:00
simon chrzanowski
ed77e40816
Log rewritten npm tarball URLs once per document (#538)
The npm handler logged a debug line for every version whose tarball URL
it rewrote. Packages with hundreds of versions produced hundreds of lines
per packument, and with cooldown enabled, where the rewrite cache is
bypassed, again on every request. It now logs one line per document with
the number of rewritten URLs.
2026-10-21 11:12:05 +01:00
simon chrzanowski
cc6069d8a0
Serve additional Maven repositories at /maven/{name}/ (#539)
* Serve additional Maven repositories at /maven/{name}/

upstream.maven_repositories maps names to further Maven repositories,
each served at /maven/{name}/ from its own upstream, like
debian_repositories does for APT. upstream.maven stays the default at
/maven/ with unchanged cache keys and its Gradle Plugin Portal fallback;
named repositories don't fall back to the portal.

Named repositories cache their files under {name}/{filename} and hash
their metadata keys from name, upstream URL and path, so the same
coordinates in two repositories, which may hold different bytes, never
share a cache entry.

A repository name is matched before the default repository and would
hide group IDs starting with it, so names of two letters or less and
common top-level domains (com, org, net, io, ...) are refused at config
load. The documentation asks for names that can't start a group ID.

Fixes #295

* Refuse every Maven Central group root as a repository name

The hand-written list missed real roots such as tools (Jackson 3),
software (AWS SDK), jakarta, javax and junit, so a repository named after
one would have hidden those groups in the default repository. The name
check now uses the first path segments of all group IDs on Maven Central,
plus Google's androidx and android, embedded from the repository's
directory listing.

Names are compared case-sensitively, since routing is, so "ORG" is no
longer refused for hiding "org".

A named repository's files are now cached under its name and a hash of
its upstream URL, so pointing it at a different URL stops serving what
the old one sent.

* Refuse Gradle Plugin Portal roots and unroutable repository names

- The default repository falls back to the Gradle Plugin Portal, so its
  group roots (gradle, nebula, ...) would be hidden by a repository of
  the same name too. The embedded list now holds the roots of Maven
  Central and the Plugin Portal.
- Repository names are limited to letters, digits, '.', '_' and '-', so
  a name can't contain characters such as '?', '#' or spaces that never
  reach the handler as configured.
- Named repositories are built once in WithRepositories, with their
  cache prefix, instead of on every request.
2026-10-21 11:10:49 +01:00
simon chrzanowski
10fc27b2b8
Cache Go modules that differ only in case apart (#540)
Module zips are stored under the module's PURL, which lowercases golang
names, and under the filename {last element}@{version}.zip. So
github.com/Sirupsen/logrus and github.com/sirupsen/logrus shared one
cache entry although upstream serves different zips, and the second one
requested failed go's checksum check.

A module path with capitals now gets a hash of its exact spelling in the
filename. All-lowercase paths keep the plain name, so existing cache
entries stay valid.

A capitalised module cached before this change sits under its lowercase
twin's plain filename. When a cached zip's upstream URL names a different
module path, it is discarded and fetched again. Only the module path is
compared, so a cache filled from another upstream.go host is kept.

Fixes #466
2026-10-21 11:08:50 +01:00
Simon Chrzanowski
844e2ae4f3 Test that Composer metadata doesn't hide upstream 404 and 401/403 2026-10-20 21:34:07 +02:00
Simon Chrzanowski
f77d299b31 Don't hide upstream 404 and 401/403 behind stale metadata
With cache_metadata on, a failed upstream metadata fetch fell back to the
cached copy for any error. A 404 (package removed) and a 401/403 (the
proxy's credentials rejected) are definite answers, though, and serving
the old version list in their place kept them from reaching the client.
They now pass through: 404 as 404, 401/403 as 502 like other upstream
failures. Timeouts, connection errors, 5xx and 429 still fall back.

The npm, Composer and Chef handlers serve metadata through the rewrite
cache, which dropped the stale flag, so a fallback looked like a fresh
200. They now set the same Warning: 110 header as other metadata.

Fixes #499
2026-10-20 21:33:08 +02:00
Christian Heim
c0320c7c87 Clearing a record and queueing its file for deletion now happen in one transaction, so a failed queue write keeps the record instead of leaving a file nothing points at. Eviction drops the queue entry again once it has deleted the file itself. 2026-10-18 07:25:55 +02:00
418 changed files with 57755 additions and 3395 deletions

17
.dockerignore Normal file
View file

@ -0,0 +1,17 @@
# Version and commit come in as build args, so the build doesn't need .git.
.git
.github
# Local build output and runtime data
/proxy
dist/
build/
cache/
*.db
*.tar.gz
# Local config and secrets
.env
.env.local
proxy.cfg
config.yaml

View file

@ -31,6 +31,40 @@ jobs:
- name: Test
run: go test -v -race ./...
postgres:
name: test (postgres)
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17@sha256:b8faeff7712663c2773a190181e1246e37e364b887611c04658c28b44e2408fe
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: proxy
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d proxy"
--health-interval 2s
--health-timeout 5s
--health-retries 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
# The packages with tests that need PROXY_DATABASE_URL. -p 1 because
# their tests drop and recreate tables in the one shared database.
- name: Test
env:
PROXY_DATABASE_URL: postgres://postgres:test@localhost:5432/proxy?sslmode=disable
run: go test -v -race -p 1 ./internal/database/... ./internal/handler/... ./internal/server/...
lint:
runs-on: ubuntu-latest
steps:
@ -44,7 +78,7 @@ jobs:
go-version-file: go.mod
- name: Install golangci-lint
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.1
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.15.0
- name: golangci-lint
run: golangci-lint run ./...
@ -76,3 +110,13 @@ jobs:
--set 'ingress.hosts[0].paths[0].path=/' \
--set 'ingress.hosts[0].paths[0].pathType=Prefix' \
>/dev/null
helm template proxy deploy/charts/proxy \
--set 'extraVolumes[0].name=corp-ca' \
--set 'extraVolumes[0].configMap.name=corp-ca' \
--set 'extraVolumeMounts[0].name=corp-ca' \
--set 'extraVolumeMounts[0].mountPath=/etc/proxy/ca' \
--set 'extraVolumeMounts[0].readOnly=true' \
--set 'extraInitContainers[0].name=wait-for-db' \
--set 'extraInitContainers[0].image=busybox:1.37' \
--set 'extraInitContainers[0].command={sh,-c,exit 0}' \
>/dev/null

137
.github/workflows/monthly-release.yml vendored Normal file
View file

@ -0,0 +1,137 @@
name: Monthly release
# Cuts a release on the 1st of each month when main is green and something
# has merged since the last release: a patch release, or a new minor version
# if a pull request labelled "breaking" was merged (a new major version from
# 1.0 on). A breaking release is created as a draft, and its images and chart
# are published only when someone publishes the draft, after writing the
# upgrade notes. Scheduled runs only report what they would do until the
# repository variable MONTHLY_RELEASE is "true".
on:
schedule:
- cron: "0 6 1 * *"
workflow_dispatch:
inputs:
dry_run:
description: Only report what would be released
type: boolean
default: true
permissions: {}
concurrency:
group: monthly-release
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
actions: write
checks: read
pull-requests: read
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
fetch-depth: 0
persist-credentials: false
- name: Decide whether to release
id: plan
env:
EVENT: ${{ github.event_name }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
ENABLED: ${{ vars.MONTHLY_RELEASE }}
run: |
set -euo pipefail
if [ "$EVENT" = workflow_dispatch ]; then
dry_run=$INPUT_DRY_RUN
elif [ "$ENABLED" = true ]; then
dry_run=false
else
dry_run=true
fi
echo "dry_run=$dry_run" >> "$GITHUB_OUTPUT"
sha=$(git rev-parse HEAD)
last=$(git describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' --exclude '*-*')
# A breaking release waits as a draft for its upgrade notes. Building
# on its tag would publish those changes without them. Older tags
# were released by hand and may have no release at all.
draft=$(gh release view "$last" -R "$REPO" --json isDraft --jq .isDraft 2>/dev/null || echo false)
if [ "$draft" = true ]; then
echo "::warning::$last is still a draft, publish it first"
exit 0
fi
merged=$(git rev-list --count "$last..$sha")
if [ "$merged" -eq 0 ]; then
echo "Nothing merged since $last"
exit 0
fi
# Every check on main's HEAD must have finished green. This run
# shows up as a check on the same commit, so it is left out.
bad=$(gh api --paginate "repos/$REPO/commits/$sha/check-runs?per_page=100" \
--jq ".check_runs[]
| select(.html_url | contains(\"/actions/runs/$GITHUB_RUN_ID/\") | not)
| select(.status != \"completed\"
or (.conclusion | IN(\"success\", \"skipped\", \"neutral\") | not))
| .name")
if [ -n "$bad" ]; then
echo "::warning::main ($sha) is not green, skipping: $(echo "$bad" | sort -u | paste -sd, -)"
exit 0
fi
since=$(git log -1 --format=%cI "$last")
breaking=$(gh pr list -R "$REPO" --state merged --base main --limit 1000 \
--search "merged:>$since label:breaking" --json number --jq '.[].number')
IFS=. read -r major minor patch <<<"${last#v}"
if [ -z "$breaking" ]; then
tag="v$major.$minor.$((patch + 1))"
else
echo "Breaking changes merged since $last: #$(echo "$breaking" | sort -n | paste -sd, - | sed 's/,/, #/g')"
if [ "$major" -eq 0 ]; then
tag="v0.$((minor + 1)).0"
else
tag="v$((major + 1)).0.0"
fi
fi
echo "$merged commits since $last, next release $tag at $sha"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "sha=$sha" >> "$GITHUB_OUTPUT"
echo "draft=$([ -n "$breaking" ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
# A tag pushed with GITHUB_TOKEN doesn't start other workflows, so the
# release workflows are dispatched on the new tag instead.
- name: Tag and start the release workflows
if: steps.plan.outputs.tag != ''
env:
TAG: ${{ steps.plan.outputs.tag }}
SHA: ${{ steps.plan.outputs.sha }}
DRY_RUN: ${{ steps.plan.outputs.dry_run }}
DRAFT: ${{ steps.plan.outputs.draft }}
run: |
set -euo pipefail
if [ "$DRAFT" = true ]; then
what="start release.yml for a draft release"
else
what="start release.yml and publish.yml"
fi
if [ "$DRY_RUN" = true ]; then
echo "Dry run: would tag $TAG at $SHA and $what"
exit 0
fi
gh api "repos/$REPO/git/refs" -f ref="refs/tags/$TAG" -f sha="$SHA" >/dev/null
gh workflow run release.yml -R "$REPO" --ref "$TAG" -f draft="$DRAFT"
if [ "$DRAFT" = true ]; then
echo "::notice::$TAG is a draft release: add the upgrade notes and publish it to push the images and chart"
else
gh workflow run publish.yml -R "$REPO" --ref "$TAG"
fi
echo "Tagged $TAG at $SHA"

View file

@ -5,6 +5,10 @@ on:
push:
tags:
- "v*"
# A draft release made by monthly-release.yml is published by hand. Releases
# published with GITHUB_TOKEN don't start this.
release:
types: [published]
permissions: {}
@ -34,7 +38,7 @@ jobs:
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Log in to the Container registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6
with:
registry: ghcr.io
username: ${{ github.actor }}
@ -57,6 +61,9 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ steps.meta.outputs.version }}
COMMIT=${{ github.sha }}
provenance: mode=max
sbom: true

View file

@ -4,6 +4,13 @@ on:
push:
tags:
- "v*"
# Started by monthly-release.yml on the tag it creates.
workflow_dispatch:
inputs:
draft:
description: Create the release as a draft
type: boolean
default: false
permissions:
contents: write
@ -11,6 +18,7 @@ permissions:
jobs:
release:
if: github.ref_type == 'tag'
runs-on: ubuntu-latest
steps:
@ -27,9 +35,9 @@ jobs:
go-version-file: go.mod
cache: false
- uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
- uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0
with:
version: "~> v2"
args: release --clean
args: release --clean${{ inputs.draft && ' --draft' || '' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -34,4 +34,4 @@ jobs:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0

View file

@ -15,6 +15,7 @@ builds:
- linux
- darwin
- windows
- freebsd
goarch:
- amd64
- arm64

View file

@ -212,6 +212,24 @@ Add PyPI registry support
- Add tests for wheel and sdist downloads
```
## Reviewing and Merging
Maintainers are the members of the proxy team in the git-pkgs org. Any maintainer can review and merge. A pull request needs green CI and an approval from a maintainer other than its author. A "request changes" review from any maintainer blocks it until that reviewer approves or withdraws the review.
Using AI to write or review is fine, but whoever approves a pull request should understand it and stand behind it. If it touches an ecosystem you don't use, you can ask someone who does to look before you approve.
Open an issue before a bigger change: anything that changes behaviour users rely on, new configuration that would be hard to remove, or a change that fans out into other git-pkgs modules. If no maintainer objects within a few days, open the pull request; it's reviewed like any other. Objections are worked out in the issue, with @andrew breaking ties for now. Where possible, deprecate before removing: an old configuration key keeps working, with a warning, in at least one release before the one that removes it.
## Releases
A release is cut on the 1st of each month by `.github/workflows/monthly-release.yml` when `main` is green and something has merged since the last tag. It is a patch release, or a new minor version if a pull request labelled `breaking` was merged since then (a new major version from 1.0 on), so label pull requests that change behaviour users rely on.
A patch release is published on its own. A breaking release is created as a draft, without images or a chart: write the upgrade notes in it and publish it, which starts `publish.yml` for the images and the chart. Until then the monthly run releases nothing.
Scheduled runs only report what they would release until the repository variable `MONTHLY_RELEASE` is set to `true`. The workflow can also be started by hand from the Actions tab, as a dry run by default.
To release by hand, push a `v*` tag.
## Questions?
Open an issue on GitHub if you have questions or need help getting started.

View file

@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM golang:1.26.7-alpine AS builder
FROM --platform=$BUILDPLATFORM golang:1.27.3-alpine AS builder
WORKDIR /src
@ -14,11 +14,15 @@ COPY . .
# Build the binary for the target platform
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags="-s -w" -o /proxy ./cmd/proxy
ARG VERSION=dev
ARG COMMIT=unknown
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
-ldflags="-s -w -X main.Version=${VERSION} -X main.Commit=${COMMIT}" \
-o /proxy ./cmd/proxy
FROM alpine:3.24.2
FROM alpine:3.24.3
RUN apk add --no-cache ca-certificates
RUN apk add --no-cache ca-certificates git
COPY --from=builder /proxy /usr/local/bin/proxy
@ -31,5 +35,9 @@ WORKDIR /data
EXPOSE 8080
# /health checks the database and storage and can take up to ten seconds.
HEALTHCHECK --interval=30s --timeout=11s --start-period=10s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1:8080/health || exit 1
ENTRYPOINT ["proxy"]
CMD ["serve"]

277
README.md
View file

@ -20,6 +20,8 @@ A 3-day cooldown means that when `lodash` publishes version `4.18.0`, your build
Resolution order: package override, then ecosystem override, then global default. This lets you set a conservative default and carve out exceptions for packages where you need faster updates. See [docs/configuration.md](docs/configuration.md) for the full config reference.
One instance can offer several cooldown policies through [named profiles](docs/configuration.md#cooldown-profiles). Configure `cooldown.profiles` and point clients at paths such as `/cooldown/3d/npm/` or `/cooldown/7d/cargo/`. Profiles share the package cache; existing URLs keep using the global policy.
## Artifact Scanning
Cooldown only looks at a version's publish timestamp — it never inspects the actual bytes. Artifact scanning closes that gap: when enabled, every artifact is staged into storage and scanned by one or more external services (trivy, ClamAV, Wiz, or anything else that speaks a small HTTP/JSON contract) before it's committed to the cache and served to clients.
@ -47,11 +49,11 @@ The proxy never uploads artifact bytes to a scanner. Each scanner is notified wi
| npm | JavaScript | Yes | ✓ |
| Cargo | Rust | Yes | ✓ |
| RubyGems | Ruby | Yes | ✓ |
| Go proxy | Go | | ✓ |
| Go proxy | Go | Yes† | ✓ |
| Hex | Elixir | Yes* | ✓ |
| pub.dev | Dart | Yes | ✓ |
| PyPI | Python | Yes | ✓ |
| Maven | Java | | ✓ |
| Maven | Java | Yes‡ | ✓ |
| Gradle Build Cache | Java/Kotlin | | ✓ |
| NuGet | .NET | Yes | ✓ |
| Composer | PHP | Yes | ✓ |
@ -66,14 +68,20 @@ The proxy never uploads artifact bytes to a scanner. Each scanner is notified wi
| RPM | RHEL/Fedora | | ✓ |
| Alpine | Alpine Linux | | ✓ |
| Arch | Arch Linux | | ✓ |
| FreeBSD | FreeBSD (ports and pkgbase) | | ✓ |
| Chef Supermarket | Chef | | ✓ |
| Generic | Any | | ✓ |
| Git | Any | | ✓ |
| Helm | Kubernetes | Yes | ✓ |
| 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.
\* Hex cooldown requires disabling registry signature verification (`HEX_UNSAFE_REGISTRY=1`) since the proxy re-encodes the protobuf payload. Origin verification can stay enabled.
† Go cooldown uses the time in each version's `.info`, which is the commit time of the tagged revision, not when the tag was pushed.
‡ Maven cooldown uses the `Last-Modified` header of each version's `.pom`. On Maven Central that is the deploy time; a Nexus or Artifactory upstream may report when it cached the file instead. SNAPSHOT versions are not held back.
## Install
@ -81,7 +89,11 @@ Cooldown requires publish timestamps in metadata. Registries without a "Yes" in
brew install git-pkgs/git-pkgs/proxy
```
Or download a binary from the [releases page](https://github.com/git-pkgs/proxy/releases).
Or download a binary from the [releases page](https://github.com/git-pkgs/proxy/releases), or run the container image:
```bash
docker run -p 8080:8080 -v proxy-data:/data ghcr.io/git-pkgs/proxy:latest
```
### Helm
@ -98,6 +110,33 @@ using SQLite and filesystem artifact storage under `/data`. See
[`deploy/charts/proxy/values.yaml`](deploy/charts/proxy/values.yaml) for ingress,
external database and object-storage configuration options.
The SQLite database needs a local disk or a block-backed volume, even with one
replica; `ReadWriteOnce` alone doesn't guarantee that, since the storage class
can still be NFS. If the cluster only offers network filesystem storage, use
Postgres. Artifact storage can stay on NFS, separately from the database. See
[SQLite](docs/configuration.md#sqlite).
To trust a private CA, for example when upstreams sit behind a TLS-intercepting
corporate proxy or are internal registries signed by an in-house CA, mount the
certificate with `extraVolumes` and `extraVolumeMounts` and add its directory
to `SSL_CERT_DIR`. The proxy process makes all upstream TLS connections itself,
Git fetches included, so no other configuration is needed. Public CAs stay
trusted alongside the private one:
```yaml
extraVolumes:
- name: corp-ca
configMap:
name: corp-ca # e.g. key corp-ca.crt, PEM-encoded
extraVolumeMounts:
- name: corp-ca
mountPath: /etc/proxy/ca
readOnly: true
extraEnv:
- name: SSL_CERT_DIR
value: /etc/ssl/certs:/etc/proxy/ca
```
## Quick Start
```bash
@ -111,7 +150,8 @@ go build -o proxy ./cmd/proxy
./proxy -listen :3000 -base-url https://proxy.example.com
```
The proxy is now running. Configure your package managers to use it.
The proxy is now running. Configure your package managers to use it. For image
builds, see [Using proxy in Docker builds](docs/docker-builds.md).
## OpenAPI (Swagger)
@ -159,9 +199,12 @@ 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](#version-cooldown) are not excluded
from the report.
from the report. Packuments point each version's attestations (provenance) at
the proxy, which fetches them from the package's registry and caches them;
attestations of versions held back by cooldown or blocked by the denylist are
not served.
To serve private packages through the same registry URL, see [Private package routes](docs/configuration.md#private-package-routes).
To serve private packages through the same registry URL, see [Private package routes](docs/configuration.md#private-package-routes). To see new releases before `metadata_ttl` runs out, see [Change feeds](docs/configuration.md#change-feeds).
### Cargo
@ -212,6 +255,14 @@ the `go` command contacts the database directly as before. Set
checksum databases are listed under `upstream.go_sumdbs`, see
[Go checksum database](docs/configuration.md#go-checksum-database).
govulncheck can use the proxy for the Go vulnerability database:
```bash
govulncheck -db http://localhost:8080/go/vulndb ./...
```
See [Go vulnerability database](docs/configuration.md#go-vulnerability-database).
### Homebrew
Point Homebrew's JSON API and artifact domain at the proxy:
@ -223,7 +274,7 @@ 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.
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. `proxy mirror pkg:brew/<formula>` pre-fetches a formula's bottles (see [Homebrew bottles](docs/configuration.md#homebrew-bottles)).
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:
@ -237,10 +288,10 @@ The equivalent environment variables are `PROXY_UPSTREAM_HOMEBREW_API` and `PROX
### Hex (Elixir)
Configure in `~/.hex/hex.config`:
Configure the mirror:
```erlang
{default_url, <<"http://localhost:8080/hex">>}.
```bash
mix hex.config mirror_url http://localhost:8080/hex
```
Or set the environment variable:
@ -249,6 +300,14 @@ Or set the environment variable:
export HEX_MIRROR=http://localhost:8080/hex
```
When the proxy applies a cooldown, also set:
```bash
export HEX_UNSAFE_REGISTRY=1
```
Filtering releases removes the registry signature. The proxy preserves the package name and repository fields, so origin verification can stay enabled. Without a cooldown, leave signature verification enabled too.
### pub.dev (Dart/Flutter)
Set the PUB_HOSTED_URL environment variable:
@ -290,6 +349,8 @@ Add to your `~/.m2/settings.xml`:
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.
Further Maven repositories can be configured under `upstream.maven_repositories` and are served at `/maven/{name}/`, each from its own upstream (see [Maven repositories](docs/configuration.md#maven-repositories)). Point a second `<repository>` or Gradle `maven(url = ...)` entry at `http://localhost:8080/maven/{name}/`.
For Gradle plugin resolution via the same proxy endpoint:
```kotlin
@ -306,17 +367,15 @@ Configure in `settings.gradle.kts`:
```kotlin
buildCache {
local {
enabled = false
}
remote<HttpBuildCache> {
url = uri("http://localhost:8080/gradle/")
isPush = System.getenv("CI") != null
}
}
```
The proxy rejects uploads unless `gradle.build_cache.read_only` is set to `false`, see [docs/configuration.md](docs/configuration.md#gradle-build-cache).
Enable build caching with `org.gradle.caching=true` in `gradle.properties`, or pass `--build-cache` when running Gradle. The local cache stays enabled and remote uploads are disabled by default.
To upload from CI, set `gradle.build_cache.read_only` to `false` on the proxy and add `isPush = System.getenv("CI") != null` inside the client's `remote<HttpBuildCache>` block. See [docs/configuration.md](docs/configuration.md#gradle-build-cache).
### NuGet
@ -358,7 +417,16 @@ Or set globally:
composer config -g repositories.proxy composer http://localhost:8080/composer
```
To serve private packages through the same URL, see [Private package routes](docs/configuration.md#private-package-routes).
`composer audit`, `audit.block-insecure` and Packagist's malware filter list
work through the proxy when `upstream.composer_repository` offers them in its
`packages.json`, as repo.packagist.org does. The proxy advertises them in its
own `packages.json` and passes the advisories and filter list requests through
to the upstream uncached. Names of packages in `upstream.composer_routes` or
`hosted.composer_vendors` are removed from advisories requests before they are
sent. Versions withheld by [cooldown](#version-cooldown) are not excluded from
the report.
To serve private packages through the same URL, see [Private package routes](docs/configuration.md#private-package-routes). To see new releases before `metadata_ttl` runs out, see [Change feeds](docs/configuration.md#change-feeds).
### Conan (C/C++)
@ -401,12 +469,17 @@ default_channels:
- http://localhost:8080/conda/main
```
`default_channels` also routes requests for `defaults` through the proxy, including `-c defaults` and `environment.yml` files that name it.
Or set via command:
```bash
conda config --add channels http://localhost:8080/conda/main
conda config --add default_channels http://localhost:8080/conda/main
```
pixi and other rattler-based clients use sharded repodata ([CEP 16](https://github.com/conda/ceps/blob/main/cep-0016.md)) where a channel offers it, as conda-forge does. Point them at the same channel URLs, for example `pixi init -c http://localhost:8080/conda/conda-forge`. The shard index and shards are cached as metadata when `cache_metadata` is on.
### CRAN (R)
Set the repository in R:
@ -425,6 +498,8 @@ local({
})
```
Source packages, Windows and macOS binaries (including the `bin/macosx/big-sur-arm64/` layout used by R 4.3 and later) are cached as artifacts. `PACKAGES` indexes and the `src/contrib/Meta/*.rds` files that `remotes::install_version` and pak read are cached as metadata.
### Julia
Set the Pkg server before starting Julia:
@ -652,16 +727,20 @@ indexes unchanged. To keep APT from selecting a denied version, configure
### RPM / Yum / DNF
Configure yum/dnf to use the proxy in `/etc/yum.repos.d/proxy.repo`:
In the `[fedora]` section of your existing repository file under `/etc/yum.repos.d/`, replace the `metalink` or `mirrorlist` entry with:
```ini
[proxy-fedora]
name=Fedora via Proxy
baseurl=http://localhost:8080/rpm/releases/$releasever/Everything/$basearch/os/
enabled=1
gpgcheck=0
```
In the `[updates]` section of `fedora-updates.repo`, replace its `metalink` or `mirrorlist` entry with:
```ini
baseurl=http://localhost:8080/rpm/updates/$releasever/Everything/$basearch/
```
Keep the existing `gpgkey` and signature settings, including `gpgcheck=1`. The proxy serves RPM packages unchanged, so their signatures remain valid.
Then:
```bash
@ -691,6 +770,11 @@ 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.
Set `upstream.apk_snapshots` (e.g. `"90d"`) to keep replaced indexes and serve
them by date at `/apk/alpine@2026-10-01/v3.22/main`, so pinned package versions
stay installable after the mirror moves on. See
[Alpine APK snapshots](docs/configuration.md#alpine-apk-snapshots).
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):
@ -774,6 +858,62 @@ Avoid `mirror.archlinuxarm.org` as the upstream. It redirects each request to a
changing regional mirror over plain HTTP, so the database and the packages can come
from mirrors with a different sync state.
### FreeBSD / pkg
Override the repositories in `/usr/local/etc/pkg/repos/FreeBSD.conf`. Settings
there are merged with `/etc/pkg/FreeBSD.conf`, so only the URL and mirror type
change and the signature settings stay. The default repository name `freebsd`
proxies the official mirror (`https://pkg.freebsd.org`). On FreeBSD 15:
```
FreeBSD-ports: {
url: "http://localhost:8080/freebsd/freebsd/${ABI}/quarterly",
mirror_type: "none"
}
FreeBSD-ports-kmods: {
url: "http://localhost:8080/freebsd/freebsd/${ABI}/kmods_quarterly_${VERSION_MINOR}",
mirror_type: "none"
}
FreeBSD-base: {
url: "http://localhost:8080/freebsd/freebsd/${ABI}/base_release_${VERSION_MINOR}",
mirror_type: "none"
}
```
FreeBSD 14 has a single repository called `FreeBSD`:
```
FreeBSD: {
url: "http://localhost:8080/freebsd/freebsd/${ABI}/quarterly",
mirror_type: "none"
}
```
Then:
```bash
pkg update
```
Keep the path after `${ABI}` that `pkg -vv` shows for each repository
(`quarterly`, `latest`, `base_latest`, ...) and replace only the host part.
The repository catalogues (`meta.conf`, `packagesite.pkg`, `data.pkg`) carry
their signature inside the archive and are served byte-for-byte unchanged
through the metadata cache (`metadata_ttl`, stale fallback). Enable
`cache_metadata` so `pkg update` keeps working while the upstream is down.
Packages under `Hashed/` have their content hash in the file name, so they are
stored in the shared artifact cache as `pkg:bsd/freebsd/{name}@{version}` and
remain available when the upstream is unreachable.
Other files are passed through uncached. That includes the bootstrap package
under `Latest/`, whose signature file is rewritten with every repository build,
and package files without a hash in their name, which can be rebuilt under the
same name. The denylist still applies to unhashed package files. Since only
cached artifacts can be scanned, both kinds of package file are refused while
a scanner covers `freebsd`; use repositories built with `pkg repo --hash` and
bootstrap pkg without the proxy.
### GitHub Releases / mise (aqua backend)
Configure named generic upstreams:
@ -797,8 +937,26 @@ 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.
installs need no API call at all.
If the fleet exceeds GitHub's anonymous rate limit, add a bearer token for
`https://api.github.com` under `upstream.auth`. Everyone who can reach the
proxy can then make requests with that token, so the proxy requires
`generic_paths` listing the API paths it may serve, and the token should be a
fine-grained one with read-only access to public repositories:
```yaml
upstream:
generic_paths:
github-api:
- "repos/*/*/releases"
- "repos/*/*/releases/**"
- "repos/*/*/tags"
auth:
"https://api.github.com":
type: bearer
token: "${GITHUB_TOKEN}"
```
Maven, mvnd, Node and Go downloads are cached the same way once their hosts
are configured as generic upstreams; their version-pinned paths are built in
@ -824,6 +982,28 @@ go.download_mirror = "http://localhost:8080/generic/golang"
Other download hosts need their version-pinned paths declared in
`upstream.generic_artifacts`.
### Git repositories
Configure named Git servers:
```yaml
upstream:
git:
github: "https://github.com"
```
Then point clones at the proxy:
```bash
git config --global url."http://localhost:8080/git/github/".insteadOf https://github.com/
```
Each repository is cached as a bare clone and refreshed whenever its branches
or tags change upstream. Git LFS objects are cached in the artifact cache;
git-lfs uses the proxy for them with no further setup. See [configuration](docs/configuration.md#git-repositories)
for setting the rewrite through environment variables, size limits, private
repositories and outage behavior.
## Configuration
The proxy can be configured via:
@ -909,7 +1089,7 @@ Run with config file:
### 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:
SQLite is the default and works well for single-node deployments, with the database file on a local disk or block-backed volume. For multi-node setups, when only network filesystem storage such as NFS is available, or if you prefer a managed database, switch to Postgres:
```yaml
database:
@ -1017,6 +1197,9 @@ 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 only the newest version of a package
proxy mirror --latest pkg:npm/lodash
# Mirror from a CycloneDX or SPDX SBOM
proxy mirror --sbom sbom.cdx.json
@ -1028,6 +1211,9 @@ proxy mirror --concurrency 8 pkg:npm/lodash@4.17.21
# Mirror a container image for one platform
proxy mirror --platform linux/amd64 pkg:docker/library/alpine@3.20
# Mirror a commit of an upstream.git repository into the git cache
proxy mirror pkg:git/github.com/git-pkgs/purl@0123456789abcdef0123456789abcdef01234567
```
The mirror command accepts the same storage and database flags as `serve`. Already-cached artifacts are skipped. If any package version fails to mirror, the command lists the errors and exits with status 1, so a CI step that warms the cache fails on a partial mirror.
@ -1126,6 +1312,9 @@ Recently cached:
| `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 /npm/-/package/{name}/dist-tags` | `npm dist-tag ls`, answered from the packument the proxy serves (cooldown and pushed versions applied); changing tags is not supported |
| `GET /npm/-/npm/v1/attestations/{name}@{version}` | npm provenance attestations of a version, fetched from the package's registry and cached |
| `GET /npm/-/v1/search`, `GET /npm/-/ping` | `npm search` and `npm ping`, passed through to upstream uncached |
| `GET /cargo/*` | Cargo sparse index protocol |
| `GET /gem/*` | RubyGems protocol |
| `GET /go/*` | Go module proxy protocol |
@ -1135,6 +1324,8 @@ Recently cached:
| `GET /maven/*` | Maven repository protocol |
| `GET /nuget/*` | NuGet V3 API |
| `GET /composer/*` | Composer/Packagist protocol |
| `GET`, `POST /composer/api/security-advisories/` | Composer security advisories, passed through to upstream without private package names |
| `GET /composer/lists/summary.json` | Composer filter list summary, passed through to upstream uncached |
| `GET /conan/*` | Conan C/C++ protocol |
| `GET /chef/*` | Chef Supermarket protocol (universe, cookbook API and downloads) |
| `GET /conda/*` | Conda/Anaconda protocol |
@ -1147,7 +1338,9 @@ Recently cached:
| `GET /v2/homebrew/core/*` | Homebrew core bottle manifests and blobs from GHCR |
| `GET /apk/{repository}/*` | Alpine APK repository protocol |
| `GET /arch/{repository}/*` | Arch Linux pacman repository protocol |
| `GET /freebsd/{repository}/*` | FreeBSD pkg repository protocol |
| `GET /generic/{name}/*` | Generic HTTP download proxy (GitHub release assets, mise/aqua) |
| `GET /git/{name}/{repo}/info/refs`, `POST /git/{name}/{repo}/git-upload-pack` | Git smart HTTP protocol, read-only |
| `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 |
@ -1170,6 +1363,17 @@ Registered when `hosted.enabled` is set. Pushing and deleting require `Authoriza
| `GET /api/hosted/{ecosystem}/{name}` | List the archives pushed for a package |
| `DELETE /api/hosted/{ecosystem}/{name}/{version}` | Delete a pushed version |
### Projects API
Registered when `projects.enabled` is set. Uploading and deleting require `Authorization: Bearer <projects.token>`. See [Project inventory](docs/configuration.md#project-inventory).
| Endpoint | Description |
|----------|-------------|
| `PUT /api/projects/{project}/sbom` | Replace a project's recorded package versions with the ones the SBOM in the body lists |
| `GET /api/projects` | List projects, or with `?purl=pkg:npm/lodash@4.17.20` the projects using a package or version |
| `GET /api/projects/{project}` | A project's recorded package versions |
| `DELETE /api/projects/{project}` | Delete a project |
### Cache API
Registered when `cache_delete` is set. It has no authentication of its own.
@ -1331,9 +1535,7 @@ Response:
{
"cached_artifacts": 142,
"total_size_bytes": 523456789,
"total_size": "499.2 MB",
"storage_url": "file:///path/to/cache/artifacts",
"database_path": "./cache/proxy.db"
"total_size": "499.2 MB"
}
```
@ -1373,6 +1575,7 @@ The proxy serves a web UI under `/ui`. No separate frontend build is needed -- t
- **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.
- **Projects** (`/ui/projects`) -- with the [project inventory](docs/configuration.md#project-inventory) enabled, the package versions each repository's uploaded SBOM lists, marked when outdated. Package pages then show which projects use each version.
## Monitoring
@ -1391,6 +1594,8 @@ The proxy exposes Prometheus metrics at `GET /metrics`. All metric names are pre
| `proxy_metadata_cache_entries` | gauge | `ecosystem` | Number of cached metadata documents per ecosystem. |
| `proxy_artifacts_evicted_total` | counter | `reason`, `ecosystem` | Artifacts evicted by the size limit (`lru`) or by age (`retention`) |
| `proxy_metadata_evicted_total` | counter | `ecosystem` | Cached metadata documents evicted by the size limit |
| `proxy_metadata_expired_by_change_feed_total` | counter | `ecosystem` | Cached metadata documents set to revalidate because an upstream [change feed](docs/configuration.md#change-feeds) listed them |
| `proxy_fetch_lease_waits_total` | counter | `kind` | Cache misses (`artifact` or `metadata`) that waited for another instance sharing the database to fetch them |
| `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 |
@ -1460,7 +1665,7 @@ The `kind` label on `proxy_cache_hits_total` and `proxy_cache_misses_total` keep
`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`, the three `proxy_metadata_cache_*` metrics, `proxy_artifacts_evicted_total`, `proxy_metadata_evicted_total`, `proxy_integrity_failures_total` and the scan metrics. Aliases collapse here: `gem` reads as `rubygems`, `composer` as `packagist`, `go` as `golang`.
**From the package record, normalized.** The six `proxy_ecosystem_*` gauges, `proxy_cache_hits_total`, `proxy_cache_misses_total`, the three `proxy_metadata_cache_*` metrics, `proxy_artifacts_evicted_total`, `proxy_metadata_evicted_total`, `proxy_metadata_expired_by_change_feed_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.
@ -1521,7 +1726,7 @@ Failing checks include an `"error"` field. Storage failures also include a `"ste
`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](#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.
`upstream_holds` lists upstream hosts that answered the proxy with HTTP 429, or 503 with a `Retry-After`, and the time until which the proxy sends them nothing. Every request to a held host, metadata and artifacts alike, fails locally for the duration the upstream asked for (capped at one hour), or for one minute doubling up to fifteen when the upstream gave no `Retry-After`. Cached metadata is served stale during a hold, and anything uncached returns HTTP 503 with the remaining `Retry-After` so a client can pace itself too. Metadata requests do not go through the circuit breaker, so without the hold a rate-limited upstream would keep receiving one request per cache miss, which is exactly the traffic it is asking the proxy to stop sending. The first non-limited response after a hold ends clears it, and `proxy_upstream_rate_limit_holds_total` counts the holds placed. The key is omitted while no host is held.
`upstream_holds` lists upstream hosts that answered the proxy with HTTP 429, or 503 with a `Retry-After`, and the time until which the proxy sends them nothing. Every request to a held host, metadata and artifacts alike, fails locally for the duration the upstream asked for (capped at one hour), or for one minute doubling up to fifteen when the upstream gave no `Retry-After`. Cached metadata is served stale during a hold, and anything uncached returns HTTP 503 with the remaining `Retry-After` so a client can pace itself too. Metadata requests do not go through the circuit breaker, so without the hold a rate-limited upstream would keep receiving one request per cache miss, which is exactly the traffic it is asking the proxy to stop sending. The first non-limited response after a hold ends clears it, and `proxy_upstream_rate_limit_holds_total` counts the holds placed. Holds are recorded in the database, so instances sharing one honour each other's holds and list them here too. The key is omitted while no host is held.
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.
@ -1567,13 +1772,23 @@ sudo systemctl start proxy
### Docker
A Dockerfile is included in the repo. Build and run:
Images for linux/amd64 and linux/arm64 are published to `ghcr.io/git-pkgs/proxy` for each release, tagged with the version and `latest`:
```bash
docker run -p 8080:8080 -v proxy-data:/data ghcr.io/git-pkgs/proxy:latest
```
To build the image from the repo instead:
```bash
docker build -t proxy .
docker run -p 8080:8080 -v proxy-data:/data proxy
```
The image has a health check that requests `/health` on port 8080. It checks the database and storage, so a container reports unhealthy when either fails. If you change the port with `PROXY_LISTEN`, override it with `--health-cmd`.
With `--read-only`, also pass `--tmpfs /tmp`: the proxy writes some uploads to a temporary file before reading them.
With Postgres and S3:
```bash

View file

@ -1,6 +1,8 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"flag"
@ -9,9 +11,12 @@ import (
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"text/tabwriter"
"time"
"github.com/git-pkgs/proxy/internal/handler"
)
const (
@ -19,10 +24,11 @@ const (
hostedTokenEnv = "PROXY_HOSTED_TOKEN"
hostedClientTimeout = 10 * time.Minute
pushArgCount = 4 // ecosystem, name, version, file
listArgCount = 2 // ecosystem, name
deleteArgCount = 3 // ecosystem, name, version
tablePadding = 2
pushArgCount = 4 // ecosystem, name, version, file
listArgCount = 2 // ecosystem, name
deleteArgCount = 3 // ecosystem, name, version
deleteMaxArgCount = 4 // ecosystem, name, version, file for generic files
tablePadding = 2
)
// hostedArtifact mirrors the JSON the /api/hosted endpoints return.
@ -69,15 +75,16 @@ func newHostedCommand(name, usage, summary string, getenv func(string) string) h
return cmd
}
// parse parses flags and checks for exactly want positional arguments.
func (c hostedCommand) parse(args []string, want int) ([]string, error) {
// parse parses flags and checks for between minArgs and maxArgs positional
// arguments.
func (c hostedCommand) parse(args []string, minArgs, maxArgs int) ([]string, error) {
if err := c.flags.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
c.flags.Usage()
}
return nil, fmt.Errorf("%w\nusage: %s", err, c.usage)
}
if c.flags.NArg() != want {
if n := c.flags.NArg(); n < minArgs || n > maxArgs {
return nil, fmt.Errorf("usage: %s", c.usage)
}
return c.flags.Args(), nil
@ -105,7 +112,7 @@ func hostedToken(getenv func(string) string) (string, error) {
func runPush(args []string, getenv func(string) string, stdout io.Writer, client *http.Client) error {
cmd := newHostedCommand("push", "proxy hosted push [-url URL] <ecosystem> <name> <version> <file>",
"Push a package archive to the proxy", getenv)
positional, err := cmd.parse(args, pushArgCount)
positional, err := cmd.parse(args, pushArgCount, pushArgCount)
if err != nil {
return err
}
@ -121,12 +128,32 @@ func runPush(args []string, getenv func(string) string, stdout io.Writer, client
}
defer func() { _ = file.Close() }()
req, err := http.NewRequest(http.MethodPut, cmd.endpoint(ecosystem, name, version), file)
// The proxy checks the upload against this, so a truncated or corrupted
// transfer is refused rather than stored.
digest := sha256.New()
size, err := io.Copy(digest, file)
if err != nil {
return err
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
return err
}
segments := []string{version}
if handler.HostedFileInPath(ecosystem) {
segments = append(segments, filepath.Base(path))
}
endpoint := cmd.endpoint(ecosystem, name, segments...) + "?sha256=" + hex.EncodeToString(digest.Sum(nil))
req, err := http.NewRequest(http.MethodPut, endpoint, file)
if err != nil {
return err
}
req.ContentLength = size
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/octet-stream")
// The proxy refuses an existing file or a bad token before reading the
// body, so the file is only uploaded once it would be accepted.
req.Header.Set("Expect", "100-continue")
var pushed hostedPushResponse
if err := doHosted(client, req, http.StatusCreated, &pushed); err != nil {
@ -139,7 +166,7 @@ func runPush(args []string, getenv func(string) string, stdout io.Writer, client
func runList(args []string, getenv func(string) string, stdout io.Writer, client *http.Client) error {
cmd := newHostedCommand("list", "proxy hosted list [-url URL] <ecosystem> <name>", "List pushed package archives", getenv)
positional, err := cmd.parse(args, listArgCount)
positional, err := cmd.parse(args, listArgCount, listArgCount)
if err != nil {
return err
}
@ -161,16 +188,20 @@ func runList(args []string, getenv func(string) string, stdout io.Writer, client
}
func runDelete(args []string, getenv func(string) string, stdout io.Writer, client *http.Client) error {
cmd := newHostedCommand("delete", "proxy hosted delete [-url URL] <ecosystem> <name> <version>", "Delete a pushed version", getenv)
positional, err := cmd.parse(args, deleteArgCount)
cmd := newHostedCommand("delete", "proxy hosted delete [-url URL] <ecosystem> <name> <version> [file]",
"Delete a pushed version, or one generic file of it", getenv)
positional, err := cmd.parse(args, deleteArgCount, deleteMaxArgCount)
if err != nil {
return err
}
if len(positional) > deleteArgCount && !handler.HostedFileInPath(positional[0]) {
return fmt.Errorf("only generic files can be deleted one at a time\nusage: %s", cmd.usage)
}
token, err := hostedToken(getenv)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodDelete, cmd.endpoint(positional[0], positional[1], positional[2]), nil)
req, err := http.NewRequest(http.MethodDelete, cmd.endpoint(positional[0], positional[1], positional[2:]...), nil)
if err != nil {
return err
}
@ -179,7 +210,11 @@ func runDelete(args []string, getenv func(string) string, stdout io.Writer, clie
if err := doHosted(client, req, http.StatusNoContent, nil); err != nil {
return err
}
_, err = fmt.Fprintf(stdout, "deleted %s %s@%s\n", positional[0], positional[1], positional[2])
deleted := positional[1] + "@" + positional[2]
if len(positional) > deleteArgCount {
deleted += " " + positional[deleteArgCount]
}
_, err = fmt.Fprintf(stdout, "deleted %s %s\n", positional[0], deleted)
return err
}
@ -210,7 +245,7 @@ func doHosted(client *http.Client, req *http.Request, wantStatus int, out any) e
const hostedUsage = "usage: proxy hosted <push|list|delete> [flags] ...\n\n" +
" push Push a package archive to the proxy\n" +
" list List pushed package archives\n" +
" delete Delete a pushed version"
" delete Delete a pushed version, or one generic file of it"
type hostedSubcommand func([]string, func(string) string, io.Writer, *http.Client) error

View file

@ -12,7 +12,8 @@ import (
)
type recordedRequest struct {
method, path, query, authorization, body string
method, path, query, authorization, body, expect string
contentLength int64
}
func hostedTestServer(t *testing.T, status int, response string) (*httptest.Server, *recordedRequest) {
@ -26,6 +27,8 @@ func hostedTestServer(t *testing.T, status int, response string) (*httptest.Serv
query: r.URL.RawQuery,
authorization: r.Header.Get("Authorization"),
body: string(body),
expect: r.Header.Get("Expect"),
contentLength: r.ContentLength,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
@ -62,14 +65,34 @@ func TestRunPush(t *testing.T) {
if recorded.method != http.MethodPut || recorded.path != "/api/hosted/npm/@types/node/20.1.0" {
t.Errorf("request = %s %s", recorded.method, recorded.path)
}
if recorded.authorization != "Bearer tok" || recorded.body != "tarball" || recorded.query != "" {
// sha256 of "tarball", so the proxy can check the upload.
const tarballSHA256 = "db4b4d0d1cb480bf9aeea253771c00febe627f236765fa37d6a5614f079a3aa0"
if recorded.authorization != "Bearer tok" || recorded.body != "tarball" || recorded.query != "sha256="+tarballSHA256 {
t.Errorf("authorization/body/query = %q/%q/%q", recorded.authorization, recorded.body, recorded.query)
}
if recorded.expect != "100-continue" || recorded.contentLength != int64(len("tarball")) {
t.Errorf("expect/content length = %q/%d, want 100-continue and the file size", recorded.expect, recorded.contentLength)
}
if out := stdout.String(); !strings.Contains(out, "node-20.1.0.tgz") || !strings.Contains(out, "abc123") {
t.Errorf("output = %q, want filename and sha256", out)
}
}
func TestRunPushGenericNamesTheFile(t *testing.T) {
srv, recorded := hostedTestServer(t, http.StatusCreated,
`{"ecosystem":"generic","name":"tools","version":"1.2.0","filename":"tool-linux-arm64.tar.gz","sha256":"abc123","size":7}`)
archive := writeArchive(t, "tool-linux-arm64.tar.gz", "tarball")
err := runPush([]string{"-url", srv.URL, "generic", "tools", "1.2.0", archive},
hostedEnv(map[string]string{"PROXY_HOSTED_TOKEN": "tok"}), io.Discard, srv.Client())
if err != nil {
t.Fatalf("runPush: %v", err)
}
if recorded.path != "/api/hosted/generic/tools/1.2.0/tool-linux-arm64.tar.gz" {
t.Errorf("path = %q, want the file name appended", recorded.path)
}
}
func TestRunPushErrors(t *testing.T) {
srv, _ := hostedTestServer(t, http.StatusBadRequest, `{"code":"BAD_REQUEST","message":"pushing packages is not supported for this ecosystem: cargo"}`)
archive := writeArchive(t, "serde-1.0.200.crate", "crate")
@ -141,6 +164,36 @@ func TestRunDelete(t *testing.T) {
}
}
func TestRunDeleteOneFile(t *testing.T) {
srv, recorded := hostedTestServer(t, http.StatusNoContent, "")
var stdout bytes.Buffer
err := runDelete([]string{"-url", srv.URL, "generic", "tools", "1.2.0", "tool-linux-arm64.tar.gz"},
hostedEnv(map[string]string{"PROXY_HOSTED_TOKEN": "tok"}), &stdout, srv.Client())
if err != nil {
t.Fatalf("runDelete: %v", err)
}
if recorded.path != "/api/hosted/generic/tools/1.2.0/tool-linux-arm64.tar.gz" {
t.Errorf("path = %q", recorded.path)
}
if out := stdout.String(); out != "deleted generic tools@1.2.0 tool-linux-arm64.tar.gz\n" {
t.Errorf("output = %q", out)
}
}
func TestRunDeleteOneFileOnlyForGeneric(t *testing.T) {
srv, recorded := hostedTestServer(t, http.StatusNoContent, "")
err := runDelete([]string{"-url", srv.URL, "npm", "@acme", "widget", "1.0.0"},
hostedEnv(map[string]string{"PROXY_HOSTED_TOKEN": "tok"}), io.Discard, srv.Client())
if err == nil || !strings.Contains(err.Error(), "only generic files") {
t.Errorf("npm delete with a fourth argument error = %v, want a refusal", err)
}
if recorded.method != "" {
t.Errorf("refused delete sent %s %s", recorded.method, recorded.path)
}
}
func TestRunHostedDispatchesSubcommands(t *testing.T) {
srv, recorded := hostedTestServer(t, http.StatusOK, `{"ecosystem":"npm","name":"request","artifacts":[]}`)

View file

@ -8,7 +8,7 @@
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description "Bearer " followed by hosted.token
// @description "Bearer " followed by hosted.token, or projects.token for /api/projects
//
// The proxy caches package artifacts from upstream registries (npm, cargo, etc.)
// providing faster, more reliable access for development teams.
@ -93,6 +93,9 @@
// PROXY_HOSTED_MAX_UPLOAD_SIZE - Max size of one pushed archive (default "100MB")
// PROXY_HOSTED_NPM_SCOPES - npm scopes served from pushed packages only
// PROXY_HOSTED_COMPOSER_VENDORS - Composer vendors served from pushed packages only
// PROXY_PROJECTS_ENABLED - Enable the project inventory (/api/projects, /ui/projects)
// PROXY_PROJECTS_TOKEN - Bearer token for uploading and deleting project SBOMs
// PROXY_PROJECTS_MAX_AGE - Remove projects not uploaded for this long (e.g. "90d")
// PROXY_URL - Proxy URL for proxy hosted
//
// Example:
@ -124,7 +127,8 @@ import (
"github.com/git-pkgs/proxy/internal/config"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/proxy/internal/denylist"
"github.com/git-pkgs/proxy/internal/handler"
"github.com/git-pkgs/proxy/internal/memlimit"
"github.com/git-pkgs/proxy/internal/mirror"
"github.com/git-pkgs/proxy/internal/server"
"github.com/git-pkgs/proxy/internal/storage"
@ -238,6 +242,7 @@ func runServe() {
fmt.Fprintf(os.Stderr, " PROXY_UPSTREAM_CARGO_DOWNLOAD Cargo download upstream URL\n")
fmt.Fprintf(os.Stderr, " PROXY_UPSTREAM_GEM RubyGems upstream URL\n")
fmt.Fprintf(os.Stderr, " PROXY_UPSTREAM_GO Go module proxy upstream URL\n")
fmt.Fprintf(os.Stderr, " PROXY_UPSTREAM_GO_VULNDB Go vulnerability database URL\n")
fmt.Fprintf(os.Stderr, " PROXY_UPSTREAM_HEX Hex repository upstream URL\n")
fmt.Fprintf(os.Stderr, " PROXY_UPSTREAM_HEX_API Hex API upstream URL\n")
fmt.Fprintf(os.Stderr, " PROXY_UPSTREAM_PUB pub registry upstream URL\n")
@ -323,6 +328,7 @@ func runServe() {
// Setup logger
logger := setupLogger(cfg.Log.Level, cfg.Log.Format)
memlimit.Apply(logger)
// Create and start server
srv, err := server.New(cfg, logger, server.BuildInfo{
@ -438,6 +444,7 @@ func runMirror() {
concurrency := fs.Int("concurrency", 4, "Number of parallel downloads") //nolint:mnd // default concurrency
dryRun := fs.Bool("dry-run", false, "Show what would be mirrored without downloading")
platforms := fs.String("platform", "", "Comma-separated os/arch[/variant] list limiting which platforms of container images are mirrored")
latest := fs.Bool("latest", false, "Mirror only the newest version of PURLs without a version")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "git-pkgs proxy - Pre-populate cache\n\n")
@ -446,6 +453,7 @@ func runMirror() {
fmt.Fprintf(os.Stderr, " proxy mirror pkg:npm/lodash@4.17.21\n")
fmt.Fprintf(os.Stderr, " proxy mirror --sbom sbom.cdx.json\n")
fmt.Fprintf(os.Stderr, " proxy mirror pkg:npm/lodash # all versions\n")
fmt.Fprintf(os.Stderr, " proxy mirror --latest pkg:npm/lodash # newest version\n")
fmt.Fprintf(os.Stderr, " proxy mirror --platform linux/amd64 pkg:docker/library/alpine@3.20\n\n")
fmt.Fprintf(os.Stderr, "Flags:\n")
fs.PrintDefaults()
@ -471,9 +479,9 @@ func runMirror() {
fmt.Fprintf(os.Stderr, "error reading SBOM %s: %v\n", *sbomPath, err)
os.Exit(1)
}
source = &mirror.SBOMSource{Data: data, Name: *sbomPath, Platforms: platformList}
source = &mirror.SBOMSource{Data: data, Name: *sbomPath, Latest: *latest, Platforms: platformList}
case len(purls) > 0:
source = &mirror.PURLSource{PURLs: purls, Platforms: platformList}
source = &mirror.PURLSource{PURLs: purls, Latest: *latest, Platforms: platformList}
default:
fmt.Fprintf(os.Stderr, "error: provide PURLs or --sbom\n")
fs.Usage()
@ -487,6 +495,7 @@ func runMirror() {
os.Exit(1)
}
cfg.LoadFromEnv()
printEnvWarnings(cfg)
if *storageURL != "" {
cfg.Storage.URL = *storageURL
@ -546,20 +555,24 @@ func runMirror() {
// Build proxy with the same upstream clients and authentication as serve
proxy, images := server.NewMirrorProxy(cfg, db, store, logger)
proxy.Denylist, err = denylist.New(cfg.Denylist.Packages)
if err != nil {
_ = db.Close()
fmt.Fprintf(os.Stderr, "invalid denylist: %v\n", err)
os.Exit(1) //nolint:gocritic // db closed above
}
proxy.CacheMetadata = true // mirror always caches metadata
proxy.MetadataTTL = cfg.ParseMetadataTTL()
proxy.MetadataMaxSize = cfg.ParseMetadataMaxSize()
proxy.MetadataStreamMaxSize = cfg.ParseMetadataStreamMaxSize()
// As serve does, resolve Swift source repositories in the denylist through
// the upstream, and refuse to run when one can't be.
if err := server.ConfigureDenylist(cfg, proxy); err != nil {
_ = db.Close()
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1) //nolint:gocritic // db closed above
}
m := mirror.New(proxy, db, store, logger, *concurrency)
m.Images = images
if cfg.Scanning.Enabled {
refuseUnscannedMirroring(m)
m := newCommandMirror(cfg, proxy, images, db, store, logger, *concurrency)
switch s := source.(type) {
case *mirror.PURLSource:
s.Versions = m.Versions
case *mirror.SBOMSource:
s.Versions = m.Versions
}
ctx, cancel := context.WithCancel(context.Background())
@ -583,7 +596,9 @@ func runMirror() {
return
}
closeGit := useCommandGit(cfg, proxy, m, logger)
progress, err := m.Run(ctx, source)
closeGit()
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
@ -607,6 +622,42 @@ func runMirror() {
}
}
// newCommandMirror builds the mirror proxy mirror runs, which fetches and
// lists versions from the configured upstreams.
func newCommandMirror(cfg *config.Config, proxy *handler.Proxy, images *handler.ContainerHandler,
db *database.DB, store storage.Storage, logger *slog.Logger, workers int) *mirror.Mirror {
m := mirror.New(proxy, db, store, logger, workers)
m.Images = images
m.Bottles = handler.NewHomebrewHandler(proxy, cfg.Upstream.HomebrewAPI)
m.Charts = handler.NewHelmHandler(proxy, cfg.BaseURL, cfg.Upstream.Helm)
server.UseMirrorUpstreams(m, cfg.Upstream, proxy.HTTPClient)
if cfg.Scanning.Enabled {
refuseUnscannedMirroring(m)
}
return m
}
// useCommandGit opens the git cache for the pkg:git items of proxy mirror,
// when upstream.git is configured, and returns the function that closes it.
// Where it can't be opened, such as while the running server holds
// git.cache_dir, those items fail with the reason and the rest are mirrored.
func useCommandGit(cfg *config.Config, proxy *handler.Proxy, m *mirror.Mirror, logger *slog.Logger) func() {
git, closeGit, err := server.NewMirrorGit(cfg, proxy, logger)
if err != nil {
m.Git = mirror.UnavailableGit{Reason: fmt.Sprintf("this command can't open the git cache (%v); while the server runs, use its mirror API", err)}
return func() {}
}
if git == nil {
return func() {}
}
m.Git = git
return func() {
if err := closeGit(); err != nil {
logger.Warn("closing the git cache", "error", err)
}
}
}
// mirrorExitCode returns the exit status for a finished mirror run: 1 when any
// package version failed to mirror, 0 otherwise.
func mirrorExitCode(progress *mirror.Progress) int {
@ -775,6 +826,14 @@ func formatSize(bytes int64) string {
}
}
// printEnvWarnings reports environment values LoadFromEnv ignored. serve
// logs them from server.Start; mirror never gets there.
func printEnvWarnings(cfg *config.Config) {
for _, warning := range cfg.EnvWarnings() {
fmt.Fprintf(os.Stderr, "warning: %s\n", warning)
}
}
func loadConfig(path string) (*config.Config, error) {
if path != "" {
return config.Load(path)

View file

@ -57,3 +57,24 @@ func TestServeHelpProcess(*testing.T) {
os.Args = []string{"proxy", "serve", "-help"}
main()
}
func TestMirrorLogsEnvWarnings(t *testing.T) {
cmd := exec.Command(os.Args[0], "-test.run=^TestMirrorEnvWarningsProcess$")
// The negative size stops mirror before it opens any storage.
cmd.Env = append(os.Environ(),
"PROXY_TEST_MIRROR_ENV_WARNINGS=1",
"PROXY_CACHE_METADATA=maybe",
"PROXY_STORAGE_MAX_SIZE=-1")
output, _ := cmd.CombinedOutput()
if !strings.Contains(string(output), "PROXY_CACHE_METADATA") {
t.Errorf("mirror did not warn about PROXY_CACHE_METADATA:\n%s", output)
}
}
func TestMirrorEnvWarningsProcess(*testing.T) {
if os.Getenv("PROXY_TEST_MIRROR_ENV_WARNINGS") != "1" {
return
}
os.Args = []string{"proxy", "mirror", "pkg:npm/lodash@4.17.21"}
main()
}

View file

@ -0,0 +1,132 @@
package main
import (
"net/http"
"net/http/cgi"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/git-pkgs/proxy/internal/gitcache"
)
// gitMirrorUpstream serves team/repo, with one commit on main, from git
// http-backend, and returns the server and the commit.
func gitMirrorUpstream(t *testing.T) (*httptest.Server, string) {
t.Helper()
git := func(dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = []string{
"PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), "GIT_CONFIG_NOSYSTEM=1",
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com",
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com",
}
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
return strings.TrimSpace(string(out))
}
backend := ""
for _, name := range []string{"git-http-backend", "git-http-backend.exe"} {
if path := filepath.Join(git("", "--exec-path"), name); fileExistsForTest(path) {
backend = path
}
}
if backend == "" {
t.Skip("git http-backend is not installed")
}
root := t.TempDir()
bare := filepath.Join(root, "team", "repo.git")
git("", "init", "--quiet", "--bare", "--initial-branch=main", bare)
work := t.TempDir()
git("", "clone", "--quiet", bare, work)
git(work, "commit", "--quiet", "--allow-empty", "-m", "first")
git(work, "push", "--quiet", "origin", "HEAD:main")
head := git(work, "rev-parse", "HEAD")
server := httptest.NewServer(&cgi.Handler{
Path: backend,
Env: []string{"PATH=" + os.Getenv("PATH"), "GIT_PROJECT_ROOT=" + root, "GIT_HTTP_EXPORT_ALL=1"},
})
t.Cleanup(server.Close)
return server, head
}
func fileExistsForTest(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// writeGitMirrorConfig writes a config with upstream.git "up" at
// upstreamURL and the git cache, storage and database under dir.
func writeGitMirrorConfig(t *testing.T, dir, upstreamURL string) string {
t.Helper()
cfg := "storage:\n" +
" url: file://" + filepath.Join(dir, "artifacts") + "\n" +
"database:\n" +
" path: " + filepath.Join(dir, "proxy.db") + "\n" +
"upstream:\n" +
" allow_loopback: true\n" +
" git:\n" +
" up: " + upstreamURL + "\n" +
"git:\n" +
" cache_dir: " + filepath.Join(dir, "git") + "\n"
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte(cfg), 0o600); err != nil {
t.Fatalf("writing config: %v", err)
}
return path
}
func TestMirrorCommandCachesGitCommit(t *testing.T) {
upstream, head := gitMirrorUpstream(t)
dir := t.TempDir()
configPath := writeGitMirrorConfig(t, dir, upstream.URL)
purl := "pkg:git/" + strings.TrimPrefix(upstream.URL, "http://") + "/team/repo@" + head
output := runMirrorProcess(t, 0, "-config", configPath, purl)
if !strings.Contains(output, "1 downloaded") {
t.Errorf("output:\n%s", output)
}
cached, _ := filepath.Glob(filepath.Join(dir, "git", "up-*", "team", "repo.git"))
if len(cached) != 1 {
t.Errorf("cached repositories %v, want team/repo.git", cached)
}
output = runMirrorProcess(t, 0, "-config", configPath, purl)
if !strings.Contains(output, "1 skipped") {
t.Errorf("second run output:\n%s", output)
}
}
// TestMirrorCommandGitCacheInUse checks pkg:git items fail, pointing at the
// mirror API, while the running server holds git.cache_dir.
func TestMirrorCommandGitCacheInUse(t *testing.T) {
upstream, head := gitMirrorUpstream(t)
dir := t.TempDir()
configPath := writeGitMirrorConfig(t, dir, upstream.URL)
cache, err := gitcache.New(gitcache.Options{
CacheDir: filepath.Join(dir, "git"),
RefreshWait: time.Second,
Client: &http.Client{},
})
if err != nil {
t.Fatal(err)
}
defer func() { _ = cache.Close() }()
purl := "pkg:git/" + strings.TrimPrefix(upstream.URL, "http://") + "/team/repo@" + head
output := runMirrorProcess(t, 1, "-config", configPath, purl)
for _, want := range []string{"in use by another process", "mirror API", "1 failed"} {
if !strings.Contains(output, want) {
t.Errorf("output missing %q:\n%s", want, output)
}
}
}

View file

@ -0,0 +1,186 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"github.com/git-pkgs/proxy/internal/database"
)
const swiftWidgetArchive = "acme widget archive"
// swiftMirrorUpstream serves acme.widget 1.0.0 the way a Swift package
// registry does, with https://github.com/acme/widget as its source
// repository, and counts archive downloads and identifier lookups. The
// repository's identifiers are ids, or only acme.widget.
func swiftMirrorUpstream(t *testing.T, ids ...string) (srv *httptest.Server, downloads, lookups *atomic.Int64) {
t.Helper()
if len(ids) == 0 {
ids = []string{"acme.widget"}
}
identifiers, err := json.Marshal(map[string][]string{"identifiers": ids})
if err != nil {
t.Fatal(err)
}
downloads, lookups = new(atomic.Int64), new(atomic.Int64)
sum := sha256.Sum256([]byte(swiftWidgetArchive))
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Version", "1")
switch r.URL.Path {
case "/identifiers":
lookups.Add(1)
if r.URL.Query().Get("url") != "https://github.com/acme/widget" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(identifiers)
case "/acme/widget":
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"releases":{"1.0.0":{}}}`)
case "/acme/widget/1.0.0/Package.swift":
w.Header().Set("Content-Type", "text/x-swift")
_, _ = io.WriteString(w, "// swift-tools-version:5.9\n")
case "/acme/widget/1.0.0":
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"id":"acme.widget","version":"1.0.0","resources":[{"name":"source-archive","type":"application/zip","checksum":%q}]}`, hex.EncodeToString(sum[:]))
case "/acme/widget/1.0.0.zip":
downloads.Add(1)
w.Header().Set("Content-Type", "application/zip")
_, _ = io.WriteString(w, swiftWidgetArchive)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
return srv, downloads, lookups
}
// writeSwiftMirrorConfig writes a config using upstream as the Swift
// registry, denying the denied package PURLs.
func writeSwiftMirrorConfig(t *testing.T, dir, upstream string, denied ...string) string {
t.Helper()
cfg := "storage:\n" +
" url: file://" + filepath.Join(dir, "artifacts") + "\n" +
"database:\n" +
" path: " + filepath.Join(dir, "proxy.db") + "\n" +
"upstream:\n" +
" swift: " + upstream + "\n" +
" allow_loopback: true\n"
if len(denied) > 0 {
cfg += "denylist:\n packages:\n"
for _, p := range denied {
cfg += " - " + p + "\n"
}
}
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte(cfg), 0o600); err != nil {
t.Fatalf("writing config: %v", err)
}
return path
}
func cachedArtifacts(t *testing.T, dir string) int64 {
t.Helper()
db, err := database.Open(filepath.Join(dir, "proxy.db"))
if err != nil {
t.Fatalf("opening database: %v", err)
}
defer func() { _ = db.Close() }()
stats, err := db.GetCacheStats()
if err != nil {
t.Fatalf("GetCacheStats: %v", err)
}
return stats.TotalArtifacts
}
// Without a denylist the fake registry mirrors the release, so the tests
// below fail for the denylist and not for the setup.
func TestMirrorSwiftRelease(t *testing.T) {
upstream, downloads, _ := swiftMirrorUpstream(t)
dir := t.TempDir()
configPath := writeSwiftMirrorConfig(t, dir, upstream.URL)
output := runMirrorProcess(t, 0, "-config", configPath, "pkg:swift/acme/widget@1.0.0")
if !strings.Contains(output, "1 downloaded") {
t.Errorf("output:\n%s", output)
}
if n := downloads.Load(); n != 1 {
t.Errorf("archive downloads = %d, want 1", n)
}
if n := cachedArtifacts(t, dir); n != 1 {
t.Errorf("cached artifacts = %d, want 1", n)
}
}
// A denylist entry naming the package by its source repository blocks the
// mirror download of the registry identity it resolves to.
func TestMirrorDenylistSwiftSourceEntry(t *testing.T) {
upstream, downloads, _ := swiftMirrorUpstream(t)
dir := t.TempDir()
configPath := writeSwiftMirrorConfig(t, dir, upstream.URL, "pkg:swift/github.com/acme/widget@1.0.0")
output := runMirrorProcess(t, 1, "-config", configPath, "pkg:swift/acme/widget@1.0.0")
if !strings.Contains(output, "0 downloaded") || !strings.Contains(output, "1 failed") {
t.Errorf("output:\n%s", output)
}
if n := downloads.Load(); n != 0 {
t.Errorf("archive downloads = %d, want 0", n)
}
if n := cachedArtifacts(t, dir); n != 0 {
t.Errorf("cached artifacts = %d, want 0", n)
}
}
// The registry may list several identities for one repository. A source
// entry denies all of them, not only the first.
func TestMirrorDenylistSwiftSourceEntryEveryIdentity(t *testing.T) {
upstream, downloads, _ := swiftMirrorUpstream(t, "other.widget", "acme.widget")
dir := t.TempDir()
configPath := writeSwiftMirrorConfig(t, dir, upstream.URL, "pkg:swift/github.com/acme/widget@1.0.0")
output := runMirrorProcess(t, 1, "-config", configPath, "pkg:swift/acme/widget@1.0.0")
if !strings.Contains(output, "0 downloaded") || !strings.Contains(output, "1 failed") {
t.Errorf("output:\n%s", output)
}
if n := downloads.Load(); n != 0 {
t.Errorf("archive downloads = %d, want 0", n)
}
}
// A source repository entry the registry can't resolve stops the mirror
// before it downloads anything, as it stops serve: left out, it would let
// the version through.
func TestMirrorDenylistUnresolvedSwiftSource(t *testing.T) {
upstream, downloads, lookups := swiftMirrorUpstream(t)
dir := t.TempDir()
configPath := writeSwiftMirrorConfig(t, dir, upstream.URL, "pkg:swift/github.com/acme/unknown@1.0.0")
output := runMirrorProcess(t, 1, "-config", configPath, "pkg:swift/acme/widget@1.0.0")
if !strings.Contains(output, "configuring denylist") || !strings.Contains(output, "github.com/acme/unknown") {
t.Errorf("output does not report the unresolved entry:\n%s", output)
}
if strings.Contains(output, "Mirror complete") {
t.Errorf("mirror ran:\n%s", output)
}
if lookups.Load() == 0 {
t.Error("the entry was not looked up")
}
if n := downloads.Load(); n != 0 {
t.Errorf("archive downloads = %d, want 0", n)
}
}

View file

@ -113,6 +113,22 @@ func TestMirrorCommandDryRunWhileScanning(t *testing.T) {
}
}
func TestMirrorCommandSBOMComponentVersion(t *testing.T) {
dir := t.TempDir()
configPath := writeScanningMirrorConfig(t, dir)
sbomPath := filepath.Join(dir, "bom.json")
data := []byte(`{"components":[{"name":"app","version":"1.0","purl":"pkg:docker/ghcr.io/team/app"}]}`)
if err := os.WriteFile(sbomPath, data, 0600); err != nil {
t.Fatal(err)
}
output := runMirrorProcess(t, 0, "-config", configPath, "-dry-run", "-sbom", sbomPath)
for _, want := range []string{"Would mirror 1 package versions", "pkg:docker/ghcr.io/team/app@1.0"} {
if !strings.Contains(output, want) {
t.Errorf("dry run missing %q:\n%s", want, output)
}
}
}
func TestRefuseUnscannedMirroring(t *testing.T) {
m := mirror.New(nil, nil, nil, nil, 1)
refuseUnscannedMirroring(m)

View file

@ -120,8 +120,8 @@ storage:
# Store fetched artifacts. Set to false to stream every download from
# upstream without storing it; metadata filtering, cooldown and the
# denylist still apply. Useful when another cache sits in front of the
# proxy. false is incompatible with direct_serve, scanning, mirror_api and
# mirror.schedules.
# proxy. false is incompatible with direct_serve, scanning, mirror_api,
# mirror.schedules and mirror.refresh.
cache_artifacts: true
# Redirect cached artifact downloads to presigned storage URLs (HTTP 302)
@ -189,6 +189,10 @@ upstream:
# npm_routes:
# "@example/*": "https://npm.example.com/registry"
# Follow the npm registry's change feed, so a new release is fetched on the
# next request instead of after metadata_ttl. Requires cache_metadata.
# npm_change_feed: true
# Cargo sparse index URL
cargo: "https://index.crates.io"
@ -219,6 +223,12 @@ upstream:
# Maven repository URL (used by /maven endpoint)
maven: "https://repo1.maven.org/maven2"
# Additional Maven repositories, served at /maven/{name}/. A name must not
# be the start of a group ID (such as "org" or "tools"), since it would hide
# those groups in the default repository.
# maven_repositories:
# grails: "https://repo.grails.org/grails/restricted"
# Gradle Plugin Portal Maven URL (fallback for plugin marker artifacts)
gradle_plugin_portal: "https://plugins.gradle.org/m2"
@ -239,6 +249,11 @@ upstream:
# composer_routes:
# "example/*": "https://composer.example.com/packages"
# Follow the change feed of the Packagist at composer, so a new release is
# fetched on the next request instead of after metadata_ttl. Requires
# cache_metadata.
# composer_change_feed: true
# Conan registry URL
conan: "https://center.conan.io"
@ -309,6 +324,14 @@ upstream:
# archlinux: "https://geo.mirror.pkgbuild.com"
# archlinuxarm: "https://<region>.mirror.archlinuxarm.org"
# Named FreeBSD pkg mirrors (used by /freebsd/{name}/), for ports and
# pkgbase repositories alike.
# Defaults to {"freebsd": "https://pkg.freebsd.org"} when empty;
# configuring any entry replaces that default.
# freebsd:
# freebsd: "https://pkg.freebsd.org"
# poudriere: "https://packages.example.com"
# Named generic HTTP upstreams (used by /generic/{name}/). The remaining
# request path and query are appended to the upstream URL. GitHub release
# assets ({owner}/{repo}/releases/download/{tag}/{asset}) are cached
@ -318,6 +341,18 @@ upstream:
# github-api: "https://api.github.com"
# apache: "https://archive.apache.org"
# Paths a generic upstream may serve, as path.Match patterns for the path
# after /generic/{name}/; a pattern ending in /** also matches everything
# nested below its prefix. Other paths answer 404. Required for an upstream
# that upstream.auth has credentials for, such as a GitHub token for
# api.github.com; use a fine-grained token with read-only access to public
# repositories.
# generic_paths:
# github-api:
# - "repos/*/*/releases"
# - "repos/*/*/releases/**"
# - "repos/*/*/tags"
# Version-pinned paths of generic upstreams, cached immutably like GitHub
# release assets. Each pattern must match the whole path after
# /generic/{name}/ and have `version` and `file` named groups; an optional
@ -326,6 +361,15 @@ upstream:
# apache:
# - 'dist/(?P<name>maven)/maven-3/(?P<version>[^/]+)/binaries/(?P<file>[^/]+)'
# Named Git servers (used by /git/{name}/{repo}), served read-only from
# cached bare repositories. repos limits which repositories may be served
# and is required when upstream.auth has credentials for the server.
# git:
# github: "https://github.com"
# wikimedia:
# url: "https://gerrit.wikimedia.org/r"
# repos: ["mediawiki/**"]
# Authentication for upstream registries
# Keys are absolute URL scopes. Scheme, host, effective port, and path
# segment boundaries must match; the longest matching scope wins.
@ -366,6 +410,14 @@ upstream:
# "https://123456789012.dkr.ecr.eu-west-1.amazonaws.com":
# type: ecr
# Git repository cache, used when upstream.git is set
# git:
# cache_dir: ./cache/git # local disk, used by this process only
# max_size: "" # evict least recently used repositories past this
# max_repo_size: 5GB # refuse a repository that grows past this
# refresh_wait: 5s # then serve the cached copy if the upstream check is slow
# stale_max_age: 24h # never serve a copy not checked for this long
# Gradle HttpBuildCache configuration
gradle:
build_cache:
@ -399,6 +451,15 @@ gradle:
# npm_scopes: ["@acme"]
# composer_vendors: ["acme"]
# Project inventory (disabled by default). Repositories upload an SBOM to
# PUT /api/projects/{project}/sbom, and the UI shows which project uses
# which package versions. Project names are visible on the unauthenticated UI.
# projects:
# enabled: true
# token: "${PROXY_PROJECTS_TOKEN}"
# # Drop projects that have not uploaded for this long ("" keeps them)
# max_age: "90d"
# Re-mirror container image tags on a schedule, so a moved tag is picked up
# even when nobody pulls it. An unchanged tag costs one HEAD request.
# mirror:
@ -406,6 +467,16 @@ gradle:
# - images: ["pkg:docker/library/alpine@3.20", "pkg:docker/library/nginx@stable"]
# interval: "6h"
# platforms: ["linux/amd64"]
#
# Mirror new releases of the most used packages in the background, so the
# first install after a release doesn't wait on the upstream. Cooldown and
# the denylist apply.
# refresh:
# interval: "24h"
# top: 50 # packages per ecosystem, by recent downloads
# since: "14d"
# max_versions: 200 # per run; default no cap
# max_bytes: "2GB" # per run; default no cap
# Health endpoint configuration.
health:
@ -421,6 +492,13 @@ health:
# to spot malicious releases before they're pulled into projects.
# Supported durations: "7d" (days), "48h" (hours), "30m" (minutes), "0" (disabled)
cooldown:
# Independent policies selected through /cooldown/{name}/{registry}/.
# profiles:
# 3d:
# default: "3d"
# 7d:
# default: "7d"
# Global default cooldown for all ecosystems
# default: "3d"

View file

@ -33,6 +33,10 @@ spec:
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.extraInitContainers }}
initContainers:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: {{ .Chart.Name }}
image: {{ include "proxy.image" . | quote }}
@ -70,6 +74,11 @@ spec:
readOnly: true
- name: data
mountPath: {{ .Values.persistence.mountPath }}
- name: tmp
mountPath: /tmp
{{- with .Values.extraVolumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
volumes:
- name: config
configMap:
@ -84,6 +93,16 @@ spec:
{{- else }}
emptyDir: {}
{{- end }}
- name: tmp
emptyDir:
{{- with .Values.tmpDir.sizeLimit }}
sizeLimit: {{ . }}
{{- else }}
{}
{{- end }}
{{- with .Values.extraVolumes }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}

View file

@ -1,6 +1,8 @@
# More than one replica requires config.data.database on Postgres,
# config.data.storage on object storage, deploymentStrategy: RollingUpdate,
# and a PVC access mode other than ReadWriteOnce (or persistence disabled).
# More than one replica requires config.data.database on Postgres and
# deploymentStrategy: RollingUpdate. Storage can be object storage (with
# persistence disabled) or a file:// URL on a ReadWriteMany volume
# (persistence.accessModes: [ReadWriteMany]). With upstream.git, each
# replica also needs its own git.cache_dir.
replicaCount: 1
nameOverride: ""
@ -54,6 +56,35 @@ extraEnvFrom: []
# - secretRef:
# name: proxy-object-storage
# Additional volumes for the pod and mounts for the proxy container, for
# example a ConfigMap holding a private CA certificate. Go adds certificates
# from the directories in SSL_CERT_DIR to its trusted roots (set it with
# extraEnv); SSL_CERT_FILE replaces the system bundle instead. All upstream
# TLS, including Git fetches, goes through the proxy process.
extraVolumes: []
# - name: corp-ca
# configMap:
# name: corp-ca
extraVolumeMounts: []
# - name: corp-ca
# mountPath: /etc/proxy/ca
# readOnly: true
# Init containers run before the proxy starts. They can use extraVolumes, but
# extraVolumeMounts applies only to the proxy container, so each init container
# needs its own volumeMounts.
extraInitContainers: []
# - name: wait-for-db
# image: busybox:1.37
# command: ["sh", "-c", "until nc -z postgres 5432; do sleep 2; done"]
# With SQLite, the database file needs a local disk or block-backed volume,
# even with one replica. ReadWriteOnce doesn't guarantee that: pick a
# storageClass that isn't NFS or another network filesystem. If only network
# filesystem storage is available, set config.data.database to Postgres.
# Artifact storage can stay on NFS on a separate volume (extraVolumes and
# extraVolumeMounts, with config.data.storage.url pointing at it).
persistence:
enabled: true
# Keep this aligned with config.data.storage.url and config.data.database.path
@ -101,6 +132,10 @@ containerSecurityContext:
drop:
- ALL
# /tmp is an emptyDir, since the root filesystem is read-only.
tmpDir:
sizeLimit: ""
resources: {}
# requests:
# cpu: 100m

Some files were not shown because too many files have changed in this diff Show more